jupytermind 0.3.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/.github/skills/ai-chemistry-scientist/SKILL.md +97 -0
- package/.github/skills/ai-chemistry-scientist/manifest.json +156 -0
- package/.github/skills/ai-data-scientist/SKILL.md +330 -0
- package/.github/skills/ai-genomics-scientist/SKILL.md +98 -0
- package/.github/skills/ai-genomics-scientist/manifest.json +93 -0
- package/.github/skills/ai-materials-scientist/SKILL.md +51 -0
- package/.github/skills/ai-materials-scientist/manifest.json +58 -0
- package/.github/skills/ai-scientist/SKILL.md +69 -0
- package/.github/skills/ai-scientist/manifest.json +61 -0
- package/.github/skills/ai-structural-biology-scientist/SKILL.md +67 -0
- package/.github/skills/ai-structural-biology-scientist/manifest.json +72 -0
- package/.github/skills/japanese-prose/NOTICE.md +17 -0
- package/.github/skills/japanese-prose/SKILL.md +111 -0
- package/.github/skills/japanese-prose/references/review-workflow.md +50 -0
- package/.github/skills/japanese-prose/references/scoring.md +24 -0
- package/.github/skills/japanese-prose/references/writing-guidelines.md +60 -0
- package/.github/skills/japanese-prose/scripts/core.py +192 -0
- package/.github/skills/japanese-prose/scripts/fixtures/natural.md +5 -0
- package/.github/skills/japanese-prose/scripts/fixtures/unnatural.md +5 -0
- package/.github/skills/japanese-prose/scripts/lint.py +378 -0
- package/.github/skills/japanese-prose/scripts/outline.py +68 -0
- package/.github/skills/japanese-prose/scripts/terms.py +112 -0
- package/.github/skills/japanese-prose/scripts/test_engine.py +117 -0
- package/.github/skills/presentation-planner/SKILL.md +257 -0
- package/.github/skills/presentation-planner/assets/design-templates/data-report.yaml +97 -0
- package/.github/skills/presentation-planner/assets/design-templates/executive-proposal.yaml +92 -0
- package/.github/skills/presentation-planner/assets/design-templates/technical-briefing.yaml +96 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/data-report.md +47 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/executive-decision.md +43 -0
- package/.github/skills/presentation-planner/assets/scenario-templates/technical-briefing.md +45 -0
- package/.github/skills/presentation-planner/references/customizing-design-templates.md +160 -0
- package/.github/skills/presentation-planner/references/design-spec-schema.md +72 -0
- package/.github/skills/presentation-planner/references/handoff-contract.md +49 -0
- package/.github/skills/presentation-planner/references/responsibility-boundary.md +32 -0
- package/.github/skills/presentation-planner/references/scenario-templates.md +55 -0
- package/.github/skills/tech-writer/SKILL.md +434 -0
- package/.github/skills/tech-writer/assets/templates/blueprint.md +187 -0
- package/.github/skills/tech-writer/assets/templates/design-doc.md +29 -0
- package/.github/skills/tech-writer/assets/templates/migration-plan.md +173 -0
- package/.github/skills/tech-writer/assets/templates/operations-runbook.md +202 -0
- package/.github/skills/tech-writer/assets/templates/pr-description.md +23 -0
- package/.github/skills/tech-writer/assets/templates/qiita.md +44 -0
- package/.github/skills/tech-writer/assets/templates/readme.md +38 -0
- package/.github/skills/tech-writer/assets/templates/requirements-definition.md +170 -0
- package/.github/skills/tech-writer/assets/templates/rfi.md +113 -0
- package/.github/skills/tech-writer/assets/templates/rfp.md +180 -0
- package/.github/skills/tech-writer/assets/templates/security-design.md +167 -0
- package/.github/skills/tech-writer/assets/templates/system-design.md +220 -0
- package/.github/skills/tech-writer/assets/templates/technical-proposal.md +112 -0
- package/.github/skills/tech-writer/assets/templates/test-plan.md +153 -0
- package/.github/skills/tech-writer/assets/templates/user-manual.md +22 -0
- package/.github/skills/tech-writer/assets/templates/white-paper.md +192 -0
- package/.github/skills/tech-writer/references/doctypes/api-docs.md +33 -0
- package/.github/skills/tech-writer/references/doctypes/blueprint.md +81 -0
- package/.github/skills/tech-writer/references/doctypes/code-comments.md +39 -0
- package/.github/skills/tech-writer/references/doctypes/design-doc.md +42 -0
- package/.github/skills/tech-writer/references/doctypes/migration-plan.md +63 -0
- package/.github/skills/tech-writer/references/doctypes/operations-runbook.md +63 -0
- package/.github/skills/tech-writer/references/doctypes/pr-commit.md +82 -0
- package/.github/skills/tech-writer/references/doctypes/qiita.md +75 -0
- package/.github/skills/tech-writer/references/doctypes/readme.md +43 -0
- package/.github/skills/tech-writer/references/doctypes/release-notes.md +30 -0
- package/.github/skills/tech-writer/references/doctypes/requirements-definition.md +61 -0
- package/.github/skills/tech-writer/references/doctypes/rfi.md +43 -0
- package/.github/skills/tech-writer/references/doctypes/rfp.md +46 -0
- package/.github/skills/tech-writer/references/doctypes/security-design.md +71 -0
- package/.github/skills/tech-writer/references/doctypes/system-design.md +74 -0
- package/.github/skills/tech-writer/references/doctypes/technical-proposal.md +49 -0
- package/.github/skills/tech-writer/references/doctypes/test-plan.md +67 -0
- package/.github/skills/tech-writer/references/doctypes/user-manual.md +58 -0
- package/.github/skills/tech-writer/references/doctypes/white-paper.md +84 -0
- package/.github/skills/tech-writer/references/doctypes/zenn.md +66 -0
- package/.github/skills/tech-writer/references/japanese-prose-optimization.md +110 -0
- package/.github/skills/tech-writer/references/style-constitution.md +104 -0
- package/.github/skills/tech-writer/scripts/lint.py +412 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/bin/ai-data-scientist.js +123 -0
- package/package.json +41 -0
- package/pyproject.toml +45 -0
- package/src/ai_chemistry_scientist/__init__.py +0 -0
- package/src/ai_chemistry_scientist/admet_prediction.py +71 -0
- package/src/ai_chemistry_scientist/bioactivity_classification.py +73 -0
- package/src/ai_chemistry_scientist/data/sample_molecules.csv +21 -0
- package/src/ai_chemistry_scientist/dispatch.py +369 -0
- package/src/ai_chemistry_scientist/docking_score.py +97 -0
- package/src/ai_chemistry_scientist/drug_likeness_rules.py +84 -0
- package/src/ai_chemistry_scientist/evidence.py +41 -0
- package/src/ai_chemistry_scientist/molecular_descriptors.py +97 -0
- package/src/ai_chemistry_scientist/molecular_formula_mass.py +40 -0
- package/src/ai_chemistry_scientist/molecular_similarity.py +78 -0
- package/src/ai_chemistry_scientist/qsar_modeling.py +105 -0
- package/src/ai_chemistry_scientist/salt_standardization.py +81 -0
- package/src/ai_chemistry_scientist/structural_alerts.py +76 -0
- package/src/ai_chemistry_scientist/structure_format_conversion.py +84 -0
- package/src/ai_chemistry_scientist/validation.py +70 -0
- package/src/ai_data_scientist/__init__.py +0 -0
- package/src/ai_data_scientist/analysis_assumptions.py +121 -0
- package/src/ai_data_scientist/anomaly_detection.py +39 -0
- package/src/ai_data_scientist/automl.py +109 -0
- package/src/ai_data_scientist/cleaning.py +56 -0
- package/src/ai_data_scientist/cli.py +90 -0
- package/src/ai_data_scientist/clustering.py +54 -0
- package/src/ai_data_scientist/dashboard.py +33 -0
- package/src/ai_data_scientist/data_definition.py +100 -0
- package/src/ai_data_scientist/data_quality.py +164 -0
- package/src/ai_data_scientist/dataset_validation.py +135 -0
- package/src/ai_data_scientist/dependency_pins.py +60 -0
- package/src/ai_data_scientist/eda.py +82 -0
- package/src/ai_data_scientist/experiment_evaluation.py +635 -0
- package/src/ai_data_scientist/explainability.py +340 -0
- package/src/ai_data_scientist/feature_engineering.py +163 -0
- package/src/ai_data_scientist/gate_config.py +32 -0
- package/src/ai_data_scientist/ingestion.py +127 -0
- package/src/ai_data_scientist/insight_engine.py +180 -0
- package/src/ai_data_scientist/japanese_nlp.py +43 -0
- package/src/ai_data_scientist/jupyter_launcher.py +137 -0
- package/src/ai_data_scientist/jupyter_mcp_client.py +94 -0
- package/src/ai_data_scientist/language_router.py +28 -0
- package/src/ai_data_scientist/lifecycle.py +221 -0
- package/src/ai_data_scientist/mcp_gateway.py +113 -0
- package/src/ai_data_scientist/mcp_runtime.py +194 -0
- package/src/ai_data_scientist/mcp_transport.py +53 -0
- package/src/ai_data_scientist/ml_modeling.py +451 -0
- package/src/ai_data_scientist/model_tuning.py +104 -0
- package/src/ai_data_scientist/notebook_audit.py +574 -0
- package/src/ai_data_scientist/project_manager.py +243 -0
- package/src/ai_data_scientist/report_export.py +73 -0
- package/src/ai_data_scientist/sensitivity.py +445 -0
- package/src/ai_data_scientist/signal_analysis.py +201 -0
- package/src/ai_data_scientist/skill_packaging.py +40 -0
- package/src/ai_data_scientist/stats_analysis.py +88 -0
- package/src/ai_data_scientist/text_nlp.py +44 -0
- package/src/ai_data_scientist/timeseries.py +68 -0
- package/src/ai_data_scientist/visualization.py +708 -0
- package/src/ai_genomics_scientist/__init__.py +1 -0
- package/src/ai_genomics_scientist/differential_expression.py +147 -0
- package/src/ai_genomics_scientist/dispatch.py +267 -0
- package/src/ai_genomics_scientist/evidence.py +45 -0
- package/src/ai_genomics_scientist/gene_set_enrichment.py +76 -0
- package/src/ai_genomics_scientist/sequence_alignment.py +97 -0
- package/src/ai_genomics_scientist/sequence_features.py +111 -0
- package/src/ai_genomics_scientist/splice_site_scoring.py +66 -0
- package/src/ai_genomics_scientist/validation.py +83 -0
- package/src/ai_genomics_scientist/variant_effect.py +147 -0
- package/src/ai_genomics_scientist/variant_pathogenicity.py +125 -0
- package/src/ai_materials_scientist/__init__.py +0 -0
- package/src/ai_materials_scientist/calphad.py +117 -0
- package/src/ai_materials_scientist/classical_monte_carlo.py +165 -0
- package/src/ai_materials_scientist/crystal_plasticity.py +184 -0
- package/src/ai_materials_scientist/dispatch.py +100 -0
- package/src/ai_materials_scientist/evidence.py +84 -0
- package/src/ai_materials_scientist/fem.py +279 -0
- package/src/ai_materials_scientist/kinetic_monte_carlo.py +145 -0
- package/src/ai_materials_scientist/molecular_dynamics.py +240 -0
- package/src/ai_materials_scientist/phase_field.py +167 -0
- package/src/ai_materials_scientist/validation.py +70 -0
- package/src/ai_scientist/__init__.py +1 -0
- package/src/ai_scientist/completion_gate.py +15 -0
- package/src/ai_scientist/data_analysis.py +46 -0
- package/src/ai_scientist/evidence_registry.py +99 -0
- package/src/ai_scientist/experimental_design.py +20 -0
- package/src/ai_scientist/language.py +14 -0
- package/src/ai_scientist/latex_renderer.py +41 -0
- package/src/ai_scientist/literature_review.py +37 -0
- package/src/ai_scientist/manifest.py +87 -0
- package/src/ai_scientist/manuscript.py +94 -0
- package/src/ai_scientist/mcp_config.py +76 -0
- package/src/ai_scientist/mcp_external.py +42 -0
- package/src/ai_scientist/mcp_failures.py +23 -0
- package/src/ai_scientist/mcp_gateway.py +38 -0
- package/src/ai_scientist/mcp_managed.py +180 -0
- package/src/ai_scientist/npm_packaging.py +49 -0
- package/src/ai_scientist/orchestrator.py +133 -0
- package/src/ai_scientist/peer_review.py +60 -0
- package/src/ai_scientist/phase_gate.py +74 -0
- package/src/ai_scientist/phase_state.py +230 -0
- package/src/ai_scientist/presentation.py +56 -0
- package/src/ai_scientist/project_config.py +31 -0
- package/src/ai_scientist/project_handle.py +74 -0
- package/src/ai_scientist/reproducibility.py +20 -0
- package/src/ai_scientist/research_planning.py +20 -0
- package/src/ai_scientist/skill_invocation.py +21 -0
- package/src/ai_scientist/tdd_gate.py +99 -0
- package/src/ai_structural_biology_scientist/__init__.py +0 -0
- package/src/ai_structural_biology_scientist/contact_map.py +87 -0
- package/src/ai_structural_biology_scientist/dispatch.py +269 -0
- package/src/ai_structural_biology_scientist/evidence.py +43 -0
- package/src/ai_structural_biology_scientist/hydrophobicity.py +101 -0
- package/src/ai_structural_biology_scientist/protein_docking_score.py +104 -0
- package/src/ai_structural_biology_scientist/secondary_structure.py +95 -0
- package/src/ai_structural_biology_scientist/structural_similarity.py +74 -0
- package/src/ai_structural_biology_scientist/validation.py +100 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "ginza>=5.2.0,<5.3",
|
|
6
|
+
# "ja-ginza>=5.2.0,<5.3",
|
|
7
|
+
# "spacy>=3.8.0,<4",
|
|
8
|
+
# ]
|
|
9
|
+
# ///
|
|
10
|
+
"""GiNZA-based Japanese prose diagnostics developed for kotonoha."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import statistics
|
|
18
|
+
import sys
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from core import (
|
|
23
|
+
Finding,
|
|
24
|
+
content_tokens,
|
|
25
|
+
dependency_depth,
|
|
26
|
+
excerpt,
|
|
27
|
+
finding_key,
|
|
28
|
+
iter_prose_blocks,
|
|
29
|
+
load_ginza,
|
|
30
|
+
read_text,
|
|
31
|
+
score_findings,
|
|
32
|
+
sentence_line,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
PHRASE_RULES = {
|
|
37
|
+
"と言えるでしょう": ("formulaic_conclusion", "断定を弱める定型句です。根拠か具体的な判断に置き換えます。"),
|
|
38
|
+
"と言えるだろう": ("formulaic_conclusion", "断定を弱める定型句です。根拠か具体的な判断に置き換えます。"),
|
|
39
|
+
"いかがでしょうか": ("reader_prompt_cliche", "読者への定型的な呼びかけです。必要な行動を直接示します。"),
|
|
40
|
+
"結論として": ("formulaic_transition", "結論の内容を見出しまたは先頭文で直接示します。"),
|
|
41
|
+
"以下のとおりです": ("empty_preview", "後続内容の要点を先に示します。"),
|
|
42
|
+
"重要なポイント": ("abstract_emphasis", "何が重要かを具体的な条件や数値で示します。"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
TRANSLATIONESE_RULES = (
|
|
46
|
+
(
|
|
47
|
+
re.compile(r"することが(?:可能|できます|できる)"),
|
|
48
|
+
"indirect_ability",
|
|
49
|
+
"「できる」または具体的な動作へ簡潔にします。",
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
re.compile(r"において(?:は|、)?"),
|
|
53
|
+
"heavy_location_phrase",
|
|
54
|
+
"場所・場面を表すなら「で」で意味が保てるか確認します。",
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
re.compile(r"を通じて(?:、)?"),
|
|
58
|
+
"through_translation",
|
|
59
|
+
"手段・期間・経験のどれを表すか具体化します。",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
CONNECTIVES = {
|
|
64
|
+
"また",
|
|
65
|
+
"さらに",
|
|
66
|
+
"一方",
|
|
67
|
+
"そのため",
|
|
68
|
+
"したがって",
|
|
69
|
+
"しかし",
|
|
70
|
+
"なお",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
DOUBLE_NEGATIVE_RULES = (
|
|
74
|
+
re.compile(r"ないとは言えない"),
|
|
75
|
+
re.compile(r"ないわけではない"),
|
|
76
|
+
re.compile(r"ないことはない"),
|
|
77
|
+
re.compile(r"なくはない"),
|
|
78
|
+
re.compile(r"なくてはならない"),
|
|
79
|
+
re.compile(r"ざるを得ない"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
GENRE_LIMITS = {
|
|
83
|
+
"general": {"length": 90, "critical_length": 150, "commas": 5, "depth": 8},
|
|
84
|
+
"tech": {"length": 110, "critical_length": 180, "commas": 6, "depth": 9},
|
|
85
|
+
"business": {"length": 80, "critical_length": 140, "commas": 5, "depth": 8},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def add(
|
|
90
|
+
findings: list[Finding],
|
|
91
|
+
line: int,
|
|
92
|
+
category: str,
|
|
93
|
+
severity: str,
|
|
94
|
+
message: str,
|
|
95
|
+
text: str,
|
|
96
|
+
evidence: dict | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
findings.append(
|
|
99
|
+
Finding(line, category, severity, message, excerpt(text), evidence)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def analyze(path: Path, genre: str, reading_load: bool) -> dict:
|
|
104
|
+
text = read_text(path)
|
|
105
|
+
blocks = iter_prose_blocks(text)
|
|
106
|
+
nlp = load_ginza(enable_ner=False)
|
|
107
|
+
limits = GENRE_LIMITS[genre]
|
|
108
|
+
findings: list[Finding] = []
|
|
109
|
+
sentence_records: list[dict] = []
|
|
110
|
+
paragraph_leads: list[tuple[int, str]] = []
|
|
111
|
+
|
|
112
|
+
for block, doc in zip(blocks, nlp.pipe([item.text for item in blocks])):
|
|
113
|
+
paragraph_tokens = content_tokens(doc)
|
|
114
|
+
if paragraph_tokens:
|
|
115
|
+
paragraph_leads.append((block.start_line, paragraph_tokens[0].lemma_))
|
|
116
|
+
|
|
117
|
+
for phrase, (category, message) in PHRASE_RULES.items():
|
|
118
|
+
if not reading_load and phrase in block.text:
|
|
119
|
+
add(findings, block.start_line, category, "warning", message, block.text)
|
|
120
|
+
|
|
121
|
+
if not reading_load:
|
|
122
|
+
for pattern, category, message in TRANSLATIONESE_RULES:
|
|
123
|
+
if pattern.search(block.text):
|
|
124
|
+
add(findings, block.start_line, category, "info", message, block.text)
|
|
125
|
+
|
|
126
|
+
for sent in doc.sents:
|
|
127
|
+
tokens = content_tokens(sent)
|
|
128
|
+
if not tokens:
|
|
129
|
+
continue
|
|
130
|
+
sent_text = sent.text.strip()
|
|
131
|
+
line = sentence_line(block, sent.start_char)
|
|
132
|
+
length = len(re.sub(r"\s+", "", sent_text))
|
|
133
|
+
comma_count = sent_text.count("、")
|
|
134
|
+
max_depth = max((dependency_depth(token) for token in tokens), default=0)
|
|
135
|
+
|
|
136
|
+
noun_run = 0
|
|
137
|
+
max_noun_run = 0
|
|
138
|
+
genitive_count = 0
|
|
139
|
+
max_genitive_count = 0
|
|
140
|
+
for token in sent:
|
|
141
|
+
if token.is_space or token.is_punct or token.pos_ == "SYM":
|
|
142
|
+
noun_run = 0
|
|
143
|
+
genitive_count = 0
|
|
144
|
+
continue
|
|
145
|
+
if token.pos_ in {"NOUN", "PROPN"}:
|
|
146
|
+
noun_run += 1
|
|
147
|
+
max_noun_run = max(max_noun_run, noun_run)
|
|
148
|
+
else:
|
|
149
|
+
noun_run = 0
|
|
150
|
+
if token.lemma_ == "の" and token.pos_ == "ADP":
|
|
151
|
+
genitive_count += 1
|
|
152
|
+
max_genitive_count = max(max_genitive_count, genitive_count)
|
|
153
|
+
elif token.pos_ in {"VERB", "AUX"}:
|
|
154
|
+
genitive_count = 0
|
|
155
|
+
|
|
156
|
+
first_lemmas = tuple(token.lemma_ for token in tokens[:2])
|
|
157
|
+
final_pos = tokens[-1].pos_
|
|
158
|
+
verb_count = sum(
|
|
159
|
+
token.pos_ in {"VERB", "AUX"} for token in tokens
|
|
160
|
+
)
|
|
161
|
+
enumeration = comma_count > limits["commas"] and verb_count <= 1
|
|
162
|
+
sentence_records.append(
|
|
163
|
+
{
|
|
164
|
+
"line": line,
|
|
165
|
+
"text": sent_text,
|
|
166
|
+
"length": length,
|
|
167
|
+
"first": first_lemmas,
|
|
168
|
+
"final_pos": final_pos,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if length > limits["length"] and not enumeration:
|
|
173
|
+
severity = (
|
|
174
|
+
"critical"
|
|
175
|
+
if length > limits["critical_length"]
|
|
176
|
+
else "warning"
|
|
177
|
+
)
|
|
178
|
+
add(
|
|
179
|
+
findings,
|
|
180
|
+
line,
|
|
181
|
+
"long_sentence",
|
|
182
|
+
severity,
|
|
183
|
+
"一文が長く、係り受けを保持しにくい状態です。意味単位で分割できるか確認します。",
|
|
184
|
+
sent_text,
|
|
185
|
+
{"characters": length, "threshold": limits["length"]},
|
|
186
|
+
)
|
|
187
|
+
if comma_count > limits["commas"] and not enumeration:
|
|
188
|
+
add(
|
|
189
|
+
findings,
|
|
190
|
+
line,
|
|
191
|
+
"comma_overload",
|
|
192
|
+
"warning",
|
|
193
|
+
"読点が多く、複数の論点が一文に入っている可能性があります。",
|
|
194
|
+
sent_text,
|
|
195
|
+
{"commas": comma_count, "threshold": limits["commas"]},
|
|
196
|
+
)
|
|
197
|
+
if max_depth > limits["depth"] and not enumeration:
|
|
198
|
+
add(
|
|
199
|
+
findings,
|
|
200
|
+
line,
|
|
201
|
+
"deep_dependency",
|
|
202
|
+
"warning",
|
|
203
|
+
"係り受けが深く、文の骨格を追う負荷が高い状態です。",
|
|
204
|
+
sent_text,
|
|
205
|
+
{"depth": max_depth, "threshold": limits["depth"]},
|
|
206
|
+
)
|
|
207
|
+
if max_noun_run >= 6:
|
|
208
|
+
add(
|
|
209
|
+
findings,
|
|
210
|
+
line,
|
|
211
|
+
"noun_chain",
|
|
212
|
+
"warning",
|
|
213
|
+
"名詞が連続しています。助詞や述語を補って関係を明示します。",
|
|
214
|
+
sent_text,
|
|
215
|
+
{"consecutive_nouns": max_noun_run},
|
|
216
|
+
)
|
|
217
|
+
if max_genitive_count >= 3:
|
|
218
|
+
add(
|
|
219
|
+
findings,
|
|
220
|
+
line,
|
|
221
|
+
"genitive_chain",
|
|
222
|
+
"info",
|
|
223
|
+
"「の」が重なっています。所有・対象・所属の関係を分けられるか確認します。",
|
|
224
|
+
sent_text,
|
|
225
|
+
{"consecutive_genitives": max_genitive_count},
|
|
226
|
+
)
|
|
227
|
+
if any(pattern.search(sent_text) for pattern in DOUBLE_NEGATIVE_RULES):
|
|
228
|
+
add(
|
|
229
|
+
findings,
|
|
230
|
+
line,
|
|
231
|
+
"double_negative",
|
|
232
|
+
"warning",
|
|
233
|
+
"否定が重なっています。肯定文で同じ意味を表せるか確認します。",
|
|
234
|
+
sent_text,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not reading_load:
|
|
238
|
+
for previous, current in zip(sentence_records, sentence_records[1:]):
|
|
239
|
+
if current["first"] and current["first"] == previous["first"]:
|
|
240
|
+
add(
|
|
241
|
+
findings,
|
|
242
|
+
current["line"],
|
|
243
|
+
"repeated_sentence_lead",
|
|
244
|
+
"info",
|
|
245
|
+
"連続する文が同じ語順で始まっています。意図した反復か確認します。",
|
|
246
|
+
current["text"],
|
|
247
|
+
{"lemmas": list(current["first"])},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if len(sentence_records) >= 5:
|
|
251
|
+
lengths = [record["length"] for record in sentence_records]
|
|
252
|
+
mean = statistics.mean(lengths)
|
|
253
|
+
if mean and statistics.pstdev(lengths) / mean < 0.16:
|
|
254
|
+
add(
|
|
255
|
+
findings,
|
|
256
|
+
0,
|
|
257
|
+
"uniform_sentence_rhythm",
|
|
258
|
+
"info",
|
|
259
|
+
"文長が均一です。重要度に応じた長短があるか通読します。",
|
|
260
|
+
"文書全体の文長分布",
|
|
261
|
+
{"coefficient_of_variation": round(statistics.pstdev(lengths) / mean, 3)},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
nominal_count = sum(
|
|
265
|
+
record["final_pos"] in {"NOUN", "PROPN"}
|
|
266
|
+
for record in sentence_records
|
|
267
|
+
)
|
|
268
|
+
if nominal_count / len(sentence_records) >= 0.55:
|
|
269
|
+
add(
|
|
270
|
+
findings,
|
|
271
|
+
0,
|
|
272
|
+
"nominal_ending_density",
|
|
273
|
+
"info",
|
|
274
|
+
"体言止めが多く、説明の関係が省略されている可能性があります。",
|
|
275
|
+
"文書全体の文末分布",
|
|
276
|
+
{
|
|
277
|
+
"ratio": round(nominal_count / len(sentence_records), 3),
|
|
278
|
+
"sentences": len(sentence_records),
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
lead_counts = Counter(lemma for _, lemma in paragraph_leads if lemma in CONNECTIVES)
|
|
283
|
+
for lemma, count in lead_counts.items():
|
|
284
|
+
if count >= 3:
|
|
285
|
+
line = next(line for line, value in paragraph_leads if value == lemma)
|
|
286
|
+
add(
|
|
287
|
+
findings,
|
|
288
|
+
line,
|
|
289
|
+
"repeated_paragraph_connective",
|
|
290
|
+
"info",
|
|
291
|
+
"複数段落が同じ接続表現で始まっています。",
|
|
292
|
+
lemma,
|
|
293
|
+
{"connective": lemma, "count": count},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
findings.sort(key=lambda item: (item.line, item.category))
|
|
297
|
+
return {
|
|
298
|
+
"file": str(path),
|
|
299
|
+
"engine": "GiNZA",
|
|
300
|
+
"model": nlp.meta.get("name", "ja_ginza"),
|
|
301
|
+
"genre": genre,
|
|
302
|
+
"mode": "reading-load" if reading_load else "prose",
|
|
303
|
+
"score": score_findings(findings),
|
|
304
|
+
"finding_count": len(findings),
|
|
305
|
+
"findings": [item.to_dict() for item in findings],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def compare_baseline(result: dict, baseline_path: Path) -> None:
|
|
310
|
+
baseline = json.loads(read_text(baseline_path))
|
|
311
|
+
previous = {
|
|
312
|
+
finding_key(
|
|
313
|
+
Finding(
|
|
314
|
+
item.get("line", 0),
|
|
315
|
+
item["category"],
|
|
316
|
+
item.get("severity", "warning"),
|
|
317
|
+
item.get("message", ""),
|
|
318
|
+
item.get("excerpt", ""),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
for item in baseline.get("findings", [])
|
|
322
|
+
}
|
|
323
|
+
current = set()
|
|
324
|
+
for item in result["findings"]:
|
|
325
|
+
finding = Finding(
|
|
326
|
+
item["line"],
|
|
327
|
+
item["category"],
|
|
328
|
+
item["severity"],
|
|
329
|
+
item["message"],
|
|
330
|
+
item["excerpt"],
|
|
331
|
+
item.get("evidence"),
|
|
332
|
+
)
|
|
333
|
+
key = finding_key(finding)
|
|
334
|
+
current.add(key)
|
|
335
|
+
item["status"] = "persisting" if key in previous else "new"
|
|
336
|
+
result["resolved_count"] = len(previous - current)
|
|
337
|
+
result["new_count"] = len(current - previous)
|
|
338
|
+
result["persisting_count"] = len(current & previous)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> int:
|
|
342
|
+
parser = argparse.ArgumentParser(description="GiNZA-based Japanese prose lint")
|
|
343
|
+
parser.add_argument("file")
|
|
344
|
+
parser.add_argument("--genre", choices=GENRE_LIMITS, default="general")
|
|
345
|
+
parser.add_argument("--reading-load", action="store_true")
|
|
346
|
+
parser.add_argument("--baseline")
|
|
347
|
+
parser.add_argument("--json", action="store_true")
|
|
348
|
+
args = parser.parse_args()
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
result = analyze(Path(args.file), args.genre, args.reading_load)
|
|
352
|
+
if args.baseline:
|
|
353
|
+
compare_baseline(result, Path(args.baseline))
|
|
354
|
+
except (OSError, ValueError) as error:
|
|
355
|
+
print(f"error: {error}", file=sys.stderr)
|
|
356
|
+
return 1
|
|
357
|
+
except Exception as error:
|
|
358
|
+
print(f"error: GiNZA analysis failed: {error}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
|
|
361
|
+
if args.json:
|
|
362
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
363
|
+
else:
|
|
364
|
+
print(
|
|
365
|
+
f"{result['file']}: score {result['score']}/100, "
|
|
366
|
+
f"{result['finding_count']} finding(s)"
|
|
367
|
+
)
|
|
368
|
+
for finding in result["findings"]:
|
|
369
|
+
print(
|
|
370
|
+
f" L{finding['line']} [{finding['severity']}] "
|
|
371
|
+
f"{finding['category']}: {finding['message']}"
|
|
372
|
+
)
|
|
373
|
+
print(f" > {finding['excerpt']}")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
if __name__ == "__main__":
|
|
378
|
+
sys.exit(main())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract headings and paragraph openings from a Markdown document."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from core import excerpt, iter_content_lines, iter_prose_blocks, read_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HEADING_CAPTURE_RE = re.compile(r"^\s{0,3}(#{1,6})\s+(.+?)\s*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract(path: Path) -> dict:
|
|
19
|
+
text = read_text(path)
|
|
20
|
+
headings = []
|
|
21
|
+
for line_number, line in iter_content_lines(text):
|
|
22
|
+
match = HEADING_CAPTURE_RE.match(line)
|
|
23
|
+
if match:
|
|
24
|
+
headings.append(
|
|
25
|
+
{
|
|
26
|
+
"line": line_number,
|
|
27
|
+
"level": len(match.group(1)),
|
|
28
|
+
"text": match.group(2),
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
paragraphs = [
|
|
32
|
+
{"line": block.start_line, "opening": excerpt(block.text, 140)}
|
|
33
|
+
for block in iter_prose_blocks(text)
|
|
34
|
+
]
|
|
35
|
+
return {
|
|
36
|
+
"file": str(path),
|
|
37
|
+
"heading_count": len(headings),
|
|
38
|
+
"paragraph_count": len(paragraphs),
|
|
39
|
+
"headings": headings,
|
|
40
|
+
"paragraphs": paragraphs,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> int:
|
|
45
|
+
parser = argparse.ArgumentParser(description="Extract a Markdown skeleton")
|
|
46
|
+
parser.add_argument("file")
|
|
47
|
+
parser.add_argument("--json", action="store_true")
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
try:
|
|
50
|
+
result = extract(Path(args.file))
|
|
51
|
+
except OSError as error:
|
|
52
|
+
print(f"error: {error}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
if args.json:
|
|
56
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
57
|
+
else:
|
|
58
|
+
for heading in result["headings"]:
|
|
59
|
+
print(
|
|
60
|
+
f"L{heading['line']} H{heading['level']} {heading['text']}"
|
|
61
|
+
)
|
|
62
|
+
for paragraph in result["paragraphs"]:
|
|
63
|
+
print(f"L{paragraph['line']} P {paragraph['opening']}")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "ginza>=5.2.0,<5.3",
|
|
6
|
+
# "ja-ginza>=5.2.0,<5.3",
|
|
7
|
+
# "spacy>=3.8.0,<4",
|
|
8
|
+
# ]
|
|
9
|
+
# ///
|
|
10
|
+
"""Extract terminology candidates with GiNZA POS and entity analysis."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from core import iter_prose_blocks, load_ginza, read_text, sentence_line
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ASCII_TERM_RE = re.compile(r"^[A-Za-z][A-Za-z0-9.+#/_-]{1,}$")
|
|
25
|
+
EXPLANATION_MARKERS = ("とは", "を指す", "と呼ぶ", "(", "(")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_candidate(token) -> bool:
|
|
29
|
+
if token.ent_type_:
|
|
30
|
+
return True
|
|
31
|
+
if token.pos_ == "PROPN":
|
|
32
|
+
return True
|
|
33
|
+
return bool(ASCII_TERM_RE.match(token.text))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract(path: Path) -> dict:
|
|
37
|
+
text = read_text(path)
|
|
38
|
+
blocks = iter_prose_blocks(text)
|
|
39
|
+
nlp = load_ginza(enable_ner=True)
|
|
40
|
+
terms: dict[str, dict] = {}
|
|
41
|
+
occurrences: dict[str, list[int]] = defaultdict(list)
|
|
42
|
+
|
|
43
|
+
for block, doc in zip(blocks, nlp.pipe([item.text for item in blocks])):
|
|
44
|
+
for token in doc:
|
|
45
|
+
if not is_candidate(token):
|
|
46
|
+
continue
|
|
47
|
+
value = token.text.strip()
|
|
48
|
+
if len(value) < 2:
|
|
49
|
+
continue
|
|
50
|
+
line = sentence_line(block, token.idx)
|
|
51
|
+
occurrences[value].append(line)
|
|
52
|
+
if value not in terms:
|
|
53
|
+
context = block.text[
|
|
54
|
+
max(0, token.idx - 30) : token.idx + len(value) + 45
|
|
55
|
+
]
|
|
56
|
+
explained = any(
|
|
57
|
+
marker in block.text[token.idx + len(value) : token.idx + len(value) + 24]
|
|
58
|
+
for marker in EXPLANATION_MARKERS
|
|
59
|
+
)
|
|
60
|
+
terms[value] = {
|
|
61
|
+
"term": value,
|
|
62
|
+
"lemma": token.lemma_,
|
|
63
|
+
"pos": token.pos_,
|
|
64
|
+
"entity": token.ent_type_ or None,
|
|
65
|
+
"first_line": line,
|
|
66
|
+
"explained_near_first_use": explained,
|
|
67
|
+
"context": re.sub(r"\s+", " ", context).strip(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
result_terms = []
|
|
71
|
+
for value, item in terms.items():
|
|
72
|
+
item["count"] = len(occurrences[value])
|
|
73
|
+
item["lines"] = sorted(set(occurrences[value]))
|
|
74
|
+
result_terms.append(item)
|
|
75
|
+
result_terms.sort(key=lambda item: (item["first_line"], item["term"]))
|
|
76
|
+
return {
|
|
77
|
+
"file": str(path),
|
|
78
|
+
"engine": "GiNZA",
|
|
79
|
+
"term_count": len(result_terms),
|
|
80
|
+
"terms": result_terms,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> int:
|
|
85
|
+
parser = argparse.ArgumentParser(description="Extract terminology candidates")
|
|
86
|
+
parser.add_argument("file")
|
|
87
|
+
parser.add_argument("--json", action="store_true")
|
|
88
|
+
args = parser.parse_args()
|
|
89
|
+
try:
|
|
90
|
+
result = extract(Path(args.file))
|
|
91
|
+
except OSError as error:
|
|
92
|
+
print(f"error: {error}", file=sys.stderr)
|
|
93
|
+
return 1
|
|
94
|
+
except Exception as error:
|
|
95
|
+
print(f"error: GiNZA analysis failed: {error}", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
if args.json:
|
|
99
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
for item in result["terms"]:
|
|
102
|
+
marker = "explained" if item["explained_near_first_use"] else "review"
|
|
103
|
+
entity = f" {item['entity']}" if item["entity"] else ""
|
|
104
|
+
print(
|
|
105
|
+
f"L{item['first_line']} {item['term']} "
|
|
106
|
+
f"({item['pos']}{entity}, {item['count']} occurrence(s), {marker})"
|
|
107
|
+
)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
sys.exit(main())
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "ginza>=5.2.0,<5.3",
|
|
6
|
+
# "ja-ginza>=5.2.0,<5.3",
|
|
7
|
+
# "spacy>=3.8.0,<4",
|
|
8
|
+
# ]
|
|
9
|
+
# ///
|
|
10
|
+
"""Behavioral tests for kotonoha's original GiNZA diagnostics."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from core import strip_markdown_inline
|
|
20
|
+
from lint import analyze, compare_baseline
|
|
21
|
+
from outline import extract as extract_outline
|
|
22
|
+
from terms import extract as extract_terms
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
26
|
+
FIXTURES = SCRIPT_DIR / "fixtures"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DiagnosticTests(unittest.TestCase):
|
|
30
|
+
def test_unnatural_fixture_scores_below_natural_fixture(self) -> None:
|
|
31
|
+
unnatural = analyze(FIXTURES / "unnatural.md", "tech", False)
|
|
32
|
+
natural = analyze(FIXTURES / "natural.md", "tech", False)
|
|
33
|
+
|
|
34
|
+
self.assertLess(unnatural["score"], natural["score"])
|
|
35
|
+
self.assertEqual(natural["finding_count"], 0)
|
|
36
|
+
categories = {item["category"] for item in unnatural["findings"]}
|
|
37
|
+
self.assertIn("formulaic_conclusion", categories)
|
|
38
|
+
self.assertIn("double_negative", categories)
|
|
39
|
+
self.assertIn("deep_dependency", categories)
|
|
40
|
+
|
|
41
|
+
def test_markdown_code_is_not_analyzed_as_prose(self) -> None:
|
|
42
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
43
|
+
path = Path(directory) / "code.md"
|
|
44
|
+
path.write_text(
|
|
45
|
+
"# Test\n\n自然な説明です。\n\n"
|
|
46
|
+
"```text\n結論として、利用することが可能です。\n```\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
result = analyze(path, "tech", False)
|
|
50
|
+
categories = {item["category"] for item in result["findings"]}
|
|
51
|
+
self.assertNotIn("formulaic_transition", categories)
|
|
52
|
+
self.assertNotIn("indirect_ability", categories)
|
|
53
|
+
|
|
54
|
+
def test_inline_code_is_masked_without_corrupting_plain_identifiers(self) -> None:
|
|
55
|
+
self.assertEqual(strip_markdown_inline("`MAX_RETRY_COUNT`"), "コード")
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
strip_markdown_inline("MAX_RETRY_COUNTを確認する"),
|
|
58
|
+
"MAX_RETRY_COUNTを確認する",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_ordinary_conditional_negation_is_not_double_negative(self) -> None:
|
|
62
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
63
|
+
path = Path(directory) / "condition.md"
|
|
64
|
+
path.write_text(
|
|
65
|
+
"# Test\n\n設定が存在しない場合、既定値は適用されません。\n",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
result = analyze(path, "tech", False)
|
|
69
|
+
categories = {item["category"] for item in result["findings"]}
|
|
70
|
+
self.assertNotIn("double_negative", categories)
|
|
71
|
+
|
|
72
|
+
def test_baseline_reports_resolved_findings(self) -> None:
|
|
73
|
+
initial = analyze(FIXTURES / "unnatural.md", "tech", False)
|
|
74
|
+
final = analyze(FIXTURES / "natural.md", "tech", False)
|
|
75
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
76
|
+
baseline = Path(directory) / "baseline.json"
|
|
77
|
+
baseline.write_text(
|
|
78
|
+
json.dumps(initial, ensure_ascii=False),
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
compare_baseline(final, baseline)
|
|
82
|
+
self.assertGreater(final["resolved_count"], 0)
|
|
83
|
+
self.assertEqual(final["new_count"], 0)
|
|
84
|
+
|
|
85
|
+
def test_outline_and_terms_return_structured_results(self) -> None:
|
|
86
|
+
outline = extract_outline(FIXTURES / "natural.md")
|
|
87
|
+
terms = extract_terms(FIXTURES / "natural.md")
|
|
88
|
+
self.assertEqual(outline["heading_count"], 1)
|
|
89
|
+
self.assertGreater(outline["paragraph_count"], 0)
|
|
90
|
+
self.assertIn("terms", terms)
|
|
91
|
+
|
|
92
|
+
def test_outline_ignores_headings_inside_code_fences(self) -> None:
|
|
93
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
94
|
+
path = Path(directory) / "outline.md"
|
|
95
|
+
path.write_text(
|
|
96
|
+
"# Real heading\n\n説明です。\n\n"
|
|
97
|
+
"```bash\n# コマンド内のコメント\n```\n",
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
outline = extract_outline(path)
|
|
101
|
+
self.assertEqual(outline["heading_count"], 1)
|
|
102
|
+
self.assertEqual(outline["headings"][0]["text"], "Real heading")
|
|
103
|
+
|
|
104
|
+
def test_terms_ignore_inline_commands(self) -> None:
|
|
105
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
106
|
+
path = Path(directory) / "terms.md"
|
|
107
|
+
path.write_text(
|
|
108
|
+
"# Test\n\n`npm run build --workspace=packages/core`を実行します。\n",
|
|
109
|
+
encoding="utf-8",
|
|
110
|
+
)
|
|
111
|
+
terms = extract_terms(path)
|
|
112
|
+
values = {item["term"] for item in terms["terms"]}
|
|
113
|
+
self.assertTrue(values.isdisjoint({"npm", "run", "build", "workspace", "core"}))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|