mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-30 20:25:23 +00:00
Survey agent persona (#8)
* Add human-in-the-loop survey helper Co-authored-by: nic <nicsins@users.noreply.github.com> * Make survey helper launcher robust and add CLI fallback Co-authored-by: nic <nicsins@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: nic <nicsins@users.noreply.github.com>
This commit is contained in:
parent
a0ae24d36b
commit
6a248a57cc
12 changed files with 978 additions and 0 deletions
77
docs/survey_helper.md
Normal file
77
docs/survey_helper.md
Normal file
|
|
@ -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`).
|
||||
|
||||
26
prompts/default/agent.system.tool.survey_helper.md
Normal file
26
prompts/default/agent.system.tool.survey_helper.md
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -16,4 +16,6 @@
|
|||
|
||||
{{ include './agent.system.tool.browser.md' }}
|
||||
|
||||
{{ include './agent.system.tool.survey_helper.md' }}
|
||||
|
||||
{{ include './agent.system.tool.collaboration.md' }}
|
||||
|
|
|
|||
9
python/survey_assistant/__init__.py
Normal file
9
python/survey_assistant/__init__.py
Normal file
|
|
@ -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)
|
||||
"""
|
||||
|
||||
90
python/survey_assistant/browser_render.py
Normal file
90
python/survey_assistant/browser_render.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
191
python/survey_assistant/extract.py
Normal file
191
python/survey_assistant/extract.py
Normal file
|
|
@ -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 <label for="...">
|
||||
field_id = field_tag.get("id")
|
||||
if field_id:
|
||||
lbl = soup.find("label", attrs={"for": field_id})
|
||||
if isinstance(lbl, Tag):
|
||||
txt = _text(lbl)
|
||||
if txt:
|
||||
return txt
|
||||
|
||||
# 2) enclosing label
|
||||
enclosing = field_tag.find_parent("label")
|
||||
if isinstance(enclosing, Tag):
|
||||
txt = _text(enclosing)
|
||||
if txt:
|
||||
return txt
|
||||
|
||||
# 3) aria-label / placeholder / name
|
||||
return _first_non_empty(
|
||||
field_tag.get("aria-label"),
|
||||
field_tag.get("placeholder"),
|
||||
field_tag.get("name"),
|
||||
)
|
||||
|
||||
|
||||
def _iter_controls(soup: BeautifulSoup) -> Iterable[Tag]:
|
||||
for tag in soup.find_all(["input", "textarea", "select"]):
|
||||
if not isinstance(tag, Tag):
|
||||
continue
|
||||
if _is_ignorable_control(tag):
|
||||
continue
|
||||
# Skip invisible elements
|
||||
if tag.has_attr("hidden") or (tag.get("aria-hidden") == "true"):
|
||||
continue
|
||||
if _is_hidden_input(tag):
|
||||
continue
|
||||
yield tag
|
||||
|
||||
|
||||
def extract_form_fields(html: str, *, max_fields: int = 200) -> list[ExtractedField]:
|
||||
"""
|
||||
Extract likely survey/form fields from HTML into a normalized structure.
|
||||
This is intentionally "best-effort" and works across many survey builders.
|
||||
"""
|
||||
soup = BeautifulSoup(html or "", "html.parser")
|
||||
|
||||
# First pass: gather raw controls in order
|
||||
controls: list[Tag] = []
|
||||
for tag in _iter_controls(soup):
|
||||
controls.append(tag)
|
||||
if len(controls) >= max_fields:
|
||||
break
|
||||
|
||||
# Group radio/checkbox by name so they show up as one question
|
||||
seen_group_names: set[str] = set()
|
||||
out: list[ExtractedField] = []
|
||||
|
||||
for c in controls:
|
||||
kind = c.name
|
||||
name = c.get("name")
|
||||
cid = c.get("id")
|
||||
required = bool(c.has_attr("required") or (c.get("aria-required") == "true"))
|
||||
|
||||
if kind == "input":
|
||||
input_type = (c.get("type") or "text").lower()
|
||||
else:
|
||||
input_type = None
|
||||
|
||||
if kind == "input" and input_type in {"radio", "checkbox"} and name:
|
||||
if name in seen_group_names:
|
||||
continue
|
||||
seen_group_names.add(name)
|
||||
group = soup.find_all("input", attrs={"name": name})
|
||||
options: list[dict[str, Any]] = []
|
||||
for opt in group:
|
||||
if not isinstance(opt, Tag):
|
||||
continue
|
||||
if _is_ignorable_control(opt) or _is_hidden_input(opt):
|
||||
continue
|
||||
opt_id = opt.get("id")
|
||||
opt_label = None
|
||||
if opt_id:
|
||||
lbl = soup.find("label", attrs={"for": opt_id})
|
||||
if isinstance(lbl, Tag):
|
||||
opt_label = _text(lbl) or None
|
||||
opt_label = _first_non_empty(opt_label, opt.get("value"), opt_id)
|
||||
options.append(
|
||||
{
|
||||
"label": opt_label,
|
||||
"value": opt.get("value"),
|
||||
}
|
||||
)
|
||||
out.append(
|
||||
ExtractedField(
|
||||
kind="input",
|
||||
input_type=input_type,
|
||||
name=name,
|
||||
id=cid,
|
||||
label=_infer_label(soup, c),
|
||||
required=required,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if kind == "select":
|
||||
options = []
|
||||
for opt in c.find_all("option"):
|
||||
if not isinstance(opt, Tag):
|
||||
continue
|
||||
options.append({"label": _text(opt) or opt.get("value"), "value": opt.get("value")})
|
||||
out.append(
|
||||
ExtractedField(
|
||||
kind="select",
|
||||
input_type=None,
|
||||
name=name,
|
||||
id=cid,
|
||||
label=_infer_label(soup, c),
|
||||
required=required,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
out.append(
|
||||
ExtractedField(
|
||||
kind=str(kind),
|
||||
input_type=input_type,
|
||||
name=name,
|
||||
id=cid,
|
||||
label=_infer_label(soup, c),
|
||||
required=required,
|
||||
)
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
66
python/survey_assistant/llm.py
Normal file
66
python/survey_assistant/llm.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib import request as url_request
|
||||
from urllib import error as url_error
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict, headers: dict[str, str] | None = None, method: str = "POST") -> dict:
|
||||
data = None if method == "GET" else json.dumps(payload).encode("utf-8")
|
||||
req_headers = {"Content-Type": "application/json"}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
req = url_request.Request(url, data=data, headers=req_headers, method=method)
|
||||
try:
|
||||
with url_request.urlopen(req, timeout=60) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
except url_error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
|
||||
except url_error.URLError as exc:
|
||||
raise RuntimeError(f"Connection error: {exc}") from exc
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def ollama_available(base_url: str = "http://localhost:11434") -> bool:
|
||||
try:
|
||||
_post_json(f"{base_url}/api/tags", payload={}, method="GET")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def suggest_answers_with_ollama(
|
||||
*,
|
||||
model: str,
|
||||
questions_text: str,
|
||||
profile_json: str,
|
||||
base_url: str = "http://localhost:11434",
|
||||
) -> str:
|
||||
"""
|
||||
Returns a plain-text set of suggestions.
|
||||
Safety intent: do not fabricate user details; answer UNKNOWN if the profile doesn't support it.
|
||||
"""
|
||||
system = (
|
||||
"You are a survey helper. Help the user answer honestly and consistently.\n"
|
||||
"- Do NOT invent personal facts.\n"
|
||||
"- If a question requires information not present in the profile, answer: UNKNOWN.\n"
|
||||
"- Keep answers concise.\n"
|
||||
)
|
||||
prompt = (
|
||||
f"{system}\n"
|
||||
f"USER_PROFILE_JSON:\n{profile_json}\n\n"
|
||||
f"SURVEY_QUESTIONS:\n{questions_text}\n\n"
|
||||
"Return suggested answers in a readable bullet list."
|
||||
)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"stream": False,
|
||||
}
|
||||
resp = _post_json(f"{base_url}/api/chat", payload=payload)
|
||||
msg = resp.get("message", {}) if isinstance(resp, dict) else {}
|
||||
return str(msg.get("content", "")).strip()
|
||||
|
||||
68
python/survey_assistant/profile.py
Normal file
68
python/survey_assistant/profile.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_PROFILE_PATH = Path("memory") / "survey_profile.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SurveyProfile:
|
||||
"""
|
||||
A user-owned profile used to answer surveys honestly and consistently.
|
||||
|
||||
This is not a "made-up persona" generator. It's meant to store real, user-approved facts
|
||||
and preferences that can be reused across surveys.
|
||||
"""
|
||||
|
||||
demographics: dict[str, Any] = field(default_factory=dict)
|
||||
preferences: dict[str, Any] = field(default_factory=dict)
|
||||
writing_style: dict[str, Any] = field(default_factory=dict)
|
||||
misc: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path = DEFAULT_PROFILE_PATH) -> "SurveyProfile":
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
return cls()
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
demographics=dict(data.get("demographics") or {}),
|
||||
preferences=dict(data.get("preferences") or {}),
|
||||
writing_style=dict(data.get("writing_style") or {}),
|
||||
misc=dict(data.get("misc") or {}),
|
||||
)
|
||||
|
||||
def save(self, path: str | Path = DEFAULT_PROFILE_PATH) -> None:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"demographics": self.demographics,
|
||||
"preferences": self.preferences,
|
||||
"writing_style": self.writing_style,
|
||||
"misc": self.misc,
|
||||
}
|
||||
p.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"demographics": self.demographics,
|
||||
"preferences": self.preferences,
|
||||
"writing_style": self.writing_style,
|
||||
"misc": self.misc,
|
||||
}
|
||||
|
||||
def merge_updates(self, updates: dict[str, Any]) -> None:
|
||||
"""
|
||||
Shallow-merge updates into the profile. Intended for user-approved updates.
|
||||
"""
|
||||
for key in ("demographics", "preferences", "writing_style", "misc"):
|
||||
val = updates.get(key)
|
||||
if isinstance(val, dict):
|
||||
getattr(self, key).update(val)
|
||||
|
||||
76
python/tools/survey_helper.py
Normal file
76
python/tools/survey_helper.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import json
|
||||
|
||||
from python.helpers.tool import Tool, Response
|
||||
from python.survey_assistant.browser_render import render_url_async
|
||||
from python.survey_assistant.extract import extract_form_fields
|
||||
from python.survey_assistant.profile import SurveyProfile
|
||||
from python.survey_assistant.llm import ollama_available, suggest_answers_with_ollama
|
||||
|
||||
|
||||
class SurveyHelper(Tool):
|
||||
"""
|
||||
Extract survey/form questions and optionally suggest answers from a saved user profile.
|
||||
|
||||
Safety: this tool does not fill or submit forms. It only extracts and suggests.
|
||||
"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
url: str = "",
|
||||
html: str = "",
|
||||
include_suggestions: bool = False,
|
||||
ollama_model: str = "llama3",
|
||||
**kwargs,
|
||||
) -> Response:
|
||||
if not url and not html:
|
||||
return Response(
|
||||
message="Error: Provide either 'url' or 'html'.",
|
||||
break_loop=False,
|
||||
)
|
||||
|
||||
page_title = ""
|
||||
final_url = url
|
||||
if url:
|
||||
rr = await render_url_async(url, screenshot_path=None)
|
||||
html = rr.html
|
||||
page_title = rr.title
|
||||
final_url = rr.final_url
|
||||
|
||||
fields = extract_form_fields(html or "")
|
||||
payload = {
|
||||
"url": final_url,
|
||||
"title": page_title,
|
||||
"field_count": len(fields),
|
||||
"fields": [f.to_dict() for f in fields],
|
||||
}
|
||||
|
||||
if include_suggestions:
|
||||
profile = SurveyProfile.load()
|
||||
questions_lines = []
|
||||
for i, f in enumerate(fields, start=1):
|
||||
label = f.label or f.name or f.id or "(unlabeled)"
|
||||
t = f.input_type or f.kind
|
||||
req = " (required)" if f.required else ""
|
||||
questions_lines.append(f"{i}. {label} — {t}{req}")
|
||||
if f.options:
|
||||
for opt in f.options[:30]:
|
||||
questions_lines.append(f" - {opt.get('label')}")
|
||||
if len(f.options) > 30:
|
||||
questions_lines.append(" - ...")
|
||||
|
||||
if ollama_available():
|
||||
try:
|
||||
payload["suggestions"] = suggest_answers_with_ollama(
|
||||
model=ollama_model,
|
||||
questions_text="\n".join(questions_lines),
|
||||
profile_json=json.dumps(profile.as_dict(), indent=2, ensure_ascii=False),
|
||||
)
|
||||
except Exception as exc:
|
||||
payload["suggestions_error"] = str(exc)
|
||||
else:
|
||||
payload["suggestions_error"] = (
|
||||
"Ollama not available at http://localhost:11434"
|
||||
)
|
||||
|
||||
return Response(message=json.dumps(payload, indent=2, ensure_ascii=False), break_loop=False)
|
||||
|
||||
78
run_survey_helper_cli.py
Normal file
78
run_survey_helper_cli.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import argparse
|
||||
import json
|
||||
|
||||
from python.survey_assistant.browser_render import render_url
|
||||
from python.survey_assistant.extract import extract_form_fields
|
||||
from python.survey_assistant.profile import SurveyProfile
|
||||
from python.survey_assistant.llm import ollama_available, suggest_answers_with_ollama
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Survey Helper (CLI)")
|
||||
p.add_argument("url", help="URL to render and extract fields from")
|
||||
p.add_argument("--json", action="store_true", help="Output JSON instead of a human-readable summary")
|
||||
p.add_argument("--suggest", action="store_true", help="Generate suggestions using local Ollama + saved profile")
|
||||
p.add_argument("--ollama-model", default="llama3", help="Ollama model name (default: llama3)")
|
||||
args = p.parse_args()
|
||||
|
||||
rr = render_url(args.url, screenshot_path=None)
|
||||
fields = extract_form_fields(rr.html)
|
||||
|
||||
payload = {
|
||||
"url": rr.final_url,
|
||||
"title": rr.title,
|
||||
"field_count": len(fields),
|
||||
"fields": [f.to_dict() for f in fields],
|
||||
}
|
||||
|
||||
if args.suggest:
|
||||
profile = SurveyProfile.load()
|
||||
questions_lines = []
|
||||
for i, f in enumerate(fields, start=1):
|
||||
label = f.label or f.name or f.id or "(unlabeled)"
|
||||
t = f.input_type or f.kind
|
||||
req = " (required)" if f.required else ""
|
||||
questions_lines.append(f"{i}. {label} — {t}{req}")
|
||||
if f.options:
|
||||
for opt in f.options[:30]:
|
||||
questions_lines.append(f" - {opt.get('label')}")
|
||||
if len(f.options) > 30:
|
||||
questions_lines.append(" - ...")
|
||||
|
||||
if not ollama_available():
|
||||
payload["suggestions_error"] = "Ollama not available at http://localhost:11434"
|
||||
else:
|
||||
payload["suggestions"] = suggest_answers_with_ollama(
|
||||
model=args.ollama_model,
|
||||
questions_text="\n".join(questions_lines),
|
||||
profile_json=json.dumps(profile.as_dict(), indent=2, ensure_ascii=False),
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
print(f"Title: {payload['title']}")
|
||||
print(f"URL: {payload['url']}")
|
||||
print(f"Fields detected: {payload['field_count']}\n")
|
||||
for i, f in enumerate(fields, start=1):
|
||||
label = f.label or f.name or f.id or "(unlabeled)"
|
||||
t = f.input_type or f.kind
|
||||
req = " (required)" if f.required else ""
|
||||
print(f"{i}. {label} — {t}{req}")
|
||||
if f.options:
|
||||
for opt in f.options[:30]:
|
||||
print(f" - {opt.get('label')}")
|
||||
if len(f.options) > 30:
|
||||
print(" - ...")
|
||||
if args.suggest:
|
||||
print("\nSuggested answers (review carefully, do not auto-submit):\n")
|
||||
if "suggestions" in payload:
|
||||
print(payload["suggestions"])
|
||||
else:
|
||||
print(payload.get("suggestions_error", "No suggestions."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
242
run_survey_helper_gui.py
Normal file
242
run_survey_helper_gui.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import json
|
||||
import os
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
|
||||
from python.survey_assistant.browser_render import render_url
|
||||
from python.survey_assistant.extract import extract_form_fields
|
||||
from python.survey_assistant.llm import ollama_available, suggest_answers_with_ollama
|
||||
from python.survey_assistant.profile import SurveyProfile, DEFAULT_PROFILE_PATH
|
||||
|
||||
|
||||
class SurveyHelperGUI:
|
||||
def __init__(self, root: tk.Tk) -> None:
|
||||
self.root = root
|
||||
self.root.title("Survey Helper (Human-in-the-loop)")
|
||||
self.root.minsize(1100, 720)
|
||||
|
||||
self.url_var = tk.StringVar(value="")
|
||||
self.ollama_model_var = tk.StringVar(value=os.environ.get("OLLAMA_MODEL", "llama3"))
|
||||
self.status_var = tk.StringVar(value="Ready")
|
||||
|
||||
self._last_html: str | None = None
|
||||
self._last_title: str | None = None
|
||||
self._last_final_url: str | None = None
|
||||
self._screenshot_path: str | None = None
|
||||
self._screenshot_img = None # keep reference
|
||||
|
||||
self._build_layout()
|
||||
self._load_profile_into_editor()
|
||||
|
||||
def _build_layout(self) -> None:
|
||||
main = ttk.Frame(self.root, padding=10)
|
||||
main.pack(fill="both", expand=True)
|
||||
main.columnconfigure(0, weight=2)
|
||||
main.columnconfigure(1, weight=3)
|
||||
main.rowconfigure(1, weight=1)
|
||||
|
||||
top = ttk.Frame(main)
|
||||
top.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 10))
|
||||
top.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(top, text="URL").grid(row=0, column=0, sticky="w", padx=(0, 8))
|
||||
ttk.Entry(top, textvariable=self.url_var).grid(row=0, column=1, sticky="ew")
|
||||
|
||||
ttk.Button(top, text="Load (render + screenshot)", command=self.on_load).grid(
|
||||
row=0, column=2, sticky="ew", padx=(8, 0)
|
||||
)
|
||||
ttk.Button(top, text="Extract questions", command=self.on_extract).grid(
|
||||
row=0, column=3, sticky="ew", padx=(8, 0)
|
||||
)
|
||||
|
||||
ttk.Label(top, text="Ollama model").grid(row=1, column=0, sticky="w", pady=(8, 0))
|
||||
ttk.Entry(top, textvariable=self.ollama_model_var, width=18).grid(
|
||||
row=1, column=1, sticky="w", pady=(8, 0)
|
||||
)
|
||||
ttk.Button(top, text="Suggest answers", command=self.on_suggest).grid(
|
||||
row=1, column=2, sticky="ew", padx=(8, 0), pady=(8, 0)
|
||||
)
|
||||
|
||||
left = ttk.LabelFrame(main, text="Preview + extracted questions")
|
||||
left.grid(row=1, column=0, sticky="nsew", padx=(0, 10))
|
||||
left.columnconfigure(0, weight=1)
|
||||
left.rowconfigure(1, weight=1)
|
||||
|
||||
self.preview_label = ttk.Label(left, text="(No preview loaded)")
|
||||
self.preview_label.grid(row=0, column=0, sticky="ew", padx=8, pady=8)
|
||||
|
||||
self.questions_text = ScrolledText(left, wrap="word")
|
||||
self.questions_text.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
|
||||
|
||||
right = ttk.Notebook(main)
|
||||
right.grid(row=1, column=1, sticky="nsew")
|
||||
|
||||
tab_profile = ttk.Frame(right, padding=8)
|
||||
tab_suggestions = ttk.Frame(right, padding=8)
|
||||
right.add(tab_profile, text="Profile (editable JSON)")
|
||||
right.add(tab_suggestions, text="Suggestions")
|
||||
|
||||
tab_profile.rowconfigure(0, weight=1)
|
||||
tab_profile.columnconfigure(0, weight=1)
|
||||
|
||||
self.profile_editor = ScrolledText(tab_profile, wrap="word")
|
||||
self.profile_editor.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
prof_btns = ttk.Frame(tab_profile)
|
||||
prof_btns.grid(row=1, column=0, sticky="ew", pady=(8, 0))
|
||||
ttk.Button(prof_btns, text="Reload from disk", command=self._load_profile_into_editor).pack(
|
||||
side="left"
|
||||
)
|
||||
ttk.Button(prof_btns, text="Save to disk", command=self._save_profile_from_editor).pack(
|
||||
side="left", padx=(8, 0)
|
||||
)
|
||||
ttk.Label(prof_btns, text=f"Path: {DEFAULT_PROFILE_PATH}").pack(side="left", padx=(12, 0))
|
||||
|
||||
tab_suggestions.rowconfigure(0, weight=1)
|
||||
tab_suggestions.columnconfigure(0, weight=1)
|
||||
self.suggestions_text = ScrolledText(tab_suggestions, wrap="word")
|
||||
self.suggestions_text.grid(row=0, column=0, sticky="nsew")
|
||||
|
||||
status = ttk.Label(self.root, textvariable=self.status_var, anchor="w")
|
||||
status.pack(fill="x")
|
||||
|
||||
def _set_status(self, msg: str) -> None:
|
||||
self.status_var.set(msg)
|
||||
|
||||
def _load_profile_into_editor(self) -> None:
|
||||
profile = SurveyProfile.load()
|
||||
self.profile_editor.delete("1.0", "end")
|
||||
self.profile_editor.insert("end", json.dumps(profile.as_dict(), indent=2, ensure_ascii=False))
|
||||
|
||||
def _save_profile_from_editor(self) -> None:
|
||||
try:
|
||||
data = json.loads(self.profile_editor.get("1.0", "end").strip() or "{}")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Profile JSON must be an object")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("Invalid JSON", str(exc))
|
||||
return
|
||||
|
||||
profile = SurveyProfile.load()
|
||||
profile.merge_updates(data)
|
||||
profile.save()
|
||||
self._set_status("Profile saved.")
|
||||
|
||||
def on_load(self) -> None:
|
||||
url = self.url_var.get().strip()
|
||||
if not url:
|
||||
return
|
||||
|
||||
self._set_status("Loading and rendering URL...")
|
||||
self.questions_text.delete("1.0", "end")
|
||||
self.questions_text.insert("end", "Loading...\n")
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
self._screenshot_path = os.path.join("tmp", "survey_helper", "preview.png")
|
||||
result = render_url(url, screenshot_path=self._screenshot_path)
|
||||
self._last_html = result.html
|
||||
self._last_title = result.title
|
||||
self._last_final_url = result.final_url
|
||||
self.root.after(0, self._update_preview)
|
||||
self.root.after(
|
||||
0,
|
||||
lambda: self._set_status(
|
||||
f"Loaded: {self._last_title or '(no title)'}"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.root.after(0, lambda: self._set_status(f"Load failed: {exc}"))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _update_preview(self) -> None:
|
||||
if self._last_final_url:
|
||||
self.preview_label.configure(text=f"{self._last_final_url}")
|
||||
if self._screenshot_path and os.path.exists(self._screenshot_path):
|
||||
try:
|
||||
self._screenshot_img = tk.PhotoImage(file=self._screenshot_path)
|
||||
# show a scaled-down preview if very large
|
||||
if self._screenshot_img.width() > 560:
|
||||
subsample = max(1, self._screenshot_img.width() // 560)
|
||||
self._screenshot_img = self._screenshot_img.subsample(subsample)
|
||||
self.preview_label.configure(image=self._screenshot_img, compound="top")
|
||||
except Exception:
|
||||
# preview is best-effort; ignore if tk cannot decode the screenshot
|
||||
pass
|
||||
|
||||
def on_extract(self) -> None:
|
||||
if not self._last_html:
|
||||
self._set_status("Nothing to extract yet. Load a URL first.")
|
||||
return
|
||||
self._set_status("Extracting questions...")
|
||||
fields = extract_form_fields(self._last_html)
|
||||
lines = []
|
||||
for i, f in enumerate(fields, start=1):
|
||||
label = f.label or f.name or f.id or "(unlabeled)"
|
||||
t = f.input_type or f.kind
|
||||
req = " (required)" if f.required else ""
|
||||
lines.append(f"{i}. {label} — {t}{req}")
|
||||
if f.options:
|
||||
for opt in f.options[:30]:
|
||||
lines.append(f" - {opt.get('label')}")
|
||||
if len(f.options) > 30:
|
||||
lines.append(" - ...")
|
||||
self.questions_text.delete("1.0", "end")
|
||||
self.questions_text.insert("end", "\n".join(lines) if lines else "(No form fields detected)")
|
||||
self._set_status(f"Extracted {len(fields)} fields.")
|
||||
|
||||
def on_suggest(self) -> None:
|
||||
questions = self.questions_text.get("1.0", "end").strip()
|
||||
if not questions or questions.startswith("Loading"):
|
||||
self._set_status("No extracted questions to suggest answers for.")
|
||||
return
|
||||
|
||||
model = self.ollama_model_var.get().strip() or "llama3"
|
||||
profile_json = self.profile_editor.get("1.0", "end").strip() or "{}"
|
||||
|
||||
if not ollama_available():
|
||||
self.suggestions_text.delete("1.0", "end")
|
||||
self.suggestions_text.insert(
|
||||
"end",
|
||||
"Ollama not detected at http://localhost:11434.\n"
|
||||
"Start Ollama and try again, or fill answers manually.\n",
|
||||
)
|
||||
self._set_status("Ollama not available.")
|
||||
return
|
||||
|
||||
self._set_status("Generating suggestions (no submission)...")
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
out = suggest_answers_with_ollama(
|
||||
model=model,
|
||||
questions_text=questions,
|
||||
profile_json=profile_json,
|
||||
)
|
||||
self.root.after(0, lambda: self._set_suggestions(out))
|
||||
except Exception as exc:
|
||||
self.root.after(0, lambda: self._set_status(f"Suggest failed: {exc}"))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _set_suggestions(self, text: str) -> None:
|
||||
self.suggestions_text.delete("1.0", "end")
|
||||
self.suggestions_text.insert("end", text or "(Empty response)")
|
||||
self._set_status("Suggestions ready. Review and submit manually in your browser.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = tk.Tk()
|
||||
app = SurveyHelperGUI(root)
|
||||
app._set_status(
|
||||
"Ready. This tool extracts questions and suggests answers; it does not submit surveys."
|
||||
)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
53
scripts/start_survey_helper.sh
Normal file
53
scripts/start_survey_helper.sh
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PIP_USER_FLAG=""
|
||||
|
||||
# Prefer a virtualenv, but fall back gracefully if venv isn't available.
|
||||
if python3 -m venv .venv >/dev/null 2>&1 && [[ -f ".venv/bin/activate" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source .venv/bin/activate
|
||||
else
|
||||
echo "Warning: could not create .venv (python venv module may be missing)."
|
||||
echo "Falling back to user-level installs. For a clean venv, install python3-venv and re-run."
|
||||
PIP_USER_FLAG="--user"
|
||||
fi
|
||||
|
||||
python3 -m pip install ${PIP_USER_FLAG} --upgrade pip >/dev/null || true
|
||||
#
|
||||
# Install *minimal* dependencies for the Survey Helper only.
|
||||
# (Installing the full Agent Zero requirements may require extra build tooling on some systems.)
|
||||
#
|
||||
python3 -m pip install ${PIP_USER_FLAG} "beautifulsoup4==4.12.3" "playwright==1.49.0"
|
||||
|
||||
# Ensure Playwright browser is installed (Chromium)
|
||||
python3 -m playwright install chromium
|
||||
|
||||
if python3 -c "import tkinter" >/dev/null 2>&1; then
|
||||
echo "Starting Survey Helper GUI..."
|
||||
python3 run_survey_helper_gui.py
|
||||
else
|
||||
echo "Error: tkinter is not installed for your Python."
|
||||
echo "To enable the GUI, install it and re-run."
|
||||
echo "Ubuntu/Debian: sudo apt-get update && sudo apt-get install -y python3-tk"
|
||||
echo ""
|
||||
echo "CLI fallback:"
|
||||
echo " SURVEY_HELPER_MODE=cli bash scripts/start_survey_helper.sh \"https://example.com\""
|
||||
if [[ "${SURVEY_HELPER_MODE:-}" == "cli" ]]; then
|
||||
if [[ "${1:-}" == "" ]]; then
|
||||
echo "Please provide a URL as the first argument in CLI mode."
|
||||
exit 1
|
||||
fi
|
||||
python3 run_survey_helper_cli.py "$1"
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue