agent-market/market/copyedit.py

265 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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