diff --git a/src/claridoc/lint.py b/src/claridoc/lint.py index 621160d..8503f51 100644 --- a/src/claridoc/lint.py +++ b/src/claridoc/lint.py @@ -15,6 +15,7 @@ from claridoc.models import ( ) from claridoc.style_contracts import ( KOREAN_EXPERIENCE_CONTRACT_ID, + first_person_metrics, korean_experience_contract_applies, plain_form_ending_locations, ) @@ -213,6 +214,17 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack if style_contract_applies else [] ) + experience_metrics = ( + first_person_metrics(text) + if style_contract_applies + else { + "first_person_marker_count": 0, + "opening_has_first_person": False, + "experience_section_count": 0, + "marked_experience_section_count": 0, + "experience_section_coverage": 0.0, + } + ) if plain_form_locations: add( "STYLE002", @@ -227,6 +239,30 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack "current behavior. Preserve quoted material and code unchanged." ), ) + if ( + style_contract_applies + and experience_metrics["experience_section_count"] + and ( + not experience_metrics["opening_has_first_person"] + or experience_metrics["experience_section_coverage"] < 0.5 + ) + ): + reasons: list[str] = [] + if not experience_metrics["opening_has_first_person"]: + reasons.append("the opening has no 저는/제가 experience marker") + if experience_metrics["experience_section_coverage"] < 0.5: + reasons.append( + "fewer than half of substantive H2 sections establish first-person experience" + ) + add( + "STYLE003", + Severity.BLOCKER, + "The Korean experience-prose contract is incomplete: " + "; ".join(reasons) + ".", + suggestion=( + "Use 저는 or 제가 where the opening and major transitions describe " + "a supported observation, action, or decision. Do not add invented experience." + ), + ) long_paragraph_count = 0 crowded_paragraph_count = 0 long_sentence_count = 0 @@ -430,6 +466,7 @@ def lint_document(text: str, brief: Brief, outline: Outline, sources: SourcePack else "none" ), "plain_form_ending_count": len(plain_form_locations), + **experience_metrics, "has_verification": has_verification, "has_tradeoffs": has_tradeoffs, "severity_counts": dict(severity_counts), diff --git a/src/claridoc/style_contracts.py b/src/claridoc/style_contracts.py index 3bb40b1..b613111 100644 --- a/src/claridoc/style_contracts.py +++ b/src/claridoc/style_contracts.py @@ -39,6 +39,7 @@ Write Korean reader-facing prose as a supported first-person experience, not as """ _PLAIN_FORM_ENDING = re.compile(r"(? 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), + }