The listener opened one asyncpg connection, called add_listener, and set running = True. Nothing watched that connection afterwards. When it dropped, the subscription was gone for good while running still reported True, so the service stayed healthy in every way anything could observe and silently stopped indexing page edits. Recovery needed a manual container restart. That happened on 2026-08-08 when postgres-shared was redeployed. The sibling settings_client survived the same event because it uses asyncpg.create_pool, which replaces dead connections; a bare LISTEN connection has no such recovery. A supervisor task now waits on asyncpg's termination callback and reconnects with bounded exponential backoff, 1s doubling to a 60s cap. It retries forever rather than giving up after N attempts: a database under maintenance does come back, and a listener that stopped trying would reproduce exactly the silent deafness this exists to prevent. The termination listener is re-registered on every new connection because asyncpg clears its listener list as soon as it fires them, so a one-time registration survives exactly one drop. running is now derived from the connection rather than assigned, and stop() sets a flag the termination callback and supervisor both check so a deliberate shutdown cannot race into a reconnect. NOTIFY is fire-and-forget, so events emitted during an outage are lost and cannot be replayed. The reconnect logs the gap and names POST /maintenance/integrity-check rather than reporting a clean recovery. Reconciling automatically is left out on purpose: deriving the tenant for a changed page is subtle here, and getting it wrong writes into the wrong user's namespace. Verified against the real database by terminating the listener's backend with pg_terminate_backend. Old code: running=True with is_closed()=True, dead forever. New code: reconnects on its own onto a new server pid. The same probe was run against both implementations so the check is known to discriminate. One existing test mocked the connection with a bare AsyncMock, which models asyncpg's synchronous is_closed() as a coroutine — always truthy, so the connection read as closed once running started deriving from it. Corrected. Co-Authored-By: Claude <noreply@anthropic.com>
45 KiB
Changelog
All notable changes to Library Desk will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixed
- The Wiki.js change listener now survives a database restart. It held a single
LISTENconnection with no supervision, so when the connection dropped it was gone permanently whilerunningstayedTrue— the service kept reporting healthy and silently stopped indexing every page edit until someone restarted the container. This happened for real on 2026-08-08 whenpostgres-sharedwas redeployed. It now detects the drop via asyncpg's termination callback and reconnects with bounded exponential backoff (1s doubling to a 60s cap), retrying indefinitely because a database in maintenance does come back and giving up would recreate the same silent deafness. WikiChangeListener.runningis derived from the live connection instead of being assigned once at startup, so it can no longer claim a subscription that does not exist.
Added
/healthreports the change listener underservices.wiki_listener(subscribed,reconnects,last_gap_seconds). Nothing previously exposed its state anywhere, which is why a dead listener went unnoticed. It is deliberately excluded from the overall healthy/degraded verdict: it recovers on its own, and flipping the container unhealthy for the duration of a database outage would add a restart loop to an incident rather than information.- On reconnect the listener logs the outage duration and warns that
NOTIFYevents emitted during the gap were lost and cannot be replayed, pointing atPOST /maintenance/integrity-checkto reconcile. A reconciliation pass is not performed automatically — tenant attribution for a changed page is non-trivial here, and guessing it wrong writes content into the wrong user's namespace.
[1.9.0] - 2026-07-20
Security
- The Wiki.js integration buttons no longer embed an API key in the browser. The re-index and entity-link endpoints now authenticate via the NPM
/library-desk/proxy (Authentik session for external users, LAN bypass for internal), verified by a trusted proxy marker header. The previously-embedded key was a full-privilege key served to every wiki visitor; it has been rotated out of service. verify_api_keynow uses a constant-time comparison.
Changed
static/wikijs-integration.jscalls library-desk same-origin (/library-desk/...) withcredentials: same-originand noAuthorizationheader. Update the Wiki.js code-injection snippet to<script src="/library-desk/static/wikijs-integration.js">.- Machine callers (the Scheduler) continue to use the Bearer API key against the container-network endpoints; only the two browser endpoints switched to proxy auth.
[1.8.1] - 2026-07-19
Changed
- Container images are now pushed via the
git.schweitz.netregistry endpoint (the.internalregistry domain is being retired); no change to the image name consumed by Watchtower. .env.exampleservice URL defaults now use docker-dataplane container names (wiki:3000,neo4j:7687,searxng:8080,paperless:8000,ollama:11434) instead of host IP + published port, ahead of the Phase 4 port lockdown; AGENTS.md deploy health-check URL corrected from port 8000 to 8089.
[1.8.0] - 2026-07-14
Added
- Stub endpoints implemented (
/ingest/check-updates,/ingest/status/{job_id},/ingest/repo-status/{repository},/deduplicate/check) — all previously returned canned "not yet implemented" responses; all now require an explicituser(Phase B rule):/ingest/check-updatescompares thecontent_hashnow recorded on the tenant's Neo4j Document nodes at ingestion time against the SHA-256 of current Wiki.js page content in a single UNWIND Cypher query, returningchanged/new/deletedpage lists (auto-generated entity stubs excluded; documents whose stored hash predates hash tracking are flaggedstored_hash_missingand count as changed)./ingest/status/{job_id}is backed by the RedisJobManager(jobs are tenant-scoped; other tenants' jobs return 404)./ingest/page,/ingest/batchand/ingest/allnow record job entries and return ajob_id./ingest/repo-status/{repository}reports wiki page count vs indexed Document-node count underusers/{tenant}/{repository}plus the tenant's Redis job statistics./deduplicate/checkruns a tenant-scoped Qdrant similarity scan: wiki chunk pairs above the threshold (default 0.9 cosine) grouped per page pair with best score, matching chunk-pair count, and page references. Read-only.
- Job + Scheduler task plumbing — In-process hourly
job_cleanup_loop(started at app startup, cancelled at shutdown) reclaims expired Redis job-set memberships (JobManager.cleanup_expired_jobs).docs/scheduler-tasks.mddefines the four production Scheduler task payloads for the deploy checklist (nightly integrity 04:30, weekly quality report Sunday 03:00, daily Paperless orphan-cleanup 05:00 on the existing endpoint, and disablingtest_example_task) with exact HTTP bodies (explicituser=jpmschweitzer,${LIBRARY_API_KEY}auth placeholder).scripts/register_scheduler_tasks.pyreads the Scheduler API location fromSCHEDULER_URLand registers them — dry-run by default (prints payloads),--executegated and requiringLIBRARY_API_KEY. - Weekly quality report —
POST /maintenance/quality-report {user}runs the duplicate scan, flags stale pages (not updated in N days AND ≤ M SearchQuery hits from the graph data), lists pages missing tags/description, folds in the latest integrity-check results (Redis-cached or run inline), and writes a dated report page tousers/{user}/system/quality-reports/YYYY-MM-DD(same-day reruns update the same page — the page id is remembered in Redis because the Wiki.js listing lags page creation). Response returns the full report content + page path. Verified end-to-end against the local dev server asllm_tester. - Nightly integrity check —
POST /maintenance/integrity-check {user}(read-only: reports, never auto-fixes) reports per tenant: wiki pages with ZERO vectors in Qdrant (silent-skip reindex victims), orphaned vectors whose wiki page no longer exists, unexpected Qdrant collections (test-tenant residue and unknown namespaces flagged; other services' collections counted as foreign), Neo4j Document nodes without wiki counterparts, plus counts and duration. The latest report is cached in Redis (30 days) so the weekly quality report can fold it in.
Fixed (Phase D review batch)
- Runtime prefetch registration is executable -
SchedulerClient.register_volatile_fetch(used by consolidation's_register_prefetch) registered tasks that were dead on arrival three ways: the JSON body sat under the ignoredbodykey (rest_api_executoronly readsconfig["payload"]), there was noauthblock (the scheduled POST would 401 against library-desk'sverify_api_key), anduserwas in the body while every/volatile/fetchendpoint requires it as a QUERY parameter (would 422). The config now putsuserin the URL query string (URL-encoded), an emptypayload, andauth: {type: bearer, token: "${LIBRARY_API_KEY}"}(substituted from the Scheduler's environment; never stored raw).SchedulerClientitself also sent no Authorization to the Scheduler API, so registration failed silently at consolidation time — it now sendsAuthorization: Bearer <SCHEDULER_API_KEY>(newscheduler_api_keysetting; a client without a key logs a warning). - Document sync uses delete-last reindex order -
DocumentSyncService._index_vectorsdeleted the document's existing chunks (delete_by_filter) BEFORE embedding, so a failed embedding pass (e.g. Ollama down) left the Paperless document with zero vectors until the next successful sync — the exact hazard already fixed for wiki pages inVectorService.update_from_page. Chunk point ids are now deterministic (uuid5 ofdocument_{id}_chunk_{i}, replacing random uuid4) so re-upserting overwrites in place; new points are upserted first and stale points (including legacy uuid4 ones) are pruned afterwards, only after a successful upsert. - 5MB response cap now aborts the download -
ContentExtractor._fetchbuffered the entire response body in memory before truncating toMAX_RESPONSE_BYTES, so the cap protected Trafilatura but not memory/bandwidth (a multi-hundred-MB URL was still fully downloaded, on up tomax_urls_per_batchconcurrent fetches). Fetches are now streamed (client.stream+aiter_bytes) and the connection is closed as soon as the cap is reached. - Registrar authenticates to the Scheduler API -
scripts/register_scheduler_tasks.py --executesent noAuthorizationheader while the Scheduler's task-management endpoints are Bearer-guarded (verify_api_key: 401 on missing key), so the existence probes 401'd (misread as "task absent") and every registration failed; only the public/healthgate passed.--executenow requiresSCHEDULER_API_KEYin the environment (refuses to run without it, key is never stored) and sendsAuthorization: Bearer $SCHEDULER_API_KEYon all of its own HTTP calls. Deploy note updated alongside the existingLIBRARY_API_KEYrequirement.
Fixed (hazards batch)
- CORS wildcard + credentials removed -
allow_origins=["*"]combined withallow_credentials=Truetold browsers to attach credentials for any site. Credentials are now disabled (all real callers are server-to-server and use theAuthorizationheader, which wildcard-origin CORS without credentials still permits) and the origin list is configurable viaCORS_ALLOW_ORIGINS(comma-separated, default*). - Scheduler task auth no longer stores raw tokens - the Phase C Scheduler task definitions put
Authorization: Bearer ${LIBRARY_API_KEY}in plainheadersand the registrar substituted the REAL key client-side on--execute, which would persist it in the Scheduler'sscheduled_tasks.configJSONB column — and the Scheduler'srest_api_executordoes not substitute env vars in plain headers anyway (only inurl/payload/auth). The definitions now use the executor'sauth: {type: bearer, token: "${LIBRARY_API_KEY}"}block, substituted from the SCHEDULER's environment at execution time; the registrar sends the placeholder verbatim and no longer needs (or accepts) the key. Also fixed: the JSON body moved from the ignoredbodykey topayload(the executor only readsconfig["payload"], so the tasks would have POSTed empty bodies and failed Phase B user validation). - Reranker index parser dedupes - an LLM ranking answer like
3,3,1inserted the same result into the final ranking twice; parsed indices are now deduplicated preserving first occurrence. - Single HybridRAG wiring point - three separate constructions existed: an unused
dependencies.get_hybrid_rag_servicesingleton lackingvolatile_service, an inline per-request copy in the/query/hybridrouter, and another inline copy in/wiki/pages/smart-createalso lackingvolatile_service. All callers now use the dependencies singleton, which includesvolatile_service(smart-create research can now hit the volatile cache leg). - Hot-path Neo4j writes use write transactions - remaining graph writes ran as auto-commit
execute_querycalls (no retry, no transaction-function semantics):GraphServiceingestion (update_from_pagedocument + entity queries),delete_page,create_entity_mentions, document/paperless/collection node deletion, orphan-entity purge, stale-document purge,cleanup_broken_relationships; the webhook rename/delete cleanup writes; document-sync_index_graph; and consolidation's_mark_search_processed/_add_entity_to_graph. All now go throughexecute_write(managed transaction with driver retry). Read paths are unchanged.
Changed (performance)
- Content extractor hardened -
ContentExtractornow downloads pages withhttpx.AsyncClientunder real connect (3s) and read timeouts on the event loop; only the CPU-bound Trafilatura parse runs in the thread pool. Previouslytrafilatura.fetch_urlran inside the worker thread with no caller-side timeout control, so anasyncio.wait_fortimeout abandoned the thread while it kept downloading for up to ~30s. Trafilatura now parses each document ONCE viabare_extraction(text + metadata together) — the old code ranextract()twice (the XML pass was computed and discarded) plusbare_extraction, three full parses per page.extract_batchcaps full-page extractions per call (default 8; overflow URLs return unsuccessful so the web leg falls back to the search snippet), responses are capped at 5MB before parsing, and thread-pool queue depth is logged for backpressure visibility. - Top-k enrichment with one batched lookup - Phase 3 (
_enrich_with_related_dossiers) ran a sequential Neo4j round-trip for EVERY fused result and the final trim then discarded most of the output. It now enriches only the results that can still reach the response (the Phase 4 rerank slice of 20 when reranking is enabled, otherwisefinal_result_count) and resolves all of them in ONE UNWIND-batched Cypher query (GraphService.get_related_documents_batch, tenant-scoped like the single-page variant, per-page ordering/limit preserved). Unenriched tail results carry an emptyrelated_dossierslist as before. - Search persistence off the hot path, one atomic transaction - HybridRAG Phase 6 (
_persist_search_for_librarian) no longer gates the/query/hybridresponse: thesearch_idis generated up front and returned immediately while the Neo4j write runs as a background task (strong task references held so tasks are not GC'd mid-flight). The write itself collapsed from ~21+ sequential auto-commit queries (SearchQuery node + per-document FOUND links + per-web-result WebResult nodes) into ONE UNWIND-basedexecute_writetransaction, so a mid-way failure can no longer leave a partial SearchQuery graph behind. The persisted shape (SearchQuery properties incl.processed: false, tenant labels,FOUNDrelationship properties, WebResult properties) is unchanged and pinned bytests/test_search_persistence.pyagainst exactly what the consolidation service queries.timing.persistence_msnow reports 0 (no longer on the request path). - Batched embeddings + delete-last reindex -
OllamaClient.embed_batchnow sends ONE batched/api/embedrequest (verified against the live Ollama; the old "batch" looped one/api/embeddingscall per chunk) with a per-text fallback preserving partial-success semantics.VectorService.update_from_pageembeds all chunks in that single call and upserts them in one Qdrant batch, and the reindex order is reversed: new points are upserted BEFORE stale points are pruned (deterministic uuid5 chunk ids make the overwrite safe), so a mid-way failure can no longer leave a page with zero vectors — the old order deleted everything first. The summary now reportsstatus(success/partial/failed) andchunks_skippedinstead of unconditionalsuccess=True; a fully failed embedding pass keeps the old vectors and reports failure. Measured on a real 7-chunk page ingest asllm_testeragainst the local server: ~375ms → ~181ms median (3 runs each). - Document sync indexing fixed -
DocumentSyncService._index_vectorsnow awaitsensure_collection(the coroutine was created but never ran, so fresh tenants had no collection at upsert time), filters outNoneentries fromembed_batchso one failed chunk embedding no longer aborts the whole document upsert (all-failed still reports failure), and routes the rawclient.delete/client.upsertcalls through the async wrapper (delete_by_filter/ new batchupsert_points). Offline unit tests added. - Async Qdrant client -
QdrantClientWrappernow usesAsyncQdrantClientwith an explicit timeout (QDRANT_TIMEOUT, default 30s). Every vector call previously ran on the synchronous client inside async wrapper methods, blocking the FastAPI event loop for the duration of each Qdrant round-trip. The wrapper API is unchanged (all methods were alreadyasync), so call sites only gained real awaits. The HybridRAG document leg was moved off the deprecated rawclient.searchonto the wrapper'ssearch_vectors(fixing a latentAttributeError: it called the nonexistentollama.embed_text, so the leg always reportedfailed), and the health check awaitsget_collections.
Changed
- BREAKING:
useris now required on every tenant-data endpoint - The implicitjpmschweitzerdefault tenant (DEFAULT_USER) has been removed everywhere. All endpoints that read or write tenant data (/query/*,/wiki/*,/vector/*,/graph/*,/ingest/*,/volatile/*,/documents/*,/stats,/rag/search) now reject requests without an explicit, non-empty, non-whitespaceuser(HTTP 422), matching the existing/maintenance/*pattern. A shared validator (require_userdependency /RequiredUsermodel type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. Caller coordination required: tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicituseron every call — see the deploy checklist.
Fixed (security)
-
Cross-tenant leaks in HybridRAG legs and ingestion closed - A live probe as
user=llm_testerreturnedjpmschweitzerpages. Root causes fixed:- Ingestion namespace enforcement:
vectorandgraphupdate_from_pagenow refuse pages whose wiki path is outsideusers/{user}/(previously any tenant could ingest any page id — including another tenant's — into its own collection/labels, which is how foreign content ended up in the vector leg)./ingest/allclampspath_prefixto the caller's namespace (400 on cross-tenant prefixes) and defaults tousers/{user}. - Search persistence: the
FOUNDlink in HybridRAG phase 6 matched(d:Document {page_id})unscoped, attaching the caller's SearchQuery to other tenants' Document nodes; it now matches onlyUser_{Tenant}_Documentnodes. - Graph enrichment/consolidation queries scoped:
_get_entity_mention_count, entity-stub generation, orphan-entity find/purge, andcleanup_broken_relationshipsmatched unscopedDocument/SearchQuerynodes; all now use the tenant's labels. Entity-page existence checks list only the tenant's wiki namespace. - Volatile collections sanitized:
volatile_{user}collection names now use the sanitized user id (same scheme as document collections). - Namespace matching hardened:
is_path_in_user_namespacenow enforces a path-segment boundary (users/llm_tester2is no longer insidellm_tester's namespace) and compares sanitized tenant segments. - Offline unit tests added per leg (vector, graph, volatile, documents, enrichment, persistence, ingestion) asserting the tenant-scoped collection/label/path is used.
- Ingestion namespace enforcement:
-
/query/graphand/graph/queryhardened to read-only - The documented "automatic user scoping" was a no-op (a live probe confirmed any user string could read the whole graph) and the client permitted writes. Raw Cypher queries are now (1) rejected with 400 when they contain write clauses (CREATE/MERGE/DELETE/DETACH/SET/REMOVE/DROP/FOREACH/LOAD CSV) or anyCALLprocedure (conservative denylist on the uppercased query), and (2) executed through a Neo4j session opened withdefault_access_mode=READ_ACCESSso the database itself refuses writes as a backstop. The endpoints are now honestly documented as admin/debug, unscoped read-only: results are not restricted to the caller's tenant labels — use/graph/nodesfor tenant-scoped access.
Added
- Degradation signaling -
HybridRAGResponsenow includessource_status(per-leg'ok'/'failed'/'disabled'for vector, graph, web, volatile, documents) anddegraded(true when any enabled leg failed). Retrieval legs report errors instead of silently swallowing them; failed legs are logged at WARNING. Both fields are additive and optional, so clients that ignore them are unaffected. - Hard-isolated test-tenant lifecycle for the test suite -
tests/conftest.pyrewritten: the production host default (192.168.86.149) is gone (TEST_HOSTenv, safelocalhostdefault; the API under test is the local wakeup server viaLIBRARY_DESK_URL, never the production container on 8089). The suite is pinned to the reserved test tenantllm_tester; a session guard aborts the entire run if the effective tenant isjpmschweitzeror outside the reservedllm_tester*namespace. Integration tests are marked and only run withRUN_INTEGRATION_TESTS=1(plus a passing guard). A session-scoped teardown deletes ALLllm_testerartifacts created during the run — Qdrant*_llm_testercollections, Neo4jUser_Llm_Tester*-labelled nodes, theusers/llm_testerwiki subtree, andllm_testerRedis keys on the service DB — with hard tenant assertions before every delete. Legacy integration tests were pinned to the test tenant (no more production-namespace reads). - Live tenant-isolation test -
tests/test_tenant_isolation_live.py(integration-marked, guard-gated): creates and ingests a wiki page asllm_testeragainst the local wakeup server + shared services, asserts/query/hybridasllm_testerretrieves its own content with ZERO results from thejpmschweitzertenant, and asserts a third nonexistent tenant (llm_tester_void, still inside the reserved namespace — nothing is ever written asjpmschweitzer) gets zero results entirely. Teardown removes everything it created. - Test-residue purge tooling -
scripts/purge_test_artifacts.py: dry-run by DEFAULT (--executerequired for real deletion), targets only confirmed test residue (Qdrantlibrary_desk_llm_tester/test_user/library_desk_test_user/memories_llm_tester/volatile_llm_tester/core_ai_user_test_*/ anything containingllm_tester; Neo4jUser_Llm_Tester*-labelled nodes and legacyusers/llm*Document nodes;llm_testerRedis keys on the service DB), prints counts per target, hard-aborts if any target rule ever matches ajpmschweitzer-namespaced identifier, and documents the snapshot prerequisite (Qdrant snapshot API +neo4j-admin database dump) in its docstring. - Offline unit tests - New mock-based tests (no live services) for model-name resolution under the env collision, per-leg failure signaling, the
/statspage-count prefix, LLM-call timeouts, and Wiki.js listing pagination.
Fixed
- Ollama generation model env collision - Renamed the generation-model setting
ollama_modeltoollama_llm_model(env:OLLAMA_LLM_MODEL, defaultgemma4:e2b). The container envOLLAMA_MODEL=nomic-embed-text(meant for embeddings) was shadowing the generation model, breaking Phase 0 keyword extraction and Phase 4 LLM re-ranking on every request. Startup now logs the resolved generation model. - LLM call timeouts - Phase 0 keyword extraction and Phase 4 re-ranking are wrapped in a 12s
asyncio.wait_forwith graceful fallback, so a hung Ollama call can no longer gate retrieval for the full 120s client timeout. /statswiki page count - The endpoint passed the bare user name as path prefix (matching nothing) and always reported 0 pages; it now counts pages underusers/{user}.- Wiki.js page listing -
list_pagesapplied the API-sidelimitbefore client-side path/tag filters, dropping matching pages that sort late; the limit now applies after filtering.list_all_pagesreplaced its fake pagination loop with a real limit-growth loop (Wiki.js 2.xpages.listhas no offset argument) that fetches until the API returns fewer pages than requested. - Consolidation loop silently drained its queue on LLM failure - Root cause of the 30-minute knowledge-consolidation loop processing 0 searches ("No unprocessed searches found" in prod): the
OLLAMA_MODELenv collision (see below) made every consolidation/api/generatecall fail with HTTP 400 ("nomic-embed-text" does not support generate— confirmed in prod logs and by direct Ollama probe), classification returned empty, and the loop STILL marked every SearchQueryprocessed: true— permanently consuming the queue with zero pages ever created (live Neo4j: 197/200 SearchQuery nodes processed with no output). LLM-infrastructure failure now raisesConsolidationLLMUnavailableError: the affected searches stay unprocessed (retried next run), the batch aborts after the first failure, and the response reportssearches_deferred. Every run now logssearches_processedandduration_ms(also new response fields). The lookback boundary is now timezone-aware UTC. Regression tests added. - Wiki.js
update_pagewithout tags - Wiki.js 2.x requirestagson the update mutation (the server unconditionally maps over it); everyupdate_page(page_id, content=...)call without tags failed withCannot read properties of undefined (reading 'map')— this silently broke the consolidation service's page-update path too. The client now preserves the page's current tags when the caller does not supply any. - Wiki.js listing completeness under pre-filter limits - Observed live:
pages.list(limit=100)returned 43 pages while 140 existed (limit=500returned all) — Wiki.js applies the limit BEFORE its own visibility filtering, so "fewer pages than requested" does not mean the listing is complete and the limit-growth loop stopped early, silently truncating listings (page counts, cleanups, integrity scans). The loop now grows the limit until the returned count stops increasing (fixed point), at the cost of one confirming fetch.
[1.7.3] - 2026-01-07
Fixed
- Endpoint TTL defaults - Updated all fetch endpoint defaults to match namespace TTLs (2x refresh interval)
[1.7.2] - 2026-01-07
Fixed
- Volatile TTL doubled - TTL now 2x refresh interval to survive missed/delayed scheduler runs
[1.7.1] - 2026-01-07
Fixed
- CI workflow - Updated Gitea Actions to trigger on tag push (matching core-api)
[1.7.0] - 2026-01-07
Added
- Combined Environment Endpoint -
POST /volatile/fetch/environment/{city}- Fetches weather and air quality concurrently with
asyncio.gather() - Single geocode lookup shared between both API calls
- More efficient than calling weather and air_quality separately
- Reduces wall-clock time and eliminates redundant geocoding
- Fetches weather and air quality concurrently with
Fixed
- Scheduler executor name - Fixed
rest_api→rest_api_executorin SchedulerTask model and register_volatile_fetch() to prevent "Executor module not found" errors
[1.6.2] - 2025-12-30
Added
-
System Statistics Endpoint -
GET /stats- Neo4j: node counts by type (Document, Entity, Collection, Search)
- Qdrant: collection counts, total vectors, per-collection breakdown
- Wiki.js: total page count
- Paperless: documents, tags, correspondents, document types
-
Weather/Forecast Separation - Split weather into two distinct namespaces
POST /volatile/fetch/weather/{city}- Current conditions only (1hr TTL)POST /volatile/fetch/forecast/{city}- 7-day outlook (12hr TTL)- Different update frequencies for efficient caching
FORECASTnamespace added to volatile namespaces
Changed
- Weather namespace TTL changed from 30 minutes to 1 hour (current conditions)
- Forecast data now stored separately with 12 hour TTL
[1.6.1] - 2025-12-30
Added
- Weather Forecast Support - Enhanced weather fetch with 7-day daily forecasts
- Current conditions now include UV index
- Daily forecasts with high/low temps, conditions, precipitation chance, UV max
- Natural language text summary with multi-day outlook
- Sun Times Endpoint -
POST /volatile/fetch/sun/{city}- Sunrise and sunset times (HH:MM and ISO formats)
- Daylight duration in seconds and hours
- Separate volatile namespace with 24hr TTL
- Useful for home automation light triggers
- Air Quality Endpoint -
POST /volatile/fetch/air_quality/{city}- European and US AQI indices
- Pollutants: PM2.5, PM10, ozone, nitrogen dioxide, sulphur dioxide, carbon monoxide
- Pollen data (grass, birch, alder) for European locations (seasonal)
- Hourly refresh (1hr TTL)
- New Base Models
SunTimesdataclass for sunrise/sunset dataAirQualitydataclass with AQI and pollutantsAirQualityProviderabstract interface
- New Volatile Namespace -
SUNfor sunrise/sunset times (86400s default TTL)
Changed
- Weather fetch now uses
get_forecast()instead ofget_current()for richer data OpenMeteoProvidernow implements bothWeatherProviderandAirQualityProvider
[1.6.0] - 2025-12-29
Added
-
Memory System Implementation - Complete three-tier memory architecture
- Volatile Fetch Endpoints - Scheduler-driven prefetch for ephemeral data
POST /volatile/fetch/{namespace}/{key}- Fetch and cache external data- Weather, news, and financial data providers integrated
- Auto-caching with namespace-specific TTLs
- Unified Memory Routing - LLM-based classification of web results
- Routes content to wiki (stable), volatile (ephemeral), file (documents), or prefetch (scheduled)
- Integrated into consolidation service post-processor
- Document Recall in HybridRAG - Paperless documents as fourth retrieval source
- Documents searched alongside wiki, volatile, and web in parallel
- New config:
enable_documents,document_limit,document_threshold paperless_idfield in results for document attributiondocument_mstiming in performance breakdown
- Volatile Fetch Endpoints - Scheduler-driven prefetch for ephemeral data
-
Scheduler Integration - External scheduler service for prefetch task management
SchedulerClient- Full REST API client for task CRUD operationsregister_volatile_fetch()convenience method for prefetch registration- Consolidation service now creates scheduled tasks for prefetch-worthy content
- Health checks integrated into startup/shutdown lifecycle
Changed
- HybridRAG now searches 4 sources in parallel (wiki, volatile, documents, web)
- Consolidation service uses external scheduler instead of settings storage for prefetch
[1.5.0] - 2025-12-26
Added
-
Central Settings Database - Tatlock-wide configuration via PostgreSQL
SettingsClientfor async access tosystem_settingsdatabase- User-scoped settings with global fallback
- API config storage with
enabledtoggle and per-source category filters - JSON Schema support for future UI rendering
-
External API Providers - Modular
src/apis/package with swappable implementationsOpenMeteoProvider- Weather with geocoding (free, no API key)NOSProvider- Dutch news RSS (16 categories including sports)BBCProvider- English news RSS (21 categories including sports)AggregatedNewsProvider- Merges sources chronologically with category filteringAlphaVantageProvider- Stock/crypto quotes (API key from settings DB)- Abstract base classes for provider interoperability
-
Provider Dependency Injection
WeatherProviderDep,NewsProviderDep,AlphaVantageProviderDeptype aliases- Async initialization with settings database integration
- Lifecycle management in
shutdown_clients()
-
Development Dependencies -
requirements-dev.txtpip-auditfor security vulnerability scanningrufffor code quality- Testing packages moved from main requirements
Changed
- News sources configurable via
news.sourcessetting - Per-source category filtering via
api.{source}.categories - Categories default to all if not specified
[1.4.8] - 2025-12-25
Added
- Paperless Orphan Cleanup -
POST /maintenance/cleanup/paperlessendpoint- Detects documents deleted from Paperless but still indexed in Library Desk
- Removes orphaned vectors and graph nodes
- Supports
dry_run=truefor preview mode
[1.4.7] - 2025-12-25
Fixed
- Paperless Custom Field Update - Fixed 400 error when marking documents as indexed
- Paperless API requires field ID (integer) not field name (string)
- Now looks up
library_indexedfield ID before updating - Webhook params format:
doc_urlandtitlefrom Jinja templates
Added
- Webhook Debug Endpoint -
POST /documents/webhook-capturefor development testing
[1.4.6] - 2025-12-25
Fixed
- Paperless Webhook Payload Format - Updated model to match Paperless
include_document=trueformat- Paperless sends
idinstead ofdocument_id - Paperless sends full document data including
content,title,tags, etc. - Webhook now uses content from payload, skipping extra Paperless API call
- Added
extra = "ignore"to handle additional Paperless fields
- Paperless sends
[1.4.5] - 2025-12-25
Added
- Document Storage Integration - Paperless-ngx integration for PDFs, images, and documents
- Event-driven architecture via Paperless webhooks
POST /documents/webhook- Receive document events from Paperless workflowsPOST /documents/upload- Upload files directly to PaperlessPOST /documents/upload-url- Download and upload documents from URLPOST /documents/search- Semantic search across indexed documentsGET /documents/health- Paperless connectivity health check
- DocumentSyncService - Indexes Paperless documents into vectors and graph
- Fetches document content via Paperless API
- Chunks text and generates embeddings for Qdrant
- Creates Document nodes in Neo4j knowledge graph
- Supports multi-tenancy via user parameter in webhook URL
- PaperlessClient - REST API client for Paperless-ngx
- Document retrieval, upload, and update operations
- Health check support
- Paperless Workflow Configuration
- Production workflow: Document Added (NOT tagged llm-test) → webhook to Library Desk
- Test workflow: Document Added (tagged llm-test) → webhook with test user
Changed
- Updated
src/config.pywith Paperless configuration settings - Added
PaperlessDepdependency injection for document endpoints
[1.4.4] - 2025-12-24
Added
- Test Data Cleanup Endpoint -
POST /maintenance/cleanup/test-data- Purges LLM test data from wiki, graph, and vectors
- Security-restricted to test user namespace only (
users/llm-tester/*,users/llm_tester/*) - Supports
dry_run=true(default) to preview before deleting - Scheduler task configured for weekly cleanup (Sunday 3:00 AM)
[1.4.3] - 2025-12-24
Changed
- Volatile Cache System Refactored to Vector Storage
- Backend migrated from Redis to Qdrant for semantic search capability
- Data converted to natural language for embedding and semantic retrieval
- Collection naming:
volatile_{user}for per-user isolation - TTL implemented via
ttl_expirytimestamp in vector payload - Simplified endpoints:
GET /volatile/search?q=...- Semantic search across volatile dataPOST /volatile/store?namespace=...&key=...- Store with query paramsGET /volatile/{namespace}/{key}- Get specific recordDELETE /volatile/{namespace}/{key}- Delete record
- Removed namespace-specific URL patterns (simpler API for LLM tool use)
Added
- HybridRAG Volatile Integration - Volatile cache now included in multi-source search
- Volatile results get priority boost in RRF fusion (current data ranks higher)
- New config options:
enable_volatile,volatile_limit(default 1),volatile_threshold - Timing breakdown includes
volatile_ms
- Volatile Cleanup Endpoint -
POST /maintenance/cleanup/volatile- Purges expired records across all
volatile_*collections - Scheduler task for every 10 minutes recommended
- Returns per-collection cleanup counts
- Purges expired records across all
- Natural Language Conversion - Structured data converted for embedding
- Template-based conversion for each namespace (weather, news, financial, etc.)
- Fallback for custom namespaces
[1.4.2] - 2025-12-24
Added
- Volatile Cache System - Ephemeral data storage with TTL
GET /volatile/{namespace}/{key}- Retrieve cached recordPOST /volatile/{namespace}/{key}- Store/update record with TTLDELETE /volatile/{namespace}/{key}- Remove recordGET /volatile/{namespace}- List keys in namespaceDELETE /volatile/{namespace}- Clear all records in namespaceGET /volatile/stats- Cache statistics by namespaceGET /volatile/scheduled- Records needing refresh (for scheduler)GET /volatile/namespaces- List available namespaces with default TTLs
- Volatile Namespaces - Predefined categories with appropriate TTLs:
weather(30min) - Weather conditions and forecastsnews(1hr) - Headlines and breaking newsfinancial(5min) - Stock prices, exchange ratestransit(5min) - Train/bus schedules, delaystraffic(10min) - Commute times, road conditionsair_quality(1hr) - Pollution, pollen countssports(1min) - Live scores, matchessocial(10min) - Social notificationssystem(1min) - Service health statuscontext(1hr) - Session statecustom(1hr) - User-defined data
- Refresh Schedule Support - Optional cron expressions for scheduler integration
[1.4.1] - 2025-12-24
Fixed
- Wiki.js API token now optional - GraphQL API works without authentication
- Container startup failure when
WIKI_GRAPHQL_APIenv var not set
[1.4.0] - 2025-12-24
Added
- Maintenance Router - New
/maintenanceendpoints for system health and cleanupGET /maintenance/health- Lightweight health check (detailed mode available)POST /maintenance/cleanup/all- Full orphan cleanup (vectors + graph)POST /maintenance/cleanup/vectors- Purge orphan vector chunksPOST /maintenance/cleanup/graph- Purge orphan graph nodesPOST /maintenance/reconcile-index- Combined cleanup + reindex missing pages
- Bidirectional Orphan Detection - Cross-validate vectors and graph nodes
find_documents_without_vectors()- Graph nodes missing vector chunksfind_chunks_without_graph_nodes()- Vector chunks missing graph nodes
- Qdrant Client Methods - Bulk operations for maintenance
scroll_all_points()- Iterate all points with paginationdelete_by_ids()- Batch delete by point IDs
- Graph Service Cleanup - Node deletion methods
delete_document_node()- Remove document and relationshipsdelete_collection_node()- Remove collection and contained documentsget_all_document_references()- Get all document references for validation
- Redis Timestamp Tracking -
last_cleanuptimestamp for scheduler integration - Memory System Plan - Documented three-tier architecture (volatile/documents/knowledge)
Changed
- Wiki.js Authentication - Switched from username/password to API token
- New
WIKI_GRAPHQL_APIenvironment variable for JWT token - Deprecated
WIKIJS_USERNAMEandWIKIJS_PASSWORD(kept for backwards compatibility)
- New
- Service Dependencies - Added
VectorServiceDepandGraphServiceDeptype aliases
Fixed
- Wiki.js client now properly handles API token auth without login flow
[1.3.3] - 2025-12-23
Added
- Temperature parameter to
OllamaClient.generate_text()for controlling output determinism TODO.mdtracking remaining stub endpoints to implement- Wired
/query/semanticendpoint to VectorService - Wired
/query/graphendpoint to GraphService
Changed
- Improved LLM prompts based on llm-findings.md recommendations:
- Keyword extraction: temperature 0.0, negative constraints
- LLM re-ranking: temperature 0.0, explicit rules
- Conflict detection: temperature 0.0, analysis steps (CoT)
- Wiki page creation: temperature 0.3, anti-hallucination constraints
- Page reconstruction: temperature 0.2, preservation constraints
- Web results analysis: temperature 0.0, conservative approach
- Test fixtures now use configurable host (TEST_HOST) instead of Docker hostnames
Removed
- Dead code: unused
get_default_user()function - Unused imports from routers (wiki.py, graph.py, hybrid_rag.py)
- Stub endpoints shadowed by real implementations (/stats, /ingest/document, /ingest/batch)
[1.3.2] - 2025-12-22
Changed
- Consolidated Ollama model configuration - All LLM operations now use single
OLLAMA_MODELenvironment variable- Removed separate
reranker_modelsetting - HybridRAG re-ranking, consolidation analysis, and wiki page writing all use the same model
- Improves VRAM efficiency by keeping one model hot
- Removed separate
- Added
OLLAMA_EMBEDDING_MODELenvironment variable for embedding model (previously overloadedOLLAMA_MODEL) - Updated WikiPageWriter to accept settings instead of hardcoded model name
[1.3.1] - 2025-12-16
Fixed
- Smart create endpoint missing
content_extractordependency causing 500 errors onPOST /wiki/pages/smart-create
[1.3.0] - 2025-12-15
Changed
-
Two-Stage RRF Architecture - Major refactor to level the playing field between wiki and web results
- Stage 1: Vector and graph results merged into single "wiki" ranking using mini-RRF
- Stage 2: Final RRF between wiki (single source) and web (single source)
- Wiki pages no longer get 2x advantage from appearing in both vector and graph searches
- Multi-source confirmation still determines wiki internal ranking
-
Skip synonyms in graph search - LLM-generated synonyms (e.g., "author") no longer match unrelated graph entities (e.g., "author2000")
- Vector search still uses synonyms for semantic similarity
- Graph search uses only core keywords for exact entity matching
Added
VECTOR_SIMILARITY_THRESHOLDconfig setting (default: 0.7) to filter weak vector matches- Deduplication in graph search to prevent same document appearing multiple times
Fixed
- Graph search duplicate entity bug where same document could appear twice if entity linked multiple times
[1.2.1] - 2025-12-15
Fixed
- HybridRAG router missing
content_extractordependency causing 500 errors on/query/hybridendpoint
[1.2.0] - 2025-12-15
Added
-
RAG Search Endpoint (
POST /rag/search)- Web, news, and image search via SearXNG
- Full content extraction using Trafilatura (F1 score 0.958)
- Redis caching with configurable TTL
- Markdown sources summary for LLM consumption
- Returns both extracted content and original snippets
-
Content Extraction Endpoints (
/content/*)POST /content/extract- Extract content from a single URLPOST /content/extract/batch- Batch extraction (up to 20 URLs)- Reusable ContentExtractor client for use across the codebase
-
HybridRAG Content Extraction Enhancement
- Web search results now include full extracted content via Trafilatura
- Falls back to original snippets if extraction fails
- Improves context quality for LLM re-ranking and consumption
Changed
- Added new configuration options:
SEARCH_CACHE_TTL- Search cache TTL in seconds (default: 300)SEARCH_TIMEOUT- SearXNG timeout (default: 10s)CONTENT_EXTRACTION_TIMEOUT- Per-URL extraction timeout (default: 5s)CONTENT_MAX_LENGTH- Max extracted content length (default: 2000)SEARCH_DEFAULT_LIMIT- Default search results (default: 10)
Dependencies
- Added
trafilatura~=1.12.0for content extraction
[1.1.3] - 2025-12-14
Added
- Watchtower update trigger in Gitea workflow after successful build
[1.1.2] - 2025-12-14
Fixed
- Updated registry login URL in Gitea workflow (git.schweitz.net → git.schweitz.internal)
[1.1.1] - 2025-12-14
Fixed
- Updated container registry tag URLs in Gitea workflow (git.schweitz.net → git.schweitz.internal)
Added
- Tests for Smart Page Creation feature (
test_smart_create.py)- Model validation tests for WikiSmartCreateRequest/Response
- WikiService.smart_create_page method tests
- Bidirectional entity linking utility tests
- Endpoint validation tests
[1.1.0] - 2025-12-11
Added
-
Smart Page Creation Endpoint (
POST /wiki/pages/smart-create)- Combines HybridRAG research with LLM content generation
- Searches existing wiki, knowledge graph, and web for topic context
- Uses WikiPageWriter to synthesize findings into structured wiki content
- Auto-generates page path from topic if not provided
- Returns research summary with source counts
-
Bidirectional Entity Linking
- New shared utility (
entity_linking_utils.py) for reusable entity linking - Forward links: Links entities mentioned in new pages to existing entity pages
- Backward links: Updates existing pages that mention the new entity
- Runs automatically in background after smart page creation
- New shared utility (
-
Version Management
- Added
pyproject.tomlwith project metadata and version - Version is now read from
pyproject.toml(single source of truth) - Health check endpoint returns current version
- FastAPI docs show current version
- Added
Changed
- Updated
config.pyto read version frompyproject.toml - Updated
main.pyto use centralized version
[1.0.0] - 2025-12-10
Added
- Initial release extracted from portainer-core
- Wiki page management (
/wiki/pagesCRUD endpoints) - HybridRAG search (
/query/hybrid) with vector, graph, and web search - Knowledge graph operations (
/graph/*) - Vector search operations (
/vector/*) - Knowledge consolidation from search results (
/consolidate/knowledge) - Entity linking and extraction
- Wiki.js change listener for auto-processing user edits
- Multi-tenant architecture with user namespace isolation