agent: look at current info, its aUEC, feat: pull up notifcations.

This commit is contained in:
2026-05-05 20:05:33 -04:00
parent f7ac45ddd8
commit 761eda6155
11 changed files with 198 additions and 8 deletions
+2
View File
@@ -1,8 +1,10 @@
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64000
UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_BASE_URL=https://api.uexcorp.space/2.0
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
TRADERAI_MEMORY_PATH=data/traderai.sqlite3 TRADERAI_MEMORY_PATH=data/traderai.sqlite3
UEX_NOTIFICATION_POLL_SECONDS=60
REQUIRE_WRITE_APPROVAL=true REQUIRE_WRITE_APPROVAL=true
+7 -3
View File
@@ -4,13 +4,14 @@ Local Ollama-powered chat for UEX marketplace workflows.
## What It Does ## 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. - 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.
- Maintains local SQLite memory with searchable recall for user facts, preferences, and prior chat context. - 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. - 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. - 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 ## Setup
@@ -33,11 +34,11 @@ Local Ollama-powered chat for UEX marketplace workflows.
## Notes ## 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. 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: 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. 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 ## 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
@@ -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 - 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
- Ollama API streaming/tool-call reference: https://github.com/ollama/ollama/blob/main/docs/api.md - 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 - SQLite FTS5 docs: https://www.sqlite.org/fts5.html
- APScheduler AsyncIO scheduler docs: https://apscheduler.readthedocs.io/en/stable/modules/schedulers/asyncio.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 - Letta/MemGPT memory hierarchy background: https://docs.letta.com/concepts/letta
+14 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from traderai.agent import OllamaAgent from traderai.agent import OllamaAgent, SYSTEM_PROMPT
from traderai.memory import MemoryStore 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["reading_tokens_per_second"] == 10
assert metrics["writing_tokens"] == 30 assert metrics["writing_tokens"] == 30
assert metrics["writing_tokens_per_second"] == 10 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}
+47
View File
@@ -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"]
+13
View File
@@ -81,3 +81,16 @@ async def test_uex_client_get_user_normalizes_user_payload():
result = await client.get_user(authenticated=True) result = await client.get_user(authenticated=True)
assert result == {"status": "ok", "user": {"username": "pilot_hudson"}} 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}]}
+13 -1
View File
@@ -12,7 +12,9 @@ from traderai.tools import ToolRegistry
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work. 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. 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.""" 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, tools: ToolRegistry,
memory: MemoryStore | None = None, memory: MemoryStore | None = None,
user_name: str | None = None, user_name: str | None = None,
num_ctx: int | None = None,
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.model = model self.model = model
self.tools = tools self.tools = tools
self.memory = memory self.memory = memory
self.user_name = user_name self.user_name = user_name
self.num_ctx = num_ctx
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}] self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
async def health(self) -> dict[str, Any]: async def health(self) -> dict[str, Any]:
@@ -165,6 +169,7 @@ class OllamaAgent:
"model": self.model, "model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction), "messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas, "tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": False, "stream": False,
}, },
) )
@@ -184,6 +189,7 @@ class OllamaAgent:
"model": self.model, "model": self.model,
"messages": self._messages_with_context(query, previous_interaction=previous_interaction), "messages": self._messages_with_context(query, previous_interaction=previous_interaction),
"tools": self.tools.schemas, "tools": self.tools.schemas,
"options": self._ollama_options(),
"stream": True, "stream": True,
}, },
) as response: ) as response:
@@ -258,6 +264,11 @@ class OllamaAgent:
for action in self.tools.pending_actions.values() 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 @staticmethod
def _tool_status(name: str) -> str: def _tool_status(name: str) -> str:
labels = { labels = {
@@ -267,6 +278,7 @@ class OllamaAgent:
"get_negotiation_messages": "Reading negotiation messages", "get_negotiation_messages": "Reading negotiation messages",
"draft_negotiation_message": "Drafting message for approval", "draft_negotiation_message": "Drafting message for approval",
"draft_marketplace_listing": "Drafting listing for approval", "draft_marketplace_listing": "Drafting listing for approval",
"check_uex_notifications": "Checking UEX notifications",
} }
return labels.get(name, f"Running {name}") return labels.get(name, f"Running {name}")
+2
View File
@@ -9,11 +9,13 @@ class Settings(BaseSettings):
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b" ollama_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64000
uex_base_url: str = "https://api.uexcorp.space/2.0" uex_base_url: str = "https://api.uexcorp.space/2.0"
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)
traderai_memory_path: str = "data/traderai.sqlite3" traderai_memory_path: str = "data/traderai.sqlite3"
uex_notification_poll_seconds: int = 60
require_write_approval: bool = True require_write_approval: bool = True
+67
View File
@@ -7,23 +7,34 @@ from uuid import uuid4
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from tzlocal import get_localzone from tzlocal import get_localzone
from traderai.memory import MemoryStore, iso_now, time_since from traderai.memory import MemoryStore, iso_now, time_since
UEX_NOTIFICATION_JOB_ID = "uex-notification-poll"
class WakeScheduler: class WakeScheduler:
def __init__(self, memory: MemoryStore) -> None: def __init__(self, memory: MemoryStore) -> None:
self.memory = memory self.memory = memory
self.scheduler = AsyncIOScheduler(timezone=get_localzone()) self.scheduler = AsyncIOScheduler(timezone=get_localzone())
self.agent = None self.agent = None
self.uex = None
self.notification_poll_seconds = 60
def bind_agent(self, agent: Any) -> None: def bind_agent(self, agent: Any) -> None:
self.agent = agent 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: def start(self) -> None:
if not self.scheduler.running: if not self.scheduler.running:
self.scheduler.start() self.scheduler.start()
self._schedule_notification_poll()
for job in self.memory.list_jobs(): for job in self.memory.list_jobs():
self._schedule_existing(job) self._schedule_existing(job)
@@ -77,3 +88,59 @@ class WakeScheduler:
text = await self.agent.generate_wake_response(wake_message) text = await self.agent.generate_wake_response(wake_message)
self.memory.add_outbox(text) self.memory.add_outbox(text)
self.memory.mark_job_run(job_id) 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}"
+9 -1
View File
@@ -35,8 +35,16 @@ def create_app() -> FastAPI:
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) 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_agent(agent)
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
app = FastAPI(title="TraderAI") app = FastAPI(title="TraderAI")
static_dir = Path(__file__).resolve().parent.parent / "web" static_dir = Path(__file__).resolve().parent.parent / "web"
+17 -2
View File
@@ -44,6 +44,7 @@ class ToolRegistry:
"recall_memory": self.recall_memory, "recall_memory": self.recall_memory,
"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,
} }
@property @property
@@ -53,7 +54,7 @@ class ToolRegistry:
"type": "function", "type": "function",
"function": { "function": {
"name": "search_marketplace_listings", "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -133,7 +134,7 @@ class ToolRegistry:
"type": "function", "type": "function",
"function": { "function": {
"name": "draft_marketplace_listing", "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": { "parameters": {
"type": "object", "type": "object",
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"], "required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
@@ -215,6 +216,14 @@ class ToolRegistry:
"parameters": {"type": "object", "properties": {}}, "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]: 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 {"error": "Scheduler is not configured."}
return {"scheduled_jobs": self.scheduler.list_jobs()} 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]: def _pending(self, label: str, endpoint: str, payload: dict[str, Any]) -> 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}
+7
View File
@@ -42,6 +42,13 @@ class UEXClient:
data = data[0] if data else None data = data[0] if data else None
return {"status": body.get("status"), "user": data} 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 def post(self, path: str, payload: dict[str, Any], authenticated: bool = True) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
response = await client.post( response = await client.post(