Files
document-haness/src/claridoc/style_contracts.py
T

198 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import re
from dataclasses import dataclass
from claridoc.models import Brief, DocumentType
KOREAN_EXPERIENCE_CONTRACT_ID = "korean_first_person_experience_v1"
_KOREAN_TECHNICAL_BLOG_PROFILES = frozenset(
{
"auto",
"woowahan_tech_blog_ko",
"korean_problem_solving_blog",
}
)
_GENERIC_STYLE_GUIDANCE = (
"Use a reader-facing style appropriate to the document type; never expose "
"planning or evidence-processing scaffolding."
)
_KOREAN_EXPERIENCE_GUIDANCE = f"""\
Reader-prose contract: {KOREAN_EXPERIENCE_CONTRACT_ID}
Write Korean reader-facing prose as a supported first-person experience, not as a list of settled facts.
- Follow this semantic order, never as a sentence template: concrete starting point -> initial expectation -> observed difference -> immediate term explanation -> author action or decision -> result, cost, or remaining limit.
- At the opening and major section transitions, use `저는` or `제가` when it establishes what the author actually inspected, ran, understood, selected, or changed. Do not repeat first person mechanically in every sentence.
- A first-person marker must represent a real observation or action supported by the source material. Never add an unsupported emotion, conversation, advice, failure, duration, result, or technical rationale.
- Use `했습니다` for directly observed or performed work: `확인했습니다`, `따라갔습니다`, `생각했습니다`.
- Use `합니다` for 현재 동작과 기술 설명: `사용합니다`, `호출합니다`, `막습니다`.
- Use `있습니다`, `없습니다`, `입니다`, and `아닙니다` for state and judgment. Do not mix reader prose ending in `한다`, `있다`, `아니다`, or `~했다`.
- Explain an unfamiliar term beside its first necessary use, as something the author came to understand while following the work.
- Connect a contrast to the concrete component and behavior that actually differ. Do not leave the reader with abstract conclusions such as a changed "position", "shape", "meaning", or "perspective".
- Preserve the exact claims, evidence status, numbers, versions, identifiers, code, commands, tables, links, diagrams, outline intents, and section order.
- Treat problem -> constraints -> options -> decision as a semantic order, never as a sentence template. Do not narrate outline labels or open consecutive paragraphs with formulaic `첫 번째 제약은`, `두 번째 제약은`, and `세 번째 제약은`.
- Use conversational but disciplined Korean. A Korean developer should be able to say the sentence naturally to a colleague without turning it into forced colloquial speech.
"""
_PLAIN_FORM_ENDING = re.compile(r"(?<!니)다(?=(?:[.!?](?:\s|$)|$))")
_FIRST_PERSON = re.compile(r"(?:저는|제가)")
_FENCE = re.compile(r"^\s*(?:```|~~~)")
_HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*$")
_IMAGE_ONLY = re.compile(r"^\s*!\[[^\]]*\]\([^)]*\)\s*$")
_TABLE_DIVIDER = re.compile(
r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$"
)
_QUOTED_SPANS = (
re.compile(r'"[^"\n]*"'),
re.compile(r"'[^'\n]*'"),
re.compile(r"“[^”\n]*”"),
re.compile(r"[^\n]*"),
re.compile(r"「[^」\n]*」"),
re.compile(r"『[^』\n]*』"),
)
@dataclass(frozen=True, slots=True)
class ReaderProseSegment:
text: str
line: int
h2_title: str | None
def korean_experience_contract_applies(brief: Brief) -> bool:
if not brief.is_korean:
return False
if brief.document_type == DocumentType.README:
return True
return (
brief.document_type == DocumentType.TECHNICAL_BLOG
and brief.constraints.style_profile.casefold() in _KOREAN_TECHNICAL_BLOG_PROFILES
)
def style_guidance(brief: Brief) -> str:
if korean_experience_contract_applies(brief):
return _KOREAN_EXPERIENCE_GUIDANCE
return _GENERIC_STYLE_GUIDANCE
def mandatory_style_review_checks(brief: Brief) -> str:
if not korean_experience_contract_applies(brief):
return ""
return """\
- For the `korean_first_person_experience_v1` contract, verify that `저는` or `제가` expresses 실제 관찰(actual observation) or action rather than decorating an objective explanation.
- Verify that the opening and major transitions let the reader follow a concrete starting point, expectation, observed difference, understanding, action, and result or remaining cost.
- Verify that an unfamiliar term is explained where the reader first needs it and that each contrast names the actual component and behavior that differ.
- Verify consistent `합니다/했습니다` reader prose outside headings, tables, quotations, code blocks, and command output.
- Flag any invented personal history, advice, emotion, failure, duration, outcome, or project rationale as an evidence defect.
"""
def revision_style_protocol(brief: Brief) -> str:
if not korean_experience_contract_applies(brief):
return ""
return """\
After resolving individual findings, recheck 문서 전체(the complete document) against `korean_first_person_experience_v1`.
Do not stop after adding one `저는` sentence. Confirm the opening and major transitions still form supported experience threads, all reader prose still uses `합니다/했습니다`, unfamiliar terms remain explained at first need, and no compliant section regressed during the whole-document rewrite.
"""
def reader_prose_segments(markdown: str) -> list[ReaderProseSegment]:
segments: list[ReaderProseSegment] = []
in_fence = False
current_h2: str | None = None
lines = markdown.splitlines()
table_lines: set[int] = set()
for index, line in enumerate(lines):
if not _TABLE_DIVIDER.match(line):
continue
if index > 0 and "|" in lines[index - 1]:
table_lines.add(index - 1)
table_lines.add(index)
cursor = index + 1
while cursor < len(lines) and lines[cursor].strip() and "|" in lines[cursor]:
table_lines.add(cursor)
cursor += 1
for line_number, raw_line in enumerate(lines, start=1):
if _FENCE.match(raw_line):
in_fence = not in_fence
continue
if in_fence:
continue
heading = _HEADING.match(raw_line)
if heading:
if len(heading.group(1)) == 2:
current_h2 = heading.group(2).strip()
continue
stripped = raw_line.strip()
if (
not stripped
or line_number - 1 in table_lines
or stripped.startswith(">")
or raw_line.startswith((" ", "\t"))
or _IMAGE_ONLY.match(raw_line)
or _TABLE_DIVIDER.match(raw_line)
or (stripped.startswith("|") and stripped.endswith("|"))
):
continue
prose = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", stripped)
prose = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", prose)
prose = re.sub(r"`[^`\n]*`", "", prose)
for quoted_span in _QUOTED_SPANS:
prose = quoted_span.sub("", prose)
prose = re.sub(r"^\s*(?:[-*+]|\d+[.)])\s+", "", prose).strip()
prose = re.sub(r"[*_~]", "", prose).strip()
if prose:
segments.append(ReaderProseSegment(prose, line_number, current_h2))
return segments
def plain_form_ending_locations(markdown: str) -> list[int]:
locations: list[int] = []
for segment in reader_prose_segments(markdown):
locations.extend(segment.line for _ in _PLAIN_FORM_ENDING.finditer(segment.text))
return locations
def first_person_metrics(markdown: str) -> dict[str, int | float | bool]:
segments = reader_prose_segments(markdown)
first_person_marker_count = sum(
len(_FIRST_PERSON.findall(segment.text))
for segment in segments
)
opening_has_first_person = bool(
segments and _FIRST_PERSON.search(segments[0].text)
)
section_markers: dict[str, bool] = {}
for segment in segments:
if segment.h2_title is None:
continue
section_markers.setdefault(segment.h2_title, False)
if _FIRST_PERSON.search(segment.text):
section_markers[segment.h2_title] = True
experience_section_count = len(section_markers)
marked_experience_section_count = sum(section_markers.values())
experience_section_coverage = (
marked_experience_section_count / experience_section_count
if experience_section_count
else 0.0
)
return {
"first_person_marker_count": first_person_marker_count,
"opening_has_first_person": opening_has_first_person,
"experience_section_count": experience_section_count,
"marked_experience_section_count": marked_experience_section_count,
"experience_section_coverage": round(experience_section_coverage, 3),
}