Compare commits

...
14 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 318636d33d release: v1.3.3 - LLM prompt improvements and dead code cleanup
Build and Push / build (release) Successful in 28s
- Improved LLM prompts with temperature control and negative constraints
- Removed dead code and unused imports
- Wired /query/semantic and /query/graph to real implementations
- Updated test fixtures for external service access

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:23:44 +01:00
jpmschweitzerandClaude Opus 4.5 b976da0092 refactor: improve web results analysis prompt
Apply llm-findings.md recommendations:

Web results analysis (temp 0.0):
- Add ANALYSIS STEPS for chain-of-thought reasoning
- Strict RULES section with negative constraints:
  - "Do NOT suggest pages with insufficient info"
  - "Do NOT invent entities not mentioned"
  - "Do NOT suggest paths outside taxonomy"
- Conservative approach: quality over quantity
- Removed "be INCLUSIVE" guidance (caused over-suggestion)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:22:12 +01:00
jpmschweitzerandClaude Opus 4.5 edfe11f0fb refactor: improve wiki page writer prompts
Apply llm-findings.md recommendations:

Conflict detection (temp 0.0):
- Add explicit analysis steps (CoT)
- Strict rules: only flag direct contradictions
- Negative constraints for false positives

Page creation (temp 0.3):
- Add CRITICAL CONSTRAINTS section
- "Do NOT invent facts not in source"
- "Do NOT fill sections with placeholders"
- Omit sections if information unavailable

Page reconstruction (temp 0.2):
- Add preservation constraints
- "Do NOT rephrase facts changing meaning"
- "Preserve exact quotes, dates, numbers verbatim"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:21:58 +01:00
jpmschweitzerandClaude Opus 4.5 8e003bb9e8 refactor: improve keyword extraction and re-ranking prompts
Apply llm-findings.md recommendations:

Keyword extraction (temp 0.0):
- Add negative constraints: "Do NOT invent terms"
- Simplify output format
- Remove verbose example

LLM re-ranking (temp 0.0):
- Add explicit rules section
- Negative constraints: "Do NOT consider document length"
- Clearer output format specification

Both prompts now use temperature=0.0 for deterministic,
consistent outputs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:21:41 +01:00
jpmschweitzerandClaude Opus 4.5 dfd1f19bf9 feat: add temperature parameter to Ollama generate_text
Add temperature control for LLM text generation:
- temperature=0.0 for deterministic outputs (JSON, rankings)
- temperature=0.3-0.5 for controlled creative content
- None uses model default (~0.7 for mistral-nemo)

Based on llm-findings.md recommendations for improving
mistral-nemo output consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:21:18 +01:00
jpmschweitzerandClaude Opus 4.5 1aca286703 test: update fixtures to use real service host
- Add TEST_HOST config (default: 192.168.86.149) in conftest.py
- Update all test fixtures to use configurable host instead of
  docker hostnames (neo4j, qdrant, wiki, etc.)
- Fix test_integration.py WikiJS client to use username/password auth
- Fix ollama_client fixture to use ollama_embedding_model setting

This allows tests to run against real services from outside Docker.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:21:02 +01:00
jpmschweitzerandClaude Opus 4.5 262a58b0d2 refactor: wire query endpoints and remove stub endpoints
- Wire /query/semantic to VectorService.search()
- Wire /query/graph to GraphService.execute_query()
- Remove stub endpoints:
  - /stats (returns zeros)
  - /ingest/document (shadowed by router)
  - /ingest/batch (shadowed by router)
- Remove unused StatsResponse model
- Add TODO.md tracking remaining stubs to implement:
  - /ingest/check-updates
  - /ingest/status/{document_id}
  - /ingest/repo-status/{repository}
  - /deduplicate/check

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:20:45 +01:00
jpmschweitzerandClaude Opus 4.5 02d728ac5b refactor: remove dead code and unused imports
- Remove unused get_default_user() from dependencies.py
- Remove unused imports from routers:
  - wiki.py: HTTPAuthorizationCredentials, Security
  - graph.py: Neo4jClient, WikiJSClient
  - hybrid_rag.py: VectorService, GraphService (duplicates)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 17:20:27 +01:00
