kotonoha 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README-ja.md +87 -21
  3. package/README.md +111 -20
  4. package/package.json +5 -2
  5. package/skills/consulting-analyst/SKILL.md +241 -0
  6. package/skills/consulting-analyst/assets/templates/analysis-brief.md +58 -0
  7. package/skills/consulting-analyst/assets/templates/decision-analysis.md +62 -0
  8. package/skills/consulting-analyst/assets/templates/hypothesis-evidence-ledger.md +48 -0
  9. package/skills/consulting-analyst/assets/templates/issue-tree.md +35 -0
  10. package/skills/consulting-analyst/assets/templates/synthesis-handoff.md +68 -0
  11. package/skills/consulting-analyst/references/evidence-and-hypotheses.md +80 -0
  12. package/skills/consulting-analyst/references/framework-selection.md +84 -0
  13. package/skills/consulting-analyst/references/handoff-contract.md +90 -0
  14. package/skills/japanese-prose/NOTICE.md +7 -1
  15. package/skills/japanese-prose/SKILL.md +48 -16
  16. package/skills/japanese-prose/references/review-workflow.md +57 -0
  17. package/skills/japanese-prose/references/scoring.md +6 -0
  18. package/skills/japanese-prose/scripts/core.py +6 -2
  19. package/skills/japanese-prose/scripts/invariants.py +182 -0
  20. package/skills/japanese-prose/scripts/lint.py +68 -3
  21. package/skills/japanese-prose/scripts/test_engine.py +168 -0
  22. package/skills/presentation-planner/SKILL.md +14 -0
  23. package/skills/tech-writer/SKILL.md +58 -24
  24. package/skills/tech-writer/assets/templates/blueprint.md +187 -0
  25. package/skills/tech-writer/assets/templates/white-paper.md +192 -0
  26. package/skills/tech-writer/references/doctypes/blueprint.md +81 -0
  27. package/skills/tech-writer/references/doctypes/white-paper.md +84 -0
  28. package/skills/tech-writer/references/japanese-prose-optimization.md +17 -8
  29. package/skills/tech-writer/references/style-constitution.md +10 -4
  30. package/skills/tech-writer/scripts/lint.py +10 -5
