Files
breakpilot-compliance/backend-compliance/compliance/services/recommendation_service.py
T
Benjamin Admin bb183b0e75
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / test-python-backend (push) Successful in 33s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Failing after 7s
CI / validate-canonical-controls (push) Successful in 16s
CI / loc-budget (push) Failing after 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m27s
CI / test-go (push) Failing after 46s
CI / iace-gt-coverage (push) Successful in 25s
feat(template-rules): backend foundation for profile-based document recommendations
Introduces the sustainable backend replacement for the hardcoded inline rules in
admin-compliance/app/sdk/document-generator/templateRecommendations.ts.

What's in this commit (Phase 1.1 - 1.5 of the rustling-yawning-boot plan):

- Migration 147: 4 new tables
  - compliance_template_rules (rule shell, document_type, current_version_id)
  - compliance_template_rule_versions (lifecycle, JSONB conditions,
    source_citation, change_summary, approval timestamps)
  - compliance_template_rule_approvals (audit trail)
  - compliance_tenant_rule_overrides (per-tenant classification overrides)
  Plus partial unique index for "only one is_live=1 version per rule".

- SQLAlchemy models: TemplateRuleDB, TemplateRuleVersionDB,
  TemplateRuleApprovalDB, TenantRuleOverrideDB (compliance/db/).

- Pydantic schemas (compliance/schemas/template_rule.py): full request/response
  set including RecommendationRequest/Result with reasons and override tracking.

- TemplateRuleService (compliance/services/): CRUD + Lifecycle transitions
  (submit_for_review/approve/publish/reject) following legal_document_service.py
  pattern with _transition() helper and approval audit trail. Plus tenant
  override upsert.

- RecommendationService: condition evaluator (eq, neq, in, not_in, gte/lte/gt/lt,
  exists, truthy) over JSONB conditions, override application, reason generation
  for human-readable explanations in workspace UI.

- 18 FastAPI routes in compliance/api/template_rule_routes.py covering rule CRUD,
  version lifecycle, override management and POST /recommend evaluation endpoint.

- Seed data: 33 initial rules ported from templateRecommendations.ts in
  compliance/data/template_rule_seed_data.py, written as published versions
  on first seed run. Idempotent via rule_key.

Phase 1.6 (pytest suite) and Phase 2 (editorial UI in admin-compliance) follow
in separate commits.

[migration-approved]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 23:13:50 +02:00

236 lines
7.3 KiB
Python

