192 lines
5.7 KiB
Python
192 lines
5.7 KiB
Python
"""Deterministic quality gates for generated child-facing content."""
|
||
|
||
from dataclasses import dataclass
|
||
from enum import StrEnum
|
||
|
||
from app.services.adapters.storybook.primary import Storybook
|
||
from app.services.adapters.text.models import StoryOutput
|
||
from app.services.harness.types import FailureCategory
|
||
|
||
|
||
class QualityGateCode(StrEnum):
|
||
"""Stable issue codes emitted by deterministic quality gates."""
|
||
|
||
MISSING_TITLE = "missing_title"
|
||
MISSING_STORY_TEXT = "missing_story_text"
|
||
MISSING_COVER_PROMPT = "missing_cover_prompt"
|
||
MISSING_STORYBOOK_PAGE = "missing_storybook_page"
|
||
INVALID_STORYBOOK_PAGE_NUMBER = "invalid_storybook_page_number"
|
||
MISSING_STORYBOOK_PAGE_TEXT = "missing_storybook_page_text"
|
||
UNSAFE_CHILD_CONTENT = "unsafe_child_content"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QualityGateIssue:
|
||
"""One deterministic quality gate issue."""
|
||
|
||
code: QualityGateCode
|
||
message: str
|
||
failure_category: FailureCategory = FailureCategory.SCHEMA_ERROR
|
||
field: str | None = None
|
||
|
||
def to_metadata(self) -> dict:
|
||
"""Return a JSON-safe metadata payload."""
|
||
|
||
return {
|
||
"code": self.code.value,
|
||
"message": self.message,
|
||
"failure_category": self.failure_category.value,
|
||
"field": self.field,
|
||
}
|
||
|
||
|
||
class QualityGateError(ValueError):
|
||
"""Raised when generated content fails deterministic quality gates."""
|
||
|
||
def __init__(self, issues: list[QualityGateIssue]):
|
||
self.issues = issues
|
||
message = ";".join(issue.message for issue in issues)
|
||
super().__init__(message)
|
||
|
||
def to_metadata(self) -> dict:
|
||
"""Return a JSON-safe metadata payload."""
|
||
|
||
return {"issues": [issue.to_metadata() for issue in self.issues]}
|
||
|
||
|
||
UNSAFE_CHILD_TERMS = (
|
||
"自杀",
|
||
"自残",
|
||
"血腥",
|
||
"虐待",
|
||
"毒品",
|
||
"色情",
|
||
)
|
||
|
||
|
||
def _is_blank(value: str | None) -> bool:
|
||
return not value or not value.strip()
|
||
|
||
|
||
def _unsafe_issue_if_present(text: str, *, field: str) -> QualityGateIssue | None:
|
||
for term in UNSAFE_CHILD_TERMS:
|
||
if term in text:
|
||
return QualityGateIssue(
|
||
code=QualityGateCode.UNSAFE_CHILD_CONTENT,
|
||
message="生成内容包含不适合 3-8 岁儿童的明显风险词。",
|
||
failure_category=FailureCategory.SAFETY_ERROR,
|
||
field=field,
|
||
)
|
||
return None
|
||
|
||
|
||
def validate_story_output(output: StoryOutput) -> None:
|
||
"""Validate generated text story output before persistence."""
|
||
|
||
issues: list[QualityGateIssue] = []
|
||
|
||
if _is_blank(output.title):
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_TITLE,
|
||
message="故事标题为空。",
|
||
field="title",
|
||
)
|
||
)
|
||
|
||
if _is_blank(output.story_text):
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_STORY_TEXT,
|
||
message="故事正文为空。",
|
||
field="story_text",
|
||
)
|
||
)
|
||
|
||
if _is_blank(output.cover_prompt_suggestion):
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_COVER_PROMPT,
|
||
message="封面提示词为空。",
|
||
field="cover_prompt_suggestion",
|
||
)
|
||
)
|
||
|
||
unsafe_issue = _unsafe_issue_if_present(
|
||
" ".join([output.title or "", output.story_text or ""]),
|
||
field="story_text",
|
||
)
|
||
if unsafe_issue is not None:
|
||
issues.append(unsafe_issue)
|
||
|
||
if issues:
|
||
raise QualityGateError(issues)
|
||
|
||
|
||
def validate_storybook_output(output: Storybook) -> None:
|
||
"""Validate generated storybook output before persistence."""
|
||
|
||
issues: list[QualityGateIssue] = []
|
||
|
||
if _is_blank(output.title):
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_TITLE,
|
||
message="绘本标题为空。",
|
||
field="title",
|
||
)
|
||
)
|
||
|
||
if not output.pages:
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_STORYBOOK_PAGE,
|
||
message="绘本至少需要一页内容。",
|
||
field="pages",
|
||
)
|
||
)
|
||
|
||
seen_page_numbers: set[int] = set()
|
||
page_texts: list[str] = []
|
||
for index, page in enumerate(output.pages, start=1):
|
||
if not isinstance(page.page_number, int) or page.page_number <= 0:
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.INVALID_STORYBOOK_PAGE_NUMBER,
|
||
message=f"绘本第 {index} 个页面页码无效。",
|
||
field=f"pages[{index - 1}].page_number",
|
||
)
|
||
)
|
||
elif page.page_number in seen_page_numbers:
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.INVALID_STORYBOOK_PAGE_NUMBER,
|
||
message=f"绘本页码 {page.page_number} 重复。",
|
||
field=f"pages[{index - 1}].page_number",
|
||
)
|
||
)
|
||
else:
|
||
seen_page_numbers.add(page.page_number)
|
||
|
||
if _is_blank(page.text):
|
||
issues.append(
|
||
QualityGateIssue(
|
||
code=QualityGateCode.MISSING_STORYBOOK_PAGE_TEXT,
|
||
message=f"绘本第 {index} 页正文为空。",
|
||
field=f"pages[{index - 1}].text",
|
||
)
|
||
)
|
||
else:
|
||
page_texts.append(page.text)
|
||
|
||
unsafe_issue = _unsafe_issue_if_present(
|
||
" ".join([output.title or "", *page_texts]),
|
||
field="pages",
|
||
)
|
||
if unsafe_issue is not None:
|
||
issues.append(unsafe_issue)
|
||
|
||
if issues:
|
||
raise QualityGateError(issues)
|
||
|