Files
cloudinit-bot 37d21fb430 feat(nocodb): 4 curated views per module + docs (REQ-INB-41)
---ci---
project: inboxos
phase: 1
milestone: v1.4
status: execute
wave: 2
task: T2
---/ci---
2026-06-09 14:16:36 +00:00

248 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# InboxOS — NocoDB Schema Design
**Status:** Phase 2 deliverable for [INB-4](/INB/issues/INB-4)
**Applies to:** NocoDB project at `https://noco.cloudinit.dev`
**Pipeline contract:** `pipelines/common.py::nocodb_upsert`, `pipelines/historical_pipeline.py` (canonical), and the OpenCode prompts under `.opencode/commands/inboxos:*.md`
This document is the **source of truth** for the four module tables in
NocoDB. The schemas are intentionally narrow: every column is either
written by one of the pipelines (after extraction) or computed from
the extraction JSON. No column is allowed to be filled by hand in
NocoDB's spreadsheet UI in production — the pipelines are the only
writer.
The live bootstrap script `scripts/apply_schemas.py` is idempotent
and creates these tables + columns + unique constraint on
`Message_ID` exactly as specified here. Re-running it on an
already-bootstrapped project is a no-op.
## Cross-cutting invariants
| Invariant | Why | How it is enforced |
| --- | --- | --- |
| `Message_ID` is the **natural primary key** for every module table. | The pipeline is retried; the same email may arrive multiple times. A stable natural key is the only way to make upserts idempotent. | `UNIQUE` constraint on `Message_ID` in every module table. `nocodb_upsert` in `pipelines/common.py` does a GET-then-PATCH/POST keyed on `Message_ID`. |
| Both pipelines write to the same tables. | The historical pipeline backfills the same data the live pipeline ingests, into the same tables. Joins across modules are performed at query time by `Message_ID`. | The schema includes the union of columns written by either pipeline; all optional. |
| The LLM extraction is **strict JSON**, gated by `inboxos:classify`. | Routing first lets us skip irrelevant modules (a receipt email doesn't waste inference on CRM extraction) and isolates per-module failures. | `.opencode/commands/inboxos:classify.md` returns `{"modules": [...]}`; `.opencode/commands/inboxos:extract-<module>.md` returns a flat record per module. |
| Module tables **never cross-reference** by foreign key. | The same email can simultaneously be a Finance record, a CRM record, and an Intel record. Cross-keys via `Message_ID` only. | All FK-style joins are done at query time by `Message_ID`. |
## Common columns (every module table)
Every module table has the same set of "audit trail" columns that the
pipelines write, in addition to the module-specific fields the LLM
produces. All are nullable so the same table can be written by
either pipeline.
| Column | Type | Written by | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | both pipelines | Natural key, stripped of angle brackets. |
| `Date` | DateTime | live (email Date header) | The live pipeline sets this to the email `Date:` header. The historical pipeline currently does not set it — to keep both pipelines writing to the same table, the column is nullable. A future schema migration can backfill from the LLM-extracted per-module date if needed. |
| `Raw_S3_Link` | URL | live | `s3://…` of the raw .eml in SeaweedFS. |
| `Papra_Link` | URL | historical (when attachments exist) | URL of the first Papra document for the email, or null. |
| `Extracted_At` | DateTime | both | Set to `now_iso()` at upsert time. Used to detect stale rows. |
## Module tables
There are exactly four module tables, one per category. The table
name in NocoDB equals the `module_name` the pipeline uses in
`NOCODB_MODULE_TABLES`. Keep them stable.
### `finance` — Finance / P&L
Receipts, invoices, bank transactions, credit-card statements,
subscription renewals, refunds, fees. Anything that affects the
personal P&L.
Field contract mirrors `.opencode/commands/inboxos:extract-finance.md`.
| Column | Type | Source | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
| `Direction` | SingleSelect | LLM | `income` / `expense` / `refund` / `transfer` / `unknown`. |
| `Counterparty` | SingleLineText | LLM | Vendor / customer / payer / payee name. |
| `Amount` | Decimal | LLM | Numeric; positive. |
| `Currency` | SingleLineText | LLM | ISO-4217, uppercase. `null` unless explicitly priced. |
| `Transaction_Date` | Date | LLM | Date of the transaction (not the email date), `YYYY-MM-DD`. |
| `Category` | SingleLineText | LLM | Free-form but stable tag: e.g. `software`, `payroll`, `travel`, `tax`, `marketing`, `cloud`. |
| `Reference` | SingleLineText | LLM | Invoice / receipt / order / transaction ID. |
| `Notes` | LongText | LLM | One-sentence human summary, ≤ ~200 chars. |
### `crm` — Personal CRM
Any person-to-person email worth remembering: networking,
recruiters, friends, vendors, prospects, customer support threads.
Captures the relationship, not just the message.
Field contract mirrors `.opencode/commands/inboxos:extract-crm.md`.
| Column | Type | Source | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
| `Contact_Name` | SingleLineText | LLM | Best guess at full name. |
| `Contact_Email` | Email | LLM | Single email address (not a list). |
| `Organization` | SingleLineText | LLM | Company / org. |
| `Role` | SingleLineText | LLM | Title or role. |
| `Channel` | SingleSelect | LLM | `inbound` / `outbound` / `intro` / `reply` / `scheduling` / `other`. |
| `Intent` | SingleSelect | LLM | `sales` / `partnership` / `recruiting` / `support` / `social` / `intro` / `other`. |
| `Last_Contacted` | Date | LLM | `YYYY-MM-DD`. |
| `Summary` | LongText | LLM | One-sentence human summary, ≤ ~200 chars. |
| `Action_Needed` | LongText | LLM | Short string describing the next action, or null. |
### `intel` — Daily News / Intel
Newsletters, market briefings, security digests, product news, RSS
digests. The point is to be able to search "what did I read about X
last month" without trawling Gmail.
Field contract mirrors `.opencode/commands/inboxos:extract-intel.md`.
| Column | Type | Source | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
| `Source` | SingleLineText | LLM | Publication / newsletter name. |
| `Topic` | SingleSelect | LLM | `markets` / `ai` / `security` / `industry` / `policy` / `tech` / `other`. |
| `Headline` | LongText | LLM | Single most important headline. |
| `Summary` | LongText | LLM | 1–3 sentence digest, ≤ ~600 chars. |
| `Items` | LongText | LLM | JSON-encoded array of up to 5 short bullet strings. Stored as a JSON-encoded string in a `LongText` column — NocoDB's native array columns are not stable across versions; storing as text keeps the contract portable. |
| `Issued_Date` | Date | LLM | `YYYY-MM-DD`. |
| `Read_Priority` | SingleSelect | LLM | `must` / `should` / `skim` / `archive`. |
### `promotions` — Promotions / Marketing
Sales, discount codes, free-shipping offers, product launches. The
point is "do not pay full price if you don't have to".
Field contract mirrors `.opencode/commands/inboxos:extract-promotions.md`.
| Column | Type | Source | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | both pipelines | Natural key. |
| `Brand` | SingleLineText | LLM | Sending brand / company / product. |
| `Offer_Type` | SingleSelect | LLM | `discount` / `bogo` / `freebie` / `launch` / `sale` / `event` / `newsletter` / `other`. |
| `Headline` | LongText | LLM | Main promotional claim. |
| `Discount_Pct` | Number | LLM | Numeric percent if explicit, else null. |
| `Discount_Code` | SingleLineText | LLM | Promo code string, or null. |
| `Valid_Until` | Date | LLM | `YYYY-MM-DD` if explicit deadline. |
| `Category` | SingleSelect | LLM | `apparel` / `software` / `hardware` / `food` / `travel` / `services` / `other`. |
| `Worth_Saving` | Checkbox | LLM | True only when there is a concrete, time-bound, generous offer. |
| `Notes` | LongText | LLM | One-sentence summary, ≤ ~200 chars. |
### `papra_index` — Papra write audit trail (REQ-INB-19)
Per-message audit trail for every Papra write intent. One row per
Papra write decision; populated by the helper
`pipelines.common.papra_index_upsert` (and the test cases in
`pipelines/_tests/test_papra_index_offline.py`). The table is the
**only** column-count change in v1.1: the four module tables
(`finance`, `crm`, `intel`, `promotions`) are unchanged from the
Phase 1.1 freeze recorded in
`docs/phase-1.1-verification/inb-28-report.md`.
| Column | Type | Source | Notes |
| --- | --- | --- | --- |
| `Message_ID` | SingleLineText (UNIQUE, required) | pipeline | Natural key. One row per Papra write intent. |
| `papra_document_id` | SingleLineText (nullable) | pipeline | The returned Papra document id; null when `dry_run=true` or when the write failed. |
| `module` | SingleLineText (nullable) | pipeline | The module that triggered the write, or null for attachment writes. |
| `kind` | SingleSelect | pipeline | `attachment` / `body`. Which Papra write path produced the row. |
| `decision_at` | DateTime (required) | pipeline | `now_iso()` at decision time. |
| `outcome` | SingleSelect | pipeline | `ok` / `failed` / `dry_run`. The final outcome. |
| `error` | LongText (nullable) | pipeline | Error string when `outcome=failed`; null otherwise. |
The schema-change procedure in §"Schema changes (process)" applies:
`scripts/apply_schemas.py` is idempotent on the four module tables
(it leaves them untouched) and creates `papra_index` on first run
(or no-ops on subsequent runs). The `--check` mode verifies the
new table's existence and `Message_ID` UNIQUE constraint.
## How the pipelines write these tables
The historical pipeline code that consumes this contract:
```python
# pipelines/historical_pipeline.py::process_one_message
modules = classify(opencode_cfg, summary, logger) # inboxos:classify
for module in modules:
extracted = extract_for_module(opencode_cfg, module, summary, papra_link, logger)
# extracted is the LLM's JSON output, with:
# "Message_ID": <set by the pipeline from the email header>
# "Papra_Link": <set by the pipeline if attachments were uploaded>
upsert_result = nocodb_upsert(nocodb_cfg, table_id, extracted, message_id)
```
Important consequences of this contract:
1. The LLM prompt never fills `Message_ID` reliably. The pipeline
overwrites `Message_ID` from the email header (or the LLM's value
if the prompt happens to include it). This is the **idempotency
key**.
2. `Raw_S3_Link` is currently written only by the **live** pipeline.
The historical pipeline could also set it, but currently does not
— the column is nullable so both pipelines can write to the same
table.
3. The LLM extractor is expected to fill the **module-specific**
fields exactly as listed above. If it returns extra fields, NocoDB
will reject the row. If it returns fewer, the missing fields are
stored as `null`.
4. The `Items` column in `intel` is a JSON-encoded string, not a
NocoDB multi-select, on purpose — it keeps the contract portable
and lets us change item boundaries without schema migrations.
## Schema changes (process)
Schema changes are versioned **here**, not in n8n or the workflow
files. The procedure is:
1. Edit this document.
2. Update `MODULES` in `scripts/apply_schemas.py` so a re-run is a
no-op on the changed columns (add, not rename; if a rename is
needed, file a separate migration issue).
3. Update the corresponding `.opencode/commands/inboxos:extract-*.md`
file so the LLM's output matches the new column.
4. Re-run `python scripts/apply_schemas.py` against NocoDB. It is
idempotent.
5. Commit the change in the Gitea repo (the same change must
propagate to the prompt file if it adds / removes / renames
fields).
6. Open a PR; reviewers should look for:
- Column additions that the LLM prompt does not yet produce (regressions).
- Column type changes that would orphan existing data.
- `Message_ID` constraint violations.
- SingleSelect option list changes that would invalidate existing values.
## Verification
The schema is considered applied and verified when:
1. `python scripts/apply_schemas.py --check` exits 0 (the script
verifies the tables and unique constraints exist and match).
2. A single test email flows through
`pipelines/historical_pipeline.py` against NocoDB and produces one
upserted row in at least one module table, retrievable by
`?where=(Message_ID,eq,…)`.
3. A second pass with the same `Message_ID` produces
`{"action": "patch"}` on the same row, confirming the upsert is
idempotent.
## Curated views (REQ-INB-41)
The NocoDB base ships with 4 curated views, one per module. The views are seeded by `scripts/apply_schemas.py --seed-views` (v1.4 REQ-INB-41).
| Table | View title | Filter |
|-------------|-----------------------------|-----------------------------------------------------|
| `finance` | Open invoices 30d | `(status,eq,open) AND (due_date,le,today+30d)` |
| `crm` | New contacts 7d | `(created_at,ge,today-7d)` |
| `intel` | Intel watchlist alerts | `(priority,eq,high)` |
| `promotions`| Promotions opt-out queue | `(opt_out,eq,true)` |
The view seed is **idempotent**: re-running `apply_schemas.py --seed-views` updates the existing views; if a view with the same name exists but a different filter, the script updates the filter (does not create a duplicate).
After running the script, the views appear in the NocoDB left-nav under each module table.
## Open alignment work
None. Both pipelines (`pipelines/live_pipeline.py` and
`pipelines/historical_pipeline.py`) read the same
`NOCODB_MODULE_TABLES` env var, call the same `inboxos:classify`
prompt, and then call the same `inboxos:extract-<module>` prompt
per matched module. The NocoDB schema here matches the field
contract that those prompts declare.