diff --git a/docs/res/survey_demo.html b/docs/res/survey_demo.html new file mode 100644 index 000000000..f5d4acd41 --- /dev/null +++ b/docs/res/survey_demo.html @@ -0,0 +1,75 @@ + + + + + + Survey Demo + + + +

Local Survey Demo

+
+

This is a local HTML survey used for testing the Agent Zero `survey_fill` tool.

+
+ + + + + + + +
+ Android +
+
+ iOS +
+ + +
+ Technology +
+
+ Sports +
+
+ Music +
+ + + + +
+
+ + + + + diff --git a/docs/surveys.md b/docs/surveys.md new file mode 100644 index 000000000..bd1aa607e --- /dev/null +++ b/docs/surveys.md @@ -0,0 +1,46 @@ +# Surveys automation (experimental) + +This repo includes an experimental **survey-filling agent** designed to work with Agent Zero's built-in Playwright browser. + +## What’s included + +- `survey_fill` tool: opens a URL (or uses the currently open page), extracts form fields from the cleaned DOM, plans answers using the current **profile** (and optionally a **persona**), then submits/advances through pages. +- Local SQLite database: stores personas, profiles, and survey answers at `memory//survey_profiles.db`. +- Background profile refiner: runs in the background and periodically turns collected survey answers into structured profile updates. + +## Demo (local HTML) + +There is a local survey at `docs/res/survey_demo.html`. + +If you run Agent Zero UI/API and want to test `survey_fill`, open it via a `file://` URL and run: + +```json +{ + "tool_name": "survey_fill", + "tool_args": { + "url": "file:///a0/docs/res/survey_demo.html", + "profile_id": "default", + "max_pages": 3, + "use_llm": true + } +} +``` + +## Personas + +Create a persona: + +```json +{ + "tool_name": "persona_create", + "tool_args": { + "generate": true, + "seed": "A 32-year-old software engineer in Berlin who prefers Android and avoids alcohol." + } +} +``` + +Then pass `persona_id` into `survey_fill`. + +By default, persona usage is **restricted to local URLs** unless you set `allow_persona=true`. + diff --git a/prompts/default/agent.system.tool.persona.md b/prompts/default/agent.system.tool.persona.md new file mode 100644 index 000000000..1d715a1ea --- /dev/null +++ b/prompts/default/agent.system.tool.persona.md @@ -0,0 +1,22 @@ +### persona_create: + +Create and store a persona for survey answering (primarily for testing/synthetic scenarios). + +- **name**: persona name +- **description**: short description +- **constraints_json**: JSON object with stable constraints (demographics/preferences/traits) +- **generate**: if `true`, drafts persona from **seed** using the utility model +- **seed**: prompt/seed text used when generating + +usage: +```json +{ + "thoughts": ["I need a testing persona for a synthetic survey."], + "tool_name": "persona_create", + "tool_args": { + "generate": true, + "seed": "A 32-year-old software engineer in Berlin who prefers Android and avoids alcohol." + } +} +``` + diff --git a/prompts/default/agent.system.tool.profile.md b/prompts/default/agent.system.tool.profile.md new file mode 100644 index 000000000..ded05bfb9 --- /dev/null +++ b/prompts/default/agent.system.tool.profile.md @@ -0,0 +1,20 @@ +### profile_update: + +Update the structured survey profile (stored in SQLite) by deep-merging a JSON patch. + +- **profile_id**: profile identifier (default: `"default"`) +- **patch_json**: JSON object to merge into the profile + +Example (set email): +```json +{ + "tool_name": "profile_update", + "tool_args": { + "profile_id": "default", + "patch_json": "{\"contact\":{\"email\":\"\"}}" + } +} +``` + +Tip: you can also set env `A0_PROFILE_EMAIL` and the survey answerer will use it as a fallback for email fields. + diff --git a/prompts/default/agent.system.tool.survey.md b/prompts/default/agent.system.tool.survey.md new file mode 100644 index 000000000..a6472d36e --- /dev/null +++ b/prompts/default/agent.system.tool.survey.md @@ -0,0 +1,32 @@ +### survey_fill: + +Fill out an online survey in the built-in Playwright browser and store answers to a local profile database for later refinement. + +- **url**: survey URL to open (optional if a page is already open) +- **profile_id**: profile identifier (default: `"default"`) +- **persona_id**: optional persona ID created via `persona_create` +- **allow_persona**: set `true` only if you have explicit permission to answer as a persona (default: `false`) +- **allow_external**: set `true` only for domains you own/are authorized to automate (default: `false`) +- **max_pages**: safety cap for multi-page surveys (default: `12`) +- **use_llm**: allow utility model to refine the action plan (default: `true`) + +External domains are blocked unless: + +- env `A0_SURVEY_ALLOWED_DOMAINS` includes the survey host (comma-separated), and +- `allow_external=true` is provided. + +usage: +```json +{ + "thoughts": ["I'll open the survey and answer using the stored profile."], + "tool_name": "survey_fill", + "tool_args": { + "url": "https://example.com/survey", + "profile_id": "default", + "allow_external": true, + "max_pages": 10, + "use_llm": true + } +} +``` + diff --git a/prompts/default/agent.system.tools.md b/prompts/default/agent.system.tools.md index 20f3753e4..d13012a82 100644 --- a/prompts/default/agent.system.tools.md +++ b/prompts/default/agent.system.tools.md @@ -10,6 +10,12 @@ {{ include './agent.system.tool.memory.md' }} +{{ include './agent.system.tool.profile.md' }} + +{{ include './agent.system.tool.persona.md' }} + +{{ include './agent.system.tool.survey.md' }} + {{ include './agent.system.tool.code_exe.md' }} {{ include './agent.system.tool.input.md' }} diff --git a/python/extensions/monologue_start/_05_start_profile_refiner.py b/python/extensions/monologue_start/_05_start_profile_refiner.py new file mode 100644 index 000000000..cf68ff961 --- /dev/null +++ b/python/extensions/monologue_start/_05_start_profile_refiner.py @@ -0,0 +1,8 @@ +from python.helpers.extension import Extension +from python.surveys.profile_refiner import ensure_profile_refiner_running + + +class StartProfileRefiner(Extension): + async def execute(self, **kwargs): + ensure_profile_refiner_running(self.agent) + diff --git a/python/helpers/browser.py b/python/helpers/browser.py index 6f71e5c7b..e8a14f4c8 100644 --- a/python/helpers/browser.py +++ b/python/helpers/browser.py @@ -275,6 +275,10 @@ class Browser: clean_dom = self.strip_html_dom(full_dom) return self.process_html_with_selectors(clean_dom) + async def get_url(self) -> str: + await self._check_page() + return self.page.url + async def click(self, selector: str): await self._check_page() ctx, selector = self._parse_selector(selector) @@ -310,6 +314,23 @@ class Browser: await ctx.fill(selector, text, force=True, timeout=Browser.interact_timeout) await self.wait_tick() + async def select(self, selector: str, value_or_label: str): + """Select option in itself; options are chosen via fill/select later + + raw_fields.append( + SurveyField( + selector=selector, + kind=kind, + name=name, + label=label, + placeholder=placeholder, + options=options, + option_selectors=option_selectors, + required=False, + ) + ) + + # Second pass: group radio/checkbox inputs by name when possible, + # creating a single field with option -> selector mapping. + grouped: list[SurveyField] = [] + radio_groups: dict[str, list[SurveyField]] = defaultdict(list) + checkbox_groups: dict[str, list[SurveyField]] = defaultdict(list) + passthrough: list[SurveyField] = [] + + for f in raw_fields: + if f.kind == FieldKind.RADIO and f.name: + radio_groups[f.name].append(f) + elif f.kind == FieldKind.CHECKBOX and f.name: + checkbox_groups[f.name].append(f) + else: + passthrough.append(f) + + def _collapse(groups: dict[str, list[SurveyField]], kind: FieldKind) -> list[SurveyField]: + out: list[SurveyField] = [] + for name, items in groups.items(): + # Build option labels from each item's label; fallback to selector. + options: list[str] = [] + option_selectors: dict[str, str] = {} + group_label = None + for it in items: + opt_label = _norm(it.label) or it.selector + if opt_label and opt_label not in option_selectors: + options.append(opt_label) + option_selectors[opt_label] = it.selector + group_label = group_label or it.label + + if not options: + # keep items as-is if we failed to collapse + out.extend(items) + continue + + out.append( + SurveyField( + selector="", # group field has no single selector; choose via option_selectors + kind=kind, + name=name, + label=group_label, + placeholder=None, + options=options, + option_selectors=option_selectors, + required=False, + ) + ) + return out + + grouped.extend(passthrough) + grouped.extend(_collapse(radio_groups, FieldKind.RADIO)) + grouped.extend(_collapse(checkbox_groups, FieldKind.CHECKBOX)) + + # Stable ordering: keep buttons last (helps answerer focus on fields first). + grouped.sort(key=lambda f: (f.kind == FieldKind.BUTTON, f.kind.value, f.label or "", f.name or "", f.selector)) + + return SurveyPage(url=url, title=title, fields=grouped, raw_dom=clean_dom or "") + diff --git a/python/surveys/profile_refiner.py b/python/surveys/profile_refiner.py new file mode 100644 index 000000000..c55a8b6e3 --- /dev/null +++ b/python/surveys/profile_refiner.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import asyncio +import json +import uuid +from collections import defaultdict +from typing import Any + +from agent import Agent +from python.helpers.defer import DeferredTask + +from .db import SurveyDB +from .schemas import UserProfile + + +def _deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> dict[str, Any]: + for k, v in src.items(): + if isinstance(v, dict) and isinstance(dst.get(k), dict): + dst[k] = _deep_merge(dst[k], v) # type: ignore[arg-type] + else: + dst[k] = v + return dst + + +class ProfileRefinerService: + """Background worker that turns survey answers into structured profile updates.""" + + DATA_KEY = "_survey_profile_refiner" + + def __init__(self, agent: Agent): + self.agent = agent + self.task: DeferredTask | None = None + + def start(self) -> None: + if self.task and self.task.is_alive(): + return + self.task = DeferredTask(thread_name=f"ProfileRefiner-{self.agent.context.id}") + if self.agent.context.task: + self.agent.context.task.add_child_task(self.task, terminate_thread=True) + self.task.start_task(self._run_loop) + + def stop(self) -> None: + if self.task: + self.task.kill(terminate_thread=True) + self.task = None + + async def _run_loop(self) -> None: + db = SurveyDB.for_agent(self.agent) + try: + while True: + await asyncio.sleep(10) + + # Do not interfere with active survey filling. + if self.agent.get_data("_survey_active"): + continue + + events = db.fetch_unprocessed_answer_events(limit=200) + if not events: + continue + + # Group by profile_id (fallback to "default"). + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for e in events: + pid = e.get("profile_id") or "default" + grouped[pid].append(e) + + processed_ids: list[str] = [] + for profile_id, evs in grouped.items(): + profile = db.get_profile(profile_id) or UserProfile( + id=profile_id, persona_id=None, data={} + ) + + # Prepare a compact evidence block. + lines = [] + for e in evs[:60]: + q = (e.get("question_text") or "").strip() + a = (e.get("answer_text") or "").strip() + if not q: + q = e.get("selector") or e.get("field_kind") or "question" + if q and a: + lines.append(f"- Q: {q}\n A: {a}") + + system = ( + "You refine a user profile from survey answers.\n" + "Output ONLY valid JSON.\n" + "Return an object with keys:\n" + "- profile_patch: object (deep-merge patch)\n" + "- extracted_facts: array of short strings\n" + "Rules:\n" + "- Prefer stable fields: demographics, contact, preferences, traits.\n" + "- If unsure, add to notes instead of guessing.\n" + ) + message = ( + f"current_profile_json: {json.dumps(profile.data or {}, ensure_ascii=False)}\n\n" + f"survey_answers:\n{chr(10).join(lines)}\n" + ) + + try: + out = await self.agent.call_utility_model( + system=system, message=message, background=True + ) + data = json.loads(out) + patch = data.get("profile_patch") if isinstance(data, dict) else None + if isinstance(patch, dict): + profile.data = _deep_merge(profile.data or {}, patch) + db.upsert_profile(profile) + processed_ids.extend([e["id"] for e in evs]) + except Exception: + # If parsing fails, do not mark processed. + continue + + if processed_ids: + db.mark_answers_processed(processed_ids) + finally: + db.close() + + +def ensure_profile_refiner_running(agent: Agent) -> ProfileRefinerService: + svc = agent.get_data(ProfileRefinerService.DATA_KEY) + if isinstance(svc, ProfileRefinerService): + svc.start() + return svc + svc = ProfileRefinerService(agent) + agent.set_data(ProfileRefinerService.DATA_KEY, svc) + svc.start() + return svc + diff --git a/python/surveys/schemas.py b/python/surveys/schemas.py new file mode 100644 index 000000000..6240e5e39 --- /dev/null +++ b/python/surveys/schemas.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Literal + + +class FieldKind(str, Enum): + TEXT = "text" + TEXTAREA = "textarea" + EMAIL = "email" + NUMBER = "number" + DATE = "date" + SELECT = "select" + RADIO = "radio" + CHECKBOX = "checkbox" + BUTTON = "button" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class Persona: + id: str + name: str + description: str + constraints: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class UserProfile: + id: str + persona_id: str | None + data: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class SurveyField: + selector: str + kind: FieldKind + name: str | None = None + label: str | None = None + placeholder: str | None = None + options: list[str] = field(default_factory=list) # for select/radio/checkbox + option_selectors: dict[str, str] = field(default_factory=dict) + required: bool = False + + +@dataclass(frozen=True) +class SurveyPage: + url: str + title: str | None + fields: list[SurveyField] + raw_dom: str + + +@dataclass(frozen=True) +class AnswerAction: + action: Literal["fill", "click", "press", "select"] + selector: str | None = None + text: str | None = None + key: str | None = None + meta: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class SurveyAnswer: + field: SurveyField + answer_text: str + confidence: float = 0.5 + rationale: str | None = None + diff --git a/python/surveys/tests/__init__.py b/python/surveys/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/python/surveys/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/python/surveys/tests/test_parser_db.py b/python/surveys/tests/test_parser_db.py new file mode 100644 index 000000000..c59dff432 --- /dev/null +++ b/python/surveys/tests/test_parser_db.py @@ -0,0 +1,72 @@ +import unittest + +from python.surveys.db import SurveyDB +from python.surveys.parser import parse_survey_page +from python.surveys.schemas import FieldKind, Persona, SurveyField, UserProfile +from python.surveys.profile_refiner import _deep_merge + + +class TestSurveyParser(unittest.TestCase): + def test_groups_radio_checkbox(self): + dom = """ +

