agentsclimarketplace

Open geo

Skill Pupok462/open-geo/.claude/skills/open-geo

Measure brand visibility in AI answers by reading the rendered answer a logged-in user actually sees (agent in a real browser). Google AI Overview, ChatGPT, Claude, Gemini, Yandex Alice. Claude Code skill, MIT.

Install
npx -y skills add Pupok462/open-geo --skill open-geo

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 17 stars17 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.

What its author says it does

Copied from the file, not written here

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.).

SKILL.md

39.8 KB, 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) so pipeline.* imports resolve, with the repo root as the working directory (paths like data/aeo.db are 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

argmeaning
<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 googleengines/google.md). This is the multi-engine extension pointgoogle (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

flagrequireddefaultmeaning
--brand "<name>"yesHuman 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>yesNumber 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|bothnodashboardWhich deliverable(s) to produce in step 6.
--period today|allnoallReporting 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|arnoenUI 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.
--forcenooffOverride 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 Rno1Repeat-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:

  1. pipeline/INTERFACES.md is 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-geo command 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.sh
    

    and re-run /open-geo from that directory (the user can also ask you to run these steps for them outside this skill). Stop.

  2. Repo present but .venv/ is missing → setup has not been run. Tell the user to run scripts/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).

  1. 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").
  2. 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.)
  3. 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 AskUserQuestion for 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 — presets 1 / 3 / 5 / 10 (+ custom).
    • --outputdashboard / pdf / both. --periodtoday / all. --langen / 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, leave questions.csv unresolved 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.
    • domain and --brand — free text. c. Echo the resolved parameters for a quick confirm, then proceed to STEP 0/1.
  4. 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).

  1. Run the audit — it fetches robots.txt / homepage / sitemap.xml / llms.txt / /.well-known, grades each check by severity, and writes the result to the audits table so the PDF/dashboard can show it later:

    .venv/bin/python -m audit.gate --domain <domain> --engine <engine>
    

    Parse stdout — a single AuditResult JSON (INTERFACES §7.1): verdict (ready | ready_with_warnings | blocked), score (0–100), passed, blockers (check ids), and checks[] (each id, severity, status, detail, remediation). A human summary is on STDERR. Add --no-cache to force a fresh audit (by default a recent audit for the same domain is reused within its TTL).

  2. Decide, per verdict:

    • blocked (a category-A blocker failed — the site is unreachable, non-200, JS-only, or robots.txt blocks the engine's search bot) and no --force given: hard-stop before any run. Print (in --lang) a short remediation report — for each blocker its detail + the concrete remediation fix, then the advisory warn/fail checks 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 --force to measure anyway. Do not create a run and do not harvest. Stop.
    • blocked with --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 (the warn/fail checks with their detail) and the score, then continue to STEP A.5.
    • ready: one line — GEO-audit: ready (score N/100) — continue.
  3. 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_sentiment prose vs the aggregate math. 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.

  1. 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-made query,lens CSV is a first-class input; loops/headless always take this path.)

  2. 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, via AskUserQuestion:

    • market / category (free text) and known competitors (free text seed; recon extends).
    • how many questions — presets 20 / 36 / 60 (+ custom). Default split is a deliberate general-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-worker sub-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 §6 and harvest/schema.py :: QuestionCandidate.

    A harvest worker grounds every candidate in an observable signal, returns a QuestionCandidate JSON pool, and cleans up its own browser tabs — it never writes questions.csv, never touches data/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-skeptic sub-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 QuestionCandidate with query,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.json
    

    Read stdout {"out","written","by_lens","dropped_dups","errors"}. errors must be empty — fix any flagged row (usually a mislabeled lens: general-with-brand or branded-without-brand) and re-run until errors: []. For a separate-language slice, call harvest.build again 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 (see gonka_questions_rationale.md for 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-run harvest.build to 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_id non-null → an unfinished run exists. Offer to resume it (reuse that run_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-run call.

  • run_id null (or the user chose fresh) → create a fresh run and capture its run_id from JSON stdout:

    .venv/bin/python -m pipeline.ingest \
      --brand "<name>" --domain <domain> --engine <engine> --new-run
    

    stdout: {"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 without run_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:

  1. Mint one group tag for the whole invocation — grp_<YYYYMMDD-HHMM>_<engine> is fine.
  2. For each repeat i = 1..R sequentially: create its run with python -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).
  3. Resume semantics are per repeat: a crashed repeat is found by find_unfinished_run and finished into its own run; already-done repeats of the group are never re-captured.
  4. 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

  1. Read all data rows from <questions.csv> (header query,lens). Validate each lens is one of general|branded|comparative; drop/flag malformed rows (note them for the summary). Let rows be the validated list, preserving file order.
  2. Locate the capture playbook engines/<engine>.md. This file is the per-engine capture instructions the subagents follow (e.g. engines/google.md for Google AI Overview — referenced in the house rules as "the capture playbook").
    • If engines/<engine>.md is 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 in engines/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.md and engines/perplexity.md ship today; passing any other engine id needs its playbook written first.)
  3. If resuming an existing run (STEP 1 returned one), drop rows already captured — read the captured keys and keep only the missing (query, lens):
    .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>))))
    "
    
    Subtract those from 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.)
  4. Split the rows to capture into min(N, len(rows)) contiguous chunks of roughly equal size, where N = --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 --brand name, and the <engine> id.
  • A pointer to pipeline/INTERFACES.md §1 as the authoritative capture contract, and to pipeline/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 injected engines/<engine>.md playbook is authoritative for how to drive the specific engine, and INTERFACES §1 for the QueryCapture shape.

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.

  1. 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 QueryCapture array, 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>.json
    

    Read stdout {"run_id", "ok": [...], "skipped": [...], "errors": [...]} (INTERFACES §3.2). Ingest is idempotent on (run_id, query, lens), so skipped (already-stored rows — normal on a resume/retry) is safe, never a duplicate. Fix any row in errors — 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 until errors is empty (bounded retries; then report residual failures).

  2. 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. Set status='done' on success, or 'failed' if the run collapsed (playbook missing, engine unreachable for everything). Finalizing status is the orchestrator's job — ingest never sets it (INTERFACES §2.1/§3.2); only runs with status='done' feed previous-run deltas and the --period all rollup (INTERFACES §4.1). Never leave a run stuck in status='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 the metrics table, and prints a JSON summary on stdout (INTERFACES §3.3). Capture this stdout — step 7's summary reads its metrics (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 in sources/citations (not just the target) — appearances + average source/citation position, per lens + all. This is deterministic math (no extra step for you); the summary's top_domains echoes the all-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.

  1. 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 from results inline:
    .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))
    "
    
  2. Write ONE short, neutral sentence per lens that appears in the run (general, branded, comparative), plus an all synthesis across them. Summarize ONLY what the per-query sentiment strings 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 those sentiment strings are in (e.g. Russian captures → Russian summary), regardless of the deliverable --lang.
    • If a lens had the brand in no query (every sentiment null), set that lens's summary to null (the UI then shows a "not mentioned" fallback). Likewise all is null only if the brand appeared in no query at all.
  3. Persist by piping a JSON object {lens: summary} to pipeline.lens_sentiment. Write the JSON to a temp file first for UTF-8/Cyrillic safety, exactly like the STEP 4 batch ingest does:
    # /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
    
    Read stdout {"run_id": <run_id>, "written": [...]} (INTERFACES §3.4) to confirm which lenses were upserted. Only the lenses you include are written; an unknown run_id exits 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.py and dashboard/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 via VITE_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 —
    curl -s http://127.0.0.1:<PORT>/api/health
    curl -s -o /dev/null -w '%{http_code}\n' http://localhost:5173/
    
    so you surface a working URL, not a hopeful one.
  • 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). If dashboard/ 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, default en) so the report renders in that language.
  • Use <date> = today (YYYY-MM-DD). Create reports/ 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 on google; 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 into sources (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 in sources, 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)

stepcallsstatus
A.5harvest-worker + harvest-skeptic subagents under harvest/METHODOLOGY.md; python -m harvest.buildBuilt — Feature 1 (question harvesting); opt-in, contract in INTERFACES §6. Skipped when a CSV is supplied.
0python -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.
1python -m pipeline.ingest --new-runContract in INTERFACES §3.1
3capture-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
4python -m pipeline.ingest --run-id (orchestrator) + pipeline.db.update_run_countsIncremental per-chunk ingest §3.2 (idempotent) + finalize helper §2 (call inline)
5python -m pipeline.aggregate --run-idContract in INTERFACES §3.3
5bpython -m pipeline.lens_sentiment --run-id (orchestrator-written qualitative per-lens roll-up)Contract in INTERFACES §3.4
6python -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'.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.