648 lines
36 KiB
Python
648 lines
36 KiB
Python
"""Market agent: LLM-driven when a key is available, otherwise a deterministic fallback.
|
|
|
|
Returns {reply, actions} where actions is a list of UI action objects from a fixed
|
|
component set the frontend knows how to render.
|
|
"""
|
|
from __future__ import annotations
|
|
import concurrent.futures, json, os, re, time, urllib.request, urllib.error
|
|
from .store import new_id, now
|
|
|
|
FEE_BPS = int(os.environ.get("AGENT_MARKET_FEE_BPS", "500")) # 5.00%
|
|
FEE_RATE = FEE_BPS / 10000.0
|
|
|
|
UI_SCHEMA = """
|
|
You are Market Guide, the AI agent that drives the page of Agent Market, a TEST DEMO marketplace
|
|
where people and AI agents buy and sell products and services. All listings are fake test data and
|
|
all payments are TEST MODE ONLY (no real money).
|
|
|
|
You control what the page shows by returning UI actions. Reply with ONE JSON object and nothing else
|
|
(no markdown, no prose outside JSON):
|
|
{"reply": "<short friendly message, max 60 words>", "actions": [ ... ]}
|
|
|
|
Allowed actions (use only these types; listing ids must come from CATALOG below, never invent them):
|
|
- {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} list matching items
|
|
- {"type":"show_compare","payload":{"heading":"...","listing_ids":["lst_...","lst_..."]}} side-by-side, 2-4 ids
|
|
- {"type":"show_checkout","payload":{"listing_id":"lst_..."}} online purchase; the panel shows price + fee
|
|
- {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} pay cash in person, $0 fee
|
|
- {"type":"show_listing_form","payload":{"defaults":{"title":"...","kind":"product|service","price":<integer cents>,"description":"...","category":"...","area":"Demo Town","payment_options":["online","cash"]}}}
|
|
use when the user wants to sell/list something; prefill everything you can infer, the user presses Publish
|
|
("cash only" => payment_options ["cash"]; "$120" => price 12000)
|
|
- {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help
|
|
- {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}}
|
|
|
|
- {"type":"set_theme","payload":{...}} change how the page LOOKS (see LOOK & LAYOUT). Applies instantly, only for this visitor.
|
|
|
|
LOOK & LAYOUT: you control the entire look of the page. Never say you can't change something visual; use set_theme.
|
|
set_theme payload = any subset of this spec (it is merged into the current look):
|
|
{"preset":"retro|minimal|neon|pastel|high contrast|sunset|ocean", // starts from that preset, other keys override
|
|
"reset":true, // back to the default look
|
|
"mode":"dark|light",
|
|
"background":{"kind":"solid|gradient|pattern|image","colors":["#hex",...],"angle":0-360,
|
|
"pattern":"dots|grid|stripes|diagonal|checks|waves","url":"https://images.unsplash.com/... or picsum.photos"},
|
|
"palette":{"bg","surface","card","text","muted","accent","border"}, // CSS colours (#hex, rgb(), names)
|
|
"font":"FONT","headingFont":"FONT","fontScale":0.75-1.75 (1=default),
|
|
"radius":0-32 (0=sharp corners, 24+=pill-ish),"density":"compact|normal|comfy",
|
|
"cardStyle":"flat|outlined|elevated|glass","layout":"grid|list","columns":0-6 (0=auto),
|
|
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]},
|
|
"sections":{"order":["hero","chips","bullets","results","footer"],"hidden":["bullets",...]},
|
|
"chat":{"position":"left|right","width":280-600,"height":320-820,"bg","accent","text"},
|
|
"animations":true|false,
|
|
"customCss":"<plain CSS rules for anything else, e.g. .card{border-width:3px} h1{letter-spacing:4px}>"}
|
|
FONT = system | serif | mono | Inter | Poppins | Montserrat | Nunito | Space Grotesk | Lora | Merriweather |
|
|
Playfair Display | Roboto Slab | Fira Code | Space Mono | VT323 | Press Start 2P | Bebas Neue | Pacifico | Comic Neue
|
|
Relative requests use CURRENT_LOOK (e.g. "bigger text" => raise fontScale from its current value, default 1).
|
|
Light mode usually needs only {"mode":"light"} plus any colours asked for; "green accent" => palette.accent.
|
|
Out of bounds (say so briefly and offer the closest option): the TEST DEMO banner, the agent badge, checkout fee
|
|
lines/amounts and the payment form are fixed for safety; no scripts or HTML; images only from the hosts above.
|
|
For a purely visual request return ONLY set_theme (do not re-show listings), and confirm what changed in "reply".
|
|
|
|
Rules:
|
|
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
|
|
- Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services.
|
|
- Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00).
|
|
- If nothing fits, say so and show the closest options with show_cards.
|
|
- Online payment adds a FEE_PERCENT facilitation fee paid by the buyer; cash in person has no fee. Mention this when relevant.
|
|
- Usually return exactly one main panel action. Keep "reply" short and do not repeat the whole catalog in text.
|
|
- Optional: if you truly need a server action first, add "tools":[{"name":"search","args":{"q":"..."}}] (also
|
|
create_listing, start_checkout, start_cash_deal); you will get TOOL_RESULTS and then answer. Prefer not to.
|
|
""".replace("FEE_PERCENT", f"{FEE_RATE*100:.1f}%")
|
|
|
|
|
|
ALLOWED_ACTIONS = {"show_hero", "show_cards", "show_listing_form", "show_compare",
|
|
"show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast", "set_theme"}
|
|
ID_RE = re.compile(r"\s*\(?\b(?:lst|pi|cash|ord|evt)_[0-9a-f]{6,}\b\)?")
|
|
|
|
|
|
def money(cents: int) -> str:
|
|
return f"${cents/100:.2f}"
|
|
|
|
|
|
def fee_for(amount_cents: int) -> int:
|
|
return int(round(amount_cents * FEE_RATE))
|
|
|
|
|
|
def run_tools(store, tools):
|
|
results = []
|
|
for t in tools or []:
|
|
name = t.get("name")
|
|
args = t.get("args") or {}
|
|
try:
|
|
if name == "search":
|
|
q = (args.get("q") or "").lower().strip()
|
|
kind = args.get("kind") or "any"
|
|
limit = int(args.get("limit") or 8)
|
|
hits = []
|
|
for L in store.all("listings"):
|
|
if L.get("status") != "active":
|
|
continue
|
|
if kind in ("product", "service") and L.get("kind") != kind:
|
|
continue
|
|
blob = " ".join([
|
|
L.get("title", ""), L.get("description", ""), L.get("category", ""),
|
|
" ".join(L.get("tags") or []), L.get("area", ""), L.get("kind", ""),
|
|
]).lower()
|
|
if not q or all(tok in blob for tok in q.split()):
|
|
hits.append(L)
|
|
hits = hits[:limit]
|
|
results.append({"name": name, "ok": True, "listings": hits})
|
|
elif name == "get_listing":
|
|
L = store.get("listings", args.get("id"))
|
|
results.append({"name": name, "ok": bool(L), "listing": L})
|
|
elif name == "create_listing":
|
|
required = ["title", "kind", "price", "description"]
|
|
if any(not args.get(k) and args.get(k) != 0 for k in required):
|
|
results.append({"name": name, "ok": False, "error": "missing_fields"})
|
|
else:
|
|
lid = new_id("lst")
|
|
L = {
|
|
"id": lid, "title": str(args["title"])[:120],
|
|
"kind": "service" if args.get("kind") == "service" else "product",
|
|
"price": max(0, int(args["price"])),
|
|
"currency": "USD", "description": str(args.get("description") or "")[:800],
|
|
"category": str(args.get("category") or "misc")[:40],
|
|
"tags": list(args.get("tags") or [])[:12],
|
|
"emoji": str(args.get("emoji") or "📦")[:4],
|
|
"seller": str(args.get("seller") or "demo-seller")[:60],
|
|
"seller_type": args.get("seller_type") if args.get("seller_type") in ("human", "agent") else "human",
|
|
"area": str(args.get("area") or "Demo Town")[:60],
|
|
"payment_options": [p for p in (args.get("payment_options") or ["online", "cash"]) if p in ("online", "cash")] or ["cash"],
|
|
"status": "active", "test_data": True, "created_at": now(),
|
|
}
|
|
store.put("listings", L)
|
|
store.log("listing_created", {"id": lid})
|
|
results.append({"name": name, "ok": True, "listing": L})
|
|
elif name == "start_checkout":
|
|
L = store.get("listings", args.get("listing_id"))
|
|
if not L:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif L.get("status") != "active":
|
|
results.append({"name": name, "ok": False, "error": "listing_not_active"})
|
|
elif "online" not in (L.get("payment_options") or []):
|
|
results.append({"name": name, "ok": False, "error": "online_not_available"})
|
|
else:
|
|
qty = max(1, int(args.get("quantity") or 1))
|
|
sub = L["price"] * qty
|
|
fee = fee_for(sub)
|
|
intent = {
|
|
"id": new_id("pi"), "listing_id": L["id"], "title": L["title"],
|
|
"buyer": str(args.get("buyer") or "demo-buyer")[:60],
|
|
"quantity": qty, "subtotal_cents": sub, "fee_cents": fee,
|
|
"fee_bps": FEE_BPS, "total_cents": sub + fee,
|
|
"currency": "USD", "status": "requires_payment",
|
|
"mode": "test", "provider": "mock", "created_at": now(),
|
|
}
|
|
store.put("intents", intent)
|
|
store.log("checkout_started", {"id": intent["id"]})
|
|
results.append({"name": name, "ok": True, "intent": intent, "listing": L})
|
|
elif name == "start_cash_deal":
|
|
L = store.get("listings", args.get("listing_id"))
|
|
if not L:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif L.get("status") != "active":
|
|
results.append({"name": name, "ok": False, "error": "listing_not_active"})
|
|
elif "cash" not in (L.get("payment_options") or []):
|
|
results.append({"name": name, "ok": False, "error": "cash_not_available"})
|
|
else:
|
|
deal = {
|
|
"id": new_id("cash"), "listing_id": L["id"], "title": L["title"],
|
|
"buyer": str(args.get("buyer") or "demo-buyer")[:60],
|
|
"seller": L.get("seller"), "price_cents": L["price"],
|
|
"fee_cents": 0, "fee_bps": 0, "currency": "USD",
|
|
"place": str(args.get("place") or L.get("area") or "Demo Town square")[:120],
|
|
"time": str(args.get("time") or "this weekend, afternoon")[:80],
|
|
"status": "arranged", "mode": "cash_in_person", "created_at": now(),
|
|
}
|
|
store.put("cash_deals", deal)
|
|
store.log("cash_deal", {"id": deal["id"]})
|
|
results.append({"name": name, "ok": True, "deal": deal, "listing": L})
|
|
elif name == "complete_cash_deal":
|
|
deal = store.get("cash_deals", args.get("deal_id"))
|
|
if not deal:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif deal["status"] == "completed":
|
|
results.append({"name": name, "ok": True, "deal": deal, "order": store.get("orders", deal.get("order_id"))})
|
|
else:
|
|
order = {"id": new_id("ord"), "listing_id": deal["listing_id"], "title": deal["title"],
|
|
"buyer": deal["buyer"], "seller": deal["seller"], "method": "cash_in_person",
|
|
"subtotal_cents": deal["price_cents"], "fee_cents": 0, "fee_bps": 0,
|
|
"total_cents": deal["price_cents"], "seller_receives_cents": deal["price_cents"],
|
|
"currency": "USD", "status": "completed", "mode": "test", "created_at": now()}
|
|
store.put("orders", order)
|
|
deal.update(status="completed", order_id=order["id"], completed_at=now())
|
|
store.put("cash_deals", deal)
|
|
L = store.get("listings", deal["listing_id"])
|
|
if L and L.get("kind") == "product":
|
|
L["status"] = "sold"; store.put("listings", L)
|
|
store.log("cash_deal_completed", {"id": deal["id"], "order": order["id"]})
|
|
results.append({"name": name, "ok": True, "deal": deal, "order": order})
|
|
else:
|
|
results.append({"name": name, "ok": False, "error": "unknown_tool"})
|
|
except Exception as e:
|
|
results.append({"name": name, "ok": False, "error": str(e)[:200]})
|
|
return results
|
|
|
|
|
|
# ---------- deterministic fallback (no LLM key) ----------
|
|
|
|
def _search_q(store, q, kind="any", limit=6):
|
|
return run_tools(store, [{"name": "search", "args": {"q": q, "kind": kind, "limit": limit}}])[0]["listings"]
|
|
|
|
|
|
def fallback_event(store, ev: dict, actor: str):
|
|
t = ev.get("type")
|
|
lid = ev.get("listing_id")
|
|
if t == "view_listing" and lid:
|
|
L = store.get("listings", lid)
|
|
if L:
|
|
return {"reply": f"{L['title']}: {money(L['price'])}. {L['description']}",
|
|
"actions": [{"type": "show_cards", "payload": {"heading": "Listing details", "listing_ids": [lid]}}]}
|
|
if t == "buy_online" and lid:
|
|
res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": lid, "buyer": actor}}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Online checkout isn't available for this one ({res['error']}). Try cash in person.", "actions": []}
|
|
it = res["intent"]
|
|
return {"reply": (f"Checkout ready. {money(it['subtotal_cents'])} + {FEE_RATE*100:.1f}% facilitation fee "
|
|
f"{money(it['fee_cents'])} = {money(it['total_cents'])}. TEST MODE: card 4242 4242 4242 4242."),
|
|
"actions": [{"type": "show_checkout", "payload": {"listing_id": lid, "intent_id": it["id"]}}]}
|
|
if t == "buy_cash" and lid:
|
|
res = run_tools(store, [{"name": "start_cash_deal", "args": {"listing_id": lid, "buyer": actor}}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Cash isn't offered for this one ({res['error']}). You can pay online instead.", "actions": []}
|
|
d = res["deal"]
|
|
return {"reply": f"Cash meetup arranged. No fee: you pay the seller {money(d['price_cents'])} in person.",
|
|
"actions": [{"type": "show_cash_meetup", "payload": {"listing_id": lid, "deal_id": d["id"]}}]}
|
|
if t == "compare":
|
|
ids = [i for i in (ev.get("listing_ids") or []) if store.get("listings", i)][:4]
|
|
if len(ids) >= 2:
|
|
return {"reply": f"Here's a side-by-side of {len(ids)} listings, including what each costs online vs cash.",
|
|
"actions": [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]}
|
|
return {"reply": "Pick at least two listings to compare.", "actions": []}
|
|
if t == "sell":
|
|
return fallback_agent(store, "i want to sell", [], actor)
|
|
if t == "submit_listing":
|
|
f = ev.get("fields") or {}
|
|
try:
|
|
price = int(round(float(str(f.get("price", "0")).replace("$", "")) * 100))
|
|
except ValueError:
|
|
price = -1
|
|
if not f.get("title") or not f.get("description") or price < 0:
|
|
return {"reply": "The listing needs a title, a description and a price.",
|
|
"actions": [{"type": "show_listing_form", "payload": {"defaults": f}}]}
|
|
args = {**f, "price": price, "seller": actor, "seller_type": "human"}
|
|
res = run_tools(store, [{"name": "create_listing", "args": args}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Couldn't create it: {res.get('error')}", "actions": []}
|
|
L = res["listing"]
|
|
return {"reply": f"Published “{L['title']}” at {money(L['price'])} (test listing).",
|
|
"actions": [{"type": "toast", "payload": {"level": "ok", "text": "Listing published"}},
|
|
{"type": "show_cards", "payload": {"heading": "Your new listing", "listing_ids": [L["id"]]}}]}
|
|
if t == "payment_result":
|
|
oid = ev.get("order_id")
|
|
if ev.get("status") == "succeeded" and oid and store.get("orders", oid):
|
|
o = store.get("orders", oid)
|
|
return {"reply": f"Test payment succeeded. You paid {money(o['total_cents'])} including a {money(o['fee_cents'])} fee.",
|
|
"actions": [{"type": "show_receipt", "payload": {"order_id": oid}}]}
|
|
return {"reply": "The test payment didn't go through. Use card 4242 4242 4242 4242, or choose cash in person.",
|
|
"actions": [{"type": "toast", "payload": {"level": "warn", "text": "Payment declined (test)"}}]}
|
|
if t == "cash_completed":
|
|
res = run_tools(store, [{"name": "complete_cash_deal", "args": {"deal_id": ev.get("deal_id")}}])[0]
|
|
if res["ok"]:
|
|
o = res["order"]
|
|
return {"reply": f"Cash deal marked complete. Fee $0.00; the seller keeps all {money(o['total_cents'])}.",
|
|
"actions": [{"type": "show_receipt", "payload": {"order_id": o["id"]}}]}
|
|
return {"reply": "Couldn't find that cash deal.", "actions": []}
|
|
return {"reply": "I didn't recognise that action.", "actions": []}
|
|
|
|
|
|
def fallback_agent(store, message: str, history, actor: str = "demo-buyer", look=None):
|
|
m = (message or "").strip()
|
|
low = m.lower()
|
|
actions = []
|
|
reply = ""
|
|
|
|
from .theme import parse_look_command
|
|
th = parse_look_command(m, look if isinstance(look, dict) else {})
|
|
if th:
|
|
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}]}
|
|
|
|
# welcome / help
|
|
if not m or low in ("hi", "hello", "hey", "help", "start"):
|
|
reply = ("Welcome to Agent Market (TEST DEMO). I drive the page: ask me to search, "
|
|
"compare, list something for sale, check out online (test card 4242…), or arrange a cash meetup. "
|
|
f"Online fee is {FEE_RATE*100:.1f}%; cash in person is free.")
|
|
actions = [{"type": "show_hero", "payload": {
|
|
"title": "Agent Market",
|
|
"subtitle": "An AI agent runs this page. List, buy, or meet for cash — all demo data.",
|
|
"bullets": [
|
|
"Chat to search, list, buy, or arrange a meetup",
|
|
f"Online payments: test mode + {FEE_RATE*100:.1f}% facilitation fee",
|
|
"Cash in person: $0 fee — we just connect you",
|
|
"Agents can buy and sell via the open JSON API",
|
|
],
|
|
}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
|
|
|
# sell / list
|
|
if any(w in low for w in ("sell", "list something", "create a listing", "post a listing", "i want to sell")):
|
|
reply = "Fill in the listing form. I'll publish it as test data. Online fee only applies when a buyer pays online."
|
|
actions = [{"type": "show_listing_form", "payload": {"defaults": {
|
|
"title": "", "kind": "product", "price": 2500, "description": "",
|
|
"category": "misc", "area": "Demo Town", "payment_options": ["online", "cash"], "emoji": "📦",
|
|
}}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
|
|
|
# cash meetup
|
|
if "cash" in low or "meetup" in low or "meet in person" in low or "in person" in low:
|
|
listings = _search_q(store, "")
|
|
cashable = [L for L in listings if "cash" in (L.get("payment_options") or [])]
|
|
# try to pick by keyword
|
|
pick = None
|
|
for L in cashable:
|
|
if any(t in low for t in (L.get("tags") or []) + [L.get("title", "").lower().split()[0]]):
|
|
pick = L
|
|
break
|
|
if not pick and cashable:
|
|
pick = cashable[0]
|
|
if not pick:
|
|
return {"reply": "No cash-friendly listings right now. Try searching first.", "actions": [], "agent": "fallback"}
|
|
res = run_tools(store, [{"name": "start_cash_deal", "args": {
|
|
"listing_id": pick["id"], "buyer": actor,
|
|
"place": pick.get("area") or "Demo Town square",
|
|
"time": "Saturday afternoon",
|
|
}}])[0]
|
|
deal = res["deal"]
|
|
reply = (f"Cash deal arranged for “{pick['title']}” at {money(pick['price'])}. "
|
|
f"Fee: $0. Meet at {deal['place']} — {deal['time']}. (TEST DEMO, no real meetup.)")
|
|
actions = [{"type": "show_cash_meetup", "payload": {
|
|
"listing_id": pick["id"], "deal_id": deal["id"],
|
|
"suggested_place": deal["place"], "suggested_time": deal["time"],
|
|
"fee_cents": 0, "price_cents": pick["price"],
|
|
}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback", "tools": res}
|
|
|
|
# buy / checkout
|
|
if any(w in low for w in ("buy", "checkout", "purchase", "pay for", "i'll take")):
|
|
listings = store.all("listings")
|
|
pick = None
|
|
# explicit id
|
|
m_id = re.search(r"lst_[a-f0-9]+", low)
|
|
if m_id:
|
|
pick = store.get("listings", m_id.group(0))
|
|
if not pick:
|
|
for L in listings:
|
|
title_words = [w for w in L.get("title", "").lower().split() if len(w) > 3]
|
|
if any(w in low for w in title_words[:3]) or any(t in low for t in (L.get("tags") or [])):
|
|
if "online" in (L.get("payment_options") or []):
|
|
pick = L
|
|
break
|
|
if not pick:
|
|
online = [L for L in listings if "online" in (L.get("payment_options") or []) and L.get("status") == "active"]
|
|
pick = online[0] if online else None
|
|
if not pick:
|
|
return {"reply": "I couldn't match a listing to buy. Try “show bikes” first.", "actions": [], "agent": "fallback"}
|
|
res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": pick["id"], "buyer": actor}}])[0]
|
|
if not res.get("ok"):
|
|
return {"reply": f"Can't start online checkout: {res.get('error')}. Try cash instead.", "actions": [], "agent": "fallback"}
|
|
intent = res["intent"]
|
|
reply = (f"Checkout for “{pick['title']}”. Subtotal {money(intent['subtotal_cents'])} + "
|
|
f"{FEE_RATE*100:.1f}% fee {money(intent['fee_cents'])} = {money(intent['total_cents'])}. "
|
|
"TEST MODE: use card 4242 4242 4242 4242.")
|
|
actions = [{"type": "show_checkout", "payload": {
|
|
"listing_id": pick["id"], "intent_id": intent["id"], "quantity": 1,
|
|
"subtotal_cents": intent["subtotal_cents"], "fee_cents": intent["fee_cents"],
|
|
"fee_bps": FEE_BPS, "total_cents": intent["total_cents"],
|
|
}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
|
|
|
# compare
|
|
if "compare" in low:
|
|
q = re.sub(r"compare|vs|versus|and", " ", low)
|
|
hits = _search_q(store, q.strip() or "bike", limit=4)
|
|
if len(hits) < 2:
|
|
hits = _search_q(store, "bike", limit=3) or _search_q(store, "", limit=3)
|
|
ids = [h["id"] for h in hits[:3]]
|
|
reply = f"Comparing {len(ids)} listings."
|
|
actions = [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
|
|
|
# search / show
|
|
q = low
|
|
for prefix in ("show me ", "show ", "find ", "search for ", "search ", "looking for ", "list "):
|
|
if q.startswith(prefix):
|
|
q = q[len(prefix):]
|
|
break
|
|
kind = "any"
|
|
if "service" in q:
|
|
kind = "service"
|
|
q = q.replace("services", "").replace("service", "")
|
|
if "product" in q:
|
|
kind = "product"
|
|
q = q.replace("products", "").replace("product", "")
|
|
max_price = None
|
|
mp = re.search(r"(?:under|below|less than|max)\s*\$?(\d+(?:\.\d+)?)", q)
|
|
if mp:
|
|
max_price = int(float(mp.group(1)) * 100)
|
|
q = q[:mp.start()] + q[mp.end():]
|
|
stop = {"everything", "all", "anything", "me", "a", "an", "the", "some", "any", "for", "i", "want", "need", "find", "show", "please"}
|
|
q = " ".join(w for w in re.sub(r"[^a-z0-9 ]", " ", q).split() if w not in stop)
|
|
q = " ".join(w[:-1] if w.endswith("s") and len(w) > 4 and w not in ("glass",) else w for w in q.split())
|
|
hits = _search_q(store, q.strip(), kind=kind, limit=8)
|
|
if not hits and q.strip():
|
|
# broader: any token
|
|
hits = _search_q(store, "", kind=kind, limit=8)
|
|
hits = [h for h in hits if any(tok in (h.get("title","")+h.get("description","")).lower() for tok in q.split() if len(tok)>2)][:6]
|
|
if max_price is not None:
|
|
hits = [h for h in hits if h["price"] <= max_price]
|
|
if hits:
|
|
reply = f"Found {len(hits)} listing(s). Click a card, or say “buy the bike” / “cash meetup for the lamp”."
|
|
actions = [{"type": "show_cards", "payload": {"heading": f"Results for “{q.strip() or 'everything'}”", "listing_ids": [h["id"] for h in hits]}}]
|
|
else:
|
|
reply = "Nothing matched. Try “bikes”, “tutoring”, “lamp”, or “lawn”."
|
|
actions = [{"type": "toast", "payload": {"level": "warn", "text": "No matches"}}]
|
|
return {"reply": reply, "actions": actions, "agent": "fallback"}
|
|
|
|
|
|
# ---------- LLM path ----------
|
|
|
|
LLM_TIMEOUT = float(os.environ.get("AGENT_MARKET_LLM_TIMEOUT", "12")) # hard cap per model attempt
|
|
LLM_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_BUDGET", "28")) # hard cap across the whole chain
|
|
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
|
|
COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60"))
|
|
_cooldown = {} # model -> unix time until which we skip it after a 429
|
|
LAST = {"model": None, "at": None, "error": None}
|
|
QUOTA = {"until": 0}
|
|
|
|
|
|
class LLMError(Exception):
|
|
def __init__(self, kind, detail=""):
|
|
super().__init__(f"{kind}: {detail}")
|
|
self.kind = kind
|
|
|
|
|
|
def _read_key():
|
|
"""Key from a file path (preferred) or env. Never logged, never returned to clients."""
|
|
path = os.environ.get("AGENT_MARKET_LLM_API_KEY_FILE")
|
|
if path:
|
|
try:
|
|
with open(os.path.expanduser(path)) as f:
|
|
k = f.read().strip()
|
|
if k:
|
|
return k
|
|
except OSError:
|
|
pass
|
|
for env in ("AGENT_MARKET_LLM_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"):
|
|
if os.environ.get(env):
|
|
return os.environ[env]
|
|
return None
|
|
|
|
|
|
def _llm_config():
|
|
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain."""
|
|
if not _read_key():
|
|
return None
|
|
base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or ""
|
|
if not base:
|
|
base = "https://api.x.ai/v1" if os.environ.get("XAI_API_KEY") else ("https://api.openai.com/v1" if os.environ.get("OPENAI_API_KEY") else "")
|
|
models = [m.strip() for m in (os.environ.get("AGENT_MARKET_LLM_MODELS") or os.environ.get("AGENT_MARKET_LLM_MODEL") or "").split(",") if m.strip()]
|
|
if not base or not models:
|
|
return None
|
|
return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
|
|
"models": models, "model": models[0]}
|
|
|
|
|
|
def _call_llm(cfg, model, messages, json_mode=True):
|
|
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
|
|
if json_mode:
|
|
payload["response_format"] = {"type": "json_object"}
|
|
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={
|
|
"Authorization": "Bearer " + _read_key(),
|
|
"Content-Type": "application/json",
|
|
"X-Title": "Agent Market demo",
|
|
})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as r:
|
|
data = json.loads(r.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors="replace")[:2000]
|
|
if e.code == 429:
|
|
m = re.search(r'"X-RateLimit-Reset":\s*"?(\d{12,})', body)
|
|
if "per-day" in body or "daily" in body:
|
|
raise LLMError("daily_quota", m.group(1) if m else "")
|
|
raise LLMError("rate_limited", body[:300])
|
|
if e.code == 400 and json_mode and "response_format" in body:
|
|
return _call_llm(cfg, model, messages, json_mode=False)
|
|
raise LLMError(f"http_{e.code}", body[:300])
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
raise LLMError("timeout_or_network", type(e).__name__)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
err = data["error"]
|
|
code = err.get("code") if isinstance(err, dict) else None
|
|
raise LLMError("rate_limited" if code == 429 else "provider_error", str(err)[:200])
|
|
try:
|
|
return data["choices"][0]["message"].get("content") or ""
|
|
except (KeyError, IndexError, TypeError):
|
|
raise LLMError("bad_response", str(data)[:200])
|
|
|
|
|
|
def _parse_agent_json(text: str):
|
|
text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S).strip()
|
|
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
|
try:
|
|
obj = json.loads(text)
|
|
except ValueError:
|
|
m = re.search(r"\{.*\}", text, re.S)
|
|
if not m:
|
|
raise LLMError("not_json", text[:120])
|
|
try:
|
|
obj = json.loads(m.group(0))
|
|
except ValueError:
|
|
raise LLMError("not_json", text[:120])
|
|
if not isinstance(obj, dict):
|
|
raise LLMError("not_json", "top-level not an object")
|
|
return obj
|
|
|
|
|
|
def _catalog(store):
|
|
return [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price_cents": L["price"],
|
|
"category": L.get("category"), "about": (L.get("description") or "")[:110],
|
|
"seller_type": L.get("seller_type"), "pay": L.get("payment_options")}
|
|
for L in store.all("listings") if L.get("status") == "active"][:60]
|
|
|
|
|
|
def _llm_once(store, cfg, model, message, history, look=""):
|
|
sys_prompt = (UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
|
|
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}"))
|
|
msgs = [{"role": "system", "content": sys_prompt}]
|
|
for h in (history or [])[-6:]:
|
|
if h.get("role") in ("user", "assistant") and h.get("content"):
|
|
msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
|
|
msgs.append({"role": "user", "content": message})
|
|
content = _call_llm(cfg, model, msgs)
|
|
plan = _parse_agent_json(content)
|
|
tool_results = []
|
|
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
|
|
if tools:
|
|
tool_results = run_tools(store, tools)
|
|
msgs += [{"role": "assistant", "content": content},
|
|
{"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
|
|
"\nNow return the final {\"reply\",\"actions\"} JSON only."}]
|
|
plan = _parse_agent_json(_call_llm(cfg, model, msgs))
|
|
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()
|
|
return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results}
|
|
|
|
|
|
def llm_agent(store, message, history, cfg, look=""):
|
|
"""Try each model in the chain; skip models cooling down after a 429. Raises if all fail."""
|
|
errors = []
|
|
now_t = time.time()
|
|
if QUOTA["until"] > now_t:
|
|
raise LLMError("all_models_failed", json.dumps([{"model": "*", "error": "daily_quota", "ms": 0}]))
|
|
chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:]
|
|
start = time.time()
|
|
for model in chain:
|
|
t0 = time.time()
|
|
remaining = LLM_BUDGET - (t0 - start)
|
|
if remaining < 3:
|
|
errors.append({"model": model, "error": "skipped_budget", "ms": 0})
|
|
continue
|
|
try:
|
|
fut = _pool.submit(_llm_once, store, cfg, model, message, history, look)
|
|
try:
|
|
out = fut.result(timeout=min(LLM_TIMEOUT, remaining))
|
|
except concurrent.futures.TimeoutError:
|
|
raise LLMError("timeout", f">{min(LLM_TIMEOUT, remaining):.0f}s")
|
|
out.update(agent="llm", provider=cfg["provider"], model=model,
|
|
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
|
|
LAST.update(model=model, at=time.time(), error=None)
|
|
return out
|
|
except LLMError as e:
|
|
if e.kind == "rate_limited":
|
|
_cooldown[model] = time.time() + COOLDOWN_429
|
|
elif e.kind == "daily_quota":
|
|
# the free-tier daily cap is per key: park every model until the reset (or 15 min if unknown)
|
|
d = str(e).split(": ", 1)[-1]
|
|
until = int(d) / 1000 if d.isdigit() else time.time() + 900
|
|
for mm in cfg["models"]:
|
|
_cooldown[mm] = max(_cooldown.get(mm, 0), until)
|
|
QUOTA["until"] = until
|
|
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
break
|
|
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
except Exception as e: # never let the page break
|
|
errors.append({"model": model, "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)})
|
|
LAST.update(error=errors[-1]["error"] if errors else "no_model", at=time.time())
|
|
raise LLMError("all_models_failed", json.dumps(errors))
|
|
|
|
|
|
# Structured UI events (button clicks, form submits, payment results) have one obvious outcome,
|
|
# so they are handled deterministically: fast, reliable, and they don't spend free-tier LLM quota.
|
|
# Free-text messages go to the LLM. Set AGENT_MARKET_LLM_EVENTS=1 to route events to the LLM too.
|
|
LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1"
|
|
|
|
|
|
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""):
|
|
actor = (actor or "demo-buyer")[:60]
|
|
t0 = time.time()
|
|
try:
|
|
look_d = json.loads(look) if look else {}
|
|
except ValueError:
|
|
look_d = {}
|
|
if event and (event.get("type") == "landing" or not LLM_EVENTS):
|
|
if event.get("type") == "landing":
|
|
out = fallback_agent(store, "show me everything", [], actor)
|
|
else:
|
|
out = fallback_event(store, event, actor)
|
|
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
cfg = _llm_config()
|
|
if cfg:
|
|
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
|
|
try:
|
|
return llm_agent(store, text, history or [], cfg, look)
|
|
except Exception as e:
|
|
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d)
|
|
out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__),
|
|
fallbacks=json.loads(str(e).split(": ", 1)[1]) if getattr(e, "kind", "") == "all_models_failed" else [],
|
|
latency_ms=int((time.time() - t0) * 1000))
|
|
out["reply"] = "(AI model unavailable right now; the built-in agent answered.) " + out["reply"]
|
|
return out
|
|
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor, look_d)
|
|
out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
|
|
|
|
def agent_status():
|
|
cfg = _llm_config()
|
|
if cfg:
|
|
now_t = time.time()
|
|
return {"mode": "llm", "provider": cfg["provider"], "model": cfg["models"][0], "chain": cfg["models"],
|
|
"cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t],
|
|
"last_model": LAST["model"], "last_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic",
|
|
"daily_quota_exhausted_until_utc": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime(QUOTA["until"])) if QUOTA["until"] > now_t else None}
|
|
return {"mode": "fallback", "provider": "deterministic", "model": None,
|
|
"note": "No hosted LLM configured (set AGENT_MARKET_LLM_API_KEY_FILE or AGENT_MARKET_LLM_API_KEY, plus AGENT_MARKET_LLM_BASE_URL and AGENT_MARKET_LLM_MODELS). Using the labelled rule-based fallback agent."}
|