fix(librarian): map live HybridRAG response fields correctly

The client parsed field names the live library-desk service never
returns, so every result rendered as "unknown (score: 0.00)":

- source_type/sources -> source + sources (icons key off sources values)
- rrf_score -> score
- context -> formatted_context
- related_dossiers are per-result; top level aggregates unique titles
- synonyms live inside the keywords dict as a {term: [synonyms]} map

Also stop sending zero limits (service 422s on limit < 1); disabled
legs now rely on the enable_* flags with limits clamped to >= 1.

Adds a recorded live response as a fixture plus contract tests that
pin the mapping (non-unknown sources, non-zero scores, icon coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:00:10 +02:00
co-authored by Claude Fable 5
parent 6ec77091b6
commit 59f5b54ac9
5 changed files with 500 additions and 21 deletions
+37 -14
View File
@@ -56,12 +56,14 @@ class VectorSearchResult(BaseModel):
class HybridSearchResult(BaseModel):
"""Result from HybridRAG search."""
source: str # "vector", "graph", "web"
source: str # source_type: "wiki", "web", "volatile", "document"
sources: list[str] = Field(default_factory=list) # legs that found it: "vector", "graph", "web", ...
title: str
content: str
url: str | None = None
score: float
score: float # rrf_score from the live service
page_id: int | None = None
related_dossiers: list[dict[str, Any]] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
@@ -250,14 +252,16 @@ class LibraryDeskClient:
user = user or get_user()
client = self._ensure_client()
# The live service requires all limits >= 1 (422 otherwise);
# legs are disabled via the enable_* flags, not a zero limit.
payload = {
"query": query,
"config": {
"vector_limit": vector_limit,
"graph_limit": graph_limit,
"web_limit": web_limit,
"document_limit": document_limit,
"volatile_limit": volatile_limit,
"vector_limit": max(vector_limit, 1),
"graph_limit": max(graph_limit, 1),
"web_limit": max(web_limit, 1),
"document_limit": max(document_limit, 1),
"volatile_limit": max(volatile_limit, 1),
"enable_documents": document_limit > 0,
"enable_volatile": volatile_limit > 0,
"enable_web": web_limit > 0,
@@ -277,32 +281,51 @@ class LibraryDeskClient:
data = response.json()
# Parse results
# Parse results (live field names: source_type, sources, rrf_score,
# related_dossiers; older names kept as fallbacks)
results = []
for r in data.get("results", []):
results.append(HybridSearchResult(
source=r.get("source", "unknown"),
source=r.get("source_type") or r.get("source", "unknown"),
sources=r.get("sources", []),
title=r.get("title", ""),
content=r.get("content", ""),
url=r.get("url"),
score=r.get("score", 0.0),
score=r.get("rrf_score", r.get("score", 0.0)),
page_id=r.get("page_id"),
related_dossiers=r.get("related_dossiers", []),
metadata=r.get("metadata", {}),
))
# Handle keywords being either a list or a dict with core_keywords
# Handle keywords being either a list or a dict with core_keywords;
# the live service nests synonyms inside the keywords dict as a
# {term: [synonyms]} map.
raw_keywords = data.get("keywords", [])
raw_synonyms: Any = data.get("synonyms", [])
if isinstance(raw_keywords, dict):
keywords = raw_keywords.get("core_keywords", [])
raw_synonyms = raw_keywords.get("synonyms", {})
else:
keywords = raw_keywords
if isinstance(raw_synonyms, dict):
synonyms = [s for values in raw_synonyms.values() for s in values]
else:
synonyms = raw_synonyms
# Aggregate per-result related dossiers into unique top-level titles
related_dossiers: list[str] = []
for result in results:
for dossier in result.related_dossiers:
title = dossier.get("title", "")
if title and title not in related_dossiers:
related_dossiers.append(title)
return HybridRAGResponse(
results=results,
keywords=keywords,
synonyms=data.get("synonyms", []),
related_dossiers=data.get("related_dossiers", []),
formatted_context=data.get("formatted_context", ""),
synonyms=synonyms,
related_dossiers=related_dossiers,
formatted_context=data.get("context", data.get("formatted_context", "")),
search_id=data.get("search_id"),
timing=data.get("timing", {}),
)
+16 -7
View File
@@ -9,6 +9,18 @@ from src.core.logging_config import get_logger
logger = get_logger(__name__)
# Icons keyed by the values library-desk emits in each result's `sources`
# list (search legs) and `source_type` (result origin).
SOURCE_ICONS = {
"vector": "📄",
"graph": "🔗",
"web": "🌐",
"document": "📑",
"documents": "📑",
"volatile": "⚡",
"wiki": "📄",
}
# ============================================================================
# HybridRAG Search
@@ -76,13 +88,10 @@ async def hybrid_search(
# Add results
for i, result in enumerate(response.results, 1):
source_icon = {
"vector": "📄",
"graph": "🔗",
"web": "🌐",
"document": "📑",
"volatile": "⚡",
}.get(result.source, "•")
source_keys = result.sources or [result.source]
source_icon = "".join(
dict.fromkeys(SOURCE_ICONS.get(key, "•") for key in source_keys)
)
output_parts.append(
f"{i}. {source_icon} **{result.title}** (score: {result.score:.2f})"