#!/usr/bin/env python3 """Transparenter Evidenzschlüssel für maschinennahe Textmuster. Dieses Modul ist bewusst kein "KI-Prozent-Detektor". Es verbindet messbare Merkmale zu einem nachvollziehbaren Evidenzindex und gibt zusätzlich die Aussagekraft der Einordnung aus. Die Datei bildet die Entscheidungsschicht. Tokenstatistische Werte wie Surprisal, Entropie oder Top-k-Anteile müssen von einem lokal betriebenen Sprachmodell geliefert werden. Einfache Oberflächenmerkmale können direkt aus einem Text berechnet werden. Schnellstart: python ki_text_evidenz.py --example-input python ki_text_evidenz.py --input analyse.json --demo-profile --pretty Kalibrierung mit eigenen Vergleichsdaten: python ki_text_evidenz.py --calibrate kalibrierung.json \ --output-profile profil.json python ki_text_evidenz.py --input analyse.json \ --profile profil.json --pretty Format der Kalibrierungsdatei: { "records": [ {"label": "human", "features": {"top10_share": 0.31}}, {"label": "machine", "features": {"top10_share": 0.61}} ] } Mindestens 30 menschliche und 30 maschinelle Datensätze pro Merkmal werden empfohlen. Für einen belastbaren Produktivbetrieb sind deutlich mehr und nach Sprache sowie Textsorte getrennte Daten erforderlich. """ from __future__ import annotations import argparse import json import math import re import statistics from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Iterable, Literal, Mapping, Sequence Direction = Literal["high", "low"] GROUP_WEIGHTS: dict[str, float] = { "token_statistics": 50.0, "stylometry": 20.0, "segment_analysis": 20.0, "discourse": 10.0, } @dataclass(frozen=True) class FeatureSpec: key: str label: str group: str direction: Direction description: str FEATURE_SPECS: tuple[FeatureSpec, ...] = ( FeatureSpec( "top10_share", "Anteil Top-10-Tokens", "token_statistics", "high", "Anteil der Tokens, die das Referenzmodell unter den zehn wahrscheinlichsten sah.", ), FeatureSpec( "top100_share", "Anteil Top-100-Tokens", "token_statistics", "high", "Anteil der Tokens unter den hundert wahrscheinlichsten Modellvorhersagen.", ), FeatureSpec( "mean_surprisal", "Mittlere Surprisal", "token_statistics", "low", "Niedrige Werte bedeuten, dass die tatsächlichen Tokens wenig überraschend waren.", ), FeatureSpec( "surprisal_cv", "Variation der Surprisal", "token_statistics", "low", "Niedrige Werte zeigen eine gleichförmige Vorhersagbarkeit über den Text.", ), FeatureSpec( "mean_entropy", "Mittlere Entropie", "token_statistics", "low", "Niedrige Entropie bedeutet, dass das Referenzmodell häufig wenige klare Fortsetzungen sah.", ), FeatureSpec( "repetition_rate", "Token-Wiederholungsrate", "token_statistics", "high", "Anteil wiederkehrender Tokenfolgen im Verhältnis zur Textlänge.", ), FeatureSpec( "sentence_length_cv", "Variation der Satzlänge", "stylometry", "low", "Niedrige Werte zeigen ähnlich lange Sätze.", ), FeatureSpec( "lexical_diversity", "Wortschatzvielfalt", "stylometry", "low", "Niedrige Werte können auf einen gleichförmigen Wortschatz hinweisen.", ), FeatureSpec( "repeated_sentence_start_rate", "Wiederholte Satzanfänge", "stylometry", "high", "Anteil der Sätze mit bereits verwendeten ersten zwei Wörtern.", ), FeatureSpec( "machine_segment_share", "Maschinennahe Segmente", "segment_analysis", "high", "Anteil der Textsegmente mit einem Evidenzindex ab 45.", ), FeatureSpec( "segment_uniformity", "Gleichförmigkeit der Segmente", "segment_analysis", "high", "Hohe Werte zeigen ähnliche Evidenzwerte über mehrere Textsegmente.", ), FeatureSpec( "transition_phrase_rate", "Standardisierte Übergänge", "discourse", "high", "Anteil der Sätze mit typischen Übergangs- oder Zusammenfassungsformeln.", ), FeatureSpec( "generic_phrase_rate", "Generische Formulierungen", "discourse", "high", "Anteil erkannter allgemeiner oder formelhafter Formulierungen.", ), FeatureSpec( "repeated_ngram_rate", "Wiederholte Wortfolgen", "discourse", "high", "Anteil wiederholter Wortdreiergruppen.", ), ) @dataclass class FeatureRule: key: str label: str group: str direction: Direction thresholds: list[float] description: str human_count: int = 0 machine_count: int = 0 machine_trigger_rates: list[float] = field(default_factory=list) def points(self, value: float) -> int: """Gibt 0 bis 3 Evidenzpunkte zurück.""" weak, clear, strong = self.thresholds if self.direction == "high": if value >= strong: return 3 if value >= clear: return 2 if value >= weak: return 1 return 0 if value <= strong: return 3 if value <= clear: return 2 if value <= weak: return 1 return 0 def triggered_threshold(self, points: int) -> float | None: if points <= 0: return None return self.thresholds[points - 1] @dataclass class ReferenceProfile: version: str language: str genre: str calibrated: bool group_weights: dict[str, float] rules: list[FeatureRule] calibration_metadata: dict[str, Any] = field(default_factory=dict) @classmethod def from_dict(cls, data: Mapping[str, Any]) -> "ReferenceProfile": return cls( version=str(data["version"]), language=str(data.get("language", "unknown")), genre=str(data.get("genre", "unknown")), calibrated=bool(data.get("calibrated", False)), group_weights={ str(key): float(value) for key, value in data.get("group_weights", GROUP_WEIGHTS).items() }, rules=[FeatureRule(**rule) for rule in data["rules"]], calibration_metadata=dict(data.get("calibration_metadata", {})), ) def to_dict(self) -> dict[str, Any]: return asdict(self) @dataclass class FeatureEvidence: key: str label: str group: str value: float points: int threshold: float | None direction: Direction description: str @dataclass class AnalysisResult: classification: str evidence_index: float | None confidence: str active_groups: list[str] group_scores: dict[str, float] features: list[FeatureEvidence] token_count: int model_count: int model_agreement: float hybrid_pattern: bool provenance: str | None result_text: str limitations: list[str] profile: dict[str, Any] def to_dict(self) -> dict[str, Any]: result = asdict(self) result["features"] = [asdict(item) for item in self.features] return result TRANSITION_PHRASES = ( "darüber hinaus", "zusammenfassend", "abschliessend", "im folgenden", "es ist wichtig", "ein weiterer aspekt", "nicht zuletzt", "insgesamt lässt sich", "auf der anderen seite", "in diesem zusammenhang", ) GENERIC_PHRASES = ( "in der heutigen zeit", "eine wichtige rolle", "zahlreiche vorteile", "vielfältige möglichkeiten", "es lässt sich sagen", "von entscheidender bedeutung", "sowohl chancen als auch risiken", "ein komplexes thema", "ganzheitlicher ansatz", "individuelle bedürfnisse", ) def _words(text: str) -> list[str]: return re.findall(r"[^\W\d_]+(?:['’-][^\W\d_]+)?", text.lower(), re.UNICODE) def _sentences(text: str) -> list[str]: return [part.strip() for part in re.split(r"(?<=[.!?])\s+|\n+", text) if part.strip()] def _coefficient_of_variation(values: Sequence[float]) -> float: if len(values) < 2: return 0.0 mean = statistics.fmean(values) if mean == 0: return 0.0 return statistics.pstdev(values) / mean def _repeated_ngram_rate(tokens: Sequence[str], n: int = 3) -> float: if len(tokens) < n: return 0.0 ngrams = [tuple(tokens[index : index + n]) for index in range(len(tokens) - n + 1)] return max(0.0, 1.0 - len(set(ngrams)) / len(ngrams)) def extract_surface_features(text: str) -> dict[str, float]: """Berechnet schwächere, modellunabhängige Oberflächenmerkmale. Diese Werte dürfen die tokenstatistische Analyse ergänzen, aber nicht ersetzen. Wortschatzwerte sind trotz Korrektur weiterhin längenabhängig. """ tokens = _words(text) sentences = _sentences(text) sentence_tokens = [_words(sentence) for sentence in sentences] lengths = [len(items) for items in sentence_tokens if items] starts: list[tuple[str, ...]] = [] for items in sentence_tokens: if items: starts.append(tuple(items[:2])) repeated_starts = len(starts) - len(set(starts)) lowered = text.lower() transition_hits = sum(lowered.count(phrase) for phrase in TRANSITION_PHRASES) generic_hits = sum(lowered.count(phrase) for phrase in GENERIC_PHRASES) # Root-TTR ist gegenüber der simplen Type-Token-Ratio etwas stabiler. lexical_diversity = 0.0 if tokens: lexical_diversity = len(set(tokens)) / math.sqrt(2.0 * len(tokens)) lexical_diversity = min(1.0, lexical_diversity) return { "sentence_length_cv": _coefficient_of_variation(lengths), "lexical_diversity": lexical_diversity, "repeated_sentence_start_rate": repeated_starts / max(1, len(starts)), "transition_phrase_rate": transition_hits / max(1, len(sentences)), "generic_phrase_rate": generic_hits / max(1, len(tokens)), "repeated_ngram_rate": _repeated_ngram_rate(tokens), } def _quantile(values: Sequence[float], probability: float) -> float: ordered = sorted(float(value) for value in values) if not ordered: raise ValueError("Für ein Quantil werden Werte benötigt.") if len(ordered) == 1: return ordered[0] position = (len(ordered) - 1) * probability lower = math.floor(position) upper = math.ceil(position) if lower == upper: return ordered[lower] fraction = position - lower return ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction def _trigger_rate(values: Sequence[float], direction: Direction, threshold: float) -> float: if not values: return 0.0 if direction == "high": hits = sum(value >= threshold for value in values) else: hits = sum(value <= threshold for value in values) return hits / len(values) def calibrate_profile( records: Sequence[Mapping[str, Any]], *, language: str = "de", genre: str = "mixed", minimum_per_class: int = 30, ) -> ReferenceProfile: """Leitet transparente Grenzwerte aus menschlichen Baselines ab. Schwach, klar und stark entsprechen Abweichungen über das 75., 90. und 97.5. Perzentil der menschlichen Verteilung. Bei Merkmalen mit Richtung ``low`` werden das 25., 10. und 2.5. Perzentil verwendet. Ein Merkmal wird nur übernommen, wenn sich der Median der maschinellen Daten in die erwartete Richtung verschiebt. """ human: dict[str, list[float]] = {spec.key: [] for spec in FEATURE_SPECS} machine: dict[str, list[float]] = {spec.key: [] for spec in FEATURE_SPECS} for record in records: label = str(record.get("label", "")).lower() if label not in {"human", "machine"}: continue target = human if label == "human" else machine features = record.get("features", {}) for spec in FEATURE_SPECS: value = features.get(spec.key) if isinstance(value, (int, float)) and math.isfinite(float(value)): target[spec.key].append(float(value)) rules: list[FeatureRule] = [] skipped: dict[str, str] = {} for spec in FEATURE_SPECS: human_values = human[spec.key] machine_values = machine[spec.key] if len(human_values) < minimum_per_class or len(machine_values) < minimum_per_class: skipped[spec.key] = "zu wenige Werte" continue human_median = statistics.median(human_values) machine_median = statistics.median(machine_values) expected_shift = ( machine_median > human_median if spec.direction == "high" else machine_median < human_median ) if not expected_shift: skipped[spec.key] = "keine passende Verschiebung im Kalibrierungskorpus" continue probabilities = (0.75, 0.90, 0.975) if spec.direction == "high" else (0.25, 0.10, 0.025) thresholds = [_quantile(human_values, probability) for probability in probabilities] rules.append( FeatureRule( key=spec.key, label=spec.label, group=spec.group, direction=spec.direction, thresholds=thresholds, description=spec.description, human_count=len(human_values), machine_count=len(machine_values), machine_trigger_rates=[ _trigger_rate(machine_values, spec.direction, threshold) for threshold in thresholds ], ) ) if not rules: raise ValueError( "Es konnten keine Regeln kalibriert werden. Prüfe Datenmenge, Labels und Merkmale." ) return ReferenceProfile( version="1.0", language=language, genre=genre, calibrated=True, group_weights=dict(GROUP_WEIGHTS), rules=rules, calibration_metadata={ "records": len(records), "minimum_per_class": minimum_per_class, "method": "menschliche Perzentile 75/90/97.5 bzw. 25/10/2.5", "skipped_features": skipped, }, ) def demo_profile() -> ReferenceProfile: """Liefert ausschliesslich technische Beispielwerte, keine Produktivwerte.""" thresholds: dict[str, list[float]] = { "top10_share": [0.42, 0.52, 0.62], "top100_share": [0.72, 0.82, 0.90], "mean_surprisal": [3.20, 2.60, 2.00], "surprisal_cv": [0.75, 0.60, 0.45], "mean_entropy": [5.50, 4.80, 4.00], "repetition_rate": [0.04, 0.07, 0.11], "sentence_length_cv": [0.65, 0.50, 0.35], "lexical_diversity": [0.55, 0.45, 0.35], "repeated_sentence_start_rate": [0.08, 0.15, 0.25], "machine_segment_share": [0.45, 0.65, 0.80], "segment_uniformity": [0.55, 0.70, 0.85], "transition_phrase_rate": [0.10, 0.20, 0.35], "generic_phrase_rate": [0.003, 0.006, 0.010], "repeated_ngram_rate": [0.02, 0.05, 0.09], } return ReferenceProfile( version="demo-1.0", language="de", genre="mixed", calibrated=False, group_weights=dict(GROUP_WEIGHTS), rules=[ FeatureRule( key=spec.key, label=spec.label, group=spec.group, direction=spec.direction, thresholds=thresholds[spec.key], description=spec.description, ) for spec in FEATURE_SPECS ], calibration_metadata={ "warning": "Nur technische Beispielwerte. Vor produktiver Nutzung kalibrieren." }, ) def _segment_features(segment_scores: Sequence[float]) -> dict[str, float]: if not segment_scores: return {} scores = [max(0.0, min(100.0, float(score))) for score in segment_scores] machine_share = sum(score >= 45.0 for score in scores) / len(scores) variation = statistics.pstdev(scores) if len(scores) > 1 else 0.0 uniformity = max(0.0, 1.0 - variation / 50.0) return { "machine_segment_share": machine_share, "segment_uniformity": uniformity, } def _hybrid_pattern(segment_scores: Sequence[float]) -> bool: if len(segment_scores) < 3: return False human_like = sum(score < 25.0 for score in segment_scores) / len(segment_scores) machine_like = sum(score >= 65.0 for score in segment_scores) / len(segment_scores) return human_like >= 0.30 and machine_like >= 0.30 def _confidence( token_count: int, model_count: int, model_agreement: float, domain_matched: bool, corpus_quality: float, ) -> str: if token_count < 150: return "nicht bewertbar" length_points = min(30.0, max(0.0, (token_count - 150) / 350.0 * 30.0)) model_points = min(20.0, model_count / 3.0 * 20.0) agreement_points = max(0.0, min(1.0, model_agreement)) * 25.0 domain_points = 15.0 if domain_matched else 0.0 corpus_points = max(0.0, min(1.0, corpus_quality)) * 10.0 quality = length_points + model_points + agreement_points + domain_points + corpus_points if quality >= 75.0: return "hoch" if quality >= 45.0: return "mittel" return "niedrig" def classify( *, token_count: int, features: Mapping[str, float], profile: ReferenceProfile, text: str | None = None, segment_scores: Sequence[float] = (), model_count: int = 1, model_agreement: float = 0.0, domain_matched: bool = False, corpus_quality: float = 0.0, provenance: str | None = None, ) -> AnalysisResult: """Klassifiziert Merkmale transparent und ohne Wahrscheinlichkeitsbehauptung.""" combined: dict[str, float] = {} if text: combined.update(extract_surface_features(text)) combined.update( { str(key): float(value) for key, value in features.items() if isinstance(value, (int, float)) and math.isfinite(float(value)) } ) combined.update(_segment_features(segment_scores)) feature_evidence: list[FeatureEvidence] = [] group_points: dict[str, float] = {group: 0.0 for group in profile.group_weights} group_max: dict[str, float] = {group: 0.0 for group in profile.group_weights} for rule in profile.rules: if rule.key not in combined: continue value = combined[rule.key] points = rule.points(value) group_points.setdefault(rule.group, 0.0) group_max.setdefault(rule.group, 0.0) group_points[rule.group] += points group_max[rule.group] += 3.0 feature_evidence.append( FeatureEvidence( key=rule.key, label=rule.label, group=rule.group, value=round(value, 6), points=points, threshold=rule.triggered_threshold(points), direction=rule.direction, description=rule.description, ) ) group_scores: dict[str, float] = {} active_groups: list[str] = [] for group, weight in profile.group_weights.items(): maximum = group_max.get(group, 0.0) ratio = group_points.get(group, 0.0) / maximum if maximum else 0.0 group_scores[group] = round(ratio * weight, 2) if maximum and ratio >= 0.34: active_groups.append(group) evidence_index = round(sum(group_scores.values()), 1) hybrid = _hybrid_pattern(segment_scores) confidence = _confidence( token_count, model_count, model_agreement, domain_matched, corpus_quality, ) if not profile.calibrated and provenance not in {"machine_verified", "human_verified"}: confidence = "niedrig" if provenance in {"machine_verified", "human_verified"}: confidence = "hoch" limitations: list[str] = [] if not profile.calibrated: limitations.append("Das verwendete Profil enthält nur Beispielwerte und ist nicht kalibriert.") if token_count < 300: limitations.append("Kurze Texte liefern weniger stabile statistische Signale.") if model_count < 2: limitations.append("Die Analyse verwendet weniger als zwei Referenzmodelle.") if not domain_matched: limitations.append("Sprache oder Textsorte wurden nicht als passend zum Vergleichskorpus bestätigt.") limitations.append( "Das Ergebnis beschreibt statistische Ähnlichkeit und beweist weder Urheberschaft noch ein bestimmtes Modell." ) if provenance == "machine_verified": classification = "verifizierte maschinelle Herkunft" result_text = "Vertrauenswürdige Herkunftsdaten weisen eine maschinelle Erzeugung aus." elif provenance == "human_verified": classification = "verifizierter menschlicher Arbeitsprozess" result_text = "Vertrauenswürdige Prozessdaten weisen einen menschlichen Arbeitsprozess aus." elif token_count < 150: classification = "nicht genügend Text" evidence_index = None result_text = "Der Text ist zu kurz für eine belastbare statistische Einordnung." elif hybrid: classification = "mögliches gemischtes oder überarbeitetes Muster" result_text = ( "Einzelne Textsegmente unterscheiden sich deutlich. Das kann auf gemischte " "Urheberschaft, Überarbeitung oder eingefügte Passagen hinweisen." ) elif ( evidence_index >= 65.0 and token_count >= 300 and len(active_groups) >= 3 and model_count >= 2 and model_agreement >= 0.60 ): classification = "stark maschinennahes Muster" result_text = ( "Der Text weist mehrere unabhängige statistische Merkmale auf, die im " "verwendeten Vergleichskorpus häufiger bei maschinell erzeugten Texten vorkamen." ) elif evidence_index >= 45.0 and len(active_groups) >= 2: classification = "eher maschinennahes Muster" result_text = ( "Der Text weist statistische Merkmale auf, die im verwendeten Vergleichskorpus " "häufiger bei maschinell erzeugten Texten vorkamen." ) elif evidence_index >= 25.0: classification = "uneindeutiges Muster" result_text = "Die gemessenen Merkmale erlauben keine klare Einordnung." else: classification = "keine deutlichen maschinennahen Muster" result_text = ( "Die Analyse fand keine deutlichen maschinennahen Muster. Das beweist keine " "menschliche Urheberschaft." ) feature_evidence.sort(key=lambda item: (item.points, item.group, item.key), reverse=True) return AnalysisResult( classification=classification, evidence_index=evidence_index, confidence=confidence, active_groups=active_groups, group_scores=group_scores, features=feature_evidence, token_count=token_count, model_count=model_count, model_agreement=round(max(0.0, min(1.0, model_agreement)), 3), hybrid_pattern=hybrid, provenance=provenance, result_text=result_text, limitations=limitations, profile={ "version": profile.version, "language": profile.language, "genre": profile.genre, "calibrated": profile.calibrated, }, ) def _load_json(path: str | Path) -> dict[str, Any]: with Path(path).open("r", encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, dict): raise ValueError("Die JSON-Datei muss ein Objekt enthalten.") return data def _example_input() -> dict[str, Any]: return { "token_count": 642, "features": { "top10_share": 0.57, "top100_share": 0.86, "mean_surprisal": 2.35, "surprisal_cv": 0.52, "mean_entropy": 4.55, "repetition_rate": 0.075, }, "segment_scores": [67, 71, 62, 69, 74], "model_count": 2, "model_agreement": 0.78, "domain_matched": True, "corpus_quality": 0.80, "provenance": None, } def _analyse_payload(payload: Mapping[str, Any], profile: ReferenceProfile) -> AnalysisResult: text = payload.get("text") token_count = int(payload.get("token_count", len(_words(str(text or ""))))) return classify( token_count=token_count, features=payload.get("features", {}), profile=profile, text=str(text) if text is not None else None, segment_scores=payload.get("segment_scores", []), model_count=int(payload.get("model_count", 1)), model_agreement=float(payload.get("model_agreement", 0.0)), domain_matched=bool(payload.get("domain_matched", False)), corpus_quality=float(payload.get("corpus_quality", 0.0)), provenance=payload.get("provenance"), ) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--input", help="JSON-Datei mit Text, Merkmalen und Metadaten.") parser.add_argument("--profile", help="Kalibriertes Referenzprofil als JSON.") parser.add_argument( "--demo-profile", action="store_true", help="Verwendet nicht kalibrierte technische Beispielwerte.", ) parser.add_argument("--calibrate", help="JSON-Datei mit human/machine-Kalibrierungsdaten.") parser.add_argument("--output-profile", help="Zielpfad für das kalibrierte Profil.") parser.add_argument("--language", default="de", help="Sprache des Kalibrierungskorpus.") parser.add_argument("--genre", default="mixed", help="Textsorte des Kalibrierungskorpus.") parser.add_argument("--minimum-per-class", type=int, default=30) parser.add_argument("--example-input", action="store_true") parser.add_argument("--print-demo-profile", action="store_true") parser.add_argument("--pretty", action="store_true", help="Formatiert die JSON-Ausgabe.") return parser def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) indent = 2 if args.pretty else None if args.example_input: print(json.dumps(_example_input(), ensure_ascii=False, indent=2)) return 0 if args.print_demo_profile: print(json.dumps(demo_profile().to_dict(), ensure_ascii=False, indent=2)) return 0 if args.calibrate: if not args.output_profile: parser.error("--calibrate benötigt --output-profile") calibration = _load_json(args.calibrate) records = calibration.get("records", []) if not isinstance(records, list): raise ValueError("'records' muss eine Liste sein.") profile = calibrate_profile( records, language=args.language, genre=args.genre, minimum_per_class=args.minimum_per_class, ) with Path(args.output_profile).open("w", encoding="utf-8") as handle: json.dump(profile.to_dict(), handle, ensure_ascii=False, indent=2) handle.write("\n") print(json.dumps({"saved": args.output_profile, "rules": len(profile.rules)}, ensure_ascii=False)) return 0 if not args.input: parser.error("Nutze --input, --calibrate, --example-input oder --print-demo-profile") if not args.profile and not args.demo_profile: parser.error("Nutze --profile PROFIL.json oder ausdrücklich --demo-profile") profile = ReferenceProfile.from_dict(_load_json(args.profile)) if args.profile else demo_profile() payload = _load_json(args.input) result = _analyse_payload(payload, profile) print(json.dumps(result.to_dict(), ensure_ascii=False, indent=indent)) return 0 if __name__ == "__main__": raise SystemExit(main())