From b7efe4992ac9a6a2ff3be1787b7a4dbffe00bd7d Mon Sep 17 00:00:00 2001 From: nic <33767999+nicsins@users.noreply.github.com> Date: Tue, 17 Mar 2026 04:02:15 -0500 Subject: [PATCH] Add human-in-the-loop survey helper (GUI + CLI + Agent tool) (#181) * Add human-in-the-loop survey helper Co-authored-by: nic * Make survey helper launcher robust and add CLI fallback Co-authored-by: nic * Detect missing display for tkinter GUI Co-authored-by: nic * Add prediction dataset + review workflow for uncertain survey answers Co-authored-by: nic --------- Co-authored-by: Cursor Agent Co-authored-by: nic --- docs/survey_helper.md | 19 ++- .../agent.system.tool.survey_helper.md | 11 +- python/survey_assistant/extract.py | 29 +++- python/survey_assistant/llm.py | 66 +++++++++ python/survey_assistant/predictions.py | 129 ++++++++++++++++++ python/tools/survey_helper.py | 86 +++++++++--- run_survey_helper_cli.py | 122 +++++++++++++---- run_survey_helper_review.py | 73 ++++++++++ scripts/start_survey_helper.sh | 40 +++++- 9 files changed, 526 insertions(+), 49 deletions(-) create mode 100644 python/survey_assistant/predictions.py create mode 100644 run_survey_helper_review.py diff --git a/docs/survey_helper.md b/docs/survey_helper.md index 81cd2c03d..3c8f72a9d 100644 --- a/docs/survey_helper.md +++ b/docs/survey_helper.md @@ -4,7 +4,7 @@ This feature helps you **review** a survey page, **extract** form questions, and 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**. +- It **does not** fabricate personal details; if your profile doesn’t contain needed info, predictions are flagged as **needs_clarification=true** for later review. ## How it works @@ -24,12 +24,23 @@ It is intentionally designed to be human-in-the-loop: - **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. + - If the profile doesn’t contain enough info, the tool will still provide **educated guesses** but will mark them as **needs_clarification=true** so you can review later. ## Profile storage - Stored at `memory/survey_profile.json` - You can edit it in the GUI under **“Profile (editable JSON)”**. +## Prediction dataset (review later) + +When you enable recording, any predicted answers that require clarification are appended to: +- `memory/survey_predictions.jsonl` + +You can review/export pending items and store clarifications with: +- `python3 run_survey_helper_review.py --list` +- `python3 run_survey_helper_review.py --export-json` +- `python3 run_survey_helper_review.py --id q_... --answer "..." --save-to-profile` + ## Ways to use it ### A) Desktop GUI (recommended) @@ -64,6 +75,12 @@ Or directly: python3 run_survey_helper_cli.py "https://example.com/survey" --json ``` +To generate predictions and record items that need clarification: + +```bash +python3 run_survey_helper_cli.py "https://example.com/survey" --predict --record --top-k 3 --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`. diff --git a/prompts/default/agent.system.tool.survey_helper.md b/prompts/default/agent.system.tool.survey_helper.md index 66304e74c..168f280ec 100644 --- a/prompts/default/agent.system.tool.survey_helper.md +++ b/prompts/default/agent.system.tool.survey_helper.md @@ -4,13 +4,16 @@ Extracts form/survey questions from a URL (rendered) or provided HTML, and retur 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. +- Do not fabricate personal details; rely on the user's saved profile. If info is missing, predictions must be marked `needs_clarification=true` with assumptions noted in the rationale. 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) +- `include_suggestions` (bool, optional): if true, also generate answer predictions (candidates + confidence) from the saved profile (requires local Ollama) - `ollama_model` (string, optional): Ollama model name (default `llama3`) +- `top_k` (int, optional): candidates per question (default 3) +- `record_predictions` (bool, optional): if true, append items marked `needs_clarification=true` to `memory/survey_predictions.jsonl` +- `predictions_path` (string, optional): override dataset path (default `memory/survey_predictions.jsonl`) Usage: ```json @@ -19,7 +22,9 @@ Usage: "tool_name": "survey_helper", "tool_args": { "url": "https://example.com/survey", - "include_suggestions": false + "include_suggestions": true, + "top_k": 3, + "record_predictions": true } } ``` diff --git a/python/survey_assistant/extract.py b/python/survey_assistant/extract.py index 8bd3bb920..4ff045c51 100644 --- a/python/survey_assistant/extract.py +++ b/python/survey_assistant/extract.py @@ -76,6 +76,32 @@ def _infer_label(soup: BeautifulSoup, field_tag: Tag) -> str | None: field_tag.get("name"), ) +def _infer_group_label(soup: BeautifulSoup, first_input: Tag) -> str | None: + # Prefer
Question...
+ fs = first_input.find_parent("fieldset") + if isinstance(fs, Tag): + legend = fs.find("legend") + if isinstance(legend, Tag): + txt = _text(legend) + if txt: + return txt + + # Try previous meaningful text near the input (common in survey builders) + probe: Tag | None = first_input + for _ in range(6): + if not probe: + break + prev = probe.find_previous( + ["h1", "h2", "h3", "h4", "h5", "h6", "p", "div", "span", "label"] + ) + if isinstance(prev, Tag): + txt = _text(prev) + # Avoid using option labels (very short) as question label + if txt and len(txt) >= 4: + return txt + probe = probe.parent if isinstance(probe.parent, Tag) else None + + return _first_non_empty(first_input.get("name"), first_input.get("aria-label")) def _iter_controls(soup: BeautifulSoup) -> Iterable[Tag]: for tag in soup.find_all(["input", "textarea", "select"]): @@ -144,13 +170,14 @@ def extract_form_fields(html: str, *, max_fields: int = 200) -> list[ExtractedFi "value": opt.get("value"), } ) + group_label = _infer_group_label(soup, c) out.append( ExtractedField( kind="input", input_type=input_type, name=name, id=cid, - label=_infer_label(soup, c), + label=group_label or _infer_label(soup, c), required=required, options=options, ) diff --git a/python/survey_assistant/llm.py b/python/survey_assistant/llm.py index 72abb5275..b6bcf0e4d 100644 --- a/python/survey_assistant/llm.py +++ b/python/survey_assistant/llm.py @@ -64,3 +64,69 @@ def suggest_answers_with_ollama( msg = resp.get("message", {}) if isinstance(resp, dict) else {} return str(msg.get("content", "")).strip() +def predict_answers_json_with_ollama( + *, + model: str, + url: str, + title: str, + fields_json: str, + profile_json: str, + top_k: int = 3, + base_url: str = "http://localhost:11434", +) -> dict: + """ + Return structured predictions for each field as JSON. + + Contract: + - Always output candidates with confidences in [0,1] + - If profile lacks required info, still provide best-guess candidates but set needs_clarification=true + - Never claim facts not supported by the profile; label assumptions in rationale + """ + system = ( + "You are a survey helper that suggests answers for the user.\n" + "Hard rules:\n" + "- Do NOT invent personal facts.\n" + "- If info is missing, make an educated guess BUT mark it as an assumption and set needs_clarification=true.\n" + "- Prefer neutral/privacy-preserving options when uncertain.\n" + "- Output MUST be valid JSON only (no markdown).\n" + ) + + prompt = ( + f"{system}\n" + f"PAGE_URL: {url}\n" + f"PAGE_TITLE: {title}\n" + f"TOP_K: {top_k}\n\n" + f"USER_PROFILE_JSON:\n{profile_json}\n\n" + f"FIELDS_JSON (array of fields; each has kind/input_type/label/options/etc):\n{fields_json}\n\n" + "Return JSON with this shape:\n" + "{\n" + ' "predictions": [\n' + " {\n" + ' "field_index": 1,\n' + ' "selected": "string",\n' + ' "confidence": 0.0,\n' + ' "candidates": [{"value":"string","confidence":0.0}],\n' + ' "needs_clarification": true,\n' + ' "rationale": "short explanation, mention when assumption"\n' + " }\n" + " ]\n" + "}\n" + ) + + 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 {} + content = str(msg.get("content", "")).strip() + try: + parsed = json.loads(content) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + return {"error": "Model did not return valid JSON", "raw": content} diff --git a/python/survey_assistant/predictions.py b/python/survey_assistant/predictions.py new file mode 100644 index 000000000..dbb60a081 --- /dev/null +++ b/python/survey_assistant/predictions.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, asdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +DEFAULT_PREDICTIONS_PATH = Path("memory") / "survey_predictions.jsonl" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _stable_hash(data: Any) -> str: + raw = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + return hashlib.sha256(raw).hexdigest()[:16] + + +def build_question_id(*, url: str, field: dict[str, Any]) -> str: + key = { + "url": url, + "kind": field.get("kind"), + "input_type": field.get("input_type"), + "name": field.get("name"), + "id": field.get("id"), + "label": field.get("label"), + "options": field.get("options") or [], + } + return f"q_{_stable_hash(key)}" + + +@dataclass +class Candidate: + value: str + confidence: float + + +@dataclass +class PredictionRecord: + """ + A single predicted answer for a question/field, stored for later review/clarification. + """ + + id: str + timestamp: str + url: str + title: str + field_index: int + field: dict[str, Any] + selected: str + confidence: float + candidates: list[Candidate] + rationale: str + needs_clarification: bool + source: str # llm|heuristic|profile + + def to_jsonl(self) -> str: + data = asdict(self) + data["candidates"] = [asdict(c) for c in self.candidates] + return json.dumps(data, ensure_ascii=False) + + +def append_predictions( + records: Iterable[PredictionRecord], + *, + path: str | Path = DEFAULT_PREDICTIONS_PATH, +) -> Path: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with p.open("a", encoding="utf-8") as f: + for r in records: + f.write(r.to_jsonl() + "\n") + return p + + +def load_predictions(path: str | Path = DEFAULT_PREDICTIONS_PATH) -> list[dict[str, Any]]: + p = Path(path) + if not p.exists(): + return [] + out: list[dict[str, Any]] = [] + for line in p.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if isinstance(obj, dict): + out.append(obj) + except Exception: + continue + return out + + +def pending_predictions( + path: str | Path = DEFAULT_PREDICTIONS_PATH, +) -> list[dict[str, Any]]: + return [r for r in load_predictions(path) if r.get("needs_clarification") is True] + + +def write_clarifications( + clarifications: dict[str, str], + *, + path: str | Path = Path("memory") / "survey_clarifications.json", +) -> Path: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + existing: dict[str, str] = {} + if p.exists(): + try: + obj = json.loads(p.read_text(encoding="utf-8")) + if isinstance(obj, dict): + existing = {str(k): str(v) for k, v in obj.items()} + except Exception: + existing = {} + existing.update({str(k): str(v) for k, v in clarifications.items()}) + p.write_text(json.dumps(existing, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + return p + + +def utc_now_iso() -> str: + # exported helper + return _utc_now_iso() + diff --git a/python/tools/survey_helper.py b/python/tools/survey_helper.py index ff3a0aa4f..055d8a2de 100644 --- a/python/tools/survey_helper.py +++ b/python/tools/survey_helper.py @@ -4,7 +4,15 @@ 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 +from python.survey_assistant.llm import ollama_available, predict_answers_json_with_ollama +from python.survey_assistant.predictions import ( + DEFAULT_PREDICTIONS_PATH, + PredictionRecord, + Candidate, + append_predictions, + build_question_id, + utc_now_iso, +) class SurveyHelper(Tool): @@ -20,6 +28,9 @@ class SurveyHelper(Tool): html: str = "", include_suggestions: bool = False, ollama_model: str = "llama3", + top_k: int = 3, + record_predictions: bool = False, + predictions_path: str = str(DEFAULT_PREDICTIONS_PATH), **kwargs, ) -> Response: if not url and not html: @@ -46,31 +57,68 @@ class SurveyHelper(Tool): 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( + pred = predict_answers_json_with_ollama( model=ollama_model, - questions_text="\n".join(questions_lines), + url=final_url, + title=page_title, + fields_json=json.dumps([f.to_dict() for f in fields], ensure_ascii=False), profile_json=json.dumps(profile.as_dict(), indent=2, ensure_ascii=False), + top_k=max(1, min(8, int(top_k or 3))), ) + payload["predictions"] = pred.get("predictions", []) + if pred.get("error"): + payload["predictions_error"] = pred.get("error") + payload["predictions_raw"] = pred.get("raw") + + if record_predictions and isinstance(payload.get("predictions"), list): + records: list[PredictionRecord] = [] + for item in payload["predictions"]: + try: + idx = int(item.get("field_index")) + except Exception: + continue + if idx < 1 or idx > len(fields): + continue + if not bool(item.get("needs_clarification")): + continue + field_dict = fields[idx - 1].to_dict() + qid = build_question_id(url=final_url, field=field_dict) + cand_objs: list[Candidate] = [] + for c in (item.get("candidates") or [])[: max(1, min(10, top_k))]: + try: + cand_objs.append( + Candidate( + value=str(c.get("value", "")), + confidence=float(c.get("confidence", 0.0)), + ) + ) + except Exception: + continue + records.append( + PredictionRecord( + id=qid, + timestamp=utc_now_iso(), + url=final_url, + title=page_title, + field_index=idx, + field=field_dict, + selected=str(item.get("selected", "")), + confidence=float(item.get("confidence", 0.0) or 0.0), + candidates=cand_objs, + rationale=str(item.get("rationale", "")), + needs_clarification=True, + source="llm", + ) + ) + if records: + p = append_predictions(records, path=predictions_path) + payload["recorded_predictions_path"] = str(p) except Exception as exc: - payload["suggestions_error"] = str(exc) + payload["predictions_error"] = str(exc) else: - payload["suggestions_error"] = ( - "Ollama not available at http://localhost:11434" - ) + payload["predictions_error"] = "Ollama not available at http://localhost:11434" return Response(message=json.dumps(payload, indent=2, ensure_ascii=False), break_loop=False) diff --git a/run_survey_helper_cli.py b/run_survey_helper_cli.py index 1d094ce2e..7b7a1836c 100644 --- a/run_survey_helper_cli.py +++ b/run_survey_helper_cli.py @@ -4,15 +4,38 @@ 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 +from python.survey_assistant.llm import ollama_available, predict_answers_json_with_ollama +from python.survey_assistant.predictions import ( + DEFAULT_PREDICTIONS_PATH, + PredictionRecord, + Candidate, + append_predictions, + build_question_id, + utc_now_iso, +) 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( + "--predict", + action="store_true", + help="Generate structured predictions (candidates + confidence) using local Ollama + saved profile", + ) p.add_argument("--ollama-model", default="llama3", help="Ollama model name (default: llama3)") + p.add_argument("--top-k", type=int, default=3, help="Number of candidates per question (default: 3)") + p.add_argument( + "--record", + action="store_true", + help="Append predictions needing clarification to memory/survey_predictions.jsonl", + ) + p.add_argument( + "--predictions-path", + default=str(DEFAULT_PREDICTIONS_PATH), + help="Where to store predictions JSONL (default: memory/survey_predictions.jsonl)", + ) args = p.parse_args() rr = render_url(args.url, screenshot_path=None) @@ -25,28 +48,69 @@ def main() -> None: "fields": [f.to_dict() for f in fields], } - if args.suggest: + recorded_path = None + if args.predict: 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" + payload["predictions_error"] = "Ollama not available at http://localhost:11434" else: - payload["suggestions"] = suggest_answers_with_ollama( + pred = predict_answers_json_with_ollama( model=args.ollama_model, - questions_text="\n".join(questions_lines), + url=rr.final_url, + title=rr.title, + fields_json=json.dumps([f.to_dict() for f in fields], ensure_ascii=False), profile_json=json.dumps(profile.as_dict(), indent=2, ensure_ascii=False), + top_k=max(1, min(8, args.top_k)), ) + payload["predictions"] = pred.get("predictions", []) + if pred.get("error"): + payload["predictions_error"] = pred.get("error") + payload["predictions_raw"] = pred.get("raw") + + if args.record and isinstance(payload.get("predictions"), list): + records: list[PredictionRecord] = [] + for item in payload["predictions"]: + try: + idx = int(item.get("field_index")) + except Exception: + continue + if idx < 1 or idx > len(fields): + continue + field_dict = fields[idx - 1].to_dict() + qid = build_question_id(url=rr.final_url, field=field_dict) + needs = bool(item.get("needs_clarification")) + if not needs: + continue + cand_objs = [] + for c in (item.get("candidates") or [])[: max(1, min(10, args.top_k))]: + try: + cand_objs.append( + Candidate( + value=str(c.get("value", "")), + confidence=float(c.get("confidence", 0.0)), + ) + ) + except Exception: + continue + records.append( + PredictionRecord( + id=qid, + timestamp=utc_now_iso(), + url=rr.final_url, + title=rr.title, + field_index=idx, + field=field_dict, + selected=str(item.get("selected", "")), + confidence=float(item.get("confidence", 0.0) or 0.0), + candidates=cand_objs, + rationale=str(item.get("rationale", "")), + needs_clarification=True, + source="llm", + ) + ) + if records: + recorded_path = append_predictions(records, path=args.predictions_path) + payload["recorded_predictions_path"] = str(recorded_path) if args.json: print(json.dumps(payload, indent=2, ensure_ascii=False)) @@ -65,12 +129,24 @@ def main() -> None: 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"]) + if args.predict: + print("\nPredictions (review carefully, do not auto-submit):\n") + if "predictions" in payload and isinstance(payload["predictions"], list): + for item in payload["predictions"]: + print(f"- field_index={item.get('field_index')}: {item.get('selected')} " + f"(conf={item.get('confidence')}, clarify={item.get('needs_clarification')})") + cands = item.get("candidates") or [] + for c in cands[: max(1, min(10, args.top_k))]: + print(f" - {c.get('value')} ({c.get('confidence')})") + rat = (item.get("rationale") or "").strip() + if rat: + print(f" rationale: {rat}") + elif "predictions_error" in payload: + print(payload.get("predictions_error", "No predictions.")) else: - print(payload.get("suggestions_error", "No suggestions.")) + print("No predictions.") + if recorded_path: + print(f"\nRecorded pending items to: {recorded_path}") if __name__ == "__main__": diff --git a/run_survey_helper_review.py b/run_survey_helper_review.py new file mode 100644 index 000000000..7752ed95d --- /dev/null +++ b/run_survey_helper_review.py @@ -0,0 +1,73 @@ +import argparse +import json + +from python.survey_assistant.predictions import ( + DEFAULT_PREDICTIONS_PATH, + pending_predictions, + write_clarifications, +) +from python.survey_assistant.profile import SurveyProfile + + +def main() -> None: + p = argparse.ArgumentParser(description="Review / clarify saved survey predictions") + p.add_argument( + "--predictions", + default=str(DEFAULT_PREDICTIONS_PATH), + help="Path to survey_predictions.jsonl (default: memory/survey_predictions.jsonl)", + ) + p.add_argument("--list", action="store_true", help="List pending items needing clarification") + p.add_argument("--export-json", action="store_true", help="Export pending items as JSON") + p.add_argument("--id", default="", help="Prediction/question id to clarify (q_...)") + p.add_argument("--answer", default="", help="Clarified answer/value to store") + p.add_argument( + "--save-to-profile", + action="store_true", + help="Also store the clarified answer into the profile misc bucket by id", + ) + args = p.parse_args() + + pend = pending_predictions(args.predictions) + + if args.export_json: + print(json.dumps(pend, indent=2, ensure_ascii=False)) + return + + if args.id and args.answer: + write_clarifications({args.id: args.answer}) + if args.save_to_profile: + prof = SurveyProfile.load() + prof.misc.setdefault("clarified_answers_by_id", {}) + if isinstance(prof.misc["clarified_answers_by_id"], dict): + prof.misc["clarified_answers_by_id"][args.id] = args.answer + prof.save() + print(f"Saved clarification for {args.id}") + return + + # default: list + if not args.list and not args.export_json: + args.list = True + + if args.list: + if not pend: + print("No pending predictions needing clarification.") + return + for i, r in enumerate(pend, start=1): + field = r.get("field") or {} + label = field.get("label") or field.get("name") or field.get("id") or "(unlabeled)" + print(f"{i}. {r.get('id')} — {label}") + print(f" selected: {r.get('selected')} (confidence={r.get('confidence')})") + cands = r.get("candidates") or [] + if cands: + print(" candidates:") + for c in cands[:5]: + print(f" - {c.get('value')} ({c.get('confidence')})") + rationale = (r.get("rationale") or "").strip() + if rationale: + print(f" rationale: {rationale}") + print("") + + +if __name__ == "__main__": + main() + diff --git a/scripts/start_survey_helper.sh b/scripts/start_survey_helper.sh index 58cab60dc..db9889ebd 100644 --- a/scripts/start_survey_helper.sh +++ b/scripts/start_survey_helper.sh @@ -31,8 +31,44 @@ python3 -m pip install ${PIP_USER_FLAG} "beautifulsoup4==4.12.3" "playwright==1. 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 + # If there's no display server, tkinter will raise a TclError at runtime. + if [[ -z "${DISPLAY:-}" ]]; then + echo "Error: no DISPLAY detected (headless environment)." + echo "To run the GUI, run this on a machine with a desktop session, or use the CLI fallback below." + 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" + exit 0 + fi + exit 1 + fi + + # Sanity-check that tkinter can actually connect to the display. + if python3 -c "import tkinter as tk; r=tk.Tk(); r.destroy()" >/dev/null 2>&1; then + echo "Starting Survey Helper GUI..." + python3 run_survey_helper_gui.py + else + echo "Error: tkinter is installed but cannot connect to your display (\$DISPLAY=${DISPLAY})." + echo "If you're in a server/VM, you likely need X forwarding or to run on a desktop machine." + 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" + exit 0 + fi + exit 1 + fi else echo "Error: tkinter is not installed for your Python." echo "To enable the GUI, install it and re-run."