mirror of
http://100.103.83.12:3003/fegger/pv-agent.git
synced 2026-09-17 15:06:24 +00:00
feat(agent): add odoo review mode with plausibility verdict
This commit is contained in:
+85
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user