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:
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user