Local model: short catalog refs (L1, L2, ...) mapped back to real ids; checkout and cash examples

A 4B model miscopied 8-hex listing ids (picked a tutoring id for a kids' bike). The local prompt now shows L-refs, the
server maps them back before tools/hydration (unknown refs are dropped as before), tool results are aliased the same
way, and refs are scrubbed from replies. Hosted models still see real ids.
This commit is contained in:
leora 2026-10-08 22:05:11 +08:00
parent d785091d64
commit 6c2a1d6a40
3 changed files with 55 additions and 7 deletions

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}}}]} "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"}}]} "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"]}}}]} "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). 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): 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 # 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 "{}") + "\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)) "\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": h["role"], "content": str(h["content"])[:600]})
msgs.append({"role": "user", "content": _visitor(message) if local else message}) msgs.append({"role": "user", "content": _visitor(message) if local else message})
t_end = time.time() + (timeout or LLM_TIMEOUT) t_end = time.time() + (timeout or LLM_TIMEOUT)
alias = _aliases(store) if local else {}
content = _call_llm(cfg, model, msgs, timeout=timeout) content = _call_llm(cfg, model, msgs, timeout=timeout)
plan = _parse_agent_json(content) plan = _unalias(_parse_agent_json(content), alias)
tool_results = [] tool_results = []
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3] tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
if tools: if tools:
tool_results = run_tools(store, 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}, 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."}] "\nNow return the final {\"reply\",\"actions\"} JSON only."}]
left = t_end - time.time() left = t_end - time.time()
if left < 2: if left < 2:
raise LLMError("timeout", "no time left for the tool round") 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] 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() reply = str(plan.get("reply") or "").strip()
if not reply and not actions: if not reply and not actions:
raise LLMError("empty_plan", "") raise LLMError("empty_plan", "")
reply = ID_RE.sub("", reply).strip() 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} 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) self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return return
if last.startswith("TOOL_RESULTS"): 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.", out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.",
"actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}}, "actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}},
{"type": "not_allowed_component", "payload": {}}]} {"type": "not_allowed_component", "payload": {}}]}

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Unit checks for the model-output normalisers used with the local backup model (no server, no model).""" """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__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
os.environ["AGENT_MARKET_DATA"] = tempfile.mkdtemp() os.environ["AGENT_MARKET_DATA"] = tempfile.mkdtemp()
for k in ("AGENT_MARKET_LLM_API_KEY_FILE", "AGENT_MARKET_LOCAL_LLM_URL"): 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") 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() 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")) 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 # primer: re-prime after the local server restarts (llama-server /metrics counter goes back down) or is down
import http.server, threading import http.server, threading
M = {"n": 500} M = {"n": 500}