Demo

+
Preferred mobile platform
+
Android
+
iOS
+
Topics
+
Music
+
Sports
+ """ + page = parse_survey_page(dom, url="file://demo") + radios = [f for f in page.fields if f.kind == FieldKind.RADIO] + checks = [f for f in page.fields if f.kind == FieldKind.CHECKBOX] + self.assertEqual(len(radios), 1) + self.assertEqual(len(checks), 1) + self.assertIn("android", " ".join(radios[0].options).lower()) + self.assertTrue(radios[0].option_selectors) + self.assertEqual(set(checks[0].option_selectors.values()), {"3a", "4a"}) + + +class TestSurveyDB(unittest.TestCase): + def test_db_roundtrip_and_events(self): + db = SurveyDB(":memory:") + try: + persona = Persona(id="p1", name="Test", description="x", constraints={"a": 1}) + db.upsert_persona(persona) + got = db.get_persona("p1") + self.assertIsNotNone(got) + self.assertEqual(got.name, "Test") + + profile = UserProfile(id="default", persona_id="p1", data={"demographics": {"country": "DE"}}) + db.upsert_profile(profile) + + db.create_session("s1", url="file://demo", persona_id="p1", profile_id="default") + field = SurveyField(selector="1a", kind=FieldKind.TEXT, label="Email") + db.insert_answer("a1", "s1", "Email", field, "test@example.com") + + evs = db.fetch_unprocessed_answer_events() + self.assertEqual(len(evs), 1) + self.assertEqual(evs[0]["profile_id"], "default") + self.assertEqual(evs[0]["persona_id"], "p1") + self.assertEqual(evs[0]["answer_text"], "test@example.com") + + db.mark_answers_processed(["a1"]) + self.assertEqual(db.fetch_unprocessed_answer_events(), []) + finally: + db.close() + + +class TestDeepMerge(unittest.TestCase): + def test_deep_merge(self): + dst = {"a": {"b": 1}, "x": 1} + src = {"a": {"c": 2}, "x": 2, "y": 3} + out = _deep_merge(dst, src) + self.assertEqual(out["a"]["b"], 1) + self.assertEqual(out["a"]["c"], 2) + self.assertEqual(out["x"], 2) + self.assertEqual(out["y"], 3) + + +if __name__ == "__main__": + unittest.main() + diff --git a/python/tools/persona_create.py b/python/tools/persona_create.py new file mode 100644 index 000000000..ec67d3237 --- /dev/null +++ b/python/tools/persona_create.py @@ -0,0 +1,65 @@ +import json +import uuid + +from python.helpers.tool import Tool, Response +from python.surveys.db import SurveyDB +from python.surveys.schemas import Persona + + +class PersonaCreate(Tool): + async def execute( + self, + name: str = "", + description: str = "", + constraints_json: str = "", + generate: bool = False, + seed: str = "", + **kwargs, + ): + """ + Create (and store) a persona used for survey answering. + + If generate=true, the utility model will draft name/description/constraints using 'seed'. + """ + db = SurveyDB.for_agent(self.agent) + try: + constraints = {} + if constraints_json: + constraints = json.loads(constraints_json) + + if generate: + system = ( + "You design a survey-answering persona for testing.\n" + "Output ONLY valid JSON with keys: name, description, constraints.\n" + "Constraints must be a JSON object with stable fields (demographics, preferences, traits).\n" + ) + msg = f"seed: {seed}\nexisting_name: {name}\nexisting_description: {description}\n" + out = await self.agent.call_utility_model(system=system, message=msg, background=False) + data = json.loads(out) + if isinstance(data, dict): + name = str(data.get("name") or name or "Persona") + description = str(data.get("description") or description or "") + if isinstance(data.get("constraints"), dict): + constraints = data["constraints"] + + if not name: + name = "Persona" + + persona = Persona( + id=str(uuid.uuid4()), + name=name, + description=description or "", + constraints=constraints or {}, + ) + db.upsert_persona(persona) + return Response( + message=json.dumps( + {"persona_id": persona.id, "name": persona.name}, + ensure_ascii=False, + indent=2, + ), + break_loop=False, + ) + finally: + db.close() + diff --git a/python/tools/profile_update.py b/python/tools/profile_update.py new file mode 100644 index 000000000..934c9b54c --- /dev/null +++ b/python/tools/profile_update.py @@ -0,0 +1,57 @@ +import json + +from python.helpers.tool import Tool, Response +from python.surveys.db import SurveyDB +from python.surveys.schemas import UserProfile +from python.surveys.profile_refiner import _deep_merge + + +class ProfileUpdate(Tool): + async def execute( + self, + profile_id: str = "default", + patch_json: str = "", + persona_id: str = "", + **kwargs, + ): + """ + Update (deep-merge) the structured survey profile stored in SQLite. + + - profile_id: profile key (default "default") + - patch_json: JSON object to deep-merge into profile.data + - persona_id: optional persona association + """ + if not patch_json: + return Response( + message="patch_json is required and must be a JSON object.", + break_loop=False, + ) + + try: + patch = json.loads(patch_json) + except Exception as e: + return Response(message=f"Invalid patch_json: {e}", break_loop=False) + + if not isinstance(patch, dict): + return Response(message="patch_json must be a JSON object.", break_loop=False) + + db = SurveyDB.for_agent(self.agent) + try: + profile = db.get_profile(profile_id) or UserProfile( + id=profile_id, persona_id=(persona_id or None), data={} + ) + if persona_id: + profile.persona_id = persona_id + profile.data = _deep_merge(profile.data or {}, patch) + db.upsert_profile(profile) + return Response( + message=json.dumps( + {"profile_id": profile.id, "updated_keys": sorted(patch.keys())}, + ensure_ascii=False, + indent=2, + ), + break_loop=False, + ) + finally: + db.close() + diff --git a/python/tools/survey_fill.py b/python/tools/survey_fill.py new file mode 100644 index 000000000..f4a88afc3 --- /dev/null +++ b/python/tools/survey_fill.py @@ -0,0 +1,243 @@ +import json +import uuid +from urllib.parse import urlparse + +from python.tools.browser import Browser +from python.helpers.tool import Response +from python.helpers import dotenv + +from python.surveys.answerer import answer_page +from python.surveys.db import SurveyDB +from python.surveys.parser import parse_survey_page +from python.surveys.schemas import Persona, UserProfile + + +def _is_local_url(url: str) -> bool: + if not url: + return True + if url.startswith("file://"): + return True + try: + host = (urlparse(url).hostname or "").lower() + return host in {"localhost", "127.0.0.1"} + except Exception: + return False + + +def _host(url: str) -> str: + try: + return (urlparse(url).hostname or "").lower() + except Exception: + return "" + + +def _allowed_external_host(host: str) -> bool: + # Comma-separated allowlist, e.g. "surveys.mycompany.com,forms.example.org" + raw = str(dotenv.get_dotenv_value("A0_SURVEY_ALLOWED_DOMAINS", "") or "") + allowed = {h.strip().lower() for h in raw.split(",") if h.strip()} + if not allowed: + return False + return host in allowed + + +class SurveyFill(Browser): + async def execute( + self, + url: str = "", + profile_id: str = "default", + persona_id: str = "", + allow_persona: bool = False, + allow_external: bool = False, + max_pages: int = 12, + use_llm: bool = True, + **kwargs, + ): + """ + Fill out an online survey in the built-in browser. + + Safety: persona usage is restricted by default; set allow_persona=true to enable. + """ + + await self.prepare_state() + db = SurveyDB.for_agent(self.agent) + + if url and not _is_local_url(url): + host = _host(url) + if not allow_external or not _allowed_external_host(host): + msg = ( + "External survey domains are blocked by default.\n" + "To enable for authorized/owned surveys, set env `A0_SURVEY_ALLOWED_DOMAINS` " + "to a comma-separated allowlist and pass allow_external=true.\n" + f"Blocked host: {host or '(unknown)'}" + ) + self.log.update(error=msg) + return Response(message=msg, break_loop=False) + + if persona_id and not allow_persona and url and not _is_local_url(url): + msg = ( + "Persona mode is disabled for non-local URLs by default. " + "Re-run with allow_persona=true if you have explicit permission to answer as a persona." + ) + self.log.update(error=msg) + return Response(message=msg, break_loop=False) + + persona: Persona | None = db.get_persona(persona_id) if persona_id else None + profile = db.get_profile(profile_id) or UserProfile( + id=profile_id, persona_id=(persona.id if persona else None), data={} + ) + if persona and profile.persona_id != persona.id: + profile.persona_id = persona.id + db.upsert_profile(profile) + + session_id = str(uuid.uuid4()) + if url: + self.update_progress("Opening survey...") + await self.state.browser.open(url) + + current_url = await self.state.browser.get_url() + db.create_session( + session_id=session_id, + url=current_url, + persona_id=persona.id if persona else None, + profile_id=profile.id, + status="running", + ) + + self.agent.set_data("_survey_active", True) + actions_log = [] + error_text = None + try: + for i in range(max_pages): + self.update_progress(f"Parsing page {i+1}/{max_pages}...") + await self.state.browser.wait_for_action() + dom = await self.state.browser.get_clean_dom() + current_url = await self.state.browser.get_url() + page = parse_survey_page(dom, url=current_url) + + low = (dom or "").lower() + if any(k in low for k in ("thank you", "thanks for completing", "response recorded")): + break + + self.update_progress("Planning answers...") + plan = await answer_page( + self.agent, + page, + profile, + persona, + use_llm=use_llm, + seed=i, + ) + + if not plan: + break + + self.update_progress("Answering...") + for act in plan: + if act.action == "fill" and act.selector and act.text is not None: + await self.state.browser.fill(act.selector, act.text) + db.insert_answer( + answer_id=str(uuid.uuid4()), + session_id=session_id, + question_text=act.meta.get("label") if isinstance(act.meta, dict) else None, + field=_field_stub_for_action(act), + answer_text=act.text, + raw={"action": act.__dict__}, + ) + actions_log.append({"action": "fill", "selector": act.selector, "text": act.text}) + elif act.action == "select" and act.selector and act.text is not None: + await self.state.browser.select(act.selector, act.text) + db.insert_answer( + answer_id=str(uuid.uuid4()), + session_id=session_id, + question_text=act.meta.get("label") if isinstance(act.meta, dict) else None, + field=_field_stub_for_action(act), + answer_text=act.text, + raw={"action": act.__dict__}, + ) + actions_log.append({"action": "select", "selector": act.selector, "text": act.text}) + elif act.action == "click" and act.selector: + await self.state.browser.click(act.selector) + val = act.meta.get("option") if isinstance(act.meta, dict) else None + db.insert_answer( + answer_id=str(uuid.uuid4()), + session_id=session_id, + question_text=act.meta.get("label") if isinstance(act.meta, dict) else None, + field=_field_stub_for_action(act), + answer_text=str(val) if val else "clicked", + raw={"action": act.__dict__}, + ) + actions_log.append({"action": "click", "selector": act.selector}) + elif act.action == "press" and act.key: + await self.state.browser.press(act.key) + actions_log.append({"action": "press", "key": act.key}) + + await self.state.browser.wait(0.25) + + # If we clicked "next"/"submit", navigation may occur; give it a moment. + await self.state.browser.wait_for_action() + + db.complete_session(session_id, status="completed") + except Exception as e: + error_text = str(e) + db.complete_session(session_id, status="error") + finally: + self.agent.set_data("_survey_active", False) + db.close() + + self.update_progress("Taking screenshot...") + screenshot = await self.save_screenshot() + self.log.update(screenshot=screenshot) + if error_text: + try: + dom = await self.state.browser.get_clean_dom() + except Exception: + dom = "" + payload = { + "session_id": session_id, + "url": current_url, + "profile_id": profile.id, + "persona_id": persona.id if persona else None, + "error": error_text, + "actions": actions_log[-40:], + "dom_excerpt": dom[:4000], + "screenshot": screenshot, + } + self.log.update(error=error_text) + self.cleanup_history() + self.update_progress("Done") + return Response( + message=json.dumps(payload, ensure_ascii=False, indent=2), + break_loop=False, + ) + self.cleanup_history() + self.update_progress("Done") + + payload = { + "session_id": session_id, + "url": current_url, + "profile_id": profile.id, + "persona_id": persona.id if persona else None, + "actions": actions_log[-40:], + "screenshot": screenshot, + } + return Response(message=json.dumps(payload, ensure_ascii=False, indent=2), break_loop=False) + + +def _field_stub_for_action(act): + # Minimal field record for DB; full parsed field is not always resolvable after refinement. + from python.surveys.schemas import SurveyField, FieldKind + + kind = FieldKind.UNKNOWN + if act.action == "fill": + kind = FieldKind.TEXT + elif act.action == "select": + kind = FieldKind.SELECT + return SurveyField( + selector=act.selector or "", + kind=kind, + label=act.meta.get("label") if isinstance(act.meta, dict) else None, + options=[], + option_selectors={}, + required=False, + ) + diff --git a/run_survey_demo.py b/run_survey_demo.py new file mode 100644 index 000000000..cc77ee338 --- /dev/null +++ b/run_survey_demo.py @@ -0,0 +1,24 @@ +import asyncio +import os +from pathlib import Path + +from agent import AgentContext +from initialize import initialize +from python.tools.survey_fill import SurveyFill + + +async def main(): + config = initialize() + ctx = AgentContext(config) + + demo_path = Path(__file__).parent / "docs" / "res" / "survey_demo.html" + url = demo_path.resolve().as_uri() # file://... + + tool = SurveyFill(agent=ctx.agent0, name="survey_fill", args={}, message="") + res = await tool.execute(url=url, profile_id="default", max_pages=3, use_llm=False) + print(res.message) + + +if __name__ == "__main__": + asyncio.run(main()) +