Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ SSE は購読登録と初期スナップショットの取得を中断なしに

[API の型生成](docs/generated/README.md)でバックエンドのモデルから OpenAPI と TypeScript 型を生成する。フロントエンドの Adapter は生成した型を使用し、API モデルを独自に再定義しない。

ルーム名とトークの背景の上限はOpenAPIから [form-limits.ts](frontend/src/generated/form-limits.ts) へ生成する。Home MediatorとOperator MediatorがUnicodeコードポイントで保存前に検査し、上限超過時も下書きを保持する。背景の検査は共通の `backgroundValidation` を使う。Viewは入力を途中で切らず、担当するフォーム内に検証結果を表示する。APIのPydantic検証も保持する。

## 辞書の制限と検証

入力上限は [models.py](backend/app/models.py) の `DICTIONARY_LIMITS` に置き、Pydanticモデルと、[生成した辞書規則](frontend/src/generated/dictionary-rules.ts)で共有する。`check:api` が上限値とUnicode case foldingの一致を検査する。APIの型は従来どおりOpenAPIから生成し、画面の下書きや操作イベントとは分ける。
Expand Down
11 changes: 11 additions & 0 deletions backend/tests/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ def receive(client, room, text, identifier="same"):
return response.json()


def test_room_name_and_background_use_unicode_character_limits_and_keep_saved_settings():
name, background = "𠮷" * 120, "𠮷" * 4000
with TestClient(create_app(environment={})) as client:
room = create_room(client, name, background)
for invalid_name, invalid_background in ((name + "𠮷", background), (name, background + "𠮷")):
response = client.put(f"/api/rooms/{room['id']}", json={"name": invalid_name, "ai": {"background": invalid_background}})
assert response.status_code == 422
saved = client.get(f"/api/rooms/{room['id']}").json()
assert saved["name"] == name and saved["background"] == background


def test_switching_rooms_preserves_each_caption_and_background_but_rejects_old_tab_writes():
with TestClient(create_app(environment={})) as client:
first = create_room(client, "作家へのインタビュー", "写真と布の展示")
Expand Down
2 changes: 1 addition & 1 deletion docs/ai-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Geminiは[generateContent](https://ai.google.dev/api/generate-content)、Claude

## トークの背景と直近の字幕

「トークの背景」にはテーマ、登壇者、作品名などを4,000文字まで入力できる。保存後の確定字幕から使い、既存の字幕を再校正しない。背景はルームのAI設定として保存し、再起動後も保持する。直近の字幕はメモリ上にあり、再起動で消える。
「トークの背景」にはテーマ、登壇者、作品名などを4,000文字まで入力できる。「𠮷」などの補助漢字も1文字と数える。上限を超えた下書きは保持し、保存前にエラーを表示する。保存後の確定字幕から使い、既存の字幕を再校正しない。背景はルームのAI設定として保存し、再起動後も保持する。直近の字幕はメモリ上にあり、再起動で消える。

各要求では、その時点の背景、単語辞書、同じ接続の直前の確定字幕を取得する。直近の字幕は採用済みの表示文を古い順で最大6件・合計3,000文字まで渡し、文の途中では切らない。対象自身・後続字幕・未確定字幕・以前の接続の履歴は混ぜない。接続先や入力元を変えた場合は履歴の範囲をリセットし、同じ入力の送信形式だけを変えた場合は保つ。

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions docs/assets/unicode-form-limits-2026-10-25/verification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"checked_at": "2026-10-25T22:16:23.909517+09:00",
"environment": "隔離した実FastAPIと背景の実ブラウザ。架空の文字列のみ使用。音声・外部AIなし。",
"browser": {
"room_name_limit": 120,
"background_limit": 4000,
"supplementary_background_utf16_length": 8000,
"oversized_name_retained": 121,
"oversized_background_retained": 4001,
"invalid_submit_blocked": true,
"keyboard_submit_blocked": true,
"ai_off_invalid_draft_retained": true,
"corrected_background": "短い背景",
"input_focus_retained": true,
"ai_off_background_hidden_only_after_save": true,
"cancelled_form_discarded": true,
"existing_room_reconnected": true,
"mobile_viewport_width": 390,
"mobile_document_width": 390,
"console_errors": 0
},
"api": {
"oversized_room_statuses": [
422,
422
],
"oversized_ai_status": 422,
"saved_settings_preserved": true,
"saved_strategy": "off",
"saved_name_characters": 120,
"public_captions_before_save": 0,
"public_captions_after_save": 1,
"published_text": "展示の新しい見方を、一緒に探してみましょう。",
"human_edited": true
},
"checks": {
"command": "bash scripts/check.sh",
"node": "22.23.4",
"frontend_passed": 145,
"backend_passed": 196,
"pyright_errors": 0
},
"audit": {
"separate_session": true,
"p0_p1_findings": 0
},
"audio_started": false
}
39 changes: 0 additions & 39 deletions docs/exec-plans/active/2026-10-25-unicode-form-limits.md

