126 plikow (md, yaml, sh, py) odwolywalo sie do sciezek sprzed migracji.
15 markdown-linkow [..](..) -> policzona sciezka WZGLEDNA wobec pliku
odsylajacego (wczesniej czesc z nich byla repo-root-relative i nie
rozwiazywala sie z katalogu, w ktorym lezala)
200 odwolan tekstowych (backticki, proza, yaml, importy w kodzie)
-> nowa sciezka repo-root-relative, zgodnie z konwencja repo
5 linkow rodzenstwa (gole nazwy plikow, np. "](DEPLOY.md)") — dzialaly
tylko w starym katalogu; przeliczone recznie
Objete m.in.: CLAUDE.md (scripts/onboard/README.md -> kb/runbooks/
node-onboarding-tool.md, docs/backlog.md -> kb/phases/backlog.md),
README.md, .claude/skills/, 20 session logow, kod jobow.
Ostatnie 5 odwolan pochodzi z tresci wciagnietej rebasem z origin/master
(session log 2026-07-31, override node-agenta na SOLARII, dwie pozycje
backlogu) — wskazywaly na docs/incidents/, docs/kb/modules/ i
services/narty27/README.md sprzed migracji.
Dodany wzajemny link miedzy kb/services/control-plane.md (stub kodu)
a kb/subsystems/control-plane.md (opis, deprecated) — dwa dokumenty o tym
samym systemie, latwe do pomylenia.
Weryfikacja na 790 plikach: 0 odwolan do starych sciezek,
0 martwych linkow markdown. Lint OKF: 190/190 plikow ZGODNE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""Paperless -> envelope adapter — module 5 phase 2 (kb/phases/kb-m5-faza2.md,
|
|
§4.2-4.3, §6 step 5).
|
|
|
|
Reads documents from the Paperless REST API (read-only — GET only, this job never
|
|
writes to Paperless) and inserts them as `source='paperless'` rows into the `envelope`
|
|
table on kb-postgres, reusing `kb_mail.envelope.Envelope` / `kb_mail.db.insert_envelope`
|
|
(packages/kb-mail, untouched by this change — see plan §1.6). Existing `source='gmail'`
|
|
rows and `document_chunk` are never touched.
|
|
|
|
Cross-source proof (plan §1.9, §4.2): when a document's `original_file_name` matches a
|
|
`consume_name` in the documents-ingest registry.json (produced by phase 1,
|
|
jobs/documents-ingest/extractor.py), a `source_mail` entity links the document envelope
|
|
back to the originating mail envelope. No match = no `source_mail` entity — a normal,
|
|
expected outcome for documents added outside the faktury-1 pipeline.
|
|
|
|
Runs on PIHA (needs a route to both the Paperless API and kb-postgres):
|
|
|
|
Install (from repo root):
|
|
pip install -e packages/kb-mail/
|
|
pip install -e jobs/documents-ingest/
|
|
|
|
Usage:
|
|
# Dry run (default) — fetch, map, count; no DB writes:
|
|
documents-ingest-paperless --dsn postgresql://kb:<pw>@localhost:5433/kb \\
|
|
--paperless-token <token>
|
|
|
|
# Real run:
|
|
documents-ingest-paperless --dsn ... --paperless-token ... --apply
|
|
|
|
DSN can come from KB_DSN, token from PAPERLESS_API_TOKEN, URL from PAPERLESS_URL
|
|
(defaults to Paperless' fixed LAN address per services/paperless/env.example).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import AsyncIterator, Optional
|
|
|
|
import aiohttp
|
|
import asyncpg
|
|
import structlog
|
|
|
|
from kb_mail.db import insert_envelope
|
|
from kb_mail.envelope import Envelope
|
|
|
|
from .extractor import DEFAULT_REGISTRY, load_registry
|
|
|
|
_log = structlog.get_logger(__name__)
|
|
|
|
DEFAULT_PAPERLESS_URL = "http://192.168.31.5:8210"
|
|
DEFAULT_PAGE_SIZE = 200
|
|
|
|
|
|
def parse_document_ts(value: str) -> datetime:
|
|
"""Parse Paperless `created` (ISO 8601, always carries an offset) to UTC-aware."""
|
|
dt = datetime.fromisoformat(value)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def build_registry_index(registry: dict[str, dict]) -> dict[str, dict]:
|
|
"""Invert the sha256-keyed registry to consume_name -> {envelope_id, sha256, filename}.
|
|
|
|
consume_name (== Paperless `original_file_name` for faktury-1 uploads) is unique in
|
|
the registry (verified in plan §1.9), so this is a safe 1:1 index for the join.
|
|
"""
|
|
return {
|
|
entry["consume_name"]: {
|
|
"envelope_id": entry["envelope_id"],
|
|
"sha256": sha256,
|
|
"filename": entry["filename"],
|
|
}
|
|
for sha256, entry in registry.items()
|
|
}
|
|
|
|
|
|
def find_source_mail(original_file_name: Optional[str], registry_index: dict[str, dict]) -> Optional[dict]:
|
|
"""Deterministic cross-source join: original_file_name == registry consume_name (plan §1.9).
|
|
|
|
Returns None when there is no match — a normal outcome for documents added outside
|
|
the faktury-1 pipeline (plan §4.2), never guessed via filename similarity or content.
|
|
"""
|
|
if not original_file_name:
|
|
return None
|
|
return registry_index.get(original_file_name)
|
|
|
|
|
|
def build_entities(
|
|
doc: dict,
|
|
correspondents: dict[int, Optional[str]],
|
|
tags: dict[int, Optional[str]],
|
|
source_mail: Optional[dict],
|
|
) -> list[dict]:
|
|
"""Build the `entities[]` list for one document, per plan §4.2."""
|
|
entities: list[dict] = [
|
|
{"type": "content", "text": doc.get("content") or ""},
|
|
{"type": "correspondent", "name": correspondents.get(doc.get("correspondent"))},
|
|
]
|
|
for tag_id in doc.get("tags") or []:
|
|
entities.append({"type": "tag", "name": tags.get(tag_id)})
|
|
entities.append({"type": "filename", "value": doc.get("original_file_name")})
|
|
entities.append({"type": "content_type", "value": doc.get("mime_type")})
|
|
if source_mail is not None:
|
|
entities.append({
|
|
"type": "source_mail",
|
|
"envelope_id": source_mail["envelope_id"],
|
|
"attachment_sha256": source_mail["sha256"],
|
|
"attachment_filename": source_mail["filename"],
|
|
})
|
|
return entities
|
|
|
|
|
|
def map_document(
|
|
doc: dict,
|
|
correspondents: dict[int, Optional[str]],
|
|
tags: dict[int, Optional[str]],
|
|
source_mail: Optional[dict],
|
|
) -> Envelope:
|
|
"""Map one Paperless API document record to a KB envelope (plan §4.2-4.3)."""
|
|
return Envelope(
|
|
id=f"paperless:{doc['id']}",
|
|
source="paperless",
|
|
ts=parse_document_ts(doc["created"]),
|
|
raw_ref=str(doc["id"]),
|
|
geo=None,
|
|
entities=build_entities(doc, correspondents, tags, source_mail),
|
|
)
|
|
|
|
|
|
async def fetch_lookup(session: aiohttp.ClientSession, base_url: str, path: str) -> dict[int, Optional[str]]:
|
|
"""Fetch a paginated Paperless id->name table (correspondents, tags) into a dict."""
|
|
result: dict[int, Optional[str]] = {}
|
|
url: Optional[str] = f"{base_url}{path}"
|
|
while url:
|
|
async with session.get(url) as resp:
|
|
resp.raise_for_status()
|
|
data = await resp.json()
|
|
for item in data.get("results", []):
|
|
result[item["id"]] = item.get("name")
|
|
url = data.get("next")
|
|
return result
|
|
|
|
|
|
async def iter_documents(
|
|
session: aiohttp.ClientSession,
|
|
base_url: str,
|
|
page_size: int = DEFAULT_PAGE_SIZE,
|
|
limit: Optional[int] = None,
|
|
) -> AsyncIterator[dict]:
|
|
"""Yield every document from `GET /api/documents/`, following pagination.
|
|
|
|
`limit` caps the total number yielded (across pages) — for smoke-testing a slice
|
|
without pulling the whole collection. Full re-runs are cheap and idempotent (~186
|
|
docs today), so there is no --offset: unlike the 225k-row header backfill, this job
|
|
does not need resumable partitioning.
|
|
"""
|
|
url: Optional[str] = f"{base_url}/api/documents/?page_size={page_size}&ordering=id"
|
|
count = 0
|
|
while url:
|
|
async with session.get(url) as resp:
|
|
resp.raise_for_status()
|
|
data = await resp.json()
|
|
for doc in data.get("results", []):
|
|
if limit is not None and count >= limit:
|
|
return
|
|
yield doc
|
|
count += 1
|
|
url = data.get("next")
|
|
|
|
|
|
async def run(
|
|
dsn: str,
|
|
paperless_url: str,
|
|
paperless_token: str,
|
|
registry_path: Path = DEFAULT_REGISTRY,
|
|
limit: Optional[int] = None,
|
|
page_size: int = DEFAULT_PAGE_SIZE,
|
|
apply: bool = False,
|
|
) -> dict[str, int]:
|
|
"""Fetch Paperless documents, map to envelopes, and (if apply) insert new ones.
|
|
|
|
Returns stats that must always balance:
|
|
|
|
fetched = already_in_db + inserted + errors
|
|
|
|
`source_mail_linked` and `empty_content` are informational subsets of `fetched`,
|
|
not separate outcome buckets. dry-run (apply=False) never writes — `inserted`
|
|
reports what *would* be written, mirroring documents-ingest's extractor.py.
|
|
Idempotent via a pre-fetched set of existing `source='paperless'` envelope ids
|
|
(insert_envelope's own ON CONFLICT DO NOTHING is the second line of defense).
|
|
"""
|
|
stats = {
|
|
"fetched": 0,
|
|
"already_in_db": 0,
|
|
"inserted": 0,
|
|
"source_mail_linked": 0,
|
|
"empty_content": 0,
|
|
"errors": 0,
|
|
}
|
|
|
|
registry_index = build_registry_index(load_registry(registry_path))
|
|
|
|
conn = await asyncpg.connect(dsn)
|
|
try:
|
|
existing_ids = {
|
|
r["id"] for r in await conn.fetch("SELECT id FROM envelope WHERE source = 'paperless'")
|
|
}
|
|
|
|
headers = {"Authorization": f"Token {paperless_token}"}
|
|
async with aiohttp.ClientSession(headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as session:
|
|
correspondents = await fetch_lookup(session, paperless_url, "/api/correspondents/")
|
|
tags = await fetch_lookup(session, paperless_url, "/api/tags/")
|
|
|
|
async for doc in iter_documents(session, paperless_url, page_size=page_size, limit=limit):
|
|
stats["fetched"] += 1
|
|
|
|
try:
|
|
source_mail = find_source_mail(doc.get("original_file_name"), registry_index)
|
|
env = map_document(doc, correspondents, tags, source_mail)
|
|
except Exception:
|
|
_log.warning("skip.map_error", document_id=doc.get("id"), exc_info=True)
|
|
stats["errors"] += 1
|
|
continue
|
|
|
|
if not (doc.get("content") or "").strip():
|
|
stats["empty_content"] += 1
|
|
if source_mail is not None:
|
|
stats["source_mail_linked"] += 1
|
|
|
|
if env.id in existing_ids:
|
|
stats["already_in_db"] += 1
|
|
continue
|
|
|
|
if apply:
|
|
await insert_envelope(conn, env)
|
|
existing_ids.add(env.id)
|
|
stats["inserted"] += 1
|
|
finally:
|
|
await conn.close()
|
|
|
|
balance = stats["already_in_db"] + stats["inserted"] + stats["errors"]
|
|
if balance != stats["fetched"]:
|
|
_log.error("stats_mismatch", fetched=stats["fetched"], balance=balance, **stats)
|
|
|
|
_log.info("run_complete", apply=apply, **stats)
|
|
return stats
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Adapt Paperless documents into KB envelopes (module 5, phase 2 — plan §4.2-4.3)."
|
|
)
|
|
parser.add_argument("--dsn", default=os.environ.get("KB_DSN"),
|
|
help="asyncpg DSN for kb-postgres (or set KB_DSN env var)")
|
|
parser.add_argument("--paperless-url", default=os.environ.get("PAPERLESS_URL", DEFAULT_PAPERLESS_URL),
|
|
help=f"Paperless base URL (default: {DEFAULT_PAPERLESS_URL}, or set PAPERLESS_URL)")
|
|
parser.add_argument("--paperless-token", default=os.environ.get("PAPERLESS_API_TOKEN"),
|
|
help="Paperless API token (or set PAPERLESS_API_TOKEN env var)")
|
|
parser.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY,
|
|
help=f"documents-ingest phase-1 registry JSON (default: {DEFAULT_REGISTRY})")
|
|
parser.add_argument("--limit", type=int, default=None,
|
|
help="Max documents to process (default: all)")
|
|
parser.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE,
|
|
help=f"Paperless API page size (default: {DEFAULT_PAGE_SIZE})")
|
|
parser.add_argument("--apply", action="store_true",
|
|
help="Actually insert envelope rows. Default is dry-run.")
|
|
args = parser.parse_args()
|
|
|
|
if not args.dsn:
|
|
_log.error("missing_dsn", hint="pass --dsn or set KB_DSN")
|
|
sys.exit(1)
|
|
if not args.paperless_token:
|
|
_log.error("missing_token", hint="pass --paperless-token or set PAPERLESS_API_TOKEN")
|
|
sys.exit(1)
|
|
|
|
stats = asyncio.run(
|
|
run(
|
|
dsn=args.dsn,
|
|
paperless_url=args.paperless_url,
|
|
paperless_token=args.paperless_token,
|
|
registry_path=args.registry,
|
|
limit=args.limit,
|
|
page_size=args.page_size,
|
|
apply=args.apply,
|
|
)
|
|
)
|
|
|
|
mode = "APPLY" if args.apply else "DRY-RUN"
|
|
_log.info("summary", mode=mode, **stats)
|
|
|
|
balanced = stats["fetched"] == stats["already_in_db"] + stats["inserted"] + stats["errors"]
|
|
failed = stats["errors"] > 0 or not balanced
|
|
sys.exit(1 if failed else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|