Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d6c2d57fd9
|
|||
|
71638fcaed
|
@@ -2,6 +2,8 @@ OLLAMA_BASE_URL=http://localhost:11434
|
|||||||
OLLAMA_MODEL=qwen3.5:9b
|
OLLAMA_MODEL=qwen3.5:9b
|
||||||
OLLAMA_NUM_CTX=64512
|
OLLAMA_NUM_CTX=64512
|
||||||
UEX_BASE_URL=https://api.uexcorp.space/2.0
|
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_SECRET_KEY=
|
||||||
UEX_BEARER_TOKEN=
|
UEX_BEARER_TOKEN=
|
||||||
TRADERAI_USER_NAME=
|
TRADERAI_USER_NAME=
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ Local Ollama-powered chat for UEX marketplace workflows.
|
|||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`.
|
- 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.
|
- 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.
|
- Drafts negotiation messages and marketplace listings as pending actions.
|
||||||
- Requires browser approval before sending authenticated write requests to UEX.
|
- Requires browser approval before sending authenticated write requests to UEX.
|
||||||
@@ -23,6 +25,8 @@ 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.
|
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:
|
4. Install and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -71,6 +75,8 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
|
|||||||
## Sources Used
|
## Sources Used
|
||||||
|
|
||||||
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1
|
- 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 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
|
- 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
|
- Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import pytest
|
|||||||
import respx
|
import respx
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
|
|
||||||
|
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
|
|
||||||
@@ -121,6 +122,131 @@ class FakeUEX:
|
|||||||
return {"status": "ok", "posted": self.posts[-1]}
|
return {"status": "ok", "posted": self.posts[-1]}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSCMDB:
|
||||||
|
base_url = "https://scmdb.test"
|
||||||
|
|
||||||
|
async def list_versions(self):
|
||||||
|
return [
|
||||||
|
{"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"},
|
||||||
|
{"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_data(self, version=None, channel="live"):
|
||||||
|
return {
|
||||||
|
"version": version or "4.7.2-live.1",
|
||||||
|
"factions": {
|
||||||
|
"fac-haul": {"name": "Covalex"},
|
||||||
|
"fac-bounty": {"name": "Bounty Hunters Guild"},
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"scope-rep": {"scopeName": "FactionReputation"},
|
||||||
|
},
|
||||||
|
"factionRewardsPools": [
|
||||||
|
[{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}],
|
||||||
|
[{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}],
|
||||||
|
],
|
||||||
|
"partialRewardPayoutPools": [
|
||||||
|
[],
|
||||||
|
[{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}],
|
||||||
|
],
|
||||||
|
"resourcePools": {
|
||||||
|
"res-tungsten": {"name": "Tungsten"},
|
||||||
|
},
|
||||||
|
"blueprintPools": {
|
||||||
|
"bp-pool": {
|
||||||
|
"name": "Ship Salvage Rewards",
|
||||||
|
"blueprints": [{"name": "Abrade Scraper Module"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"locationPools": {
|
||||||
|
"loc-a18": {"name": "Area18"},
|
||||||
|
"loc-baijini": {"name": "Baijini Point"},
|
||||||
|
},
|
||||||
|
"contracts": [
|
||||||
|
{
|
||||||
|
"id": "mission-haul",
|
||||||
|
"debugName": "Haul_Tungsten_Test",
|
||||||
|
"title": "Move Tungsten",
|
||||||
|
"description": "Move Tungsten to Baijini Point.",
|
||||||
|
"missionType": "Hauling",
|
||||||
|
"category": "career",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 50250,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 1,
|
||||||
|
"haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}],
|
||||||
|
"locations": ["loc-a18"],
|
||||||
|
"destinations": ["loc-baijini"],
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
"illegal": False,
|
||||||
|
"canBeShared": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mission-bounty",
|
||||||
|
"debugName": "Bounty_Blueprint_Test",
|
||||||
|
"title": "Ambush Op",
|
||||||
|
"description": "Clean out targets.",
|
||||||
|
"missionType": "Bounty Hunter",
|
||||||
|
"factionGuid": "fac-bounty",
|
||||||
|
"rewardUEC": 120000,
|
||||||
|
"factionRewardsIndex": 1,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"itemRewards": [{"name": "Council Scrip", "amount": 5}],
|
||||||
|
"blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}],
|
||||||
|
"systems": ["Pyro"],
|
||||||
|
"illegal": True,
|
||||||
|
"canBeShared": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"legacyContracts": [
|
||||||
|
{
|
||||||
|
"id": "legacy-delivery",
|
||||||
|
"debugName": "Legacy_Delivery_Test",
|
||||||
|
"title": "Old Box Run",
|
||||||
|
"missionType": "Delivery",
|
||||||
|
"factionGuid": "fac-haul",
|
||||||
|
"rewardUEC": 1000,
|
||||||
|
"factionRewardsIndex": 0,
|
||||||
|
"partialRewardPayoutIndex": 0,
|
||||||
|
"systems": ["Stanton"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": """
|
||||||
|
<html>
|
||||||
|
<head><title>Star Citizen - Salvage modifier - Abrade Scraper Module</title></head>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<tr><td>NAME</td><td>Abrade Scraper Module</td></tr>
|
||||||
|
<tr><td>MANUFACTURER</td><td>Greycat Industrial</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - ArcCorp - Area18 - Dumper's Depot</td><td>21 250</td><td>2956-01-29</td></tr>
|
||||||
|
<tr><td>Stanton - microTech - Port Tressler - Platinum Bay</td><td>21 250</td><td>2956-01-04</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_marketplace_listings_filters_locally():
|
async def test_search_marketplace_listings_filters_locally():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
@@ -236,6 +362,113 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool():
|
|||||||
assert "uex_draft_post" not in names
|
assert "uex_draft_post" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_schemas_expose_scmdb_mission_tools():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
names = {schema["function"]["name"] for schema in registry.schemas}
|
||||||
|
|
||||||
|
assert "list_scmdb_versions" in names
|
||||||
|
assert "search_scmdb_missions" in names
|
||||||
|
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())
|
||||||
|
|
||||||
|
result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling")
|
||||||
|
|
||||||
|
assert result["version"] == "4.7.2-live.1"
|
||||||
|
assert result["matched"] == 1
|
||||||
|
mission = result["missions"][0]
|
||||||
|
assert mission["title"] == "Move Tungsten"
|
||||||
|
assert mission["rewards"]["uec"] == 50250
|
||||||
|
assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}]
|
||||||
|
assert mission["rewards"]["hauling"] == [
|
||||||
|
{"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations():
|
||||||
|
registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB())
|
||||||
|
|
||||||
|
result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test")
|
||||||
|
|
||||||
|
mission = result["mission"]
|
||||||
|
assert mission["title"] == "Ambush Op"
|
||||||
|
assert mission["faction"] == "Bounty Hunters Guild"
|
||||||
|
assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}]
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
<html><head><title>Star Citizen - Food - Whamburger</title></head>
|
||||||
|
<body><table><tr><td>NAME</td><td>Whamburger</td></tr></table>
|
||||||
|
<table><tr><th>LOCATION</th><th>BASE PRICE</th><th>VERIFIED</th></tr>
|
||||||
|
<tr><td>Stanton - Area18 - Cubby Blast</td><td>9</td><td>2956-01-01</td></tr></table></body></html>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_search_uex_api_index_finds_history_tools():
|
async def test_search_uex_api_index_finds_history_tools():
|
||||||
registry = ToolRegistry(FakeUEX())
|
registry = ToolRegistry(FakeUEX())
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ Use tools when the user asks about UEX data, open/current listings, active negot
|
|||||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||||
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.
|
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.
|
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.
|
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.
|
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.
|
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
|
||||||
@@ -514,6 +516,11 @@ class OllamaAgent:
|
|||||||
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
|
"summarize_uex_commodity_price_history": "Summarizing commodity price history",
|
||||||
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
|
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
|
||||||
"summarize_uex_currency_index_history": "Summarizing currency index history",
|
"summarize_uex_currency_index_history": "Summarizing currency index history",
|
||||||
|
"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_api_catalog": "Checking UEX API catalog",
|
||||||
"uex_get": "Fetching UEX data",
|
"uex_get": "Fetching UEX data",
|
||||||
"uex_draft_post": "Drafting UEX write for approval",
|
"uex_draft_post": "Drafting UEX write for approval",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
|||||||
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
|
||||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "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_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "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},
|
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
|
||||||
@@ -64,6 +66,8 @@ class Settings(BaseSettings):
|
|||||||
ollama_model: str = "qwen3.5:9b"
|
ollama_model: str = "qwen3.5:9b"
|
||||||
ollama_num_ctx: int = 64512
|
ollama_num_ctx: int = 64512
|
||||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
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_secret_key: str | None = Field(default=None)
|
||||||
uex_bearer_token: str | None = Field(default=None)
|
uex_bearer_token: str | None = Field(default=None)
|
||||||
traderai_user_name: 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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SCMDBClient:
|
||||||
|
def __init__(self, base_url: str = "https://scmdb.net") -> None:
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._versions: list[dict[str, Any]] | None = None
|
||||||
|
self._data_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
async def list_versions(self) -> list[dict[str, Any]]:
|
||||||
|
if self._versions is not None:
|
||||||
|
return self._versions
|
||||||
|
body = await self._get_json("data/versions.json")
|
||||||
|
if not isinstance(body, list):
|
||||||
|
raise SCMDBError("SCMDB versions response was not a list.")
|
||||||
|
self._versions = [
|
||||||
|
item
|
||||||
|
for item in body
|
||||||
|
if isinstance(item, dict) and item.get("version") and item.get("file")
|
||||||
|
]
|
||||||
|
return self._versions
|
||||||
|
|
||||||
|
async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
selected = await self.resolve_version(version=version, channel=channel)
|
||||||
|
cache_key = str(selected["version"])
|
||||||
|
if cache_key not in self._data_cache:
|
||||||
|
body = await self._get_json(f"data/{selected['file']}")
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise SCMDBError(f"SCMDB data for {cache_key} was not an object.")
|
||||||
|
self._data_cache[cache_key] = body
|
||||||
|
return self._data_cache[cache_key]
|
||||||
|
|
||||||
|
async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]:
|
||||||
|
versions = await self.list_versions()
|
||||||
|
if not versions:
|
||||||
|
raise SCMDBError("SCMDB did not return any data versions.")
|
||||||
|
|
||||||
|
if version:
|
||||||
|
needle = version.casefold().strip()
|
||||||
|
for item in versions:
|
||||||
|
item_version = str(item["version"])
|
||||||
|
if item_version.casefold() == needle or needle in item_version.casefold():
|
||||||
|
return item
|
||||||
|
raise SCMDBError(f"SCMDB version not found: {version}")
|
||||||
|
|
||||||
|
channel = (channel or "live").casefold().strip()
|
||||||
|
if channel in {"latest", "any", "all"}:
|
||||||
|
return versions[0]
|
||||||
|
if channel not in {"live", "ptu"}:
|
||||||
|
raise SCMDBError("SCMDB channel must be live, ptu, or latest.")
|
||||||
|
for item in versions:
|
||||||
|
if f"-{channel}." in str(item["version"]).casefold():
|
||||||
|
return item
|
||||||
|
return versions[0]
|
||||||
|
|
||||||
|
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 SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}")
|
||||||
|
return body
|
||||||
+12
-1
@@ -21,8 +21,10 @@ from pydantic import BaseModel
|
|||||||
from traderai.agent import OllamaAgent, OllamaUnavailable
|
from traderai.agent import OllamaAgent, OllamaUnavailable
|
||||||
from traderai.config import save_settings, settings_payload
|
from traderai.config import save_settings, settings_payload
|
||||||
from traderai.config import get_settings
|
from traderai.config import get_settings
|
||||||
|
from traderai.cornerstone_client import CornerstoneClient
|
||||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.scmdb_client import SCMDBClient
|
||||||
from traderai.tools import ToolRegistry
|
from traderai.tools import ToolRegistry
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||||
@@ -75,7 +77,16 @@ def create_app() -> FastAPI:
|
|||||||
memory = MemoryStore(settings.traderai_memory_path)
|
memory = MemoryStore(settings.traderai_memory_path)
|
||||||
scheduler = WakeScheduler(memory)
|
scheduler = WakeScheduler(memory)
|
||||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
||||||
tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler)
|
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||||
|
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
||||||
|
tools = ToolRegistry(
|
||||||
|
uex,
|
||||||
|
settings.require_write_approval,
|
||||||
|
memory=memory,
|
||||||
|
scheduler=scheduler,
|
||||||
|
scmdb=scmdb,
|
||||||
|
cornerstone=cornerstone,
|
||||||
|
)
|
||||||
agent = OllamaAgent(
|
agent = OllamaAgent(
|
||||||
settings.ollama_base_url,
|
settings.ollama_base_url,
|
||||||
settings.ollama_model,
|
settings.ollama_model,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import uuid
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_item_page
|
||||||
from traderai.memory import MemoryStore
|
from traderai.memory import MemoryStore
|
||||||
from traderai.scheduler import WakeScheduler
|
from traderai.scheduler import WakeScheduler
|
||||||
|
from traderai.scmdb_client import SCMDBClient
|
||||||
from traderai.uex_client import UEXClient
|
from traderai.uex_client import UEXClient
|
||||||
|
|
||||||
|
|
||||||
@@ -155,8 +157,12 @@ class ToolRegistry:
|
|||||||
require_write_approval: bool = True,
|
require_write_approval: bool = True,
|
||||||
memory: MemoryStore | None = None,
|
memory: MemoryStore | None = None,
|
||||||
scheduler: WakeScheduler | None = None,
|
scheduler: WakeScheduler | None = None,
|
||||||
|
scmdb: SCMDBClient | None = None,
|
||||||
|
cornerstone: CornerstoneClient | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.uex = uex
|
self.uex = uex
|
||||||
|
self.scmdb = scmdb or SCMDBClient()
|
||||||
|
self.cornerstone = cornerstone or CornerstoneClient()
|
||||||
self.require_write_approval = require_write_approval
|
self.require_write_approval = require_write_approval
|
||||||
self.memory = memory
|
self.memory = memory
|
||||||
self.scheduler = scheduler
|
self.scheduler = scheduler
|
||||||
@@ -173,6 +179,11 @@ class ToolRegistry:
|
|||||||
"schedule_wake_job": self.schedule_wake_job,
|
"schedule_wake_job": self.schedule_wake_job,
|
||||||
"list_wake_jobs": self.list_wake_jobs,
|
"list_wake_jobs": self.list_wake_jobs,
|
||||||
"check_uex_notifications": self.check_uex_notifications,
|
"check_uex_notifications": self.check_uex_notifications,
|
||||||
|
"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_api_catalog"] = self.uex_api_catalog
|
||||||
self.handlers["uex_get"] = self.uex_get
|
self.handlers["uex_get"] = self.uex_get
|
||||||
@@ -197,6 +208,8 @@ class ToolRegistry:
|
|||||||
*self._history_summary_schemas(),
|
*self._history_summary_schemas(),
|
||||||
*self._uex_post_schemas(),
|
*self._uex_post_schemas(),
|
||||||
*self._uex_delete_schemas(),
|
*self._uex_delete_schemas(),
|
||||||
|
*self._scmdb_schemas(),
|
||||||
|
*self._cornerstone_schemas(),
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -767,6 +780,102 @@ class ToolRegistry:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_schemas(cls) -> list[dict[str, Any]]:
|
||||||
|
version_controls = {
|
||||||
|
"version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."},
|
||||||
|
"channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"},
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_scmdb_versions",
|
||||||
|
"description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channel": {"type": "string", "enum": ["live", "ptu", "latest"]},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "search_scmdb_missions",
|
||||||
|
"description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."},
|
||||||
|
"mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."},
|
||||||
|
"category": {"type": "string"},
|
||||||
|
"faction": {"type": "string"},
|
||||||
|
"system": {"type": "string", "description": "Star system such as Stanton or Pyro."},
|
||||||
|
"illegal": {"type": "boolean"},
|
||||||
|
"include_legacy": {"type": "boolean", "default": True},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10},
|
||||||
|
**version_controls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_scmdb_mission_rewards",
|
||||||
|
"description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"debug_name": {"type": "string"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"include_legacy": {"type": "boolean", "default": True},
|
||||||
|
**version_controls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
@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
|
@classmethod
|
||||||
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
|
def _uex_post_schemas(cls) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
@@ -980,6 +1089,197 @@ class ToolRegistry:
|
|||||||
pending = [item for item in notifications if not item.get("date_read")]
|
pending = [item for item in notifications if not item.get("date_read")]
|
||||||
return {"count": len(pending), "notifications": pending}
|
return {"count": len(pending), "notifications": pending}
|
||||||
|
|
||||||
|
async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]:
|
||||||
|
versions = await self.scmdb.list_versions()
|
||||||
|
channel_filter = (channel or "").casefold().strip()
|
||||||
|
if channel_filter in {"live", "ptu"}:
|
||||||
|
versions = [
|
||||||
|
item
|
||||||
|
for item in versions
|
||||||
|
if f"-{channel_filter}." in str(item.get("version", "")).casefold()
|
||||||
|
]
|
||||||
|
elif channel_filter not in {"", "latest"}:
|
||||||
|
return {"error": "SCMDB channel must be live, ptu, or latest."}
|
||||||
|
return {
|
||||||
|
"source": self.scmdb.base_url,
|
||||||
|
"count": len(versions),
|
||||||
|
"versions": versions,
|
||||||
|
"default_channel": "live",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search_scmdb_missions(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
mission_type: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
faction: str | None = None,
|
||||||
|
system: str | None = None,
|
||||||
|
illegal: bool | None = None,
|
||||||
|
include_legacy: bool = True,
|
||||||
|
limit: int = 10,
|
||||||
|
version: str | None = None,
|
||||||
|
channel: str = "live",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||||
|
q = (query or "").casefold().strip()
|
||||||
|
mission_type_filter = (mission_type or "").casefold().strip()
|
||||||
|
category_filter = (category or "").casefold().strip()
|
||||||
|
faction_filter = (faction or "").casefold().strip()
|
||||||
|
system_filter = (system or "").casefold().strip()
|
||||||
|
matched = []
|
||||||
|
|
||||||
|
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||||
|
summary = self._summarize_scmdb_mission(data, mission, source=source)
|
||||||
|
if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold():
|
||||||
|
continue
|
||||||
|
if category_filter and category_filter not in str(summary.get("category") or "").casefold():
|
||||||
|
continue
|
||||||
|
if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold():
|
||||||
|
continue
|
||||||
|
if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold():
|
||||||
|
continue
|
||||||
|
if illegal is not None and bool(summary.get("illegal")) != illegal:
|
||||||
|
continue
|
||||||
|
if q and q not in self._scmdb_search_text(data, mission, summary):
|
||||||
|
continue
|
||||||
|
matched.append(summary)
|
||||||
|
|
||||||
|
limit = max(1, min(limit, 25))
|
||||||
|
return {
|
||||||
|
"source": self.scmdb.base_url,
|
||||||
|
"version": data.get("version"),
|
||||||
|
"matched": len(matched),
|
||||||
|
"returned": min(len(matched), limit),
|
||||||
|
"truncated": len(matched) > limit,
|
||||||
|
"missions": matched[:limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_scmdb_mission_rewards(
|
||||||
|
self,
|
||||||
|
id: str | None = None,
|
||||||
|
debug_name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
include_legacy: bool = True,
|
||||||
|
version: str | None = None,
|
||||||
|
channel: str = "live",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not any([id, debug_name, title]):
|
||||||
|
return {"error": "Provide id, debug_name, or title."}
|
||||||
|
|
||||||
|
data = await self.scmdb.get_data(version=version, channel=channel)
|
||||||
|
exact = []
|
||||||
|
fuzzy = []
|
||||||
|
id_filter = (id or "").casefold().strip()
|
||||||
|
debug_filter = (debug_name or "").casefold().strip()
|
||||||
|
title_filter = (title or "").casefold().strip()
|
||||||
|
|
||||||
|
for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy):
|
||||||
|
mission_id = str(mission.get("id") or "").casefold()
|
||||||
|
mission_debug = str(mission.get("debugName") or "").casefold()
|
||||||
|
mission_title = str(mission.get("title") or "").casefold()
|
||||||
|
if id_filter and mission_id == id_filter:
|
||||||
|
exact.append((source, mission))
|
||||||
|
elif debug_filter and mission_debug == debug_filter:
|
||||||
|
exact.append((source, mission))
|
||||||
|
elif title_filter and mission_title == title_filter:
|
||||||
|
exact.append((source, mission))
|
||||||
|
elif title_filter and title_filter in mission_title:
|
||||||
|
fuzzy.append((source, mission))
|
||||||
|
elif debug_filter and debug_filter in mission_debug:
|
||||||
|
fuzzy.append((source, mission))
|
||||||
|
|
||||||
|
candidates = exact or fuzzy
|
||||||
|
if len(candidates) != 1:
|
||||||
|
return {
|
||||||
|
"source": self.scmdb.base_url,
|
||||||
|
"version": data.get("version"),
|
||||||
|
"matched": len(candidates),
|
||||||
|
"error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.",
|
||||||
|
"matches": [
|
||||||
|
self._summarize_scmdb_mission(data, mission, source=source)
|
||||||
|
for source, mission in candidates[:10]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
source, mission = candidates[0]
|
||||||
|
return {
|
||||||
|
"source": self.scmdb.base_url,
|
||||||
|
"version": data.get("version"),
|
||||||
|
"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]:
|
def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
|
||||||
action_id = str(uuid.uuid4())
|
action_id = str(uuid.uuid4())
|
||||||
payload = {key: value for key, value in payload.items() if value is not None}
|
payload = {key: value for key, value in payload.items() if value is not None}
|
||||||
@@ -1171,6 +1471,290 @@ class ToolRegistry:
|
|||||||
def _is_number(value: Any) -> bool:
|
def _is_number(value: Any) -> bool:
|
||||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]:
|
||||||
|
contracts = [
|
||||||
|
("contracts", mission)
|
||||||
|
for mission in data.get("contracts") or []
|
||||||
|
if isinstance(mission, dict)
|
||||||
|
]
|
||||||
|
if include_legacy:
|
||||||
|
contracts.extend(
|
||||||
|
("legacyContracts", mission)
|
||||||
|
for mission in data.get("legacyContracts") or []
|
||||||
|
if isinstance(mission, dict)
|
||||||
|
)
|
||||||
|
return contracts
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _summarize_scmdb_mission(
|
||||||
|
cls,
|
||||||
|
data: dict[str, Any],
|
||||||
|
mission: dict[str, Any],
|
||||||
|
source: str,
|
||||||
|
detailed: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
summary: dict[str, Any] = {
|
||||||
|
"id": mission.get("id"),
|
||||||
|
"debug_name": mission.get("debugName"),
|
||||||
|
"source": source,
|
||||||
|
"title": mission.get("title"),
|
||||||
|
"mission_type": mission.get("missionType"),
|
||||||
|
"category": mission.get("category"),
|
||||||
|
"faction": cls._scmdb_faction_name(data, mission.get("factionGuid")),
|
||||||
|
"systems": mission.get("systems") or [],
|
||||||
|
"illegal": bool(mission.get("illegal")),
|
||||||
|
"can_be_shared": bool(mission.get("canBeShared")),
|
||||||
|
"once_only": bool(mission.get("onceOnly")),
|
||||||
|
"time_to_complete_minutes": mission.get("timeToComplete"),
|
||||||
|
"max_players": mission.get("maxPlayersPerInstance"),
|
||||||
|
"cooldown_minutes": mission.get("personalCooldownTime"),
|
||||||
|
"min_standing": cls._scmdb_standing(mission.get("minStanding")),
|
||||||
|
"max_standing": cls._scmdb_standing(mission.get("maxStanding")),
|
||||||
|
"rewards": cls._scmdb_rewards(data, mission, detailed=detailed),
|
||||||
|
}
|
||||||
|
if detailed:
|
||||||
|
summary.update(
|
||||||
|
{
|
||||||
|
"description": cls._compact_scalar(mission.get("description")),
|
||||||
|
"locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20),
|
||||||
|
"destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20),
|
||||||
|
"prerequisites": cls._compact_scalar(mission.get("prerequisites")),
|
||||||
|
"available_in_prison": bool(mission.get("availableInPrison")),
|
||||||
|
"reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")),
|
||||||
|
"reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")),
|
||||||
|
"hide_in_mobiglas": bool(mission.get("hideInMobiGlas")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]:
|
||||||
|
rewards: dict[str, Any] = {
|
||||||
|
"uec": mission.get("rewardUEC"),
|
||||||
|
"buy_in": mission.get("buyIn"),
|
||||||
|
"dynamic_uec": mission.get("rewardIsDynamic"),
|
||||||
|
"reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")),
|
||||||
|
"failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")),
|
||||||
|
"items": cls._scmdb_item_rewards(mission.get("itemRewards")),
|
||||||
|
"blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed),
|
||||||
|
"hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")),
|
||||||
|
"partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")),
|
||||||
|
}
|
||||||
|
return {key: value for key, value in rewards.items() if value not in (None, "", [], {})}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(index, int):
|
||||||
|
return []
|
||||||
|
pools = data.get("factionRewardsPools") or []
|
||||||
|
if index < 0 or index >= len(pools):
|
||||||
|
return []
|
||||||
|
return cls._scmdb_reputation_rewards(data, pools[index])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(rewards, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for reward in rewards:
|
||||||
|
if not isinstance(reward, dict):
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"faction": cls._scmdb_faction_name(data, reward.get("factionGuid")),
|
||||||
|
"scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")),
|
||||||
|
"amount": reward.get("amount"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return [item for item in result if item.get("amount") not in (None, "")]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(rewards, list):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": reward.get("name"),
|
||||||
|
"amount": reward.get("amount"),
|
||||||
|
}
|
||||||
|
for reward in rewards
|
||||||
|
if isinstance(reward, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(rewards, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for reward in rewards:
|
||||||
|
if not isinstance(reward, dict):
|
||||||
|
continue
|
||||||
|
pool_id = reward.get("blueprintPool")
|
||||||
|
pool = (data.get("blueprintPools") or {}).get(pool_id) or {}
|
||||||
|
blueprints = pool.get("blueprints") or []
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"pool": pool.get("name") or reward.get("poolName"),
|
||||||
|
"chance": reward.get("chance"),
|
||||||
|
"trigger": reward.get("trigger"),
|
||||||
|
"blueprints": [
|
||||||
|
cls._compact_scalar(item.get("name"))
|
||||||
|
for item in blueprints[: 20 if detailed else 5]
|
||||||
|
if isinstance(item, dict) and item.get("name")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(orders, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
resources = data.get("resourcePools") or {}
|
||||||
|
for order in orders:
|
||||||
|
if not isinstance(order, dict):
|
||||||
|
continue
|
||||||
|
resource = resources.get(order.get("resource")) or {}
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"resource": resource.get("name") or order.get("resource"),
|
||||||
|
"min_scu": order.get("minSCU"),
|
||||||
|
"max_scu": order.get("maxSCU"),
|
||||||
|
"max_container_size_scu": order.get("maxContainerSize"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(index, int):
|
||||||
|
return []
|
||||||
|
pools = data.get("partialRewardPayoutPools") or []
|
||||||
|
if index < 0 or index >= len(pools):
|
||||||
|
return []
|
||||||
|
payouts = pools[index]
|
||||||
|
if not isinstance(payouts, list):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"min_percent": payout.get("minPercentage"),
|
||||||
|
"max_percent": payout.get("maxPercentage"),
|
||||||
|
"currency_multiplier": payout.get("currencyRewardMultiplier"),
|
||||||
|
"reputation_multipliers": payout.get("reputationMultipliers"),
|
||||||
|
}
|
||||||
|
for payout in payouts
|
||||||
|
if isinstance(payout, dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||||
|
if not guid:
|
||||||
|
return None
|
||||||
|
faction = (data.get("factions") or {}).get(guid)
|
||||||
|
if isinstance(faction, dict):
|
||||||
|
return faction.get("name") or guid
|
||||||
|
return str(guid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None:
|
||||||
|
if not guid:
|
||||||
|
return None
|
||||||
|
scope = (data.get("scopes") or {}).get(guid)
|
||||||
|
if isinstance(scope, dict):
|
||||||
|
return scope.get("scopeName") or guid
|
||||||
|
return str(guid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scmdb_standing(value: Any) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
key: value.get(key)
|
||||||
|
for key in ("name", "minReputation", "scopeName")
|
||||||
|
if value.get(key) not in (None, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]:
|
||||||
|
if not isinstance(keys, list):
|
||||||
|
return []
|
||||||
|
pool = data.get(pool_key) or {}
|
||||||
|
names = []
|
||||||
|
for key in keys[:limit]:
|
||||||
|
item = pool.get(key)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
names.append(str(item.get("name") or key))
|
||||||
|
else:
|
||||||
|
names.append(str(key))
|
||||||
|
return names
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str:
|
||||||
|
pieces = [
|
||||||
|
summary.get("title"),
|
||||||
|
summary.get("debug_name"),
|
||||||
|
summary.get("mission_type"),
|
||||||
|
summary.get("category"),
|
||||||
|
summary.get("faction"),
|
||||||
|
mission.get("description"),
|
||||||
|
" ".join(summary.get("systems") or []),
|
||||||
|
]
|
||||||
|
rewards = summary.get("rewards") or {}
|
||||||
|
pieces.append(str(rewards.get("uec") or ""))
|
||||||
|
for item in rewards.get("reputation") or []:
|
||||||
|
pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")])
|
||||||
|
for item in rewards.get("items") or []:
|
||||||
|
pieces.extend([item.get("name"), item.get("amount")])
|
||||||
|
for item in rewards.get("blueprints") or []:
|
||||||
|
pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])])
|
||||||
|
for item in rewards.get("hauling") or []:
|
||||||
|
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
|
@staticmethod
|
||||||
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user