This file was deleted.

1 change: 1 addition & 0 deletions docs/generated/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ API モデルを変更するときに参照する。生成の流れは次のと
| OpenAPI の生成 | [scripts/export_openapi.py](../../scripts/export_openapi.py) → [openapi.json](openapi.json) |
| TypeScript の生成 | `openapi-typescript` → [frontend/src/generated/api.d.ts](../../frontend/src/generated/api.d.ts) |
| 辞書の入力制限と文字変換表 | [scripts/export_openapi.py](../../scripts/export_openapi.py) → [dictionary-rules.ts](../../frontend/src/generated/dictionary-rules.ts)。Pydanticモデルと同じ上限値、サーバーのPythonと同じUnicode case foldingを使う |
| ルーム名・背景の入力制限 | OpenAPI → [form-limits.ts](../../frontend/src/generated/form-limits.ts)。画面の保存前検査でAPIと同じ文字数を使う |

入力、操作画面用字幕、観客用字幕、セッション、表示切り替えの型をフロントエンドへ渡す。公開モデルの境界は[システム構成](../../ARCHITECTURE.md#api-と公開データの境界)を参照する。

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
| UDトークへ訂正を戻す接続を調べる | [編集通信の解析](research/udtalk-editor-protocol.md)・[公開資料](research/udtalk-editor-protocol-sources.md) | 純正編集アプリの要求形式、オフライン実行結果、復旧後の実反映と未確認事項 |
| AIを選び、速度と費用を比較する | [AIの選択と振り分け](ai-routing.md) | GPT-6 Luna・GPT-6.1 Sol・Claude・Gemini、文脈と料金の根拠、振り分けと実接続の確認 |
| 固有名詞・専門用語を登録する | [単語辞書](dictionary.md) | 表記・読み・誤認識例、認識と校正への反映、保存先 |
| API モデルを変更する | [API の型生成](generated/README.md) | 生成元、再生成、型の一致確認 |
| API モデルを変更する | [API の型生成](generated/README.md) | 生成元、再生成、型と入力制限の一致確認 |
| 新しい機能の要件を検討する | [v0.2 の設計案](design-v0.2.md) | 将来要件、未確定事項、受け入れ条件の案 |
| マイクから文字起こしする | [マイク入力](microphone-input.md) | OpenAI・Appleの準備、開始・停止、試験上限、確認範囲 |
| クラウド音声認識を比較する | [クラウド候補の調査](research/2026-10-10-cloud-transcription.md) | Google・Azure・AmiVoice・AWS などの日本語対応、途中結果、料金、データの扱い |
Expand Down
2 changes: 2 additions & 0 deletions docs/rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
2. ルーム名と配信先を選びます。「UDトークへ反映」では編集用の参加リンク、「独立配信」では字幕の入力元を指定します。必要ならトークの背景を記入します。
3. 「登録して接続」を押します。登録済みのルームは一覧の「接続」から開けます。設定アイコンから名前・接続先・背景を変更できます。

ルーム名は120文字、トークの背景は4,000文字まで入力できます。「𠮷」などの補助漢字も1文字と数えます。上限を超えた場合は入力内容を残し、保存前にエラーを表示します。

字幕入力の接続は同時に一つです。別のルームへ接続すると、前のルームの入力と処理中のAI校正を終了します。ルームを切り替えても、それまで受け取った字幕はルームごとに保持します。ホームへ戻る操作でも、現在の入力を終了します。

形式が不正なリンクと閲覧専用リンクは、接続を切り替える前に拒否します。保存済みの編集用リンクは画面へ返しません。接続先を変えるときだけ、新しいリンクを入力します。参加キーの有効性はUDトークの応答で判断します。
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/ai.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formLimits } from "./generated/form-limits";
import type { AiRouting, AiSettings } from "./domain";

