From 43ecd690073fccd6163030e90a19b9ec2685d5a8 Mon Sep 17 00:00:00 2001 From: Luca Fiaschi Date: Sun, 21 Jun 2026 15:02:19 -0400 Subject: [PATCH] feat: native persona-driven website navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a first-class API for letting a sampled persona browse a real website and choose its own next action, turning the `persona-browse` skill (a manual recipe) into reproducible, testable code. `PersonaNavigator` reads each page (text + optional screenshot), reacts in character, and picks ONE action from the real on-page options — AudienceKit never guesses the next move with heuristics. The browser stays injected via the `Browser` protocol (the same dependency-light philosophy as the pluggable LLM backends), so callers wire in agent-browser, Playwright, or a fixture. - audiencekit/browse.py: PageView, BrowseAction, PersonaStep, PersonaNavigator, Browser protocol, build_browse_prompt, run_browse_session, extra_fields for domain-specific reaction fields, and a `boundary` hook to stop at out-of-scope payment/verification walls. - Export the new surface from the package root. - tests/test_browse.py: 7 network-free tests using an injected backend + a fixture browser. - Document the programmatic path in the persona-browse skill and README. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 16 +- audiencekit/__init__.py | 26 +++ audiencekit/browse.py | 305 +++++++++++++++++++++++++++++++++ skills/persona-browse/SKILL.md | 41 +++++ tests/test_browse.py | 138 +++++++++++++++ 5 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 audiencekit/browse.py create mode 100644 tests/test_browse.py diff --git a/README.md b/README.md index a30313c..34b6dd1 100644 --- a/README.md +++ b/README.md @@ -236,10 +236,18 @@ study = ak.Study.from_dict({ ### Website Analysis -Use the `skills/persona-browse` workflow with a browsing-capable agent to sample -one persona, visit a page, and record what that persona understands, trusts, -misses, and objects to. This is useful for landing pages, product pages, pricing -pages, and competitor audits. +Two ways to put a persona in front of a real site: + +- **Scripted** — `ak.PersonaNavigator` + `ak.run_browse_session` drive a + reproducible, structured walkthrough where the persona reads each page and + **chooses its own next action** from the real on-page options. Inject any + browser through the `ak.Browser` protocol (agent-browser, Playwright, a + fixture). Good for many personas across many sites with structured output. +- **Exploratory** — the `skills/persona-browse` workflow for a free-form, + narrated first-person walkthrough with a browsing-capable agent. + +Both are useful for landing pages, product pages, pricing pages, and competitor +audits. ### Synthetic Campaign Testing diff --git a/audiencekit/__init__.py b/audiencekit/__init__.py index 929efef..16263f5 100644 --- a/audiencekit/__init__.py +++ b/audiencekit/__init__.py @@ -8,9 +8,26 @@ respondents = ak.sample_panel(pool, n=30) study = ak.Study.from_dict({...}) results = ak.SyntheticPanel(respondents).run_survey(study) + +Persona-driven browsing (the persona reads each page and picks its own next move): + + row = ak.sample_panel(pool, n=1, seed=13).iloc[0].to_dict() + nav = ak.PersonaNavigator(row, task="find a dinner plan for next week") + steps = ak.run_browse_session(nav, browser, "https://example.com/") """ from .backends import AnthropicBackend, GeminiBackend, LLMBackend, OpenAIBackend, make_backend +from .browse import ( + DEFAULT_REACTION_FIELDS, + LEAVE, + BrowseAction, + Browser, + PageView, + PersonaNavigator, + PersonaStep, + build_browse_prompt, + run_browse_session, +) from .gss import load_gss, prepare_gss_persona_frame, write_gss_panel from .personas import ( GSS_PERSONA_FIELDS, @@ -26,15 +43,23 @@ __all__ = [ "AudienceFrame", "AnthropicBackend", + "BrowseAction", + "Browser", + "DEFAULT_REACTION_FIELDS", "GeminiBackend", "GSS_PERSONA_FIELDS", "GSS_PERSONA_TEMPLATE", + "LEAVE", "LLMBackend", "OpenAIBackend", + "PageView", + "PersonaNavigator", + "PersonaStep", "PersonaTemplate", "Question", "Study", "SyntheticPanel", + "build_browse_prompt", "build_persona", "build_survey_prompt", "is_luxury_household", @@ -44,6 +69,7 @@ "parse_json_response", "prepare_gss_persona_frame", "render_persona", + "run_browse_session", "sample_panel", "write_gss_panel", ] diff --git a/audiencekit/browse.py b/audiencekit/browse.py new file mode 100644 index 0000000..baf05f3 --- /dev/null +++ b/audiencekit/browse.py @@ -0,0 +1,305 @@ +"""Persona-driven website navigation for synthetic audience members. + +Where :mod:`audiencekit.survey` asks a persona to react to a *static* stimulus, +this module lets a persona *browse*: on each page it reads what is actually on +screen, reacts in first person, and chooses one real on-page action — click this +link, type into that field, scroll, or leave. The persona is the decision maker; +AudienceKit never guesses the next move with heuristics. + +AudienceKit stays browser-agnostic on purpose (the same reason it does not bundle +a single LLM vendor). It owns the *decision layer*; the actual automation is +injected through the small :class:`Browser` protocol, so callers can plug in +``agent-browser``, Playwright, Selenium, or a recorded fixture. + +Quick start:: + + import audiencekit as ak + + pool = ak.load_panel() + row = ak.sample_panel(pool, n=1, seed=13).iloc[0].to_dict() + nav = ak.PersonaNavigator(row, backend_type="gemini", + task="find a dinner plan for next week") + + # `browser` is any object implementing the Browser protocol below. + steps = ak.run_browse_session(nav, browser, "https://example.com/", max_steps=8) + for step in steps: + print(step.milestone, "->", step.quote) + +Driving a single page manually:: + + page = browser.read() + step = nav.step(page, browser.actions()) + if not step.left: + browser.execute(step.action) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional, Protocol, Sequence, Union, runtime_checkable + +from .backends import make_backend +from .primitives import PersonaTemplate +from .survey import parse_json_response, render_persona + +# Reserved id for the always-available "I would leave this site" choice. +LEAVE = "leave" + +# The in-character fields every browse reaction returns. Callers can request more +# via PersonaNavigator(extra_fields=...), e.g. ("price_feel", "how the price feels"). +DEFAULT_REACTION_FIELDS: tuple[tuple[str, str], ...] = ( + ("notices", "the one thing you notice first on this screen, in your words"), + ("reaction", "your overall reaction: one of positive | mixed | negative"), + ("believable", "how the main claim reads: one of believable | generic | exaggerated | unsure"), + ("trust_shift", "how your trust moved: one of up | down | none"), + ("intent", "your willingness to continue: one of continue | hesitant | leaning_out"), +) + + +@dataclass(frozen=True) +class PageView: + """A snapshot of the page the persona is reacting to.""" + + text: str = "" + url: str = "" + title: str = "" + milestone: str = "" + screenshot: Optional[Union[str, Path]] = None + + +@dataclass(frozen=True) +class BrowseAction: + """One thing the persona can do on the current page. + + ``id`` is what the persona returns to pick this action. ``payload`` is opaque + to AudienceKit and carried back to the caller's :class:`Browser` for execution + (e.g. an element ref, a value to type, an action kind). + """ + + id: str + label: str + payload: Any = None + + +@dataclass(frozen=True) +class PersonaStep: + """The outcome of one in-character browsing decision.""" + + page: PageView + reaction: dict[str, Any] + action: Optional[BrowseAction] + left: bool + raw: str + valid: bool + + @property + def quote(self) -> str: + return str(self.reaction.get("quote", "")).strip() + + @property + def milestone(self) -> str: + return self.page.milestone or self.page.url + + @property + def leave_reason(self) -> str: + return str(self.reaction.get("leave_reason", "")).strip() + + def summary(self) -> str: + """A short line for the running history fed back into later steps.""" + where = self.page.milestone or self.page.title or self.page.url or "page" + did = "left the site" if self.left else (f"clicked '{self.action.label}'" if self.action else "looked around") + return f"{where}: {self.quote or 'reacted'} ({did})" + + +def _trim(text: str, limit: int = 6000) -> str: + text = (text or "").strip() + return text if len(text) <= limit else text[:limit] + "\n[...truncated...]" + + +def _render_actions(actions: Sequence[BrowseAction]) -> str: + return "\n".join(f" {action.id}: {action.label}" for action in actions) or " (no actions)" + + +def build_browse_prompt( + attributes: dict[str, Any], + page: PageView, + actions: Sequence[BrowseAction], + *, + task: str = "", + history: Sequence[str] | None = None, + reaction_fields: Sequence[tuple[str, str]] = DEFAULT_REACTION_FIELDS, + persona_template: PersonaTemplate | str | Any | None = None, +) -> str: + """Build the prompt that asks a persona to react and choose one action.""" + persona = render_persona(attributes, persona_template) + task_block = f"\nYour goal: {task}\n" if task else "" + trail = "" + if history: + trail = "\n# What you have already seen and done\n" + "\n".join(f"- {item}" for item in list(history)[-5:]) + "\n" + where = page.milestone or page.title or "this screen" + field_lines = "\n".join(f'- "{fid}": {desc}' for fid, desc in reaction_fields) + return f"""# Role +You are a real person browsing a website, not a usability expert. +React naturally and stay consistent with who you are. You will not pay or place an order. + +# Who you are +{persona} +{task_block}{trail} +# Where you are now +You are on "{where}" ({page.url}). Look at the attached screenshot if there is one. +Page content for reference: +\"\"\" +{_trim(page.text)} +\"\"\" + +# What you can actually do here (choose exactly ONE by its id) +{_render_actions(actions)} + +# Respond +React in character to THIS screen, then decide what YOU do next by picking one action id. +A real person rarely leaves on the very first screen — look around first; pick the '{LEAVE}' +action only once you have genuinely seen enough to walk away. + +Return ONE JSON object with exactly these keys and nothing else (no markdown): +{field_lines} +- "quote": one vivid first-person sentence reacting to this exact screen +- "action_choice": the id of the single action you take next (use '{LEAVE}' to leave) +- "leave_reason": if you chose '{LEAVE}', why in your words; otherwise '' +""" + + +@runtime_checkable +class Browser(Protocol): + """Minimal automation surface AudienceKit drives. Inject your own. + + Implementations wrap agent-browser, Playwright, Selenium, or a fixture. + AudienceKit only reads pages, lists candidate actions, and asks to execute + the one the persona chose — it never decides the next move itself. + """ + + def open(self, url: str) -> None: ... + + def read(self) -> PageView: ... + + def actions(self) -> Sequence[BrowseAction]: ... + + def execute(self, action: BrowseAction) -> None: ... + + def close(self) -> None: ... + + +class PersonaNavigator: + """A sampled persona that reacts to pages and chooses its own next action.""" + + def __init__( + self, + attributes: dict[str, Any], + backend_type: str = "gemini", + model: Optional[str] = None, + backend: Any | None = None, + *, + task: str = "", + persona_template: PersonaTemplate | str | Any | None = None, + extra_fields: Sequence[tuple[str, str]] = (), + temperature: float = 0.7, + max_tokens: int = 1300, + ): + self.attributes = dict(attributes) + self.backend = backend or make_backend(backend_type, model) + self.task = task + self.persona_template = persona_template + self.reaction_fields = tuple(DEFAULT_REACTION_FIELDS) + tuple(extra_fields) + self.temperature = temperature + self.max_tokens = max_tokens + + def step( + self, + page: PageView, + actions: Sequence[BrowseAction], + history: Sequence[str] | None = None, + **backend_kwargs: Any, + ) -> PersonaStep: + """Elicit one in-character reaction and the persona's chosen action. + + A '{LEAVE}' action is always offered, so the persona can disengage on any + page. ``backend_kwargs`` are forwarded to ``backend.get_completion`` (for + example a Gemini ``thinking_config``). + """ + menu = list(actions) + if not any(action.id == LEAVE for action in menu): + menu.append(BrowseAction(id=LEAVE, label="leave — I have seen enough and would not continue here")) + by_id = {action.id: action for action in menu} + + prompt = build_browse_prompt( + self.attributes, + page, + menu, + task=self.task, + history=history, + reaction_fields=self.reaction_fields, + persona_template=self.persona_template, + ) + try: + raw = self.backend.get_completion( + prompt, + image=str(page.screenshot) if page.screenshot else None, + temperature=self.temperature, + max_tokens=self.max_tokens, + **backend_kwargs, + ) + except RuntimeError: + raw = "" + parsed = parse_json_response(raw) + valid = parsed is not None + reaction = parsed or {} + + choice = str(reaction.get("action_choice", "")).strip() + chosen = by_id.get(choice) + left = chosen is None or chosen.id == LEAVE + return PersonaStep( + page=page, + reaction=reaction, + action=None if left else chosen, + left=left, + raw=raw or "", + valid=valid, + ) + + +def run_browse_session( + navigator: PersonaNavigator, + browser: Browser, + start_url: str, + *, + max_steps: int = 8, + boundary: Callable[[PageView], bool] | None = None, + verbose: bool = False, +) -> list[PersonaStep]: + """Drive a full persona-led walkthrough and return the ordered steps. + + The persona decides every move. The session ends when the persona leaves, a + page has no actionable next step, ``max_steps`` is reached, or ``boundary`` + returns True for the current page (use it to stop at out-of-scope walls such + as payment, password, phone, or captcha). + """ + steps: list[PersonaStep] = [] + history: list[str] = [] + try: + browser.open(start_url) + for _ in range(max_steps): + page = browser.read() + actions = list(browser.actions()) + step = navigator.step(page, actions, history=history) + steps.append(step) + history.append(step.summary()) + if verbose: + print(f"{step.milestone}: {step.quote}") + if boundary is not None and boundary(page): + break + if step.left or step.action is None: + break + browser.execute(step.action) + finally: + browser.close() + return steps diff --git a/skills/persona-browse/SKILL.md b/skills/persona-browse/SKILL.md index f656d0a..8da0586 100644 --- a/skills/persona-browse/SKILL.md +++ b/skills/persona-browse/SKILL.md @@ -31,6 +31,47 @@ For non-GSS data, use `ak.AudienceFrame` plus `ak.PersonaTemplate`. Show the persona card before browsing. Keep the attributes in working memory and let them shape attention, skepticism, budget sensitivity, and vocabulary. +## Programmatic API + +For reproducible, scripted walkthroughs (many personas, many sites, structured +output), use the native `PersonaNavigator` instead of free-form narration. The +persona reads each page and **chooses its own next action** from the real on-page +options — AudienceKit never picks the move with heuristics. The browser is +injected through the `ak.Browser` protocol, so plug in `agent-browser`, +Playwright, Selenium, or a fixture: + +```python +import audiencekit as ak + +row = ak.sample_panel(ak.load_panel(), n=1, seed=13).iloc[0].to_dict() +nav = ak.PersonaNavigator(row, backend_type="gemini", + task="find a dinner plan for next week", + extra_fields=(("price_feel", "how the price feels to you"),)) + +steps = ak.run_browse_session(nav, browser, "https://example.com/", max_steps=8) +for step in steps: + print(step.milestone, "->", step.quote, "| left" if step.left else f"| {step.action.label}") +``` + +A minimal `Browser` adapter over the `agent-browser` CLI looks like: + +```python +class AgentBrowser: + def __init__(self, session="persona"): self.session = session + def open(self, url): run("open", url) + def read(self): + return ak.PageView(text=run("snapshot", "--compact"), url=run("get", "url"), + title=run("get", "title")) + def actions(self): + # parse clickable refs from the accessibility snapshot into BrowseAction(id, label, payload=ref) + return [...] + def execute(self, action): run("click", action.payload) + def close(self): run("close") +``` + +Pass `boundary=lambda page: "payment" in page.text.lower()` to `run_browse_session` +to stop before out-of-scope payment, password, phone, or captcha walls. + ## Browse Use the configured browser automation tool for navigation. Make 4-6 moves maximum: diff --git a/tests/test_browse.py b/tests/test_browse.py new file mode 100644 index 0000000..8f4575d --- /dev/null +++ b/tests/test_browse.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json + +from audiencekit import ( + BrowseAction, + PageView, + PersonaNavigator, + build_browse_prompt, + run_browse_session, +) + + +class ScriptedBackend: + """Returns a queued JSON reaction per call and records prompts.""" + + def __init__(self, responses): + self.responses = list(responses) + self.prompts = [] + + def get_completion(self, prompt, image=None, **kwargs): + self.prompts.append((prompt, image, kwargs)) + return self.responses.pop(0) + + +def _row(): + return {"age": 71, "sex": "Female", "region": "South", "income16": "$20,000 to $22,499"} + + +def test_build_browse_prompt_lists_actions_and_persona(): + prompt = build_browse_prompt( + _row(), + PageView(text="Menu. $11.49/serving.", url="https://x.test/menu", milestone="menu"), + [BrowseAction(id="a1", label="click the 'See plans' button")], + task="find a dinner plan", + ) + assert "find a dinner plan" in prompt + assert "a1: click the 'See plans' button" in prompt + assert "action_choice" in prompt + assert "71 year old" in prompt or "71" in prompt + + +def test_step_resolves_chosen_action(): + backend = ScriptedBackend([json.dumps({"action_choice": "a2", "quote": "Let me see the menu."})]) + nav = PersonaNavigator(_row(), backend=backend) + actions = [BrowseAction("a1", "click 'Pricing'"), BrowseAction("a2", "click 'Menu'", payload={"ref": "e9"})] + step = nav.step(PageView(text="home", url="u"), actions) + assert step.valid is True + assert step.left is False + assert step.action is not None and step.action.id == "a2" + assert step.action.payload == {"ref": "e9"} + assert step.quote == "Let me see the menu." + + +def test_step_leave_choice_marks_session_done(): + backend = ScriptedBackend([json.dumps({"action_choice": "leave", "leave_reason": "Too pricey.", "quote": "Not for me."})]) + nav = PersonaNavigator(_row(), backend=backend) + step = nav.step(PageView(text="$$$"), [BrowseAction("a1", "click 'Plans'")]) + assert step.left is True + assert step.action is None + assert step.leave_reason == "Too pricey." + + +def test_step_unparseable_or_unknown_choice_defaults_to_leave(): + backend = ScriptedBackend(["not json at all", json.dumps({"action_choice": "a99", "quote": "?"})]) + nav = PersonaNavigator(_row(), backend=backend) + bad = nav.step(PageView(text="x"), [BrowseAction("a1", "click")]) + assert bad.valid is False and bad.left is True + unknown = nav.step(PageView(text="x"), [BrowseAction("a1", "click")]) + assert unknown.left is True # unknown id falls back to leaving + + +def test_extra_fields_are_requested_in_prompt(): + backend = ScriptedBackend([json.dumps({"action_choice": "leave"})]) + nav = PersonaNavigator(_row(), backend=backend, extra_fields=(("price_feel", "how the price feels to you"),)) + nav.step(PageView(text="x"), [BrowseAction("a1", "click")]) + assert "price_feel" in backend.prompts[0][0] + + +class FakeBrowser: + """A two-page fixture site driven through the Browser protocol.""" + + def __init__(self): + self.opened = None + self.executed = [] + self.closed = False + self._page = 0 + + def open(self, url): + self.opened = url + + def read(self): + if self._page == 0: + return PageView(text="Homepage", url="https://x.test/", milestone="homepage") + return PageView(text="Menu with prices", url="https://x.test/menu", milestone="menu") + + def actions(self): + return [BrowseAction("a1", "click 'Menu'", payload="menu-ref")] + + def execute(self, action): + self.executed.append(action) + self._page += 1 + + def close(self): + self.closed = True + + +def test_run_browse_session_lets_persona_drive_then_leave(): + backend = ScriptedBackend( + [ + json.dumps({"action_choice": "a1", "quote": "I'll open the menu."}), + json.dumps({"action_choice": "leave", "leave_reason": "Too expensive.", "quote": "Out of my budget."}), + ] + ) + nav = PersonaNavigator(_row(), backend=backend) + browser = FakeBrowser() + steps = run_browse_session(nav, browser, "https://x.test/", max_steps=8) + + assert browser.opened == "https://x.test/" + assert browser.closed is True + assert len(steps) == 2 + assert [s.page.milestone for s in steps] == ["homepage", "menu"] + assert steps[0].action.payload == "menu-ref" + assert steps[-1].left is True + assert len(browser.executed) == 1 # second step left, so no further navigation + assert "homepage" in steps[1].summary() or "menu" in steps[1].summary() + + +def test_run_browse_session_stops_at_boundary(): + backend = ScriptedBackend([json.dumps({"action_choice": "a1", "quote": "Looks like a payment wall."})]) + nav = PersonaNavigator(_row(), backend=backend) + browser = FakeBrowser() + steps = run_browse_session( + nav, browser, "https://x.test/", max_steps=8, boundary=lambda page: "Homepage" in page.text + ) + assert len(steps) == 1 # boundary on the first page halts the walkthrough + assert browser.executed == [] + assert browser.closed is True