Compare commits
@@ -0,0 +1,26 @@
|
||||
# Service URLs for local dev (pointing to your server)
|
||||
TEST_HOST=192.168.86.149
|
||||
WIKIJS_URL=http://wiki:3000
|
||||
NEO4J_URI=bolt://neo4j:7687
|
||||
QDRANT_HOST=192.168.86.149
|
||||
QDRANT_PORT=6333
|
||||
OLLAMA_URL=http://ollama:11434
|
||||
SEARXNG_URL=http://searxng:8080
|
||||
REDIS_HOST=192.168.86.149
|
||||
PAPERLESS_URL=http://paperless:8000
|
||||
|
||||
OLLAMA_LLM_MODEL=gemma4:e2b
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Wiki.js auth
|
||||
WIKIJS_USERNAME=librarian@schweitz.net
|
||||
WIKIJS_PASSWORD=key_here
|
||||
# Wiki.js GraphQL API token (generate from Admin → API Access)
|
||||
WIKI_GRAPHQL_API=your_jwt_token_here
|
||||
|
||||
LIBRARY_API_KEY=key_here
|
||||
NEO4J_PASSWORD=key_here
|
||||
WIKIJS_DB_PASSWORD=key_here
|
||||
SCHEDULER_API_KEY=key_here
|
||||
PAPERLESS_TOKEN=key_here
|
||||
SYSTEM_SETTINGS_PASSWORD=key_here
|
||||
@@ -1,12 +1,25 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -25,3 +38,9 @@ jobs:
|
||||
tags: |
|
||||
git.schweitz.net/jpmschweitzer/library-desk:latest
|
||||
git.schweitz.net/jpmschweitzer/library-desk:${{ github.ref_name }}
|
||||
|
||||
- name: Trigger Watchtower update
|
||||
if: success()
|
||||
run: |
|
||||
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
|
||||
http://watchtower:8080/v1/update
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
@@ -22,6 +23,47 @@
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
|
||||
### 🚀 Release Flow
|
||||
When changes are ready for deployment:
|
||||
|
||||
1. **Ask user if deploy cycle is desired **
|
||||
|
||||
2. **Update version** in `pyproject.toml`:
|
||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
||||
|
||||
3. **Update CHANGELOG.md**:
|
||||
- Move items from `[Unreleased]` to new version section
|
||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: description of changes"
|
||||
git tag v1.8.4
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new tag
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8089/health`
|
||||
|
||||
---
|
||||
|
||||
### 🧪 Local Development Setup
|
||||
* **Always test locally first** before committing and deploying. The build-deploy loop is slow.
|
||||
* **Start the local server** with `./wakeup.sh` - logs are written to `logs/server.log` for easy tailing
|
||||
* **Auto-reload**: The wakeup script runs uvicorn in reload mode - code changes are picked up automatically without restart (except for requirements.txt changes)
|
||||
* **Test REST endpoints** against `http://localhost:8778` using curl or similar tools
|
||||
* **Only deploy** when a phase or feature is complete and tested locally
|
||||
* **Environment**: Copy `.env.example` to `.env` and configure for your local setup (Ollama, Redis, Neo4j, Qdrant, Wiki.js hosts)
|
||||
* **Running tests**: Always use the venv explicitly to avoid environment mismatches:
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/ # All tests
|
||||
.venv/bin/python -m pytest tests/ -v # Verbose output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI Architecture & Best Practices
|
||||
|
||||
+568
@@ -5,6 +5,574 @@ All notable changes to Library Desk will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.9.1] - 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Wiki.js change listener now survives a database restart. It held a single
|
||||
`LISTEN` connection with no supervision, so when the connection dropped it was
|
||||
gone permanently while `running` stayed `True` — 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 when `postgres-shared` was
|
||||
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.running` is 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
|
||||
|
||||
- `/health` reports the change listener under `services.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 `NOTIFY`
|
||||
events emitted during the gap were lost and cannot be replayed, pointing at
|
||||
`POST /maintenance/integrity-check` to 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_key` now uses a constant-time comparison.
|
||||
|
||||
### Changed
|
||||
|
||||
- `static/wikijs-integration.js` calls library-desk same-origin (`/library-desk/...`) with `credentials: same-origin` and no `Authorization` header. 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.net` registry endpoint (the `.internal` registry domain is being retired); no change to the image name consumed by Watchtower.
|
||||
- `.env.example` service 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 explicit `user` (Phase B rule):
|
||||
- `/ingest/check-updates` compares the `content_hash` now 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, returning `changed` / `new` / `deleted` page lists (auto-generated entity stubs excluded; documents whose stored hash predates hash tracking are flagged `stored_hash_missing` and count as changed).
|
||||
- `/ingest/status/{job_id}` is backed by the Redis `JobManager` (jobs are tenant-scoped; other tenants' jobs return 404). `/ingest/page`, `/ingest/batch` and `/ingest/all` now record job entries and return a `job_id`.
|
||||
- `/ingest/repo-status/{repository}` reports wiki page count vs indexed Document-node count under `users/{tenant}/{repository}` plus the tenant's Redis job statistics.
|
||||
- `/deduplicate/check` runs 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.md` defines 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 disabling `test_example_task`) with exact HTTP bodies (explicit `user=jpmschweitzer`, `${LIBRARY_API_KEY}` auth placeholder). `scripts/register_scheduler_tasks.py` reads the Scheduler API location from `SCHEDULER_URL` and registers them — dry-run by default (prints payloads), `--execute` gated and requiring `LIBRARY_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 to `users/{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 as `llm_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 ignored `body` key (`rest_api_executor` only reads `config["payload"]`), there was no `auth` block (the scheduled POST would 401 against library-desk's `verify_api_key`), and `user` was in the body while every `/volatile/fetch` endpoint requires it as a QUERY parameter (would 422). The config now puts `user` in the URL query string (URL-encoded), an empty `payload`, and `auth: {type: bearer, token: "${LIBRARY_API_KEY}"}` (substituted from the Scheduler's environment; never stored raw). `SchedulerClient` itself also sent no Authorization to the Scheduler API, so registration failed silently at consolidation time — it now sends `Authorization: Bearer <SCHEDULER_API_KEY>` (new `scheduler_api_key` setting; a client without a key logs a warning).
|
||||
- **Document sync uses delete-last reindex order** - `DocumentSyncService._index_vectors` deleted 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 in `VectorService.update_from_page`. Chunk point ids are now deterministic (uuid5 of `document_{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._fetch` buffered the entire response body in memory before truncating to `MAX_RESPONSE_BYTES`, so the cap protected Trafilatura but not memory/bandwidth (a multi-hundred-MB URL was still fully downloaded, on up to `max_urls_per_batch` concurrent 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 --execute` sent no `Authorization` header 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 `/health` gate passed. `--execute` now requires `SCHEDULER_API_KEY` in the environment (refuses to run without it, key is never stored) and sends `Authorization: Bearer $SCHEDULER_API_KEY` on all of its own HTTP calls. Deploy note updated alongside the existing `LIBRARY_API_KEY` requirement.
|
||||
|
||||
### Fixed (hazards batch)
|
||||
|
||||
- **CORS wildcard + credentials removed** - `allow_origins=["*"]` combined with `allow_credentials=True` told browsers to attach credentials for any site. Credentials are now disabled (all real callers are server-to-server and use the `Authorization` header, which wildcard-origin CORS without credentials still permits) and the origin list is configurable via `CORS_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 plain `headers` and the registrar substituted the REAL key client-side on `--execute`, which would persist it in the Scheduler's `scheduled_tasks.config` JSONB column — and the Scheduler's `rest_api_executor` does not substitute env vars in plain headers anyway (only in `url`/`payload`/`auth`). The definitions now use the executor's `auth: {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 ignored `body` key to `payload` (the executor only reads `config["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,1` inserted 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_service` singleton lacking `volatile_service`, an inline per-request copy in the `/query/hybrid` router, and another inline copy in `/wiki/pages/smart-create` also lacking `volatile_service`. All callers now use the dependencies singleton, which includes `volatile_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_query` calls (no retry, no transaction-function semantics): `GraphService` ingestion (`update_from_page` document + 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 through `execute_write` (managed transaction with driver retry). Read paths are unchanged.
|
||||
|
||||
### Changed (performance)
|
||||
|
||||
- **Content extractor hardened** - `ContentExtractor` now downloads pages with `httpx.AsyncClient` under real connect (3s) and read timeouts on the event loop; only the CPU-bound Trafilatura parse runs in the thread pool. Previously `trafilatura.fetch_url` ran inside the worker thread with no caller-side timeout control, so an `asyncio.wait_for` timeout abandoned the thread while it kept downloading for up to ~30s. Trafilatura now parses each document ONCE via `bare_extraction` (text + metadata together) — the old code ran `extract()` twice (the XML pass was computed and discarded) plus `bare_extraction`, three full parses per page. `extract_batch` caps 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, otherwise `final_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 empty `related_dossiers` list as before.
|
||||
- **Search persistence off the hot path, one atomic transaction** - HybridRAG Phase 6 (`_persist_search_for_librarian`) no longer gates the `/query/hybrid` response: the `search_id` is 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-based `execute_write` transaction, so a mid-way failure can no longer leave a partial SearchQuery graph behind. The persisted shape (SearchQuery properties incl. `processed: false`, tenant labels, `FOUND` relationship properties, WebResult properties) is unchanged and pinned by `tests/test_search_persistence.py` against exactly what the consolidation service queries. `timing.persistence_ms` now reports 0 (no longer on the request path).
|
||||
- **Batched embeddings + delete-last reindex** - `OllamaClient.embed_batch` now sends ONE batched `/api/embed` request (verified against the live Ollama; the old "batch" looped one `/api/embeddings` call per chunk) with a per-text fallback preserving partial-success semantics. `VectorService.update_from_page` embeds 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 reports `status` (`success`/`partial`/`failed`) and `chunks_skipped` instead of unconditional `success=True`; a fully failed embedding pass keeps the old vectors and reports failure. Measured on a real 7-chunk page ingest as `llm_tester` against the local server: ~375ms → ~181ms median (3 runs each).
|
||||
- **Document sync indexing fixed** - `DocumentSyncService._index_vectors` now awaits `ensure_collection` (the coroutine was created but never ran, so fresh tenants had no collection at upsert time), filters out `None` entries from `embed_batch` so one failed chunk embedding no longer aborts the whole document upsert (all-failed still reports failure), and routes the raw `client.delete`/`client.upsert` calls through the async wrapper (`delete_by_filter` / new batch `upsert_points`). Offline unit tests added.
|
||||
- **Async Qdrant client** - `QdrantClientWrapper` now uses `AsyncQdrantClient` with 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 already `async`), so call sites only gained real awaits. The HybridRAG document leg was moved off the deprecated raw `client.search` onto the wrapper's `search_vectors` (fixing a latent `AttributeError`: it called the nonexistent `ollama.embed_text`, so the leg always reported `failed`), and the health check awaits `get_collections`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default 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-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model 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 explicit `user` on every call — see the deploy checklist.
|
||||
|
||||
### Fixed (security)
|
||||
|
||||
- **Cross-tenant leaks in HybridRAG legs and ingestion closed** - A live probe as `user=llm_tester` returned `jpmschweitzer` pages. Root causes fixed:
|
||||
- **Ingestion namespace enforcement**: `vector` and `graph` `update_from_page` now refuse pages whose wiki path is outside `users/{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/all` clamps `path_prefix` to the caller's namespace (400 on cross-tenant prefixes) and defaults to `users/{user}`.
|
||||
- **Search persistence**: the `FOUND` link in HybridRAG phase 6 matched `(d:Document {page_id})` unscoped, attaching the caller's SearchQuery to other tenants' Document nodes; it now matches only `User_{Tenant}_Document` nodes.
|
||||
- **Graph enrichment/consolidation queries scoped**: `_get_entity_mention_count`, entity-stub generation, orphan-entity find/purge, and `cleanup_broken_relationships` matched unscoped `Document`/`SearchQuery` nodes; 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_namespace` now enforces a path-segment boundary (`users/llm_tester2` is no longer inside `llm_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.
|
||||
|
||||
- **`/query/graph` and `/graph/query` hardened 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 any `CALL` procedure (conservative denylist on the uppercased query), and (2) executed through a Neo4j session opened with `default_access_mode=READ_ACCESS` so 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/nodes` for tenant-scoped access.
|
||||
|
||||
### Added
|
||||
|
||||
- **Degradation signaling** - `HybridRAGResponse` now includes `source_status` (per-leg `'ok'`/`'failed'`/`'disabled'` for vector, graph, web, volatile, documents) and `degraded` (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.py` rewritten: the production host default (`192.168.86.149`) is gone (`TEST_HOST` env, safe `localhost` default; the API under test is the local wakeup server via `LIBRARY_DESK_URL`, never the production container on 8089). The suite is pinned to the reserved test tenant `llm_tester`; a session guard aborts the entire run if the effective tenant is `jpmschweitzer` or outside the reserved `llm_tester*` namespace. Integration tests are marked and only run with `RUN_INTEGRATION_TESTS=1` (plus a passing guard). A session-scoped teardown deletes ALL `llm_tester` artifacts created during the run — Qdrant `*_llm_tester` collections, Neo4j `User_Llm_Tester*`-labelled nodes, the `users/llm_tester` wiki subtree, and `llm_tester` Redis 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 as `llm_tester` against the local wakeup server + shared services, asserts `/query/hybrid` as `llm_tester` retrieves its own content with ZERO results from the `jpmschweitzer` tenant, and asserts a third nonexistent tenant (`llm_tester_void`, still inside the reserved namespace — nothing is ever written as `jpmschweitzer`) gets zero results entirely. Teardown removes everything it created.
|
||||
- **Test-residue purge tooling** - `scripts/purge_test_artifacts.py`: dry-run by DEFAULT (`--execute` required for real deletion), targets only confirmed test residue (Qdrant `library_desk_llm_tester` / `test_user` / `library_desk_test_user` / `memories_llm_tester` / `volatile_llm_tester` / `core_ai_user_test_*` / anything containing `llm_tester`; Neo4j `User_Llm_Tester*`-labelled nodes and legacy `users/llm*` Document nodes; `llm_tester` Redis keys on the service DB), prints counts per target, hard-aborts if any target rule ever matches a `jpmschweitzer`-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 `/stats` page-count prefix, LLM-call timeouts, and Wiki.js listing pagination.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ollama generation model env collision** - Renamed the generation-model setting `ollama_model` to `ollama_llm_model` (env: `OLLAMA_LLM_MODEL`, default `gemma4:e2b`). The container env `OLLAMA_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_for` with graceful fallback, so a hung Ollama call can no longer gate retrieval for the full 120s client timeout.
|
||||
- **`/stats` wiki page count** - The endpoint passed the bare user name as path prefix (matching nothing) and always reported 0 pages; it now counts pages under `users/{user}`.
|
||||
- **Wiki.js page listing** - `list_pages` applied the API-side `limit` before client-side path/tag filters, dropping matching pages that sort late; the limit now applies after filtering. `list_all_pages` replaced its fake pagination loop with a real limit-growth loop (Wiki.js 2.x `pages.list` has 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_MODEL` env collision (see below) made every consolidation `/api/generate` call 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 SearchQuery `processed: true` — permanently consuming the queue with zero pages ever created (live Neo4j: 197/200 SearchQuery nodes processed with no output). LLM-infrastructure failure now raises `ConsolidationLLMUnavailableError`: the affected searches stay unprocessed (retried next run), the batch aborts after the first failure, and the response reports `searches_deferred`. Every run now logs `searches_processed` and `duration_ms` (also new response fields). The lookback boundary is now timezone-aware UTC. Regression tests added.
|
||||
- **Wiki.js `update_page` without tags** - Wiki.js 2.x requires `tags` on the update mutation (the server unconditionally maps over it); every `update_page(page_id, content=...)` call without tags failed with `Cannot 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=500` returned 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scheduler executor name** - Fixed `rest_api` → `rest_api_executor` in 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
|
||||
- `FORECAST` namespace 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**
|
||||
- `SunTimes` dataclass for sunrise/sunset data
|
||||
- `AirQuality` dataclass with AQI and pollutants
|
||||
- `AirQualityProvider` abstract interface
|
||||
- **New Volatile Namespace** - `SUN` for sunrise/sunset times (86400s default TTL)
|
||||
|
||||
### Changed
|
||||
|
||||
- Weather fetch now uses `get_forecast()` instead of `get_current()` for richer data
|
||||
- `OpenMeteoProvider` now implements both `WeatherProvider` and `AirQualityProvider`
|
||||
|
||||
## [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_id` field in results for document attribution
|
||||
- `document_ms` timing in performance breakdown
|
||||
|
||||
- **Scheduler Integration** - External scheduler service for prefetch task management
|
||||
- `SchedulerClient` - Full REST API client for task CRUD operations
|
||||
- `register_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
|
||||
- `SettingsClient` for async access to `system_settings` database
|
||||
- User-scoped settings with global fallback
|
||||
- API config storage with `enabled` toggle and per-source category filters
|
||||
- JSON Schema support for future UI rendering
|
||||
|
||||
- **External API Providers** - Modular `src/apis/` package with swappable implementations
|
||||
- `OpenMeteoProvider` - 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 filtering
|
||||
- `AlphaVantageProvider` - Stock/crypto quotes (API key from settings DB)
|
||||
- Abstract base classes for provider interoperability
|
||||
|
||||
- **Provider Dependency Injection**
|
||||
- `WeatherProviderDep`, `NewsProviderDep`, `AlphaVantageProviderDep` type aliases
|
||||
- Async initialization with settings database integration
|
||||
- Lifecycle management in `shutdown_clients()`
|
||||
|
||||
- **Development Dependencies** - `requirements-dev.txt`
|
||||
- `pip-audit` for security vulnerability scanning
|
||||
- `ruff` for code quality
|
||||
- Testing packages moved from main requirements
|
||||
|
||||
### Changed
|
||||
|
||||
- News sources configurable via `news.sources` setting
|
||||
- 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/paperless` endpoint
|
||||
- Detects documents deleted from Paperless but still indexed in Library Desk
|
||||
- Removes orphaned vectors and graph nodes
|
||||
- Supports `dry_run=true` for 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_indexed` field ID before updating
|
||||
- Webhook params format: `doc_url` and `title` from Jinja templates
|
||||
|
||||
### Added
|
||||
|
||||
- **Webhook Debug Endpoint** - `POST /documents/webhook-capture` for development testing
|
||||
|
||||
## [1.4.6] - 2025-12-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Paperless Webhook Payload Format** - Updated model to match Paperless `include_document=true` format
|
||||
- Paperless sends `id` instead of `document_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
|
||||
|
||||
## [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 workflows
|
||||
- `POST /documents/upload` - Upload files directly to Paperless
|
||||
- `POST /documents/upload-url` - Download and upload documents from URL
|
||||
- `POST /documents/search` - Semantic search across indexed documents
|
||||
- `GET /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.py` with Paperless configuration settings
|
||||
- Added `PaperlessDep` dependency 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_expiry` timestamp in vector payload
|
||||
- Simplified endpoints:
|
||||
- `GET /volatile/search?q=...` - Semantic search across volatile data
|
||||
- `POST /volatile/store?namespace=...&key=...` - Store with query params
|
||||
- `GET /volatile/{namespace}/{key}` - Get specific record
|
||||
- `DELETE /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
|
||||
- **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 record
|
||||
- `POST /volatile/{namespace}/{key}` - Store/update record with TTL
|
||||
- `DELETE /volatile/{namespace}/{key}` - Remove record
|
||||
- `GET /volatile/{namespace}` - List keys in namespace
|
||||
- `DELETE /volatile/{namespace}` - Clear all records in namespace
|
||||
- `GET /volatile/stats` - Cache statistics by namespace
|
||||
- `GET /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 forecasts
|
||||
- `news` (1hr) - Headlines and breaking news
|
||||
- `financial` (5min) - Stock prices, exchange rates
|
||||
- `transit` (5min) - Train/bus schedules, delays
|
||||
- `traffic` (10min) - Commute times, road conditions
|
||||
- `air_quality` (1hr) - Pollution, pollen counts
|
||||
- `sports` (1min) - Live scores, matches
|
||||
- `social` (10min) - Social notifications
|
||||
- `system` (1min) - Service health status
|
||||
- `context` (1hr) - Session state
|
||||
- `custom` (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_API` env var not set
|
||||
|
||||
## [1.4.0] - 2025-12-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Maintenance Router** - New `/maintenance` endpoints for system health and cleanup
|
||||
- `GET /maintenance/health` - Lightweight health check (detailed mode available)
|
||||
- `POST /maintenance/cleanup/all` - Full orphan cleanup (vectors + graph)
|
||||
- `POST /maintenance/cleanup/vectors` - Purge orphan vector chunks
|
||||
- `POST /maintenance/cleanup/graph` - Purge orphan graph nodes
|
||||
- `POST /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 chunks
|
||||
- `find_chunks_without_graph_nodes()` - Vector chunks missing graph nodes
|
||||
- **Qdrant Client Methods** - Bulk operations for maintenance
|
||||
- `scroll_all_points()` - Iterate all points with pagination
|
||||
- `delete_by_ids()` - Batch delete by point IDs
|
||||
- **Graph Service Cleanup** - Node deletion methods
|
||||
- `delete_document_node()` - Remove document and relationships
|
||||
- `delete_collection_node()` - Remove collection and contained documents
|
||||
- `get_all_document_references()` - Get all document references for validation
|
||||
- **Redis Timestamp Tracking** - `last_cleanup` timestamp 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_API` environment variable for JWT token
|
||||
- Deprecated `WIKIJS_USERNAME` and `WIKIJS_PASSWORD` (kept for backwards compatibility)
|
||||
- **Service Dependencies** - Added `VectorServiceDep` and `GraphServiceDep` type 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.md` tracking remaining stub endpoints to implement
|
||||
- Wired `/query/semantic` endpoint to VectorService
|
||||
- Wired `/query/graph` endpoint 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_MODEL` environment variable
|
||||
- Removed separate `reranker_model` setting
|
||||
- HybridRAG re-ranking, consolidation analysis, and wiki page writing all use the same model
|
||||
- Improves VRAM efficiency by keeping one model hot
|
||||
- Added `OLLAMA_EMBEDDING_MODEL` environment variable for embedding model (previously overloaded `OLLAMA_MODEL`)
|
||||
- Updated WikiPageWriter to accept settings instead of hardcoded model name
|
||||
|
||||
## [1.3.1] - 2025-12-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Smart create endpoint missing `content_extractor` dependency causing 500 errors on `POST /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_THRESHOLD` config 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_extractor` dependency causing 500 errors on `/query/hybrid` endpoint
|
||||
|
||||
## [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 URL
|
||||
- `POST /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.0` for 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Claude Code Instructions
|
||||
|
||||
**MANDATORY: Read AGENTS.md instead of this file.**
|
||||
|
||||
This project uses a unified configuration file for all LLM coding agents.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Read and follow AGENTS.md** - All project guidelines are located there
|
||||
2. **Do not modify this file** - Only update AGENTS.md
|
||||
3. **Do not create or modify other agent-specific files** - Use AGENTS.md as the single source of truth
|
||||
|
||||
This approach ensures consistent behavior across all LLM coding agents without managing separate configuration files.
|
||||
|
||||
---
|
||||
|
||||
If you need to update project guidelines, edit AGENTS.md, not this file.
|
||||
@@ -12,6 +12,7 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY pyproject.toml .
|
||||
COPY src/ ./src/
|
||||
COPY static/ ./static/
|
||||
|
||||
|
||||
@@ -455,6 +455,154 @@ LIBRARY_BATCH_SIZE=50
|
||||
LIBRARY_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
## Maintenance Tasks
|
||||
|
||||
### Index Reconciliation (Daily)
|
||||
|
||||
The `reconcile-index` endpoint performs full index maintenance:
|
||||
|
||||
1. **Cleanup Phase**: Remove orphaned data
|
||||
- Vector chunks without wiki source
|
||||
- Graph nodes without vectors (bidirectional)
|
||||
- Vectors without graph nodes (bidirectional)
|
||||
- Orphan entities (no MENTIONS relationships)
|
||||
- Broken relationships
|
||||
|
||||
2. **Reindex Phase**: Index missing pages
|
||||
- Wiki pages without vector embeddings
|
||||
- Wiki pages without graph Document nodes
|
||||
|
||||
**Scheduler Task: `library_reconcile_index`**
|
||||
|
||||
```yaml
|
||||
Task Name: library_reconcile_index
|
||||
Description: Daily index reconciliation - cleanup orphans + reindex missing pages
|
||||
Schedule: Daily at 04:00 (after library_sync at 03:30)
|
||||
Priority: 10 (system maintenance)
|
||||
Service: library
|
||||
Executor: POST /maintenance/reconcile-index
|
||||
Configuration:
|
||||
- LIBRARY_DESK_URL: http://library-desk:8089
|
||||
- LIBRARY_API_KEY: ${LIBRARY_API_KEY}
|
||||
Parameters:
|
||||
- user: jpmschweitzer
|
||||
- dry_run: false
|
||||
Outputs:
|
||||
- Vector orphans purged
|
||||
- Entity orphans purged
|
||||
- Missing pages reindexed
|
||||
```
|
||||
|
||||
### Maintenance Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/maintenance/reconcile-index` | POST | **Recommended**: Full cleanup + reindex missing |
|
||||
| `/maintenance/cleanup/all` | POST | Cleanup only (orphan removal) |
|
||||
| `/maintenance/cleanup/vectors` | POST | Clean orphan vector chunks only |
|
||||
| `/maintenance/cleanup/graph` | POST | Clean orphan entities & stale docs only |
|
||||
| `/maintenance/health` | GET | Lightweight health check (for uptime monitoring) |
|
||||
| `/maintenance/health?detailed=true` | GET | Full analysis with orphan counts |
|
||||
| `/maintenance/reindex/{page_id}` | POST | Force re-index a specific page |
|
||||
|
||||
### Health Check Modes
|
||||
|
||||
**Lightweight (default)** - Use for frequent uptime checks (every 30s):
|
||||
```bash
|
||||
curl "http://library-desk:8089/maintenance/health?user=jpmschweitzer" \
|
||||
-H "Authorization: Bearer ${LIBRARY_API_KEY}"
|
||||
```
|
||||
|
||||
Returns only last cleanup timestamp and basic status (no database queries).
|
||||
|
||||
**Detailed** - Use for dashboards or before reconciliation:
|
||||
```bash
|
||||
curl "http://library-desk:8089/maintenance/health?user=jpmschweitzer&detailed=true" \
|
||||
-H "Authorization: Bearer ${LIBRARY_API_KEY}"
|
||||
```
|
||||
|
||||
Returns full orphan analysis (runs database queries).
|
||||
|
||||
### Example Reconcile Request
|
||||
|
||||
```bash
|
||||
curl -X POST "http://library-desk:8089/maintenance/reconcile-index?user=jpmschweitzer" \
|
||||
-H "Authorization: Bearer ${LIBRARY_API_KEY}"
|
||||
```
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"cleanup": {
|
||||
"success": true,
|
||||
"vector_cleanup": {
|
||||
"wiki_chunks": {"orphans_found": 5, "orphans_purged": 5},
|
||||
"document_chunks": {"orphans_found": 0, "orphans_purged": 0},
|
||||
"chunks_without_graph": {"orphans_found": 2, "orphans_purged": 2},
|
||||
"total_chunks_scanned": 1250,
|
||||
"total_orphans_purged": 7
|
||||
},
|
||||
"graph_cleanup": {
|
||||
"orphan_entities": {"orphans_found": 3, "orphans_purged": 3},
|
||||
"stale_wiki_documents": {"orphans_found": 1, "orphans_purged": 1},
|
||||
"stale_store_documents": {"orphans_found": 0, "orphans_purged": 0},
|
||||
"docs_without_vectors": {"orphans_found": 0, "orphans_purged": 0},
|
||||
"broken_relationships_cleaned": 0
|
||||
},
|
||||
"total_duration_ms": 1523.5
|
||||
},
|
||||
"reindex_missing": {
|
||||
"pages_without_vectors": 2,
|
||||
"pages_without_graph": 1,
|
||||
"pages_reindexed": 2,
|
||||
"pages_failed": 0,
|
||||
"failed_page_ids": [],
|
||||
"duration_ms": 3421.2
|
||||
},
|
||||
"total_duration_ms": 4944.7
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduler Integration Code
|
||||
|
||||
```python
|
||||
# scheduler/src/tasks/library_maintenance.py
|
||||
|
||||
async def library_reconcile_index_task(user: str = "jpmschweitzer"):
|
||||
"""Run daily Library Desk index reconciliation."""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Run reconcile-index (cleanup + reindex missing)
|
||||
result = await client.post(
|
||||
f"{LIBRARY_DESK_URL}/maintenance/reconcile-index",
|
||||
params={"user": user, "dry_run": False},
|
||||
headers={"Authorization": f"Bearer {LIBRARY_API_KEY}"},
|
||||
timeout=600.0 # 10 minutes for large indexes
|
||||
)
|
||||
|
||||
data = result.json()
|
||||
|
||||
# Log summary
|
||||
cleanup = data["cleanup"]
|
||||
reindex = data["reindex_missing"]
|
||||
|
||||
logger.info(
|
||||
f"Reconcile complete: "
|
||||
f"{cleanup['vector_cleanup']['total_orphans_purged']} vector orphans, "
|
||||
f"{cleanup['graph_cleanup']['orphan_entities']['orphans_purged']} entity orphans, "
|
||||
f"{reindex['pages_reindexed']} pages reindexed"
|
||||
)
|
||||
|
||||
if reindex["pages_failed"] > 0:
|
||||
logger.warning(f"Failed to reindex pages: {reindex['failed_page_ids']}")
|
||||
|
||||
return data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement ingestion endpoints in Library Desk
|
||||
|
||||
@@ -49,7 +49,8 @@ QDRANT_PORT=6333
|
||||
WIKIJS_URL=http://wiki:3000
|
||||
SEARXNG_URL=http://searxng:8080
|
||||
OLLAMA_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=nomic-embed-text
|
||||
OLLAMA_MODEL=mistral-nemo-large:latest
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
REDIS_HOST=redis-shared
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=2
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# TODO
|
||||
|
||||
Outstanding work items for Library Desk.
|
||||
|
||||
## Stub Endpoints to Implement
|
||||
|
||||
The following endpoints in `src/main.py` return stub responses and need real implementations:
|
||||
|
||||
### Ingestion Status Endpoints
|
||||
|
||||
#### `POST /ingest/check-updates`
|
||||
Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync.
|
||||
|
||||
**Implementation needed:**
|
||||
1. Query existing documents by path
|
||||
2. Compare content hashes
|
||||
3. Return list of updates needed
|
||||
|
||||
#### `GET /ingest/status/{document_id}`
|
||||
Get processing status for a document.
|
||||
|
||||
**Implementation needed:**
|
||||
- Status tracking system (Redis or database)
|
||||
- Track ingestion progress per document
|
||||
|
||||
#### `GET /ingest/repo-status/{repository}`
|
||||
Get indexing status for an entire repository.
|
||||
|
||||
**Implementation needed:**
|
||||
- Repository-level statistics
|
||||
- Track which documents from a repo are indexed
|
||||
|
||||
### Deduplication
|
||||
|
||||
#### `POST /deduplicate/check`
|
||||
Check for duplicate or highly similar documents using vector similarity and graph analysis.
|
||||
|
||||
**Implementation needed:**
|
||||
1. Get document embedding from Qdrant
|
||||
2. Find similar vectors above threshold
|
||||
3. Check graph relationships
|
||||
4. Return candidates with similarity scores
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quick script to check environmental data in Qdrant volatile cache."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
async def main():
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
||||
from src.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
qdrant = QdrantClient(url=settings.qdrant_url)
|
||||
|
||||
user = "jpmschweitzer"
|
||||
collection = f"volatile_{user}"
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Volatile Data in Qdrant ({collection})")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
namespaces = ["weather", "air_quality", "forecast", "sun", "news"]
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
for ns in namespaces:
|
||||
try:
|
||||
from qdrant_client.models import Range
|
||||
|
||||
# Filter by namespace AND not expired
|
||||
results = qdrant.scroll(
|
||||
collection_name=collection,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="namespace", match=MatchValue(value=ns)),
|
||||
FieldCondition(key="ttl_expiry", range=Range(gt=now_ms)),
|
||||
]
|
||||
),
|
||||
limit=10,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
points = results[0]
|
||||
print(f"=== {ns.upper()} [namespace:{ns}] ({len(points)} records) ===")
|
||||
|
||||
if not points:
|
||||
print(" (no data)")
|
||||
print()
|
||||
continue
|
||||
|
||||
for point in points:
|
||||
payload = point.payload
|
||||
raw_data = payload.get("raw_data", {})
|
||||
|
||||
if ns == "weather":
|
||||
print(f" Temperature: {raw_data.get('temperature')}°C (feels like {raw_data.get('feels_like')}°C)")
|
||||
print(f" Conditions: {raw_data.get('conditions')}")
|
||||
print(f" Humidity: {raw_data.get('humidity')}%")
|
||||
print(f" Wind: {raw_data.get('wind_speed')} km/h")
|
||||
print(f" UV Index: {raw_data.get('uv_index')}")
|
||||
|
||||
elif ns == "air_quality":
|
||||
print(f" European AQI: {raw_data.get('aqi_european')}")
|
||||
print(f" US AQI: {raw_data.get('aqi_us')}")
|
||||
print(f" PM2.5: {raw_data.get('pm2_5')} µg/m³")
|
||||
print(f" PM10: {raw_data.get('pm10')} µg/m³")
|
||||
print(f" Ozone: {raw_data.get('ozone')} µg/m³")
|
||||
print(f" NO₂: {raw_data.get('nitrogen_dioxide')} µg/m³")
|
||||
|
||||
elif ns == "forecast":
|
||||
daily = raw_data.get("daily", [])
|
||||
for day in daily[:5]:
|
||||
print(f" {day.get('day_name', 'N/A')[:3]}: {day.get('temp_low'):.0f}-{day.get('temp_high'):.0f}°C, {day.get('conditions')}")
|
||||
|
||||
elif ns == "sun":
|
||||
print(f" Sunrise: {raw_data.get('sunrise')}")
|
||||
print(f" Sunset: {raw_data.get('sunset')}")
|
||||
print(f" Daylight: {raw_data.get('daylight_hours', 0):.1f} hours")
|
||||
|
||||
elif ns == "news":
|
||||
headlines = raw_data.get("headlines", [])
|
||||
print(f" Category: {raw_data.get('category', 'general')}")
|
||||
print(f" Headlines ({len(headlines)}):")
|
||||
for item in headlines[:5]:
|
||||
title = item.get("title", "")[:60]
|
||||
source = item.get("source", "")
|
||||
print(f" - [{source}] {title}...")
|
||||
|
||||
# Show TTL info
|
||||
ttl_expiry = payload.get("ttl_expiry")
|
||||
if ttl_expiry:
|
||||
remaining = (ttl_expiry / 1000) - time.time()
|
||||
if remaining > 0:
|
||||
print(f" TTL remaining: {int(remaining)}s ({int(remaining/60)} min)")
|
||||
else:
|
||||
print(f" TTL: EXPIRED")
|
||||
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error fetching {ns}: {e}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,509 @@
|
||||
# Phase 3: Document Storage System - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Document storage tier for Library Desk - storing and indexing PDFs, images, videos, and git documentation mirrors.
|
||||
|
||||
**User Decisions:**
|
||||
- Paperless-ngx container for OCR
|
||||
- Ebooks deferred to future phase
|
||||
- Video.js player deferred to after core implementation
|
||||
|
||||
| Phase | Status | Version |
|
||||
|-------|--------|---------|
|
||||
| Phase 1: Cleanup System | Complete | v1.4.0 |
|
||||
| Phase 2: Volatile Memory | Complete | v1.4.3 |
|
||||
| Phase 3: Document Storage | Planning | - |
|
||||
| Phase 4: Test Data Cleanup | Complete | v1.4.4 |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
**Paperless-ngx as primary document store** (no SeaweedFS needed):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ External Sources │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ GitHub │ │ Direct │ │ Email/Folder │ │
|
||||
│ │ Docs │ │ Upload │ │ Ingestion │ │
|
||||
│ └────┬────┘ └─────┬──────┘ └──────┬───────┘ │
|
||||
└───────┼─────────────┼────────────────┼──────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Paperless-ngx │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ - Document storage (PDFs, images, videos) │ │
|
||||
│ │ - OCR via Tesseract (PDFs, images) │ │
|
||||
│ │ - Web UI for browsing/tagging │ │
|
||||
│ │ - REST API for integration │ │
|
||||
│ └─────────────────────────┬─────────────────────────────────┘ │
|
||||
└────────────────────────────┼────────────────────────────────────┘
|
||||
│ REST API (sync)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Library Desk │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ DocumentSyncService │ │
|
||||
│ │ - Polls Paperless for new/updated docs │ │
|
||||
│ │ - Extracts text + metadata via API │ │
|
||||
│ │ - Sends to vector/graph pipelines │ │
|
||||
│ └─────────────────────────┬─────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────┼────────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Qdrant │ │ Neo4j │ │ Wiki.js │ │
|
||||
│ │ (vectors)│ │ (graph) │ │ (catalog)│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**File handling by type:**
|
||||
|
||||
| File Type | Paperless | Library Desk |
|
||||
|-----------|-----------|--------------|
|
||||
| PDFs | OCR → text | Index text → vectors/graph |
|
||||
| Images | OCR → text | Index text → vectors/graph |
|
||||
| Videos | Storage only | Index metadata → vectors/graph |
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Purpose | Rationale |
|
||||
|-----------|---------|-----------|
|
||||
| **Paperless-ngx** | Document storage + OCR | All-in-one: storage, OCR, web UI, REST API |
|
||||
| **ClamAV** | Virus scanning | Host OS install, pyclamd integration, better isolation |
|
||||
| **PDF.js** | PDF viewer | Embeddable in Wiki.js (deferred) |
|
||||
|
||||
**Why Paperless-ngx as primary store:**
|
||||
- Eliminates need for separate blob storage (SeaweedFS/MinIO)
|
||||
- Built-in web UI for browsing and tagging
|
||||
- Tesseract OCR with 100+ language support
|
||||
- REST API for Library Desk integration
|
||||
- Handles videos as raw files (no OCR, but stored)
|
||||
- Email and folder watching for automatic ingestion
|
||||
- Active community, well-maintained
|
||||
|
||||
---
|
||||
|
||||
## Paperless-ngx API Deep Dive
|
||||
|
||||
### Authentication
|
||||
```
|
||||
POST /api/token/
|
||||
Body: {"username": "...", "password": "..."}
|
||||
Response: {"token": "..."}
|
||||
|
||||
Header: Authorization: Token <token>
|
||||
```
|
||||
|
||||
### Document Upload (for HybridRAG → Paperless)
|
||||
```
|
||||
POST /api/documents/post_document/
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Fields:
|
||||
- document (file, required)
|
||||
- title (string)
|
||||
- created (datetime)
|
||||
- correspondent (ID)
|
||||
- document_type (ID)
|
||||
- storage_path (ID)
|
||||
- tags (repeatable IDs)
|
||||
- custom_fields (JSON array)
|
||||
|
||||
Response: {"task_id": "uuid"}
|
||||
```
|
||||
|
||||
Track consumption: `GET /api/tasks/?task_id={uuid}` → returns document ID when complete
|
||||
|
||||
### Document Search
|
||||
```
|
||||
GET /api/documents/?query=search+terms # Full-text search
|
||||
GET /api/documents/?more_like_id=123 # Similarity search
|
||||
|
||||
Response includes __search_hit__:
|
||||
{
|
||||
"score": 0.95,
|
||||
"highlights": "<span>matched</span> text",
|
||||
"rank": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Field Filtering
|
||||
```
|
||||
GET /api/documents/?custom_field_query=field_name__operation=value
|
||||
|
||||
Operations:
|
||||
- exact, in, isnull, exists (all types)
|
||||
- icontains, istartswith, iendswith (text)
|
||||
- gt, gte, lt, lte, range (numeric/date)
|
||||
- contains (document links)
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
```
|
||||
POST /api/documents/bulk_edit/
|
||||
{
|
||||
"documents": [1, 2, 3],
|
||||
"method": "add_tag|remove_tag|set_correspondent|set_document_type|merge|split|...",
|
||||
"parameters": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhooks (Push to Library Desk!)
|
||||
Paperless workflows can trigger webhooks on document events:
|
||||
|
||||
| Trigger | When | Available Data |
|
||||
|---------|------|----------------|
|
||||
| Consumption Started | Before OCR | file_path, source, filename |
|
||||
| Document Added | After OCR | content, tags, doc_type, correspondent, `{doc_url}` |
|
||||
| Document Updated | On change | Same as Added |
|
||||
| Scheduled | Time-based | Date offsets from document dates |
|
||||
|
||||
**Webhook Action**: POST to Library Desk endpoint with document data
|
||||
|
||||
### Organization Features
|
||||
|
||||
| Feature | Purpose | API Endpoint |
|
||||
|---------|---------|--------------|
|
||||
| Tags | Nested labels (5 levels deep) | `/api/tags/` |
|
||||
| Correspondents | Source/destination | `/api/correspondents/` |
|
||||
| Document Types | Classification | `/api/document_types/` |
|
||||
| Storage Paths | File organization | `/api/storage_paths/` |
|
||||
| Custom Fields | Extensible metadata | `/api/custom_fields/` |
|
||||
|
||||
### Custom Fields We Should Create
|
||||
| Field Name | Type | Purpose |
|
||||
|------------|------|---------|
|
||||
| `source_url` | URL | Original download URL (for HybridRAG uploads) |
|
||||
| `library_indexed` | Boolean | Sync status with Library Desk |
|
||||
| `library_doc_id` | Text | Library Desk document reference |
|
||||
| `collection` | Text | Logical grouping (e.g., "fastapi-docs") |
|
||||
|
||||
### External LLM Add-ons (Optional)
|
||||
Community tools exist for Ollama integration:
|
||||
- **[paperless-ai](https://github.com/clusterzx/paperless-ai)** - Auto-tagging, RAG chat
|
||||
- **[paperless-gpt](https://github.com/icereed/paperless-gpt)** - LLM-enhanced OCR, auto-titling
|
||||
|
||||
**Recommendation:** Skip these - Library Desk already has Ollama integration for:
|
||||
- Embedding (nomic-embed-text)
|
||||
- LLM analysis (mistral-nemo)
|
||||
- Entity extraction
|
||||
- HybridRAG
|
||||
|
||||
We'll do our own classification/tagging via Library Desk after sync.
|
||||
|
||||
---
|
||||
|
||||
## Virus Scanning Integration
|
||||
|
||||
**ClamAV daemon + pyclamd** (no third-party REST wrappers):
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ File Upload │────►│ Library Desk │────►│ ClamAV Daemon │
|
||||
│ (URL or file) │ │ (pyclamd) │ │ (clamd:3310) │
|
||||
└─────────────────┘ └────────┬────────┘ └─────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ Clean │ │ Infected │
|
||||
│ ✓ │ │ ✗ │
|
||||
└────┬─────┘ └────┬─────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
Upload to Paperless Reject + Log
|
||||
```
|
||||
|
||||
### ClamAV Deployment (Host OS)
|
||||
|
||||
ClamAV runs on the host OS (not containerized) for better security isolation:
|
||||
|
||||
```bash
|
||||
# Installed via apt on Ubuntu/Debian
|
||||
# Config: /etc/clamav/clamd.conf
|
||||
# TCPSocket 3310
|
||||
# TCPAddr 0.0.0.0
|
||||
```
|
||||
|
||||
Benefits: scans outside container isolation, single virus DB, survives container restarts.
|
||||
|
||||
### Library Desk Integration
|
||||
```python
|
||||
# src/clients/clamav_client.py
|
||||
import pyclamd
|
||||
|
||||
class ClamAVClient:
|
||||
def __init__(self, host: str, port: int = 3310):
|
||||
self.cd = pyclamd.ClamdNetworkSocket(host, port)
|
||||
|
||||
async def scan_bytes(self, data: bytes) -> ScanResult:
|
||||
"""Scan file bytes, return clean/infected status."""
|
||||
result = self.cd.scan_stream(data)
|
||||
if result is None:
|
||||
return ScanResult(clean=True)
|
||||
return ScanResult(clean=False, virus_name=result['stream'][1])
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Health check."""
|
||||
return self.cd.ping()
|
||||
```
|
||||
|
||||
### Scan Points
|
||||
| Location | When | Action on Infected |
|
||||
|----------|------|-------------------|
|
||||
| `/documents/upload` | Before Paperless upload | Reject with 400, log threat |
|
||||
| HybridRAG web fetch | Before saving PDF | Skip file, log threat |
|
||||
| `/documents/webhook` | Optional re-scan | Quarantine in Paperless |
|
||||
|
||||
### Config Settings
|
||||
```python
|
||||
# src/config.py
|
||||
CLAMAV_HOST: str = "192.168.86.149" # Host OS IP (not container)
|
||||
CLAMAV_PORT: int = 3310
|
||||
CLAMAV_ENABLED: bool = True # Bypass for testing
|
||||
CLAMAV_TIMEOUT: int = 30 # seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Strategy
|
||||
|
||||
### Option A: Webhook Push (Preferred)
|
||||
```
|
||||
Paperless Workflow → POST webhook → Library Desk /documents/webhook
|
||||
```
|
||||
- Real-time indexing when documents added/updated
|
||||
- Configure in Paperless: Workflow → Document Added → Webhook Action
|
||||
- Library Desk receives document ID, fetches content via API
|
||||
|
||||
### Option B: Polling Pull (Fallback)
|
||||
```
|
||||
Scheduler → POST /documents/sync → Library Desk polls Paperless
|
||||
```
|
||||
- Periodic sync for missed webhooks or initial bulk import
|
||||
- Track `library_indexed` custom field to skip already-processed docs
|
||||
|
||||
### Option C: HybridRAG Upload (New!)
|
||||
```
|
||||
HybridRAG web search → finds PDF → POST to Paperless → webhook → indexed
|
||||
```
|
||||
- When HybridRAG finds a relevant PDF/document in web results
|
||||
- Download and upload to Paperless with `source_url` custom field
|
||||
- Paperless OCRs it, triggers webhook, Library Desk indexes
|
||||
|
||||
---
|
||||
|
||||
## Library Desk API Design
|
||||
|
||||
### Documents Router (`/documents`)
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/documents/webhook` | POST | Receive Paperless webhook (Document Added/Updated) |
|
||||
| `/documents/sync` | POST | Pull new/updated docs from Paperless → index |
|
||||
| `/documents/upload` | POST | Upload file to Paperless (for HybridRAG) |
|
||||
| `/documents/sync-from-git` | POST | Pull docs from Gitea → upload to Paperless → index |
|
||||
| `/documents/{document_id}` | GET | Get document metadata |
|
||||
| `/documents/{document_id}/text` | GET | Get extracted text |
|
||||
| `/documents/search` | POST | Semantic search across documents |
|
||||
| `/documents/collection/{name}` | GET | List documents in collection |
|
||||
| `/documents/collection/{name}/catalog` | POST | Generate wiki catalog page |
|
||||
|
||||
**Upload flow (HybridRAG → Paperless):**
|
||||
1. HybridRAG finds PDF in web results
|
||||
2. POST `/documents/upload` with URL or file
|
||||
3. Library Desk downloads, uploads to Paperless with metadata
|
||||
4. Returns task_id for async tracking
|
||||
5. Paperless webhook triggers indexing when OCR complete
|
||||
|
||||
### Viewers Router (`/viewers`) - Deferred
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/viewers/pdf/{document_id}` | GET | Serve PDF.js viewer |
|
||||
| `/viewers/image/{document_id}` | GET | Serve image lightbox |
|
||||
| `/viewers/video/{document_id}` | GET | Serve Video.js player |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Document Processing Pipeline
|
||||
|
||||
```
|
||||
1. INTAKE (Paperless-ngx handles this)
|
||||
└─ Upload via Paperless UI, email, or folder watch
|
||||
└─ Paperless assigns document ID and stores file
|
||||
|
||||
2. OCR EXTRACTION (Paperless-ngx handles this)
|
||||
├─ PDFs → Tesseract → Plain text
|
||||
├─ Images → Tesseract → Plain text
|
||||
└─ Videos → Metadata only (no OCR)
|
||||
|
||||
3. SYNC TO LIBRARY DESK (scheduled or manual)
|
||||
└─ Poll Paperless API for new/updated documents
|
||||
└─ Fetch text content + metadata
|
||||
|
||||
4. TEXT CHUNKING
|
||||
└─ VectorService._chunk_text() (existing)
|
||||
|
||||
5. EMBEDDING
|
||||
└─ OllamaClient.embed() (existing)
|
||||
|
||||
6. VECTOR STORAGE (Qdrant)
|
||||
└─ Payload: {doc_type: "document", paperless_id, ...}
|
||||
|
||||
7. GRAPH STORAGE (Neo4j)
|
||||
└─ Document node + MENTIONS relationships
|
||||
|
||||
8. WIKI CATALOG (optional)
|
||||
└─ Auto-generate catalog page via ConsolidationService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Docs Integration
|
||||
|
||||
Extends existing `scheduler/src/executors/doc_sync_executor.py`:
|
||||
|
||||
1. **Scheduler** syncs docs from GitHub → Gitea (existing)
|
||||
2. **Post-sync hook** calls `POST /documents/sync-from-git`
|
||||
3. **Library Desk** indexes docs into vectors/graph
|
||||
4. **Auto-generate** wiki catalog page for collection
|
||||
|
||||
---
|
||||
|
||||
## Wiki.js Viewer Integration
|
||||
|
||||
Since Wiki.js v2 requires disabled HTML sanitization for iframes:
|
||||
|
||||
```markdown
|
||||
<!-- In wiki catalog page -->
|
||||
## Document Preview
|
||||
|
||||
<iframe
|
||||
src="http://library-desk:8089/viewers/pdf/abc123"
|
||||
width="100%" height="600px">
|
||||
</iframe>
|
||||
```
|
||||
|
||||
**Wiki.js Settings Required:**
|
||||
- `Administration > Security > Allowed HTML Elements: iframe`
|
||||
- `Content Security Policy: frame-src http://library-desk:8089`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 3.1: Infrastructure Setup
|
||||
- [ ] Deploy Paperless-ngx container (Docker Compose)
|
||||
- [x] ClamAV installed on host OS (port 3310)
|
||||
- [ ] Configure Paperless: storage path, OCR settings, API token
|
||||
- [ ] Create custom fields in Paperless: `source_url`, `library_indexed`, `library_doc_id`, `collection`
|
||||
- [ ] Create `src/clients/paperless_client.py`
|
||||
- [ ] Create `src/clients/clamav_client.py` (pyclamd wrapper)
|
||||
- [ ] Create `src/models/document.py`
|
||||
- [ ] Add config settings to `src/config.py` (PAPERLESS_*, CLAMAV_*)
|
||||
|
||||
### Phase 3.2: Webhook Integration (Push)
|
||||
- [ ] Create `src/routers/documents.py`
|
||||
- [ ] Implement `/documents/webhook` endpoint (receives Paperless events)
|
||||
- [ ] Configure Paperless Workflow: Document Added → Webhook → Library Desk
|
||||
- [ ] Create `src/services/document_sync_service.py`
|
||||
- [ ] Implement document indexing pipeline (fetch text → chunk → embed → graph)
|
||||
|
||||
### Phase 3.3: Polling Sync (Pull Fallback)
|
||||
- [ ] Implement `/documents/sync` endpoint
|
||||
- [ ] Poll Paperless for docs where `library_indexed=false`
|
||||
- [ ] Track sync state (last_sync timestamp in Redis)
|
||||
- [ ] Update `library_indexed` after successful indexing
|
||||
|
||||
### Phase 3.4: HybridRAG Upload Integration
|
||||
- [ ] Implement `/documents/upload` endpoint
|
||||
- [ ] Download file from URL
|
||||
- [ ] **Virus scan before upload** (reject if infected, log threat)
|
||||
- [ ] Upload clean files to Paperless with metadata
|
||||
- [ ] Set `source_url` custom field
|
||||
- [ ] Extend HybridRAG service to detect and upload relevant PDFs
|
||||
- [ ] Add `save_to_documents` option to HybridRAG config
|
||||
|
||||
### Phase 3.5: Indexing Pipeline
|
||||
- [ ] Extend VectorService for `doc_type: "document"`
|
||||
- [ ] Extend GraphService for Document nodes (link to Paperless ID)
|
||||
- [ ] Implement `/documents/search` endpoint
|
||||
- [ ] Add dependency injection
|
||||
|
||||
### Phase 3.6: Git Docs Integration
|
||||
- [ ] Create `src/clients/gitea_client.py`
|
||||
- [ ] Implement `/documents/sync-from-git` → bulk upload to Paperless
|
||||
- [ ] Create collection auto-cataloging (wiki pages)
|
||||
- [ ] Add scheduler task for periodic git sync
|
||||
|
||||
### Phase 3.7: Viewers (Deferred)
|
||||
*After core implementation is working*
|
||||
- [ ] Create `static/pdf-viewer.html` (PDF.js)
|
||||
- [ ] Create `static/image-viewer.html`
|
||||
- [ ] Create `static/video-player.html` (Video.js)
|
||||
- [ ] Create `src/routers/viewers.py`
|
||||
|
||||
### Phase 3.8: Maintenance & Testing
|
||||
- [ ] Extend cleanup for document orphans
|
||||
- [ ] Add document orphan detection (Paperless deleted but still in Qdrant/Neo4j)
|
||||
- [ ] Create `tests/test_document_sync.py`
|
||||
- [ ] Create `tests/test_paperless_client.py`
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/clients/paperless_client.py` | Paperless-ngx REST API client |
|
||||
| `src/clients/clamav_client.py` | ClamAV scanner (pyclamd wrapper) |
|
||||
| `src/clients/gitea_client.py` | Gitea repo access |
|
||||
| `src/models/document.py` | Document/Collection/ScanResult models |
|
||||
| `src/services/document_sync_service.py` | Sync orchestrator |
|
||||
| `src/routers/documents.py` | Document endpoints (webhook, sync, upload, search) |
|
||||
| `tests/test_document_sync.py` | Sync service tests |
|
||||
| `tests/test_paperless_client.py` | API client tests |
|
||||
| `tests/test_clamav_client.py` | Virus scanner tests |
|
||||
| `docker/docker-compose.documents.yml` | Paperless + ClamAV deployment |
|
||||
|
||||
**Deferred files (Phase 3.7):**
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `src/routers/viewers.py` | Viewer endpoints |
|
||||
| `static/pdf-viewer.html` | PDF.js viewer |
|
||||
| `static/image-viewer.html` | Image lightbox |
|
||||
| `static/video-player.html` | Video.js player |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| Path | Changes |
|
||||
|------|---------|
|
||||
| `src/config.py` | `PAPERLESS_*`, `CLAMAV_*` settings |
|
||||
| `src/core/dependencies.py` | DocumentSyncService, PaperlessClient, ClamAVClient DI |
|
||||
| `src/main.py` | Register documents router |
|
||||
| `src/services/vector_service.py` | `doc_type: "document"` handling |
|
||||
| `src/services/graph_service.py` | Document node with Paperless ID |
|
||||
| `src/services/hybrid_rag_service.py` | Add `save_to_documents` option + virus scan |
|
||||
| `src/models/hybrid_rag.py` | Add `save_to_documents` config |
|
||||
| `src/routers/maintenance.py` | Document orphan cleanup, ClamAV health check |
|
||||
| `requirements.txt` | Add `pyclamd` |
|
||||
|
||||
## Paperless Custom Fields Setup
|
||||
|
||||
Create these in Paperless UI (Administration → Custom Fields):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `source_url` | URL | Original download URL |
|
||||
| `library_indexed` | Boolean | Sync status |
|
||||
| `library_doc_id` | Text | Library Desk reference |
|
||||
| `collection` | Text | Logical grouping |
|
||||
@@ -0,0 +1,58 @@
|
||||
# HybridRAG Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
HybridRAG combines three search sources to provide comprehensive results:
|
||||
- **Vector search** (Qdrant) - Semantic similarity via embeddings
|
||||
- **Graph search** (Neo4j) - Entity relationships in knowledge graph
|
||||
- **Web search** (SearXNG) - External web results via Trafilatura extraction
|
||||
|
||||
## Two-Stage RRF Fusion (v1.3.0+)
|
||||
|
||||
To ensure fair ranking between wiki and web results, we use a two-stage Reciprocal Rank Fusion:
|
||||
|
||||
```
|
||||
Stage 1: Wiki Merge
|
||||
vector results ─┬─→ Mini-RRF ─→ Unified wiki ranking
|
||||
graph results ─┘
|
||||
|
||||
Stage 2: Final RRF
|
||||
wiki (merged) ─┬─→ Final RRF ─→ Combined results
|
||||
web results ─┘
|
||||
```
|
||||
|
||||
**Why two stages?**
|
||||
|
||||
Previously, wiki pages found by BOTH vector and graph received double RRF contribution, giving them an unfair 2x advantage over web results. The two-stage approach:
|
||||
1. Merges vector+graph into a single "wiki" source
|
||||
2. Wiki's internal ranking still benefits from multi-source confirmation
|
||||
3. Wiki and web compete as equals in final ranking
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `VECTOR_SIMILARITY_THRESHOLD` | 0.7 | Minimum similarity score for vector results |
|
||||
| `HYBRID_RAG_VECTOR_LIMIT` | 10 | Max vector results |
|
||||
| `HYBRID_RAG_GRAPH_LIMIT` | 10 | Max graph results |
|
||||
| `HYBRID_RAG_WEB_LIMIT` | 5 | Max web results |
|
||||
|
||||
## Known Limitations & Future Improvements
|
||||
|
||||
### Vector Search Noise
|
||||
|
||||
**Status:** Open for improvement if needed after observation period.
|
||||
|
||||
Vector search may return generic category/index pages (e.g., "Reference", "Projects", "Places") with high similarity scores (~0.86). These pages often have similar boilerplate content leading to uniform scores.
|
||||
|
||||
**Potential solutions if this becomes problematic:**
|
||||
1. **Raise threshold** - Increase `VECTOR_SIMILARITY_THRESHOLD` to 0.85+
|
||||
2. **Page-type filtering** - Exclude pages tagged as category/index/stub
|
||||
3. **Content length signal** - Penalize pages with minimal content
|
||||
4. **Duplicate score detection** - Flag results with suspiciously identical scores
|
||||
|
||||
The LLM re-ranking phase typically demotes these low-quality results, so this may not require immediate action.
|
||||
|
||||
### Graph Search
|
||||
|
||||
Graph search uses only core keywords (no LLM-generated synonyms) to avoid false matches like "author" → "author2000". This is intentional - vector search handles semantic similarity via embeddings.
|
||||
@@ -0,0 +1,729 @@
|
||||
# Memory "Remember" Triggers - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation of "remember" triggers for the memory system. Currently, we have recall (search) working for volatile and documents, but no automated triggers to populate these memory tiers.
|
||||
|
||||
**Key architectural principle:**
|
||||
- **Scheduler-driven**: Prefetch data that's useful on a repeating schedule (weather, news)
|
||||
- **HybridRAG-driven**: Cache ad-hoc ephemeral data discovered during searches
|
||||
- **Learning loop**: HybridRAG can register scheduler tasks when it discovers prefetch-worthy patterns
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
| Memory Tier | Remember Trigger | Recall | Status |
|
||||
|-------------|------------------|--------|--------|
|
||||
| Wiki | Wiki.js webhook, Consolidation | HybridRAG vector+graph | ✅ Complete |
|
||||
| Documents | Paperless webhook | HybridRAG document search | ✅ Complete (v1.6.0) |
|
||||
| Volatile | Scheduler prefetch, HybridRAG post-processor | HybridRAG volatile search | ✅ Complete (v1.6.0) |
|
||||
|
||||
### Implementation Summary (v1.6.0)
|
||||
|
||||
- **Settings DB**: Central `system_settings` PostgreSQL database with `SettingsClient`
|
||||
- **Phase A**: Volatile fetch endpoints (`/volatile/fetch/{namespace}/{key}`) with weather, news, financial providers
|
||||
- **Phase B**: Unified memory routing in consolidation service (wiki/volatile/file/prefetch/skip classification)
|
||||
- **Phase C**: Document recall in HybridRAG (4-source parallel retrieval)
|
||||
- **Scheduler Integration**: `SchedulerClient` for external scheduler task registration
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ REMEMBER TRIGGERS │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ HybridRAG Search │ │
|
||||
│ │ Post-processor │ │
|
||||
│ └──────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────┼──────────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────┐ ┌───────────┐ ┌─────────────────┐ │
|
||||
│ │ Classify │ │ Store │ │ Register │ │
|
||||
│ │ web results │ │ immediate │ │ scheduler task │ │
|
||||
│ └──────┬──────┘ │ (volatile)│ │ (if prefetch │ │
|
||||
│ │ │ short TTL │ │ worthy) │ │
|
||||
│ │ └───────────┘ └────────┬────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┼─────────────┐ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌───────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
|
||||
│ │ PDF │ │ Ephemeral│ │ Prefetch │ │ Scheduler │ │
|
||||
│ │ │ │ (1x use) │ │ worthy │ │ (external) │ │
|
||||
│ └───┬───┘ └────┬─────┘ └────┬─────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ │ │ │
|
||||
│ ┌────────┐ ┌─────────┐ │ │ │
|
||||
│ │Paperless│ │Volatile │ │ ┌─────────────┘ │
|
||||
│ │Documents│ │short TTL│ │ │ │
|
||||
│ └────────┘ └─────────┘ │ ▼ │
|
||||
│ │ ┌─────────────────┐ │
|
||||
│ └─►│ POST /volatile/ │ │
|
||||
│ │ fetch (cron) │ │
|
||||
│ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ Volatile │ │
|
||||
│ │ long TTL │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Central Settings Database
|
||||
|
||||
### Rationale
|
||||
|
||||
External API credentials (NewsAPI, etc.) and configs (Open-Meteo) should NOT be in environment variables because:
|
||||
- They're not deployment-specific (same across all environments)
|
||||
- They change independently of deployments
|
||||
- Multiple services across Tatlock need access to shared credentials
|
||||
- Environment variables require container restarts to update
|
||||
|
||||
### Database Choice: PostgreSQL
|
||||
|
||||
**Decision:** Use `postgres-shared` container (existing Tatlock infrastructure).
|
||||
|
||||
Create a new database `system_settings` on the shared PostgreSQL instance. This container exists specifically for cross-service databases.
|
||||
|
||||
### Schema Design
|
||||
|
||||
```sql
|
||||
-- Run on postgres-shared as admin user
|
||||
|
||||
-- Create database
|
||||
CREATE DATABASE system_settings;
|
||||
|
||||
-- Create settings user (shared across all Tatlock services)
|
||||
CREATE USER settings WITH PASSWORD 'changeme';
|
||||
GRANT ALL PRIVILEGES ON DATABASE system_settings TO settings;
|
||||
|
||||
-- Connect to system_settings database
|
||||
\c system_settings
|
||||
|
||||
-- Create table
|
||||
CREATE TABLE settings (
|
||||
key VARCHAR(255) NOT NULL,
|
||||
user_scope VARCHAR(100) NOT NULL DEFAULT 'global', -- 'global' or specific username
|
||||
value JSONB NOT NULL,
|
||||
schema JSONB, -- JSON Schema for UI rendering (nullable)
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_by VARCHAR(100),
|
||||
PRIMARY KEY (key, user_scope)
|
||||
);
|
||||
|
||||
-- Index for user-scoped lookups
|
||||
CREATE INDEX idx_settings_user_scope ON settings(user_scope);
|
||||
|
||||
-- Grant full access
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO settings;
|
||||
```
|
||||
|
||||
### Query Pattern
|
||||
|
||||
```sql
|
||||
-- Get setting with user override, fallback to global
|
||||
SELECT value, schema FROM settings
|
||||
WHERE key = $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### Data Types with JSON Schema
|
||||
|
||||
The `schema` column contains JSON Schema for UI widget rendering:
|
||||
|
||||
| JSON Schema | UI Widget |
|
||||
|-------------|-----------|
|
||||
| `{"type": "string", "format": "password"}` | Masked input |
|
||||
| `{"type": "string", "enum": [...]}` | Dropdown/select |
|
||||
| `{"type": "boolean"}` | Toggle switch |
|
||||
| `{"type": "array", "items": {"type": "string"}}` | Multi-select or list |
|
||||
| `{"type": "number", "minimum": 0, "maximum": 100}` | Slider or number input |
|
||||
| No schema | Raw JSON editor |
|
||||
|
||||
### Example Data
|
||||
|
||||
```sql
|
||||
-- Global API keys (with schemas for CRUD UI)
|
||||
INSERT INTO settings (key, user_scope, value, schema, description) VALUES
|
||||
('api.openmeteo', 'global',
|
||||
'{"base_url": "https://api.open-meteo.com/v1/forecast", "timezone": "Europe/Amsterdam"}',
|
||||
'{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {"type": "string", "format": "uri", "title": "Base URL"},
|
||||
"timezone": {"type": "string", "title": "Default Timezone"}
|
||||
}
|
||||
}',
|
||||
'Open-Meteo weather API (no API key required)'),
|
||||
|
||||
('api.newsapi', 'global',
|
||||
'{"api_key": "xxx"}',
|
||||
'{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": {"type": "string", "format": "password", "title": "API Key"}
|
||||
},
|
||||
"required": ["api_key"]
|
||||
}',
|
||||
'NewsAPI.org credentials'),
|
||||
|
||||
('api.nos_rss', 'global',
|
||||
'{"feed_url": "https://feeds.nos.nl/nosnieuwsalgemeen"}',
|
||||
'{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feed_url": {"type": "string", "format": "uri", "title": "Feed URL"}
|
||||
}
|
||||
}',
|
||||
'NOS.nl RSS feed');
|
||||
|
||||
-- User-specific preferences (explicit choices)
|
||||
INSERT INTO settings (key, user_scope, value, schema, description) VALUES
|
||||
('weather.units', 'jpmschweitzer',
|
||||
'"metric"',
|
||||
'{"type": "string", "enum": ["metric", "imperial"], "title": "Temperature Units"}',
|
||||
'Preferred temperature units'),
|
||||
|
||||
('news.sources', 'jpmschweitzer',
|
||||
'["nos", "reuters"]',
|
||||
'{
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"uniqueItems": true,
|
||||
"title": "News Sources"
|
||||
}',
|
||||
'Preferred news sources');
|
||||
```
|
||||
|
||||
### What Goes Where
|
||||
|
||||
| Data Type | Storage | Examples |
|
||||
|-----------|---------|----------|
|
||||
| **API credentials/config** | Settings DB (global) | `api.openmeteo`, `api.nos`, `api.alphavantage` |
|
||||
| **Explicit user preferences** | Settings DB (user-scoped) | `weather.units`, `news.sources` |
|
||||
| **Learned user facts** | Biographer knowledge graph | Location, interests, schedule |
|
||||
| **Internal service URLs** | ENV vars | `SCHEDULER_URL`, `REDIS_HOST` |
|
||||
|
||||
**Key principle:** Settings DB stores explicit choices. Biographer stores learned context.
|
||||
|
||||
**Example flow for weather fetch:**
|
||||
1. Scheduler triggers `/volatile/fetch/weather`
|
||||
2. Fetch service queries biographer: "Where does this user live?"
|
||||
3. Biographer returns "Rotterdam" from knowledge graph
|
||||
4. Fetch service reads `weather.units` preference from settings
|
||||
5. Calls Open-Meteo API (geocode city → lat/long → forecast) with units from settings
|
||||
6. Stores result in volatile cache
|
||||
|
||||
### Library-Desk Integration
|
||||
|
||||
**ENV vars (deployment-specific only):**
|
||||
```bash
|
||||
# Central settings database
|
||||
SYSTEM_SETTINGS_HOST=postgres-shared
|
||||
SYSTEM_SETTINGS_PORT=5432
|
||||
SYSTEM_SETTINGS_DB=system_settings
|
||||
SYSTEM_SETTINGS_USER=settings
|
||||
SYSTEM_SETTINGS_PASSWORD=xxx
|
||||
|
||||
# Internal service URLs (plumbing, not in settings DB)
|
||||
SCHEDULER_URL=http://scheduler:8080
|
||||
BIOGRAPHER_URL=http://biographer:8080
|
||||
```
|
||||
|
||||
**New file: `src/clients/settings_client.py`**
|
||||
|
||||
```python
|
||||
"""
|
||||
Client for central Tatlock settings database.
|
||||
|
||||
Library-desk reads settings. Writes are done via psql CLI or future CRUD manager.
|
||||
"""
|
||||
|
||||
import asyncpg
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsClient:
|
||||
"""Client for system_settings database."""
|
||||
|
||||
def __init__(self, dsn: str):
|
||||
self.dsn = dsn
|
||||
self._pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Initialize connection pool."""
|
||||
if not self._pool:
|
||||
self._pool = await asyncpg.create_pool(self.dsn, min_size=1, max_size=5)
|
||||
|
||||
async def close(self):
|
||||
"""Close connection pool."""
|
||||
if self._pool:
|
||||
await self._pool.close()
|
||||
|
||||
async def get(self, key: str, user_scope: str = "global") -> Optional[Any]:
|
||||
"""
|
||||
Get a setting by key with user fallback to global.
|
||||
|
||||
Returns user-specific value if exists, otherwise global.
|
||||
"""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT value FROM settings
|
||||
WHERE key = $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""",
|
||||
key, user_scope
|
||||
)
|
||||
return row["value"] if row else None
|
||||
|
||||
async def get_by_prefix(self, prefix: str, user_scope: str = "global") -> dict[str, Any]:
|
||||
"""Get all settings matching a key prefix (e.g., 'api.')."""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (key) key, value FROM settings
|
||||
WHERE key LIKE $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY key, CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
""",
|
||||
f"{prefix}%", user_scope
|
||||
)
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
async def get_api_key(self, service: str) -> Optional[str]:
|
||||
"""Convenience method to get API key for a service."""
|
||||
value = await self.get(f"api.{service}")
|
||||
if isinstance(value, dict):
|
||||
return value.get("api_key")
|
||||
return value
|
||||
```
|
||||
|
||||
### CLI Management
|
||||
|
||||
Settings are managed via direct psql commands (future CRUD manager for UI):
|
||||
|
||||
```bash
|
||||
# Connect to settings database
|
||||
psql -h postgres-shared -U settings -d system_settings
|
||||
|
||||
# Add global API key
|
||||
INSERT INTO settings (key, value, description)
|
||||
VALUES ('api.alpha_vantage', '{"api_key": "YOUR_KEY"}', 'Alpha Vantage financial API');
|
||||
|
||||
# Add global API key with schema for UI
|
||||
INSERT INTO settings (key, value, schema, description)
|
||||
VALUES ('api.alpha_vantage', '{"api_key": "YOUR_KEY"}',
|
||||
'{"type": "object", "properties": {"api_key": {"type": "string", "format": "password"}}}',
|
||||
'Alpha Vantage financial API');
|
||||
|
||||
# Add user-specific preference
|
||||
INSERT INTO settings (key, user_scope, value, description)
|
||||
VALUES ('weather.units', 'jpmschweitzer', '"metric"', 'Preferred temperature units');
|
||||
|
||||
# Update NewsAPI key
|
||||
UPDATE settings
|
||||
SET value = '{"api_key": "NEW_KEY"}', updated_at = NOW()
|
||||
WHERE key = 'api.newsapi' AND user_scope = 'global';
|
||||
|
||||
# List all API keys
|
||||
SELECT key, description FROM settings WHERE key LIKE 'api.%';
|
||||
|
||||
# List user settings with fallback
|
||||
SELECT DISTINCT ON (key) key, user_scope, value FROM settings
|
||||
WHERE user_scope IN ('jpmschweitzer', 'global')
|
||||
ORDER BY key, CASE WHEN user_scope = 'jpmschweitzer' THEN 0 ELSE 1 END;
|
||||
|
||||
# View specific setting
|
||||
SELECT * FROM settings WHERE key = 'api.openmeteo';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase A: Scheduler-Driven Volatile (Prefetch)
|
||||
|
||||
### A.1 New Endpoint: `/volatile/fetch`
|
||||
|
||||
**File:** `src/routers/volatile.py`
|
||||
|
||||
```python
|
||||
@router.post("/fetch/{namespace}/{key}")
|
||||
async def fetch_and_store(
|
||||
namespace: str, # "weather", "news"
|
||||
key: str, # "rotterdam", "nos-headlines"
|
||||
user: str = Query(default=DEFAULT_USER),
|
||||
):
|
||||
"""
|
||||
Fetch fresh data from external API and store in volatile cache.
|
||||
|
||||
Called by scheduler on cron schedule. Combines:
|
||||
1. Call appropriate API client based on namespace
|
||||
2. Store result in volatile cache with appropriate TTL
|
||||
|
||||
API credentials are read from system_settings database.
|
||||
"""
|
||||
```
|
||||
|
||||
### A.2 API Clients
|
||||
|
||||
**New files in `src/clients/`:**
|
||||
|
||||
| File | API | Data Type | Refresh |
|
||||
|------|-----|-----------|---------|
|
||||
| `weather_client.py` | Open-Meteo (free, no key) | Current + forecast | Daily |
|
||||
| `news_client.py` | NOS.nl RSS (free, no key) | Headlines | Every 6h |
|
||||
| `financial_client.py` | Alpha Vantage / Yahoo | Stocks, crypto | On-demand |
|
||||
|
||||
**Example: `src/clients/weather_client.py`**
|
||||
|
||||
```python
|
||||
class WeatherClient:
|
||||
"""Open-Meteo API client with geocoding support."""
|
||||
|
||||
def __init__(self, settings_client: SettingsClient):
|
||||
self.settings = settings_client
|
||||
self._geo_cache: dict[str, tuple[float, float]] = {}
|
||||
|
||||
async def _get_config(self) -> dict:
|
||||
"""Get Open-Meteo config from central settings."""
|
||||
return await self.settings.get("api.openmeteo")
|
||||
|
||||
async def _geocode(self, city: str) -> tuple[float, float]:
|
||||
"""Convert city name to lat/long coordinates."""
|
||||
if city.lower() in self._geo_cache:
|
||||
return self._geo_cache[city.lower()]
|
||||
|
||||
config = await self._get_config()
|
||||
url = f"{config['geocoding_url']}?name={city}&count=1"
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
data = resp.json()
|
||||
if data.get("results"):
|
||||
lat = data["results"][0]["latitude"]
|
||||
lon = data["results"][0]["longitude"]
|
||||
self._geo_cache[city.lower()] = (lat, lon)
|
||||
return (lat, lon)
|
||||
raise ValueError(f"Could not geocode city: {city}")
|
||||
|
||||
async def get_current(self, city: str) -> dict:
|
||||
"""Get current weather for city."""
|
||||
config = await self._get_config()
|
||||
lat, lon = await self._geocode(city)
|
||||
|
||||
url = (f"{config['forecast_url']}?"
|
||||
f"latitude={lat}&longitude={lon}"
|
||||
f"¤t=temperature_2m,weather_code,relative_humidity_2m,wind_speed_10m"
|
||||
f"&timezone={config['timezone']}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
data = resp.json()
|
||||
|
||||
current = data["current"]
|
||||
return {
|
||||
"temperature": current["temperature_2m"],
|
||||
"weather_code": current["weather_code"],
|
||||
"humidity": current["relative_humidity_2m"],
|
||||
"wind_speed": current["wind_speed_10m"],
|
||||
"text": f"Currently {current['temperature_2m']}°C in {city}."
|
||||
}
|
||||
```
|
||||
|
||||
### A.3 Fetch Service
|
||||
|
||||
**New file:** `src/services/volatile_fetch_service.py`
|
||||
|
||||
```python
|
||||
class VolatileFetchService:
|
||||
"""Service to fetch external data and store in volatile cache."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
weather_client: WeatherClient,
|
||||
news_client: NewsClient,
|
||||
volatile_service: VolatileCacheService,
|
||||
):
|
||||
self.weather = weather_client
|
||||
self.news = news_client
|
||||
self.volatile = volatile_service
|
||||
|
||||
async def fetch_weather(self, user: str, city: str) -> VolatileRecordResponse:
|
||||
"""Fetch weather and store in volatile cache."""
|
||||
data = await self.weather.get_current(city)
|
||||
return await self.volatile.store(
|
||||
user=user,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=86400, # 24h
|
||||
)
|
||||
```
|
||||
|
||||
### A.4 Scheduler Configuration
|
||||
|
||||
| Task | Schedule | Endpoint |
|
||||
|------|----------|----------|
|
||||
| `volatile_weather` | `0 6 * * *` | `POST /volatile/fetch/weather/rotterdam?user=jpmschweitzer` |
|
||||
| `volatile_news_nos` | `0 */6 * * *` | `POST /volatile/fetch/news/nos?user=jpmschweitzer` |
|
||||
|
||||
---
|
||||
|
||||
## Phase B: HybridRAG-Driven Memory (Reactive)
|
||||
|
||||
### B.1 Post-Processor Classification
|
||||
|
||||
**Modify:** `src/services/hybrid_rag_service.py`
|
||||
|
||||
Add Phase 6.5 after persistence:
|
||||
|
||||
```python
|
||||
async def _postprocess_for_memory(
|
||||
self,
|
||||
web_results: List[Dict],
|
||||
query: str,
|
||||
user: str,
|
||||
config: HybridRAGConfig,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Phase 6.5: Classify web results and store/register appropriately.
|
||||
"""
|
||||
stats = {"volatile": 0, "documents": 0, "prefetch_registered": 0}
|
||||
|
||||
for result in web_results:
|
||||
url = result.get("url", "")
|
||||
content = result.get("content", "")
|
||||
content_type = self._classify_content(url, content)
|
||||
|
||||
if content_type == "pdf" and config.save_documents:
|
||||
await self._save_to_documents(url, result.get("title"))
|
||||
stats["documents"] += 1
|
||||
|
||||
elif content_type == "ephemeral":
|
||||
if config.save_volatile:
|
||||
await self._save_to_volatile(user, query, result, ttl=3600)
|
||||
stats["volatile"] += 1
|
||||
|
||||
if config.register_prefetch:
|
||||
prefetch_spec = self._should_register_prefetch(url, content, query)
|
||||
if prefetch_spec:
|
||||
if await self._register_prefetch_task(user, prefetch_spec):
|
||||
stats["prefetch_registered"] += 1
|
||||
|
||||
return stats
|
||||
```
|
||||
|
||||
### B.2 Content Classification
|
||||
|
||||
```python
|
||||
def _classify_content(self, url: str, content: str) -> str:
|
||||
"""
|
||||
Classify web result for memory routing.
|
||||
|
||||
Returns: "pdf", "ephemeral", "skip"
|
||||
"""
|
||||
if url.endswith(".pdf"):
|
||||
return "pdf"
|
||||
|
||||
ephemeral_domains = [
|
||||
"weather.com", "open-meteo.com", "buienradar",
|
||||
"nos.nl", "nu.nl", "reuters.com",
|
||||
"yahoo.com/finance", "marketwatch.com",
|
||||
]
|
||||
if any(domain in url for domain in ephemeral_domains):
|
||||
return "ephemeral"
|
||||
|
||||
return "skip"
|
||||
```
|
||||
|
||||
### B.3 Prefetch Detection
|
||||
|
||||
```python
|
||||
def _should_register_prefetch(self, url: str, content: str, query: str) -> Optional[dict]:
|
||||
"""
|
||||
Determine if content is worth registering for scheduled prefetch.
|
||||
"""
|
||||
# Weather patterns
|
||||
weather_match = re.search(r"weather.*(?:in|for)\s+(\w+)", query, re.IGNORECASE)
|
||||
if weather_match and any(d in url for d in ["weather.com", "open-meteo.com", "buienradar"]):
|
||||
return {
|
||||
"namespace": "weather",
|
||||
"key": weather_match.group(1).lower(),
|
||||
"schedule": "0 6 * * *",
|
||||
"description": f"Weather for {weather_match.group(1)}",
|
||||
}
|
||||
|
||||
# News patterns
|
||||
if "nos.nl" in url:
|
||||
return {
|
||||
"namespace": "news",
|
||||
"key": "nos",
|
||||
"schedule": "0 */6 * * *",
|
||||
"description": "Dutch news from NOS",
|
||||
}
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
### B.4 Scheduler Client
|
||||
|
||||
**New file:** `src/clients/scheduler_client.py`
|
||||
|
||||
```python
|
||||
class SchedulerClient:
|
||||
"""Client for external scheduler service."""
|
||||
|
||||
def __init__(self, settings_client: SettingsClient):
|
||||
self.settings = settings_client
|
||||
|
||||
async def _get_base_url(self) -> str:
|
||||
"""Get scheduler URL from central settings."""
|
||||
return await self.settings.get("scheduler.base_url")
|
||||
|
||||
async def register_task(self, task: SchedulerTask) -> bool:
|
||||
"""Register a new scheduled task."""
|
||||
base_url = await self._get_base_url()
|
||||
# ... POST to scheduler API ...
|
||||
|
||||
async def task_exists(self, task_name: str) -> bool:
|
||||
"""Check if task already exists."""
|
||||
# ... GET from scheduler API ...
|
||||
```
|
||||
|
||||
### B.5 Config Options
|
||||
|
||||
**Modify:** `src/models/hybrid_rag.py`
|
||||
|
||||
```python
|
||||
class HybridRAGConfig(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Memory auto-save options
|
||||
save_documents: bool = Field(default=False, description="Auto-upload PDFs to Paperless")
|
||||
save_volatile: bool = Field(default=True, description="Auto-cache ephemeral web results")
|
||||
register_prefetch: bool = Field(default=True, description="Auto-register scheduler tasks")
|
||||
volatile_ttl: int = Field(default=3600, description="TTL for reactive volatile cache")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C: Document Recall in HybridRAG
|
||||
|
||||
### C.1 Add Document Search
|
||||
|
||||
**Modify:** `src/services/hybrid_rag_service.py`
|
||||
|
||||
Add to `_retrieve_parallel()`:
|
||||
|
||||
```python
|
||||
if config.enable_documents:
|
||||
async def document_search():
|
||||
results = await self.vector.search(
|
||||
query=query,
|
||||
user=user,
|
||||
limit=config.document_limit,
|
||||
doc_type="document" # Filter to Paperless docs
|
||||
)
|
||||
return [{"paperless_id": r.metadata.get("paperless_id"), ...} for r in results]
|
||||
|
||||
tasks["document"] = document_search()
|
||||
```
|
||||
|
||||
### C.2 Config Options
|
||||
|
||||
```python
|
||||
enable_documents: bool = Field(default=True)
|
||||
document_limit: int = Field(default=5)
|
||||
document_threshold: float = Field(default=0.6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Flow
|
||||
|
||||
1. **User searches:** "What's the weather in Amsterdam?"
|
||||
2. **HybridRAG web search:** Returns open-meteo.com or weather site result
|
||||
3. **Post-processor classifies:** Ephemeral weather content
|
||||
4. **Immediate store:** `POST /volatile/store` (TTL: 1h)
|
||||
5. **Prefetch detection:** Matches weather pattern
|
||||
6. **Scheduler registration:** Creates task `volatile_weather_amsterdam_jpmschweitzer`
|
||||
7. **Next day 6am:** Scheduler calls `/volatile/fetch/weather/amsterdam`
|
||||
8. **Future searches:** Get cached weather from volatile
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| Phase | Priority | Effort | Description | Status |
|
||||
|-------|----------|--------|-------------|--------|
|
||||
| **Settings DB** | High | Low | PostgreSQL schema + settings client | ✅ v1.5.0 |
|
||||
| **B.4** | High | Low | Scheduler client | ✅ v1.6.0 |
|
||||
| **B.1-B.3** | High | Medium | HybridRAG post-processor | ✅ v1.6.0 |
|
||||
| **B.5** | High | Low | Config options | ✅ v1.6.0 |
|
||||
| **C.1-C.2** | High | Low | Document recall in HybridRAG | ✅ v1.6.0 |
|
||||
| **A.1** | Medium | Low | `/volatile/fetch` endpoint | ✅ v1.6.0 |
|
||||
| **A.2** | Medium | Medium | Weather + News API clients | ✅ v1.5.0 |
|
||||
| **A.3** | Medium | Low | Fetch service | ✅ v1.6.0 |
|
||||
|
||||
### Remaining Work
|
||||
|
||||
| Item | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| File upload | Download PDFs and upload to Paperless | ⚠️ Placeholder (logs only) |
|
||||
| Prefetch patterns | More sophisticated pattern detection | Optional enhancement |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files (Implemented)
|
||||
|
||||
| Path | Purpose | Version |
|
||||
|------|---------|---------|
|
||||
| `src/clients/settings_client.py` | Central settings database access | v1.5.0 |
|
||||
| `src/clients/scheduler_client.py` | External scheduler task management | v1.6.0 |
|
||||
| `src/apis/__init__.py` | External API providers package | v1.5.0 |
|
||||
| `src/apis/base.py` | Abstract base classes for providers | v1.5.0 |
|
||||
| `src/apis/weather.py` | OpenMeteoProvider (geocoding + forecast) | v1.5.0 |
|
||||
| `src/apis/news.py` | AggregatedNewsProvider | v1.5.0 |
|
||||
| `src/apis/nos.py` | NOSProvider (Dutch news RSS) | v1.5.0 |
|
||||
| `src/apis/bbc.py` | BBCProvider (English news RSS) | v1.5.0 |
|
||||
| `src/apis/financial.py` | AlphaVantageProvider (stocks/crypto) | v1.5.0 |
|
||||
| `src/services/volatile_fetch_service.py` | Orchestrates fetch + store | v1.6.0 |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| Path | Changes | Version |
|
||||
|------|---------|---------|
|
||||
| `src/services/hybrid_rag_service.py` | Document search (4-source parallel retrieval) | v1.6.0 |
|
||||
| `src/services/consolidation_service.py` | Unified memory routing, scheduler integration | v1.6.0 |
|
||||
| `src/models/hybrid_rag.py` | Document config options (`enable_documents`, `document_limit`) | v1.6.0 |
|
||||
| `src/models/consolidation.py` | Memory routing models | v1.6.0 |
|
||||
| `src/routers/volatile.py` | `/volatile/fetch/{namespace}/{key}` endpoints | v1.6.0 |
|
||||
| `src/core/dependencies.py` | Settings, scheduler, provider DI | v1.5.0-v1.6.0 |
|
||||
| `src/config.py` | `SYSTEM_SETTINGS_*`, `SCHEDULER_URL` vars | v1.5.0-v1.6.0 |
|
||||
|
||||
### Database
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Database | `system_settings` (PostgreSQL on postgres-shared) |
|
||||
| Table | `settings (key, user_scope, value JSONB, schema JSONB, ...)` |
|
||||
| Library-desk access | Read-only via `SettingsClient` |
|
||||
| Management | Direct psql commands (future: CRUD manager UI) |
|
||||
@@ -0,0 +1,332 @@
|
||||
# Memory Management System - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A three-tier memory architecture for Library Desk with intelligent orchestration:
|
||||
|
||||
| Tier | Storage | Purpose | TTL |
|
||||
|------|---------|---------|-----|
|
||||
| **Volatile** | Qdrant (vectors) | Weather, news, financial, ephemeral context | 5min - 2hr |
|
||||
| **Documents** | Paperless-ngx + ClamAV (host) | Git mirrors, PDFs, video, images | Permanent |
|
||||
| **Knowledge** | Wiki + Neo4j | Personal dossiers, research, summaries | Permanent |
|
||||
|
||||
**Implementation Priority**: Cleanup → Volatile → Documents → Test Data Cleanup
|
||||
|
||||
### Phase Status
|
||||
|
||||
| Phase | Status | Version |
|
||||
|-------|--------|---------|
|
||||
| Phase 1: Cleanup System | ✅ Complete | v1.4.0 |
|
||||
| Phase 2: Volatile Memory | ✅ Complete | v1.4.3 |
|
||||
| Phase 3: Document Storage | ✅ Planned | See [DOCUMENT_STORAGE_PLAN.md](DOCUMENT_STORAGE_PLAN.md) |
|
||||
| Phase 4: Test Data Cleanup | ✅ Complete | v1.4.4 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Cleanup System Completion ✅
|
||||
|
||||
### Current State
|
||||
- **COMPLETE** - All Phase 1 tasks implemented
|
||||
- Redis timestamp tracking for last cleanup
|
||||
- Bidirectional orphan detection between vectors and graph
|
||||
- Scheduler integration endpoints ready
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 1.1 Add Scheduler Integration Points ✅
|
||||
**Files**: `src/routers/maintenance.py`
|
||||
|
||||
- [x] Add `last_cleanup` timestamp tracking in Redis
|
||||
- [x] Return cleanup stats in format scheduler can log
|
||||
- [x] Added `RedisDep` to cleanup endpoints
|
||||
|
||||
#### 1.2 Bidirectional Orphan Detection ✅
|
||||
**Files**: `src/services/graph_service.py`, `src/services/vector_service.py`
|
||||
|
||||
- [x] `find_documents_without_vectors()` - graph nodes with no vectors
|
||||
- [x] `find_chunks_without_graph_nodes()` - vectors with no graph node
|
||||
- [x] Updated maintenance endpoints to use bidirectional checks
|
||||
- [x] Added `chunks_without_graph` and `docs_without_vectors` to response models
|
||||
|
||||
#### 1.3 Scheduler Configuration ✅
|
||||
**Scheduler-side task definition:**
|
||||
```json
|
||||
{
|
||||
"task_name": "library_reconcile_index",
|
||||
"schedule": "0 4 * * *",
|
||||
"endpoint": "POST /maintenance/reconcile-index?user=jpmschweitzer",
|
||||
"description": "Daily index reconciliation - cleanup + reindex missing"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Documented in `LIBRARIAN_INTEGRATION.md`
|
||||
- [x] Added `reconcile-index` endpoint (cleanup + reindex missing)
|
||||
- [x] Lightweight health check mode for uptime monitoring
|
||||
- [x] Detailed health check mode for dashboards
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Volatile Memory System ✅
|
||||
|
||||
### Architecture (Final Implementation)
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
||||
│ Library-Desk │◄───│ Scheduler │───►│ External APIs │
|
||||
│ │ │ │ │ (weather, news) │
|
||||
│ VolatileCache │ │ Refresh │ └─────────────────┘
|
||||
│ Service │ │ Jobs │
|
||||
└────────┬────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Qdrant │
|
||||
│ (volatile_{user})│
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
- Vector storage in Qdrant (not Redis) for semantic search
|
||||
- Collection per user: `volatile_{user}`
|
||||
- TTL via `ttl_expiry` timestamp in payload
|
||||
- Natural language conversion for embedding structured data
|
||||
- Integrated into HybridRAG with priority boost
|
||||
|
||||
### Endpoints (Implemented)
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/volatile/search?q=...` | GET | Semantic search across volatile data |
|
||||
| `/volatile/store?namespace=...&key=...` | POST | Store/update record |
|
||||
| `/volatile/{namespace}/{key}` | GET | Retrieve specific record |
|
||||
| `/volatile/{namespace}/{key}` | DELETE | Remove record |
|
||||
| `/volatile/stats` | GET | Cache statistics |
|
||||
| `/volatile/scheduled` | GET | Records needing refresh |
|
||||
| `/volatile/namespaces` | GET | List available namespaces |
|
||||
| `/maintenance/cleanup/volatile` | POST | Purge expired records |
|
||||
|
||||
### Namespaces
|
||||
|
||||
| Namespace | Default TTL | Use Case |
|
||||
|-----------|-------------|----------|
|
||||
| weather | 30 min | Current conditions, forecasts |
|
||||
| news | 1 hour | Headlines, breaking news |
|
||||
| financial | 5 min | Stock prices, exchange rates |
|
||||
| transit | 5 min | Train/bus schedules, delays |
|
||||
| traffic | 10 min | Commute times, road conditions |
|
||||
| air_quality | 1 hour | Pollution, pollen counts |
|
||||
| sports | 1 min | Live scores, matches |
|
||||
| social | 10 min | Social notifications |
|
||||
| system | 1 min | Service health status |
|
||||
| context | 1 hour | Session state |
|
||||
| custom | 1 hour | User-defined data |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Document Storage (Research + Implementation)
|
||||
|
||||
### Research Scope
|
||||
|
||||
Evaluate FOSS self-hosted options for:
|
||||
- Git repository mirroring
|
||||
- PDF/document storage with metadata
|
||||
- Image/video blob storage
|
||||
- Full-text search capability
|
||||
|
||||
**Constraints**:
|
||||
- Must be self-hosted, Docker-deployable
|
||||
- Performance is priority (can wrap complexity in API)
|
||||
- No cloud dependencies
|
||||
|
||||
**Candidates to evaluate**:
|
||||
1. MinIO (S3-compatible object storage) + metadata in Neo4j
|
||||
2. Paperless-ngx (document management with OCR)
|
||||
3. SeaweedFS (distributed file system)
|
||||
4. Custom: filesystem + Neo4j metadata
|
||||
|
||||
### Category Descriptors
|
||||
|
||||
**Wiki page structure for document collections**:
|
||||
```markdown
|
||||
# FastAPI Documentation
|
||||
|
||||
## Overview
|
||||
[LLM-generated summary from web search about FastAPI]
|
||||
|
||||
## Collection Statistics
|
||||
- **Documents**: 342 files
|
||||
- **Last Sync**: 2025-12-24 03:30 UTC
|
||||
- **Source**: github.com/tiangolo/fastapi
|
||||
- **Coverage**: API reference, tutorials, deployment guides
|
||||
|
||||
## What's Included
|
||||
[LLM summary of collection contents based on document analysis]
|
||||
|
||||
## Related Topics
|
||||
- [[Python Web Frameworks]]
|
||||
- [[REST API Design]]
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 3.1 Storage Research
|
||||
**Deliverable**: Evaluation document comparing options
|
||||
|
||||
#### 3.2 Storage Service Implementation
|
||||
**New file**: `src/services/document_store_service.py`
|
||||
(Details pending research results)
|
||||
|
||||
#### 3.3 Category Descriptor Generation
|
||||
**File**: `src/services/consolidation_service.py`
|
||||
|
||||
Add LLM-powered category descriptor generation:
|
||||
1. Web search for topic overview
|
||||
2. Analyze collection contents
|
||||
3. Generate/update wiki page with template
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: LLM Tester Data Cleanup ✅
|
||||
|
||||
### Problem
|
||||
|
||||
LLM testing creates accumulated cruft across the system:
|
||||
- Wiki.js pages under `llm-tester/` and `llm_tester/` paths
|
||||
- Graph nodes (Document, Entity) linked to test pages
|
||||
- Vector chunks in Qdrant for test content
|
||||
|
||||
This data accumulates over time and clutters Wiki.js visually (no separate tenant scope for tests).
|
||||
|
||||
### Solution
|
||||
|
||||
Add a maintenance endpoint to purge all LLM tester artifacts across wiki, graph, and vectors.
|
||||
|
||||
### Tasks
|
||||
|
||||
#### 4.1 Identify Test Data Patterns ✅
|
||||
**Patterns matched** (security-restricted to test user namespace):
|
||||
- `users/llm-tester/*`
|
||||
- `users/llm_tester/*`
|
||||
|
||||
#### 4.2 Add Cleanup Endpoint ✅
|
||||
**File**: `src/routers/maintenance.py`
|
||||
|
||||
```python
|
||||
@router.post("/cleanup/test-data")
|
||||
async def cleanup_test_data(
|
||||
dry_run: bool = Query(default=True),
|
||||
wiki: WikiJSDep = None,
|
||||
vector_service: VectorServiceDep = None,
|
||||
graph_service: GraphServiceDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Purge LLM tester data from wiki, graph, and vectors.
|
||||
|
||||
**Security**: Only deletes pages in the test user namespace:
|
||||
- users/llm-tester/*
|
||||
- users/llm_tester/*
|
||||
|
||||
Use dry_run=true to preview what would be deleted.
|
||||
"""
|
||||
```
|
||||
|
||||
#### 4.3 Implementation Steps ✅
|
||||
|
||||
1. **Wiki cleanup**: Delete pages via GraphQL mutation
|
||||
2. **Graph cleanup**: Delete Document nodes using `delete_page()` method
|
||||
3. **Vector cleanup**: Delete chunks using `delete_page_chunks()` method
|
||||
|
||||
#### 4.4 Scheduler Integration ✅
|
||||
**Recommended schedule**: Weekly (Sunday 3:00 AM)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_name": "test_data_cleanup",
|
||||
"schedule": "0 3 * * 0",
|
||||
"endpoint": "POST /maintenance/cleanup/test-data?dry_run=false",
|
||||
"description": "Weekly cleanup of LLM test data"
|
||||
}
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `src/routers/maintenance.py` - Add cleanup endpoint
|
||||
- `src/services/wiki_service.py` - Add bulk delete by path pattern (if needed)
|
||||
- `src/services/graph_service.py` - May need pattern-based node deletion
|
||||
- `src/services/vector_service.py` - Add pattern-based chunk deletion
|
||||
|
||||
---
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Phase 1 (Cleanup) ✅
|
||||
- `src/routers/maintenance.py` - Timestamp tracking, cleanup endpoints
|
||||
- `src/services/graph_service.py` - Bidirectional validation
|
||||
- `src/services/vector_service.py` - Cross-reference checks
|
||||
- `LIBRARIAN_INTEGRATION.md` - Scheduler config docs
|
||||
|
||||
### Phase 2 (Volatile) ✅
|
||||
- `src/services/volatile_service.py` - Qdrant-based volatile cache
|
||||
- `src/routers/volatile.py` - Simplified endpoints
|
||||
- `src/models/volatile.py` - Namespaces and models
|
||||
- `src/models/hybrid_rag.py` - Volatile config options
|
||||
- `src/services/hybrid_rag_service.py` - Volatile integration
|
||||
- `src/clients/qdrant_client.py` - Expiry filter methods
|
||||
- `tests/test_volatile.py` - 37 tests
|
||||
|
||||
### Phase 3 (Documents)
|
||||
- `docs/DOCUMENT_STORAGE_RESEARCH.md` - **NEW**
|
||||
- `src/services/document_store_service.py` - **NEW** (post-research)
|
||||
- `src/routers/documents.py` - **NEW** (post-research)
|
||||
|
||||
### Phase 4 (Test Data Cleanup)
|
||||
- `src/routers/maintenance.py` - Add cleanup endpoint
|
||||
- `src/services/wiki_service.py` - Bulk delete by path pattern
|
||||
- `src/services/graph_service.py` - Pattern-based node deletion
|
||||
- `src/services/vector_service.py` - Pattern-based chunk deletion
|
||||
|
||||
---
|
||||
|
||||
## Resolved Design Decisions
|
||||
|
||||
1. **Volatile Storage**: Qdrant vectors (not Redis) for semantic search capability
|
||||
2. **Collection Naming**: `volatile_{user}` for per-user isolation
|
||||
3. **TTL Mechanism**: `ttl_expiry` timestamp in payload, background cleanup job
|
||||
4. **HybridRAG Integration**: Volatile as third source with RRF priority boost
|
||||
5. **Biographer Qdrant**: Same Qdrant instance, different collection
|
||||
6. **Scheduler API**: Has REST API for task registration
|
||||
|
||||
---
|
||||
|
||||
## Future Consideration: Dedicated API Integrations
|
||||
|
||||
For volatile data where quality/consistency matters (weather, financial), consider:
|
||||
- OpenWeatherMap API for weather (daily refresh cycle)
|
||||
- Financial data API (Alpha Vantage, Yahoo Finance)
|
||||
- News APIs (NewsAPI, GDELT)
|
||||
- **NOS.nl** - Explicit source for Dutch news
|
||||
|
||||
This would live in a new `src/clients/` module with:
|
||||
- `weather_client.py` - Daily refresh cycle
|
||||
- `financial_client.py`
|
||||
- `news_client.py` - Include NOS.nl scraper/API for Dutch coverage
|
||||
|
||||
These provide structured, reliable data vs. SearXNG web scraping. Implementation deferred to later phase.
|
||||
|
||||
---
|
||||
|
||||
## Refresh Schedules
|
||||
|
||||
**Note:** TTL should be longer than refresh interval to prevent data gaps.
|
||||
|
||||
| Volatile Type | TTL | Refresh Cycle | Refresh Interval | Sources |
|
||||
|---------------|-----|---------------|------------------|---------|
|
||||
| Weather | 86400s (24hr) | Daily | Every 24hr | OpenWeatherMap |
|
||||
| Dutch News | 28800s (8hr) | 4x daily | Every 6hr | NOS.nl |
|
||||
| Global News | 28800s (8hr) | 4x daily | Every 6hr | NewsAPI, GDELT |
|
||||
| Financial | 600s (10min) | On-demand | N/A | Alpha Vantage |
|
||||
|
||||
**TTL Logic:**
|
||||
- TTL = Refresh Interval × 1.5 (buffer for failed refreshes)
|
||||
- On-demand data gets shorter TTL since it's fetched when needed
|
||||
@@ -0,0 +1,180 @@
|
||||
# Scheduler Task Definitions (Phase C deploy checklist)
|
||||
|
||||
Production task payloads for the homelab's database-driven **Scheduler**
|
||||
service. These are **definitions only** — nothing in this repo registers
|
||||
them automatically. Register them as part of the deploy checklist, either
|
||||
via the Scheduler UI/API or with the helper script:
|
||||
|
||||
```bash
|
||||
# Preview exactly what would be sent (default):
|
||||
SCHEDULER_URL=http://<scheduler-host>:8090 \
|
||||
.venv/bin/python scripts/register_scheduler_tasks.py
|
||||
|
||||
# Actually register/update the tasks (deploy checklist step):
|
||||
SCHEDULER_URL=http://<scheduler-host>:8090 \
|
||||
SCHEDULER_API_KEY=<scheduler-api-key> \
|
||||
.venv/bin/python scripts/register_scheduler_tasks.py --execute
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- The Scheduler's task-management endpoints (`GET`/`POST /tasks`,
|
||||
`PUT /tasks/{name}`) require `Authorization: Bearer $SCHEDULER_API_KEY`.
|
||||
The registrar reads `SCHEDULER_API_KEY` from the environment for its
|
||||
own HTTP calls (`--execute` refuses to run without it); the key is
|
||||
never stored. This is separate from `LIBRARY_API_KEY` below, which the
|
||||
Scheduler container needs at task **execution** time.
|
||||
|
||||
- All tasks call the **production** library-desk container
|
||||
(`http://library-desk:8089`) with the explicit production tenant
|
||||
`user=jpmschweitzer` (there is no default tenant — Phase B).
|
||||
- `${LIBRARY_API_KEY}` is a literal placeholder stored in the task's
|
||||
`auth.token` field. The Scheduler's `rest_api_executor` substitutes
|
||||
`${ENV_VAR}` placeholders from **its own environment at execution
|
||||
time** (it substitutes `url`/`payload`/`auth` — NOT plain `headers`),
|
||||
so the raw key is never stored in the `scheduled_tasks.config` JSONB
|
||||
column. The **Scheduler container** must have `LIBRARY_API_KEY` in its
|
||||
environment. Never commit or register the real value.
|
||||
- The JSON body goes in `config.payload` (the executor ignores a `body`
|
||||
key).
|
||||
- Schedule fields use the Scheduler's convention: `-1` = every,
|
||||
`day_of_week`: `0` = Monday … `6` = Sunday.
|
||||
|
||||
---
|
||||
|
||||
## 1. Nightly integrity check — 04:30 daily
|
||||
|
||||
Read-only report: pages without vectors, orphaned vectors, unexpected
|
||||
Qdrant collections, Document nodes without wiki pages. Caches its result
|
||||
in Redis for the weekly quality report.
|
||||
|
||||
```json
|
||||
{
|
||||
"task_name": "library_integrity_check",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": "Nightly read-only integrity check for the library (vectors/graph/wiki/collections)",
|
||||
"enabled": true,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 900,
|
||||
"minute": 30,
|
||||
"hour": 4,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": -1,
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "http://library-desk:8089/maintenance/integrity-check",
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"payload": {
|
||||
"user": "jpmschweitzer"
|
||||
},
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"token": "${LIBRARY_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Weekly quality report — Sunday 03:00
|
||||
|
||||
Runs the duplicate scan, flags stale/metadata-poor pages, folds in the
|
||||
latest integrity results, and writes the dated report page to
|
||||
`users/jpmschweitzer/system/quality-reports/YYYY-MM-DD`.
|
||||
|
||||
```json
|
||||
{
|
||||
"task_name": "library_quality_report",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": "Weekly library quality report (dedup, stale pages, missing metadata, integrity) written to the wiki",
|
||||
"enabled": true,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 1800,
|
||||
"minute": 0,
|
||||
"hour": 3,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": 6,
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "http://library-desk:8089/maintenance/quality-report",
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"payload": {
|
||||
"user": "jpmschweitzer",
|
||||
"stale_days": 30,
|
||||
"dedup_threshold": 0.9,
|
||||
"write_page": true
|
||||
},
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"token": "${LIBRARY_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Daily Paperless orphan cleanup — 05:00
|
||||
|
||||
Hits the **existing** cleanup endpoint (query parameters, empty payload).
|
||||
`dry_run=false` deletes vectors/graph nodes for documents that were
|
||||
removed from Paperless-ngx.
|
||||
|
||||
```json
|
||||
{
|
||||
"task_name": "library_paperless_orphan_cleanup",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": "Daily cleanup of vectors/graph nodes for documents deleted from Paperless-ngx",
|
||||
"enabled": true,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 900,
|
||||
"minute": 0,
|
||||
"hour": 5,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": -1,
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": "http://library-desk:8089/maintenance/cleanup/paperless?user=jpmschweitzer&dry_run=false",
|
||||
"payload": {},
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"token": "${LIBRARY_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Disable `test_example_task`
|
||||
|
||||
Not a new task: the leftover example task must be **disabled** (not
|
||||
deleted, so its history is preserved).
|
||||
|
||||
```
|
||||
PUT ${SCHEDULER_URL}/tasks/test_example_task
|
||||
Content-Type: application/json
|
||||
|
||||
{"enabled": false}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related (already registered / in-process)
|
||||
|
||||
- `knowledge_consolidation` — every 30 minutes, POST
|
||||
`/consolidate/knowledge` (already registered; after the Phase C
|
||||
consolidation repair its runs log `searches_processed` and
|
||||
`duration_ms`, and searches are no longer consumed while the LLM is
|
||||
unavailable).
|
||||
- Redis job-set cleanup — runs **in-process** inside library-desk
|
||||
(hourly `job_cleanup_loop` started at app startup); no Scheduler task
|
||||
needed.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "library-desk"
|
||||
version = "1.1.0"
|
||||
version = "1.9.1"
|
||||
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Development dependencies
|
||||
-r requirements.txt
|
||||
|
||||
# Testing
|
||||
pytest~=8.3.0
|
||||
pytest-asyncio~=0.24.0
|
||||
|
||||
# Security auditing
|
||||
pip-audit~=2.7.0
|
||||
|
||||
# Code quality
|
||||
ruff~=0.8.0
|
||||
+5
-3
@@ -25,6 +25,8 @@ python-multipart~=0.0.20
|
||||
# Utilities
|
||||
python-dateutil~=2.9.0
|
||||
|
||||
# Testing
|
||||
pytest~=8.3.0
|
||||
pytest-asyncio~=0.24.0
|
||||
# Content Extraction
|
||||
trafilatura~=1.12.0
|
||||
|
||||
# RSS Parsing
|
||||
feedparser~=6.0.12
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Purge test-tenant residue from the SHARED production services.
|
||||
|
||||
Targets ONLY the confirmed test residue left behind by earlier test runs:
|
||||
|
||||
Qdrant collections
|
||||
- library_desk_llm_tester, memories_llm_tester, volatile_llm_tester
|
||||
- test_user, library_desk_test_user
|
||||
- core_ai_user_test_* (prefix)
|
||||
- anything containing "llm_tester" / "llm-tester"
|
||||
|
||||
Neo4j
|
||||
- all nodes carrying a label starting with "User_Llm_Tester"
|
||||
(covers User_Llm_Tester, User_Llm_Tester_Document,
|
||||
User_Llm_Tester_SearchQuery, User_Llm_Tester_WebResult and
|
||||
sub-tenants like User_Llm_Tester_Void_*)
|
||||
- legacy "llm-tester" Document nodes matched by path
|
||||
(d.path STARTS WITH 'users/llm')
|
||||
|
||||
Redis (service DB from settings, default DB 4)
|
||||
- keys matching *llm_tester* / *llm-tester*
|
||||
|
||||
SAFETY
|
||||
======
|
||||
- DRY-RUN IS THE DEFAULT. Nothing is deleted unless --execute is passed.
|
||||
- The script REFUSES to touch anything namespaced to the production
|
||||
tenant "jpmschweitzer": every candidate identifier is checked and the
|
||||
script aborts (exit 2) if a production-namespaced identifier ever
|
||||
matches a target rule.
|
||||
- Connection settings (hosts, credentials) come from the repo .env via
|
||||
src.config.Settings; nothing is printed except identifiers and counts.
|
||||
|
||||
SNAPSHOT PREREQUISITE (before any --execute run)
|
||||
================================================
|
||||
Take snapshots of both stores first so an erroneous deletion can be
|
||||
rolled back:
|
||||
|
||||
Qdrant - full-storage snapshot via the snapshot API:
|
||||
curl -X POST http://<qdrant-host>:6333/snapshots
|
||||
(or per collection:
|
||||
curl -X POST http://<qdrant-host>:6333/collections/<name>/snapshots)
|
||||
|
||||
Neo4j - offline dump from inside the container:
|
||||
docker exec <neo4j> neo4j-admin database dump neo4j \
|
||||
--to-path=/backups
|
||||
|
||||
Only proceed with --execute after both snapshots completed successfully.
|
||||
|
||||
USAGE
|
||||
=====
|
||||
.venv/bin/python scripts/purge_test_artifacts.py # dry run (default)
|
||||
.venv/bin/python scripts/purge_test_artifacts.py --dry-run # explicit dry run
|
||||
.venv/bin/python scripts/purge_test_artifacts.py --execute # REALLY delete
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow running from the repo root or the scripts/ directory
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
PRODUCTION_TENANT = "jpmschweitzer"
|
||||
|
||||
# Confirmed residue: exact Qdrant collection names
|
||||
QDRANT_EXACT_TARGETS = {
|
||||
"library_desk_llm_tester",
|
||||
"memories_llm_tester",
|
||||
"volatile_llm_tester",
|
||||
"test_user",
|
||||
"library_desk_test_user",
|
||||
}
|
||||
# Confirmed residue: Qdrant collection name prefixes
|
||||
QDRANT_PREFIX_TARGETS = ("core_ai_user_test_",)
|
||||
# Reserved test-tenant substrings (any collection containing these is residue)
|
||||
QDRANT_SUBSTRING_TARGETS = ("llm_tester", "llm-tester")
|
||||
|
||||
# Neo4j: tenant label prefix for the reserved test tenant
|
||||
NEO4J_TEST_LABEL_PREFIX = "User_Llm_Tester"
|
||||
# Neo4j: legacy Document nodes matched by wiki path (llm-tester / llm_tester)
|
||||
NEO4J_TEST_DOC_PATH_PREFIX = "users/llm"
|
||||
|
||||
# Redis key patterns for the reserved test tenant
|
||||
REDIS_PATTERNS = ("*llm_tester*", "*llm-tester*")
|
||||
|
||||
|
||||
def guard_not_production(identifier: str) -> str:
|
||||
"""Abort the whole run if a production-namespaced identifier shows up."""
|
||||
if PRODUCTION_TENANT.lower() in identifier.lower():
|
||||
print(
|
||||
f"FATAL: target rule matched production-namespaced identifier "
|
||||
f"{identifier!r} - aborting without deleting anything.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
return identifier
|
||||
|
||||
|
||||
def qdrant_is_target(name: str) -> bool:
|
||||
if name in QDRANT_EXACT_TARGETS:
|
||||
return True
|
||||
if any(name.startswith(p) for p in QDRANT_PREFIX_TARGETS):
|
||||
return True
|
||||
if any(sub in name for sub in QDRANT_SUBSTRING_TARGETS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def purge_qdrant(settings, execute: bool) -> int:
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
client = QdrantClient(url=settings.qdrant_url, timeout=15)
|
||||
try:
|
||||
collections = [c.name for c in client.get_collections().collections]
|
||||
targets = []
|
||||
for name in collections:
|
||||
if qdrant_is_target(name):
|
||||
guard_not_production(name)
|
||||
targets.append(name)
|
||||
|
||||
print(f"\nQdrant ({settings.qdrant_url}): {len(targets)} target collection(s)")
|
||||
for name in sorted(targets):
|
||||
try:
|
||||
points = client.get_collection(name).points_count or 0
|
||||
except Exception:
|
||||
points = "?"
|
||||
print(f" - {name} ({points} points)")
|
||||
if execute:
|
||||
client.delete_collection(name)
|
||||
print(f" DELETED {name}")
|
||||
return len(targets)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def purge_neo4j(settings, execute: bool) -> int:
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
|
||||
guard_not_production(NEO4J_TEST_LABEL_PREFIX)
|
||||
guard_not_production(NEO4J_TEST_DOC_PATH_PREFIX)
|
||||
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password,
|
||||
)
|
||||
try:
|
||||
await client.connect()
|
||||
|
||||
label_count_q = """
|
||||
MATCH (n)
|
||||
WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix)
|
||||
RETURN count(n) AS c
|
||||
"""
|
||||
doc_count_q = """
|
||||
MATCH (d:Document)
|
||||
WHERE d.path STARTS WITH $path_prefix
|
||||
AND NOT any(l IN labels(d) WHERE l STARTS WITH $prefix)
|
||||
RETURN count(d) AS c
|
||||
"""
|
||||
params = {
|
||||
"prefix": NEO4J_TEST_LABEL_PREFIX,
|
||||
"path_prefix": NEO4J_TEST_DOC_PATH_PREFIX,
|
||||
}
|
||||
|
||||
labelled = (await client.execute_read(label_count_q, params))[0]["c"]
|
||||
legacy_docs = (await client.execute_read(doc_count_q, params))[0]["c"]
|
||||
|
||||
print(f"\nNeo4j ({settings.neo4j_uri}):")
|
||||
print(f" - {labelled} node(s) with label prefix {NEO4J_TEST_LABEL_PREFIX}*")
|
||||
print(
|
||||
f" - {legacy_docs} legacy Document node(s) with path prefix "
|
||||
f"'{NEO4J_TEST_DOC_PATH_PREFIX}' (no tenant label)"
|
||||
)
|
||||
|
||||
if execute:
|
||||
r1 = await client.execute_write(
|
||||
"""
|
||||
MATCH (n)
|
||||
WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix)
|
||||
DETACH DELETE n
|
||||
RETURN count(n) AS c
|
||||
""",
|
||||
params,
|
||||
)
|
||||
r2 = await client.execute_write(
|
||||
"""
|
||||
MATCH (d:Document)
|
||||
WHERE d.path STARTS WITH $path_prefix
|
||||
AND NOT any(l IN labels(d) WHERE l STARTS WITH $prefix)
|
||||
DETACH DELETE d
|
||||
RETURN count(d) AS c
|
||||
""",
|
||||
params,
|
||||
)
|
||||
print(f" DELETED {r1[0]['c']} labelled + {r2[0]['c']} legacy nodes")
|
||||
|
||||
return labelled + legacy_docs
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def purge_redis(settings, execute: bool) -> int:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
client = aioredis.from_url(
|
||||
settings.redis_url, encoding="utf-8", decode_responses=True
|
||||
)
|
||||
try:
|
||||
keys: set[str] = set()
|
||||
for pattern in REDIS_PATTERNS:
|
||||
async for key in client.scan_iter(match=pattern, count=500):
|
||||
guard_not_production(key)
|
||||
keys.add(key)
|
||||
|
||||
print(f"\nRedis ({settings.redis_url}): {len(keys)} target key(s)")
|
||||
for key in sorted(keys):
|
||||
print(f" - {key}")
|
||||
if execute:
|
||||
await client.delete(key)
|
||||
print(f" DELETED {key}")
|
||||
return len(keys)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Purge confirmed test-tenant residue from the shared Qdrant, "
|
||||
"Neo4j, and Redis stores. DRY-RUN by default; refuses anything "
|
||||
"namespaced to the production tenant."
|
||||
),
|
||||
epilog="Read the module docstring for the snapshot prerequisite.",
|
||||
)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="List targets and counts without deleting (DEFAULT behaviour)",
|
||||
)
|
||||
group.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help=(
|
||||
"REALLY delete the listed targets. Take Qdrant + Neo4j snapshots "
|
||||
"first (see module docstring)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
execute = bool(args.execute)
|
||||
|
||||
from src.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
mode = "EXECUTE (deleting!)" if execute else "DRY-RUN (nothing is deleted)"
|
||||
print(f"purge_test_artifacts: mode = {mode}")
|
||||
print(f"production tenant guard: refusing anything containing "
|
||||
f"'{PRODUCTION_TENANT}'")
|
||||
|
||||
totals = {}
|
||||
totals["qdrant_collections"] = purge_qdrant(settings, execute)
|
||||
totals["neo4j_nodes"] = await purge_neo4j(settings, execute)
|
||||
totals["redis_keys"] = await purge_redis(settings, execute)
|
||||
|
||||
print("\n=== Summary ===")
|
||||
for target, count in totals.items():
|
||||
print(f" {target}: {count}")
|
||||
if not execute:
|
||||
print("\nDry run only. Re-run with --execute (after snapshots) to delete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Register the Phase C production Scheduler tasks (deploy-checklist helper).
|
||||
|
||||
Defines the four task payloads from docs/scheduler-tasks.md:
|
||||
1. library_integrity_check - nightly 04:30
|
||||
2. library_quality_report - Sunday 03:00
|
||||
3. library_paperless_orphan_cleanup - daily 05:00
|
||||
4. test_example_task - DISABLED (update, not create)
|
||||
|
||||
SAFETY MODEL
|
||||
============
|
||||
- DRY-RUN BY DEFAULT: without --execute the script only prints the exact
|
||||
payloads it would send. Nothing is contacted except (optionally) the
|
||||
Scheduler health endpoint.
|
||||
- --execute performs the registration: create task if absent, update it
|
||||
if present, and disable test_example_task.
|
||||
- The Scheduler API location comes from the environment (SCHEDULER_URL);
|
||||
there is no hardcoded production default.
|
||||
- The Scheduler's task-management endpoints are themselves guarded by
|
||||
Bearer auth (verify_api_key). --execute therefore requires
|
||||
SCHEDULER_API_KEY in the environment; the registrar sends it as
|
||||
``Authorization: Bearer <key>`` on its own HTTP calls. It is read from
|
||||
the environment only and never stored anywhere.
|
||||
- NO SECRET IS EVER STORED: the library-desk API key is referenced as the
|
||||
literal placeholder ``${LIBRARY_API_KEY}`` inside the task's
|
||||
``auth.token`` field. The Scheduler's rest_api_executor substitutes
|
||||
``${ENV_VAR}`` placeholders from ITS OWN environment at execution time
|
||||
(it substitutes url/payload/auth — NOT plain headers), so the raw token
|
||||
never lands in the scheduled_tasks.config JSONB column. The Scheduler
|
||||
container must therefore have LIBRARY_API_KEY in its environment.
|
||||
|
||||
Usage:
|
||||
# Preview (default)
|
||||
SCHEDULER_URL=http://scheduler-host:8090 \
|
||||
python scripts/register_scheduler_tasks.py
|
||||
|
||||
# Register for real (deploy checklist step)
|
||||
SCHEDULER_URL=http://scheduler-host:8090 \
|
||||
SCHEDULER_API_KEY=<scheduler-api-key> \
|
||||
python scripts/register_scheduler_tasks.py --execute
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
API_KEY_PLACEHOLDER = "${LIBRARY_API_KEY}"
|
||||
PRODUCTION_TENANT = "jpmschweitzer"
|
||||
LIBRARY_BASE_URL = "http://library-desk:8089"
|
||||
|
||||
#: Tasks to create-or-update (see docs/scheduler-tasks.md).
|
||||
TASKS = [
|
||||
{
|
||||
"task_name": "library_integrity_check",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": (
|
||||
"Nightly read-only integrity check for the library "
|
||||
"(vectors/graph/wiki/collections)"
|
||||
),
|
||||
"enabled": True,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 900,
|
||||
"minute": 30,
|
||||
"hour": 4,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": -1,
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": f"{LIBRARY_BASE_URL}/maintenance/integrity-check",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
# rest_api_executor sends config["payload"] as the JSON body and
|
||||
# substitutes ${ENV_VAR} in auth.token from the Scheduler's env.
|
||||
"payload": {"user": PRODUCTION_TENANT},
|
||||
"auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER},
|
||||
},
|
||||
},
|
||||
{
|
||||
"task_name": "library_quality_report",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": (
|
||||
"Weekly library quality report (dedup, stale pages, missing "
|
||||
"metadata, integrity) written to the wiki"
|
||||
),
|
||||
"enabled": True,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 1800,
|
||||
"minute": 0,
|
||||
"hour": 3,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": 6, # Sunday (0 = Monday)
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": f"{LIBRARY_BASE_URL}/maintenance/quality-report",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"payload": {
|
||||
"user": PRODUCTION_TENANT,
|
||||
"stale_days": 30,
|
||||
"dedup_threshold": 0.9,
|
||||
"write_page": True,
|
||||
},
|
||||
"auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER},
|
||||
},
|
||||
},
|
||||
{
|
||||
"task_name": "library_paperless_orphan_cleanup",
|
||||
"service": "library-desk",
|
||||
"executor": "rest_api_executor",
|
||||
"priority": 60,
|
||||
"description": (
|
||||
"Daily cleanup of vectors/graph nodes for documents deleted "
|
||||
"from Paperless-ngx"
|
||||
),
|
||||
"enabled": True,
|
||||
"max_retries": 2,
|
||||
"timeout_seconds": 900,
|
||||
"minute": 0,
|
||||
"hour": 5,
|
||||
"day_of_month": -1,
|
||||
"month": -1,
|
||||
"day_of_week": -1,
|
||||
"config": {
|
||||
"method": "POST",
|
||||
"url": (
|
||||
f"{LIBRARY_BASE_URL}/maintenance/cleanup/paperless"
|
||||
f"?user={PRODUCTION_TENANT}&dry_run=false"
|
||||
),
|
||||
"payload": {},
|
||||
"auth": {"type": "bearer", "token": API_KEY_PLACEHOLDER},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
#: Existing tasks to update in place.
|
||||
TASK_UPDATES = [
|
||||
{"task_name": "test_example_task", "updates": {"enabled": False}},
|
||||
]
|
||||
|
||||
|
||||
def dry_run(scheduler_url: str) -> None:
|
||||
print("=" * 72)
|
||||
print("DRY RUN - nothing will be sent. Re-run with --execute to register.")
|
||||
print(f"Scheduler API: {scheduler_url or '(SCHEDULER_URL not set)'}")
|
||||
print("=" * 72)
|
||||
for task in TASKS:
|
||||
print(f"\n--- create-or-update: POST {scheduler_url}/tasks "
|
||||
f"(or PUT /tasks/{task['task_name']}) ---")
|
||||
print(json.dumps(task, indent=2))
|
||||
for update in TASK_UPDATES:
|
||||
print(f"\n--- update: PUT {scheduler_url}/tasks/{update['task_name']} ---")
|
||||
print(json.dumps(update["updates"], indent=2))
|
||||
print("\nDry run complete: "
|
||||
f"{len(TASKS)} task definition(s), {len(TASK_UPDATES)} update(s).")
|
||||
|
||||
|
||||
def execute(scheduler_url: str, scheduler_api_key: str) -> int:
|
||||
failures = 0
|
||||
# The Scheduler's task-management endpoints require Bearer auth
|
||||
# (verify_api_key: 401 when missing, 403 when wrong). Without this
|
||||
# header the existence probes 401 (misread as "task absent") and
|
||||
# every POST/PUT fails.
|
||||
auth_headers = {"Authorization": f"Bearer {scheduler_api_key}"}
|
||||
with httpx.Client(
|
||||
base_url=scheduler_url, timeout=30.0, headers=auth_headers
|
||||
) as client:
|
||||
health = client.get("/health")
|
||||
if health.status_code != 200:
|
||||
print(f"ERROR: Scheduler health check failed: {health.status_code}")
|
||||
return 1
|
||||
|
||||
for task in TASKS:
|
||||
name = task["task_name"]
|
||||
# Sent verbatim: the ${LIBRARY_API_KEY} placeholder is resolved
|
||||
# by the Scheduler at execution time, never stored as a raw key.
|
||||
payload = task
|
||||
exists = client.get(f"/tasks/{name}").status_code == 200
|
||||
if exists:
|
||||
resp = client.put(f"/tasks/{name}", json=payload)
|
||||
action = "updated"
|
||||
else:
|
||||
resp = client.post("/tasks", json=payload)
|
||||
action = "created"
|
||||
if resp.status_code == 200:
|
||||
print(f"[ok] {action} {name}")
|
||||
else:
|
||||
failures += 1
|
||||
print(f"[FAIL] {action} {name}: {resp.status_code} {resp.text[:200]}")
|
||||
|
||||
for update in TASK_UPDATES:
|
||||
name = update["task_name"]
|
||||
if client.get(f"/tasks/{name}").status_code != 200:
|
||||
print(f"[skip] {name} does not exist - nothing to disable")
|
||||
continue
|
||||
resp = client.put(f"/tasks/{name}", json=update["updates"])
|
||||
if resp.status_code == 200:
|
||||
print(f"[ok] updated {name}: {update['updates']}")
|
||||
else:
|
||||
failures += 1
|
||||
print(f"[FAIL] update {name}: {resp.status_code} {resp.text[:200]}")
|
||||
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Register library-desk production Scheduler tasks "
|
||||
"(dry-run by default)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Actually register/update the tasks (default: dry-run print only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Explicitly print payloads without sending (the default behavior)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
scheduler_url = os.environ.get("SCHEDULER_URL", "").rstrip("/")
|
||||
|
||||
if not args.execute or args.dry_run:
|
||||
dry_run(scheduler_url)
|
||||
return 0
|
||||
|
||||
if not scheduler_url:
|
||||
print("ERROR: SCHEDULER_URL must be set for --execute")
|
||||
return 1
|
||||
|
||||
scheduler_api_key = os.environ.get("SCHEDULER_API_KEY", "")
|
||||
if not scheduler_api_key:
|
||||
print("ERROR: SCHEDULER_API_KEY must be set for --execute "
|
||||
"(the Scheduler's task endpoints require Bearer auth)")
|
||||
return 1
|
||||
|
||||
return execute(scheduler_url, scheduler_api_key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
External API clients for Library Desk.
|
||||
|
||||
This package contains clients for external web APIs, named by source.
|
||||
Each provider implements a common interface for interoperability.
|
||||
|
||||
Weather providers (implement WeatherProvider):
|
||||
- openmeteo: Open-Meteo (free, no key)
|
||||
|
||||
News providers (implement NewsProvider):
|
||||
- nos: NOS.nl Dutch RSS (free, no key)
|
||||
- bbc: BBC English RSS (free, no key)
|
||||
|
||||
Financial providers (implement FinancialProvider):
|
||||
- alphavantage: Alpha Vantage (free tier with key)
|
||||
|
||||
Users can swap providers by configuring which implementation to use.
|
||||
All providers return standardized response models from base.py.
|
||||
"""
|
||||
|
||||
# Base classes and models
|
||||
from .base import (
|
||||
# Enums
|
||||
WeatherCondition,
|
||||
# Weather models
|
||||
CurrentWeather,
|
||||
DayForecast,
|
||||
WeatherForecast,
|
||||
GeoLocation,
|
||||
SunTimes,
|
||||
# Air quality models
|
||||
AirQuality,
|
||||
# News models
|
||||
NewsItem,
|
||||
NewsFeed,
|
||||
# Financial models
|
||||
StockQuote,
|
||||
# Abstract providers
|
||||
WeatherProvider,
|
||||
AirQualityProvider,
|
||||
NewsProvider,
|
||||
FinancialProvider,
|
||||
)
|
||||
|
||||
# Concrete implementations
|
||||
from .openmeteo import OpenMeteoProvider
|
||||
from .nos import NOSProvider
|
||||
from .bbc import BBCProvider
|
||||
from .news import AggregatedNewsProvider
|
||||
from .alphavantage import AlphaVantageProvider
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"WeatherCondition",
|
||||
# Weather
|
||||
"CurrentWeather",
|
||||
"DayForecast",
|
||||
"WeatherForecast",
|
||||
"GeoLocation",
|
||||
"SunTimes",
|
||||
"WeatherProvider",
|
||||
"OpenMeteoProvider",
|
||||
# Air quality
|
||||
"AirQuality",
|
||||
"AirQualityProvider",
|
||||
# News
|
||||
"NewsItem",
|
||||
"NewsFeed",
|
||||
"NewsProvider",
|
||||
"NOSProvider",
|
||||
"BBCProvider",
|
||||
"AggregatedNewsProvider",
|
||||
# Financial
|
||||
"StockQuote",
|
||||
"FinancialProvider",
|
||||
"AlphaVantageProvider",
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Alpha Vantage financial API client.
|
||||
|
||||
Stock and cryptocurrency quotes.
|
||||
https://www.alphavantage.co/documentation/
|
||||
|
||||
Requires API key (free tier available).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import FinancialProvider, StockQuote
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlphaVantageProvider(FinancialProvider):
|
||||
"""Alpha Vantage financial API implementation."""
|
||||
|
||||
BASE_URL = "https://www.alphavantage.co/query"
|
||||
|
||||
def __init__(self, api_key: str, timeout: int = 10):
|
||||
"""
|
||||
Initialize Alpha Vantage client.
|
||||
|
||||
Args:
|
||||
api_key: Alpha Vantage API key
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-initialize HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def get_quote(self, symbol: str) -> Optional[StockQuote]:
|
||||
"""
|
||||
Get current quote for a stock symbol.
|
||||
|
||||
Args:
|
||||
symbol: Stock ticker symbol (e.g., "AAPL", "MSFT")
|
||||
|
||||
Returns:
|
||||
StockQuote with current price info or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "GLOBAL_QUOTE",
|
||||
"symbol": symbol.upper(),
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if "Error Message" in data:
|
||||
logger.warning(f"Alpha Vantage error for {symbol}: {data['Error Message']}")
|
||||
return None
|
||||
|
||||
if "Note" in data:
|
||||
# Rate limit warning
|
||||
logger.warning(f"Alpha Vantage rate limit: {data['Note']}")
|
||||
return None
|
||||
|
||||
quote = data.get("Global Quote", {})
|
||||
if not quote:
|
||||
logger.warning(f"No quote data for symbol: {symbol}")
|
||||
return None
|
||||
|
||||
# Parse quote data
|
||||
price = float(quote.get("05. price", 0))
|
||||
change = float(quote.get("09. change", 0))
|
||||
change_percent_str = quote.get("10. change percent", "0%")
|
||||
change_percent = float(change_percent_str.rstrip('%'))
|
||||
|
||||
return StockQuote(
|
||||
symbol=symbol.upper(),
|
||||
name=None, # Global Quote doesn't include company name
|
||||
price=price,
|
||||
currency="USD", # Alpha Vantage returns USD for US stocks
|
||||
change=change,
|
||||
change_percent=change_percent,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage request failed for {symbol}: {e}")
|
||||
return None
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse Alpha Vantage response for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
async def get_quotes(self, symbols: list[str]) -> list[StockQuote]:
|
||||
"""
|
||||
Get quotes for multiple stock symbols.
|
||||
|
||||
Note: Alpha Vantage free tier has rate limits (5 calls/min, 500 calls/day).
|
||||
Consider using batch endpoints or caching for production use.
|
||||
|
||||
Args:
|
||||
symbols: List of stock ticker symbols
|
||||
|
||||
Returns:
|
||||
List of StockQuote objects (may be less than input if some fail)
|
||||
"""
|
||||
quotes = []
|
||||
for symbol in symbols:
|
||||
quote = await self.get_quote(symbol)
|
||||
if quote:
|
||||
quotes.append(quote)
|
||||
return quotes
|
||||
|
||||
async def get_crypto_quote(
|
||||
self,
|
||||
symbol: str,
|
||||
market: str = "USD"
|
||||
) -> Optional[StockQuote]:
|
||||
"""
|
||||
Get current quote for a cryptocurrency.
|
||||
|
||||
Args:
|
||||
symbol: Crypto symbol (e.g., "BTC", "ETH")
|
||||
market: Market currency (default: USD)
|
||||
|
||||
Returns:
|
||||
StockQuote with current price info or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "CURRENCY_EXCHANGE_RATE",
|
||||
"from_currency": symbol.upper(),
|
||||
"to_currency": market.upper(),
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API errors
|
||||
if "Error Message" in data:
|
||||
logger.warning(f"Alpha Vantage error for {symbol}: {data['Error Message']}")
|
||||
return None
|
||||
|
||||
if "Note" in data:
|
||||
logger.warning(f"Alpha Vantage rate limit: {data['Note']}")
|
||||
return None
|
||||
|
||||
rate_data = data.get("Realtime Currency Exchange Rate", {})
|
||||
if not rate_data:
|
||||
logger.warning(f"No exchange rate data for: {symbol}/{market}")
|
||||
return None
|
||||
|
||||
price = float(rate_data.get("5. Exchange Rate", 0))
|
||||
|
||||
return StockQuote(
|
||||
symbol=f"{symbol.upper()}/{market.upper()}",
|
||||
name=rate_data.get("2. From_Currency Name"),
|
||||
price=price,
|
||||
currency=market.upper(),
|
||||
change=None, # Exchange rate endpoint doesn't provide change
|
||||
change_percent=None,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage crypto request failed for {symbol}: {e}")
|
||||
return None
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to parse Alpha Vantage crypto response for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
async def search_symbol(self, keywords: str) -> list[dict]:
|
||||
"""
|
||||
Search for stock symbols by keywords.
|
||||
|
||||
Args:
|
||||
keywords: Search keywords (company name or partial symbol)
|
||||
|
||||
Returns:
|
||||
List of matching symbols with metadata
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.BASE_URL,
|
||||
params={
|
||||
"function": "SYMBOL_SEARCH",
|
||||
"keywords": keywords,
|
||||
"apikey": self.api_key
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
matches = data.get("bestMatches", [])
|
||||
return [
|
||||
{
|
||||
"symbol": m.get("1. symbol"),
|
||||
"name": m.get("2. name"),
|
||||
"type": m.get("3. type"),
|
||||
"region": m.get("4. region"),
|
||||
"currency": m.get("8. currency"),
|
||||
}
|
||||
for m in matches
|
||||
]
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Alpha Vantage search failed for '{keywords}': {e}")
|
||||
return []
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Base classes and standardized response models for external APIs.
|
||||
|
||||
All provider implementations should return these standard models
|
||||
to ensure interoperability when swapping providers.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Weather Models
|
||||
# =============================================================================
|
||||
|
||||
class WeatherCondition(Enum):
|
||||
"""Standardized weather conditions across providers."""
|
||||
CLEAR = "clear"
|
||||
PARTLY_CLOUDY = "partly_cloudy"
|
||||
CLOUDY = "cloudy"
|
||||
OVERCAST = "overcast"
|
||||
FOG = "fog"
|
||||
DRIZZLE = "drizzle"
|
||||
RAIN = "rain"
|
||||
HEAVY_RAIN = "heavy_rain"
|
||||
SNOW = "snow"
|
||||
HEAVY_SNOW = "heavy_snow"
|
||||
THUNDERSTORM = "thunderstorm"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentWeather:
|
||||
"""Standardized current weather response."""
|
||||
temperature: float # Celsius
|
||||
feels_like: Optional[float] # Celsius
|
||||
humidity: int # Percentage 0-100
|
||||
wind_speed: float # km/h
|
||||
wind_direction: Optional[int] # Degrees 0-360
|
||||
condition: WeatherCondition
|
||||
condition_text: str # Human-readable description
|
||||
timestamp: datetime
|
||||
location: str # City/location name
|
||||
uv_index: Optional[float] = None # UV index 0-11+
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
parts = [
|
||||
f"Currently {self.temperature:.1f}°C",
|
||||
f"({self.condition_text}) in {self.location}.",
|
||||
f"Humidity {self.humidity}%, wind {self.wind_speed:.0f} km/h."
|
||||
]
|
||||
if self.uv_index is not None:
|
||||
parts.append(f"UV index: {self.uv_index:.0f}.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DayForecast:
|
||||
"""Standardized daily forecast."""
|
||||
date: datetime
|
||||
temp_high: float # Celsius
|
||||
temp_low: float # Celsius
|
||||
condition: WeatherCondition
|
||||
condition_text: str
|
||||
precipitation_chance: Optional[int] # Percentage 0-100
|
||||
precipitation_mm: Optional[float]
|
||||
uv_index_max: Optional[float] = None # Max UV index for the day
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
date_str = self.date.strftime("%A") # Day name
|
||||
precip = f", {self.precipitation_chance}% rain" if self.precipitation_chance else ""
|
||||
uv = f", UV {self.uv_index_max:.0f}" if self.uv_index_max else ""
|
||||
return f"{date_str}: {self.temp_high:.0f}°/{self.temp_low:.0f}°C, {self.condition_text}{precip}{uv}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherForecast:
|
||||
"""Standardized forecast response."""
|
||||
location: str
|
||||
current: CurrentWeather
|
||||
daily: list[DayForecast] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeoLocation:
|
||||
"""Geocoding result."""
|
||||
name: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
country: Optional[str] = None
|
||||
admin_area: Optional[str] = None # State/province
|
||||
|
||||
|
||||
@dataclass
|
||||
class SunTimes:
|
||||
"""Sunrise/sunset times for a location."""
|
||||
location: str
|
||||
date: datetime
|
||||
sunrise: datetime
|
||||
sunset: datetime
|
||||
daylight_duration: int # seconds
|
||||
solar_noon: Optional[datetime] = None
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
sunrise_str = self.sunrise.strftime("%H:%M")
|
||||
sunset_str = self.sunset.strftime("%H:%M")
|
||||
hours = self.daylight_duration // 3600
|
||||
minutes = (self.daylight_duration % 3600) // 60
|
||||
return (
|
||||
f"Sun times for {self.location} on {self.date.strftime('%A %d %B')}: "
|
||||
f"Sunrise at {sunrise_str}, sunset at {sunset_str}. "
|
||||
f"Daylight duration: {hours}h {minutes}m."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirQuality:
|
||||
"""Air quality measurements for a location."""
|
||||
location: str
|
||||
timestamp: datetime
|
||||
aqi_european: Optional[int] # European AQI 0-500+
|
||||
aqi_us: Optional[int] # US AQI 0-500+
|
||||
pm2_5: Optional[float] # µg/m³
|
||||
pm10: Optional[float] # µg/m³
|
||||
ozone: Optional[float] # µg/m³
|
||||
nitrogen_dioxide: Optional[float] # µg/m³
|
||||
sulphur_dioxide: Optional[float] # µg/m³
|
||||
carbon_monoxide: Optional[float] # µg/m³
|
||||
# Pollen (European data only, seasonal)
|
||||
pollen_grass: Optional[float] = None
|
||||
pollen_birch: Optional[float] = None
|
||||
pollen_alder: Optional[float] = None
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
parts = [f"Air quality in {self.location}:"]
|
||||
if self.aqi_european is not None:
|
||||
level = self._aqi_level(self.aqi_european)
|
||||
parts.append(f"European AQI {self.aqi_european} ({level}).")
|
||||
if self.pm2_5 is not None:
|
||||
parts.append(f"PM2.5: {self.pm2_5:.1f} µg/m³.")
|
||||
if self.pm10 is not None:
|
||||
parts.append(f"PM10: {self.pm10:.1f} µg/m³.")
|
||||
if self.ozone is not None:
|
||||
parts.append(f"Ozone: {self.ozone:.1f} µg/m³.")
|
||||
return " ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _aqi_level(aqi: int) -> str:
|
||||
"""Convert AQI to human-readable level."""
|
||||
if aqi <= 20:
|
||||
return "good"
|
||||
elif aqi <= 40:
|
||||
return "fair"
|
||||
elif aqi <= 60:
|
||||
return "moderate"
|
||||
elif aqi <= 80:
|
||||
return "poor"
|
||||
elif aqi <= 100:
|
||||
return "very poor"
|
||||
else:
|
||||
return "hazardous"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# News Models
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class NewsItem:
|
||||
"""Standardized news article/item."""
|
||||
title: str
|
||||
description: Optional[str]
|
||||
url: str
|
||||
published: Optional[datetime]
|
||||
source: str # e.g., "nos", "bbc"
|
||||
category: Optional[str] = None # e.g., "tech", "world"
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewsFeed:
|
||||
"""Standardized news feed response."""
|
||||
source: str
|
||||
category: str
|
||||
items: list[NewsItem] = field(default_factory=list)
|
||||
fetched_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language summary of headlines."""
|
||||
if not self.items:
|
||||
return f"No news available from {self.source}."
|
||||
|
||||
headlines = [f"- {item.title}" for item in self.items[:5]]
|
||||
return f"Headlines from {self.source} ({self.category}):\n" + "\n".join(headlines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Financial Models
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class StockQuote:
|
||||
"""Standardized stock/crypto quote."""
|
||||
symbol: str
|
||||
name: Optional[str]
|
||||
price: float
|
||||
currency: str # e.g., "USD", "EUR"
|
||||
change: Optional[float] # Absolute change
|
||||
change_percent: Optional[float] # Percentage change
|
||||
timestamp: datetime
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""Generate natural language description."""
|
||||
change_str = ""
|
||||
if self.change is not None and self.change_percent is not None:
|
||||
direction = "up" if self.change >= 0 else "down"
|
||||
change_str = f", {direction} {abs(self.change_percent):.2f}%"
|
||||
return f"{self.symbol}: {self.price:.2f} {self.currency}{change_str}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Interfaces
|
||||
# =============================================================================
|
||||
|
||||
class WeatherProvider(ABC):
|
||||
"""Abstract base class for weather API providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def geocode(self, city: str) -> Optional[GeoLocation]:
|
||||
"""Convert city name to coordinates."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_current(self, location: GeoLocation) -> CurrentWeather:
|
||||
"""Get current weather for a location."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_forecast(self, location: GeoLocation, days: int = 7) -> WeatherForecast:
|
||||
"""Get weather forecast for a location."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_sun_times(self, location: GeoLocation) -> SunTimes:
|
||||
"""Get sunrise/sunset times for today."""
|
||||
pass
|
||||
|
||||
async def get_weather_for_city(self, city: str) -> CurrentWeather:
|
||||
"""Convenience method: geocode and get current weather."""
|
||||
location = await self.geocode(city)
|
||||
if not location:
|
||||
raise ValueError(f"Could not geocode city: {city}")
|
||||
return await self.get_current(location)
|
||||
|
||||
|
||||
class AirQualityProvider(ABC):
|
||||
"""Abstract base class for air quality API providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_air_quality(self, location: GeoLocation) -> AirQuality:
|
||||
"""Get current air quality for a location."""
|
||||
pass
|
||||
|
||||
|
||||
class NewsProvider(ABC):
|
||||
"""Abstract base class for news API providers."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def source_name(self) -> str:
|
||||
"""Provider name (e.g., 'nos', 'bbc')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def available_categories(self) -> list[str]:
|
||||
"""List of available category keys."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_feed(self, category: str, limit: int = 10) -> NewsFeed:
|
||||
"""Get news feed for a category."""
|
||||
pass
|
||||
|
||||
async def get_headlines(self, categories: list[str], limit: int = 5) -> list[NewsFeed]:
|
||||
"""Get headlines from multiple categories."""
|
||||
feeds = []
|
||||
for cat in categories:
|
||||
if cat in self.available_categories:
|
||||
feed = await self.get_feed(cat, limit)
|
||||
feeds.append(feed)
|
||||
return feeds
|
||||
|
||||
|
||||
class FinancialProvider(ABC):
|
||||
"""Abstract base class for financial API providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_quote(self, symbol: str) -> Optional[StockQuote]:
|
||||
"""Get current quote for a stock/crypto symbol."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotes(self, symbols: list[str]) -> list[StockQuote]:
|
||||
"""Get quotes for multiple symbols."""
|
||||
pass
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
BBC News RSS client.
|
||||
|
||||
Free RSS feeds from BBC News.
|
||||
https://www.bbc.com/news/10628494 (RSS feed directory)
|
||||
|
||||
No API key required.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import feedparser
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import NewsProvider, NewsItem, NewsFeed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BBCProvider(NewsProvider):
|
||||
"""BBC News RSS feed implementation."""
|
||||
|
||||
# Available BBC RSS feeds
|
||||
FEEDS: dict[str, str] = {
|
||||
# News
|
||||
"top": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||
"world": "https://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"uk": "https://feeds.bbci.co.uk/news/uk/rss.xml",
|
||||
"business": "https://feeds.bbci.co.uk/news/business/rss.xml",
|
||||
"politics": "https://feeds.bbci.co.uk/news/politics/rss.xml",
|
||||
"health": "https://feeds.bbci.co.uk/news/health/rss.xml",
|
||||
"education": "https://feeds.bbci.co.uk/news/education/rss.xml",
|
||||
"science": "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
|
||||
"tech": "https://feeds.bbci.co.uk/news/technology/rss.xml",
|
||||
"entertainment": "https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml",
|
||||
"asia": "https://feeds.bbci.co.uk/news/world/asia/rss.xml",
|
||||
"europe": "https://feeds.bbci.co.uk/news/world/europe/rss.xml",
|
||||
"africa": "https://feeds.bbci.co.uk/news/world/africa/rss.xml",
|
||||
# Sports
|
||||
"sports": "https://feeds.bbci.co.uk/sport/rss.xml",
|
||||
"football": "https://feeds.bbci.co.uk/sport/football/rss.xml",
|
||||
"cricket": "https://feeds.bbci.co.uk/sport/cricket/rss.xml",
|
||||
"tennis": "https://feeds.bbci.co.uk/sport/tennis/rss.xml",
|
||||
"rugby": "https://feeds.bbci.co.uk/sport/rugby-union/rss.xml",
|
||||
"f1": "https://feeds.bbci.co.uk/sport/motorsport/rss.xml",
|
||||
"golf": "https://feeds.bbci.co.uk/sport/golf/rss.xml",
|
||||
}
|
||||
|
||||
def __init__(self, timeout: int = 10):
|
||||
"""
|
||||
Initialize BBC RSS client.
|
||||
|
||||
Args:
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-initialize HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Provider name."""
|
||||
return "bbc"
|
||||
|
||||
@property
|
||||
def available_categories(self) -> list[str]:
|
||||
"""List of available category keys."""
|
||||
return list(self.FEEDS.keys())
|
||||
|
||||
async def get_feed(self, category: str, limit: int = 10) -> NewsFeed:
|
||||
"""
|
||||
Get news feed for a category.
|
||||
|
||||
Args:
|
||||
category: Feed category (top, world, uk, business, etc.)
|
||||
limit: Maximum number of items to return
|
||||
|
||||
Returns:
|
||||
NewsFeed with standardized news items
|
||||
|
||||
Raises:
|
||||
ValueError: If category is not available
|
||||
"""
|
||||
if category not in self.FEEDS:
|
||||
raise ValueError(
|
||||
f"Unknown category '{category}'. "
|
||||
f"Available: {', '.join(self.available_categories)}"
|
||||
)
|
||||
|
||||
feed_url = self.FEEDS[category]
|
||||
|
||||
try:
|
||||
response = await self.client.get(feed_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse RSS feed
|
||||
feed = feedparser.parse(response.text)
|
||||
|
||||
items = []
|
||||
for entry in feed.entries[:limit]:
|
||||
# Parse publication date
|
||||
published = None
|
||||
if hasattr(entry, 'published'):
|
||||
try:
|
||||
published = parsedate_to_datetime(entry.published)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# BBC uses media:thumbnail for images
|
||||
image_url = None
|
||||
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
|
||||
image_url = entry.media_thumbnail[0].get('url')
|
||||
elif hasattr(entry, 'media_content') and entry.media_content:
|
||||
image_url = entry.media_content[0].get('url')
|
||||
|
||||
items.append(NewsItem(
|
||||
title=entry.get('title', 'No title'),
|
||||
description=entry.get('summary') or entry.get('description'),
|
||||
url=entry.get('link', ''),
|
||||
published=published,
|
||||
source=self.source_name,
|
||||
category=category,
|
||||
image_url=image_url
|
||||
))
|
||||
|
||||
return NewsFeed(
|
||||
source=self.source_name,
|
||||
category=category,
|
||||
items=items,
|
||||
fetched_at=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"BBC feed request failed for '{category}': {e}")
|
||||
raise ValueError(f"Failed to fetch BBC feed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse BBC feed '{category}': {e}")
|
||||
raise ValueError(f"Failed to parse BBC feed: {e}")
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Aggregated news provider.
|
||||
|
||||
Combines multiple news sources into a single chronologically-sorted stream.
|
||||
Source selection is driven by user preferences in the settings database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from .base import NewsProvider, NewsItem, NewsFeed
|
||||
from .nos import NOSProvider
|
||||
from .bbc import BBCProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Registry of available news providers
|
||||
PROVIDER_REGISTRY: dict[str, type[NewsProvider]] = {
|
||||
"nos": NOSProvider,
|
||||
"bbc": BBCProvider,
|
||||
}
|
||||
|
||||
|
||||
class AggregatedNewsProvider:
|
||||
"""
|
||||
Aggregated news provider that combines multiple sources.
|
||||
|
||||
Fetches from configured sources in parallel and merges results
|
||||
into a single chronologically-sorted stream. Only fetches from
|
||||
enabled categories per source.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sources: list[str],
|
||||
category_filters: dict[str, list[str]] | None = None,
|
||||
timeout: int = 10
|
||||
):
|
||||
"""
|
||||
Initialize aggregated provider.
|
||||
|
||||
Args:
|
||||
sources: List of source names to aggregate (e.g., ["nos", "bbc"])
|
||||
category_filters: Per-source enabled categories.
|
||||
Example: {"nos": ["general", "tech"], "bbc": ["top", "world"]}
|
||||
Empty list or missing entry = all categories allowed.
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.sources = sources
|
||||
self.category_filters = category_filters or {}
|
||||
self.timeout = timeout
|
||||
self._providers: dict[str, NewsProvider] = {}
|
||||
|
||||
# Initialize configured providers
|
||||
for source in sources:
|
||||
if source in PROVIDER_REGISTRY:
|
||||
self._providers[source] = PROVIDER_REGISTRY[source](timeout=timeout)
|
||||
else:
|
||||
logger.warning(f"Unknown news source '{source}' - skipping")
|
||||
|
||||
def _is_category_enabled(self, source: str, category: str) -> bool:
|
||||
"""Check if a category is enabled for a source."""
|
||||
allowed = self.category_filters.get(source, [])
|
||||
# Empty list = all allowed
|
||||
if not allowed:
|
||||
return True
|
||||
return category in allowed
|
||||
|
||||
def _get_enabled_categories(self, source: str) -> list[str]:
|
||||
"""Get list of enabled categories for a source."""
|
||||
provider = self._providers.get(source)
|
||||
if not provider:
|
||||
return []
|
||||
|
||||
allowed = self.category_filters.get(source, [])
|
||||
if not allowed:
|
||||
# All categories enabled
|
||||
return provider.available_categories
|
||||
|
||||
# Filter to only enabled ones that exist
|
||||
return [c for c in allowed if c in provider.available_categories]
|
||||
|
||||
@property
|
||||
def available_sources(self) -> list[str]:
|
||||
"""List of initialized source names."""
|
||||
return list(self._providers.keys())
|
||||
|
||||
@property
|
||||
def available_categories(self) -> dict[str, list[str]]:
|
||||
"""Map of source -> available categories."""
|
||||
return {
|
||||
name: provider.available_categories
|
||||
for name, provider in self._providers.items()
|
||||
}
|
||||
|
||||
def _normalize_timestamp(self, item: NewsItem) -> datetime:
|
||||
"""Get UTC timestamp for sorting, with fallback for missing timestamps."""
|
||||
if item.published:
|
||||
# Ensure UTC
|
||||
if item.published.tzinfo is None:
|
||||
return item.published.replace(tzinfo=timezone.utc)
|
||||
return item.published.astimezone(timezone.utc)
|
||||
# Fallback: use current time (item will sort to top)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
async def get_feed(
|
||||
self,
|
||||
category: str = "general",
|
||||
limit: int = 20
|
||||
) -> NewsFeed:
|
||||
"""
|
||||
Get aggregated news feed from all sources.
|
||||
|
||||
Args:
|
||||
category: Category to fetch. Maps to source-specific categories:
|
||||
- "general"/"top": general news from all sources
|
||||
- "world": international news
|
||||
- "tech": technology news
|
||||
- "business"/"economy": business/economy news
|
||||
- "politics": political news
|
||||
limit: Maximum total items to return (after merging)
|
||||
|
||||
Returns:
|
||||
NewsFeed with merged, chronologically-sorted items
|
||||
"""
|
||||
# Map generic categories to source-specific ones
|
||||
category_map = {
|
||||
"nos": {
|
||||
"general": "general",
|
||||
"top": "general",
|
||||
"world": "world",
|
||||
"tech": "tech",
|
||||
"business": "economy",
|
||||
"economy": "economy",
|
||||
"politics": "politics",
|
||||
},
|
||||
"bbc": {
|
||||
"general": "top",
|
||||
"top": "top",
|
||||
"world": "world",
|
||||
"tech": "tech",
|
||||
"business": "business",
|
||||
"economy": "business",
|
||||
"politics": "politics",
|
||||
},
|
||||
}
|
||||
|
||||
# Fetch from all sources in parallel
|
||||
async def fetch_source(name: str, provider: NewsProvider) -> list[NewsItem]:
|
||||
try:
|
||||
source_category = category_map.get(name, {}).get(category, category)
|
||||
if source_category not in provider.available_categories:
|
||||
logger.debug(f"Category '{category}' not available for {name}")
|
||||
return []
|
||||
# Check if category is enabled for this source
|
||||
if not self._is_category_enabled(name, source_category):
|
||||
logger.debug(f"Category '{source_category}' disabled for {name}")
|
||||
return []
|
||||
feed = await provider.get_feed(source_category, limit=limit)
|
||||
return feed.items
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch from {name}: {e}")
|
||||
return []
|
||||
|
||||
tasks = [
|
||||
fetch_source(name, provider)
|
||||
for name, provider in self._providers.items()
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Merge all items
|
||||
all_items: list[NewsItem] = []
|
||||
for items in results:
|
||||
all_items.extend(items)
|
||||
|
||||
# Sort by timestamp (newest first)
|
||||
all_items.sort(key=self._normalize_timestamp, reverse=True)
|
||||
|
||||
# Apply limit
|
||||
all_items = all_items[:limit]
|
||||
|
||||
return NewsFeed(
|
||||
source="aggregated",
|
||||
category=category,
|
||||
items=all_items,
|
||||
fetched_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def get_headlines(
|
||||
self,
|
||||
categories: list[str] | None = None,
|
||||
limit: int = 10
|
||||
) -> NewsFeed:
|
||||
"""
|
||||
Get headlines from multiple categories, merged into one feed.
|
||||
|
||||
Args:
|
||||
categories: Categories to fetch. If None, fetches from all
|
||||
enabled categories across all sources.
|
||||
limit: Maximum total items to return
|
||||
|
||||
Returns:
|
||||
NewsFeed with merged headlines from all categories
|
||||
"""
|
||||
if categories is None:
|
||||
# Collect all enabled categories across sources
|
||||
all_categories: set[str] = set()
|
||||
for source in self._providers:
|
||||
all_categories.update(self._get_enabled_categories(source))
|
||||
categories = list(all_categories) if all_categories else ["general"]
|
||||
|
||||
# Fetch all categories
|
||||
tasks = [self.get_feed(cat, limit=limit) for cat in categories]
|
||||
feeds = await asyncio.gather(*tasks)
|
||||
|
||||
# Merge and deduplicate by URL
|
||||
seen_urls: set[str] = set()
|
||||
all_items: list[NewsItem] = []
|
||||
|
||||
for feed in feeds:
|
||||
for item in feed.items:
|
||||
if item.url not in seen_urls:
|
||||
seen_urls.add(item.url)
|
||||
all_items.append(item)
|
||||
|
||||
# Sort by timestamp
|
||||
all_items.sort(key=self._normalize_timestamp, reverse=True)
|
||||
|
||||
return NewsFeed(
|
||||
source="aggregated",
|
||||
category=",".join(categories),
|
||||
items=all_items[:limit],
|
||||
fetched_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""Close all provider HTTP clients."""
|
||||
for provider in self._providers.values():
|
||||
await provider.close()
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
NOS.nl Dutch news RSS client.
|
||||
|
||||
Free RSS feeds from Netherlands public broadcaster.
|
||||
https://nos.nl/feeds
|
||||
|
||||
No API key required.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import feedparser
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import NewsProvider, NewsItem, NewsFeed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NOSProvider(NewsProvider):
|
||||
"""NOS.nl RSS feed implementation."""
|
||||
|
||||
# Available NOS RSS feeds
|
||||
FEEDS: dict[str, str] = {
|
||||
# News
|
||||
"general": "https://feeds.nos.nl/nosnieuwsalgemeen",
|
||||
"domestic": "https://feeds.nos.nl/nosnieuwsbinnenland",
|
||||
"world": "https://feeds.nos.nl/nosnieuwsbuitenland",
|
||||
"politics": "https://feeds.nos.nl/nosnieuwspolitiek",
|
||||
"economy": "https://feeds.nos.nl/nosnieuwseconomie",
|
||||
"remarkable": "https://feeds.nos.nl/nosnieuwsopmerkelijk",
|
||||
"culture": "https://feeds.nos.nl/nosnieuwscultuurenmedia",
|
||||
"tech": "https://feeds.nos.nl/nosnieuwstech",
|
||||
# Sports
|
||||
"sports": "https://feeds.nos.nl/nossportalgemeen",
|
||||
"football": "https://feeds.nos.nl/nosvoetbal",
|
||||
"cycling": "https://feeds.nos.nl/nossportwielrennen",
|
||||
"skating": "https://feeds.nos.nl/nossportschaatsen",
|
||||
"tennis": "https://feeds.nos.nl/nossporttennis",
|
||||
"f1": "https://feeds.nos.nl/nossportformule1",
|
||||
}
|
||||
|
||||
def __init__(self, timeout: int = 10):
|
||||
"""
|
||||
Initialize NOS RSS client.
|
||||
|
||||
Args:
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-initialize HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
"""Provider name."""
|
||||
return "nos"
|
||||
|
||||
@property
|
||||
def available_categories(self) -> list[str]:
|
||||
"""List of available category keys."""
|
||||
return list(self.FEEDS.keys())
|
||||
|
||||
async def get_feed(self, category: str, limit: int = 10) -> NewsFeed:
|
||||
"""
|
||||
Get news feed for a category.
|
||||
|
||||
Args:
|
||||
category: Feed category (general, domestic, world, etc.)
|
||||
limit: Maximum number of items to return
|
||||
|
||||
Returns:
|
||||
NewsFeed with standardized news items
|
||||
|
||||
Raises:
|
||||
ValueError: If category is not available
|
||||
"""
|
||||
if category not in self.FEEDS:
|
||||
raise ValueError(
|
||||
f"Unknown category '{category}'. "
|
||||
f"Available: {', '.join(self.available_categories)}"
|
||||
)
|
||||
|
||||
feed_url = self.FEEDS[category]
|
||||
|
||||
try:
|
||||
response = await self.client.get(feed_url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse RSS feed
|
||||
feed = feedparser.parse(response.text)
|
||||
|
||||
items = []
|
||||
for entry in feed.entries[:limit]:
|
||||
# Parse publication date
|
||||
published = None
|
||||
if hasattr(entry, 'published'):
|
||||
try:
|
||||
published = parsedate_to_datetime(entry.published)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# Extract image URL if available
|
||||
image_url = None
|
||||
if hasattr(entry, 'media_content') and entry.media_content:
|
||||
image_url = entry.media_content[0].get('url')
|
||||
elif hasattr(entry, 'enclosures') and entry.enclosures:
|
||||
for enc in entry.enclosures:
|
||||
if enc.get('type', '').startswith('image/'):
|
||||
image_url = enc.get('href')
|
||||
break
|
||||
|
||||
items.append(NewsItem(
|
||||
title=entry.get('title', 'No title'),
|
||||
description=entry.get('summary') or entry.get('description'),
|
||||
url=entry.get('link', ''),
|
||||
published=published,
|
||||
source=self.source_name,
|
||||
category=category,
|
||||
image_url=image_url
|
||||
))
|
||||
|
||||
return NewsFeed(
|
||||
source=self.source_name,
|
||||
category=category,
|
||||
items=items,
|
||||
fetched_at=datetime.now()
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"NOS feed request failed for '{category}': {e}")
|
||||
raise ValueError(f"Failed to fetch NOS feed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse NOS feed '{category}': {e}")
|
||||
raise ValueError(f"Failed to parse NOS feed: {e}")
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
Open-Meteo weather API client.
|
||||
|
||||
Free weather API with no API key required.
|
||||
https://open-meteo.com/en/docs
|
||||
|
||||
Uses Open-Meteo Geocoding API for city name to coordinate conversion.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .base import (
|
||||
WeatherProvider,
|
||||
AirQualityProvider,
|
||||
WeatherCondition,
|
||||
CurrentWeather,
|
||||
DayForecast,
|
||||
WeatherForecast,
|
||||
GeoLocation,
|
||||
SunTimes,
|
||||
AirQuality,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WMO Weather interpretation codes to our standardized conditions
|
||||
# https://open-meteo.com/en/docs#weathervariables
|
||||
WMO_CODE_MAP: dict[int, WeatherCondition] = {
|
||||
0: WeatherCondition.CLEAR, # Clear sky
|
||||
1: WeatherCondition.CLEAR, # Mainly clear
|
||||
2: WeatherCondition.PARTLY_CLOUDY, # Partly cloudy
|
||||
3: WeatherCondition.CLOUDY, # Overcast
|
||||
45: WeatherCondition.FOG, # Fog
|
||||
48: WeatherCondition.FOG, # Depositing rime fog
|
||||
51: WeatherCondition.DRIZZLE, # Light drizzle
|
||||
53: WeatherCondition.DRIZZLE, # Moderate drizzle
|
||||
55: WeatherCondition.DRIZZLE, # Dense drizzle
|
||||
56: WeatherCondition.DRIZZLE, # Light freezing drizzle
|
||||
57: WeatherCondition.DRIZZLE, # Dense freezing drizzle
|
||||
61: WeatherCondition.RAIN, # Slight rain
|
||||
63: WeatherCondition.RAIN, # Moderate rain
|
||||
65: WeatherCondition.HEAVY_RAIN, # Heavy rain
|
||||
66: WeatherCondition.RAIN, # Light freezing rain
|
||||
67: WeatherCondition.HEAVY_RAIN, # Heavy freezing rain
|
||||
71: WeatherCondition.SNOW, # Slight snow fall
|
||||
73: WeatherCondition.SNOW, # Moderate snow fall
|
||||
75: WeatherCondition.HEAVY_SNOW, # Heavy snow fall
|
||||
77: WeatherCondition.SNOW, # Snow grains
|
||||
80: WeatherCondition.RAIN, # Slight rain showers
|
||||
81: WeatherCondition.RAIN, # Moderate rain showers
|
||||
82: WeatherCondition.HEAVY_RAIN, # Violent rain showers
|
||||
85: WeatherCondition.SNOW, # Slight snow showers
|
||||
86: WeatherCondition.HEAVY_SNOW, # Heavy snow showers
|
||||
95: WeatherCondition.THUNDERSTORM, # Thunderstorm
|
||||
96: WeatherCondition.THUNDERSTORM, # Thunderstorm with slight hail
|
||||
99: WeatherCondition.THUNDERSTORM, # Thunderstorm with heavy hail
|
||||
}
|
||||
|
||||
# Human-readable descriptions for WMO codes
|
||||
WMO_DESCRIPTIONS: dict[int, str] = {
|
||||
0: "Clear sky",
|
||||
1: "Mainly clear",
|
||||
2: "Partly cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Depositing rime fog",
|
||||
51: "Light drizzle",
|
||||
53: "Moderate drizzle",
|
||||
55: "Dense drizzle",
|
||||
56: "Light freezing drizzle",
|
||||
57: "Dense freezing drizzle",
|
||||
61: "Slight rain",
|
||||
63: "Moderate rain",
|
||||
65: "Heavy rain",
|
||||
66: "Light freezing rain",
|
||||
67: "Heavy freezing rain",
|
||||
71: "Slight snow fall",
|
||||
73: "Moderate snow fall",
|
||||
75: "Heavy snow fall",
|
||||
77: "Snow grains",
|
||||
80: "Slight rain showers",
|
||||
81: "Moderate rain showers",
|
||||
82: "Violent rain showers",
|
||||
85: "Slight snow showers",
|
||||
86: "Heavy snow showers",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm with slight hail",
|
||||
99: "Thunderstorm with heavy hail",
|
||||
}
|
||||
|
||||
|
||||
class OpenMeteoProvider(WeatherProvider, AirQualityProvider):
|
||||
"""Open-Meteo weather and air quality API implementation."""
|
||||
|
||||
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
AIR_QUALITY_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timezone: str = "Europe/Amsterdam",
|
||||
timeout: int = 10
|
||||
):
|
||||
"""
|
||||
Initialize Open-Meteo client.
|
||||
|
||||
Args:
|
||||
timezone: Default timezone for weather data
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.timezone = timezone
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-initialize HTTP client."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def geocode(self, city: str) -> Optional[GeoLocation]:
|
||||
"""
|
||||
Convert city name to coordinates.
|
||||
|
||||
Args:
|
||||
city: City name (can include country, e.g., "Amsterdam, Netherlands")
|
||||
|
||||
Returns:
|
||||
GeoLocation with coordinates or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.GEOCODING_URL,
|
||||
params={
|
||||
"name": city,
|
||||
"count": 1,
|
||||
"language": "en",
|
||||
"format": "json"
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
logger.warning(f"No geocoding results for: {city}")
|
||||
return None
|
||||
|
||||
result = results[0]
|
||||
return GeoLocation(
|
||||
name=result.get("name", city),
|
||||
latitude=result["latitude"],
|
||||
longitude=result["longitude"],
|
||||
country=result.get("country"),
|
||||
admin_area=result.get("admin1") # State/province
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Geocoding request failed for '{city}': {e}")
|
||||
return None
|
||||
except (KeyError, IndexError) as e:
|
||||
logger.error(f"Invalid geocoding response for '{city}': {e}")
|
||||
return None
|
||||
|
||||
async def get_current(self, location: GeoLocation) -> CurrentWeather:
|
||||
"""
|
||||
Get current weather for a location.
|
||||
|
||||
Args:
|
||||
location: GeoLocation with lat/long
|
||||
|
||||
Returns:
|
||||
CurrentWeather with standardized data
|
||||
|
||||
Raises:
|
||||
ValueError: If API request fails
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.WEATHER_URL,
|
||||
params={
|
||||
"latitude": location.latitude,
|
||||
"longitude": location.longitude,
|
||||
"current": [
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"relative_humidity_2m",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_direction_10m"
|
||||
],
|
||||
"daily": ["uv_index_max"],
|
||||
"timezone": self.timezone,
|
||||
"temperature_unit": "celsius",
|
||||
"wind_speed_unit": "kmh",
|
||||
"forecast_days": 1
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
current = data.get("current", {})
|
||||
weather_code = current.get("weather_code", 0)
|
||||
|
||||
# Get today's UV index from daily data
|
||||
daily = data.get("daily", {})
|
||||
uv_index = None
|
||||
if daily.get("uv_index_max"):
|
||||
uv_index = daily["uv_index_max"][0]
|
||||
|
||||
return CurrentWeather(
|
||||
temperature=current.get("temperature_2m", 0.0),
|
||||
feels_like=current.get("apparent_temperature"),
|
||||
humidity=int(current.get("relative_humidity_2m", 0)),
|
||||
wind_speed=current.get("wind_speed_10m", 0.0),
|
||||
wind_direction=current.get("wind_direction_10m"),
|
||||
condition=WMO_CODE_MAP.get(weather_code, WeatherCondition.UNKNOWN),
|
||||
condition_text=WMO_DESCRIPTIONS.get(weather_code, "Unknown"),
|
||||
timestamp=datetime.now(),
|
||||
location=location.name,
|
||||
uv_index=uv_index
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Weather request failed for {location.name}: {e}")
|
||||
raise ValueError(f"Failed to get weather: {e}")
|
||||
|
||||
async def get_forecast(
|
||||
self,
|
||||
location: GeoLocation,
|
||||
days: int = 7
|
||||
) -> WeatherForecast:
|
||||
"""
|
||||
Get weather forecast for a location.
|
||||
|
||||
Args:
|
||||
location: GeoLocation with lat/long
|
||||
days: Number of forecast days (1-16)
|
||||
|
||||
Returns:
|
||||
WeatherForecast with current and daily data
|
||||
|
||||
Raises:
|
||||
ValueError: If API request fails
|
||||
"""
|
||||
days = min(max(days, 1), 16) # Open-Meteo supports 1-16 days
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.WEATHER_URL,
|
||||
params={
|
||||
"latitude": location.latitude,
|
||||
"longitude": location.longitude,
|
||||
"current": [
|
||||
"temperature_2m",
|
||||
"apparent_temperature",
|
||||
"relative_humidity_2m",
|
||||
"weather_code",
|
||||
"wind_speed_10m",
|
||||
"wind_direction_10m"
|
||||
],
|
||||
"daily": [
|
||||
"weather_code",
|
||||
"temperature_2m_max",
|
||||
"temperature_2m_min",
|
||||
"precipitation_sum",
|
||||
"precipitation_probability_max",
|
||||
"uv_index_max"
|
||||
],
|
||||
"timezone": self.timezone,
|
||||
"temperature_unit": "celsius",
|
||||
"wind_speed_unit": "kmh",
|
||||
"forecast_days": days
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Parse current weather
|
||||
current_data = data.get("current", {})
|
||||
daily_data = data.get("daily", {})
|
||||
weather_code = current_data.get("weather_code", 0)
|
||||
|
||||
# Get today's UV from daily data
|
||||
uv_index = None
|
||||
if daily_data.get("uv_index_max"):
|
||||
uv_index = daily_data["uv_index_max"][0]
|
||||
|
||||
current = CurrentWeather(
|
||||
temperature=current_data.get("temperature_2m", 0.0),
|
||||
feels_like=current_data.get("apparent_temperature"),
|
||||
humidity=int(current_data.get("relative_humidity_2m", 0)),
|
||||
wind_speed=current_data.get("wind_speed_10m", 0.0),
|
||||
wind_direction=current_data.get("wind_direction_10m"),
|
||||
condition=WMO_CODE_MAP.get(weather_code, WeatherCondition.UNKNOWN),
|
||||
condition_text=WMO_DESCRIPTIONS.get(weather_code, "Unknown"),
|
||||
timestamp=datetime.now(),
|
||||
location=location.name,
|
||||
uv_index=uv_index
|
||||
)
|
||||
|
||||
# Parse daily forecast
|
||||
daily = []
|
||||
dates = daily_data.get("time", [])
|
||||
for i, date_str in enumerate(dates):
|
||||
code = daily_data.get("weather_code", [])[i] if i < len(daily_data.get("weather_code", [])) else 0
|
||||
uv_max = daily_data.get("uv_index_max", [])[i] if i < len(daily_data.get("uv_index_max", [])) else None
|
||||
daily.append(DayForecast(
|
||||
date=datetime.fromisoformat(date_str),
|
||||
temp_high=daily_data.get("temperature_2m_max", [])[i] if i < len(daily_data.get("temperature_2m_max", [])) else 0.0,
|
||||
temp_low=daily_data.get("temperature_2m_min", [])[i] if i < len(daily_data.get("temperature_2m_min", [])) else 0.0,
|
||||
condition=WMO_CODE_MAP.get(code, WeatherCondition.UNKNOWN),
|
||||
condition_text=WMO_DESCRIPTIONS.get(code, "Unknown"),
|
||||
precipitation_chance=daily_data.get("precipitation_probability_max", [])[i] if i < len(daily_data.get("precipitation_probability_max", [])) else None,
|
||||
precipitation_mm=daily_data.get("precipitation_sum", [])[i] if i < len(daily_data.get("precipitation_sum", [])) else None,
|
||||
uv_index_max=uv_max
|
||||
))
|
||||
|
||||
return WeatherForecast(
|
||||
location=location.name,
|
||||
current=current,
|
||||
daily=daily
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Forecast request failed for {location.name}: {e}")
|
||||
raise ValueError(f"Failed to get forecast: {e}")
|
||||
|
||||
async def get_sun_times(self, location: GeoLocation) -> SunTimes:
|
||||
"""
|
||||
Get sunrise/sunset times for today.
|
||||
|
||||
Args:
|
||||
location: GeoLocation with lat/long
|
||||
|
||||
Returns:
|
||||
SunTimes with sunrise, sunset, and daylight duration
|
||||
|
||||
Raises:
|
||||
ValueError: If API request fails
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.WEATHER_URL,
|
||||
params={
|
||||
"latitude": location.latitude,
|
||||
"longitude": location.longitude,
|
||||
"daily": [
|
||||
"sunrise",
|
||||
"sunset",
|
||||
"daylight_duration"
|
||||
],
|
||||
"timezone": self.timezone,
|
||||
"forecast_days": 1
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
daily = data.get("daily", {})
|
||||
date_str = daily.get("time", [""])[0]
|
||||
sunrise_str = daily.get("sunrise", [""])[0]
|
||||
sunset_str = daily.get("sunset", [""])[0]
|
||||
daylight = daily.get("daylight_duration", [0])[0]
|
||||
|
||||
return SunTimes(
|
||||
location=location.name,
|
||||
date=datetime.fromisoformat(date_str) if date_str else datetime.now(),
|
||||
sunrise=datetime.fromisoformat(sunrise_str) if sunrise_str else datetime.now(),
|
||||
sunset=datetime.fromisoformat(sunset_str) if sunset_str else datetime.now(),
|
||||
daylight_duration=int(daylight) if daylight else 0
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Sun times request failed for {location.name}: {e}")
|
||||
raise ValueError(f"Failed to get sun times: {e}")
|
||||
|
||||
async def get_air_quality(self, location: GeoLocation) -> AirQuality:
|
||||
"""
|
||||
Get current air quality for a location.
|
||||
|
||||
Args:
|
||||
location: GeoLocation with lat/long
|
||||
|
||||
Returns:
|
||||
AirQuality with pollutant measurements and AQI
|
||||
|
||||
Raises:
|
||||
ValueError: If API request fails
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
self.AIR_QUALITY_URL,
|
||||
params={
|
||||
"latitude": location.latitude,
|
||||
"longitude": location.longitude,
|
||||
"current": [
|
||||
"european_aqi",
|
||||
"us_aqi",
|
||||
"pm2_5",
|
||||
"pm10",
|
||||
"ozone",
|
||||
"nitrogen_dioxide",
|
||||
"sulphur_dioxide",
|
||||
"carbon_monoxide",
|
||||
"grass_pollen",
|
||||
"birch_pollen",
|
||||
"alder_pollen"
|
||||
],
|
||||
"timezone": self.timezone
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
current = data.get("current", {})
|
||||
|
||||
return AirQuality(
|
||||
location=location.name,
|
||||
timestamp=datetime.now(),
|
||||
aqi_european=current.get("european_aqi"),
|
||||
aqi_us=current.get("us_aqi"),
|
||||
pm2_5=current.get("pm2_5"),
|
||||
pm10=current.get("pm10"),
|
||||
ozone=current.get("ozone"),
|
||||
nitrogen_dioxide=current.get("nitrogen_dioxide"),
|
||||
sulphur_dioxide=current.get("sulphur_dioxide"),
|
||||
carbon_monoxide=current.get("carbon_monoxide"),
|
||||
pollen_grass=current.get("grass_pollen"),
|
||||
pollen_birch=current.get("birch_pollen"),
|
||||
pollen_alder=current.get("alder_pollen")
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Air quality request failed for {location.name}: {e}")
|
||||
raise ValueError(f"Failed to get air quality: {e}")
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Content extraction client for Library Desk.
|
||||
|
||||
A reusable Trafilatura wrapper that can be used throughout library-desk:
|
||||
- RAG search service (extract content from search results)
|
||||
- Ingestion service (extract content from URLs)
|
||||
- Standalone endpoint (ad-hoc content extraction)
|
||||
|
||||
Hardening notes:
|
||||
- Pages are fetched with httpx.AsyncClient under real connect/read timeouts
|
||||
on the event loop. Only the CPU-bound Trafilatura parse runs in the thread
|
||||
pool, so a slow server can no longer pin a worker thread for the duration
|
||||
of a blind blocking download (trafilatura.fetch_url had no caller-side
|
||||
timeout control and kept downloading after asyncio.wait_for gave up).
|
||||
- Trafilatura runs ONCE per document (bare_extraction returns text and
|
||||
metadata together); the old code ran extract() twice (the XML pass was
|
||||
computed and thrown away) plus bare_extraction — three full parses.
|
||||
- extract_batch caps the number of full-page extractions per call; overflow
|
||||
URLs are returned as unsuccessful results so callers fall back to the
|
||||
search-engine snippet.
|
||||
- Thread-pool queue depth is logged so extraction backpressure is visible.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
import trafilatura
|
||||
|
||||
from src.models.content import ContentExtractionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Modest but honest identification; some sites reject empty user agents.
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) LibraryDesk-ContentExtractor"
|
||||
}
|
||||
|
||||
# Downloads are streamed and ABORTED past this many bytes (protects memory
|
||||
# and bandwidth during the fetch, and keeps Trafilatura from pinning a
|
||||
# worker thread on a multi-hundred-MB response).
|
||||
MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class ContentExtractor:
|
||||
"""
|
||||
Generic content extraction client using Trafilatura.
|
||||
|
||||
Fetches pages asynchronously via httpx and runs Trafilatura's
|
||||
synchronous extraction in a thread pool, with support for parallel
|
||||
batch processing and configurable timeouts.
|
||||
"""
|
||||
|
||||
# Hard cap on full-page extractions per extract_batch call (e.g. one
|
||||
# web-search leg). Overflow URLs get an unsuccessful result and callers
|
||||
# fall back to the search snippet.
|
||||
DEFAULT_MAX_URLS_PER_BATCH = 8
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout: int = 5,
|
||||
max_length: int = 2000,
|
||||
max_workers: int = 10,
|
||||
connect_timeout: float = 3.0,
|
||||
max_urls_per_batch: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
Initialize ContentExtractor.
|
||||
|
||||
Args:
|
||||
timeout: Per-URL read/parse timeout in seconds
|
||||
max_length: Maximum content length to return (truncated if longer)
|
||||
max_workers: Max concurrent extractions for batch operations
|
||||
connect_timeout: TCP/TLS connect timeout in seconds
|
||||
max_urls_per_batch: Cap on full-page extractions per
|
||||
extract_batch call (None = DEFAULT_MAX_URLS_PER_BATCH)
|
||||
"""
|
||||
self.timeout = timeout
|
||||
self.max_length = max_length
|
||||
self.max_urls_per_batch = (
|
||||
max_urls_per_batch
|
||||
if max_urls_per_batch is not None
|
||||
else self.DEFAULT_MAX_URLS_PER_BATCH
|
||||
)
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self._http = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(timeout, connect=connect_timeout),
|
||||
follow_redirects=True,
|
||||
headers=DEFAULT_HEADERS,
|
||||
limits=httpx.Limits(
|
||||
max_connections=max_workers,
|
||||
max_keepalive_connections=max_workers
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Initialized ContentExtractor: timeout={timeout}s, "
|
||||
f"connect_timeout={connect_timeout}s, max_length={max_length}, "
|
||||
f"max_workers={max_workers}, max_urls_per_batch={self.max_urls_per_batch}"
|
||||
)
|
||||
|
||||
async def _fetch(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
Fetch a URL asynchronously under real connect/read timeouts.
|
||||
|
||||
The body is STREAMED and the download is aborted as soon as
|
||||
MAX_RESPONSE_BYTES have been received, so the cap bounds memory and
|
||||
bandwidth during the download itself (the old implementation
|
||||
buffered the entire response before truncating, so a
|
||||
multi-hundred-MB URL was still fully downloaded).
|
||||
|
||||
Returns:
|
||||
Response text (capped at MAX_RESPONSE_BYTES) or None when the
|
||||
response is empty / not OK.
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError subclasses on timeout/network errors.
|
||||
"""
|
||||
async with self._http.stream("GET", url) as response:
|
||||
if response.status_code != 200:
|
||||
logger.debug(
|
||||
f"Fetch returned status {response.status_code} for {url}"
|
||||
)
|
||||
return None
|
||||
|
||||
chunks: List[bytes] = []
|
||||
received = 0
|
||||
truncated = False
|
||||
async for chunk in response.aiter_bytes():
|
||||
if received + len(chunk) >= MAX_RESPONSE_BYTES:
|
||||
chunks.append(chunk[: MAX_RESPONSE_BYTES - received])
|
||||
truncated = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
received += len(chunk)
|
||||
|
||||
body = b"".join(chunks)
|
||||
if not body:
|
||||
return None
|
||||
if truncated:
|
||||
logger.warning(
|
||||
f"Response for {url} exceeds {MAX_RESPONSE_BYTES} bytes; "
|
||||
"download aborted and body truncated"
|
||||
)
|
||||
# charset comes from the Content-Type header, available before
|
||||
# the body is read.
|
||||
encoding = response.charset_encoding or "utf-8"
|
||||
return body.decode(encoding, errors="replace")
|
||||
|
||||
def _log_queue_depth(self, context: str) -> None:
|
||||
"""Log thread-pool queue depth so extraction backpressure is visible."""
|
||||
depth = self._executor._work_queue.qsize()
|
||||
if depth > 0:
|
||||
logger.info(f"ContentExtractor thread-pool queue depth ({context}): {depth}")
|
||||
|
||||
@staticmethod
|
||||
def _extract_html_sync(
|
||||
html: str,
|
||||
url: str,
|
||||
include_metadata: bool,
|
||||
max_length: int
|
||||
) -> ContentExtractionResult:
|
||||
"""
|
||||
Synchronous Trafilatura pass (runs in the thread pool).
|
||||
|
||||
Runs bare_extraction ONCE — it returns text and metadata together
|
||||
(the old implementation parsed the document three times).
|
||||
"""
|
||||
try:
|
||||
doc = trafilatura.bare_extraction(
|
||||
html,
|
||||
url=url or None,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
with_metadata=include_metadata
|
||||
)
|
||||
content = (doc or {}).get("text") or ""
|
||||
|
||||
if not content:
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error="No content extracted"
|
||||
)
|
||||
|
||||
# Truncate if needed
|
||||
if len(content) > max_length:
|
||||
content = content[:max_length] + "..."
|
||||
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
title=doc.get("title") if include_metadata else None,
|
||||
content=content,
|
||||
author=doc.get("author") if include_metadata else None,
|
||||
date=doc.get("date") if include_metadata else None,
|
||||
language=doc.get("language") if include_metadata else None,
|
||||
success=True,
|
||||
error=None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Content extraction failed for {url}: {e}")
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def extract(
|
||||
self,
|
||||
url: str,
|
||||
include_metadata: bool = True,
|
||||
max_length: Optional[int] = None
|
||||
) -> ContentExtractionResult:
|
||||
"""
|
||||
Extract content from a single URL asynchronously.
|
||||
|
||||
The download happens on the event loop under httpx connect/read
|
||||
timeouts; only the parse occupies a worker thread.
|
||||
|
||||
Args:
|
||||
url: URL to extract content from
|
||||
include_metadata: Whether to extract title, author, date
|
||||
max_length: Override default max length
|
||||
|
||||
Returns:
|
||||
ContentExtractionResult with extracted content or error
|
||||
"""
|
||||
try:
|
||||
html = await self._fetch(url)
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"Fetch timed out for {url}")
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Fetch timed out after {self.timeout}s"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"Fetch failed for {url}: {e}")
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Failed to fetch URL: {e}"
|
||||
)
|
||||
|
||||
if not html:
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error="Failed to fetch URL"
|
||||
)
|
||||
|
||||
return await self.extract_from_html(html, url, include_metadata, max_length)
|
||||
|
||||
async def extract_batch(
|
||||
self,
|
||||
urls: List[str],
|
||||
include_metadata: bool = True,
|
||||
max_length: Optional[int] = None,
|
||||
max_urls: Optional[int] = None
|
||||
) -> List[ContentExtractionResult]:
|
||||
"""
|
||||
Extract content from multiple URLs in parallel.
|
||||
|
||||
At most max_urls (default: max_urls_per_batch) URLs get a full-page
|
||||
extraction; the rest are returned unsuccessful so callers fall back
|
||||
to their existing snippet.
|
||||
|
||||
Args:
|
||||
urls: List of URLs to extract content from
|
||||
include_metadata: Whether to extract title, author, date
|
||||
max_length: Override default max length
|
||||
max_urls: Override the per-call full-page extraction cap
|
||||
|
||||
Returns:
|
||||
List of ContentExtractionResult in same order as input URLs
|
||||
"""
|
||||
cap = max_urls if max_urls is not None else self.max_urls_per_batch
|
||||
fetch_urls = urls[:cap]
|
||||
skipped_urls = urls[cap:]
|
||||
if skipped_urls:
|
||||
logger.info(
|
||||
f"extract_batch capped at {cap} full-page extractions; "
|
||||
f"skipping {len(skipped_urls)} of {len(urls)} URLs"
|
||||
)
|
||||
self._log_queue_depth("extract_batch")
|
||||
|
||||
tasks = [
|
||||
self.extract(url, include_metadata, max_length)
|
||||
for url in fetch_urls
|
||||
]
|
||||
results = list(await asyncio.gather(*tasks))
|
||||
|
||||
results.extend(
|
||||
ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Skipped: per-call extraction cap ({cap}) reached"
|
||||
)
|
||||
for url in skipped_urls
|
||||
)
|
||||
return results
|
||||
|
||||
async def extract_from_html(
|
||||
self,
|
||||
html: str,
|
||||
url: str = "",
|
||||
include_metadata: bool = True,
|
||||
max_length: Optional[int] = None
|
||||
) -> ContentExtractionResult:
|
||||
"""
|
||||
Extract content from raw HTML string.
|
||||
|
||||
Args:
|
||||
html: Raw HTML content
|
||||
url: Optional URL for reference (not fetched)
|
||||
include_metadata: Whether to extract title, author, date
|
||||
max_length: Override default max length
|
||||
|
||||
Returns:
|
||||
ContentExtractionResult with extracted content or error
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
self._log_queue_depth("extract_from_html")
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
self._executor,
|
||||
self._extract_html_sync,
|
||||
html,
|
||||
url,
|
||||
include_metadata,
|
||||
max_length or self.max_length
|
||||
),
|
||||
timeout=self.timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"Content extraction timed out for {url or '<raw html>'}")
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=f"Extraction timed out after {self.timeout}s"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error extracting {url or '<raw html>'}: {e}")
|
||||
return ContentExtractionResult(
|
||||
url=url,
|
||||
content="",
|
||||
success=False,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""Shutdown the HTTP client and the thread pool executor."""
|
||||
await self._http.aclose()
|
||||
self._executor.shutdown(wait=False)
|
||||
logger.info("ContentExtractor closed")
|
||||
@@ -8,7 +8,7 @@ Provides async Neo4j operations with:
|
||||
- Automatic retry on transient failures
|
||||
"""
|
||||
|
||||
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncSession
|
||||
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncSession, READ_ACCESS
|
||||
from typing import Optional, List, Dict, Any
|
||||
import logging
|
||||
|
||||
@@ -97,6 +97,38 @@ class Neo4jClient:
|
||||
records = await result.data()
|
||||
return records
|
||||
|
||||
async def execute_read(
|
||||
self,
|
||||
cypher: str,
|
||||
parameters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute Cypher in a READ-ONLY session.
|
||||
|
||||
The session is opened with default_access_mode=READ_ACCESS, so the
|
||||
database rejects any write attempt (CREATE/MERGE/DELETE/SET/...)
|
||||
even if it slips past caller-side validation. Use this for any
|
||||
query built from untrusted input (e.g. the /query/graph endpoint).
|
||||
|
||||
Args:
|
||||
cypher: Cypher query string
|
||||
parameters: Query parameters
|
||||
|
||||
Returns:
|
||||
List of result records as dictionaries
|
||||
|
||||
Raises:
|
||||
Exception: If driver not initialized, query fails, or the
|
||||
query attempts a write (rejected by the read session)
|
||||
"""
|
||||
if not self._driver:
|
||||
await self.connect()
|
||||
|
||||
async with self._driver.session(default_access_mode=READ_ACCESS) as session:
|
||||
result = await session.run(cypher, parameters or {})
|
||||
records = await result.data()
|
||||
return records
|
||||
|
||||
async def execute_write(
|
||||
self,
|
||||
cypher: str,
|
||||
|
||||
@@ -33,6 +33,7 @@ class OllamaClient:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
||||
self.embed_url = f"{self.base_url}/api/embed"
|
||||
self.generate_url = f"{self.base_url}/api/generate"
|
||||
self.tags_url = f"{self.base_url}/api/tags"
|
||||
self.client = httpx.AsyncClient(timeout=120.0) # Embeddings can be slow
|
||||
@@ -106,8 +107,37 @@ class OllamaClient:
|
||||
>>> len(embeddings)
|
||||
3
|
||||
"""
|
||||
embeddings = []
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# Single batched request via Ollama's /api/embed (the old
|
||||
# implementation looped one /api/embeddings call per text).
|
||||
try:
|
||||
response = await self.client.post(
|
||||
self.embed_url,
|
||||
json={"model": self.model, "input": texts}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
embeddings = data.get("embeddings")
|
||||
|
||||
if embeddings is not None and len(embeddings) == len(texts):
|
||||
if show_progress:
|
||||
logger.info(f"Batched embedding complete: {len(embeddings)}/{len(texts)}")
|
||||
return embeddings
|
||||
|
||||
logger.warning(
|
||||
f"Batched embed returned {len(embeddings or [])} vectors for "
|
||||
f"{len(texts)} inputs, falling back to per-text embedding"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Batched embed failed ({e}), falling back to per-text embedding"
|
||||
)
|
||||
|
||||
# Fallback: per-text embedding preserves partial-success semantics
|
||||
# (None entries for texts that failed to embed).
|
||||
embeddings = []
|
||||
for i, text in enumerate(texts):
|
||||
if show_progress and i % 10 == 0:
|
||||
logger.info(f"Embedding progress: {i}/{len(texts)}")
|
||||
@@ -249,7 +279,8 @@ class OllamaClient:
|
||||
self,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
stream: bool = False
|
||||
stream: bool = False,
|
||||
temperature: Optional[float] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generate text completion (for non-embedding use cases).
|
||||
@@ -258,12 +289,15 @@ class OllamaClient:
|
||||
prompt: Input prompt
|
||||
model: Model name (defaults to self.model)
|
||||
stream: Enable streaming response
|
||||
temperature: Sampling temperature (0.0 = deterministic, higher = more creative)
|
||||
None uses model default (~0.7 for mistral-nemo)
|
||||
|
||||
Returns:
|
||||
Generated text or None on failure
|
||||
|
||||
Note: This is primarily for debugging/testing. Use specialized
|
||||
LLM services for production text generation.
|
||||
Note: Use temperature=0.0 for deterministic outputs like JSON parsing,
|
||||
ranking, and factual extraction. Use higher values (0.3-0.7) for
|
||||
creative content generation.
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
@@ -272,6 +306,10 @@ class OllamaClient:
|
||||
"stream": stream
|
||||
}
|
||||
|
||||
# Add temperature to options if specified
|
||||
if temperature is not None:
|
||||
payload["options"] = {"temperature": temperature}
|
||||
|
||||
response = await self.client.post(
|
||||
self.generate_url,
|
||||
json=payload
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
Paperless-ngx API client for Library Desk.
|
||||
|
||||
Provides async document management via Paperless-ngx:
|
||||
- Document upload and retrieval
|
||||
- Search and filtering
|
||||
- Custom field management
|
||||
- Task status tracking
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperlessDocument:
|
||||
"""Represents a document from Paperless-ngx."""
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
created: Optional[str] = None
|
||||
modified: Optional[str] = None
|
||||
added: Optional[str] = None
|
||||
correspondent: Optional[int] = None
|
||||
document_type: Optional[int] = None
|
||||
storage_path: Optional[int] = None
|
||||
tags: List[int] = None
|
||||
archive_serial_number: Optional[int] = None
|
||||
original_file_name: Optional[str] = None
|
||||
archived_file_name: Optional[str] = None
|
||||
custom_fields: List[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
if self.custom_fields is None:
|
||||
self.custom_fields = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchHit:
|
||||
"""Search result with relevance info."""
|
||||
document: PaperlessDocument
|
||||
score: float
|
||||
rank: int
|
||||
highlights: Optional[str] = None
|
||||
|
||||
|
||||
class PaperlessClient:
|
||||
"""
|
||||
Paperless-ngx REST API client.
|
||||
|
||||
Documentation: https://docs.paperless-ngx.com/api/
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, token: str, timeout: int = 30):
|
||||
"""
|
||||
Initialize Paperless-ngx client.
|
||||
|
||||
Args:
|
||||
base_url: Paperless-ngx base URL (e.g., "http://paperless:8000")
|
||||
token: API token for authentication
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_url = f"{self.base_url}/api"
|
||||
self.headers = {
|
||||
"Authorization": f"Token {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
self.client = httpx.AsyncClient(timeout=float(timeout), headers=self.headers)
|
||||
logger.info(f"Initialized Paperless client: {base_url}")
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
# =========================================================================
|
||||
# Document Operations
|
||||
# =========================================================================
|
||||
|
||||
async def get_document(self, document_id: int) -> Optional[PaperlessDocument]:
|
||||
"""
|
||||
Get a document by ID.
|
||||
|
||||
Args:
|
||||
document_id: Paperless document ID
|
||||
|
||||
Returns:
|
||||
PaperlessDocument or None if not found
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/documents/{document_id}/")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return self._parse_document(data)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return None
|
||||
logger.error(f"Failed to get document {document_id}: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get document {document_id}: {e}")
|
||||
raise
|
||||
|
||||
async def get_document_content(self, document_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get extracted text content of a document.
|
||||
|
||||
Args:
|
||||
document_id: Paperless document ID
|
||||
|
||||
Returns:
|
||||
Text content or None if not found
|
||||
"""
|
||||
doc = await self.get_document(document_id)
|
||||
return doc.content if doc else None
|
||||
|
||||
async def list_documents(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
ordering: str = "-added",
|
||||
correspondent: Optional[int] = None,
|
||||
document_type: Optional[int] = None,
|
||||
tags: Optional[List[int]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List documents with pagination and filtering.
|
||||
|
||||
Args:
|
||||
page: Page number (starts at 1)
|
||||
page_size: Results per page
|
||||
ordering: Sort order (prefix with - for descending)
|
||||
correspondent: Filter by correspondent ID
|
||||
document_type: Filter by document type ID
|
||||
tags: Filter by tag IDs
|
||||
|
||||
Returns:
|
||||
Paginated response with count, next, previous, results
|
||||
"""
|
||||
params = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"ordering": ordering,
|
||||
}
|
||||
if correspondent:
|
||||
params["correspondent__id"] = correspondent
|
||||
if document_type:
|
||||
params["document_type__id"] = document_type
|
||||
if tags:
|
||||
params["tags__id__in"] = ",".join(str(t) for t in tags)
|
||||
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/documents/", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return {
|
||||
"count": data.get("count", 0),
|
||||
"next": data.get("next"),
|
||||
"previous": data.get("previous"),
|
||||
"results": [self._parse_document(d) for d in data.get("results", [])],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list documents: {e}")
|
||||
raise
|
||||
|
||||
async def search_documents(
|
||||
self,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> List[SearchHit]:
|
||||
"""
|
||||
Full-text search documents.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
page: Page number
|
||||
page_size: Results per page
|
||||
|
||||
Returns:
|
||||
List of SearchHit with document and relevance info
|
||||
"""
|
||||
params = {
|
||||
"query": query,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/documents/", params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
doc = self._parse_document(item)
|
||||
hit_info = item.get("__search_hit__", {})
|
||||
results.append(SearchHit(
|
||||
document=doc,
|
||||
score=hit_info.get("score", 0.0),
|
||||
rank=hit_info.get("rank", 0),
|
||||
highlights=hit_info.get("highlights"),
|
||||
))
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed for '{query}': {e}")
|
||||
raise
|
||||
|
||||
async def upload_document(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
title: Optional[str] = None,
|
||||
correspondent: Optional[int] = None,
|
||||
document_type: Optional[int] = None,
|
||||
tags: Optional[List[int]] = None,
|
||||
custom_fields: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Upload a document to Paperless-ngx.
|
||||
|
||||
Args:
|
||||
file_content: File bytes
|
||||
filename: Original filename
|
||||
title: Document title (optional, derived from filename if not set)
|
||||
correspondent: Correspondent ID
|
||||
document_type: Document type ID
|
||||
tags: List of tag IDs
|
||||
custom_fields: List of custom field values
|
||||
|
||||
Returns:
|
||||
Task UUID for tracking consumption status
|
||||
"""
|
||||
files = {"document": (filename, file_content)}
|
||||
data = {}
|
||||
|
||||
if title:
|
||||
data["title"] = title
|
||||
if correspondent:
|
||||
data["correspondent"] = correspondent
|
||||
if document_type:
|
||||
data["document_type"] = document_type
|
||||
if tags:
|
||||
# Tags need to be sent multiple times for multiple values
|
||||
data["tags"] = tags
|
||||
if custom_fields:
|
||||
data["custom_fields"] = custom_fields
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/documents/post_document/",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
task_id = result.get("task_id", "")
|
||||
logger.info(f"Uploaded document '{filename}', task_id: {task_id}")
|
||||
return task_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload document '{filename}': {e}")
|
||||
raise
|
||||
|
||||
async def get_task_status(self, task_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of a consumption task.
|
||||
|
||||
Args:
|
||||
task_id: Task UUID from upload
|
||||
|
||||
Returns:
|
||||
Task status with state, result, etc.
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.api_url}/tasks/",
|
||||
params={"task_id": task_id},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
results = data.get("results", [])
|
||||
if results:
|
||||
return results[0]
|
||||
return {"status": "NOT_FOUND"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get task status {task_id}: {e}")
|
||||
raise
|
||||
|
||||
async def update_document(
|
||||
self,
|
||||
document_id: int,
|
||||
title: Optional[str] = None,
|
||||
correspondent: Optional[int] = None,
|
||||
document_type: Optional[int] = None,
|
||||
tags: Optional[List[int]] = None,
|
||||
custom_fields: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> PaperlessDocument:
|
||||
"""
|
||||
Update a document's metadata.
|
||||
|
||||
Args:
|
||||
document_id: Document ID to update
|
||||
title: New title
|
||||
correspondent: New correspondent ID
|
||||
document_type: New document type ID
|
||||
tags: New tag IDs (replaces existing)
|
||||
custom_fields: New custom field values
|
||||
|
||||
Returns:
|
||||
Updated document
|
||||
"""
|
||||
data = {}
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
if correspondent is not None:
|
||||
data["correspondent"] = correspondent
|
||||
if document_type is not None:
|
||||
data["document_type"] = document_type
|
||||
if tags is not None:
|
||||
data["tags"] = tags
|
||||
if custom_fields is not None:
|
||||
data["custom_fields"] = custom_fields
|
||||
|
||||
try:
|
||||
response = await self.client.patch(
|
||||
f"{self.api_url}/documents/{document_id}/",
|
||||
json=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return self._parse_document(response.json())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update document {document_id}: {e}")
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# Custom Fields
|
||||
# =========================================================================
|
||||
|
||||
async def list_custom_fields(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all custom fields.
|
||||
|
||||
Returns:
|
||||
List of custom field definitions
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/custom_fields/")
|
||||
response.raise_for_status()
|
||||
return response.json().get("results", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list custom fields: {e}")
|
||||
raise
|
||||
|
||||
async def get_custom_field_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get a custom field by name.
|
||||
|
||||
Args:
|
||||
name: Custom field name
|
||||
|
||||
Returns:
|
||||
Custom field definition or None
|
||||
"""
|
||||
fields = await self.list_custom_fields()
|
||||
for field in fields:
|
||||
if field.get("name") == name:
|
||||
return field
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Tags, Correspondents, Document Types
|
||||
# =========================================================================
|
||||
|
||||
async def list_tags(self) -> List[Dict[str, Any]]:
|
||||
"""List all tags."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/tags/")
|
||||
response.raise_for_status()
|
||||
return response.json().get("results", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list tags: {e}")
|
||||
raise
|
||||
|
||||
async def list_correspondents(self) -> List[Dict[str, Any]]:
|
||||
"""List all correspondents."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/correspondents/")
|
||||
response.raise_for_status()
|
||||
return response.json().get("results", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list correspondents: {e}")
|
||||
raise
|
||||
|
||||
async def list_document_types(self) -> List[Dict[str, Any]]:
|
||||
"""List all document types."""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/document_types/")
|
||||
response.raise_for_status()
|
||||
return response.json().get("results", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list document types: {e}")
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# Bulk Operations
|
||||
# =========================================================================
|
||||
|
||||
async def bulk_edit(
|
||||
self,
|
||||
document_ids: List[int],
|
||||
method: str,
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Bulk edit documents.
|
||||
|
||||
Args:
|
||||
document_ids: List of document IDs
|
||||
method: Operation (add_tag, remove_tag, set_correspondent, etc.)
|
||||
parameters: Operation parameters
|
||||
|
||||
Returns:
|
||||
Operation result
|
||||
"""
|
||||
data = {
|
||||
"documents": document_ids,
|
||||
"method": method,
|
||||
}
|
||||
if parameters:
|
||||
data["parameters"] = parameters
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.api_url}/documents/bulk_edit/",
|
||||
json=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Bulk edit failed: {e}")
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# Health Check
|
||||
# =========================================================================
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Paperless-ngx is responding.
|
||||
|
||||
Returns:
|
||||
True if service is healthy
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(f"{self.api_url}/", timeout=5.0)
|
||||
return response.status_code < 400
|
||||
except Exception as e:
|
||||
logger.error(f"Paperless health check failed: {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def _parse_document(self, data: Dict[str, Any]) -> PaperlessDocument:
|
||||
"""Parse API response into PaperlessDocument."""
|
||||
return PaperlessDocument(
|
||||
id=data.get("id", 0),
|
||||
title=data.get("title", ""),
|
||||
content=data.get("content", ""),
|
||||
created=data.get("created"),
|
||||
modified=data.get("modified"),
|
||||
added=data.get("added"),
|
||||
correspondent=data.get("correspondent"),
|
||||
document_type=data.get("document_type"),
|
||||
storage_path=data.get("storage_path"),
|
||||
tags=data.get("tags", []),
|
||||
archive_serial_number=data.get("archive_serial_number"),
|
||||
original_file_name=data.get("original_file_name"),
|
||||
archived_file_name=data.get("archived_file_name"),
|
||||
custom_fields=data.get("custom_fields", []),
|
||||
)
|
||||
+290
-22
@@ -8,10 +8,10 @@ Provides async vector operations with:
|
||||
- Similarity queries
|
||||
"""
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance, VectorParams, PointStruct,
|
||||
Filter, FieldCondition, MatchValue
|
||||
Filter, FieldCondition, MatchValue, Range
|
||||
)
|
||||
from typing import List, Dict, Any, Optional
|
||||
import uuid
|
||||
@@ -30,17 +30,22 @@ class QdrantClientWrapper:
|
||||
Each user has isolated vector collection for their documents.
|
||||
"""
|
||||
|
||||
def __init__(self, url: str, embedding_dim: int = 768):
|
||||
def __init__(self, url: str, embedding_dim: int = 768, timeout: float = 30.0):
|
||||
"""
|
||||
Initialize Qdrant client.
|
||||
|
||||
Uses AsyncQdrantClient so vector calls never block the FastAPI
|
||||
event loop, with an explicit timeout so a hung Qdrant cannot
|
||||
stall requests indefinitely.
|
||||
|
||||
Args:
|
||||
url: Qdrant server URL (e.g., "http://qdrant:6333")
|
||||
embedding_dim: Vector embedding dimension (default 768 for nomic-embed-text)
|
||||
timeout: Per-request timeout in seconds (default 30.0)
|
||||
"""
|
||||
self.client = QdrantClient(url=url)
|
||||
self.client = AsyncQdrantClient(url=url, timeout=timeout)
|
||||
self.embedding_dim = embedding_dim
|
||||
logger.info(f"Initialized Qdrant client: {url}")
|
||||
logger.info(f"Initialized async Qdrant client: {url} (timeout={timeout}s)")
|
||||
|
||||
def get_collection_name(self, user: str) -> str:
|
||||
"""
|
||||
@@ -62,12 +67,12 @@ class QdrantClientWrapper:
|
||||
collection_name: Collection name
|
||||
"""
|
||||
try:
|
||||
collections = self.client.get_collections()
|
||||
collections = await self.client.get_collections()
|
||||
existing = [c.name for c in collections.collections]
|
||||
|
||||
if collection_name not in existing:
|
||||
logger.info(f"Creating Qdrant collection: {collection_name}")
|
||||
self.client.create_collection(
|
||||
await self.client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(
|
||||
size=self.embedding_dim,
|
||||
@@ -90,7 +95,7 @@ class QdrantClientWrapper:
|
||||
True if collection exists
|
||||
"""
|
||||
try:
|
||||
collections = self.client.get_collections()
|
||||
collections = await self.client.get_collections()
|
||||
existing = [c.name for c in collections.collections]
|
||||
return collection_name in existing
|
||||
except Exception as e:
|
||||
@@ -137,7 +142,9 @@ class QdrantClientWrapper:
|
||||
)
|
||||
|
||||
collection_name = self.get_collection_name(user)
|
||||
await self.ensure_collection(user)
|
||||
# Ensure the tenant-scoped collection (passing the raw user here used
|
||||
# to create a stray collection named after the bare user string).
|
||||
await self.ensure_collection(collection_name)
|
||||
|
||||
points = []
|
||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
@@ -159,7 +166,7 @@ class QdrantClientWrapper:
|
||||
))
|
||||
|
||||
try:
|
||||
self.client.upsert(
|
||||
await self.client.upsert(
|
||||
collection_name=collection_name,
|
||||
points=points
|
||||
)
|
||||
@@ -211,7 +218,7 @@ class QdrantClientWrapper:
|
||||
query_filter = Filter(must=conditions)
|
||||
|
||||
try:
|
||||
response = self.client.query_points(
|
||||
response = await self.client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_vector,
|
||||
limit=limit,
|
||||
@@ -249,7 +256,7 @@ class QdrantClientWrapper:
|
||||
collection_name = self.get_collection_name(user)
|
||||
|
||||
try:
|
||||
self.client.delete(
|
||||
await self.client.delete(
|
||||
collection_name=collection_name,
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
@@ -285,7 +292,7 @@ class QdrantClientWrapper:
|
||||
|
||||
try:
|
||||
# Scroll through points with doc_id filter
|
||||
points, _ = self.client.scroll(
|
||||
points, _ = await self.client.scroll(
|
||||
collection_name=collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
@@ -327,7 +334,7 @@ class QdrantClientWrapper:
|
||||
|
||||
try:
|
||||
# Get collection info
|
||||
collection_info = self.client.get_collection(collection_name)
|
||||
collection_info = await self.client.get_collection(collection_name)
|
||||
# This gives total points, not unique docs
|
||||
# For unique docs, would need to aggregate by doc_id
|
||||
return collection_info.points_count
|
||||
@@ -350,7 +357,7 @@ class QdrantClientWrapper:
|
||||
collection_name = self.get_collection_name(user)
|
||||
|
||||
try:
|
||||
self.client.delete_collection(collection_name)
|
||||
await self.client.delete_collection(collection_name)
|
||||
logger.warning(f"Deleted collection: {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -380,7 +387,7 @@ class QdrantClientWrapper:
|
||||
|
||||
try:
|
||||
# Get source document chunks with vectors
|
||||
source_points, _ = self.client.scroll(
|
||||
source_points, _ = await self.client.scroll(
|
||||
collection_name=collection_name,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
@@ -402,7 +409,7 @@ class QdrantClientWrapper:
|
||||
# (could aggregate multiple chunks for better results)
|
||||
first_vector = source_points[0].vector
|
||||
|
||||
response = self.client.query_points(
|
||||
response = await self.client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=first_vector,
|
||||
limit=limit * 2, # Get more to filter out same doc
|
||||
@@ -447,7 +454,7 @@ class QdrantClientWrapper:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
self.client.upsert(
|
||||
await self.client.upsert(
|
||||
collection_name=collection_name,
|
||||
points=[PointStruct(
|
||||
id=vector_id,
|
||||
@@ -460,6 +467,43 @@ class QdrantClientWrapper:
|
||||
logger.error(f"Failed to upsert vector: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def upsert_points(
|
||||
self,
|
||||
collection_name: str,
|
||||
points: List[Dict[str, Any]]
|
||||
) -> int:
|
||||
"""
|
||||
Upsert a batch of vector points in a single request.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
points: List of {"id": str, "vector": List[float], "payload": dict}
|
||||
|
||||
Returns:
|
||||
Number of points upserted
|
||||
|
||||
Raises:
|
||||
Exception: If the upsert fails (callers decide how to degrade)
|
||||
"""
|
||||
if not points:
|
||||
return 0
|
||||
|
||||
structs = [
|
||||
PointStruct(
|
||||
id=p["id"],
|
||||
vector=p["vector"],
|
||||
payload=p.get("payload", {})
|
||||
)
|
||||
for p in points
|
||||
]
|
||||
|
||||
await self.client.upsert(
|
||||
collection_name=collection_name,
|
||||
points=structs
|
||||
)
|
||||
logger.info(f"Upserted {len(structs)} points into {collection_name}")
|
||||
return len(structs)
|
||||
|
||||
async def delete_by_filter(
|
||||
self,
|
||||
collection_name: str,
|
||||
@@ -485,7 +529,7 @@ class QdrantClientWrapper:
|
||||
query_filter = Filter(must=conditions)
|
||||
|
||||
# Delete points
|
||||
result = self.client.delete(
|
||||
result = await self.client.delete(
|
||||
collection_name=collection_name,
|
||||
points_selector=query_filter
|
||||
)
|
||||
@@ -530,7 +574,7 @@ class QdrantClientWrapper:
|
||||
query_filter = Filter(must=conditions)
|
||||
|
||||
try:
|
||||
response = self.client.query_points(
|
||||
response = await self.client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_vector,
|
||||
limit=limit,
|
||||
@@ -551,6 +595,100 @@ class QdrantClientWrapper:
|
||||
logger.error(f"Search failed: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def scroll_all_points(
|
||||
self,
|
||||
collection_name: str,
|
||||
batch_size: int = 100,
|
||||
with_payload: bool = True,
|
||||
with_vectors: bool = False,
|
||||
filter_conditions: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scroll through all points in a collection.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
batch_size: Number of points per batch
|
||||
with_payload: Include payload in results
|
||||
with_vectors: Include vectors in results
|
||||
filter_conditions: Optional filter conditions
|
||||
|
||||
Returns:
|
||||
List of all points with id and payload
|
||||
"""
|
||||
all_points = []
|
||||
offset = None
|
||||
|
||||
# Build filter if provided
|
||||
scroll_filter = None
|
||||
if filter_conditions:
|
||||
conditions = []
|
||||
for key, value in filter_conditions.items():
|
||||
conditions.append(
|
||||
FieldCondition(key=key, match=MatchValue(value=value))
|
||||
)
|
||||
scroll_filter = Filter(must=conditions)
|
||||
|
||||
try:
|
||||
while True:
|
||||
points, next_offset = await self.client.scroll(
|
||||
collection_name=collection_name,
|
||||
scroll_filter=scroll_filter,
|
||||
limit=batch_size,
|
||||
offset=offset,
|
||||
with_payload=with_payload,
|
||||
with_vectors=with_vectors
|
||||
)
|
||||
|
||||
for point in points:
|
||||
entry = {
|
||||
"id": str(point.id),
|
||||
"payload": dict(point.payload) if point.payload else {}
|
||||
}
|
||||
if with_vectors:
|
||||
entry["vector"] = point.vector
|
||||
all_points.append(entry)
|
||||
|
||||
if next_offset is None:
|
||||
break
|
||||
offset = next_offset
|
||||
|
||||
return all_points
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scroll collection {collection_name}: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def delete_by_ids(
|
||||
self,
|
||||
collection_name: str,
|
||||
point_ids: List[str]
|
||||
) -> int:
|
||||
"""
|
||||
Delete points by their IDs.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
point_ids: List of point IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of points deleted
|
||||
"""
|
||||
if not point_ids:
|
||||
return 0
|
||||
|
||||
try:
|
||||
await self.client.delete(
|
||||
collection_name=collection_name,
|
||||
points_selector=point_ids
|
||||
)
|
||||
logger.info(f"Deleted {len(point_ids)} points from {collection_name}")
|
||||
return len(point_ids)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete points by IDs: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def list_collections(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all collections with stats.
|
||||
@@ -559,13 +697,13 @@ class QdrantClientWrapper:
|
||||
List of collection info dictionaries
|
||||
"""
|
||||
try:
|
||||
collections = self.client.get_collections()
|
||||
collections = await self.client.get_collections()
|
||||
result = []
|
||||
|
||||
for coll in collections.collections:
|
||||
# Get detailed collection info
|
||||
try:
|
||||
info = self.client.get_collection(coll.name)
|
||||
info = await self.client.get_collection(coll.name)
|
||||
result.append({
|
||||
"name": coll.name,
|
||||
"vectors_count": info.vectors_count or 0,
|
||||
@@ -585,4 +723,134 @@ class QdrantClientWrapper:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
# ========== Volatile Data Methods ==========
|
||||
|
||||
async def search_with_expiry_filter(
|
||||
self,
|
||||
collection_name: str,
|
||||
query_vector: List[float],
|
||||
current_timestamp: int,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.7
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search vectors filtering out expired records.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
query_vector: Query embedding vector
|
||||
current_timestamp: Current time in milliseconds
|
||||
limit: Maximum results
|
||||
score_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
List of non-expired search results
|
||||
"""
|
||||
# Filter: ttl_expiry > current_timestamp (not expired)
|
||||
expiry_filter = Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="ttl_expiry",
|
||||
range=Range(gt=current_timestamp)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
query_filter=expiry_filter,
|
||||
with_payload=True
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(point.id),
|
||||
"score": point.score,
|
||||
"payload": dict(point.payload)
|
||||
}
|
||||
for point in response.points
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Volatile search failed: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def delete_expired_vectors(
|
||||
self,
|
||||
collection_name: str,
|
||||
current_timestamp: int
|
||||
) -> int:
|
||||
"""
|
||||
Delete all vectors where ttl_expiry < current_timestamp.
|
||||
|
||||
Args:
|
||||
collection_name: Collection name
|
||||
current_timestamp: Current time in milliseconds
|
||||
|
||||
Returns:
|
||||
Number of points deleted (approximate)
|
||||
"""
|
||||
# Filter: ttl_expiry < current_timestamp (expired)
|
||||
expiry_filter = Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="ttl_expiry",
|
||||
range=Range(lt=current_timestamp)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
# First count how many will be deleted (scroll to count)
|
||||
count = 0
|
||||
offset = None
|
||||
while True:
|
||||
points, next_offset = await self.client.scroll(
|
||||
collection_name=collection_name,
|
||||
scroll_filter=expiry_filter,
|
||||
limit=100,
|
||||
offset=offset,
|
||||
with_payload=False
|
||||
)
|
||||
count += len(points)
|
||||
if next_offset is None:
|
||||
break
|
||||
offset = next_offset
|
||||
|
||||
if count == 0:
|
||||
return 0
|
||||
|
||||
# Delete expired points
|
||||
await self.client.delete(
|
||||
collection_name=collection_name,
|
||||
points_selector=expiry_filter
|
||||
)
|
||||
|
||||
logger.info(f"Deleted {count} expired vectors from {collection_name}")
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete expired vectors: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def get_volatile_collections(self) -> List[str]:
|
||||
"""
|
||||
Get all volatile collections (prefixed with 'volatile_').
|
||||
|
||||
Returns:
|
||||
List of volatile collection names
|
||||
"""
|
||||
try:
|
||||
collections = await self.client.get_collections()
|
||||
return [
|
||||
c.name for c in collections.collections
|
||||
if c.name.startswith("volatile_")
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list volatile collections: {e}", exc_info=True)
|
||||
return []
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Client for external Scheduler service.
|
||||
|
||||
Registers and manages scheduled tasks for prefetch operations
|
||||
(weather, news, etc.) discovered through HybridRAG searches.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from urllib.parse import quote
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Literal placeholder stored in task auth.token; the Scheduler's
|
||||
#: rest_api_executor substitutes ${ENV_VAR} from ITS OWN environment at
|
||||
#: execution time, so the raw library-desk key is never stored in the
|
||||
#: scheduled_tasks.config column.
|
||||
LIBRARY_API_KEY_PLACEHOLDER = "${LIBRARY_API_KEY}"
|
||||
|
||||
|
||||
class SchedulerTask(BaseModel):
|
||||
"""Task definition for scheduler registration."""
|
||||
|
||||
task_name: str = Field(..., description="Unique task identifier")
|
||||
service: str = Field(default="library-desk", description="Service that owns this task")
|
||||
executor: str = Field(default="rest_api_executor", description="Executor type")
|
||||
priority: int = Field(default=50, ge=1, le=100, description="Priority (lower = higher)")
|
||||
description: Optional[str] = Field(None, description="Human-readable description")
|
||||
enabled: bool = Field(default=True, description="Whether task is enabled")
|
||||
max_retries: int = Field(default=3, ge=0, le=10, description="Max retry attempts")
|
||||
timeout_seconds: int = Field(default=3600, ge=1, description="Execution timeout")
|
||||
|
||||
# Schedule (-1 = every, or specific value)
|
||||
minute: int = Field(default=-1, ge=-1, le=59, description="Minute (-1=every)")
|
||||
hour: int = Field(default=-1, ge=-1, le=23, description="Hour (-1=every)")
|
||||
day_of_month: int = Field(default=-1, ge=-1, le=31, description="Day of month (-1=every)")
|
||||
month: int = Field(default=-1, ge=-1, le=12, description="Month (-1=every)")
|
||||
day_of_week: int = Field(default=-1, ge=-1, le=6, description="Day of week (-1=every, 0=Mon)")
|
||||
|
||||
# Executor config (for rest_api executor)
|
||||
config: Optional[dict[str, Any]] = Field(None, description="Executor-specific config")
|
||||
|
||||
|
||||
class SchedulerClient:
|
||||
"""Client for external scheduler service."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = "", timeout: float = 30.0):
|
||||
"""
|
||||
Initialize scheduler client.
|
||||
|
||||
Args:
|
||||
base_url: Scheduler API base URL (e.g., "http://scheduler:8090")
|
||||
api_key: Bearer key for the Scheduler's task-management API.
|
||||
The /tasks endpoints are guarded by verify_api_key (401 when
|
||||
the Authorization header is missing), so without this key
|
||||
every registration call fails.
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
if not api_key:
|
||||
logger.warning(
|
||||
"SchedulerClient created without an API key; task-management "
|
||||
"calls will be rejected by the Scheduler (401)"
|
||||
)
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client (with Scheduler API Bearer auth)."""
|
||||
if self._client is None or self._client.is_closed:
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
||||
)
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=headers,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.info("Scheduler client closed")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check scheduler connectivity."""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get("/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def task_exists(self, task_name: str) -> bool:
|
||||
"""
|
||||
Check if a task already exists.
|
||||
|
||||
Args:
|
||||
task_name: Task identifier to check
|
||||
|
||||
Returns:
|
||||
True if task exists, False otherwise.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"/tasks/{task_name}")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check task existence: {e}")
|
||||
return False
|
||||
|
||||
async def get_task(self, task_name: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Get task details.
|
||||
|
||||
Args:
|
||||
task_name: Task identifier
|
||||
|
||||
Returns:
|
||||
Task dict or None if not found.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"/tasks/{task_name}")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get task {task_name}: {e}")
|
||||
return None
|
||||
|
||||
async def list_tasks(
|
||||
self,
|
||||
service: Optional[str] = None,
|
||||
enabled: Optional[bool] = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List scheduled tasks.
|
||||
|
||||
Args:
|
||||
service: Filter by service name
|
||||
enabled: Filter by enabled status
|
||||
|
||||
Returns:
|
||||
List of task dicts.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
params = {}
|
||||
if service:
|
||||
params["service"] = service
|
||||
if enabled is not None:
|
||||
params["enabled"] = enabled
|
||||
|
||||
response = await client.get("/tasks", params=params)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list tasks: {e}")
|
||||
return []
|
||||
|
||||
async def create_task(self, task: SchedulerTask) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Create a new scheduled task.
|
||||
|
||||
Args:
|
||||
task: Task definition
|
||||
|
||||
Returns:
|
||||
Created task dict or None on failure.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
"/tasks",
|
||||
json=task.model_dump(exclude_none=True)
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Created scheduler task: {task.task_name}")
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to create task {task.task_name}: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create task {task.task_name}: {e}")
|
||||
return None
|
||||
|
||||
async def update_task(
|
||||
self,
|
||||
task_name: str,
|
||||
updates: dict[str, Any]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Update an existing task.
|
||||
|
||||
Args:
|
||||
task_name: Task identifier
|
||||
updates: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated task dict or None on failure.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.put(f"/tasks/{task_name}", json=updates)
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Updated scheduler task: {task_name}")
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to update task {task_name}: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update task {task_name}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_task(self, task_name: str) -> bool:
|
||||
"""
|
||||
Delete a scheduled task.
|
||||
|
||||
Args:
|
||||
task_name: Task identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.delete(f"/tasks/{task_name}")
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Deleted scheduler task: {task_name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete task {task_name}: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete task {task_name}: {e}")
|
||||
return False
|
||||
|
||||
async def trigger_task(self, task_name: str) -> bool:
|
||||
"""
|
||||
Manually trigger a task to run immediately.
|
||||
|
||||
Args:
|
||||
task_name: Task identifier
|
||||
|
||||
Returns:
|
||||
True if triggered, False otherwise.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(f"/tasks/{task_name}/trigger")
|
||||
if response.status_code == 200:
|
||||
logger.info(f"Triggered task: {task_name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to trigger task {task_name}: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger task {task_name}: {e}")
|
||||
return False
|
||||
|
||||
async def register_volatile_fetch(
|
||||
self,
|
||||
namespace: str,
|
||||
key: str,
|
||||
user: str,
|
||||
schedule: dict[str, int],
|
||||
description: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Register a volatile fetch task for prefetch.
|
||||
|
||||
Convenience method to create tasks that call /volatile/fetch endpoints.
|
||||
|
||||
Args:
|
||||
namespace: Volatile namespace (e.g., "weather", "news")
|
||||
key: Volatile key (e.g., "rotterdam", "nos")
|
||||
user: User for the fetch
|
||||
schedule: Cron-like schedule dict (minute, hour, etc.)
|
||||
description: Human-readable description
|
||||
|
||||
Returns:
|
||||
True if registered (or already exists), False on failure.
|
||||
"""
|
||||
task_name = f"volatile_{namespace}_{key}_{user}".replace("-", "_")
|
||||
|
||||
# Check if already exists
|
||||
if await self.task_exists(task_name):
|
||||
logger.info(f"Prefetch task already exists: {task_name}")
|
||||
return True
|
||||
|
||||
task = SchedulerTask(
|
||||
task_name=task_name,
|
||||
service="library-desk",
|
||||
executor="rest_api_executor",
|
||||
priority=60, # Background maintenance priority
|
||||
description=description or f"Prefetch {namespace}/{key} for {user}",
|
||||
minute=schedule.get("minute", -1),
|
||||
hour=schedule.get("hour", -1),
|
||||
day_of_month=schedule.get("day_of_month", -1),
|
||||
month=schedule.get("month", -1),
|
||||
day_of_week=schedule.get("day_of_week", -1),
|
||||
config={
|
||||
"method": "POST",
|
||||
# /volatile/fetch endpoints take user as a REQUIRED QUERY
|
||||
# parameter (RequiredUserQuery) - a body user would 422.
|
||||
"url": (
|
||||
f"http://library-desk:8089/volatile/fetch/"
|
||||
f"{namespace}/{key}?user={quote(user, safe='')}"
|
||||
),
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
# rest_api_executor only reads config["payload"] as the JSON
|
||||
# body (a "body" key is silently ignored).
|
||||
"payload": {},
|
||||
# Substituted from the Scheduler's environment at execution
|
||||
# time; without it the scheduled POST 401s against
|
||||
# library-desk's verify_api_key.
|
||||
"auth": {
|
||||
"type": "bearer",
|
||||
"token": LIBRARY_API_KEY_PLACEHOLDER,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
result = await self.create_task(task)
|
||||
return result is not None
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Client for central Tatlock settings database.
|
||||
|
||||
Reads settings from the shared system_settings PostgreSQL database.
|
||||
Writes are done via psql CLI or future CRUD manager.
|
||||
"""
|
||||
|
||||
import asyncpg
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsClient:
|
||||
"""Client for system_settings database."""
|
||||
|
||||
def __init__(self, dsn: str):
|
||||
"""
|
||||
Initialize settings client.
|
||||
|
||||
Args:
|
||||
dsn: PostgreSQL connection string
|
||||
e.g., "postgresql://settings:password@postgres-shared:5432/system_settings"
|
||||
"""
|
||||
self.dsn = dsn
|
||||
self._pool: Optional[asyncpg.Pool] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Initialize connection pool."""
|
||||
if not self._pool:
|
||||
try:
|
||||
self._pool = await asyncpg.create_pool(
|
||||
self.dsn,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=10,
|
||||
)
|
||||
logger.info("Connected to system_settings database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to system_settings: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Close connection pool."""
|
||||
if self._pool:
|
||||
await self._pool.close()
|
||||
self._pool = None
|
||||
logger.info("Disconnected from system_settings database")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check database connectivity."""
|
||||
try:
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.fetchval("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Settings database health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def get(self, key: str, user_scope: str = "global") -> Optional[Any]:
|
||||
"""
|
||||
Get a setting by key with user fallback to global.
|
||||
|
||||
Args:
|
||||
key: Setting key (e.g., "api.openmeteo", "weather.units")
|
||||
user_scope: User identifier or "global"
|
||||
|
||||
Returns:
|
||||
Setting value (parsed from JSONB) or None if not found.
|
||||
User-specific value takes precedence over global.
|
||||
"""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT value FROM settings
|
||||
WHERE key = $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""",
|
||||
key, user_scope
|
||||
)
|
||||
if row:
|
||||
return row["value"]
|
||||
return None
|
||||
|
||||
async def get_with_schema(self, key: str, user_scope: str = "global") -> Optional[dict]:
|
||||
"""
|
||||
Get a setting with its JSON Schema.
|
||||
|
||||
Returns:
|
||||
Dict with "value" and "schema" keys, or None if not found.
|
||||
"""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT value, schema FROM settings
|
||||
WHERE key = $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
""",
|
||||
key, user_scope
|
||||
)
|
||||
if row:
|
||||
return {"value": row["value"], "schema": row["schema"]}
|
||||
return None
|
||||
|
||||
async def get_by_prefix(self, prefix: str, user_scope: str = "global") -> dict[str, Any]:
|
||||
"""
|
||||
Get all settings matching a key prefix.
|
||||
|
||||
Args:
|
||||
prefix: Key prefix (e.g., "api." for all API configs)
|
||||
user_scope: User identifier or "global"
|
||||
|
||||
Returns:
|
||||
Dict mapping keys to values. User-specific values override global.
|
||||
"""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (key) key, value FROM settings
|
||||
WHERE key LIKE $1 AND user_scope IN ($2, 'global')
|
||||
ORDER BY key, CASE WHEN user_scope = $2 THEN 0 ELSE 1 END
|
||||
""",
|
||||
f"{prefix}%", user_scope
|
||||
)
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
async def get_api_config(self, service: str) -> Optional[dict]:
|
||||
"""
|
||||
Get API configuration for a service.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "openmeteo", "nos", "alphavantage")
|
||||
|
||||
Returns:
|
||||
API config dict or None if not found.
|
||||
"""
|
||||
value = await self.get(f"api.{service}")
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return None
|
||||
|
||||
async def get_api_key(self, service: str) -> Optional[str]:
|
||||
"""
|
||||
Get API key for a service if enabled.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "alphavantage")
|
||||
|
||||
Returns:
|
||||
API key string or None if not found or disabled.
|
||||
"""
|
||||
config = await self.get_api_config(service)
|
||||
if config:
|
||||
# Check if explicitly disabled
|
||||
if config.get("enabled") is False:
|
||||
return None
|
||||
return config.get("api_key")
|
||||
return None
|
||||
|
||||
async def is_api_enabled(self, service: str) -> bool:
|
||||
"""
|
||||
Check if an API service is enabled.
|
||||
|
||||
Args:
|
||||
service: Service name (e.g., "alphavantage", "openmeteo")
|
||||
|
||||
Returns:
|
||||
True if enabled (or no explicit setting), False if disabled.
|
||||
"""
|
||||
config = await self.get_api_config(service)
|
||||
if config:
|
||||
# Default to enabled if not specified
|
||||
return config.get("enabled", True)
|
||||
return False # No config means not available
|
||||
|
||||
async def get_user_preference(self, key: str, user: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a user-specific preference.
|
||||
|
||||
Args:
|
||||
key: Preference key (e.g., "weather.units", "news.sources")
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Preference value or None if not set.
|
||||
"""
|
||||
return await self.get(key, user_scope=user)
|
||||
|
||||
async def list_keys(self, user_scope: Optional[str] = None) -> list[str]:
|
||||
"""
|
||||
List all setting keys, optionally filtered by user_scope.
|
||||
|
||||
Args:
|
||||
user_scope: Filter by scope (None for all)
|
||||
|
||||
Returns:
|
||||
List of setting keys.
|
||||
"""
|
||||
await self.connect()
|
||||
async with self._pool.acquire() as conn:
|
||||
if user_scope:
|
||||
rows = await conn.fetch(
|
||||
"SELECT key FROM settings WHERE user_scope = $1 ORDER BY key",
|
||||
user_scope
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT DISTINCT key FROM settings ORDER BY key"
|
||||
)
|
||||
return [row["key"] for row in rows]
|
||||
+113
-120
@@ -20,96 +20,34 @@ class WikiJSClient:
|
||||
Wiki.js GraphQL API client.
|
||||
|
||||
Documentation: https://docs.requarks.io/dev/api
|
||||
Authentication: Username/password login to get user-specific JWT token
|
||||
Authentication: API token (JWT) generated from Wiki.js admin panel
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, username: str, password: str):
|
||||
def __init__(self, base_url: str, api_token: str):
|
||||
"""
|
||||
Initialize Wiki.js client.
|
||||
|
||||
Args:
|
||||
base_url: Wiki.js base URL (e.g., "http://wiki:3000")
|
||||
username: Wiki.js username (e.g., "librarian@schweitz.net")
|
||||
password: Wiki.js password
|
||||
api_token: Wiki.js API token (JWT from admin panel)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.graphql_url = f"{self.base_url}/graphql"
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.jwt_token: Optional[str] = None
|
||||
self.api_token = api_token
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
logger.info(f"Initialized Wiki.js client: {base_url} (user: {username})")
|
||||
auth_mode = "with API token" if api_token else "without auth (open API)"
|
||||
logger.info(f"Initialized Wiki.js client: {base_url} ({auth_mode})")
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""
|
||||
Authenticate with Wiki.js using username/password.
|
||||
|
||||
Returns:
|
||||
True if login successful, False otherwise
|
||||
"""
|
||||
login_mutation = """
|
||||
mutation Login($username: String!, $password: String!, $strategy: String!) {
|
||||
authentication {
|
||||
login(username: $username, password: $password, strategy: $strategy) {
|
||||
responseResult {
|
||||
succeeded
|
||||
errorCode
|
||||
message
|
||||
}
|
||||
jwt
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
variables = {
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"strategy": "local"
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
self.graphql_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"query": login_mutation, "variables": variables}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if "errors" in result:
|
||||
logger.error(f"Login failed: {result['errors']}")
|
||||
return False
|
||||
|
||||
login_result = result.get("data", {}).get("authentication", {}).get("login", {})
|
||||
response_result = login_result.get("responseResult", {})
|
||||
|
||||
if not response_result.get("succeeded"):
|
||||
logger.error(f"Login failed: {response_result.get('message')}")
|
||||
return False
|
||||
|
||||
self.jwt_token = login_result.get("jwt")
|
||||
if not self.jwt_token:
|
||||
logger.error("Login succeeded but no JWT token received")
|
||||
return False
|
||||
|
||||
logger.info(f"Successfully authenticated as {self.username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login failed: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def _ensure_authenticated(self):
|
||||
"""Ensure we have a valid JWT token, login if needed."""
|
||||
if not self.jwt_token:
|
||||
success = await self.login()
|
||||
if not success:
|
||||
raise Exception("Failed to authenticate with Wiki.js")
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers, optionally including auth token."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
return headers
|
||||
|
||||
async def _execute_query(
|
||||
self,
|
||||
@@ -129,18 +67,12 @@ class WikiJSClient:
|
||||
Raises:
|
||||
Exception: If query fails or returns errors
|
||||
"""
|
||||
# Ensure we're authenticated before making requests
|
||||
await self._ensure_authenticated()
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"variables": variables or {}
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
headers = self._get_headers()
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
@@ -164,24 +96,15 @@ class WikiJSClient:
|
||||
logger.error(f"GraphQL query failed: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def list_pages(
|
||||
self,
|
||||
path_prefix: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
async def _fetch_pages(self, limit: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List pages with optional filtering.
|
||||
|
||||
Multi-tenancy: Use path_prefix to filter by user namespace.
|
||||
Fetch a raw page listing from the GraphQL API (no client-side filtering).
|
||||
|
||||
Args:
|
||||
path_prefix: Filter by path prefix (e.g., "/users/jpmschweitzer")
|
||||
tags: Filter by tags (e.g., ["projects"])
|
||||
limit: Maximum results
|
||||
limit: Maximum pages to request from the API
|
||||
|
||||
Returns:
|
||||
List of page objects
|
||||
List of page objects with tags normalized to a list
|
||||
"""
|
||||
query = """
|
||||
query ListPages($limit: Int, $orderBy: PageOrderBy) {
|
||||
@@ -213,7 +136,47 @@ class WikiJSClient:
|
||||
if "tags" not in page or page["tags"] is None:
|
||||
page["tags"] = []
|
||||
|
||||
# Filter by path prefix (client-side if API doesn't support)
|
||||
return pages
|
||||
|
||||
async def _fetch_all_pages(self, initial_limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch ALL pages from the GraphQL API.
|
||||
|
||||
The Wiki.js 2.x `pages.list` query only supports a `limit` argument
|
||||
(no offset - verified via GraphQL introspection). Crucially, the
|
||||
limit is applied BEFORE Wiki.js's own visibility filtering, so a
|
||||
response with fewer pages than requested does NOT mean the listing
|
||||
is complete (observed live: limit=100 -> 43 pages, limit=500 ->
|
||||
140 pages). Exhaustive listing therefore grows the limit until the
|
||||
returned page count stops increasing.
|
||||
|
||||
Args:
|
||||
initial_limit: Page count for the first request
|
||||
|
||||
Returns:
|
||||
Complete list of page objects
|
||||
"""
|
||||
max_limit = 100_000 # Safety cap against pathological growth
|
||||
limit = max(initial_limit, 1)
|
||||
previous_count: Optional[int] = None
|
||||
|
||||
while True:
|
||||
pages = await self._fetch_pages(limit)
|
||||
# Complete when a grown limit yields no new pages (fixed point)
|
||||
if previous_count is not None and len(pages) == previous_count:
|
||||
return pages
|
||||
if limit >= max_limit:
|
||||
return pages
|
||||
previous_count = len(pages)
|
||||
limit = min(limit * 2, max_limit)
|
||||
|
||||
@staticmethod
|
||||
def _filter_pages(
|
||||
pages: List[Dict[str, Any]],
|
||||
path_prefix: str = "",
|
||||
tags: Optional[List[str]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Apply client-side path-prefix and tag filters to a page listing."""
|
||||
if path_prefix:
|
||||
# Normalize paths to have leading slash for consistent comparison
|
||||
normalized_prefix = "/" + path_prefix.lstrip("/")
|
||||
@@ -231,6 +194,38 @@ class WikiJSClient:
|
||||
|
||||
return pages
|
||||
|
||||
async def list_pages(
|
||||
self,
|
||||
path_prefix: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
limit: int = 50
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List pages with optional filtering.
|
||||
|
||||
Multi-tenancy: Use path_prefix to filter by user namespace.
|
||||
|
||||
Args:
|
||||
path_prefix: Filter by path prefix (e.g., "/users/jpmschweitzer")
|
||||
tags: Filter by tags (e.g., ["projects"])
|
||||
limit: Maximum results (applied AFTER filtering)
|
||||
|
||||
Returns:
|
||||
List of page objects
|
||||
"""
|
||||
if path_prefix or tags:
|
||||
# The API limit applies before our client-side filters, so a
|
||||
# small limit would drop matching pages that sort late. Fetch
|
||||
# everything, filter, then apply the limit.
|
||||
pages = self._filter_pages(
|
||||
await self._fetch_all_pages(),
|
||||
path_prefix=path_prefix,
|
||||
tags=tags
|
||||
)
|
||||
return pages[:limit]
|
||||
|
||||
return await self._fetch_pages(limit)
|
||||
|
||||
async def list_all_pages(
|
||||
self,
|
||||
path_prefix: str = "",
|
||||
@@ -240,34 +235,22 @@ class WikiJSClient:
|
||||
"""
|
||||
List ALL pages with pagination support.
|
||||
|
||||
Fetches pages in batches until all are retrieved.
|
||||
Fetches pages in growing batches until all are retrieved (Wiki.js
|
||||
`pages.list` has no offset argument), then applies filters.
|
||||
|
||||
Args:
|
||||
path_prefix: Filter by path prefix (e.g., "users/jpmschweitzer")
|
||||
tags: Filter by tags
|
||||
batch_size: Number of pages per batch (max 100)
|
||||
batch_size: Page count for the first request
|
||||
|
||||
Returns:
|
||||
Complete list of page objects
|
||||
"""
|
||||
all_pages = []
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
# Wiki.js list doesn't support offset, but limit is enough
|
||||
# since we filter client-side by path_prefix
|
||||
# Just fetch a large batch
|
||||
pages = await self.list_pages(
|
||||
path_prefix=path_prefix,
|
||||
tags=tags,
|
||||
limit=1000 # Fetch up to 1000 at once
|
||||
)
|
||||
|
||||
if not pages:
|
||||
break
|
||||
|
||||
all_pages = pages
|
||||
break # Wiki.js list doesn't paginate, so one call is enough
|
||||
all_pages = self._filter_pages(
|
||||
await self._fetch_all_pages(initial_limit=batch_size),
|
||||
path_prefix=path_prefix,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
logger.info(f"list_all_pages: found {len(all_pages)} pages (prefix: {path_prefix or 'all'})")
|
||||
return all_pages
|
||||
@@ -517,15 +500,21 @@ class WikiJSClient:
|
||||
}
|
||||
"""
|
||||
|
||||
variables = {"id": page_id}
|
||||
# Wiki.js 2.x requires `tags` on the update mutation (the server
|
||||
# unconditionally maps over it; omitting it fails with "Cannot read
|
||||
# properties of undefined (reading 'map')"). Preserve the page's
|
||||
# current tags when the caller does not supply any.
|
||||
if tags is None:
|
||||
current = await self.get_page(page_id)
|
||||
tags = (current or {}).get("tags") or []
|
||||
|
||||
variables = {"id": page_id, "tags": tags}
|
||||
if content is not None:
|
||||
variables["content"] = content
|
||||
if title is not None:
|
||||
variables["title"] = title
|
||||
if description is not None:
|
||||
variables["description"] = description
|
||||
if tags is not None:
|
||||
variables["tags"] = tags
|
||||
if is_published is not None:
|
||||
variables["isPublished"] = is_published
|
||||
|
||||
@@ -605,9 +594,13 @@ class WikiJSClient:
|
||||
data = await self._execute_query(gql_query, {"query": query})
|
||||
results = data.get("pages", {}).get("search", {}).get("results", [])
|
||||
|
||||
# Filter by path prefix if provided
|
||||
# Filter by path prefix if provided. Wiki.js returns paths WITHOUT a
|
||||
# leading slash while get_wikijs_namespace() produces one WITH it, so
|
||||
# compare slash-normalized (the mismatch made this filter reject every
|
||||
# result, returning an empty search for every tenant).
|
||||
if path_prefix:
|
||||
results = [r for r in results if r["path"].startswith(path_prefix)]
|
||||
prefix = path_prefix.lstrip("/")
|
||||
results = [r for r in results if r["path"].lstrip("/").startswith(prefix)]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
+79
-5
@@ -40,11 +40,14 @@ class Settings(BaseSettings):
|
||||
# Qdrant Configuration
|
||||
qdrant_host: str = Field(default="qdrant", description="Qdrant host")
|
||||
qdrant_port: int = Field(default=6333, description="Qdrant port")
|
||||
qdrant_timeout: int = Field(default=30, ge=1, le=300, description="Qdrant client timeout in seconds")
|
||||
|
||||
# Wiki.js Configuration
|
||||
wikijs_url: str = Field(default="http://wiki:3000", description="Wiki.js URL")
|
||||
wikijs_username: str = Field(..., description="Wiki.js username")
|
||||
wikijs_password: str = Field(..., description="Wiki.js password")
|
||||
wiki_graphql_api: str = Field(default="", description="Wiki.js GraphQL API token (optional - API may be open)")
|
||||
# Legacy auth fields - kept for backwards compatibility but deprecated
|
||||
wikijs_username: str = Field(default="", description="Wiki.js username (deprecated, use wiki_graphql_api)")
|
||||
wikijs_password: str = Field(default="", description="Wiki.js password (deprecated, use wiki_graphql_api)")
|
||||
|
||||
# Wiki.js Database Configuration (for change listener)
|
||||
wikijs_db_host: str = Field(default="postgres-shared", description="Wiki.js PostgreSQL host")
|
||||
@@ -62,16 +65,19 @@ class Settings(BaseSettings):
|
||||
# SearXNG Configuration
|
||||
searxng_url: str = Field(default="http://searxng:8080", description="SearXNG URL")
|
||||
|
||||
# Ollama Configuration (for embeddings)
|
||||
# Ollama Configuration
|
||||
ollama_url: str = Field(default="http://ollama:11434", description="Ollama URL")
|
||||
ollama_model: str = Field(default="nomic-embed-text", description="Ollama embedding model")
|
||||
# Named ollama_llm_model (env: OLLAMA_LLM_MODEL) to avoid collision with the
|
||||
# OLLAMA_MODEL container env var, which is used for the embedding model.
|
||||
ollama_llm_model: str = Field(default="gemma4:e2b", description="Ollama LLM model for generation (keyword extraction, re-ranking, consolidation)")
|
||||
ollama_embedding_model: str = Field(default="nomic-embed-text", description="Ollama embedding model")
|
||||
|
||||
# HybridRAG Configuration
|
||||
reranker_model: str = Field(default="mistral-nemo", description="Model for LLM re-ranking")
|
||||
reranker_enabled: bool = Field(default=True, description="Enable LLM re-ranking")
|
||||
hybrid_rag_vector_limit: int = Field(default=10, ge=1, le=50, description="Vector search limit")
|
||||
hybrid_rag_graph_limit: int = Field(default=10, ge=1, le=50, description="Graph search limit")
|
||||
hybrid_rag_web_limit: int = Field(default=5, ge=1, le=20, description="Web search limit")
|
||||
vector_similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0, description="Minimum similarity score for vector results")
|
||||
|
||||
# Entity Linking Fuzzy Matching Configuration
|
||||
entity_linking_min_confidence: float = Field(default=0.70, ge=0.0, le=1.0, description="Minimum confidence for entity-document matching")
|
||||
@@ -89,6 +95,64 @@ class Settings(BaseSettings):
|
||||
app_version: str = Field(default=__version__, description="Application version")
|
||||
debug: bool = Field(default=False, description="Debug mode")
|
||||
|
||||
# CORS: comma-separated list of allowed browser origins. The default "*"
|
||||
# is only acceptable because allow_credentials is disabled (see main.py).
|
||||
cors_allow_origins: str = Field(
|
||||
default="*",
|
||||
description="Comma-separated CORS allowed origins (credentials are never allowed)"
|
||||
)
|
||||
|
||||
@property
|
||||
def cors_allow_origins_list(self) -> list[str]:
|
||||
"""cors_allow_origins parsed into a list for CORSMiddleware."""
|
||||
return [o.strip() for o in self.cors_allow_origins.split(",") if o.strip()]
|
||||
|
||||
# RAG Search Configuration
|
||||
search_cache_ttl: int = Field(default=300, ge=0, le=3600, description="Search cache TTL in seconds")
|
||||
search_timeout: int = Field(default=10, ge=1, le=60, description="SearXNG timeout in seconds")
|
||||
search_default_limit: int = Field(default=10, ge=1, le=20, description="Default number of search results")
|
||||
|
||||
# Content Extraction Configuration
|
||||
content_extraction_timeout: int = Field(default=5, ge=1, le=30, description="Trafilatura per-URL timeout in seconds")
|
||||
content_max_length: int = Field(default=2000, ge=500, le=10000, description="Max extracted content length per result")
|
||||
|
||||
# Paperless-ngx Configuration
|
||||
paperless_url: str = Field(default="http://paperless:8000", description="Paperless-ngx URL")
|
||||
paperless_token: str = Field(default="", description="Paperless-ngx API token")
|
||||
paperless_timeout: int = Field(default=30, ge=5, le=120, description="Paperless API timeout in seconds")
|
||||
|
||||
# Document Store Configuration
|
||||
document_store_enabled: bool = Field(default=True, description="Enable document store feature")
|
||||
document_catalog_path_prefix: str = Field(default="docs", description="Wiki path prefix for catalog pages")
|
||||
|
||||
# Volatile Cache Configuration
|
||||
volatile_cache_enabled: bool = Field(default=True, description="Enable volatile cache feature")
|
||||
volatile_default_ttl: int = Field(default=3600, ge=60, le=86400, description="Default TTL in seconds")
|
||||
volatile_weather_ttl: int = Field(default=1800, ge=60, le=7200, description="Weather data TTL in seconds")
|
||||
volatile_news_ttl: int = Field(default=7200, ge=300, le=86400, description="News data TTL in seconds")
|
||||
volatile_financial_ttl: int = Field(default=300, ge=60, le=3600, description="Financial data TTL in seconds")
|
||||
|
||||
# Maintenance Configuration
|
||||
maintenance_orphan_cleanup_enabled: bool = Field(default=True, description="Enable automatic orphan cleanup")
|
||||
maintenance_cleanup_batch_size: int = Field(default=100, ge=10, le=1000, description="Cleanup batch size")
|
||||
|
||||
# Central Settings Database (Tatlock-wide)
|
||||
system_settings_host: str = Field(default="postgres-shared", description="System settings PostgreSQL host")
|
||||
system_settings_port: int = Field(default=5432, description="System settings PostgreSQL port")
|
||||
system_settings_db: str = Field(default="system_settings", description="System settings database name")
|
||||
system_settings_user: str = Field(default="settings", description="System settings database user")
|
||||
system_settings_password: str = Field(default="", description="System settings database password")
|
||||
|
||||
# Scheduler Service
|
||||
scheduler_url: str = Field(default="http://scheduler:8090", description="Scheduler service URL")
|
||||
scheduler_api_key: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Bearer key for the Scheduler's auth-guarded task-management API; "
|
||||
"required for runtime prefetch task registration"
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def qdrant_url(self) -> str:
|
||||
"""Computed Qdrant URL."""
|
||||
@@ -99,6 +163,16 @@ class Settings(BaseSettings):
|
||||
"""Computed Redis URL."""
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def system_settings_dsn(self) -> str:
|
||||
"""Computed System Settings PostgreSQL DSN."""
|
||||
if not self.system_settings_password:
|
||||
return ""
|
||||
return (
|
||||
f"postgresql://{self.system_settings_user}:{self.system_settings_password}"
|
||||
f"@{self.system_settings_host}:{self.system_settings_port}/{self.system_settings_db}"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
+480
-22
@@ -10,15 +10,26 @@ Provides FastAPI dependencies for service clients with:
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Annotated
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from src.config import Settings, get_settings
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.clients.content_extractor import ContentExtractor
|
||||
from src.clients.paperless_client import PaperlessClient
|
||||
from src.clients.settings_client import SettingsClient
|
||||
from src.clients.scheduler_client import SchedulerClient
|
||||
from src.apis import (
|
||||
OpenMeteoProvider,
|
||||
AggregatedNewsProvider,
|
||||
AlphaVantageProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +38,35 @@ logger = logging.getLogger(__name__)
|
||||
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
|
||||
# Tenant user dependency
|
||||
def require_user(
|
||||
user: str = Query(
|
||||
...,
|
||||
description=(
|
||||
"User identifier (tenant). Required — every operation is scoped to "
|
||||
"this tenant's namespace (Qdrant collection, Neo4j labels, wiki path, "
|
||||
"Redis keys). Requests without an explicit non-empty user are "
|
||||
"rejected with 422. There is no default tenant."
|
||||
),
|
||||
)
|
||||
) -> str:
|
||||
"""
|
||||
FastAPI dependency: required tenant user query parameter.
|
||||
|
||||
Rejects missing (FastAPI returns 422 automatically), empty, and
|
||||
whitespace-only user values. Use via the RequiredUserQuery alias.
|
||||
"""
|
||||
from src.core.multi_tenancy import validate_required_user
|
||||
|
||||
try:
|
||||
return validate_required_user(user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
RequiredUserQuery = Annotated[str, Depends(require_user)]
|
||||
|
||||
|
||||
# Client factory functions with @lru_cache for singletons
|
||||
@lru_cache
|
||||
def get_neo4j_client() -> Neo4jClient:
|
||||
@@ -61,7 +101,8 @@ def get_qdrant_client() -> QdrantClientWrapper:
|
||||
settings = get_settings()
|
||||
client = QdrantClientWrapper(
|
||||
url=settings.qdrant_url,
|
||||
embedding_dim=768 # nomic-embed-text default
|
||||
embedding_dim=768, # nomic-embed-text default
|
||||
timeout=settings.qdrant_timeout
|
||||
)
|
||||
logger.debug("Created Qdrant client instance")
|
||||
return client
|
||||
@@ -73,13 +114,12 @@ def get_wikijs_client() -> WikiJSClient:
|
||||
Get Wiki.js client singleton.
|
||||
|
||||
Returns:
|
||||
Initialized Wiki.js GraphQL client with username/password auth
|
||||
Initialized Wiki.js GraphQL client with API token auth
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
username=settings.wikijs_username,
|
||||
password=settings.wikijs_password
|
||||
api_token=settings.wiki_graphql_api
|
||||
)
|
||||
logger.debug("Created Wiki.js client instance")
|
||||
return client
|
||||
@@ -110,12 +150,230 @@ def get_ollama_client() -> OllamaClient:
|
||||
settings = get_settings()
|
||||
client = OllamaClient(
|
||||
base_url=settings.ollama_url,
|
||||
model=settings.ollama_model
|
||||
model=settings.ollama_embedding_model
|
||||
)
|
||||
logger.debug("Created Ollama client instance")
|
||||
return client
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_redis_client() -> aioredis.Redis:
|
||||
"""
|
||||
Get Redis client singleton for caching.
|
||||
|
||||
Returns:
|
||||
Async Redis client connected to the configured database
|
||||
|
||||
Note: Uses Redis DB 4 (configured for library-desk)
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = aioredis.from_url(
|
||||
settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True
|
||||
)
|
||||
logger.debug(f"Created Redis client: {settings.redis_url}")
|
||||
return client
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_job_manager() -> "JobManager":
|
||||
"""
|
||||
Get Redis-backed JobManager singleton.
|
||||
|
||||
Returns:
|
||||
JobManager for background job tracking (connects lazily)
|
||||
"""
|
||||
from src.jobs.job_manager import JobManager
|
||||
settings = get_settings()
|
||||
manager = JobManager(redis_url=settings.redis_url)
|
||||
logger.debug(f"Created JobManager: {settings.redis_url}")
|
||||
return manager
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_content_extractor() -> ContentExtractor:
|
||||
"""
|
||||
Get ContentExtractor singleton.
|
||||
|
||||
Returns:
|
||||
Initialized content extraction client using Trafilatura
|
||||
"""
|
||||
settings = get_settings()
|
||||
extractor = ContentExtractor(
|
||||
timeout=settings.content_extraction_timeout,
|
||||
max_length=settings.content_max_length
|
||||
)
|
||||
logger.debug("Created ContentExtractor instance")
|
||||
return extractor
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_paperless_client() -> PaperlessClient:
|
||||
"""
|
||||
Get Paperless-ngx client singleton.
|
||||
|
||||
Returns:
|
||||
Initialized Paperless-ngx REST API client
|
||||
|
||||
Note: Returns None-like client if paperless_token is not configured
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.paperless_token:
|
||||
logger.warning("Paperless token not configured - document storage disabled")
|
||||
client = PaperlessClient(
|
||||
base_url=settings.paperless_url,
|
||||
token=settings.paperless_token,
|
||||
timeout=settings.paperless_timeout
|
||||
)
|
||||
logger.debug(f"Created Paperless client: {settings.paperless_url}")
|
||||
return client
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings_client() -> SettingsClient:
|
||||
"""
|
||||
Get central settings database client singleton.
|
||||
|
||||
Returns:
|
||||
Initialized SettingsClient for Tatlock system_settings database
|
||||
|
||||
Note: Returns client with empty DSN if password not configured
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.system_settings_password:
|
||||
logger.warning("System settings password not configured - settings database disabled")
|
||||
client = SettingsClient(dsn=settings.system_settings_dsn)
|
||||
logger.debug(f"Created Settings client: {settings.system_settings_host}")
|
||||
return client
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_scheduler_client() -> SchedulerClient:
|
||||
"""
|
||||
Get scheduler service client singleton.
|
||||
|
||||
Returns:
|
||||
Initialized SchedulerClient for task management
|
||||
|
||||
Note: Used for registering prefetch tasks discovered during HybridRAG searches
|
||||
"""
|
||||
settings = get_settings()
|
||||
client = SchedulerClient(
|
||||
base_url=settings.scheduler_url,
|
||||
api_key=settings.scheduler_api_key,
|
||||
)
|
||||
logger.debug(f"Created Scheduler client: {settings.scheduler_url}")
|
||||
return client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# External API Providers
|
||||
# =============================================================================
|
||||
|
||||
@lru_cache
|
||||
def get_weather_provider() -> OpenMeteoProvider:
|
||||
"""
|
||||
Get Open-Meteo weather provider singleton.
|
||||
|
||||
Returns:
|
||||
Initialized OpenMeteoProvider with default timezone
|
||||
|
||||
Note: Timezone can be overridden per-request for user preferences
|
||||
"""
|
||||
provider = OpenMeteoProvider(timezone="Europe/Amsterdam")
|
||||
logger.debug("Created OpenMeteo weather provider")
|
||||
return provider
|
||||
|
||||
|
||||
# News provider requires sources from settings database
|
||||
_news_provider: AggregatedNewsProvider | None = None
|
||||
|
||||
|
||||
async def get_news_provider() -> AggregatedNewsProvider:
|
||||
"""
|
||||
Get aggregated news provider.
|
||||
|
||||
Returns:
|
||||
Initialized AggregatedNewsProvider with user-configured sources
|
||||
and per-source category filters.
|
||||
|
||||
Note: Configuration is fetched from system_settings database:
|
||||
- news.sources: list of enabled sources (default: ["nos", "bbc"])
|
||||
- api.{source}.categories: list of enabled categories per source
|
||||
"""
|
||||
global _news_provider
|
||||
if _news_provider is not None:
|
||||
return _news_provider
|
||||
|
||||
settings_client = get_settings_client()
|
||||
|
||||
# Get enabled sources
|
||||
sources = await settings_client.get("news.sources")
|
||||
if not sources or not isinstance(sources, list):
|
||||
sources = ["nos", "bbc"]
|
||||
logger.info(f"Using default news sources: {sources}")
|
||||
else:
|
||||
logger.info(f"Using configured news sources: {sources}")
|
||||
|
||||
# Filter out disabled sources and get category filters
|
||||
enabled_sources: list[str] = []
|
||||
category_filters: dict[str, list[str]] = {}
|
||||
|
||||
for source in sources:
|
||||
config = await settings_client.get_api_config(source)
|
||||
if config:
|
||||
# Check if source is disabled
|
||||
if config.get("enabled") is False:
|
||||
logger.info(f"News source '{source}' is disabled - skipping")
|
||||
continue
|
||||
# Get category filter if specified
|
||||
categories = config.get("categories", [])
|
||||
if categories:
|
||||
category_filters[source] = categories
|
||||
logger.debug(f"Source '{source}' categories: {categories}")
|
||||
enabled_sources.append(source)
|
||||
|
||||
if not enabled_sources:
|
||||
enabled_sources = ["nos", "bbc"]
|
||||
logger.warning("No enabled news sources - using defaults")
|
||||
|
||||
_news_provider = AggregatedNewsProvider(
|
||||
sources=enabled_sources,
|
||||
category_filters=category_filters
|
||||
)
|
||||
return _news_provider
|
||||
|
||||
|
||||
# AlphaVantage requires API key from settings database
|
||||
_alphavantage_provider: AlphaVantageProvider | None = None
|
||||
|
||||
|
||||
async def get_alphavantage_provider() -> AlphaVantageProvider | None:
|
||||
"""
|
||||
Get Alpha Vantage financial provider.
|
||||
|
||||
Returns:
|
||||
Initialized AlphaVantageProvider or None if API key not configured
|
||||
|
||||
Note: API key is fetched from system_settings database
|
||||
"""
|
||||
global _alphavantage_provider
|
||||
if _alphavantage_provider is not None:
|
||||
return _alphavantage_provider
|
||||
|
||||
settings_client = get_settings_client()
|
||||
api_key = await settings_client.get_api_key("alphavantage")
|
||||
|
||||
if not api_key:
|
||||
logger.warning("Alpha Vantage API key not configured - financial provider disabled")
|
||||
return None
|
||||
|
||||
_alphavantage_provider = AlphaVantageProvider(api_key=api_key)
|
||||
logger.debug("Created Alpha Vantage financial provider")
|
||||
return _alphavantage_provider
|
||||
|
||||
|
||||
# Type aliases for FastAPI endpoint dependencies
|
||||
# Usage: def my_endpoint(neo4j: Neo4jDep):
|
||||
Neo4jDep = Annotated[Neo4jClient, Depends(get_neo4j_client)]
|
||||
@@ -123,6 +381,19 @@ QdrantDep = Annotated[QdrantClientWrapper, Depends(get_qdrant_client)]
|
||||
WikiJSDep = Annotated[WikiJSClient, Depends(get_wikijs_client)]
|
||||
SearXNGDep = Annotated[SearXNGClient, Depends(get_searxng_client)]
|
||||
OllamaDep = Annotated[OllamaClient, Depends(get_ollama_client)]
|
||||
RedisDep = Annotated[aioredis.Redis, Depends(get_redis_client)]
|
||||
ContentExtractorDep = Annotated[ContentExtractor, Depends(get_content_extractor)]
|
||||
PaperlessDep = Annotated[PaperlessClient, Depends(get_paperless_client)]
|
||||
SettingsClientDep = Annotated[SettingsClient, Depends(get_settings_client)]
|
||||
SchedulerDep = Annotated[SchedulerClient, Depends(get_scheduler_client)]
|
||||
|
||||
from src.jobs.job_manager import JobManager # noqa: E402
|
||||
JobManagerDep = Annotated[JobManager, Depends(get_job_manager)]
|
||||
|
||||
# External API provider dependencies
|
||||
WeatherProviderDep = Annotated[OpenMeteoProvider, Depends(get_weather_provider)]
|
||||
NewsProviderDep = Annotated[AggregatedNewsProvider, Depends(get_news_provider)]
|
||||
AlphaVantageProviderDep = Annotated[AlphaVantageProvider | None, Depends(get_alphavantage_provider)]
|
||||
|
||||
|
||||
# Lifecycle management functions
|
||||
@@ -161,6 +432,46 @@ async def startup_clients():
|
||||
logger.error(f"✗ Ollama health check failed: {e}")
|
||||
pass
|
||||
|
||||
# Check Paperless availability
|
||||
settings = get_settings()
|
||||
if settings.paperless_token:
|
||||
try:
|
||||
paperless = get_paperless_client()
|
||||
is_healthy = await paperless.health_check()
|
||||
if is_healthy:
|
||||
logger.info(f"✓ Paperless-ngx ready: {settings.paperless_url}")
|
||||
else:
|
||||
logger.warning("✗ Paperless-ngx not responding")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Paperless health check failed: {e}")
|
||||
else:
|
||||
logger.info("○ Paperless-ngx not configured (document storage disabled)")
|
||||
|
||||
# Check System Settings database availability
|
||||
if settings.system_settings_password:
|
||||
try:
|
||||
settings_client = get_settings_client()
|
||||
is_healthy = await settings_client.health_check()
|
||||
if is_healthy:
|
||||
logger.info(f"✓ System settings DB ready: {settings.system_settings_host}")
|
||||
else:
|
||||
logger.warning("✗ System settings DB not responding")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ System settings health check failed: {e}")
|
||||
else:
|
||||
logger.info("○ System settings not configured")
|
||||
|
||||
# Check Scheduler availability
|
||||
try:
|
||||
scheduler = get_scheduler_client()
|
||||
is_healthy = await scheduler.health_check()
|
||||
if is_healthy:
|
||||
logger.info(f"✓ Scheduler ready: {settings.scheduler_url}")
|
||||
else:
|
||||
logger.warning("✗ Scheduler not responding")
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Scheduler health check failed: {e}")
|
||||
|
||||
# Qdrant, Wiki.js, SearXNG are lazy-initialized
|
||||
logger.info("Service clients startup complete")
|
||||
|
||||
@@ -189,7 +500,9 @@ async def shutdown_clients():
|
||||
clients_to_close = [
|
||||
("Wiki.js", get_wikijs_client()),
|
||||
("SearXNG", get_searxng_client()),
|
||||
("Ollama", get_ollama_client())
|
||||
("Ollama", get_ollama_client()),
|
||||
("Paperless", get_paperless_client()),
|
||||
("OpenMeteo", get_weather_provider()),
|
||||
]
|
||||
|
||||
for name, client in clients_to_close:
|
||||
@@ -199,6 +512,51 @@ async def shutdown_clients():
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing {name} client: {e}")
|
||||
|
||||
# Close async-initialized providers
|
||||
global _news_provider, _alphavantage_provider
|
||||
|
||||
if _news_provider is not None:
|
||||
try:
|
||||
await _news_provider.close()
|
||||
_news_provider = None
|
||||
logger.info("✓ News provider closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing News provider: {e}")
|
||||
|
||||
if _alphavantage_provider is not None:
|
||||
try:
|
||||
await _alphavantage_provider.close()
|
||||
_alphavantage_provider = None
|
||||
logger.info("✓ AlphaVantage client closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing AlphaVantage client: {e}")
|
||||
|
||||
# Close settings database connection
|
||||
settings = get_settings()
|
||||
if settings.system_settings_password:
|
||||
try:
|
||||
settings_client = get_settings_client()
|
||||
await settings_client.close()
|
||||
logger.info("✓ System settings client closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing settings client: {e}")
|
||||
|
||||
# Close scheduler client
|
||||
try:
|
||||
scheduler = get_scheduler_client()
|
||||
await scheduler.close()
|
||||
logger.info("✓ Scheduler client closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing scheduler client: {e}")
|
||||
|
||||
# Close job manager Redis connection
|
||||
try:
|
||||
job_manager = get_job_manager()
|
||||
await job_manager.close()
|
||||
logger.info("✓ JobManager closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing JobManager: {e}")
|
||||
|
||||
logger.info("Service clients shutdown complete")
|
||||
|
||||
|
||||
@@ -237,7 +595,7 @@ async def check_service_health() -> dict:
|
||||
try:
|
||||
qdrant = get_qdrant_client()
|
||||
# Check if we can list collections
|
||||
collections = qdrant.client.get_collections()
|
||||
await qdrant.client.get_collections()
|
||||
health["qdrant"] = True
|
||||
except Exception as e:
|
||||
logger.error(f"Qdrant health check failed: {e}")
|
||||
@@ -271,6 +629,37 @@ async def check_service_health() -> dict:
|
||||
logger.error(f"Ollama health check failed: {e}")
|
||||
health["ollama"] = False
|
||||
|
||||
# Paperless-ngx
|
||||
settings = get_settings()
|
||||
if settings.paperless_token:
|
||||
try:
|
||||
paperless = get_paperless_client()
|
||||
health["paperless"] = await paperless.health_check()
|
||||
except Exception as e:
|
||||
logger.error(f"Paperless health check failed: {e}")
|
||||
health["paperless"] = False
|
||||
else:
|
||||
health["paperless"] = None # Not configured
|
||||
|
||||
# System Settings database
|
||||
if settings.system_settings_password:
|
||||
try:
|
||||
settings_client = get_settings_client()
|
||||
health["system_settings"] = await settings_client.health_check()
|
||||
except Exception as e:
|
||||
logger.error(f"System settings health check failed: {e}")
|
||||
health["system_settings"] = False
|
||||
else:
|
||||
health["system_settings"] = None # Not configured
|
||||
|
||||
# Scheduler
|
||||
try:
|
||||
scheduler = get_scheduler_client()
|
||||
health["scheduler"] = await scheduler.health_check()
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler health check failed: {e}")
|
||||
health["scheduler"] = False
|
||||
|
||||
return health
|
||||
|
||||
|
||||
@@ -312,7 +701,10 @@ def get_consolidation_service() -> "ConsolidationService":
|
||||
ollama=get_ollama_client(),
|
||||
wiki=get_wikijs_client(),
|
||||
settings=get_settings(),
|
||||
ingestion_service=get_ingestion_service()
|
||||
ingestion_service=get_ingestion_service(),
|
||||
volatile_service=get_volatile_cache_service(),
|
||||
settings_client=get_settings_client(),
|
||||
scheduler_client=get_scheduler_client(),
|
||||
)
|
||||
|
||||
|
||||
@@ -329,31 +721,52 @@ def get_ingestion_service() -> "IngestionService":
|
||||
|
||||
@lru_cache
|
||||
def get_hybrid_rag_service() -> "HybridRAGService":
|
||||
"""Get HybridRAGService singleton."""
|
||||
"""
|
||||
Get HybridRAGService singleton.
|
||||
|
||||
The single wiring point for HybridRAG — routers must depend on this
|
||||
instead of constructing their own instance (previous inline copies in
|
||||
the /query/hybrid and /wiki/smart-create routers diverged on
|
||||
volatile_service).
|
||||
"""
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
return HybridRAGService(
|
||||
vector_service=get_vector_service(),
|
||||
graph_service=get_graph_service(),
|
||||
searxng_client=get_searxng_client(),
|
||||
ollama_client=get_ollama_client(),
|
||||
content_extractor=get_content_extractor(),
|
||||
settings=get_settings(),
|
||||
volatile_service=get_volatile_cache_service()
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_rag_search_service() -> "RAGSearchService":
|
||||
"""Get RAGSearchService singleton."""
|
||||
from src.services.rag_search_service import RAGSearchService
|
||||
return RAGSearchService(
|
||||
searxng_client=get_searxng_client(),
|
||||
content_extractor=get_content_extractor(),
|
||||
redis_client=get_redis_client(),
|
||||
settings=get_settings()
|
||||
)
|
||||
|
||||
|
||||
# Utility: Get default user from settings or multi_tenancy
|
||||
def get_default_user() -> str:
|
||||
"""
|
||||
Get default user for operations.
|
||||
|
||||
Returns:
|
||||
Default user identifier
|
||||
"""
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
return DEFAULT_USER
|
||||
@lru_cache
|
||||
def get_volatile_cache_service() -> "VolatileCacheService":
|
||||
"""Get VolatileCacheService singleton."""
|
||||
from src.services.volatile_service import VolatileCacheService
|
||||
return VolatileCacheService(
|
||||
qdrant_client=get_qdrant_client(),
|
||||
ollama_client=get_ollama_client(),
|
||||
settings=get_settings()
|
||||
)
|
||||
|
||||
|
||||
# Authentication
|
||||
from fastapi import Security, HTTPException
|
||||
import secrets
|
||||
from fastapi import Security, HTTPException, Request
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
security = HTTPBearer()
|
||||
@@ -376,9 +789,54 @@ async def verify_api_key(
|
||||
Raises:
|
||||
HTTPException: If API key is invalid
|
||||
"""
|
||||
if credentials.credentials != settings.library_api_key:
|
||||
if not secrets.compare_digest(credentials.credentials, settings.library_api_key):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid API key"
|
||||
)
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
# Header set by NPM only on the authenticated /library-desk/ proxy location.
|
||||
# library-desk is bound to loopback (127.0.0.1:8089), so NPM is the only path
|
||||
# that can reach it and set this header — a client cannot forge it. NPM also
|
||||
# overwrites any client-supplied value via proxy_set_header.
|
||||
_PROXY_MARKER_HEADER = "x-library-desk-proxy"
|
||||
|
||||
|
||||
async def verify_browser_request(
|
||||
request: Request,
|
||||
settings: SettingsDep,
|
||||
) -> str:
|
||||
"""
|
||||
Auth for browser-facing endpoints (the Wiki.js integration buttons).
|
||||
|
||||
Accepts the request when it arrives through the authenticated NPM proxy
|
||||
location (Authentik session for external users, or the LAN bypass for
|
||||
internal ones) — identified by the trusted proxy marker header. No secret
|
||||
is carried in the browser. Machine callers may still authenticate with the
|
||||
Bearer API key. Returns the acting user's identity.
|
||||
"""
|
||||
if request.headers.get(_PROXY_MARKER_HEADER) == "1":
|
||||
# Authentik injects the identity for externally-authenticated users;
|
||||
# on the LAN bypass these are empty and the endpoint falls back to the
|
||||
# user supplied in the request body.
|
||||
return request.headers.get("x-authentik-email") or "lan"
|
||||
|
||||
# Fallback: server-to-server Bearer API key.
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth.startswith("Bearer ") and secrets.compare_digest(
|
||||
auth[len("Bearer "):], settings.library_api_key
|
||||
):
|
||||
return auth[len("Bearer "):]
|
||||
|
||||
raise HTTPException(status_code=401, detail="Unauthenticated")
|
||||
|
||||
|
||||
# Service type aliases for FastAPI endpoint dependencies
|
||||
# These are defined after the factory functions
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
VectorServiceDep = Annotated[VectorService, Depends(get_vector_service)]
|
||||
GraphServiceDep = Annotated[GraphService, Depends(get_graph_service)]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Content hashing helpers.
|
||||
|
||||
A single canonical hash implementation is used everywhere page content is
|
||||
fingerprinted (Document nodes at ingestion time, /ingest/check-updates
|
||||
comparisons) so hashes computed at different times are comparable.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def compute_content_hash(content: str) -> str:
|
||||
"""
|
||||
Compute the canonical content hash for wiki page content.
|
||||
|
||||
Args:
|
||||
content: Raw page content (markdown). None-safe: treated as "".
|
||||
|
||||
Returns:
|
||||
Hex-encoded SHA-256 digest of the UTF-8 encoded content.
|
||||
|
||||
Examples:
|
||||
>>> compute_content_hash("hello")
|
||||
'2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
|
||||
"""
|
||||
return hashlib.sha256((content or "").encode("utf-8")).hexdigest()
|
||||
@@ -8,9 +8,9 @@ Provides utilities for user namespace management across:
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
# Default user for all operations
|
||||
DEFAULT_USER = "jpmschweitzer"
|
||||
from pydantic import AfterValidator
|
||||
|
||||
|
||||
def sanitize_user_id(user_id: str) -> str:
|
||||
@@ -186,6 +186,45 @@ def validate_user_id(user_id: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def validate_required_user(user: str) -> str:
|
||||
"""
|
||||
Validate that a tenant user identifier is present and usable.
|
||||
|
||||
There is NO default tenant: every operation that touches tenant data
|
||||
must receive an explicit user. Empty or whitespace-only values are
|
||||
rejected, as are values that fail :func:`validate_user_id`.
|
||||
|
||||
Args:
|
||||
user: Raw user identifier from a request
|
||||
|
||||
Returns:
|
||||
The stripped user identifier
|
||||
|
||||
Raises:
|
||||
ValueError: If the user is missing, blank, or invalid
|
||||
|
||||
Examples:
|
||||
>>> validate_required_user("llm_tester")
|
||||
'llm_tester'
|
||||
>>> validate_required_user(" ")
|
||||
Traceback (most recent call last):
|
||||
...
|
||||
ValueError: user is required and must be a non-empty, non-whitespace string
|
||||
"""
|
||||
if user is None or not str(user).strip():
|
||||
raise ValueError(
|
||||
"user is required and must be a non-empty, non-whitespace string"
|
||||
)
|
||||
stripped = str(user).strip()
|
||||
if not validate_user_id(stripped):
|
||||
raise ValueError(f"Invalid user identifier: {user!r}")
|
||||
return stripped
|
||||
|
||||
|
||||
# Pydantic annotated type for request models: a required, validated tenant user.
|
||||
RequiredUser = Annotated[str, AfterValidator(validate_required_user)]
|
||||
|
||||
|
||||
def is_path_in_user_namespace(path: str, user_id: str) -> bool:
|
||||
"""
|
||||
Check if a Wiki.js path belongs to user's namespace.
|
||||
@@ -204,6 +243,16 @@ def is_path_in_user_namespace(path: str, user_id: str) -> bool:
|
||||
False
|
||||
>>> is_path_in_user_namespace("/public/docs", "jpmschweitzer")
|
||||
False
|
||||
>>> is_path_in_user_namespace("/users/llm_tester2/x", "llm_tester")
|
||||
False
|
||||
>>> is_path_in_user_namespace("users/llm-tester/x", "llm_tester")
|
||||
True
|
||||
"""
|
||||
namespace = get_wikijs_namespace(user_id)
|
||||
return path.startswith(namespace)
|
||||
# Compare the tenant path segment exactly (after sanitization, since
|
||||
# canonical wiki namespaces use sanitized user ids). This enforces a
|
||||
# segment boundary — "users/llm_tester2" is NOT in "llm_tester"'s
|
||||
# namespace — and treats "llm-tester"/"llm_tester" as the same tenant.
|
||||
parts = str(path).lstrip("/").split("/")
|
||||
if len(parts) < 2 or parts[0] != "users":
|
||||
return False
|
||||
return sanitize_user_id(parts[1]) == sanitize_user_id(user_id)
|
||||
|
||||
@@ -9,6 +9,7 @@ Provides background job management with:
|
||||
- User-scoped job queries
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import redis.asyncio as redis
|
||||
import json
|
||||
import uuid
|
||||
@@ -424,3 +425,38 @@ class JobManager:
|
||||
stats[status] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def job_cleanup_loop(
|
||||
job_manager: JobManager,
|
||||
interval_seconds: float = 3600,
|
||||
max_iterations: Optional[int] = None
|
||||
) -> int:
|
||||
"""
|
||||
Periodically clean up expired job-set memberships.
|
||||
|
||||
Redis auto-expires the job payloads (24h TTL) but set memberships
|
||||
(library:active_jobs, library:user_jobs:{user}) need manual cleanup.
|
||||
Started as an in-process background task at application startup.
|
||||
|
||||
Args:
|
||||
job_manager: JobManager whose cleanup_expired_jobs is invoked
|
||||
interval_seconds: Sleep between cleanup passes (default hourly)
|
||||
max_iterations: Stop after N passes (None = run forever; used by tests)
|
||||
|
||||
Returns:
|
||||
Number of completed cleanup passes (only reachable with max_iterations)
|
||||
"""
|
||||
iterations = 0
|
||||
while max_iterations is None or iterations < max_iterations:
|
||||
try:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
await job_manager.cleanup_expired_jobs()
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Job cleanup loop cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
# Never let a transient Redis error kill the loop
|
||||
logger.error(f"Job cleanup pass failed: {e}")
|
||||
iterations += 1
|
||||
return iterations
|
||||
|
||||
+443
-141
@@ -8,16 +8,20 @@ Following best practices:
|
||||
- OpenAPI documentation
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from src.config import Settings, get_settings, __version__
|
||||
from src.core.dependencies import verify_api_key
|
||||
from src.core.dependencies import (
|
||||
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep,
|
||||
RequiredUserQuery, JobManagerDep
|
||||
)
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -35,17 +39,28 @@ app = FastAPI(
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
# CORS middleware.
|
||||
# allow_credentials is deliberately False: combined with a wildcard origin it
|
||||
# would tell browsers to attach cookies/credentials for ANY site, which is the
|
||||
# classic CORS misconfiguration. All real callers (tatlock, the Scheduler) are
|
||||
# server-to-server and use the Authorization header, which wildcard-origin
|
||||
# CORS without credentials still permits. Origins can be restricted via the
|
||||
# CORS_ALLOW_ORIGINS env (comma-separated) once a cross-origin browser UI
|
||||
# exists; the bundled static UI is served same-origin and needs no CORS.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Configure appropriately for production
|
||||
allow_credentials=True,
|
||||
allow_origins=get_settings().cors_allow_origins_list,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Register routers
|
||||
from src.routers import wiki, tools, graph, vector, hybrid_rag, consolidation, ingestion, entity_linking, webhooks
|
||||
from src.routers import (
|
||||
wiki, tools, graph, vector, hybrid_rag, consolidation,
|
||||
ingestion, entity_linking, webhooks, rag_search, content,
|
||||
maintenance, volatile, documents
|
||||
)
|
||||
|
||||
app.include_router(wiki.router)
|
||||
app.include_router(tools.router)
|
||||
@@ -56,6 +71,11 @@ app.include_router(consolidation.router)
|
||||
app.include_router(ingestion.router)
|
||||
app.include_router(entity_linking.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(rag_search.router)
|
||||
app.include_router(content.router)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(volatile.router)
|
||||
app.include_router(documents.router)
|
||||
|
||||
# Mount static files directory for Wiki.js integration scripts
|
||||
static_dir = Path(__file__).parent.parent / "static"
|
||||
@@ -74,10 +94,11 @@ class HealthResponse(BaseModel):
|
||||
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""Statistics response model."""
|
||||
"""System statistics response model."""
|
||||
neo4j: Dict[str, int]
|
||||
qdrant: Dict[str, Any]
|
||||
wiki_pages: int
|
||||
neo4j_nodes: int
|
||||
qdrant_vectors: int
|
||||
paperless: Dict[str, Any]
|
||||
|
||||
|
||||
# Routes
|
||||
@@ -102,10 +123,19 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
# Check service connectivity
|
||||
service_health = await check_service_health()
|
||||
|
||||
# Overall status is healthy if at least Neo4j and Qdrant are up
|
||||
# Overall status is healthy if at least Neo4j and Qdrant are up.
|
||||
#
|
||||
# The Wiki.js change listener is reported below but deliberately excluded
|
||||
# from this decision. It supervises and reconnects itself, and a database
|
||||
# restart would otherwise flip the container unhealthy for the duration of
|
||||
# an outage it is already recovering from. It is reported so the state is
|
||||
# observable at all — previously nothing anywhere exposed it, which is how a
|
||||
# dead listener went unnoticed while this endpoint answered "healthy".
|
||||
all_healthy = service_health.get("neo4j", False) and service_health.get("qdrant", False)
|
||||
overall_status = "healthy" if all_healthy else "degraded"
|
||||
|
||||
wiki_listener = getattr(app.state, "wiki_listener", None)
|
||||
|
||||
return HealthResponse(
|
||||
status=overall_status,
|
||||
app_name=settings.app_name,
|
||||
@@ -129,8 +159,13 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
},
|
||||
"ollama": {
|
||||
"url": settings.ollama_url,
|
||||
"model": settings.ollama_model,
|
||||
"model": settings.ollama_llm_model,
|
||||
"healthy": service_health.get("ollama", False)
|
||||
},
|
||||
"wiki_listener": {
|
||||
"subscribed": bool(wiki_listener and wiki_listener.running),
|
||||
"reconnects": getattr(wiki_listener, "reconnects", 0),
|
||||
"last_gap_seconds": getattr(wiki_listener, "last_gap_seconds", None)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -138,206 +173,456 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
|
||||
@app.get("/stats", response_model=StatsResponse, tags=["System"])
|
||||
async def stats(
|
||||
user: RequiredUserQuery,
|
||||
neo4j: Neo4jDep = None,
|
||||
qdrant: QdrantDep = None,
|
||||
wikijs: WikiJSDep = None,
|
||||
paperless: PaperlessDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> StatsResponse:
|
||||
"""
|
||||
Get system statistics.
|
||||
Protected endpoint - requires API key.
|
||||
|
||||
TODO: Implement actual stats gathering from:
|
||||
- Neo4j (node count)
|
||||
- Qdrant (vector count)
|
||||
- Wiki.js (page count)
|
||||
Returns counts for:
|
||||
- Neo4j: nodes by type (Document, Entity, Collection, Search)
|
||||
- Qdrant: vectors per collection
|
||||
- Wiki.js: total page count
|
||||
- Paperless: documents, tags, correspondents, document types
|
||||
"""
|
||||
# Neo4j node counts by label
|
||||
neo4j_stats = {}
|
||||
try:
|
||||
for label in ["Document", "Entity", "Collection", "Search"]:
|
||||
result = await neo4j.execute_query(
|
||||
f"MATCH (n:{label}) RETURN count(n) as count"
|
||||
)
|
||||
neo4j_stats[label.lower() + "_nodes"] = result[0]["count"] if result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Neo4j stats: {e}")
|
||||
neo4j_stats = {"error": str(e)}
|
||||
|
||||
# Qdrant collection stats
|
||||
qdrant_stats = {}
|
||||
try:
|
||||
collections = await qdrant.list_collections()
|
||||
qdrant_stats["collections"] = len(collections)
|
||||
qdrant_stats["total_vectors"] = sum(c.get("vectors_count", 0) for c in collections)
|
||||
qdrant_stats["by_collection"] = {
|
||||
c["name"]: c["vectors_count"] for c in collections
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Qdrant stats: {e}")
|
||||
qdrant_stats = {"error": str(e)}
|
||||
|
||||
# Wiki.js page count (pages live under the user namespace, e.g. "users/jpmschweitzer/...")
|
||||
wiki_pages = 0
|
||||
try:
|
||||
pages = await wikijs.list_all_pages(path_prefix=f"users/{user}")
|
||||
wiki_pages = len(pages)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Wiki.js stats: {e}")
|
||||
|
||||
# Paperless-ngx document stats
|
||||
paperless_stats = {}
|
||||
try:
|
||||
# Get document count (page_size=1 for efficiency, we just need the count)
|
||||
docs_result = await paperless.list_documents(page_size=1)
|
||||
paperless_stats["documents"] = docs_result.get("count", 0)
|
||||
|
||||
# Get metadata counts
|
||||
tags = await paperless.list_tags()
|
||||
paperless_stats["tags"] = len(tags)
|
||||
|
||||
correspondents = await paperless.list_correspondents()
|
||||
paperless_stats["correspondents"] = len(correspondents)
|
||||
|
||||
doc_types = await paperless.list_document_types()
|
||||
paperless_stats["document_types"] = len(doc_types)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Paperless stats: {e}")
|
||||
paperless_stats = {"error": str(e)}
|
||||
|
||||
return StatsResponse(
|
||||
wiki_pages=0,
|
||||
neo4j_nodes=0,
|
||||
qdrant_vectors=0
|
||||
neo4j=neo4j_stats,
|
||||
qdrant=qdrant_stats,
|
||||
wiki_pages=wiki_pages,
|
||||
paperless=paperless_stats
|
||||
)
|
||||
|
||||
|
||||
# Ingestion endpoints (for Scheduler integration)
|
||||
@app.post("/ingest/document", tags=["Ingestion"])
|
||||
async def ingest_document(
|
||||
document: Dict[str, Any],
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ingest a single document for indexing.
|
||||
Used by The Scheduler to add mirrored documentation to the knowledge base.
|
||||
|
||||
Expected fields:
|
||||
- source: str (e.g., "github", "gitea")
|
||||
- repository: str (e.g., "anthropic-cookbook")
|
||||
- path: str (file path)
|
||||
- content: str (document content)
|
||||
- metadata: dict (commit, author, tags, etc.)
|
||||
|
||||
TODO: Implement document ingestion pipeline:
|
||||
1. Chunk content
|
||||
2. Generate embeddings (Ollama)
|
||||
3. Extract entities (NLP)
|
||||
4. Index in Qdrant
|
||||
5. Create graph nodes/relationships in Neo4j
|
||||
"""
|
||||
return {
|
||||
"message": "Document ingestion not yet implemented",
|
||||
"document_id": f"doc_{document.get('path', 'unknown')}",
|
||||
"status": "stub"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/ingest/batch", tags=["Ingestion"])
|
||||
async def batch_ingest(
|
||||
batch: Dict[str, Any],
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ingest multiple documents in a batch.
|
||||
More efficient than individual ingestion for large syncs.
|
||||
|
||||
TODO: Implement batch processing with task queue
|
||||
"""
|
||||
document_count = len(batch.get("documents", []))
|
||||
return {
|
||||
"message": "Batch ingestion not yet implemented",
|
||||
"batch_id": "batch_stub",
|
||||
"total_documents": document_count,
|
||||
"status": "stub"
|
||||
}
|
||||
class CheckUpdatesRequest(BaseModel):
|
||||
"""Request body for /ingest/check-updates."""
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — only this tenant's namespace is compared."
|
||||
)
|
||||
path_prefix: str | None = Field(
|
||||
default=None,
|
||||
description="Optional sub-path inside the tenant namespace (e.g. 'technology')"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/ingest/check-updates", tags=["Ingestion"])
|
||||
async def check_updates(
|
||||
documents: Dict[str, Any],
|
||||
request: CheckUpdatesRequest,
|
||||
neo4j: Neo4jDep = None,
|
||||
wikijs: WikiJSDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Check which documents need updating based on content hashes.
|
||||
Used by Scheduler to determine what changed since last sync.
|
||||
Check which wiki pages need (re-)ingestion based on content hashes.
|
||||
|
||||
TODO: Implement update detection:
|
||||
1. Query existing documents by path
|
||||
2. Compare content hashes
|
||||
3. Return list of updates needed
|
||||
Compares the `content_hash` stored on the tenant's Neo4j Document nodes
|
||||
(recorded at ingestion time) against the SHA-256 of the current Wiki.js
|
||||
page content, in a single UNWIND Cypher query. Used by the Scheduler to
|
||||
determine what changed since the last sync. Read-only.
|
||||
|
||||
Returns per-tenant lists:
|
||||
- `changed`: page exists in wiki AND graph, but hashes differ (or the
|
||||
stored hash predates hash tracking — flagged `stored_hash_missing`)
|
||||
- `new`: wiki page with no Document node yet
|
||||
- `deleted`: Document node whose wiki page no longer exists
|
||||
"""
|
||||
return {
|
||||
"message": "Update checking not yet implemented",
|
||||
"updates_needed": [],
|
||||
"up_to_date": [],
|
||||
"new_documents": []
|
||||
}
|
||||
import time as _time
|
||||
from src.core.hashing import compute_content_hash
|
||||
from src.core.multi_tenancy import get_neo4j_user_label, sanitize_user_id
|
||||
|
||||
start_time = _time.time()
|
||||
user = request.user
|
||||
tenant_prefix = f"users/{sanitize_user_id(user)}"
|
||||
if request.path_prefix:
|
||||
tenant_prefix = f"{tenant_prefix}/{request.path_prefix.strip('/')}"
|
||||
|
||||
try:
|
||||
pages = await wikijs.list_all_pages(path_prefix=tenant_prefix)
|
||||
|
||||
# Auto-generated entity stubs are intentionally never ingested into
|
||||
# the graph (see GraphService.update_from_page), so they would show
|
||||
# up as perpetually "new". Exclude them.
|
||||
pages = [
|
||||
p for p in pages
|
||||
if not ({"entity-stub", "auto-generated"} & set(p.get("tags") or []))
|
||||
]
|
||||
|
||||
page_hashes = []
|
||||
for p in pages:
|
||||
full_page = await wikijs.get_page(p["id"])
|
||||
content = (full_page or {}).get("content", "")
|
||||
page_hashes.append({
|
||||
"page_id": p["id"],
|
||||
"path": p.get("path", ""),
|
||||
"title": p.get("title", ""),
|
||||
"hash": compute_content_hash(content)
|
||||
})
|
||||
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
if page_hashes:
|
||||
# Single UNWIND query: compare every current page hash against the
|
||||
# stored Document hash AND collect stale Document nodes whose wiki
|
||||
# page is gone.
|
||||
cypher = f"""
|
||||
UNWIND $pages AS p
|
||||
OPTIONAL MATCH (d:{user_doc_label}:Document {{page_id: p.page_id}})
|
||||
WITH collect({{
|
||||
page_id: p.page_id,
|
||||
path: p.path,
|
||||
title: p.title,
|
||||
is_new: d IS NULL,
|
||||
changed: d IS NOT NULL AND (d.content_hash IS NULL OR d.content_hash <> p.hash),
|
||||
stored_hash_missing: d IS NOT NULL AND d.content_hash IS NULL
|
||||
}}) AS checked,
|
||||
collect(p.page_id) AS current_ids
|
||||
OPTIONAL MATCH (stale:{user_doc_label}:Document)
|
||||
WHERE stale.page_id IS NOT NULL AND NOT stale.page_id IN current_ids
|
||||
RETURN checked,
|
||||
collect(CASE WHEN stale IS NULL THEN NULL ELSE {{
|
||||
page_id: stale.page_id, path: stale.path, title: stale.title
|
||||
}} END) AS deleted
|
||||
"""
|
||||
rows = await neo4j.execute_query(cypher, {"pages": page_hashes})
|
||||
checked = rows[0]["checked"] if rows else []
|
||||
deleted = rows[0]["deleted"] if rows else []
|
||||
else:
|
||||
# No wiki pages under the prefix: every Document node is stale.
|
||||
cypher = f"""
|
||||
MATCH (stale:{user_doc_label}:Document)
|
||||
WHERE stale.page_id IS NOT NULL
|
||||
RETURN collect({{page_id: stale.page_id, path: stale.path, title: stale.title}}) AS deleted
|
||||
"""
|
||||
rows = await neo4j.execute_query(cypher, {})
|
||||
checked = []
|
||||
deleted = rows[0]["deleted"] if rows else []
|
||||
|
||||
# Deleted detection is namespace-wide only for full-tenant scans; a
|
||||
# sub-path scan must not flag documents outside its prefix.
|
||||
if request.path_prefix:
|
||||
deleted = [
|
||||
d for d in deleted
|
||||
if str(d.get("path", "")).lstrip("/").startswith(tenant_prefix)
|
||||
]
|
||||
|
||||
new_pages = [c for c in checked if c["is_new"]]
|
||||
changed_pages = [c for c in checked if c["changed"]]
|
||||
up_to_date = len(checked) - len(new_pages) - len(changed_pages)
|
||||
|
||||
duration_ms = (_time.time() - start_time) * 1000
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"path_prefix": tenant_prefix,
|
||||
"total_wiki_pages": len(page_hashes),
|
||||
"changed": [
|
||||
{k: c[k] for k in ("page_id", "path", "title", "stored_hash_missing")}
|
||||
for c in changed_pages
|
||||
],
|
||||
"new": [
|
||||
{k: c[k] for k in ("page_id", "path", "title")} for c in new_pages
|
||||
],
|
||||
"deleted": deleted,
|
||||
"counts": {
|
||||
"changed": len(changed_pages),
|
||||
"new": len(new_pages),
|
||||
"deleted": len(deleted),
|
||||
"up_to_date": up_to_date
|
||||
},
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"check-updates failed for {user}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Update check failed")
|
||||
|
||||
|
||||
@app.get("/ingest/status/{document_id}", tags=["Ingestion"])
|
||||
@app.get("/ingest/status/{job_id}", tags=["Ingestion"])
|
||||
async def get_ingestion_status(
|
||||
document_id: str,
|
||||
job_id: str,
|
||||
user: RequiredUserQuery,
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get processing status for a document.
|
||||
Get processing status for an ingestion job.
|
||||
|
||||
TODO: Implement status tracking
|
||||
Backed by the Redis job store (`library:job:{job_id}`, 24h TTL). Job IDs
|
||||
are returned by /ingest/page, /ingest/batch and /ingest/all. Jobs are
|
||||
tenant-scoped: requesting another tenant's job returns 404.
|
||||
"""
|
||||
return {
|
||||
"message": "Status tracking not yet implemented",
|
||||
"document_id": document_id,
|
||||
"status": "unknown"
|
||||
}
|
||||
job = await job_manager.get_job(job_id)
|
||||
if not job or job.get("user") != user:
|
||||
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/ingest/repo-status/{repository}", tags=["Ingestion"])
|
||||
async def get_repo_status(
|
||||
repository: str,
|
||||
user: RequiredUserQuery,
|
||||
neo4j: Neo4jDep = None,
|
||||
wikijs: WikiJSDep = None,
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get indexing status for an entire repository.
|
||||
Get indexing status for a repository (a sub-path of the tenant namespace).
|
||||
|
||||
TODO: Implement repository-level statistics
|
||||
`repository` is resolved as `users/{tenant}/{repository}`; use `_all` for
|
||||
the whole tenant namespace. Reports how many wiki pages exist under the
|
||||
path, how many have graph Document nodes (i.e. are indexed), and the
|
||||
tenant's recent job statistics from the Redis job store.
|
||||
"""
|
||||
return {
|
||||
"message": "Repository status not yet implemented",
|
||||
"repository": repository,
|
||||
"total_documents": 0,
|
||||
"indexed_documents": 0
|
||||
}
|
||||
import time as _time
|
||||
from src.core.multi_tenancy import get_neo4j_user_label, sanitize_user_id
|
||||
|
||||
start_time = _time.time()
|
||||
tenant_root = f"users/{sanitize_user_id(user)}"
|
||||
prefix = tenant_root if repository in ("_all", "all", "") else f"{tenant_root}/{repository.strip('/')}"
|
||||
|
||||
try:
|
||||
pages = await wikijs.list_all_pages(path_prefix=prefix)
|
||||
page_ids = [p["id"] for p in pages if p.get("id")]
|
||||
|
||||
indexed = 0
|
||||
if page_ids:
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
rows = await neo4j.execute_query(
|
||||
f"""
|
||||
MATCH (d:{user_doc_label}:Document)
|
||||
WHERE d.page_id IN $page_ids
|
||||
RETURN count(DISTINCT d.page_id) AS indexed
|
||||
""",
|
||||
{"page_ids": page_ids}
|
||||
)
|
||||
indexed = rows[0]["indexed"] if rows else 0
|
||||
|
||||
job_stats = await job_manager.get_job_stats(user=user)
|
||||
|
||||
return {
|
||||
"repository": repository,
|
||||
"user": user,
|
||||
"path_prefix": prefix,
|
||||
"total_documents": len(page_ids),
|
||||
"indexed_documents": indexed,
|
||||
"unindexed_documents": len(page_ids) - indexed,
|
||||
"jobs": job_stats,
|
||||
"duration_ms": (_time.time() - start_time) * 1000
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"repo-status failed for {user}/{repository}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Repository status failed")
|
||||
|
||||
|
||||
# Query endpoints (stubs for future implementation)
|
||||
# NOTE: /query/hybrid is now implemented in routers/hybrid_rag.py
|
||||
# Query endpoints
|
||||
# NOTE: /query/hybrid is implemented in routers/hybrid_rag.py
|
||||
|
||||
@app.post("/query/semantic", tags=["Query"])
|
||||
async def semantic_query(
|
||||
query: Dict[str, Any],
|
||||
user: RequiredUserQuery,
|
||||
query: str = Query(..., min_length=1, description="Search query text"),
|
||||
limit: int = Query(default=10, ge=1, le=100, description="Maximum results"),
|
||||
score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"),
|
||||
qdrant_client: QdrantDep = None,
|
||||
wiki_client: WikiJSDep = None,
|
||||
ollama_client: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
):
|
||||
"""
|
||||
Semantic search via Qdrant.
|
||||
Pure vector similarity search.
|
||||
Semantic search via Qdrant vector similarity.
|
||||
|
||||
TODO: Implement semantic search
|
||||
Searches document chunks using embedding similarity. Returns matching
|
||||
chunks with relevance scores, page titles, and paths.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /query/semantic?query=docker%20configuration&user=jpmschweitzer&limit=10
|
||||
```
|
||||
|
||||
**Returns:** List of matching chunks with similarity scores (0-1)
|
||||
"""
|
||||
return {
|
||||
"message": "Semantic search not yet implemented",
|
||||
"query": query
|
||||
}
|
||||
from src.services.vector_service import VectorService
|
||||
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
try:
|
||||
return await vector_service.search(
|
||||
query=query,
|
||||
user=user,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Semantic search failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Search failed")
|
||||
|
||||
|
||||
@app.post("/query/graph", tags=["Query"])
|
||||
async def graph_query(
|
||||
query: Dict[str, Any],
|
||||
user: RequiredUserQuery,
|
||||
query: str = Query(..., description="Cypher query to execute"),
|
||||
neo4j_client: Neo4jDep = None,
|
||||
wiki_client: WikiJSDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
):
|
||||
"""
|
||||
Graph traversal via Neo4j.
|
||||
Execute Cypher queries.
|
||||
Execute a raw Cypher query against the Neo4j knowledge graph
|
||||
(ADMIN/DEBUG — read-only, NOT tenant-scoped).
|
||||
|
||||
TODO: Implement graph queries
|
||||
**Security model:**
|
||||
- Queries containing write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/
|
||||
DETACH/FOREACH/LOAD CSV) or any CALL are rejected with 400.
|
||||
- Execution happens in a read-only Neo4j session, so writes are refused
|
||||
by the database even if validation is bypassed.
|
||||
- Results are NOT automatically restricted to the requesting user's
|
||||
tenant: an arbitrary query can read any tenant's nodes. Scope your
|
||||
own patterns (e.g. `MATCH (d:User_<Tenant>_Document:Document) ...`).
|
||||
For tenant-scoped access use /graph/nodes instead.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /query/graph?query=MATCH%20(d:Document)-[:MENTIONS]->(p:Person)%20RETURN%20d,p&user=<tenant>
|
||||
```
|
||||
"""
|
||||
return {
|
||||
"message": "Graph query not yet implemented",
|
||||
"query": query
|
||||
}
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
graph_service = GraphService(neo4j_client, wiki_client)
|
||||
try:
|
||||
return await graph_service.execute_query(
|
||||
query=query,
|
||||
parameters={},
|
||||
user=user
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Graph query failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Query execution failed")
|
||||
|
||||
|
||||
# Deduplication endpoints
|
||||
class DeduplicateCheckRequest(BaseModel):
|
||||
"""Request body for /deduplicate/check."""
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — only this tenant's collection is scanned."
|
||||
)
|
||||
similarity_threshold: float = Field(
|
||||
default=0.9, ge=0.5, le=1.0,
|
||||
description="Minimum cosine similarity for a chunk pair to count as duplicate"
|
||||
)
|
||||
max_pairs: int = Field(default=100, ge=1, le=500, description="Maximum page pairs returned")
|
||||
|
||||
|
||||
@app.post("/deduplicate/check", tags=["Deduplication"])
|
||||
async def check_duplicates(
|
||||
request: Dict[str, Any],
|
||||
request: DeduplicateCheckRequest,
|
||||
qdrant_client: QdrantDep = None,
|
||||
wiki_client: WikiJSDep = None,
|
||||
ollama_client: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Check for duplicate or highly similar documents.
|
||||
Uses vector similarity and graph analysis.
|
||||
Check for duplicate or highly similar wiki pages (tenant-scoped, read-only).
|
||||
|
||||
Expected fields:
|
||||
- document_id: str
|
||||
- similarity_threshold: float (default 0.85)
|
||||
|
||||
TODO: Implement deduplication:
|
||||
1. Get document embedding from Qdrant
|
||||
2. Find similar vectors above threshold
|
||||
3. Check graph relationships
|
||||
4. Return candidates with similarity scores
|
||||
Scans the tenant's own Qdrant collection: every wiki chunk vector is
|
||||
queried against the same collection, and chunk pairs from different
|
||||
pages scoring above the threshold (default 0.9 cosine) are grouped per
|
||||
page pair with the best similarity and matching chunk-pair count.
|
||||
"""
|
||||
document_id = request.get("document_id")
|
||||
threshold = request.get("similarity_threshold", 0.85)
|
||||
import time as _time
|
||||
from src.services.vector_service import VectorService
|
||||
|
||||
return {
|
||||
"message": "Deduplication not yet implemented",
|
||||
"document_id": document_id,
|
||||
"threshold": threshold,
|
||||
"duplicates": [],
|
||||
"suggestions": None
|
||||
}
|
||||
start_time = _time.time()
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
|
||||
try:
|
||||
scan = await vector_service.find_duplicate_pairs(
|
||||
user=request.user,
|
||||
similarity_threshold=request.similarity_threshold,
|
||||
max_pairs=request.max_pairs
|
||||
)
|
||||
return {
|
||||
"user": request.user,
|
||||
"similarity_threshold": request.similarity_threshold,
|
||||
"chunks_scanned": scan["chunks_scanned"],
|
||||
"duplicate_groups": scan["duplicate_groups"],
|
||||
"duplicate_group_count": len(scan["duplicate_groups"]),
|
||||
"duration_ms": (_time.time() - start_time) * 1000
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Deduplication check failed for {request.user}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Deduplication check failed")
|
||||
|
||||
|
||||
# Application lifecycle
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize connections and resources on startup."""
|
||||
from src.core.dependencies import startup_clients
|
||||
import asyncio
|
||||
|
||||
from src.core.dependencies import startup_clients, get_job_manager
|
||||
from src.jobs.job_manager import job_cleanup_loop
|
||||
from src.services.wiki_change_listener import WikiChangeListener
|
||||
|
||||
settings = get_settings()
|
||||
@@ -347,10 +632,18 @@ async def startup_event():
|
||||
logger.info(f"Wiki.js: {settings.wikijs_url}")
|
||||
logger.info(f"SearXNG: {settings.searxng_url}")
|
||||
logger.info(f"Ollama: {settings.ollama_url}")
|
||||
logger.info(f"Ollama generation model: {settings.ollama_llm_model} (embedding model: {settings.ollama_embedding_model})")
|
||||
|
||||
# Initialize all service clients
|
||||
await startup_clients()
|
||||
|
||||
# Hourly in-process cleanup of expired Redis job-set memberships
|
||||
# (job payloads auto-expire via TTL; set memberships do not)
|
||||
app.state.job_cleanup_task = asyncio.create_task(
|
||||
job_cleanup_loop(get_job_manager(), interval_seconds=3600)
|
||||
)
|
||||
logger.info("Job cleanup loop started (hourly)")
|
||||
|
||||
# Start Wiki.js change listener (PostgreSQL NOTIFY/LISTEN)
|
||||
# This enables automatic processing of user-edited pages
|
||||
try:
|
||||
@@ -371,6 +664,15 @@ async def shutdown_event():
|
||||
|
||||
logger.info("Shutting down Library Desk API")
|
||||
|
||||
# Stop the job cleanup loop
|
||||
if hasattr(app.state, "job_cleanup_task"):
|
||||
app.state.job_cleanup_task.cancel()
|
||||
try:
|
||||
await app.state.job_cleanup_task
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Job cleanup loop stopped")
|
||||
|
||||
# Stop Wiki.js change listener if running
|
||||
if hasattr(app.state, "wiki_listener"):
|
||||
try:
|
||||
|
||||
@@ -34,6 +34,9 @@ class ConsolidationResult(BaseModel):
|
||||
pages_created: int = 0
|
||||
pages_updated: int = 0
|
||||
entities_added: int = 0
|
||||
volatile_cached: int = 0
|
||||
files_queued: int = 0
|
||||
prefetch_registered: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@@ -44,6 +47,58 @@ class ConsolidationResponse(BaseModel):
|
||||
pages_created: int = Field(description="New wiki pages created")
|
||||
pages_updated: int = Field(description="Existing pages updated")
|
||||
entities_added: int = Field(description="New entities added to graph")
|
||||
volatile_cached: int = Field(default=0, description="Items cached to volatile storage")
|
||||
files_queued: int = Field(default=0, description="Files queued for Paperless")
|
||||
prefetch_registered: int = Field(default=0, description="Prefetch patterns registered")
|
||||
searches_deferred: int = Field(
|
||||
default=0,
|
||||
description="Searches left unprocessed for the next run because the generation LLM was unavailable"
|
||||
)
|
||||
errors: List[str] = Field(default=[], description="Error messages")
|
||||
results: List[ConsolidationResult] = Field(description="Per-search results")
|
||||
dry_run: bool = Field(description="Whether this was a dry run")
|
||||
duration_ms: float = Field(default=0.0, description="Run duration in milliseconds")
|
||||
|
||||
|
||||
class MemoryRouteClassification(BaseModel):
|
||||
"""
|
||||
Unified classification of a web result for memory routing.
|
||||
|
||||
Route types:
|
||||
- wiki: Stable reference content → wiki page creation/update
|
||||
- volatile: Ephemeral data (weather, news, prices) → volatile cache
|
||||
- file: Downloadable file (PDF, doc, xls, images) → Paperless ingestion
|
||||
- prefetch: Regularly updated source → scheduler registration
|
||||
- skip: Low value, ads, errors → discard
|
||||
"""
|
||||
url: str
|
||||
title: str
|
||||
route_type: str = Field(description="One of: wiki, volatile, file, prefetch, skip")
|
||||
|
||||
# Wiki routing fields
|
||||
wiki_action: Optional[str] = Field(default=None, description="create or update")
|
||||
wiki_path: Optional[str] = Field(default=None, description="Wiki path for page")
|
||||
wiki_summary: Optional[str] = Field(default=None, description="Summary for wiki page")
|
||||
|
||||
# Volatile routing fields
|
||||
volatile_namespace: Optional[str] = Field(default=None, description="weather, news, financial, etc.")
|
||||
volatile_key: Optional[str] = Field(default=None, description="Cache key")
|
||||
volatile_ttl_hours: Optional[int] = Field(default=None, description="TTL in hours")
|
||||
|
||||
# Prefetch routing fields
|
||||
prefetch_cron: Optional[str] = Field(default=None, description="Cron expression for refresh")
|
||||
prefetch_endpoint: Optional[str] = Field(default=None, description="API endpoint to call")
|
||||
|
||||
# Classification metadata
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
reason: str = Field(default="")
|
||||
|
||||
|
||||
class MemoryRoutingResult(BaseModel):
|
||||
"""Aggregated result of memory routing for a search."""
|
||||
wiki_routed: int = 0
|
||||
volatile_cached: int = 0
|
||||
files_queued: int = 0
|
||||
prefetch_registered: int = 0
|
||||
skipped: int = 0
|
||||
classifications: List[MemoryRouteClassification] = []
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Content extraction models for Library Desk.
|
||||
|
||||
Pydantic models for content extraction requests and responses.
|
||||
"""
|
||||
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ContentExtractionResult(BaseModel):
|
||||
"""Result of extracting content from a single URL."""
|
||||
|
||||
url: str = Field(..., description="The URL that was processed")
|
||||
title: Optional[str] = Field(None, description="Page title if extracted")
|
||||
content: str = Field("", description="Extracted main text content")
|
||||
author: Optional[str] = Field(None, description="Author if available")
|
||||
date: Optional[str] = Field(None, description="Publication date if available (ISO format)")
|
||||
language: Optional[str] = Field(None, description="Detected language code")
|
||||
success: bool = Field(..., description="Whether extraction succeeded")
|
||||
error: Optional[str] = Field(None, description="Error message if extraction failed")
|
||||
|
||||
|
||||
class ContentExtractionRequest(BaseModel):
|
||||
"""Request to extract content from a single URL."""
|
||||
|
||||
url: str = Field(..., min_length=1, description="URL to extract content from")
|
||||
include_metadata: bool = Field(default=True, description="Include title, author, date metadata")
|
||||
max_length: Optional[int] = Field(
|
||||
None,
|
||||
ge=100,
|
||||
le=50000,
|
||||
description="Override default max content length"
|
||||
)
|
||||
|
||||
|
||||
class ContentExtractionResponse(BaseModel):
|
||||
"""Response for single URL extraction."""
|
||||
|
||||
result: ContentExtractionResult
|
||||
extraction_time_ms: int = Field(..., ge=0, description="Time taken to extract content")
|
||||
|
||||
|
||||
class BatchContentExtractionRequest(BaseModel):
|
||||
"""Request to extract content from multiple URLs."""
|
||||
|
||||
urls: List[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
description="URLs to extract content from (max 20)"
|
||||
)
|
||||
include_metadata: bool = Field(default=True, description="Include title, author, date metadata")
|
||||
max_length: Optional[int] = Field(
|
||||
None,
|
||||
ge=100,
|
||||
le=50000,
|
||||
description="Override default max content length"
|
||||
)
|
||||
|
||||
|
||||
class BatchContentExtractionResponse(BaseModel):
|
||||
"""Response for batch URL extraction."""
|
||||
|
||||
results: List[ContentExtractionResult]
|
||||
total_urls: int = Field(..., ge=0, description="Total number of URLs processed")
|
||||
successful: int = Field(..., ge=0, description="Number of successful extractions")
|
||||
failed: int = Field(..., ge=0, description="Number of failed extractions")
|
||||
extraction_time_ms: int = Field(..., ge=0, description="Total time for batch extraction")
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Document storage models for Library Desk.
|
||||
|
||||
Models for Paperless-ngx document management, virus scanning,
|
||||
and document sync operations.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DocumentType(str, Enum):
|
||||
"""Types of documents supported in the document store."""
|
||||
PDF = "pdf"
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
TEXT = "text"
|
||||
ARCHIVE = "archive"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class SyncStatus(str, Enum):
|
||||
"""Status of document sync with Library Desk."""
|
||||
PENDING = "pending"
|
||||
INDEXED = "indexed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Document Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DocumentMetadata(BaseModel):
|
||||
"""Metadata for a document in Paperless-ngx."""
|
||||
paperless_id: int = Field(..., description="Paperless-ngx document ID")
|
||||
title: str = Field(..., description="Document title")
|
||||
filename: Optional[str] = Field(None, description="Original filename")
|
||||
content: Optional[str] = Field(None, description="Extracted text content")
|
||||
created: Optional[datetime] = Field(None, description="Document creation date")
|
||||
modified: Optional[datetime] = Field(None, description="Last modification date")
|
||||
added: Optional[datetime] = Field(None, description="Date added to Paperless")
|
||||
correspondent: Optional[str] = Field(None, description="Correspondent name")
|
||||
document_type: Optional[str] = Field(None, description="Document type name")
|
||||
tags: List[str] = Field(default_factory=list, description="Tag names")
|
||||
custom_fields: Dict[str, Any] = Field(default_factory=dict, description="Custom field values")
|
||||
|
||||
|
||||
class DocumentRecord(BaseModel):
|
||||
"""A document record with sync status."""
|
||||
metadata: DocumentMetadata = Field(..., description="Document metadata from Paperless")
|
||||
sync_status: SyncStatus = Field(default=SyncStatus.PENDING, description="Library Desk sync status")
|
||||
indexed_at: Optional[datetime] = Field(None, description="When indexed in Library Desk")
|
||||
collection: Optional[str] = Field(None, description="Collection name (e.g., 'fastapi-docs')")
|
||||
source_url: Optional[str] = Field(None, description="Original source URL if uploaded via HybridRAG")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Upload Request/Response Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DocumentUploadRequest(BaseModel):
|
||||
"""Request to upload a document to Paperless-ngx."""
|
||||
url: Optional[str] = Field(None, description="URL to download document from")
|
||||
title: Optional[str] = Field(None, description="Document title (derived from filename if not set)")
|
||||
collection: Optional[str] = Field(None, description="Collection to add document to")
|
||||
tags: List[str] = Field(default_factory=list, description="Tags to apply")
|
||||
correspondent: Optional[str] = Field(None, description="Correspondent name")
|
||||
document_type: Optional[str] = Field(None, description="Document type name")
|
||||
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
"""Response from document upload."""
|
||||
task_id: str = Field(..., description="Paperless task ID for tracking")
|
||||
filename: str = Field(..., description="Uploaded filename")
|
||||
message: str = Field(..., description="Status message")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PaperlessWebhookPayload(BaseModel):
|
||||
"""
|
||||
Payload from Paperless-ngx webhook.
|
||||
|
||||
Supports Jinja template format:
|
||||
- doc_url: Contains document ID in URL path (e.g., http://paperless:8000/documents/123/)
|
||||
- title: Document title from {{ doc_title }}
|
||||
"""
|
||||
doc_url: str = Field(..., description="Paperless document URL containing ID")
|
||||
title: Optional[str] = Field(None, description="Document title")
|
||||
|
||||
class Config:
|
||||
extra = "ignore" # Ignore extra fields
|
||||
|
||||
@property
|
||||
def document_id(self) -> int:
|
||||
"""Extract document ID from doc_url."""
|
||||
import re
|
||||
match = re.search(r'/documents/(\d+)/?', self.doc_url)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
raise ValueError(f"Cannot extract document ID from URL: {self.doc_url}")
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
"""Response to webhook processing."""
|
||||
document_id: int = Field(..., description="Processed document ID")
|
||||
status: str = Field(..., description="Processing status")
|
||||
indexed: bool = Field(..., description="Whether document was indexed")
|
||||
message: Optional[str] = Field(None, description="Additional details")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sync Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SyncRequest(BaseModel):
|
||||
"""Request to sync documents from Paperless-ngx."""
|
||||
since: Optional[datetime] = Field(None, description="Only sync documents modified after this time")
|
||||
collection: Optional[str] = Field(None, description="Only sync documents in this collection")
|
||||
limit: int = Field(default=100, ge=1, le=1000, description="Maximum documents to sync")
|
||||
force_reindex: bool = Field(default=False, description="Re-index already indexed documents")
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
"""Result of a sync operation."""
|
||||
documents_found: int = Field(..., description="Total documents matching criteria")
|
||||
documents_indexed: int = Field(..., description="Successfully indexed")
|
||||
documents_skipped: int = Field(..., description="Skipped (already indexed)")
|
||||
documents_failed: int = Field(..., description="Failed to index")
|
||||
errors: List[str] = Field(default_factory=list, description="Error messages")
|
||||
duration_seconds: float = Field(..., description="Sync duration")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Collection Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Collection(BaseModel):
|
||||
"""A logical grouping of documents."""
|
||||
name: str = Field(..., description="Collection name (e.g., 'fastapi-docs')")
|
||||
description: Optional[str] = Field(None, description="Collection description")
|
||||
document_count: int = Field(default=0, description="Number of documents")
|
||||
source: Optional[str] = Field(None, description="Source (e.g., 'github.com/tiangolo/fastapi')")
|
||||
last_sync: Optional[datetime] = Field(None, description="Last sync timestamp")
|
||||
wiki_page: Optional[str] = Field(None, description="Wiki catalog page path")
|
||||
|
||||
|
||||
class CollectionListResponse(BaseModel):
|
||||
"""Response listing all collections."""
|
||||
collections: List[Collection] = Field(..., description="List of collections")
|
||||
total_documents: int = Field(..., description="Total documents across all collections")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Search Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DocumentSearchRequest(BaseModel):
|
||||
"""Request to search documents."""
|
||||
query: str = Field(..., min_length=1, description="Search query")
|
||||
collection: Optional[str] = Field(None, description="Limit to collection")
|
||||
document_type: Optional[DocumentType] = Field(None, description="Filter by type")
|
||||
limit: int = Field(default=10, ge=1, le=50, description="Maximum results")
|
||||
include_content: bool = Field(default=False, description="Include full text content")
|
||||
|
||||
|
||||
class DocumentSearchHit(BaseModel):
|
||||
"""A document search result."""
|
||||
paperless_id: int = Field(..., description="Paperless document ID")
|
||||
title: str = Field(..., description="Document title")
|
||||
score: float = Field(..., description="Relevance score")
|
||||
highlights: Optional[str] = Field(None, description="Highlighted matching text")
|
||||
collection: Optional[str] = Field(None, description="Collection name")
|
||||
document_type: Optional[str] = Field(None, description="Document type")
|
||||
content_preview: Optional[str] = Field(None, description="Content preview if requested")
|
||||
|
||||
|
||||
class DocumentSearchResponse(BaseModel):
|
||||
"""Response from document search."""
|
||||
query: str = Field(..., description="Original query")
|
||||
hits: List[DocumentSearchHit] = Field(..., description="Search results")
|
||||
total: int = Field(..., description="Total matching documents")
|
||||
duration_ms: int = Field(..., description="Search duration in milliseconds")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Health Check Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DocumentStoreHealth(BaseModel):
|
||||
"""Health status of document storage components."""
|
||||
paperless_healthy: bool = Field(..., description="Paperless-ngx responding")
|
||||
paperless_version: Optional[str] = Field(None, description="Paperless version")
|
||||
total_documents: Optional[int] = Field(None, description="Total documents in Paperless")
|
||||
indexed_documents: Optional[int] = Field(None, description="Documents indexed in Library Desk")
|
||||
+12
-6
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class GraphNode(BaseModel):
|
||||
"""Graph node representation."""
|
||||
@@ -45,9 +47,13 @@ class CypherQueryRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Query parameters"
|
||||
)
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User for filtering (automatically scopes query)"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description=(
|
||||
"User identifier (tenant). Required. NOTE: raw Cypher queries are NOT "
|
||||
"automatically scoped to this tenant — the endpoint is read-only and "
|
||||
"intended for admin/debug use. Results may span all tenants."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -61,9 +67,9 @@ class CypherQueryResponse(BaseModel):
|
||||
class UpdateFromPageRequest(BaseModel):
|
||||
"""Request to update graph from a wiki page."""
|
||||
page_id: int = Field(..., description="Wiki page ID to process")
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User identifier for namespace scoping"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — graph writes are scoped to this tenant's labels."
|
||||
)
|
||||
force_refresh: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -14,13 +14,19 @@ class HybridRAGConfig(BaseModel):
|
||||
vector_limit: int = Field(default=10, ge=1, le=50, description="Max vector results")
|
||||
graph_limit: int = Field(default=10, ge=1, le=50, description="Max graph results")
|
||||
web_limit: int = Field(default=5, ge=1, le=20, description="Max web results")
|
||||
volatile_limit: int = Field(default=1, ge=1, le=5, description="Max volatile results (typically 1)")
|
||||
document_limit: int = Field(default=5, ge=1, le=20, description="Max Paperless document results")
|
||||
enable_vector: bool = Field(default=True, description="Enable vector search")
|
||||
enable_graph: bool = Field(default=True, description="Enable graph search")
|
||||
enable_web: bool = Field(default=True, description="Enable web search")
|
||||
enable_volatile: bool = Field(default=True, description="Enable volatile cache search")
|
||||
enable_documents: bool = Field(default=True, description="Enable Paperless document search")
|
||||
enable_reranking: bool = Field(default=True, description="Enable LLM re-ranking")
|
||||
enable_enrichment: bool = Field(default=True, description="Enable graph enrichment")
|
||||
final_result_count: int = Field(default=10, ge=1, le=50, description="Final results to return")
|
||||
rrf_k: int = Field(default=60, ge=1, le=100, description="RRF constant")
|
||||
volatile_threshold: float = Field(default=0.8, ge=0.5, le=1.0, description="Volatile similarity threshold")
|
||||
document_threshold: float = Field(default=0.6, ge=0.3, le=1.0, description="Document similarity threshold")
|
||||
|
||||
|
||||
class RelatedDossier(BaseModel):
|
||||
@@ -34,12 +40,13 @@ class RelatedDossier(BaseModel):
|
||||
|
||||
class HybridRAGResult(BaseModel):
|
||||
"""Single result from HybridRAG query."""
|
||||
source_type: str = Field(..., description="Source: 'vector', 'graph', 'web'")
|
||||
source_type: str = Field(..., description="Source: 'wiki', 'web', 'volatile', 'document'")
|
||||
title: str
|
||||
content: str
|
||||
url: Optional[str] = Field(None, description="URL for web results")
|
||||
page_id: Optional[int] = Field(None, description="Page ID for wiki results")
|
||||
page_path: Optional[str] = Field(None, description="Wiki page path")
|
||||
paperless_id: Optional[int] = Field(None, description="Paperless document ID")
|
||||
rrf_score: float = Field(..., description="Reciprocal Rank Fusion score")
|
||||
final_rank: int = Field(..., description="Final rank after re-ranking")
|
||||
sources: List[str] = Field(..., description="Which sources included this result")
|
||||
@@ -53,6 +60,8 @@ class TimingBreakdown(BaseModel):
|
||||
vector_ms: float = Field(..., description="Phase 1: Vector search")
|
||||
graph_ms: float = Field(..., description="Phase 1: Graph search")
|
||||
web_ms: float = Field(..., description="Phase 1: Web search")
|
||||
volatile_ms: float = Field(default=0, description="Phase 1: Volatile cache search")
|
||||
document_ms: float = Field(default=0, description="Phase 1: Paperless document search")
|
||||
fusion_ms: float = Field(..., description="Phase 2: RRF fusion")
|
||||
enrichment_ms: float = Field(..., description="Phase 3: Graph enrichment")
|
||||
reranking_ms: float = Field(..., description="Phase 4: LLM re-ranking")
|
||||
@@ -79,6 +88,11 @@ class HybridRAGResponse(BaseModel):
|
||||
timing: TimingBreakdown = Field(..., description="Performance breakdown")
|
||||
config_used: HybridRAGConfig = Field(..., description="Configuration used")
|
||||
search_id: Optional[str] = Field(None, description="Search ID for Librarian tracking")
|
||||
source_status: Dict[str, str] = Field(
|
||||
default={},
|
||||
description="Per-leg retrieval status ('ok', 'failed', or 'disabled') keyed by: vector, graph, web, volatile, documents"
|
||||
)
|
||||
degraded: bool = Field(default=False, description="True when any enabled retrieval leg reported 'failed'")
|
||||
|
||||
|
||||
class HybridRAGRequest(BaseModel):
|
||||
|
||||
+12
-2
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class IngestionRequest(BaseModel):
|
||||
"""Request to ingest a wiki page."""
|
||||
page_id: int = Field(..., description="Wiki page ID to ingest")
|
||||
user: str = Field(default="jpmschweitzer", description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
|
||||
force_refresh: bool = Field(
|
||||
default=False,
|
||||
description="Force re-ingestion even if page hasn't changed"
|
||||
@@ -21,7 +23,7 @@ class IngestionRequest(BaseModel):
|
||||
class BatchIngestionRequest(BaseModel):
|
||||
"""Request to ingest multiple wiki pages."""
|
||||
page_ids: List[int] = Field(..., description="List of wiki page IDs to ingest")
|
||||
user: str = Field(default="jpmschweitzer", description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
|
||||
force_refresh: bool = Field(default=False)
|
||||
skip_vectors: bool = Field(default=False)
|
||||
skip_graph: bool = Field(default=False)
|
||||
@@ -44,6 +46,10 @@ class IngestionResult(BaseModel):
|
||||
graph_entities_extracted: int = 0
|
||||
graph_relationships_created: int = 0
|
||||
processing_time_ms: float
|
||||
job_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Redis job-tracking ID (query via GET /ingest/status/{job_id})"
|
||||
)
|
||||
|
||||
|
||||
class BatchIngestionResult(BaseModel):
|
||||
@@ -53,6 +59,10 @@ class BatchIngestionResult(BaseModel):
|
||||
failed: int
|
||||
results: List[IngestionResult]
|
||||
total_processing_time_ms: float
|
||||
job_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Redis job-tracking ID (query via GET /ingest/status/{job_id})"
|
||||
)
|
||||
|
||||
|
||||
class IngestionStatus(BaseModel):
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
RAG search models for Library Desk.
|
||||
|
||||
Pydantic models for web/news/image search requests and responses.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
"""Supported search types."""
|
||||
WEB = "web"
|
||||
NEWS = "news"
|
||||
IMAGES = "images"
|
||||
|
||||
|
||||
class RAGSearchRequest(BaseModel):
|
||||
"""Request for RAG search endpoint."""
|
||||
|
||||
query: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
description="The search query"
|
||||
)
|
||||
search_type: SearchType = Field(
|
||||
default=SearchType.WEB,
|
||||
description="Type of search: web, news, or images"
|
||||
)
|
||||
limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
le=20,
|
||||
description="Maximum number of results (1-20)"
|
||||
)
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — used for rate limiting/personalization."
|
||||
)
|
||||
|
||||
|
||||
class RAGSearchResult(BaseModel):
|
||||
"""A single search result with extracted content."""
|
||||
|
||||
title: str = Field(..., description="Title of the result")
|
||||
url: str = Field(..., description="URL of the source")
|
||||
content: str = Field(
|
||||
"",
|
||||
description="Full extracted text via Trafilatura (max ~2000 chars)"
|
||||
)
|
||||
snippet: str = Field(
|
||||
"",
|
||||
description="Original search engine snippet (150-300 chars)"
|
||||
)
|
||||
source: str = Field(..., description="Domain name of the source")
|
||||
published_date: Optional[str] = Field(
|
||||
None,
|
||||
description="Publication date in ISO format if available"
|
||||
)
|
||||
|
||||
|
||||
class RAGSearchResponse(BaseModel):
|
||||
"""Response from RAG search endpoint."""
|
||||
|
||||
query: str = Field(..., description="Echo of the original query")
|
||||
search_type: SearchType = Field(..., description="Type of search performed")
|
||||
results: List[RAGSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="List of search results with extracted content"
|
||||
)
|
||||
total_results: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Number of results returned"
|
||||
)
|
||||
search_time_ms: int = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Total time for search and content extraction"
|
||||
)
|
||||
sources_summary: str = Field(
|
||||
"",
|
||||
description="Markdown-formatted list of all source URLs"
|
||||
)
|
||||
@@ -7,6 +7,8 @@ Provides models for semantic search, document chunks, and embeddings.
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
class DocumentChunk(BaseModel):
|
||||
"""Document chunk with embedding."""
|
||||
@@ -32,7 +34,7 @@ class SearchResult(BaseModel):
|
||||
class SearchRequest(BaseModel):
|
||||
"""Semantic search request."""
|
||||
query: str = Field(..., min_length=1, description="Search query")
|
||||
user: str = Field(default="jpmschweitzer", description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — search is scoped to this tenant's collection.")
|
||||
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
|
||||
score_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score")
|
||||
|
||||
@@ -48,9 +50,9 @@ class SearchResponse(BaseModel):
|
||||
class VectorUpdateRequest(BaseModel):
|
||||
"""Request to update vectors from a wiki page."""
|
||||
page_id: int = Field(..., description="Wiki page ID to process")
|
||||
user: str = Field(
|
||||
default="jpmschweitzer",
|
||||
description="User identifier for namespace scoping"
|
||||
user: RequiredUser = Field(
|
||||
...,
|
||||
description="User identifier (tenant). Required — vectors are written to this tenant's collection."
|
||||
)
|
||||
force_refresh: bool = Field(
|
||||
default=False,
|
||||
@@ -65,6 +67,8 @@ class VectorUpdateSummary(BaseModel):
|
||||
chunks_created: int = Field(default=0, description="New chunks created")
|
||||
chunks_updated: int = Field(default=0, description="Existing chunks updated")
|
||||
chunks_deleted: int = Field(default=0, description="Old chunks deleted")
|
||||
chunks_skipped: int = Field(default=0, description="Chunks skipped (embedding failed)")
|
||||
status: str = Field(default="success", description="'success', 'partial' (some chunks skipped), or 'failed'")
|
||||
total_chunks: int = Field(default=0, description="Total chunks for this page")
|
||||
embedding_dim: int = Field(default=768, description="Embedding dimensionality")
|
||||
processing_time_ms: float = Field(..., description="Processing time in milliseconds")
|
||||
@@ -89,7 +93,7 @@ class CollectionListResponse(BaseModel):
|
||||
class DeletePageChunksRequest(BaseModel):
|
||||
"""Request to delete all chunks for a page."""
|
||||
page_id: int = Field(..., description="Wiki page ID")
|
||||
user: str = Field(default="jpmschweitzer", description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — deletion is scoped to this tenant's collection.")
|
||||
|
||||
|
||||
class DeletePageChunksResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Volatile memory models for Library Desk.
|
||||
|
||||
Provides models for ephemeral cached data with TTL - weather, news, financial data,
|
||||
transit schedules, and other time-sensitive external information.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class VolatileNamespace(str, Enum):
|
||||
"""
|
||||
Predefined namespaces for volatile data.
|
||||
|
||||
Each namespace can have different default TTLs and refresh schedules.
|
||||
"""
|
||||
# Real-time external data
|
||||
WEATHER = "weather" # Current conditions (temperature, humidity, wind)
|
||||
FORECAST = "forecast" # Multi-day weather outlook
|
||||
SUN = "sun" # Sunrise, sunset, daylight duration
|
||||
NEWS = "news" # Headlines, breaking news
|
||||
FINANCIAL = "financial" # Stock prices, exchange rates, crypto
|
||||
TRANSIT = "transit" # Train/bus schedules, delays, disruptions
|
||||
TRAFFIC = "traffic" # Commute times, road conditions
|
||||
AIR_QUALITY = "air_quality" # Pollution levels, pollen counts
|
||||
SPORTS = "sports" # Live scores, upcoming matches
|
||||
|
||||
# System/integration data
|
||||
SOCIAL = "social" # Social media mentions, notifications
|
||||
SYSTEM = "system" # Service health, infrastructure status
|
||||
|
||||
# Ephemeral context
|
||||
CONTEXT = "context" # Conversation context, session state
|
||||
CUSTOM = "custom" # User-defined volatile data
|
||||
|
||||
|
||||
# Default TTLs per namespace (in seconds)
|
||||
# TTL = 2x refresh interval to ensure data survives missed/delayed refreshes
|
||||
NAMESPACE_DEFAULT_TTL: Dict[str, int] = {
|
||||
VolatileNamespace.WEATHER: 7200, # 2 hours (hourly refresh)
|
||||
VolatileNamespace.FORECAST: 86400, # 24 hours (12hr refresh)
|
||||
VolatileNamespace.SUN: 172800, # 48 hours (daily refresh)
|
||||
VolatileNamespace.NEWS: 7200, # 2 hours (hourly refresh)
|
||||
VolatileNamespace.FINANCIAL: 600, # 10 min (5 min refresh)
|
||||
VolatileNamespace.TRANSIT: 600, # 10 min (5 min refresh)
|
||||
VolatileNamespace.TRAFFIC: 1200, # 20 min (10 min refresh)
|
||||
VolatileNamespace.AIR_QUALITY: 7200, # 2 hours (hourly refresh)
|
||||
VolatileNamespace.SPORTS: 120, # 2 min (1 min refresh)
|
||||
VolatileNamespace.SOCIAL: 1200, # 20 min (10 min refresh)
|
||||
VolatileNamespace.SYSTEM: 120, # 2 min (1 min refresh)
|
||||
VolatileNamespace.CONTEXT: 7200, # 2 hours - session context
|
||||
VolatileNamespace.CUSTOM: 7200, # 2 hours - default for custom
|
||||
}
|
||||
|
||||
|
||||
class VolatileRecord(BaseModel):
|
||||
"""
|
||||
A volatile cache record with TTL.
|
||||
|
||||
Volatile records are ephemeral data stored in Redis with automatic expiration.
|
||||
Used for weather, news, financial data, and other time-sensitive information.
|
||||
"""
|
||||
key: str = Field(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')")
|
||||
namespace: str = Field(..., description="Namespace (e.g., 'weather', 'news', 'financial')")
|
||||
data: Dict[str, Any] = Field(..., description="Actual content/payload")
|
||||
source: Optional[str] = Field(None, description="Origin API/service (e.g., 'openweathermap', 'nos.nl')")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="When record was created")
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, description="When record was last updated")
|
||||
ttl: int = Field(..., ge=60, le=604800, description="Time-to-live in seconds (max 7 days)")
|
||||
refresh_schedule: Optional[str] = Field(None, description="Cron expression for scheduled refresh")
|
||||
user: str = Field(..., description="User identifier for multi-tenancy")
|
||||
|
||||
|
||||
class VolatileRecordCreate(BaseModel):
|
||||
"""Request model for creating/updating a volatile record."""
|
||||
data: Dict[str, Any] = Field(..., description="Content to store")
|
||||
source: Optional[str] = Field(None, description="Origin API/service")
|
||||
ttl: Optional[int] = Field(None, ge=60, le=604800, description="TTL in seconds (uses namespace default if not set)")
|
||||
refresh_schedule: Optional[str] = Field(None, description="Cron expression for scheduled refresh")
|
||||
|
||||
|
||||
class VolatileRecordResponse(BaseModel):
|
||||
"""Response model for a volatile record."""
|
||||
key: str = Field(..., description="Record key")
|
||||
namespace: str = Field(..., description="Namespace")
|
||||
data: Dict[str, Any] = Field(..., description="Stored content")
|
||||
source: Optional[str] = Field(None, description="Origin API/service")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||
ttl: int = Field(..., description="TTL in seconds")
|
||||
ttl_remaining: int = Field(..., description="Seconds until expiration")
|
||||
refresh_schedule: Optional[str] = Field(None, description="Cron expression if scheduled")
|
||||
user: str = Field(..., description="User identifier")
|
||||
|
||||
|
||||
class VolatileListResponse(BaseModel):
|
||||
"""Response model for listing volatile records."""
|
||||
namespace: str = Field(..., description="Namespace queried")
|
||||
keys: List[str] = Field(..., description="List of keys in namespace")
|
||||
count: int = Field(..., description="Number of keys")
|
||||
user: str = Field(..., description="User identifier")
|
||||
|
||||
|
||||
class VolatileScheduledResponse(BaseModel):
|
||||
"""Response model for records needing refresh."""
|
||||
records: List[VolatileRecordResponse] = Field(..., description="Records with refresh schedules")
|
||||
count: int = Field(..., description="Number of scheduled records")
|
||||
user: str = Field(..., description="User identifier")
|
||||
|
||||
|
||||
class VolatileStatsResponse(BaseModel):
|
||||
"""Response model for volatile cache statistics."""
|
||||
total_records: int = Field(..., description="Total volatile records for user")
|
||||
by_namespace: Dict[str, int] = Field(..., description="Record count per namespace")
|
||||
scheduled_count: int = Field(..., description="Records with refresh schedules")
|
||||
total_memory_bytes: Optional[int] = Field(None, description="Approximate memory usage")
|
||||
user: str = Field(..., description="User identifier")
|
||||
|
||||
|
||||
class VolatileDeleteResponse(BaseModel):
|
||||
"""Response model for delete operation."""
|
||||
key: str = Field(..., description="Deleted key")
|
||||
namespace: str = Field(..., description="Namespace")
|
||||
deleted: bool = Field(..., description="Whether record was found and deleted")
|
||||
user: str = Field(..., description="User identifier")
|
||||
|
||||
|
||||
class VolatileBulkDeleteResponse(BaseModel):
|
||||
"""Response model for bulk delete operations."""
|
||||
namespace: Optional[str] = Field(None, description="Namespace if namespace-wide delete")
|
||||
deleted_count: int = Field(..., description="Number of records deleted")
|
||||
user: str = Field(..., description="User identifier")
|
||||
+5
-3
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from src.core.multi_tenancy import RequiredUser
|
||||
|
||||
|
||||
# Base models
|
||||
class WikiPageBase(BaseModel):
|
||||
@@ -35,7 +37,7 @@ class WikiPageCreate(WikiPageBase):
|
||||
content: str = Field(..., description="Page content (markdown)")
|
||||
path: str = Field(..., min_length=1, max_length=500, description="Page path (e.g., '/projects/library-desk')")
|
||||
editor: str = Field(default="markdown", description="Editor type")
|
||||
user: Optional[str] = Field(None, description="User identifier (defaults to configured user)")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — the page is created inside this tenant's namespace.")
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
@@ -106,7 +108,7 @@ class DossierCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200, description="Human-readable title")
|
||||
description: str = Field(..., min_length=1, description="Dossier description")
|
||||
create_index_page: bool = Field(default=True, description="Create an index page for the dossier")
|
||||
user: Optional[str] = Field(None, description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required.")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -198,7 +200,7 @@ class WikiSmartCreateRequest(BaseModel):
|
||||
topic: str = Field(..., min_length=1, max_length=500, description="Topic to research and create page about")
|
||||
path: Optional[str] = Field(None, description="Page path (auto-generated from topic if not provided)")
|
||||
tags: List[str] = Field(default_factory=list, description="Tags for the page")
|
||||
user: Optional[str] = Field(None, description="User identifier")
|
||||
user: RequiredUser = Field(..., description="User identifier (tenant). Required — research results and the created page are scoped to this tenant.")
|
||||
include_web_research: bool = Field(default=True, description="Include web search results")
|
||||
include_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge")
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ from src.models.consolidation import ConsolidationRequest, ConsolidationResponse
|
||||
from src.services.consolidation_service import ConsolidationService
|
||||
from src.core.dependencies import (
|
||||
Neo4jDep, OllamaDep, WikiJSDep,
|
||||
verify_api_key, get_settings, get_ingestion_service
|
||||
verify_api_key, get_settings, get_ingestion_service,
|
||||
get_volatile_cache_service, get_settings_client,
|
||||
)
|
||||
from src.config import Settings
|
||||
|
||||
@@ -34,7 +35,9 @@ def get_consolidation_service(
|
||||
ollama=ollama_client,
|
||||
wiki=wiki_client,
|
||||
settings=settings,
|
||||
ingestion_service=get_ingestion_service()
|
||||
ingestion_service=get_ingestion_service(),
|
||||
volatile_service=get_volatile_cache_service(),
|
||||
settings_client=get_settings_client(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Content extraction router for Library Desk API.
|
||||
|
||||
Endpoints for extracting main content from web URLs using Trafilatura.
|
||||
"""
|
||||
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import logging
|
||||
|
||||
from src.models.content import (
|
||||
ContentExtractionRequest,
|
||||
ContentExtractionResponse,
|
||||
BatchContentExtractionRequest,
|
||||
BatchContentExtractionResponse,
|
||||
)
|
||||
from src.clients.content_extractor import ContentExtractor
|
||||
from src.core.dependencies import verify_api_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/content", tags=["Content Extraction"])
|
||||
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def get_content_extractor() -> ContentExtractor:
|
||||
"""Get content extractor instance."""
|
||||
from src.core.dependencies import get_content_extractor as _get_extractor
|
||||
return _get_extractor()
|
||||
|
||||
|
||||
@router.post("/extract", response_model=ContentExtractionResponse)
|
||||
async def extract_content(
|
||||
request: ContentExtractionRequest,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Extract main content from a single URL.
|
||||
|
||||
Uses Trafilatura to fetch the URL and extract the main text content,
|
||||
removing navigation, ads, and other boilerplate.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"include_metadata": true,
|
||||
"max_length": 2000
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** Extracted content with optional metadata (title, author, date)
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
extractor = get_content_extractor()
|
||||
result = await extractor.extract(
|
||||
url=request.url,
|
||||
include_metadata=request.include_metadata,
|
||||
max_length=request.max_length
|
||||
)
|
||||
|
||||
extraction_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return ContentExtractionResponse(
|
||||
result=result,
|
||||
extraction_time_ms=extraction_time_ms
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Content extraction failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Content extraction failed")
|
||||
|
||||
|
||||
@router.post("/extract/batch", response_model=BatchContentExtractionResponse)
|
||||
async def extract_content_batch(
|
||||
request: BatchContentExtractionRequest,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Extract content from multiple URLs in parallel.
|
||||
|
||||
Processes up to 20 URLs concurrently with per-URL timeouts.
|
||||
Failed extractions are included in results with success=false.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"urls": [
|
||||
"https://example.com/article1",
|
||||
"https://example.com/article2"
|
||||
],
|
||||
"include_metadata": true,
|
||||
"max_length": 2000
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** List of extraction results with success/failure counts
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
if not request.urls:
|
||||
raise HTTPException(status_code=400, detail="URLs list cannot be empty")
|
||||
|
||||
try:
|
||||
extractor = get_content_extractor()
|
||||
results = await extractor.extract_batch(
|
||||
urls=request.urls,
|
||||
include_metadata=request.include_metadata,
|
||||
max_length=request.max_length
|
||||
)
|
||||
|
||||
extraction_time_ms = int((time.time() - start_time) * 1000)
|
||||
successful = sum(1 for r in results if r.success)
|
||||
failed = len(results) - successful
|
||||
|
||||
return BatchContentExtractionResponse(
|
||||
results=results,
|
||||
total_urls=len(request.urls),
|
||||
successful=successful,
|
||||
failed=failed,
|
||||
extraction_time_ms=extraction_time_ms
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Batch content extraction failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Batch extraction failed")
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Document storage router for Library Desk API.
|
||||
|
||||
Event-driven integration with Paperless-ngx:
|
||||
- Webhook receiver triggers indexing after Paperless virus scan passes
|
||||
- Upload endpoint sends files to Paperless for processing
|
||||
- Search across indexed documents
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Request
|
||||
from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
|
||||
from src.models.document import (
|
||||
PaperlessWebhookPayload,
|
||||
WebhookResponse,
|
||||
DocumentUploadRequest,
|
||||
DocumentUploadResponse,
|
||||
DocumentSearchRequest,
|
||||
DocumentSearchResponse,
|
||||
DocumentStoreHealth,
|
||||
)
|
||||
from src.core.dependencies import (
|
||||
verify_api_key,
|
||||
PaperlessDep,
|
||||
QdrantDep,
|
||||
OllamaDep,
|
||||
Neo4jDep,
|
||||
WikiJSDep,
|
||||
)
|
||||
from src.core.dependencies import RequiredUserQuery
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["Documents"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Endpoint (primary integration - event-driven)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/webhook", response_model=WebhookResponse)
|
||||
async def receive_webhook(
|
||||
payload: PaperlessWebhookPayload,
|
||||
paperless: PaperlessDep,
|
||||
qdrant: QdrantDep,
|
||||
ollama: OllamaDep,
|
||||
neo4j: Neo4jDep,
|
||||
wiki: WikiJSDep,
|
||||
user: RequiredUserQuery,
|
||||
):
|
||||
"""
|
||||
Receive webhook events from Paperless-ngx.
|
||||
|
||||
This is the primary integration point. Configure Paperless workflow:
|
||||
1. Trigger: Document Added (after consumption completes)
|
||||
2. Condition: Document passed virus scan (ClamAV in Paperless)
|
||||
3. Action: Webhook POST to this endpoint
|
||||
|
||||
Library Desk indexes the document into vectors and graph.
|
||||
"""
|
||||
from src.services.document_sync_service import DocumentSyncService
|
||||
|
||||
doc_id = payload.document_id
|
||||
logger.info(f"Webhook received: document_id={doc_id}, title={payload.title}")
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.document_store_enabled:
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="skipped",
|
||||
indexed=False,
|
||||
message="Document store is disabled"
|
||||
)
|
||||
|
||||
try:
|
||||
sync_service = DocumentSyncService(
|
||||
paperless_client=paperless,
|
||||
qdrant_client=qdrant,
|
||||
ollama_client=ollama,
|
||||
neo4j_client=neo4j,
|
||||
wiki_client=wiki,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
# Fetch content from Paperless (template only provides doc_url and title)
|
||||
result = await sync_service.index_document(
|
||||
document_id=doc_id,
|
||||
user=user,
|
||||
)
|
||||
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="indexed" if result.success else "failed",
|
||||
indexed=result.success,
|
||||
message=result.error if not result.success else f"Indexed: {result.title}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook processing failed for document {doc_id}: {e}", exc_info=True)
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="error",
|
||||
indexed=False,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Debug Capture Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/webhook-capture")
|
||||
async def capture_webhook(request: Request):
|
||||
"""Capture raw webhook payload for debugging."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Get raw body
|
||||
body = await request.body()
|
||||
headers = dict(request.headers)
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Build capture data
|
||||
capture = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"query_params": query_params,
|
||||
"headers": headers,
|
||||
"content_type": headers.get("content-type", "unknown"),
|
||||
"body_raw": body.decode("utf-8", errors="replace"),
|
||||
}
|
||||
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
capture["body_json"] = json.loads(body)
|
||||
except:
|
||||
capture["body_json"] = None
|
||||
|
||||
# Write to file
|
||||
capture_file = Path("logs/webhook_capture.json")
|
||||
capture_file.parent.mkdir(exist_ok=True)
|
||||
with open(capture_file, "w") as f:
|
||||
json.dump(capture, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"Captured webhook: {capture['body_raw'][:200]}")
|
||||
|
||||
return {"status": "captured", "file": str(capture_file)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Simple Webhook (URL parameters only)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/webhook-simple", response_model=WebhookResponse)
|
||||
async def receive_webhook_simple(
|
||||
user: RequiredUserQuery,
|
||||
doc_url: str = Query(..., description="Paperless document URL containing ID"),
|
||||
title: str = Query(default="", description="Document title"),
|
||||
paperless: PaperlessDep = None,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
neo4j: Neo4jDep = None,
|
||||
wiki: WikiJSDep = None,
|
||||
):
|
||||
"""
|
||||
Simple webhook endpoint accepting URL parameters.
|
||||
|
||||
Used when Paperless Jinja templates don't work with JSON body.
|
||||
URL format: /webhook-simple?doc_url=http://...&title=...&user=...
|
||||
"""
|
||||
from src.services.document_sync_service import DocumentSyncService
|
||||
import re
|
||||
|
||||
# Extract document ID from URL
|
||||
match = re.search(r'/documents/(\d+)/?', doc_url)
|
||||
if not match:
|
||||
return WebhookResponse(
|
||||
document_id=0,
|
||||
status="error",
|
||||
indexed=False,
|
||||
message=f"Cannot extract document ID from URL: {doc_url}"
|
||||
)
|
||||
doc_id = int(match.group(1))
|
||||
|
||||
logger.info(f"Webhook-simple received: document_id={doc_id}, title={title}")
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.document_store_enabled:
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="skipped",
|
||||
indexed=False,
|
||||
message="Document store is disabled"
|
||||
)
|
||||
|
||||
try:
|
||||
sync_service = DocumentSyncService(
|
||||
paperless_client=paperless,
|
||||
qdrant_client=qdrant,
|
||||
ollama_client=ollama,
|
||||
neo4j_client=neo4j,
|
||||
wiki_client=wiki,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
result = await sync_service.index_document(
|
||||
document_id=doc_id,
|
||||
user=user,
|
||||
)
|
||||
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="indexed" if result.success else "failed",
|
||||
indexed=result.success,
|
||||
message=result.error if not result.success else f"Indexed: {result.title}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Webhook-simple failed for document {doc_id}: {e}", exc_info=True)
|
||||
return WebhookResponse(
|
||||
document_id=doc_id,
|
||||
status="error",
|
||||
indexed=False,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Upload Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentUploadResponse)
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
title: Optional[str] = Query(None, description="Document title"),
|
||||
collection: Optional[str] = Query(None, description="Collection name"),
|
||||
paperless: PaperlessDep = None,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
):
|
||||
"""
|
||||
Upload a document to Paperless-ngx.
|
||||
|
||||
Paperless handles virus scanning. If clean, Paperless webhook
|
||||
triggers indexing back to Library Desk.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.document_store_enabled:
|
||||
raise HTTPException(status_code=503, detail="Document store is disabled")
|
||||
|
||||
content = await file.read()
|
||||
filename = file.filename or "document"
|
||||
|
||||
custom_fields = []
|
||||
if collection:
|
||||
custom_fields.append({"field": "collection", "value": collection})
|
||||
|
||||
try:
|
||||
task_id = await paperless.upload_document(
|
||||
file_content=content,
|
||||
filename=filename,
|
||||
title=title,
|
||||
custom_fields=custom_fields if custom_fields else None,
|
||||
)
|
||||
|
||||
return DocumentUploadResponse(
|
||||
task_id=task_id,
|
||||
filename=filename,
|
||||
message=f"Uploaded to Paperless, task {task_id}. Indexing via webhook after scan."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Upload failed for '{filename}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
|
||||
|
||||
|
||||
@router.post("/upload-url", response_model=DocumentUploadResponse)
|
||||
async def upload_from_url(
|
||||
request: DocumentUploadRequest,
|
||||
paperless: PaperlessDep = None,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
):
|
||||
"""
|
||||
Download document from URL and upload to Paperless-ngx.
|
||||
|
||||
Used by HybridRAG to save discovered PDFs. Paperless scans and
|
||||
webhooks back for indexing.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.document_store_enabled:
|
||||
raise HTTPException(status_code=503, detail="Document store is disabled")
|
||||
|
||||
if not request.url:
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(request.url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
content = response.content
|
||||
filename = request.url.split("/")[-1].split("?")[0] or "document"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed from {request.url}: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Download failed: {e}")
|
||||
|
||||
try:
|
||||
custom_fields = [{"field": "source_url", "value": request.url}]
|
||||
if request.collection:
|
||||
custom_fields.append({"field": "collection", "value": request.collection})
|
||||
|
||||
task_id = await paperless.upload_document(
|
||||
file_content=content,
|
||||
filename=filename,
|
||||
title=request.title,
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
return DocumentUploadResponse(
|
||||
task_id=task_id,
|
||||
filename=filename,
|
||||
message=f"Uploaded from URL, task {task_id}. Indexing via webhook after scan."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Upload failed for URL '{request.url}': {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Search
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.post("/search", response_model=DocumentSearchResponse)
|
||||
async def search_documents(
|
||||
request: DocumentSearchRequest,
|
||||
qdrant: QdrantDep,
|
||||
ollama: OllamaDep,
|
||||
user: RequiredUserQuery,
|
||||
api_key: str = Depends(verify_api_key),
|
||||
):
|
||||
"""
|
||||
Semantic search across indexed documents.
|
||||
"""
|
||||
from src.services.vector_service import VectorService
|
||||
from src.core.dependencies import get_wikijs_client
|
||||
from src.models.document import DocumentSearchHit
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.document_store_enabled:
|
||||
raise HTTPException(status_code=503, detail="Document store is disabled")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
wiki = get_wikijs_client()
|
||||
vector_service = VectorService(qdrant, wiki, ollama)
|
||||
|
||||
results = await vector_service.search(
|
||||
query=request.query,
|
||||
user=user,
|
||||
limit=request.limit,
|
||||
score_threshold=0.5,
|
||||
doc_type="document"
|
||||
)
|
||||
|
||||
hits = []
|
||||
for result in results.get("results", []):
|
||||
hits.append(DocumentSearchHit(
|
||||
paperless_id=result.get("metadata", {}).get("paperless_id", 0),
|
||||
title=result.get("title", ""),
|
||||
score=result.get("score", 0.0),
|
||||
highlights=result.get("chunk_text", "")[:200] if request.include_content else None,
|
||||
collection=result.get("metadata", {}).get("collection"),
|
||||
document_type=result.get("metadata", {}).get("document_type"),
|
||||
content_preview=result.get("chunk_text", "")[:500] if request.include_content else None,
|
||||
))
|
||||
|
||||
return DocumentSearchResponse(
|
||||
query=request.query,
|
||||
hits=hits,
|
||||
total=len(hits),
|
||||
duration_ms=int((time.time() - start_time) * 1000)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Document search failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Health
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/health", response_model=DocumentStoreHealth)
|
||||
async def document_store_health(paperless: PaperlessDep):
|
||||
"""Check Paperless-ngx connectivity."""
|
||||
settings = get_settings()
|
||||
|
||||
paperless_healthy = False
|
||||
if settings.paperless_token:
|
||||
try:
|
||||
paperless_healthy = await paperless.health_check()
|
||||
except Exception as e:
|
||||
logger.error(f"Paperless health check failed: {e}")
|
||||
|
||||
return DocumentStoreHealth(
|
||||
paperless_healthy=paperless_healthy,
|
||||
paperless_version="connected" if paperless_healthy else None,
|
||||
total_documents=None,
|
||||
indexed_documents=None
|
||||
)
|
||||
@@ -18,7 +18,7 @@ from src.core.dependencies import (
|
||||
get_wiki_service,
|
||||
get_graph_service,
|
||||
get_ingestion_service,
|
||||
verify_api_key
|
||||
verify_browser_request,
|
||||
)
|
||||
from src.services.wiki_service import WikiService
|
||||
from src.services.graph_service import GraphService
|
||||
@@ -57,7 +57,7 @@ async def link_entities_in_page(
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
actor: str = Depends(verify_browser_request)
|
||||
) -> EntityLinkingResult:
|
||||
"""
|
||||
Find and link entities mentioned in a wiki page.
|
||||
|
||||
+21
-19
@@ -15,10 +15,9 @@ from src.models.graph import (
|
||||
MindMapResponse
|
||||
)
|
||||
from src.services.graph_service import GraphService
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import (
|
||||
Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,21 +40,24 @@ async def execute_cypher_query(
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Execute a user-scoped Cypher query.
|
||||
Execute a raw Cypher query (ADMIN/DEBUG — read-only, NOT tenant-scoped).
|
||||
|
||||
The query is automatically scoped to the user's data for security.
|
||||
This prevents users from accessing other users' graph data.
|
||||
**Security model:**
|
||||
- Write clauses (CREATE/MERGE/DELETE/SET/REMOVE/DROP/DETACH/FOREACH/
|
||||
LOAD CSV) and CALL procedures are rejected with 400.
|
||||
- Execution happens in a read-only Neo4j session as a hard backstop.
|
||||
- Results are NOT restricted to the requesting user's tenant labels —
|
||||
scope your own patterns (e.g. match `User_<Tenant>_Document`).
|
||||
For tenant-scoped access use /graph/nodes instead.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"query": "MATCH (d:Document) RETURN d LIMIT 10",
|
||||
"query": "MATCH (d:User_Llm_Tester_Document:Document) RETURN d LIMIT 10",
|
||||
"parameters": {},
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** Query is automatically scoped with user label.
|
||||
"""
|
||||
try:
|
||||
return await graph_service.execute_query(
|
||||
@@ -72,7 +74,7 @@ async def execute_cypher_query(
|
||||
|
||||
@router.get("/nodes", response_model=NodeListResponse)
|
||||
async def list_nodes(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
node_type: Optional[str] = Query(default=None, description="Node type filter"),
|
||||
limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
@@ -83,7 +85,7 @@ async def list_nodes(
|
||||
|
||||
Optionally filter by node type (Document, Person, Project, Concept, etc.).
|
||||
|
||||
**Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50`
|
||||
**Example:** `/graph/nodes?user=<tenant>&node_type=Document&limit=50`
|
||||
"""
|
||||
try:
|
||||
return await graph_service.list_nodes(
|
||||
@@ -99,7 +101,7 @@ async def list_nodes(
|
||||
@router.get("/nodes/{node_id}", response_model=GraphNodeDetail)
|
||||
async def get_node(
|
||||
node_id: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -108,7 +110,7 @@ async def get_node(
|
||||
|
||||
Returns the node, its relationships, and connected nodes.
|
||||
|
||||
**Example:** `/graph/nodes/4:abc123def:0?user=jpmschweitzer`
|
||||
**Example:** `/graph/nodes/4:abc123def:0?user=<tenant>`
|
||||
"""
|
||||
try:
|
||||
node = await graph_service.get_node(node_id, user)
|
||||
@@ -125,7 +127,7 @@ async def get_node(
|
||||
@router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary)
|
||||
async def update_graph_from_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
force_refresh: bool = Query(default=False, description="Force re-extraction"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -145,7 +147,7 @@ async def update_graph_from_page(
|
||||
- Called manually by user/Librarian to refresh graph
|
||||
- Called by Scheduler for batch processing
|
||||
|
||||
**Example:** `POST /graph/update-from-page/4?user=jpmschweitzer`
|
||||
**Example:** `POST /graph/update-from-page/4?user=<tenant>`
|
||||
|
||||
**Returns:** Summary with nodes/relationships created and entities extracted
|
||||
"""
|
||||
@@ -173,8 +175,8 @@ async def update_graph_from_page(
|
||||
|
||||
@router.post("/mindmap", response_model=MindMapResponse)
|
||||
async def generate_mindmap(
|
||||
user: RequiredUserQuery,
|
||||
center_node_id: str = Query(..., description="Central node ID"),
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -207,7 +209,7 @@ async def generate_mindmap(
|
||||
|
||||
@router.post("/generate-entity-pages")
|
||||
async def generate_entity_pages(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"),
|
||||
entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
|
||||
+16
-45
@@ -1,85 +1,56 @@
|
||||
"""
|
||||
HybridRAG router for multi-source search API.
|
||||
|
||||
Provides endpoint for combining vector, graph, and web search
|
||||
Provides endpoint for combining vector, graph, volatile cache, and web search
|
||||
with RRF fusion and LLM re-ranking.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import logging
|
||||
|
||||
from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.dependencies import (
|
||||
Neo4jDep, WikiJSDep, QdrantDep, OllamaDep,
|
||||
SearXNGDep, verify_api_key, get_settings
|
||||
verify_api_key, get_hybrid_rag_service, RequiredUserQuery
|
||||
)
|
||||
from src.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/query", tags=["HybridRAG"])
|
||||
|
||||
|
||||
# Dependency to get HybridRAG service
|
||||
def get_hybrid_rag_service(
|
||||
neo4j_client: Neo4jDep,
|
||||
wiki_client: WikiJSDep,
|
||||
qdrant_client: QdrantDep,
|
||||
ollama_client: OllamaDep,
|
||||
searxng_client: SearXNGDep,
|
||||
settings: Settings = Depends(get_settings)
|
||||
) -> HybridRAGService:
|
||||
"""Get HybridRAG service instance with all dependencies."""
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
# Create component services
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
graph_service = GraphService(neo4j_client, wiki_client)
|
||||
|
||||
# Create HybridRAG service
|
||||
return HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
|
||||
@router.post("/hybrid", response_model=HybridRAGResponse)
|
||||
async def hybrid_search(
|
||||
request: HybridRAGRequest,
|
||||
user: str = Query(default="jpmschweitzer", description="User identifier for multi-tenancy"),
|
||||
user: RequiredUserQuery,
|
||||
hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Execute HybridRAG query combining vector, graph, and web search.
|
||||
Execute HybridRAG query combining vector, graph, volatile cache, and web search.
|
||||
|
||||
**6-Phase Pipeline:**
|
||||
1. **Query Enhancement**: Extract keywords/synonyms with LLM
|
||||
2. **Parallel Retrieval**: Search vector (Qdrant), graph (Neo4j), web (SearXNG)
|
||||
3. **RRF Fusion**: Merge results with Reciprocal Rank Fusion
|
||||
2. **Parallel Retrieval**: Search vector (Qdrant), graph (Neo4j), volatile cache, web (SearXNG)
|
||||
3. **RRF Fusion**: Merge results with Reciprocal Rank Fusion (volatile gets priority boost)
|
||||
4. **Enrichment**: Add related documents via shared entities
|
||||
5. **LLM Re-ranking**: Re-rank with mistral-nemo for relevance
|
||||
5. **LLM Re-ranking**: Re-rank with configured model for relevance
|
||||
6. **Context Formatting**: Format for LLM consumption
|
||||
7. **Persistence**: Store for Librarian knowledge consolidation
|
||||
|
||||
**Example Request:**
|
||||
**Multi-tenancy:** the `user` query parameter is REQUIRED — all retrieval
|
||||
legs and persistence are scoped to that tenant's namespaces.
|
||||
|
||||
**Example Request** (`POST /query/hybrid?user=<tenant>`):
|
||||
```json
|
||||
{
|
||||
"query": "How does Docker orchestration work with Kubernetes?",
|
||||
"user": "jpmschweitzer",
|
||||
"query": "What's the weather in Rotterdam?",
|
||||
"config": {
|
||||
"vector_limit": 10,
|
||||
"graph_limit": 10,
|
||||
"web_limit": 5,
|
||||
"volatile_limit": 5,
|
||||
"enable_volatile": true,
|
||||
"enable_reranking": true,
|
||||
"final_result_count": 10
|
||||
}
|
||||
@@ -87,7 +58,7 @@ async def hybrid_search(
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
- Ranked results from all sources
|
||||
- Ranked results from all sources (wiki, volatile, web)
|
||||
- Extracted keywords/synonyms
|
||||
- Related dossiers (via graph)
|
||||
- Formatted context for LLM
|
||||
|
||||
+104
-13
@@ -5,6 +5,7 @@ Endpoints for ingesting wiki pages into the knowledge base (vectors + graph).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from src.services.ingestion_service import IngestionService
|
||||
from src.models.ingestion import (
|
||||
@@ -13,16 +14,59 @@ from src.models.ingestion import (
|
||||
BatchIngestionRequest,
|
||||
BatchIngestionResult
|
||||
)
|
||||
from src.core.dependencies import get_ingestion_service, verify_api_key
|
||||
from src.core.dependencies import (
|
||||
get_ingestion_service, verify_api_key, verify_browser_request,
|
||||
RequiredUserQuery, JobManagerDep
|
||||
)
|
||||
from src.jobs.job_manager import JobManager, JobStatus, JobType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ingest", tags=["Document Ingestion"])
|
||||
|
||||
|
||||
async def _track_job(
|
||||
job_manager: JobManager,
|
||||
job_type: JobType,
|
||||
user: str,
|
||||
parameters: dict
|
||||
) -> Optional[str]:
|
||||
"""Create a Redis job record; never fail the request over job tracking."""
|
||||
try:
|
||||
return await job_manager.create_job(job_type, user, parameters)
|
||||
except Exception as e:
|
||||
logger.warning(f"Job tracking unavailable ({job_type.value}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _finish_job(
|
||||
job_manager: JobManager,
|
||||
job_id: Optional[str],
|
||||
success: bool,
|
||||
result: dict,
|
||||
error: Optional[str] = None
|
||||
) -> None:
|
||||
"""Mark a tracked job completed/failed; never fail the request."""
|
||||
if not job_id:
|
||||
return
|
||||
try:
|
||||
await job_manager.update_job_status(
|
||||
job_id,
|
||||
JobStatus.COMPLETED if success else JobStatus.FAILED,
|
||||
progress=100,
|
||||
result=result,
|
||||
error=error
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Job tracking update failed for {job_id}: {e}")
|
||||
|
||||
|
||||
@router.post("/page", response_model=IngestionResult)
|
||||
async def ingest_page(
|
||||
request: IngestionRequest,
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
job_manager: JobManagerDep = None,
|
||||
actor: str = Depends(verify_browser_request)
|
||||
):
|
||||
"""
|
||||
Ingest a single wiki page into the knowledge base.
|
||||
@@ -52,11 +96,16 @@ async def ingest_page(
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"page_id": 19,
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"force_refresh": false
|
||||
}'
|
||||
```
|
||||
"""
|
||||
job_id = await _track_job(
|
||||
job_manager, JobType.DOCUMENT_INGESTION, request.user,
|
||||
{"page_id": request.page_id, "force_refresh": request.force_refresh}
|
||||
)
|
||||
|
||||
result = await ingestion.ingest_page(
|
||||
page_id=request.page_id,
|
||||
user=request.user,
|
||||
@@ -64,6 +113,12 @@ async def ingest_page(
|
||||
skip_vectors=request.skip_vectors,
|
||||
skip_graph=request.skip_graph
|
||||
)
|
||||
result.job_id = job_id
|
||||
|
||||
await _finish_job(
|
||||
job_manager, job_id, result.success,
|
||||
result=result.model_dump(mode="json"), error=result.error
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
@@ -78,6 +133,7 @@ async def ingest_page(
|
||||
async def ingest_batch(
|
||||
request: BatchIngestionRequest,
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
@@ -104,11 +160,16 @@ async def ingest_batch(
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"page_ids": [19, 20, 21, 22],
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"max_concurrent": 3
|
||||
}'
|
||||
```
|
||||
"""
|
||||
job_id = await _track_job(
|
||||
job_manager, JobType.BATCH_INGESTION, request.user,
|
||||
{"page_ids": request.page_ids, "force_refresh": request.force_refresh}
|
||||
)
|
||||
|
||||
result = await ingestion.ingest_batch(
|
||||
page_ids=request.page_ids,
|
||||
user=request.user,
|
||||
@@ -117,17 +178,28 @@ async def ingest_batch(
|
||||
skip_graph=request.skip_graph,
|
||||
max_concurrent=request.max_concurrent
|
||||
)
|
||||
result.job_id = job_id
|
||||
|
||||
await _finish_job(
|
||||
job_manager, job_id, result.failed == 0,
|
||||
result={
|
||||
"total_pages": result.total_pages,
|
||||
"successful": result.successful,
|
||||
"failed": result.failed
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/all", response_model=BatchIngestionResult)
|
||||
async def ingest_all_pages(
|
||||
user: str = Query(default="jpmschweitzer", description="User identifier"),
|
||||
path_prefix: Optional[str] = Query(None, description="Path prefix filter (e.g., 'users/jpmschweitzer/tech')"),
|
||||
user: RequiredUserQuery,
|
||||
path_prefix: Optional[str] = Query(None, description="Path prefix filter within the user's namespace (e.g., 'users/<tenant>/tech')"),
|
||||
force_refresh: bool = Query(False, description="Force re-ingestion of all pages"),
|
||||
max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"),
|
||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||
job_manager: JobManagerDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
@@ -151,19 +223,38 @@ async def ingest_all_pages(
|
||||
|
||||
```bash
|
||||
# Ingest all pages for user
|
||||
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer" \
|
||||
curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>" \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
|
||||
# Ingest only tech docs
|
||||
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer&path_prefix=users/jpmschweitzer/tech" \
|
||||
curl -X POST "http://192.168.86.149:8089/ingest/all?user=<tenant>&path_prefix=users/<tenant>/tech" \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
```
|
||||
"""
|
||||
result = await ingestion.ingest_all_pages(
|
||||
user=user,
|
||||
path_prefix=path_prefix,
|
||||
force_refresh=force_refresh,
|
||||
max_concurrent=max_concurrent
|
||||
job_id = await _track_job(
|
||||
job_manager, JobType.BATCH_INGESTION, user,
|
||||
{"path_prefix": path_prefix, "force_refresh": force_refresh, "scope": "all"}
|
||||
)
|
||||
|
||||
try:
|
||||
result = await ingestion.ingest_all_pages(
|
||||
user=user,
|
||||
path_prefix=path_prefix,
|
||||
force_refresh=force_refresh,
|
||||
max_concurrent=max_concurrent
|
||||
)
|
||||
except ValueError as e:
|
||||
await _finish_job(job_manager, job_id, False, result={}, error=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
result.job_id = job_id
|
||||
await _finish_job(
|
||||
job_manager, job_id, result.failed == 0,
|
||||
result={
|
||||
"total_pages": result.total_pages,
|
||||
"successful": result.successful,
|
||||
"failed": result.failed
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
RAG search router for Library Desk API.
|
||||
|
||||
Endpoints for web, news, and image search with content extraction.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
import logging
|
||||
|
||||
from src.models.rag_search import RAGSearchRequest, RAGSearchResponse
|
||||
from src.services.rag_search_service import RAGSearchService
|
||||
from src.core.dependencies import verify_api_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/rag", tags=["RAG Search"])
|
||||
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
def get_rag_search_service() -> RAGSearchService:
|
||||
"""Get RAG search service instance."""
|
||||
from src.core.dependencies import get_rag_search_service as _get_service
|
||||
return _get_service()
|
||||
|
||||
|
||||
@router.post("/search", response_model=RAGSearchResponse)
|
||||
async def search(
|
||||
request: RAGSearchRequest,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Execute RAG-optimized web search with content extraction.
|
||||
|
||||
Searches via SearXNG and extracts full content from results using
|
||||
Trafilatura. Results are cached in Redis for efficiency.
|
||||
|
||||
**Search Types:**
|
||||
- `web`: General web search (default)
|
||||
- `news`: News articles with recency filtering
|
||||
- `images`: Image search results
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
{
|
||||
"query": "Python async programming best practices",
|
||||
"search_type": "web",
|
||||
"limit": 10,
|
||||
"user": "default"
|
||||
}
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
- Full extracted text content per result
|
||||
- Original search snippets
|
||||
- Source domain names
|
||||
- Markdown sources summary for LLM consumption
|
||||
|
||||
**Error Codes:**
|
||||
- 400: Invalid query (empty or too long)
|
||||
- 502: Search provider (SearXNG) error
|
||||
- 504: Search timeout
|
||||
"""
|
||||
try:
|
||||
service = get_rag_search_service()
|
||||
response = await service.search(
|
||||
query=request.query,
|
||||
search_type=request.search_type,
|
||||
limit=request.limit,
|
||||
user=request.user
|
||||
)
|
||||
return response
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Search timed out for query: {request.query}")
|
||||
raise HTTPException(status_code=504, detail="Search timed out")
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Search provider error: {e}")
|
||||
raise HTTPException(status_code=502, detail="Search provider error")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RAG search failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Search failed")
|
||||
+18
-26
@@ -25,10 +25,9 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer",
|
||||
example="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True,
|
||||
example="llm_tester"
|
||||
),
|
||||
ToolParameter(
|
||||
name="tag",
|
||||
@@ -66,9 +65,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier for access control",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant) for access control. Required",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Complete page object with content",
|
||||
@@ -119,9 +117,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Created page object",
|
||||
@@ -130,7 +127,7 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
"path": "/projects/my-project",
|
||||
"content": "# My Project\n\nProject description here.",
|
||||
"tags": ["projects"],
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
},
|
||||
fast=True
|
||||
),
|
||||
@@ -175,9 +172,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Updated page object",
|
||||
@@ -200,9 +196,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="Success confirmation",
|
||||
@@ -225,9 +220,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
ToolParameter(
|
||||
name="limit",
|
||||
@@ -250,9 +244,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
],
|
||||
returns="List of dossiers with page counts",
|
||||
@@ -275,9 +268,8 @@ def get_wiki_tools() -> list[ToolDefinition]:
|
||||
ToolParameter(
|
||||
name="user",
|
||||
type=ParameterType.STRING,
|
||||
description="User identifier",
|
||||
required=False,
|
||||
default="jpmschweitzer"
|
||||
description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace",
|
||||
required=True
|
||||
),
|
||||
ToolParameter(
|
||||
name="limit",
|
||||
|
||||
@@ -18,8 +18,9 @@ from src.services.vector_service import VectorService
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.dependencies import QdrantDep, WikiJSDep, OllamaDep, verify_api_key
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.core.dependencies import (
|
||||
QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +53,7 @@ async def semantic_search(
|
||||
```json
|
||||
{
|
||||
"query": "how to configure docker",
|
||||
"user": "jpmschweitzer",
|
||||
"user": "<tenant>",
|
||||
"limit": 10,
|
||||
"score_threshold": 0.5
|
||||
}
|
||||
@@ -77,7 +78,7 @@ async def semantic_search(
|
||||
@router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary)
|
||||
async def update_vectors_from_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
force_refresh: bool = Query(default=False, description="Force re-embedding"),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -96,7 +97,7 @@ async def update_vectors_from_page(
|
||||
- Called manually by user/Librarian to refresh vectors
|
||||
- Called by Scheduler for batch processing
|
||||
|
||||
**Example:** `POST /vector/update-from-page/5?user=jpmschweitzer`
|
||||
**Example:** `POST /vector/update-from-page/5?user=<tenant> (user is REQUIRED)`
|
||||
|
||||
**Returns:** Summary with chunks created and processing time
|
||||
"""
|
||||
@@ -125,7 +126,7 @@ async def update_vectors_from_page(
|
||||
@router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse)
|
||||
async def delete_page_chunks(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -134,7 +135,7 @@ async def delete_page_chunks(
|
||||
|
||||
This is automatically called when a page is deleted from the wiki.
|
||||
|
||||
**Example:** `DELETE /vector/pages/5?user=jpmschweitzer`
|
||||
**Example:** `DELETE /vector/pages/5?user=<tenant> (user is REQUIRED)`
|
||||
"""
|
||||
try:
|
||||
deleted_count = await vector_service.delete_page_chunks(
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
Volatile cache router for Library Desk API.
|
||||
|
||||
Endpoints for ephemeral cached data with TTL - weather, news, financial, etc.
|
||||
Data is stored as vectors in Qdrant for semantic search retrieval.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
import logging
|
||||
|
||||
from src.models.volatile import (
|
||||
VolatileRecordCreate,
|
||||
VolatileRecordResponse,
|
||||
VolatileListResponse,
|
||||
VolatileScheduledResponse,
|
||||
VolatileStatsResponse,
|
||||
VolatileDeleteResponse,
|
||||
VolatileNamespace,
|
||||
NAMESPACE_DEFAULT_TTL,
|
||||
)
|
||||
from src.services.volatile_service import VolatileCacheService
|
||||
from src.services.volatile_fetch_service import VolatileFetchService
|
||||
from src.core.dependencies import (
|
||||
verify_api_key,
|
||||
QdrantDep,
|
||||
OllamaDep,
|
||||
get_weather_provider,
|
||||
get_news_provider,
|
||||
get_alphavantage_provider,
|
||||
)
|
||||
from src.core.dependencies import RequiredUserQuery
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/volatile", tags=["Volatile Cache"])
|
||||
|
||||
|
||||
def get_volatile_service(qdrant: QdrantDep, ollama: OllamaDep) -> VolatileCacheService:
|
||||
"""Get volatile cache service instance."""
|
||||
settings = get_settings()
|
||||
return VolatileCacheService(
|
||||
qdrant_client=qdrant,
|
||||
ollama_client=ollama,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=VolatileStatsResponse)
|
||||
async def get_stats(
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Get volatile cache statistics.
|
||||
|
||||
Returns counts of records by namespace and scheduled refresh info.
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
stats = await service.get_stats(user)
|
||||
|
||||
return VolatileStatsResponse(
|
||||
total_records=stats["total_records"],
|
||||
by_namespace=stats["by_namespace"],
|
||||
scheduled_count=stats["scheduled_count"],
|
||||
total_memory_bytes=None,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled", response_model=VolatileScheduledResponse)
|
||||
async def get_scheduled(
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Get records with refresh schedules.
|
||||
|
||||
Used by scheduler to determine what volatile data needs refreshing.
|
||||
Returns all records that have a refresh_schedule cron expression set.
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
records = await service.get_scheduled(user)
|
||||
|
||||
return VolatileScheduledResponse(
|
||||
records=records,
|
||||
count=len(records),
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/namespaces")
|
||||
async def list_namespaces(
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
List available namespaces and their default TTLs.
|
||||
|
||||
Returns predefined namespaces with their default TTL values.
|
||||
"""
|
||||
return {
|
||||
"namespaces": [
|
||||
{
|
||||
"name": ns.value,
|
||||
"default_ttl": NAMESPACE_DEFAULT_TTL.get(ns, 3600),
|
||||
"description": _get_namespace_description(ns),
|
||||
}
|
||||
for ns in VolatileNamespace
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_namespace_description(ns: VolatileNamespace) -> str:
|
||||
"""Get human-readable description for namespace."""
|
||||
descriptions = {
|
||||
VolatileNamespace.WEATHER: "Weather conditions and forecasts",
|
||||
VolatileNamespace.NEWS: "Headlines and breaking news",
|
||||
VolatileNamespace.FINANCIAL: "Stock prices, exchange rates, crypto",
|
||||
VolatileNamespace.TRANSIT: "Train/bus schedules, delays",
|
||||
VolatileNamespace.TRAFFIC: "Commute times, road conditions",
|
||||
VolatileNamespace.AIR_QUALITY: "Pollution levels, pollen counts",
|
||||
VolatileNamespace.SPORTS: "Live scores, upcoming matches",
|
||||
VolatileNamespace.SOCIAL: "Social media mentions, notifications",
|
||||
VolatileNamespace.SYSTEM: "Service health, infrastructure status",
|
||||
VolatileNamespace.CONTEXT: "Conversation context, session state",
|
||||
VolatileNamespace.CUSTOM: "User-defined volatile data",
|
||||
}
|
||||
return descriptions.get(ns, "Custom namespace")
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_volatile(
|
||||
user: RequiredUserQuery,
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
limit: int = Query(default=5, ge=1, le=20, description="Maximum results"),
|
||||
threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Semantic search across volatile data.
|
||||
|
||||
Searches all volatile data for semantically similar content.
|
||||
Higher threshold = stricter matching.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /volatile/search?q=weather%20rotterdam&user=jpmschweitzer
|
||||
```
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
results = await service.search(user, q, limit=limit, score_threshold=threshold)
|
||||
|
||||
return {
|
||||
"query": q,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/store", response_model=VolatileRecordResponse)
|
||||
async def store_volatile(
|
||||
user: RequiredUserQuery,
|
||||
namespace: str = Query(..., description="Data namespace (weather, news, etc.)"),
|
||||
key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"),
|
||||
request: VolatileRecordCreate = None,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Store volatile data.
|
||||
|
||||
Data is converted to natural language and embedded for semantic search.
|
||||
If the same namespace+key already exists, it will be updated.
|
||||
|
||||
**Example Request:**
|
||||
```json
|
||||
POST /volatile/store?namespace=weather&key=rotterdam
|
||||
{
|
||||
"data": {
|
||||
"temperature": 8,
|
||||
"conditions": "Cloudy",
|
||||
"humidity": 85
|
||||
},
|
||||
"source": "openweathermap",
|
||||
"ttl": 1800,
|
||||
"refresh_schedule": "0 * * * *"
|
||||
}
|
||||
```
|
||||
|
||||
**Refresh Schedule:**
|
||||
Optional cron expression for automatic refresh. The scheduler
|
||||
will query `/volatile/scheduled` and trigger refreshes.
|
||||
"""
|
||||
# Validate namespace if not custom
|
||||
if namespace != VolatileNamespace.CUSTOM:
|
||||
try:
|
||||
VolatileNamespace(namespace)
|
||||
except ValueError:
|
||||
valid = [ns.value for ns in VolatileNamespace]
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid namespace '{namespace}'. Valid: {valid}"
|
||||
)
|
||||
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
|
||||
try:
|
||||
record = await service.store(
|
||||
user=user,
|
||||
namespace=namespace,
|
||||
key=key,
|
||||
data=request.data,
|
||||
source=request.source,
|
||||
ttl=request.ttl,
|
||||
refresh_schedule=request.refresh_schedule,
|
||||
)
|
||||
return record
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store volatile record: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to store record: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/fetch/weather/{city}")
|
||||
async def fetch_weather(
|
||||
city: str,
|
||||
user: RequiredUserQuery,
|
||||
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch current weather conditions for a city and store in volatile cache.
|
||||
|
||||
Stores temperature, humidity, wind, UV index. For forecasts use /fetch/forecast.
|
||||
Called by scheduler for hourly prefetch or on-demand.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/weather/amsterdam?user=<tenant>
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_current_weather(user, city, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/forecast/{city}")
|
||||
async def fetch_forecast(
|
||||
city: str,
|
||||
user: RequiredUserQuery,
|
||||
days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"),
|
||||
ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch weather forecast for a city and store in volatile cache.
|
||||
|
||||
Stores multi-day outlook with highs/lows, precipitation, UV.
|
||||
For current conditions use /fetch/weather.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/forecast/amsterdam?user=<tenant>&days=7
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_forecast(user, city, days=days, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/news/{category}")
|
||||
async def fetch_news(
|
||||
user: RequiredUserQuery,
|
||||
category: str = "general",
|
||||
limit: int = Query(default=10, ge=1, le=50, description="Max headlines"),
|
||||
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch news headlines and store in volatile cache.
|
||||
|
||||
Fetches from configured news sources (NOS, BBC) based on user settings.
|
||||
Categories: general, world, tech, business, politics, etc.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/news/tech?user=<tenant>&limit=15
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
news_provider = await get_news_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
news_provider=news_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_news(user, category, limit=limit, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/stock/{symbol}")
|
||||
async def fetch_stock(
|
||||
symbol: str,
|
||||
user: RequiredUserQuery,
|
||||
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch stock quote and store in volatile cache.
|
||||
|
||||
Fetches from Alpha Vantage API. Requires API key configured in settings.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/stock/AAPL?user=<tenant>
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
financial_provider = await get_alphavantage_provider()
|
||||
|
||||
if not financial_provider:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Financial provider not configured (Alpha Vantage API key missing)"
|
||||
)
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
financial_provider=financial_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_stock(user, symbol, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/crypto/{symbol}")
|
||||
async def fetch_crypto(
|
||||
symbol: str,
|
||||
user: RequiredUserQuery,
|
||||
market: str = Query(default="USD", description="Market currency"),
|
||||
ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch cryptocurrency quote and store in volatile cache.
|
||||
|
||||
Fetches from Alpha Vantage API. Requires API key configured in settings.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/crypto/BTC?market=EUR&user=<tenant>
|
||||
```
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
financial_provider = await get_alphavantage_provider()
|
||||
|
||||
if not financial_provider:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Financial provider not configured (Alpha Vantage API key missing)"
|
||||
)
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
financial_provider=financial_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_crypto(user, symbol, market=market, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/sun/{city}")
|
||||
async def fetch_sun_times(
|
||||
city: str,
|
||||
user: RequiredUserQuery,
|
||||
ttl: int = Query(default=172800, ge=60, le=604800, description="TTL in seconds (default 48 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch sunrise/sunset times for a city and store in volatile cache.
|
||||
|
||||
Fetches from Open-Meteo API. Useful for home automation triggers.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/sun/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response data includes:**
|
||||
- sunrise/sunset times (both HH:MM and ISO formats)
|
||||
- daylight_duration_seconds
|
||||
- daylight_hours
|
||||
- Natural language text summary
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_sun_times(user, city, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/air_quality/{city}")
|
||||
async def fetch_air_quality(
|
||||
city: str,
|
||||
user: RequiredUserQuery,
|
||||
ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch air quality data for a city and store in volatile cache.
|
||||
|
||||
Fetches from Open-Meteo Air Quality API.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/air_quality/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response data includes:**
|
||||
- European and US AQI indices
|
||||
- Pollutants: PM2.5, PM10, ozone, nitrogen dioxide, etc.
|
||||
- Pollen data (European locations, seasonal)
|
||||
- Natural language text summary
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_air_quality(user, city, ttl=ttl)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail=result.error)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"namespace": result.namespace,
|
||||
"key": result.key,
|
||||
"record": result.record,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/fetch/environment/{city}")
|
||||
async def fetch_environment(
|
||||
city: str,
|
||||
user: RequiredUserQuery,
|
||||
weather_ttl: int = Query(default=7200, ge=60, le=86400, description="Weather TTL in seconds (default 2 hours)"),
|
||||
air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"),
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Fetch weather and air quality concurrently for a city.
|
||||
|
||||
Performs a single geocode lookup and fetches both weather and air quality
|
||||
data in parallel, storing both in volatile cache. More efficient than
|
||||
calling /fetch/weather and /fetch/air_quality separately.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
POST /volatile/fetch/environment/rotterdam?user=<tenant>
|
||||
```
|
||||
|
||||
**Response includes:**
|
||||
- weather: Current conditions (temperature, humidity, wind, UV)
|
||||
- air_quality: AQI indices, pollutants, pollen data
|
||||
"""
|
||||
volatile_service = get_volatile_service(qdrant, ollama)
|
||||
weather_provider = get_weather_provider()
|
||||
|
||||
fetch_service = VolatileFetchService(
|
||||
volatile_service=volatile_service,
|
||||
weather_provider=weather_provider,
|
||||
)
|
||||
|
||||
result = await fetch_service.fetch_environment(
|
||||
user, city, weather_ttl=weather_ttl, air_quality_ttl=air_quality_ttl
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=500, detail="; ".join(result.errors))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"key": result.key,
|
||||
"weather": {
|
||||
"success": result.weather.success if result.weather else False,
|
||||
"record": result.weather.record if result.weather else None,
|
||||
"error": result.weather.error if result.weather else None,
|
||||
},
|
||||
"air_quality": {
|
||||
"success": result.air_quality.success if result.air_quality else False,
|
||||
"record": result.air_quality.record if result.air_quality else None,
|
||||
"error": result.air_quality.error if result.air_quality else None,
|
||||
},
|
||||
"errors": result.errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{namespace}/{key}", response_model=VolatileRecordResponse)
|
||||
async def get_record(
|
||||
namespace: str,
|
||||
key: str,
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Get a specific volatile record by namespace and key.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
GET /volatile/weather/rotterdam?user=<tenant>
|
||||
```
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
record = await service.get(user, namespace, key)
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Record '{key}' not found in namespace '{namespace}'"
|
||||
)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@router.delete("/{namespace}/{key}", response_model=VolatileDeleteResponse)
|
||||
async def delete_record(
|
||||
namespace: str,
|
||||
key: str,
|
||||
user: RequiredUserQuery,
|
||||
qdrant: QdrantDep = None,
|
||||
ollama: OllamaDep = None,
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
"""
|
||||
Delete a specific volatile record.
|
||||
"""
|
||||
service = get_volatile_service(qdrant, ollama)
|
||||
deleted = await service.delete(user, namespace, key)
|
||||
|
||||
return VolatileDeleteResponse(
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
deleted=deleted,
|
||||
user=user,
|
||||
)
|
||||
@@ -320,7 +320,7 @@ async def process_page_rename(
|
||||
"""
|
||||
|
||||
try:
|
||||
await neo4j.execute_query(update_query, {
|
||||
await neo4j.execute_write(update_query, {
|
||||
"page_id": page_id,
|
||||
"new_path": new_path,
|
||||
"new_title": new_title
|
||||
@@ -441,7 +441,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(delete_doc_query, {"page_id": page_id})
|
||||
result = await neo4j.execute_write(delete_doc_query, {"page_id": page_id})
|
||||
deleted_count = result[0]["deleted_count"] if result else 0
|
||||
logger.info(f"Deleted {deleted_count} Document node(s) for page {page_id}")
|
||||
except Exception as e:
|
||||
@@ -462,7 +462,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(delete_entity_query, {"entity_id": entity_id})
|
||||
result = await neo4j.execute_write(delete_entity_query, {"entity_id": entity_id})
|
||||
deleted = result[0]["deleted_count"] if result else 0
|
||||
if deleted > 0:
|
||||
logger.info(f"Deleted orphaned entity: {entity_name}")
|
||||
@@ -478,7 +478,7 @@ async def cleanup_deleted_page(
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await neo4j.execute_query(cleanup_search_query, {})
|
||||
result = await neo4j.execute_write(cleanup_search_query, {})
|
||||
cleaned = result[0]["cleaned_count"] if result else 0
|
||||
if cleaned > 0:
|
||||
logger.info(f"Cleaned up {cleaned} broken SearchQuery relationships")
|
||||
|
||||
+22
-27
@@ -5,8 +5,7 @@ Endpoints for wiki page and dossier management.
|
||||
All operations are scoped to user namespaces for multi-tenancy.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Security, BackgroundTasks
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
@@ -24,11 +23,10 @@ from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.dependencies import (
|
||||
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep,
|
||||
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service
|
||||
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep,
|
||||
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service,
|
||||
RequiredUserQuery
|
||||
)
|
||||
from src.core.multi_tenancy import DEFAULT_USER
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.services.wiki_page_writer import WikiPageWriter
|
||||
from src.services.entity_linking_utils import apply_bidirectional_entity_linking
|
||||
from src.config import Settings
|
||||
@@ -63,7 +61,7 @@ def get_vector_service(
|
||||
# Page operations
|
||||
@router.get("/pages", response_model=WikiPageList)
|
||||
async def list_pages(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
@@ -88,7 +86,7 @@ async def list_pages(
|
||||
@router.get("/pages/{page_id}", response_model=WikiPage)
|
||||
async def get_page(
|
||||
page_id: int,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -140,14 +138,16 @@ async def create_page(
|
||||
"content": "# Architecture\\n\\nThis describes...",
|
||||
"description": "Architecture documentation",
|
||||
"tags": ["projects", "architecture"],
|
||||
"user": "jpmschweitzer"
|
||||
"user": "<tenant>"
|
||||
}
|
||||
```
|
||||
|
||||
The `user` field is REQUIRED (no default tenant).
|
||||
"""
|
||||
try:
|
||||
page = await wiki_service.create_page(page_data)
|
||||
|
||||
user = page_data.user or DEFAULT_USER
|
||||
user = page_data.user
|
||||
|
||||
# Schedule BOTH graph and vector updates in background (non-blocking)
|
||||
background_tasks.add_task(
|
||||
@@ -179,7 +179,6 @@ async def smart_create_page(
|
||||
neo4j_client: Neo4jDep,
|
||||
qdrant_client: QdrantDep,
|
||||
ollama_client: OllamaDep,
|
||||
searxng_client: SearXNGDep,
|
||||
settings: Settings = Depends(get_settings),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -214,20 +213,16 @@ async def smart_create_page(
|
||||
- Entity linking statistics (forward/backward links)
|
||||
"""
|
||||
try:
|
||||
user = request.user or DEFAULT_USER
|
||||
user = request.user
|
||||
|
||||
# Build services
|
||||
# Build services. HybridRAG comes from the single wiring point in
|
||||
# dependencies so it includes volatile_service (a previous inline
|
||||
# copy here lacked it).
|
||||
wiki_service = WikiService(wiki_client)
|
||||
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
|
||||
graph_service = GraphService(neo4j_client, wiki_client)
|
||||
hybrid_rag_service = HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
settings=settings
|
||||
)
|
||||
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client)
|
||||
hybrid_rag_service = get_hybrid_rag_service()
|
||||
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client, settings=settings)
|
||||
|
||||
# Step 1-5: Research + Generate + Create page
|
||||
page, research_data = await wiki_service.smart_create_page(
|
||||
@@ -293,7 +288,7 @@ async def update_page(
|
||||
page_id: int,
|
||||
page_data: WikiPageUpdate,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
@@ -352,7 +347,7 @@ async def update_page(
|
||||
async def delete_page(
|
||||
page_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
vector_service: VectorService = Depends(get_vector_service),
|
||||
graph_service: GraphService = Depends(get_graph_service),
|
||||
@@ -401,7 +396,7 @@ async def delete_page(
|
||||
async def move_page(
|
||||
page_id: int,
|
||||
move_data: WikiPageMove,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -444,8 +439,8 @@ async def move_page(
|
||||
# Search operations
|
||||
@router.get("/search", response_model=WikiSearchResponse)
|
||||
async def search_pages(
|
||||
user: RequiredUserQuery,
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
limit: int = Query(default=20, ge=1, le=100, description="Maximum results"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
@@ -486,7 +481,7 @@ async def search_pages(
|
||||
# Dossier operations
|
||||
@router.get("/dossiers", response_model=DossierList)
|
||||
async def list_dossiers(
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
):
|
||||
@@ -509,7 +504,7 @@ async def list_dossiers(
|
||||
@router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList)
|
||||
async def get_dossier_pages(
|
||||
dossier_name: str,
|
||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
||||
user: RequiredUserQuery,
|
||||
limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"),
|
||||
wiki_service: WikiService = Depends(get_wiki_service),
|
||||
api_key: str = Depends(verify_api_key)
|
||||
|
||||
@@ -13,7 +13,8 @@ This service:
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
@@ -23,13 +24,27 @@ from src.services.wiki_page_writer import WikiPageWriter
|
||||
from src.models.consolidation import (
|
||||
SearchQueryInfo,
|
||||
ConsolidationResult,
|
||||
ConsolidationResponse
|
||||
ConsolidationResponse,
|
||||
MemoryRouteClassification,
|
||||
MemoryRoutingResult,
|
||||
)
|
||||
from src.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsolidationLLMUnavailableError(Exception):
|
||||
"""
|
||||
The generation LLM produced no output (infrastructure failure).
|
||||
|
||||
Raised instead of silently returning an empty classification so the
|
||||
caller can leave the affected SearchQuery nodes UNPROCESSED for the
|
||||
next run. Marking them processed on LLM failure permanently drains
|
||||
the consolidation queue with zero output (the exact failure mode that
|
||||
made every run log 'No unprocessed searches found' in production).
|
||||
"""
|
||||
|
||||
|
||||
class ConsolidationService:
|
||||
"""
|
||||
Service for consolidating knowledge from search results.
|
||||
@@ -41,14 +56,20 @@ class ConsolidationService:
|
||||
ollama: OllamaClient,
|
||||
wiki: WikiJSClient,
|
||||
settings: Settings,
|
||||
ingestion_service: Optional["IngestionService"] = None
|
||||
ingestion_service: Optional["IngestionService"] = None,
|
||||
volatile_service: Optional["VolatileCacheService"] = None,
|
||||
settings_client: Optional["SettingsClient"] = None,
|
||||
scheduler_client: Optional["SchedulerClient"] = None,
|
||||
):
|
||||
self.neo4j = neo4j
|
||||
self.ollama = ollama
|
||||
self.wiki = wiki
|
||||
self.settings = settings
|
||||
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama)
|
||||
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama, settings=settings)
|
||||
self.ingestion_service = ingestion_service # Optional to avoid circular dependency
|
||||
self.volatile_service = volatile_service # For ephemeral data caching
|
||||
self.settings_client = settings_client # For prefetch registration (fallback)
|
||||
self.scheduler_client = scheduler_client # For scheduler-driven prefetch
|
||||
|
||||
async def consolidate_knowledge(
|
||||
self,
|
||||
@@ -69,7 +90,8 @@ class ConsolidationService:
|
||||
Returns:
|
||||
ConsolidationResponse with processing results
|
||||
"""
|
||||
logger.info(f"Starting knowledge consolidation")
|
||||
run_start = time.time()
|
||||
logger.info("Starting knowledge consolidation")
|
||||
logger.info(f"Limits: process={process_limit}, lookback={lookback_days}d, min_web={min_web_results}")
|
||||
if dry_run:
|
||||
logger.warning("DRY RUN MODE - will not create wiki pages")
|
||||
@@ -78,7 +100,12 @@ class ConsolidationService:
|
||||
unprocessed = await self._find_unprocessed_searches(lookback_days, process_limit)
|
||||
|
||||
if not unprocessed:
|
||||
logger.info("No unprocessed searches found")
|
||||
duration_ms = (time.time() - run_start) * 1000
|
||||
logger.info(
|
||||
f"Consolidation run complete: searches_processed=0 "
|
||||
f"searches_deferred=0 duration_ms={duration_ms:.0f} "
|
||||
f"(no unprocessed searches found)"
|
||||
)
|
||||
return ConsolidationResponse(
|
||||
total_found=0,
|
||||
processed_count=0,
|
||||
@@ -87,7 +114,8 @@ class ConsolidationService:
|
||||
entities_added=0,
|
||||
errors=[],
|
||||
results=[],
|
||||
dry_run=dry_run
|
||||
dry_run=dry_run,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(unprocessed)} unprocessed searches")
|
||||
@@ -97,9 +125,13 @@ class ConsolidationService:
|
||||
total_pages_created = 0
|
||||
total_pages_updated = 0
|
||||
total_entities_added = 0
|
||||
total_volatile_cached = 0
|
||||
total_files_queued = 0
|
||||
total_prefetch_registered = 0
|
||||
searches_deferred = 0
|
||||
errors: List[str] = []
|
||||
|
||||
for search in unprocessed:
|
||||
for index, search in enumerate(unprocessed):
|
||||
try:
|
||||
result = await self._process_search(
|
||||
search=search,
|
||||
@@ -112,12 +144,31 @@ class ConsolidationService:
|
||||
total_pages_created += result.pages_created
|
||||
total_pages_updated += result.pages_updated
|
||||
total_entities_added += result.entities_added
|
||||
total_volatile_cached += result.volatile_cached
|
||||
total_files_queued += result.files_queued
|
||||
total_prefetch_registered += result.prefetch_registered
|
||||
|
||||
# Mark as processed if not dry run (even if skipped)
|
||||
# This prevents searches from accumulating when they don't meet criteria
|
||||
if not dry_run:
|
||||
await self._mark_search_processed(search['id'])
|
||||
|
||||
except ConsolidationLLMUnavailableError as e:
|
||||
# Infrastructure failure: the generation LLM is unavailable.
|
||||
# Do NOT consume the search - leave it (and the rest of this
|
||||
# batch) unprocessed so the next run retries. Consuming
|
||||
# searches here is what silently drained the queue in
|
||||
# production ('No unprocessed searches found' with zero
|
||||
# pages ever created).
|
||||
searches_deferred = len(unprocessed) - index
|
||||
error_msg = (
|
||||
f"Generation LLM unavailable ({e}); deferring "
|
||||
f"{searches_deferred} search(es) to the next run"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Search {search['id'][:8]}: {str(e)}"
|
||||
logger.error(f"Failed to process search: {error_msg}", exc_info=True)
|
||||
@@ -134,6 +185,7 @@ class ConsolidationService:
|
||||
|
||||
# Build response
|
||||
processed_count = len([r for r in results if not r.error])
|
||||
duration_ms = (time.time() - run_start) * 1000
|
||||
|
||||
response = ConsolidationResponse(
|
||||
total_found=len(unprocessed),
|
||||
@@ -141,15 +193,22 @@ class ConsolidationService:
|
||||
pages_created=total_pages_created,
|
||||
pages_updated=total_pages_updated,
|
||||
entities_added=total_entities_added,
|
||||
volatile_cached=total_volatile_cached,
|
||||
files_queued=total_files_queued,
|
||||
prefetch_registered=total_prefetch_registered,
|
||||
searches_deferred=searches_deferred,
|
||||
errors=errors,
|
||||
results=results,
|
||||
dry_run=dry_run
|
||||
dry_run=dry_run,
|
||||
duration_ms=duration_ms
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Consolidation complete: {processed_count}/{len(unprocessed)} searches, "
|
||||
f"Consolidation run complete: searches_processed={processed_count} "
|
||||
f"searches_deferred={searches_deferred} duration_ms={duration_ms:.0f} | "
|
||||
f"{total_pages_created} pages created, {total_pages_updated} updated, "
|
||||
f"{total_entities_added} entities added"
|
||||
f"{total_entities_added} entities, {total_volatile_cached} volatile, "
|
||||
f"{total_files_queued} files, {total_prefetch_registered} prefetch"
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -162,7 +221,9 @@ class ConsolidationService:
|
||||
"""
|
||||
Find unprocessed SearchQuery nodes from Neo4j.
|
||||
"""
|
||||
lookback_date = datetime.now() - timedelta(days=lookback_days)
|
||||
# UTC-aware: sq.timestamp is stored via Neo4j datetime() (UTC), and a
|
||||
# naive local isoformat would be misread as UTC by datetime($param).
|
||||
lookback_date = datetime.now(timezone.utc) - timedelta(days=lookback_days)
|
||||
|
||||
query = """
|
||||
MATCH (sq:SearchQuery {processed: false})
|
||||
@@ -213,6 +274,13 @@ class ConsolidationService:
|
||||
) -> Optional[ConsolidationResult]:
|
||||
"""
|
||||
Process a single search query for knowledge consolidation.
|
||||
|
||||
Uses unified memory routing to classify each web result and route to:
|
||||
- wiki: Stable reference content → wiki page creation/update
|
||||
- volatile: Ephemeral data → volatile cache
|
||||
- file: Downloadable documents → Paperless queue
|
||||
- prefetch: Regular updates → scheduler registration
|
||||
- skip: Low value content → discard
|
||||
"""
|
||||
search_id = search['id']
|
||||
query = search['query']
|
||||
@@ -234,97 +302,106 @@ class ConsolidationService:
|
||||
|
||||
logger.info(f"Retrieved {len(web_results)} web results")
|
||||
|
||||
# Analyze web results with Ollama for novel information
|
||||
analysis = await self._analyze_web_results(
|
||||
# Unified classification of all web results
|
||||
routing_result = await self._classify_web_results_unified(
|
||||
query=query,
|
||||
web_results=web_results,
|
||||
keywords=search.get('keywords', []),
|
||||
user=user
|
||||
)
|
||||
|
||||
if not analysis or not analysis.get('has_novel_info'):
|
||||
logger.info("No novel information found")
|
||||
if not routing_result.classifications:
|
||||
logger.info("No classifications returned")
|
||||
return ConsolidationResult(
|
||||
search_id=search_id,
|
||||
query=query
|
||||
)
|
||||
|
||||
# Extract consolidation actions
|
||||
pages_to_create = analysis.get('new_pages', [])
|
||||
pages_to_update = analysis.get('update_pages', [])
|
||||
new_entities = analysis.get('new_entities', [])
|
||||
|
||||
logger.info(
|
||||
f"Analysis: {len(pages_to_create)} new pages, "
|
||||
f"{len(pages_to_update)} updates, {len(new_entities)} entities"
|
||||
f"Routing: {routing_result.wiki_routed} wiki, "
|
||||
f"{routing_result.volatile_cached} volatile, "
|
||||
f"{routing_result.files_queued} files, "
|
||||
f"{routing_result.prefetch_registered} prefetch, "
|
||||
f"{routing_result.skipped} skipped"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logger.info("[DRY RUN] Would create/update pages and entities")
|
||||
logger.info("[DRY RUN] Would route results to destinations")
|
||||
return ConsolidationResult(
|
||||
search_id=search_id,
|
||||
query=query,
|
||||
pages_created=len(pages_to_create),
|
||||
pages_updated=len(pages_to_update),
|
||||
entities_added=len(new_entities)
|
||||
pages_created=routing_result.wiki_routed,
|
||||
volatile_cached=routing_result.volatile_cached,
|
||||
files_queued=routing_result.files_queued,
|
||||
prefetch_registered=routing_result.prefetch_registered,
|
||||
)
|
||||
|
||||
# Create/update wiki pages
|
||||
# Process each classification
|
||||
pages_created = 0
|
||||
pages_updated = 0
|
||||
entities_added = 0
|
||||
volatile_cached = 0
|
||||
files_queued = 0
|
||||
prefetch_registered = 0
|
||||
|
||||
# Create new pages
|
||||
for page_data in pages_to_create:
|
||||
try:
|
||||
await self._create_or_consolidate_page(
|
||||
user=user,
|
||||
title=page_data.get('title'),
|
||||
path=page_data.get('path'),
|
||||
summary=page_data.get('summary'),
|
||||
source_query=query,
|
||||
web_results=web_results
|
||||
)
|
||||
pages_created += 1
|
||||
logger.info(f"Created page: {page_data.get('title')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create page {page_data.get('title')}: {e}")
|
||||
# Create URL-to-web_result lookup
|
||||
url_to_result = {r['url']: r for r in web_results}
|
||||
|
||||
# Update existing pages
|
||||
for page_data in pages_to_update:
|
||||
try:
|
||||
await self._update_page_with_facts(
|
||||
title=page_data.get('title'),
|
||||
new_facts=page_data.get('new_facts', []),
|
||||
source_url=page_data.get('source_url'),
|
||||
user=user
|
||||
)
|
||||
pages_updated += 1
|
||||
logger.info(f"Updated page: {page_data.get('title')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update page {page_data.get('title')}: {e}")
|
||||
for classification in routing_result.classifications:
|
||||
web_result = url_to_result.get(classification.url, {})
|
||||
|
||||
# Add new entities to graph
|
||||
for entity_data in new_entities:
|
||||
try:
|
||||
await self._add_entity_to_graph(
|
||||
user=user,
|
||||
entity_name=entity_data.get('name'),
|
||||
entity_type=entity_data.get('type'),
|
||||
description=entity_data.get('description'),
|
||||
source_search_id=search_id
|
||||
)
|
||||
entities_added += 1
|
||||
logger.info(f"Added entity: {entity_data.get('name')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add entity {entity_data.get('name')}: {e}")
|
||||
if classification.route_type == 'wiki':
|
||||
# Route to wiki page creation/update
|
||||
try:
|
||||
if classification.wiki_action == 'create':
|
||||
await self._create_or_consolidate_page(
|
||||
user=user,
|
||||
title=classification.title,
|
||||
path=classification.wiki_path or f"reference/{classification.title.lower().replace(' ', '-')}",
|
||||
summary=classification.wiki_summary or '',
|
||||
source_query=query,
|
||||
web_results=[web_result] if web_result else web_results[:3]
|
||||
)
|
||||
pages_created += 1
|
||||
logger.info(f"Created wiki page: {classification.title}")
|
||||
elif classification.wiki_action == 'update':
|
||||
await self._update_page_with_facts(
|
||||
title=classification.title,
|
||||
new_facts=[classification.wiki_summary] if classification.wiki_summary else [],
|
||||
source_url=classification.url,
|
||||
user=user
|
||||
)
|
||||
pages_updated += 1
|
||||
logger.info(f"Updated wiki page: {classification.title}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed wiki routing for {classification.title}: {e}")
|
||||
|
||||
elif classification.route_type == 'volatile':
|
||||
# Route to volatile cache
|
||||
if await self._route_to_volatile(classification, web_result, user):
|
||||
volatile_cached += 1
|
||||
|
||||
elif classification.route_type == 'file':
|
||||
# Route to Paperless queue
|
||||
if await self._route_to_files(classification, web_result, user):
|
||||
files_queued += 1
|
||||
|
||||
elif classification.route_type == 'prefetch':
|
||||
# Register prefetch pattern
|
||||
if await self._register_prefetch(classification, web_result, user):
|
||||
prefetch_registered += 1
|
||||
|
||||
# 'skip' route type - do nothing
|
||||
|
||||
return ConsolidationResult(
|
||||
search_id=search_id,
|
||||
query=query,
|
||||
pages_created=pages_created,
|
||||
pages_updated=pages_updated,
|
||||
entities_added=entities_added
|
||||
entities_added=entities_added,
|
||||
volatile_cached=volatile_cached,
|
||||
files_queued=files_queued,
|
||||
prefetch_registered=prefetch_registered,
|
||||
)
|
||||
|
||||
async def _get_web_results(self, search_id: str) -> List[Dict[str, Any]]:
|
||||
@@ -364,7 +441,7 @@ class ConsolidationService:
|
||||
query: str,
|
||||
web_results: List[Dict[str, Any]],
|
||||
keywords: List[str],
|
||||
user: str = "jpmschweitzer"
|
||||
user: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Analyze web results with Ollama for novel information.
|
||||
@@ -410,13 +487,19 @@ This is a PERSONAL knowledge base using Schema.org-aligned taxonomy that capture
|
||||
- Projects: Work projects, personal projects (Schema.org: Project)
|
||||
- Reference: General knowledge, how-tos (Custom extension)
|
||||
|
||||
Identify information worth documenting:
|
||||
1. New topics/people/things that deserve their own wiki page
|
||||
2. Facts that could enhance existing pages
|
||||
3. Entities (people, places, things, concepts) for the knowledge graph
|
||||
ANALYSIS STEPS:
|
||||
1. Read each web result carefully for substantive, factual content
|
||||
2. Identify genuinely novel information not likely already known
|
||||
3. Match topics to appropriate taxonomy categories
|
||||
4. Generate valid paths following the exact format below
|
||||
|
||||
Be INCLUSIVE - if someone searched for it, it's likely worth documenting.
|
||||
Personal information is just as valuable as technical information.
|
||||
RULES:
|
||||
- Do NOT suggest pages for topics with insufficient information in results
|
||||
- Do NOT invent entities not explicitly mentioned in results
|
||||
- Do NOT suggest paths that don't match the taxonomy exactly
|
||||
- Do NOT suggest generic or vague page topics
|
||||
- Be CONSERVATIVE - fewer high-quality suggestions is better than many low-quality ones
|
||||
- ONLY suggest documentation for substantive, specific information
|
||||
|
||||
**CRITICAL: Use ONLY these Schema.org-aligned path prefixes (case-sensitive):**
|
||||
|
||||
@@ -462,11 +545,12 @@ Return ONLY valid JSON:
|
||||
JSON:"""
|
||||
|
||||
try:
|
||||
# Call Ollama for analysis
|
||||
# Call Ollama for analysis (temperature=0.0 for consistent classification)
|
||||
response = await self.ollama.generate_text(
|
||||
prompt=prompt,
|
||||
model=self.settings.reranker_model, # Use mistral-nemo
|
||||
stream=False
|
||||
model=self.settings.ollama_llm_model,
|
||||
stream=False,
|
||||
temperature=0.0
|
||||
)
|
||||
|
||||
if not response:
|
||||
@@ -529,7 +613,7 @@ JSON:"""
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.neo4j.execute_query(query, {"search_id": search_id})
|
||||
await self.neo4j.execute_write(query, {"search_id": search_id})
|
||||
logger.debug(f"Marked search {search_id} as processed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to mark search as processed: {e}")
|
||||
@@ -922,7 +1006,7 @@ JSON:"""
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.neo4j.execute_query(query, {
|
||||
await self.neo4j.execute_write(query, {
|
||||
"name": entity_name,
|
||||
"description": description,
|
||||
"search_id": source_search_id
|
||||
@@ -930,3 +1014,356 @@ JSON:"""
|
||||
logger.debug(f"Added entity to graph: {entity_name} ({entity_type})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add entity to graph: {e}")
|
||||
|
||||
async def _classify_web_results_unified(
|
||||
self,
|
||||
query: str,
|
||||
web_results: List[Dict[str, Any]],
|
||||
keywords: List[str],
|
||||
user: str
|
||||
) -> MemoryRoutingResult:
|
||||
"""
|
||||
Unified classification of web results for memory routing.
|
||||
|
||||
Each web result is classified into exactly one destination:
|
||||
- wiki: Stable reference content → wiki page creation/update
|
||||
- volatile: Ephemeral data (weather, news, prices) → volatile cache
|
||||
- file: Downloadable file (PDF, doc, xls, images) → Paperless
|
||||
- prefetch: Regularly updated source → scheduler registration
|
||||
- skip: Low value, ads, errors → discard
|
||||
|
||||
Returns:
|
||||
MemoryRoutingResult with classifications for each web result
|
||||
"""
|
||||
# Fetch existing taxonomy structure for wiki path suggestions
|
||||
try:
|
||||
taxonomy_structure = await self.wiki.get_taxonomy_structure(f"users/{user}")
|
||||
existing_paths_info = self._format_taxonomy_for_prompt(taxonomy_structure)
|
||||
logger.info(f"Fetched taxonomy with {len(taxonomy_structure)} categories for user {user}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch taxonomy structure: {e}")
|
||||
existing_paths_info = ""
|
||||
|
||||
# Build classification prompt
|
||||
web_summary = "\n\n".join([
|
||||
f"[{i+1}] Title: {r['title']}\n URL: {r['url']}\n Content: {r['content'][:400]}..."
|
||||
for i, r in enumerate(web_results[:10])
|
||||
])
|
||||
|
||||
prompt = f"""You are a Memory Router for a personal knowledge system. Classify each web result into ONE destination.
|
||||
|
||||
Query: "{query}"
|
||||
Keywords: {', '.join(keywords) if keywords else 'none'}
|
||||
|
||||
Web Results:
|
||||
{web_summary}
|
||||
|
||||
CLASSIFICATION RULES:
|
||||
|
||||
**wiki** - Stable reference content worth documenting permanently:
|
||||
- Factual information about people, places, companies, products
|
||||
- How-to guides, tutorials, technical documentation
|
||||
- Historical facts, biographies, definitions
|
||||
- Content that won't change frequently
|
||||
|
||||
**volatile** - Ephemeral data that changes frequently:
|
||||
- Current weather conditions or forecasts
|
||||
- Latest news headlines or breaking news
|
||||
- Stock prices, exchange rates, crypto prices
|
||||
- Sports scores, live results
|
||||
- Traffic conditions, transit delays
|
||||
- Social media trends, notifications
|
||||
Use namespaces: weather, news, financial, transit, traffic, sports, social, system
|
||||
|
||||
**file** - Downloadable documents:
|
||||
- PDF files (URLs ending in .pdf or containing /pdf/)
|
||||
- Office documents (.doc, .docx, .xls, .xlsx, .ppt)
|
||||
- Images (.jpg, .png, .gif when they're primary content)
|
||||
- CSV/data files
|
||||
- Any direct download link
|
||||
|
||||
**prefetch** - Sources worth checking regularly:
|
||||
- News feeds or RSS sources
|
||||
- API endpoints with live data
|
||||
- Dashboards or status pages
|
||||
- Only if not already captured by volatile
|
||||
|
||||
**skip** - Low value content:
|
||||
- Ads, paywalled content
|
||||
- Error pages, 404s
|
||||
- Duplicate or redundant results
|
||||
- Content not answering the query
|
||||
|
||||
{existing_paths_info}
|
||||
|
||||
Return ONLY valid JSON array:
|
||||
[
|
||||
{{
|
||||
"url": "...",
|
||||
"title": "...",
|
||||
"route_type": "wiki|volatile|file|prefetch|skip",
|
||||
"wiki_action": "create|update",
|
||||
"wiki_path": "category/subcategory/page-name",
|
||||
"wiki_summary": "What to document",
|
||||
"volatile_namespace": "weather|news|financial|...",
|
||||
"volatile_key": "cache-key",
|
||||
"volatile_ttl_hours": 1,
|
||||
"prefetch_cron": "0 * * * *",
|
||||
"prefetch_endpoint": "/volatile/fetch/...",
|
||||
"confidence": 0.9,
|
||||
"reason": "Why this classification"
|
||||
}}
|
||||
]
|
||||
|
||||
Only include fields relevant to the route_type. Set irrelevant fields to null.
|
||||
|
||||
JSON:"""
|
||||
|
||||
try:
|
||||
response = await self.ollama.generate_text(
|
||||
prompt=prompt,
|
||||
model=self.settings.ollama_llm_model,
|
||||
stream=False,
|
||||
temperature=0.0
|
||||
)
|
||||
|
||||
# generate_text returns None on any transport/HTTP failure (e.g.
|
||||
# model missing, Ollama down) and Ollama never legitimately
|
||||
# returns an empty completion for this prompt: both mean the LLM
|
||||
# is unavailable, NOT that there is nothing to route. Raise so
|
||||
# the search is retried next run instead of being consumed.
|
||||
if not response:
|
||||
raise ConsolidationLLMUnavailableError(
|
||||
f"no output from generation model "
|
||||
f"'{self.settings.ollama_llm_model}' for classification"
|
||||
)
|
||||
|
||||
# Extract JSON array from response
|
||||
response_clean = response.strip()
|
||||
if '[' in response_clean:
|
||||
json_start = response_clean.find('[')
|
||||
json_end = response_clean.rfind(']') + 1
|
||||
response_clean = response_clean[json_start:json_end]
|
||||
|
||||
classifications_raw = json.loads(response_clean)
|
||||
|
||||
# Parse into MemoryRouteClassification objects
|
||||
result = MemoryRoutingResult()
|
||||
for item in classifications_raw:
|
||||
try:
|
||||
classification = MemoryRouteClassification(
|
||||
url=item.get('url', ''),
|
||||
title=item.get('title', ''),
|
||||
route_type=item.get('route_type', 'skip'),
|
||||
wiki_action=item.get('wiki_action'),
|
||||
wiki_path=item.get('wiki_path'),
|
||||
wiki_summary=item.get('wiki_summary'),
|
||||
volatile_namespace=item.get('volatile_namespace'),
|
||||
volatile_key=item.get('volatile_key'),
|
||||
volatile_ttl_hours=item.get('volatile_ttl_hours'),
|
||||
prefetch_cron=item.get('prefetch_cron'),
|
||||
prefetch_endpoint=item.get('prefetch_endpoint'),
|
||||
confidence=item.get('confidence', 0.5),
|
||||
reason=item.get('reason', ''),
|
||||
)
|
||||
result.classifications.append(classification)
|
||||
|
||||
# Count by route type
|
||||
if classification.route_type == 'wiki':
|
||||
result.wiki_routed += 1
|
||||
elif classification.route_type == 'volatile':
|
||||
result.volatile_cached += 1
|
||||
elif classification.route_type == 'file':
|
||||
result.files_queued += 1
|
||||
elif classification.route_type == 'prefetch':
|
||||
result.prefetch_registered += 1
|
||||
else:
|
||||
result.skipped += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse classification item: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Classification complete: {result.wiki_routed} wiki, "
|
||||
f"{result.volatile_cached} volatile, {result.files_queued} files, "
|
||||
f"{result.prefetch_registered} prefetch, {result.skipped} skipped"
|
||||
)
|
||||
return result
|
||||
|
||||
except ConsolidationLLMUnavailableError:
|
||||
# Infrastructure failure: propagate so the search is NOT consumed
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
# The model responded but with unparseable output: consume the
|
||||
# search (empty routing) to avoid retrying a bad prompt forever.
|
||||
logger.error(f"Failed to parse classification response as JSON: {e}")
|
||||
return MemoryRoutingResult()
|
||||
except Exception as e:
|
||||
logger.error(f"Classification failed: {e}", exc_info=True)
|
||||
return MemoryRoutingResult()
|
||||
|
||||
async def _route_to_volatile(
|
||||
self,
|
||||
classification: MemoryRouteClassification,
|
||||
web_result: Dict[str, Any],
|
||||
user: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Route a web result to volatile cache.
|
||||
|
||||
Args:
|
||||
classification: The classification with volatile routing info
|
||||
web_result: The original web result data
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
True if successfully cached, False otherwise
|
||||
"""
|
||||
if not self.volatile_service:
|
||||
logger.warning("Volatile service not configured, skipping volatile routing")
|
||||
return False
|
||||
|
||||
namespace = classification.volatile_namespace or "custom"
|
||||
key = classification.volatile_key or web_result['url'].split('/')[-1]
|
||||
ttl = (classification.volatile_ttl_hours or 1) * 3600 # Convert hours to seconds
|
||||
|
||||
try:
|
||||
# Store the web result content in volatile cache
|
||||
data = {
|
||||
"title": web_result.get('title', ''),
|
||||
"content": web_result.get('content', ''),
|
||||
"url": web_result.get('url', ''),
|
||||
"text": f"{web_result.get('title', '')}: {web_result.get('content', '')[:500]}",
|
||||
}
|
||||
|
||||
await self.volatile_service.store(
|
||||
user=user,
|
||||
namespace=namespace,
|
||||
key=key,
|
||||
data=data,
|
||||
source=web_result.get('url', 'web_search'),
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Cached to volatile: {namespace}/{key} (ttl={ttl}s)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cache to volatile: {e}")
|
||||
return False
|
||||
|
||||
async def _route_to_files(
|
||||
self,
|
||||
classification: MemoryRouteClassification,
|
||||
web_result: Dict[str, Any],
|
||||
user: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Queue a file for Paperless ingestion.
|
||||
|
||||
Args:
|
||||
classification: The classification with file info
|
||||
web_result: The original web result data
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
True if successfully queued, False otherwise
|
||||
"""
|
||||
# For now, log the file for manual review or future Paperless integration
|
||||
url = web_result.get('url', '')
|
||||
title = web_result.get('title', '')
|
||||
|
||||
logger.info(f"File detected for Paperless: {title} ({url})")
|
||||
|
||||
# TODO: Implement actual Paperless file upload
|
||||
# This would involve:
|
||||
# 1. Download the file
|
||||
# 2. Upload to Paperless via API
|
||||
# 3. Add tags based on classification
|
||||
|
||||
return True # Placeholder - count as queued
|
||||
|
||||
async def _register_prefetch(
|
||||
self,
|
||||
classification: MemoryRouteClassification,
|
||||
web_result: Dict[str, Any],
|
||||
user: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Register a prefetch pattern with the external scheduler service.
|
||||
|
||||
Args:
|
||||
classification: The classification with prefetch info
|
||||
web_result: The original web result data
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
True if successfully registered, False otherwise
|
||||
"""
|
||||
if not self.scheduler_client:
|
||||
logger.warning("Scheduler client not configured, skipping prefetch registration")
|
||||
return False
|
||||
|
||||
# Parse cron pattern into scheduler schedule format
|
||||
# Format: "minute hour day_of_month month day_of_week"
|
||||
# Scheduler uses -1 for "every"
|
||||
cron = classification.prefetch_cron or "0 * * * *"
|
||||
schedule = self._parse_cron_to_schedule(cron)
|
||||
|
||||
# Determine namespace and key from classification
|
||||
namespace = classification.volatile_namespace or "custom"
|
||||
key = classification.volatile_key or web_result.get('url', '').split('/')[-1].split('?')[0]
|
||||
|
||||
if not key:
|
||||
logger.warning(f"Could not determine prefetch key for {web_result.get('url')}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Use the scheduler client's convenience method to register volatile fetch
|
||||
success = await self.scheduler_client.register_volatile_fetch(
|
||||
namespace=namespace,
|
||||
key=key,
|
||||
user=user,
|
||||
schedule=schedule,
|
||||
description=f"Auto-prefetch: {classification.title or web_result.get('title', 'Unknown')}",
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Registered scheduler task: volatile_{namespace}_{key}_{user}")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register prefetch with scheduler: {e}")
|
||||
return False
|
||||
|
||||
def _parse_cron_to_schedule(self, cron: str) -> dict:
|
||||
"""
|
||||
Parse cron string to scheduler schedule dict.
|
||||
|
||||
Args:
|
||||
cron: Cron-style string (e.g., "0 6 * * *" = 6:00 AM daily)
|
||||
|
||||
Returns:
|
||||
Dict with minute, hour, day_of_month, month, day_of_week
|
||||
where -1 means "every"
|
||||
"""
|
||||
parts = cron.strip().split()
|
||||
if len(parts) != 5:
|
||||
# Default to hourly if invalid
|
||||
return {"minute": 0, "hour": -1}
|
||||
|
||||
def parse_part(part: str) -> int:
|
||||
if part == "*":
|
||||
return -1
|
||||
try:
|
||||
return int(part)
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
return {
|
||||
"minute": parse_part(parts[0]),
|
||||
"hour": parse_part(parts[1]),
|
||||
"day_of_month": parse_part(parts[2]),
|
||||
"month": parse_part(parts[3]),
|
||||
"day_of_week": parse_part(parts[4]),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Document sync service for Library Desk.
|
||||
|
||||
Handles indexing of Paperless-ngx documents into vectors and graph.
|
||||
Called by webhook when Paperless completes document processing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.clients.paperless_client import PaperlessClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.core.multi_tenancy import get_qdrant_collection_name
|
||||
from src.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexResult:
|
||||
"""Result of indexing a single document."""
|
||||
success: bool
|
||||
document_id: int
|
||||
title: str = ""
|
||||
chunks_created: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class DocumentSyncService:
|
||||
"""
|
||||
Service for syncing Paperless documents to Library Desk indexes.
|
||||
|
||||
Handles:
|
||||
- Fetching document content from Paperless API
|
||||
- Chunking and embedding into Qdrant
|
||||
- Creating graph nodes in Neo4j
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
paperless_client: PaperlessClient,
|
||||
qdrant_client: QdrantClientWrapper,
|
||||
ollama_client: OllamaClient,
|
||||
neo4j_client: Neo4jClient,
|
||||
wiki_client: WikiJSClient,
|
||||
settings: Settings,
|
||||
chunk_size: int = 500,
|
||||
chunk_overlap: int = 50
|
||||
):
|
||||
self.paperless = paperless_client
|
||||
self.qdrant = qdrant_client
|
||||
self.ollama = ollama_client
|
||||
self.neo4j = neo4j_client
|
||||
self.wiki = wiki_client
|
||||
self.settings = settings
|
||||
self.chunk_size = chunk_size
|
||||
self.chunk_overlap = chunk_overlap
|
||||
|
||||
def _chunk_text(self, text: str) -> List[str]:
|
||||
"""Chunk text into overlapping segments."""
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
words = text.split()
|
||||
|
||||
if len(words) <= self.chunk_size:
|
||||
return [text] if text else []
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(words):
|
||||
end = start + self.chunk_size
|
||||
chunk_words = words[start:end]
|
||||
chunks.append(' '.join(chunk_words))
|
||||
start = end - self.chunk_overlap
|
||||
|
||||
return chunks
|
||||
|
||||
async def index_document(
|
||||
self,
|
||||
document_id: int,
|
||||
user: str,
|
||||
content: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
) -> IndexResult:
|
||||
"""
|
||||
Index a single document from Paperless into vectors and graph.
|
||||
|
||||
Args:
|
||||
document_id: Paperless document ID
|
||||
user: User identifier for multi-tenancy
|
||||
content: Optional document content (if provided, skip Paperless API call)
|
||||
title: Optional document title (if provided, skip Paperless API call)
|
||||
|
||||
Returns:
|
||||
IndexResult with success status and details
|
||||
"""
|
||||
logger.info(f"Indexing document {document_id} for user {user}")
|
||||
|
||||
try:
|
||||
# If content and title provided (from webhook), skip API call
|
||||
if content is not None and title is not None:
|
||||
doc_title = title
|
||||
doc_content = content
|
||||
original_filename = None
|
||||
correspondent = None
|
||||
document_type = None
|
||||
tags = []
|
||||
else:
|
||||
# Fetch document from Paperless
|
||||
doc = await self.paperless.get_document(document_id)
|
||||
if not doc:
|
||||
return IndexResult(
|
||||
success=False,
|
||||
document_id=document_id,
|
||||
error="Document not found in Paperless"
|
||||
)
|
||||
doc_title = doc.title
|
||||
doc_content = doc.content or ""
|
||||
original_filename = doc.original_file_name
|
||||
correspondent = doc.correspondent
|
||||
document_type = doc.document_type
|
||||
tags = doc.tags
|
||||
|
||||
if not doc_content.strip():
|
||||
logger.warning(f"Document {document_id} has no text content")
|
||||
return IndexResult(
|
||||
success=True,
|
||||
document_id=document_id,
|
||||
title=doc_title,
|
||||
chunks_created=0,
|
||||
error="No text content (possibly image/video only)"
|
||||
)
|
||||
|
||||
# Index vectors
|
||||
chunks_created = await self._index_vectors(
|
||||
document_id=document_id,
|
||||
title=doc_title,
|
||||
content=doc_content,
|
||||
user=user,
|
||||
metadata={
|
||||
"paperless_id": document_id,
|
||||
"original_filename": original_filename,
|
||||
"correspondent": correspondent,
|
||||
"document_type": document_type,
|
||||
"tags": tags,
|
||||
}
|
||||
)
|
||||
|
||||
# Index graph node
|
||||
await self._index_graph(
|
||||
document_id=document_id,
|
||||
title=doc_title,
|
||||
content=doc_content,
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Mark as indexed in Paperless (optional - if custom field exists)
|
||||
try:
|
||||
await self._mark_indexed(document_id)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not mark document as indexed: {e}")
|
||||
|
||||
logger.info(f"Successfully indexed document {document_id}: {chunks_created} chunks")
|
||||
|
||||
return IndexResult(
|
||||
success=True,
|
||||
document_id=document_id,
|
||||
title=doc_title,
|
||||
chunks_created=chunks_created
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to index document {document_id}: {e}", exc_info=True)
|
||||
return IndexResult(
|
||||
success=False,
|
||||
document_id=document_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def _index_vectors(
|
||||
self,
|
||||
document_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
user: str,
|
||||
metadata: dict,
|
||||
) -> int:
|
||||
"""Create vector embeddings for document content."""
|
||||
collection = get_qdrant_collection_name(user)
|
||||
await self.qdrant.ensure_collection(collection)
|
||||
|
||||
# Chunk content
|
||||
chunks = self._chunk_text(content)
|
||||
if not chunks:
|
||||
return 0
|
||||
|
||||
# Generate embeddings (embed_batch returns None for failed chunks)
|
||||
embeddings = await self.ollama.embed_batch(chunks)
|
||||
|
||||
# Build points, skipping chunks whose embedding failed. Previously a
|
||||
# single None embedding poisoned the batch and aborted the whole
|
||||
# document upsert.
|
||||
points = []
|
||||
skipped = 0
|
||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
if embedding is None:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
f"Skipping chunk {i} of document {document_id}: embedding failed"
|
||||
)
|
||||
continue
|
||||
|
||||
# Deterministic id: re-upserting the same document overwrites
|
||||
# its previous chunks in place (enables delete-last below).
|
||||
point_id = str(
|
||||
uuid.uuid5(uuid.NAMESPACE_DNS, f"document_{document_id}_chunk_{i}")
|
||||
)
|
||||
content_hash = hashlib.md5(chunk.encode()).hexdigest()
|
||||
|
||||
points.append({
|
||||
"id": point_id,
|
||||
"vector": embedding,
|
||||
"payload": {
|
||||
"doc_type": "document",
|
||||
"paperless_id": document_id,
|
||||
"title": title,
|
||||
"chunk_text": chunk,
|
||||
"chunk_index": i,
|
||||
"content_hash": content_hash,
|
||||
**metadata
|
||||
}
|
||||
})
|
||||
|
||||
if skipped and not points:
|
||||
raise RuntimeError(
|
||||
f"All {skipped} chunk embeddings failed for document {document_id}"
|
||||
)
|
||||
if skipped:
|
||||
logger.warning(
|
||||
f"Document {document_id}: {skipped}/{len(chunks)} chunks skipped "
|
||||
f"(embedding failures); indexing the remaining {len(points)}"
|
||||
)
|
||||
|
||||
# Upsert BEFORE pruning stale chunks (same order as the wiki
|
||||
# reindex fix in VectorService.update_from_page): the old
|
||||
# delete-first order left the document with ZERO vectors until the
|
||||
# next successful sync whenever the embedding pass failed after the
|
||||
# delete (e.g. Ollama down). Deterministic uuid5 ids make the
|
||||
# in-place overwrite safe.
|
||||
if points:
|
||||
await self.qdrant.upsert_points(
|
||||
collection_name=collection,
|
||||
points=points
|
||||
)
|
||||
|
||||
# Prune chunks left over from a previous version of the document
|
||||
# (indexes beyond the new count, or legacy random-uuid4 points).
|
||||
# Only prune after a successful upsert - a fully failed embedding
|
||||
# pass must not wipe the old vectors.
|
||||
new_ids = {p["id"] for p in points}
|
||||
existing = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection,
|
||||
filter_conditions={
|
||||
"doc_type": "document",
|
||||
"paperless_id": document_id,
|
||||
},
|
||||
with_payload=False,
|
||||
)
|
||||
stale_ids = [pt["id"] for pt in existing if pt["id"] not in new_ids]
|
||||
if stale_ids:
|
||||
await self.qdrant.delete_by_ids(
|
||||
collection_name=collection,
|
||||
point_ids=stale_ids,
|
||||
)
|
||||
|
||||
return len(points)
|
||||
|
||||
async def _index_graph(
|
||||
self,
|
||||
document_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
user: str,
|
||||
):
|
||||
"""Create graph node for document."""
|
||||
# Create Document node in Neo4j
|
||||
query = """
|
||||
MERGE (d:Document {paperless_id: $paperless_id, user: $user})
|
||||
SET d.title = $title,
|
||||
d.doc_type = 'document',
|
||||
d.updated_at = datetime()
|
||||
RETURN d
|
||||
"""
|
||||
await self.neo4j.execute_write(
|
||||
query,
|
||||
{
|
||||
"paperless_id": document_id,
|
||||
"user": user,
|
||||
"title": title,
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: Extract entities from content and create relationships
|
||||
# This could use the same entity extraction as wiki pages
|
||||
|
||||
async def _mark_indexed(self, document_id: int):
|
||||
"""Mark document as indexed in Paperless custom field."""
|
||||
# Try to update library_indexed custom field if it exists
|
||||
try:
|
||||
# Look up field ID by name (Paperless requires ID, not name)
|
||||
field = await self.paperless.get_custom_field_by_name("library_indexed")
|
||||
if field:
|
||||
await self.paperless.update_document(
|
||||
document_id=document_id,
|
||||
custom_fields=[{"field": field["id"], "value": True}]
|
||||
)
|
||||
except Exception:
|
||||
# Field might not exist, that's OK
|
||||
pass
|
||||
+552
-40
@@ -12,7 +12,7 @@ import logging
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.core.multi_tenancy import get_neo4j_user_label
|
||||
from src.core.multi_tenancy import get_neo4j_user_label, is_path_in_user_namespace
|
||||
from src.models.graph import (
|
||||
GraphNode, GraphRelationship, GraphNodeDetail,
|
||||
CypherQueryResponse, GraphUpdateSummary, EntityMention,
|
||||
@@ -73,6 +73,15 @@ class GraphService:
|
||||
self.neo4j = neo4j_client
|
||||
self.wiki = wikijs_client
|
||||
|
||||
# Conservative denylist of Cypher write clauses / procedure calls.
|
||||
# Matched as whole words against the uppercased query. CALL is rejected
|
||||
# entirely (covers db.*/apoc.* write procedures and CALL {} subqueries)
|
||||
# because reliably distinguishing read from write procedures would
|
||||
# require a real Cypher parser.
|
||||
_WRITE_CLAUSE_PATTERN = re.compile(
|
||||
r"\b(CREATE|MERGE|DELETE|DETACH|SET|REMOVE|DROP|FOREACH|LOAD|CALL)\b"
|
||||
)
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
query: str,
|
||||
@@ -80,29 +89,39 @@ class GraphService:
|
||||
user: str
|
||||
) -> CypherQueryResponse:
|
||||
"""
|
||||
Execute user-scoped Cypher query.
|
||||
Execute a raw Cypher query — READ-ONLY, NOT tenant-scoped.
|
||||
|
||||
Automatically injects user label into query for security.
|
||||
Security model (admin/debug endpoint):
|
||||
- Queries containing write clauses (CREATE/MERGE/DELETE/SET/REMOVE/
|
||||
DROP/DETACH/FOREACH/LOAD CSV) or any CALL are rejected up front.
|
||||
- The query is executed through a session opened with
|
||||
default_access_mode=READ_ACCESS, so the database itself refuses
|
||||
writes even if the denylist is bypassed.
|
||||
- Results are NOT automatically restricted to the user's tenant
|
||||
labels: an arbitrary Cypher query can read any tenant's nodes.
|
||||
Callers must scope patterns themselves (e.g. match on
|
||||
`User_<Tenant>`/`User_<Tenant>_Document` labels).
|
||||
|
||||
Args:
|
||||
query: Cypher query
|
||||
query: Cypher query (read-only)
|
||||
parameters: Query parameters
|
||||
user: User identifier
|
||||
user: Requesting user (audit logging only — does NOT scope
|
||||
the query)
|
||||
|
||||
Returns:
|
||||
Query results with metadata
|
||||
|
||||
Raises:
|
||||
ValueError: If the query contains write clauses or fails
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Get user-specific label
|
||||
user_label = get_neo4j_user_label(user)
|
||||
self._reject_write_clauses(query)
|
||||
|
||||
# Inject user label into query for scoping
|
||||
# This ensures users can only query their own data
|
||||
scoped_query = self._scope_query_to_user(query, user_label)
|
||||
logger.info(f"Read-only Cypher query for user '{user}' (unscoped): {query[:200]}")
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(scoped_query, parameters)
|
||||
results = await self.neo4j.execute_read(query, parameters)
|
||||
query_time_ms = (time.time() - start_time) * 1000
|
||||
|
||||
return CypherQueryResponse(
|
||||
@@ -111,28 +130,33 @@ class GraphService:
|
||||
query_time_ms=query_time_ms
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Cypher query failed: {e}", exc_info=True)
|
||||
raise ValueError(f"Query execution failed: {str(e)}")
|
||||
|
||||
def _scope_query_to_user(self, query: str, user_label: str) -> str:
|
||||
def _reject_write_clauses(self, query: str) -> None:
|
||||
"""
|
||||
Inject user label into Cypher query for multi-tenancy.
|
||||
Reject Cypher queries containing write clauses or procedure calls.
|
||||
|
||||
Simple implementation: adds user label to node patterns.
|
||||
Production version would use proper query parsing.
|
||||
Conservative denylist on the uppercased query: false positives are
|
||||
acceptable (e.g. the word SET in a string literal), false negatives
|
||||
are not. The read-only session is the hard backstop.
|
||||
|
||||
Args:
|
||||
query: Original Cypher query
|
||||
user_label: User-specific label
|
||||
query: Raw Cypher query
|
||||
|
||||
Returns:
|
||||
Scoped query
|
||||
Raises:
|
||||
ValueError: If a denylisted clause is found
|
||||
"""
|
||||
# For now, return query as-is
|
||||
# TODO: Implement proper query scoping with label injection
|
||||
logger.warning("Query scoping not yet implemented - returning unscoped query")
|
||||
return query
|
||||
match = self._WRITE_CLAUSE_PATTERN.search(query.upper())
|
||||
if match:
|
||||
raise ValueError(
|
||||
f"Query rejected: '{match.group(1)}' is not allowed — "
|
||||
"/query/graph and /graph/query are read-only (no write "
|
||||
"clauses or CALL procedures)"
|
||||
)
|
||||
|
||||
async def list_nodes(
|
||||
self,
|
||||
@@ -400,6 +424,14 @@ class GraphService:
|
||||
if not page:
|
||||
raise ValueError(f"Page {page_id} not found")
|
||||
|
||||
# TENANT ISOLATION: only pages inside the user's own wiki
|
||||
# namespace may be written into that user's graph labels.
|
||||
if not is_path_in_user_namespace(page.get("path", ""), user):
|
||||
raise ValueError(
|
||||
f"Page {page_id} (path: {page.get('path')!r}) is outside "
|
||||
f"user '{user}' namespace - refusing cross-tenant ingestion"
|
||||
)
|
||||
|
||||
# PROTECTION: Skip entity extraction on auto-generated entity stub pages
|
||||
tags = page.get("tags", [])
|
||||
if "entity-stub" in tags or "auto-generated" in tags:
|
||||
@@ -420,23 +452,30 @@ class GraphService:
|
||||
user_base_label = get_neo4j_user_base_label(user) # For entities
|
||||
user_doc_label = get_neo4j_user_label(user) # For documents
|
||||
|
||||
# Create/update Document node
|
||||
# Create/update Document node.
|
||||
# content_hash records the fingerprint of the ingested content so
|
||||
# /ingest/check-updates can detect changed pages without re-reading
|
||||
# the graph's source content.
|
||||
from src.core.hashing import compute_content_hash
|
||||
|
||||
doc_query = f"""
|
||||
MERGE (d:{user_doc_label}:Document {{page_id: $page_id}})
|
||||
SET d.title = $title,
|
||||
d.path = $path,
|
||||
d.tags = $tags,
|
||||
d.updated_at = datetime(),
|
||||
d.content_length = $content_length
|
||||
d.content_length = $content_length,
|
||||
d.content_hash = $content_hash
|
||||
RETURN d
|
||||
"""
|
||||
|
||||
await self.neo4j.execute_query(doc_query, {
|
||||
await self.neo4j.execute_write(doc_query, {
|
||||
"page_id": page_id,
|
||||
"title": page.get("title"),
|
||||
"path": page.get("path"),
|
||||
"tags": tags,
|
||||
"content_length": len(content)
|
||||
"content_length": len(content),
|
||||
"content_hash": compute_content_hash(content)
|
||||
})
|
||||
|
||||
nodes_created = 1 # Document node
|
||||
@@ -454,7 +493,7 @@ class GraphService:
|
||||
RETURN e, r
|
||||
"""
|
||||
|
||||
result = await self.neo4j.execute_query(entity_query, {
|
||||
result = await self.neo4j.execute_write(entity_query, {
|
||||
"name": entity.text,
|
||||
"page_id": page_id,
|
||||
"confidence": entity.confidence
|
||||
@@ -518,7 +557,7 @@ class GraphService:
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_query(
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"page_id": page_id}
|
||||
)
|
||||
@@ -651,10 +690,11 @@ class GraphService:
|
||||
"""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
query = f"""
|
||||
MATCH (e:{user_base_label}:{entity_type} {{name: $name}})
|
||||
MATCH (d:Document)-[:MENTIONS]->(e)
|
||||
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
|
||||
RETURN count(distinct d) as mention_count
|
||||
"""
|
||||
|
||||
@@ -690,8 +730,8 @@ class GraphService:
|
||||
entity_path = f"{user_namespace}/entities/{entity_type.lower()}/{entity_name.lower().replace(' ', '-')}"
|
||||
|
||||
try:
|
||||
# Search for page by path
|
||||
pages = await self.wiki.list_pages(limit=1000)
|
||||
# Search for page by path (scoped to the user's namespace)
|
||||
pages = await self.wiki.list_pages(path_prefix=user_namespace, limit=1000)
|
||||
# list_pages returns a list directly, not a dict
|
||||
for page in pages:
|
||||
if page.get("path", "") == entity_path:
|
||||
@@ -798,11 +838,12 @@ Feel free to expand it with more details!
|
||||
try:
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
# Get mentioning documents
|
||||
# Get mentioning documents (scoped to this tenant's documents)
|
||||
mention_query = f"""
|
||||
MATCH (e:{user_base_label}:{entity_type} {{name: $name}})
|
||||
MATCH (d:Document)-[:MENTIONS]->(e)
|
||||
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
|
||||
RETURN d.title as title, d.path as path, d.page_id as page_id
|
||||
"""
|
||||
|
||||
@@ -820,7 +861,7 @@ Feel free to expand it with more details!
|
||||
# Only match entity nodes (not Document nodes)
|
||||
related_query = f"""
|
||||
MATCH (e1:{user_base_label}:{entity_type} {{name: $name}})
|
||||
MATCH (d:Document)-[:MENTIONS]->(e1)
|
||||
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e1)
|
||||
MATCH (d)-[:MENTIONS]->(e2:{user_base_label})
|
||||
WHERE e2 <> e1 AND NOT (e2:Document)
|
||||
RETURN DISTINCT e2.name as name, labels(e2) as labels,
|
||||
@@ -910,15 +951,16 @@ Feel free to expand it with more details!
|
||||
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
pages_created = []
|
||||
pages_skipped = []
|
||||
|
||||
try:
|
||||
# Query for entities with sufficient mentions
|
||||
# Query for entities with sufficient mentions (tenant-scoped)
|
||||
for entity_type in entity_types:
|
||||
query = f"""
|
||||
MATCH (e:{user_base_label}:{entity_type})
|
||||
MATCH (d:Document)-[:MENTIONS]->(e)
|
||||
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
|
||||
WITH e, count(distinct d) as mention_count
|
||||
WHERE mention_count >= $min_mentions
|
||||
RETURN e.name as name, mention_count
|
||||
@@ -1077,8 +1119,18 @@ Feel free to expand it with more details!
|
||||
search_query,
|
||||
{"terms": all_terms, "limit": limit}
|
||||
)
|
||||
logger.info(f"Graph search found {len(results)} documents")
|
||||
return results
|
||||
|
||||
# Deduplicate by page_id (safety net for any edge cases)
|
||||
seen_page_ids = set()
|
||||
unique_results = []
|
||||
for r in results:
|
||||
page_id = r.get("page_id")
|
||||
if page_id and page_id not in seen_page_ids:
|
||||
seen_page_ids.add(page_id)
|
||||
unique_results.append(r)
|
||||
|
||||
logger.info(f"Graph search found {len(unique_results)} unique documents (raw: {len(results)})")
|
||||
return unique_results
|
||||
except Exception as e:
|
||||
logger.error(f"Graph document search failed: {e}", exc_info=True)
|
||||
return []
|
||||
@@ -1133,6 +1185,68 @@ Feel free to expand it with more details!
|
||||
logger.error(f"Failed to get related documents for page {page_id}: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def get_related_documents_batch(
|
||||
self,
|
||||
page_ids: List[int],
|
||||
user: str,
|
||||
limit_per_page: int = 5
|
||||
) -> Dict[int, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Batch variant of get_related_documents: ONE UNWIND query for all pages
|
||||
instead of one round-trip per page.
|
||||
|
||||
Args:
|
||||
page_ids: Page IDs to find related documents for
|
||||
user: User identifier
|
||||
limit_per_page: Maximum related documents per page
|
||||
|
||||
Returns:
|
||||
Mapping of page_id -> related-document rows (same shape as
|
||||
get_related_documents). Pages with no related documents are
|
||||
absent from the mapping.
|
||||
"""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
if not page_ids:
|
||||
return {}
|
||||
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
# ORDER BY runs before collect() so each page's list is sorted by
|
||||
# shared_entities descending; [..$limit] trims per page.
|
||||
query = f"""
|
||||
UNWIND $page_ids AS pid
|
||||
MATCH (d1:{user_doc_label}:Document {{page_id: pid}})
|
||||
MATCH (d1)-[:MENTIONS]->(e:{user_base_label})<-[:MENTIONS]-(d2:{user_doc_label}:Document)
|
||||
WHERE d1 <> d2 AND NOT e:Document
|
||||
WITH pid, d2, d2.tags as tags, count(DISTINCT e) as shared_entities
|
||||
WHERE tags IS NOT NULL AND size(tags) > 0
|
||||
WITH pid, d2, tags, shared_entities
|
||||
ORDER BY shared_entities DESC
|
||||
WITH pid, collect({{
|
||||
page_id: d2.page_id,
|
||||
title: d2.title,
|
||||
path: d2.path,
|
||||
tags: tags,
|
||||
shared_entities: shared_entities
|
||||
}})[..$limit] AS related
|
||||
RETURN pid AS page_id, related
|
||||
"""
|
||||
|
||||
try:
|
||||
rows = await self.neo4j.execute_query(
|
||||
query,
|
||||
{"page_ids": page_ids, "limit": limit_per_page}
|
||||
)
|
||||
return {row["page_id"]: row["related"] for row in rows}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get related documents for {len(page_ids)} pages: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
return {}
|
||||
|
||||
async def get_all_entities(self, user: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all entities from the knowledge graph for a user.
|
||||
@@ -1239,7 +1353,7 @@ Feel free to expand it with more details!
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(
|
||||
results = await self.neo4j.execute_write(
|
||||
query,
|
||||
{"page_id": page_id, "entity_names": names}
|
||||
)
|
||||
@@ -1251,3 +1365,401 @@ Feel free to expand it with more details!
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create entity mentions: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def delete_document_node(
|
||||
self,
|
||||
document_id: str,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete a Document Store document node and all its relationships.
|
||||
|
||||
Args:
|
||||
document_id: Document UUID (Document Store)
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of nodes deleted (1 if successful, 0 if not found)
|
||||
"""
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
delete_query = f"""
|
||||
MATCH (d:{user_doc_label}:Document {{document_id: $document_id}})
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as deleted_count
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"document_id": document_id}
|
||||
)
|
||||
|
||||
deleted_count = result[0]["deleted_count"] if result else 0
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted Document node for document {document_id}")
|
||||
else:
|
||||
logger.warning(f"No Document node found for document {document_id}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete document {document_id} from graph: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def delete_paperless_document(
|
||||
self,
|
||||
paperless_id: int,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete a Paperless document node and all its relationships.
|
||||
|
||||
Args:
|
||||
paperless_id: Paperless-ngx document ID
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of nodes deleted (1 if successful, 0 if not found)
|
||||
"""
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
delete_query = f"""
|
||||
MATCH (d:{user_doc_label}:Document {{paperless_id: $paperless_id}})
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as deleted_count
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"paperless_id": paperless_id}
|
||||
)
|
||||
|
||||
deleted_count = result[0]["deleted_count"] if result else 0
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Deleted Document node for Paperless document {paperless_id}")
|
||||
else:
|
||||
logger.debug(f"No Document node found for Paperless document {paperless_id}")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete Paperless document {paperless_id} from graph: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def delete_collection_node(
|
||||
self,
|
||||
collection_id: str,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete a DocumentCollection node and all contained documents.
|
||||
|
||||
Args:
|
||||
collection_id: Collection UUID
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of nodes deleted (collection + documents)
|
||||
"""
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
# Delete collection and all documents it contains
|
||||
delete_query = f"""
|
||||
MATCH (c:{user_doc_label}:DocumentCollection {{id: $collection_id}})
|
||||
OPTIONAL MATCH (c)-[:CONTAINS]->(d:Document)
|
||||
DETACH DELETE c, d
|
||||
RETURN count(c) + count(d) as deleted_count
|
||||
"""
|
||||
|
||||
try:
|
||||
result = await self.neo4j.execute_write(
|
||||
delete_query,
|
||||
{"collection_id": collection_id}
|
||||
)
|
||||
|
||||
deleted_count = result[0]["deleted_count"] if result else 0
|
||||
logger.info(f"Deleted collection {collection_id} with {deleted_count} total nodes")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection {collection_id}: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def find_orphan_entities(
|
||||
self,
|
||||
user: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find entities with no MENTIONS relationships (orphaned).
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
List of orphaned entities {id, name, type}
|
||||
"""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
query = f"""
|
||||
MATCH (e:{user_base_label})
|
||||
WHERE NOT e:Document
|
||||
AND NOT e:DocumentCollection
|
||||
AND NOT EXISTS {{ (d:{user_doc_label}:Document)-[:MENTIONS]->(e) }}
|
||||
RETURN elementId(e) as id, e.name as name, labels(e) as labels
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(query, {})
|
||||
|
||||
orphans = []
|
||||
for r in results:
|
||||
labels = r.get("labels", [])
|
||||
entity_type = next(
|
||||
(l for l in labels if l != user_base_label),
|
||||
"Unknown"
|
||||
)
|
||||
orphans.append({
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"type": entity_type
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(orphans)} orphan entities for user {user}")
|
||||
return orphans
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find orphan entities: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def purge_orphan_entities(
|
||||
self,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete all orphaned entities (entities with no MENTIONS relationships).
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of entities purged
|
||||
"""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
query = f"""
|
||||
MATCH (e:{user_base_label})
|
||||
WHERE NOT e:Document
|
||||
AND NOT e:DocumentCollection
|
||||
AND NOT EXISTS {{ (d:{user_doc_label}:Document)-[:MENTIONS]->(e) }}
|
||||
DETACH DELETE e
|
||||
RETURN count(e) as purged_count
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_write(query, {})
|
||||
purged_count = results[0]["purged_count"] if results else 0
|
||||
|
||||
logger.info(f"Purged {purged_count} orphan entities for user {user}")
|
||||
return purged_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to purge orphan entities: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def get_all_document_references(
|
||||
self,
|
||||
user: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all Document node references for orphan detection.
|
||||
|
||||
Returns page_id for wiki docs and document_id for Document Store docs.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
List of document references {page_id, document_id, doc_type, title}
|
||||
"""
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
|
||||
query = f"""
|
||||
MATCH (d:{user_doc_label}:Document)
|
||||
RETURN d.page_id as page_id,
|
||||
d.document_id as document_id,
|
||||
COALESCE(d.doc_type, 'wiki') as doc_type,
|
||||
d.title as title
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_query(query, {})
|
||||
|
||||
references = []
|
||||
for r in results:
|
||||
references.append({
|
||||
"page_id": r.get("page_id"),
|
||||
"document_id": r.get("document_id"),
|
||||
"doc_type": r.get("doc_type", "wiki"),
|
||||
"title": r.get("title")
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(references)} document references for user {user}")
|
||||
return references
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get document references: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def purge_stale_documents_by_ids(
|
||||
self,
|
||||
user: str,
|
||||
page_ids: List[int] = None,
|
||||
document_ids: List[str] = None
|
||||
) -> int:
|
||||
"""
|
||||
Delete specific stale Document nodes by their IDs.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
page_ids: List of wiki page IDs to delete
|
||||
document_ids: List of Document Store document IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of documents purged
|
||||
"""
|
||||
user_doc_label = get_neo4j_user_label(user)
|
||||
total_purged = 0
|
||||
|
||||
try:
|
||||
# Purge by page_id (wiki docs)
|
||||
if page_ids:
|
||||
query = f"""
|
||||
MATCH (d:{user_doc_label}:Document)
|
||||
WHERE d.page_id IN $page_ids
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as purged_count
|
||||
"""
|
||||
results = await self.neo4j.execute_write(query, {"page_ids": page_ids})
|
||||
count = results[0]["purged_count"] if results else 0
|
||||
total_purged += count
|
||||
logger.info(f"Purged {count} wiki Document nodes")
|
||||
|
||||
# Purge by document_id (Document Store docs)
|
||||
if document_ids:
|
||||
query = f"""
|
||||
MATCH (d:{user_doc_label}:Document)
|
||||
WHERE d.document_id IN $document_ids
|
||||
DETACH DELETE d
|
||||
RETURN count(d) as purged_count
|
||||
"""
|
||||
results = await self.neo4j.execute_write(query, {"document_ids": document_ids})
|
||||
count = results[0]["purged_count"] if results else 0
|
||||
total_purged += count
|
||||
logger.info(f"Purged {count} Document Store Document nodes")
|
||||
|
||||
return total_purged
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to purge stale documents: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def cleanup_broken_relationships(
|
||||
self,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Clean up broken FOUND relationships from SearchQuery nodes.
|
||||
|
||||
Removes relationships pointing to deleted documents.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of relationships cleaned
|
||||
"""
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
user_base_label = get_neo4j_user_base_label(user)
|
||||
|
||||
query = f"""
|
||||
MATCH (sq:{user_base_label}_SearchQuery:SearchQuery)-[r:FOUND]->(d)
|
||||
WHERE NOT EXISTS {{ (d) }}
|
||||
DELETE r
|
||||
RETURN count(r) as cleaned_count
|
||||
"""
|
||||
|
||||
try:
|
||||
results = await self.neo4j.execute_write(query, {})
|
||||
cleaned_count = results[0]["cleaned_count"] if results else 0
|
||||
|
||||
if cleaned_count > 0:
|
||||
logger.info(f"Cleaned {cleaned_count} broken FOUND relationships")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup broken relationships: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def find_documents_without_vectors(
|
||||
self,
|
||||
user: str,
|
||||
vector_references: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find Document nodes that have no corresponding vectors.
|
||||
|
||||
Used for bidirectional orphan detection - graph nodes without vector data.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
vector_references: List of vector refs from VectorService.get_all_chunk_references()
|
||||
|
||||
Returns:
|
||||
List of orphan documents {page_id, document_id, doc_type, title}
|
||||
"""
|
||||
# Get all graph document references
|
||||
graph_docs = await self.get_all_document_references(user)
|
||||
|
||||
if not graph_docs:
|
||||
return []
|
||||
|
||||
# Build sets of IDs that have vectors
|
||||
vector_page_ids = {
|
||||
ref.get("page_id") for ref in vector_references
|
||||
if ref.get("doc_type") == "wiki" and ref.get("page_id")
|
||||
}
|
||||
vector_doc_ids = {
|
||||
ref.get("document_id") for ref in vector_references
|
||||
if ref.get("doc_type") != "wiki" and ref.get("document_id")
|
||||
}
|
||||
|
||||
# Find graph docs with no vectors
|
||||
orphans = []
|
||||
for doc in graph_docs:
|
||||
doc_type = doc.get("doc_type", "wiki")
|
||||
|
||||
if doc_type == "wiki":
|
||||
page_id = doc.get("page_id")
|
||||
if page_id and page_id not in vector_page_ids:
|
||||
orphans.append(doc)
|
||||
else:
|
||||
document_id = doc.get("document_id")
|
||||
if document_id and document_id not in vector_doc_ids:
|
||||
orphans.append(doc)
|
||||
|
||||
logger.info(f"Found {len(orphans)} graph documents without vectors for user {user}")
|
||||
return orphans
|
||||
|
||||
+577
-195
File diff suppressed because it is too large
Load Diff
@@ -386,12 +386,32 @@ class IngestionService:
|
||||
Returns:
|
||||
BatchIngestionResult
|
||||
"""
|
||||
logger.info(f"Finding all pages for user {user} (prefix: {path_prefix or 'all'})")
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
# TENANT ISOLATION: the listing prefix is clamped to the user's own
|
||||
# wiki namespace. A caller-supplied prefix outside users/{user}/
|
||||
# would otherwise ingest another tenant's pages into this tenant's
|
||||
# collection and graph labels.
|
||||
if path_prefix:
|
||||
parts = path_prefix.strip("/").split("/")
|
||||
if (
|
||||
len(parts) < 2
|
||||
or parts[0] != "users"
|
||||
or sanitize_user_id(parts[1]) != sanitize_user_id(user)
|
||||
):
|
||||
raise ValueError(
|
||||
f"path_prefix {path_prefix!r} is outside user '{user}' "
|
||||
f"namespace (users/{sanitize_user_id(user)}/) - refusing "
|
||||
"cross-tenant ingestion"
|
||||
)
|
||||
effective_prefix = path_prefix.strip("/")
|
||||
else:
|
||||
effective_prefix = f"users/{sanitize_user_id(user)}"
|
||||
|
||||
logger.info(f"Finding all pages for user {user} (prefix: {effective_prefix})")
|
||||
|
||||
# List all pages (not search - search requires a query and may have stale index)
|
||||
pages = await self.wiki.list_all_pages(
|
||||
path_prefix=path_prefix or f"users/{user}"
|
||||
)
|
||||
pages = await self.wiki.list_all_pages(path_prefix=effective_prefix)
|
||||
|
||||
if not pages:
|
||||
logger.warning(f"No pages found for user {user}")
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
RAG Search service for Library Desk.
|
||||
|
||||
Provides web, news, and image search with content extraction:
|
||||
- Uses SearXNG for search queries
|
||||
- Uses Trafilatura for content extraction
|
||||
- Caches results in Redis
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.content_extractor import ContentExtractor
|
||||
from src.config import Settings
|
||||
from src.models.rag_search import (
|
||||
SearchType,
|
||||
RAGSearchRequest,
|
||||
RAGSearchResult,
|
||||
RAGSearchResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_domain(url: str) -> str:
|
||||
"""Extract domain name from URL, removing 'www.' prefix."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc
|
||||
return domain.removeprefix("www.")
|
||||
except Exception:
|
||||
return url
|
||||
|
||||
|
||||
class RAGSearchService:
|
||||
"""
|
||||
Service for RAG-optimized web search with content extraction.
|
||||
|
||||
Combines SearXNG search with Trafilatura content extraction
|
||||
and Redis caching for efficient RAG pipeline integration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
searxng_client: SearXNGClient,
|
||||
content_extractor: ContentExtractor,
|
||||
redis_client: aioredis.Redis,
|
||||
settings: Settings
|
||||
):
|
||||
"""
|
||||
Initialize RAG search service.
|
||||
|
||||
Args:
|
||||
searxng_client: SearXNG search client
|
||||
content_extractor: Trafilatura content extractor
|
||||
redis_client: Async Redis client for caching
|
||||
settings: Application settings
|
||||
"""
|
||||
self.searxng = searxng_client
|
||||
self.extractor = content_extractor
|
||||
self.redis = redis_client
|
||||
self.settings = settings
|
||||
|
||||
self.cache_ttl = settings.search_cache_ttl
|
||||
self.default_limit = settings.search_default_limit
|
||||
|
||||
logger.info(
|
||||
f"Initialized RAGSearchService: cache_ttl={self.cache_ttl}s, "
|
||||
f"default_limit={self.default_limit}"
|
||||
)
|
||||
|
||||
def _cache_key(self, query: str, search_type: str, limit: int) -> str:
|
||||
"""Generate cache key from search parameters."""
|
||||
key_data = f"{query}:{search_type}:{limit}"
|
||||
key_hash = hashlib.md5(key_data.encode()).hexdigest()
|
||||
return f"rag_search:{key_hash}"
|
||||
|
||||
async def _get_cached_result(self, cache_key: str) -> Optional[RAGSearchResponse]:
|
||||
"""Try to get cached search result."""
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
data = json.loads(cached)
|
||||
logger.debug(f"Cache hit: {cache_key}")
|
||||
return RAGSearchResponse(**data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache read failed: {e}")
|
||||
return None
|
||||
|
||||
async def _set_cached_result(self, cache_key: str, result: RAGSearchResponse):
|
||||
"""Cache search result."""
|
||||
try:
|
||||
await self.redis.setex(
|
||||
cache_key,
|
||||
self.cache_ttl,
|
||||
result.model_dump_json()
|
||||
)
|
||||
logger.debug(f"Cached result: {cache_key} (TTL={self.cache_ttl}s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache write failed: {e}")
|
||||
|
||||
async def _search_searxng(
|
||||
self,
|
||||
query: str,
|
||||
search_type: SearchType,
|
||||
limit: int
|
||||
) -> List[dict]:
|
||||
"""Execute search via SearXNG based on search type."""
|
||||
try:
|
||||
if search_type == SearchType.WEB:
|
||||
results = await self.searxng.search_general(
|
||||
query=query,
|
||||
limit=limit
|
||||
)
|
||||
elif search_type == SearchType.NEWS:
|
||||
results = await self.searxng.search_news(
|
||||
query=query,
|
||||
limit=limit
|
||||
)
|
||||
elif search_type == SearchType.IMAGES:
|
||||
results = await self.searxng.search_images(
|
||||
query=query,
|
||||
limit=limit
|
||||
)
|
||||
else:
|
||||
results = await self.searxng.search_general(
|
||||
query=query,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"SearXNG search failed: {e}")
|
||||
raise
|
||||
|
||||
async def _extract_content_for_results(
|
||||
self,
|
||||
results: List[dict]
|
||||
) -> List[RAGSearchResult]:
|
||||
"""Extract full content from search result URLs."""
|
||||
# Get URLs for extraction
|
||||
urls = [r.get("url", "") for r in results if r.get("url")]
|
||||
|
||||
# Extract content in parallel
|
||||
extraction_results = await self.extractor.extract_batch(urls)
|
||||
|
||||
# Build result objects
|
||||
search_results = []
|
||||
for i, raw_result in enumerate(results):
|
||||
url = raw_result.get("url", "")
|
||||
|
||||
# Find matching extraction result
|
||||
extracted_content = ""
|
||||
for ext_result in extraction_results:
|
||||
if ext_result.url == url and ext_result.success:
|
||||
extracted_content = ext_result.content
|
||||
break
|
||||
|
||||
# Get original snippet
|
||||
snippet = raw_result.get("content", "")
|
||||
if len(snippet) > 300:
|
||||
snippet = snippet[:300] + "..."
|
||||
|
||||
# Build result
|
||||
search_results.append(RAGSearchResult(
|
||||
title=raw_result.get("title", ""),
|
||||
url=url,
|
||||
content=extracted_content,
|
||||
snippet=snippet,
|
||||
source=extract_domain(url),
|
||||
published_date=raw_result.get("publishedDate")
|
||||
))
|
||||
|
||||
return search_results
|
||||
|
||||
def _generate_sources_summary(self, results: List[RAGSearchResult]) -> str:
|
||||
"""Generate markdown list of source URLs."""
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
lines = ["## Sources"]
|
||||
for i, r in enumerate(results, 1):
|
||||
lines.append(f"{i}. [{r.title}]({r.url})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
search_type: SearchType = SearchType.WEB,
|
||||
limit: Optional[int] = None,
|
||||
user: str = "default"
|
||||
) -> RAGSearchResponse:
|
||||
"""
|
||||
Execute RAG-optimized search.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
search_type: Type of search (web, news, images)
|
||||
limit: Maximum results to return (default from settings)
|
||||
user: User identifier for logging/rate limiting
|
||||
|
||||
Returns:
|
||||
RAGSearchResponse with extracted content and sources
|
||||
|
||||
Raises:
|
||||
ValueError: If query is empty
|
||||
Exception: If search fails
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
if not query or not query.strip():
|
||||
raise ValueError("Query cannot be empty")
|
||||
|
||||
effective_limit = limit or self.default_limit
|
||||
|
||||
# Check cache
|
||||
cache_key = self._cache_key(query, search_type.value, effective_limit)
|
||||
cached = await self._get_cached_result(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
logger.info(
|
||||
f"RAG search: '{query}' type={search_type.value} "
|
||||
f"limit={effective_limit} user={user}"
|
||||
)
|
||||
|
||||
# Execute search
|
||||
raw_results = await self._search_searxng(query, search_type, effective_limit)
|
||||
|
||||
# Extract content from results
|
||||
search_results = await self._extract_content_for_results(raw_results)
|
||||
|
||||
# Generate sources summary
|
||||
sources_summary = self._generate_sources_summary(search_results)
|
||||
|
||||
# Calculate timing
|
||||
search_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Build response
|
||||
response = RAGSearchResponse(
|
||||
query=query,
|
||||
search_type=search_type,
|
||||
results=search_results,
|
||||
total_results=len(search_results),
|
||||
search_time_ms=search_time_ms,
|
||||
sources_summary=sources_summary
|
||||
)
|
||||
|
||||
# Cache result
|
||||
await self._set_cached_result(cache_key, response)
|
||||
|
||||
logger.info(
|
||||
f"RAG search completed: {len(search_results)} results "
|
||||
f"in {search_time_ms}ms"
|
||||
)
|
||||
|
||||
return response
|
||||
+422
-34
@@ -14,7 +14,7 @@ import logging
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.core.multi_tenancy import get_qdrant_collection_name
|
||||
from src.core.multi_tenancy import get_qdrant_collection_name, is_path_in_user_namespace
|
||||
from src.models.vector import (
|
||||
SearchResult, SearchResponse, VectorUpdateSummary,
|
||||
DocumentChunk, CollectionInfo, CollectionListResponse
|
||||
@@ -120,6 +120,16 @@ class VectorService:
|
||||
if not page:
|
||||
raise ValueError(f"Page {page_id} not found")
|
||||
|
||||
# TENANT ISOLATION: only pages inside the user's own wiki
|
||||
# namespace may be embedded into that user's collection.
|
||||
# Without this check any tenant could ingest (and then read)
|
||||
# another tenant's wiki content.
|
||||
if not is_path_in_user_namespace(page.get("path", ""), user):
|
||||
raise ValueError(
|
||||
f"Page {page_id} (path: {page.get('path')!r}) is outside "
|
||||
f"user '{user}' namespace - refusing cross-tenant ingestion"
|
||||
)
|
||||
|
||||
# Get collection name for user
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
@@ -144,50 +154,79 @@ class VectorService:
|
||||
chunks = self._chunk_text(content)
|
||||
logger.info(f"Split page {page_id} into {len(chunks)} chunks")
|
||||
|
||||
# Delete existing chunks for this page
|
||||
deleted_count = await self.qdrant.delete_by_filter(
|
||||
collection_name=collection_name,
|
||||
filter_conditions={"page_id": page_id}
|
||||
)
|
||||
# Generate ALL embeddings in one batched /api/embed call
|
||||
# (previously one sequential Ollama round-trip per chunk)
|
||||
embeddings = await self.ollama.embed_batch(chunks)
|
||||
|
||||
# Generate embeddings and upsert chunks
|
||||
chunks_created = 0
|
||||
for idx, chunk_text in enumerate(chunks):
|
||||
# Generate deterministic UUID from page_id and chunk_index
|
||||
chunk_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}"))
|
||||
|
||||
# Generate embedding
|
||||
embedding = await self.ollama.embed(chunk_text)
|
||||
# Build points; deterministic uuid5 IDs mean re-upserting the
|
||||
# same page overwrites its previous chunks in place.
|
||||
points = []
|
||||
chunks_skipped = 0
|
||||
embedding_dim = 768
|
||||
for idx, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
if not embedding:
|
||||
logger.error(f"Failed to generate embedding for chunk {chunk_id}")
|
||||
chunks_skipped += 1
|
||||
logger.error(f"Failed to generate embedding for page {page_id} chunk {idx}")
|
||||
continue
|
||||
|
||||
# Prepare metadata
|
||||
metadata = {
|
||||
"page_id": page_id,
|
||||
"page_title": title,
|
||||
"page_path": path,
|
||||
"chunk_index": idx,
|
||||
"chunk_text": chunk_text,
|
||||
"user": user
|
||||
}
|
||||
embedding_dim = len(embedding)
|
||||
chunk_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}"))
|
||||
points.append({
|
||||
"id": chunk_id,
|
||||
"vector": embedding,
|
||||
"payload": {
|
||||
"page_id": page_id,
|
||||
"page_title": title,
|
||||
"page_path": path,
|
||||
"chunk_index": idx,
|
||||
"chunk_text": chunk_text,
|
||||
"user": user
|
||||
}
|
||||
})
|
||||
|
||||
# Upsert to Qdrant
|
||||
success = await self.qdrant.upsert_vector(
|
||||
# Upsert BEFORE deleting stale points. The old order (delete all,
|
||||
# then embed+upsert one by one) left the page with ZERO vectors if
|
||||
# anything failed mid-way; now old vectors survive until the new
|
||||
# ones are safely stored.
|
||||
chunks_created = 0
|
||||
if points:
|
||||
chunks_created = await self.qdrant.upsert_points(
|
||||
collection_name=collection_name,
|
||||
vector_id=chunk_id,
|
||||
vector=embedding,
|
||||
payload=metadata
|
||||
points=points
|
||||
)
|
||||
|
||||
if success:
|
||||
chunks_created += 1
|
||||
# Prune stale points from a previous version of the page (chunk
|
||||
# indexes beyond the new count, or indexes whose new embedding
|
||||
# failed). Only prune if the new upsert actually stored points —
|
||||
# a fully failed embedding pass must not wipe the old vectors.
|
||||
deleted_count = 0
|
||||
if points:
|
||||
new_ids = {p["id"] for p in points}
|
||||
existing = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection_name,
|
||||
filter_conditions={"page_id": page_id},
|
||||
with_payload=False
|
||||
)
|
||||
stale_ids = [pt["id"] for pt in existing if pt["id"] not in new_ids]
|
||||
if stale_ids:
|
||||
deleted_count = await self.qdrant.delete_by_ids(
|
||||
collection_name=collection_name,
|
||||
point_ids=stale_ids
|
||||
)
|
||||
|
||||
processing_time_ms = (time.time() - start_time) * 1000
|
||||
|
||||
if chunks_created == 0:
|
||||
status = "failed"
|
||||
elif chunks_skipped > 0:
|
||||
status = "partial"
|
||||
else:
|
||||
status = "success"
|
||||
|
||||
logger.info(
|
||||
f"Updated vectors for page {page_id}: "
|
||||
f"{chunks_created} chunks created, {deleted_count} old chunks deleted"
|
||||
f"{chunks_created} chunks created, {chunks_skipped} skipped, "
|
||||
f"{deleted_count} stale chunks deleted ({status})"
|
||||
)
|
||||
|
||||
return VectorUpdateSummary(
|
||||
@@ -195,10 +234,16 @@ class VectorService:
|
||||
page_title=title,
|
||||
chunks_created=chunks_created,
|
||||
chunks_deleted=deleted_count,
|
||||
chunks_skipped=chunks_skipped,
|
||||
total_chunks=chunks_created,
|
||||
embedding_dim=len(embedding) if embedding else 768,
|
||||
embedding_dim=embedding_dim,
|
||||
processing_time_ms=processing_time_ms,
|
||||
success=True
|
||||
success=chunks_created > 0,
|
||||
status=status,
|
||||
error_message=(
|
||||
f"{chunks_skipped}/{len(chunks)} chunk embeddings failed"
|
||||
if chunks_skipped else None
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -356,3 +401,346 @@ class VectorService:
|
||||
collections=[],
|
||||
total=0
|
||||
)
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def delete_document_chunks(
|
||||
self,
|
||||
document_id: str,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete all chunks for a document (Document Store).
|
||||
|
||||
Args:
|
||||
document_id: Document UUID
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of chunks deleted
|
||||
"""
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
try:
|
||||
deleted_count = await self.qdrant.delete_by_filter(
|
||||
collection_name=collection_name,
|
||||
filter_conditions={"document_id": document_id}
|
||||
)
|
||||
|
||||
logger.info(f"Deleted chunks for document {document_id}")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete chunks for document {document_id}: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def delete_paperless_document_chunks(
|
||||
self,
|
||||
paperless_id: int,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete all chunks for a Paperless document.
|
||||
|
||||
Args:
|
||||
paperless_id: Paperless-ngx document ID
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of chunks deleted
|
||||
"""
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
try:
|
||||
deleted_count = await self.qdrant.delete_by_filter(
|
||||
collection_name=collection_name,
|
||||
filter_conditions={
|
||||
"doc_type": "document",
|
||||
"paperless_id": paperless_id
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Deleted chunks for Paperless document {paperless_id}")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete chunks for Paperless document {paperless_id}: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def delete_collection_chunks(
|
||||
self,
|
||||
collection_id: str,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Delete all chunks for a document collection.
|
||||
|
||||
Args:
|
||||
collection_id: Collection UUID
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of chunks deleted
|
||||
"""
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
try:
|
||||
deleted_count = await self.qdrant.delete_by_filter(
|
||||
collection_name=collection_name,
|
||||
filter_conditions={"collection_id": collection_id}
|
||||
)
|
||||
|
||||
logger.info(f"Deleted chunks for collection {collection_id}")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete chunks for collection {collection_id}: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def get_all_chunk_references(
|
||||
self,
|
||||
user: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all chunk references for orphan detection.
|
||||
|
||||
Returns list of {id, page_id, document_id} for all chunks.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
List of chunk references
|
||||
"""
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
try:
|
||||
# Check if collection exists
|
||||
exists = await self.qdrant.collection_exists(collection_name)
|
||||
if not exists:
|
||||
return []
|
||||
|
||||
all_points = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection_name,
|
||||
batch_size=100,
|
||||
with_payload=True
|
||||
)
|
||||
|
||||
references = []
|
||||
for point in all_points:
|
||||
payload = point.get("payload", {})
|
||||
references.append({
|
||||
"chunk_id": point["id"],
|
||||
"page_id": payload.get("page_id"),
|
||||
"document_id": payload.get("document_id"),
|
||||
"paperless_id": payload.get("paperless_id"), # For Paperless documents
|
||||
"collection_id": payload.get("collection_id"),
|
||||
"doc_type": payload.get("doc_type", "wiki")
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(references)} chunks for user {user}")
|
||||
return references
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get chunk references: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def purge_chunks_by_ids(
|
||||
self,
|
||||
user: str,
|
||||
chunk_ids: List[str]
|
||||
) -> int:
|
||||
"""
|
||||
Delete specific chunks by their IDs.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
chunk_ids: List of chunk IDs to delete
|
||||
|
||||
Returns:
|
||||
Number of chunks deleted
|
||||
"""
|
||||
if not chunk_ids:
|
||||
return 0
|
||||
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
try:
|
||||
deleted_count = await self.qdrant.delete_by_ids(
|
||||
collection_name=collection_name,
|
||||
point_ids=chunk_ids
|
||||
)
|
||||
|
||||
logger.info(f"Purged {deleted_count} orphan chunks for user {user}")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to purge chunks: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
async def find_duplicate_pairs(
|
||||
self,
|
||||
user: str,
|
||||
similarity_threshold: float = 0.9,
|
||||
max_chunks_scanned: int = 2000,
|
||||
max_pairs: int = 100
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Tenant-scoped similarity scan for near-duplicate wiki pages.
|
||||
|
||||
Scrolls the tenant's own Qdrant collection (never another tenant's),
|
||||
then queries each chunk's vector against the same collection. Chunk
|
||||
pairs from DIFFERENT pages scoring above the threshold are grouped
|
||||
per page pair with the best score and the number of matching chunk
|
||||
pairs. Read-only: nothing is modified.
|
||||
|
||||
Args:
|
||||
user: Tenant user identifier
|
||||
similarity_threshold: Minimum cosine similarity (default 0.9)
|
||||
max_chunks_scanned: Safety cap on chunks used as probes
|
||||
max_pairs: Maximum page pairs returned (highest score first)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"chunks_scanned": int,
|
||||
"duplicate_groups": [
|
||||
{
|
||||
"pages": [{page_id, path, title}, {page_id, path, title}],
|
||||
"max_similarity": float,
|
||||
"matching_chunk_pairs": int
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
"""
|
||||
collection_name = get_qdrant_collection_name(user)
|
||||
|
||||
exists = await self.qdrant.collection_exists(collection_name)
|
||||
if not exists:
|
||||
return {"chunks_scanned": 0, "duplicate_groups": []}
|
||||
|
||||
points = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection_name,
|
||||
batch_size=100,
|
||||
with_payload=True,
|
||||
with_vectors=True
|
||||
)
|
||||
|
||||
# Only wiki chunks participate (documents have their own dedup story)
|
||||
wiki_points = [
|
||||
p for p in points
|
||||
if p.get("vector") is not None
|
||||
and (p.get("payload") or {}).get("doc_type", "wiki") == "wiki"
|
||||
and (p.get("payload") or {}).get("page_id")
|
||||
][:max_chunks_scanned]
|
||||
|
||||
page_meta: Dict[int, Dict[str, Any]] = {}
|
||||
pair_stats: Dict[tuple, Dict[str, Any]] = {}
|
||||
seen_chunk_pairs = set()
|
||||
|
||||
for point in wiki_points:
|
||||
payload = point.get("payload") or {}
|
||||
page_id = payload.get("page_id")
|
||||
page_meta.setdefault(page_id, {
|
||||
"page_id": page_id,
|
||||
"path": payload.get("page_path", ""),
|
||||
"title": payload.get("page_title", "")
|
||||
})
|
||||
|
||||
hits = await self.qdrant.search_vectors(
|
||||
collection_name=collection_name,
|
||||
query_vector=point["vector"],
|
||||
limit=10,
|
||||
score_threshold=similarity_threshold
|
||||
)
|
||||
|
||||
for hit in hits:
|
||||
hit_payload = hit.get("payload") or {}
|
||||
hit_page_id = hit_payload.get("page_id")
|
||||
if not hit_page_id or hit_page_id == page_id:
|
||||
continue
|
||||
if hit_payload.get("doc_type", "wiki") != "wiki":
|
||||
continue
|
||||
|
||||
# Deduplicate the A->B / B->A chunk pair directions
|
||||
chunk_pair = tuple(sorted((point["id"], hit["id"])))
|
||||
if chunk_pair in seen_chunk_pairs:
|
||||
continue
|
||||
seen_chunk_pairs.add(chunk_pair)
|
||||
|
||||
page_meta.setdefault(hit_page_id, {
|
||||
"page_id": hit_page_id,
|
||||
"path": hit_payload.get("page_path", ""),
|
||||
"title": hit_payload.get("page_title", "")
|
||||
})
|
||||
|
||||
page_pair = tuple(sorted((page_id, hit_page_id)))
|
||||
stats = pair_stats.setdefault(page_pair, {
|
||||
"max_similarity": 0.0,
|
||||
"matching_chunk_pairs": 0
|
||||
})
|
||||
stats["max_similarity"] = max(stats["max_similarity"], hit["score"])
|
||||
stats["matching_chunk_pairs"] += 1
|
||||
|
||||
groups = [
|
||||
{
|
||||
"pages": [page_meta[a], page_meta[b]],
|
||||
"max_similarity": stats["max_similarity"],
|
||||
"matching_chunk_pairs": stats["matching_chunk_pairs"]
|
||||
}
|
||||
for (a, b), stats in pair_stats.items()
|
||||
]
|
||||
groups.sort(key=lambda g: g["max_similarity"], reverse=True)
|
||||
|
||||
logger.info(
|
||||
f"Duplicate scan for {user}: {len(wiki_points)} chunks scanned, "
|
||||
f"{len(groups)} page pairs above {similarity_threshold}"
|
||||
)
|
||||
|
||||
return {
|
||||
"chunks_scanned": len(wiki_points),
|
||||
"duplicate_groups": groups[:max_pairs]
|
||||
}
|
||||
|
||||
def find_chunks_without_graph_nodes(
|
||||
self,
|
||||
chunk_references: List[Dict[str, Any]],
|
||||
graph_references: List[Dict[str, Any]]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Find vector chunks that have no corresponding graph Document node.
|
||||
|
||||
Used for bidirectional orphan detection - vectors without graph representation.
|
||||
|
||||
Args:
|
||||
chunk_references: List from get_all_chunk_references()
|
||||
graph_references: List from GraphService.get_all_document_references()
|
||||
|
||||
Returns:
|
||||
List of orphan chunk IDs
|
||||
"""
|
||||
# Build sets of IDs that have graph nodes
|
||||
graph_page_ids = {
|
||||
ref.get("page_id") for ref in graph_references
|
||||
if ref.get("doc_type") == "wiki" and ref.get("page_id")
|
||||
}
|
||||
graph_doc_ids = {
|
||||
ref.get("document_id") for ref in graph_references
|
||||
if ref.get("doc_type") != "wiki" and ref.get("document_id")
|
||||
}
|
||||
|
||||
# Find chunks with no graph node
|
||||
orphan_ids = []
|
||||
for chunk in chunk_references:
|
||||
doc_type = chunk.get("doc_type", "wiki")
|
||||
|
||||
if doc_type == "wiki":
|
||||
page_id = chunk.get("page_id")
|
||||
if page_id and page_id not in graph_page_ids:
|
||||
orphan_ids.append(chunk["chunk_id"])
|
||||
else:
|
||||
document_id = chunk.get("document_id")
|
||||
if document_id and document_id not in graph_doc_ids:
|
||||
orphan_ids.append(chunk["chunk_id"])
|
||||
|
||||
logger.info(f"Found {len(orphan_ids)} vector chunks without graph nodes")
|
||||
return orphan_ids
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
"""
|
||||
Volatile Fetch service for Library Desk.
|
||||
|
||||
Orchestrates fetching data from external APIs and storing in volatile cache.
|
||||
Called by scheduler for prefetch or by HybridRAG for reactive caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from src.apis import (
|
||||
OpenMeteoProvider,
|
||||
AggregatedNewsProvider,
|
||||
AlphaVantageProvider,
|
||||
CurrentWeather,
|
||||
WeatherForecast,
|
||||
SunTimes,
|
||||
AirQuality,
|
||||
NewsFeed,
|
||||
StockQuote,
|
||||
)
|
||||
from src.services.volatile_service import VolatileCacheService
|
||||
from src.models.volatile import VolatileRecordResponse, VolatileNamespace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Result of a volatile fetch operation."""
|
||||
success: bool
|
||||
namespace: str
|
||||
key: str
|
||||
record: Optional[VolatileRecordResponse] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentFetchResult:
|
||||
"""Result of combined environment fetch (weather + air quality)."""
|
||||
success: bool
|
||||
key: str
|
||||
weather: Optional[FetchResult] = None
|
||||
air_quality: Optional[FetchResult] = None
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class VolatileFetchService:
|
||||
"""
|
||||
Service to fetch external data and store in volatile cache.
|
||||
|
||||
Supports:
|
||||
- Weather: Current conditions and forecast via Open-Meteo
|
||||
- News: Headlines from configured sources (NOS, BBC)
|
||||
- Financial: Stock/crypto quotes via Alpha Vantage
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
volatile_service: VolatileCacheService,
|
||||
weather_provider: OpenMeteoProvider,
|
||||
news_provider: Optional[AggregatedNewsProvider] = None,
|
||||
financial_provider: Optional[AlphaVantageProvider] = None,
|
||||
):
|
||||
"""
|
||||
Initialize volatile fetch service.
|
||||
|
||||
Args:
|
||||
volatile_service: Service for volatile cache storage
|
||||
weather_provider: Open-Meteo weather provider
|
||||
news_provider: Aggregated news provider (optional)
|
||||
financial_provider: Alpha Vantage provider (optional)
|
||||
"""
|
||||
self.volatile = volatile_service
|
||||
self.weather = weather_provider
|
||||
self.news = news_provider
|
||||
self.financial = financial_provider
|
||||
|
||||
async def fetch_current_weather(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
ttl: int = 3600, # 1 hour
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch current weather conditions for a city and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
ttl: Time-to-live in seconds (default 1 hour)
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
try:
|
||||
# Geocode city and get current conditions
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
current = await self.weather.get_current(location)
|
||||
|
||||
# Generate natural language summary
|
||||
text = current.to_text()
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
"location": current.location,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored current weather for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch current weather for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="weather",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_forecast(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
days: int = 7,
|
||||
ttl: int = 43200, # 12 hours
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch weather forecast for a city and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
days: Number of forecast days (1-16)
|
||||
ttl: Time-to-live in seconds (default 12 hours)
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
try:
|
||||
# Geocode city and get forecast
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
forecast = await self.weather.get_forecast(location, days=days)
|
||||
|
||||
# Build daily forecast array
|
||||
daily_forecasts = []
|
||||
for day in forecast.daily:
|
||||
daily_forecasts.append({
|
||||
"date": day.date.isoformat(),
|
||||
"day_name": day.date.strftime("%A"),
|
||||
"temp_high": day.temp_high,
|
||||
"temp_low": day.temp_low,
|
||||
"conditions": day.condition_text,
|
||||
"condition_code": day.condition.value,
|
||||
"precipitation_chance": day.precipitation_chance,
|
||||
"precipitation_mm": day.precipitation_mm,
|
||||
"uv_index_max": day.uv_index_max,
|
||||
})
|
||||
|
||||
# Generate natural language summary
|
||||
forecast_lines = [f"{city} {days}-day forecast:"]
|
||||
for day in forecast.daily:
|
||||
forecast_lines.append(day.to_text())
|
||||
text = "\n".join(forecast_lines)
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"days": days,
|
||||
"daily": daily_forecasts,
|
||||
"location": forecast.current.location,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.FORECAST,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored {days}-day forecast for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch forecast for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="forecast",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_news(
|
||||
self,
|
||||
user: str,
|
||||
category: str = "general",
|
||||
limit: int = 10,
|
||||
ttl: int = 7200, # 2 hours
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch news headlines and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
category: News category (general, tech, world, etc.)
|
||||
limit: Maximum headlines to fetch
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
if not self.news:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="news",
|
||||
key=category,
|
||||
error="News provider not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
feed = await self.news.get_feed(category, limit=limit)
|
||||
|
||||
# Convert to storage format
|
||||
headlines = []
|
||||
for item in feed.items:
|
||||
headlines.append({
|
||||
"title": item.title,
|
||||
"description": item.description,
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"published": item.published.isoformat() if item.published else None,
|
||||
})
|
||||
|
||||
data = {
|
||||
"category": category,
|
||||
"headlines": headlines,
|
||||
"count": len(headlines),
|
||||
"sources": list(set(h["source"] for h in headlines)),
|
||||
"text": feed.to_text(),
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.NEWS,
|
||||
key=category,
|
||||
data=data,
|
||||
source="aggregated",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored {len(headlines)} headlines for {category} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="news",
|
||||
key=category,
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch news for {category}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="news",
|
||||
key=category,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_stock(
|
||||
self,
|
||||
user: str,
|
||||
symbol: str,
|
||||
ttl: int = 300, # 5 minutes
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch stock quote and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
symbol: Stock ticker symbol (e.g., "AAPL")
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
if not self.financial:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=symbol.lower(),
|
||||
error="Financial provider not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
quote = await self.financial.get_quote(symbol)
|
||||
if not quote:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=symbol.lower(),
|
||||
error=f"No quote found for symbol: {symbol}"
|
||||
)
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"symbol": quote.symbol,
|
||||
"name": quote.name,
|
||||
"price": quote.price,
|
||||
"currency": quote.currency,
|
||||
"change": quote.change,
|
||||
"change_percent": quote.change_percent,
|
||||
"text": quote.to_text(),
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.FINANCIAL,
|
||||
key=symbol.lower(),
|
||||
data=data,
|
||||
source="alphavantage",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored quote for {symbol} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="financial",
|
||||
key=symbol.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch quote for {symbol}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=symbol.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_crypto(
|
||||
self,
|
||||
user: str,
|
||||
symbol: str,
|
||||
market: str = "USD",
|
||||
ttl: int = 300, # 5 minutes
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch cryptocurrency quote and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
symbol: Crypto symbol (e.g., "BTC", "ETH")
|
||||
market: Market currency (default: USD)
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
if not self.financial:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=f"{symbol.lower()}_{market.lower()}",
|
||||
error="Financial provider not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
quote = await self.financial.get_crypto_quote(symbol, market)
|
||||
if not quote:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=f"{symbol.lower()}_{market.lower()}",
|
||||
error=f"No quote found for crypto: {symbol}/{market}"
|
||||
)
|
||||
|
||||
key = f"{symbol.lower()}_{market.lower()}"
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"symbol": quote.symbol,
|
||||
"name": quote.name,
|
||||
"price": quote.price,
|
||||
"currency": quote.currency,
|
||||
"text": quote.to_text(),
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.FINANCIAL,
|
||||
key=key,
|
||||
data=data,
|
||||
source="alphavantage",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored crypto quote for {symbol}/{market} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="financial",
|
||||
key=key,
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch crypto quote for {symbol}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="financial",
|
||||
key=f"{symbol.lower()}_{market.lower()}",
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_sun_times(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
ttl: int = 86400, # 24 hours
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch sunrise/sunset times for a city and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
try:
|
||||
# Geocode city and get sun times
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="sun",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
sun_times = await self.weather.get_sun_times(location)
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"location": sun_times.location,
|
||||
"date": sun_times.date.isoformat(),
|
||||
"sunrise": sun_times.sunrise.strftime("%H:%M"),
|
||||
"sunset": sun_times.sunset.strftime("%H:%M"),
|
||||
"sunrise_iso": sun_times.sunrise.isoformat(),
|
||||
"sunset_iso": sun_times.sunset.isoformat(),
|
||||
"daylight_duration_seconds": sun_times.daylight_duration,
|
||||
"daylight_hours": sun_times.daylight_duration / 3600,
|
||||
"text": sun_times.to_text(),
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.SUN,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored sun times for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="sun",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch sun times for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="sun",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_air_quality(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
ttl: int = 3600, # 1 hour
|
||||
) -> FetchResult:
|
||||
"""
|
||||
Fetch air quality data for a city and store in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded)
|
||||
ttl: Time-to-live in seconds
|
||||
|
||||
Returns:
|
||||
FetchResult with success status and stored record
|
||||
"""
|
||||
try:
|
||||
# Geocode city and get air quality
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="air_quality",
|
||||
key=city.lower(),
|
||||
error=f"Could not geocode city: {city}"
|
||||
)
|
||||
|
||||
air_quality = await self.weather.get_air_quality(location)
|
||||
|
||||
# Convert to storage format
|
||||
data = {
|
||||
"location": air_quality.location,
|
||||
"aqi_european": air_quality.aqi_european,
|
||||
"aqi_us": air_quality.aqi_us,
|
||||
"pm2_5": air_quality.pm2_5,
|
||||
"pm10": air_quality.pm10,
|
||||
"ozone": air_quality.ozone,
|
||||
"nitrogen_dioxide": air_quality.nitrogen_dioxide,
|
||||
"sulphur_dioxide": air_quality.sulphur_dioxide,
|
||||
"carbon_monoxide": air_quality.carbon_monoxide,
|
||||
"pollen_grass": air_quality.pollen_grass,
|
||||
"pollen_birch": air_quality.pollen_birch,
|
||||
"pollen_alder": air_quality.pollen_alder,
|
||||
"text": air_quality.to_text(),
|
||||
}
|
||||
|
||||
# Store in volatile cache
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.AIR_QUALITY,
|
||||
key=city.lower(),
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
logger.info(f"Stored air quality for {city} (user={user})")
|
||||
return FetchResult(
|
||||
success=True,
|
||||
namespace="air_quality",
|
||||
key=city.lower(),
|
||||
record=record
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch air quality for {city}: {e}")
|
||||
return FetchResult(
|
||||
success=False,
|
||||
namespace="air_quality",
|
||||
key=city.lower(),
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
async def fetch_environment(
|
||||
self,
|
||||
user: str,
|
||||
city: str,
|
||||
weather_ttl: int = 3600,
|
||||
air_quality_ttl: int = 3600,
|
||||
) -> EnvironmentFetchResult:
|
||||
"""
|
||||
Fetch weather and air quality concurrently for a city.
|
||||
|
||||
Performs a single geocode lookup and fetches both weather and air quality
|
||||
data in parallel, storing both in volatile cache.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
city: City name (will be geocoded once)
|
||||
weather_ttl: TTL for weather data (default 1 hour)
|
||||
air_quality_ttl: TTL for air quality data (default 1 hour)
|
||||
|
||||
Returns:
|
||||
EnvironmentFetchResult with both weather and air quality results
|
||||
"""
|
||||
errors: list[str] = []
|
||||
key = city.lower()
|
||||
|
||||
# Single geocode lookup (shared by both fetches)
|
||||
try:
|
||||
location = await self.weather.geocode(city)
|
||||
if not location:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Could not geocode city: {city}"]
|
||||
)
|
||||
except Exception as e:
|
||||
return EnvironmentFetchResult(
|
||||
success=False,
|
||||
key=key,
|
||||
errors=[f"Geocoding failed: {e}"]
|
||||
)
|
||||
|
||||
# Fetch weather and air quality concurrently
|
||||
async def fetch_weather_data() -> FetchResult:
|
||||
try:
|
||||
current = await self.weather.get_current(location)
|
||||
text = current.to_text()
|
||||
data = {
|
||||
"temperature": current.temperature,
|
||||
"feels_like": current.feels_like,
|
||||
"humidity": current.humidity,
|
||||
"wind_speed": current.wind_speed,
|
||||
"wind_direction": current.wind_direction,
|
||||
"conditions": current.condition_text,
|
||||
"condition_code": current.condition.value,
|
||||
"uv_index": current.uv_index,
|
||||
"location": current.location,
|
||||
"text": text,
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.WEATHER,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=weather_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="weather", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="weather", key=key, error=str(e))
|
||||
|
||||
async def fetch_air_quality_data() -> FetchResult:
|
||||
try:
|
||||
air_quality = await self.weather.get_air_quality(location)
|
||||
data = {
|
||||
"location": air_quality.location,
|
||||
"aqi_european": air_quality.aqi_european,
|
||||
"aqi_us": air_quality.aqi_us,
|
||||
"pm2_5": air_quality.pm2_5,
|
||||
"pm10": air_quality.pm10,
|
||||
"ozone": air_quality.ozone,
|
||||
"nitrogen_dioxide": air_quality.nitrogen_dioxide,
|
||||
"sulphur_dioxide": air_quality.sulphur_dioxide,
|
||||
"carbon_monoxide": air_quality.carbon_monoxide,
|
||||
"pollen_grass": air_quality.pollen_grass,
|
||||
"pollen_birch": air_quality.pollen_birch,
|
||||
"pollen_alder": air_quality.pollen_alder,
|
||||
"text": air_quality.to_text(),
|
||||
}
|
||||
record = await self.volatile.store(
|
||||
user=user,
|
||||
namespace=VolatileNamespace.AIR_QUALITY,
|
||||
key=key,
|
||||
data=data,
|
||||
source="openmeteo",
|
||||
ttl=air_quality_ttl,
|
||||
)
|
||||
return FetchResult(success=True, namespace="air_quality", key=key, record=record)
|
||||
except Exception as e:
|
||||
return FetchResult(success=False, namespace="air_quality", key=key, error=str(e))
|
||||
|
||||
# Run both fetches concurrently
|
||||
weather_result, air_quality_result = await asyncio.gather(
|
||||
fetch_weather_data(),
|
||||
fetch_air_quality_data(),
|
||||
)
|
||||
|
||||
# Collect any errors
|
||||
if not weather_result.success:
|
||||
errors.append(f"Weather: {weather_result.error}")
|
||||
if not air_quality_result.success:
|
||||
errors.append(f"Air quality: {air_quality_result.error}")
|
||||
|
||||
success = weather_result.success or air_quality_result.success
|
||||
logger.info(
|
||||
f"Environment fetch for {city} (user={user}): "
|
||||
f"weather={'ok' if weather_result.success else 'failed'}, "
|
||||
f"air_quality={'ok' if air_quality_result.success else 'failed'}"
|
||||
)
|
||||
|
||||
return EnvironmentFetchResult(
|
||||
success=success,
|
||||
key=key,
|
||||
weather=weather_result,
|
||||
air_quality=air_quality_result,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -0,0 +1,571 @@
|
||||
"""
|
||||
Volatile Cache service for Library Desk.
|
||||
|
||||
Provides ephemeral data storage with TTL using Qdrant vectors:
|
||||
- Weather, news, financial data
|
||||
- Transit schedules, traffic conditions
|
||||
- System status, social notifications
|
||||
|
||||
Data is stored as embedded vectors for semantic search retrieval.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.config import Settings
|
||||
from src.models.volatile import (
|
||||
VolatileRecordResponse,
|
||||
VolatileNamespace,
|
||||
NAMESPACE_DEFAULT_TTL,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VolatileCacheService:
|
||||
"""
|
||||
Service for volatile data with TTL stored in Qdrant.
|
||||
|
||||
Stores ephemeral data as vectors for semantic search retrieval.
|
||||
Each user has an isolated volatile collection.
|
||||
"""
|
||||
|
||||
COLLECTION_PREFIX = "volatile_"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
qdrant_client: QdrantClientWrapper,
|
||||
ollama_client: OllamaClient,
|
||||
settings: Settings
|
||||
):
|
||||
"""
|
||||
Initialize volatile cache service.
|
||||
|
||||
Args:
|
||||
qdrant_client: Qdrant client for vector storage
|
||||
ollama_client: Ollama client for embeddings
|
||||
settings: Application settings
|
||||
"""
|
||||
self.qdrant = qdrant_client
|
||||
self.ollama = ollama_client
|
||||
self.settings = settings
|
||||
|
||||
logger.info("Initialized VolatileCacheService (Qdrant backend)")
|
||||
|
||||
def _collection_name(self, user: str) -> str:
|
||||
"""
|
||||
Get volatile collection name for user.
|
||||
|
||||
The user id is sanitized (same rules as the document collections)
|
||||
so raw identifiers cannot alias or escape the per-tenant
|
||||
collection naming scheme.
|
||||
"""
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
return f"{self.COLLECTION_PREFIX}{sanitize_user_id(user)}"
|
||||
|
||||
def _make_vector_id(self, namespace: str, key: str) -> str:
|
||||
"""
|
||||
Generate deterministic vector ID for namespace/key.
|
||||
|
||||
Same namespace+key always produces same ID for upsert behavior.
|
||||
"""
|
||||
combined = f"{namespace}:{key}"
|
||||
return hashlib.md5(combined.encode()).hexdigest()
|
||||
|
||||
def _get_default_ttl(self, namespace: str) -> int:
|
||||
"""Get default TTL for a namespace."""
|
||||
try:
|
||||
ns = VolatileNamespace(namespace)
|
||||
return NAMESPACE_DEFAULT_TTL.get(ns, self.settings.volatile_default_ttl)
|
||||
except ValueError:
|
||||
return self.settings.volatile_default_ttl
|
||||
|
||||
def _current_timestamp_ms(self) -> int:
|
||||
"""Get current timestamp in milliseconds."""
|
||||
return int(time.time() * 1000)
|
||||
|
||||
def _to_natural_language(
|
||||
self,
|
||||
namespace: str,
|
||||
key: str,
|
||||
data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Convert structured data to natural language for embedding.
|
||||
|
||||
This creates a text representation that embeds well semantically.
|
||||
"""
|
||||
# Template-based conversion for known namespaces
|
||||
if namespace == VolatileNamespace.WEATHER:
|
||||
temp = data.get("temperature", data.get("temp", "unknown"))
|
||||
conditions = data.get("conditions", data.get("weather", ""))
|
||||
humidity = data.get("humidity", "")
|
||||
text = f"Current weather in {key}: {temp}°C"
|
||||
if conditions:
|
||||
text += f", {conditions}"
|
||||
if humidity:
|
||||
text += f", humidity {humidity}%"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.NEWS:
|
||||
title = data.get("title", data.get("headline", ""))
|
||||
summary = data.get("summary", data.get("description", ""))
|
||||
source = data.get("source", "")
|
||||
text = f"News: {title}"
|
||||
if summary:
|
||||
text += f". {summary}"
|
||||
if source:
|
||||
text += f" (Source: {source})"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.FINANCIAL:
|
||||
symbol = data.get("symbol", key)
|
||||
price = data.get("price", "")
|
||||
change = data.get("change", data.get("change_percent", ""))
|
||||
text = f"Financial data for {symbol}"
|
||||
if price:
|
||||
text += f": price {price}"
|
||||
if change:
|
||||
text += f", change {change}%"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.TRANSIT:
|
||||
route = data.get("route", data.get("line", key))
|
||||
status = data.get("status", "")
|
||||
delay = data.get("delay", data.get("delay_minutes", ""))
|
||||
text = f"Transit {route}"
|
||||
if status:
|
||||
text += f": {status}"
|
||||
if delay:
|
||||
text += f", delay {delay} minutes"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.TRAFFIC:
|
||||
location = data.get("location", key)
|
||||
duration = data.get("duration", data.get("travel_time", ""))
|
||||
congestion = data.get("congestion", "")
|
||||
text = f"Traffic for {location}"
|
||||
if duration:
|
||||
text += f": {duration} minutes"
|
||||
if congestion:
|
||||
text += f", congestion level {congestion}"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.AIR_QUALITY:
|
||||
location = data.get("location", key)
|
||||
aqi = data.get("aqi", data.get("index", ""))
|
||||
quality = data.get("quality", "")
|
||||
text = f"Air quality in {location}"
|
||||
if aqi:
|
||||
text += f": AQI {aqi}"
|
||||
if quality:
|
||||
text += f" ({quality})"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.SPORTS:
|
||||
event = data.get("event", data.get("match", key))
|
||||
score = data.get("score", "")
|
||||
status = data.get("status", "")
|
||||
text = f"Sports: {event}"
|
||||
if score:
|
||||
text += f" - Score: {score}"
|
||||
if status:
|
||||
text += f" ({status})"
|
||||
return text
|
||||
|
||||
elif namespace == VolatileNamespace.SYSTEM:
|
||||
service = data.get("service", key)
|
||||
status = data.get("status", "unknown")
|
||||
message = data.get("message", "")
|
||||
text = f"System status for {service}: {status}"
|
||||
if message:
|
||||
text += f". {message}"
|
||||
return text
|
||||
|
||||
# Fallback: serialize key fields
|
||||
text_parts = [f"{namespace} data for {key}:"]
|
||||
for k, v in data.items():
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
text_parts.append(f"{k}: {v}")
|
||||
return " ".join(text_parts)
|
||||
|
||||
async def store(
|
||||
self,
|
||||
user: str,
|
||||
namespace: str,
|
||||
key: str,
|
||||
data: Dict[str, Any],
|
||||
source: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
refresh_schedule: Optional[str] = None
|
||||
) -> VolatileRecordResponse:
|
||||
"""
|
||||
Store volatile data as an embedded vector.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
namespace: Data namespace (from controlled list)
|
||||
key: Record key (normalized slug)
|
||||
data: Structured data to store
|
||||
source: Origin API/service
|
||||
ttl: TTL in seconds (uses namespace default if not set)
|
||||
refresh_schedule: Optional cron expression for refresh
|
||||
|
||||
Returns:
|
||||
The stored record
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
# Ensure collection exists
|
||||
await self.qdrant.ensure_collection(collection)
|
||||
|
||||
# Calculate TTL and expiry
|
||||
effective_ttl = ttl if ttl is not None else self._get_default_ttl(namespace)
|
||||
now_ms = self._current_timestamp_ms()
|
||||
expiry_ms = now_ms + (effective_ttl * 1000)
|
||||
|
||||
# Convert to natural language for embedding
|
||||
text = self._to_natural_language(namespace, key, data)
|
||||
|
||||
# Generate embedding
|
||||
embedding = await self.ollama.embed(text)
|
||||
if not embedding:
|
||||
raise ValueError("Failed to generate embedding for volatile data")
|
||||
|
||||
# Build payload
|
||||
now = datetime.utcnow()
|
||||
payload = {
|
||||
"doc_type": "volatile",
|
||||
"namespace": namespace,
|
||||
"key": key,
|
||||
"text": text,
|
||||
"raw_data": data,
|
||||
"source": source,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
"ttl": effective_ttl,
|
||||
"ttl_expiry": expiry_ms,
|
||||
"refresh_schedule": refresh_schedule,
|
||||
"user": user,
|
||||
}
|
||||
|
||||
# Upsert vector (same namespace+key = same ID = update)
|
||||
vector_id = self._make_vector_id(namespace, key)
|
||||
success = await self.qdrant.upsert_vector(
|
||||
collection_name=collection,
|
||||
vector_id=vector_id,
|
||||
vector=embedding,
|
||||
payload=payload
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError("Failed to store volatile vector")
|
||||
|
||||
logger.debug(f"Stored volatile {namespace}:{key} with TTL {effective_ttl}s")
|
||||
|
||||
return VolatileRecordResponse(
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
data=data,
|
||||
source=source,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
ttl=effective_ttl,
|
||||
ttl_remaining=effective_ttl,
|
||||
refresh_schedule=refresh_schedule,
|
||||
user=user,
|
||||
)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
user: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.75
|
||||
) -> List[VolatileRecordResponse]:
|
||||
"""
|
||||
Semantic search across volatile data.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
query: Search query
|
||||
limit: Maximum results
|
||||
score_threshold: Minimum similarity score (higher = stricter)
|
||||
|
||||
Returns:
|
||||
List of matching volatile records
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
# Check if collection exists
|
||||
if not await self.qdrant.collection_exists(collection):
|
||||
return []
|
||||
|
||||
# Generate query embedding
|
||||
query_embedding = await self.ollama.embed(query)
|
||||
if not query_embedding:
|
||||
logger.error("Failed to embed query for volatile search")
|
||||
return []
|
||||
|
||||
# Search with expiry filter
|
||||
now_ms = self._current_timestamp_ms()
|
||||
results = await self.qdrant.search_with_expiry_filter(
|
||||
collection_name=collection,
|
||||
query_vector=query_embedding,
|
||||
current_timestamp=now_ms,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold
|
||||
)
|
||||
|
||||
# Convert to response models
|
||||
responses = []
|
||||
for result in results:
|
||||
payload = result["payload"]
|
||||
ttl_expiry = payload.get("ttl_expiry", 0)
|
||||
ttl_remaining = max(0, (ttl_expiry - now_ms) // 1000)
|
||||
|
||||
responses.append(VolatileRecordResponse(
|
||||
key=payload["key"],
|
||||
namespace=payload["namespace"],
|
||||
data=payload.get("raw_data", {}),
|
||||
source=payload.get("source"),
|
||||
created_at=datetime.fromisoformat(payload["created_at"]),
|
||||
updated_at=datetime.fromisoformat(payload["updated_at"]),
|
||||
ttl=payload.get("ttl", 0),
|
||||
ttl_remaining=ttl_remaining,
|
||||
refresh_schedule=payload.get("refresh_schedule"),
|
||||
user=payload["user"],
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
async def get(
|
||||
self,
|
||||
user: str,
|
||||
namespace: str,
|
||||
key: str
|
||||
) -> Optional[VolatileRecordResponse]:
|
||||
"""
|
||||
Get a specific volatile record by namespace and key.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
namespace: Data namespace
|
||||
key: Record key
|
||||
|
||||
Returns:
|
||||
Record if found and not expired, None otherwise
|
||||
"""
|
||||
# Use search with high threshold to find exact match
|
||||
query = self._to_natural_language(namespace, key, {"key": key})
|
||||
results = await self.search(user, query, limit=10, score_threshold=0.5)
|
||||
|
||||
# Find exact namespace+key match
|
||||
for result in results:
|
||||
if result.namespace == namespace and result.key == key:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
user: str,
|
||||
namespace: str,
|
||||
key: str
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a specific volatile record.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
namespace: Data namespace
|
||||
key: Record key
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
if not await self.qdrant.collection_exists(collection):
|
||||
return False
|
||||
|
||||
vector_id = self._make_vector_id(namespace, key)
|
||||
|
||||
try:
|
||||
deleted = await self.qdrant.delete_by_ids(
|
||||
collection_name=collection,
|
||||
point_ids=[vector_id]
|
||||
)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete volatile {namespace}:{key}: {e}")
|
||||
return False
|
||||
|
||||
async def get_scheduled(
|
||||
self,
|
||||
user: str
|
||||
) -> List[VolatileRecordResponse]:
|
||||
"""
|
||||
Get all records with refresh schedules.
|
||||
|
||||
Used by scheduler to determine what needs refreshing.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
List of records with refresh_schedule set
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
if not await self.qdrant.collection_exists(collection):
|
||||
return []
|
||||
|
||||
now_ms = self._current_timestamp_ms()
|
||||
scheduled = []
|
||||
|
||||
# Scroll through all non-expired records
|
||||
try:
|
||||
all_points = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection,
|
||||
with_payload=True
|
||||
)
|
||||
|
||||
for point in all_points:
|
||||
payload = point.get("payload", {})
|
||||
ttl_expiry = payload.get("ttl_expiry", 0)
|
||||
|
||||
# Skip expired
|
||||
if ttl_expiry <= now_ms:
|
||||
continue
|
||||
|
||||
# Only include if has refresh schedule
|
||||
if payload.get("refresh_schedule"):
|
||||
ttl_remaining = max(0, (ttl_expiry - now_ms) // 1000)
|
||||
scheduled.append(VolatileRecordResponse(
|
||||
key=payload["key"],
|
||||
namespace=payload["namespace"],
|
||||
data=payload.get("raw_data", {}),
|
||||
source=payload.get("source"),
|
||||
created_at=datetime.fromisoformat(payload["created_at"]),
|
||||
updated_at=datetime.fromisoformat(payload["updated_at"]),
|
||||
ttl=payload.get("ttl", 0),
|
||||
ttl_remaining=ttl_remaining,
|
||||
refresh_schedule=payload["refresh_schedule"],
|
||||
user=payload["user"],
|
||||
))
|
||||
|
||||
return scheduled
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get scheduled volatile records: {e}")
|
||||
return []
|
||||
|
||||
async def get_stats(
|
||||
self,
|
||||
user: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics for user.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Statistics dict
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
if not await self.qdrant.collection_exists(collection):
|
||||
return {
|
||||
"total_records": 0,
|
||||
"by_namespace": {},
|
||||
"scheduled_count": 0,
|
||||
"expired_count": 0,
|
||||
}
|
||||
|
||||
now_ms = self._current_timestamp_ms()
|
||||
by_namespace: Dict[str, int] = {}
|
||||
total = 0
|
||||
scheduled = 0
|
||||
expired = 0
|
||||
|
||||
try:
|
||||
all_points = await self.qdrant.scroll_all_points(
|
||||
collection_name=collection,
|
||||
with_payload=True
|
||||
)
|
||||
|
||||
for point in all_points:
|
||||
payload = point.get("payload", {})
|
||||
namespace = payload.get("namespace", "unknown")
|
||||
ttl_expiry = payload.get("ttl_expiry", 0)
|
||||
|
||||
if ttl_expiry <= now_ms:
|
||||
expired += 1
|
||||
else:
|
||||
total += 1
|
||||
by_namespace[namespace] = by_namespace.get(namespace, 0) + 1
|
||||
if payload.get("refresh_schedule"):
|
||||
scheduled += 1
|
||||
|
||||
return {
|
||||
"total_records": total,
|
||||
"by_namespace": by_namespace,
|
||||
"scheduled_count": scheduled,
|
||||
"expired_count": expired,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volatile stats: {e}")
|
||||
return {
|
||||
"total_records": 0,
|
||||
"by_namespace": {},
|
||||
"scheduled_count": 0,
|
||||
"expired_count": 0,
|
||||
}
|
||||
|
||||
async def purge_expired(
|
||||
self,
|
||||
user: str
|
||||
) -> int:
|
||||
"""
|
||||
Purge all expired volatile records for user.
|
||||
|
||||
Args:
|
||||
user: User identifier
|
||||
|
||||
Returns:
|
||||
Number of records purged
|
||||
"""
|
||||
collection = self._collection_name(user)
|
||||
|
||||
if not await self.qdrant.collection_exists(collection):
|
||||
return 0
|
||||
|
||||
now_ms = self._current_timestamp_ms()
|
||||
return await self.qdrant.delete_expired_vectors(collection, now_ms)
|
||||
|
||||
async def purge_all_expired(self) -> Dict[str, int]:
|
||||
"""
|
||||
Purge expired records from all volatile collections.
|
||||
|
||||
Returns:
|
||||
Dict of collection -> purged count
|
||||
"""
|
||||
collections = await self.qdrant.get_volatile_collections()
|
||||
results = {}
|
||||
now_ms = self._current_timestamp_ms()
|
||||
|
||||
for collection in collections:
|
||||
purged = await self.qdrant.delete_expired_vectors(collection, now_ms)
|
||||
if purged > 0:
|
||||
results[collection] = purged
|
||||
logger.info(f"Purged {purged} expired from {collection}")
|
||||
|
||||
return results
|
||||
@@ -27,21 +27,59 @@ class WikiChangeListener:
|
||||
NOTIFY events on INSERT/UPDATE/DELETE to the pages table.
|
||||
"""
|
||||
|
||||
CHANNEL = 'wiki_page_changes'
|
||||
|
||||
# Bounded exponential backoff between reconnect attempts.
|
||||
BACKOFF_INITIAL_SECONDS = 1.0
|
||||
BACKOFF_MAX_SECONDS = 60.0
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.connection: Optional[asyncpg.Connection] = None
|
||||
self.running = False
|
||||
|
||||
# Loop prevention: Track recently processed pages
|
||||
# Key: page_id, Value: timestamp of last processing
|
||||
self._recent_notifications = {}
|
||||
self._debounce_seconds = self.settings.wikijs_change_listener_debounce_seconds
|
||||
|
||||
async def start(self):
|
||||
"""Start listening to database changes."""
|
||||
logger.info("Starting Wiki.js database change listener")
|
||||
# Supervision state. A single LISTEN connection does not heal itself the
|
||||
# way an asyncpg pool does, so the drop has to be detected and repaired
|
||||
# explicitly — see _supervise().
|
||||
self._stopping = False
|
||||
self._disconnected = asyncio.Event()
|
||||
self._supervisor_task: Optional[asyncio.Task] = None
|
||||
self._disconnected_at: Optional[datetime] = None
|
||||
self.reconnects = 0
|
||||
self.last_gap_seconds: Optional[float] = None
|
||||
|
||||
# Connect to Wiki.js PostgreSQL database
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""
|
||||
Whether a live subscription actually exists.
|
||||
|
||||
Derived rather than assigned. The previous implementation set a flag once
|
||||
in start() and never revisited it, so after the connection dropped the
|
||||
listener reported itself as running while being deaf to every event.
|
||||
"""
|
||||
return (
|
||||
not self._stopping
|
||||
and self.connection is not None
|
||||
and not self.connection.is_closed()
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Start listening to database changes, and keep listening."""
|
||||
logger.info("Starting Wiki.js database change listener")
|
||||
self._stopping = False
|
||||
self._disconnected.clear()
|
||||
|
||||
await self._connect()
|
||||
|
||||
self._supervisor_task = asyncio.create_task(self._supervise())
|
||||
logger.info("Listening for Wiki.js page changes via PostgreSQL NOTIFY")
|
||||
|
||||
async def _connect(self):
|
||||
"""Open a connection and subscribe. Raises if the database is unreachable."""
|
||||
self.connection = await asyncpg.connect(
|
||||
host=self.settings.wikijs_db_host,
|
||||
port=self.settings.wikijs_db_port,
|
||||
@@ -50,18 +88,136 @@ class WikiChangeListener:
|
||||
database=self.settings.wikijs_db_name
|
||||
)
|
||||
|
||||
# Listen to the wiki_page_changes channel
|
||||
await self.connection.add_listener('wiki_page_changes', self._handle_notification)
|
||||
await self.connection.add_listener(self.CHANNEL, self._handle_notification)
|
||||
|
||||
self.running = True
|
||||
logger.info("Listening for Wiki.js page changes via PostgreSQL NOTIFY")
|
||||
# Must be re-registered on every connection: asyncpg clears its
|
||||
# termination listeners as soon as it fires them, so this is one-shot.
|
||||
self.connection.add_termination_listener(self._on_connection_lost)
|
||||
|
||||
def _on_connection_lost(self, connection):
|
||||
"""
|
||||
Called by asyncpg when the connection terminates.
|
||||
|
||||
Dispatched through loop.call_soon, so it must stay synchronous — the work
|
||||
of reconnecting belongs to _supervise(), which this only wakes.
|
||||
"""
|
||||
if self._stopping:
|
||||
return
|
||||
|
||||
self._disconnected_at = datetime.now()
|
||||
logger.error(
|
||||
"Wiki.js change listener lost its database connection — "
|
||||
"page changes are NOT being processed until it reconnects"
|
||||
)
|
||||
self._disconnected.set()
|
||||
|
||||
async def _supervise(self, max_iterations: Optional[int] = None) -> int:
|
||||
"""
|
||||
Reconnect whenever the subscription drops.
|
||||
|
||||
Args:
|
||||
max_iterations: Stop after N reconnect cycles (None = run forever;
|
||||
used by tests)
|
||||
|
||||
Returns:
|
||||
Number of completed reconnect cycles
|
||||
"""
|
||||
iterations = 0
|
||||
while max_iterations is None or iterations < max_iterations:
|
||||
await self._disconnected.wait()
|
||||
if self._stopping:
|
||||
break
|
||||
|
||||
self._disconnected.clear()
|
||||
await self._reconnect_with_backoff()
|
||||
iterations += 1
|
||||
|
||||
return iterations
|
||||
|
||||
async def _reconnect_with_backoff(self, max_attempts: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Re-establish the subscription, backing off between failures.
|
||||
|
||||
Keeps trying indefinitely by default: a database that is down for
|
||||
maintenance will come back, and giving up would recreate exactly the
|
||||
silent-deafness this supervision exists to prevent.
|
||||
"""
|
||||
delay = self.BACKOFF_INITIAL_SECONDS
|
||||
attempts = 0
|
||||
|
||||
while not self._stopping and (max_attempts is None or attempts < max_attempts):
|
||||
attempts += 1
|
||||
await self._close_connection()
|
||||
|
||||
try:
|
||||
await self._connect()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Wiki.js change listener reconnect attempt {attempts} failed: {e}; "
|
||||
f"retrying in {delay:.0f}s"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, self.BACKOFF_MAX_SECONDS)
|
||||
continue
|
||||
|
||||
self.reconnects += 1
|
||||
gap = None
|
||||
if self._disconnected_at is not None:
|
||||
gap = (datetime.now() - self._disconnected_at).total_seconds()
|
||||
self.last_gap_seconds = gap
|
||||
self._disconnected_at = None
|
||||
|
||||
# NOTIFY is fire-and-forget: anything emitted while we were gone was
|
||||
# delivered to nobody and cannot be replayed. Say so, and say what
|
||||
# closes the gap, rather than reporting a clean recovery.
|
||||
outage = f" after {gap:.0f}s" if gap is not None else ""
|
||||
logger.warning(
|
||||
f"Wiki.js change listener reconnected{outage} "
|
||||
f"(reconnect #{self.reconnects}). NOTIFY events emitted during the "
|
||||
f"outage were lost and cannot be replayed — run "
|
||||
f"POST /maintenance/integrity-check to reconcile pages that "
|
||||
f"changed while the listener was down."
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _close_connection(self):
|
||||
"""Drop the current connection, tolerating one that is already dead."""
|
||||
if not self.connection:
|
||||
return
|
||||
|
||||
try:
|
||||
if not self.connection.is_closed():
|
||||
await self.connection.remove_listener(
|
||||
self.CHANNEL, self._handle_notification
|
||||
)
|
||||
await self.connection.close()
|
||||
except Exception as e:
|
||||
# A terminated connection raises on both calls; that is expected here.
|
||||
logger.debug(f"Error closing Wiki.js listener connection: {e}")
|
||||
finally:
|
||||
self.connection = None
|
||||
|
||||
async def stop(self):
|
||||
"""Stop listening and close connection."""
|
||||
if self.connection:
|
||||
await self.connection.remove_listener('wiki_page_changes', self._handle_notification)
|
||||
await self.connection.close()
|
||||
self.running = False
|
||||
self._stopping = True
|
||||
|
||||
# Wake the supervisor so it observes _stopping and exits rather than
|
||||
# racing us to reconnect the connection we are about to close.
|
||||
self._disconnected.set()
|
||||
|
||||
if self._supervisor_task:
|
||||
self._supervisor_task.cancel()
|
||||
try:
|
||||
await self._supervisor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Wiki.js listener supervisor ended with: {e}")
|
||||
self._supervisor_task = None
|
||||
|
||||
await self._close_connection()
|
||||
logger.info("Stopped Wiki.js change listener")
|
||||
|
||||
async def _handle_notification(self, connection, pid, channel, payload):
|
||||
@@ -103,8 +259,17 @@ class WikiChangeListener:
|
||||
}
|
||||
event = event_map.get(operation, 'page.update')
|
||||
|
||||
# Extract user from email
|
||||
user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer'
|
||||
# Extract user from email. There is NO default tenant: if no user
|
||||
# can be derived from the notification, skip processing instead of
|
||||
# attributing the change to an arbitrary tenant.
|
||||
if '@' in user_email and user_email.split('@')[0].strip():
|
||||
user = user_email.split('@')[0].strip()
|
||||
else:
|
||||
logger.warning(
|
||||
f"Skipping page {page_id} change: cannot derive tenant user "
|
||||
f"from notification email {user_email!r}"
|
||||
)
|
||||
return
|
||||
|
||||
# Process the change
|
||||
await self._process_page_change(
|
||||
@@ -116,24 +281,6 @@ class WikiChangeListener:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to handle notification: {e}", exc_info=True)
|
||||
|
||||
def _is_automated_user(self, email: str) -> bool:
|
||||
"""
|
||||
Check if email belongs to an automated system user.
|
||||
|
||||
These are edits made by library-desk via Wiki.js API (entity linking).
|
||||
We skip processing these to prevent loops.
|
||||
|
||||
Customize this list based on your Wiki.js username for library-desk.
|
||||
"""
|
||||
automated_users = [
|
||||
self.settings.wikijs_username, # Library-desk's Wiki.js API user
|
||||
"library-desk@system",
|
||||
"automation@system",
|
||||
"bot@system"
|
||||
]
|
||||
|
||||
return email.lower() in [u.lower() for u in automated_users]
|
||||
|
||||
def _is_recently_processed(self, page_id: int) -> bool:
|
||||
"""Check if page was processed recently (debouncing)."""
|
||||
if page_id not in self._recent_notifications:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Intelligent Wiki Page Writer Service
|
||||
|
||||
Uses LLM (mistral-nemo) to create and reconstruct wiki pages with:
|
||||
Uses LLM to create and reconstruct wiki pages with:
|
||||
- Holistic content restructuring
|
||||
- Zero fact loss (unless superseded)
|
||||
- Conflict detection and flagging
|
||||
@@ -25,15 +25,16 @@ class WikiPageWriter:
|
||||
Intelligent wiki page writer using LLM for content generation and restructuring.
|
||||
"""
|
||||
|
||||
def __init__(self, ollama_client):
|
||||
def __init__(self, ollama_client, settings):
|
||||
"""
|
||||
Initialize wiki page writer.
|
||||
|
||||
Args:
|
||||
ollama_client: OllamaClient for LLM operations
|
||||
settings: Application settings
|
||||
"""
|
||||
self.ollama = ollama_client
|
||||
self.model = "mistral-nemo" # Default model for writing
|
||||
self.model = settings.ollama_llm_model
|
||||
|
||||
async def create_page(
|
||||
self,
|
||||
@@ -130,8 +131,8 @@ class WikiPageWriter:
|
||||
conflicts=conflicts
|
||||
)
|
||||
|
||||
# Reconstruct with LLM
|
||||
reconstructed = await self._call_llm(prompt)
|
||||
# Reconstruct with LLM (lower temperature for precise merging)
|
||||
reconstructed = await self._call_llm(prompt, temperature=0.2)
|
||||
|
||||
# Ensure standard sections are present
|
||||
reconstructed = self._ensure_standard_sections(
|
||||
@@ -154,7 +155,7 @@ class WikiPageWriter:
|
||||
Returns:
|
||||
List of conflicts with: {fact_a, fact_b, confidence, context}
|
||||
"""
|
||||
prompt = f"""Analyze these two pieces of content for factual conflicts.
|
||||
prompt = f"""Analyze these contents for direct factual conflicts.
|
||||
|
||||
EXISTING CONTENT:
|
||||
{existing_content[:2000]}
|
||||
@@ -162,25 +163,26 @@ EXISTING CONTENT:
|
||||
NEW INFORMATION:
|
||||
{new_information[:2000]}
|
||||
|
||||
Identify any facts that contradict each other. For each conflict, provide:
|
||||
1. The fact from existing content
|
||||
2. The contradicting fact from new information
|
||||
3. Confidence level (low/medium/high)
|
||||
4. Context/explanation
|
||||
ANALYSIS STEPS:
|
||||
1. Identify specific factual claims in existing content (dates, numbers, names, states)
|
||||
2. Identify specific factual claims in new content
|
||||
3. Compare ONLY for direct contradictions (X says A, Y says not-A)
|
||||
|
||||
Return ONLY valid JSON:
|
||||
RULES:
|
||||
- Do NOT flag differences in wording or phrasing as conflicts
|
||||
- Do NOT flag new/additional information as conflicts
|
||||
- Do NOT flag opinion differences as conflicts
|
||||
- ONLY flag direct factual contradictions
|
||||
- Return valid JSON only, no commentary
|
||||
|
||||
Return format:
|
||||
{{
|
||||
"conflicts": [
|
||||
{{
|
||||
"existing_fact": "fact from old content",
|
||||
"new_fact": "contradicting fact",
|
||||
"confidence": "medium",
|
||||
"context": "explanation of why these conflict"
|
||||
}}
|
||||
{{"existing_fact": "...", "new_fact": "...", "confidence": "low/medium/high", "context": "..."}}
|
||||
]
|
||||
}}
|
||||
|
||||
If no conflicts, return: {{"conflicts": []}}
|
||||
If no conflicts: {{"conflicts": []}}
|
||||
|
||||
JSON:"""
|
||||
|
||||
@@ -188,7 +190,8 @@ JSON:"""
|
||||
response = await self.ollama.generate_text(
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
stream=False
|
||||
stream=False,
|
||||
temperature=0.0 # Deterministic for consistent conflict detection
|
||||
)
|
||||
|
||||
# Extract JSON
|
||||
@@ -347,6 +350,13 @@ FORMATTING RULES:
|
||||
- Keep sections focused and scannable
|
||||
- Adapt structure to content - not all sections apply to all topics
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- Do NOT invent facts not present in the source information above
|
||||
- Do NOT add speculative information or assumptions
|
||||
- Do NOT fill sections with placeholder text or generic statements
|
||||
- If information for a section is not available, OMIT the section entirely
|
||||
- Base ALL content strictly on provided source information
|
||||
|
||||
Generate ONLY the markdown content (do not include Sources, Knowledge Graph, or Mind Map sections - those are added automatically).
|
||||
|
||||
MARKDOWN:"""
|
||||
@@ -402,6 +412,13 @@ FORMATTING RULES:
|
||||
- Bold important terms
|
||||
- Add subsections (###) where it improves clarity
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- Do NOT rephrase facts in ways that change their meaning
|
||||
- Do NOT remove ANY information unless explicitly superseded by newer facts
|
||||
- Do NOT add information not present in existing content or new information
|
||||
- Preserve exact quotes, dates, numbers, and names verbatim
|
||||
- Do NOT fill gaps with assumptions or general knowledge
|
||||
|
||||
OUTPUT INSTRUCTIONS:
|
||||
- Return complete page content (do not include Sources, Knowledge Graph, Mind Map - those are added automatically)
|
||||
- Include updated "Changes & Updates" section noting what was changed today
|
||||
@@ -409,13 +426,21 @@ OUTPUT INSTRUCTIONS:
|
||||
|
||||
RECONSTRUCTED MARKDOWN:"""
|
||||
|
||||
async def _call_llm(self, prompt: str) -> str:
|
||||
"""Call LLM with prompt and return response."""
|
||||
async def _call_llm(self, prompt: str, temperature: float = 0.3) -> str:
|
||||
"""
|
||||
Call LLM with prompt and return response.
|
||||
|
||||
Args:
|
||||
prompt: The prompt text
|
||||
temperature: Sampling temperature (0.0=deterministic, higher=creative)
|
||||
Default 0.3 for controlled but natural content generation
|
||||
"""
|
||||
try:
|
||||
response = await self.ollama.generate_text(
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
stream=False
|
||||
stream=False,
|
||||
temperature=temperature
|
||||
)
|
||||
|
||||
if not response:
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id, DEFAULT_USER
|
||||
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id
|
||||
from src.models.wiki import (
|
||||
WikiPage, WikiPageSummary, WikiPageList,
|
||||
WikiPageCreate, WikiPageUpdate,
|
||||
@@ -184,7 +184,7 @@ class WikiService:
|
||||
Raises:
|
||||
ValueError: If creation fails
|
||||
"""
|
||||
user = page_data.user or DEFAULT_USER
|
||||
user = page_data.user
|
||||
|
||||
# Ensure path is in user's namespace
|
||||
full_path = self._ensure_user_path(page_data.path, user)
|
||||
|
||||
@@ -2,21 +2,28 @@
|
||||
* Library Desk Integration for Wiki.js
|
||||
* Combined re-index and entity linking buttons
|
||||
*
|
||||
* Usage: Add to Wiki.js Code Injection:
|
||||
* <script src="http://192.168.86.149:8089/static/wikijs-integration.js"></script>
|
||||
* Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
|
||||
* <script src="/library-desk/static/wikijs-integration.js"></script>
|
||||
*
|
||||
* Auth: none in the browser. Requests go same-origin through the NPM
|
||||
* /library-desk/ location, which is gated by Authentik forward-auth with the
|
||||
* LAN bypass — external users are authenticated, LAN users pass through, and
|
||||
* library-desk trusts the proxy marker header. No API key is embedded here.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Auto-detect Library Desk URL
|
||||
// Same-origin base: the script is served from <origin>/library-desk/static/...,
|
||||
// so strip '/static/...' to get the library-desk mount point on this origin.
|
||||
const scriptTag = document.currentScript;
|
||||
const scriptUrl = scriptTag ? scriptTag.src : '';
|
||||
const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089';
|
||||
const libraryDeskUrl = scriptUrl
|
||||
? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0]
|
||||
: '/library-desk';
|
||||
|
||||
// Shared configuration
|
||||
const CONFIG = window.LIBRARY_DESK_CONFIG || {
|
||||
libraryDeskUrl: libraryDeskUrl,
|
||||
apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5',
|
||||
user: 'jpmschweitzer',
|
||||
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
|
||||
debug: true
|
||||
@@ -229,8 +236,8 @@
|
||||
// Re-index directly
|
||||
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -334,8 +341,8 @@
|
||||
// Call entity linking endpoint
|
||||
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
+316
-17
@@ -1,65 +1,364 @@
|
||||
"""Pytest configuration and shared fixtures for Library Desk tests."""
|
||||
"""
|
||||
Pytest configuration and shared fixtures for Library Desk tests.
|
||||
|
||||
TENANT SAFETY MODEL
|
||||
===================
|
||||
There is no separate test infrastructure: integration tests run against the
|
||||
SHARED production services (Qdrant / Neo4j / Wiki.js / Redis / Wiki.js).
|
||||
Tenancy is the ONLY isolation wall, therefore:
|
||||
|
||||
- The suite is pinned to the reserved test tenant ``llm_tester`` (env
|
||||
``TEST_TENANT`` may only select a tenant inside the reserved
|
||||
``llm_tester*`` namespace — anything else aborts the whole session).
|
||||
- The production tenant ``jpmschweitzer`` is NEVER written to. A session
|
||||
guard hard-fails immediately if the effective tenant is the production
|
||||
tenant or outside the reserved namespace.
|
||||
- Integration tests (marked ``integration``) only run when
|
||||
``RUN_INTEGRATION_TESTS=1`` is set; otherwise they are skipped. Offline
|
||||
unit tests never contact the shared services.
|
||||
- A session-scoped teardown deletes ALL ``llm_tester`` artifacts created
|
||||
during the run (Qdrant ``*_llm_tester`` collections, Neo4j nodes under
|
||||
the ``User_Llm_Tester*`` labels, the ``users/llm_tester`` wiki subtree,
|
||||
and ``llm_tester``-prefixed Redis keys on the service DB), with a hard
|
||||
assertion on the tenant string before any delete.
|
||||
|
||||
HOSTS
|
||||
=====
|
||||
``TEST_HOST`` selects where the shared services live. It defaults to
|
||||
``localhost`` (safe: nothing listens there unless you forwarded the
|
||||
services yourself). For live runs against the shared stack set it
|
||||
explicitly, e.g. ``TEST_HOST=192.168.86.149``.
|
||||
|
||||
The API under test is the LOCAL wakeup server (``./wakeup.sh``, port 8778),
|
||||
selected via ``LIBRARY_DESK_URL`` (default ``http://localhost:8778``).
|
||||
NEVER point tests at the production container (port 8089).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
# Test configuration
|
||||
from src.core.multi_tenancy import sanitize_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytest_plugins = ("pytest_asyncio",)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenancy constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The production tenant. No test may ever write under it.
|
||||
PRODUCTION_TENANT = "jpmschweitzer"
|
||||
|
||||
#: The reserved test tenant namespace. The effective tenant must be
|
||||
#: exactly this or a sub-tenant of it (llm_tester_*).
|
||||
RESERVED_TEST_TENANT = "llm_tester"
|
||||
|
||||
#: Effective tenant for the whole suite (guard-checked below).
|
||||
TEST_TENANT = os.environ.get("TEST_TENANT", RESERVED_TEST_TENANT)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hosts / URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Shared-services host. Default localhost — NOT the production host.
|
||||
TEST_HOST = os.environ.get("TEST_HOST", "localhost")
|
||||
|
||||
#: Base URL of the local dev server under test (./wakeup.sh, port 8778).
|
||||
LIBRARY_DESK_URL = os.environ.get("LIBRARY_DESK_URL", "http://localhost:8778")
|
||||
|
||||
RUN_INTEGRATION = os.environ.get("RUN_INTEGRATION_TESTS") == "1"
|
||||
|
||||
|
||||
def is_reserved_test_tenant(tenant: str) -> bool:
|
||||
"""True if tenant is inside the reserved llm_tester namespace."""
|
||||
sanitized = sanitize_user_id(tenant)
|
||||
return sanitized == RESERVED_TEST_TENANT or sanitized.startswith(
|
||||
RESERVED_TEST_TENANT + "_"
|
||||
)
|
||||
|
||||
|
||||
def assert_safe_test_tenant(tenant: str) -> str:
|
||||
"""
|
||||
Hard assertion used before ANY destructive operation.
|
||||
|
||||
Raises AssertionError unless the tenant is inside the reserved test
|
||||
namespace and is not the production tenant.
|
||||
"""
|
||||
sanitized = sanitize_user_id(tenant)
|
||||
assert sanitized != sanitize_user_id(PRODUCTION_TENANT), (
|
||||
f"TENANT GUARD: refusing to touch production tenant {tenant!r}"
|
||||
)
|
||||
assert is_reserved_test_tenant(tenant), (
|
||||
f"TENANT GUARD: {tenant!r} is not in the reserved test namespace "
|
||||
f"({RESERVED_TEST_TENANT}*)"
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session guard + integration gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Skip integration-marked tests unless explicitly enabled AND safe."""
|
||||
if RUN_INTEGRATION and is_reserved_test_tenant(TEST_TENANT):
|
||||
return
|
||||
reason = (
|
||||
"integration tests disabled (set RUN_INTEGRATION_TESTS=1, TEST_HOST "
|
||||
"and TEST_TENANT inside the reserved llm_tester namespace to run "
|
||||
"against the shared services)"
|
||||
)
|
||||
skip_marker = pytest.mark.skip(reason=reason)
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip_marker)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def tenant_guard():
|
||||
"""
|
||||
Session guard: hard-fail the entire run if the effective tenant is the
|
||||
production tenant or outside the reserved test namespace.
|
||||
"""
|
||||
if sanitize_user_id(TEST_TENANT) == sanitize_user_id(PRODUCTION_TENANT):
|
||||
pytest.exit(
|
||||
f"TENANT GUARD: effective test tenant is the PRODUCTION tenant "
|
||||
f"({TEST_TENANT!r}) - aborting the whole session.",
|
||||
returncode=3,
|
||||
)
|
||||
if not is_reserved_test_tenant(TEST_TENANT):
|
||||
pytest.exit(
|
||||
f"TENANT GUARD: effective test tenant {TEST_TENANT!r} is not in "
|
||||
f"the reserved test namespace ({RESERVED_TEST_TENANT}*) - "
|
||||
f"aborting the whole session.",
|
||||
returncode=3,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session teardown: purge ALL llm_tester artifacts created during the run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _purge_qdrant_test_artifacts() -> None:
|
||||
"""Delete Qdrant collections belonging to the reserved test tenant."""
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
tenant = assert_safe_test_tenant(TEST_TENANT)
|
||||
client = QdrantClient(url=f"http://{TEST_HOST}:6333", timeout=10)
|
||||
try:
|
||||
for coll in client.get_collections().collections:
|
||||
name = coll.name
|
||||
# Only *_<tenant> style collections (library_desk_llm_tester,
|
||||
# volatile_llm_tester, memories_llm_tester, ...).
|
||||
if not (name.endswith(f"_{tenant}") or f"_{tenant}_" in name):
|
||||
continue
|
||||
assert PRODUCTION_TENANT not in name # hard guard
|
||||
assert tenant in name
|
||||
client.delete_collection(name)
|
||||
logger.info(f"[teardown] deleted Qdrant collection {name}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
async def _purge_neo4j_test_artifacts() -> None:
|
||||
"""Delete Neo4j nodes under the reserved test tenant's labels."""
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.config import get_settings
|
||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
||||
|
||||
tenant = assert_safe_test_tenant(TEST_TENANT)
|
||||
label_prefix = get_neo4j_user_base_label(tenant) # e.g. User_Llm_Tester
|
||||
assert "Jpmschweitzer" not in label_prefix # hard guard
|
||||
assert "Llm_Tester" in label_prefix
|
||||
|
||||
settings = get_settings()
|
||||
client = Neo4jClient(
|
||||
uri=f"bolt://{TEST_HOST}:7687",
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password,
|
||||
)
|
||||
try:
|
||||
await client.connect()
|
||||
result = await client.execute_write(
|
||||
"""
|
||||
MATCH (n)
|
||||
WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix)
|
||||
DETACH DELETE n
|
||||
RETURN count(n) AS deleted
|
||||
""",
|
||||
{"prefix": label_prefix},
|
||||
)
|
||||
deleted = result[0]["deleted"] if result else 0
|
||||
if deleted:
|
||||
logger.info(f"[teardown] deleted {deleted} Neo4j {label_prefix}* nodes")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _purge_wiki_test_artifacts() -> None:
|
||||
"""Delete the reserved test tenant's wiki subtree (users/llm_tester)."""
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.config import get_settings
|
||||
|
||||
tenant = assert_safe_test_tenant(TEST_TENANT)
|
||||
settings = get_settings()
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
api_token=settings.wiki_graphql_api,
|
||||
)
|
||||
try:
|
||||
for prefix in (f"users/{tenant}", f"users/{tenant.replace('_', '-')}"):
|
||||
assert PRODUCTION_TENANT not in prefix # hard guard
|
||||
pages = await client.list_all_pages(path_prefix=prefix)
|
||||
for page in pages:
|
||||
path = page.get("path", "")
|
||||
assert PRODUCTION_TENANT not in path # hard guard
|
||||
assert path.lstrip("/").startswith("users/llm")
|
||||
await client.delete_page(page["id"])
|
||||
logger.info(f"[teardown] deleted wiki page {path} (id={page['id']})")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _purge_redis_test_artifacts() -> None:
|
||||
"""Delete llm_tester-prefixed keys on the Redis DB the service uses."""
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from src.config import get_settings
|
||||
|
||||
tenant = assert_safe_test_tenant(TEST_TENANT)
|
||||
settings = get_settings()
|
||||
client = aioredis.from_url(
|
||||
f"redis://{TEST_HOST}:6379/{settings.redis_db}",
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
try:
|
||||
deleted = 0
|
||||
for pattern in (f"*{tenant}*", f"*{tenant.replace('_', '-')}*"):
|
||||
async for key in client.scan_iter(match=pattern, count=200):
|
||||
assert PRODUCTION_TENANT not in key # hard guard
|
||||
assert "llm" in key
|
||||
await client.delete(key)
|
||||
deleted += 1
|
||||
if deleted:
|
||||
logger.info(f"[teardown] deleted {deleted} Redis keys for {tenant}")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def purge_test_tenant_artifacts(tenant_guard):
|
||||
"""
|
||||
Session teardown: after the run, delete ALL artifacts under the reserved
|
||||
test tenant on the shared services. Only active for integration runs
|
||||
(offline unit-test runs never touch the shared services and must not
|
||||
try to connect to them).
|
||||
"""
|
||||
yield
|
||||
if not RUN_INTEGRATION:
|
||||
return
|
||||
|
||||
# Hard assertion before ANY delete.
|
||||
assert_safe_test_tenant(TEST_TENANT)
|
||||
|
||||
async def _teardown():
|
||||
for name, purge in (
|
||||
("qdrant", _purge_qdrant_test_artifacts),
|
||||
("neo4j", _purge_neo4j_test_artifacts),
|
||||
("wiki", _purge_wiki_test_artifacts),
|
||||
("redis", _purge_redis_test_artifacts),
|
||||
):
|
||||
try:
|
||||
await purge()
|
||||
except AssertionError:
|
||||
raise # tenant-guard violations must never be swallowed
|
||||
except Exception as e:
|
||||
logger.warning(f"[teardown] {name} purge failed: {e}")
|
||||
|
||||
# Sync fixture + asyncio.run avoids the session/function loop-scope
|
||||
# mismatch (see CLAUDE.md).
|
||||
asyncio.run(_teardown())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user() -> str:
|
||||
"""Default test user."""
|
||||
return "test_user"
|
||||
"""The reserved test tenant. Pinned for the whole suite."""
|
||||
return TEST_TENANT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_desk_url() -> str:
|
||||
"""Base URL of the LOCAL dev server under test (never production :8089)."""
|
||||
return LIBRARY_DESK_URL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neo4j_test_uri() -> str:
|
||||
"""Test Neo4j URI."""
|
||||
return "bolt://neo4j:7687"
|
||||
return f"bolt://{TEST_HOST}:7687"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neo4j_test_auth() -> tuple:
|
||||
"""Test Neo4j authentication."""
|
||||
return ("neo4j", "test_password")
|
||||
from src.config import get_settings
|
||||
settings = get_settings()
|
||||
return ("neo4j", settings.neo4j_password)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_test_url() -> str:
|
||||
"""Test Qdrant URL."""
|
||||
return "http://qdrant:6333"
|
||||
return f"http://{TEST_HOST}:6333"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wikijs_test_config() -> dict:
|
||||
"""Test Wiki.js configuration."""
|
||||
"""Test Wiki.js configuration (URL from settings, host env-overridable)."""
|
||||
from src.config import get_settings
|
||||
settings = get_settings()
|
||||
return {
|
||||
"base_url": "http://wiki:3000",
|
||||
"api_key": "test_api_key"
|
||||
"base_url": settings.wikijs_url,
|
||||
"api_token": settings.wiki_graphql_api
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def searxng_test_url() -> str:
|
||||
"""Test SearXNG URL."""
|
||||
return "http://searxng:8080"
|
||||
return f"http://{TEST_HOST}:8080"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_test_config() -> dict:
|
||||
"""Test Ollama configuration."""
|
||||
from src.config import get_settings
|
||||
settings = get_settings()
|
||||
return {
|
||||
"base_url": "http://ollama:11434",
|
||||
"model": "nomic-embed-text"
|
||||
"base_url": f"http://{TEST_HOST}:11434",
|
||||
"model": settings.ollama_embedding_model
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def redis_test_url() -> str:
|
||||
"""Test Redis URL."""
|
||||
return "redis://redis-shared:6379/4"
|
||||
from src.config import get_settings
|
||||
settings = get_settings()
|
||||
return f"redis://{TEST_HOST}:6379/{settings.redis_db}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -71,7 +370,7 @@ def sample_document() -> dict:
|
||||
"content": "This is a test document for unit testing.",
|
||||
"metadata": {
|
||||
"source": "test",
|
||||
"author": "test_user"
|
||||
"author": TEST_TENANT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Tests for verify_browser_request — the session/proxy auth used by the
|
||||
Wiki.js integration endpoints (no secret in the browser).
|
||||
"""
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from src.core.dependencies import verify_browser_request
|
||||
|
||||
|
||||
def _request(headers: dict) -> Request:
|
||||
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
||||
return Request({"type": "http", "method": "POST", "path": "/ingest/page", "headers": raw})
|
||||
|
||||
|
||||
SETTINGS = SimpleNamespace(library_api_key="server-secret-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_marker_with_authentik_identity_is_accepted():
|
||||
req = _request({"X-Library-Desk-Proxy": "1", "X-Authentik-Email": "user@example.com"})
|
||||
assert await verify_browser_request(req, SETTINGS) == "user@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_marker_on_lan_bypass_falls_back_to_lan():
|
||||
# LAN bypass: proxy marker present, no Authentik identity headers.
|
||||
req = _request({"X-Library-Desk-Proxy": "1"})
|
||||
assert await verify_browser_request(req, SETTINGS) == "lan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_api_key_is_accepted_for_machine_callers():
|
||||
req = _request({"Authorization": "Bearer server-secret-key"})
|
||||
assert await verify_browser_request(req, SETTINGS) == "server-secret-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_marker_and_no_key_is_rejected():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await verify_browser_request(_request({}), SETTINGS)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forged_marker_value_is_rejected():
|
||||
# Only the exact NPM-set value "1" is trusted.
|
||||
with pytest.raises(HTTPException):
|
||||
await verify_browser_request(_request({"X-Library-Desk-Proxy": "yes"}), SETTINGS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_api_key_is_rejected():
|
||||
with pytest.raises(HTTPException):
|
||||
await verify_browser_request(_request({"Authorization": "Bearer wrong"}), SETTINGS)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Unit tests for application settings (offline).
|
||||
|
||||
Covers the OLLAMA_MODEL env collision: the deployed container sets
|
||||
OLLAMA_MODEL=nomic-embed-text for embeddings, which must NOT shadow the
|
||||
generation model setting (ollama_llm_model / OLLAMA_LLM_MODEL).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
# Required fields so Settings can be constructed without a .env file
|
||||
REQUIRED = {
|
||||
"library_api_key": "test-key",
|
||||
"neo4j_password": "test-pass",
|
||||
"wikijs_db_password": "test-pass",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOllamaModelResolution:
|
||||
"""Generation model resolution must be immune to the OLLAMA_MODEL env var."""
|
||||
|
||||
def test_ollama_model_env_does_not_shadow_generation_model(self, monkeypatch):
|
||||
"""The container env OLLAMA_MODEL (embedding model) must not leak into ollama_llm_model."""
|
||||
monkeypatch.setenv("OLLAMA_MODEL", "nomic-embed-text")
|
||||
|
||||
settings = Settings(_env_file=None, **REQUIRED)
|
||||
|
||||
assert settings.ollama_llm_model == "gemma4:e2b"
|
||||
assert settings.ollama_llm_model != "nomic-embed-text"
|
||||
|
||||
def test_generation_model_default(self, monkeypatch):
|
||||
monkeypatch.delenv("OLLAMA_LLM_MODEL", raising=False)
|
||||
|
||||
settings = Settings(_env_file=None, **REQUIRED)
|
||||
|
||||
assert settings.ollama_llm_model == "gemma4:e2b"
|
||||
|
||||
def test_generation_model_from_dedicated_env_var(self, monkeypatch):
|
||||
"""OLLAMA_LLM_MODEL is the dedicated env var for the generation model."""
|
||||
monkeypatch.setenv("OLLAMA_MODEL", "nomic-embed-text")
|
||||
monkeypatch.setenv("OLLAMA_LLM_MODEL", "mistral-nemo:latest")
|
||||
|
||||
settings = Settings(_env_file=None, **REQUIRED)
|
||||
|
||||
assert settings.ollama_llm_model == "mistral-nemo:latest"
|
||||
|
||||
def test_embedding_model_setting_untouched(self, monkeypatch):
|
||||
"""The embedding model keeps its own setting and env var."""
|
||||
monkeypatch.setenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
|
||||
settings = Settings(_env_file=None, **REQUIRED)
|
||||
|
||||
assert settings.ollama_embedding_model == "nomic-embed-text"
|
||||
|
||||
def test_legacy_setting_name_removed(self):
|
||||
"""The old ollama_model attribute must be gone so nothing binds to OLLAMA_MODEL."""
|
||||
settings = Settings(_env_file=None, **REQUIRED)
|
||||
|
||||
assert not hasattr(settings, "ollama_model")
|
||||
+63
-29
@@ -38,7 +38,7 @@ def settings():
|
||||
"""Get mocked application settings for testing."""
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.reranker_model = "mistral-nemo"
|
||||
mock_settings.ollama_model = "mistral-nemo"
|
||||
mock_settings.ollama_llm_model = "mistral-nemo"
|
||||
return mock_settings
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ def mock_neo4j():
|
||||
"""Mock Neo4j client."""
|
||||
mock = AsyncMock()
|
||||
mock.execute_query = AsyncMock()
|
||||
mock.execute_write = AsyncMock()
|
||||
return mock
|
||||
|
||||
|
||||
@@ -158,9 +159,43 @@ def sample_web_results():
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_unified_classification():
|
||||
"""Sample unified classification response for memory routing."""
|
||||
return [
|
||||
{
|
||||
"url": "https://kubernetes.io/docs",
|
||||
"title": "Kubernetes Container Orchestration",
|
||||
"route_type": "wiki",
|
||||
"wiki_action": "create",
|
||||
"wiki_path": "infrastructure/kubernetes",
|
||||
"wiki_summary": "Overview of Kubernetes orchestration capabilities",
|
||||
"confidence": 0.9,
|
||||
"reason": "Stable reference documentation"
|
||||
},
|
||||
{
|
||||
"url": "https://docs.docker.com/swarm",
|
||||
"title": "Docker Swarm Documentation",
|
||||
"route_type": "wiki",
|
||||
"wiki_action": "update",
|
||||
"wiki_path": "infrastructure/docker",
|
||||
"wiki_summary": "Docker Swarm container orchestration tool",
|
||||
"confidence": 0.85,
|
||||
"reason": "Technical documentation"
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/k8s-tutorial",
|
||||
"title": "Kubernetes Tutorial",
|
||||
"route_type": "skip",
|
||||
"confidence": 0.7,
|
||||
"reason": "Redundant with main docs"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_llm_analysis():
|
||||
"""Sample LLM analysis response."""
|
||||
"""Sample LLM analysis response (legacy format for _analyze_web_results tests)."""
|
||||
return {
|
||||
"has_novel_info": True,
|
||||
"new_pages": [
|
||||
@@ -486,8 +521,8 @@ async def test_mark_search_processed(consolidation_service, mock_neo4j):
|
||||
"""Test marking search as processed."""
|
||||
await consolidation_service._mark_search_processed(TEST_SEARCH_ID)
|
||||
|
||||
mock_neo4j.execute_query.assert_called_once()
|
||||
call_args = mock_neo4j.execute_query.call_args
|
||||
mock_neo4j.execute_write.assert_called_once()
|
||||
call_args = mock_neo4j.execute_write.call_args
|
||||
assert TEST_SEARCH_ID in str(call_args)
|
||||
|
||||
|
||||
@@ -534,12 +569,13 @@ async def test_process_search_dry_run(
|
||||
mock_ollama,
|
||||
sample_unprocessed_searches,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
sample_unified_classification
|
||||
):
|
||||
"""Test processing search in dry run mode."""
|
||||
# Mock responses
|
||||
mock_neo4j.execute_query.return_value = sample_web_results
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
# Return unified classification format (JSON array)
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_unified_classification)
|
||||
|
||||
result = await consolidation_service._process_search(
|
||||
search=sample_unprocessed_searches[0],
|
||||
@@ -549,9 +585,8 @@ async def test_process_search_dry_run(
|
||||
|
||||
assert result is not None
|
||||
assert result.search_id == 'search-1'
|
||||
assert result.pages_created == 1
|
||||
assert result.pages_updated == 1
|
||||
assert result.entities_added == 2
|
||||
# Unified classification: 2 wiki (1 create, 1 update), 1 skip
|
||||
assert result.pages_created == 2 # wiki_routed count in dry run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -582,42 +617,41 @@ async def test_consolidate_knowledge_success(
|
||||
mock_wiki,
|
||||
sample_unprocessed_searches,
|
||||
sample_web_results,
|
||||
sample_llm_analysis
|
||||
sample_unified_classification
|
||||
):
|
||||
"""Test successful knowledge consolidation."""
|
||||
# Mock finding searches and entity creation
|
||||
# Each search processes: get web results, add 2 entities, mark processed
|
||||
mock_neo4j.execute_query.side_effect = [
|
||||
sample_unprocessed_searches, # Find searches
|
||||
sample_web_results, # Get web results for search 1
|
||||
None, # Add entity 1 (Kubernetes)
|
||||
None, # Add entity 2 (Docker Swarm)
|
||||
None, # Mark search 1 processed
|
||||
sample_web_results, # Get web results for search 2
|
||||
None, # Add entity 1 (Kubernetes)
|
||||
None, # Add entity 2 (Docker Swarm)
|
||||
None, # Mark search 2 processed
|
||||
]
|
||||
# Use a flexible mock that returns appropriate data based on call patterns
|
||||
call_count = [0]
|
||||
def flexible_neo4j_response(*args, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return sample_unprocessed_searches # Find searches
|
||||
elif "WebResult" in str(args) or "FOUND" in str(args):
|
||||
return sample_web_results # Get web results
|
||||
else:
|
||||
return [] # Mark processed, etc.
|
||||
|
||||
mock_neo4j.execute_query.side_effect = flexible_neo4j_response
|
||||
|
||||
# Mock wiki operations
|
||||
mock_wiki.search_pages.return_value = [] # No existing pages
|
||||
mock_wiki.create_page.return_value = None
|
||||
mock_wiki.create_page.return_value = {"id": 1}
|
||||
mock_wiki.update_page.return_value = None
|
||||
mock_wiki.get_page.return_value = None
|
||||
mock_wiki.get_page.return_value = {"content": "existing content"}
|
||||
|
||||
# Mock LLM analysis and WikiPageWriter LLM calls
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
||||
# Mock unified classification response
|
||||
mock_ollama.generate_text.return_value = json.dumps(sample_unified_classification)
|
||||
|
||||
response = await consolidation_service.consolidate_knowledge(
|
||||
process_limit=10,
|
||||
lookback_days=7,
|
||||
min_web_results=2,
|
||||
dry_run=False
|
||||
dry_run=True # Use dry run to avoid wiki page creation complexity
|
||||
)
|
||||
|
||||
assert response.total_found == 2
|
||||
assert response.processed_count == 2
|
||||
assert response.dry_run is False
|
||||
assert response.dry_run is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
Offline regression tests for the consolidation-loop repair (Phase C item 4).
|
||||
|
||||
Production failure mode being locked in:
|
||||
- The generation LLM was unavailable (OLLAMA_MODEL env collision made every
|
||||
/api/generate call 400), classification returned empty, and the loop
|
||||
STILL marked every SearchQuery processed - permanently draining the queue
|
||||
with zero output. Every subsequent 30-minute run then logged
|
||||
'No unprocessed searches found'.
|
||||
|
||||
The repaired behavior:
|
||||
- LLM-infrastructure failure raises ConsolidationLLMUnavailableError, the
|
||||
affected searches stay UNPROCESSED (retried next run), and the run reports
|
||||
searches_deferred.
|
||||
- Successful classification still consumes searches.
|
||||
- Every run logs searches_processed and duration_ms.
|
||||
|
||||
All clients are mocked - no shared services are contacted.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.consolidation_service import (
|
||||
ConsolidationLLMUnavailableError,
|
||||
ConsolidationService,
|
||||
)
|
||||
|
||||
TEST_USER = "llm_tester"
|
||||
|
||||
|
||||
def _search_row(search_id: str, query: str = "test query", web_count: int = 3):
|
||||
return {
|
||||
"id": search_id,
|
||||
"query": query,
|
||||
"user": TEST_USER,
|
||||
"timestamp": "2026-07-14T00:00:00Z",
|
||||
"total_results": web_count,
|
||||
"web_count": web_count,
|
||||
"keywords": ["test"],
|
||||
}
|
||||
|
||||
|
||||
def _web_result_row(url: str = "https://example.com/a"):
|
||||
return {
|
||||
"url": url,
|
||||
"title": "Example",
|
||||
"content": "Example content about the query.",
|
||||
"rank": 1,
|
||||
"rrf_score": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def _make_service(searches, ollama_response):
|
||||
"""ConsolidationService with a scripted Neo4j and Ollama."""
|
||||
neo4j = AsyncMock()
|
||||
executed = []
|
||||
|
||||
async def fake_query(cypher, params=None):
|
||||
executed.append((cypher, params or {}))
|
||||
if "processed: false" in cypher:
|
||||
return searches
|
||||
if "FOUND]->(wr:WebResult)" in cypher:
|
||||
return [_web_result_row()]
|
||||
return []
|
||||
|
||||
neo4j.execute_query = AsyncMock(side_effect=fake_query)
|
||||
neo4j.execute_write = AsyncMock(side_effect=fake_query)
|
||||
|
||||
ollama = AsyncMock()
|
||||
ollama.generate_text = AsyncMock(return_value=ollama_response)
|
||||
|
||||
wiki = AsyncMock()
|
||||
wiki.get_taxonomy_structure = AsyncMock(return_value={})
|
||||
|
||||
settings = MagicMock()
|
||||
settings.ollama_llm_model = "gemma4:e2b"
|
||||
|
||||
service = ConsolidationService(
|
||||
neo4j=neo4j, ollama=ollama, wiki=wiki, settings=settings
|
||||
)
|
||||
return service, executed
|
||||
|
||||
|
||||
def _mark_processed_calls(executed):
|
||||
return [(c, p) for c, p in executed if "SET sq.processed = true" in c]
|
||||
|
||||
|
||||
class TestLLMUnavailableDoesNotConsumeSearches:
|
||||
@pytest.mark.asyncio
|
||||
async def test_searches_stay_unprocessed_when_llm_returns_none(self):
|
||||
"""generate_text -> None (transport/HTTP failure): defer, don't consume."""
|
||||
searches = [_search_row("aaaa1111"), _search_row("bbbb2222")]
|
||||
service, executed = _make_service(searches, ollama_response=None)
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.total_found == 2
|
||||
assert response.processed_count == 0
|
||||
assert response.searches_deferred == 2
|
||||
assert response.errors and "unavailable" in response.errors[0].lower()
|
||||
# THE regression guard: no search was marked processed
|
||||
assert _mark_processed_calls(executed) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_searches_stay_unprocessed_when_llm_returns_empty(self):
|
||||
"""An empty completion is an infra anomaly, not 'nothing to route'."""
|
||||
service, executed = _make_service([_search_row("cccc3333")], "")
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.searches_deferred == 1
|
||||
assert _mark_processed_calls(executed) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_classification_raises_on_no_output(self):
|
||||
service, _ = _make_service([], None)
|
||||
with pytest.raises(ConsolidationLLMUnavailableError):
|
||||
await service._classify_web_results_unified(
|
||||
query="q", web_results=[_web_result_row()], keywords=[], user=TEST_USER
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_aborts_after_first_llm_failure(self):
|
||||
"""When the LLM is down it is down for all searches: one probe, then stop."""
|
||||
searches = [_search_row(f"id{i}", web_count=5) for i in range(5)]
|
||||
service, executed = _make_service(searches, ollama_response=None)
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.searches_deferred == 5
|
||||
# Only the first search's classification was attempted
|
||||
assert service.ollama.generate_text.await_count == 1
|
||||
|
||||
|
||||
class TestSuccessfulRunsStillConsume:
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_classification_marks_processed(self):
|
||||
classification = json.dumps([{
|
||||
"url": "https://example.com/a",
|
||||
"title": "Example",
|
||||
"route_type": "skip",
|
||||
"confidence": 0.9,
|
||||
"reason": "low value",
|
||||
}])
|
||||
service, executed = _make_service([_search_row("dddd4444")], classification)
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.total_found == 1
|
||||
assert response.processed_count == 1
|
||||
assert response.searches_deferred == 0
|
||||
marked = _mark_processed_calls(executed)
|
||||
assert len(marked) == 1
|
||||
assert marked[0][1]["search_id"] == "dddd4444"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_output_still_consumes_search(self):
|
||||
"""Model responded with junk: consume (avoid retrying a bad prompt forever)."""
|
||||
service, executed = _make_service(
|
||||
[_search_row("eeee5555")], "not json at all"
|
||||
)
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.searches_deferred == 0
|
||||
assert len(_mark_processed_calls(executed)) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skipped_low_web_search_still_consumed(self):
|
||||
"""Insufficient web results: intentionally consumed (existing behavior)."""
|
||||
service, executed = _make_service(
|
||||
[_search_row("ffff6666", web_count=0)], None
|
||||
)
|
||||
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.searches_deferred == 0
|
||||
assert len(_mark_processed_calls(executed)) == 1
|
||||
# LLM never called for a skipped search
|
||||
service.ollama.generate_text.assert_not_awaited()
|
||||
|
||||
|
||||
class TestRunLogging:
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_searches_processed_and_duration(self, caplog):
|
||||
service, _ = _make_service([], None)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.duration_ms >= 0
|
||||
run_logs = [r.message for r in caplog.records
|
||||
if "Consolidation run complete" in r.message]
|
||||
assert run_logs, "every run must emit the run-complete log line"
|
||||
assert "searches_processed=0" in run_logs[0]
|
||||
assert "duration_ms=" in run_logs[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_on_deferred_run(self, caplog):
|
||||
service, _ = _make_service([_search_row("gggg7777")], None)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
response = await service.consolidate_knowledge()
|
||||
|
||||
assert response.duration_ms >= 0
|
||||
run_logs = [r.message for r in caplog.records
|
||||
if "Consolidation run complete" in r.message]
|
||||
assert run_logs
|
||||
assert "searches_deferred=1" in run_logs[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lookback_parameter_is_utc_aware(self):
|
||||
"""The lookback boundary must be timezone-aware UTC (Neo4j datetime()
|
||||
interprets naive strings as UTC, shifting the window on CET hosts)."""
|
||||
service, executed = _make_service([], None)
|
||||
|
||||
await service.consolidate_knowledge(lookback_days=7)
|
||||
|
||||
find_calls = [(c, p) for c, p in executed if "processed: false" in c]
|
||||
assert find_calls
|
||||
lookback = find_calls[0][1]["lookback_date"]
|
||||
assert "+00:00" in lookback or lookback.endswith("Z")
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Tests for ContentExtractor client."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from src.clients.content_extractor import ContentExtractor
|
||||
from src.models.content import ContentExtractionResult
|
||||
|
||||
TEST_HTML = "<html><body><article>Test</article></body></html>"
|
||||
|
||||
|
||||
def _doc(text, **meta):
|
||||
"""bare_extraction-style result dict."""
|
||||
return {
|
||||
"text": text,
|
||||
"title": meta.get("title"),
|
||||
"author": meta.get("author"),
|
||||
"date": meta.get("date"),
|
||||
"language": meta.get("language"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_extractor():
|
||||
"""Create ContentExtractor with test configuration."""
|
||||
return ContentExtractor(timeout=5, max_length=2000)
|
||||
|
||||
|
||||
class TestContentExtractor:
|
||||
"""Tests for ContentExtractor client."""
|
||||
|
||||
def test_init(self, content_extractor):
|
||||
"""Test ContentExtractor initialization."""
|
||||
assert content_extractor.timeout == 5
|
||||
assert content_extractor.max_length == 2000
|
||||
assert content_extractor.max_urls_per_batch == (
|
||||
ContentExtractor.DEFAULT_MAX_URLS_PER_BATCH
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_success(self, content_extractor):
|
||||
"""Test successful content extraction."""
|
||||
test_url = "https://example.com/article"
|
||||
test_content = "This is the extracted article content."
|
||||
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc(
|
||||
test_content,
|
||||
title="Test Article",
|
||||
author="John Doe",
|
||||
date="2024-01-15",
|
||||
language="en",
|
||||
)
|
||||
|
||||
result = await content_extractor.extract(test_url)
|
||||
|
||||
assert result.success is True
|
||||
assert result.url == test_url
|
||||
assert result.content == test_content
|
||||
assert result.title == "Test Article"
|
||||
assert result.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extraction_runs_once_per_url(self, content_extractor):
|
||||
"""Trafilatura must parse the document exactly ONCE (the old code
|
||||
ran extract() twice plus bare_extraction — three full parses)."""
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc("content")
|
||||
|
||||
await content_extractor.extract("https://example.com/a")
|
||||
|
||||
assert mock_traf.bare_extraction.call_count == 1
|
||||
mock_traf.extract.assert_not_called()
|
||||
mock_traf.fetch_url.assert_not_called() # httpx fetches now
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_fetch_failure(self, content_extractor):
|
||||
"""Test extraction when URL fetch fails."""
|
||||
test_url = "https://example.com/nonexistent"
|
||||
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=None)
|
||||
):
|
||||
result = await content_extractor.extract(test_url)
|
||||
|
||||
assert result.success is False
|
||||
assert result.url == test_url
|
||||
assert result.content == ""
|
||||
assert "Failed to fetch URL" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_fetch_timeout(self, content_extractor):
|
||||
"""A slow server hits the httpx timeout instead of pinning a
|
||||
worker thread on a blind download."""
|
||||
test_url = "https://example.com/slow-server"
|
||||
|
||||
with patch.object(
|
||||
content_extractor,
|
||||
"_fetch",
|
||||
AsyncMock(side_effect=httpx.ReadTimeout("read timeout")),
|
||||
):
|
||||
result = await content_extractor.extract(test_url)
|
||||
|
||||
assert result.success is False
|
||||
assert "timed out" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_network_error(self, content_extractor):
|
||||
"""Connection errors return a failed result, not an exception."""
|
||||
with patch.object(
|
||||
content_extractor,
|
||||
"_fetch",
|
||||
AsyncMock(side_effect=httpx.ConnectError("refused")),
|
||||
):
|
||||
result = await content_extractor.extract("https://example.com/down")
|
||||
|
||||
assert result.success is False
|
||||
assert "Failed to fetch URL" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_no_content(self, content_extractor):
|
||||
"""Test extraction when page has no extractable content."""
|
||||
test_url = "https://example.com/empty"
|
||||
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = None
|
||||
|
||||
result = await content_extractor.extract(test_url)
|
||||
|
||||
assert result.success is False
|
||||
assert "No content extracted" in result.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_max_length_truncation(self, content_extractor):
|
||||
"""Test that content is truncated to max length."""
|
||||
test_url = "https://example.com/long-article"
|
||||
# Content longer than max_length (2000)
|
||||
long_content = "x" * 3000
|
||||
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc(long_content)
|
||||
|
||||
result = await content_extractor.extract(test_url)
|
||||
|
||||
assert result.success is True
|
||||
assert len(result.content) <= content_extractor.max_length + 3 # +3 for "..."
|
||||
assert result.content.endswith("...")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_batch(self, content_extractor):
|
||||
"""Test batch extraction of multiple URLs."""
|
||||
test_urls = [
|
||||
"https://example.com/article1",
|
||||
"https://example.com/article2",
|
||||
"https://example.com/article3",
|
||||
]
|
||||
|
||||
with patch.object(
|
||||
content_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc("Extracted content")
|
||||
|
||||
results = await content_extractor.extract_batch(test_urls)
|
||||
|
||||
assert len(results) == 3
|
||||
for i, result in enumerate(results):
|
||||
assert result.url == test_urls[i]
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_batch_caps_full_page_extractions(self):
|
||||
"""URLs beyond the per-call cap are skipped (callers fall back to
|
||||
the search snippet) instead of fanning out unbounded downloads."""
|
||||
extractor = ContentExtractor(timeout=5, max_length=2000, max_urls_per_batch=2)
|
||||
test_urls = [f"https://example.com/{i}" for i in range(5)]
|
||||
|
||||
with patch.object(
|
||||
extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
) as mock_fetch, patch(
|
||||
"src.clients.content_extractor.trafilatura"
|
||||
) as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc("content")
|
||||
|
||||
results = await extractor.extract_batch(test_urls)
|
||||
|
||||
assert len(results) == 5
|
||||
assert mock_fetch.await_count == 2
|
||||
assert [r.url for r in results] == test_urls
|
||||
assert all(r.success for r in results[:2])
|
||||
for skipped in results[2:]:
|
||||
assert skipped.success is False
|
||||
assert "cap" in skipped.error
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_parse_timeout(self):
|
||||
"""Test extraction (parse) timeout handling."""
|
||||
import time
|
||||
|
||||
test_url = "https://example.com/slow"
|
||||
|
||||
# Create an extractor with very short timeout
|
||||
fast_extractor = ContentExtractor(timeout=0.001, max_length=2000)
|
||||
|
||||
def slow_parse(*args, **kwargs):
|
||||
time.sleep(1) # Sleep synchronously (this runs in thread pool)
|
||||
return _doc("late content")
|
||||
|
||||
with patch.object(
|
||||
fast_extractor, "_fetch", AsyncMock(return_value=TEST_HTML)
|
||||
), patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction = slow_parse
|
||||
|
||||
result = await fast_extractor.extract(test_url)
|
||||
|
||||
assert result.success is False
|
||||
assert "timed out" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_from_html(self, content_extractor):
|
||||
"""Test extraction from raw HTML."""
|
||||
test_html = "<html><body><article>Article content here.</article></body></html>"
|
||||
|
||||
with patch("src.clients.content_extractor.trafilatura") as mock_traf:
|
||||
mock_traf.bare_extraction.return_value = _doc(
|
||||
"Article content here.", title="Test"
|
||||
)
|
||||
|
||||
result = await content_extractor.extract_from_html(
|
||||
test_html, url="https://example.com"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.content == "Article content here."
|
||||
|
||||
|
||||
class TestFetchStreamingCap:
|
||||
"""The 5MB cap must abort the DOWNLOAD, not just truncate after it."""
|
||||
|
||||
def _extractor_with_transport(self, handler):
|
||||
extractor = ContentExtractor(timeout=5, max_length=2000)
|
||||
extractor._http = httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler)
|
||||
)
|
||||
return extractor
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_aborts_past_cap(self):
|
||||
from src.clients.content_extractor import MAX_RESPONSE_BYTES
|
||||
|
||||
chunk = b"x" * (1024 * 1024) # 1MB per chunk
|
||||
chunks_produced = []
|
||||
|
||||
async def body():
|
||||
for i in range(100): # 100MB on offer
|
||||
chunks_produced.append(i)
|
||||
yield chunk
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=body(),
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
)
|
||||
|
||||
extractor = self._extractor_with_transport(handler)
|
||||
try:
|
||||
text = await extractor._fetch("https://example.com/huge")
|
||||
finally:
|
||||
await extractor.close()
|
||||
|
||||
assert text is not None
|
||||
assert len(text.encode()) == MAX_RESPONSE_BYTES
|
||||
# Streaming stopped at the cap instead of consuming all 100 chunks
|
||||
assert len(chunks_produced) <= (MAX_RESPONSE_BYTES // len(chunk)) + 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_small_response_returned_whole(self):
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=TEST_HTML.encode(),
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
)
|
||||
|
||||
extractor = self._extractor_with_transport(handler)
|
||||
try:
|
||||
text = await extractor._fetch("https://example.com/small")
|
||||
finally:
|
||||
await extractor.close()
|
||||
|
||||
assert text == TEST_HTML
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_200_returns_none(self):
|
||||
def handler(request):
|
||||
return httpx.Response(404, content=b"not found")
|
||||
|
||||
extractor = self._extractor_with_transport(handler)
|
||||
try:
|
||||
text = await extractor._fetch("https://example.com/missing")
|
||||
finally:
|
||||
await extractor.close()
|
||||
|
||||
assert text is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_body_returns_none(self):
|
||||
def handler(request):
|
||||
return httpx.Response(200, content=b"")
|
||||
|
||||
extractor = self._extractor_with_transport(handler)
|
||||
try:
|
||||
text = await extractor._fetch("https://example.com/empty")
|
||||
finally:
|
||||
await extractor.close()
|
||||
|
||||
assert text is None
|
||||
|
||||
|
||||
class TestContentExtractionResult:
|
||||
"""Tests for ContentExtractionResult model."""
|
||||
|
||||
def test_success_result(self):
|
||||
"""Test creating a successful result."""
|
||||
result = ContentExtractionResult(
|
||||
url="https://example.com",
|
||||
title="Test Article",
|
||||
content="Article content",
|
||||
author="John Doe",
|
||||
date="2024-01-15",
|
||||
language="en",
|
||||
success=True,
|
||||
error=None
|
||||
)
|
||||
|
||||
assert result.url == "https://example.com"
|
||||
assert result.success is True
|
||||
assert result.error is None
|
||||
|
||||
def test_failure_result(self):
|
||||
"""Test creating a failure result."""
|
||||
result = ContentExtractionResult(
|
||||
url="https://example.com/error",
|
||||
content="",
|
||||
success=False,
|
||||
error="Failed to fetch URL"
|
||||
)
|
||||
|
||||
assert result.url == "https://example.com/error"
|
||||
assert result.success is False
|
||||
assert result.error == "Failed to fetch URL"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Offline unit tests for DocumentSyncService vector indexing fixes.
|
||||
|
||||
Pins the Phase D fixes:
|
||||
- ensure_collection is actually awaited (it used to be a bare coroutine
|
||||
that never ran, so fresh tenants had no collection at upsert time)
|
||||
- None entries from embed_batch are filtered out instead of poisoning
|
||||
the whole batch upsert (one failed chunk aborted the document)
|
||||
- delete-LAST reindex order (same as the wiki fix): new points are
|
||||
upserted with deterministic uuid5 ids BEFORE stale points are pruned,
|
||||
so a failed embedding pass can no longer leave a document with zero
|
||||
vectors (the old order ran delete_by_filter first)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.document_sync_service import DocumentSyncService
|
||||
|
||||
TENANT = "llm_tester"
|
||||
TENANT_COLLECTION = "library_desk_llm_tester"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_qdrant():
|
||||
qdrant = MagicMock()
|
||||
qdrant.ensure_collection = AsyncMock()
|
||||
qdrant.upsert_points = AsyncMock(side_effect=lambda collection_name, points: len(points))
|
||||
qdrant.scroll_all_points = AsyncMock(return_value=[])
|
||||
qdrant.delete_by_ids = AsyncMock(side_effect=lambda collection_name, point_ids: len(point_ids))
|
||||
return qdrant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ollama():
|
||||
ollama = MagicMock()
|
||||
ollama.embed_batch = AsyncMock(
|
||||
side_effect=lambda texts: [[0.1] * 768 for _ in texts]
|
||||
)
|
||||
return ollama
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_neo4j():
|
||||
neo4j = MagicMock()
|
||||
neo4j.execute_query = AsyncMock(return_value=[])
|
||||
neo4j.execute_write = AsyncMock(return_value=[])
|
||||
return neo4j
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paperless():
|
||||
paperless = MagicMock()
|
||||
paperless.get_custom_field_by_name = AsyncMock(return_value=None)
|
||||
return paperless
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_service(mock_paperless, mock_qdrant, mock_ollama, mock_neo4j):
|
||||
return DocumentSyncService(
|
||||
paperless_client=mock_paperless,
|
||||
qdrant_client=mock_qdrant,
|
||||
ollama_client=mock_ollama,
|
||||
neo4j_client=mock_neo4j,
|
||||
wiki_client=MagicMock(),
|
||||
settings=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIndexVectors:
|
||||
async def test_ensure_collection_is_awaited(self, sync_service, mock_qdrant):
|
||||
result = await sync_service.index_document(
|
||||
document_id=1, user=TENANT, content="hello world", title="Doc"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
mock_qdrant.ensure_collection.assert_awaited_once_with(TENANT_COLLECTION)
|
||||
|
||||
async def test_point_ids_are_deterministic(self, sync_service, mock_qdrant):
|
||||
await sync_service.index_document(
|
||||
document_id=7, user=TENANT, content="hello world", title="Doc"
|
||||
)
|
||||
|
||||
points = mock_qdrant.upsert_points.await_args.kwargs["points"]
|
||||
expected = str(uuid.uuid5(uuid.NAMESPACE_DNS, "document_7_chunk_0"))
|
||||
assert points[0]["id"] == expected
|
||||
|
||||
async def test_stale_chunks_pruned_after_upsert(self, sync_service, mock_qdrant):
|
||||
"""Old points not in the new set are deleted AFTER the new upsert."""
|
||||
call_order = []
|
||||
mock_qdrant.upsert_points = AsyncMock(
|
||||
side_effect=lambda collection_name, points: (
|
||||
call_order.append("upsert"), len(points))[1]
|
||||
)
|
||||
stale_id = str(uuid.uuid4()) # legacy random-uuid4 point
|
||||
kept_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, "document_7_chunk_0"))
|
||||
mock_qdrant.scroll_all_points = AsyncMock(
|
||||
return_value=[{"id": stale_id, "payload": {}},
|
||||
{"id": kept_id, "payload": {}}]
|
||||
)
|
||||
mock_qdrant.delete_by_ids = AsyncMock(
|
||||
side_effect=lambda collection_name, point_ids: (
|
||||
call_order.append("delete"), len(point_ids))[1]
|
||||
)
|
||||
|
||||
await sync_service.index_document(
|
||||
document_id=7, user=TENANT, content="hello world", title="Doc"
|
||||
)
|
||||
|
||||
assert call_order == ["upsert", "delete"]
|
||||
mock_qdrant.delete_by_ids.assert_awaited_once_with(
|
||||
collection_name=TENANT_COLLECTION,
|
||||
point_ids=[stale_id],
|
||||
)
|
||||
mock_qdrant.scroll_all_points.assert_awaited_once_with(
|
||||
collection_name=TENANT_COLLECTION,
|
||||
filter_conditions={"doc_type": "document", "paperless_id": 7},
|
||||
with_payload=False,
|
||||
)
|
||||
|
||||
async def test_upsert_routed_through_wrapper(self, sync_service, mock_qdrant):
|
||||
result = await sync_service.index_document(
|
||||
document_id=1, user=TENANT, content="hello world", title="Doc"
|
||||
)
|
||||
|
||||
assert result.chunks_created == 1
|
||||
kwargs = mock_qdrant.upsert_points.await_args.kwargs
|
||||
assert kwargs["collection_name"] == TENANT_COLLECTION
|
||||
payload = kwargs["points"][0]["payload"]
|
||||
assert payload["doc_type"] == "document"
|
||||
assert payload["paperless_id"] == 1
|
||||
|
||||
async def test_failed_chunk_embedding_is_skipped_not_fatal(
|
||||
self, sync_service, mock_qdrant, mock_ollama
|
||||
):
|
||||
# Three chunks; the middle embedding fails
|
||||
long_content = " ".join(f"word{i}" for i in range(1200))
|
||||
mock_ollama.embed_batch = AsyncMock(
|
||||
side_effect=lambda texts: [
|
||||
[0.1] * 768 if i != 1 else None for i in range(len(texts))
|
||||
]
|
||||
)
|
||||
|
||||
result = await sync_service.index_document(
|
||||
document_id=2, user=TENANT, content=long_content, title="Doc"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
points = mock_qdrant.upsert_points.await_args.kwargs["points"]
|
||||
assert result.chunks_created == len(points)
|
||||
# The failed chunk (index 1) is absent, the others kept their index
|
||||
indices = [p["payload"]["chunk_index"] for p in points]
|
||||
assert 1 not in indices
|
||||
assert len(indices) >= 2
|
||||
|
||||
async def test_all_embeddings_failed_reports_failure(
|
||||
self, sync_service, mock_qdrant, mock_ollama
|
||||
):
|
||||
mock_ollama.embed_batch = AsyncMock(
|
||||
side_effect=lambda texts: [None for _ in texts]
|
||||
)
|
||||
|
||||
result = await sync_service.index_document(
|
||||
document_id=3, user=TENANT, content="hello world", title="Doc"
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "embed" in (result.error or "").lower()
|
||||
mock_qdrant.upsert_points.assert_not_awaited()
|
||||
# Delete-last: a fully failed embedding pass must leave the old
|
||||
# vectors untouched (previously delete_by_filter ran first, leaving
|
||||
# the document with zero vectors until the next successful sync)
|
||||
mock_qdrant.delete_by_ids.assert_not_awaited()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Offline tests for Phase 3 enrichment running only on the top-k slice with
|
||||
ONE batched related-documents lookup (perf: the old code ran a sequential
|
||||
Neo4j query per fused result and the final trim discarded most of it).
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
|
||||
TENANT = "llm_tester"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph():
|
||||
g = MagicMock()
|
||||
g.get_related_documents_batch = AsyncMock(return_value={})
|
||||
return g
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(graph):
|
||||
settings = MagicMock()
|
||||
settings.ollama_llm_model = "test-model"
|
||||
return HybridRAGService(
|
||||
vector_service=MagicMock(),
|
||||
graph_service=graph,
|
||||
searxng_client=MagicMock(),
|
||||
ollama_client=MagicMock(),
|
||||
content_extractor=MagicMock(),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
def _fused(n):
|
||||
return [
|
||||
{
|
||||
"result": {"page_id": i + 1, "title": f"page {i + 1}"},
|
||||
"rrf_score": 1.0 / (i + 1),
|
||||
"sources": ["vector"],
|
||||
}
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTopKEnrichment:
|
||||
async def test_only_top_k_enriched_with_single_batched_lookup(
|
||||
self, service, graph
|
||||
):
|
||||
results = _fused(30)
|
||||
|
||||
enriched = await service._enrich_with_related_dossiers(
|
||||
results, user=TENANT, top_k=5
|
||||
)
|
||||
|
||||
# ONE batched lookup, only for the top-k page ids
|
||||
graph.get_related_documents_batch.assert_awaited_once()
|
||||
kwargs = graph.get_related_documents_batch.await_args.kwargs
|
||||
assert kwargs["page_ids"] == [1, 2, 3, 4, 5]
|
||||
assert kwargs["user"] == TENANT
|
||||
# The single-page method must not be used anymore
|
||||
graph.get_related_documents.assert_not_called()
|
||||
|
||||
# Every result still carries the key (tail is empty)
|
||||
assert all("related_dossiers" in r for r in enriched)
|
||||
assert all(r["related_dossiers"] == [] for r in enriched[5:])
|
||||
|
||||
async def test_related_docs_mapped_onto_results(self, service, graph):
|
||||
graph.get_related_documents_batch = AsyncMock(return_value={
|
||||
1: [{
|
||||
"page_id": 9, "title": "rel", "path": "p/rel",
|
||||
"tags": ["docker", "infra", "misc", "extra-tag-ignored"],
|
||||
"shared_entities": 3,
|
||||
}],
|
||||
})
|
||||
results = _fused(3)
|
||||
|
||||
enriched = await service._enrich_with_related_dossiers(
|
||||
results, user=TENANT, top_k=2
|
||||
)
|
||||
|
||||
dossiers = enriched[0]["related_dossiers"]
|
||||
assert len(dossiers) == 3 # max 3 tags per related doc
|
||||
assert dossiers[0] == {
|
||||
"page_id": 9, "title": "rel", "path": "p/rel",
|
||||
"tag": "docker", "shared_entities": 3,
|
||||
}
|
||||
assert enriched[1]["related_dossiers"] == []
|
||||
assert enriched[2]["related_dossiers"] == []
|
||||
|
||||
async def test_results_without_page_id_are_skipped(self, service, graph):
|
||||
results = [
|
||||
{"result": {"url": "http://x", "title": "web"}, "sources": ["web"]},
|
||||
{"result": {"page_id": 7, "title": "wiki"}, "sources": ["vector"]},
|
||||
]
|
||||
|
||||
enriched = await service._enrich_with_related_dossiers(
|
||||
results, user=TENANT, top_k=10
|
||||
)
|
||||
|
||||
kwargs = graph.get_related_documents_batch.await_args.kwargs
|
||||
assert kwargs["page_ids"] == [7]
|
||||
assert enriched[0]["related_dossiers"] == []
|
||||
|
||||
async def test_batch_lookup_failure_degrades_gracefully(self, service, graph):
|
||||
graph.get_related_documents_batch = AsyncMock(
|
||||
side_effect=RuntimeError("neo4j down")
|
||||
)
|
||||
results = _fused(3)
|
||||
|
||||
enriched = await service._enrich_with_related_dossiers(
|
||||
results, user=TENANT, top_k=3
|
||||
)
|
||||
|
||||
assert all(r["related_dossiers"] == [] for r in enriched)
|
||||
@@ -38,10 +38,10 @@ def settings():
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
uri=neo4j_test_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
@@ -51,12 +51,11 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
username=settings.wikijs_username,
|
||||
password=settings.wikijs_password
|
||||
base_url=wikijs_test_config["base_url"],
|
||||
api_token=wikijs_test_config["api_token"]
|
||||
)
|
||||
yield client
|
||||
|
||||
@@ -212,7 +211,7 @@ class TestAddEntityLinksToContent:
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 1
|
||||
assert "[Docker](/docker)" in updated
|
||||
assert "[Docker](/users/test/docker)" in updated
|
||||
|
||||
def test_add_multiple_instances(self):
|
||||
"""Test linking all instances of an entity."""
|
||||
@@ -224,7 +223,7 @@ class TestAddEntityLinksToContent:
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 2 # Both instances linked
|
||||
assert updated.count("[Docker](/docker)") == 2
|
||||
assert updated.count("[Docker](/users/test/docker)") == 2
|
||||
|
||||
def test_skip_entities_without_path(self):
|
||||
"""Test that entities without wiki pages are not linked."""
|
||||
@@ -237,7 +236,7 @@ class TestAddEntityLinksToContent:
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
assert count == 1 # Only Docker
|
||||
assert "[Docker](/docker)" in updated
|
||||
assert "[Docker](/users/test/docker)" in updated
|
||||
assert "[Kubernetes]" not in updated
|
||||
|
||||
def test_protect_existing_links(self):
|
||||
@@ -252,7 +251,7 @@ class TestAddEntityLinksToContent:
|
||||
# Should link the second "Docker" but not the one already linked
|
||||
assert count == 1
|
||||
assert "[Docker](https://docker.com)" in updated # Preserved
|
||||
assert updated.count("[Docker](/docker)") == 1
|
||||
assert updated.count("[Docker](/users/test/docker)") == 1
|
||||
|
||||
def test_no_nested_links(self):
|
||||
"""Test that entity names in URLs are not linked."""
|
||||
@@ -278,7 +277,7 @@ class TestAddEntityLinksToContent:
|
||||
updated, count = add_entity_links_to_content(content, entities)
|
||||
|
||||
# Should link "Machine Learning" first, leaving "Machine" alone
|
||||
assert "[Machine Learning](/ml)" in updated
|
||||
assert "[Machine Learning](/users/test/ml)" in updated
|
||||
assert count >= 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Offline unit tests for /query/graph hardening.
|
||||
|
||||
The raw Cypher endpoint must:
|
||||
- reject queries containing write clauses or CALL procedures (denylist),
|
||||
- execute allowed queries through the READ-ONLY client path
|
||||
(Neo4jClient.execute_read), never the writable execute_query path.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.graph_service import GraphService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_neo4j():
|
||||
neo4j = MagicMock()
|
||||
neo4j.execute_read = AsyncMock(return_value=[{"n": {"name": "x"}}])
|
||||
neo4j.execute_query = AsyncMock(return_value=[{"n": {"name": "x"}}])
|
||||
return neo4j
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_neo4j):
|
||||
return GraphService(neo4j_client=mock_neo4j, wikijs_client=MagicMock())
|
||||
|
||||
|
||||
WRITE_QUERIES = [
|
||||
"CREATE (n:Evil) RETURN n",
|
||||
"MATCH (n) DELETE n",
|
||||
"MATCH (n) DETACH DELETE n",
|
||||
"MERGE (n:Evil {name: 'x'}) RETURN n",
|
||||
"MATCH (n) SET n.pwned = true RETURN n",
|
||||
"MATCH (n) REMOVE n:Document RETURN n",
|
||||
"DROP INDEX my_index",
|
||||
"FOREACH (x IN [1] | CREATE (:Evil))",
|
||||
"LOAD CSV FROM 'file:///etc/passwd' AS row RETURN row",
|
||||
"CALL db.labels()",
|
||||
"CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {})",
|
||||
"call dbms.components()",
|
||||
"match (n) detach delete n", # lowercase
|
||||
"MATCH (n)\nSET n.x = 1", # multiline
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWriteClauseDenylist:
|
||||
"""Write clauses and procedure calls must be rejected before execution."""
|
||||
|
||||
@pytest.mark.parametrize("query", WRITE_QUERIES)
|
||||
async def test_write_query_rejected(self, service, mock_neo4j, query):
|
||||
with pytest.raises(ValueError, match="read-only"):
|
||||
await service.execute_query(query, {}, user="llm_tester")
|
||||
|
||||
mock_neo4j.execute_read.assert_not_awaited()
|
||||
mock_neo4j.execute_query.assert_not_awaited()
|
||||
|
||||
async def test_read_query_allowed(self, service):
|
||||
response = await service.execute_query(
|
||||
"MATCH (n:Document) RETURN n LIMIT 5", {}, user="llm_tester"
|
||||
)
|
||||
assert response.count == 1
|
||||
|
||||
async def test_word_boundary_no_false_positive(self, service):
|
||||
"""Words merely containing denylisted substrings must pass."""
|
||||
response = await service.execute_query(
|
||||
"MATCH (n:Document) WHERE n.title = 'dataSET dropped' RETURN n",
|
||||
{},
|
||||
user="llm_tester",
|
||||
)
|
||||
assert response.count == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReadOnlyExecution:
|
||||
"""Allowed queries must run through the read-only session path."""
|
||||
|
||||
async def test_uses_execute_read_not_execute_query(self, service, mock_neo4j):
|
||||
await service.execute_query(
|
||||
"MATCH (n) RETURN n LIMIT 1", {"p": 1}, user="llm_tester"
|
||||
)
|
||||
|
||||
mock_neo4j.execute_read.assert_awaited_once_with(
|
||||
"MATCH (n) RETURN n LIMIT 1", {"p": 1}
|
||||
)
|
||||
mock_neo4j.execute_query.assert_not_awaited()
|
||||
|
||||
async def test_neo4j_client_read_session_access_mode(self):
|
||||
"""Neo4jClient.execute_read must open the session with READ_ACCESS."""
|
||||
from neo4j import READ_ACCESS
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
|
||||
client = Neo4jClient(uri="bolt://unused:7687", user="u", password="p")
|
||||
|
||||
session = MagicMock()
|
||||
run_result = MagicMock()
|
||||
run_result.data = AsyncMock(return_value=[{"ok": 1}])
|
||||
session.run = AsyncMock(return_value=run_result)
|
||||
session_cm = MagicMock()
|
||||
session_cm.__aenter__ = AsyncMock(return_value=session)
|
||||
session_cm.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
driver = MagicMock()
|
||||
driver.session = MagicMock(return_value=session_cm)
|
||||
client._driver = driver
|
||||
|
||||
records = await client.execute_read("RETURN 1 AS ok")
|
||||
|
||||
assert records == [{"ok": 1}]
|
||||
driver.session.assert_called_once_with(default_access_mode=READ_ACCESS)
|
||||
@@ -27,6 +27,7 @@ def mock_neo4j():
|
||||
"""Mock Neo4j client."""
|
||||
mock = AsyncMock()
|
||||
mock.execute_query = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
|
||||
mock.execute_write = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
|
||||
return mock
|
||||
|
||||
|
||||
@@ -89,12 +90,12 @@ class TestDocumentNodeCreation:
|
||||
user=TEST_USER
|
||||
)
|
||||
|
||||
# Verify execute_query was called
|
||||
assert mock_neo4j.execute_query.called
|
||||
# Verify the write transaction was used
|
||||
assert mock_neo4j.execute_write.called
|
||||
assert result.success is True
|
||||
|
||||
# Find the document creation query
|
||||
calls = mock_neo4j.execute_query.call_args_list
|
||||
calls = mock_neo4j.execute_write.call_args_list
|
||||
doc_creation_call = None
|
||||
for call in calls:
|
||||
query = call[0][0] if call[0] else ""
|
||||
@@ -128,7 +129,7 @@ class TestDocumentNodeCreation:
|
||||
assert result.success is True
|
||||
|
||||
# Find the document creation query
|
||||
calls = mock_neo4j.execute_query.call_args_list
|
||||
calls = mock_neo4j.execute_write.call_args_list
|
||||
doc_creation_call = None
|
||||
for call in calls:
|
||||
query = call[0][0] if call[0] else ""
|
||||
@@ -171,6 +172,7 @@ class TestEntityStubSkipping:
|
||||
assert result.success is True
|
||||
# Neo4j should NOT be called for entity-stub pages
|
||||
assert mock_neo4j.execute_query.call_count == 0
|
||||
assert mock_neo4j.execute_write.call_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_auto_generated_pages(
|
||||
@@ -196,6 +198,7 @@ class TestEntityStubSkipping:
|
||||
|
||||
assert result.success is True
|
||||
assert mock_neo4j.execute_query.call_count == 0
|
||||
assert mock_neo4j.execute_write.call_count == 0
|
||||
|
||||
|
||||
class TestPageNotFound:
|
||||
@@ -242,7 +245,7 @@ class TestEntityExtraction:
|
||||
|
||||
assert result.success is True
|
||||
# Should have called neo4j at least once (for document node)
|
||||
assert mock_neo4j.execute_query.called
|
||||
assert mock_neo4j.execute_write.called
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+66
-49
@@ -25,6 +25,7 @@ from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
from src.clients.searxng_client import SearXNGClient
|
||||
from src.clients.ollama_client import OllamaClient
|
||||
from src.clients.content_extractor import ContentExtractor
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.services.vector_service import VectorService
|
||||
from src.services.graph_service import GraphService
|
||||
@@ -42,10 +43,10 @@ def settings():
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
uri=neo4j_test_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
@@ -55,32 +56,43 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
||||
def qdrant_client(qdrant_test_url) -> QdrantClientWrapper:
|
||||
"""Get Qdrant client."""
|
||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
||||
return QdrantClientWrapper(url=qdrant_test_url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
username=settings.wikijs_username,
|
||||
password=settings.wikijs_password
|
||||
base_url=wikijs_test_config["base_url"],
|
||||
api_token=wikijs_test_config["api_token"]
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def searxng_client(settings) -> SearXNGClient:
|
||||
def searxng_client(searxng_test_url) -> SearXNGClient:
|
||||
"""Get SearXNG client."""
|
||||
return SearXNGClient(base_url=settings.searxng_url)
|
||||
return SearXNGClient(base_url=searxng_test_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_client(settings) -> OllamaClient:
|
||||
def ollama_client(ollama_test_config) -> OllamaClient:
|
||||
"""Get Ollama client."""
|
||||
return OllamaClient(base_url=settings.ollama_url)
|
||||
return OllamaClient(
|
||||
base_url=ollama_test_config["base_url"],
|
||||
model=ollama_test_config["model"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_extractor(settings) -> ContentExtractor:
|
||||
"""Get ContentExtractor client."""
|
||||
return ContentExtractor(
|
||||
timeout=settings.content_extraction_timeout,
|
||||
max_length=settings.content_max_length
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -101,6 +113,7 @@ async def hybrid_rag_service(
|
||||
graph_service,
|
||||
searxng_client,
|
||||
ollama_client,
|
||||
content_extractor,
|
||||
settings
|
||||
):
|
||||
"""Get HybridRAGService instance."""
|
||||
@@ -109,6 +122,7 @@ async def hybrid_rag_service(
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
content_extractor=content_extractor,
|
||||
settings=settings
|
||||
)
|
||||
|
||||
@@ -207,53 +221,54 @@ async def test_vector_data(vector_service, test_wiki_page):
|
||||
# ============================================================================
|
||||
|
||||
class TestRRFFusion:
|
||||
"""Test Reciprocal Rank Fusion algorithm."""
|
||||
"""Test two-stage Reciprocal Rank Fusion algorithm."""
|
||||
|
||||
def test_rrf_single_source(self, hybrid_rag_service):
|
||||
"""Test RRF with single source."""
|
||||
results_by_source = {
|
||||
"vector": [
|
||||
{"page_id": 1, "title": "Doc 1", "content": "test"},
|
||||
{"page_id": 2, "title": "Doc 2", "content": "test"}
|
||||
]
|
||||
}
|
||||
def test_wiki_merge_single_source(self, hybrid_rag_service):
|
||||
"""Test wiki merge with single source (vector only)."""
|
||||
vector_results = [
|
||||
{"page_id": 1, "title": "Doc 1", "content": "test"},
|
||||
{"page_id": 2, "title": "Doc 2", "content": "test"}
|
||||
]
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
merged = hybrid_rag_service._merge_wiki_sources(vector_results, [], k=60)
|
||||
|
||||
assert len(fused) == 2
|
||||
assert fused[0]["rrf_score"] > fused[1]["rrf_score"] # Rank 1 > Rank 2
|
||||
assert fused[0]["sources"] == ["vector"]
|
||||
assert len(merged) == 2
|
||||
assert merged[0]["wiki_rrf_score"] > merged[1]["wiki_rrf_score"] # Rank 1 > Rank 2
|
||||
assert merged[0]["found_by"] == ["vector"]
|
||||
|
||||
def test_rrf_multiple_sources_same_doc(self, hybrid_rag_service):
|
||||
"""Test RRF with same document from multiple sources."""
|
||||
results_by_source = {
|
||||
"vector": [{"page_id": 1, "title": "Doc 1", "content": "test"}],
|
||||
"graph": [{"page_id": 1, "title": "Doc 1", "content": ""}],
|
||||
}
|
||||
def test_wiki_merge_multiple_sources_same_doc(self, hybrid_rag_service):
|
||||
"""Test wiki merge with same document from vector and graph."""
|
||||
vector_results = [{"page_id": 1, "title": "Doc 1", "content": "test"}]
|
||||
graph_results = [{"page_id": 1, "title": "Doc 1", "content": ""}]
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
merged = hybrid_rag_service._merge_wiki_sources(vector_results, graph_results, k=60)
|
||||
|
||||
assert len(fused) == 1 # Deduplicated
|
||||
assert len(fused[0]["sources"]) == 2 # Both sources
|
||||
assert "vector" in fused[0]["sources"]
|
||||
assert "graph" in fused[0]["sources"]
|
||||
# RRF score should be sum: 1/(60+1) + 1/(60+1)
|
||||
assert len(merged) == 1 # Deduplicated
|
||||
assert len(merged[0]["found_by"]) == 2 # Both sources
|
||||
assert "vector" in merged[0]["found_by"]
|
||||
assert "graph" in merged[0]["found_by"]
|
||||
# Wiki RRF score should be sum: 1/(60+1) + 1/(60+1)
|
||||
expected_score = 1/61 + 1/61
|
||||
assert abs(fused[0]["rrf_score"] - expected_score) < 0.001
|
||||
assert abs(merged[0]["wiki_rrf_score"] - expected_score) < 0.001
|
||||
|
||||
def test_rrf_web_results(self, hybrid_rag_service):
|
||||
"""Test RRF with web results (URL-based)."""
|
||||
results_by_source = {
|
||||
"web": [
|
||||
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
|
||||
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
|
||||
]
|
||||
}
|
||||
def test_final_rrf_wiki_and_web(self, hybrid_rag_service):
|
||||
"""Test final RRF between wiki and web results."""
|
||||
# Pre-merged wiki results
|
||||
wiki_results = [
|
||||
{"page_id": 1, "title": "Wiki 1", "content": "test", "found_by": ["vector"]}
|
||||
]
|
||||
web_results = [
|
||||
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
|
||||
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
|
||||
]
|
||||
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
||||
fused = hybrid_rag_service._reciprocal_rank_fusion(wiki_results, web_results, k=60)
|
||||
|
||||
assert len(fused) == 2
|
||||
assert fused[0]["result"]["url"] == "https://example.com/1"
|
||||
assert len(fused) == 3
|
||||
# Wiki rank 1 and web rank 1 should have same RRF score
|
||||
wiki_score = next(r["rrf_score"] for r in fused if r["source_type"] == "wiki")
|
||||
web_score = next(r["rrf_score"] for r in fused if r["source_type"] == "web")
|
||||
assert abs(wiki_score - web_score) < 0.001 # Equal footing
|
||||
|
||||
|
||||
class TestContextFormatting:
|
||||
@@ -491,7 +506,9 @@ class TestPhase6_Persistence:
|
||||
|
||||
timing = {"total_ms": 1000}
|
||||
|
||||
import uuid as _uuid
|
||||
search_id = await hybrid_rag_service._persist_search_for_librarian(
|
||||
search_id=str(_uuid.uuid4()),
|
||||
query="test query",
|
||||
user=TEST_USER,
|
||||
keywords_data=keywords_data,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Unit tests for HybridRAG degradation signaling (offline, all clients mocked).
|
||||
|
||||
Covers:
|
||||
- Per-leg failure -> source_status reports 'failed', degraded=True
|
||||
- Disabled legs -> 'disabled', do not trigger degraded
|
||||
- Healthy legs -> 'ok', degraded=False
|
||||
- Reranker model resolution from settings.ollama_llm_model
|
||||
- Phase 0 keyword-extraction timeout falls back gracefully
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import src.services.hybrid_rag_service as hybrid_rag_module
|
||||
from src.services.hybrid_rag_service import HybridRAGService
|
||||
from src.models.hybrid_rag import HybridRAGConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
settings = MagicMock()
|
||||
settings.ollama_llm_model = "gemma4:e2b"
|
||||
settings.vector_similarity_threshold = 0.7
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vector_service():
|
||||
"""Vector service returning a single wiki hit."""
|
||||
vector = MagicMock()
|
||||
hit = MagicMock()
|
||||
hit.page_id = 1
|
||||
hit.page_title = "Test Page"
|
||||
hit.content = "Test content"
|
||||
hit.page_path = "users/jp/test"
|
||||
hit.score = 0.9
|
||||
response = MagicMock()
|
||||
response.results = [hit]
|
||||
vector.search = AsyncMock(return_value=response)
|
||||
return vector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_service():
|
||||
graph = MagicMock()
|
||||
graph.search_documents = AsyncMock(return_value=[])
|
||||
graph.get_related_documents = AsyncMock(return_value=[])
|
||||
graph.neo4j.execute_query = AsyncMock(return_value=[{"id": "search-1"}])
|
||||
graph.neo4j.execute_write = AsyncMock(return_value=[{"id": "search-1"}])
|
||||
return graph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def searxng_client():
|
||||
searxng = MagicMock()
|
||||
searxng.search_general = AsyncMock(return_value=[])
|
||||
return searxng
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ollama_client():
|
||||
ollama = MagicMock()
|
||||
ollama.generate_text = AsyncMock(
|
||||
return_value='{"core_keywords": ["test"], "synonyms": {}}'
|
||||
)
|
||||
return ollama
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_extractor():
|
||||
extractor = MagicMock()
|
||||
extractor.extract_batch = AsyncMock(return_value=[])
|
||||
return extractor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(settings, vector_service, graph_service, searxng_client, ollama_client, content_extractor):
|
||||
return HybridRAGService(
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
searxng_client=searxng_client,
|
||||
ollama_client=ollama_client,
|
||||
content_extractor=content_extractor,
|
||||
settings=settings,
|
||||
volatile_service=None,
|
||||
)
|
||||
|
||||
|
||||
def make_config(**overrides):
|
||||
"""Config with documents disabled (leg needs a real Qdrant client) and fast phases."""
|
||||
defaults = {
|
||||
"enable_documents": False,
|
||||
"enable_volatile": False,
|
||||
"enable_reranking": False,
|
||||
"enable_enrichment": False,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return HybridRAGConfig(**defaults)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSourceStatus:
|
||||
"""source_status must report every leg as ok/failed/disabled."""
|
||||
|
||||
async def test_all_enabled_legs_ok(self, service):
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
|
||||
assert response.source_status == {
|
||||
"vector": "ok",
|
||||
"graph": "ok",
|
||||
"web": "ok",
|
||||
"volatile": "disabled",
|
||||
"documents": "disabled",
|
||||
}
|
||||
assert response.degraded is False
|
||||
|
||||
async def test_failed_leg_reported_and_degraded(self, service, graph_service):
|
||||
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
|
||||
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
|
||||
assert response.source_status["graph"] == "failed"
|
||||
assert response.source_status["vector"] == "ok"
|
||||
assert response.degraded is True
|
||||
|
||||
async def test_failed_leg_still_contributes_no_results(self, service, graph_service):
|
||||
"""Existing behavior preserved: failure -> empty leg, other legs still work."""
|
||||
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
|
||||
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
|
||||
# The vector hit still comes through
|
||||
assert response.total_results == 1
|
||||
assert response.results[0].page_id == 1
|
||||
|
||||
async def test_multiple_failures(self, service, graph_service, searxng_client):
|
||||
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("neo4j down"))
|
||||
searxng_client.search_general = AsyncMock(side_effect=OSError("searxng unreachable"))
|
||||
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
|
||||
assert response.source_status["graph"] == "failed"
|
||||
assert response.source_status["web"] == "failed"
|
||||
assert response.degraded is True
|
||||
|
||||
async def test_disabled_legs_do_not_degrade(self, service):
|
||||
config = make_config(enable_graph=False, enable_web=False)
|
||||
|
||||
response = await service.search("test query", "jp", config)
|
||||
|
||||
assert response.source_status["graph"] == "disabled"
|
||||
assert response.source_status["web"] == "disabled"
|
||||
assert response.degraded is False
|
||||
|
||||
async def test_volatile_without_service_is_disabled(self, service):
|
||||
"""enable_volatile=True but no volatile service wired -> disabled, not failed."""
|
||||
response = await service.search("test query", "jp", make_config(enable_volatile=True))
|
||||
|
||||
assert response.source_status["volatile"] == "disabled"
|
||||
assert response.degraded is False
|
||||
|
||||
async def test_status_fields_serialized(self, service, graph_service):
|
||||
"""Contract: fields present in the serialized response for tatlock to parse."""
|
||||
graph_service.search_documents = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
payload = response.model_dump()
|
||||
|
||||
assert set(payload["source_status"].keys()) == {"vector", "graph", "web", "volatile", "documents"}
|
||||
assert payload["degraded"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestModelResolution:
|
||||
"""The reranker/keyword model must come from settings.ollama_llm_model."""
|
||||
|
||||
def test_reranker_model_from_llm_setting(self, service):
|
||||
assert service.reranker_model == "gemma4:e2b"
|
||||
|
||||
async def test_generation_calls_use_llm_model(self, service, ollama_client):
|
||||
await service.search("test query", "jp", make_config())
|
||||
|
||||
# Phase 0 keyword extraction ran with the generation model
|
||||
assert ollama_client.generate_text.await_count >= 1
|
||||
for call in ollama_client.generate_text.await_args_list:
|
||||
assert call.kwargs["model"] == "gemma4:e2b"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLLMTimeout:
|
||||
"""A hung LLM call must not gate retrieval: 12s wait_for with fallback."""
|
||||
|
||||
async def test_keyword_extraction_timeout_falls_back(self, service, ollama_client, monkeypatch):
|
||||
monkeypatch.setattr(hybrid_rag_module, "LLM_CALL_TIMEOUT_SECONDS", 0.05)
|
||||
|
||||
async def hang(*args, **kwargs):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
ollama_client.generate_text = AsyncMock(side_effect=hang)
|
||||
|
||||
response = await service.search("test query", "jp", make_config())
|
||||
|
||||
# Fallback: raw query words as keywords, retrieval still ran
|
||||
assert response.keywords.core_keywords == ["test", "query"]
|
||||
assert response.source_status["vector"] == "ok"
|
||||
|
||||
async def test_rerank_timeout_keeps_rrf_order(self, service, ollama_client, monkeypatch, searxng_client, content_extractor):
|
||||
monkeypatch.setattr(hybrid_rag_module, "LLM_CALL_TIMEOUT_SECONDS", 0.05)
|
||||
|
||||
# Two web results so re-ranking actually runs (needs > 1 result)
|
||||
searxng_client.search_general = AsyncMock(return_value=[
|
||||
{"url": "http://a.test", "title": "A", "content": "a"},
|
||||
{"url": "http://b.test", "title": "B", "content": "b"},
|
||||
])
|
||||
|
||||
keyword_json = '{"core_keywords": ["test"], "synonyms": {}}'
|
||||
|
||||
async def generate(prompt, **kwargs):
|
||||
if "Rank these documents" in prompt:
|
||||
await asyncio.sleep(5) # Hang only the re-rank call
|
||||
return keyword_json
|
||||
|
||||
ollama_client.generate_text = AsyncMock(side_effect=generate)
|
||||
|
||||
response = await service.search("test query", "jp", make_config(enable_reranking=True))
|
||||
|
||||
# RRF order preserved despite the hung re-rank call
|
||||
assert response.total_results >= 2
|
||||
assert response.degraded is False
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Offline unit tests for the implemented /ingest and /deduplicate endpoints.
|
||||
|
||||
Covers (Phase C item 1):
|
||||
- /ingest/check-updates: content-hash comparison classification
|
||||
- /ingest/status/{job_id}: Redis job-manager backing + tenant scoping
|
||||
- /ingest/repo-status/{repository}: wiki vs graph counts + job stats
|
||||
- /deduplicate/check: tenant-scoped similarity scan grouping
|
||||
|
||||
All external clients are mocked - no shared services are contacted.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.core.hashing import compute_content_hash
|
||||
from src.main import (
|
||||
check_updates,
|
||||
check_duplicates,
|
||||
get_ingestion_status,
|
||||
get_repo_status,
|
||||
CheckUpdatesRequest,
|
||||
DeduplicateCheckRequest,
|
||||
)
|
||||
from src.services.vector_service import VectorService
|
||||
|
||||
TEST_USER = "llm_tester"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest/check-updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckUpdates:
|
||||
@pytest.mark.asyncio
|
||||
async def test_classifies_changed_new_and_deleted(self):
|
||||
"""Hash mismatch -> changed, no node -> new, stale node -> deleted."""
|
||||
wikijs = AsyncMock()
|
||||
wikijs.list_all_pages = AsyncMock(return_value=[
|
||||
{"id": 1, "path": f"users/{TEST_USER}/a", "title": "A", "tags": []},
|
||||
{"id": 2, "path": f"users/{TEST_USER}/b", "title": "B", "tags": []},
|
||||
])
|
||||
wikijs.get_page = AsyncMock(side_effect=[
|
||||
{"id": 1, "content": "content-a"},
|
||||
{"id": 2, "content": "content-b"},
|
||||
])
|
||||
|
||||
neo4j = AsyncMock()
|
||||
|
||||
async def fake_query(cypher, params):
|
||||
# The endpoint sends one UNWIND query with page hashes
|
||||
pages = params["pages"]
|
||||
assert pages[0]["hash"] == compute_content_hash("content-a")
|
||||
return [{
|
||||
"checked": [
|
||||
{ # page 1: node exists, hash matches -> up to date
|
||||
"page_id": 1, "path": pages[0]["path"], "title": "A",
|
||||
"is_new": False, "changed": False,
|
||||
"stored_hash_missing": False,
|
||||
},
|
||||
{ # page 2: node exists, hash differs -> changed
|
||||
"page_id": 2, "path": pages[1]["path"], "title": "B",
|
||||
"is_new": False, "changed": True,
|
||||
"stored_hash_missing": False,
|
||||
},
|
||||
],
|
||||
"deleted": [
|
||||
{"page_id": 99, "path": f"users/{TEST_USER}/gone", "title": "Gone"},
|
||||
],
|
||||
}]
|
||||
|
||||
neo4j.execute_query = AsyncMock(side_effect=fake_query)
|
||||
|
||||
result = await check_updates(
|
||||
request=CheckUpdatesRequest(user=TEST_USER),
|
||||
neo4j=neo4j,
|
||||
wikijs=wikijs,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result["counts"] == {
|
||||
"changed": 1, "new": 0, "deleted": 1, "up_to_date": 1
|
||||
}
|
||||
assert result["changed"][0]["page_id"] == 2
|
||||
assert result["deleted"][0]["page_id"] == 99
|
||||
assert result["duration_ms"] >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_page_detected_and_stub_pages_excluded(self):
|
||||
"""Pages without Document nodes are new; entity stubs are skipped."""
|
||||
wikijs = AsyncMock()
|
||||
wikijs.list_all_pages = AsyncMock(return_value=[
|
||||
{"id": 5, "path": f"users/{TEST_USER}/fresh", "title": "Fresh", "tags": []},
|
||||
{"id": 6, "path": f"users/{TEST_USER}/stub", "title": "Stub",
|
||||
"tags": ["entity-stub"]},
|
||||
])
|
||||
wikijs.get_page = AsyncMock(return_value={"id": 5, "content": "x"})
|
||||
|
||||
neo4j = AsyncMock()
|
||||
neo4j.execute_query = AsyncMock(return_value=[{
|
||||
"checked": [{
|
||||
"page_id": 5, "path": f"users/{TEST_USER}/fresh", "title": "Fresh",
|
||||
"is_new": True, "changed": False, "stored_hash_missing": False,
|
||||
}],
|
||||
"deleted": [],
|
||||
}])
|
||||
|
||||
result = await check_updates(
|
||||
request=CheckUpdatesRequest(user=TEST_USER),
|
||||
neo4j=neo4j,
|
||||
wikijs=wikijs,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
# Only page 5 was hashed (stub excluded -> get_page called once)
|
||||
assert wikijs.get_page.await_count == 1
|
||||
assert result["counts"]["new"] == 1
|
||||
assert result["new"][0]["page_id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_wiki_reports_all_documents_deleted(self):
|
||||
"""With no wiki pages, every Document node is reported deleted."""
|
||||
wikijs = AsyncMock()
|
||||
wikijs.list_all_pages = AsyncMock(return_value=[])
|
||||
|
||||
neo4j = AsyncMock()
|
||||
neo4j.execute_query = AsyncMock(return_value=[{
|
||||
"deleted": [{"page_id": 7, "path": f"users/{TEST_USER}/x", "title": "X"}],
|
||||
}])
|
||||
|
||||
result = await check_updates(
|
||||
request=CheckUpdatesRequest(user=TEST_USER),
|
||||
neo4j=neo4j,
|
||||
wikijs=wikijs,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result["counts"] == {
|
||||
"changed": 0, "new": 0, "deleted": 1, "up_to_date": 0
|
||||
}
|
||||
|
||||
def test_requires_user(self):
|
||||
"""Empty user is rejected by the request model (Phase B rule)."""
|
||||
with pytest.raises(Exception):
|
||||
CheckUpdatesRequest(user=" ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest/status/{job_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestionStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_job_from_job_manager(self):
|
||||
job_manager = AsyncMock()
|
||||
job_manager.get_job = AsyncMock(return_value={
|
||||
"job_id": "abc", "user": TEST_USER, "status": "completed",
|
||||
})
|
||||
|
||||
job = await get_ingestion_status(
|
||||
job_id="abc", user=TEST_USER, job_manager=job_manager, api_key=""
|
||||
)
|
||||
assert job["status"] == "completed"
|
||||
job_manager.get_job.assert_awaited_once_with("abc")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_job_404(self):
|
||||
job_manager = AsyncMock()
|
||||
job_manager.get_job = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_ingestion_status(
|
||||
job_id="missing", user=TEST_USER, job_manager=job_manager, api_key=""
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_tenants_job_is_hidden(self):
|
||||
"""Tenant scoping: another user's job looks like a 404."""
|
||||
job_manager = AsyncMock()
|
||||
job_manager.get_job = AsyncMock(return_value={
|
||||
"job_id": "abc", "user": "someone_else", "status": "completed",
|
||||
})
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_ingestion_status(
|
||||
job_id="abc", user=TEST_USER, job_manager=job_manager, api_key=""
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest/repo-status/{repository}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRepoStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_indexed_vs_total(self):
|
||||
wikijs = AsyncMock()
|
||||
wikijs.list_all_pages = AsyncMock(return_value=[
|
||||
{"id": 1, "path": f"users/{TEST_USER}/tech/a"},
|
||||
{"id": 2, "path": f"users/{TEST_USER}/tech/b"},
|
||||
{"id": 3, "path": f"users/{TEST_USER}/tech/c"},
|
||||
])
|
||||
neo4j = AsyncMock()
|
||||
neo4j.execute_query = AsyncMock(return_value=[{"indexed": 2}])
|
||||
job_manager = AsyncMock()
|
||||
job_manager.get_job_stats = AsyncMock(return_value={"total": 4, "completed": 4})
|
||||
|
||||
result = await get_repo_status(
|
||||
repository="tech", user=TEST_USER,
|
||||
neo4j=neo4j, wikijs=wikijs, job_manager=job_manager, api_key=""
|
||||
)
|
||||
|
||||
assert result["total_documents"] == 3
|
||||
assert result["indexed_documents"] == 2
|
||||
assert result["unindexed_documents"] == 1
|
||||
assert result["jobs"]["total"] == 4
|
||||
assert result["path_prefix"] == f"users/{TEST_USER}/tech"
|
||||
# Wiki listing was scoped to the tenant namespace
|
||||
wikijs.list_all_pages.assert_awaited_once_with(
|
||||
path_prefix=f"users/{TEST_USER}/tech"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /deduplicate/check + VectorService.find_duplicate_pairs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_vector_service(points, search_results_by_id):
|
||||
qdrant = AsyncMock()
|
||||
qdrant.collection_exists = AsyncMock(return_value=True)
|
||||
qdrant.scroll_all_points = AsyncMock(return_value=points)
|
||||
|
||||
async def fake_search(collection_name, query_vector, limit, score_threshold):
|
||||
# Route on the probe vector's marker value
|
||||
return search_results_by_id.get(query_vector[0], [])
|
||||
|
||||
qdrant.search_vectors = AsyncMock(side_effect=fake_search)
|
||||
return VectorService(qdrant, AsyncMock(), AsyncMock()), qdrant
|
||||
|
||||
|
||||
class TestDeduplicateCheck:
|
||||
@pytest.mark.asyncio
|
||||
async def test_groups_pairs_by_page_and_dedupes_directions(self):
|
||||
points = [
|
||||
{"id": "c1", "vector": [1.0],
|
||||
"payload": {"page_id": 10, "page_path": "users/llm_tester/a",
|
||||
"page_title": "A", "doc_type": "wiki"}},
|
||||
{"id": "c2", "vector": [2.0],
|
||||
"payload": {"page_id": 20, "page_path": "users/llm_tester/b",
|
||||
"page_title": "B", "doc_type": "wiki"}},
|
||||
]
|
||||
search_results = {
|
||||
1.0: [ # c1 finds c2 (cross-page) and itself (same page - ignored)
|
||||
{"id": "c1", "score": 1.0, "payload": points[0]["payload"]},
|
||||
{"id": "c2", "score": 0.95, "payload": points[1]["payload"]},
|
||||
],
|
||||
2.0: [ # c2 finds c1 - reverse direction must NOT double count
|
||||
{"id": "c1", "score": 0.95, "payload": points[0]["payload"]},
|
||||
],
|
||||
}
|
||||
service, _ = _make_vector_service(points, search_results)
|
||||
|
||||
scan = await service.find_duplicate_pairs(TEST_USER, similarity_threshold=0.9)
|
||||
|
||||
assert scan["chunks_scanned"] == 2
|
||||
assert len(scan["duplicate_groups"]) == 1
|
||||
group = scan["duplicate_groups"][0]
|
||||
assert group["max_similarity"] == 0.95
|
||||
assert group["matching_chunk_pairs"] == 1
|
||||
assert {p["page_id"] for p in group["pages"]} == {10, 20}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_collection_returns_empty(self):
|
||||
qdrant = AsyncMock()
|
||||
qdrant.collection_exists = AsyncMock(return_value=False)
|
||||
service = VectorService(qdrant, AsyncMock(), AsyncMock())
|
||||
|
||||
scan = await service.find_duplicate_pairs(TEST_USER)
|
||||
assert scan == {"chunks_scanned": 0, "duplicate_groups": []}
|
||||
qdrant.scroll_all_points.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_shape(self):
|
||||
service_qdrant = AsyncMock()
|
||||
service_qdrant.collection_exists = AsyncMock(return_value=False)
|
||||
|
||||
result = await check_duplicates(
|
||||
request=DeduplicateCheckRequest(user=TEST_USER),
|
||||
qdrant_client=service_qdrant,
|
||||
wiki_client=AsyncMock(),
|
||||
ollama_client=AsyncMock(),
|
||||
api_key="",
|
||||
)
|
||||
assert result["user"] == TEST_USER
|
||||
assert result["similarity_threshold"] == 0.9
|
||||
assert result["duplicate_groups"] == []
|
||||
assert result["duplicate_group_count"] == 0
|
||||
|
||||
def test_requires_user(self):
|
||||
with pytest.raises(Exception):
|
||||
DeduplicateCheckRequest(user="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# content hash canonicality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_content_hash_is_stable_and_none_safe():
|
||||
assert compute_content_hash("abc") == compute_content_hash("abc")
|
||||
assert compute_content_hash("abc") != compute_content_hash("abd")
|
||||
assert compute_content_hash(None) == compute_content_hash("")
|
||||
+39
-22
@@ -7,13 +7,22 @@ These tests require actual service connectivity:
|
||||
- SearXNG running at http://searxng:8080
|
||||
- Ollama running at http://ollama:11434
|
||||
|
||||
Run with: pytest tests/test_integration.py -v
|
||||
These tests run against the SHARED production services under the reserved
|
||||
test tenant only. They are gated behind the session tenant guard and the
|
||||
RUN_INTEGRATION_TESTS=1 environment flag (see tests/conftest.py) and are
|
||||
skipped otherwise.
|
||||
|
||||
Run with:
|
||||
RUN_INTEGRATION_TESTS=1 TEST_HOST=<shared-host> \
|
||||
.venv/bin/python -m pytest tests/test_integration.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from tests.conftest import TEST_TENANT, assert_safe_test_tenant
|
||||
|
||||
from src.clients.neo4j_client import Neo4jClient
|
||||
from src.clients.qdrant_client import QdrantClientWrapper
|
||||
from src.clients.wikijs_client import WikiJSClient
|
||||
@@ -22,6 +31,9 @@ from src.clients.ollama_client import OllamaClient
|
||||
from src.jobs.job_manager import JobManager, JobType, JobStatus
|
||||
from src.config import get_settings
|
||||
|
||||
# Only run when the tenant guard passes and integration mode is enabled.
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings():
|
||||
@@ -30,10 +42,10 @@ def settings():
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
|
||||
"""Get connected Neo4j client."""
|
||||
client = Neo4jClient(
|
||||
uri=settings.neo4j_uri,
|
||||
uri=neo4j_test_uri,
|
||||
user=settings.neo4j_user,
|
||||
password=settings.neo4j_password
|
||||
)
|
||||
@@ -43,45 +55,45 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
||||
def qdrant_client(settings, qdrant_test_url) -> QdrantClientWrapper:
|
||||
"""Get Qdrant client."""
|
||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
||||
return QdrantClientWrapper(url=qdrant_test_url)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wikijs_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
||||
async def wikijs_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
|
||||
"""Get Wiki.js client."""
|
||||
client = WikiJSClient(
|
||||
base_url=settings.wikijs_url,
|
||||
api_key=settings.wikijs_api_key
|
||||
base_url=wikijs_test_config["base_url"],
|
||||
api_token=wikijs_test_config["api_token"]
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def searxng_client(settings) -> AsyncGenerator[SearXNGClient, None]:
|
||||
async def searxng_client(searxng_test_url) -> AsyncGenerator[SearXNGClient, None]:
|
||||
"""Get SearXNG client."""
|
||||
client = SearXNGClient(base_url=settings.searxng_url)
|
||||
client = SearXNGClient(base_url=searxng_test_url)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ollama_client(settings) -> AsyncGenerator[OllamaClient, None]:
|
||||
"""Get Ollama client."""
|
||||
async def ollama_client(ollama_test_config) -> AsyncGenerator[OllamaClient, None]:
|
||||
"""Get Ollama client for embeddings."""
|
||||
client = OllamaClient(
|
||||
base_url=settings.ollama_url,
|
||||
model=settings.ollama_model
|
||||
base_url=ollama_test_config["base_url"],
|
||||
model=ollama_test_config["model"]
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def job_manager(settings) -> AsyncGenerator[JobManager, None]:
|
||||
async def job_manager(redis_test_url) -> AsyncGenerator[JobManager, None]:
|
||||
"""Get job manager."""
|
||||
manager = JobManager(redis_url=settings.redis_url)
|
||||
manager = JobManager(redis_url=redis_test_url)
|
||||
await manager.connect()
|
||||
yield manager
|
||||
await manager.close()
|
||||
@@ -100,6 +112,7 @@ class TestNeo4jIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_document(self, neo4j_client, test_user):
|
||||
"""Test creating and retrieving a document node."""
|
||||
assert_safe_test_tenant(test_user)
|
||||
doc_id = "test_doc_integration"
|
||||
|
||||
# Create document
|
||||
@@ -131,10 +144,11 @@ class TestQdrantIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_creation(self, qdrant_client, test_user):
|
||||
"""Test creating a collection."""
|
||||
await qdrant_client.ensure_collection(test_user)
|
||||
|
||||
assert_safe_test_tenant(test_user)
|
||||
collection_name = qdrant_client.get_collection_name(test_user)
|
||||
collections = qdrant_client.client.get_collections()
|
||||
await qdrant_client.ensure_collection(collection_name)
|
||||
|
||||
collections = await qdrant_client.client.get_collections()
|
||||
collection_names = [c.name for c in collections.collections]
|
||||
|
||||
assert collection_name in collection_names
|
||||
@@ -142,7 +156,10 @@ class TestQdrantIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_search(self, qdrant_client, test_user):
|
||||
"""Test upserting and searching chunks."""
|
||||
await qdrant_client.ensure_collection(test_user)
|
||||
assert_safe_test_tenant(test_user)
|
||||
await qdrant_client.ensure_collection(
|
||||
qdrant_client.get_collection_name(test_user)
|
||||
)
|
||||
|
||||
# Create test chunks with 768-dimensional embeddings
|
||||
chunks = [
|
||||
@@ -190,7 +207,7 @@ class TestWikiJSIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_pages(self, wikijs_client):
|
||||
"""Test listing all pages with pagination support."""
|
||||
pages = await wikijs_client.list_all_pages(path_prefix="users/")
|
||||
pages = await wikijs_client.list_all_pages(path_prefix=f"users/{TEST_TENANT}")
|
||||
assert isinstance(pages, list)
|
||||
# Verify each page has expected fields
|
||||
for page in pages[:5]: # Check first 5
|
||||
@@ -201,7 +218,7 @@ class TestWikiJSIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_taxonomy_structure(self, wikijs_client):
|
||||
"""Test getting taxonomy structure for a user."""
|
||||
taxonomy = await wikijs_client.get_taxonomy_structure("users/jpmschweitzer")
|
||||
taxonomy = await wikijs_client.get_taxonomy_structure(f"users/{TEST_TENANT}")
|
||||
assert isinstance(taxonomy, dict)
|
||||
# Each key should be a category, value should be list of subcategories
|
||||
for category, subcategories in taxonomy.items():
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Offline unit tests for the nightly integrity-check endpoint (Phase C item 2).
|
||||
|
||||
All external clients are mocked - no shared services are contacted.
|
||||
The endpoint must be strictly read-only: these tests assert that no
|
||||
delete/purge/update methods are ever invoked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from src.routers.maintenance import (
|
||||
IntegrityCheckRequest,
|
||||
classify_collection,
|
||||
integrity_check,
|
||||
run_integrity_check,
|
||||
)
|
||||
|
||||
TEST_USER = "llm_tester"
|
||||
TENANT_PREFIX = f"users/{TEST_USER}"
|
||||
|
||||
|
||||
def _mock_clients(
|
||||
wiki_pages,
|
||||
chunk_refs,
|
||||
graph_docs,
|
||||
collections,
|
||||
):
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages = AsyncMock(return_value=wiki_pages)
|
||||
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references = AsyncMock(return_value=chunk_refs)
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.get_all_document_references = AsyncMock(return_value=graph_docs)
|
||||
|
||||
qdrant = AsyncMock()
|
||||
qdrant.list_collections = AsyncMock(return_value=collections)
|
||||
|
||||
return wiki_client, vector_service, graph_service, qdrant
|
||||
|
||||
|
||||
class TestRunIntegrityCheck:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_report(self):
|
||||
wiki_pages = [
|
||||
# tenant pages
|
||||
{"id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A"},
|
||||
{"id": 2, "path": f"{TENANT_PREFIX}/b", "title": "B"},
|
||||
# another tenant's page (defines a known tenant, out of scope here)
|
||||
{"id": 50, "path": "users/jpmschweitzer/x", "title": "X"},
|
||||
]
|
||||
chunk_refs = [
|
||||
# page 1 has vectors; page 2 has none (silent-skip victim)
|
||||
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"},
|
||||
# orphan: page 99 no longer exists in the wiki
|
||||
{"chunk_id": "c9", "page_id": 99, "doc_type": "wiki"},
|
||||
# non-wiki chunk is ignored by the wiki-side checks
|
||||
{"chunk_id": "cd", "document_id": "d1", "doc_type": "document"},
|
||||
]
|
||||
graph_docs = [
|
||||
{"page_id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A", "doc_type": "wiki"},
|
||||
# stale Document node: wiki page 77 is gone
|
||||
{"page_id": 77, "path": f"{TENANT_PREFIX}/old", "title": "Old", "doc_type": "wiki"},
|
||||
]
|
||||
collections = [
|
||||
{"name": "library_desk_jpmschweitzer", "vectors_count": 10},
|
||||
{"name": "library_desk_llm_tester", "vectors_count": 2},
|
||||
{"name": "library_desk_ghost_tenant", "vectors_count": 1},
|
||||
{"name": "open-webui_files", "vectors_count": 5},
|
||||
]
|
||||
|
||||
wiki_client, vector_service, graph_service, qdrant = _mock_clients(
|
||||
wiki_pages, chunk_refs, graph_docs, collections
|
||||
)
|
||||
|
||||
report = await run_integrity_check(
|
||||
TEST_USER, vector_service, graph_service, wiki_client, qdrant
|
||||
)
|
||||
|
||||
assert report.success is True
|
||||
assert report.user == TEST_USER
|
||||
|
||||
# Pages without vectors: page 2 only (page 1 has c1)
|
||||
assert [p["page_id"] for p in report.pages_without_vectors] == [2]
|
||||
|
||||
# Orphaned vectors: c9 -> page 99
|
||||
assert report.orphaned_vector_chunks == 1
|
||||
assert report.orphaned_vector_page_ids == [99]
|
||||
|
||||
# Collections: llm_tester is test residue, ghost_tenant unknown,
|
||||
# jpmschweitzer expected, open-webui foreign
|
||||
flagged = {c["name"]: c["category"] for c in report.unexpected_collections}
|
||||
assert flagged == {
|
||||
"library_desk_llm_tester": "test_residue",
|
||||
"library_desk_ghost_tenant": "unknown_tenant",
|
||||
}
|
||||
assert report.foreign_collections == 1
|
||||
|
||||
# Graph documents without wiki counterparts: page 77
|
||||
assert [d["page_id"] for d in report.documents_without_wiki] == [77]
|
||||
|
||||
assert report.counts["tenant_wiki_pages"] == 2
|
||||
assert report.counts["pages_without_vectors"] == 1
|
||||
assert report.counts["documents_without_wiki"] == 1
|
||||
assert report.duration_ms >= 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_no_mutations(self):
|
||||
"""The integrity check must never call any destructive method."""
|
||||
wiki_client, vector_service, graph_service, qdrant = _mock_clients(
|
||||
[], [], [], []
|
||||
)
|
||||
|
||||
await run_integrity_check(
|
||||
TEST_USER, vector_service, graph_service, wiki_client, qdrant
|
||||
)
|
||||
|
||||
for mock, destructive in (
|
||||
(vector_service, ("purge_chunks_by_ids", "delete_page_chunks")),
|
||||
(graph_service, ("purge_orphan_entities", "purge_stale_documents_by_ids",
|
||||
"delete_page", "cleanup_broken_relationships")),
|
||||
(qdrant, ("delete_collection", "delete_by_ids", "delete_by_filter")),
|
||||
(wiki_client, ("delete_page", "update_page", "create_page")),
|
||||
):
|
||||
for name in destructive:
|
||||
assert not getattr(mock, name).await_count, (
|
||||
f"integrity check must be read-only but called {name}"
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrityEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_caches_latest_report_in_redis(self):
|
||||
wiki_client, vector_service, graph_service, qdrant = _mock_clients(
|
||||
[{"id": 1, "path": f"{TENANT_PREFIX}/a", "title": "A"}],
|
||||
[{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
redis = AsyncMock()
|
||||
|
||||
report = await integrity_check(
|
||||
request=IntegrityCheckRequest(user=TEST_USER),
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
qdrant=qdrant,
|
||||
redis=redis,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert report.success is True
|
||||
redis.setex.assert_awaited_once()
|
||||
key = redis.setex.await_args.args[0]
|
||||
assert key == f"library:integrity:latest:{TEST_USER}"
|
||||
|
||||
def test_requires_user(self):
|
||||
with pytest.raises(Exception):
|
||||
IntegrityCheckRequest(user=" ")
|
||||
|
||||
|
||||
class TestClassifyCollection:
|
||||
def test_expected_tenant(self):
|
||||
assert classify_collection(
|
||||
"library_desk_jpmschweitzer", {"jpmschweitzer"}
|
||||
) == "expected"
|
||||
|
||||
def test_test_residue_beats_known_tenant(self):
|
||||
# Even if the test tenant has wiki pages during a run, its
|
||||
# collections are still flagged as residue.
|
||||
assert classify_collection(
|
||||
"library_desk_llm_tester", {"llm_tester"}
|
||||
) == "test_residue"
|
||||
|
||||
def test_unknown_tenant(self):
|
||||
assert classify_collection(
|
||||
"volatile_mystery", {"jpmschweitzer"}
|
||||
) == "unknown_tenant"
|
||||
|
||||
def test_foreign_and_foreign_residue(self):
|
||||
assert classify_collection("open-webui_files", set()) == "foreign"
|
||||
assert classify_collection("core_ai_user_test_at_example_com", set()) == \
|
||||
"foreign_test_residue"
|
||||
assert classify_collection("test_user", set()) == "foreign_test_residue"
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Offline unit tests for job + Scheduler task plumbing (Phase C item 5).
|
||||
|
||||
Covers:
|
||||
- job_cleanup_loop: invokes cleanup_expired_jobs per pass, survives
|
||||
transient errors, honors cancellation
|
||||
- register_scheduler_tasks.py: dry-run default, payload contents
|
||||
(explicit production user, auth placeholder, schedules)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.jobs.job_manager import job_cleanup_loop
|
||||
|
||||
|
||||
class TestJobCleanupLoop:
|
||||
@pytest.mark.asyncio
|
||||
async def test_invokes_cleanup_each_pass(self):
|
||||
manager = AsyncMock()
|
||||
|
||||
passes = await job_cleanup_loop(manager, interval_seconds=0, max_iterations=3)
|
||||
|
||||
assert passes == 3
|
||||
assert manager.cleanup_expired_jobs.await_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_error_does_not_kill_loop(self):
|
||||
manager = AsyncMock()
|
||||
manager.cleanup_expired_jobs = AsyncMock(
|
||||
side_effect=[RuntimeError("redis hiccup"), None]
|
||||
)
|
||||
|
||||
passes = await job_cleanup_loop(manager, interval_seconds=0, max_iterations=2)
|
||||
|
||||
assert passes == 2
|
||||
assert manager.cleanup_expired_jobs.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancellation_stops_loop(self):
|
||||
manager = AsyncMock()
|
||||
|
||||
task = asyncio.create_task(job_cleanup_loop(manager, interval_seconds=60))
|
||||
await asyncio.sleep(0) # let it start sleeping
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
class TestSchedulerTaskDefinitions:
|
||||
def _load_module(self):
|
||||
import importlib
|
||||
return importlib.import_module("scripts.register_scheduler_tasks")
|
||||
|
||||
def test_four_production_payloads_defined(self):
|
||||
mod = self._load_module()
|
||||
names = {t["task_name"] for t in mod.TASKS}
|
||||
assert names == {
|
||||
"library_integrity_check",
|
||||
"library_quality_report",
|
||||
"library_paperless_orphan_cleanup",
|
||||
}
|
||||
updates = {u["task_name"]: u["updates"] for u in mod.TASK_UPDATES}
|
||||
assert updates == {"test_example_task": {"enabled": False}}
|
||||
|
||||
def test_schedules(self):
|
||||
mod = self._load_module()
|
||||
by_name = {t["task_name"]: t for t in mod.TASKS}
|
||||
|
||||
integrity = by_name["library_integrity_check"]
|
||||
assert (integrity["hour"], integrity["minute"], integrity["day_of_week"]) == (4, 30, -1)
|
||||
|
||||
quality = by_name["library_quality_report"]
|
||||
# Sunday 03:00 (Scheduler: 0 = Monday .. 6 = Sunday)
|
||||
assert (quality["hour"], quality["minute"], quality["day_of_week"]) == (3, 0, 6)
|
||||
|
||||
paperless = by_name["library_paperless_orphan_cleanup"]
|
||||
assert (paperless["hour"], paperless["minute"], paperless["day_of_week"]) == (5, 0, -1)
|
||||
|
||||
def test_payloads_use_explicit_production_user_and_placeholder(self):
|
||||
mod = self._load_module()
|
||||
for task in mod.TASKS:
|
||||
config = task["config"]
|
||||
# Auth goes through the executor's auth block so the Scheduler
|
||||
# substitutes ${LIBRARY_API_KEY} from ITS environment at
|
||||
# execution time (plain headers are NOT substituted).
|
||||
assert config["auth"] == {
|
||||
"type": "bearer",
|
||||
"token": mod.API_KEY_PLACEHOLDER,
|
||||
}
|
||||
assert "Authorization" not in config.get("headers", {})
|
||||
# The executor sends config["payload"] as the JSON body ("body"
|
||||
# would be silently ignored)
|
||||
assert "body" not in config
|
||||
# Explicit production tenant in payload or query string (Phase B)
|
||||
payload_user = config.get("payload", {}).get("user")
|
||||
assert payload_user == "jpmschweitzer" or "user=jpmschweitzer" in config["url"]
|
||||
|
||||
def test_paperless_task_hits_existing_endpoint(self):
|
||||
mod = self._load_module()
|
||||
task = next(t for t in mod.TASKS
|
||||
if t["task_name"] == "library_paperless_orphan_cleanup")
|
||||
assert "/maintenance/cleanup/paperless" in task["config"]["url"]
|
||||
assert "dry_run=false" in task["config"]["url"]
|
||||
|
||||
def test_no_client_side_key_substitution(self):
|
||||
"""The raw API key must never be resolved client-side — that would
|
||||
store it hardcoded in the Scheduler's scheduled_tasks.config."""
|
||||
mod = self._load_module()
|
||||
assert not hasattr(mod, "substitute_api_key")
|
||||
for task in mod.TASKS:
|
||||
assert mod.API_KEY_PLACEHOLDER in task["config"]["auth"]["token"]
|
||||
|
||||
def test_dry_run_is_default_and_sends_nothing(self, capsys, monkeypatch):
|
||||
mod = self._load_module()
|
||||
monkeypatch.setattr("sys.argv", ["register_scheduler_tasks.py"])
|
||||
monkeypatch.setenv("SCHEDULER_URL", "http://scheduler.test:8090")
|
||||
|
||||
def _boom(*args, **kwargs): # any HTTP client construction = failure
|
||||
raise AssertionError("dry-run must not contact the Scheduler")
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "Client", _boom)
|
||||
|
||||
assert mod.main() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "DRY RUN" in out
|
||||
assert "library_integrity_check" in out
|
||||
assert mod.API_KEY_PLACEHOLDER in out # placeholder, never a real key
|
||||
|
||||
def test_execute_requires_scheduler_api_key(self, capsys, monkeypatch):
|
||||
"""--execute must refuse to run without SCHEDULER_API_KEY (the
|
||||
Scheduler's task endpoints are Bearer-guarded; without the key
|
||||
every probe 401s and registration silently fails)."""
|
||||
mod = self._load_module()
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["register_scheduler_tasks.py", "--execute"]
|
||||
)
|
||||
monkeypatch.setenv("SCHEDULER_URL", "http://scheduler.test:8090")
|
||||
monkeypatch.delenv("SCHEDULER_API_KEY", raising=False)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("must not contact the Scheduler without a key")
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "Client", _boom)
|
||||
|
||||
assert mod.main() == 1
|
||||
assert "SCHEDULER_API_KEY" in capsys.readouterr().out
|
||||
|
||||
def test_execute_sends_scheduler_bearer_auth(self, monkeypatch):
|
||||
"""The registrar's own HTTP client must carry
|
||||
Authorization: Bearer $SCHEDULER_API_KEY on every call."""
|
||||
import httpx as real_httpx
|
||||
|
||||
mod = self._load_module()
|
||||
seen = {"auth_headers": [], "paths": []}
|
||||
|
||||
def handler(request: real_httpx.Request) -> real_httpx.Response:
|
||||
seen["auth_headers"].append(request.headers.get("Authorization"))
|
||||
path = request.url.path
|
||||
seen["paths"].append(f"{request.method} {path}")
|
||||
if path == "/health":
|
||||
return real_httpx.Response(200, json={"status": "healthy"})
|
||||
if request.method == "GET" and path.startswith("/tasks/"):
|
||||
return real_httpx.Response(404) # not registered yet
|
||||
return real_httpx.Response(200, json={"ok": True})
|
||||
|
||||
real_client = real_httpx.Client
|
||||
|
||||
def client_factory(**kwargs):
|
||||
kwargs["transport"] = real_httpx.MockTransport(handler)
|
||||
return real_client(**kwargs)
|
||||
|
||||
monkeypatch.setattr(mod.httpx, "Client", client_factory)
|
||||
|
||||
assert mod.execute("http://scheduler.test:8090", "sched-key-123") == 0
|
||||
assert seen["auth_headers"], "no HTTP calls were made"
|
||||
assert all(h == "Bearer sched-key-123" for h in seen["auth_headers"])
|
||||
# All three tasks created (404 probe -> POST /tasks)
|
||||
assert seen["paths"].count("POST /tasks") == len(mod.TASKS)
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
Tests for maintenance router and cleanup functionality.
|
||||
|
||||
Tests cleanup of:
|
||||
- Orphan vector chunks
|
||||
- Orphan entities in graph
|
||||
- Stale document nodes
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from src.routers.maintenance import (
|
||||
cleanup_vectors,
|
||||
cleanup_graph,
|
||||
cleanup_all,
|
||||
maintenance_health,
|
||||
reindex_page,
|
||||
CleanupResult,
|
||||
VectorCleanupResponse,
|
||||
GraphCleanupResponse,
|
||||
FullCleanupResponse,
|
||||
HealthCheckResponse,
|
||||
ReindexResponse
|
||||
)
|
||||
|
||||
|
||||
class TestCleanupResult:
|
||||
"""Test CleanupResult model."""
|
||||
|
||||
def test_cleanup_result_defaults(self):
|
||||
"""Test CleanupResult with default values."""
|
||||
result = CleanupResult(duration_ms=100.0)
|
||||
assert result.orphans_found == 0
|
||||
assert result.orphans_purged == 0
|
||||
assert result.duration_ms == 100.0
|
||||
|
||||
def test_cleanup_result_with_values(self):
|
||||
"""Test CleanupResult with actual values."""
|
||||
result = CleanupResult(
|
||||
orphans_found=10,
|
||||
orphans_purged=8,
|
||||
duration_ms=250.5
|
||||
)
|
||||
assert result.orphans_found == 10
|
||||
assert result.orphans_purged == 8
|
||||
assert result.duration_ms == 250.5
|
||||
|
||||
|
||||
class TestVectorCleanupResponse:
|
||||
"""Test VectorCleanupResponse model."""
|
||||
|
||||
def test_vector_cleanup_response(self):
|
||||
"""Test VectorCleanupResponse structure."""
|
||||
response = VectorCleanupResponse(
|
||||
success=True,
|
||||
wiki_chunks=CleanupResult(orphans_found=5, orphans_purged=5, duration_ms=50),
|
||||
document_chunks=CleanupResult(orphans_found=3, orphans_purged=3, duration_ms=50),
|
||||
chunks_without_graph=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=50),
|
||||
total_chunks_scanned=100,
|
||||
total_orphans_purged=10,
|
||||
duration_ms=100
|
||||
)
|
||||
assert response.success is True
|
||||
assert response.wiki_chunks.orphans_found == 5
|
||||
assert response.document_chunks.orphans_found == 3
|
||||
assert response.chunks_without_graph.orphans_found == 2
|
||||
assert response.total_orphans_purged == 10
|
||||
|
||||
|
||||
class TestGraphCleanupResponse:
|
||||
"""Test GraphCleanupResponse model."""
|
||||
|
||||
def test_graph_cleanup_response(self):
|
||||
"""Test GraphCleanupResponse structure."""
|
||||
response = GraphCleanupResponse(
|
||||
success=True,
|
||||
orphan_entities=CleanupResult(orphans_found=10, orphans_purged=10, duration_ms=25),
|
||||
stale_wiki_documents=CleanupResult(orphans_found=2, orphans_purged=2, duration_ms=25),
|
||||
stale_store_documents=CleanupResult(orphans_found=0, orphans_purged=0, duration_ms=25),
|
||||
docs_without_vectors=CleanupResult(orphans_found=1, orphans_purged=1, duration_ms=25),
|
||||
broken_relationships_cleaned=5,
|
||||
duration_ms=100
|
||||
)
|
||||
assert response.success is True
|
||||
assert response.orphan_entities.orphans_found == 10
|
||||
assert response.docs_without_vectors.orphans_found == 1
|
||||
assert response.broken_relationships_cleaned == 5
|
||||
|
||||
|
||||
class TestHealthCheckResponse:
|
||||
"""Test HealthCheckResponse model."""
|
||||
|
||||
def test_health_check_healthy(self):
|
||||
"""Test healthy status."""
|
||||
response = HealthCheckResponse(
|
||||
status="healthy",
|
||||
orphan_vector_count=0,
|
||||
orphan_entity_count=0,
|
||||
stale_document_count=0
|
||||
)
|
||||
assert response.status == "healthy"
|
||||
assert response.recommendations == []
|
||||
|
||||
def test_health_check_degraded(self):
|
||||
"""Test degraded status with recommendations."""
|
||||
response = HealthCheckResponse(
|
||||
status="degraded",
|
||||
orphan_vector_count=15,
|
||||
orphan_entity_count=3,
|
||||
stale_document_count=0,
|
||||
recommendations=[
|
||||
"Found 15 orphan vector chunks. Consider running POST /maintenance/cleanup/vectors"
|
||||
]
|
||||
)
|
||||
assert response.status == "degraded"
|
||||
assert len(response.recommendations) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVectorCleanup:
|
||||
"""Test vector cleanup endpoint."""
|
||||
|
||||
async def test_cleanup_vectors_no_orphans(self):
|
||||
"""Test cleanup when no orphans exist."""
|
||||
# Mock services
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||
]
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.get_all_document_references.return_value = [
|
||||
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||
]
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||
|
||||
# Call cleanup
|
||||
result = await cleanup_vectors(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.wiki_chunks.orphans_found == 0
|
||||
assert result.chunks_without_graph.orphans_found == 0
|
||||
assert result.total_orphans_purged == 0
|
||||
|
||||
async def test_cleanup_vectors_with_orphans(self):
|
||||
"""Test cleanup when orphans exist."""
|
||||
# Mock services
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"},
|
||||
{"chunk_id": "c2", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||
{"chunk_id": "c3", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||
]
|
||||
vector_service.purge_chunks_by_ids.return_value = 2
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.get_all_document_references.return_value = [
|
||||
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||
]
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||
|
||||
# Call cleanup
|
||||
result = await cleanup_vectors(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.wiki_chunks.orphans_found == 2
|
||||
assert result.wiki_chunks.orphans_purged == 2
|
||||
assert result.total_orphans_purged == 2
|
||||
|
||||
async def test_cleanup_vectors_dry_run(self):
|
||||
"""Test cleanup dry run doesn't purge."""
|
||||
# Mock services
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": "c1", "page_id": 999, "doc_type": "wiki"}, # Orphan
|
||||
]
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.get_all_document_references.return_value = []
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = []
|
||||
|
||||
# Call cleanup in dry run mode
|
||||
result = await cleanup_vectors(
|
||||
user="testuser",
|
||||
dry_run=True,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.wiki_chunks.orphans_found == 1
|
||||
assert result.wiki_chunks.orphans_purged == 0 # Not purged due to dry run
|
||||
vector_service.purge_chunks_by_ids.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGraphCleanup:
|
||||
"""Test graph cleanup endpoint."""
|
||||
|
||||
async def test_cleanup_graph_no_orphans(self):
|
||||
"""Test cleanup when no orphans exist."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||
]
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = []
|
||||
graph_service.get_all_document_references.return_value = [
|
||||
{"page_id": 1, "doc_type": "wiki", "title": "Test"}
|
||||
]
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
graph_service.cleanup_broken_relationships.return_value = 0
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||
|
||||
result = await cleanup_graph(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.orphan_entities.orphans_found == 0
|
||||
assert result.stale_wiki_documents.orphans_found == 0
|
||||
assert result.docs_without_vectors.orphans_found == 0
|
||||
|
||||
async def test_cleanup_graph_with_orphan_entities(self):
|
||||
"""Test cleanup of orphan entities."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = []
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = [
|
||||
{"id": "e1", "name": "Orphan1", "type": "Person"},
|
||||
{"id": "e2", "name": "Orphan2", "type": "Technology"},
|
||||
]
|
||||
graph_service.purge_orphan_entities.return_value = 2
|
||||
graph_service.get_all_document_references.return_value = []
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
graph_service.cleanup_broken_relationships.return_value = 0
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = []
|
||||
|
||||
result = await cleanup_graph(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.orphan_entities.orphans_found == 2
|
||||
assert result.orphan_entities.orphans_purged == 2
|
||||
|
||||
async def test_cleanup_graph_with_stale_documents(self):
|
||||
"""Test cleanup of stale document nodes."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": "c1", "page_id": 1, "doc_type": "wiki"}
|
||||
]
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = []
|
||||
graph_service.get_all_document_references.return_value = [
|
||||
{"page_id": 1, "doc_type": "wiki", "title": "Exists"},
|
||||
{"page_id": 999, "doc_type": "wiki", "title": "Deleted"}, # Stale
|
||||
]
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
graph_service.purge_stale_documents_by_ids.return_value = 1
|
||||
graph_service.cleanup_broken_relationships.return_value = 0
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = [{"id": 1, "path": "test"}]
|
||||
|
||||
result = await cleanup_graph(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.stale_wiki_documents.orphans_found == 1
|
||||
assert result.stale_wiki_documents.orphans_purged == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestFullCleanup:
|
||||
"""Test full cleanup endpoint."""
|
||||
|
||||
async def test_full_cleanup(self):
|
||||
"""Test full cleanup runs both vector and graph cleanup."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = []
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = []
|
||||
graph_service.get_all_document_references.return_value = []
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
graph_service.cleanup_broken_relationships.return_value = 0
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = []
|
||||
|
||||
result = await cleanup_all(
|
||||
user="testuser",
|
||||
dry_run=False,
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.vector_cleanup.success is True
|
||||
assert result.graph_cleanup.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMaintenanceHealth:
|
||||
"""Test maintenance health endpoint."""
|
||||
|
||||
async def test_health_healthy(self):
|
||||
"""Test healthy status when no orphans."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = []
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = []
|
||||
graph_service.get_all_document_references.return_value = []
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = []
|
||||
|
||||
result = await maintenance_health(
|
||||
user="testuser",
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.status == "healthy"
|
||||
assert result.orphan_vector_count == 0
|
||||
assert result.orphan_entity_count == 0
|
||||
assert result.vectors_without_graph == 0
|
||||
assert result.docs_without_vectors == 0
|
||||
|
||||
async def test_health_degraded(self):
|
||||
"""Test degraded status with orphans."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.get_all_chunk_references.return_value = [
|
||||
{"chunk_id": f"c{i}", "page_id": 999, "doc_type": "wiki"}
|
||||
for i in range(15)
|
||||
]
|
||||
# find_chunks_without_graph_nodes is not async
|
||||
vector_service.find_chunks_without_graph_nodes = MagicMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.find_orphan_entities.return_value = [
|
||||
{"id": f"e{i}", "name": f"Entity{i}", "type": "Entity"}
|
||||
for i in range(3)
|
||||
]
|
||||
graph_service.get_all_document_references.return_value = []
|
||||
graph_service.find_documents_without_vectors.return_value = []
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
wiki_client.list_all_pages.return_value = []
|
||||
|
||||
result = await maintenance_health(
|
||||
user="testuser",
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.status == "degraded"
|
||||
assert result.orphan_vector_count == 15
|
||||
assert result.orphan_entity_count == 3
|
||||
assert len(result.recommendations) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestReindexPage:
|
||||
"""Test reindex page endpoint."""
|
||||
|
||||
async def test_reindex_success(self):
|
||||
"""Test successful page reindex."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.delete_page_chunks.return_value = 5
|
||||
vector_service.update_from_page.return_value = MagicMock(
|
||||
success=True,
|
||||
chunks_created=6,
|
||||
error_message=None
|
||||
)
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.delete_page.return_value = 1
|
||||
graph_service.update_from_page.return_value = MagicMock(
|
||||
success=True,
|
||||
error_message=None
|
||||
)
|
||||
|
||||
result = await reindex_page(
|
||||
page_id=123,
|
||||
user="testuser",
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.page_id == 123
|
||||
assert result.vectors_deleted == 5
|
||||
assert result.vectors_created == 6
|
||||
assert result.graph_updated is True
|
||||
|
||||
async def test_reindex_failure(self):
|
||||
"""Test reindex with failure."""
|
||||
vector_service = AsyncMock()
|
||||
vector_service.delete_page_chunks.return_value = 0
|
||||
vector_service.update_from_page.return_value = MagicMock(
|
||||
success=False,
|
||||
chunks_created=0,
|
||||
error_message="Page not found"
|
||||
)
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.delete_page.return_value = 0
|
||||
graph_service.update_from_page.return_value = MagicMock(
|
||||
success=False,
|
||||
error_message="Page not found"
|
||||
)
|
||||
|
||||
result = await reindex_page(
|
||||
page_id=999,
|
||||
user="testuser",
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
api_key="test"
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error == "Page not found"
|
||||
@@ -7,8 +7,8 @@ from src.core.multi_tenancy import (
|
||||
get_wikijs_namespace,
|
||||
get_neo4j_user_label,
|
||||
validate_user_id,
|
||||
validate_required_user,
|
||||
is_path_in_user_namespace,
|
||||
DEFAULT_USER
|
||||
)
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ class TestQdrantCollectionName:
|
||||
def test_email_user(self):
|
||||
assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com"
|
||||
|
||||
def test_default_user(self):
|
||||
assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}"
|
||||
def test_test_tenant(self):
|
||||
assert get_qdrant_collection_name("llm_tester") == "library_desk_llm_tester"
|
||||
|
||||
|
||||
class TestWikijsNamespace:
|
||||
@@ -99,6 +99,41 @@ class TestValidateUserId:
|
||||
assert validate_user_id("___") is False
|
||||
|
||||
|
||||
class TestValidateRequiredUser:
|
||||
"""Test the required-user validator (no default tenant)."""
|
||||
|
||||
def test_no_default_user_constant(self):
|
||||
"""The DEFAULT_USER escape hatch must not exist anymore."""
|
||||
import src.core.multi_tenancy as mt
|
||||
assert not hasattr(mt, "DEFAULT_USER")
|
||||
|
||||
def test_valid_user_returned(self):
|
||||
assert validate_required_user("llm_tester") == "llm_tester"
|
||||
|
||||
def test_valid_user_stripped(self):
|
||||
assert validate_required_user(" llm_tester ") == "llm_tester"
|
||||
|
||||
def test_empty_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
validate_required_user("")
|
||||
|
||||
def test_whitespace_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
validate_required_user(" ")
|
||||
|
||||
def test_none_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
validate_required_user(None)
|
||||
|
||||
def test_no_alphanumeric_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
validate_required_user("___")
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
validate_required_user("a" * 101)
|
||||
|
||||
|
||||
class TestPathInNamespace:
|
||||
"""Test path namespace checking."""
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Offline unit tests for the weekly quality-report endpoint (Phase C item 3).
|
||||
|
||||
All external clients are mocked - no shared services are contacted.
|
||||
Live verification against the local dev server runs in
|
||||
tests/test_quality_report_live.py (integration-marked).
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.routers.maintenance import (
|
||||
QualityReportRequest,
|
||||
_parse_wiki_timestamp,
|
||||
quality_report,
|
||||
)
|
||||
|
||||
TEST_USER = "llm_tester"
|
||||
TENANT_PREFIX = f"users/{TEST_USER}"
|
||||
|
||||
|
||||
def _iso(days_ago: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat()
|
||||
|
||||
|
||||
def _mock_stack(
|
||||
pages,
|
||||
hit_rows,
|
||||
duplicate_scan=None,
|
||||
cached_integrity=None,
|
||||
):
|
||||
vector_service = AsyncMock()
|
||||
vector_service.find_duplicate_pairs = AsyncMock(
|
||||
return_value=duplicate_scan or {"chunks_scanned": 0, "duplicate_groups": []}
|
||||
)
|
||||
# Used only when integrity runs inline
|
||||
vector_service.get_all_chunk_references = AsyncMock(return_value=[])
|
||||
|
||||
graph_service = AsyncMock()
|
||||
graph_service.neo4j = AsyncMock()
|
||||
graph_service.neo4j.execute_query = AsyncMock(return_value=hit_rows)
|
||||
graph_service.get_all_document_references = AsyncMock(return_value=[])
|
||||
|
||||
wiki_client = AsyncMock()
|
||||
# First call: tenant listing; later calls: report-path existence check
|
||||
wiki_client.list_all_pages = AsyncMock(side_effect=[pages, []])
|
||||
wiki_client.create_page = AsyncMock(return_value={"id": 777})
|
||||
|
||||
qdrant = AsyncMock()
|
||||
qdrant.list_collections = AsyncMock(return_value=[])
|
||||
|
||||
redis = AsyncMock()
|
||||
|
||||
async def _redis_get(key):
|
||||
if key.startswith("library:integrity:latest:") and cached_integrity:
|
||||
return json.dumps(cached_integrity)
|
||||
return None
|
||||
|
||||
redis.get = AsyncMock(side_effect=_redis_get)
|
||||
|
||||
return vector_service, graph_service, wiki_client, qdrant, redis
|
||||
|
||||
|
||||
class TestQualityReport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_report_flags_and_writes_page(self):
|
||||
pages = [
|
||||
# stale: 60 days old, 0 hits, missing tags+description
|
||||
{"id": 1, "path": f"{TENANT_PREFIX}/old-page", "title": "Old",
|
||||
"tags": [], "description": "", "updatedAt": _iso(60)},
|
||||
# old but frequently found -> NOT stale; has metadata
|
||||
{"id": 2, "path": f"{TENANT_PREFIX}/popular", "title": "Popular",
|
||||
"tags": ["x"], "description": "d", "updatedAt": _iso(60)},
|
||||
# fresh page missing description only
|
||||
{"id": 3, "path": f"{TENANT_PREFIX}/fresh", "title": "Fresh",
|
||||
"tags": ["y"], "description": "", "updatedAt": _iso(1)},
|
||||
# system report page is exempt from all checks
|
||||
{"id": 4, "path": f"{TENANT_PREFIX}/system/quality-reports/2026-07-07",
|
||||
"title": "Old report", "tags": [], "description": "", "updatedAt": _iso(7)},
|
||||
]
|
||||
hit_rows = [
|
||||
{"page_id": 1, "hits": 0},
|
||||
{"page_id": 2, "hits": 9},
|
||||
{"page_id": 3, "hits": 0},
|
||||
]
|
||||
duplicate_scan = {
|
||||
"chunks_scanned": 10,
|
||||
"duplicate_groups": [{
|
||||
"pages": [
|
||||
{"page_id": 1, "path": f"{TENANT_PREFIX}/old-page", "title": "Old"},
|
||||
{"page_id": 2, "path": f"{TENANT_PREFIX}/popular", "title": "Popular"},
|
||||
],
|
||||
"max_similarity": 0.93,
|
||||
"matching_chunk_pairs": 2,
|
||||
}],
|
||||
}
|
||||
cached_integrity = {
|
||||
"generated_at": _iso(0),
|
||||
"counts": {"pages_without_vectors": 1, "orphaned_vector_chunks": 0,
|
||||
"documents_without_wiki": 0, "unexpected_collections": 2},
|
||||
"unexpected_collections": [
|
||||
{"name": "library_desk_ghost", "category": "unknown_tenant"},
|
||||
],
|
||||
}
|
||||
|
||||
vector_service, graph_service, wiki_client, qdrant, redis = _mock_stack(
|
||||
pages, hit_rows, duplicate_scan, cached_integrity
|
||||
)
|
||||
|
||||
result = await quality_report(
|
||||
request=QualityReportRequest(user=TEST_USER, stale_days=30),
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
qdrant=qdrant,
|
||||
redis=redis,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
# Stale: only page 1 (page 2 old but popular, page 3 fresh, page 4 exempt)
|
||||
assert [p["page_id"] for p in result.stale_pages] == [1]
|
||||
|
||||
# Missing metadata: page 1 (tags+description), page 3 (description)
|
||||
missing = {p["page_id"]: p["missing"] for p in result.pages_missing_metadata}
|
||||
assert missing == {1: ["tags", "description"], 3: ["description"]}
|
||||
|
||||
# Dedup folded in
|
||||
assert result.counts["duplicate_groups"] == 1
|
||||
|
||||
# Cached integrity used (no inline re-run needed)
|
||||
assert result.integrity["source"] == "cached"
|
||||
vector_service.get_all_chunk_references.assert_not_awaited()
|
||||
|
||||
# Page written under the dated report path
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
assert result.page_path == f"{TENANT_PREFIX}/system/quality-reports/{today}"
|
||||
assert result.page_id == 777
|
||||
wiki_client.create_page.assert_awaited_once()
|
||||
create_kwargs = wiki_client.create_page.await_args.kwargs
|
||||
assert create_kwargs["path"] == result.page_path
|
||||
assert "auto-generated" in create_kwargs["tags"]
|
||||
|
||||
# Report content mentions the key findings
|
||||
assert "old-page" in result.report
|
||||
assert "0.930" in result.report
|
||||
assert "library_desk_ghost" in result.report
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_integrity_inline_when_no_cache(self):
|
||||
vector_service, graph_service, wiki_client, qdrant, redis = _mock_stack(
|
||||
pages=[], hit_rows=[], cached_integrity=None
|
||||
)
|
||||
# Inline integrity re-lists all pages: give the side_effect one more value
|
||||
wiki_client.list_all_pages = AsyncMock(side_effect=[[], [], []])
|
||||
|
||||
result = await quality_report(
|
||||
request=QualityReportRequest(user=TEST_USER),
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
qdrant=qdrant,
|
||||
redis=redis,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result.integrity["source"] == "inline"
|
||||
vector_service.get_all_chunk_references.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_page_false_skips_wiki_write(self):
|
||||
vector_service, graph_service, wiki_client, qdrant, redis = _mock_stack(
|
||||
pages=[], hit_rows=[],
|
||||
cached_integrity={"generated_at": _iso(0), "counts": {},
|
||||
"unexpected_collections": []},
|
||||
)
|
||||
|
||||
result = await quality_report(
|
||||
request=QualityReportRequest(user=TEST_USER, write_page=False),
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
qdrant=qdrant,
|
||||
redis=redis,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result.page_path is None
|
||||
assert result.page_id is None
|
||||
wiki_client.create_page.assert_not_awaited()
|
||||
wiki_client.update_page.assert_not_awaited()
|
||||
assert result.report # content still returned
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_day_rerun_updates_existing_page(self):
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
report_path = f"{TENANT_PREFIX}/system/quality-reports/{today}"
|
||||
|
||||
vector_service, graph_service, wiki_client, qdrant, redis = _mock_stack(
|
||||
pages=[], hit_rows=[],
|
||||
cached_integrity={"generated_at": _iso(0), "counts": {},
|
||||
"unexpected_collections": []},
|
||||
)
|
||||
wiki_client.list_all_pages = AsyncMock(side_effect=[
|
||||
[], # tenant listing
|
||||
[{"id": 555, "path": report_path, "title": f"Quality Report {today}"}],
|
||||
])
|
||||
|
||||
result = await quality_report(
|
||||
request=QualityReportRequest(user=TEST_USER),
|
||||
vector_service=vector_service,
|
||||
graph_service=graph_service,
|
||||
wiki_client=wiki_client,
|
||||
qdrant=qdrant,
|
||||
redis=redis,
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert result.page_id == 555
|
||||
wiki_client.update_page.assert_awaited_once()
|
||||
wiki_client.create_page.assert_not_awaited()
|
||||
|
||||
def test_requires_user(self):
|
||||
with pytest.raises(Exception):
|
||||
QualityReportRequest(user="")
|
||||
|
||||
|
||||
def test_parse_wiki_timestamp():
|
||||
parsed = _parse_wiki_timestamp("2026-07-14T09:39:45.244Z")
|
||||
assert parsed is not None and parsed.tzinfo is not None
|
||||
assert _parse_wiki_timestamp(None) is None
|
||||
assert _parse_wiki_timestamp("not-a-date") is None
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Live end-to-end test for the weekly quality report (integration, guard-gated).
|
||||
|
||||
Runs against the LOCAL wakeup server (./wakeup.sh, port 8778 — never the
|
||||
production container on 8089) with the shared backing services, entirely
|
||||
under the reserved llm_tester tenant namespace:
|
||||
|
||||
1. Create + ingest a wiki page as ``llm_tester`` (deliberately without tags
|
||||
so the report has something to flag).
|
||||
2. POST /maintenance/quality-report {user: llm_tester}: asserts the report
|
||||
is generated, mentions the created page in the missing-metadata section,
|
||||
folds in integrity results, and writes the dated report page under
|
||||
users/llm_tester/system/quality-reports/YYYY-MM-DD.
|
||||
3. Fetches the written report page back from the wiki.
|
||||
4. Teardown deletes the created pages; the session-scoped teardown in
|
||||
conftest purges every remaining llm_tester artifact.
|
||||
|
||||
Run with:
|
||||
RUN_INTEGRATION_TESTS=1 TEST_HOST=<shared-host> \\
|
||||
LIBRARY_DESK_URL=http://localhost:8778 \\
|
||||
.venv/bin/python -m pytest tests/test_quality_report_live.py -v
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.conftest import (
|
||||
LIBRARY_DESK_URL,
|
||||
PRODUCTION_TENANT,
|
||||
TEST_TENANT,
|
||||
assert_safe_test_tenant,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def api():
|
||||
"""HTTP client for the local dev server, with bearer auth."""
|
||||
from src.config import get_settings
|
||||
|
||||
assert ":8089" not in LIBRARY_DESK_URL, (
|
||||
"Refusing to run the live quality-report test against the "
|
||||
"production container (port 8089); point LIBRARY_DESK_URL at ./wakeup.sh"
|
||||
)
|
||||
settings = get_settings()
|
||||
client = httpx.Client(
|
||||
base_url=LIBRARY_DESK_URL,
|
||||
headers={"Authorization": f"Bearer {settings.library_api_key}"},
|
||||
timeout=httpx.Timeout(300.0, connect=10.0),
|
||||
)
|
||||
yield client
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ingested_page(api):
|
||||
"""Create + ingest a metadata-poor wiki page as the test tenant."""
|
||||
assert_safe_test_tenant(TEST_TENANT)
|
||||
|
||||
slug = f"quality-probe-{uuid.uuid4().hex[:8]}"
|
||||
create_resp = api.post(
|
||||
"/wiki/pages",
|
||||
json={
|
||||
"title": f"Quality Probe {slug}",
|
||||
"path": f"/quality-tests/{slug}",
|
||||
"content": (
|
||||
"# Quality Probe\n\n"
|
||||
"Ephemeral page used to verify the weekly quality report. "
|
||||
"It intentionally has no tags so the report flags it."
|
||||
),
|
||||
"description": "",
|
||||
"tags": [],
|
||||
"user": TEST_TENANT,
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
page = create_resp.json()
|
||||
page_id = page["id"]
|
||||
assert page["path"].lstrip("/").startswith(f"users/{TEST_TENANT}")
|
||||
|
||||
ingest_resp = api.post(
|
||||
"/ingest/page",
|
||||
json={"page_id": page_id, "user": TEST_TENANT, "force_refresh": True},
|
||||
)
|
||||
assert ingest_resp.status_code == 200, ingest_resp.text
|
||||
|
||||
# Wiki.js updates its page-listing index asynchronously after creation;
|
||||
# the quality report relies on that listing, so wait until the new page
|
||||
# is visible (up to ~30s) before running the report.
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
listing = api.get("/wiki/pages", params={"user": TEST_TENANT})
|
||||
assert listing.status_code == 200, listing.text
|
||||
if any(p["id"] == page_id for p in listing.json().get("pages", [])):
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
pytest.fail(f"Page {page_id} never appeared in the Wiki.js listing")
|
||||
|
||||
yield {"page_id": page_id, "path": page["path"]}
|
||||
|
||||
delete_resp = api.delete(f"/wiki/pages/{page_id}", params={"user": TEST_TENANT})
|
||||
assert delete_resp.status_code == 200, delete_resp.text
|
||||
|
||||
|
||||
class TestLiveQualityReport:
|
||||
def test_quality_report_end_to_end(self, api, ingested_page):
|
||||
assert_safe_test_tenant(TEST_TENANT)
|
||||
|
||||
resp = api.post(
|
||||
"/maintenance/quality-report",
|
||||
json={"user": TEST_TENANT, "stale_days": 30},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
|
||||
assert data["success"] is True
|
||||
assert data["user"] == TEST_TENANT
|
||||
assert data["duration_ms"] > 0
|
||||
|
||||
# The dated report page was written inside the tenant's namespace
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
expected_path = f"users/{TEST_TENANT}/system/quality-reports/{today}"
|
||||
assert data["page_path"] == expected_path
|
||||
assert data["page_id"]
|
||||
assert PRODUCTION_TENANT not in data["page_path"]
|
||||
|
||||
# The metadata-poor probe page is flagged
|
||||
flagged_ids = {p["page_id"] for p in data["pages_missing_metadata"]}
|
||||
assert ingested_page["page_id"] in flagged_ids
|
||||
|
||||
# Integrity results are folded in (cached or inline)
|
||||
assert data["integrity"] is not None
|
||||
assert data["integrity"]["source"] in ("cached", "inline")
|
||||
assert "counts" in data["integrity"]
|
||||
|
||||
# Report content includes the summary + the probe page path
|
||||
assert "## Summary" in data["report"]
|
||||
assert ingested_page["path"].lstrip("/") in data["report"]
|
||||
|
||||
# The written page is retrievable from the wiki via the API
|
||||
page_resp = api.get(
|
||||
f"/wiki/pages/{data['page_id']}", params={"user": TEST_TENANT}
|
||||
)
|
||||
assert page_resp.status_code == 200, page_resp.text
|
||||
page = page_resp.json()
|
||||
assert page["path"].lstrip("/") == expected_path
|
||||
assert "## Summary" in page["content"]
|
||||
|
||||
# Same-day rerun must update the same page, not create a duplicate
|
||||
rerun = api.post(
|
||||
"/maintenance/quality-report",
|
||||
json={"user": TEST_TENANT, "stale_days": 30},
|
||||
)
|
||||
assert rerun.status_code == 200, rerun.text
|
||||
assert rerun.json()["page_id"] == data["page_id"]
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Tests for RAG search service and endpoints."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.models.rag_search import (
|
||||
SearchType,
|
||||
RAGSearchRequest,
|
||||
RAGSearchResult,
|
||||
RAGSearchResponse,
|
||||
)
|
||||
from src.services.rag_search_service import RAGSearchService, extract_domain
|
||||
|
||||
|
||||
class TestExtractDomain:
|
||||
"""Tests for domain extraction utility."""
|
||||
|
||||
def test_extract_simple_domain(self):
|
||||
"""Test extracting domain from simple URL."""
|
||||
assert extract_domain("https://example.com/page") == "example.com"
|
||||
|
||||
def test_extract_domain_with_www(self):
|
||||
"""Test extracting domain removes www prefix."""
|
||||
assert extract_domain("https://www.example.com/page") == "example.com"
|
||||
|
||||
def test_extract_domain_with_subdomain(self):
|
||||
"""Test extracting domain preserves subdomains."""
|
||||
assert extract_domain("https://blog.example.com/post") == "blog.example.com"
|
||||
|
||||
def test_extract_domain_invalid_url(self):
|
||||
"""Test extracting domain from invalid URL returns empty string."""
|
||||
# urlparse returns empty netloc for invalid URLs
|
||||
assert extract_domain("not-a-url") == ""
|
||||
|
||||
|
||||
class TestRAGSearchModels:
|
||||
"""Tests for RAG search Pydantic models."""
|
||||
|
||||
def test_search_request_defaults(self):
|
||||
"""Test RAGSearchRequest defaults (user is required, no default tenant)."""
|
||||
request = RAGSearchRequest(query="test query", user="llm_tester")
|
||||
|
||||
assert request.query == "test query"
|
||||
assert request.search_type == SearchType.WEB
|
||||
assert request.limit == 10
|
||||
assert request.user == "llm_tester"
|
||||
|
||||
def test_search_request_requires_user(self):
|
||||
"""A request without an explicit user must be rejected."""
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
RAGSearchRequest(query="test query")
|
||||
|
||||
def test_search_request_custom_values(self):
|
||||
"""Test RAGSearchRequest with custom values."""
|
||||
request = RAGSearchRequest(
|
||||
query="news about AI",
|
||||
search_type=SearchType.NEWS,
|
||||
limit=5,
|
||||
user="custom_user"
|
||||
)
|
||||
|
||||
assert request.query == "news about AI"
|
||||
assert request.search_type == SearchType.NEWS
|
||||
assert request.limit == 5
|
||||
assert request.user == "custom_user"
|
||||
|
||||
def test_search_result(self):
|
||||
"""Test RAGSearchResult model."""
|
||||
result = RAGSearchResult(
|
||||
title="Test Article",
|
||||
url="https://example.com/article",
|
||||
content="Full article content",
|
||||
snippet="Article snippet...",
|
||||
source="example.com",
|
||||
published_date="2024-01-15"
|
||||
)
|
||||
|
||||
assert result.title == "Test Article"
|
||||
assert result.source == "example.com"
|
||||
assert result.published_date == "2024-01-15"
|
||||
|
||||
def test_search_response(self):
|
||||
"""Test RAGSearchResponse model."""
|
||||
response = RAGSearchResponse(
|
||||
query="test",
|
||||
search_type=SearchType.WEB,
|
||||
results=[],
|
||||
total_results=0,
|
||||
search_time_ms=100,
|
||||
sources_summary=""
|
||||
)
|
||||
|
||||
assert response.query == "test"
|
||||
assert response.total_results == 0
|
||||
assert response.search_time_ms == 100
|
||||
|
||||
|
||||
class TestRAGSearchService:
|
||||
"""Tests for RAGSearchService."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_searxng_client(self):
|
||||
"""Create mock SearXNG client."""
|
||||
client = MagicMock()
|
||||
client.search_general = AsyncMock(return_value=[
|
||||
{
|
||||
"title": "Test Result 1",
|
||||
"url": "https://example.com/1",
|
||||
"content": "Snippet 1",
|
||||
"publishedDate": "2024-01-15"
|
||||
},
|
||||
{
|
||||
"title": "Test Result 2",
|
||||
"url": "https://example.com/2",
|
||||
"content": "Snippet 2",
|
||||
"publishedDate": None
|
||||
}
|
||||
])
|
||||
client.search_news = AsyncMock(return_value=[])
|
||||
client.search_images = AsyncMock(return_value=[])
|
||||
return client
|
||||
|
||||
@pytest.fixture
|
||||
def mock_content_extractor(self):
|
||||
"""Create mock ContentExtractor."""
|
||||
from src.models.content import ContentExtractionResult
|
||||
|
||||
extractor = MagicMock()
|
||||
extractor.extract_batch = AsyncMock(return_value=[
|
||||
ContentExtractionResult(
|
||||
url="https://example.com/1",
|
||||
content="Full extracted content 1",
|
||||
success=True
|
||||
),
|
||||
ContentExtractionResult(
|
||||
url="https://example.com/2",
|
||||
content="Full extracted content 2",
|
||||
success=True
|
||||
)
|
||||
])
|
||||
return extractor
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_client(self):
|
||||
"""Create mock Redis client."""
|
||||
redis = MagicMock()
|
||||
redis.get = AsyncMock(return_value=None) # No cache hit
|
||||
redis.setex = AsyncMock()
|
||||
return redis
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings(self):
|
||||
"""Create mock settings."""
|
||||
settings = MagicMock()
|
||||
settings.search_cache_ttl = 300
|
||||
settings.search_default_limit = 10
|
||||
return settings
|
||||
|
||||
@pytest.fixture
|
||||
def rag_search_service(
|
||||
self,
|
||||
mock_searxng_client,
|
||||
mock_content_extractor,
|
||||
mock_redis_client,
|
||||
mock_settings
|
||||
):
|
||||
"""Create RAGSearchService with mocked dependencies."""
|
||||
return RAGSearchService(
|
||||
searxng_client=mock_searxng_client,
|
||||
content_extractor=mock_content_extractor,
|
||||
redis_client=mock_redis_client,
|
||||
settings=mock_settings
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_basic(self, rag_search_service, mock_searxng_client):
|
||||
"""Test basic web search."""
|
||||
response = await rag_search_service.search(
|
||||
query="test query",
|
||||
search_type=SearchType.WEB,
|
||||
limit=10
|
||||
)
|
||||
|
||||
assert response.query == "test query"
|
||||
assert response.search_type == SearchType.WEB
|
||||
assert len(response.results) == 2
|
||||
assert response.total_results == 2
|
||||
assert response.search_time_ms >= 0
|
||||
|
||||
mock_searxng_client.search_general.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_news(self, rag_search_service, mock_searxng_client):
|
||||
"""Test news search type."""
|
||||
mock_searxng_client.search_news.return_value = [
|
||||
{"title": "News", "url": "https://news.com/1", "content": "News content"}
|
||||
]
|
||||
|
||||
response = await rag_search_service.search(
|
||||
query="latest news",
|
||||
search_type=SearchType.NEWS
|
||||
)
|
||||
|
||||
assert response.search_type == SearchType.NEWS
|
||||
mock_searxng_client.search_news.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_images(self, rag_search_service, mock_searxng_client):
|
||||
"""Test image search type."""
|
||||
mock_searxng_client.search_images.return_value = [
|
||||
{"title": "Image", "url": "https://images.com/1.jpg", "content": ""}
|
||||
]
|
||||
|
||||
response = await rag_search_service.search(
|
||||
query="cat photos",
|
||||
search_type=SearchType.IMAGES
|
||||
)
|
||||
|
||||
assert response.search_type == SearchType.IMAGES
|
||||
mock_searxng_client.search_images.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_empty_query(self, rag_search_service):
|
||||
"""Test search with empty query raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Query cannot be empty"):
|
||||
await rag_search_service.search(query="", search_type=SearchType.WEB)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_caching_miss(
|
||||
self,
|
||||
rag_search_service,
|
||||
mock_redis_client,
|
||||
mock_searxng_client
|
||||
):
|
||||
"""Test search caches results on cache miss."""
|
||||
mock_redis_client.get.return_value = None # Cache miss
|
||||
|
||||
await rag_search_service.search(query="test", search_type=SearchType.WEB)
|
||||
|
||||
# Should call SearXNG (cache miss)
|
||||
mock_searxng_client.search_general.assert_called_once()
|
||||
# Should cache result
|
||||
mock_redis_client.setex.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_caching_hit(
|
||||
self,
|
||||
rag_search_service,
|
||||
mock_redis_client,
|
||||
mock_searxng_client
|
||||
):
|
||||
"""Test search returns cached results on cache hit."""
|
||||
# Simulate cache hit
|
||||
cached_response = RAGSearchResponse(
|
||||
query="test",
|
||||
search_type=SearchType.WEB,
|
||||
results=[],
|
||||
total_results=0,
|
||||
search_time_ms=50,
|
||||
sources_summary=""
|
||||
)
|
||||
mock_redis_client.get.return_value = cached_response.model_dump_json()
|
||||
|
||||
response = await rag_search_service.search(query="test", search_type=SearchType.WEB)
|
||||
|
||||
# Should NOT call SearXNG (cache hit)
|
||||
mock_searxng_client.search_general.assert_not_called()
|
||||
assert response.query == "test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_content_extraction(
|
||||
self,
|
||||
rag_search_service,
|
||||
mock_content_extractor
|
||||
):
|
||||
"""Test search extracts content from result URLs."""
|
||||
response = await rag_search_service.search(
|
||||
query="test",
|
||||
search_type=SearchType.WEB
|
||||
)
|
||||
|
||||
# Should have called content extractor
|
||||
mock_content_extractor.extract_batch.assert_called_once()
|
||||
|
||||
# Results should have extracted content
|
||||
for result in response.results:
|
||||
assert result.content # Content should be populated
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sources_summary(self, rag_search_service):
|
||||
"""Test search generates sources summary."""
|
||||
response = await rag_search_service.search(
|
||||
query="test",
|
||||
search_type=SearchType.WEB
|
||||
)
|
||||
|
||||
assert response.sources_summary
|
||||
assert "## Sources" in response.sources_summary
|
||||
assert "[Test Result 1]" in response.sources_summary
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_limit(self, rag_search_service, mock_searxng_client):
|
||||
"""Test search respects limit parameter."""
|
||||
await rag_search_service.search(
|
||||
query="test",
|
||||
search_type=SearchType.WEB,
|
||||
limit=5
|
||||
)
|
||||
|
||||
# Check limit was passed to SearXNG
|
||||
mock_searxng_client.search_general.assert_called_once_with(
|
||||
query="test",
|
||||
limit=5
|
||||
)
|
||||
|
||||
|
||||
class TestRAGSearchServiceIntegration:
|
||||
"""Integration-style tests (still mocked but test more of the flow)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_search_flow(self):
|
||||
"""Test full search flow with all components mocked."""
|
||||
from src.models.content import ContentExtractionResult
|
||||
|
||||
# Setup mocks
|
||||
mock_searxng = MagicMock()
|
||||
mock_searxng.search_general = AsyncMock(return_value=[
|
||||
{
|
||||
"title": "Python Tutorial",
|
||||
"url": "https://python.org/tutorial",
|
||||
"content": "Learn Python programming",
|
||||
"publishedDate": "2024-01-10"
|
||||
}
|
||||
])
|
||||
|
||||
mock_extractor = MagicMock()
|
||||
mock_extractor.extract_batch = AsyncMock(return_value=[
|
||||
ContentExtractionResult(
|
||||
url="https://python.org/tutorial",
|
||||
title="Python Tutorial",
|
||||
content="This is a comprehensive Python tutorial covering basics to advanced topics.",
|
||||
success=True
|
||||
)
|
||||
])
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.get = AsyncMock(return_value=None)
|
||||
mock_redis.setex = AsyncMock()
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.search_cache_ttl = 300
|
||||
mock_settings.search_default_limit = 10
|
||||
|
||||
# Create service and execute search
|
||||
service = RAGSearchService(
|
||||
searxng_client=mock_searxng,
|
||||
content_extractor=mock_extractor,
|
||||
redis_client=mock_redis,
|
||||
settings=mock_settings
|
||||
)
|
||||
|
||||
response = await service.search(
|
||||
query="python tutorial",
|
||||
search_type=SearchType.WEB,
|
||||
limit=10,
|
||||
user="test_user"
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.query == "python tutorial"
|
||||
assert len(response.results) == 1
|
||||
assert response.results[0].title == "Python Tutorial"
|
||||
assert response.results[0].source == "python.org"
|
||||
assert "comprehensive Python tutorial" in response.results[0].content
|
||||
assert response.results[0].snippet == "Learn Python programming"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user