AI and Documentation - dathere/qsv GitHub Wiki

AI & Documentation

Tier: Intermediate Commands covered: describegpt, synthesize, apply summarize

Note

Per-command flag reference lives in /docs/help/. This page is the workflow layer β€” when to reach for each command and how they compose.

The flagship here is describegpt β€” a neuro-symbolic data dictionary generator and SQL-RAG chat assistant. "Neuro-symbolic" means the heavy lifting (column types, ranges, cardinalities) is done deterministically by qsv's stats and frequency caches; the LLM only fills in human-friendly labels and descriptions. The result: hallucination-resistant data documentation, often produced against a local LLM (Ollama / Jan / LM Studio).

Since 20.1.0, describegpt can also tag each column with a semantic Content Type from a curated 49-token vocabulary (email, phone, street_address, job_title, credit_card, ipv6_address, …). Those tags then drive the new synthesize command, which generates a statistically-faithful fake CSV that mirrors the source's distributions and null rates while substituting realistic fakes for sensitive columns.

For the deep-dive, see docs/Describegpt.md and the output gallery (Markdown, JSON, TOON, Semantic Markdown, OKF, Spanish, Mandarin, and SQL-RAG examples).

Note

Looking for color or pro? color moved to Selection & Inspection β†’ color (it's the colorized cousin of table). pro moved to Integrations β†’ qsv pro bridge. Neither has an LLM/AI surface β€” they're listed here historically.

Quick decision table

If you want to… Use Notes
Generate a data dictionary for a CSV describegpt --dictionary Outputs deterministic stats + LLM-written labels
Tag each column with a semantic Content Type describegpt --dictionary --infer-content-type 49-token vocabulary; unique_id deterministic for ALL-UNIQUE cols (20.1.0+; non-Float cols only since 24.0.0)
Sharpen labels with cross-field awareness describegpt --all --two-pass Second LLM pass relates fields (e.g. street_no + street_name + city + zip β†’ mailing address) (20.1.0+)
Generate description + tags + dictionary describegpt --all The full "FAIRify" mode
Emit the data dictionary as a JSON Schema describegpt --dictionary --format jsonschema Draft 2020-12; qsv/LLM extras under x-qsv
Emit the data dictionary as Semantic Markdown describegpt --dictionary --format semanticmd Agent-parseable markdown (semanticmd.org) a converter turns into JSON (20.2.0+)
Emit the data dictionary as Open Knowledge Format describegpt --dictionary --format okf Leaner, vendor-neutral OKF markdown + YAML frontmatter
Ask a natural-language question about a CSV describegpt --prompt "..." SQL-RAG sub-mode kicks in when needed
Customize the Markdown layout describegpt --markdown-template my.toml MiniJinja TOML templates per inference kind (20.1.0+)
Get multilingual descriptions (Spanish, Mandarin, …) describegpt --language ... LLM-driven; quality varies by model. Omit it and the dataset's language is auto-detected locally (22.0.1+)
Use a local LLM (Ollama / Jan / LM Studio) describegpt -u http://localhost:11434/v1 --model ... Recommended for sensitive data; LLM timeout auto-disabled on localhost (21.1.0+)
Feed describegpt extra dataset context describegpt --context-file notes.md ... Text/CSV/spreadsheet inlined as {{ context }}; PDF/image sent as a multimodal attachment (21.1.0+)
Have the LLM propose each column's null sentinels describegpt --dictionary --infer-null-values ... Emits null_values (confirmed) + null_candidates (needs confirmation) into x-qsv; reported, never applied
Narrate a Data Schematic tour for a given audience describegpt --dictionary --format jsonschema --tour-audience "..." Writes x-qsv.tour; default "Explain like I'm 10" (23.0.1+)
Get a command's JSON tool definition or help Markdown from the binary <command> --help --format json|md Or --export-tool-definitions <dir> for every command (24.0.0+) β€” details
Condense a text column into a summary apply summarize <column> -c summary Per-row LLM summarization; OpenAI-compatible (21.1.0+)
Generate a statistically-faithful fake CSV synthesize Preserves distributions + null rates; substitutes realistic fakes (20.1.0+)

describegpt

Calls any OpenAI-compatible LLM endpoint with a configurable MiniJinja-templated prompt. The prompt is fed pre-computed summary statistics and frequency distribution from qsv's stats / frequency caches β€” that's the "symbolic" half. The LLM generates only the natural-language labels and descriptions β€” that's the "neuro" half. Result: deterministic, reproducible documentation that doesn't hallucinate column names or invent ranges.

Two modes

  • Dictionary mode (--dictionary, --description, --tags, --all) β€” generates documentation for the whole dataset.
  • Chat / RAG mode (--prompt "...") β€” answer a natural-language question. If the answer needs more than stats+frequency, qsv enters SQL-RAG sub-mode: it generates a SQL query, runs it against the data (DuckDB if QSV_DUCKDB_PATH is set, Polars SQL otherwise), and returns the deterministic answer.

Example: full data dictionary against OpenAI's default model

qsv describegpt data.csv --api-key "$OPENAI_API_KEY" --all
# Writes data.dictionary.json (or similar) and prints a Markdown summary

Example: against a local Ollama instance (no API key, no data leaves your machine)

qsv describegpt NYC_311_SR_2010-2020-sample-1M.csv \
  -u http://localhost:11434/v1 \
  --model deepseek-r1:14b \
  --dictionary

Note

Localhost timeout auto-disabled (21.1.0+). When the --base-url points at a localhost endpoint (a local LLM), the LLM completion timeout is now disabled by default β€” local models running on modest hardware can legitimately take far longer than the 300-second cloud default. An explicit --timeout (or the QSV_TIMEOUT env var) always wins, even on localhost.

Example: chat β€” ask a question the stats alone can answer

qsv describegpt NYC_311_SR_2010-2020-sample-1M.csv \
  --prompt "What is the most common complaint type?"

The LLM consults the frequency table (already cached) and answers without writing any SQL.

Example: chat with SQL-RAG sub-mode

qsv describegpt NYC_311_SR_2010-2020-sample-1M.csv \
  --prompt "What are the top 10 complaint types by community board and borough by year?"

Stats alone can't answer this (it needs a group-by Γ— borough Γ— year), so qsv:

  1. Builds a small random sample as additional LLM context.
  2. Asks the LLM to write a SQL query that answers the question.
  3. Runs the query with DuckDB (if QSV_DUCKDB_PATH is set) or Polars SQL.
  4. Returns the actual result, not a guess. See docs/describegpt/nyc311-describegpt-prompt.md for a worked example with the resulting CSV.

Example: iterative SQL-RAG session refinement

The Allegheny property sales session shows three rounds of refinement against the dataset. The final query produces a most-expensive-listings CSV β€” deterministic, reproducible.

Example: multilingual output

qsv describegpt NYC_311.csv --all --language Spanish  > nyc311-describegpt-spanish.md
qsv describegpt NYC_311.csv --all --language Mandarin > nyc311-describegpt-mandarin.md

(See docs/describegpt/ for actual Spanish and Mandarin outputs.)

--language is not limited to languages β€” it takes a language, dialect or tone: "Castilian", "Franglais", "Taglish", "Chavacano", "Gen Z", "Pirate", "Shakespearean English", "Yoda".

Automatic content-language detection (new in 22.0.1)

With --dictionary, describegpt detects the dataset's own content language before it asks the LLM anything β€” locally, from sampled values of the text columns, using whatlang. It is deterministic and costs zero tokens (no LLM round trip).

When detection clears the confidence threshold, three fields are emitted β€” detected_language, detected_language_code (ISO 639-3) and detected_language_confidence β€” at the top level of JSON output, or in the x-qsv object of JSON Schema output. When detection is not confident, the fields are simply omitted rather than guessed.

Detection then drives the LLM's output language, so a Spanish dataset gets a Spanish dictionary without being asked. Downstream, viz smart reads detected_language_code to render the whole Data Schematic in that language.

Important

--language is dual-typed β€” what it does depends on whether you pass a string or a number:

You pass Meaning
(nothing) Auto-detect, at an 80% confidence threshold
A float 0.0–1.0 (e.g. --language 0.6) Auto-detect, but with your confidence threshold
A string (e.g. --language Spanish) Always use that language β€” auto-detection is overridden

There is no --lang short form; the flag is spelled out in full.

Example: controlled tag vocabulary (avoid LLM tag drift)

qsv describegpt NYC_311.csv \
  --tags \
  --tag-vocab data-tag-vocabulary.csv > nyc311-tags.md

--tag-vocab constrains the LLM to choose from a list of approved tags β€” useful for CKAN harmonization.

--tour-audience β€” narrate a Data Schematic for a specific reader (new in 23.0.1)

viz smart dashboards open with a guided Tour, and --tour-audience decides who it is talking to. It has describegpt write an x-qsv.tour narration into the dictionary during inference, addressed to that audience.

qsv describegpt city_budget.csv --dictionary --format jsonschema \
    --tour-audience "the City Council" -o city_budget.schema.json

It defaults to "Explain like I'm 10", and the same dataset will happily brief "a board of directors", "data journalists", "my 11th grade Civics class" or "an urban planner". Combine with --language for a non-English tour.

Note

Only meaningful with --dictionary when the format is jsonschema; ignored otherwise. qsv viz smart --dictionary infer forwards the flag automatically. The audience shapes the tour prose only β€” field labels and descriptions keep their normal register, so re-pitching the narration never quietly rewrites the dictionary.

Language detection is gated on chunk agreement (fixed in 23.0.1)

Automatic content-language detection used to accept any reading that cleared a confidence threshold. A table of city, country and continent names was read as Italian at 0.9308 confidence β€” comfortably past the 0.8 gate β€” and viz smart then localized an entire Data Schematic to Italian.

Detection now requires agreement across chunks rather than confidence on one, so a confidently-wrong reading of proper nouns no longer wins.

Output formats

describegpt emits Markdown (default), JSON, TOON (Toon Format β€” a compact JSON encoding designed for LLM prompts), JSONSchema, SemanticMd, and OKF (the last three dictionary-centric β€” see below). See docs/describegpt/ for examples of each.

JSON Schema output (--format jsonschema)

describegpt --format jsonschema emits the Data Dictionary as a JSON Schema (draft 2020-12) document instead of Markdown/JSON/TOON. Each column becomes a property carrying its inferred type plus the LLM-written Label and Description. If the description inference also ran, it becomes the schema's top-level description; tags, if generated, land under x-qsv.tags. New in 24.0.0: the description is written as plain prose (#4689). Single-symbol LaTeX spans that some models emit, such as $\times$ or $\approx$, become Γ— and β‰ˆ before any output is written. Markdown-escaped dollars (\$179.73) are kept, since they stop GitHub from rendering the text between two amounts as math, and the viz Data Schematic now displays them as $179.73.

qsv- and LLM-specific metadata that JSON Schema has no native slot for β€” cardinality, null_count, weighted example counts, semantic Content Type, and the extra stats columns β€” is preserved under a single x-qsv annotation object per property. Unknown x- keywords are ignored by conforming validators (per the 2020-12 spec), so the schema still validates real data while keeping qsv's richer context for tooling that knows to look.

Two x-qsv hints on a numeric measure drive the viz smart KPI overview row (edit them in the saved schema to fine-tune the dial):

  • x-qsv.gauge_range β€” a [min, max] canonical scale (e.g. [0, 1] for a ratio, [0, 100] for a percent) that renders the measure's KPI tile as a gauge. describegpt --dictionary emits this automatically for measures on a recognizable canonical scale, but only keeps it when the observed data falls within the range, so a mis-scaled range can't draw a misleading dial.
  • x-qsv.target β€” a goal value that adds a "vs target" delta (value βˆ’ target) to the tile. This is a target you supply, never a fabricated prior-period baseline, so describegpt never emits it β€” hand-author it in the schema.

Also written here when their inferences run: x-qsv.role / x-qsv.concept (the analytical role and catalog concept that also drive semantic viz smart panel selection and the SemanticMd/OKF dictionaries), and, with --infer-null-values, the null_values / null_candidates sentinel lists.

x-qsv annotations added in 23.0.1

  • x-qsv.unit β€” a per-field UCUM code for a numeric measure. qsv already carried units piecemeal (x-qsv.currency is the unit for money, x-qsv.denominator.unit for an area denominator) and every other measure had none, so a chart axis read "temperature" rather than "temperature (Β°C)". viz renders the resolved symbol on the KPI tile, the panel subtitle, hover text and axis titles.

    [!NOTE] UCUM is case-sensitive β€” m is metre, M is the mega prefix β€” so the code is matched exactly. The accepted codes are injected into the prompt (unlike ISO currency codes, which a model emits unprompted; UCUM it does not). A code outside the table is silently dropped, not rendered.

  • measure.money β€” a first-class money concept, so a dollar column no longer lands on the generic measure.amount and no longer formats with an SI "G" suffix. For money, the currency is the unit, so a verified x-qsv.currency takes precedence over x-qsv.unit on the same field.
  • x-qsv.denominator β€” derived, not merely accepted: describegpt identifies a measure.population column and emits it as the denominator for the region entries, so a viz smart rate map charts per-capita without the reader ever naming either column.
  • x-qsv.denominator.level β€” the geography the denominator describes (geo.state, geo.county, …), copied from the chosen denominator onto the region entries. Without it, cardinality alone cannot tell a denominator from one measured over a coarser geography β€” a county extract that joined in state populations would chart county counts over state totals. viz compares this against the region key and refuses a mismatch.
  • x-qsv.tour β€” the guided-tour narration, written when --tour-audience is passed. See the guided Tour.

Important

An inferred dictionary is a draft; the reviewed copy is the artifact of record.

Re-inferring over the same data with the same model can legitimately flip a numeric column from role: measure to role: dimension β€” LLM inference is not reproducible, and qsv assumes a human corrects it. Save the schema, review it, and keep that file.

The control is real but bounded: qsv re-verifies every hint on read, so a hand-edit can correct the model where it guessed wrong but cannot assert a hint the data does not support. A declared money measure on a zero-padded code column is refused, not drawn. Human judgment overrides the model; neither overrides the data.

Two policies, and the rule that separates them. Some annotations qsv accepts on proposal and then verifies; others you must declare and qsv never guesses. The discriminator is whether the annotation changes a number:

Annotation What it does Policy A bad value is…
x-qsv.denominator.unit (and --denominator-unit) multiplies values by a conversion factor declare-only β€” never inferred a hard error
x-qsv.currency labels a correct number propose-then-verify silently dropped
x-qsv.unit labels a correct number propose-then-verify silently dropped
x-qsv.denominator.level routes a check; changes no number propose-then-verify silently dropped

Tip

Fine-tune a dictionary interactively. The visual-data-dictionary skill ships edit_dictionary.py, a terminal UI that walks every column and edits the five fields that steer panel selection β€” role, concept, label, description and aggregation β€” previewing how each edit reroutes the column before you render. --summary prints the current routing non-interactively.

# emit a JSON Schema for customers.csv, enriched with LLM labels & descriptions
qsv describegpt customers.csv --dictionary --format jsonschema -o customers.schema.json

# then validate the data against it with qsv's own validator
qsv validate customers.csv customers.schema.json

The jsonschema format requires the dictionary phase (--dictionary or --all); the --prompt chat mode is not supported. Two flags fine-tune the output:

  • --allow-extra-cols β€” emit additionalProperties: true at the schema root (default is false β€” strict).
  • --strict-dates β€” emit format: date / date-time for Date/DateTime columns. Off by default because qsv's date inference is permissive (it accepts strings like June 27, 1968) while JSON Schema's date formats require RFC 3339 β€” set it only when your columns are guaranteed RFC 3339. Mirrors the same flag on the schema command.

This pairs naturally with validate β€” see Recipe: JSON Schema Validation for the validation side.

Semantic Markdown output (--format semanticmd, new in 20.2.0)

describegpt --format semanticmd emits the Data Dictionary as a Semantic Markdown document β€” human-readable markdown with a few lightweight, agent-parseable conventions that a companion converter turns into structured JSON. It's the middle ground between freeform English and a formal schema: a person reads it as a normal markdown report, while an agent gets concrete structural cues.

The document is organized into sections:

  • YAML frontmatter β€” dataset id / title / row_count / grain, an optional temporal_coverage and spatial envelope, a sorted concept index, and --tags (when run). String scalars are quoted when needed so YAML consumers don't re-type them β€” a 2020-12-23 value stays a string, a source URL stays text.
  • # Dataset β€” the --description result (when run), a ## Grain statement ("one row = …"), and a Resource / Schema / Title table.
  • # Schema β€” a Column / Type / Role / Concept / Join? / Null / Label table (codes wrapped in backticks, required prefix for non-null columns) and a heuristically-inferred Primary key (only when exactly one column is structurally unique β€” cardinality == rowcount, no nulls). New in 24.0.0: all-unique Float columns no longer count, so a table with an Integer id and an all-unique Float measure now infers id as the primary key; the same exemption applies to the 1:1 join class.
  • ## Column subsections β€” the LLM Description plus bold-key bullets for Concept, Role, Join, and Quality; a ### Validation block (numeric ranges or text length ranges), ### Choices (low-cardinality enumerations), and a rich ### Statistics block (mean, median, quartiles, skewness, inner fences, sparsity).
  • # Resource β€” a ## Statistics table (Column / Min / Max / Cardinality / Null Count) and per-column ### Frequency tables carrying Choice, Frequency, Percentage, and Rank (sourced from qsv's own frequency computation; aggregation buckets like Other… / (NULL)… render with a blank rank).

What makes it agent- & catalog-ready: every column carries a catalog-wide Concept ID (e.g. geo.zip_code, id.surrogate_key) for cross-dataset join discovery, an analytical Role (dimension / measure / identifier / timestamp), join keys with a 1:1 / N:1 cardinality class, and data-quality flags (PII, PII-location, sparse, placeholder-dates). Because Concept, Role, and grain need semantic context, semanticmd implies --infer-content-type.

Three optional flags populate the frontmatter (SemanticMd-only; omitted when unset, ignored by other formats): --ds-source (source / provenance), --ds-updated (last-updated date), and --ds-license (license).

# emit a Semantic Markdown data dictionary, enriched with LLM labels, description & tags
qsv describegpt customers.csv --all --format semanticmd \
  --ds-source "https://data.example.gov/catalog/customers" \
  --ds-updated 2026-01-15 \
  --ds-license "CC-BY-4.0" \
  -o customers-dictionary.md

Like jsonschema, the semanticmd format requires the dictionary phase (--dictionary or --all) and does not support the --prompt chat mode. When the other inference phases run, the --description result becomes the # Dataset description and --tags are embedded in the YAML frontmatter. Rendering goes through a user-overridable MiniJinja template (semanticmd_md_body_template, see --markdown-template below). See nyc311-describegpt-semanticmd.md for a full example.

Converting to JSON β€” the datadict.yaml schema

The emitted document's front matter opens with semantic-md: datadict.yaml, a pointer telling the semantic-md converter which schema turns the markdown into structured JSON. qsv ships that schema at docs/describegpt/datadict.yaml β€” a semantic-md schema that maps each section of the data dictionary (Dataset, Schema + per-column subsections, Resource statistics, and Frequencies) onto a JSON object. Inline-code cells (`column`, `concept`, `resource` …) are re-extracted so their values are stored without literal backticks.

Run the conversion with the semantic-md Python package, and you get the machine-readable JSON committed alongside the markdown as nyc311-describegpt-semanticmd.json β€” the same content an agent or data catalog would consume, minus the LLM round-trip:

# pip install semantic-md==0.0.2 mistletoe==1.5.1 jsonpatch==1.33 pyyaml==6.0.3
from semantic_md import convert as smd

front, body = smd.md_parse_front_matter(open("nyc311-describegpt-semanticmd.md").read())
schema = smd.Schema.read(open("datadict.yaml").read())
data = smd.to_json(smd.md_parse_body(body, schema), schema)   # -> the JSON object

A regenerate-and-verify check, check_semanticmd.py (modeled on qsv --generate-help-md), converts the markdown in-process, asserts a few structural invariants, and diffs the result against the committed JSON artifact β€” so a behavior change in datadict.yaml, the semantic-md package, or the semanticmd template fails loudly instead of drifting unnoticed. Run python3 check_semanticmd.py to verify, or --update to refresh the artifact. The toolchain is fully version-pinned for reproducibility.

Note: datadict.yaml carries a small two-rule workaround for an upstream semantic-md quirk where a {var|md} run reaching end-of-document under-counts consumed tokens by one (dropping qsv's trailing attribution paragraph). The extra trailer rule can be removed once that is fixed upstream.

Open Knowledge Format output (--format okf)

describegpt --format okf emits the Data Dictionary as an Open Knowledge Format document β€” Google Cloud's vendor-neutral "knowledge as plain markdown + YAML frontmatter" format. It's a leaner sibling of Semantic Markdown: where semanticmd packs every column with Concept IDs, Roles, join keys, quality flags, and rich statistics for cross-dataset agents, OKF stays deliberately minimal and human-first, matching OKF's spec (a small required core plus arbitrary extensions). It reuses the exact same dictionary-inference pipeline β€” only the rendering template differs.

The document has two parts:

  • YAML frontmatter β€” the required type key plus OKF's recommended title, description (a single-sentence summary), resource, timestamp, and tags. String scalars are quoted when needed so a 2020-12-31 value stays a string.
  • # <title> body β€” the full --description prose (when run), followed by a # Schema table:
| Column | Type | Content Type | Description | Enumeration |
| --- | --- | --- | --- | --- |
| `Unique Key` | integer | unique_id | A system-generated unique identifier for each record. |  |
| `Status` | text | category | Current status of the complaint. | Assigned<br>Closed<br>…<br>Unspecified |

The columns are Column (name), Type (storage type β€” integer/number/boolean/timestamp/text), Content Type (the LLM-inferred semantic content type, e.g. unique_id, date:%m/%d/%Y, category), Description (the LLM column description), and Enumeration (low-cardinality choices joined with <br>; blank for high-cardinality columns).

Two frontmatter mappings come from the shared dataset flags: --ds-source populates resource (the asset URI) and --ds-updated populates timestamp. The required type key is set with the OKF-only --okf-type flag (default "CSV Table"; set it to "BigQuery Table", "Snowflake Table", etc. to reflect the real source system).

# emit an OKF data dictionary, enriched with LLM labels, description & tags
qsv describegpt customers.csv --all --format okf \
  --okf-type "BigQuery Table" \
  --ds-source "bq://acme/sales/customers" \
  --ds-updated 2026-01-15 \
  -o customers.okf.md

Like jsonschema and semanticmd, OKF requires the dictionary phase (--dictionary or --all), does not support the --prompt chat mode, and implies --infer-content-type (which populates the Content Type column). When the other phases run, the --description result feeds both the body prose and the single-sentence frontmatter description, and --tags are embedded in the frontmatter. Rendering goes through a user-overridable MiniJinja template (okf_md_body_template, see --markdown-template below). See nyc311-describegpt-okf.md for a full example.

--infer-null-values β€” LLM-proposed null sentinels

Pass --infer-null-values (with --dictionary/--all) to have the LLM propose each field's null sentinels β€” the literal tokens (N/A, unknown, -, refused, …) that stand in for a missing value. The results land in each property's x-qsv object in the dictionary, split by qsv β€” not the model β€” into two lists:

  • null_values β€” tokens independently confirmed to occur as a literal value in a String column, spelled exactly as found (every observed casing listed).
  • null_candidates β€” everything else the LLM proposed, each stamped confirm_required: true.

A numeric or date placeholder (-999, 9999, 1900-01-01) can only ever be a candidate: it parses as a valid value, so no scan can distinguish it from real data β€” an LLM is the only thing that can propose one, and a human the only thing that can confirm it. Sentinels are reported, never applied.

This is the complement of denull: denull deterministically detects and (with --apply) blanks sentinels in columns that would promote to a numeric type once cleaned, while --infer-null-values also surfaces sentinels in purely categorical columns (status = ok/pending/NULL) that denull intentionally ignores.

Configurable prompts

The default prompt templates are in resources/describegpt_defaults.toml. Copy and edit to fit your organization's documentation style β€” describegpt --prompt-file my-prompts.toml ....

Bundled prompt file versioning

The bundled prompt file carries a version. 22.0.1 bumped it 8.0.0 β†’ 9.0.0: dictionary_prompt now asks for the grain sentence in the target language, plus a new top-level grain_unit string (the bare entity noun), emitted as x-qsv.grain_unit in JSON Schema output.

Note

This only bites if you pin your own --prompt-file. A custom prompt file won't request grain_unit, so the field is simply absent and viz smart falls back to its previous grain-parsing behavior β€” nothing breaks. The visible symptom is narrow: localized chart titles keep the old English-leak issue until you update your prompt (#4321). If you don't pin a prompt file, there is nothing to do.

--context-file for extra dataset context (new in 21.1.0)

Pass --context-file <file> to feed the LLM additional context about the dataset β€” provenance, domain terms, variable/code labels, column conventions, or anything the bare stats can't convey. The context sharpens labels, descriptions, and tags without touching the deterministic symbolic half, and is part of the cache key (keyed on file content), so editing it busts the describegpt cache; an empty (or unset) context file collapses back to the no-context cache key and renders {{ context }} as an empty string.

The file type is sniffed from its contents, not its extension (the same machinery the sniff command uses), and is handled two ways depending on what it is:

  • Text-extractable β€” plain text, Markdown, CSV, and Excel/ODS spreadsheets (the latter extracted to CSV) are inlined into the rendered prompts via the {{ context }} template variable.
  • Multimodal attachment β€” PDF and image files (JPEG/PNG/WebP/GIF) are base64-encoded and sent to the LLM as a multimodal content block. This needs a multimodal-capable model and endpoint (e.g. a vision/PDF-aware model); plain text-only models will reject or ignore it.

By default the context is injected into the user message, but a custom prompt-file template may reference {{ context }} anywhere to place it explicitly. Word/PowerPoint (.docx/.pptx) are not supported β€” convert them to PDF or text first. The whole attachment is capped at ~32 MB (the OpenAI per-request ceiling).

# Markdown notes inlined as {{ context }}
qsv describegpt sensor_readings.csv --all \
  --context-file dataset-notes.md \
  -u http://localhost:11434/v1 --model deepseek-r1:14b

# a PDF data dictionary / codebook sent as a multimodal attachment
qsv describegpt sensor_readings.csv --all \
  --context-file codebook.pdf \
  -u https://api.openai.com/v1 -m gpt-4o -k "$OPENAI_API_KEY"

Declare the columns you added. If you enriched the extract before describing it (a lookup, a join, a computed field), say so in the context file. Otherwise the gaps in those columns read as defects of the source data. Documented in the --context-file help since 24.0.0 #4690: on a CMS Open Payments extract with a ZIPβ†’county lookup, the description reported its 57 blank counties as "geographic data is incomplete". With this one line added, gemma-4-31b called them "Enrichment Gaps … not missing from the original CMS source" in 2 of 2 runs:

- County_FIPS / County_Name are NOT part of the CMS file. They were added afterwards by looking up
  each ZIP code in the Census 2020 ZCTA-to-county relationship file. A blank county means that ZIP
  had no county match; it is a gap in that enrichment step, not missing CMS data.

Content Types β€” semantic column labels (new in 20.1.0)

Pass --infer-content-type and describegpt asks the LLM to tag each column with a semantic Content Type from a curated 49-token vocabulary that covers people (first_name, last_name, full_name, job_title, …), addresses (building_number, street_name, street_address, city, state, state_abbr, zip_code, country, country_code, …), contact info (email, phone, username), technical identifiers (ip_address, ipv6_address, mac_address, uuid, url), payment (credit_card, currency_code, isbn), and more (industry, profession, license_plate, …). The full token list and the faker each token maps to lives in src/cmd/synthesize/faker_map.rs.

A Content Type column is appended to the emitted Data Dictionary. Those tags drive synthesize's realistic-fake generation but they're also useful on their own as auto-generated documentation.

Deterministic unique_id. Columns whose cardinality equals their row count (primary keys, surrogate keys, UUIDs, sequence numbers) are tagged unique_id directly by qsv, before the LLM ever sees them β€” so the label is 100 % reproducible and doesn't drift between LLM versions. New in 24.0.0: Float columns are exempt: a continuous measurement at full precision (a price, a duration, a coordinate) is all-unique as a matter of course, so it gets its content type, role and concept from the LLM like any other field β€” which still cannot assign unique_id itself (#4653).

qsv describegpt customers.csv --dictionary --infer-content-type --format JSON -o customers.dict.json

--two-pass cross-field refinement (new in 20.1.0)

A first-pass LLM call sees each column in isolation, which means it can mislabel "this is a 2-letter string column" without realizing the next column is a zip_code (making the first one a state_abbr). Pass --two-pass and describegpt runs a second LLM call that takes the whole first-pass Data Dictionary as JSON context, then refines each field's Label, Description, and (when --infer-content-type is set) Content Type with cross-field awareness. Roughly doubles dictionary LLM cost β€” opt in when accuracy matters more than throughput.

This is also the option that unlocks cross-column consistency in synthesize (a synthetic state_abbr will be a real US state abbreviation matched to the row's zip_code).

qsv describegpt customers.csv --all --two-pass --infer-content-type \
  -u http://localhost:11434/v1 --model deepseek-r1:14b

--markdown-template for customizable Markdown output (new in 20.1.0)

Override the Markdown layout that describegpt emits with a TOML file of MiniJinja templates. Six optional template fields β€” dictionary_md_template, description_md_template, tags_md_template, custom_prompt_md_template, the per-field dictionary_md_body_template that fills the dictionary wrapper's {{ llm_response }}, and semanticmd_md_body_template (the whole --format semanticmd document, added in 20.2.0). Any omitted field falls back to the embedded default, so a minimal TOML can override just the one template you want to change. Custom Jinja filters (pipe_escape, br_replace, human_count, dict_cell, humanize_examples) are pre-registered and documented inline in resources/describegpt_md_defaults.toml.

qsv describegpt customers.csv --all --markdown-template team-style.toml > customers-dictionary.md

Lower LLM cost in 20.1.0

The default description and tags prompts now inline {{ dictionary }} directly into the system prompt instead of re-sending the dictionary as a separate chat message. Measurably cuts token usage on multi-phase (--all) runs.

See also: /docs/help/describegpt.md, docs/Describegpt.md, Output gallery, Stats Cache & Caching, SQL & Polars, Claude Cowork Plugin, MCP Server, Cookbook β†’ Stats β†’ Insights, Cookbook β†’ Synthesize Fake Data.

synthesize

New in 20.1.0. Generate a statistically-faithful synthetic CSV from a real one β€” same value mix, same distribution shape, same null rate β€” without any of the original records. Useful for sharing test data, populating staging environments, building demos, and benchmarking pipelines without leaking real customer data.

synthesize runs stats and frequency against the source under the hood, then emits N rows that reproduce per-column attributes:

  • Categorical / low-cardinality columns are rebuilt by frequency-weighted sampling of the real value set β€” cardinality, weights, and repetition structure preserved exactly.
  • Numeric and date/datetime columns are reproduced with quartile buckets, so the shape of the distribution (not just min/max) is preserved.
  • Null ratios are matched per column.
  • Unstructured text columns that carry string-length stats (min/max/avg/stddev) get values truncated to the source's character-length distribution.

Layer in a Data Dictionary from describegpt --dictionary --infer-content-type --format JSON and each column's Content Type picks a realistic fake-rs faker β€” email, phone, street_address, uuid, etc. β€” for non-enumerable columns. --locale picks from 14 fake-rs locales (en, fr_fr, de_de, it_it, pt_br, pt_pt, ja_jp, zh_cn, zh_tw, ar_sa, cy_gb, fa_ir, nl_nl, tr_tr).

Important

Cross-column correlation is not modeled by default. Columns are generated independently. Use describegpt --two-pass to let the LLM detect related fields (e.g. city ↔ zip_code) and refine Content Types β€” synthesize will then keep those relationships consistent in the generated rows.

Example: pure statistical synthesis β€” no LLM needed

qsv synthesize data.csv -n 1000 --seed 42 > synthetic.csv

The --seed flag makes the output fully reproducible β€” same seed, same file, every time.

Example: realistic fakes via a Content-Type-tagged dictionary

# 1. Build the dictionary once (local LLM keeps the data on-device)
qsv describegpt customers.csv --dictionary --infer-content-type --format JSON \
  -u http://localhost:11434/v1 --model deepseek-r1:14b -o customers.dict.json

# 2. Synthesize 10,000 rows that mirror customers.csv's shape, with realistic fakes
qsv synthesize customers.csv --dictionary customers.dict.json \
  --locale en --seed 42 -n 10000 > customers-fake.csv

Example: one-shot β€” let synthesize build the dictionary itself

qsv synthesize data.csv --infer-content-type --seed 42 -n 5000 > synthetic.csv

Requires an LLM API key in QSV_LLM_APIKEY (or -u <local-endpoint> for a local LLM).

Example: stable source β†’ fake mapping (--consistent-fakes)

qsv synthesize customers.csv --dictionary customers.dict.json \
  --consistent-fakes --seed 42 -n 10000 > customers-fake.csv

For structured-faker columns with bounded cardinality, the same source value always produces the same fake β€” useful for deidentified synthesis where you want stable joins on the faked columns.

See also: /docs/help/synthesize.md, describegpt Content Types, Cookbook β†’ Synthesize Fake Data, Stats Cache & Caching, src/cmd/synthesize/faker_map.rs β€” Content Type β†’ faker mapping.

apply summarize

New in 21.1.0. A new apply subcommand that condenses a text column (or group of columns) into a new column using an OpenAI-compatible LLM β€” one HTTP call per row. Handy for distilling long free-text fields (support tickets, notes, abstracts, reviews) into short summaries you can scan, filter, or feed downstream.

Unlike the CPU-bound, Rayon-parallel apply subcommands, summarize is network-bound and sequential. The prompt is a MiniJinja template rendered per record β€” reference any column by its "safe" name (non-alphanumeric chars become _), with the 1-based row number available as {{QSV_ROWNO}}. If you omit --prompt/--prompt-file, a default prompt that summarizes the selected column/s is used.

The endpoint, model, and API key resolve with the precedence CLI flag > env var (QSV_LLM_BASE_URL / QSV_LLM_MODEL / QSV_LLM_APIKEY) > built-in default (default base URL http://localhost:1234/v1, default model openai/gpt-oss-20b) β€” the same conventions as synthesize and describegpt. An API key is required for non-localhost URLs (set --api-key NONE to suppress sending one).

Results are cached on disk (keyed by base-url, model, max-tokens, addl-props, and the rendered prompt) so repeated rows and re-runs don't re-pay for inference β€” --no-cache disables it, --fresh forces fresh calls that refresh the cache.

Example: summarize a column with a local LLM

qsv apply summarize support_ticket -c summary file.csv

Example: custom prompt across multiple columns, against a cloud provider

qsv apply summarize subject,body -c summary \
  --prompt 'Summarize: {{subject}} - {{body}}' \
  -u https://api.openai.com/v1 -m gpt-4o-mini -k "$OPENAI_API_KEY" file.csv

Example: also capture per-row inference time & token usage

qsv apply summarize notes -c summary --stats file.csv

Important

One HTTP call is made per uncached row, so summarizing large files can be slow and costly. The on-disk cache cushions re-runs, but plan accordingly for first passes over big inputs.

See also: apply, /docs/help/apply.md, describegpt.

Machine-readable command definitions

New in 24.0.0: every command describes itself (#4638). Agents and pipelines get the same truth humans do, straight from the binary they will run.

qsv stats --help --format json     # the command's JSON tool definition (same schema as the MCP skills)
qsv stats --help --format md       # its help Markdown, as in docs/help/
qsv --export-tool-definitions defs/  # both, for every installed command, plus manifest.json
  • The definitions are generated by the binary from its own usage text, so they always match the installed version and feature set β€” including commands outside the MCP skill set.
  • manifest.json records the qsv version, the binary name, its installed commands and which of them are in the MCP skill set.
  • Available in qsv and qsvmcp; neither needs the mcp feature, so the prebuilt qsv has both.
  • Worked example β€” gating a pipeline on manifest.json and reading definitions with jq: Integrations β†’ Agents & pipelines.

See also

⚠️ **GitHub.com Fallback** ⚠️