Add generation harness runtime

This commit is contained in:
2026-06-21 22:31:38 +08:00
parent 7ebdfb2582
commit 459ca9edef
18 changed files with 2846 additions and 419 deletions

View File

@@ -0,0 +1,191 @@
"""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)