chore: 문서를 작성할 때 한국어의 표현 작성 스킬 추가 및 1인칭 관점의 글 작성 검증 테스트 추가
This commit is contained in:
+167
-26
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user