M25: expose provenance-aware knowledge statistics
This commit is contained in:
@@ -100,6 +100,30 @@ def record_feedback(
|
||||
@router.get("/status", response_model=KnowledgeHealth)
|
||||
def knowledge_status(
|
||||
language: SupportedLanguage = "en-GB",
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> KnowledgeHealth:
|
||||
return get_knowledge_provider().health(language)
|
||||
health = get_knowledge_provider().health(language)
|
||||
if health.provider != "ragcore":
|
||||
return health
|
||||
|
||||
latest_sync = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest_sync is None:
|
||||
return health
|
||||
|
||||
reported = latest_sync.after_json or {}
|
||||
synced = reported.get("synced")
|
||||
failed = reported.get("failed")
|
||||
return health.model_copy(
|
||||
update={
|
||||
"reported_synced_document_count": synced if isinstance(synced, int) else None,
|
||||
"reported_failed_document_count": failed if isinstance(failed, int) else None,
|
||||
"last_sync_at": latest_sync.occurred_at,
|
||||
"statistics_state": "sync_reported",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Protocol
|
||||
|
||||
@@ -8,6 +9,7 @@ from pydantic import BaseModel
|
||||
from app.core.config import get_settings
|
||||
|
||||
EvidenceState = Literal["grounded", "insufficient", "unavailable"]
|
||||
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
|
||||
|
||||
|
||||
class SourceCard(BaseModel):
|
||||
@@ -36,6 +38,11 @@ class KnowledgeHealth(BaseModel):
|
||||
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
|
||||
# unknown, never "zero procedures".
|
||||
document_count: int | None
|
||||
source_document_count: int
|
||||
reported_synced_document_count: int | None
|
||||
reported_failed_document_count: int | None
|
||||
last_sync_at: datetime | None
|
||||
statistics_state: KnowledgeStatisticsState
|
||||
|
||||
|
||||
class KnowledgeProvider(Protocol):
|
||||
|
||||
@@ -322,6 +322,11 @@ class DemoKnowledgeProvider:
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
document_count=self._document_count_by_language[language],
|
||||
source_document_count=self._document_count_by_language[language],
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
def _score(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
|
||||
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||
|
||||
@@ -140,6 +143,10 @@ class RAGcoreKnowledgeProvider:
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
available = False
|
||||
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||
source_document_count = sum(
|
||||
document.language == language
|
||||
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
|
||||
)
|
||||
return KnowledgeHealth(
|
||||
provider=self.name,
|
||||
available=available,
|
||||
@@ -150,6 +157,11 @@ class RAGcoreKnowledgeProvider:
|
||||
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
|
||||
# so the UI never turns this into the misleading claim "0 procedures".
|
||||
document_count=None,
|
||||
source_document_count=source_document_count,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="not_reported",
|
||||
)
|
||||
|
||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||
|
||||
@@ -5,7 +5,9 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.api.routers import knowledge as knowledge_router
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
||||
|
||||
@@ -73,6 +75,8 @@ def test_demo_provider_health_reports_document_count():
|
||||
assert health.provider == "demo"
|
||||
assert health.available is True
|
||||
assert health.document_count == 11
|
||||
assert health.source_document_count == 11
|
||||
assert health.statistics_state == "verified"
|
||||
|
||||
|
||||
def test_demo_provider_health_reports_document_count_per_language():
|
||||
@@ -154,6 +158,46 @@ def test_knowledge_status_endpoint(ops_client):
|
||||
assert response.json()["provider"] == "demo"
|
||||
|
||||
|
||||
def test_ragcore_status_separates_sync_report_from_unverifiable_index(
|
||||
client, ops_client, monkeypatch
|
||||
):
|
||||
class FakeRagcoreProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="ready",
|
||||
tenant="fleet-ops",
|
||||
workspace="operations",
|
||||
collection="internal-procedures",
|
||||
document_count=None,
|
||||
source_document_count=11,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="not_reported",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", FakeRagcoreProvider)
|
||||
settings = get_settings()
|
||||
sync = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": "rag-statistics-test", "synced": 32, "failed": 1},
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert sync.status_code == 200
|
||||
|
||||
response = ops_client.get("/api/v1/knowledge/status?language=nl-BE")
|
||||
assert response.status_code == 200
|
||||
status = response.json()
|
||||
assert status["document_count"] is None
|
||||
assert status["source_document_count"] == 11
|
||||
assert status["reported_synced_document_count"] == 32
|
||||
assert status["reported_failed_document_count"] == 1
|
||||
assert status["last_sync_at"] is not None
|
||||
assert status["statistics_state"] == "sync_reported"
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
self.status_code = status_code
|
||||
@@ -224,6 +268,9 @@ def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||
health = provider.health()
|
||||
assert health.provider == "ragcore"
|
||||
assert health.available is True
|
||||
assert health.document_count is None
|
||||
assert health.source_document_count == 11
|
||||
assert health.statistics_state == "not_reported"
|
||||
|
||||
|
||||
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user