-
Notifications
You must be signed in to change notification settings - Fork 0
ingest architecture
Turn large personal data exports into stable, reviewable source materials without pretending every file is the same thing.
The ingest pipeline is archive-first and source-family-aware.
For every ingest run:
- preserve the raw archive or source file unchanged
- discover every ingestable material inside it
- classify each material into a source family
- record that decision in a manifest under
state/manifests/ - only then parse, extract evidence, and update the graph
The rendered graph is not the source of truth during ingest.
The durable source of truth is state/catalog.sqlite, especially:
-
sourcesfor imported source metadata -
source_spansfor parsed source offsets -
evidence_itemsfor model-extracted evidence tied to one source -
extraction_checkpointsfor resumable chunk extraction inside one source - graph tables for the latest rendered projection over stored evidence
This means extraction and graph scoring are intentionally separate phases:
- A material is parsed into spans.
- Materials are kept whole when they fit the extraction character budget; only oversized materials are split into chunks.
- Each chunk is sent to the extractor with only that source context.
- The extractor emits evidence records, not skill levels.
- Evidence records are stored with source ID, span offsets, quotes, candidate skills, evidence type, confidence, and time references.
- Graph recompute loads all stored evidence, groups it by candidate skill, canonicalizes against the existing catalog, and scores the affected skill state from the merged evidence history.
The system must not compress a batch of materials into one summary and then infer skills from that summary. Batch boundaries are allowed for scheduling graph refreshes, but not for replacing per-source evidence.
Agent logs have an additional attribution rule. Parsed artifacts keep user, assistant, tool, system, and thinking spans for auditability, but evidence extraction receives only user/human/developer spans. This prevents model outputs, tool logs, or hidden reasoning transcripts from being re-attributed as the user's own skills.
Long-running graph scoring can run from a stable evidence snapshot while another process prepares more input. The safe concurrent path is traccia stage-dir, which does discovery, source-family detection, raw import linking, parsing, source/span storage, manifests, and progress checkpoints only.
stage-dir intentionally does not call the LLM extractor, does not recompute the graph, and does not run deletion sync. Prepared materials are marked as prepared in the ingest run state, so a later normal traccia ingest-dir can resume from them and perform evidence extraction plus graph sync. This keeps the active scorer from competing with a second LLM caller while still allowing slow filesystem discovery and parsing work to advance.
For already-known sources, stage-dir trusts the existing parsed artifact instead of rehashing remote files. This makes it useful for mounted folders where the likely goal is to find new material without spending hours revalidating every old file through FUSE.
During discovery, state/progress.json reports both seen_this_scan and the split between already_tracked and new_to_run_state. seen_this_scan is not a claim that those files are new; it is only the current filesystem walk count.
Use this mode when an existing scoring process was already started before the newer coordination code was available. Future worker orchestration can add a shared LLM lease, but old already-running processes cannot acquire a lock that did not exist when they were started.
Full extraction can also run beside graph scoring if every runner has been started with the shared LLM lease code. In that mode, use normal ingest with graph and deletion sync disabled:
traccia ingest-dir /path/to/archive --project-root my-traccia --no-sync-graph --no-sync-deletionsThe LLM lease coordinates extractor, canonicalizer, and scorer requests through TRACCIA_LLM_LEASE_PATH or /tmp/traccia-llm-request.lock. Canonicalizer and serial scorer requests acquire the lease exclusively. Extractor requests and parallel skill scorer requests acquire shared slots when their worker counts are above 1. Ingest can still parse files and write source/evidence records while scoring owns graph table writes. Newly extracted evidence is durable immediately, but it is not reflected in the rendered graph until the next graph scoring pass includes it.
Graph scoring has two modes:
| mode | behavior |
|---|---|
incremental |
default; reuse unchanged candidate canonicalization and final skill score caches |
full |
bypass graph scoring caches and rerun canonicalization/scoring from stored evidence |
Use incremental for normal archive growth:
traccia score --mode incremental --project-root my-tracciaUse full when auditing, repairing, or intentionally changing scoring semantics:
traccia score --mode full --project-root my-tracciaSkill scoring supports bounded internal parallelism:
traccia score --mode incremental --project-root my-traccia --parallel-scores 4The config default is:
graph_scoring:
parallel_scores: 1This pool is deliberately narrower than running multiple scorer processes. The
parent process still owns canonicalization, cache writes, progress files, graph
checkpoints, and final graph replacement. Worker threads only call score_skill
for cache-missing skills and return strict score payloads to the parent. Because
each score request contains only one skill plus that skill's merged evidence
history, parallelism changes scheduling and quota burn rate, not the information
available to the scoring model.
Directory ingest also accepts --score-mode incremental, --score-mode full, or
--score-mode none. none stores source/evidence records and leaves graph
projection refresh for a later traccia score run.
Incremental scoring is not "only brand-new candidate names." It means only changed graph facts. If a new source adds evidence to an existing skill, that skill is rescored once from the merged evidence history. If a source introduces a new candidate skill, the new candidate is canonicalized and the resulting skill is scored. Unchanged candidate decisions and unchanged final skill evidence fingerprints are reused from SQLite caches.
Scoring progress is separate from ingest progress:
| file | meaning |
|---|---|
state/progress.json |
current directory/material ingest state |
state/graph-score-progress.json |
current graph scoring mode, cache hit/miss counts, changed work, and active candidate/skill |
state/graph-score-runs.jsonl |
append-only scoring run event stream keyed by run ID, model, score mode, progress, cache stats, and final totals |
state/catalog.sqlite:pipeline_runs |
one SQLite summary row per graph scoring run |
The rendered graph remains a projection over state/catalog.sqlite. A crash during
scoring does not delete source evidence or completed score cache rows.
Directory ingest also supports bounded parallel extraction inside one ingest process:
traccia ingest-dir /path/to/archive --project-root my-traccia --parallel-extractions 4The same value can live in config as a fallback when no lane pool is configured:
ingest:
parallel_extractions: 4The default is 1. Values above 1 only parallelize evidence extraction for separate materials. Discovery, resume decisions, manifest writes, progress writes, deletion sync, live graph checkpoints, and final graph recompute are still parent-side serialized. Workers use separate pipeline/backend/storage handles, then return material outcomes to the parent process. This avoids sharing one backend client or SQLite connection across threads, and it keeps graph scoring from racing against active evidence writes.
Parallel extraction is intentionally batched. A graph checkpoint may run between completed worker batches, but never while workers are still writing parsed spans or evidence. This keeps the per-source extraction contract intact while allowing operators to spend more available model quota when the backend and machine can handle it.
For split-provider extraction, configure backend.extraction_backends instead of only
raising ingest.parallel_extractions. The lane pool can mix providers, but the graph
canonicalizer and scorer still stay on the primary backend:
backend:
provider: openai_compatible
model: glm-5-turbo
api_key_env: CLIPROXYAPI_KEY
base_url: http://127.0.0.1:8317/v1
api_style: chat_completions
structured_output_mode: json_schema
extraction_backends:
- name: glm
model: glm-5-turbo
api_key_env: CLIPROXYAPI_KEY
base_url: http://127.0.0.1:8317/v1
parallel_extractions: 5
- name: gemini
model: star-gemini-3.5-flash
api_key_env: CLIPROXYAPI_KEY
base_url: http://127.0.0.1:8317/v1
supports_vision: true
parallel_extractions: 3In that configuration the effective extraction pool is 8, but it is still one pipeline with one resumable source state, not two independent ingest jobs.
Targeted subfolder ingests should use --import-prefix when the source identity must remain anchored to a larger archive path. For example, ingesting only rrss-data/08042026/Twitter should still use the import prefix rrss-data/08042026/Twitter; otherwise the same files would be reidentified under a shorter twitter/... prefix. The same rule disambiguates multiple account exports that both end in extracted/Takeout.
Linked media belongs to the parent source context. If a tweet, post, message, or export row references local media, the parser keeps attachment metadata on the same parsed document instead of creating an unrelated source.
Image attachments have an additional extraction rule:
- If the active backend is vision-capable (
multimodal.enable_vision=trueandbackend.supports_vision=true), Traccia may send the resolved image bytes alongside the text payload. - If the active backend is not vision-capable, Traccia must mark the material as
delayedinstead of extracting it with text alone. - A later run with a vision-capable backend, for example
star-gemini-3-flashthrough an OpenAI-style proxy, must retry delayed materials from the same resumable run state.
This prevents silent quality loss. OCR text, alt text, filenames, and contextual hints are useful, but they are not equivalent to a vision model inspecting the image in context.
Remote media URLs also stay attached to the parent source. If a Google Takeout activity row, playlist entry, comment, tweet, note, chat log, or other source mentions a YouTube or direct audio/video URL, Traccia stores the original record and adds recovered media context as attachment text.
The default remote media lane is high-context rather than transcript-only:
multimodal:
enable_remote_media_enrichment: true
remote_media_enrichment_command: summarize
remote_media_enrichment_video_mode: understand
enable_remote_media_slides: true
enable_remote_media_slides_ocr: trueThat runs summarize --extract --video-mode understand --slides --slides-ocr for eligible remote media. The intent is to recover topics that are often absent from weak metadata: CAD, Blender, machining, code walkthroughs, design lectures, diagrams, and slide-heavy tutorials.
The default lane requires more than the summarize binary. Full YouTube visual enrichment needs ffmpeg, yt-dlp, and tesseract for OCR. If captions are unavailable and summarize falls back to audio transcription, it also needs either a supported transcription API key (GROQ_API_KEY, ASSEMBLYAI_API_KEY, GEMINI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, GOOGLE_API_KEY, OPENAI_API_KEY, or FAL_KEY) or a local whisper.cpp setup available to summarize. Local whisper.cpp requires both the whisper-cli binary, or SUMMARIZE_WHISPER_CPP_BINARY, and a model file. summarize checks SUMMARIZE_WHISPER_CPP_MODEL_PATH first and otherwise defaults to ~/.summarize/cache/whisper-cpp/models/ggml-base.bin. traccia doctor reports this as status=ready, status=degraded, or status=disabled without failing normal doctor checks.
Operators can set remote_media_enrichment_video_mode: transcript plus both slide flags to false for a faster transcript-only lane, or set enable_remote_media_enrichment: false to keep only the original URLs.
Directory ingest checkpoints raw progress after every material, so a crash, quota limit, or mount failure does not require starting from zero.
Live graph refreshes are a separate operator-facing projection. They are disabled by default because full graph scoring can dominate multi-hour archive ingests. Directory ingest still checkpoints raw progress and extraction checkpoints after each material/chunk, then runs one full graph sync at final completion.
If live checkpoints are explicitly enabled, large runs batch them by graph_refresh.live_checkpoint_material_interval and graph_refresh.live_checkpoint_min_interval_seconds. The material interval is counted against newly extracted evidence items, not merely files that were parsed. Files that produce no evidence still checkpoint raw progress, but they do not trigger an expensive graph rescore by themselves.
graph_refresh:
live_checkpoints_enabled: false
live_checkpoint_material_interval: 25
live_checkpoint_min_interval_seconds: 1800.0
small_run_live_checkpoint_material_limit: 10For small runs, enabling live checkpoints can refresh the graph immediately after new evidence because the overhead is low and fast visual feedback is useful. For large archive runs, the default final-only graph sync avoids repeatedly rescoring a nearly identical graph while preserving per-material evidence and resumability.
Changing graph refresh cadence must not change final output semantics. The final graph sync still recomputes from all stored evidence.
Current families:
| family | intent | examples |
|---|---|---|
generic |
fallback path for ordinary text-like files | markdown, txt, code, loose csv/json |
google_takeout |
Google export bundles with product-specific subtrees |
Takeout/, archive_browser.html, My Activity/
|
discord_data_package |
Discord personal data packages |
account/user.json, messages/index.json, servers/index.json
|
twitter_archive |
X/Twitter account archives |
data/account.js, data/tweets.js, Your archive.html
|
reddit_export |
Reddit account export layouts |
comments.csv, posts.csv, conversations.json
|
instagram_export |
Instagram export bundles and Meta account downloads scoped to Instagram |
Instagram/, profile_information/, followers_and_following/
|
facebook_export |
Facebook export bundles and Meta account downloads scoped to Facebook |
Facebook/, your_facebook_activity/, profile_information/
|
Family detection is heuristic and intentionally explicit. Unknown layouts fall back to generic instead of silently being forced into a wrong adapter.
Google Takeout is a high-noise export family. A useful ingest should not spend quota on every file simply because it exists in the archive. The discovery layer applies a product-aware skip policy before parsing:
| area | ingested | skipped |
|---|---|---|
Gmail / Correo
|
message headers, dates, subjects, labels, sent-message body snippets | received-message bodies by default, user settings exports |
| YouTube / YouTube Music | history, comments, subscriptions, metadata, URLs, transcript enrichment when available | raw uploaded video binaries |
Google Photos / Google Fotos
|
deterministic fast-vision image samples paired with sidecar metadata | standalone sidecar JSON, non-image bulk media |
| Drive | selected document, code, notebook, spreadsheet, calendar, and text formats | archives, caches, game/runtime data, opaque media, unsupported binaries |
| account and device products | nothing by default | account, payment, device, profile, alert, and product-survey metadata |
The policy lives in src/traccia/google_takeout.py instead of in prompts. This keeps discovery deterministic, testable, and inspectable before any model call happens.
Google Photos sidecars are intentionally paired with sampled image materials. The source text records the sidecar metadata, and the parsed source carries the image as an attachment. If the active backend is not vision-capable, the material is marked delayed so a later run with multimodal.enable_vision: true and backend.supports_vision: true can process the same image context.
The default configuration is:
google_takeout:
relevance_mode: skill_relevant
gmail_mode: metadata_plus_sent
youtube_enrichment: detailed
photos_mode: fast_vision
drive_mode: selective_docs
max_photo_vision_samples_per_folder: 8This is a relevance filter, not a claim that skipped files have no personal meaning. The contract is narrower: skipped files have low expected skill-evidence value relative to their parsing cost, privacy risk, or redundancy.
Supported mode values:
| key | values |
|---|---|
relevance_mode |
skill_relevant, off
|
gmail_mode |
metadata_plus_sent, off
|
photos_mode |
fast_vision, off
|
drive_mode |
selective_docs, all, off
|
Each ingest run writes one manifest file:
{
"manifest_id": "ingest_rrss_data_ab12cd34ef",
"root_uri": "file:///data/personal-archive",
"generated_at": "2026-04-19T12:00:00+00:00",
"materials": [
{
"relative_import_path": "personal-archive/reddit-export/comments.csv",
"source_path": "/mnt/archive/personal-archive/reddit-export.zip",
"archive_member": "comments.csv",
"source_family": "reddit_export",
"source_family_subproduct": "comments",
"detection_reason": "matched Reddit export marker: comments.csv",
"status": "processed",
"source_id": "src_comments_a1b2c3"
}
]
}This is the run contract for large exports. If parsing or scoring changes later, the manifest still shows:
- what was discovered
- where it came from
- what family it was treated as
- whether it was processed, skipped, delayed, or failed
Discovery summaries and state/progress.json also keep a by_family_subproduct breakdown so a long-running scan can be checked at a more useful granularity than just top-level families.
Source-family adapters are now part of the ingest path for known messy export shapes.
Intended flow:
discoverdetect familyexpand containernormalize into family-specific recordschunk normalized recordsrun LLM extraction on normalized chunks
Currently wired adapters:
| family | adapter |
|---|---|
google_takeout |
export HTML cleanup plus contextual text wrapping for loose text files |
instagram_export / facebook_export
|
HTML export cleanup that drops CSS boilerplate and keeps the visible conversation or activity text |
twitter_archive |
window.YTD... JavaScript payload normalization into record-oriented text |
reddit_export |
CSV row normalization into record-oriented text with timestamps and subreddit context |
That is the line between "can ingest big exports" and "can ingest big exports reliably".
Known export families are first-class lanes. Everything else still flows through the generic file-by-file path instead of being rejected.
Document-like files are a separate concern from archive-family detection.
For pdf and docx materials:
- normalize before chunking
- prefer markdown-preserving local conversion over plain-text extraction
- default to a local-first provider order, with PDFs preferring
marker -> docling -> markitdown -> nativeand DOCX preferringdocling -> markitdown -> native - keep OCR as a separate provider choice, not an OpenAI-bound plugin inside the normalizer
- prefer free/local OCR engines such as Docling auto OCR, Tesseract, EasyOCR, or RapidOCR when OCR is needed
The chosen normalizer and OCR usage belong in source metadata so later review can distinguish plain extraction from LLM-assisted document recovery.
- no fake universal binary parser
- no corpus-wide LLM pass over raw archives
- no archive-family guesses hidden inside prompts
- no direct dependency on provider HTML pages when structured files exist