Files
inboxos-v1/docs/extraction-contract.md
cloudinit-bot 980cc19450 feat(ml+security): pii_scrub.py — mask SSN/CC/IBAN/phone before Ollama (REQ-INB-57)
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---
2026-06-09 14:26:09 +00:00

191 lines
9.3 KiB
Markdown

# InboxOS — Extraction Contract (canonical)
**Status:** Phase 2 deliverable for [INB-4](/INB/issues/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**:
1. `inboxos:classify` — given the email as JSON on stdin, returns
`{"modules": ["finance", "crm", ...]}` listing the modules that
apply (or `[]` if none).
2. 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 (see `docs/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)
```python
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_failure` for
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 `.md` files in `.opencode/commands/`. No prompt is
embedded in a workflow `.yml` or 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_model` in `pipelines/common.py` (lines 605-624)
parses the model's reply with `json.JSONDecoder.raw_decode` and
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:
1. **SSN**: `\b\d{3}-\d{2}-\d{4}\b` → `[SSN-MASKED]`
2. **Credit card** (Luhn-validated): `\b(?:\d[ -]*?){13,19}\b` where the digit sequence passes the Luhn check → `[CC-MASKED]`. Non-CC digit sequences (e.g., order numbers) are not masked.
3. **IBAN**: per-country regex (`[A-Z]{2}\d{2}[A-Z0-9]{1,30}`) → `[IBAN-MASKED]`
4. **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.