jpmschweitzerandClaude Opus 4.5 a1832e3245 refactor: consolidate Ollama model configuration
Build and Push / build (release) Successful in 27s
- Add OLLAMA_EMBEDDING_MODEL for embeddings (nomic-embed-text)
- OLLAMA_MODEL now used for all LLM operations (mistral-nemo-large:latest)
- Remove separate reranker_model setting
- Update WikiPageWriter to use settings instead of hardcoded model
- Improves VRAM efficiency by keeping one model hot

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 11:15:27 +01:00
jpmschweitzerandClaude Opus 4.5 5be31a5a00 docs: add release flow section to AGENTS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 18:51:49 +01:00
jpmschweitzerandClaude Opus 4.5 376284f90e fix: add content_extractor to smart-create endpoint
Build and Push / build (release) Successful in 30s
The POST /wiki/pages/smart-create endpoint was failing with 500
Internal Server Error because HybridRAGService.__init__() was
missing the required content_extractor parameter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:31:23 +01:00
jpmschweitzerandClaude Opus 4.5 f095de1162 docs: add HybridRAG architecture documentation
Documents two-stage RRF, configuration options, and notes
potential vector search noise improvements for future reference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:54:38 +01:00
jpmschweitzerandClaude Opus 4.5 c359fcbcd8 feat: two-stage RRF for fair wiki vs web ranking
Build and Push / build (release) Successful in 28s
- Merge vector+graph into single wiki source before RRF with web
- Wiki pages no longer get 2x advantage from dual retrieval
- Add vector similarity threshold (0.7 default)
- Skip synonyms in graph search to reduce noise
- Fix duplicate entity links bug in graph search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:49:31 +01:00
jpmschweitzerandClaude Opus 4.5 464ec5380c fix: add content_extractor to hybrid_rag router dependency
Build and Push / build (release) Successful in 29s
The router had its own local get_hybrid_rag_service factory that was
missing the new content_extractor parameter, causing 500 errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 17:00:38 +01:00
21 changed files with 638 additions and 308 deletions
+26
View File
@@ -23,6 +23,32 @@
* **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🚀 Release Flow
When changes are ready for deployment:
1. **Ask user if deploy cycle is desired **
2. **Update version** in `pyproject.toml`:
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
- New features: bump minor version (1.8.4 → 1.9.0)
3. **Update CHANGELOG.md**:
- Move items from `[Unreleased]` to new version section
- Add release date: `## [1.8.4] - 2025-12-16`
4. **Commit and tag**:
```bash
git add -A
git commit -m "fix: description of changes"
git tag v1.8.4
git push origin main --tags
```
5. **CI/CD triggers automatically**:
- Gitea CI builds Docker image on new tag
- Watchtower pulls and deploys to production
- Verify deployment: `curl http://192.168.86.149:8000/health`
---
## 2. FastAPI Architecture & Best Practices
+72
View File
@@ -5,6 +5,78 @@ All notable changes to Library Desk will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.3] - 2025-12-23
### Added
- Temperature parameter to `OllamaClient.generate_text()` for controlling output determinism
- `TODO.md` tracking remaining stub endpoints to implement
- Wired `/query/semantic` endpoint to VectorService
- Wired `/query/graph` endpoint to GraphService
### Changed
- **Improved LLM prompts** based on llm-findings.md recommendations:
- Keyword extraction: temperature 0.0, negative constraints
- LLM re-ranking: temperature 0.0, explicit rules
- Conflict detection: temperature 0.0, analysis steps (CoT)
- Wiki page creation: temperature 0.3, anti-hallucination constraints
- Page reconstruction: temperature 0.2, preservation constraints
- Web results analysis: temperature 0.0, conservative approach
- Test fixtures now use configurable host (TEST_HOST) instead of Docker hostnames
### Removed
- Dead code: unused `get_default_user()` function
- Unused imports from routers (wiki.py, graph.py, hybrid_rag.py)
- Stub endpoints shadowed by real implementations (/stats, /ingest/document, /ingest/batch)
## [1.3.2] - 2025-12-22
### Changed
- **Consolidated Ollama model configuration** - All LLM operations now use single `OLLAMA_MODEL` environment variable
- Removed separate `reranker_model` setting
- HybridRAG re-ranking, consolidation analysis, and wiki page writing all use the same model
- Improves VRAM efficiency by keeping one model hot
- Added `OLLAMA_EMBEDDING_MODEL` environment variable for embedding model (previously overloaded `OLLAMA_MODEL`)
- Updated WikiPageWriter to accept settings instead of hardcoded model name
## [1.3.1] - 2025-12-16
### Fixed
- Smart create endpoint missing `content_extractor` dependency causing 500 errors on `POST /wiki/pages/smart-create`
## [1.3.0] - 2025-12-15
### Changed
- **Two-Stage RRF Architecture** - Major refactor to level the playing field between wiki and web results
- Stage 1: Vector and graph results merged into single "wiki" ranking using mini-RRF
- Stage 2: Final RRF between wiki (single source) and web (single source)
- Wiki pages no longer get 2x advantage from appearing in both vector and graph searches
- Multi-source confirmation still determines wiki internal ranking
- **Skip synonyms in graph search** - LLM-generated synonyms (e.g., "author") no longer match unrelated graph entities (e.g., "author2000")
- Vector search still uses synonyms for semantic similarity
- Graph search uses only core keywords for exact entity matching
### Added
- `VECTOR_SIMILARITY_THRESHOLD` config setting (default: 0.7) to filter weak vector matches
- Deduplication in graph search to prevent same document appearing multiple times
### Fixed
- Graph search duplicate entity bug where same document could appear twice if entity linked multiple times
## [1.2.1] - 2025-12-15
### Fixed
- HybridRAG router missing `content_extractor` dependency causing 500 errors on `/query/hybrid` endpoint
## [1.2.0] - 2025-12-15
### Added
+2 -1
View File
@@ -49,7 +49,8 @@ QDRANT_PORT=6333
WIKIJS_URL=http://wiki:3000
SEARXNG_URL=http://searxng:8080
OLLAMA_URL=http://ollama:11434
OLLAMA_MODEL=nomic-embed-text
OLLAMA_MODEL=mistral-nemo-large:latest
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
REDIS_HOST=redis-shared
REDIS_PORT=6379
REDIS_DB=2
+52
View File
@@ -0,0 +1,52 @@
# TODO
Outstanding work items for Library Desk.
## Stub Endpoints to Implement
The following endpoints in `src/main.py` return stub responses and need real implementations:
### Ingestion Status Endpoints
#### `POST /ingest/check-updates`
Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync.
**Implementation needed:**
1. Query existing documents by path
2. Compare content hashes
3. Return list of updates needed
#### `GET /ingest/status/{document_id}`
Get processing status for a document.
**Implementation needed:**
- Status tracking system (Redis or database)
- Track ingestion progress per document
#### `GET /ingest/repo-status/{repository}`
Get indexing status for an entire repository.
**Implementation needed:**
- Repository-level statistics
- Track which documents from a repo are indexed
### Deduplication
#### `POST /deduplicate/check`
Check for duplicate or highly similar documents using vector similarity and graph analysis.
**Implementation needed:**
1. Get document embedding from Qdrant
2. Find similar vectors above threshold
3. Check graph relationships
4. Return candidates with similarity scores
## System Statistics
#### `GET /stats`
Get system statistics (wiki pages, neo4j nodes, qdrant vectors).
**Implementation needed:**
- Query Neo4j for node count
- Query Qdrant for vector count
- Query Wiki.js for page count
+58
View File
@@ -0,0 +1,58 @@
# HybridRAG Architecture
## Overview
HybridRAG combines three search sources to provide comprehensive results:
- **Vector search** (Qdrant) - Semantic similarity via embeddings
- **Graph search** (Neo4j) - Entity relationships in knowledge graph
- **Web search** (SearXNG) - External web results via Trafilatura extraction
## Two-Stage RRF Fusion (v1.3.0+)
To ensure fair ranking between wiki and web results, we use a two-stage Reciprocal Rank Fusion:
```
Stage 1: Wiki Merge
vector results ─┬─→ Mini-RRF ─→ Unified wiki ranking
graph results ─┘
Stage 2: Final RRF
wiki (merged) ─┬─→ Final RRF ─→ Combined results
web results ─┘
```
**Why two stages?**
Previously, wiki pages found by BOTH vector and graph received double RRF contribution, giving them an unfair 2x advantage over web results. The two-stage approach:
1. Merges vector+graph into a single "wiki" source
2. Wiki's internal ranking still benefits from multi-source confirmation
3. Wiki and web compete as equals in final ranking
## Configuration
| Setting | Default | Description |
|---------|---------|-------------|
| `VECTOR_SIMILARITY_THRESHOLD` | 0.7 | Minimum similarity score for vector results |
| `HYBRID_RAG_VECTOR_LIMIT` | 10 | Max vector results |
| `HYBRID_RAG_GRAPH_LIMIT` | 10 | Max graph results |
| `HYBRID_RAG_WEB_LIMIT` | 5 | Max web results |
## Known Limitations & Future Improvements
### Vector Search Noise
**Status:** Open for improvement if needed after observation period.
Vector search may return generic category/index pages (e.g., "Reference", "Projects", "Places") with high similarity scores (~0.86). These pages often have similar boilerplate content leading to uniform scores.
**Potential solutions if this becomes problematic:**
1. **Raise threshold** - Increase `VECTOR_SIMILARITY_THRESHOLD` to 0.85+
2. **Page-type filtering** - Exclude pages tagged as category/index/stub
3. **Content length signal** - Penalize pages with minimal content
4. **Duplicate score detection** - Flag results with suspiciously identical scores
The LLM re-ranking phase typically demotes these low-quality results, so this may not require immediate action.
### Graph Search
Graph search uses only core keywords (no LLM-generated synonyms) to avoid false matches like "author" → "author2000". This is intentional - vector search handles semantic similarity via embeddings.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "library-desk"
version = "1.2.0"
version = "1.3.3"
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
readme = "README.md"
requires-python = ">=3.12"
+11 -3
View File
@@ -249,7 +249,8 @@ class OllamaClient:
self,
prompt: str,
model: Optional[str] = None,
stream: bool = False
stream: bool = False,
temperature: Optional[float] = None
) -> Optional[str]:
"""
Generate text completion (for non-embedding use cases).
@@ -258,12 +259,15 @@ class OllamaClient:
prompt: Input prompt
model: Model name (defaults to self.model)
stream: Enable streaming response
temperature: Sampling temperature (0.0 = deterministic, higher = more creative)
None uses model default (~0.7 for mistral-nemo)
Returns:
Generated text or None on failure
Note: This is primarily for debugging/testing. Use specialized
LLM services for production text generation.
Note: Use temperature=0.0 for deterministic outputs like JSON parsing,
ranking, and factual extraction. Use higher values (0.3-0.7) for
creative content generation.
"""
try:
payload = {
@@ -272,6 +276,10 @@ class OllamaClient:
"stream": stream
}
# Add temperature to options if specified
if temperature is not None:
payload["options"] = {"temperature": temperature}
response = await self.client.post(
self.generate_url,
json=payload
+4 -3
View File
@@ -62,16 +62,17 @@ class Settings(BaseSettings):
# SearXNG Configuration
searxng_url: str = Field(default="http://searxng:8080", description="SearXNG URL")
# Ollama Configuration (for embeddings)
# Ollama Configuration
ollama_url: str = Field(default="http://ollama:11434", description="Ollama URL")
ollama_model: str = Field(default="nomic-embed-text", description="Ollama embedding model")
ollama_model: str = Field(default="mistral-nemo-large:latest", description="Ollama LLM model")
ollama_embedding_model: str = Field(default="nomic-embed-text", description="Ollama embedding model")
# HybridRAG Configuration
reranker_model: str = Field(default="mistral-nemo", description="Model for LLM re-ranking")
reranker_enabled: bool = Field(default=True, description="Enable LLM re-ranking")
hybrid_rag_vector_limit: int = Field(default=10, ge=1, le=50, description="Vector search limit")
hybrid_rag_graph_limit: int = Field(default=10, ge=1, le=50, description="Graph search limit")
hybrid_rag_web_limit: int = Field(default=5, ge=1, le=20, description="Web search limit")
vector_similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0, description="Minimum similarity score for vector results")
# Entity Linking Fuzzy Matching Configuration
entity_linking_min_confidence: float = Field(default=0.70, ge=0.0, le=1.0, description="Minimum confidence for entity-document matching")
+1 -13
View File
@@ -113,7 +113,7 @@ def get_ollama_client() -> OllamaClient:
settings = get_settings()
client = OllamaClient(
base_url=settings.ollama_url,
model=settings.ollama_model
model=settings.ollama_embedding_model
)
logger.debug("Created Ollama client instance")
return client
@@ -395,18 +395,6 @@ def get_rag_search_service() -> "RAGSearchService":
)
# 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
+69 -100
View File
@@ -8,7 +8,7 @@ Following best practices:
- OpenAPI documentation
"""
from fastapi import FastAPI, HTTPException, Depends
from fastapi import FastAPI, HTTPException, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -17,7 +17,10 @@ import logging
from pathlib import Path
from src.config import Settings, get_settings, __version__
from src.core.dependencies import verify_api_key
from src.core.dependencies import (
verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep
)
from src.core.multi_tenancy import DEFAULT_USER
# Configure logging
logging.basicConfig(
@@ -78,13 +81,6 @@ class HealthResponse(BaseModel):
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]:
@@ -141,77 +137,6 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
)
@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],
@@ -269,41 +194,85 @@ async def get_repo_status(
}
# Query endpoints (stubs for future implementation)
# NOTE: /query/hybrid is now implemented in routers/hybrid_rag.py
# Query endpoints
# NOTE: /query/hybrid is implemented in routers/hybrid_rag.py
@app.post("/query/semantic", tags=["Query"])
async def semantic_query(
query: Dict[str, Any],
query: str = Query(..., min_length=1, description="Search query text"),
user: str = Query(default=DEFAULT_USER, description="User identifier"),
limit: int = Query(default=10, ge=1, le=100, description="Maximum results"),
score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"),
qdrant_client: QdrantDep = None,
wiki_client: WikiJSDep = None,
ollama_client: OllamaDep = None,
api_key: str = Depends(verify_api_key)
) -> Dict[str, Any]:
):
"""
Semantic search via Qdrant.
Pure vector similarity search.
Semantic search via Qdrant vector similarity.
TODO: Implement semantic search
Searches document chunks using embedding similarity. Returns matching
chunks with relevance scores, page titles, and paths.
**Example:**
```
POST /query/semantic?query=docker%20configuration&user=jpmschweitzer&limit=10
```
**Returns:** List of matching chunks with similarity scores (0-1)
"""
return {
"message": "Semantic search not yet implemented",
"query": query
}
from src.services.vector_service import VectorService
vector_service = VectorService(qdrant_client, wiki_client, ollama_client)
try:
return await vector_service.search(
query=query,
user=user,
limit=limit,
score_threshold=score_threshold
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Semantic search failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Search failed")
@app.post("/query/graph", tags=["Query"])
async def graph_query(
query: Dict[str, Any],
query: str = Query(..., description="Cypher query to execute"),
user: str = Query(default=DEFAULT_USER, description="User for scoping (auto-filters results)"),
neo4j_client: Neo4jDep = None,
wiki_client: WikiJSDep = None,
api_key: str = Depends(verify_api_key)
) -> Dict[str, Any]:
):
"""
Graph traversal via Neo4j.
Execute Cypher queries.
Execute a Cypher query against the Neo4j knowledge graph.
TODO: Implement graph queries
Queries are automatically scoped to the user's data for security.
Use this for custom graph traversals beyond what /graph/nodes provides.
**Example:**
```
POST /query/graph?query=MATCH%20(d:Document)-[:MENTIONS]->(p:Person)%20RETURN%20d,p&user=jpmschweitzer
```
**Security:** All queries are user-scoped to prevent cross-user data access.
"""
return {
"message": "Graph query not yet implemented",
"query": query
}
from src.services.graph_service import GraphService
graph_service = GraphService(neo4j_client, wiki_client)
try:
return await graph_service.execute_query(
query=query,
parameters={},
user=user
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Graph query failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Query execution failed")
# Deduplication endpoints
-2
View File
@@ -15,8 +15,6 @@ from src.models.graph import (
MindMapResponse
)
from src.services.graph_service import GraphService
from src.clients.neo4j_client import Neo4jClient
from src.clients.wikijs_client import WikiJSClient
from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key
from src.core.multi_tenancy import DEFAULT_USER
+3 -5
View File
@@ -10,13 +10,9 @@ 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
SearXNGDep, ContentExtractorDep, verify_api_key, get_settings
)
from src.config import Settings
@@ -32,6 +28,7 @@ def get_hybrid_rag_service(
qdrant_client: QdrantDep,
ollama_client: OllamaDep,
searxng_client: SearXNGDep,
content_extractor: ContentExtractorDep,
settings: Settings = Depends(get_settings)
) -> HybridRAGService:
"""Get HybridRAG service instance with all dependencies."""
@@ -48,6 +45,7 @@ def get_hybrid_rag_service(
graph_service=graph_service,
searxng_client=searxng_client,
ollama_client=ollama_client,
content_extractor=content_extractor,
settings=settings
)
+5 -4
View File
@@ -5,8 +5,7 @@ Endpoints for wiki page and dossier management.
All operations are scoped to user namespaces for multi-tenancy.
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Security, BackgroundTasks
from fastapi.security import HTTPAuthorizationCredentials
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
from typing import Optional
import logging
@@ -24,7 +23,7 @@ from src.clients.neo4j_client import Neo4jClient
from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.ollama_client import OllamaClient
from src.core.dependencies import (
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep,
WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep,
verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service
)
from src.core.multi_tenancy import DEFAULT_USER
@@ -180,6 +179,7 @@ async def smart_create_page(
qdrant_client: QdrantDep,
ollama_client: OllamaDep,
searxng_client: SearXNGDep,
content_extractor: ContentExtractorDep,
settings: Settings = Depends(get_settings),
api_key: str = Depends(verify_api_key)
):
@@ -225,9 +225,10 @@ async def smart_create_page(
graph_service=graph_service,
searxng_client=searxng_client,
ollama_client=ollama_client,
content_extractor=content_extractor,
settings=settings
)
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client)
wiki_page_writer = WikiPageWriter(ollama_client=ollama_client, settings=settings)
# Step 1-5: Research + Generate + Create page
page, research_data = await wiki_service.smart_create_page(
+17 -10
View File
@@ -47,7 +47,7 @@ class ConsolidationService:
self.ollama = ollama
self.wiki = wiki
self.settings = settings
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama)
self.wiki_page_writer = WikiPageWriter(ollama_client=ollama, settings=settings)
self.ingestion_service = ingestion_service # Optional to avoid circular dependency
async def consolidate_knowledge(
@@ -410,13 +410,19 @@ This is a PERSONAL knowledge base using Schema.org-aligned taxonomy that capture
- Projects: Work projects, personal projects (Schema.org: Project)
- Reference: General knowledge, how-tos (Custom extension)
Identify information worth documenting:
1. New topics/people/things that deserve their own wiki page
2. Facts that could enhance existing pages
3. Entities (people, places, things, concepts) for the knowledge graph
ANALYSIS STEPS:
1. Read each web result carefully for substantive, factual content
2. Identify genuinely novel information not likely already known
3. Match topics to appropriate taxonomy categories
4. Generate valid paths following the exact format below
Be INCLUSIVE - if someone searched for it, it's likely worth documenting.
Personal information is just as valuable as technical information.
RULES:
- Do NOT suggest pages for topics with insufficient information in results
- Do NOT invent entities not explicitly mentioned in results
- Do NOT suggest paths that don't match the taxonomy exactly
- Do NOT suggest generic or vague page topics
- Be CONSERVATIVE - fewer high-quality suggestions is better than many low-quality ones
- ONLY suggest documentation for substantive, specific information
**CRITICAL: Use ONLY these Schema.org-aligned path prefixes (case-sensitive):**
@@ -462,11 +468,12 @@ Return ONLY valid JSON:
JSON:"""
try:
# Call Ollama for analysis
# Call Ollama for analysis (temperature=0.0 for consistent classification)
response = await self.ollama.generate_text(
prompt=prompt,
model=self.settings.reranker_model, # Use mistral-nemo
stream=False
model=self.settings.ollama_model,
stream=False,
temperature=0.0
)
if not response:
+12 -2
View File
@@ -1077,8 +1077,18 @@ Feel free to expand it with more details!
search_query,
{"terms": all_terms, "limit": limit}
)
logger.info(f"Graph search found {len(results)} documents")
return results
# Deduplicate by page_id (safety net for any edge cases)
seen_page_ids = set()
unique_results = []
for r in results:
page_id = r.get("page_id")
if page_id and page_id not in seen_page_ids:
seen_page_ids.add(page_id)
unique_results.append(r)
logger.info(f"Graph search found {len(unique_results)} unique documents (raw: {len(results)})")
return unique_results
except Exception as e:
logger.error(f"Graph document search failed: {e}", exc_info=True)
return []
+162 -62
View File
@@ -6,7 +6,7 @@ HybridRAG service combining vector, graph, and web search.
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
4. LLM Re-ranking - Re-rank with configured Ollama model
5. Context Formatting - Format for LLM consumption
6. Persistence - Store for Librarian processing
"""
@@ -65,7 +65,7 @@ class HybridRAGService:
self.ollama = ollama_client
self.content_extractor = content_extractor
self.settings = settings
self.reranker_model = settings.reranker_model
self.reranker_model = settings.ollama_model
async def search(
self,
@@ -105,14 +105,20 @@ class HybridRAGService:
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
# Phase 2: Two-Stage RRF Fusion
phase2_start = time.time()
# Stage 1: Merge wiki sources (vector + graph) into single ranking
wiki_merged = self._merge_wiki_sources(
vector_results=raw_results.get("vector", []),
graph_results=raw_results.get("graph", []),
k=config.rrf_k
)
# Stage 2: Final RRF between wiki and web (equal footing)
fused_results = self._reciprocal_rank_fusion(
results_by_source={
"vector": raw_results.get("vector", []),
"graph": raw_results.get("graph", []),
"web": raw_results.get("web", [])
},
wiki_results=wiki_merged,
web_results=raw_results.get("web", []),
k=config.rrf_k
)
timing["fusion_ms"] = (time.time() - phase2_start) * 1000
@@ -189,25 +195,21 @@ class HybridRAGService:
Returns:
Dictionary with keywords, entities, synonyms, expansions
"""
prompt = f"""Extract search terms from this query. For each important word, provide synonyms and expansions.
prompt = f"""Extract search terms from this query.
Query: "{query}"
Return ONLY valid JSON:
{{
"core_keywords": ["key", "words", "from", "query"],
"synonyms": {{
"word": ["alternative", "terms"]
}}
}}
RULES:
- Extract ONLY keywords explicitly present or directly implied in the query
- Do NOT invent terms, concepts, or synonyms not clearly related
- Do NOT add general knowledge or associations
- Provide synonyms ONLY for technical terms with well-known alternatives
- Return valid JSON only, no commentary
Example for "Docker container hosting":
Return format:
{{
"core_keywords": ["docker", "container", "hosting"],
"synonyms": {{
"docker": ["containerization", "container runtime"],
"hosting": ["server", "infrastructure"]
}}
"core_keywords": ["words", "from", "query"],
"synonyms": {{"term": ["direct", "alternatives"]}}
}}
JSON:"""
@@ -215,7 +217,8 @@ JSON:"""
try:
response = await self.ollama.generate_text(
prompt=prompt,
model=self.reranker_model
model=self.reranker_model,
temperature=0.0 # Deterministic for consistent extraction
)
# Parse JSON response (handle potential extra text)
@@ -288,7 +291,8 @@ JSON:"""
response = await self.vector.search(
query=query,
user=user,
limit=config.vector_limit
limit=config.vector_limit,
score_threshold=self.settings.vector_similarity_threshold
)
results = [
{
@@ -313,11 +317,19 @@ JSON:"""
async def graph_search():
start = time.time()
try:
# Skip synonyms for graph search - only use core keywords
# Synonyms like "author" can match unrelated entities like "author2000"
graph_keywords = {
"core_keywords": keywords_data.get("core_keywords", []),
"entities": keywords_data.get("entities", []),
"synonyms": {}, # No synonyms for exact entity matching
"expansions": {}
}
results = await self.graph.search_documents(
query=query,
user=user,
limit=config.graph_limit,
keywords_data=keywords_data
keywords_data=graph_keywords
)
formatted = [
{
@@ -394,52 +406,134 @@ JSON:"""
return output
def _reciprocal_rank_fusion(
def _merge_wiki_sources(
self,
results_by_source: Dict[str, List],
vector_results: List[Dict],
graph_results: List[Dict],
k: int = 60
) -> List[Dict[str, Any]]:
"""
Phase 2: Merge results using Reciprocal Rank Fusion.
Stage 1: Merge vector and graph into single wiki ranking using RRF.
RRF formula: score = sum(1 / (k + rank)) for each source
Both sources search the same wiki pool, so we combine them before
final RRF with web to avoid double-counting wiki pages.
Args:
results_by_source: Results from each source
vector_results: Results from vector search
graph_results: Results from graph search
k: RRF constant (default 60)
Returns:
Merged and sorted results
Merged wiki results sorted by wiki RRF score
"""
wiki_scores = {}
# Process vector results
for rank, result in enumerate(vector_results, start=1):
page_id = result.get("page_id")
if not page_id:
continue
result_id = f"page_{page_id}"
if result_id not in wiki_scores:
wiki_scores[result_id] = {
"result": dict(result), # Copy to avoid mutation
"wiki_rrf_score": 0.0,
"found_by": []
}
wiki_scores[result_id]["wiki_rrf_score"] += 1 / (k + rank)
wiki_scores[result_id]["found_by"].append("vector")
# Process graph results
for rank, result in enumerate(graph_results, start=1):
page_id = result.get("page_id")
if not page_id:
continue
result_id = f"page_{page_id}"
if result_id not in wiki_scores:
wiki_scores[result_id] = {
"result": dict(result),
"wiki_rrf_score": 0.0,
"found_by": []
}
wiki_scores[result_id]["wiki_rrf_score"] += 1 / (k + rank)
wiki_scores[result_id]["found_by"].append("graph")
# Add graph metadata to existing result
wiki_scores[result_id]["result"]["entity_matches"] = result.get("entity_matches")
wiki_scores[result_id]["result"]["matched_entities"] = result.get("matched_entities")
# Sort by wiki RRF score
sorted_wiki = sorted(
wiki_scores.values(),
key=lambda x: x["wiki_rrf_score"],
reverse=True
)
# Return merged results with wiki ranking
merged = []
for wiki_rank, item in enumerate(sorted_wiki, start=1):
merged.append({
**item["result"],
"wiki_rank": wiki_rank,
"wiki_rrf_score": item["wiki_rrf_score"],
"found_by": item["found_by"],
"source": "wiki"
})
logger.info(f"Wiki merge: {len(merged)} unique pages from vector+graph")
return merged
def _reciprocal_rank_fusion(
self,
wiki_results: List[Dict],
web_results: List[Dict],
k: int = 60
) -> List[Dict[str, Any]]:
"""
Stage 2: Final RRF between wiki (single source) and web.
Wiki results are pre-merged from vector+graph, so wiki and web
now compete on equal footing.
Args:
wiki_results: Pre-merged wiki results from _merge_wiki_sources()
web_results: Results from web search
k: RRF constant (default 60)
Returns:
Final 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
# Wiki results (single source, already merged)
for rank, result in enumerate(wiki_results, start=1):
page_id = result.get("page_id")
if not page_id:
continue
result_id = f"page_{page_id}"
rrf_scores[result_id] = {
"result": result,
"rrf_score": 1 / (k + rank),
"sources": result.get("found_by", ["wiki"]),
"source_type": "wiki"
}
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"]))
)
# Web results (single source)
for rank, result in enumerate(web_results, start=1):
url = result.get("url")
if not url:
continue
result_id = f"url_{hash(url)}"
rrf_scores[result_id] = {
"result": result,
"rrf_score": 1 / (k + rank),
"sources": ["web"],
"source_type": "web"
}
# Sort by RRF score descending
sorted_results = sorted(
@@ -448,7 +542,7 @@ JSON:"""
reverse=True
)
logger.info(f"RRF fusion: {len(sorted_results)} unique results from {len(results_by_source)} sources")
logger.info(f"Final RRF: {len(sorted_results)} results (wiki + web)")
return sorted_results
@@ -526,21 +620,27 @@ JSON:"""
for i, r in enumerate(results)
])
prompt = f"""Given this search query and documents, rank them by relevance.
prompt = f"""Rank these documents by relevance to the query.
Query: {query}
Documents:
{docs_text}
Return only the numbers in order of relevance (most relevant first).
Example: 3,1,5,2,4
RULES:
- Rank ONLY by how well content answers the query
- Do NOT consider document length, formatting, or style
- Do NOT add explanation or commentary
- Return ONLY comma-separated numbers, most relevant first
Example output: 3,1,5,2,4
Ranking:"""
response = await self.ollama.generate_text(
prompt=prompt,
model=self.reranker_model
model=self.reranker_model,
temperature=0.0 # Deterministic for consistent rankings
)
# Parse response: "3,1,5,2,4" → [2, 0, 4, 1, 3] (0-indexed)
+48 -23
View File
@@ -1,7 +1,7 @@
"""
Intelligent Wiki Page Writer Service
Uses LLM (mistral-nemo) to create and reconstruct wiki pages with:
Uses LLM to create and reconstruct wiki pages with:
- Holistic content restructuring
- Zero fact loss (unless superseded)
- Conflict detection and flagging
@@ -25,15 +25,16 @@ class WikiPageWriter:
Intelligent wiki page writer using LLM for content generation and restructuring.
"""
def __init__(self, ollama_client):
def __init__(self, ollama_client, settings):
"""
Initialize wiki page writer.
Args:
ollama_client: OllamaClient for LLM operations
settings: Application settings
"""
self.ollama = ollama_client
self.model = "mistral-nemo" # Default model for writing
self.model = settings.ollama_model
async def create_page(
self,
@@ -130,8 +131,8 @@ class WikiPageWriter:
conflicts=conflicts
)
# Reconstruct with LLM
reconstructed = await self._call_llm(prompt)
# Reconstruct with LLM (lower temperature for precise merging)
reconstructed = await self._call_llm(prompt, temperature=0.2)
# Ensure standard sections are present
reconstructed = self._ensure_standard_sections(
@@ -154,7 +155,7 @@ class WikiPageWriter:
Returns:
List of conflicts with: {fact_a, fact_b, confidence, context}
"""
prompt = f"""Analyze these two pieces of content for factual conflicts.
prompt = f"""Analyze these contents for direct factual conflicts.
EXISTING CONTENT:
{existing_content[:2000]}
@@ -162,25 +163,26 @@ EXISTING CONTENT:
NEW INFORMATION:
{new_information[:2000]}
Identify any facts that contradict each other. For each conflict, provide:
1. The fact from existing content
2. The contradicting fact from new information
3. Confidence level (low/medium/high)
4. Context/explanation
ANALYSIS STEPS:
1. Identify specific factual claims in existing content (dates, numbers, names, states)
2. Identify specific factual claims in new content
3. Compare ONLY for direct contradictions (X says A, Y says not-A)
Return ONLY valid JSON:
RULES:
- Do NOT flag differences in wording or phrasing as conflicts
- Do NOT flag new/additional information as conflicts
- Do NOT flag opinion differences as conflicts
- ONLY flag direct factual contradictions
- Return valid JSON only, no commentary
Return format:
{{
"conflicts": [
{{
"existing_fact": "fact from old content",
"new_fact": "contradicting fact",
"confidence": "medium",
"context": "explanation of why these conflict"
}}
{{"existing_fact": "...", "new_fact": "...", "confidence": "low/medium/high", "context": "..."}}
]
}}
If no conflicts, return: {{"conflicts": []}}
If no conflicts: {{"conflicts": []}}
JSON:"""
@@ -188,7 +190,8 @@ JSON:"""
response = await self.ollama.generate_text(
prompt=prompt,
model=self.model,
stream=False
stream=False,
temperature=0.0 # Deterministic for consistent conflict detection
)
# Extract JSON
@@ -347,6 +350,13 @@ FORMATTING RULES:
- Keep sections focused and scannable
- Adapt structure to content - not all sections apply to all topics
CRITICAL CONSTRAINTS:
- Do NOT invent facts not present in the source information above
- Do NOT add speculative information or assumptions
- Do NOT fill sections with placeholder text or generic statements
- If information for a section is not available, OMIT the section entirely
- Base ALL content strictly on provided source information
Generate ONLY the markdown content (do not include Sources, Knowledge Graph, or Mind Map sections - those are added automatically).
MARKDOWN:"""
@@ -402,6 +412,13 @@ FORMATTING RULES:
- Bold important terms
- Add subsections (###) where it improves clarity
CRITICAL CONSTRAINTS:
- Do NOT rephrase facts in ways that change their meaning
- Do NOT remove ANY information unless explicitly superseded by newer facts
- Do NOT add information not present in existing content or new information
- Preserve exact quotes, dates, numbers, and names verbatim
- Do NOT fill gaps with assumptions or general knowledge
OUTPUT INSTRUCTIONS:
- Return complete page content (do not include Sources, Knowledge Graph, Mind Map - those are added automatically)
- Include updated "Changes & Updates" section noting what was changed today
@@ -409,13 +426,21 @@ OUTPUT INSTRUCTIONS:
RECONSTRUCTED MARKDOWN:"""
async def _call_llm(self, prompt: str) -> str:
"""Call LLM with prompt and return response."""
async def _call_llm(self, prompt: str, temperature: float = 0.3) -> str:
"""
Call LLM with prompt and return response.
Args:
prompt: The prompt text
temperature: Sampling temperature (0.0=deterministic, higher=creative)
Default 0.3 for controlled but natural content generation
"""
try:
response = await self.ollama.generate_text(
prompt=prompt,
model=self.model,
stream=False
stream=False,
temperature=temperature
)
if not response:
+20 -9
View File
@@ -1,5 +1,6 @@
"""Pytest configuration and shared fixtures for Library Desk tests."""
import os
import pytest
import pytest_asyncio
from typing import AsyncGenerator
@@ -7,6 +8,9 @@ from typing import AsyncGenerator
# Test configuration
pytest_plugins = ("pytest_asyncio",)
# Use real host for tests (services available at this IP)
TEST_HOST = os.environ.get("TEST_HOST", "192.168.86.149")
@pytest.fixture
def test_user() -> str:
@@ -17,49 +21,56 @@ def test_user() -> str:
@pytest.fixture
def neo4j_test_uri() -> str:
"""Test Neo4j URI."""
return "bolt://neo4j:7687"
return f"bolt://{TEST_HOST}:7687"
@pytest.fixture
def neo4j_test_auth() -> tuple:
"""Test Neo4j authentication."""
return ("neo4j", "test_password")
from src.config import get_settings
settings = get_settings()
return ("neo4j", settings.neo4j_password)
@pytest.fixture
def qdrant_test_url() -> str:
"""Test Qdrant URL."""
return "http://qdrant:6333"
return f"http://{TEST_HOST}:6333"
@pytest.fixture
def wikijs_test_config() -> dict:
"""Test Wiki.js configuration."""
from src.config import get_settings
settings = get_settings()
return {
"base_url": "http://wiki:3000",
"api_key": "test_api_key"
"base_url": f"http://{TEST_HOST}:3000",
"username": settings.wikijs_username,
"password": settings.wikijs_password
}
@pytest.fixture
def searxng_test_url() -> str:
"""Test SearXNG URL."""
return "http://searxng:8080"
return f"http://{TEST_HOST}:8080"
@pytest.fixture
def ollama_test_config() -> dict:
"""Test Ollama configuration."""
from src.config import get_settings
settings = get_settings()
return {
"base_url": "http://ollama:11434",
"model": "nomic-embed-text"
"base_url": f"http://{TEST_HOST}:11434",
"model": settings.ollama_embedding_model
}
@pytest.fixture
def redis_test_url() -> str:
"""Test Redis URL."""
return "redis://redis-shared:6379/4"
return f"redis://{TEST_HOST}:6379/4"
@pytest.fixture
+6 -6
View File
@@ -38,10 +38,10 @@ def settings():
@pytest_asyncio.fixture
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
"""Get connected Neo4j client."""
client = Neo4jClient(
uri=settings.neo4j_uri,
uri=neo4j_test_uri,
user=settings.neo4j_user,
password=settings.neo4j_password
)
@@ -51,12 +51,12 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
@pytest_asyncio.fixture
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
"""Get Wiki.js client."""
client = WikiJSClient(
base_url=settings.wikijs_url,
username=settings.wikijs_username,
password=settings.wikijs_password
base_url=wikijs_test_config["base_url"],
username=wikijs_test_config["username"],
password=wikijs_test_config["password"]
)
yield client
+53 -49
View File
@@ -43,10 +43,10 @@ def settings():
@pytest_asyncio.fixture
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
"""Get connected Neo4j client."""
client = Neo4jClient(
uri=settings.neo4j_uri,
uri=neo4j_test_uri,
user=settings.neo4j_user,
password=settings.neo4j_password
)
@@ -56,32 +56,35 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
@pytest.fixture
def qdrant_client(settings) -> QdrantClientWrapper:
def qdrant_client(qdrant_test_url) -> QdrantClientWrapper:
"""Get Qdrant client."""
return QdrantClientWrapper(url=settings.qdrant_url)
return QdrantClientWrapper(url=qdrant_test_url)
@pytest_asyncio.fixture
async def wiki_client(settings) -> AsyncGenerator[WikiJSClient, None]:
async def wiki_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
"""Get Wiki.js client."""
client = WikiJSClient(
base_url=settings.wikijs_url,
username=settings.wikijs_username,
password=settings.wikijs_password
base_url=wikijs_test_config["base_url"],
username=wikijs_test_config["username"],
password=wikijs_test_config["password"]
)
yield client
@pytest.fixture
def searxng_client(settings) -> SearXNGClient:
def searxng_client(searxng_test_url) -> SearXNGClient:
"""Get SearXNG client."""
return SearXNGClient(base_url=settings.searxng_url)
return SearXNGClient(base_url=searxng_test_url)
@pytest.fixture
def ollama_client(settings) -> OllamaClient:
def ollama_client(ollama_test_config) -> OllamaClient:
"""Get Ollama client."""
return OllamaClient(base_url=settings.ollama_url)
return OllamaClient(
base_url=ollama_test_config["base_url"],
model=ollama_test_config["model"]
)
@pytest.fixture
@@ -219,53 +222,54 @@ async def test_vector_data(vector_service, test_wiki_page):
# ============================================================================
class TestRRFFusion:
"""Test Reciprocal Rank Fusion algorithm."""
"""Test two-stage Reciprocal Rank Fusion algorithm."""
def test_rrf_single_source(self, hybrid_rag_service):
"""Test RRF with single source."""
results_by_source = {
"vector": [
{"page_id": 1, "title": "Doc 1", "content": "test"},
{"page_id": 2, "title": "Doc 2", "content": "test"}
]
}
def test_wiki_merge_single_source(self, hybrid_rag_service):
"""Test wiki merge with single source (vector only)."""
vector_results = [
{"page_id": 1, "title": "Doc 1", "content": "test"},
{"page_id": 2, "title": "Doc 2", "content": "test"}
]
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
merged = hybrid_rag_service._merge_wiki_sources(vector_results, [], k=60)
assert len(fused) == 2
assert fused[0]["rrf_score"] > fused[1]["rrf_score"] # Rank 1 > Rank 2
assert fused[0]["sources"] == ["vector"]
assert len(merged) == 2
assert merged[0]["wiki_rrf_score"] > merged[1]["wiki_rrf_score"] # Rank 1 > Rank 2
assert merged[0]["found_by"] == ["vector"]
def test_rrf_multiple_sources_same_doc(self, hybrid_rag_service):
"""Test RRF with same document from multiple sources."""
results_by_source = {
"vector": [{"page_id": 1, "title": "Doc 1", "content": "test"}],
"graph": [{"page_id": 1, "title": "Doc 1", "content": ""}],
}
def test_wiki_merge_multiple_sources_same_doc(self, hybrid_rag_service):
"""Test wiki merge with same document from vector and graph."""
vector_results = [{"page_id": 1, "title": "Doc 1", "content": "test"}]
graph_results = [{"page_id": 1, "title": "Doc 1", "content": ""}]
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
merged = hybrid_rag_service._merge_wiki_sources(vector_results, graph_results, k=60)
assert len(fused) == 1 # Deduplicated
assert len(fused[0]["sources"]) == 2 # Both sources
assert "vector" in fused[0]["sources"]
assert "graph" in fused[0]["sources"]
# RRF score should be sum: 1/(60+1) + 1/(60+1)
assert len(merged) == 1 # Deduplicated
assert len(merged[0]["found_by"]) == 2 # Both sources
assert "vector" in merged[0]["found_by"]
assert "graph" in merged[0]["found_by"]
# Wiki RRF score should be sum: 1/(60+1) + 1/(60+1)
expected_score = 1/61 + 1/61
assert abs(fused[0]["rrf_score"] - expected_score) < 0.001
assert abs(merged[0]["wiki_rrf_score"] - expected_score) < 0.001
def test_rrf_web_results(self, hybrid_rag_service):
"""Test RRF with web results (URL-based)."""
results_by_source = {
"web": [
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
]
}
def test_final_rrf_wiki_and_web(self, hybrid_rag_service):
"""Test final RRF between wiki and web results."""
# Pre-merged wiki results
wiki_results = [
{"page_id": 1, "title": "Wiki 1", "content": "test", "found_by": ["vector"]}
]
web_results = [
{"url": "https://example.com/1", "title": "Web 1", "content": "test"},
{"url": "https://example.com/2", "title": "Web 2", "content": "test"}
]
fused = hybrid_rag_service._reciprocal_rank_fusion(results_by_source, k=60)
fused = hybrid_rag_service._reciprocal_rank_fusion(wiki_results, web_results, k=60)
assert len(fused) == 2
assert fused[0]["result"]["url"] == "https://example.com/1"
assert len(fused) == 3
# Wiki rank 1 and web rank 1 should have same RRF score
wiki_score = next(r["rrf_score"] for r in fused if r["source_type"] == "wiki")
web_score = next(r["rrf_score"] for r in fused if r["source_type"] == "web")
assert abs(wiki_score - web_score) < 0.001 # Equal footing
class TestContextFormatting:
+16 -15
View File
@@ -30,10 +30,10 @@ def settings():
@pytest_asyncio.fixture
async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
async def neo4j_client(settings, neo4j_test_uri) -> AsyncGenerator[Neo4jClient, None]:
"""Get connected Neo4j client."""
client = Neo4jClient(
uri=settings.neo4j_uri,
uri=neo4j_test_uri,
user=settings.neo4j_user,
password=settings.neo4j_password
)
@@ -43,45 +43,46 @@ async def neo4j_client(settings) -> AsyncGenerator[Neo4jClient, None]:
@pytest.fixture
def qdrant_client(settings) -> QdrantClientWrapper:
def qdrant_client(settings, qdrant_test_url) -> QdrantClientWrapper:
"""Get Qdrant client."""
return QdrantClientWrapper(url=settings.qdrant_url)
return QdrantClientWrapper(url=qdrant_test_url)
@pytest_asyncio.fixture
async def wikijs_client(settings) -> AsyncGenerator[WikiJSClient, None]:
async def wikijs_client(wikijs_test_config) -> AsyncGenerator[WikiJSClient, None]:
"""Get Wiki.js client."""
client = WikiJSClient(
base_url=settings.wikijs_url,
api_key=settings.wikijs_api_key
base_url=wikijs_test_config["base_url"],
username=wikijs_test_config["username"],
password=wikijs_test_config["password"]
)
yield client
await client.close()
@pytest_asyncio.fixture
async def searxng_client(settings) -> AsyncGenerator[SearXNGClient, None]:
async def searxng_client(searxng_test_url) -> AsyncGenerator[SearXNGClient, None]:
"""Get SearXNG client."""
client = SearXNGClient(base_url=settings.searxng_url)
client = SearXNGClient(base_url=searxng_test_url)
yield client
await client.close()
@pytest_asyncio.fixture
async def ollama_client(settings) -> AsyncGenerator[OllamaClient, None]:
"""Get Ollama client."""
async def ollama_client(ollama_test_config) -> AsyncGenerator[OllamaClient, None]:
"""Get Ollama client for embeddings."""
client = OllamaClient(
base_url=settings.ollama_url,
model=settings.ollama_model
base_url=ollama_test_config["base_url"],
model=ollama_test_config["model"]
)
yield client
await client.close()
@pytest_asyncio.fixture
async def job_manager(settings) -> AsyncGenerator[JobManager, None]:
async def job_manager(redis_test_url) -> AsyncGenerator[JobManager, None]:
"""Get job manager."""
manager = JobManager(redis_url=settings.redis_url)
manager = JobManager(redis_url=redis_test_url)
await manager.connect()
yield manager
await manager.close()