feat(agent): add odoo review mode with plausibility verdict

This commit is contained in:
2026-09-17 00:29:03 +02:00
parent 7162726b66
commit ba4bf57dcf
10 changed files with 670 additions and 34 deletions
+85 -6
View File
@@ -41,13 +41,39 @@ class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class FactIn(StrictModel):
key: str = Field(pattern=r"^[a-z0-9_.\-]{1,64}$")
value: str = Field(min_length=1, max_length=200)
note: str | None = Field(default=None, max_length=200)
class ComputationIn(StrictModel):
label: str = Field(min_length=1, max_length=200)
result: str = Field(min_length=1, max_length=200)
basis: str | None = Field(default=None, max_length=200)
components: list[FactIn] = Field(default_factory=list, max_length=40)
class ReviewContextIn(StrictModel):
"""Schema-gebundener Odoo-Kontext (M4.2). Keine freien Objekte, keine
Personendaten-Felder — Odoo kuratiert die facts pro Workflow."""
facts: list[FactIn] = Field(default_factory=list, max_length=40)
computation: ComputationIn | None = None
note: str | None = Field(default=None, max_length=500)
class AskRequest(StrictModel):
question: str = Field(min_length=3, max_length=2000)
top_k: int | None = Field(default=None, ge=1, le=20)
mode: Literal["knowledge"] = Field(
mode: Literal["knowledge", "review"] = Field(
default="knowledge",
description="Derzeit ausschließlich KB-Wissen; kein Payroll-Datenkontext.",
description=(
"knowledge = KB-Wissen; review = Plausibilitätsprüfung eines "
"übermittelten Odoo-Ergebnisses (erfordert PV_REVIEW_MODE)."
),
)
context: ReviewContextIn | None = None
class SourceOut(StrictModel):
@@ -70,12 +96,26 @@ class PlannedQueryOut(StrictModel):
class GroundingOut(StrictModel):
data_scope: Literal["knowledge_base_only"] = DATA_SCOPE
data_scope: Literal[
"knowledge_base_only", "knowledge_base_plus_review_context"
] = DATA_SCOPE
citations_verified: bool
context_count: int
regenerations: int
class PlausibilityCheckOut(StrictModel):
status: Literal["ok", "warn", "open"]
aspect: str
detail: str
source_ids: list[str]
class PlausibilityOut(StrictModel):
verdict: Literal["plausible", "implausible", "not_checkable"]
checks: list[PlausibilityCheckOut]
class AskResponse(StrictModel):
api_version: Literal["v1"] = API_VERSION
request_id: str
@@ -99,6 +139,8 @@ class AskResponse(StrictModel):
latency_ms: int
regenerations: int = 0
ratings_enabled: bool = False
mode: Literal["knowledge", "review"] = "knowledge"
plausibility: PlausibilityOut | None = None
class HealthResponse(StrictModel):
@@ -306,7 +348,10 @@ def _extract_clarification(answer: str, refused: bool) -> str | None:
def _response_from_result(
result: dict, request_id: str, ratings_enabled: bool = False
result: dict,
request_id: str,
ratings_enabled: bool = False,
mode: str = "knowledge",
) -> AskResponse:
if not result["verified"]:
status = "uncertain"
@@ -315,6 +360,12 @@ def _response_from_result(
else:
status = "answered"
citations = list(result["citations"])
plausibility = None
if result.get("plausibility") is not None:
plausibility = PlausibilityOut(
verdict=result["plausibility"]["verdict"],
checks=[PlausibilityCheckOut(**c) for c in result["plausibility"]["checks"]],
)
return AskResponse(
request_id=request_id,
status=status,
@@ -337,6 +388,11 @@ def _response_from_result(
planned=result.get("planned", False),
planned_queries=result.get("planned_queries", []),
grounding=GroundingOut(
data_scope=(
"knowledge_base_plus_review_context"
if mode == "review"
else DATA_SCOPE
),
citations_verified=result["verified"],
context_count=result["n_context"],
regenerations=result["regenerations"],
@@ -346,6 +402,8 @@ def _response_from_result(
latency_ms=result["latency_ms"],
regenerations=result["regenerations"],
ratings_enabled=ratings_enabled,
mode=mode, # type: ignore[arg-type]
plausibility=plausibility,
)
@@ -353,6 +411,24 @@ def _ask(req: AskRequest, request: Request) -> AskResponse:
rag: AppState = request.app.state.rag
cfg = rag.ensure()
request_id = _request_id(request)
context: dict | None = None
if req.mode == "review":
if not cfg.review_mode:
raise HTTPException(
status_code=422,
detail="Der Review-Modus ist auf diesem Dienst nicht aktiviert.",
)
if req.context is None:
raise HTTPException(
status_code=422,
detail="Der Review-Modus erfordert einen schema-gebundenen Kontext.",
)
context = req.context.model_dump(mode="json")
elif req.context is not None:
raise HTTPException(
status_code=422,
detail="Kontext ist nur im Modus review erlaubt.",
)
try:
result = answer_question(
req.question,
@@ -360,6 +436,7 @@ def _ask(req: AskRequest, request: Request) -> AskResponse:
client=rag.get_client(),
retriever=rag.get_retriever(),
top_k=req.top_k,
context=context,
)
except Exception as exc:
logger.exception("Antwortgenerierung fehlgeschlagen request_id=%s", request_id)
@@ -369,12 +446,14 @@ def _ask(req: AskRequest, request: Request) -> AskResponse:
) from exc
result.pop("draft", None)
response = _response_from_result(
result, request_id, ratings_enabled=cfg.audit_enabled
result, request_id, ratings_enabled=cfg.audit_enabled, mode=req.mode
)
audit = rag.get_audit()
if audit is not None:
try:
audit.record_interaction(response.model_dump(mode="json"))
audit.record_interaction(
response.model_dump(mode="json"), context=context
)
except Exception:
# Die Fachantwort darf bei einem reinen Audit-Fehler nicht verloren gehen.
logger.exception("Audit-Protokollierung fehlgeschlagen request_id=%s", request_id)