diff --git a/src/claridoc/prompts.py b/src/claridoc/prompts.py index 2722fc8..a379332 100644 --- a/src/claridoc/prompts.py +++ b/src/claridoc/prompts.py @@ -11,6 +11,11 @@ from claridoc.models import ( Outline, SourcePack, ) +from claridoc.style_contracts import ( + mandatory_style_review_checks, + revision_style_protocol, + style_guidance, +) FOUNDATION_RULES = """\ @@ -29,25 +34,6 @@ FOUNDATION_RULES = """\ 13. End with the decision the reader should carry into a similar situation, not a generic recap or a checklist added by habit. """ -WOOWAHAN_TECH_BLOG_KO = """\ -Korean technical-blog operating profile (derived from a bounded sample of Woowahan engineering articles; it is not an official house-style specification): -- Begin from the team or system's concrete context, then expose the friction in observable terms. -- Explain why the problem mattered before introducing the selected tool or architecture. -- Show prior approaches, failed attempts, or realistic alternatives when they affected the decision. -- State the selection criteria and the reason for the final choice. Pair benefits with the cost or boundary that remained. -- Let implementation details answer the problem already established; do not turn the article into a component inventory. -- Connect verification to the original problem. Report only what the available tests or observations actually prove. -- Treat problem -> constraints -> options -> decision as a semantic order, never as a sentence template. Do not narrate outline labels to the reader. -- Start a paragraph from a concrete actor, state, change, consequence, or decision when the evidence supports one. Make the subject and impact visible instead of opening with an abstract category label. -- Do not open consecutive paragraphs with formulaic ordinal frames such as “첫 번째 제약은”, “두 번째 제약은”, and “세 번째 제약은”. Use ordinals for a real sequence, method, layer, or figure; use a list or meaningful subheadings for genuinely parallel items. -- A question heading or transition must receive an immediate answer in the following prose. Do not use unanswered rhetorical questions as decoration. -- Use “하지만/다만” only for a real contrast and “이 때문에/그 결과/그래서/이에” only when the referenced cause is explicit in the preceding context. -- Use “팀에서는/저희는/우리는” when ownership or project-local judgment matters, not as a filler subject and never to universalize a local choice. -- Use conversational but disciplined Korean. Avoid canned phrases such as “이 절에서는”, “제공된 근거에 따르면”, “독자는 ~할 수 있다”, and repeated “먼저/다음으로/마지막으로”. -- An “예상 독자” block is optional. Use it only when it materially prevents the wrong audience from reading the article; never insert it as mandatory boilerplate. -- Revise for flow: when a paragraph feels paused or a connector feels forced, repair the logical relation rather than adding a transition word. -""" - ROLE_GUIDANCE: dict[str, str] = { "logic": "Audit premises, causal links, section order, transitions, contradictions, and whether each conclusion follows from stated constraints and evidence.", "reader": "Simulate the declared reader. Audit orientation, missing context, cognitive load, examples, scan paths, and whether process language or internal metadata breaks immersion.", @@ -62,17 +48,6 @@ def _dump(value: Any) -> str: return json.dumps(value, ensure_ascii=False, indent=2) -def _style_guidance(brief: Brief) -> str: - profile = brief.constraints.style_profile.casefold() - if brief.is_korean and brief.document_type.value == "technical_blog" and profile in { - "auto", - "woowahan_tech_blog_ko", - "korean_problem_solving_blog", - }: - return WOOWAHAN_TECH_BLOG_KO - return "Use a reader-facing style appropriate to the document type; never expose planning or evidence-processing scaffolding." - - def _citation_policy(brief: Brief) -> str: style = brief.constraints.citation_style if not brief.constraints.require_citations: @@ -134,7 +109,7 @@ Apply these foundation rules: {FOUNDATION_RULES} Apply this style guidance: -{_style_guidance(brief)} +{style_guidance(brief)} {_source_hierarchy()} @@ -176,7 +151,7 @@ Apply these foundation rules: {FOUNDATION_RULES} Apply this style guidance: -{_style_guidance(brief)} +{style_guidance(brief)} {_source_hierarchy()} @@ -232,7 +207,7 @@ Apply these foundation rules: {FOUNDATION_RULES} Apply this style guidance: -{_style_guidance(brief)} +{style_guidance(brief)} {_source_hierarchy()} @@ -247,6 +222,7 @@ Mandatory checks: - The opening must establish a real problem or tension rather than recite audience, scope, and source metadata. - Information-architecture labels must not leak as repetitive sentence scaffolding. In Korean technical blogs, distinguish real ordered sequences from formulaic “첫 번째/두 번째/세 번째 + abstract category” paragraph openings. - A question heading or transition must be answered immediately, and each contrast or causal connector must point to a real relation in the surrounding prose. +{mandatory_style_review_checks(brief)} Scoring dimensions (0-100 each): {dimension_list} @@ -313,7 +289,7 @@ Apply these foundation rules: {FOUNDATION_RULES} Apply this style guidance: -{_style_guidance(brief)} +{style_guidance(brief)} {_source_hierarchy()} @@ -328,6 +304,7 @@ Revision protocol: 8. Preserve correct material and the author's project context; avoid generic filler and unrelated rewrites. 9. Remove repeated ordinal sentence scaffolding that merely reads the outline aloud. Preserve ordinals when they identify a real procedure, method, layer, or figure, and prefer a list or meaningful subheadings for parallel items. 10. Return the entire revised document, not a patch or explanation. +{revision_style_protocol(brief)} Citation policy: {_citation_policy(brief)} Date policy: {_date_policy(brief)} diff --git a/src/claridoc/style_contracts.py b/src/claridoc/style_contracts.py new file mode 100644 index 0000000..b30b2e2 --- /dev/null +++ b/src/claridoc/style_contracts.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +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. +""" + + +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. +""" diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 4fddd84..d4bf8c6 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -9,17 +9,22 @@ from tests.helpers import brief_dict, make_sources class PromptTests(unittest.TestCase): - def _korean_blog(self) -> Brief: - data = brief_dict() + def _brief( + self, + document_type: str = "technical_blog", + *, + language: str = "ko-KR", + style_profile: str = "woowahan_tech_blog_ko", + ) -> Brief: + data = brief_dict(document_type) data["title"] = "기술적 선택을 설명하는 글" - data["language"] = "ko-KR" + data["language"] = language data["reader_goal"] = "안전한 구현 방식을 선택한다" data["core_message"] = "기술 선택은 문제와 비용을 함께 설명해야 한다." - data["constraints"]["style_profile"] = "woowahan_tech_blog_ko" + data["constraints"]["style_profile"] = style_profile return Brief.from_dict(data) - def test_korean_blog_prompts_separate_information_structure_from_sentence_form(self) -> None: - brief = self._korean_blog() + def _prompts(self, brief: Brief) -> tuple[str, str, str]: sources = make_sources() outline = create_outline(brief, sources) lint_report = LintReport(score=100.0, word_count=0, issues=[], metrics={}) @@ -27,13 +32,40 @@ class PromptTests(unittest.TestCase): draft = drafting_prompt(brief, outline, sources) review = review_prompt(brief, outline, sources, "# draft", lint_report, "editor") revision = revision_prompt(brief, outline, sources, "# draft", lint_report, []) + return draft, review, revision - self.assertIn("semantic order, never as a sentence template", draft) - self.assertIn("첫 번째 제약은", draft) - self.assertIn("Information-architecture labels must not leak", review) - self.assertIn("real ordered sequences", review) - self.assertIn("Remove repeated ordinal sentence scaffolding", revision) - self.assertIn("real procedure, method, layer, or figure", revision) + def test_korean_blog_prompts_share_experience_prose_contract(self) -> None: + prompts = self._prompts(self._brief()) + + for prompt in prompts: + with self.subTest(stage=prompt[:40]): + self.assertIn("korean_first_person_experience_v1", prompt) + self.assertIn("저는", prompt) + self.assertIn("제가", prompt) + self.assertIn("했습니다", prompt) + self.assertIn("현재 동작과 기술 설명", prompt) + + self.assertIn("semantic order, never as a sentence template", prompts[0]) + self.assertIn("실제 관찰", prompts[1]) + self.assertIn("문서 전체", prompts[2]) + + def test_korean_readme_prompts_share_experience_prose_contract(self) -> None: + prompts = self._prompts(self._brief("readme", style_profile="auto")) + + for prompt in prompts: + self.assertIn("korean_first_person_experience_v1", prompt) + self.assertIn("저는", prompt) + self.assertIn("했습니다", prompt) + + def test_unrelated_document_types_do_not_receive_experience_contract(self) -> None: + briefs = [ + self._brief(language="en-US"), + self._brief("tutorial", style_profile="auto"), + ] + + for brief in briefs: + for prompt in self._prompts(brief): + self.assertNotIn("korean_first_person_experience_v1", prompt) if __name__ == "__main__":