Typed chat: free hosted models -> local model (llama-server), no keyword rules; busy reply + Retry #7

Merged
leora merged 7 commits from feat/local-llm-backup into main 2026-10-08 22:18:21 +08:00
3 changed files with 55 additions and 7 deletions
Showing only changes of commit 6c2a1d6a40 - Show all commits

View File

@ -497,14 +497,46 @@ EXAMPLES (visitor message => your whole answer). Follow the pattern; use the vis
"a teal to purple gradient behind everything" => {"reply":"Teal to purple gradient background.","actions":[{"type":"set_theme","payload":{"background":{"kind":"gradient","colors":["#12a594","#8e4ec6"],"angle":135}}}]}
"make it neon" => {"reply":"Neon look applied.","actions":[{"type":"set_theme","payload":{"preset":"neon"}}]}
"I'd like to sell my old sofa for $60, cash only" => {"reply":"I've filled in the listing form. Check it and press Publish.","actions":[{"type":"show_listing_form","payload":{"defaults":{"title":"Old sofa","kind":"product","price":6000,"description":"Used sofa in good condition.","category":"home","area":"Demo Town","payment_options":["cash"]}}}]}
"<a search>" => {"reply":"<one short line>","actions":[{"type":"show_cards","payload":{"heading":"<what was searched>","listing_ids":["<only CATALOG ids that match>"]}}]}
"<a search>" => {"reply":"<one short line>","actions":[{"type":"show_cards","payload":{"heading":"<what was searched>","listing_ids":["<id of each CATALOG item that fits>"]}}]}
"I'll buy <item> and pay online" => {"reply":"Here's the checkout (price + fee).","actions":[{"type":"show_checkout","payload":{"listing_id":"<that item's CATALOG id>"}}]}
"can I pay cash for <item> in person?" => {"reply":"Cash meetup set up, no fee.","actions":[{"type":"show_cash_meetup","payload":{"listing_id":"<that item's CATALOG id>","suggested_place":"Demo Town square","suggested_time":"Saturday 10am"}}]}
Only change what the visitor asked for. A visual or text request returns set_theme only (no listings).
"""
# Small models miscopy 8-hex ids (lst_ffe42107 vs lst_29d7f320), so the local model sees short catalog refs (L1, L2, ...)
# that are mapped back to real ids before anything runs. Unknown refs are dropped like any invented id.
LOCAL_SCHEMA = UI_SCHEMA.replace('"lst_..."', '"L7"').replace("internal ids (lst_...,", "internal ids (L7, lst_...,")
ALIAS_RE = re.compile(r"\s*\(?\bL\d{1,3}\b\)?:?")
def _aliases(store):
"""ref -> real listing id, in catalog order (stable while the catalog is unchanged, so the prompt stays cacheable)."""
return {f"L{i}": c["id"] for i, c in enumerate(_catalog(store), 1)}
def _unalias(obj, alias):
"""Map L-refs back to real listing ids in action payloads / tool args (in place)."""
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k == "listing_id" and isinstance(v, str):
obj[k] = alias.get(v.strip(), v)
elif k == "listing_ids" and isinstance(v, list):
obj[k] = [alias.get(x.strip(), x) if isinstance(x, str) else x for x in v]
else:
_unalias(v, alias)
elif isinstance(obj, list):
for x in obj:
_unalias(x, alias)
return obj
def _system_prompt(store, look="", local=False):
# stable parts first (schema, examples, catalog) so a local model's prompt cache can reuse them across turns
return (UI_SCHEMA + (LOCAL_EXAMPLES if local else "") + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
cat = _catalog(store)
if local:
cat = [{**c, "id": f"L{i}"} for i, c in enumerate(cat, 1)]
return ((LOCAL_SCHEMA + LOCAL_EXAMPLES if local else UI_SCHEMA) + "\nCATALOG (all active listings):\n" + json.dumps(cat)[:9000] +
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}") +
"\nPAGE_TEXT (what the editable text slots show right now):\n" + _page_text(look))
@ -588,24 +620,30 @@ def _llm_once(store, cfg, model, message, history, look="", timeout=None):
msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
msgs.append({"role": "user", "content": _visitor(message) if local else message})
t_end = time.time() + (timeout or LLM_TIMEOUT)
alias = _aliases(store) if local else {}
content = _call_llm(cfg, model, msgs, timeout=timeout)
plan = _parse_agent_json(content)
plan = _unalias(_parse_agent_json(content), alias)
tool_results = []
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
if tools:
tool_results = run_tools(store, tools)
tr = json.dumps(tool_results)
for ref, real in alias.items():
tr = tr.replace(real, ref)
msgs += [{"role": "assistant", "content": content},
{"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
{"role": "user", "content": "TOOL_RESULTS:\n" + tr[:6000] +
"\nNow return the final {\"reply\",\"actions\"} JSON only."}]
left = t_end - time.time()
if left < 2:
raise LLMError("timeout", "no time left for the tool round")
plan = _parse_agent_json(_call_llm(cfg, model, msgs, timeout=left))
plan = _unalias(_parse_agent_json(_call_llm(cfg, model, msgs, timeout=left)), alias)
actions = [a for a in (plan.get("actions") or []) if isinstance(a, dict) and a.get("type") in ALLOWED_ACTIONS]
reply = str(plan.get("reply") or "").strip()
if not reply and not actions:
raise LLMError("empty_plan", "")
reply = ID_RE.sub("", reply).strip()
if local:
reply = ALIAS_RE.sub("", reply).strip()
return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results}

View File

@ -51,7 +51,7 @@ class H(BaseHTTPRequestHandler):
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return
if last.startswith("TOOL_RESULTS"):
ids = re.findall(r'"id": "(lst_[a-f0-9]+)"', last)[:3]
ids = re.findall(r'"id": "(lst_[a-f0-9]+|L\d+)"', last)[:3] # local model sees short refs (L1, L2, ...)
out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.",
"actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}},
{"type": "not_allowed_component", "payload": {}}]}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Unit checks for the model-output normalisers used with the local backup model (no server, no model)."""
import os, sys, tempfile
import os, re, sys, tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["AGENT_MARKET_DATA"] = tempfile.mkdtemp()
for k in ("AGENT_MARKET_LLM_API_KEY_FILE", "AGENT_MARKET_LOCAL_LLM_URL"):
@ -37,6 +37,16 @@ check("no hosted key and no local model -> no chain (typed messages get the busy
os.environ.update(AGENT_MARKET_LOCAL_LLM_URL="http://127.0.0.1:9/v1", AGENT_MARKET_LOCAL_LLM_MODEL="m")
cfg = agent._llm_config()
check("local model alone is a valid chain", cfg and cfg["models"] == [] and cfg["local"]["model"] == "m" and cfg["local"]["url"].endswith("/v1/chat/completions"))
# local model: short catalog refs in the prompt, mapped back to real ids; unknown refs left for hydrate() to drop
al = agent._aliases(st)
sp = agent._system_prompt(st, "", local=True)
check("local prompt uses short refs, not hex ids", '"id": "L1"' in sp and not re.search(r"lst_[0-9a-f]{6}", sp.split("CATALOG (all active listings)")[1]) and '"L7"' in sp, len(al))
plan = agent._unalias({"actions": [{"type": "show_cards", "payload": {"listing_ids": ["L1", "L999"]}},
{"type": "show_checkout", "payload": {"listing_id": "L2"}}]}, al)
check("refs mapped back (unknown kept for hydrate to drop)", plan["actions"][0]["payload"]["listing_ids"] == [al["L1"], "L999"] and
plan["actions"][1]["payload"]["listing_id"] == al["L2"])
check("hosted prompt keeps real ids", al["L1"] in agent._system_prompt(st, "", local=False))
# primer: re-prime after the local server restarts (llama-server /metrics counter goes back down) or is down
import http.server, threading
M = {"n": 500}