Inital Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""TraderAI application package."""
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||
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.
|
||||
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."""
|
||||
|
||||
|
||||
class OllamaAgent:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str,
|
||||
tools: ToolRegistry,
|
||||
memory: MemoryStore | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.tools = tools
|
||||
self.memory = memory
|
||||
self.user_name = user_name
|
||||
self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"message": f"Ollama is offline or unreachable at {self.base_url}. Start Ollama and make sure the model is pulled.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
||||
models = [model.get("name") or model.get("model") for model in body.get("models", [])]
|
||||
return {
|
||||
"online": True,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"model_available": self.model in models,
|
||||
"models": models,
|
||||
"message": "Ollama is online.",
|
||||
}
|
||||
|
||||
async def ensure_available(self) -> None:
|
||||
health = await self.health()
|
||||
if not health["online"]:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
|
||||
async def chat(self, content: str) -> dict[str, Any]:
|
||||
await self.ensure_available()
|
||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
||||
if self.memory:
|
||||
self.memory.add_conversation("user", content)
|
||||
self.messages.append({"role": "user", "content": content})
|
||||
for _ in range(5):
|
||||
response = await self._ollama_chat(content, previous_interaction=previous_interaction)
|
||||
message = response.get("message") or {}
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
self.messages.append({"role": "assistant", "content": message.get("content", "")})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", message.get("content", ""))
|
||||
return {"message": message.get("content", ""), "pending_actions": self._pending_payloads()}
|
||||
|
||||
self.messages.append(message)
|
||||
for call in tool_calls:
|
||||
name, arguments = self._extract_call(call)
|
||||
result = await self.tools.execute(name, arguments)
|
||||
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
|
||||
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
||||
self.messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback)
|
||||
return {"message": fallback, "pending_actions": self._pending_payloads()}
|
||||
|
||||
async def chat_events(self, content: str) -> AsyncIterator[dict[str, Any]]:
|
||||
health = await self.health()
|
||||
if not health["online"]:
|
||||
yield {"type": "warning", "message": health["message"]}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
return
|
||||
|
||||
previous_interaction = self.memory.last_interaction() if self.memory else None
|
||||
if self.memory:
|
||||
self.memory.add_conversation("user", content)
|
||||
self.messages.append({"role": "user", "content": content})
|
||||
yield {"type": "status", "message": "Thinking"}
|
||||
|
||||
for _ in range(5):
|
||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
async for event in self._ollama_chat_stream(content, previous_interaction=previous_interaction):
|
||||
message = event.get("message") or {}
|
||||
chunk = message.get("content") or ""
|
||||
if chunk:
|
||||
assistant_message["content"] += chunk
|
||||
yield {"type": "token", "content": chunk}
|
||||
if message.get("tool_calls"):
|
||||
tool_calls.extend(message["tool_calls"])
|
||||
|
||||
if not tool_calls:
|
||||
self.messages.append(assistant_message)
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", assistant_message.get("content", ""))
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
return
|
||||
|
||||
assistant_message["tool_calls"] = tool_calls
|
||||
self.messages.append(assistant_message)
|
||||
for call in tool_calls:
|
||||
name, arguments = self._extract_call(call)
|
||||
yield {"type": "status", "message": self._tool_status(name)}
|
||||
result = await self.tools.execute(name, arguments)
|
||||
self.messages.append({"role": "tool", "tool_name": name, "content": json.dumps(result)})
|
||||
|
||||
yield {"type": "status", "message": "Writing response"}
|
||||
|
||||
fallback = "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
||||
self.messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback)
|
||||
yield {"type": "token", "content": fallback}
|
||||
yield {"type": "done", "pending_actions": self._pending_payloads()}
|
||||
|
||||
async def generate_wake_response(self, wake_message: str) -> str:
|
||||
await self.ensure_available()
|
||||
self.messages.append({"role": "user", "content": wake_message})
|
||||
response = await self._ollama_chat(wake_message)
|
||||
message = response.get("message") or {}
|
||||
content = message.get("content", "")
|
||||
self.messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message)
|
||||
self.memory.add_conversation("assistant", content)
|
||||
return content or wake_message
|
||||
|
||||
async def _ollama_chat(self, query: str = "", previous_interaction: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"tools": self.tools.schemas,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _ollama_chat_stream(
|
||||
self,
|
||||
query: str = "",
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": self._messages_with_context(query, previous_interaction=previous_interaction),
|
||||
"tools": self.tools.schemas,
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield json.loads(line)
|
||||
|
||||
def _messages_with_context(
|
||||
self,
|
||||
query: str,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
context = self._runtime_context(query, previous_interaction=previous_interaction)
|
||||
if not context:
|
||||
return self.messages
|
||||
return [self.messages[0], {"role": "system", "content": context}, *self.messages[1:]]
|
||||
|
||||
def _runtime_context(self, query: str, previous_interaction: dict[str, Any] | None = None) -> str:
|
||||
local_zone = get_localzone()
|
||||
parts = [
|
||||
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
||||
]
|
||||
if self.user_name:
|
||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
||||
|
||||
if self.memory is None:
|
||||
return "\n".join(parts)
|
||||
|
||||
profile = self.memory.get_profile()
|
||||
if profile:
|
||||
identity = self._profile_identity(profile)
|
||||
if identity:
|
||||
parts.append(identity)
|
||||
parts.append(f"Known user profile JSON: {json.dumps(self._profile_for_prompt(profile), ensure_ascii=True)}.")
|
||||
|
||||
last = previous_interaction if previous_interaction is not None else self.memory.last_interaction()
|
||||
if last:
|
||||
parts.append(
|
||||
f"Previous interaction before this message: {last['created_at']} "
|
||||
f"({time_since(last['created_at'])}, role {last['role']})."
|
||||
)
|
||||
else:
|
||||
parts.append("Previous interaction before this message: none recorded.")
|
||||
|
||||
memories = self.memory.recall(query, limit=6)
|
||||
if memories:
|
||||
memory_text = "\n".join(
|
||||
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
|
||||
for item in memories
|
||||
)
|
||||
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||
|
||||
recent = self.memory.recent_conversation(limit=6)
|
||||
if recent:
|
||||
recent_text = "\n".join(
|
||||
f"- {item['created_at']} {item['role']}: {item['content'][:500]}"
|
||||
for item in recent
|
||||
)
|
||||
parts.append(f"Recent conversation excerpts:\n{recent_text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _pending_payloads(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": action.id,
|
||||
"label": action.label,
|
||||
"endpoint": action.endpoint,
|
||||
"payload": action.payload,
|
||||
}
|
||||
for action in self.tools.pending_actions.values()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _tool_status(name: str) -> str:
|
||||
labels = {
|
||||
"search_marketplace_listings": "Searching UEX listings",
|
||||
"get_marketplace_listing": "Fetching listing details",
|
||||
"list_marketplace_negotiations": "Checking negotiations",
|
||||
"get_negotiation_messages": "Reading negotiation messages",
|
||||
"draft_negotiation_message": "Drafting message for approval",
|
||||
"draft_marketplace_listing": "Drafting listing for approval",
|
||||
}
|
||||
return labels.get(name, f"Running {name}")
|
||||
|
||||
@staticmethod
|
||||
def _profile_identity(profile: dict[str, Any]) -> str:
|
||||
user = profile.get("uex_user")
|
||||
if not isinstance(user, dict):
|
||||
configured = profile.get("configured_name")
|
||||
return f"You are speaking with {configured}." if configured else ""
|
||||
|
||||
username = user.get("username") or user.get("user_username")
|
||||
name = user.get("name")
|
||||
fields = []
|
||||
if username and name and username != name:
|
||||
fields.append(f"You are speaking with UEX user {username} ({name}).")
|
||||
elif username or name:
|
||||
fields.append(f"You are speaking with UEX user {username or name}.")
|
||||
|
||||
details = []
|
||||
for key, label in [
|
||||
("timezone", "timezone"),
|
||||
("language", "preferred language"),
|
||||
("specializations", "specializations"),
|
||||
("languages", "languages"),
|
||||
("archetypes", "archetypes"),
|
||||
]:
|
||||
value = user.get(key)
|
||||
if value:
|
||||
details.append(f"{label}: {value}")
|
||||
if details:
|
||||
fields.append("UEX profile details: " + "; ".join(details) + ".")
|
||||
return " ".join(fields)
|
||||
|
||||
@staticmethod
|
||||
def _profile_for_prompt(profile: dict[str, Any]) -> dict[str, Any]:
|
||||
user = profile.get("uex_user")
|
||||
if not isinstance(user, dict):
|
||||
return profile
|
||||
|
||||
useful_user_fields = [
|
||||
"id",
|
||||
"name",
|
||||
"username",
|
||||
"avatar",
|
||||
"bio",
|
||||
"website_url",
|
||||
"timezone",
|
||||
"language",
|
||||
"day_availability",
|
||||
"time_availability",
|
||||
"specializations",
|
||||
"languages",
|
||||
"archetypes",
|
||||
"is_datarunner",
|
||||
"is_staff",
|
||||
"is_away_game",
|
||||
"date_rsi_verified",
|
||||
"date_twitch_verified",
|
||||
]
|
||||
prompt_profile = dict(profile)
|
||||
prompt_profile["uex_user"] = {
|
||||
key: user[key]
|
||||
for key in useful_user_fields
|
||||
if key in user and user[key] not in (None, "")
|
||||
}
|
||||
return prompt_profile
|
||||
|
||||
@staticmethod
|
||||
def _extract_call(call: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
function = call.get("function") or {}
|
||||
name = function.get("name") or call.get("name")
|
||||
arguments = function.get("arguments") or call.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(arguments or "{}")
|
||||
return name, arguments
|
||||
|
||||
|
||||
class OllamaUnavailable(RuntimeError):
|
||||
pass
|
||||
@@ -0,0 +1,22 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "qwen3.5:9b"
|
||||
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"
|
||||
require_write_approval: bool = True
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso_now() -> str:
|
||||
return utc_now().isoformat()
|
||||
|
||||
|
||||
def iso_now_in_zone(zone: ZoneInfo) -> str:
|
||||
return utc_now().astimezone(zone).isoformat()
|
||||
|
||||
|
||||
def parse_iso(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def time_since(value: str, now: datetime | None = None) -> str:
|
||||
then = parse_iso(value)
|
||||
current = now or utc_now()
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
seconds = max(0, int((current - then).total_seconds()))
|
||||
if seconds < 60:
|
||||
return f"{seconds} seconds ago"
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return _plural(minutes, "minute") + " ago"
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return _plural(hours, "hour") + " ago"
|
||||
days = hours // 24
|
||||
return _plural(days, "day") + " ago"
|
||||
|
||||
|
||||
def _plural(value: int, unit: str) -> str:
|
||||
suffix = "" if value == 1 else "s"
|
||||
return f"{value} {unit}{suffix}"
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
importance INTEGER NOT NULL DEFAULT 3,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||
content,
|
||||
kind UNINDEXED,
|
||||
content='memories',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||||
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
|
||||
VALUES('delete', old.id, old.content, old.kind);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||||
INSERT INTO memories_fts(memories_fts, rowid, content, kind)
|
||||
VALUES('delete', old.id, old.content, old.kind);
|
||||
INSERT INTO memories_fts(rowid, content, kind) VALUES (new.id, new.content, new.kind);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_profile (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
prompt TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_value TEXT NOT NULL,
|
||||
next_run_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_run_at TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def add_conversation(self, role: str, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
|
||||
(role, content, iso_now()),
|
||||
)
|
||||
|
||||
def last_interaction(self) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO memories(kind, content, importance, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(kind, content, importance, json.dumps(metadata or {}), now, now),
|
||||
)
|
||||
memory_id = cursor.lastrowid
|
||||
return {"id": memory_id, "kind": kind, "content": content, "importance": importance, "created_at": now}
|
||||
|
||||
def recall(self, query: str, limit: int = 6) -> list[dict[str, Any]]:
|
||||
if not query.strip():
|
||||
return self.top_memories(limit)
|
||||
|
||||
with self._connect() as db:
|
||||
try:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT m.id, m.kind, m.content, m.importance, m.metadata, m.created_at,
|
||||
bm25(memories_fts) AS rank
|
||||
FROM memories_fts
|
||||
JOIN memories m ON m.id = memories_fts.rowid
|
||||
WHERE memories_fts MATCH ?
|
||||
ORDER BY rank, m.importance DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(self._fts_query(query), limit),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, kind, content, importance, metadata, created_at
|
||||
FROM memories
|
||||
WHERE content LIKE ?
|
||||
ORDER BY importance DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"%{query}%", limit),
|
||||
).fetchall()
|
||||
return [self._memory_row(row) for row in rows]
|
||||
|
||||
def top_memories(self, limit: int = 6) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, kind, content, importance, metadata, created_at
|
||||
FROM memories
|
||||
ORDER BY importance DESC, updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [self._memory_row(row) for row in rows]
|
||||
|
||||
def inspect(self, limit: int = 50) -> dict[str, Any]:
|
||||
with self._connect() as db:
|
||||
memories = db.execute(
|
||||
"""
|
||||
SELECT id, kind, content, importance, metadata, created_at, updated_at
|
||||
FROM memories
|
||||
ORDER BY importance DESC, updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
conversations = db.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at
|
||||
FROM conversations
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
profile_rows = db.execute(
|
||||
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
||||
).fetchall()
|
||||
jobs = db.execute(
|
||||
"SELECT * FROM scheduled_jobs ORDER BY enabled DESC, next_run_at"
|
||||
).fetchall()
|
||||
outbox = db.execute(
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
profile = []
|
||||
for row in profile_rows:
|
||||
item = dict(row)
|
||||
try:
|
||||
item["value"] = json.loads(item["value"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
profile.append(item)
|
||||
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"memories": [self._memory_row(row) for row in memories],
|
||||
"conversations": [dict(row) for row in conversations],
|
||||
"profile": profile,
|
||||
"scheduled_jobs": [dict(row) for row in jobs],
|
||||
"outbox": [dict(row) for row in outbox],
|
||||
}
|
||||
|
||||
def clear(
|
||||
self,
|
||||
include_memories: bool = True,
|
||||
include_conversations: bool = True,
|
||||
include_profile: bool = False,
|
||||
include_jobs: bool = False,
|
||||
include_outbox: bool = True,
|
||||
) -> dict[str, int]:
|
||||
deleted: dict[str, int] = {}
|
||||
with self._connect() as db:
|
||||
if include_memories:
|
||||
deleted["memories"] = db.execute("DELETE FROM memories").rowcount
|
||||
db.execute("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')")
|
||||
if include_conversations:
|
||||
deleted["conversations"] = db.execute("DELETE FROM conversations").rowcount
|
||||
if include_profile:
|
||||
deleted["profile"] = db.execute("DELETE FROM user_profile").rowcount
|
||||
if include_jobs:
|
||||
deleted["scheduled_jobs"] = db.execute("DELETE FROM scheduled_jobs").rowcount
|
||||
if include_outbox:
|
||||
deleted["outbox"] = db.execute("DELETE FROM outbox").rowcount
|
||||
return deleted
|
||||
|
||||
def set_profile(self, key: str, value: Any) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO user_profile(key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, json.dumps(value), iso_now()),
|
||||
)
|
||||
|
||||
def get_profile(self) -> dict[str, Any]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute("SELECT key, value FROM user_profile").fetchall()
|
||||
profile = {}
|
||||
for row in rows:
|
||||
try:
|
||||
profile[row["key"]] = json.loads(row["value"])
|
||||
except json.JSONDecodeError:
|
||||
profile[row["key"]] = row["value"]
|
||||
return profile
|
||||
|
||||
def add_job(
|
||||
self,
|
||||
job_id: str,
|
||||
prompt: str,
|
||||
trigger_type: str,
|
||||
trigger_value: str,
|
||||
next_run_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO scheduled_jobs(id, prompt, trigger_type, trigger_value, next_run_at, created_at, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
prompt=excluded.prompt,
|
||||
trigger_type=excluded.trigger_type,
|
||||
trigger_value=excluded.trigger_value,
|
||||
next_run_at=excluded.next_run_at,
|
||||
enabled=1
|
||||
""",
|
||||
(job_id, prompt, trigger_type, trigger_value, next_run_at, iso_now()),
|
||||
)
|
||||
return {
|
||||
"id": job_id,
|
||||
"prompt": prompt,
|
||||
"trigger_type": trigger_type,
|
||||
"trigger_value": trigger_value,
|
||||
"next_run_at": next_run_at,
|
||||
}
|
||||
|
||||
def list_jobs(self) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM scheduled_jobs WHERE enabled = 1 ORDER BY next_run_at IS NULL, next_run_at"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
||||
(iso_now(), next_run_at, job_id),
|
||||
)
|
||||
|
||||
def add_outbox(self, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
||||
|
||||
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT id, content, created_at FROM outbox WHERE delivered_at IS NULL ORDER BY id"
|
||||
).fetchall()
|
||||
db.execute(
|
||||
"UPDATE outbox SET delivered_at = ? WHERE delivered_at IS NULL",
|
||||
(now,),
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _fts_query(query: str) -> str:
|
||||
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
||||
return " OR ".join(f'"{token}"' for token in tokens) or '""'
|
||||
|
||||
@staticmethod
|
||||
def _memory_row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
data = dict(row)
|
||||
if "metadata" in data:
|
||||
try:
|
||||
data["metadata"] = json.loads(data["metadata"])
|
||||
except json.JSONDecodeError:
|
||||
data["metadata"] = {}
|
||||
return data
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import MemoryStore, iso_now, time_since
|
||||
|
||||
|
||||
class WakeScheduler:
|
||||
def __init__(self, memory: MemoryStore) -> None:
|
||||
self.memory = memory
|
||||
self.scheduler = AsyncIOScheduler(timezone=get_localzone())
|
||||
self.agent = None
|
||||
|
||||
def bind_agent(self, agent: Any) -> None:
|
||||
self.agent = agent
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
for job in self.memory.list_jobs():
|
||||
self._schedule_existing(job)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
|
||||
def schedule_date(self, run_at: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
|
||||
parsed = datetime.fromisoformat(run_at)
|
||||
job_id = job_id or f"wake-{uuid4()}"
|
||||
trigger = DateTrigger(run_date=parsed)
|
||||
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
|
||||
return self.memory.add_job(job_id, prompt, "date", run_at, parsed.isoformat())
|
||||
|
||||
def schedule_cron(self, cron: str, prompt: str, job_id: str | None = None) -> dict[str, Any]:
|
||||
job_id = job_id or f"wake-{uuid4()}"
|
||||
trigger = CronTrigger.from_crontab(cron)
|
||||
self.scheduler.add_job(self._run_job, trigger=trigger, id=job_id, args=[job_id, prompt], replace_existing=True)
|
||||
next_run = self.scheduler.get_job(job_id).next_run_time
|
||||
return self.memory.add_job(job_id, prompt, "cron", cron, next_run.isoformat() if next_run else None)
|
||||
|
||||
def list_jobs(self) -> list[dict[str, Any]]:
|
||||
return self.memory.list_jobs()
|
||||
|
||||
def _schedule_existing(self, job: dict[str, Any]) -> None:
|
||||
if job["trigger_type"] == "cron":
|
||||
trigger = CronTrigger.from_crontab(job["trigger_value"])
|
||||
elif job["trigger_type"] == "date":
|
||||
trigger = DateTrigger(run_date=datetime.fromisoformat(job["trigger_value"]))
|
||||
else:
|
||||
return
|
||||
self.scheduler.add_job(
|
||||
self._run_job,
|
||||
trigger=trigger,
|
||||
id=job["id"],
|
||||
args=[job["id"], job["prompt"]],
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
async def _run_job(self, job_id: str, prompt: str) -> None:
|
||||
last = self.memory.last_interaction()
|
||||
last_text = f"{last['created_at']} ({time_since(last['created_at'])})" if last else "never"
|
||||
wake_message = (
|
||||
f"Scheduled wake job fired. Current time is {iso_now()}. "
|
||||
f"The last chat interaction was {last_text}. Job instruction: {prompt}"
|
||||
)
|
||||
if self.agent is None:
|
||||
self.memory.add_outbox(wake_message)
|
||||
return
|
||||
|
||||
text = await self.agent.generate_wake_response(wake_message)
|
||||
self.memory.add_outbox(text)
|
||||
self.memory.mark_job_run(job_id)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from traderai.agent import OllamaAgent, OllamaUnavailable
|
||||
from traderai.config import get_settings
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ClearMemoryRequest(BaseModel):
|
||||
include_memories: bool = True
|
||||
include_conversations: bool = True
|
||||
include_profile: bool = False
|
||||
include_jobs: bool = False
|
||||
include_outbox: bool = True
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
memory = MemoryStore(settings.traderai_memory_path)
|
||||
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)
|
||||
scheduler.bind_agent(agent)
|
||||
|
||||
app = FastAPI(title="TraderAI")
|
||||
static_dir = Path(__file__).resolve().parent.parent / "web"
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
await refresh_user_profile()
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
scheduler.shutdown()
|
||||
|
||||
async def refresh_user_profile() -> None:
|
||||
if settings.traderai_user_name:
|
||||
memory.set_profile("configured_name", settings.traderai_user_name)
|
||||
agent.user_name = agent.user_name or settings.traderai_user_name
|
||||
|
||||
try:
|
||||
response = await uex.get_user(authenticated=True)
|
||||
except Exception as exc:
|
||||
memory.set_profile("uex_user_error", str(exc))
|
||||
if settings.traderai_user_name:
|
||||
try:
|
||||
response = await uex.get_user(username=settings.traderai_user_name)
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
data = response.get("user")
|
||||
if data:
|
||||
memory.set_profile("uex_user", data)
|
||||
username = data.get("username") or data.get("user_username") or data.get("name")
|
||||
if username:
|
||||
agent.user_name = username
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"ollama": await agent.health(),
|
||||
"user": memory.get_profile(),
|
||||
"jobs": scheduler.list_jobs(),
|
||||
}
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: ChatRequest) -> dict:
|
||||
try:
|
||||
return await agent.chat(request.message)
|
||||
except OllamaUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
async def events():
|
||||
async for event in agent.chat_events(request.message):
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
|
||||
return StreamingResponse(events(), media_type="text/event-stream")
|
||||
|
||||
@app.get("/api/pending-actions")
|
||||
async def pending_actions() -> dict:
|
||||
return {"pending_actions": agent._pending_payloads()}
|
||||
|
||||
@app.get("/api/notifications")
|
||||
async def notifications() -> dict:
|
||||
return {"notifications": memory.undelivered_outbox()}
|
||||
|
||||
@app.get("/api/wake-jobs")
|
||||
async def wake_jobs() -> dict:
|
||||
return {"scheduled_jobs": scheduler.list_jobs()}
|
||||
|
||||
@app.get("/api/memory")
|
||||
async def inspect_memory(limit: int = 50) -> dict:
|
||||
return memory.inspect(max(1, min(limit, 200)))
|
||||
|
||||
@app.post("/api/memory/clear")
|
||||
async def clear_memory(request: ClearMemoryRequest) -> dict:
|
||||
if request.include_jobs:
|
||||
scheduler.shutdown()
|
||||
deleted = memory.clear(
|
||||
include_memories=request.include_memories,
|
||||
include_conversations=request.include_conversations,
|
||||
include_profile=request.include_profile,
|
||||
include_jobs=request.include_jobs,
|
||||
include_outbox=request.include_outbox,
|
||||
)
|
||||
if request.include_jobs:
|
||||
scheduler.start()
|
||||
return {"deleted": deleted, "memory": memory.inspect(50)}
|
||||
|
||||
@app.post("/api/approve/{action_id}")
|
||||
async def approve(action_id: str) -> dict:
|
||||
return await tools.approve(action_id)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
ToolHandler = Callable[..., Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingAction:
|
||||
id: str
|
||||
label: str
|
||||
endpoint: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(
|
||||
self,
|
||||
uex: UEXClient,
|
||||
require_write_approval: bool = True,
|
||||
memory: MemoryStore | None = None,
|
||||
scheduler: WakeScheduler | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
self.pending_actions: dict[str, PendingAction] = {}
|
||||
self.handlers: dict[str, ToolHandler] = {
|
||||
"search_marketplace_listings": self.search_marketplace_listings,
|
||||
"get_marketplace_listing": self.get_marketplace_listing,
|
||||
"list_marketplace_negotiations": self.list_marketplace_negotiations,
|
||||
"get_negotiation_messages": self.get_negotiation_messages,
|
||||
"draft_negotiation_message": self.draft_negotiation_message,
|
||||
"draft_marketplace_listing": self.draft_marketplace_listing,
|
||||
"remember_user_fact": self.remember_user_fact,
|
||||
"recall_memory": self.recall_memory,
|
||||
"schedule_wake_job": self.schedule_wake_job,
|
||||
"list_wake_jobs": self.list_wake_jobs,
|
||||
}
|
||||
|
||||
@property
|
||||
def schemas(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_marketplace_listings",
|
||||
"description": "Search active UEX marketplace listings. UEX returns up to 100 active listings; filters are applied locally.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Text to search in title, description, location, advertiser, or slug."},
|
||||
"operation": {"type": "string", "enum": ["buy", "sell"]},
|
||||
"type": {"type": "string", "enum": ["item", "service", "contract"]},
|
||||
"username": {"type": "string", "description": "Advertiser IGN."},
|
||||
"location": {"type": "string"},
|
||||
"min_price": {"type": "number"},
|
||||
"max_price": {"type": "number"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 25},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_marketplace_listing",
|
||||
"description": "Fetch a specific UEX marketplace listing by id or slug.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"slug": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_marketplace_negotiations",
|
||||
"description": "List authenticated marketplace negotiations for the configured UEX user.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"id_listing": {"type": "integer"},
|
||||
"hash": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_negotiation_messages",
|
||||
"description": "Fetch authenticated messages from a marketplace negotiation by hash or id_negotiation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "draft_negotiation_message",
|
||||
"description": "Draft a message or offer to a UEX negotiation. This creates a pending action that must be approved before sending.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"hash": {"type": "string"},
|
||||
"id_negotiation": {"type": "integer"},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["id_category", "operation", "type", "unit", "title", "description", "price", "currency", "language"],
|
||||
"properties": {
|
||||
"id_item": {"type": "integer"},
|
||||
"id_star_system": {"type": "integer"},
|
||||
"id_organization": {"type": "integer"},
|
||||
"id_category": {"type": "integer"},
|
||||
"operation": {"type": "string", "enum": ["buy", "sell"]},
|
||||
"type": {"type": "string", "enum": ["item", "service", "contract"]},
|
||||
"unit": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"price": {"type": "number"},
|
||||
"currency": {"type": "string", "enum": ["UEC", "WIF"]},
|
||||
"language": {"type": "string", "default": "en_US"},
|
||||
"location": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
"availability": {"type": "string"},
|
||||
"in_stock": {"type": "integer"},
|
||||
"hours_expiration": {"type": "integer"},
|
||||
"is_hidden": {"type": "integer", "enum": [0, 1]},
|
||||
"is_production": {"type": "integer", "enum": [0, 1], "default": 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "remember_user_fact",
|
||||
"description": "Persist a durable user preference, identity detail, trading rule, or long-term note for future chats.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["content"],
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"kind": {"type": "string", "enum": ["user", "preference", "trading", "project", "note"], "default": "note"},
|
||||
"importance": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall_memory",
|
||||
"description": "Search long-term memory for relevant prior facts, preferences, and chat context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 6},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "schedule_wake_job",
|
||||
"description": "Create a scheduled wake-up job for the assistant. Use either run_at for one-time jobs or cron for recurring jobs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["prompt"],
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What the AI should consider or do when it wakes."},
|
||||
"run_at": {"type": "string", "description": "ISO datetime for a one-time wake job, such as 2026-05-05T20:30:00-04:00."},
|
||||
"cron": {"type": "string", "description": "Five-field cron expression for recurring jobs, such as 0 9 * * *."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_wake_jobs",
|
||||
"description": "List currently enabled scheduled assistant wake jobs.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
handler = self.handlers.get(name)
|
||||
if not handler:
|
||||
return {"error": f"Unknown tool: {name}"}
|
||||
try:
|
||||
return await handler(**arguments)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
async def approve(self, action_id: str) -> dict[str, Any]:
|
||||
action = self.pending_actions.pop(action_id, None)
|
||||
if not action:
|
||||
return {"error": f"Pending action not found: {action_id}"}
|
||||
return await self.uex.post(action.endpoint, action.payload, authenticated=True)
|
||||
|
||||
async def search_marketplace_listings(
|
||||
self,
|
||||
query: str | None = None,
|
||||
operation: str | None = None,
|
||||
type: str | None = None,
|
||||
username: str | None = None,
|
||||
location: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
limit: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
response = await self.uex.get("marketplace_listings", {"username": username})
|
||||
listings = response.get("data") or []
|
||||
filtered = []
|
||||
q = (query or "").casefold()
|
||||
loc = (location or "").casefold()
|
||||
for listing in listings:
|
||||
if operation and listing.get("operation") != operation:
|
||||
continue
|
||||
if type and listing.get("type") != type:
|
||||
continue
|
||||
if min_price is not None and float(listing.get("price") or 0) < min_price:
|
||||
continue
|
||||
if max_price is not None and float(listing.get("price") or 0) > max_price:
|
||||
continue
|
||||
if loc and loc not in str(listing.get("location") or "").casefold():
|
||||
continue
|
||||
haystack = " ".join(str(listing.get(k) or "") for k in ["title", "description", "location", "user_username", "slug"]).casefold()
|
||||
if q and q not in haystack:
|
||||
continue
|
||||
filtered.append(self._summarize_listing(listing))
|
||||
if len(filtered) >= max(1, min(limit, 25)):
|
||||
break
|
||||
return {"count": len(filtered), "listings": filtered}
|
||||
|
||||
async def get_marketplace_listing(self, id: int | None = None, slug: str | None = None) -> dict[str, Any]:
|
||||
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
|
||||
return {"listing": response.get("data")}
|
||||
|
||||
async def list_marketplace_negotiations(
|
||||
self,
|
||||
id: int | None = None,
|
||||
id_listing: int | None = None,
|
||||
hash: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.uex.get("marketplace_negotiations", {"id": id, "id_listing": id_listing, "hash": hash}, authenticated=True)
|
||||
|
||||
async def get_negotiation_messages(self, hash: str | None = None, id_negotiation: int | None = None) -> dict[str, Any]:
|
||||
return await self.uex.get("marketplace_negotiations_messages", {"hash": hash, "id_negotiation": id_negotiation}, authenticated=True)
|
||||
|
||||
async def draft_negotiation_message(
|
||||
self,
|
||||
message: str,
|
||||
hash: str | None = None,
|
||||
id_negotiation: int | None = None,
|
||||
is_production: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
payload = {"message": message, "hash": hash, "id_negotiation": id_negotiation, "is_production": is_production}
|
||||
return self._pending("Send negotiation message", "marketplace_negotiations_messages", payload)
|
||||
|
||||
async def draft_marketplace_listing(self, **payload: Any) -> dict[str, Any]:
|
||||
return self._pending("Post marketplace listing", "marketplace_advertise", payload)
|
||||
|
||||
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."}
|
||||
return {"memory": self.memory.remember(kind, content, importance)}
|
||||
|
||||
async def recall_memory(self, query: str = "", limit: int = 6) -> dict[str, Any]:
|
||||
if self.memory is None:
|
||||
return {"error": "Memory store is not configured."}
|
||||
return {"memories": self.memory.recall(query, max(1, min(limit, 10)))}
|
||||
|
||||
async def schedule_wake_job(
|
||||
self,
|
||||
prompt: str,
|
||||
run_at: str | None = None,
|
||||
cron: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.scheduler is None:
|
||||
return {"error": "Scheduler is not configured."}
|
||||
if bool(run_at) == bool(cron):
|
||||
return {"error": "Provide exactly one of run_at or cron."}
|
||||
if run_at:
|
||||
return {"scheduled_job": self.scheduler.schedule_date(run_at, prompt)}
|
||||
return {"scheduled_job": self.scheduler.schedule_cron(cron or "", prompt)}
|
||||
|
||||
async def list_wake_jobs(self) -> dict[str, Any]:
|
||||
if self.scheduler is None:
|
||||
return {"error": "Scheduler is not configured."}
|
||||
return {"scheduled_jobs": self.scheduler.list_jobs()}
|
||||
|
||||
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}
|
||||
self.pending_actions[action_id] = PendingAction(action_id, label, endpoint, payload)
|
||||
return {
|
||||
"pending_action": {
|
||||
"id": action_id,
|
||||
"label": label,
|
||||
"endpoint": endpoint,
|
||||
"payload": payload,
|
||||
"approval_required": self.require_write_approval,
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": listing.get("id"),
|
||||
"slug": listing.get("slug"),
|
||||
"title": listing.get("title"),
|
||||
"operation": listing.get("operation"),
|
||||
"type": listing.get("type"),
|
||||
"price": listing.get("price"),
|
||||
"currency": listing.get("currency"),
|
||||
"unit": listing.get("unit"),
|
||||
"location": listing.get("location"),
|
||||
"availability": listing.get("availability"),
|
||||
"in_stock": listing.get("in_stock"),
|
||||
"advertiser": listing.get("user_username"),
|
||||
"expires_at": listing.get("date_expiration"),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class UEXError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class UEXClient:
|
||||
def __init__(self, base_url: str, secret_key: str | None = None, bearer_token: str | None = None) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.secret_key = secret_key
|
||||
self.bearer_token = bearer_token
|
||||
|
||||
def _headers(self, authenticated: bool = False) -> dict[str, str]:
|
||||
headers = {"Accept": "application/json"}
|
||||
if authenticated:
|
||||
if not self.secret_key and not self.bearer_token:
|
||||
raise UEXError("UEX_SECRET_KEY or UEX_BEARER_TOKEN is required for this action.")
|
||||
if self.secret_key:
|
||||
headers["secret-key"] = self.secret_key
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
return headers
|
||||
|
||||
async def get(self, path: str, params: dict[str, Any] | None = None, authenticated: bool = False) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/{path.strip('/')}/",
|
||||
params={k: v for k, v in (params or {}).items() if v is not None},
|
||||
headers=self._headers(authenticated),
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
async def get_user(self, username: str | None = None, authenticated: bool = False) -> dict[str, Any]:
|
||||
body = await self.get("user", {"username": username}, authenticated=authenticated)
|
||||
data = body.get("data")
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data else None
|
||||
return {"status": body.get("status"), "user": 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(
|
||||
f"{self.base_url}/{path.strip('/')}/",
|
||||
json=payload,
|
||||
headers=self._headers(authenticated),
|
||||
)
|
||||
return self._handle_response(response)
|
||||
|
||||
@staticmethod
|
||||
def _handle_response(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise UEXError(f"UEX returned non-JSON response: HTTP {response.status_code}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise UEXError(f"UEX HTTP {response.status_code}: {body}")
|
||||
return body
|
||||
Reference in New Issue
Block a user