library-hiroba 0.3.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.
Potentially problematic release.
This version of library-hiroba might be problematic. Click here for more details.
- library_hiroba/__init__.py +50 -0
- library_hiroba/_ai.py +490 -0
- library_hiroba/_components.py +622 -0
- library_hiroba/_core.py +177 -0
- library_hiroba/_css.py +570 -0
- library_hiroba/_forms.py +468 -0
- library_hiroba/py.typed +0 -0
- library_hiroba/ui.py +63 -0
- library_hiroba-0.3.0.dist-info/METADATA +343 -0
- library_hiroba-0.3.0.dist-info/RECORD +12 -0
- library_hiroba-0.3.0.dist-info/WHEEL +4 -0
- library_hiroba-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""library_hiroba — Google Colab と PyHiroba で同じコードが動く、教育向けの道具。
|
|
2
|
+
|
|
3
|
+
2つの入口があります。
|
|
4
|
+
|
|
5
|
+
``ui``
|
|
6
|
+
ノートブックに UI 部品(カード・クイズ・進捗バーなど)を表示します。
|
|
7
|
+
純 Python・標準ライブラリだけで動きます。
|
|
8
|
+
|
|
9
|
+
``ai``
|
|
10
|
+
ブラウザやノートブックの中で小さな言語モデルを動かします。
|
|
11
|
+
PyHiroba では本体が用意した経路を、Colab では transformers を使います。
|
|
12
|
+
|
|
13
|
+
from library_hiroba import ai, ui
|
|
14
|
+
|
|
15
|
+
ui.card("今日の目標", "for文を使って、九九の表を作ってみよう!")
|
|
16
|
+
|
|
17
|
+
await ai.load()
|
|
18
|
+
print(await ai.ask("日本の四季について、2行で書いて"))
|
|
19
|
+
|
|
20
|
+
``ai`` は使われたときに初めて読み込みます。``ui`` だけを使う環境に、
|
|
21
|
+
AI 側の重い依存(transformers / torch)を持ち込まないためです。
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
__version__ = "0.3.0"
|
|
27
|
+
|
|
28
|
+
__all__ = ["__version__", "ai", "ui"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str):
|
|
32
|
+
"""``ui`` と ``ai`` を、使われたときに初めて読み込む(PEP 562)。
|
|
33
|
+
|
|
34
|
+
ここで先に読み込んでしまうと、``ui`` しか使わない環境にも AI 側の
|
|
35
|
+
依存が持ち込まれる。``ui`` の「依存ゼロ・純 Python」を守るための遅延。
|
|
36
|
+
|
|
37
|
+
読み込みには ``import_module`` を使う。``from . import ui`` と書くと、
|
|
38
|
+
その解決がこの ``__getattr__`` を呼び戻して無限に繰り返す。
|
|
39
|
+
"""
|
|
40
|
+
import importlib
|
|
41
|
+
|
|
42
|
+
if name == "ui":
|
|
43
|
+
return importlib.import_module(".ui", __name__)
|
|
44
|
+
if name == "ai":
|
|
45
|
+
return importlib.import_module("._ai", __name__).ai
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def __dir__() -> list[str]:
|
|
50
|
+
return sorted(__all__)
|
library_hiroba/_ai.py
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""小さな言語モデルを、PyHiroba でも Colab でも同じ書き方で動かす。
|
|
2
|
+
|
|
3
|
+
from library_hiroba import ai
|
|
4
|
+
|
|
5
|
+
await ai.load()
|
|
6
|
+
print(await ai.ask("日本の四季について、2行で書いて"))
|
|
7
|
+
|
|
8
|
+
なぜ ``await`` が要るか
|
|
9
|
+
----------------------
|
|
10
|
+
PyHiroba は GitHub Pages で配信しているため COOP/COEP ヘッダを付けられず、
|
|
11
|
+
``SharedArrayBuffer`` を使った同期待ちができません。そのためブラウザ側では
|
|
12
|
+
「待つ」処理にせざるを得ません。Colab 側は待つ必要がありませんが、
|
|
13
|
+
**同じコードが両方で動く**ことを優先して、こちらも ``await`` の形に揃えています。
|
|
14
|
+
ノートブック(Colab / Jupyter / PyHiroba)は、セルの中でそのまま ``await`` が使えます。
|
|
15
|
+
|
|
16
|
+
2つの経路
|
|
17
|
+
---------
|
|
18
|
+
- PyHiroba(ブラウザ)… 本体が用意した ``js.pyhirobaAsk`` を通す。やり取りは JSON 文字列だけ
|
|
19
|
+
- Colab など … ``transformers`` と ``torch`` を使う(``pip install "library-hiroba[ai]"``)
|
|
20
|
+
|
|
21
|
+
入力した文章が外部に送られることはありません。通信はモデルを受け取るときだけです。
|
|
22
|
+
|
|
23
|
+
ライセンス: 使用するモデルのライセンスは配布元をご確認ください
|
|
24
|
+
(既定の Qwen2.5 は Apache-2.0)。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
|
|
31
|
+
__all__ = ["Ai", "ai"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# モデルの名前
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# ブラウザ側は同じモデルを精度違い(q8 / q4)で並べるため、名前に -q8 / -q4 が付く。
|
|
38
|
+
# Colab 側にその区別は無い。どちらの名前で呼ばれても動くよう、ここで受け止める。
|
|
39
|
+
#
|
|
40
|
+
# 共通の名前(これを使うのが推奨)… qwen05 / qwen15 / llmjp150m
|
|
41
|
+
# ブラウザ固有の名前(そのまま通す)… qwen05-q8 / qwen05-q4 / qwen15-q4 / llmjp150m-q4
|
|
42
|
+
#
|
|
43
|
+
# ``colab_id`` と ``browser_repo`` は**同じモデルの別形式**でなければいけない。
|
|
44
|
+
# 片方だけ新しい版に上げると、同じ名前を書いたのに環境で違うモデルが動く。
|
|
45
|
+
# ブラウザは ONNX に変換されたものしか読めないので、選べる幅はそちらで決まる。
|
|
46
|
+
# 増やせるかどうかの調べ方は docs/PYHIROBA_INTEGRATION.md の「モデルを増やす」に。
|
|
47
|
+
MODELS = {
|
|
48
|
+
"qwen05": {
|
|
49
|
+
"label": "Qwen2.5 0.5B(日本語が使えます・おすすめ)",
|
|
50
|
+
"colab_id": "Qwen/Qwen2.5-0.5B-Instruct",
|
|
51
|
+
"browser_repo": "onnx-community/Qwen2.5-0.5B-Instruct",
|
|
52
|
+
"browser_key": "qwen05-q8",
|
|
53
|
+
"browser_variants": ("qwen05-q8", "qwen05-q4"),
|
|
54
|
+
"approx_mb": {"browser": 900, "colab": 1000},
|
|
55
|
+
},
|
|
56
|
+
"qwen15": {
|
|
57
|
+
"label": "Qwen2.5 1.5B(日本語がより自然・重い)",
|
|
58
|
+
"colab_id": "Qwen/Qwen2.5-1.5B-Instruct",
|
|
59
|
+
"browser_repo": "onnx-community/Qwen2.5-1.5B-Instruct",
|
|
60
|
+
"browser_key": "qwen15-q4",
|
|
61
|
+
"browser_variants": ("qwen15-q4",),
|
|
62
|
+
"approx_mb": {"browser": 1600, "colab": 3100},
|
|
63
|
+
},
|
|
64
|
+
"qwen3_06": {
|
|
65
|
+
"label": "Qwen3 0.6B(Qwen2.5 0.5B より新しい・日本語が少し良い)",
|
|
66
|
+
"colab_id": "Qwen/Qwen3-0.6B",
|
|
67
|
+
"browser_repo": "onnx-community/Qwen3-0.6B-ONNX",
|
|
68
|
+
"browser_key": "qwen3_06-q4",
|
|
69
|
+
"browser_variants": ("qwen3_06-q4", "qwen3_06-q8"),
|
|
70
|
+
"approx_mb": {"browser": 550, "colab": 1500},
|
|
71
|
+
"has_thinking": True,
|
|
72
|
+
},
|
|
73
|
+
"qwen3_17": {
|
|
74
|
+
"label": "Qwen3 1.7B(この一覧でいちばん賢い・重い)",
|
|
75
|
+
"colab_id": "Qwen/Qwen3-1.7B",
|
|
76
|
+
"browser_repo": "onnx-community/Qwen3-1.7B-ONNX",
|
|
77
|
+
"browser_key": "qwen3_17-q4",
|
|
78
|
+
"browser_variants": ("qwen3_17-q4",),
|
|
79
|
+
"approx_mb": {"browser": 1300, "colab": 3400},
|
|
80
|
+
"has_thinking": True,
|
|
81
|
+
},
|
|
82
|
+
"llmjp150m": {
|
|
83
|
+
# instruct3 ではなく instruct2。ONNX に変換されているのが instruct2 だけで、
|
|
84
|
+
# Colab をこちらに合わせないと、同じ名前で環境ごとに別のモデルが動いてしまう。
|
|
85
|
+
# 150M では両者の差はほとんどなく、揃えるほうを採った。
|
|
86
|
+
"label": "LLM-jp-3 150M(国産・とても軽い/文章は不自然です)",
|
|
87
|
+
"colab_id": "llm-jp/llm-jp-3-150m-instruct2",
|
|
88
|
+
"browser_repo": "onnx-community/llm-jp-3-150m-instruct2-ONNX",
|
|
89
|
+
"browser_key": "llmjp150m-q4",
|
|
90
|
+
"browser_variants": ("llmjp150m-q4",),
|
|
91
|
+
"approx_mb": {"browser": 255, "colab": 600},
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
DEFAULT_MODEL = "qwen05"
|
|
96
|
+
|
|
97
|
+
# ブラウザ固有の名前 → 共通の名前
|
|
98
|
+
_VARIANT_TO_BASE = {
|
|
99
|
+
variant: base for base, spec in MODELS.items() for variant in spec["browser_variants"]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def resolve(name: str | None) -> tuple[str, str]:
|
|
104
|
+
"""モデル名を「共通の名前」と「ブラウザに渡す名前」の組にする。
|
|
105
|
+
|
|
106
|
+
どちらの書き方で呼ばれても受け付ける。共通の名前だけを渡された場合、
|
|
107
|
+
ブラウザには推奨の精度(``browser_key``)を渡す。
|
|
108
|
+
"""
|
|
109
|
+
if name is None:
|
|
110
|
+
name = DEFAULT_MODEL
|
|
111
|
+
name = str(name)
|
|
112
|
+
if name in MODELS:
|
|
113
|
+
return name, MODELS[name]["browser_key"]
|
|
114
|
+
if name in _VARIANT_TO_BASE:
|
|
115
|
+
# 精度まで指定された場合は、その指定を尊重してそのまま渡す
|
|
116
|
+
return _VARIANT_TO_BASE[name], name
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"そのモデルは選べません: {name}"
|
|
119
|
+
"(await ai.models() で選べるものを確認できます)"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# 考えている途中を隠す
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Qwen3 系は答えの前に <think>…</think> で考えを書く。授業では答えだけ見えれば
|
|
127
|
+
# よく、途中が出ると読みづらい。組み立てるときに出さない設定を頼み(古い版だと
|
|
128
|
+
# 通らないので、その時は黙って諦める)、出てきてしまったぶんは最後に削る。
|
|
129
|
+
# 本体(ブラウザ)側にも同じ処理を頼んである。docs/PYHIROBA_INTEGRATION.md 参照。
|
|
130
|
+
_THINKING_BLOCK = re.compile(r"<think>.*?</think>", re.S)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def strip_thinking(text: object) -> str:
|
|
134
|
+
"""``<think>…</think>`` を取り除く。考えるモデル以外には何も起きない。"""
|
|
135
|
+
text = _THINKING_BLOCK.sub("", str(text))
|
|
136
|
+
# 字数が尽きて閉じられなかった場合。答えはまだ書かれていないので、
|
|
137
|
+
# 考えの途中を見せるより空で返す(呼び出し側が字数を増やせば済む)。
|
|
138
|
+
unclosed = text.find("<think>")
|
|
139
|
+
if unclosed != -1:
|
|
140
|
+
text = text[:unclosed]
|
|
141
|
+
return text.strip()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_OPEN_TAG = "<think>"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _unfinished_tag_length(text: str) -> int:
|
|
148
|
+
"""末尾が ``<think>`` の書きかけなら、その長さを返す。
|
|
149
|
+
|
|
150
|
+
``答え<thi`` の ``<thi`` は続きが ``nk>`` かもしれない。出してしまうと
|
|
151
|
+
取り消せないので、ここだけ保留する。書きかけでない普通の文字は待たせない。
|
|
152
|
+
"""
|
|
153
|
+
for length in range(min(len(text), len(_OPEN_TAG) - 1), 0, -1):
|
|
154
|
+
if _OPEN_TAG.startswith(text[-length:]):
|
|
155
|
+
return length
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ThinkingFilter:
|
|
160
|
+
"""少しずつ届く文字から、考えている途中を取り除いて渡す。
|
|
161
|
+
|
|
162
|
+
届いた分を溜めたうえで毎回まるごと判定し、**前回より増えた分だけ**返す。
|
|
163
|
+
``<think>`` がチャンクの境目で割れても取りこぼさないのは、切れ端ではなく
|
|
164
|
+
常に全文を見ているため。末尾は少し残す(``<thi`` まで届いた時点で出すと、
|
|
165
|
+
続きが ``nk>`` だったときに取り消せない)。
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(self) -> None:
|
|
169
|
+
self._buffer = ""
|
|
170
|
+
self._shown = ""
|
|
171
|
+
|
|
172
|
+
def feed(self, chunk: str) -> str:
|
|
173
|
+
"""届いた分を渡し、表示してよくなった分を受け取る。"""
|
|
174
|
+
self._buffer += str(chunk)
|
|
175
|
+
return self._advance(hold_back=True)
|
|
176
|
+
|
|
177
|
+
def finish(self) -> str:
|
|
178
|
+
"""もう続きが来ないとき、残りを全部受け取る。"""
|
|
179
|
+
return self._advance(hold_back=False)
|
|
180
|
+
|
|
181
|
+
def _advance(self, hold_back: bool) -> str:
|
|
182
|
+
clean = strip_thinking(self._buffer)
|
|
183
|
+
if hold_back:
|
|
184
|
+
keep = _unfinished_tag_length(clean)
|
|
185
|
+
if keep:
|
|
186
|
+
clean = clean[:-keep]
|
|
187
|
+
if len(clean) <= len(self._shown):
|
|
188
|
+
return ""
|
|
189
|
+
new, self._shown = clean[len(self._shown) :], clean
|
|
190
|
+
return new
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _dtype_keyword(pipeline) -> str:
|
|
194
|
+
"""``pipeline()`` に数値の精度を渡すときのキーワード名。
|
|
195
|
+
|
|
196
|
+
transformers 5 で ``torch_dtype`` は ``dtype`` に改名された。古い名前も
|
|
197
|
+
まだ通るが、実行するたびに非推奨の警告が出る。Colab に入っている版が
|
|
198
|
+
どちらでも警告なく動くよう、その版が受け付ける名前で渡す。
|
|
199
|
+
"""
|
|
200
|
+
import inspect
|
|
201
|
+
|
|
202
|
+
return "dtype" if "dtype" in inspect.signature(pipeline).parameters else "torch_dtype"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def in_browser() -> bool:
|
|
206
|
+
"""PyHiroba のワーカーの中にいるか。
|
|
207
|
+
|
|
208
|
+
``js`` が入るのは Pyodide だけで、``pyhirobaAsk`` を持つのは PyHiroba 本体だけ。
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
import js
|
|
212
|
+
except ImportError:
|
|
213
|
+
return False
|
|
214
|
+
return hasattr(js, "pyhirobaAsk")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Ai:
|
|
221
|
+
"""小さな言語モデルを動かす。PyHiroba と Colab で同じ使い方ができる。"""
|
|
222
|
+
|
|
223
|
+
def __init__(self) -> None:
|
|
224
|
+
self._pipe = None
|
|
225
|
+
self._name: str | None = None
|
|
226
|
+
|
|
227
|
+
async def models(self) -> list[dict]:
|
|
228
|
+
"""選べるモデルの一覧(名前と目安の通信量)。
|
|
229
|
+
|
|
230
|
+
返す形はどちらの経路でも同じ ``[{"name", "label", "approxMB"}, …]``。
|
|
231
|
+
通信量は環境で実際に違うため、その環境の値を返す。
|
|
232
|
+
"""
|
|
233
|
+
where = "browser" if in_browser() else "colab"
|
|
234
|
+
return [
|
|
235
|
+
{"name": name, "label": spec["label"], "approxMB": spec["approx_mb"][where]}
|
|
236
|
+
for name, spec in MODELS.items()
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
async def load(self, model: str | None = None) -> str:
|
|
240
|
+
"""モデルを読み込む。初回だけ時間と通信量がかかる。"""
|
|
241
|
+
base, browser_key = resolve(model)
|
|
242
|
+
if in_browser():
|
|
243
|
+
return await self._load_in_browser(browser_key)
|
|
244
|
+
return self._load_with_transformers(base)
|
|
245
|
+
|
|
246
|
+
async def ask(self, prompt: object, max_tokens: int | None = None) -> str:
|
|
247
|
+
"""文章を渡して、続きを書いてもらう。"""
|
|
248
|
+
if in_browser():
|
|
249
|
+
return await self._ask_in_browser(prompt, max_tokens)
|
|
250
|
+
if self._pipe is None:
|
|
251
|
+
await self.load()
|
|
252
|
+
return self._ask_with_transformers(prompt, max_tokens)
|
|
253
|
+
|
|
254
|
+
async def stream(self, prompt: object, max_tokens: int | None = None):
|
|
255
|
+
"""答えを、書けたところから少しずつ受け取る。
|
|
256
|
+
|
|
257
|
+
``ask()`` は全部書き終わるまで返らない。小さなモデルでも数十秒かかる
|
|
258
|
+
ことがあり、そのあいだ画面が変わらない。こちらは届いたぶんから返す。
|
|
259
|
+
|
|
260
|
+
text = ""
|
|
261
|
+
async for chunk in ai.stream("日本の四季について"):
|
|
262
|
+
text += chunk
|
|
263
|
+
print(text)
|
|
264
|
+
|
|
265
|
+
つなげると ``ask()`` と同じ文になる。**少しずつ返せない環境では、
|
|
266
|
+
全部書き終えてから一度にまとめて返す**(同じコードが動くことを優先)。
|
|
267
|
+
"""
|
|
268
|
+
if in_browser():
|
|
269
|
+
async for chunk in self._stream_in_browser(prompt, max_tokens):
|
|
270
|
+
yield chunk
|
|
271
|
+
return
|
|
272
|
+
if self._pipe is None:
|
|
273
|
+
await self.load()
|
|
274
|
+
async for chunk in self._stream_with_transformers(prompt, max_tokens):
|
|
275
|
+
yield chunk
|
|
276
|
+
|
|
277
|
+
# --- ブラウザ経路(PyHiroba 本体との契約) -----------------------------
|
|
278
|
+
#
|
|
279
|
+
# 本体のワーカーが js.pyhirobaAsk(kind, argsJson) -> Promise<resultJson> を用意する。
|
|
280
|
+
# やり取りは JSON 文字列だけ(Pyodide と JS の境界を単純に保つため)。
|
|
281
|
+
# kind は本体の許可リストにある ai-load / ai-ask / ai-models の3つのみ。
|
|
282
|
+
|
|
283
|
+
async def _call_host(self, kind: str, args_json: str) -> dict:
|
|
284
|
+
import json
|
|
285
|
+
|
|
286
|
+
import js
|
|
287
|
+
|
|
288
|
+
raw = await js.pyhirobaAsk(kind, args_json)
|
|
289
|
+
# 本体が壊れた応答を返したときに、JSONDecodeError や
|
|
290
|
+
# 「'list' object has no attribute 'get'」のような、利用者には意味の
|
|
291
|
+
# 分からない例外で止まらないようにする。原因の見当がつく文言にする。
|
|
292
|
+
try:
|
|
293
|
+
result = json.loads(raw)
|
|
294
|
+
except (TypeError, ValueError) as error:
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
f"PyHiroba 本体からの返事を読み取れませんでした({kind})。"
|
|
297
|
+
f"本体側の不具合の可能性があります。返ってきた内容: {str(raw)[:80]!r}"
|
|
298
|
+
) from error
|
|
299
|
+
if not isinstance(result, dict):
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
f"PyHiroba 本体からの返事の形が違います({kind})。"
|
|
302
|
+
f"{{…}} の形を期待しましたが {type(result).__name__} でした。"
|
|
303
|
+
)
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
async def _load_in_browser(self, browser_key: str) -> str:
|
|
307
|
+
import json
|
|
308
|
+
|
|
309
|
+
result = await self._call_host("ai-load", json.dumps({"model": browser_key}))
|
|
310
|
+
self._name = browser_key
|
|
311
|
+
return result.get("message", "準備ができました")
|
|
312
|
+
|
|
313
|
+
async def _ask_in_browser(self, prompt: object, max_tokens: int | None) -> str:
|
|
314
|
+
import json
|
|
315
|
+
|
|
316
|
+
if self._name is None:
|
|
317
|
+
await self.load()
|
|
318
|
+
result = await self._call_host(
|
|
319
|
+
"ai-ask", json.dumps({"prompt": str(prompt), "max_tokens": max_tokens})
|
|
320
|
+
)
|
|
321
|
+
# 本体側でも削ってもらう約束だが、こちらでも通す。本体が削り忘れても
|
|
322
|
+
# 両方の経路で同じ結果になるようにするため(二重にかけても何も起きない)。
|
|
323
|
+
return strip_thinking(result.get("text", ""))
|
|
324
|
+
|
|
325
|
+
async def _stream_in_browser(self, prompt: object, max_tokens: int | None):
|
|
326
|
+
"""本体が少しずつ返せるなら使い、無理なら ``ai-ask`` に落とす。
|
|
327
|
+
|
|
328
|
+
本体との受け渡しは「1回頼んで1回返る」形しか無いので、少しずつ受け取る
|
|
329
|
+
ときは ``ai-ask-start`` で始めて ``ai-ask-next`` を繰り返し呼ぶ。
|
|
330
|
+
本体がこれを知らない場合(古い版・未実装)は、そのまま ``ai-ask`` で
|
|
331
|
+
全文を受け取って一度に返す。詳しくは docs/PYHIROBA_INTEGRATION.md。
|
|
332
|
+
"""
|
|
333
|
+
import json
|
|
334
|
+
|
|
335
|
+
if self._name is None:
|
|
336
|
+
await self.load()
|
|
337
|
+
try:
|
|
338
|
+
started = await self._call_host(
|
|
339
|
+
"ai-ask-start", json.dumps({"prompt": str(prompt), "max_tokens": max_tokens})
|
|
340
|
+
)
|
|
341
|
+
stream_id = started.get("id")
|
|
342
|
+
except Exception: # noqa: BLE001 — 未対応の伝わり方は本体次第
|
|
343
|
+
stream_id = None
|
|
344
|
+
if not stream_id:
|
|
345
|
+
# 少しずつは無理だった。全文を一度に返す
|
|
346
|
+
yield await self._ask_in_browser(prompt, max_tokens)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
thinking = ThinkingFilter()
|
|
350
|
+
while True:
|
|
351
|
+
part = await self._call_host("ai-ask-next", json.dumps({"id": stream_id}))
|
|
352
|
+
if part.get("done"):
|
|
353
|
+
last = thinking.feed(part.get("text", "")) + thinking.finish()
|
|
354
|
+
if last:
|
|
355
|
+
yield last
|
|
356
|
+
return
|
|
357
|
+
chunk = thinking.feed(part.get("text", ""))
|
|
358
|
+
if chunk:
|
|
359
|
+
yield chunk
|
|
360
|
+
|
|
361
|
+
# --- Colab 経路(transformers + torch) --------------------------------
|
|
362
|
+
|
|
363
|
+
def _load_with_transformers(self, base: str) -> str:
|
|
364
|
+
if self._pipe is not None and self._name == base:
|
|
365
|
+
return "すでに準備できています"
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
import torch
|
|
369
|
+
from transformers import pipeline
|
|
370
|
+
except ImportError as error:
|
|
371
|
+
raise ImportError(
|
|
372
|
+
"transformers と torch が必要です。次の行を先に実行してください:\n"
|
|
373
|
+
' !pip install -q "library-hiroba[ai]"'
|
|
374
|
+
) from error
|
|
375
|
+
|
|
376
|
+
device = 0 if torch.cuda.is_available() else -1
|
|
377
|
+
self._pipe = pipeline(
|
|
378
|
+
"text-generation",
|
|
379
|
+
model=MODELS[base]["colab_id"],
|
|
380
|
+
device=device,
|
|
381
|
+
**{_dtype_keyword(pipeline): torch.float16 if device == 0 else torch.float32},
|
|
382
|
+
)
|
|
383
|
+
self._name = base
|
|
384
|
+
where = "GPU" if device == 0 else "CPU"
|
|
385
|
+
return f"準備ができました({MODELS[base]['label']}/{where}で動きます)"
|
|
386
|
+
|
|
387
|
+
def _build_input(self, messages: list[dict]):
|
|
388
|
+
"""モデルに渡すものを組み立てる。
|
|
389
|
+
|
|
390
|
+
考えるモデルには「考えを書かないで」と頼んだうえで渡す。頼めない古い
|
|
391
|
+
テンプレートのときは、そのまま渡して後から削る(``strip_thinking``)。
|
|
392
|
+
"""
|
|
393
|
+
if not MODELS[self._name].get("has_thinking"):
|
|
394
|
+
return messages
|
|
395
|
+
tokenizer = getattr(self._pipe, "tokenizer", None)
|
|
396
|
+
if tokenizer is None:
|
|
397
|
+
return messages
|
|
398
|
+
try:
|
|
399
|
+
return tokenizer.apply_chat_template(
|
|
400
|
+
messages,
|
|
401
|
+
tokenize=False,
|
|
402
|
+
add_generation_prompt=True,
|
|
403
|
+
enable_thinking=False,
|
|
404
|
+
)
|
|
405
|
+
except (AttributeError, TypeError, ValueError):
|
|
406
|
+
# テンプレートが enable_thinking を知らない版か、そもそも
|
|
407
|
+
# apply_chat_template を持たない場合。会話の形のまま渡して、
|
|
408
|
+
# 出力側の削り取り(strip_thinking)だけで対処する。
|
|
409
|
+
return messages
|
|
410
|
+
|
|
411
|
+
def _generation_kwargs(self, max_tokens: int | None) -> dict:
|
|
412
|
+
# 生成の設定はブラウザ側と揃えてある。小さなモデルはばらつきを大きくすると
|
|
413
|
+
# 意味の通らない文章になりやすいので、温度を下げ繰り返しを抑える。
|
|
414
|
+
return {
|
|
415
|
+
"max_new_tokens": max_tokens or 256,
|
|
416
|
+
"temperature": 0.3,
|
|
417
|
+
"top_p": 0.9,
|
|
418
|
+
"repetition_penalty": 1.15,
|
|
419
|
+
"do_sample": True,
|
|
420
|
+
"return_full_text": False,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async def _stream_with_transformers(self, prompt: object, max_tokens: int | None):
|
|
424
|
+
"""生成を別スレッドで走らせ、書けた分から受け取る。
|
|
425
|
+
|
|
426
|
+
transformers の streamer は「次が来るまで待つ」ふつうの反復子なので、
|
|
427
|
+
そのまま回すとノートブック全体が止まる。1つ取り出すごとに別スレッドへ
|
|
428
|
+
逃がして、待っているあいだ画面が動けるようにする。
|
|
429
|
+
"""
|
|
430
|
+
import asyncio
|
|
431
|
+
import threading
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
from transformers import TextIteratorStreamer
|
|
435
|
+
except ImportError:
|
|
436
|
+
# 少しずつ返せない版。全部書けてから一度に返す(同じコードは動く)
|
|
437
|
+
yield self._ask_with_transformers(prompt, max_tokens)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
streamer = TextIteratorStreamer(
|
|
441
|
+
self._pipe.tokenizer, skip_prompt=True, skip_special_tokens=True
|
|
442
|
+
)
|
|
443
|
+
messages = [{"role": "user", "content": str(prompt)}]
|
|
444
|
+
failure: list[BaseException] = []
|
|
445
|
+
|
|
446
|
+
def generate() -> None:
|
|
447
|
+
try:
|
|
448
|
+
self._pipe(
|
|
449
|
+
self._build_input(messages),
|
|
450
|
+
streamer=streamer,
|
|
451
|
+
**self._generation_kwargs(max_tokens),
|
|
452
|
+
)
|
|
453
|
+
except BaseException as error: # noqa: BLE001 — 呼び出し側へ運ぶ
|
|
454
|
+
failure.append(error)
|
|
455
|
+
streamer.end()
|
|
456
|
+
|
|
457
|
+
worker = threading.Thread(target=generate, daemon=True)
|
|
458
|
+
worker.start()
|
|
459
|
+
|
|
460
|
+
loop = asyncio.get_running_loop()
|
|
461
|
+
iterator = iter(streamer)
|
|
462
|
+
stop = object()
|
|
463
|
+
thinking = ThinkingFilter()
|
|
464
|
+
while True:
|
|
465
|
+
piece = await loop.run_in_executor(None, lambda: next(iterator, stop))
|
|
466
|
+
if piece is stop:
|
|
467
|
+
break
|
|
468
|
+
chunk = thinking.feed(piece)
|
|
469
|
+
if chunk:
|
|
470
|
+
yield chunk
|
|
471
|
+
# 生成側で落ちていたら、黙って短い答えを返さずに知らせる
|
|
472
|
+
if failure:
|
|
473
|
+
raise failure[0]
|
|
474
|
+
last = thinking.finish()
|
|
475
|
+
if last:
|
|
476
|
+
yield last
|
|
477
|
+
|
|
478
|
+
def _ask_with_transformers(self, prompt: object, max_tokens: int | None) -> str:
|
|
479
|
+
out = self._pipe(
|
|
480
|
+
self._build_input([{"role": "user", "content": str(prompt)}]),
|
|
481
|
+
**self._generation_kwargs(max_tokens),
|
|
482
|
+
)
|
|
483
|
+
text = out[0]["generated_text"]
|
|
484
|
+
# 会話形式で渡すと返り値も会話の並びになる。最後の発言を取り出す。
|
|
485
|
+
if isinstance(text, list):
|
|
486
|
+
text = (text[-1] or {}).get("content", "") if text else ""
|
|
487
|
+
return strip_thinking(str(text))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
ai = Ai()
|