ステータス: 設計中(Issue #8)。一部(役割分担・エンドポイント契約の骨格)は旧版から継承、一部(認証)は新規
改訂: 2026-09-26 Brainの停止処理中に返す503を追加した(4節、9節。#15 #issuecomment-6624の確認事項2に運用者が同意)
改訂: 2026-09-26 UIプロセスの稼働状態の通知と状態取得は別文書で定義する旨の参照を、1節に追加した(#16 #issuecomment-6644の4に運用者が同意)
本書は、複数のUI層(音声UIAiChat、将来のテキストUI等)がWSL側のBrain(AiChatWls)へ接続する際に交わすHTTP APIの契約を、本書単体で完結する形で定義する。単一UI(音声UI)の要請から場当たり的に決めるのではなく、複数UI層が同時にBrainへ接続する構成を前提として設計する。UI固有の実装(音声UIのadjust_volume等)は、本書を土台とした付録文書で別途定義する。
control、後述)の解釈・実行は、UI層の責務とする。本書が定義するのは会話プロトコル(/ask・keepalive・/clear_history・/health/*)である。UIプロセスの稼働状態をBrainへ伝える管理プロトコル(管理heartbeat・終了通知・停止への応答、/runtime/*)と、Brain・UIの状態取得は、本書では定義しない。ランタイム管理 設計§7・§8と、詳細設計: UIプロセスの観測と状態取得で定義する。セッションのkeepalive(5節)は会話履歴の維持のためのものであり、UIプロセスの生存確認には使わない(設計書§10)。
単一プロセスでも、session_idが異なれば別々のロック・別々のOllamaChatインスタンス(=別々の会話履歴)を使うため、Brain側のセッションロックでは互いにブロックしない。同一セッション内の連続する発言は同じロックで直列化されるため、会話の継続(履歴の整合性)も保たれる。単一プロセスの制約が効くのは「プロセス全体としての処理能力の天井」であって、「複数セッションを同時に正しく継続できるか」ではない。ただし、Ollama自体の同時実行能力(モデルの並列度設定)によっては、Brainのロックが空いていてもOllama側の順番待ちで遅延することがある。それはBrainの設計とは別の話であり、本書のスコープ外。
Brainは信頼境界(自宅LAN)内での利用を前提としつつ、複数クライアントが同時に接続する構成になるため、最低限のアクセス制御として共有トークンによる認証を導入する。
BRAIN_AUTH_TOKEN)を.envに持つ。トークンはリポジトリにコミットしない(既存のAUDIO_OUTPUT_VOLUME等と同じ.env管理)。Authorization: Bearer <token>ヘッダーを付与する。/health/live含む)でトークンを検証し、一致しない場合は401 Unauthorizedを返す。openssl rand -hex 32を生成し、Brain側・クライアント側それぞれの.envに設定)。ローテーションの自動化は今回のスコープ外。127.0.0.1:8811相当)で運用されており、この認証は現在の攻撃面を減らす対策ではなく、将来LAN/他デバイスへ公開する際に効く保険として先に入れておくもの。「認証があるから外部公開しても安全」と判断しないこと。外部公開を検討する時点で、この節全体を改めて見直す。BRAIN_AUTH_TOKEN = os.environ["BRAIN_AUTH_TOKEN"]
async def verify_token(authorization: str = Header(default="")) -> None:
scheme, _, token = authorization.partition(" ")
if scheme != "Bearer" or not secrets.compare_digest(token, BRAIN_AUTH_TOKEN):
raise HTTPException(status_code=401, detail="invalid or missing token")
app = FastAPI(dependencies=[Depends(verify_token)])
| メソッド・パス | 用途 |
|---|---|
POST /ask |
1回のユーザー入力に対する応答を得る(7節) |
POST /session/{session_id}/keepalive |
セッションの生存を通知する |
POST /clear_history |
セッションの会話履歴を消去する |
GET /health/live |
プロセスが生きているかの疎通確認 |
GET /health/ready |
Ollama等、依存先まで含めた疎通確認 |
Brainの停止処理中は、/ask・/session/{session_id}/keepalive・/clear_historyが503を返す(9節「Brainの停止処理中の503」)。/health/*は停止処理中も応答する。
/ask以外の4エンドポイントの契約は以下の通り。
POST /session/{session_id}/keepalivesession_idはパス変数。session_idでも新規セッションを作成する(/askと同じ_acquire_session経由。5節)。空の会話履歴を持つセッションが作られるだけで、実害はない。200 {"status": "ok"}。last_heartbeatを更新する(5節)。POST /clear_historyリクエスト:
{ "session_id": "string" }
session_idの場合は何もせず200 {"status": "ok"}を返す(no-op、エラーにしない)。_acquire_sessionでピン留めしsession.lockを取得した上で、会話履歴・last_request_id・last_result(8節の冪等性キャッシュ)を同時にクリアする。/clear_historyは、そのセッションの/ask処理または再試行と同時に呼び出してはならない。呼び出し側は、未確定の(応答をまだ受け取っていない)リクエストをすべて断念してから履歴を消去すること。この契約に反した場合の挙動は未定義(8節「適用範囲・限界」と同じ残存リスク)。GET /health/live200 {"status": "ok"}。GET /health/ready/api/tags)への疎通を確認する。失敗時は500。500。200 {"status": "ok"}。session_id)session_idを1つ生成し(uuid4())、プロセスの生存期間中ずっと使い回す。プロセス再起動時は新しいIDに切り替わり、以前の履歴を引き継がない。session_idを生成するため、複数クライアントが同時に接続しても採番の衝突・調整は不要(UUIDの衝突は無視できる確率)。Brain側がsession_idを発行・管理する仕組みは設けない。session_idごとに、会話履歴・OllamaChatインスタンス・ロック・TTL等をプロセス内メモリの辞書(dict[session_id, Session])で保持する。ttl_sec(セッションごとに保持、7節でUIが/askごとに指定するsession_ttl_secで更新)以上経過し、かつ処理中のリクエストが0件のセッションを回収してよい。/askは処理開始時と完了時の両方で更新する。開始時だけだと、TTLを短く設定したセッションで、処理完了直後・次のスイープ周期で即座に回収されうるため。POST /session/{id}/keepaliveの呼び出しも更新のトリガーになる。SWEEP_INTERVAL_SEC=5分間隔でBrainが掃除する前提に対し、UI側は60秒程度を目安に)POST /session/{id}/keepaliveを呼ぶ。ただし本書はこのハートビート送信ループ自体の実装は必須としない。1回限りの用途(比較試験スクリプト等)はハートビートを送らなくてよく、いずれ上記TTLで回収される。session.lock(threading.Lock)で同一セッションへの処理を直列化する。ロックの取得・解放は各エンドポイントハンドラだけが行い(/ask・/clear_history)、辞書アクセス用のヘルパーはロックに一切触れない。理由: ヘルパーがロック取得まで担当すると、非再入可能なthreading.Lockのため呼び出し側が二重に取得しようとして自己デッドロックする経路が生まれる。active_requests): セッション辞書自体はsessions_guardという別のロックで保護する。sessions_guardを長時間(=セッションのLLM処理時間)保持したままsession.lockの取得を待つと、その間ほかの全セッションの作成・回収・履歴消去が止まり、2節で示した「別セッションは互いにブロックしない」という並行性が壊れる。これを避けるため、辞書アクセスはactive_requestsカウンタの増減だけで完結させ、sessions_guardを保持する時間を最小化する。@dataclass
class Session:
chat: OllamaChat
lock: threading.Lock = field(default_factory=threading.Lock)
last_heartbeat: float = field(default_factory=time.monotonic)
ttl_sec: float = DEFAULT_SESSION_TTL_SEC
active_requests: int = 0 # sessions_guard配下でのみ増減する
def _acquire_session(session_id: str) -> Session:
with sessions_guard:
session = sessions.get(session_id)
if session is None:
session = Session(chat=chat_factory())
sessions[session_id] = session
session.active_requests += 1
return session
def _release_session(session: Session) -> None:
with sessions_guard:
session.active_requests -= 1
def _sweep_once() -> None:
now = time.monotonic()
with sessions_guard:
expired_ids = [
session_id
for session_id, session in sessions.items()
if session.active_requests == 0 and now - session.last_heartbeat > session.ttl_sec
]
for session_id in expired_ids:
session = sessions.pop(session_id)
session.chat.close()
/ask・/clear_historyは_acquire_sessionでピン留めしてからwith session.lock:で処理し、finallyで_release_sessionする(8節参照)。ピン留め中(active_requests > 0)のセッションはTTLが過ぎていてもスイーパーの対象にならないため、処理中に取り上げられることはない。uvicorn --workers 1固定)を前提とする。複数ワーカー・複数プロセスでの水平スケールは、セッション状態をRedis等の外部ストアへ移す必要があり、今回のスコープ外とする。session_idを持つだけなので単一プロセスのままで成立する)。スコープ外なのは、単一プロセスの処理能力を超える負荷への対応。client_type"voice" または "text"を指定する。音声UI向けの簡潔な口調・URL除去は、client_typeの値でBrainが切り替える(Brain側が呼び出し元を決め打ちしない)。未知の値は400エラーとする(新しいUI種別を追加する場合は、Brain側でclient_typeの扱いを明示的に追加してから接続を開始すること。無申告の値をなし崩しに許容しない)。
POST /ask{
"session_id": "string",
"request_id": "string",
"text": "string",
"client_type": "voice | text",
"history_turns": 6,
"session_ttl_sec": 86400
}
request_id: 1回のユーザー入力(発話・テキスト送信)につき1つ生成し、その入力に対する再送(8節)では同じ値を使い回す。新しい入力には新しいrequest_idを生成する。history_turns(任意、整数): 次回以降、LLMへ渡す会話履歴の上限ターン数。Brainの.envではなく、UI層がリクエストごとに指定する(音声UIは応答速度優先で短め、テキストUIは長めの文脈を許容する、といった判断はUI層の責務であり、Brainが一律の値を決め打ちしない)。Brainは受け取った値をそのセッションの現在の設定として上書きする(次回の履歴トリミングから反映。値を後から増やしても、既に切り捨てた履歴は復元されない)。省略時はBrain側の既定値(6)を使う。0は「履歴を一切保持しない」を意味する。Brainは0〜50にクランプする(暴走防止)。session_ttl_sec(任意、整数): 最終ハートビートから何秒でこのセッションを回収してよいか(5節)。これもUI層が指定する(短命な検証スクリプトは短く、常駐する音声UIは長く、といった判断はUI層の責務)。Brainは受け取るたびにそのセッションのTTL設定を上書きする。省略時はBrain側の既定値(86400秒=24時間)を使う。Brainは60〜604800(1分〜7日)にクランプする。{
"conversation": "string",
"control": [ { "type": "string", "...": "..." } ]
}
conversation: 読み上げ・表示すべき自然文。0文字あり得る(何も言うことがない場合。エラーではない)。control: UI層が解釈・実行すべきアクションの配列。0件以上。controlの一般規則Brainはデバイス状態を一切持たないため、controlの各要素は「意図・方向」だけを表現し、具体的な数値・現在値は含まない(現在値をUIからBrainへ送る必要をなくすため)。個々のtypeが何を意味するかは、本書ではなく、それを解釈するUI固有の付録文書で定義する(例: 音声UIのadjust_volume)。
既知の制約: 8節の通り、同一/ask呼び出し内で同じtypeのアクションは1件に重複除去される。これは再試行時の二重適用を防ぐためだが、副作用として「同一発話内で同じtypeの異なる意図(例: 音量を上げてから下げる)」も1件に潰れる。現状controlのtypeはadjust_volumeのみで、この副作用が実害になる場面は想定しにくいため、今は対応しない。typeが複数種類に増える時点で、type単位の重複除去が妥当かどうかを再検討する。
/ask呼び出し内で、tool-callingの複数ラウンドや後述の再試行によって同じtypeのアクションが複数回発生しても、Brainはcontrolに同じtypeを1件しか含めない(重複除去はBrain側の責務)。typeや不正な内容を例外にせず無視してよい(ログに残すことを推奨)。conversationが空文字でcontrolも空の場合のみ、Brainは「うまく答えられませんでした。もう一度お願いします。」のような定型の失敗文言をconversationに設定する。controlが非空なら、conversationが空でもこの定型文で上書きしない(アクションは成功しているのに失敗したと誤って伝えることになるため)。request_idによる重複防止)/askは冪等ではない。呼び出すたびに、Brain側の会話履歴に追記し、controlが返されればUI側でも実行される。
UIがタイムアウトやネットワーク断で応答を受け取れなかった場合、Brain側では次のいずれかが起きている可能性がある。
3番のケースで単純に同じ入力を再送すると、Brain側で同じ入力がもう一度処理され、会話履歴の二重追加や、UI側でのcontrolの二重適用(例: 音量が2段階分変わる)が起こる。
session_idごとに、直近に処理したrequest_id・そのリクエスト内容のハッシュ・結果を保持する。request_idが保持している値と一致し、かつリクエスト内容(text・client_type)のハッシュも一致する場合、OllamaChat.ask()を再実行せず、保持している結果をそのまま返す。request_idは一致するがリクエスト内容が一致しない場合(request_idの使い回しバグ等)、409 Conflictを返す。誤って別内容の結果を黙って返すより、バグとして顕在化させる。history_turns・session_ttl_secはセッションの設定であり「リクエスト内容」には含めない。再送のたびに値が違っても同一性判定には影響させず、単純に最新の値でセッション設定を上書きする。request_idが一致しない場合は通常通り処理し、結果を新しいrequest_id・ハッシュと共に保持し直す。lock(5節)により同一セッションへのリクエストは直列化されているため、「処理中に別リクエストが同時に同じrequest_idを処理してしまう」競合状態は発生しない(2番のケースで届いた再送は、ロック取得まで待たされた後、この一致判定によってそのまま前回結果を受け取る)。last_resultは直近1件だけを保持すればよい。既存のセッション掃除処理(TTL)の対象にもそのまま含まれる。/clear_history(4節)は、このlast_request_id・last_resultも同じsession.lock内で一緒にクリアする。これは通常経路(同一request_idでの再試行)における重複防止であり、あらゆる状況を含めた厳密なat-most-once保証ではない。 具体的には次の残存リスクを受容する。
UI側の契約として、「同一セッションでは、あるリクエストの成功または再試行断念が確定するまで、次の入力を送らない」ことを前提とする。しかし、UI側が全リトライを断念した時点で、そのHTTPリクエストがBrain側でまだ処理中(セッションロック待ちも含む)のままであることがあり得る。UIが断念後に別の新しい入力を送ると、次の順序が起こり得る。
r1が処理中(またはロック待ち)r1の全リトライがタイムアウトし、断念するr2を送信r1の処理が先に終わっていれば問題ないが、スレッドの実行順序次第ではr2の処理後にr1(のロック待ちだったコピー)が処理され、断念されたはずのr1がr2より後に反映されるこの経路は、UIの契約だけでは塞げない(Brain側の処理をUIから中断させる手段がないため)。個人用・loopback運用としてはこの残存リスクを受容し、厳密な保証は実装しない。将来、複数UI・高頻度利用等で厳密な保証が必要になった場合は、以下のいずれかへ拡張する。
request_id → resultをTTL付きで保持する(直近1件に限定しない)server.py、設計)class AskRequest(BaseModel):
session_id: str
request_id: str
text: str
client_type: str
history_turns: int | None = None
session_ttl_sec: float | None = None
@dataclass
class Session:
chat: OllamaChat
lock: threading.Lock = field(default_factory=threading.Lock)
last_heartbeat: float = field(default_factory=time.monotonic)
ttl_sec: float = DEFAULT_SESSION_TTL_SEC
active_requests: int = 0
last_request_id: str | None = None
last_content_hash: str | None = None
last_result: AskResult | None = None
def _content_hash(request: AskRequest) -> str:
return hashlib.sha256(f"{request.text}\x00{request.client_type}".encode()).hexdigest()
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
if request.client_type not in ("voice", "text"):
raise HTTPException(status_code=400, detail="client_type must be 'voice' or 'text'")
session = _acquire_session(request.session_id) # 5節。active_requests += 1
try:
with session.lock:
session.last_heartbeat = time.monotonic()
session.ttl_sec = _clamp(request.session_ttl_sec, DEFAULT_SESSION_TTL_SEC, 60, 604800)
session.chat.max_history_turns = _clamp(request.history_turns, 6, 0, 50)
content_hash = _content_hash(request)
if request.request_id == session.last_request_id:
if content_hash != session.last_content_hash:
raise HTTPException(status_code=409, detail="request_id reused with different content")
result = session.last_result
else:
result = session.chat.ask(request.text, client_type=request.client_type)
session.last_request_id = request.request_id
session.last_content_hash = content_hash
session.last_result = result
session.last_heartbeat = time.monotonic() # 完了時にも更新(5節)
return AskResponse(conversation=result.answer, control=result.control)
finally:
_release_session(session) # 5節。active_requests -= 1
request_idのUUIDと機能的には同等。「適用範囲・限界」に記載の残存リスクは残るため、厳密な保証まで求める場合の選択肢として位置づけを変更したが、今回は採用しない。カウンタの永続化・セッション再生成時のリセットを気にしなくていいUUIDの方がシンプルと判断した。以下は本書の対象外であり、各UIの付録文書がUI固有の判断として定義する。
本書が提供するのは、これらの判断をUI側が安全に行えるようにする土台(request_idによる重複防止。ただし8節「適用範囲・限界」の残存リスクあり)だけである。
4xxはリトライ対象外400(不正なclient_type)・401(認証失敗)・409(request_idの使い回し、8節)・422(必須項目不足・型不正、FastAPI/Pydanticの標準応答)など、4xxはすべてネットワーク断・タイムアウトのような一時的な失敗ではなく、設定・実装不備によるハードエラーである。個別のコードを列挙して判定するのではなく「4xx全体をリトライ対象外」として扱う方が、新しいエラーコードが増えても取りこぼさず堅牢である。再試行しても状況は変わらないため、UI側の実装はこれらを一時的な通信失敗のリトライ経路に混ぜず、既存の「うまく答えられませんでした。もう一度お願いします。」と同じ失敗応答(そのターンを終了し待受に戻る)として扱うこと。ここを見落とすと、HTTPエラー→未処理例外→クラッシュ→自動再起動→同じエラーの再発、という再起動ループに陥る恐れがある(音声UI側で過去にAUDIO_INPUT_DEVICE不正値により実際に発生した障害パターンと同型)。UI固有の付録文書には、このケースを明示的なテスト項目として含めること。
503Brainは、正式な停止(実行環境からの終了シグナル)を受けると停止処理中に入り、その後に届いた/ask・/session/{session_id}/keepalive・/clear_historyを処理せず、次を返す(認証の判定は先に行うため、認証に失敗した要求は401のまま)。
503 Service UnavailableRetry-After: 5{"detail": "Brainは停止処理中です", "reason": "brain_stopping"}これは一時的な失敗であり、上記の4xxとは異なり、UIは再試行の対象にできる。実際に再試行するか、何回・どの間隔で行うかは、UI固有の判断である(本節の冒頭)。停止処理中に入る前に受け付けた要求は、上限時間まで完了を待たれ、上限を過ぎると打ち切られる。打ち切られた要求は、UIからは接続の切断として見える。打ち切られた要求は会話履歴にも冪等性キャッシュにも残らない。停止シーケンス全体(観測中のUIへの通知と応答を含む)はランタイム管理 設計§7.7、Brain側の詳細は詳細設計§6で定める。
src/keina_assistant/のコードで確認した)POST /askが{conversation, control}を返す変更: 実装・テスト済み(src/keina_assistant/server.py, llm.py, tools.py)。active_requestsピン留め方式): 実装・テスト済み(2026-09-18、コミットdefaced、Issue #10)。_acquire_session/_release_sessionに置き換え、旧方式のTOCTOU上のバグは解消した。401(fail-closed)。request_idによる冪等性・内容ハッシュ照合・409(8節): 実装・テスト済み(同上)。history_turns/session_ttl_secのUI供給化(7節): 実装・テスト済み(同上)。リクエストで受け取った値をクランプしてセッションに反映する。.envのMAX_HISTORY_TURNSは、新規セッションの初期値としてのみ使う。関連: Issue #9。/clear_historyが冪等性キャッシュ(last_request_id/last_result)を一緒にクリアする件: 実装・テスト済み(同上)。503(9節): 実装・テスト・実機確認済み(2026-09-26、Issue #15。src/keina_assistant/server.py。実機確認は#15 #issuecomment-6630)。