@@ -0,0 +1,90 @@
1
+ # Analysis handoff contract
2
+
3
+ The handoff transfers reasoning and evidence without transferring ownership
4
+ of final prose or presentation design.
5
+
6
+ ## Required fields
7
+
8
+ `synthesis-handoff.md` must include:
9
+
10
+ - Decision-maker and decision question
11
+ - Intended outcome and deadline
12
+ - Recommended answer and confidence
13
+ - Supporting issue, hypothesis, finding, source, and evidence IDs
14
+ - Assumptions and estimates
15
+ - Counterevidence and alternative interpretations
16
+ - Options, criteria, weights, and sensitivity when applicable
17
+ - Current, target, gap, intervention, and action IDs when applicable
18
+ - Risks, unresolved questions, and evidence-gathering actions
19
+ - Required destination and target doctype or presentation purpose
20
+ - Frozen items the receiving skill must preserve
21
+
22
+ ## ID semantics and traceability
23
+
24
+ - `Q` identifies an issue; `HYP` a hypothesis; `FND` a synthesized finding.
25
+ - `SRC` identifies a source record; `EVD` an analysis evidence record linked
26
+ to its source. Never rename a source record to `EVD` or overwrite analysis
27
+ evidence with a source citation.
28
+ - `GAP` identifies a current-to-target gap; `INT` an intervention defined in
29
+ Current–Target–Gap; `CRT` an option criterion; `ACT` a recommended action.
30
+ - Findings preserve their Q / HYP and EVD links and reference relevant
31
+ GAP / INT / CRT IDs. Actions link to supporting FND IDs, related Q / HYP
32
+ IDs, and applicable GAP / INT / CRT IDs. Transition risks reference the
33
+ affected GAP / INT IDs.
34
+ - Preserve this chain in destination documents. Blueprint traceability keeps
35
+ relevant Q / HYP / FND / EVD and GAP / INT / CRT alongside its driver,
36
+ principle, capability, workstream, and KPI IDs. White Paper claims link
37
+ relevant HYP / FND / EVD IDs, while source records remain SRC IDs.
38
+ - Use only defined, relevant IDs. Mark inapplicable links `N/A` with a reason;
39
+ do not require consulting IDs when no consulting handoff exists.
40
+
41
+ If `Analysis status` is `Incomplete`, the receiving skill must either return
42
+ the decision-critical gaps for further analysis or visibly preserve the
43
+ incomplete status, evidence gaps, confidence, and conditional nature of every
44
+ affected conclusion. It must not convert an incomplete handoff into an
45
+ unqualified recommendation.
46
+
47
+ ## Handoff to tech-writer
48
+
49
+ `tech-writer` owns document structure, doctype completeness, Markdown, and
50
+ reader-facing prose. It may:
51
+
52
+ - Reorder analysis to fit the doctype
53
+ - Rename headings
54
+ - Condense repeated evidence
55
+ - Add doctype-required metadata and controls
56
+
57
+ It must not:
58
+
59
+ - Change evidence IDs or source meaning
60
+ - Present assumptions as facts
61
+ - Remove counterevidence that affects the conclusion
62
+ - Change criteria, weights, scores, confidence, or recommendation without
63
+ returning the change to analysis
64
+
65
+ ## Handoff to presentation-planner
66
+
67
+ `presentation-planner` owns audience reasoning, scenario, slide order, and
68
+ visual handoff. It may:
69
+
70
+ - Select the shortest evidence set that supports the audience decision
71
+ - Split findings across slides
72
+ - Adapt the order for the audience's prior knowledge
73
+
74
+ It must preserve:
75
+
76
+ - The decision question and requested action
77
+ - Evidence status and confidence
78
+ - Material caveats and counterevidence
79
+ - Option-comparison logic
80
+ - Conditions that would change the recommendation
81
+
82
+ ## Return conditions
83
+
84
+ Return the handoff to `consulting-analyst` when:
85
+
86
+ - A new conclusion is required
87
+ - New evidence changes confidence or ranking
88
+ - The audience asks a different decision question
89
+ - Criteria or target-state dimensions change
90
+ - Communication edits expose a contradiction in the analysis
@@ -1,4 +1,4 @@
1
- # GiNZA dependency notice
1
+ # Diagnostic dependency notices
2
2
 
3
3
  This skill is an original kotonoha implementation and does not include source
4
4
  code from `coji/natural-japanese`.
@@ -15,3 +15,9 @@ datasets.
15
15
  The implementation targets GiNZA 5.2.x and uses token surface forms, lemmas,
16
16
  universal part-of-speech tags, dependency relationships, sentence boundaries,
17
17
  and named-entity labels.
