pairvoice 0.1.0__py3-none-any.whl
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.
- pairvoice/__init__.py +0 -0
- pairvoice/__main__.py +14 -0
- pairvoice/audio_state.py +147 -0
- pairvoice/bundle.py +18 -0
- pairvoice/checker.py +199 -0
- pairvoice/cli.py +361 -0
- pairvoice/client.py +26 -0
- pairvoice/config.py +258 -0
- pairvoice/eval_cases.tsv +26 -0
- pairvoice/evaluation.py +184 -0
- pairvoice/examples/dict.example.tsv +3 -0
- pairvoice/examples/prompt.example.txt +19 -0
- pairvoice/generations.py +47 -0
- pairvoice/install.py +171 -0
- pairvoice/launchd.py +25 -0
- pairvoice/lifecycle.py +475 -0
- pairvoice/llm.py +98 -0
- pairvoice/logs.py +86 -0
- pairvoice/menubar.py +357 -0
- pairvoice/mute.py +163 -0
- pairvoice/player.py +175 -0
- pairvoice/postprocess.py +117 -0
- pairvoice/profiles.py +114 -0
- pairvoice/server.py +219 -0
- pairvoice/studio/package.json +53 -0
- pairvoice/studio/server/app.ts +68 -0
- pairvoice/studio/server/history.ts +122 -0
- pairvoice/studio/server/http.ts +124 -0
- pairvoice/studio/server/paths.ts +30 -0
- pairvoice/studio/server/router.ts +80 -0
- pairvoice/studio/server/routes/corpus.ts +189 -0
- pairvoice/studio/server/routes/dict.ts +96 -0
- pairvoice/studio/server/routes/pairvoice.ts +153 -0
- pairvoice/studio/server/routes/profiles.ts +299 -0
- pairvoice/studio/server/routes/prompt.ts +45 -0
- pairvoice/studio/server/static.ts +51 -0
- pairvoice/studio/server/storage.ts +89 -0
- pairvoice/studio/server.ts +49 -0
- pairvoice/studio/shared/api-types.ts +125 -0
- pairvoice/studio/web/dist/assets/index-CpU73UpB.css +2 -0
- pairvoice/studio/web/dist/assets/index-Cyj45oOs.js +11 -0
- pairvoice/studio/web/dist/index.html +13 -0
- pairvoice/studio_process.py +128 -0
- pairvoice/tts.py +274 -0
- pairvoice-0.1.0.dist-info/METADATA +221 -0
- pairvoice-0.1.0.dist-info/RECORD +49 -0
- pairvoice-0.1.0.dist-info/WHEEL +4 -0
- pairvoice-0.1.0.dist-info/entry_points.txt +2 -0
- pairvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
pairvoice/__init__.py
ADDED
|
File without changes
|
pairvoice/__main__.py
ADDED
pairvoice/audio_state.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""CoreAudio の公開 API で、音声入出力中のプロセスを調べる。
|
|
2
|
+
|
|
3
|
+
追加パッケージも Xcode も要らない。定数は AudioHardware.h の値。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ctypes
|
|
9
|
+
import os
|
|
10
|
+
import struct
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_core_audio = ctypes.CDLL("/System/Library/Frameworks/CoreAudio.framework/CoreAudio")
|
|
16
|
+
_libproc = ctypes.CDLL("/usr/lib/libproc.dylib")
|
|
17
|
+
|
|
18
|
+
_SYSTEM_OBJECT = 1
|
|
19
|
+
_PROC_PIDPATHINFO_MAXSIZE = 4096
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fourcc(code: str) -> int:
|
|
23
|
+
return struct.unpack(">I", code.encode())[0]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SCOPE_GLOBAL = _fourcc("glob")
|
|
27
|
+
_PROCESS_LIST = _fourcc("prs#")
|
|
28
|
+
_PID = _fourcc("ppid")
|
|
29
|
+
_IS_RUNNING_INPUT = _fourcc("piri")
|
|
30
|
+
_IS_RUNNING_OUTPUT = _fourcc("piro")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Address(ctypes.Structure):
|
|
34
|
+
_fields_ = [
|
|
35
|
+
("mSelector", ctypes.c_uint32),
|
|
36
|
+
("mScope", ctypes.c_uint32),
|
|
37
|
+
("mElement", ctypes.c_uint32),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AudioProcess:
|
|
43
|
+
pid: int
|
|
44
|
+
name: str
|
|
45
|
+
input_running: bool
|
|
46
|
+
output_running: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_property(obj: int, selector: int, ctype) -> list | None:
|
|
50
|
+
address = _Address(selector, _SCOPE_GLOBAL, 0)
|
|
51
|
+
size = ctypes.c_uint32(0)
|
|
52
|
+
if (
|
|
53
|
+
_core_audio.AudioObjectGetPropertyDataSize(
|
|
54
|
+
ctypes.c_uint32(obj), ctypes.byref(address), 0, None, ctypes.byref(size)
|
|
55
|
+
)
|
|
56
|
+
!= 0
|
|
57
|
+
):
|
|
58
|
+
return None
|
|
59
|
+
count = size.value // ctypes.sizeof(ctype)
|
|
60
|
+
if count == 0:
|
|
61
|
+
return []
|
|
62
|
+
buffer = (ctype * count)()
|
|
63
|
+
if (
|
|
64
|
+
_core_audio.AudioObjectGetPropertyData(
|
|
65
|
+
ctypes.c_uint32(obj),
|
|
66
|
+
ctypes.byref(address),
|
|
67
|
+
0,
|
|
68
|
+
None,
|
|
69
|
+
ctypes.byref(size),
|
|
70
|
+
ctypes.byref(buffer),
|
|
71
|
+
)
|
|
72
|
+
!= 0
|
|
73
|
+
):
|
|
74
|
+
return None
|
|
75
|
+
return list(buffer)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _process_name(pid: int, cache: dict[int, str]) -> str:
|
|
79
|
+
cached = cache.get(pid)
|
|
80
|
+
if cached is not None:
|
|
81
|
+
return cached
|
|
82
|
+
buffer = ctypes.create_string_buffer(_PROC_PIDPATHINFO_MAXSIZE)
|
|
83
|
+
length = _libproc.proc_pidpath(pid, buffer, _PROC_PIDPATHINFO_MAXSIZE)
|
|
84
|
+
name = Path(buffer.value.decode(errors="replace")).name if length > 0 else ""
|
|
85
|
+
cache[pid] = name
|
|
86
|
+
return name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sample_processes() -> tuple[AudioProcess, ...]:
|
|
90
|
+
# 呼び出しごとに作り直すローカルキャッシュ。プロセスは終了後に pid が
|
|
91
|
+
# 再利用されるため、呼び出しをまたいで保持すると古い名前を返しかねない。
|
|
92
|
+
name_cache: dict[int, str] = {}
|
|
93
|
+
objects = _get_property(_SYSTEM_OBJECT, _PROCESS_LIST, ctypes.c_uint32) or []
|
|
94
|
+
processes = []
|
|
95
|
+
for obj in objects:
|
|
96
|
+
pid_values = _get_property(obj, _PID, ctypes.c_int32)
|
|
97
|
+
if not pid_values:
|
|
98
|
+
continue
|
|
99
|
+
pid = pid_values[0]
|
|
100
|
+
input_values = _get_property(obj, _IS_RUNNING_INPUT, ctypes.c_uint32) or [0]
|
|
101
|
+
output_values = _get_property(obj, _IS_RUNNING_OUTPUT, ctypes.c_uint32) or [0]
|
|
102
|
+
output_running = bool(output_values[0])
|
|
103
|
+
processes.append(
|
|
104
|
+
AudioProcess(
|
|
105
|
+
pid=pid,
|
|
106
|
+
# 名前は出力の除外にしか使わないので、出力中のプロセスだけ引く。
|
|
107
|
+
# 読み上げの間は 0.25 秒ごとに呼ばれる
|
|
108
|
+
name=_process_name(pid, name_cache) if output_running else "",
|
|
109
|
+
input_running=bool(input_values[0]),
|
|
110
|
+
output_running=output_running,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return tuple(processes)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class AudioActivity:
|
|
118
|
+
microphone: bool
|
|
119
|
+
output: bool
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AudioProbe:
|
|
123
|
+
"""呼ばれた時点の入出力の有無を1回だけ取る(約13ms)。
|
|
124
|
+
|
|
125
|
+
再生の直前に確かめたいので、裏で監視して結果を溜めることはしない。
|
|
126
|
+
自分(常駐サーバー)の再生はほかのアプリの音に数えない。
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
ignore_processes: tuple[str, ...] = (),
|
|
132
|
+
sampler: Callable[[], tuple[AudioProcess, ...]] = sample_processes,
|
|
133
|
+
own_pid: int | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._ignored = {name.lower() for name in ignore_processes}
|
|
136
|
+
self._sampler = sampler
|
|
137
|
+
self._own_pid = os.getpid() if own_pid is None else own_pid
|
|
138
|
+
|
|
139
|
+
def sample(self) -> AudioActivity:
|
|
140
|
+
processes = self._sampler()
|
|
141
|
+
return AudioActivity(
|
|
142
|
+
microphone=any(p.input_running for p in processes),
|
|
143
|
+
output=any(
|
|
144
|
+
p.output_running and p.pid != self._own_pid and p.name.lower() not in self._ignored
|
|
145
|
+
for p in processes
|
|
146
|
+
),
|
|
147
|
+
)
|
pairvoice/bundle.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""パッケージに同梱した studio / 雛形の置き場所。DATA_ROOT とは別物。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bundle_root(package_dir: Path) -> Path:
|
|
9
|
+
"""studio/ と examples/ を持つディレクトリ。
|
|
10
|
+
|
|
11
|
+
wheel から入れるとパッケージの中に同梱されている(pyproject の force-include)。
|
|
12
|
+
リポジトリから editable で入れると __file__ は src/pairvoice/ を指したままなので、
|
|
13
|
+
2つ遡ったリポジトリ直下にある。
|
|
14
|
+
"""
|
|
15
|
+
return package_dir if (package_dir / "studio").is_dir() else package_dir.parents[1]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
BUNDLE_ROOT = bundle_root(Path(__file__).resolve().parent)
|
pairvoice/checker.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""読み上げ用の要約を、要約プロンプトが要求する厳守ルールに照らして機械判定する。
|
|
2
|
+
|
|
3
|
+
ヒューリスティックなので、絶対評価ではなく候補どうしの相対比較に使う。
|
|
4
|
+
|
|
5
|
+
ルール:
|
|
6
|
+
1. punct_count : 句点・感嘆符・疑問符(。!?)が出力全体で1個または2個(二文構成まで許可)。
|
|
7
|
+
3個以上は違反。最後の句読点より後に文字が続く場合も違反。
|
|
8
|
+
2. code_ident : ファイルパス・関数名・変数名・コマンド名などのコード識別子を含まない。
|
|
9
|
+
ただし CI・PR のような短い大文字略語は許可する。
|
|
10
|
+
3. imperative : 命令・急かし口調(「〜しな」「〜してきな」「さっさと〜」文末「〜な」)を含まない。
|
|
11
|
+
3・4・7 の「文末」は、二文構成ならそれぞれの文の末尾を指す。
|
|
12
|
+
4. noun_ne : 名詞・形容詞+「ね」止め(「大丈夫ね」「順調ね」)を含まない。
|
|
13
|
+
5. length_emoji : 約30文字程度(許容 10〜45 文字、二文構成なら上限は緩める)、絵文字を含まない。
|
|
14
|
+
6. first_person : 一人称「俺」「僕」「オレ」「ボク」を含まない。
|
|
15
|
+
7. desu_masu : です・ます調の文末(「〜ます。」「〜でした。」「〜ません。」「〜ください。」等)を含まない。
|
|
16
|
+
話者の好みに依存するので、config.toml の [eval] style = "casual" のときだけ
|
|
17
|
+
判定する。既定("any")は文体を問わず、常に PASS にする。
|
|
18
|
+
|
|
19
|
+
判定は単一のテキストに対して行い、各ルールについて pass/fail の bool を返す。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
SENTENCE_END_CHARS = "。!?"
|
|
27
|
+
SENTENCE_PATTERN = re.compile(f"[^{SENTENCE_END_CHARS}]+[{SENTENCE_END_CHARS}]*")
|
|
28
|
+
|
|
29
|
+
# 短い技術略語のホワイトリスト(これらは code_ident 違反として数えない)
|
|
30
|
+
ALLOWED_ACRONYMS = {
|
|
31
|
+
"CI",
|
|
32
|
+
"PR",
|
|
33
|
+
"QA",
|
|
34
|
+
"UI",
|
|
35
|
+
"UX",
|
|
36
|
+
"API",
|
|
37
|
+
"URL",
|
|
38
|
+
"ID",
|
|
39
|
+
"AI",
|
|
40
|
+
"TTS",
|
|
41
|
+
"OK",
|
|
42
|
+
"NG",
|
|
43
|
+
"TODO",
|
|
44
|
+
"FYI",
|
|
45
|
+
"ASMR",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# コード識別子らしさの検出パターン群
|
|
49
|
+
CODE_PATTERNS = [
|
|
50
|
+
r"[a-zA-Z0-9_]+\.(sh|py|js|ts|tsx|jsx|json|md|yml|yaml|txt|log|rb|go|rs|c|cpp|h|java)\b", # ファイル名
|
|
51
|
+
r"/[a-zA-Z0-9_\-./]+/[a-zA-Z0-9_\-./]+", # パスらしき文字列
|
|
52
|
+
r"[a-zA-Z_][a-zA-Z0-9_]*\(\)", # 関数呼び出し func()
|
|
53
|
+
r"\b[a-z]+[A-Z][a-zA-Z0-9]*\b", # camelCase
|
|
54
|
+
r"\b[a-zA-Z][a-zA-Z0-9]*_[a-zA-Z0-9_]+\b", # snake_case
|
|
55
|
+
r"`[^`]+`", # バッククォート
|
|
56
|
+
r"\$[A-Z_][A-Z0-9_]*", # シェル変数
|
|
57
|
+
r"--[a-zA-Z][a-zA-Z0-9\-]*", # CLIオプション
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
EMOJI_PATTERN = re.compile(
|
|
61
|
+
# 2300 台と 2B00 台は記号のブロックで、⌘ ⏎ や矢印のような技術文書の記号が多い。
|
|
62
|
+
# 絵文字として出る字(⌚⌛ ⌨ ⏏ ⏩-⏳ ⏸-⏺ ⬛⬜ ⭐ ⭕)だけを拾う。
|
|
63
|
+
# 〰 〽 ㊗ ㊙ は CJK の記号の中にある絵文字で、FE0F は絵文字として表示させる異体字セレクタ
|
|
64
|
+
"[\U0001f300-\U0001faff\U00002600-\U000027bf\U0001f1e6-\U0001f1ff"
|
|
65
|
+
"\u231a\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u2b1b\u2b1c\u2b50\u2b55"
|
|
66
|
+
"\u3030\u303d\u3297\u3299\ufe0f]+",
|
|
67
|
+
flags=re.UNICODE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# 命令・急かし口調
|
|
71
|
+
# 文末の裸の「〜な」は動詞連用形(い段中心)に付く命令形。
|
|
72
|
+
# 「かな」(〜かな、疑問の“かな”)「だな」(断定の柔らかい念押し)「たな」(〜ついたな)
|
|
73
|
+
# 「よな」(〜だったよな)は命令ではないため除外。
|
|
74
|
+
IMPERATIVE_PATTERNS = [
|
|
75
|
+
r"しな(?:よ|さい)?[。!?]?$", # 「〜しな」文末
|
|
76
|
+
r"してきな",
|
|
77
|
+
r"さっさと",
|
|
78
|
+
r"しろ[。!?]?$",
|
|
79
|
+
r"(?<![かだではたよ])な[。!?]?$", # 文末の裸の「〜な」(か/だ/で/は/た/よの後は除外)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# 名詞・形容詞+「ね」止め(「〜だね」「〜だよ」は許可、直接「ね」で終わるものを検出)
|
|
83
|
+
# 「大丈夫ね」「順調ね」のように、形容動詞語幹/名詞に直接「ね」が付くケースを検出する。
|
|
84
|
+
# 動詞の活用語尾(う段: う く ぐ す つ ぬ ぶ む ゆ る/た・だ・て・で)や
|
|
85
|
+
# い形容詞語尾(い)、終助詞よ・ん の後に続く「ね」は自然な活用なので除外する。
|
|
86
|
+
NOUN_NE_PATTERN = re.compile(r"(?<![うくぐすつぬぶむゆるたてだでいんよら])ね[。!?]?$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def split_sentences(text: str) -> list[str]:
|
|
90
|
+
"""句点・感嘆符・疑問符の直後で切る。文末に $ でアンカーする判定は文ごとに当てる。
|
|
91
|
+
|
|
92
|
+
全体の末尾にだけ当てると、二文構成(ルール1で許可)の一文目の違反を見逃す。
|
|
93
|
+
"""
|
|
94
|
+
return [s for s in SENTENCE_PATTERN.findall(text) if s.strip()]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_punct_count(text: str) -> tuple[bool, str]:
|
|
98
|
+
positions = [i for i, ch in enumerate(text) if ch in SENTENCE_END_CHARS]
|
|
99
|
+
if len(positions) == 0 or len(positions) > 2:
|
|
100
|
+
return False, f"句読点数={len(positions)} (期待=1〜2)"
|
|
101
|
+
last = positions[-1]
|
|
102
|
+
trailing = text[last + 1 :].strip()
|
|
103
|
+
if trailing:
|
|
104
|
+
return False, f"句点後に文字列が続く: '{trailing}'"
|
|
105
|
+
return True, ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_code_ident(text: str) -> tuple[bool, str]:
|
|
109
|
+
hits = []
|
|
110
|
+
for pat in CODE_PATTERNS:
|
|
111
|
+
for m in re.finditer(pat, text):
|
|
112
|
+
token = m.group(0)
|
|
113
|
+
if token.strip("`$-") in ALLOWED_ACRONYMS:
|
|
114
|
+
continue
|
|
115
|
+
hits.append(token)
|
|
116
|
+
# 英単語連続(3文字以上のアルファベット列)もチェックするが、許可略語は除外
|
|
117
|
+
for m in re.finditer(r"[A-Za-z][A-Za-z0-9]{2,}", text):
|
|
118
|
+
token = m.group(0)
|
|
119
|
+
if token.upper() in ALLOWED_ACRONYMS or token in ALLOWED_ACRONYMS:
|
|
120
|
+
continue
|
|
121
|
+
# 既に上のパターンで拾われていなければ追加候補として拾う
|
|
122
|
+
hits.append(token)
|
|
123
|
+
hits = list(dict.fromkeys(hits))
|
|
124
|
+
if hits:
|
|
125
|
+
return False, f"コード識別子疑い: {hits}"
|
|
126
|
+
return True, ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_imperative(text: str) -> tuple[bool, str]:
|
|
130
|
+
sentences = split_sentences(text)
|
|
131
|
+
for pat in IMPERATIVE_PATTERNS:
|
|
132
|
+
if any(re.search(pat, s) for s in sentences):
|
|
133
|
+
return False, f"命令/急かし口調: pattern={pat}"
|
|
134
|
+
return True, ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_noun_ne(text: str) -> tuple[bool, str]:
|
|
138
|
+
if any(NOUN_NE_PATTERN.search(s) for s in split_sentences(text)):
|
|
139
|
+
return False, "名詞/形容詞+「ね」止めの疑い"
|
|
140
|
+
return True, ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_length_emoji(text: str) -> tuple[bool, str]:
|
|
144
|
+
if EMOJI_PATTERN.search(text):
|
|
145
|
+
return False, "絵文字を含む"
|
|
146
|
+
n = len(text)
|
|
147
|
+
positions = [i for i, ch in enumerate(text) if ch in SENTENCE_END_CHARS]
|
|
148
|
+
upper = 60 if len(positions) >= 2 else 45
|
|
149
|
+
if n < 10 or n > upper:
|
|
150
|
+
return False, f"文字数={n} (期待 10〜{upper} 程度)"
|
|
151
|
+
return True, ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
FIRST_PERSON_WORDS = ["俺", "僕", "オレ", "ボク"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_first_person(text: str) -> tuple[bool, str]:
|
|
158
|
+
hits = [w for w in FIRST_PERSON_WORDS if w in text]
|
|
159
|
+
if hits:
|
|
160
|
+
return False, f"一人称を含む: {hits}"
|
|
161
|
+
return True, ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# です・ます調の文末(style = "casual" のときだけ違反)
|
|
165
|
+
DESU_MASU_PATTERN = re.compile(
|
|
166
|
+
r"(です|ます|ました|でした|ません|ください|ましょう|でしょう|ですよ|ますよ|ましたよ)[。!?]?$"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def check_desu_masu(text: str) -> tuple[bool, str]:
|
|
171
|
+
if any(DESU_MASU_PATTERN.search(s) for s in split_sentences(text)):
|
|
172
|
+
return False, "です・ます調の文末"
|
|
173
|
+
return True, ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
RULES = [
|
|
177
|
+
("punct_count", check_punct_count),
|
|
178
|
+
("code_ident", check_code_ident),
|
|
179
|
+
("imperative", check_imperative),
|
|
180
|
+
("noun_ne", check_noun_ne),
|
|
181
|
+
("length_emoji", check_length_emoji),
|
|
182
|
+
("first_person", check_first_person),
|
|
183
|
+
("desu_masu", check_desu_masu),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def evaluate(text: str, style: str = "any") -> dict:
|
|
188
|
+
"""rules はルール名 → 合否、reasons は違反したルールだけの理由。
|
|
189
|
+
|
|
190
|
+
desu_masu は style が "casual" のときだけ判定し、それ以外(知らない値も)は PASS にする。
|
|
191
|
+
"""
|
|
192
|
+
text = text.strip()
|
|
193
|
+
rules, reasons = {}, {}
|
|
194
|
+
for name, fn in RULES:
|
|
195
|
+
ok, reason = (True, "") if name == "desu_masu" and style != "casual" else fn(text)
|
|
196
|
+
rules[name] = ok
|
|
197
|
+
if not ok:
|
|
198
|
+
reasons[name] = reason
|
|
199
|
+
return {"text": text, "rules": rules, "reasons": reasons, "all_pass": all(rules.values())}
|