diff --git a/.env.example b/.env.example index 6c9c175..f380ad7 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ OLLAMA_MODEL=qwen3.5:9b OLLAMA_NUM_CTX=64512 UEX_BASE_URL=https://api.uexcorp.space/2.0 SCMDB_BASE_URL=https://scmdb.net +CORNERSTONE_BASE_URL=https://finder.cstone.space UEX_SECRET_KEY= UEX_BEARER_TOKEN= TRADERAI_USER_NAME= diff --git a/README.md b/README.md index a2a88b3..50f5a00 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Local Ollama-powered chat for UEX marketplace workflows. - Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. - Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo. +- Searches Cornerstone Universal Item Finder data so the assistant can find where in-game items are sold, including store/location, base price, and verified date. - Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set. - Drafts negotiation messages and marketplace listings as pending actions. - Requires browser approval before sending authenticated write requests to UEX. @@ -25,6 +26,7 @@ Local Ollama-powered chat for UEX marketplace workflows. 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. `SCMDB_BASE_URL` defaults to `https://scmdb.net`. + `CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`. 4. Install and run: ```powershell @@ -74,6 +76,7 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d - UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 - SCMDB mission data: https://scmdb.net/ +- Cornerstone Universal Item Finder: https://finder.cstone.space/ - UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1 - UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1 - Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling diff --git a/tests/test_tools.py b/tests/test_tools.py index f2fa503..e3972dc 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -2,6 +2,7 @@ import pytest import respx from httpx import Response +from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page from traderai.tools import ToolRegistry from traderai.uex_client import UEXClient @@ -213,6 +214,39 @@ class FakeSCMDB: } +class FakeCornerstone: + base_url = "https://finder.cstone.test" + + async def list_items(self): + return [ + {"id": "item-abrade", "name": "Abrade Scraper Module", "sold": True}, + {"id": "item-cinch", "name": "Cinch Scraper Module", "sold": True}, + {"id": "item-poster", "name": "Zeus 2955 Ship Showdown Poster", "sold": False}, + ] + + async def get_item_page(self, item_id): + assert item_id == "item-abrade" + return { + "url": f"{self.base_url}/ShipSalvageMods1/{item_id}", + "html": """ + + Star Citizen - Salvage modifier - Abrade Scraper Module + + + + +
NAMEAbrade Scraper Module
MANUFACTURERGreycat Industrial
+ + + + +
LOCATIONBASE PRICEVERIFIED
Stanton - ArcCorp - Area18 - Dumper's Depot21 2502956-01-29
Stanton - microTech - Port Tressler - Platinum Bay21 2502956-01-04
+ + + """, + } + + @pytest.mark.asyncio async def test_search_marketplace_listings_filters_locally(): registry = ToolRegistry(FakeUEX()) @@ -338,6 +372,15 @@ def test_schemas_expose_scmdb_mission_tools(): assert "get_scmdb_mission_rewards" in names +def test_schemas_expose_cornerstone_item_tools(): + registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) + + names = {schema["function"]["name"] for schema in registry.schemas} + + assert "search_cornerstone_items" in names + assert "get_cornerstone_item_locations" in names + + @pytest.mark.asyncio async def test_search_scmdb_missions_returns_reward_summary(): registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) @@ -368,6 +411,64 @@ async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"] +@pytest.mark.asyncio +async def test_search_cornerstone_items_filters_sold_items(): + registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) + + result = await registry.search_cornerstone_items(query="scraper", sold_only=True) + + assert result["matched"] == 2 + assert {item["name"] for item in result["items"]} == {"Abrade Scraper Module", "Cinch Scraper Module"} + assert result["items"][0]["url"].startswith("https://finder.cstone.test/Search/item-") + + +@pytest.mark.asyncio +async def test_get_cornerstone_item_locations_parses_store_prices(): + registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) + + result = await registry.get_cornerstone_item_locations(query="abrade", location="Area18") + + assert result["item"]["name"] == "Abrade Scraper Module" + assert result["item"]["general"]["manufacturer"] == "Greycat Industrial" + assert result["matched_locations"] == 1 + assert result["locations"] == [ + { + "location": "Stanton - ArcCorp - Area18 - Dumper's Depot", + "base_price": 21250, + "base_price_display": "21 250", + "verified": "2956-01-29", + } + ] + + +def test_parse_cornerstone_item_page_extracts_locations(): + parsed = parse_cornerstone_item_page( + """ + Star Citizen - Food - Whamburger +
NAMEWhamburger
+ +
LOCATIONBASE PRICEVERIFIED
Stanton - Area18 - Cubby Blast92956-01-01
+ """ + ) + + assert parsed["name"] == "Whamburger" + assert parsed["locations"][0]["base_price"] == 9 + + +@pytest.mark.asyncio +@respx.mock +async def test_cornerstone_client_accepts_json_encoded_string_payload(): + respx.get("https://finder.cstone.space/GetSearch").mock( + return_value=Response( + 200, + json='[{"id":"item-1","name":"Abrade Scraper Module","Sold":1}]', + ) + ) + client = CornerstoneClient("https://finder.cstone.space") + + assert await client.list_items() == [{"id": "item-1", "name": "Abrade Scraper Module", "sold": True}] + + @pytest.mark.asyncio async def test_search_uex_api_index_finds_history_tools(): registry = ToolRegistry(FakeUEX()) diff --git a/traderai/agent.py b/traderai/agent.py index b886428..9b0c21e 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -18,6 +18,7 @@ UEX credentials are configured server-side when available. Never ask the user to Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact. When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints. Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version. +Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data. Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales. Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds. @@ -518,6 +519,8 @@ class OllamaAgent: "list_scmdb_versions": "Checking SCMDB versions", "search_scmdb_missions": "Searching SCMDB missions", "get_scmdb_mission_rewards": "Fetching SCMDB mission rewards", + "search_cornerstone_items": "Searching Cornerstone items", + "get_cornerstone_item_locations": "Fetching Cornerstone item locations", "uex_api_catalog": "Checking UEX API catalog", "uex_get": "Fetching UEX data", "uex_draft_post": "Drafting UEX write for approval", diff --git a/traderai/config.py b/traderai/config.py index 9b0e259..beebca1 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -16,6 +16,7 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = { "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, "scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False}, + "cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, @@ -66,6 +67,7 @@ class Settings(BaseSettings): ollama_num_ctx: int = 64512 uex_base_url: str = "https://api.uexcorp.space/2.0" scmdb_base_url: str = "https://scmdb.net" + cornerstone_base_url: str = "https://finder.cstone.space" uex_secret_key: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None) diff --git a/traderai/cornerstone_client.py b/traderai/cornerstone_client.py new file mode 100644 index 0000000..fda744f --- /dev/null +++ b/traderai/cornerstone_client.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from html.parser import HTMLParser +import json +from typing import Any + +import httpx + + +class CornerstoneError(RuntimeError): + pass + + +class CornerstoneClient: + def __init__(self, base_url: str = "https://finder.cstone.space") -> None: + self.base_url = base_url.rstrip("/") + self._items: list[dict[str, Any]] | None = None + + async def list_items(self) -> list[dict[str, Any]]: + if self._items is not None: + return self._items + body = await self._get_json("GetSearch") + if isinstance(body, str): + body = json.loads(body) + if not isinstance(body, list): + raise CornerstoneError("Cornerstone search response was not a list.") + self._items = [ + {"id": item.get("id"), "name": item.get("name"), "sold": bool(item.get("Sold"))} + for item in body + if isinstance(item, dict) and item.get("id") and item.get("name") + ] + return self._items + + async def get_item_page(self, item_id: str) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + response = await client.get( + f"{self.base_url}/Search/{item_id.strip()}", + headers={"Accept": "text/html,application/xhtml+xml"}, + ) + if response.status_code >= 400: + raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}") + return {"url": str(response.url), "html": response.text} + + async def _get_json(self, path: str) -> Any: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"}) + try: + body = response.json() + except ValueError as exc: + raise CornerstoneError(f"Cornerstone returned non-JSON response: HTTP {response.status_code}") from exc + if response.status_code >= 400: + raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {body}") + return body + + +class CornerstonePageParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.title = "" + self.tables: list[list[list[str]]] = [] + self._skip_depth = 0 + self._in_title = False + self._current_table: list[list[str]] | None = None + self._current_row: list[str] | None = None + self._current_cell: list[str] | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.casefold() + if tag in {"script", "style"}: + self._skip_depth += 1 + return + if self._skip_depth: + return + if tag == "title": + self._in_title = True + elif tag == "table": + self._current_table = [] + elif tag == "tr" and self._current_table is not None: + self._current_row = [] + elif tag in {"td", "th"} and self._current_row is not None: + self._current_cell = [] + + def handle_endtag(self, tag: str) -> None: + tag = tag.casefold() + if tag in {"script", "style"} and self._skip_depth: + self._skip_depth -= 1 + return + if self._skip_depth: + return + if tag == "title": + self._in_title = False + elif tag in {"td", "th"} and self._current_cell is not None and self._current_row is not None: + text = " ".join("".join(self._current_cell).split()) + self._current_row.append(text) + self._current_cell = None + elif tag == "tr" and self._current_row is not None and self._current_table is not None: + if any(cell for cell in self._current_row): + self._current_table.append(self._current_row) + self._current_row = None + elif tag == "table" and self._current_table is not None: + if self._current_table: + self.tables.append(self._current_table) + self._current_table = None + + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + if self._in_title: + self.title += data + if self._current_cell is not None: + self._current_cell.append(data) + + +def parse_cornerstone_item_page(html: str) -> dict[str, Any]: + parser = CornerstonePageParser() + parser.feed(html) + info: dict[str, Any] = {"page_title": " ".join(parser.title.split())} + general: dict[str, str] = {} + locations = [] + + for table in parser.tables: + if not table: + continue + header = [cell.casefold() for cell in table[0]] + if len(header) >= 3 and "location" in header[0] and "price" in header[1] and "verified" in header[2]: + for row in table[1:]: + if len(row) < 3: + continue + locations.append( + { + "location": row[0], + "base_price": _parse_cornerstone_price(row[1]), + "base_price_display": row[1], + "verified": row[2], + } + ) + elif all(len(row) >= 2 for row in table): + for row in table: + key = row[0].strip().lower().replace(" ", "_") + value = row[1].strip() + if key and value and key not in general: + general[key] = value + + info["name"] = general.get("name") or _name_from_title(info["page_title"]) + if general: + info["general"] = general + info["locations"] = locations + return info + + +def _parse_cornerstone_price(value: str) -> int | None: + digits = "".join(char for char in value if char.isdigit()) + return int(digits) if digits else None + + +def _name_from_title(title: str) -> str | None: + if " - " not in title: + return title or None + return title.rsplit(" - ", 1)[-1].strip() or None diff --git a/traderai/server.py b/traderai/server.py index 86e72fa..8bf622a 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -21,6 +21,7 @@ from pydantic import BaseModel from traderai.agent import OllamaAgent, OllamaUnavailable from traderai.config import save_settings, settings_payload from traderai.config import get_settings +from traderai.cornerstone_client import CornerstoneClient from traderai.memory import DEFAULT_THREAD_ID, MemoryStore from traderai.scheduler import WakeScheduler from traderai.scmdb_client import SCMDBClient @@ -77,7 +78,15 @@ def create_app() -> FastAPI: scheduler = WakeScheduler(memory) uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) scmdb = SCMDBClient(settings.scmdb_base_url) - tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler, scmdb=scmdb) + cornerstone = CornerstoneClient(settings.cornerstone_base_url) + tools = ToolRegistry( + uex, + settings.require_write_approval, + memory=memory, + scheduler=scheduler, + scmdb=scmdb, + cornerstone=cornerstone, + ) agent = OllamaAgent( settings.ollama_base_url, settings.ollama_model, diff --git a/traderai/tools.py b/traderai/tools.py index 380fb2c..519be99 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -4,6 +4,7 @@ import uuid from dataclasses import dataclass from typing import Any, Awaitable, Callable +from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page from traderai.memory import MemoryStore from traderai.scheduler import WakeScheduler from traderai.scmdb_client import SCMDBClient @@ -157,9 +158,11 @@ class ToolRegistry: memory: MemoryStore | None = None, scheduler: WakeScheduler | None = None, scmdb: SCMDBClient | None = None, + cornerstone: CornerstoneClient | None = None, ) -> None: self.uex = uex self.scmdb = scmdb or SCMDBClient() + self.cornerstone = cornerstone or CornerstoneClient() self.require_write_approval = require_write_approval self.memory = memory self.scheduler = scheduler @@ -179,6 +182,8 @@ class ToolRegistry: "list_scmdb_versions": self.list_scmdb_versions, "search_scmdb_missions": self.search_scmdb_missions, "get_scmdb_mission_rewards": self.get_scmdb_mission_rewards, + "search_cornerstone_items": self.search_cornerstone_items, + "get_cornerstone_item_locations": self.get_cornerstone_item_locations, } self.handlers["uex_api_catalog"] = self.uex_api_catalog self.handlers["uex_get"] = self.uex_get @@ -204,6 +209,7 @@ class ToolRegistry: *self._uex_post_schemas(), *self._uex_delete_schemas(), *self._scmdb_schemas(), + *self._cornerstone_schemas(), { "type": "function", "function": { @@ -834,6 +840,42 @@ class ToolRegistry: }, ] + @classmethod + def _cornerstone_schemas(cls) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": "search_cornerstone_items", + "description": "Search Cornerstone Universal Item Finder items. Use this to find exact item names and ids before asking where an item is sold.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Item name to search for."}, + "sold_only": {"type": "boolean", "default": False, "description": "Only return items marked as sold in-game by Cornerstone."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10}, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_cornerstone_item_locations", + "description": "Fetch where a Star Citizen item is sold using Cornerstone Universal Item Finder, including store/location, base price, and verified date.", + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Cornerstone item id from search_cornerstone_items."}, + "query": {"type": "string", "description": "Item name if id is not known."}, + "location": {"type": "string", "description": "Optional local filter for system, planet, station, city, or shop name."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, + }, + }, + }, + }, + ] + @classmethod def _uex_post_schemas(cls) -> list[dict[str, Any]]: return [ @@ -1166,6 +1208,78 @@ class ToolRegistry: "mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True), } + async def search_cornerstone_items( + self, + query: str = "", + sold_only: bool = False, + limit: int = 10, + ) -> dict[str, Any]: + items = await self.cornerstone.list_items() + q = (query or "").casefold().strip() + matches = [] + for item in items: + if sold_only and not item.get("sold"): + continue + score = self._cornerstone_match_score(q, str(item.get("name") or "")) + if q and score <= 0: + continue + matches.append((score, item)) + matches.sort(key=lambda match: (-match[0], str(match[1].get("name") or "").casefold())) + limit = max(1, min(limit, 25)) + compacted = [ + { + "id": item.get("id"), + "name": item.get("name"), + "sold": bool(item.get("sold")), + "url": f"{self.cornerstone.base_url}/Search/{item.get('id')}", + } + for _, item in matches[:limit] + ] + return { + "source": self.cornerstone.base_url, + "matched": len(matches), + "returned": len(compacted), + "truncated": len(matches) > limit, + "items": compacted, + } + + async def get_cornerstone_item_locations( + self, + id: str | None = None, + query: str | None = None, + location: str | None = None, + limit: int = 20, + ) -> dict[str, Any]: + item = await self._resolve_cornerstone_item(id=id, query=query) + if not item: + return {"error": "No Cornerstone item matched. Provide an id or a more specific query."} + + page = await self.cornerstone.get_item_page(str(item["id"])) + parsed = parse_cornerstone_item_page(page["html"]) + locations = parsed.get("locations") or [] + location_filter = (location or "").casefold().strip() + if location_filter: + locations = [ + entry + for entry in locations + if location_filter in str(entry.get("location") or "").casefold() + ] + limit = max(1, min(limit, 50)) + return { + "source": self.cornerstone.base_url, + "url": page["url"], + "item": { + "id": item.get("id"), + "name": parsed.get("name") or item.get("name"), + "sold": bool(item.get("sold")), + "general": parsed.get("general") or {}, + }, + "matched_locations": len(locations), + "returned": min(len(locations), limit), + "truncated": len(locations) > limit, + "locations": locations[:limit], + } + def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]: action_id = str(uuid.uuid4()) payload = {key: value for key, value in payload.items() if value is not None} @@ -1599,6 +1713,48 @@ class ToolRegistry: pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")]) return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold() + async def _resolve_cornerstone_item(self, id: str | None = None, query: str | None = None) -> dict[str, Any] | None: + items = await self.cornerstone.list_items() + id_filter = (id or "").casefold().strip() + if id_filter: + for item in items: + if str(item.get("id") or "").casefold() == id_filter: + return item + return {"id": id, "name": id, "sold": True} + + q = (query or "").casefold().strip() + if not q: + return None + exact = [item for item in items if str(item.get("name") or "").casefold() == q] + if exact: + exact.sort(key=lambda item: not bool(item.get("sold"))) + return exact[0] + scored = [ + (self._cornerstone_match_score(q, str(item.get("name") or "")), item) + for item in items + ] + scored = [match for match in scored if match[0] > 0] + if not scored: + return None + scored.sort(key=lambda match: (-match[0], not bool(match[1].get("sold")), str(match[1].get("name") or "").casefold())) + return scored[0][1] + + @staticmethod + def _cornerstone_match_score(query: str, name: str) -> int: + if not query: + return 1 + normalized = name.casefold() + if normalized == query: + return 10000 + if normalized.startswith(query): + return 9000 - len(normalized) + if query in normalized: + return 8000 - normalized.index(query) + tokens = [token for token in query.split() if token] + if tokens and all(token in normalized for token in tokens): + return 7000 - len(normalized) + return 0 + @staticmethod def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: return {