refactor: extract library-desk to standalone repository
- Move library-desk service to git.schweitz.net/jpmschweitzer/library-desk - Update stacks/library-desk.yml to use container image - Add library-desk to External Repositories in CONTAINERS.md - Remove source code (now in external repo) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ Services marked "(external)" have source code in separate Gitea repositories. Co
|
|||||||
|---------|------------|-------|
|
|---------|------------|-------|
|
||||||
| **Scheduler** | [scheduler](https://git.schweitz.net/jpmschweitzer/scheduler) | `git.schweitz.net/jpmschweitzer/scheduler:latest` |
|
| **Scheduler** | [scheduler](https://git.schweitz.net/jpmschweitzer/scheduler) | `git.schweitz.net/jpmschweitzer/scheduler:latest` |
|
||||||
| **Core API** | [core-api](https://git.schweitz.net/jpmschweitzer/core-api) | `git.schweitz.net/jpmschweitzer/core-api:latest` |
|
| **Core API** | [core-api](https://git.schweitz.net/jpmschweitzer/core-api) | `git.schweitz.net/jpmschweitzer/core-api:latest` |
|
||||||
|
| **Library Desk** | [library-desk](https://git.schweitz.net/jpmschweitzer/library-desk) | `git.schweitz.net/jpmschweitzer/library-desk:latest` |
|
||||||
|
|
||||||
**Development workflow:** Clone repo → make changes → create Gitea release → Watchtower auto-updates container.
|
**Development workflow:** Clone repo → make changes → create Gitea release → Watchtower auto-updates container.
|
||||||
|
|
||||||
|
|||||||
@@ -1,473 +0,0 @@
|
|||||||
# Librarian Integration Guide
|
|
||||||
|
|
||||||
**How The Scheduler (Librarian) interacts with Library Desk for documentation management**
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Scheduler's documentation mirroring tasks feed content into The Library system via Library Desk API. This creates a knowledge graph and vector index of all documentation for semantic search and relationship discovery.
|
|
||||||
|
|
||||||
## Architecture Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ The Scheduler │ (The Librarian)
|
|
||||||
│ (scheduler) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ 1. Mirror docs from sources
|
|
||||||
│ (GitHub, Gitea, etc.)
|
|
||||||
↓
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Gitea Repo │
|
|
||||||
│ docs-mirror/* │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ 2. Ingest to Library
|
|
||||||
↓
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Library Desk │ (Coordination API)
|
|
||||||
│ (library-desk) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│ 3. Process & Index
|
|
||||||
├──→ Neo4j (relationships)
|
|
||||||
├──→ Qdrant (embeddings)
|
|
||||||
└──→ Wiki.js (dossiers)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Required Endpoints
|
|
||||||
|
|
||||||
### 1. Document Ingestion
|
|
||||||
|
|
||||||
**POST /ingest/document**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source": "github",
|
|
||||||
"repository": "anthropics/anthropic-cookbook",
|
|
||||||
"path": "skills/citation/guide.md",
|
|
||||||
"content": "# Citation Guide\n...",
|
|
||||||
"metadata": {
|
|
||||||
"commit_sha": "abc123",
|
|
||||||
"author": "Anthropic",
|
|
||||||
"updated_at": "2025-12-08T10:30:00Z",
|
|
||||||
"gitea_mirror_path": "docs-mirror/anthropic-cookbook/skills/citation/guide.md",
|
|
||||||
"language": "markdown",
|
|
||||||
"tags": ["skills", "citation", "prompting"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"document_id": "doc_abc123xyz",
|
|
||||||
"status": "processing",
|
|
||||||
"operations": {
|
|
||||||
"chunking": "pending",
|
|
||||||
"embedding": "pending",
|
|
||||||
"entity_extraction": "pending",
|
|
||||||
"graph_indexing": "pending"
|
|
||||||
},
|
|
||||||
"estimated_completion": "2025-12-08T10:30:15Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Batch Ingestion
|
|
||||||
|
|
||||||
**POST /ingest/batch**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source": "github",
|
|
||||||
"repository": "anthropics/anthropic-cookbook",
|
|
||||||
"documents": [
|
|
||||||
{
|
|
||||||
"path": "skills/citation/guide.md",
|
|
||||||
"content": "...",
|
|
||||||
"metadata": {...}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "skills/summarization/techniques.md",
|
|
||||||
"content": "...",
|
|
||||||
"metadata": {...}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"batch_id": "batch_xyz789",
|
|
||||||
"total_documents": 2,
|
|
||||||
"status": "processing",
|
|
||||||
"documents": [
|
|
||||||
{"document_id": "doc_1", "status": "queued"},
|
|
||||||
{"document_id": "doc_2", "status": "queued"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Document Status Check
|
|
||||||
|
|
||||||
**GET /ingest/status/{document_id}**
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"document_id": "doc_abc123xyz",
|
|
||||||
"status": "completed",
|
|
||||||
"operations": {
|
|
||||||
"chunking": "completed",
|
|
||||||
"embedding": "completed",
|
|
||||||
"entity_extraction": "completed",
|
|
||||||
"graph_indexing": "completed"
|
|
||||||
},
|
|
||||||
"results": {
|
|
||||||
"chunks_created": 12,
|
|
||||||
"vectors_indexed": 12,
|
|
||||||
"entities_extracted": 8,
|
|
||||||
"relationships_created": 15
|
|
||||||
},
|
|
||||||
"completed_at": "2025-12-08T10:30:14Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Content Update Detection
|
|
||||||
|
|
||||||
**POST /ingest/check-updates**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"documents": [
|
|
||||||
{
|
|
||||||
"path": "docs-mirror/anthropic-cookbook/skills/citation/guide.md",
|
|
||||||
"content_hash": "sha256:abc123...",
|
|
||||||
"updated_at": "2025-12-08T10:30:00Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"updates_needed": [
|
|
||||||
{
|
|
||||||
"path": "docs-mirror/anthropic-cookbook/skills/citation/guide.md",
|
|
||||||
"reason": "content_changed",
|
|
||||||
"last_indexed": "2025-12-07T10:30:00Z",
|
|
||||||
"action": "re-index"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"up_to_date": [],
|
|
||||||
"new_documents": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Deduplication Check
|
|
||||||
|
|
||||||
**POST /deduplicate/check**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"document_id": "doc_abc123xyz",
|
|
||||||
"similarity_threshold": 0.85
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"duplicates": [
|
|
||||||
{
|
|
||||||
"document_id": "doc_def456",
|
|
||||||
"similarity": 0.92,
|
|
||||||
"path": "docs-mirror/claude-docs/citation-best-practices.md",
|
|
||||||
"overlap_summary": "Both documents cover citation formatting"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"suggestions": {
|
|
||||||
"action": "merge_or_cross_reference",
|
|
||||||
"confidence": 0.88
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Repository Sync Status
|
|
||||||
|
|
||||||
**GET /ingest/repo-status/{repository_name}**
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"repository": "anthropic-cookbook",
|
|
||||||
"total_documents": 156,
|
|
||||||
"indexed_documents": 156,
|
|
||||||
"pending_updates": 0,
|
|
||||||
"failed_documents": 0,
|
|
||||||
"last_sync": "2025-12-08T03:00:00Z",
|
|
||||||
"next_scheduled_sync": "2025-12-09T03:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scheduler Integration Workflow
|
|
||||||
|
|
||||||
### Phase 1: Mirror Documentation (Current)
|
|
||||||
```python
|
|
||||||
# This already exists in the Scheduler
|
|
||||||
async def mirror_documentation():
|
|
||||||
"""Mirror docs from external sources to Gitea"""
|
|
||||||
repos = [
|
|
||||||
"anthropics/anthropic-cookbook",
|
|
||||||
"anthropics/prompt-eng-interactive-tutorial",
|
|
||||||
# ... etc
|
|
||||||
]
|
|
||||||
|
|
||||||
for repo in repos:
|
|
||||||
# Clone/pull to /docs-mirror/{repo-name}
|
|
||||||
await git_sync(repo, f"/docs-mirror/{repo}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Index to Library (New)
|
|
||||||
```python
|
|
||||||
async def index_to_library():
|
|
||||||
"""Send mirrored docs to Library Desk for indexing"""
|
|
||||||
|
|
||||||
# Get list of documents in docs-mirror
|
|
||||||
docs_path = Path("/docs-mirror")
|
|
||||||
|
|
||||||
for repo_dir in docs_path.iterdir():
|
|
||||||
if not repo_dir.is_dir():
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Find all markdown files
|
|
||||||
md_files = list(repo_dir.rglob("*.md"))
|
|
||||||
|
|
||||||
# Check what needs updating
|
|
||||||
update_check = await check_library_updates(md_files)
|
|
||||||
|
|
||||||
if update_check["updates_needed"]:
|
|
||||||
# Batch ingest updated documents
|
|
||||||
await batch_ingest_documents(
|
|
||||||
repository=repo_dir.name,
|
|
||||||
documents=update_check["updates_needed"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for processing to complete
|
|
||||||
await wait_for_batch_completion(batch_id)
|
|
||||||
|
|
||||||
# Check for duplicates
|
|
||||||
await check_and_resolve_duplicates(repo_dir.name)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: Monitor & Maintain
|
|
||||||
```python
|
|
||||||
async def maintain_library_index():
|
|
||||||
"""Periodic maintenance of Library index"""
|
|
||||||
|
|
||||||
# Check for orphaned entries (deleted from source)
|
|
||||||
await cleanup_orphaned_documents()
|
|
||||||
|
|
||||||
# Update embeddings if model changed
|
|
||||||
await refresh_embeddings_if_needed()
|
|
||||||
|
|
||||||
# Generate relationship maps for new content
|
|
||||||
await discover_document_relationships()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scheduler Task Definition
|
|
||||||
|
|
||||||
**New Task: `library_sync`**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
Task Name: library_sync
|
|
||||||
Description: Sync mirrored documentation to Library for indexing and search
|
|
||||||
Schedule: Daily at 03:30 (after doc mirroring at 03:00)
|
|
||||||
Priority: 15 (user maintenance)
|
|
||||||
Service: library
|
|
||||||
Executor: scheduler.tasks.library_tasks.sync_library_index
|
|
||||||
Dependencies:
|
|
||||||
- docs_mirror (must complete first)
|
|
||||||
Configuration:
|
|
||||||
- LIBRARY_DESK_URL: http://library-desk:8089
|
|
||||||
- LIBRARY_API_KEY: ${LIBRARY_API_KEY}
|
|
||||||
- BATCH_SIZE: 50
|
|
||||||
- CHECK_UPDATES_ONLY: true
|
|
||||||
Outputs:
|
|
||||||
- Documents indexed
|
|
||||||
- Duplicates found
|
|
||||||
- Relationships created
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Client Example
|
|
||||||
|
|
||||||
```python
|
|
||||||
# scheduler/src/clients/library_desk.py
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
class LibraryDeskClient:
|
|
||||||
def __init__(self, base_url: str, api_key: str):
|
|
||||||
self.base_url = base_url
|
|
||||||
self.headers = {
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
async def ingest_document(
|
|
||||||
self,
|
|
||||||
source: str,
|
|
||||||
repository: str,
|
|
||||||
path: str,
|
|
||||||
content: str,
|
|
||||||
metadata: Dict[str, Any]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Ingest a single document"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.base_url}/ingest/document",
|
|
||||||
headers=self.headers,
|
|
||||||
json={
|
|
||||||
"source": source,
|
|
||||||
"repository": repository,
|
|
||||||
"path": path,
|
|
||||||
"content": content,
|
|
||||||
"metadata": metadata
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def batch_ingest(
|
|
||||||
self,
|
|
||||||
source: str,
|
|
||||||
repository: str,
|
|
||||||
documents: List[Dict[str, Any]]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Ingest multiple documents"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.base_url}/ingest/batch",
|
|
||||||
headers=self.headers,
|
|
||||||
json={
|
|
||||||
"source": source,
|
|
||||||
"repository": repository,
|
|
||||||
"documents": documents
|
|
||||||
},
|
|
||||||
timeout=300.0 # 5 minutes for large batches
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def check_updates(
|
|
||||||
self,
|
|
||||||
documents: List[Dict[str, str]]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Check which documents need updating"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{self.base_url}/ingest/check-updates",
|
|
||||||
headers=self.headers,
|
|
||||||
json={"documents": documents}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
async def get_repo_status(self, repository: str) -> Dict[str, Any]:
|
|
||||||
"""Get indexing status for a repository"""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{self.base_url}/ingest/repo-status/{repository}",
|
|
||||||
headers=self.headers
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Typical Workflow Sequence
|
|
||||||
|
|
||||||
### Daily Documentation Sync (03:00-03:45)
|
|
||||||
|
|
||||||
1. **03:00** - Scheduler runs `docs_mirror` task
|
|
||||||
- Pulls latest from all configured repos
|
|
||||||
- Writes to `/docs-mirror/*`
|
|
||||||
|
|
||||||
2. **03:30** - Scheduler runs `library_sync` task
|
|
||||||
- Scans `/docs-mirror/` for changes
|
|
||||||
- Calls `POST /ingest/check-updates` with file hashes
|
|
||||||
- Gets list of updated/new documents
|
|
||||||
|
|
||||||
3. **03:31-03:40** - Batch ingestion
|
|
||||||
- Groups documents by repo
|
|
||||||
- Calls `POST /ingest/batch` for each repo
|
|
||||||
- Monitors `GET /ingest/status/{batch_id}`
|
|
||||||
|
|
||||||
4. **03:41-03:44** - Post-processing
|
|
||||||
- Calls `POST /deduplicate/check` for new docs
|
|
||||||
- Reviews duplicate suggestions
|
|
||||||
- Logs statistics to Scheduler database
|
|
||||||
|
|
||||||
5. **03:45** - Completion
|
|
||||||
- Scheduler marks task complete
|
|
||||||
- Sends summary to logs
|
|
||||||
- Updates next run time
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Retry Strategy
|
|
||||||
```python
|
|
||||||
async def ingest_with_retry(document: Dict, max_retries: int = 3):
|
|
||||||
"""Ingest with exponential backoff"""
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
result = await library_client.ingest_document(**document)
|
|
||||||
return result
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
await asyncio.sleep(2 ** attempt) # 1s, 2s, 4s
|
|
||||||
else:
|
|
||||||
# Log failure and continue
|
|
||||||
logger.error(f"Failed to ingest {document['path']} after {max_retries} attempts")
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### Graceful Degradation
|
|
||||||
- If Library Desk is down, queue documents for later ingestion
|
|
||||||
- Store failed ingestions in Scheduler database
|
|
||||||
- Retry failed ingestions on next run
|
|
||||||
|
|
||||||
## Metrics to Track
|
|
||||||
|
|
||||||
The Scheduler should track:
|
|
||||||
- Documents mirrored vs. documents indexed
|
|
||||||
- Average ingestion time per document
|
|
||||||
- Deduplication rate
|
|
||||||
- Failed ingestions
|
|
||||||
- Library Desk response times
|
|
||||||
|
|
||||||
These can be displayed in the Scheduler UI dashboard.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Add to Scheduler's environment:
|
|
||||||
```bash
|
|
||||||
# Library Integration
|
|
||||||
LIBRARY_DESK_URL=http://library-desk:8089
|
|
||||||
LIBRARY_API_KEY=${LIBRARY_API_KEY}
|
|
||||||
LIBRARY_BATCH_SIZE=50
|
|
||||||
LIBRARY_SYNC_ENABLED=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Implement ingestion endpoints in Library Desk
|
|
||||||
2. Add LibraryDeskClient to Scheduler
|
|
||||||
3. Create `library_sync` task in Scheduler
|
|
||||||
4. Test with small batch of documents
|
|
||||||
5. Monitor and tune performance
|
|
||||||
6. Expand to full documentation corpus
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
- **Automatic Knowledge Base**: All mirrored docs automatically indexed
|
|
||||||
- **Semantic Search**: Find docs by meaning, not just keywords
|
|
||||||
- **Relationship Discovery**: Understand connections between docs
|
|
||||||
- **Deduplication**: Identify overlapping content across repos
|
|
||||||
- **HybridRAG Ready**: Knowledge graph + vectors enable advanced AI queries
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# Library Desk - API Coordination Service
|
|
||||||
|
|
||||||
**FastAPI service that coordinates all Library operations**
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Library Desk is the central coordination layer for The Library system, providing a unified API for:
|
|
||||||
|
|
||||||
- **HybridRAG Queries** - Combines Neo4j (structure) + Qdrant (semantics) + SearXNG (web)
|
|
||||||
- **Document Ingestion** - Parse, chunk, embed, and index documents
|
|
||||||
- **Entity Extraction** - NLP to identify classes, functions, concepts
|
|
||||||
- **Relationship Mapping** - Link entities in Neo4j knowledge graph
|
|
||||||
- **Mind Map Generation** - Query Neo4j graph → Render D3.js visualizations
|
|
||||||
- **Wiki.js Proxy** - CRUD operations for dossiers
|
|
||||||
- **Deduplication** - Vector similarity + graph analysis
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Library Desk API (FastAPI)
|
|
||||||
├── Neo4j (knowledge graph)
|
|
||||||
├── Qdrant (vector search)
|
|
||||||
├── Wiki.js (wiki operations)
|
|
||||||
├── SearXNG (web search)
|
|
||||||
├── Ollama (embeddings)
|
|
||||||
└── Redis (caching)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- **Python**: 3.12+
|
|
||||||
- **Dependencies**: See `requirements.txt`
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Environment variables (set in Portainer stack):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Required
|
|
||||||
LIBRARY_API_KEY=<generate-with-openssl-rand-hex-32>
|
|
||||||
NEO4J_PASSWORD=<neo4j-password>
|
|
||||||
WIKIJS_API_KEY=<from-wiki-admin-panel>
|
|
||||||
|
|
||||||
# Optional (defaults provided)
|
|
||||||
NEO4J_URI=bolt://neo4j:7687
|
|
||||||
NEO4J_USER=neo4j
|
|
||||||
QDRANT_HOST=qdrant
|
|
||||||
QDRANT_PORT=6333
|
|
||||||
WIKIJS_URL=http://wiki:3000
|
|
||||||
SEARXNG_URL=http://searxng:8080
|
|
||||||
OLLAMA_URL=http://ollama:11434
|
|
||||||
OLLAMA_MODEL=nomic-embed-text
|
|
||||||
REDIS_HOST=redis-shared
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_DB=2
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### System
|
|
||||||
|
|
||||||
- `GET /` - Root endpoint
|
|
||||||
- `GET /health` - Health check (public)
|
|
||||||
- `GET /stats` - System statistics (authenticated)
|
|
||||||
|
|
||||||
### Query (All require API key)
|
|
||||||
|
|
||||||
- `POST /query/hybrid` - HybridRAG (graph + vector + web)
|
|
||||||
- `POST /query/semantic` - Vector search only
|
|
||||||
- `POST /query/graph` - Graph traversal only
|
|
||||||
- `GET /query/related/{id}` - Find related content
|
|
||||||
|
|
||||||
### Content Management (Future)
|
|
||||||
|
|
||||||
- `POST /ingest/document` - Index new document
|
|
||||||
- `POST /ingest/wiki-page` - Sync Wiki.js page
|
|
||||||
- `POST /wiki/dossier` - Create dossier (proxies to Wiki.js)
|
|
||||||
- `PUT /wiki/dossier/{id}` - Update dossier
|
|
||||||
- `DELETE /wiki/dossier/{id}` - Delete dossier
|
|
||||||
|
|
||||||
### Graph Operations (Future)
|
|
||||||
|
|
||||||
- `GET /graph/entities` - List entities
|
|
||||||
- `GET /graph/mindmap/{id}` - Generate mind map
|
|
||||||
- `POST /graph/query` - Execute Cypher query
|
|
||||||
|
|
||||||
### Deduplication (Future)
|
|
||||||
|
|
||||||
- `POST /deduplicate/find` - Find duplicates
|
|
||||||
- `POST /deduplicate/merge` - Merge duplicates
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Local Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create virtual environment
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Run development server
|
|
||||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8089
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
Following [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices):
|
|
||||||
|
|
||||||
```
|
|
||||||
library-desk/
|
|
||||||
├── src/
|
|
||||||
│ ├── __init__.py # Package initialization
|
|
||||||
│ ├── main.py # FastAPI application
|
|
||||||
│ ├── config.py # Pydantic settings
|
|
||||||
│ └── [future modules] # Domain-specific modules
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
Future structure (as features are added):
|
|
||||||
|
|
||||||
```
|
|
||||||
library-desk/
|
|
||||||
├── src/
|
|
||||||
│ ├── query/ # Query domain
|
|
||||||
│ │ ├── router.py
|
|
||||||
│ │ ├── schemas.py
|
|
||||||
│ │ ├── service.py
|
|
||||||
│ │ └── dependencies.py
|
|
||||||
│ ├── graph/ # Graph domain
|
|
||||||
│ ├── wiki/ # Wiki domain
|
|
||||||
│ └── ingest/ # Ingestion domain
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices Implemented
|
|
||||||
|
|
||||||
✅ **Async routes** for I/O operations
|
|
||||||
✅ **Dependency injection** for configuration and auth
|
|
||||||
✅ **Pydantic models** for request/response validation
|
|
||||||
✅ **Modular settings** using Pydantic Settings
|
|
||||||
✅ **API key authentication** with Bearer tokens
|
|
||||||
✅ **OpenAPI documentation** auto-generated
|
|
||||||
✅ **Proper logging** with structured format
|
|
||||||
✅ **CORS middleware** configured
|
|
||||||
✅ **Health checks** for monitoring
|
|
||||||
✅ **Minor version locking** (`~=`) in requirements
|
|
||||||
✅ **CVE-checked dependencies** (Dec 2025)
|
|
||||||
|
|
||||||
## API Documentation
|
|
||||||
|
|
||||||
Once running, access:
|
|
||||||
|
|
||||||
- **Interactive docs**: http://localhost:8089/docs
|
|
||||||
- **ReDoc**: http://localhost:8089/redoc
|
|
||||||
- **OpenAPI spec**: http://localhost:8089/openapi.json
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All protected endpoints require a Bearer token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer ${LIBRARY_API_KEY}" \
|
|
||||||
http://localhost:8089/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests (when implemented)
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# With coverage
|
|
||||||
pytest --cov=src --cov-report=term
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Deployed via Portainer stack: `/stacks/library-desk.yml`
|
|
||||||
|
|
||||||
The container:
|
|
||||||
- Runs on port 8089
|
|
||||||
- Auto-creates venv on startup
|
|
||||||
- Installs dependencies from requirements.txt
|
|
||||||
- Starts uvicorn with 2 workers
|
|
||||||
- Mounts source code for live editing
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
- **Uptime Kuma**: Monitor `/health` endpoint
|
|
||||||
- **Logs**: `docker logs library-desk`
|
|
||||||
- **Stats**: `GET /stats` (requires API key)
|
|
||||||
|
|
||||||
## Scheduler Integration
|
|
||||||
|
|
||||||
See [LIBRARIAN_INTEGRATION.md](./LIBRARIAN_INTEGRATION.md) for details on how The Scheduler (Librarian) integrates with Library Desk for automated documentation indexing.
|
|
||||||
|
|
||||||
**Key Workflow:**
|
|
||||||
1. Scheduler mirrors docs to Gitea (daily 03:00)
|
|
||||||
2. Scheduler syncs to Library Desk (daily 03:30)
|
|
||||||
3. Library Desk ingests, chunks, embeds, and indexes
|
|
||||||
4. Content becomes searchable via HybridRAG
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
- [ ] Implement HybridRAG query logic
|
|
||||||
- [ ] Add Neo4j connection pooling
|
|
||||||
- [ ] Add Qdrant client initialization
|
|
||||||
- [ ] Implement Wiki.js API proxy
|
|
||||||
- [ ] Add entity extraction (spaCy/NLP)
|
|
||||||
- [ ] Implement mind map generation
|
|
||||||
- [ ] Add deduplication logic
|
|
||||||
- [ ] **Implement Scheduler integration endpoints** (see LIBRARIAN_INTEGRATION.md)
|
|
||||||
- [ ] Add comprehensive tests
|
|
||||||
- [ ] Add rate limiting
|
|
||||||
- [ ] Add request tracing
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)
|
|
||||||
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [Pydantic Documentation](https://docs.pydantic.dev/)
|
|
||||||
- [Neo4j Python Driver](https://neo4j.com/docs/python-manual/current/)
|
|
||||||
- [Qdrant Python Client](https://python-client.qdrant.tech/)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Part of Portainer Core infrastructure.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- Check logs: `docker logs library-desk`
|
|
||||||
- Health check: `curl http://localhost:8089/health`
|
|
||||||
- API docs: http://localhost:8089/docs
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
# Wiki.js Change Detection - Complete Setup Guide
|
|
||||||
|
|
||||||
Complete setup instructions for real-time Wiki.js page change detection with loop prevention.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This system automatically processes user-edited Wiki.js pages using PostgreSQL database triggers and NOTIFY/LISTEN.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- ✅ Real-time change detection via database triggers
|
|
||||||
- ✅ Read-only database user (security best practice)
|
|
||||||
- ✅ Loop prevention (automated edits don't trigger re-processing)
|
|
||||||
- ✅ Debouncing (prevents duplicate processing)
|
|
||||||
- ✅ Graceful error handling
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ User edits page in Wiki.js │
|
|
||||||
└──────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ PostgreSQL Trigger fires │
|
|
||||||
│ pg_notify('wiki_page_changes', 'UPDATE:123:...')│
|
|
||||||
└──────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ WikiChangeListener (library-desk) │
|
|
||||||
│ ├─ LOOP CHECK #1: Is author automated user? │ ← Prevents loops
|
|
||||||
│ ├─ LOOP CHECK #2: Recently processed? │ ← Debouncing
|
|
||||||
│ └─ Process page (ingest + entity linking) │
|
|
||||||
└──────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ Entity linking writes back to Wiki.js │
|
|
||||||
│ (via API as wikijs_username) │
|
|
||||||
└──────────────────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ Trigger fires again BUT... │
|
|
||||||
│ LOOP CHECK #1 catches it: author is │
|
|
||||||
│ automated user → SKIP processing │ ← Loop prevented!
|
|
||||||
└────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Wiki.js running with PostgreSQL database
|
|
||||||
- Library-desk has network access to PostgreSQL
|
|
||||||
- PostgreSQL admin access (for initial setup)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Create Read-Only Database User
|
|
||||||
|
|
||||||
**Why:** Library-desk only needs to LISTEN for notifications, not modify data. Using a read-only user is a security best practice.
|
|
||||||
|
|
||||||
### Run SQL as PostgreSQL admin:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U postgres -d wiki
|
|
||||||
```
|
|
||||||
|
|
||||||
Then execute:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Create read-only user for change listener
|
|
||||||
CREATE USER library_desk_listener WITH PASSWORD 'secure_password_here';
|
|
||||||
|
|
||||||
-- Grant minimal permissions
|
|
||||||
GRANT CONNECT ON DATABASE wiki TO library_desk_listener;
|
|
||||||
GRANT USAGE ON SCHEMA public TO library_desk_listener;
|
|
||||||
GRANT SELECT ON TABLE pages TO library_desk_listener;
|
|
||||||
|
|
||||||
-- Verify permissions
|
|
||||||
\du library_desk_listener
|
|
||||||
-- Should show: No roles, just basic login
|
|
||||||
|
|
||||||
-- Test LISTEN capability (important!)
|
|
||||||
\c wiki library_desk_listener
|
|
||||||
LISTEN wiki_page_changes;
|
|
||||||
-- Should succeed even with read-only permissions
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
**Save the password** - you'll need it for `.env` configuration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: Install Database Triggers
|
|
||||||
|
|
||||||
Triggers emit NOTIFY events when pages are created/updated/deleted.
|
|
||||||
|
|
||||||
### Run the setup script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it library-desk bash
|
|
||||||
cd /app
|
|
||||||
python3 setup_wiki_triggers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected output:**
|
|
||||||
```
|
|
||||||
======================================================================
|
|
||||||
Wiki.js Database Trigger Setup
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
Connecting to Wiki.js database at postgres:5432
|
|
||||||
Database: wiki
|
|
||||||
User: wikijs
|
|
||||||
|
|
||||||
✓ Connected to Wiki.js database
|
|
||||||
|
|
||||||
Installing triggers...
|
|
||||||
|
|
||||||
✓ Triggers installed successfully:
|
|
||||||
|
|
||||||
- wiki_page_delete_trigger (DELETE)
|
|
||||||
- wiki_page_insert_trigger (INSERT)
|
|
||||||
- wiki_page_update_trigger (UPDATE)
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
Setup complete!
|
|
||||||
======================================================================
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual verification:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT trigger_name, event_manipulation
|
|
||||||
FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages'
|
|
||||||
ORDER BY trigger_name;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Configure Environment Variables
|
|
||||||
|
|
||||||
Add to your `services/library-desk/.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Wiki.js API User (used for entity linking)
|
|
||||||
# IMPORTANT: This username will be filtered out to prevent loops
|
|
||||||
WIKIJS_USERNAME=your_wikijs_api_username
|
|
||||||
|
|
||||||
# Wiki.js Database Configuration (for change listener)
|
|
||||||
WIKIJS_DB_HOST=postgres
|
|
||||||
WIKIJS_DB_PORT=5432
|
|
||||||
WIKIJS_DB_NAME=wiki
|
|
||||||
WIKIJS_DB_USER=library_desk_listener # Read-only user created in Step 1
|
|
||||||
WIKIJS_DB_PASSWORD=secure_password_here # From Step 1
|
|
||||||
|
|
||||||
# Loop Prevention: Debounce duration (optional, default: 5 seconds)
|
|
||||||
# Prevents processing duplicate notifications for the same page
|
|
||||||
WIKIJS_CHANGE_LISTENER_DEBOUNCE_SECONDS=5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Critical:** The `WIKIJS_USERNAME` must match the Wiki.js user that library-desk uses for API calls. This prevents infinite loops when entity linking updates pages.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4: Restart Library-Desk
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker restart library-desk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check logs for successful startup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --tail 50 | grep -E "(Wiki.js change listener|wiki_page_changes)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected log output:**
|
|
||||||
```
|
|
||||||
INFO - Starting Wiki.js database change listener
|
|
||||||
INFO - Listening for Wiki.js page changes via PostgreSQL NOTIFY
|
|
||||||
INFO - Wiki.js change listener started successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5: Test the System
|
|
||||||
|
|
||||||
### Test 1: Manual Page Edit
|
|
||||||
|
|
||||||
1. **Edit a page in Wiki.js** (add some text)
|
|
||||||
2. **Save the page**
|
|
||||||
3. **Watch library-desk logs:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --follow | grep -E "(Received|notification|Processing page|Entity linking)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected log output:**
|
|
||||||
```
|
|
||||||
INFO - Received UPDATE notification for page 123 by user@example.com
|
|
||||||
INFO - Processing page.update for page 123 ('Test Page')
|
|
||||||
INFO - Ingesting page 123 into knowledge base
|
|
||||||
INFO - Ingestion complete for page 123
|
|
||||||
INFO - Applying bidirectional entity linking for page 123
|
|
||||||
INFO - Entity linking complete for page 123: 5 forward links, 3 backward links
|
|
||||||
INFO - Successfully processed page.update for page 123
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 2: Loop Prevention
|
|
||||||
|
|
||||||
After the entity linking completes (which updates the Wiki.js page), you should see:
|
|
||||||
|
|
||||||
```
|
|
||||||
DEBUG - Skipping notification for page 123 - automated edit by wikijs_username (likely entity linking)
|
|
||||||
```
|
|
||||||
|
|
||||||
**This means loop prevention is working!** The automated edit triggered a notification, but the listener correctly filtered it out.
|
|
||||||
|
|
||||||
### Test 3: Debouncing
|
|
||||||
|
|
||||||
If you rapidly save the same page multiple times (within 5 seconds), you should see:
|
|
||||||
|
|
||||||
```
|
|
||||||
INFO - Received UPDATE notification for page 123 by user@example.com
|
|
||||||
INFO - Processing page.update for page 123...
|
|
||||||
DEBUG - Skipping notification for page 123 - processed within last 5s (debouncing)
|
|
||||||
DEBUG - Skipping notification for page 123 - processed within last 5s (debouncing)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Loop Prevention Details
|
|
||||||
|
|
||||||
The system has **two layers of loop prevention**:
|
|
||||||
|
|
||||||
### Layer 1: Automated User Filtering
|
|
||||||
|
|
||||||
**Problem:** Entity linking updates Wiki.js pages via API, which triggers database changes, which triggers notifications again.
|
|
||||||
|
|
||||||
**Solution:** Filter out notifications where the author matches `WIKIJS_USERNAME`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In wiki_change_listener.py:125-141
|
|
||||||
def _is_automated_user(self, email: str) -> bool:
|
|
||||||
automated_users = [
|
|
||||||
self.settings.wikijs_username, # Your Wiki.js API user
|
|
||||||
"library-desk@system",
|
|
||||||
"automation@system"
|
|
||||||
]
|
|
||||||
return email.lower() in [u.lower() for u in automated_users]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Customize:** If you use different automation users, add them to the list.
|
|
||||||
|
|
||||||
### Layer 2: Debouncing
|
|
||||||
|
|
||||||
**Problem:** Multiple rapid notifications for the same page (network hiccups, Wiki.js behavior).
|
|
||||||
|
|
||||||
**Solution:** Track recently processed pages and ignore duplicates within N seconds:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In wiki_change_listener.py:143-164
|
|
||||||
def _is_recently_processed(self, page_id: int) -> bool:
|
|
||||||
if page_id not in self._recent_notifications:
|
|
||||||
return False
|
|
||||||
|
|
||||||
last_processed = self._recent_notifications[page_id]
|
|
||||||
elapsed = (datetime.now() - last_processed).total_seconds()
|
|
||||||
|
|
||||||
return elapsed < self._debounce_seconds # Default: 5 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configure:** Set `WIKIJS_CHANGE_LISTENER_DEBOUNCE_SECONDS` in `.env` (1-60 seconds).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Check Listener Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Is listener running?
|
|
||||||
docker logs library-desk --tail 50 | grep "Wiki.js change listener"
|
|
||||||
|
|
||||||
# Expected: "Wiki.js change listener started successfully"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitor Real-Time Changes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Watch for page change notifications
|
|
||||||
docker logs library-desk --follow | grep -E "(Received|Processing page|Entity linking|Skipping)"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Check Loop Prevention Stats
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Count automated user filters (should be non-zero after entity linking runs)
|
|
||||||
docker logs library-desk | grep "automated edit" | wc -l
|
|
||||||
|
|
||||||
# Count debouncing skips
|
|
||||||
docker logs library-desk | grep "debouncing" | wc -l
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Listener Not Starting
|
|
||||||
|
|
||||||
**Error:** `Failed to start Wiki.js change listener`
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check database connection:**
|
|
||||||
```bash
|
|
||||||
docker exec library-desk ping postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test database credentials:**
|
|
||||||
```bash
|
|
||||||
docker exec library-desk psql -h postgres -U library_desk_listener -d wiki -c "\dt"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify user exists:**
|
|
||||||
```bash
|
|
||||||
docker exec postgres psql -U postgres -d wiki -c "\du library_desk_listener"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changes Not Being Processed
|
|
||||||
|
|
||||||
**Problem:** Page edits but no logs appear
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Verify triggers installed:**
|
|
||||||
```bash
|
|
||||||
docker exec postgres psql -U postgres -d wiki -c "
|
|
||||||
SELECT trigger_name FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
Should list 3 triggers (INSERT, UPDATE, DELETE).
|
|
||||||
|
|
||||||
2. **Test notification manually:**
|
|
||||||
```bash
|
|
||||||
docker exec postgres psql -U postgres -d wiki -c "
|
|
||||||
NOTIFY wiki_page_changes, 'TEST:999:test@example.com';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
Check library-desk logs for "Received TEST notification".
|
|
||||||
|
|
||||||
3. **Check authorEmail field exists:**
|
|
||||||
```bash
|
|
||||||
docker exec postgres psql -U postgres -d wiki -c "
|
|
||||||
SELECT column_name FROM information_schema.columns
|
|
||||||
WHERE table_name = 'pages' AND column_name = 'authorEmail';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Infinite Loop Detected
|
|
||||||
|
|
||||||
**Symptoms:** Continuous processing logs for the same page
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Verify WIKIJS_USERNAME is correct:**
|
|
||||||
```bash
|
|
||||||
docker exec library-desk env | grep WIKIJS_USERNAME
|
|
||||||
```
|
|
||||||
Must match the Wiki.js username library-desk uses for API calls.
|
|
||||||
|
|
||||||
2. **Check automated user filtering:**
|
|
||||||
```bash
|
|
||||||
docker logs library-desk | grep "automated edit"
|
|
||||||
```
|
|
||||||
If you see no "Skipping" logs, the filter isn't working.
|
|
||||||
|
|
||||||
3. **Manually stop processing:**
|
|
||||||
```bash
|
|
||||||
docker restart library-desk
|
|
||||||
```
|
|
||||||
Then fix the `WIKIJS_USERNAME` configuration.
|
|
||||||
|
|
||||||
### Permission Denied
|
|
||||||
|
|
||||||
**Error:** `permission denied to create trigger`
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Run trigger setup as postgres admin:**
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U postgres -d wiki
|
|
||||||
# Then run setup_wiki_triggers.py with admin user
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Grant trigger creation permission:**
|
|
||||||
```sql
|
|
||||||
GRANT CREATE ON DATABASE wiki TO wikijs;
|
|
||||||
ALTER TABLE pages OWNER TO wikijs;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Reference
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `WIKIJS_USERNAME` | *required* | Wiki.js API user (for loop prevention) |
|
|
||||||
| `WIKIJS_DB_HOST` | `postgres` | PostgreSQL host |
|
|
||||||
| `WIKIJS_DB_PORT` | `5432` | PostgreSQL port |
|
|
||||||
| `WIKIJS_DB_NAME` | `wiki` | Wiki.js database name |
|
|
||||||
| `WIKIJS_DB_USER` | `library_desk_listener` | Read-only database user |
|
|
||||||
| `WIKIJS_DB_PASSWORD` | *required* | Database password |
|
|
||||||
| `WIKIJS_CHANGE_LISTENER_DEBOUNCE_SECONDS` | `5` | Debounce duration (1-60s) |
|
|
||||||
|
|
||||||
### Automated Users List
|
|
||||||
|
|
||||||
Edits by these users are filtered out to prevent loops:
|
|
||||||
|
|
||||||
```python
|
|
||||||
automated_users = [
|
|
||||||
self.settings.wikijs_username, # From WIKIJS_USERNAME
|
|
||||||
"library-desk@system",
|
|
||||||
"automation@system",
|
|
||||||
"bot@system"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Customize:** Edit `wiki_change_listener.py:134-139` to add your automation users.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
✅ **Read-only database user** - `library_desk_listener` cannot modify data
|
|
||||||
✅ **No direct database writes** - All Wiki.js updates go through API
|
|
||||||
✅ **Loop prevention** - Automated edits don't trigger re-processing
|
|
||||||
✅ **Error isolation** - Listener failures don't affect Wiki.js
|
|
||||||
✅ **Connection resilience** - asyncpg handles reconnection automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Uninstalling
|
|
||||||
|
|
||||||
To remove the change detection system:
|
|
||||||
|
|
||||||
### 1. Stop the listener:
|
|
||||||
|
|
||||||
Edit `src/main.py` and comment out lines 354-364 (listener startup).
|
|
||||||
|
|
||||||
### 2. Remove database triggers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U postgres -d wiki -c "
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_insert_trigger ON pages;
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_update_trigger ON pages;
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_delete_trigger ON pages;
|
|
||||||
DROP FUNCTION IF EXISTS notify_page_change();
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Remove read-only user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U postgres -d wiki -c "
|
|
||||||
DROP USER IF EXISTS library_desk_listener;
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
- `src/services/wiki_change_listener.py` - LISTEN service implementation
|
|
||||||
- `src/routers/webhooks.py` - Processing logic (shared)
|
|
||||||
- `src/services/consolidation_service.py` - Bidirectional entity linking
|
|
||||||
- `setup_wiki_triggers.py` - Trigger installation script
|
|
||||||
- `setup_wiki_readonly_user.sql` - Read-only user creation SQL
|
|
||||||
- `src/main.py:354-383` - Listener startup/shutdown hooks
|
|
||||||
- `src/config.py:38-49` - Configuration settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
With this system enabled:
|
|
||||||
|
|
||||||
| User Action | Processing | Loop Prevention |
|
|
||||||
|-------------|-----------|-----------------|
|
|
||||||
| Manual page edit | ✅ Processed | N/A |
|
|
||||||
| AI agent creates page | ✅ Processed | N/A |
|
|
||||||
| Entity linking updates page | ❌ Skipped | ✅ Automated user filter |
|
|
||||||
| Rapid duplicate edits | ⚡ Debounced | ✅ Timestamp check |
|
|
||||||
|
|
||||||
**Result:** User-edited and AI-generated pages are indistinguishable in the knowledge base, with zero risk of infinite loops!
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
# Wiki.js Webhook Implementation - Complete Summary
|
|
||||||
|
|
||||||
## What We Built
|
|
||||||
|
|
||||||
A comprehensive webhook system that makes **user-edited wiki pages indistinguishable from AI-generated content** in your knowledge base.
|
|
||||||
|
|
||||||
### Files Created/Modified
|
|
||||||
|
|
||||||
| File | Purpose | Lines |
|
|
||||||
|------|---------|-------|
|
|
||||||
| `src/routers/webhooks.py` | Webhook handler for all CRUD events | ~450 |
|
|
||||||
| `src/main.py` | Registered webhook router | +1 |
|
|
||||||
| `WEBHOOK_SETUP.md` | Configuration guide | Documentation |
|
|
||||||
| `WEBHOOK_IMPLEMENTATION_SUMMARY.md` | This file | Documentation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features Implemented
|
|
||||||
|
|
||||||
### ✅ 1. **Page Creation** (`page.create`)
|
|
||||||
- Triggers full ingestion pipeline
|
|
||||||
- Generates vector embeddings (Qdrant)
|
|
||||||
- Extracts entities (Neo4j)
|
|
||||||
- **Bidirectional entity linking**:
|
|
||||||
- New page links to existing entities
|
|
||||||
- Existing entity pages link back to new page
|
|
||||||
|
|
||||||
### ✅ 2. **Page Updates** (`page.update`)
|
|
||||||
- Re-ingests page with force refresh
|
|
||||||
- Updates vector embeddings
|
|
||||||
- Refreshes entity extraction
|
|
||||||
- **Re-applies bidirectional linking**
|
|
||||||
|
|
||||||
### ✅ 3. **Page Deletion** (`page.delete`)
|
|
||||||
**Comprehensive cleanup:**
|
|
||||||
- ✅ Removes vector embeddings from Qdrant
|
|
||||||
- ✅ Identifies orphaned entities (only mentioned in deleted page)
|
|
||||||
- ✅ Deletes Document node from Neo4j
|
|
||||||
- ✅ Deletes orphaned entities
|
|
||||||
- ✅ Cleans up broken `MENTIONS` relationships
|
|
||||||
- ✅ Cleans up broken `SearchQuery` relationships
|
|
||||||
|
|
||||||
### ✅ 4. **Page Rename/Move** (`page.rename`)
|
|
||||||
**Intelligent handling:**
|
|
||||||
- ✅ Updates Document node path in Neo4j
|
|
||||||
- ✅ Updates title in Neo4j
|
|
||||||
- **Path-only change** (move): Quick path update, no re-processing
|
|
||||||
- **Title change** (rename): Full re-processing + entity relinking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────┐
|
|
||||||
│ Wiki.js │ User edits page
|
|
||||||
│ (Frontend) │
|
|
||||||
└──────┬──────┘
|
|
||||||
│ HTTP POST (webhook)
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ library-desk:8089/webhooks/wikijs │
|
|
||||||
│ (Webhook Handler) │
|
|
||||||
└──────┬──────────────────────────────┘
|
|
||||||
│ Background task
|
|
||||||
↓
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ Processing Pipeline │
|
|
||||||
│ ├─ Ingestion (vectors + graph) │
|
|
||||||
│ ├─ Entity extraction │
|
|
||||||
│ ├─ Forward linking (page → entities) │
|
|
||||||
│ └─ Backward linking (entities → page) │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌───────────────────────────────────┐
|
|
||||||
│ Knowledge Base Updated │
|
|
||||||
│ ├─ Qdrant (vectors) │
|
|
||||||
│ ├─ Neo4j (graph + entities) │
|
|
||||||
│ └─ Wiki.js (entity links in MD) │
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Processing Flow
|
|
||||||
|
|
||||||
**1. Page Create/Update**
|
|
||||||
```
|
|
||||||
Webhook received → Extract user from email → Background task:
|
|
||||||
├─ Ingest page (embeddings + entities)
|
|
||||||
├─ Apply forward entity linking
|
|
||||||
├─ Find reverse references
|
|
||||||
└─ Apply backward entity linking
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Page Delete**
|
|
||||||
```
|
|
||||||
Webhook received → Background task:
|
|
||||||
├─ Remove vectors from Qdrant
|
|
||||||
├─ Find orphaned entities
|
|
||||||
├─ Delete Document node (cascades MENTIONS)
|
|
||||||
├─ Delete orphaned entities
|
|
||||||
└─ Clean broken SearchQuery relationships
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Page Rename/Move**
|
|
||||||
```
|
|
||||||
Webhook received → Check what changed:
|
|
||||||
├─ Path only? → Update Document.path
|
|
||||||
└─ Title changed? → Re-ingest + re-link entities
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Required
|
|
||||||
|
|
||||||
### Step 1: Wiki.js Webhook Setup
|
|
||||||
|
|
||||||
Navigate to **Administration** → **Webhooks** → **Add Webhook**
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
|
||||||
| Endpoint URL | `http://library-desk:8089/webhooks/wikijs` |
|
|
||||||
| Events | ☑ page.create, page.update, page.delete, page.rename |
|
|
||||||
| Authorization | `Bearer af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5` |
|
|
||||||
|
|
||||||
### Step 2: Deploy Updated Code
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd services/library-desk
|
|
||||||
docker-compose up -d --build library-desk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Verify Webhook Endpoint
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://192.168.86.149:8089/webhooks/health
|
|
||||||
|
|
||||||
# Expected response:
|
|
||||||
# {"status": "ok", "service": "webhooks"}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing the Webhook
|
|
||||||
|
|
||||||
### Test 1: Create Page
|
|
||||||
1. Create new page in Wiki.js: "Test Entity Linking"
|
|
||||||
2. Add content: "This page discusses Docker and Kubernetes orchestration"
|
|
||||||
3. Save page
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors created in Qdrant
|
|
||||||
- Document node created in Neo4j with entities: Docker, Kubernetes
|
|
||||||
- "Docker" and "Kubernetes" become hyperlinks (if entities exist)
|
|
||||||
- Pages about Docker/Kubernetes now link back to this page
|
|
||||||
|
|
||||||
### Test 2: Update Page
|
|
||||||
1. Edit the page
|
|
||||||
2. Add: "We also use Terraform for infrastructure"
|
|
||||||
3. Save
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors updated
|
|
||||||
- "Terraform" entity extracted
|
|
||||||
- "Terraform" becomes hyperlink
|
|
||||||
- Terraform entity page links back to this page
|
|
||||||
|
|
||||||
### Test 3: Delete Page
|
|
||||||
1. Delete the page from Wiki.js
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors removed from Qdrant
|
|
||||||
- Document node removed from Neo4j
|
|
||||||
- If "Test Entity Linking" was the only page mentioning an entity, that entity is deleted (orphan cleanup)
|
|
||||||
|
|
||||||
### Test 4: Move Page
|
|
||||||
1. Create page at `/tech/docker-guide`
|
|
||||||
2. Move to `/infrastructure/docker-guide`
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Document.path updated to `/infrastructure/docker-guide`
|
|
||||||
- No re-processing (vectors/entities unchanged)
|
|
||||||
- Links remain valid
|
|
||||||
|
|
||||||
### Test 5: Rename Page
|
|
||||||
1. Create page "Docker Guide"
|
|
||||||
2. Rename to "Docker Best Practices"
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Document.title updated
|
|
||||||
- Full re-processing (title is entity-relevant)
|
|
||||||
- Entity linking refreshed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Characteristics
|
|
||||||
|
|
||||||
### Response Times
|
|
||||||
- **Webhook acknowledgment**: <100ms (non-blocking)
|
|
||||||
- **Background processing**: 2-10s depending on page size
|
|
||||||
- **No impact on Wiki.js editing experience**
|
|
||||||
|
|
||||||
### Resource Usage
|
|
||||||
Per page edit:
|
|
||||||
- **Ollama**: 1-2 embedding calls (~500ms each)
|
|
||||||
- **Neo4j**: 3-5 queries (~100ms total)
|
|
||||||
- **Qdrant**: 1 upsert operation (~50ms)
|
|
||||||
- **Wiki.js**: 1-N page updates for backward links
|
|
||||||
|
|
||||||
### Scaling Considerations
|
|
||||||
- Background tasks run asynchronously (non-blocking)
|
|
||||||
- For bulk operations (>100 pages), consider:
|
|
||||||
1. Temporarily disable webhook
|
|
||||||
2. Use batch ingestion endpoint
|
|
||||||
3. Re-enable webhook after bulk import
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring & Debugging
|
|
||||||
|
|
||||||
### Check Webhook Activity
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Watch webhook logs
|
|
||||||
docker logs library-desk --follow | grep webhook
|
|
||||||
|
|
||||||
# Look for:
|
|
||||||
# - "Received Wiki.js webhook: page.create"
|
|
||||||
# - "Processing page.update for page 123"
|
|
||||||
# - "Entity linking complete: 5 forward links, 3 backward links"
|
|
||||||
# - "Cleanup complete for deleted page 456"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify Processing Complete
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if page was indexed
|
|
||||||
curl -X POST http://192.168.86.149:8089/query/hybrid \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"query": "your page content", "user": "jpmschweitzer"}'
|
|
||||||
|
|
||||||
# Check graph entities
|
|
||||||
# In Neo4j Browser:
|
|
||||||
MATCH (d:Document {page_id: 123})-[:MENTIONS]->(e)
|
|
||||||
RETURN d.title, collect(e.name) as entities
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Issues & Solutions
|
|
||||||
|
|
||||||
**Problem:** Webhook not triggering
|
|
||||||
- ✅ Check Wiki.js webhook configuration
|
|
||||||
- ✅ Verify library-desk is running
|
|
||||||
- ✅ Check authorization header
|
|
||||||
|
|
||||||
**Problem:** Processing fails silently
|
|
||||||
- ✅ Check library-desk logs: `docker logs library-desk`
|
|
||||||
- ✅ Verify Neo4j/Qdrant/Ollama are accessible
|
|
||||||
- ✅ Check user email mapping in `extract_user_from_email()`
|
|
||||||
|
|
||||||
**Problem:** Orphaned entities remain after deletion
|
|
||||||
- ✅ Orphan detection only removes entities with ZERO `MENTIONS` relationships
|
|
||||||
- ✅ If entity is mentioned in other pages, it's preserved (correct behavior)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Advanced Customization
|
|
||||||
|
|
||||||
### 1. Custom User Mapping
|
|
||||||
|
|
||||||
Edit `src/routers/webhooks.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def extract_user_from_email(email: str) -> str:
|
|
||||||
"""Map Wiki.js user emails to library-desk users"""
|
|
||||||
user_map = {
|
|
||||||
"admin@example.com": "admin",
|
|
||||||
"john@example.com": "jpmschweitzer"
|
|
||||||
}
|
|
||||||
return user_map.get(email, email.split("@")[0])
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Selective Processing
|
|
||||||
|
|
||||||
Process only specific paths:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In handle_wikijs_webhook():
|
|
||||||
page_path = payload.page.get("path", "")
|
|
||||||
if not page_path.startswith("/tech/"):
|
|
||||||
logger.info(f"Skipping page outside /tech/")
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Disable Backward Linking
|
|
||||||
|
|
||||||
To save processing time, disable backward linking:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In process_wiki_page_change():
|
|
||||||
# Comment out:
|
|
||||||
# link_stats = await consolidation_service._apply_bidirectional_entity_linking(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
✅ **User-edited pages** have vectors in Qdrant
|
|
||||||
✅ **User-edited pages** have entities in Neo4j
|
|
||||||
✅ **User-edited pages** have bidirectional entity links
|
|
||||||
✅ **Deleted pages** are cleaned from vectors + graph
|
|
||||||
✅ **Renamed/moved pages** update correctly
|
|
||||||
✅ **No difference** between manual and AI-generated pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Deploy the code** (restart library-desk)
|
|
||||||
2. **Configure Wiki.js webhook** (Administration → Webhooks)
|
|
||||||
3. **Test with a sample page** (create, edit, delete)
|
|
||||||
4. **Monitor logs** (verify processing completes)
|
|
||||||
5. **Verify results** (check vectors, graph, entity links)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
**Before webhooks:**
|
|
||||||
- Only AI-generated pages fully integrated
|
|
||||||
- Manual edits not indexed/linked
|
|
||||||
- Inconsistent knowledge base
|
|
||||||
|
|
||||||
**After webhooks:**
|
|
||||||
- **All pages processed identically**
|
|
||||||
- **Automatic entity extraction**
|
|
||||||
- **Bidirectional linking**
|
|
||||||
- **Clean deletion handling**
|
|
||||||
- **Rename/move support**
|
|
||||||
|
|
||||||
**Result:** A unified, consistent knowledge base regardless of content source! 🎉
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
# Wiki.js Webhook Setup Guide
|
|
||||||
|
|
||||||
Complete guide to enable automatic processing of user-edited wiki pages.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
When you manually edit pages in Wiki.js, the webhook system ensures they're processed identically to AI-generated content:
|
|
||||||
|
|
||||||
- ✅ **Vectors updated** - Qdrant embeddings refreshed
|
|
||||||
- ✅ **Graph updated** - Entities extracted, relationships created
|
|
||||||
- ✅ **Bidirectional links** - Automatic entity linking both ways
|
|
||||||
- ✅ **Deletions handled** - Clean removal from vectors + graph
|
|
||||||
- ✅ **Moves processed** - Path updates propagated to graph
|
|
||||||
|
|
||||||
## Step 1: Configure Wiki.js Webhook
|
|
||||||
|
|
||||||
### Access Wiki.js Administration
|
|
||||||
|
|
||||||
1. Log into Wiki.js as administrator
|
|
||||||
2. Navigate to **Administration** → **Webhooks**
|
|
||||||
3. Click **Add New Webhook**
|
|
||||||
|
|
||||||
### Webhook Configuration
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|-------|-------|
|
|
||||||
| **Name** | Library Desk Integration |
|
|
||||||
| **Endpoint URL** | `http://library-desk:8089/webhooks/wikijs` |
|
|
||||||
| **Content Type** | `application/json` |
|
|
||||||
| **Events** | ☑ page.create<br>☑ page.update<br>☑ page.delete<br>☑ page.rename |
|
|
||||||
| **Status** | ✅ Active |
|
|
||||||
|
|
||||||
### Authentication Header
|
|
||||||
|
|
||||||
Add custom header for API authentication:
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5
|
|
||||||
```
|
|
||||||
|
|
||||||
*(Use your actual Library Desk API key from `.env`)*
|
|
||||||
|
|
||||||
## Step 2: Verify Webhook Endpoint
|
|
||||||
|
|
||||||
Test the webhook health endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://192.168.86.149:8089/webhooks/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"service": "webhooks"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Test Webhook Integration
|
|
||||||
|
|
||||||
### Test 1: Page Creation
|
|
||||||
|
|
||||||
1. Create a new page in Wiki.js
|
|
||||||
2. Add content mentioning existing entities (e.g., "Docker", "Kubernetes")
|
|
||||||
3. Save the page
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors created in Qdrant
|
|
||||||
- Document node created in Neo4j
|
|
||||||
- Entities extracted and linked
|
|
||||||
- Bidirectional links added (new page links to entities, entities' pages link back)
|
|
||||||
|
|
||||||
### Test 2: Page Update
|
|
||||||
|
|
||||||
1. Edit an existing page
|
|
||||||
2. Add new content with entity mentions
|
|
||||||
3. Save changes
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors updated in Qdrant (old removed, new added)
|
|
||||||
- Graph updated with new entities
|
|
||||||
- Entity links refreshed
|
|
||||||
|
|
||||||
### Test 3: Page Deletion
|
|
||||||
|
|
||||||
1. Delete a page from Wiki.js
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Vectors removed from Qdrant
|
|
||||||
- Document node removed from Neo4j
|
|
||||||
- Orphaned entities cleaned up (entities only mentioned in this page)
|
|
||||||
- Broken relationships removed
|
|
||||||
|
|
||||||
### Test 4: Page Move/Rename
|
|
||||||
|
|
||||||
1. Move a page to different location (e.g., `/tech/docker` → `/infrastructure/docker`)
|
|
||||||
2. Or rename the page
|
|
||||||
|
|
||||||
**Expected Result:**
|
|
||||||
- Document node path updated in Neo4j
|
|
||||||
- Entity links updated if title changed
|
|
||||||
- Vectors remain valid (no re-embedding needed unless content changed)
|
|
||||||
|
|
||||||
## Event Processing Details
|
|
||||||
|
|
||||||
### page.create
|
|
||||||
|
|
||||||
```
|
|
||||||
User creates page → Webhook → Library Desk
|
|
||||||
├─ Ingest page (vector + graph)
|
|
||||||
├─ Extract entities
|
|
||||||
├─ Forward linking (page → entities)
|
|
||||||
└─ Backward linking (entities → page)
|
|
||||||
```
|
|
||||||
|
|
||||||
### page.update
|
|
||||||
|
|
||||||
```
|
|
||||||
User edits page → Webhook → Library Desk
|
|
||||||
├─ Re-ingest page (force refresh)
|
|
||||||
├─ Update entities
|
|
||||||
├─ Refresh forward links
|
|
||||||
└─ Refresh backward links
|
|
||||||
```
|
|
||||||
|
|
||||||
### page.delete
|
|
||||||
|
|
||||||
```
|
|
||||||
User deletes page → Webhook → Library Desk
|
|
||||||
├─ Remove vectors from Qdrant
|
|
||||||
├─ Find orphaned entities
|
|
||||||
├─ Delete Document node
|
|
||||||
├─ Delete orphaned entities
|
|
||||||
└─ Clean broken relationships
|
|
||||||
```
|
|
||||||
|
|
||||||
### page.rename
|
|
||||||
|
|
||||||
```
|
|
||||||
User moves/renames page → Webhook → Library Desk
|
|
||||||
├─ Update Document node path
|
|
||||||
├─ Update title if changed
|
|
||||||
├─ Re-link entities if title changed
|
|
||||||
└─ Update references
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring Webhook Activity
|
|
||||||
|
|
||||||
### Check Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Library Desk logs
|
|
||||||
docker logs library-desk --follow | grep webhook
|
|
||||||
|
|
||||||
# Look for:
|
|
||||||
# - "Received Wiki.js webhook: page.create"
|
|
||||||
# - "Processing page.update for page 123"
|
|
||||||
# - "Cleanup complete for deleted page 456"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify Processing
|
|
||||||
|
|
||||||
After making changes in Wiki.js, verify:
|
|
||||||
|
|
||||||
**1. Vectors Updated:**
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.86.149:8089/query/hybrid \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"query": "your page content", "user": "jpmschweitzer"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Graph Updated:**
|
|
||||||
```cypher
|
|
||||||
// In Neo4j Browser
|
|
||||||
MATCH (d:Document {page_id: 123})
|
|
||||||
OPTIONAL MATCH (d)-[:MENTIONS]->(e)
|
|
||||||
RETURN d, collect(e.name) as entities
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Entity Links Added:**
|
|
||||||
Visit the page in Wiki.js and verify entity names are hyperlinked.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Webhook Not Triggering
|
|
||||||
|
|
||||||
**Problem:** Changes in Wiki.js don't trigger processing
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check webhook configuration in Wiki.js Admin
|
|
||||||
2. Verify endpoint URL is correct: `http://library-desk:8089/webhooks/wikijs`
|
|
||||||
3. Check authorization header is set
|
|
||||||
4. Verify library-desk is running: `docker ps | grep library-desk`
|
|
||||||
5. Check library-desk logs: `docker logs library-desk --tail 50`
|
|
||||||
|
|
||||||
### Processing Failures
|
|
||||||
|
|
||||||
**Problem:** Webhook triggers but processing fails
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check library-desk logs for errors
|
|
||||||
2. Verify Neo4j is accessible
|
|
||||||
3. Verify Qdrant is accessible
|
|
||||||
4. Check Ollama is running (for embeddings)
|
|
||||||
5. Verify user mapping in `extract_user_from_email()`
|
|
||||||
|
|
||||||
### Orphaned Entities
|
|
||||||
|
|
||||||
**Problem:** Deleted pages leave entities behind
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
Manually clean orphaned entities:
|
|
||||||
```cypher
|
|
||||||
// Find orphaned entities (no MENTIONS relationships)
|
|
||||||
MATCH (e:User_Jpmschweitzer)
|
|
||||||
WHERE NOT e:Document
|
|
||||||
AND NOT EXISTS {(d:Document)-[:MENTIONS]->(e)}
|
|
||||||
DETACH DELETE e
|
|
||||||
RETURN count(e) as deleted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Broken Links
|
|
||||||
|
|
||||||
**Problem:** Entity links broken after page moves
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
Re-run entity linking on all pages:
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.86.149:8089/entity-linking/link-all \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"user": "jpmschweitzer"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Background Processing
|
|
||||||
|
|
||||||
Webhook handler processes events in the background to avoid blocking Wiki.js:
|
|
||||||
- Webhook response: <100ms (immediate acknowledgment)
|
|
||||||
- Processing: 2-10s (runs asynchronously)
|
|
||||||
- No impact on Wiki.js editing experience
|
|
||||||
|
|
||||||
### Rate Limiting
|
|
||||||
|
|
||||||
For bulk operations (importing many pages):
|
|
||||||
- Consider temporarily disabling webhook
|
|
||||||
- Use batch ingestion endpoint instead: `/ingest/batch`
|
|
||||||
- Re-enable webhook after bulk import
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Custom User Mapping
|
|
||||||
|
|
||||||
By default, user is extracted from email (`user@domain` → `user`).
|
|
||||||
|
|
||||||
Customize in `/services/library-desk/src/routers/webhooks.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def extract_user_from_email(email: str) -> str:
|
|
||||||
# Option 1: Map specific emails to users
|
|
||||||
user_map = {
|
|
||||||
"admin@example.com": "admin",
|
|
||||||
"john.doe@example.com": "jpmschweitzer"
|
|
||||||
}
|
|
||||||
return user_map.get(email, email.split("@")[0])
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Filtering
|
|
||||||
|
|
||||||
To process only certain pages, add filtering in webhook handler:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Only process pages in /tech/ path
|
|
||||||
if not payload.page.get("path", "").startswith("/tech/"):
|
|
||||||
logger.info(f"Skipping page outside /tech/: {payload.page.get('path')}")
|
|
||||||
return
|
|
||||||
```
|
|
||||||
|
|
||||||
### Disable Bidirectional Linking
|
|
||||||
|
|
||||||
To only do forward linking (page → entities) without backward linking:
|
|
||||||
|
|
||||||
Edit `process_wiki_page_change()`:
|
|
||||||
```python
|
|
||||||
# Comment out the bidirectional linking call
|
|
||||||
# link_stats = await consolidation_service._apply_bidirectional_entity_linking(...)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
With webhooks configured:
|
|
||||||
|
|
||||||
| User Action | Automatic Processing |
|
|
||||||
|-------------|---------------------|
|
|
||||||
| Create page | Ingest + entity link (bidirectional) |
|
|
||||||
| Edit page | Re-ingest + refresh links |
|
|
||||||
| Delete page | Clean vectors + graph + orphaned entities |
|
|
||||||
| Move page | Update paths + refresh links |
|
|
||||||
|
|
||||||
**Result:** User-edited and AI-generated pages are **indistinguishable** in the knowledge base!
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
# Wiki.js Page Change Detection
|
|
||||||
|
|
||||||
Automatic processing system for user-edited Wiki.js pages using PostgreSQL database triggers.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Problem:** Wiki.js doesn't have built-in webhooks (feature is [planned but not released](https://requarks.canny.io/wiki/p/webhooks))
|
|
||||||
|
|
||||||
**Solution:** PostgreSQL NOTIFY/LISTEN triggers to detect page changes in real-time
|
|
||||||
|
|
||||||
When you manually edit pages in Wiki.js, the change detection system ensures they're processed identically to AI-generated content:
|
|
||||||
|
|
||||||
- ✅ **Vectors updated** - Qdrant embeddings refreshed
|
|
||||||
- ✅ **Graph updated** - Entities extracted, relationships created
|
|
||||||
- ✅ **Bidirectional links** - Automatic entity linking both ways
|
|
||||||
- ✅ **Deletions handled** - Clean removal from vectors + graph
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Wiki.js (User edits page) │
|
|
||||||
└──────────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ PostgreSQL "pages" table │
|
|
||||||
│ INSERT/UPDATE/DELETE triggers fire │
|
|
||||||
└──────────────────┬──────────────────────────┘
|
|
||||||
│ pg_notify('wiki_page_changes', ...)
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ library-desk WikiChangeListener │
|
|
||||||
│ Receives notification via LISTEN │
|
|
||||||
└──────────────────┬──────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Processing Pipeline (same as webhooks) │
|
|
||||||
│ ├─ Ingest page (vectors + graph) │
|
|
||||||
│ ├─ Extract entities │
|
|
||||||
│ ├─ Forward linking (page → entities) │
|
|
||||||
│ └─ Backward linking (entities → page) │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Components:**
|
|
||||||
|
|
||||||
1. **PostgreSQL Triggers** - Installed in Wiki.js database, emit NOTIFY events on page changes
|
|
||||||
2. **WikiChangeListener** - Python service that LISTENs for notifications and processes changes
|
|
||||||
3. **Processing Pipeline** - Same logic as webhook handler (uses `process_wiki_page_change()`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Library-desk has network access to Wiki.js PostgreSQL database
|
|
||||||
- Database user has `CREATE FUNCTION` and `CREATE TRIGGER` permissions
|
|
||||||
|
|
||||||
### Step 1: Configure Environment Variables
|
|
||||||
|
|
||||||
Add these to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Wiki.js Database Configuration (for change detection)
|
|
||||||
WIKIJS_DB_HOST=postgres # PostgreSQL host (usually same as Wiki.js)
|
|
||||||
WIKIJS_DB_PORT=5432 # PostgreSQL port
|
|
||||||
WIKIJS_DB_NAME=wiki # Wiki.js database name
|
|
||||||
WIKIJS_DB_USER=wikijs # Database user (needs trigger permissions)
|
|
||||||
WIKIJS_DB_PASSWORD=your_password # Database password
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Install Database Triggers
|
|
||||||
|
|
||||||
Run the setup script from within the library-desk container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enter the container
|
|
||||||
docker exec -it library-desk bash
|
|
||||||
|
|
||||||
# Run trigger setup script
|
|
||||||
cd /app
|
|
||||||
python3 setup_wiki_triggers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected output:**
|
|
||||||
```
|
|
||||||
======================================================================
|
|
||||||
Wiki.js Database Trigger Setup
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
Connecting to Wiki.js database at postgres:5432
|
|
||||||
Database: wiki
|
|
||||||
User: wikijs
|
|
||||||
|
|
||||||
✓ Connected to Wiki.js database
|
|
||||||
|
|
||||||
Installing triggers...
|
|
||||||
|
|
||||||
✓ Triggers installed successfully:
|
|
||||||
|
|
||||||
- wiki_page_delete_trigger (DELETE)
|
|
||||||
- wiki_page_insert_trigger (INSERT)
|
|
||||||
- wiki_page_update_trigger (UPDATE)
|
|
||||||
|
|
||||||
======================================================================
|
|
||||||
Setup complete!
|
|
||||||
======================================================================
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Restart library-desk service to activate the listener
|
|
||||||
2. Edit a page in Wiki.js to test
|
|
||||||
3. Check library-desk logs for processing messages
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Restart Library-Desk
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker restart library-desk
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check logs for successful startup:**
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --tail 50 | grep -E "(Wiki.js change listener|wiki_page_changes)"
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see:
|
|
||||||
```
|
|
||||||
INFO - Starting Wiki.js database change listener
|
|
||||||
INFO - Listening for Wiki.js page changes via PostgreSQL NOTIFY
|
|
||||||
INFO - Wiki.js change listener started successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Test the System
|
|
||||||
|
|
||||||
1. **Create a new page in Wiki.js**
|
|
||||||
- Add content mentioning existing entities (e.g., "Docker", "Kubernetes")
|
|
||||||
- Save the page
|
|
||||||
|
|
||||||
2. **Check library-desk logs:**
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --follow | grep -E "(Received|Processing|Entity linking)"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected log output:**
|
|
||||||
```
|
|
||||||
INFO - Received INSERT notification for page 123 by user@example.com
|
|
||||||
INFO - Processing page.create for page 123 ('Test Page')
|
|
||||||
INFO - Ingesting page 123 into knowledge base
|
|
||||||
INFO - Ingestion complete for page 123
|
|
||||||
INFO - Applying bidirectional entity linking for page 123
|
|
||||||
INFO - Entity linking complete for page 123: 5 forward links, 3 backward links (2 pages updated)
|
|
||||||
INFO - Successfully processed page.create for page 123
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify results:**
|
|
||||||
- Check vectors in Qdrant
|
|
||||||
- Check Neo4j for Document node and entities
|
|
||||||
- Visit the page in Wiki.js - entities should be hyperlinked
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event Processing Details
|
|
||||||
|
|
||||||
### page.create (INSERT trigger)
|
|
||||||
|
|
||||||
```
|
|
||||||
User creates page → PostgreSQL INSERT trigger fires
|
|
||||||
├─ pg_notify('wiki_page_changes', 'INSERT:123:user@example.com')
|
|
||||||
├─ WikiChangeListener receives notification
|
|
||||||
├─ Ingest page (vector + graph)
|
|
||||||
├─ Extract entities
|
|
||||||
├─ Forward linking (page → entities)
|
|
||||||
└─ Backward linking (entities → page)
|
|
||||||
```
|
|
||||||
|
|
||||||
### page.update (UPDATE trigger)
|
|
||||||
|
|
||||||
```
|
|
||||||
User edits page → PostgreSQL UPDATE trigger fires
|
|
||||||
├─ pg_notify('wiki_page_changes', 'UPDATE:123:user@example.com')
|
|
||||||
├─ WikiChangeListener receives notification
|
|
||||||
├─ Re-ingest page (force refresh)
|
|
||||||
├─ Update entities
|
|
||||||
├─ Refresh forward links
|
|
||||||
└─ Refresh backward links
|
|
||||||
```
|
|
||||||
|
|
||||||
### page.delete (DELETE trigger)
|
|
||||||
|
|
||||||
```
|
|
||||||
User deletes page → PostgreSQL DELETE trigger fires
|
|
||||||
├─ pg_notify('wiki_page_changes', 'DELETE:123:user@example.com')
|
|
||||||
├─ WikiChangeListener receives notification
|
|
||||||
├─ Remove vectors from Qdrant
|
|
||||||
├─ Find orphaned entities
|
|
||||||
├─ Delete Document node
|
|
||||||
├─ Delete orphaned entities
|
|
||||||
└─ Clean broken relationships
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Check Listener Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if listener is running
|
|
||||||
docker logs library-desk --tail 50 | grep "Wiki.js change listener"
|
|
||||||
|
|
||||||
# Should show:
|
|
||||||
# INFO - Wiki.js change listener started successfully
|
|
||||||
# INFO - Listening for Wiki.js page changes via PostgreSQL NOTIFY
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitor Page Changes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Watch for page change notifications
|
|
||||||
docker logs library-desk --follow | grep -E "(Received.*notification|Processing page)"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verify Trigger Installation
|
|
||||||
|
|
||||||
Connect to Wiki.js database and check:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- List installed triggers
|
|
||||||
SELECT trigger_name, event_manipulation, event_object_table
|
|
||||||
FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages'
|
|
||||||
ORDER BY trigger_name;
|
|
||||||
|
|
||||||
-- Expected result:
|
|
||||||
-- wiki_page_delete_trigger | DELETE | pages
|
|
||||||
-- wiki_page_insert_trigger | INSERT | pages
|
|
||||||
-- wiki_page_update_trigger | UPDATE | pages
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Notification Manually
|
|
||||||
|
|
||||||
You can manually test the NOTIFY/LISTEN system:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- In one session, listen for notifications:
|
|
||||||
LISTEN wiki_page_changes;
|
|
||||||
|
|
||||||
-- In another session, manually trigger:
|
|
||||||
NOTIFY wiki_page_changes, 'TEST:999:test@example.com';
|
|
||||||
|
|
||||||
-- The first session should receive the notification
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Change Listener Not Starting
|
|
||||||
|
|
||||||
**Problem:** Logs show "Failed to start Wiki.js change listener"
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Check database connection settings in `.env`
|
|
||||||
2. Verify library-desk can reach Wiki.js PostgreSQL:
|
|
||||||
```bash
|
|
||||||
docker exec library-desk ping postgres
|
|
||||||
```
|
|
||||||
3. Test database credentials:
|
|
||||||
```bash
|
|
||||||
docker exec library-desk psql -h postgres -U wikijs -d wiki -c "\dt"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changes Not Being Processed
|
|
||||||
|
|
||||||
**Problem:** Pages edited but no processing logs appear
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. **Check triggers are installed:**
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U wikijs -d wiki -c "
|
|
||||||
SELECT trigger_name FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Verify listener is running:**
|
|
||||||
```bash
|
|
||||||
docker logs library-desk | grep "Listening for Wiki.js page changes"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test notification manually:**
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U wikijs -d wiki -c "
|
|
||||||
NOTIFY wiki_page_changes, 'INSERT:123:test@example.com';
|
|
||||||
"
|
|
||||||
```
|
|
||||||
Check library-desk logs for "Received INSERT notification"
|
|
||||||
|
|
||||||
4. **Check authorEmail field exists:**
|
|
||||||
The triggers use `authorEmail` field from Wiki.js pages table. Verify:
|
|
||||||
```sql
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'pages' AND column_name = 'authorEmail';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trigger Permission Denied
|
|
||||||
|
|
||||||
**Problem:** Setup script fails with "permission denied to create trigger"
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. Grant necessary permissions to database user:
|
|
||||||
```sql
|
|
||||||
GRANT CREATE ON DATABASE wiki TO wikijs;
|
|
||||||
ALTER TABLE pages OWNER TO wikijs;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Or run setup script with database admin user:
|
|
||||||
```bash
|
|
||||||
# Modify WIKIJS_DB_USER temporarily to postgres
|
|
||||||
WIKIJS_DB_USER=postgres python3 setup_wiki_triggers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Processing Errors
|
|
||||||
|
|
||||||
**Problem:** Notification received but processing fails
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
|
|
||||||
1. Check library-desk logs for specific error:
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --tail 100 | grep -A 10 "Failed to handle notification"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Verify user mapping works:
|
|
||||||
- Triggers extract user from `authorEmail` field
|
|
||||||
- Check `extract_user_from_email()` in `wiki_change_listener.py`
|
|
||||||
- Default: `user@domain.com` → `user`
|
|
||||||
|
|
||||||
3. Ensure page_id exists and is accessible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Mapping
|
|
||||||
|
|
||||||
By default, the user is extracted from the Wiki.js `authorEmail` field:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# user@example.com → user
|
|
||||||
user = user_email.split('@')[0]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Custom mapping:** Edit `src/services/wiki_change_listener.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def _handle_notification(self, connection, pid, channel, payload):
|
|
||||||
# ... existing code ...
|
|
||||||
|
|
||||||
# Custom user mapping
|
|
||||||
user_map = {
|
|
||||||
"admin@example.com": "admin",
|
|
||||||
"john.doe@example.com": "jpmschweitzer"
|
|
||||||
}
|
|
||||||
user = user_map.get(user_email, user_email.split('@')[0])
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparison: Webhooks vs Database Triggers
|
|
||||||
|
|
||||||
| Feature | Webhooks (Not Available) | PostgreSQL Triggers |
|
|
||||||
|---------|-------------------------|---------------------|
|
|
||||||
| **Availability** | Planned, not released | Available now |
|
|
||||||
| **Setup** | Simple (UI configuration) | Requires database access |
|
|
||||||
| **Reliability** | HTTP-based, can fail | Database-native, highly reliable |
|
|
||||||
| **Performance** | Network overhead | Minimal overhead |
|
|
||||||
| **Maintenance** | None | Must survive database migrations |
|
|
||||||
| **Isolation** | Loose coupling | Tight coupling to database |
|
|
||||||
|
|
||||||
**Recommendation:** Use database triggers until Wiki.js webhooks are officially released. When webhooks become available, migrate to that approach for better isolation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Uninstalling
|
|
||||||
|
|
||||||
To remove the triggers (if switching to webhooks later):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it postgres psql -U wikijs -d wiki -c "
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_insert_trigger ON pages;
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_update_trigger ON pages;
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_delete_trigger ON pages;
|
|
||||||
DROP FUNCTION IF EXISTS notify_page_change();
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then remove the listener startup from `src/main.py` (lines 354-364).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
With database triggers configured:
|
|
||||||
|
|
||||||
| User Action | Automatic Processing |
|
|
||||||
|-------------|---------------------:|
|
|
||||||
| Create page | Ingest + entity link (bidirectional) |
|
|
||||||
| Edit page | Re-ingest + refresh links |
|
|
||||||
| Delete page | Clean vectors + graph + orphaned entities |
|
|
||||||
|
|
||||||
**Result:** User-edited and AI-generated pages are **indistinguishable** in the knowledge base!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
- `src/services/wiki_change_listener.py` - LISTEN service implementation
|
|
||||||
- `src/routers/webhooks.py` - Processing logic (shared with future webhook approach)
|
|
||||||
- `src/services/consolidation_service.py` - Bidirectional entity linking
|
|
||||||
- `setup_wiki_triggers.py` - Trigger installation script
|
|
||||||
- `src/main.py` - Application startup/shutdown hooks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future: Migration to Webhooks
|
|
||||||
|
|
||||||
When Wiki.js webhooks are released:
|
|
||||||
|
|
||||||
1. Configure webhook in Wiki.js admin panel
|
|
||||||
2. Point to `http://library-desk:8089/webhooks/wikijs`
|
|
||||||
3. Remove database triggers (see Uninstalling section)
|
|
||||||
4. Disable listener startup in `src/main.py`
|
|
||||||
|
|
||||||
The webhook handler already exists and uses the same processing logic!
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Cleanup script to remove all test data from Neo4j graph database.
|
|
||||||
This prepares the system for production by removing:
|
|
||||||
- SearchQuery nodes (HybridRAG search history)
|
|
||||||
- WebResult nodes (external web search results)
|
|
||||||
- Any orphaned nodes without relationships
|
|
||||||
"""
|
|
||||||
import requests
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
BASE_URL = "http://192.168.86.149:8089"
|
|
||||||
API_KEY = "af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5"
|
|
||||||
USER = "jpmschweitzer"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {API_KEY}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
def execute_query(query_text, description):
|
|
||||||
"""Execute a Cypher query via API."""
|
|
||||||
url = f"{BASE_URL}/graph/query"
|
|
||||||
payload = {
|
|
||||||
"user": USER,
|
|
||||||
"query": query_text
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f"🔄 {description}...")
|
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
print(f" ❌ Failed: {response.status_code}")
|
|
||||||
print(f" {response.text}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
return data
|
|
||||||
|
|
||||||
def count_nodes(node_type):
|
|
||||||
"""Count nodes of a specific type."""
|
|
||||||
if node_type == "SearchQuery":
|
|
||||||
query = f'MATCH (n:SearchQuery) WHERE n.user = "{USER}" RETURN count(n) as count'
|
|
||||||
elif node_type == "WebResult":
|
|
||||||
query = f'MATCH (n:User_{USER}_WebResult:WebResult) RETURN count(n) as count'
|
|
||||||
elif node_type == "Document":
|
|
||||||
query = f'MATCH (n:User_{USER}_Document:Document) RETURN count(n) as count'
|
|
||||||
else:
|
|
||||||
query = f'MATCH (n:User_{USER}) WHERE NOT n:Document AND NOT n:SearchQuery AND NOT n:WebResult RETURN count(n) as count'
|
|
||||||
|
|
||||||
result = execute_query(query, f"Counting {node_type} nodes")
|
|
||||||
if result and result.get("results"):
|
|
||||||
return result["results"][0]["count"]
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def delete_search_queries():
|
|
||||||
"""Delete all SearchQuery nodes and their relationships."""
|
|
||||||
query = f'''
|
|
||||||
MATCH (sq:SearchQuery)
|
|
||||||
WHERE sq.user = "{USER}"
|
|
||||||
DETACH DELETE sq
|
|
||||||
RETURN count(*) as deleted
|
|
||||||
'''
|
|
||||||
|
|
||||||
result = execute_query(query, "Deleting SearchQuery nodes")
|
|
||||||
if result and result.get("results"):
|
|
||||||
return result["results"][0].get("deleted", 0)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def delete_web_results():
|
|
||||||
"""Delete all WebResult nodes."""
|
|
||||||
query = f'''
|
|
||||||
MATCH (wr:User_{USER}_WebResult:WebResult)
|
|
||||||
DETACH DELETE wr
|
|
||||||
RETURN count(*) as deleted
|
|
||||||
'''
|
|
||||||
|
|
||||||
result = execute_query(query, "Deleting WebResult nodes")
|
|
||||||
if result and result.get("results"):
|
|
||||||
return result["results"][0].get("deleted", 0)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def delete_orphaned_nodes():
|
|
||||||
"""Delete any orphaned entity nodes without relationships."""
|
|
||||||
query = f'''
|
|
||||||
MATCH (n:User_{USER})
|
|
||||||
WHERE NOT n:Document
|
|
||||||
AND NOT n:SearchQuery
|
|
||||||
AND NOT n:WebResult
|
|
||||||
AND NOT (n)--()
|
|
||||||
DETACH DELETE n
|
|
||||||
RETURN count(*) as deleted
|
|
||||||
'''
|
|
||||||
|
|
||||||
result = execute_query(query, "Deleting orphaned nodes")
|
|
||||||
if result and result.get("results"):
|
|
||||||
return result["results"][0].get("deleted", 0)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def get_all_node_stats():
|
|
||||||
"""Get statistics on all node types."""
|
|
||||||
query = f'''
|
|
||||||
MATCH (n:User_{USER})
|
|
||||||
WITH labels(n) as labels, count(n) as count
|
|
||||||
RETURN labels, count
|
|
||||||
ORDER BY count DESC
|
|
||||||
'''
|
|
||||||
|
|
||||||
result = execute_query(query, "Getting node statistics")
|
|
||||||
if result and result.get("results"):
|
|
||||||
return result["results"]
|
|
||||||
return []
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 70)
|
|
||||||
print("🧹 Neo4j Graph Database Cleanup Script")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Show current state
|
|
||||||
print("📊 Current Database State:")
|
|
||||||
print("-" * 70)
|
|
||||||
|
|
||||||
search_count = count_nodes("SearchQuery")
|
|
||||||
web_count = count_nodes("WebResult")
|
|
||||||
doc_count = count_nodes("Document")
|
|
||||||
other_count = count_nodes("Other")
|
|
||||||
|
|
||||||
print(f" SearchQuery nodes: {search_count}")
|
|
||||||
print(f" WebResult nodes: {web_count}")
|
|
||||||
print(f" Document nodes: {doc_count}")
|
|
||||||
print(f" Other entity nodes: {other_count}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if search_count == 0 and web_count == 0 and doc_count == 0 and other_count == 0:
|
|
||||||
print("✨ Database is already clean!")
|
|
||||||
return
|
|
||||||
|
|
||||||
total_to_delete = search_count + web_count + other_count
|
|
||||||
if total_to_delete == 0 and doc_count > 0:
|
|
||||||
print("⚠️ Only Document nodes found (legitimate data)")
|
|
||||||
print(" No cleanup needed!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Ask for confirmation
|
|
||||||
print("⚠️ WARNING: About to delete:")
|
|
||||||
if search_count > 0:
|
|
||||||
print(f" - {search_count} SearchQuery nodes (search history)")
|
|
||||||
if web_count > 0:
|
|
||||||
print(f" - {web_count} WebResult nodes (cached web results)")
|
|
||||||
if other_count > 0:
|
|
||||||
print(f" - {other_count} orphaned entity nodes")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(" Document nodes will NOT be deleted (they represent wiki pages)")
|
|
||||||
print()
|
|
||||||
|
|
||||||
response = input("Continue with cleanup? (yes/no): ").strip().lower()
|
|
||||||
if response != "yes":
|
|
||||||
print("❌ Cancelled")
|
|
||||||
return
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("🚀 Starting cleanup...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Delete SearchQuery nodes
|
|
||||||
if search_count > 0:
|
|
||||||
deleted_sq = delete_search_queries()
|
|
||||||
print(f" ✅ Deleted {deleted_sq} SearchQuery nodes")
|
|
||||||
|
|
||||||
# Delete WebResult nodes
|
|
||||||
if web_count > 0:
|
|
||||||
deleted_wr = delete_web_results()
|
|
||||||
print(f" ✅ Deleted {deleted_wr} WebResult nodes")
|
|
||||||
|
|
||||||
# Delete orphaned nodes
|
|
||||||
if other_count > 0:
|
|
||||||
deleted_orphans = delete_orphaned_nodes()
|
|
||||||
print(f" ✅ Deleted {deleted_orphans} orphaned nodes")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 70)
|
|
||||||
print("📊 Final Database State")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
# Show final state
|
|
||||||
search_count_final = count_nodes("SearchQuery")
|
|
||||||
web_count_final = count_nodes("WebResult")
|
|
||||||
doc_count_final = count_nodes("Document")
|
|
||||||
other_count_final = count_nodes("Other")
|
|
||||||
|
|
||||||
print(f" SearchQuery nodes: {search_count_final}")
|
|
||||||
print(f" WebResult nodes: {web_count_final}")
|
|
||||||
print(f" Document nodes: {doc_count_final}")
|
|
||||||
print(f" Other entity nodes: {other_count_final}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if search_count_final == 0 and web_count_final == 0 and other_count_final == 0:
|
|
||||||
print("✨ Graph database is now production-ready!")
|
|
||||||
print(" Only legitimate wiki-derived data remains.")
|
|
||||||
else:
|
|
||||||
print("⚠️ Some nodes remain - this might be expected if you have active wiki pages")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n❌ Interrupted by user")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n\n❌ Error: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Cleanup script to delete all wiki pages via REST API.
|
|
||||||
This will trigger automatic vector and graph cleanup for each page.
|
|
||||||
"""
|
|
||||||
import requests
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
BASE_URL = "http://192.168.86.149:8089"
|
|
||||||
API_KEY = "af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5"
|
|
||||||
USER = "jpmschweitzer"
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {API_KEY}",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
|
|
||||||
def list_all_pages():
|
|
||||||
"""List all wiki pages."""
|
|
||||||
url = f"{BASE_URL}/wiki/pages"
|
|
||||||
params = {"user": USER, "limit": 200}
|
|
||||||
|
|
||||||
print(f"🔍 Listing all pages for user: {USER}")
|
|
||||||
response = requests.get(url, headers=headers, params=params)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
print(f"❌ Failed to list pages: {response.status_code}")
|
|
||||||
print(response.text)
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
pages = data.get("pages", [])
|
|
||||||
print(f"📚 Found {len(pages)} pages")
|
|
||||||
return pages
|
|
||||||
|
|
||||||
def delete_page(page_id, title):
|
|
||||||
"""Delete a single wiki page."""
|
|
||||||
url = f"{BASE_URL}/wiki/pages/{page_id}"
|
|
||||||
params = {"user": USER}
|
|
||||||
|
|
||||||
print(f"🗑️ Deleting page {page_id}: {title}")
|
|
||||||
response = requests.delete(url, headers=headers, params=params)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
print(f" ✅ Deleted successfully")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(f" ❌ Failed: {response.status_code}")
|
|
||||||
print(f" {response.text}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("=" * 60)
|
|
||||||
print("🧹 Wiki Cleanup Script")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# List all pages
|
|
||||||
pages = list_all_pages()
|
|
||||||
|
|
||||||
if not pages:
|
|
||||||
print("✨ No pages found. Wiki is already clean!")
|
|
||||||
return
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f"⚠️ WARNING: About to delete {len(pages)} pages!")
|
|
||||||
print(" This will also delete:")
|
|
||||||
print(" - Vector embeddings in Qdrant")
|
|
||||||
print(" - Document nodes in Neo4j")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Ask for confirmation
|
|
||||||
response = input("Continue? (yes/no): ").strip().lower()
|
|
||||||
if response != "yes":
|
|
||||||
print("❌ Cancelled")
|
|
||||||
return
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("🚀 Starting deletion...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Delete each page
|
|
||||||
success_count = 0
|
|
||||||
failed_count = 0
|
|
||||||
|
|
||||||
for i, page in enumerate(pages, 1):
|
|
||||||
page_id = page.get("id")
|
|
||||||
title = page.get("title", "Untitled")
|
|
||||||
|
|
||||||
print(f"[{i}/{len(pages)}] ", end="")
|
|
||||||
|
|
||||||
if delete_page(page_id, title):
|
|
||||||
success_count += 1
|
|
||||||
else:
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
# Small delay to avoid overwhelming the API
|
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("📊 Cleanup Summary")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"✅ Successfully deleted: {success_count} pages")
|
|
||||||
print(f"❌ Failed to delete: {failed_count} pages")
|
|
||||||
print(f"📝 Total pages: {len(pages)}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if success_count > 0:
|
|
||||||
print("🧹 Background cleanup tasks are running to remove:")
|
|
||||||
print(" - Vector embeddings from Qdrant")
|
|
||||||
print(" - Document nodes from Neo4j")
|
|
||||||
print()
|
|
||||||
print("✨ Wiki cleanup complete!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
try:
|
|
||||||
main()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\n❌ Interrupted by user")
|
|
||||||
sys.exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\n\n❌ Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
# Knowledge Consolidation (Librarian Task)
|
|
||||||
|
|
||||||
Automated processing of HybridRAG search results to consolidate knowledge into wiki pages.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The **Knowledge Consolidation** system is a Librarian task that:
|
|
||||||
1. Processes unprocessed `SearchQuery` nodes from Neo4j (created by HybridRAG searches)
|
|
||||||
2. Analyzes web results with Ollama (mistral-nemo) for novel information
|
|
||||||
3. Creates new wiki pages for new concepts/technologies
|
|
||||||
4. Updates existing pages with new facts and citations
|
|
||||||
5. Adds new entities to the knowledge graph
|
|
||||||
6. Marks `SearchQuery` nodes as processed
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- **Library Desk**: Exposes `/consolidate/knowledge` REST endpoint
|
|
||||||
- **Scheduler**: Calls endpoint periodically via generic `rest_api_executor`
|
|
||||||
- **Separation of concerns**: Scheduler triggers, Library Desk executes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Endpoint: POST /consolidate/knowledge
|
|
||||||
|
|
||||||
**URL:** `http://library-desk:8089/consolidate/knowledge`
|
|
||||||
|
|
||||||
**Authentication:** Bearer token (LIBRARY_DESK_API_KEY)
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_limit": 10, // Max searches to process per run (1-100)
|
|
||||||
"lookback_days": 7, // Only process searches from last N days (1-90)
|
|
||||||
"min_web_results": 2, // Minimum web results required (1-20)
|
|
||||||
"dry_run": false // If true, analyze but don't create pages
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_found": 5,
|
|
||||||
"processed_count": 4,
|
|
||||||
"pages_created": 2,
|
|
||||||
"pages_updated": 3,
|
|
||||||
"entities_added": 7,
|
|
||||||
"errors": ["Search abc123: Failed to parse response"],
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"search_id": "uuid-1",
|
|
||||||
"query": "docker orchestration kubernetes",
|
|
||||||
"pages_created": 1,
|
|
||||||
"pages_updated": 1,
|
|
||||||
"entities_added": 3,
|
|
||||||
"error": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dry_run": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual Testing
|
|
||||||
|
|
||||||
### Test the endpoint directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set API key
|
|
||||||
export LIBRARY_DESK_API_KEY="your-api-key-here"
|
|
||||||
|
|
||||||
# Test with dry run (no changes)
|
|
||||||
curl -X POST http://192.168.86.149:8089/consolidate/knowledge \
|
|
||||||
-H "Authorization: Bearer $LIBRARY_DESK_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"process_limit": 5,
|
|
||||||
"lookback_days": 7,
|
|
||||||
"min_web_results": 2,
|
|
||||||
"dry_run": true
|
|
||||||
}' | python3 -m json.tool
|
|
||||||
|
|
||||||
# Real run (creates pages)
|
|
||||||
curl -X POST http://192.168.86.149:8089/consolidate/knowledge \
|
|
||||||
-H "Authorization: Bearer $LIBRARY_DESK_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"process_limit": 10,
|
|
||||||
"lookback_days": 7,
|
|
||||||
"min_web_results": 2,
|
|
||||||
"dry_run": false
|
|
||||||
}' | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scheduler Integration
|
|
||||||
|
|
||||||
### Step 1: Set Environment Variable
|
|
||||||
|
|
||||||
The Scheduler needs the Library Desk API key to authenticate:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add to Scheduler's .env or docker-compose
|
|
||||||
LIBRARY_DESK_API_KEY=your-library-desk-api-key-here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Register Task via Scheduler API
|
|
||||||
|
|
||||||
Create the scheduled task using the Scheduler's REST API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set Scheduler API key
|
|
||||||
export SCHEDULER_API_KEY="your-scheduler-api-key"
|
|
||||||
|
|
||||||
# Create knowledge consolidation task (runs hourly)
|
|
||||||
curl -X POST http://192.168.86.149:8090/tasks \
|
|
||||||
-H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"task_name": "knowledge_consolidation",
|
|
||||||
"service": "library-desk",
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"priority": 50,
|
|
||||||
"minute": 0,
|
|
||||||
"hour": -1,
|
|
||||||
"day_of_month": -1,
|
|
||||||
"month": -1,
|
|
||||||
"day_of_week": -1,
|
|
||||||
"enabled": true,
|
|
||||||
"description": "Hourly Librarian knowledge consolidation from HybridRAG search results",
|
|
||||||
"config": {
|
|
||||||
"url": "http://library-desk:8089/consolidate/knowledge",
|
|
||||||
"method": "POST",
|
|
||||||
"payload": {
|
|
||||||
"process_limit": 10,
|
|
||||||
"lookback_days": 7,
|
|
||||||
"min_web_results": 2,
|
|
||||||
"dry_run": false
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"token": "${LIBRARY_DESK_API_KEY}"
|
|
||||||
},
|
|
||||||
"timeout": 300,
|
|
||||||
"response_path": "processed_count"
|
|
||||||
}
|
|
||||||
}' | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
**Schedule Patterns:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Every hour at minute 0
|
|
||||||
"minute": 0, "hour": -1, ...
|
|
||||||
|
|
||||||
# Every 4 hours at minute 15
|
|
||||||
"minute": 15, "hour": [0, 4, 8, 12, 16, 20], ...
|
|
||||||
|
|
||||||
# Daily at 3:00 AM
|
|
||||||
"minute": 0, "hour": 3, "day_of_month": -1, ...
|
|
||||||
|
|
||||||
# Every Monday at 9:00 AM
|
|
||||||
"minute": 0, "hour": 9, "day_of_week": 0, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generic REST API Executor
|
|
||||||
|
|
||||||
The Scheduler's `rest_api_executor` is a universal executor for calling any REST API across the system.
|
|
||||||
|
|
||||||
### Features:
|
|
||||||
- **HTTP Methods**: GET, POST, PUT, DELETE, PATCH
|
|
||||||
- **Authentication**: Bearer token, Basic auth, API key
|
|
||||||
- **Environment Variables**: Use `${VAR_NAME}` for secrets
|
|
||||||
- **Configurable timeouts and SSL verification**
|
|
||||||
- **JSON payload support**
|
|
||||||
- **Response extraction via JSONPath**
|
|
||||||
|
|
||||||
### Example Configurations:
|
|
||||||
|
|
||||||
**1. Library Desk Knowledge Consolidation:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"config": {
|
|
||||||
"url": "http://library-desk:8089/consolidate/knowledge",
|
|
||||||
"method": "POST",
|
|
||||||
"payload": {"process_limit": 10, "lookback_days": 7},
|
|
||||||
"auth": {"type": "bearer", "token": "${LIBRARY_DESK_API_KEY}"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Core API Container Restart:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"config": {
|
|
||||||
"url": "http://core-api:8088/v1/infrastructure/containers/nginx/restart",
|
|
||||||
"method": "POST",
|
|
||||||
"auth": {"type": "bearer", "token": "${CORE_API_KEY}"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. External Webhook (Slack notification):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"config": {
|
|
||||||
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
|
|
||||||
"method": "POST",
|
|
||||||
"payload": {"text": "Daily backup completed"},
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Basic Auth Example:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"config": {
|
|
||||||
"url": "http://internal-service:8080/api/sync",
|
|
||||||
"method": "GET",
|
|
||||||
"auth": {
|
|
||||||
"type": "basic",
|
|
||||||
"username": "${SERVICE_USER}",
|
|
||||||
"password": "${SERVICE_PASSWORD}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Managing the Task
|
|
||||||
|
|
||||||
### View Task Status:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
http://192.168.86.149:8090/tasks/knowledge_consolidation | python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Execution History:
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
"http://192.168.86.149:8090/executions?task_name=knowledge_consolidation&limit=10" \
|
|
||||||
| python3 -m json.tool
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manually Trigger Task:
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
http://192.168.86.149:8090/tasks/knowledge_consolidation/trigger
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Task Configuration:
|
|
||||||
```bash
|
|
||||||
curl -X PUT \
|
|
||||||
-H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"enabled": false,
|
|
||||||
"config": {
|
|
||||||
"payload": {
|
|
||||||
"process_limit": 20,
|
|
||||||
"dry_run": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}' \
|
|
||||||
http://192.168.86.149:8090/tasks/knowledge_consolidation
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Task:
|
|
||||||
```bash
|
|
||||||
curl -X DELETE \
|
|
||||||
-H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
http://192.168.86.149:8090/tasks/knowledge_consolidation
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### 1. HybridRAG Search Creates SearchQuery Nodes
|
|
||||||
|
|
||||||
When a user performs a HybridRAG search (e.g., "docker orchestration kubernetes"):
|
|
||||||
- Library Desk stores search in Neo4j as `SearchQuery` node
|
|
||||||
- Web results stored as `WebResult` nodes with `FOUND` relationships
|
|
||||||
- `processed: false` flag indicates it needs consolidation
|
|
||||||
|
|
||||||
### 2. Scheduler Triggers Consolidation (Hourly)
|
|
||||||
|
|
||||||
The Scheduler runs the task at configured intervals:
|
|
||||||
- Calls Library Desk `/consolidate/knowledge` endpoint via HTTP
|
|
||||||
- Passes configuration (process_limit, lookback_days, etc.)
|
|
||||||
- Authenticates with Bearer token from environment
|
|
||||||
|
|
||||||
### 3. Library Desk Processes Searches
|
|
||||||
|
|
||||||
For each unprocessed `SearchQuery`:
|
|
||||||
1. **Query Neo4j** for searches with `processed: false` from last N days
|
|
||||||
2. **Retrieve web results** via `FOUND` relationships
|
|
||||||
3. **Analyze with Ollama** (mistral-nemo) for novel information:
|
|
||||||
- Extract new concepts/technologies worth documenting
|
|
||||||
- Identify facts to add to existing pages
|
|
||||||
- Find new entities for knowledge graph
|
|
||||||
4. **Create/update wiki pages** (TODO: implement wiki API)
|
|
||||||
5. **Update knowledge graph** with new entities
|
|
||||||
6. **Mark SearchQuery as processed** (`processed: true, processed_at: timestamp`)
|
|
||||||
|
|
||||||
### 4. Response Returned to Scheduler
|
|
||||||
|
|
||||||
Library Desk returns summary:
|
|
||||||
- Total searches found and processed
|
|
||||||
- Pages created/updated
|
|
||||||
- Entities added
|
|
||||||
- Errors encountered
|
|
||||||
|
|
||||||
Scheduler logs the result and marks execution as complete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
### 1. Wiki Page Creation
|
|
||||||
Currently logs what would be created. Need to implement:
|
|
||||||
- Wiki.js API integration for page creation
|
|
||||||
- Template-based page generation
|
|
||||||
- Citation formatting from web sources
|
|
||||||
|
|
||||||
### 2. Enhanced Entity Extraction
|
|
||||||
- Use spaCy or similar NLP for entity recognition
|
|
||||||
- Link entities between documents
|
|
||||||
- Build entity relationship graph
|
|
||||||
|
|
||||||
### 3. Smart Consolidation
|
|
||||||
- Detect duplicate/similar pages
|
|
||||||
- Merge related content
|
|
||||||
- Suggest tag improvements
|
|
||||||
|
|
||||||
### 4. User Feedback Loop
|
|
||||||
- Flag low-confidence consolidations for review
|
|
||||||
- Allow users to approve/reject suggestions
|
|
||||||
- Learn from user feedback
|
|
||||||
|
|
||||||
### 5. Metrics and Monitoring
|
|
||||||
- Track consolidation success rates
|
|
||||||
- Measure wiki growth over time
|
|
||||||
- Identify knowledge gaps
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Task not running?
|
|
||||||
|
|
||||||
1. **Check task is enabled:**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
http://192.168.86.149:8090/tasks/knowledge_consolidation
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Check environment variable:**
|
|
||||||
```bash
|
|
||||||
docker exec scheduler env | grep LIBRARY_DESK_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Check Scheduler logs:**
|
|
||||||
```bash
|
|
||||||
docker logs scheduler --tail 50
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task failing?
|
|
||||||
|
|
||||||
1. **Check execution history:**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer $SCHEDULER_API_KEY" \
|
|
||||||
"http://192.168.86.149:8090/executions?task_name=knowledge_consolidation&limit=5"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test endpoint directly:**
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.86.149:8089/consolidate/knowledge \
|
|
||||||
-H "Authorization: Bearer $LIBRARY_DESK_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"process_limit": 1, "dry_run": true}'
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Check Library Desk logs:**
|
|
||||||
```bash
|
|
||||||
docker logs library-desk --tail 100 | grep -i consolidation
|
|
||||||
```
|
|
||||||
|
|
||||||
### No searches being processed?
|
|
||||||
|
|
||||||
Searches may already be processed or too old:
|
|
||||||
- Increase `lookback_days` to process older searches
|
|
||||||
- Lower `min_web_results` threshold
|
|
||||||
- Check Neo4j for unprocessed searches:
|
|
||||||
```cypher
|
|
||||||
MATCH (sq:SearchQuery {processed: false})
|
|
||||||
RETURN sq.query, sq.timestamp, sq.web_count
|
|
||||||
ORDER BY sq.timestamp DESC
|
|
||||||
LIMIT 10
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example Workflow
|
|
||||||
|
|
||||||
**1. User searches:** "What is Docker Swarm orchestration?"
|
|
||||||
|
|
||||||
**2. HybridRAG creates SearchQuery:**
|
|
||||||
```cypher
|
|
||||||
CREATE (sq:SearchQuery {
|
|
||||||
id: "uuid-123",
|
|
||||||
query: "What is Docker Swarm orchestration?",
|
|
||||||
user: "jpmschweitzer",
|
|
||||||
timestamp: datetime(),
|
|
||||||
processed: false,
|
|
||||||
web_count: 5
|
|
||||||
})
|
|
||||||
// + WebResult nodes for each web search result
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Scheduler triggers consolidation (hourly):**
|
|
||||||
```
|
|
||||||
[2025-01-09 14:00:00] Scheduler: Running knowledge_consolidation task
|
|
||||||
[2025-01-09 14:00:01] REST API Executor: POST http://library-desk:8089/consolidate/knowledge
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Library Desk processes:**
|
|
||||||
```
|
|
||||||
[2025-01-09 14:00:02] Found 3 unprocessed searches
|
|
||||||
[2025-01-09 14:00:03] Processing: "What is Docker Swarm orchestration?"
|
|
||||||
[2025-01-09 14:00:04] Retrieved 5 web results
|
|
||||||
[2025-01-09 14:00:08] LLM analysis: Novel info found
|
|
||||||
[2025-01-09 14:00:08] Would create page: "Docker Swarm Orchestration"
|
|
||||||
[2025-01-09 14:00:08] Marked search uuid-123 as processed
|
|
||||||
[2025-01-09 14:00:10] Consolidation complete: 3/3 searches, 2 pages created
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. Scheduler logs result:**
|
|
||||||
```
|
|
||||||
[2025-01-09 14:00:10] Task knowledge_consolidation succeeded: Processed 3/3 searches. Created 2 pages, updated 1 pages, added 5 entities.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The Knowledge Consolidation system provides:
|
|
||||||
- ✅ **Automated knowledge capture** from HybridRAG searches
|
|
||||||
- ✅ **Separation of concerns** via REST API
|
|
||||||
- ✅ **Generic REST executor** reusable across system
|
|
||||||
- ✅ **Flexible scheduling** via Scheduler
|
|
||||||
- ✅ **Comprehensive logging** and error handling
|
|
||||||
- ✅ **Dry run mode** for testing
|
|
||||||
- ⏳ **Wiki integration** (TODO)
|
|
||||||
- ⏳ **Advanced entity extraction** (TODO)
|
|
||||||
|
|
||||||
For more information:
|
|
||||||
- Scheduler: `/services/scheduler/README.md`
|
|
||||||
- Library Desk: `/services/library-desk/README.md`
|
|
||||||
- HybridRAG: `/services/library-desk/docs/HYBRID_RAG.md`
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# [Topic Title]
|
|
||||||
|
|
||||||
> **Last Updated:** YYYY-MM-DD | **Status:** Draft/Active/Archived
|
|
||||||
> **Tags:** #tag1 #tag2 #tag3
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
A concise 2-3 sentence overview of the topic. This should capture the essence and primary purpose for both AI and human readers.
|
|
||||||
|
|
||||||
**Key Facts:**
|
|
||||||
- Most important fact #1
|
|
||||||
- Most important fact #2
|
|
||||||
- Most important fact #3
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Detailed introduction to the topic. Explain what it is, why it matters, and its context within the broader ecosystem.
|
|
||||||
|
|
||||||
## Core Concepts
|
|
||||||
|
|
||||||
### Concept 1: [Name]
|
|
||||||
|
|
||||||
Explanation of the first core concept.
|
|
||||||
|
|
||||||
### Concept 2: [Name]
|
|
||||||
|
|
||||||
Explanation of the second core concept.
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
Description of how the system/concept is structured.
|
|
||||||
|
|
||||||
```
|
|
||||||
[Diagram or code block if applicable]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Specifications
|
|
||||||
|
|
||||||
| Property | Value | Notes |
|
|
||||||
|----------|-------|-------|
|
|
||||||
| Property 1 | Value 1 | Additional context |
|
|
||||||
| Property 2 | Value 2 | Additional context |
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
### Primary Use Case
|
|
||||||
|
|
||||||
Description and example.
|
|
||||||
|
|
||||||
### Secondary Use Cases
|
|
||||||
|
|
||||||
- Use case 1
|
|
||||||
- Use case 2
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Practice 1**: Description
|
|
||||||
2. **Practice 2**: Description
|
|
||||||
|
|
||||||
## Common Issues & Solutions
|
|
||||||
|
|
||||||
| Issue | Solution | Reference |
|
|
||||||
|-------|----------|-----------|
|
|
||||||
| Problem description | How to resolve | [Link] |
|
|
||||||
|
|
||||||
## Related Topics
|
|
||||||
|
|
||||||
- [Related Topic 1](link) - Brief description
|
|
||||||
- [Related Topic 2](link) - Brief description
|
|
||||||
|
|
||||||
## Changes & Updates
|
|
||||||
|
|
||||||
### 2025-01-09
|
|
||||||
- Updated section X with new information from [Source]
|
|
||||||
- Corrected fact Y (previously stated Z)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
1. [Source Title](URL) - Publication Name, Date
|
|
||||||
2. [Source Title](URL) - Publication Name, Date
|
|
||||||
3. [Source Title](URL) - Publication Name, Date
|
|
||||||
|
|
||||||
## Knowledge Graph
|
|
||||||
|
|
||||||
**Related Entities:**
|
|
||||||
- [Entity 1](graph-link) - Relationship description
|
|
||||||
- [Entity 2](graph-link) - Relationship description
|
|
||||||
|
|
||||||
**Mentioned In:**
|
|
||||||
- [Document 1](wiki-link) - Context
|
|
||||||
- [Document 2](wiki-link) - Context
|
|
||||||
|
|
||||||
## Mind Map
|
|
||||||
|
|
||||||
View this topic in context: [Mind Map Link](mindmap-url)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*This page follows the Library Desk Wiki Standard v1.0*
|
|
||||||
*Generated/Updated by: [Human/Librarian Agent]*
|
|
||||||
*Quality Score: [0-100] | Completeness: [0-100%]*
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
# Pytest configuration for Library Desk
|
|
||||||
|
|
||||||
# Test discovery
|
|
||||||
python_files = test_*.py
|
|
||||||
python_classes = Test*
|
|
||||||
python_functions = test_*
|
|
||||||
|
|
||||||
# Asyncio settings
|
|
||||||
asyncio_mode = auto
|
|
||||||
asyncio_default_fixture_loop_scope = function
|
|
||||||
|
|
||||||
# Output options
|
|
||||||
addopts =
|
|
||||||
-v
|
|
||||||
--strict-markers
|
|
||||||
--tb=short
|
|
||||||
--disable-warnings
|
|
||||||
|
|
||||||
# Markers for test categorization
|
|
||||||
markers =
|
|
||||||
unit: Unit tests (no external dependencies)
|
|
||||||
integration: Integration tests (require services)
|
|
||||||
slow: Slow tests that take more than 1 second
|
|
||||||
|
|
||||||
# Test paths
|
|
||||||
testpaths = tests
|
|
||||||
|
|
||||||
# Minimum Python version
|
|
||||||
minversion = 3.12
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Requires Python 3.12+
|
|
||||||
|
|
||||||
# FastAPI Framework (latest Dec 2024)
|
|
||||||
fastapi~=0.115.0
|
|
||||||
uvicorn[standard]~=0.32.0
|
|
||||||
pydantic~=2.10.0
|
|
||||||
pydantic-settings~=2.6.0
|
|
||||||
|
|
||||||
# HTTP Client (no known CVEs)
|
|
||||||
httpx~=0.27.0
|
|
||||||
|
|
||||||
# Database & Vector Store
|
|
||||||
neo4j~=6.0.3
|
|
||||||
qdrant-client~=1.16.1
|
|
||||||
asyncpg~=0.29.0 # PostgreSQL async driver for Wiki.js change detection
|
|
||||||
|
|
||||||
# Redis (Python 3.12 compatible)
|
|
||||||
redis~=7.1.0
|
|
||||||
|
|
||||||
# Security
|
|
||||||
python-jose[cryptography]~=3.3.0
|
|
||||||
passlib[bcrypt]~=1.7.4
|
|
||||||
python-multipart~=0.0.20
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
python-dateutil~=2.9.0
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
pytest~=8.3.0
|
|
||||||
pytest-asyncio~=0.24.0
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Setup PostgreSQL triggers in Wiki.js database for change detection.
|
|
||||||
|
|
||||||
This script creates database triggers that emit NOTIFY events when
|
|
||||||
pages are created, updated, or deleted in Wiki.js.
|
|
||||||
|
|
||||||
Run this once to enable automatic processing of user-edited pages.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
import sys
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
# Import the corrected trigger SQL from the listener module
|
|
||||||
from src.services.wiki_change_listener import SETUP_TRIGGERS_SQL
|
|
||||||
|
|
||||||
|
|
||||||
async def setup_triggers():
|
|
||||||
"""Install database triggers in Wiki.js PostgreSQL database."""
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
print("=" * 70)
|
|
||||||
print("Wiki.js Database Trigger Setup")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
print(f"Connecting to Wiki.js database at {settings.wikijs_db_host}:{settings.wikijs_db_port}")
|
|
||||||
print(f"Database: {settings.wikijs_db_name}")
|
|
||||||
print(f"User: {settings.wikijs_db_user}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Connect to Wiki.js database
|
|
||||||
connection = await asyncpg.connect(
|
|
||||||
host=settings.wikijs_db_host,
|
|
||||||
port=settings.wikijs_db_port,
|
|
||||||
user=settings.wikijs_db_user,
|
|
||||||
password=settings.wikijs_db_password,
|
|
||||||
database=settings.wikijs_db_name
|
|
||||||
)
|
|
||||||
|
|
||||||
print("✓ Connected to Wiki.js database")
|
|
||||||
print()
|
|
||||||
print("Installing triggers...")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Execute setup SQL
|
|
||||||
await connection.execute(SETUP_TRIGGERS_SQL)
|
|
||||||
|
|
||||||
# Verify triggers were created
|
|
||||||
triggers = await connection.fetch("""
|
|
||||||
SELECT trigger_name, event_manipulation
|
|
||||||
FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages'
|
|
||||||
ORDER BY trigger_name
|
|
||||||
""")
|
|
||||||
|
|
||||||
if triggers:
|
|
||||||
print("✓ Triggers installed successfully:")
|
|
||||||
print()
|
|
||||||
for trigger in triggers:
|
|
||||||
print(f" - {trigger['trigger_name']} ({trigger['event_manipulation']})")
|
|
||||||
print()
|
|
||||||
print("=" * 70)
|
|
||||||
print("Setup complete!")
|
|
||||||
print("=" * 70)
|
|
||||||
print()
|
|
||||||
print("Next steps:")
|
|
||||||
print(" 1. Restart library-desk service to activate the listener")
|
|
||||||
print(" 2. Edit a page in Wiki.js to test")
|
|
||||||
print(" 3. Check library-desk logs for processing messages")
|
|
||||||
print()
|
|
||||||
else:
|
|
||||||
print("✗ No triggers found after installation")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
await connection.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Error: {e}")
|
|
||||||
print()
|
|
||||||
print("Common issues:")
|
|
||||||
print(" - Check database connection settings in .env")
|
|
||||||
print(" - Ensure database user has CREATE FUNCTION and CREATE TRIGGER permissions")
|
|
||||||
print(" - Verify Wiki.js database is accessible from library-desk service")
|
|
||||||
print()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(setup_triggers())
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""
|
|
||||||
Library Desk - FastAPI Coordination Service
|
|
||||||
|
|
||||||
The coordination layer for The Library system, providing:
|
|
||||||
- HybridRAG queries (Neo4j + Qdrant + SearXNG)
|
|
||||||
- Document ingestion and indexing
|
|
||||||
- Entity extraction and relationship mapping
|
|
||||||
- Mind map generation
|
|
||||||
- Wiki.js API proxy
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
"""
|
|
||||||
Neo4j async client for Library Desk.
|
|
||||||
|
|
||||||
Provides async Neo4j operations with:
|
|
||||||
- Connection pooling via AsyncGraphDatabase
|
|
||||||
- Session management with context managers
|
|
||||||
- Multi-tenancy support via user labels
|
|
||||||
- Automatic retry on transient failures
|
|
||||||
"""
|
|
||||||
|
|
||||||
from neo4j import AsyncGraphDatabase, AsyncDriver, AsyncSession
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_label
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Neo4jClient:
|
|
||||||
"""
|
|
||||||
Async Neo4j client with connection pooling.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Singleton driver instance (expensive to create)
|
|
||||||
- Session-per-request pattern (lightweight)
|
|
||||||
- Automatic transaction retry
|
|
||||||
- Multi-tenancy via user-specific labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, uri: str, user: str, password: str):
|
|
||||||
"""
|
|
||||||
Initialize Neo4j client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
uri: Neo4j Bolt URI (e.g., "bolt://neo4j:7687")
|
|
||||||
user: Neo4j username
|
|
||||||
password: Neo4j password
|
|
||||||
"""
|
|
||||||
self.uri = uri
|
|
||||||
self._driver: Optional[AsyncDriver] = None
|
|
||||||
self._auth = (user, password)
|
|
||||||
|
|
||||||
async def connect(self):
|
|
||||||
"""
|
|
||||||
Initialize connection pool.
|
|
||||||
|
|
||||||
Should be called once at app startup.
|
|
||||||
Driver handles connection pooling internally.
|
|
||||||
"""
|
|
||||||
if not self._driver:
|
|
||||||
self._driver = AsyncGraphDatabase.driver(
|
|
||||||
self.uri,
|
|
||||||
auth=self._auth,
|
|
||||||
max_connection_pool_size=50,
|
|
||||||
connection_timeout=30.0,
|
|
||||||
max_transaction_retry_time=30.0
|
|
||||||
)
|
|
||||||
# Verify connectivity
|
|
||||||
await self._driver.verify_connectivity()
|
|
||||||
logger.info(f"Connected to Neo4j at {self.uri}")
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""
|
|
||||||
Close connection pool.
|
|
||||||
|
|
||||||
Should be called once at app shutdown.
|
|
||||||
"""
|
|
||||||
if self._driver:
|
|
||||||
await self._driver.close()
|
|
||||||
self._driver = None
|
|
||||||
logger.info("Closed Neo4j connection")
|
|
||||||
|
|
||||||
async def execute_query(
|
|
||||||
self,
|
|
||||||
cypher: str,
|
|
||||||
parameters: Optional[Dict[str, Any]] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Execute Cypher query and return results.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cypher: Cypher query string
|
|
||||||
parameters: Query parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of result records as dictionaries
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If driver not initialized or query fails
|
|
||||||
"""
|
|
||||||
if not self._driver:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
async with self._driver.session() as session:
|
|
||||||
result = await session.run(cypher, parameters or {})
|
|
||||||
records = await result.data()
|
|
||||||
return records
|
|
||||||
|
|
||||||
async def execute_write(
|
|
||||||
self,
|
|
||||||
cypher: str,
|
|
||||||
parameters: Optional[Dict[str, Any]] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Execute write transaction with automatic retry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cypher: Cypher query string
|
|
||||||
parameters: Query parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of result records as dictionaries
|
|
||||||
"""
|
|
||||||
if not self._driver:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
async def write_tx(tx):
|
|
||||||
result = await tx.run(cypher, parameters or {})
|
|
||||||
return await result.data()
|
|
||||||
|
|
||||||
async with self._driver.session() as session:
|
|
||||||
return await session.execute_write(write_tx)
|
|
||||||
|
|
||||||
# Multi-tenancy helpers
|
|
||||||
|
|
||||||
def get_user_label(self, user: str) -> str:
|
|
||||||
"""
|
|
||||||
Get Neo4j label for user's documents.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Neo4j label string (e.g., "User_Jpmschweitzer_Document")
|
|
||||||
"""
|
|
||||||
return get_neo4j_user_label(user)
|
|
||||||
|
|
||||||
# Document node operations
|
|
||||||
|
|
||||||
async def create_document_node(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str,
|
|
||||||
properties: Dict[str, Any]
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Create document node with user label.
|
|
||||||
|
|
||||||
Node structure:
|
|
||||||
(doc:Document:User_{user}_Document {
|
|
||||||
id: "doc_123",
|
|
||||||
source: "github",
|
|
||||||
repository: "anthropic-cookbook",
|
|
||||||
path: "skills/citation/guide.md",
|
|
||||||
title: "Citation Guide",
|
|
||||||
created_at: timestamp(),
|
|
||||||
updated_at: timestamp(),
|
|
||||||
content_hash: "sha256:..."
|
|
||||||
})
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Unique document ID
|
|
||||||
properties: Document properties
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created node properties or None on failure
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
# Ensure required properties
|
|
||||||
properties["id"] = doc_id
|
|
||||||
if "created_at" not in properties:
|
|
||||||
properties["created_at"] = "timestamp()"
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
CREATE (doc:Document:{user_label})
|
|
||||||
SET doc = $properties
|
|
||||||
SET doc.created_at = timestamp()
|
|
||||||
SET doc.updated_at = timestamp()
|
|
||||||
RETURN doc
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_write(cypher, {"properties": properties})
|
|
||||||
return result[0]["doc"] if result else None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create document node: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_document_node(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get document node by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Document node properties or None if not found
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
MATCH (doc:Document:{user_label} {{id: $doc_id}})
|
|
||||||
RETURN doc
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_query(cypher, {"doc_id": doc_id})
|
|
||||||
return result[0]["doc"] if result else None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get document node: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def delete_document_node(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Delete document node and all its relationships.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False otherwise
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
MATCH (doc:Document:{user_label} {{id: $doc_id}})
|
|
||||||
DETACH DELETE doc
|
|
||||||
RETURN count(doc) as deleted
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_write(cypher, {"doc_id": doc_id})
|
|
||||||
return result[0]["deleted"] > 0 if result else False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete document node: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def find_similar_documents(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_ids: List[str],
|
|
||||||
max_depth: int = 2,
|
|
||||||
limit: int = 20
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Find documents similar to given docs via graph traversal.
|
|
||||||
|
|
||||||
Uses: Shared concepts, shared entities, citation links.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_ids: List of source document IDs
|
|
||||||
max_depth: Maximum traversal depth
|
|
||||||
limit: Maximum results to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of similar documents with connection strength
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
MATCH (source:Document:{user_label})
|
|
||||||
WHERE source.id IN $doc_ids
|
|
||||||
MATCH (source)-[*1..{max_depth}]-(related:Document:{user_label})
|
|
||||||
WHERE related.id <> source.id AND NOT related.id IN $doc_ids
|
|
||||||
WITH related, count(*) as connection_strength
|
|
||||||
ORDER BY connection_strength DESC
|
|
||||||
LIMIT $limit
|
|
||||||
RETURN related, connection_strength
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_query(
|
|
||||||
cypher,
|
|
||||||
{"doc_ids": doc_ids, "limit": limit}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to find similar documents: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def list_user_documents(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all documents for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
limit: Maximum results to return
|
|
||||||
offset: Number of results to skip
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of document nodes
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
MATCH (doc:Document:{user_label})
|
|
||||||
RETURN doc
|
|
||||||
ORDER BY doc.created_at DESC
|
|
||||||
SKIP $offset
|
|
||||||
LIMIT $limit
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_query(
|
|
||||||
cypher,
|
|
||||||
{"offset": offset, "limit": limit}
|
|
||||||
)
|
|
||||||
return [r["doc"] for r in result]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list documents: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Concept/entity operations
|
|
||||||
|
|
||||||
async def create_concept_node(
|
|
||||||
self,
|
|
||||||
concept_name: str,
|
|
||||||
concept_type: str,
|
|
||||||
properties: Optional[Dict[str, Any]] = None
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Create or update concept node.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
concept_name: Concept name
|
|
||||||
concept_type: Concept type (Technique, Tool, Pattern, etc.)
|
|
||||||
properties: Additional properties
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Concept node properties
|
|
||||||
"""
|
|
||||||
cypher = """
|
|
||||||
MERGE (concept:Concept {name: $name})
|
|
||||||
ON CREATE SET
|
|
||||||
concept.type = $type,
|
|
||||||
concept.first_seen = timestamp(),
|
|
||||||
concept.mention_count = 1
|
|
||||||
ON MATCH SET
|
|
||||||
concept.mention_count = concept.mention_count + 1
|
|
||||||
SET concept += $properties
|
|
||||||
RETURN concept
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_write(
|
|
||||||
cypher,
|
|
||||||
{
|
|
||||||
"name": concept_name,
|
|
||||||
"type": concept_type,
|
|
||||||
"properties": properties or {}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result[0]["concept"] if result else None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create concept node: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def link_document_to_concept(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str,
|
|
||||||
concept_name: str
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Create MENTIONS relationship between document and concept.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
concept_name: Concept name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if link created
|
|
||||||
"""
|
|
||||||
user_label = self.get_user_label(user)
|
|
||||||
|
|
||||||
cypher = f"""
|
|
||||||
MATCH (doc:Document:{user_label} {{id: $doc_id}})
|
|
||||||
MATCH (concept:Concept {{name: $concept_name}})
|
|
||||||
MERGE (doc)-[r:MENTIONS]->(concept)
|
|
||||||
ON CREATE SET r.count = 1
|
|
||||||
ON MATCH SET r.count = r.count + 1
|
|
||||||
RETURN r
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await self.execute_write(
|
|
||||||
cypher,
|
|
||||||
{"doc_id": doc_id, "concept_name": concept_name}
|
|
||||||
)
|
|
||||||
return len(result) > 0
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to link document to concept: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
"""
|
|
||||||
Ollama client for embeddings generation.
|
|
||||||
|
|
||||||
Provides async embedding operations via Ollama API:
|
|
||||||
- Text embedding generation
|
|
||||||
- Batch embedding support
|
|
||||||
- Model management
|
|
||||||
"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OllamaClient:
|
|
||||||
"""
|
|
||||||
Ollama API client for embeddings.
|
|
||||||
|
|
||||||
Documentation: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
||||||
Default model: nomic-embed-text (768-dimensional embeddings)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, model: str = "nomic-embed-text"):
|
|
||||||
"""
|
|
||||||
Initialize Ollama client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_url: Ollama server URL (e.g., "http://ollama:11434")
|
|
||||||
model: Embedding model name (default: "nomic-embed-text")
|
|
||||||
"""
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
self.model = model
|
|
||||||
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
|
||||||
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
|
|
||||||
logger.info(f"Initialized Ollama client: {base_url}, model: {model}")
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close HTTP client"""
|
|
||||||
await self.client.aclose()
|
|
||||||
|
|
||||||
async def embed(self, text: str) -> Optional[List[float]]:
|
|
||||||
"""
|
|
||||||
Generate embedding for single text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Text to embed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding vector (768-dimensional for nomic-embed-text) or None on failure
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> embedding = await client.embed("Hello world")
|
|
||||||
>>> len(embedding)
|
|
||||||
768
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"model": self.model,
|
|
||||||
"prompt": text
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await self.client.post(
|
|
||||||
self.embeddings_url,
|
|
||||||
json=payload
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
embedding = data.get("embedding")
|
|
||||||
if not embedding:
|
|
||||||
logger.error(f"No embedding in response: {data}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return embedding
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Embedding failed: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def embed_batch(
|
|
||||||
self,
|
|
||||||
texts: List[str],
|
|
||||||
show_progress: bool = False
|
|
||||||
) -> List[Optional[List[float]]]:
|
|
||||||
"""
|
|
||||||
Generate embeddings for multiple texts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of texts to embed
|
|
||||||
show_progress: Log progress for large batches
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of embedding vectors (same order as input)
|
|
||||||
None entries for texts that failed to embed
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> texts = ["Hello", "World", "Test"]
|
|
||||||
>>> embeddings = await client.embed_batch(texts)
|
|
||||||
>>> len(embeddings)
|
|
||||||
3
|
|
||||||
"""
|
|
||||||
embeddings = []
|
|
||||||
|
|
||||||
for i, text in enumerate(texts):
|
|
||||||
if show_progress and i % 10 == 0:
|
|
||||||
logger.info(f"Embedding progress: {i}/{len(texts)}")
|
|
||||||
|
|
||||||
embedding = await self.embed(text)
|
|
||||||
embeddings.append(embedding)
|
|
||||||
|
|
||||||
if show_progress:
|
|
||||||
logger.info(f"Embedding complete: {len(embeddings)}/{len(texts)}")
|
|
||||||
|
|
||||||
return embeddings
|
|
||||||
|
|
||||||
async def embed_batch_filtered(
|
|
||||||
self,
|
|
||||||
texts: List[str],
|
|
||||||
show_progress: bool = False
|
|
||||||
) -> List[List[float]]:
|
|
||||||
"""
|
|
||||||
Generate embeddings for multiple texts, filtering out failures.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
texts: List of texts to embed
|
|
||||||
show_progress: Log progress for large batches
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of successful embedding vectors (may be shorter than input)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> texts = ["Hello", "World", "Test"]
|
|
||||||
>>> embeddings = await client.embed_batch_filtered(texts)
|
|
||||||
>>> all(e is not None for e in embeddings)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
all_embeddings = await self.embed_batch(texts, show_progress)
|
|
||||||
return [e for e in all_embeddings if e is not None]
|
|
||||||
|
|
||||||
async def embed_documents(
|
|
||||||
self,
|
|
||||||
documents: List[Dict[str, Any]],
|
|
||||||
content_field: str = "content",
|
|
||||||
show_progress: bool = False
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Embed documents with metadata preservation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
documents: List of document dictionaries
|
|
||||||
content_field: Field name containing text to embed
|
|
||||||
show_progress: Log progress for large batches
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of documents with added "embedding" field
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> docs = [
|
|
||||||
... {"content": "Hello world", "id": 1},
|
|
||||||
... {"content": "Test doc", "id": 2}
|
|
||||||
... ]
|
|
||||||
>>> embedded = await client.embed_documents(docs)
|
|
||||||
>>> "embedding" in embedded[0]
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
texts = [doc.get(content_field, "") for doc in documents]
|
|
||||||
embeddings = await self.embed_batch(texts, show_progress)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for doc, embedding in zip(documents, embeddings):
|
|
||||||
doc_copy = doc.copy()
|
|
||||||
doc_copy["embedding"] = embedding
|
|
||||||
results.append(doc_copy)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def get_embedding_dimension(self) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
Get embedding dimension for current model.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Embedding dimension (e.g., 768 for nomic-embed-text) or None on failure
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> dim = await client.get_embedding_dimension()
|
|
||||||
>>> dim
|
|
||||||
768
|
|
||||||
"""
|
|
||||||
# Generate a test embedding to determine dimension
|
|
||||||
test_embedding = await self.embed("test")
|
|
||||||
if test_embedding:
|
|
||||||
return len(test_embedding)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def list_models(self) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List available Ollama models.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of model information dictionaries
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> models = await client.list_models()
|
|
||||||
>>> any(m["name"] == "nomic-embed-text" for m in models)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = await self.client.get(self.tags_url)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("models", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list models: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def check_model_available(self, model_name: Optional[str] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a model is available.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_name: Model name to check (defaults to self.model)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if model is available, False otherwise
|
|
||||||
"""
|
|
||||||
check_model = model_name or self.model
|
|
||||||
models = await self.list_models()
|
|
||||||
|
|
||||||
# Check for exact match or match with :latest tag
|
|
||||||
for m in models:
|
|
||||||
name = m.get("name", "")
|
|
||||||
# Exact match
|
|
||||||
if name == check_model:
|
|
||||||
return True
|
|
||||||
# Match without tag (e.g., "nomic-embed-text" matches "nomic-embed-text:latest")
|
|
||||||
if name.startswith(f"{check_model}:"):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def generate_text(
|
|
||||||
self,
|
|
||||||
prompt: str,
|
|
||||||
model: Optional[str] = None,
|
|
||||||
stream: bool = False
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Generate text completion (for non-embedding use cases).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: Input prompt
|
|
||||||
model: Model name (defaults to self.model)
|
|
||||||
stream: Enable streaming response
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Generated text or None on failure
|
|
||||||
|
|
||||||
Note: This is primarily for debugging/testing. Use specialized
|
|
||||||
LLM services for production text generation.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"model": model or self.model,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": stream
|
|
||||||
}
|
|
||||||
|
|
||||||
response = await self.client.post(
|
|
||||||
self.generate_url,
|
|
||||||
json=payload
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if stream:
|
|
||||||
# For streaming, return first chunk
|
|
||||||
# Full streaming implementation would need async generator
|
|
||||||
return response.text
|
|
||||||
else:
|
|
||||||
data = response.json()
|
|
||||||
return data.get("response")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Text generation failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def health_check(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if Ollama server is reachable and model is available.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if healthy, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Check server is up and get models in one call
|
|
||||||
response = await self.client.get(self.tags_url, timeout=5.0)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
models = data.get("models", [])
|
|
||||||
|
|
||||||
# Check model is available
|
|
||||||
check_model = self.model
|
|
||||||
model_found = False
|
|
||||||
for m in models:
|
|
||||||
name = m.get("name", "")
|
|
||||||
if name == check_model or name.startswith(f"{check_model}:"):
|
|
||||||
model_found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not model_found:
|
|
||||||
logger.warning(f"Model '{self.model}' not found in Ollama")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Health check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def estimate_tokens(self, text: str) -> int:
|
|
||||||
"""
|
|
||||||
Rough estimate of token count for text.
|
|
||||||
|
|
||||||
Uses simple heuristic: ~4 characters per token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Estimated token count
|
|
||||||
"""
|
|
||||||
return len(text) // 4
|
|
||||||
|
|
||||||
def chunk_text_for_embedding(
|
|
||||||
self,
|
|
||||||
text: str,
|
|
||||||
max_tokens: int = 512,
|
|
||||||
overlap: int = 50
|
|
||||||
) -> List[str]:
|
|
||||||
"""
|
|
||||||
Chunk text into segments suitable for embedding.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text
|
|
||||||
max_tokens: Maximum tokens per chunk
|
|
||||||
overlap: Token overlap between chunks
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of text chunks
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> chunks = client.chunk_text_for_embedding(long_text, max_tokens=512)
|
|
||||||
>>> all(client.estimate_tokens(c) <= 512 for c in chunks)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
# Convert tokens to approximate character count
|
|
||||||
max_chars = max_tokens * 4
|
|
||||||
overlap_chars = overlap * 4
|
|
||||||
|
|
||||||
if len(text) <= max_chars:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
start = 0
|
|
||||||
|
|
||||||
while start < len(text):
|
|
||||||
end = start + max_chars
|
|
||||||
chunk = text[start:end]
|
|
||||||
|
|
||||||
# Try to break at sentence boundary
|
|
||||||
if end < len(text):
|
|
||||||
last_period = chunk.rfind(". ")
|
|
||||||
if last_period > max_chars * 0.5: # Only break if > 50% through chunk
|
|
||||||
end = start + last_period + 1
|
|
||||||
chunk = text[start:end]
|
|
||||||
|
|
||||||
chunks.append(chunk.strip())
|
|
||||||
start = end - overlap_chars
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
"""
|
|
||||||
Qdrant client wrapper for Library Desk.
|
|
||||||
|
|
||||||
Provides async vector operations with:
|
|
||||||
- Collection-per-user multi-tenancy
|
|
||||||
- Document chunk storage with embeddings
|
|
||||||
- Semantic search
|
|
||||||
- Similarity queries
|
|
||||||
"""
|
|
||||||
|
|
||||||
from qdrant_client import QdrantClient
|
|
||||||
from qdrant_client.models import (
|
|
||||||
Distance, VectorParams, PointStruct,
|
|
||||||
Filter, FieldCondition, MatchValue
|
|
||||||
)
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from src.core.multi_tenancy import get_qdrant_collection_name
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class QdrantClientWrapper:
|
|
||||||
"""
|
|
||||||
Qdrant client wrapper with multi-tenancy support.
|
|
||||||
|
|
||||||
Pattern: Collection per user (from qdrant_memory.py)
|
|
||||||
Each user has isolated vector collection for their documents.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, url: str, embedding_dim: int = 768):
|
|
||||||
"""
|
|
||||||
Initialize Qdrant client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Qdrant server URL (e.g., "http://qdrant:6333")
|
|
||||||
embedding_dim: Vector embedding dimension (default 768 for nomic-embed-text)
|
|
||||||
"""
|
|
||||||
self.client = QdrantClient(url=url)
|
|
||||||
self.embedding_dim = embedding_dim
|
|
||||||
logger.info(f"Initialized Qdrant client: {url}")
|
|
||||||
|
|
||||||
def get_collection_name(self, user: str) -> str:
|
|
||||||
"""
|
|
||||||
Get collection name for user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Collection name (e.g., "library_desk_jpmschweitzer")
|
|
||||||
"""
|
|
||||||
return get_qdrant_collection_name(user)
|
|
||||||
|
|
||||||
async def ensure_collection(self, collection_name: str):
|
|
||||||
"""
|
|
||||||
Create collection if it doesn't exist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
collections = 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(
|
|
||||||
collection_name=collection_name,
|
|
||||||
vectors_config=VectorParams(
|
|
||||||
size=self.embedding_dim,
|
|
||||||
distance=Distance.COSINE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info(f"Created collection: {collection_name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error ensuring collection: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def collection_exists(self, collection_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if collection exists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if collection exists
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
collections = self.client.get_collections()
|
|
||||||
existing = [c.name for c in collections.collections]
|
|
||||||
return collection_name in existing
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking collection: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def upsert_document_chunks(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str,
|
|
||||||
chunks: List[Dict[str, Any]],
|
|
||||||
embeddings: List[List[float]]
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Upsert document chunks with embeddings.
|
|
||||||
|
|
||||||
Point structure:
|
|
||||||
{
|
|
||||||
id: "doc_123_chunk_0",
|
|
||||||
vector: [...],
|
|
||||||
payload: {
|
|
||||||
doc_id: "doc_123",
|
|
||||||
chunk_index: 0,
|
|
||||||
content: "text content",
|
|
||||||
metadata: {...}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
chunks: List of chunk dictionaries with content
|
|
||||||
embeddings: List of embedding vectors
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of chunks upserted
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If chunks and embeddings length mismatch
|
|
||||||
"""
|
|
||||||
if len(chunks) != len(embeddings):
|
|
||||||
raise ValueError(
|
|
||||||
f"Chunks ({len(chunks)}) and embeddings ({len(embeddings)}) length mismatch"
|
|
||||||
)
|
|
||||||
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
await self.ensure_collection(user)
|
|
||||||
|
|
||||||
points = []
|
|
||||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
|
||||||
# Generate deterministic point ID
|
|
||||||
point_id = str(uuid.uuid5(
|
|
||||||
uuid.NAMESPACE_DNS,
|
|
||||||
f"{doc_id}_chunk_{i}"
|
|
||||||
))
|
|
||||||
|
|
||||||
points.append(PointStruct(
|
|
||||||
id=point_id,
|
|
||||||
vector=embedding,
|
|
||||||
payload={
|
|
||||||
"doc_id": doc_id,
|
|
||||||
"chunk_index": i,
|
|
||||||
"content": chunk.get("content", ""),
|
|
||||||
"metadata": chunk.get("metadata", {})
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.client.upsert(
|
|
||||||
collection_name=collection_name,
|
|
||||||
points=points
|
|
||||||
)
|
|
||||||
logger.info(f"Upserted {len(points)} chunks for {doc_id}")
|
|
||||||
return len(points)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to upsert chunks: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
query_vector: List[float],
|
|
||||||
limit: int = 10,
|
|
||||||
score_threshold: float = 0.7,
|
|
||||||
filter_dict: Optional[Dict[str, Any]] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Semantic search in user's collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
query_vector: Query embedding vector
|
|
||||||
limit: Maximum results to return
|
|
||||||
score_threshold: Minimum similarity score (0.0-1.0)
|
|
||||||
filter_dict: Optional payload filters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of matches with scores and payloads
|
|
||||||
|
|
||||||
Example:
|
|
||||||
results = await client.search(
|
|
||||||
user="jpmschweitzer",
|
|
||||||
query_vector=[0.1, 0.2, ...],
|
|
||||||
limit=5,
|
|
||||||
filter_dict={"doc_id": "doc_123"}
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
# Build filter if provided
|
|
||||||
query_filter = None
|
|
||||||
if filter_dict:
|
|
||||||
conditions = []
|
|
||||||
for key, value in filter_dict.items():
|
|
||||||
conditions.append(
|
|
||||||
FieldCondition(key=key, match=MatchValue(value=value))
|
|
||||||
)
|
|
||||||
query_filter = Filter(must=conditions)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.client.query_points(
|
|
||||||
collection_name=collection_name,
|
|
||||||
query=query_vector,
|
|
||||||
limit=limit,
|
|
||||||
score_threshold=score_threshold,
|
|
||||||
query_filter=query_filter,
|
|
||||||
with_payload=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(point.id),
|
|
||||||
"score": point.score,
|
|
||||||
"doc_id": point.payload["doc_id"],
|
|
||||||
"chunk_index": point.payload["chunk_index"],
|
|
||||||
"content": point.payload["content"],
|
|
||||||
"metadata": point.payload.get("metadata", {})
|
|
||||||
}
|
|
||||||
for point in response.points
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Search failed: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def delete_document(self, user: str, doc_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Delete all chunks for a document.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted successfully
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.client.delete(
|
|
||||||
collection_name=collection_name,
|
|
||||||
points_selector=Filter(
|
|
||||||
must=[
|
|
||||||
FieldCondition(
|
|
||||||
key="doc_id",
|
|
||||||
match=MatchValue(value=doc_id)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info(f"Deleted chunks for {doc_id}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete document: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_document_chunks(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all chunks for a document.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Document ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of chunks with content and metadata
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Scroll through points with doc_id filter
|
|
||||||
points, _ = self.client.scroll(
|
|
||||||
collection_name=collection_name,
|
|
||||||
scroll_filter=Filter(
|
|
||||||
must=[
|
|
||||||
FieldCondition(
|
|
||||||
key="doc_id",
|
|
||||||
match=MatchValue(value=doc_id)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
limit=1000,
|
|
||||||
with_payload=True,
|
|
||||||
with_vectors=False
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(point.id),
|
|
||||||
"chunk_index": point.payload["chunk_index"],
|
|
||||||
"content": point.payload["content"],
|
|
||||||
"metadata": point.payload.get("metadata", {})
|
|
||||||
}
|
|
||||||
for point in points
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get document chunks: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def count_documents(self, user: str) -> int:
|
|
||||||
"""
|
|
||||||
Count total number of unique documents in user's collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of unique documents
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get collection info
|
|
||||||
collection_info = 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
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to count documents: {e}", exc_info=True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def delete_collection(self, user: str) -> bool:
|
|
||||||
"""
|
|
||||||
Delete user's entire collection.
|
|
||||||
|
|
||||||
Warning: This removes all data for the user!
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted successfully
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.client.delete_collection(collection_name)
|
|
||||||
logger.warning(f"Deleted collection: {collection_name}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete collection: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def find_similar_chunks(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
doc_id: str,
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Find chunks similar to those in a given document.
|
|
||||||
|
|
||||||
Strategy: Get all chunks from doc, use their vectors to find similar chunks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
doc_id: Source document ID
|
|
||||||
limit: Maximum results per chunk
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of similar chunks from other documents
|
|
||||||
"""
|
|
||||||
collection_name = self.get_collection_name(user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get source document chunks with vectors
|
|
||||||
source_points, _ = self.client.scroll(
|
|
||||||
collection_name=collection_name,
|
|
||||||
scroll_filter=Filter(
|
|
||||||
must=[
|
|
||||||
FieldCondition(
|
|
||||||
key="doc_id",
|
|
||||||
match=MatchValue(value=doc_id)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
limit=10, # Sample first 10 chunks
|
|
||||||
with_payload=True,
|
|
||||||
with_vectors=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not source_points:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Search using first chunk's vector
|
|
||||||
# (could aggregate multiple chunks for better results)
|
|
||||||
first_vector = source_points[0].vector
|
|
||||||
|
|
||||||
response = self.client.query_points(
|
|
||||||
collection_name=collection_name,
|
|
||||||
query=first_vector,
|
|
||||||
limit=limit * 2, # Get more to filter out same doc
|
|
||||||
with_payload=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out chunks from same document
|
|
||||||
similar = [
|
|
||||||
{
|
|
||||||
"id": str(point.id),
|
|
||||||
"score": point.score,
|
|
||||||
"doc_id": point.payload["doc_id"],
|
|
||||||
"content": point.payload["content"]
|
|
||||||
}
|
|
||||||
for point in response.points
|
|
||||||
if point.payload["doc_id"] != doc_id
|
|
||||||
]
|
|
||||||
|
|
||||||
return similar[:limit]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to find similar chunks: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def upsert_vector(
|
|
||||||
self,
|
|
||||||
collection_name: str,
|
|
||||||
vector_id: str,
|
|
||||||
vector: List[float],
|
|
||||||
payload: Dict[str, Any]
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Upsert a single vector point.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
vector_id: Point ID
|
|
||||||
vector: Embedding vector
|
|
||||||
payload: Point payload/metadata
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.client.upsert(
|
|
||||||
collection_name=collection_name,
|
|
||||||
points=[PointStruct(
|
|
||||||
id=vector_id,
|
|
||||||
vector=vector,
|
|
||||||
payload=payload
|
|
||||||
)]
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to upsert vector: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def delete_by_filter(
|
|
||||||
self,
|
|
||||||
collection_name: str,
|
|
||||||
filter_conditions: Dict[str, Any]
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Delete points matching filter conditions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
filter_conditions: Filter conditions (e.g., {"page_id": 5})
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of points deleted (approximation)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Build filter
|
|
||||||
conditions = []
|
|
||||||
for key, value in filter_conditions.items():
|
|
||||||
conditions.append(
|
|
||||||
FieldCondition(key=key, match=MatchValue(value=value))
|
|
||||||
)
|
|
||||||
query_filter = Filter(must=conditions)
|
|
||||||
|
|
||||||
# Delete points
|
|
||||||
result = self.client.delete(
|
|
||||||
collection_name=collection_name,
|
|
||||||
points_selector=query_filter
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return operation status (Qdrant doesn't return count directly)
|
|
||||||
logger.info(f"Deleted points with filter {filter_conditions}")
|
|
||||||
return 1 # Placeholder, actual count not available from API
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete by filter: {e}", exc_info=True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def search_vectors(
|
|
||||||
self,
|
|
||||||
collection_name: str,
|
|
||||||
query_vector: List[float],
|
|
||||||
limit: int = 10,
|
|
||||||
score_threshold: float = 0.5,
|
|
||||||
filter_conditions: Optional[Dict[str, Any]] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search vectors in collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
collection_name: Collection name
|
|
||||||
query_vector: Query embedding vector
|
|
||||||
limit: Maximum results
|
|
||||||
score_threshold: Minimum similarity score
|
|
||||||
filter_conditions: Optional filter conditions
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of search results with scores and payloads
|
|
||||||
"""
|
|
||||||
# Build filter if provided
|
|
||||||
query_filter = None
|
|
||||||
if filter_conditions:
|
|
||||||
conditions = []
|
|
||||||
for key, value in filter_conditions.items():
|
|
||||||
conditions.append(
|
|
||||||
FieldCondition(key=key, match=MatchValue(value=value))
|
|
||||||
)
|
|
||||||
query_filter = Filter(must=conditions)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.client.query_points(
|
|
||||||
collection_name=collection_name,
|
|
||||||
query=query_vector,
|
|
||||||
limit=limit,
|
|
||||||
score_threshold=score_threshold,
|
|
||||||
query_filter=query_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"Search failed: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def list_collections(self) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all collections with stats.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of collection info dictionaries
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
collections = self.client.get_collections()
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for coll in collections.collections:
|
|
||||||
# Get detailed collection info
|
|
||||||
try:
|
|
||||||
info = self.client.get_collection(coll.name)
|
|
||||||
result.append({
|
|
||||||
"name": coll.name,
|
|
||||||
"vectors_count": info.vectors_count or 0,
|
|
||||||
"points_count": info.points_count or 0,
|
|
||||||
"segments_count": info.segments_count or 0
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to get info for collection {coll.name}: {e}")
|
|
||||||
result.append({
|
|
||||||
"name": coll.name,
|
|
||||||
"vectors_count": 0,
|
|
||||||
"points_count": 0,
|
|
||||||
"segments_count": 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list collections: {e}", exc_info=True)
|
|
||||||
return []
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
"""
|
|
||||||
SearXNG search client for Library Desk.
|
|
||||||
|
|
||||||
Provides async web search via SearXNG instance:
|
|
||||||
- General web search
|
|
||||||
- Category-specific search
|
|
||||||
- Language filtering
|
|
||||||
- Result formatting
|
|
||||||
"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SearXNGClient:
|
|
||||||
"""
|
|
||||||
SearXNG search API client.
|
|
||||||
|
|
||||||
Documentation: https://docs.searxng.org/dev/search_api.html
|
|
||||||
API Format: JSON (?format=json)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str):
|
|
||||||
"""
|
|
||||||
Initialize SearXNG client.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_url: SearXNG base URL (e.g., "http://searxng:8080")
|
|
||||||
"""
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
self.search_url = f"{self.base_url}/search"
|
|
||||||
self.client = httpx.AsyncClient(timeout=30.0)
|
|
||||||
logger.info(f"Initialized SearXNG client: {base_url}")
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close HTTP client"""
|
|
||||||
await self.client.aclose()
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
categories: Optional[List[str]] = None,
|
|
||||||
language: str = "en",
|
|
||||||
time_range: Optional[str] = None,
|
|
||||||
safesearch: int = 0,
|
|
||||||
pageno: int = 1,
|
|
||||||
limit: int = 10
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Search via SearXNG.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query string
|
|
||||||
categories: List of categories (e.g., ["general", "images", "news"])
|
|
||||||
language: Language code (e.g., "en", "fr", "auto")
|
|
||||||
time_range: Time range filter ("day", "week", "month", "year", None for all)
|
|
||||||
safesearch: Safe search level (0=off, 1=moderate, 2=strict)
|
|
||||||
pageno: Page number (starts at 1)
|
|
||||||
limit: Maximum results to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Search results dictionary with:
|
|
||||||
- query: Original query
|
|
||||||
- results: List of result items
|
|
||||||
- number_of_results: Total results found
|
|
||||||
- suggestions: Query suggestions
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If search fails
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
"q": query,
|
|
||||||
"format": "json",
|
|
||||||
"language": language,
|
|
||||||
"safesearch": safesearch,
|
|
||||||
"pageno": pageno
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add optional parameters
|
|
||||||
if categories:
|
|
||||||
params["categories"] = ",".join(categories)
|
|
||||||
if time_range:
|
|
||||||
params["time_range"] = time_range
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await self.client.get(self.search_url, params=params)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Limit results if requested
|
|
||||||
results = data.get("results", [])
|
|
||||||
if limit:
|
|
||||||
results = results[:limit]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"query": data.get("query", query),
|
|
||||||
"results": results,
|
|
||||||
"number_of_results": data.get("number_of_results", 0),
|
|
||||||
"suggestions": data.get("suggestions", []),
|
|
||||||
"answers": data.get("answers", []),
|
|
||||||
"infoboxes": data.get("infoboxes", [])
|
|
||||||
}
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
|
||||||
raise Exception(f"SearXNG search failed: {e.response.status_code}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Search failed: {e}", exc_info=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def search_general(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
language: str = "en",
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Simplified general web search.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
language: Language code
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of search results with:
|
|
||||||
- title: Page title
|
|
||||||
- url: Page URL
|
|
||||||
- content: Page description/snippet
|
|
||||||
- engine: Search engine that provided result
|
|
||||||
- score: Relevance score (if available)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self.search(
|
|
||||||
query=query,
|
|
||||||
categories=["general"],
|
|
||||||
language=language,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
return result.get("results", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"General search failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def search_with_context(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
context: Optional[str] = None,
|
|
||||||
language: str = "en",
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search with additional context appended to query.
|
|
||||||
|
|
||||||
Useful for RAG queries where you want to enhance search with context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Primary search query
|
|
||||||
context: Additional context to append
|
|
||||||
language: Language code
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of search results
|
|
||||||
"""
|
|
||||||
enhanced_query = f"{query} {context}" if context else query
|
|
||||||
return await self.search_general(enhanced_query, language, limit)
|
|
||||||
|
|
||||||
async def search_documentation(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search for technical documentation.
|
|
||||||
|
|
||||||
Enhances query with "documentation" and filters for technical content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query (e.g., "FastAPI")
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of documentation search results
|
|
||||||
"""
|
|
||||||
enhanced_query = f"{query} documentation"
|
|
||||||
return await self.search_general(enhanced_query, limit=limit)
|
|
||||||
|
|
||||||
async def search_recent(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
time_range: str = "month",
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search for recent content only.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
time_range: Time range ("day", "week", "month", "year")
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of recent search results
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self.search(
|
|
||||||
query=query,
|
|
||||||
categories=["general"],
|
|
||||||
time_range=time_range,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
return result.get("results", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Recent search failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_suggestions(self, query: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get search query suggestions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Partial query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of suggested queries
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self.search(query=query, limit=1)
|
|
||||||
return result.get("suggestions", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get suggestions: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def search_images(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
safesearch: int = 1,
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search for images.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
safesearch: Safe search level (0=off, 1=moderate, 2=strict)
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of image results with:
|
|
||||||
- title: Image title
|
|
||||||
- url: Image URL
|
|
||||||
- thumbnail_src: Thumbnail URL
|
|
||||||
- img_src: Full image URL
|
|
||||||
- content: Description
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self.search(
|
|
||||||
query=query,
|
|
||||||
categories=["images"],
|
|
||||||
safesearch=safesearch,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
return result.get("results", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Image search failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def search_news(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
time_range: str = "week",
|
|
||||||
limit: int = 10
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search for news articles.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
time_range: Time range ("day", "week", "month")
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of news results
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await self.search(
|
|
||||||
query=query,
|
|
||||||
categories=["news"],
|
|
||||||
time_range=time_range,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
return result.get("results", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"News search failed: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def format_results_for_rag(
|
|
||||||
self,
|
|
||||||
results: List[Dict[str, Any]],
|
|
||||||
max_snippet_length: int = 500
|
|
||||||
) -> List[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Format search results for RAG context.
|
|
||||||
|
|
||||||
Extracts relevant fields and truncates content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: Raw search results
|
|
||||||
max_snippet_length: Maximum length of content snippet
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted results with title, url, snippet
|
|
||||||
"""
|
|
||||||
formatted = []
|
|
||||||
for result in results:
|
|
||||||
content = result.get("content", "")
|
|
||||||
if len(content) > max_snippet_length:
|
|
||||||
content = content[:max_snippet_length] + "..."
|
|
||||||
|
|
||||||
formatted.append({
|
|
||||||
"title": result.get("title", ""),
|
|
||||||
"url": result.get("url", ""),
|
|
||||||
"snippet": content,
|
|
||||||
"engine": result.get("engine", ""),
|
|
||||||
"score": result.get("score", 0.0)
|
|
||||||
})
|
|
||||||
|
|
||||||
return formatted
|
|
||||||
|
|
||||||
async def health_check(self) -> bool:
|
|
||||||
"""
|
|
||||||
Simple health check - verify SearXNG is responding.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if service is reachable, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Just hit the base URL to check if service is up
|
|
||||||
response = await self.client.get(self.base_url, timeout=5.0)
|
|
||||||
# Accept any 2xx or 3xx status (redirects are ok)
|
|
||||||
return response.status_code < 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"SearXNG health check failed: {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
"""
|
|
||||||
Wiki.js GraphQL client for Library Desk.
|
|
||||||
|
|
||||||
Provides async Wiki.js operations via GraphQL API:
|
|
||||||
- Page CRUD (create, read, update, delete)
|
|
||||||
- Search and listing
|
|
||||||
- Tag management
|
|
||||||
- Multi-tenancy via path namespaces
|
|
||||||
"""
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WikiJSClient:
|
|
||||||
"""
|
|
||||||
Wiki.js GraphQL API client.
|
|
||||||
|
|
||||||
Documentation: https://docs.requarks.io/dev/api
|
|
||||||
Authentication: Username/password login to get user-specific JWT token
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_url: str, username: str, password: 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
|
|
||||||
"""
|
|
||||||
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.client = httpx.AsyncClient(timeout=30.0)
|
|
||||||
logger.info(f"Initialized Wiki.js client: {base_url} (user: {username})")
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
async def _execute_query(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
variables: Optional[Dict] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Execute GraphQL query.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: GraphQL query string
|
|
||||||
variables: Query variables
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Query result data
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await self.client.post(
|
|
||||||
self.graphql_url,
|
|
||||||
headers=headers,
|
|
||||||
json=payload
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
if "errors" in result:
|
|
||||||
logger.error(f"GraphQL errors: {result['errors']}")
|
|
||||||
raise Exception(f"GraphQL errors: {result['errors']}")
|
|
||||||
|
|
||||||
return result.get("data", {})
|
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
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]]:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of page objects
|
|
||||||
"""
|
|
||||||
query = """
|
|
||||||
query ListPages($limit: Int, $orderBy: PageOrderBy) {
|
|
||||||
pages {
|
|
||||||
list(limit: $limit, orderBy: $orderBy) {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
title
|
|
||||||
description
|
|
||||||
tags
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
isPublished
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
variables = {
|
|
||||||
"limit": limit,
|
|
||||||
"orderBy": "TITLE"
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await self._execute_query(query, variables)
|
|
||||||
pages = data.get("pages", {}).get("list", [])
|
|
||||||
|
|
||||||
# Ensure tags is always a list
|
|
||||||
for page in pages:
|
|
||||||
if "tags" not in page or page["tags"] is None:
|
|
||||||
page["tags"] = []
|
|
||||||
|
|
||||||
# Filter by path prefix (client-side if API doesn't support)
|
|
||||||
if path_prefix:
|
|
||||||
# Normalize paths to have leading slash for consistent comparison
|
|
||||||
normalized_prefix = "/" + path_prefix.lstrip("/")
|
|
||||||
pages = [
|
|
||||||
p for p in pages
|
|
||||||
if ("/" + p["path"].lstrip("/")).startswith(normalized_prefix)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Filter by tags (dossiers)
|
|
||||||
if tags:
|
|
||||||
pages = [
|
|
||||||
p for p in pages
|
|
||||||
if any(tag in (p.get("tags") or []) for tag in tags)
|
|
||||||
]
|
|
||||||
|
|
||||||
return pages
|
|
||||||
|
|
||||||
async def list_all_pages(
|
|
||||||
self,
|
|
||||||
path_prefix: str = "",
|
|
||||||
tags: Optional[List[str]] = None,
|
|
||||||
batch_size: int = 100
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List ALL pages with pagination support.
|
|
||||||
|
|
||||||
Fetches pages in batches until all are retrieved.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path_prefix: Filter by path prefix (e.g., "users/jpmschweitzer")
|
|
||||||
tags: Filter by tags
|
|
||||||
batch_size: Number of pages per batch (max 100)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
logger.info(f"list_all_pages: found {len(all_pages)} pages (prefix: {path_prefix or 'all'})")
|
|
||||||
return all_pages
|
|
||||||
|
|
||||||
async def get_taxonomy_structure(
|
|
||||||
self,
|
|
||||||
user_namespace: str,
|
|
||||||
limit: int = 500
|
|
||||||
) -> Dict[str, List[str]]:
|
|
||||||
"""
|
|
||||||
Get the existing taxonomy structure for a user namespace.
|
|
||||||
|
|
||||||
Returns a dict mapping top-level categories to their subcategories.
|
|
||||||
This helps the LLM choose existing paths rather than creating new ones.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_namespace: User namespace (e.g., "users/jpmschweitzer")
|
|
||||||
limit: Maximum pages to fetch
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict like {"reference": ["political-entities", "tech"], "places": ["the-netherlands"]}
|
|
||||||
"""
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
pages = await self.list_pages(path_prefix=user_namespace, limit=limit)
|
|
||||||
|
|
||||||
# Extract structure: category -> set of subcategories
|
|
||||||
structure: Dict[str, set] = defaultdict(set)
|
|
||||||
prefix = user_namespace.strip("/") + "/"
|
|
||||||
|
|
||||||
for page in pages:
|
|
||||||
path = page.get("path", "")
|
|
||||||
if not path.startswith(prefix):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get relative path within user namespace
|
|
||||||
rel_path = path[len(prefix):]
|
|
||||||
parts = rel_path.split("/")
|
|
||||||
|
|
||||||
if len(parts) >= 1:
|
|
||||||
category = parts[0]
|
|
||||||
if len(parts) >= 2:
|
|
||||||
# Has subcategory (e.g., reference/political-entities/nato)
|
|
||||||
structure[category].add(parts[1])
|
|
||||||
else:
|
|
||||||
# Just ensure category exists in structure
|
|
||||||
structure[category] # Access to ensure key exists
|
|
||||||
|
|
||||||
# Convert sets to sorted lists
|
|
||||||
return {cat: sorted(list(subs)) for cat, subs in sorted(structure.items())}
|
|
||||||
|
|
||||||
async def get_page(self, page_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get single page by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Page object or None if not found
|
|
||||||
"""
|
|
||||||
query = """
|
|
||||||
query GetPage($id: Int!) {
|
|
||||||
pages {
|
|
||||||
single(id: $id) {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
title
|
|
||||||
description
|
|
||||||
content
|
|
||||||
tags {
|
|
||||||
tag
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
isPublished
|
|
||||||
editor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Ensure page_id is int (GraphQL requires Int, not String)
|
|
||||||
data = await self._execute_query(query, {"id": int(page_id)})
|
|
||||||
page = data.get("pages", {}).get("single")
|
|
||||||
|
|
||||||
# Extract tag strings from tag objects
|
|
||||||
if page and "tags" in page and page["tags"]:
|
|
||||||
page["tags"] = [t["tag"] for t in page["tags"]]
|
|
||||||
elif page:
|
|
||||||
page["tags"] = []
|
|
||||||
|
|
||||||
return page
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get page {page_id}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def create_page(
|
|
||||||
self,
|
|
||||||
path: str,
|
|
||||||
title: str,
|
|
||||||
content: str,
|
|
||||||
description: str = "",
|
|
||||||
tags: Optional[List[str]] = None,
|
|
||||||
is_published: bool = True,
|
|
||||||
is_private: bool = False,
|
|
||||||
editor: str = "markdown"
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Create new page.
|
|
||||||
|
|
||||||
Multi-tenancy: Ensure path starts with user namespace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Page path (e.g., "/users/jpmschweitzer/projects/library-desk")
|
|
||||||
title: Page title
|
|
||||||
content: Page content (markdown)
|
|
||||||
description: Short description
|
|
||||||
tags: List of tags (for dossier organization)
|
|
||||||
is_published: Whether page is published
|
|
||||||
is_private: Whether page is private
|
|
||||||
editor: Editor type (markdown, wysiwyg, etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created page object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If creation fails
|
|
||||||
"""
|
|
||||||
mutation = """
|
|
||||||
mutation CreatePage(
|
|
||||||
$content: String!,
|
|
||||||
$description: String!,
|
|
||||||
$editor: String!,
|
|
||||||
$isPublished: Boolean!,
|
|
||||||
$isPrivate: Boolean!,
|
|
||||||
$locale: String!,
|
|
||||||
$path: String!,
|
|
||||||
$tags: [String]!,
|
|
||||||
$title: String!
|
|
||||||
) {
|
|
||||||
pages {
|
|
||||||
create(
|
|
||||||
content: $content,
|
|
||||||
description: $description,
|
|
||||||
editor: $editor,
|
|
||||||
isPublished: $isPublished,
|
|
||||||
isPrivate: $isPrivate,
|
|
||||||
locale: $locale,
|
|
||||||
path: $path,
|
|
||||||
tags: $tags,
|
|
||||||
title: $title
|
|
||||||
) {
|
|
||||||
responseResult {
|
|
||||||
succeeded
|
|
||||||
errorCode
|
|
||||||
message
|
|
||||||
}
|
|
||||||
page {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
variables = {
|
|
||||||
"path": path,
|
|
||||||
"title": title,
|
|
||||||
"content": content,
|
|
||||||
"description": description,
|
|
||||||
"tags": tags or [],
|
|
||||||
"isPublished": is_published,
|
|
||||||
"isPrivate": is_private,
|
|
||||||
"editor": editor,
|
|
||||||
"locale": "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await self._execute_query(mutation, variables)
|
|
||||||
result = data.get("pages", {}).get("create", {})
|
|
||||||
|
|
||||||
if not result.get("responseResult", {}).get("succeeded"):
|
|
||||||
error = result.get("responseResult", {})
|
|
||||||
raise Exception(f"Failed to create page: {error}")
|
|
||||||
|
|
||||||
logger.info(f"Created page: {path}")
|
|
||||||
return result.get("page")
|
|
||||||
|
|
||||||
async def update_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
content: Optional[str] = None,
|
|
||||||
title: Optional[str] = None,
|
|
||||||
description: Optional[str] = None,
|
|
||||||
tags: Optional[List[str]] = None,
|
|
||||||
is_published: Optional[bool] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Update existing page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID
|
|
||||||
content: New content (optional)
|
|
||||||
title: New title (optional)
|
|
||||||
description: New description (optional)
|
|
||||||
tags: New tags (optional)
|
|
||||||
is_published: Published status (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated page object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If update fails
|
|
||||||
"""
|
|
||||||
mutation = """
|
|
||||||
mutation UpdatePage(
|
|
||||||
$id: Int!,
|
|
||||||
$content: String,
|
|
||||||
$title: String,
|
|
||||||
$description: String,
|
|
||||||
$tags: [String],
|
|
||||||
$isPublished: Boolean
|
|
||||||
) {
|
|
||||||
pages {
|
|
||||||
update(
|
|
||||||
id: $id,
|
|
||||||
content: $content,
|
|
||||||
title: $title,
|
|
||||||
description: $description,
|
|
||||||
tags: $tags,
|
|
||||||
isPublished: $isPublished
|
|
||||||
) {
|
|
||||||
responseResult {
|
|
||||||
succeeded
|
|
||||||
errorCode
|
|
||||||
message
|
|
||||||
}
|
|
||||||
page {
|
|
||||||
id
|
|
||||||
updatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
variables = {"id": page_id}
|
|
||||||
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
|
|
||||||
|
|
||||||
data = await self._execute_query(mutation, variables)
|
|
||||||
result = data.get("pages", {}).get("update", {})
|
|
||||||
|
|
||||||
if not result.get("responseResult", {}).get("succeeded"):
|
|
||||||
error = result.get("responseResult", {})
|
|
||||||
raise Exception(f"Failed to update page: {error}")
|
|
||||||
|
|
||||||
logger.info(f"Updated page: {page_id}")
|
|
||||||
return result.get("page")
|
|
||||||
|
|
||||||
async def delete_page(self, page_id: int):
|
|
||||||
"""
|
|
||||||
Delete page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If deletion fails
|
|
||||||
"""
|
|
||||||
mutation = """
|
|
||||||
mutation DeletePage($id: Int!) {
|
|
||||||
pages {
|
|
||||||
delete(id: $id) {
|
|
||||||
responseResult {
|
|
||||||
succeeded
|
|
||||||
errorCode
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = await self._execute_query(mutation, {"id": page_id})
|
|
||||||
result = data.get("pages", {}).get("delete", {})
|
|
||||||
|
|
||||||
if not result.get("responseResult", {}).get("succeeded"):
|
|
||||||
error = result.get("responseResult", {})
|
|
||||||
raise Exception(f"Failed to delete page: {error}")
|
|
||||||
|
|
||||||
logger.info(f"Deleted page: {page_id}")
|
|
||||||
|
|
||||||
async def search_pages(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
path_prefix: Optional[str] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Search pages by content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
path_prefix: Optional path prefix filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of matching pages
|
|
||||||
"""
|
|
||||||
gql_query = """
|
|
||||||
query SearchPages($query: String!) {
|
|
||||||
pages {
|
|
||||||
search(query: $query) {
|
|
||||||
results {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
title
|
|
||||||
description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
data = await self._execute_query(gql_query, {"query": query})
|
|
||||||
results = data.get("pages", {}).get("search", {}).get("results", [])
|
|
||||||
|
|
||||||
# Filter by path prefix if provided
|
|
||||||
if path_prefix:
|
|
||||||
results = [r for r in results if r["path"].startswith(path_prefix)]
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def get_page_tree(self, path: str = "/") -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get page tree structure.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Root path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tree structure of pages
|
|
||||||
"""
|
|
||||||
query = """
|
|
||||||
query GetPageTree($parent: Int, $mode: String!) {
|
|
||||||
pages {
|
|
||||||
tree(parent: $parent, mode: $mode) {
|
|
||||||
id
|
|
||||||
path
|
|
||||||
title
|
|
||||||
isFolder
|
|
||||||
pageId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await self._execute_query(
|
|
||||||
query,
|
|
||||||
{"parent": 0, "mode": "all"}
|
|
||||||
)
|
|
||||||
return data.get("pages", {}).get("tree", [])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get page tree: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def move_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
new_path: str,
|
|
||||||
locale: str = "en"
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Move/rename page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID
|
|
||||||
new_path: New page path
|
|
||||||
locale: Page locale
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful
|
|
||||||
"""
|
|
||||||
mutation = """
|
|
||||||
mutation MovePage($id: Int!, $destinationPath: String!, $destinationLocale: String!) {
|
|
||||||
pages {
|
|
||||||
move(id: $id, destinationPath: $destinationPath, destinationLocale: $destinationLocale) {
|
|
||||||
responseResult {
|
|
||||||
succeeded
|
|
||||||
errorCode
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await self._execute_query(
|
|
||||||
mutation,
|
|
||||||
{
|
|
||||||
"id": page_id,
|
|
||||||
"destinationPath": new_path,
|
|
||||||
"destinationLocale": locale
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = data.get("pages", {}).get("move", {})
|
|
||||||
success = result.get("responseResult", {}).get("succeeded", False)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Moved page {page_id} to {new_path}")
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to move page: {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
Application configuration using Pydantic Settings.
|
|
||||||
Following best practices: modular settings, environment-based config.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""Application settings loaded from environment variables."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=False,
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
library_api_key: str = Field(..., description="API key for authentication")
|
|
||||||
|
|
||||||
# Neo4j Configuration
|
|
||||||
neo4j_uri: str = Field(default="bolt://neo4j:7687", description="Neo4j Bolt URI")
|
|
||||||
neo4j_user: str = Field(default="neo4j", description="Neo4j username")
|
|
||||||
neo4j_password: str = Field(..., description="Neo4j password")
|
|
||||||
|
|
||||||
# Qdrant Configuration
|
|
||||||
qdrant_host: str = Field(default="qdrant", description="Qdrant host")
|
|
||||||
qdrant_port: int = Field(default=6333, description="Qdrant port")
|
|
||||||
|
|
||||||
# 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.js Database Configuration (for change listener)
|
|
||||||
wikijs_db_host: str = Field(default="postgres-shared", description="Wiki.js PostgreSQL host")
|
|
||||||
wikijs_db_port: int = Field(default=5432, description="Wiki.js PostgreSQL port")
|
|
||||||
wikijs_db_name: str = Field(default="library", description="Wiki.js database name")
|
|
||||||
wikijs_db_user: str = Field(default="library_desk_listener", description="Wiki.js database user (read-only)")
|
|
||||||
wikijs_db_password: str = Field(..., description="Wiki.js database password")
|
|
||||||
wikijs_change_listener_debounce_seconds: int = Field(
|
|
||||||
default=5,
|
|
||||||
ge=1,
|
|
||||||
le=60,
|
|
||||||
description="Debounce duration to prevent processing duplicate notifications"
|
|
||||||
)
|
|
||||||
|
|
||||||
# SearXNG Configuration
|
|
||||||
searxng_url: str = Field(default="http://searxng:8080", description="SearXNG URL")
|
|
||||||
|
|
||||||
# Ollama Configuration (for embeddings)
|
|
||||||
ollama_url: str = Field(default="http://ollama:11434", description="Ollama URL")
|
|
||||||
ollama_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")
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
entity_linking_min_entity_length: int = Field(default=5, ge=1, le=50, description="Minimum entity name length for matching")
|
|
||||||
entity_linking_min_containment_ratio: float = Field(default=0.30, ge=0.0, le=1.0, description="Minimum containment ratio for substring matching")
|
|
||||||
entity_linking_min_token_overlap: float = Field(default=0.60, ge=0.0, le=1.0, description="Minimum token overlap ratio for matching")
|
|
||||||
|
|
||||||
# Redis Configuration (for job tracking - separate DB from wiki)
|
|
||||||
redis_host: str = Field(default="redis-shared", description="Redis host")
|
|
||||||
redis_port: int = Field(default=6379, description="Redis port")
|
|
||||||
redis_db: int = Field(default=4, description="Redis database number (4 for library-desk jobs)")
|
|
||||||
|
|
||||||
# Application
|
|
||||||
app_name: str = Field(default="Library Desk", description="Application name")
|
|
||||||
app_version: str = Field(default="1.0.0", description="Application version")
|
|
||||||
debug: bool = Field(default=False, description="Debug mode")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def qdrant_url(self) -> str:
|
|
||||||
"""Computed Qdrant URL."""
|
|
||||||
return f"http://{self.qdrant_host}:{self.qdrant_port}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def redis_url(self) -> str:
|
|
||||||
"""Computed Redis URL."""
|
|
||||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""
|
|
||||||
Get cached settings instance.
|
|
||||||
Uses lru_cache to ensure single instance across app.
|
|
||||||
"""
|
|
||||||
return Settings()
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
"""
|
|
||||||
Dependency injection for Library Desk.
|
|
||||||
|
|
||||||
Provides FastAPI dependencies for service clients with:
|
|
||||||
- Singleton pattern via @lru_cache
|
|
||||||
- Lazy initialization
|
|
||||||
- Proper lifecycle management
|
|
||||||
- Type aliases for clean endpoint signatures
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Annotated
|
|
||||||
from fastapi import Depends
|
|
||||||
import logging
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# Settings dependency
|
|
||||||
SettingsDep = Annotated[Settings, Depends(get_settings)]
|
|
||||||
|
|
||||||
|
|
||||||
# Client factory functions with @lru_cache for singletons
|
|
||||||
@lru_cache
|
|
||||||
def get_neo4j_client() -> Neo4jClient:
|
|
||||||
"""
|
|
||||||
Get Neo4j client singleton.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initialized Neo4j client (not yet connected)
|
|
||||||
|
|
||||||
Note: Call client.connect() during app startup
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
client = Neo4jClient(
|
|
||||||
uri=settings.neo4j_uri,
|
|
||||||
user=settings.neo4j_user,
|
|
||||||
password=settings.neo4j_password
|
|
||||||
)
|
|
||||||
logger.debug("Created Neo4j client instance")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_qdrant_client() -> QdrantClientWrapper:
|
|
||||||
"""
|
|
||||||
Get Qdrant client singleton.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initialized Qdrant client
|
|
||||||
|
|
||||||
Note: Collections are created lazily per-user
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
client = QdrantClientWrapper(
|
|
||||||
url=settings.qdrant_url,
|
|
||||||
embedding_dim=768 # nomic-embed-text default
|
|
||||||
)
|
|
||||||
logger.debug("Created Qdrant client instance")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_wikijs_client() -> WikiJSClient:
|
|
||||||
"""
|
|
||||||
Get Wiki.js client singleton.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initialized Wiki.js GraphQL client with username/password auth
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
client = WikiJSClient(
|
|
||||||
base_url=settings.wikijs_url,
|
|
||||||
username=settings.wikijs_username,
|
|
||||||
password=settings.wikijs_password
|
|
||||||
)
|
|
||||||
logger.debug("Created Wiki.js client instance")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_searxng_client() -> SearXNGClient:
|
|
||||||
"""
|
|
||||||
Get SearXNG client singleton.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initialized SearXNG search client
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
client = SearXNGClient(base_url=settings.searxng_url)
|
|
||||||
logger.debug("Created SearXNG client instance")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_ollama_client() -> OllamaClient:
|
|
||||||
"""
|
|
||||||
Get Ollama client singleton.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initialized Ollama embeddings client
|
|
||||||
"""
|
|
||||||
settings = get_settings()
|
|
||||||
client = OllamaClient(
|
|
||||||
base_url=settings.ollama_url,
|
|
||||||
model=settings.ollama_model
|
|
||||||
)
|
|
||||||
logger.debug("Created Ollama client instance")
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
# Type aliases for FastAPI endpoint dependencies
|
|
||||||
# Usage: def my_endpoint(neo4j: Neo4jDep):
|
|
||||||
Neo4jDep = Annotated[Neo4jClient, Depends(get_neo4j_client)]
|
|
||||||
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)]
|
|
||||||
|
|
||||||
|
|
||||||
# Lifecycle management functions
|
|
||||||
async def startup_clients():
|
|
||||||
"""
|
|
||||||
Initialize all service clients at application startup.
|
|
||||||
|
|
||||||
Should be called in FastAPI lifespan or startup event.
|
|
||||||
|
|
||||||
Performs:
|
|
||||||
- Neo4j connection pool initialization
|
|
||||||
- Neo4j connectivity verification
|
|
||||||
- Ollama model availability check
|
|
||||||
"""
|
|
||||||
logger.info("Starting up service clients...")
|
|
||||||
|
|
||||||
# Initialize Neo4j connection pool
|
|
||||||
neo4j = get_neo4j_client()
|
|
||||||
try:
|
|
||||||
await neo4j.connect()
|
|
||||||
logger.info("✓ Neo4j connected")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"✗ Neo4j connection failed: {e}")
|
|
||||||
# Don't fail startup - allow degraded operation
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check Ollama availability
|
|
||||||
ollama = get_ollama_client()
|
|
||||||
try:
|
|
||||||
is_healthy = await ollama.health_check()
|
|
||||||
if is_healthy:
|
|
||||||
logger.info(f"✓ Ollama ready (model: {ollama.model})")
|
|
||||||
else:
|
|
||||||
logger.warning(f"✗ Ollama model '{ollama.model}' not available")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"✗ Ollama health check failed: {e}")
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Qdrant, Wiki.js, SearXNG are lazy-initialized
|
|
||||||
logger.info("Service clients startup complete")
|
|
||||||
|
|
||||||
|
|
||||||
async def shutdown_clients():
|
|
||||||
"""
|
|
||||||
Cleanup all service clients at application shutdown.
|
|
||||||
|
|
||||||
Should be called in FastAPI lifespan or shutdown event.
|
|
||||||
|
|
||||||
Performs:
|
|
||||||
- Close Neo4j connection pool
|
|
||||||
- Close HTTP clients
|
|
||||||
"""
|
|
||||||
logger.info("Shutting down service clients...")
|
|
||||||
|
|
||||||
# Close Neo4j driver
|
|
||||||
neo4j = get_neo4j_client()
|
|
||||||
try:
|
|
||||||
await neo4j.close()
|
|
||||||
logger.info("✓ Neo4j closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error closing Neo4j: {e}")
|
|
||||||
|
|
||||||
# Close HTTP clients
|
|
||||||
clients_to_close = [
|
|
||||||
("Wiki.js", get_wikijs_client()),
|
|
||||||
("SearXNG", get_searxng_client()),
|
|
||||||
("Ollama", get_ollama_client())
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, client in clients_to_close:
|
|
||||||
try:
|
|
||||||
await client.close()
|
|
||||||
logger.info(f"✓ {name} client closed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error closing {name} client: {e}")
|
|
||||||
|
|
||||||
logger.info("Service clients shutdown complete")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_service_health() -> dict:
|
|
||||||
"""
|
|
||||||
Check health of all service clients.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with health status of each service:
|
|
||||||
{
|
|
||||||
"neo4j": bool,
|
|
||||||
"qdrant": bool,
|
|
||||||
"wikijs": bool,
|
|
||||||
"searxng": bool,
|
|
||||||
"ollama": bool
|
|
||||||
}
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
>>> health = await check_service_health()
|
|
||||||
>>> health["neo4j"]
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
health = {}
|
|
||||||
|
|
||||||
# Neo4j
|
|
||||||
try:
|
|
||||||
neo4j = get_neo4j_client()
|
|
||||||
# Simple query to check connectivity
|
|
||||||
await neo4j.execute_query("RETURN 1 as test", {})
|
|
||||||
health["neo4j"] = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Neo4j health check failed: {e}")
|
|
||||||
health["neo4j"] = False
|
|
||||||
|
|
||||||
# Qdrant
|
|
||||||
try:
|
|
||||||
qdrant = get_qdrant_client()
|
|
||||||
# Check if we can list collections
|
|
||||||
collections = qdrant.client.get_collections()
|
|
||||||
health["qdrant"] = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Qdrant health check failed: {e}")
|
|
||||||
health["qdrant"] = False
|
|
||||||
|
|
||||||
# Wiki.js
|
|
||||||
try:
|
|
||||||
wikijs = get_wikijs_client()
|
|
||||||
# Try a simple query (list pages with limit 1)
|
|
||||||
await wikijs.list_pages(limit=1)
|
|
||||||
health["wikijs"] = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Wiki.js health check failed: {e}")
|
|
||||||
health["wikijs"] = False
|
|
||||||
|
|
||||||
# SearXNG
|
|
||||||
try:
|
|
||||||
searxng = get_searxng_client()
|
|
||||||
# Just check if service is up (no actual search)
|
|
||||||
health["searxng"] = await searxng.health_check()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"SearXNG health check failed: {e}")
|
|
||||||
health["searxng"] = False
|
|
||||||
|
|
||||||
# Ollama
|
|
||||||
try:
|
|
||||||
ollama = get_ollama_client()
|
|
||||||
is_healthy = await ollama.health_check()
|
|
||||||
health["ollama"] = is_healthy
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ollama health check failed: {e}")
|
|
||||||
health["ollama"] = False
|
|
||||||
|
|
||||||
return health
|
|
||||||
|
|
||||||
|
|
||||||
# Service factory functions
|
|
||||||
@lru_cache
|
|
||||||
def get_vector_service() -> "VectorService":
|
|
||||||
"""Get VectorService singleton."""
|
|
||||||
from src.services.vector_service import VectorService
|
|
||||||
return VectorService(
|
|
||||||
qdrant_client=get_qdrant_client(),
|
|
||||||
wikijs_client=get_wikijs_client(),
|
|
||||||
ollama_client=get_ollama_client()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_graph_service() -> "GraphService":
|
|
||||||
"""Get GraphService singleton."""
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
return GraphService(
|
|
||||||
neo4j_client=get_neo4j_client(),
|
|
||||||
wikijs_client=get_wikijs_client()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_wiki_service() -> "WikiService":
|
|
||||||
"""Get WikiService singleton."""
|
|
||||||
from src.services.wiki_service import WikiService
|
|
||||||
return WikiService(wiki_client=get_wikijs_client())
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_consolidation_service() -> "ConsolidationService":
|
|
||||||
"""Get ConsolidationService singleton."""
|
|
||||||
from src.services.consolidation_service import ConsolidationService
|
|
||||||
return ConsolidationService(
|
|
||||||
neo4j=get_neo4j_client(),
|
|
||||||
ollama=get_ollama_client(),
|
|
||||||
wiki=get_wikijs_client(),
|
|
||||||
settings=get_settings(),
|
|
||||||
ingestion_service=get_ingestion_service()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_ingestion_service() -> "IngestionService":
|
|
||||||
"""Get IngestionService singleton."""
|
|
||||||
from src.services.ingestion_service import IngestionService
|
|
||||||
return IngestionService(
|
|
||||||
vector_service=get_vector_service(),
|
|
||||||
graph_service=get_graph_service(),
|
|
||||||
wiki_client=get_wikijs_client()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_hybrid_rag_service() -> "HybridRAGService":
|
|
||||||
"""Get HybridRAGService singleton."""
|
|
||||||
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(),
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
from fastapi import Security, HTTPException
|
|
||||||
from fastapi.security import HTTPBearer
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
|
|
||||||
|
|
||||||
async def verify_api_key(
|
|
||||||
credentials: Annotated[HTTPBearer, Security(security)],
|
|
||||||
settings: SettingsDep
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Verify API key from Bearer token.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
credentials: HTTP Bearer credentials
|
|
||||||
settings: Application settings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
API key if valid
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If API key is invalid
|
|
||||||
"""
|
|
||||||
if credentials.credentials != settings.library_api_key:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Invalid API key"
|
|
||||||
)
|
|
||||||
return credentials.credentials
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""
|
|
||||||
Multi-tenancy helpers for Library Desk.
|
|
||||||
|
|
||||||
Provides utilities for user namespace management across:
|
|
||||||
- Wiki.js (path-based namespaces)
|
|
||||||
- Neo4j (user-specific labels)
|
|
||||||
- Qdrant (collection per user)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Default user for all operations
|
|
||||||
DEFAULT_USER = "jpmschweitzer"
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_user_id(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Sanitize user ID for use in collection names, labels, and paths.
|
|
||||||
|
|
||||||
Converts special characters to underscores and ensures alphanumeric safety.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Raw user identifier (email, username, etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sanitized user ID safe for use in identifiers
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> sanitize_user_id("john@example.com")
|
|
||||||
'john_at_example_com'
|
|
||||||
>>> sanitize_user_id("user.name")
|
|
||||||
'user_name'
|
|
||||||
>>> sanitize_user_id("User Name")
|
|
||||||
'user_name'
|
|
||||||
"""
|
|
||||||
sanitized = user_id.lower()
|
|
||||||
|
|
||||||
# Convert @ to _at_
|
|
||||||
sanitized = sanitized.replace("@", "_at_")
|
|
||||||
|
|
||||||
# Convert dots to underscores
|
|
||||||
sanitized = sanitized.replace(".", "_")
|
|
||||||
|
|
||||||
# Replace any non-alphanumeric characters with underscores
|
|
||||||
sanitized = re.sub(r'[^a-z0-9_]', '_', sanitized)
|
|
||||||
|
|
||||||
# Remove consecutive underscores
|
|
||||||
sanitized = re.sub(r'_+', '_', sanitized)
|
|
||||||
|
|
||||||
# Remove leading/trailing underscores
|
|
||||||
sanitized = sanitized.strip('_')
|
|
||||||
|
|
||||||
return sanitized
|
|
||||||
|
|
||||||
|
|
||||||
def get_qdrant_collection_name(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get Qdrant collection name for user.
|
|
||||||
|
|
||||||
Pattern: library_desk_{sanitized_user_id}
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Qdrant collection name
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_qdrant_collection_name("jpmschweitzer")
|
|
||||||
'library_desk_jpmschweitzer'
|
|
||||||
>>> get_qdrant_collection_name("john@example.com")
|
|
||||||
'library_desk_john_at_example_com'
|
|
||||||
"""
|
|
||||||
sanitized = sanitize_user_id(user_id)
|
|
||||||
return f"library_desk_{sanitized}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_wikijs_namespace(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get Wiki.js namespace (path prefix) for user.
|
|
||||||
|
|
||||||
Pattern: /users/{sanitized_user_id}
|
|
||||||
|
|
||||||
All wiki pages for a user will be under this namespace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Wiki.js path prefix
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_wikijs_namespace("jpmschweitzer")
|
|
||||||
'/users/jpmschweitzer'
|
|
||||||
>>> get_wikijs_namespace("john@example.com")
|
|
||||||
'/users/john_at_example_com'
|
|
||||||
"""
|
|
||||||
sanitized = sanitize_user_id(user_id)
|
|
||||||
return f"/users/{sanitized}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_neo4j_user_base_label(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get Neo4j base label for user's nodes (entities, etc).
|
|
||||||
|
|
||||||
Pattern: User_{Sanitized}
|
|
||||||
|
|
||||||
Uses title case for Neo4j label convention.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Neo4j base label for user's nodes
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_neo4j_user_base_label("jpmschweitzer")
|
|
||||||
'User_Jpmschweitzer'
|
|
||||||
>>> get_neo4j_user_base_label("john@example.com")
|
|
||||||
'User_John_At_Example_Com'
|
|
||||||
"""
|
|
||||||
sanitized = sanitize_user_id(user_id)
|
|
||||||
# Title case each segment for Neo4j label convention
|
|
||||||
parts = sanitized.split('_')
|
|
||||||
titled = '_'.join(part.capitalize() for part in parts if part)
|
|
||||||
return f"User_{titled}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_neo4j_user_label(user_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get Neo4j label for user's documents.
|
|
||||||
|
|
||||||
Pattern: User_{Sanitized}_Document
|
|
||||||
|
|
||||||
Uses title case for Neo4j label convention.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Neo4j label for user's document nodes
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> get_neo4j_user_label("jpmschweitzer")
|
|
||||||
'User_Jpmschweitzer_Document'
|
|
||||||
>>> get_neo4j_user_label("john@example.com")
|
|
||||||
'User_John_At_Example_Com_Document'
|
|
||||||
"""
|
|
||||||
sanitized = sanitize_user_id(user_id)
|
|
||||||
# Title case each segment for Neo4j label convention
|
|
||||||
parts = sanitized.split('_')
|
|
||||||
titled = '_'.join(part.capitalize() for part in parts if part)
|
|
||||||
return f"User_{titled}_Document"
|
|
||||||
|
|
||||||
|
|
||||||
def validate_user_id(user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Validate that a user ID is acceptable.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
- Not empty
|
|
||||||
- Not too long (max 100 chars)
|
|
||||||
- Contains some alphanumeric characters
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User identifier to validate
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, False otherwise
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> validate_user_id("jpmschweitzer")
|
|
||||||
True
|
|
||||||
>>> validate_user_id("")
|
|
||||||
False
|
|
||||||
>>> validate_user_id("a" * 101)
|
|
||||||
False
|
|
||||||
"""
|
|
||||||
if not user_id or len(user_id) > 100:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Must contain at least one alphanumeric character
|
|
||||||
if not re.search(r'[a-zA-Z0-9]', user_id):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_path_in_user_namespace(path: str, user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a Wiki.js path belongs to user's namespace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Wiki.js page path
|
|
||||||
user_id: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if path is in user's namespace
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> is_path_in_user_namespace("/users/jpmschweitzer/projects", "jpmschweitzer")
|
|
||||||
True
|
|
||||||
>>> is_path_in_user_namespace("/users/other/projects", "jpmschweitzer")
|
|
||||||
False
|
|
||||||
>>> is_path_in_user_namespace("/public/docs", "jpmschweitzer")
|
|
||||||
False
|
|
||||||
"""
|
|
||||||
namespace = get_wikijs_namespace(user_id)
|
|
||||||
return path.startswith(namespace)
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""
|
|
||||||
Redis-based job tracking for Library Desk.
|
|
||||||
|
|
||||||
Provides background job management with:
|
|
||||||
- Job creation and status tracking
|
|
||||||
- Progress updates
|
|
||||||
- Result storage
|
|
||||||
- Auto-expiration after 24 hours
|
|
||||||
- User-scoped job queries
|
|
||||||
"""
|
|
||||||
|
|
||||||
import redis.asyncio as redis
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional, Dict, Any, List
|
|
||||||
from enum import Enum
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(str, Enum):
|
|
||||||
"""Job status enumeration."""
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class JobType(str, Enum):
|
|
||||||
"""Job type enumeration."""
|
|
||||||
DOCUMENT_INGESTION = "document_ingestion"
|
|
||||||
BATCH_INGESTION = "batch_ingestion"
|
|
||||||
GRAPH_UPDATE = "graph_update"
|
|
||||||
VECTOR_UPDATE = "vector_update"
|
|
||||||
EMBEDDINGS_GENERATION = "embeddings_generation"
|
|
||||||
|
|
||||||
|
|
||||||
class JobManager:
|
|
||||||
"""
|
|
||||||
Redis-based job tracking manager.
|
|
||||||
|
|
||||||
Key structure:
|
|
||||||
- library:job:{job_id} - Job data (JSON, TTL 24h)
|
|
||||||
- library:user_jobs:{user} - Set of job IDs for user
|
|
||||||
- library:active_jobs - Set of active (non-completed) job IDs
|
|
||||||
|
|
||||||
Job data format:
|
|
||||||
{
|
|
||||||
"job_id": "uuid",
|
|
||||||
"job_type": "document_ingestion",
|
|
||||||
"status": "processing",
|
|
||||||
"user": "jpmschweitzer",
|
|
||||||
"parameters": {...},
|
|
||||||
"result": {...},
|
|
||||||
"error": "error message",
|
|
||||||
"progress": 50,
|
|
||||||
"created_at": "2024-01-15T10:30:00Z",
|
|
||||||
"started_at": "2024-01-15T10:30:05Z",
|
|
||||||
"completed_at": "2024-01-15T10:35:00Z"
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, redis_url: str):
|
|
||||||
"""
|
|
||||||
Initialize job manager.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
redis_url: Redis connection URL (e.g., "redis://redis-shared:6379/4")
|
|
||||||
"""
|
|
||||||
self.redis_url = redis_url
|
|
||||||
self._redis: Optional[redis.Redis] = None
|
|
||||||
self.ttl = 86400 # 24 hours
|
|
||||||
logger.info(f"Initialized JobManager: {redis_url}")
|
|
||||||
|
|
||||||
async def connect(self):
|
|
||||||
"""Initialize Redis connection."""
|
|
||||||
if not self._redis:
|
|
||||||
self._redis = await redis.from_url(
|
|
||||||
self.redis_url,
|
|
||||||
encoding="utf-8",
|
|
||||||
decode_responses=True
|
|
||||||
)
|
|
||||||
logger.info("Connected to Redis for job tracking")
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
"""Close Redis connection."""
|
|
||||||
if self._redis:
|
|
||||||
await self._redis.close()
|
|
||||||
self._redis = None
|
|
||||||
logger.info("Closed Redis connection")
|
|
||||||
|
|
||||||
def _job_key(self, job_id: str) -> str:
|
|
||||||
"""Get Redis key for job."""
|
|
||||||
return f"library:job:{job_id}"
|
|
||||||
|
|
||||||
def _user_jobs_key(self, user: str) -> str:
|
|
||||||
"""Get Redis key for user's jobs."""
|
|
||||||
return f"library:user_jobs:{user}"
|
|
||||||
|
|
||||||
def _active_jobs_key(self) -> str:
|
|
||||||
"""Get Redis key for active jobs."""
|
|
||||||
return "library:active_jobs"
|
|
||||||
|
|
||||||
async def create_job(
|
|
||||||
self,
|
|
||||||
job_type: JobType,
|
|
||||||
user: str,
|
|
||||||
parameters: Dict[str, Any]
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Create new job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_type: Type of job
|
|
||||||
user: User identifier
|
|
||||||
parameters: Job parameters
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Job ID (UUID)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> job_id = await manager.create_job(
|
|
||||||
... JobType.DOCUMENT_INGESTION,
|
|
||||||
... "jpmschweitzer",
|
|
||||||
... {"doc_url": "https://example.com/doc.pdf"}
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
|
||||||
job_data = {
|
|
||||||
"job_id": job_id,
|
|
||||||
"job_type": job_type.value,
|
|
||||||
"status": JobStatus.QUEUED.value,
|
|
||||||
"user": user,
|
|
||||||
"parameters": parameters,
|
|
||||||
"result": None,
|
|
||||||
"error": None,
|
|
||||||
"progress": 0,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Store job data
|
|
||||||
await self._redis.setex(
|
|
||||||
self._job_key(job_id),
|
|
||||||
self.ttl,
|
|
||||||
json.dumps(job_data)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add to user's job set
|
|
||||||
await self._redis.sadd(self._user_jobs_key(user), job_id)
|
|
||||||
await self._redis.expire(self._user_jobs_key(user), self.ttl)
|
|
||||||
|
|
||||||
# Add to active jobs set
|
|
||||||
await self._redis.sadd(self._active_jobs_key(), job_id)
|
|
||||||
|
|
||||||
logger.info(f"Created job {job_id}: {job_type.value} for user {user}")
|
|
||||||
return job_id
|
|
||||||
|
|
||||||
async def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get job by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: Job ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Job data dictionary or None if not found
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> job = await manager.get_job(job_id)
|
|
||||||
>>> job["status"]
|
|
||||||
'processing'
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
job_json = await self._redis.get(self._job_key(job_id))
|
|
||||||
if not job_json:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return json.loads(job_json)
|
|
||||||
|
|
||||||
async def update_job_status(
|
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
status: JobStatus,
|
|
||||||
progress: Optional[int] = None,
|
|
||||||
result: Optional[Dict[str, Any]] = None,
|
|
||||||
error: Optional[str] = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update job status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: Job ID
|
|
||||||
status: New status
|
|
||||||
progress: Progress percentage (0-100)
|
|
||||||
result: Result data (for completed jobs)
|
|
||||||
error: Error message (for failed jobs)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> await manager.update_job_status(
|
|
||||||
... job_id,
|
|
||||||
... JobStatus.PROCESSING,
|
|
||||||
... progress=50
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
job = await self.get_job(job_id)
|
|
||||||
if not job:
|
|
||||||
logger.error(f"Job {job_id} not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
job["status"] = status.value
|
|
||||||
if progress is not None:
|
|
||||||
job["progress"] = progress
|
|
||||||
if result is not None:
|
|
||||||
job["result"] = result
|
|
||||||
if error is not None:
|
|
||||||
job["error"] = error
|
|
||||||
|
|
||||||
# Update timestamps
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
if status == JobStatus.PROCESSING and not job.get("started_at"):
|
|
||||||
job["started_at"] = now
|
|
||||||
if status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
|
|
||||||
job["completed_at"] = now
|
|
||||||
|
|
||||||
# Save updated job
|
|
||||||
await self._redis.setex(
|
|
||||||
self._job_key(job_id),
|
|
||||||
self.ttl,
|
|
||||||
json.dumps(job)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove from active jobs if completed
|
|
||||||
if status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
|
|
||||||
await self._redis.srem(self._active_jobs_key(), job_id)
|
|
||||||
|
|
||||||
logger.info(f"Updated job {job_id}: status={status.value}, progress={progress}")
|
|
||||||
|
|
||||||
async def get_user_jobs(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
limit: int = 50
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all jobs for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
limit: Maximum jobs to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of job dictionaries (most recent first)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> jobs = await manager.get_user_jobs("jpmschweitzer", limit=10)
|
|
||||||
>>> len(jobs)
|
|
||||||
10
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
# Get job IDs for user
|
|
||||||
job_ids = await self._redis.smembers(self._user_jobs_key(user))
|
|
||||||
if not job_ids:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Fetch job data
|
|
||||||
jobs = []
|
|
||||||
for job_id in job_ids:
|
|
||||||
job = await self.get_job(job_id)
|
|
||||||
if job:
|
|
||||||
jobs.append(job)
|
|
||||||
|
|
||||||
# Sort by created_at descending
|
|
||||||
jobs.sort(key=lambda j: j.get("created_at", ""), reverse=True)
|
|
||||||
|
|
||||||
return jobs[:limit]
|
|
||||||
|
|
||||||
async def get_active_jobs(self, limit: int = 100) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all active (non-completed) jobs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
limit: Maximum jobs to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of active job dictionaries
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> active = await manager.get_active_jobs()
|
|
||||||
>>> all(j["status"] in ["queued", "processing"] for j in active)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
job_ids = await self._redis.smembers(self._active_jobs_key())
|
|
||||||
if not job_ids:
|
|
||||||
return []
|
|
||||||
|
|
||||||
jobs = []
|
|
||||||
for job_id in job_ids:
|
|
||||||
job = await self.get_job(job_id)
|
|
||||||
if job:
|
|
||||||
jobs.append(job)
|
|
||||||
|
|
||||||
return jobs[:limit]
|
|
||||||
|
|
||||||
async def cancel_job(self, job_id: str):
|
|
||||||
"""
|
|
||||||
Cancel a job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: Job ID
|
|
||||||
|
|
||||||
Note: This only marks the job as cancelled. The actual job
|
|
||||||
worker must check status and stop processing.
|
|
||||||
"""
|
|
||||||
await self.update_job_status(
|
|
||||||
job_id,
|
|
||||||
JobStatus.CANCELLED
|
|
||||||
)
|
|
||||||
logger.info(f"Cancelled job {job_id}")
|
|
||||||
|
|
||||||
async def delete_job(self, job_id: str, user: str):
|
|
||||||
"""
|
|
||||||
Delete a job.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
job_id: Job ID
|
|
||||||
user: User identifier (for authorization)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False if not found or unauthorized
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
job = await self.get_job(job_id)
|
|
||||||
if not job:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check user authorization
|
|
||||||
if job.get("user") != user:
|
|
||||||
logger.warning(f"User {user} attempted to delete job {job_id} owned by {job.get('user')}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Delete job data
|
|
||||||
await self._redis.delete(self._job_key(job_id))
|
|
||||||
|
|
||||||
# Remove from user's jobs
|
|
||||||
await self._redis.srem(self._user_jobs_key(user), job_id)
|
|
||||||
|
|
||||||
# Remove from active jobs
|
|
||||||
await self._redis.srem(self._active_jobs_key(), job_id)
|
|
||||||
|
|
||||||
logger.info(f"Deleted job {job_id}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def cleanup_expired_jobs(self):
|
|
||||||
"""
|
|
||||||
Clean up expired jobs from sets.
|
|
||||||
|
|
||||||
Redis will auto-expire job data, but set memberships need manual cleanup.
|
|
||||||
Should be called periodically (e.g., hourly).
|
|
||||||
"""
|
|
||||||
if not self._redis:
|
|
||||||
await self.connect()
|
|
||||||
|
|
||||||
# Cleanup active jobs set
|
|
||||||
job_ids = await self._redis.smembers(self._active_jobs_key())
|
|
||||||
for job_id in job_ids:
|
|
||||||
exists = await self._redis.exists(self._job_key(job_id))
|
|
||||||
if not exists:
|
|
||||||
await self._redis.srem(self._active_jobs_key(), job_id)
|
|
||||||
|
|
||||||
logger.info("Cleaned up expired jobs")
|
|
||||||
|
|
||||||
async def get_job_stats(self, user: Optional[str] = None) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
Get job statistics.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: Optional user filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Statistics dictionary:
|
|
||||||
{
|
|
||||||
"total": 100,
|
|
||||||
"queued": 5,
|
|
||||||
"processing": 10,
|
|
||||||
"completed": 80,
|
|
||||||
"failed": 5
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if user:
|
|
||||||
jobs = await self.get_user_jobs(user, limit=1000)
|
|
||||||
else:
|
|
||||||
jobs = await self.get_active_jobs(limit=1000)
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"total": len(jobs),
|
|
||||||
"queued": 0,
|
|
||||||
"processing": 0,
|
|
||||||
"completed": 0,
|
|
||||||
"failed": 0,
|
|
||||||
"cancelled": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
for job in jobs:
|
|
||||||
status = job.get("status", "")
|
|
||||||
if status in stats:
|
|
||||||
stats[status] += 1
|
|
||||||
|
|
||||||
return stats
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
"""
|
|
||||||
Library Desk - Main FastAPI Application
|
|
||||||
|
|
||||||
Following best practices:
|
|
||||||
- Async routes for I/O operations
|
|
||||||
- Dependency injection for configuration
|
|
||||||
- Proper error handling
|
|
||||||
- OpenAPI documentation
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Depends
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Dict, Any
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from src.config import Settings, get_settings
|
|
||||||
from src.core.dependencies import verify_api_key
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
|
||||||
app = FastAPI(
|
|
||||||
title="Library Desk API",
|
|
||||||
description="Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and mind map generation",
|
|
||||||
version="1.0.0",
|
|
||||||
docs_url="/docs",
|
|
||||||
redoc_url="/redoc",
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS middleware
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"], # Configure appropriately for production
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Register routers
|
|
||||||
from src.routers import wiki, tools, graph, vector, hybrid_rag, consolidation, ingestion, entity_linking, webhooks
|
|
||||||
|
|
||||||
app.include_router(wiki.router)
|
|
||||||
app.include_router(tools.router)
|
|
||||||
app.include_router(graph.router)
|
|
||||||
app.include_router(vector.router)
|
|
||||||
app.include_router(hybrid_rag.router)
|
|
||||||
app.include_router(consolidation.router)
|
|
||||||
app.include_router(ingestion.router)
|
|
||||||
app.include_router(entity_linking.router)
|
|
||||||
app.include_router(webhooks.router)
|
|
||||||
|
|
||||||
# Mount static files directory for Wiki.js integration scripts
|
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
|
||||||
if static_dir.exists():
|
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
||||||
logger.info(f"Mounted static files from {static_dir}")
|
|
||||||
|
|
||||||
|
|
||||||
# Response Models
|
|
||||||
class HealthResponse(BaseModel):
|
|
||||||
"""Health check response model."""
|
|
||||||
status: str
|
|
||||||
app_name: str
|
|
||||||
version: str
|
|
||||||
services: Dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
class StatsResponse(BaseModel):
|
|
||||||
"""Statistics response model."""
|
|
||||||
wiki_pages: int
|
|
||||||
neo4j_nodes: int
|
|
||||||
qdrant_vectors: int
|
|
||||||
|
|
||||||
|
|
||||||
# Routes
|
|
||||||
@app.get("/", tags=["Root"])
|
|
||||||
async def root() -> Dict[str, str]:
|
|
||||||
"""Root endpoint."""
|
|
||||||
return {
|
|
||||||
"message": "Library Desk API",
|
|
||||||
"docs": "/docs",
|
|
||||||
"health": "/health"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse, tags=["System"])
|
|
||||||
async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
|
||||||
"""
|
|
||||||
Health check endpoint.
|
|
||||||
Returns status of all connected services.
|
|
||||||
"""
|
|
||||||
from src.core.dependencies import check_service_health
|
|
||||||
|
|
||||||
# Check service connectivity
|
|
||||||
service_health = await check_service_health()
|
|
||||||
|
|
||||||
# Overall status is healthy if at least Neo4j and Qdrant are up
|
|
||||||
all_healthy = service_health.get("neo4j", False) and service_health.get("qdrant", False)
|
|
||||||
overall_status = "healthy" if all_healthy else "degraded"
|
|
||||||
|
|
||||||
return HealthResponse(
|
|
||||||
status=overall_status,
|
|
||||||
app_name=settings.app_name,
|
|
||||||
version=settings.app_version,
|
|
||||||
services={
|
|
||||||
"neo4j": {
|
|
||||||
"url": settings.neo4j_uri,
|
|
||||||
"healthy": service_health.get("neo4j", False)
|
|
||||||
},
|
|
||||||
"qdrant": {
|
|
||||||
"url": settings.qdrant_url,
|
|
||||||
"healthy": service_health.get("qdrant", False)
|
|
||||||
},
|
|
||||||
"wikijs": {
|
|
||||||
"url": settings.wikijs_url,
|
|
||||||
"healthy": service_health.get("wikijs", False)
|
|
||||||
},
|
|
||||||
"searxng": {
|
|
||||||
"url": settings.searxng_url,
|
|
||||||
"healthy": service_health.get("searxng", False)
|
|
||||||
},
|
|
||||||
"ollama": {
|
|
||||||
"url": settings.ollama_url,
|
|
||||||
"model": settings.ollama_model,
|
|
||||||
"healthy": service_health.get("ollama", False)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/stats", response_model=StatsResponse, tags=["System"])
|
|
||||||
async def stats(
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
return StatsResponse(
|
|
||||||
wiki_pages=0,
|
|
||||||
neo4j_nodes=0,
|
|
||||||
qdrant_vectors=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/ingest/check-updates", tags=["Ingestion"])
|
|
||||||
async def check_updates(
|
|
||||||
documents: Dict[str, Any],
|
|
||||||
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.
|
|
||||||
|
|
||||||
TODO: Implement update detection:
|
|
||||||
1. Query existing documents by path
|
|
||||||
2. Compare content hashes
|
|
||||||
3. Return list of updates needed
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"message": "Update checking not yet implemented",
|
|
||||||
"updates_needed": [],
|
|
||||||
"up_to_date": [],
|
|
||||||
"new_documents": []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ingest/status/{document_id}", tags=["Ingestion"])
|
|
||||||
async def get_ingestion_status(
|
|
||||||
document_id: str,
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get processing status for a document.
|
|
||||||
|
|
||||||
TODO: Implement status tracking
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"message": "Status tracking not yet implemented",
|
|
||||||
"document_id": document_id,
|
|
||||||
"status": "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/ingest/repo-status/{repository}", tags=["Ingestion"])
|
|
||||||
async def get_repo_status(
|
|
||||||
repository: str,
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get indexing status for an entire repository.
|
|
||||||
|
|
||||||
TODO: Implement repository-level statistics
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"message": "Repository status not yet implemented",
|
|
||||||
"repository": repository,
|
|
||||||
"total_documents": 0,
|
|
||||||
"indexed_documents": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Query endpoints (stubs for future implementation)
|
|
||||||
# NOTE: /query/hybrid is now implemented in routers/hybrid_rag.py
|
|
||||||
|
|
||||||
@app.post("/query/semantic", tags=["Query"])
|
|
||||||
async def semantic_query(
|
|
||||||
query: Dict[str, Any],
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Semantic search via Qdrant.
|
|
||||||
Pure vector similarity search.
|
|
||||||
|
|
||||||
TODO: Implement semantic search
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"message": "Semantic search not yet implemented",
|
|
||||||
"query": query
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/query/graph", tags=["Query"])
|
|
||||||
async def graph_query(
|
|
||||||
query: Dict[str, Any],
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Graph traversal via Neo4j.
|
|
||||||
Execute Cypher queries.
|
|
||||||
|
|
||||||
TODO: Implement graph queries
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"message": "Graph query not yet implemented",
|
|
||||||
"query": query
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Deduplication endpoints
|
|
||||||
@app.post("/deduplicate/check", tags=["Deduplication"])
|
|
||||||
async def check_duplicates(
|
|
||||||
request: Dict[str, Any],
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Check for duplicate or highly similar documents.
|
|
||||||
Uses vector similarity and graph analysis.
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
document_id = request.get("document_id")
|
|
||||||
threshold = request.get("similarity_threshold", 0.85)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "Deduplication not yet implemented",
|
|
||||||
"document_id": document_id,
|
|
||||||
"threshold": threshold,
|
|
||||||
"duplicates": [],
|
|
||||||
"suggestions": None
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Application lifecycle
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""Initialize connections and resources on startup."""
|
|
||||||
from src.core.dependencies import startup_clients
|
|
||||||
from src.services.wiki_change_listener import WikiChangeListener
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
|
||||||
logger.info(f"Neo4j: {settings.neo4j_uri}")
|
|
||||||
logger.info(f"Qdrant: {settings.qdrant_url}")
|
|
||||||
logger.info(f"Wiki.js: {settings.wikijs_url}")
|
|
||||||
logger.info(f"SearXNG: {settings.searxng_url}")
|
|
||||||
logger.info(f"Ollama: {settings.ollama_url}")
|
|
||||||
|
|
||||||
# Initialize all service clients
|
|
||||||
await startup_clients()
|
|
||||||
|
|
||||||
# Start Wiki.js change listener (PostgreSQL NOTIFY/LISTEN)
|
|
||||||
# This enables automatic processing of user-edited pages
|
|
||||||
try:
|
|
||||||
wiki_listener = WikiChangeListener()
|
|
||||||
await wiki_listener.start()
|
|
||||||
# Store reference for shutdown
|
|
||||||
app.state.wiki_listener = wiki_listener
|
|
||||||
logger.info("Wiki.js change listener started successfully")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start Wiki.js change listener: {e}", exc_info=True)
|
|
||||||
logger.warning("Continuing without change listener - manual page updates will not be auto-processed")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
async def shutdown_event():
|
|
||||||
"""Clean up resources on shutdown."""
|
|
||||||
from src.core.dependencies import shutdown_clients
|
|
||||||
|
|
||||||
logger.info("Shutting down Library Desk API")
|
|
||||||
|
|
||||||
# Stop Wiki.js change listener if running
|
|
||||||
if hasattr(app.state, "wiki_listener"):
|
|
||||||
try:
|
|
||||||
await app.state.wiki_listener.stop()
|
|
||||||
logger.info("Wiki.js change listener stopped")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error stopping Wiki.js change listener: {e}")
|
|
||||||
|
|
||||||
# Close all service clients
|
|
||||||
await shutdown_clients()
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""
|
|
||||||
Knowledge Consolidation models for Librarian processing.
|
|
||||||
|
|
||||||
Used by the consolidation endpoint to process SearchQuery nodes
|
|
||||||
and consolidate knowledge into wiki pages.
|
|
||||||
"""
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class ConsolidationRequest(BaseModel):
|
|
||||||
"""Request for knowledge consolidation from search results."""
|
|
||||||
process_limit: int = Field(default=10, ge=1, le=100, description="Max searches to process")
|
|
||||||
lookback_days: int = Field(default=7, ge=1, le=90, description="Process searches from last N days")
|
|
||||||
min_web_results: int = Field(default=2, ge=1, le=20, description="Minimum web results needed")
|
|
||||||
dry_run: bool = Field(default=False, description="If true, analyze but don't create pages")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchQueryInfo(BaseModel):
|
|
||||||
"""Information about a search query to process."""
|
|
||||||
id: str
|
|
||||||
query: str
|
|
||||||
user: str
|
|
||||||
timestamp: str
|
|
||||||
total_results: int
|
|
||||||
web_count: int
|
|
||||||
keywords: List[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
class ConsolidationResult(BaseModel):
|
|
||||||
"""Result of processing a single search query."""
|
|
||||||
search_id: str
|
|
||||||
query: str
|
|
||||||
pages_created: int = 0
|
|
||||||
pages_updated: int = 0
|
|
||||||
entities_added: int = 0
|
|
||||||
error: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ConsolidationResponse(BaseModel):
|
|
||||||
"""Response from knowledge consolidation."""
|
|
||||||
total_found: int = Field(description="Total unprocessed searches found")
|
|
||||||
processed_count: int = Field(description="Successfully processed searches")
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""
|
|
||||||
Graph models for Library Desk Neo4j operations.
|
|
||||||
|
|
||||||
Provides models for knowledge graph nodes, relationships, and queries.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
class GraphNode(BaseModel):
|
|
||||||
"""Graph node representation."""
|
|
||||||
id: str = Field(..., description="Node ID")
|
|
||||||
labels: List[str] = Field(..., description="Node labels")
|
|
||||||
properties: Dict[str, Any] = Field(default_factory=dict, description="Node properties")
|
|
||||||
|
|
||||||
|
|
||||||
class GraphRelationship(BaseModel):
|
|
||||||
"""Graph relationship representation."""
|
|
||||||
id: str = Field(..., description="Relationship ID")
|
|
||||||
type: str = Field(..., description="Relationship type")
|
|
||||||
start_node: str = Field(..., description="Start node ID")
|
|
||||||
end_node: str = Field(..., description="End node ID")
|
|
||||||
properties: Dict[str, Any] = Field(default_factory=dict, description="Relationship properties")
|
|
||||||
|
|
||||||
|
|
||||||
class GraphNodeDetail(BaseModel):
|
|
||||||
"""Detailed node with relationships."""
|
|
||||||
node: GraphNode = Field(..., description="Node data")
|
|
||||||
relationships: List[GraphRelationship] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Connected relationships"
|
|
||||||
)
|
|
||||||
related_nodes: List[GraphNode] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Connected nodes"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CypherQueryRequest(BaseModel):
|
|
||||||
"""Request to execute a Cypher query."""
|
|
||||||
query: str = Field(..., description="Cypher query to execute")
|
|
||||||
parameters: Dict[str, Any] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="Query parameters"
|
|
||||||
)
|
|
||||||
user: str = Field(
|
|
||||||
default="jpmschweitzer",
|
|
||||||
description="User for filtering (automatically scopes query)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CypherQueryResponse(BaseModel):
|
|
||||||
"""Response from Cypher query execution."""
|
|
||||||
results: List[Dict[str, Any]] = Field(..., description="Query results")
|
|
||||||
count: int = Field(..., description="Number of results")
|
|
||||||
query_time_ms: float = Field(..., description="Query execution time in milliseconds")
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
force_refresh: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Force re-extraction even if page hasn't changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EntityMention(BaseModel):
|
|
||||||
"""Extracted entity mention."""
|
|
||||||
text: str = Field(..., description="Entity text")
|
|
||||||
type: str = Field(..., description="Entity type (Person, Project, Concept, etc.)")
|
|
||||||
confidence: float = Field(default=1.0, description="Extraction confidence (0-1)")
|
|
||||||
|
|
||||||
|
|
||||||
class GraphUpdateSummary(BaseModel):
|
|
||||||
"""Summary of graph update operation."""
|
|
||||||
page_id: int = Field(..., description="Page ID processed")
|
|
||||||
page_title: str = Field(..., description="Page title")
|
|
||||||
nodes_created: int = Field(default=0, description="New nodes created")
|
|
||||||
nodes_updated: int = Field(default=0, description="Existing nodes updated")
|
|
||||||
relationships_created: int = Field(default=0, description="New relationships created")
|
|
||||||
entities_extracted: List[EntityMention] = Field(
|
|
||||||
default_factory=list,
|
|
||||||
description="Entities extracted from page"
|
|
||||||
)
|
|
||||||
processing_time_ms: float = Field(..., description="Processing time in milliseconds")
|
|
||||||
success: bool = Field(default=True, description="Whether update succeeded")
|
|
||||||
error_message: Optional[str] = Field(default=None, description="Error message if failed")
|
|
||||||
|
|
||||||
|
|
||||||
class NodeListResponse(BaseModel):
|
|
||||||
"""Response for node listing."""
|
|
||||||
nodes: List[GraphNode] = Field(..., description="List of nodes")
|
|
||||||
total: int = Field(..., description="Total number of nodes")
|
|
||||||
user: str = Field(..., description="User filter applied")
|
|
||||||
|
|
||||||
|
|
||||||
class MindMapNode(BaseModel):
|
|
||||||
"""Mind map node for visualization."""
|
|
||||||
id: str = Field(..., description="Node ID")
|
|
||||||
label: str = Field(..., description="Node label/name")
|
|
||||||
type: str = Field(..., description="Node type")
|
|
||||||
size: int = Field(default=10, description="Visual size")
|
|
||||||
color: Optional[str] = Field(default=None, description="Node color")
|
|
||||||
|
|
||||||
|
|
||||||
class MindMapLink(BaseModel):
|
|
||||||
"""Mind map link for visualization."""
|
|
||||||
source: str = Field(..., description="Source node ID")
|
|
||||||
target: str = Field(..., description="Target node ID")
|
|
||||||
type: str = Field(..., description="Relationship type")
|
|
||||||
strength: float = Field(default=1.0, description="Link strength")
|
|
||||||
|
|
||||||
|
|
||||||
class MindMapResponse(BaseModel):
|
|
||||||
"""Mind map data for D3.js or similar visualization."""
|
|
||||||
nodes: List[MindMapNode] = Field(..., description="Graph nodes")
|
|
||||||
links: List[MindMapLink] = Field(..., description="Graph edges")
|
|
||||||
center_node: str = Field(..., description="Central node ID")
|
|
||||||
depth: int = Field(..., description="Traversal depth")
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
HybridRAG models for multi-source search with RRF fusion.
|
|
||||||
|
|
||||||
Combines vector search (Qdrant), knowledge graph (Neo4j), and web search (SearXNG)
|
|
||||||
with Reciprocal Rank Fusion and LLM re-ranking.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class HybridRAGConfig(BaseModel):
|
|
||||||
"""Configuration for HybridRAG query."""
|
|
||||||
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")
|
|
||||||
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_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")
|
|
||||||
|
|
||||||
|
|
||||||
class RelatedDossier(BaseModel):
|
|
||||||
"""Related document metadata from graph enrichment."""
|
|
||||||
page_id: int
|
|
||||||
title: str
|
|
||||||
path: str
|
|
||||||
tag: str
|
|
||||||
shared_entities: int
|
|
||||||
|
|
||||||
|
|
||||||
class HybridRAGResult(BaseModel):
|
|
||||||
"""Single result from HybridRAG query."""
|
|
||||||
source_type: str = Field(..., description="Source: 'vector', 'graph', 'web'")
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
related_dossiers: List[RelatedDossier] = Field(default=[], description="Related documents via shared entities")
|
|
||||||
metadata: Dict[str, Any] = Field(default={}, description="Additional metadata")
|
|
||||||
|
|
||||||
|
|
||||||
class TimingBreakdown(BaseModel):
|
|
||||||
"""Performance timing breakdown for each phase."""
|
|
||||||
query_enhancement_ms: float = Field(..., description="Phase 0: Keyword/synonym extraction")
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
persistence_ms: float = Field(..., description="Phase 6: Search persistence")
|
|
||||||
total_ms: float = Field(..., description="Total end-to-end time")
|
|
||||||
|
|
||||||
|
|
||||||
class KeywordExtraction(BaseModel):
|
|
||||||
"""Extracted keywords and synonyms from query enhancement."""
|
|
||||||
core_keywords: List[str] = Field(default=[], description="Primary keywords")
|
|
||||||
entities: List[str] = Field(default=[], description="Named entities")
|
|
||||||
synonyms: Dict[str, List[str]] = Field(default={}, description="Synonyms map")
|
|
||||||
expansions: Dict[str, List[str]] = Field(default={}, description="Abbreviation expansions")
|
|
||||||
|
|
||||||
|
|
||||||
class HybridRAGResponse(BaseModel):
|
|
||||||
"""Response from HybridRAG query."""
|
|
||||||
query: str = Field(..., description="Original search query")
|
|
||||||
keywords: KeywordExtraction = Field(..., description="Extracted keywords/synonyms")
|
|
||||||
results: List[HybridRAGResult] = Field(..., description="Ranked search results")
|
|
||||||
context: str = Field(..., description="Formatted context for LLM consumption")
|
|
||||||
source_counts: Dict[str, int] = Field(..., description="Result counts by source")
|
|
||||||
total_results: int = Field(..., description="Total number of results")
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class HybridRAGRequest(BaseModel):
|
|
||||||
"""Request for HybridRAG query."""
|
|
||||||
query: str = Field(..., min_length=1, max_length=500, description="Search query")
|
|
||||||
config: Optional[HybridRAGConfig] = Field(None, description="Custom configuration")
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Pydantic models for Document Ingestion system.
|
|
||||||
"""
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
force_refresh: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Force re-ingestion even if page hasn't changed"
|
|
||||||
)
|
|
||||||
skip_vectors: bool = Field(default=False, description="Skip vector embedding generation")
|
|
||||||
skip_graph: bool = Field(default=False, description="Skip graph entity extraction")
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
force_refresh: bool = Field(default=False)
|
|
||||||
skip_vectors: bool = Field(default=False)
|
|
||||||
skip_graph: bool = Field(default=False)
|
|
||||||
max_concurrent: int = Field(
|
|
||||||
default=3,
|
|
||||||
ge=1,
|
|
||||||
le=10,
|
|
||||||
description="Maximum concurrent ingestion tasks"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class IngestionResult(BaseModel):
|
|
||||||
"""Result of a single page ingestion."""
|
|
||||||
page_id: int
|
|
||||||
page_title: str
|
|
||||||
page_path: Optional[str] = None
|
|
||||||
success: bool
|
|
||||||
error: Optional[str] = None
|
|
||||||
vector_chunks_created: int = 0
|
|
||||||
graph_entities_extracted: int = 0
|
|
||||||
graph_relationships_created: int = 0
|
|
||||||
processing_time_ms: float
|
|
||||||
|
|
||||||
|
|
||||||
class BatchIngestionResult(BaseModel):
|
|
||||||
"""Result of batch ingestion."""
|
|
||||||
total_pages: int
|
|
||||||
successful: int
|
|
||||||
failed: int
|
|
||||||
results: List[IngestionResult]
|
|
||||||
total_processing_time_ms: float
|
|
||||||
|
|
||||||
|
|
||||||
class IngestionStatus(BaseModel):
|
|
||||||
"""Status of an ingestion job."""
|
|
||||||
job_id: str
|
|
||||||
status: str # "queued", "processing", "completed", "failed"
|
|
||||||
progress: int # 0-100
|
|
||||||
page_id: Optional[int] = None
|
|
||||||
result: Optional[IngestionResult] = None
|
|
||||||
created_at: datetime
|
|
||||||
started_at: Optional[datetime] = None
|
|
||||||
completed_at: Optional[datetime] = None
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""
|
|
||||||
Tool catalog models for Library Desk.
|
|
||||||
|
|
||||||
Provides simplified tool definitions optimized for AI agent consumption.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class ParameterType(str, Enum):
|
|
||||||
"""Parameter data types."""
|
|
||||||
STRING = "string"
|
|
||||||
INTEGER = "integer"
|
|
||||||
BOOLEAN = "boolean"
|
|
||||||
ARRAY = "array"
|
|
||||||
OBJECT = "object"
|
|
||||||
|
|
||||||
|
|
||||||
class ToolParameter(BaseModel):
|
|
||||||
"""Tool parameter definition."""
|
|
||||||
name: str = Field(..., description="Parameter name")
|
|
||||||
type: ParameterType = Field(..., description="Parameter type")
|
|
||||||
description: str = Field(..., description="Parameter description")
|
|
||||||
required: bool = Field(default=False, description="Whether parameter is required")
|
|
||||||
default: Optional[Any] = Field(default=None, description="Default value if not required")
|
|
||||||
example: Optional[Any] = Field(default=None, description="Example value")
|
|
||||||
|
|
||||||
|
|
||||||
class ToolDefinition(BaseModel):
|
|
||||||
"""Individual tool definition."""
|
|
||||||
name: str = Field(..., description="Tool identifier (e.g., 'wiki_create_page')")
|
|
||||||
category: str = Field(..., description="Tool category (e.g., 'wiki', 'graph')")
|
|
||||||
description: str = Field(..., description="What this tool does")
|
|
||||||
method: str = Field(..., description="HTTP method (GET, POST, PUT, DELETE)")
|
|
||||||
endpoint: str = Field(..., description="API endpoint path")
|
|
||||||
parameters: List[ToolParameter] = Field(default_factory=list, description="Tool parameters")
|
|
||||||
returns: str = Field(..., description="What the tool returns")
|
|
||||||
example: Optional[Dict[str, Any]] = Field(default=None, description="Example request")
|
|
||||||
fast: bool = Field(default=True, description="Whether operation completes quickly (<5s)")
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryInfo(BaseModel):
|
|
||||||
"""Tool category information."""
|
|
||||||
name: str = Field(..., description="Category name")
|
|
||||||
description: str = Field(..., description="Category description")
|
|
||||||
tool_count: int = Field(..., description="Number of tools in category")
|
|
||||||
|
|
||||||
|
|
||||||
class ToolCatalog(BaseModel):
|
|
||||||
"""Complete tool catalog response."""
|
|
||||||
service: str = Field(default="library-desk", description="Service name")
|
|
||||||
version: str = Field(default="1.0.0", description="API version")
|
|
||||||
base_url: str = Field(..., description="Base URL for API")
|
|
||||||
categories: List[CategoryInfo] = Field(..., description="Available categories")
|
|
||||||
tools: List[ToolDefinition] = Field(..., description="All available tools")
|
|
||||||
authentication: str = Field(
|
|
||||||
default="Bearer token via Authorization header",
|
|
||||||
description="Authentication method"
|
|
||||||
)
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""
|
|
||||||
Vector models for Library Desk Qdrant operations.
|
|
||||||
|
|
||||||
Provides models for semantic search, document chunks, and embeddings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentChunk(BaseModel):
|
|
||||||
"""Document chunk with embedding."""
|
|
||||||
chunk_id: str = Field(..., description="Unique chunk ID (page_id:chunk_index)")
|
|
||||||
page_id: int = Field(..., description="Wiki page ID")
|
|
||||||
chunk_index: int = Field(..., description="Chunk index within document")
|
|
||||||
content: str = Field(..., description="Chunk text content")
|
|
||||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
|
||||||
"""Semantic search result."""
|
|
||||||
chunk_id: str = Field(..., description="Chunk ID")
|
|
||||||
page_id: int = Field(..., description="Wiki page ID")
|
|
||||||
page_title: Optional[str] = Field(None, description="Page title")
|
|
||||||
page_path: Optional[str] = Field(None, description="Page path")
|
|
||||||
chunk_index: int = Field(..., description="Chunk index")
|
|
||||||
content: str = Field(..., description="Chunk content")
|
|
||||||
score: float = Field(..., description="Similarity score (0-1)")
|
|
||||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
|
||||||
"""Semantic search request."""
|
|
||||||
query: str = Field(..., min_length=1, description="Search query")
|
|
||||||
user: str = Field(default="jpmschweitzer", description="User identifier")
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
|
||||||
"""Semantic search response."""
|
|
||||||
query: str = Field(..., description="Search query")
|
|
||||||
results: List[SearchResult] = Field(..., description="Search results")
|
|
||||||
total: int = Field(..., description="Number of results")
|
|
||||||
user: str = Field(..., description="User filter applied")
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
force_refresh: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Force re-embedding even if page hasn't changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VectorUpdateSummary(BaseModel):
|
|
||||||
"""Summary of vector update operation."""
|
|
||||||
page_id: int = Field(..., description="Page ID processed")
|
|
||||||
page_title: str = Field(..., description="Page title")
|
|
||||||
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")
|
|
||||||
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")
|
|
||||||
success: bool = Field(default=True, description="Whether update succeeded")
|
|
||||||
error_message: Optional[str] = Field(default=None, description="Error message if failed")
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionInfo(BaseModel):
|
|
||||||
"""Qdrant collection information."""
|
|
||||||
name: str = Field(..., description="Collection name")
|
|
||||||
vectors_count: int = Field(..., description="Number of vectors")
|
|
||||||
points_count: int = Field(..., description="Number of points")
|
|
||||||
segments_count: int = Field(..., description="Number of segments")
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionListResponse(BaseModel):
|
|
||||||
"""List of Qdrant collections."""
|
|
||||||
collections: List[CollectionInfo] = Field(..., description="List of collections")
|
|
||||||
total: int = Field(..., description="Total number of collections")
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class DeletePageChunksResponse(BaseModel):
|
|
||||||
"""Response from deleting page chunks."""
|
|
||||||
page_id: int = Field(..., description="Page ID")
|
|
||||||
chunks_deleted: int = Field(..., description="Number of chunks deleted")
|
|
||||||
success: bool = Field(..., description="Whether deletion succeeded")
|
|
||||||
message: str = Field(..., description="Result message")
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
"""
|
|
||||||
Pydantic models for Wiki.js operations.
|
|
||||||
|
|
||||||
Models for:
|
|
||||||
- Wiki pages (CRUD operations)
|
|
||||||
- Dossiers (tag-based collections)
|
|
||||||
- Search results
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
# Base models
|
|
||||||
class WikiPageBase(BaseModel):
|
|
||||||
"""Base wiki page fields."""
|
|
||||||
title: str = Field(..., min_length=1, max_length=500, description="Page title")
|
|
||||||
description: Optional[str] = Field(None, max_length=1000, description="Page description")
|
|
||||||
tags: List[str] = Field(default_factory=list, description="Tags (for dossier organization)")
|
|
||||||
is_published: bool = Field(default=True, description="Whether page is published")
|
|
||||||
|
|
||||||
@field_validator("tags")
|
|
||||||
@classmethod
|
|
||||||
def validate_tags(cls, v: List[str]) -> List[str]:
|
|
||||||
"""Validate and clean tags."""
|
|
||||||
# Remove empty tags and strip whitespace
|
|
||||||
cleaned = [tag.strip() for tag in v if tag.strip()]
|
|
||||||
# Ensure uniqueness
|
|
||||||
return list(set(cleaned))
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPageCreate(WikiPageBase):
|
|
||||||
"""Request model for creating a wiki page."""
|
|
||||||
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)")
|
|
||||||
|
|
||||||
@field_validator("path")
|
|
||||||
@classmethod
|
|
||||||
def validate_path(cls, v: str) -> str:
|
|
||||||
"""Validate page path."""
|
|
||||||
# Ensure path starts with /
|
|
||||||
if not v.startswith("/"):
|
|
||||||
v = f"/{v}"
|
|
||||||
# Remove trailing slash
|
|
||||||
if v.endswith("/") and v != "/":
|
|
||||||
v = v.rstrip("/")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPageUpdate(BaseModel):
|
|
||||||
"""Request model for updating a wiki page."""
|
|
||||||
content: Optional[str] = Field(None, description="Updated content")
|
|
||||||
title: Optional[str] = Field(None, min_length=1, max_length=500, description="Updated title")
|
|
||||||
description: Optional[str] = Field(None, max_length=1000, description="Updated description")
|
|
||||||
tags: Optional[List[str]] = Field(None, description="Updated tags")
|
|
||||||
|
|
||||||
@field_validator("tags")
|
|
||||||
@classmethod
|
|
||||||
def validate_tags(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
|
||||||
"""Validate and clean tags."""
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
cleaned = [tag.strip() for tag in v if tag.strip()]
|
|
||||||
return list(set(cleaned))
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPage(WikiPageBase):
|
|
||||||
"""Response model for a wiki page."""
|
|
||||||
id: int = Field(..., description="Page ID")
|
|
||||||
path: str = Field(..., description="Page path")
|
|
||||||
content: Optional[str] = Field(None, description="Page content")
|
|
||||||
created_at: Optional[str] = Field(None, description="Creation timestamp")
|
|
||||||
updated_at: Optional[str] = Field(None, description="Last update timestamp")
|
|
||||||
editor: Optional[str] = Field(None, description="Editor type")
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPageSummary(BaseModel):
|
|
||||||
"""Summarized wiki page (for list responses)."""
|
|
||||||
id: int = Field(..., description="Page ID")
|
|
||||||
path: str = Field(..., description="Page path")
|
|
||||||
title: str = Field(..., description="Page title")
|
|
||||||
description: Optional[str] = Field(None, description="Page description")
|
|
||||||
tags: List[str] = Field(default_factory=list, description="Page tags")
|
|
||||||
updated_at: Optional[str] = Field(None, description="Last update timestamp")
|
|
||||||
is_published: bool = Field(..., description="Publication status")
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPageList(BaseModel):
|
|
||||||
"""Response model for list of pages."""
|
|
||||||
pages: List[WikiPageSummary] = Field(..., description="List of pages")
|
|
||||||
total: int = Field(..., description="Total number of pages")
|
|
||||||
filtered_by_tag: Optional[str] = Field(None, description="Tag filter applied")
|
|
||||||
user: str = Field(..., description="User namespace")
|
|
||||||
|
|
||||||
|
|
||||||
# Dossier models
|
|
||||||
class DossierCreate(BaseModel):
|
|
||||||
"""Request model for creating a dossier."""
|
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="Dossier name (becomes a tag)")
|
|
||||||
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")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, v: str) -> str:
|
|
||||||
"""Validate dossier name (will be used as tag)."""
|
|
||||||
# Convert to lowercase, replace spaces with hyphens
|
|
||||||
name = v.lower().strip()
|
|
||||||
name = name.replace(" ", "-")
|
|
||||||
# Remove special characters except hyphens and underscores
|
|
||||||
name = "".join(c for c in name if c.isalnum() or c in "-_")
|
|
||||||
if not name:
|
|
||||||
raise ValueError("Dossier name must contain alphanumeric characters")
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
class DossierInfo(BaseModel):
|
|
||||||
"""Response model for dossier information."""
|
|
||||||
name: str = Field(..., description="Dossier name (tag)")
|
|
||||||
title: str = Field(..., description="Dossier title")
|
|
||||||
description: str = Field(..., description="Dossier description")
|
|
||||||
page_count: int = Field(..., description="Number of pages in dossier")
|
|
||||||
index_page_id: Optional[int] = Field(None, description="ID of index page")
|
|
||||||
index_page_path: Optional[str] = Field(None, description="Path to index page")
|
|
||||||
created_at: Optional[str] = Field(None, description="Creation timestamp")
|
|
||||||
|
|
||||||
|
|
||||||
class DossierList(BaseModel):
|
|
||||||
"""Response model for list of dossiers."""
|
|
||||||
dossiers: List[DossierInfo] = Field(..., description="List of dossiers")
|
|
||||||
total: int = Field(..., description="Total number of dossiers")
|
|
||||||
user: str = Field(..., description="User namespace")
|
|
||||||
|
|
||||||
|
|
||||||
# Search models
|
|
||||||
class WikiSearchResult(BaseModel):
|
|
||||||
"""Search result item."""
|
|
||||||
id: int = Field(..., description="Page ID")
|
|
||||||
path: str = Field(..., description="Page path")
|
|
||||||
title: str = Field(..., description="Page title")
|
|
||||||
description: Optional[str] = Field(None, description="Page description")
|
|
||||||
relevance: Optional[float] = Field(None, description="Search relevance score")
|
|
||||||
|
|
||||||
|
|
||||||
class WikiSearchResponse(BaseModel):
|
|
||||||
"""Response model for search results."""
|
|
||||||
results: List[WikiSearchResult] = Field(..., description="Search results")
|
|
||||||
query: str = Field(..., description="Search query")
|
|
||||||
total: int = Field(..., description="Total results found")
|
|
||||||
|
|
||||||
|
|
||||||
# Move/rename models
|
|
||||||
class WikiPageMove(BaseModel):
|
|
||||||
"""Request model for moving/renaming a page."""
|
|
||||||
new_path: str = Field(..., min_length=1, description="New page path")
|
|
||||||
locale: str = Field(default="en", description="Page locale")
|
|
||||||
|
|
||||||
@field_validator("new_path")
|
|
||||||
@classmethod
|
|
||||||
def validate_new_path(cls, v: str) -> str:
|
|
||||||
"""Validate new path."""
|
|
||||||
if not v.startswith("/"):
|
|
||||||
v = f"/{v}"
|
|
||||||
if v.endswith("/") and v != "/":
|
|
||||||
v = v.rstrip("/")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
# Response models for operations
|
|
||||||
class WikiOperationResponse(BaseModel):
|
|
||||||
"""Generic response for wiki operations."""
|
|
||||||
success: bool = Field(..., description="Whether operation succeeded")
|
|
||||||
message: str = Field(..., description="Operation message")
|
|
||||||
page_id: Optional[int] = Field(None, description="Page ID (if applicable)")
|
|
||||||
page_path: Optional[str] = Field(None, description="Page path (if applicable)")
|
|
||||||
|
|
||||||
|
|
||||||
class DossierOperationResponse(BaseModel):
|
|
||||||
"""Response for dossier operations."""
|
|
||||||
success: bool = Field(..., description="Whether operation succeeded")
|
|
||||||
message: str = Field(..., description="Operation message")
|
|
||||||
dossier_name: str = Field(..., description="Dossier name")
|
|
||||||
index_page_id: Optional[int] = Field(None, description="Index page ID (if created)")
|
|
||||||
index_page_path: Optional[str] = Field(None, description="Index page path (if created)")
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"""
|
|
||||||
Knowledge Consolidation router for Librarian processing.
|
|
||||||
|
|
||||||
Provides endpoints for the Scheduler to trigger knowledge consolidation
|
|
||||||
from HybridRAG search results into wiki pages.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
|
||||||
import logging
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
from src.config import Settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/consolidate", tags=["Consolidation"])
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get Consolidation service
|
|
||||||
def get_consolidation_service(
|
|
||||||
neo4j_client: Neo4jDep,
|
|
||||||
ollama_client: OllamaDep,
|
|
||||||
wiki_client: WikiJSDep,
|
|
||||||
settings: Settings = Depends(get_settings)
|
|
||||||
) -> ConsolidationService:
|
|
||||||
"""Get ConsolidationService instance with all dependencies."""
|
|
||||||
return ConsolidationService(
|
|
||||||
neo4j=neo4j_client,
|
|
||||||
ollama=ollama_client,
|
|
||||||
wiki=wiki_client,
|
|
||||||
settings=settings,
|
|
||||||
ingestion_service=get_ingestion_service()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/knowledge", response_model=ConsolidationResponse)
|
|
||||||
async def consolidate_knowledge(
|
|
||||||
request: ConsolidationRequest,
|
|
||||||
consolidation_service: ConsolidationService = Depends(get_consolidation_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Consolidate knowledge from HybridRAG search results into wiki pages.
|
|
||||||
|
|
||||||
**Librarian Task** - Processes unprocessed SearchQuery nodes to:
|
|
||||||
1. Find searches with web results from last N days
|
|
||||||
2. Analyze web content with LLM for novel information
|
|
||||||
3. Create new wiki pages for new concepts/technologies
|
|
||||||
4. Update existing pages with new facts and citations
|
|
||||||
5. Add new entities to knowledge graph
|
|
||||||
6. Mark SearchQuery nodes as processed
|
|
||||||
|
|
||||||
**Typically called by The Scheduler** on a periodic basis (e.g., hourly).
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `process_limit`: Maximum searches to process per run (default: 10)
|
|
||||||
- `lookback_days`: Only process searches from last N days (default: 7)
|
|
||||||
- `min_web_results`: Minimum web results required to consolidate (default: 2)
|
|
||||||
- `dry_run`: If true, analyze but don't create pages (default: false)
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
- `total_found`: Number of unprocessed searches found
|
|
||||||
- `processed_count`: Successfully processed searches
|
|
||||||
- `pages_created`: New wiki pages created
|
|
||||||
- `pages_updated`: Existing pages updated with new facts
|
|
||||||
- `entities_added`: New entities added to knowledge graph
|
|
||||||
- `errors`: List of error messages if any failed
|
|
||||||
- `results`: Per-search processing results
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"process_limit": 10,
|
|
||||||
"lookback_days": 7,
|
|
||||||
"min_web_results": 2,
|
|
||||||
"dry_run": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Example Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_found": 5,
|
|
||||||
"processed_count": 4,
|
|
||||||
"pages_created": 2,
|
|
||||||
"pages_updated": 3,
|
|
||||||
"entities_added": 7,
|
|
||||||
"errors": ["Search abc123: Failed to parse response"],
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"search_id": "uuid-1",
|
|
||||||
"query": "docker orchestration kubernetes",
|
|
||||||
"pages_created": 1,
|
|
||||||
"pages_updated": 1,
|
|
||||||
"entities_added": 3
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dry_run": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Scheduler Task Configuration:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"task_name": "knowledge_consolidation",
|
|
||||||
"service": "library-desk",
|
|
||||||
"executor": "rest_api_executor",
|
|
||||||
"priority": 50,
|
|
||||||
"minute": 0,
|
|
||||||
"hour": -1,
|
|
||||||
"description": "Hourly knowledge consolidation from search results",
|
|
||||||
"config": {
|
|
||||||
"url": "http://library-desk:8089/consolidate/knowledge",
|
|
||||||
"method": "POST",
|
|
||||||
"payload": {
|
|
||||||
"process_limit": 10,
|
|
||||||
"lookback_days": 7,
|
|
||||||
"min_web_results": 2,
|
|
||||||
"dry_run": false
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"type": "bearer",
|
|
||||||
"token": "${LIBRARY_DESK_API_KEY}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
f"Knowledge consolidation requested: "
|
|
||||||
f"limit={request.process_limit}, lookback={request.lookback_days}d, "
|
|
||||||
f"dry_run={request.dry_run}"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=request.process_limit,
|
|
||||||
lookback_days=request.lookback_days,
|
|
||||||
min_web_results=request.min_web_results,
|
|
||||||
dry_run=request.dry_run
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Consolidation completed: {response.processed_count}/{response.total_found} searches, "
|
|
||||||
f"{response.pages_created} pages created, {response.pages_updated} updated"
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Invalid request: {e}")
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Knowledge consolidation failed: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Consolidation failed")
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
"""
|
|
||||||
Entity Linking Router
|
|
||||||
|
|
||||||
Finds and links mentions of existing entities in wiki pages to the knowledge graph.
|
|
||||||
Creates both:
|
|
||||||
1. Graph relationships (MENTIONS) in Neo4j
|
|
||||||
2. Hyperlinks in wiki page content (markdown links)
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
|
||||||
import re
|
|
||||||
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
from src.core.dependencies import (
|
|
||||||
get_wiki_service,
|
|
||||||
get_graph_service,
|
|
||||||
get_ingestion_service,
|
|
||||||
verify_api_key
|
|
||||||
)
|
|
||||||
from src.services.wiki_service import WikiService
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
from src.services.ingestion_service import IngestionService
|
|
||||||
from src.models.wiki import WikiPageUpdate
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/entity-linking", tags=["Entity Linking"])
|
|
||||||
|
|
||||||
|
|
||||||
class EntityLinkingResult(BaseModel):
|
|
||||||
"""Result of entity linking operation."""
|
|
||||||
page_id: int
|
|
||||||
page_title: str
|
|
||||||
entities_found: List[Dict[str, Any]]
|
|
||||||
new_graph_links_created: int # New MENTIONS relationships in Neo4j
|
|
||||||
total_graph_links: int
|
|
||||||
content_links_added: int # New hyperlinks added to wiki page content
|
|
||||||
content_updated: bool # Whether wiki page content was modified
|
|
||||||
re_indexed: bool
|
|
||||||
processing_time_ms: float
|
|
||||||
|
|
||||||
|
|
||||||
class EntityLinkingRequest(BaseModel):
|
|
||||||
"""Request for entity linking."""
|
|
||||||
user: str
|
|
||||||
page_id: int
|
|
||||||
create_relationships: bool = True
|
|
||||||
re_index_if_changed: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/link-page", response_model=EntityLinkingResult)
|
|
||||||
async def link_entities_in_page(
|
|
||||||
request: EntityLinkingRequest,
|
|
||||||
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)
|
|
||||||
) -> EntityLinkingResult:
|
|
||||||
"""
|
|
||||||
Find and link entities mentioned in a wiki page.
|
|
||||||
|
|
||||||
This endpoint:
|
|
||||||
1. Retrieves the page content from Wiki.js
|
|
||||||
2. Fetches all existing entities from the knowledge graph
|
|
||||||
3. Finds mentions of those entities in the page text
|
|
||||||
4. Creates MENTIONS relationships in Neo4j
|
|
||||||
5. Optionally re-indexes the page if new links were created
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: EntityLinkingRequest with page_id and user
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EntityLinkingResult with statistics about linked entities
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Entity linking for page {request.page_id} (user: {request.user})")
|
|
||||||
|
|
||||||
# Step 1: Get page content
|
|
||||||
page = await wiki_service.get_page(request.page_id, request.user)
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Page {request.page_id} not found")
|
|
||||||
|
|
||||||
page_title = page.title
|
|
||||||
page_content = page.content
|
|
||||||
original_content = page_content # Keep original for comparison
|
|
||||||
|
|
||||||
logger.info(f"Processing page: {page_title}")
|
|
||||||
|
|
||||||
# Step 2: Get all entities from knowledge graph with their wiki page paths
|
|
||||||
entities = await get_entities_with_paths(graph_service, request.user)
|
|
||||||
logger.info(f"Found {len(entities)} existing entities in graph")
|
|
||||||
|
|
||||||
# Step 3: Find entity mentions in page content
|
|
||||||
found_entities = find_entity_mentions(page_content, entities)
|
|
||||||
logger.info(f"Found {len(found_entities)} entity mentions in page")
|
|
||||||
|
|
||||||
# Filter out self-referential links (entities linking to the current page)
|
|
||||||
found_entities = [
|
|
||||||
e for e in found_entities
|
|
||||||
if e.get("doc_page_id") != request.page_id
|
|
||||||
]
|
|
||||||
logger.info(f"After filtering self-references: {len(found_entities)} entities to link")
|
|
||||||
|
|
||||||
# Step 4: Add hyperlinks to wiki content for entities with pages
|
|
||||||
content_links_added = 0
|
|
||||||
content_updated = False
|
|
||||||
|
|
||||||
if found_entities:
|
|
||||||
updated_content, content_links_added = add_entity_links_to_content(
|
|
||||||
page_content,
|
|
||||||
found_entities
|
|
||||||
)
|
|
||||||
|
|
||||||
if updated_content != original_content:
|
|
||||||
content_updated = True
|
|
||||||
logger.info(f"Added {content_links_added} hyperlinks to page content")
|
|
||||||
|
|
||||||
# Update the wiki page (preserve existing title, description, tags)
|
|
||||||
try:
|
|
||||||
update_data = WikiPageUpdate(
|
|
||||||
content=updated_content,
|
|
||||||
title=page.title,
|
|
||||||
description=page.description if hasattr(page, 'description') else None,
|
|
||||||
tags=page.tags if hasattr(page, 'tags') else None
|
|
||||||
)
|
|
||||||
await wiki_service.update_page(
|
|
||||||
page_id=request.page_id,
|
|
||||||
page_data=update_data,
|
|
||||||
user=request.user
|
|
||||||
)
|
|
||||||
logger.info(f"Updated wiki page {request.page_id} with entity links")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update wiki page content: {e}")
|
|
||||||
# Continue anyway - graph links can still be created
|
|
||||||
|
|
||||||
# Step 5: Create graph relationships if requested
|
|
||||||
new_graph_links = 0
|
|
||||||
total_graph_links = 0
|
|
||||||
|
|
||||||
if request.create_relationships and found_entities:
|
|
||||||
new_graph_links = await graph_service.create_entity_mentions(
|
|
||||||
page_id=request.page_id,
|
|
||||||
user=request.user,
|
|
||||||
entity_names=found_entities
|
|
||||||
)
|
|
||||||
total_graph_links = len(found_entities)
|
|
||||||
logger.info(f"Created {new_graph_links} new MENTIONS relationships")
|
|
||||||
|
|
||||||
# Step 6: Re-index if changes were made (content or graph)
|
|
||||||
re_indexed = False
|
|
||||||
if request.re_index_if_changed and (content_updated or new_graph_links > 0):
|
|
||||||
logger.info(f"Re-indexing page {request.page_id} due to entity linking changes")
|
|
||||||
try:
|
|
||||||
await ingestion_service.ingest_page(request.page_id, request.user)
|
|
||||||
re_indexed = True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Re-indexing failed: {e}")
|
|
||||||
# Don't fail the whole operation if re-indexing fails
|
|
||||||
|
|
||||||
processing_time = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
return EntityLinkingResult(
|
|
||||||
page_id=request.page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
entities_found=found_entities,
|
|
||||||
new_graph_links_created=new_graph_links,
|
|
||||||
total_graph_links=total_graph_links,
|
|
||||||
content_links_added=content_links_added,
|
|
||||||
content_updated=content_updated,
|
|
||||||
re_indexed=re_indexed,
|
|
||||||
processing_time_ms=processing_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Entity linking failed: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Entity linking failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def find_entity_mentions(content: str, entities: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Find mentions of entities in the page content.
|
|
||||||
|
|
||||||
Uses case-insensitive regex matching to find entity names in the text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Page content to search
|
|
||||||
entities: List of entities with 'name', 'type', and optionally 'path' fields
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of found entities with additional 'mentions' field
|
|
||||||
"""
|
|
||||||
found = []
|
|
||||||
content_lower = content.lower()
|
|
||||||
|
|
||||||
for entity in entities:
|
|
||||||
entity_name = entity.get("name", "")
|
|
||||||
if not entity_name or len(entity_name) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create regex pattern for whole word matching
|
|
||||||
# This avoids matching "John" in "Johnson"
|
|
||||||
pattern = r'\b' + re.escape(entity_name.lower()) + r'\b'
|
|
||||||
|
|
||||||
# Find all matches
|
|
||||||
matches = list(re.finditer(pattern, content_lower))
|
|
||||||
|
|
||||||
if matches:
|
|
||||||
found.append({
|
|
||||||
"name": entity_name,
|
|
||||||
"type": entity.get("type", "unknown"),
|
|
||||||
"mentions": len(matches),
|
|
||||||
"entity_id": entity.get("id"),
|
|
||||||
"path": entity.get("path"), # Include path if available
|
|
||||||
"doc_page_id": entity.get("doc_page_id") # Include for self-reference filtering
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by number of mentions (descending)
|
|
||||||
found.sort(key=lambda x: x["mentions"], reverse=True)
|
|
||||||
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def fuzzy_match_entity_to_document(
|
|
||||||
entity_name: str,
|
|
||||||
doc_title: str,
|
|
||||||
settings
|
|
||||||
) -> Tuple[bool, float]:
|
|
||||||
"""
|
|
||||||
Match entity name to document title with tolerance for variations.
|
|
||||||
|
|
||||||
Uses multiple strategies with confidence scoring:
|
|
||||||
1. Exact match (confidence: 1.0)
|
|
||||||
2. Containment match (confidence: 0.85-0.80)
|
|
||||||
3. Token overlap match (confidence: 0.60-0.80)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_name: Entity name to match
|
|
||||||
doc_title: Document title to match against
|
|
||||||
settings: Application settings with matching thresholds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (is_match, confidence_score)
|
|
||||||
"""
|
|
||||||
e_lower = entity_name.lower().strip()
|
|
||||||
d_lower = doc_title.lower().strip()
|
|
||||||
|
|
||||||
# Strategy 1: Exact match (confidence: 1.0)
|
|
||||||
if e_lower == d_lower:
|
|
||||||
return True, 1.0
|
|
||||||
|
|
||||||
# Strategy 2: Containment match (confidence: 0.85-0.80)
|
|
||||||
# Require minimum length to avoid false positives
|
|
||||||
if len(entity_name) >= settings.entity_linking_min_entity_length:
|
|
||||||
# Entity is substring of title: "lingecollege" in "RSG Lingecollege"
|
|
||||||
if e_lower in d_lower:
|
|
||||||
confidence = len(entity_name) / len(doc_title)
|
|
||||||
if confidence >= settings.entity_linking_min_containment_ratio:
|
|
||||||
return True, 0.85
|
|
||||||
|
|
||||||
# Title is substring of entity: "Google" in "Google Cloud Platform"
|
|
||||||
if d_lower in e_lower:
|
|
||||||
confidence = len(doc_title) / len(entity_name)
|
|
||||||
if confidence >= settings.entity_linking_min_containment_ratio:
|
|
||||||
return True, 0.80
|
|
||||||
|
|
||||||
# Strategy 3: Word-level overlap (confidence: 0.60-0.80)
|
|
||||||
entity_tokens = set(e_lower.split())
|
|
||||||
title_tokens = set(d_lower.split())
|
|
||||||
|
|
||||||
# Remove common stop words to reduce false positives
|
|
||||||
stop_words = {'the', 'a', 'an', 'of', 'for', 'and', 'or', 'in', 'on', 'at', 'to'}
|
|
||||||
entity_tokens -= stop_words
|
|
||||||
title_tokens -= stop_words
|
|
||||||
|
|
||||||
if entity_tokens and title_tokens:
|
|
||||||
overlap = len(entity_tokens & title_tokens)
|
|
||||||
total = len(entity_tokens | title_tokens)
|
|
||||||
overlap_ratio = overlap / total
|
|
||||||
|
|
||||||
# Require significant overlap to avoid weak matches
|
|
||||||
if overlap_ratio >= settings.entity_linking_min_token_overlap:
|
|
||||||
confidence = 0.75 * overlap_ratio # Scale: 0.45-0.75
|
|
||||||
return True, confidence
|
|
||||||
|
|
||||||
return False, 0.0
|
|
||||||
|
|
||||||
|
|
||||||
async def get_entities_with_paths(graph_service: GraphService, user: str) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get all entities and check which ones have corresponding wiki pages.
|
|
||||||
|
|
||||||
Uses fuzzy matching to handle name variations, typos, and partial matches
|
|
||||||
while minimizing false positives through confidence thresholds.
|
|
||||||
|
|
||||||
Returns entities with their names, types, paths, and match confidence.
|
|
||||||
"""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
|
||||||
|
|
||||||
# Step 1: Get all entities
|
|
||||||
entities_query = f"""
|
|
||||||
MATCH (e:{user_base_label})
|
|
||||||
WHERE NOT e:Document
|
|
||||||
RETURN e.name as name,
|
|
||||||
e.type as type,
|
|
||||||
e.id as id
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Step 2: Get all documents
|
|
||||||
documents_query = """
|
|
||||||
MATCH (d:Document)
|
|
||||||
RETURN d.title as title,
|
|
||||||
d.path as path,
|
|
||||||
d.page_id as page_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
entities = await graph_service.neo4j.execute_query(entities_query, {})
|
|
||||||
documents = await graph_service.neo4j.execute_query(documents_query, {})
|
|
||||||
|
|
||||||
# Step 3: Fuzzy match entities to documents
|
|
||||||
results = []
|
|
||||||
for entity in entities:
|
|
||||||
entity_name = entity["name"]
|
|
||||||
if not entity_name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
best_match = None
|
|
||||||
best_confidence = 0.0
|
|
||||||
|
|
||||||
# Try to match this entity to any document
|
|
||||||
for doc in documents:
|
|
||||||
doc_title = doc["title"]
|
|
||||||
if not doc_title:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_match, confidence = fuzzy_match_entity_to_document(
|
|
||||||
entity_name,
|
|
||||||
doc_title,
|
|
||||||
settings
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_match and confidence > best_confidence:
|
|
||||||
best_match = doc
|
|
||||||
best_confidence = confidence
|
|
||||||
|
|
||||||
# Only include matches above minimum confidence threshold
|
|
||||||
if best_match and best_confidence >= settings.entity_linking_min_confidence:
|
|
||||||
results.append({
|
|
||||||
"name": entity_name,
|
|
||||||
"type": entity.get("type", "unknown"),
|
|
||||||
"id": entity.get("id"),
|
|
||||||
"path": best_match["path"],
|
|
||||||
"doc_page_id": best_match["page_id"],
|
|
||||||
"match_confidence": best_confidence # NEW: track confidence
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# No matching document found (or below threshold)
|
|
||||||
results.append({
|
|
||||||
"name": entity_name,
|
|
||||||
"type": entity.get("type", "unknown"),
|
|
||||||
"id": entity.get("id"),
|
|
||||||
"path": None,
|
|
||||||
"doc_page_id": None,
|
|
||||||
"match_confidence": 0.0
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Matched {len([r for r in results if r['path']])} entities to documents (threshold: {settings.entity_linking_min_confidence})")
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get entities with paths: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def add_entity_links_to_content(
|
|
||||||
content: str,
|
|
||||||
entities_with_paths: List[Dict[str, Any]]
|
|
||||||
) -> Tuple[str, int]:
|
|
||||||
"""
|
|
||||||
Add markdown hyperlinks for entities in the content.
|
|
||||||
|
|
||||||
Only links entities that:
|
|
||||||
1. Have a wiki page (path is not None)
|
|
||||||
2. Are not already inside existing links
|
|
||||||
3. Are not already linked in the content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (updated_content, links_added_count)
|
|
||||||
"""
|
|
||||||
if not entities_with_paths:
|
|
||||||
return content, 0
|
|
||||||
|
|
||||||
# Filter to only entities with paths
|
|
||||||
linkable_entities = [e for e in entities_with_paths if e.get("path")]
|
|
||||||
if not linkable_entities:
|
|
||||||
return content, 0
|
|
||||||
|
|
||||||
# Sort by length (longest first) to avoid partial replacements
|
|
||||||
# e.g., "Machine Learning" before "Machine"
|
|
||||||
linkable_entities.sort(key=lambda x: len(x["name"]), reverse=True)
|
|
||||||
|
|
||||||
updated_content = content
|
|
||||||
links_added = 0
|
|
||||||
|
|
||||||
for entity in linkable_entities:
|
|
||||||
entity_name = entity["name"]
|
|
||||||
entity_path = entity["path"]
|
|
||||||
|
|
||||||
# Skip short entity names to avoid false positives
|
|
||||||
if len(entity_name) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create markdown link with full path (including user namespace)
|
|
||||||
# Wiki.js expects full paths like /users/jpmschweitzer/...
|
|
||||||
markdown_link = f"[{entity_name}](/{entity_path})"
|
|
||||||
|
|
||||||
# Find all existing markdown links to protect them (recompute each iteration)
|
|
||||||
link_pattern = r'\[([^\]]+)\]\([^\)]+\)'
|
|
||||||
existing_links = list(re.finditer(link_pattern, updated_content))
|
|
||||||
|
|
||||||
# Create a list of protected ranges (start, end) for ENTIRE links (text + URL)
|
|
||||||
# This prevents linking entity names inside existing link URLs
|
|
||||||
protected_ranges = [(m.start(), m.end()) for m in existing_links]
|
|
||||||
|
|
||||||
# Find all potential matches
|
|
||||||
pattern = r'\b(' + re.escape(entity_name) + r')\b'
|
|
||||||
matches = list(re.finditer(pattern, updated_content, flags=re.IGNORECASE))
|
|
||||||
|
|
||||||
# Filter out matches that are inside existing links
|
|
||||||
valid_matches = []
|
|
||||||
for match in matches:
|
|
||||||
match_start = match.start()
|
|
||||||
match_end = match.end()
|
|
||||||
|
|
||||||
# Check if this match is inside any protected range
|
|
||||||
inside_link = False
|
|
||||||
for prot_start, prot_end in protected_ranges:
|
|
||||||
if prot_start <= match_start < prot_end or prot_start < match_end <= prot_end:
|
|
||||||
inside_link = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Also check if already a link (pattern like [entity_name](...))
|
|
||||||
if match_end < len(updated_content) - 1:
|
|
||||||
next_chars = updated_content[match_end:match_end+2]
|
|
||||||
if next_chars == '](':
|
|
||||||
inside_link = True
|
|
||||||
|
|
||||||
if not inside_link:
|
|
||||||
valid_matches.append(match)
|
|
||||||
|
|
||||||
if not valid_matches:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Replace valid matches in reverse order (to preserve positions)
|
|
||||||
for match in reversed(valid_matches):
|
|
||||||
updated_content = (
|
|
||||||
updated_content[:match.start()] +
|
|
||||||
markdown_link +
|
|
||||||
updated_content[match.end():]
|
|
||||||
)
|
|
||||||
links_added += 1
|
|
||||||
|
|
||||||
return updated_content, links_added
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
"""
|
|
||||||
Graph router for Library Desk API.
|
|
||||||
|
|
||||||
Endpoints for Neo4j knowledge graph operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
||||||
from typing import Optional, List
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from src.models.graph import (
|
|
||||||
CypherQueryRequest, CypherQueryResponse,
|
|
||||||
UpdateFromPageRequest, GraphUpdateSummary,
|
|
||||||
NodeListResponse, GraphNodeDetail,
|
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/graph", tags=["Graph"])
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get graph service
|
|
||||||
def get_graph_service(
|
|
||||||
neo4j_client: Neo4jDep,
|
|
||||||
wiki_client: WikiJSDep
|
|
||||||
) -> GraphService:
|
|
||||||
"""Get graph service instance."""
|
|
||||||
return GraphService(neo4j_client, wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/query", response_model=CypherQueryResponse)
|
|
||||||
async def execute_cypher_query(
|
|
||||||
request: CypherQueryRequest,
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Execute a user-scoped Cypher query.
|
|
||||||
|
|
||||||
The query is automatically scoped to the user's data for security.
|
|
||||||
This prevents users from accessing other users' graph data.
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": "MATCH (d:Document) RETURN d LIMIT 10",
|
|
||||||
"parameters": {},
|
|
||||||
"user": "jpmschweitzer"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Security:** Query is automatically scoped with user label.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await graph_service.execute_query(
|
|
||||||
query=request.query,
|
|
||||||
parameters=request.parameters,
|
|
||||||
user=request.user
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Cypher query failed: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Query execution failed")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nodes", response_model=NodeListResponse)
|
|
||||||
async def list_nodes(
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
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),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List graph nodes for a user.
|
|
||||||
|
|
||||||
Optionally filter by node type (Document, Person, Project, Concept, etc.).
|
|
||||||
|
|
||||||
**Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await graph_service.list_nodes(
|
|
||||||
user=user,
|
|
||||||
node_type=node_type,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list nodes: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to 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"),
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get detailed information about a graph node.
|
|
||||||
|
|
||||||
Returns the node, its relationships, and connected nodes.
|
|
||||||
|
|
||||||
**Example:** `/graph/nodes/4:abc123def:0?user=jpmschweitzer`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
node = await graph_service.get_node(node_id, user)
|
|
||||||
if not node:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Node {node_id} not found")
|
|
||||||
return node
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get node {node_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to 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"),
|
|
||||||
force_refresh: bool = Query(default=False, description="Force re-extraction"),
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update knowledge graph from a wiki page.
|
|
||||||
|
|
||||||
This endpoint:
|
|
||||||
1. Fetches the page from Wiki.js
|
|
||||||
2. Extracts entities (people, projects, concepts, technologies)
|
|
||||||
3. Creates/updates Document node
|
|
||||||
4. Creates entity nodes and MENTIONS relationships
|
|
||||||
5. Returns summary of what was updated
|
|
||||||
|
|
||||||
**Use Cases:**
|
|
||||||
- Called automatically after page creation/update (via BackgroundTasks)
|
|
||||||
- Called manually by user/Librarian to refresh graph
|
|
||||||
- Called by Scheduler for batch processing
|
|
||||||
|
|
||||||
**Example:** `POST /graph/update-from-page/4?user=jpmschweitzer`
|
|
||||||
|
|
||||||
**Returns:** Summary with nodes/relationships created and entities extracted
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await graph_service.update_from_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh
|
|
||||||
)
|
|
||||||
|
|
||||||
if not summary.success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Graph update failed: {summary.error_message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update graph from page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Graph update failed")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mindmap", response_model=MindMapResponse)
|
|
||||||
async def generate_mindmap(
|
|
||||||
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)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Generate mind map data for visualization.
|
|
||||||
|
|
||||||
Traverses the graph from a center node and returns nodes/links
|
|
||||||
in a format suitable for D3.js or similar visualization libraries.
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `center_node_id`: The node to center the mind map on
|
|
||||||
- `depth`: How many hops away from center to include (1-5)
|
|
||||||
- `user`: User identifier for scoping
|
|
||||||
|
|
||||||
**Example:** `POST /graph/mindmap?center_node_id=4:abc:0&depth=2`
|
|
||||||
|
|
||||||
**Returns:** Nodes and links for visualization
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await graph_service.generate_mindmap(
|
|
||||||
center_node_id=center_node_id,
|
|
||||||
user=user,
|
|
||||||
depth=depth
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to generate mindmap: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Mindmap generation failed")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-entity-pages")
|
|
||||||
async def generate_entity_pages(
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
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),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Generate wiki stub pages for graph entities.
|
|
||||||
|
|
||||||
Creates pages in `/entities/{type}/{name}` namespace for entities
|
|
||||||
that have been mentioned in multiple documents. This creates a
|
|
||||||
bidirectional knowledge graph ↔ wiki synchronization.
|
|
||||||
|
|
||||||
**Threshold:** Entities must be mentioned in at least `min_mentions` documents (default: 5)
|
|
||||||
|
|
||||||
**Auto-stub flow:**
|
|
||||||
1. Find entities with >= min_mentions
|
|
||||||
2. Check if entity already has a wiki page
|
|
||||||
3. If not, create stub page with:
|
|
||||||
- List of mentioning documents
|
|
||||||
- Related entities (co-occurring)
|
|
||||||
- Auto-generated tag to prevent feedback loops
|
|
||||||
|
|
||||||
**Feedback loop protection:** Pages tagged with `entity-stub` skip entity extraction
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- `min_mentions`: Minimum number of document mentions required (default: 5)
|
|
||||||
- `entity_types`: List of types to process (default: Person, Technology, Concept, Project)
|
|
||||||
- `user`: User identifier for scoping
|
|
||||||
|
|
||||||
**Example:** `POST /graph/generate-entity-pages?min_mentions=3&user=jpmschweitzer`
|
|
||||||
|
|
||||||
**Returns:** Summary with list of pages created and skipped
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
result = await graph_service.generate_entity_stubs(
|
|
||||||
user=user,
|
|
||||||
min_mentions=min_mentions,
|
|
||||||
entity_types=entity_types
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to generate entity pages: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Entity page generation failed")
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""
|
|
||||||
HybridRAG router for multi-source search API.
|
|
||||||
|
|
||||||
Provides endpoint for combining vector, graph, and web search
|
|
||||||
with RRF fusion and LLM re-ranking.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
||||||
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
|
|
||||||
)
|
|
||||||
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"),
|
|
||||||
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.
|
|
||||||
|
|
||||||
**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
|
|
||||||
4. **Enrichment**: Add related documents via shared entities
|
|
||||||
5. **LLM Re-ranking**: Re-rank with mistral-nemo for relevance
|
|
||||||
6. **Context Formatting**: Format for LLM consumption
|
|
||||||
7. **Persistence**: Store for Librarian knowledge consolidation
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": "How does Docker orchestration work with Kubernetes?",
|
|
||||||
"user": "jpmschweitzer",
|
|
||||||
"config": {
|
|
||||||
"vector_limit": 10,
|
|
||||||
"graph_limit": 10,
|
|
||||||
"web_limit": 5,
|
|
||||||
"enable_reranking": true,
|
|
||||||
"final_result_count": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
- Ranked results from all sources
|
|
||||||
- Extracted keywords/synonyms
|
|
||||||
- Related dossiers (via graph)
|
|
||||||
- Formatted context for LLM
|
|
||||||
- Performance timing breakdown
|
|
||||||
- Search ID for Librarian tracking
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(f"HybridRAG request: '{request.query}' for user '{user}'")
|
|
||||||
|
|
||||||
response = await hybrid_rag_service.search(
|
|
||||||
query=request.query,
|
|
||||||
user=user,
|
|
||||||
config=request.config
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"HybridRAG completed: {response.total_results} results in {response.timing.total_ms:.0f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"Invalid request: {e}")
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"HybridRAG search failed: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Search failed")
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""
|
|
||||||
Document Ingestion API Router
|
|
||||||
|
|
||||||
Endpoints for ingesting wiki pages into the knowledge base (vectors + graph).
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from src.services.ingestion_service import IngestionService
|
|
||||||
from src.models.ingestion import (
|
|
||||||
IngestionRequest,
|
|
||||||
IngestionResult,
|
|
||||||
BatchIngestionRequest,
|
|
||||||
BatchIngestionResult
|
|
||||||
)
|
|
||||||
from src.core.dependencies import get_ingestion_service, verify_api_key
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/ingest", tags=["Document Ingestion"])
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Ingest a single wiki page into the knowledge base.
|
|
||||||
|
|
||||||
This endpoint:
|
|
||||||
1. Fetches page content from Wiki.js
|
|
||||||
2. Chunks content and generates embeddings (Qdrant)
|
|
||||||
3. Extracts entities and updates knowledge graph (Neo4j)
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
- **After page creation**: Automatically called by consolidation service
|
|
||||||
- **Manual re-indexing**: Force refresh a page after manual edits
|
|
||||||
- **Selective ingestion**: Skip vectors or graph if only one is needed
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- Typical page: 1-3 seconds
|
|
||||||
- Large page (>5000 words): 5-10 seconds
|
|
||||||
- Vector and graph ingestion run in parallel
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.86.149:8089/ingest/page \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"page_id": 19,
|
|
||||||
"user": "jpmschweitzer",
|
|
||||||
"force_refresh": false
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
result = await ingestion.ingest_page(
|
|
||||||
page_id=request.page_id,
|
|
||||||
user=request.user,
|
|
||||||
force_refresh=request.force_refresh,
|
|
||||||
skip_vectors=request.skip_vectors,
|
|
||||||
skip_graph=request.skip_graph
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result.success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Ingestion failed: {result.error}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch", response_model=BatchIngestionResult)
|
|
||||||
async def ingest_batch(
|
|
||||||
request: BatchIngestionRequest,
|
|
||||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Ingest multiple wiki pages concurrently.
|
|
||||||
|
|
||||||
## Concurrency Control
|
|
||||||
|
|
||||||
The `max_concurrent` parameter controls how many pages are processed simultaneously:
|
|
||||||
- **Low (1-2)**: Safer for resource-constrained systems
|
|
||||||
- **Medium (3-5)**: Good balance of speed and stability
|
|
||||||
- **High (6-10)**: Maximum speed, requires good resources
|
|
||||||
|
|
||||||
## Batch Size Recommendations
|
|
||||||
|
|
||||||
- **Small batches (<10 pages)**: Use max_concurrent=3-5
|
|
||||||
- **Medium batches (10-50 pages)**: Use max_concurrent=3
|
|
||||||
- **Large batches (>50 pages)**: Use max_concurrent=2, consider splitting
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://192.168.86.149:8089/ingest/batch \
|
|
||||||
-H "Authorization: Bearer $API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"page_ids": [19, 20, 21, 22],
|
|
||||||
"user": "jpmschweitzer",
|
|
||||||
"max_concurrent": 3
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
result = await ingestion.ingest_batch(
|
|
||||||
page_ids=request.page_ids,
|
|
||||||
user=request.user,
|
|
||||||
force_refresh=request.force_refresh,
|
|
||||||
skip_vectors=request.skip_vectors,
|
|
||||||
skip_graph=request.skip_graph,
|
|
||||||
max_concurrent=request.max_concurrent
|
|
||||||
)
|
|
||||||
|
|
||||||
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')"),
|
|
||||||
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),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Ingest all wiki pages for a user (bulk re-indexing).
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
- **Initial setup**: Index all existing pages
|
|
||||||
- **Full re-index**: After major schema changes
|
|
||||||
- **Path-specific**: Re-index a specific section (e.g., all tech docs)
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Small wiki (<50 pages)**: 2-5 minutes
|
|
||||||
- **Medium wiki (50-200 pages)**: 5-20 minutes
|
|
||||||
- **Large wiki (>200 pages)**: 20+ minutes
|
|
||||||
|
|
||||||
**Recommendation**: Run as background job for large wikis
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ingest all pages for user
|
|
||||||
curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer" \
|
|
||||||
-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" \
|
|
||||||
-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
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
"""
|
|
||||||
Tools router for Library Desk API.
|
|
||||||
|
|
||||||
Provides tool discovery endpoint for AI agents.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from src.models.tools import (
|
|
||||||
ToolCatalog, ToolDefinition, ToolParameter, CategoryInfo, ParameterType
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tools", tags=["Tools"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_wiki_tools() -> list[ToolDefinition]:
|
|
||||||
"""Get wiki tool definitions."""
|
|
||||||
return [
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_list_pages",
|
|
||||||
category="wiki",
|
|
||||||
description="List wiki pages for a user with optional tag filtering",
|
|
||||||
method="GET",
|
|
||||||
endpoint="/wiki/pages",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer",
|
|
||||||
example="jpmschweitzer"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="tag",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Filter by tag (dossier)",
|
|
||||||
required=False,
|
|
||||||
example="projects"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="limit",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Maximum pages to return",
|
|
||||||
required=False,
|
|
||||||
default=50,
|
|
||||||
example=20
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="List of pages with summaries",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_get_page",
|
|
||||||
category="wiki",
|
|
||||||
description="Get a single wiki page by ID with full content",
|
|
||||||
method="GET",
|
|
||||||
endpoint="/wiki/pages/{page_id}",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="page_id",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Page ID to retrieve",
|
|
||||||
required=True,
|
|
||||||
example=123
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier for access control",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="Complete page object with content",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_create_page",
|
|
||||||
category="wiki",
|
|
||||||
description="Create a new wiki page in user's namespace",
|
|
||||||
method="POST",
|
|
||||||
endpoint="/wiki/pages",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="title",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Page title",
|
|
||||||
required=True,
|
|
||||||
example="Project Documentation"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="path",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Page path (will be prefixed with user namespace)",
|
|
||||||
required=True,
|
|
||||||
example="/projects/my-project"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="content",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Page content in Markdown format",
|
|
||||||
required=True,
|
|
||||||
example="# Overview\n\nThis is the content."
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="description",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Short page description",
|
|
||||||
required=False,
|
|
||||||
example="Documentation for my project"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="tags",
|
|
||||||
type=ParameterType.ARRAY,
|
|
||||||
description="Tags for categorization (dossiers)",
|
|
||||||
required=False,
|
|
||||||
example=["projects", "documentation"]
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="Created page object",
|
|
||||||
example={
|
|
||||||
"title": "My Project",
|
|
||||||
"path": "/projects/my-project",
|
|
||||||
"content": "# My Project\n\nProject description here.",
|
|
||||||
"tags": ["projects"],
|
|
||||||
"user": "jpmschweitzer"
|
|
||||||
},
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_update_page",
|
|
||||||
category="wiki",
|
|
||||||
description="Update an existing wiki page",
|
|
||||||
method="PUT",
|
|
||||||
endpoint="/wiki/pages/{page_id}",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="page_id",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Page ID to update",
|
|
||||||
required=True,
|
|
||||||
example=123
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="title",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="New page title",
|
|
||||||
required=False
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="content",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="New page content",
|
|
||||||
required=False
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="description",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="New description",
|
|
||||||
required=False
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="tags",
|
|
||||||
type=ParameterType.ARRAY,
|
|
||||||
description="New tags",
|
|
||||||
required=False
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="Updated page object",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_delete_page",
|
|
||||||
category="wiki",
|
|
||||||
description="Delete a wiki page",
|
|
||||||
method="DELETE",
|
|
||||||
endpoint="/wiki/pages/{page_id}",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="page_id",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Page ID to delete",
|
|
||||||
required=True,
|
|
||||||
example=123
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="Success confirmation",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_search_pages",
|
|
||||||
category="wiki",
|
|
||||||
description="Search wiki pages by content within user's namespace",
|
|
||||||
method="GET",
|
|
||||||
endpoint="/wiki/search",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="q",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Search query",
|
|
||||||
required=True,
|
|
||||||
example="docker configuration"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="limit",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Maximum results",
|
|
||||||
required=False,
|
|
||||||
default=20
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="List of matching pages",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_list_dossiers",
|
|
||||||
category="wiki",
|
|
||||||
description="List all dossiers (unique tags) for a user with page counts",
|
|
||||||
method="GET",
|
|
||||||
endpoint="/wiki/dossiers",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="List of dossiers with page counts",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
ToolDefinition(
|
|
||||||
name="wiki_get_dossier_pages",
|
|
||||||
category="wiki",
|
|
||||||
description="Get all pages in a specific dossier",
|
|
||||||
method="GET",
|
|
||||||
endpoint="/wiki/dossiers/{dossier_name}/pages",
|
|
||||||
parameters=[
|
|
||||||
ToolParameter(
|
|
||||||
name="dossier_name",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="Dossier name (tag)",
|
|
||||||
required=True,
|
|
||||||
example="projects"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="user",
|
|
||||||
type=ParameterType.STRING,
|
|
||||||
description="User identifier",
|
|
||||||
required=False,
|
|
||||||
default="jpmschweitzer"
|
|
||||||
),
|
|
||||||
ToolParameter(
|
|
||||||
name="limit",
|
|
||||||
type=ParameterType.INTEGER,
|
|
||||||
description="Maximum pages",
|
|
||||||
required=False,
|
|
||||||
default=100
|
|
||||||
),
|
|
||||||
],
|
|
||||||
returns="List of pages in dossier",
|
|
||||||
fast=True
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ToolCatalog)
|
|
||||||
async def get_tool_catalog() -> ToolCatalog:
|
|
||||||
"""
|
|
||||||
Get simplified tool catalog for AI agent consumption.
|
|
||||||
|
|
||||||
This endpoint provides a machine-readable catalog of all Library Desk tools,
|
|
||||||
optimized for discovery and use by AI agents like The Librarian.
|
|
||||||
|
|
||||||
Returns tool definitions with:
|
|
||||||
- Clear descriptions
|
|
||||||
- Parameter specifications
|
|
||||||
- Usage examples
|
|
||||||
- Performance characteristics
|
|
||||||
"""
|
|
||||||
wiki_tools = get_wiki_tools()
|
|
||||||
|
|
||||||
# Calculate category stats
|
|
||||||
categories = {}
|
|
||||||
for tool in wiki_tools:
|
|
||||||
if tool.category not in categories:
|
|
||||||
categories[tool.category] = 0
|
|
||||||
categories[tool.category] += 1
|
|
||||||
|
|
||||||
category_info = [
|
|
||||||
CategoryInfo(
|
|
||||||
name="wiki",
|
|
||||||
description="Wiki.js page and dossier management operations",
|
|
||||||
tool_count=categories.get("wiki", 0)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
return ToolCatalog(
|
|
||||||
service="library-desk",
|
|
||||||
version="1.0.0",
|
|
||||||
base_url="http://library-desk:8089",
|
|
||||||
categories=category_info,
|
|
||||||
tools=wiki_tools,
|
|
||||||
authentication="Bearer token via Authorization header (LIBRARY_API_KEY)"
|
|
||||||
)
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""
|
|
||||||
Vector router for Library Desk API.
|
|
||||||
|
|
||||||
Endpoints for semantic search and vector operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
||||||
from typing import Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from src.models.vector import (
|
|
||||||
SearchRequest, SearchResponse,
|
|
||||||
VectorUpdateRequest, VectorUpdateSummary,
|
|
||||||
CollectionListResponse,
|
|
||||||
DeletePageChunksRequest, DeletePageChunksResponse
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/vector", tags=["Vector"])
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get vector service
|
|
||||||
def get_vector_service(
|
|
||||||
qdrant_client: QdrantDep,
|
|
||||||
wiki_client: WikiJSDep,
|
|
||||||
ollama_client: OllamaDep
|
|
||||||
) -> VectorService:
|
|
||||||
"""Get vector service instance."""
|
|
||||||
return VectorService(qdrant_client, wiki_client, ollama_client)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=SearchResponse)
|
|
||||||
async def semantic_search(
|
|
||||||
request: SearchRequest,
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Perform semantic search across user's documents.
|
|
||||||
|
|
||||||
Uses Ollama to generate query embedding, then searches Qdrant
|
|
||||||
for similar document chunks.
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": "how to configure docker",
|
|
||||||
"user": "jpmschweitzer",
|
|
||||||
"limit": 10,
|
|
||||||
"score_threshold": 0.5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Returns:** List of matching chunks with similarity scores
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await vector_service.search(
|
|
||||||
query=request.query,
|
|
||||||
user=request.user,
|
|
||||||
limit=request.limit,
|
|
||||||
score_threshold=request.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")
|
|
||||||
|
|
||||||
|
|
||||||
@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"),
|
|
||||||
force_refresh: bool = Query(default=False, description="Force re-embedding"),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update vector embeddings from a wiki page.
|
|
||||||
|
|
||||||
This endpoint:
|
|
||||||
1. Fetches the page from Wiki.js
|
|
||||||
2. Chunks the content (500 tokens with 50 token overlap)
|
|
||||||
3. Generates embeddings via Ollama
|
|
||||||
4. Upserts chunks to Qdrant with metadata
|
|
||||||
|
|
||||||
**Use Cases:**
|
|
||||||
- Called automatically after page creation/update (via BackgroundTasks)
|
|
||||||
- Called manually by user/Librarian to refresh vectors
|
|
||||||
- Called by Scheduler for batch processing
|
|
||||||
|
|
||||||
**Example:** `POST /vector/update-from-page/5?user=jpmschweitzer`
|
|
||||||
|
|
||||||
**Returns:** Summary with chunks created and processing time
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await vector_service.update_from_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh
|
|
||||||
)
|
|
||||||
|
|
||||||
if not summary.success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Vector update failed: {summary.error_message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return summary
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update vectors from page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Vector update failed")
|
|
||||||
|
|
||||||
|
|
||||||
@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"),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete all vector chunks for a wiki page.
|
|
||||||
|
|
||||||
This is automatically called when a page is deleted from the wiki.
|
|
||||||
|
|
||||||
**Example:** `DELETE /vector/pages/5?user=jpmschweitzer`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
deleted_count = await vector_service.delete_page_chunks(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
return DeletePageChunksResponse(
|
|
||||||
page_id=page_id,
|
|
||||||
chunks_deleted=deleted_count,
|
|
||||||
success=deleted_count > 0,
|
|
||||||
message=f"Deleted {deleted_count} chunks for page {page_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete chunks for page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to delete chunks")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/collections", response_model=CollectionListResponse)
|
|
||||||
async def list_collections(
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List all Qdrant collections with statistics.
|
|
||||||
|
|
||||||
Returns collection names, vector counts, and point counts.
|
|
||||||
|
|
||||||
**Example:** `GET /vector/collections`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await vector_service.list_collections()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list collections: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to list collections")
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
"""
|
|
||||||
Wiki.js Webhook Handler
|
|
||||||
|
|
||||||
Receives webhook events from Wiki.js for page CRUD operations
|
|
||||||
and processes them identically to AI-generated content.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, Literal
|
|
||||||
|
|
||||||
from src.core.dependencies import (
|
|
||||||
get_ingestion_service,
|
|
||||||
get_wiki_service,
|
|
||||||
get_graph_service,
|
|
||||||
verify_api_key
|
|
||||||
)
|
|
||||||
from src.services.ingestion_service import IngestionService
|
|
||||||
from src.services.consolidation_service import ConsolidationService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/webhooks", tags=["Webhooks"])
|
|
||||||
|
|
||||||
|
|
||||||
class WikiJSWebhookPayload(BaseModel):
|
|
||||||
"""
|
|
||||||
Wiki.js webhook payload structure.
|
|
||||||
|
|
||||||
See: https://docs.requarks.io/webhooks
|
|
||||||
"""
|
|
||||||
event: Literal["page.create", "page.update", "page.delete", "page.rename"]
|
|
||||||
page: dict # Contains: id, title, path, content, etc.
|
|
||||||
user: dict # Contains: id, name, email
|
|
||||||
timestamp: str
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookProcessingResult(BaseModel):
|
|
||||||
"""Result of webhook processing."""
|
|
||||||
success: bool
|
|
||||||
page_id: int
|
|
||||||
page_title: str
|
|
||||||
event: str
|
|
||||||
ingested: bool
|
|
||||||
entity_linking: Optional[dict] = None
|
|
||||||
error: Optional[str] = None
|
|
||||||
processing_time_ms: float
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/wikijs", response_model=WebhookProcessingResult)
|
|
||||||
async def handle_wikijs_webhook(
|
|
||||||
payload: WikiJSWebhookPayload,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Handle Wiki.js webhook events.
|
|
||||||
|
|
||||||
Processes page changes identically to AI-generated content:
|
|
||||||
1. Ingest page → Vector embeddings + Graph entity extraction
|
|
||||||
2. Apply bidirectional entity linking
|
|
||||||
|
|
||||||
This ensures user-edited pages have the same structure and
|
|
||||||
connectivity as AI-generated pages.
|
|
||||||
|
|
||||||
Configuration in Wiki.js:
|
|
||||||
- Administration → Webhooks
|
|
||||||
- Add new webhook:
|
|
||||||
- URL: http://library-desk:8089/webhooks/wikijs
|
|
||||||
- Events: page.create, page.update
|
|
||||||
- Headers: Authorization: Bearer <API_KEY>
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
page_id = payload.page.get("id")
|
|
||||||
page_title = payload.page.get("title")
|
|
||||||
user_email = payload.user.get("email", "unknown")
|
|
||||||
|
|
||||||
# Extract user identifier from email (assumes format: user@domain)
|
|
||||||
# Adjust this based on your user mapping strategy
|
|
||||||
user = extract_user_from_email(user_email)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Received Wiki.js webhook: {payload.event} "
|
|
||||||
f"for page {page_id} ('{page_title}') by {user_email}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Handle different events
|
|
||||||
if payload.event == "page.delete":
|
|
||||||
# For deletions, clean up vectors and graph
|
|
||||||
logger.info(f"Page {page_id} deleted, cleaning up knowledge base")
|
|
||||||
background_tasks.add_task(
|
|
||||||
cleanup_deleted_page,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
user=user,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
processing_time = (time.time() - start_time) * 1000
|
|
||||||
return WebhookProcessingResult(
|
|
||||||
success=True,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
event=payload.event,
|
|
||||||
ingested=False,
|
|
||||||
processing_time_ms=processing_time
|
|
||||||
)
|
|
||||||
|
|
||||||
# For create/update events, process the page
|
|
||||||
if payload.event in ["page.create", "page.update"]:
|
|
||||||
# Run ingestion and entity linking in background
|
|
||||||
# to avoid blocking the webhook response
|
|
||||||
background_tasks.add_task(
|
|
||||||
process_wiki_page_change,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
user=user,
|
|
||||||
event=payload.event,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
|
|
||||||
processing_time = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
return WebhookProcessingResult(
|
|
||||||
success=True,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
event=payload.event,
|
|
||||||
ingested=True, # Will be processed in background
|
|
||||||
processing_time_ms=processing_time
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle page rename/move events
|
|
||||||
if payload.event == "page.rename":
|
|
||||||
# Update path and re-link if title changed
|
|
||||||
new_path = payload.page.get("path")
|
|
||||||
new_title = payload.page.get("title")
|
|
||||||
old_path = payload.page.get("oldPath", new_path)
|
|
||||||
old_title = payload.page.get("oldTitle", new_title)
|
|
||||||
|
|
||||||
background_tasks.add_task(
|
|
||||||
process_page_rename,
|
|
||||||
page_id=page_id,
|
|
||||||
old_path=old_path,
|
|
||||||
new_path=new_path,
|
|
||||||
old_title=old_title,
|
|
||||||
new_title=new_title,
|
|
||||||
user=user,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
|
|
||||||
processing_time = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
return WebhookProcessingResult(
|
|
||||||
success=True,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
event=payload.event,
|
|
||||||
ingested=False, # Path update only, no re-embedding needed
|
|
||||||
processing_time_ms=processing_time
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Webhook processing failed: {e}", exc_info=True)
|
|
||||||
processing_time = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
return WebhookProcessingResult(
|
|
||||||
success=False,
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
event=payload.event,
|
|
||||||
ingested=False,
|
|
||||||
error=str(e),
|
|
||||||
processing_time_ms=processing_time
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def process_wiki_page_change(
|
|
||||||
page_id: int,
|
|
||||||
page_title: str,
|
|
||||||
user: str,
|
|
||||||
event: str,
|
|
||||||
ingestion_service: IngestionService
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Process wiki page change identically to AI-generated content.
|
|
||||||
|
|
||||||
This ensures consistency between manual and AI workflows.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Ingest page → Vector embeddings + Graph entities
|
|
||||||
2. Apply bidirectional entity linking
|
|
||||||
"""
|
|
||||||
from src.core.dependencies import get_neo4j_client, get_ollama_client, get_wikijs_client
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Processing {event} for page {page_id} ('{page_title}')")
|
|
||||||
|
|
||||||
# STEP 1: Ingest page (vector + graph)
|
|
||||||
logger.info(f"Ingesting page {page_id} into knowledge base")
|
|
||||||
await ingestion_service.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=(event == "page.update") # Force refresh on updates
|
|
||||||
)
|
|
||||||
logger.info(f"Ingestion complete for page {page_id}")
|
|
||||||
|
|
||||||
# STEP 2: Apply bidirectional entity linking
|
|
||||||
logger.info(f"Applying bidirectional entity linking for page {page_id}")
|
|
||||||
|
|
||||||
# Import consolidation service to use the entity linking method
|
|
||||||
settings = get_settings()
|
|
||||||
consolidation_service = ConsolidationService(
|
|
||||||
neo4j=get_neo4j_client(),
|
|
||||||
ollama=get_ollama_client(),
|
|
||||||
wiki=get_wikijs_client(),
|
|
||||||
settings=settings,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
|
|
||||||
link_stats = await consolidation_service._apply_bidirectional_entity_linking(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Entity linking complete for page {page_id}: "
|
|
||||||
f"{link_stats['forward_links']} forward links, "
|
|
||||||
f"{link_stats['backward_links']} backward links "
|
|
||||||
f"({link_stats['pages_updated']} pages updated)"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Successfully processed {event} for page {page_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to process page {page_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_user_from_email(email: str) -> str:
|
|
||||||
"""
|
|
||||||
Extract user identifier from email.
|
|
||||||
|
|
||||||
Customize this based on your user mapping strategy:
|
|
||||||
- Option 1: Use email prefix (user@domain → user)
|
|
||||||
- Option 2: Map email to Wiki.js username
|
|
||||||
- Option 3: Use email directly
|
|
||||||
|
|
||||||
Args:
|
|
||||||
email: User email from Wiki.js webhook
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User identifier for multi-tenancy
|
|
||||||
"""
|
|
||||||
# Option 1: Extract prefix from email
|
|
||||||
if "@" in email:
|
|
||||||
return email.split("@")[0]
|
|
||||||
|
|
||||||
# Fallback: use email as-is
|
|
||||||
return email
|
|
||||||
|
|
||||||
|
|
||||||
async def process_page_rename(
|
|
||||||
page_id: int,
|
|
||||||
old_path: str,
|
|
||||||
new_path: str,
|
|
||||||
old_title: str,
|
|
||||||
new_title: str,
|
|
||||||
user: str,
|
|
||||||
ingestion_service: IngestionService
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Process page rename/move events.
|
|
||||||
|
|
||||||
Handles two scenarios:
|
|
||||||
1. Path change only (move to different location) - update path in graph
|
|
||||||
2. Title change (rename) - update title, re-link entities, re-process
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page ID
|
|
||||||
old_path: Previous page path
|
|
||||||
new_path: New page path
|
|
||||||
old_title: Previous page title
|
|
||||||
new_title: New page title
|
|
||||||
user: User identifier
|
|
||||||
ingestion_service: Ingestion service instance
|
|
||||||
"""
|
|
||||||
from src.core.dependencies import get_neo4j_client
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_label
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
f"Processing rename for page {page_id}: "
|
|
||||||
f"'{old_title}' → '{new_title}', "
|
|
||||||
f"'{old_path}' → '{new_path}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
neo4j = get_neo4j_client()
|
|
||||||
user_doc_label = get_neo4j_user_label(user)
|
|
||||||
|
|
||||||
# Check if title changed (rename) or just path changed (move)
|
|
||||||
title_changed = old_title != new_title
|
|
||||||
path_changed = old_path != new_path
|
|
||||||
|
|
||||||
if not title_changed and not path_changed:
|
|
||||||
logger.info("No changes detected, skipping processing")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update Document node in graph
|
|
||||||
update_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: $page_id}})
|
|
||||||
SET d.path = $new_path,
|
|
||||||
d.title = $new_title,
|
|
||||||
d.updated_at = datetime()
|
|
||||||
RETURN d
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
await neo4j.execute_query(update_query, {
|
|
||||||
"page_id": page_id,
|
|
||||||
"new_path": new_path,
|
|
||||||
"new_title": new_title
|
|
||||||
})
|
|
||||||
logger.info(f"Updated Document node for page {page_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update Document node: {e}")
|
|
||||||
|
|
||||||
# If title changed, need to re-process for entity linking
|
|
||||||
if title_changed:
|
|
||||||
logger.info(f"Title changed, re-processing page {page_id}")
|
|
||||||
|
|
||||||
# Re-ingest to update entities (title might be an entity)
|
|
||||||
try:
|
|
||||||
await ingestion_service.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=True
|
|
||||||
)
|
|
||||||
logger.info(f"Re-ingested page {page_id} after title change")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to re-ingest page {page_id}: {e}")
|
|
||||||
|
|
||||||
# Re-apply bidirectional entity linking
|
|
||||||
from src.config import get_settings
|
|
||||||
from src.core.dependencies import get_ollama_client, get_wikijs_client
|
|
||||||
from src.services.consolidation_service import ConsolidationService
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
consolidation_service = ConsolidationService(
|
|
||||||
neo4j=neo4j,
|
|
||||||
ollama=get_ollama_client(),
|
|
||||||
wiki=get_wikijs_client(),
|
|
||||||
settings=settings,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
link_stats = await consolidation_service._apply_bidirectional_entity_linking(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=new_title,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Entity linking complete for renamed page {page_id}: "
|
|
||||||
f"{link_stats['forward_links']} forward links, "
|
|
||||||
f"{link_stats['backward_links']} backward links"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to apply entity linking: {e}")
|
|
||||||
|
|
||||||
elif path_changed:
|
|
||||||
logger.info(f"Path changed only (move), no re-processing needed")
|
|
||||||
|
|
||||||
logger.info(f"Rename processing complete for page {page_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to process rename for page {page_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_deleted_page(
|
|
||||||
page_id: int,
|
|
||||||
page_title: str,
|
|
||||||
user: str,
|
|
||||||
ingestion_service: IngestionService
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Clean up vectors and graph when a page is deleted.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Remove vector embeddings from Qdrant
|
|
||||||
2. Remove Document node from Neo4j
|
|
||||||
3. Clean up orphaned entities (entities only connected to this document)
|
|
||||||
4. Remove broken MENTIONS relationships
|
|
||||||
"""
|
|
||||||
from src.core.dependencies import get_neo4j_client, get_vector_service
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label, get_neo4j_user_label
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"Cleaning up deleted page {page_id} ('{page_title}')")
|
|
||||||
|
|
||||||
vector_service = get_vector_service()
|
|
||||||
neo4j = get_neo4j_client()
|
|
||||||
|
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
|
||||||
user_doc_label = get_neo4j_user_label(user)
|
|
||||||
|
|
||||||
# STEP 1: Remove vectors from Qdrant
|
|
||||||
logger.info(f"Removing vectors for page {page_id}")
|
|
||||||
try:
|
|
||||||
await vector_service.delete_page_chunks(page_id, user)
|
|
||||||
logger.info(f"Removed vectors for page {page_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to remove vectors for page {page_id}: {e}")
|
|
||||||
|
|
||||||
# STEP 2: Find and store orphaned entities before deletion
|
|
||||||
# (entities that only have this document mentioning them)
|
|
||||||
orphaned_entities_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: $page_id}})-[:MENTIONS]->(e:{user_base_label})
|
|
||||||
WHERE NOT e:Document
|
|
||||||
WITH e, count{{(d2:Document)-[:MENTIONS]->(e)}} as mention_count
|
|
||||||
WHERE mention_count = 1
|
|
||||||
RETURN e.id as entity_id, e.name as entity_name, labels(e) as labels
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
orphaned = await neo4j.execute_query(orphaned_entities_query, {"page_id": page_id})
|
|
||||||
logger.info(f"Found {len(orphaned)} orphaned entities for page {page_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to find orphaned entities: {e}")
|
|
||||||
orphaned = []
|
|
||||||
|
|
||||||
# STEP 3: Delete Document node (this will cascade delete MENTIONS relationships)
|
|
||||||
delete_doc_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: $page_id}})
|
|
||||||
DETACH DELETE d
|
|
||||||
RETURN count(d) as deleted_count
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await neo4j.execute_query(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:
|
|
||||||
logger.error(f"Failed to delete Document node: {e}")
|
|
||||||
|
|
||||||
# STEP 4: Delete orphaned entities
|
|
||||||
if orphaned:
|
|
||||||
for entity in orphaned:
|
|
||||||
entity_id = entity["entity_id"]
|
|
||||||
entity_name = entity["entity_name"]
|
|
||||||
|
|
||||||
delete_entity_query = f"""
|
|
||||||
MATCH (e:{user_base_label} {{id: $entity_id}})
|
|
||||||
WHERE NOT e:Document
|
|
||||||
AND NOT EXISTS {{(d:Document)-[:MENTIONS]->(e)}}
|
|
||||||
DETACH DELETE e
|
|
||||||
RETURN count(e) as deleted_count
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await neo4j.execute_query(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}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete orphaned entity {entity_name}: {e}")
|
|
||||||
|
|
||||||
# STEP 5: Clean up broken SearchQuery relationships
|
|
||||||
cleanup_search_query = f"""
|
|
||||||
MATCH (sq:SearchQuery)-[r:FOUND]->(d:Document)
|
|
||||||
WHERE NOT EXISTS {{(d)}}
|
|
||||||
DELETE r
|
|
||||||
RETURN count(r) as cleaned_count
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await neo4j.execute_query(cleanup_search_query, {})
|
|
||||||
cleaned = result[0]["cleaned_count"] if result else 0
|
|
||||||
if cleaned > 0:
|
|
||||||
logger.info(f"Cleaned up {cleaned} broken SearchQuery relationships")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clean SearchQuery relationships: {e}")
|
|
||||||
|
|
||||||
logger.info(f"Cleanup complete for deleted page {page_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to cleanup deleted page {page_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
@router.get("/health")
|
|
||||||
async def webhook_health():
|
|
||||||
"""Health check for webhook endpoint."""
|
|
||||||
return {"status": "ok", "service": "webhooks"}
|
|
||||||
@@ -1,409 +0,0 @@
|
|||||||
"""
|
|
||||||
Wiki router for Library Desk API.
|
|
||||||
|
|
||||||
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 typing import Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from src.models.wiki import (
|
|
||||||
WikiPage, WikiPageList, WikiPageCreate, WikiPageUpdate, WikiPageMove,
|
|
||||||
WikiOperationResponse, WikiSearchResponse,
|
|
||||||
DossierList, WikiSearchResult
|
|
||||||
)
|
|
||||||
from src.services.wiki_service import WikiService
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
from src.services.vector_service import VectorService
|
|
||||||
from src.clients.wikijs_client import WikiJSClient
|
|
||||||
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, verify_api_key
|
|
||||||
from src.core.multi_tenancy import DEFAULT_USER
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/wiki", tags=["Wiki"])
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get wiki service
|
|
||||||
def get_wiki_service(wiki_client: WikiJSDep) -> WikiService:
|
|
||||||
"""Get wiki service instance."""
|
|
||||||
return WikiService(wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get graph service
|
|
||||||
def get_graph_service(neo4j_client: Neo4jDep, wiki_client: WikiJSDep) -> GraphService:
|
|
||||||
"""Get graph service instance."""
|
|
||||||
return GraphService(neo4j_client, wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
# Dependency to get vector service
|
|
||||||
def get_vector_service(
|
|
||||||
qdrant_client: QdrantDep,
|
|
||||||
wiki_client: WikiJSDep,
|
|
||||||
ollama_client: OllamaDep
|
|
||||||
) -> VectorService:
|
|
||||||
"""Get vector service instance."""
|
|
||||||
return VectorService(qdrant_client, wiki_client, ollama_client)
|
|
||||||
|
|
||||||
|
|
||||||
# Page operations
|
|
||||||
@router.get("/pages", response_model=WikiPageList)
|
|
||||||
async def list_pages(
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
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),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List wiki pages for a user.
|
|
||||||
|
|
||||||
Optionally filter by tag (dossier). Pages are scoped to user's namespace.
|
|
||||||
|
|
||||||
**Example:** `/wiki/pages?user=jpmschweitzer&tag=projects&limit=20`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await wiki_service.list_pages(user=user, tag=tag, limit=limit)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list pages: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pages/{page_id}", response_model=WikiPage)
|
|
||||||
async def get_page(
|
|
||||||
page_id: int,
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get a single wiki page by ID.
|
|
||||||
|
|
||||||
Access is restricted to pages within the user's namespace.
|
|
||||||
|
|
||||||
**Example:** `/wiki/pages/123?user=jpmschweitzer`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page = await wiki_service.get_page(page_id=page_id, user=user)
|
|
||||||
if not page:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Page {page_id} not found or access denied")
|
|
||||||
return page
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pages", response_model=WikiPage, status_code=201)
|
|
||||||
async def create_page(
|
|
||||||
page_data: WikiPageCreate,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create a new wiki page.
|
|
||||||
|
|
||||||
The page will be created in the user's namespace. If path doesn't start
|
|
||||||
with namespace, it will be automatically prefixed.
|
|
||||||
|
|
||||||
**Auto-updates knowledge graph and vector embeddings**: After creating
|
|
||||||
the page, the graph and vectors are automatically updated in the
|
|
||||||
background to extract entities/relationships and generate semantic embeddings.
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Library Desk Architecture",
|
|
||||||
"path": "/projects/library-desk/architecture",
|
|
||||||
"content": "# Architecture\\n\\nThis describes...",
|
|
||||||
"description": "Architecture documentation",
|
|
||||||
"tags": ["projects", "architecture"],
|
|
||||||
"user": "jpmschweitzer"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page = await wiki_service.create_page(page_data)
|
|
||||||
|
|
||||||
user = page_data.user or DEFAULT_USER
|
|
||||||
|
|
||||||
# Schedule BOTH graph and vector updates in background (non-blocking)
|
|
||||||
background_tasks.add_task(
|
|
||||||
graph_service.update_from_page,
|
|
||||||
page_id=page.id,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
background_tasks.add_task(
|
|
||||||
vector_service.update_from_page,
|
|
||||||
page_id=page.id,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Page {page.id} created, graph and vector updates scheduled")
|
|
||||||
return page
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create page: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/pages/{page_id}", response_model=WikiPage)
|
|
||||||
async def update_page(
|
|
||||||
page_id: int,
|
|
||||||
page_data: WikiPageUpdate,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update an existing wiki page.
|
|
||||||
|
|
||||||
Only pages within the user's namespace can be updated.
|
|
||||||
Partial updates are supported - only provided fields will be updated.
|
|
||||||
|
|
||||||
**Auto-updates knowledge graph and vector embeddings**: After updating
|
|
||||||
the page, the graph and vectors are automatically refreshed in the
|
|
||||||
background to reflect the changes.
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Updated Title",
|
|
||||||
"tags": ["projects", "updated"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
page = await wiki_service.update_page(
|
|
||||||
page_id=page_id,
|
|
||||||
page_data=page_data,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schedule BOTH graph and vector updates in background (non-blocking)
|
|
||||||
background_tasks.add_task(
|
|
||||||
graph_service.update_from_page,
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=True # Force refresh on updates
|
|
||||||
)
|
|
||||||
background_tasks.add_task(
|
|
||||||
vector_service.update_from_page,
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=True # Force refresh on updates
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Page {page_id} updated, graph and vector refresh scheduled")
|
|
||||||
return page
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/pages/{page_id}", response_model=WikiOperationResponse)
|
|
||||||
async def delete_page(
|
|
||||||
page_id: int,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
vector_service: VectorService = Depends(get_vector_service),
|
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete a wiki page.
|
|
||||||
|
|
||||||
Only pages within the user's namespace can be deleted.
|
|
||||||
This operation cannot be undone.
|
|
||||||
|
|
||||||
**Auto-cleanup**: Vector chunks and graph nodes for this page are
|
|
||||||
automatically deleted in the background.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
success = await wiki_service.delete_page(page_id=page_id, user=user)
|
|
||||||
|
|
||||||
# Schedule vector cleanup in background
|
|
||||||
background_tasks.add_task(
|
|
||||||
vector_service.delete_page_chunks,
|
|
||||||
page_id=page_id,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schedule graph cleanup in background
|
|
||||||
background_tasks.add_task(
|
|
||||||
graph_service.delete_page,
|
|
||||||
page_id=page_id,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Page {page_id} deleted, vector and graph cleanup scheduled")
|
|
||||||
return WikiOperationResponse(
|
|
||||||
success=success,
|
|
||||||
message=f"Page {page_id} deleted successfully",
|
|
||||||
page_id=page_id
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pages/{page_id}/move", response_model=WikiOperationResponse)
|
|
||||||
async def move_page(
|
|
||||||
page_id: int,
|
|
||||||
move_data: WikiPageMove,
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Move or rename a wiki page.
|
|
||||||
|
|
||||||
The new path must be within the user's namespace.
|
|
||||||
|
|
||||||
**Example Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"new_path": "/projects/library-desk/docs/architecture"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
success = await wiki_service.move_page(
|
|
||||||
page_id=page_id,
|
|
||||||
new_path=move_data.new_path,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to move page")
|
|
||||||
|
|
||||||
return WikiOperationResponse(
|
|
||||||
success=True,
|
|
||||||
message=f"Page {page_id} moved successfully",
|
|
||||||
page_id=page_id,
|
|
||||||
page_path=move_data.new_path
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to move page {page_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
# Search operations
|
|
||||||
@router.get("/search", response_model=WikiSearchResponse)
|
|
||||||
async def search_pages(
|
|
||||||
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)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Search wiki pages within user's namespace.
|
|
||||||
|
|
||||||
**Example:** `/wiki/search?q=architecture&user=jpmschweitzer&limit=10`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
results = await wiki_service.search_pages(
|
|
||||||
query=q,
|
|
||||||
user=user,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
return WikiSearchResponse(
|
|
||||||
results=[
|
|
||||||
WikiSearchResult(
|
|
||||||
id=r.id,
|
|
||||||
path=r.path,
|
|
||||||
title=r.title,
|
|
||||||
description=r.description,
|
|
||||||
relevance=None
|
|
||||||
)
|
|
||||||
for r in results
|
|
||||||
],
|
|
||||||
query=q,
|
|
||||||
total=len(results)
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Search failed: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
# Dossier operations
|
|
||||||
@router.get("/dossiers", response_model=DossierList)
|
|
||||||
async def list_dossiers(
|
|
||||||
user: str = Query(default=DEFAULT_USER, description="User identifier"),
|
|
||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
|
||||||
api_key: str = Depends(verify_api_key)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List all dossiers (unique tags) for a user.
|
|
||||||
|
|
||||||
Dossiers are tag-based collections of pages.
|
|
||||||
|
|
||||||
**Example:** `/wiki/dossiers?user=jpmschweitzer`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await wiki_service.list_dossiers(user=user)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list dossiers: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
|
|
||||||
|
|
||||||
@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"),
|
|
||||||
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)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get all pages in a dossier.
|
|
||||||
|
|
||||||
Returns pages tagged with the dossier name.
|
|
||||||
|
|
||||||
**Example:** `/wiki/dossiers/projects/pages?user=jpmschweitzer`
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await wiki_service.get_dossier_pages(
|
|
||||||
dossier_name=dossier_name,
|
|
||||||
user=user,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get dossier pages: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
|
||||||
@@ -1,932 +0,0 @@
|
|||||||
"""
|
|
||||||
Knowledge Consolidation Service (Librarian Logic)
|
|
||||||
|
|
||||||
Processes unprocessed SearchQuery nodes from HybridRAG searches
|
|
||||||
to consolidate new knowledge into wiki pages.
|
|
||||||
|
|
||||||
This service:
|
|
||||||
1. Queries Neo4j for unprocessed SearchQuery nodes
|
|
||||||
2. Analyzes web results with Ollama for novel information
|
|
||||||
3. Creates/updates wiki pages with new facts
|
|
||||||
4. Updates knowledge graph with new entities
|
|
||||||
5. Marks SearchQuery nodes as processed
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
|
|
||||||
from src.clients.neo4j_client import Neo4jClient
|
|
||||||
from src.clients.ollama_client import OllamaClient
|
|
||||||
from src.clients.wikijs_client import WikiJSClient
|
|
||||||
from src.services.wiki_page_writer import WikiPageWriter
|
|
||||||
from src.models.consolidation import (
|
|
||||||
SearchQueryInfo,
|
|
||||||
ConsolidationResult,
|
|
||||||
ConsolidationResponse
|
|
||||||
)
|
|
||||||
from src.config import Settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ConsolidationService:
|
|
||||||
"""
|
|
||||||
Service for consolidating knowledge from search results.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
neo4j: Neo4jClient,
|
|
||||||
ollama: OllamaClient,
|
|
||||||
wiki: WikiJSClient,
|
|
||||||
settings: Settings,
|
|
||||||
ingestion_service: Optional["IngestionService"] = None
|
|
||||||
):
|
|
||||||
self.neo4j = neo4j
|
|
||||||
self.ollama = ollama
|
|
||||||
self.wiki = wiki
|
|
||||||
self.settings = settings
|
|
||||||
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama)
|
|
||||||
self.ingestion_service = ingestion_service # Optional to avoid circular dependency
|
|
||||||
|
|
||||||
async def consolidate_knowledge(
|
|
||||||
self,
|
|
||||||
process_limit: int = 10,
|
|
||||||
lookback_days: int = 7,
|
|
||||||
min_web_results: int = 2,
|
|
||||||
dry_run: bool = False
|
|
||||||
) -> ConsolidationResponse:
|
|
||||||
"""
|
|
||||||
Process unprocessed search queries and consolidate knowledge.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
process_limit: Maximum searches to process
|
|
||||||
lookback_days: Only process searches from last N days
|
|
||||||
min_web_results: Minimum web results required to consolidate
|
|
||||||
dry_run: If True, analyze but don't create pages
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ConsolidationResponse with processing results
|
|
||||||
"""
|
|
||||||
logger.info(f"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")
|
|
||||||
|
|
||||||
# Find unprocessed searches
|
|
||||||
unprocessed = await self._find_unprocessed_searches(lookback_days, process_limit)
|
|
||||||
|
|
||||||
if not unprocessed:
|
|
||||||
logger.info("No unprocessed searches found")
|
|
||||||
return ConsolidationResponse(
|
|
||||||
total_found=0,
|
|
||||||
processed_count=0,
|
|
||||||
pages_created=0,
|
|
||||||
pages_updated=0,
|
|
||||||
entities_added=0,
|
|
||||||
errors=[],
|
|
||||||
results=[],
|
|
||||||
dry_run=dry_run
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Found {len(unprocessed)} unprocessed searches")
|
|
||||||
|
|
||||||
# Process each search
|
|
||||||
results: List[ConsolidationResult] = []
|
|
||||||
total_pages_created = 0
|
|
||||||
total_pages_updated = 0
|
|
||||||
total_entities_added = 0
|
|
||||||
errors: List[str] = []
|
|
||||||
|
|
||||||
for search in unprocessed:
|
|
||||||
try:
|
|
||||||
result = await self._process_search(
|
|
||||||
search=search,
|
|
||||||
min_web_results=min_web_results,
|
|
||||||
dry_run=dry_run
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
results.append(result)
|
|
||||||
total_pages_created += result.pages_created
|
|
||||||
total_pages_updated += result.pages_updated
|
|
||||||
total_entities_added += result.entities_added
|
|
||||||
|
|
||||||
# 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 Exception as e:
|
|
||||||
error_msg = f"Search {search['id'][:8]}: {str(e)}"
|
|
||||||
logger.error(f"Failed to process search: {error_msg}", exc_info=True)
|
|
||||||
errors.append(error_msg)
|
|
||||||
results.append(ConsolidationResult(
|
|
||||||
search_id=search['id'],
|
|
||||||
query=search['query'],
|
|
||||||
error=str(e)
|
|
||||||
))
|
|
||||||
|
|
||||||
# Mark as processed even on error (to avoid retrying failed searches forever)
|
|
||||||
if not dry_run:
|
|
||||||
await self._mark_search_processed(search['id'])
|
|
||||||
|
|
||||||
# Build response
|
|
||||||
processed_count = len([r for r in results if not r.error])
|
|
||||||
|
|
||||||
response = ConsolidationResponse(
|
|
||||||
total_found=len(unprocessed),
|
|
||||||
processed_count=processed_count,
|
|
||||||
pages_created=total_pages_created,
|
|
||||||
pages_updated=total_pages_updated,
|
|
||||||
entities_added=total_entities_added,
|
|
||||||
errors=errors,
|
|
||||||
results=results,
|
|
||||||
dry_run=dry_run
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Consolidation complete: {processed_count}/{len(unprocessed)} searches, "
|
|
||||||
f"{total_pages_created} pages created, {total_pages_updated} updated, "
|
|
||||||
f"{total_entities_added} entities added"
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def _find_unprocessed_searches(
|
|
||||||
self,
|
|
||||||
lookback_days: int,
|
|
||||||
limit: int
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Find unprocessed SearchQuery nodes from Neo4j.
|
|
||||||
"""
|
|
||||||
lookback_date = datetime.now() - timedelta(days=lookback_days)
|
|
||||||
|
|
||||||
query = """
|
|
||||||
MATCH (sq:SearchQuery {processed: false})
|
|
||||||
WHERE sq.timestamp > datetime($lookback_date)
|
|
||||||
RETURN sq.id as id,
|
|
||||||
sq.query as query,
|
|
||||||
sq.user as user,
|
|
||||||
sq.timestamp as timestamp,
|
|
||||||
sq.total_results as total_results,
|
|
||||||
sq.web_count as web_count,
|
|
||||||
sq.keywords as keywords
|
|
||||||
ORDER BY sq.timestamp DESC
|
|
||||||
LIMIT $limit
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = await self.neo4j.execute_query(
|
|
||||||
query,
|
|
||||||
{
|
|
||||||
"lookback_date": lookback_date.isoformat(),
|
|
||||||
"limit": limit
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
searches = []
|
|
||||||
for record in results:
|
|
||||||
searches.append({
|
|
||||||
'id': record['id'],
|
|
||||||
'query': record['query'],
|
|
||||||
'user': record['user'],
|
|
||||||
'timestamp': record['timestamp'],
|
|
||||||
'total_results': record.get('total_results', 0),
|
|
||||||
'web_count': record.get('web_count', 0),
|
|
||||||
'keywords': record.get('keywords', [])
|
|
||||||
})
|
|
||||||
|
|
||||||
return searches
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to find unprocessed searches: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def _process_search(
|
|
||||||
self,
|
|
||||||
search: Dict[str, Any],
|
|
||||||
min_web_results: int,
|
|
||||||
dry_run: bool
|
|
||||||
) -> Optional[ConsolidationResult]:
|
|
||||||
"""
|
|
||||||
Process a single search query for knowledge consolidation.
|
|
||||||
"""
|
|
||||||
search_id = search['id']
|
|
||||||
query = search['query']
|
|
||||||
user = search['user']
|
|
||||||
web_count = search.get('web_count', 0)
|
|
||||||
|
|
||||||
logger.info(f"Processing: '{query}' (user: {user}, web: {web_count})")
|
|
||||||
|
|
||||||
# Skip if insufficient web results
|
|
||||||
if web_count < min_web_results:
|
|
||||||
logger.info(f"Skipping - insufficient web results ({web_count} < {min_web_results})")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get web results from SearchQuery
|
|
||||||
web_results = await self._get_web_results(search_id)
|
|
||||||
if not web_results:
|
|
||||||
logger.info("No web results found in database")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(web_results)} web results")
|
|
||||||
|
|
||||||
# Analyze web results with Ollama for novel information
|
|
||||||
analysis = await self._analyze_web_results(
|
|
||||||
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")
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
logger.info("[DRY RUN] Would create/update pages and entities")
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create/update wiki pages
|
|
||||||
pages_created = 0
|
|
||||||
pages_updated = 0
|
|
||||||
entities_added = 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}")
|
|
||||||
|
|
||||||
# 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}")
|
|
||||||
|
|
||||||
# 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}")
|
|
||||||
|
|
||||||
return ConsolidationResult(
|
|
||||||
search_id=search_id,
|
|
||||||
query=query,
|
|
||||||
pages_created=pages_created,
|
|
||||||
pages_updated=pages_updated,
|
|
||||||
entities_added=entities_added
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _get_web_results(self, search_id: str) -> List[Dict[str, Any]]:
|
|
||||||
"""Get web results for a search from Neo4j."""
|
|
||||||
query = """
|
|
||||||
MATCH (sq:SearchQuery {id: $search_id})-[f:FOUND]->(wr:WebResult)
|
|
||||||
RETURN wr.url as url,
|
|
||||||
wr.title as title,
|
|
||||||
wr.content as content,
|
|
||||||
f.rank as rank,
|
|
||||||
f.rrf_score as rrf_score
|
|
||||||
ORDER BY f.rank
|
|
||||||
LIMIT 20
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = await self.neo4j.execute_query(query, {"search_id": search_id})
|
|
||||||
|
|
||||||
web_results = []
|
|
||||||
for record in results:
|
|
||||||
web_results.append({
|
|
||||||
'url': record['url'],
|
|
||||||
'title': record['title'],
|
|
||||||
'content': record['content'],
|
|
||||||
'rank': record['rank'],
|
|
||||||
'rrf_score': record['rrf_score']
|
|
||||||
})
|
|
||||||
|
|
||||||
return web_results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get web results: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def _analyze_web_results(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
web_results: List[Dict[str, Any]],
|
|
||||||
keywords: List[str],
|
|
||||||
user: str = "jpmschweitzer"
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Analyze web results with Ollama for novel information.
|
|
||||||
|
|
||||||
Returns analysis with has_novel_info, new_pages, update_pages, new_entities.
|
|
||||||
"""
|
|
||||||
# Fetch existing taxonomy structure for this user
|
|
||||||
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 analysis prompt
|
|
||||||
web_summary = "\n\n".join([
|
|
||||||
f"[{i+1}] {r['title']}\n{r['url']}\n{r['content'][:300]}..."
|
|
||||||
for i, r in enumerate(web_results[:5])
|
|
||||||
])
|
|
||||||
|
|
||||||
prompt = f"""You are a Librarian helping build a personal knowledge base and extended memory system.
|
|
||||||
|
|
||||||
Analyze these web search results for information worth documenting in our personal wiki.
|
|
||||||
|
|
||||||
Query: "{query}"
|
|
||||||
Keywords: {', '.join(keywords) if keywords else 'none'}
|
|
||||||
|
|
||||||
Web Results:
|
|
||||||
{web_summary}
|
|
||||||
|
|
||||||
This is a PERSONAL knowledge base using Schema.org-aligned taxonomy that captures:
|
|
||||||
- People: Family members, friends, colleagues, public figures (Schema.org: Person)
|
|
||||||
- Companies: Businesses, organizations, institutions (Schema.org: Organization)
|
|
||||||
- Places: Locations, restaurants, travel destinations (Schema.org: Place)
|
|
||||||
- Entertainment: Books, movies, TV, music, games (Schema.org: CreativeWork)
|
|
||||||
- Recipes: Food, cooking techniques, ingredients (Schema.org: CreativeWork/Recipe)
|
|
||||||
- Products: Purchased items, gear, tools, equipment (Schema.org: Product)
|
|
||||||
- Technology: Software, applications, infrastructure (Schema.org: SoftwareApplication)
|
|
||||||
- Health: Medical info, fitness, wellness (Schema.org: MedicalEntity)
|
|
||||||
- Events: Concerts, travel, appointments, important dates (Schema.org: Event)
|
|
||||||
- Hobbies: Personal interests, activities, pastimes (Custom extension)
|
|
||||||
- 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
|
|
||||||
|
|
||||||
Be INCLUSIVE - if someone searched for it, it's likely worth documenting.
|
|
||||||
Personal information is just as valuable as technical information.
|
|
||||||
|
|
||||||
**CRITICAL: Use ONLY these Schema.org-aligned path prefixes (case-sensitive):**
|
|
||||||
|
|
||||||
- People: `people/<name>` (Schema.org: Person)
|
|
||||||
- Companies: `companies/<company-name>` (Schema.org: Organization)
|
|
||||||
- Places: `places/<location>` (Schema.org: Place)
|
|
||||||
- Entertainment (Schema.org: CreativeWork):
|
|
||||||
- Books: `entertainment/books/<title>`
|
|
||||||
- Movies: `entertainment/movies/<title>`
|
|
||||||
- TV: `entertainment/tv/<title>`
|
|
||||||
- Music: `entertainment/music/<artist-or-album>`
|
|
||||||
- Games: `entertainment/games/<title>`
|
|
||||||
- Recipes: `recipes/<cuisine-or-category>/<dish>` (Schema.org: Recipe)
|
|
||||||
- Products: `products/<category>/<product-name>` (Schema.org: Product)
|
|
||||||
- Technology: `technology/<category>/<topic>` (Schema.org: SoftwareApplication)
|
|
||||||
- Health: `health/<category>/<topic>` (Schema.org: MedicalEntity)
|
|
||||||
- Events: `events/<event-type>/<event-name>` (Schema.org: Event)
|
|
||||||
- Hobbies: `hobbies/<hobby-name>` (Custom extension)
|
|
||||||
- Projects: `projects/<project-name>` (Schema.org: Project)
|
|
||||||
- Reference: `reference/<category>/<topic>` (Custom extension)
|
|
||||||
|
|
||||||
**Path Rules:**
|
|
||||||
- Use lowercase with hyphens (kebab-case): "machine-learning" not "Machine_Learning"
|
|
||||||
- Keep paths 2-3 levels deep maximum
|
|
||||||
- Be consistent with existing paths when possible
|
|
||||||
|
|
||||||
{existing_paths_info}
|
|
||||||
|
|
||||||
Return ONLY valid JSON:
|
|
||||||
{{
|
|
||||||
"has_novel_info": true,
|
|
||||||
"new_pages": [
|
|
||||||
{{"title": "Page Title", "path": "companies/example-company", "summary": "What information to include"}}
|
|
||||||
],
|
|
||||||
"update_pages": [
|
|
||||||
{{"title": "Existing Page", "new_facts": ["fact 1"], "source_url": "url"}}
|
|
||||||
],
|
|
||||||
"new_entities": [
|
|
||||||
{{"name": "Entity Name", "type": "person/place/thing/concept/recipe/media", "description": "Brief description"}}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
|
|
||||||
JSON:"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Call Ollama for analysis
|
|
||||||
response = await self.ollama.generate_text(
|
|
||||||
prompt=prompt,
|
|
||||||
model=self.settings.reranker_model, # Use mistral-nemo
|
|
||||||
stream=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
logger.warning("Empty response from Ollama")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Extract JSON 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]
|
|
||||||
|
|
||||||
analysis = json.loads(response_clean)
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Failed to parse Ollama response as JSON: {e}")
|
|
||||||
logger.debug(f"Response was: {response[:500]}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Analysis failed: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _format_taxonomy_for_prompt(self, taxonomy: Dict[str, List[str]]) -> str:
|
|
||||||
"""
|
|
||||||
Format taxonomy structure for inclusion in LLM prompt.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
taxonomy: Dict mapping categories to subcategories
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted string showing existing paths
|
|
||||||
"""
|
|
||||||
if not taxonomy:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
lines = ["**Existing paths in your wiki (PREFER these over creating new ones):**"]
|
|
||||||
for category, subcategories in taxonomy.items():
|
|
||||||
if subcategories:
|
|
||||||
lines.append(f"- {category}/")
|
|
||||||
for sub in subcategories:
|
|
||||||
lines.append(f" - {category}/{sub}/")
|
|
||||||
else:
|
|
||||||
lines.append(f"- {category}/")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("**IMPORTANT:** If a suitable existing path exists, use it instead of creating a new category.")
|
|
||||||
lines.append("Example: NATO should go in `reference/political-entities/` not a new `reference/military-alliances/`")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
async def _mark_search_processed(self, search_id: str):
|
|
||||||
"""Mark SearchQuery node as processed."""
|
|
||||||
query = """
|
|
||||||
MATCH (sq:SearchQuery {id: $search_id})
|
|
||||||
SET sq.processed = true,
|
|
||||||
sq.processed_at = datetime()
|
|
||||||
RETURN sq.id
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.neo4j.execute_query(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}")
|
|
||||||
|
|
||||||
async def _apply_bidirectional_entity_linking(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
page_title: str,
|
|
||||||
user: str
|
|
||||||
) -> Dict[str, int]:
|
|
||||||
"""
|
|
||||||
Apply bidirectional entity linking after page creation/update.
|
|
||||||
|
|
||||||
This runs AFTER ingestion so entities are extracted and in the graph.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
1. Link entities in the new page (forward links to existing entities)
|
|
||||||
2. Find pages that mention the new entity (reverse references)
|
|
||||||
3. Link entities in those pages (backward links to the new entity)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page ID
|
|
||||||
page_title: Page title (used to find reverse references)
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with link counts: {
|
|
||||||
"forward_links": int, # Links added to the new page
|
|
||||||
"backward_links": int, # Links added to other pages pointing to new page
|
|
||||||
"pages_updated": int # Number of other pages updated
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
forward_links = 0
|
|
||||||
backward_links = 0
|
|
||||||
pages_updated = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from src.routers.entity_linking import link_entities_in_page, EntityLinkingRequest
|
|
||||||
from src.core.dependencies import get_wiki_service, get_graph_service
|
|
||||||
|
|
||||||
wiki_service = get_wiki_service()
|
|
||||||
graph_service = get_graph_service()
|
|
||||||
|
|
||||||
# STEP 1: Forward linking - link entities in the new page
|
|
||||||
logger.info(f"Step 1/3: Linking entities in page {page_id} ('{page_title}')")
|
|
||||||
try:
|
|
||||||
forward_result = await link_entities_in_page(
|
|
||||||
request=EntityLinkingRequest(
|
|
||||||
user=user,
|
|
||||||
page_id=page_id,
|
|
||||||
create_relationships=True,
|
|
||||||
re_index_if_changed=False # Already indexed, no need to re-index
|
|
||||||
),
|
|
||||||
wiki_service=wiki_service,
|
|
||||||
graph_service=graph_service,
|
|
||||||
ingestion_service=self.ingestion_service,
|
|
||||||
api_key="" # Internal call, no auth needed
|
|
||||||
)
|
|
||||||
forward_links = forward_result.content_links_added
|
|
||||||
logger.info(f"Added {forward_links} forward links in page {page_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to add forward links: {e}")
|
|
||||||
|
|
||||||
# STEP 2: Find reverse references - which pages mention this new entity?
|
|
||||||
logger.info(f"Step 2/3: Finding pages that mention '{page_title}'")
|
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
|
||||||
|
|
||||||
# Query to find documents that mention entities with this page's title
|
|
||||||
reverse_query = f"""
|
|
||||||
// Find entities with the same name as the page title
|
|
||||||
MATCH (e:{user_base_label})
|
|
||||||
WHERE toLower(e.name) = toLower($title)
|
|
||||||
AND NOT e:Document
|
|
||||||
|
|
||||||
// Find documents that mention those entities
|
|
||||||
MATCH (d:Document)-[r:MENTIONS]->(e)
|
|
||||||
WHERE d.page_id <> $page_id // Exclude the page itself
|
|
||||||
|
|
||||||
RETURN DISTINCT d.page_id as page_id, d.title as title
|
|
||||||
LIMIT 50
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
reverse_refs = await self.neo4j.execute_query(
|
|
||||||
reverse_query,
|
|
||||||
{"title": page_title, "page_id": page_id}
|
|
||||||
)
|
|
||||||
logger.info(f"Found {len(reverse_refs)} pages that mention '{page_title}'")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to find reverse references: {e}")
|
|
||||||
reverse_refs = []
|
|
||||||
|
|
||||||
# STEP 3: Backward linking - add links in those pages to the new entity
|
|
||||||
if reverse_refs:
|
|
||||||
logger.info(f"Step 3/3: Adding backward links in {len(reverse_refs)} pages")
|
|
||||||
for ref in reverse_refs:
|
|
||||||
try:
|
|
||||||
backward_result = await link_entities_in_page(
|
|
||||||
request=EntityLinkingRequest(
|
|
||||||
user=user,
|
|
||||||
page_id=ref['page_id'],
|
|
||||||
create_relationships=False, # Relationships already exist
|
|
||||||
re_index_if_changed=False # Don't re-index for link updates
|
|
||||||
),
|
|
||||||
wiki_service=wiki_service,
|
|
||||||
graph_service=graph_service,
|
|
||||||
ingestion_service=self.ingestion_service,
|
|
||||||
api_key=""
|
|
||||||
)
|
|
||||||
if backward_result.content_links_added > 0:
|
|
||||||
backward_links += backward_result.content_links_added
|
|
||||||
pages_updated += 1
|
|
||||||
logger.info(
|
|
||||||
f"Added {backward_result.content_links_added} links "
|
|
||||||
f"in page {ref['page_id']} ('{ref['title']}')"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to add backward links in page {ref['page_id']}: {e}")
|
|
||||||
else:
|
|
||||||
logger.info("Step 3/3: No reverse references found, skipping backward linking")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"forward_links": forward_links,
|
|
||||||
"backward_links": backward_links,
|
|
||||||
"pages_updated": pages_updated
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Bidirectional entity linking failed: {e}", exc_info=True)
|
|
||||||
return {
|
|
||||||
"forward_links": 0,
|
|
||||||
"backward_links": 0,
|
|
||||||
"pages_updated": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _create_or_consolidate_page(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
title: str,
|
|
||||||
path: str,
|
|
||||||
summary: str,
|
|
||||||
source_query: str,
|
|
||||||
web_results: List[Dict[str, Any]]
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Create wiki page or consolidate with existing synonym page.
|
|
||||||
|
|
||||||
Uses WikiPageWriter for intelligent LLM-based content generation:
|
|
||||||
- For new pages: Holistic structured content creation
|
|
||||||
- For existing pages: Zero-loss reconstruction with conflict detection
|
|
||||||
"""
|
|
||||||
# Normalize path to user namespace
|
|
||||||
if not path.startswith(f"users/{user}"):
|
|
||||||
path = f"users/{user}/{path.lstrip('/')}"
|
|
||||||
|
|
||||||
# Format web results as source information
|
|
||||||
source_information = [
|
|
||||||
{
|
|
||||||
'title': r['title'],
|
|
||||||
'url': r['url'],
|
|
||||||
'content': r['content']
|
|
||||||
}
|
|
||||||
for r in web_results[:5] # Top 5 web results
|
|
||||||
]
|
|
||||||
|
|
||||||
# Search for existing pages with similar titles (synonym consolidation)
|
|
||||||
existing_pages = await self.wiki.search_pages(title, path_prefix=f"users/{user}")
|
|
||||||
|
|
||||||
if existing_pages:
|
|
||||||
# Page exists - reconstruct with new information using LLM
|
|
||||||
logger.info(f"Found existing page for '{title}', will reconstruct with new info")
|
|
||||||
page_id = existing_pages[0]['id']
|
|
||||||
|
|
||||||
# Get current content
|
|
||||||
existing_page = await self.wiki.get_page(page_id)
|
|
||||||
if existing_page:
|
|
||||||
# Build new information text from summary and web results
|
|
||||||
new_information = f"{summary}\n\n"
|
|
||||||
for r in web_results[:3]:
|
|
||||||
new_information += f"- {r['title']}: {r['content'][:200]}...\n"
|
|
||||||
|
|
||||||
# Use WikiPageWriter to reconstruct with LLM
|
|
||||||
reconstructed_content, conflicts = await self.wiki_page_writer.reconstruct_page(
|
|
||||||
title=title,
|
|
||||||
existing_content=existing_page['content'],
|
|
||||||
new_information=new_information,
|
|
||||||
new_sources=source_information,
|
|
||||||
detect_conflicts=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if conflicts:
|
|
||||||
logger.warning(
|
|
||||||
f"Detected {len(conflicts)} conflicts when updating '{title}' - "
|
|
||||||
"LLM chose most authoritative sources"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.wiki.update_page(
|
|
||||||
page_id=page_id,
|
|
||||||
content=reconstructed_content
|
|
||||||
)
|
|
||||||
logger.info(f"Reconstructed existing page: {title}")
|
|
||||||
|
|
||||||
# Trigger ingestion to update vectors and graph
|
|
||||||
if self.ingestion_service:
|
|
||||||
try:
|
|
||||||
await self.ingestion_service.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=True
|
|
||||||
)
|
|
||||||
logger.info(f"Ingested updated page {page_id} into knowledge base")
|
|
||||||
|
|
||||||
# Apply bidirectional entity linking after ingestion
|
|
||||||
link_stats = await self._apply_bidirectional_entity_linking(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=title,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Entity linking complete: {link_stats['forward_links']} forward links, "
|
|
||||||
f"{link_stats['backward_links']} backward links "
|
|
||||||
f"({link_stats['pages_updated']} pages updated)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to ingest updated page {page_id}: {e}")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create new page with LLM-generated structured content
|
|
||||||
logger.info(f"Creating new page: {title}")
|
|
||||||
|
|
||||||
# Use WikiPageWriter to create structured content
|
|
||||||
content = await self.wiki_page_writer.create_page(
|
|
||||||
title=title,
|
|
||||||
topic_summary=summary,
|
|
||||||
source_information=source_information,
|
|
||||||
entities=None, # Could extract from keywords if available
|
|
||||||
related_docs=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract tags from path for dossier organization
|
|
||||||
path_parts = path.split('/')
|
|
||||||
tags = [part for part in path_parts if part and part not in ['users', user]]
|
|
||||||
|
|
||||||
created_page = await self.wiki.create_page(
|
|
||||||
path=path,
|
|
||||||
title=title,
|
|
||||||
content=content,
|
|
||||||
description=f"Consolidated from search: {source_query}",
|
|
||||||
tags=tags[:3], # Limit to 3 tags
|
|
||||||
is_published=True
|
|
||||||
)
|
|
||||||
|
|
||||||
page_id = created_page.get("id") if created_page else None
|
|
||||||
logger.info(f"Created new page: {path} (page_id: {page_id})")
|
|
||||||
|
|
||||||
# Trigger ingestion to update vectors and graph
|
|
||||||
if self.ingestion_service and page_id:
|
|
||||||
try:
|
|
||||||
await self.ingestion_service.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=False # New page, no need to force
|
|
||||||
)
|
|
||||||
logger.info(f"Ingested new page {page_id} into knowledge base")
|
|
||||||
|
|
||||||
# Apply bidirectional entity linking after ingestion
|
|
||||||
link_stats = await self._apply_bidirectional_entity_linking(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=title,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Entity linking complete: {link_stats['forward_links']} forward links, "
|
|
||||||
f"{link_stats['backward_links']} backward links "
|
|
||||||
f"({link_stats['pages_updated']} pages updated)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to ingest new page {page_id}: {e}")
|
|
||||||
|
|
||||||
async def _update_page_with_facts(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
new_facts: List[str],
|
|
||||||
source_url: str,
|
|
||||||
user: str
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update existing page with new facts using LLM reconstruction.
|
|
||||||
|
|
||||||
Uses WikiPageWriter to intelligently merge facts with zero loss.
|
|
||||||
"""
|
|
||||||
# Search for page
|
|
||||||
pages = await self.wiki.search_pages(title, path_prefix=f"users/{user}")
|
|
||||||
|
|
||||||
if not pages:
|
|
||||||
logger.warning(f"Page '{title}' not found for update")
|
|
||||||
return
|
|
||||||
|
|
||||||
page_id = pages[0]['id']
|
|
||||||
existing_page = await self.wiki.get_page(page_id)
|
|
||||||
|
|
||||||
if not existing_page:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Build new information from facts
|
|
||||||
new_information = "\n".join([f"- {fact}" for fact in new_facts])
|
|
||||||
|
|
||||||
# Format source
|
|
||||||
source_information = [{
|
|
||||||
'title': source_url,
|
|
||||||
'url': source_url,
|
|
||||||
'content': new_information
|
|
||||||
}]
|
|
||||||
|
|
||||||
# Use WikiPageWriter to reconstruct with LLM
|
|
||||||
reconstructed_content, conflicts = await self.wiki_page_writer.reconstruct_page(
|
|
||||||
title=title,
|
|
||||||
existing_content=existing_page['content'],
|
|
||||||
new_information=new_information,
|
|
||||||
new_sources=source_information,
|
|
||||||
detect_conflicts=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if conflicts:
|
|
||||||
logger.warning(
|
|
||||||
f"Detected {len(conflicts)} conflicts when updating '{title}' with new facts"
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.wiki.update_page(
|
|
||||||
page_id=page_id,
|
|
||||||
content=reconstructed_content
|
|
||||||
)
|
|
||||||
|
|
||||||
# Trigger ingestion to update vectors and graph
|
|
||||||
logger.debug(f"ingestion_service available: {self.ingestion_service is not None}")
|
|
||||||
if self.ingestion_service:
|
|
||||||
try:
|
|
||||||
logger.info(f"Starting ingestion for updated page {page_id}")
|
|
||||||
await self.ingestion_service.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=True
|
|
||||||
)
|
|
||||||
logger.info(f"Ingested updated page {page_id} into knowledge base")
|
|
||||||
|
|
||||||
# Apply bidirectional entity linking after ingestion
|
|
||||||
link_stats = await self._apply_bidirectional_entity_linking(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=title,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Entity linking complete: {link_stats['forward_links']} forward links, "
|
|
||||||
f"{link_stats['backward_links']} backward links "
|
|
||||||
f"({link_stats['pages_updated']} pages updated)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to ingest updated page {page_id}: {e}")
|
|
||||||
|
|
||||||
async def _add_entity_to_graph(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
entity_name: str,
|
|
||||||
entity_type: str,
|
|
||||||
description: str,
|
|
||||||
source_search_id: str
|
|
||||||
):
|
|
||||||
"""Add new entity to knowledge graph."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
|
||||||
|
|
||||||
# Create entity node with appropriate type label
|
|
||||||
type_label = entity_type.capitalize() if entity_type else "Entity"
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
MERGE (e:{user_base_label}:{type_label} {{name: $name}})
|
|
||||||
ON CREATE SET
|
|
||||||
e.description = $description,
|
|
||||||
e.created_at = datetime(),
|
|
||||||
e.source = 'librarian_consolidation',
|
|
||||||
e.source_search_id = $search_id
|
|
||||||
ON MATCH SET
|
|
||||||
e.updated_at = datetime()
|
|
||||||
RETURN e
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.neo4j.execute_query(query, {
|
|
||||||
"name": entity_name,
|
|
||||||
"description": description,
|
|
||||||
"search_id": source_search_id
|
|
||||||
})
|
|
||||||
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}")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,739 +0,0 @@
|
|||||||
"""
|
|
||||||
HybridRAG service combining vector, graph, and web search.
|
|
||||||
|
|
||||||
6-Phase Pipeline:
|
|
||||||
0. Query Enhancement - Extract keywords/synonyms with LLM
|
|
||||||
1. Parallel Retrieval - Vector + Graph + Web search
|
|
||||||
2. RRF Fusion - Merge results with Reciprocal Rank Fusion
|
|
||||||
3. Enrichment - Add related dossiers via graph
|
|
||||||
4. LLM Re-ranking - Re-rank with mistral-nemo
|
|
||||||
5. Context Formatting - Format for LLM consumption
|
|
||||||
6. Persistence - Store for Librarian processing
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
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.config import Settings
|
|
||||||
from src.models.hybrid_rag import (
|
|
||||||
HybridRAGConfig, HybridRAGRequest, HybridRAGResponse,
|
|
||||||
HybridRAGResult, TimingBreakdown, KeywordExtraction,
|
|
||||||
RelatedDossier
|
|
||||||
)
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label, get_neo4j_user_label
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class HybridRAGService:
|
|
||||||
"""
|
|
||||||
Service for HybridRAG multi-source search with fusion and re-ranking.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
vector_service: VectorService,
|
|
||||||
graph_service: GraphService,
|
|
||||||
searxng_client: SearXNGClient,
|
|
||||||
ollama_client: OllamaClient,
|
|
||||||
settings: Settings
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize HybridRAG service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vector_service: Service for Qdrant vector search
|
|
||||||
graph_service: Service for Neo4j graph search
|
|
||||||
searxng_client: Client for web search
|
|
||||||
ollama_client: Client for LLM (keyword extraction, re-ranking)
|
|
||||||
settings: Application settings
|
|
||||||
"""
|
|
||||||
self.vector = vector_service
|
|
||||||
self.graph = graph_service
|
|
||||||
self.searxng = searxng_client
|
|
||||||
self.ollama = ollama_client
|
|
||||||
self.settings = settings
|
|
||||||
self.reranker_model = settings.reranker_model
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
user: str,
|
|
||||||
config: Optional[HybridRAGConfig] = None
|
|
||||||
) -> HybridRAGResponse:
|
|
||||||
"""
|
|
||||||
Execute HybridRAG search across all sources.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
user: User identifier
|
|
||||||
config: Optional configuration override
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Complete search response with ranked results and timing
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
timing = {}
|
|
||||||
|
|
||||||
# Use default config if not provided
|
|
||||||
if not config:
|
|
||||||
config = HybridRAGConfig()
|
|
||||||
|
|
||||||
logger.info(f"HybridRAG search: '{query}' for user '{user}'")
|
|
||||||
|
|
||||||
# Phase 0: Query Enhancement
|
|
||||||
phase0_start = time.time()
|
|
||||||
keywords_data = await self._extract_keywords_and_synonyms(query)
|
|
||||||
timing["query_enhancement_ms"] = (time.time() - phase0_start) * 1000
|
|
||||||
|
|
||||||
# Phase 1: Parallel Retrieval
|
|
||||||
phase1_start = time.time()
|
|
||||||
raw_results = await self._retrieve_parallel(query, user, config, keywords_data)
|
|
||||||
timing["vector_ms"] = raw_results.get("timing", {}).get("vector_ms", 0)
|
|
||||||
timing["graph_ms"] = raw_results.get("timing", {}).get("graph_ms", 0)
|
|
||||||
timing["web_ms"] = raw_results.get("timing", {}).get("web_ms", 0)
|
|
||||||
|
|
||||||
# Phase 2: RRF Fusion
|
|
||||||
phase2_start = time.time()
|
|
||||||
fused_results = self._reciprocal_rank_fusion(
|
|
||||||
results_by_source={
|
|
||||||
"vector": raw_results.get("vector", []),
|
|
||||||
"graph": raw_results.get("graph", []),
|
|
||||||
"web": raw_results.get("web", [])
|
|
||||||
},
|
|
||||||
k=config.rrf_k
|
|
||||||
)
|
|
||||||
timing["fusion_ms"] = (time.time() - phase2_start) * 1000
|
|
||||||
|
|
||||||
# Phase 3: Enrichment
|
|
||||||
phase3_start = time.time()
|
|
||||||
if config.enable_enrichment:
|
|
||||||
enriched_results = await self._enrich_with_related_dossiers(fused_results, user)
|
|
||||||
else:
|
|
||||||
enriched_results = fused_results
|
|
||||||
timing["enrichment_ms"] = (time.time() - phase3_start) * 1000
|
|
||||||
|
|
||||||
# Phase 4: LLM Re-ranking
|
|
||||||
phase4_start = time.time()
|
|
||||||
if config.enable_reranking and len(enriched_results) > 1:
|
|
||||||
reranked_results = await self._rerank_with_llm(enriched_results[:20], query)
|
|
||||||
else:
|
|
||||||
reranked_results = enriched_results
|
|
||||||
timing["reranking_ms"] = (time.time() - phase4_start) * 1000
|
|
||||||
|
|
||||||
# Limit to final result count
|
|
||||||
final_results = reranked_results[:config.final_result_count]
|
|
||||||
|
|
||||||
# Update final ranks
|
|
||||||
for i, result in enumerate(final_results, start=1):
|
|
||||||
result["final_rank"] = i
|
|
||||||
|
|
||||||
# Convert to HybridRAGResult models
|
|
||||||
result_models = self._convert_to_result_models(final_results)
|
|
||||||
|
|
||||||
# Phase 5: Context Formatting
|
|
||||||
context = self._format_context_for_llm(result_models)
|
|
||||||
|
|
||||||
# Calculate source counts
|
|
||||||
source_counts = {}
|
|
||||||
for result in result_models:
|
|
||||||
for source in result.sources:
|
|
||||||
source_counts[source] = source_counts.get(source, 0) + 1
|
|
||||||
|
|
||||||
timing["total_ms"] = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
# Phase 6: Persistence (async, non-blocking)
|
|
||||||
phase6_start = time.time()
|
|
||||||
search_id = await self._persist_search_for_librarian(
|
|
||||||
query=query,
|
|
||||||
user=user,
|
|
||||||
keywords_data=keywords_data,
|
|
||||||
raw_results=raw_results,
|
|
||||||
final_results=final_results,
|
|
||||||
timing=timing
|
|
||||||
)
|
|
||||||
timing["persistence_ms"] = (time.time() - phase6_start) * 1000
|
|
||||||
|
|
||||||
# Build response
|
|
||||||
return HybridRAGResponse(
|
|
||||||
query=query,
|
|
||||||
keywords=KeywordExtraction(**keywords_data),
|
|
||||||
results=result_models,
|
|
||||||
context=context,
|
|
||||||
source_counts=source_counts,
|
|
||||||
total_results=len(result_models),
|
|
||||||
timing=TimingBreakdown(**timing),
|
|
||||||
config_used=config,
|
|
||||||
search_id=search_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _extract_keywords_and_synonyms(self, query: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Phase 0: Extract keywords, entities, and synonyms using LLM.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with keywords, entities, synonyms, expansions
|
|
||||||
"""
|
|
||||||
prompt = f"""Extract search terms from this query. For each important word, provide synonyms and expansions.
|
|
||||||
|
|
||||||
Query: "{query}"
|
|
||||||
|
|
||||||
Return ONLY valid JSON:
|
|
||||||
{{
|
|
||||||
"core_keywords": ["key", "words", "from", "query"],
|
|
||||||
"synonyms": {{
|
|
||||||
"word": ["alternative", "terms"]
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
Example for "Docker container hosting":
|
|
||||||
{{
|
|
||||||
"core_keywords": ["docker", "container", "hosting"],
|
|
||||||
"synonyms": {{
|
|
||||||
"docker": ["containerization", "container runtime"],
|
|
||||||
"hosting": ["server", "infrastructure"]
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
JSON:"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await self.ollama.generate_text(
|
|
||||||
prompt=prompt,
|
|
||||||
model=self.reranker_model
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse JSON response (handle potential extra text)
|
|
||||||
response_clean = response.strip()
|
|
||||||
# Try to extract JSON if wrapped in text
|
|
||||||
if '{' in response_clean:
|
|
||||||
json_start = response_clean.find('{')
|
|
||||||
json_end = response_clean.rfind('}') + 1
|
|
||||||
response_clean = response_clean[json_start:json_end]
|
|
||||||
|
|
||||||
keywords_data = json.loads(response_clean)
|
|
||||||
|
|
||||||
# Ensure all required fields exist
|
|
||||||
result = {
|
|
||||||
"core_keywords": keywords_data.get("core_keywords", []),
|
|
||||||
"entities": keywords_data.get("entities", []),
|
|
||||||
"synonyms": keywords_data.get("synonyms", {}),
|
|
||||||
"expansions": keywords_data.get("expansions", {})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"Extracted keywords: {result['core_keywords'][:5]}, synonyms: {len(result['synonyms'])} terms")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.warning(f"Failed to parse LLM keyword extraction: {e}, using fallback")
|
|
||||||
# Fallback to simple extraction
|
|
||||||
words = query.split()
|
|
||||||
return {
|
|
||||||
"core_keywords": words,
|
|
||||||
"entities": [],
|
|
||||||
"synonyms": {},
|
|
||||||
"expansions": {}
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Keyword extraction failed: {e}", exc_info=True)
|
|
||||||
return {
|
|
||||||
"core_keywords": query.split(),
|
|
||||||
"entities": [],
|
|
||||||
"synonyms": {},
|
|
||||||
"expansions": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _retrieve_parallel(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
user: str,
|
|
||||||
config: HybridRAGConfig,
|
|
||||||
keywords_data: Dict[str, Any]
|
|
||||||
) -> Dict[str, List]:
|
|
||||||
"""
|
|
||||||
Phase 1: Retrieve results from all sources in parallel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
user: User identifier
|
|
||||||
config: Search configuration
|
|
||||||
keywords_data: Extracted keywords/synonyms
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with results from each source and timing
|
|
||||||
"""
|
|
||||||
tasks = {}
|
|
||||||
timing = {}
|
|
||||||
|
|
||||||
# Vector search
|
|
||||||
if config.enable_vector:
|
|
||||||
async def vector_search():
|
|
||||||
start = time.time()
|
|
||||||
try:
|
|
||||||
response = await self.vector.search(
|
|
||||||
query=query,
|
|
||||||
user=user,
|
|
||||||
limit=config.vector_limit
|
|
||||||
)
|
|
||||||
results = [
|
|
||||||
{
|
|
||||||
"page_id": r.page_id,
|
|
||||||
"title": r.page_title,
|
|
||||||
"content": r.content,
|
|
||||||
"path": r.page_path,
|
|
||||||
"score": r.score,
|
|
||||||
"source": "vector"
|
|
||||||
}
|
|
||||||
for r in response.results
|
|
||||||
]
|
|
||||||
return results, (time.time() - start) * 1000
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Vector search failed: {e}", exc_info=True)
|
|
||||||
return [], (time.time() - start) * 1000
|
|
||||||
|
|
||||||
tasks["vector"] = vector_search()
|
|
||||||
|
|
||||||
# Graph search
|
|
||||||
if config.enable_graph:
|
|
||||||
async def graph_search():
|
|
||||||
start = time.time()
|
|
||||||
try:
|
|
||||||
results = await self.graph.search_documents(
|
|
||||||
query=query,
|
|
||||||
user=user,
|
|
||||||
limit=config.graph_limit,
|
|
||||||
keywords_data=keywords_data
|
|
||||||
)
|
|
||||||
formatted = [
|
|
||||||
{
|
|
||||||
"page_id": r["page_id"],
|
|
||||||
"title": r["title"],
|
|
||||||
"content": "", # Graph doesn't return content
|
|
||||||
"path": r["path"],
|
|
||||||
"entity_matches": r.get("entity_matches", 0),
|
|
||||||
"matched_entities": r.get("matched_entities", []),
|
|
||||||
"source": "graph"
|
|
||||||
}
|
|
||||||
for r in results
|
|
||||||
]
|
|
||||||
return formatted, (time.time() - start) * 1000
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Graph search failed: {e}", exc_info=True)
|
|
||||||
return [], (time.time() - start) * 1000
|
|
||||||
|
|
||||||
tasks["graph"] = graph_search()
|
|
||||||
|
|
||||||
# Web search
|
|
||||||
if config.enable_web:
|
|
||||||
async def web_search():
|
|
||||||
start = time.time()
|
|
||||||
try:
|
|
||||||
results = await self.searxng.search_general(
|
|
||||||
query=query,
|
|
||||||
limit=config.web_limit
|
|
||||||
)
|
|
||||||
formatted = [
|
|
||||||
{
|
|
||||||
"url": r.get("url"),
|
|
||||||
"title": r.get("title", ""),
|
|
||||||
"content": r.get("content", ""),
|
|
||||||
"engine": r.get("engine", ""),
|
|
||||||
"source": "web"
|
|
||||||
}
|
|
||||||
for r in results
|
|
||||||
]
|
|
||||||
return formatted, (time.time() - start) * 1000
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Web search failed: {e}", exc_info=True)
|
|
||||||
return [], (time.time() - start) * 1000
|
|
||||||
|
|
||||||
tasks["web"] = web_search()
|
|
||||||
|
|
||||||
# Execute all searches in parallel
|
|
||||||
results_dict = await asyncio.gather(*tasks.values())
|
|
||||||
|
|
||||||
# Combine results with timing
|
|
||||||
output = {"timing": {}}
|
|
||||||
for i, source in enumerate(tasks.keys()):
|
|
||||||
results, source_timing = results_dict[i]
|
|
||||||
output[source] = results
|
|
||||||
output["timing"][f"{source}_ms"] = source_timing
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Parallel retrieval: vector={len(output.get('vector', []))}, "
|
|
||||||
f"graph={len(output.get('graph', []))}, web={len(output.get('web', []))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
def _reciprocal_rank_fusion(
|
|
||||||
self,
|
|
||||||
results_by_source: Dict[str, List],
|
|
||||||
k: int = 60
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Phase 2: Merge results using Reciprocal Rank Fusion.
|
|
||||||
|
|
||||||
RRF formula: score = sum(1 / (k + rank)) for each source
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results_by_source: Results from each source
|
|
||||||
k: RRF constant (default 60)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Merged and sorted results
|
|
||||||
"""
|
|
||||||
rrf_scores = {}
|
|
||||||
|
|
||||||
for source, results in results_by_source.items():
|
|
||||||
for rank, result in enumerate(results, start=1):
|
|
||||||
# Use page_id for wiki results, url hash for web results
|
|
||||||
if result.get("page_id"):
|
|
||||||
result_id = f"page_{result['page_id']}"
|
|
||||||
elif result.get("url"):
|
|
||||||
result_id = f"url_{hash(result['url'])}"
|
|
||||||
else:
|
|
||||||
continue # Skip results without ID
|
|
||||||
|
|
||||||
if result_id not in rrf_scores:
|
|
||||||
rrf_scores[result_id] = {
|
|
||||||
"result": result,
|
|
||||||
"rrf_score": 0.0,
|
|
||||||
"sources": [],
|
|
||||||
"source_type": source
|
|
||||||
}
|
|
||||||
|
|
||||||
# RRF formula: sum of 1/(k + rank) across sources
|
|
||||||
rrf_scores[result_id]["rrf_score"] += 1 / (k + rank)
|
|
||||||
rrf_scores[result_id]["sources"].append(source)
|
|
||||||
|
|
||||||
# If result appears in multiple sources, update source_type
|
|
||||||
if len(rrf_scores[result_id]["sources"]) > 1:
|
|
||||||
rrf_scores[result_id]["source_type"] = "+".join(
|
|
||||||
sorted(set(rrf_scores[result_id]["sources"]))
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by RRF score descending
|
|
||||||
sorted_results = sorted(
|
|
||||||
rrf_scores.values(),
|
|
||||||
key=lambda x: x["rrf_score"],
|
|
||||||
reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"RRF fusion: {len(sorted_results)} unique results from {len(results_by_source)} sources")
|
|
||||||
|
|
||||||
return sorted_results
|
|
||||||
|
|
||||||
async def _enrich_with_related_dossiers(
|
|
||||||
self,
|
|
||||||
results: List[Dict[str, Any]],
|
|
||||||
user: str
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Phase 3: Enrich results with related documents via shared entities.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: Fused results
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Results with related_dossiers added
|
|
||||||
"""
|
|
||||||
for result in results:
|
|
||||||
result_data = result.get("result", {})
|
|
||||||
page_id = result_data.get("page_id")
|
|
||||||
|
|
||||||
if page_id:
|
|
||||||
try:
|
|
||||||
related_docs = await self.graph.get_related_documents(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
limit=5
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to RelatedDossier format
|
|
||||||
related_dossiers = []
|
|
||||||
for doc in related_docs:
|
|
||||||
for tag in doc.get("tags", [])[:3]: # Max 3 tags per doc
|
|
||||||
related_dossiers.append({
|
|
||||||
"page_id": doc["page_id"],
|
|
||||||
"title": doc["title"],
|
|
||||||
"path": doc["path"],
|
|
||||||
"tag": tag,
|
|
||||||
"shared_entities": doc["shared_entities"]
|
|
||||||
})
|
|
||||||
|
|
||||||
result["related_dossiers"] = related_dossiers[:5] # Limit to 5 total
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to get related docs for page {page_id}: {e}")
|
|
||||||
result["related_dossiers"] = []
|
|
||||||
else:
|
|
||||||
result["related_dossiers"] = []
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def _rerank_with_llm(
|
|
||||||
self,
|
|
||||||
results: List[Dict[str, Any]],
|
|
||||||
query: str
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Phase 4: Re-rank results using LLM for better relevance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: Results to re-rank (top 20)
|
|
||||||
query: Original search query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Re-ranked results
|
|
||||||
"""
|
|
||||||
if len(results) <= 1:
|
|
||||||
return results
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build prompt with numbered results
|
|
||||||
docs_text = "\n".join([
|
|
||||||
f"{i+1}. {r['result'].get('title', 'Untitled')} - {r['result'].get('content', '')[:200]}..."
|
|
||||||
for i, r in enumerate(results)
|
|
||||||
])
|
|
||||||
|
|
||||||
prompt = f"""Given this search query and documents, rank them by relevance.
|
|
||||||
|
|
||||||
Query: {query}
|
|
||||||
|
|
||||||
Documents:
|
|
||||||
{docs_text}
|
|
||||||
|
|
||||||
Return only the numbers in order of relevance (most relevant first).
|
|
||||||
Example: 3,1,5,2,4
|
|
||||||
|
|
||||||
Ranking:"""
|
|
||||||
|
|
||||||
response = await self.ollama.generate_text(
|
|
||||||
prompt=prompt,
|
|
||||||
model=self.reranker_model
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse response: "3,1,5,2,4" → [2, 0, 4, 1, 3] (0-indexed)
|
|
||||||
indices_str = response.strip().split('\n')[0] # Take first line
|
|
||||||
indices = [int(x.strip()) - 1 for x in indices_str.split(",") if x.strip().isdigit()]
|
|
||||||
|
|
||||||
# Reorder results according to LLM ranking
|
|
||||||
reranked = []
|
|
||||||
for idx in indices:
|
|
||||||
if 0 <= idx < len(results):
|
|
||||||
reranked.append(results[idx])
|
|
||||||
|
|
||||||
# Add any results that weren't in the LLM response
|
|
||||||
for i, result in enumerate(results):
|
|
||||||
if i not in indices and result not in reranked:
|
|
||||||
reranked.append(result)
|
|
||||||
|
|
||||||
logger.info(f"LLM re-ranking: reordered {len(reranked)} results")
|
|
||||||
return reranked
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"LLM re-ranking failed: {e}, using RRF order")
|
|
||||||
return results # Fallback to RRF order
|
|
||||||
|
|
||||||
def _format_context_for_llm(self, results: List[HybridRAGResult]) -> str:
|
|
||||||
"""
|
|
||||||
Phase 5: Format results into context for LLM consumption.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: Ranked results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted context string
|
|
||||||
"""
|
|
||||||
context_parts = []
|
|
||||||
|
|
||||||
for i, result in enumerate(results[:10], start=1):
|
|
||||||
# Source indicator
|
|
||||||
source_tag = f"[{result.source_type.upper()}]"
|
|
||||||
|
|
||||||
# Related dossiers if available
|
|
||||||
related = ""
|
|
||||||
if result.related_dossiers:
|
|
||||||
tags = ", ".join([d.tag for d in result.related_dossiers[:3]])
|
|
||||||
related = f"\n Related research: {tags}"
|
|
||||||
|
|
||||||
# Build context entry
|
|
||||||
content_preview = result.content[:300] if result.content else "(no content)"
|
|
||||||
context_parts.append(
|
|
||||||
f"{i}. {source_tag} {result.title}\n"
|
|
||||||
f" {content_preview}...{related}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n\n".join(context_parts)
|
|
||||||
|
|
||||||
async def _persist_search_for_librarian(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
user: str,
|
|
||||||
keywords_data: Dict[str, Any],
|
|
||||||
raw_results: Dict[str, List],
|
|
||||||
final_results: List[Dict[str, Any]],
|
|
||||||
timing: Dict[str, float]
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Phase 6: Store search query and results for Librarian processing.
|
|
||||||
|
|
||||||
Creates SearchQuery node in Neo4j with relationships to found documents
|
|
||||||
and web results for offline knowledge consolidation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
user: User identifier
|
|
||||||
keywords_data: Extracted keywords/synonyms
|
|
||||||
raw_results: Results from each source
|
|
||||||
final_results: Final ranked results
|
|
||||||
timing: Performance timing
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Search ID for tracking
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
user_base_label = get_neo4j_user_base_label(user)
|
|
||||||
search_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
# Create SearchQuery node
|
|
||||||
create_query = f"""
|
|
||||||
CREATE (sq:{user_base_label}_SearchQuery:SearchQuery {{
|
|
||||||
id: $search_id,
|
|
||||||
query: $query,
|
|
||||||
user: $user,
|
|
||||||
timestamp: datetime(),
|
|
||||||
processed: false,
|
|
||||||
total_results: $total_results,
|
|
||||||
vector_count: $vector_count,
|
|
||||||
graph_count: $graph_count,
|
|
||||||
web_count: $web_count,
|
|
||||||
keywords: $keywords,
|
|
||||||
synonyms: $synonyms,
|
|
||||||
timing_ms: $timing_ms
|
|
||||||
}})
|
|
||||||
RETURN sq.id as id
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = await self.graph.neo4j.execute_query(create_query, {
|
|
||||||
"search_id": search_id,
|
|
||||||
"query": query,
|
|
||||||
"user": user,
|
|
||||||
"total_results": len(final_results),
|
|
||||||
"vector_count": len(raw_results.get("vector", [])),
|
|
||||||
"graph_count": len(raw_results.get("graph", [])),
|
|
||||||
"web_count": len(raw_results.get("web", [])),
|
|
||||||
"keywords": keywords_data.get("core_keywords", []),
|
|
||||||
"synonyms": json.dumps(keywords_data.get("synonyms", {})),
|
|
||||||
"timing_ms": timing.get("total_ms", 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Link to found wiki documents (top 20)
|
|
||||||
for rank, result_data in enumerate(final_results[:20], start=1):
|
|
||||||
result = result_data.get("result", {})
|
|
||||||
page_id = result.get("page_id")
|
|
||||||
|
|
||||||
if page_id:
|
|
||||||
link_doc_query = f"""
|
|
||||||
MATCH (sq:{user_base_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
|
||||||
MATCH (d:Document {{page_id: $page_id}})
|
|
||||||
MERGE (sq)-[f:FOUND]->(d)
|
|
||||||
SET f.source = $source,
|
|
||||||
f.rank = $rank,
|
|
||||||
f.rrf_score = $rrf_score,
|
|
||||||
f.final_rank = $final_rank
|
|
||||||
"""
|
|
||||||
|
|
||||||
await self.graph.neo4j.execute_query(link_doc_query, {
|
|
||||||
"search_id": search_id,
|
|
||||||
"page_id": page_id,
|
|
||||||
"source": result_data.get("source_type", "unknown"),
|
|
||||||
"rank": rank,
|
|
||||||
"rrf_score": result_data.get("rrf_score", 0),
|
|
||||||
"final_rank": result_data.get("final_rank", rank)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Store web results as WebResult nodes (top 10)
|
|
||||||
web_results = [r for r in final_results[:10] if r.get("result", {}).get("url")]
|
|
||||||
for rank, result_data in enumerate(web_results, start=1):
|
|
||||||
result = result_data.get("result", {})
|
|
||||||
create_web_query = f"""
|
|
||||||
MATCH (sq:{user_base_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
|
||||||
CREATE (wr:{user_base_label}_WebResult:WebResult {{
|
|
||||||
url: $url,
|
|
||||||
title: $title,
|
|
||||||
content: $content,
|
|
||||||
search_id: $search_id,
|
|
||||||
timestamp: datetime()
|
|
||||||
}})
|
|
||||||
CREATE (sq)-[:FOUND {{
|
|
||||||
source: "web",
|
|
||||||
rank: $rank,
|
|
||||||
rrf_score: $rrf_score
|
|
||||||
}}]->(wr)
|
|
||||||
"""
|
|
||||||
|
|
||||||
await self.graph.neo4j.execute_query(create_web_query, {
|
|
||||||
"search_id": search_id,
|
|
||||||
"url": result.get("url"),
|
|
||||||
"title": result.get("title", ""),
|
|
||||||
"content": result.get("content", "")[:1000], # Truncate
|
|
||||||
"rank": rank,
|
|
||||||
"rrf_score": result_data.get("rrf_score", 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Persisted search {search_id} for Librarian processing")
|
|
||||||
return search_id
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to persist search for Librarian: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _convert_to_result_models(self, results: List[Dict[str, Any]]) -> List[HybridRAGResult]:
|
|
||||||
"""
|
|
||||||
Convert internal result format to HybridRAGResult models.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
results: Internal result dictionaries
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of HybridRAGResult models
|
|
||||||
"""
|
|
||||||
models = []
|
|
||||||
|
|
||||||
for result_data in results:
|
|
||||||
result = result_data.get("result", {})
|
|
||||||
related_dossiers = result_data.get("related_dossiers", [])
|
|
||||||
|
|
||||||
models.append(HybridRAGResult(
|
|
||||||
source_type=result_data.get("source_type", "unknown"),
|
|
||||||
title=result.get("title", "Untitled"),
|
|
||||||
content=result.get("content", ""),
|
|
||||||
url=result.get("url"),
|
|
||||||
page_id=result.get("page_id"),
|
|
||||||
page_path=result.get("path"),
|
|
||||||
rrf_score=result_data.get("rrf_score", 0),
|
|
||||||
final_rank=result_data.get("final_rank", 0),
|
|
||||||
sources=result_data.get("sources", []),
|
|
||||||
related_dossiers=[RelatedDossier(**d) for d in related_dossiers],
|
|
||||||
metadata={
|
|
||||||
"entity_matches": result.get("entity_matches"),
|
|
||||||
"matched_entities": result.get("matched_entities"),
|
|
||||||
"engine": result.get("engine")
|
|
||||||
}
|
|
||||||
))
|
|
||||||
|
|
||||||
return models
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
"""
|
|
||||||
Document Ingestion Service
|
|
||||||
|
|
||||||
Orchestrates the ingestion of wiki pages into the knowledge base:
|
|
||||||
1. Fetches page content from Wiki.js
|
|
||||||
2. Generates vector embeddings (Qdrant)
|
|
||||||
3. Extracts entities and updates knowledge graph (Neo4j)
|
|
||||||
|
|
||||||
This service is called by:
|
|
||||||
- Consolidation service (after creating/updating pages)
|
|
||||||
- Manual ingestion endpoints
|
|
||||||
- Batch ingestion jobs
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
from typing import List, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
import time
|
|
||||||
|
|
||||||
from src.services.vector_service import VectorService
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
from src.clients.wikijs_client import WikiJSClient
|
|
||||||
from src.models.ingestion import (
|
|
||||||
IngestionRequest,
|
|
||||||
IngestionResult,
|
|
||||||
BatchIngestionRequest,
|
|
||||||
BatchIngestionResult
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class IngestionService:
|
|
||||||
"""
|
|
||||||
Service for ingesting wiki pages into the knowledge base.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
vector_service: VectorService,
|
|
||||||
graph_service: GraphService,
|
|
||||||
wiki_client: WikiJSClient
|
|
||||||
):
|
|
||||||
self.vector = vector_service
|
|
||||||
self.graph = graph_service
|
|
||||||
self.wiki = wiki_client
|
|
||||||
|
|
||||||
async def ingest_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str,
|
|
||||||
force_refresh: bool = False,
|
|
||||||
skip_vectors: bool = False,
|
|
||||||
skip_graph: bool = False,
|
|
||||||
skip_entity_linking: bool = False
|
|
||||||
) -> IngestionResult:
|
|
||||||
"""
|
|
||||||
Ingest a single wiki page into the knowledge base.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page ID
|
|
||||||
user: User identifier
|
|
||||||
force_refresh: Force re-ingestion even if unchanged
|
|
||||||
skip_vectors: Skip vector embedding generation
|
|
||||||
skip_graph: Skip graph entity extraction
|
|
||||||
skip_entity_linking: Skip automatic entity linking
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
IngestionResult with operation details
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
logger.info(f"Starting ingestion for page {page_id} (user: {user})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch page to get metadata
|
|
||||||
page = await self.wiki.get_page(page_id)
|
|
||||||
if not page:
|
|
||||||
return IngestionResult(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=f"Page {page_id}",
|
|
||||||
success=False,
|
|
||||||
error="Page not found in Wiki.js",
|
|
||||||
processing_time_ms=(time.time() - start_time) * 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
page_title = page.get("title", f"Page {page_id}")
|
|
||||||
page_path = page.get("path", "")
|
|
||||||
|
|
||||||
# Ingest vectors and graph in parallel
|
|
||||||
tasks = []
|
|
||||||
|
|
||||||
if not skip_vectors:
|
|
||||||
tasks.append(self._ingest_vectors(page_id, user, force_refresh))
|
|
||||||
else:
|
|
||||||
tasks.append(asyncio.create_task(asyncio.sleep(0))) # Dummy task
|
|
||||||
|
|
||||||
if not skip_graph:
|
|
||||||
tasks.append(self._ingest_graph(page_id, user, force_refresh))
|
|
||||||
else:
|
|
||||||
tasks.append(asyncio.create_task(asyncio.sleep(0))) # Dummy task
|
|
||||||
|
|
||||||
# Execute in parallel
|
|
||||||
vector_result, graph_result = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
# Handle errors
|
|
||||||
vector_chunks = 0
|
|
||||||
graph_entities = 0
|
|
||||||
graph_relationships = 0
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
if not skip_vectors:
|
|
||||||
if isinstance(vector_result, Exception):
|
|
||||||
errors.append(f"Vector ingestion failed: {str(vector_result)}")
|
|
||||||
logger.error(f"Vector ingestion failed for page {page_id}: {vector_result}")
|
|
||||||
else:
|
|
||||||
vector_chunks = vector_result.get("chunks_created", 0)
|
|
||||||
|
|
||||||
if not skip_graph:
|
|
||||||
if isinstance(graph_result, Exception):
|
|
||||||
errors.append(f"Graph ingestion failed: {str(graph_result)}")
|
|
||||||
logger.error(f"Graph ingestion failed for page {page_id}: {graph_result}")
|
|
||||||
else:
|
|
||||||
# entities_extracted is a list, get its length
|
|
||||||
entities_list = graph_result.get("entities_extracted", [])
|
|
||||||
graph_entities = len(entities_list) if isinstance(entities_list, list) else 0
|
|
||||||
graph_relationships = graph_result.get("relationships_created", 0)
|
|
||||||
|
|
||||||
# Step 3: Link existing entities in the page content (after graph extraction)
|
|
||||||
entity_links_created = 0
|
|
||||||
if not skip_entity_linking and not skip_graph and not isinstance(graph_result, Exception):
|
|
||||||
try:
|
|
||||||
entity_links_created = await self._link_existing_entities(page_id, user, page)
|
|
||||||
logger.info(f"Created {entity_links_created} entity mention links for page {page_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity linking failed for page {page_id}: {e}")
|
|
||||||
# Don't fail the whole ingestion if entity linking fails
|
|
||||||
|
|
||||||
processing_time_ms = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
result = IngestionResult(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page_title,
|
|
||||||
page_path=page_path,
|
|
||||||
success=len(errors) == 0,
|
|
||||||
error="; ".join(errors) if errors else None,
|
|
||||||
vector_chunks_created=vector_chunks,
|
|
||||||
graph_entities_extracted=graph_entities,
|
|
||||||
graph_relationships_created=graph_relationships,
|
|
||||||
processing_time_ms=processing_time_ms
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.success:
|
|
||||||
logger.info(
|
|
||||||
f"Successfully ingested page {page_id}: "
|
|
||||||
f"{vector_chunks} chunks, {graph_entities} entities, "
|
|
||||||
f"{graph_relationships} relationships, {entity_links_created} entity links "
|
|
||||||
f"in {processing_time_ms:.0f}ms"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Partial ingestion failure for page {page_id}: {result.error}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Ingestion failed for page {page_id}: {e}", exc_info=True)
|
|
||||||
return IngestionResult(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=f"Page {page_id}",
|
|
||||||
success=False,
|
|
||||||
error=str(e),
|
|
||||||
processing_time_ms=(time.time() - start_time) * 1000
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _ingest_vectors(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str,
|
|
||||||
force_refresh: bool
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Ingest page into vector database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with chunks_created count
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await self.vector.update_from_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chunks_created": summary.chunks_created,
|
|
||||||
"chunks_deleted": summary.chunks_deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Vector ingestion failed for page {page_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _ingest_graph(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str,
|
|
||||||
force_refresh: bool
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Ingest page into knowledge graph.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with entities_extracted and relationships_created counts
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await self.graph.update_from_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"entities_extracted": summary.entities_extracted,
|
|
||||||
"relationships_created": summary.relationships_created
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Graph ingestion failed for page {page_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _link_existing_entities(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str,
|
|
||||||
page: dict
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Find and link mentions of existing entities in the page content.
|
|
||||||
|
|
||||||
This runs automatically after graph extraction to create MENTIONS relationships
|
|
||||||
for entities that already exist in the knowledge graph but were mentioned in
|
|
||||||
this page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page ID
|
|
||||||
user: User identifier
|
|
||||||
page: Page dict with content (from WikiJSClient)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of new entity mention links created
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
try:
|
|
||||||
page_content = page.get("content", "")
|
|
||||||
if not page_content or len(page_content) < 10:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Get all existing entities from the knowledge graph
|
|
||||||
entities = await self.graph.get_all_entities(user)
|
|
||||||
if not entities:
|
|
||||||
logger.debug(f"No existing entities found for user {user}, skipping entity linking")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Find entity mentions in page content
|
|
||||||
found_entities = []
|
|
||||||
content_lower = page_content.lower()
|
|
||||||
|
|
||||||
for entity in entities:
|
|
||||||
entity_name = entity.get("name", "")
|
|
||||||
if not entity_name or len(entity_name) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create regex pattern for whole word matching
|
|
||||||
# This avoids matching "John" in "Johnson"
|
|
||||||
pattern = r'\b' + re.escape(entity_name.lower()) + r'\b'
|
|
||||||
|
|
||||||
# Find all matches
|
|
||||||
matches = list(re.finditer(pattern, content_lower))
|
|
||||||
|
|
||||||
if matches:
|
|
||||||
found_entities.append({
|
|
||||||
"name": entity_name,
|
|
||||||
"type": entity.get("type", "unknown"),
|
|
||||||
"mentions": len(matches),
|
|
||||||
"entity_id": entity.get("id")
|
|
||||||
})
|
|
||||||
|
|
||||||
if not found_entities:
|
|
||||||
logger.debug(f"No entity mentions found in page {page_id}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Create MENTIONS relationships
|
|
||||||
new_links_created = await self.graph.create_entity_mentions(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
entity_names=found_entities
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_links_created
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Entity linking failed for page {page_id}: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def ingest_batch(
|
|
||||||
self,
|
|
||||||
page_ids: List[int],
|
|
||||||
user: str,
|
|
||||||
force_refresh: bool = False,
|
|
||||||
skip_vectors: bool = False,
|
|
||||||
skip_graph: bool = False,
|
|
||||||
max_concurrent: int = 3
|
|
||||||
) -> BatchIngestionResult:
|
|
||||||
"""
|
|
||||||
Ingest multiple wiki pages concurrently.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_ids: List of wiki page IDs to ingest
|
|
||||||
user: User identifier
|
|
||||||
force_refresh: Force re-ingestion
|
|
||||||
skip_vectors: Skip vector embedding generation
|
|
||||||
skip_graph: Skip graph entity extraction
|
|
||||||
max_concurrent: Maximum concurrent ingestion tasks
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BatchIngestionResult with per-page results
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
logger.info(f"Starting batch ingestion of {len(page_ids)} pages (user: {user})")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
semaphore = asyncio.Semaphore(max_concurrent)
|
|
||||||
|
|
||||||
async def ingest_with_semaphore(page_id: int):
|
|
||||||
async with semaphore:
|
|
||||||
return await self.ingest_page(
|
|
||||||
page_id=page_id,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh,
|
|
||||||
skip_vectors=skip_vectors,
|
|
||||||
skip_graph=skip_graph
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute all ingestions with concurrency control
|
|
||||||
tasks = [ingest_with_semaphore(page_id) for page_id in page_ids]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
# Calculate summary
|
|
||||||
successful = sum(1 for r in results if r.success)
|
|
||||||
failed = len(results) - successful
|
|
||||||
total_processing_time_ms = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
batch_result = BatchIngestionResult(
|
|
||||||
total_pages=len(page_ids),
|
|
||||||
successful=successful,
|
|
||||||
failed=failed,
|
|
||||||
results=results,
|
|
||||||
total_processing_time_ms=total_processing_time_ms
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Batch ingestion complete: {successful}/{len(page_ids)} successful "
|
|
||||||
f"in {total_processing_time_ms:.0f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
return batch_result
|
|
||||||
|
|
||||||
async def ingest_all_pages(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
path_prefix: Optional[str] = None,
|
|
||||||
force_refresh: bool = False,
|
|
||||||
max_concurrent: int = 3
|
|
||||||
) -> BatchIngestionResult:
|
|
||||||
"""
|
|
||||||
Ingest all wiki pages for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
path_prefix: Optional path prefix filter (e.g., "users/jpmschweitzer")
|
|
||||||
force_refresh: Force re-ingestion
|
|
||||||
max_concurrent: Maximum concurrent ingestion tasks
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BatchIngestionResult
|
|
||||||
"""
|
|
||||||
logger.info(f"Finding all pages for user {user} (prefix: {path_prefix or 'all'})")
|
|
||||||
|
|
||||||
# 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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not pages:
|
|
||||||
logger.warning(f"No pages found for user {user}")
|
|
||||||
return BatchIngestionResult(
|
|
||||||
total_pages=0,
|
|
||||||
successful=0,
|
|
||||||
failed=0,
|
|
||||||
results=[],
|
|
||||||
total_processing_time_ms=0
|
|
||||||
)
|
|
||||||
|
|
||||||
page_ids = [p['id'] for p in pages]
|
|
||||||
logger.info(f"Found {len(page_ids)} pages to ingest")
|
|
||||||
|
|
||||||
return await self.ingest_batch(
|
|
||||||
page_ids=page_ids,
|
|
||||||
user=user,
|
|
||||||
force_refresh=force_refresh,
|
|
||||||
max_concurrent=max_concurrent
|
|
||||||
)
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
"""
|
|
||||||
Vector service for Library Desk Qdrant operations.
|
|
||||||
|
|
||||||
Handles semantic search, document chunking, and embeddings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import hashlib
|
|
||||||
import uuid
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
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.models.vector import (
|
|
||||||
SearchResult, SearchResponse, VectorUpdateSummary,
|
|
||||||
DocumentChunk, CollectionInfo, CollectionListResponse
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class VectorService:
|
|
||||||
"""
|
|
||||||
Service for Qdrant vector operations.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Document chunking
|
|
||||||
- Embedding generation
|
|
||||||
- Semantic search
|
|
||||||
- Vector CRUD operations
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
qdrant_client: QdrantClientWrapper,
|
|
||||||
wikijs_client: WikiJSClient,
|
|
||||||
ollama_client: OllamaClient,
|
|
||||||
chunk_size: int = 500,
|
|
||||||
chunk_overlap: int = 50
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize vector service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
qdrant_client: Qdrant database client
|
|
||||||
wikijs_client: Wiki.js client for fetching pages
|
|
||||||
ollama_client: Ollama client for embeddings
|
|
||||||
chunk_size: Target chunk size in tokens (approximate)
|
|
||||||
chunk_overlap: Overlap between chunks in tokens
|
|
||||||
"""
|
|
||||||
self.qdrant = qdrant_client
|
|
||||||
self.wiki = wikijs_client
|
|
||||||
self.ollama = ollama_client
|
|
||||||
self.chunk_size = chunk_size
|
|
||||||
self.chunk_overlap = chunk_overlap
|
|
||||||
|
|
||||||
def _chunk_text(self, text: str) -> List[str]:
|
|
||||||
"""
|
|
||||||
Chunk text into overlapping segments.
|
|
||||||
|
|
||||||
Simple word-based chunking for now.
|
|
||||||
TODO: Use tiktoken or similar for token-accurate chunking.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Text to chunk
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of text chunks
|
|
||||||
"""
|
|
||||||
# Remove extra whitespace
|
|
||||||
text = re.sub(r'\s+', ' ', text).strip()
|
|
||||||
|
|
||||||
# Split into words (approximates tokens)
|
|
||||||
words = text.split()
|
|
||||||
|
|
||||||
if len(words) <= self.chunk_size:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
start = 0
|
|
||||||
|
|
||||||
while start < len(words):
|
|
||||||
end = start + self.chunk_size
|
|
||||||
chunk_words = words[start:end]
|
|
||||||
chunks.append(' '.join(chunk_words))
|
|
||||||
|
|
||||||
# Move start forward with overlap
|
|
||||||
start = end - self.chunk_overlap
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
async def update_from_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str,
|
|
||||||
force_refresh: bool = False
|
|
||||||
) -> VectorUpdateSummary:
|
|
||||||
"""
|
|
||||||
Update vector embeddings from a wiki page.
|
|
||||||
|
|
||||||
Chunks the page content, generates embeddings, and upserts to Qdrant.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page ID
|
|
||||||
user: User identifier
|
|
||||||
force_refresh: Force re-embedding even if unchanged
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Summary of update operation
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Fetch page from Wiki.js
|
|
||||||
page = await self.wiki.get_page(page_id)
|
|
||||||
if not page:
|
|
||||||
raise ValueError(f"Page {page_id} not found")
|
|
||||||
|
|
||||||
# Get collection name for user
|
|
||||||
collection_name = get_qdrant_collection_name(user)
|
|
||||||
|
|
||||||
# Ensure collection exists
|
|
||||||
await self.qdrant.ensure_collection(collection_name)
|
|
||||||
|
|
||||||
# Extract content
|
|
||||||
content = page.get("content", "")
|
|
||||||
title = page.get("title", "")
|
|
||||||
path = page.get("path", "")
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
logger.warning(f"Page {page_id} has no content, skipping vector update")
|
|
||||||
return VectorUpdateSummary(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=title,
|
|
||||||
processing_time_ms=(time.time() - start_time) * 1000,
|
|
||||||
success=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Chunk the content
|
|
||||||
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 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)
|
|
||||||
if not embedding:
|
|
||||||
logger.error(f"Failed to generate embedding for chunk {chunk_id}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Prepare metadata
|
|
||||||
metadata = {
|
|
||||||
"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(
|
|
||||||
collection_name=collection_name,
|
|
||||||
vector_id=chunk_id,
|
|
||||||
vector=embedding,
|
|
||||||
payload=metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
chunks_created += 1
|
|
||||||
|
|
||||||
processing_time_ms = (time.time() - start_time) * 1000
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Updated vectors for page {page_id}: "
|
|
||||||
f"{chunks_created} chunks created, {deleted_count} old chunks deleted"
|
|
||||||
)
|
|
||||||
|
|
||||||
return VectorUpdateSummary(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=title,
|
|
||||||
chunks_created=chunks_created,
|
|
||||||
chunks_deleted=deleted_count,
|
|
||||||
total_chunks=chunks_created,
|
|
||||||
embedding_dim=len(embedding) if embedding else 768,
|
|
||||||
processing_time_ms=processing_time_ms,
|
|
||||||
success=True
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
processing_time_ms = (time.time() - start_time) * 1000
|
|
||||||
logger.error(f"Failed to update vectors for page {page_id}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
return VectorUpdateSummary(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title="Unknown",
|
|
||||||
processing_time_ms=processing_time_ms,
|
|
||||||
success=False,
|
|
||||||
error_message=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
user: str,
|
|
||||||
limit: int = 10,
|
|
||||||
score_threshold: float = 0.5
|
|
||||||
) -> SearchResponse:
|
|
||||||
"""
|
|
||||||
Perform semantic search across user's documents.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query text
|
|
||||||
user: User identifier
|
|
||||||
limit: Maximum results to return
|
|
||||||
score_threshold: Minimum similarity score (0-1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Search results with similarity scores
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get collection name
|
|
||||||
collection_name = get_qdrant_collection_name(user)
|
|
||||||
|
|
||||||
# Check if collection exists
|
|
||||||
exists = await self.qdrant.collection_exists(collection_name)
|
|
||||||
if not exists:
|
|
||||||
logger.info(f"Collection {collection_name} doesn't exist, returning empty results")
|
|
||||||
return SearchResponse(
|
|
||||||
query=query,
|
|
||||||
results=[],
|
|
||||||
total=0,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate query embedding
|
|
||||||
query_embedding = await self.ollama.embed(query)
|
|
||||||
if not query_embedding:
|
|
||||||
raise ValueError("Failed to generate query embedding")
|
|
||||||
|
|
||||||
# Search in Qdrant
|
|
||||||
search_results = await self.qdrant.search_vectors(
|
|
||||||
collection_name=collection_name,
|
|
||||||
query_vector=query_embedding,
|
|
||||||
limit=limit,
|
|
||||||
score_threshold=score_threshold
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to SearchResult models
|
|
||||||
results = []
|
|
||||||
for result in search_results:
|
|
||||||
payload = result.get("payload", {})
|
|
||||||
results.append(SearchResult(
|
|
||||||
chunk_id=result["id"],
|
|
||||||
page_id=payload.get("page_id", 0),
|
|
||||||
page_title=payload.get("page_title"),
|
|
||||||
page_path=payload.get("page_path"),
|
|
||||||
chunk_index=payload.get("chunk_index", 0),
|
|
||||||
content=payload.get("chunk_text", ""),
|
|
||||||
score=result["score"],
|
|
||||||
metadata=payload
|
|
||||||
))
|
|
||||||
|
|
||||||
query_time_ms = (time.time() - start_time) * 1000
|
|
||||||
logger.info(f"Semantic search completed in {query_time_ms:.2f}ms: {len(results)} results")
|
|
||||||
|
|
||||||
return SearchResponse(
|
|
||||||
query=query,
|
|
||||||
results=results,
|
|
||||||
total=len(results),
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Semantic search failed: {e}", exc_info=True)
|
|
||||||
return SearchResponse(
|
|
||||||
query=query,
|
|
||||||
results=[],
|
|
||||||
total=0,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete_page_chunks(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
user: str
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Delete all chunks for a wiki page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Wiki page 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={"page_id": page_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Deleted {deleted_count} chunks for page {page_id}")
|
|
||||||
return deleted_count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete chunks for page {page_id}: {e}", exc_info=True)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def list_collections(self) -> CollectionListResponse:
|
|
||||||
"""
|
|
||||||
List all Qdrant collections.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of collections with stats
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
collections_data = await self.qdrant.list_collections()
|
|
||||||
|
|
||||||
collections = []
|
|
||||||
for coll in collections_data:
|
|
||||||
collections.append(CollectionInfo(
|
|
||||||
name=coll["name"],
|
|
||||||
vectors_count=coll.get("vectors_count", 0),
|
|
||||||
points_count=coll.get("points_count", 0),
|
|
||||||
segments_count=coll.get("segments_count", 0)
|
|
||||||
))
|
|
||||||
|
|
||||||
return CollectionListResponse(
|
|
||||||
collections=collections,
|
|
||||||
total=len(collections)
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to list collections: {e}", exc_info=True)
|
|
||||||
return CollectionListResponse(
|
|
||||||
collections=[],
|
|
||||||
total=0
|
|
||||||
)
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
"""
|
|
||||||
Wiki.js Database Change Listener
|
|
||||||
|
|
||||||
Listens to PostgreSQL NOTIFY events for page changes in Wiki.js
|
|
||||||
and triggers the same processing as webhooks would.
|
|
||||||
|
|
||||||
This is an alternative to Wiki.js webhooks (which don't exist in open-source version).
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import asyncpg
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from src.config import get_settings
|
|
||||||
from src.core.dependencies import get_ingestion_service
|
|
||||||
from src.services.consolidation_service import ConsolidationService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WikiChangeListener:
|
|
||||||
"""
|
|
||||||
Listens to PostgreSQL NOTIFY events from Wiki.js database.
|
|
||||||
|
|
||||||
This requires setting up triggers in the Wiki.js database to emit
|
|
||||||
NOTIFY events on INSERT/UPDATE/DELETE to the pages table.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Connect to Wiki.js PostgreSQL database
|
|
||||||
self.connection = await asyncpg.connect(
|
|
||||||
host=self.settings.wikijs_db_host,
|
|
||||||
port=self.settings.wikijs_db_port,
|
|
||||||
user=self.settings.wikijs_db_user,
|
|
||||||
password=self.settings.wikijs_db_password,
|
|
||||||
database=self.settings.wikijs_db_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Listen to the wiki_page_changes channel
|
|
||||||
await self.connection.add_listener('wiki_page_changes', self._handle_notification)
|
|
||||||
|
|
||||||
self.running = True
|
|
||||||
logger.info("Listening for Wiki.js page changes via PostgreSQL NOTIFY")
|
|
||||||
|
|
||||||
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
|
|
||||||
logger.info("Stopped Wiki.js change listener")
|
|
||||||
|
|
||||||
async def _handle_notification(self, connection, pid, channel, payload):
|
|
||||||
"""Handle NOTIFY event from database."""
|
|
||||||
try:
|
|
||||||
# Payload format: "operation:page_id:user_email"
|
|
||||||
# e.g., "INSERT:123:user@example.com"
|
|
||||||
parts = payload.split(':')
|
|
||||||
if len(parts) < 3:
|
|
||||||
logger.warning(f"Invalid notification payload: {payload}")
|
|
||||||
return
|
|
||||||
|
|
||||||
operation = parts[0] # INSERT, UPDATE, DELETE
|
|
||||||
page_id = int(parts[1])
|
|
||||||
user_email = parts[2]
|
|
||||||
|
|
||||||
logger.info(f"Received {operation} notification for page {page_id} by {user_email}")
|
|
||||||
|
|
||||||
# LOOP PREVENTION: Debouncing - ignore rapid duplicate notifications
|
|
||||||
# Note: We rely solely on debouncing for loop prevention because:
|
|
||||||
# - The user_email in notifications is the page creator, not the editor
|
|
||||||
# - Creator != namespace owner (e.g., 'librarian' creates page in 'users/jpmschweitzer/')
|
|
||||||
# - Filtering by creator breaks legitimate page ingestion
|
|
||||||
if self._is_recently_processed(page_id):
|
|
||||||
logger.debug(
|
|
||||||
f"Skipping notification for page {page_id} - "
|
|
||||||
f"processed within last {self._debounce_seconds}s (debouncing)"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Mark as recently processed
|
|
||||||
self._mark_as_processed(page_id)
|
|
||||||
|
|
||||||
# Map operation to webhook-style event
|
|
||||||
event_map = {
|
|
||||||
'INSERT': 'page.create',
|
|
||||||
'UPDATE': 'page.update',
|
|
||||||
'DELETE': 'page.delete'
|
|
||||||
}
|
|
||||||
event = event_map.get(operation, 'page.update')
|
|
||||||
|
|
||||||
# Extract user from email
|
|
||||||
user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer'
|
|
||||||
|
|
||||||
# Process the change
|
|
||||||
await self._process_page_change(
|
|
||||||
page_id=page_id,
|
|
||||||
event=event,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
return False
|
|
||||||
|
|
||||||
last_processed = self._recent_notifications[page_id]
|
|
||||||
elapsed = (datetime.now() - last_processed).total_seconds()
|
|
||||||
|
|
||||||
return elapsed < self._debounce_seconds
|
|
||||||
|
|
||||||
def _mark_as_processed(self, page_id: int):
|
|
||||||
"""Mark page as recently processed."""
|
|
||||||
self._recent_notifications[page_id] = datetime.now()
|
|
||||||
|
|
||||||
# Clean up old entries (keep last 100 pages)
|
|
||||||
if len(self._recent_notifications) > 100:
|
|
||||||
# Remove oldest entries
|
|
||||||
sorted_items = sorted(
|
|
||||||
self._recent_notifications.items(),
|
|
||||||
key=lambda x: x[1]
|
|
||||||
)
|
|
||||||
self._recent_notifications = dict(sorted_items[-100:])
|
|
||||||
|
|
||||||
async def _process_page_change(self, page_id: int, event: str, user: str):
|
|
||||||
"""Process page change identically to webhook handler."""
|
|
||||||
from src.routers.webhooks import process_wiki_page_change, cleanup_deleted_page
|
|
||||||
|
|
||||||
ingestion_service = get_ingestion_service()
|
|
||||||
|
|
||||||
if event == 'page.delete':
|
|
||||||
# For deletions, need to handle cleanup
|
|
||||||
# Note: We don't have page_title at this point, use page_id
|
|
||||||
await cleanup_deleted_page(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=f"Page {page_id}",
|
|
||||||
user=user,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# For create/update, get page details and process
|
|
||||||
from src.core.dependencies import get_wiki_service
|
|
||||||
wiki_service = get_wiki_service()
|
|
||||||
|
|
||||||
try:
|
|
||||||
page = await wiki_service.get_page(page_id, user)
|
|
||||||
|
|
||||||
# If page access failed (wrong user), try to extract correct user from page path
|
|
||||||
if not page:
|
|
||||||
# Try to get page metadata without user validation to find correct namespace
|
|
||||||
try:
|
|
||||||
# Query Wiki.js directly for page path
|
|
||||||
page_info = await wiki_service.wiki_client.get_page(page_id)
|
|
||||||
if page_info and page_info.get('path'):
|
|
||||||
# Extract user from path: users/{user}/...
|
|
||||||
path_parts = page_info['path'].split('/')
|
|
||||||
if len(path_parts) >= 2 and path_parts[0] == 'users':
|
|
||||||
correct_user = path_parts[1]
|
|
||||||
logger.debug(f"Retrying page {page_id} with correct user: {correct_user}")
|
|
||||||
page = await wiki_service.get_page(page_id, correct_user)
|
|
||||||
user = correct_user
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Could not extract user from page {page_id} path: {e}")
|
|
||||||
|
|
||||||
if page:
|
|
||||||
await process_wiki_page_change(
|
|
||||||
page_id=page_id,
|
|
||||||
page_title=page.title,
|
|
||||||
user=user,
|
|
||||||
event=event,
|
|
||||||
ingestion_service=ingestion_service
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Could not retrieve page {page_id} for processing")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to process page {page_id}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# SQL to set up triggers in Wiki.js database
|
|
||||||
SETUP_TRIGGERS_SQL = """
|
|
||||||
-- Create function to notify on page changes
|
|
||||||
-- Note: Wiki.js pages table has authorId (FK to users.id), not authorEmail
|
|
||||||
-- We look up the email from the users table
|
|
||||||
CREATE OR REPLACE FUNCTION notify_page_change()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
author_email TEXT;
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
-- Look up email from users table using OLD.authorId
|
|
||||||
SELECT email INTO author_email FROM users WHERE id = OLD."authorId";
|
|
||||||
IF author_email IS NULL THEN
|
|
||||||
author_email := 'unknown@system';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
PERFORM pg_notify(
|
|
||||||
'wiki_page_changes',
|
|
||||||
TG_OP || ':' || OLD.id || ':' || author_email
|
|
||||||
);
|
|
||||||
RETURN OLD;
|
|
||||||
ELSE
|
|
||||||
-- Look up email from users table using NEW.authorId
|
|
||||||
SELECT email INTO author_email FROM users WHERE id = NEW."authorId";
|
|
||||||
IF author_email IS NULL THEN
|
|
||||||
author_email := 'unknown@system';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
PERFORM pg_notify(
|
|
||||||
'wiki_page_changes',
|
|
||||||
TG_OP || ':' || NEW.id || ':' || author_email
|
|
||||||
);
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Create triggers on pages table
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_insert_trigger ON pages;
|
|
||||||
CREATE TRIGGER wiki_page_insert_trigger
|
|
||||||
AFTER INSERT ON pages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION notify_page_change();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_update_trigger ON pages;
|
|
||||||
CREATE TRIGGER wiki_page_update_trigger
|
|
||||||
AFTER UPDATE ON pages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION notify_page_change();
|
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS wiki_page_delete_trigger ON pages;
|
|
||||||
CREATE TRIGGER wiki_page_delete_trigger
|
|
||||||
AFTER DELETE ON pages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION notify_page_change();
|
|
||||||
|
|
||||||
-- Verify triggers are created
|
|
||||||
SELECT
|
|
||||||
trigger_name,
|
|
||||||
event_manipulation,
|
|
||||||
event_object_table
|
|
||||||
FROM information_schema.triggers
|
|
||||||
WHERE event_object_table = 'pages'
|
|
||||||
ORDER BY trigger_name;
|
|
||||||
"""
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
"""
|
|
||||||
Intelligent Wiki Page Writer Service
|
|
||||||
|
|
||||||
Uses LLM (mistral-nemo) to create and reconstruct wiki pages with:
|
|
||||||
- Holistic content restructuring
|
|
||||||
- Zero fact loss (unless superseded)
|
|
||||||
- Conflict detection and flagging
|
|
||||||
- Standard formatting with template adherence
|
|
||||||
- Professional organization (summary, tables, chapters)
|
|
||||||
|
|
||||||
This service is used by:
|
|
||||||
- Consolidation service (Librarian knowledge consolidation)
|
|
||||||
- Any other service that needs to create/update wiki pages
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WikiPageWriter:
|
|
||||||
"""
|
|
||||||
Intelligent wiki page writer using LLM for content generation and restructuring.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, ollama_client):
|
|
||||||
"""
|
|
||||||
Initialize wiki page writer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ollama_client: OllamaClient for LLM operations
|
|
||||||
"""
|
|
||||||
self.ollama = ollama_client
|
|
||||||
self.model = "mistral-nemo" # Default model for writing
|
|
||||||
|
|
||||||
async def create_page(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
topic_summary: str,
|
|
||||||
source_information: List[Dict[str, str]],
|
|
||||||
entities: Optional[List[str]] = None,
|
|
||||||
related_docs: Optional[List[str]] = None
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Create new wiki page with structured content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Page title
|
|
||||||
topic_summary: Brief summary of the topic
|
|
||||||
source_information: List of {title, url, content} dicts
|
|
||||||
entities: Related entities from knowledge graph
|
|
||||||
related_docs: Related documents/pages
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted markdown content
|
|
||||||
"""
|
|
||||||
logger.info(f"Creating wiki page: {title}")
|
|
||||||
|
|
||||||
# Build source context
|
|
||||||
sources_text = self._format_sources_for_llm(source_information)
|
|
||||||
|
|
||||||
# Create page using LLM
|
|
||||||
prompt = self._build_create_prompt(
|
|
||||||
title=title,
|
|
||||||
summary=topic_summary,
|
|
||||||
sources=sources_text,
|
|
||||||
entities=entities or [],
|
|
||||||
related_docs=related_docs or []
|
|
||||||
)
|
|
||||||
|
|
||||||
content = await self._call_llm(prompt)
|
|
||||||
|
|
||||||
# Post-process to ensure template compliance
|
|
||||||
content = self._ensure_standard_sections(
|
|
||||||
content=content,
|
|
||||||
title=title,
|
|
||||||
sources=source_information,
|
|
||||||
entities=entities or [],
|
|
||||||
related_docs=related_docs or []
|
|
||||||
)
|
|
||||||
|
|
||||||
return content
|
|
||||||
|
|
||||||
async def reconstruct_page(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
existing_content: str,
|
|
||||||
new_information: str,
|
|
||||||
new_sources: List[Dict[str, str]],
|
|
||||||
detect_conflicts: bool = True
|
|
||||||
) -> Tuple[str, Optional[List[Dict[str, Any]]]]:
|
|
||||||
"""
|
|
||||||
Reconstruct existing page with new information.
|
|
||||||
|
|
||||||
Intelligently merges new content with existing, restructures for clarity,
|
|
||||||
and detects factual conflicts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Page title
|
|
||||||
existing_content: Current page content
|
|
||||||
new_information: New information to integrate
|
|
||||||
new_sources: Sources for new information
|
|
||||||
detect_conflicts: Whether to detect and flag conflicts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (reconstructed_content, conflicts)
|
|
||||||
conflicts: List of detected conflicts or None
|
|
||||||
"""
|
|
||||||
logger.info(f"Reconstructing wiki page: {title}")
|
|
||||||
|
|
||||||
# Detect conflicts first
|
|
||||||
conflicts = None
|
|
||||||
if detect_conflicts:
|
|
||||||
conflicts = await self._detect_conflicts(
|
|
||||||
existing_content=existing_content,
|
|
||||||
new_information=new_information
|
|
||||||
)
|
|
||||||
|
|
||||||
if conflicts:
|
|
||||||
logger.warning(f"Detected {len(conflicts)} potential conflicts in {title}")
|
|
||||||
|
|
||||||
# Build reconstruction prompt
|
|
||||||
prompt = self._build_reconstruct_prompt(
|
|
||||||
title=title,
|
|
||||||
existing_content=existing_content,
|
|
||||||
new_information=new_information,
|
|
||||||
new_sources=self._format_sources_for_llm(new_sources),
|
|
||||||
conflicts=conflicts
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reconstruct with LLM
|
|
||||||
reconstructed = await self._call_llm(prompt)
|
|
||||||
|
|
||||||
# Ensure standard sections are present
|
|
||||||
reconstructed = self._ensure_standard_sections(
|
|
||||||
content=reconstructed,
|
|
||||||
title=title,
|
|
||||||
sources=new_sources,
|
|
||||||
is_update=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return reconstructed, conflicts
|
|
||||||
|
|
||||||
async def _detect_conflicts(
|
|
||||||
self,
|
|
||||||
existing_content: str,
|
|
||||||
new_information: str
|
|
||||||
) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
Detect factual conflicts between existing and new content.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of conflicts with: {fact_a, fact_b, confidence, context}
|
|
||||||
"""
|
|
||||||
prompt = f"""Analyze these two pieces of content for factual conflicts.
|
|
||||||
|
|
||||||
EXISTING CONTENT:
|
|
||||||
{existing_content[:2000]}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Return ONLY valid JSON:
|
|
||||||
{{
|
|
||||||
"conflicts": [
|
|
||||||
{{
|
|
||||||
"existing_fact": "fact from old content",
|
|
||||||
"new_fact": "contradicting fact",
|
|
||||||
"confidence": "medium",
|
|
||||||
"context": "explanation of why these conflict"
|
|
||||||
}}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
|
|
||||||
If no conflicts, return: {{"conflicts": []}}
|
|
||||||
|
|
||||||
JSON:"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await self.ollama.generate_text(
|
|
||||||
prompt=prompt,
|
|
||||||
model=self.model,
|
|
||||||
stream=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract JSON
|
|
||||||
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]
|
|
||||||
|
|
||||||
result = json.loads(response_clean)
|
|
||||||
conflicts = result.get('conflicts', [])
|
|
||||||
|
|
||||||
return conflicts if conflicts else None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Conflict detection failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _build_create_prompt(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
summary: str,
|
|
||||||
sources: str,
|
|
||||||
entities: List[str],
|
|
||||||
related_docs: List[str]
|
|
||||||
) -> str:
|
|
||||||
"""Build LLM prompt for creating new page."""
|
|
||||||
return f"""You are a Librarian creating a dossier for a personal knowledge base and extended memory system.
|
|
||||||
|
|
||||||
Create a comprehensive, well-structured wiki page with appropriate sections for the content type.
|
|
||||||
|
|
||||||
TOPIC: {title}
|
|
||||||
|
|
||||||
SUMMARY: {summary}
|
|
||||||
|
|
||||||
SOURCE INFORMATION:
|
|
||||||
{sources}
|
|
||||||
|
|
||||||
RELATED ENTITIES: {', '.join(entities) if entities else 'None'}
|
|
||||||
|
|
||||||
RELATED DOCUMENTS: {', '.join(related_docs) if related_docs else 'None'}
|
|
||||||
|
|
||||||
CONTENT TYPE GUIDELINES (Schema.org-aligned):
|
|
||||||
|
|
||||||
For PEOPLE (family, friends, colleagues, public figures) (Schema.org: Person):
|
|
||||||
- Executive Summary (who they are, key facts)
|
|
||||||
- Background & Biography
|
|
||||||
- Relationships & Connections
|
|
||||||
- Professional Info / Career
|
|
||||||
- Interests & Preferences
|
|
||||||
- Important Dates & Events
|
|
||||||
- Notes & Observations
|
|
||||||
|
|
||||||
For COMPANIES (businesses, organizations, startups) (Schema.org: Organization):
|
|
||||||
- Executive Summary (what they do, industry, key facts)
|
|
||||||
- Overview & Mission
|
|
||||||
- Products & Services
|
|
||||||
- History & Milestones
|
|
||||||
- Leadership & Team
|
|
||||||
- Personal Connection / Experience
|
|
||||||
- Notable Projects or Achievements
|
|
||||||
|
|
||||||
For PLACES (locations, restaurants, destinations) (Schema.org: Place):
|
|
||||||
- Executive Summary (what/where, key details)
|
|
||||||
- Location & How to Get There
|
|
||||||
- Description & Atmosphere
|
|
||||||
- Features & Amenities
|
|
||||||
- Personal Experiences / Visits
|
|
||||||
- Recommendations & Tips
|
|
||||||
|
|
||||||
For ENTERTAINMENT (books, movies, TV, music, games) (Schema.org: CreativeWork):
|
|
||||||
- Executive Summary (title, creator, key facts)
|
|
||||||
- Synopsis / Overview
|
|
||||||
- Key Characters / Themes
|
|
||||||
- Personal Thoughts & Ratings
|
|
||||||
- Memorable Moments / Quotes
|
|
||||||
- Related Works
|
|
||||||
|
|
||||||
For RECIPES & FOOD (Schema.org: Recipe):
|
|
||||||
- Executive Summary (dish name, cuisine type)
|
|
||||||
- Ingredients (formatted as table or list)
|
|
||||||
- Instructions (step-by-step)
|
|
||||||
- Cooking Tips & Variations
|
|
||||||
- Personal Notes & Modifications
|
|
||||||
- Source / Origin
|
|
||||||
|
|
||||||
For PRODUCTS (gear, tools, purchases) (Schema.org: Product):
|
|
||||||
- Executive Summary (what it is, brand/model, key specs)
|
|
||||||
- Overview & Purpose
|
|
||||||
- Specifications (formatted as table)
|
|
||||||
- Purchase Information (where, when, price)
|
|
||||||
- Personal Experience / Review
|
|
||||||
- Maintenance & Care
|
|
||||||
- Related Products / Alternatives
|
|
||||||
|
|
||||||
For TECHNOLOGY (software, applications, infrastructure) (Schema.org: SoftwareApplication):
|
|
||||||
- Executive Summary (what it is, key facts)
|
|
||||||
- Overview & Purpose
|
|
||||||
- Technical Details (tables for specs)
|
|
||||||
- Setup & Configuration
|
|
||||||
- Use Cases & Applications
|
|
||||||
- Best Practices
|
|
||||||
- Common Issues & Solutions
|
|
||||||
|
|
||||||
For EVENTS (concerts, travel, appointments) (Schema.org: Event):
|
|
||||||
- Executive Summary (what, when, where)
|
|
||||||
- Event Details (date, time, location, venue)
|
|
||||||
- Participants / Attendees
|
|
||||||
- Planning & Preparation
|
|
||||||
- Experience / Highlights
|
|
||||||
- Photos / Media
|
|
||||||
- Notes & Reflections
|
|
||||||
|
|
||||||
For HEALTH (medical, fitness, wellness) (Schema.org: MedicalEntity):
|
|
||||||
- Executive Summary (condition/topic, key facts)
|
|
||||||
- Overview & Background
|
|
||||||
- Symptoms / Signs / Characteristics
|
|
||||||
- Treatments / Approaches / Recommendations
|
|
||||||
- Personal Experience / Progress
|
|
||||||
- Resources & References
|
|
||||||
- Important Dates (appointments, changes)
|
|
||||||
|
|
||||||
For HOBBIES (activities, interests, pastimes) (Custom extension):
|
|
||||||
- Executive Summary (what it is, why interesting)
|
|
||||||
- Getting Started / Basics
|
|
||||||
- Equipment & Materials
|
|
||||||
- Techniques & Skills
|
|
||||||
- Personal Progress / Achievements
|
|
||||||
- Resources & Communities
|
|
||||||
- Goals & Future Plans
|
|
||||||
|
|
||||||
For PROJECTS (work projects, personal projects) (Schema.org: Project):
|
|
||||||
- Executive Summary (what, why, status)
|
|
||||||
- Goals & Objectives
|
|
||||||
- Timeline & Milestones
|
|
||||||
- Team / Collaborators
|
|
||||||
- Technical Details / Architecture
|
|
||||||
- Current Status & Next Steps
|
|
||||||
- Lessons Learned / Reflections
|
|
||||||
|
|
||||||
For REFERENCE (general knowledge, how-tos) (Custom extension):
|
|
||||||
- Executive Summary
|
|
||||||
- Overview & Context
|
|
||||||
- Key Concepts & Definitions
|
|
||||||
- Step-by-Step Guide (if applicable)
|
|
||||||
- Examples & Use Cases
|
|
||||||
- Tips & Best Practices
|
|
||||||
- Related Topics & Further Reading
|
|
||||||
|
|
||||||
FORMATTING RULES:
|
|
||||||
- Use markdown headers (##, ###)
|
|
||||||
- Create tables for structured data (ingredients, specs, comparisons)
|
|
||||||
- Use bullet points for lists
|
|
||||||
- Include code blocks with ``` where applicable
|
|
||||||
- Bold important terms
|
|
||||||
- Keep sections focused and scannable
|
|
||||||
- Adapt structure to content - not all sections apply to all topics
|
|
||||||
|
|
||||||
Generate ONLY the markdown content (do not include Sources, Knowledge Graph, or Mind Map sections - those are added automatically).
|
|
||||||
|
|
||||||
MARKDOWN:"""
|
|
||||||
|
|
||||||
def _build_reconstruct_prompt(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
existing_content: str,
|
|
||||||
new_information: str,
|
|
||||||
new_sources: str,
|
|
||||||
conflicts: Optional[List[Dict[str, Any]]]
|
|
||||||
) -> str:
|
|
||||||
"""Build LLM prompt for reconstructing page."""
|
|
||||||
conflicts_note = ""
|
|
||||||
if conflicts:
|
|
||||||
conflicts_note = "\n\nDETECTED CONFLICTS:\n"
|
|
||||||
for i, c in enumerate(conflicts, 1):
|
|
||||||
conflicts_note += f"{i}. Existing: '{c['existing_fact']}'\n"
|
|
||||||
conflicts_note += f" New: '{c['new_fact']}'\n"
|
|
||||||
conflicts_note += f" Confidence: {c['confidence']}\n"
|
|
||||||
conflicts_note += f" Note: {c['context']}\n\n"
|
|
||||||
conflicts_note += "IMPORTANT: For conflicts, prefer the most recent/authoritative source. Add a note in 'Changes & Updates' section when facts are superseded.\n"
|
|
||||||
|
|
||||||
return f"""Reconstruct this wiki page by intelligently merging new information with existing content.
|
|
||||||
|
|
||||||
TITLE: {title}
|
|
||||||
|
|
||||||
EXISTING CONTENT:
|
|
||||||
{existing_content}
|
|
||||||
|
|
||||||
NEW INFORMATION TO INTEGRATE:
|
|
||||||
{new_information}
|
|
||||||
|
|
||||||
NEW SOURCES:
|
|
||||||
{new_sources}
|
|
||||||
{conflicts_note}
|
|
||||||
|
|
||||||
RECONSTRUCTION REQUIREMENTS:
|
|
||||||
1. **Zero Fact Loss**: Preserve ALL facts from existing content unless superseded
|
|
||||||
2. **Holistic Restructuring**: Reorganize for better flow and clarity
|
|
||||||
3. **Conflict Resolution**: When facts conflict, choose most authoritative/recent
|
|
||||||
4. **Professional Structure**:
|
|
||||||
- Update Executive Summary with key facts
|
|
||||||
- Organize into clear chapters
|
|
||||||
- Use tables for specifications/comparisons
|
|
||||||
- Maintain consistent formatting
|
|
||||||
5. **Update Tracking**: Add entry to "Changes & Updates" section with today's date
|
|
||||||
|
|
||||||
FORMATTING RULES:
|
|
||||||
- Maintain markdown structure
|
|
||||||
- Use tables for data (| col1 | col2 |)
|
|
||||||
- Keep existing good structure, improve where needed
|
|
||||||
- Bold important terms
|
|
||||||
- Add subsections (###) where it improves clarity
|
|
||||||
|
|
||||||
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
|
|
||||||
- If facts were superseded, note it clearly
|
|
||||||
|
|
||||||
RECONSTRUCTED MARKDOWN:"""
|
|
||||||
|
|
||||||
async def _call_llm(self, prompt: str) -> str:
|
|
||||||
"""Call LLM with prompt and return response."""
|
|
||||||
try:
|
|
||||||
response = await self.ollama.generate_text(
|
|
||||||
prompt=prompt,
|
|
||||||
model=self.model,
|
|
||||||
stream=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
raise Exception("Empty response from LLM")
|
|
||||||
|
|
||||||
return response.strip()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"LLM call failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _format_sources_for_llm(self, sources: List[Dict[str, str]]) -> str:
|
|
||||||
"""Format source information for LLM prompt."""
|
|
||||||
formatted = []
|
|
||||||
for i, source in enumerate(sources, 1):
|
|
||||||
formatted.append(f"[{i}] {source.get('title', 'Untitled')}")
|
|
||||||
formatted.append(f" URL: {source.get('url', 'N/A')}")
|
|
||||||
content = source.get('content', '')[:500] # Limit content length
|
|
||||||
formatted.append(f" Content: {content}...\n")
|
|
||||||
|
|
||||||
return "\n".join(formatted)
|
|
||||||
|
|
||||||
def _ensure_standard_sections(
|
|
||||||
self,
|
|
||||||
content: str,
|
|
||||||
title: str,
|
|
||||||
sources: List[Dict[str, str]],
|
|
||||||
entities: Optional[List[str]] = None,
|
|
||||||
related_docs: Optional[List[str]] = None,
|
|
||||||
is_update: bool = False
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Ensure page has standard footer sections (Sources, Knowledge Graph, Mind Map).
|
|
||||||
|
|
||||||
These sections are standardized and appended automatically.
|
|
||||||
"""
|
|
||||||
# Remove any existing standard sections
|
|
||||||
for section in ["## Sources", "## Knowledge Graph", "## Mind Map"]:
|
|
||||||
if section in content:
|
|
||||||
content = content.split(section)[0]
|
|
||||||
|
|
||||||
# Add horizontal rule before footer
|
|
||||||
content = content.rstrip() + "\n\n---\n\n"
|
|
||||||
|
|
||||||
# Add Sources section
|
|
||||||
content += "## Sources\n\n"
|
|
||||||
if sources:
|
|
||||||
for i, source in enumerate(sources, 1):
|
|
||||||
content += f"{i}. [{source.get('title', 'Source')}]({source.get('url', '#')})\n"
|
|
||||||
else:
|
|
||||||
content += "*No sources listed*\n"
|
|
||||||
|
|
||||||
# Add Knowledge Graph section
|
|
||||||
content += "\n## Knowledge Graph\n\n"
|
|
||||||
if entities:
|
|
||||||
content += "**Related Entities:**\n"
|
|
||||||
for entity in entities[:10]: # Limit to 10
|
|
||||||
content += f"- {entity}\n"
|
|
||||||
else:
|
|
||||||
content += "*No entities linked yet*\n"
|
|
||||||
|
|
||||||
content += "\n**View in Neo4j:** [Explore Graph](/graph)\n"
|
|
||||||
|
|
||||||
# Add Mind Map section
|
|
||||||
content += "\n## Mind Map\n\n"
|
|
||||||
content += f"**Interactive Mind Map:** [View Topic Map](/mindmap?topic={title.replace(' ', '+')})\n"
|
|
||||||
|
|
||||||
# Add footer metadata
|
|
||||||
content += "\n---\n\n"
|
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
||||||
action = "Updated" if is_update else "Created"
|
|
||||||
content += f"*{action}: {timestamp} | Generated by: Librarian Agent* \n"
|
|
||||||
content += "*Template: Library Desk Wiki Standard v1.0*\n"
|
|
||||||
|
|
||||||
return content
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
"""
|
|
||||||
Wiki service layer for Library Desk.
|
|
||||||
|
|
||||||
Handles business logic for wiki operations with:
|
|
||||||
- Multi-tenant path scoping
|
|
||||||
- Dossier management (tag-based)
|
|
||||||
- Page CRUD operations
|
|
||||||
- Search functionality
|
|
||||||
"""
|
|
||||||
|
|
||||||
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.models.wiki import (
|
|
||||||
WikiPage, WikiPageSummary, WikiPageList,
|
|
||||||
WikiPageCreate, WikiPageUpdate,
|
|
||||||
DossierInfo, DossierList
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class WikiService:
|
|
||||||
"""
|
|
||||||
Service layer for wiki operations.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
- Enforce multi-tenant path scoping
|
|
||||||
- Convert between client and API models
|
|
||||||
- Handle dossier (tag) operations
|
|
||||||
- Provide business logic layer
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, wiki_client: WikiJSClient):
|
|
||||||
"""
|
|
||||||
Initialize wiki service.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wiki_client: Initialized Wiki.js client
|
|
||||||
"""
|
|
||||||
self.wiki_client = wiki_client
|
|
||||||
|
|
||||||
def _get_user_namespace(self, user: str) -> str:
|
|
||||||
"""
|
|
||||||
Get user's wiki namespace with validation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Wiki.js namespace path
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If user ID is invalid
|
|
||||||
"""
|
|
||||||
if not validate_user_id(user):
|
|
||||||
raise ValueError(f"Invalid user ID: {user}")
|
|
||||||
return get_wikijs_namespace(user)
|
|
||||||
|
|
||||||
def _ensure_user_path(self, path: str, user: str) -> str:
|
|
||||||
"""
|
|
||||||
Ensure path is within user's namespace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path: Requested page path
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Full path within user namespace
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> self._ensure_user_path("/projects/foo", "jpmschweitzer")
|
|
||||||
'/users/jpmschweitzer/projects/foo'
|
|
||||||
"""
|
|
||||||
namespace = self._get_user_namespace(user)
|
|
||||||
|
|
||||||
# If path already starts with namespace, return as-is
|
|
||||||
if path.startswith(namespace):
|
|
||||||
return path
|
|
||||||
|
|
||||||
# Remove leading slash from path if present
|
|
||||||
path = path.lstrip("/")
|
|
||||||
|
|
||||||
# Combine namespace and path
|
|
||||||
return f"{namespace}/{path}"
|
|
||||||
|
|
||||||
async def list_pages(
|
|
||||||
self,
|
|
||||||
user: str,
|
|
||||||
tag: Optional[str] = None,
|
|
||||||
limit: int = 50
|
|
||||||
) -> WikiPageList:
|
|
||||||
"""
|
|
||||||
List pages for a user, optionally filtered by tag.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
tag: Optional tag filter (dossier)
|
|
||||||
limit: Maximum pages to return
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WikiPageList with pages and metadata
|
|
||||||
"""
|
|
||||||
namespace = self._get_user_namespace(user)
|
|
||||||
|
|
||||||
# Get pages with filtering
|
|
||||||
pages = await self.wiki_client.list_pages(
|
|
||||||
path_prefix=namespace,
|
|
||||||
tags=[tag] if tag else None,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to summary format
|
|
||||||
summaries = [
|
|
||||||
WikiPageSummary(
|
|
||||||
id=p["id"],
|
|
||||||
path=p["path"],
|
|
||||||
title=p["title"],
|
|
||||||
description=p.get("description"),
|
|
||||||
tags=p.get("tags", []),
|
|
||||||
updated_at=p.get("updatedAt"),
|
|
||||||
is_published=p.get("isPublished", True)
|
|
||||||
)
|
|
||||||
for p in pages
|
|
||||||
]
|
|
||||||
|
|
||||||
return WikiPageList(
|
|
||||||
pages=summaries,
|
|
||||||
total=len(summaries),
|
|
||||||
filtered_by_tag=tag,
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_page(self, page_id: int, user: str) -> Optional[WikiPage]:
|
|
||||||
"""
|
|
||||||
Get a single page by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID
|
|
||||||
user: User identifier (for validation)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WikiPage or None if not found or access denied
|
|
||||||
|
|
||||||
Note: Validates that page belongs to user's namespace
|
|
||||||
"""
|
|
||||||
page = await self.wiki_client.get_page(page_id)
|
|
||||||
|
|
||||||
if not page:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate page is in user's namespace
|
|
||||||
namespace = self._get_user_namespace(user)
|
|
||||||
page_path = "/" + page["path"].lstrip("/") # Normalize path with leading slash
|
|
||||||
if not page_path.startswith(namespace):
|
|
||||||
logger.warning(f"User {user} attempted to access page outside namespace: {page['path']}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return WikiPage(
|
|
||||||
id=page["id"],
|
|
||||||
path=page["path"],
|
|
||||||
title=page["title"],
|
|
||||||
description=page.get("description"),
|
|
||||||
content=page.get("content"),
|
|
||||||
tags=page.get("tags", []),
|
|
||||||
created_at=page.get("createdAt"),
|
|
||||||
updated_at=page.get("updatedAt"),
|
|
||||||
is_published=page.get("isPublished", True),
|
|
||||||
editor=page.get("editor")
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create_page(self, page_data: WikiPageCreate) -> WikiPage:
|
|
||||||
"""
|
|
||||||
Create a new wiki page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_data: Page creation data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created WikiPage
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If creation fails
|
|
||||||
"""
|
|
||||||
user = page_data.user or DEFAULT_USER
|
|
||||||
|
|
||||||
# Ensure path is in user's namespace
|
|
||||||
full_path = self._ensure_user_path(page_data.path, user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = await self.wiki_client.create_page(
|
|
||||||
path=full_path,
|
|
||||||
title=page_data.title,
|
|
||||||
content=page_data.content,
|
|
||||||
description=page_data.description or "",
|
|
||||||
tags=page_data.tags,
|
|
||||||
is_published=page_data.is_published,
|
|
||||||
editor=page_data.editor
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch full page details
|
|
||||||
page = await self.wiki_client.get_page(created["id"])
|
|
||||||
if not page:
|
|
||||||
raise ValueError("Page created but could not be retrieved")
|
|
||||||
|
|
||||||
return WikiPage(
|
|
||||||
id=page["id"],
|
|
||||||
path=page["path"],
|
|
||||||
title=page["title"],
|
|
||||||
description=page.get("description"),
|
|
||||||
content=page.get("content"),
|
|
||||||
tags=page.get("tags", []),
|
|
||||||
created_at=page.get("createdAt"),
|
|
||||||
updated_at=page.get("updatedAt"),
|
|
||||||
is_published=page.get("isPublished", True),
|
|
||||||
editor=page.get("editor")
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create page: {e}", exc_info=True)
|
|
||||||
raise ValueError(f"Failed to create page: {str(e)}")
|
|
||||||
|
|
||||||
async def update_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
page_data: WikiPageUpdate,
|
|
||||||
user: str
|
|
||||||
) -> WikiPage:
|
|
||||||
"""
|
|
||||||
Update an existing page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID to update
|
|
||||||
page_data: Update data
|
|
||||||
user: User identifier (for validation)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated WikiPage
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If page not found or update fails
|
|
||||||
"""
|
|
||||||
# Verify page exists and belongs to user
|
|
||||||
existing = await self.get_page(page_id, user)
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Page {page_id} not found or access denied")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.wiki_client.update_page(
|
|
||||||
page_id=page_id,
|
|
||||||
content=page_data.content,
|
|
||||||
title=page_data.title,
|
|
||||||
description=page_data.description,
|
|
||||||
tags=page_data.tags,
|
|
||||||
is_published=True # Always keep pages published for internal wiki
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch updated page
|
|
||||||
updated = await self.get_page(page_id, user)
|
|
||||||
if not updated:
|
|
||||||
raise ValueError("Page updated but could not be retrieved")
|
|
||||||
|
|
||||||
return updated
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update page {page_id}: {e}", exc_info=True)
|
|
||||||
raise ValueError(f"Failed to update page: {str(e)}")
|
|
||||||
|
|
||||||
async def delete_page(self, page_id: int, user: str) -> bool:
|
|
||||||
"""
|
|
||||||
Delete a page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID to delete
|
|
||||||
user: User identifier (for validation)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted successfully
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If page not found or deletion fails
|
|
||||||
"""
|
|
||||||
# Verify page exists and belongs to user
|
|
||||||
existing = await self.get_page(page_id, user)
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Page {page_id} not found or access denied")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.wiki_client.delete_page(page_id)
|
|
||||||
logger.info(f"Deleted page {page_id} for user {user}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete page {page_id}: {e}", exc_info=True)
|
|
||||||
raise ValueError(f"Failed to delete page: {str(e)}")
|
|
||||||
|
|
||||||
async def search_pages(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
user: str,
|
|
||||||
limit: int = 20
|
|
||||||
) -> List[WikiPageSummary]:
|
|
||||||
"""
|
|
||||||
Search pages in user's namespace.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query
|
|
||||||
user: User identifier
|
|
||||||
limit: Maximum results
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of matching pages
|
|
||||||
"""
|
|
||||||
namespace = self._get_user_namespace(user)
|
|
||||||
|
|
||||||
results = await self.wiki_client.search_pages(
|
|
||||||
query=query,
|
|
||||||
path_prefix=namespace
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to summaries (limit results)
|
|
||||||
return [
|
|
||||||
WikiPageSummary(
|
|
||||||
id=r["id"],
|
|
||||||
path=r["path"],
|
|
||||||
title=r["title"],
|
|
||||||
description=r.get("description"),
|
|
||||||
tags=[], # Search results don't include tags
|
|
||||||
updated_at=None,
|
|
||||||
is_published=True
|
|
||||||
)
|
|
||||||
for r in results[:limit]
|
|
||||||
]
|
|
||||||
|
|
||||||
async def move_page(
|
|
||||||
self,
|
|
||||||
page_id: int,
|
|
||||||
new_path: str,
|
|
||||||
user: str
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Move/rename a page.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id: Page ID to move
|
|
||||||
new_path: New path (within user namespace)
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if moved successfully
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If operation fails
|
|
||||||
"""
|
|
||||||
# Verify page exists and belongs to user
|
|
||||||
existing = await self.get_page(page_id, user)
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Page {page_id} not found or access denied")
|
|
||||||
|
|
||||||
# Ensure new path is in user's namespace
|
|
||||||
full_new_path = self._ensure_user_path(new_path, user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
success = await self.wiki_client.move_page(page_id, full_new_path)
|
|
||||||
if success:
|
|
||||||
logger.info(f"Moved page {page_id} to {full_new_path}")
|
|
||||||
return success
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to move page {page_id}: {e}", exc_info=True)
|
|
||||||
raise ValueError(f"Failed to move page: {str(e)}")
|
|
||||||
|
|
||||||
# Dossier operations (tag-based)
|
|
||||||
|
|
||||||
async def list_dossiers(self, user: str) -> DossierList:
|
|
||||||
"""
|
|
||||||
List all dossiers (unique tags) for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user: User identifier
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DossierList with all dossiers
|
|
||||||
"""
|
|
||||||
# Get all pages for user
|
|
||||||
pages = await self.list_pages(user, limit=1000)
|
|
||||||
|
|
||||||
# Collect unique tags
|
|
||||||
tag_counts: Dict[str, int] = {}
|
|
||||||
for page in pages.pages:
|
|
||||||
for tag in page.tags:
|
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
|
||||||
|
|
||||||
# Create dossier info for each tag
|
|
||||||
dossiers = [
|
|
||||||
DossierInfo(
|
|
||||||
name=tag,
|
|
||||||
title=tag.replace("-", " ").title(),
|
|
||||||
description=f"Dossier for {tag}",
|
|
||||||
page_count=count,
|
|
||||||
index_page_id=None,
|
|
||||||
index_page_path=None,
|
|
||||||
created_at=None
|
|
||||||
)
|
|
||||||
for tag, count in tag_counts.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
return DossierList(
|
|
||||||
dossiers=sorted(dossiers, key=lambda d: d.page_count, reverse=True),
|
|
||||||
total=len(dossiers),
|
|
||||||
user=user
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_dossier_pages(
|
|
||||||
self,
|
|
||||||
dossier_name: str,
|
|
||||||
user: str,
|
|
||||||
limit: int = 100
|
|
||||||
) -> WikiPageList:
|
|
||||||
"""
|
|
||||||
Get all pages in a dossier (by tag).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
dossier_name: Dossier name (tag)
|
|
||||||
user: User identifier
|
|
||||||
limit: Maximum pages
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WikiPageList filtered by dossier tag
|
|
||||||
"""
|
|
||||||
return await self.list_pages(user, tag=dossier_name, limit=limit)
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
*/
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Auto-detect Library Desk URL
|
|
||||||
const scriptTag = document.currentScript;
|
|
||||||
const scriptUrl = scriptTag ? scriptTag.src : '';
|
|
||||||
const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089';
|
|
||||||
|
|
||||||
// Shared configuration
|
|
||||||
const CONFIG = window.LIBRARY_DESK_CONFIG || {
|
|
||||||
libraryDeskUrl: libraryDeskUrl,
|
|
||||||
apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5',
|
|
||||||
user: 'jpmschweitzer',
|
|
||||||
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
|
|
||||||
debug: true
|
|
||||||
};
|
|
||||||
|
|
||||||
function log() {
|
|
||||||
if (CONFIG.debug) {
|
|
||||||
console.log('[Library Desk]', ...arguments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store page ID globally once fetched
|
|
||||||
let CACHED_PAGE_ID = null;
|
|
||||||
let CACHED_PAGE_PATH = null;
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
log('Initializing Library Desk integration...');
|
|
||||||
log('Library Desk URL:', CONFIG.libraryDeskUrl);
|
|
||||||
|
|
||||||
// Wait for page to be ready, then add buttons
|
|
||||||
setTimeout(addButtons, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addButtons() {
|
|
||||||
const pagePath = getPagePath();
|
|
||||||
if (!pagePath) {
|
|
||||||
log('Skipping buttons - not on a content page');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Page path:', pagePath);
|
|
||||||
|
|
||||||
// Check if buttons already exist
|
|
||||||
if (document.getElementById('library-desk-buttons')) {
|
|
||||||
log('Buttons already exist');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and cache page ID during initialization
|
|
||||||
CACHED_PAGE_PATH = pagePath;
|
|
||||||
CACHED_PAGE_ID = await getPageIdFromPath(pagePath);
|
|
||||||
log('Cached page ID for this session:', CACHED_PAGE_ID);
|
|
||||||
|
|
||||||
if (CONFIG.buttonPosition === 'floating') {
|
|
||||||
addFloatingButtons(pagePath);
|
|
||||||
} else {
|
|
||||||
addToolbarButtons(pagePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToolbarButtons(pagePath) {
|
|
||||||
const toolbar = findToolbar();
|
|
||||||
if (!toolbar) {
|
|
||||||
log('Toolbar not found, falling back to floating buttons');
|
|
||||||
addFloatingButtons(pagePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container for both buttons
|
|
||||||
const btnContainer = document.createElement('div');
|
|
||||||
btnContainer.id = 'library-desk-buttons';
|
|
||||||
btnContainer.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;';
|
|
||||||
|
|
||||||
// Re-index button
|
|
||||||
const reindexBtn = createToolbarButton(
|
|
||||||
'library-desk-reindex-btn',
|
|
||||||
'Re-index this page in Library Desk (vectors + knowledge graph)',
|
|
||||||
'mdi-database-sync',
|
|
||||||
() => reindexPage(pagePath, reindexBtn)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Entity link button
|
|
||||||
const entityLinkBtn = createToolbarButton(
|
|
||||||
'library-desk-entitylink-btn',
|
|
||||||
'Link entities mentioned in this page to the knowledge graph',
|
|
||||||
'mdi-graph-outline',
|
|
||||||
() => linkEntities(pagePath, entityLinkBtn)
|
|
||||||
);
|
|
||||||
|
|
||||||
btnContainer.appendChild(reindexBtn);
|
|
||||||
btnContainer.appendChild(entityLinkBtn);
|
|
||||||
toolbar.appendChild(btnContainer);
|
|
||||||
log('Toolbar buttons added');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createToolbarButton(id, title, icon, onClick) {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.id = id;
|
|
||||||
button.className = 'v-btn v-btn--icon v-btn--round theme--dark v-size--default';
|
|
||||||
button.type = 'button';
|
|
||||||
button.title = title;
|
|
||||||
button.setAttribute('aria-label', title);
|
|
||||||
|
|
||||||
button.innerHTML = `<span class="v-btn__content"><i class="v-icon notranslate mdi ${icon} theme--dark" style="font-size: 20px;"></i></span>`;
|
|
||||||
|
|
||||||
button.addEventListener('mouseenter', function() {
|
|
||||||
this.style.backgroundColor = 'rgba(255, 255, 255, 0.08)';
|
|
||||||
});
|
|
||||||
button.addEventListener('mouseleave', function() {
|
|
||||||
this.style.backgroundColor = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
button.onclick = function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFloatingButtons(pagePath) {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.id = 'library-desk-buttons';
|
|
||||||
container.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
z-index: 9999;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Re-index button
|
|
||||||
const reindexBtn = createFloatingButton(
|
|
||||||
'library-desk-reindex-btn',
|
|
||||||
'🔄 Re-index',
|
|
||||||
'Re-index this page in Library Desk',
|
|
||||||
'#1976d2',
|
|
||||||
'#1565c0',
|
|
||||||
() => reindexPage(pagePath, reindexBtn)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Entity link button
|
|
||||||
const entityLinkBtn = createFloatingButton(
|
|
||||||
'library-desk-entitylink-btn',
|
|
||||||
'🔗 Link Entities',
|
|
||||||
'Link entities in this page',
|
|
||||||
'#43a047',
|
|
||||||
'#388e3c',
|
|
||||||
() => linkEntities(pagePath, entityLinkBtn)
|
|
||||||
);
|
|
||||||
|
|
||||||
container.appendChild(reindexBtn);
|
|
||||||
container.appendChild(entityLinkBtn);
|
|
||||||
document.body.appendChild(container);
|
|
||||||
log('Floating buttons added');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFloatingButton(id, text, title, bgColor, hoverColor, onClick) {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.id = id;
|
|
||||||
button.innerHTML = text;
|
|
||||||
button.title = title;
|
|
||||||
button.style.cssText = `
|
|
||||||
padding: 10px 15px;
|
|
||||||
background: ${bgColor};
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
transition: all 0.3s;
|
|
||||||
`;
|
|
||||||
|
|
||||||
button.onmouseover = function() { this.style.background = hoverColor; };
|
|
||||||
button.onmouseout = function() { this.style.background = bgColor; };
|
|
||||||
button.onclick = onClick;
|
|
||||||
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RE-INDEX FUNCTIONALITY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function reindexPage(pagePath, button) {
|
|
||||||
const isFloating = button.id === 'library-desk-reindex-btn' && button.innerHTML.includes('Re-index');
|
|
||||||
const icon = isFloating ? null : button.querySelector('.v-icon');
|
|
||||||
const originalContent = isFloating ? button.innerHTML : null;
|
|
||||||
const originalIcon = icon ? icon.className : null;
|
|
||||||
|
|
||||||
button.disabled = true;
|
|
||||||
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '⏳ Loading...';
|
|
||||||
button.style.background = '#757575';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-loading mdi-spin theme--dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use cached page ID from initialization
|
|
||||||
if (!CACHED_PAGE_ID) {
|
|
||||||
throw new Error('Page ID not available - was not fetched during initialization');
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Re-indexing page', CACHED_PAGE_ID, '...');
|
|
||||||
|
|
||||||
// Re-index directly
|
|
||||||
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user: CONFIG.user,
|
|
||||||
page_id: CACHED_PAGE_ID,
|
|
||||||
force_refresh: true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '✓ Done!';
|
|
||||||
button.style.background = '#4caf50';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-check-circle theme--dark';
|
|
||||||
icon.style.color = '#4caf50';
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = 'Re-indexed: ' + result.page_title + '\n\n' +
|
|
||||||
'✓ Vectors: ' + result.vector_chunks_created + ' chunks\n' +
|
|
||||||
'✓ Entities: ' + result.graph_entities_extracted + '\n' +
|
|
||||||
'✓ Relationships: ' + result.graph_relationships_created + '\n' +
|
|
||||||
'⏱ Time: ' + Math.round(result.processing_time_ms) + 'ms';
|
|
||||||
|
|
||||||
log('Success:', message);
|
|
||||||
|
|
||||||
// Show notification
|
|
||||||
showNotification('success', 'Page Re-indexed', message);
|
|
||||||
|
|
||||||
// Reset button
|
|
||||||
setTimeout(function() {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.style.background = '#1976d2';
|
|
||||||
} else {
|
|
||||||
icon.className = originalIcon;
|
|
||||||
icon.style.color = '';
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
throw new Error(result.detail || result.error || 'Re-indexing failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Library Desk] Error:', error);
|
|
||||||
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '✗ Failed';
|
|
||||||
button.style.background = '#f44336';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-alert-circle theme--dark';
|
|
||||||
icon.style.color = '#f44336';
|
|
||||||
}
|
|
||||||
|
|
||||||
showNotification('error', 'Re-indexing Failed', error.message + '\n\nCheck browser console for details.');
|
|
||||||
|
|
||||||
// Reset button
|
|
||||||
setTimeout(function() {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.style.background = '#1976d2';
|
|
||||||
} else {
|
|
||||||
icon.className = originalIcon;
|
|
||||||
icon.style.color = '';
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
}, 4000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ENTITY LINKING FUNCTIONALITY
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function linkEntities(pagePath, button) {
|
|
||||||
const isFloating = button.id === 'library-desk-entitylink-btn' && button.innerHTML.includes('Link');
|
|
||||||
const icon = isFloating ? null : button.querySelector('.v-icon');
|
|
||||||
const originalContent = isFloating ? button.innerHTML : null;
|
|
||||||
const originalIcon = icon ? icon.className : null;
|
|
||||||
|
|
||||||
button.disabled = true;
|
|
||||||
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '⏳ Finding...';
|
|
||||||
button.style.background = '#757575';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-loading mdi-spin theme--dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get page ID from Wiki.js GraphQL API
|
|
||||||
const pageId = await getPageIdFromPath(pagePath);
|
|
||||||
if (!pageId) {
|
|
||||||
throw new Error('Could not get page ID from Wiki.js');
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Linking entities for page', pageId, '...');
|
|
||||||
|
|
||||||
// Call entity linking endpoint
|
|
||||||
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
user: CONFIG.user,
|
|
||||||
page_id: pageId,
|
|
||||||
create_relationships: true,
|
|
||||||
re_index_if_changed: true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '✓ Linked!';
|
|
||||||
button.style.background = '#4caf50';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-check-circle theme--dark';
|
|
||||||
icon.style.color = '#4caf50';
|
|
||||||
}
|
|
||||||
|
|
||||||
const entityList = result.entities_found
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(e => `- ${e.name} (${e.mentions} mentions)${e.path ? ' ✓ linked' : ''}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const message = 'Entity Linking Complete!\n\n' +
|
|
||||||
'Found: ' + result.entities_found.length + ' entities\n' +
|
|
||||||
'Wiki links added: ' + result.content_links_added + '\n' +
|
|
||||||
'Graph links added: ' + result.new_graph_links_created + '\n' +
|
|
||||||
'Content updated: ' + (result.content_updated ? 'Yes' : 'No') + '\n' +
|
|
||||||
'Re-indexed: ' + (result.re_indexed ? 'Yes' : 'No') + '\n\n' +
|
|
||||||
'Top entities:\n' + (entityList || 'None');
|
|
||||||
|
|
||||||
log('Success:', result);
|
|
||||||
alert(message);
|
|
||||||
|
|
||||||
// Reload page if content was updated
|
|
||||||
if (result.content_updated) {
|
|
||||||
log('Reloading page to show updated content...');
|
|
||||||
setTimeout(function() {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
|
||||||
setTimeout(function() {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.style.background = '#43a047';
|
|
||||||
} else {
|
|
||||||
icon.className = originalIcon;
|
|
||||||
icon.style.color = '';
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(result.detail || 'Entity linking failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Library Desk] Error:', error);
|
|
||||||
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = '✗ Failed';
|
|
||||||
button.style.background = '#f44336';
|
|
||||||
} else {
|
|
||||||
icon.className = 'v-icon notranslate mdi mdi-alert-circle theme--dark';
|
|
||||||
icon.style.color = '#f44336';
|
|
||||||
}
|
|
||||||
|
|
||||||
alert('Entity linking failed:\n\n' + error.message);
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
if (isFloating) {
|
|
||||||
button.innerHTML = originalContent;
|
|
||||||
button.style.background = '#43a047';
|
|
||||||
} else {
|
|
||||||
icon.className = originalIcon;
|
|
||||||
icon.style.color = '';
|
|
||||||
}
|
|
||||||
button.disabled = false;
|
|
||||||
}, 4000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SHARED UTILITIES
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function getPageIdFromPath(pagePath) {
|
|
||||||
/**
|
|
||||||
* Query Wiki.js GraphQL API to get page ID from path.
|
|
||||||
* Uses singleByPath query which requires path and locale.
|
|
||||||
*/
|
|
||||||
try {
|
|
||||||
// Get locale from URL or default to 'en'
|
|
||||||
const urlLocaleMatch = window.location.pathname.match(/^\/([a-z]{2})\//);
|
|
||||||
const locale = urlLocaleMatch ? urlLocaleMatch[1] : 'en';
|
|
||||||
|
|
||||||
log('Querying Wiki.js GraphQL for page:', pagePath, 'locale:', locale);
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
query ($path: String!, $locale: String!) {
|
|
||||||
pages {
|
|
||||||
singleByPath(path: $path, locale: $locale) {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const response = await fetch('/graphql', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: query,
|
|
||||||
variables: { path: pagePath, locale: locale }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
log('GraphQL response:', result);
|
|
||||||
|
|
||||||
if (result.data && result.data.pages && result.data.pages.singleByPath) {
|
|
||||||
const pageId = result.data.pages.singleByPath.id;
|
|
||||||
log('Found page ID from GraphQL:', pageId);
|
|
||||||
return parseInt(pageId, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.errors) {
|
|
||||||
log('GraphQL errors:', result.errors.map(e => e.message).join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
log('GraphQL query returned no page');
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
log('GraphQL query failed:', error.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPageId() {
|
|
||||||
/**
|
|
||||||
* Synchronous wrapper - returns null and triggers async fetch.
|
|
||||||
* Actual buttons will need to call getPageIdFromPath() directly.
|
|
||||||
*/
|
|
||||||
log('getPageId() called - page ID must be fetched asynchronously via getPageIdFromPath()');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForPageElement(callback, maxAttempts = 10, interval = 500) {
|
|
||||||
/**
|
|
||||||
* Wait for Vue store to be available, then call callback.
|
|
||||||
*/
|
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
function check() {
|
|
||||||
attempts++;
|
|
||||||
|
|
||||||
// Check if Vue store or page data is available
|
|
||||||
const hasVueStore = window.$store && (window.$store.state || window.$store.get);
|
|
||||||
const hasPageData = window.$page || window.pageId;
|
|
||||||
|
|
||||||
if (hasVueStore || hasPageData) {
|
|
||||||
log('Vue/page data available after', attempts, 'attempts');
|
|
||||||
callback();
|
|
||||||
} else if (attempts < maxAttempts) {
|
|
||||||
log('Waiting for Vue/page data... attempt', attempts);
|
|
||||||
setTimeout(check, interval);
|
|
||||||
} else {
|
|
||||||
log('Vue/page data never appeared after', maxAttempts, 'attempts - adding buttons anyway');
|
|
||||||
callback(); // Add buttons anyway, they just won't work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
check();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findToolbar() {
|
|
||||||
const selectors = [
|
|
||||||
'header.v-toolbar .v-toolbar__content',
|
|
||||||
'.v-app-bar .v-toolbar__content',
|
|
||||||
'nav.v-toolbar .v-toolbar__content',
|
|
||||||
'header .v-toolbar__content',
|
|
||||||
'.v-app-bar__content'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (var i = 0; i < selectors.length; i++) {
|
|
||||||
var elem = document.querySelector(selectors[i]);
|
|
||||||
if (elem) return elem;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPagePath() {
|
|
||||||
var path = window.location.pathname;
|
|
||||||
path = path.replace(/^\//, '').replace(/\/$/, '');
|
|
||||||
path = path.replace(/^[a-z]{2}\//, ''); // Remove language code
|
|
||||||
|
|
||||||
// Don't add buttons to home page or special pages
|
|
||||||
if (path === '' || path === 'home' || path.startsWith('_') || path.startsWith('a/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showNotification(type, title, message) {
|
|
||||||
// Try Wiki.js notifications if available
|
|
||||||
if (window.$store && typeof window.$store.commit === 'function') {
|
|
||||||
try {
|
|
||||||
window.$store.commit('showNotification', {
|
|
||||||
message: title + ': ' + message,
|
|
||||||
style: type,
|
|
||||||
icon: type === 'success' ? 'check' : 'alert'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
// Fall through to alert
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to browser alert
|
|
||||||
alert(title + '\n\n' + message);
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Library Desk integration loaded successfully');
|
|
||||||
log('Version: 2.0.0 - Combined re-index and entity linking');
|
|
||||||
|
|
||||||
})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for Library Desk service."""
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""Pytest configuration and shared fixtures for Library Desk tests."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
# Test configuration
|
|
||||||
pytest_plugins = ("pytest_asyncio",)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_user() -> str:
|
|
||||||
"""Default test user."""
|
|
||||||
return "test_user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def neo4j_test_uri() -> str:
|
|
||||||
"""Test Neo4j URI."""
|
|
||||||
return "bolt://neo4j:7687"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def neo4j_test_auth() -> tuple:
|
|
||||||
"""Test Neo4j authentication."""
|
|
||||||
return ("neo4j", "test_password")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def qdrant_test_url() -> str:
|
|
||||||
"""Test Qdrant URL."""
|
|
||||||
return "http://qdrant:6333"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def wikijs_test_config() -> dict:
|
|
||||||
"""Test Wiki.js configuration."""
|
|
||||||
return {
|
|
||||||
"base_url": "http://wiki:3000",
|
|
||||||
"api_key": "test_api_key"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def searxng_test_url() -> str:
|
|
||||||
"""Test SearXNG URL."""
|
|
||||||
return "http://searxng:8080"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ollama_test_config() -> dict:
|
|
||||||
"""Test Ollama configuration."""
|
|
||||||
return {
|
|
||||||
"base_url": "http://ollama:11434",
|
|
||||||
"model": "nomic-embed-text"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def redis_test_url() -> str:
|
|
||||||
"""Test Redis URL."""
|
|
||||||
return "redis://redis-shared:6379/4"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_document() -> dict:
|
|
||||||
"""Sample document for testing."""
|
|
||||||
return {
|
|
||||||
"id": "test_doc_1",
|
|
||||||
"title": "Test Document",
|
|
||||||
"content": "This is a test document for unit testing.",
|
|
||||||
"metadata": {
|
|
||||||
"source": "test",
|
|
||||||
"author": "test_user"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_chunks() -> list:
|
|
||||||
"""Sample document chunks for testing."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"content": "First chunk of text.",
|
|
||||||
"metadata": {"chunk_index": 0}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Second chunk of text.",
|
|
||||||
"metadata": {"chunk_index": 1}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Third chunk of text.",
|
|
||||||
"metadata": {"chunk_index": 2}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_embeddings() -> list:
|
|
||||||
"""Sample embeddings for testing (768-dimensional for nomic-embed-text)."""
|
|
||||||
import random
|
|
||||||
random.seed(42) # Reproducible embeddings
|
|
||||||
|
|
||||||
# Generate 3 sample 768-dimensional embeddings
|
|
||||||
return [
|
|
||||||
[random.random() for _ in range(768)],
|
|
||||||
[random.random() for _ in range(768)],
|
|
||||||
[random.random() for _ in range(768)]
|
|
||||||
]
|
|
||||||
@@ -1,754 +0,0 @@
|
|||||||
"""
|
|
||||||
Comprehensive tests for Knowledge Consolidation system.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- ConsolidationService (unit tests with mocks)
|
|
||||||
- Consolidation API endpoint (integration tests)
|
|
||||||
- Model validation
|
|
||||||
- Error handling
|
|
||||||
- Dry run mode
|
|
||||||
|
|
||||||
Run with: pytest tests/test_consolidation.py -v -s
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
from src.services.consolidation_service import ConsolidationService
|
|
||||||
from src.models.consolidation import (
|
|
||||||
ConsolidationRequest,
|
|
||||||
ConsolidationResponse,
|
|
||||||
ConsolidationResult,
|
|
||||||
SearchQueryInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test constants
|
|
||||||
TEST_USER = "consolidation-tester"
|
|
||||||
TEST_SEARCH_ID = "test-search-123"
|
|
||||||
|
|
||||||
|
|
||||||
# Fixtures
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def settings():
|
|
||||||
"""Get mocked application settings for testing."""
|
|
||||||
mock_settings = MagicMock()
|
|
||||||
mock_settings.reranker_model = "mistral-nemo"
|
|
||||||
mock_settings.ollama_model = "mistral-nemo"
|
|
||||||
return mock_settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_neo4j():
|
|
||||||
"""Mock Neo4j client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.execute_query = AsyncMock()
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_ollama():
|
|
||||||
"""Mock Ollama client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.generate_text = AsyncMock()
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_wiki():
|
|
||||||
"""Mock Wiki.js client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
# Default mock for taxonomy structure
|
|
||||||
mock.get_taxonomy_structure = AsyncMock(return_value={
|
|
||||||
"companies": [],
|
|
||||||
"people": [],
|
|
||||||
"places": ["the-netherlands"],
|
|
||||||
"reference": ["political-entities", "tech"],
|
|
||||||
"technology": ["tools", "services"]
|
|
||||||
})
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_ingestion():
|
|
||||||
"""Mock Ingestion service."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.ingest_page = AsyncMock(return_value=MagicMock(success=True))
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def consolidation_service(mock_neo4j, mock_ollama, mock_wiki, mock_ingestion, settings):
|
|
||||||
"""Get ConsolidationService with mocked dependencies."""
|
|
||||||
return ConsolidationService(
|
|
||||||
neo4j=mock_neo4j,
|
|
||||||
ollama=mock_ollama,
|
|
||||||
wiki=mock_wiki,
|
|
||||||
settings=settings,
|
|
||||||
ingestion_service=mock_ingestion
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def consolidation_service_no_ingestion(mock_neo4j, mock_ollama, mock_wiki, settings):
|
|
||||||
"""Get ConsolidationService without ingestion service (legacy behavior)."""
|
|
||||||
return ConsolidationService(
|
|
||||||
neo4j=mock_neo4j,
|
|
||||||
ollama=mock_ollama,
|
|
||||||
wiki=mock_wiki,
|
|
||||||
settings=settings,
|
|
||||||
ingestion_service=None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_unprocessed_searches():
|
|
||||||
"""Sample unprocessed search queries."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'id': 'search-1',
|
|
||||||
'query': 'docker orchestration kubernetes',
|
|
||||||
'user': TEST_USER,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'total_results': 10,
|
|
||||||
'web_count': 5,
|
|
||||||
'keywords': ['docker', 'orchestration', 'kubernetes']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id': 'search-2',
|
|
||||||
'query': 'python async programming',
|
|
||||||
'user': TEST_USER,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'total_results': 8,
|
|
||||||
'web_count': 3,
|
|
||||||
'keywords': ['python', 'async', 'programming']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_web_results():
|
|
||||||
"""Sample web search results."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'url': 'https://kubernetes.io/docs',
|
|
||||||
'title': 'Kubernetes Documentation',
|
|
||||||
'content': 'Kubernetes is an orchestration platform for containers...',
|
|
||||||
'rank': 1,
|
|
||||||
'rrf_score': 0.05
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'url': 'https://docs.docker.com/swarm',
|
|
||||||
'title': 'Docker Swarm Documentation',
|
|
||||||
'content': 'Docker Swarm is a container orchestration tool...',
|
|
||||||
'rank': 2,
|
|
||||||
'rrf_score': 0.04
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'url': 'https://example.com/k8s-tutorial',
|
|
||||||
'title': 'Kubernetes Tutorial',
|
|
||||||
'content': 'Learn how to use Kubernetes for container orchestration...',
|
|
||||||
'rank': 3,
|
|
||||||
'rrf_score': 0.03
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_llm_analysis():
|
|
||||||
"""Sample LLM analysis response."""
|
|
||||||
return {
|
|
||||||
"has_novel_info": True,
|
|
||||||
"new_pages": [
|
|
||||||
{
|
|
||||||
"title": "Kubernetes Container Orchestration",
|
|
||||||
"path": "infrastructure/kubernetes",
|
|
||||||
"summary": "Overview of Kubernetes orchestration capabilities"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"update_pages": [
|
|
||||||
{
|
|
||||||
"title": "Docker Infrastructure",
|
|
||||||
"new_facts": [
|
|
||||||
"Kubernetes provides automatic bin packing",
|
|
||||||
"Self-healing capabilities with automatic restarts"
|
|
||||||
],
|
|
||||||
"source_url": "https://kubernetes.io/docs"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"new_entities": [
|
|
||||||
{
|
|
||||||
"name": "Kubernetes",
|
|
||||||
"type": "technology",
|
|
||||||
"description": "Container orchestration platform"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Docker Swarm",
|
|
||||||
"type": "technology",
|
|
||||||
"description": "Docker's native orchestration tool"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Model Tests
|
|
||||||
|
|
||||||
def test_consolidation_request_validation():
|
|
||||||
"""Test ConsolidationRequest model validation."""
|
|
||||||
# Valid request
|
|
||||||
request = ConsolidationRequest(
|
|
||||||
process_limit=10,
|
|
||||||
lookback_days=7,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
assert request.process_limit == 10
|
|
||||||
assert request.lookback_days == 7
|
|
||||||
assert request.min_web_results == 2
|
|
||||||
assert request.dry_run is False
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
request = ConsolidationRequest()
|
|
||||||
assert request.process_limit == 10
|
|
||||||
assert request.lookback_days == 7
|
|
||||||
assert request.min_web_results == 2
|
|
||||||
assert request.dry_run is False
|
|
||||||
|
|
||||||
# Validate limits
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
ConsolidationRequest(process_limit=0) # Too low
|
|
||||||
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
ConsolidationRequest(process_limit=101) # Too high
|
|
||||||
|
|
||||||
|
|
||||||
def test_consolidation_response_model():
|
|
||||||
"""Test ConsolidationResponse model."""
|
|
||||||
response = ConsolidationResponse(
|
|
||||||
total_found=5,
|
|
||||||
processed_count=4,
|
|
||||||
pages_created=2,
|
|
||||||
pages_updated=3,
|
|
||||||
entities_added=5,
|
|
||||||
errors=["Error 1"],
|
|
||||||
results=[],
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.total_found == 5
|
|
||||||
assert response.processed_count == 4
|
|
||||||
assert response.pages_created == 2
|
|
||||||
assert len(response.errors) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_consolidation_result_model():
|
|
||||||
"""Test ConsolidationResult model."""
|
|
||||||
result = ConsolidationResult(
|
|
||||||
search_id="test-123",
|
|
||||||
query="test query",
|
|
||||||
pages_created=1,
|
|
||||||
pages_updated=2,
|
|
||||||
entities_added=3,
|
|
||||||
error=None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.search_id == "test-123"
|
|
||||||
assert result.query == "test query"
|
|
||||||
assert result.pages_created == 1
|
|
||||||
assert result.error is None
|
|
||||||
|
|
||||||
|
|
||||||
# Service Unit Tests
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_find_unprocessed_searches_empty(consolidation_service, mock_neo4j):
|
|
||||||
"""Test finding unprocessed searches when none exist."""
|
|
||||||
# Mock empty result
|
|
||||||
mock_neo4j.execute_query.return_value = []
|
|
||||||
|
|
||||||
searches = await consolidation_service._find_unprocessed_searches(
|
|
||||||
lookback_days=7,
|
|
||||||
limit=10
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(searches) == 0
|
|
||||||
mock_neo4j.execute_query.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_find_unprocessed_searches_with_results(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
sample_unprocessed_searches
|
|
||||||
):
|
|
||||||
"""Test finding unprocessed searches with results."""
|
|
||||||
# Mock Neo4j response
|
|
||||||
mock_neo4j.execute_query.return_value = sample_unprocessed_searches
|
|
||||||
|
|
||||||
searches = await consolidation_service._find_unprocessed_searches(
|
|
||||||
lookback_days=7,
|
|
||||||
limit=10
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(searches) == 2
|
|
||||||
assert searches[0]['query'] == 'docker orchestration kubernetes'
|
|
||||||
assert searches[1]['query'] == 'python async programming'
|
|
||||||
mock_neo4j.execute_query.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_web_results(consolidation_service, mock_neo4j, sample_web_results):
|
|
||||||
"""Test retrieving web results for a search."""
|
|
||||||
# Mock Neo4j response
|
|
||||||
mock_neo4j.execute_query.return_value = sample_web_results
|
|
||||||
|
|
||||||
results = await consolidation_service._get_web_results(TEST_SEARCH_ID)
|
|
||||||
|
|
||||||
assert len(results) == 3
|
|
||||||
assert results[0]['title'] == 'Kubernetes Documentation'
|
|
||||||
assert results[1]['url'] == 'https://docs.docker.com/swarm'
|
|
||||||
mock_neo4j.execute_query.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_web_results_empty(consolidation_service, mock_neo4j):
|
|
||||||
"""Test retrieving web results when none exist."""
|
|
||||||
mock_neo4j.execute_query.return_value = []
|
|
||||||
|
|
||||||
results = await consolidation_service._get_web_results(TEST_SEARCH_ID)
|
|
||||||
|
|
||||||
assert len(results) == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_web_results_with_novel_info(
|
|
||||||
consolidation_service,
|
|
||||||
mock_ollama,
|
|
||||||
mock_wiki,
|
|
||||||
sample_web_results,
|
|
||||||
sample_llm_analysis
|
|
||||||
):
|
|
||||||
"""Test analyzing web results with Ollama - novel info found."""
|
|
||||||
# Mock Ollama response
|
|
||||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="docker orchestration",
|
|
||||||
web_results=sample_web_results,
|
|
||||||
keywords=["docker", "orchestration"],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert analysis is not None
|
|
||||||
assert analysis['has_novel_info'] is True
|
|
||||||
assert len(analysis['new_pages']) == 1
|
|
||||||
assert len(analysis['update_pages']) == 1
|
|
||||||
assert len(analysis['new_entities']) == 2
|
|
||||||
mock_ollama.generate_text.assert_called_once()
|
|
||||||
# Verify taxonomy was fetched
|
|
||||||
mock_wiki.get_taxonomy_structure.assert_called_once_with(f"users/{TEST_USER}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_web_results_no_novel_info(
|
|
||||||
consolidation_service,
|
|
||||||
mock_ollama,
|
|
||||||
sample_web_results
|
|
||||||
):
|
|
||||||
"""Test analyzing web results - no novel info."""
|
|
||||||
# Mock Ollama response with no novel info
|
|
||||||
analysis_no_novel = {
|
|
||||||
"has_novel_info": False,
|
|
||||||
"new_pages": [],
|
|
||||||
"update_pages": [],
|
|
||||||
"new_entities": []
|
|
||||||
}
|
|
||||||
mock_ollama.generate_text.return_value = json.dumps(analysis_no_novel)
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="common topic",
|
|
||||||
web_results=sample_web_results,
|
|
||||||
keywords=[],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert analysis is not None
|
|
||||||
assert analysis['has_novel_info'] is False
|
|
||||||
assert len(analysis['new_pages']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_web_results_invalid_json(
|
|
||||||
consolidation_service,
|
|
||||||
mock_ollama,
|
|
||||||
sample_web_results
|
|
||||||
):
|
|
||||||
"""Test analyzing web results with invalid JSON response."""
|
|
||||||
# Mock Ollama response with invalid JSON
|
|
||||||
mock_ollama.generate_text.return_value = "This is not JSON"
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="test query",
|
|
||||||
web_results=sample_web_results,
|
|
||||||
keywords=[],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert analysis is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_web_results_json_in_markdown(
|
|
||||||
consolidation_service,
|
|
||||||
mock_ollama,
|
|
||||||
sample_web_results,
|
|
||||||
sample_llm_analysis
|
|
||||||
):
|
|
||||||
"""Test extracting JSON from markdown-wrapped response."""
|
|
||||||
# Mock Ollama response with JSON wrapped in markdown
|
|
||||||
wrapped_response = f"""Here's the analysis:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{json.dumps(sample_llm_analysis)}
|
|
||||||
```
|
|
||||||
|
|
||||||
Hope this helps!"""
|
|
||||||
mock_ollama.generate_text.return_value = wrapped_response
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="test",
|
|
||||||
web_results=sample_web_results,
|
|
||||||
keywords=[],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert analysis is not None
|
|
||||||
assert analysis['has_novel_info'] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_web_results_taxonomy_failure(
|
|
||||||
consolidation_service,
|
|
||||||
mock_ollama,
|
|
||||||
mock_wiki,
|
|
||||||
sample_web_results,
|
|
||||||
sample_llm_analysis
|
|
||||||
):
|
|
||||||
"""Test that analysis continues even if taxonomy fetch fails."""
|
|
||||||
# Mock taxonomy fetch failure
|
|
||||||
mock_wiki.get_taxonomy_structure.side_effect = Exception("Connection error")
|
|
||||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="test",
|
|
||||||
web_results=sample_web_results,
|
|
||||||
keywords=[],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should still succeed, just without taxonomy info in prompt
|
|
||||||
assert analysis is not None
|
|
||||||
assert analysis['has_novel_info'] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_format_taxonomy_for_prompt(consolidation_service):
|
|
||||||
"""Test taxonomy formatting for LLM prompt."""
|
|
||||||
taxonomy = {
|
|
||||||
"companies": [],
|
|
||||||
"places": ["the-netherlands", "rotterdam"],
|
|
||||||
"reference": ["political-entities"]
|
|
||||||
}
|
|
||||||
|
|
||||||
formatted = consolidation_service._format_taxonomy_for_prompt(taxonomy)
|
|
||||||
|
|
||||||
assert "Existing paths" in formatted
|
|
||||||
assert "companies/" in formatted
|
|
||||||
assert "places/the-netherlands/" in formatted
|
|
||||||
assert "places/rotterdam/" in formatted
|
|
||||||
assert "reference/political-entities/" in formatted
|
|
||||||
assert "IMPORTANT" in formatted
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_format_taxonomy_empty(consolidation_service):
|
|
||||||
"""Test taxonomy formatting with empty taxonomy."""
|
|
||||||
formatted = consolidation_service._format_taxonomy_for_prompt({})
|
|
||||||
assert formatted == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
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
|
|
||||||
assert TEST_SEARCH_ID in str(call_args)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_search_insufficient_web_results(
|
|
||||||
consolidation_service,
|
|
||||||
sample_unprocessed_searches
|
|
||||||
):
|
|
||||||
"""Test processing search with insufficient web results."""
|
|
||||||
search = sample_unprocessed_searches[1].copy()
|
|
||||||
search['web_count'] = 1 # Below minimum
|
|
||||||
|
|
||||||
result = await consolidation_service._process_search(
|
|
||||||
search=search,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_search_no_web_results_in_db(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
sample_unprocessed_searches
|
|
||||||
):
|
|
||||||
"""Test processing search when web results not found in DB."""
|
|
||||||
mock_neo4j.execute_query.return_value = []
|
|
||||||
|
|
||||||
result = await consolidation_service._process_search(
|
|
||||||
search=sample_unprocessed_searches[0],
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_search_dry_run(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_ollama,
|
|
||||||
sample_unprocessed_searches,
|
|
||||||
sample_web_results,
|
|
||||||
sample_llm_analysis
|
|
||||||
):
|
|
||||||
"""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)
|
|
||||||
|
|
||||||
result = await consolidation_service._process_search(
|
|
||||||
search=sample_unprocessed_searches[0],
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=True
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidate_knowledge_no_searches(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j
|
|
||||||
):
|
|
||||||
"""Test consolidation when no unprocessed searches found."""
|
|
||||||
mock_neo4j.execute_query.return_value = []
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=10,
|
|
||||||
lookback_days=7,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.total_found == 0
|
|
||||||
assert response.processed_count == 0
|
|
||||||
assert response.pages_created == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidate_knowledge_success(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_ollama,
|
|
||||||
mock_wiki,
|
|
||||||
sample_unprocessed_searches,
|
|
||||||
sample_web_results,
|
|
||||||
sample_llm_analysis
|
|
||||||
):
|
|
||||||
"""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
|
|
||||||
]
|
|
||||||
|
|
||||||
# Mock wiki operations
|
|
||||||
mock_wiki.search_pages.return_value = [] # No existing pages
|
|
||||||
mock_wiki.create_page.return_value = None
|
|
||||||
mock_wiki.update_page.return_value = None
|
|
||||||
mock_wiki.get_page.return_value = None
|
|
||||||
|
|
||||||
# Mock LLM analysis and WikiPageWriter LLM calls
|
|
||||||
mock_ollama.generate_text.return_value = json.dumps(sample_llm_analysis)
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=10,
|
|
||||||
lookback_days=7,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.total_found == 2
|
|
||||||
assert response.processed_count == 2
|
|
||||||
assert response.dry_run is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidate_knowledge_with_errors(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_ollama,
|
|
||||||
sample_unprocessed_searches
|
|
||||||
):
|
|
||||||
"""Test consolidation with some searches failing."""
|
|
||||||
# Mock finding searches - return empty for web results to trigger internal error handling
|
|
||||||
mock_neo4j.execute_query.side_effect = [
|
|
||||||
sample_unprocessed_searches, # Find searches
|
|
||||||
[], # Empty web results for search 1 (causes skip, not error)
|
|
||||||
[], # Empty web results for search 2 (causes skip, not error)
|
|
||||||
]
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=10,
|
|
||||||
lookback_days=7,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=False
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.total_found == 2
|
|
||||||
# Both searches skipped due to no web results (not errors)
|
|
||||||
assert response.processed_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
# Integration Tests (API Endpoint)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidation_endpoint_minimal_request(consolidation_service):
|
|
||||||
"""Test consolidation endpoint with minimal request."""
|
|
||||||
pytest.importorskip("fastapi") # Skip if fastapi not available
|
|
||||||
|
|
||||||
# This would require proper test client setup
|
|
||||||
# Placeholder for integration test structure
|
|
||||||
request = ConsolidationRequest()
|
|
||||||
assert request.process_limit == 10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidation_endpoint_custom_config(consolidation_service):
|
|
||||||
"""Test consolidation endpoint with custom configuration."""
|
|
||||||
request = ConsolidationRequest(
|
|
||||||
process_limit=5,
|
|
||||||
lookback_days=14,
|
|
||||||
min_web_results=3,
|
|
||||||
dry_run=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert request.process_limit == 5
|
|
||||||
assert request.lookback_days == 14
|
|
||||||
assert request.min_web_results == 3
|
|
||||||
assert request.dry_run is True
|
|
||||||
|
|
||||||
|
|
||||||
# Edge Cases
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_consolidate_with_max_limits(consolidation_service, mock_neo4j):
|
|
||||||
"""Test consolidation with maximum limits."""
|
|
||||||
mock_neo4j.execute_query.return_value = []
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=100, # Max
|
|
||||||
lookback_days=90, # Max
|
|
||||||
min_web_results=20, # Max
|
|
||||||
dry_run=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.total_found == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_analyze_empty_web_results(consolidation_service, mock_ollama):
|
|
||||||
"""Test analyzing with empty web results list."""
|
|
||||||
mock_ollama.generate_text.return_value = json.dumps({
|
|
||||||
"has_novel_info": False,
|
|
||||||
"new_pages": [],
|
|
||||||
"update_pages": [],
|
|
||||||
"new_entities": []
|
|
||||||
})
|
|
||||||
|
|
||||||
analysis = await consolidation_service._analyze_web_results(
|
|
||||||
query="test",
|
|
||||||
web_results=[],
|
|
||||||
keywords=[],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should still call LLM but return no novel info
|
|
||||||
assert analysis is not None
|
|
||||||
|
|
||||||
|
|
||||||
# Performance/Load Tests (optional)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_many_searches_dry_run(
|
|
||||||
consolidation_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_ollama
|
|
||||||
):
|
|
||||||
"""Test processing many searches in dry run mode."""
|
|
||||||
# Generate many test searches
|
|
||||||
many_searches = [
|
|
||||||
{
|
|
||||||
'id': f'search-{i}',
|
|
||||||
'query': f'test query {i}',
|
|
||||||
'user': TEST_USER,
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
'total_results': 5,
|
|
||||||
'web_count': 3,
|
|
||||||
'keywords': ['test']
|
|
||||||
}
|
|
||||||
for i in range(50)
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_neo4j.execute_query.return_value = many_searches[:10] # Limit by config
|
|
||||||
|
|
||||||
response = await consolidation_service.consolidate_knowledge(
|
|
||||||
process_limit=10,
|
|
||||||
lookback_days=7,
|
|
||||||
min_web_results=2,
|
|
||||||
dry_run=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should only process up to limit
|
|
||||||
assert response.total_found == 10
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v", "-s"])
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
"""
|
|
||||||
Comprehensive tests for Entity Linking system.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Finding entity mentions in pages
|
|
||||||
- Creating MENTIONS relationships in Neo4j
|
|
||||||
- Adding hyperlinks to wiki content
|
|
||||||
- Idempotency (safe to run multiple times)
|
|
||||||
- Protection of existing links (no nesting)
|
|
||||||
- Multi-tenancy isolation
|
|
||||||
|
|
||||||
Run with: pytest tests/test_entity_linking.py -v -s
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
from src.clients.neo4j_client import Neo4jClient
|
|
||||||
from src.clients.wikijs_client import WikiJSClient
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
from src.services.wiki_service import WikiService
|
|
||||||
from src.routers.entity_linking import (
|
|
||||||
find_entity_mentions,
|
|
||||||
add_entity_links_to_content,
|
|
||||||
get_entities_with_paths
|
|
||||||
)
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
# Test user to isolate test data
|
|
||||||
TEST_USER = "entity-link-tester"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def settings():
|
|
||||||
"""Get application settings."""
|
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
|
||||||
"""Get connected Neo4j client."""
|
|
||||||
client = Neo4jClient(
|
|
||||||
uri=settings.neo4j_uri,
|
|
||||||
user=settings.neo4j_user,
|
|
||||||
password=settings.neo4j_password
|
|
||||||
)
|
|
||||||
await client.connect()
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
|
||||||
"""Get Wiki.js client."""
|
|
||||||
client = WikiJSClient(
|
|
||||||
base_url=settings.wikijs_url,
|
|
||||||
username=settings.wikijs_username,
|
|
||||||
password=settings.wikijs_password
|
|
||||||
)
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def graph_service(neo4j_client, wiki_client):
|
|
||||||
"""Get GraphService instance."""
|
|
||||||
return GraphService(neo4j_client, wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def wiki_service(wiki_client):
|
|
||||||
"""Get WikiService instance."""
|
|
||||||
return WikiService(wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def test_entities(graph_service):
|
|
||||||
"""Create test entities in graph."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
|
||||||
|
|
||||||
# Clean up any existing test entities
|
|
||||||
cleanup_query = f"""
|
|
||||||
MATCH (n:{user_base_label})
|
|
||||||
WHERE n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
|
||||||
DETACH DELETE n
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(cleanup_query)
|
|
||||||
|
|
||||||
# Create test entities
|
|
||||||
create_query = f"""
|
|
||||||
CREATE (d:{user_base_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
||||||
CREATE (k:{user_base_label}:Technology {{name: 'Kubernetes', type: 'technology'}})
|
|
||||||
CREATE (p:{user_base_label}:Technology {{name: 'PostgreSQL', type: 'technology'}})
|
|
||||||
RETURN d.name, k.name, p.name
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(create_query)
|
|
||||||
|
|
||||||
yield ["Docker", "Kubernetes", "PostgreSQL"]
|
|
||||||
|
|
||||||
# Cleanup after test
|
|
||||||
await graph_service.neo4j.execute_query(cleanup_query)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Unit Tests - Entity Mention Detection
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestFindEntityMentions:
|
|
||||||
"""Test finding entity mentions in content."""
|
|
||||||
|
|
||||||
def test_find_single_mention(self):
|
|
||||||
"""Test finding a single entity mention."""
|
|
||||||
content = "Docker is a containerization platform."
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "type": "technology"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0]["name"] == "Docker"
|
|
||||||
assert found[0]["mentions"] == 1
|
|
||||||
|
|
||||||
def test_find_multiple_mentions(self):
|
|
||||||
"""Test finding multiple mentions of same entity."""
|
|
||||||
content = "Docker containers run on Docker Engine. Docker is great!"
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "type": "technology"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0]["name"] == "Docker"
|
|
||||||
assert found[0]["mentions"] == 3
|
|
||||||
|
|
||||||
def test_case_insensitive_matching(self):
|
|
||||||
"""Test case-insensitive entity matching."""
|
|
||||||
content = "docker and DOCKER and Docker are the same"
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "type": "technology"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0]["mentions"] == 3
|
|
||||||
|
|
||||||
def test_whole_word_matching(self):
|
|
||||||
"""Test that partial word matches are excluded."""
|
|
||||||
content = "Kubernetes and Kubernetes-based and MyKubernetes"
|
|
||||||
entities = [
|
|
||||||
{"name": "Kubernetes", "type": "technology"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 1
|
|
||||||
# Regex \b matches at hyphens, so "Kubernetes-based" contains "Kubernetes"
|
|
||||||
# Only "MyKubernetes" is excluded (no word boundary)
|
|
||||||
assert found[0]["mentions"] == 2 # "Kubernetes" and "Kubernetes-based"
|
|
||||||
|
|
||||||
def test_ignore_short_names(self):
|
|
||||||
"""Test that entities with names <3 chars are ignored."""
|
|
||||||
content = "Go is a programming language by Google"
|
|
||||||
entities = [
|
|
||||||
{"name": "Go", "type": "language"}, # Too short
|
|
||||||
{"name": "Google", "type": "organization"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 1
|
|
||||||
assert found[0]["name"] == "Google"
|
|
||||||
|
|
||||||
def test_sort_by_mention_count(self):
|
|
||||||
"""Test results are sorted by mention count."""
|
|
||||||
content = "Docker Docker Docker. Kubernetes Kubernetes. PostgreSQL."
|
|
||||||
entities = [
|
|
||||||
{"name": "PostgreSQL", "type": "database"},
|
|
||||||
{"name": "Docker", "type": "technology"},
|
|
||||||
{"name": "Kubernetes", "type": "technology"}
|
|
||||||
]
|
|
||||||
|
|
||||||
found = find_entity_mentions(content, entities)
|
|
||||||
|
|
||||||
assert len(found) == 3
|
|
||||||
assert found[0]["name"] == "Docker" # Most mentions
|
|
||||||
assert found[0]["mentions"] == 3
|
|
||||||
assert found[1]["name"] == "Kubernetes"
|
|
||||||
assert found[1]["mentions"] == 2
|
|
||||||
assert found[2]["name"] == "PostgreSQL"
|
|
||||||
assert found[2]["mentions"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Unit Tests - Content Link Addition
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestAddEntityLinksToContent:
|
|
||||||
"""Test adding hyperlinks to content."""
|
|
||||||
|
|
||||||
def test_add_single_link(self):
|
|
||||||
"""Test adding a single entity link."""
|
|
||||||
content = "Docker is a containerization platform."
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "path": "users/test/docker"}
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
assert "[Docker](/docker)" in updated
|
|
||||||
|
|
||||||
def test_add_multiple_instances(self):
|
|
||||||
"""Test linking all instances of an entity."""
|
|
||||||
content = "Docker containers run on Docker Engine."
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "path": "users/test/docker"}
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
assert count == 2 # Both instances linked
|
|
||||||
assert updated.count("[Docker](/docker)") == 2
|
|
||||||
|
|
||||||
def test_skip_entities_without_path(self):
|
|
||||||
"""Test that entities without wiki pages are not linked."""
|
|
||||||
content = "Docker and Kubernetes are used together."
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "path": "users/test/docker"},
|
|
||||||
{"name": "Kubernetes", "path": None} # No page
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
assert count == 1 # Only Docker
|
|
||||||
assert "[Docker](/docker)" in updated
|
|
||||||
assert "[Kubernetes]" not in updated
|
|
||||||
|
|
||||||
def test_protect_existing_links(self):
|
|
||||||
"""Test that existing markdown links are not modified."""
|
|
||||||
content = "See [Docker](https://docker.com) for more info. Docker is great!"
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "path": "users/test/docker"}
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
def test_no_nested_links(self):
|
|
||||||
"""Test that entity names in URLs are not linked."""
|
|
||||||
content = "Check [Docker Hub](/docker/hub) for images."
|
|
||||||
entities = [
|
|
||||||
{"name": "Docker", "path": "users/test/docker"}
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
# "Docker" in the URL path should not be linked
|
|
||||||
assert count == 0
|
|
||||||
assert "[Docker Hub](/docker/hub)" in updated # Unchanged
|
|
||||||
|
|
||||||
def test_longest_first_matching(self):
|
|
||||||
"""Test that longer entity names are matched first."""
|
|
||||||
content = "Machine Learning and Machine are different."
|
|
||||||
entities = [
|
|
||||||
{"name": "Machine Learning", "path": "users/test/ml"},
|
|
||||||
{"name": "Machine", "path": "users/test/machine"}
|
|
||||||
]
|
|
||||||
|
|
||||||
updated, count = add_entity_links_to_content(content, entities)
|
|
||||||
|
|
||||||
# Should link "Machine Learning" first, leaving "Machine" alone
|
|
||||||
assert "[Machine Learning](/ml)" in updated
|
|
||||||
assert count >= 1
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Integration Tests - Full Entity Linking Flow
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestEntityLinkingIntegration:
|
|
||||||
"""Test full entity linking flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_entities_with_paths(self, graph_service, test_entities):
|
|
||||||
"""Test retrieving entities and their wiki page paths."""
|
|
||||||
entities = await get_entities_with_paths(graph_service, TEST_USER)
|
|
||||||
|
|
||||||
# Should find our test entities
|
|
||||||
entity_names = [e["name"] for e in entities]
|
|
||||||
assert "Docker" in entity_names
|
|
||||||
assert "Kubernetes" in entity_names
|
|
||||||
assert "PostgreSQL" in entity_names
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_mentions_relationships(self, graph_service, test_entities):
|
|
||||||
"""Test creating MENTIONS relationships."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_label
|
|
||||||
|
|
||||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
|
||||||
|
|
||||||
# Create a test document node
|
|
||||||
doc_query = f"""
|
|
||||||
CREATE (d:{user_doc_label}:Document {{
|
|
||||||
page_id: 9999,
|
|
||||||
title: 'Test Doc',
|
|
||||||
path: 'users/test/doc'
|
|
||||||
}})
|
|
||||||
RETURN d
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(doc_query)
|
|
||||||
|
|
||||||
# Create MENTIONS relationships
|
|
||||||
found_entities = [
|
|
||||||
{"name": "Docker"},
|
|
||||||
{"name": "Kubernetes"}
|
|
||||||
]
|
|
||||||
|
|
||||||
new_links = await graph_service.create_entity_mentions(
|
|
||||||
page_id=9999,
|
|
||||||
user=TEST_USER,
|
|
||||||
entity_names=found_entities
|
|
||||||
)
|
|
||||||
|
|
||||||
assert new_links == 2
|
|
||||||
|
|
||||||
# Verify relationships exist
|
|
||||||
verify_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})-[r:MENTIONS]->(e)
|
|
||||||
RETURN count(r) as mention_count
|
|
||||||
"""
|
|
||||||
result = await graph_service.neo4j.execute_query(verify_query)
|
|
||||||
assert result[0]["mention_count"] == 2
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
cleanup_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: 9999}})
|
|
||||||
DETACH DELETE d
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(cleanup_query)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idempotency(self, graph_service):
|
|
||||||
"""Test that entity linking is idempotent."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_label, get_neo4j_user_base_label
|
|
||||||
|
|
||||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
|
||||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
|
||||||
|
|
||||||
# Aggressively clean up ALL test data first (fresh start)
|
|
||||||
cleanup_all = f"""
|
|
||||||
MATCH (n)
|
|
||||||
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
|
||||||
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
|
||||||
DETACH DELETE n
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(cleanup_all)
|
|
||||||
|
|
||||||
# Create a unique test entity
|
|
||||||
entity_query = f"""
|
|
||||||
CREATE (e:{user_base_label}:Technology {{name: 'TestDockerEntity', type: 'technology'}})
|
|
||||||
RETURN e
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(entity_query)
|
|
||||||
|
|
||||||
# Create test document
|
|
||||||
doc_query = f"""
|
|
||||||
CREATE (d:{user_doc_label}:Document {{
|
|
||||||
page_id: 9998,
|
|
||||||
title: 'Test Doc 2',
|
|
||||||
path: 'users/test/doc2'
|
|
||||||
}})
|
|
||||||
RETURN d
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(doc_query)
|
|
||||||
|
|
||||||
found_entities = [{"name": "TestDockerEntity"}]
|
|
||||||
|
|
||||||
# Link once
|
|
||||||
first_run = await graph_service.create_entity_mentions(
|
|
||||||
page_id=9998,
|
|
||||||
user=TEST_USER,
|
|
||||||
entity_names=found_entities
|
|
||||||
)
|
|
||||||
assert first_run == 1
|
|
||||||
|
|
||||||
# Link again - should not create duplicates
|
|
||||||
second_run = await graph_service.create_entity_mentions(
|
|
||||||
page_id=9998,
|
|
||||||
user=TEST_USER,
|
|
||||||
entity_names=found_entities
|
|
||||||
)
|
|
||||||
assert second_run == 0 # No new links
|
|
||||||
|
|
||||||
# Verify only one relationship exists
|
|
||||||
verify_query = f"""
|
|
||||||
MATCH (d:{user_doc_label}:Document {{page_id: 9998}})-[r:MENTIONS]->()
|
|
||||||
RETURN count(r) as mention_count
|
|
||||||
"""
|
|
||||||
result = await graph_service.neo4j.execute_query(verify_query)
|
|
||||||
assert result[0]["mention_count"] == 1
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
cleanup_query = f"""
|
|
||||||
MATCH (n)
|
|
||||||
WHERE (n:{user_base_label} OR n:{user_doc_label})
|
|
||||||
AND (n.page_id = 9998 OR n.name = 'TestDockerEntity')
|
|
||||||
DETACH DELETE n
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(cleanup_query)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Multi-Tenancy Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestEntityLinkingMultiTenancy:
|
|
||||||
"""Test multi-tenancy isolation in entity linking."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_user_isolation(self, graph_service):
|
|
||||||
"""Test that entities are isolated by user."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
user1_label = get_neo4j_user_base_label("user1")
|
|
||||||
user2_label = get_neo4j_user_base_label("user2")
|
|
||||||
|
|
||||||
# Create entity for user1
|
|
||||||
create_user1 = f"""
|
|
||||||
CREATE (e:{user1_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
||||||
RETURN e
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(create_user1)
|
|
||||||
|
|
||||||
# Create entity for user2
|
|
||||||
create_user2 = f"""
|
|
||||||
CREATE (e:{user2_label}:Technology {{name: 'Docker', type: 'technology'}})
|
|
||||||
RETURN e
|
|
||||||
"""
|
|
||||||
await graph_service.neo4j.execute_query(create_user2)
|
|
||||||
|
|
||||||
# Get entities for user1 - should only see user1's entities
|
|
||||||
entities_user1 = await get_entities_with_paths(graph_service, "user1")
|
|
||||||
entity_names_user1 = [e["name"] for e in entities_user1]
|
|
||||||
|
|
||||||
# Verify isolation
|
|
||||||
assert "Docker" in entity_names_user1
|
|
||||||
# We can't verify the exact count without knowing what else is in the DB,
|
|
||||||
# but we verified we can retrieve entities for user1
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
await graph_service.neo4j.execute_query(f"MATCH (e:{user1_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
|
||||||
await graph_service.neo4j.execute_query(f"MATCH (e:{user2_label}) WHERE e.name = 'Docker' DETACH DELETE e")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Cleanup
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_entity_linking_test_data(neo4j_client):
|
|
||||||
"""Clean up all test data created by entity linking tests."""
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
|
|
||||||
for user in [TEST_USER, "user1", "user2"]:
|
|
||||||
user_label = get_neo4j_user_base_label(user)
|
|
||||||
|
|
||||||
cleanup_query = f"""
|
|
||||||
MATCH (n:{user_label})
|
|
||||||
WHERE n.page_id IN [9999, 9998]
|
|
||||||
OR n.name IN ['Docker', 'Kubernetes', 'PostgreSQL']
|
|
||||||
DETACH DELETE n
|
|
||||||
"""
|
|
||||||
await neo4j_client.execute_query(cleanup_query)
|
|
||||||
|
|
||||||
print(f"\n✓ Cleaned up entity linking test data")
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for GraphService - knowledge graph operations.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Document node creation with tags
|
|
||||||
- Entity-stub page skipping
|
|
||||||
- Entity extraction
|
|
||||||
|
|
||||||
Run with: pytest tests/test_graph_service.py -v -s
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
|
|
||||||
|
|
||||||
# Test constants
|
|
||||||
TEST_USER = "graph-tester"
|
|
||||||
TEST_PAGE_ID = 123
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_neo4j():
|
|
||||||
"""Mock Neo4j client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.execute_query = AsyncMock(return_value=[{"d": {"page_id": TEST_PAGE_ID}}])
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_wiki():
|
|
||||||
"""Mock Wiki.js client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def graph_service(mock_neo4j, mock_wiki):
|
|
||||||
"""Get GraphService with mocked dependencies."""
|
|
||||||
return GraphService(
|
|
||||||
neo4j_client=mock_neo4j,
|
|
||||||
wikijs_client=mock_wiki
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_page():
|
|
||||||
"""Sample wiki page data."""
|
|
||||||
return {
|
|
||||||
"id": TEST_PAGE_ID,
|
|
||||||
"title": "Test Page",
|
|
||||||
"path": f"users/{TEST_USER}/technology/docker",
|
|
||||||
"content": "Docker is a containerization platform. It uses containers to run applications.",
|
|
||||||
"tags": ["technology", "docker", "containers"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_page_without_tags():
|
|
||||||
"""Sample wiki page without tags."""
|
|
||||||
return {
|
|
||||||
"id": TEST_PAGE_ID,
|
|
||||||
"title": "Test Page No Tags",
|
|
||||||
"path": f"users/{TEST_USER}/misc/test",
|
|
||||||
"content": "This is a test page with no tags.",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentNodeCreation:
|
|
||||||
"""Test Document node creation in Neo4j."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_document_node_includes_tags(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_wiki,
|
|
||||||
sample_page
|
|
||||||
):
|
|
||||||
"""Test that Document node is created with tags property."""
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=sample_page)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify execute_query was called
|
|
||||||
assert mock_neo4j.execute_query.called
|
|
||||||
assert result.success is True
|
|
||||||
|
|
||||||
# Find the document creation query
|
|
||||||
calls = mock_neo4j.execute_query.call_args_list
|
|
||||||
doc_creation_call = None
|
|
||||||
for call in calls:
|
|
||||||
query = call[0][0] if call[0] else ""
|
|
||||||
if "MERGE" in query and "Document" in query and "tags" in query:
|
|
||||||
doc_creation_call = call
|
|
||||||
break
|
|
||||||
|
|
||||||
assert doc_creation_call is not None, "Document creation query with tags not found"
|
|
||||||
|
|
||||||
# Verify tags are in the query parameters
|
|
||||||
params = doc_creation_call[0][1] if len(doc_creation_call[0]) > 1 else {}
|
|
||||||
assert "tags" in params
|
|
||||||
assert params["tags"] == ["technology", "docker", "containers"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_document_node_with_empty_tags(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_wiki,
|
|
||||||
sample_page_without_tags
|
|
||||||
):
|
|
||||||
"""Test Document node creation with empty tags list."""
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=sample_page_without_tags)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
|
|
||||||
# Find the document creation query
|
|
||||||
calls = mock_neo4j.execute_query.call_args_list
|
|
||||||
doc_creation_call = None
|
|
||||||
for call in calls:
|
|
||||||
query = call[0][0] if call[0] else ""
|
|
||||||
if "MERGE" in query and "Document" in query:
|
|
||||||
doc_creation_call = call
|
|
||||||
break
|
|
||||||
|
|
||||||
assert doc_creation_call is not None
|
|
||||||
params = doc_creation_call[0][1] if len(doc_creation_call[0]) > 1 else {}
|
|
||||||
assert "tags" in params
|
|
||||||
assert params["tags"] == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestEntityStubSkipping:
|
|
||||||
"""Test that entity-stub pages are skipped."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skip_entity_stub_pages(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_wiki
|
|
||||||
):
|
|
||||||
"""Test that entity-stub tagged pages skip entity extraction."""
|
|
||||||
stub_page = {
|
|
||||||
"id": TEST_PAGE_ID,
|
|
||||||
"title": "Auto Entity",
|
|
||||||
"path": f"users/{TEST_USER}/entities/test",
|
|
||||||
"content": "Auto-generated content.",
|
|
||||||
"tags": ["entity-stub", "auto-generated"]
|
|
||||||
}
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=stub_page)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return success but skip processing
|
|
||||||
assert result.success is True
|
|
||||||
# Neo4j should NOT be called for entity-stub pages
|
|
||||||
assert mock_neo4j.execute_query.call_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_skip_auto_generated_pages(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_wiki
|
|
||||||
):
|
|
||||||
"""Test that auto-generated tagged pages skip entity extraction."""
|
|
||||||
auto_page = {
|
|
||||||
"id": TEST_PAGE_ID,
|
|
||||||
"title": "Auto Page",
|
|
||||||
"path": f"users/{TEST_USER}/auto/test",
|
|
||||||
"content": "Auto-generated content.",
|
|
||||||
"tags": ["auto-generated"]
|
|
||||||
}
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=auto_page)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
assert mock_neo4j.execute_query.call_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestPageNotFound:
|
|
||||||
"""Test handling of missing pages."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_page_not_found_returns_failure(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_wiki
|
|
||||||
):
|
|
||||||
"""Test that missing page returns failure result."""
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=999,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# The service catches the exception and returns a failed result
|
|
||||||
assert result.success is False
|
|
||||||
assert result.error_message is not None
|
|
||||||
assert "not found" in result.error_message.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestEntityExtraction:
|
|
||||||
"""Test entity extraction from page content."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_creates_document_and_entities(
|
|
||||||
self,
|
|
||||||
graph_service,
|
|
||||||
mock_neo4j,
|
|
||||||
mock_wiki,
|
|
||||||
sample_page
|
|
||||||
):
|
|
||||||
"""Test that document and entity nodes are created."""
|
|
||||||
mock_wiki.get_page = AsyncMock(return_value=sample_page)
|
|
||||||
|
|
||||||
result = await graph_service.update_from_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
# Should have called neo4j at least once (for document node)
|
|
||||||
assert mock_neo4j.execute_query.called
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v", "-s"])
|
|
||||||
@@ -1,711 +0,0 @@
|
|||||||
"""
|
|
||||||
Comprehensive tests for HybridRAG system.
|
|
||||||
|
|
||||||
Tests cover all 6 phases:
|
|
||||||
- Phase 0: Query Enhancement (keyword/synonym extraction)
|
|
||||||
- Phase 1: Parallel Retrieval (vector + graph + web)
|
|
||||||
- Phase 2: RRF Fusion
|
|
||||||
- Phase 3: Enrichment (related dossiers)
|
|
||||||
- Phase 4: LLM Re-ranking
|
|
||||||
- Phase 5: Context Formatting
|
|
||||||
- Phase 6: Persistence (search storage)
|
|
||||||
|
|
||||||
Uses 'llm-tester' user to avoid contaminating production data.
|
|
||||||
|
|
||||||
Run with: pytest tests/test_hybrid_rag.py -v -s
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
import json
|
|
||||||
|
|
||||||
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.services.hybrid_rag_service import HybridRAGService
|
|
||||||
from src.services.vector_service import VectorService
|
|
||||||
from src.services.graph_service import GraphService
|
|
||||||
from src.models.hybrid_rag import HybridRAGConfig, HybridRAGRequest
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
# Test user to isolate test data
|
|
||||||
TEST_USER = "llm-tester"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def settings():
|
|
||||||
"""Get application settings."""
|
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
|
||||||
"""Get connected Neo4j client."""
|
|
||||||
client = Neo4jClient(
|
|
||||||
uri=settings.neo4j_uri,
|
|
||||||
user=settings.neo4j_user,
|
|
||||||
password=settings.neo4j_password
|
|
||||||
)
|
|
||||||
await client.connect()
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
|
||||||
"""Get Qdrant client."""
|
|
||||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
|
||||||
"""Get Wiki.js client."""
|
|
||||||
client = WikiJSClient(
|
|
||||||
base_url=settings.wikijs_url,
|
|
||||||
username=settings.wikijs_username,
|
|
||||||
password=settings.wikijs_password
|
|
||||||
)
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def searxng_client(settings) -> SearXNGClient:
|
|
||||||
"""Get SearXNG client."""
|
|
||||||
return SearXNGClient(base_url=settings.searxng_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ollama_client(settings) -> OllamaClient:
|
|
||||||
"""Get Ollama client."""
|
|
||||||
return OllamaClient(base_url=settings.ollama_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def vector_service(qdrant_client, wiki_client, ollama_client):
|
|
||||||
"""Get VectorService instance."""
|
|
||||||
return VectorService(qdrant_client, wiki_client, ollama_client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def graph_service(neo4j_client, wiki_client):
|
|
||||||
"""Get GraphService instance."""
|
|
||||||
return GraphService(neo4j_client, wiki_client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def hybrid_rag_service(
|
|
||||||
vector_service,
|
|
||||||
graph_service,
|
|
||||||
searxng_client,
|
|
||||||
ollama_client,
|
|
||||||
settings
|
|
||||||
):
|
|
||||||
"""Get HybridRAGService instance."""
|
|
||||||
return HybridRAGService(
|
|
||||||
vector_service=vector_service,
|
|
||||||
graph_service=graph_service,
|
|
||||||
searxng_client=searxng_client,
|
|
||||||
ollama_client=ollama_client,
|
|
||||||
settings=settings
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def test_wiki_page(wiki_client):
|
|
||||||
"""
|
|
||||||
Create test wiki page for llm-tester user.
|
|
||||||
|
|
||||||
Creates a page about Docker and Kubernetes for testing.
|
|
||||||
"""
|
|
||||||
from src.core.multi_tenancy import get_wikijs_namespace
|
|
||||||
|
|
||||||
namespace = get_wikijs_namespace(TEST_USER)
|
|
||||||
path = f"{namespace}/testing/docker-kubernetes"
|
|
||||||
|
|
||||||
# Create test page
|
|
||||||
page_data = {
|
|
||||||
"title": "Docker and Kubernetes Testing",
|
|
||||||
"path": path,
|
|
||||||
"content": """# Docker and Kubernetes
|
|
||||||
|
|
||||||
Docker is a containerization platform that packages applications into containers.
|
|
||||||
Kubernetes (k8s) is an orchestration platform for managing Docker containers at scale.
|
|
||||||
|
|
||||||
## Key Technologies
|
|
||||||
- Docker: Container runtime
|
|
||||||
- Kubernetes: Orchestration platform
|
|
||||||
- Helm: Package manager for Kubernetes
|
|
||||||
- kubectl: Command-line tool for k8s
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
Our infrastructure uses Docker containers orchestrated by Kubernetes clusters.
|
|
||||||
We deploy microservices using Helm charts and manage them with kubectl.
|
|
||||||
""",
|
|
||||||
"description": "Test page for HybridRAG testing",
|
|
||||||
"tags": ["testing", "infrastructure", "docker"]
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Delete if exists
|
|
||||||
existing = await wiki_client.search_pages(query="Docker and Kubernetes Testing")
|
|
||||||
for page in existing:
|
|
||||||
if page.get("path") == path:
|
|
||||||
await wiki_client.delete_page(page["id"])
|
|
||||||
|
|
||||||
# Create new
|
|
||||||
page = await wiki_client.create_page(**page_data)
|
|
||||||
yield page
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
try:
|
|
||||||
await wiki_client.delete_page(page["id"])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
pytest.skip(f"Could not create test page: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def test_graph_data(graph_service, test_wiki_page):
|
|
||||||
"""
|
|
||||||
Populate graph with test data for llm-tester.
|
|
||||||
|
|
||||||
Extracts entities from test page.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await graph_service.update_from_page(
|
|
||||||
page_id=test_wiki_page["id"],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
yield summary
|
|
||||||
except Exception as e:
|
|
||||||
pytest.skip(f"Could not populate graph: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def test_vector_data(vector_service, test_wiki_page):
|
|
||||||
"""
|
|
||||||
Populate vector DB with test data for llm-tester.
|
|
||||||
|
|
||||||
Creates embeddings from test page.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
summary = await vector_service.update_from_page(
|
|
||||||
page_id=test_wiki_page["id"],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
yield summary
|
|
||||||
except Exception as e:
|
|
||||||
pytest.skip(f"Could not populate vectors: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Unit Tests - Individual Components
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestRRFFusion:
|
|
||||||
"""Test 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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
|
||||||
|
|
||||||
assert len(fused) == 2
|
|
||||||
assert fused[0]["rrf_score"] > fused[1]["rrf_score"] # Rank 1 > Rank 2
|
|
||||||
assert fused[0]["sources"] == ["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": ""}],
|
|
||||||
}
|
|
||||||
|
|
||||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, 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)
|
|
||||||
expected_score = 1/61 + 1/61
|
|
||||||
assert abs(fused[0]["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"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
|
|
||||||
|
|
||||||
assert len(fused) == 2
|
|
||||||
assert fused[0]["result"]["url"] == "https://example.com/1"
|
|
||||||
|
|
||||||
|
|
||||||
class TestContextFormatting:
|
|
||||||
"""Test context formatting for LLM."""
|
|
||||||
|
|
||||||
def test_format_basic(self, hybrid_rag_service):
|
|
||||||
"""Test basic context formatting."""
|
|
||||||
from src.models.hybrid_rag import HybridRAGResult
|
|
||||||
|
|
||||||
results = [
|
|
||||||
HybridRAGResult(
|
|
||||||
source_type="vector",
|
|
||||||
title="Test Document",
|
|
||||||
content="This is test content for formatting",
|
|
||||||
page_id=1,
|
|
||||||
rrf_score=0.5,
|
|
||||||
final_rank=1,
|
|
||||||
sources=["vector"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
context = hybrid_rag_service._format_context_for_llm(results)
|
|
||||||
|
|
||||||
assert "Test Document" in context
|
|
||||||
assert "[VECTOR]" in context
|
|
||||||
assert "test content" in context
|
|
||||||
|
|
||||||
def test_format_with_related_dossiers(self, hybrid_rag_service):
|
|
||||||
"""Test context formatting with related dossiers."""
|
|
||||||
from src.models.hybrid_rag import HybridRAGResult, RelatedDossier
|
|
||||||
|
|
||||||
results = [
|
|
||||||
HybridRAGResult(
|
|
||||||
source_type="vector+graph",
|
|
||||||
title="Test Document",
|
|
||||||
content="Content",
|
|
||||||
page_id=1,
|
|
||||||
rrf_score=0.5,
|
|
||||||
final_rank=1,
|
|
||||||
sources=["vector", "graph"],
|
|
||||||
related_dossiers=[
|
|
||||||
RelatedDossier(
|
|
||||||
page_id=2,
|
|
||||||
title="Related Doc",
|
|
||||||
path="/test/related",
|
|
||||||
tag="infrastructure",
|
|
||||||
shared_entities=5
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
context = hybrid_rag_service._format_context_for_llm(results)
|
|
||||||
|
|
||||||
assert "Related research: infrastructure" in context
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Integration Tests - Phase Testing
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestPhase0_QueryEnhancement:
|
|
||||||
"""Test Phase 0: Query Enhancement (keyword/synonym extraction)."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extract_keywords_basic(self, hybrid_rag_service):
|
|
||||||
"""Test basic keyword extraction."""
|
|
||||||
query = "Docker container orchestration with Kubernetes"
|
|
||||||
|
|
||||||
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
|
|
||||||
|
|
||||||
assert "core_keywords" in keywords_data
|
|
||||||
assert "entities" in keywords_data
|
|
||||||
assert "synonyms" in keywords_data
|
|
||||||
assert "expansions" in keywords_data
|
|
||||||
|
|
||||||
# Should extract Docker and Kubernetes
|
|
||||||
all_terms = (
|
|
||||||
keywords_data["core_keywords"] +
|
|
||||||
keywords_data["entities"]
|
|
||||||
)
|
|
||||||
assert any("docker" in term.lower() for term in all_terms)
|
|
||||||
assert any("kubernetes" in term.lower() or "k8s" in term.lower() for term in all_terms)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extract_keywords_with_abbreviations(self, hybrid_rag_service):
|
|
||||||
"""Test keyword extraction handles abbreviations."""
|
|
||||||
query = "k8s cluster management"
|
|
||||||
|
|
||||||
keywords_data = await hybrid_rag_service._extract_keywords_and_synonyms(query)
|
|
||||||
|
|
||||||
# Should expand k8s to kubernetes
|
|
||||||
all_data = json.dumps(keywords_data).lower()
|
|
||||||
assert "k8s" in all_data or "kubernetes" in all_data
|
|
||||||
|
|
||||||
|
|
||||||
class TestPhase1_ParallelRetrieval:
|
|
||||||
"""Test Phase 1: Parallel Retrieval."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_parallel_retrieval_all_sources(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
test_wiki_page,
|
|
||||||
test_graph_data,
|
|
||||||
test_vector_data
|
|
||||||
):
|
|
||||||
"""Test parallel retrieval from all sources."""
|
|
||||||
config = HybridRAGConfig(
|
|
||||||
enable_vector=True,
|
|
||||||
enable_graph=True,
|
|
||||||
enable_web=True,
|
|
||||||
vector_limit=5,
|
|
||||||
graph_limit=5,
|
|
||||||
web_limit=3
|
|
||||||
)
|
|
||||||
|
|
||||||
keywords_data = {
|
|
||||||
"core_keywords": ["docker", "kubernetes"],
|
|
||||||
"entities": ["Docker", "Kubernetes"],
|
|
||||||
"synonyms": {"docker": ["container"], "kubernetes": ["k8s"]},
|
|
||||||
"expansions": {"k8s": ["kubernetes"]}
|
|
||||||
}
|
|
||||||
|
|
||||||
results = await hybrid_rag_service._retrieve_parallel(
|
|
||||||
query="docker kubernetes",
|
|
||||||
user=TEST_USER,
|
|
||||||
config=config,
|
|
||||||
keywords_data=keywords_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "vector" in results
|
|
||||||
assert "graph" in results
|
|
||||||
assert "web" in results
|
|
||||||
assert "timing" in results
|
|
||||||
|
|
||||||
# Should have timing for each source
|
|
||||||
assert results["timing"]["vector_ms"] >= 0
|
|
||||||
assert results["timing"]["graph_ms"] >= 0
|
|
||||||
assert results["timing"]["web_ms"] >= 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_parallel_retrieval_graceful_degradation(self, hybrid_rag_service):
|
|
||||||
"""Test graceful degradation when sources fail."""
|
|
||||||
config = HybridRAGConfig(
|
|
||||||
enable_vector=True,
|
|
||||||
enable_graph=True,
|
|
||||||
enable_web=True
|
|
||||||
)
|
|
||||||
|
|
||||||
keywords_data = {"core_keywords": ["test"], "entities": [], "synonyms": {}, "expansions": {}}
|
|
||||||
|
|
||||||
# Even if some sources fail, should return results from working sources
|
|
||||||
results = await hybrid_rag_service._retrieve_parallel(
|
|
||||||
query="test query",
|
|
||||||
user=TEST_USER,
|
|
||||||
config=config,
|
|
||||||
keywords_data=keywords_data
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should have all keys even if empty
|
|
||||||
assert "vector" in results
|
|
||||||
assert "graph" in results
|
|
||||||
assert "web" in results
|
|
||||||
|
|
||||||
|
|
||||||
class TestPhase3_Enrichment:
|
|
||||||
"""Test Phase 3: Graph Enrichment."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enrich_with_related_dossiers(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
graph_service,
|
|
||||||
test_wiki_page,
|
|
||||||
test_graph_data
|
|
||||||
):
|
|
||||||
"""Test enriching results with related dossiers."""
|
|
||||||
# Create mock fused results
|
|
||||||
fused_results = [
|
|
||||||
{
|
|
||||||
"result": {
|
|
||||||
"page_id": test_wiki_page["id"],
|
|
||||||
"title": test_wiki_page["title"],
|
|
||||||
"content": "test"
|
|
||||||
},
|
|
||||||
"rrf_score": 0.5,
|
|
||||||
"sources": ["vector"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
enriched = await hybrid_rag_service._enrich_with_related_dossiers(
|
|
||||||
fused_results,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(enriched) == 1
|
|
||||||
assert "related_dossiers" in enriched[0]
|
|
||||||
# May or may not have related docs depending on graph state
|
|
||||||
assert isinstance(enriched[0]["related_dossiers"], list)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPhase6_Persistence:
|
|
||||||
"""Test Phase 6: Search Persistence."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_search_creates_node(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
neo4j_client,
|
|
||||||
test_wiki_page
|
|
||||||
):
|
|
||||||
"""Test that search persistence creates SearchQuery node."""
|
|
||||||
keywords_data = {
|
|
||||||
"core_keywords": ["docker", "kubernetes"],
|
|
||||||
"entities": [],
|
|
||||||
"synonyms": {},
|
|
||||||
"expansions": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
raw_results = {
|
|
||||||
"vector": [{"page_id": test_wiki_page["id"], "title": "Test", "content": "test"}],
|
|
||||||
"graph": [],
|
|
||||||
"web": []
|
|
||||||
}
|
|
||||||
|
|
||||||
final_results = [
|
|
||||||
{
|
|
||||||
"result": {"page_id": test_wiki_page["id"], "title": "Test"},
|
|
||||||
"rrf_score": 0.5,
|
|
||||||
"final_rank": 1,
|
|
||||||
"sources": ["vector"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
timing = {"total_ms": 1000}
|
|
||||||
|
|
||||||
search_id = await hybrid_rag_service._persist_search_for_librarian(
|
|
||||||
query="test query",
|
|
||||||
user=TEST_USER,
|
|
||||||
keywords_data=keywords_data,
|
|
||||||
raw_results=raw_results,
|
|
||||||
final_results=final_results,
|
|
||||||
timing=timing
|
|
||||||
)
|
|
||||||
|
|
||||||
assert search_id is not None
|
|
||||||
|
|
||||||
# Verify SearchQuery node was created
|
|
||||||
from src.core.multi_tenancy import get_neo4j_user_base_label
|
|
||||||
user_label = get_neo4j_user_base_label(TEST_USER)
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
|
||||||
RETURN sq.query as query, sq.processed as processed
|
|
||||||
"""
|
|
||||||
|
|
||||||
result = await neo4j_client.execute_query(query, {"search_id": search_id})
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["query"] == "test query"
|
|
||||||
assert result[0]["processed"] == False
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
cleanup_query = f"""
|
|
||||||
MATCH (sq:{user_label}_SearchQuery:SearchQuery {{id: $search_id}})
|
|
||||||
DETACH DELETE sq
|
|
||||||
"""
|
|
||||||
await neo4j_client.execute_query(cleanup_query, {"search_id": search_id})
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# End-to-End Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class TestHybridRAG_EndToEnd:
|
|
||||||
"""End-to-end tests for complete HybridRAG flow."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_search_pipeline(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
test_wiki_page,
|
|
||||||
test_graph_data,
|
|
||||||
test_vector_data
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Test complete HybridRAG search pipeline with all 6 phases.
|
|
||||||
|
|
||||||
This is the main end-to-end test that validates:
|
|
||||||
- Phase 0: Query enhancement
|
|
||||||
- Phase 1: Parallel retrieval
|
|
||||||
- Phase 2: RRF fusion
|
|
||||||
- Phase 3: Enrichment
|
|
||||||
- Phase 4: Re-ranking
|
|
||||||
- Phase 5: Context formatting
|
|
||||||
- Phase 6: Persistence
|
|
||||||
"""
|
|
||||||
query = "How does Docker work with Kubernetes?"
|
|
||||||
config = HybridRAGConfig(
|
|
||||||
vector_limit=5,
|
|
||||||
graph_limit=5,
|
|
||||||
web_limit=3,
|
|
||||||
enable_reranking=True,
|
|
||||||
enable_enrichment=True,
|
|
||||||
final_result_count=10
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute full search
|
|
||||||
response = await hybrid_rag_service.search(
|
|
||||||
query=query,
|
|
||||||
user=TEST_USER,
|
|
||||||
config=config
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate response structure
|
|
||||||
assert response.query == query
|
|
||||||
assert response.keywords is not None
|
|
||||||
assert response.results is not None
|
|
||||||
assert response.context is not None
|
|
||||||
assert response.source_counts is not None
|
|
||||||
assert response.total_results >= 0
|
|
||||||
assert response.timing is not None
|
|
||||||
assert response.config_used == config
|
|
||||||
assert response.search_id is not None
|
|
||||||
|
|
||||||
# Validate timing breakdown
|
|
||||||
assert response.timing.query_enhancement_ms >= 0
|
|
||||||
assert response.timing.vector_ms >= 0
|
|
||||||
assert response.timing.graph_ms >= 0
|
|
||||||
assert response.timing.web_ms >= 0
|
|
||||||
assert response.timing.fusion_ms >= 0
|
|
||||||
assert response.timing.enrichment_ms >= 0
|
|
||||||
assert response.timing.reranking_ms >= 0
|
|
||||||
assert response.timing.persistence_ms >= 0
|
|
||||||
assert response.timing.total_ms >= 0
|
|
||||||
|
|
||||||
# Validate keywords extraction
|
|
||||||
assert len(response.keywords.core_keywords) > 0
|
|
||||||
|
|
||||||
# Validate context is formatted
|
|
||||||
assert len(response.context) > 0
|
|
||||||
|
|
||||||
# Log results for inspection
|
|
||||||
print(f"\n=== HybridRAG E2E Test Results ===")
|
|
||||||
print(f"Query: {response.query}")
|
|
||||||
print(f"Total Results: {response.total_results}")
|
|
||||||
print(f"Source Counts: {response.source_counts}")
|
|
||||||
print(f"Keywords: {response.keywords.core_keywords}")
|
|
||||||
print(f"Total Time: {response.timing.total_ms:.0f}ms")
|
|
||||||
print(f"Search ID: {response.search_id}")
|
|
||||||
|
|
||||||
if response.results:
|
|
||||||
print(f"\nTop Result:")
|
|
||||||
top = response.results[0]
|
|
||||||
print(f" Title: {top.title}")
|
|
||||||
print(f" Source: {top.source_type}")
|
|
||||||
print(f" RRF Score: {top.rrf_score:.4f}")
|
|
||||||
print(f" Rank: {top.final_rank}")
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_with_disabled_sources(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
test_wiki_page,
|
|
||||||
test_vector_data
|
|
||||||
):
|
|
||||||
"""Test HybridRAG with some sources disabled."""
|
|
||||||
config = HybridRAGConfig(
|
|
||||||
enable_vector=True,
|
|
||||||
enable_graph=False, # Disabled
|
|
||||||
enable_web=False, # Disabled
|
|
||||||
enable_reranking=False,
|
|
||||||
final_result_count=5
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await hybrid_rag_service.search(
|
|
||||||
query="docker containers",
|
|
||||||
user=TEST_USER,
|
|
||||||
config=config
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should only have vector results
|
|
||||||
assert response.total_results >= 0
|
|
||||||
if response.total_results > 0:
|
|
||||||
assert all(
|
|
||||||
"vector" in result.sources
|
|
||||||
for result in response.results
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_performance_target(
|
|
||||||
self,
|
|
||||||
hybrid_rag_service,
|
|
||||||
test_wiki_page,
|
|
||||||
test_graph_data,
|
|
||||||
test_vector_data
|
|
||||||
):
|
|
||||||
"""Test that search completes within performance target (<3.5s)."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
config = HybridRAGConfig()
|
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
response = await hybrid_rag_service.search(
|
|
||||||
query="kubernetes orchestration",
|
|
||||||
user=TEST_USER,
|
|
||||||
config=config
|
|
||||||
)
|
|
||||||
duration_ms = (time.time() - start) * 1000
|
|
||||||
|
|
||||||
print(f"\nPerformance: {duration_ms:.0f}ms (target: <3500ms)")
|
|
||||||
|
|
||||||
# Soft assertion - warn if exceeds target
|
|
||||||
if duration_ms > 3500:
|
|
||||||
print(f"WARNING: Search exceeded 3.5s target ({duration_ms:.0f}ms)")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Cleanup Tests
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cleanup_test_data(neo4j_client, qdrant_client):
|
|
||||||
"""
|
|
||||||
Cleanup test data for llm-tester user.
|
|
||||||
|
|
||||||
Run this to clean up test data:
|
|
||||||
pytest tests/test_hybrid_rag.py::test_cleanup_test_data -v -s
|
|
||||||
"""
|
|
||||||
from src.core.multi_tenancy import (
|
|
||||||
get_neo4j_user_base_label,
|
|
||||||
get_neo4j_user_label,
|
|
||||||
get_qdrant_collection_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean Neo4j
|
|
||||||
user_base_label = get_neo4j_user_base_label(TEST_USER)
|
|
||||||
user_doc_label = get_neo4j_user_label(TEST_USER)
|
|
||||||
|
|
||||||
# Delete all test user nodes
|
|
||||||
delete_query = f"""
|
|
||||||
MATCH (n)
|
|
||||||
WHERE n:{user_base_label} OR n:{user_doc_label}
|
|
||||||
DETACH DELETE n
|
|
||||||
"""
|
|
||||||
await neo4j_client.execute_query(delete_query, {})
|
|
||||||
|
|
||||||
# Clean Qdrant
|
|
||||||
collection_name = get_qdrant_collection_name(TEST_USER)
|
|
||||||
try:
|
|
||||||
await qdrant_client.delete_collection(collection_name)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print(f"\n✓ Cleaned up test data for user: {TEST_USER}")
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for IngestionService - document ingestion operations.
|
|
||||||
|
|
||||||
Tests cover:
|
|
||||||
- Single page ingestion
|
|
||||||
- Batch ingestion
|
|
||||||
- Full re-index (ingest_all_pages)
|
|
||||||
- list_all_pages usage
|
|
||||||
|
|
||||||
Run with: pytest tests/test_ingestion.py -v -s
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
from src.services.ingestion_service import IngestionService
|
|
||||||
from src.models.ingestion import (
|
|
||||||
IngestionRequest,
|
|
||||||
IngestionResult,
|
|
||||||
BatchIngestionRequest,
|
|
||||||
BatchIngestionResult
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Test constants
|
|
||||||
TEST_USER = "ingestion-tester"
|
|
||||||
TEST_PAGE_ID = 456
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_vector_service():
|
|
||||||
"""Mock Vector service."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.update_from_page = AsyncMock(return_value=MagicMock(
|
|
||||||
chunks_created=2,
|
|
||||||
chunks_deleted=0,
|
|
||||||
success=True
|
|
||||||
))
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_graph_service():
|
|
||||||
"""Mock Graph service."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.update_from_page = AsyncMock(return_value=MagicMock(
|
|
||||||
entities_extracted=5,
|
|
||||||
relationships_created=5,
|
|
||||||
success=True
|
|
||||||
))
|
|
||||||
mock.create_entity_mention_links = AsyncMock(return_value=3)
|
|
||||||
mock.get_all_entities = AsyncMock(return_value=[])
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_wiki_client():
|
|
||||||
"""Mock Wiki.js client."""
|
|
||||||
mock = AsyncMock()
|
|
||||||
mock.get_page = AsyncMock(return_value={
|
|
||||||
"id": TEST_PAGE_ID,
|
|
||||||
"title": "Test Page",
|
|
||||||
"path": f"users/{TEST_USER}/test",
|
|
||||||
"content": "Test content here.",
|
|
||||||
"tags": ["test"]
|
|
||||||
})
|
|
||||||
mock.list_all_pages = AsyncMock(return_value=[
|
|
||||||
{"id": 1, "path": f"users/{TEST_USER}/page1", "title": "Page 1"},
|
|
||||||
{"id": 2, "path": f"users/{TEST_USER}/page2", "title": "Page 2"},
|
|
||||||
{"id": 3, "path": f"users/{TEST_USER}/page3", "title": "Page 3"},
|
|
||||||
])
|
|
||||||
return mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ingestion_service(mock_vector_service, mock_graph_service, mock_wiki_client):
|
|
||||||
"""Get IngestionService with mocked dependencies."""
|
|
||||||
return IngestionService(
|
|
||||||
vector_service=mock_vector_service,
|
|
||||||
graph_service=mock_graph_service,
|
|
||||||
wiki_client=mock_wiki_client
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSinglePageIngestion:
|
|
||||||
"""Test single page ingestion."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_page_success(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test successful page ingestion."""
|
|
||||||
result = await ingestion_service.ingest_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
assert result.page_id == TEST_PAGE_ID
|
|
||||||
mock_wiki_client.get_page.assert_called_once_with(TEST_PAGE_ID)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_page_not_found(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test ingestion when page not found."""
|
|
||||||
mock_wiki_client.get_page.return_value = None
|
|
||||||
|
|
||||||
result = await ingestion_service.ingest_page(
|
|
||||||
page_id=999,
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is False
|
|
||||||
assert "not found" in result.error.lower()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_page_skip_vectors(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_vector_service,
|
|
||||||
mock_graph_service
|
|
||||||
):
|
|
||||||
"""Test ingestion with vectors skipped."""
|
|
||||||
result = await ingestion_service.ingest_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER,
|
|
||||||
skip_vectors=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
# Vector service should not be called
|
|
||||||
mock_vector_service.update_from_page.assert_not_called()
|
|
||||||
# Graph service should still be called
|
|
||||||
mock_graph_service.update_from_page.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_page_skip_graph(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_vector_service,
|
|
||||||
mock_graph_service
|
|
||||||
):
|
|
||||||
"""Test ingestion with graph skipped."""
|
|
||||||
result = await ingestion_service.ingest_page(
|
|
||||||
page_id=TEST_PAGE_ID,
|
|
||||||
user=TEST_USER,
|
|
||||||
skip_graph=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.success is True
|
|
||||||
# Vector service should be called
|
|
||||||
mock_vector_service.update_from_page.assert_called_once()
|
|
||||||
# Graph service should not be called
|
|
||||||
mock_graph_service.update_from_page.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestBatchIngestion:
|
|
||||||
"""Test batch page ingestion."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_batch_success(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test successful batch ingestion."""
|
|
||||||
result = await ingestion_service.ingest_batch(
|
|
||||||
page_ids=[1, 2, 3],
|
|
||||||
user=TEST_USER,
|
|
||||||
max_concurrent=2
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.total_pages == 3
|
|
||||||
assert result.successful == 3
|
|
||||||
assert result.failed == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_batch_with_failures(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test batch ingestion with some failures."""
|
|
||||||
# Make page 2 not found
|
|
||||||
def get_page_side_effect(page_id):
|
|
||||||
if page_id == 2:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"id": page_id,
|
|
||||||
"title": f"Page {page_id}",
|
|
||||||
"path": f"users/{TEST_USER}/page{page_id}",
|
|
||||||
"content": "Content",
|
|
||||||
"tags": []
|
|
||||||
}
|
|
||||||
|
|
||||||
mock_wiki_client.get_page.side_effect = get_page_side_effect
|
|
||||||
|
|
||||||
result = await ingestion_service.ingest_batch(
|
|
||||||
page_ids=[1, 2, 3],
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.total_pages == 3
|
|
||||||
assert result.successful == 2
|
|
||||||
assert result.failed == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestIngestAllPages:
|
|
||||||
"""Test full re-index (ingest_all_pages)."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_all_uses_list_all_pages(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test that ingest_all_pages uses list_all_pages (not search)."""
|
|
||||||
result = await ingestion_service.ingest_all_pages(
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should use list_all_pages, not search_pages
|
|
||||||
mock_wiki_client.list_all_pages.assert_called_once()
|
|
||||||
# Should have processed 3 pages from the mock
|
|
||||||
assert result.total_pages == 3
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_all_with_path_prefix(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test ingest_all_pages with path prefix filter."""
|
|
||||||
await ingestion_service.ingest_all_pages(
|
|
||||||
user=TEST_USER,
|
|
||||||
path_prefix=f"users/{TEST_USER}/technology"
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_wiki_client.list_all_pages.assert_called_once_with(
|
|
||||||
path_prefix=f"users/{TEST_USER}/technology"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_all_empty_wiki(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test ingest_all_pages when no pages found."""
|
|
||||||
mock_wiki_client.list_all_pages.return_value = []
|
|
||||||
|
|
||||||
result = await ingestion_service.ingest_all_pages(
|
|
||||||
user=TEST_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.total_pages == 0
|
|
||||||
assert result.successful == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ingest_all_respects_max_concurrent(
|
|
||||||
self,
|
|
||||||
ingestion_service,
|
|
||||||
mock_wiki_client
|
|
||||||
):
|
|
||||||
"""Test that max_concurrent parameter is passed through."""
|
|
||||||
# Create many pages
|
|
||||||
mock_wiki_client.list_all_pages.return_value = [
|
|
||||||
{"id": i, "path": f"users/{TEST_USER}/page{i}", "title": f"Page {i}"}
|
|
||||||
for i in range(20)
|
|
||||||
]
|
|
||||||
|
|
||||||
result = await ingestion_service.ingest_all_pages(
|
|
||||||
user=TEST_USER,
|
|
||||||
max_concurrent=5
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.total_pages == 20
|
|
||||||
|
|
||||||
|
|
||||||
class TestIngestionModels:
|
|
||||||
"""Test ingestion request/response models."""
|
|
||||||
|
|
||||||
def test_ingestion_request_defaults(self):
|
|
||||||
"""Test IngestionRequest default values."""
|
|
||||||
request = IngestionRequest(
|
|
||||||
page_id=123,
|
|
||||||
user="testuser"
|
|
||||||
)
|
|
||||||
assert request.page_id == 123
|
|
||||||
assert request.user == "testuser"
|
|
||||||
assert request.force_refresh is False
|
|
||||||
assert request.skip_vectors is False
|
|
||||||
assert request.skip_graph is False
|
|
||||||
|
|
||||||
def test_batch_ingestion_request(self):
|
|
||||||
"""Test BatchIngestionRequest."""
|
|
||||||
request = BatchIngestionRequest(
|
|
||||||
page_ids=[1, 2, 3],
|
|
||||||
user="testuser",
|
|
||||||
max_concurrent=5
|
|
||||||
)
|
|
||||||
assert len(request.page_ids) == 3
|
|
||||||
assert request.max_concurrent == 5
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
pytest.main([__file__, "-v", "-s"])
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
"""Integration tests for Library Desk service clients.
|
|
||||||
|
|
||||||
These tests require actual service connectivity:
|
|
||||||
- Neo4j running at bolt://neo4j:7687
|
|
||||||
- Qdrant running at http://qdrant:6333
|
|
||||||
- Wiki.js running at http://wiki:3000
|
|
||||||
- SearXNG running at http://searxng:8080
|
|
||||||
- Ollama running at http://ollama:11434
|
|
||||||
|
|
||||||
Run with: pytest tests/test_integration.py -v
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from typing import AsyncGenerator
|
|
||||||
|
|
||||||
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.jobs.job_manager import JobManager, JobType, JobStatus
|
|
||||||
from src.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def settings():
|
|
||||||
"""Get application settings."""
|
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
|
|
||||||
"""Get connected Neo4j client."""
|
|
||||||
client = Neo4jClient(
|
|
||||||
uri=settings.neo4j_uri,
|
|
||||||
user=settings.neo4j_user,
|
|
||||||
password=settings.neo4j_password
|
|
||||||
)
|
|
||||||
await client.connect()
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def qdrant_client(settings) -> QdrantClientWrapper:
|
|
||||||
"""Get Qdrant client."""
|
|
||||||
return QdrantClientWrapper(url=settings.qdrant_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def wikijs_client(settings) -> AsyncGenerator[WikiJSClient, None]:
|
|
||||||
"""Get Wiki.js client."""
|
|
||||||
client = WikiJSClient(
|
|
||||||
base_url=settings.wikijs_url,
|
|
||||||
api_key=settings.wikijs_api_key
|
|
||||||
)
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def searxng_client(settings) -> AsyncGenerator[SearXNGClient, None]:
|
|
||||||
"""Get SearXNG client."""
|
|
||||||
client = SearXNGClient(base_url=settings.searxng_url)
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def ollama_client(settings) -> AsyncGenerator[OllamaClient, None]:
|
|
||||||
"""Get Ollama client."""
|
|
||||||
client = OllamaClient(
|
|
||||||
base_url=settings.ollama_url,
|
|
||||||
model=settings.ollama_model
|
|
||||||
)
|
|
||||||
yield client
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def job_manager(settings) -> AsyncGenerator[JobManager, None]:
|
|
||||||
"""Get job manager."""
|
|
||||||
manager = JobManager(redis_url=settings.redis_url)
|
|
||||||
await manager.connect()
|
|
||||||
yield manager
|
|
||||||
await manager.close()
|
|
||||||
|
|
||||||
|
|
||||||
class TestNeo4jIntegration:
|
|
||||||
"""Test Neo4j connectivity and basic operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connection(self, neo4j_client):
|
|
||||||
"""Test Neo4j connection."""
|
|
||||||
result = await neo4j_client.execute_query("RETURN 1 as test", {})
|
|
||||||
assert result
|
|
||||||
assert result[0]["test"] == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_and_get_document(self, neo4j_client, test_user):
|
|
||||||
"""Test creating and retrieving a document node."""
|
|
||||||
doc_id = "test_doc_integration"
|
|
||||||
|
|
||||||
# Create document
|
|
||||||
doc = await neo4j_client.create_document_node(
|
|
||||||
user=test_user,
|
|
||||||
doc_id=doc_id,
|
|
||||||
properties={
|
|
||||||
"title": "Integration Test Document",
|
|
||||||
"source": "test"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert doc is not None
|
|
||||||
assert doc["id"] == doc_id
|
|
||||||
|
|
||||||
# Retrieve document
|
|
||||||
retrieved = await neo4j_client.get_document_node(test_user, doc_id)
|
|
||||||
assert retrieved is not None
|
|
||||||
assert retrieved["id"] == doc_id
|
|
||||||
assert retrieved["title"] == "Integration Test Document"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
deleted = await neo4j_client.delete_document_node(test_user, doc_id)
|
|
||||||
assert deleted is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestQdrantIntegration:
|
|
||||||
"""Test Qdrant connectivity and basic operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_collection_creation(self, qdrant_client, test_user):
|
|
||||||
"""Test creating a collection."""
|
|
||||||
await qdrant_client.ensure_collection(test_user)
|
|
||||||
|
|
||||||
collection_name = qdrant_client.get_collection_name(test_user)
|
|
||||||
collections = qdrant_client.client.get_collections()
|
|
||||||
collection_names = [c.name for c in collections.collections]
|
|
||||||
|
|
||||||
assert collection_name in collection_names
|
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
# Create test chunks with 768-dimensional embeddings
|
|
||||||
chunks = [
|
|
||||||
{"content": "Test chunk 1", "metadata": {}},
|
|
||||||
{"content": "Test chunk 2", "metadata": {}}
|
|
||||||
]
|
|
||||||
embeddings = [
|
|
||||||
[0.1] * 768,
|
|
||||||
[0.2] * 768
|
|
||||||
]
|
|
||||||
|
|
||||||
# Upsert
|
|
||||||
count = await qdrant_client.upsert_document_chunks(
|
|
||||||
user=test_user,
|
|
||||||
doc_id="test_doc_qdrant",
|
|
||||||
chunks=chunks,
|
|
||||||
embeddings=embeddings
|
|
||||||
)
|
|
||||||
assert count == 2
|
|
||||||
|
|
||||||
# Search
|
|
||||||
results = await qdrant_client.search(
|
|
||||||
user=test_user,
|
|
||||||
query_vector=[0.1] * 768,
|
|
||||||
limit=5,
|
|
||||||
score_threshold=0.0
|
|
||||||
)
|
|
||||||
assert len(results) > 0
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
deleted = await qdrant_client.delete_document(test_user, "test_doc_qdrant")
|
|
||||||
assert deleted is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestWikiJSIntegration:
|
|
||||||
"""Test Wiki.js connectivity and basic operations."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_pages(self, wikijs_client):
|
|
||||||
"""Test listing pages."""
|
|
||||||
pages = await wikijs_client.list_pages(limit=10)
|
|
||||||
assert isinstance(pages, list)
|
|
||||||
# May be empty if wiki is new
|
|
||||||
|
|
||||||
@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/")
|
|
||||||
assert isinstance(pages, list)
|
|
||||||
# Verify each page has expected fields
|
|
||||||
for page in pages[:5]: # Check first 5
|
|
||||||
assert "id" in page
|
|
||||||
assert "path" in page
|
|
||||||
assert "title" in page
|
|
||||||
|
|
||||||
@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")
|
|
||||||
assert isinstance(taxonomy, dict)
|
|
||||||
# Each key should be a category, value should be list of subcategories
|
|
||||||
for category, subcategories in taxonomy.items():
|
|
||||||
assert isinstance(category, str)
|
|
||||||
assert isinstance(subcategories, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_pages(self, wikijs_client):
|
|
||||||
"""Test searching pages."""
|
|
||||||
results = await wikijs_client.search_pages("test")
|
|
||||||
assert isinstance(results, list)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSearXNGIntegration:
|
|
||||||
"""Test SearXNG connectivity and search."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_general_search(self, searxng_client):
|
|
||||||
"""Test general web search."""
|
|
||||||
results = await searxng_client.search_general("python programming", limit=5)
|
|
||||||
assert isinstance(results, list)
|
|
||||||
if results:
|
|
||||||
assert "title" in results[0]
|
|
||||||
assert "url" in results[0]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_search_with_suggestions(self, searxng_client):
|
|
||||||
"""Test getting search suggestions."""
|
|
||||||
suggestions = await searxng_client.get_suggestions("pytho")
|
|
||||||
assert isinstance(suggestions, list)
|
|
||||||
|
|
||||||
|
|
||||||
class TestOllamaIntegration:
|
|
||||||
"""Test Ollama connectivity and embeddings."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health_check(self, ollama_client):
|
|
||||||
"""Test Ollama health check."""
|
|
||||||
is_healthy = await ollama_client.health_check()
|
|
||||||
# May be False if model not pulled
|
|
||||||
assert isinstance(is_healthy, bool)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_models(self, ollama_client):
|
|
||||||
"""Test listing available models."""
|
|
||||||
models = await ollama_client.list_models()
|
|
||||||
assert isinstance(models, list)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
True, # Skip by default as embeddings can be slow
|
|
||||||
reason="Embedding generation is slow - enable manually if needed"
|
|
||||||
)
|
|
||||||
async def test_generate_embedding(self, ollama_client):
|
|
||||||
"""Test generating a single embedding."""
|
|
||||||
embedding = await ollama_client.embed("test text")
|
|
||||||
if embedding:
|
|
||||||
assert isinstance(embedding, list)
|
|
||||||
assert len(embedding) > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestJobManagerIntegration:
|
|
||||||
"""Test Job Manager with Redis."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_and_get_job(self, job_manager, test_user):
|
|
||||||
"""Test creating and retrieving a job."""
|
|
||||||
# Create job
|
|
||||||
job_id = await job_manager.create_job(
|
|
||||||
job_type=JobType.DOCUMENT_INGESTION,
|
|
||||||
user=test_user,
|
|
||||||
parameters={"doc_url": "https://example.com/doc.pdf"}
|
|
||||||
)
|
|
||||||
assert job_id
|
|
||||||
|
|
||||||
# Get job
|
|
||||||
job = await job_manager.get_job(job_id)
|
|
||||||
assert job is not None
|
|
||||||
assert job["job_id"] == job_id
|
|
||||||
assert job["user"] == test_user
|
|
||||||
assert job["status"] == JobStatus.QUEUED.value
|
|
||||||
|
|
||||||
# Update job
|
|
||||||
await job_manager.update_job_status(
|
|
||||||
job_id,
|
|
||||||
JobStatus.PROCESSING,
|
|
||||||
progress=50
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_job = await job_manager.get_job(job_id)
|
|
||||||
assert updated_job["status"] == JobStatus.PROCESSING.value
|
|
||||||
assert updated_job["progress"] == 50
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
deleted = await job_manager.delete_job(job_id, test_user)
|
|
||||||
assert deleted is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestDependencyInjection:
|
|
||||||
"""Test dependency injection and lifecycle management."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_startup_clients(self):
|
|
||||||
"""Test client startup."""
|
|
||||||
from src.core.dependencies import startup_clients
|
|
||||||
|
|
||||||
# Should not raise exceptions
|
|
||||||
await startup_clients()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_health(self):
|
|
||||||
"""Test health check for all services."""
|
|
||||||
from src.core.dependencies import check_service_health
|
|
||||||
|
|
||||||
health = await check_service_health()
|
|
||||||
|
|
||||||
assert isinstance(health, dict)
|
|
||||||
assert "neo4j" in health
|
|
||||||
assert "qdrant" in health
|
|
||||||
assert "wikijs" in health
|
|
||||||
assert "searxng" in health
|
|
||||||
assert "ollama" in health
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shutdown_clients(self):
|
|
||||||
"""Test client shutdown."""
|
|
||||||
from src.core.dependencies import shutdown_clients
|
|
||||||
|
|
||||||
# Should not raise exceptions
|
|
||||||
await shutdown_clients()
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""Tests for multi-tenancy helpers."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from src.core.multi_tenancy import (
|
|
||||||
sanitize_user_id,
|
|
||||||
get_qdrant_collection_name,
|
|
||||||
get_wikijs_namespace,
|
|
||||||
get_neo4j_user_label,
|
|
||||||
validate_user_id,
|
|
||||||
is_path_in_user_namespace,
|
|
||||||
DEFAULT_USER
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSanitizeUserId:
|
|
||||||
"""Test user ID sanitization."""
|
|
||||||
|
|
||||||
def test_lowercase_conversion(self):
|
|
||||||
assert sanitize_user_id("JohnDoe") == "johndoe"
|
|
||||||
|
|
||||||
def test_email_conversion(self):
|
|
||||||
assert sanitize_user_id("john@example.com") == "john_at_example_com"
|
|
||||||
|
|
||||||
def test_dot_conversion(self):
|
|
||||||
assert sanitize_user_id("john.doe") == "john_doe"
|
|
||||||
|
|
||||||
def test_space_conversion(self):
|
|
||||||
assert sanitize_user_id("John Doe") == "john_doe"
|
|
||||||
|
|
||||||
def test_special_chars_removal(self):
|
|
||||||
assert sanitize_user_id("john-doe!") == "john_doe"
|
|
||||||
|
|
||||||
def test_consecutive_underscores(self):
|
|
||||||
assert sanitize_user_id("john__doe") == "john_doe"
|
|
||||||
|
|
||||||
def test_leading_trailing_underscores(self):
|
|
||||||
assert sanitize_user_id("_john_") == "john"
|
|
||||||
|
|
||||||
|
|
||||||
class TestQdrantCollectionName:
|
|
||||||
"""Test Qdrant collection name generation."""
|
|
||||||
|
|
||||||
def test_simple_user(self):
|
|
||||||
assert get_qdrant_collection_name("jpmschweitzer") == "library_desk_jpmschweitzer"
|
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestWikijsNamespace:
|
|
||||||
"""Test Wiki.js namespace generation."""
|
|
||||||
|
|
||||||
def test_simple_user(self):
|
|
||||||
assert get_wikijs_namespace("jpmschweitzer") == "/users/jpmschweitzer"
|
|
||||||
|
|
||||||
def test_email_user(self):
|
|
||||||
assert get_wikijs_namespace("john@example.com") == "/users/john_at_example_com"
|
|
||||||
|
|
||||||
def test_starts_with_slash(self):
|
|
||||||
namespace = get_wikijs_namespace("testuser")
|
|
||||||
assert namespace.startswith("/")
|
|
||||||
|
|
||||||
|
|
||||||
class TestNeo4jUserLabel:
|
|
||||||
"""Test Neo4j user label generation."""
|
|
||||||
|
|
||||||
def test_simple_user(self):
|
|
||||||
assert get_neo4j_user_label("jpmschweitzer") == "User_Jpmschweitzer_Document"
|
|
||||||
|
|
||||||
def test_email_user(self):
|
|
||||||
result = get_neo4j_user_label("john@example.com")
|
|
||||||
# Should be title case
|
|
||||||
assert result == "User_John_At_Example_Com_Document"
|
|
||||||
|
|
||||||
def test_title_case(self):
|
|
||||||
result = get_neo4j_user_label("john_doe")
|
|
||||||
assert result == "User_John_Doe_Document"
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidateUserId:
|
|
||||||
"""Test user ID validation."""
|
|
||||||
|
|
||||||
def test_valid_simple(self):
|
|
||||||
assert validate_user_id("jpmschweitzer") is True
|
|
||||||
|
|
||||||
def test_valid_email(self):
|
|
||||||
assert validate_user_id("john@example.com") is True
|
|
||||||
|
|
||||||
def test_empty_invalid(self):
|
|
||||||
assert validate_user_id("") is False
|
|
||||||
|
|
||||||
def test_too_long_invalid(self):
|
|
||||||
assert validate_user_id("a" * 101) is False
|
|
||||||
|
|
||||||
def test_no_alphanumeric_invalid(self):
|
|
||||||
assert validate_user_id("___") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestPathInNamespace:
|
|
||||||
"""Test path namespace checking."""
|
|
||||||
|
|
||||||
def test_path_in_namespace(self):
|
|
||||||
assert is_path_in_user_namespace("/users/jpmschweitzer/projects", "jpmschweitzer") is True
|
|
||||||
|
|
||||||
def test_path_not_in_namespace(self):
|
|
||||||
assert is_path_in_user_namespace("/users/other/projects", "jpmschweitzer") is False
|
|
||||||
|
|
||||||
def test_public_path_not_in_namespace(self):
|
|
||||||
assert is_path_in_user_namespace("/public/docs", "jpmschweitzer") is False
|
|
||||||
|
|
||||||
def test_root_path(self):
|
|
||||||
assert is_path_in_user_namespace("/users/test", "test") is True
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for Wiki.js Change Listener
|
|
||||||
|
|
||||||
Tests the PostgreSQL NOTIFY/LISTEN change detection system including:
|
|
||||||
- Database connection and listener startup
|
|
||||||
- Notification handling (INSERT/UPDATE/DELETE)
|
|
||||||
- Loop prevention (automated user filtering)
|
|
||||||
- Debouncing (duplicate notification filtering)
|
|
||||||
- Page processing
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from src.services.wiki_change_listener import WikiChangeListener
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_settings():
|
|
||||||
"""Mock settings for testing."""
|
|
||||||
settings = MagicMock()
|
|
||||||
settings.wikijs_db_host = "postgres-shared"
|
|
||||||
settings.wikijs_db_port = 5432
|
|
||||||
settings.wikijs_db_user = "library_desk_listener"
|
|
||||||
settings.wikijs_db_password = "test_password"
|
|
||||||
settings.wikijs_db_name = "library"
|
|
||||||
settings.wikijs_username = "librarian@schweitz.net"
|
|
||||||
settings.wikijs_change_listener_debounce_seconds = 5
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def listener(mock_settings):
|
|
||||||
"""Create a WikiChangeListener instance with mocked settings."""
|
|
||||||
with patch('src.services.wiki_change_listener.get_settings', return_value=mock_settings):
|
|
||||||
return WikiChangeListener()
|
|
||||||
|
|
||||||
|
|
||||||
class TestWikiChangeListener:
|
|
||||||
"""Test suite for WikiChangeListener."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_listener_initialization(self, listener, mock_settings):
|
|
||||||
"""Test that listener initializes with correct settings."""
|
|
||||||
assert listener.settings == mock_settings
|
|
||||||
assert listener.connection is None
|
|
||||||
assert listener.running is False
|
|
||||||
assert listener._debounce_seconds == 5
|
|
||||||
assert len(listener._recent_notifications) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_automated_user_filtering(self, listener):
|
|
||||||
"""Test that automated users are correctly identified."""
|
|
||||||
# Automated users should be filtered
|
|
||||||
assert listener._is_automated_user("librarian@schweitz.net") is True
|
|
||||||
assert listener._is_automated_user("library-desk@system") is True
|
|
||||||
assert listener._is_automated_user("automation@system") is True
|
|
||||||
assert listener._is_automated_user("bot@system") is True
|
|
||||||
|
|
||||||
# Case insensitive
|
|
||||||
assert listener._is_automated_user("LIBRARIAN@SCHWEITZ.NET") is True
|
|
||||||
|
|
||||||
# Regular users should not be filtered
|
|
||||||
assert listener._is_automated_user("user@example.com") is False
|
|
||||||
assert listener._is_automated_user("john@example.com") is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_debouncing_prevents_duplicates(self, listener):
|
|
||||||
"""Test that debouncing prevents duplicate processing."""
|
|
||||||
page_id = 123
|
|
||||||
|
|
||||||
# First notification - should not be filtered
|
|
||||||
assert listener._is_recently_processed(page_id) is False
|
|
||||||
|
|
||||||
# Mark as processed
|
|
||||||
listener._mark_as_processed(page_id)
|
|
||||||
|
|
||||||
# Immediate second notification - should be filtered (within debounce window)
|
|
||||||
assert listener._is_recently_processed(page_id) is True
|
|
||||||
|
|
||||||
# Different page - should not be filtered
|
|
||||||
assert listener._is_recently_processed(456) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_debouncing_expires_after_window(self, listener):
|
|
||||||
"""Test that debouncing expires after the configured time window."""
|
|
||||||
page_id = 123
|
|
||||||
|
|
||||||
# Mark as processed with old timestamp (outside debounce window)
|
|
||||||
listener._recent_notifications[page_id] = datetime.now() - timedelta(seconds=10)
|
|
||||||
|
|
||||||
# Should not be filtered anymore (10 seconds > 5 second debounce)
|
|
||||||
assert listener._is_recently_processed(page_id) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mark_as_processed_cleanup(self, listener):
|
|
||||||
"""Test that old entries are cleaned up to prevent memory growth."""
|
|
||||||
# Add 101 entries to trigger cleanup (threshold is 100)
|
|
||||||
for i in range(101):
|
|
||||||
listener._mark_as_processed(i)
|
|
||||||
|
|
||||||
# Should only keep last 100 entries
|
|
||||||
assert len(listener._recent_notifications) == 100
|
|
||||||
|
|
||||||
# Oldest entry (0) should be removed
|
|
||||||
assert 0 not in listener._recent_notifications
|
|
||||||
|
|
||||||
# Newest entries should be kept
|
|
||||||
assert 100 in listener._recent_notifications
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_notification_payload_parsing(self, listener):
|
|
||||||
"""Test that notification payloads are correctly parsed."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# Test UPDATE notification
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:123:user@example.com'
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
call_args = mock_process.call_args[1]
|
|
||||||
assert call_args['page_id'] == 123
|
|
||||||
assert call_args['event'] == 'page.update'
|
|
||||||
assert call_args['user'] == 'user'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_notification_operations_mapping(self, listener):
|
|
||||||
"""Test that database operations map to correct webhook events."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# INSERT -> page.create (use page 100)
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'INSERT:100:user@example.com'
|
|
||||||
)
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
assert mock_process.call_args[1]['event'] == 'page.create'
|
|
||||||
|
|
||||||
mock_process.reset_mock()
|
|
||||||
|
|
||||||
# UPDATE -> page.update (use different page 200 to avoid debouncing)
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:200:user@example.com'
|
|
||||||
)
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
assert mock_process.call_args[1]['event'] == 'page.update'
|
|
||||||
|
|
||||||
mock_process.reset_mock()
|
|
||||||
|
|
||||||
# DELETE -> page.delete (use different page 300 to avoid debouncing)
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'DELETE:300:user@example.com'
|
|
||||||
)
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
assert mock_process.call_args[1]['event'] == 'page.delete'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_automated_user_notification_filtered(self, listener):
|
|
||||||
"""Test that notifications from automated users are filtered out."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# Notification from automated user should be skipped
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:123:librarian@schweitz.net'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process should NOT be called
|
|
||||||
mock_process.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_duplicate_notification_filtered(self, listener):
|
|
||||||
"""Test that duplicate notifications within debounce window are filtered."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
page_id = 123
|
|
||||||
|
|
||||||
# Mark page as recently processed
|
|
||||||
listener._mark_as_processed(page_id)
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# Duplicate notification should be skipped
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
f'UPDATE:{page_id}:user@example.com'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process should NOT be called
|
|
||||||
mock_process.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_notification_payload_handled(self, listener):
|
|
||||||
"""Test that invalid notification payloads are handled gracefully."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# Invalid payload (too few parts)
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'INVALID:123' # Missing user email
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should not crash and should not process
|
|
||||||
mock_process.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_email_to_username_extraction(self, listener):
|
|
||||||
"""Test that user email is correctly extracted to username."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:123:john.doe@example.com'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should extract 'john.doe' from email
|
|
||||||
call_args = mock_process.call_args[1]
|
|
||||||
assert call_args['user'] == 'john.doe'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_email_without_at_sign_fallback(self, listener):
|
|
||||||
"""Test fallback when email doesn't contain @ sign."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:123:invaliduser'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should use default user
|
|
||||||
call_args = mock_process.call_args[1]
|
|
||||||
assert call_args['user'] == 'jpmschweitzer'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_page_delete_calls_cleanup(self, listener):
|
|
||||||
"""Test that DELETE events call cleanup_deleted_page."""
|
|
||||||
with patch('src.routers.webhooks.cleanup_deleted_page', new_callable=AsyncMock) as mock_cleanup, \
|
|
||||||
patch('src.services.wiki_change_listener.get_ingestion_service') as mock_service:
|
|
||||||
|
|
||||||
await listener._process_page_change(
|
|
||||||
page_id=123,
|
|
||||||
event='page.delete',
|
|
||||||
user='testuser'
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_cleanup.assert_called_once()
|
|
||||||
call_args = mock_cleanup.call_args[1]
|
|
||||||
assert call_args['page_id'] == 123
|
|
||||||
assert call_args['user'] == 'testuser'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_page_create_calls_process_wiki_page_change(self, listener):
|
|
||||||
"""Test that CREATE events call process_wiki_page_change."""
|
|
||||||
mock_page = MagicMock()
|
|
||||||
mock_page.title = "Test Page"
|
|
||||||
|
|
||||||
with patch('src.routers.webhooks.process_wiki_page_change', new_callable=AsyncMock) as mock_process, \
|
|
||||||
patch('src.core.dependencies.get_wiki_service') as mock_wiki_service_factory, \
|
|
||||||
patch('src.services.wiki_change_listener.get_ingestion_service'):
|
|
||||||
|
|
||||||
mock_wiki_service = AsyncMock()
|
|
||||||
mock_wiki_service.get_page.return_value = mock_page
|
|
||||||
mock_wiki_service_factory.return_value = mock_wiki_service
|
|
||||||
|
|
||||||
await listener._process_page_change(
|
|
||||||
page_id=123,
|
|
||||||
event='page.create',
|
|
||||||
user='testuser'
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
call_args = mock_process.call_args[1]
|
|
||||||
assert call_args['page_id'] == 123
|
|
||||||
assert call_args['page_title'] == "Test Page"
|
|
||||||
assert call_args['event'] == 'page.create'
|
|
||||||
assert call_args['user'] == 'testuser'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_page_update_calls_process_wiki_page_change(self, listener):
|
|
||||||
"""Test that UPDATE events call process_wiki_page_change."""
|
|
||||||
mock_page = MagicMock()
|
|
||||||
mock_page.title = "Updated Page"
|
|
||||||
|
|
||||||
with patch('src.routers.webhooks.process_wiki_page_change', new_callable=AsyncMock) as mock_process, \
|
|
||||||
patch('src.core.dependencies.get_wiki_service') as mock_wiki_service_factory, \
|
|
||||||
patch('src.services.wiki_change_listener.get_ingestion_service'):
|
|
||||||
|
|
||||||
mock_wiki_service = AsyncMock()
|
|
||||||
mock_wiki_service.get_page.return_value = mock_page
|
|
||||||
mock_wiki_service_factory.return_value = mock_wiki_service
|
|
||||||
|
|
||||||
await listener._process_page_change(
|
|
||||||
page_id=456,
|
|
||||||
event='page.update',
|
|
||||||
user='testuser'
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_process.assert_called_once()
|
|
||||||
call_args = mock_process.call_args[1]
|
|
||||||
assert call_args['page_id'] == 456
|
|
||||||
assert call_args['page_title'] == "Updated Page"
|
|
||||||
assert call_args['event'] == 'page.update'
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_connection_lifecycle(self, listener, mock_settings):
|
|
||||||
"""Test listener connection start and stop lifecycle."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch('src.services.wiki_change_listener.asyncpg.connect', return_value=mock_connection) as mock_connect:
|
|
||||||
# Start listener
|
|
||||||
await listener.start()
|
|
||||||
|
|
||||||
# Verify connection was established with correct parameters
|
|
||||||
mock_connect.assert_called_once_with(
|
|
||||||
host=mock_settings.wikijs_db_host,
|
|
||||||
port=mock_settings.wikijs_db_port,
|
|
||||||
user=mock_settings.wikijs_db_user,
|
|
||||||
password=mock_settings.wikijs_db_password,
|
|
||||||
database=mock_settings.wikijs_db_name
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify listener was added
|
|
||||||
mock_connection.add_listener.assert_called_once_with(
|
|
||||||
'wiki_page_changes',
|
|
||||||
listener._handle_notification
|
|
||||||
)
|
|
||||||
|
|
||||||
assert listener.running is True
|
|
||||||
assert listener.connection == mock_connection
|
|
||||||
|
|
||||||
# Stop listener
|
|
||||||
await listener.stop()
|
|
||||||
|
|
||||||
# Verify listener was removed and connection closed
|
|
||||||
mock_connection.remove_listener.assert_called_once()
|
|
||||||
mock_connection.close.assert_called_once()
|
|
||||||
assert listener.running is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoopPreventionScenarios:
|
|
||||||
"""Integration tests for loop prevention scenarios."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_full_loop_prevention_flow(self, listener):
|
|
||||||
"""
|
|
||||||
Test complete loop prevention flow:
|
|
||||||
1. User edits page -> Processes
|
|
||||||
2. Entity linking updates page (as automated user) -> Filtered
|
|
||||||
3. Rapid duplicate edits -> Debounced
|
|
||||||
"""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# 1. User edit - should process
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:100:user@example.com'
|
|
||||||
)
|
|
||||||
assert mock_process.call_count == 1
|
|
||||||
|
|
||||||
mock_process.reset_mock()
|
|
||||||
|
|
||||||
# 2. Automated edit (entity linking) - should be filtered
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:100:librarian@schweitz.net'
|
|
||||||
)
|
|
||||||
assert mock_process.call_count == 0
|
|
||||||
|
|
||||||
# 3. Rapid duplicate from same user - should be debounced
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:100:user@example.com'
|
|
||||||
)
|
|
||||||
assert mock_process.call_count == 0 # Debounced
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_different_pages_not_debounced(self, listener):
|
|
||||||
"""Test that edits to different pages are not debounced."""
|
|
||||||
mock_connection = AsyncMock()
|
|
||||||
|
|
||||||
with patch.object(listener, '_process_page_change', new_callable=AsyncMock) as mock_process:
|
|
||||||
# Edit page 100
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:100:user@example.com'
|
|
||||||
)
|
|
||||||
assert mock_process.call_count == 1
|
|
||||||
|
|
||||||
# Edit page 200 immediately - should NOT be debounced
|
|
||||||
await listener._handle_notification(
|
|
||||||
mock_connection, 1234, 'wiki_page_changes',
|
|
||||||
'UPDATE:200:user@example.com'
|
|
||||||
)
|
|
||||||
assert mock_process.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestWikiChangeListenerIntegration:
|
|
||||||
"""
|
|
||||||
Integration tests (require actual PostgreSQL connection).
|
|
||||||
These tests are marked with @pytest.mark.integration and skipped by default.
|
|
||||||
Run with: pytest -m integration
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_real_database_connection(self):
|
|
||||||
"""Test connection to real PostgreSQL database (requires setup)."""
|
|
||||||
pytest.skip("Requires actual PostgreSQL setup with triggers")
|
|
||||||
|
|
||||||
listener = WikiChangeListener()
|
|
||||||
try:
|
|
||||||
await listener.start()
|
|
||||||
assert listener.running is True
|
|
||||||
assert listener.connection is not None
|
|
||||||
finally:
|
|
||||||
await listener.stop()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_real_notification_handling(self):
|
|
||||||
"""Test handling real NOTIFY events from PostgreSQL."""
|
|
||||||
pytest.skip("Requires actual PostgreSQL setup with triggers")
|
|
||||||
|
|
||||||
# This would test actual pg_notify() calls from triggers
|
|
||||||
# and verify the listener receives and processes them
|
|
||||||
+5
-88
@@ -4,41 +4,15 @@ version: '3.8'
|
|||||||
# Application Layer
|
# Application Layer
|
||||||
# Port: 8089 (HTTP)
|
# Port: 8089 (HTTP)
|
||||||
# GPU: No
|
# GPU: No
|
||||||
# Storage: SSD (venv, logs)
|
# Source: https://git.schweitz.net/jpmschweitzer/library-desk
|
||||||
|
|
||||||
services:
|
services:
|
||||||
library-desk:
|
library-desk:
|
||||||
image: python:3.12-slim
|
image: git.schweitz.net/jpmschweitzer/library-desk:latest
|
||||||
container_name: library-desk
|
container_name: library-desk
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8089:8089" # FastAPI HTTP
|
- "8089:8089" # FastAPI HTTP
|
||||||
volumes:
|
|
||||||
# Mount source code for live editing
|
|
||||||
- /home/jpmschweitzer/Projects/portainer-core/services/library-desk:/app
|
|
||||||
# Persist container's venv for fast restarts
|
|
||||||
- /home/jpmschweitzer/docker-data/library-desk/venv:/venv
|
|
||||||
|
|
||||||
working_dir: /app
|
|
||||||
|
|
||||||
command: >
|
|
||||||
sh -c "
|
|
||||||
echo 'Installing system dependencies...' &&
|
|
||||||
apt-get update -qq &&
|
|
||||||
apt-get install -y --no-install-recommends curl >/dev/null 2>&1 &&
|
|
||||||
rm -rf /var/lib/apt/lists/* &&
|
|
||||||
echo 'Setting up Python environment...' &&
|
|
||||||
if [ ! -f /venv/bin/python ]; then
|
|
||||||
echo 'Initializing venv...' &&
|
|
||||||
python3 -m venv --clear /venv;
|
|
||||||
fi &&
|
|
||||||
echo 'Upgrading pip...' &&
|
|
||||||
/venv/bin/python -m pip install --upgrade pip --quiet &&
|
|
||||||
echo 'Installing dependencies from requirements.txt...' &&
|
|
||||||
/venv/bin/python -m pip install -r /app/requirements.txt --quiet &&
|
|
||||||
echo 'Starting Library Desk API...' &&
|
|
||||||
/venv/bin/python -m uvicorn src.main:app --host 0.0.0.0 --port 8089 --workers 2
|
|
||||||
"
|
|
||||||
environment:
|
environment:
|
||||||
# API Configuration
|
# API Configuration
|
||||||
- LIBRARY_API_KEY=${LIBRARY_API_KEY}
|
- LIBRARY_API_KEY=${LIBRARY_API_KEY}
|
||||||
@@ -73,6 +47,8 @@ services:
|
|||||||
# Python Configuration
|
# Python Configuration
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- TZ=${TZ:-Europe/Amsterdam}
|
- TZ=${TZ:-Europe/Amsterdam}
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
networks:
|
networks:
|
||||||
- docker-dataplane
|
- docker-dataplane
|
||||||
deploy:
|
deploy:
|
||||||
@@ -82,7 +58,7 @@ services:
|
|||||||
limits:
|
limits:
|
||||||
memory: 768M
|
memory: 768M
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8089/health')"]
|
test: ["CMD", "curl", "-f", "http://localhost:8089/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -92,62 +68,3 @@ networks:
|
|||||||
docker-dataplane:
|
docker-dataplane:
|
||||||
external: true
|
external: true
|
||||||
name: docker-dataplane
|
name: docker-dataplane
|
||||||
|
|
||||||
# ⚠️ SECURITY WARNING:
|
|
||||||
# Set these in environment variables before deploying:
|
|
||||||
# - LIBRARY_API_KEY (generate with: openssl rand -hex 32)
|
|
||||||
# - NEO4J_PASSWORD (from library-neo4j deployment)
|
|
||||||
# - LIBRARY_DB_PASSWORD (from library-wiki deployment)
|
|
||||||
# - WIKIJS_API_KEY (from Wiki.js admin panel → API Access)
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# 1. Create /home/jpmschweitzer/Projects/portainer-core/services/library/services/front-desk/
|
|
||||||
# 2. Create requirements.txt (see DEPLOYMENT.md Phase 4.3)
|
|
||||||
# 3. Create main.py with FastAPI app (see DEPLOYMENT.md Phase 4.3)
|
|
||||||
#
|
|
||||||
# API Endpoints (once implemented):
|
|
||||||
# Query:
|
|
||||||
# POST /query/hybrid # HybridRAG (graph + vector + web)
|
|
||||||
# POST /query/semantic # Vector search only
|
|
||||||
# POST /query/graph # Graph traversal only
|
|
||||||
# GET /query/related/{id} # Find related content
|
|
||||||
#
|
|
||||||
# Content Management:
|
|
||||||
# POST /ingest/document # Index new document
|
|
||||||
# POST /ingest/wiki-page # Sync Wiki.js page
|
|
||||||
# POST /wiki/dossier # Create dossier (proxies to Wiki.js)
|
|
||||||
# PUT /wiki/dossier/{id} # Update dossier
|
|
||||||
# DELETE /wiki/dossier/{id} # Delete dossier
|
|
||||||
#
|
|
||||||
# Graph Operations:
|
|
||||||
# GET /graph/entities # List entities
|
|
||||||
# GET /graph/mindmap/{id} # Generate mind map for dossier
|
|
||||||
# POST /graph/query # Execute Cypher query
|
|
||||||
#
|
|
||||||
# Deduplication:
|
|
||||||
# POST /deduplicate/find # Find potential duplicates
|
|
||||||
# POST /deduplicate/merge # Merge duplicate entities
|
|
||||||
#
|
|
||||||
# System:
|
|
||||||
# GET /stats # System statistics
|
|
||||||
# GET /health # Health check
|
|
||||||
#
|
|
||||||
# API Documentation:
|
|
||||||
# - Interactive docs: http://192.168.86.149:8089/docs
|
|
||||||
# - OpenAPI spec: http://192.168.86.149:8089/openapi.json
|
|
||||||
#
|
|
||||||
# Features:
|
|
||||||
# - HybridRAG queries (Neo4j + Qdrant + SearXNG)
|
|
||||||
# - Document ingestion and indexing
|
|
||||||
# - Entity extraction and relationship mapping
|
|
||||||
# - Mind map generation
|
|
||||||
# - Deduplication detection
|
|
||||||
# - Wiki.js API proxy
|
|
||||||
#
|
|
||||||
# Dependencies:
|
|
||||||
# - Neo4j (knowledge graph)
|
|
||||||
# - Qdrant (vector search)
|
|
||||||
# - Wiki.js (wiki operations)
|
|
||||||
# - SearXNG (web search)
|
|
||||||
# - Ollama (embeddings)
|
|
||||||
# - Redis (caching)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user