Open geo
Run a list of queries through a chosen AI engine, measure the target domain's visibility/citation in the AI answers, and produce a dashboard or PDF report. Use when the user runs /open-geo or asks to measure a brand's GEO / AI-search visibility (citations in Google AI Overview, etc.).From its SKILL.md
npx -y skills add Pupok462/open-geo --skill open-geoAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
3 things to look at
- skips confirmationTells the agent to proceed without asking first, 2 times: "do not print the intro or ask anything" and 1 more.
- 18 stars18 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 3 commands, including `.venv/bin/python -m audit.gate --domain <domain> --engine <engine>` and 2 more.
SKILL.md
39.8 KB, ~10.5k tokens by cl100k_base, as published. Nobody here has run it
open-geo — GEO visibility run orchestrator
You are the orchestrator for one open-geo run: drive a list of queries through one AI engine, capture how the target domain shows up in the answers, ingest the captures through the validated pipeline, aggregate metrics, and emit a dashboard and/or a PDF report — finishing with a short summary.
This skill is the single operator entry point. It coordinates components that are
specified in pipeline/INTERFACES.md (the authoritative contract). Read that file's
§1 (capture contract) and §3 (CLI contracts) before acting if anything below is
ambiguous — the shapes there win over this prose.
Conventions (from
CLAUDE.md): code/identifiers and intermediate JSON are English. The final summary printed to the user follows--lang(default English). Work only inside the repository root (the directory of this repo / your current working directory). Run all Python with the project venv (.venv/bin/python) sopipeline.*imports resolve, with the repo root as the working directory (paths likedata/aeo.dbare repo-root-relative).
INVOCATION
/open-geo <questions.csv> <engine> <domain> --brand "<name>" --n-worker <N> \
[--output dashboard|pdf|both] [--period today|all] [--lang en|ru|zh|ar] [--force]
Positional arguments
| arg | meaning |
|---|---|
<questions.csv> | Path to the input CSV. Columns: query,lens where lens ∈ general | branded | comparative. See examples/questions.csv for a ready sample. general = neutral query, no brand named; branded = brand explicitly named; comparative = brand vs alternatives. Either a hand-made CSV or one generated by STEP A.5 (question harvesting, Feature 1 — harvest/METHODOLOGY.md); both are first-class. |
<engine> | Engine id, snake_case, e.g. google. This value is (a) the engine field written into every QueryCapture and the run, and (b) the basename of the capture playbook the workers load: engines/<engine>.md (so google ↔ engines/google.md). This is the multi-engine extension point — google (Google AI Overview), chatgpt_search (ChatGPT web search), claude_search (Claude web search), yandex_neuro (Yandex Alice / Нейро), gemini (Google Gemini) and deepseek (DeepSeek web search) ship today; perplexity (Perplexity) has an authored playbook (engines/perplexity.md) pending its first live-validation run; the others are on the roadmap (ROADMAP Feature 3), and adding one is mainly authoring engines/<engine>.md (see engines/README.md). |
<domain> | The target — a registrable domain (example.com) or a URL prefix (github.com/user/repo). Accept any spelling; normalized via pipeline.schema.normalize_target. Workers match links against the target via matches_target/target_ranks (same semantics pipeline-wide). |
Flags
| flag | required | default | meaning |
|---|---|---|---|
--brand "<name>" | yes | — | Human brand name (free text, may contain spaces — keep it quoted). Stored on the run; used in report/dashboard titles and the summary. |
--n-worker <N> | yes | — | Number of capture sub-agents to run in parallel — the run's concurrency. Step 2 splits the queries into N chunks, one per worker. |
--output dashboard|pdf|both | no | dashboard | Which deliverable(s) to produce in step 6. |
--period today|all | no | all | Reporting window passed to the dashboard/report: today = just this run's date, all = full history for this brand+engine (adds the PDF trend chart / the dashboard's whole-period view). Previous-run deltas (INTERFACES §4.1) render whenever an earlier completed run exists — in the PDF for either period, and in the dashboard's latest-run view. |
--lang en|ru|zh|ar | no | en | UI language for the deliverables: it is passed to the report (report.generate --lang) and is the dashboard's default language (the switcher can still change it in the browser). Extensible to any code registered in i18n/locales.json. It also sets the language of the final summary you print in step 7. |
--force | no | off | Override the GEO-audit gate (STEP 0): proceed with the run even when the audit verdict is blocked (a category-A blocker — the domain is unreadable by the engine's search bot / unreachable / JS-only). Without it, a blocked verdict hard-stops before any run and prints the remediation. Advisory (ready_with_warnings) verdicts never need --force. |
--repeat R | no | 1 | Repeat-run group (INTERFACES §2.1, Feature 5): capture the SAME question set R times as R ordinary runs sharing one group_id. Costs R× capture — a deliberate operator choice to separate signal from LLM noise. The dashboard then reads the group as one measurement: weighted mean of the seven metrics + a min–max spread chip per card (deltas are suppressed inside a group). R=1 = today's behavior, no group. See "Repeats" note under STEP 1. |
If a required argument is missing, go to STEP A (the parameter wizard) to collect it
interactively. Only hard-stop — a short error (in --lang), no empty run — if a required value
is still unresolved after the wizard (or the user abandons it), or if questions.csv does not
exist / has no data rows.
STEP R — REPO-ROOT GUARD (always the very first check)
This skill reaches users two ways: a clone of the open-geo repository (the primary,
fully supported path) or the Claude Code plugin (a discovery wrapper: its manifest
registers this skill and the worker agents — nothing else). The pipeline itself
(pipeline/*, engines/*.md, .venv, data/aeo.db) exists only inside a repo clone,
and every path in this skill is repo-root-relative.
Before STEP A, probe the current working directory with Read:
-
pipeline/INTERFACES.mdis missing → you are NOT in a repo clone (typical when the plugin-installed skill is invoked from an unrelated project). Do not run the wizard, do not call.venv/bin/python, do not create any file or directory here. Print (in--lang): the/open-geocommand is installed, but the open-geo pipeline runs from a clone of the repository — then the exact steps:git clone https://github.com/Pupok462/open-geo cd open-geo && scripts/setup.shand re-run
/open-geofrom that directory (the user can also ask you to run these steps for them outside this skill). Stop. -
Repo present but
.venv/is missing → setup has not been run. Tell the user to runscripts/setup.sh(creates the venv, installs Python deps and the dashboard frontend), then re-run/open-geo. Stop.
STEP A — RESOLVE PARAMETERS (intro + wizard, with fast-path bypass)
Run this after the STEP R guard, before STEP 0. Goal: end up with every required parameter resolved.
Required: questions.csv, engine, domain, --brand, --n-worker.
Optional (defaults): --output (dashboard), --period (all), --lang (en).
- Parse the invocation — gather values from positional args, flags, AND anything the user expressed in free text (e.g. "measure example.com on google, 5 workers, pdf").
- FAST PATH — all required resolved: do not print the intro or ask anything. Echo one
confirmation line —
Running: csv=… engine=… domain=… brand=… n-worker=… output=… period=… lang=…— then proceed to STEP 0/1. (This is the path loops/headless use: pass full args, skip the wizard.) - GUIDED PATH — something required is missing:
a. Print a short intro (2–4 lines): what open-geo does (drives queries through an AI engine,
measures the target domain's visibility/citation, emits a dashboard and/or PDF) and what it
produces.
b. Ask only for the missing parameters, using
AskUserQuestionfor the enumerable ones:engine— offer only engines that actually have a playbook:.venv/bin/python -c "import glob,os; print('\n'.join(sorted(os.path.basename(p)[:-3] for p in glob.glob('engines/*.md') if os.path.basename(p)!='README.md')))"(today, sorted:chatgpt_search,claude_search,deepseek,gemini,google,perplexity,yandex_neuro). If the user names an engine without a playbook, say it is not available yet (ROADMAP Feature 3) and stop.--n-worker— presets1 / 3 / 5 / 10(+ custom).--output—dashboard / pdf / both.--period—today / all.--lang—en / ru / zh / ar.questions.csv— offer found CSVs (+ "other path"), and a "Generate a set" option:.venv/bin/python -c "import glob; print('\n'.join(glob.glob('*.csv')+glob.glob('examples/*.csv')))"If the user picks Generate, leavequestions.csvunresolved here and let STEP A.5 harvest it (it writes the CSV and sets the path). If they pick a file / give a path, that is the input CSV and STEP A.5 is skipped.domainand--brand— free text. c. Echo the resolved parameters for a quick confirm, then proceed to STEP 0/1.
- If a required value is still unknown after the wizard (or it is abandoned), apply the guard from
INVOCATION: a short error in
--lang, no empty run.
STEP 0 — GEO-AUDIT GATE (runs FIRST: after the domain is known, before harvesting or a run)
Run this right after STEP A (so <domain> and <engine> are resolved) and before STEP A.5
and STEP 1 — there is no point harvesting questions or spending capture tokens on a domain an AI
engine cannot even read. This is the Domain GEO-Audit Gate (ROADMAP Feature 2); the contract is
pipeline/INTERFACES.md §7, the check semantics audit/CHECKS.md. It is deterministic Python
(non-LLM, no browser).
-
Run the audit — it fetches
robots.txt/ homepage /sitemap.xml/llms.txt//.well-known, grades each check by severity, and writes the result to theauditstable so the PDF/dashboard can show it later:.venv/bin/python -m audit.gate --domain <domain> --engine <engine>Parse stdout — a single
AuditResultJSON (INTERFACES §7.1):verdict(ready|ready_with_warnings|blocked),score(0–100),passed,blockers(check ids), andchecks[](eachid,severity,status,detail,remediation). A human summary is on STDERR. Add--no-cacheto force a fresh audit (by default a recent audit for the same domain is reused within its TTL). -
Decide, per
verdict:blocked(a category-A blocker failed — the site is unreachable, non-200, JS-only, orrobots.txtblocks the engine's search bot) and no--forcegiven: hard-stop before any run. Print (in--lang) a short remediation report — for each blocker itsdetail+ the concreteremediationfix, then the advisorywarn/failchecks below it — and say plainly: the domain is not visibility-ready, so a capture run would waste tokens; fix the blockers, or re-run with--forceto measure anyway. Do not create a run and do not harvest. Stop.blockedwith--force: warn loudly (list the blockers + their fixes), then continue — the operator chose to measure an unready domain.ready_with_warnings: briefly surface the advisory problems (thewarn/failchecks with theirdetail) and thescore, then continue to STEP A.5.ready: one line —GEO-audit: ready (score N/100)— continue.
-
The audit is now stored (keyed by the registrable domain), so STEP 6's PDF/dashboard read it back (
get_latest_audit) and render the full check table — you need not repeat the audit there.
Boundary. The gate is deterministic and only emits structured JSON; you (the orchestrator) turn that JSON into the human-language remediation the operator reads — the same division as the
lens_sentimentprose vs theaggregatemath. Only category-A failures block; everything else is advisory. Authority:pipeline/INTERFACES.md §7+audit/CHECKS.md.
STEP A.5 — SOURCE THE QUESTIONS (bring-your-own vs harvest a grounded set)
Run this after STEP A and STEP 0, before STEP 1. Goal: end up with a real <questions.csv> on disk.
It is the operator entry point for question harvesting (Feature 1) — the process authority is
harvest/METHODOLOGY.md, the contract is pipeline/INTERFACES.md §6. Harvesting is agentic
(recon sub-agents under the methodology), not an algorithm, and it is opt-in.
-
FAST PATH / bring-your-own — a real CSV is already resolved. If STEP A resolved
<questions.csv>to a path that exists and has data rows, this step is a no-op — use that file and go straight to STEP 0. (A user's own hand-madequery,lensCSV is a first-class input; loops/headless always take this path.) -
GENERATE PATH — the user chose "Generate a set" (or no CSV is resolved). Harvest one:
a. Collect harvest inputs (reuse what STEP A already has — brand, domain,
--lang). Ask only for what is missing, viaAskUserQuestion:- market / category (free text) and known competitors (free text seed; recon extends).
- how many questions — presets
20 / 36 / 60(+ custom). Default split is a deliberategeneral-tilt derived from the count (for ~36:16 / 10 / 10); offer to override the general/branded/comparative split. - language(s) of the queries — default to
--lang, but note the query language is the language people really ask in, independent of the deliverable--lang; a distinct-language slice goes to its own file (<name>_<code>.csv). Do not machine-translate for coverage.
b. Plan the segments from the inputs (METHODOLOGY §5) — the "different angles" on the product (demand primary/secondary, supply if two-sided, category/discovery, branded-reputation, comparative-rivals, regional slice). A two-sided product adds a supply segment; a single-sided one may not. Keep the plan to the segments the product actually has.
c. Phase A — fan-out grounded recon. Spawn one
harvest-workersub-agent per segment (Task tool), in parallel. Its full contract lives in.claude/agents/harvest-worker.md— do not restate it. Give each a self-contained brief:- the full text of
harvest/METHODOLOGY.md(authoritative process + iron reality rule); - the product context (brand, domain, market, competitors);
- its one segment + dominant lens(es), its worker index (for its unique temp file
/tmp/open_geo_harvest_<idx>.json), the target 15–25 candidates, and the language(s); - authority pointers:
pipeline/INTERFACES.md §6andharvest/schema.py :: QuestionCandidate.
A harvest worker grounds every candidate in an observable signal, returns a
QuestionCandidateJSON pool, and cleans up its own browser tabs — it never writesquestions.csv, never touchesdata/aeo.db, never balances or trims (that is your Phase B).d. Phase B — synthesize (you, the orchestrator). Merge all pools; dedup by meaning (not just text); drop anything without a real signal or violating its lens (METHODOLOGY §3/§4); balance to the target split with the
general-tilt, maximizing intent diversity within each lens; split any non-primary-language slice into its own list.e. Phase C — adversarial skeptic. Spawn 1–2
harvest-skepticsub-agents (Task tool; contract in.claude/agents/harvest-skeptic.md) with the thesis + the final{query, lens}list. They return KEEP/CUT verdicts. Apply the cuts, backfill each with the next-strongest distinct Phase-A candidate, until every shipped line survives.f. Commit to CSV via the build CLI (INTERFACES §6.2). Write your final candidate array (each a
QuestionCandidatewithquery,lens,segment,signal,source_url) to a UTF-8 temp file, then:.venv/bin/python -m harvest.build --out <name>_questions.csv --brand "<name>" \ < /tmp/open_geo_harvest_final.jsonRead stdout
{"out","written","by_lens","dropped_dups","errors"}.errorsmust be empty — fix any flagged row (usually a mislabeled lens: general-with-brand or branded-without-brand) and re-run untilerrors: []. For a separate-language slice, callharvest.buildagain with its own--out <name>_<code>.csv.g. Write
<name>_rationale.md— per segment: who we catch, on which observable signals (from the workers'signal/source_url), why this lens; plus the competitors that surfaced. This is the provenance the CSV omits (seegonka_questions_rationale.mdfor the shape). Keep it in the language of the audit's stakeholders.h. REVIEW GATE (human-in-the-loop). Show a short summary — total,
by_lens, and the full query list — and ask (AskUserQuestion): Apply (use this CSV for the run), Edit (you open<name>_questions.csv, the user tweaks rows / you adjust per their notes, then re-runharvest.buildto re-validate —errors: []before proceeding), or Discard (fall back to bring-your-own: re-offer file selection / a path). On Apply/Edit, set<questions.csv>to the written path and proceed to STEP 0. This gate is deliberate — never skip straight to capture on a generated set without the operator seeing it (moat #3, trust).
Boundary. Harvesting only produces the CSV; nothing downstream changes. The capture contract (§1), the run, ingest/aggregate are untouched — STEP 1 onward treats a harvested CSV exactly like a hand-made one.
STEP 1 — CREATE OR RESUME THE RUN
First check for an unfinished run to resume — a previous run of this brand+engine
left status='running' by a crash (INTERFACES §2.1). Look before creating anything:
.venv/bin/python -c "
import json
from pipeline.db import get_conn, init_db, get_or_create_brand, find_unfinished_run
conn = get_conn('data/aeo.db'); init_db(conn)
bid = get_or_create_brand(conn, '<name>', '<domain>')
print(json.dumps({'run_id': find_unfinished_run(conn, bid, '<engine>')}))
"
-
run_idnon-null → an unfinished run exists. Offer to resume it (reuse thatrun_id; STEP 2 captures only the rows it is still missing) vs. start fresh. On the fast path (loops/headless, all args supplied) resume automatically — unattended recovery is the whole point. Keep the chosen<run_id>and skip the--new-runcall. -
run_idnull (or the user chose fresh) → create a fresh run and capture itsrun_idfrom JSON stdout:.venv/bin/python -m pipeline.ingest \ --brand "<name>" --domain <domain> --engine <engine> --new-runstdout:
{"run_id": <int>}(per INTERFACES §3.1). Parse it and keep<run_id>for every later step. Human/log noise goes to STDERR — only the JSON object is on STDOUT. -
If creation errors or stdout is not parseable JSON with a
run_id, stop and report it (in--lang). Nothing downstream can proceed withoutrun_id.
Repeats (--repeat R, R > 1)
The whole point is R independent captures of the same CSV, grouped so readers can see mean + spread instead of trusting one noisy run (INTERFACES §2.1). Flow:
- Mint one group tag for the whole invocation —
grp_<YYYYMMDD-HHMM>_<engine>is fine. - For each repeat
i = 1..Rsequentially: create its run withpython -m pipeline.ingest --brand … --domain … --engine … --new-run --group-id <tag>, then execute STEPS 2–5b for that run exactly as for a single run (full CSV each time — do NOT dedupe across repeats; a repeat IS the same question asked again). - Resume semantics are per repeat: a crashed repeat is found by
find_unfinished_runand finished into its own run; already-donerepeats of the group are never re-captured. - Deliverables (STEP 6) run once, after the last repeat. The dashboard detects the
group automatically (latest run carries the
group_id) and shows the mean + min–max spread; nothing extra to pass.
STEP 2 — PREPARE THE WORK & THE PLAYBOOK
- Read all data rows from
<questions.csv>(headerquery,lens). Validate eachlensis one ofgeneral|branded|comparative; drop/flag malformed rows (note them for the summary). Letrowsbe the validated list, preserving file order. - Locate the capture playbook
engines/<engine>.md. This file is the per-engine capture instructions the subagents follow (e.g.engines/google.mdfor Google AI Overview — referenced in the house rules as "the capture playbook").- If
engines/<engine>.mdis missing, do not invent a procedure. Stop and tell the user (in--lang) that the playbook for this engine is not present yet and must be added before a run — the capture contract still applies, but the engine-specific "how to drive it" lives in that file. The pattern for authoring a new engine playbook is inengines/README.md(multi-engine is ROADMAP Feature 3). (engines/google.md,engines/chatgpt_search.md,engines/claude_search.md,engines/yandex_neuro.md,engines/gemini.md,engines/deepseek.mdandengines/perplexity.mdship today; passing any other engine id needs its playbook written first.)
- If
- If resuming an existing run (STEP 1 returned one), drop rows already captured —
read the captured keys and keep only the missing
(query, lens):
Subtract those from.venv/bin/python -c " import json from pipeline.db import get_conn, get_captured_keys conn = get_conn('data/aeo.db') print(json.dumps(sorted(list(t) for t in get_captured_keys(conn, <run_id>)))) "rows. If nothing remains, skip capture entirely and jump to STEP 4.2 (finalize) → STEP 5. (Ingest is idempotent, so re-capturing a stored row is harmless — skipping just saves a browser hit.) - Split the rows to capture into
min(N, len(rows))contiguous chunks of roughly equal size, whereN = --n-worker. Each chunk keeps its rows' original(query, lens)pairs.
STEP 3 — FAN-OUT CAPTURE (one capture-worker subagent per chunk)
Spawn N = --n-worker subagents of type capture-worker (Task tool), one per chunk, and
run them in parallel — each drives its chunk concurrently in its own browser tab/context. A
capture worker's only job is to capture and RETURN data; it never ingests, creates runs, starts
servers, or writes the DB. Its full step-by-step contract lives in
.claude/agents/capture-worker.md — do not restate it here. Give each capture-worker a
self-contained brief containing:
- The full text of
engines/<engine>.md(the capture playbook). - Its chunk of
(query, lens)rows, and its chunk index (1..N) — used to name its validation temp file uniquely (/tmp/open_geo_cap_<idx>.json), since parallel workers share/tmp. - The target
<domain>, the--brandname, and the<engine>id. - A pointer to
pipeline/INTERFACES.md§1 as the authoritative capture contract, and topipeline/schema.py :: QueryCapture/normalize_domain.
Do not give the worker the
run_id, the DB path, or any ingest command — a capture worker never writes to the DB and never starts a server. The orchestrator owns all DB writes and the deliverables (steps 4 and 6).
The worker's full step-by-step contract — output fields, the no-DB and no-source-visit rules, per-worker temp-file self-validation, what to return — lives in
.claude/agents/capture-worker.md. It is engine-agnostic; the injectedengines/<engine>.mdplaybook is authoritative for how to drive the specific engine, andINTERFACES §1for theQueryCaptureshape.
Parallelism — N workers run concurrently
The skill spawns N = --n-worker capture sub-agents and runs them in parallel:
step 2 splits the query rows into N chunks and each sub-agent drives its chunk
concurrently, each in its own browser tab/context. --n-worker IS the run's real
concurrency — raise it to go wider.
- If Google shows a reCAPTCHA / "unusual traffic" challenge, the affected worker stops and surfaces it to the human (per the playbook) instead of solving or hammering it; the other workers keep going.
STEP 4 — INGEST & FINALIZE (orchestrator owns all DB writes)
The database is written only by you (the orchestrator), as each worker returns its chunk — incrementally, so a crash mid-run never loses already-captured work (INTERFACES §2.1). The workers never touched the DB.
-
Ingest each worker's chunk as it returns — incrementally, not one batch at the end (durability: a crash can't lose chunks already returned). For each returned
QueryCapturearray, write it to a temp file (UTF-8/Cyrillic-safe) and ingest into the run:.venv/bin/python -m pipeline.ingest --run-id <run_id> < /tmp/open_geo_chunk_<idx>.jsonRead stdout
{"run_id", "ok": [...], "skipped": [...], "errors": [...]}(INTERFACES §3.2). Ingest is idempotent on(run_id, query, lens), soskipped(already-stored rows — normal on a resume/retry) is safe, never a duplicate. Fix any row inerrors— correct the field from the returned data, or re-dispatch that one(query, lens)to a worker — and re-send only the fixed objects to the same--run-id. Repeat untilerrorsis empty (bounded retries; then report residual failures). -
Finalize counts + status. There is no "finalize" CLI; use the documented helper
pipeline.db.update_run_counts(INTERFACES §2) inline:.venv/bin/python -c " from pipeline.db import get_conn, update_run_counts conn = get_conn('data/aeo.db') update_run_counts(conn, run_id=<run_id>, n_queries=<total rows attempted>, n_ok=<rows accepted by ingest>, n_failed=<rows never accepted>, status='done') "n_queries= total(query, lens)rows attempted (from the full CSV, including a resume's already-done rows);n_ok= rows captured (ingest keeps this live, =COUNT(results));n_failed=n_queries − n_ok. Setstatus='done'on success, or'failed'if the run collapsed (playbook missing, engine unreachable for everything). Finalizingstatusis the orchestrator's job —ingestnever sets it (INTERFACES §2.1/§3.2); only runs withstatus='done'feed previous-run deltas and the--period allrollup (INTERFACES §4.1). Never leave a run stuck instatus='running'.
STEP 5 — AGGREGATE METRICS
.venv/bin/python -m pipeline.aggregate --run-id <run_id>
- Computes metrics per lens plus one
lens="all"aggregate row, writes them to themetricstable, and prints a JSON summary on stdout (INTERFACES §3.3). Capture this stdout — step 7's summary reads itsmetrics(lens="all"row) directly. - In the same pass it also builds the top-domains leaderboard into
domain_stats(INTERFACES §2/§4.2): for every domain insources/citations(not just the target) — appearances + average source/citation position, per lens +all. This is deterministic math (no extra step for you); the summary'stop_domainsechoes theall-scope top 10. It powers the dashboard's "Top domains in answer space" panel and the report's top-domains section, and recomputes idempotently on re-aggregate.
STEP 5b — SYNTHESIZE PER-LENS SENTIMENT (orchestrator writes the qualitative roll-up)
pipeline.aggregate (STEP 5) stays deterministic math — it does not touch sentiment.
You (the orchestrator, already an LLM) write the qualitative per-lens roll-up here, then
persist it via pipeline.lens_sentiment (INTERFACES §3.4) into the lens_sentiment table
(INTERFACES §2). This is separate from metrics on purpose, so a re-aggregate never
clobbers the synthesized prose.
- Gather the per-query
sentiments grouped by lens for this run. You already have them from the STEP 4 captures; if not handy, read them back fromresultsinline:.venv/bin/python -c " import json from pipeline.db import get_conn conn = get_conn('data/aeo.db') rows = conn.execute( 'SELECT lens, sentiment FROM results WHERE run_id=? ORDER BY lens', (<run_id>,)).fetchall() print(json.dumps([dict(r) for r in rows], ensure_ascii=False)) " - Write ONE short, neutral sentence per lens that appears in the run (
general,branded,comparative), plus anallsynthesis across them. Summarize ONLY what the per-querysentimentstrings of that lens actually say — never invent ranks, competitors, numbers, or praise the captures don't contain; keep it ~1 sentence.- Language: follow the DATA, not
--lang. The summary is a roll-up of captured sentiment text, so write it in the language thosesentimentstrings are in (e.g. Russian captures → Russian summary), regardless of the deliverable--lang. - If a lens had the brand in no query (every
sentimentnull), set that lens's summary tonull(the UI then shows a "not mentioned" fallback). Likewiseallisnullonly if the brand appeared in no query at all.
- Language: follow the DATA, not
- Persist by piping a JSON object
{lens: summary}topipeline.lens_sentiment. Write the JSON to a temp file first for UTF-8/Cyrillic safety, exactly like the STEP 4 batch ingest does:
Read stdout# /tmp/open_geo_sentiment.json holds e.g. # {"all": "...", "general": "...", "branded": "...", "comparative": null} .venv/bin/python -m pipeline.lens_sentiment --run-id <run_id> < /tmp/open_geo_sentiment.json{"run_id": <run_id>, "written": [...]}(INTERFACES §3.4) to confirm which lenses were upserted. Only the lenses you include are written; an unknownrun_idexits 1.
The dashboard then renders these as a "Sentiment by lens" card strip above the results table, and the PDF report shows them as the lead line of its sentiment section.
STEP 6 — EMIT DELIVERABLE(S) per --output
Ordering — the skill does this, not a worker, and only after steps 3–5. Deliverables are produced by the orchestrator once every capture is collected & ingested, the run is finalized, and metrics are aggregated. A capture worker never starts a server or generates a report. Start long-running servers in the background on a free port.
The report and dashboard components are built and their entry points are verified working (commands below are the real ones). They are intentionally not in INTERFACES — their contracts live in their own dirs (
report/generate.pyanddashboard/README.md). If you need detail beyond what's shown, read those.
--output dashboard (default) — or as part of both
Start the dashboard (FastAPI backend + Vite/React frontend) and print the local URL.
The frontend selects brand/engine/period through its own UI controls (read from the API),
so you do not scope brand/engine/period via the query string — only the UI language:
hand the operator http://localhost:5173/?lang=<lang>, which seeds the dashboard's initial
language from the run's --lang (the in-browser switcher still overrides it, and the choice
persists in localStorage).
# Run BOTH in the background (they are long-running dev servers).
# Background shells do NOT inherit the repo-root CWD, so use ABSOLUTE paths anchored at
# <REPO> = the repository root (your working directory). Do NOT use a relative
# `.venv/bin/python` or `cd dashboard/web` here — backgrounded, they fail (exit 127 /
# wrong CWD). `--app-dir <REPO>` lets uvicorn import `dashboard.api` regardless of CWD.
#
# 1) API (read-only over data/aeo.db). PICK A FREE PORT — 8000 is often taken:
OPEN_GEO_DB=<REPO>/data/aeo.db <REPO>/.venv/bin/python -m uvicorn dashboard.api:app \
--host 127.0.0.1 --port <PORT> --app-dir <REPO>
# 2) Web (Vite dev server), pointed at the API's port. Use `npm --prefix` instead of `cd`
# (run `npm --prefix <REPO>/dashboard/web install` once if node_modules is missing):
VITE_API_BASE=http://127.0.0.1:<PORT> npm --prefix <REPO>/dashboard/web run dev
- Port caveat: local port 8000 is often already occupied by another service on
this machine. Pick a free port for the API (e.g.
8077) and point the frontend at it viaVITE_API_BASE(CORS is open, so a cross-origin base works without the dev proxy):VITE_API_BASE=http://127.0.0.1:<PORT> npm --prefix <REPO>/dashboard/web run dev - Verify before handing off (a backgrounded server can exit non-zero or the port can
clash): probe both before printing the URL —
so you surface a working URL, not a hopeful one.curl -s http://127.0.0.1:<PORT>/api/health curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173/ - After both are up, print the Vite dev URL the operator should open —
http://localhost:5173/?lang=<lang>(the frontend's own controls drive brand/engine/period;?lang=<lang>seeds the UI language from the run's--lang, and the switcher still overrides). Ifdashboard/cannot be started, say so (in--lang) and skip gracefully (still finish steps 5 and 7).
--output pdf — or as part of both
.venv/bin/python -m report.generate \
--brand "<name>" --domain <domain> --engine <engine> \
--period <period> --lang <lang> \
--out reports/<brand>_<date>.pdf [--db data/aeo.db]
- This is the real, built CLI. It prints progress/status to stderr; the output path
(
--out) is what to surface to the operator. Pass--lang <lang>(the run's--lang, defaulten) so the report renders in that language. - Use
<date>= today (YYYY-MM-DD). Createreports/if missing. Print the resulting file path. If the command fails, say so (in--lang) and skip gracefully. - Combined multi-engine document (Feature 7): when the operator asks for one document
across every engine the brand has runs on, swap
--engine <engine>for--engines all(or an explicit comma list) — one PDF: engines side-by-side table, then a chapter per engine. Numbers are never blended across engines.
--output both
Do both of the above: start the dashboard (print URL) and generate the PDF (print path).
STEP 7 — SUMMARY (printed to the user, in --lang)
Read the lens="all" row from the pipeline.aggregate JSON captured in step 5 and
print a short summary of headline metrics for this run, in the --lang language (default
English). Cover:
- Answer coverage (
overview_coverage) — share of queries where a grounded, source-backed answer rendered at all (an AI Overview ongoogle; a web-search-backed answer on the chat engines). - Visibility in sources (
visibility_in_sources) — share of overview queries where the target domain made it intosources(n_in_sources / n_overviews). - Visibility in citations (
visibility_in_citations) — share of overview queries where the domain is cited in the answer (n_cited / n_overviews). - Average source position (
avg_source_position) — average best (min) rank of the domain among sources (lower = better;—if the domain never appears in sources). - Average citation position (
avg_citation_position) — average best (min) rank of the domain among citations (lower = better;—if the domain is never cited). - Relative citation (
relative_citation) — the source→citation conversion: of the queries where the domain was insources, the share where it was actually cited (n_cited / n_in_sources; higher = better,∈ [0, 1];—if the domain never appears in sources). This is the last step of the visibility funnel (n_cited ≤ n_in_sources ≤ n_overviews ≤ n_queries). - Brand mention rate (
brand_mention_rate) — of the grounded answers, the share whose prose mentions the brand name, linked or not (n_brand_mentions / n_overviews; higher = better). An adjacent axis, not a funnel stage — an unlinked mention is invisible to the link funnel, so do not read it as nested in sources/citations (INTERFACES §4).
Format as percentages where natural, and note guard cases (null → "no data" / "—", not
0). End by pointing to the produced deliverable(s): the dashboard URL and/or the PDF path.
If a previous completed run exists, you may mention the direction of change
(deltas are computed at read-time per INTERFACES §4.1) — otherwise omit.
Example shape (English; fill with real numbers; one lens="all" row drives it):
Run for brand "Example" (engine google), queries: 30.
• Answer coverage: 73% (22 of 30 queries).
• Visibility in sources: 41% of grounded answers.
• Visibility in citations: 32% of grounded answers.
• Average source position: 2.4 (lower is better).
• Average citation position: 1.7 (lower is better).
• Source→citation conversion (relative citation): 78% (higher is better).
• Brand mention rate: 55% of grounded answers name the brand.
Report: reports/example_2026-06-19.pdf · Dashboard: http://localhost:5173/?lang=en
COMPONENT DEPENDENCY MAP (where this skill leans on others)
| step | calls | status |
|---|---|---|
| A.5 | harvest-worker + harvest-skeptic subagents under harvest/METHODOLOGY.md; python -m harvest.build | Built — Feature 1 (question harvesting); opt-in, contract in INTERFACES §6. Skipped when a CSV is supplied. |
| 0 | python -m audit.gate --domain <d> --engine <e> (deterministic, non-LLM) | Built — Feature 2 (GEO-audit gate); runs before A.5, hard-stops a blocked domain (overridable with --force). Contract in INTERFACES §7, checks in audit/CHECKS.md. |
| 1 | python -m pipeline.ingest --new-run | Contract in INTERFACES §3.1 |
| 3 | capture-worker subagent (.claude/agents/capture-worker.md) driving engines/<engine>.md (workers capture & return JSON — no DB writes) | Capture contract §1; playbook file may be absent early |
| 4 | python -m pipeline.ingest --run-id (orchestrator) + pipeline.db.update_run_counts | Incremental per-chunk ingest §3.2 (idempotent) + finalize helper §2 (call inline) |
| 5 | python -m pipeline.aggregate --run-id | Contract in INTERFACES §3.3 |
| 5b | python -m pipeline.lens_sentiment --run-id (orchestrator-written qualitative per-lens roll-up) | Contract in INTERFACES §3.4 |
| 6 | python -m report.generate … --lang <lang>; dashboard API (uvicorn dashboard.api:app) + web (npm run dev) | Built — entry points confirmed/working; contracts live in report/ & dashboard/ (intentionally not in INTERFACES) |
Keep the run operator-friendly: parse JSON from stdout (never scrape logs), fail loudly (in
--lang) on missing prerequisites, and never leave a run stuck in status='running'.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.