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"(? 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), }