feat: history tools

This commit is contained in:
2026-05-06 13:16:27 -04:00
parent 5850674448
commit da016c23cb
3 changed files with 453 additions and 5 deletions
+5
View File
@@ -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",
+363 -5
View File
@@ -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 {