feat(profile): P14+P15+P16 — B2B-Heuristik + Doc-URL-Dedup + Homepage-Profile
P14 — _detect_no_direct_sales erweitert um 3 Cluster: A) OEM-Konfigurator (BMW/Audi/Mercedes/VW/Porsche-Markennamen + Vertragshaendler-Pattern) B) B2B-Dienstleister (CE-Zertifizierung, Compliance-Beratung, Schulungen, Auditierung, TISAX, ISO-Normen, Arbeitssicherheit, ...) C) NGO/Verein/Public (Spendenkonto, Vereinsregister, gemeinnuetzig, ...) Schwelle: pos >= 2 pro Cluster UND pos > neg. Bisher: nur OEM. P15 — Doc-URL-Dedup im Worker: wenn mehrere Doc-Types DASSELBE Dokument referenzieren (Safetykon-Pattern: User gibt /datenschutz fuer dse, cookie UND widerruf), wird nur dem primaeren Doc-Type (Priority: dse > impressum > cookie > widerruf > agb > nutzungsbedingungen) der Text gegeben. Andere landen als "Nicht separat vorhanden — wird im Dokument 'X' mit-geprueft." Eliminiert die 8+8 systematischen widerruf/cookie False Positives. P16 — Profile-Detection auch Homepage-Text: Homepage-HTML wird mit kurzem Fetch (8s timeout) gezogen, getrippt und zum profile_input gemerged. Vor- her wirkte P14 nur wenn B2B-Indikatoren im DSE/Impressum-Pflichttext standen — bei Safetykon stehen sie nur im Homepage-Menue. Plus Bonus: TDM-Override-Submit-Button wird deaktiviert wenn Reason < 10 Zeichen — verhindert dass User wie heute in den Bug rein klickt. Smoke-Test Safetykon (B2B Compliance-Dienstleister): dse geprueft (kein err) impressum geprueft (kein err) cookie "Nicht separat vorhanden — wird in DSE mit-geprueft" agb "Nicht anwendbar — kein Direkt-Kaufvertrag" widerruf "Nicht anwendbar — kein Direkt-Kaufvertrag" nutzungsbedingungen "Nicht anwendbar — kein Direkt-Kaufvertrag" Vorher: 16 False Positives. Jetzt: 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -331,7 +331,7 @@ export function ComplianceCheckTab() {
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || filledCount === 0}
|
||||
disabled={loading || filledCount === 0 || (tdmOverride && tdmOverrideReason.trim().length < 10)}
|
||||
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -275,9 +275,73 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
if entry.get("text"):
|
||||
doc_texts[entry["doc_type"]] = entry["text"]
|
||||
|
||||
# P15: Dedupe — wenn mehrere Doc-Types DASSELBE Dokument referenzieren
|
||||
# (z.B. Safetykon: User gibt /datenschutz fuer dse + cookie + widerruf),
|
||||
# behalten wir nur den primaeren Doc-Type. Andere: leeren + note.
|
||||
# Priorität: dse > impressum > cookie > widerruf > agb > nutzungsbedingungen
|
||||
_DOC_PRIORITY = ["dse", "impressum", "cookie", "widerruf", "agb",
|
||||
"nutzungsbedingungen", "social_media", "dsb"]
|
||||
seen_text_hash: dict[int, str] = {}
|
||||
for dt in _DOC_PRIORITY:
|
||||
entry = next((e for e in doc_entries if e.get("doc_type") == dt
|
||||
and e.get("text")), None)
|
||||
if not entry:
|
||||
continue
|
||||
text_hash = hash((entry.get("text") or "").strip()[:1000])
|
||||
if text_hash in seen_text_hash:
|
||||
primary = seen_text_hash[text_hash]
|
||||
logger.info(
|
||||
"P15 dedup: doc_type=%s referenziert dasselbe Dokument "
|
||||
"wie %s (URL=%s) -> als Duplikat markiert.",
|
||||
dt, primary, entry.get("url", "")[:60],
|
||||
)
|
||||
entry["text"] = ""
|
||||
entry["word_count"] = 0
|
||||
entry["url"] = ""
|
||||
entry["dup_of"] = primary
|
||||
doc_texts.pop(dt, None)
|
||||
else:
|
||||
seen_text_hash[text_hash] = dt
|
||||
|
||||
# Step 2: Detect business profile (35-40%)
|
||||
_update(check_id, "Geschaeftsmodell wird erkannt...", 37)
|
||||
profile = await detect_business_profile(doc_texts)
|
||||
# P16: Homepage-Text mit fuer Profile-Detection (no_direct_sales
|
||||
# B2B-Indikatoren wie "CE-Zertifizierung" / "Schulungen" stehen oft
|
||||
# nur im Homepage-Menue, nicht im Pflichttext).
|
||||
profile_input = dict(doc_texts)
|
||||
try:
|
||||
base_url = ""
|
||||
for e in doc_entries:
|
||||
if e.get("url"):
|
||||
from urllib.parse import urlparse
|
||||
p = urlparse(e["url"])
|
||||
if p.scheme and p.netloc:
|
||||
base_url = f"{p.scheme}://{p.netloc}/"
|
||||
break
|
||||
if base_url:
|
||||
import re as _re
|
||||
async with httpx.AsyncClient(
|
||||
timeout=8.0, follow_redirects=True,
|
||||
headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 HeadlessChrome/120.0.0.0"},
|
||||
) as _hc:
|
||||
_hr = await _hc.get(base_url)
|
||||
if _hr.status_code == 200 and "text/html" in _hr.headers.get(
|
||||
"content-type", ""):
|
||||
_html = _hr.text[:60000]
|
||||
_html = _re.sub(r"<script[^>]*>.*?</script>", " ",
|
||||
_html, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
_html = _re.sub(r"<style[^>]*>.*?</style>", " ",
|
||||
_html, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
_html = _re.sub(r"<[^>]+>", " ", _html)
|
||||
_html = _re.sub(r"\s+", " ", _html).strip()
|
||||
if len(_html.split()) > 30:
|
||||
profile_input["__homepage"] = _html[:20000]
|
||||
logger.info("P16 homepage merged for profile: %d words",
|
||||
len(_html.split()))
|
||||
except Exception as e:
|
||||
logger.debug("homepage fetch for profile failed: %s", e)
|
||||
profile = await detect_business_profile(profile_input)
|
||||
profile_dict = asdict(profile)
|
||||
|
||||
# Step 3: Check each document
|
||||
@@ -323,6 +387,15 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
_update(check_id, f"Pruefen {i+1}/{n_entries}: {label}...", pct)
|
||||
|
||||
if not text or len(text) < 50:
|
||||
# P15: duplicate doc that was deduped against a primary doc
|
||||
if entry.get("dup_of"):
|
||||
results.append(DocCheckResult(
|
||||
label=label, url="", doc_type=doc_type,
|
||||
error=f"Nicht separat vorhanden — wird im Dokument "
|
||||
f"'{_doc_type_label(entry['dup_of'])}' "
|
||||
f"mit-geprueft.",
|
||||
))
|
||||
continue
|
||||
# Empty entry — either from auto-discovery padding (no URL
|
||||
# to fetch) or from a fetch that returned nothing. If there
|
||||
# was a URL we keep the error so the user knows the fetch
|
||||
|
||||
@@ -335,9 +335,10 @@ async def detect_business_profile(documents: dict[str, str]) -> BusinessProfile:
|
||||
return profile
|
||||
|
||||
|
||||
# Indikatoren: Site verweist primaer auf Vertragshaendler/Niederlassungen
|
||||
# statt einen eigenen Checkout-Vertragsabschluss zu bieten.
|
||||
_NO_DIRECT_SALES_POSITIVE = [
|
||||
# P14: drei Cluster die jeweils unabhaengig no_direct_sales=True triggern.
|
||||
|
||||
# Cluster A: OEM-Konfigurator-Pattern (Auto-Hersteller mit Vertragshaendler-Netz)
|
||||
_OEM_POSITIVE = [
|
||||
"vertragshaendler", "vertragshändler", "vertragspartner",
|
||||
"vertragswerkstatt", "haendlersuche", "händlersuche",
|
||||
"niederlassung", "vertretung", "autorisierter haendler",
|
||||
@@ -347,27 +348,80 @@ _NO_DIRECT_SALES_POSITIVE = [
|
||||
"anfrage an haendler", "anfrage an händler",
|
||||
"konfigurator", "fahrzeug konfigurieren",
|
||||
"ihre individuelle anfrage",
|
||||
# OEM-Markennamen — sind Hersteller-Marken die ueblicherweise via
|
||||
# Haendler vertreiben.
|
||||
"bmw vertriebs", "audi vertriebs", "mercedes-benz vertriebs",
|
||||
"volkswagen vertriebs", "porsche zentrum",
|
||||
# OEM-Markennamen im Pflichttext (Datenschutz erwaehnt Hersteller)
|
||||
"bmw ag", "audi ag", "mercedes-benz ag", "volkswagen ag",
|
||||
"porsche ag", "opel automobile gmbh",
|
||||
]
|
||||
|
||||
# Cluster B: B2B-Dienstleister (Beratung / Compliance / Schulung / CE)
|
||||
_B2B_SERVICE_POSITIVE = [
|
||||
"ce-zertifizierung", "ce zertifizierung",
|
||||
"ce-konformitaet", "ce-konformität",
|
||||
"ce-kennzeichnung", "ce kennzeichnung",
|
||||
"compliance-beratung", "compliance beratung",
|
||||
"arbeitssicherheit", "product compliance",
|
||||
"produktsicherheit", "produkthaftung",
|
||||
"auditierung", "auditor", "auditierungen",
|
||||
"schulungen", "workshops", "akademie",
|
||||
"beratungsleistungen", "consultingleistungen",
|
||||
"consulting services", "managementsystem",
|
||||
"datenschutzbeauftragter (extern)",
|
||||
"externer datenschutzbeauftragter",
|
||||
"datenschutz-audit", "tisax", "iso 27001",
|
||||
"iso 9001", "iso 14001", "iso 45001",
|
||||
"gefaehrdungsbeurteilung", "gefährdungsbeurteilung",
|
||||
"betriebsbeauftragter", "fachkraft fuer arbeitssicherheit",
|
||||
"fachkraft für arbeitssicherheit",
|
||||
]
|
||||
|
||||
# Cluster C: NGO / Verein / oeffentliche Verwaltung
|
||||
_NONPROFIT_PUBLIC_POSITIVE = [
|
||||
"spendenkonto", "vereinsregister", "gemeinnuetzig",
|
||||
"gemeinnützig", "ehrenamtlich", "foerderverein",
|
||||
"förderverein", "stiftung", "buergeramt", "bürgeramt",
|
||||
"landratsamt", "kommunalverwaltung",
|
||||
]
|
||||
|
||||
# Backwards-compat
|
||||
_NO_DIRECT_SALES_POSITIVE = (
|
||||
_OEM_POSITIVE + _B2B_SERVICE_POSITIVE + _NONPROFIT_PUBLIC_POSITIVE
|
||||
)
|
||||
|
||||
# Indikatoren GEGEN no_direct_sales: echte Online-Shop-Funktionen.
|
||||
_DIRECT_SALES_NEGATIVE = [
|
||||
"in den warenkorb", "warenkorb hinzu", "zur kasse",
|
||||
"jetzt kaufen", "kostenpflichtig bestellen",
|
||||
"zahlungspflichtig bestellen", "sofort-kauf",
|
||||
"online bestellen", "lieferadresse", "rechnungsadresse",
|
||||
"versandkosten", "lieferzeit", "lieferbedingungen",
|
||||
"checkout", "stueckpreis", "stückpreis",
|
||||
]
|
||||
|
||||
|
||||
def _detect_no_direct_sales(full_text: str) -> bool:
|
||||
"""Heuristik: erkennt OEM-Konfigurator-Sites die nicht direkt verkaufen."""
|
||||
"""Heuristik: True wenn Site keinen Direkt-Vertrieb mit B2C-Kunden hat.
|
||||
|
||||
Trifft fuer 3 Cluster zu (jeweils mind. 2 Treffer im Cluster):
|
||||
A) OEM-Konfigurator (Auto-Hersteller)
|
||||
B) B2B-Dienstleister (Beratung/Compliance/Schulung)
|
||||
C) NGO / oeffentliche Verwaltung
|
||||
|
||||
Negativ-Signale (echte Shop-Funktionen) zaehlen gegen den Cluster:
|
||||
nur True wenn pos > neg.
|
||||
"""
|
||||
text = full_text.lower()
|
||||
pos = sum(1 for k in _NO_DIRECT_SALES_POSITIVE if k in text)
|
||||
oem = sum(1 for k in _OEM_POSITIVE if k in text)
|
||||
b2b = sum(1 for k in _B2B_SERVICE_POSITIVE if k in text)
|
||||
npg = sum(1 for k in _NONPROFIT_PUBLIC_POSITIVE if k in text)
|
||||
neg = sum(1 for k in _DIRECT_SALES_NEGATIVE if k in text)
|
||||
# Mindestens 3 Haendler-Indikatoren UND weniger Shop-Indikatoren als
|
||||
# Haendler-Indikatoren. Vermeidet false-positive fuer Shops die
|
||||
# zusaetzlich "Haendlersuche" als Filiale-Finder anbieten.
|
||||
return pos >= 3 and pos > neg
|
||||
# Jeder Cluster ist eigenstaendig: 2 Treffer + weniger Negativ-Signale
|
||||
# als Cluster-Treffer.
|
||||
if oem >= 2 and oem > neg:
|
||||
return True
|
||||
if b2b >= 2 and b2b > neg:
|
||||
return True
|
||||
if npg >= 2 and npg > neg:
|
||||
return True
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user