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
+167 -26
View File
@@ -31,6 +31,44 @@ DANGEROUS_PATTERNS = (
r"\bDELETE\s+FROM\b",
)
META_LEAK_PATTERNS: tuple[tuple[str, str], ...] = (
(r"제공된\s+(?:근거|자료)(?:\s*팩)?", "Evidence-pack process language leaked into reader-facing prose."),
(r"확인\s*대상으로\s*제시", "Source-processing language leaked into reader-facing prose."),
(r"<\/?(?:BRIEF|SOURCE_PACK|OUTLINE|DETERMINISTIC_LINT|MODEL_REVIEWS)_JSON>", "Prompt tag leaked into the document."),
(r"\b(?:BRIEF|SOURCE_PACK|OUTLINE)_JSON\b", "Prompt artifact name leaked into the document."),
)
CANNED_META_PATTERNS: tuple[tuple[str, str], ...] = (
(r"\s*절은.{0,100}답한다", "Section-planning narration is visible to the reader."),
(r"This section answers", "Section-planning narration is visible to the reader."),
(r"독자의 목표인", "Prompt-derived audience narration is visible to the reader."),
(r"다룰 핵심 항목은", "Prompt-derived outline narration is visible to the reader."),
)
CHOICE_PATTERN = re.compile(
r"(?:의도적으로|선택(?:했|하였다|한다|했다|하기로)|채택(?:했|하였다|한다|했다)|"
r"허용(?:했|하였다|한다|했다)|유지(?:했|하였다|한다|했다)|제외(?:했|하였다|한다|했다)|"
r"금지(?:했|하였다|한다|했다)|도입(?:했|하였다|한다|했다)|사용하기로|"
r"\b(?:intentionally|chose|chosen|selected|adopted|allowed|kept|rejected|forbids?|decided to)\b)",
re.IGNORECASE,
)
RATIONALE_PATTERN = re.compile(
r"(?:이유|때문|목적|위해|하려|피하|줄이|막기|보장|제약|따라서|왜냐|"
r"because|so that|in order to|to avoid|to reduce|constraint|rationale|reason)",
re.IGNORECASE,
)
TRADEOFF_PATTERN = re.compile(
r"(?:대안|대신|반면|비용|수용|포기|가드레일|경계|금지|한계|"
r"alternative|instead|whereas|cost|accepted|guardrail|boundary|limit|trade-?off|rejected)",
re.IGNORECASE,
)
ORDINAL_PARAGRAPH_OPENING = re.compile(
r"^(?:첫\s*번째|두\s*번째|세\s*번째|네\s*번째|다섯\s*번째|여섯\s*번째|일곱\s*번째|"
r"첫째|둘째|셋째|넷째|다섯째|여섯째|일곱째)"
r"(?:\s+[^.!?\n]{1,28}?)?(?:은|는|이|가)\s",
re.IGNORECASE,
)
@dataclass(slots=True)
class ParsedHeading:
@@ -121,9 +159,48 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack
add("AUD003", Severity.INFO, "Non-scope is not visible near the start.",
suggestion="Mention exclusions that the audience could reasonably expect.")
for pattern, message in META_LEAK_PATTERNS:
for match in re.finditer(pattern, text, flags=re.IGNORECASE | re.DOTALL):
add("META001", Severity.ERROR, message, line=line_number(text, match.start()),
suggestion="Remove authoring/evidence-process language and write the supported point directly.")
for pattern, message in CANNED_META_PATTERNS:
for match in re.finditer(pattern, text, flags=re.IGNORECASE | re.DOTALL):
add("META002", Severity.WARNING, message, line=line_number(text, match.start()),
suggestion="Replace the planning sentence with the actual claim, situation, or transition.")
opening_contract_terms = (
"이 글의 독자는", "읽고 나면", "범위는", "비범위", "적용 맥락",
"the intended readers", "after reading", "scope:", "non-scope:", "version/date context",
)
opening_contract_count = sum(term in lead.casefold() for term in opening_contract_terms)
if brief.document_type == DocumentType.TECHNICAL_BLOG and opening_contract_count >= 3:
add("OPEN001", Severity.ERROR, "The opening reads like a prompt contract rather than a technical story.",
suggestion="Open with a concrete situation, observable problem, cost, or decision tension.")
# Paragraph and sentence focus.
prose = strip_code_blocks(text)
paragraphs = _paragraphs(prose)
formulaic_ordinal_openings = [
(paragraph, start_index)
for paragraph, start_index in paragraphs
if ORDINAL_PARAGRAPH_OPENING.search(paragraph)
]
if brief.is_korean and brief.document_type == DocumentType.TECHNICAL_BLOG:
for index in range(max(0, len(formulaic_ordinal_openings) - 2)):
cluster = formulaic_ordinal_openings[index:index + 3]
if cluster[-1][1] - cluster[0][1] > 2400:
continue
add(
"STYLE001",
Severity.WARNING,
"Three nearby paragraphs use formulaic ordinal openings that expose the outline as prose.",
line=line_number(prose, cluster[0][1]),
suggestion=(
"State the concrete actor, state, change, consequence, or decision directly. "
"If the items are truly ordered or parallel, use a list or meaningful subheadings."
),
)
break
long_paragraph_count = 0
crowded_paragraph_count = 0
long_sentence_count = 0
@@ -171,6 +248,29 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack
add("TYPE007", Severity.WARNING, "Reference document has no table-like lookup surface.",
suggestion="Use a table for fields, parameters, defaults, or errors when appropriate.")
# Choice rationale and decision completeness.
if brief.document_type in {DocumentType.TECHNICAL_BLOG, DocumentType.EXPLANATION, DocumentType.DESIGN_DOC}:
for index, (paragraph, start_index) in enumerate(paragraphs):
if not CHOICE_PATTERN.search(paragraph):
continue
next_paragraph = paragraphs[index + 1][0] if index + 1 < len(paragraphs) else ""
context = f"{paragraph}\n{next_paragraph}"
if not RATIONALE_PATTERN.search(context):
add("RAT001", Severity.ERROR,
"A technical choice is declared without explaining why it was made.",
line=line_number(prose, start_index),
suggestion="State the relevant constraint and the reason in the same or next paragraph; otherwise remove or qualify the intentional-choice claim.")
if not TRADEOFF_PATTERN.search(context):
add("RAT002", Severity.WARNING,
"A technical choice does not expose an alternative, accepted cost, or guardrail.",
line=line_number(prose, start_index),
suggestion="Name the realistic alternative and the boundary or cost accepted with the choice.")
for section in outline.sections:
if section.decision_requirements and brief.constraints.require_citations and sources.sources and not section.evidence_ids:
add("RAT003", Severity.ERROR, f"Decision section has no allocated evidence: {section.title}",
section=section.title, suggestion="Retrieve a source that explicitly contains the decision rationale or record the evidence gap.")
# Evidence and claim hygiene.
known_marker_pattern = None
used_markers: set[str] = set()
@@ -178,35 +278,52 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack
alternatives = "|".join(re.escape(source_id) for source_id in sorted(sources.ids, key=len, reverse=True))
known_marker_pattern = re.compile(rf"\[({alternatives})\]")
used_markers = set(known_marker_pattern.findall(text))
source_like_pattern = re.compile(r"\[((?:SRC|S)[A-Za-z0-9_-]+)\]")
source_like_pattern = re.compile(r"\[((?:SRC|S|L)[A-Za-z0-9_-]+)\]")
unknown_markers = sorted(set(source_like_pattern.findall(text)) - sources.ids)
for marker in unknown_markers:
add("EVD001", Severity.ERROR, f"Unknown source marker: [{marker}]",
suggestion="Use an ID from the source pack or remove the unsupported claim.")
suggestion="Use a valid public citation form or remove the unsupported marker.")
if brief.constraints.require_citations and not sources.sources:
add("EVD002", Severity.ERROR, "Citations are required but the source pack is empty.")
if brief.constraints.require_citations and sources.sources and not (used_markers & sources.ids):
add("EVD003", Severity.ERROR, "No source-pack citation markers are used.",
suggestion="Attach [SOURCE_ID] to each source-backed claim.")
add("EVD002", Severity.ERROR, "Evidence is required but the source pack is empty.",
suggestion="Provide a source pack or collect evidence from a local documentation corpus.")
uncited_numeric = 0
if brief.constraints.require_citations and sources.sources:
for paragraph, start_index in paragraphs:
if uncited_numeric >= 4:
break
if not re.search(r"\d", paragraph):
continue
if known_marker_pattern and known_marker_pattern.search(paragraph):
continue
if re.search(r"예시|가정|illustrative|example|단계|step|명령", paragraph.casefold()):
continue
add("EVD004", Severity.WARNING, "A numeric or version-like claim has no source marker.",
line=line_number(prose, start_index), suggestion="Cite it, qualify it, or mark it as illustrative.")
uncited_numeric += 1
citation_style = brief.constraints.citation_style
if citation_style == "source_id":
if brief.constraints.require_citations and sources.sources and not (used_markers & sources.ids):
add("EVD003", Severity.ERROR, "No source-pack citation markers are used.",
suggestion="Attach [SOURCE_ID] to each source-backed claim.")
uncited_numeric = 0
if brief.constraints.require_citations and sources.sources:
for paragraph, start_index in paragraphs:
if uncited_numeric >= 4:
break
if not re.search(r"\d", paragraph):
continue
if known_marker_pattern and known_marker_pattern.search(paragraph):
continue
if re.search(r"예시|가정|illustrative|example|단계|step|명령", paragraph.casefold()):
continue
add("EVD004", Severity.WARNING, "A numeric or version-like claim has no source marker.",
line=line_number(prose, start_index), suggestion="Cite it, qualify it, or mark it as illustrative.")
uncited_numeric += 1
unused_sources = sorted(sources.ids - used_markers)
if unused_sources:
add("EVD005", Severity.INFO, f"Source-pack entries not cited: {', '.join(unused_sources)}")
else:
for marker in sorted(used_markers):
match = re.search(rf"\[{re.escape(marker)}\]", text)
add("EVD007", Severity.ERROR, f"Internal source marker leaked into reader-facing prose: [{marker}]",
line=line_number(text, match.start()) if match else None,
suggestion="Remove the marker. Keep claim provenance in the generated evidence-map sidecar.")
unused_sources = sorted(sources.ids - used_markers)
if unused_sources:
add("EVD005", Severity.INFO, f"Source-pack entries not cited: {', '.join(unused_sources)}")
if citation_style == "hidden":
for source in sources.sources:
if source.path and source.path in text:
match = re.search(re.escape(source.path), text)
add("META004", Severity.ERROR, f"Internal repository path leaked into the document: {source.path}",
line=line_number(text, match.start()) if match else None,
suggestion="Describe the supported technical point; keep the path in provenance.md.")
for forbidden in brief.forbidden_claims:
if forbidden.casefold() in lowered:
@@ -230,9 +347,30 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack
f"Destructive command lacks nearby safety controls ({', '.join(missing)}): {match.group(0)}",
line=line_number(text, match.start()),
suggestion="Add impact warning, checkpoint/recovery path, expected effect, and verification.")
if brief.constraints.version_context and brief.constraints.version_context.casefold() not in lowered:
add("VER001", Severity.WARNING, "Configured version/date context is not stated in the document.",
suggestion=f"State the applicable context: {brief.constraints.version_context}")
if (
brief.constraints.date_policy == "always"
and brief.constraints.version_context
and brief.constraints.version_context.casefold() not in lowered
):
add("VER001", Severity.WARNING, "Required material version/date context is not stated in the document.",
suggestion=f"State the applicable context naturally: {brief.constraints.version_context}")
date_boilerplate = re.compile(
r"(?:예시|문서|이\s*글|자료).{0,40}\b20\d{2}-\d{2}-\d{2}\b.{0,20}기준|"
r"(?:example|document|article).{0,40}\b20\d{2}-\d{2}-\d{2}\b.{0,25}(?:as of|checked)",
re.IGNORECASE | re.DOTALL,
)
for match in date_boilerplate.finditer(text):
add("DATE001", Severity.ERROR, "Access-date or example-date boilerplate leaked into the article.",
line=line_number(text, match.start()),
suggestion="Remove the date unless it materially changes behavior, compatibility, or reproducibility.")
if brief.constraints.date_policy != "always":
for source in sources.sources:
if source.accessed and source.accessed in text:
match = re.search(re.escape(source.accessed), text)
add("DATE002", Severity.WARNING, f"A source access date appears in reader-facing prose: {source.accessed}",
line=line_number(text, match.start()) if match else None,
suggestion="Keep access dates in provenance metadata, not in the article.")
total_words = word_count(text)
target = brief.constraints.target_words
@@ -256,7 +394,10 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack
"h2_count": sum(heading.level == 2 for heading in headings),
"source_count": len(sources.sources),
"cited_source_count": len(used_markers & sources.ids),
"citation_style": brief.constraints.citation_style,
"decision_section_count": sum(bool(section.decision_requirements) for section in outline.sections),
"numbered_steps": numbered_steps,
"formulaic_ordinal_opening_count": len(formulaic_ordinal_openings),
"has_verification": has_verification,
"has_tradeoffs": has_tradeoffs,
"severity_counts": dict(severity_counts),