CPO-APPROVAL-REQUIRED: the masking policy is CPO-owned. The proposed policy in v1.4-candidates.md §3.2 T1-SEC-3 is adopted as the default: mask SSN, credit-card (Luhn-validated), IBAN, phone (E.164). The CPO can amend via a follow-up PR at any time. ---ci--- project: inboxos phase: 1 milestone: v1.4 status: execute wave: 3 task: T3 cpo_approval_required: true cpo_approval_status: proposed_policy_adopted_as_default ---/ci---
9.3 KiB
InboxOS — Extraction Contract (canonical)
Status: Phase 2 deliverable for INB-4 Scope: Single source of truth for how a raw email becomes a structured NocoDB record, end-to-end.
The contract, in one paragraph
For every email that the InboxOS pipelines process, the pipeline
calls pipelines.common.llm_run_command with the ollama backend
twice:
inboxos:classify— given the email as JSON on stdin, returns{"modules": ["finance", "crm", ...]}listing the modules that apply (or[]if none).- For each module in that list,
inboxos:extract-<module>— given the same email as JSON on stdin, returns a flat record matching the module's NocoDB schema (seedocs/schemas.md).
The pipeline then UPSERTs each module record into the corresponding
NocoDB table, keyed on Message_ID for idempotency.
This is the only extraction path. Anything that writes to a NocoDB module table MUST go through this contract.
Prompt files
| File | Role | Output shape |
|---|---|---|
.opencode/commands/inboxos:classify.md |
Route an email to modules | {"modules": [...]} |
.opencode/commands/inboxos:extract-finance.md |
Extract a finance record |
{"Direction": ..., "Counterparty": ..., ...} |
.opencode/commands/inboxos:extract-crm.md |
Extract a crm record |
{"Contact_Name": ..., "Contact_Email": ..., ...} |
.opencode/commands/inboxos:extract-intel.md |
Extract an intel record |
{"Source": ..., "Topic": ..., "Items": [...], ...} |
.opencode/commands/inboxos:extract-promotions.md |
Extract a promotions record |
{"Brand": ..., "Offer_Type": ..., "Worth_Saving": true, ...} |
All five use model ollama/gemma4:e4b (confirmed available at
https://ollama-frp.cloudinit.dev/api/tags). The canonical pin per
REQ-INB-11 is gemma4:e4b; the prompt frontmatter holds the
default model which the runtime can override via the
INBOXOS_LLM_MODEL env var (see pipelines/common.py:545-546).
The full field contracts for each module are listed in
docs/schemas.md. The prompts are the implementation; the schemas
doc is the spec.
Model spec
The production extraction path uses a single pinned Ollama model:
gemma4:e4b. This is the canonical pin per REQ-INB-11. There is no
model-escalation chain in the production path.
Each prompt's frontmatter declares model: ollama/gemma4:e4b. The
runtime reads INBOXOS_LLM_MODEL (default: gemma4:e4b) and uses
that as the authoritative model; the prompt frontmatter is the
fallback. The classifier and per-module extractors share the same
model — no escalation, no per-prompt overrides in production.
This is a deliberate CTO choice (lens: "Cost efficiency — zero-cost inference on local Ollama") and is the smallest model that, in our limited testing, has been observed to follow the strict-JSON contract on the modules we route to.
How the pipelines invoke the prompts
pipelines/historical_pipeline.py (canonical reference)
CLASSIFY_COMMAND = os.environ.get("INBOXOS_CLASSIFY_COMMAND", "inboxos:classify")
EXTRACT_COMMAND_PREFIX = os.environ.get("INBOXOS_EXTRACT_COMMAND_PREFIX", "inboxos:extract-")
def classify(llm_cfg, summary, logger):
result = llm_run_command(llm_cfg, CLASSIFY_COMMAND, summary, logger=logger)
modules = result.get("modules") or []
return [m.strip().lower() for m in modules if m.strip()]
def extract_for_module(llm_cfg, module, summary, papra_link, logger):
command_name = f"{EXTRACT_COMMAND_PREFIX}{module}"
result = llm_run_command(llm_cfg, command_name, summary, logger=logger)
if not isinstance(result, dict):
return None
result["Message_ID"] = summary["message_id"] # pipeline overwrites
if papra_link:
result["Papra_Link"] = papra_link
return result
# in process_one_message:
modules = classify(llm_cfg, summary, logger)
for module in modules:
table_id = module_tables.get(module) # from NOCODB_MODULE_TABLES env
if not table_id:
log_event(logger, "module_table_missing", module=module, ...)
continue
extracted = extract_for_module(llm_cfg, module, summary, papra_link, logger)
upsert_result = nocodb_upsert(nocodb_cfg, table_id, extracted, message_id)
LLMConfig is constructed from environment via
LLMConfig.from_env() (see pipelines/common.py:492-585). The
llm_run_command entry point dispatches to the configured backend —
default ollama, with the prompt loaded from INBOXOS_PROMPT_DIR
and parsed for frontmatter model: (see
pipelines/common.py::llm_run_command and _ollama_call_one_model,
lines 636-680).
The historical pipeline reads NOCODB_MODULE_TABLES from the
environment — a JSON dict like
{"finance":"tbl_xxx","crm":"tbl_yyy","intel":"tbl_zzz","promotions":"tbl_www"}.
The apply_schemas.py bootstrap script prints this value after
creating the tables.
pipelines/live_pipeline.py (aligned)
live_pipeline.py is wired to the canonical contract: it reads
NOCODB_MODULE_TABLES (JSON dict), calls inboxos:classify once
per message, then invokes inboxos:extract-<module> for each
matched module. The two pipelines share the same env var and the
same prompt family — there is no second contract.
Why "classify first, then extract" (not "one big extract all")
- Cost. A receipt email doesn't need to invoke the CRM, intel, and promotions extractors. Routing first lets us skip the irrelevant calls.
- Isolation. A bad prompt for one module doesn't fail the whole
email. The historical pipeline logs
extract_permanent_failurefor the one module and continues. - Observability. Per-module logs make it obvious which extractor is producing low-quality data.
- Prompt size. Each per-module prompt is small and focused, which gives the small local model (4B effective params) a better chance of following the strict-JSON contract.
A single "extract all" call would be cheaper to invoke (one round trip) but more expensive to operate (always pays the full LLM cost) and harder to debug. The CTO call is "classify first".
Prompt-as-code (governance)
Per CTO lens ("Prompt as code — extraction prompts are version-controlled in Gitea, not embedded in n8n workflows"):
- Prompts are
.mdfiles in.opencode/commands/. No prompt is embedded in a workflow.ymlor a Python string. - Changes go through git: open a PR, get a review, merge. Bad changes are reverted by reverting the commit. UPSERT semantics mean a bad row is overwritten by the next run with the reverted prompt, so no data is permanently corrupted.
- The strict-JSON contract is enforced by the prompt's hard-rules
section. The Python wrapper
llm_run_command/_ollama_call_one_modelinpipelines/common.py(lines 605-624) parses the model's reply withjson.JSONDecoder.raw_decodeand fails loudly if the first non-whitespace token is not valid JSON. There is no "best effort" path.
Legacy: opencode CLI backend
INBOXOS_LLM_BACKEND=opencode is kept for offline tests only.
The opencode binary install step in historical-pipeline.yml is
retained for any human-driven opencode invocation from the repo
worktree; the pipeline itself does not shell out to it. The
opencode backend in pipelines/common.py:440-487 is documented
as broken on opencode 1.16.2 and is not exercised in production or
in CI. Use the ollama backend (the default) for production.
See docs/operations-runbook.md §6.3 for the data-sovereignty
posture (local LLM only).
Failure semantics
| Failure | What the pipeline does | Operator action |
|---|---|---|
Ollama classify returns invalid JSON or modules: [] |
.eml is dead-lettered to eml/dead-letter/<mid>.json with the failure reason; cursor advances. |
Inspect the prompt output; fix the prompt. |
| Ollama extract returns invalid JSON for one module | Run logs extract_permanent_failure for that module, stops without advancing offset, retries next run. |
Inspect the prompt output; fix the prompt. |
| Ollama extract returns a record that does not match the table | NocoDB rejects the row (column error). Run stops; offset does not advance. | Add the column to the schema (apply_schemas.py), retry. |
| NocoDB is down / 5xx | Tenacity retries with exponential backoff. Permanent failure stops the run. | Restore NocoDB; re-run. |
PII masking (v1.4 REQ-INB-57)
Before the email body is sent to Ollama, pipelines/pii_scrub.py::scrub_pii() applies 4 regex-based maskers in sequence:
- SSN:
\b\d{3}-\d{2}-\d{4}\b→[SSN-MASKED] - Credit card (Luhn-validated):
\b(?:\d[ -]*?){13,19}\bwhere the digit sequence passes the Luhn check →[CC-MASKED]. Non-CC digit sequences (e.g., order numbers) are not masked. - IBAN: per-country regex (
[A-Z]{2}\d{2}[A-Z0-9]{1,30}) →[IBAN-MASKED] - Phone (E.164):
\+[1-9]\d{1,14}\b→[PHONE-MASKED]
The masked body is what Ollama sees. The raw body is never sent to the LLM.
The audit trail is per-message: a PIIMaskSummary dataclass records the count of each PII type masked, and the summary is emitted to _artifacts/pii_scrub.jsonl (operator-local state, .gitignore-d).
The masking is conservative: false positives are preferred over false negatives. The Luhn check on credit cards ensures non-CC digit sequences are not masked. The CPO can amend the masking policy (which PII types to mask, which to preserve for extraction) at any time via a follow-up PR without changing the LLM contract.