live: patch Papra_Link onto NocoDB rows after attachment write (REQ-INB-25)
The historical pipeline captures Papra_Link during the pre-classify attachment walk and writes it onto the NocoDB row at extract time. The live pipeline previously emitted the papra_decision log event but did not PATCH the link onto the row, creating a documented asymmetry. This commit closes it. pipelines/live_pipeline.py: - Capture the first returned id from papra_write_attachment as papra_link (parallel to the historical pipeline's pre-classify walk). - After the per-module upsert loop, iterate over the collected upserts and PATCH Papra_Link onto each module row that was upserted. Idempotent: a re-run with the same papra_link is a no-op on the column. - Emit a structured papra_link_patched event with the message_id, the papra_link, and the list of modules_patched. Failures are per-row papra_link_patch_failed events; the pipeline continues. - The papra_write_body branch also captures its single id into papra_link (defensive; body uploads go to one module, but the parity patch is harmless if there are 0 upserts). docs/extraction-contract.md: - §'Live vs historical: Papra_Link asymmetry' subsection removed. The asymmetry is closed; the cross-references to ARCHITECTURE.md and papra-write-policy.md are updated by T2.3. ARCHITECTURE.md: - §'Document Store (curated)' 'Live vs historical asymmetry' paragraph replaced with a single sentence: 'Both pipelines capture Papra_Link on the NocoDB row whenever the attachment walk returns a papra_link.' with a forward pointer to docs/extraction-contract.md. pipelines/_tests/test_papra_link_parity_offline.py (new, ~1 test): - test_papra_link_patched_on_live_after_attachment_write asserts the parity patch: 2 module rows + 2 Papra_Link patches, one papra_link_patched event listing both modules. The papra_decide function in pipelines/common.py is unchanged; the patch is a caller-side change in live_pipeline.py only. docs/papra-write-policy.md §Rule 1 'Live pipeline note' removal is deferred to T2.3 (Wave 2) per the plan. ---ci--- project: inboxos phase: 1 milestone: v1.1 status: execute ---/ci---
This commit is contained in:
@@ -62,14 +62,10 @@
|
||||
|
||||
- **Papra** (`docker/docker-compose.yml`): Curated document store for selected attachments + bodies. **First ship is dry-run** — the pipeline emits Papra write intent but does not call the real Papra API. See `docs/papra-write-policy.md`.
|
||||
|
||||
**Live vs historical asymmetry.** The historical pipeline captures
|
||||
`Papra_Link` during the pre-classify attachment walk (see
|
||||
`pipelines/historical_pipeline.py:296-336`); the live pipeline calls
|
||||
`papra_decide` once after classify and does not pre-walk
|
||||
attachments. This asymmetry is intentional — see
|
||||
`docs/extraction-contract.md §Live vs historical: Papra_Link
|
||||
asymmetry` and `docs/papra-write-policy.md §Rule 1 — attachments
|
||||
present`.
|
||||
Both pipelines capture `Papra_Link` on the NocoDB row whenever the
|
||||
attachment walk returns a `papra_link`; see
|
||||
`docs/extraction-contract.md` and the `papra_link_patched` event in
|
||||
`pipelines/live_pipeline.py`.
|
||||
|
||||
### Orchestration
|
||||
|
||||
|
||||
@@ -152,34 +152,6 @@ version-controlled in Gitea, not embedded in n8n workflows"):
|
||||
fails loudly if the first non-whitespace token is not valid JSON.
|
||||
There is no "best effort" path.
|
||||
|
||||
## Live vs historical: Papra_Link asymmetry
|
||||
|
||||
The two pipelines have a documented asymmetry around `Papra_Link`:
|
||||
|
||||
- **Historical pipeline** (`pipelines/historical_pipeline.py:296-336`)
|
||||
calls `papra_write_attachment` *before* the classify step during
|
||||
the attachment walk. If any attachment is written, the first
|
||||
document id is captured and passed to `extract_for_module` as
|
||||
`papra_link`; the subsequent NocoDB row gains a `Papra_Link`
|
||||
field.
|
||||
- **Live pipeline** (`pipelines/live_pipeline.py:490-510`) calls
|
||||
`papra_decide` once per message, *after* classify. Attachment
|
||||
decisions are still recorded, but the pre-classify walk is
|
||||
skipped — the live pipeline never pays the per-message cost of an
|
||||
attachment pre-walk.
|
||||
|
||||
This is **intentional** in this phase. The historical pipeline
|
||||
already pays the LLM round-trip cost for the backfill batch, and
|
||||
capturing `Papra_Link` during the walk is essentially free. The
|
||||
live pipeline optimises for the 5-minute cron, where one round
|
||||
trip per message is the dominant cost. The structured log
|
||||
(`papra_decision` event) is the per-message source of truth for
|
||||
which documents would have been written in a live Papra run.
|
||||
|
||||
See `docs/papra-write-policy.md §Rule 1 — attachments present`
|
||||
("Live pipeline note:") for the policy view, and
|
||||
`ARCHITECTURE.md §Document Store (curated)` for the system view.
|
||||
|
||||
## Legacy: opencode CLI backend
|
||||
|
||||
`INBOXOS_LLM_BACKEND=opencode` is **kept for offline tests only**.
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Offline smoke for the live-pipeline Papra_Link parity (REQ-INB-25).
|
||||
|
||||
Asserts that when the live pipeline's `process_one_message` returns
|
||||
from a successful `papra_write_attachment` call with a non-None
|
||||
`papra_link`, every module row that was upserted in this run has
|
||||
`Papra_Link` set to the returned value. The asymmetry between
|
||||
live and historical pipelines documented in
|
||||
`docs/extraction-contract.md §Live vs historical: Papra_Link
|
||||
asymmetry` is closed by this code path.
|
||||
|
||||
The test stubs the network layers (`gitea_request`, `requests.*`,
|
||||
`papra_decide`, `nocodb_upsert`) and feeds a single in-memory
|
||||
fixture message through `process_one_message`, then inspects the
|
||||
NocoDB fake to assert the parity patch landed.
|
||||
|
||||
Run from the inboxos/ project root:
|
||||
|
||||
python3 pipelines/_tests/test_papra_link_parity_offline.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import email
|
||||
import email.policy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PIPE_DIR = HERE.parent
|
||||
sys.path.insert(0, str(PIPE_DIR))
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────
|
||||
|
||||
PDF_PAYLOAD = b"%PDF-1.4\n%minimal\n"
|
||||
|
||||
MESSAGE_WITH_PDF = (
|
||||
b"Message-ID: <pdf-parity@example.com>\r\n"
|
||||
b'From: "Vendor" <v@vendor.com>\r\n'
|
||||
b"Subject: Your invoice\r\n"
|
||||
b"Date: Thu, 01 May 2026 12:00:00 +0000\r\n"
|
||||
b"MIME-Version: 1.0\r\n"
|
||||
b'Content-Type: multipart/mixed; boundary="BND"\r\n'
|
||||
b"\r\n"
|
||||
b"--BND\r\n"
|
||||
b"Content-Type: text/plain; charset=utf-8\r\n"
|
||||
b"\r\n"
|
||||
b"Invoice attached.\r\n"
|
||||
b"--BND\r\n"
|
||||
b'Content-Type: application/pdf; name="invoice.pdf"\r\n'
|
||||
b'Content-Disposition: attachment; filename="invoice.pdf"\r\n'
|
||||
b"Content-Transfer-Encoding: base64\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
MESSAGE_WITH_PDF += base64.b64encode(PDF_PAYLOAD) + b"\r\n--BND--\r\n"
|
||||
|
||||
|
||||
def _silent_logger() -> logging.Logger:
|
||||
lg = logging.getLogger("inboxos-papra-link-smoke")
|
||||
lg.addHandler(logging.NullHandler())
|
||||
lg.propagate = False
|
||||
return lg
|
||||
|
||||
|
||||
# ── Fakes ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeNoco:
|
||||
"""Records every upsert call so the test can inspect the rows."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
# message_id -> {table_id: {row_id: record}}
|
||||
self.rows: dict[str, dict[str, dict[int, dict[str, Any]]]] = {}
|
||||
|
||||
def upsert(self, cfg, table_id, record, message_id):
|
||||
self.calls.append({
|
||||
"table_id": table_id,
|
||||
"message_id": message_id,
|
||||
"record": dict(record),
|
||||
})
|
||||
if message_id not in self.rows:
|
||||
self.rows[message_id] = {}
|
||||
if table_id not in self.rows[message_id]:
|
||||
self.rows[message_id][table_id] = {}
|
||||
existing = self.rows[message_id][table_id]
|
||||
if existing:
|
||||
row_id = next(iter(existing))
|
||||
existing[row_id].update(record)
|
||||
return {"action": "patch", "id": row_id, "record": existing[row_id]}
|
||||
row_id = len(existing) + 1
|
||||
existing[row_id] = dict(record)
|
||||
return {"action": "post", "id": row_id, "record": existing[row_id]}
|
||||
|
||||
|
||||
# ── Test ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_papra_link_patched_on_live_after_attachment_write() -> None:
|
||||
"""Live pipeline patches Papra_Link onto every module row when an attachment is written."""
|
||||
from common import LLMConfig, NocoConfig, PapraConfig
|
||||
import live_pipeline
|
||||
|
||||
msg = email.message_from_bytes(MESSAGE_WITH_PDF, policy=email.policy.default)
|
||||
|
||||
# NocoConfig / LLMConfig / PapraConfig (all dry-run for the Papra side)
|
||||
nocodb_cfg = NocoConfig(
|
||||
base_url="https://noco.example.test",
|
||||
api_token="tok",
|
||||
project_id="p",
|
||||
)
|
||||
llm_cfg = LLMConfig(
|
||||
backend="ollama",
|
||||
prompt_dir="./.opencode/commands",
|
||||
opencode_bin="opencode",
|
||||
ollama_base_url="https://ollama.example.test",
|
||||
ollama_api_key=None,
|
||||
default_model="gemma4:e4b",
|
||||
extract_model="gemma4:e4b",
|
||||
extract_command_prefix="inboxos:extract-",
|
||||
timeout=60,
|
||||
max_tokens=4096,
|
||||
)
|
||||
papra_cfg = PapraConfig(
|
||||
base_url="https://papra.example.test",
|
||||
api_token="tok",
|
||||
organization_id="org",
|
||||
body_write_modules=["crm", "finance", "legal"],
|
||||
metadata_only_modules=["intel", "promotions"],
|
||||
dry_run=True,
|
||||
dry_run_override=None,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Module tables: one finance + one crm.
|
||||
module_tables = {"finance": "tbl_finance_x", "crm": "tbl_crm_y"}
|
||||
|
||||
fake_noco = _FakeNoco()
|
||||
# The live pipeline's classify + extract path is also stubbed:
|
||||
# we want the upsert loop to write 2 rows and then the Papra_Link
|
||||
# patch to write 2 more (one PATCH per row).
|
||||
def fake_classify(*args, **kwargs):
|
||||
return ["finance", "crm"]
|
||||
|
||||
def fake_extract(llm_cfg, module, summary, logger):
|
||||
return {"Message_ID": summary["message_id"], "Some_Col": f"value-{module}"}
|
||||
|
||||
logger = _silent_logger()
|
||||
captured: list[dict[str, Any]] = []
|
||||
|
||||
def fake_papra_write_attachment(cfg, decision, *, message_id, module, logger):
|
||||
# Pretend Papra accepted the attachment and returned a doc id.
|
||||
return ["doc-parity-12345"]
|
||||
|
||||
def fake_papra_decide(*, message_id, modules, msg, cfg):
|
||||
from common import PapraDecision, PapraTarget, _content_hash
|
||||
# Build one attachment target.
|
||||
target = PapraTarget(
|
||||
kind="attachment",
|
||||
filename="invoice.pdf",
|
||||
content_type="application/pdf",
|
||||
payload=PDF_PAYLOAD,
|
||||
content_hash=_content_hash(PDF_PAYLOAD),
|
||||
)
|
||||
return PapraDecision(
|
||||
would_write=True,
|
||||
reason="attachment_present",
|
||||
module=None,
|
||||
targets=[target],
|
||||
)
|
||||
|
||||
def fake_save_state(s3, bucket, state):
|
||||
# No-op: avoid SeaweedFS writes in the offline test.
|
||||
pass
|
||||
|
||||
def fake_s3_put_bytes(s3, bucket, key, body, content_type="application/octet-stream"):
|
||||
pass
|
||||
|
||||
# s3 client stub: only the methods the live pipeline uses.
|
||||
class _FakeS3:
|
||||
pass
|
||||
|
||||
# Patch the live pipeline's symbol references.
|
||||
with patch.object(live_pipeline, "papra_decide", side_effect=fake_papra_decide), \
|
||||
patch.object(live_pipeline, "papra_write_attachment", side_effect=fake_papra_write_attachment), \
|
||||
patch.object(live_pipeline, "classify", side_effect=fake_classify), \
|
||||
patch.object(live_pipeline, "extract_for_module", side_effect=fake_extract), \
|
||||
patch.object(live_pipeline, "nocodb_upsert", side_effect=fake_noco.upsert), \
|
||||
patch.object(live_pipeline, "s3_put_bytes", side_effect=fake_s3_put_bytes), \
|
||||
patch.object(live_pipeline, "save_state", side_effect=fake_save_state), \
|
||||
patch.object(live_pipeline, "log_event",
|
||||
side_effect=lambda logger, event, **fields: captured.append({"event": event, **fields})):
|
||||
ok = live_pipeline.process_one_message(
|
||||
uid=1,
|
||||
raw=MESSAGE_WITH_PDF,
|
||||
s3=_FakeS3(),
|
||||
s3_bucket="inboxos",
|
||||
nocodb_cfg=nocodb_cfg,
|
||||
llm_cfg=llm_cfg,
|
||||
module_tables=module_tables,
|
||||
papra_cfg=papra_cfg,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert ok is True, "process_one_message returned False"
|
||||
|
||||
# We expect 4 nocodb_upsert calls: 2 module rows + 2 Papra_Link patches.
|
||||
assert len(fake_noco.calls) == 4, (
|
||||
f"expected 4 upsert calls (2 rows + 2 patches), got {len(fake_noco.calls)}"
|
||||
)
|
||||
|
||||
# The last 2 calls must be the Papra_Link patches.
|
||||
patch_calls = [c for c in fake_noco.calls if c["record"].get("Papra_Link")]
|
||||
assert len(patch_calls) == 2, (
|
||||
f"expected 2 Papra_Link patch calls, got {len(patch_calls)}"
|
||||
)
|
||||
patched_table_ids = {c["table_id"] for c in patch_calls}
|
||||
assert patched_table_ids == {"tbl_finance_x", "tbl_crm_y"}, (
|
||||
f"expected patches on both tables, got {patched_table_ids}"
|
||||
)
|
||||
for c in patch_calls:
|
||||
assert c["record"]["Papra_Link"] == "doc-parity-12345"
|
||||
|
||||
# The papra_link_patched event must have been emitted.
|
||||
parity_events = [e for e in captured if e.get("event") == "papra_link_patched"]
|
||||
assert len(parity_events) == 1, (
|
||||
f"expected 1 papra_link_patched event, got {len(parity_events)}"
|
||||
)
|
||||
ev = parity_events[0]
|
||||
assert ev["papra_link"] == "doc-parity-12345"
|
||||
assert sorted(ev["modules_patched"]) == ["crm", "finance"]
|
||||
|
||||
|
||||
# ── Runner ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run() -> int:
|
||||
tests = [
|
||||
test_papra_link_patched_on_live_after_attachment_write,
|
||||
]
|
||||
failures = []
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
print(f"PASS {t.__name__}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failures.append((t.__name__, exc))
|
||||
print(f"FAIL {t.__name__}: {exc}")
|
||||
print(f"--- {len(tests) - len(failures)}/{len(tests)} passed")
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_run())
|
||||
@@ -493,16 +493,20 @@ def process_one_message(
|
||||
msg=msg,
|
||||
cfg=papra_cfg,
|
||||
)
|
||||
papra_link: str | None = None
|
||||
if papra_decision_obj.reason == "attachment_present":
|
||||
papra_write_attachment(
|
||||
written_ids = papra_write_attachment(
|
||||
papra_cfg,
|
||||
papra_decision_obj,
|
||||
message_id=message_id,
|
||||
module=None,
|
||||
logger=logger,
|
||||
)
|
||||
papra_link = written_ids[0] if written_ids else None
|
||||
if papra_link:
|
||||
log_event(logger, "papra_uploaded", message_id=message_id, papra_link=papra_link)
|
||||
elif papra_decision_obj.reason == "body_module":
|
||||
papra_write_body(
|
||||
papra_link = papra_write_body(
|
||||
papra_cfg,
|
||||
papra_decision_obj,
|
||||
message_id=message_id,
|
||||
@@ -522,6 +526,44 @@ def process_one_message(
|
||||
dry_run=papra_cfg.dry_run,
|
||||
)
|
||||
|
||||
# 4b. Papra_Link parity patch (REQ-INB-25). When the attachment
|
||||
# walk returned a Papra document id, mirror the historical
|
||||
# pipeline's behaviour and PATCH `Papra_Link` onto every
|
||||
# module row that was upserted above. Idempotent: a re-run
|
||||
# with the same papra_link produces no observable change.
|
||||
if papra_link and upserts:
|
||||
patched_modules: list[str] = []
|
||||
for entry in upserts:
|
||||
table_id = entry.get("table_id")
|
||||
module_name = entry.get("module")
|
||||
row_id = entry.get("id")
|
||||
if not table_id or not row_id or not module_name:
|
||||
continue
|
||||
try:
|
||||
nocodb_upsert(
|
||||
nocodb_cfg,
|
||||
table_id,
|
||||
{"Papra_Link": papra_link},
|
||||
message_id,
|
||||
)
|
||||
patched_modules.append(module_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_event(
|
||||
logger,
|
||||
"papra_link_patch_failed",
|
||||
message_id=message_id,
|
||||
module=module_name,
|
||||
error=str(exc),
|
||||
)
|
||||
if patched_modules:
|
||||
log_event(
|
||||
logger,
|
||||
"papra_link_patched",
|
||||
message_id=message_id,
|
||||
papra_link=papra_link,
|
||||
modules_patched=patched_modules,
|
||||
)
|
||||
|
||||
log_event(logger, "process_done", message_id=message_id, upserts=upserts)
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user