This commit is contained in:
+396
-38
@@ -20,7 +20,8 @@ from traderai.tools import ToolRegistry
|
||||
from traderai.version import __version__
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
||||
SYSTEM_PROMPT = """You are TraderAI, a sharp Star Citizen marketplace copilot for UEX work.
|
||||
Sound like a competent player who knows the game and the market. Be natural, direct, and helpful. Avoid corporate filler, robotic phrasing, and meta notes.
|
||||
Use tools when the user asks about UEX data, open/current listings, active negotiations, unread notifications, messages, offers, or posting ads.
|
||||
Use continual plan tools when the user asks for multi-day or recurring marketplace work, such as finding several parts, watching for deals, tracking candidates, or coordinating negotiations over time.
|
||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||
@@ -29,11 +30,13 @@ When the user asks for history, trends, changes over time, or past prices, prefe
|
||||
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
||||
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
||||
Use Wikelo ship project tools when the user asks for Wikelo ship requirements, Wikelo build materials, or what items are needed for a Wikelo ship project.
|
||||
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||
Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise.
|
||||
For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds.
|
||||
When drafting negotiation messages or marketplace replies, write like a real player would. Keep messages human, concise, and purposeful. Never include internal notes like "Tone note".
|
||||
For continual plans, never invent an unknown parts checklist. If the required items cannot be derived from provided details or tools, create the plan in a needs-input state and say what item list is missing.
|
||||
When a scheduled wake job fires, always write a concise Inbox-ready result that says what you checked, the key findings, and the suggested next action.
|
||||
Keep prices, listing ids, slugs, users, and UEX status codes precise. If data is missing, say what you need next."""
|
||||
@@ -64,7 +67,7 @@ class OllamaAgent:
|
||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if self.provider == "openai":
|
||||
if self._is_openai_compatible_provider():
|
||||
return await self._openai_health()
|
||||
if self.provider == "codex":
|
||||
return await self._codex_health()
|
||||
@@ -119,7 +122,7 @@ class OllamaAgent:
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||
with image_scope:
|
||||
for _ in range(10):
|
||||
for _ in self._tool_rounds():
|
||||
try:
|
||||
response = await self._chat_once(
|
||||
prompt_text,
|
||||
@@ -155,7 +158,7 @@ class OllamaAgent:
|
||||
result = await self.tools.execute(name, arguments)
|
||||
last_tool_results.append({"tool": name, "result": result})
|
||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "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."
|
||||
fallback = self._tool_round_limit_message()
|
||||
messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||
@@ -187,7 +190,7 @@ class OllamaAgent:
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
image_scope = self.tools.chat_image_scope(normalized_images) if hasattr(self.tools, "chat_image_scope") else nullcontext()
|
||||
with image_scope:
|
||||
for _ in range(10):
|
||||
for _ in self._tool_rounds():
|
||||
assistant_message: dict[str, Any] = {"role": "assistant", "content": ""}
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
|
||||
@@ -198,7 +201,15 @@ class OllamaAgent:
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=resolved_thread_id,
|
||||
):
|
||||
if event.get("type") == "reasoning":
|
||||
reasoning_chunk = event.get("content") or ""
|
||||
if reasoning_chunk:
|
||||
assistant_message["reasoning_content"] = assistant_message.get("reasoning_content", "") + reasoning_chunk
|
||||
yield {"type": "reasoning", "content": reasoning_chunk}
|
||||
continue
|
||||
message = event.get("message") or {}
|
||||
if message.get("reasoning_content"):
|
||||
assistant_message["reasoning_content"] = message.get("reasoning_content")
|
||||
chunk = message.get("content") or ""
|
||||
if chunk:
|
||||
assistant_message["content"] += chunk
|
||||
@@ -247,7 +258,7 @@ class OllamaAgent:
|
||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "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."
|
||||
fallback = self._tool_round_limit_message()
|
||||
messages.append({"role": "assistant", "content": fallback})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("assistant", fallback, resolved_thread_id)
|
||||
@@ -260,7 +271,7 @@ class OllamaAgent:
|
||||
previous_interaction = self.memory.last_interaction("wake") if self.memory else None
|
||||
messages.append({"role": "user", "content": wake_message})
|
||||
last_tool_results: list[dict[str, Any]] = []
|
||||
for _ in range(10):
|
||||
for _ in self._tool_rounds():
|
||||
try:
|
||||
response = await self._chat_once(
|
||||
wake_message,
|
||||
@@ -298,7 +309,7 @@ class OllamaAgent:
|
||||
result = await self.tools.execute(name, arguments)
|
||||
last_tool_results.append({"tool": name, "result": result})
|
||||
messages.append({"role": "tool", "tool_name": name, "tool_call_id": call.get("id"), "content": json.dumps(result)})
|
||||
content = "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
||||
content = self._wake_tool_round_limit_message()
|
||||
messages.append({"role": "assistant", "content": content})
|
||||
if self.memory:
|
||||
self.memory.add_conversation("system", wake_message, "wake")
|
||||
@@ -312,7 +323,7 @@ class OllamaAgent:
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
if self.provider == "openai":
|
||||
if self._is_openai_compatible_provider():
|
||||
return await self._openai_chat(
|
||||
query,
|
||||
messages,
|
||||
@@ -340,7 +351,7 @@ class OllamaAgent:
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
if self.provider == "openai":
|
||||
if self._is_openai_compatible_provider():
|
||||
async for event in self._openai_chat_stream(
|
||||
query,
|
||||
messages,
|
||||
@@ -441,8 +452,8 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": False,
|
||||
**self._openai_request_options(stream=False),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -452,6 +463,7 @@ class OllamaAgent:
|
||||
return {
|
||||
"message": {
|
||||
"role": message.get("role", "assistant"),
|
||||
"reasoning_content": message.get("reasoning_content") or "",
|
||||
"content": message.get("content") or "",
|
||||
"tool_calls": message.get("tool_calls") or [],
|
||||
}
|
||||
@@ -479,8 +491,8 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": True,
|
||||
**self._openai_request_options(stream=True),
|
||||
},
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
@@ -493,8 +505,15 @@ class OllamaAgent:
|
||||
if payload == "[DONE]":
|
||||
break
|
||||
event = json.loads(payload)
|
||||
if event.get("usage"):
|
||||
metrics = self._cloud_usage_metrics(event["usage"])
|
||||
if metrics:
|
||||
yield {"type": "metrics", **metrics}
|
||||
choice = (event.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
reasoning_content = delta.get("reasoning_content") or ""
|
||||
if reasoning_content:
|
||||
yield {"type": "reasoning", "content": reasoning_content}
|
||||
content = delta.get("content") or ""
|
||||
if content:
|
||||
yield {"message": {"role": "assistant", "content": content}}
|
||||
@@ -502,9 +521,11 @@ class OllamaAgent:
|
||||
self._merge_openai_tool_call(tool_calls, tool_call)
|
||||
finish_reason = choice.get("finish_reason")
|
||||
if finish_reason:
|
||||
message = choice.get("message") or {}
|
||||
yield {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"reasoning_content": message.get("reasoning_content") or "",
|
||||
"content": "",
|
||||
"tool_calls": self._ordered_tool_calls(tool_calls),
|
||||
},
|
||||
@@ -574,18 +595,19 @@ class OllamaAgent:
|
||||
continue
|
||||
attached_image_count = len(message.get("images") or [])
|
||||
break
|
||||
context = self._runtime_context(
|
||||
stable_context, volatile_context = self._runtime_context_parts(
|
||||
query,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
attached_image_count=attached_image_count,
|
||||
)
|
||||
if not context:
|
||||
contexts = [part for part in ("\n".join(stable_context), "\n".join(volatile_context)) if part]
|
||||
if not contexts:
|
||||
return messages
|
||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||
return [messages[0], *({"role": "system", "content": context} for context in contexts), *messages[1:]]
|
||||
|
||||
async def _openai_health(self) -> dict[str, Any]:
|
||||
return await self._cloud_health("openai")
|
||||
return await self._cloud_health(self.provider if self._is_openai_compatible_provider() else "openai")
|
||||
|
||||
async def _codex_health(self) -> dict[str, Any]:
|
||||
command = self._codex_command()
|
||||
@@ -671,6 +693,19 @@ class OllamaAgent:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _openai_request_options(self, stream: bool) -> dict[str, Any]:
|
||||
if self.provider == "deepseek":
|
||||
options: dict[str, Any] = {}
|
||||
if self.reasoning_effort in {"none", "minimal"}:
|
||||
options["thinking"] = {"type": "disabled"}
|
||||
else:
|
||||
options["thinking"] = {"type": "enabled"}
|
||||
options["reasoning_effort"] = "max" if self.reasoning_effort in {"xhigh", "max"} else "high"
|
||||
if stream:
|
||||
options["stream_options"] = {"include_usage": True}
|
||||
return options
|
||||
return {"reasoning_effort": self.reasoning_effort}
|
||||
|
||||
def _openai_messages(
|
||||
self,
|
||||
query: str,
|
||||
@@ -706,6 +741,8 @@ class OllamaAgent:
|
||||
entry["content"] = content_parts
|
||||
if role == "assistant" and message.get("tool_calls"):
|
||||
entry["tool_calls"] = message["tool_calls"]
|
||||
if role == "assistant" and message.get("reasoning_content"):
|
||||
entry["reasoning_content"] = message["reasoning_content"]
|
||||
if role == "tool":
|
||||
entry["tool_call_id"] = message.get("tool_call_id") or message.get("tool_name") or "tool"
|
||||
normalized.append(entry)
|
||||
@@ -729,10 +766,29 @@ class OllamaAgent:
|
||||
def _provider_label(self) -> str:
|
||||
if self.provider == "openai":
|
||||
return "OpenAI model"
|
||||
if self.provider == "deepseek":
|
||||
return "DeepSeek model"
|
||||
if self.provider == "codex":
|
||||
return "Codex model"
|
||||
return "local model"
|
||||
|
||||
def _is_openai_compatible_provider(self) -> bool:
|
||||
return self.provider in {"openai", "deepseek"}
|
||||
|
||||
def _tool_rounds(self):
|
||||
if self.provider == "deepseek":
|
||||
while True:
|
||||
yield None
|
||||
return
|
||||
for _ in range(10):
|
||||
yield None
|
||||
|
||||
def _tool_round_limit_message(self) -> str:
|
||||
return "I hit the tool-call limit while working on that. Try narrowing the request or approve any pending action first."
|
||||
|
||||
def _wake_tool_round_limit_message(self) -> str:
|
||||
return "I hit the tool-call limit while running this scheduled wake job. Check the job prompt or pending approvals."
|
||||
|
||||
@staticmethod
|
||||
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||
index = int(delta.get("index") or 0)
|
||||
@@ -1175,20 +1231,21 @@ class OllamaAgent:
|
||||
models.append(slug)
|
||||
return sorted(set(models))
|
||||
|
||||
def _runtime_context(
|
||||
def _runtime_context_parts(
|
||||
self,
|
||||
query: str,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
attached_image_count: int = 0,
|
||||
) -> str:
|
||||
) -> tuple[list[str], list[str]]:
|
||||
local_zone = get_localzone()
|
||||
parts = [
|
||||
stable_parts: list[str] = []
|
||||
volatile_parts = [
|
||||
f"Current local date/time: {iso_now()} UTC; {iso_now_in_zone(local_zone)} {local_zone}.",
|
||||
]
|
||||
if attached_image_count:
|
||||
label = "image" if attached_image_count == 1 else "images"
|
||||
parts.append(
|
||||
volatile_parts.append(
|
||||
f"Current user message includes {attached_image_count} pasted {label}. "
|
||||
"You can inspect them visually. If the user wants one reused in a marketplace listing draft, "
|
||||
"call draft_marketplace_listing or draft_marketplace_listing_with_cornerstone_image with "
|
||||
@@ -1202,34 +1259,34 @@ class OllamaAgent:
|
||||
if uex.bearer_token:
|
||||
auth_methods.append("bearer token")
|
||||
if auth_methods:
|
||||
parts.append(
|
||||
stable_parts.append(
|
||||
"UEX API authentication is configured server-side with "
|
||||
+ " and ".join(auth_methods)
|
||||
+ "; use authenticated UEX tools directly and do not ask for tokens."
|
||||
)
|
||||
else:
|
||||
parts.append("UEX API authentication is not configured server-side.")
|
||||
stable_parts.append("UEX API authentication is not configured server-side.")
|
||||
if self.user_name:
|
||||
parts.append(f"Known user name/handle: {self.user_name}.")
|
||||
stable_parts.append(f"Known user name/handle: {self.user_name}.")
|
||||
|
||||
if self.memory is None:
|
||||
return "\n".join(parts)
|
||||
return stable_parts, volatile_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)}.")
|
||||
stable_parts.append(identity)
|
||||
stable_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(thread_id)
|
||||
if last:
|
||||
parts.append(
|
||||
volatile_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.")
|
||||
volatile_parts.append("Previous interaction before this message: none recorded.")
|
||||
|
||||
memories = self.memory.recall(query, limit=6)
|
||||
if memories:
|
||||
@@ -1237,17 +1294,24 @@ class OllamaAgent:
|
||||
f"- [{item['kind']}, importance {item['importance']}] {item['content']}"
|
||||
for item in memories
|
||||
)
|
||||
parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||
volatile_parts.append(f"Relevant long-term memories:\n{memory_text}")
|
||||
|
||||
recent = self.memory.recent_conversation(limit=6, thread_id=thread_id)
|
||||
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 from this chat:\n{recent_text}")
|
||||
return stable_parts, volatile_parts
|
||||
|
||||
return "\n".join(parts)
|
||||
def _runtime_context(
|
||||
self,
|
||||
query: str,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
attached_image_count: int = 0,
|
||||
) -> str:
|
||||
stable_parts, volatile_parts = self._runtime_context_parts(
|
||||
query,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
attached_image_count=attached_image_count,
|
||||
)
|
||||
return "\n".join(part for part in ("\n".join(stable_parts), "\n".join(volatile_parts)) if part)
|
||||
|
||||
def _messages_for_thread(self, thread_id: str | None) -> list[dict[str, Any]]:
|
||||
resolved_thread_id = self._thread_id(thread_id)
|
||||
@@ -1283,7 +1347,7 @@ class OllamaAgent:
|
||||
f"Message: {first_message[:800]}"
|
||||
)
|
||||
try:
|
||||
if self.provider == "openai":
|
||||
if self._is_openai_compatible_provider():
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
@@ -1295,6 +1359,7 @@ class OllamaAgent:
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
**self._openai_request_options(stream=False),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -1330,6 +1395,34 @@ class OllamaAgent:
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
async def generate_plan_draft(
|
||||
self,
|
||||
title: str = "",
|
||||
objective: str = "",
|
||||
kind: str = "buying",
|
||||
constraints: dict[str, Any] | None = None,
|
||||
items: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
clean_title = str(title or "").strip()
|
||||
clean_objective = str(objective or "").strip()
|
||||
clean_kind = str(kind or "buying").strip().casefold() or "buying"
|
||||
clean_constraints = dict(constraints or {})
|
||||
clean_items = self._normalize_plan_items(items or [])
|
||||
seed = {
|
||||
"title": clean_title,
|
||||
"objective": clean_objective,
|
||||
"kind": clean_kind,
|
||||
"constraints": clean_constraints,
|
||||
"items": clean_items,
|
||||
}
|
||||
prompt = self._plan_draft_prompt(seed)
|
||||
fallback = self._heuristic_plan_draft(seed)
|
||||
try:
|
||||
payload = await self._generate_plain_text(prompt, system_prompt="You draft structured continual plan JSON for TraderAI.")
|
||||
return self._normalize_plan_draft(payload, seed, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _thread_id(thread_id: str | None) -> str:
|
||||
return (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
@@ -1346,6 +1439,254 @@ class OllamaAgent:
|
||||
text = " ".join(words[:8])
|
||||
return text[:64]
|
||||
|
||||
async def _generate_plain_text(self, prompt: str, system_prompt: str) -> str:
|
||||
if self._is_openai_compatible_provider():
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=self._openai_headers(),
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
**self._openai_request_options(stream=False),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
choice = (response.json().get("choices") or [{}])[0]
|
||||
message = choice.get("message") or {}
|
||||
return str(message.get("content") or "")
|
||||
if self.provider == "codex":
|
||||
result = await self._codex_app_server_turn(
|
||||
prompt,
|
||||
[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
thread_id="plan-draft",
|
||||
)
|
||||
return str(result.get("message") or "")
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"options": self._ollama_options(),
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
message = response.json().get("message") or {}
|
||||
return str(message.get("content") or "")
|
||||
|
||||
@classmethod
|
||||
def _normalize_plan_draft(
|
||||
cls,
|
||||
raw_text: str,
|
||||
seed: dict[str, Any],
|
||||
fallback: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base = dict(fallback or cls._heuristic_plan_draft(seed))
|
||||
payload = cls._parse_json_object(raw_text)
|
||||
if not isinstance(payload, dict):
|
||||
return base
|
||||
|
||||
title = str(payload.get("title") or seed.get("title") or base.get("title") or "").strip()
|
||||
objective = str(payload.get("objective") or seed.get("objective") or base.get("objective") or "").strip()
|
||||
kind = str(payload.get("kind") or seed.get("kind") or base.get("kind") or "buying").strip().casefold() or "buying"
|
||||
cadence = cls._normalize_plan_cadence(payload.get("cadence")) or base.get("cadence")
|
||||
constraints = cls._normalize_plan_constraints(payload.get("constraints"), seed.get("constraints") or {}, base.get("constraints") or {})
|
||||
items = cls._normalize_plan_items(payload.get("items") or seed.get("items") or base.get("items") or [])
|
||||
|
||||
if kind == "buying" and not items:
|
||||
items = list(base.get("items") or [])
|
||||
if not constraints.get("instructions"):
|
||||
constraints["instructions"] = (base.get("constraints") or {}).get("instructions") or cls._default_plan_instructions(kind)
|
||||
if not constraints.get("message_tone"):
|
||||
constraints["message_tone"] = (base.get("constraints") or {}).get("message_tone") or "friendly and direct"
|
||||
|
||||
return {
|
||||
"title": title or base.get("title") or "Continual plan",
|
||||
"objective": objective or base.get("objective") or title or "Continue this plan",
|
||||
"kind": kind,
|
||||
"cadence": cadence,
|
||||
"constraints": constraints,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _heuristic_plan_draft(cls, seed: dict[str, Any]) -> dict[str, Any]:
|
||||
title = str(seed.get("title") or "").strip()
|
||||
objective = str(seed.get("objective") or "").strip()
|
||||
kind = str(seed.get("kind") or "buying").strip().casefold() or "buying"
|
||||
constraints = cls._normalize_plan_constraints(seed.get("constraints"), {}, {})
|
||||
items = cls._normalize_plan_items(seed.get("items") or [])
|
||||
|
||||
if not items and kind == "buying":
|
||||
inferred_names = cls._infer_item_names(f"{title}\n{objective}")
|
||||
items = [{"item_name": name, "desired_quantity": 1, "max_unit_price": None} for name in inferred_names[:8]]
|
||||
|
||||
if not constraints.get("message_tone"):
|
||||
constraints["message_tone"] = "friendly and direct"
|
||||
if not constraints.get("instructions"):
|
||||
constraints["instructions"] = cls._default_plan_instructions(kind)
|
||||
|
||||
return {
|
||||
"title": title or "Continual plan",
|
||||
"objective": objective or title or "Continue this plan",
|
||||
"kind": kind,
|
||||
"cadence": cls._normalize_plan_cadence(seed.get("cadence")) or ("0 */6 * * *" if kind == "buying" else "0 */4 * * *"),
|
||||
"constraints": constraints,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _default_plan_instructions(kind: str) -> str:
|
||||
if kind == "custom":
|
||||
return "Check for meaningful updates, summarize what changed, and suggest the next move."
|
||||
return "Track the best active listings, avoid bad prices, and draft messages for approval when a strong candidate appears."
|
||||
|
||||
@staticmethod
|
||||
def _plan_draft_prompt(seed: dict[str, Any]) -> str:
|
||||
return (
|
||||
"Draft a continual TraderAI plan as strict JSON.\n"
|
||||
"Return one JSON object only with keys: title, objective, kind, cadence, constraints, items.\n"
|
||||
"constraints may include message_tone, instructions, preferred_locations, excluded_sellers, max_unit_price.\n"
|
||||
"items must be an array of objects with item_name, desired_quantity, max_unit_price.\n"
|
||||
"If the request is vague, still fill cadence, message_tone, and instructions.\n"
|
||||
"Only include checklist items when they can be reasonably inferred from the request or existing draft.\n"
|
||||
"Do not wrap the JSON in markdown.\n\n"
|
||||
f"Current draft seed: {json.dumps(seed, ensure_ascii=True)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_object(raw_text: str) -> dict[str, Any] | None:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except ValueError:
|
||||
pass
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start == -1 or end <= start:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(text[start : end + 1])
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _normalize_plan_constraints(cls, value: Any, seed: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for source in (fallback, seed, value if isinstance(value, dict) else {}):
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
for key, item in source.items():
|
||||
if item in (None, "", [], {}):
|
||||
continue
|
||||
if key in {"preferred_locations", "excluded_sellers"}:
|
||||
if isinstance(item, list):
|
||||
merged[key] = [str(entry).strip() for entry in item if str(entry).strip()]
|
||||
elif key == "max_unit_price":
|
||||
try:
|
||||
merged[key] = float(item)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
else:
|
||||
merged[key] = str(item).strip()
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _normalize_plan_cadence(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
parts = text.split()
|
||||
return text if len(parts) == 5 else None
|
||||
|
||||
@classmethod
|
||||
def _normalize_plan_items(cls, items: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("item_name") or item.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
normalized_item: dict[str, Any] = {"item_name": name}
|
||||
try:
|
||||
normalized_item["desired_quantity"] = max(1, int(item.get("desired_quantity") or item.get("quantity") or 1))
|
||||
except (TypeError, ValueError):
|
||||
normalized_item["desired_quantity"] = 1
|
||||
try:
|
||||
if item.get("max_unit_price") not in (None, ""):
|
||||
normalized_item["max_unit_price"] = float(item.get("max_unit_price"))
|
||||
elif item.get("max_price") not in (None, ""):
|
||||
normalized_item["max_unit_price"] = float(item.get("max_price"))
|
||||
else:
|
||||
normalized_item["max_unit_price"] = None
|
||||
except (TypeError, ValueError):
|
||||
normalized_item["max_unit_price"] = None
|
||||
normalized.append(normalized_item)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _infer_item_names(text: str) -> list[str]:
|
||||
source = str(text or "")
|
||||
quoted = [match.strip() for match in re.findall(r'"([^"\n]{2,80})"|\'([^\'\n]{2,80})\'', source)]
|
||||
names = [next((part for part in group if part), "") for group in quoted]
|
||||
if names:
|
||||
return [name for name in names if name]
|
||||
|
||||
lines = []
|
||||
for raw_line in source.splitlines():
|
||||
line = raw_line.strip(" -*\t")
|
||||
if not line:
|
||||
continue
|
||||
if any(token in line for token in [",", ";", "/"]):
|
||||
parts = re.split(r"[,;/]+", line)
|
||||
lines.extend(part.strip() for part in parts if part.strip())
|
||||
else:
|
||||
lines.append(line)
|
||||
|
||||
stopwords = {
|
||||
"need", "needs", "want", "wants", "find", "draft", "deal", "deals", "parts", "part", "items",
|
||||
"watch", "track", "check", "buy", "buying", "for", "the", "and", "with", "from", "best", "cheapest",
|
||||
}
|
||||
inferred = []
|
||||
for line in lines:
|
||||
clean = re.sub(r"\s+", " ", line).strip().strip(".")
|
||||
if len(clean) < 3:
|
||||
continue
|
||||
lowered = clean.casefold()
|
||||
if lowered in stopwords:
|
||||
continue
|
||||
if any(phrase in lowered for phrase in ["find and draft", "check for", "continue this plan"]):
|
||||
continue
|
||||
inferred.append(clean[:120])
|
||||
deduped = []
|
||||
seen = set()
|
||||
for item in inferred:
|
||||
key = item.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
def _pending_payloads(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
@@ -1405,6 +1746,8 @@ class OllamaAgent:
|
||||
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
||||
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
||||
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
||||
"search_wikelo_ship_projects": "Searching Wikelo ship projects",
|
||||
"get_wikelo_ship_project": "Fetching Wikelo ship requirements",
|
||||
"search_cornerstone_items": "Searching Cornerstone items",
|
||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||
@@ -1442,6 +1785,21 @@ class OllamaAgent:
|
||||
"writing_tokens_per_second": rate(output_tokens, output_duration),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _cloud_usage_metrics(usage: dict[str, Any]) -> dict[str, Any]:
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
cache_hit_tokens = int(usage.get("prompt_cache_hit_tokens") or 0)
|
||||
cache_miss_tokens = int(usage.get("prompt_cache_miss_tokens") or 0)
|
||||
metrics = {
|
||||
"reading_tokens": prompt_tokens,
|
||||
"writing_tokens": completion_tokens,
|
||||
}
|
||||
if cache_hit_tokens or cache_miss_tokens:
|
||||
metrics["cache_hit_tokens"] = cache_hit_tokens
|
||||
metrics["cache_miss_tokens"] = cache_miss_tokens
|
||||
return metrics
|
||||
|
||||
@staticmethod
|
||||
def _profile_identity(profile: dict[str, Any]) -> str:
|
||||
user = profile.get("uex_user")
|
||||
|
||||
Reference in New Issue
Block a user