From 97c751c5859bd8263adc3a7362de40cbe6cb8ef7 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Fri, 8 May 2026 00:02:59 -0400 Subject: [PATCH] versioning: 0.0.4, feat: create listing, source image --- pyproject.toml | 3 +- tests/test_tools.py | 66 +++++++++++++- traderai/agent.py | 5 +- traderai/cornerstone_client.py | 69 +++++++++++++- traderai/tools.py | 160 ++++++++++++++++++++++++++++++++- traderai/version.py | 3 +- uv.lock | 3 +- 7 files changed, 297 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf20c8b..8d2e789 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "traderai" -version = "0.0.3" +version = "0.0.4" description = "Local Ollama-powered assistant for UEX marketplace workflows." requires-python = ">=3.11" dependencies = [ @@ -37,3 +37,4 @@ include = ["traderai*"] + diff --git a/tests/test_tools.py b/tests/test_tools.py index e3972dc..2a0aa26 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -230,7 +230,10 @@ class FakeCornerstone: "url": f"{self.base_url}/ShipSalvageMods1/{item_id}", "html": """ - Star Citizen - Salvage modifier - Abrade Scraper Module + + Star Citizen - Salvage modifier - Abrade Scraper Module + + @@ -246,6 +249,15 @@ class FakeCornerstone: """, } + async def get_image_data(self, url, max_bytes=10_000_000): + assert url == f"{self.base_url}/images/abrade.png" + return { + "url": url, + "content_type": "image/png", + "size_bytes": 12, + "image_data": "ZmFrZS1pbWFnZQ==", + } + @pytest.mark.asyncio async def test_search_marketplace_listings_filters_locally(): @@ -379,6 +391,8 @@ def test_schemas_expose_cornerstone_item_tools(): assert "search_cornerstone_items" in names assert "get_cornerstone_item_locations" in names + assert "get_cornerstone_item_media" in names + assert "draft_marketplace_listing_with_cornerstone_image" in names @pytest.mark.asyncio @@ -441,18 +455,64 @@ async def test_get_cornerstone_item_locations_parses_store_prices(): ] +@pytest.mark.asyncio +async def test_get_cornerstone_item_media_returns_absolute_image_urls(): + registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) + + result = await registry.get_cornerstone_item_media(query="abrade") + + assert result["media"] == [ + { + "url": "https://finder.cstone.test/images/abrade.png", + "source": "og:image", + } + ] + + +@pytest.mark.asyncio +async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display(): + registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) + + result = await registry.draft_marketplace_listing_with_cornerstone_image( + item_query="abrade", + id_category=3, + operation="sell", + type="item", + unit="unit", + title="Abrade Scraper Module", + description="Clean module, ready for pickup.", + price=21250, + currency="UEC", + language="en_US", + source="purchased_in_game", + in_stock=1, + ) + + pending = result["pending_action"] + stored = registry.pending_actions[pending["id"]] + + assert pending["endpoint"] == "marketplace_advertise" + assert pending["payload"]["image_data"].startswith("Star Citizen - Food - Whamburger + Star Citizen - Food - Whamburger
NAMEAbrade Scraper Module
NAMEWhamburger
+ Whamburger
LOCATIONBASE PRICEVERIFIED
Stanton - Area18 - Cubby Blast92956-01-01
- """ + """, + "https://finder.cstone.test/Search/item-wham", ) assert parsed["name"] == "Whamburger" assert parsed["locations"][0]["base_price"] == 9 + assert parsed["media"][0]["url"] == "https://finder.cstone.test/img/wham.png" + assert parsed["media"][1]["url"] == "https://example.test/extra.png" @pytest.mark.asyncio diff --git a/traderai/agent.py b/traderai/agent.py index 576f3f2..169e42e 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -20,6 +20,7 @@ Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_p 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. +When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone. 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. @@ -474,7 +475,7 @@ class OllamaAgent: "label": action.label, "method": action.method, "endpoint": action.endpoint, - "payload": action.payload, + "payload": self.tools._display_payload(action.payload) if hasattr(self.tools, "_display_payload") else action.payload, "metadata": action.metadata or {}, } for action in self.tools.pending_actions.values() @@ -524,6 +525,7 @@ class OllamaAgent: "get_scmdb_mission_rewards": "Fetching SCMDB mission rewards", "search_cornerstone_items": "Searching Cornerstone items", "get_cornerstone_item_locations": "Fetching Cornerstone item locations", + "get_cornerstone_item_media": "Fetching Cornerstone item media", "uex_api_catalog": "Checking UEX API catalog", "uex_get": "Fetching UEX data", "uex_draft_post": "Drafting UEX write for approval", @@ -534,6 +536,7 @@ class OllamaAgent: "get_negotiation_messages": "Reading negotiation messages", "draft_negotiation_message": "Drafting message for approval", "draft_marketplace_listing": "Drafting listing for approval", + "draft_marketplace_listing_with_cornerstone_image": "Drafting listing with Cornerstone image", "check_uex_notifications": "Checking UEX notifications", } return labels.get(name, f"Running {name}") diff --git a/traderai/cornerstone_client.py b/traderai/cornerstone_client.py index fda744f..cb45ff4 100644 --- a/traderai/cornerstone_client.py +++ b/traderai/cornerstone_client.py @@ -1,8 +1,10 @@ from __future__ import annotations from html.parser import HTMLParser +import base64 import json from typing import Any +from urllib.parse import urljoin import httpx @@ -41,6 +43,23 @@ class CornerstoneClient: raise CornerstoneError(f"Cornerstone HTTP {response.status_code}: {response.text[:240]}") return {"url": str(response.url), "html": response.text} + async def get_image_data(self, url: str, max_bytes: int = 10_000_000) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + response = await client.get(url, headers={"Accept": "image/png,image/jpeg,image/*"}) + if response.status_code >= 400: + raise CornerstoneError(f"Cornerstone image HTTP {response.status_code}: {response.text[:240]}") + content_type = response.headers.get("content-type", "").split(";")[0].strip().casefold() + if content_type not in {"image/jpeg", "image/jpg", "image/png"}: + raise CornerstoneError(f"Cornerstone image was not JPG or PNG: {content_type or 'unknown content type'}") + if len(response.content) > max_bytes: + raise CornerstoneError(f"Cornerstone image is larger than {max_bytes} bytes.") + return { + "url": str(response.url), + "content_type": content_type, + "size_bytes": len(response.content), + "image_data": base64.b64encode(response.content).decode("ascii"), + } + 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"}) @@ -58,6 +77,7 @@ class CornerstonePageParser(HTMLParser): super().__init__(convert_charrefs=True) self.title = "" self.tables: list[list[list[str]]] = [] + self.images: list[dict[str, str]] = [] self._skip_depth = 0 self._in_title = False self._current_table: list[list[str]] | None = None @@ -73,6 +93,29 @@ class CornerstonePageParser(HTMLParser): return if tag == "title": self._in_title = True + elif tag == "meta": + attr_map = self._attrs(attrs) + name = (attr_map.get("property") or attr_map.get("name") or "").casefold() + content = attr_map.get("content") or "" + if content and name in {"og:image", "twitter:image", "twitter:image:src"}: + self.images.append({"url": content, "source": name}) + elif tag == "link": + attr_map = self._attrs(attrs) + rel = (attr_map.get("rel") or "").casefold() + href = attr_map.get("href") or "" + if href and "image_src" in rel: + self.images.append({"url": href, "source": "link:image_src"}) + elif tag == "img": + attr_map = self._attrs(attrs) + url = attr_map.get("src") or attr_map.get("data-src") or attr_map.get("data-original") or "" + if url: + self.images.append( + { + "url": url, + "alt": attr_map.get("alt") or "", + "source": "img", + } + ) elif tag == "table": self._current_table = [] elif tag == "tr" and self._current_table is not None: @@ -110,8 +153,12 @@ class CornerstonePageParser(HTMLParser): if self._current_cell is not None: self._current_cell.append(data) + @staticmethod + def _attrs(attrs: list[tuple[str, str | None]]) -> dict[str, str]: + return {key.casefold(): value or "" for key, value in attrs} -def parse_cornerstone_item_page(html: str) -> dict[str, Any]: + +def parse_cornerstone_item_page(html: str, page_url: str | None = None) -> dict[str, Any]: parser = CornerstonePageParser() parser.feed(html) info: dict[str, Any] = {"page_title": " ".join(parser.title.split())} @@ -142,6 +189,9 @@ def parse_cornerstone_item_page(html: str) -> dict[str, Any]: general[key] = value info["name"] = general.get("name") or _name_from_title(info["page_title"]) + media = _dedupe_media(parser.images, page_url) + if media: + info["media"] = media if general: info["general"] = general info["locations"] = locations @@ -157,3 +207,20 @@ def _name_from_title(title: str) -> str | None: if " - " not in title: return title or None return title.rsplit(" - ", 1)[-1].strip() or None + + +def _dedupe_media(images: list[dict[str, str]], page_url: str | None = None) -> list[dict[str, str]]: + media = [] + seen = set() + for image in images: + raw_url = (image.get("url") or "").strip() + if not raw_url or raw_url.startswith("data:"): + continue + url = urljoin(page_url or "", raw_url) + if url in seen: + continue + seen.add(url) + item = dict(image) + item["url"] = url + media.append(item) + return media diff --git a/traderai/tools.py b/traderai/tools.py index 1f9afb4..6d6cb17 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -196,6 +196,8 @@ class ToolRegistry: "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, + "get_cornerstone_item_media": self.get_cornerstone_item_media, + "draft_marketplace_listing_with_cornerstone_image": self.draft_marketplace_listing_with_cornerstone_image, } self.handlers["uex_api_catalog"] = self.uex_api_catalog self.handlers["uex_get"] = self.uex_get @@ -311,7 +313,7 @@ class ToolRegistry: "type": "function", "function": { "name": "draft_marketplace_listing", - "description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting.", + "description": "Draft a new UEX marketplace listing. Listing prices are in-game aUEC/UEC credits, not real-world dollars. This creates a pending action that must be approved before posting. Prefer draft_marketplace_listing_with_cornerstone_image for item posts when a Cornerstone image is useful.", "parameters": { "type": "object", "required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], @@ -332,8 +334,12 @@ class ToolRegistry: "source": {"type": "string"}, "availability": {"type": "string"}, "in_stock": {"type": "integer"}, + "durability": {"type": "integer", "minimum": 0, "maximum": 100}, + "video_url": {"type": "string"}, + "image_data": {"type": "string", "description": "Base64 JPG or PNG image data for UEX upload."}, "hours_expiration": {"type": "integer"}, "is_hidden": {"type": "integer", "enum": [0, 1]}, + "is_tv_allowed": {"type": "integer", "enum": [0, 1]}, "is_production": {"type": "integer", "enum": [0, 1], "default": 1}, }, }, @@ -512,7 +518,7 @@ class ToolRegistry: "label": action.label, "method": action.method, "endpoint": action.endpoint, - "payload": action.payload, + "payload": self._display_payload(action.payload), "metadata": action.metadata or {}, }, } @@ -973,6 +979,60 @@ class ToolRegistry: }, }, }, + { + "type": "function", + "function": { + "name": "get_cornerstone_item_media", + "description": "Fetch Cornerstone item page media, especially image URLs that can be used when drafting UEX marketplace listings.", + "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."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5}, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "draft_marketplace_listing_with_cornerstone_image", + "description": "Draft a UEX marketplace listing and source the listing image from Cornerstone. The image is downloaded as base64 image_data and included in the pending action. Nothing is posted until user approval.", + "parameters": { + "type": "object", + "required": ["item_query", "id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], + "properties": { + "item_query": {"type": "string", "description": "Cornerstone item name to source an image from."}, + "cornerstone_id": {"type": "string", "description": "Cornerstone item id, if already known."}, + "id_item": {"type": "integer"}, + "id_star_system": {"type": "integer"}, + "id_terminal": {"type": "integer"}, + "id_organization": {"type": "integer"}, + "id_category": {"type": "integer"}, + "operation": {"type": "string", "enum": ["buy", "sell", "rent", "trade"]}, + "type": {"type": "string", "enum": ["item", "service", "contract"]}, + "unit": {"type": "string"}, + "title": {"type": "string"}, + "description": {"type": "string"}, + "price": {"type": "number"}, + "currency": {"type": "string", "enum": ["UEC"]}, + "language": {"type": "string", "default": "en_US"}, + "location": {"type": "string"}, + "source": {"type": "string", "enum": ["looted", "pledged", "purchased_in_game", "pirated", "gifted"]}, + "availability": {"type": "string"}, + "in_stock": {"type": "integer"}, + "durability": {"type": "integer", "minimum": 0, "maximum": 100}, + "video_url": {"type": "string"}, + "hours_expiration": {"type": "integer"}, + "is_hidden": {"type": "integer", "enum": [0, 1]}, + "is_tv_allowed": {"type": "integer", "enum": [0, 1]}, + "is_production": {"type": "integer", "enum": [0, 1], "default": 1}, + "require_image": {"type": "boolean", "default": False, "description": "Return an error instead of drafting if no Cornerstone JPG/PNG image can be sourced."}, + }, + }, + }, + }, ] @classmethod @@ -1167,6 +1227,55 @@ class ToolRegistry: async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]: return self._pending("Post marketplace listing", "marketplace_advertise", payload) + async def draft_marketplace_listing_with_cornerstone_image( + self, + item_query: str, + cornerstone_id: str | None = None, + **payload: Any, + ) -> dict[str, Any]: + require_image = bool(payload.pop("require_image", False)) + item = await self._resolve_cornerstone_item(id=cornerstone_id, query=item_query) + if not item: + return {"error": "No Cornerstone item matched. Provide cornerstone_id or a more specific item_query."} + + page = await self.cornerstone.get_item_page(str(item["id"])) + parsed = parse_cornerstone_item_page(page["html"], page["url"]) + media = parsed.get("media") or [] + image_result: dict[str, Any] | None = None + image_error = "" + for media_item in media: + try: + image_result = await self.cornerstone.get_image_data(media_item["url"]) + break + except Exception as exc: + image_error = str(exc) + + if image_result: + payload["image_data"] = image_result["image_data"] + elif require_image: + return { + "error": "Cornerstone item matched, but no usable JPG/PNG image could be sourced.", + "cornerstone": { + "item": {"id": item.get("id"), "name": parsed.get("name") or item.get("name")}, + "url": page["url"], + "media": media, + "image_error": image_error, + }, + } + + payload.setdefault("id_item", self._int_or_none(item.get("id"))) + metadata = { + "cornerstone_item_id": item.get("id"), + "cornerstone_item_name": parsed.get("name") or item.get("name"), + "cornerstone_url": page["url"], + "cornerstone_image_url": image_result.get("url") if image_result else None, + "cornerstone_image_content_type": image_result.get("content_type") if image_result else None, + "cornerstone_image_size_bytes": image_result.get("size_bytes") if image_result else None, + "cornerstone_image_status": "included" if image_result else "not_found", + "cornerstone_image_error": image_error or None, + } + return self._pending("Post marketplace listing with Cornerstone image", "marketplace_advertise", payload, metadata=metadata) + async def remember_user_fact(self, content: str, kind: str = "note", importance: int = 3) -> dict[str, Any]: if self.memory is None: return {"error": "Memory store is not configured."} @@ -1430,7 +1539,7 @@ class ToolRegistry: 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"]) + parsed = parse_cornerstone_item_page(page["html"], page["url"]) locations = parsed.get("locations") or [] location_filter = (location or "").casefold().strip() if location_filter: @@ -1455,6 +1564,34 @@ class ToolRegistry: "locations": locations[:limit], } + async def get_cornerstone_item_media( + self, + id: str | None = None, + query: str | None = None, + limit: int = 5, + ) -> 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"], page["url"]) + media = parsed.get("media") or [] + limit = max(1, min(limit, 10)) + 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 {}, + }, + "returned": min(len(media), limit), + "truncated": len(media) > limit, + "media": media[:limit], + } + def _pending( self, label: str, @@ -1474,12 +1611,27 @@ class ToolRegistry: "label": label, "method": method, "endpoint": endpoint, - "payload": payload, + "payload": self._display_payload(payload), "metadata": metadata, "approval_required": self.require_write_approval, } } + @staticmethod + def _display_payload(payload: dict[str, Any]) -> dict[str, Any]: + display = dict(payload) + image_data = display.get("image_data") + if isinstance(image_data, str) and image_data: + display["image_data"] = f"" + return display + + @staticmethod + def _int_or_none(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + def _record_pending_action_result(self, action: PendingAction, result_kind: str, result: dict[str, Any]) -> None: metadata = action.metadata or {} plan_id = metadata.get("plan_id") diff --git a/traderai/version.py b/traderai/version.py index 8bf45f7..8bce068 100644 --- a/traderai/version.py +++ b/traderai/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -__version__ = "0.0.3" +__version__ = "0.0.4" RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases" RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases" @@ -9,3 +9,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo + diff --git a/uv.lock b/uv.lock index 43a20bf..20b5304 100644 --- a/uv.lock +++ b/uv.lock @@ -755,7 +755,7 @@ wheels = [ [[package]] name = "traderai" -version = "0.0.3" +version = "0.0.4" source = { virtual = "." } dependencies = [ { name = "apscheduler" }, @@ -1049,3 +1049,4 @@ wheels = [ +