Data pipeline
DATA.1 — point werbos at a directory, get a JSONL training corpus. Markdown, code, plaintext; heuristic or LLM-generated Q&A; eight secret patterns stripped by default; idempotent on re-run.
werbos data is the front door of the training story. Same
code path whether the input is a personal notes folder, a company
docs export, or a 10-GB GitHub repo clone.
The output is chat-format JSONL ready for HF PEFT / TRL / any
QLoRA trainer that expects {"messages": [...]}.
Sprint DATA.1 shipped this end-to-end on 2026-05-31. The short-form version is in How to train; this page is the deep dive.
TL;DR
# Walk a directory, write training rows
werbos data ingest ~/docs --out corpus.jsonl
# What's in there?
werbos data stats corpus.jsonl
# Eyeball a few rows
werbos data sample corpus.jsonl --n 5
What gets read
| Extension | Reader | Default strategy |
|---|---|---|
.md / .mdx / .markdown | markdown (heading-tree) | heuristic |
.py | Python (docstring + signature) | heuristic |
.ts / .tsx / .js / .jsx | JSDoc | heuristic |
.go | Go (// doc-comment) | heuristic |
.txt / .log / .text | plaintext (paragraph chunker) | heuristic |
anything, with --strategy llm_qa | reader by extension → local LLM generates Q&A | llm_qa |
Files with no registered reader are walked but skipped (PDF, HTML, Slack exports, Notion exports — those land in DATA.1.1+ when customer demand appears).
The markdown reader strips YAML frontmatter, walks the heading tree, and emits one chunk per (heading, body) pair. Bodies under 40 characters are skipped (noise). Bodies over 4000 characters split on paragraph boundaries so the resulting chunks fit the trainer’s context window.
The code readers extract documented entities only:
- Python: top-level + class-level
def/classwith a triple-quoted docstring →"What is def foo() for?"→ the docstring - JSDoc:
/** ... */ function|class|const foo→ docs as body, declaration as heading - Go:
//comment runs preceding afunc/type/vardeclaration → comments as body, identifier as heading
Undocumented code is intentionally skipped. Training on code without comments produces noisy rows the model can’t learn from.
Strategies
heuristic (default — fast, no LLM)
Turns structured input into Q&A pairs deterministically. The prompt template is language-aware:
- Markdown: heading becomes a question; body becomes the answer. Template chosen by content-hash so re-ingesting the same chunk produces the same prompt (idempotency).
- Code: signature is the heading; docstring is the body.
Asks “what is
def foo()for?” / “describe the behavior ofdef bar()” etc. - Plain text: first sentence is a pseudo-heading; falls back to “summarize the following” for headingless bodies.
No LLM call. Idempotent on re-ingest. Suitable when the input data is already shaped like a knowledge base.
llm_qa (slow, broader coverage)
For unstructured prose where heuristic has nothing to grip on (personal notes, slack threads, meeting notes, legacy docs without headings):
- Hash the chunk + model + prompt version.
- Check
~/werbos/llm_qa_cache.json. - On miss, send the chunk to your local Ollama with a fixed system prompt asking for N=3 Q/A pairs.
- Parse the response.
- Cache the result.
- Emit one TrainingRow per Q&A pair.
Privacy invariant: chunk text never leaves the box. Same SSRF guard as every other werbos LLM call. The cache and the output JSONL stay local.
Idempotency invariant: same chunk + same model + same
prompt version = same rows on re-ingest. Blow away
~/werbos/llm_qa_cache.json to force regeneration.
both
Runs heuristic and llm_qa over every chunk. Useful when you
want the deterministic baseline plus the broader coverage —
sink dedup means redundant rows collapse via row_id.
Filtering
Secret detection
Eight patterns ship in v1, all dropping the whole row when matched (prompt OR response):
| Pattern | Catches |
|---|---|
github_token | ghp_* / gho_* / ghu_* / ghr_* / ghs_* |
openai_key | sk-* / sk-proj-* |
anthropic_key | sk-ant-* |
aws_key | AKIA* / ASIA* (16 base32) |
jwt | three-segment base64url eyJ*.eyJ*.* |
env_secret | KEY=value shape with credential-ish KEY name |
operator_path | ~/.ssh/* / ~/Library/Keychains/* / ~/.gnupg/* |
private_key_pem | -----BEGIN (RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY----- |
werbos data stats reports the per-pattern drop count so you
can see what got filtered.
Disable with --no-filter-secrets (verified-clean corpora
only).
Length cap
Rows whose prompt + response > 16,000 characters are dropped.
The trainer’s tokenizer caps at ~4096 tokens; rows past this
just get truncated mid-content anyway.
Dedup
Two layers:
- In-memory
row_idset during a single run — same content produced by the same strategy hashes to the samerow_id. - Pre-existing-file scan at sink open time. Re-running
werbos data ingestover the same corpus loads the existingrow_idvalues from the output JSONL and skips them. Result: re-ingest is a true no-op even after a process restart.
CLI reference
werbos data ingest <path> [options]
| Flag | Default | Notes |
|---|---|---|
--out PATH | corpus.jsonl | output JSONL path |
--strategy {heuristic,llm_qa,both} | heuristic | extractor |
--include PATTERN | (none) | glob, repeatable |
--exclude PATTERN | (none) | glob, repeatable |
--max-files N | 10000 | safety cap on walked files |
--no-filter-secrets | OFF | disable secret regex pass |
--strip-meta | OFF | omit _meta block from rows |
--dry-run | OFF | walk + report counts, write nothing |
werbos data stats <corpus.jsonl>
Prints row count, per-strategy histogram, prompt/response length distribution (min/median/max/mean), top 10 sources by row count.
werbos data sample <corpus.jsonl> [--n N]
Pretty-prints N sample rows for eyeball verification.
→ Split + score commands are documented in eval suite.
Output schema
Locked v1 contract (schema_version: "corpus/1"):
{
"messages": [
{"role": "user", "content": "What is X?"},
{"role": "assistant", "content": "X is Y."}
],
"_meta": {
"schema_version": "corpus/1",
"source": "/path/to/source.md",
"strategy": "heuristic",
"chunk_id": "sha256:...",
"row_id": "sha256:..."
}
}
Pass --strip-meta to omit _meta (some trainers reject extra
fields). Keep meta by default so werbos data stats and
re-ingest dedup work.
Schema versioning
CORPUS_SCHEMA_VERSION = "corpus/1" is stamped on every row.
Bumping it would break v1 readers, so the versioning rules are:
- Additive optional fields are safe within v1.x. A new
_meta.tagsfield appended tomorrow still loads in v1 readers — they just ignore it. - Renaming, retyping, removing, or changing semantics ships
a sibling
corpus/2module. v1 stays untouched. A future trainer dispatches on_meta.schema_versionto pick the right reader. - The
IntentKindof the extractor (strategyfield) is open-ended on purpose. New strategies land as new values without bumping the schema.
Same discipline as parsetongue, precook, glasshouse.
What does NOT ship in v1
- PDF / HTML / Slack / Notion / GitHub-clone readers — DATA.1.1+ if customer demand appears.
- Embedding / semantic dedup — exact-hash only in v1.
- Quality scoring — flagging boilerplate / legal headers / license blocks is DATA.3.
- Streaming / resumable ingest — runs to completion in one process; DATA.4 if corpus size demands it.
- PII detection beyond secrets — names / emails / phone numbers in personal corpus. v1 trusts the operator’s judgment on what to feed in.
Architecture notes
werbos.data
├── schema.py TrainingRow / Chunk / IngestStats
├── ingest.py walk → read → extract → filter → sink
├── readers/
│ ├── markdown.py heading-tree chunker
│ ├── code.py Python docstring + JSDoc + Go doc-comment
│ └── plaintext.py paragraph chunker
├── extractors/
│ ├── heuristic.py language-aware Q&A template (deterministic)
│ └── llm_qa.py local Ollama Q&A pair generation
├── filters.py 8 secret regexes + length cap + dedup
└── sink.py JsonlSink (append, dedup, idempotent reload)
Sprint doc: docs/sprints/data1-corpus-ingest.md in the werbos
repo. 97 tests, all green on commit.
Next
- Eval suite — the other side of the pipeline: split corpus, score model, measure noise floor
- How to train — the integrated story