[ADD] l10n_at_payroll_agent: PV-agent review bridge for payout plausibility

PV-agent bridge (D25/M4.1): Odoo stays the system of record; the
standalone, KB-bound knowledge service (pv-agent) reviews plausibility
and returns evidence-backed answers (mode=review, agent API v1). No
back path: the agent never calls Odoo and receives no personal data —
only the minimized, schema-bound review context.

- context builder: projects a computed draft payslip with an
  ATP_PRAMIE input (planned one-off payout) onto the review context
  (facts + computation): gross wage (hr.version.wage), applying
  collective agreement (version.kv_id) and the draft's DG/LST lines
  (SVDG_*/LST_*/FLAFDB/KOMMST/DZ/BVG/WIEN_DAG)
- client (stdlib urllib, no extra dependency): POST /v1/ask with
  X-Request-ID, bearer key from company settings (pv_agent_url /
  pv_agent_api_key, group_hr_payroll_user); neutral UserErrors,
  technical details only in the server log
- review wizard on the draft payslip: agent answer (evidence-backed),
  structured verdict (plausible / implausible / not checkable) and
  checks in the dialog
- security: wizard bound to hr_payroll.group_hr_payroll_user; agent
  service key stored on res.company, never logged; 5 tests
This commit is contained in:
2026-09-17 13:50:18 +02:00
parent ec33dfacf2
commit a848c07e96
11 changed files with 558 additions and 0 deletions
@@ -0,0 +1,123 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""PV-Agent-Client: HTTP-Zugriff auf den eigenständigen Wissensdienst.
Odoo orchestriert und rechnet (System of Record, D25); der Agent antwortet
streng KB-gebunden. Der Client sendet ausschließlich Frage, Modus und den
schema-gebundenen Kontext — keine Personendaten, keine Rohobjekte. Der
Service-Key liegt am Unternehmen (`pv_agent_api_key`) und wird niemals
geloggt. HTTP-Zugriff über die stdlib (urllib) — keine Zusatzabhängigkeit.
"""
import json
import urllib.error
import urllib.request
from odoo import _, fields, models
from odoo.exceptions import UserError
DEFAULT_PV_AGENT_URL = "http://127.0.0.1:8080"
REQUEST_TIMEOUT_S = 240.0 # KB-Antworten beobachtet: ~40-160 s
class PvAgentHttpError(RuntimeError):
"""Interner Fehler-Wrapper für den Client-Test-Seam."""
def __init__(self, status, detail=""):
super().__init__(f"HTTP {status}: {detail}")
self.status = status
self.detail = detail
def _post_json(url, payload, api_key, request_id, timeout_s):
"""Test-Seam: einziger HTTP-Ausgang des Moduls (stdlib-only)."""
headers = {
"Content-Type": "application/json",
"X-Request-ID": request_id,
}
if api_key:
headers["Authorization"] = "Bearer " + api_key
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(request, timeout=timeout_s) as response:
return response.status, json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", errors="replace")
except OSError:
pass
raise PvAgentHttpError(exc.code, detail) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise PvAgentHttpError(0, str(exc)) from exc
def _error_detail(status, detail):
"""Aus der Fehlerantwort des Agenten die neutrale Detailzeile ziehen —
der Agent selbst liefert keine internen Hosts/Exceptions (v1-Vertrag)."""
try:
import json as _json
body = _json.loads(detail)
if isinstance(body, dict) and isinstance(body.get("detail"), str):
return body["detail"]
except (ValueError, TypeError):
pass
return ""
class PvAgentClient(models.AbstractModel):
_name = "pv.agent.client"
_description = "PV-Agent-Client (Wissensdienst / Review)"
def _l10n_at_pv_agent_url(self):
return self.env.company.pv_agent_url or DEFAULT_PV_AGENT_URL
def _l10n_at_pv_agent_api_key(self):
return self.env.company.pv_agent_api_key or ""
def l10n_at_pv_ask_review(self, question, context, request_id):
"""POST /v1/ask (mode=review) — liefert die geprüfte Antwort des
Agenten (dict). Fehler werden als UserError mit neutraler Meldung
abgebildet; technische Details stehen nur im Server-Log."""
url = self._l10n_at_pv_agent_url().rstrip("/") + "/v1/ask"
payload = {
"question": question,
"mode": "review",
"context": context,
}
try:
status, body = _post_json(
url, payload, self._l10n_at_pv_agent_api_key(), request_id,
REQUEST_TIMEOUT_S)
except PvAgentHttpError as exc:
if exc.status == 401:
raise UserError(_(
"PV-Agent: Service-Key fehlt oder ist ungültig. "
"PV-Agent-API-Key in den Unternehmensstammdaten prüfen."))
if exc.status == 422:
raise UserError(_(
"PV-Agent hat die Anfrage abgelehnt: %s",
_error_detail(exc.status, exc.detail) or "ungültige Anfrage"))
if exc.status in (0, 502, 503, 504):
raise UserError(_(
"PV-Agent nicht erreichbar (URL: %s). Dienststatus und "
"PV-Agent-URL in den Unternehmensstammdaten prüfen.",
self._l10n_at_pv_agent_url()))
raise UserError(_("PV-Agent-Fehler (HTTP %s).", exc.status)) from exc
return body
class ResCompany(models.Model):
_inherit = "res.company"
pv_agent_url = fields.Char(
string="PV-Agent-URL",
default=DEFAULT_PV_AGENT_URL,
help="Basis-URL des eigenständigen Wissensdienstes, z. B. "
"http://127.0.0.1:8080 wenn der Agent am selben Host läuft.",
groups="hr_payroll.group_hr_payroll_user")
pv_agent_api_key = fields.Char(
string="PV-Agent Service-Key",
help="Bearer-Key der Agent-API. Nur im Unternehmen hinterlegen — "
"niemals in Logs oder Meldungen ausgeben.",
groups="hr_payroll.group_hr_payroll_user")