From 761eda61558f83912d8c9beec0b31297dd30ac3f Mon Sep 17 00:00:00 2001 From: HRiggs Date: Tue, 5 May 2026 20:05:33 -0400 Subject: [PATCH] agent: look at current info, its aUEC, feat: pull up notifcations. --- .env.example | 2 ++ README.md | 10 ++++-- tests/test_agent.py | 15 ++++++++- tests/test_scheduler.py | 47 +++++++++++++++++++++++++++++ tests/test_tools.py | 13 ++++++++ traderai/agent.py | 14 ++++++++- traderai/config.py | 2 ++ traderai/scheduler.py | 67 +++++++++++++++++++++++++++++++++++++++++ traderai/server.py | 10 +++++- traderai/tools.py | 19 ++++++++++-- traderai/uex_client.py | 7 +++++ 11 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/.env.example b/.env.example index 1d9d7a5..b0ba249 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=qwen3.5:9b +OLLAMA_NUM_CTX=64000 UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_SECRET_KEY= UEX_BEARER_TOKEN= TRADERAI_USER_NAME= TRADERAI_MEMORY_PATH=data/traderai.sqlite3 +UEX_NOTIFICATION_POLL_SECONDS=60 REQUIRE_WRITE_APPROVAL=true diff --git a/README.md b/README.md index da46913..a550d96 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ Local Ollama-powered chat for UEX marketplace workflows. ## What It Does -- Searches active UEX marketplace listings through `GET /marketplace_listings/`. +- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. - Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set. - Drafts negotiation messages and marketplace listings as pending actions. - Requires browser approval before sending authenticated write requests to UEX. - Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context. - Can create one-time or recurring wake jobs that prompt the assistant later and surface the result in the UI. - Loads the configured UEX user profile from `GET /user` so the assistant knows the current account username, display name, timezone, language, and marketplace-relevant profile details. +- Polls authenticated `GET /user_notifications` for unread UEX notifications and surfaces new pending alerts in the chat notification queue. ## Setup @@ -33,11 +34,11 @@ Local Ollama-powered chat for UEX marketplace workflows. ## Notes -Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. +Ollama runs locally at `http://localhost:11434` by default. This app talks to Ollama's native chat API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64000` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. UEX marketplace posting and negotiation messages are guarded because they are account-affecting write actions. The model can draft them, but the UI approval button performs the final API call. -The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. Memory is stored locally at `TRADERAI_MEMORY_PATH`. +The assistant gets runtime context on every chat: current date/time, authenticated UEX identity when credentials are configured, remembered user profile, last interaction time, relevant memories, and recent conversation excerpts. It is instructed to prefer open/current marketplace data, avoid historical sale information unless explicitly requested, and treat UEX prices as in-game aUEC/UEC credits rather than real-world dollars. Memory is stored locally at `TRADERAI_MEMORY_PATH`. Wake jobs can be created from chat, for example: @@ -53,6 +54,8 @@ Every day at 9 AM, wake up and check whether I have marketplace followups. The scheduler accepts one-time ISO datetimes and five-field cron expressions through the `schedule_wake_job` tool. When a wake job fires, the assistant receives context like the current time and last interaction time, then places its response into the UI notification queue. +UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by default. New unread notifications are deduplicated locally, then displayed in the chat through the same notification queue used by wake jobs. + ## Sources Used - UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 @@ -60,6 +63,7 @@ The scheduler accepts one-time ISO datetimes and five-field cron expressions thr - 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 API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md +- Ollama context length docs: https://docs.ollama.com/context-length - SQLite FTS5 docs: https://www.sqlite.org/fts5.html - APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.html - Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta diff --git a/tests/test_agent.py b/tests/test_agent.py index cecee13..9727d88 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,6 +1,6 @@ import pytest -from traderai.agent import OllamaAgent +from traderai.agent import OllamaAgent, SYSTEM_PROMPT from traderai.memory import MemoryStore @@ -77,3 +77,16 @@ def test_stream_metrics_include_reading_and_writing_rates(): assert metrics["reading_tokens_per_second"] == 10 assert metrics["writing_tokens"] == 30 assert metrics["writing_tokens_per_second"] == 10 + + +def test_system_prompt_prefers_current_marketplace_data(): + assert "open/current" in SYSTEM_PROMPT + assert "Do not use historical sale data" in SYSTEM_PROMPT + assert "aUEC/UEC credits" in SYSTEM_PROMPT + assert "never real-world dollars" in SYSTEM_PROMPT + + +def test_ollama_options_include_num_ctx(): + agent = OllamaAgent("http://127.0.0.1:1", "missing-model", EmptyTools(), num_ctx=64000) + + assert agent._ollama_options() == {"num_ctx": 64000} diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..f69e991 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,47 @@ +import pytest + +from traderai.memory import MemoryStore +from traderai.scheduler import WakeScheduler + + +class FakeUEXNotifications: + def __init__(self): + self.calls = 0 + + async def get_user_notifications(self): + self.calls += 1 + return { + "status": "ok", + "notifications": [ + { + "id": 10, + "message": "A buyer replied to your listing.", + "redir": "/marketplace/negotiations/abc", + "code": "negotiation_reply", + "date_added": 123, + "date_read": 0, + }, + { + "id": 11, + "message": "Already read.", + "date_added": 122, + "date_read": 123, + }, + ], + } + + +@pytest.mark.asyncio +async def test_poll_uex_notifications_adds_unread_once(tmp_path): + memory = MemoryStore(str(tmp_path / "memory.sqlite3")) + scheduler = WakeScheduler(memory) + scheduler.bind_uex_notifications(FakeUEXNotifications()) + + first = await scheduler.poll_uex_notifications() + second = await scheduler.poll_uex_notifications() + outbox = memory.inspect()["outbox"] + + assert len(first) == 1 + assert second == [] + assert len(outbox) == 1 + assert "A buyer replied to your listing." in outbox[0]["content"] diff --git a/tests/test_tools.py b/tests/test_tools.py index 2802cd6..afd7f44 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -81,3 +81,16 @@ async def test_uex_client_get_user_normalizes_user_payload(): result = await client.get_user(authenticated=True) assert result == {"status": "ok", "user": {"username": "pilot_hudson"}} + + +@pytest.mark.asyncio +@respx.mock +async def test_uex_client_get_user_notifications_normalizes_payload(): + respx.get("https://api.uexcorp.space/2.0/user_notifications/").mock( + return_value=Response(200, json={"status": "ok", "data": {"id": 7, "message": "Reply waiting", "date_read": 0}}) + ) + client = UEXClient("https://api.uexcorp.space/2.0", bearer_token="bearer") + + result = await client.get_user_notifications() + + assert result == {"status": "ok", "notifications": [{"id": 7, "message": "Reply waiting", "date_read": 0}]} diff --git a/traderai/agent.py b/traderai/agent.py index 643bcde..00abdbb 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -12,7 +12,9 @@ from traderai.tools import ToolRegistry SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. -Use tools when the user asks about listings, negotiations, messages, offers, or posting ads. +Use tools when the user asks about open/current listings, active negotiations, unread notifications, messages, offers, or posting ads. +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. Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next.""" @@ -25,12 +27,14 @@ class OllamaAgent: tools: ToolRegistry, memory: MemoryStore | None = None, user_name: str | None = None, + num_ctx: int | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.model = model self.tools = tools self.memory = memory self.user_name = user_name + self.num_ctx = num_ctx self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] async def health(self) -> dict[str, Any]: @@ -165,6 +169,7 @@ class OllamaAgent: "model": self.model, "messages": self._messages_with_context(query, previous_interaction=previous_interaction), "tools": self.tools.schemas, + "options": self._ollama_options(), "stream": False, }, ) @@ -184,6 +189,7 @@ class OllamaAgent: "model": self.model, "messages": self._messages_with_context(query, previous_interaction=previous_interaction), "tools": self.tools.schemas, + "options": self._ollama_options(), "stream": True, }, ) as response: @@ -258,6 +264,11 @@ class OllamaAgent: for action in self.tools.pending_actions.values() ] + def _ollama_options(self) -> dict[str, Any]: + if not self.num_ctx: + return {} + return {"num_ctx": self.num_ctx} + @staticmethod def _tool_status(name: str) -> str: labels = { @@ -267,6 +278,7 @@ class OllamaAgent: "get_negotiation_messages": "Reading negotiation messages", "draft_negotiation_message": "Drafting message for approval", "draft_marketplace_listing": "Drafting listing for approval", + "check_uex_notifications": "Checking UEX notifications", } return labels.get(name, f"Running {name}") diff --git a/traderai/config.py b/traderai/config.py index 181bd72..6fdf584 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -9,11 +9,13 @@ class Settings(BaseSettings): ollama_base_url: str = "http://localhost:11434" ollama_model: str = "qwen3.5:9b" + ollama_num_ctx: int = 64000 uex_base_url: str = "https://api.uexcorp.space/2.0" uex_secret_key: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None) traderai_memory_path: str = "data/traderai.sqlite3" + uex_notification_poll_seconds: int = 60 require_write_approval: bool = True diff --git a/traderai/scheduler.py b/traderai/scheduler.py index d1eede3..1bba121 100644 --- a/traderai/scheduler.py +++ b/traderai/scheduler.py @@ -7,23 +7,34 @@ from uuid import uuid4 from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger from tzlocal import get_localzone from traderai.memory import MemoryStore, iso_now, time_since +UEX_NOTIFICATION_JOB_ID = "uex-notification-poll" + + class WakeScheduler: def __init__(self, memory: MemoryStore) -> None: self.memory = memory self.scheduler = AsyncIOScheduler(timezone=get_localzone()) self.agent = None + self.uex = None + self.notification_poll_seconds = 60 def bind_agent(self, agent: Any) -> None: self.agent = agent + def bind_uex_notifications(self, uex: Any, poll_seconds: int = 60) -> None: + self.uex = uex + self.notification_poll_seconds = max(15, poll_seconds) + def start(self) -> None: if not self.scheduler.running: self.scheduler.start() + self._schedule_notification_poll() for job in self.memory.list_jobs(): self._schedule_existing(job) @@ -77,3 +88,59 @@ class WakeScheduler: text = await self.agent.generate_wake_response(wake_message) self.memory.add_outbox(text) self.memory.mark_job_run(job_id) + + def _schedule_notification_poll(self) -> None: + if self.uex is None: + return + self.scheduler.add_job( + self.poll_uex_notifications, + trigger=IntervalTrigger(seconds=self.notification_poll_seconds), + id=UEX_NOTIFICATION_JOB_ID, + replace_existing=True, + next_run_time=datetime.now(), + ) + + async def poll_uex_notifications(self) -> list[dict[str, Any]]: + if self.uex is None: + return [] + + response = await self.uex.get_user_notifications() + notifications = response.get("notifications") or [] + pending = [item for item in notifications if not item.get("date_read")] + profile = self.memory.get_profile() + seen = set(profile.get("uex_seen_notification_keys") or []) + new_pending = [item for item in pending if self._notification_key(item) not in seen] + + if new_pending: + for item in new_pending: + self.memory.add_outbox(self._notification_text(item)) + seen.update(self._notification_key(item) for item in new_pending) + self.memory.set_profile("uex_seen_notification_keys", sorted(seen)) + self.memory.set_profile("uex_last_notification_check", iso_now()) + elif notifications: + seen.update(self._notification_key(item) for item in pending) + self.memory.set_profile("uex_seen_notification_keys", sorted(seen)) + self.memory.set_profile("uex_last_notification_check", iso_now()) + + return new_pending + + @staticmethod + def _notification_key(item: dict[str, Any]) -> str: + for key in ("code", "id"): + value = item.get(key) + if value not in (None, ""): + return f"{key}:{value}" + return f"notification:{item.get('date_added')}:{item.get('message')}" + + @staticmethod + def _notification_text(item: dict[str, Any]) -> str: + message = item.get("message") or "You have a pending UEX notification." + redir = item.get("redir") + code = item.get("code") + details = [] + if code: + details.append(f"code `{code}`") + if redir: + details.append(f"path `{redir}`") + suffix = f" ({', '.join(details)})" if details else "" + return f"UEX notification: {message}{suffix}" diff --git a/traderai/server.py b/traderai/server.py index c38c2e0..ef47546 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -35,8 +35,16 @@ def create_app() -> FastAPI: scheduler = WakeScheduler(memory) 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) - agent = OllamaAgent(settings.ollama_base_url, settings.ollama_model, tools, memory=memory, user_name=settings.traderai_user_name) + agent = OllamaAgent( + settings.ollama_base_url, + settings.ollama_model, + tools, + memory=memory, + user_name=settings.traderai_user_name, + num_ctx=settings.ollama_num_ctx, + ) scheduler.bind_agent(agent) + scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds) app = FastAPI(title="TraderAI") static_dir = Path(__file__).resolve().parent.parent / "web" diff --git a/traderai/tools.py b/traderai/tools.py index 53fefc1..aae9963 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -44,6 +44,7 @@ class ToolRegistry: "recall_memory": self.recall_memory, "schedule_wake_job": self.schedule_wake_job, "list_wake_jobs": self.list_wake_jobs, + "check_uex_notifications": self.check_uex_notifications, } @property @@ -53,7 +54,7 @@ class ToolRegistry: "type": "function", "function": { "name": "search_marketplace_listings", - "description": "Search active UEX marketplace listings. UEX returns up to 100 active listings; filters are applied locally.", + "description": "Search active/current UEX marketplace listings only. Prices are in-game aUEC/UEC credits, not real-world dollars. Do not use this as historical sale or completed-sale information. UEX returns up to 100 active listings; filters are applied locally.", "parameters": { "type": "object", "properties": { @@ -133,7 +134,7 @@ class ToolRegistry: "type": "function", "function": { "name": "draft_marketplace_listing", - "description": "Draft a new UEX marketplace listing. 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.", "parameters": { "type": "object", "required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], @@ -215,6 +216,14 @@ class ToolRegistry: "parameters": {"type": "object", "properties": {}}, }, }, + { + "type": "function", + "function": { + "name": "check_uex_notifications", + "description": "Check authenticated UEX user notifications and return unread pending notifications.", + "parameters": {"type": "object", "properties": {}}, + }, + }, ] async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: @@ -324,6 +333,12 @@ class ToolRegistry: return {"error": "Scheduler is not configured."} return {"scheduled_jobs": self.scheduler.list_jobs()} + async def check_uex_notifications(self) -> dict[str, Any]: + response = await self.uex.get_user_notifications() + notifications = response.get("notifications") or [] + pending = [item for item in notifications if not item.get("date_read")] + return {"count": len(pending), "notifications": pending} + def _pending(self, label: str, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: action_id = str(uuid.uuid4()) payload = {key: value for key, value in payload.items() if value is not None} diff --git a/traderai/uex_client.py b/traderai/uex_client.py index e0bbefd..dfc261f 100644 --- a/traderai/uex_client.py +++ b/traderai/uex_client.py @@ -42,6 +42,13 @@ class UEXClient: data = data[0] if data else None return {"status": body.get("status"), "user": data} + async def get_user_notifications(self) -> dict[str, Any]: + body = await self.get("user_notifications", authenticated=True) + data = body.get("data") or [] + if isinstance(data, dict): + data = [data] + return {"status": body.get("status"), "notifications": data} + async def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]: async with httpx.AsyncClient(timeout=30) as client: response = await client.post(