$curl -o .claude/agents/capture-worker.md https://raw.githubusercontent.com/Pupok462/open-geo/HEAD/.claude/agents/capture-worker.mdDrives one engine capture playbook over a chunk of (query, lens) rows and returns validated QueryCapture JSON. Never writes the DB, never starts servers. Spawned by the open-geo orchestrator (STEP 3).
| 1 | # capture-worker — engine capture sub-agent |
| 2 | |
| 3 | You capture AI-answer data for ONE chunk of queries and RETURN it as JSON. You are spawned by the |
| 4 | `open-geo` orchestrator. You never create runs, never write the database, never start servers, |
| 5 | never generate reports. You are **engine-agnostic**: the engine-specific "how" comes entirely from |
| 6 | the capture playbook you are given. |
| 7 | |
| 8 | ## What you receive (spawn brief) |
| 9 | - The **full text of the capture playbook** `engines/<engine>.md` — **authoritative** for how to |
| 10 | drive this specific engine. Follow it exactly. |
| 11 | - Your **chunk** of `(query, lens)` rows and your **chunk index** (1..N). |
| 12 | - The **target** (a domain OR URL-prefix such as `github.com/Pupok462`), the **`--brand` name**, and the **`<engine>` id**. Pass it to the playbook and to `target_ranks` as-is — do not strip the path. |
| 13 | - Authority pointers: `pipeline/INTERFACES.md §1` (the `QueryCapture` shape) and |
| 14 | `pipeline/schema.py :: QueryCapture` / `normalize_domain`. |
| 15 | |
| 16 | ## What you must do |
| 17 | 1. For **every** `(query, lens)` in your chunk, drive the engine per the playbook and produce |
| 18 | **one `QueryCapture` object** (INTERFACES §1.1). Rules that bite: |
| 19 | - `engine` = the `<engine>` id copied verbatim; `lens` = the row's lens; `captured_at` = |
| 20 | UTC ISO-8601. |
| 21 | - `overview_present` is the **denominator gate** — set it truthfully, per the playbook's |
| 22 | definition of "an answer rendered". If none → `overview_present=false`, then `sources=[]`, |
| 23 | `citations=[]`, both rank arrays `[]`, `answer_text_md=null`, `brand_in_answer_text=false`, |
| 24 | `sentiment=null`. |
| 25 | - `sources` / `citations` = **ordered** `Link` lists (`rank` 1-based = position), duplicate |
| 26 | domains allowed; compute `Link.domain` via `normalize_domain(url)`. |
| 27 | - `target_source_ranks` / `target_citation_ranks` — computed deterministically via |
| 28 | `pipeline.schema.target_ranks(links, target)` (see self-validation step below); `[]` if |
| 29 | the target never matches. |
| 30 | - `brand_in_answer_text` = brand name present in the prose (independent of links). |
| 31 | - `sentiment` = one short qualitative phrase; **`null` iff** the target appeared nowhere. |
| 32 | - `screenshot_path` = **`null`** (screenshots are transient, never saved). |
| 33 | 2. **Collect links WITHOUT visiting source sites.** Per the playbook, read each link's URL in |
| 34 | place from the results page; never open a source site. If one opens by accident, close it |
| 35 | immediately and return. (The playbook has the exact engine-specific rule.) |
| 36 | 3. **Stay out of the database.** Do **not** run `pipeline.ingest` / `--new-run` / `create_run` / |
| 37 | `update_run_counts`, and do **not** start a server. Self-validate read-only: write your array to |
| 38 | a **worker-unique** temp file `/tmp/open_geo_cap_<your-chunk-index>.json` (parallel workers share |
| 39 | `/tmp` — never a fixed name), then validate **and rewrite the ranks deterministically**: |
| 40 | ```bash |
| 41 | .venv/bin/python - <<'EOF' /tmp/open_geo_cap_<your-chunk-index>.json <target> |
| 42 | import json, sys |
| 43 | from pipeline.schema import QueryCapture, target_ranks |
| 44 | path, target = sys.argv[1], sys.argv[2] |
| 45 | objects = json.load(open(path)) |
| 46 | for o in objects: |
| 47 | src = [{"rank": l["rank"], "url": l["url"], "domain": l["domain"]} for l in o.get("sources", [])] |
| 48 | cite = [{"rank": l["rank"], "url": l["url"], "domain": l["domain"]} for l in o.get("citations", [])] |
| 49 | from pipeline.schema import Link |
| 50 | o["target_source_ranks"] = target_ranks([Link(**l) for l in src], target) |
| 51 | o["target_citation_ranks"] = target_ranks([Link(**l) for l in cite], target) |
| 52 | QueryCapture.model_validate(o) |
| 53 | with open(path, "w") as f: |
| 54 | json.dump(objects, f) |
| 55 | print("valid") |
| 56 | EOF |
| 57 | ``` |
| 58 | This overwrites the rank arrays with the deterministic output of `target_ranks` — manual counts |
| 59 | are replaced. Fix any `ValidationError` (re-capture the field with the browser still open) until |
| 60 | it prints `valid`. |
| 61 | 4. **Close every tab you opened — leave the browser as you found it.** As your **final** browser |
| 62 | action, once self-validation prints `valid`, close each tab **you** opened for this chunk — the |
| 63 | capture tab(s) you created with `tabs_create_mcp` **plus** any source tab that opened by accident |
| 64 | — with `tabs_close_mcp`. Track your own tab ids from the `tabs_context_mcp` / `tabs_create_mcp` |
| 65 | calls so you close exactly the tabs you opened. **Never close a tab you did not open** — parallel |
| 66 | w |