265 lines
15 KiB
Python
265 lines
15 KiB
Python
"""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
|