feat: add cornerstone
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
+10
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user