Artikel

10. Qualitätsprüfung

Allgemein

Die Qualitätsprüfung verhindert, dass ein generierter Beitrag direkt in die Review-Queue oder in den Veröffentlichungsprozess gelangt, obwohl er technisch oder inhaltlich ungeeignet ist.

Das Quality-Gate prüft nicht, ob ein Beitrag brillant ist. Es prüft, ob er veröffentlichbar ist.

PostDraft
    ↓
harte Ausschlussregeln
    ↓
Zeichenlimit
    ↓
Link-Prüfung
    ↓
Wiederholungsprüfung
    ↓
Tonalitätsprüfung
    ↓
Verkaufsdruck-Prüfung
    ↓
draft bleibt draft oder wird quality_failed

10.1 Harte Ausschlussregeln

Harte Ausschlussregeln laufen ohne KI. Sie sind deterministisch.

Ein Beitrag wird abgelehnt, wenn:

Text leer ist
Text nur aus Leerzeichen besteht
Plattform unbekannt ist
Beitragstyp unbekannt ist
Pflichtbild fehlt
Pflichtlink fehlt
verbotene Formulierung enthalten ist
Text zu lang ist
Text technische Platzhalter enthält
Text interne Markierungen enthält

Beispiele für verbotene Fragmente:

Jetzt kaufen
Unbedingt lesen
Tauchen Sie ein
Meisterwerk
Ein Muss
Lassen Sie sich verzaubern
{{ 
}}
TODO
Lorem ipsum

Python:

class HardRejectRule:
    def __init__(self) -> None:
        self.blocked_fragments = [
            "jetzt kaufen",
            "unbedingt lesen",
            "tauchen sie ein",
            "meisterwerk",
            "ein muss",
            "lassen sie sich verzaubern",
            "{{",
            "}}",
            "todo",
            "lorem ipsum",
        ]

    def validate(self, draft: "PostDraft") -> None:
        text = draft.text.strip()

        if text == "":
            raise QualityGateError("Post text is empty.")

        platform_names = [
            "linkedin",
            "instagram",
            "facebook",
        ]

        if draft.platform not in platform_names:
            raise QualityGateError("Unsupported platform: " + draft.platform)

        post_types = [
            "book_intro",
            "quote",
            "workshop",
            "blog_summary",
            "comment",
        ]

        if draft.post_type not in post_types:
            raise QualityGateError("Unsupported post type: " + draft.post_type)

        lowered_text = text.lower()

        for blocked_fragment in self.blocked_fragments:
            if blocked_fragment in lowered_text:
                raise QualityGateError("Blocked fragment found: " + blocked_fragment)

        if draft.platform == "instagram" and draft.image_path is None:
            raise QualityGateError("Instagram post requires image_path.")

Fehlerklasse:

class QualityGateError(Exception):
    pass

10.2 Generik-Erkennung

Generische Beiträge sind formal korrekt, aber wertlos.

Typische Muster:

Spannende Einblicke
Ein zeitloses Werk
Ein Klassiker, der nichts von seiner Aktualität verloren hat
Perfekt für alle Literaturfreunde
Dieses Buch nimmt Sie mit auf eine Reise

Deterministische Prüfung:

