diff --git a/docs/survey_helper.md b/docs/survey_helper.md new file mode 100644 index 000000000..81cd2c03d --- /dev/null +++ b/docs/survey_helper.md @@ -0,0 +1,77 @@ +# Survey Helper (Human-in-the-loop) + +This feature helps you **review** a survey page, **extract** form questions, and optionally **generate suggested answers** from a **user-owned profile**. + +It is intentionally designed to be human-in-the-loop: +- It **does not** automatically submit surveys. +- It **does not** fabricate personal details; if your profile doesn’t contain needed info, suggestions should return **UNKNOWN**. + +## How it works + +- **1) Load a URL** + - The app renders the URL with Playwright (Chromium) and captures: + - page HTML + - page title + final URL (after redirects) + - a screenshot preview + +- **2) Extract questions** + - The HTML is parsed and likely form controls are detected: + - inputs (including grouped radio/checkbox questions) + - selects + - textareas + - Each question includes a best-effort label + options (when available). + +- **3) Suggest answers (optional)** + - If you have **Ollama** running locally, the tool can generate answer suggestions using your saved profile. + - Suggestions are only a draft for you to copy/paste and review. + +## Profile storage + +- Stored at `memory/survey_profile.json` +- You can edit it in the GUI under **“Profile (editable JSON)”**. + +## Ways to use it + +### A) Desktop GUI (recommended) + +Run: + +```bash +bash scripts/start_survey_helper.sh +``` + +This script sets up a local `.venv/` and installs only the **minimal** packages needed for the Survey Helper (BeautifulSoup + Playwright), then installs Playwright’s Chromium browser. + +If the script reports `tkinter` is missing, install it: +- Ubuntu/Debian: `sudo apt-get update && sudo apt-get install -y python3-tk` + +Then in the window: +- Paste URL → **Load (render + screenshot)** +- **Extract questions** +- (Optional) start Ollama and click **Suggest answers** + +### A2) CLI mode (no GUI) + +If you don’t have a GUI environment (or don’t want one), run: + +```bash +SURVEY_HELPER_MODE=cli bash scripts/start_survey_helper.sh "https://example.com/survey" +``` + +Or directly: + +```bash +python3 run_survey_helper_cli.py "https://example.com/survey" --json +``` + +### B) Inside Agent Zero (tool mode) + +If you are running the Agent Zero Web UI, you can ask the agent to use `survey_helper`. + +Example request: +- “Use `survey_helper` on this URL and show me the extracted fields. Don’t submit anything.” + +The tool returns JSON with `fields[]`, and optionally `suggestions` if enabled. + +Note: installing/running the full Agent Zero stack may be easiest via the project’s Docker workflow (see `README.md` / `docs/installation.md`). + diff --git a/prompts/default/agent.system.tool.survey_helper.md b/prompts/default/agent.system.tool.survey_helper.md new file mode 100644 index 000000000..66304e74c --- /dev/null +++ b/prompts/default/agent.system.tool.survey_helper.md @@ -0,0 +1,26 @@ +### survey_helper: + +Extracts form/survey questions from a URL (rendered) or provided HTML, and returns a JSON structure of detected fields. + +Safety constraints: +- This tool **does not fill** fields and **does not submit** surveys. +- Do not fabricate personal details; rely on the user's saved profile (if suggestions are requested) or return UNKNOWN. + +Arguments: +- `url` (string, optional): web page to render and extract fields from +- `html` (string, optional): raw HTML to parse instead of rendering a URL +- `include_suggestions` (bool, optional): if true, also generate answer suggestions from the saved profile (requires local Ollama) +- `ollama_model` (string, optional): Ollama model name (default `llama3`) + +Usage: +```json +{ + "thoughts": ["I need to extract the survey questions from this URL."], + "tool_name": "survey_helper", + "tool_args": { + "url": "https://example.com/survey", + "include_suggestions": false + } +} +``` + diff --git a/prompts/default/agent.system.tools.md b/prompts/default/agent.system.tools.md index 590ae46d4..20f3753e4 100644 --- a/prompts/default/agent.system.tools.md +++ b/prompts/default/agent.system.tools.md @@ -16,4 +16,6 @@ {{ include './agent.system.tool.browser.md' }} +{{ include './agent.system.tool.survey_helper.md' }} + {{ include './agent.system.tool.collaboration.md' }} diff --git a/python/survey_assistant/__init__.py b/python/survey_assistant/__init__.py new file mode 100644 index 000000000..aeb1f8f21 --- /dev/null +++ b/python/survey_assistant/__init__.py @@ -0,0 +1,9 @@ +""" +Survey assistant helpers. + +This package intentionally focuses on: +- Extracting survey/form questions from rendered HTML +- Suggesting answers from a user-provided profile +- Keeping the human in control (no auto-submission) +""" + diff --git a/python/survey_assistant/browser_render.py b/python/survey_assistant/browser_render.py new file mode 100644 index 000000000..6b74da9bc --- /dev/null +++ b/python/survey_assistant/browser_render.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from pathlib import Path + +from playwright.async_api import async_playwright + + +@dataclass +class RenderResult: + url: str + final_url: str + title: str + html: str + screenshot_path: str | None = None + + +async def _render_url_async( + url: str, + *, + screenshot_path: str | None = None, + viewport_width: int = 1200, + viewport_height: int = 900, + timeout_ms: int = 30000, +) -> RenderResult: + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True, args=["--disable-http2"]) + context = await browser.new_context( + viewport={"width": viewport_width, "height": viewport_height}, + user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36", + ) + page = await context.new_page() + await page.goto(url, wait_until="networkidle", timeout=timeout_ms) + title = (await page.title()) or "" + final_url = page.url + html = await page.content() + if screenshot_path: + p = Path(screenshot_path) + p.parent.mkdir(parents=True, exist_ok=True) + await page.screenshot(path=str(p), full_page=False) + await context.close() + await browser.close() + return RenderResult( + url=url, + final_url=final_url, + title=title, + html=html, + screenshot_path=screenshot_path, + ) + + +async def render_url_async( + url: str, + *, + screenshot_path: str | None = None, + viewport_width: int = 1200, + viewport_height: int = 900, + timeout_ms: int = 30000, +) -> RenderResult: + return await _render_url_async( + url, + screenshot_path=screenshot_path, + viewport_width=viewport_width, + viewport_height=viewport_height, + timeout_ms=timeout_ms, + ) + + +def render_url( + url: str, + *, + screenshot_path: str | None = None, + viewport_width: int = 1200, + viewport_height: int = 900, + timeout_ms: int = 30000, +) -> RenderResult: + """ + Synchronous wrapper for GUI/CLI use. + """ + return asyncio.run( + _render_url_async( + url, + screenshot_path=screenshot_path, + viewport_width=viewport_width, + viewport_height=viewport_height, + timeout_ms=timeout_ms, + ) + ) + diff --git a/python/survey_assistant/extract.py b/python/survey_assistant/extract.py new file mode 100644 index 000000000..8bd3bb920 --- /dev/null +++ b/python/survey_assistant/extract.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from dataclasses import dataclass, asdict, field +from typing import Any, Iterable + +from bs4 import BeautifulSoup, Tag + + +@dataclass +class ExtractedField: + kind: str # input|textarea|select + input_type: str | None = None # text|radio|checkbox|... + name: str | None = None + id: str | None = None + label: str | None = None + required: bool = False + options: list[dict[str, Any]] = field(default_factory=list) # [{label,value}] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _text(el: Tag | None) -> str: + if not el: + return "" + return " ".join(el.get_text(" ", strip=True).split()) + + +def _first_non_empty(*vals: str | None) -> str | None: + for v in vals: + if v is None: + continue + vv = " ".join(str(v).strip().split()) + if vv: + return vv + return None + + +def _is_hidden_input(tag: Tag) -> bool: + if tag.name != "input": + return False + t = (tag.get("type") or "").lower() + return t == "hidden" + + +def _is_ignorable_control(tag: Tag) -> bool: + if tag.name == "input": + t = (tag.get("type") or "text").lower() + return t in {"submit", "button", "reset", "image", "file", "hidden"} + if tag.name in {"button"}: + return True + return False + + +def _infer_label(soup: BeautifulSoup, field_tag: Tag) -> str | None: + # 1) explicit