chore: 문서를 작성할 때 한국어의 표현 작성 스킬 추가 및 1인칭 관점의 글 작성 검증 테스트 추가

This commit is contained in:
DongHyeonka
2026-07-29 16:48:03 +09:00
parent c39406bbdd
commit 41501b5d06
520 changed files with 95494 additions and 2231 deletions
+676
View File
@@ -0,0 +1,676 @@
from __future__ import annotations
import math
import re
from dataclasses import asdict, dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Iterable
class ValidationError(ValueError):
"""Raised when a user-supplied contract is invalid."""
class DocumentType(str, Enum):
TECHNICAL_BLOG = "technical_blog"
TUTORIAL = "tutorial"
HOW_TO = "how_to"
EXPLANATION = "explanation"
REFERENCE = "reference"
TROUBLESHOOTING = "troubleshooting"
DESIGN_DOC = "design_doc"
@classmethod
def values(cls) -> list[str]:
return [member.value for member in cls]
class Severity(str, Enum):
BLOCKER = "blocker"
ERROR = "error"
WARNING = "warning"
INFO = "info"
REVIEW_DIMENSIONS: tuple[str, ...] = (
"reader_goal_alignment",
"information_architecture",
"logical_flow",
"decision_rationale",
"source_usefulness",
"reader_facing_prose",
"cognitive_load",
"evidence_traceability",
"example_verifiability",
"scannability",
"operational_safety",
"completeness_and_limits",
)
REVIEW_SEVERITIES = frozenset(member.value for member in Severity)
@dataclass(slots=True)
class Audience:
roles: list[str]
prior_knowledge: list[str] = field(default_factory=list)
needs: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Audience":
roles = _string_list(data.get("roles"), "audience.roles", required=True)
return cls(
roles=roles,
prior_knowledge=_string_list(data.get("prior_knowledge", []), "audience.prior_knowledge"),
needs=_string_list(data.get("needs", []), "audience.needs"),
)
@dataclass(slots=True)
class Constraints:
target_words: int = 1600
tone: str = "professional and direct"
version_context: str = ""
max_heading_depth: int = 3
require_citations: bool = True
allow_external_knowledge: bool = False
citation_style: str = "hidden"
date_policy: str = "only_when_material"
style_profile: str = "auto"
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Constraints":
if data is None:
data = {}
if not isinstance(data, dict):
raise ValidationError("constraints must be an object")
target_words = _integer(data.get("target_words", 1600), "constraints.target_words")
max_heading_depth = _integer(data.get("max_heading_depth", 3), "constraints.max_heading_depth")
if target_words < 200 or target_words > 30000:
raise ValidationError("constraints.target_words must be between 200 and 30000")
if max_heading_depth < 2 or max_heading_depth > 6:
raise ValidationError("constraints.max_heading_depth must be between 2 and 6")
citation_style = str(data.get("citation_style", "hidden")).strip().lower()
if citation_style not in {"hidden", "footnote", "inline_link", "source_id"}:
raise ValidationError(
"constraints.citation_style must be one of: hidden, footnote, inline_link, source_id"
)
date_policy = str(data.get("date_policy", "only_when_material")).strip().lower()
if date_policy not in {"only_when_material", "always", "never"}:
raise ValidationError(
"constraints.date_policy must be one of: only_when_material, always, never"
)
return cls(
target_words=target_words,
tone=_nonempty_string(data.get("tone", "professional and direct"), "constraints.tone"),
version_context=str(data.get("version_context", "")).strip(),
max_heading_depth=max_heading_depth,
require_citations=_boolean(data.get("require_citations", True), "constraints.require_citations"),
allow_external_knowledge=_boolean(
data.get("allow_external_knowledge", False),
"constraints.allow_external_knowledge",
),
citation_style=citation_style,
date_policy=date_policy,
style_profile=str(data.get("style_profile", "auto")).strip() or "auto",
)
@dataclass(slots=True)
class Brief:
title: str
document_type: DocumentType
language: str
audience: Audience
reader_goal: str
core_message: str
scope: list[str]
non_scope: list[str]
prerequisites: list[str]
required_topics: list[str]
constraints: Constraints = field(default_factory=Constraints)
forbidden_claims: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Brief":
if not isinstance(data, dict):
raise ValidationError("brief must be a JSON object")
raw_type = _nonempty_string(data.get("document_type"), "document_type")
try:
document_type = DocumentType(raw_type)
except ValueError as exc:
raise ValidationError(
f"document_type must be one of: {', '.join(DocumentType.values())}"
) from exc
return cls(
title=_nonempty_string(data.get("title"), "title"),
document_type=document_type,
language=_nonempty_string(data.get("language", "ko-KR"), "language"),
audience=Audience.from_dict(_mapping(data.get("audience"), "audience")),
reader_goal=_nonempty_string(data.get("reader_goal"), "reader_goal"),
core_message=_nonempty_string(data.get("core_message"), "core_message"),
scope=_string_list(data.get("scope"), "scope", required=True),
non_scope=_string_list(data.get("non_scope", []), "non_scope"),
prerequisites=_string_list(data.get("prerequisites", []), "prerequisites"),
required_topics=_string_list(data.get("required_topics", []), "required_topics"),
constraints=Constraints.from_dict(data.get("constraints")),
forbidden_claims=_string_list(data.get("forbidden_claims", []), "forbidden_claims"),
metadata=_mapping(data.get("metadata", {}), "metadata"),
)
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["document_type"] = self.document_type.value
return data
@property
def is_korean(self) -> bool:
return self.language.lower().startswith("ko")
@dataclass(slots=True)
class Source:
id: str
title: str
url: str
publisher: str = ""
accessed: str = ""
facts: list[str] = field(default_factory=list)
notes: str = ""
source_type: str = "external"
status: str = ""
path: str = ""
heading: str = ""
line_start: int | None = None
line_end: int | None = None
claim_ids: list[str] = field(default_factory=list)
decision_ids: list[str] = field(default_factory=list)
priority: float = 0.0
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Source":
source_id = _nonempty_string(data.get("id"), "source.id")
if not re.fullmatch(r"[A-Za-z0-9_-]+", source_id):
raise ValidationError(f"source id contains unsupported characters: {source_id}")
line_start = _optional_integer(data.get("line_start"), f"source[{source_id}].line_start")
line_end = _optional_integer(data.get("line_end"), f"source[{source_id}].line_end")
if line_start is not None and line_start < 1:
raise ValidationError(f"source[{source_id}].line_start must be positive")
if line_end is not None and line_end < 1:
raise ValidationError(f"source[{source_id}].line_end must be positive")
if line_start is not None and line_end is not None and line_end < line_start:
raise ValidationError(f"source[{source_id}].line_end must be >= line_start")
return cls(
id=source_id,
title=_nonempty_string(data.get("title"), f"source[{source_id}].title"),
url=_nonempty_string(data.get("url"), f"source[{source_id}].url"),
publisher=str(data.get("publisher", "")).strip(),
accessed=str(data.get("accessed", "")).strip(),
facts=_string_list(data.get("facts", []), f"source[{source_id}].facts"),
notes=str(data.get("notes", "")).strip(),
source_type=str(data.get("source_type", "external")).strip() or "external",
status=str(data.get("status", "")).strip(),
path=str(data.get("path", "")).strip(),
heading=str(data.get("heading", "")).strip(),
line_start=line_start,
line_end=line_end,
claim_ids=_string_list(data.get("claim_ids", []), f"source[{source_id}].claim_ids"),
decision_ids=_string_list(data.get("decision_ids", []), f"source[{source_id}].decision_ids"),
priority=_number(data.get("priority", 0.0), f"source[{source_id}].priority"),
)
@dataclass(slots=True)
class SourcePack:
sources: list[Source] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "SourcePack":
if data is None:
data = {"sources": []}
if not isinstance(data, dict):
raise ValidationError("source pack must be a JSON object")
raw_sources = data.get("sources", [])
if not isinstance(raw_sources, list):
raise ValidationError("sources must be an array")
sources = [Source.from_dict(_mapping(item, "source")) for item in raw_sources]
ids = [source.id for source in sources]
duplicates = sorted({source_id for source_id in ids if ids.count(source_id) > 1})
if duplicates:
raise ValidationError(f"duplicate source ids: {', '.join(duplicates)}")
return cls(sources=sources)
def to_dict(self) -> dict[str, Any]:
return {"sources": [asdict(source) for source in self.sources]}
@property
def ids(self) -> set[str]:
return {source.id for source in self.sources}
@dataclass(slots=True)
class OutlineSection:
id: str
intent: str
title: str
reader_question: str
purpose: str
must_include: list[str] = field(default_factory=list)
evidence_ids: list[str] = field(default_factory=list)
decision_requirements: list[str] = field(default_factory=list)
transition_to_next: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "OutlineSection":
return cls(
id=_nonempty_string(data.get("id"), "outline.section.id"),
intent=_nonempty_string(data.get("intent"), "outline.section.intent"),
title=_nonempty_string(data.get("title"), "outline.section.title"),
reader_question=_nonempty_string(data.get("reader_question"), "outline.section.reader_question"),
purpose=_nonempty_string(data.get("purpose"), "outline.section.purpose"),
must_include=_string_list(data.get("must_include", []), "outline.section.must_include"),
evidence_ids=_string_list(data.get("evidence_ids", []), "outline.section.evidence_ids"),
decision_requirements=_string_list(
data.get("decision_requirements", []), "outline.section.decision_requirements"
),
transition_to_next=str(data.get("transition_to_next", "")).strip(),
)
@dataclass(slots=True)
class Outline:
title: str
document_type: DocumentType
sections: list[OutlineSection]
planning_notes: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Outline":
raw_type = _nonempty_string(data.get("document_type"), "outline.document_type")
try:
document_type = DocumentType(raw_type)
except ValueError as exc:
raise ValidationError(f"invalid outline document_type: {raw_type}") from exc
raw_sections = data.get("sections")
if not isinstance(raw_sections, list) or not raw_sections:
raise ValidationError("outline.sections must be a non-empty array")
sections = [OutlineSection.from_dict(_mapping(item, "outline.section")) for item in raw_sections]
return cls(
title=_nonempty_string(data.get("title"), "outline.title"),
document_type=document_type,
sections=sections,
planning_notes=_string_list(data.get("planning_notes", []), "outline.planning_notes"),
)
def to_dict(self) -> dict[str, Any]:
return {
"title": self.title,
"document_type": self.document_type.value,
"sections": [asdict(section) for section in self.sections],
"planning_notes": self.planning_notes,
}
@dataclass(slots=True)
class LintIssue:
code: str
severity: Severity
message: str
line: int | None = None
section: str = ""
suggestion: str = ""
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["severity"] = self.severity.value
return data
@dataclass(slots=True)
class LintReport:
score: float
word_count: int
issues: list[LintIssue]
metrics: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"score": self.score,
"word_count": self.word_count,
"issues": [issue.to_dict() for issue in self.issues],
"metrics": self.metrics,
}
def count(self, severity: Severity) -> int:
return sum(issue.severity == severity for issue in self.issues)
@dataclass(slots=True)
class ReviewIssue:
section: str
problem: str
why_it_matters: str
fix: str
severity: str = "error"
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ReviewIssue":
severity = _nonempty_string(data.get("severity"), "review.issue.severity").lower()
if severity not in REVIEW_SEVERITIES:
raise ValidationError(
"review.issue.severity must be one of: " + ", ".join(sorted(REVIEW_SEVERITIES))
)
return cls(
section=str(data.get("section", "")).strip(),
problem=_nonempty_string(data.get("problem"), "review.issue.problem"),
why_it_matters=_nonempty_string(
data.get("why_it_matters"), "review.issue.why_it_matters"
),
fix=_nonempty_string(data.get("fix"), "review.issue.fix"),
severity=severity,
)
@dataclass(slots=True)
class ModelReview:
role: str
provider: str
score: float
dimension_scores: dict[str, float]
issues: list[ReviewIssue]
strengths: list[str]
questions: list[str]
raw_response: str = ""
@classmethod
def from_dict(cls, data: dict[str, Any], *, role: str, provider: str, raw_response: str = "") -> "ModelReview":
if not isinstance(data, dict):
raise ValidationError("review must be a JSON object")
expected_top_level = {"score", "dimension_scores", "issues", "strengths", "questions"}
missing = sorted(expected_top_level - set(data))
unknown = sorted(set(data) - expected_top_level)
if missing:
raise ValidationError(f"review is missing required fields: {', '.join(missing)}")
if unknown:
raise ValidationError(f"review contains unsupported fields: {', '.join(unknown)}")
score = _number(data.get("score"), "review.score")
if score < 0 or score > 100:
raise ValidationError("review.score must be between 0 and 100")
raw_dimensions = _mapping(data.get("dimension_scores"), "review.dimension_scores")
missing_dimensions = sorted(set(REVIEW_DIMENSIONS) - set(raw_dimensions))
unknown_dimensions = sorted(set(raw_dimensions) - set(REVIEW_DIMENSIONS))
if missing_dimensions:
raise ValidationError(
"review.dimension_scores is missing: " + ", ".join(missing_dimensions)
)
if unknown_dimensions:
raise ValidationError(
"review.dimension_scores contains unsupported dimensions: "
+ ", ".join(unknown_dimensions)
)
dimensions: dict[str, float] = {}
for key in REVIEW_DIMENSIONS:
numeric = _number(raw_dimensions[key], f"review.dimension_scores.{key}")
if numeric < 0 or numeric > 100:
raise ValidationError(f"review dimension {key} must be between 0 and 100")
dimensions[key] = numeric
raw_issues = data.get("issues")
if not isinstance(raw_issues, list):
raise ValidationError("review.issues must be an array")
return cls(
role=role,
provider=provider,
score=score,
dimension_scores=dimensions,
issues=[ReviewIssue.from_dict(_mapping(item, "review.issue")) for item in raw_issues],
strengths=_string_list(data.get("strengths", []), "review.strengths"),
questions=_string_list(data.get("questions", []), "review.questions"),
raw_response=raw_response,
)
@property
def blocker_count(self) -> int:
return sum(issue.severity == "blocker" for issue in self.issues)
def to_dict(self) -> dict[str, Any]:
return {
"role": self.role,
"provider": self.provider,
"score": self.score,
"dimension_scores": self.dimension_scores,
"issues": [asdict(issue) for issue in self.issues],
"strengths": self.strengths,
"questions": self.questions,
"raw_response": self.raw_response,
}
@dataclass(slots=True)
class ProviderSpec:
provider: str
model: str = ""
timeout_seconds: int = 300
options: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict[str, Any] | str | None, *, default: str = "mock") -> "ProviderSpec":
if data is None:
return cls(provider=default)
if isinstance(data, str):
return cls(provider=data)
if not isinstance(data, dict):
raise ValidationError("provider configuration must be a string or object")
timeout = _integer(data.get("timeout_seconds", 300), "provider.timeout_seconds")
if timeout < 1:
raise ValidationError("provider timeout_seconds must be positive")
return cls(
provider=_nonempty_string(data.get("provider", default), "provider.provider"),
model=str(data.get("model", "")).strip(),
timeout_seconds=timeout,
options=_mapping(data.get("options", {}), "provider.options"),
)
@dataclass(slots=True)
class ReviewerSpec:
role: str
provider: ProviderSpec
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ReviewerSpec":
return cls(
role=_nonempty_string(data.get("role"), "reviewer.role"),
provider=ProviderSpec.from_dict(data),
)
@dataclass(slots=True)
class QualityGate:
minimum_score: float = 82.0
max_blockers: int = 0
max_errors: int = 2
max_revisions: int = 2
deterministic_weight: float = 0.4
model_weight: float = 0.6
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "QualityGate":
if data is None:
data = {}
if not isinstance(data, dict):
raise ValidationError("quality_gate must be an object")
minimum_score = _number(data.get("minimum_score", 82.0), "quality_gate.minimum_score")
max_blockers = _integer(data.get("max_blockers", 0), "quality_gate.max_blockers")
max_errors = _integer(data.get("max_errors", 2), "quality_gate.max_errors")
max_revisions = _integer(data.get("max_revisions", 2), "quality_gate.max_revisions")
deterministic_weight = _number(
data.get("deterministic_weight", 0.4), "quality_gate.deterministic_weight"
)
model_weight = _number(data.get("model_weight", 0.6), "quality_gate.model_weight")
if minimum_score < 0 or minimum_score > 100:
raise ValidationError("quality_gate.minimum_score must be between 0 and 100")
if min(max_blockers, max_errors, max_revisions) < 0:
raise ValidationError("quality_gate count limits must be non-negative")
if not 0 <= deterministic_weight <= 1 or not 0 <= model_weight <= 1:
raise ValidationError("quality_gate weights must be between 0 and 1")
if abs((deterministic_weight + model_weight) - 1.0) > 1e-6:
raise ValidationError("quality_gate weights must sum to 1.0")
return cls(
minimum_score=minimum_score,
max_blockers=max_blockers,
max_errors=max_errors,
max_revisions=max_revisions,
deterministic_weight=deterministic_weight,
model_weight=model_weight,
)
@dataclass(slots=True)
class PipelineConfig:
planner: ProviderSpec
writer: ProviderSpec
reviewers: list[ReviewerSpec]
reviser: ProviderSpec
quality_gate: QualityGate
fail_on_reviewer_error: bool = True
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PipelineConfig":
if not isinstance(data, dict):
raise ValidationError("pipeline configuration must be a JSON object")
missing_stages = [name for name in ("planner", "writer", "reviewers", "reviser") if name not in data]
if missing_stages:
raise ValidationError(
"pipeline configuration is missing required fields: " + ", ".join(missing_stages)
)
raw_reviewers = data.get("reviewers")
if not isinstance(raw_reviewers, list):
raise ValidationError("reviewers must be an array")
reviewers = [ReviewerSpec.from_dict(_mapping(item, "reviewer")) for item in raw_reviewers]
if not reviewers:
raise ValidationError("reviewers must contain at least one reviewer")
roles = [reviewer.role for reviewer in reviewers]
duplicate_roles = sorted({role for role in roles if roles.count(role) > 1})
if duplicate_roles:
raise ValidationError("duplicate reviewer roles: " + ", ".join(duplicate_roles))
return cls(
planner=ProviderSpec.from_dict(data.get("planner")),
writer=ProviderSpec.from_dict(data.get("writer")),
reviewers=reviewers,
reviser=ProviderSpec.from_dict(data.get("reviser")),
quality_gate=QualityGate.from_dict(data.get("quality_gate")),
fail_on_reviewer_error=_boolean(
data.get("fail_on_reviewer_error", True), "fail_on_reviewer_error"
),
)
def to_dict(self) -> dict[str, Any]:
return {
"planner": asdict(self.planner),
"writer": asdict(self.writer),
"reviewers": [
{"role": reviewer.role, **asdict(reviewer.provider)} for reviewer in self.reviewers
],
"reviser": asdict(self.reviser),
"quality_gate": asdict(self.quality_gate),
"fail_on_reviewer_error": self.fail_on_reviewer_error,
}
@dataclass(slots=True)
class RoundResult:
round_number: int
draft_path: Path
lint_report: LintReport
reviews: list[ModelReview]
composite_score: float
blocker_count: int
error_count: int
passed: bool
@dataclass(slots=True)
class RunResult:
output_dir: Path
final_path: Path
report_path: Path
manifest_path: Path
passed: bool
final_score: float
rounds: list[RoundResult]
warnings: list[str] = field(default_factory=list)
def _nonempty_string(value: Any, field_name: str) -> str:
if value is None:
raise ValidationError(f"{field_name} is required")
text = str(value).strip()
if not text:
raise ValidationError(f"{field_name} must not be empty")
return text
def _string_list(value: Any, field_name: str, *, required: bool = False) -> list[str]:
if value is None:
if required:
raise ValidationError(f"{field_name} is required")
return []
if not isinstance(value, list):
raise ValidationError(f"{field_name} must be an array of strings")
result = []
for item in value:
text = str(item).strip()
if text:
result.append(text)
if required and not result:
raise ValidationError(f"{field_name} must contain at least one item")
return result
def _mapping(value: Any, field_name: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValidationError(f"{field_name} must be an object")
return value
def _boolean(value: Any, field_name: str) -> bool:
if not isinstance(value, bool):
raise ValidationError(f"{field_name} must be a boolean")
return value
def _integer(value: Any, field_name: str) -> int:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValidationError(f"{field_name} must be an integer")
if isinstance(value, float) and (not math.isfinite(value) or not value.is_integer()):
raise ValidationError(f"{field_name} must be an integer")
return int(value)
def _optional_integer(value: Any, field_name: str) -> int | None:
if value is None or value == "":
return None
return _integer(value, field_name)
def _number(value: Any, field_name: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValidationError(f"{field_name} must be a finite number")
result = float(value)
if not math.isfinite(result):
raise ValidationError(f"{field_name} must be a finite number")
return result
def unique_nonempty(values: Iterable[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
text = value.strip()
if text and text not in seen:
seen.add(text)
result.append(text)
return result