chore: 문서를 작성할 때 한국어의 표현 작성 스킬 추가 및 1인칭 관점의 글 작성 검증 테스트 추가
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from claridoc.models import (
|
||||
Brief,
|
||||
DocumentType,
|
||||
LintIssue,
|
||||
LintReport,
|
||||
Outline,
|
||||
Severity,
|
||||
SourcePack,
|
||||
)
|
||||
from claridoc.utils import line_number, normalize_heading, strip_code_blocks, word_count
|
||||
|
||||
|
||||
GENERIC_HEADINGS = {
|
||||
"introduction", "intro", "overview", "details", "misc", "other", "summary",
|
||||
"소개", "개요", "내용", "상세", "기타", "요약",
|
||||
}
|
||||
|
||||
DANGEROUS_PATTERNS = (
|
||||
r"\brm\s+-rf\b",
|
||||
r"\bDROP\s+(?:TABLE|DATABASE)\b",
|
||||
r"\bkubectl\s+delete\b",
|
||||
r"\bterraform\s+destroy\b",
|
||||
r"\bgit\s+reset\s+--hard\b",
|
||||
r"\btruncate\s+table\b",
|
||||
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:
|
||||
line: int
|
||||
level: int
|
||||
title: str
|
||||
index: int
|
||||
|
||||
|
||||
def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack) -> LintReport:
|
||||
issues: list[LintIssue] = []
|
||||
headings, fence_openings, fence_balanced = _parse_markdown(text)
|
||||
|
||||
def add(code: str, severity: Severity, message: str, *, line: int | None = None,
|
||||
section: str = "", suggestion: str = "") -> None:
|
||||
issues.append(LintIssue(code, severity, message, line, section, suggestion))
|
||||
|
||||
# Markdown integrity and headings.
|
||||
if not fence_balanced:
|
||||
add("MD001", Severity.BLOCKER, "Code fence is not closed.", suggestion="Close every fenced code block.")
|
||||
for line_no, language in fence_openings:
|
||||
if not language:
|
||||
add("MD002", Severity.WARNING, "Code fence has no language tag.", line=line_no,
|
||||
suggestion="Add a language such as ```python, ```bash, or ```text.")
|
||||
|
||||
h1s = [heading for heading in headings if heading.level == 1]
|
||||
if len(h1s) != 1:
|
||||
add("STR001", Severity.ERROR, f"Expected exactly one H1, found {len(h1s)}.",
|
||||
suggestion=f"Use one H1 with the title: {brief.title}")
|
||||
elif normalize_heading(h1s[0].title) != normalize_heading(brief.title):
|
||||
add("STR002", Severity.ERROR, "H1 does not match the brief title.", line=h1s[0].line,
|
||||
suggestion=f"Set the H1 to: {brief.title}")
|
||||
|
||||
previous_level = 0
|
||||
for heading in headings:
|
||||
if heading.level > brief.constraints.max_heading_depth:
|
||||
add("STR003", Severity.WARNING,
|
||||
f"Heading depth {heading.level} exceeds configured maximum {brief.constraints.max_heading_depth}.",
|
||||
line=heading.line, section=heading.title)
|
||||
if previous_level and heading.level > previous_level + 1:
|
||||
add("STR004", Severity.ERROR, f"Heading level jumps from H{previous_level} to H{heading.level}.",
|
||||
line=heading.line, section=heading.title, suggestion="Do not skip heading levels.")
|
||||
previous_level = heading.level
|
||||
|
||||
normalized_titles = [normalize_heading(heading.title) for heading in headings]
|
||||
duplicate_titles = {title for title, count in Counter(normalized_titles).items() if title and count > 1}
|
||||
for duplicate in duplicate_titles:
|
||||
first = next(heading for heading in headings if normalize_heading(heading.title) == duplicate)
|
||||
add("STR005", Severity.WARNING, f"Heading is duplicated: {first.title}", line=first.line,
|
||||
suggestion="Use unique headings that expose each section's distinct job.")
|
||||
for heading in headings:
|
||||
if heading.title.casefold().strip(" :") in GENERIC_HEADINGS:
|
||||
add("STR006", Severity.WARNING, f"Heading is too generic: {heading.title}", line=heading.line,
|
||||
suggestion="Name the reader question or conclusion handled by the section.")
|
||||
|
||||
h2_positions: dict[str, list[int]] = {}
|
||||
for position, heading in enumerate(headings):
|
||||
if heading.level == 2:
|
||||
h2_positions.setdefault(normalize_heading(heading.title), []).append(position)
|
||||
expected_positions: list[int] = []
|
||||
for section in outline.sections:
|
||||
key = normalize_heading(section.title)
|
||||
if key not in h2_positions:
|
||||
add("STR007", Severity.ERROR, f"Required H2 is missing: {section.title}", section=section.title,
|
||||
suggestion="Use every outline H2 exactly once.")
|
||||
else:
|
||||
positions = h2_positions[key]
|
||||
expected_positions.append(positions[0])
|
||||
if len(positions) > 1:
|
||||
add("STR009", Severity.ERROR, f"Required H2 appears {len(positions)} times: {section.title}",
|
||||
section=section.title, suggestion="Use every outline H2 exactly once.")
|
||||
if expected_positions and expected_positions != sorted(expected_positions):
|
||||
add("STR008", Severity.ERROR, "Required H2 sections are out of contract order.",
|
||||
suggestion="Restore the H2 order from outline.json.")
|
||||
|
||||
# Reader orientation.
|
||||
lead = strip_code_blocks(text)[:1800]
|
||||
lead_words = _content_words(lead)
|
||||
goal_words = _content_words(brief.reader_goal)
|
||||
message_words = _content_words(brief.core_message)
|
||||
if goal_words and not goal_words.intersection(lead_words):
|
||||
add("AUD001", Severity.WARNING, "The opening does not visibly connect to the reader goal.",
|
||||
suggestion="State what the reader will be able to do or decide in the first section.")
|
||||
if message_words and not message_words.intersection(lead_words):
|
||||
add("AUD002", Severity.WARNING, "The core message is not visible near the start.",
|
||||
suggestion="Front-load the answer before expanding the reasoning.")
|
||||
if brief.non_scope and not _contains_any(lead, brief.non_scope):
|
||||
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
|
||||
for paragraph, start_index in paragraphs:
|
||||
if len(paragraph) > 900 and long_paragraph_count < 5:
|
||||
add("READ001", Severity.WARNING, f"Paragraph is long ({len(paragraph)} characters).",
|
||||
line=line_number(prose, start_index), suggestion="Split at the change of idea or reasoning step.")
|
||||
long_paragraph_count += 1
|
||||
sentences = [item.strip() for item in re.split(r"(?<=[.!?。!?])\s+|(?<=다\.)\s*", paragraph) if item.strip()]
|
||||
if len(sentences) > 6 and crowded_paragraph_count < 5:
|
||||
add("READ002", Severity.WARNING, f"Paragraph contains {len(sentences)} sentences.",
|
||||
line=line_number(prose, start_index), suggestion="Keep one central point per paragraph.")
|
||||
crowded_paragraph_count += 1
|
||||
for sentence in sentences:
|
||||
if word_count(sentence) > 55 and long_sentence_count < 5:
|
||||
add("READ003", Severity.WARNING, "Sentence is unusually long.",
|
||||
line=line_number(prose, start_index), suggestion="Split the sentence at a logical dependency.")
|
||||
long_sentence_count += 1
|
||||
break
|
||||
|
||||
# Type-specific contract checks.
|
||||
lowered = prose.casefold()
|
||||
numbered_steps = bool(re.search(r"(?m)^\s*\d+[.)]\s+\S", prose))
|
||||
has_code_or_example = "```" in text or bool(re.search(r"예시|example|worked example|사례", lowered))
|
||||
has_verification = bool(re.search(r"검증|확인|성공 기준|expected (?:result|output)|verify|validation", lowered))
|
||||
has_prerequisites = bool(re.search(r"사전|준비|prerequisite|before you begin|requirements", lowered))
|
||||
has_tradeoffs = bool(re.search(r"트레이드오프|trade-?off|대안|alternative|한계|limit|실패 조건", lowered))
|
||||
has_rollback = bool(re.search(r"롤백|원복|복구|rollback|revert|recovery", lowered))
|
||||
|
||||
if brief.document_type in {DocumentType.TUTORIAL, DocumentType.HOW_TO, DocumentType.TROUBLESHOOTING}:
|
||||
if not numbered_steps:
|
||||
add("TYPE001", Severity.ERROR, "Procedural document has no numbered steps.",
|
||||
suggestion="Use ordered steps with one primary action per step.")
|
||||
if not has_prerequisites:
|
||||
add("TYPE002", Severity.ERROR, "Procedural document does not state prerequisites.")
|
||||
if not has_verification:
|
||||
add("TYPE003", Severity.ERROR, "Procedural document lacks an observable verification step.")
|
||||
if brief.document_type in {DocumentType.HOW_TO, DocumentType.TROUBLESHOOTING, DocumentType.DESIGN_DOC} and not has_rollback:
|
||||
add("TYPE004", Severity.ERROR, "Document type requires rollback or recovery guidance.")
|
||||
if brief.document_type in {DocumentType.TECHNICAL_BLOG, DocumentType.TUTORIAL, DocumentType.EXPLANATION} and not has_code_or_example:
|
||||
add("TYPE005", Severity.ERROR, "Document lacks a concrete or worked example.")
|
||||
if brief.document_type in {DocumentType.TECHNICAL_BLOG, DocumentType.EXPLANATION, DocumentType.DESIGN_DOC} and not has_tradeoffs:
|
||||
add("TYPE006", Severity.ERROR, "Document does not discuss alternatives, limits, or trade-offs.")
|
||||
if brief.document_type == DocumentType.REFERENCE and "|" not in text:
|
||||
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()
|
||||
if sources.ids:
|
||||
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|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 a valid public citation form or remove the unsupported marker.")
|
||||
|
||||
if brief.constraints.require_citations and not sources.sources:
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
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:
|
||||
add("EVD006", Severity.BLOCKER, f"Forbidden claim appears in the document: {forbidden}",
|
||||
suggestion="Remove the claim or change the brief deliberately.")
|
||||
|
||||
# Safety, unresolved placeholders, and version context.
|
||||
for match in re.finditer(r"\b(?:TODO|TBD|FIXME)\b|\{\{[^}]+\}\}", text, flags=re.IGNORECASE):
|
||||
add("FIN001", Severity.ERROR, f"Unresolved placeholder: {match.group(0)}", line=line_number(text, match.start()))
|
||||
for pattern in DANGEROUS_PATTERNS:
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
context = text[max(0, match.start() - 500): min(len(text), match.end() + 500)].casefold()
|
||||
requirements = {
|
||||
"impact warning": r"경고|주의|영향|위험|warning|caution|impact|risk",
|
||||
"checkpoint or recovery": r"백업|체크포인트|스냅샷|롤백|원복|복구|backup|checkpoint|snapshot|rollback|revert|recovery",
|
||||
"verification": r"검증|확인|예상 결과|성공 기준|verify|validation|expected (?:effect|result|output)|success criterion",
|
||||
}
|
||||
missing = [name for name, safety_pattern in requirements.items() if not re.search(safety_pattern, context)]
|
||||
if missing:
|
||||
add("SAFE001", Severity.BLOCKER,
|
||||
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.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
|
||||
if total_words < target * 0.45:
|
||||
add("LEN001", Severity.ERROR, f"Document is substantially under target ({total_words}/{target} words).")
|
||||
elif total_words < target * 0.65:
|
||||
add("LEN002", Severity.WARNING, f"Document is under target ({total_words}/{target} words).")
|
||||
elif total_words > target * 1.6:
|
||||
add("LEN003", Severity.WARNING, f"Document is substantially over target ({total_words}/{target} words).")
|
||||
|
||||
penalties = {
|
||||
Severity.BLOCKER: 25.0,
|
||||
Severity.ERROR: 8.0,
|
||||
Severity.WARNING: 2.5,
|
||||
Severity.INFO: 0.5,
|
||||
}
|
||||
score = max(0.0, round(100.0 - sum(penalties[issue.severity] for issue in issues), 1))
|
||||
severity_counts = Counter(issue.severity.value for issue in issues)
|
||||
metrics = {
|
||||
"heading_count": len(headings),
|
||||
"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),
|
||||
}
|
||||
return LintReport(score=score, word_count=total_words, issues=issues, metrics=metrics)
|
||||
|
||||
|
||||
def render_lint_markdown(report: LintReport) -> str:
|
||||
lines = [
|
||||
"# Deterministic lint report",
|
||||
"",
|
||||
f"- Score: **{report.score:.1f}/100**",
|
||||
f"- Word count: **{report.word_count}**",
|
||||
f"- Issues: **{len(report.issues)}**",
|
||||
"",
|
||||
]
|
||||
if not report.issues:
|
||||
lines.append("No issues found.\n")
|
||||
return "\n".join(lines)
|
||||
lines.extend(["| Severity | Code | Location | Finding | Suggested correction |", "|---|---|---|---|---|"])
|
||||
for issue in report.issues:
|
||||
location = f"line {issue.line}" if issue.line else (issue.section or "—")
|
||||
message = issue.message.replace("|", "\\|")
|
||||
suggestion = issue.suggestion.replace("|", "\\|") if issue.suggestion else "—"
|
||||
lines.append(f"| {issue.severity.value} | `{issue.code}` | {location} | {message} | {suggestion} |")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_markdown(text: str) -> tuple[list[ParsedHeading], list[tuple[int, str]], bool]:
|
||||
headings: list[ParsedHeading] = []
|
||||
openings: list[tuple[int, str]] = []
|
||||
in_fence = False
|
||||
offset = 0
|
||||
for line_no, raw_line in enumerate(text.splitlines(keepends=True), start=1):
|
||||
line = raw_line.rstrip("\r\n")
|
||||
fence = re.match(r"^\s*```\s*([^\s`]*)", line)
|
||||
if fence:
|
||||
if not in_fence:
|
||||
openings.append((line_no, fence.group(1).strip()))
|
||||
in_fence = not in_fence
|
||||
offset += len(raw_line)
|
||||
continue
|
||||
if not in_fence:
|
||||
match = re.match(r"^(#{1,6})\s+(.+?)\s*#*\s*$", line)
|
||||
if match:
|
||||
headings.append(ParsedHeading(line_no, len(match.group(1)), match.group(2).strip(), offset))
|
||||
offset += len(raw_line)
|
||||
return headings, openings, not in_fence
|
||||
|
||||
|
||||
def _paragraphs(text: str) -> list[tuple[str, int]]:
|
||||
result: list[tuple[str, int]] = []
|
||||
cursor = 0
|
||||
for match in re.finditer(r"(?:^|\n\s*\n)([^\n].*?)(?=\n\s*\n|\Z)", text, flags=re.DOTALL):
|
||||
paragraph = match.group(1).strip()
|
||||
if not paragraph:
|
||||
continue
|
||||
if paragraph.startswith("#") or re.match(r"^(?:[-*+] |\d+[.)] )", paragraph):
|
||||
continue
|
||||
if paragraph.startswith("|"):
|
||||
continue
|
||||
result.append((paragraph, match.start(1)))
|
||||
cursor = match.end()
|
||||
return result
|
||||
|
||||
|
||||
def _content_words(text: str) -> set[str]:
|
||||
stop = {
|
||||
"그리고", "하지만", "대한", "통해", "위한", "에서", "으로", "하는", "한다", "문서", "독자", "이글",
|
||||
"the", "and", "for", "with", "from", "that", "this", "what", "when", "into", "your", "document",
|
||||
}
|
||||
return {
|
||||
word.casefold()
|
||||
for word in re.findall(r"[0-9A-Za-z가-힣]+", text)
|
||||
if len(word) >= 2 and word.casefold() not in stop
|
||||
}
|
||||
|
||||
|
||||
def _contains_any(text: str, phrases: list[str]) -> bool:
|
||||
lowered = text.casefold()
|
||||
for phrase in phrases:
|
||||
tokens = _content_words(phrase)
|
||||
if tokens and any(token in lowered for token in tokens):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user