#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ chat.py -- kernel.py の「会話層」 kernel.py は命令しか扱えない。この層は、命令でない入力(雑談・質問・独り言)に 返事をするためのもの。設計思想は kernel.py と同じ: ・ふだんは掛け算(ニューラルネット推論)を一切しない。 classify() は文字パターンとスロットだけで判定し、LLM を呼ばない。 ・先生(LLM)を呼ぶのは「返事を作るとき」と「分類に行き詰まったとき」だけ。 ・覚えたことは SQLite に残り、次の会話に効いてくる。 ・recall() も埋め込みモデルを使わない。文字 2-gram の重なり(Jaccard)を 語の希少度(IDF 的な重み)で補正するだけ。標準ライブラリのみ。 使い方: python3 chat.py 対話ループ (:quit で終了、:mem で記憶を表示) """ from __future__ import annotations import math import os import re import sqlite3 import sys import time import unicodedata HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) DEFAULT_DB = os.path.join(HERE, "memory.db") # ============================================================ # (0) 文字あつかいの下ごしらえ # ============================================================ # 記号・空白を落とすための表。ここで落とした文字は n-gram に入らない。 _DROP_RE = re.compile(r"[\s、。,.,\.!!??「」『』()()\[\]【】…ー~〜::;;\"'`]+") def normalize(text: str) -> str: """全角/半角のブレをならし、記号と空白を落として比較用の文字列にする。""" if not text: return "" s = unicodedata.normalize("NFKC", text).lower() return _DROP_RE.sub("", s) def ngrams(text: str, n: int = 2) -> set: """文字 n-gram の集合。短すぎる文はその文字自身を 1 個だけ返す。""" s = normalize(text) if len(s) <= n: return {s} if s else set() return {s[i:i + n] for i in range(len(s) - n + 1)} # ============================================================ # (1) 分類 -- LLM を呼ばずに済むなら絶対に呼ばない # ============================================================ # 命令らしさを直に示す語尾・依頼表現。スロットが片方しか無くてもこれで命令に倒す。 _ORDER_TAIL = ( "して", "してくれ", "してよ", "しといて", "しとい", "ください", "くださ", "ちょうだい", "頼む", "たのむ", "お願い", "おねがい", "やって", "やっと", "まとめて", "整理して", "片付けて", "開いて", "ひらいて", "消して", "けして", "作って", "つくって", "コピーして", "移動して", "動かして", "実行", "起動", "教えて", "見せて", "みせて", "出して", "だして", "探して", "さがして", "数えて", "かぞえて", "並べて", "ならべて", "しろ", "せよ", ) # 雑談らしさ: あいさつ・相づち・感情の吐露。 _CHAT_WORD = ( "こんにちは", "こんばんは", "おはよう", "やあ", "ハロー", "はろー", "hello", "hi", "ありがとう", "ありがと", "thanks", "サンキュー", "よろしく", "はじめまして", "おつかれ", "お疲れ", "ごめん", "すまん", "すみません", "さようなら", "またね", "そうなんだ", "なるほど", "へえ", "ふーん", "まじか", "すごい", "すご", "つかれた", "疲れた", "ねむい", "眠い", "たのしい", "楽しい", "うれしい", "嬉しい", "かなしい", "悲しい", "つらい", "辛い", "おなかすいた", "腹減", "元気", "げんき", "調子どう", "どう思う", "だと思う", "気がする", "な気分", ) # 一人称の感想・独り言。「〜だなあ」「〜かも」など。 _MONOLOGUE_TAIL = ( "かな", "かなあ", "かなー", "だなあ", "だなー", "だね", "ですね", "だよね", "かも", "かもしれない", "気がする", "と思う", "とおもう", "みたい", "らしい", "でした", "だった", "ちゃった", "しまった", "のに", "けどね", ) # 疑問符・疑問詞。ただし「〜を数えて何個?」のように命令スロットが揃っていれば命令優先。 _QUESTION_MARK = ("?", "?") # 「ファイルの話をしている」と判断できる語。問い合わせの判定に使う # 相手や自分のことを聞いている合図。これがあれば雑談 _SELFISH = ("あなた", "きみ", "君は", "君の", "お前", "自分", "私のこと", "誰", "だれ", "何者", "なにもの") _FILEISH = ("ファイル", "フォルダ", "ディレクトリ", "写真", "画像", "動画", "pdf", "書類", "資料", "メモ", "デスクトップ", "ダウンロード", "中身", "入って", "何がある", "なにがある") _QUESTION_WORD = ( "なに", "何", "なぜ", "なんで", "どうして", "どこ", "だれ", "誰", "いつ", "どう", "どんな", "どっち", "どちら", "いくつ", "ですか", "ますか", "の?", ) # 命令に必須のスロット名(kernel.py の SEED と対応) _ACT = "動作" _PLACE = "場所" # 「〜てくれる?」「〜てもらえる?」のような、頼みごとの形。 # 「ください」「ほしい」も同じ仲間 _REQUEST = re.compile( r"(?:て|で)\s*(?:くれる|くれない|くれます|もらえる|もらえない|もらえます|" r"もらっていい|いただけ)(?:か|の)?[??]?\s*$" r"|(?:て|で)\s*(?:ください|ちょうだい|ほしい|ほしいな|おいて)\s*[。!!]?\s*$") def _has(text: str, words) -> bool: return any(w in text for w in words) def _ends_with(text: str, tails) -> bool: t = text.rstrip("。..!!??  ") return any(t.endswith(x) for x in tails) def mono_no_hanashi(text: str) -> bool: """ファイル・フォルダ・場所・頼みごとの気配があるか(無ければ、先生に分類を聞くまでもなく雑談)。 ★ 2026-09-12: 算数の問いを「あいまい」→先生が「命令」→ consult に 900トークン ×2 → 雑談へ、 という回り道で 1回の返事に 204秒かかっていた。ものの話でなければ、その回り道に入れない。""" low = unicodedata.normalize("NFKC", text or "").lower() if "/" in low or "~" in low: return True # 「説明して」「教えて」も頼みの形だが、**もの**が出てこなければ カーネルの出番ではない return _has(low, _FILEISH) def classify(text: str, slots: dict) -> str: """入力が「命令」「雑談」「あいまい」のどれかを、LLM を使わずに判定する。 引数: text : 生の入力文 slots : kernel.draw_cards() が埋めたスロット辞書 (例 {"動作":"移動","場所":"Desktop"}) 戻り値: "命令" / "雑談" / "あいまい" "あいまい" のときだけ、呼び出し側が先生に聞けばよい。 """ raw = (text or "").strip() if not raw: return "あいまい" low = unicodedata.normalize("NFKC", raw).lower() slots = slots or {} has_act = _ACT in slots has_place = _PLACE in slots has_obj = ("種類" in slots) or ("時期" in slots) order_tail = (_ends_with(low, _ORDER_TAIL) or _has(low, ("してください", "してくれ", "お願いします"))) # 場所もパスも無いのに動作カードだけ引けた時は、命令ではない。 # 「自分の言葉で教えて」の「教えて」を【一覧】と読んで、 # ファイル一覧を出そうとしてしまう事故を防ぐ if slots and "場所" not in slots and "パス" not in slots \ and not _has(low, _FILEISH): return "雑談" # 日本語の「依頼形」。?で終わるが、質問ではなく頼みごと。 # 「寄せといてくれる?」「移してもらえる?」「やってくれない?」 # ここを見ていなかったので、頼みごとが質問と読まれ、 # 一覧が返るだけで何もしてくれなかった。 # ただし、はっきりした依頼の形だけに限る。 # 「〜って何?」のような問いを巻き込むと、質問でものが動いてしまう if _REQUEST.search(low) and (has_act or has_place): return "命令" # --- 0) 疑問形かどうかを、何よりも先に見る ★ ---------------------- # 「〜には何がある?」のような問いで、ものを動かしてはいけない。 # 以前ここを後回しにしていたため、質問でファイルを移動する事故が起きた。 is_question = (raw.rstrip().endswith(_QUESTION_MARK) or _has(low, _QUESTION_WORD)) if is_question and not order_tail: # ファイルやフォルダの話だと分かる時だけ「問い合わせ」。 # 時期だけ・種類だけで判断すると、雑談を問い合わせに化けさせる if _has(low, _FILEISH) and (has_place or has_obj or _has(low, _FILEISH)): return "問い合わせ" if has_place and not _has(low, _SELFISH): return "問い合わせ" return "雑談" # --- 1) スロットが揃っていれば「命令」。ここが最速路。 ------------- if has_act and has_place: return "命令" # --- 2) 依頼の語尾があり、動作か場所のどちらかが取れていれば命令 ----- if order_tail and (has_act or has_place or has_obj): return "命令" # --- 3) 明確な雑談マーカー ------------------------------------------- # あいさつ語だけ / 感情の吐露は、スロットが多少取れていても雑談。 if _has(low, _CHAT_WORD): # 「デスクトップの写真まとめて、ありがとう」のような混在は命令を優先 if not (has_act and (has_place or has_obj)): return "雑談" # 一人称の感想・独り言の語尾 if _ends_with(low, _MONOLOGUE_TAIL): return "雑談" # 自己開示(「私の名前は〇〇です」「僕は東京に住んでる」)は雑談で確定。 # 覚えるべき事実の型と同じパターンを使い回すので、追加コストはほぼ無い。 if not order_tail and any(p.search(raw) for p, _ in _FACT_PATTERNS): return "雑談" # --- 4) スロットが何も取れず、依頼語尾も無い短文 → 雑談寄りだが断定しない - if not slots and not order_tail: # 「うん」「はい」のような相づちは雑談で確定してよい if len(normalize(raw)) <= 3: return "雑談" return "あいまい" # --- 5) それ以外は判断保留 ------------------------------------------- return "あいまい" # ============================================================ # (2) 記憶 -- SQLite。埋め込みは使わない # ============================================================ _SCHEMA = """ CREATE TABLE IF NOT EXISTS turns ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS facts ( key TEXT PRIMARY KEY, value TEXT NOT NULL, ts REAL NOT NULL ); CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts); """ # 「私の名前は〇〇です」のような、そのまま覚えてよい事実の型。 _FACT_PATTERNS = [ (re.compile(r"(?:私|わたし|僕|ぼく|俺|おれ)の名前は([^\s。、,\.!!??]{1,20})"), "名前"), (re.compile(r"(?:私|わたし|僕|ぼく|俺|おれ)は([^\s。、,\.!!??]{1,20})(?:です|だ|と言います|といいます|と申します)"), "名前"), (re.compile(r"(?:私|わたし|僕|ぼく|俺|おれ)の(?:好きな|すきな)([^\sは]{1,10})は([^\s。、,\.!!??]{1,20})"), None), (re.compile(r"(?:私|わたし|僕|ぼく|俺|おれ)は([^\s。、,\.!!??]{1,20})に(?:住んで|すんで)"), "住んでいる場所"), (re.compile(r"(?:私|わたし|僕|ぼく|俺|おれ)の(?:仕事|職業)は([^\s。、,\.!!??]{1,20})"), "仕事"), ] # 事実の値の末尾に残りがちな丁寧語・助動詞。ここを削らないと「アキトです」と覚えてしまう。 _COPULA_TAIL = ("なんです", "なのです", "だったんだ", "なんだ", "ですよ", "だよ", "でした", "だった", "です", "だす", "だ", "ます") def trim_copula(value: str) -> str: """「アキトです」→「アキト」。短くなりすぎる場合は削らない。""" v = (value or "").strip() for t in _COPULA_TAIL: if v.endswith(t) and len(v) - len(t) >= 1: return v[: -len(t)] return v class Memory: """会話の記憶。SQLite で永続化する。 ・add() : 発言を 1 件ためる。事実らしければ facts にも自動で書く。 ・recent() : 直近の会話。 ・recall() : 関連する過去の発言。文字 2-gram の重なりを IDF で重み付け。 ・note() : 「私の名前は〇〇」のような事実を明示的に覚える。 """ def __init__(self, path: str = DEFAULT_DB): self.path = path # 画面(アプリ)からは別のスレッドで呼ばれる。 # 既定のままだと「別のスレッドからは使えない」と怒られる。 # 同時に触らないよう、呼ぶ側で順番を守っている(server._LOCK) self.conn = sqlite3.connect(path, check_same_thread=False) self.conn.executescript(_SCHEMA) self.conn.commit() # recall 用のキャッシュ。add のたびに捨てる。 self._cache = None # ------------------------------------------------ 書く def add(self, role: str, text: str) -> int: """発言を 1 件ためる。戻り値は行 id。""" text = (text or "").strip() if not text: return -1 ts = time.time() cur = self.conn.execute( "INSERT INTO turns(ts, role, text) VALUES(?,?,?)", (ts, role, text)) self.conn.commit() self._cache = None if role == "user": self._auto_note(text) return int(cur.lastrowid) def _auto_note(self, text: str) -> None: """発言から自明な事実を拾って facts に入れる。取れなければ何もしない。""" for pat, key in _FACT_PATTERNS: m = pat.search(text) if not m: continue if key is None: # 「私の好きな色は青」型: グループ1がキー、2が値 if m.lastindex and m.lastindex >= 2: self.note("好きな" + m.group(1), m.group(2)) else: self.note(key, m.group(1)) return def note(self, key: str, value: str) -> None: """事実を覚える(同じキーは上書き)。""" key = (key or "").strip() value = trim_copula(value) if not key or not value: return self.conn.execute( "INSERT INTO facts(key, value, ts) VALUES(?,?,?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value, ts=excluded.ts", (key, value, time.time())) self.conn.commit() def wipe(self) -> None: """覚えていることを全部消す(/forget ぜんぶ で呼ばれる)""" self.conn.executescript( "DELETE FROM turns; DELETE FROM facts;") self.conn.commit() # ------------------------------------------------ 読む def facts(self) -> dict: rows = self.conn.execute( "SELECT key, value FROM facts ORDER BY ts DESC").fetchall() return {k: v for k, v in rows} def recent(self, n: int = 8) -> list: """直近の会話を古い順で返す。[{"id","role","text","ts"}, ...]""" rows = self.conn.execute( "SELECT id, ts, role, text FROM turns ORDER BY id DESC LIMIT ?", (int(n),)).fetchall() rows.reverse() return [{"id": r[0], "ts": r[1], "role": r[2], "text": r[3]} for r in rows] # -------- recall: 文字 2-gram Jaccard + IDF 重み ---------------------- def _index(self): """全発言の n-gram と、gram ごとの出現文書数(df)を作ってキャッシュする。 埋め込みモデルは使わない。ここが「掛け算をしない」検索の本体。 """ if self._cache is not None: return self._cache rows = self.conn.execute( "SELECT id, ts, role, text FROM turns ORDER BY id").fetchall() docs = [] df = {} for rid, ts, role, text in rows: g = ngrams(text) if not g: continue docs.append((rid, ts, role, text, g)) for x in g: df[x] = df.get(x, 0) + 1 n_docs = max(1, len(docs)) # IDF: 珍しい gram ほど重い。よくある gram(「です」など)はほぼ 0 になる。 idf = {x: math.log(1.0 + n_docs / (1.0 + c)) for x, c in df.items()} self._cache = (docs, idf, n_docs) return self._cache def recall(self, query: str, n: int = 3, exclude_ids=None) -> list: """query に関連する過去の発言を上位 n 件返す。 戻り値: [{"id","role","text","score","ts"}, ...] スコア降順。 """ q = ngrams(query) if not q: return [] docs, idf, n_docs = self._index() skip = set(exclude_ids or ()) # 「私の名前は?」を投げた直後にその発言自身が最上位に来ないよう、 # 正規化して同一の文は結果から外す。 qn = normalize(query) # 未知の gram(この DB に一度も出ていない)は最大級に珍しいので重めに扱う unseen = math.log(1.0 + n_docs) def w(x): return idf.get(x, unseen) qw = sum(w(x) for x in q) out = [] for rid, ts, role, text, g in docs: if rid in skip or normalize(text) == qn: continue inter = q & g if not inter: continue iw = sum(w(x) for x in inter) uw = qw + sum(w(x) for x in g) - iw if uw <= 0: continue # 重み付き Jaccard。さらに「質問語がどれだけ拾えたか」で軽く補正。 score = (iw / uw) * (0.5 + 0.5 * (iw / qw if qw else 0.0)) out.append({"id": rid, "ts": ts, "role": role, "text": text, "score": round(score, 4)}) out.sort(key=lambda d: (-d["score"], -d["id"])) return out[:int(n)] def close(self): try: self.conn.close() except Exception: pass # ============================================================ # (3) 返事づくり -- ここだけ先生(LLM)を呼ぶ # ============================================================ # システムプロンプトは短く。長いと入力トークンが増えて遅くなる。 def _load_persona(): """人格の設定。persona.json があればそれを使う(自由に書き換えてよい)""" import json as _j p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "persona.json") d = {"名前": "カーネル", "説明": "この人のパソコンの中だけで動く、手作りの相棒", "口調": "親しい相手に話すように、2文以内で短く", "できること": ""} if os.path.exists(p): try: d.update(_j.load(open(p, encoding="utf-8"))) except Exception: pass return d _P = _load_persona() # 外部APIに丸投げすると「私はChatGPTです」と名乗ってしまうため、 # 名乗り方をこちらで固定する SYS = (f"あなたの名前は「{_P['名前']}」。{_P['説明']}。" f"ChatGPT・Claude・Gemini など他の名前を名乗ってはいけない。" f"名前を聞かれた時だけ「{_P['名前']}」と答える。" # これが無いと毎回「カーネルです。」で始めてしまい、読みづらくなる f"毎回名乗ってはいけない。返事を「{_P['名前']}です」で始めてはいけない。" f"分からないこと・覚えていないことは、正直に分からないと言う。" + (f"できることは次の通りで、これ以外はできない:{_P['できること']}。" if _P.get("できること") else "") + f"日本語で、{_P['口調']}答える。前置き・箇条書き・記号は使わない。" # ★ 掟(2026-09-11 実測で入れた)。32問で 行動を促す 6/16→12/16、お世辞は元から合わせない(14/16→16/16)。 # 「先に『違う』と言え」と書くと相談にまで「違う。」と返す(9/16)ので、その指示は入れないこと。 + (f"{_P['掟']}" if _P.get("掟") else "")) _ROLE_JA = {"user": "相手", "assistant": "あなた", "system": "メモ"} NARABI = os.environ.get("KERNEL_ZATSUDAN_NARABI", "会話が先") # ★ 2026-09-17: 頭脳が自分で道具を選ぶ(erabu.py)。一覧は system の末尾=頼み文の頭に来るので、使い回しが効く。 # KERNEL_DOUGU_ERABI=0 で前の形(道具を見せない)。 DOUGU_ERABI = os.environ.get("KERNEL_DOUGU_ERABI", "1") != "0" # ★ 2026-09-18: 道具か雑談かを 1トークンの確率で先に決める(kimeru・Jev の真似)。**既定は切**: # 物差し(dougu/hakaru_kimeru.py・36問)で 最良 22/36、返事に「道具: 言い方」を書かせる元の形は 約30/36。 # はい/いいえ の確かめも当てにならなかった(正しい道具に p=0.001、似た語だけの雑談に 0.88)。KERNEL_KIMERU=1 で試せる KIMERU = os.environ.get("KERNEL_KIMERU", "0") == "1" # ★ 2026-09-18: 直感役(chokkan.py・学習した小さな決定モデル)。KERNEL_CHOKKAN=0 で切る CHOKKAN = os.environ.get("KERNEL_CHOKKAN", "1") != "0" def sys_dougu() -> str: if not DOUGU_ERABI: return SYS try: import erabu return SYS + "\n\n" + erabu.oshie(kaku=not KIMERU) except Exception: return SYS def _build_prompt(text: str, history: list, memory: "Memory") -> str: """過去の会話 + 記憶から引いた関連事項を混ぜたプロンプトを作る。""" lines = [] # (a) 覚えている事実 if memory is not None: f = memory.facts() if f: lines.append("【覚えていること】") for k, v in list(f.items())[:6]: lines.append(" %s: %s" % (k, str(v)[:80])) # (b) 直近の会話に出てこない、関連する過去の発言 kanren = [] if memory is not None: recent_ids = {h.get("id") for h in (history or []) if isinstance(h, dict)} hits = memory.recall(text, n=2, exclude_ids=recent_ids) hits = [h for h in hits if h["score"] >= 0.05] if hits: kanren.append("【関連する昔の発言】") for h in hits: kanren.append(" %s: %s" % (_ROLE_JA.get(h["role"], h["role"]), str(h["text"])[:160])) # (c) 直近の会話 kaiwa = [] if history: kaiwa.append("【直近の会話】") # ★ 手元の頭脳は読み込みが 15〜30 t/s。頼み文 1トークンが 0.05秒。長いほど遅い。 for h in history[-4:]: if isinstance(h, dict): kaiwa.append(" %s: %s" % (_ROLE_JA.get(h.get("role"), "相手"), str(h.get("text", ""))[:200])) else: kaiwa.append(" %s" % (h,)) # ★ 2026-09-17: 並びは「会話が先・関連は後」。手元の頭脳は前と同じ頭の部分だけ使い回す(--cache-reuse は # 前へずれた行しか拾えない)。毎回変わる「関連する昔の発言」が会話の前にあると、会話が毎回読み直しになる。 # 操作の輪で 537→97 トークンになったのと同じ理屈。KERNEL_ZATSUDAN_NARABI=関連が先 で前の形。 if NARABI == "関連が先": lines += kanren + kaiwa else: lines += kaiwa + kanren lines.append("【今の発言】" + text) lines.append("これに短く返事して。") return "\n".join(lines) def reply(text: str, history: list, memory: "Memory") -> dict: """雑談への返事を作る。teachers.ask_panel で先生団に同時に聞き、速い方を採る。 戻り値: {"text": str, "teacher": str, "ms": int, "error": str|None} """ t0 = time.monotonic() out = {"text": "", "teacher": "", "ms": 0, "error": None} try: from teachers import ask_panel except Exception as e: out["error"] = "先生に接続できません: %s" % e out["ms"] = int((time.monotonic() - t0) * 1000) return out prompt = _build_prompt(text, history, memory) # ★ 2026-09-18: 返事を書かせる前に「道具か雑談か・どの用件か」を先に決める近道。 # 直感役 chokkan(学習した小さな決定モデル・数ミリ秒・確率つき)が既定。KERNEL_KIMERU=1 なら頭脳の 1トークン判定(不採用・比較用)。 # 自信が線より低ければ 今まで通り 頭脳が返事を書く(頭脳は返事の中で「道具: 言い方」も書ける=二段構え)。 # 材料が文から取れない用件(探す・書く・送る…)だけ、頭脳に「言い方」を 1行書かせる。 k = None if DOUGU_ERABI and CHOKKAN: try: import chokkan k = chokkan.kimeru(text) k["法"] = "直感" except Exception as e: k = {"error": "%s: %s" % (type(e).__name__, e)} elif DOUGU_ERABI and KIMERU: try: import erabu k = erabu.kimeru_dougu(sys_dougu(), prompt) k["法"] = "頭脳1トークン" if k.get("用件") and k.get("自信", 0.0) < erabu.SEN: k["用件"] = None except Exception as e: k = {"error": "%s: %s" % (type(e).__name__, e)} if k: out["決め"] = k name = (k or {}).get("用件") if name: try: import erabu slots = erabu.zairyou(name, text) if slots is not None: out.update(text="", teacher=k.get("法", "決め"), ms=int((time.monotonic() - t0) * 1000), 道具=(name, slots)) return out rows = ask_panel(prompt + "\n\n" + (erabu.IIKATA_TOI % name), system=sys_dougu(), timeout=90, fukasa=0) for r in rows: if not r.get("error") and (r.get("text") or "").strip(): hit = erabu.yomu(r["text"], name) if hit and hit[0]: out.update(text=r["text"].strip(), teacher=k.get("法", "決め") + "+" + r["teacher"], ms=int((time.monotonic() - t0) * 1000), 道具=hit) return out break # 言い方が作れなかった → 雑談の返事へ(下) except Exception as e: out["決め"] = dict(k, error="%s: %s" % (type(e).__name__, e)) try: # ★ 2026-09-12: 手元の頭脳に聞くようになったので、雲の上向けの 20秒では足りない。 # 深さは「おまかせ」(短い雑談は深さ0、多段の問いだけ深く)。画面は流しながら受け、止められる。 # ★ 深さは **相手の言葉** で決める。組み立てた prompt(履歴入り)で決めると長さで毎回「深く」になる。 import teachers as _T fukasa = _T.fukasa_miru(text) _T.mado_hyouji(True) try: rows = ask_panel(prompt, system=sys_dougu(), timeout=180, fukasa=fukasa) finally: _T.mado_hyouji(False) except Exception as e: out["error"] = "相談に失敗: %s" % e out["ms"] = int((time.monotonic() - t0) * 1000) return out # ask_panel は「速く返った順」。最初に成功したものを採用する。 for r in rows: if not r.get("error") and (r.get("text") or "").strip(): out["text"] = r["text"].strip() out["teacher"] = r["teacher"] out["ms"] = int((time.monotonic() - t0) * 1000) if DOUGU_ERABI: try: import erabu out["道具"] = erabu.yomu(out["text"]) # (用件, 材料) / ("", 言い方)=当たらず / None except Exception: out["道具"] = None return out errs = [("%s: %s" % (r.get("teacher"), r.get("error"))) for r in rows] out["error"] = "先生が全滅: " + ("; ".join(errs) if errs else "応答なし") out["ms"] = int((time.monotonic() - t0) * 1000) return out def ask_teacher_classify(text: str) -> str: """classify が「あいまい」を返した時だけ使う、先生への分類相談。 ここは遅い(0.5〜0.7秒)。呼び出し側が必要なときだけ呼ぶこと。 失敗したら安全側("雑談")に倒す。 """ try: from teachers import ask_panel except Exception: return "雑談" prompt = ("次の発言はPCへの操作命令か、ただの雑談か。" "「命令」か「雑談」のどちらか一語だけを出力せよ。\n発言: " + text) try: rows = ask_panel(prompt, system="一語だけ出力する。", timeout=15, fukasa=0) except Exception: return "雑談" for r in rows: t = (r.get("text") or "") if not r.get("error"): if "命令" in t: return "命令" if "雑談" in t: return "雑談" return "雑談" # ============================================================ # (4) スロット取り -- kernel.py があれば使い、無ければ素通し # ============================================================ def draw_slots(text: str) -> dict: """kernel.draw_cards() でスロットを埋める。kernel が読めなければ空辞書。""" try: import kernel try: kernel.load_learned() except Exception: pass return kernel.draw_cards(text) or {} except Exception: return {} # ============================================================ # (5) CLI # ============================================================ def _show_mem(mem: Memory) -> None: f = mem.facts() print("--- 覚えている事実 (%d 件) ---" % len(f)) for k, v in f.items(): print(" %s = %s" % (k, v)) rows = mem.recent(20) print("--- 直近の会話 (%d 件) ---" % len(rows)) for r in rows: print(" [%d] %-9s %s" % (r["id"], r["role"], r["text"])) def main(argv: list) -> int: db = DEFAULT_DB if len(argv) > 1: db = argv[1] mem = Memory(db) print("chat.py -- 会話層 (記憶: %s)" % db) print(" :quit で終了 / :mem で記憶を表示") while True: try: line = input("\nあなた> ").strip() except (EOFError, KeyboardInterrupt): print() break if not line: continue if line in (":quit", ":q", ":exit"): break if line == ":mem": _show_mem(mem) continue t0 = time.monotonic() slots = draw_slots(line) kind = classify(line, slots) ms = int((time.monotonic() - t0) * 1000) print(" [分類] %s (%dms, スロット=%s)" % (kind, ms, slots or "なし")) if kind == "あいまい": kind = ask_teacher_classify(line) print(" [分類] 先生に確認 → %s" % kind) mem.add("user", line) if kind == "命令": print(" これは命令です(kernel側で処理)") continue r = reply(line, mem.recent(8), mem) if r["error"]: print(" [エラー] %s" % r["error"]) else: print("AI> %s" % r["text"]) print(" (%s / %dms)" % (r["teacher"], r["ms"])) mem.add("assistant", r["text"]) mem.close() return 0 if __name__ == "__main__": sys.exit(main(sys.argv))