feat: add cornerstone

This commit is contained in:
2026-05-07 21:47:30 -04:00
parent 71638fcaed
commit d6c2d57fd9
8 changed files with 435 additions and 1 deletions
+156
View File
@@ -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 {