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
+
+
+ | NAME | Abrade Scraper Module |
+ | MANUFACTURER | Greycat Industrial |
+
+
+ | LOCATION | BASE PRICE | VERIFIED |
+ | Stanton - ArcCorp - Area18 - Dumper's Depot | 21 250 | 2956-01-29 |
+ | Stanton - microTech - Port Tressler - Platinum Bay | 21 250 | 2956-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
+
+ | LOCATION | BASE PRICE | VERIFIED |
+ | Stanton - Area18 - Cubby Blast | 9 | 2956-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 {