feat: add weekly quality-report endpoint writing dated wiki report

POST /maintenance/quality-report {user}:
- runs the tenant-scoped duplicate scan (cosine >= threshold, default 0.9)
- flags stale pages: not updated in stale_days AND <= max_search_hits
  SearchQuery FOUND hits from the tenant's graph data
- lists pages missing tags/description (report subtree exempt)
- folds in the latest integrity-check results (Redis cache from
  /maintenance/integrity-check, or run inline when absent)
- writes the dated report to users/{user}/system/quality-reports/YYYY-MM-DD
  via the existing wiki write path; same-day reruns update the same page
  (page id remembered in Redis because the Wiki.js listing lags creation)
- response returns the full markdown report + page path + counts +
  duration_ms

Also fixes WikiJSClient.update_page: Wiki.js 2.x requires tags on the
update mutation (server maps over it unconditionally); calls without tags
failed with "Cannot read properties of undefined (reading 'map')" -
which also silently broke the consolidation page-update path. Current
tags are now preserved when the caller supplies none.

Verified live end-to-end on the local dev server as llm_tester
(tests/test_quality_report_live.py, integration-marked): probe page
flagged for missing metadata, report page written and fetched back,
same-day rerun updates in place, teardown leaves zero llm_tester pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 12:22:19 +02:00
co-authored by Claude Fable 5
parent 77dc5b00a1
commit 191a8be6c5
5 changed files with 768 additions and 4 deletions
+9 -3
View File
@@ -500,15 +500,21 @@ class WikiJSClient:
}
"""
variables = {"id": page_id}
# Wiki.js 2.x requires `tags` on the update mutation (the server
# unconditionally maps over it; omitting it fails with "Cannot read
# properties of undefined (reading 'map')"). Preserve the page's
# current tags when the caller does not supply any.
if tags is None:
current = await self.get_page(page_id)
tags = (current or {}).get("tags") or []
variables = {"id": page_id, "tags": tags}
if content is not None:
variables["content"] = content
if title is not None:
variables["title"] = title
if description is not None:
variables["description"] = description
if tags is not None:
variables["tags"] = tags
if is_published is not None:
variables["isPublished"] = is_published
+360 -1
View File
@@ -23,7 +23,7 @@ from src.core.dependencies import (
)
from src.core.multi_tenancy import RequiredUser, sanitize_user_id
from src.config import get_settings
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__)
@@ -1342,3 +1342,362 @@ async def integrity_check(
except Exception as e:
logger.error(f"Integrity check failed for {request.user}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Integrity check failed")
# ========== Weekly quality report ==========
QUALITY_REPORT_PATH_TEMPLATE = "users/{tenant}/system/quality-reports/{date}"
class QualityReportRequest(BaseModel):
"""Request body for /maintenance/quality-report."""
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — the report is scoped to this tenant."
)
stale_days: int = Field(
default=30, ge=1, le=365,
description="Pages not updated in this many days are stale candidates"
)
max_search_hits: int = Field(
default=1, ge=0, le=100,
description="A stale candidate is only flagged when its SearchQuery hit count is <= this"
)
dedup_threshold: float = Field(
default=0.9, ge=0.5, le=1.0,
description="Cosine similarity threshold for the duplicate scan"
)
write_page: bool = Field(
default=True,
description="Write the dated report page to the tenant's wiki (users/{user}/system/quality-reports/YYYY-MM-DD)"
)
class QualityReportResponse(BaseModel):
"""Weekly quality report for one tenant."""
success: bool
user: str
generated_at: str
page_path: Optional[str] = Field(
default=None, description="Wiki path of the written report page (None when write_page=false)"
)
page_id: Optional[int] = None
report: str = Field(description="Full markdown report content")
duplicate_groups: List[Dict[str, Any]] = Field(default_factory=list)
stale_pages: List[Dict[str, Any]] = Field(default_factory=list)
pages_missing_metadata: List[Dict[str, Any]] = Field(default_factory=list)
integrity: Optional[Dict[str, Any]] = Field(
default=None, description="Latest integrity-check result (cached or run inline)"
)
counts: Dict[str, int] = Field(default_factory=dict)
duration_ms: float = 0.0
def _parse_wiki_timestamp(value: Any) -> Optional[datetime]:
"""Parse a Wiki.js ISO timestamp ('...Z' or offset) to aware UTC."""
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except ValueError:
return None
async def _get_search_hit_counts(graph_service: GraphService, user: str) -> Dict[int, int]:
"""
Per-page SearchQuery FOUND-hit counts from the tenant's graph data.
Returns {page_id: hits} for every tenant Document node.
"""
from src.core.multi_tenancy import get_neo4j_user_base_label, get_neo4j_user_label
base_label = get_neo4j_user_base_label(user)
doc_label = get_neo4j_user_label(user)
query = f"""
MATCH (d:{doc_label}:Document)
WHERE d.page_id IS NOT NULL
OPTIONAL MATCH (sq:{base_label}_SearchQuery:SearchQuery)-[f:FOUND]->(d)
RETURN d.page_id AS page_id, count(f) AS hits
"""
try:
rows = await graph_service.neo4j.execute_query(query, {})
return {row["page_id"]: row["hits"] for row in rows}
except Exception as e:
logger.warning(f"Failed to get search hit counts for {user}: {e}")
return {}
def _render_quality_report(
user: str,
generated_at: str,
duplicate_scan: Dict[str, Any],
stale_pages: List[Dict[str, Any]],
missing_metadata: List[Dict[str, Any]],
integrity: Optional[Dict[str, Any]],
stale_days: int,
max_search_hits: int,
dedup_threshold: float,
) -> str:
"""Render the markdown report page content."""
lines = [
f"Automated quality report for tenant `{user}`, generated {generated_at}.",
"",
"## Summary",
"",
"| Metric | Count |",
"|---|---|",
f"| Potential duplicate page pairs (cosine ≥ {dedup_threshold}) | {len(duplicate_scan.get('duplicate_groups', []))} |",
f"| Stale pages (> {stale_days}d old, ≤ {max_search_hits} search hits) | {len(stale_pages)} |",
f"| Pages missing tags/description | {len(missing_metadata)} |",
]
if integrity:
counts = integrity.get("counts", {})
lines += [
f"| Pages without vectors (integrity) | {counts.get('pages_without_vectors', 0)} |",
f"| Orphaned vector chunks (integrity) | {counts.get('orphaned_vector_chunks', 0)} |",
f"| Documents without wiki page (integrity) | {counts.get('documents_without_wiki', 0)} |",
f"| Unexpected Qdrant collections (integrity) | {counts.get('unexpected_collections', 0)} |",
]
lines += ["", "## Potential duplicates", ""]
groups = duplicate_scan.get("duplicate_groups", [])
if groups:
for g in groups:
pages = g.get("pages", [])
refs = "".join(f"`{p.get('path')}` ({p.get('title')})" for p in pages)
lines.append(
f"- {refs} — similarity {g.get('max_similarity', 0):.3f}, "
f"{g.get('matching_chunk_pairs', 0)} matching chunk pair(s)"
)
else:
lines.append("_None found._")
lines += ["", f"## Stale pages (not updated in {stale_days} days, ≤ {max_search_hits} search hits)", ""]
if stale_pages:
for p in stale_pages:
lines.append(
f"- `{p['path']}` ({p['title']}) — last updated {p['updated_at']}, "
f"{p['search_hits']} search hit(s)"
)
else:
lines.append("_None found._")
lines += ["", "## Pages missing metadata", ""]
if missing_metadata:
for p in missing_metadata:
lines.append(f"- `{p['path']}` ({p['title']}) — missing: {', '.join(p['missing'])}")
else:
lines.append("_None found._")
lines += ["", "## Integrity check", ""]
if integrity:
lines.append(f"Source: {integrity.get('source', 'unknown')} (generated {integrity.get('generated_at', '?')})")
lines.append("")
for key, value in integrity.get("counts", {}).items():
lines.append(f"- {key}: {value}")
unexpected = integrity.get("unexpected_collections", [])
if unexpected:
lines.append("")
lines.append("Unexpected Qdrant collections:")
for c in unexpected:
lines.append(f"- `{c.get('name')}` ({c.get('category')})")
else:
lines.append("_No integrity data available._")
return "\n".join(lines) + "\n"
@router.post("/quality-report", response_model=QualityReportResponse)
async def quality_report(
request: QualityReportRequest,
vector_service: VectorServiceDep = None,
graph_service: GraphServiceDep = None,
wiki_client: WikiJSDep = None,
qdrant: QdrantDep = None,
redis: RedisDep = None,
api_key: str = Depends(verify_api_key)
):
"""
Weekly quality report for one tenant.
Runs the duplicate scan, flags stale pages (not updated in N days AND a
low SearchQuery hit count from the graph data), lists pages missing
tags/description, folds in the latest integrity-check results (cached in
Redis by /maintenance/integrity-check, or run inline when absent), and
writes a dated report page to the tenant's wiki under
`users/{user}/system/quality-reports/YYYY-MM-DD`.
**Scheduler Task** — weekly Sunday 03:00, see docs/scheduler-tasks.md.
"""
import json as _json
start_time = time.time()
user = request.user
tenant = sanitize_user_id(user)
tenant_prefix = f"users/{tenant}"
system_prefix = f"{tenant_prefix}/system/"
now = datetime.now(timezone.utc)
generated_at = now.isoformat()
try:
# 1. Duplicate scan (tenant-scoped, read-only)
duplicate_scan = await vector_service.find_duplicate_pairs(
user=user,
similarity_threshold=request.dedup_threshold
)
# 2. Page inventory + search-hit counts
pages = await wiki_client.list_all_pages(path_prefix=tenant_prefix)
# The report subtree itself is exempt from quality checks
pages = [
p for p in pages
if not str(p.get("path", "")).lstrip("/").startswith(system_prefix)
]
hit_counts = await _get_search_hit_counts(graph_service, user)
stale_cutoff = now - timedelta(days=request.stale_days)
stale_pages = []
missing_metadata = []
for p in pages:
path = p.get("path", "")
title = p.get("title", "")
updated_at = _parse_wiki_timestamp(p.get("updatedAt"))
hits = hit_counts.get(p.get("id"), 0)
if updated_at and updated_at < stale_cutoff and hits <= request.max_search_hits:
stale_pages.append({
"page_id": p.get("id"),
"path": path,
"title": title,
"updated_at": updated_at.date().isoformat(),
"search_hits": hits,
})
missing = []
if not p.get("tags"):
missing.append("tags")
if not (p.get("description") or "").strip():
missing.append("description")
if missing:
missing_metadata.append({
"page_id": p.get("id"),
"path": path,
"title": title,
"missing": missing,
})
# 3. Integrity results: latest cached report, or run inline
integrity: Optional[Dict[str, Any]] = None
if redis:
try:
cached = await redis.get(INTEGRITY_LATEST_KEY.format(user=user))
if cached:
integrity = _json.loads(cached)
integrity["source"] = "cached"
except Exception as e:
logger.warning(f"Failed to read cached integrity report: {e}")
if integrity is None:
inline = await run_integrity_check(
user=user,
vector_service=vector_service,
graph_service=graph_service,
wiki_client=wiki_client,
qdrant=qdrant
)
integrity = inline.model_dump(mode="json")
integrity["source"] = "inline"
# 4. Render + write the dated report page
report_content = _render_quality_report(
user=user,
generated_at=generated_at,
duplicate_scan=duplicate_scan,
stale_pages=stale_pages,
missing_metadata=missing_metadata,
integrity=integrity,
stale_days=request.stale_days,
max_search_hits=request.max_search_hits,
dedup_threshold=request.dedup_threshold,
)
page_path = None
page_id = None
if request.write_page:
date_str = now.date().isoformat()
page_path = QUALITY_REPORT_PATH_TEMPLATE.format(tenant=tenant, date=date_str)
title = f"Quality Report {date_str}"
# Same-day reruns must UPDATE the existing page. The Wiki.js page
# listing updates asynchronously after creation, so the page id
# written today is remembered in Redis and used directly.
page_id_key = f"library:quality_report:page:{tenant}:{date_str}"
if redis:
try:
cached_id = await redis.get(page_id_key)
if cached_id:
page_id = int(cached_id)
except Exception as e:
logger.warning(f"Failed to read quality-report page id: {e}")
if page_id is None:
existing = await wiki_client.list_all_pages(path_prefix=page_path)
exact = [
p for p in existing
if str(p.get("path", "")).lstrip("/") == page_path
]
if exact:
page_id = exact[0]["id"]
if page_id is not None:
await wiki_client.update_page(page_id=page_id, content=report_content)
logger.info(f"Updated quality report page {page_path} (id={page_id})")
else:
created = await wiki_client.create_page(
path=page_path,
title=title,
content=report_content,
description=f"Automated weekly quality report for {user}",
tags=["quality-report", "auto-generated"],
is_published=True,
)
page_id = created.get("id") if created else None
logger.info(f"Created quality report page {page_path} (id={page_id})")
if redis and page_id:
try:
await redis.setex(page_id_key, 86400 * 2, str(page_id))
except Exception as e:
logger.warning(f"Failed to cache quality-report page id: {e}")
duration_ms = (time.time() - start_time) * 1000
counts = {
"duplicate_groups": len(duplicate_scan.get("duplicate_groups", [])),
"stale_pages": len(stale_pages),
"pages_missing_metadata": len(missing_metadata),
"pages_checked": len(pages),
}
logger.info(f"Quality report for {user}: {counts} in {duration_ms:.0f}ms")
return QualityReportResponse(
success=True,
user=user,
generated_at=generated_at,
page_path=page_path,
page_id=page_id,
report=report_content,
duplicate_groups=duplicate_scan.get("duplicate_groups", []),
stale_pages=stale_pages,
pages_missing_metadata=missing_metadata,
integrity=integrity,
counts=counts,
duration_ms=duration_ms
)
except Exception as e:
logger.error(f"Quality report failed for {user}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Quality report failed")