Skip to content

Codebase Guide

romaingaspard edited this page Jun 21, 2026 · 5 revisions

Codebase Guide

A practical map for reading and changing the code. If you only read one wiki page before touching the source, read this one — then Architecture for the why.

The shape to keep in your head: raw JSONL → typed records (schema.py) → analytics (pure Python) → {CLI, dashboard, CSV}. Costs are computed at read time; the analytics layer never imports Streamlit or pandas.

Module map

You want to change… Look here
What a column is called / adding a column / the token-type keys schema.pythe single source of truth, imported by every reader and writer.
How JSONL is parsed, deduped, attributed; the CSV writer extract.py (collect() parses; run_extract() writes + reports). Owns the per-file parse cache.
A number in any report / a new analysis analytics.pyDataset, load_dataset, CostEngine, and every aggregation. No UI deps.
How a model is priced / adding a provider pricing.py + data/pricing.yml.
How prompts get their category / complexity categorize.py (regex classifier + the opt-in SemanticClassifier + LLM clients incl. Azure). Shared anchors in data/semantic_anchors.yml.
The taxonomy audit (--audit-categories) / embeddings taxonomy_audit.py (HDBSCAN diagnostic) + embeddings.py (the single door to the vector model).
Output composition (language / code-vs-test / lines / prose-vs-code) compose.py + tokenizer.py; aggregated by analytics.output_composition/by_output.
Context composition (loading vs rent, per source / per file) context.py (attribute_context_cost walk); aggregated by analytics.context_cost/file_footprint.
Task attribution (TodoWrite spine + inference fallback) tasks.py; aggregated by analytics.by_task/task_graph.
The before/after Compare tab analytics.impact_report/split_on_pivot + dashboard/impact.py + pages/9_compare.py.
The quota endpoint / quota_log.csv snapshot.py.
Where the Claude dir / cache dir live (machine-dependent roots) paths.py.
Atomic writes, formula-injection escaping storage.py.
How a TableResult becomes a rich table / CSV / JSON render.pythe only place display formatting ($, %, separators) lives.
A command's args / dispatch / exit code cli.py + __main__.py.
Anything in the Streamlit UI dashboard/data.py (Dataset → pandas), filters.py (cross-filters), echarts.py (render + click/brush), theme.py (colors), impact.py (Compare tab), pages/* (incl. 8_composition.py, 11_explorer.py, 12_file_explorer.py).

The data model

Read schema.py first — it defines the token-type keys, every CSV's columns, and the TypedDict shapes exchanged between parser and aggregation. The canonical output is six normalized CSVs keyed by prompt_id / session_id (categories.csv from categorize and quota_log.csv from snapshot round out the set):

File Grain
sessions.csv one session
prompts.csv one real human prompt (pseudo-prompts excluded)
prompts_text.csv full prompt text (omitted with --no-text)
tokens.csv prompt_id × model × token_type × is_sidechainraw counts only, no costs
requests.csv prompt_id × request_index — one deduplicated API request
token_types.csv reference: machine key, label, description
categories.csv category, complexity, classifier model (owned by categorize)
quota_log.csv append-only quota time series

Plus the additive composition tables (Axes C/D/B2) — prompts.csv/tokens.csv stay byte-identical, all metrics-only (no source, no absolute paths): output_files.csv + output_tokens.csv (output), context_sources.csv + context_cost.csv (context), tasks.csv + task_prompts.csv (tasks). Full columns in Architecture → the data model.

Because schema.py is the single source of truth, adding a column is a one-line change there that every reader/writer picks up. The hard rule when you touch the schema: keep the V7 invariant — summing tokens.csv over is_sidechain must still reproduce per-prompt totals, and the numbers must still reconcile with ccusage (see Accuracy and Pricing).

How cross-filtering works

Clicking a bar to filter the whole board (Power-BI style) is not an ECharts feature — ECharts only gives us the native events (click, brushEnd) and the x-axis brush. The "cross" is a thin layer we own on top of Streamlit's shared session_state. The round-trip:

  1. Capture (ECharts → Python). echarts.render(..., click=True) binds a native click handler that returns params.name; streamlit-echarts ferries it back into the component value under chart_event. The time trend uses render_events with a brushEnd handler (date_brush_js) that returns [startLabel, endLabel] instead — so the caller disambiguates a pick by type (a clicked category is a str, a brushed range is a list).
  2. Stage the write. echarts.apply_click (or apply_date_range) turns that value into a filter selection. It can't write the target key directly: the target is a sidebar st.multiselect whose value Streamlit forbids mutating after the widget is instantiated this run. So it calls filters.stage_filter, which parks the value under a …__pending key, and triggers a rerun.
  3. Drain before the widgets render. At the top of filters.render_sidebar, _drain_pending copies the staged value into the real KEY_* key before the multiselects are created — the one window where the write is legal. Reset works the same way (a staged _PENDING_RESET flag).
  4. Cascade. Every page calls filters.apply_filters, which filters prompts by the selected model/project/category/date, then cascades to tokens and sessions by surviving ids. All charts re-read the filtered frames on the rerun — that shared store, not any ECharts link, is the "cross".

Two guards worth knowing before you touch this: the component value is sticky (it re-emits the same pick on every rerun), so apply_click/apply_date_range no-op unless the selection actually changes; and pseudo-prompt token rows (session overhead, no prompts.csv row) cascade through their session, not prompt_id, or they'd vanish even with no filter active. Landmine: streamlit-echarts only works under a real streamlit run — the click round-trip is dead in a bare import or a test harness.

Run, test, extend

Dev setup is uv-based. The four checks that gate CI (the "quartet"):

uv sync --all-extras
ruff check .
ruff format --check .
mypy prompt_analytics tests
pytest                       # coverage gate 85%

Run the tool against the demo data without your own logs:

prompt-analytics summary --from-csv demo_data
CCA_DEMO=1 prompt-analytics dashboard     # the dashboard on demo data

Adding a new analysis command

  1. Write the aggregation in analytics.py, returning a TableResult (so it gets table / CSV / JSON for free via render.py). Keep it pure — no UI deps.
  2. Wire the subcommand in cli.py (args + dispatch).
  3. Optionally surface it in the dashboard under dashboard/pages/ using data.py to reshape the Dataset into pandas.
  4. Add a test (the suite is the safety net for a refactor) and, if it shows a new number, keep the README/wiki example honest.

Adding a pricing provider

Add a rate card under providers: in data/pricing.yml (per-token prices, optional per_request). Lookup is exact-then-longest-prefix, so you don't need an entry for every point release. See Accuracy and Pricing for the lookup rules and the weekly drift job that keeps the bundled grid honest.

Supporting a new Claude Code version

Capture a fixture with scripts/capture_fixture.py into tests/fixtures/claude-code-<version>/. The parser is pinned against these, so a format change shows up as a failing test rather than silent drift.

Mental model for newcomers

  • extract is an export, not a prerequisite. Analysis commands parse the JSONL live in memory when there is no fresh extract — so numbers are always current and a pricing change never needs a re-extract.
  • One source of truth per concern: schema in schema.py, formatting in render.py, prices in pricing.yml, colors in dashboard/theme.py. When in doubt, change it in the one place.
  • The analytics layer is UI-agnostic. If you find yourself importing Streamlit or pandas into analytics.py, the logic belongs in dashboard/ instead.

See Architecture for the data-flow diagram and the design decisions behind this layout.

Clone this wiki locally