198 lines
8.7 KiB
Python
198 lines
8.7 KiB
Python
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),
|
||
}
|