Compare commits
3 Commits
ec64d2e32c
...
aacd2b789e
| Author | SHA1 | Date | |
|---|---|---|---|
| aacd2b789e | |||
| 0c021dfdc9 | |||
| 3db09b4d03 |
@ -65,6 +65,13 @@ never changes the page for anyone else. **Reset look** in the header, or saying
|
||||
- The CSS uses cascade layers in the order `base < user < protected`. The TEST DEMO banner, the agent badge, and the checkout, cash and receipt panels (fee lines, totals, the test-card notice and the card inputs) live in `protected`. No theme can hide, recolour or shrink them.
|
||||
- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours.
|
||||
- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it.
|
||||
- **Page text.** The logo (top-left brand text), headline, subtitle, footer, quick-action buttons and bullets are editable
|
||||
through `copy`. The built-in agent (`market/copyedit.py`) understands "replace/change/rename/swap A to/with/into B",
|
||||
locations such as top left, header, logo, brand, site name, title, headline, subtitle and footer, quoted or unquoted text,
|
||||
and a generic find-and-replace when A is the current text of exactly one slot. It keeps the case you type for B; text typed
|
||||
all in lowercase takes the style of the text it replaces ("new internet" replacing "Agent Market" gives "New Internet").
|
||||
When it can't tell what to change, it says what it understood and offers the closest option. The model prompt gets the
|
||||
same slot map plus the visitor's current page text (`PAGE_TEXT`).
|
||||
- If the model is unavailable, the built-in agent understands the common look commands: presets, light/dark, background colours, gradients and patterns, accent and text colour, fonts, bigger or smaller text, corners, density, card style, list/grid, columns, chat position and size, hiding or showing sections, headline, logo and subtitle, animations, and reset.
|
||||
|
||||
### Fees
|
||||
@ -203,6 +210,7 @@ python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl
|
||||
bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, fallback, key handling, rate limit
|
||||
python3 tests/nl_check.py http://127.0.0.1:8795 # real model: 6 natural-language prompts
|
||||
python3 tests/test_theme.py # theme spec + customCss sanitiser
|
||||
python3 tests/test_copyedit.py # built-in copy edits (logo/headline/... phrasings)
|
||||
NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, persistence, hostile stored theme, reset (REQUIRE_LLM=1 to insist on the model)
|
||||
NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome (14 checks)
|
||||
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs fake model: badges, fallback text, quota, rate-limit toast, sanitised set_theme
|
||||
|
||||
@ -43,7 +43,7 @@ set_theme payload = any subset of this spec (it is merged into the current look)
|
||||
"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":["..."]},
|
||||
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]}, // page TEXT, see PAGE_TEXT
|
||||
"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,
|
||||
@ -55,6 +55,13 @@ set_theme payload = any subset of this spec (it is merged into the current look)
|
||||
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".
|
||||
PAGE TEXT is editable with set_theme copy (current values are in PAGE_TEXT below). Where things are:
|
||||
copy.logo = the brand/site name in the TOP-LEFT corner of the header (a.k.a. logo, brand, site name, header text, "top left").
|
||||
copy.headline = the big title, copy.subhead = the line under it (subtitle/tagline), copy.footer = the bottom line,
|
||||
copy.chips = the quick-action buttons, copy.bullets = the bullet points (send the whole list when changing one item).
|
||||
"replace/change/rename A to/with B": find A in PAGE_TEXT and change that slot (only the words A if A is part of it).
|
||||
Keep the user's capitalisation for B (if they typed it all lowercase, match the style of the text it replaces).
|
||||
e.g. "replace agent market to new internet on the top left corner" => {"type":"set_theme","payload":{"copy":{"logo":"New Internet"}}}
|
||||
|
||||
Rules:
|
||||
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
|
||||
@ -283,7 +290,8 @@ def fallback_agent(store, message: str, history, actor: str = "demo-buyer", look
|
||||
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]}]}
|
||||
# payload None = understood a copy edit but couldn't apply it; the reply says why (no listings, no silent no-op)
|
||||
return {"reply": th[0], "actions": [{"type": "set_theme", "payload": th[1]}] if th[1] else []}
|
||||
|
||||
# welcome / help
|
||||
if not m or low in ("hi", "hello", "hey", "help", "start"):
|
||||
@ -529,9 +537,21 @@ def _catalog(store):
|
||||
for L in store.all("listings") if L.get("status") == "active"][:60]
|
||||
|
||||
|
||||
def _page_text(look):
|
||||
from .copyedit import current_copy
|
||||
try:
|
||||
t = json.loads(look) if look else {}
|
||||
except Exception:
|
||||
t = {}
|
||||
c = current_copy(t if isinstance(t, dict) else {}, FEE_BPS)
|
||||
return json.dumps({"logo": c["logo"], "headline": c["headline"], "subhead": c["subhead"][:120], "footer": c["footer"],
|
||||
"chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False)
|
||||
|
||||
|
||||
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 "{}"))
|
||||
"\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))
|
||||
msgs = [{"role": "system", "content": sys_prompt}]
|
||||
for h in (history or [])[-6:]:
|
||||
if h.get("role") in ("user", "assistant") and h.get("content"):
|
||||
|
||||
264
market/copyedit.py
Normal file
264
market/copyedit.py
Normal file
@ -0,0 +1,264 @@
|
||||
"""Rule-based UI copy edits for the built-in agent (used when no hosted/local model answers).
|
||||
|
||||
Understands e.g. "replace agent market to new internet on the top left corner", "rename the logo to X",
|
||||
"call the site X", "top left should say X", "change the title to X", "replace A with B" (generic find-and-replace
|
||||
across the editable copy slots: logo, headline, subtitle, footer, chip labels, bullets).
|
||||
Returns (reply, patch) where patch is a set_theme patch, or (reply, None) when it understood a copy edit but
|
||||
couldn't apply it (the reply says what it understood and offers the closest option), or None (not a copy edit).
|
||||
"""
|
||||
import difflib
|
||||
import os
|
||||
import re
|
||||
|
||||
# Keep in sync with copyDefaults in web/src/App.tsx (tests/test_copyedit.py checks this).
|
||||
DEFAULT_COPY = {
|
||||
"logo": "Agent Market",
|
||||
"headline": "A marketplace you can just talk to.",
|
||||
"subhead": "Tell the agent what you want. It searches, compares, lists your stuff and runs the checkout, right here on the "
|
||||
"page. You can also restyle the whole page from the chat.",
|
||||
"footer": "Agent Market demo · fake test data · test-mode payments · open API",
|
||||
"chips": [{"label": "🚲 Show me bikes", "say": "show me bikes"}, {"label": "📐 Tutoring", "say": "find tutoring services"},
|
||||
{"label": "⚖️ Compare tutoring", "say": "compare the two tutoring options"}, {"label": "➕ Sell something", "event": "sell"},
|
||||
{"label": "🎨 Make it look retro", "say": "make it look retro"}],
|
||||
"bullets": ["{fee}% facilitation fee when you pay online (test mode)", "$0 fee when you meet and pay cash in person",
|
||||
"Agents welcome: other AI agents buy and sell through the open API (see For agents)"],
|
||||
}
|
||||
LIMITS = {"logo": 40, "headline": 120, "subhead": 240, "footer": 160, "chip": 40, "bullet": 140}
|
||||
NAMES = {"logo": "top-left logo text", "headline": "headline", "subhead": "subtitle", "footer": "footer"}
|
||||
|
||||
# location phrases -> slot (word-bounded; order matters: specific before generic)
|
||||
LOCS = [
|
||||
("subhead", r"sub-?title|sub-?head(?:ing|line)?|tag-?line|intro(?:duction)? text|(?:text|line) (?:under|below) the (?:headline|title|heading)|description"),
|
||||
("logo", r"top[- ]?left(?: corner)?(?: text)?|upper[- ]?left(?: corner)?(?: text)?|left[- ]?top|logo(?: text| name)?|brand(?:ing| name| text)?|"
|
||||
r"site[- ]?(?:name|title)|website (?:name|title)|name of (?:the |this )?(?:site|website|page|store|shop|marketplace|market)|"
|
||||
r"(?:store|shop|marketplace|market|app) name|header(?: text| title| name)?|word-?mark|nav(?:bar)? (?:text|title|name)|"
|
||||
r"(?:corner|top) text|title (?:in|at|on) the (?:top[- ]?left|header|corner|top)"),
|
||||
("footer", r"footer(?: text| line)?|bottom(?: text| line)?|text at the bottom(?: of the page)?|(?:last|bottom) line"),
|
||||
("headline", r"(?:main |big |page |hero )?(?:headline|heading|title|header text)|big text|main text|hero(?: text)?|h1"),
|
||||
]
|
||||
LOC_RE = "|".join(f"(?:{p})" for _, p in LOCS)
|
||||
STRONG = r"replace|rename|swap|re-?word|retitle|re-?label"
|
||||
WEAK = r"change|switch|turn|update|edit|set|make|alter|modify|put|write"
|
||||
SEPS = r"to|with|into|for|by|->|→|=>|=|as"
|
||||
STYLE_WORDS = r"background|colou?r|font|mode|accent|gradient|theme|look|style|layout|chat|column|corners?|radius|dark|light|preset|" \
|
||||
r"pattern|spacing|density|animation|cards?|grid|list|size|bigger|smaller|order|card"
|
||||
|
||||
|
||||
def current_copy(theme, fee_bps=None):
|
||||
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
|
||||
"""The text each editable slot shows right now for this visitor (defaults + their overrides)."""
|
||||
fee = f"{fee_bps / 100:.{1 if fee_bps % 100 else 0}f}"
|
||||
c = {k: (list(v) if isinstance(v, list) else v) for k, v in DEFAULT_COPY.items()}
|
||||
c["bullets"] = [b.replace("{fee}", fee) for b in c["bullets"]]
|
||||
over = (theme or {}).get("copy") if isinstance(theme, dict) else None
|
||||
for k, v in (over or {}).items() if isinstance(over, dict) else ():
|
||||
if k in ("logo", "headline", "subhead", "footer") and isinstance(v, str) and v.strip():
|
||||
c[k] = v
|
||||
elif k == "chips" and isinstance(v, list):
|
||||
c["chips"] = [x for x in v if isinstance(x, dict) and isinstance(x.get("label"), str)]
|
||||
elif k == "bullets" and isinstance(v, list):
|
||||
c["bullets"] = [x for x in v if isinstance(x, str)]
|
||||
return c
|
||||
|
||||
|
||||
def _slot_of(phrase):
|
||||
"""'the top left corner' -> 'logo'; returns None unless the phrase is (only) a location."""
|
||||
p = re.sub(r"^(?:the |this |that |our |my |your |its )?(?:(?:text|words?|wording|copy|writing|label|name)\s+(?:in|on|at|of|for)\s+(?:the\s+)?)?", "",
|
||||
phrase.strip().lower())
|
||||
p = re.sub(r"\s+(?:text|area|section|part|bit|corner)$", "", p).strip()
|
||||
p = re.sub(r"^(?:in|on|at)\s+(?:the\s+)?", "", p)
|
||||
for slot, pat in LOCS:
|
||||
if re.fullmatch(f"(?:{pat})(?: of (?:the |this )?(?:page|site|screen|website))?", p):
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _strip_loc_suffix(b):
|
||||
"""'new internet on the top left corner' -> ('new internet', 'logo')."""
|
||||
m = re.search(r"\s+(?:on|in|at|for|up in|over in|inside|within)\s+(?:the\s+|this\s+)?(" + LOC_RE + r")"
|
||||
r"(?:\s+(?:corner|area|section|bar|part|text))?(?:\s+of\s+(?:the\s+|this\s+)?(?:page|site|screen|website))?\s*$", b, re.I)
|
||||
if m:
|
||||
return b[:m.start()].rstrip(), _slot_of(m.group(1))
|
||||
return b, None
|
||||
|
||||
|
||||
def _clean(s):
|
||||
s = s.strip()
|
||||
s = re.sub(r"\s+(?:please|pls|thanks|thank you|instead|now|for me)\s*[.!]*$", "", s, flags=re.I).strip()
|
||||
q = re.fullmatch(r"[\"“”'‘’`](.+)[\"“”'‘’`]", s)
|
||||
if q:
|
||||
return q.group(1).strip(), True
|
||||
return s.rstrip(" .!?"), False
|
||||
|
||||
|
||||
def _match_case(new, old):
|
||||
"""Keep the case the user typed for the new text; only an all-lowercase entry adopts the replaced text's style
|
||||
(so 'new internet' replacing 'Agent Market' becomes 'New Internet')."""
|
||||
if not new or new != new.lower() or not re.search(r"[A-Za-z]", old or ""):
|
||||
return new
|
||||
words = [w for w in re.findall(r"[A-Za-z][\w'’-]*", old)]
|
||||
if old.isupper() and len(old) > 1:
|
||||
return new.upper()
|
||||
if words and all(w[0].isupper() for w in words):
|
||||
return re.sub(r"(^|(?<=[\s\-/]))([a-z])", lambda m: m.group(1) + m.group(2).upper(), new)
|
||||
if words and words[0][0].isupper():
|
||||
return new[:1].upper() + new[1:]
|
||||
return new
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return re.sub(r"\s+", " ", re.sub(r"[\"“”'‘’`.!?]+$", "", (s or "").strip().lower()))
|
||||
|
||||
|
||||
def _slots(cur):
|
||||
out = [("logo", None, cur["logo"]), ("headline", None, cur["headline"]), ("subhead", None, cur["subhead"]), ("footer", None, cur["footer"])]
|
||||
out += [("chip", i, ch["label"]) for i, ch in enumerate(cur["chips"])]
|
||||
out += [("bullet", i, b) for i, b in enumerate(cur["bullets"])]
|
||||
return out
|
||||
|
||||
|
||||
def _label(slot, i, text):
|
||||
"""Name of a slot for replies, with its article ("the headline", "the “📐 Tutoring” button")."""
|
||||
if slot == "chip":
|
||||
return f"the “{text}” button"
|
||||
if slot == "bullet":
|
||||
return f"bullet {i + 1}"
|
||||
return "the " + NAMES[slot]
|
||||
|
||||
|
||||
def _apply(cur, slot, i, new, old_fragment=None):
|
||||
"""Build the patch. old_fragment set => replace that substring (case-insensitive) inside the slot's text."""
|
||||
def sub(text):
|
||||
if old_fragment is None:
|
||||
return _match_case(new, text)
|
||||
rx = re.compile(re.escape(old_fragment), re.I)
|
||||
return rx.sub(lambda m: _match_case(new, m.group(0)), text)
|
||||
if slot in ("logo", "headline", "subhead", "footer"):
|
||||
val = sub(cur[slot])[:LIMITS[slot]]
|
||||
return {"copy": {slot: val}}, val
|
||||
if slot == "chip":
|
||||
chips = [dict(c) for c in cur["chips"]]
|
||||
chips[i]["label"] = sub(chips[i]["label"])[:LIMITS["chip"]]
|
||||
return {"copy": {"chips": chips}}, chips[i]["label"]
|
||||
bullets = list(cur["bullets"])
|
||||
bullets[i] = sub(bullets[i])[:LIMITS["bullet"]]
|
||||
return {"copy": {"bullets": bullets}}, bullets[i]
|
||||
|
||||
|
||||
def _find(cur, a):
|
||||
"""Where does text A appear? Exact whole-slot matches first, then substring matches."""
|
||||
na = _norm(a)
|
||||
if not na:
|
||||
return [], []
|
||||
exact = [(s, i, t) for s, i, t in _slots(cur) if _norm(t) == na or _norm(re.sub(r"^\W+", "", t)) == na]
|
||||
sub = [(s, i, t) for s, i, t in _slots(cur) if na in (t or "").lower() and (s, i, t) not in exact]
|
||||
return exact, sub
|
||||
|
||||
|
||||
def _closest(cur, a):
|
||||
best, score = None, 0.0
|
||||
for s, i, t in _slots(cur):
|
||||
r = difflib.SequenceMatcher(None, _norm(a), _norm(t)).ratio()
|
||||
if r > score:
|
||||
best, score = (s, i, t), r
|
||||
return best if score >= 0.45 else None
|
||||
|
||||
|
||||
def _candidates(text):
|
||||
"""Yield (A, B, kind) readings of the message; A may be a location or literal text."""
|
||||
t = re.sub(r"^(?:hey|hi|ok|okay|so)[,!]?\s+", "", text.strip(), flags=re.I)
|
||||
t = re.sub(r"^(?:please|pls|can you|could you|would you|will you|i(?:'d| would) like (?:you )?to|i want (?:you )?to|let'?s)\s+", "", t, flags=re.I)
|
||||
t = re.sub(r"^(?:please)\s+", "", t, flags=re.I)
|
||||
# two quoted strings: replace "A" with "B"
|
||||
q = re.findall(r"[\"“‘'`]([^\"“”‘’'`]+)[\"”’'`]", t)
|
||||
if len(q) >= 2 and re.match(rf"(?:{STRONG}|{WEAK})\b", t, re.I):
|
||||
yield q[0], q[1], "pair"
|
||||
# verb A sep B (try every separator position; caller picks the reading that resolves)
|
||||
m = re.match(rf"(?P<v>{STRONG}|{WEAK})\s+(?P<rest>.+)$", t, re.I | re.S)
|
||||
if m:
|
||||
rest = m.group("rest")
|
||||
for sm in re.finditer(rf"\s+(?:{SEPS})\s+|\s*(?:->|→|=>|=)\s*", rest, re.I):
|
||||
a, b = rest[:sm.start()], rest[sm.end():]
|
||||
if a.strip() and b.strip():
|
||||
yield a, b, m.group("v").lower()
|
||||
# LOC should say / make LOC say / I want LOC to say / LOC: B
|
||||
m = re.match(rf"(?:make|have|let|get)?\s*(?:the\s+)?(?P<loc>.+?)\s+(?:should|must|needs? to|has to|to|will|shall)?\s*"
|
||||
rf"(?:say|says|read|reads|show|shows|display|displays|be|become|state)\s*:?\s+(?P<b>.+)$", t, re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
m = re.match(rf"(?:i want|i'd like)\s+(?:the\s+)?(?P<loc>.+?)\s+to\s+(?:say|read|show|be)\s+(?P<b>.+)$", text.strip(), re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
m = re.match(r"(?P<loc>[^:]{2,40}?)\s*:\s*(?P<b>.+)$", t)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
# call the site X / name it X / rename the site X / retitle the site X
|
||||
m = re.match(r"(?:call|name|rename|retitle|title|brand)\s+(?:the |this |our |my )?(?:site|website|page|store|shop|marketplace|market|app|it)"
|
||||
r"\s+(?:to\s+|as\s+)?(?P<b>.+)$", t, re.I)
|
||||
if m:
|
||||
yield "logo", m.group("b"), "call"
|
||||
m = re.match(r"use\s+(?P<b>.+?)\s+as\s+(?:the\s+)?(?P<loc>.+)$", t, re.I)
|
||||
if m and _slot_of(m.group("loc")):
|
||||
yield m.group("loc"), m.group("b"), "say"
|
||||
|
||||
|
||||
def parse_copy_edit(text, theme=None, fee_bps=None):
|
||||
fee_bps = fee_bps if fee_bps is not None else int(os.environ.get("AGENT_MARKET_FEE_BPS", "500"))
|
||||
if not text or len(text) > 400:
|
||||
return None
|
||||
cur = current_copy(theme, fee_bps)
|
||||
low = text.lower()
|
||||
fallback_understood = None
|
||||
for a_raw, b_raw, kind in _candidates(text):
|
||||
b, hint = _strip_loc_suffix(_clean(b_raw)[0]) if kind != "pair" else (b_raw.strip(), None)
|
||||
b, quoted = _clean(b) if kind != "pair" else (b, True)
|
||||
a_loc_text, hint_a = _strip_loc_suffix(a_raw.strip())
|
||||
a, a_quoted = _clean(a_loc_text) if kind != "pair" else (a_raw, True)
|
||||
hint = hint or hint_a
|
||||
if not b or len(b) > 240:
|
||||
continue
|
||||
loc = "logo" if kind == "call" else (_slot_of(a) if not a_quoted else None)
|
||||
if loc:
|
||||
patch, val = _apply(cur, loc, None, b)
|
||||
return f"Done: the {NAMES[loc]} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
exact, sub = _find(cur, a)
|
||||
if hint:
|
||||
# a location was named: replace inside that slot (whole text if it matches, else the matching words)
|
||||
here = [x for x in exact + sub if x[0] == hint]
|
||||
if here:
|
||||
s, i, t = here[0]
|
||||
frag = None if _norm(t) == _norm(a) else a
|
||||
patch, val = _apply(cur, s, i, b, frag)
|
||||
return f"Done: the {NAMES[s]} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
if kind in ("say",) or not (exact or sub):
|
||||
patch, val = _apply(cur, hint, None, b)
|
||||
return (f"Done: the {NAMES[hint]} now says “{val}” (I couldn't find “{a}” there, so I replaced the whole text). "
|
||||
"Say “reset the look” to undo."), patch
|
||||
if len(exact) == 1 or (not exact and len(sub) == 1):
|
||||
s, i, t = (exact or sub)[0]
|
||||
# whole-text match replaces the slot; a partial match (or a chip label minus its emoji) replaces just those words
|
||||
frag = None if exact and _norm(t) == _norm(a) else a
|
||||
patch, val = _apply(cur, s, i, b, frag)
|
||||
return f"Done: {_label(s, i, t)} now says “{val}”. Say “reset the look” to undo.", patch
|
||||
if len(exact) + len(sub) > 1:
|
||||
where = ", ".join(_label(s, i, t) for s, i, t in (exact + sub)[:4]).replace("the ", "")
|
||||
fallback_understood = fallback_understood or (
|
||||
f"I understood: change “{a}” to “{b}”, but that text appears in more than one place ({where}). "
|
||||
f"Say where, e.g. “change the {NAMES.get((exact + sub)[0][0], 'headline')} to {b}”.")
|
||||
continue
|
||||
# A not on the page: only answer (instead of falling through to other commands) for clear copy-edit wording
|
||||
if fallback_understood is None and (re.match(rf"\s*(?:please\s+)?(?:{STRONG})\b", low) or kind == "pair") \
|
||||
and not re.search(rf"\b(?:{STYLE_WORDS})\b", low) and not re.search(r"\b(?:buy|sell|order|cart|checkout|pay)\b", a.lower()):
|
||||
near = _closest(cur, a)
|
||||
if near:
|
||||
s, i, t = near
|
||||
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. "
|
||||
f"Closest is {_label(s, i, t)} (“{t[:60]}”). Say “change the {NAMES.get(s, 'headline')} to {b}” "
|
||||
"if that's what you meant.")
|
||||
else:
|
||||
fallback_understood = (f"I understood: change “{a}” to “{b}”, but I can't find “{a}” on the page. I can edit the "
|
||||
f"logo (“{cur['logo']}”), headline, subtitle, footer, quick-action buttons and bullets, "
|
||||
f"e.g. “change the logo to {b}”.")
|
||||
if fallback_understood:
|
||||
return fallback_understood, None
|
||||
return None
|
||||
@ -216,6 +216,9 @@ def sanitize_patch(p, depth=0):
|
||||
if isinstance(ch, str):
|
||||
ch = {"label": ch, "say": ch}
|
||||
if isinstance(ch, dict) and _text(ch.get("label"), 40):
|
||||
if ch.get("event") in ("sell",):
|
||||
chips.append({"label": _text(ch["label"], 40), "event": ch["event"]})
|
||||
else:
|
||||
chips.append({"label": _text(ch["label"], 40), "say": _text(ch.get("say") or ch["label"], 120)})
|
||||
c["chips"] = chips
|
||||
if isinstance(copy.get("bullets"), list):
|
||||
@ -301,23 +304,17 @@ def parse_look_command(text, current=None):
|
||||
cur = current if isinstance(current, dict) else {}
|
||||
if re.search(r"\breset\b.*\b(look|theme|style|design|page|colou?rs?)\b|\b(default|original) (look|theme)\b|^reset$", low):
|
||||
return "Done: the page is back to its default look.", {"reset": True}
|
||||
# UI copy edits first ("change the headline to Retro vibes" is text, not the retro preset)
|
||||
from .copyedit import parse_copy_edit
|
||||
ce = parse_copy_edit(text, cur)
|
||||
if ce:
|
||||
return ce[0], (sanitize_patch(ce[1]) if ce[1] else None)
|
||||
for name in list(PRESETS) + list(PRESET_ALIASES):
|
||||
if re.search(r"\b" + re.escape(name) + r"\b", low) and re.search(r"look|theme|style|make it|switch|preset|vibe|feel", low) \
|
||||
and not re.search(r"background|gradient", low):
|
||||
real = PRESET_ALIASES.get(name, name)
|
||||
return f"Applied the {real} look. Say “reset the look” to go back.", {"preset": real}
|
||||
patch, said = {}, []
|
||||
m = re.search(r"(headline|title|heading)\s+(?:to|say|says|reads?|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I)
|
||||
if m:
|
||||
patch.setdefault("copy", {})["headline"] = m.group(2).strip()[:120]; said.append("new headline")
|
||||
m = re.search(r"(subtitle|subhead|tagline)\s+(?:to|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I)
|
||||
if m:
|
||||
patch.setdefault("copy", {})["subhead"] = m.group(2).strip()[:240]; said.append("new subtitle")
|
||||
m = re.search(r"(logo|site name|brand(?: name)?)\s+(?:to|:)\s+[\"“']?(.+?)[\"”']?$", text or "", re.I)
|
||||
if m:
|
||||
patch.setdefault("copy", {})["logo"] = m.group(2).strip()[:40]; said.append("new logo text")
|
||||
if patch.get("copy"):
|
||||
return "Done: " + ", ".join(said) + ".", patch
|
||||
if re.search(r"\blight (mode|theme)\b|\bmake it light\b", low):
|
||||
patch["mode"] = "light"; said.append("light mode")
|
||||
elif re.search(r"\bdark (mode|theme)\b|\bmake it dark\b", low):
|
||||
@ -344,7 +341,8 @@ def parse_look_command(text, current=None):
|
||||
if m:
|
||||
c = m.group(1) or m.group(2); patch.setdefault("palette", {})["text"] = NAMED[c]; said.append(f"{c} text")
|
||||
for name in sorted(FONTS, key=len, reverse=True):
|
||||
if name.lower() in low and name != "system":
|
||||
if name != "system" and re.search(r"\b" + re.escape(name.lower()) + r"\b", low) \
|
||||
and (re.search(r"\b(font|typeface|type|lettering|letters|text)\b", low) or " " in name):
|
||||
patch["font"] = name; said.append(f"{name} font"); break
|
||||
else:
|
||||
for alias, name in (("monospace", "mono"), ("serif", "serif"), ("handwrit", "Pacifico"), ("script", "Pacifico"),
|
||||
|
||||
@ -83,6 +83,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await page.waitForSelector("[data-component=compare]", { timeout: 15000 });
|
||||
check("compare view renders", (await page.$$("[data-component=compare] th")).length >= 3);
|
||||
|
||||
// UI copy edit: the user's exact phrasing renames the top-left logo; then reset the look
|
||||
await ask("replace agent market to new internet on the top left corner");
|
||||
const logo = await page.$eval("#logoText", (n) => n.textContent);
|
||||
check("copy edit: top-left logo says New Internet", logo === "New Internet", logo);
|
||||
if (SHOTS) { await page.evaluate(() => window.scrollTo(0, 0)); await sleep(600); await page.screenshot({ path: SHOTS + "/11-logo-new-internet.png" }); }
|
||||
await ask("reset the look");
|
||||
check("reset restores the logo", (await page.$eval("#logoText", (n) => n.textContent)) === "Agent Market");
|
||||
|
||||
// compare via card checkboxes + compare bar
|
||||
await ask("show me everything");
|
||||
await page.$$eval("#stage .card input[aria-label=compare]", (ns) => ns.slice(0, 2).forEach((n) => n.click()));
|
||||
|
||||
@ -40,6 +40,10 @@ assert a["type"]=="set_theme" and p["background"]["colors"]==["#ff7e5f","#feb47b
|
||||
assert "@import" not in p["customCss"] and "position" not in p["customCss"] and "border-width:3px" in p["customCss"], p
|
||||
assert "<" not in p["copy"]["headline"] and "lst_" not in d["reply"], (p, d["reply"])
|
||||
print("PASS model set_theme is validated (bad colour, @import, position, HTML dropped; ids scrubbed from reply)")'
|
||||
curl -s localhost:18799/api/chat -H 'Content-Type: application/json' -d '{"message":"replace agent market to new internet on the top left corner","theme":{}}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin); a=d["actions"]
|
||||
assert d["agent"]=="llm" and a and a[0]["type"]=="set_theme" and a[0]["payload"]["patch"]["copy"]=={"logo":"New Internet"}, d
|
||||
print("PASS model path: prompt explains top-left = logo + current page text -> copy.logo set")'
|
||||
curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}' >/dev/null
|
||||
curl -s localhost:18802/api/chat -H 'Content-Type: application/json' -d '{"message":"switch to light mode with a green accent"}' | python3 -c '
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
|
||||
@ -24,6 +24,15 @@ class H(BaseHTTPRequestHandler):
|
||||
self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
|
||||
return
|
||||
last = body["messages"][-1]["content"]
|
||||
if "new internet" in last.lower():
|
||||
# answers like a model that read the prompt: it only knows "top left" = copy.logo if the prompt says so
|
||||
sysmsg = body["messages"][0]["content"]
|
||||
knows = "TOP-LEFT" in sysmsg and '"logo": "Agent Market"' in sysmsg
|
||||
out = ({"reply": "Renamed the top-left logo to New Internet.", "actions": [{"type": "set_theme", "payload": {"copy": {"logo": "New Internet"}}}]}
|
||||
if knows else {"reply": "I can't change that text.", "actions": []})
|
||||
b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode()
|
||||
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
|
||||
return
|
||||
if "background" in last:
|
||||
out = {"reply": "Done! Sunset gradient applied (lst_deadbeef).", "actions": [{"type": "set_theme", "payload": {
|
||||
"background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "javascript:alert(1)"], "angle": 180},
|
||||
|
||||
90
tests/test_copyedit.py
Normal file
90
tests/test_copyedit.py
Normal file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Built-in agent: UI copy edits (logo/headline/subtitle/footer/chips/bullets). No server or model needed."""
|
||||
import json, os, re, sys
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
os.environ.pop("AGENT_MARKET_LLM_API_KEY_FILE", None)
|
||||
from market.theme import parse_look_command
|
||||
from market.copyedit import DEFAULT_COPY
|
||||
from market import agent
|
||||
|
||||
results = []
|
||||
def check(name, ok, d=""):
|
||||
results.append(bool(ok)); print(("PASS " if ok else "FAIL ") + name + (f" [{d}]" if d else ""))
|
||||
|
||||
def copy_of(text, theme=None):
|
||||
r = parse_look_command(text, theme or {})
|
||||
return (r[0], (r[1] or {}).get("copy")) if r else (None, None)
|
||||
|
||||
LOGO = [
|
||||
"replace agent market to new internet on the top left corner", # the user's exact phrasing
|
||||
"change Agent Market to New Internet in the top left", "rename the logo to New Internet", "call the site New Internet",
|
||||
"top left should say New Internet", "replace Agent Market with New Internet", "change the top left text to New Internet",
|
||||
"make the logo say new internet", "I want the header to say New Internet", "logo: New Internet", "use New Internet as the site name",
|
||||
"change the brand to new internet please", "rename the website to new internet", "name the site new internet",
|
||||
"change agent market into new internet", "swap Agent Market for New Internet", 'replace "Agent Market" with "New Internet"',
|
||||
"Please change the site name to New Internet.", "the top-left corner should read New Internet",
|
||||
]
|
||||
for p in LOGO:
|
||||
reply, c = copy_of(p)
|
||||
check(f"logo: {p}", c == {"logo": "New Internet"}, f"{c} / {reply}")
|
||||
|
||||
cases = [
|
||||
("change the title to Hello World", {"headline": "Hello World"}),
|
||||
("change the headline to Retro vibes", {"headline": "Retro vibes"}),
|
||||
("replace A marketplace you can just talk to. with Buy and sell anything nearby", {"headline": "Buy and sell anything nearby"}),
|
||||
("replace marketplace with bazaar", {"headline": "A bazaar you can just talk to."}),
|
||||
("set the subtitle to Fast and free", {"subhead": "Fast and free"}),
|
||||
("swap the tagline with Shop by chatting", {"subhead": "Shop by chatting"}),
|
||||
("swap the footer to Made for testing", {"footer": "Made for testing"}),
|
||||
("replace market with Internet in the logo", {"logo": "Agent Internet"}),
|
||||
("the top left should read NEW INTERNET", {"logo": "NEW INTERNET"}),
|
||||
("change the logo to new Internet", {"logo": "new Internet"}), # mixed case typed -> kept exactly
|
||||
]
|
||||
for p, want in cases:
|
||||
reply, c = copy_of(p)
|
||||
check(f"copy: {p}", c == want, f"{c}")
|
||||
reply, c = copy_of("rename Tutoring to Lessons")
|
||||
check("chip label edited, emoji + action kept", c and c["chips"][1] == {"label": "📐 Lessons", "say": "find tutoring services"} and c["chips"][3].get("event") == "sell", c and c["chips"][:4])
|
||||
reply, c = copy_of("replace $0 fee with no fee")
|
||||
check("bullet edited by find-and-replace", c and c["bullets"][1].startswith("no fee when you meet"), c)
|
||||
reply, c = copy_of("replace New Internet with Agent Hub", {"copy": {"logo": "New Internet"}})
|
||||
check("find-and-replace uses the visitor's current text", c == {"logo": "Agent Hub"}, c)
|
||||
|
||||
# unclear -> says what it understood + closest option, never silently nothing / listings
|
||||
r = parse_look_command("replace agent with robot", {})
|
||||
check("ambiguous text -> asks where", r and r[1] is None and "more than one place" in r[0], r and r[0])
|
||||
r = parse_look_command("replace agnet markt with New Internet", {})
|
||||
check("typo -> offers the closest slot", r and r[1] is None and "Closest is the top-left logo text" in r[0], r and r[0])
|
||||
r = parse_look_command("replace foo bar with baz", {})
|
||||
check("not found -> lists what can be edited", r and r[1] is None and "can't find" in r[0], r and r[0])
|
||||
|
||||
# no hijacking of look commands, search or selling
|
||||
for p, key in (("change the background to a sunset gradient", "background"), ("switch to light mode with a green accent", "mode"),
|
||||
("use a serif font and make the text bigger", "font"), ("replace the background with blue", "background"),
|
||||
("move the chat to the left", "chat"), ("hide the bullet points", "sections")):
|
||||
r = parse_look_command(p, {})
|
||||
check(f"look command still works: {p}", r and r[1] and key in r[1] and "copy" not in r[1], r and r[1])
|
||||
for p in ("show me bikes", "change my order to cash", "I'd like to sell my lamp for $20", "find tutoring services"):
|
||||
check(f"not a copy edit: {p}", parse_look_command(p, {}) is None)
|
||||
r = parse_look_command("make it look retro", {})
|
||||
check("preset still works", r and r[1] == {"preset": "retro"})
|
||||
r = parse_look_command("use the Inter font", {})
|
||||
check("font names are word-bounded (Inter font still works)", r and r[1].get("font") == "Inter")
|
||||
check("'internet' is not the Inter font", "font" not in (parse_look_command("make the headline say internet rocks", {})[1] or {}))
|
||||
|
||||
# end to end through the built-in agent (what /api/chat returns when no model answers)
|
||||
out = agent.fallback_agent(None, "replace agent market to new internet on the top left corner", [], "t", {})
|
||||
check("built-in agent: user's exact phrasing -> set_theme copy.logo, no listings",
|
||||
[a["type"] for a in out["actions"]] == ["set_theme"] and out["actions"][0]["payload"]["copy"] == {"logo": "New Internet"}, out["reply"])
|
||||
out = agent.fallback_agent(None, "replace agent with robot", [], "t", {})
|
||||
check("built-in agent: unclear edit -> explanation only", out["actions"] == [] and "agent" in out["reply"])
|
||||
|
||||
# server copy defaults match the React app's defaults
|
||||
app = open(os.path.join(os.path.dirname(__file__), "..", "web", "src", "App.tsx"), encoding="utf-8").read()
|
||||
ok = all(s in app for s in (DEFAULT_COPY["logo"], DEFAULT_COPY["headline"], DEFAULT_COPY["subhead"]) ) and \
|
||||
all(c["label"] in app for c in DEFAULT_COPY["chips"]) and all(b.split("{fee}% ")[-1] in app for b in DEFAULT_COPY["bullets"])
|
||||
check("DEFAULT_COPY in sync with web/src/App.tsx", ok)
|
||||
check("model prompt explains top-left = logo and includes PAGE_TEXT", "TOP-LEFT" in agent.UI_SCHEMA and '"logo": "Agent Market"' in agent._page_text("{}"))
|
||||
|
||||
print(f"\n{sum(results)}/{len(results)} passed")
|
||||
sys.exit(0 if all(results) else 1)
|
||||
Loading…
x
Reference in New Issue
Block a user