class GenericPhraseValidator:
    def __init__(self) -> None:
        self.generic_fragments = [
            "zeitloses werk",
            "nichts von seiner aktualität verloren",
            "spannende einblicke",
            "literaturfreunde",
            "nimmt sie mit auf eine reise",
            "faszinierende geschichte",
            "unvergessliche figuren",
            "perfekt für",
            "für alle fans von",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.generic_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Generic phrase found: " + fragment)

Zusätzlich kann OpenAI eine zweite Prüfung übernehmen.

Prompt:

Du prüfst, ob ein Social-Media-Beitrag generisch klingt.

Beitrag:
{{ post_text }}

Kriterien:
- allgemeine Floskeln
- austauschbare Buchwerbung
- keine konkrete Aussage
- kein eigener Gedanke
- kein erkennbarer Bezug zu Buch, Verlag oder Thema

Antworte ausschließlich im JSON-Format.

Schema:

GENERIC_CHECK_SCHEMA: dict[str, object] = {
    "type": "object",
    "properties": {
        "is_generic": {
            "type": "boolean"
        },
        "reason": {
            "type": "string"
        }
    },
    "required": [
        "is_generic",
        "reason"
    ],
    "additionalProperties": False
}

10.3 Wiederholungsprüfung

Wiederholung entsteht auf zwei Ebenen:

identischer Text
inhaltlich gleicher Gedanke

Deterministische Prüfung gegen bereits veröffentlichte Beiträge:

class ExactDuplicateValidator:
    def validate(self, new_text: str, previous_texts: list[str]) -> None:
        normalized_new_text = self._normalize(new_text)

        for previous_text in previous_texts:
            normalized_previous_text = self._normalize(previous_text)

            if normalized_new_text == normalized_previous_text:
                raise QualityGateError("Exact duplicate found.")

    def _normalize(self, text: str) -> str:
        lowered_text = text.lower()
        collapsed_text = " ".join(lowered_text.split())

        return collapsed_text

Einfache Ähnlichkeitsprüfung ohne externe Bibliothek:

class SimilarityValidator:
    def validate(self, new_text: str, previous_texts: list[str], max_similarity: float) -> None:
        new_words = self._words(new_text)

        for previous_text in previous_texts:
            previous_words = self._words(previous_text)
            similarity = self._jaccard(new_words, previous_words)

            if similarity >= max_similarity:
                raise QualityGateError("Similar post found.")

    def _words(self, text: str) -> set[str]:
        lowered_text = text.lower()
        cleaned_text = ""

        for character in lowered_text:
            if character.isalnum() is True or character == " ":
                cleaned_text = cleaned_text + character
            else:
                cleaned_text = cleaned_text + " "

        words = set(cleaned_text.split())

        return words

    def _jaccard(self, left_words: set[str], right_words: set[str]) -> float:
        if len(left_words) == 0 and len(right_words) == 0:
            return 1.0

        intersection = left_words.intersection(right_words)
        union = left_words.union(right_words)

        return len(intersection) / len(union)

Empfohlener Schwellenwert:

0.65

Für spätere Versionen kann OpenAI zusätzlich prüfen, ob der Gedanke trotz anderer Formulierung identisch ist.

10.4 Link-Prüfung

Links dürfen nicht blind übernommen werden.

Zu prüfen:

Link vorhanden, wenn erforderlich
Link beginnt mit https://
Link enthält keine Leerzeichen
Link gehört zu erlaubter Domain
Link ist nicht offensichtlich kaputt

Für Social-Media-Posts reicht zunächst eine formale Prüfung.

from urllib.parse import urlparse


class LinkValidator:
    def __init__(self, allowed_domains: list[str]) -> None:
        self.allowed_domains = allowed_domains

    def validate_optional(self, url: str | None) -> None:
        if url is None:
            return

        self._validate_url(url)

    def validate_required(self, url: str | None) -> None:
        if url is None:
            raise QualityGateError("Required URL is missing.")

        self._validate_url(url)

    def _validate_url(self, url: str) -> None:
        cleaned_url = url.strip()

        if cleaned_url == "":
            raise QualityGateError("URL is empty.")

        if " " in cleaned_url:
            raise QualityGateError("URL contains spaces.")

        parsed_url = urlparse(cleaned_url)

        if parsed_url.scheme != "https":
            raise QualityGateError("URL must use https.")

        if parsed_url.netloc == "":
            raise QualityGateError("URL host is missing.")

        if parsed_url.netloc not in self.allowed_domains:
            raise QualityGateError("URL domain is not allowed: " + parsed_url.netloc)

Beispiel:

validator = LinkValidator(
    allowed_domains=[
        "xyz.de",
        "www.xyz.de",
        "abc.com",
        "www.abc.com",
    ]
)

HTTP-Prüfung kann später ergänzt werden. Für ein schnelles MVP reicht die formale Prüfung, weil sie keine Netzwerkfehler erzeugt.

10.5 Zeichenlimit-Prüfung

Plattformlimits sollten absichtlich niedriger gesetzt werden als die technischen Maximallimits.

Empfohlene interne Limits:

LinkedIn: 900 Zeichen
Instagram: 700 Zeichen
Facebook: 1.200 Zeichen
Kommentar: 500 Zeichen
Antwort auf Nutzerkommentar: 600 Zeichen

Python:

class CharacterLimitValidator:
    def __init__(self) -> None:
        self.platform_limits = {
            "linkedin": 900,
            "instagram": 700,
            "facebook": 1200,
        }

        self.post_type_limits = {
            "comment": 500,
            "comment_reply": 600,
        }

    def validate(self, platform: str, post_type: str, text: str) -> None:
        limit = self._get_limit(platform, post_type)

        if len(text) > limit:
            raise QualityGateError(
                "Text exceeds character limit. Limit: "
                + str(limit)
                + ", actual: "
                + str(len(text))
            )

    def _get_limit(self, platform: str, post_type: str) -> int:
        post_type_limit = self.post_type_limits.get(post_type)

        if post_type_limit is not None:
            return post_type_limit

        platform_limit = self.platform_limits.get(platform)

        if platform_limit is None:
            raise QualityGateError("Missing character limit for platform: " + platform)

        return platform_limit

10.6 Tonalitätsprüfung

Die Tonalitätsprüfung kann zweistufig erfolgen:

harte Phrase-Prüfung im Code
semantische Prüfung per OpenAI

Deterministische Prüfung:

class ToneValidator:
    def __init__(self) -> None:
        self.rejected_fragments = [
            "unglaublich",
            "fantastisch",
            "wunderschön",
            "magisch",
            "bezaubernd",
            "atemberaubend",
            "einzigartig",
            "sensationell",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.rejected_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Rejected tone fragment found: " + fragment)

OpenAI-Prüfung:

Du prüfst die Tonalität eines Social-Media-Beitrags für einen kleinen deutschen E-Book-Verlag.

Gewünschte Tonalität:
- sachlich
- professionell
- kultiviert
- nicht anbiedernd
- nicht künstlich emotional
- nicht werblich
- keine Floskeln

Beitrag:
{{ post_text }}

Antworte ausschließlich als JSON.

Schema:

TONE_CHECK_SCHEMA: dict[str, object] = {
    "type": "object",
    "properties": {
        "passed": {
            "type": "boolean"
        },
        "tone_score": {
            "type": "number"
        },
        "reason": {
            "type": "string"
        }
    },
    "required": [
        "passed",
        "tone_score",
        "reason"
    ],
    "additionalProperties": False
}

Validator für das Ergebnis:

from typing import Any


class ToneCheckResultValidator:
    def validate(self, payload: dict[str, Any]) -> None:
        passed = payload.get("passed")
        tone_score = payload.get("tone_score")
        reason = payload.get("reason")

        if isinstance(passed, bool) is False:
            raise QualityGateError("tone_check.passed must be boolean.")

        if isinstance(tone_score, int) is False and isinstance(tone_score, float) is False:
            raise QualityGateError("tone_check.tone_score must be number.")

        if isinstance(reason, str) is False:
            raise QualityGateError("tone_check.reason must be string.")

        if passed is False:
            raise QualityGateError("Tone check failed: " + reason)

10.7 Verkaufsdruck-Prüfung

Verkaufsdruck beschädigt die Verlagsstimme. Die Prüfung sollte streng sein.

Blockierte Muster:

Jetzt kaufen
Hier bestellen
Nur heute
Sichern Sie sich
Greifen Sie zu
Ein Muss für alle
Unbedingt lesen

Python:

class SalesPressureValidator:
    def __init__(self) -> None:
        self.blocked_fragments = [
            "jetzt kaufen",
            "hier bestellen",
            "nur heute",
            "sichern sie sich",
            "greifen sie zu",
            "ein muss für alle",
            "unbedingt lesen",
            "kaufen sie",
            "bestellen sie",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.blocked_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Sales pressure found: " + fragment)

Zulässig sind dezente Hinweise:

Die Ausgabe ist im Shop verfügbar.
Mehr dazu im Blog.
Weitere Titel stehen im Verlagsprogramm.

Bei Kommentaren sollte jeder Verkaufslink ausgeschlossen werden.

class CommentSalesLinkValidator:
    def validate(self, post_type: str, text: str) -> None:
        if post_type != "comment" and post_type != "comment_reply":
            return

        lowered_text = text.lower()

        if "https://" in lowered_text:
            raise QualityGateError("Comments must not contain links.")

        if "shop" in lowered_text:
            raise QualityGateError("Comments must not contain shop references.")

10.8 Beispiel: quality_gate.py

Datei:

src/quality_gate.py

from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse


class QualityGateError(Exception):
    pass


@dataclass(frozen=True)
class QualityGateResult:
    passed: bool
    reason: str
    checks: dict[str, bool]


@dataclass(frozen=True)
class PostDraft:
    id: str
    source_type: str
    source_id: str
    platform: str
    post_type: str
    status: str
    text: str
    image_path: str | None
    target_url: str | None
    prompt_name: str
    prompt_version: str
    model: str
    quality_result: dict[str, Any] | None
    created_at: str
    updated_at: str


class HardRejectRule:
    def __init__(self) -> None:
        self.blocked_fragments = [
            "jetzt kaufen",
            "unbedingt lesen",
            "tauchen sie ein",
            "meisterwerk",
            "ein muss",
            "lassen sie sich verzaubern",
            "{{",
            "}}",
            "todo",
            "lorem ipsum",
        ]

    def validate(self, draft: PostDraft) -> None:
        text = draft.text.strip()

        if text == "":
            raise QualityGateError("Post text is empty.")

        platform_names = [
            "linkedin",
            "instagram",
            "facebook",
        ]

        if draft.platform not in platform_names:
            raise QualityGateError("Unsupported platform: " + draft.platform)

        post_types = [
            "book_intro",
            "quote",
            "workshop",
            "blog_summary",
            "comment",
            "comment_reply",
        ]

        if draft.post_type not in post_types:
            raise QualityGateError("Unsupported post type: " + draft.post_type)

        lowered_text = text.lower()

        for blocked_fragment in self.blocked_fragments:
            if blocked_fragment in lowered_text:
                raise QualityGateError("Blocked fragment found: " + blocked_fragment)

        if draft.platform == "instagram" and draft.image_path is None:
            raise QualityGateError("Instagram post requires image_path.")


class CharacterLimitValidator:
    def __init__(self) -> None:
        self.platform_limits = {
            "linkedin": 900,
            "instagram": 700,
            "facebook": 1200,
        }

        self.post_type_limits = {
            "comment": 500,
            "comment_reply": 600,
        }

    def validate(self, platform: str, post_type: str, text: str) -> None:
        limit = self._get_limit(platform, post_type)

        if len(text) > limit:
            raise QualityGateError(
                "Text exceeds character limit. Limit: "
                + str(limit)
                + ", actual: "
                + str(len(text))
            )

    def _get_limit(self, platform: str, post_type: str) -> int:
        post_type_limit = self.post_type_limits.get(post_type)

        if post_type_limit is not None:
            return post_type_limit

        platform_limit = self.platform_limits.get(platform)

        if platform_limit is None:
            raise QualityGateError("Missing character limit for platform: " + platform)

        return platform_limit


class LinkValidator:
    def __init__(self, allowed_domains: list[str]) -> None:
        self.allowed_domains = allowed_domains

    def validate_optional(self, url: str | None) -> None:
        if url is None:
            return

        self._validate_url(url)

    def validate_required(self, url: str | None) -> None:
        if url is None:
            raise QualityGateError("Required URL is missing.")

        self._validate_url(url)

    def _validate_url(self, url: str) -> None:
        cleaned_url = url.strip()

        if cleaned_url == "":
            raise QualityGateError("URL is empty.")

        if " " in cleaned_url:
            raise QualityGateError("URL contains spaces.")

        parsed_url = urlparse(cleaned_url)

        if parsed_url.scheme != "https":
            raise QualityGateError("URL must use https.")

        if parsed_url.netloc == "":
            raise QualityGateError("URL host is missing.")

        if parsed_url.netloc not in self.allowed_domains:
            raise QualityGateError("URL domain is not allowed: " + parsed_url.netloc)


class GenericPhraseValidator:
    def __init__(self) -> None:
        self.generic_fragments = [
            "zeitloses werk",
            "nichts von seiner aktualität verloren",
            "spannende einblicke",
            "literaturfreunde",
            "nimmt sie mit auf eine reise",
            "faszinierende geschichte",
            "unvergessliche figuren",
            "perfekt für",
            "für alle fans von",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.generic_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Generic phrase found: " + fragment)


class ExactDuplicateValidator:
    def validate(self, new_text: str, previous_texts: list[str]) -> None:
        normalized_new_text = self._normalize(new_text)

        for previous_text in previous_texts:
            normalized_previous_text = self._normalize(previous_text)

            if normalized_new_text == normalized_previous_text:
                raise QualityGateError("Exact duplicate found.")

    def _normalize(self, text: str) -> str:
        lowered_text = text.lower()
        collapsed_text = " ".join(lowered_text.split())

        return collapsed_text


class SimilarityValidator:
    def validate(self, new_text: str, previous_texts: list[str], max_similarity: float) -> None:
        new_words = self._words(new_text)

        for previous_text in previous_texts:
            previous_words = self._words(previous_text)
            similarity = self._jaccard(new_words, previous_words)

            if similarity >= max_similarity:
                raise QualityGateError("Similar post found.")

    def _words(self, text: str) -> set[str]:
        lowered_text = text.lower()
        cleaned_text = ""

        for character in lowered_text:
            if character.isalnum() is True or character == " ":
                cleaned_text = cleaned_text + character
            else:
                cleaned_text = cleaned_text + " "

        return set(cleaned_text.split())

    def _jaccard(self, left_words: set[str], right_words: set[str]) -> float:
        if len(left_words) == 0 and len(right_words) == 0:
            return 1.0

        intersection = left_words.intersection(right_words)
        union = left_words.union(right_words)

        return len(intersection) / len(union)


class ToneValidator:
    def __init__(self) -> None:
        self.rejected_fragments = [
            "unglaublich",
            "fantastisch",
            "wunderschön",
            "magisch",
            "bezaubernd",
            "atemberaubend",
            "einzigartig",
            "sensationell",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.rejected_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Rejected tone fragment found: " + fragment)


class SalesPressureValidator:
    def __init__(self) -> None:
        self.blocked_fragments = [
            "jetzt kaufen",
            "hier bestellen",
            "nur heute",
            "sichern sie sich",
            "greifen sie zu",
            "ein muss für alle",
            "unbedingt lesen",
            "kaufen sie",
            "bestellen sie",
        ]

    def validate(self, text: str) -> None:
        lowered_text = text.lower()

        for fragment in self.blocked_fragments:
            if fragment in lowered_text:
                raise QualityGateError("Sales pressure found: " + fragment)


class CommentSalesLinkValidator:
    def validate(self, post_type: str, text: str) -> None:
        if post_type != "comment" and post_type != "comment_reply":
            return

        lowered_text = text.lower()

        if "https://" in lowered_text:
            raise QualityGateError("Comments must not contain links.")

        if "shop" in lowered_text:
            raise QualityGateError("Comments must not contain shop references.")


class QualityGate:
    def __init__(self) -> None:
        self.hard_reject_rule = HardRejectRule()
        self.character_limit_validator = CharacterLimitValidator()
        self.link_validator = LinkValidator(
            allowed_domains=[
                "xyz.de",
                "www.xyz.de",
                "abc.com",
                "www.abc.com",
            ]
        )
        self.generic_phrase_validator = GenericPhraseValidator()
        self.exact_duplicate_validator = ExactDuplicateValidator()
        self.similarity_validator = SimilarityValidator()
        self.tone_validator = ToneValidator()
        self.sales_pressure_validator = SalesPressureValidator()
        self.comment_sales_link_validator = CommentSalesLinkValidator()

    def check(self, draft: PostDraft, previous_texts: list[str]) -> QualityGateResult:
        checks: dict[str, bool] = {
            "hard_reject": False,
            "character_limit": False,
            "link": False,
            "generic": False,
            "duplicate": False,
            "similarity": False,
            "tone": False,
            "sales_pressure": False,
            "comment_sales_link": False,
        }

        try:
            self.hard_reject_rule.validate(draft)
            checks["hard_reject"] = True

            self.character_limit_validator.validate(
                platform=draft.platform,
                post_type=draft.post_type,
                text=draft.text,
            )
            checks["character_limit"] = True

            self.link_validator.validate_optional(draft.target_url)
            checks["link"] = True

            self.generic_phrase_validator.validate(draft.text)
            checks["generic"] = True

            self.exact_duplicate_validator.validate(draft.text, previous_texts)
            checks["duplicate"] = True

            self.similarity_validator.validate(
                new_text=draft.text,
                previous_texts=previous_texts,
                max_similarity=0.65,
            )
            checks["similarity"] = True

            self.tone_validator.validate(draft.text)
            checks["tone"] = True

            self.sales_pressure_validator.validate(draft.text)
            checks["sales_pressure"] = True

            self.comment_sales_link_validator.validate(
                post_type=draft.post_type,
                text=draft.text,
            )
            checks["comment_sales_link"] = True

            return QualityGateResult(
                passed=True,
                reason="passed",
                checks=checks,
            )
        except QualityGateError as exception:
            return QualityGateResult(
                passed=False,
                reason=str(exception),
                checks=checks,
            )

Beispiel: Aufruf

from src.quality_gate import PostDraft
from src.quality_gate import QualityGate


def main() -> None:
    draft = PostDraft(
        id="draft_20260512_001",
        source_type="manual_topic",
        source_id="topic_001",
        platform="linkedin",
        post_type="workshop",
        status="draft",
        text="Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig. OCR-Korrektur, Typografie und Metadaten bleiben echte Arbeit.",
        image_path=None,
        target_url="https://www.xyz.de",
        prompt_name="workshop_note",
        prompt_version="1.0.0",
        model="gpt-4.1-mini",
        quality_result=None,
        created_at="2026-05-12T09:00:00+02:00",
        updated_at="2026-05-12T09:00:00+02:00",
    )

    previous_texts = [
        "Gemeinfreie Literatur ist frei verfügbar, aber daraus entsteht noch keine gute digitale Ausgabe."
    ]

    quality_gate = QualityGate()
    result = quality_gate.check(draft, previous_texts)

    print(result)


if __name__ == "__main__":
    main()

Ergebnis dieses Kapitels

Das Quality-Gate prüft jetzt:

harte Ausschlussregeln
Generik
Wiederholung
Links
Zeichenlimits
Tonalität
Verkaufsdruck
Kommentarlinks

Damit wird aus einem rohen KI-Entwurf noch kein veröffentlichter Beitrag, sondern erst ein technisch und stilistisch geprüfter Kandidat für die Review-Queue.

0 Kommentare