feat(agents): Specialist-Agents Phase 2 Foundation + Cookie-Policy-Agent
Sprint 1 — Foundation (User-Vorgabe 2026-06-08): Foundation: - _base.py: BaseSpecialistAgent ABC + Pydantic Contract (AgentInput/AgentOutput/Finding/Recommendation/McCoverage/EscalationLog). - _base.lint_output(): Disclaimer-Linter verbietet "rechtssicher" / "garantiert" / "gesetzeskonform" — scrubbed inline + Log in notes. - _registry.py: AgentRegistry mit MC-Owner-Mapping (verhindert Doppel-Ownership). - _escalation.py: cascade(local → ovh). qwen2.5:7b default, OVH 120b als Stage-2 (deaktiviert wenn OVH_URL leer). - _rollup.py: deterministisches Dedup ähnlicher actions zu Recommendations mit related_finding_ids[]. - _evidence_vault.py: Pro-Run File-Vault für Playwright-Videos, Screenshots, CSV. SHA256 + manifest.json. DSR-tauglich (delete_run). Agenten: - ImpressumAgent v2 (impressum/agent.py + mcs.py) — konsolidiert v1-Pattern-Match + v2-LLM-MVP unter dem neuen Contract. 12 MCs. - CookiePolicyAgent v1 (cookie_policy/agent.py + mcs.py) — 12 MCs zu Cookie-Richtlinie-Vollständigkeit + KB-Layer für CMP-Vendor-Cross-Check. Tests: 25/25 grün (10 Impressum + 9 Vault + 6 Cookie-Policy). Roadmap: SSE-Test-Endpoint + Frontend-Tab → DSE/AGB-Agents → Cookie-Banner-Themen-Agent → Cross-Doc-Konsistenz-Agent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Impressum-Specialist-Agent v2 — konsolidiert Pattern (v1) + LLM (v2-mvp).
|
||||
|
||||
Public Entry-Point: ImpressumAgent (inherits BaseSpecialistAgent).
|
||||
"""
|
||||
|
||||
from .agent import ImpressumAgent
|
||||
|
||||
__all__ = ["ImpressumAgent"]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Impressum-Agent v2 — konsolidierter BaseSpecialistAgent.
|
||||
|
||||
Ablauf:
|
||||
1. Deterministische MCs durchlaufen → Findings + mc_coverage.
|
||||
2. Wenn unklare Felder (HIGH/MEDIUM missing) → LLM-Eskalation.
|
||||
3. LLM-Findings dedupen mit MC-Findings nach field_id.
|
||||
4. Rollup → Recommendations.
|
||||
5. Disclaimer-Lint → AgentOutput.
|
||||
|
||||
Phase 1 (jetzt): qwen2.5:7b als Stage-1, OVH optional als Stage-2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .._base import (
|
||||
AgentInput,
|
||||
AgentOutput,
|
||||
BaseSpecialistAgent,
|
||||
EscalationLog,
|
||||
EvidenceSource,
|
||||
Finding,
|
||||
McCoverage,
|
||||
Severity,
|
||||
SourceType,
|
||||
lint_output,
|
||||
)
|
||||
from .._escalation import cascade
|
||||
from .._rollup import rollup
|
||||
from .mcs import MC_IDS, MCS, detect_automotive, scope_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """Du bist ein deutscher Datenschutz-Anwalt mit Fokus
|
||||
§ 5 TMG / DDG (Anbieterkennzeichnung). Aufgabe: Impressum prüfen und
|
||||
LÜCKEN aufzählen, die einer regex-basierten Vorprüfung entgangen sind.
|
||||
|
||||
WICHTIG:
|
||||
- KEINE Bewertung "rechtssicher" / "garantiert" / "konform".
|
||||
- Wenn unsicher: leeres Array zurückgeben statt zu halluzinieren.
|
||||
- Wörtliches Zitat als evidence bei jeder Lücke.
|
||||
|
||||
Antworte NUR mit JSON, Schema:
|
||||
{"findings": [
|
||||
{"field_id": "...", "severity": "HIGH|MEDIUM|LOW",
|
||||
"title": "...", "evidence": "wörtliches Zitat",
|
||||
"action": "konkrete Empfehlung"}
|
||||
]}
|
||||
"""
|
||||
|
||||
|
||||
class ImpressumAgent(BaseSpecialistAgent):
|
||||
agent_id = "impressum"
|
||||
agent_version = "2.0"
|
||||
doc_type = "impressum"
|
||||
owned_mc_ids = MC_IDS
|
||||
|
||||
async def evaluate(self, agent_input: AgentInput) -> AgentOutput:
|
||||
start = datetime.now(timezone.utc)
|
||||
text = (agent_input.text or "").strip()
|
||||
scope = set(agent_input.business_scope or [])
|
||||
# Auto-detect KFZ
|
||||
is_automotive = detect_automotive(text)
|
||||
if is_automotive:
|
||||
scope.add("automotive")
|
||||
|
||||
mc_findings: list[Finding] = []
|
||||
coverage: list[McCoverage] = []
|
||||
|
||||
if len(text) < 50:
|
||||
# Doc zu kurz → alle als skipped
|
||||
for mc in MCS:
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="skipped",
|
||||
reason="doc too short or empty",
|
||||
))
|
||||
return self._finalize(
|
||||
start, mc_findings, [], coverage, confidence=0.0,
|
||||
notes="Impressum-Text zu kurz oder leer.",
|
||||
)
|
||||
|
||||
for mc in MCS:
|
||||
if not scope_matches(mc, scope, is_automotive):
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="na",
|
||||
reason=f"scope mismatch (needs {mc.requires_scope})",
|
||||
))
|
||||
continue
|
||||
found = any(p.search(text) for p in mc.patterns)
|
||||
if found:
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id, status="ok",
|
||||
))
|
||||
continue
|
||||
# Missing → Finding
|
||||
sev = self._sev(mc.severity_if_missing)
|
||||
action = self._build_action(mc, is_automotive)
|
||||
mc_findings.append(Finding(
|
||||
check_id=f"IMPRESSUM-AGENT-{mc.field_id.upper()}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=mc.field_id,
|
||||
severity=sev,
|
||||
severity_reason="missing",
|
||||
title=f"Pflichtangabe '{mc.label}' fehlt im Impressum",
|
||||
norm=mc.norm,
|
||||
evidence="",
|
||||
action=action,
|
||||
confidence=0.95,
|
||||
sources=[EvidenceSource(
|
||||
source_type=SourceType.MC,
|
||||
source_id=mc.mc_id,
|
||||
detail=f"regex check {len(mc.patterns)} pattern(s) negative",
|
||||
)],
|
||||
))
|
||||
coverage.append(McCoverage(
|
||||
mc_id=mc.mc_id,
|
||||
status=sev.value.lower(),
|
||||
reason="missing",
|
||||
))
|
||||
|
||||
# Eskalation: für die identifizierten Lücken kann ein LLM
|
||||
# zusätzliche Tiefen-Findings liefern (z.B. "Geschäftsführer
|
||||
# genannt, aber ohne Nachname"). Confidence der MC-Findings
|
||||
# ist hoch — eskalieren wir wegen "weitere subtile Lücken
|
||||
# finden", nicht weil wir unsicher sind.
|
||||
esc_findings, esc_logs = await self._maybe_escalate(text, scope)
|
||||
|
||||
# Dedup per field_id (MC hat Priorität)
|
||||
seen_fields = {f.field_id for f in mc_findings if f.field_id}
|
||||
for f in esc_findings:
|
||||
if f.field_id and f.field_id in seen_fields:
|
||||
continue
|
||||
mc_findings.append(f)
|
||||
|
||||
# Confidence: harmonisches Mittel über alle Finding-Confidences
|
||||
if mc_findings:
|
||||
confs = [f.confidence for f in mc_findings if f.confidence]
|
||||
overall = sum(confs) / len(confs) if confs else 0.8
|
||||
else:
|
||||
overall = 0.95 # nichts gefunden → alle MCs ok
|
||||
|
||||
return self._finalize(
|
||||
start, mc_findings, esc_logs, coverage, confidence=overall,
|
||||
)
|
||||
|
||||
async def _maybe_escalate(
|
||||
self, text: str, scope: set[str],
|
||||
) -> tuple[list[Finding], list[EscalationLog]]:
|
||||
"""LLM-Stage. Aktivierbar via Agent-Settings (default an)."""
|
||||
# Hard cap auf Text-Größe für den LLM-Pass
|
||||
user_prompt = (
|
||||
f"BUSINESS-SCOPE: {', '.join(sorted(scope))}\n\n"
|
||||
f"IMPRESSUM-TEXT:\n{text[:4000]}\n\n"
|
||||
"Liste subtile Lücken nach § 5 TMG. Nur JSON."
|
||||
)
|
||||
res, logs = await cascade(_SYSTEM_PROMPT, user_prompt)
|
||||
if res is None or not isinstance(res.parsed, (dict, list)):
|
||||
return [], logs
|
||||
raw = (res.parsed.get("findings")
|
||||
if isinstance(res.parsed, dict) else res.parsed)
|
||||
if not isinstance(raw, list):
|
||||
return [], logs
|
||||
out: list[Finding] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
fid = str(item.get("field_id") or "unknown")[:40]
|
||||
sev_raw = str(item.get("severity") or "MEDIUM").upper()
|
||||
sev = self._sev(sev_raw)
|
||||
out.append(Finding(
|
||||
check_id=f"IMPRESSUM-AGENT-LLM-{fid.upper()}",
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
field_id=fid,
|
||||
severity=sev,
|
||||
severity_reason="llm_detected",
|
||||
title=str(item.get("title") or "")[:200],
|
||||
norm="§ 5 TMG / DDG (LLM-Analyse)",
|
||||
evidence=str(item.get("evidence") or "")[:300],
|
||||
action=str(item.get("action") or "")[:400],
|
||||
confidence=0.7,
|
||||
sources=[EvidenceSource(
|
||||
source_type=res.stage,
|
||||
source_id=res.model,
|
||||
detail=f"prompt_chars={len(user_prompt)}",
|
||||
confidence=0.7,
|
||||
)],
|
||||
))
|
||||
return out, logs
|
||||
|
||||
def _finalize(
|
||||
self,
|
||||
start: datetime,
|
||||
findings: list[Finding],
|
||||
esc_logs: list[EscalationLog],
|
||||
coverage: list[McCoverage],
|
||||
confidence: float,
|
||||
notes: str = "",
|
||||
) -> AgentOutput:
|
||||
end = datetime.now(timezone.utc)
|
||||
recs = rollup(findings)
|
||||
out = AgentOutput(
|
||||
agent=self.agent_id,
|
||||
agent_version=self.agent_version,
|
||||
started_at=start,
|
||||
finished_at=end,
|
||||
duration_ms=int((end - start).total_seconds() * 1000),
|
||||
findings=findings,
|
||||
recommendations=recs,
|
||||
mc_coverage=coverage,
|
||||
escalation_log=esc_logs,
|
||||
confidence=confidence,
|
||||
notes=notes,
|
||||
mc_total=len(coverage),
|
||||
mc_ok=sum(1 for c in coverage if c.status == "ok"),
|
||||
mc_na=sum(1 for c in coverage if c.status == "na"),
|
||||
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"),
|
||||
)
|
||||
return lint_output(out)
|
||||
|
||||
@staticmethod
|
||||
def _sev(value: str) -> Severity:
|
||||
v = (value or "").upper()
|
||||
if v == "HIGH":
|
||||
return Severity.HIGH
|
||||
if v == "MEDIUM":
|
||||
return Severity.MEDIUM
|
||||
if v == "LOW":
|
||||
return Severity.LOW
|
||||
return Severity.INFO
|
||||
|
||||
@staticmethod
|
||||
def _build_action(mc, is_automotive: bool) -> str:
|
||||
if mc.field_id == "aufsichtsbehoerde" and is_automotive:
|
||||
return (
|
||||
"Aufsichtsbehörde im Impressum benennen. Für "
|
||||
"KFZ-Hersteller/-Vertrieb typisch: Kraftfahrt-"
|
||||
"Bundesamt (KBA), Fördestraße 16, 24944 Flensburg, "
|
||||
"www.kba.de. Bei Ladestrom-Vertrieb zusätzlich "
|
||||
"Bundesnetzagentur (BNetzA)."
|
||||
)
|
||||
return (
|
||||
f"{mc.label} im Impressum ergänzen "
|
||||
f"(Pflichtangabe nach {mc.norm})."
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Machine-Check-Definitionen für den Impressum-Agent.
|
||||
|
||||
Eine MC = ein abgegrenzter, deterministischer Check über das
|
||||
Impressum-Dokument. Owner = impressum-agent.
|
||||
|
||||
Quelle: § 5 TMG / DDG + § 18 MStV + § 36 VSBG + Art. 14 EU-VO 524/2013.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Pattern
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MC:
|
||||
"""Eine Machine-Check-Definition."""
|
||||
mc_id: str # IMP-MC-001 ...
|
||||
field_id: str # name_anbieter, handelsregister, ...
|
||||
label: str
|
||||
norm: str
|
||||
patterns: tuple[Pattern[str], ...] = field(default_factory=tuple)
|
||||
severity_if_missing: str = "MEDIUM" # HIGH | MEDIUM | LOW | INFO
|
||||
requires_scope: tuple[str, ...] = field(default_factory=tuple)
|
||||
# Wenn True: bei Scope-Mismatch nicht-applicable melden, sonst skip
|
||||
explicit_na: bool = True
|
||||
|
||||
|
||||
MCS: tuple[MC, ...] = (
|
||||
MC(
|
||||
mc_id="IMP-MC-001",
|
||||
field_id="name_anbieter",
|
||||
label="Name + Anschrift des Anbieters",
|
||||
norm="§ 5 Abs. 1 Nr. 1 TMG",
|
||||
severity_if_missing="HIGH",
|
||||
patterns=(
|
||||
re.compile(
|
||||
r"\b(?:Anbieter|Diensteanbieter|"
|
||||
r"Verantwortlich(?:er Anbieter)?)\s*[:.\s]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
# Label-free fallback: Firma (Rechtsform) + Adresse
|
||||
re.compile(
|
||||
r"\b[A-ZÄÖÜ][\w\-\& ]{1,80}?\s+"
|
||||
r"(?:GmbH|AG|UG|KG|SE|GbR|OHG|Limited|Ltd|LLC)\s*"
|
||||
r"[\s\S]{0,400}?"
|
||||
r"\b\d{5}\s+[A-ZÄÖÜ]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-002",
|
||||
field_id="kontakt_email",
|
||||
label="Email-Adresse",
|
||||
norm="§ 5 Abs. 1 Nr. 2 TMG",
|
||||
severity_if_missing="HIGH",
|
||||
patterns=(re.compile(r"\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b",
|
||||
re.IGNORECASE),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-003",
|
||||
field_id="kontakt_telefon",
|
||||
label="Telefon",
|
||||
norm="§ 5 Abs. 1 Nr. 2 TMG",
|
||||
severity_if_missing="MEDIUM",
|
||||
patterns=(re.compile(
|
||||
r"(?:Tel(?:efon)?|Phone)\.?\s*[:.\s]\s*[\+\d][\d\s/\-()]{5,}",
|
||||
re.IGNORECASE,
|
||||
),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-004",
|
||||
field_id="handelsregister",
|
||||
label="Handelsregister-Eintrag",
|
||||
norm="§ 5 Abs. 1 Nr. 4 TMG",
|
||||
severity_if_missing="HIGH",
|
||||
patterns=(
|
||||
re.compile(r"\bHR[BA]\s+\d", re.IGNORECASE),
|
||||
re.compile(r"Handelsregister", re.IGNORECASE),
|
||||
),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-005",
|
||||
field_id="ust_id",
|
||||
label="USt-IdNr",
|
||||
norm="§ 5 Abs. 1 Nr. 6 TMG",
|
||||
severity_if_missing="MEDIUM",
|
||||
patterns=(
|
||||
re.compile(
|
||||
r"\b(?:USt-?Id(?:Nr)?\.?|VAT(?:-?Id)?)\s*[:.\s]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(r"\bDE\d{9}\b"),
|
||||
),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-006",
|
||||
field_id="vertretungsberechtigte",
|
||||
label="Vertretungsberechtigte Person",
|
||||
norm="§ 5 Abs. 1 Nr. 1 TMG (juristische Personen)",
|
||||
severity_if_missing="HIGH",
|
||||
patterns=(
|
||||
re.compile(
|
||||
r"(?:Gesch(?:ae|ä)ftsf(?:ue|ü)hrer|"
|
||||
r"Vertretungsberechtigt|vertreten\s+durch)"
|
||||
r"\s*[:.\s]",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(r"\bManagement\s*[:.\s]\s*[A-ZÄÖÜ]",
|
||||
re.IGNORECASE),
|
||||
re.compile(r"\bDirector(?:s|en)?\s*[:.\s]\s*[A-ZÄÖÜ]",
|
||||
re.IGNORECASE),
|
||||
),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-007",
|
||||
field_id="vertretungsberechtigte_label_korrekt",
|
||||
label="Deutsches Label 'Geschäftsführer' statt 'Management'",
|
||||
norm="§ 5 Abs. 1 Nr. 1 TMG (Deutsch-Pflicht, gerichtsfest)",
|
||||
severity_if_missing="MEDIUM",
|
||||
patterns=(re.compile(
|
||||
r"(?:Gesch(?:ae|ä)ftsf(?:ue|ü)hrer|"
|
||||
r"Vorstand|"
|
||||
r"Vertretungsberechtigt|vertreten\s+durch)"
|
||||
r"\s*[:.\s]",
|
||||
re.IGNORECASE,
|
||||
),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-008",
|
||||
field_id="aufsichtsbehoerde",
|
||||
label="Aufsichtsbehörde (regulierte Branchen)",
|
||||
norm="§ 5 Abs. 1 Nr. 3 TMG (Branchen-bedingt)",
|
||||
severity_if_missing="LOW",
|
||||
requires_scope=("regulated_profession", "financial_services",
|
||||
"insurance", "automotive"),
|
||||
patterns=(
|
||||
re.compile(r"Aufsichtsbeh(?:ö|oe)rde\s*[:.\s]",
|
||||
re.IGNORECASE),
|
||||
re.compile(
|
||||
r"\bBAFin\b|\bBNetzA\b|\bLKA\b|\bKBA\b|"
|
||||
r"Kraftfahrt-?Bundesamt|Bundesnetzagentur|"
|
||||
r"Bundesanstalt\s+f(?:ü|ue)r",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-009",
|
||||
field_id="verantwortlicher_redaktion",
|
||||
label="Verantwortlicher § 18 MStV (journalistisch-redaktionell)",
|
||||
norm="§ 18 MStV (bei Blog/News/Magazin/Newsroom Pflicht)",
|
||||
severity_if_missing="MEDIUM",
|
||||
requires_scope=("editorial",),
|
||||
patterns=(re.compile(
|
||||
r"(?:Verantwortlich(?:er|e)?\s+(?:f(?:ue|ü)r|i\.S\.d\.|"
|
||||
r"nach|gem(?:ae|ä)ß)\s+§\s*18|"
|
||||
r"V\.i\.S\.d\.\s*§?\s*18|"
|
||||
r"redaktionell\s+Verantwortlich)",
|
||||
re.IGNORECASE,
|
||||
),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-010",
|
||||
field_id="verbraucher_streitbeilegung",
|
||||
label="Verbraucher-Streitbeilegung-Hinweis",
|
||||
norm="§ 36 VSBG (B2C-Anbieter Pflicht)",
|
||||
severity_if_missing="MEDIUM",
|
||||
requires_scope=("ecommerce", "b2c"),
|
||||
patterns=(re.compile(
|
||||
r"(?:Verbraucherschlichtungs|VSBG|"
|
||||
r"Streitbeilegung|"
|
||||
r"Schlichtungsstelle|"
|
||||
r"alternative\s+Streit(?:beilegung|schlichtung))",
|
||||
re.IGNORECASE,
|
||||
),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-011",
|
||||
field_id="berufsangaben",
|
||||
label="Berufsbezeichnung + Berufsrechtliche Angaben",
|
||||
norm="§ 5 Abs. 1 Nr. 5 TMG (Kammerberufe)",
|
||||
severity_if_missing="LOW",
|
||||
requires_scope=("regulated_profession",),
|
||||
patterns=(re.compile(
|
||||
r"Berufsbezeichnung|Berufsordnung|Kammer", re.IGNORECASE,
|
||||
),),
|
||||
),
|
||||
MC(
|
||||
mc_id="IMP-MC-012",
|
||||
field_id="odr_link",
|
||||
label="OS-Link auf EU-Plattform",
|
||||
norm="Art. 14 EU-VO 524/2013 (B2C-Onlineshops)",
|
||||
severity_if_missing="MEDIUM",
|
||||
requires_scope=("ecommerce",),
|
||||
patterns=(re.compile(r"ec\.europa\.eu/consumers/odr",
|
||||
re.IGNORECASE),),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Public list of all MC-IDs for the Registry
|
||||
MC_IDS: tuple[str, ...] = tuple(m.mc_id for m in MCS)
|
||||
|
||||
|
||||
def scope_matches(mc: MC, scope: set[str], is_automotive: bool) -> bool:
|
||||
"""Entscheidet ob die MC auf den Business-Scope anwendbar ist."""
|
||||
if not mc.requires_scope:
|
||||
return True
|
||||
if mc.field_id == "aufsichtsbehoerde" and is_automotive:
|
||||
return True
|
||||
return any(s in scope for s in mc.requires_scope)
|
||||
|
||||
|
||||
def detect_automotive(text: str) -> bool:
|
||||
"""KFZ-Hersteller/-Vertrieb → triggert KBA-Hint."""
|
||||
if re.search(
|
||||
r"\b(?:KFZ|Fahrzeug(?:e|herstellung|verkauf)?|Automobil|"
|
||||
r"E-Auto|Elektroauto|Auto-?Konfigurator|"
|
||||
r"Elektrofahrzeug|Hybrid-?Fahrzeug)\b",
|
||||
text, re.IGNORECASE,
|
||||
):
|
||||
return True
|
||||
return bool(re.search(
|
||||
r"\b(?:Tesla|BMW|Mercedes-?Benz|Audi|Volkswagen|Porsche|"
|
||||
r"Volvo|Stellantis|Skoda|Seat|Cupra|MINI|Smart|"
|
||||
r"Opel|Ford\s+Deutschland|Hyundai|Kia|Toyota|Mazda|"
|
||||
r"Nissan|Honda|Subaru|Lexus|Polestar|NIO|BYD|Rivian|"
|
||||
r"Lucid)\s+(?:Germany|Deutschland|Group|Holding|AG|"
|
||||
r"GmbH|S(?:E|\.A\.))\b",
|
||||
text, re.IGNORECASE,
|
||||
))
|
||||
Reference in New Issue
Block a user