feat(agent): 4-Status-Modell (NOT_APPLICABLE/INSUFFICIENT_EVIDENCE/POSSIBLY_APPLICABLE) für Impressum
Kanonisches Compliance-Datenmodell, Impressum-Agent als Referenz: - CheckStatus-Enum + Finding.status GETRENNT von severity (Verdikt ≠ Risiko) - Unbestimmte Rechtsform (weder Text noch Wizard) → INSUFFICIENT_EVIDENCE (INFO) statt hartem HIGH-FAIL; legal_form_dependent-Gate + detect_legal_form_present - §18-MStV-Graubereich (Corporate-Blog via has_editorial_content) → POSSIBLY_APPLICABLE (LOW Prüf-Hinweis); 3-stufig via scope_disposition - Recommendations nur aus echten FAILs; mc_insufficient/mc_possibly-Aggregate - Frontend: Verdikt-Pill + Coverage-Vokabular - 19 neue Tests (test_four_status.py, AgentFindingCard); CI-Suite 204 grün, v3 25 / GT 13 unverändert Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,21 @@ class Severity(str, Enum):
|
||||
INFO = "INFO"
|
||||
|
||||
|
||||
class CheckStatus(str, Enum):
|
||||
"""Verdikt eines Checks — GETRENNT vom Risiko (severity).
|
||||
|
||||
User-Vorgabe 2026-06-10 (kanonisches Datenmodell):
|
||||
- Applicability ≠ Compliance: NOT_APPLICABLE ist KEIN FAIL.
|
||||
- Unknown ≠ Fail: nicht bestimmbar → INSUFFICIENT_EVIDENCE, kein FAIL.
|
||||
severity bleibt die Risiko-Achse (HIGH/…/INFO); status ist das Urteil.
|
||||
"""
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
NOT_APPLICABLE = "not_applicable"
|
||||
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
||||
POSSIBLY_APPLICABLE = "possibly_applicable"
|
||||
|
||||
|
||||
class SourceType(str, Enum):
|
||||
"""Wo kommt das Finding her? Für die auditfeste Beweiskette."""
|
||||
MC = "mc" # Machine-Check (deterministisch)
|
||||
@@ -50,12 +65,14 @@ class EvidenceSource(BaseModel):
|
||||
|
||||
class Finding(BaseModel):
|
||||
"""Ein einzelnes Audit-Finding aus einem Specialist-Agent."""
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
model_config = ConfigDict(use_enum_values=True, validate_default=True)
|
||||
|
||||
check_id: str # z.B. IMPRESSUM-AGENT-HANDELSREGISTER
|
||||
agent: str # impressum_v2
|
||||
agent_version: str # 2.0
|
||||
field_id: str = "" # field-key innerhalb des Agenten
|
||||
# Verdikt (was IST der Fall) — getrennt vom Risiko (severity).
|
||||
status: CheckStatus = CheckStatus.FAIL
|
||||
severity: Severity
|
||||
severity_reason: str = ""
|
||||
title: str
|
||||
@@ -78,9 +95,17 @@ class Recommendation(BaseModel):
|
||||
|
||||
|
||||
class McCoverage(BaseModel):
|
||||
"""Welche MC hat der Agent geprüft + Ergebnis."""
|
||||
"""Welche MC hat der Agent geprüft + Ergebnis.
|
||||
|
||||
status-Vokabular (mappt auf CheckStatus):
|
||||
ok → PASS
|
||||
high | medium | low → FAIL (Risiko = severity der Quelle)
|
||||
na → NOT_APPLICABLE (Rechtsform/Branche)
|
||||
insufficient_evidence → INSUFFICIENT_EVIDENCE (nicht bestimmbar)
|
||||
skipped → Dokument nicht ladbar / zu kurz
|
||||
"""
|
||||
mc_id: str
|
||||
status: str # ok | high | medium | low | na | skipped
|
||||
status: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@@ -127,6 +152,8 @@ class AgentOutput(BaseModel):
|
||||
mc_high: int = 0
|
||||
mc_medium: int = 0
|
||||
mc_low: int = 0
|
||||
mc_insufficient: int = 0
|
||||
mc_possibly: int = 0
|
||||
|
||||
|
||||
# Verbotene Wörter im Output — sicherheitshalber, damit kein Agent
|
||||
|
||||
@@ -59,4 +59,11 @@ def scan_context_to_scope(scan_context: dict | None) -> list[str]:
|
||||
if legal_form in _NON_VERTRETUNG_FORMS:
|
||||
scope.add("keine_vertretung")
|
||||
|
||||
# ── 4-Status: Rechtsform ueberhaupt bekannt? ──
|
||||
# Hat der Wizard eine Rechtsform geliefert, ist die Register-/Vertretungs-
|
||||
# pflicht belastbar entscheidbar (FAIL bei Fehlen). Fehlt sie hier UND im
|
||||
# Text → INSUFFICIENT_EVIDENCE (Entscheidung trifft der Agent).
|
||||
if legal_form:
|
||||
scope.add("legal_form_known")
|
||||
|
||||
return sorted(scope)
|
||||
|
||||
@@ -28,6 +28,7 @@ from .._base import (
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
BaseSpecialistAgent,
|
||||
CheckStatus,
|
||||
EscalationLog,
|
||||
EvidenceSource,
|
||||
Finding,
|
||||
@@ -39,7 +40,13 @@ from .._base import (
|
||||
from .._pattern_library import record as record_pattern
|
||||
from .._rollup import rollup
|
||||
from .._semantic_validator import build_rename_action, validate_present
|
||||
from .mcs import MC_IDS, MCS, detect_automotive, scope_matches
|
||||
from .mcs import (
|
||||
MC_IDS,
|
||||
MCS,
|
||||
detect_automotive,
|
||||
detect_legal_form_present,
|
||||
scope_disposition,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -113,11 +120,18 @@ class ImpressumAgent(BaseSpecialistAgent):
|
||||
# Pattern-MCs die Findings-Quelle. field_id = semantisches Feld
|
||||
# (passt zum Semantic-Validator + den GT-Tests).
|
||||
is_auto = "automotive" in scope
|
||||
# 4-Status: ist die Rechtsform ueberhaupt bestimmbar (Wizard ODER
|
||||
# im Text genannt)? Wenn nicht, duerfen rechtsform-abhaengige Pflichten
|
||||
# NICHT hart als FAIL gewertet werden → INSUFFICIENT_EVIDENCE.
|
||||
form_known = (
|
||||
"legal_form_known" in scope or detect_legal_form_present(text)
|
||||
)
|
||||
for mc in MCS:
|
||||
if not scope_matches(mc, scope, is_auto):
|
||||
disp = scope_disposition(mc, scope, is_auto)
|
||||
if disp == "na":
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="na",
|
||||
reason="nicht im Business-Scope",
|
||||
reason="nicht anwendbar (Rechtsform/Branche)",
|
||||
))
|
||||
continue
|
||||
if any(p.search(text) for p in mc.patterns):
|
||||
@@ -133,12 +147,78 @@ class ImpressumAgent(BaseSpecialistAgent):
|
||||
reason="optional — nicht angegeben",
|
||||
))
|
||||
continue
|
||||
if disp == "possible":
|
||||
# Graubereich (z.B. Corporate-Blog → §18 MStV evtl.) →
|
||||
# POSSIBLY_APPLICABLE: Pruef-Hinweis (LOW), kein Verstoss.
|
||||
findings.append(Finding(
|
||||
check_id=f"IMP-{mc.field_id}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=mc.field_id,
|
||||
status=CheckStatus.POSSIBLY_APPLICABLE,
|
||||
severity=Severity.LOW,
|
||||
severity_reason="graubereich",
|
||||
title=f"{mc.label}: ggf. relevant — manuell prüfen",
|
||||
norm=mc.norm,
|
||||
evidence="",
|
||||
action=(
|
||||
"Bei journalistisch-redaktionellen Inhalten "
|
||||
"(Nachrichten/Magazin) ist ein Verantwortlicher nach "
|
||||
"§ 18 MStV anzugeben. Bei reinem Corporate-Blog meist "
|
||||
"nicht erforderlich — bitte prüfen."
|
||||
),
|
||||
confidence=0.5,
|
||||
sources=[EvidenceSource(
|
||||
source_type=SourceType.REGEX,
|
||||
source_id=mc.mc_id,
|
||||
detail="Graubereich-Signal (Blog/News), kein hartes Gate",
|
||||
confidence=0.5,
|
||||
)],
|
||||
))
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="possibly_applicable",
|
||||
reason="Graubereich — manuelle Prüfung",
|
||||
))
|
||||
continue
|
||||
if mc.legal_form_dependent and not form_known:
|
||||
# Rechtsform unbestimmt → kein hartes FAIL, sondern
|
||||
# 'unzureichende Evidenz' (severity INFO, Hinweis statt Verstoss).
|
||||
findings.append(Finding(
|
||||
check_id=f"IMP-{mc.field_id}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=mc.field_id,
|
||||
status=CheckStatus.INSUFFICIENT_EVIDENCE,
|
||||
severity=Severity.INFO,
|
||||
severity_reason="rechtsform_unbestimmt",
|
||||
title=f"{mc.label}: Rechtsform nicht erkennbar",
|
||||
norm=mc.norm,
|
||||
evidence="",
|
||||
action=(
|
||||
"Rechtsform im Impressum nicht eindeutig erkennbar — "
|
||||
"bitte pruefen, ob das Unternehmen registerpflichtig "
|
||||
"ist; falls ja, die Pflichtangabe ergaenzen."
|
||||
),
|
||||
confidence=0.4,
|
||||
sources=[EvidenceSource(
|
||||
source_type=SourceType.REGEX,
|
||||
source_id=mc.mc_id,
|
||||
detail="keine Rechtsform im Text + kein legal_form im Scope",
|
||||
confidence=0.4,
|
||||
)],
|
||||
))
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="insufficient_evidence",
|
||||
reason="Rechtsform unbestimmt",
|
||||
))
|
||||
continue
|
||||
sev = _SEV_TO_ENUM.get(mc.severity_if_missing, Severity.MEDIUM)
|
||||
findings.append(Finding(
|
||||
check_id=f"IMP-{mc.field_id}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=mc.field_id,
|
||||
status=CheckStatus.FAIL,
|
||||
severity=sev,
|
||||
severity_reason="pflichtangabe_missing",
|
||||
title=f"Pflichtangabe fehlt: {mc.label}",
|
||||
@@ -157,9 +237,14 @@ class ImpressumAgent(BaseSpecialistAgent):
|
||||
mc_id=mc.mc_id, status=sev.value.lower(),
|
||||
reason="kein Pattern-Treffer",
|
||||
))
|
||||
n_fail = sum(1 for f in findings
|
||||
if f.status == CheckStatus.FAIL.value)
|
||||
n_unklar = sum(1 for f in findings
|
||||
if f.status == CheckStatus.INSUFFICIENT_EVIDENCE.value)
|
||||
notes_parts.append(
|
||||
f"{len(MCS)} §5-TMG-MCs geprüft · "
|
||||
f"{len(findings)} Pflichtangabe(n) offen"
|
||||
f"{n_fail} Pflichtangabe(n) offen"
|
||||
+ (f" · {n_unklar} unklar (Rechtsform)" if n_unklar else "")
|
||||
)
|
||||
|
||||
# ── Layer 3: Semantic-Validator nur für HIGH/MEDIUM-Fails ──
|
||||
@@ -246,7 +331,11 @@ class ImpressumAgent(BaseSpecialistAgent):
|
||||
notes: str = "",
|
||||
) -> AgentOutput:
|
||||
end = datetime.now(timezone.utc)
|
||||
recs = rollup(findings)
|
||||
# Recommendations nur aus echten FAILs — INSUFFICIENT_EVIDENCE /
|
||||
# POSSIBLY_APPLICABLE sind Hinweise, keine Pflicht-Massnahmen
|
||||
# (User-Datenmodell: Finding → Remediation nur bei echtem Verstoss).
|
||||
recs = rollup([f for f in findings
|
||||
if f.status == CheckStatus.FAIL.value])
|
||||
out = AgentOutput(
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
@@ -265,5 +354,9 @@ class ImpressumAgent(BaseSpecialistAgent):
|
||||
mc_high=sum(1 for c in coverage if c.status == "high"),
|
||||
mc_medium=sum(1 for c in coverage if c.status == "medium"),
|
||||
mc_low=sum(1 for c in coverage if c.status == "low"),
|
||||
mc_insufficient=sum(
|
||||
1 for c in coverage if c.status == "insufficient_evidence"),
|
||||
mc_possibly=sum(
|
||||
1 for c in coverage if c.status == "possibly_applicable"),
|
||||
)
|
||||
return lint_output(out)
|
||||
|
||||
@@ -32,6 +32,14 @@ class MC:
|
||||
# Wenn True: fehlt die Angabe → KEIN Finding (z.B. USt-IdNr —
|
||||
# Kleinunternehmer §19 haben legitim keine). Nur wenn vorhanden relevant.
|
||||
optional: bool = False
|
||||
# Wenn True: die Pflicht haengt an der Rechtsform (Handelsregister,
|
||||
# Vertretungsberechtigte). Ist die Rechtsform weder im Text noch im
|
||||
# Scope bestimmbar → INSUFFICIENT_EVIDENCE statt hartem FAIL.
|
||||
legal_form_dependent: bool = False
|
||||
# Graubereich: liegt eines dieser Tokens vor (aber NICHT requires_scope),
|
||||
# ist die MC NICHT hart anwendbar, sondern POSSIBLY_APPLICABLE — Pruef-
|
||||
# Hinweis (severity LOW) statt FAIL. Z.B. Corporate-Blog (§18 MStV evtl.).
|
||||
possibly_applies_scope: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
MCS: tuple[MC, ...] = (
|
||||
@@ -86,6 +94,7 @@ MCS: tuple[MC, ...] = (
|
||||
norm="§ 5 Abs. 1 Nr. 4 TMG",
|
||||
severity_if_missing="HIGH",
|
||||
excludes_scope=("kein_handelsregister",),
|
||||
legal_form_dependent=True,
|
||||
patterns=(
|
||||
re.compile(r"\bHR[BA]\s+\d", re.IGNORECASE),
|
||||
re.compile(r"Handelsregister", re.IGNORECASE),
|
||||
@@ -113,6 +122,7 @@ MCS: tuple[MC, ...] = (
|
||||
norm="§ 5 Abs. 1 Nr. 1 TMG (juristische Personen)",
|
||||
severity_if_missing="HIGH",
|
||||
excludes_scope=("keine_vertretung",),
|
||||
legal_form_dependent=True,
|
||||
patterns=(
|
||||
re.compile(
|
||||
r"(?:Gesch(?:ae|ä)ftsf(?:ue|ü)hr(?:er|ung|erin)|"
|
||||
@@ -171,6 +181,7 @@ MCS: tuple[MC, ...] = (
|
||||
norm="§ 18 MStV (bei Blog/News/Magazin/Newsroom Pflicht)",
|
||||
severity_if_missing="MEDIUM",
|
||||
requires_scope=("editorial",),
|
||||
possibly_applies_scope=("editorial_possible",),
|
||||
patterns=(re.compile(
|
||||
r"(?:Verantwortlich(?:er|e)?\s+(?:f(?:ue|ü)r|i\.S\.d\.|"
|
||||
r"nach|gem(?:ae|ä)ß)\s+§\s*18|"
|
||||
@@ -235,6 +246,45 @@ def scope_matches(mc: MC, scope: set[str], is_automotive: bool) -> bool:
|
||||
return any(s in scope for s in mc.requires_scope)
|
||||
|
||||
|
||||
_LEGAL_FORM_RE = re.compile(
|
||||
r"(?:\bGmbH\b|\bgGmbH\b|\bmbH\b|\bUG\b|\bAG\b|\bSE\b|\bKGaA\b|"
|
||||
r"\bKG\b|\bOHG\b|\bGbR\b|\bPartG(?:mbB)?\b|"
|
||||
r"\be\.?\s?K(?:fm|fr)?\.?\b|\be\.?\s?V\.?\b|\bStiftung\b|"
|
||||
r"\bLtd\.?\b|\bLimited\b|\bLLC\b|\bS\.A\.|\bN\.V\.|\bB\.V\.|"
|
||||
r"\bEinzelunternehm\w*|\bKaufmann\b|\bKauffrau\b|\bFreiberuf\w*)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def scope_disposition(mc: MC, scope: set[str], is_automotive: bool) -> str:
|
||||
"""3-Wege-Anwendbarkeit: 'applies' (hart) | 'possible' (Graubereich) |
|
||||
'na' (nicht anwendbar).
|
||||
|
||||
'possible' nur, wenn die MC NICHT hart anwendbar ist, aber ein
|
||||
possibly_applies_scope-Token vorliegt (z.B. Corporate-Blog → §18 MStV
|
||||
evtl. relevant) → POSSIBLY_APPLICABLE statt FAIL/NA."""
|
||||
if mc.excludes_scope and any(s in scope for s in mc.excludes_scope):
|
||||
return "na"
|
||||
if scope_matches(mc, scope, is_automotive):
|
||||
return "applies"
|
||||
if mc.possibly_applies_scope and any(
|
||||
s in scope for s in mc.possibly_applies_scope
|
||||
):
|
||||
return "possible"
|
||||
return "na"
|
||||
|
||||
|
||||
def detect_legal_form_present(text: str) -> bool:
|
||||
"""Nennt der Text ueberhaupt eine Rechtsform?
|
||||
|
||||
Grundlage fuer INSUFFICIENT_EVIDENCE: ohne erkennbare Rechtsform (und
|
||||
ohne legal_form im Scope) kann der Agent die Register-/Vertretungs-
|
||||
pflicht nicht belastbar behaupten → kein hartes FAIL, sondern
|
||||
'unzureichende Evidenz' (User-Vorgabe 2026-06-10: 'Muster Consulting'
|
||||
ohne Rechtsform darf kein 'Handelsregister fehlt' ausloesen)."""
|
||||
return bool(_LEGAL_FORM_RE.search(text or ""))
|
||||
|
||||
|
||||
def detect_automotive(text: str) -> bool:
|
||||
"""KFZ-Hersteller/-Vertrieb → triggert KBA-Hint."""
|
||||
if re.search(
|
||||
|
||||
Reference in New Issue
Block a user