M17: ground knowledge and integration evidence

This commit is contained in:
NuklearRabbit
2026-08-10 12:27:17 +02:00
parent 686795a452
commit 8030753dbc
15 changed files with 480 additions and 74 deletions
+47 -1
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.audit import AuditEvent
from app.schemas import CurrentUser
from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
@@ -22,6 +24,11 @@ class AskQuestionRequest(BaseModel):
language: SupportedLanguage = "en-GB"
class KnowledgeFeedbackRequest(BaseModel):
correlation_id: uuid.UUID
helpful: bool
@router.post("/questions", response_model=GroundedAnswer)
def ask_question(
body: AskQuestionRequest,
@@ -51,6 +58,45 @@ def ask_question(
return answer
@router.post("/feedback")
def record_feedback(
body: KnowledgeFeedbackRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, str]:
question_event = db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "knowledge_question_asked",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if question_event is None:
raise HTTPException(status_code=404, detail="Knowledge exchange not found")
existing = db.scalar(
select(AuditEvent).where(
AuditEvent.action == "knowledge_feedback_recorded",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if existing is not None:
existing.metadata_json = {"helpful": body.helpful}
else:
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="knowledge_feedback_recorded",
entity_type="knowledge",
correlation_id=body.correlation_id,
metadata={"helpful": body.helpful},
)
db.commit()
return {"status": "recorded"}
@router.get("/status", response_model=KnowledgeHealth)
def knowledge_status(
language: SupportedLanguage = "en-GB",
+17 -3
View File
@@ -17,9 +17,10 @@ _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}',
"fr-BE": "Selon « {title} » : {excerpt}",
}
_DEFAULT_LANGUAGE = "en-GB"
_MAX_SOURCE_CARDS = 3
_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
@@ -48,14 +49,26 @@ def _question_concepts(question: str) -> set[str]:
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]:
seen: set[tuple[str, str]] = set()
"""Collapse duplicate chunks and re-uploaded document versions.
RAGcore document/version UUIDs change across uploads, so they are not useful
deduplication keys. Human-visible citation identity is the normalized title,
section and excerpt.
"""
seen: set[tuple[str, str, str]] = set()
unique: list[SourceCard] = []
for source in sources:
key = (source.document_id, source.section)
key = (
source.title.strip().casefold(),
source.section.strip().casefold(),
" ".join(source.excerpt.split()).casefold(),
)
if key in seen:
continue
seen.add(key)
unique.append(source)
if len(unique) == _MAX_SOURCE_CARDS:
break
return unique
@@ -193,6 +206,7 @@ class RAGcoreKnowledgeProvider:
)
for citation in citations.values()
]
sources = _deduplicate_sources(sources)
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
+63 -6
View File
@@ -107,9 +107,7 @@ def test_demo_provider_grounds_damage_question_in_french():
def test_demo_provider_insufficient_evidence_message_is_localized():
provider = DemoKnowledgeProvider()
nl_answer = provider.ask(
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
)
nl_answer = provider.ask("Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE")
fr_answer = provider.ask(
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
)
@@ -142,9 +140,7 @@ def test_ask_question_is_audited_without_leaking_full_text(ops_client):
"/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"},
)
events = ops_client.get(
"/api/v1/audit", params={"action": "knowledge_question_asked"}
).json()
events = ops_client.get("/api/v1/audit", params={"action": "knowledge_question_asked"}).json()
assert len(events) >= 1
metadata = events[0]["metadata"]
assert "evidence_state" in metadata
@@ -288,6 +284,67 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
assert source.excerpt
def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
citations = []
for index in range(5):
citations.append(
{
"id": f"cite-{index}",
"document_id": f"doc-{index}",
"document_version_id": f"version-{index}",
"title": "Damage procedure" if index < 2 else f"Procedure {index}",
"section": "Return",
"excerpt": (
"Record visible damage before release."
if index < 2
else f"Unique procedure evidence {index}."
),
}
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))),
)
answer = provider.ask("What must I do about vehicle damage?", "dedupe-test")
assert len(answer.sources) == 3
assert sum(source.title == "Damage procedure" for source in answer.sources) == 1
def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client):
answer = ops_client.post(
"/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"},
).json()
payload = {"correlation_id": answer["correlation_id"], "helpful": True}
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
payload["helpful"] = False
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
events = ops_client.get(
"/api/v1/audit", params={"action": "knowledge_feedback_recorded"}
).json()
matching = [e for e in events if e["correlation_id"] == answer["correlation_id"]]
assert len(matching) == 1
assert matching[0]["metadata"]["helpful"] is False
def test_knowledge_feedback_cannot_target_another_users_exchange(client):
assert client.post("/api/v1/demo/login", json={"role": "rental_employee"}).status_code == 200
answer = client.post(
"/api/v1/knowledge/questions",
json={"question": "How do I register a vehicle return?"},
).json()
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
response = client.post(
"/api/v1/knowledge/feedback",
json={"correlation_id": answer["correlation_id"], "helpful": True},
)
assert response.status_code == 404
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")