M17: ground knowledge and integration evidence
This commit is contained in:
@@ -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,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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user