export const defaultAiSettings: AiSettings = {
Expand All @@ -22,3 +23,8 @@ export function editableAiSettings(ai: AiRouting): AiSettings {
export function knownAiModel(value: string): value is AiSettings["preferred_model"] {
return defaultAiSettings.candidates.some(model => model === value);
}

export function backgroundValidation(value: string): string {
return Array.from(value).length > formLimits.background
? `トークの背景は${formLimits.background.toLocaleString("ja-JP")}文字以内で入力してください。` : "";
}
2 changes: 2 additions & 0 deletions frontend/src/generated/form-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Generated from OpenAPI by scripts/export_openapi.py; do not edit.
export const formLimits = {"roomName":120,"background":4000} as const;
14 changes: 10 additions & 4 deletions frontend/src/machines/home.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { formLimits } from "../generated/form-limits";
import { assign, fromPromise, setup, type SnapshotFrom } from "xstate";
import { defaultAiSettings } from "../ai";
import { backgroundValidation, defaultAiSettings } from "../ai";
import { OperationError, type AiSettings, type HomeEvent, type RoomCatalog, type RoomDetails, type RoomsApi, type ViewEvent } from "../domain";

interface Form {
Expand Down Expand Up @@ -28,6 +29,11 @@ const emptyForm = (): Form => ({ name: "", mode: "client", inputSource: "udtalk"
const message = (error: unknown) => error instanceof OperationError ? error.message : "ルームの接続を確認できませんでした。もう一度お試しください。";
const knownRoom = (context: Context, event: HomeEvent) => "roomId" in event && context.catalog.rooms.some(room => room.id === event.roomId);

const roomFormValidation = (form: Form) => Array.from(form.name.trim()).length > formLimits.roomName
? `ルーム名は${formLimits.roomName}文字以内で入力してください。` : backgroundValidation(form.ai.background);
const canSubmitRoom = (context: Context) => context.formOpen && Boolean(context.form.name.trim())
&& !roomFormValidation(context.form) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink);

export const homeMachine = setup({
types: {} as { context: Context; events: ViewEvent; input: { api: RoomsApi } },
actors: {
Expand All @@ -40,9 +46,9 @@ export const homeMachine = setup({
},
guards: {
knownRoom: ({ context, event }) => knownRoom(context, event as HomeEvent),
validForm: ({ context }) => context.formOpen && Boolean(context.form.name.trim()) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink),
validForm: ({ context }) => canSubmitRoom(context),
submitKey: ({ context, event }) => event.type === "home.form.keypressed" && event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.isComposing
&& context.formOpen && Boolean(context.form.name.trim()) && (context.form.mode === "host" || Boolean(context.form.editorUrl.trim()) || context.form.storedLink),
&& canSubmitRoom(context),
},
actions: {
newForm: assign(({ context }) => ({ form: emptyForm(), formOpen: true, editingRoomId: null, message: "", focusVersion: context.focusVersion + 1 })),
Expand All @@ -54,7 +60,7 @@ export const homeMachine = setup({
if (event.field === "editorUrl") form.editorUrl = event.value;
if (event.field === "mode" && (event.value === "host" || event.value === "client")) form.mode = event.value;
if (event.field === "inputSource" && (event.value === "udtalk" || event.value === "microphone")) form.inputSource = event.value;
return { form, message: "" };
return { form, message: roomFormValidation(form) };
}),
requestRoom: assign(({ event }) => "roomId" in event ? { requestedRoomId: event.roomId, message: "" } : {}),
},
Expand Down
Loading
Loading