From da016c23cb6fb3af6dc7b6db96b87739d9571684 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Wed, 6 May 2026 13:16:27 -0400 Subject: [PATCH] feat: history tools --- tests/test_tools.py | 85 ++++++++++ traderai/agent.py | 5 + traderai/tools.py | 368 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 453 insertions(+), 5 deletions(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index bd8eafd..ecd54af 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -8,6 +8,52 @@ from traderai.uex_client import UEXClient class FakeUEX: async def get(self, path, params=None, authenticated=False): + if path == "commodities_prices_history": + return { + "status": "ok", + "data": [ + { + "id": 1, + "id_terminal": 7, + "id_commodity": 3, + "commodity_name": "Gold", + "terminal_name": "Port Tressler", + "price_buy": 4000, + "price_sell": 5000, + "scu_buy": 100, + "scu_sell": 20, + "date_added": 100, + }, + { + "id": 2, + "id_terminal": 7, + "id_commodity": 3, + "commodity_name": "Gold", + "terminal_name": "Port Tressler", + "price_buy": 4200, + "price_sell": 4800, + "scu_buy": 80, + "scu_sell": 30, + "date_added": 200, + }, + ], + } + if path == "marketplace_prices_history": + return { + "status": "ok", + "data": [ + {"id": 1, "item_name": "Widget", "operation": "sell", "price": 1000, "currency": "UEC", "date_added": 100}, + {"id": 2, "item_name": "Widget", "operation": "sell", "price": 1250, "currency": "UEC", "date_added": 200}, + ], + } + if path == "currencies_index_history": + return { + "status": "ok", + "data": [ + {"id": 1, "currency": "UEC", "index_value": 100.0, "basket_value": 5000.0, "date_added": 100}, + {"id": 2, "currency": "UEC", "index_value": 110.0, "basket_value": 5500.0, "date_added": 200}, + ], + } if path == "commodities_prices": return { "status": "ok", @@ -170,6 +216,45 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool(): assert "uex_draft_post" not in names +@pytest.mark.asyncio +async def test_search_uex_api_index_finds_history_tools(): + registry = ToolRegistry(FakeUEX()) + + result = await registry.execute("search_uex_api_index", {"query": "history", "history_only": True}) + + tools = {item["tool"] for item in result["get"]} + assert "get_uex_commodities_prices_history" in tools + assert "get_uex_marketplace_prices_history" in tools + assert "get_uex_currencies_index_history" in tools + + +@pytest.mark.asyncio +async def test_summarize_commodity_price_history_returns_trend_metrics(): + registry = ToolRegistry(FakeUEX()) + + result = await registry.execute( + "summarize_uex_commodity_price_history", + {"id_terminal": 7, "id_commodity": 3}, + ) + + assert result["resource"] == "commodities_prices_history" + assert result["count"] == 2 + assert result["labels"] == {"commodity_name": "Gold", "terminal_name": "Port Tressler"} + assert result["metrics"]["price_buy"]["change"] == 200 + assert result["metrics"]["price_sell"]["pct_change"] == -4.0 + + +@pytest.mark.asyncio +async def test_summarize_marketplace_and_currency_history(): + registry = ToolRegistry(FakeUEX()) + + market = await registry.execute("summarize_uex_marketplace_price_history", {"item_name": "Widget"}) + currency = await registry.execute("summarize_uex_currency_index_history", {"currency": "UEC"}) + + assert market["metrics"]["price"]["pct_change"] == 25.0 + assert currency["metrics"]["index_value"]["change"] == 10.0 + + @pytest.mark.asyncio @respx.mock async def test_uex_client_get_user_normalizes_user_payload(): diff --git a/traderai/agent.py b/traderai/agent.py index e5ce43b..feb9750 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -14,6 +14,7 @@ from traderai.tools import ToolRegistry SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. 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. 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. @@ -280,6 +281,10 @@ class OllamaAgent: if name.startswith("delete_uex_"): return f"Drafting UEX {name.removeprefix('delete_uex_')} delete for approval" labels = { + "search_uex_api_index": "Searching UEX API index", + "summarize_uex_commodity_price_history": "Summarizing commodity price history", + "summarize_uex_marketplace_price_history": "Summarizing marketplace price history", + "summarize_uex_currency_index_history": "Summarizing currency index history", "uex_api_catalog": "Checking UEX API catalog", "uex_get": "Fetching UEX data", "uex_draft_post": "Drafting UEX write for approval", diff --git a/traderai/tools.py b/traderai/tools.py index 3111a05..2c210c2 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -25,7 +25,7 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = { "group": "trade", }, "commodities_prices_all": {"params": [], "auth": False, "group": "trade", "heavy": True}, - "commodities_prices_history": {"params": ["id_commodity", "id_terminal", "commodity_name", "terminal_name"], "auth": False, "group": "trade"}, + "commodities_prices_history": {"params": ["id_terminal", "id_commodity", "game_version"], "auth": False, "group": "trade", "history": True}, "commodities_ranking": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "trade"}, "commodities_raw_averages": {"params": ["id_commodity", "commodity_name", "commodity_code", "commodity_slug"], "auth": False, "group": "mining"}, "commodities_raw_prices": {"params": ["id_terminal", "id_commodity", "terminal_name", "commodity_name"], "auth": False, "group": "mining"}, @@ -37,7 +37,7 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = { "contracts": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, "crew": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, "currencies_index": {"params": ["code"], "auth": False, "group": "reference"}, - "currencies_index_history": {"params": ["code"], "auth": False, "group": "reference"}, + "currencies_index_history": {"params": ["currency", "date_from", "date_to"], "auth": False, "group": "reference", "history": True}, "data_extract": {"params": ["table"], "auth": False, "group": "data"}, "data_parameters": {"params": ["endpoint"], "auth": False, "group": "data"}, "factions": {"params": ["id", "name", "slug"], "auth": False, "group": "reference"}, @@ -59,7 +59,26 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = { "marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"}, "marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True}, - "marketplace_prices_history": {"params": ["id_item", "id_listing", "item_name"], "auth": False, "group": "marketplace"}, + "marketplace_prices_history": { + "params": [ + "id_item", + "id_listing", + "id_terminal", + "id_star_system", + "id_category", + "item_uuid", + "item_name", + "operation", + "quality_tier", + "currency", + "game_version", + "date_start", + "date_end", + ], + "auth": False, + "group": "marketplace", + "history": True, + }, "marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"}, "moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, "orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"}, @@ -107,6 +126,12 @@ UEX_DELETE_RESOURCES = { "user_trades_remove", } +UEX_RESOURCE_DESCRIPTIONS = { + "commodities_prices_history": "Historical commodity prices at a terminal. Requires id_terminal and id_commodity; accepts game_version. UEX limits this to 500 rows.", + "marketplace_prices_history": "Historical marketplace price snapshots, one row per listing per price change. Requires at least one filter; supports date_start/date_end and up to 1000 records.", + "currencies_index_history": "Historical UEX currency index snapshots with basket component detail. Supports currency, date_from, and date_to timestamps.", +} + @dataclass class PendingAction: @@ -147,6 +172,10 @@ class ToolRegistry: self.handlers["uex_get"] = self.uex_get self.handlers["uex_draft_post"] = self.uex_draft_post self.handlers["uex_draft_delete"] = self.uex_draft_delete + self.handlers["search_uex_api_index"] = self.search_uex_api_index + self.handlers["summarize_uex_commodity_price_history"] = self.summarize_uex_commodity_price_history + self.handlers["summarize_uex_marketplace_price_history"] = self.summarize_uex_marketplace_price_history + self.handlers["summarize_uex_currency_index_history"] = self.summarize_uex_currency_index_history for resource in UEX_GET_RESOURCES: self.handlers[self._get_tool_name(resource)] = self._make_get_handler(resource) for resource in UEX_POST_RESOURCES: @@ -157,7 +186,9 @@ class ToolRegistry: @property def schemas(self) -> list[dict[str, Any]]: return [ + self._api_index_schema(), *self._uex_get_schemas(), + *self._history_summary_schemas(), *self._uex_post_schemas(), *self._uex_delete_schemas(), { @@ -457,6 +488,145 @@ class ToolRegistry: resource = self._validate_resource(resource, UEX_DELETE_RESOURCES) return self._pending(label or f"DELETE {resource}", resource, params or {}, method="DELETE") + async def search_uex_api_index( + self, + query: str = "", + group: str | None = None, + history_only: bool = False, + limit: int = 20, + ) -> dict[str, Any]: + needle = query.casefold().strip() + matches = [] + for resource, info in sorted(UEX_GET_RESOURCES.items()): + if group and info["group"] != group: + continue + if history_only and not info.get("history"): + continue + haystack = " ".join( + [ + resource, + info["group"], + " ".join(info["params"]), + UEX_RESOURCE_DESCRIPTIONS.get(resource, ""), + ] + ).casefold() + if needle and needle not in haystack: + continue + matches.append(self._resource_index_entry("GET", resource, info)) + if len(matches) >= max(1, min(limit, 50)): + break + + post_matches = [] + if not history_only: + for resource in sorted(UEX_POST_RESOURCES): + if group and group != "write": + continue + if needle and needle not in resource.casefold(): + continue + post_matches.append( + { + "method": "POST", + "resource": resource, + "tool": self._post_tool_name(resource), + "approval_required": True, + "docs_url": self._docs_url("post", resource), + } + ) + + delete_matches = [] + if not history_only: + for resource in sorted(UEX_DELETE_RESOURCES): + if group and group != "write": + continue + if needle and needle not in resource.casefold(): + continue + delete_matches.append( + { + "method": "DELETE", + "resource": resource, + "tool": self._delete_tool_name(resource), + "approval_required": True, + "docs_url": self._docs_url("delete", resource), + } + ) + + return { + "count": len(matches) + len(post_matches) + len(delete_matches), + "get": matches, + "post": post_matches[: max(0, min(limit, 50) - len(matches))], + "delete": delete_matches[: max(0, min(limit, 50) - len(matches) - len(post_matches))], + } + + async def summarize_uex_commodity_price_history( + self, + id_terminal: int, + id_commodity: int, + game_version: str | None = None, + limit: int = 100, + ) -> dict[str, Any]: + return await self._history_summary( + "commodities_prices_history", + {"id_terminal": id_terminal, "id_commodity": id_commodity, "game_version": game_version}, + value_fields=["price_buy", "price_sell", "scu_buy", "scu_sell", "scu_sell_stock"], + label_fields=["commodity_name", "terminal_name", "game_version"], + limit=limit, + ) + + async def summarize_uex_marketplace_price_history( + self, + id_item: str | int | None = None, + id_listing: int | None = None, + id_terminal: int | None = None, + id_star_system: int | None = None, + id_category: int | None = None, + item_uuid: str | None = None, + item_name: str | None = None, + operation: str | None = None, + quality_tier: int | None = None, + currency: str | None = None, + game_version: str | None = None, + date_start: str | None = None, + date_end: str | None = None, + limit: int = 250, + ) -> dict[str, Any]: + params = { + "id_item": id_item, + "id_listing": id_listing, + "id_terminal": id_terminal, + "id_star_system": id_star_system, + "id_category": id_category, + "item_uuid": item_uuid, + "item_name": item_name, + "operation": operation, + "quality_tier": quality_tier, + "currency": currency, + "game_version": game_version, + "date_start": date_start, + "date_end": date_end, + } + return await self._history_summary( + "marketplace_prices_history", + params, + value_fields=["price", "quality"], + label_fields=["item_name", "operation", "currency", "terminal_name", "game_version"], + limit=limit, + ) + + async def summarize_uex_currency_index_history( + self, + currency: str | None = None, + date_from: int | None = None, + date_to: int | None = None, + limit: int = 365, + ) -> dict[str, Any]: + return await self._history_summary( + "currencies_index_history", + {"currency": currency, "date_from": date_from, "date_to": date_to}, + value_fields=["index_value", "basket_value", "data_window_days"], + label_fields=["currency", "methodology"], + limit=limit, + ) + def _make_get_handler(self, resource: str) -> ToolHandler: async def handler(**arguments: Any) -> dict[str, Any]: fields = arguments.pop("fields", None) @@ -502,6 +672,95 @@ class ToolRegistry: for resource, info in sorted(UEX_GET_RESOURCES.items()) ] + @classmethod + def _api_index_schema(cls) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": "search_uex_api_index", + "description": "Search the indexed UEX API tool catalog by topic, resource, parameter, or group. Use to discover exact tool names, especially history tools.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "group": { + "type": "string", + "enum": ["trade", "marketplace", "items", "vehicles", "locations", "mining", "user", "reference", "data", "write"], + }, + "history_only": {"type": "boolean", "default": False}, + "limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}, + }, + }, + }, + } + + @classmethod + def _history_summary_schemas(cls) -> list[dict[str, Any]]: + controls = { + "limit": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 250}, + } + return [ + { + "type": "function", + "function": { + "name": "summarize_uex_commodity_price_history", + "description": "Summarize historical commodity price and inventory changes for one commodity at one terminal.", + "parameters": { + "type": "object", + "required": ["id_terminal", "id_commodity"], + "properties": { + "id_terminal": {"type": "integer"}, + "id_commodity": {"type": "integer"}, + "game_version": {"type": "string"}, + **controls, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "summarize_uex_marketplace_price_history", + "description": "Summarize marketplace historical price snapshots for an item, listing, terminal, category, system, or date range.", + "parameters": { + "type": "object", + "properties": { + "id_item": {"oneOf": [{"type": "integer"}, {"type": "string"}]}, + "id_listing": {"type": "integer"}, + "id_terminal": {"type": "integer"}, + "id_star_system": {"type": "integer"}, + "id_category": {"type": "integer"}, + "item_uuid": {"type": "string"}, + "item_name": {"type": "string"}, + "operation": {"type": "string", "enum": ["buy", "sell"]}, + "quality_tier": {"type": "integer", "minimum": 0, "maximum": 4}, + "currency": {"type": "string"}, + "game_version": {"type": "string"}, + "date_start": {"type": "string", "description": "YYYY-MM-DD"}, + "date_end": {"type": "string", "description": "YYYY-MM-DD"}, + **controls, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "summarize_uex_currency_index_history", + "description": "Summarize historical UEX currency index snapshots and basket value changes.", + "parameters": { + "type": "object", + "properties": { + "currency": {"type": "string"}, + "date_from": {"type": "integer", "description": "Unix timestamp."}, + "date_to": {"type": "integer", "description": "Unix timestamp."}, + **controls, + }, + }, + }, + }, + ] + @classmethod def _uex_post_schemas(cls) -> list[dict[str, Any]]: return [ @@ -566,7 +825,7 @@ class ToolRegistry: @staticmethod def _query_param_schema(param: str) -> dict[str, Any]: - if param == "id" or param.startswith("id_"): + if param == "id" or param.startswith("id_") or param in {"date_from", "date_to", "quality_tier"}: return {"type": "integer"} return {"type": "string"} @@ -574,7 +833,11 @@ class ToolRegistry: def _get_tool_description(resource: str, info: dict[str, Any]) -> str: auth = " Authenticated." if info["auth"] else "" heavy = " Heavy endpoint; use fields and limit." if info.get("heavy") else "" - return f"GET UEX /{resource}/ with compact, token-limited results.{auth}{heavy}" + history = " History endpoint." if info.get("history") else "" + description = UEX_RESOURCE_DESCRIPTIONS.get(resource) + if description: + return f"GET UEX /{resource}/ with compact, token-limited results. {description}{auth}{heavy}" + return f"GET UEX /{resource}/ with compact, token-limited results.{history}{auth}{heavy}" @staticmethod def _get_tool_name(resource: str) -> str: @@ -588,6 +851,25 @@ class ToolRegistry: def _delete_tool_name(resource: str) -> str: return f"delete_uex_{resource}" + @classmethod + def _resource_index_entry(cls, method: str, resource: str, info: dict[str, Any]) -> dict[str, Any]: + return { + "method": method, + "resource": resource, + "tool": cls._get_tool_name(resource), + "group": info["group"], + "params": info["params"], + "authenticated": info["auth"], + "history": bool(info.get("history")), + "heavy": bool(info.get("heavy")), + "description": UEX_RESOURCE_DESCRIPTIONS.get(resource, ""), + "docs_url": cls._docs_url("get", resource), + } + + @staticmethod + def _docs_url(method: str, resource: str) -> str: + return f"https://uexcorp.space/api/documentation/id/{method}_{resource}/" + async def search_marketplace_listings( self, query: str | None = None, @@ -792,6 +1074,82 @@ class ToolRegistry: return " ".join(str(value) for value in item.values() if isinstance(value, (str, int, float))).casefold() return str(item).casefold() + async def _history_summary( + self, + resource: str, + params: dict[str, Any], + value_fields: list[str], + label_fields: list[str], + limit: int, + ) -> dict[str, Any]: + info = UEX_GET_RESOURCES[resource] + cleaned_params = self._filter_params(params, info["params"]) + response = await self.uex.get(resource, cleaned_params, authenticated=bool(info["auth"])) + rows = [ + row + for row in self._as_list(response.get("data")) + if isinstance(row, dict) + ][: max(1, min(limit, 1000))] + rows_sorted = sorted(rows, key=lambda row: int(row.get("date_added") or 0)) + latest = rows_sorted[-1] if rows_sorted else {} + earliest = rows_sorted[0] if rows_sorted else {} + summaries = { + field: self._numeric_history_summary(rows_sorted, field) + for field in value_fields + if any(self._is_number(row.get(field)) for row in rows_sorted) + } + labels = { + field: latest.get(field) + for field in label_fields + if latest.get(field) not in (None, "") + } + sample_fields = ["id", "date_added", *label_fields, *value_fields] + recent = [ + self._project_item(row, fields=sample_fields, mode="summary") + for row in list(reversed(rows_sorted[-5:])) + ] + return { + "status": response.get("status"), + "resource": resource, + "params": cleaned_params, + "count": len(rows), + "date_start": earliest.get("date_added"), + "date_end": latest.get("date_added"), + "labels": labels, + "metrics": summaries, + "recent": recent, + "docs_url": self._docs_url("get", resource), + } + + @classmethod + def _numeric_history_summary(cls, rows: list[dict[str, Any]], field: str) -> dict[str, Any]: + points = [ + (int(row.get("date_added") or 0), float(row[field])) + for row in rows + if cls._is_number(row.get(field)) + ] + values = [value for _, value in points] + first_date, first_value = points[0] + last_date, last_value = points[-1] + change = last_value - first_value + pct_change = (change / first_value * 100) if first_value else None + return { + "first": first_value, + "first_date": first_date, + "latest": last_value, + "latest_date": last_date, + "min": min(values), + "max": max(values), + "avg": round(sum(values) / len(values), 4), + "change": round(change, 4), + "pct_change": round(pct_change, 4) if pct_change is not None else None, + "points": len(points), + } + + @staticmethod + def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + @staticmethod def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: return {