# mypy: disable-error-code="arg-type,assignment,union-attr"
"""
Recommendation Service — Auswertung der Empfehlungs-Regeln gegen ein Profil.
- Lädt alle live (is_live=1, status='published') Regeln
- Lädt Tenant-Overrides
- Iteriert Regeln, evaluiert ``conditions`` JSONB gegen Profil-Dict
- Wendet Overrides an (Klassifikation überschreiben oder Regel deaktivieren)
- Gruppiert in required/recommended/optional mit Begründung
"""
from typing import Any, Optional
from sqlalchemy.orm import Session
from compliance.db.template_rule_models import (
TemplateRuleDB,
TemplateRuleVersionDB,
TenantRuleOverrideDB,
)
from compliance.schemas.template_rule import (
RecommendationRequest,
RecommendationResult,
RecommendedItem,
)
# ---------------------------------------------------------------- condition eval
def _eval_clause(clause: dict[str, Any], profile: dict[str, Any]) -> bool:
"""Evaluate eine einzelne Bedingungsklausel gegen das Profil-Dict."""
field = clause.get("field")
op = clause.get("op")
expected = clause.get("value")
if not field or not op:
return False
actual = profile.get(field)
if op == "exists":
return actual is not None and actual != "" and actual is not False
if op == "eq":
return actual == expected
if op == "neq":
return actual != expected
if op == "in":
if not isinstance(expected, list):
return False
return actual in expected
if op == "not_in":
if not isinstance(expected, list):
return True
return actual not in expected
if op in ("gt", "gte", "lt", "lte"):
try:
a = _to_number(actual)
e = _to_number(expected)
except (TypeError, ValueError):
return False
if op == "gt":
return a > e
if op == "gte":
return a >= e
if op == "lt":
return a < e
if op == "lte":
return a <= e
if op == "truthy":
return bool(actual)
if op == "falsy":
return not bool(actual)
return False
def _to_number(v: Any) -> float:
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
# Buckets wie "50_249" → nimm untere Schwelle
if "_" in v:
head = v.split("_")[0]
return float(head)
return float(v)
raise ValueError(f"Cannot coerce to number: {v}")
def _eval_condition(condition: dict[str, Any], profile: dict[str, Any]) -> bool:
"""Evaluate die gesamte Bedingung (all/any über Klauseln)."""
if not condition:
return True # leere Bedingung → immer wahr
kind = condition.get("kind", "all")
clauses = condition.get("clauses") or []
if not clauses:
return True
if kind == "all":
return all(_eval_clause(c, profile) for c in clauses)
if kind == "any":
return any(_eval_clause(c, profile) for c in clauses)
return False
def _build_reason(
rule: TemplateRuleDB,
version: TemplateRuleVersionDB,
profile: dict[str, Any],
override: Optional[TenantRuleOverrideDB],
) -> str:
"""Menschenlesbare Begründung für den Reviewer im Workspace."""
parts: list[str] = []
conds = version.conditions or {}
clauses = conds.get("clauses") or []
matched = [
c for c in clauses if _eval_clause(c, profile)
]
if matched:
bits = []
for c in matched[:3]:
field = c.get("field", "?")
op = c.get("op", "?")
val = c.get("value", "?")
actual = profile.get(field)
bits.append(f"{field} ({actual}) {op} {val}")
parts.append("Trifft zu: " + "; ".join(bits))
else:
parts.append(f"Always-rule für '{rule.document_type}'")
if version.source_citation:
parts.append(f"Quelle: {version.source_citation}")
if override:
if override.override_classification:
parts.append(
f"Tenant-Override: {version.classification}"
f"{override.override_classification} ({override.reason})"
)
else:
parts.append(f"Tenant-Override: deaktiviert ({override.reason})")
return " · ".join(parts)
# ---------------------------------------------------------------- service
class RecommendationService:
def __init__(self, db: Session) -> None:
self.db = db
def recommend(
self,
request: RecommendationRequest,
tenant_id: Optional[str] = None,
) -> RecommendationResult:
profile = dict(request.profile or {})
if request.compliance_depth_level:
profile.setdefault("compliance_depth_level", request.compliance_depth_level)
# Live-Versionen aller Regeln laden
live_versions = (
self.db.query(TemplateRuleVersionDB)
.filter(TemplateRuleVersionDB.is_live == 1)
.all()
)
if not live_versions:
return RecommendationResult(
required=[], recommended=[], optional=[], profile_used=profile,
)
rule_ids = [v.rule_id for v in live_versions]
rules = {
r.id: r for r in
self.db.query(TemplateRuleDB)
.filter(TemplateRuleDB.id.in_(rule_ids))
.all()
}
# Tenant-Overrides
overrides: dict[Any, TenantRuleOverrideDB] = {}
if tenant_id:
for o in (
self.db.query(TenantRuleOverrideDB)
.filter(TenantRuleOverrideDB.tenant_id == tenant_id)
.filter(TenantRuleOverrideDB.rule_id.in_(rule_ids))
.all()
):
overrides[o.rule_id] = o
buckets: dict[str, list[RecommendedItem]] = {
"required": [], "recommended": [], "optional": [],
}
for v in live_versions:
rule = rules.get(v.rule_id)
if not rule:
continue
if not _eval_condition(v.conditions or {}, profile):
continue
override = overrides.get(v.rule_id)
base_class = v.classification
effective_class = base_class
if override:
if override.override_classification is None:
# Override = deaktiviert
continue
effective_class = override.override_classification
reason = _build_reason(rule, v, profile, override)
item = RecommendedItem(
document_type=rule.document_type,
title=rule.title,
rule_id=str(rule.id),
rule_key=rule.rule_key,
classification=effective_class,
base_classification=base_class,
source_citation=v.source_citation or "",
reason=reason,
override_applied=override is not None,
)
buckets[effective_class].append(item)
# Sortiert nach document_type stabil
for k in buckets:
buckets[k].sort(key=lambda i: i.document_type)
return RecommendationResult(
required=buckets["required"],
recommended=buckets["recommended"],
optional=buckets["optional"],
profile_used=profile,
)
__all__ = ["RecommendationService"]