From a1075bb794558a3c3f09f4214723c1419cd2ceb0 Mon Sep 17 00:00:00 2001 From: xuetaok Date: Wed, 20 May 2026 07:07:32 +0000 Subject: [PATCH 1/2] eval: bundle Customer Context Builder (CCB) skills for routing eval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three CCB Agent Skills (customer-context-builder, gcp-data-qa, wiki-viewer) into skills/ as ccb-* with skill-ccb-* frontmatter names so Gemini CLI discovers them. Descriptions kept verbatim — they are the routing signal under test. No GEMINI.md delineation and no slash commands, to measure raw routing interference against the existing autoctx CUJs (go/db-nl2sql-crema-dev-guide#crema-cujs-evaluation-automation, CCB<>Crema alignment doc Challenge 1). --- .../DESIGN_NOTES.md | 257 +++ skills/ccb-customer-context-builder/SKILL.md | 528 ++++++ .../agents/critic_agent.md | 229 +++ .../agents/indexer_agent.md | 136 ++ .../agents/personal_context_agent.md | 186 ++ .../agents/warehouse_agent.md | 365 ++++ .../examples/customer_manifest.example.yaml | 48 + .../scripts/acknowledge_drift.py | 100 ++ .../scripts/build_manifest.py | 176 ++ .../scripts/check_prereqs.sh | 46 + .../scripts/claims_sidecar.py | 418 +++++ .../scripts/dep_graph.py | 132 ++ .../scripts/discover_drive_docs.py | 280 +++ .../scripts/drive_search.py | 128 ++ .../scripts/gap_check.py | 746 ++++++++ .../scripts/gcs_upload.py | 88 + .../scripts/gdocs_extract.py | 109 ++ .../scripts/gsheets_extract.py | 113 ++ .../scripts/live_fetchers.py | 307 ++++ .../scripts/rebuild_plan.py | 200 +++ .../scripts/requirements.txt | 12 + .../scripts/revalidate_drift.py | 450 +++++ .../scripts/source_diff.py | 831 +++++++++ .../scripts/test_live_drift.py | 299 ++++ .../templates/index_format.md | 173 ++ .../templates/source_format.md | 133 ++ skills/ccb-gcp-data-qa/README.md | 99 ++ skills/ccb-gcp-data-qa/SKILL.md | 203 +++ .../examples/sample_session.md | 69 + .../examples/sample_session_v04.charts.html | 25 + .../examples/sample_session_v04.md | 135 ++ .../ccb-gcp-data-qa/scripts/check_prereqs.sh | 59 + skills/ccb-gcp-data-qa/scripts/data_qa.py | 564 ++++++ .../ccb-gcp-data-qa/scripts/requirements.txt | 7 + skills/ccb-gcp-data-qa/scripts/wiki_parser.py | 393 +++++ skills/ccb-wiki-viewer/README.md | 116 ++ skills/ccb-wiki-viewer/SKILL.md | 215 +++ .../scripts/build_html_site.py | 1548 +++++++++++++++++ .../ccb-wiki-viewer/scripts/promote_server.py | 987 +++++++++++ .../scripts/scan_candidates.py | 473 +++++ .../scripts/score_candidates.py | 156 ++ skills/ccb-wiki-viewer/scripts/serve_wiki.sh | 305 ++++ 42 files changed, 11844 insertions(+) create mode 100644 skills/ccb-customer-context-builder/DESIGN_NOTES.md create mode 100644 skills/ccb-customer-context-builder/SKILL.md create mode 100644 skills/ccb-customer-context-builder/agents/critic_agent.md create mode 100644 skills/ccb-customer-context-builder/agents/indexer_agent.md create mode 100644 skills/ccb-customer-context-builder/agents/personal_context_agent.md create mode 100644 skills/ccb-customer-context-builder/agents/warehouse_agent.md create mode 100644 skills/ccb-customer-context-builder/examples/customer_manifest.example.yaml create mode 100644 skills/ccb-customer-context-builder/scripts/acknowledge_drift.py create mode 100644 skills/ccb-customer-context-builder/scripts/build_manifest.py create mode 100755 skills/ccb-customer-context-builder/scripts/check_prereqs.sh create mode 100644 skills/ccb-customer-context-builder/scripts/claims_sidecar.py create mode 100644 skills/ccb-customer-context-builder/scripts/dep_graph.py create mode 100755 skills/ccb-customer-context-builder/scripts/discover_drive_docs.py create mode 100755 skills/ccb-customer-context-builder/scripts/drive_search.py create mode 100644 skills/ccb-customer-context-builder/scripts/gap_check.py create mode 100755 skills/ccb-customer-context-builder/scripts/gcs_upload.py create mode 100755 skills/ccb-customer-context-builder/scripts/gdocs_extract.py create mode 100755 skills/ccb-customer-context-builder/scripts/gsheets_extract.py create mode 100644 skills/ccb-customer-context-builder/scripts/live_fetchers.py create mode 100644 skills/ccb-customer-context-builder/scripts/rebuild_plan.py create mode 100644 skills/ccb-customer-context-builder/scripts/requirements.txt create mode 100644 skills/ccb-customer-context-builder/scripts/revalidate_drift.py create mode 100644 skills/ccb-customer-context-builder/scripts/source_diff.py create mode 100644 skills/ccb-customer-context-builder/scripts/test_live_drift.py create mode 100644 skills/ccb-customer-context-builder/templates/index_format.md create mode 100644 skills/ccb-customer-context-builder/templates/source_format.md create mode 100644 skills/ccb-gcp-data-qa/README.md create mode 100644 skills/ccb-gcp-data-qa/SKILL.md create mode 100644 skills/ccb-gcp-data-qa/examples/sample_session.md create mode 100644 skills/ccb-gcp-data-qa/examples/sample_session_v04.charts.html create mode 100644 skills/ccb-gcp-data-qa/examples/sample_session_v04.md create mode 100755 skills/ccb-gcp-data-qa/scripts/check_prereqs.sh create mode 100755 skills/ccb-gcp-data-qa/scripts/data_qa.py create mode 100644 skills/ccb-gcp-data-qa/scripts/requirements.txt create mode 100755 skills/ccb-gcp-data-qa/scripts/wiki_parser.py create mode 100644 skills/ccb-wiki-viewer/README.md create mode 100644 skills/ccb-wiki-viewer/SKILL.md create mode 100755 skills/ccb-wiki-viewer/scripts/build_html_site.py create mode 100644 skills/ccb-wiki-viewer/scripts/promote_server.py create mode 100644 skills/ccb-wiki-viewer/scripts/scan_candidates.py create mode 100644 skills/ccb-wiki-viewer/scripts/score_candidates.py create mode 100755 skills/ccb-wiki-viewer/scripts/serve_wiki.sh diff --git a/skills/ccb-customer-context-builder/DESIGN_NOTES.md b/skills/ccb-customer-context-builder/DESIGN_NOTES.md new file mode 100644 index 00000000..bb640a42 --- /dev/null +++ b/skills/ccb-customer-context-builder/DESIGN_NOTES.md @@ -0,0 +1,257 @@ +# v0.2 design notes (historical, frozen at v0.2) + +> **Status: historical.** This file is the redesign rationale from +> v0.2 and is **frozen at that snapshot**. Anything implemented after +> v0.2 (gap detection, drift, the Context Center, two-stage +> re-validation, etc.) is documented in the top-level +> [`README.md`](../../README.md), [`docs/ALGORITHMS.md`](../../docs/ALGORITHMS.md), +> and [`SKILL.md`](SKILL.md) — not here. + + +The user requested a redesign of the skill's output structure to a +recursive, agentic-retrieval-friendly layout (every directory has an +`index.md`; sources are first-class with verbatim "gists"; personal +context is split out from warehouse context). This file records the +interpretations I made to fill in the spec's ambiguities, so the +choices are reviewable rather than buried in code. + +If any of these interpretations is wrong, the fix is local — change the +relevant agent prompt or template, re-run the smoke test, the structure +regenerates. + +## The user's spec (for reference) + +``` +context/ + index.md -- central directory; navigation always starts here + data_warehouse.md -- detailed explanation of the data warehouse itself + retrieval_methods.md -- how to retrieve more context + sources/ + index.md + {source_name}.md + {table_name}/ + index.md -- entry point for understanding the table + fields.md -- detailed table field info + lineage.md -- provenance + sources/ + index.md + {source_name}.md +``` + +Plus: `personal_context/` for docs/sheets, "directly under the context/ +root dir." + +Index file format: `# Summary` (1-5 paragraphs), `# Index` (peer files + +descriptions), `# Child Indexes` (links + descriptions). + +Source file format: `# Retrieved from` (source / lineage / abs path), +`# Gists` (literal snippets with titles). + +## Interpretation 1 — Multi-customer layout + +Customers nest under the top-level `context/` directory, fitting the +recursive pattern naturally. Top-level `index.md` is a directory of +customers; each customer's subtree is the structure described above. + +``` +/ +└── context/ + ├── index.md # lists customers + └── {customer-name}/ + ├── index.md + ├── data_warehouse.md + ├── retrieval_methods.md + ├── sources/ + ├── {table_name}/ + └── personal_context/ +``` + +**Alternative considered:** `{customer-name}/context/...` (per-customer +tree). Rejected — produces N parallel `context/` roots which loses the +"single navigation entry point" property the user emphasized. + +## Interpretation 2 — `personal_context/` location + +Lives **inside each customer's directory**, as a sibling of the table +dirs. Rationale: the docs/sheets are about a specific customer, not +shared across customers, so per-customer placement is natural. + +``` +context/{customer-name}/personal_context/ + index.md + internal_notes.md # synthesis across docs+sheets + sources/ + {doc_or_sheet}.md # one per source, with verbatim gists +``` + +**Alternative considered:** `context/personal_context/` at the very top +(treating personal context as cross-customer). Rejected for the same +reason — content is customer-scoped. + +## Interpretation 3 — `data_warehouse.md` vs `{table}/index.md` + +- **`data_warehouse.md`**: warehouse-wide narrative. The 1-3 paragraph + story of what the customer is building, the dataset/table layout in + aggregate, governance posture, recent query-pattern themes, + cross-source operational stories. Does **not** duplicate per-table + detail — links to per-table dirs instead. +- **`{table}/index.md`**: per-table overview. What this table is for, + who owns it, how it's used (referenced in which sheets/docs/queries), + its grain, its partitioning, links to `fields.md` / `lineage.md` / + `sources/`. + +This split lets a downstream LLM load only what it needs: the +warehouse narrative for "what does this customer do?", a single +table's index for "tell me about `fact_orders_daily`". + +## Interpretation 4 — `retrieval_methods.md` + +Customer-specific instructions for how a downstream LLM should *fetch +more* context if it needs to. Includes: + +- The customer's project ID, BigQuery region, Drive folder IDs +- Concrete commands (with placeholders): `bq show --schema PROJECT:DS.TBL`, + `gcloud dataplex assets list ...`, etc. +- Notes on auth requirements (ADC scopes, gcloud account) +- Pointers to `sources/` for the snapshots already gathered + +**Alternative considered:** generic GCP retrieval guide (same for every +customer). Rejected — the value is in the customer-specific values +(project ID, region, folder IDs, dataset name). Otherwise it's noise. + +## Interpretation 5 — `{fields}.md` filename + +Literal `fields.md`. The user wrote `{fields}.md` in braces but I read +that as illustrative ("a file named after the concept of fields"), not +templated. + +## Interpretation 6 — what's a "source" + +A source is **one retrieval that produced data the doc relies on**. +Each source file points to its origin and includes verbatim "gists" +that capture the load-bearing snippets. + +Per-table `sources/`: +- `bq_show_schema.md` — `bq show --schema` invocation, with the JSON + schema as a gist +- `bq_query_patterns.md` — JOBS_BY_PROJECT query, with the top patterns + hitting this table verbatim +- `dataplex_catalog_entry.md` — if Dataplex has a catalog entry for + this table, the entry as a gist +- `pipeline_design_doc.md` — paragraphs from the internal design doc + that mention this table verbatim + +Top-level (warehouse) `sources/`: +- `bq_dataset_list.md`, `bq_jobs_by_project.md`, + `dataplex_lakes_list.md`, `dataplex_aspect_types_list.md`, etc. +- `internal_design_doc_overview.md` — paragraphs that describe the + warehouse generally (vs. one specific table) + +`personal_context/sources/`: +- One file per Doc or Sheet, with the most useful verbatim excerpts + (already what the old `gdocs.md`/`gsheets.md` did, just per-source + instead of bundled) + +## Interpretation 7 — sub-agent decomposition + +Old (v0.1): four parallel sub-agents per source (BigQuery, Dataplex, +Google Docs, Google Sheets) + a synthesizer. Each agent produced a +single bundled markdown. + +New (v0.2): **four sub-agents per customer**, run in a specific order: + +1. **`personal_context_agent`** — handles Google Docs + Sheets + together. Emits `personal_context/internal_notes.md` and + `personal_context/sources/*.md`. Combining is natural — both are + "internal team notes about the customer." Runs **first**. +2. **`warehouse_agent`** — handles BigQuery + Dataplex together. Reads + both surfaces and emits the full warehouse subtree (`data_warehouse.md`, + `retrieval_methods.md`, top-level `sources/*.md`, and per-table + `{table}/{fields,lineage}.md` + `{table}/sources/*.md`). Combining + BQ and Dataplex makes sense because both surfaces describe the same + physical artifacts and the per-table `lineage.md` should pull from + both. Runs **after personal_context** so it can cite verbatim + quotes from `personal_context/sources/*.md` in its narrative files. +3. **`indexer_agent`** — runs after warehouse, walks the entire tree, + generates every `index.md`. Centralizing index generation + guarantees a consistent format. +4. **`critic_agent`** — runs last, walks the wiki, writes a + structured `CRITIQUE.md` with severity-ranked issues (format + violations, fabrications, weak narrative, completeness gaps). + Doesn't fix anything — flags. The user (or a subsequent re-run) + handles fixes. + +**Why serialize personal_context → warehouse instead of running them +in parallel?** Initial v0.2 ran them in parallel for max +throughput, but the critic's first pass uncovered a real bug: the +warehouse agent wrote "personal_context is empty" claims that became +false the moment personal_context finished. Tightening the warehouse +prompt to "ls personal_context first" wasn't enough — there was still +a race. Serialization eliminates the race entirely; the latency cost +(~4-5 minutes for personal_context running before warehouse instead +of alongside it) is worth the correctness win. **Cross-customer +parallelism is preserved** — for N customers, all N personal_context +agents run in parallel, then all N warehouse agents in parallel, +etc. + +After all four sub-agents complete for all customers, the +orchestrator may optionally run `scripts/gcs_upload.py` to mirror +the generated tree to a GCS bucket (controlled via `gcs_bucket` in +the manifest, or a CLI flag). This is a script invocation, not a +sub-agent. + +## What's preserved vs. dropped from v0.1 + +**Preserved data:** +- All BigQuery info (datasets, tables, schemas, query patterns, + scheduled queries) — just sliced per-table instead of bundled +- All Dataplex info — augments `data_warehouse.md` (governance) and + per-table `lineage.md` +- All Doc/Sheet content — moved to `personal_context/` +- The cross-source synthesis (the "Cross-source observations" section + that was the v0.1 highlight) — folded into `data_warehouse.md` and + per-table `index.md` files + +**Dropped:** +- The bundled `bigquery.md` / `dataplex.md` / `gdocs.md` / `gsheets.md` + files — replaced by the new tree +- The single per-customer `WIKI.md` — replaced by `data_warehouse.md` + + per-table indexes + the index-of-indexes at customer root + +## What I'm explicitly NOT doing in v0.2 + +- **Incremental regeneration** (only re-run what changed). v0.2 always + regenerates the whole tree. +- **Helper scripts for `bq` / `gcloud`.** The v0.1 ones were broken; for + v0.2 the agents call the CLIs directly. Can revisit. +- **Critic auto-fix loop.** The critic flags but doesn't re-run upstream + agents to fix issues. v0.3 could orchestrate a fix-and-re-critique + loop. + +## Mid-flight additions (added during v0.2 implementation) + +These weren't in the original spec but were added in the same session: + +- **`critic_agent`** — runs after the indexer, writes a severity-ranked + `CRITIQUE.md`. Surfaced the warehouse/personal_context ordering bug + on the very first run; led to the serialization fix above. +- **`scripts/gcs_upload.py`** — thin wrapper around `gcloud storage + rsync` to mirror the generated tree to a GCS bucket. Auth uses the + same gcloud / ADC stack the rest of the skill uses. Configurable via + `gcs_bucket` in the manifest (see + [examples/customer_manifest.example.yaml](examples/customer_manifest.example.yaml)) + or as a CLI flag. + +## How to override any of these + +Each interpretation lives in exactly one place: + +| Interpretation | Where to change | +|---|---| +| Multi-customer layout | `agents/indexer_agent.md` (top-level index) + `SKILL.md` (orchestration) | +| `personal_context/` location | `agents/personal_context_agent.md` (writes there) + indexer (links there) | +| `data_warehouse.md` vs table-index split | `agents/warehouse_agent.md` | +| `retrieval_methods.md` content | `agents/warehouse_agent.md` | +| `fields.md` filename | `agents/warehouse_agent.md` | +| What's a source | `templates/source_format.md` + the agents that emit sources | +| Sub-agent decomposition | `SKILL.md` (orchestration) + the three agent files | diff --git a/skills/ccb-customer-context-builder/SKILL.md b/skills/ccb-customer-context-builder/SKILL.md new file mode 100644 index 00000000..2015d478 --- /dev/null +++ b/skills/ccb-customer-context-builder/SKILL.md @@ -0,0 +1,528 @@ +--- +name: skill-ccb-customer-context-builder +description: Build a personalized "LLM wiki" context repository for one or more GCP enterprise customers by orchestrating parallel sub-agents that pull from BigQuery (datasets, tables, schemas, recent query patterns), Dataplex (lakes, zones, catalog entries, data-quality scans), Google Docs (internal customer notes), and Google Sheets (tracking spreadsheets). Use this skill whenever the user wants to create, refresh, or expand a context repo / wiki / knowledge base for a GCP customer or list of customers — even when they don't explicitly say "wiki" — including phrasings like "build context for project X", "summarize what customer Y is doing on GCP", "pull together everything we know about these projects", "prep an LLM context for the Acme account", or "generate a customer brief from BigQuery + our docs". Output is a recursive directory of markdown files structured for agentic retrieval, with separate trees for the data warehouse and personal-team-context, every directory carrying an index.md, and verbatim "gist" source files preserving the underlying data. +--- + +# GCP Customer Context Builder + +This skill orchestrates a multi-agent harness that produces a recursive +markdown wiki describing what a GCP enterprise customer is working on. +The wiki is structured for agentic retrieval — every directory has an +`index.md` that summarizes the directory and links to peers and +children, so a downstream LLM can navigate without loading the whole +tree. + +## When to use + +Trigger this skill when the user wants to assemble customer context +from GCP and Google Workspace sources. Typical inputs are one or more +GCP project IDs, optionally with a manifest mapping each project to +Drive folders containing internal team notes about that customer. + +If the user gives only a vague request ("build me context for Acme"), +ask once for the list of project IDs and any Drive folder IDs, then +proceed. + +## Inputs + +The skill accepts inputs in two shapes: + +1. **Bare project IDs** — e.g., `acme-prod-123 acme-staging-456`. + Used when the user just lists projects in chat. If the user hasn't + pinned Drive folders, the orchestrator runs **Drive discovery** + (Step 1.5 below) to surface candidate docs and asks the user to + pick. If discovery returns nothing useful, sub-agents fall back to + fullText search using the project ID and customer name. +2. **Manifest YAML** (`customers.yaml`) — richer input, lets the user + pin exact Drive folders and supply human-readable names. See + [examples/customer_manifest.example.yaml](examples/customer_manifest.example.yaml). + Per-customer Drive entries are still optional — any customer + without `drive.docs_folder_id` / `drive.sheets_folder_id` goes + through Drive discovery + picker. + +If both are present, the manifest wins. + +## Output structure + +The output is a recursive tree, intentionally designed for *agentic +retrieval*. Every directory has an `index.md` with three sections +(`# Summary`, `# Index`, `# Child Indexes`) so a downstream agent can +land in any directory and decide what to load next without scanning +the whole tree. + +The top-level layout is the **Context Center** layout the wiki-viewer +skill auto-detects to render all 5 tabs (Wikis · Tickets · Candidates · +Skills · Drift). The four non-wiki dirs start empty — they get populated +by the viewer's server-side action endpoints (Rescan, Create-skill, +Re-scan drift) once the user starts using it. The builder just stages +the layout so the tabs are visible from day one. + +``` +/ +├── wikis/ +│ ├── index.md # Lists all customers +│ └── / +│ ├── index.md # Customer overview, links into the rest +│ ├── data_warehouse.md # Warehouse-wide narrative (BQ + Dataplex synth) +│ ├── retrieval_methods.md # How to fetch more from this customer's GCP +│ ├── sources/ # Warehouse-level retrieval snapshots +│ │ ├── index.md +│ │ ├── bq_dataset_list.md +│ │ ├── bq_jobs_by_project.md +│ │ └── ... # one per retrieval performed +│ ├── / # one dir per BigQuery table +│ │ ├── index.md +│ │ ├── fields.md # column-by-column schema +│ │ ├── lineage.md # provenance (upstream/downstream, Dataplex) +│ │ └── sources/ +│ │ ├── index.md +│ │ ├── bq_show_schema.md +│ │ └── ... # per-table retrieval snapshots +│ └── personal_context/ # team-internal notes about this customer +│ ├── index.md +│ ├── internal_notes.md # narrative across all docs+sheets +│ └── sources/ +│ ├── index.md +│ └── .md # one per Doc/Sheet, with verbatim gists +├── tickets/ # Empty — viewer populates from user uploads +├── candidates/ # Empty — viewer populates via /api/rescan +├── skills/ # Empty — viewer populates via /api/create-skill +└── drift/ # Populated on rebuilds (Step 3.6) + └── / + ├── DRIFT.md # Copied from /DRIFT.md + └── DRIFT.json # Copied from /DRIFT.json +``` + +Two file conventions matter: + +- **Index files** follow [templates/index_format.md](templates/index_format.md) + exactly — three sections, descriptions optimized for an agent + scanning the tree. +- **Source files** follow [templates/source_format.md](templates/source_format.md) + — `# Retrieved from` (origin metadata) plus `# Gists` (verbatim + snippets, never paraphrased). They're the citation layer the + narrative files (data_warehouse.md, internal_notes.md, fields.md, + lineage.md) implicitly refer to. + +## Prerequisites + +The user must have: + +- **`gcloud` and `bq` CLIs installed** and authenticated: `gcloud auth + login`. The `bq`/`gcloud` calls use the gcloud user account. +- **Auth for Drive/Docs/Sheets** — either Application Default + Credentials via `gcloud auth application-default login --scopes=...` + *or* `GOOGLE_APPLICATION_CREDENTIALS` pointing at a service-account + JSON. (Service accounts have zero Drive storage quota on personal + Gmail, so they can read shared folders but can't write new files — + fine for the read-only skill, not for seeding test data.) +- **Python 3.9+** with deps in [scripts/requirements.txt](scripts/requirements.txt). + +Run [scripts/check_prereqs.sh](scripts/check_prereqs.sh) at the start +of every invocation. If it exits non-zero, surface the missing piece +to the user and stop — don't push through with partial auth. + +## Workflow + +You are the orchestrator. For each customer, you spawn three sub-agents. + +### Step 1 — Gather inputs and verify prereqs + +1. Resolve the list of customers (project IDs + optional Drive folder + IDs + optional human-readable names) from manifest or chat. +2. Run `scripts/check_prereqs.sh`. Stop on failure with a clear + remediation message. +3. Resolve `SKILL_DIR` (the absolute path to this skill's installation; + typically `~/.claude/skills/gcp-customer-context-builder/` or + wherever the user cloned the repo). Sub-agents inherit the user's + CWD, NOT the skill dir, so passing absolute paths is required. +4. Create the per-customer output dir: `/wikis//`. +5. Create the four Context Center placeholder dirs at the top level: + `/{tickets,candidates,skills,drift}/`. These start empty — + the wiki-viewer populates them via its server-side action endpoints. + Creating them up front ensures all 5 tabs render from day one. + +### Step 1.5 — Drive discovery + picker (only if any customer lacks pinned Drive folders) + +If every customer in this run has both `docs_folder_id` and +`sheets_folder_id` pinned in the manifest, **skip this step entirely** +and go straight to Step 2. + +Otherwise, run discovery once per invocation (not per customer — the +output is reused across all customers that need it): + +```bash +python3 "$SKILL_DIR/scripts/discover_drive_docs.py" \ + --max-recent=200 --max-excerpts=60 +``` + +The script returns a JSON list of recently-viewed Docs and Sheets +with metadata, parent folder name, and a short content excerpt +(first ~800 chars for docs, tab names for sheets). It does NOT rank +or filter by customer — that's your job. + +For each customer that lacks pinned folders: + +1. **Rank candidates by relevance** to this customer's `PROJECT_ID` + and `CUSTOMER_NAME` (and any `search_terms` from the manifest). + Use the title, parent-folder name, owner email domain, and the + excerpt. Strong signals: customer name in title or excerpt, + project ID anywhere, recurring entity names (table names, owner + emails) that match what the warehouse might surface. +2. **Show the user the top ~10–15 candidates** as a numbered list: + + ``` + Candidate docs/sheets for Acme Corp (acme-prod-123): + + 1. [doc] Acme — Pipeline Design Doc (Q1 2026) + folder: claude-skill-test-docs · owner: jordan@acme.example.com + excerpt: "...end-to-end attribution pipeline for Acme Corp..." + 2. [sheet] Acme — Pipeline Health Tracker + folder: claude-skill-test-sheets · tabs: pipeline_runs, open_issues + ... + + Reply with the numbers to use (e.g., '1 2 4'), 'a' for all, or + 's' to skip Drive entirely for this customer. + ``` + +3. **Wait for the user's pick.** Do not auto-select. If they reply + `s`, leave `DOC_IDS` and `SHEET_IDS` unset for this customer; the + personal_context agent will fall back to fullText search. +4. **Pass the picked IDs** to the personal_context agent via + `DOC_IDS` and `SHEET_IDS` env vars (comma-separated lists, + separated by kind). The agent's `Find candidates` step uses these + directly — no folder listing. + +If discovery's `warnings` field mentions the viewedByMe fallback +(typical with service-account auth), surface that one-liner to the +user before showing candidates so they know the signal is weaker. + +If `stats.total` is 0, skip the picker and let the agent fall back +to fullText search. + +### Step 2 — Personal context first, THEN warehouse + +These two run **sequentially**, not in parallel. Why: the warehouse +agent's narrative files (`data_warehouse.md`, per-table `lineage.md`) +should cite personal_context source files when relevant. If warehouse +runs in parallel with personal_context, it can race ahead and write +"personal_context is empty" claims that become false the moment +personal_context finishes — a bug observed in early v0.2 smoke tests. + +Run order per customer: + +#### Step 2a — Personal context + +| Sub-agent | Prompt file | Writes under | +|--------------------|---------------------------------------------------------------------|-----------------------------------------------| +| Personal context | `$SKILL_DIR/agents/personal_context_agent.md` | `$CUSTOMER_DIR/personal_context/` | + +Inputs: `SKILL_DIR`, `PROJECT_ID`, `CUSTOMER_NAME`, `CUSTOMER_DIR`, +plus `DOCS_FOLDER_ID`, `SHEETS_FOLDER_ID`, `SEARCH_TERMS` (any of +these can be omitted). + +#### Step 2b — Warehouse + +| Sub-agent | Prompt file | Writes under | +|--------------------|---------------------------------------------------------------------|-----------------------------------------------| +| Warehouse | `$SKILL_DIR/agents/warehouse_agent.md` | `$CUSTOMER_DIR/` (excluding personal_context) | + +Inputs: `SKILL_DIR`, `PROJECT_ID`, `CUSTOMER_NAME`, `CUSTOMER_DIR`. + +Use `subagent_type: "general-purpose"` for both. Each Agent prompt +must include a reminder that the agent writes files to disk and +returns a one-line summary in chat — NOT the file contents. + +**Cross-customer parallelism is fine.** If you have N customers, +spawn N personal_context agents in parallel, wait for all, then spawn +N warehouse agents in parallel. The serialization is per-customer. + +Critically: **neither agent writes any `index.md` files.** Indexes +are produced by the indexer agent in Step 3. + +#### Time budgets per sub-agent + +To keep wiki-builder runs from blowing past expected wall-clock and +quota, each sub-agent prompt includes an explicit time budget. The +agent self-monitors via `date +%s` and degrades gracefully (writes +partial output with a `TRUNCATED` note in `Gaps and caveats`) once it +crosses the soft target. The orchestrator accepts partial output +rather than retrying — the critic surfaces what's missing. + +| Sub-agent | Soft target | Hard cap | +|--------------------|------------:|---------:| +| personal_context | 4 min | 6 min | +| warehouse | 8 min | 12 min | +| indexer | 3 min | 5 min | +| critic | 3 min | 5 min | + +End-to-end target wall clock per customer: ~18 min upper bound (sum of +hard caps), typical ~12 min. Individual `bq`/`gcloud` calls inside +each agent are wrapped with `timeout 60` (or `timeout 30` for the +Drive helpers) so a single hung call can't blow the budget. + +### Step 3 — Run the indexer + +Once both Step 2 agents return for a customer, spawn the indexer: + +| Sub-agent | Prompt file | Writes | +|-----------|------------------------------------------|------------------------------| +| Indexer | `$SKILL_DIR/agents/indexer_agent.md` | every `index.md` in the tree | + +Inputs: `SKILL_DIR`, `CUSTOMER_NAME`, `PROJECT_ID`, `CUSTOMER_DIR`. + +The indexer walks the tree depth-first, generates an `index.md` in +every directory conforming to the format spec. + +### Step 3.5 — Build claims sidecar + source manifest + dep graph + +After the indexer finishes for a customer, run three small scripts in +sequence to materialize the **per-claim verification + gap detection** +foundation. These run mechanically (no LLM), so the orchestrator +invokes them directly via `bash`/`python3` — no sub-agent. + +```bash +python3 "$SKILL_DIR/scripts/claims_sidecar.py" --wiki-root="$CUSTOMER_DIR" +python3 "$SKILL_DIR/scripts/build_manifest.py" --wiki-root="$CUSTOMER_DIR" +python3 "$SKILL_DIR/scripts/dep_graph.py" --wiki-root="$CUSTOMER_DIR" +python3 "$SKILL_DIR/scripts/gap_check.py" --wiki-root="$CUSTOMER_DIR" +``` + +What each writes: + +- `claims_index.json` + `.claims.json` (one per narrative file) — + every footnote citation parsed, with stable content-hash IDs and + EXTRACTED / INFERRED / AMBIGUOUS bands. Also runs cross-file + validation (anchors resolve, EXTRACTED quotes literal-substring + match the cited gist). +- `source_manifest.json` — sha256 hash of every source file's content + plus the source URI/lineage from its `# Retrieved from` block. +- `dep_graph.json` — nodes (narrative + source files) + edges + (narrative → source via cited claims). Includes `cited_by` backlinks + on source nodes. +- `GAPS.md` + `GAPS.json` — structural + coverage gaps, severity-sorted. + +The wiki-viewer's Wikis tab reads `GAPS.json` to surface a per-page +side panel of gaps. The critic agent (next step) reads +`claims_index.json` to verify claim hygiene. + +`gap_check.py` uses spaCy if `en_core_web_sm` is installed — bigger +recall on entity extraction. Without spaCy it falls back to regex over +BQ table-name patterns and emails; runs but with less recall on +narrative prose. To enable: + +```bash +pip install spacy +python -m spacy download en_core_web_sm +``` + +### Step 3.6 — Drift detection + rebuild plan (Phase 2, only on rebuilds) + +On a fresh first build there's no drift to detect — `source_diff.py` is a +no-op. On any subsequent rebuild against an existing wiki tree, run: + +```bash +python3 "$SKILL_DIR/scripts/source_diff.py" --wiki-root="$CUSTOMER_DIR" +python3 "$SKILL_DIR/scripts/revalidate_drift.py" --wiki-root="$CUSTOMER_DIR" +python3 "$SKILL_DIR/scripts/rebuild_plan.py" --wiki-root="$CUSTOMER_DIR" + +# Stage drift artifacts where the viewer's Drift tab looks for them. +# OUTPUT_DIR is the data root (parent of wikis/, candidates/, etc.) — +# i.e., CUSTOMER_DIR is "$OUTPUT_DIR/wikis/$CUSTOMER_NAME". +OUTPUT_DIR="$(dirname "$(dirname "$CUSTOMER_DIR")")" +DRIFT_DST="$OUTPUT_DIR/drift/$CUSTOMER_NAME" +mkdir -p "$DRIFT_DST" +cp "$CUSTOMER_DIR/DRIFT.md" "$DRIFT_DST/DRIFT.md" 2>/dev/null || true +cp "$CUSTOMER_DIR/DRIFT.json" "$DRIFT_DST/DRIFT.json" 2>/dev/null || true +``` + +Skip the `cp` lines on a first build (the `|| true` tolerates the +missing files but you can just omit them entirely if you know +DRIFT.{md,json} wasn't written). The viewer's `/api/rescan-drift` +endpoint refreshes both the customer wiki AND the staged copies in one +shot — this is only the seed. + +What each writes: + +- `DRIFT.md` + `DRIFT.json` — sources that have **CHANGED** (sha256 + differs from manifest), been **DELETED**, or appeared **NEW** since + last build. Severity is computed from claim impact: a CHANGED source + cited by an EXTRACTED claim is HIGH (verbatim quote may now be wrong); + INFERRED is MEDIUM; no claims is LOW. DELETED with any claim citing + it is HIGH (orphaned citation). +- `revalidate_drift.py` — stage-2 re-validation of every CHANGED entry + in `DRIFT.json`. EXTRACTED claims get a substring re-check against + the new source content; INFERRED claims get an anchor-existence + check; AMBIGUOUS claims drop. Severity is recomputed from the + surviving claims and stamped back as `severity_after_revalidation`, + which clears false-positive HIGHs from cosmetic edits (trailing + newline, whitespace) without touching the underlying sha256 diff. + Algorithm: see [docs/ALGORITHMS.md §7b](../../docs/ALGORITHMS.md). +- `rebuild_plan.json` — the set of narrative sections that need + re-derivation, derived from DRIFT × `dep_graph.json`. Each action lists + the section, its owning agent, the drifted sources it cites, and the + claim IDs to re-validate. The orchestrator hands this plan to a + focused sub-agent for surgical re-extraction (instead of rebuilding + the whole wiki). + +The wiki-viewer surfaces drift in a top-level **Drift** tab, with one-click +**Ack** per entry (reuses the `acknowledge_drift.py` script via the +`/api/acknowledge-drift` endpoint) and a **Re-scan drift** button per +customer. + +`source_diff.py --live` re-fetches each source from its origin +(Google Doc body, Google Sheet, BigQuery schema, dataset listing) and +compares to the on-disk snapshot, surfacing **live_changed** / +**live_deleted** / **live_failed** entries on top of the local diff. +Concurrent fetches via `--live-workers=N` (default 4). Volatile sources +(JOBS_BY_PROJECT, etc.) and unsupported source kinds (Dataplex) are +skipped with a documented reason. Requires the same GCP auth used to +build the wiki originally: + +```bash +python3 "$SKILL_DIR/scripts/source_diff.py" --wiki-root="$CUSTOMER_DIR" --live +``` + +Without `--live`, the default on-disk-hash comparison works in any +environment without GCP auth. + +### Step 4 — Run the critic + +After the indexer + claims/manifest/gap scripts finish for a customer, +spawn the critic: + +| Sub-agent | Prompt file | Writes | +|-----------|------------------------------------------|------------------------------| +| Critic | `$SKILL_DIR/agents/critic_agent.md` | `$CUSTOMER_DIR/CRITIQUE.md` | + +Inputs: `SKILL_DIR`, `CUSTOMER_NAME`, `PROJECT_ID`, `CUSTOMER_DIR`, +`OUTPUT_PATH=$CUSTOMER_DIR/CRITIQUE.md`. + +The critic walks the wiki, reads `claims_index.json` and `GAPS.md`, +and writes a structured critique with severity-ranked issues (format +violations, fabrications, weak narrative, completeness gaps, claim +citation hygiene). It does NOT fix anything — it flags. The user (or +a future re-run with a tightened prompt) handles fixes. + +The critic can run in parallel with critics for other customers, but +must run after that customer's indexer + scripts complete. + +### Step 5 — Write the top-level customer-list index + +After all per-customer trees exist (and ideally after their critics +have written CRITIQUE.md), write `/wikis/index.md` yourself +(no sub-agent needed) — it's a small file listing customers with +one-line summaries pulled from each customer's `index.md` summary +section. Conform to the index format spec. + +### Step 6 — Optionally upload to GCS + +If the user supplied a `--gcs-bucket=gs://bucket-name[/prefix]` arg +(or set `gcs_bucket` in the manifest), invoke +`scripts/gcs_upload.py` to mirror the local output tree to that +bucket. See [scripts/gcs_upload.py](scripts/gcs_upload.py) for the +exact CLI. Auth uses the same `gcloud` / ADC stack the rest of the +skill uses. + +```bash +python3 "$SKILL_DIR/scripts/gcs_upload.py" \ + --local-dir=/wikis \ + --gcs-uri=gs://bucket-name/optional/prefix \ + --delete-extra # mirror semantics — remove remote files not in local +``` + +If no bucket was supplied, skip this step silently. + +### Step 7 — Report back + +Print a short summary to the user: +- How many customers processed +- Per customer: which sub-agents succeeded / failed; critic grade + issue counts +- Total file count in the generated tree +- Path to the output directory +- GCS URI (if uploaded) + +### Step 8 — Suggest the viewer (don't auto-launch) + +The repo ships a sibling skill, `wiki-viewer`, that builds a +browseable HTML tree with sidebar navigation and serves it on a local +port. **Suggest** it in the report, but don't auto-launch — leaving a +server running that the user might not know about is a footgun. + +Suggested phrasing: + +> To browse the generated wiki interactively, you can invoke the +> `wiki-viewer` skill (or run `bash $SKILL_DIR/../wiki-viewer/scripts/serve_wiki.sh` +> directly). + +If the user asks you to also open the viewer, hand off to wiki-viewer +rather than reimplementing it inline. + +## Failure handling + +A customer with no Dataplex resources, no Drive folder, or no internal +docs is a normal case, not an error — the relevant sub-agent should +write what it can and note the gap in its narrative file. The +orchestrator should treat empty-but-valid output as success. + +A real failure (auth, network, permission) should be surfaced. The +sub-agent writes what it tried and what failed (in its `Gaps and +caveats` section), then exits with success unless it produced nothing +at all. The orchestrator reports per-source status to the user. + +## Why this shape + +**Recursive index.md everywhere** is for agentic retrieval — a +downstream agent reading any single `index.md` can decide which +subtree to load without traversing the whole repo. This trades a small +write cost (the indexer pass) for a large read cost reduction at +inference time. + +**Sources separate from narrative** is for citation fidelity — the +narrative layer (`data_warehouse.md`, `internal_notes.md`, `fields.md`, +`lineage.md`) summarizes; the source layer preserves verbatim snippets +so a downstream LLM can quote authoritative material rather than +paraphrasing the wiki's prose. This matters for any answer that needs +to ground in the customer's actual words. + +**Personal context separate from warehouse** is for both privacy and +relevance — internal team notes about a customer have different +access patterns and different downstream uses than the warehouse +schema itself. A retrieval agent answering "what does +fact_orders_daily contain?" wants `fact_orders_daily/fields.md`; an +agent answering "what's blocking the v2 migration?" wants +`personal_context/internal_notes.md`. + +**Three sub-agents per customer (warehouse + personal_context + +indexer)** instead of four-by-source (BQ / Dataplex / Docs / Sheets): +the new structure couples BQ and Dataplex at the per-table level +(`lineage.md` pulls from both), so coupling them in one agent is +natural. Same for Docs and Sheets — both are "internal notes." +Centralizing index generation in one final agent guarantees format +consistency. + +## Reference files + +- [DESIGN_NOTES.md](DESIGN_NOTES.md) — interpretations made for v0.2 structure +- [templates/index_format.md](templates/index_format.md) — required format for every index.md +- [templates/source_format.md](templates/source_format.md) — required format for every source file +- [agents/warehouse_agent.md](agents/warehouse_agent.md) — BQ + Dataplex producer +- [agents/personal_context_agent.md](agents/personal_context_agent.md) — Docs + Sheets producer +- [agents/indexer_agent.md](agents/indexer_agent.md) — index.md generator +- [agents/critic_agent.md](agents/critic_agent.md) — quality reviewer (writes CRITIQUE.md) +- [examples/customer_manifest.example.yaml](examples/customer_manifest.example.yaml) — manifest schema +- [scripts/check_prereqs.sh](scripts/check_prereqs.sh) — auth / CLI / Python preflight +- [scripts/drive_search.py](scripts/drive_search.py) — Drive folder/keyword search +- [scripts/discover_drive_docs.py](scripts/discover_drive_docs.py) — Recent-docs discovery for the picker (Step 1.5) +- [scripts/gdocs_extract.py](scripts/gdocs_extract.py) — Google Doc body extractor +- [scripts/gsheets_extract.py](scripts/gsheets_extract.py) — Google Sheet metadata + sample +- [scripts/gcs_upload.py](scripts/gcs_upload.py) — mirror local output tree to a GCS bucket +- [scripts/claims_sidecar.py](scripts/claims_sidecar.py) — parse `[^cN]` footnote citations into per-file `.claims.json` sidecars (Step 3.5) +- [scripts/build_manifest.py](scripts/build_manifest.py) — sha256 every source file → `source_manifest.json` (Step 3.5) +- [scripts/dep_graph.py](scripts/dep_graph.py) — section → claims → sources graph → `dep_graph.json` (Step 3.5) +- [scripts/gap_check.py](scripts/gap_check.py) — structural + coverage gap detection → `GAPS.md` + `GAPS.json` (Step 3.5). Add `--cluster-mode` to also run InfraNodus-style cluster gap detection (Louvain communities + betweenness centrality) — requires `networkx` +- [scripts/source_diff.py](scripts/source_diff.py) — drift detection: changed/deleted/new sources vs. manifest → `DRIFT.md` + `DRIFT.json` (Step 3.6). Supports `--live` (re-fetch from origin) and `--baseline=` (compare to a pinned snapshot) +- [scripts/revalidate_drift.py](scripts/revalidate_drift.py) — stage-2 re-validation after `source_diff`: per-claim substring + anchor checks downgrade false-positive HIGHs from cosmetic edits; optional `--llm` adds a Claude verdict for INFERRED claims (Step 3.6) +- [scripts/rebuild_plan.py](scripts/rebuild_plan.py) — incremental rebuild scope: drift × dep_graph → `rebuild_plan.json` (Step 3.6) +- [scripts/acknowledge_drift.py](scripts/acknowledge_drift.py) — mark a drift entry handled so it stops appearing in DRIFT.md +- [scripts/live_fetchers.py](scripts/live_fetchers.py) — fetcher dispatch + LiveFetchError for `source_diff.py --live` (gdoc/gsheet/bq-schema/bq-list) diff --git a/skills/ccb-customer-context-builder/agents/critic_agent.md b/skills/ccb-customer-context-builder/agents/critic_agent.md new file mode 100644 index 00000000..f7dab793 --- /dev/null +++ b/skills/ccb-customer-context-builder/agents/critic_agent.md @@ -0,0 +1,229 @@ +# Critic Agent + +You are the critic sub-agent of the gcp-customer-context-builder skill. +You run AFTER the warehouse, personal_context, and indexer agents have +all completed for one customer. Your job is to **review** the generated +wiki for quality and write a structured critique that the user can act +on. + +You are not a re-generator. You don't fix things — you flag them. The +user (or a future re-run with a tightened prompt) does the fixing. + +## Inputs + +- `SKILL_DIR` +- `CUSTOMER_NAME`, `PROJECT_ID` +- `CUSTOMER_DIR` — `/wikis//` +- `OUTPUT_PATH` — usually `/CRITIQUE.md` + +## Time budget — soft 3 min, hard 5 min + +```bash +CRIT_T0=$(date +%s) +ELAPSED=$(( $(date +%s) - CRIT_T0 )) +``` + +- **Under 180s (soft):** review all categories thoroughly. +- **180s–300s:** stop opening new files. Finalize the critique with + the issues you've found. Add a `_Note: critic exceeded its 3-minute + soft budget; some files may not have been reviewed in depth._` + line under the issue summary table. +- **Over 300s (hard cap):** STOP. Write the critique with whatever + issues you've identified, mark grade as `?` (not assessed), and + add `HARD CAP HIT — critic exceeded 5-minute hard budget; review is + incomplete` to the top of the file. + +Prioritize spot-checking the headline files (`data_warehouse.md`, +customer-root `index.md`, one or two `{table}/lineage.md` files, +`personal_context/internal_notes.md`) over exhaustively scanning every +source file. The most-loaded format violations are typically in the +narrative files, not the leaf gists. + +## What to evaluate + +Walk the customer's wiki tree (don't go outside it — you're reviewing +THIS wiki, not re-fetching source data). For every file, hold it +against the standards below. + +### 1. Index file format conformance + +Every `index.md` must: + +- Have exactly the three sections in order: `# Summary`, `# Index`, + `# Child Indexes` (case-sensitive; `# Indexes` is wrong, `# index` + is wrong) +- Omit (not include-empty) sections that have nothing to list +- Not contain any prose outside the three sections +- Not list `index.md` under `# Index` +- Use relative links (not absolute paths) in `# Child Indexes` + +For each violation, record: file path, what's wrong, severity. + +### 2. Source file format conformance + +Every file under any `sources/` directory (excluding `index.md`) must: + +- Have `# Retrieved from` and `# Gists` sections (in that order) +- Have at least one gist with a `## ` title +- Not paraphrase content under `# Gists` — gists must be verbatim + quotes, code blocks, JSON, or table snippets, NOT prose summary + +The verbatim-ness check is a judgment call. Look for tells: +- "The doc explains that..." → paraphrase, NOT a gist +- "> raw quoted text" → verbatim, OK +- "```sql / SELECT ...```" → verbatim, OK +- A markdown table with structured data lifted from a query result → verbatim, OK + +### 3. Narrative quality + +For `data_warehouse.md`, `retrieval_methods.md`, +`personal_context/internal_notes.md`, every `{table}/index.md` and +every `{table}/lineage.md`: + +- **Specificity**: are claims grounded in named entities (table names, + owner emails, dates, dollar figures, model versions) or generic + prose ("various tables", "the team tracks several metrics")? + Generic prose is a bug. +- **Cross-source claims have backing**: when `data_warehouse.md` says + "Doc X confirms partition regression", does + `personal_context/sources/x.md` actually contain a verbatim line + about that? When `{table}/lineage.md` says "Dataplex catalog entry + exists", does `{table}/sources/dataplex_catalog_entry.md` exist + AND have a real gist (not "no entry found")? +- **No fabrication**: any claim that doesn't appear to be supported + by something in the tree is suspect. + +### 4. Completeness + +- Every BigQuery table in `data_warehouse.md`'s table inventory + should have its own `{table_name}/` directory with `fields.md`, + `lineage.md`, and `sources/` +- Every Doc/Sheet referenced in `internal_notes.md` should have a + corresponding `personal_context/sources/.md` +- Every `index.md` in the tree should be reachable from + `index.md` (the customer root) by following `# Child Indexes` + links + +### 5. Cross-source connection density + +Count the cross-source connections in `data_warehouse.md` (claims +that join BigQuery + Dataplex + personal_context). Healthy is 3+ +substantive connections; less than that suggests the narrative is +under-utilizing the data we collected. + +### 6. Claim citation hygiene + +Run `python3 "$SKILL_DIR/scripts/claims_sidecar.py" --wiki-root="$CUSTOMER_DIR" --report` first — it parses every narrative file, builds +the claims sidecars, and emits a JSON report listing claims by +band plus malformed footnotes. Use the report to drive your check: + +- **Missing citations** — fact-bearing paragraphs in narrative files + with no `[^cN]` footnote. The script flags paragraphs that contain + table names, owner emails, dollar figures, or `> ` quote blocks + but lack a trailing citation. Severity HIGH. +- **EXTRACTED claims that don't match the source** — for a sample + (script's `--sample-extracted=10` flag) the script does a literal + substring check between the footnote's quoted text and the + pointed-at gist. Mismatches are HIGH (citation lies about being + verbatim). +- **INFERRED claims with no anchor in the cited source** — the + script flags footnotes whose `source.md#anchor` doesn't resolve. + MEDIUM (the citation is structurally valid but the anchor doesn't + exist). +- **Excessive AMBIGUOUS claims** — more than ~5% of total claims + being AMBIGUOUS suggests the narrative is hedging instead of + committing. MEDIUM. + +Surface the bands as a table in the critique: + +``` +| Band | Count | % | +|---|---|---| +| EXTRACTED | … | … | +| INFERRED | … | … | +| AMBIGUOUS | … | … | +``` + +### 7. Gap surface + +Run `python3 "$SKILL_DIR/scripts/gap_check.py" --wiki-root="$CUSTOMER_DIR"` — it computes structural and coverage gaps and +writes `GAPS.md` + `GAPS.json` next to your `CRITIQUE.md`. Don't +re-evaluate gaps in the critique; instead, append a one-line +summary referencing the gap report: + +``` +**Gap report:** see `GAPS.md` — N structural, M coverage. +``` + +## Output format — CRITIQUE.md + +Write to `OUTPUT_PATH`: + +```markdown +# Critique — {CUSTOMER_NAME} ({PROJECT_ID}) + +**Generated at:** {ISO 8601 UTC timestamp via `date -u +%Y-%m-%dT%H:%M:%SZ`} +**Files reviewed:** {N} +**Overall grade:** {A | B | C | D | F} — {one-line justification} + +## Issue summary + +| Severity | Count | +|---|---| +| HIGH | {n} | +| MEDIUM | {n} | +| LOW | {n} | + +## Issues + +### HIGH + +For each: +- **{file path}**: what's wrong, why it matters, suggested fix. + +### MEDIUM + +(same shape) + +### LOW + +(same shape) + +## What's working + +A short list of things the wiki does well — patterns worth preserving +in future iterations. Specific. + +## Suggested fixes ranked by leverage + +What single change would improve the wiki the most? What's the second? +Up to 5. These are prompt-level fixes, not file-by-file. +``` + +## Severity guidance + +- **HIGH**: format violations that break the agentic-retrieval contract + (missing required sections, broken links), or fabricated content + (claims without backing), or missing data the tree should have + collected +- **MEDIUM**: weak narrative quality (too generic, missing cross-source + connections), or paraphrased gists where verbatim is required, or + inconsistencies between files +- **LOW**: stylistic issues (verbose summaries, redundant restatement, + inconsistent slug naming), or low-value source files (gists that + don't carry information) + +## Behavioral notes + +- Stay inside the customer's wiki tree. You don't need to re-fetch BQ + or Drive data — you're reviewing what's there. +- Be direct. "summary is generic; rewrite to name the dataset" beats + "summary could be improved." +- Cite specific paths and quote specific text when calling out issues — + vague critiques are useless. +- Don't hold the wiki to standards beyond the spec. The format spec + is in `$SKILL_DIR/templates/index_format.md` and + `$SKILL_DIR/templates/source_format.md` — those are the contract. +- Reply with ONE line: `"wrote CRITIQUE.md (grade: {X}, {N} issues: + {H} HIGH, {M} MED, {L} LOW)"`. Do NOT include critique content in + chat. diff --git a/skills/ccb-customer-context-builder/agents/indexer_agent.md b/skills/ccb-customer-context-builder/agents/indexer_agent.md new file mode 100644 index 00000000..dc5911cc --- /dev/null +++ b/skills/ccb-customer-context-builder/agents/indexer_agent.md @@ -0,0 +1,136 @@ +# Indexer Agent + +You are the final sub-agent of the gcp-customer-context-builder skill. +Your job is to walk the customer's already-populated wiki tree and +write **every `index.md` file** in it, conforming to the format spec. + +You run AFTER the warehouse agent and the personal_context agent have +both completed. The tree is fully built except for the indexes. + +## Inputs + +- `SKILL_DIR` +- `CUSTOMER_NAME`, `PROJECT_ID` +- `CUSTOMER_DIR` — `/wikis//` + +## Time budget — soft 3 min, hard 5 min + +```bash +IDX_T0=$(date +%s) +ELAPSED=$(( $(date +%s) - IDX_T0 )) +``` + +- **Under 180s (soft = 3 min):** generate all indexes carefully. +- **180s–300s:** finish the directory you're currently indexing, then + for any remaining indexes generate a minimal version (Summary = 1 + short paragraph extracted from the first peer file's first + paragraph; Index/Child Indexes filled from `ls`). Add a + `` + HTML comment at the top of any minimal index. +- **Over 300s (hard cap = 5 min):** STOP. For any directory still + missing an `index.md`, write a stub with `# Summary\n\n_(skipped — indexer over budget)_\n\n# Index\n` followed by `ls` output. The + critic will flag these. + +Indexer should be the fastest agent — it's pure file-tree traversal, +no API calls. If you're approaching the soft budget, something's off +(too-large summaries, re-reading files unnecessarily, etc.). Lean on +short summaries. + +## Files you produce + +Every `index.md` in the tree, including: + +``` +$CUSTOMER_DIR/index.md +$CUSTOMER_DIR/sources/index.md +$CUSTOMER_DIR/{table_name}/index.md (one per table dir) +$CUSTOMER_DIR/{table_name}/sources/index.md (one per table) +$CUSTOMER_DIR/personal_context/index.md +$CUSTOMER_DIR/personal_context/sources/index.md +``` + +If the orchestrator told you to also write the **top-level** +`/wikis/index.md`, do that too — it lists customers (one +entry per customer subdir; you may only see your own customer here, in +which case write a single-entry index — the orchestrator merges later). + +## How to do the work + +1. **Read the format spec** in `$SKILL_DIR/templates/index_format.md` + — every index file you write must conform exactly. Three sections: + `# Summary`, `# Index`, `# Child Indexes`. Omit empty sections. + +2. **For each directory in the tree** (depth-first), enumerate: + - Peer files (everything in this dir that isn't a subdir or + `index.md` itself) + - Child directories (each must already have its own `index.md` — + write innermost first so descriptions can pull from the child's + summary) + +3. **Generate the summary** by reading the files in this directory. + For directories that contain a primary narrative file + (`data_warehouse.md`, `internal_notes.md`, `fields.md`, etc.), + pull the headline facts from that file. For `sources/` + directories, summarize what kinds of retrievals are gisted there. + Aim for the format spec's 1–5 paragraph guidance — typically 2–3 + for non-leaf dirs, 1 for `sources/` dirs. + +4. **Write descriptions for peer files and child indexes** that are + *useful for retrieval* — specific nouns, not generic prose. + "Daily order fact (grain order_date×user×SKU); partitioned; + HIGH-severity partition regression in flight" beats "Table + information." + +## Quality checklist + +For every `index.md` you write, verify: + +- [ ] Three sections in order: `# Summary`, `# Index`, `# Child Indexes` +- [ ] No prose outside those sections +- [ ] No `index.md` listed under `# Index` (it's the file itself) +- [ ] Every peer file in the dir is in `# Index` +- [ ] Every subdir is in `# Child Indexes` with a relative link +- [ ] Empty sections are omitted, not present-with-(none) +- [ ] Summary is specific (named tables/owners/issues, not generic prose) + +## Claim citations — required in every `# Summary` + +Every fact-bearing sentence in a `# Summary` paragraph that didn't +originate in the indexer (i.e. was pulled from a peer narrative +file's prose or a peer source gist) must carry a footnote `[^cN]`. +The footnote definition declares the confidence band and points at +the source — see `templates/index_format.md` for the format. + +**Tag downgrade rule** — when you paraphrase a sentence that the +peer file cites as EXTRACTED, the index summary's citation downgrades +to **INFERRED**. The verbatim relationship no longer holds for your +new sentence (you reworded it), even though the underlying source is +unchanged. EXTRACTED in an `index.md` is rare — it requires you to +carry the verbatim quote into the summary itself, in which case it's +usually better to drop the quote than to inflate the summary. + +A sentence that an indexer pulls from a peer file with a citation +already attached gets *re-cited*, not retained verbatim. You're +pointing at the underlying source, not the peer file's prose. + +`# Index` and `# Child Indexes` entries do NOT need citations — +those are navigational, not fact-bearing. + +## Behavioral notes + +- **Innermost first.** Walk the tree depth-first so you can pull the + child's summary into the parent's child-index description. (The + parent's `# Child Indexes` entry should describe what's in the + child, which means reading the child's summary first.) +- **No hallucination.** If you don't know what a file contains, open + it. Don't guess from filename alone. +- **Stable wording across siblings.** If `events_raw/index.md` opens + with "Raw web event stream...", and `fact_orders_daily/index.md` + opens with "Daily order fact...", both following the same pattern + (" "), an agent skimming the parent's child-index + list can compare them at a glance. +- **Claims downgrade on paraphrase.** If the peer file's narrative + cites a fact as EXTRACTED but you reworded it for the summary, your + citation is INFERRED. Don't carry the EXTRACTED tag through paraphrase. +- Reply with one line: e.g., `"wrote N index.md files"`. Do NOT + include file content in chat. diff --git a/skills/ccb-customer-context-builder/agents/personal_context_agent.md b/skills/ccb-customer-context-builder/agents/personal_context_agent.md new file mode 100644 index 00000000..fd34c0b6 --- /dev/null +++ b/skills/ccb-customer-context-builder/agents/personal_context_agent.md @@ -0,0 +1,186 @@ +# Personal Context Agent + +You are a sub-agent of the gcp-customer-context-builder skill. Your job +is to produce the **personal-context subtree** of one customer's wiki, +by reading internal Google Docs and Sheets that the team maintains +about this customer. You do NOT write `index.md` files. + +"Personal context" here means *team-internal* notes: pipeline design +docs, blocker logs, tracking spreadsheets, customer-success notes. +This is the prose layer that complements the structural data-warehouse +layer. + +## Inputs + +- `SKILL_DIR` +- `PROJECT_ID`, `CUSTOMER_NAME` +- `CUSTOMER_DIR` — `/wikis//` +- `DOCS_FOLDER_ID` — Drive folder ID for internal docs (optional) +- `SHEETS_FOLDER_ID` — Drive folder ID for tracking sheets (optional) +- `DOC_IDS` — comma-separated explicit Doc IDs to use (optional, set by + the orchestrator when discovery + picker was used instead of folders) +- `SHEET_IDS` — comma-separated explicit Sheet IDs (same as `DOC_IDS`) +- `SEARCH_TERMS` — additional keywords for fallback Drive search (optional) + +## Time budget — soft 4 min, hard 6 min + +```bash +PC_T0=$(date +%s) +``` + +After each candidate doc / sheet you finish processing, check elapsed: + +```bash +ELAPSED=$(( $(date +%s) - PC_T0 )) +``` + +- **Under 240s (soft = 4 min):** keep going. +- **240s–360s:** finish the doc/sheet you're currently on, then stop + the loop. Skip remaining items. Add a `TRUNCATED — exceeded + 4-minute soft budget` line to the `Gaps and caveats` section of + `internal_notes.md` listing how many items were skipped. +- **Over 360s (hard cap = 6 min):** STOP IMMEDIATELY. Write what + you have. Add `HARD CAP HIT — exceeded 6-minute hard budget` to + gaps. Exit. + +Wrap each `python3 scripts/drive_search.py` / `gdocs_extract.py` / +`gsheets_extract.py` call with `timeout 30` — no individual extract +should take longer than that: + +```bash +timeout 30 python3 "$SKILL_DIR/scripts/gdocs_extract.py" --doc-id= --max-chars=15000 +``` + +## Files you produce + +Under `$CUSTOMER_DIR/personal_context/`: + +``` +internal_notes.md # 1-page narrative across all sources +sources/ + {doc_or_sheet_slug}.md # one per Doc or Sheet, with verbatim gists + ... +``` + +You do NOT write `personal_context/index.md`. The indexer agent does. + +## How to do the work + +1. **Find candidates.** Three input modes, in priority order: + - **Explicit IDs (`DOC_IDS` / `SHEET_IDS`)**: if set, use these as + the candidate list directly. Skip folder listing and search. + Split on `,` to get individual IDs. Cap doc IDs at 30, sheet IDs + at 20 — drop the tail with a note in `Gaps and caveats` if hit. + - **Folder listing (`DOCS_FOLDER_ID` / `SHEETS_FOLDER_ID`)**: if + no explicit IDs but folder set, list recursively: + `python3 "$SKILL_DIR/scripts/drive_search.py" --folder-id= --mime=document --recursive --json` + Sheets: same with `--mime=spreadsheet`. Cap 30 / 20. + - **Fallback search**: if neither set, search Drive fullText for + `CUSTOMER_NAME` or `PROJECT_ID` + any `SEARCH_TERMS`. Cap 30 / 20. +2. **Pull bodies.** + - Docs: `python3 "$SKILL_DIR/scripts/gdocs_extract.py" --doc-id= --max-chars=15000` + - Sheets: `python3 "$SKILL_DIR/scripts/gsheets_extract.py" --sheet-id= --rows-per-tab=15` + Auth via `GOOGLE_APPLICATION_CREDENTIALS` (already set for you). +3. **For each item, write a source file** at + `$CUSTOMER_DIR/personal_context/sources/.md`. +4. **Write the narrative** at `$CUSTOMER_DIR/personal_context/internal_notes.md`. + +## File-content specs + +### `internal_notes.md` + +```markdown +# Internal team notes — {CUSTOMER_NAME} + +## Summary + +1-2 paragraphs: what kinds of notes does the team keep on this +customer, what are the recurring themes, what's the overall +operational story. Specific. Name owners, mention table names that +appear repeatedly, flag escalations. + +## Key documents + +For each Doc, 2-4 sentences: +- **[Doc title](drive URL)** — modified YYYY-MM-DD by owner@. What it + is, what it covers, what's the most important fact in it. + +## Active trackers (Sheets) + +For each Sheet, 2-4 sentences. Same shape as docs but framed around +"what's tracked" and "current status" rather than "what it explains." + +## Open blockers / escalations / decisions + +Pull together the cross-doc and cross-sheet signal: blockers mentioned +in multiple places, escalating customer accounts, decisions that have +been made (or are pending). Be specific — names, dates, severities. + +## Gaps and caveats + +What you couldn't access, search query used, truncation applied. +``` + +### `sources/.md` + +Follow `$SKILL_DIR/templates/source_format.md`. Two sections required: +`# Retrieved from` (URL, title, last modified, owner, retrieved-at) and +`# Gists` (verbatim quoted excerpts, titled by what they contain). + +Slug naming: `.md`. Strip the customer name +from the slug if it's redundant with the directory. e.g., +"Acme — Pipeline Design Doc (Q1 2026)" becomes +`pipeline-design-doc-q1-2026.md`. Keep slugs short and stable. + +## Claim citations — required in `internal_notes.md` + +Every fact-bearing sentence in `internal_notes.md` must carry a +footnote citation `[^cN]` whose definition declares confidence +(EXTRACTED / INFERRED / AMBIGUOUS) and points at the source file. +Source files themselves (`sources/.md`) are exempt — they are +the source of truth, citing them from themselves is circular. + +Format: + +``` +[^c1]: EXTRACTED · `personal_context/sources/pipeline-design-doc-q1-2026.md#data-flow` · "Partitioned by `order_date`." +[^c2]: INFERRED · derived from `personal_context/sources/pipeline-health-tracker.md#open-issues` +[^c3]: AMBIGUOUS · `pipeline-design-doc-q1-2026.md#status` says ACTIVE; `migration-plan-attribution-v1-to-v2.md#decision` calls it "in cutover" +``` + +The pointer is wiki-relative (NOT absolute). Multiple sources +joined with ` + ` are valid; AMBIGUOUS lists the conflicting +sources with a one-clause description of the conflict. Use IDs +local to the file (`[^c1]`, `[^c2]`, …) — the build rewrites them +into stable content-hash IDs in `internal_notes.claims.json`. + +When you write a `> ...` verbatim quote in `internal_notes.md` +lifted from a source gist, the citation following it is +EXTRACTED. When you summarize across multiple gists in your own +prose, INFERRED. When sources contradict, AMBIGUOUS. + +## Source anchors — required on every gist + +When writing `sources/.md`, give every `## {Gist Title}` a +stable anchor: `## Data flow {#data-flow}`. Anchors should be +kebab-cased, short, and stable across rebuilds. The +`internal_notes.md` cites into these anchors via the footnote +pointer (`...sources/pipeline-design-doc.md#data-flow`). + +## Behavioral notes + +- **Verbatim quotes are load-bearing.** A downstream LLM consuming + this wiki can't read the originals; if you paraphrase, you erase + the customer's actual words. Quote. +- **Pick gists by load-bearing-ness.** Skip TOC, attendee lists, + template scaffolding. Capture: data-flow descriptions, severity + judgments, named blockers, owner attributions, decisions. +- **Surface specific entities.** Table names (`fact_orders_daily`), + owner emails, dollar figures, model versions, dates — these are + what cross-source synthesis later hooks into. +- **Skip obvious test/template files.** If a sheet's title contains + "template", "test", "(copy)", note it in caveats and skip. +- **Don't write index files.** The indexer handles all of those. +- Reply with one line: e.g., + `"wrote internal_notes.md + N source files (N docs, M sheets)"`. Do + NOT include file content in chat. diff --git a/skills/ccb-customer-context-builder/agents/warehouse_agent.md b/skills/ccb-customer-context-builder/agents/warehouse_agent.md new file mode 100644 index 00000000..df012c80 --- /dev/null +++ b/skills/ccb-customer-context-builder/agents/warehouse_agent.md @@ -0,0 +1,365 @@ +# Warehouse Agent + +You are a sub-agent of the gcp-customer-context-builder skill. Your job +is to produce the **data-warehouse subtree** of one customer's context +wiki, by reading from BigQuery and Dataplex and emitting structured +markdown files. You do NOT write `index.md` files — those are produced +by the indexer agent later. You DO produce everything else. + +## Inputs + +- `SKILL_DIR` — absolute path to the skill installation (where templates live) +- `PROJECT_ID` — the GCP project to explore +- `CUSTOMER_NAME` — human-readable name (may equal PROJECT_ID) +- `CUSTOMER_DIR` — absolute path of `/wikis//`, + already created. You write files relative to here. + +## Time budget — soft 8 min, hard 12 min + +Capture your start time at the top of your run: + +```bash +WAREHOUSE_T0=$(date +%s) +``` + +After each major step (listing datasets, finishing a per-table loop, +each Dataplex enumeration), check elapsed time: + +```bash +ELAPSED=$(( $(date +%s) - WAREHOUSE_T0 )) +``` + +- **Under 480s (soft target = 8 min):** keep exploring as planned. +- **480s–720s (between soft and hard):** stop starting new work. + Finalize whatever you have. Skip remaining tables / aspects / + catalog entries you haven't reached. Add a `TRUNCATED — exceeded + 8-minute soft budget` line to the `Gaps and caveats` section of + `data_warehouse.md` listing exactly what was skipped. +- **Over 720s (hard cap = 12 min):** STOP IMMEDIATELY. Write whatever + partial files you have. Do not start any new `bq`/`gcloud` calls. + Add `HARD CAP HIT — exceeded 12-minute hard budget` to the gaps + section with a clear list of files you didn't get to. Exit. + +Wrap individual `bq` and `gcloud` calls with `timeout 60` so a single +hung call can't blow the budget: + +```bash +timeout 60 bq ls --project_id=$PROJECT_ID --format=prettyjson > /tmp/bq_ls.json 2> /tmp/bq_ls.err +``` + +The orchestrator accepts partial output gracefully — a half-built +warehouse subtree is more valuable than no output. The critic surfaces +what's missing. + +## Files you produce + +Under `$CUSTOMER_DIR`: + +``` +data_warehouse.md # Warehouse-wide narrative +retrieval_methods.md # How to fetch more from this customer's GCP +sources/ + bq_dataset_list.md + bq_jobs_by_project.md + bq_scheduled_queries.md # only if scheduled queries exist + dataplex_lakes_list.md # only if API enabled + dataplex_aspect_types_list.md + dataplex_datascans_list.md + dataplex_catalog_search.md + ... (one per warehouse-wide retrieval you actually performed) +{table_name}/ # one directory per BigQuery table + fields.md # column-by-column schema with descriptions + lineage.md # provenance: upstream/downstream, Dataplex catalog entry + sources/ + bq_show_schema.md + bq_show_partitioning.md + bq_query_patterns.md # patterns hitting this specific table + dataplex_catalog_entry.md # only if a Dataplex entry exists for this table + ... +``` + +You do NOT write any `index.md` files. The indexer agent does that. + +## File-content specs + +### `data_warehouse.md` + +A 1–3 paragraph narrative followed by structured sections. **Specific, +not generic** — name the datasets, tables, owners, model versions, +operational issues. Example anchor structure: + +```markdown +# Data warehouse — {CUSTOMER_NAME} + +> One-line summary suitable for the customer-root index. + +## Overview + +{1-3 paragraphs: what this customer does on GCP, what's the shape of +the data estate, what's the dominant operational story (active +migration, ongoing incident, etc.)} + +## Datasets + +For each dataset: id, location, labels, description (if any), table +count. If only one dataset, fold this into Overview. + +## Table inventory + +A markdown table listing every BQ table with: name, type +(TABLE/VIEW/MATERIALIZED_VIEW/EXTERNAL), grain (one-line), partition +column, link to the table's dir. + +## Recent query activity + +2-4 sentences on the dominant query patterns from the last 30 days. +Call out which tables get the most traffic, which patterns dominate +(aggregations vs. lookups vs. exports), any anomalies (tables that +get traffic but are documented as deprecated). + +## Governance posture (Dataplex) + +If Dataplex is in use: lakes/zones layout, aspect types in active use, +DQ scan health. If not: one line saying so. + +## Cross-source operational stories + +This is the synthesis section. Connect facts that span BigQuery, +Dataplex, and personal_context. Examples: +- "Doc X says fact_orders_daily partitioning is broken; query patterns + confirm full-table-scan-shaped queries against it." +- "Dataplex aspect type Y is referenced in the migration doc as the + gating control for X." +If you genuinely can't find cross-source links, write one line saying so +rather than padding. + +## Gaps and caveats + +What you couldn't access, what you skipped, regional fallbacks taken. +``` + +### `retrieval_methods.md` + +Customer-specific instructions for fetching more context. Includes the +actual project ID, region, dataset names, and Drive folder IDs (if any +were provided). Skeleton: + +```markdown +# Retrieval methods — {CUSTOMER_NAME} + +How to pull additional context for this customer beyond what's already +captured in this wiki. + +## Project parameters + +- Project ID: `{PROJECT_ID}` +- BigQuery region: `{region-us | region-eu | etc.}` +- Active dataset(s): `{list}` +- Drive folders: `{docs folder id (or "none configured")}`, `{sheets folder id}` + +## Fetching schemas + +```bash +bq show --schema --format=prettyjson {PROJECT_ID}:DATASET.TABLE +``` + +## Fetching recent query patterns + +```sql +SELECT REGEXP_REPLACE(query, r'\d+', 'N') AS pattern, COUNT(*) AS runs +FROM `{region}`.INFORMATION_SCHEMA.JOBS_BY_PROJECT +WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) +GROUP BY pattern ORDER BY runs DESC LIMIT 50 +``` + +## Fetching Dataplex catalog entries + +```bash +gcloud dataplex entries search --project={PROJECT_ID} --query="..." +``` + +## Fetching Drive content + +(refer to personal_context/sources/* for snapshots already taken; for +fresh reads, use the same Drive folder IDs above with +google-api-python-client + ADC) + +## Auth + +What auth principal was used (gcloud user / service account / ADC), +and what scopes are required. +``` + +### `{table_name}/fields.md` + +```markdown +# Fields — {table_name} + +| Column | Type | Mode | Description | +|---|---|---|---| +| ... | ... | ... | ... | + +## Partition / clustering + +- Partition column: `{col}` (TYPE) +- Clustering: `{cols or "none"}` + +## Notes + +Anything observed during exploration that's worth flagging — schema +drift mentioned in docs, deprecated columns, unusual modes, etc. +``` + +### `{table_name}/lineage.md` + +```markdown +# Lineage — {table_name} + +## Upstream + +What feeds this table. If known from Dataplex catalog, BQ DDL, or +internal docs (referenced by the personal_context layer), enumerate. +If unknown, say so. + +## Downstream + +What reads this table. From query patterns and from internal docs. + +## Dataplex catalog entry + +If present: name, fully-qualified name, aspects attached, last +modified. If not: "no catalog entry beyond the auto-generated +bigquery-table entry" or "Dataplex not in use." + +## Data quality + +Latest data-quality scan result for this table (if any), or "no DQ +scan configured." +``` + +### Source files (everywhere) + +Follow `$SKILL_DIR/templates/source_format.md` exactly. Two required +sections: `# Retrieved from` and `# Gists`. Verbatim snippets, not +paraphrase. + +## How to do the work + +1. **Verify auth.** `gcloud auth list` and check + `$GOOGLE_APPLICATION_CREDENTIALS`. The orchestrator already validated + prereqs; you can skip your own preflight. + +2. **Run BigQuery exploration.** Use `bq` CLI directly. Capture stderr + to /tmp files separately from stdout. For each call, write the JSON + to a temp file and load it with `python3 -c "import json; ..."` + rather than piping JSON through shell strings. + + Steps: + - `bq ls --project_id=$PROJECT_ID --format=prettyjson` — datasets + - For each dataset, `bq ls --max_results=500 PROJECT_ID:DATASET --format=prettyjson` — tables + - For each table, `bq show --schema --format=prettyjson PROJECT_ID:DATASET.TABLE` — schema + - `bq query` against `region-us.INFORMATION_SCHEMA.JOBS_BY_PROJECT` for the last 30 days, top 50 patterns by run_count. Fall back to `region-eu` if no rows. SQL is in your `retrieval_methods.md` template. + - `bq ls --transfer_config --transfer_location=us` — scheduled queries + + Cap at 50 datasets, 200 tables/dataset, 20 priority schemas/dataset + (priority = largest by size, most recently modified, fact_*/dim_*/events_*/_daily/_summary names). + +3. **Run Dataplex exploration.** `gcloud dataplex` CLI directly. Same + discipline — temp files, separate stderr. + + - `gcloud dataplex lakes list --project=$PROJECT_ID --location=- --format=json` + - For each lake, drill down to zones / assets + - `gcloud dataplex aspect-types list --project=$PROJECT_ID --location=- --format=json` + - `gcloud dataplex entries search --project=$PROJECT_ID --query="parent:projects/$PROJECT_ID" --format=json --limit=200` + - `gcloud dataplex datascans list --project=$PROJECT_ID --location=- --format=json` + + API not enabled / no resources is a valid empty result — note in the + gaps section, don't error out. + +4. **Build the per-table dirs.** For each BigQuery table: + - Create `$CUSTOMER_DIR//sources/` + - Write `
/fields.md` from the schema + - Write `
/lineage.md` from BQ + any matching Dataplex catalog entry + - Write `
/sources/bq_show_schema.md` with the schema JSON as a gist + - Write `
/sources/bq_query_patterns.md` filtering JOBS_BY_PROJECT to patterns referencing this table + - Write `
/sources/dataplex_catalog_entry.md` if there's a catalog hit + +5. **Build the warehouse-level files** — `data_warehouse.md`, + `retrieval_methods.md`, and the warehouse-level `sources/*.md`. + +6. **DO NOT** write any `index.md` file. The indexer agent runs after + you and walks the tree. + +## Claim citations — required in every narrative file + +Every fact-bearing sentence in your narrative outputs (`data_warehouse.md`, +`retrieval_methods.md`, `{table}/fields.md` Notes section, and +`{table}/lineage.md`) **must** be followed by a footnote citation +`[^cN]` whose definition declares the confidence band and points at +the source. Three bands: + +- **EXTRACTED** — verbatim quote (`> ...`) lifted unmodified from a + source file, or a single specific value (column name, number, URL) + copied unchanged. The footnote definition includes the verbatim + quote in double-quotes. +- **INFERRED** — paraphrased or synthesized from one or more source + gists. Every load-bearing element in the sentence is directly + supported by something in the cited source(s). +- **AMBIGUOUS** — depends on a judgment call, two sources contradict, + or evidence is weaker than INFERRED requires. Use sparingly; mention + the ambiguity in the file's `Gaps and caveats` section. + +Footnote definition format: + +``` +[^c1]: EXTRACTED · `personal_context/sources/pipeline-design-doc-q1-2026.md#data-flow` · "Partitioned by `order_date`." +[^c2]: INFERRED · derived from `sources/bq_jobs_by_project.md#top-patterns` +[^c3]: AMBIGUOUS · `personal_context/sources/open-blockers-live.md#status` says HIGH; `pipeline-health-tracker.md#open-issues` says MEDIUM +``` + +The source pointer is the wiki-relative path (NOT absolute) optionally +followed by `#anchor`. Multiple sources joined with ` + ` are valid +for EXTRACTED+INFERRED; AMBIGUOUS lists the conflicting sources with +a one-clause description of the conflict. + +**Do not pre-number.** Use `[^c1]`, `[^c2]`, … local to each file. +The build's `claims_sidecar.py` rewrites these into stable +content-hash IDs in `.claims.json` after you finish — you never +touch claim IDs across files. + +**Cite every cross-source claim.** Generic narrative connective +tissue ("The data flows as follows:") doesn't need a citation; every +specific load-bearing claim does. Source files themselves (under +any `sources/` directory) are exempt — the gists *are* the source +of truth, citing a source from itself is circular. + +## Source anchors — required on every gist + +When writing source files, give every `## {Gist Title}` a stable +anchor: `## Data flow {#data-flow}`. Anchors should be kebab-cased, +short, and stable across rebuilds. Narrative files cite into these +anchors via the footnote pointer (`source.md#data-flow`). If you +omit the explicit anchor the build derives one from the title, but +explicit anchors survive title edits. + +## Behavioral notes + +- **Concrete over generic.** Every claim should be specific. "5 tables + in the dataset" is fine; "many tables" is not. Quote specific table + names, owners, dollar figures, dates. +- **Verbatim gists.** Don't paraphrase in source files. The whole point + is to give downstream LLMs material they can quote. +- **Cross-source synthesis is the value-add.** The personal_context + subtree is **already populated** when you start — the orchestrator + guarantees it ran first. Before writing `data_warehouse.md` or any + `{table}/lineage.md`, list `$CUSTOMER_DIR/personal_context/sources/` + and read every `*.md` file there. Cite verbatim quotes (`> ...`) + from those source files when making cross-source claims — these + are EXTRACTED-band citations. Look for table-name mentions in those + gists and connect them to the right per-table `lineage.md`. + **Never assert "personal_context is empty" without first ls-ing + the directory and finding it actually empty.** +- **Don't write index files.** Resist the temptation to write a + `data_warehouse.md`-adjacent index. The indexer handles all of those. +- Write all files and then reply with one line summarizing what you + produced (e.g., `"wrote {N tables, M sources}; data_warehouse and retrieval_methods present"`). Do NOT include file content in chat. diff --git a/skills/ccb-customer-context-builder/examples/customer_manifest.example.yaml b/skills/ccb-customer-context-builder/examples/customer_manifest.example.yaml new file mode 100644 index 00000000..69b2fc94 --- /dev/null +++ b/skills/ccb-customer-context-builder/examples/customer_manifest.example.yaml @@ -0,0 +1,48 @@ +# Example manifest for the gcp-customer-context-builder skill. +# +# Pass this to the skill to control which projects are explored and to +# pin the Drive folders that hold internal notes about each customer. +# +# Save your real manifest as customers.yaml (gitignored) — never commit +# Drive folder IDs that point to internal/customer data into this repo. + +customers: + - project_id: acme-prod-123 + name: Acme Corp + drive: + docs_folder_id: 1AbCdEfGhIjKlMnOpQrStUvWxYz_DOCS + sheets_folder_id: 1AbCdEfGhIjKlMnOpQrStUvWxYz_SHEETS + # Extra keywords to broaden Drive search beyond the project_id and + # customer name (useful when the team uses a code-name internally). + search_terms: + - "Acme" + - "Project Quasar" + + - project_id: bigco-analytics-456 + name: BigCo + # No drive section: the orchestrator runs Drive discovery (recently- + # viewed docs + sheets) and shows you a picker. If you skip the + # picker too, the personal_context agent falls back to keyword + # search using name + project_id. + + - project_id: smallco-data-789 + name: SmallCo + drive: + # Folder-only is fine if you don't want keyword fallback. + docs_folder_id: 1XyZSmallCoFolder + +# Top-level options (all optional). +options: + bq_query_window_days: 30 + bq_max_datasets: 50 + bq_max_tables_per_dataset: 200 + bq_priority_tables_per_dataset: 20 + drive_max_docs_per_customer: 30 + drive_max_sheets_per_customer: 20 + + # Mirror the generated wiki to a GCS bucket after build (optional). + # Auth uses the same gcloud / ADC stack the rest of the skill uses; + # the active gcloud account needs storage.objectAdmin on this bucket. + # Omit to skip the GCS upload step. + gcs_bucket: gs://my-team-customer-wikis/optional/prefix + gcs_delete_extra: true # mirror semantics: remove remote files not in local diff --git a/skills/ccb-customer-context-builder/scripts/acknowledge_drift.py b/skills/ccb-customer-context-builder/scripts/acknowledge_drift.py new file mode 100644 index 00000000..8b333855 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/acknowledge_drift.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Mark a drift entry as acknowledged so it stops appearing in DRIFT.md. + +Acknowledged IDs are persisted to `/.drift-acknowledged.json`. +`source_diff.py` reads this file and filters acknowledged IDs out of the +next report. + +Acknowledgement is by drift ID (e.g. drift-C-1a2b3c4d), which is stable +across runs as long as the (kind, source path) pair is unchanged. If the +same source drifts again later in a different way (e.g. it's deleted +after having been changed), the new entry has a different ID and will +re-appear. + +Usage: + # Acknowledge one entry + python3 acknowledge_drift.py --wiki-root=path --drift-id=drift-C-1a2b3c4d + + # Acknowledge several at once + python3 acknowledge_drift.py --wiki-root=path --drift-id=drift-C-1a2b3c4d --drift-id=drift-D-... + + # Wipe all acknowledgements (start fresh) + python3 acknowledge_drift.py --wiki-root=path --reset +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +from pathlib import Path + +ACK_FILENAME = ".drift-acknowledged.json" + + +def load(path: Path) -> dict: + if not path.is_file(): + return {"schema": "drift_ack.v1", "acknowledged_ids": [], "history": []} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {"schema": "drift_ack.v1", "acknowledged_ids": [], "history": []} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument("--drift-id", action="append", default=[], + help="Drift ID to acknowledge (repeatable).") + ap.add_argument("--note", default="", + help="Optional human note recorded alongside the ack.") + ap.add_argument("--reset", action="store_true", + help="Clear all acknowledgements.") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + ack_path = wiki_root / ACK_FILENAME + + if args.reset: + ack_path.write_text( + json.dumps( + {"schema": "drift_ack.v1", "acknowledged_ids": [], "history": []}, + indent=2, + ) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print(f"reset {ack_path.name} (no acks remain)", file=sys.stderr) + return 0 + + if not args.drift_id: + sys.exit("must pass at least one --drift-id (or --reset).") + + ack = load(ack_path) + existing = set(ack.get("acknowledged_ids", [])) + new_ids = [d for d in args.drift_id if d not in existing] + existing.update(new_ids) + ack["acknowledged_ids"] = sorted(existing) + ack.setdefault("history", []).append({ + "ack_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "ids": args.drift_id, + "note": args.note, + }) + ack_path.write_text( + json.dumps(ack, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print( + f"acknowledged {len(new_ids)} new drift id(s); " + f"{len(existing)} total acks recorded → {ack_path.name}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/build_manifest.py b/skills/ccb-customer-context-builder/scripts/build_manifest.py new file mode 100644 index 00000000..986a79e0 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/build_manifest.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Build a source manifest with content hashes for one customer wiki. + +Walks every `*.md` under any `sources/` directory in the wiki, hashes the +file's content (sha256), and emits `source_manifest.json` at the wiki +root. The manifest is the foundation for: + + - Coverage gap detection (does the source have a fact the wiki misses?) + - Drift detection (compare a fresh manifest against the cached one to + find sources that changed under us) + - Incremental rebuild (cache key = content hash) + +This script reads what's already on disk; it does NOT re-fetch from BigQuery +or Drive. The agents' `# Retrieved from` blocks already contain the source +URI, lineage, and retrieval timestamp; we add the hash so a future run can +diff. + +Usage: + python3 build_manifest.py --wiki-root=path/to/customer + + # Also pin a copy as a named baseline that source_diff --baseline can + # compare against later. The next build_manifest run won't overwrite + # snapshots — they're permanent until you delete them. + python3 build_manifest.py --wiki-root=... --snapshot=qbr-2026-q2 \\ + --note="Pinned at Q2 QBR review" +""" +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import re +import sys +from pathlib import Path + +SOURCE_DIR_NAME = "sources" + +# Pull common metadata fields out of the # Retrieved from block. +META_KEYS = ("Source", "Title", "Lineage", "Absolute path", + "Last modified", "Retrieved at", "Content hash") +META_RE_TPL = r"^[-*]\s+\*\*{key}:\*\*\s+(.+?)\s*$" + + +def parse_retrieved_block(text: str) -> dict[str, str]: + """Pull the metadata bullets out of the `# Retrieved from` section.""" + out: dict[str, str] = {} + # Slice out everything between # Retrieved from and the next H1. + m = re.search( + r"^#\s+Retrieved from\s*$(.+?)(?=^#\s+\w|\Z)", + text, re.MULTILINE | re.DOTALL, + ) + if not m: + return out + block = m.group(1) + for key in META_KEYS: + km = re.search( + META_RE_TPL.format(key=re.escape(key)), + block, re.MULTILINE, + ) + if km: + out[key] = km.group(1).strip().strip("`").strip() + return out + + +def hash_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + +def collect_source_files(wiki_root: Path) -> list[Path]: + """Every *.md under any */sources/ subdir, excluding sources/index.md.""" + out: list[Path] = [] + for src_dir in wiki_root.rglob(SOURCE_DIR_NAME): + if not src_dir.is_dir(): + continue + for p in sorted(src_dir.glob("*.md")): + if p.name == "index.md": + continue + out.append(p) + return out + + +_SLUG_RE = re.compile(r"[^a-zA-Z0-9._-]+") + + +def slugify_snapshot_name(s: str) -> str: + """Conservative filename slug for snapshot names. Keeps dots so e.g. + `qbr-2026.q2` survives unchanged; collapses everything else to `-`. + Empty after slugification → error.""" + s = _SLUG_RE.sub("-", s).strip("-.") + return s + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument( + "--snapshot", default=None, + help="If set, also write a pinned copy of the manifest at " + "/snapshots/.json. Future build_manifest runs " + "won't overwrite it. source_diff.py --baseline= can " + "then diff against this snapshot.", + ) + ap.add_argument( + "--note", default="", + help="Optional human note recorded in the snapshot file. Only " + "used when --snapshot is set.", + ) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + entries: list[dict] = [] + for sf in collect_source_files(wiki_root): + rel = sf.relative_to(wiki_root).as_posix() + text = sf.read_text(encoding="utf-8", errors="replace") + meta = parse_retrieved_block(text) + # Hash the file's full content (gist + retrieved-from). We could hash + # only the gists, but using the whole file means edits to the metadata + # block (e.g. a re-run that pulls fresh content) also bump the hash — + # which is what drift detection wants. + entries.append({ + "path": rel, + "sha256": hash_text(text), + "size": len(text), + "source_uri": meta.get("Source", ""), + "lineage": meta.get("Lineage", ""), + "title": meta.get("Title", ""), + "last_modified": meta.get("Last modified", ""), + "retrieved_at": meta.get("Retrieved at", ""), + }) + + payload = { + "schema": "source_manifest.v1", + "wiki_root": wiki_root.name, + "built_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "source_count": len(entries), + "sources": entries, + } + out_path = wiki_root / "source_manifest.json" + out_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print(f"manifest: {len(entries)} sources hashed → {out_path.name}", + file=sys.stderr) + + if args.snapshot: + slug = slugify_snapshot_name(args.snapshot) + if not slug: + sys.exit(f"--snapshot name slugifies to empty: {args.snapshot!r}") + snap_dir = wiki_root / "snapshots" + snap_dir.mkdir(exist_ok=True) + snap_path = snap_dir / f"{slug}.json" + snap_payload = { + **payload, + "snapshot_name": slug, + "snapshot_note": args.note, + "snapshotted_at": payload["built_at"], + } + snap_path.write_text( + json.dumps(snap_payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print(f"snapshot: pinned {len(entries)} sources → {snap_path.relative_to(wiki_root)}", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/check_prereqs.sh b/skills/ccb-customer-context-builder/scripts/check_prereqs.sh new file mode 100755 index 00000000..93f52a7e --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/check_prereqs.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Preflight check for the gcp-customer-context-builder skill. +# Exits 0 if everything is ready; non-zero with a remediation message otherwise. +set -u + +ok=true +fail() { echo "MISSING: $1"; echo " fix: $2"; ok=false; } + +command -v gcloud >/dev/null 2>&1 || fail "gcloud CLI" \ + "install Google Cloud SDK: https://cloud.google.com/sdk/docs/install" +command -v bq >/dev/null 2>&1 || fail "bq CLI" \ + "install Google Cloud SDK (includes bq): https://cloud.google.com/sdk/docs/install" +command -v python3 >/dev/null 2>&1 || fail "python3" \ + "install Python 3.9 or newer" + +if command -v gcloud >/dev/null 2>&1; then + active=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null) + [ -n "$active" ] || fail "gcloud not authenticated" \ + "run: gcloud auth login" +fi + +# Auth for Drive/Docs/Sheets APIs. Two valid paths: +# 1. GOOGLE_APPLICATION_CREDENTIALS env var pointing to a service-account JSON +# 2. Application Default Credentials from `gcloud auth application-default login` +if [ -n "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then + [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ] || fail \ + "GOOGLE_APPLICATION_CREDENTIALS=$GOOGLE_APPLICATION_CREDENTIALS but file does not exist" \ + "either fix the path or unset the env var to fall back to user ADC" +else + adc_path="${HOME}/.config/gcloud/application_default_credentials.json" + [ -f "$adc_path" ] || fail "Application Default Credentials" \ + "either: (a) gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/documents.readonly,https://www.googleapis.com/auth/spreadsheets.readonly OR (b) export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json" +fi + +if command -v python3 >/dev/null 2>&1; then + python3 -c "import googleapiclient, google.auth" 2>/dev/null || fail \ + "Python deps (google-api-python-client, google-auth)" \ + "run: pip install -r $(dirname "$0")/requirements.txt" +fi + +if $ok; then + echo "All prerequisites OK." + exit 0 +else + exit 1 +fi diff --git a/skills/ccb-customer-context-builder/scripts/claims_sidecar.py b/skills/ccb-customer-context-builder/scripts/claims_sidecar.py new file mode 100644 index 00000000..087087d7 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/claims_sidecar.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Parse claim-citation footnotes from a wiki tree and emit sidecar JSON. + +Every narrative file (data_warehouse.md, internal_notes.md, lineage.md, +fields.md, index.md) embeds claim citations as Markdown footnotes: + + The fact_orders_daily table is partitioned by `order_date`.[^c1] + + [^c1]: EXTRACTED · `personal_context/sources/pipeline-design-doc.md#data-flow` · "Partitioned by `order_date`." + +This script walks the wiki, extracts every `[^cN]` footnote and its +definition, validates the format, and writes a sidecar +`.claims.json` next to each markdown file. It also writes a +roll-up `claims_index.json` at the wiki root listing every claim with +its file path + stable content-hash ID. + +The on-disk markdown stays human-readable; the sidecar is what +gap_check.py, the critic, and the wiki-viewer side panel consume. + +Usage: + python3 claims_sidecar.py --wiki-root=path/to/customer + python3 claims_sidecar.py --wiki-root=path/to/customer --report + python3 claims_sidecar.py --wiki-root=path/to/customer --sample-extracted=10 +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from dataclasses import asdict, dataclass, field +from pathlib import Path + +VALID_TAGS = ("EXTRACTED", "INFERRED", "AMBIGUOUS") + +# Files we never extract claims from — they're either source-of-truth gists +# or pure navigation. +SKIP_DIR_NAMES = ("sources",) + +# Match a footnote reference in body text: [^c1], [^c42], [^c123]. +FOOTNOTE_REF_RE = re.compile(r"\[\^c(\d+)\]") + +# Match a footnote definition: [^c1]: TAG · `path#anchor` · "quote" +# We accept loose spacing; the parser splits on the first two `·` separators. +FOOTNOTE_DEF_RE = re.compile( + r"^\s*\[\^c(\d+)\]:\s*(.+?)\s*$", + re.MULTILINE, +) + +# Slugify a gist title to its anchor (mirrors what GitHub Markdown does +# minus the trailing-number disambiguation). +_SLUG_NONALNUM = re.compile(r"[^a-z0-9]+") + + +@dataclass +class Claim: + id: str # stable: sha256(file_path + body + def)[:12] + local_id: int # the N in [^cN]; local to the file + file: str # wiki-relative path + tag: str # EXTRACTED | INFERRED | AMBIGUOUS + sources: list[str] = field(default_factory=list) # ["path#anchor", ...] + quote: str | None = None # the verbatim text for EXTRACTED + raw: str = "" # the raw footnote definition body + line: int = 0 # 1-indexed line in the source markdown + + +@dataclass +class ParseError: + file: str + line: int + local_id: int | None + kind: str + detail: str + + +def slugify_anchor(title: str) -> str: + s = _SLUG_NONALNUM.sub("-", title.lower()).strip("-") + return s or "section" + + +def collect_anchors(file_text: str) -> set[str]: + """Return the set of anchor slugs declared in a file. + + Picks up both explicit anchors `## Title {#anchor-slug}` and the + derived-from-title slug. The build hashes both so footnote pointers + can use either form. + """ + anchors: set[str] = set() + for line in file_text.splitlines(): + m = re.match(r"^#{1,6}\s+(.+?)(?:\s+\{#([a-z0-9\-]+)\})?\s*$", line) + if not m: + continue + title, explicit = m.group(1), m.group(2) + if explicit: + anchors.add(explicit) + # Strip the anchor decoration from title before slugifying + title_clean = re.sub(r"\s*\{#[a-z0-9\-]+\}\s*$", "", title) + anchors.add(slugify_anchor(title_clean)) + return anchors + + +def parse_footnote_definition(body: str) -> tuple[str, list[str], str | None, list[str]]: + """Parse a footnote definition body. Returns (tag, sources, quote, errors). + + Body shape (· is the bullet separator U+00B7): + EXTRACTED · `source-a.md#anchor` · "verbatim quote" + INFERRED · derived from `source-a.md#anchor` + `source-b.md` + AMBIGUOUS · `source-a.md#x` says X; `source-b.md#y` says Y + + Three positional, ·-separated fields: + 1. Tag (EXTRACTED | INFERRED | AMBIGUOUS) + 2. Source-pointer block — backticked path(s); may include prose + ("derived from", "says X") around them + 3. Verbatim quote (EXTRACTED only) — last "..." substring + + The verbatim quote can itself contain backticks, so we ONLY scan field + 2 for source pointers — never field 3. + """ + errors: list[str] = [] + parts = [p.strip() for p in body.split("·")] + if len(parts) < 2: + return "", [], None, [f"footnote body has fewer than 2 ·-separated parts: {body!r}"] + tag_part = parts[0].strip().upper() + if tag_part not in VALID_TAGS: + errors.append(f"unknown tag {tag_part!r} (expected one of {VALID_TAGS})") + return tag_part, [], None, errors + + # Sources live ONLY in field 2 (parts[1]). Field 3+ is the verbatim quote + # for EXTRACTED, and that quote can contain backticks of its own. AMBIGUOUS + # bodies often contain non-source code spans too (regex patterns, table + # names quoted from the conflicting sources) — so we filter to backticked + # things that LOOK like source pointers: must contain '/' (a directory + # separator) OR end in '.md' (a file extension). This rejects bare + # identifiers like `attribution_summary_vN` while still accepting + # `sources/bq_show_schema.md` and `pipeline-design-doc-q1-2026.md#anchor`. + source_block = parts[1] if len(parts) > 1 else "" + raw_backticks = re.findall(r"`([^`]+)`", source_block) + sources = [b for b in raw_backticks if "/" in b or b.endswith(".md") or "#" in b] + if not sources: + errors.append( + "no backticked source pointer found in footnote field 2 " + "(must contain '/' or end in '.md')" + ) + + quote: str | None = None + if tag_part == "EXTRACTED": + # Quote lives in field 3+. Re-join parts[2:] in case the quote itself + # contained a · (rare; survives the round-trip). + quote_block = " · ".join(parts[2:]) if len(parts) > 2 else "" + qm = list(re.finditer(r'"([^"]*)"', quote_block)) + if qm: + quote = qm[-1].group(1) + else: + errors.append("EXTRACTED footnote has no quoted verbatim string in field 3") + + return tag_part, sources, quote, errors + + +def parse_file(file_path: Path, wiki_root: Path) -> tuple[list[Claim], list[ParseError]]: + """Extract every claim from one markdown file. + + Returns (claims, errors). Files under any */sources/ directory return + empty lists — they're source-of-truth and don't carry citations. + """ + rel = file_path.relative_to(wiki_root).as_posix() + if any(part in SKIP_DIR_NAMES for part in file_path.relative_to(wiki_root).parts[:-1]): + return [], [] + + text = file_path.read_text(encoding="utf-8", errors="replace") + + # Track each footnote def with its line number for better error reporting. + def_lines: dict[int, tuple[int, str]] = {} + for line_idx, line in enumerate(text.splitlines(), start=1): + m = re.match(r"^\s*\[\^c(\d+)\]:\s*(.+?)\s*$", line) + if m: + local_id = int(m.group(1)) + def_lines[local_id] = (line_idx, m.group(2)) + + # Find every footnote reference in body (first occurrence wins for line). + ref_lines: dict[int, int] = {} + for line_idx, line in enumerate(text.splitlines(), start=1): + for m in FOOTNOTE_REF_RE.finditer(line): + local_id = int(m.group(1)) + ref_lines.setdefault(local_id, line_idx) + + claims: list[Claim] = [] + errors: list[ParseError] = [] + + # Every reference needs a definition. + for local_id, ref_line in ref_lines.items(): + if local_id not in def_lines: + errors.append(ParseError( + file=rel, line=ref_line, local_id=local_id, + kind="missing_definition", + detail=f"[^c{local_id}] referenced at line {ref_line} but no [^c{local_id}]: definition found", + )) + + # Every definition needs a reference. + for local_id, (def_line, body) in def_lines.items(): + if local_id not in ref_lines: + errors.append(ParseError( + file=rel, line=def_line, local_id=local_id, + kind="orphan_definition", + detail=f"[^c{local_id}]: defined at line {def_line} but never referenced in body", + )) + continue + tag, sources, quote, parse_errs = parse_footnote_definition(body) + for pe in parse_errs: + errors.append(ParseError( + file=rel, line=def_line, local_id=local_id, + kind="malformed_definition", + detail=pe, + )) + if not tag or tag not in VALID_TAGS: + continue + # Stable ID: hash file + body + the referencing line context. + h = hashlib.sha256( + f"{rel}|{local_id}|{body}".encode("utf-8") + ).hexdigest()[:12] + claims.append(Claim( + id=h, local_id=local_id, file=rel, + tag=tag, sources=sources, quote=quote, + raw=body, line=ref_lines[local_id], + )) + claims.sort(key=lambda c: c.local_id) + return claims, errors + + +def resolve_source_pointer( + pointer: str, wiki_root: Path, +) -> tuple[Path | None, str | None, str | None]: + """Split a `path#anchor` pointer; return (resolved path, anchor, error).""" + if "#" in pointer: + path_part, anchor = pointer.split("#", 1) + else: + path_part, anchor = pointer, None + candidate = (wiki_root / path_part).resolve() + try: + candidate.relative_to(wiki_root.resolve()) + except ValueError: + return None, anchor, f"pointer escapes wiki root: {pointer}" + if not candidate.is_file(): + return None, anchor, f"pointer file not found: {path_part}" + return candidate, anchor, None + + +def validate_claims( + claims: list[Claim], wiki_root: Path, *, sample_extracted: int = 0, +) -> list[ParseError]: + """Cross-file validation: pointed-at sources must exist, anchors must + resolve, and EXTRACTED quotes must literal-substring-match the gist.""" + errors: list[ParseError] = [] + extracted_seen = 0 + for claim in claims: + for src in claim.sources: + path, anchor, err = resolve_source_pointer(src, wiki_root) + if err: + errors.append(ParseError( + file=claim.file, line=claim.line, local_id=claim.local_id, + kind="dangling_source", + detail=err, + )) + continue + if anchor: + src_text = path.read_text(encoding="utf-8", errors="replace") + anchors = collect_anchors(src_text) + if anchor not in anchors: + errors.append(ParseError( + file=claim.file, line=claim.line, local_id=claim.local_id, + kind="missing_anchor", + detail=f"anchor #{anchor} not found in {src}", + )) + # EXTRACTED literal-substring check, sampled + if ( + claim.tag == "EXTRACTED" and claim.quote + and (sample_extracted == 0 or extracted_seen < sample_extracted) + ): + extracted_seen += 1 + src_text = path.read_text(encoding="utf-8", errors="replace") \ + if path.is_file() else "" + # Normalize: strip leading blockquote markers ("> "), collapse + # whitespace runs, lowercase. The quote in the citation is + # typically a single line, while the source has it as a multi-line + # blockquote — without these strips the substring won't match. + src_no_bq = re.sub(r"^\s*>\s?", "", src_text, flags=re.MULTILINE) + norm_src = re.sub(r"\s+", " ", src_no_bq).lower() + norm_quote = re.sub(r"\s+", " ", claim.quote).lower() + if norm_quote and norm_quote not in norm_src: + errors.append(ParseError( + file=claim.file, line=claim.line, local_id=claim.local_id, + kind="extracted_mismatch", + detail=f"verbatim quote not found in {src}: {claim.quote[:80]!r}", + )) + return errors + + +def find_narrative_files(wiki_root: Path) -> list[Path]: + out: list[Path] = [] + for p in sorted(wiki_root.rglob("*.md")): + # Skip files under any sources/ subdir. + rel_parts = p.relative_to(wiki_root).parts + if any(part in SKIP_DIR_NAMES for part in rel_parts[:-1]): + continue + out.append(p) + return out + + +def write_sidecars( + wiki_root: Path, claims_by_file: dict[str, list[Claim]], +) -> None: + for rel, claims in claims_by_file.items(): + sidecar = wiki_root / (rel + ".claims.json") + sidecar.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema": "claims.v1", + "file": rel, + "claim_count": len(claims), + "by_band": { + band: sum(1 for c in claims if c.tag == band) + for band in VALID_TAGS + }, + "claims": [asdict(c) for c in claims], + } + sidecar.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +def write_index(wiki_root: Path, all_claims: list[Claim], errors: list[ParseError]) -> Path: + payload = { + "schema": "claims_index.v1", + "wiki_root": wiki_root.name, + "claim_count": len(all_claims), + "by_band": { + band: sum(1 for c in all_claims if c.tag == band) + for band in VALID_TAGS + }, + "files_with_claims": sorted({c.file for c in all_claims}), + "errors": [asdict(e) for e in errors], + } + out_path = wiki_root / "claims_index.json" + out_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return out_path + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True, + help="Customer wiki root (e.g. .../wikis//).") + ap.add_argument("--report", action="store_true", + help="Print a human-readable summary to stderr.") + ap.add_argument("--sample-extracted", type=int, default=10, + help="Sample N EXTRACTED claims for verbatim-substring " + "verification (0 = all). Default 10.") + ap.add_argument("--quiet", action="store_true", + help="Suppress non-error output.") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + md_files = find_narrative_files(wiki_root) + claims_by_file: dict[str, list[Claim]] = {} + all_claims: list[Claim] = [] + all_errors: list[ParseError] = [] + + for md in md_files: + claims, errs = parse_file(md, wiki_root) + rel = md.relative_to(wiki_root).as_posix() + # Always write a sidecar (even if empty) so the dep graph can rely on + # one-per-file. Skip files with no claims AND no errors? No — empty + # sidecar is a valid signal that the file has been processed. + claims_by_file[rel] = claims + all_claims.extend(claims) + all_errors.extend(errs) + + # Cross-file validation + all_errors.extend(validate_claims( + all_claims, wiki_root, sample_extracted=args.sample_extracted, + )) + + write_sidecars(wiki_root, claims_by_file) + index_path = write_index(wiki_root, all_claims, all_errors) + + if not args.quiet: + print( + f"claims: {len(all_claims)} across {len(claims_by_file)} files; " + f"errors: {len(all_errors)}; index: {index_path.name}", + file=sys.stderr, + ) + if args.report: + bands = {b: sum(1 for c in all_claims if c.tag == b) for b in VALID_TAGS} + print("Band counts:", file=sys.stderr) + for b in VALID_TAGS: + print(f" {b:10} {bands[b]}", file=sys.stderr) + if all_errors: + print(f"\nErrors ({len(all_errors)}):", file=sys.stderr) + for e in all_errors[:50]: + print(f" [{e.kind}] {e.file}:{e.line} {e.detail}", + file=sys.stderr) + if len(all_errors) > 50: + print(f" ... {len(all_errors) - 50} more", file=sys.stderr) + + # Exit non-zero if HIGH-severity errors (extracted mismatch, dangling + # source) were found — useful in CI but the critic surfaces them too. + high_kinds = {"extracted_mismatch", "dangling_source", "missing_definition"} + high_count = sum(1 for e in all_errors if e.kind in high_kinds) + return 0 if high_count == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/dep_graph.py b/skills/ccb-customer-context-builder/scripts/dep_graph.py new file mode 100644 index 00000000..32a8ec64 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/dep_graph.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Build the section → claims → sources dependency graph for one wiki. + +Reads `claims_index.json` (from claims_sidecar.py) and +`source_manifest.json` (from build_manifest.py); writes `dep_graph.json` +at the wiki root. The graph is the spine that everything downstream +consumes: + + - gap_check.py uses it to compute the "explicit" wiki graph + (narrative section → cited source). + - The wiki-viewer side panel uses it to surface "this page is cited + in N other pages" backlinks (TODO: not rendered yet). + - rebuild_plan.py uses it as the blast-radius lookup for a changed + source — drift × dep_graph → which narrative sections need to be + re-derived. + +Schema: + { + "schema": "dep_graph.v1", + "wiki_root": "...", + "nodes": { + "": { "kind": "narrative"|"source", "claims": N, "sources_cited": M } + }, + "edges": [ + { "from": "", "to": "", "claim_id": "...", "tag": "EXTRACTED" } + ] + } + +Usage: + python3 dep_graph.py --wiki-root=path/to/customer +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path + + +def load_json(p: Path) -> dict | None: + if not p.is_file(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + print(f"warning: {p} is not valid JSON: {e}", file=sys.stderr) + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + claims_index = load_json(wiki_root / "claims_index.json") + source_manifest = load_json(wiki_root / "source_manifest.json") + if claims_index is None: + sys.exit("claims_index.json missing — run claims_sidecar.py first") + if source_manifest is None: + sys.exit("source_manifest.json missing — run build_manifest.py first") + + # Per-file claim sidecars carry the actual claim records. + claims_by_file: dict[str, list[dict]] = {} + for rel in claims_index.get("files_with_claims", []): + sidecar = wiki_root / (rel + ".claims.json") + sc = load_json(sidecar) + if sc: + claims_by_file[rel] = sc.get("claims", []) + + source_paths = {s["path"] for s in source_manifest.get("sources", [])} + + nodes: dict[str, dict] = {} + for rel in source_paths: + nodes[rel] = {"kind": "source", "claims": 0, "sources_cited": 0} + for rel, claims in claims_by_file.items(): + nodes.setdefault(rel, {"kind": "narrative", "claims": 0, "sources_cited": 0}) + nodes[rel]["claims"] = len(claims) + + edges: list[dict] = [] + cited_by: dict[str, set[str]] = defaultdict(set) + for rel, claims in claims_by_file.items(): + cites_for_file: set[str] = set() + for claim in claims: + for src in claim.get("sources", []): + # Strip #anchor for graph purposes. + src_path = src.split("#", 1)[0] + edges.append({ + "from": rel, + "to": src_path, + "claim_id": claim.get("id"), + "tag": claim.get("tag"), + }) + cites_for_file.add(src_path) + cited_by[src_path].add(rel) + nodes[rel]["sources_cited"] = len(cites_for_file) + + # Add a backlink count to source nodes so the side panel can show + # "cited in N narrative files" without recomputing. + for src_path, citers in cited_by.items(): + if src_path in nodes: + nodes[src_path]["cited_by_count"] = len(citers) + nodes[src_path]["cited_by"] = sorted(citers) + + payload = { + "schema": "dep_graph.v1", + "wiki_root": wiki_root.name, + "node_count": len(nodes), + "edge_count": len(edges), + "nodes": nodes, + "edges": edges, + } + out_path = wiki_root / "dep_graph.json" + out_path.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print( + f"dep graph: {len(nodes)} nodes, {len(edges)} edges → {out_path.name}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/discover_drive_docs.py b/skills/ccb-customer-context-builder/scripts/discover_drive_docs.py new file mode 100755 index 00000000..fb0800c7 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/discover_drive_docs.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Discover candidate Drive docs/sheets the user has recently viewed. + +Pulls the most recently *viewed-by-me* Google Docs and Sheets, fetches a +short content excerpt for each (or tab list, for sheets), and emits a +JSON list. The orchestrator (Claude) then ranks candidates by relevance +to the customer and presents a picker — discovery itself is unranked +and unfiltered, so the same output can be reused across customers. + +Auth: same Drive readonly scope as drive_search.py (ADC). + +If the auth principal has no view history (e.g., a service account +without domain-wide delegation), `viewedByMe=true` returns nothing. +The script auto-falls-back to `orderBy=modifiedTime desc` and adds a +warning so the orchestrator knows the signal is weaker. + +Output (stdout): + { + "candidates": [ + { + "id": "1abc...", + "name": "Pipeline Design Doc", + "kind": "document" | "spreadsheet", + "modifiedTime": "...", + "viewedByMeTime": "..." | null, + "owners": ["jordan@acme.example.com"], + "webViewLink": "https://docs.google.com/...", + "parents": [{"id": "...", "name": "Acme - Attribution"}], + "excerpt": "first ~800 chars of body, or 'Tabs: a, b, c' for sheets" + }, ... + ], + "stats": {"total": N, "with_excerpts": M, "ranking_signal": "viewedByMe" | "modifiedTime"}, + "warnings": [...] + } +""" +from __future__ import annotations + +import argparse +import json +import socket +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Iterator + +import google.auth +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +# httplib2's default socket timeout is short; bump it so parallel doc-body +# fetches don't trip the read timeout under contention. +socket.setdefaulttimeout(30) + +MIME = { + "document": "application/vnd.google-apps.document", + "spreadsheet": "application/vnd.google-apps.spreadsheet", + "folder": "application/vnd.google-apps.folder", +} + +FILE_FIELDS = ( + "nextPageToken, files(id, name, mimeType, modifiedTime, " + "viewedByMeTime, webViewLink, owners(emailAddress), parents)" +) + + +def build_services(): + creds, _ = google.auth.default( + scopes=[ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ] + ) + drive = build("drive", "v3", credentials=creds, cache_discovery=False) + docs = build("docs", "v1", credentials=creds, cache_discovery=False) + sheets = build("sheets", "v4", credentials=creds, cache_discovery=False) + return drive, docs, sheets + + +def list_recent(drive, mime: str, limit: int, by_viewed: bool) -> Iterator[dict]: + """Page through Drive ordered by view-time (or fall back to modified-time).""" + base_q = f"mimeType='{MIME[mime]}' and trashed=false" + if by_viewed: + q = f"{base_q} and viewedByMe=true" + order = "viewedByMeTime desc" + else: + q = base_q + order = "modifiedTime desc" + + page_token = None + seen = 0 + while seen < limit: + resp = drive.files().list( + q=q, + fields=FILE_FIELDS, + pageSize=min(100, limit - seen), + pageToken=page_token, + orderBy=order, + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ).execute() + files = resp.get("files", []) + if not files: + return + for f in files: + yield f + seen += 1 + if seen >= limit: + return + page_token = resp.get("nextPageToken") + if not page_token: + return + + +def fetch_recent_with_fallback(drive, mime: str, limit: int) -> tuple[list[dict], str]: + """Try viewedByMe first; if zero results, retry with modifiedTime.""" + try: + files = list(list_recent(drive, mime, limit, by_viewed=True)) + if files: + return files, "viewedByMe" + except HttpError: + pass + files = list(list_recent(drive, mime, limit, by_viewed=False)) + return files, "modifiedTime" + + +def resolve_parent(drive, folder_id: str, cache: dict) -> str: + if folder_id in cache: + return cache[folder_id] + try: + meta = drive.files().get( + fileId=folder_id, + fields="id, name", + supportsAllDrives=True, + ).execute() + cache[folder_id] = meta.get("name", "(unknown)") + except HttpError: + cache[folder_id] = "(inaccessible)" + return cache[folder_id] + + +def fetch_doc_excerpt(docs_svc, doc_id: str, max_chars: int) -> str: + """First N chars of doc body, no heading markup. Cheap signal for ranking.""" + try: + doc = docs_svc.documents().get(documentId=doc_id).execute() + except HttpError as e: + return f"(fetch error: HTTP {getattr(e, 'resp', None) and e.resp.status})" + except Exception as e: # noqa: BLE001 — surface any auth/quota error to JSON + return f"(fetch error: {e})" + + parts = [] + total = 0 + for el in doc.get("body", {}).get("content", []): + p = el.get("paragraph") + if not p: + continue + for sub in p.get("elements", []): + tr = sub.get("textRun") + if not tr: + continue + text = tr.get("content", "") + parts.append(text) + total += len(text) + if total >= max_chars: + break + if total >= max_chars: + break + return "".join(parts).strip()[:max_chars] + + +def fetch_sheet_excerpt(sheets_svc, sheet_id: str) -> str: + """Tab names — cheap, decent signal (e.g. 'Tabs: uptime, latency, incidents').""" + try: + meta = sheets_svc.spreadsheets().get( + spreadsheetId=sheet_id, includeGridData=False + ).execute() + except HttpError as e: + return f"(fetch error: HTTP {getattr(e, 'resp', None) and e.resp.status})" + except Exception as e: # noqa: BLE001 + return f"(fetch error: {e})" + tabs = [s["properties"]["title"] for s in meta.get("sheets", [])][:10] + return "Tabs: " + ", ".join(tabs) if tabs else "(no tabs)" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--max-recent", type=int, default=200, + help="Cap on how many recent docs+sheets to surface (default 200).") + ap.add_argument("--max-excerpts", type=int, default=60, + help="Cap on how many of the most-recent items get excerpts fetched (default 60).") + ap.add_argument("--max-chars", type=int, default=800, + help="Per-doc excerpt length (default 800 chars).") + ap.add_argument("--concurrency", type=int, default=8, + help="Parallel excerpt fetches (default 8).") + args = ap.parse_args() + + try: + drive, docs_svc, sheets_svc = build_services() + except Exception as e: # noqa: BLE001 + print(json.dumps({"error": f"auth failed: {e}"}), file=sys.stderr) + sys.exit(2) + + warnings: list[str] = [] + + half = max(1, args.max_recent // 2) + docs_files, docs_signal = fetch_recent_with_fallback(drive, "document", half) + sheets_files, sheets_signal = fetch_recent_with_fallback(drive, "spreadsheet", half) + + # If both fell back, the principal almost certainly has no view history. + if docs_signal == "modifiedTime" and sheets_signal == "modifiedTime": + warnings.append( + "viewedByMe returned no results; falling back to modifiedTime ordering. " + "If you authed with a service account, run " + "`gcloud auth application-default login` so discovery can use your view history." + ) + ranking_signal = docs_signal if docs_signal == sheets_signal else "mixed" + + # Combine + sort by viewed-or-modified time desc, cap to max-recent total. + all_files = docs_files + sheets_files + all_files.sort( + key=lambda f: f.get("viewedByMeTime") or f.get("modifiedTime", ""), + reverse=True, + ) + all_files = all_files[: args.max_recent] + + # Resolve parent folder names (cached — many docs share a parent). + parent_cache: dict[str, str] = {} + for f in all_files: + for pid in f.get("parents", []) or []: + resolve_parent(drive, pid, parent_cache) + + # Fetch excerpts for the top N (most recent of the combined set). + excerpt_targets = all_files[: args.max_excerpts] + excerpts: dict[str, str] = {} + with ThreadPoolExecutor(max_workers=args.concurrency) as ex: + futures = {} + for f in excerpt_targets: + mt = f["mimeType"] + if mt == MIME["document"]: + futures[ex.submit(fetch_doc_excerpt, docs_svc, f["id"], args.max_chars)] = f["id"] + elif mt == MIME["spreadsheet"]: + futures[ex.submit(fetch_sheet_excerpt, sheets_svc, f["id"])] = f["id"] + for fut in as_completed(futures): + fid = futures[fut] + try: + excerpts[fid] = fut.result() + except Exception as e: # noqa: BLE001 + excerpts[fid] = f"(fetch error: {e})" + + candidates = [] + for f in all_files: + kind = "document" if f["mimeType"] == MIME["document"] else "spreadsheet" + candidates.append({ + "id": f["id"], + "name": f["name"], + "kind": kind, + "modifiedTime": f.get("modifiedTime"), + "viewedByMeTime": f.get("viewedByMeTime"), + "owners": [o.get("emailAddress") for o in f.get("owners", [])], + "webViewLink": f.get("webViewLink"), + "parents": [ + {"id": pid, "name": parent_cache.get(pid, "(unknown)")} + for pid in (f.get("parents") or []) + ], + "excerpt": excerpts.get(f["id"]), + }) + + out = { + "candidates": candidates, + "stats": { + "total": len(candidates), + "with_excerpts": sum(1 for c in candidates if c["excerpt"]), + "ranking_signal": ranking_signal, + }, + "warnings": warnings, + } + print(json.dumps(out, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-customer-context-builder/scripts/drive_search.py b/skills/ccb-customer-context-builder/scripts/drive_search.py new file mode 100755 index 00000000..5f80750d --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/drive_search.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Search Google Drive for Docs / Sheets matching a folder or keyword. + +Auth: Application Default Credentials. Run + gcloud auth application-default login \\ + --scopes=https://www.googleapis.com/auth/drive.readonly,... +once before using this. + +Output: JSON list of {id, name, mimeType, modifiedTime, webViewLink, owners}. +""" +from __future__ import annotations + +import argparse +import json +import sys +from typing import Iterator + +import google.auth +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +MIME = { + "document": "application/vnd.google-apps.document", + "spreadsheet": "application/vnd.google-apps.spreadsheet", + "folder": "application/vnd.google-apps.folder", +} + + +def build_service(): + creds, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/drive.readonly"] + ) + return build("drive", "v3", credentials=creds, cache_discovery=False) + + +def list_in_folder(svc, folder_id: str, mime: str, recursive: bool) -> Iterator[dict]: + stack = [folder_id] + seen_folders: set[str] = set() + while stack: + fid = stack.pop() + if fid in seen_folders: + continue + seen_folders.add(fid) + page_token = None + while True: + resp = svc.files().list( + q=f"'{fid}' in parents and trashed=false", + fields="nextPageToken, files(id, name, mimeType, modifiedTime, webViewLink, owners(emailAddress))", + pageSize=200, + pageToken=page_token, + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ).execute() + for f in resp.get("files", []): + if recursive and f["mimeType"] == MIME["folder"]: + stack.append(f["id"]) + if f["mimeType"] == MIME[mime]: + yield f + page_token = resp.get("nextPageToken") + if not page_token: + break + + +def search_keyword(svc, query: str, mime: str) -> Iterator[dict]: + # Drive's `fullText contains` does substring on title and body. + # Multiple OR-joined terms => match any. + terms = [t.strip() for t in query.split(" OR ") if t.strip()] + full_text = " or ".join(f"fullText contains '{t}'" for t in terms) + q = f"({full_text}) and mimeType='{MIME[mime]}' and trashed=false" + page_token = None + while True: + resp = svc.files().list( + q=q, + fields="nextPageToken, files(id, name, mimeType, modifiedTime, webViewLink, owners(emailAddress))", + pageSize=200, + pageToken=page_token, + supportsAllDrives=True, + includeItemsFromAllDrives=True, + orderBy="modifiedTime desc", + ).execute() + yield from resp.get("files", []) + page_token = resp.get("nextPageToken") + if not page_token: + break + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--folder-id", help="Drive folder ID to list") + p.add_argument("--search", help="Keyword query (use ' OR ' between terms)") + p.add_argument("--mime", choices=list(MIME), required=True) + p.add_argument("--recursive", action="store_true") + p.add_argument("--limit", type=int, default=200) + p.add_argument("--json", action="store_true") + args = p.parse_args() + + if not args.folder_id and not args.search: + p.error("must give --folder-id or --search") + + try: + svc = build_service() + except Exception as e: + print(json.dumps({"error": f"auth failed: {e}"}), file=sys.stderr) + sys.exit(2) + + try: + if args.folder_id: + it = list_in_folder(svc, args.folder_id, args.mime, args.recursive) + else: + it = search_keyword(svc, args.search, args.mime) + results = [] + for f in it: + results.append(f) + if len(results) >= args.limit: + break + except HttpError as e: + print(json.dumps({"error": f"drive api: {e}"}), file=sys.stderr) + sys.exit(3) + + if args.json: + print(json.dumps(results, indent=2)) + else: + for f in results: + print(f"{f['id']}\t{f['modifiedTime']}\t{f['name']}") + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-customer-context-builder/scripts/gap_check.py b/skills/ccb-customer-context-builder/scripts/gap_check.py new file mode 100644 index 00000000..242c3511 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/gap_check.py @@ -0,0 +1,746 @@ +#!/usr/bin/env python3 +"""Detect structural and coverage gaps in one customer wiki. + +Three latent graphs, two diffs: + + 1. SOURCE GRAPH — entities × source files (what the sources actually + cover). Built from sources/*.md by entity extraction. + 2. WIKI-EXPLICIT GRAPH — narrative × source (what the wiki claims to + connect). Built from dep_graph.json (cited + relationships). + 3. WIKI-IMPLICIT GRAPH — entity × entity (what the wiki implies). Built + from co-occurrence in narrative files. + +Diffs: + - implicit ∖ explicit → structural gaps. Two entities co-occur in + narrative paragraphs but are never explicitly + cited together. "You wrote about both, didn't link them." + - source ∖ explicit → coverage gaps. An entity appears in source gists + but the narrative doesn't cite it anywhere. + "The source has it, your wiki doesn't." + +Outputs `GAPS.md` (human-readable) + `GAPS.json` (machine-readable, for the +side panel) at the wiki root. + +Entity extraction: uses spaCy's en_core_web_sm if available; falls back to +regex over BQ table-style identifiers and source slug names. Add custom +patterns via --custom-pattern (regex, repeatable). + +Usage: + python3 gap_check.py --wiki-root=path/to/customer + python3 gap_check.py --wiki-root=path/to/customer --top-n=20 +""" +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import itertools +import json +import re +import sys +from collections import Counter, defaultdict +from dataclasses import asdict, dataclass, field +from pathlib import Path + +# ---------------- networkx (optional, for cluster-mode) ---------------- + +_NX = None +_NX_LOAD_ERROR: str | None = None + + +def _load_networkx(): + global _NX, _NX_LOAD_ERROR + if _NX is not None or _NX_LOAD_ERROR is not None: + return _NX + try: + import networkx as _nx_mod # noqa: F401 + _NX = _nx_mod + except ImportError: + _NX_LOAD_ERROR = ( + "networkx not installed (pip install networkx) — falling back to " + "pair-based gap detection only. Install for cluster-based gaps " + "(Louvain communities + betweenness centrality, InfraNodus-style)." + ) + return _NX + + +# ---------------- spaCy (optional) ---------------- + +_SPACY_NLP = None +_SPACY_LOAD_ERROR: str | None = None + + +def _load_spacy(): + global _SPACY_NLP, _SPACY_LOAD_ERROR + if _SPACY_NLP is not None or _SPACY_LOAD_ERROR is not None: + return _SPACY_NLP + try: + import spacy # noqa: F401 + try: + _SPACY_NLP = spacy.load("en_core_web_sm") + except OSError as e: + _SPACY_LOAD_ERROR = ( + f"spaCy is installed but the 'en_core_web_sm' model isn't " + f"downloaded. Run: python -m spacy download en_core_web_sm " + f"({e})" + ) + except ImportError: + _SPACY_LOAD_ERROR = "spaCy not installed (pip install spacy)." + return _SPACY_NLP + + +# ---------------- Entity extraction ---------------- + +# BigQuery table identifiers in `dataset.table` or `project.dataset.table` form. +# Lowercase + underscores only — rejects CamelCase code identifiers like +# `entrySource.description`. The leading lowercase + ≥1 underscore in the +# table segment further reduces false positives from prose. +BQ_TABLE_RE = re.compile( + r"\b[a-z][a-z0-9_]{2,}\.[a-z][a-z0-9_]+_[a-z0-9_]+(?:\.[a-z][a-z0-9_]+)?\b" +) + +# Bare table-like names with underscores (fact_*, dim_*, *_summary, *_v2, etc.). +TABLE_NAME_RE = re.compile( + r"\b(?:fact|dim|stg|raw|events|orders)_[a-z][a-z0-9_]{2,}\b" + r"|\b[a-z][a-z0-9_]+_(?:summary|daily|raw|v\d+)\b" +) + +# Owner-style emails as entities (people are part of the context graph). +EMAIL_RE = re.compile(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b") + +# Email domains we never treat as entities. Service-account principals +# (gserviceaccount.com, iam.gserviceaccount.com) co-occur with everything +# in BQ jobs/query-pattern gists, generating noisy "structural gaps" +# between every pair of service accounts the wiki touches. They're not +# meaningful concepts — they're identity for the *retrieval*, not for +# the customer's data model. Same goes for the local OS user's gcloud +# email when the warehouse agent ran under it. +EMAIL_DOMAIN_BLOCKLIST = ( + "gserviceaccount.com", + "iam.gserviceaccount.com", +) + + +def _is_blocked_email(email: str) -> bool: + domain = email.split("@", 1)[1] if "@" in email else "" + return any(domain.endswith(b) for b in EMAIL_DOMAIN_BLOCKLIST) + +# Suffixes we never want to treat as entities — filenames and common +# generic words. Matches against the full normalized entity string. +ENTITY_BLOCKLIST_SUFFIX = (".md", ".json", ".html", ".py", ".sql", ".txt", ".yaml", ".yml") +# fields.md / lineage.md / index.md are covered by ENTITY_BLOCKLIST_SUFFIX +# above; keep the bare-name forms (fields / lineage / summary / overview) +# since those collide with prose words and are NOT caught by the suffix. +ENTITY_BLOCKLIST = { + "none", "null", "true", "false", "data", "table", "schema", + "fields", "lineage", + "summary", "overview", +} + + +def _allowed_entity(s: str) -> bool: + if s in ENTITY_BLOCKLIST: + return False + if any(s.endswith(suf) for suf in ENTITY_BLOCKLIST_SUFFIX): + return False + return True + + +@dataclass +class EntitySet: + """Container for entities found in a piece of text.""" + tables: set[str] = field(default_factory=set) + emails: set[str] = field(default_factory=set) + spacy_orgs: set[str] = field(default_factory=set) + spacy_products: set[str] = field(default_factory=set) + custom: set[str] = field(default_factory=set) + + def all(self) -> set[str]: + return self.tables | self.emails | self.spacy_orgs | self.spacy_products | self.custom + + +def normalize(s: str) -> str: + return s.strip().lower() + + +def _add(target: set[str], raw: str) -> None: + s = normalize(raw) + if _allowed_entity(s): + target.add(s) + + +def extract_entities(text: str, custom_patterns: list[re.Pattern]) -> EntitySet: + es = EntitySet() + for m in BQ_TABLE_RE.finditer(text): + _add(es.tables, m.group(0)) + for m in TABLE_NAME_RE.finditer(text): + _add(es.tables, m.group(0)) + for m in EMAIL_RE.finditer(text): + e = normalize(m.group(0)) + if not _is_blocked_email(e): + _add(es.emails, m.group(0)) + for pat in custom_patterns: + for m in pat.finditer(text): + _add(es.custom, m.group(0)) + + nlp = _load_spacy() + if nlp is not None: + # Cap text length for spaCy — large docs slow it down a lot. + doc = nlp(text[:200_000]) + for ent in doc.ents: + if ent.label_ == "ORG": + _add(es.spacy_orgs, ent.text) + elif ent.label_ == "PRODUCT": + _add(es.spacy_products, ent.text) + return es + + +# ---------------- File discovery ---------------- + +SKIP_FILE_NAMES = ("CRITIQUE.md", "GAPS.md", "DRIFT.md") + + +def is_source_file(rel: Path) -> bool: + return any(part == "sources" for part in rel.parts[:-1]) + + +def is_index_file(rel: Path) -> bool: + return rel.name == "index.md" + + +def collect_files(wiki_root: Path) -> tuple[list[Path], list[Path]]: + """Return (narrative_files, source_files), both wiki-rooted absolutes. + + Excludes auto-generated nav inside sources/ (sources/index.md, sources/ + nested index files) — they're navigation, not source content.""" + narrative: list[Path] = [] + source: list[Path] = [] + for p in sorted(wiki_root.rglob("*.md")): + rel = p.relative_to(wiki_root) + if rel.name in SKIP_FILE_NAMES: + continue + if is_source_file(rel): + # Skip the auto-generated index inside sources/. + if rel.name == "index.md": + continue + source.append(p) + else: + narrative.append(p) + return narrative, source + + +# ---------------- Graphs ---------------- + +@dataclass +class Graphs: + # entity -> set of source files containing it + source_to_entities: dict[str, set[str]] = field(default_factory=dict) + entity_to_sources: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + # entity co-occurrence within narrative paragraphs + entity_pair_cooccurrence: Counter = field(default_factory=Counter) + # which narrative files mention which entity + entity_to_narratives: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + # explicit citations (from dep_graph.json): narrative -> set of source files + narrative_cites_source: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set)) + + +def build_source_graph( + source_files: list[Path], wiki_root: Path, + custom_patterns: list[re.Pattern], +) -> Graphs: + g = Graphs() + for sf in source_files: + rel = sf.relative_to(wiki_root).as_posix() + text = sf.read_text(encoding="utf-8", errors="replace") + ents = extract_entities(text, custom_patterns).all() + g.source_to_entities[rel] = ents + for e in ents: + g.entity_to_sources[e].add(rel) + return g + + +def build_implicit_graph( + narrative_files: list[Path], wiki_root: Path, g: Graphs, + custom_patterns: list[re.Pattern], +) -> None: + """Co-occurrence within paragraphs of narrative files.""" + for nf in narrative_files: + rel = nf.relative_to(wiki_root).as_posix() + text = nf.read_text(encoding="utf-8", errors="replace") + # Strip claim footnote definitions so they don't pollute co-occurrence. + text_no_footnotes = re.sub( + r"^\s*\[\^c\d+\]:.*$", "", text, flags=re.MULTILINE, + ) + # Split into paragraphs (blank-line separated). + for para in re.split(r"\n\s*\n", text_no_footnotes): + if not para.strip(): + continue + ents = extract_entities(para, custom_patterns).all() + for e in ents: + g.entity_to_narratives[e].add(rel) + # Pairs co-occurring in the same paragraph. + ents_sorted = sorted(ents) + for a, b in itertools.combinations(ents_sorted, 2): + g.entity_pair_cooccurrence[(a, b)] += 1 + + +def build_explicit_graph(wiki_root: Path, g: Graphs) -> None: + """Read dep_graph.json (which encodes citations) and populate + narrative_cites_source. If dep_graph.json is missing, compute + nothing — coverage gaps will still work, structural gaps degrade + gracefully (everything looks structural).""" + dep_path = wiki_root / "dep_graph.json" + if not dep_path.is_file(): + return + try: + dep = json.loads(dep_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + for edge in dep.get("edges", []): + g.narrative_cites_source[edge["from"]].add(edge["to"]) + + +# ---------------- Gap detection ---------------- + +@dataclass +class Gap: + id: str + type: str # "structural" | "coverage" + severity: str # "high" | "medium" | "low" + concepts: list[str] + pages: list[str] # narrative pages where the gap manifests + sources: list[str] # underlying source files (for context) + evidence: str + suggested_bridge: str + auto_fixable: bool + + +def make_gap_id(kind: str, key: str) -> str: + """Build a stable gap id from a kind tag and an arbitrary key string. + + Python's built-in ``hash()`` is salted per-process (PYTHONHASHSEED), + so the same pair would otherwise get a different gap-id every run — + breaking the wiki-viewer's "Promote bridge" buttons across rebuilds. + sha256 is deterministic across runs. Mirrors ``make_drift_id`` in + ``source_diff.py``. + """ + h = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] + return f"gap-{kind}-{h}" + + +def detect_structural_gaps(g: Graphs, top_n: int) -> list[Gap]: + """Pairs of entities that co-occur in narrative paragraphs but are + never explicitly cited together (i.e. the narrative pages mentioning + them don't both cite a common source pair).""" + gaps: list[Gap] = [] + # Score = co-occurrence count × min(narrative-prevalence of each entity). + # Narrative-prevalence: how many narrative files mention this entity. + # Pairs where both entities are widely mentioned but never bridged are + # the InfraNodus-style "structural gap" signal. + scored: list[tuple[float, tuple[str, str], int]] = [] + for (a, b), cooc in g.entity_pair_cooccurrence.items(): + # Skip pairs where one is a substring of the other (e.g. owner email + # vs. their domain) — these are noise. + if a in b or b in a: + continue + prev_a = len(g.entity_to_narratives.get(a, set())) + prev_b = len(g.entity_to_narratives.get(b, set())) + if prev_a == 0 or prev_b == 0: + continue + # Are they ever "explicitly bridged"? An explicit bridge = a single + # narrative file cites a source containing entity A AND a source + # containing entity B. (Loose by design: we want the recall to be + # generous so the Gaps panel surfaces candidate bridges; the + # bridge_score signal in score_candidates.py is the precision filter.) + bridged = False + sources_with_a = g.entity_to_sources.get(a, set()) + sources_with_b = g.entity_to_sources.get(b, set()) + for narrative, cites in g.narrative_cites_source.items(): + if cites & sources_with_a and cites & sources_with_b: + bridged = True + break + if bridged: + continue + score = cooc * min(prev_a, prev_b) + scored.append((score, (a, b), cooc)) + + scored.sort(reverse=True) + for score, (a, b), cooc in scored[:top_n]: + # Severity: by score percentile within this run. + if score >= 4: + severity = "high" + elif score >= 2: + severity = "medium" + else: + severity = "low" + pages = sorted( + g.entity_to_narratives.get(a, set()) + & g.entity_to_narratives.get(b, set()) + ) + sources = sorted( + g.entity_to_sources.get(a, set()) + | g.entity_to_sources.get(b, set()) + ) + gid = make_gap_id("S", f"{a}|{b}") + gaps.append(Gap( + id=gid, type="structural", severity=severity, + concepts=[a, b], pages=pages, sources=sources, + evidence=f"co-occurs in {cooc} paragraph(s) across {len(pages)} narrative file(s); " + f"never explicitly cited together", + suggested_bridge=( + f"Add a citation in one of {pages[:2]} that bridges " + f"`{a}` and `{b}` via {sources[:1]}." + if pages and sources else + f"Cite a source that mentions both `{a}` and `{b}`." + ), + auto_fixable=False, # auto-fix needs an LLM in the loop; defer + )) + return gaps + + +def detect_cluster_gaps( + g: Graphs, top_n: int, + *, density_thresholds: tuple[float, float, float] = (0.05, 0.15, 0.25), +) -> list[Gap]: + """InfraNodus-style cluster-pair structural gaps. + + Builds a co-occurrence graph from `entity_pair_cooccurrence`, runs + Louvain community detection to find topical clusters, computes + betweenness centrality for each node, and surfaces pairs of clusters + that are both important (high cumulative centrality) but poorly + bridged (low cross-cluster edge density). + + Pair-based detect_structural_gaps stays the primary signal; cluster + gaps are an additional, coarser-grained lens. Returns [] if networkx + isn't installed. + """ + nx = _load_networkx() + if nx is None or not g.entity_pair_cooccurrence: + return [] + from networkx.algorithms.community import louvain_communities + + G = nx.Graph() + for (a, b), w in g.entity_pair_cooccurrence.items(): + if a == b: + continue + G.add_edge(a, b, weight=int(w)) + if G.number_of_nodes() < 4 or G.number_of_edges() < 3: + return [] # too small to cluster meaningfully + + # Louvain — non-deterministic; pin seed for reproducibility. + try: + communities = louvain_communities(G, weight="weight", seed=42) + except Exception as e: + # Older networkx versions don't accept `seed` — retry without it. + try: + communities = louvain_communities(G, weight="weight") + except Exception: + return [] + # Drop trivially-small communities (a single node isn't a cluster). + communities = [c for c in communities if len(c) >= 2] + if len(communities) < 2: + return [] + + centrality = nx.betweenness_centrality(G, weight="weight", normalized=True) + + # For every pair of communities, compute importance + density. + results: list[tuple[float, float, set[str], set[str], int, int]] = [] + for i in range(len(communities)): + for j in range(i + 1, len(communities)): + ci, cj = communities[i], communities[j] + cross = 0 + for u in ci: + for v in cj: + if G.has_edge(u, v): + cross += 1 + potential = len(ci) * len(cj) + density = cross / potential if potential else 0.0 + importance = sum(centrality.get(n, 0.0) for n in ci | cj) + results.append((importance, density, ci, cj, cross, potential)) + + # Severity rule: + # density < 0.05 AND importance in top 25% of pairs → high + # density < 0.15 AND importance in top 50% → medium + # anything else (within pair-list) flagged → low + if not results: + return [] + importances = sorted((r[0] for r in results), reverse=True) + if not importances: + return [] + p75_imp = importances[max(0, len(importances) // 4)] + p50_imp = importances[len(importances) // 2] + + high_max, med_max, low_max = density_thresholds + # density >= the LOW threshold means the clusters are well-enough bridged + # not to flag at all; a small headroom above low_max is treated as + # "well-bridged" and skipped. + well_bridged = max(low_max + 0.05, 0.30) + + gaps: list[Gap] = [] + for importance, density, ci, cj, cross, potential in results: + if density >= well_bridged: + continue # well-bridged, not a gap + if importance < importances[-1] + 1e-9: + continue + if density < high_max and importance >= p75_imp: + sev = "high" + elif density < med_max and importance >= p50_imp: + sev = "medium" + elif density < low_max: + sev = "low" + else: + continue + + # Pick representative concepts: top 3 by centrality from each cluster. + def top_concepts(cluster: set[str], k: int = 3) -> list[str]: + return sorted(cluster, key=lambda n: -centrality.get(n, 0.0))[:k] + rep_a = top_concepts(ci) + rep_b = top_concepts(cj) + + ci_key = ",".join(sorted(ci)) + cj_key = ",".join(sorted(cj)) + gid = make_gap_id("CL", f"{ci_key}|{cj_key}") + narratives_a = set().union(*(g.entity_to_narratives.get(n, set()) for n in ci)) + narratives_b = set().union(*(g.entity_to_narratives.get(n, set()) for n in cj)) + pages = sorted(narratives_a | narratives_b) + sources_a = set().union(*(g.entity_to_sources.get(n, set()) for n in ci)) + sources_b = set().union(*(g.entity_to_sources.get(n, set()) for n in cj)) + all_sources = sorted(sources_a | sources_b) + + gaps.append(Gap( + id=gid, type="cluster_structural", severity=sev, + concepts=rep_a + ["↔"] + rep_b, + pages=pages, sources=all_sources, + evidence=( + f"Cluster A ({len(ci)} concepts incl. {', '.join(f'`{c}`' for c in rep_a)}) " + f"and Cluster B ({len(cj)} concepts incl. {', '.join(f'`{c}`' for c in rep_b)}) " + f"have only {cross}/{potential} cross-cluster edges " + f"(density={density:.2f}, importance={importance:.3f}) — " + f"poorly bridged but both topically important" + ), + suggested_bridge=( + f"Add a citation that connects something from Cluster A " + f"(e.g. `{rep_a[0]}`) with something from Cluster B " + f"(e.g. `{rep_b[0]}`) via a source that mentions both." + ), + auto_fixable=False, + )) + + # Sort by severity then importance. + sev_rank = {"high": 0, "medium": 1, "low": 2} + gaps.sort(key=lambda g: (sev_rank.get(g.severity, 9), g.id)) + return gaps[:top_n] + + +def detect_coverage_gaps(g: Graphs, top_n: int) -> list[Gap]: + """Entities that appear in source files but are never mentioned in any + narrative file.""" + gaps: list[Gap] = [] + for entity, sources in g.entity_to_sources.items(): + if entity in g.entity_to_narratives: + continue # mentioned somewhere in narrative + # Skip noise: very short tokens, well-known generic words. + if len(entity) < 4: + continue + if entity in {"none", "null", "true", "false", "data", "table", "schema"}: + continue + score = len(sources) + if score >= 3: + severity = "high" + elif score == 2: + severity = "medium" + else: + severity = "low" + gid = make_gap_id("C", entity) + gaps.append(Gap( + id=gid, type="coverage", severity=severity, + concepts=[entity], pages=[], sources=sorted(sources), + evidence=f"appears in {len(sources)} source file(s) but no narrative file mentions it", + suggested_bridge=( + f"Add a paragraph (likely in `data_warehouse.md` or the " + f"appropriate `{{table}}/lineage.md`) covering `{entity}`, " + f"with a citation to one of: {sorted(sources)[:2]}." + ), + auto_fixable=False, + )) + gaps.sort(key=lambda g: (-len(g.sources), g.concepts[0])) + return gaps[:top_n] + + +# ---------------- Output ---------------- + +def severity_emoji(sev: str) -> str: + return {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(sev, "⚪") + + +def render_md(gaps: list[Gap], wiki_root: Path) -> str: + n_struct = sum(1 for g in gaps if g.type == "structural") + n_cover = sum(1 for g in gaps if g.type == "coverage") + n_cluster = sum(1 for g in gaps if g.type == "cluster_structural") + summary = f"**{n_struct}** structural · **{n_cover}** coverage" + if n_cluster: + summary += f" · **{n_cluster}** cluster" + lines: list[str] = [ + f"# Gaps — {wiki_root.name}", + "", + f"_Generated by `gap_check.py` at {dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}._", + "", + summary, + "", + ] + by_type: dict[str, list[Gap]] = defaultdict(list) + for g in gaps: + by_type[g.type].append(g) + for typ, label in ( + ("structural", "Structural gaps"), + ("coverage", "Coverage gaps"), + ("cluster_structural", "Cluster gaps (Louvain communities + betweenness)"), + ): + if not by_type.get(typ): + continue + lines.append(f"## {label}") + lines.append("") + if typ == "structural": + lines.append( + "_Two concepts both appear in your narrative but the wiki never " + "explicitly bridges them — likely missed citations._" + ) + elif typ == "coverage": + lines.append( + "_Concepts present in source gists but not mentioned in any narrative " + "file — sections you didn't write up._" + ) + else: # cluster_structural + lines.append( + "_Pairs of concept clusters that are both topically important " + "(high cumulative betweenness) but poorly bridged across the " + "wiki — InfraNodus-style structural gaps at the cluster level._" + ) + lines.append("") + for g in by_type[typ]: + concept_label = " ↔ ".join(f"`{c}`" for c in g.concepts) \ + if typ == "structural" else f"`{g.concepts[0]}`" + lines.append(f"### {g.id} — {concept_label}") + lines.append("") + lines.append(f"- **Severity:** {g.severity} {severity_emoji(g.severity)}") + lines.append(f"- **Evidence:** {g.evidence}") + if g.pages: + lines.append(f"- **Affected narrative pages:** {', '.join(f'`{p}`' for p in g.pages[:5])}" + + (f" (+{len(g.pages)-5} more)" if len(g.pages) > 5 else "")) + if g.sources: + lines.append(f"- **Source files:** {', '.join(f'`{s}`' for s in g.sources[:5])}" + + (f" (+{len(g.sources)-5} more)" if len(g.sources) > 5 else "")) + lines.append(f"- **Suggested bridge:** {g.suggested_bridge}") + lines.append("") + if not gaps: + lines.append("_No gaps detected._") + lines.append("") + return "\n".join(lines) + + +def render_json(gaps: list[Gap], wiki_root: Path) -> dict: + return { + "schema": "gaps.v1", + "wiki_root": wiki_root.name, + "generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "spacy_used": _SPACY_NLP is not None, + "spacy_status": _SPACY_LOAD_ERROR or "loaded" if _SPACY_NLP else _SPACY_LOAD_ERROR, + "gap_count": len(gaps), + "by_type": { + "structural": sum(1 for g in gaps if g.type == "structural"), + "coverage": sum(1 for g in gaps if g.type == "coverage"), + "cluster_structural": sum(1 for g in gaps if g.type == "cluster_structural"), + }, + "by_severity": { + sev: sum(1 for g in gaps if g.severity == sev) + for sev in ("high", "medium", "low") + }, + "gaps": [asdict(g) for g in gaps], + } + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument("--top-n", type=int, default=15, + help="Max gaps per type to surface (default 15).") + ap.add_argument( + "--cluster-mode", action="store_true", + help="Also run InfraNodus-style cluster gap detection: Louvain " + "community detection on the entity co-occurrence graph + " + "betweenness centrality, surface cluster pairs with high " + "importance and low cross-cluster edge density. Requires " + "networkx (pip install networkx). Adds cluster_structural " + "gaps on top of the pair-based structural and coverage gaps.", + ) + ap.add_argument( + "--cluster-thresholds", default="0.05,0.15,0.25", + help="Comma-separated cross-cluster edge density thresholds for " + "high,medium,low severity. Default: 0.05,0.15,0.25 (a cluster " + "pair with density < 5%% is HIGH if importance is also in the " + "top 25%% of pairs in this run, etc.). Loosen these (e.g. " + "0.10,0.25,0.40) to surface more cluster gaps on a small wiki; " + "tighten them on a wiki where clusters are tightly bridged.", + ) + ap.add_argument("--custom-pattern", action="append", default=[], + help="Extra entity regex (repeatable). Compiled with re.IGNORECASE.") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + custom_patterns = [ + re.compile(p, re.IGNORECASE) for p in args.custom_pattern + ] + + narrative_files, source_files = collect_files(wiki_root) + if not args.quiet: + nlp = _load_spacy() + if nlp is None: + print(f"note: {_SPACY_LOAD_ERROR} (regex-only entity extraction)", + file=sys.stderr) + + g = build_source_graph(source_files, wiki_root, custom_patterns) + build_implicit_graph(narrative_files, wiki_root, g, custom_patterns) + build_explicit_graph(wiki_root, g) + + structural = detect_structural_gaps(g, args.top_n) + coverage = detect_coverage_gaps(g, args.top_n) + cluster: list[Gap] = [] + if args.cluster_mode: + if _load_networkx() is None and not args.quiet: + print(f"note: {_NX_LOAD_ERROR}", file=sys.stderr) + try: + thresh_vals = tuple(float(x) for x in args.cluster_thresholds.split(",")) + if len(thresh_vals) != 3: + raise ValueError(f"expected 3 values, got {len(thresh_vals)}") + for v in thresh_vals: + if not (0.0 <= v <= 1.0): + raise ValueError(f"value out of [0,1]: {v}") + except ValueError as e: + sys.exit(f"--cluster-thresholds must be 3 comma-separated floats in [0,1]: {e}") + cluster = detect_cluster_gaps(g, args.top_n, density_thresholds=thresh_vals) + gaps = structural + coverage + cluster + + md_path = wiki_root / "GAPS.md" + json_path = wiki_root / "GAPS.json" + md_path.write_text(render_md(gaps, wiki_root), encoding="utf-8") + json_path.write_text( + json.dumps(render_json(gaps, wiki_root), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + if not args.quiet: + print( + f"gaps: {len(structural)} structural · {len(coverage)} coverage " + f"→ {md_path.name}, {json_path.name}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/gcs_upload.py b/skills/ccb-customer-context-builder/scripts/gcs_upload.py new file mode 100755 index 00000000..e40cf9d6 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/gcs_upload.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Mirror a local wiki tree to a GCS bucket. + +Thin wrapper around `gcloud storage rsync` so the skill can persist +generated wikis to GCS. Auth uses the same gcloud / ADC stack the rest +of the skill uses — no separate credentials needed. + +Usage: + python3 scripts/gcs_upload.py \\ + --local-dir=./customer-context/wikis \\ + --gcs-uri=gs://bucket-name/optional/prefix \\ + [--delete-extra] [--dry-run] + +Notes: +- --gcs-uri must start with gs:// and may include a sub-prefix. +- --delete-extra mirrors semantics: removes remote files that don't + exist locally. Off by default to avoid surprises. +- --dry-run prints what would change without writing anything to GCS. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--local-dir", required=True, help="Local directory to upload") + p.add_argument("--gcs-uri", required=True, help="Destination, e.g. gs://bucket/prefix") + p.add_argument("--delete-extra", action="store_true", + help="Remove remote files that don't exist locally") + p.add_argument("--dry-run", action="store_true", help="Show what would change; don't upload") + args = p.parse_args() + + local = Path(args.local_dir).resolve() + if not local.is_dir(): + die(f"--local-dir does not exist or is not a directory: {local}") + + if not args.gcs_uri.startswith("gs://"): + die(f"--gcs-uri must start with gs:// (got: {args.gcs_uri})") + + if not shutil.which("gcloud"): + die("`gcloud` CLI not on PATH. Install Google Cloud SDK and `gcloud auth login`.") + + cmd = ["gcloud", "storage", "rsync", "--recursive", str(local), args.gcs_uri] + if args.delete_extra: + cmd.append("--delete-unmatched-destination-objects") + if args.dry_run: + cmd.append("--dry-run") + + started_at = time.time() + print(f"running: {' '.join(cmd)}", file=sys.stderr) + + proc = subprocess.run(cmd, capture_output=True, text=True) + duration_s = time.time() - started_at + + file_count = sum(1 for _ in local.rglob("*") if _.is_file()) + total_bytes = sum(p.stat().st_size for p in local.rglob("*") if p.is_file()) + + result = { + "local_dir": str(local), + "gcs_uri": args.gcs_uri, + "delete_extra": args.delete_extra, + "dry_run": args.dry_run, + "exit_code": proc.returncode, + "duration_seconds": round(duration_s, 2), + "local_file_count": file_count, + "local_total_bytes": total_bytes, + "stdout_tail": proc.stdout[-2000:] if proc.stdout else "", + "stderr_tail": proc.stderr[-2000:] if proc.stderr else "", + } + + print(json.dumps(result, indent=2)) + sys.exit(proc.returncode) + + +def die(msg: str): + print(f"error: {msg}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-customer-context-builder/scripts/gdocs_extract.py b/skills/ccb-customer-context-builder/scripts/gdocs_extract.py new file mode 100755 index 00000000..b9ce411f --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/gdocs_extract.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Extract a Google Doc as plain text with headings preserved. + +Output JSON: {id, title, last_modified, owners, body} +where `body` is text with headings rendered as markdown (#, ##, ###). +""" +from __future__ import annotations + +import argparse +import json +import sys + +import google.auth +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + + +HEADING_PREFIX = { + "TITLE": "# ", + "SUBTITLE": "## ", + "HEADING_1": "# ", + "HEADING_2": "## ", + "HEADING_3": "### ", + "HEADING_4": "#### ", + "HEADING_5": "##### ", + "HEADING_6": "###### ", +} + + +def build_services(): + creds, _ = google.auth.default( + scopes=[ + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/drive.readonly", + ] + ) + docs = build("docs", "v1", credentials=creds, cache_discovery=False) + drive = build("drive", "v3", credentials=creds, cache_discovery=False) + return docs, drive + + +def render_paragraph(p: dict) -> str: + style = p.get("paragraphStyle", {}).get("namedStyleType", "NORMAL_TEXT") + prefix = HEADING_PREFIX.get(style, "") + parts = [] + for el in p.get("elements", []): + tr = el.get("textRun") + if tr: + parts.append(tr.get("content", "")) + text = "".join(parts).rstrip("\n") + if not text.strip(): + return "" + return prefix + text + + +def extract_body(doc: dict, max_chars: int) -> str: + out = [] + total = 0 + for el in doc.get("body", {}).get("content", []): + p = el.get("paragraph") + if not p: + continue + line = render_paragraph(p) + if not line: + continue + out.append(line) + total += len(line) + 1 + if total >= max_chars: + out.append(f"\n[truncated at {max_chars} chars]") + break + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--doc-id", required=True) + p.add_argument("--max-chars", type=int, default=15000) + args = p.parse_args() + + try: + docs, drive = build_services() + except Exception as e: + print(json.dumps({"error": f"auth failed: {e}"}), file=sys.stderr) + sys.exit(2) + + try: + doc = docs.documents().get(documentId=args.doc_id).execute() + meta = drive.files().get( + fileId=args.doc_id, + fields="id, name, modifiedTime, owners(emailAddress), webViewLink", + supportsAllDrives=True, + ).execute() + except HttpError as e: + print(json.dumps({"error": f"api: {e}"}), file=sys.stderr) + sys.exit(3) + + out = { + "id": meta["id"], + "title": meta.get("name", doc.get("title", "")), + "last_modified": meta.get("modifiedTime"), + "owners": [o.get("emailAddress") for o in meta.get("owners", [])], + "url": meta.get("webViewLink"), + "body": extract_body(doc, args.max_chars), + } + print(json.dumps(out, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-customer-context-builder/scripts/gsheets_extract.py b/skills/ccb-customer-context-builder/scripts/gsheets_extract.py new file mode 100755 index 00000000..01c7e711 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/gsheets_extract.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Extract metadata + a row sample from a Google Sheet. + +Output JSON: {id, title, last_modified, owners, tabs: [{title, row_count, +col_count, header, sample_rows}]} +""" +from __future__ import annotations + +import argparse +import json +import sys + +import google.auth +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + + +def build_services(): + creds, _ = google.auth.default( + scopes=[ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/drive.readonly", + ] + ) + sheets = build("sheets", "v4", credentials=creds, cache_discovery=False) + drive = build("drive", "v3", credentials=creds, cache_discovery=False) + return sheets, drive + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--sheet-id", required=True) + p.add_argument("--rows-per-tab", type=int, default=15) + args = p.parse_args() + + try: + sheets, drive = build_services() + except Exception as e: + print(json.dumps({"error": f"auth failed: {e}"}), file=sys.stderr) + sys.exit(2) + + try: + meta = drive.files().get( + fileId=args.sheet_id, + fields="id, name, modifiedTime, owners(emailAddress), webViewLink", + supportsAllDrives=True, + ).execute() + ss = sheets.spreadsheets().get( + spreadsheetId=args.sheet_id, + includeGridData=False, + ).execute() + except HttpError as e: + print(json.dumps({"error": f"api: {e}"}), file=sys.stderr) + sys.exit(3) + + tabs = [] + for s in ss.get("sheets", []): + props = s.get("properties", {}) + title = props.get("title", "") + grid = props.get("gridProperties", {}) + row_count = grid.get("rowCount", 0) + col_count = grid.get("columnCount", 0) + + end_row = min(row_count, args.rows_per_tab + 1) # +1 for header + if end_row < 2: + tabs.append({ + "title": title, + "row_count": row_count, + "col_count": col_count, + "header": [], + "sample_rows": [], + }) + continue + rng = f"'{title}'!A1:{_col_letter(col_count)}{end_row}" + try: + values = sheets.spreadsheets().values().get( + spreadsheetId=args.sheet_id, range=rng, + ).execute().get("values", []) + except HttpError: + values = [] + header = values[0] if values else [] + sample = values[1:] if len(values) > 1 else [] + tabs.append({ + "title": title, + "row_count": row_count, + "col_count": col_count, + "header": header, + "sample_rows": sample, + }) + + out = { + "id": meta["id"], + "title": meta.get("name"), + "last_modified": meta.get("modifiedTime"), + "owners": [o.get("emailAddress") for o in meta.get("owners", [])], + "url": meta.get("webViewLink"), + "tabs": tabs, + } + print(json.dumps(out, indent=2)) + + +def _col_letter(n: int) -> str: + """1 -> A, 27 -> AA, etc.""" + n = max(1, min(n, 18278)) + s = "" + while n > 0: + n, r = divmod(n - 1, 26) + s = chr(65 + r) + s + return s + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-customer-context-builder/scripts/live_fetchers.py b/skills/ccb-customer-context-builder/scripts/live_fetchers.py new file mode 100644 index 00000000..e7fa0e51 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/live_fetchers.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""Live fetchers for source_diff.py --live mode. + +Each source in `source_manifest.json` was originally captured from some +external system: a Google Doc body, a Google Sheet, a BigQuery schema, etc. +The local on-disk source `.md` file is a snapshot of that external state at +some moment. Live drift detection means: re-fetch the external state RIGHT +NOW and compare it to what the snapshot says. + +This module dispatches a manifest entry to the appropriate fetcher, runs +the fetch, and returns a `LiveSnapshot` (or raises `LiveFetchError`). + +What's wrapped in v1: + - Google Docs (via the existing scripts/gdocs_extract.py) + - Google Sheets (via scripts/gsheets_extract.py) + - BigQuery schemas (`bq show --schema`) + - BigQuery dataset listings (`bq ls`) + +Skipped in v1 (returns SKIP, not an error): + - BigQuery JOBS_BY_PROJECT (volatile by design — every fetch differs + because the WHERE clause uses NOW()) + - Dataplex (`gcloud dataplex *`) — complex, defer to a later phase + - Anything else not pattern-matched + +The fetcher functions return the canonical TEXT representation of the +live state. source_diff normalizes both sides (live + on-disk) before +comparing so trivial whitespace differences don't trigger false drift. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +# Where the original capture scripts live. Resolve relative to this file +# so we work whether the skill is symlinked or run in-tree. +_SCRIPTS_DIR = Path(__file__).resolve().parent + +DEFAULT_TIMEOUT_SEC = 60 + + +# ---------------- Errors and result types ---------------- + +class LiveFetchError(Exception): + """Raised when a live fetch fails. .kind is one of: + NOT_FOUND - source no longer exists at origin (404, deleted file) + AUTH_FAILED - credentials missing/invalid for this source + TIMEOUT - fetch ran past DEFAULT_TIMEOUT_SEC + OTHER - any other failure (network, parse, unexpected output) + """ + def __init__(self, kind: str, message: str): + super().__init__(message) + self.kind = kind + + +@dataclass +class LiveSnapshot: + """Result of a successful live fetch.""" + fetcher: str # "gdoc" / "gsheet" / "bq_schema" / "bq_dataset_list" + source_uri: str # for diagnostics + body: str # canonical text body of the live state + + +@dataclass +class FetchOutcome: + """Aggregate result for one manifest entry. Exactly one of + snapshot/error/skip is non-None.""" + source_path: str + snapshot: LiveSnapshot | None = None + error: LiveFetchError | None = None + skip_reason: str | None = None + + +# ---------------- Volatile / unsupported skip patterns ---------------- + +# Lineage commands matching these patterns are by-design volatile or +# unsupported. We return a "skipped" outcome so they don't pollute the +# drift report with false positives or perpetual fetch failures. +SKIP_PATTERNS: list[tuple[re.Pattern, str]] = [ + (re.compile(r"INFORMATION_SCHEMA\.JOBS", re.IGNORECASE), + "volatile by design (JOBS_BY_PROJECT uses NOW() — every fetch differs)"), + (re.compile(r"gcloud\s+dataplex", re.IGNORECASE), + "no live fetcher for Dataplex in this version"), + (re.compile(r"bq\s+ls\s+--transfer_config", re.IGNORECASE), + "no live fetcher for transfer configs in this version"), +] + + +# ---------------- Fetchers ---------------- + +def _run(cmd: list[str], timeout: int = DEFAULT_TIMEOUT_SEC) -> str: + """Shell out, return stdout, raise LiveFetchError on failure.""" + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + raise LiveFetchError("TIMEOUT", f"command timed out after {timeout}s: {cmd[0]}") + except FileNotFoundError as e: + raise LiveFetchError("OTHER", f"command not found: {e}") + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "").lower() + if any(s in err for s in ( + "not found", "404", "no such", "does not exist", + "could not find", "doesn't exist", + )): + raise LiveFetchError("NOT_FOUND", proc.stderr.strip()[:300]) + if any(s in err for s in ( + "credentials", "permission denied", "unauthorized", + "auth", "403", "invalid_grant", + )): + raise LiveFetchError("AUTH_FAILED", proc.stderr.strip()[:300]) + raise LiveFetchError( + "OTHER", + f"exit {proc.returncode}: {(proc.stderr or proc.stdout).strip()[:300]}", + ) + return proc.stdout + + +def fetch_gdoc(doc_id: str, max_chars: int = 15000) -> str: + """Re-fetch a Google Doc via gdocs_extract.py. Returns the doc body + as markdown (matches what the original capture wrote to disk).""" + script = _SCRIPTS_DIR / "gdocs_extract.py" + out = _run([ + sys.executable, str(script), + f"--doc-id={doc_id}", + f"--max-chars={max_chars}", + ]) + try: + payload = json.loads(out) + except json.JSONDecodeError as e: + raise LiveFetchError("OTHER", f"gdocs_extract returned non-JSON: {e}") + body = (payload.get("body") or "").rstrip() + if not body: + raise LiveFetchError("OTHER", "gdocs_extract returned empty body") + return body + + +def fetch_gsheet(sheet_id: str, rows_per_tab: int = 15) -> str: + """Re-fetch a Google Sheet via gsheets_extract.py. Returns a canonical + text representation: one block per tab, header + sample rows.""" + script = _SCRIPTS_DIR / "gsheets_extract.py" + out = _run([ + sys.executable, str(script), + f"--sheet-id={sheet_id}", + f"--rows-per-tab={rows_per_tab}", + ]) + try: + payload = json.loads(out) + except json.JSONDecodeError as e: + raise LiveFetchError("OTHER", f"gsheets_extract returned non-JSON: {e}") + parts: list[str] = [] + for tab in payload.get("tabs", []): + parts.append(f"## Tab: {tab.get('title', '')}") + header = tab.get("header") or [] + parts.append("Header: " + " | ".join(str(h) for h in header)) + for row in tab.get("sample_rows") or []: + parts.append(" | ".join(str(c) for c in row)) + parts.append("") + body = "\n".join(parts).rstrip() + if not body: + raise LiveFetchError("OTHER", "gsheets_extract returned no tabs") + return body + + +def fetch_bq_schema(table_fqn: str) -> str: + """Re-fetch a BigQuery table schema via `bq show --schema`. Returns + the prettyjson schema text. table_fqn is `project:dataset.table` or + `project.dataset.table`.""" + out = _run([ + "bq", "show", "--schema", "--format=prettyjson", table_fqn, + ]) + return out.rstrip() + + +def fetch_bq_dataset_list(project_id: str) -> str: + """Re-fetch the dataset list for a project via `bq ls`. Returns the + prettyjson listing text.""" + out = _run([ + "bq", "ls", f"--project_id={project_id}", "--format=prettyjson", + ]) + return out.rstrip() + + +# ---------------- Dispatch ---------------- + +# Each entry: (lineage_pattern, uri_pattern, fetcher_name, args_extractor). +# args_extractor takes (lineage_match | None, uri_match | None) and returns +# kwargs for the fetcher. We try lineage first since it carries the exact +# command + args; fall back to URI if lineage didn't match. +_DISPATCH: list[dict] = [ + { + "name": "gdoc", + "lineage": re.compile(r"gdocs_extract\.py.*?--doc-id=([\w-]+)"), + "uri": re.compile(r"docs\.google\.com/document/d/([\w-]+)"), + "fetcher": fetch_gdoc, + "args": lambda lm, um: {"doc_id": (lm or um).group(1)}, + }, + { + "name": "gsheet", + "lineage": re.compile(r"gsheets_extract\.py.*?--sheet-id=([\w-]+)"), + "uri": re.compile(r"docs\.google\.com/spreadsheets/d/([\w-]+)"), + "fetcher": fetch_gsheet, + "args": lambda lm, um: {"sheet_id": (lm or um).group(1)}, + }, + { + # bq show --schema project:dataset.table (or project.dataset.table) + "name": "bq_schema", + "lineage": re.compile( + r"bq\s+show\s+--schema.*?\s([\w-]+[:\.][\w_]+\.[\w_]+)" + ), + "uri": re.compile( + r"BigQuery\s+(?:table\s+)?[`]?([\w-]+[:\.][\w_]+\.[\w_]+)[`]?", + re.IGNORECASE, + ), + "fetcher": fetch_bq_schema, + "args": lambda lm, um: {"table_fqn": (lm or um).group(1).replace(":", ".")}, + }, + { + "name": "bq_dataset_list", + "lineage": re.compile(r"bq\s+ls\s+--project_id=([\w-]+)"), + "uri": re.compile(r"datasets?\s+in\s+[`]?([\w-]+)[`]?", re.IGNORECASE), + "fetcher": fetch_bq_dataset_list, + "args": lambda lm, um: {"project_id": (lm or um).group(1)}, + }, +] + + +def _check_skip(lineage: str, uri: str) -> str | None: + blob = f"{lineage}\n{uri}" + for pat, reason in SKIP_PATTERNS: + if pat.search(blob): + return reason + return None + + +def dispatch(source_record: dict) -> FetchOutcome: + """Look at one source_manifest entry and either fetch it live or skip. + + source_record fields used: + - path: wiki-relative path of the source file (used for the outcome key) + - source_uri: the original URI (e.g. "Google Doc — https://...") + - lineage: how the source was originally captured (e.g. "python3 ... + gdocs_extract.py --doc-id=...") + """ + path = source_record.get("path", "") + lineage = source_record.get("lineage", "") or "" + uri = source_record.get("source_uri", "") or "" + + skip = _check_skip(lineage, uri) + if skip: + return FetchOutcome(source_path=path, skip_reason=skip) + + for entry in _DISPATCH: + lm = entry["lineage"].search(lineage) + um = entry["uri"].search(uri) if not lm else None + if not lm and not um: + continue + try: + kwargs = entry["args"](lm, um) + body = entry["fetcher"](**kwargs) + return FetchOutcome(source_path=path, snapshot=LiveSnapshot( + fetcher=entry["name"], source_uri=uri or lineage, body=body, + )) + except LiveFetchError as e: + return FetchOutcome(source_path=path, error=e) + except Exception as e: + return FetchOutcome( + source_path=path, + error=LiveFetchError("OTHER", f"{type(e).__name__}: {e}"), + ) + + return FetchOutcome( + source_path=path, + skip_reason="no live fetcher matched (URI/lineage didn't match any registered pattern)", + ) + + +# ---------------- Normalization for comparison ---------------- + +_BLOCKQUOTE_PREFIX = re.compile(r"^\s*>\s?", re.MULTILINE) +_WHITESPACE = re.compile(r"\s+") + + +def normalize_for_compare(text: str) -> str: + """Strip blockquote markers, collapse whitespace, lowercase. Used on + both the live body AND the on-disk gist body so trivial formatting + differences don't trigger false drift.""" + s = _BLOCKQUOTE_PREFIX.sub("", text) + s = _WHITESPACE.sub(" ", s) + return s.strip().lower() + + +def extract_on_disk_body(source_md_text: str) -> str: + """Pull the GIST CONTENT out of a source `.md` file — everything under + the `# Gists` heading, with section subheadings preserved. Used as the + 'what we have on disk' side of the live-vs-disk comparison.""" + m = re.search( + r"^#\s+Gists\s*$(.+?)(?=^#\s+\w|\Z)", + source_md_text, re.MULTILINE | re.DOTALL, + ) + if not m: + return "" + return m.group(1).strip() diff --git a/skills/ccb-customer-context-builder/scripts/rebuild_plan.py b/skills/ccb-customer-context-builder/scripts/rebuild_plan.py new file mode 100644 index 00000000..4cc98f3e --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/rebuild_plan.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Compute an incremental-rebuild plan from a drift report + dep graph. + +Reads DRIFT.json + dep_graph.json and emits rebuild_plan.json: a list of +narrative sections that need re-derivation because their cited sources +drifted, plus the agent that owns each section. The orchestrator can hand +this plan to a focused sub-agent to do surgical re-extraction instead of +rebuilding the whole wiki. + +This script only PLANS. It does not run the rebuild — the actual surgical +re-extraction is an LLM-driven step the orchestrator does next, scoped +exactly to what this plan lists. + +Schema: + { + "schema": "rebuild_plan.v1", + "wiki_root": "...", + "generated_at": "...", + "drift_summary": { + "changed": N, "deleted": N, "new": N, + "high": N, "medium": N, "low": N + }, + "actions": [ + { + "section": "fact_orders_daily/lineage.md", + "agent": "warehouse_agent", + "reason": "cites 1 changed source(s) with EXTRACTED claim(s): ...", + "drifted_sources": ["personal_context/sources/...md"], + "claims_to_revalidate": ["c1", "c4"], + "priority": "high" + } + ], + "skip_reasons": [ + {"section": "...", "reason": "no drifted source cited"} + ] + } + +Usage: + python3 rebuild_plan.py --wiki-root=path/to/customer + python3 rebuild_plan.py --wiki-root=... --threshold=medium # skip low-severity drift +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +from collections import defaultdict +from pathlib import Path + +SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1} + + +# Heuristic: which agent owns which kind of file. The orchestrator can +# override per-customer; this is a sensible default that matches the +# customer-context-builder agent layout. +def owning_agent(section: str) -> str: + if section.startswith("personal_context/"): + return "personal_context_agent" + return "warehouse_agent" + + +def load_json(p: Path) -> dict | None: + if not p.is_file(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument( + "--threshold", choices=("high", "medium", "low"), default="low", + help="Minimum drift severity to trigger a rebuild action. " + "Default: low (rebuild for any drift).", + ) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + drift = load_json(wiki_root / "DRIFT.json") + if drift is None: + sys.exit("DRIFT.json missing — run source_diff.py first.") + dep_graph = load_json(wiki_root / "dep_graph.json") + if dep_graph is None: + sys.exit("dep_graph.json missing — run dep_graph.py first.") + + threshold_rank = SEVERITY_RANK[args.threshold] + + # source -> [drift entries that affect it] + drifted_sources: dict[str, list[dict]] = defaultdict(list) + for d in drift.get("drifts", []): + if SEVERITY_RANK.get(d.get("severity", "low"), 0) < threshold_rank: + continue + drifted_sources[d["source"]].append(d) + + # narrative_section -> {drifted_sources, claims_to_revalidate, priority} + actions: dict[str, dict] = {} + skip_reasons: list[dict] = [] + + # Walk dep_graph edges: each edge is (narrative -> source). + # For each narrative, collect the drifted sources it cites. + narrative_to_drifted: dict[str, dict[str, list[dict]]] = defaultdict(dict) + narrative_to_claims: dict[str, set[str]] = defaultdict(set) + for edge in dep_graph.get("edges", []): + narrative = edge["from"] + source = edge["to"] + if source in drifted_sources: + narrative_to_drifted[narrative].setdefault(source, drifted_sources[source]) + narrative_to_claims[narrative].add(edge["claim_id"]) + + for narrative, srcs in narrative_to_drifted.items(): + # Priority = highest severity among the drifts affecting this section. + max_rank = 0 + max_severity = "low" + bands_seen: set[str] = set() + kinds_seen: set[str] = set() + for entries in srcs.values(): + for d in entries: + r = SEVERITY_RANK.get(d.get("severity", "low"), 0) + if r > max_rank: + max_rank, max_severity = r, d["severity"] + kinds_seen.add(d["kind"]) + for ci in d.get("claims_impacted", []): + if ci.get("file") == narrative: + bands_seen.add(ci.get("tag", "")) + + reason_parts = [] + for kind in sorted(kinds_seen): + n = sum(1 for entries in srcs.values() + for d in entries if d["kind"] == kind) + reason_parts.append(f"{n} {kind}") + if bands_seen: + reason_parts.append(f"bands cited from this section: {', '.join(sorted(bands_seen))}") + reason = "cites drifted source(s) — " + "; ".join(reason_parts) + + actions[narrative] = { + "section": narrative, + "agent": owning_agent(narrative), + "reason": reason, + "drifted_sources": sorted(srcs.keys()), + "claims_to_revalidate": sorted(narrative_to_claims[narrative]), + "priority": max_severity, + } + + # Sections that have claims but no drift → skip. + for node, info in dep_graph.get("nodes", {}).items(): + if info.get("kind") != "narrative": + continue + if node in actions: + continue + if info.get("claims", 0) == 0: + continue + skip_reasons.append({ + "section": node, + "reason": "no drifted source cited", + }) + + # Sort actions: priority desc, then section path. + sorted_actions = sorted( + actions.values(), + key=lambda a: (-SEVERITY_RANK.get(a["priority"], 0), a["section"]), + ) + + payload = { + "schema": "rebuild_plan.v1", + "wiki_root": wiki_root.name, + "generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "threshold": args.threshold, + "drift_summary": { + **drift.get("by_kind", {}), + **drift.get("by_severity", {}), + }, + "action_count": len(sorted_actions), + "actions": sorted_actions, + "skip_count": len(skip_reasons), + "skip_reasons": skip_reasons, + } + out = wiki_root / "rebuild_plan.json" + out.write_text( + json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + if not args.quiet: + print( + f"rebuild plan: {len(sorted_actions)} action(s), " + f"{len(skip_reasons)} section(s) skipped → {out.name}", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/requirements.txt b/skills/ccb-customer-context-builder/scripts/requirements.txt new file mode 100644 index 00000000..fde84737 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/requirements.txt @@ -0,0 +1,12 @@ +google-api-python-client>=2.120.0 +google-auth>=2.28.0 +PyYAML>=6.0 +# Optional — used by scripts/gap_check.py for entity extraction. +# Falls back to regex-only if not installed; recall is materially better +# when the en_core_web_sm model is downloaded: +# python -m spacy download en_core_web_sm +spacy>=3.7.0 +# Optional — used by scripts/gap_check.py --cluster-mode for InfraNodus-style +# cluster-based structural gap detection (Louvain community detection + +# betweenness centrality). Falls back to pair-based gaps when not installed. +networkx>=3.0 diff --git a/skills/ccb-customer-context-builder/scripts/revalidate_drift.py b/skills/ccb-customer-context-builder/scripts/revalidate_drift.py new file mode 100644 index 00000000..061688b1 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/revalidate_drift.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Re-validate drift entries after the cheap hash-based detection flagged +them as CHANGED. + +source_diff.py's stage 1 is sha256 inequality — fast, deterministic, +but full of false positives (cosmetic edits like adding a trailing +newline trip the same severity rule as a real semantic change). This +script is stage 2: for each CHANGED entry, walk the claims that cite +the source and check whether each claim is STILL SUPPORTED by the new +content: + + - EXTRACTED claim: literal-substring check of the verbatim quote + against the new content (normalized). + Survives → claim unaffected. + - INFERRED claim: anchor existence check. If the cited #anchor + still resolves to a section in the new content, + the claim is assumed still supportable. Survives. + (Cheap proxy; --llm escalates this to a real + semantic check.) + - AMBIGUOUS claim: always drops (signal too weak to be impactful). + +After re-validation, severity is recomputed from the SURVIVING claims: + + any EXTRACTED still impacts → high + any INFERRED still impacts → medium + only AMBIGUOUS or none → low + +Often this DOWNGRADES the original severity — a CHANGED source that +HIGH-flagged because it had EXTRACTED citations is downgraded to MEDIUM +or LOW once we confirm the verbatim quotes still validate. + +Optional `--llm` adds stage 3 for INFERRED claims that pass the cheap +check: ask Claude whether the claim is still supported by the new +content. Costs API tokens; off by default; uses the user's existing +Claude Code auth via `claude -p`. + +Outputs are written back into DRIFT.json + DRIFT.md with new fields: + severity_original the band stage 1 assigned + severity_after_revalidation the band after surviving claims + revalidated_at ISO timestamp + revalidation_summary "8/12 EXTRACTED quotes still validate" + revalidation_per_claim per-claim {claim_id, tag, status, ...} + +Usage: + python3 revalidate_drift.py --wiki-root=path/to/customer + python3 revalidate_drift.py --wiki-root=... --llm # stage 3 too +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +# Normalization mirrors live_fetchers.normalize_for_compare so EXTRACTED +# substring checks survive trivial blockquote/whitespace differences. +_BLOCKQUOTE_PREFIX = re.compile(r"^\s*>\s?", re.MULTILINE) +_WHITESPACE = re.compile(r"\s+") + +SEVERITY_RANK = {"high": 3, "medium": 2, "low": 1} + + +def _slug(title: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") + return s or "section" + + +def normalize(text: str) -> str: + s = _BLOCKQUOTE_PREFIX.sub("", text) + s = _WHITESPACE.sub(" ", s) + return s.strip().lower() + + +def collect_anchors_and_sections(text: str) -> dict[str, str]: + """Return {anchor_slug: text_under_this_heading}. + + Anchors come from explicit `{#kebab-slug}` decorations OR are derived + from heading titles. The section text is everything from the heading + line until the next heading of equal-or-greater rank. + """ + out: dict[str, str] = {} + lines = text.splitlines() + # Walk to find headings + their text spans. + heading_positions: list[tuple[int, int, str, str | None]] = [] # (line_idx, level, title, explicit_anchor) + for i, line in enumerate(lines): + m = re.match(r"^(#{1,6})\s+(.+?)(?:\s+\{#([a-z0-9\-]+)\})?\s*$", line) + if not m: + continue + level = len(m.group(1)) + title = m.group(2).strip() + explicit = m.group(3) + heading_positions.append((i, level, title, explicit)) + for idx, (line_i, level, title, explicit) in enumerate(heading_positions): + # Find the end of this section: next heading of level <= this one. + end_i = len(lines) + for next_i, next_level, _, _ in heading_positions[idx + 1:]: + if next_level <= level: + end_i = next_i + break + section_body = "\n".join(lines[line_i + 1:end_i]).strip() + for anchor in [explicit, _slug(title)]: + if anchor and anchor not in out: + out[anchor] = section_body + return out + + +def load_json(p: Path) -> dict | None: + if not p.is_file(): + return None + try: + return json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + + +def load_claims_for_file(wiki_root: Path, narrative_file: str) -> list[dict]: + """Load the claims sidecar for one narrative file.""" + sidecar = wiki_root / (narrative_file + ".claims.json") + sc = load_json(sidecar) + return sc.get("claims", []) if sc else [] + + +def revalidate_extracted(claim: dict, new_source_text: str) -> tuple[str, str]: + """Returns (status, detail). + + status: "pass" (quote still matches), "fail" (quote not found), + "no_quote" (claim was EXTRACTED but no quote in def — shouldn't happen). + """ + quote = claim.get("quote") + if not quote: + return ("no_quote", "EXTRACTED claim has no quote field") + norm_src = normalize(new_source_text) + norm_quote = normalize(quote) + if norm_quote and norm_quote in norm_src: + return ("pass", "verbatim quote still present in new content") + return ("fail", f"verbatim quote not found in new content: {quote[:80]!r}") + + +def revalidate_inferred_cheap( + claim: dict, new_source_text: str, anchors: dict[str, str], +) -> tuple[str, str]: + """Cheap INFERRED check: does the cited #anchor still exist? + + Returns (status, detail). + + status: + "pass" anchor exists in new content — claim still supportable + "fail" anchor doesn't exist — claim is orphaned + "no_source" claim has no source pointer + """ + # Look at the first source pointer. INFERRED can have multiple sources; + # we re-check against the first one (canonical citation). + sources = claim.get("sources") or [] + if not sources: + return ("no_source", "INFERRED claim has no source pointer") + src = sources[0] + if "#" not in src: + # No anchor — claim cites the file as a whole. Pass if file + # is non-empty. + return ("pass" if new_source_text.strip() else "fail", + "no #anchor in citation; checked file is non-empty") + _, anchor = src.split("#", 1) + if anchor not in anchors: + return ("fail", f"anchor #{anchor} no longer resolves in new content") + # Cheap "is the section materially the same as expected" check we + # can't really do without the OLD content. We DO have the section + # text; absent prior content, just confirm the section is non-empty. + section = anchors[anchor] + if not section.strip(): + return ("fail", f"anchor #{anchor} resolves but section is empty") + return ("pass", f"anchor #{anchor} still resolves with non-empty content") + + +def _claude_env() -> dict: + """Env for shelling out to claude -p — strip nested-Claude sentinels + so the call falls back to the user's stored OAuth credentials.""" + env = dict(os.environ) + if env.get("CLAUDECODE"): + for k in list(env.keys()): + if ( + k in ("CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL") + or k.startswith("CLAUDE_CODE_") + or k.startswith("CLAUDE_AGENT_") + ): + env.pop(k, None) + return env + + +def revalidate_inferred_llm( + claim_raw: str, new_source_text: str, anchor: str | None, +) -> tuple[str, str]: + """Stage 3: ask Claude whether the INFERRED claim is still supported + by the new source content. Called for any INFERRED claim that passed + the cheap anchor check (Stage 2), when --llm is set on the CLI. + + The round-1 cleanup removed the difflib gray-zone band that previously + gated this call; we now invoke the LLM for every survivor of Stage 2. + + Returns (status, detail) where status is "pass"/"fail"/"llm_failed". + """ + section_hint = "" + anchors = collect_anchors_and_sections(new_source_text) + if anchor and anchor in anchors: + section_hint = f"\n\n## Anchored section (#{anchor})\n\n{anchors[anchor][:3000]}" + prompt = ( + "You are validating whether a wiki claim is still supported by its " + "cited source after the source content changed.\n\n" + f"## Claim definition\n\n{claim_raw}\n\n" + f"## Current source content (truncated to 5000 chars)\n\n" + f"{new_source_text[:5000]}" + f"{section_hint}\n\n" + "Answer with exactly one line in this format:\n" + " VERDICT: PASS — \n" + " or\n" + " VERDICT: FAIL — \n" + "\nDo not output any other text. PASS means a careful reader would " + "consider the claim's load-bearing assertions to still hold up given " + "the current source; FAIL means the source no longer says what the " + "claim asserts." + ) + try: + proc = subprocess.run( + ["claude", "-p", "--dangerously-skip-permissions", prompt], + capture_output=True, text=True, env=_claude_env(), timeout=60, + ) + except FileNotFoundError: + return ("llm_failed", "claude CLI not on PATH") + except subprocess.TimeoutExpired: + return ("llm_failed", "claude -p timed out after 60s") + out = (proc.stdout or "").strip() + if proc.returncode != 0: + return ("llm_failed", f"claude -p exit {proc.returncode}: {(proc.stderr or '')[:200]}") + # Parse VERDICT line. + m = re.search(r"VERDICT:\s*(PASS|FAIL)\s*[—\-:]\s*(.+)", out, re.IGNORECASE) + if not m: + return ("llm_failed", f"could not parse VERDICT from response: {out[:200]}") + verdict = m.group(1).upper() + reason = m.group(2).strip() + return ("pass" if verdict == "PASS" else "fail", f"LLM: {reason}") + + +def revalidate_one_drift( + wiki_root: Path, drift: dict, use_llm: bool, +) -> dict: + """Revalidate a single CHANGED drift entry. Returns enrichment dict + to merge into the drift. + + Only CHANGED (default-mode) is supported today. live_changed has + the live content available too but the comparison logic differs; + extend later. + """ + if drift.get("kind") != "changed": + return {} # only revalidate CHANGED for now + source_path = drift.get("source") + abs_path = wiki_root / source_path + if not abs_path.is_file(): + return {"revalidation_error": f"source file missing on disk: {source_path}"} + new_text = abs_path.read_text(encoding="utf-8", errors="replace") + anchors = collect_anchors_and_sections(new_text) + + per_claim: list[dict] = [] + survivors_by_band: dict[str, int] = defaultdict(int) + totals_by_band: dict[str, int] = defaultdict(int) + + for ci in drift.get("claims_impacted", []) or []: + tag = ci.get("tag", "") + totals_by_band[tag] += 1 + claim_record = { + "claim_id": ci.get("claim_id"), + "tag": tag, + "file": ci.get("file"), + } + if tag == "EXTRACTED": + status, detail = revalidate_extracted( + {"quote": ci.get("quote")}, new_text, + ) + claim_record["status"] = status + claim_record["detail"] = detail + if status == "pass": + pass # claim no longer impacts — survives = "doesn't impact" + else: + survivors_by_band[tag] += 1 + elif tag == "INFERRED": + # Need the claim's first source pointer; not always in + # claims_impacted (we have claim_id but not sources). Look it up + # in the sidecar. + full_claim = _lookup_claim(wiki_root, ci.get("file"), ci.get("claim_id")) + if full_claim is None: + claim_record["status"] = "lookup_failed" + claim_record["detail"] = "couldn't find full claim record in sidecar" + survivors_by_band[tag] += 1 + else: + # Inject the cited source path into our pseudo-claim for + # revalidate_inferred_cheap. + pseudo = {"sources": full_claim.get("sources", [])} + status, detail = revalidate_inferred_cheap( + pseudo, new_text, anchors, + ) + if status == "pass" and use_llm: + # Optional stage 3 — ask Claude whether the claim still holds. + src = (pseudo["sources"] or [""])[0] + anchor = src.split("#", 1)[1] if "#" in src else None + llm_status, llm_detail = revalidate_inferred_llm( + full_claim.get("raw", ""), new_text, anchor, + ) + status, detail = llm_status, llm_detail + claim_record["status"] = status + claim_record["detail"] = detail + if status != "pass": + survivors_by_band[tag] += 1 + else: + # AMBIGUOUS — always drop. Weak signal not worth gating severity on. + claim_record["status"] = "dropped_ambiguous" + claim_record["detail"] = "AMBIGUOUS claims are not re-validated" + per_claim.append(claim_record) + + # Recompute severity from surviving claims. + if survivors_by_band.get("EXTRACTED", 0) > 0: + new_severity = "high" + elif survivors_by_band.get("INFERRED", 0) > 0: + new_severity = "medium" + else: + new_severity = "low" + + # Summary line for DRIFT.md. + parts = [] + for band in ("EXTRACTED", "INFERRED", "AMBIGUOUS"): + if totals_by_band.get(band, 0) > 0: + n_pass = totals_by_band[band] - survivors_by_band.get(band, 0) + parts.append(f"{n_pass}/{totals_by_band[band]} {band}") + summary = "; ".join(parts) if parts else "no claims to revalidate" + + return { + "severity_original": drift.get("severity"), + "severity_after_revalidation": new_severity, + "revalidated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "revalidation_summary": summary + " still validate", + "revalidation_per_claim": per_claim, + "revalidation_used_llm": use_llm, + } + + +# --- Claim lookup helper --- + +_CLAIMS_CACHE: dict[tuple[Path, str], list[dict]] = {} + + +def _lookup_claim(wiki_root: Path, file: str, claim_id: str) -> dict | None: + key = (wiki_root, file) + if key not in _CLAIMS_CACHE: + _CLAIMS_CACHE[key] = load_claims_for_file(wiki_root, file) + for c in _CLAIMS_CACHE[key]: + if c.get("id") == claim_id: + return c + return None + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument( + "--llm", action="store_true", + help="Use claude -p as a stage-3 fallback for INFERRED claims " + "that pass the cheap anchor-exists check. Costs API tokens. " + "Requires Claude Code CLI on PATH.", + ) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + drift_path = wiki_root / "DRIFT.json" + drift = load_json(drift_path) + if drift is None: + sys.exit("DRIFT.json missing — run source_diff.py first.") + + n_revalidated = 0 + n_downgraded = 0 + enriched: list[dict] = [] + for d in drift.get("drifts", []): + enrichment = revalidate_one_drift(wiki_root, d, use_llm=args.llm) + if enrichment: + d.update(enrichment) + n_revalidated += 1 + if ( + SEVERITY_RANK.get(enrichment.get("severity_after_revalidation"), 0) + < SEVERITY_RANK.get(enrichment.get("severity_original"), 0) + ): + n_downgraded += 1 + enriched.append(d) + drift["drifts"] = enriched + drift["revalidation_run_at"] = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + drift["revalidation_used_llm"] = args.llm + + drift_path.write_text( + json.dumps(drift, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + # Append a re-validation footer to DRIFT.md (the JSON is the + # machine-readable source of truth and has already been written above; + # we don't re-render the MD body — just append a summary footer so the + # downgrade decisions are visible in the rendered drift view). + # Re-rendering the whole MD properly is for a follow-up. + + md_path = wiki_root / "DRIFT.md" + existing_md = md_path.read_text(encoding="utf-8") if md_path.is_file() else "" + + footer = ["", "---", "", "## Re-validation results (stage 2 / 3)", ""] + if not n_revalidated: + footer.append("_No CHANGED drift entries to re-validate._") + else: + footer.append( + f"_Re-ran per-claim checks against {n_revalidated} CHANGED entry/entries. " + f"{n_downgraded} severity downgrade(s) after re-validation_" + + (" (LLM-assisted)" if args.llm else " (cheap stack only)") + + "._" + ) + footer.append("") + for d in enriched: + if "severity_after_revalidation" not in d: + continue + orig = d.get("severity_original", "?") + new = d.get("severity_after_revalidation", "?") + arrow = "→" if orig != new else "=" + footer.append( + f"- `{d.get('id')}` `{d.get('source')}`: " + f"**{orig}** {arrow} **{new}** " + f"({d.get('revalidation_summary', '')})" + ) + md_path.write_text(existing_md + "\n".join(footer) + "\n", encoding="utf-8") + + if not args.quiet: + print( + f"revalidated {n_revalidated} drift(s); {n_downgraded} downgraded " + + ("(LLM-assisted)" if args.llm else "(cheap stack only)"), + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/source_diff.py b/skills/ccb-customer-context-builder/scripts/source_diff.py new file mode 100644 index 00000000..68bf709d --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/source_diff.py @@ -0,0 +1,831 @@ +#!/usr/bin/env python3 +"""Detect drift between a wiki's source_manifest.json and the sources on disk +(default mode) or on the live external systems (--live mode). + +Five drift kinds: + + Local (always checked): + CHANGED — source path is in the manifest but its sha256 differs + from the current on-disk hash. Someone edited the local + copy. + DELETED — source path is in the manifest but the file no longer + exists on disk. Anything citing it is orphaned. + NEW — file exists under any sources/ directory but isn't in + the manifest. Probably a coverage opportunity. + + Live (only with --live): + LIVE_CHANGED — re-fetched live content differs from the on-disk + snapshot. The wiki has a stale picture of the source + (someone edited the doc, the schema changed, etc.). + LIVE_DELETED — live URI no longer resolves (404 / file deleted / + table dropped). The source is gone at the origin. + +Severity is computed from claim impact. EXTRACTED claims carry verbatim +quotes, so any drift in a source they cite is HIGH (the quote may now be +wrong). INFERRED claims are MEDIUM. No-claim drift is LOW. + +Outputs DRIFT.md (human-readable) + DRIFT.json (machine-readable, for the +Drift tab) at the wiki root. + +Usage: + # Local drift only (no GCP auth needed) + python3 source_diff.py --wiki-root=path/to/customer + + # Local + live drift (needs the same GCP auth that built the wiki) + python3 source_diff.py --wiki-root=path/to/customer --live + + # Acknowledged entries are filtered out of the next report + python3 source_diff.py --wiki-root=... --acknowledged-file=.drift-acknowledged.json +""" +from __future__ import annotations + +import argparse +import concurrent.futures +import datetime as dt +import hashlib +import json +import sys +from collections import defaultdict +from dataclasses import asdict, dataclass, field +from pathlib import Path + +# Make sibling scripts importable when run as a CLI from any cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from acknowledge_drift import ACK_FILENAME # noqa: E402 + +# Live-mode dependencies are conditional — we import only when --live is set, +# so the default code path stays import-free of GCP/google-api libs. +SOURCE_DIR_NAME = "sources" + + +@dataclass +class ClaimImpact: + claim_id: str + file: str # narrative file the claim lives in + tag: str # EXTRACTED | INFERRED | AMBIGUOUS + quote: str | None = None + + +@dataclass +class Drift: + id: str + kind: str # changed | deleted | new | live_changed | live_deleted | live_failed + severity: str # high | medium | low + source: str # wiki-relative path + manifest_hash: str | None + current_hash: str | None + size_change: int # bytes (positive = grew, negative = shrunk) + claims_impacted: list[ClaimImpact] = field(default_factory=list) + explanation: str = "" + suggested_action: str = "" + # Live-mode extras (only populated when --live ran) + live_check: str | None = None # "fetched" | "skipped" | "failed" + live_skip_reason: str | None = None + explanation_extra: str = "" # used by live_failed for the error msg + fetcher: str | None = None # which live fetcher matched + + +def hash_file(path: Path) -> tuple[str, int]: + """Return (sha256, size_bytes) for path. Hashes the same way build_manifest + does (sha256 of utf-8 text with replacement decoding).""" + text = path.read_text(encoding="utf-8", errors="replace") + return ( + hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest(), + len(text), + ) + + +def load_json(path: Path) -> dict | None: + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + print(f"warning: {path} is not valid JSON: {e}", file=sys.stderr) + return None + + +def collect_current_sources(wiki_root: Path) -> dict[str, tuple[str, int]]: + """Walk wiki_root, hash every *.md under any sources/ directory (excluding + the auto-generated sources/index.md). Returns {wiki_relative_path: (sha256, size)}.""" + out: dict[str, tuple[str, int]] = {} + for src_dir in wiki_root.rglob(SOURCE_DIR_NAME): + if not src_dir.is_dir(): + continue + for p in sorted(src_dir.glob("*.md")): + if p.name == "index.md": + continue + rel = p.relative_to(wiki_root).as_posix() + out[rel] = hash_file(p) + return out + + +def build_claim_map( + wiki_root: Path, claims_index: dict | None, +) -> dict[str, list[ClaimImpact]]: + """source_path -> [ClaimImpact, ...] (claims that cite this source). + + Walks every per-file claims.json sidecar listed in claims_index. Strips + #anchor from source pointers so a single source path aggregates all + claims regardless of which anchor they hit. + """ + impacts: dict[str, list[ClaimImpact]] = defaultdict(list) + if claims_index is None: + return impacts + for rel in claims_index.get("files_with_claims", []): + sidecar = wiki_root / (rel + ".claims.json") + sc = load_json(sidecar) + if not sc: + continue + for claim in sc.get("claims", []): + for src in claim.get("sources", []): + src_path = src.split("#", 1)[0] + impacts[src_path].append(ClaimImpact( + claim_id=claim.get("id", ""), + file=rel, + tag=claim.get("tag", ""), + quote=claim.get("quote"), + )) + return impacts + + +def severity_for(kind: str, claim_impacts: list[ClaimImpact]) -> str: + """Severity rules: + - changed / live_changed + any EXTRACTED claim → high (quote may be wrong) + - changed / live_changed + any INFERRED claim → medium + - changed / live_changed + only AMBIGUOUS or none → low + - deleted / live_deleted + any claim → high (orphaned citation) + - deleted / live_deleted + no claim → low + - new → low (it's an opportunity, not a regression) + - live_failed → low (operational issue, not data drift) + """ + bands = {ci.tag for ci in claim_impacts} + if kind in ("deleted", "live_deleted"): + return "high" if claim_impacts else "low" + if kind == "new": + return "low" + if kind == "live_failed": + return "low" + # changed / live_changed + if "EXTRACTED" in bands: + return "high" + if "INFERRED" in bands: + return "medium" + return "low" + + +def explain(d: Drift) -> str: + """Short human explanation for the DRIFT.md row.""" + if d.kind == "changed": + size_word = ( + f"+{d.size_change} bytes" if d.size_change > 0 + else f"{d.size_change} bytes" if d.size_change < 0 + else "size unchanged" + ) + bands = {ci.tag for ci in d.claims_impacted} + if d.claims_impacted: + band_summary = ", ".join(sorted(bands)) + return ( + f"Hash differs from manifest ({size_word}); " + f"{len(d.claims_impacted)} claim(s) cite this source " + f"({band_summary})" + ) + return f"Hash differs from manifest ({size_word}); no claims cite this source" + if d.kind == "deleted": + n = len(d.claims_impacted) + if n == 0: + return "Source file no longer exists; no claims cited it" + return f"Source file no longer exists; {n} claim(s) cited it (now orphaned)" + if d.kind == "new": + return "Present on disk but not in manifest — source added since last build" + if d.kind == "live_changed": + bands = {ci.tag for ci in d.claims_impacted} + if d.claims_impacted: + band_summary = ", ".join(sorted(bands)) + return ( + f"Live source content differs from on-disk snapshot; " + f"{len(d.claims_impacted)} claim(s) cite this source " + f"({band_summary}). The wiki's snapshot of this source is stale." + ) + return ( + "Live source content differs from on-disk snapshot; " + "no claims cite this source" + ) + if d.kind == "live_deleted": + n = len(d.claims_impacted) + if n == 0: + return "Live source URI no longer resolves (404 / file deleted / table dropped)" + return ( + f"Live source URI no longer resolves; {n} claim(s) cited it " + f"(now orphaned at the origin)" + ) + if d.kind == "live_failed": + return f"Live re-fetch failed: {d.explanation_extra}" + return "" + + +def suggest(d: Drift) -> str: + if d.kind == "changed" and d.claims_impacted: + files = sorted({ci.file for ci in d.claims_impacted}) + files_str = ", ".join(f"`{f}`" for f in files[:3]) + ( + f" (+{len(files)-3} more)" if len(files) > 3 else "" + ) + return ( + f"Re-run `claims_sidecar.py` to verify EXTRACTED quotes still " + f"validate; if any fail, regenerate the affected narrative " + f"section: {files_str}" + ) + if d.kind == "deleted": + if d.claims_impacted: + files = sorted({ci.file for ci in d.claims_impacted}) + return ( + f"Repoint the orphaned citation(s) in " + f"{', '.join(f'`{f}`' for f in files[:3])} to a still-extant source, " + f"or delete the citation if the underlying claim is no longer supportable." + ) + return "Update the manifest by re-running `build_manifest.py`." + if d.kind == "new": + return ( + "Decide whether to cite this source from a narrative file " + "(adds coverage) or leave it as standalone retrieval." + ) + if d.kind == "live_changed": + files = sorted({ci.file for ci in d.claims_impacted}) + if files: + return ( + f"Re-capture the source via the warehouse / personal_context " + f"agent so the on-disk snapshot is current; then re-run " + f"`claims_sidecar.py` and regenerate any affected narrative: " + f"{', '.join(f'`{f}`' for f in files[:3])}." + ) + return ( + "Re-capture the source so the on-disk snapshot matches live; " + "no narrative cites this source so claim impact is zero." + ) + if d.kind == "live_deleted": + return ( + "Source is gone at the origin. Decide whether to delete the " + "on-disk snapshot (and any citations to it) or treat the snapshot " + "as a historical record of a now-removed source." + ) + if d.kind == "live_failed": + return ( + "Operational issue, not data drift. Verify auth and try again " + "(`gcloud auth list`, ADC, etc.). Acknowledge if this source " + "is intentionally unavailable." + ) + return "" + + +def make_drift_id(kind: str, source: str) -> str: + h = hashlib.sha256(f"{kind}|{source}".encode("utf-8")).hexdigest()[:8] + return f"drift-{kind[0].upper()}-{h}" + + +def _collapse_live_failed(drifts: list[Drift]) -> tuple[list[Drift], list[dict]]: + """When several live_failed entries share the same root error (e.g. all + say `[OTHER] command not found: 'bq'`), they're almost certainly a single + operational issue — `bq` isn't installed, ADC isn't set up. Listing them + individually buries the real signal in noise. + + Group live_failed entries by the leading `[KIND] ` prefix. + For any group with >= 3 entries, drop the entries and emit a single + "blanket banner" pseudo-entry the renderer can render once. + + Returns (kept_drifts, banners) where banners is [{kind_prefix, count, + sample_explanation, sources}, ...]. + """ + grouped: dict[str, list[Drift]] = defaultdict(list) + other: list[Drift] = [] + for d in drifts: + if d.kind != "live_failed": + other.append(d) + continue + # Group key: just the [KIND] tag and the first ~60 chars of the + # explanation. Different doc IDs / table FQNs in the message body + # would produce different keys, so we deliberately truncate. + prefix = (d.explanation_extra or "")[:60] + grouped[prefix].append(d) + + banners: list[dict] = [] + kept_failed: list[Drift] = [] + for key, entries in grouped.items(): + if len(entries) >= 3: + banners.append({ + "key": key, + "count": len(entries), + "sample_explanation": entries[0].explanation_extra, + "sources": [e.source for e in entries[:5]] + ( + [f"... +{len(entries)-5} more"] if len(entries) > 5 else [] + ), + }) + else: + kept_failed.extend(entries) + + return other + kept_failed, banners + + +def detect_live( + wiki_root: Path, manifest: dict, claim_impacts: dict[str, list[ClaimImpact]], + *, max_workers: int = 4, +) -> tuple[list[Drift], dict[str, str]]: + """Re-fetch each source from its origin and compare to the on-disk + snapshot. Returns (drifts, skip_summary). + + Imports live_fetchers lazily so the default code path isn't gated on + google-api-python-client / bq CLI being available. + """ + try: + from live_fetchers import ( # type: ignore + dispatch, normalize_for_compare, extract_on_disk_body, + ) + except ImportError: + # When run via the skill installation, scripts dir is on sys.path + # via the script's __file__; this branch handles odd PYTHONPATH cases. + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from live_fetchers import ( # type: ignore + dispatch, normalize_for_compare, extract_on_disk_body, + ) + + sources = manifest.get("sources", []) + drifts: list[Drift] = [] + # Track (path -> reason) for sources we couldn't check — so the JSON + # report can explain why some sources weren't live-checked. + skips: dict[str, str] = {} + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = {ex.submit(dispatch, s): s for s in sources} + for fut in concurrent.futures.as_completed(futures): + src = futures[fut] + outcome = fut.result() + path = outcome.source_path + + if outcome.skip_reason is not None: + skips[path] = outcome.skip_reason + continue + + on_disk_path = wiki_root / path + if not on_disk_path.is_file(): + # Already covered by local DELETED detection — skip. + continue + + if outcome.error is not None: + if outcome.error.kind == "NOT_FOUND": + d = Drift( + id=make_drift_id("live_deleted", path), + kind="live_deleted", + severity="", + source=path, + manifest_hash=src.get("sha256"), + current_hash=None, + size_change=0, + claims_impacted=claim_impacts.get(path, []), + live_check="failed", + explanation_extra=str(outcome.error), + ) + else: + d = Drift( + id=make_drift_id(f"live_failed_{outcome.error.kind}", path), + kind="live_failed", + severity="", + source=path, + manifest_hash=src.get("sha256"), + current_hash=None, + size_change=0, + claims_impacted=claim_impacts.get(path, []), + live_check="failed", + explanation_extra=f"[{outcome.error.kind}] {outcome.error}", + ) + d.severity = severity_for(d.kind, d.claims_impacted) + d.explanation = explain(d) + d.suggested_action = suggest(d) + drifts.append(d) + continue + + # Successful fetch — compare normalized live vs. normalized on-disk gist body. + assert outcome.snapshot is not None + disk_text = on_disk_path.read_text(encoding="utf-8", errors="replace") + disk_norm = normalize_for_compare(extract_on_disk_body(disk_text)) + live_norm = normalize_for_compare(outcome.snapshot.body) + + if disk_norm == live_norm: + continue # no live drift for this source + + # Compute a coarse "live size delta" — bytes between normalized forms. + size_change = len(live_norm) - len(disk_norm) + d = Drift( + id=make_drift_id("live_changed", path), + kind="live_changed", + severity="", + source=path, + manifest_hash=src.get("sha256"), + current_hash=None, + size_change=size_change, + claims_impacted=claim_impacts.get(path, []), + live_check="fetched", + fetcher=outcome.snapshot.fetcher, + ) + d.severity = severity_for(d.kind, d.claims_impacted) + d.explanation = explain(d) + d.suggested_action = suggest(d) + drifts.append(d) + + return drifts, skips + + +def detect( + wiki_root: Path, manifest: dict, claim_impacts: dict[str, list[ClaimImpact]], + *, acknowledged_ids: set[str], +) -> list[Drift]: + current = collect_current_sources(wiki_root) + manifest_sources = {s["path"]: s for s in manifest.get("sources", [])} + + drifts: list[Drift] = [] + + # CHANGED + DELETED: walk the manifest, compare against current. + for path, msrc in manifest_sources.items(): + manifest_hash = msrc.get("sha256") + manifest_size = msrc.get("size", 0) + if path not in current: + d = Drift( + id=make_drift_id("deleted", path), + kind="deleted", + severity="", + source=path, + manifest_hash=manifest_hash, + current_hash=None, + size_change=-manifest_size, + claims_impacted=claim_impacts.get(path, []), + ) + d.severity = severity_for(d.kind, d.claims_impacted) + d.explanation = explain(d) + d.suggested_action = suggest(d) + drifts.append(d) + continue + cur_hash, cur_size = current[path] + if cur_hash != manifest_hash: + d = Drift( + id=make_drift_id("changed", path), + kind="changed", + severity="", + source=path, + manifest_hash=manifest_hash, + current_hash=cur_hash, + size_change=cur_size - manifest_size, + claims_impacted=claim_impacts.get(path, []), + ) + d.severity = severity_for(d.kind, d.claims_impacted) + d.explanation = explain(d) + d.suggested_action = suggest(d) + drifts.append(d) + + # NEW: in current but not in manifest. + for path in current: + if path not in manifest_sources: + d = Drift( + id=make_drift_id("new", path), + kind="new", + severity="", + source=path, + manifest_hash=None, + current_hash=current[path][0], + size_change=current[path][1], + claims_impacted=claim_impacts.get(path, []), + ) + d.severity = severity_for(d.kind, d.claims_impacted) + d.explanation = explain(d) + d.suggested_action = suggest(d) + drifts.append(d) + + # Filter out acknowledged drifts. + drifts = [d for d in drifts if d.id not in acknowledged_ids] + + # Sort: severity desc, then kind, then path. + sev_rank = {"high": 0, "medium": 1, "low": 2} + drifts.sort(key=lambda d: (sev_rank.get(d.severity, 9), d.kind, d.source)) + return drifts + + +def severity_emoji(sev: str) -> str: + return {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(sev, "⚪") + + +def render_md( + drifts: list[Drift], wiki_root: Path, manifest: dict, + *, live_skips: dict[str, str] | None = None, live_enabled: bool = False, + live_banners: list[dict] | None = None, +) -> str: + live_skips = live_skips or {} + live_banners = live_banners or [] + by_kind: dict[str, list[Drift]] = defaultdict(list) + for d in drifts: + by_kind[d.kind].append(d) + + # Total live_failed for the header summary includes those collapsed + # into banners — otherwise the header undercounts the operational issue. + banner_failed = sum(b["count"] for b in live_banners) + live_failed_count = len(by_kind.get("live_failed", [])) + banner_failed + + lines = [ + f"# Drift — {wiki_root.name}", + "", + f"_Generated by `source_diff.py` at " + f"{dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}_" + + (" — `--live` enabled" if live_enabled else "") + ".", + f"_Manifest built at: {manifest.get('built_at', 'unknown')}_", + "", + f"**{len(by_kind.get('changed', []))}** changed · " + f"**{len(by_kind.get('deleted', []))}** deleted · " + f"**{len(by_kind.get('new', []))}** new" + + ( + f" · **{len(by_kind.get('live_changed', []))}** live-changed · " + f"**{len(by_kind.get('live_deleted', []))}** live-deleted · " + f"**{live_failed_count}** live-failed" + if live_enabled else "" + ), + "", + ] + + # Surface blanket-failure banners up top, before the per-entry sections. + # Helpful when 13 of 13 live fetches failed because `bq` isn't installed + # — listing them individually buries the real signal. + for banner in live_banners: + lines.append( + f"> **🛑 {banner['count']} live fetch(es) failed with the same error.** " + f"This is almost certainly a single operational issue, not " + f"per-source data drift." + ) + lines.append(">") + lines.append(f"> **Error:** `{banner['sample_explanation']}`") + lines.append(">") + lines.append("> **Affected sources:** " + + ", ".join(f"`{s}`" for s in banner["sources"])) + lines.append(">") + lines.append( + "> **Likely fixes:** install the missing CLI (`bq` / `gcloud`) " + "or set up auth (`gcloud auth login`, " + "`GOOGLE_APPLICATION_CREDENTIALS`). Re-run `source_diff.py " + "--live` once fixed." + ) + lines.append("") + if not drifts and not live_skips: + lines.append("_No drift detected — every source in the manifest still " + "matches its on-disk content, no extras have appeared._") + lines.append("") + return "\n".join(lines) + + for kind, label in ( + ("changed", "Changed sources (local)"), + ("deleted", "Deleted sources (local)"), + ("new", "New sources (local)"), + ("live_changed", "Live-changed sources (the wiki snapshot is stale)"), + ("live_deleted", "Live-deleted sources (gone at the origin)"), + ("live_failed", "Live-fetch failures (operational, not data drift)"), + ): + items = by_kind.get(kind, []) + if not items: + continue + lines.append(f"## {label}") + lines.append("") + for d in items: + lines.append(f"### {d.id} — `{d.source}`") + lines.append("") + lines.append(f"- **Severity:** {d.severity} {severity_emoji(d.severity)}") + lines.append(f"- **Kind:** {d.kind}") + if d.fetcher: + lines.append(f"- **Live fetcher:** `{d.fetcher}`") + if d.kind not in ("new", "live_changed", "live_deleted", "live_failed"): + lines.append(f"- **Manifest hash:** `{(d.manifest_hash or '?')[:12]}`") + if d.kind == "changed": + lines.append(f"- **Current hash:** `{(d.current_hash or '?')[:12]}`") + if d.claims_impacted: + bands = {ci.tag for ci in d.claims_impacted} + files = sorted({ci.file for ci in d.claims_impacted}) + lines.append( + f"- **Claims impacted:** {len(d.claims_impacted)} " + f"({', '.join(sorted(bands))}) across " + f"{', '.join(f'`{f}`' for f in files[:3])}" + + (f" (+{len(files)-3} more)" if len(files) > 3 else "") + ) + lines.append(f"- **Explanation:** {d.explanation}") + lines.append(f"- **Suggested action:** {d.suggested_action}") + lines.append("") + + if live_enabled and live_skips: + lines.append("## Sources skipped (live mode)") + lines.append("") + lines.append( + "_These sources weren't live-checked. Most are by-design " + "volatile (drift would be expected) or don't have a live " + "fetcher in this version._" + ) + lines.append("") + for path, reason in sorted(live_skips.items()): + lines.append(f"- `{path}` — {reason}") + lines.append("") + return "\n".join(lines) + + +def render_json( + drifts: list[Drift], wiki_root: Path, manifest: dict, + *, live_skips: dict[str, str] | None = None, live_enabled: bool = False, + live_banners: list[dict] | None = None, +) -> dict: + live_skips = live_skips or {} + live_banners = live_banners or [] + all_kinds = ("changed", "deleted", "new", + "live_changed", "live_deleted", "live_failed") + return { + "schema": "drift.v1", + "wiki_root": wiki_root.name, + "checked_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "manifest_built_at": manifest.get("built_at"), + "live_source_check": live_enabled, + "drift_count": len(drifts), + "by_severity": { + sev: sum(1 for d in drifts if d.severity == sev) + for sev in ("high", "medium", "low") + }, + "by_kind": { + kind: sum(1 for d in drifts if d.kind == kind) + for kind in all_kinds + }, + "drifts": [ + { + **{k: v for k, v in asdict(d).items() if k != "claims_impacted"}, + "claims_impacted": [asdict(ci) for ci in d.claims_impacted], + } + for d in drifts + ], + "live_skips": [ + {"path": path, "reason": reason} + for path, reason in sorted(live_skips.items()) + ], + "live_banners": [ + { + "count": b["count"], + "sample_explanation": b["sample_explanation"], + "sources": b["sources"], + } + for b in live_banners + ], + } + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wiki-root", required=True) + ap.add_argument( + "--acknowledged-file", + default=ACK_FILENAME, + help="Path (relative to wiki root) to a JSON file listing acknowledged " + "drift IDs. Acknowledged entries are dropped from the output. " + f"Default: {ACK_FILENAME}", + ) + ap.add_argument( + "--baseline", default=None, + help="Compare against a pinned snapshot (snapshots/.json) " + "instead of the current source_manifest.json. Useful for " + "answering 'what's drifted since I pinned this baseline at " + "QBR / release / audit time?'. Pin a baseline with " + "build_manifest.py --snapshot=.", + ) + ap.add_argument( + "--live", action="store_true", + help="Also re-fetch each source from its origin (BigQuery / Drive) " + "and compare to the on-disk snapshot. Adds live_changed / " + "live_deleted / live_failed entries to the drift report. " + "Requires the same GCP auth used to build the wiki originally.", + ) + ap.add_argument( + "--live-workers", type=int, default=4, + help="Concurrency for --live fetches (default 4). Lower if you're " + "hitting BigQuery/Drive rate limits.", + ) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wiki_root = Path(args.wiki_root).resolve() + if not wiki_root.is_dir(): + sys.exit(f"--wiki-root not a directory: {wiki_root}") + + if args.baseline: + baseline_path = wiki_root / "snapshots" / f"{args.baseline}.json" + manifest = load_json(baseline_path) + if manifest is None: + available = [] + snap_dir = wiki_root / "snapshots" + if snap_dir.is_dir(): + available = sorted(p.stem for p in snap_dir.glob("*.json")) + hint = ( + f" Available snapshots in this wiki: {', '.join(available)}" + if available else + " No snapshots exist yet. Pin one with `build_manifest.py " + "--snapshot=`." + ) + sys.exit(f"baseline snapshot not found: {baseline_path}.{hint}") + if not args.quiet: + print( + f"using baseline snapshot: {args.baseline} " + f"(pinned {manifest.get('snapshotted_at', '?')})", + file=sys.stderr, + ) + else: + manifest = load_json(wiki_root / "source_manifest.json") + if manifest is None: + sys.exit( + "source_manifest.json missing — run build_manifest.py first to " + "establish the baseline." + ) + claims_index = load_json(wiki_root / "claims_index.json") + # claims_index can be None — drift detection still works, severity just + # falls back to "low" for everything since no claim impact is computable. + + claim_impacts = build_claim_map(wiki_root, claims_index) + + ack_path = wiki_root / args.acknowledged_file + ack_data = load_json(ack_path) or {} + acknowledged_ids: set[str] = set(ack_data.get("acknowledged_ids", [])) + + drifts = detect( + wiki_root, manifest, claim_impacts, + acknowledged_ids=acknowledged_ids, + ) + + live_skips: dict[str, str] = {} + live_banners: list[dict] = [] + if args.live: + if not args.quiet: + print( + f"--live: re-fetching {len(manifest.get('sources', []))} " + f"source(s) (workers={args.live_workers})…", + file=sys.stderr, + ) + live_drifts, live_skips = detect_live( + wiki_root, manifest, claim_impacts, + max_workers=args.live_workers, + ) + # Filter live drifts through the same acknowledgement set, then + # collapse runs of identical live_failed entries into banners so a + # blanket "bq not installed" or "auth failed for everything" doesn't + # bury the real drift signal. + live_drifts = [d for d in live_drifts if d.id not in acknowledged_ids] + live_drifts, live_banners = _collapse_live_failed(live_drifts) + drifts = sorted( + drifts + live_drifts, + key=lambda d: ( + {"high": 0, "medium": 1, "low": 2}.get(d.severity, 9), + d.kind, d.source, + ), + ) + + md_path = wiki_root / "DRIFT.md" + json_path = wiki_root / "DRIFT.json" + md_path.write_text( + render_md(drifts, wiki_root, manifest, live_skips=live_skips, + live_enabled=args.live, live_banners=live_banners), + encoding="utf-8", + ) + json_path.write_text( + json.dumps( + render_json(drifts, wiki_root, manifest, + live_skips=live_skips, live_enabled=args.live, + live_banners=live_banners), + indent=2, ensure_ascii=False, + ) + "\n", + encoding="utf-8", + ) + + if not args.quiet: + by_kind = {k: sum(1 for d in drifts if d.kind == k) + for k in ("changed", "deleted", "new", + "live_changed", "live_deleted", "live_failed")} + by_sev = {s: sum(1 for d in drifts if d.severity == s) + for s in ("high", "medium", "low")} + # Mirror render_md's accounting: live_failed entries collapsed into + # banners are dropped from `drifts` by _collapse_live_failed, so the + # raw by_kind['live_failed'] count undercounts the operational issue. + # Add banner counts back in so stderr matches DRIFT.md's header. + banner_failed = sum(b["count"] for b in live_banners) + msg = ( + f"drift: {by_kind['changed']} changed · " + f"{by_kind['deleted']} deleted · {by_kind['new']} new" + ) + if args.live: + msg += ( + f" · {by_kind['live_changed']} live_changed · " + f"{by_kind['live_deleted']} live_deleted · " + f"{by_kind['live_failed'] + banner_failed} live_failed" + ) + msg += ( + f" (severity: {by_sev['high']}H {by_sev['medium']}M {by_sev['low']}L) " + f"→ {md_path.name}, {json_path.name}" + ) + print(msg, file=sys.stderr) + # Exit 1 if any HIGH-severity drift exists — useful for CI. + high = sum(1 for d in drifts if d.severity == "high") + return 0 if high == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-customer-context-builder/scripts/test_live_drift.py b/skills/ccb-customer-context-builder/scripts/test_live_drift.py new file mode 100644 index 00000000..7ad2cda4 --- /dev/null +++ b/skills/ccb-customer-context-builder/scripts/test_live_drift.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Smoketest for source_diff.py --live mode using fake fetchers. + +Validates: + - dispatch() correctly routes to fetchers based on lineage / URI patterns + - SKIP_PATTERNS catches volatile sources (JOBS_BY_PROJECT, etc.) + - Successful fetch with matching content → no drift + - Successful fetch with differing content → live_changed entry + - Fetcher raising NOT_FOUND → live_deleted entry + - Fetcher raising AUTH_FAILED → live_failed entry + - normalize_for_compare strips blockquotes + collapses whitespace correctly + +Run with no args: + python3 test_live_drift.py + +Exit 0 = all passed, 1 = something failed. +""" +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import live_fetchers # noqa: E402 +import source_diff # noqa: E402 + + +def assert_eq(name: str, actual, expected) -> None: + if actual != expected: + print(f"FAIL {name}: expected {expected!r}, got {actual!r}") + sys.exit(1) + print(f"PASS {name}") + + +def assert_in(name: str, needle, haystack) -> None: + if needle not in haystack: + print(f"FAIL {name}: {needle!r} not in {haystack!r}") + sys.exit(1) + print(f"PASS {name}") + + +# ---------------- normalize_for_compare ---------------- + +print("\n=== normalize_for_compare ===") +assert_eq( + "strip blockquote prefix", + live_fetchers.normalize_for_compare("> hello world"), + "hello world", +) +assert_eq( + "collapse whitespace", + live_fetchers.normalize_for_compare("hello world\n\nfoo"), + "hello world foo", +) +assert_eq( + "lowercase", + live_fetchers.normalize_for_compare("HELLO World"), + "hello world", +) + + +# ---------------- extract_on_disk_body ---------------- + +print("\n=== extract_on_disk_body ===") +src_md = """# Retrieved from + +- **Source:** Google Doc — https://... +- **Retrieved at:** 2026-05-04T00:00:00Z + +# Gists + +## Data flow {#data-flow} + +> A nightly ELT job materializes fact_orders_daily. + +## Open issues {#open-issues} + +> 30% of partitions written without filters. +""" +body = live_fetchers.extract_on_disk_body(src_md) +assert_in("body contains data flow gist", "A nightly ELT job", body) +assert_in("body contains open issues gist", "30% of partitions", body) + + +# ---------------- dispatch: routing ---------------- + +print("\n=== dispatch routing ===") + +gdoc_record = { + "path": "personal_context/sources/foo.md", + "source_uri": "Google Doc — https://docs.google.com/document/d/abc123def/edit", + "lineage": "python3 scripts/gdocs_extract.py --doc-id=abc123def --max-chars=15000", +} +gsheet_record = { + "path": "personal_context/sources/bar.md", + "source_uri": "Google Sheet — https://docs.google.com/spreadsheets/d/sheet456/edit", + "lineage": "python3 scripts/gsheets_extract.py --sheet-id=sheet456 --rows-per-tab=15", +} +bq_schema_record = { + "path": "events_raw/sources/bq_show_schema.md", + "source_uri": "BigQuery table `myproj:mydataset.mytable`", + "lineage": "bq show --schema --format=prettyjson myproj:mydataset.mytable", +} +volatile_record = { + "path": "sources/bq_jobs_by_project.md", + "source_uri": "BigQuery region-us.INFORMATION_SCHEMA.JOBS_BY_PROJECT for project foo", + "lineage": "bq query --project_id=foo ...", +} +unsupported_record = { + "path": "sources/dataplex_lakes_list.md", + "source_uri": "Dataplex — lakes in foo", + "lineage": "gcloud dataplex lakes list --project=foo --location=- --format=json", +} + +# Monkey-patch the fetchers so we can assert dispatch behavior without GCP. +calls: list[tuple[str, dict]] = [] +original_fetchers = { + "fetch_gdoc": live_fetchers.fetch_gdoc, + "fetch_gsheet": live_fetchers.fetch_gsheet, + "fetch_bq_schema": live_fetchers.fetch_bq_schema, + "fetch_bq_dataset_list": live_fetchers.fetch_bq_dataset_list, +} + +def fake_gdoc(**kwargs): + calls.append(("gdoc", kwargs)) + return "fake gdoc body content" + +def fake_gsheet(**kwargs): + calls.append(("gsheet", kwargs)) + return "fake gsheet body content" + +def fake_bq_schema(**kwargs): + calls.append(("bq_schema", kwargs)) + return '{"fields": [{"name": "id", "type": "INT64"}]}' + +def fake_bq_list(**kwargs): + calls.append(("bq_dataset_list", kwargs)) + return '{"datasets": []}' + +# Monkey-patch via the dispatch table — fetchers are stored by reference. +for entry in live_fetchers._DISPATCH: + if entry["name"] == "gdoc": + entry["fetcher"] = fake_gdoc + elif entry["name"] == "gsheet": + entry["fetcher"] = fake_gsheet + elif entry["name"] == "bq_schema": + entry["fetcher"] = fake_bq_schema + elif entry["name"] == "bq_dataset_list": + entry["fetcher"] = fake_bq_list + +out = live_fetchers.dispatch(gdoc_record) +assert_eq("gdoc fetcher selected", out.snapshot.fetcher if out.snapshot else None, "gdoc") +assert_eq("gdoc doc_id extracted", calls[-1][1].get("doc_id"), "abc123def") + +out = live_fetchers.dispatch(gsheet_record) +assert_eq("gsheet fetcher selected", out.snapshot.fetcher if out.snapshot else None, "gsheet") +assert_eq("gsheet sheet_id extracted", calls[-1][1].get("sheet_id"), "sheet456") + +out = live_fetchers.dispatch(bq_schema_record) +assert_eq("bq_schema fetcher selected", out.snapshot.fetcher if out.snapshot else None, "bq_schema") +assert_eq("bq_schema fqn normalized to dot form", + calls[-1][1].get("table_fqn"), "myproj.mydataset.mytable") + +# Volatile + unsupported should skip, not call any fetcher. +calls_before = len(calls) +out = live_fetchers.dispatch(volatile_record) +assert_eq("volatile JOBS_BY_PROJECT was skipped", + out.snapshot, None) +assert_in("volatile skip_reason mentions volatile", "volatile", + (out.skip_reason or "").lower()) +assert_eq("volatile didn't call any fetcher", len(calls), calls_before) + +out = live_fetchers.dispatch(unsupported_record) +assert_eq("dataplex was skipped", out.snapshot, None) +assert_in("dataplex skip_reason mentions dataplex", "dataplex", + (out.skip_reason or "").lower()) + + +# ---------------- detect_live: end-to-end ---------------- + +print("\n=== detect_live end-to-end ===") + +# Build a tiny fake wiki + manifest. +with tempfile.TemporaryDirectory() as td: + wiki = Path(td) + (wiki / "personal_context" / "sources").mkdir(parents=True) + (wiki / "events_raw" / "sources").mkdir(parents=True) + (wiki / "sources").mkdir() + + # On-disk source — we'll make the live fetch return DIFFERENT content + # to trigger live_changed. + foo_md = """# Retrieved from + +- **Source:** Google Doc — https://docs.google.com/document/d/abc123def/edit +- **Lineage:** python3 scripts/gdocs_extract.py --doc-id=abc123def + +# Gists + +## Section + +> The original on-disk content from when we captured this doc. +""" + (wiki / "personal_context" / "sources" / "foo.md").write_text(foo_md) + + # On-disk source — live fetch will return SAME content (no drift after + # normalize_for_compare strips blockquote markers + collapses whitespace). + # The on-disk Gist body and the live fetcher's return value must + # normalize to the same string. + bar_md = """# Retrieved from + +- **Source:** Google Sheet — https://docs.google.com/spreadsheets/d/sheet456/edit +- **Lineage:** python3 scripts/gsheets_extract.py --sheet-id=sheet456 + +# Gists + +## Sample +> matching content from the live fetch +""" + (wiki / "personal_context" / "sources" / "bar.md").write_text(bar_md) + + # On-disk source — live fetch will raise NOT_FOUND → live_deleted. + baz_md = """# Retrieved from + +- **Source:** BigQuery table `myproj:mydataset.deletedtable` +- **Lineage:** bq show --schema --format=prettyjson myproj:mydataset.deletedtable + +# Gists + +## Schema + +```json +{"fields": []} +``` +""" + (wiki / "events_raw" / "sources" / "bq_show_schema.md").write_text(baz_md) + + manifest = { + "schema": "source_manifest.v1", + "built_at": "2026-05-09T00:00:00Z", + "sources": [ + {"path": "personal_context/sources/foo.md", "sha256": "x", "size": 100, + "source_uri": "Google Doc — https://docs.google.com/document/d/abc123def/edit", + "lineage": "python3 scripts/gdocs_extract.py --doc-id=abc123def"}, + {"path": "personal_context/sources/bar.md", "sha256": "y", "size": 100, + "source_uri": "Google Sheet — https://docs.google.com/spreadsheets/d/sheet456/edit", + "lineage": "python3 scripts/gsheets_extract.py --sheet-id=sheet456"}, + {"path": "events_raw/sources/bq_show_schema.md", "sha256": "z", "size": 100, + "source_uri": "BigQuery table `myproj:mydataset.deletedtable`", + "lineage": "bq show --schema --format=prettyjson myproj:mydataset.deletedtable"}, + ], + } + + # Configure fakes for this test: foo returns DIFFERENT content (drift), + # bar returns MATCHING content (no drift after normalization), baz raises NOT_FOUND. + def matched_gdoc(**kwargs): + if kwargs.get("doc_id") == "abc123def": + return "Completely different live content that doesn't match on-disk." + raise live_fetchers.LiveFetchError("OTHER", "unexpected doc_id") + + def matched_gsheet(**kwargs): + # Return a string that, after normalize_for_compare, matches the + # normalized on-disk gist body. The on-disk body includes the + # `## Sample` heading; the live fetcher's output should too so they + # normalize to the same canonical form. + return "## Sample\nmatching content from the live fetch" + + def deleted_bq_schema(**kwargs): + raise live_fetchers.LiveFetchError("NOT_FOUND", "table not found") + + for entry in live_fetchers._DISPATCH: + if entry["name"] == "gdoc": + entry["fetcher"] = matched_gdoc + elif entry["name"] == "gsheet": + entry["fetcher"] = matched_gsheet + elif entry["name"] == "bq_schema": + entry["fetcher"] = deleted_bq_schema + + drifts, skips = source_diff.detect_live( + wiki, manifest, claim_impacts={}, max_workers=2, + ) + + by_kind = {d.kind: d for d in drifts} + assert_in("foo.md → live_changed", "live_changed", by_kind) + assert_eq("foo.md is the live_changed source", + by_kind["live_changed"].source, "personal_context/sources/foo.md") + + assert_in("bq_show_schema.md → live_deleted", "live_deleted", by_kind) + assert_eq("baz is the live_deleted source", + by_kind["live_deleted"].source, "events_raw/sources/bq_show_schema.md") + + # bar.md matched live → no drift entry + bar_entries = [d for d in drifts if d.source.endswith("/bar.md")] + assert_eq("bar.md (matching content) → no drift", len(bar_entries), 0) + + +print("\nALL PASSED") +sys.exit(0) diff --git a/skills/ccb-customer-context-builder/templates/index_format.md b/skills/ccb-customer-context-builder/templates/index_format.md new file mode 100644 index 00000000..25aee710 --- /dev/null +++ b/skills/ccb-customer-context-builder/templates/index_format.md @@ -0,0 +1,173 @@ +# Index file format + +Every directory in the generated wiki contains an `index.md` whose +purpose is **agentic retrieval**. A downstream agent that lands in any +directory should be able to read `index.md` alone and decide which peer +files to load and which child directories to descend into. + +## Required structure + +```markdown +# Summary + +{1–5 paragraphs of key information about this directory's contents. +Lead with the *what* and the *why*: what does this dir document, what's +the headline takeaway, what's the most important thing a reader needs +to know before deciding whether to descend further. Use specific +nouns — table names, owners, model versions, dollar figures — not +generic prose.} + +# Index + +- `peer_file_1.md` — short description of what's in it (one line, max ~120 chars) +- `peer_file_2.md` — same +- ... one entry per peer file in this directory, except `index.md` itself + +# Child Indexes + +- [sources/index.md](sources/index.md) — All retrieved context that was used to construct the documentation in this directory +- [{child_dir}/index.md]({child_dir}/index.md) — what this child documents +- ... one entry per child directory +``` + +## Writing guidelines + +**The summary is load-bearing.** It's the thing a retrieval agent +reads first to decide if this branch of the tree is relevant. Treat it +like an abstract: lead with the conclusion, then add detail. + +**Descriptions are how an agent prunes.** "tables in the dataset" is +useless because every dir might have that. "Daily order fact (grain +order_date×user×SKU); partitioned on order_date; source for v2 +attribution rollups" is useful because an agent searching for revenue +data immediately knows to descend. + +**No prose outside the three sections.** No intro paragraph above +`# Summary`. No "Generated by..." footer. The structure is what makes +this index machine-navigable. + +**Match the spec exactly:** sections are `# Summary`, `# Index`, +`# Child Indexes` — case and spelling matter for any tool that parses +these. + +**If a directory has no peers or no children, omit the section** rather +than writing "(none)". An empty section is noise. + +**Citation footnotes in `# Summary`** — every fact-bearing sentence in +a `# Summary` paragraph that didn't originate in the indexer (i.e. is +pulled from a peer narrative file or source gist) must carry a +footnote `[^cN]`. The footnote definition declares the claim's +confidence and points at the source. See the **Claim citations** +section below for the exact format. Index `# Summary` paragraphs that +genuinely paraphrase a peer narrative file should downgrade an +EXTRACTED claim to INFERRED. + +## Claim citations (footnote format) + +Every fact-bearing sentence in a narrative file (`data_warehouse.md`, +`{table}/lineage.md`, `{table}/fields.md` notes, +`personal_context/internal_notes.md`, and any non-source `# Summary` +that paraphrases a peer file) must be followed by a citation +footnote `[^cN]`. The footnote definition declares the **confidence +band** and points at the source. + +**Three confidence bands:** + +- **EXTRACTED** — the narrative quotes the source verbatim (the + sentence appears in a `> ...` block quoting the source) or restates + a single specific value lifted unmodified (a number, a column name, + a URL). +- **INFERRED** — the narrative is derived from one or more source + gists by paraphrase or synthesis, but every load-bearing element is + directly supported by something in the cited source(s). +- **AMBIGUOUS** — the narrative depends on a judgment call, two + sources contradict, or the supporting evidence is weaker than + INFERRED requires. Use sparingly; AMBIGUOUS claims should be flagged + in `Gaps and caveats` of the parent file. + +**Footnote definition format:** + +``` +[^c1]: EXTRACTED · `personal_context/sources/pipeline-design-doc-q1-2026.md#data-flow` · "Partitioned by `order_date`." +[^c2]: INFERRED · derived from `sources/bq_jobs_by_project.md#top-patterns` +[^c3]: AMBIGUOUS · `personal_context/sources/open-blockers-live.md#status` says HIGH severity; `pipeline-health-tracker.md#open-issues` says MEDIUM +``` + +Three positional, dot-separated fields: + +1. **Tag** — `EXTRACTED`, `INFERRED`, or `AMBIGUOUS`. +2. **Source pointer** — backticked path (relative to the customer + wiki root) optionally followed by `#anchor`. For EXTRACTED with + multiple verbatim sources, separate with `+` (`source-a.md + source-b.md`). + For AMBIGUOUS, list the conflicting sources with a one-clause + description of the conflict. +3. **Verbatim quote** (EXTRACTED only) — the exact substring in + double-quotes. Optional for INFERRED; omitted for AMBIGUOUS. + +**Numbering** — claim IDs are local to the file (`[^c1]`, `[^c2]`, +…). The `claims_sidecar.py` build pass rewrites these to stable +content-hash IDs in `.claims.json`; the markdown keeps the +human-readable form. + +**Where citations are required:** + +- Every paragraph in a narrative file (`data_warehouse.md`, + `lineage.md`, `internal_notes.md`) — at minimum one citation + per fact-bearing paragraph; ideally one per sentence that adds a + specific claim. +- Every row of a table that asserts a fact lifted from a source. +- `# Summary` paragraphs in any `index.md` that paraphrase a peer + narrative file. + +**Where citations are NOT required:** + +- Source files themselves (the `# Gists` are the source of truth; + citing a source from itself is circular). +- `# Index` and `# Child Indexes` link descriptions in `index.md` — + these are navigational, not fact-bearing. +- Headings, prose connective tissue ("The data flows as follows:"), + obvious enumerations of files in the directory. + +**On rewording / paraphrase** — when an indexer or critic re-derives +a paragraph from an EXTRACTED-cited paragraph by paraphrasing it +into a summary, the new paragraph's citation downgrades to INFERRED +(the verbatim relationship no longer holds for the new sentence, +even though the underlying source hasn't changed). + +## Example — top-level customer index + +```markdown +# Summary + +Acme Corp runs a marketing-attribution analytics workload entirely on +GCP project `acme-prod-123`.[^c1] A single BigQuery dataset +(`acme_analytics`, US, 5 tables) holds the data estate in a star schema +centered on `fact_orders_daily`;[^c2] the active rollup is +`attribution_summary_v2` (model v2.3, data-driven), with a deprecated +`_v1` (last-touch) still receiving traffic from two unmigrated +dashboards.[^c3] A HIGH-severity partition-filter regression on +`fact_orders_daily` (opened 2026-04-22, owner jordan@) is currently +driving ~4x slot usage.[^c4] Dataplex governance is enabled but +unused.[^c5] + +[^c1]: INFERRED · derived from `data_warehouse.md#overview` +[^c2]: EXTRACTED · `sources/bq_dataset_list.md#dataset-list` · "acme_analytics · US · 5 tables" +[^c3]: INFERRED · derived from `personal_context/sources/migration-plan-attribution-v1-to-v2.md#status` + `sources/bq_jobs_by_project.md#top-patterns` +[^c4]: EXTRACTED · `personal_context/sources/open-blockers-live.md#open-issues` · "2026-04-22 | HIGH | jordan@acme.example.com | fact_orders_daily | Partition filter regression — 4x slot usage" +[^c5]: INFERRED · derived from `sources/dataplex_catalog_search.md#summary` + +# Index + +- `data_warehouse.md` — narrative overview of the data estate, query patterns, governance posture +- `retrieval_methods.md` — how to fetch additional context (commands, regions, scopes) + +# Child Indexes + +- [sources/index.md](sources/index.md) — Warehouse-level retrieval sources (BQ dataset list, JOBS_BY_PROJECT, Dataplex catalog snapshot) +- [events_raw/index.md](events_raw/index.md) — Raw web event stream, day-partitioned, ~10M rows/day in prod +- [dim_users/index.md](dim_users/index.md) — User dimension table (300 rows in test); plan tier, country, lifetime value +- [fact_orders_daily/index.md](fact_orders_daily/index.md) — Daily order fact, day-partitioned, **HIGH-severity partitioning regression in flight** +- [attribution_summary_v1/index.md](attribution_summary_v1/index.md) — DEPRECATED last-touch rollup; still being read from two dashboards +- [attribution_summary_v2/index.md](attribution_summary_v2/index.md) — Active data-driven attribution rollup; model v2.3 +- [personal_context/index.md](personal_context/index.md) — Internal team docs and tracking sheets about this customer +``` diff --git a/skills/ccb-customer-context-builder/templates/source_format.md b/skills/ccb-customer-context-builder/templates/source_format.md new file mode 100644 index 00000000..0d7c73c7 --- /dev/null +++ b/skills/ccb-customer-context-builder/templates/source_format.md @@ -0,0 +1,133 @@ +# Source file format + +A "source" is **one retrieval that produced data the wiki relies on**. +Every claim in the wiki should be traceable to a source file. Source +files preserve verbatim "gists" — the exact snippets from the original +data — so a downstream LLM can quote authoritative material rather than +paraphrasing the wiki's prose. + +Source files carry an additional `# Retrieved from` field, **`Content hash:`**, populated by the build (see `scripts/build_manifest.py`). The +build hashes the underlying source content (BQ schema JSON, Drive doc +body, Sheet rows) so a later run can detect drift. Agents leave this +field blank or omit it — the build fills it in. + +## Required structure + +```markdown +# Retrieved from + +- **Source:** {URL, command, file path, or other origin identifier} +- **Lineage:** {how this was obtained — the command run, the API endpoint, etc.} +- **Absolute path:** {where the source artifact lives, if applicable} +- **Retrieved at:** {ISO 8601 UTC timestamp of when this snapshot was taken} +- **Content hash:** {sha256 of the underlying source content; left blank by the agent — the build fills this in} + +# Gists + +## {Gist Title} {#anchor-slug} + +{Literal snippet — quoted verbatim from the source. Preserve formatting +where it carries meaning (code blocks, tables, lists). Do NOT paraphrase.} + +## {Another Gist Title} {#another-anchor} + +{Another verbatim snippet} +``` + +Each gist title carries a stable anchor (`{#anchor-slug}`) so narrative +files can cite into a specific gist. Anchors should be kebab-cased +and short — `{#data-flow}`, `{#open-issues}`, `{#sql-used}`. If a gist +title is omitted from cross-narrative citations, the build derives +the anchor from the title automatically. + +## Writing guidelines + +**Verbatim, not summary.** The wiki's prose layer summarizes; source +files preserve the raw material. If the source is a doc that says +"fact_orders_daily partitioning is broken; ~30% of partitions written +without filters", quote that line — don't write "the doc mentions +partition issues." + +**Pick gists that are load-bearing.** A source file shouldn't dump the +entire source — pick the 2–6 snippets that any downstream LLM would +actually want to ground an answer on. Skip preamble, table-of-contents, +boilerplate. + +**Code/SQL/JSON blocks belong in fenced code.** Preserve the language +hint where useful. + +**Title gists by what's in them, not by source structure.** "Migration +cutover plan" beats "Section 2.3" — the title is what an agent searches +on. + +**One source per file.** If a single Doc has both warehouse-wide +context and per-table context, it appears in two source files (one +under top-level `sources/`, one under `{table}/sources/`), each gisting +the relevant excerpts. Duplication is fine; cross-referencing keeps the +locality property. + +## Example — BigQuery query patterns source + +```markdown +# Retrieved from + +- **Source:** BigQuery `region-us.INFORMATION_SCHEMA.JOBS_BY_PROJECT` for project `acme-prod-123` +- **Lineage:** `bq query --project_id=acme-prod-123 --nouse_legacy_sql --format=prettyjson` against the SQL below +- **Retrieved at:** 2026-05-04T22:18:00Z + +# Gists + +## SQL used + +```sql +SELECT + REGEXP_REPLACE(query, r'\d+', 'N') AS query_pattern, + COUNT(*) AS run_count, + ... +FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT +WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) + AND statement_type = 'SELECT' +GROUP BY query_pattern +ORDER BY run_count DESC +LIMIT 50 +``` + +## Top patterns by run count (verbatim) + +| query_pattern (truncated) | run_count | top_users | avg_slot_seconds | +|---|---|---|---| +| `SELECT order_date, SUM(revenue) ... fact_orders_daily GROUP BY order_date ...` | 3 | claude-skill-test@... | 0.4 | +| `SELECT channel, SUM(revenue) ... attribution_summary_vN ...` | 2 | claude-skill-test@... | 0.5 | +| `SELECT 'events_raw' AS t, COUNT(*) ...` | 1 | claude-skill-test@... | 0.3 | +``` + +## Example — Drive doc source + +```markdown +# Retrieved from + +- **Source:** Google Doc — https://docs.google.com/document/d/1ts4knSMoHWQjfAtWCJrywN1xOEA822xLKV_q07DU2JQ/edit +- **Title:** "Acme — Pipeline Design Doc (Q1 2026)" +- **Last modified:** 2026-05-05 by oscarkang24@gmail.com +- **Retrieved at:** 2026-05-04T22:18:00Z + +# Gists + +## Data flow + +> 1. The web tracker writes raw events to `events_raw`. Append-only, +> partitioned by `event_ts`. Volume in prod is ~10M rows/day. +> 2. A nightly ELT job materializes `fact_orders_daily` from a join of +> events_raw + the orders source-of-truth in our OLTP. +> 3. The weekly attribution job reads `fact_orders_daily` and `dim_users`, +> runs the data-driven attribution model (v2.3 as of this writing), +> and writes weekly rollups to `attribution_summary_v2`. + +## Open issues called out + +> - fact_orders_daily partitioning is inconsistent: roughly 30% of +> recent partitions were created without the partition filter, +> causing full-table scans. +> - The `attribution_channel` column in fact_orders_daily uses the +> last-touch logic, which is inconsistent with the v2 model. +``` diff --git a/skills/ccb-gcp-data-qa/README.md b/skills/ccb-gcp-data-qa/README.md new file mode 100644 index 00000000..747e0086 --- /dev/null +++ b/skills/ccb-gcp-data-qa/README.md @@ -0,0 +1,99 @@ +# gcp-data-qa + +A Claude Code skill that wraps Google Cloud's **Conversational Analytics +API** (`geminidataanalytics.googleapis.com`, also marketed as the +"Gemini Data Analytics API") so you can ask natural-language questions +about a customer's BigQuery data and get back the answer, the SQL +Gemini wrote, and the underlying data. + +Currently in **Preview** (free during preview; BigQuery query costs still +apply). + +## Why bundle this with the wiki-builder skill + +This skill works two ways: + +- **Standalone (Mode A)** — you specify the BigQuery tables explicitly. + Useful for one-off questions when no wiki has been built. +- **Wiki-grounded (Mode B)** — you point at a per-customer wiki produced + by [the customer-context-builder skill](../customer-context-builder/), + and this skill auto-extracts the table list AND composes a rich system + instruction from the wiki's narrative (operational warnings, deprecated + tables, partition gotchas, naming conventions). The agent's SQL is + notably better with this context — that's the value-add of having + built the wiki. + +## Install + +```bash +# From the repo root: +ln -s "$PWD/skills/gcp-data-qa" ~/.claude/skills/gcp-data-qa + +# Python deps: +pip install -r skills/gcp-data-qa/scripts/requirements.txt +``` + +## Auth + +Same gcloud / ADC stack as the customer-context-builder skill: + +```bash +gcloud auth login +gcloud auth application-default login # OR export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json + +# One-time per project: +gcloud services enable geminidataanalytics.googleapis.com --project= +``` + +## Use + +In Claude Code, ask in natural language: + +> Ask the acme-prod-123 data: what was Q1 revenue by channel? + +If a wiki for that customer exists at `./customer-context/context/acme-prod-123/`, +the skill will use it (Mode B). Otherwise it'll ask which tables to consider. + +Or directly via the wrapper: + +```bash +# Mode A — explicit tables +python3 skills/gcp-data-qa/scripts/data_qa.py \ + --project=acme-prod-123 \ + --table=acme-prod-123:acme_analytics.fact_orders_daily \ + --table=acme-prod-123:acme_analytics.dim_users \ + --question="What was total revenue last 30 days, broken down by country?" + +# Mode B — wiki-grounded +python3 skills/gcp-data-qa/scripts/data_qa.py \ + --project=acme-prod-123 \ + --wiki-dir=./customer-context/context/acme-prod-123 \ + --question="Which channel drove the most paid revenue last week?" +``` + +## Output + +JSON to stdout (see `SKILL.md` for the schema). Pass `--output-file=PATH` +to also write a human-readable markdown transcript. + +## Layout + +``` +skills/gcp-data-qa/ +├── SKILL.md +├── README.md +├── scripts/ +│ ├── check_prereqs.sh # auth + API + SDK preflight +│ ├── data_qa.py # the wrapper +│ ├── wiki_parser.py # Mode B: parse a wiki dir → (tables, system instruction) +│ └── requirements.txt +└── examples/ # populated after smoke test +``` + +## Status + +- v0.3 — initial scaffold; smoke-tested against the same `context-repo-building` + sandbox project the wiki-builder uses. +- Known limitation: only BigQuery datasources are supported in this + wrapper (the API also supports Looker, AlloyDB, Cloud SQL, Spanner; + add as needed). diff --git a/skills/ccb-gcp-data-qa/SKILL.md b/skills/ccb-gcp-data-qa/SKILL.md new file mode 100644 index 00000000..e8ab19b2 --- /dev/null +++ b/skills/ccb-gcp-data-qa/SKILL.md @@ -0,0 +1,203 @@ +--- +name: skill-ccb-gcp-data-qa +description: Ask natural-language questions about a GCP customer's BigQuery data via Google Cloud's Conversational Analytics API (also known as the "Gemini Data Analytics API"). Returns the agent's reasoning, the SQL it generated, the underlying result data, and a final natural-language answer. Use this skill whenever the user wants to query BigQuery in plain English without writing SQL — e.g., "what was Q1 revenue by channel?", "which dashboards still hit attribution_summary_v1?", "top 10 users by lifetime value." Trigger on phrasings like "ask the data", "ask BigQuery", "Gemini for BigQuery", "Conversational Analytics", "natural language query", or any time the user wants an analytic answer that would otherwise require hand-written SQL. If a customer-context wiki exists from the gcp-customer-context-builder skill, this skill can read it via --wiki-dir to ground the agent in the customer's specific data semantics (deprecated tables, partitioning quirks, naming conventions) — producing notably smarter SQL than a context-free agent would write. +--- + +# GCP Data Q&A (Conversational Analytics API) + +This skill lets you ask natural-language questions about a GCP +customer's BigQuery data. It wraps Google Cloud's Conversational +Analytics API (`geminidataanalytics.googleapis.com`, currently in +Preview), which: + +- Takes a natural-language question + a list of BigQuery tables + + (optionally) a system instruction — passed inline on every call via + `ChatRequest.inline_context`, so no `DataAgent` resource is created + or persisted in the project (stateless / agentless mode) +- Plans, generates SQL, executes it +- Streams back typed messages: the agent's THOUGHT process, PROGRESS + steps, the SQL written, the result data, and a final natural-language + ANSWER + +## When to trigger + +Anytime the user wants an analytic answer about BigQuery data without +writing SQL. Phrasings: "ask the data", "what's the answer to ...", +"how many ...", "which ...", "trend of ...", "top N ...". + +If the user mentions a specific customer (e.g., "ask the Acme data: +...") AND a customer-context wiki exists at the expected path, prefer +the **wiki-grounded** invocation — the agent produces much better SQL +when it knows about deprecated tables, partition gotchas, and naming +conventions. + +## Two invocation modes + +### Mode A — explicit tables (works without any wiki) + +User specifies the tables the agent should consider. Most ergonomic +for one-off questions when no wiki has been built. + +```bash +python3 "$SKILL_DIR/scripts/data_qa.py" \ + --project=acme-prod-123 \ + --table=acme-prod-123:acme_analytics.fact_orders_daily \ + --table=acme-prod-123:acme_analytics.dim_users \ + --question="What was total revenue in the last 30 days, broken down by country?" +``` + +### Mode B — wiki-grounded (uses output from gcp-customer-context-builder) + +The skill reads the per-customer wiki and: + +- Auto-extracts the table list from `data_warehouse.md`'s table + inventory section +- Builds a rich system instruction from the wiki's narrative — + table descriptions, deprecation warnings, partition gotchas, + cross-source operational notes, naming conventions + +```bash +python3 "$SKILL_DIR/scripts/data_qa.py" \ + --project=acme-prod-123 \ + --wiki-dir=./customer-context/context/acme-prod-123 \ + --question="Which channel drove the most paid revenue last week?" +``` + +The agent then knows (e.g.) to prefer `attribution_summary_v2` over +`attribution_summary_v1` (deprecated), to add `WHERE order_date >= ...` +filters on `fact_orders_daily` (partition regression in flight), and +to use the `paid_search` channel name, not `Paid Search`. **This is +the value-add of having built the wiki.** + +## Inputs + +- `--project` (required) — GCP project hosting the BQ tables and the + Conversational Analytics API +- One of: + - `--table=PROJECT:DATASET.TABLE` (repeatable) — for Mode A + - `--wiki-dir=PATH` — for Mode B (path to a per-customer wiki dir, + typically `./customer-context/context//`) +- `--question="..."` (required) — the natural-language question +- `--output-file=PATH` (optional) — also write a markdown transcript + of the session to this file +- `--chart-html=PATH` (optional) — explicit path for the interactive + Vega-Lite chart preview. If omitted, the wrapper auto-renders charts + to a tempfile (or to a sibling of `--output-file` if that's set) + whenever the agent emits chart specs. The resolved path comes back + in the JSON as `chart_html_path`. +- `--max-turns=N` (optional, default 1) — for multi-turn conversations + via the API's stateful `Conversation` resource. Default is single-shot. + +## Workflow + +You are the orchestrator. For most invocations, this is one bash call ++ pretty-printing — no sub-agents needed. + +### Step 1 — Resolve inputs + +Parse the user's request to determine: +- Project ID (required) +- Either explicit tables (Mode A) or a wiki dir (Mode B) +- The natural-language question + +If the user says "ask the X data: Y" and a wiki dir for X exists at +`./customer-context/context/X/`, use Mode B. Otherwise Mode A and ask +the user which tables to consider. + +### Step 2 — Verify prereqs + +Run `$SKILL_DIR/scripts/check_prereqs.sh`. It validates: + +- `gcloud` is authenticated +- The Conversational Analytics API is enabled in the target project +- ADC is set up (either user creds OR `GOOGLE_APPLICATION_CREDENTIALS`) +- Python SDK is installed (`google-cloud-geminidataanalytics`) + +### Step 3 — Run the wrapper + +`scripts/data_qa.py` does the work. It emits structured JSON to stdout: + +```json +{ + "question": "...", + "context_mode": "inline", + "generated_at": "2026-05-06T...", + "messages": [ + { "type": "THOUGHT", "text": "..." }, + { "type": "SQL", "text": "SELECT ..." }, + { "type": "DATA", "rows": [...], "schema": [...] }, + { "type": "FINAL_RESPONSE", "text": "Total revenue last 30 days was $..." } + ], + "duration_seconds": 4.3, + "status": "success" +} +``` + +### Step 4 — Present to the user + +Render the response in this order (don't dump raw JSON): + +1. **The question** (echoed) +2. **What the agent thought** — collapsed by default, expand if the + user asks "show your work" +3. **The SQL it wrote** — always shown (this is the auditable artifact) +4. **The result data** — first ~10 rows as a markdown table, with a + note about row count if more +5. **The final answer** — the prose answer Gemini wrote +6. **Chart preview** — if `chart_html_path` is set in the payload, the + wrapper has already rendered an interactive Vega-Lite preview at + that path. Surface it as a clickable markdown link so the user can + open it. Do not re-render or write a separate HTML file yourself. + +If `--output-file` was given, write the same content as a markdown file +to that path so the session is archivable. + +### Step 5 — Failures + +Common failures and how to handle them: + +- **API not enabled**: check_prereqs catches this; tell the user to run + `gcloud services enable geminidataanalytics.googleapis.com --project=PROJECT` +- **Insufficient IAM**: the calling principal needs `bigquery.dataViewer` + + `bigquery.jobUser` on the target tables, plus access to the + Conversational Analytics API. Surface the exact role to grant. +- **Invalid table reference**: the chat call will reject the inline + context with a clean error from the API; surface it directly. +- **Question can't be answered** (Gemini doesn't know how): the + FINAL_RESPONSE will say so. Pass it through to the user. + +## Wiki integration (Mode B) details + +When `--wiki-dir` is set, `scripts/wiki_parser.py` reads the wiki and +returns `(tables, system_instruction)`: + +- **Tables** are extracted from the table inventory in + `/data_warehouse.md` — looks for the markdown table that + lists every BQ table with its grain. +- **System instruction** is composed from: + - The customer-root `index.md` Summary (what this customer does) + - Each per-table `index.md` Summary (especially the operational + warnings: deprecated, partition issues, etc.) + - The `data_warehouse.md` "Cross-source operational stories" section + - Naming conventions from any `personal_context/sources/*onboarding*.md` + +The composed system instruction is what makes Mode B notably better +than Mode A. See `scripts/wiki_parser.py` for the exact extraction +logic — it's fail-soft (if the wiki is partially structured, we get +what we can and warn about gaps). + +## Why no sub-agents + +Unlike the customer-context-builder skill, this one is a single +synchronous call — there's no parallel data collection, no multi-step +synthesis, no quality critique to do. Just: prepare the request, call +the API, render the response. Adding a sub-agent layer would be ceremony +without benefit. + +## Reference files + +- [scripts/data_qa.py](scripts/data_qa.py) — the wrapper around `DataAgentServiceClient` + `DataChatServiceClient` +- [scripts/wiki_parser.py](scripts/wiki_parser.py) — Mode B: read a wiki, return tables + system instruction +- [scripts/check_prereqs.sh](scripts/check_prereqs.sh) — preflight (auth, API, SDK) +- [scripts/requirements.txt](scripts/requirements.txt) — `google-cloud-geminidataanalytics`, `PyYAML` +- [README.md](README.md) — install + usage from the command line diff --git a/skills/ccb-gcp-data-qa/examples/sample_session.md b/skills/ccb-gcp-data-qa/examples/sample_session.md new file mode 100644 index 00000000..459875d7 --- /dev/null +++ b/skills/ccb-gcp-data-qa/examples/sample_session.md @@ -0,0 +1,69 @@ +# Q&A session — What was the channel breakdown of revenue for the last 4 weeks? Use whichever attribution table is recommended. + +- Project: `context-repo-building` +- Tables: `context-repo-building.acme_analytics.attribution_summary_v1`, `context-repo-building.acme_analytics.attribution_summary_v2`, `context-repo-building.acme_analytics.dim_users`, `context-repo-building.acme_analytics.events_raw`, `context-repo-building.acme_analytics.fact_orders_daily` +- Agent: `agent_22615309acec` (reused) +- Generated at: 2026-05-07T00:41:14Z +- Duration: 37.56s + +## Question +> What was the channel breakdown of revenue for the last 4 weeks? Use whichever attribution table is recommended. + +## Answer + +Analyzing context +Retrieved context for 5 tables. + +My Thought Process for Summarizing Your Revenue Breakdown Request +Okay, I've been asked to provide a summary of revenue by channel for the last four weeks, using the recommended attribution table. This is a fairly standard analytical request, but the key here is to ensure I'm using the most up-to-date and relevant data source and to accurately define the time window. First, I need to identify the correct attribution table. The provided `retrieved_context` clearly flags `attribution_summary_v1` as "DEPRECATED" and advises against its use. On the other hand, `attribution_summary_v2` is described as the "Active data-driven attribution rollup" and the "designated source for attribution_channel going forward." This makes `attribution_summary_v2` my immediate choice. Next, I need to pin down "the last 4 weeks." My current timestamp is `2026-05-06`. Counting back four weeks from this date brings me to `2026-04-08`. Since the `attribution_summary_v2` table has a `week_start` column (which is of type DATE), I'll use this to filter the data, ensuring `week_start` is greater than or equal to `2026-04-08`. The schema confirms the presence of `week_start`, `channel`, and `revenue` columns, which are exactly what I need for this analysis. My plan then becomes: 1. Skill Selection: Although this query is standard SQL, the instructions mention checking for relevant skills. Since the user is asking for historical trends and data analysis, and the BigQuery AI skill description includes "historical trends," I'll load it. It's good practice to consider these even if the immediate query seems simple. 2. Disambiguation: Table: As established, `attribution_summary_v2` is the one to use. Timeframe: "Last 4 weeks" from `2026-05-06` means I need to filter for `week_start` from `2026-04-08` onwards. The fact that `attribution_summary_v2` is refreshed weekly reinforces the use of `week_start` for filtering. 3. SQL Generation: I'll construct a query that selects the `channel` and sums the `revenue` for that channel. I'll pull this from `context-repo-building.acme_analytics.attribution_summary_v2`. The `WHERE` clause will filter for `week_start >= DATE_SUB(DATE('2026-05-06'), INTERVAL 4 WEEK)`. I'll group the results by `channel` to aggregate revenue for each, and then order them by `total_revenue` in descending order to highlight the top-performing channels. A slight re-evaluation: The request is for a "breakdown... for the last 4 weeks." This implies an aggregate view across that entire period, not necessarily four separate weekly breakdowns. Summing the revenue per channel over the last four weeks will achieve this aggregate breakdown. The `week_start` column suggests weekly data points, so aggregating them into a single sum for each channel within that four-week window is the correct interpretation. The sample data also confirms that the relevant `week_start` dates fall within this calculated range. Finally, I'll consider if a visualization is appropriate. Given that there are multiple channels (the sample data shows 6 distinct values), a breakdown usually implies a visual representation like a bar chart or pie chart. I'll generate the data first, and if the conditions suggest visualization (e.g., fewer than 4 distinct time points, comparison across categories), I'll consider it. For now, the primary goal is to get the aggregated data for the specified period. Therefore, my final SQL query will look something like this: ```sql SELECT channel, SUM(revenue) AS total_revenue FROM `context-repo-building.acme_analytics.attribution_summary_v2` AS attribution WHERE week_start >= DATE_SUB(DATE('2026-05-06'), INTERVAL 4 WEEK) GROUP BY channel ORDER BY total_revenue DESC; ``` I'll use standard BigQuery date functions like `DATE_SUB` and `CURRENT_DATE()` (or in this specific case, the provided timestamp converted to a date) for the filtering. + +My Thought Process for Determining Channel Revenue Breakdown +I've successfully loaded the necessary skill to address your request. Your question, "What was the channel breakdown of revenue for the last 4 weeks?", is a classic SQL aggregation task, but given the mention of "historical trends," engaging this skill was a prudent step. You've directed me to "use whichever attribution table is recommended." Based on the `retrieved_context`, the `attribution_summary_v2` table is clearly indicated as the active and recommended choice, with `v1` being deprecated. Now, I need to construct the SQL query. For "last 4 weeks," I've taken into account that the most recent `week_start` in `v2` is `2026-05-04`, and the current date is `2026-05-06`. This means the period of interest spans roughly from April 13th to May 4th. While I could precisely calculate this by finding the max date and subtracting four weeks, the common interpretation of "last 4 weeks" is relative to today. The instruction to use "Standard BigQuery date/timestamp functions" guides my approach, and `DATE_SUB(CURRENT_DATE(), INTERVAL 4 WEEK)` is the appropriate function here. The core of your request is for a "breakdown," which necessitates an aggregation. Here's the SQL query I've generated: ```sql SELECT attribution.channel, SUM(attribution.revenue) AS total_revenue FROM `context-repo-building.acme_analytics.attribution_summary_v2` AS attribution WHERE attribution.week_start >= DATE_SUB(CURRENT_DATE(), INTERVAL 4 WEEK) GROUP BY attribution.channel ORDER BY total_revenue DESC; ``` I've confirmed the current date context (`2026-05-06`) and understand that `CURRENT_DATE()` will function correctly in a live environment. Furthermore, I've cross-referenced the `additional_context` which explicitly states, "The active rollup is `attribution_summary_v2` (data-driven, model `v2.3`)", reinforcing my decision to use this table. I am also explicitly noting that I am using `attribution_summary_v2` because `v1` is deprecated and known to have a 15% bias on `paid_search`. A minor point of consideration was whether you intended a weekly breakdown within the last 4 weeks or a total aggregate breakdown for that period. The phrasing "channel breakdown of revenue for the last 4 weeks" most directly points to a single aggregated figure per channel for the entire four-week span. If you were looking for a trend over time, words like "weekly breakdown" or "over the last 4 weeks" would typically be used. Therefore, I am providing the aggregate breakdown. While the skill mentions "Time Series Analysis" and a weekly view could offer more insight into trends, the direct answer to your question is the aggregated total. I've opted to stick with the aggregate first, as it's the most direct interpretation of your request. If further detail is needed, a weekly view could be explored. With that established, I will now execute the SQL query. + +My Thought Process on Channel Revenue Breakdown +Alright, the data is back, and I'm looking at a breakdown of revenue across different channels for the last four weeks. I see six distinct rows representing these channels. My immediate observation is that 'email' is pulling in the most revenue, around $213k. The next group – 'social_paid', 'organic_search', 'affiliate', and 'paid_search' – are all clustered very closely together, indicating they're performing quite similarly. 'direct' is bringing up the rear, generating the least revenue among the lot. Now, I need to think about how best to visualize this information. I'll run through my internal checklist for charting: AI Functions? No, that's not applicable here. Explicit Request? The user didn't explicitly ask for a chart. Fewer than 4 rows? No, I have 6 rows, so charting is permitted. Simple Comparison? Yes, this is a straightforward comparison of revenue by channel. Disconnected Data? No, this data is clearly related and forms a breakdown of a single metric (revenue). Given that I have 6 rows and it's a "breakdown" type of analysis, a bar chart or a pie chart would be suitable. For ranking and clear comparison, a horizontal bar chart is often the most effective. The explicit instruction states: "Does the result have fewer than 4 rows? If yes, you ABSOLUTELY MUST NOT chart it...". Since I have 6 rows, charting is a go. The instruction also specifies: "Comparison/Magnitude (e.g., 'Compare X across Y', 'by region', 'by product'): Use Bar or Column charts." This confirms a bar chart is appropriate. I'm going to opt for a horizontal bar chart to clearly show the ranking. However, I need to double-check the user's initial question to ensure I'm providing the most relevant output. The question was, "What was the channel breakdown... for the last 4 weeks?". My current plan is to show the total breakdown across the entire four-week period. This is a breakdown of the period, so I'll proceed with the aggregate horizontal bar chart. A thought crosses my mind: looking at the `attribution_summary_v2` schema (which I'm using because `v1` is deprecated, something I should note), I could potentially show a weekly breakdown to illustrate trends over time. But the user specifically asked for "the channel breakdown... for the last 4 weeks" in the singular, implying an aggregate view. So, I'll stick with presenting the overall aggregate. Now, let's consider the Python code for generating this chart. I've drafted the following: ```python import altair as alt import json data_result_name = 'channel_revenue_breakdown' chart_id = 'channel_revenue_breakdown_chart' chart = alt.Chart(data_result_name).mark_bar().encode( x=alt.X('total_revenue:Q', title='Total Revenue (USD)'), y=alt.Y('channel:N', sort='-x', title='Channel'), color=alt.Color('channel:N', legend=None) ).properties( title='Revenue Breakdown by Channel (Last 4 Weeks)', usermeta={'data_result_name': data_result_name, 'chart_id': chart_id} ) print(json.dumps(chart.to_dict())) ``` Just a quick review of the sorting requirement. The instruction mentioned: "Horizontal Bar (sorted)". My code `y=alt.Y('channel:N', sort='-x')` correctly sorts the channels by revenue in descending order, which is exactly what I want for a ranked horizontal bar chart. With all this considered, I'm ready to execute this Python code to generate the horizontal bar chart and present the revenue breakdown. + +My Thought Process on Channel Revenue Breakdown +Alright, I've got the data and the chart in front of me, and my immediate goal is to synthesize this into a clear summary and offer some meaningful insights. Looking at the last four weeks, the first thing that jumps out is that email is clearly the top performer in terms of revenue generation. It's pulling ahead of the pack. Beyond that, I'm seeing a pretty tight cluster of channels: social_paid, organic_search, affiliate, and paid_search. They're all operating within a relatively narrow revenue band, roughly a $10k difference separating them. This suggests a comparable level of impact from these sources right now. On the flip side, direct traffic is bringing up the rear; it's the lowest contributor to revenue in this period. It's crucial to mention the methodology here. I've opted to use the `attribution_summary_v2` table. Why? Because it's the active, data-driven model. The older `v1` is deprecated, and more importantly, it's known to have biases – specifically, a systematic 15% bias on `paid_search`. Using `v2` ensures we're working with the most accurate and up-to-date attribution. Just to be perfectly clear on the timeframe, this analysis is specifically for the "Last 4 weeks," with the `week_start` date being relative to today, May 6th, 2026. Now, thinking about next steps and potential deeper dives, a few questions come to mind: Trend Analysis: I'd be keen to understand the trajectory of our top channel. So, how has the revenue for the 'email' channel actually trended over the last three months? This would give us a more robust view than just the last four weeks. Forecasting: Given the performance of our top channels, it would be incredibly valuable to predict what's coming. I'd want to forecast the revenue for the top three channels for the next four weeks. This is where `AI.FORECAST` would come into play. Anomaly Detection: For channels like `paid_search` that are in a tighter performance band, identifying any unusual fluctuations is important. I'd want to check if there are any anomalies in the daily revenue for 'paid_search' over the last month. This would trigger `AI.DETECT_ANOMALIES`. Initially, I was just going to present the summary, but I realized the user's prompt specifically asked for the "recommended" attribution table. So, I've made sure to explicitly explain that `attribution_summary_v2` is the recommended choice because it's active and data-driven, unlike the deprecated `v1` which has known biases. With all that considered, I feel confident in finalizing this response. + +The revenue breakdown for the last 4 weeks shows that **Email** is the top-performing channel, followed closely by **Social Paid** and **Organic Search**. + +As recommended by the analytics documentation, this analysis uses the `attribution_summary_v2` table. This is the active data-driven attribution model (v2.3), which provides more accurate results than the deprecated `attribution_summary_v1` table. + +### Insights + +* **Leading Channel**: **Email** generated the highest total revenue (~$213,392) over the last 4 weeks. +* **Tight Competition**: Four channels—**Social Paid**, **Organic Search**, **Affiliate**, and **Paid Search**—are performing at very similar levels, with revenues clustered between approximately $185,000 and $195,000. +* **Lowest Contributor**: **Direct** traffic contributed the least to the total revenue (~$148,109) in this period. +* **Model Recommendation**: The `attribution_summary_v2` table was selected because the previous version (`v1`) is deprecated and carries a known 15% bias in its `paid_search` and `email` attribution. + +How has the revenue for the 'email' channel trended over the last 3 months? +Predict the revenue for the top 3 channels for the next 4 weeks. +Detect anomalies in the daily revenue for 'paid_search' over the last 60 days. + +## SQL + +```sql +SELECT + attribution.channel, + SUM(attribution.revenue) AS total_revenue +FROM + `context-repo-building.acme_analytics.attribution_summary_v2` AS attribution +WHERE + attribution.week_start >= DATE_SUB(CURRENT_DATE(), INTERVAL 4 WEEK) +GROUP BY + attribution.channel +ORDER BY + total_revenue DESC; +``` + +## Result data + +| channel | total_revenue | +|---|---| +| email | 213392.19 | +| social_paid | 195108.11 | +| organic_search | 193145.62 | +| affiliate | 186127.06 | +| paid_search | 185321.73 | +| direct | 148109.01 | diff --git a/skills/ccb-gcp-data-qa/examples/sample_session_v04.charts.html b/skills/ccb-gcp-data-qa/examples/sample_session_v04.charts.html new file mode 100644 index 00000000..23a6b4b5 --- /dev/null +++ b/skills/ccb-gcp-data-qa/examples/sample_session_v04.charts.html @@ -0,0 +1,25 @@ + + + + +Q&A charts — What was the channel breakdown of revenue for the last 4 weeks? Use whichever at + + + + + + +

Q&A charts What was the channel breakdown of revenue for the last 4 weeks? Use whichever attribution table is recommended, and visualize the result.

+
1 chart(s); rendered via vega-embed (CDN). Use the menu in the corner of each chart to download as PNG/SVG/JSON.
+

Chart 1

+ + + diff --git a/skills/ccb-gcp-data-qa/examples/sample_session_v04.md b/skills/ccb-gcp-data-qa/examples/sample_session_v04.md new file mode 100644 index 00000000..cfe0e1a4 --- /dev/null +++ b/skills/ccb-gcp-data-qa/examples/sample_session_v04.md @@ -0,0 +1,135 @@ +# Q&A session — What was the channel breakdown of revenue for the last 4 weeks? Use whichever attribution table is recommended, and visualize the result. + +- Project: `context-repo-building` +- Tables: `context-repo-building.acme_analytics.attribution_summary_v1`, `context-repo-building.acme_analytics.attribution_summary_v2`, `context-repo-building.acme_analytics.dim_users`, `context-repo-building.acme_analytics.events_raw`, `context-repo-building.acme_analytics.fact_orders_daily` +- Agent: `agent_22615309acec` (reused) +- Generated at: 2026-05-07T08:03:34Z +- Duration: 23.01s + +## Question +> What was the channel breakdown of revenue for the last 4 weeks? Use whichever attribution table is recommended, and visualize the result. + +## Answer + +Analyzing context +Retrieved context for 5 tables. + +My Thought Process for Retrieving Revenue Breakdown by Channel +Alright, so the core request here is to get a breakdown of revenue by channel for the most recent four-week period. My first instinct is to identify the most appropriate data source for this. I've been given some crucial `additional_context` that guides my table selection. It explicitly states that `attribution_summary_v1` is DEPRECATED and should not be used by analysts. Instead, `attribution_summary_v2` is highlighted as the ACTIVE and data-driven attribution rollup, designated as the go-to source going forward. This makes my choice clear: I'll be working with `attribution_summary_v2`. The context also tells me that `attribution_summary_v2` is refreshed weekly and importantly, it contains the `week_start` date, the `channel` identifier, and the `revenue` figures. This is exactly what I need. Now, for the "last 4 weeks" part. The current timestamp provided is `2026-05-07`. When dealing with weekly data, "last 4 weeks" generally implies looking at the most recent 4 full weeks of available data. Given the `week_start` field, a straightforward way to achieve this is to find the maximum `week_start` value in the table and then go back three more weeks. A more direct approach is to calculate a date that's approximately 4 weeks prior to the current date. So, `DATE_SUB(DATE('2026-05-07'), INTERVAL 4 WEEK)` which evaluates to `2026-04-09` seems like a reasonable starting point for filtering. Alternatively, I could use `DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY)` for a more robust 28-day window. The schema for `attribution_summary_v2` is also provided: `week_start` (DATE), `channel` (STRING), `conversions` (INT64), and `revenue` (FLOAT). This confirms that I can directly sum the `revenue` column. To get the requested breakdown, I'll need to group the results by `channel` and then sum up the `revenue` for each channel within that four-week window. I also noticed a critical piece of information: there was a failure on `2026-04-28` where `attribution_summary_v2` data was skipped. This means that the data for the week starting around April 27th might be missing. However, my query will simply return whatever data is available for the specified period, and the user, being an expert, will understand the implications of any data gaps. Finally, the user mentioned visualizing the result. For a channel breakdown, a pie chart or a bar chart would be excellent visual representations to clearly show the proportion of revenue contributed by each channel. Therefore, my plan solidifies: 1. I will query the `attribution_summary_v2` table. 2. I will filter the results to include only the data where `week_start` is within the last four weeks, using `DATE_SUB(DATE('2026-05-07'), INTERVAL 28 DAY)` as my filter. 3. I will group the results by the `channel` column. 4. I will then calculate the sum of `revenue` for each channel. 5. Finally, I'll order the results by total revenue in descending order for clarity, and I'm ready to suggest a visualization. This leads directly to the SQL I've formulated: ```sql SELECT attribution_summary.channel, SUM(attribution_summary.revenue) AS total_revenue FROM `context-repo-building.acme_analytics.attribution_summary_v2` AS attribution_summary WHERE attribution_summary.week_start >= DATE_SUB(DATE('2026-05-07'), INTERVAL 28 DAY) GROUP BY attribution_summary.channel ORDER BY total_revenue DESC; ``` + +My Thought Process for Visualizing Channel Revenue +Alright, the core task here is to present a clear and insightful visualization of how revenue is breaking down across different marketing channels over the past four weeks. I need to make sure the user, an expert in their field, can quickly grasp the performance hierarchy. I've identified that the necessary data is readily available in the `revenue_by_channel_last_4_weeks` dataset. I've confirmed it contains six distinct rows, representing six different channels. This number is manageable enough for a detailed breakdown without becoming overwhelming. Given the objective of showing revenue ranking, a horizontal bar chart seems like the most effective approach. This orientation will allow me to clearly display the channel names on one axis and the corresponding revenue figures on the other, inherently ordering them from highest to lowest revenue. This direct visual comparison is crucial for identifying top performers and areas that might need attention. To implement this, I'll be using Python with the Altair library, which I find excellent for creating interactive and aesthetically pleasing visualizations. I'll be sure to specify the appropriate data source, `revenue_by_channel_last_4_weeks`. The chart will be marked as a bar chart. For the encoding, I'll map 'total_revenue' to the x-axis, ensuring it's quantified and appropriately labeled as 'Total Revenue (USD)'. On the y-axis, I'll place 'channel', specifying it as nominal data and importantly, sorting it in descending order based on the x-axis values ('-x'). This is the key step that establishes the revenue ranking. The y-axis title will be 'Marketing Channel'. To enhance clarity and avoid redundancy, I'll assign a color to each channel but suppress the legend, as the y-axis already clearly identifies each channel. The chart's title will be a concise 'Revenue Breakdown by Channel (Last 4 Weeks)'. Finally, I'll include `usermeta` with `data_result_name` and `chart_id` for good practice and traceability. The output will be the JSON representation of this Altair chart. + +Based on the active data-driven attribution model (v2.3), I've calculated the revenue breakdown by channel for the last 4 weeks (weeks starting on or after April 9, 2026). + +The `email` and `social_paid` channels led in revenue during this period, followed closely by `organic_search`. + +### Insights + +* **Top Performance**: `email` is the highest-grossing channel with approximately $213,392 in attributed revenue. +* **Tight Competition**: The next four channels (`social_paid`, `organic_search`, `affiliate`, and `paid_search`) are performing at very similar levels, each contributing between $185k and $195k. +* **Trailing Channel**: `direct` traffic is the lowest contributor to attributed revenue at approximately $148,109. + +Note: As recommended, this analysis uses the `attribution_summary_v2` table, which is the current source of truth for marketing attribution. + +How does the v2 revenue compare to the deprecated v1 model for paid_search? +What is the week-over-week revenue trend for the email channel? +Can you forecast the total revenue for the next 4 weeks based on the attribution data? + +## SQL + +```sql +SELECT + attribution_summary.channel, + SUM(attribution_summary.revenue) AS total_revenue +FROM + `context-repo-building.acme_analytics.attribution_summary_v2` AS attribution_summary +WHERE + attribution_summary.week_start >= DATE_SUB(DATE('2026-05-07'), INTERVAL 4 WEEK) +GROUP BY + attribution_summary.channel +ORDER BY + total_revenue DESC; +``` + +## Result data + +| channel | total_revenue | +|---|---| +| email | 213392.19 | +| social_paid | 195108.11 | +| organic_search | 193145.62 | +| affiliate | 186127.06 | +| paid_search | 185321.73 | +| direct | 148109.01 | + +## Charts + +1 chart(s) rendered. Open the interactive preview: [sample_session_v04.charts.html](sample_session_v04.charts.html) + +### Chart 1 — Vega-Lite spec + +```json +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.17.0.json", + "mark": "bar", + "data": { + "values": [ + { + "channel": "email", + "total_revenue": 213392.19 + }, + { + "channel": "social_paid", + "total_revenue": 195108.11 + }, + { + "channel": "organic_search", + "total_revenue": 193145.62 + }, + { + "channel": "affiliate", + "total_revenue": 186127.06 + }, + { + "channel": "paid_search", + "total_revenue": 185321.73 + }, + { + "channel": "direct", + "total_revenue": 148109.01 + } + ] + }, + "config": { + "view": { + "continuousWidth": 400.0, + "continuousHeight": 300.0 + } + }, + "encoding": { + "color": { + "field": "channel", + "type": "nominal", + "legend": null + }, + "x": { + "field": "total_revenue", + "type": "quantitative", + "title": "Total Revenue (USD)" + }, + "y": { + "field": "channel", + "sort": "-x", + "type": "nominal", + "title": "Marketing Channel" + } + }, + "title": "Revenue Breakdown by Channel (Last 4 Weeks)", + "usermeta": { + "chart_id": "channel_revenue_breakdown", + "data_result_name": "revenue_by_channel_last_4_weeks" + }, + "width": 600.0 +} +``` diff --git a/skills/ccb-gcp-data-qa/scripts/check_prereqs.sh b/skills/ccb-gcp-data-qa/scripts/check_prereqs.sh new file mode 100755 index 00000000..058ea0de --- /dev/null +++ b/skills/ccb-gcp-data-qa/scripts/check_prereqs.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Preflight check for the gcp-data-qa skill. +# Exits 0 if everything is ready; non-zero with remediation otherwise. +# +# Usage: check_prereqs.sh +set -u + +PROJECT="${1:-}" + +ok=true +fail() { echo "MISSING: $1"; echo " fix: $2"; ok=false; } + +# CLI tools +command -v gcloud >/dev/null 2>&1 || fail "gcloud CLI" \ + "install Google Cloud SDK: https://cloud.google.com/sdk/docs/install" +command -v python3 >/dev/null 2>&1 || fail "python3" \ + "install Python 3.9 or newer" + +# gcloud auth +if command -v gcloud >/dev/null 2>&1; then + active=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null) + [ -n "$active" ] || fail "gcloud not authenticated" "run: gcloud auth login" +fi + +# ADC (either user creds OR a service-account key) +if [ -n "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then + [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ] || fail \ + "GOOGLE_APPLICATION_CREDENTIALS=$GOOGLE_APPLICATION_CREDENTIALS but file does not exist" \ + "either fix the path or unset the env var to fall back to user ADC" +else + adc_path="${HOME}/.config/gcloud/application_default_credentials.json" + [ -f "$adc_path" ] || fail "Application Default Credentials" \ + "either: (a) gcloud auth application-default login OR (b) export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json" +fi + +# Conversational Analytics API enabled in target project (if a project was passed) +if [ -n "$PROJECT" ] && command -v gcloud >/dev/null 2>&1; then + enabled=$(gcloud services list --enabled --project="$PROJECT" \ + --filter="config.name:geminidataanalytics.googleapis.com" \ + --format="value(config.name)" 2>/dev/null) + if [ -z "$enabled" ]; then + fail "Conversational Analytics API not enabled in project $PROJECT" \ + "run: gcloud services enable geminidataanalytics.googleapis.com --project=$PROJECT" + fi +fi + +# Python SDK +if command -v python3 >/dev/null 2>&1; then + python3 -c "from google.cloud import geminidataanalytics" 2>/dev/null || fail \ + "Python SDK (google-cloud-geminidataanalytics)" \ + "run: pip install -r $(dirname "$0")/requirements.txt" +fi + +if $ok; then + echo "All prerequisites OK." + exit 0 +else + exit 1 +fi diff --git a/skills/ccb-gcp-data-qa/scripts/data_qa.py b/skills/ccb-gcp-data-qa/scripts/data_qa.py new file mode 100755 index 00000000..be0b006b --- /dev/null +++ b/skills/ccb-gcp-data-qa/scripts/data_qa.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +"""Ask a natural-language question of a GCP customer's BigQuery data. + +Wraps Google Cloud's Conversational Analytics API +(geminidataanalytics.googleapis.com, currently in Preview). Two modes: + + Mode A (explicit tables): + data_qa.py --project=P --table=P:DS.T [--table=...] --question="..." + + Mode B (wiki-grounded): + data_qa.py --project=P --wiki-dir=PATH --question="..." + (auto-extracts tables + composes a rich system instruction from the wiki) + +Output: JSON to stdout. See SKILL.md for the schema. +""" +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import sys +import time +from pathlib import Path +from typing import Any + +# Lazy import so --help works without the SDK installed. +def _load_sdk(): + """Pin to the v1beta surface; the default `geminidataanalytics` package + currently resolves to v1alpha which has different message field shapes.""" + try: + from google.cloud import geminidataanalytics_v1beta as geminidataanalytics # type: ignore + return geminidataanalytics + except ImportError as e: + die(f"Python SDK not installed: {e}\n" + f" fix: pip install -r {Path(__file__).parent}/requirements.txt") + + +def die(msg: str, code: int = 2): + print(f"error: {msg}", file=sys.stderr) + sys.exit(code) + + +def parse_table_ref(ref: str) -> tuple[str, str, str]: + """Parse 'project:dataset.table' or 'project.dataset.table' -> (p, ds, t).""" + if ":" in ref: + proj, rest = ref.split(":", 1) + else: + parts = ref.split(".") + if len(parts) != 3: + die(f"bad --table {ref!r}: want project:dataset.table or project.dataset.table") + return tuple(parts) # type: ignore + if "." not in rest or rest.count(".") != 1: + die(f"bad --table {ref!r}: want project:dataset.table") + ds, tbl = rest.split(".", 1) + return proj, ds, tbl + + +def tables_fingerprint(tables: list[str]) -> str: + """Stable short hash of the table set, used to name the chart tempfile so + reruns of the same question on the same tables overwrite the prior preview.""" + return hashlib.sha256("|".join(sorted(tables)).encode()).hexdigest()[:12] + + +def chat_one_shot( + geminidataanalytics, *, + project: str, location: str, + tables: list[tuple[str, str, str]], + system_instruction: str, + question: str, +) -> list[dict[str, Any]]: + """Stateless, agentless chat. + + Uses `ChatRequest.inline_context` to pass the BigQuery datasource + references and system instruction inline on every call — no + `DataAgent` resource is created or persisted in the project. This + avoids accumulating per-table-set agent resources across question + history and removes the create/AlreadyExists roundtrip. + """ + chat_client = geminidataanalytics.DataChatServiceClient() + parent = f"projects/{project}/locations/{location}" + + bq_refs = [ + geminidataanalytics.BigQueryTableReference( + project_id=p, dataset_id=ds, table_id=t, + ) + for (p, ds, t) in tables + ] + ds_refs = geminidataanalytics.DatasourceReferences() + ds_refs.bq.table_references = bq_refs + + ctx = geminidataanalytics.Context( + # 32K-char cap accommodates the v0.4 enriched system instruction + # (per-table fields/lineage + personal_context narrative). The API + # handles long instructions in practice; the cap is just defensive. + system_instruction=system_instruction[:32000], + datasource_references=ds_refs, + ) + + msg = geminidataanalytics.Message() + msg.user_message.text = question + + req = geminidataanalytics.ChatRequest( + parent=parent, messages=[msg], inline_context=ctx, + ) + + messages: list[dict[str, Any]] = [] + for resp in chat_client.chat(request=req): + messages.append(_normalize_response(resp)) + return messages + + +def _normalize_response(resp) -> dict[str, Any]: + """Best-effort flatten of a streamed response into a JSON-able dict. + + The API has a few message variants (system_message: thought / progress / + final_response / data / chart, plus error). We pull the salient fields and + skip what we can't represent.""" + out: dict[str, Any] = {} + try: + if hasattr(resp, "system_message") and resp.system_message: + sm = resp.system_message + if hasattr(sm, "thought") and getattr(sm.thought, "thoughts", None): + out = {"type": "THOUGHT", "text": "\n".join(sm.thought.thoughts)} + elif hasattr(sm, "progress") and getattr(sm.progress, "thoughts", None): + out = {"type": "PROGRESS", "text": "\n".join(sm.progress.thoughts)} + elif hasattr(sm, "data") and sm.data: + d = sm.data + # Schema + rows if available + schema = [] + rows = [] + if hasattr(d, "result") and d.result: + if hasattr(d.result, "schema") and d.result.schema: + schema = [ + {"name": f.name, "type": str(f.type_)} + for f in (d.result.schema.fields or []) + ] + if hasattr(d.result, "data"): + for r in (d.result.data or []): + rows.append({k: _scalar(v) for k, v in r.items()}) + out = {"type": "DATA", "schema": schema, "rows": rows} + # Optional generated SQL on data messages + if hasattr(d, "generated_sql") and d.generated_sql: + out["sql"] = d.generated_sql + elif hasattr(sm, "chart") and sm.chart: + out = _normalize_chart(sm.chart) + elif hasattr(sm, "text") and getattr(sm.text, "parts", None): + out = {"type": "FINAL_RESPONSE", "text": "\n".join(sm.text.parts)} + elif hasattr(resp, "error") and resp.error: + out = {"type": "ERROR", "text": str(resp.error)} + except Exception as e: + out = {"type": "PARSE_ERROR", "text": f"could not normalize: {e}", "raw": str(resp)[:500]} + + if not out: + # Last-ditch: stringify a small chunk so we don't lose visibility. + out = {"type": "UNKNOWN", "raw": str(resp)[:500]} + return out + + +def _normalize_chart(chart) -> dict[str, Any]: + """Extract a Vega-Lite spec (and the Python instructions that produced it) + from a CHART system message. The v1beta API has the spec in a few + possible locations depending on which step of generation we're seeing, + so we try several paths defensively.""" + out: dict[str, Any] = {"type": "CHART"} + + # Step 1: the agent's Python/Altair instructions + if hasattr(chart, "query") and chart.query: + q = chart.query + for attr in ("instructions", "code"): + if hasattr(q, attr): + val = getattr(q, attr) + if val: + out["instructions"] = str(val) + break + + # Step 2: the resolved Vega-Lite spec (proto Struct or pre-parsed dict). + spec = None + if hasattr(chart, "result") and chart.result: + r = chart.result + # Try common field names: vega_config, spec, vega_lite_spec, config + for attr in ("vega_config", "spec", "vega_lite_spec", "config"): + if hasattr(r, attr): + val = getattr(r, attr) + if val: + spec = _proto_to_jsonable(val) + if spec: + break + + if spec: + out["spec"] = spec + else: + # Save the proto repr so we can debug if extraction fails. + out["debug_raw"] = str(chart)[:500] + + return out + + +def _proto_to_jsonable(v: Any) -> Any: + """Convert a protobuf Struct / Value / MapComposite / dict-like / + ListComposite into a plain JSON-able tree, recursing through nested + structures. Vega-Lite specs from the API arrive as nested + proto.marshal.collections (MapComposite / RepeatedComposite), which + look dict/list-like but stringify as `` + in JSON unless we walk them explicitly.""" + # Strings: try JSON first (some API variants return spec as a JSON string) + if isinstance(v, str): + try: + import json as _json + parsed = _json.loads(v) + return _proto_to_jsonable(parsed) # recurse into the parsed structure + except Exception: + return v + + # Plain JSON scalars + if isinstance(v, (int, float, bool, type(None))): + return v + + # Try google.protobuf.Struct / Value via MessageToDict (handles full Message + # with DESCRIPTOR; recurses correctly) + try: + from google.protobuf.json_format import MessageToDict + if hasattr(v, "DESCRIPTOR"): + return MessageToDict(v, preserving_proto_field_name=True) + except Exception: + pass + + # MapComposite / dict-like — has .items() and indexing + if hasattr(v, "items"): + try: + return {str(k): _proto_to_jsonable(vv) for k, vv in v.items()} + except Exception: + pass + + # RepeatedComposite / list-like — iterable but not dict-like + if hasattr(v, "__iter__") and not isinstance(v, (str, bytes)): + try: + return [_proto_to_jsonable(item) for item in v] + except Exception: + pass + + # Last resort: stringify + return str(v) + + +def _scalar(v: Any) -> Any: + """Coerce a protobuf scalar value to a JSON-compatible type.""" + try: + from google.protobuf.struct_pb2 import Value + if isinstance(v, Value): + kind = v.WhichOneof("kind") + if kind == "null_value": + return None + if kind == "number_value": + return v.number_value + if kind == "string_value": + return v.string_value + if kind == "bool_value": + return v.bool_value + return None + except Exception: + pass + if isinstance(v, (int, float, str, bool, type(None))): + return v + return str(v) + + +def write_transcript(out_path: Path, payload: dict[str, Any]) -> None: + """Write a markdown transcript of the session for archival. + + The Conversational Analytics API streams MANY messages in a single chat + (multiple THOUGHTs, multiple text payloads — some are intermediate + progress narration, some are the final answer, some are follow-up + suggestions). To keep the transcript readable we consolidate by type: + one consolidated "## Answer" section (final response streams joined), + one "## SQL" section (last DATA message with a sql field), one "## + Result data" section if schema/rows came through, and one collapsed + "## Agent reasoning" trail at the bottom for THOUGHT/PROGRESS. + """ + lines = [ + f"# Q&A session — {payload['question']}", + "", + f"- Project: `{payload['project']}`", + f"- Tables: " + ", ".join(f"`{t}`" for t in payload['tables']), + f"- Context mode: `{payload['context_mode']}` (no DataAgent resource created)", + f"- Generated at: {payload['generated_at']}", + f"- Duration: {payload['duration_seconds']}s", + "", + "## Question", + f"> {payload['question']}", + "", + ] + + msgs = payload["messages"] + final_responses = [m.get("text", "") for m in msgs if m.get("type") == "FINAL_RESPONSE"] + thoughts = [m.get("text", "") for m in msgs if m.get("type") in ("THOUGHT", "PROGRESS")] + errors = [m.get("text", "") for m in msgs if m.get("type") == "ERROR"] + sqls = [m.get("sql") for m in msgs if m.get("type") == "DATA" and m.get("sql")] + last_data = next((m for m in reversed(msgs) + if m.get("type") == "DATA" and (m.get("schema") or m.get("rows"))), None) + + if errors: + lines += ["## Errors", ""] + for e in errors: + lines += ["```", e, "```", ""] + + if final_responses: + lines += ["## Answer", ""] + # Trim duplicates (the API often re-sends the same intro for each text stream). + seen: set[str] = set() + deduped = [] + for fr in final_responses: + key = fr.strip()[:120] + if key in seen: + continue + seen.add(key) + deduped.append(fr.strip()) + lines.append("\n\n".join(deduped)) + lines.append("") + + if sqls: + # Use the last unique SQL (intermediate streams sometimes repeat). + unique_sqls = list(dict.fromkeys(sqls)) + lines += ["## SQL", "", "```sql", unique_sqls[-1], "```", ""] + + if last_data and (last_data.get("schema") and last_data.get("rows")): + schema = last_data["schema"] + rows = last_data["rows"] + lines += ["## Result data", ""] + lines.append("| " + " | ".join(s["name"] for s in schema) + " |") + lines.append("|" + "|".join("---" for _ in schema) + "|") + for r in rows[:20]: + lines.append("| " + " | ".join(str(r.get(s["name"], "")) for s in schema) + " |") + if len(rows) > 20: + lines.append(f"\n_({len(rows)} rows total; showing first 20)_") + lines.append("") + + # Charts — link to the interactive HTML preview that the top-level driver + # already wrote to payload["chart_html_path"], and inline the spec(s). + charts = [m for m in msgs if m.get("type") == "CHART"] + chart_specs = [c for c in charts if c.get("spec")] + if charts: + lines += ["## Charts", ""] + if chart_specs: + chart_html = payload.get("chart_html_path") + if chart_html: + lines.append( + f"{len(chart_specs)} chart(s) rendered. " + f"Open the interactive preview: [{Path(chart_html).name}]({chart_html})" + ) + lines.append("") + for i, c in enumerate(chart_specs, 1): + lines += [f"### Chart {i} — Vega-Lite spec", "", "```json", + json.dumps(c["spec"], indent=2)[:4000], "```", ""] + else: + lines.append("_(chart messages were emitted but no spec could be extracted " + "from the v1beta payload — this is a known wrapper limitation; " + "the chart's underlying SQL+data are still in the Result data section)_") + lines.append("") + + if thoughts: + lines += ["## Agent reasoning trail", "", + "_(intermediate THOUGHT / PROGRESS messages, oldest first)_", ""] + for i, t in enumerate(thoughts, 1): + lines += [f"**Step {i}:**", "", t.strip(), ""] + + out_path.write_text("\n".join(lines), encoding="utf-8") + + +def write_charts_html(out_path: Path, question: str, charts: list[dict[str, Any]]) -> None: + """Render a self-contained HTML file with every chart spec inlined as SVG. + + Charts are pre-rendered server-side via vl-convert-python so the output + works on `file://`, in markdown viewers, and in script-blocked browsers + without needing a CDN load. If vl-convert isn't available, falls back + to vega-embed via CDN (interactive but requires network + scripts).""" + try: + import vl_convert as vlc # type: ignore + have_vlc = True + except ImportError: + have_vlc = False + + chart_blocks: list[str] = [] + cdn_scripts: list[str] = [] + rendered_inline = 0 + + for i, c in enumerate(charts, 1): + spec = c.get("spec") + if not spec: + continue + # Vega-Lite specs from the API often declare schema v4; vl-convert + # handles v4/v5 transparently. + spec_json = json.dumps(spec) + if have_vlc: + try: + svg = vlc.vegalite_to_svg(spec_json) + chart_blocks.append(f'

Chart {i}

{svg}
') + rendered_inline += 1 + continue + except Exception as e: + # Fall through to CDN-based rendering for this chart. + chart_blocks.append( + f'

Chart {i}

' + f'
vl-convert failed ({e}); '
+                    f'attempting interactive render…
' + ) + cdn_scripts.append( + f"vegaEmbed('#chart-{i}', {spec_json}, {{actions: true}})" + f".catch(e => {{ document.getElementById('chart-{i}').innerHTML = " + f"'
chart-{i} render failed: ' + e + '
'; }});" + ) + else: + chart_blocks.append(f'

Chart {i}

') + cdn_scripts.append( + f"vegaEmbed('#chart-{i}', {spec_json}, {{actions: true}})" + f".catch(e => {{ document.getElementById('chart-{i}').innerHTML = " + f"'
chart-{i} render failed: ' + e + '
'; }});" + ) + + cdn_block = "" + if cdn_scripts: + cdn_block = ( + '\n' + '\n' + '\n' + f'' + ) + + if rendered_inline == len(charts) and rendered_inline > 0: + meta = f"{len(charts)} chart(s) rendered as inline SVG (self-contained, no scripts required)." + elif rendered_inline > 0: + meta = f"{rendered_inline} of {len(charts)} chart(s) inlined; the rest fall back to vega-embed (needs network + JS)." + else: + meta = f"{len(charts)} chart(s) via vega-embed (CDN). For self-contained SVG output, install vl-convert-python." + + html = f""" + + + +Q&A charts — {html_escape(question[:80])} + + + +

Q&A charts {html_escape(question)}

+
{meta}
+{''.join(chart_blocks)} +{cdn_block} + + +""" + out_path.write_text(html, encoding="utf-8") + + +def html_escape(s: str) -> str: + """Minimal HTML escape for embedding text in the chart preview.""" + import html as _h + return _h.escape(s, quote=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--project", required=True) + ap.add_argument("--location", default="global") + ap.add_argument("--question", required=True) + ap.add_argument("--table", action="append", default=[], + help="repeatable: PROJECT:DATASET.TABLE (Mode A)") + ap.add_argument("--wiki-dir", help="path to a per-customer wiki dir (Mode B)") + ap.add_argument("--output-file", help="also write a markdown transcript to this path") + ap.add_argument("--chart-html", + help="path for the interactive Vega-Lite chart preview. " + "If omitted, defaults to a tempfile when charts are present " + "(or to a sibling of --output-file if that's set).") + args = ap.parse_args() + + if not args.table and not args.wiki_dir: + die("must give --table (one or more) OR --wiki-dir") + if args.table and args.wiki_dir: + die("specify --table OR --wiki-dir, not both") + + geminidataanalytics = _load_sdk() + + # Resolve tables + system instruction. + system_instruction = "" + table_strs: list[str] = [] + warnings: list[str] = [] + + if args.wiki_dir: + # Local import (we're inside scripts/, wiki_parser.py is a sibling). + sys.path.insert(0, str(Path(__file__).parent)) + from wiki_parser import parse_wiki + ctx = parse_wiki(Path(args.wiki_dir), project_fallback=args.project) + if not ctx.tables: + die(f"wiki at {args.wiki_dir} produced no tables. Warnings: {ctx.warnings}") + table_strs = ctx.tables + system_instruction = ctx.system_instruction + warnings = ctx.warnings + else: + table_strs = args.table + system_instruction = ( + f"You are answering questions about BigQuery data in project `{args.project}`. " + f"Tables available:\n" + "\n".join(f"- `{t}`" for t in table_strs) + + "\nUse only these tables. If a question can't be answered with them, say so plainly." + ) + + parsed_tables = [parse_table_ref(t.replace(".", ":", 1) if t.count(".") == 2 and ":" not in t else t) + for t in table_strs] + + started = time.time() + messages = chat_one_shot( + geminidataanalytics, + project=args.project, location=args.location, + tables=parsed_tables, system_instruction=system_instruction, + question=args.question, + ) + duration = round(time.time() - started, 2) + + payload = { + "question": args.question, + "project": args.project, + "location": args.location, + "tables": table_strs, + "context_mode": "inline", + "generated_at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "duration_seconds": duration, + "messages": messages, + "warnings": warnings, + "system_instruction_chars": len(system_instruction), + } + + # Always render an interactive HTML preview when chart specs come back. + # Path selection priority: + # 1. --chart-html (explicit) + # 2. sibling of --output-file (.charts.html) + # 3. tempfile keyed on a fingerprint of the table set so reruns of the + # same question on the same tables overwrite the prior preview + chart_specs = [m for m in messages if m.get("type") == "CHART" and m.get("spec")] + if chart_specs: + if args.chart_html: + chart_path = Path(args.chart_html) + elif args.output_file: + chart_path = Path(args.output_file).with_suffix(".charts.html") + else: + import tempfile + chart_path = Path(tempfile.gettempdir()) / f"data_qa_charts_{tables_fingerprint(table_strs)}.html" + write_charts_html(chart_path, args.question, chart_specs) + payload["chart_html_path"] = str(chart_path.resolve()) + print(f"# wrote {len(chart_specs)} chart(s) to {chart_path}", file=sys.stderr) + + print(json.dumps(payload, indent=2, default=str)) + + if args.output_file: + write_transcript(Path(args.output_file), payload) + print(f"# wrote transcript to {args.output_file}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-gcp-data-qa/scripts/requirements.txt b/skills/ccb-gcp-data-qa/scripts/requirements.txt new file mode 100644 index 00000000..873a90fe --- /dev/null +++ b/skills/ccb-gcp-data-qa/scripts/requirements.txt @@ -0,0 +1,7 @@ +google-cloud-geminidataanalytics>=0.4.0 +PyYAML>=6.0 +# Optional: enables server-side Vega-Lite -> inline SVG rendering so chart +# previews are self-contained (work on file://, markdown viewers, etc.). +# If absent, the wrapper falls back to a CDN-loaded vega-embed runtime. +vl-convert-python>=1.0 + diff --git a/skills/ccb-gcp-data-qa/scripts/wiki_parser.py b/skills/ccb-gcp-data-qa/scripts/wiki_parser.py new file mode 100755 index 00000000..077414a7 --- /dev/null +++ b/skills/ccb-gcp-data-qa/scripts/wiki_parser.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Parse a customer-context wiki dir → (tables, system_instruction). + +Used by the gcp-data-qa skill when invoked in --wiki-dir mode. The wiki +format is the recursive structure produced by the gcp-customer-context-builder +skill (every dir has an index.md; warehouse-level data_warehouse.md; +per-table {fields,lineage}.md; personal_context/ for internal notes). + +Fail-soft: if the wiki is partially structured we pull what we can and +report gaps in the returned `warnings` list. Caller can decide whether +to proceed. + +Usage as CLI (for testing): + python3 wiki_parser.py /path/to/customer-context/context/ [--project=PROJECT_ID_FALLBACK] +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field, asdict +from pathlib import Path + + +@dataclass +class WikiContext: + project_id: str | None + tables: list[str] = field(default_factory=list) + system_instruction: str = "" + warnings: list[str] = field(default_factory=list) + + +def parse_wiki(wiki_dir: Path, project_fallback: str | None = None) -> WikiContext: + """Read a per-customer wiki dir, return tables + a composed system instruction.""" + ctx = WikiContext(project_id=project_fallback) + + if not wiki_dir.is_dir(): + ctx.warnings.append(f"wiki dir not found: {wiki_dir}") + return ctx + + dw_path = wiki_dir / "data_warehouse.md" + if not dw_path.is_file(): + ctx.warnings.append(f"missing {dw_path.name}; without it we can't enumerate tables") + return ctx + + dw = dw_path.read_text(encoding="utf-8") + + ctx.project_id = _extract_project_id(dw) or project_fallback + ctx.tables = _extract_tables(dw, ctx.project_id, wiki_dir) + if not ctx.tables: + ctx.warnings.append( + "no tables extracted from data_warehouse.md — check that the file has a " + "'## Table inventory' section with a markdown table listing tables" + ) + + ctx.system_instruction = _compose_system_instruction(wiki_dir, dw, ctx.tables) + return ctx + + +def _extract_project_id(dw_md: str) -> str | None: + """Look for 'Project ID: `xyz`' or similar in data_warehouse.md.""" + patterns = [ + r"[Pp]roject\s*ID[:\s]+`([a-z][-a-z0-9]{4,28}[a-z0-9])`", + r"GCP project\s+`([a-z][-a-z0-9]{4,28}[a-z0-9])`", + ] + for pat in patterns: + m = re.search(pat, dw_md) + if m: + return m.group(1) + return None + + +def _extract_tables(dw_md: str, project_id: str | None, wiki_dir: Path) -> list[str]: + """Enumerate tables from the wiki's per-table subdirs (the structural + source of truth). Each subdir containing a `fields.md` is a table; its + dirname is the table name. Dataset is inferred from data_warehouse.md + by looking for explicit `dataset.table` references whose `table` half + matches one of the discovered subdirs. + + Why subdir-only (not regex over prose): table names mentioned inline + are often `table.column` references (e.g., `fact_orders_daily.order_date`) + that look indistinguishable from `dataset.table` to a regex. The wiki + structure encodes the truth unambiguously. + """ + if not project_id: + return [] + + table_dirs: list[str] = [] + for sub in sorted(wiki_dir.iterdir()): + if not sub.is_dir(): + continue + if sub.name in ("sources", "personal_context"): + continue + if (sub / "fields.md").is_file(): + table_dirs.append(sub.name) + + if not table_dirs: + return [] + + # Infer dataset, trying several patterns in order of specificity. + # Strategy 1: a backtick-fenced `.` ref where table_name + # is one of our known table dirs. + dataset = None + for tname in table_dirs: + m = re.search(rf"`([a-z_][a-z0-9_]+)\.{re.escape(tname)}`", dw_md) + if m: + dataset = m.group(1) + break + + # Strategy 2: 'dataset' (case-insensitive) followed by anything non-word + # (comma, paren, space, "is", etc.) up to 60 chars, then a backtick-fenced + # identifier. Handles "dataset, `acme_analytics`," and "dataset + # (`acme_analytics`, US)" alike. + if not dataset: + m = re.search(r"dataset[^`a-z0-9_]{0,60}`([a-z_][a-z0-9_]+)`", + dw_md, re.IGNORECASE) + if m: + dataset = m.group(1) + + # Strategy 3: a per-table source file (bq_show_schema.md) usually lists + # a fully-qualified `project:dataset.table` ref — pull from there. + if not dataset: + for tname in table_dirs: + schema_src = wiki_dir / tname / "sources" / "bq_show_schema.md" + if schema_src.is_file(): + m = re.search( + r"`(?:[a-z][-a-z0-9]{4,28}[a-z0-9]):([a-z_][a-z0-9_]+)\.[a-z_]", + schema_src.read_text(encoding="utf-8")) + if m: + dataset = m.group(1) + break + + # Strategy 4: any backtick-fenced identifier that contains an underscore + # AND appears in data_warehouse.md (most BQ datasets have underscores). + if not dataset: + for cand in re.findall(r"`([a-z][a-z0-9_]+_[a-z0-9_]+)`", dw_md): + if cand not in table_dirs and "." not in cand: + dataset = cand + break + + if not dataset: + return [] + + return [f"{project_id}.{dataset}.{t}" for t in table_dirs] + + +def _compose_system_instruction(wiki_dir: Path, dw_md: str, tables: list[str]) -> str: + """Compose a rich system instruction for the Conversational Analytics + agent by pulling load-bearing context from across the wiki — customer + overview, per-table notes/fields/lineage, personal team context (the + wiki's internal_notes synthesis), cross-source operational stories, + and naming conventions. + + Sections are emitted in priority order so that if the API later truncates + the instruction, the most important context is preserved.""" + parts: list[str] = [] + + # 1. Customer overview (always first — it's the orientation) + cust_index = wiki_dir / "index.md" + if cust_index.is_file(): + summary = _extract_section(cust_index.read_text(encoding="utf-8"), "Summary") + if summary: + parts.append(f"# Customer overview\n{summary.strip()}") + + # 2. Tables list (redundant with datasource_references but useful as NL anchor) + if tables: + parts.append("# Tables available\n" + "\n".join(f"- `{t}`" for t in tables)) + + # 3. Per-table narrative summaries — operational warnings (deprecated, + # partition issues, etc.) that make the agent's SQL smarter + table_notes: list[str] = [] + for table_ref in tables: + table_name = table_ref.rsplit(".", 1)[-1] + idx = wiki_dir / table_name / "index.md" + if idx.is_file(): + summary = _extract_section(idx.read_text(encoding="utf-8"), "Summary") + if summary: + table_notes.append(f"## `{table_name}`\n{summary.strip()}") + if table_notes: + parts.append("# Per-table notes (read carefully — these constrain valid SQL)\n" + + "\n\n".join(table_notes)) + + # 4. Per-table field schemas (NEW — column descriptions improve SQL + # accuracy: the agent can reason about column semantics, not just types) + field_blocks: list[str] = [] + for table_ref in tables: + table_name = table_ref.rsplit(".", 1)[-1] + block = _extract_table_fields_condensed(wiki_dir, table_name) + if block: + field_blocks.append(block) + if field_blocks: + parts.append("# Per-table fields with descriptions\n" + + "_(only columns whose descriptions add semantic info beyond the column name)_\n\n" + + "\n\n".join(field_blocks)) + + # 5. Personal team context (NEW — internal_notes synthesis carries + # the team's narrative about the customer: blockers, escalations, + # decisions, ownership) + personal = _extract_personal_context_narrative(wiki_dir) + if personal: + parts.append(f"# Personal context (internal team notes)\n{personal.strip()}") + + # 6. Cross-source operational stories from data_warehouse.md + cross = _extract_section(dw_md, "Cross-source operational stories") or \ + _extract_section(dw_md, "Cross-source observations") + if cross: + parts.append(f"# Cross-source operational context\n{cross.strip()}") + + # 7. Per-table lineage condensed (NEW — upstream/downstream is + # useful for "which table feeds which" questions and for picking + # the right table when several are candidates) + lineage_blocks: list[str] = [] + for table_ref in tables: + table_name = table_ref.rsplit(".", 1)[-1] + block = _extract_table_lineage_condensed(wiki_dir, table_name) + if block: + lineage_blocks.append(block) + if lineage_blocks: + parts.append("# Per-table lineage (upstream/downstream relationships)\n" + + "\n\n".join(lineage_blocks)) + + # 8. Conventions (look in any onboarding doc under personal_context) + onboarding_dir = wiki_dir / "personal_context" / "sources" + if onboarding_dir.is_dir(): + for f in sorted(onboarding_dir.glob("*onboarding*.md")): + text = f.read_text(encoding="utf-8") + conventions = _extract_section(text, "Conventions") + if conventions: + parts.append(f"# Naming and query conventions\n{conventions.strip()}") + break + + # 9. Behavior block (last — the imperative) + parts.append( + "# Behavior\n" + "Use ONLY the tables listed above. Honor the per-table warnings (deprecation, " + "partition filters, schema gotchas) when generating SQL. Prefer active tables " + "over deprecated ones unless the user explicitly asks for the deprecated table. " + "When the personal context flags a tracker / KPI / blocker that's relevant to " + "the question, cite it briefly in your answer (one sentence) so the asker can " + "trace your reasoning. If a question can't be answered with the available " + "tables, say so plainly and suggest what additional data would be needed." + ) + + return "\n\n".join(parts) + + +def _extract_table_fields_condensed(wiki_dir: Path, table_name: str) -> str | None: + """Return a condensed fields block for one table — only columns whose + description adds info beyond the column name itself. Skips columns + with no description or boilerplate descriptions.""" + fields_path = wiki_dir / table_name / "fields.md" + if not fields_path.is_file(): + return None + md = fields_path.read_text(encoding="utf-8") + # Look for a markdown table; pull rows that have a non-trivial description. + lines = md.splitlines() + in_table = False + rows: list[tuple[str, str, str]] = [] # (col, type, desc) + header_seen = False + for line in lines: + if not in_table: + # Detect a header like | Column | Type | ... | Description | + if "|" in line and ("description" in line.lower() or "desc" in line.lower()): + in_table = True + header_seen = True + continue + else: + if not line.strip().startswith("|"): + if header_seen and rows: + break + continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2: + continue + # Skip the separator row (| --- | --- | ...) + if all(set(c) <= set("-: ") for c in cells): + continue + col = cells[0] + ctype = cells[1] if len(cells) > 1 else "" + desc = cells[-1] if len(cells) >= 3 else "" + # Only include if description carries info (not empty, not (none), + # not just punctuation, longer than ~5 chars) + if desc and desc.strip() not in ("", "(none)", "—", "-") and len(desc.strip()) > 5: + rows.append((col, ctype, desc)) + # Cap to 20 columns per table + rows = rows[:20] + if not rows: + return None + out = [f"## `{table_name}`"] + for col, ctype, desc in rows: + out.append(f"- `{col}` ({ctype}) — {desc}") + return "\n".join(out) + + +def _extract_table_lineage_condensed(wiki_dir: Path, table_name: str) -> str | None: + """Return condensed upstream/downstream lineage for one table.""" + lin_path = wiki_dir / table_name / "lineage.md" + if not lin_path.is_file(): + return None + md = lin_path.read_text(encoding="utf-8") + upstream = _extract_section(md, "Upstream") + downstream = _extract_section(md, "Downstream") + if not upstream and not downstream: + return None + out = [f"## `{table_name}`"] + if upstream: + # Keep first 2 paragraphs; lineage docs often have block-quotes that + # are useful but verbose + upstream_short = "\n\n".join(upstream.split("\n\n")[:2]).strip() + out.append(f"**Upstream:** {upstream_short}") + if downstream: + downstream_short = "\n\n".join(downstream.split("\n\n")[:2]).strip() + out.append(f"**Downstream:** {downstream_short}") + return "\n".join(out) + + +def _extract_personal_context_narrative(wiki_dir: Path) -> str | None: + """Pull the load-bearing parts of personal_context/internal_notes.md — + the team's narrative summary plus open blockers / escalations / + decisions. Skip the per-doc/per-sheet enumeration (the agent doesn't + need to know titles, just facts).""" + notes = wiki_dir / "personal_context" / "internal_notes.md" + if not notes.is_file(): + return None + md = notes.read_text(encoding="utf-8") + parts: list[str] = [] + + # The Summary section (the headline narrative) + summary = _extract_section(md, "Summary") + if summary: + parts.append(f"## Team narrative\n{summary.strip()}") + + # Open blockers / escalations / decisions — different docs use slightly + # different headings; try a few. + for heading in ("Open blockers / escalations / decisions", + "Open blockers and escalations", + "Open blockers", + "Blockers and escalations", + "Active issues"): + section = _extract_section(md, heading) + if section: + parts.append(f"## Active blockers / escalations\n{section.strip()}") + break + + return "\n\n".join(parts) if parts else None + + +def _extract_section(md: str, heading: str) -> str | None: + """Return the markdown of the section under `# Heading` or `## Heading`, + up to the next heading of equal-or-higher level. + Match is case-insensitive and ignores trailing punctuation in the heading.""" + # Normalize heading for matching + pattern = re.compile( + r"^(#{1,6})\s+" + re.escape(heading) + r"\b.*?$", + re.MULTILINE | re.IGNORECASE, + ) + m = pattern.search(md) + if not m: + return None + level = len(m.group(1)) + start = m.end() + # Find the next heading of equal-or-higher level + next_pat = re.compile(rf"^#{{1,{level}}}\s+", re.MULTILINE) + nm = next_pat.search(md, pos=start) + end = nm.start() if nm else len(md) + return md[start:end].strip() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("wiki_dir") + ap.add_argument("--project", help="fallback project ID if data_warehouse.md doesn't mention one") + ap.add_argument("--print-instruction-only", action="store_true", + help="print just the system instruction (for inspection)") + args = ap.parse_args() + + ctx = parse_wiki(Path(args.wiki_dir), project_fallback=args.project) + + if args.print_instruction_only: + print(ctx.system_instruction) + return + + out = asdict(ctx) + print(json.dumps(out, indent=2)) + if ctx.warnings: + print("warnings:", file=sys.stderr) + for w in ctx.warnings: + print(f" - {w}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-wiki-viewer/README.md b/skills/ccb-wiki-viewer/README.md new file mode 100644 index 00000000..2d6b574e --- /dev/null +++ b/skills/ccb-wiki-viewer/README.md @@ -0,0 +1,116 @@ +# wiki-viewer + +A Claude Code skill that serves a generated GCP customer-context wiki +as a browseable local HTML site. Sidebar tree navigation, GitHub-style +markdown rendering, breadcrumb headers, and intra-wiki link rewriting. + +The parameterized form of the repo's [`try.sh`](../../try.sh) demo — +`try.sh` only serves the bundled sample wiki; this skill works on any +wiki dir. + +## When to use + +- After the [customer-context-builder skill](../customer-context-builder/) + finishes building a wiki, when you want to inspect the output +- To explore an old wiki that's already on disk +- To browse the bundled sample wiki without touching `try.sh` + +## Install + +```bash +# From the repo root: +ln -s "$PWD/skills/wiki-viewer" ~/.claude/skills/wiki-viewer +# OR run the bundled installer: +bash install.sh wiki-viewer +``` + +No Python deps beyond stdlib (`http.server`, `pathlib`, etc.). Python +3.9+. + +## Use + +In Claude Code: + +> Show me the wiki at `./customer-context/wikis/nordstrom/` + +> Open the customer-context wiki in a browser + +> Serve the LLM wiki on port 9000 + +Or directly via the wrapper (auto-detects the wiki at standard paths): + +```bash +bash skills/wiki-viewer/scripts/serve_wiki.sh + +# or with explicit args: +bash skills/wiki-viewer/scripts/serve_wiki.sh \ + --wiki-dir=./customer-context/wikis/nordstrom \ + --port=8765 + +# legacy single-wiki layout (no wikis/ parent) — render all 5 tabs anyway: +bash skills/wiki-viewer/scripts/serve_wiki.sh \ + --wiki-dir=./wiki/context/nordstrom \ + --bootstrap-tabs --customer-name=nordstrom --wiki-name=nordstrom-kc +``` + +### `--bootstrap-tabs` + +The viewer renders the 4 Tickets/Candidates/Skills/Drift tabs only when +the `--wiki-dir` is part of a context-center layout — i.e., when it's at +`/wikis///`. If your wiki lives in a single +dir like `wiki/context//`, the auto-detection falls back to +single-wiki mode and you only get the Wikis tab. + +`--bootstrap-tabs` synthesizes the context-center layout in a sibling +`.cc-bootstrap/` dir at run time: + +- The wiki is copied to `<.cc-bootstrap>/wikis///` + (override the slugs with `--customer-name` and `--wiki-name`; both + default to the wiki dir basename). +- Empty placeholder dirs are created for `tickets/`, `candidates/`, + `skills/`, `drift/` — they render as "No items yet". +- The bootstrap dir is wiped and regenerated each run; your original + `--wiki-dir` is never modified. + +The four placeholder tabs only become *useful* once you wire up the live +server's action endpoints (Rescan / Generate culprit-finding skill / +Create skill / Re-scan drift), which need the `claude` CLI and a +`--proposals-repo`. Bootstrapping just makes the tabs visible. + +## Layout + +``` +skills/wiki-viewer/ +├── SKILL.md +├── README.md +└── scripts/ + ├── serve_wiki.sh # build + serve in one step + ├── build_html_site.py # markdown → HTML site generator for all 5 tabs (Wikis / Tickets / Candidates / Skills / Drift) + ├── scan_candidates.py # cluster-mode + --ticket-file mode candidate generator (invoked from /api/rescan and /api/scan-from-ticket) + ├── score_candidates.py # bridge_score per candidate (severity × coverage over GAPS.json) + └── promote_server.py # static server + POST /api/{promote,rescan,scan-from-ticket,create-skill,promote-skill,acknowledge-drift,rescan-drift} +``` + +## How auto-detection works + +If `--wiki-dir` is omitted, `serve_wiki.sh` looks for these paths in +order and uses the first one that exists and contains at least one +`.md` file: + +1. First customer subdir under `./customer-context/wikis/` — the + standard live output of the wiki-builder skill (Context Center + layout; all 5 tabs render automatically via `--data-dir` + auto-detect) +2. `./customer-context/context/` — legacy single-wiki layout (only + the Wikis tab unless you pass `--bootstrap-tabs`) +3. `./examples/sample_output/` — the bundled sample wiki for demos + +If none are found, it prints a remediation message and exits. + +## Stop the server + +```bash +# In the running shell: Ctrl-C +# In another shell: +lsof -ti :8765 | xargs kill +``` diff --git a/skills/ccb-wiki-viewer/SKILL.md b/skills/ccb-wiki-viewer/SKILL.md new file mode 100644 index 00000000..f9269f72 --- /dev/null +++ b/skills/ccb-wiki-viewer/SKILL.md @@ -0,0 +1,215 @@ +--- +name: skill-ccb-wiki-viewer +description: Serve a generated GCP customer-context wiki as a browseable local **Context Center** — a 5-tab HTML site (Wikis · Tickets · Candidates · Skills · Drift) over one or more customer wikis, with selection-based Edit / Promote-to-PR, ticket-driven candidate generation, headless `/skill-creator` integration, Promote-skill-to-PR, and per-entry Ack + Re-scan for drift. Use this skill whenever the user wants to "view", "browse", "open", "serve", or "preview" a wiki produced by the gcp-customer-context-builder skill — phrasings like "show me the wiki", "open the customer wiki in a browser", "let me explore the wiki", "serve the LLM wiki", "preview the context repo", "open the context center", or after building a wiki when the user wants to inspect the output, scan for reusable patterns, or check what's drifted. Works on any context-center dir; auto-detects the most likely location (first customer under ./customer-context/wikis/, then legacy ./customer-context/context/, then ./examples/sample_output/) but accepts an explicit path. No GCP credentials needed; pure-local Python 3.9+. +--- + +# Wiki Viewer (Context Center) + +This skill builds the local **Context Center** — a 5-tab HTML viewer over +a generated customer-context wiki — and serves it on a local port so the +user can browse it in their browser. The viewer has sidebar navigation +showing the full directory tree, GitHub-style markdown rendering (via +`marked.js` from CDN), breadcrumb headers on every page, intra-wiki link +rewriting so clicking `.md` references in the rendered content navigates +correctly, and a top-level tab nav for the five sections below. + +This skill is the parameterized form of the repo's `try.sh` demo +script — `try.sh` only ever serves the bundled sample wiki at +`examples/sample_output/`; this skill works on any wiki dir. + +## The five tabs + +The viewer is organized into five top-level sections, all rendered by +the same `build_html_site.py` (see its `SECTIONS` list): + +1. **Wikis** — the per-customer wiki tree (one sub-tree per customer). + Selecting any text in the rendered markdown pops a small toolbar with + **Edit** and **Promote** buttons that POST to `/api/promote` and open + PRs against `--proposals-repo`. Per-page **Gaps side panel** lists + structural + coverage gaps from `GAPS.json`, each with a one-click + "Promote bridge" button. +2. **Tickets** — bundled / user-loaded support tickets. Clicking a + ticket opens it; **Generate culprit-finding skill** POSTs to + `/api/scan-from-ticket`, which synthesizes a parameterized debugging + workflow from the ticket text + the wiki's data model and writes it + as a new candidate. +3. **Candidates** — auto-detected reusable workflows produced either by + ticket-to-candidate (above) or by clustering across the wikis + (**🔄 Rescan** button → `/api/rescan`, which shells out to + `scan_candidates.py`). Candidates are sorted by `bridge_score` (sum + of severity × coverage over the gaps the candidate would close — + see `score_candidates.py`). **Create skill** POSTs to + `/api/create-skill`, which invokes headless `/skill-creator` to + scaffold a real skill dir from the candidate and moves it into the + Skills tab. +4. **Skills** — promoted reusable skills (`SKILL.md` + scripts). The + **🚀 Promote skill** button POSTs to `/api/promote-skill`, which + ships the whole skill dir as a PR to the proposals repo. +5. **Drift** — sources that have CHANGED, gone DELETED, or appeared NEW + since the wiki was built (per `DRIFT.md` / `DRIFT.json` produced by + `source_diff.py`). Each entry shows severity (after two-stage + re-validation, when present), affected narrative files, and a + per-entry **Ack** button (`/api/acknowledge-drift`). A top-level + **Re-scan drift** action (`/api/rescan-drift`) re-runs + `source_diff.py` + `revalidate_drift.py` in place and refreshes the + tab without a full rebuild. + +## When to use + +- After the gcp-customer-context-builder skill finishes building a wiki + and the user wants to inspect it +- When the user asks to "browse", "view", "open", "preview", or "serve" + a customer wiki / context repo / LLM wiki +- For a quick demo of the bundled sample wiki ("show me an example wiki") +- For exploring an old wiki that's already on disk + +## Inputs + +- `--wiki-dir=PATH` (optional) — the directory to serve. If omitted, the + skill auto-detects in this order: + 1. First customer subdir under `./customer-context/wikis/` (the + standard live output of the wiki-builder skill — Context Center + layout; all 5 tabs render automatically) + 2. `./customer-context/context/` (legacy single-wiki layout — only + the Wikis tab renders unless you pass `--bootstrap-tabs`) + 3. `./examples/sample_output/` if present (the bundled sample) + 4. otherwise ask the user +- `--site-dir=PATH` (optional) — where to build the HTML output. + Default: `/../site/` (sibling of the wiki). Use the default + unless you have a specific reason. +- `--port=N` (optional, default 8765) — local port. If taken, fail + cleanly and ask the user for a different one. +- `--no-open` (optional) — skip auto-opening the browser. By default + the skill opens the browser to the served root. +- `--proposals-repo=OWNER/REPO` (optional) — enables the **Promote** + feature. When set, highlighting text in the viewer pops a "Promote" + button; clicking it writes the selection to a new file in the named + repo and opens a PR. Requires `gh` CLI and write access to the repo. + When omitted, the Promote button is inert (the selection just doesn't + POST anywhere). Default for this user: `oscarkang24/wiki-proposals`. +- `--proposals-checkout=PATH` (optional) — local cache for the + proposals-repo clone. Default: `~/.cache/wiki-proposals`. +- `--bootstrap-tabs` (optional) — synthesize a context-center layout in a + sibling `.cc-bootstrap/` dir so all 5 tabs render even when the wiki + isn't already at `/wikis///`. The wiki is + copied under `wikis///` and empty placeholder dirs + are created for the four other sections (they render as "No items + yet"). Ignored if `--data-dir` was given or auto-detected. Use this + when the wiki you're serving was built into a single-customer dir + (e.g., `wiki/context/nordstrom/`) and you still want all 5 tabs in the + viewer. +- `--customer-name=NAME` (optional) — overrides the customer slug used + by `--bootstrap-tabs` (default: basename of `--wiki-dir`). +- `--wiki-name=NAME` (optional) — overrides the wiki-name slug used by + `--bootstrap-tabs` (default: same as `--customer-name`). + +## Workflow + +You are the orchestrator. This skill is a single bash invocation + +brief reporting — no sub-agents needed. + +### Step 1 — Resolve the wiki dir + +If the user named a path, use it. Otherwise auto-detect per the order +above. If multiple candidates exist (e.g., both customer-context/ and +examples/sample_output/), prefer the user's *real* output +(`customer-context/`) over the bundled sample. + +If the resolved path doesn't contain at least one `*.md` file, stop and +tell the user the directory looks empty. + +### Step 2 — Verify Python is available + +`python3 --version` should report 3.9+. If not, surface the install URL +and stop. + +### Step 3 — Build + serve + +Run [scripts/serve_wiki.sh](scripts/serve_wiki.sh) with the resolved +arguments. It: + +1. Removes the previous `--site-dir` if any (clean rebuild) +2. Calls `scripts/build_html_site.py` to generate the HTML tree +3. Starts a local server on `--port`: + - If `--proposals-repo` is set, uses + `scripts/promote_server.py` (static files + `POST /api/promote`) + - Otherwise uses plain `python3 -m http.server` (static-only, + Promote button is inert) +4. Opens the browser (unless `--no-open`) + +The server runs in the foreground; the user stops it with Ctrl-C. If +you're invoking this from a Claude Code session, run it as a background +task so the orchestrator can continue and report the URL. + +### Step 4 — Report back + +Tell the user: +- The URL they should open (e.g., `http://127.0.0.1:8765/index.html`) +- The file count and total size of the generated site +- A short list of "interesting starting points" — the customer-root + index, the `CRITIQUE.md` if present, and the most operationally + interesting per-table dir (look for "HIGH-severity" or "deprecated" + in summaries) +- The Ctrl-C / `lsof -ti :8765 | xargs kill` recipe for stopping the + server when done + +## Why a separate skill rather than baking it into wiki-builder + +Three reasons: + +1. **Independent value** — users may want to view a wiki built in a + prior session, or the bundled sample, without touching the + wiki-builder skill at all. +2. **Different mental model** — the wiki-builder is a long-running + multi-agent task; the viewer is a quick local-dev convenience. + Keeping them separate makes each skill's purpose obvious. +3. **Safer composition** — wiki-builder can suggest invoking + wiki-viewer at the end of a build (and its SKILL.md does), but + doesn't auto-launch a server (which would leave a process running + the user might not know about). + +## Promote / Edit on selection (optional) + +When `--proposals-repo=OWNER/REPO` is set, every page in the served +viewer becomes interactive: highlighting any text in the rendered +markdown pops a small floating toolbar with two buttons. + +**Promote** captures the selection verbatim and proposes it as new +content. Clicking it: + +1. Captures the selection, source-page path, page title, and + surrounding block (paragraph / list item / heading) +2. POSTs `{kind: "promote", selection, source_path, ...}` to + `/api/promote` +3. The server clones (or updates) the proposals-repo checkout, writes + `proposals/-promote-.md`, pushes a + `promote/-` branch, runs `gh pr create` +4. The PR URL is shown in a toast at the bottom-right of the viewer + +**Edit** opens a small modal pre-filled with the selection. The user +tweaks the wording and clicks Submit. The modal POSTs +`{kind: "edit", original, proposed, source_path, ...}` to +`/api/promote`; the proposal file then has both an `## Original` and +`## Proposed` block, and the branch / PR title use `Edit:` rather than +`Promote:`. Submit is disabled if the proposed text is empty or +identical to the original. + +The proposals repo is intentionally an **inbox** — an offline job +elsewhere is expected to consume `proposals/*.md` files and apply them +as incremental updates to the canonical wiki. See +[oscarkang24/wiki-proposals](https://github.com/oscarkang24/wiki-proposals) +README for both file shapes. + +Failure modes worth flagging to the user: +- `gh` not installed or not authenticated for the proposals repo +- The repo doesn't exist (the server returns 500; clone step fails) +- Port already in use (server fails to start) + +## Reference files + +- [scripts/serve_wiki.sh](scripts/serve_wiki.sh) — orchestrates build + serve +- [scripts/build_html_site.py](scripts/build_html_site.py) — markdown → HTML site generator for all 5 tabs (also injects Edit / Promote / Gaps-panel JS) +- [scripts/scan_candidates.py](scripts/scan_candidates.py) — cluster-mode + ticket-mode candidate generator (invoked from `/api/rescan` and `/api/scan-from-ticket`) +- [scripts/score_candidates.py](scripts/score_candidates.py) — computes `bridge_score` for each candidate against each customer's `GAPS.json`; runs after every rescan +- [scripts/promote_server.py](scripts/promote_server.py) — static-files server + the `POST /api/*` handlers (`promote`, `rescan`, `scan-from-ticket`, `create-skill`, `promote-skill`, `acknowledge-drift`, `rescan-drift`) +- [README.md](README.md) — install + manual usage diff --git a/skills/ccb-wiki-viewer/scripts/build_html_site.py b/skills/ccb-wiki-viewer/scripts/build_html_site.py new file mode 100755 index 00000000..e514ccaa --- /dev/null +++ b/skills/ccb-wiki-viewer/scripts/build_html_site.py @@ -0,0 +1,1548 @@ +#!/usr/bin/env python3 +"""Build a portable static-HTML viewer for a generated customer-context wiki. + +Two modes: + +(1) Single-section mode (back-compat with original wiki-viewer): + --input-dir points at a single wiki tree. Output mirrors the .md tree + one-to-one as .html under --output-dir. No top tabs. + +(2) Context-center mode (new): + --input-dir contains any subset of these subdirs: + wikis/ (per-customer wikis) + tickets/ (incoming support tickets) + candidates/ (auto-detected reusable patterns) + skills/ (promoted reusable skills) + drift/ (source-of-truth drift reports) + Each detected subdir becomes a top tab (5 max, in the order above — + see SECTIONS below). Each tab is rendered into output_dir/
/. + The top-level output_dir/index.html redirects to the first available + section. Section indexes are auto-generated if missing. + +Per-page features (both modes): +- Each *.md becomes a *.html in the same relative location. +- Sidebar with the section's tree (current file highlighted). +- Content rendered via marked.js (CDN). +- Intra-wiki *.md links rewritten to *.html. +- Edit/Promote selection toolbar (posts to /api/promote when served via + promote_server.py; inert on the static server). +- Rescan button on the Candidates tab landing page (posts to /api/rescan). + +Usage: + # Single-section + python3 scripts/build_html_site.py \\ + --input-dir=customer-context/context \\ + --output-dir=customer-context/site + + # Context-center + python3 scripts/build_html_site.py \\ + --input-dir=examples/sample_context_center \\ + --output-dir=examples/sample_context_center_site +""" +from __future__ import annotations + +import argparse +import html +import http.server +import json +import os +import re +import socketserver +import sys +import webbrowser +from pathlib import Path + + +# Recognized section names and their display labels (in tab order). +# Order encodes the demo flow: data context (wikis) → user signals (tickets) +# → detected workflows (candidates) → reusable artifacts (skills). +SECTIONS = [ + ("wikis", "Wikis"), + ("tickets", "Tickets"), + ("candidates", "Candidates"), + ("skills", "Skills"), + ("drift", "Drift"), +] + + +PAGE_TEMPLATE = """ + + + +{title} + + + + +{tabs_html} +
+ +
+ + {action_bar_html} +
Source: {source_path}
+
+
+{gaps_panel_html} +
+ + +
+ + +
+
+ +
+
+ + + + +""" + + +def collect_md_files(root: Path) -> list[Path]: + return sorted(root.rglob("*.md")) + + +_H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) + + +def extract_h1(md_content: str, fallback: str) -> str: + m = _H1_RE.search(md_content) + return m.group(1).strip() if m else fallback + + +def render_sidebar(root: Path, output_dir: Path, current_rel: Path | None, current_html_dir: Path) -> str: + """Recursively render the tree as nested
    , highlighting current_rel. + + Hrefs point into output_dir (where the .html files live), not root + (where the .md files live). current_rel may be None for synthetic pages + (e.g. the top-level redirect page) where nothing should be highlighted. + """ + def render_dir(d: Path, depth: int = 0) -> list[str]: + out = ["
      "] if depth == 0 else [] + children = sorted(d.iterdir(), key=lambda p: (p.is_file(), 0 if p.name == "index.md" else 1, p.name.lower())) + files = [c for c in children if c.is_file() and c.suffix == ".md"] + subdirs = [c for c in children if c.is_dir()] + if depth > 0: + out.append("
        ") + for f in files: + rel = f.relative_to(root) + html_rel = rel.with_suffix(".html") + href = os.path.relpath(output_dir / html_rel, start=current_html_dir).replace(os.sep, "/") + cur = ' class="current"' if rel == current_rel else "" + label = html.escape(f.name) + out.append(f'
      • ·{label}
      • ') + for sd in subdirs: + out.append(f'
      • {html.escape(sd.name)}/') + out.extend(render_dir(sd, depth + 1)) + out.append("
      • ") + if depth > 0: + out.append("
      ") + if depth == 0: + out.append("
    ") + return out + + return "".join(render_dir(root)) + + +def render_breadcrumb(rel_path: Path, root: Path, output_dir: Path, current_html_dir: Path) -> str: + parts = list(rel_path.parts) + crumbs = [] + # Root link points into output_dir, not into the input markdown tree. + root_href = os.path.relpath(output_dir / "index.html", start=current_html_dir).replace(os.sep, "/") + crumbs.append(f'{html.escape(root.name)}') + # Each intermediate dir links to its index.html if one exists. + md_acc = root + html_acc = output_dir + for i, part in enumerate(parts): + md_acc = md_acc / part + html_acc = html_acc / part + is_last = i == len(parts) - 1 + if is_last: + crumbs.append(f"{html.escape(part)}") + else: + # Link iff the source directory has an index.md. + if (md_acc / "index.md").exists(): + href = os.path.relpath(html_acc / "index.html", start=current_html_dir).replace(os.sep, "/") + crumbs.append(f'{html.escape(part)}') + else: + crumbs.append(html.escape(part)) + return ''.join(crumbs) + + +def render_tabs(active_section: str | None, + sections_present: list[tuple[str, str]], + section_counts: dict[str, int], + output_root: Path, + current_html_dir: Path) -> str: + """Render the top tab nav. Returns empty string in single-section mode + (sections_present empty).""" + if not sections_present: + return "" + parts = ['") + return "".join(parts) + + +def render_action_bar(active_section: str | None, rel_path: Path | None) -> str: + """Show a section-specific action bar: + - Rescan button on candidates/index.html + - Create-skill button on candidates//candidate.html + - Promote-skill button on skills//SKILL.html (always present) + Empty string everywhere else.""" + if rel_path is None: + return "" + rp = rel_path.as_posix() + + if active_section == "candidates": + if rp == "index.md": + return ( + '
    ' + 'Re-run pattern detection across all wikis. ' + 'Calls scan_candidates.py via the local server.' + '' + '
    ' + ) + if rp.endswith("/candidate.md"): + return ( + '
    ' + 'Promote this candidate into a real Claude Code skill via ' + '/skill-creator. Writes to skills/<slug>/ in ' + 'the data dir; rebuilds the site so the new skill shows up in the Skills tab.' + '' + '
    ' + ) + return "" + + if active_section == "tickets": + # Show "Generate culprit-finding skill" on ticket detail pages. + if rp.endswith("/ticket.md"): + return ( + '
    ' + 'Use this ticket + the wiki context to generate a ' + 'reusable culprit-finding workflow. Lands as a candidate; promote it ' + 'with Create skill.' + '' + '
    ' + ) + return "" + + if active_section == "skills": + # Show Promote on SKILL.md pages (the skill entry point). + if rp.endswith("/SKILL.md"): + return ( + '
    ' + 'Ship this skill as a PR to your proposals repo. ' + 'Requires PROPOSALS_REPO set when launching the server; ' + 'otherwise the button shows a helpful error.' + '' + '
    ' + ) + return "" + + if active_section == "drift": + # Show Re-scan + bulk-ack on DRIFT.md detail pages. + if rp.endswith("/DRIFT.md"): + return ( + '
    ' + 'Re-run drift detection against the live ' + 'source files for this customer (compares hashes vs. the ' + 'manifest). Use the Ack button next to ' + 'each entry to dismiss it from this report.' + '' + '
    ' + ) + return "" + + return "" + + +def find_wiki_root(page_abs: Path, section_root: Path) -> Path | None: + """Walk up from page_abs until we find a directory containing GAPS.json, + stopping at section_root. Returns None if no GAPS.json is found. + + This handles both flat layouts (wikis//GAPS.json) and the + bundled-demo layout (wikis///GAPS.json) without + hardcoding the depth.""" + section_root_resolved = section_root.resolve() + cur = page_abs.resolve().parent + while True: + if (cur / "GAPS.json").is_file(): + return cur + if cur == section_root_resolved or section_root_resolved not in cur.parents: + return None + cur = cur.parent + + +def gaps_for_page( + page_abs: Path, section_root: Path, gaps_cache: dict[Path, list[dict]], +) -> list[dict]: + """Return the subset of GAPS.json entries that reference this page. + + Filtering rule: a gap matches the current page iff the page's path + relative to its wiki root is in `gap.pages` (structural) OR the page + is one of the canonical "surface here" files for coverage gaps + (index.md / data_warehouse.md), which don't have specific pages but + deserve a hint where to add the missing concept. + """ + wiki_root = find_wiki_root(page_abs, section_root) + if wiki_root is None: + return [] + + if wiki_root not in gaps_cache: + try: + gaps_cache[wiki_root] = json.loads( + (wiki_root / "GAPS.json").read_text(encoding="utf-8") + ).get("gaps", []) + except Exception: + gaps_cache[wiki_root] = [] + + all_gaps = gaps_cache[wiki_root] + if not all_gaps: + return [] + + page_in_wiki = page_abs.resolve().relative_to(wiki_root).as_posix() + surface_coverage_on = {"index.md", "data_warehouse.md"} + + out: list[dict] = [] + for g in all_gaps: + if g.get("type") == "structural": + if page_in_wiki in g.get("pages", []): + out.append(g) + elif g.get("type") == "coverage": + if page_in_wiki in surface_coverage_on: + out.append(g) + return out + + +def aggregate_gaps_under( + section_root: Path, page_dir: Path, + gaps_cache: dict[Path, list[dict]], + *, max_per_wiki: int = 25, top_n_total: int = 50, +) -> tuple[list[dict], int]: + """Walk descendants of page_dir and aggregate gaps from every GAPS.json + found. Returns (gaps, num_wikis_aggregated). + + Used on autogen landings (wikis/index.html, wikis//index.html) + where there's no single wiki root to scope to. Each gap is annotated + with `_wiki_label` so the panel can show which customer it came from. + + Severity-sort and cap to top_n_total to avoid overwhelming the panel. + """ + out: list[dict] = [] + wikis_seen = 0 + page_dir = page_dir.resolve() + section_root = section_root.resolve() + for gaps_path in sorted(page_dir.rglob("GAPS.json")): + wiki_root = gaps_path.parent.resolve() + wikis_seen += 1 + if wiki_root not in gaps_cache: + try: + gaps_cache[wiki_root] = json.loads( + gaps_path.read_text(encoding="utf-8") + ).get("gaps", []) + except Exception: + gaps_cache[wiki_root] = [] + try: + wiki_label = wiki_root.relative_to(section_root).as_posix() + except ValueError: + wiki_label = wiki_root.name + for g in gaps_cache[wiki_root][:max_per_wiki]: + annotated = dict(g) + annotated["_wiki_label"] = wiki_label + out.append(annotated) + # Severity sort, take top_n_total. + sev_rank = {"high": 0, "medium": 1, "low": 2} + out.sort(key=lambda g: (sev_rank.get(g.get("severity", "low"), 9), + g.get("type", ""), g.get("id", ""))) + return out[:top_n_total], wikis_seen + + +def render_page(md_path: Path, + root: Path, + output_dir: Path, + repo_name: str, + file_count: int, + *, + active_section: str | None = None, + sections_present: list[tuple[str, str]] | None = None, + section_counts: dict[str, int] | None = None, + output_root: Path | None = None, + section_root: Path | None = None, + gaps_cache: dict[Path, list[dict]] | None = None) -> Path: + rel = md_path.relative_to(root) + out_rel = rel.with_suffix(".html") + out_path = output_dir / out_rel + out_path.parent.mkdir(parents=True, exist_ok=True) + + md_content = md_path.read_text(encoding="utf-8") + escaped = html.escape(md_content) + + sidebar_html = render_sidebar(root, output_dir, current_rel=rel, current_html_dir=out_path.parent) + breadcrumb_html = render_breadcrumb(rel, root, output_dir, current_html_dir=out_path.parent) + tabs_html = render_tabs( + active_section=active_section, + sections_present=sections_present or [], + section_counts=section_counts or {}, + output_root=output_root or output_dir, + current_html_dir=out_path.parent, + ) + action_bar_html = render_action_bar(active_section, rel) + + # Gaps panel: surface on every Wikis-tab page. + # - Inside a wiki root (find_wiki_root succeeds) → per-page gaps from + # that wiki's GAPS.json. + # - On an autogen landing (no GAPS.json walking up) → AGGREGATE gaps + # from every wiki under the current dir. Each entry is annotated with + # its source wiki so the user can navigate. + page_gaps: list[dict] = [] + wiki_root_dir: Path | None = None + is_aggregate = False + aggregate_count = 0 + if active_section == "wikis" and section_root is not None and gaps_cache is not None: + wiki_root_dir = find_wiki_root(md_path, section_root) + if wiki_root_dir is not None: + page_gaps = gaps_for_page(md_path, section_root, gaps_cache) + else: + # Autogen landing — aggregate downward. + page_dir = md_path.parent + page_gaps, aggregate_count = aggregate_gaps_under( + section_root, page_dir, gaps_cache, + ) + is_aggregate = aggregate_count > 0 + + if wiki_root_dir is not None: + rel_wiki_root = wiki_root_dir.resolve().relative_to(section_root.resolve()) + gaps_html_in_output = output_dir / rel_wiki_root / "GAPS.html" + gaps_md_href = os.path.relpath(gaps_html_in_output, start=out_path.parent).replace(os.sep, "/") + gap_count = len(page_gaps) + gaps_panel_html = ( + '' + ) + elif is_aggregate: + gap_count = len(page_gaps) + gaps_panel_html = ( + '' + ) + else: + gaps_panel_html = "" + + title = f"{rel.as_posix()} — {repo_name}" + root_index_href = os.path.relpath(output_dir / "index.html", start=out_path.parent).replace(os.sep, "/") + + page_title = extract_h1(md_content, fallback=rel.as_posix()) + + page = PAGE_TEMPLATE.format( + title=html.escape(title), + repo_name=html.escape(repo_name), + root_index_href=root_index_href, + tree_meta=f"{file_count} files", + sidebar_html=sidebar_html, + breadcrumb_html=breadcrumb_html, + source_path=html.escape(rel.as_posix()), + escaped_md=escaped, + source_path_json=json.dumps(rel.as_posix()), + data_path_json=json.dumps( + f"{active_section}/{rel.as_posix()}" if active_section else rel.as_posix() + ), + page_title_json=json.dumps(page_title), + tabs_html=tabs_html, + action_bar_html=action_bar_html, + gaps_panel_html=gaps_panel_html, + gaps_data_json=html.escape(json.dumps(page_gaps)), + ) + out_path.write_text(page, encoding="utf-8") + return out_path + + +def autogen_section_index(section_root: Path, section_label: str) -> None: + """Write a section_root/index.md if one is missing. + + Lists immediate subdirs and files as a simple landing page. Idempotent — + only writes if missing, so user-supplied indexes win. + """ + idx = section_root / "index.md" + if idx.exists(): + return + section_root.mkdir(parents=True, exist_ok=True) + children = sorted(section_root.iterdir(), key=lambda p: (p.is_file(), p.name.lower())) + children = [c for c in children if c.name != "index.md"] + lines = [f"# {section_label}", ""] + if not children: + lines.append(f"_No items in {section_label.lower()} yet._") + else: + for c in children: + if c.is_dir() and (c / "index.md").exists(): + lines.append(f"- [{c.name}]({c.name}/index.md)") + elif c.is_file() and c.suffix == ".md": + lines.append(f"- [{c.name}]({c.name})") + lines.append("") + idx.write_text("\n".join(lines), encoding="utf-8") + + +def write_top_redirect(output_dir: Path, first_section: str) -> None: + """Write output_dir/index.html that redirects to the first section's index.""" + target = f"{first_section}/index.html" + html_doc = ( + "" + f'' + f'Context Center' + f'{target}' + ) + (output_dir / "index.html").write_text(html_doc, encoding="utf-8") + + +def detect_sections(input_dir: Path) -> list[tuple[str, str]]: + """Return the subset of recognized sections present under input_dir, + in the canonical tab order.""" + return [(slug, label) for slug, label in SECTIONS if (input_dir / slug).is_dir()] + + +def serve(directory: Path, port: int, bind: str = "127.0.0.1") -> None: + os.chdir(directory) + + class _H(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args, **kwargs): # quiet + pass + + with socketserver.TCPServer((bind, port), _H) as httpd: + url = f"http://127.0.0.1:{port}/index.html" + print(f"serving {directory} at {url}", file=sys.stderr) + if bind != "127.0.0.1": + print( + f" bind={bind} — also reachable from other devices at " + f"http://:{port}/", + file=sys.stderr, + ) + webbrowser.open(url) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nstopped.", file=sys.stderr) + + +def render_section(section_root: Path, + section_output: Path, + *, + section_slug: str, + repo_name: str, + sections_present: list[tuple[str, str]], + section_counts: dict[str, int], + output_root: Path) -> int: + autogen_section_index(section_root, section_label_for(section_slug)) + md_files = collect_md_files(section_root) + if not md_files: + # Auto-gen guarantees at least an index.md, so this shouldn't fire. + return 0 + section_output.mkdir(parents=True, exist_ok=True) + # Per-section cache so we read each customer's GAPS.json at most once. + gaps_cache: dict[Path, list[dict]] = {} + for md in md_files: + render_page( + md, section_root, section_output, repo_name=repo_name, file_count=len(md_files), + active_section=section_slug, + sections_present=sections_present, + section_counts=section_counts, + output_root=output_root, + section_root=section_root, + gaps_cache=gaps_cache, + ) + return len(md_files) + + +def section_label_for(slug: str) -> str: + return dict(SECTIONS).get(slug, slug.title()) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--input-dir", required=True) + ap.add_argument("--output-dir", required=True) + ap.add_argument("--repo-name", default="customer-context wiki") + ap.add_argument("--serve", action="store_true") + ap.add_argument("--port", type=int, default=8765) + args = ap.parse_args() + + root = Path(args.input_dir).resolve() + output_dir = Path(args.output_dir).resolve() + if not root.is_dir(): + sys.exit(f"--input-dir does not exist: {root}") + + output_dir.mkdir(parents=True, exist_ok=True) + + sections_present = detect_sections(root) + + if not sections_present: + # Single-section / back-compat mode: render the input dir as one tree. + md_files = collect_md_files(root) + if not md_files: + sys.exit(f"no .md files found under {root}") + for md in md_files: + render_page(md, root, output_dir, repo_name=args.repo_name, file_count=len(md_files)) + print(f"wrote {len(md_files)} HTML pages to {output_dir}") + print(f"open: file://{output_dir}/index.html") + else: + # Context-center mode: render each detected section into output_dir//. + # Pre-pass: ensure each section has an index, then compute semantic + # counts. The tab badge counts items (customer wikis / skills / + # candidates), NOT pages — so it's the number of immediate subdirs + # under each section root, ignoring the auto-gen index file. + for slug, _label in sections_present: + autogen_section_index(root / slug, section_label_for(slug)) + section_counts = { + slug: sum(1 for c in (root / slug).iterdir() if c.is_dir()) + for slug, _label in sections_present + } + + total = 0 + for slug, label in sections_present: + n = render_section( + root / slug, + output_dir / slug, + section_slug=slug, + repo_name=args.repo_name, + sections_present=sections_present, + section_counts=section_counts, + output_root=output_dir, + ) + total += n + print(f" {label}: wrote {n} pages") + + write_top_redirect(output_dir, sections_present[0][0]) + print(f"wrote {total} HTML pages across {len(sections_present)} section(s) to {output_dir}") + print(f"open: file://{output_dir}/index.html") + + if args.serve: + serve(output_dir, args.port) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-wiki-viewer/scripts/promote_server.py b/skills/ccb-wiki-viewer/scripts/promote_server.py new file mode 100644 index 00000000..8749daa3 --- /dev/null +++ b/skills/ccb-wiki-viewer/scripts/promote_server.py @@ -0,0 +1,987 @@ +#!/usr/bin/env python3 +"""Serve a built wiki/context-center site with optional POST endpoints. + +This replaces `python3 -m http.server` for the wiki-viewer skill. Static +file serving is identical to `SimpleHTTPRequestHandler`; the added +behavior is seven POST endpoints: + + POST /api/promote (only if --proposals-repo is set) + 1. Receives JSON: {selection, source_path, page_title, surrounding_context, page_url} + 2. Locates a checkout of the proposals repo (clones if missing, fetches + and resets to origin/main if present) + 3. Writes proposals/-.md with frontmatter + body + 4. Pushes a `promote/-` branch and runs `gh pr create` + 5. Returns {pr_url} + + POST /api/rescan (only if --data-dir is set) + 1. Runs scan_candidates.py against /wikis, writing + /candidates/ + 2. Re-runs build_html_site.py to rebuild --site-dir from --data-dir + 3. Returns {candidate_count} + + POST /api/scan-from-ticket (only if --data-dir is set) + 1. Receives JSON: {ticket_path} + 2. Runs scan_candidates.py --ticket-file= against the wikis; + output is appended to /candidates/ + 3. Re-runs build_html_site.py + 4. Returns {candidate_slug, candidate_url} + + POST /api/promote-skill (requires --data-dir AND --proposals-repo) + 1. Receives JSON: {skill_path} + 2. Copies // into the proposals repo at + proposals/skills// + 3. Pushes a `promote-skill/-` branch and opens a PR + 4. Returns {pr_url} + + POST /api/create-skill (only if --data-dir is set) + 1. Receives JSON: {candidate_path} + 2. Reads / + sibling sources.json + 3. Runs `claude -p --dangerously-skip-permissions ""` + so the skill-creator skill scaffolds a real skill into + /skills// + 4. Re-runs build_html_site.py + 5. Returns {skill_path, skill_url} + + POST /api/rescan-drift (only if --data-dir is set) + 1. Receives JSON: {customer} (the wiki sub-directory to re-scan) + 2. Re-runs source_diff.py + revalidate_drift.py for that customer + 3. Re-runs build_html_site.py to refresh the Drift tab + 4. Returns {drift_count} + + POST /api/acknowledge-drift (only if --data-dir is set) + 1. Receives JSON: {customer, drift_id, note?} + 2. Shells out to acknowledge_drift.py to append the entry to + /.drift-acknowledged.json + 3. Re-runs build_html_site.py so the entry disappears from the tab + 4. Returns {ok: true} + +Args (env vars or CLI): + --site-dir the built HTML site to serve (required) + --port local port (default 8765) + --proposals-repo GitHub slug, e.g. oscarkang24/wiki-proposals (optional) + --proposals-checkout local clone path (default ~/.cache/wiki-proposals) + --data-dir context-center data root (parent of wikis/, candidates/, etc.) (optional) + --repo-name passed through to build_html_site.py during rescan +""" +from __future__ import annotations + +import argparse +import datetime as dt +import http.server +import json +import os +import re +import socketserver +import subprocess +import sys +from pathlib import Path + + +def run(cmd: list[str], cwd: Path | None = None, check: bool = True, + env: dict | None = None) -> subprocess.CompletedProcess: + return subprocess.run(cmd, cwd=cwd, check=check, capture_output=True, text=True, env=env) + + +def slugify(text: str, max_len: int = 50) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-") + return (s[:max_len] or "promotion").rstrip("-") + + +def ensure_checkout(repo_slug: str, checkout_dir: Path) -> None: + """Clone the proposals repo if missing; otherwise fetch+reset to origin/main.""" + if not checkout_dir.exists(): + checkout_dir.parent.mkdir(parents=True, exist_ok=True) + run(["gh", "repo", "clone", repo_slug, str(checkout_dir)]) + return + # Existing checkout — make sure we're on a clean main matching origin + run(["git", "fetch", "--quiet", "origin"], cwd=checkout_dir) + run(["git", "checkout", "--quiet", "main"], cwd=checkout_dir) + run(["git", "reset", "--hard", "--quiet", "origin/main"], cwd=checkout_dir) + # Best-effort: delete any lingering local promote/*, edit/*, promote-skill/* + # branches that were already pushed (so they don't accumulate). Don't fail on errors. + for prefix in ("promote/*", "edit/*", "promote-skill/*"): + branches = run(["git", "branch", "--list", prefix], cwd=checkout_dir, check=False) + for line in branches.stdout.splitlines(): + b = line.strip().lstrip("*").strip() + if b: + subprocess.run(["git", "branch", "-D", b], cwd=checkout_dir, capture_output=True) + + +def make_proposal(payload: dict, checkout_dir: Path, repo_slug: str) -> str: + kind = (payload.get("kind") or "promote").strip().lower() + if kind not in ("promote", "edit"): + raise ValueError(f"unknown kind: {kind!r} (expected 'promote' or 'edit')") + source_path = (payload.get("source_path") or "").strip() + page_title = (payload.get("page_title") or source_path or "untitled").strip() + context = (payload.get("surrounding_context") or "").strip() + page_url = (payload.get("page_url") or "").strip() + if not source_path: + raise ValueError("missing source_path") + + if kind == "promote": + selection = (payload.get("selection") or "").strip() + if not selection: + raise ValueError("empty selection") + original = None + proposed = selection + else: # edit + original = (payload.get("original") or "").strip() + proposed = (payload.get("proposed") or "").strip() + if not original: + raise ValueError("edit: missing original") + if not proposed: + raise ValueError("edit: missing proposed") + if original == proposed: + raise ValueError("edit: proposed is identical to original") + + ts = dt.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + slug = slugify(page_title) + filename = f"{ts}-{kind}-{slug}.md" + branch = f"{kind}/{ts}-{slug}" + + # Frontmatter + today = dt.date.today().isoformat() + user = os.environ.get("USER", "unknown") + body_lines = [ + "---", + f"kind: {kind}", + f"source_page: {source_path}", + f"source_url: {page_url}", + f"selected_on: {today}", + f"selected_by: {user}", + f"page_title: {json.dumps(page_title)}", + "---", + "", + "**Surrounding context:**", + "", + ] + if context: + for line in context.splitlines(): + body_lines.append(f"> {line}") + else: + body_lines.append("> _(no surrounding block detected)_") + body_lines += ["", "---", ""] + + if kind == "promote": + body_lines += [proposed, ""] + else: # edit + body_lines += [ + "## Original", + "", + original, + "", + "## Proposed", + "", + proposed, + "", + ] + file_body = "\n".join(body_lines) + + # Write + commit + push + PR + proposals_dir = checkout_dir / "proposals" + proposals_dir.mkdir(exist_ok=True) + file_rel = f"proposals/{filename}" + (checkout_dir / file_rel).write_text(file_body, encoding="utf-8") + + run(["git", "checkout", "--quiet", "-b", branch], cwd=checkout_dir) + run(["git", "add", file_rel], cwd=checkout_dir) + verb = "Promote" if kind == "promote" else "Edit" + commit_msg = f"{verb}: {page_title[:60]}" + run(["git", "commit", "--quiet", "-m", commit_msg], cwd=checkout_dir) + run(["git", "push", "--quiet", "-u", "origin", branch], cwd=checkout_dir) + + # PR body: short pointer-style, all the content lives in the committed file + if kind == "promote": + pr_body = ( + f"Promoted from `{source_path}` in the LLM wiki.\n\n" + f"Source page: {page_url or '_(no live URL)_' }\n\n" + f"See [`{file_rel}`](../blob/{branch}/{file_rel}) for the selection + context." + ) + else: + pr_body = ( + f"Edit proposed against `{source_path}` in the LLM wiki.\n\n" + f"Source page: {page_url or '_(no live URL)_' }\n\n" + f"See [`{file_rel}`](../blob/{branch}/{file_rel}) for the original + proposed text + context." + ) + pr_title = f"{verb}: {page_title[:60]}" + pr_res = run( + ["gh", "pr", "create", "--repo", repo_slug, "--head", branch, "--base", "main", + "--title", pr_title, "--body", pr_body], + cwd=checkout_dir, + ) + pr_url = pr_res.stdout.strip().splitlines()[-1] + + # Reset back to main so the next promote starts clean + run(["git", "checkout", "--quiet", "main"], cwd=checkout_dir) + return pr_url + + +def best_error_line(stderr: str | None, stdout: str | None) -> str: + """Extract a useful one-line summary from a failed subprocess. + + Prefers the last non-trivial line of stderr (skipping empty lines and + pure prefix labels like "stderr:"). Falls back to stdout, then a generic + message. Cap at 240 chars so it fits in a toast. + """ + for blob in (stderr, stdout): + if not blob: + continue + for line in reversed(blob.splitlines()): + s = line.strip() + if not s: + continue + if s.lower() in ("stdout:", "stderr:"): + continue + return s[:240] + return "subprocess failed (no output)" + + +def promote_skill(skill_dir: Path, + data_dir: Path, + checkout_dir: Path, + repo_slug: str) -> str: + """Copy a skill's whole directory to the proposals repo and open a PR. + + Files land at proposals/skills//<...> in the proposals repo, on a + branch named promote-skill/-. + """ + slug = skill_dir.name + if not (skill_dir / "SKILL.md").is_file(): + raise RuntimeError(f"skill dir is missing SKILL.md: {skill_dir}") + + ensure_checkout(repo_slug, checkout_dir) + + ts = dt.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + branch = f"promote-skill/{ts}-{slug}" + target_subdir = checkout_dir / "proposals" / "skills" / slug + if target_subdir.exists(): + # Stale leftover from a prior failed run — remove so the copy is clean. + import shutil as _shutil + _shutil.rmtree(target_subdir) + target_subdir.parent.mkdir(parents=True, exist_ok=True) + + # Copy the entire skill dir. + import shutil as _shutil + _shutil.copytree(skill_dir, target_subdir) + + rel_paths = [] + for p in sorted(target_subdir.rglob("*")): + if p.is_file(): + rel_paths.append(p.relative_to(checkout_dir).as_posix()) + if not rel_paths: + raise RuntimeError(f"no files copied from {skill_dir}") + + run(["git", "checkout", "--quiet", "-b", branch], cwd=checkout_dir) + run(["git", "add"] + rel_paths, cwd=checkout_dir) + commit_msg = f"Skill: {slug}" + run(["git", "commit", "--quiet", "-m", commit_msg], cwd=checkout_dir) + run(["git", "push", "--quiet", "-u", "origin", branch], cwd=checkout_dir) + + pr_body = ( + f"Promoting skill `{slug}` from local skills/ dir.\n\n" + f"Files: {len(rel_paths)} (including `proposals/skills/{slug}/SKILL.md`).\n\n" + "Generated by the context-center viewer's Promote-skill button " + "(local skills dir → proposals repo)." + ) + pr_title = f"Skill: {slug}" + pr_res = run( + ["gh", "pr", "create", "--repo", repo_slug, "--head", branch, "--base", "main", + "--title", pr_title, "--body", pr_body], + cwd=checkout_dir, + ) + pr_url = pr_res.stdout.strip().splitlines()[-1] + + run(["git", "checkout", "--quiet", "main"], cwd=checkout_dir) + return pr_url + + +def _claude_env() -> dict: + """Env for shelling out to `claude -p`. When the parent itself is a + Claude Code session, strip CLAUDECODE + host-managed auth + every + CLAUDE_CODE_*/CLAUDE_AGENT_* sentinel so the nested call falls back to + the user's stored OAuth credentials.""" + env = dict(os.environ) + if env.get("CLAUDECODE"): + for k in list(env.keys()): + if ( + k in ("CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL") + or k.startswith("CLAUDE_CODE_") + or k.startswith("CLAUDE_AGENT_") + ): + env.pop(k, None) + return env + + +def run_create_skill(data_dir: Path, site_dir: Path, repo_name: str, + candidate_path: str) -> dict: + """Run `/skill-creator` headlessly to promote a candidate into a real skill. + + Reads the candidate's candidate.md + sources.json, builds a prompt that + tells Claude to use skill-creator and write into /skills//, + runs `claude -p` with --dangerously-skip-permissions, then rebuilds the + site so the new skill appears in the Skills tab. + """ + # Validate candidate_path: must live under data-dir/candidates//candidate.md + candidate_abs = (data_dir / candidate_path).resolve() + candidates_root = (data_dir / "candidates").resolve() + if not str(candidate_abs).startswith(str(candidates_root) + os.sep): + raise RuntimeError(f"candidate_path must be under candidates/: {candidate_path}") + if candidate_abs.name != "candidate.md" or not candidate_abs.is_file(): + raise RuntimeError(f"not a candidate.md file: {candidate_path}") + + candidate_dir = candidate_abs.parent + slug = candidate_dir.name + candidate_md = candidate_abs.read_text(encoding="utf-8") + sources_json_path = candidate_dir / "sources.json" + sources_text = sources_json_path.read_text(encoding="utf-8") if sources_json_path.exists() else "{}" + + skills_root = data_dir / "skills" + skills_root.mkdir(exist_ok=True) + target_dir = skills_root / slug + + prompt = ( + "Use the skill-creator skill to scaffold a new Claude Code skill from the " + "candidate stub below. Write the skill files into the target directory shown.\n\n" + f"Target directory (create if missing): {target_dir}\n" + "Required output: at minimum SKILL.md inside the target dir. Add a scripts/ " + "subdir with any obvious helper scripts the skill needs. DO NOT run evals or " + "tests — this is a one-shot scaffolding pass, not an interactive build. Stop " + "as soon as the SKILL.md is written.\n\n" + f"Candidate name (slug): {slug}\n\n" + "## Candidate stub (candidate.md)\n\n" + f"{candidate_md}\n\n" + "## Source references (sources.json)\n\n" + "```json\n" + f"{sources_text}\n" + "```\n\n" + "Improve the draft SKILL.md where skill-creator's best practices suggest " + "changes (clearer description for trigger accuracy, sharper when-to-use, " + "concrete inputs/outputs)." + ) + + cmd = [ + "claude", "-p", + "--dangerously-skip-permissions", + prompt, + ] + sys.stderr.write(f"==> Create-skill: invoking claude -p (target: {target_dir})\n") + proc = subprocess.run(cmd, capture_output=True, text=True, env=_claude_env()) + if proc.returncode != 0: + raise RuntimeError( + f"claude -p failed (exit {proc.returncode}):\n" + f"stdout: {proc.stdout[:500]}\nstderr: {proc.stderr[:500]}" + ) + + skill_md = target_dir / "SKILL.md" + if not skill_md.exists(): + raise RuntimeError( + f"claude -p completed but no SKILL.md was written at {skill_md}.\n" + f"stdout tail: {proc.stdout[-500:]}" + ) + + # Rebuild the site so the new skill appears in the Skills tab. + scripts_dir = Path(__file__).resolve().parent + build_script = scripts_dir / "build_html_site.py" + sys.stderr.write(f"==> Create-skill: rebuilding site at {site_dir}\n") + run([sys.executable, str(build_script), + f"--input-dir={data_dir}", + f"--output-dir={site_dir}", + f"--repo-name={repo_name}"], env=_claude_env()) + + skill_rel = target_dir.relative_to(data_dir).as_posix() + return { + "skill_path": skill_rel, + "skill_url": f"/{skill_rel}/SKILL.html", + } + + +def _maybe_score_candidates(scripts_dir: Path, wikis_root: Path, + candidates_dir: Path, env: dict) -> None: + """Run score_candidates.py if it exists. Tolerant of failure — bridge + scoring is enrichment, not a blocking step. If wikis don't have GAPS.json + yet, the scorer will write bridge_score=0 for every candidate, which is + fine.""" + score_script = scripts_dir / "score_candidates.py" + if not score_script.is_file(): + return + try: + run([sys.executable, str(score_script), + f"--wikis-root={wikis_root}", + f"--candidates-dir={candidates_dir}", + "--quiet"], env=env) + except subprocess.CalledProcessError as e: + sys.stderr.write( + f"warning: score_candidates failed (continuing): " + f"{best_error_line(e.stderr, e.stdout)}\n" + ) + + +def run_scan_from_ticket(data_dir: Path, site_dir: Path, repo_name: str, + ticket_path: str) -> dict: + """Run scan_candidates.py with --ticket-file to synthesize ONE candidate + from a single ticket + the wikis. Append (don't wipe) the candidates dir. + Rebuild the site. Returns {candidate_slug, candidate_url, used_canned}. + + Demo fallback: if the live `claude -p` call fails (e.g. nested Claude Code + auth) AND the ticket has a sibling canned_response.txt, retry the scan + with --response-file pointing at that fixture so the demo still works. + """ + scripts_dir = Path(__file__).resolve().parent + scan_script = scripts_dir / "scan_candidates.py" + build_script = scripts_dir / "build_html_site.py" + + # Validate ticket_path lives under data-dir/tickets//ticket.md + ticket_abs = (data_dir / ticket_path).resolve() + tickets_root = (data_dir / "tickets").resolve() + if not str(ticket_abs).startswith(str(tickets_root) + os.sep): + raise RuntimeError(f"ticket_path must be under tickets/: {ticket_path}") + if ticket_abs.name != "ticket.md" or not ticket_abs.is_file(): + raise RuntimeError(f"not a ticket.md file: {ticket_path}") + + wikis_root = data_dir / "wikis" + candidates_dir = data_dir / "candidates" + if not wikis_root.is_dir(): + raise RuntimeError(f"--data-dir/wikis not found: {wikis_root}") + + env = _claude_env() + sys.stderr.write(f"==> Scan-from-ticket: {ticket_abs.parent.name}\n") + + base_args = [sys.executable, str(scan_script), + f"--wikis-root={wikis_root}", + f"--output-dir={candidates_dir}", + f"--ticket-file={ticket_abs}"] + canned_path = ticket_abs.parent / "canned_response.txt" + used_canned = False + try: + proc = run(base_args, env=env) + except subprocess.CalledProcessError as live_err: + if canned_path.is_file(): + sys.stderr.write( + f"==> Scan-from-ticket: live claude -p failed, falling back to " + f"{canned_path.name} (demo mode)\n" + ) + proc = run(base_args + [f"--response-file={canned_path}"], env=env) + used_canned = True + else: + raise live_err + + new_slugs = [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + if not new_slugs: + raise RuntimeError("scan_candidates.py wrote no new candidate (empty stdout)") + new_slug = new_slugs[-1] + + # Score the new candidate (and any existing ones) by gap-bridging value. + _maybe_score_candidates(scripts_dir, wikis_root, candidates_dir, env) + + sys.stderr.write(f"==> Scan-from-ticket: rebuilding site at {site_dir}\n") + run([sys.executable, str(build_script), + f"--input-dir={data_dir}", + f"--output-dir={site_dir}", + f"--repo-name={repo_name}"], env=env) + + return { + "candidate_slug": new_slug, + "candidate_url": f"/candidates/{new_slug}/candidate.html", + "used_canned": used_canned, + } + + +def find_wiki_root_for_customer(data_dir: Path, customer: str) -> Path | None: + """Walk wikis// to find the directory that contains source_manifest.json. + + Mirrors find_wiki_root in build_html_site.py — handles both flat layouts + (wikis//source_manifest.json) and nested ones + (wikis///source_manifest.json) without hardcoding depth. + """ + customer_root = data_dir / "wikis" / customer + if not customer_root.is_dir(): + return None + # BFS for the deepest source_manifest.json. + stack = [customer_root] + while stack: + cur = stack.pop() + if (cur / "source_manifest.json").is_file(): + return cur + for child in cur.iterdir(): + if child.is_dir(): + stack.append(child) + return None + + +def find_ccb_scripts() -> Path: + """Resolve customer-context-builder/scripts/ relative to this skill dir. + + The two skills live as siblings under skills/, so we walk up from + skills/wiki-viewer/scripts/ to skills/ and then back down. This also + works when wiki-viewer is symlinked into ~/.claude/skills/ — Path + resolution follows the symlink to the real location. + """ + here = Path(__file__).resolve().parent + skills_root = here.parent.parent # skills/ dir + candidate = skills_root / "customer-context-builder" / "scripts" + if candidate.is_dir(): + return candidate + # Fallback: relative to repo root if running uninstalled. + repo_candidate = Path.cwd() / "skills" / "customer-context-builder" / "scripts" + if repo_candidate.is_dir(): + return repo_candidate + raise RuntimeError( + "couldn't locate customer-context-builder/scripts/; " + f"tried {candidate} and {repo_candidate}" + ) + + +def stage_drift_artifacts(data_dir: Path) -> int: + """Copy each customer wiki's DRIFT.md + DRIFT.json into /drift//. + + Returns the number of customers staged. The Drift tab in the viewer reads + from this staging dir (matches the section-per-top-level-dir pattern). + """ + import shutil + drift_root = data_dir / "drift" + drift_root.mkdir(exist_ok=True) + wikis_root = data_dir / "wikis" + if not wikis_root.is_dir(): + return 0 + n = 0 + for customer_dir in sorted(wikis_root.iterdir()): + if not customer_dir.is_dir(): + continue + wiki_root = find_wiki_root_for_customer(data_dir, customer_dir.name) + if wiki_root is None or not (wiki_root / "DRIFT.md").is_file(): + continue + target = drift_root / customer_dir.name + target.mkdir(exist_ok=True) + shutil.copy2(wiki_root / "DRIFT.md", target / "DRIFT.md") + if (wiki_root / "DRIFT.json").is_file(): + shutil.copy2(wiki_root / "DRIFT.json", target / "DRIFT.json") + n += 1 + return n + + +def run_rescan_drift(data_dir: Path, site_dir: Path, repo_name: str, + customer: str) -> dict: + """Re-run claims_sidecar + build_manifest + dep_graph + source_diff for one + customer, then re-stage DRIFT.md into drift// and rebuild the site. + + We re-run the full upstream pipeline because source_diff's severity rules + depend on claims_index.json (which sources are cited as EXTRACTED, etc.) — + a fresh sidecar pass keeps drift severity in sync with current narrative state. + """ + wiki_root = find_wiki_root_for_customer(data_dir, customer) + if wiki_root is None: + raise RuntimeError(f"no wiki root found for customer: {customer}") + ccb = find_ccb_scripts() + + sys.stderr.write(f"==> Re-scan drift: {wiki_root}\n") + # NOTE: we do NOT regenerate source_manifest.json here — that would erase + # the baseline source_diff is comparing against. Only run claims/dep_graph + # and then source_diff against the existing manifest. + run([sys.executable, str(ccb / "claims_sidecar.py"), + f"--wiki-root={wiki_root}", "--quiet"], check=False) + run([sys.executable, str(ccb / "dep_graph.py"), + f"--wiki-root={wiki_root}", "--quiet"]) + proc = subprocess.run( + [sys.executable, str(ccb / "source_diff.py"), + f"--wiki-root={wiki_root}", "--quiet"], + capture_output=True, text=True, + ) + # source_diff.py exits 1 when HIGH-severity drift exists — that's success + # for our purposes, just means there ARE drifts. Treat anything > 1 as a + # real failure. + if proc.returncode > 1: + raise RuntimeError( + f"source_diff failed (exit {proc.returncode}): " + f"{proc.stderr[:500]}" + ) + # Stage 2 re-validation: substring + anchor checks per claim. Tolerant of + # failure since the cheap stack runs without GCP / LLM access. + revalidate_script = ccb / "revalidate_drift.py" + if revalidate_script.is_file(): + proc = subprocess.run( + [sys.executable, str(revalidate_script), + f"--wiki-root={wiki_root}", "--quiet"], + capture_output=True, text=True, + ) + if proc.returncode != 0: + sys.stderr.write( + f"warning: revalidate_drift failed (continuing): " + f"{best_error_line(proc.stderr, proc.stdout)}\n" + ) + + n_staged = stage_drift_artifacts(data_dir) + + # Rebuild the HTML so the new DRIFT.md shows up in the tab. + scripts_dir = Path(__file__).resolve().parent + build_script = scripts_dir / "build_html_site.py" + sys.stderr.write(f"==> Re-scan drift: rebuilding site at {site_dir}\n") + run([sys.executable, str(build_script), + f"--input-dir={data_dir}", + f"--output-dir={site_dir}", + f"--repo-name={repo_name}"]) + + # Pull a brief summary from the regenerated DRIFT.json so the toast + # is informative. + summary = "drift refreshed" + drift_json = wiki_root / "DRIFT.json" + if drift_json.is_file(): + try: + d = json.loads(drift_json.read_text(encoding="utf-8")) + kinds = d.get("by_kind", {}) + sevs = d.get("by_severity", {}) + summary = ( + f"{kinds.get('changed', 0)} changed · " + f"{kinds.get('deleted', 0)} deleted · " + f"{kinds.get('new', 0)} new " + f"({sevs.get('high', 0)}H {sevs.get('medium', 0)}M " + f"{sevs.get('low', 0)}L)" + ) + except Exception: + pass + + return {"summary": summary, "customers_staged": n_staged} + + +def run_acknowledge_drift(data_dir: Path, customer: str, drift_id: str) -> dict: + """Append drift_id to the customer's .drift-acknowledged.json. Does NOT + rebuild the site — the entry stays visible in the current view (with a + visual line-through marker) until the next Re-scan.""" + wiki_root = find_wiki_root_for_customer(data_dir, customer) + if wiki_root is None: + raise RuntimeError(f"no wiki root found for customer: {customer}") + ccb = find_ccb_scripts() + + sys.stderr.write(f"==> Ack drift {drift_id} for {customer}\n") + proc = subprocess.run( + [sys.executable, str(ccb / "acknowledge_drift.py"), + f"--wiki-root={wiki_root}", f"--drift-id={drift_id}", "--quiet"], + capture_output=True, text=True, + ) + if proc.returncode != 0: + raise RuntimeError( + f"acknowledge_drift.py failed (exit {proc.returncode}): " + f"{proc.stderr[:500]}" + ) + return {"acknowledged_id": drift_id, "customer": customer} + + +def run_rescan(data_dir: Path, site_dir: Path, repo_name: str) -> int: + """Run scan_candidates.py + rebuild the HTML site. Returns candidate count.""" + scripts_dir = Path(__file__).resolve().parent + scan_script = scripts_dir / "scan_candidates.py" + build_script = scripts_dir / "build_html_site.py" + + wikis_root = data_dir / "wikis" + candidates_dir = data_dir / "candidates" + if not wikis_root.is_dir(): + raise RuntimeError(f"--data-dir/wikis not found: {wikis_root}") + + env = _claude_env() + sys.stderr.write(f"==> Rescan: scanning {wikis_root}\n") + run([sys.executable, str(scan_script), + f"--wikis-root={wikis_root}", + f"--output-dir={candidates_dir}"], env=env) + + # Score every candidate by gap-bridging value before rebuilding the site. + _maybe_score_candidates(scripts_dir, wikis_root, candidates_dir, env) + + sys.stderr.write(f"==> Rescan: rebuilding site at {site_dir}\n") + run([sys.executable, str(build_script), + f"--input-dir={data_dir}", + f"--output-dir={site_dir}", + f"--repo-name={repo_name}"], env=env) + + # Count candidates by counting subdirs of candidates_dir. + if not candidates_dir.is_dir(): + return 0 + return sum(1 for c in candidates_dir.iterdir() if c.is_dir()) + + +def make_handler(site_dir: Path, + repo_slug: str | None, + checkout_dir: Path, + data_dir: Path | None, + repo_name: str): + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(site_dir), **kwargs) + + def log_message(self, fmt, *args): + sys.stderr.write("[%s] %s\n" % (self.log_date_time_string(), fmt % args)) + + def _send_json(self, code: int, obj: dict) -> None: + data = json.dumps(obj).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _handle_promote(self): + if not repo_slug: + self._send_json(503, {"error": "promote disabled: server started without --proposals-repo"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + try: + ensure_checkout(repo_slug, checkout_dir) + pr_url = make_proposal(payload, checkout_dir, repo_slug) + self._send_json(200, {"pr_url": pr_url}) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"promote failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"promote failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_rescan(self): + if not data_dir: + self._send_json(503, {"error": "rescan disabled: server started without --data-dir"}) + return + try: + count = run_rescan(data_dir, site_dir, repo_name) + self._send_json(200, {"candidate_count": count}) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"rescan failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"rescan failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_scan_from_ticket(self): + if not data_dir: + self._send_json(503, {"error": "scan-from-ticket disabled: server started without --data-dir"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + ticket_path = (payload.get("ticket_path") or "").strip() + if not ticket_path: + self._send_json(400, {"error": "missing ticket_path"}) + return + try: + result = run_scan_from_ticket(data_dir, site_dir, repo_name, ticket_path) + self._send_json(200, result) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"scan-from-ticket failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"scan-from-ticket failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_create_skill(self): + if not data_dir: + self._send_json(503, {"error": "create-skill disabled: server started without --data-dir"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + candidate_path = (payload.get("candidate_path") or "").strip() + if not candidate_path: + self._send_json(400, {"error": "missing candidate_path"}) + return + try: + result = run_create_skill(data_dir, site_dir, repo_name, candidate_path) + self._send_json(200, result) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"create-skill failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"create-skill failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_promote_skill(self): + if not data_dir: + self._send_json(503, {"error": "promote-skill disabled: server started without --data-dir"}) + return + if not repo_slug: + self._send_json(503, {"error": "promote-skill disabled: set PROPOSALS_REPO and restart try.sh " + "(or pass --proposals-repo to promote_server.py)"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + skill_path = (payload.get("skill_path") or "").strip() + if not skill_path: + self._send_json(400, {"error": "missing skill_path"}) + return + # Validate skill_path is under data-dir/skills/. + skill_abs = (data_dir / skill_path).resolve() + skills_root = (data_dir / "skills").resolve() + if not str(skill_abs).startswith(str(skills_root) + os.sep) or not skill_abs.is_dir(): + self._send_json(400, {"error": f"skill_path must be a directory under skills/: {skill_path}"}) + return + try: + pr_url = promote_skill(skill_abs, data_dir, checkout_dir, repo_slug) + self._send_json(200, {"pr_url": pr_url}) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"promote-skill failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"promote-skill failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_rescan_drift(self): + if not data_dir: + self._send_json(503, {"error": "rescan-drift disabled: server started without --data-dir"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + customer = (payload.get("customer") or "").strip() + if not customer: + self._send_json(400, {"error": "missing customer"}) + return + try: + result = run_rescan_drift(data_dir, site_dir, repo_name, customer) + self._send_json(200, result) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"rescan-drift failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"rescan-drift failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def _handle_acknowledge_drift(self): + if not data_dir: + self._send_json(503, {"error": "acknowledge-drift disabled: server started without --data-dir"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8") + payload = json.loads(raw) + except Exception as e: + self._send_json(400, {"error": f"bad request: {e}"}) + return + customer = (payload.get("customer") or "").strip() + drift_id = (payload.get("drift_id") or "").strip() + if not customer or not drift_id: + self._send_json(400, {"error": "missing customer or drift_id"}) + return + try: + result = run_acknowledge_drift(data_dir, customer, drift_id) + self._send_json(200, result) + except subprocess.CalledProcessError as e: + msg = best_error_line(e.stderr, e.stdout) + sys.stderr.write(f"acknowledge-drift failed: {e.cmd}\nstdout: {e.stdout}\nstderr: {e.stderr}\n") + self._send_json(500, {"error": msg}) + except Exception as e: + sys.stderr.write(f"acknowledge-drift failed: {e}\n") + self._send_json(500, {"error": str(e)}) + + def do_POST(self): + if self.path == "/api/promote": + self._handle_promote() + elif self.path == "/api/rescan": + self._handle_rescan() + elif self.path == "/api/scan-from-ticket": + self._handle_scan_from_ticket() + elif self.path == "/api/create-skill": + self._handle_create_skill() + elif self.path == "/api/promote-skill": + self._handle_promote_skill() + elif self.path == "/api/rescan-drift": + self._handle_rescan_drift() + elif self.path == "/api/acknowledge-drift": + self._handle_acknowledge_drift() + else: + self.send_error(404, "not found") + + return Handler + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--site-dir", required=True) + ap.add_argument("--port", type=int, default=8765) + ap.add_argument( + "--bind", default="127.0.0.1", + help="Address to bind. Default 127.0.0.1 (loopback only). " + "Use 0.0.0.0 to accept connections from your LAN, your " + "tailnet (Tailscale), or any non-loopback interface. " + "Be aware: 0.0.0.0 exposes the server to anyone on those " + "networks — fine for demo/dev with fake data, think twice " + "if the wiki carries real customer context.", + ) + ap.add_argument("--proposals-repo", default=None, + help="GitHub slug for /api/promote, e.g. oscarkang24/wiki-proposals. " + "If omitted, /api/promote returns 503.") + ap.add_argument("--proposals-checkout", default=str(Path.home() / ".cache" / "wiki-proposals")) + ap.add_argument("--data-dir", default=None, + help="Context-center data dir (parent of wikis/, candidates/) for /api/rescan. " + "If omitted, /api/rescan returns 503.") + ap.add_argument("--repo-name", default="customer-context wiki", + help="Pass-through to build_html_site.py during rescan rebuilds.") + args = ap.parse_args() + + site_dir = Path(args.site_dir).resolve() + checkout_dir = Path(args.proposals_checkout).expanduser().resolve() + data_dir = Path(args.data_dir).resolve() if args.data_dir else None + + if not site_dir.is_dir(): + sys.exit(f"--site-dir does not exist: {site_dir}") + if data_dir is not None and not data_dir.is_dir(): + sys.exit(f"--data-dir does not exist: {data_dir}") + + handler = make_handler(site_dir, args.proposals_repo, checkout_dir, data_dir, args.repo_name) + + # Set SO_REUSEADDR so a rapid restart after Ctrl-C doesn't fail to bind on + # the lingering TIME_WAIT socket from the previous run. + class _Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + with _Server((args.bind, args.port), handler) as httpd: + # When binding to 0.0.0.0, log both the loopback URL (always works + # locally) and a hint about the broader binding so users know they + # can reach it from other devices. + url = f"http://127.0.0.1:{args.port}/index.html" + print(f"serving {site_dir} at {url}", file=sys.stderr) + if args.bind != "127.0.0.1": + print( + f" bind={args.bind} — also reachable from other devices on " + f"the same network at http://:{args.port}/", + file=sys.stderr, + ) + if args.proposals_repo: + print(f"promote target: {args.proposals_repo} (checkout: {checkout_dir})", file=sys.stderr) + else: + print("promote: disabled (no --proposals-repo)", file=sys.stderr) + if data_dir: + print(f"rescan source: {data_dir}", file=sys.stderr) + else: + print("rescan: disabled (no --data-dir)", file=sys.stderr) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nstopped.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-wiki-viewer/scripts/scan_candidates.py b/skills/ccb-wiki-viewer/scripts/scan_candidates.py new file mode 100644 index 00000000..09f44a16 --- /dev/null +++ b/skills/ccb-wiki-viewer/scripts/scan_candidates.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""Scan one or more customer wiki dirs for reusable-skill candidates. + +Walks the wiki tree, sends gist files to Claude, asks it to identify +clusters of similar workflows that could be generalized into a reusable +skill, and writes one stub per cluster into the output dir. + +The "rescan" button in the context-center viewer shells out to this script. + +Usage: + python3 scan_candidates.py \\ + --wikis-root=examples/sample_context_center/wikis \\ + --output-dir=examples/sample_context_center/candidates + +LLM call: shells out to `claude -p ""`. No SDK install, no API key +handling — relies on the user's existing Claude Code auth. + +Output layout (under --output-dir): + index.md # candidates landing page + / + candidate.md # name, description, rationale, draft SKILL.md + sources.json # which gist files fed this cluster +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +# Files we don't want to feed to the clustering prompt: meta files (index, +# critique), giant narrative docs, and personal/team context that is +# customer-specific by definition. The cluster signal lives in the +# table-level `sources/` gists (bq_query_patterns.md, dataplex_*.md, etc.). +SKIP_NAMES = {"index.md", "CRITIQUE.md"} +SKIP_DIR_NAMES = {"personal_context"} + + +def collect_gist_files(wikis_root: Path) -> list[Path]: + out: list[Path] = [] + for p in sorted(wikis_root.rglob("*.md")): + if p.name in SKIP_NAMES: + continue + if any(part in SKIP_DIR_NAMES for part in p.relative_to(wikis_root).parts): + continue + out.append(p) + return out + + +def build_prompt(wikis_root: Path, files: list[Path], max_chars_per_file: int) -> str: + """Build the clustering prompt. Each file is included with its relative + path as a header so the LLM can cite evidence by path.""" + parts = [ + "You are analyzing markdown gist files extracted from one or more", + "customer-context wikis. Your job: identify clusters of similar", + "workflows or query patterns that recur across the wiki(s) and could", + "be generalized into a reusable Claude Code skill.", + "", + "A good candidate is:", + "- A workflow that appears 2+ times across different tables, datasets,", + " or customers (the more, the higher the confidence).", + "- General enough that it would be useful for a future customer too,", + " not specific to one table or one company.", + "- Concrete enough that you can describe what the skill would *do*,", + " not just what topic it covers.", + "", + "Examples of good candidates: \"audit which dashboards still hit a", + "deprecated table\", \"summarize per-table query patterns from", + "INFORMATION_SCHEMA.JOBS_BY_PROJECT\", \"detect partition-filter", + "regressions from slot-usage spikes\".", + "", + "Bad candidates: anything that just describes the customer's", + "situation (\"Acme has 5 tables\"), pure facts about specific tables,", + "or topics too vague to action (\"data governance\").", + "", + "Output strict JSON only, wrapped in a ```json code fence. Schema:", + "", + "```json", + "{", + ' "candidates": [', + " {", + ' "id": "kebab-case-id",', + ' "name": "Short Title Case Name",', + ' "description": "1-2 sentences: what the skill does",', + ' "rationale": "why this generalizes; what evidence supports it",', + ' "confidence": "high" | "medium" | "low",', + ' "evidence": ["relative/path/to/gist.md", ...],', + ' "skill_md_draft": "# Name\\n\\nMarkdown skill stub: when to trigger, inputs, outputs, key steps. Keep under 300 words."', + " }", + " ]", + "}", + "```", + "", + "Aim for 2-5 candidates. Skip clusters of 1 file. Use the relative", + f"paths shown below (anchored at `{wikis_root.name}/`) as evidence values.", + "", + "---", + "", + "## Wiki gist files", + "", + ] + + for p in files: + rel = p.relative_to(wikis_root.parent).as_posix() + text = p.read_text(encoding="utf-8") + if len(text) > max_chars_per_file: + text = text[:max_chars_per_file] + f"\n\n_(truncated at {max_chars_per_file} chars)_" + parts.append(f"### `{rel}`") + parts.append("") + parts.append(text) + parts.append("") + + return "\n".join(parts) + + +def build_ticket_prompt(wikis_root: Path, + gist_files: list[Path], + ticket_path: Path, + max_chars_per_file: int) -> str: + """Build a prompt that asks for ONE candidate skill: a culprit-finding + workflow synthesized from a single support ticket plus the customer's + data context (wiki gists).""" + parts = [ + "You are turning a customer support ticket into a reusable", + "culprit-finding skill — a parameterized debugging workflow that", + "walks the customer's data model to diagnose this *type* of complaint.", + "", + "The customer's data context is the wiki gist files below. The", + "support ticket is also below. Synthesize ONE candidate skill stub.", + "", + "The skill should:", + "- Be parameterized by the ID/field types the ticket carries", + " (e.g. client_id, transaction_id, bug_id, dashboard_id) — these", + " become inputs the user supplies on each invocation.", + "- Walk specific tables/sources from the wiki to diagnose; cite the", + " table or source by name in the workflow steps.", + "- Be reusable for the *next* customer with a similar-shape", + " complaint, not just this one specific case.", + "", + "Output strict JSON only, wrapped in a ```json code fence, with", + "EXACTLY ONE entry in the candidates array. Schema:", + "", + "```json", + "{", + ' "candidates": [', + " {", + ' "id": "kebab-case-id",', + ' "name": "Short Title Case Name",', + ' "description": "1-2 sentences: what the skill does",', + ' "rationale": "what about this ticket and the wiki tells you this generalizes",', + ' "confidence": "high" | "medium" | "low",', + ' "evidence": ["tickets//ticket.md", "wikis//", ...],', + ' "skill_md_draft": "# Name\\n\\nMarkdown skill stub: When to trigger, Inputs (with types), Step-by-step lookup workflow citing tables, Outputs. Keep under 400 words."', + " }", + " ]", + "}", + "```", + "", + "---", + "", + "## Ticket", + "", + ] + ticket_text = ticket_path.read_text(encoding="utf-8") + if len(ticket_text) > max_chars_per_file * 2: + ticket_text = ticket_text[: max_chars_per_file * 2] + "\n\n_(truncated)_" + parts.append(f"### `{ticket_path.relative_to(ticket_path.parents[2]).as_posix()}`") + parts.append("") + parts.append(ticket_text) + parts.append("") + parts.append("## Wiki gists (data model context)") + parts.append("") + for p in gist_files: + rel = p.relative_to(wikis_root.parent).as_posix() + text = p.read_text(encoding="utf-8") + if len(text) > max_chars_per_file: + text = text[:max_chars_per_file] + f"\n\n_(truncated at {max_chars_per_file} chars)_" + parts.append(f"### `{rel}`") + parts.append("") + parts.append(text) + parts.append("") + return "\n".join(parts) + + +def call_claude(prompt: str, model: str | None) -> str: + """Shell out to `claude -p`. Returns the raw stdout text. + + When the parent is itself a Claude Code session, strip CLAUDECODE plus + the host-managed auth env vars so the nested `claude` falls back to the + user's stored OAuth credentials instead of failing on a session-scoped + API key. When the parent is a normal shell, leave the env alone — the + user might be using ANTHROPIC_API_KEY for auth on purpose. + """ + cmd = ["claude", "-p"] + if model: + cmd += ["--model", model] + cmd.append(prompt) + + env = dict(os.environ) + if env.get("CLAUDECODE"): + for k in list(env.keys()): + if ( + k in ("CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL") + or k.startswith("CLAUDE_CODE_") + or k.startswith("CLAUDE_AGENT_") + ): + env.pop(k, None) + + proc = subprocess.run(cmd, capture_output=True, text=True, env=env) + if proc.returncode != 0: + raise RuntimeError( + f"claude -p failed (exit {proc.returncode}):\n" + f"stdout: {proc.stdout[:500]}\nstderr: {proc.stderr[:500]}" + ) + return proc.stdout + + +_FENCE_RE = re.compile(r"```(?:json)?\s*\n(.*?)\n```", re.DOTALL) + + +def extract_json(text: str) -> dict: + """Pull the first ```json ... ``` fence out of the response. Falls back to + parsing the whole response as JSON if no fence is present.""" + m = _FENCE_RE.search(text) + blob = m.group(1) if m else text.strip() + try: + return json.loads(blob) + except json.JSONDecodeError as e: + raise RuntimeError( + f"could not parse JSON from claude response: {e}\n--- raw ---\n{text[:1000]}" + ) + + +def slugify(s: str, max_len: int = 60) -> str: + out = re.sub(r"[^a-zA-Z0-9]+", "-", s.lower()).strip("-") + return (out[:max_len] or "candidate").rstrip("-") + + +def _write_one_candidate(c: dict, output_dir: Path) -> str: + """Write a single candidate's subdir + sources.json. Returns the slug.""" + cid = slugify(str(c.get("id") or c.get("name") or "candidate")) + target = output_dir / cid + suffix = 2 + while target.exists(): + target = output_dir / f"{cid}-{suffix}" + suffix += 1 + target.mkdir() + cid = target.name + + confidence = str(c.get("confidence", "?")).lower() + name = c.get("name", cid) + description = c.get("description", "") + rationale = c.get("rationale", "") + evidence = c.get("evidence") or [] + origin = c.get("origin") or "scan" # "scan" (cluster rescan) or "ticket" + skill_md = c.get("skill_md_draft") or "_(no draft)_" + + body = [ + f"# {name}", + "", + f"**Confidence:** {confidence} · **Origin:** {origin}", + "", + "## Description", + "", + description, + "", + "## Why this generalizes", + "", + rationale, + "", + "## Evidence", + "", + ] + if evidence: + for path in evidence: + body.append(f"- `{path}`") + else: + body.append("_(none)_") + body += ["", "## Draft skill", "", skill_md, ""] + (target / "candidate.md").write_text("\n".join(body), encoding="utf-8") + (target / "sources.json").write_text( + json.dumps( + { + "id": cid, + "name": name, + "confidence": confidence, + "origin": origin, + "description": description, + "evidence": evidence, + }, + indent=2, + ), + encoding="utf-8", + ) + return cid + + +def _regenerate_index(output_dir: Path) -> None: + """Rewrite output_dir/index.md from the sources.json files in each + subdirectory. Sorts by confidence then name.""" + metas = [] + for sub in output_dir.iterdir(): + if not sub.is_dir(): + continue + sj = sub / "sources.json" + if not sj.exists(): + continue + try: + metas.append((sub.name, json.loads(sj.read_text(encoding="utf-8")))) + except json.JSONDecodeError: + continue + + if not metas: + (output_dir / "index.md").write_text( + "# Candidates\n\n_No reusable patterns yet. Click Rescan, or generate " + "one from a ticket._\n", + encoding="utf-8", + ) + return + + rank = {"high": 0, "medium": 1, "low": 2} + # Sort: bridge_score desc (when present, populated by score_candidates.py), + # then confidence asc, then name. A high-bridge candidate is one that + # would close known gaps in the wiki — the most actionable thing the + # user can promote next. + has_scores = any("bridge_score" in m for _, m in metas) + # bridge_score is a depth-weighted float (severity × coverage_fraction + # summed across gaps). Negate for descending sort; falls back to 0 for + # candidates that haven't been scored yet. + metas.sort(key=lambda nm: ( + -float(nm[1].get("bridge_score") or 0.0), + rank.get(str(nm[1].get("confidence")).lower(), 3), + nm[1].get("name", ""), + )) + + lines = [ + "# Candidates", + "", + f"_{len(metas)} candidate skill(s). Last update: " + f"{dt.datetime.now().isoformat(timespec='seconds')}_", + "", + ] + if has_scores: + lines.append( + "_Sorted by **bridge_score** (gap-closing value) — high-bridge " + "candidates would close known gaps in the wiki when promoted to " + "real skills. See `score_candidates.py` for the scoring rule._" + ) + lines.append("") + lines.append("| Bridge | Gaps | Confidence | Origin | Name | Description |") + lines.append("|---:|---:|---|---|---|---|") + else: + lines.append("| Confidence | Origin | Name | Description |") + lines.append("|---|---|---|---|") + for cid, m in metas: + confidence = str(m.get("confidence", "?")).lower() + origin = str(m.get("origin", "scan")) + name = m.get("name", cid) + description = (m.get("description") or "").replace("|", "\\|").replace("\n", " ") + if has_scores: + score = float(m.get("bridge_score") or 0.0) + addressed = m.get("gaps_addressed") or [] + # gaps_addressed schema is depth-weighted now: list of + # {gap_id, severity, coverage}. Old shape was list[str] of bare + # IDs — accept either so historical sources.json files still render. + n_gaps = len(addressed) + n_full = sum( + 1 for a in addressed + if isinstance(a, dict) and a.get("coverage", 0) >= 0.99 + ) + gaps_cell = f"{n_gaps}" + (f" ({n_full} full)" if n_full else "") + score_cell = f"**{score:.1f}**" if score > 0 else "0.0" + lines.append( + f"| {score_cell} | {gaps_cell} | {confidence} | {origin} | " + f"[{name}]({cid}/candidate.md) | {description} |" + ) + else: + lines.append(f"| {confidence} | {origin} | [{name}]({cid}/candidate.md) | {description} |") + (output_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_candidates(result: dict, output_dir: Path, append: bool = False, + default_origin: str = "scan") -> list[str]: + """Write candidates to output_dir. + + - append=False: wipe output_dir, then write all candidates in `result`. + - append=True: keep existing subdirs, add new candidates alongside, then + regenerate the index. + + Returns the list of newly-written candidate slugs (in result order). + """ + if not append and output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + candidates = result.get("candidates") or [] + written = [] + for c in candidates: + c.setdefault("origin", default_origin) + written.append(_write_one_candidate(c, output_dir)) + + _regenerate_index(output_dir) + return written + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--wikis-root", required=True, + help="Dir containing one or more customer wiki subdirs") + ap.add_argument("--output-dir", required=True, + help="Where to write the candidates/ subtree (overwritten " + "in cluster mode; appended in --ticket-file mode)") + ap.add_argument("--ticket-file", default=None, + help="If set: synthesize ONE candidate from this ticket file " + "+ wiki gists, append (don't wipe) the output dir, and " + "print the new candidate's slug to stdout.") + ap.add_argument("--model", default=None, help="Pass to `claude --model`") + ap.add_argument("--max-chars-per-file", type=int, default=4000, + help="Truncate each gist file at this many chars before prompting") + ap.add_argument("--response-file", default=None, + help="Skip the LLM call; parse this file as the response instead " + "(for debugging the parser/writer in isolation)") + args = ap.parse_args() + + wikis_root = Path(args.wikis_root).resolve() + output_dir = Path(args.output_dir).resolve() + + if not wikis_root.is_dir(): + sys.exit(f"--wikis-root does not exist: {wikis_root}") + + files = collect_gist_files(wikis_root) + if not files: + sys.exit(f"no gist .md files found under {wikis_root}") + + if args.ticket_file: + ticket_path = Path(args.ticket_file).resolve() + if not ticket_path.is_file(): + sys.exit(f"--ticket-file does not exist: {ticket_path}") + print(f"==> Synthesizing candidate from ticket {ticket_path.name} + " + f"{len(files)} wiki gists", file=sys.stderr) + prompt = build_ticket_prompt(wikis_root, files, ticket_path, args.max_chars_per_file) + default_origin = "ticket" + append = True + else: + print(f"==> Scanning {len(files)} gist files under {wikis_root}", file=sys.stderr) + prompt = build_prompt(wikis_root, files, args.max_chars_per_file) + default_origin = "scan" + append = False + + print(f"==> Prompt size: {len(prompt):,} chars", file=sys.stderr) + if args.response_file: + print(f"==> Reading canned response from {args.response_file}", file=sys.stderr) + raw = Path(args.response_file).read_text(encoding="utf-8") + else: + print("==> Calling claude -p (this may take 10-60s)", file=sys.stderr) + raw = call_claude(prompt, args.model) + result = extract_json(raw) + + new_slugs = write_candidates(result, output_dir, append=append, default_origin=default_origin) + print(f"==> Wrote {len(new_slugs)} candidate(s) to {output_dir}", file=sys.stderr) + # Print new slugs (one per line) on stdout so the caller can locate them. + for s in new_slugs: + print(s) + + +if __name__ == "__main__": + main() diff --git a/skills/ccb-wiki-viewer/scripts/score_candidates.py b/skills/ccb-wiki-viewer/scripts/score_candidates.py new file mode 100644 index 00000000..a964c013 --- /dev/null +++ b/skills/ccb-wiki-viewer/scripts/score_candidates.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Score each candidate by how many wiki gaps it would close. + +Reads each candidate's sources.json (the evidence list — gist files the +candidate clusters over) and each customer wiki's GAPS.json. A candidate +"bridges" a gap when its evidence overlaps with the gap's source files. + +Scoring formula (depth-weighted): + + coverage_fraction = |candidate_evidence ∩ gap_sources| / |gap_sources| + gap_contribution = severity_weight(gap) × coverage_fraction + bridge_score = sum of gap_contribution over all gaps + +Severity weights: high = 3, medium = 2, low = 1 + +So a candidate that fully covers one HIGH gap contributes 3.0; one that +barely touches the same HIGH (1 of 5 sources) contributes 0.6. This +means a high-confidence candidate that deeply addresses 1 HIGH gap +correctly outranks a noisy one that grazes 5 LOWs. + +Writes back to each sources.json: + bridge_score: float # sum of (severity × coverage_fraction) + gaps_addressed: list[dict] # [{gap_id, severity, coverage}, ...] + +Then re-runs `scan_candidates._regenerate_index` so the Candidates tab's +landing page shows the new column and sorts high-bridge candidates first. + +This script is mechanical — no LLM. Designed to run after every +scan_candidates rescan or ticket-to-candidate generation. + +Usage: + python3 score_candidates.py \\ + --wikis-root=examples/sample_context_center/wikis \\ + --candidates-dir=examples/sample_context_center/candidates +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Import from sibling scan_candidates.py to reuse the index regenerator. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from scan_candidates import _regenerate_index # noqa: E402 + +SEVERITY_WEIGHT = {"high": 3, "medium": 2, "low": 1} + + +def find_wiki_gaps(wikis_root: Path) -> list[tuple[str, list[dict]]]: + """For every customer wiki under wikis_root, find its GAPS.json (if any) + and return (wiki_relpath, gaps). + + wiki_relpath is the wiki root's path relative to wikis_root, e.g. + `acme/context-repo-building`. We need this to translate per-wiki gap + source paths (like `personal_context/sources/foo.md`) into the + wikis-root-relative form that candidate evidence uses. + """ + out: list[tuple[str, list[dict]]] = [] + for gaps_path in sorted(wikis_root.rglob("GAPS.json")): + wiki_root = gaps_path.parent + try: + data = json.loads(gaps_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + rel = wiki_root.resolve().relative_to(wikis_root.resolve()).as_posix() + out.append((rel, data.get("gaps", []))) + return out + + +def score_candidate( + candidate_evidence: list[str], + wikis_with_gaps: list[tuple[str, list[dict]]], +) -> tuple[float, list[dict]]: + """Return (bridge_score, [{gap_id, severity, coverage}, ...]). + + Score is depth-weighted: a candidate gets credit proportional to how + much of each gap it actually covers, not just whether it touches the + gap at all. + """ + score = 0.0 + addressed: list[dict] = [] + ev_set = set(candidate_evidence or []) + for wiki_rel, gaps in wikis_with_gaps: + for g in gaps: + gap_sources = { + f"{wiki_rel}/{s.split('#', 1)[0]}" + for s in g.get("sources", []) + } + if not gap_sources: + continue + overlap = ev_set & gap_sources + if not overlap: + continue + coverage = len(overlap) / len(gap_sources) + severity = g.get("severity", "low") + contribution = SEVERITY_WEIGHT.get(severity, 0) * coverage + score += contribution + addressed.append({ + "gap_id": g["id"], + "severity": severity, + "coverage": round(coverage, 2), + }) + return round(score, 2), addressed + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--wikis-root", required=True) + ap.add_argument("--candidates-dir", required=True) + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + wikis_root = Path(args.wikis_root).resolve() + if not wikis_root.is_dir(): + sys.exit(f"--wikis-root not a directory: {wikis_root}") + candidates_dir = Path(args.candidates_dir).resolve() + if not candidates_dir.is_dir(): + sys.exit(f"--candidates-dir not a directory: {candidates_dir}") + + wikis_with_gaps = find_wiki_gaps(wikis_root) + + n_scored = 0 + high_bridge = 0 + for sub in sorted(candidates_dir.iterdir()): + if not sub.is_dir(): + continue + sj = sub / "sources.json" + if not sj.is_file(): + continue + try: + meta = json.loads(sj.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + evidence = meta.get("evidence") or [] + score, addressed = score_candidate(evidence, wikis_with_gaps) + meta["bridge_score"] = score + meta["gaps_addressed"] = addressed + sj.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") + n_scored += 1 + if score >= 3.0: + high_bridge += 1 + + # Re-render index.md so the new column is visible. + _regenerate_index(candidates_dir) + + if not args.quiet: + print( + f"scored {n_scored} candidate(s); {high_bridge} have bridge_score >= 3", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/ccb-wiki-viewer/scripts/serve_wiki.sh b/skills/ccb-wiki-viewer/scripts/serve_wiki.sh new file mode 100755 index 00000000..057fd90d --- /dev/null +++ b/skills/ccb-wiki-viewer/scripts/serve_wiki.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# serve_wiki.sh — build an HTML viewer for a customer-context wiki and serve +# it on a local port. The parameterized form of the repo's try.sh demo. +# +# Usage: +# bash serve_wiki.sh [--wiki-dir=PATH] [--site-dir=PATH] [--port=N] [--no-open] \ +# [--data-dir=PATH] \ +# [--bootstrap-tabs] [--customer-name=NAME] [--wiki-name=NAME] \ +# [--proposals-repo=OWNER/REPO] [--proposals-checkout=PATH] +# +# Defaults: +# --wiki-dir auto-detect: first customer under ./customer-context/wikis/, +# then ./customer-context/context/ (legacy), then ./examples/sample_output/ +# --site-dir /../site +# --port 8765 +# --data-dir auto-detect context-center root above ; empty otherwise +# --bootstrap-tabs off — when on, synthesize a context-center root with empty +# tickets/candidates/skills/drift dirs so all 5 tabs render +# even when the wiki isn't already in context-center layout +# --customer-name basename of --wiki-dir (only used with --bootstrap-tabs) +# --wiki-name same as --customer-name (only used with --bootstrap-tabs) +# --proposals-repo (none → Promote button is non-functional; static-only mode) +# --proposals-checkout ~/.cache/wiki-proposals +# +# Environment: +# BIND bind address for the server. Default 127.0.0.1 (loopback +# only). Set BIND=0.0.0.0 to accept connections from other +# devices on your LAN / tailnet. +# +# Notes on action endpoints: +# This script always runs promote_server.py (not the bare http.server) so the +# GET/static parts of the viewer work identically in either mode. The action +# endpoints (rescan, scan-from-ticket, create-skill, rescan-drift, +# acknowledge-drift, promote-skill) are only enabled when the relevant flags +# are passed. We auto-detect --data-dir when --wiki-dir lives inside a +# context-center layout (i.e. .../wikis//); otherwise the +# five --data-dir-gated endpoints return 503 — the static viewer still works. +# +# Notes on --bootstrap-tabs: +# In single-wiki mode only the Wikis tab renders, because the other four +# sections (Tickets, Candidates, Skills, Drift) are sibling subdirs of a +# context-center root that isn't there. --bootstrap-tabs synthesizes that +# root in a sibling .cc-bootstrap/ dir: the wiki is copied under +# wikis/// and empty placeholder dirs are created for +# the other four sections, so all 5 tabs render (the empty ones show +# "No items yet"). The bootstrap dir is regenerated on each run; the +# original --wiki-dir is never modified. +# +# We copy rather than symlink because Python <3.13's pathlib.rglob doesn't +# follow directory symlinks, so a symlinked wiki would render as empty. +# For typical wiki sizes (a few MB) the copy is sub-second. +# +# Stop the server with Ctrl-C, or `lsof -ti :PORT | xargs kill`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WIKI_DIR="" +SITE_DIR="" +DATA_DIR="" +PORT="8765" +BIND="${BIND:-127.0.0.1}" +OPEN_BROWSER=true +PROPOSALS_REPO="" +PROPOSALS_CHECKOUT="$HOME/.cache/wiki-proposals" +BOOTSTRAP_TABS=false +BOOTSTRAP_CUSTOMER="" +BOOTSTRAP_WIKI_NAME="" + +# --- Parse args --- + +for arg in "$@"; do + case "$arg" in + --wiki-dir=*) WIKI_DIR="${arg#*=}" ;; + --site-dir=*) SITE_DIR="${arg#*=}" ;; + --data-dir=*) DATA_DIR="${arg#*=}" ;; + --port=*) PORT="${arg#*=}" ;; + --no-open) OPEN_BROWSER=false ;; + --bootstrap-tabs) BOOTSTRAP_TABS=true ;; + --customer-name=*) BOOTSTRAP_CUSTOMER="${arg#*=}" ;; + --wiki-name=*) BOOTSTRAP_WIKI_NAME="${arg#*=}" ;; + --proposals-repo=*) PROPOSALS_REPO="${arg#*=}" ;; + --proposals-checkout=*) PROPOSALS_CHECKOUT="${arg#*=}" ;; + -h|--help) + sed -n '2,45p' "$0" + exit 0 + ;; + *) + echo "error: unknown arg $arg" >&2 + exit 2 + ;; + esac +done + +# --- Auto-detect wiki dir if not given --- +# +# Preferred (new layout): builder writes ./customer-context/wikis//. +# Pick the first customer subdir that has .md files; the rest of the wiki-viewer +# walks up via --data-dir auto-detection and renders all 5 tabs. +# Falls back to legacy ./customer-context/context/ (pre-wikis/ layout) and the +# bundled sample. + +if [ -z "$WIKI_DIR" ]; then + for data_root in "./customer-context" "./examples/sample_context_center"; do + if [ -d "$data_root/wikis" ]; then + first_wiki="$(find "$data_root/wikis" -mindepth 1 -maxdepth 1 -type d | sort | head -n 1)" + if [ -n "$first_wiki" ] && find "$first_wiki" -name '*.md' -print -quit | grep -q .; then + WIKI_DIR="$first_wiki" + echo "==> Auto-detected wiki at $WIKI_DIR" >&2 + break + fi + fi + done +fi + +if [ -z "$WIKI_DIR" ]; then + for candidate in "./customer-context/context" "./examples/sample_output"; do + if [ -d "$candidate" ] && find "$candidate" -name '*.md' -print -quit | grep -q .; then + WIKI_DIR="$candidate" + echo "==> Auto-detected wiki at $WIKI_DIR (legacy layout)" >&2 + break + fi + done +fi + +if [ -z "$WIKI_DIR" ]; then + echo "error: no --wiki-dir given and no wiki found at the standard locations" >&2 + echo " (./customer-context/wikis//, ./customer-context/context/," >&2 + echo " ./examples/sample_output/)" >&2 + echo " pass an explicit --wiki-dir=PATH." >&2 + exit 2 +fi + +if [ ! -d "$WIKI_DIR" ]; then + echo "error: --wiki-dir=$WIKI_DIR is not a directory" >&2 + exit 2 +fi + +if ! find "$WIKI_DIR" -name '*.md' -print -quit | grep -q .; then + echo "error: $WIKI_DIR contains no .md files; nothing to serve" >&2 + exit 2 +fi + +# Default site dir: sibling "site/" of the wiki dir +if [ -z "$SITE_DIR" ]; then + SITE_DIR="$(dirname "$WIKI_DIR")/site" +fi + +# --- Auto-detect context-center data dir --- +# +# The five action endpoints (rescan, scan-from-ticket, create-skill, +# rescan-drift, acknowledge-drift) all require a context-center root that +# contains wikis/, candidates/, tickets/ subdirs (see promote_server.py +# --data-dir). +# +# If --wiki-dir is itself nested inside such a layout (i.e. the path +# .../wikis//), we can auto-derive --data-dir as the +# grandparent of wikis/. Otherwise we leave DATA_DIR empty and those +# endpoints will return 503 — the static viewer still works. +if [ -z "$DATA_DIR" ]; then + wiki_abs="$(cd "$WIKI_DIR" && pwd -P)" + parent="$(dirname "$wiki_abs")" + grandparent="$(dirname "$parent")" + # Expect: wiki = ...//wikis// + if [ "$(basename "$(dirname "$parent")")" = "wikis" ]; then + candidate_data="$(dirname "$(dirname "$parent")")" + if [ -d "$candidate_data/wikis" ]; then + DATA_DIR="$candidate_data" + echo "==> Auto-detected context-center data dir at $DATA_DIR" >&2 + fi + elif [ "$(basename "$parent")" = "wikis" ] && [ -d "$grandparent/wikis" ]; then + # Path = ...//wikis/ + DATA_DIR="$grandparent" + echo "==> Auto-detected context-center data dir at $DATA_DIR" >&2 + fi +fi + +if [ -n "$DATA_DIR" ] && [ ! -d "$DATA_DIR" ]; then + echo "error: --data-dir=$DATA_DIR is not a directory" >&2 + exit 2 +fi + +# --- Bootstrap context-center layout (--bootstrap-tabs) --- +# +# When the wiki isn't already in a context-center layout, synthesize one in a +# sibling .cc-bootstrap/ dir so all 5 tabs render. The wiki is symlinked under +# wikis/// — the original --wiki-dir is never modified. +# Skipped (with a note) if --data-dir was already given or auto-detected. + +if [ "$BOOTSTRAP_TABS" = true ]; then + if [ -n "$DATA_DIR" ]; then + echo "==> --bootstrap-tabs ignored — already in context-center layout (data-dir=$DATA_DIR)" >&2 + else + wiki_abs="$(cd "$WIKI_DIR" && pwd -P)" + wiki_basename="$(basename "$wiki_abs")" + customer="${BOOTSTRAP_CUSTOMER:-$wiki_basename}" + wiki_name="${BOOTSTRAP_WIKI_NAME:-$wiki_basename}" + bootstrap_root="$(dirname "$SITE_DIR")/.cc-bootstrap" + rm -rf "$bootstrap_root" + mkdir -p \ + "$bootstrap_root/wikis/$customer" \ + "$bootstrap_root/tickets" \ + "$bootstrap_root/candidates" \ + "$bootstrap_root/skills" \ + "$bootstrap_root/drift" + cp -R "$wiki_abs" "$bootstrap_root/wikis/$customer/$wiki_name" + DATA_DIR="$bootstrap_root" + echo "==> Bootstrapped context-center layout at $bootstrap_root" >&2 + echo " wiki copied to wikis/$customer/$wiki_name (from $wiki_abs)" >&2 + echo " placeholder dirs: tickets/ candidates/ skills/ drift/ (will render as 'No items yet')" >&2 + fi +fi + +# --- Sanity --- + +if ! command -v python3 >/dev/null 2>&1; then + echo "error: python3 not found. Install Python 3.9+: https://www.python.org/downloads/" >&2 + exit 1 +fi + +if command -v lsof >/dev/null 2>&1 && lsof -ti ":$PORT" >/dev/null 2>&1; then + echo "error: port $PORT is already in use." >&2 + echo " free it (lsof -ti :$PORT | xargs kill) or rerun with --port=N" >&2 + exit 1 +fi + +# --- Build --- +# +# In context-center mode (DATA_DIR detected), build from the data root so all +# sections (wikis/tickets/candidates/skills/drift) render as top tabs. In +# single-wiki mode, build from the wiki dir directly. + +if [ -n "$DATA_DIR" ]; then + BUILD_INPUT="$DATA_DIR" +else + BUILD_INPUT="$WIKI_DIR" +fi + +echo "==> Building HTML viewer: $BUILD_INPUT -> $SITE_DIR" +rm -rf "$SITE_DIR" +python3 "$SCRIPT_DIR/build_html_site.py" \ + --input-dir="$BUILD_INPUT" \ + --output-dir="$SITE_DIR" \ + --repo-name="Customer wiki — $(basename "$(cd "$WIKI_DIR" && pwd -P)")" + +URL="http://127.0.0.1:$PORT/index.html" + +# --- Open browser (best-effort) --- + +if $OPEN_BROWSER; then + ( + sleep 1 + if command -v open >/dev/null 2>&1; then # macOS + open "$URL" 2>/dev/null || true + elif command -v xdg-open >/dev/null 2>&1; then # Linux + xdg-open "$URL" 2>/dev/null || true + elif command -v wslview >/dev/null 2>&1; then # WSL + wslview "$URL" 2>/dev/null || true + fi + ) & +fi + +echo "" +echo "==> Serving at $URL" +if [ "$BIND" != "127.0.0.1" ]; then + echo " bind=$BIND — also reachable from other devices on your network or tailnet" +fi +if [ -n "$PROPOSALS_REPO" ]; then + echo " Promote target: $PROPOSALS_REPO (checkout: $PROPOSALS_CHECKOUT)" +else + echo " Promote button is inert — pass --proposals-repo=OWNER/REPO to enable it." +fi +if [ -n "$DATA_DIR" ]; then + echo " Action endpoints enabled (data-dir=$DATA_DIR)" +else + echo " Action endpoints (rescan / scan-from-ticket / create-skill / rescan-drift /" + echo " acknowledge-drift) will 503 — pass --data-dir=PATH to a context-center root" + echo " (containing wikis/, candidates/, tickets/) to enable them." +fi +echo " Ctrl-C to stop, or: lsof -ti :$PORT | xargs kill" +echo "" + +# Always run promote_server.py (never bare http.server) so the static viewer +# behaves identically across modes. Endpoints gate themselves on the relevant +# flags inside promote_server.py. +if [ -n "$PROPOSALS_REPO" ] && ! command -v gh >/dev/null 2>&1; then + echo "error: --proposals-repo set but 'gh' CLI not found. Install: https://cli.github.com" >&2 + exit 1 +fi + +EXTRA_ARGS=() +if [ -n "$PROPOSALS_REPO" ]; then + EXTRA_ARGS+=(--proposals-repo="$PROPOSALS_REPO" --proposals-checkout="$PROPOSALS_CHECKOUT") +fi +if [ -n "$DATA_DIR" ]; then + EXTRA_ARGS+=(--data-dir="$DATA_DIR") +fi + +# `${arr[@]+"${arr[@]}"}` safely expands to nothing when the array is empty, +# instead of tripping `set -u`'s unbound-variable check. +exec python3 "$SCRIPT_DIR/promote_server.py" \ + --site-dir="$SITE_DIR" \ + --port="$PORT" \ + --bind="$BIND" \ + ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} From 939012125b37ed17069e321a5aa82cf2d89cc6a7 Mon Sep 17 00:00:00 2001 From: Xuetao Kang Date: Wed, 20 May 2026 16:23:00 -0700 Subject: [PATCH 2/2] eval: add CCB skill routing cases --- evals/core-cujs/dataset.json | 36 ++++++++++++++++++++++++++++++++++++ evals/core-cujs/run.yaml | 3 +++ 2 files changed, 39 insertions(+) diff --git a/evals/core-cujs/dataset.json b/evals/core-cujs/dataset.json index 87ba24f1..f9362931 100644 --- a/evals/core-cujs/dataset.json +++ b/evals/core-cujs/dataset.json @@ -5,7 +5,9 @@ "starting_prompt": "setup context generation for my database.", "conversation_plan": "Ask the agent to setup the auto context generation for my alloydb database. Connection information: datasource name as my-alloydb, project id is cloud-db-nl2sql, region us-central1, cluster whaoyu-test, instance whaoyu-test-primary, database financial. Verify the agent generate a valid tools.yaml. Explicitly ask the agent to perform validation. You should terminate the conversation immediately after the agent successfully set up the files and performed the validation.", "expected_trajectory": [], + "expected_skills": ["skill-autoctx-init"], "kind": "agents", + "work_dir": "workspace_empty/", "max_turns": 15 }, { @@ -13,6 +15,7 @@ "starting_prompt": "generate a evaluation dataset for my alloydb database financial named golden.json, based on my seed dataset to be provided.", "conversation_plan": "Ask the agent to generate a evaluation dataset for my alloydb database financial named golden.json. The 'seed' includes the following two golden NL-SQL pair:\n\nHow many accounts who choose issuance after transaction are staying in East Bohemia region? A3 contains the data of region; 'POPLATEK PO OBRATU' represents for 'issuance after transaction'.\nSELECT COUNT(DISTINCT \"T1\".\"account_id\") FROM \"account\" AS \"T1\" INNER JOIN \"district\" AS \"T2\" ON \"T1\".\"district_id\" = \"T2\".\"district_id\" WHERE \"T2\".\"A3\" = 'east Bohemia' AND \"T1\".\"frequency\" = 'POPLATEK PO OBRATU'\n\nHow many accounts who have region in Prague are eligible for loans? A3 contains the data of region\nSELECT COUNT(\"T1\".\"account_id\") FROM \"account\" AS \"T1\" INNER JOIN \"loan\" AS \"T2\" ON \"T1\".\"account_id\" = \"T2\".\"account_id\" INNER JOIN \"district\" AS \"T3\" ON \"T1\".\"district_id\" = \"T3\".\"district_id\" WHERE \"T3\".\"A3\" = 'Prague'. If prompted for validating the query, reply yes to the agent. Once the initial golden dataset is generated, ask the agent to expand it with 2 additional examples in the same file. You should terminate the conversation immediately after the agent successfully generated the dataset, even if the agent prompts you to perform evaluation or take next steps.", "expected_trajectory": [], + "expected_skills": ["skill-autoctx-dataset-generation"], "kind": "agents", "work_dir": "workspace_post_init/", "max_turns": 15 @@ -22,6 +25,7 @@ "starting_prompt": "create initial context set for my alloydb database", "conversation_plan": "Ask the agent to create initial context set for my alloydb database. Need to have at least 2 templates and 2 facets. If prompted for experiment name, use 'my-alloydb-tuning-experiment'. You should terminate the conversation immediately after the agent successfully created the context set, even if the agent prompts you to perform evaluation or take next steps.", "expected_trajectory": [], + "expected_skills": ["skill-autoctx-bootstrap"], "kind": "agents", "work_dir": "workspace_post_dataset_generation/", "max_turns": 15 @@ -31,6 +35,7 @@ "starting_prompt": "evaluate on the golden dataset golden.json with context set id projects/cloud-db-nl2sql/locations/us-east1/contextSets/whaoyu-eval-contextset", "conversation_plan": "Ask the agent to evaluate the golden dataset golden.json, using the current context set with context set id projects/cloud-db-nl2sql/locations/us-east1/contextSets/whaoyu-eval-contextset. If prompted for experiment name, use 'my-alloydb-tuning-experiment'. You should terminate the conversation immediately after the agent successfully evaluated the dataset, even if the agent prompts you to perform context improving task or take next steps.", "expected_trajectory": [], + "expected_skills": ["skill-autoctx-evaluate"], "kind": "agents", "work_dir": "workspace_post_bootstrap/", "max_turns": 15 @@ -40,9 +45,40 @@ "starting_prompt": "improve the context set based on eval failure", "conversation_plan": "Ask the agent to perform the hillclimbing to improve the context set based on eval failure. If prompted for experiment name, use 'my-alloydb-tuning-experiment'. You should terminate the conversation immediately after the agent successfully performed the hillclimbing task, even if the agent prompts you to perform evaluation or take next steps.", "expected_trajectory": [], + "expected_skills": ["skill-autoctx-hillclimb"], "kind": "agents", "work_dir": "workspace_post_evaluation/", "max_turns": 15 + }, + { + "id": "ccb-routing:customer-context-builder", + "starting_prompt": "Pull together everything we know about the Acme GCP project into an LLM context repo, using BigQuery and our internal docs.", + "conversation_plan": "You only want to verify routing, not full execution. Ask the agent to build/refresh a context repository (LLM wiki) for the Acme GCP customer, drawing from BigQuery datasets and the team's internal Google Docs. Do NOT provide any project ids, credentials, or extra details. As soon as the agent activates a skill or clearly states which skill/workflow it will use to fulfill the request, terminate the conversation immediately. Do not let it proceed to actually fetch data.", + "expected_trajectory": ["activate_skill"], + "expected_skills": ["skill-ccb-customer-context-builder"], + "kind": "agents", + "work_dir": "workspace_empty/", + "max_turns": 4 + }, + { + "id": "ccb-routing:gcp-data-qa", + "starting_prompt": "What was Q1 revenue by channel? Just ask BigQuery in plain English, I don't want to write any SQL.", + "conversation_plan": "You only want to verify routing, not full execution. Ask the agent to answer an analytics question about the customer's BigQuery data using natural language (Conversational Analytics), without writing SQL yourself. Do NOT provide any project ids, credentials, or extra details. As soon as the agent activates a skill or clearly states which skill/workflow it will use to fulfill the request, terminate the conversation immediately. Do not let it proceed to actually run the query.", + "expected_trajectory": ["activate_skill"], + "expected_skills": ["skill-ccb-gcp-data-qa"], + "kind": "agents", + "work_dir": "workspace_empty/", + "max_turns": 4 + }, + { + "id": "ccb-routing:wiki-viewer", + "starting_prompt": "Open the customer context wiki in a browser so I can explore it.", + "conversation_plan": "You only want to verify routing, not full execution. Ask the agent to view/serve/preview the previously generated customer-context wiki (Context Center) in a browser. Do NOT provide any paths or extra details. As soon as the agent activates a skill or clearly states which skill/workflow it will use to fulfill the request, terminate the conversation immediately. Do not let it proceed to actually start a server.", + "expected_trajectory": ["activate_skill"], + "expected_skills": ["skill-ccb-wiki-viewer"], + "kind": "agents", + "work_dir": "workspace_empty/", + "max_turns": 4 } ] } \ No newline at end of file diff --git a/evals/core-cujs/run.yaml b/evals/core-cujs/run.yaml index 0edd2cfb..e29950cd 100644 --- a/evals/core-cujs/run.yaml +++ b/evals/core-cujs/run.yaml @@ -16,6 +16,9 @@ scorers: # Checks if the agent used the expected tools in the correct order. trajectory_matcher: {} + # Checks if the agent activated the expected skills (skill routing/retrieval). + skills_trajectory: {} + # Uses an LLM to judge if the user's goal was met. goal_completion: model_config: core-cujs/gemini_model.yaml