review_schema.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 审查结果 schema(v6)。
  5. 替代原 checker-output-schema.md 的评分制,改为结构化问题清单。
  6. """
  7. from __future__ import annotations
  8. from dataclasses import asdict, dataclass, field
  9. from datetime import datetime
  10. from typing import Any, Dict, List, Optional
  11. VALID_SEVERITIES = {"critical", "high", "medium", "low"}
  12. VALID_CATEGORIES = {
  13. "continuity", "setting", "character", "timeline",
  14. "ai_flavor", "logic", "pacing", "other",
  15. }
  16. SCORE_CATEGORIES = (
  17. "continuity",
  18. "setting",
  19. "character",
  20. "timeline",
  21. "ai_flavor",
  22. "logic",
  23. "pacing",
  24. "other",
  25. )
  26. SEVERITY_PENALTIES = {
  27. "critical": 35.0,
  28. "high": 15.0,
  29. "medium": 6.0,
  30. "low": 2.0,
  31. }
  32. def _clamp_score(value: float) -> float:
  33. return round(max(0.0, min(100.0, value)), 2)
  34. def _issue_penalty(issue: "ReviewIssue") -> float:
  35. return float(SEVERITY_PENALTIES.get(issue.severity, SEVERITY_PENALTIES["medium"]))
  36. @dataclass
  37. class ReviewIssue:
  38. severity: str
  39. category: str = "other"
  40. location: str = ""
  41. description: str = ""
  42. evidence: str = ""
  43. fix_hint: str = ""
  44. blocking: Optional[bool] = None
  45. def __post_init__(self):
  46. if self.severity not in VALID_SEVERITIES:
  47. self.severity = "medium"
  48. if self.category not in VALID_CATEGORIES:
  49. self.category = "other"
  50. if self.blocking is None:
  51. self.blocking = self.severity == "critical"
  52. def to_dict(self) -> Dict[str, Any]:
  53. return asdict(self)
  54. @dataclass
  55. class ReviewResult:
  56. chapter: int
  57. issues: List[ReviewIssue] = field(default_factory=list)
  58. summary: str = ""
  59. @property
  60. def issues_count(self) -> int:
  61. return len(self.issues)
  62. @property
  63. def blocking_count(self) -> int:
  64. return sum(1 for i in self.issues if i.blocking)
  65. @property
  66. def has_blocking(self) -> bool:
  67. return self.blocking_count > 0
  68. @property
  69. def severity_counts(self) -> Dict[str, int]:
  70. counts = {level: 0 for level in ("critical", "high", "medium", "low")}
  71. for issue in self.issues:
  72. severity = issue.severity if issue.severity in counts else "medium"
  73. counts[severity] += 1
  74. return counts
  75. @property
  76. def categories(self) -> List[str]:
  77. return sorted(set(i.category for i in self.issues))
  78. @property
  79. def critical_issues(self) -> List[str]:
  80. return [
  81. issue.description
  82. for issue in self.issues
  83. if issue.severity == "critical" and issue.description
  84. ]
  85. def _build_dimension_scores(self) -> Dict[str, float]:
  86. scores = {category: 100.0 for category in SCORE_CATEGORIES}
  87. for issue in self.issues:
  88. category = issue.category if issue.category in scores else "other"
  89. scores[category] = _clamp_score(scores[category] - _issue_penalty(issue))
  90. return scores
  91. def _build_notes(self, categories: List[str]) -> str:
  92. parts: List[str] = []
  93. if self.summary:
  94. parts.append(self.summary)
  95. parts.append(f"issues={self.issues_count}")
  96. parts.append(f"blocking={self.blocking_count}")
  97. if categories:
  98. parts.append("categories=" + ",".join(categories))
  99. return " | ".join(parts)
  100. def _calculate_overall_score(self) -> float:
  101. score = 100.0
  102. for issue in self.issues:
  103. score -= _issue_penalty(issue)
  104. return _clamp_score(score)
  105. def to_dict(self) -> Dict[str, Any]:
  106. return {
  107. "chapter": self.chapter,
  108. "issues": [i.to_dict() for i in self.issues],
  109. "issues_count": self.issues_count,
  110. "blocking_count": self.blocking_count,
  111. "has_blocking": self.has_blocking,
  112. "summary": self.summary,
  113. }
  114. def to_metrics_dict(self, report_file: str = "") -> Dict[str, Any]:
  115. categories = self.categories
  116. severity_counts = self.severity_counts
  117. return {
  118. "chapter": self.chapter,
  119. "start_chapter": self.chapter,
  120. "end_chapter": self.chapter,
  121. "overall_score": self._calculate_overall_score(),
  122. "dimension_scores": self._build_dimension_scores(),
  123. "severity_counts": severity_counts,
  124. "critical_issues": self.critical_issues,
  125. "report_file": report_file,
  126. "notes": self._build_notes(categories),
  127. "issues_count": self.issues_count,
  128. "blocking_count": self.blocking_count,
  129. "categories": categories,
  130. "timestamp": datetime.now().isoformat(timespec="seconds"),
  131. }
  132. def parse_review_output(chapter: int, raw: Dict[str, Any]) -> ReviewResult:
  133. issues = []
  134. for item in raw.get("issues", []):
  135. if not isinstance(item, dict):
  136. continue
  137. issues.append(ReviewIssue(
  138. severity=str(item.get("severity", "medium")),
  139. category=str(item.get("category", "other")),
  140. location=str(item.get("location", "")),
  141. description=str(item.get("description", "")),
  142. evidence=str(item.get("evidence", "")),
  143. fix_hint=str(item.get("fix_hint", "")),
  144. blocking=item.get("blocking"),
  145. ))
  146. return ReviewResult(
  147. chapter=chapter,
  148. issues=issues,
  149. summary=str(raw.get("summary", "")),
  150. )