18
+
19
+ The edit-invariant gate uses
20
+ [markdown-it-py](https://github.com/executablebooks/markdown-it-py), distributed
21
+ under the MIT License, to parse Markdown without treating short fences
22
+ inside longer fenced code blocks as prose. Its dependencies are resolved by
23
+ `uv` when `scripts/invariants.py` is run.
@@ -6,10 +6,10 @@ description: >-
6
6
  reviewing, or scoring Japanese prose; when the user asks for more natural,
7
7
  readable, concise, or less AI-like Japanese; or when tech-writer delegates
8
8
  its sentence-level quality pass. Owns wording, sentence rhythm, reading
9
- load, terminology review, and formulaic-expression detection. Does not own
10
- technical-document structure or presentation storylines.
9
+ load, typo and grammar review, terminology, and edit-invariant checks.
10
+ Does not own technical-document structure or presentation storylines.
11
11
  license: MIT
12
- argument-hint: "[write|review|score] [quick|full] <target file or request>"
12
+ argument-hint: "[write|proofread|review|score] [quick|full] <target file or request>"
13
13
  ---
14
14
 
15
15
  # japanese-prose
@@ -39,6 +39,8 @@ acceptance criteria, approval states, or normative force.
39
39
  - `write`: write or rewrite Japanese prose. Use `quick` unless the document
40
40
  is external-facing, high-risk, or longer than roughly 10,000 Japanese
41
41
  characters.
42
+ - `proofread`: correct demonstrable typos and grammatical errors with minimal
43
+ edits. Do not perform optional stylistic rewrites or chase a higher score.
42
44
  - `review`: report problems and proposed corrections without editing.
43
45
  - `score`: run diagnostics and report the 0–100 score without rewriting.
44
46
  - `quick`: run prose lint once, review findings in context, edit, and rerun
@@ -50,33 +52,57 @@ acceptance criteria, approval states, or normative force.
50
52
  ## Workflow
51
53
 
52
54
  1. Read the target, audience, intended outcome, and calling skill's frozen
53
- invariants.
55
+ invariants. Before editing, save an immutable copy of the entire original
56
+ file as `<workdir>/prose-original.md`. Keep this separate from diagnostic
57
+ baselines, which may change between rounds.
54
58
  2. Read `references/writing-guidelines.md` before generating or rewriting
55
- prose.
59
+ prose. Resolve `<japanese-prose-dir>` to the directory containing this
60
+ loaded skill; do not assume the shell is running in that directory.
56
61
  3. Run the GiNZA prose diagnostic:
57
62
 
58
63
  ```bash
59
- uv run scripts/lint.py <target-file> --genre tech --json > <workdir>/prose-baseline.json
64
+ uv run <japanese-prose-dir>/scripts/lint.py <target-file> --genre tech --json > <workdir>/prose-baseline.json
60
65
  ```
61
66
 
62
67
  4. For `full` mode, also run:
63
68
 
64
69
  ```bash
65
- uv run scripts/lint.py <target-file> --genre tech --reading-load --json
66
- uv run scripts/outline.py <target-file>
67
- uv run scripts/terms.py <target-file> --json
70
+ uv run <japanese-prose-dir>/scripts/lint.py <target-file> --genre tech --reading-load --json
71
+ uv run <japanese-prose-dir>/scripts/outline.py <target-file>
72
+ uv run <japanese-prose-dir>/scripts/terms.py <target-file> --json
68
73
  ```
69
74
 
70
- 5. Classify each finding as `fix` or `keep`. A detector identifies a review
71
- target; it does not authorize blind replacement.
72
- 6. Apply only fixes that improve the intended reader's understanding while
73
- preserving the frozen invariants.
75
+ 5. Read the entire prose for typos, particle errors, subject/predicate
76
+ mismatches, and incorrect inflections even when lint reports no findings.
77
+ Classify each finding as `fix` or `keep`. A detector identifies a review
78
+ target; it does not authorize blind replacement. Adjacent duplicate
79
+ particles/words, Japanese bracket mismatches, and within-paragraph style
80
+ mixing are proofreading targets, not proof of an error.
81
+ 6. Correct demonstrable errors first. In `write` mode, then consider optional
82
+ wording changes that serve the reader's stated goal. Keep these separate
83
+ from necessary corrections. Preserve acceptable wording when a change
84
+ merely substitutes a synonym or removes a legitimate qualification.
85
+ Never infer a factual or proper-name correction from plausibility alone.
74
86
  7. Rerun the prose diagnostic with
75
87
  `--baseline <workdir>/prose-baseline.json` to classify new, persisting,
76
88
  and resolved findings. Replace the baseline only after recording the
77
89
  decisions for the current round.
78
- 8. Stop when every finding has a decision, no new actionable finding appears,
79
- and the document passes the checks in `references/review-workflow.md`.
90
+ 8. Run the edit-invariant gate against the immutable original:
91
+
92
+ ```bash
93
+ uv run <japanese-prose-dir>/scripts/invariants.py <workdir>/prose-original.md <target-file> --protect <proper-name-or-domain-term> --json
94
+ ```
95
+
96
+ Repeat `--protect` for each frozen proper name or domain term; omit it only
97
+ when there are none. Exit 2 blocks changed protected content. Any edit
98
+ requires a contextual semantic review, even if all mechanical checks
99
+ pass: exit 3 means that review is pending or rejected, not completion.
100
+ Follow `references/review-workflow.md` to record the hash-bound review,
101
+ then rerun with `--review <workdir>/semantic-review.json --json`.
102
+ Recheck after every subsequent edit; a stale review cannot approve it.
103
+ 9. Stop only when every finding has a decision, no new actionable finding
104
+ appears, and the invariant gate returns `passed`. If the gate cannot run,
105
+ report the failure rather than declaring completion.
80
106
 
81
107
  Allow at most three edit-and-diagnose rounds per invocation. If actionable
82
108
  findings or invariant violations remain, report that the optimization did not
@@ -87,7 +113,10 @@ converge and list the unresolved items.
87
113
  When strong emphasis touches surrounding prose, put half-width spaces outside
88
114
  the delimiters: `これは **重要** です`, not `これは**重要**です`. Spaces are
89
115
  unnecessary at line boundaries or next to punctuation, and must not be placed
90
- inside `**`.
116
+ inside `**`. Use ASCII spaces (`U+0020`), never full-width spaces (`U+3000`),
117
+ tabs, or non-breaking spaces, immediately before and after emphasis embedded
118
+ in prose. Write `これは **「重要」** と説明する`, not
119
+ `これは **「重要」** と説明する`.
91
120
 
92
121
  ## Diagnostic interpretation
93
122
 
@@ -105,4 +134,7 @@ Report:
105
134
  - findings fixed and findings deliberately kept
106
135
  - any skipped diagnostic and its reason
107
136
  - invariant verification result
137
+ - necessary corrections versus optional wording changes
138
+ - semantic review status and any remaining uncertainty; mechanical equality
139
+ is not a guarantee of preserved meaning
108
140
  - `completed` or `did not converge`
@@ -40,6 +40,61 @@ Read only headings and paragraph openings. The argument must still be
40
40
  predictable. Then read the finished prose continuously and verify that local
41
41
  rewrites did not damage transitions or create repeated explanations.
42
42
 
43
+ ## Require a semantic review of edits
44
+
45
+ Run `scripts/invariants.py` on the immutable pre-edit file and the edited
46
+ file. It protects numbers and known quantity units in occurrence order,
47
+ uppercase identifiers, URL/link destinations, frontmatter, heading levels,
48
+ inline code, fenced/indented code, Markdown tables, HTML, and caller-supplied
49
+ `--protect` terms. The Markdown parser recognizes nested and long fences.
50
+ The check is conservative: it may block a legitimate reorder or protected
51
+ content correction. Such changes need separate authorization, not a
52
+ reviewer override of a failed invariant.
53
+
54
+ Mechanical equality cannot establish equivalent meaning. For every changed
55
+ passage, compare the original and edited text, including nearby context:
56
+
57
+ - `actors`: who performs or receives each action is unchanged
58
+ - `negation`: polarity and the scope of negation are unchanged
59
+ - `conditions`: prerequisites, exceptions, acceptance criteria, and approval states are unchanged
60
+ - `modality`: obligations, recommendations, permissions, and uncertainty are unchanged
61
+ - `scope`: limitations such as 「だけ」「すべて」「少なくとも」 are unchanged
62
+ - `causality`: causal and temporal relationships are unchanged
63
+ - `quantity_bindings`: each quantity still refers to the same object or action
64
+ - `terminology`: proper names and domain concepts are unchanged
65
+ - `structure`: section identity, order, quoted meaning, and traceability are unchanged
66
+
67
+ Record the review as JSON. Copy `before_sha256` and `after_sha256` from the
68
+ gate output, identify the actual reviewer, and set a check to `true` only
69
+ after inspecting it. Do not infer approval from a score, an unchanged token
70
+ count, or the absence of warnings. A review can be performed by the agent or
71
+ a human, but an agent review is not a human approval or a semantic proof.
72
+
73
+ ```json
74
+ {
75
+ "before_sha256": "<hash from the gate>",
76
+ "after_sha256": "<hash from the gate>",
77
+ "reviewer": "<actual reviewer identifier>",
78
+ "checks": {
79
+ "actors": false,
80
+ "negation": false,
81
+ "conditions": false,
82
+ "modality": false,
83
+ "scope": false,
84
+ "causality": false,
85
+ "quantity_bindings": false,
86
+ "terminology": false,
87
+ "structure": false
88
+ }
89
+ }
90
+ ```
91
+
92
+ Rerun the gate with `--review <review-file>`. Exit codes are 0 for `passed`,
93
+ 1 for input/runtime errors, 2 for protected-content violations, and 3 for
94
+ missing, stale, incomplete, or rejected semantic reviews. Subsequent edits
95
+ invalidate the review. If equivalence is uncertain, keep the original
96
+ wording or report the unresolved concern.
97
+
43
98
  ## Convergence
44
99
 
45
100
  The review is complete when:
@@ -47,4 +102,6 @@ The review is complete when:
47
102
  - every finding has a `fix` or `keep` decision
48
103
  - rerunning diagnostics produces no new actionable finding
49
104
  - frozen invariants match the pre-edit document
105
+ - `scripts/invariants.py` returns `passed` with a current semantic review
106
+ for changed text
50
107
  - the reader can reach the requested outcome without an unstated inference
@@ -22,3 +22,9 @@ A high score does not prove that facts are correct, the argument is complete,
22
22
  or the prose is natural. A low score does not require every finding to be
23
23
  changed. Always report the finding categories and contextual decisions beside
24
24
  the score.
25
+
26
+ Proofreading findings and style/readability findings contribute to the same
27
+ triage score, but they are not interchangeable measures of quality. Report
28
+ the categories separately. Do not use this score as typo-detection recall,
29
+ correction accuracy, semantic fidelity, or evidence that an LLM-assisted
30
+ rewrite is better. A correctly repaired typo may leave the score unchanged.
@@ -76,10 +76,14 @@ def iter_content_lines(text: str) -> Iterable[tuple[int, str]]:
76
76
 
77
77
  fence = FENCE_RE.match(raw)
78
78
  if fence:
79
- marker = fence.group(1)[0]
79
+ marker = fence.group(1)
80
80
  if not fence_marker:
81
81
  fence_marker = marker
82
- elif marker == fence_marker:
82
+ elif (
83
+ marker[0] == fence_marker[0]
84
+ and len(marker) >= len(fence_marker)
85
+ and not raw[fence.end():].strip()
86
+ ):
83
87
  fence_marker = ""
84
88
  yield index, ""
85
89
  continue
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = ["markdown-it-py>=3,<5"]
5
+ # ///
6
+ """Check protected content and require a hash-bound semantic review of edits."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ from pathlib import Path
14
+ import re
15
+ import sys
16
+
17
+ from markdown_it import MarkdownIt
18
+
19
+
20
+ SEMANTIC_CHECKS = (
21
+ "actors", "negation", "conditions", "modality", "scope", "causality",
22
+ "quantity_bindings", "terminology", "structure",
23
+ )
24
+ NUMBER_RE = re.compile(r"\d+(?:[.,]\d+)*")
25
+ IDENTIFIER_RE = re.compile(r"(?<![A-Za-z0-9_])[A-Z][A-Z0-9]*(?:[-_][A-Z0-9]+)+(?![A-Za-z0-9_])")
26
+ QUANTITY_RE = re.compile(
27
+ r"(\d+(?:[.,]\d+)*)\s*"
28
+ r"(ミリ秒|時間|年間|か月|ヶ月|秒|分|日|週|月|年|回|件|台|人|個|円|倍|"
29
+ r"[A-Za-zµμ]+(?:/[A-Za-zµμ]+)?|%|%|℃)(?![A-Za-z])"
30
+ )
31
+ BARE_URL_RE = re.compile(r"https?://[^\s<>`\"()\[\]、。!?「」『』()]+")
32
+
33
+
34
+ def fingerprint(value: object) -> str:
35
+ return hashlib.sha256(
36
+ json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
37
+ ).hexdigest()
38
+
39
+
40
+ def snapshot(text: str, protected_terms: list[str]) -> dict:
41
+ lines = text.splitlines(keepends=True)
42
+ frontmatter = ""
43
+ body = text
44
+ if lines and lines[0].strip() == "---":
45
+ end = next(
46
+ (index for index in range(1, len(lines)) if lines[index].strip() in {"---", "..."}),
47
+ None,
48
+ )
49
+ if end is not None:
50
+ frontmatter = "".join(lines[:end + 1])
51
+ body = "".join(lines[end + 1:])
52
+ body_lines = body.splitlines(keepends=True)
53
+ tokens = MarkdownIt("commonmark").enable("table").parse(body)
54
+ blocks = []
55
+ headings = []
56
+ code = []
57
+ links = []
58
+ html = []
59
+ for token in tokens:
60
+ if token.type in {"fence", "code_block", "table_open", "html_block"}:
61
+ if token.map is None:
62
+ raise ValueError(f"Missing source map for {token.type}")
63
+ start, end = token.map
64
+ blocks.append((token.type, "".join(body_lines[start:end])))
65
+ if token.type == "heading_open":
66
+ headings.append(token.tag)
67
+ for child in token.children or []:
68
+ if child.type == "code_inline":
69
+ code.append(child.content)
70
+ elif child.type == "link_open":
71
+ links.append(child.attrGet("href"))
72
+ elif child.type == "image":
73
+ links.append(child.attrGet("src"))
74
+ elif child.type == "html_inline":
75
+ html.append(child.content)
76
+ return {
77
+ "frontmatter": frontmatter,
78
+ "protected_blocks": blocks,
79
+ "heading_levels": headings,
80
+ "inline_code": code,
81
+ "link_destinations": links,
82
+ "html_tags": html,
83
+ "numbers_in_order": NUMBER_RE.findall(text),
84
+ "quantities_in_order": QUANTITY_RE.findall(text),
85
+ "identifiers_in_order": IDENTIFIER_RE.findall(text),
86
+ "bare_urls_in_order": BARE_URL_RE.findall(text),
87
+ "protected_terms": {term: text.count(term) for term in protected_terms},
88
+ }
89
+
90
+
91
+ def check(
92
+ before: Path,
93
+ after: Path,
94
+ protected_terms: list[str] | None = None,
95
+ review_path: Path | None = None,
96
+ ) -> dict:
97
+ terms = protected_terms or []
98
+ if any(not term.strip() for term in terms):
99
+ raise ValueError("Protected terms must not be empty")
100
+ original = before.read_bytes()
101
+ edited = after.read_bytes()
102
+ before_hash = hashlib.sha256(original).hexdigest()
103
+ after_hash = hashlib.sha256(edited).hexdigest()
104
+ initial = snapshot(original.decode("utf-8"), terms)
105
+ final = snapshot(edited.decode("utf-8"), terms)
106
+ violations = [
107
+ {
108
+ "category": name,
109
+ "message": f"Protected content changed: {name}",
110
+ "before_fingerprint": fingerprint(initial[name]),
111
+ "after_fingerprint": fingerprint(final[name]),
112
+ }
113
+ for name in initial if initial[name] != final[name]
114
+ ]
115
+ review_errors = []
116
+ review_required = original != edited
117
+ reviewer = None
118
+ if review_path is not None:
119
+ review = json.loads(review_path.read_text(encoding="utf-8"))
120
+ if not isinstance(review, dict):
121
+ raise ValueError("Semantic review must be a JSON object")
122
+ reviewer = review.get("reviewer")
123
+ if not isinstance(reviewer, str) or not reviewer.strip():
124
+ review_errors.append("reviewer must be a non-empty identifier")
125
+ if review.get("before_sha256") != before_hash or review.get("after_sha256") != after_hash:
126
+ review_errors.append("review hashes do not match this exact before/after pair")
127
+ checks = review.get("checks")
128
+ if not isinstance(checks, dict):
129
+ review_errors.append("checks must be an object")
130
+ else:
131
+ review_errors.extend(
132
+ f"{name} has not been affirmed" for name in SEMANTIC_CHECKS
133
+ if checks.get(name) is not True
134
+ )
135
+ elif review_required:
136
+ review_errors.append("changed text requires a hash-bound semantic review")
137
+ status = "blocked" if violations else "review_required" if review_errors else "passed"
138
+ return {
139
+ "before": str(before),
140
+ "after": str(after),
141
+ "before_sha256": before_hash,
142
+ "after_sha256": after_hash,
143
+ "status": status,
144
+ "mechanical_violations": violations,
145
+ "semantic_review_required": review_required,
146
+ "semantic_checks": list(SEMANTIC_CHECKS),
147
+ "semantic_review_errors": review_errors,
148
+ "reviewer": reviewer,
149
+ "limitations": [
150
+ "Mechanical equality does not prove semantic equivalence.",
151
+ "Proper names and domain terms require caller-supplied --protect values and contextual review.",
152
+ "Semantic checks are reviewer attestations, not automated entailment judgments.",
153
+ ],
154
+ }
155
+
156
+
157
+ def main() -> int:
158
+ parser = argparse.ArgumentParser(description=__doc__)
159
+ parser.add_argument("before")
160
+ parser.add_argument("after")
161
+ parser.add_argument("--protect", action="append", default=[])
162
+ parser.add_argument("--review", type=Path)
163
+ parser.add_argument("--json", action="store_true")
164
+ args = parser.parse_args()
165
+ try:
166
+ result = check(Path(args.before), Path(args.after), args.protect, args.review)
167
+ except (OSError, ValueError, UnicodeError) as error:
168
+ print(f"error: {error}", file=sys.stderr)
169
+ return 1
170
+ if args.json:
171
+ print(json.dumps(result, ensure_ascii=False, indent=2))
172
+ else:
173
+ print(f"{result['status']}: {args.before} -> {args.after}")
174
+ for violation in result["mechanical_violations"]:
175
+ print(f" {violation['message']}")
176
+ for error in result["semantic_review_errors"]:
177
+ print(f" Semantic review: {error}")
178
+ return {"passed": 0, "blocked": 2, "review_required": 3}[result["status"]]
179
+
180
+
181
+ if __name__ == "__main__":
182
+ sys.exit(main())
@@ -75,10 +75,12 @@ DOUBLE_NEGATIVE_RULES = (
75
75
  re.compile(r"ないわけではない"),
76
76
  re.compile(r"ないことはない"),
77
77
  re.compile(r"なくはない"),
78
- re.compile(r"なくてはならない"),
79
- re.compile(r"ざるを得ない"),
80
78
  )
81
79
 
80
+ BRACKET_PAIRS = dict(zip("([{「『【", ")]}」』】"))
81
+ POLITE_END_RE = re.compile(r"(?:です|ます|でした|ました|ません(?:でした)?|でしょう|ましょう|ください|下さい)$")
82
+ PLAIN_END_RE = re.compile(r"(?:である|だった|だ|る|た|ない)$")
83
+
82
84
  GENRE_LIMITS = {
83
85
  "general": {"length": 90, "critical_length": 150, "commas": 5, "depth": 8},
84
86
  "tech": {"length": 110, "critical_length": 180, "commas": 6, "depth": 9},
@@ -100,6 +102,64 @@ def add(
100
102
  )
101
103
 
102
104
 
105
+ def proofread(block, doc, findings: list[Finding]) -> None:
106
+ stack: list[str] = []
107
+ unbalanced = False
108
+ for character in block.text:
109
+ if character in BRACKET_PAIRS:
110
+ stack.append(character)
111
+ elif character in BRACKET_PAIRS.values():
112
+ if not stack or BRACKET_PAIRS[stack[-1]] != character:
113
+ unbalanced = True
114
+ else:
115
+ stack.pop()
116
+ if stack or unbalanced:
117
+ add(
118
+ findings, block.start_line, "unbalanced_brackets", "warning",
119
+ "括弧の対応が取れていません。引用や数式として意図した表記か確認します。",
120
+ block.text,
121
+ )
122
+
123
+ for previous, current in zip(doc, doc[1:]):
124
+ if previous.idx + len(previous.text) != current.idx or previous.text != current.text:
125
+ continue
126
+ particle = previous.pos_ == current.pos_ == "ADP"
127
+ word = (
128
+ len(current.text) >= 2
129
+ and previous.pos_ == current.pos_
130
+ and current.pos_ in {"NOUN", "VERB", "ADJ", "ADV"}
131
+ )
132
+ if particle or word:
133
+ add(
134
+ findings, sentence_line(block, current.idx),
135
+ "duplicate_particle" if particle else "duplicate_word", "warning",
136
+ "同じ助詞が隣接しています。誤入力による重複か確認します。"
137
+ if particle else "同じ語が隣接しています。意図した反復か確認します。",
138
+ current.sent.text, {"repeated": current.text},
139
+ )
140
+
141
+ first_style = None
142
+ for sent in doc.sents:
143
+ if re.search(r'[「」『』"“”]', sent.text):
144
+ continue
145
+ ending = sent.text.strip().rstrip("。!?!?")
146
+ style = (
147
+ "polite" if POLITE_END_RE.search(ending)
148
+ else "plain" if PLAIN_END_RE.search(ending)
149
+ else None
150
+ )
151
+ if style and first_style and style != first_style:
152
+ add(
153
+ findings, sentence_line(block, sent.start_char),
154
+ "mixed_sentence_style", "warning",
155
+ "同じ段落に敬体と常体が混在しています。引用や意図した使い分けか確認します。",
156
+ sent.text,
157
+ )
158
+ break
159
+ if style:
160
+ first_style = style
161
+
162
+
103
163
  def analyze(path: Path, genre: str, reading_load: bool) -> dict:
104
164
  text = read_text(path)
105
165
  blocks = iter_prose_blocks(text)
@@ -110,12 +170,17 @@ def analyze(path: Path, genre: str, reading_load: bool) -> dict:
110
170
  paragraph_leads: list[tuple[int, str]] = []
111
171
 
112
172
  for block, doc in zip(blocks, nlp.pipe([item.text for item in blocks])):
173
+ if not reading_load:
174
+ proofread(block, doc, findings)
113
175
  paragraph_tokens = content_tokens(doc)
114
176
  if paragraph_tokens:
115
177
  paragraph_leads.append((block.start_line, paragraph_tokens[0].lemma_))
116
178
 
117
179
  for phrase, (category, message) in PHRASE_RULES.items():
118
- if not reading_load and phrase in block.text:
180
+ pattern = re.escape(phrase)
181
+ if category == "formulaic_conclusion":
182
+ pattern += r"(?!か|[??])"
183
+ if not reading_load and re.search(pattern, block.text):
119
184
  add(findings, block.start_line, category, "warning", message, block.text)
120
185
 
121
186
  if not reading_load: