"""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{STRONG}|{WEAK})\s+(?P.+)$", 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.+?)\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.+)$", 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.+?)\s+to\s+(?:say|read|show|be)\s+(?P.+)$", 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[^:]{2,40}?)\s*:\s*(?P.+)$", 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.+)$", t, re.I) if m: yield "logo", m.group("b"), "call" m = re.match(r"use\s+(?P.+?)\s+as\s+(?:the\s+)?(?P.+)$", 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