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.
- package/CHANGELOG.md +36 -0
- package/README-ja.md +87 -21
- package/README.md +111 -20
- package/package.json +5 -2
- package/skills/consulting-analyst/SKILL.md +241 -0
- package/skills/consulting-analyst/assets/templates/analysis-brief.md +58 -0
- package/skills/consulting-analyst/assets/templates/decision-analysis.md +62 -0
- package/skills/consulting-analyst/assets/templates/hypothesis-evidence-ledger.md +48 -0
- package/skills/consulting-analyst/assets/templates/issue-tree.md +35 -0
- package/skills/consulting-analyst/assets/templates/synthesis-handoff.md +68 -0
- package/skills/consulting-analyst/references/evidence-and-hypotheses.md +80 -0
- package/skills/consulting-analyst/references/framework-selection.md +84 -0
- package/skills/consulting-analyst/references/handoff-contract.md +90 -0
- package/skills/japanese-prose/NOTICE.md +7 -1
- package/skills/japanese-prose/SKILL.md +48 -16
- package/skills/japanese-prose/references/review-workflow.md +57 -0
- package/skills/japanese-prose/references/scoring.md +6 -0
- package/skills/japanese-prose/scripts/core.py +6 -2
- package/skills/japanese-prose/scripts/invariants.py +182 -0
- package/skills/japanese-prose/scripts/lint.py +68 -3
- package/skills/japanese-prose/scripts/test_engine.py +168 -0
- package/skills/presentation-planner/SKILL.md +14 -0
- package/skills/tech-writer/SKILL.md +58 -24
- package/skills/tech-writer/assets/templates/blueprint.md +187 -0
- package/skills/tech-writer/assets/templates/white-paper.md +192 -0
- package/skills/tech-writer/references/doctypes/blueprint.md +81 -0
- package/skills/tech-writer/references/doctypes/white-paper.md +84 -0
- package/skills/tech-writer/references/japanese-prose-optimization.md +17 -8
- package/skills/tech-writer/references/style-constitution.md +10 -4
- 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
|
-
#
|
|
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,
|
|
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.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
79
|
-
|
|
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)
|
|
79
|
+
marker = fence.group(1)
|
|
80
80
|
if not fence_marker:
|
|
81
81
|
fence_marker = marker
|
|
82
|
-
elif
|
|
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
|
-
|
|
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:
|