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
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>
236 lines
7.3 KiB
Python
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"]
|