Repository navigation
Architecture
This document is the authoritative reference for the system structure, module responsibilities, and key data flows. Update it when you add, remove, or significantly alter a subsystem.
graph TD
Browser["Browser — SPA\nReact 18 · TypeScript · Vite · TanStack Query · Mantine UI"]
subgraph FastAPI["FastAPI Backend — Python 3.12 · SQLAlchemy 2 async · SQLite"]
direction LR
Analyzer["Document\nAnalyzer"]
Discovery["Discovery\n& RAG"]
Automation["Automation\nEngine"]
end
LLM["LLM Provider Layer\nOllama · Bedrock · Anthropic · OpenAI"]
Storage["Storage\nSQLite (ORM) · ChromaDB / Qdrant (vectors)"]
NGX["Paperless NGX\nREST API (token-authenticated)"]
Browser -->|"HTTP / REST + SSE"| FastAPI
Analyzer --> LLM
Discovery --> LLM
Automation --> LLM
FastAPI <-->|"read / write"| Storage
Analyzer -->|"GET /api/documents/"| NGX
FastAPI -->|"PATCH /api/documents/"| NGX
The FastAPI application entry-point. Contains:
- All HTTP route handlers (
@app.get/post/put/delete) -
lifespan()— startup/shutdown: fail-closed auth guard (refuse to start withoutPAPERLESS_URL, D-24), Alembic migrations, background task launch, settings seed -
_automation_loop(app, poll_interval)— the inbox poller (continuous; processes every inbox-tagged document each poll) -
_cron_loop(name, get_expr, run_job)— generic cron-driven loop (croniter); re-reads its expression each tick so settings changes apply without a restart (D-02). Drives_run_scheduler_batch(schedule_cron) and_run_scheduled_grooming_scan(grooming_scan_cron, incremental) -
_make_analysis_callbacks(app, session, label)— the per-document analyse→enqueue→(auto-approve) closures shared by the poller and the batch job -
schedule_reembed()/_daily_reembed_loop/_flush_dirty_reembeds— deferred re-embedding chokepoint and flush (D-22);_record_document_embed()stampslast_embedded_at+ writes anembeddedaudit event at every embed -
_content_drift_loop/_run_content_drift_reindex/_run_tag_drift_reindex— weekly safety net (same loop, samecontent_drift_reindex_dayssetting): the first re-embeds documents whose Paperlessmodifiedis newer thanlast_embedded_at; the second re-embeds documents whose current tags no longer matchlast_tag_ids_json— bulk tag removals/deletions fire neither the webhook nor amodifiedbump, so they need their own full-scan check (D-22) -
_session_expiry_loop(app)— hourly background task: extracts memories from sessions older than 24 hours, then deletes them (see D-19) -
_background_index(doc_id)— background task to embed a document after it is processed -
_extract_memories_from_session(session, ...)— extracts and deduplicates memories; called from explicit session close and from_session_expiry_loop - Helper utilities:
_paperless_list(),_apply_settings(),_run_alembic_migrations(), auth middleware
Session management rule:
- HTTP route handlers receive
session: AsyncSession = Depends(get_session)— the session lifecycle is managed by FastAPI's dependency injection. - Background tasks and lifespan code use
async with AsyncSessionLocal() as db— they run outside a request context. - Never use
AsyncSessionLocal()inside a route handler and never useDepends(get_session)in a background task.
DocumentAnalyzer — the core intelligence pipeline.
Key responsibilities:
-
analyze(document_id)— full pipeline: fetch metadata, build prompt, call LLM, parse JSON, apply creation policy, persist suggestion -
_fetch_entity_context()→tuple[str, list[str], list[str], list[str]]— fetches all tags / correspondents / document types from Paperless NGX in parallel, returns both the formatted prompt string and the raw lists (to avoid duplicate API calls in_apply_creation_policy) -
_build_field_instructions()— assembles per-field instruction text fromconfig.field_descriptions -
_apply_creation_policy(suggestion, …, all_tags, all_correspondents, all_document_types)— accepts pre-fetched entity lists as keyword args to skip redundant API calls; falls back to fetching individually if not provided
OCR vs full-document mode:
OCR text is already included in the Paperless NGX GET /api/documents/{id}/ metadata response (content field). The analyzer reuses doc_meta["content"] directly — it does not make a second GET /api/documents/{id}/content/ call.
Four provider adapters that all implement protocols.LLMProvider:
| Module | Provider | Notes |
|---|---|---|
ollama_provider.py |
Ollama | Single AsyncClient instance (singleton per provider object); request queue via ollama_queue.py
|
bedrock.py |
Amazon Bedrock | Lazy boto3 runtime client; cached until ExpiredTokenException, then invalidated + retried once |
anthropic_provider.py |
Anthropic | Thin wrapper around anthropic.AsyncAnthropic
|
openai_provider.py |
OpenAI | Thin wrapper around openai.AsyncOpenAI
|
All blocking (boto3) calls use loop.run_in_executor(None, fn) with asyncio.get_running_loop().
LLMProvider, VectorStore, and Reranker as typing.Protocol (structural subtyping). Implementations are not required to inherit from these — they just need to match the method signatures. This keeps the adapters decoupled.
Three VectorStore implementations plus shared module-level helpers:
| Class | Backend | Notes |
|---|---|---|
ChromaVectorStore |
ChromaDB (local disk, /data/chroma) |
Default; HNSW configurable via configuration= API |
QdrantVectorStore |
Qdrant (self-hosted or Qdrant Cloud) | Async client (D-06); lazy collection bootstrap; location=":memory:" for tests |
BedrockKnowledgeBaseStore |
Amazon Bedrock Knowledge Base | Delegates embedding storage to managed KB; read-only query |
Shared helpers (used by all backends): _build_embed_prefix, _build_base_meta, _maybe_rerank, _assemble_doc_results, _assemble_chunk_results.
Chunking is handled by _chunk → _chunk_text (char windows) or _chunk_text_sentences (sentence-aware packing).
dump_points/load_points on each store enables backend-agnostic migration without re-embedding.
make_vector_store(config, embed_provider, concurrency, providers) — single construction point for all three backends. Wires search-tuning knobs (overfetch, min-score, chunking, HNSW, reranker) consistently. Never construct a store class directly in main.py (see D-20).
Reranker implementations wired into the shared query post-processing path:
-
LLMReranker— listwise prompt to the configured chat provider; no new deps -
LocalCrossEncoderReranker— sentence-transformers cross-encoder; optional dep (pip install 'paperless-iq[rerank-local]'); torch imported lazily on first call -
BedrockReranker— Bedrock Rerank API; reuses existing Bedrock credentials
build_reranker(config, providers) factory; ships disabled (rerank_enabled=False).
migrate_embeddings(src, dst) / migrate_memories(src, dst) — copies stored vectors from one backend to another without re-embedding. Used by the settings-reload auto-migration and by POST /api/vector/migrate.
MemoryStore base (shared find_similar + SIMILARITY_THRESHOLD = 0.88). Two backends:
-
ChromaMemoryStore— default, co-located with the document store on disk -
QdrantMemoryStore— uses the same Qdrant client asQdrantVectorStore
make_memory_store(config, provider) factory selects the backend matching vector_store_backend.
GroomingService — all Library/vocabulary-maintenance operations, instantiated per request via the _grooming_svc() dependency (and standalone for the scheduled scan). Covers:
-
Entity sync from Paperless + lazy description rows (
sync_and_list_entities,update_entity) -
Descriptions — single + bulk LLM generation (
_generate_one,start_bulk_generate); the only LLM spend in grooming -
Entity embedding —
_embed_entity_rowstores the vector as JSON on the row (D-21), not in a collection -
Dedup — name + embedding candidate clusters via transitive closure (
get_dedup_candidates),merge_entities(Paperless bulk edit + audit rows + loser deletion) -
Mismatch scan —
collect_scan_candidates(zero-LLM/zero-embed: entity vector →query_chunks_by_vector→ classify with hysteresis),run_scan_dry(calibration preview),start_scan/_scan_bg(background, capped enqueue, dismissal memory). Pure classification helpers (classify_tag_entity,classify_single_valued_entity,compute_cohort_percentiles) are module-level and unit-tested.
Module-level _generate_lock / _scan_lock (plain asyncio.Locks — single-process app) serialise the two background operations; status dicts back the polled /status endpoints.
SQLAlchemy 2 ORM declarations. Nine tables:
| Table | Purpose |
|---|---|
suggestions |
Pending / approved / rejected metadata suggestions (incl. evidence_json for grooming scans) |
audit_log |
Field-level change history |
document_tracking |
Documents seen by the inbox monitor (first seen, last analyzed, reembed_dirty_since (pending deferred re-embed), last_embedded_at (vector last refreshed; drives content-drift reindex), last_tag_ids_json (tag IDs embedded last time; drives tag-drift reindex)) |
settings |
Single-row JSON blob for PaperlessIQConfig
|
conversation_sessions |
Discovery chat sessions (verbatim turns + rolling summary) |
user_memories |
Long-term memory facts |
user_permissions |
Per-user access-control flags incl. can_groom; created/updated at every login |
entity_descriptions |
Per-entity description + embedding vector (JSON) + scan bookkeeping (D-21) |
grooming_dismissals |
Permanent rejection memory for dedup pairs and scan actions (D-23) |
Schema changes are applied by Alembic (backend/alembic/) at startup — _run_alembic_migrations() self-heals pre-Alembic and stale-revision databases by stamping the live-schema baseline before upgrading.
Pydantic v2 models. Separate from ORM models — the API serialises Pydantic, persistence uses ORM. Mapping is explicit (from_attributes=True).
Loads PaperlessIQConfig from the database; falls back to PIQ_* env vars on first run. After the first UI save the database value is canonical. Credentials are Fernet-encrypted before storage (via providers/encryption.py + SECRET_KEY).
ApprovalQueue — methods to list, approve, reject, and bulk-action suggestions, handling entity creation when allow_new policies are active.
InboxMonitor — fetches documents tagged with the inbox tag that haven't been seen before.
frontend/src/
├── api.ts # All API calls (single module, typed return values)
├── i18n.ts # react-i18next init (LanguageDetector → localStorage "piq_lang")
├── locales/{en,de,fr,es,it}/translation.json # Translation resources (single namespace)
├── App.tsx # Router, layout, nav sidebar; fetches + applies permissions
├── ThemeProvider.tsx # MantineProvider + createTheme driven by /api/theme settings
├── main.tsx # React root
│
├── components/
│ └── MarkdownText.tsx # renderInline + MarkdownText component + Source type
│ # Used by DiscoveryPage for LLM answer rendering
│
├── pages/
│ ├── LoginPage.tsx
│ ├── ManualPage.tsx # On-demand document analysis
│ ├── QueuePage.tsx # Approval queue
│ ├── ProcessingPage.tsx # Status panel (monitoring only — no maintenance actions)
│ ├── AuditPage.tsx
│ ├── DiscoveryPage.tsx # RAG chat interface
│ ├── SettingsPage.tsx # ~510-line orchestrator — all state + handleSubmit
│ └── settings/
│ ├── constants.ts # METADATA_FIELDS · LLM_MODEL_DEFAULTS · EMBED_MODEL_DEFAULTS
│ ├── ConnectionTab.tsx
│ ├── AIProviderTab.tsx
│ ├── PromptsFieldsTab.tsx
│ ├── MetadataRulesTab.tsx
│ ├── AutomationTab.tsx
│ ├── AppearanceTab.tsx
│ ├── MemoriesTab.tsx
│ └── AccessControlTab.tsx # User permissions table + maintenance actions (reindex, reset)
│
├── AutocompleteInput.tsx # Reusable tag autocomplete
├── CfNameEditor.tsx # Custom field name inline editor
├── StatusPanel.tsx # Background task status widget
└── TagInput.tsx # Tag chip input
SettingsPage.tsx owns all state, effects, mutations, and the handleSubmit form handler. It renders the active tab component as a pure display tree, passing values and setters as props.
Tab components (settings/*.tsx):
- Are pure display — no
useQuery, nouseMutation, no API calls. Exceptions:-
MemoriesTabowns its fire-and-forget memory CRUD (lifting would require 4+ extra callback props for trivial state) -
AccessControlTabowns its/api/piq-usersCRUD and maintenance action calls (same reasoning; these don't feed intohandleSubmit)
-
- Receive props via explicit TypeScript
interface Props -
Never define constants that are also needed by
SettingsPage— put shared constants inconstants.ts
- TanStack Query for server state (settings, tags, custom fields, logos)
-
useStatefor all form/UI state that needs to survive tab switches (the form is a single<form>element wrapping all 8 tab views; switching tabs shows/hides — not unmounts — the content) - Permissions are fetched once in
App.tsxfromGET /api/piq-users/meafter login and applied there as page-level gating (canViewPagecontrols which nav items + pages render) - No Redux or Zustand — the query cache + local component state is sufficient
-
i18n.tsinitialises react-i18next withi18next-browser-languagedetector. Resources are the per-language JSON files underlocales/<lang>/translation.json(singletranslationnamespace, 5 languages, identical key sets). - Components consume strings via the
useTranslation()hook (const { t } = useTranslation()) — never a module-leveltimport. Interpolation uses single braces (t("key", { name })) viainterpolation: { prefix: "{", suffix: "}" }. - Language is client-side only: the detector reads/writes localStorage key
piq_lang(falls back to browser locale, thenen). There is no backend language field — switching viai18n.changeLanguagere-renders live, no reload, no API round-trip. - Adding a string means adding the key to all 5 locale files;
npm run check:i18n(scripts/check-i18n.mjs) enforces key parity in CI.
flowchart TD
Poll["InboxMonitor.poll()"] --> Analyze["DocumentAnalyzer.analyze(doc_id)"]
Analyze --> Fetch["GET /api/documents/{id}/\nmetadata + OCR content — single call"]
Analyze --> Entity["_fetch_entity_context()\ntags · correspondents · types — parallel"]
Analyze --> Fields["_build_field_instructions()"]
Fetch & Entity & Fields --> Prompt["Assemble prompt"]
Prompt --> LLM["LLMProvider.complete(prompt)"]
LLM --> Parse["Parse JSON → MetadataSuggestion"]
Parse --> Policy["_apply_creation_policy()\nreuses pre-fetched lists — no extra API calls"]
Policy --> DB[("INSERT suggestions")]
DB --> Queue["QueuePage"]
Queue --> Approve{"User approves"}
Approve --> Write["PATCH /api/documents/{id}/\nwrite metadata to Paperless NGX"]
Approve --> Audit[("INSERT audit_log\none row per changed field")]
Approve --> Status[("UPDATE suggestions.status = 'approved'")]
Per-question flow:
flowchart TD
Ask["POST /api/discover\n{question, session_id?}"] --> Session["get_or_create ConversationSession"]
Session --> History{"Has prior\nhistory?"}
History -->|Yes| Reform["LLM reformulates question\n→ standalone search query"]
History -->|No| Search
Reform --> Search["VectorStore.query()\n→ source chunks"]
Session --> Memories["MemoryStore.find_relevant()\n→ inject into system prompt"]
Memories & Search --> Chat["LLMProvider.chat()\nsystem prompt + history turns + fresh context"]
Chat --> Return["Return {answer, sources[]}"]
Return --> Append["Append turn to session.turns"]
Append --> Window{"len(turns) > 8?"}
Window -->|Yes| Compress["LLM compresses oldest turns\n→ session.summary"]
Window -->|No| Done["Done"]
On session close — memory extraction:
flowchart TD
Close["Session closed"] --> Extract["LLM extracts memorable facts\nfrom full conversation"]
Extract --> Loop["For each fact"]
Loop --> Similar{"MemoryStore.find_similar()\ncosine distance ≤ 0.08?"}
Similar -->|Duplicate| Update[("UPDATE user_memories\n+ Chroma upsert")]
Similar -->|New| Insert[("INSERT user_memories\n+ Chroma upsert")]
The form collects state from all 8 tabs into a single values dict:
- Starts from the current full server settings (
{...s}) so hidden-tab values are not lost - Overlays
FormDatafields present in the active tab's DOM - Merges React-state-owned values (theme, prompt text, field descriptions, Bedrock fields) explicitly
- Sends
PUT /api/settingswith the merged dict
This means every save is a full replacement — the backend receives the complete config, not a patch.
- All
asyncio.get_event_loop()calls have been replaced withasyncio.get_running_loop(). Do not re-introduceget_event_loop(). - Blocking calls (boto3, heavy crypto) use
await loop.run_in_executor(None, fn). - The Ollama
AsyncClientis a singleton perOllamaProviderinstance — never create a new client per request. - The Bedrock boto3 runtime client is cached on the provider instance and invalidated on
ExpiredTokenException(with one automatic retry).
- LLM API keys and Bedrock secrets are Fernet-encrypted with
SECRET_KEYbefore being stored in SQLite. Plaintext never appears in logs or API responses. - Bedrock
__KEEP__sentinel: the frontend sends"__KEEP__"for secret sub-fields that weren't changed; the backend retains the existing encrypted value. -
PAPERLESS_TOKENis stored in the environment only (not in the settings DB) and injected at request time.
- Session tokens are HMAC-SHA256 signed (
jti.username_b64.exp.sig) using the machine key. Valid for 7 days. Revocation is in-memory (cleared on restart). - Auth is enforced only when
PAPERLESS_URLis set — and the app refuses to start without it (the lifespan logs aCRITICALand raises, so uvicorn exits). There is no running open mode (D-24). The_is_auth_required()helper still returns False when unset so route tests can run open via ASGITransport, which bypasses the lifespan guard. - Login is rate-limited to 10 attempts per IP per 5-minute window (in-memory, resets on restart).
-
POST /api/auth/loginvalidates credentials against Paperless NGX (/api/token/), checks admin status, upserts theuser_permissionsrow, and issues the session token. - Public paths exempt from auth:
/api/auth/*,/api/status,/api/theme,/api/logos,/health.
-
require_perm(*perms)inmain.pyis a FastAPI dependency factory. It checks that the authenticated user has any of the listed permission flags in theiruser_permissionsrow. -
sync_ng_admins=True(default): Paperless NGX superusers/staff are auto-granted full PIQ access on login without manual permission grants (solves the bootstrap problem). - The
can_accessbase check runs inauth_middlewareusing a freshAsyncSessionLocal()session — it cannot useDepends(get_session)because middleware runs outside the request dependency graph. - All per-route permission checks use
Depends(require_perm(...))withDepends(get_session)— neverAsyncSessionLocal().
-
POST /api/webhook/paperlessis intentionally unauthenticated (Paperless NGX calls it without a PIQ session token). SetWEBHOOK_SECRETin the environment and configure Paperless NGX to send it asX-Webhook-Secretto restrict access. - CORS origins are restricted via
CORS_ALLOWED_ORIGINSenv var (comma-separated). Defaults to*for backwards compatibility.