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
8 changes: 8 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ SSE は購読登録と初期スナップショットの取得を中断なしに

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

## 辞書の制限と検証

入力上限は [models.py](backend/app/models.py) の `DICTIONARY_LIMITS` に置き、Pydanticモデルと、[生成した辞書規則](frontend/src/generated/dictionary-rules.ts)で共有する。`check:api` が上限値とUnicode case foldingの一致を検査する。APIの型は従来どおりOpenAPIから生成し、画面の下書きや操作イベントとは分ける。

画面の [dictionary.ts](frontend/src/dictionary.ts) は、入力中の下書きを保持しながら保存前にエラーを示す。APIのPydantic検証は画面以外の呼出しも受け付けるため残す。HTMLのUTF-16文字数による切り詰めは使わず、画面とAPIがUnicodeコードポイントで上限を判定する。

辞書の保存・改版照合は `WordDictionary` が担当する。音声認識は次の開始時に有効な表記を取得し、AI校正は入力を受け取った時点の辞書を保持する。手修正の保護と公開可否は `CaptionRelay`、提供元通信と処理の取消は各Adapterと `CaptionWorkflow` に保つ。この整理では入力・応答の形式、上限値、辞書の保存形式を変更しない。

## 画面の責務

画面と共有の Button・Textarea はそれぞれの Scope を持って Root の配下に置き、Passive View、Presenter、Mediator、Adapter に責務を分ける。親へ伝播する操作イベントと XState の状態遷移の詳細は[フロントエンドの構成](docs/frontend-architecture.md)を参照する。
Expand Down
15 changes: 9 additions & 6 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ class Model(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True, validate_assignment=True)


WordText = Annotated[str, Field(min_length=1, max_length=80, pattern=r"^[^\x00-\x1f\x7f<>]+$")]
DICTIONARY_LIMITS = {"maxWordLength": 80, "maxVariants": 5, "maxEntries": 100, "maxTotalLength": 10000}


WordText = Annotated[str, Field(min_length=1, max_length=DICTIONARY_LIMITS["maxWordLength"], pattern=r"^[^\x00-\x1f\x7f<>]+$")]


class DictionaryWord(Model):
term: WordText
reading: str = Field(default="", max_length=80, pattern=r"^[^\x00-\x1f\x7f<>]*$")
variants: list[WordText] = Field(default_factory=list, max_length=5)
reading: str = Field(default="", max_length=DICTIONARY_LIMITS["maxWordLength"], pattern=r"^[^\x00-\x1f\x7f<>]*$")
variants: list[WordText] = Field(default_factory=list, max_length=DICTIONARY_LIMITS["maxVariants"])
enabled: bool = True

@field_validator("term", "reading")
Expand All @@ -33,15 +36,15 @@ def valid_word(self) -> "DictionaryWord":


class DictionarySettings(Model):
entries: list[DictionaryWord] = Field(default_factory=lambda: list[DictionaryWord](), max_length=100)
entries: list[DictionaryWord] = Field(default_factory=lambda: list[DictionaryWord](), max_length=DICTIONARY_LIMITS["maxEntries"])

@model_validator(mode="after")
def unique_terms(self) -> "DictionarySettings":
keys = [unicodedata.normalize("NFKC", entry.term).casefold() for entry in self.entries]
if len(set(keys)) != len(keys):
raise ValueError("同じ表記は一度だけ登録してください。")
if sum(len(entry.term) + len(entry.reading) + sum(map(len, entry.variants)) for entry in self.entries) > 10000:
raise ValueError("辞書は合計10,000文字以内で登録してください。")
if sum(len(entry.term) + len(entry.reading) + sum(map(len, entry.variants)) for entry in self.entries) > DICTIONARY_LIMITS["maxTotalLength"]:
raise ValueError(f"辞書は合計{DICTIONARY_LIMITS['maxTotalLength']:,}文字以内で登録してください。")
return self


Expand Down
11 changes: 11 additions & 0 deletions backend/tests/test_dictionary.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ def test_dictionary_rejects_invalid_or_ambiguous_registration(entries):
assert client.get("/api/dictionary").json()["entries"] == []


def test_dictionary_counts_supplementary_characters_and_keeps_saved_words_on_rejection():
text = "𠮷" * 80
with TestClient(create_app(environment={})) as client:
response = client.put("/api/dictionary", json={"base_revision": 0, "entries": [{"term": text, "reading": text}]})
assert response.status_code == 200
assert response.json()["entries"][0]["term"] == text
response = client.put("/api/dictionary", json={"base_revision": 1, "entries": [{"term": text + "𠮷"}]})
assert response.status_code == 422
assert client.get("/api/dictionary").json()["entries"][0]["term"] == text


def test_failed_atomic_save_preserves_memory_and_file(tmp_path: Path, monkeypatch):
path = tmp_path / "words" / "dictionary.json"
dictionary = WordDictionary(path)
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.
20 changes: 20 additions & 0 deletions docs/assets/dictionary-contract-2026-10-25/verification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"checked_at": "2026-10-25T21:23:43.723009+09:00",
"environment": "隔離した実FastAPIと背景の実ブラウザ。辞書はテストプロセス内のみ。APIキーなし。",
"browser": {
"term_characters": 80,
"term_utf16_length": 160,
"reading_characters": 80,
"saved_revision": 2,
"oversized_draft_characters": 81,
"oversized_draft_retained": true,
"validation_error": "1件目は各欄80文字以内で入力してください。",
"console_errors": 0
},
"api": {
"oversized_status": 422,
"saved_revision_after_rejection": 2,
"saved_content_preserved": true
},
"audio_started": false
}
2 changes: 2 additions & 0 deletions docs/dictionary.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

各欄は80文字以内、単語は100件まで、全体は10,000文字以内。空白だけの表記、改行・制御文字・`<`・`>`、重複する表記を拒否する。表記の重複は全角/半角や大文字/小文字の差を無視して確認する。「Straße / STRASSE」のようなUnicodeの表記も保存前に重複と判断する。画面の文字変換表はサーバーのPythonから生成し、[生成結果の検証](generated/README.md)で一致を確認する。

文字数はUnicodeコードポイントで数え、「𠮷」のような補助漢字も1文字とする。上限を超えた入力は保存せず、下書きを残してエラーを表示する。上限値と文字変換表は[バックエンドから生成](generated/README.md)し、画面とAPIで同じ値を使う。

「有効」を外すと、保存後の認識・校正では参照しなくなる。削除や編集は保存するまで適用しない。パネルを閉じたり字幕を更新取得したりしても下書きを保つ。「保存済みに戻す」は入力中の辞書を破棄し、接続先から読み直す。

別の操作画面が保存した場合は、古い辞書での上書きを拒否する。入力は保持するため、必要な内容を確認してから「保存済みに戻す」で最新の辞書を読み直す。保存先に書き込めない場合も、保存済みの辞書と下書きを保持する。
Expand Down
39 changes: 39 additions & 0 deletions docs/exec-plans/active/2026-10-25-unicode-form-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ルーム名と背景の文字数をAPIへそろえる

## Goal

ルーム名とトークの背景でも、辞書と同じくUnicodeコードポイントで上限を判定する。

## Context

#21のNode更新はPR #34でマージ済み。#19の辞書の制限値はバックエンドから生成する。調査ではhome.tsxのルーム名maxLength=120、背景maxLength=4000、ai-settings.tsxの背景maxLength=4000にも同じUTF-16による切り詰めが残っている。APIのPydanticはUnicode文字数で判定する。

## Scope and Non-Goals

上記3入力の文字数、上限超過時の下書き保持と保存拒否、制限値の生成を対象にする。ルーム切替、API形式、認識Adapter、AIの要求内容、公開方針は変えない。

## Constraints From Decisions

- 既存の未コミット6項目と別作業ツリーを保持する。
- Viewは描画と通知のみ。入力検証はRoot/Mediatorなど既存の担当層へ置く。
- 生成値はPydanticのOpenAPIから作り、手で生成物を編集しない。
- 物理マイクと実音声サービスは使用しない。

## Remaining Work

1. 3入力の所有者と保存前の検査を確認し、補助漢字の切り詰めを再現する。
2. OpenAPIの上限値を生成し、入力の保存判定をUnicodeへそろえる。
3. 実APIとブラウザで上限以内の保存と上限超過時の保持を確認する。
4. 全チェックと別セッション監査、PR・CI・マージを完了する。

## Now And Next

homeのRootとOperator Mediatorの保存処理を読み、既存の辞書の修正と同じ考え方で回帰テストを追加する。

## Blockers And Human Tasks

なし。

## Verification

ルーム名120文字、背景4000文字の補助漢字を入力でき、APIが受け付ける。超過時は明確なエラーを示し、下書きと保存済み設定を保持する。API型と既存のルーム保護を維持する。
4 changes: 2 additions & 2 deletions docs/generated/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ API モデルを変更するときに参照する。生成の流れは次のと
| 生成元 | [backend/app/models.py](../../backend/app/models.py) の Pydantic モデルと [main.py](../../backend/app/main.py) のルート・応答モデル |
| 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-case-folding.ts](../../frontend/src/generated/dictionary-case-folding.ts)。サーバーのPythonと同じUnicode case foldingを使う |
| 辞書の入力制限と文字変換表 | [scripts/export_openapi.py](../../scripts/export_openapi.py) → [dictionary-rules.ts](../../frontend/src/generated/dictionary-rules.ts)。Pydanticモデルと同じ上限値、サーバーのPythonと同じUnicode case foldingを使う |

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

Expand All @@ -18,6 +18,6 @@ npm run check:api
npm run build
```

生成ファイルは手で編集しない。モデル変更と再生成結果を同じ変更に含める。`check:api` は現在の FastAPI モデル、保存された OpenAPI、TypeScript の生成結果を照合し、辞書の文字変換表がサーバーのPythonと一致することも確認する。全体の検証はルートで `bash scripts/check.sh` を実行する。
生成ファイルは手で編集しない。モデル変更と再生成結果を同じ変更に含める。`check:api` は現在の FastAPI モデル、保存された OpenAPI、TypeScript の生成結果を照合し、辞書の入力制限と文字変換表がサーバーのPythonと一致することも確認する。全体の検証はルートで `bash scripts/check.sh` を実行する。

TypeScript の型はブラウザで JSON の実行時検証を行うものではない。型生成ツールの対応範囲に合わせ、TypeScript は 5.9 系を使用する。
11 changes: 6 additions & 5 deletions frontend/src/dictionary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DictionarySnapshot, DictionaryUpdate, DictionaryWord } from "./domain";
import { dictionaryCaseFolding } from "./generated/dictionary-case-folding";
import { dictionaryCaseFolding, dictionaryLimits } from "./generated/dictionary-rules";
export { dictionaryLimits } from "./generated/dictionary-rules";

export const emptyDictionary: DictionarySnapshot = { revision: 0, entries: [] };
export interface DictionaryDraft {
Expand All @@ -22,21 +23,21 @@ export function dictionaryUpdate(draft: DictionaryDraft): DictionaryUpdate {

export function dictionaryValidation(draft: DictionaryDraft): string {
const { entries } = dictionaryUpdate(draft);
if (entries.length > 100) return "単語は100件まで登録できます。";
if (entries.length > dictionaryLimits.maxEntries) return `単語は${dictionaryLimits.maxEntries}件まで登録できます。`;
const terms = new Set<string>();
let total = 0;
for (const [index, word] of entries.entries()) {
const label = `${index + 1}件目`;
if (!word.term) return `${label}の表記を入力してください。`;
const values = [word.term, word.reading, ...word.variants];
if (values.some(value => Array.from(value).length > 80)) return `${label}は各欄80文字以内で入力してください。`;
if (values.some(value => Array.from(value).length > dictionaryLimits.maxWordLength)) return `${label}は各欄${dictionaryLimits.maxWordLength}文字以内で入力してください。`;
if (values.some(value => /[\x00-\x1f\x7f<>]/.test(value))) return `${label}に改行や < > は使えません。誤認識の表記は1行ずつ入力してください。`;
if (word.variants.length > 5) return `${label}の誤認識の表記は5件以内で入力してください。`;
if (word.variants.length > dictionaryLimits.maxVariants) return `${label}の誤認識の表記は${dictionaryLimits.maxVariants}件以内で入力してください。`;
if (new Set(word.variants).size !== word.variants.length) return `${label}の誤認識の表記が重複しています。`;
const key = Array.from(word.term.normalize("NFKC"), character => dictionaryCaseFolding[character] ?? character).join("");
if (terms.has(key)) return `${label}の表記は登録済みです。同じ表記は一度だけ登録してください。`;
terms.add(key);
total += values.reduce((sum, value) => sum + Array.from(value).length, 0);
}
return total > 10000 ? "辞書は合計10,000文字以内で登録してください。" : "";
return total > dictionaryLimits.maxTotalLength ? `辞書は合計${dictionaryLimits.maxTotalLength.toLocaleString("ja-JP")}文字以内で登録してください。` : "";
}

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions frontend/src/machines/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CaptionConflict, OperationError, disclosureIds, type DisclosureId, type
import { changeDisplayField, defaultDisplay, selectDisplayPalette } from "../display";
import { editableAiSettings, defaultAi, knownAiModel } from "../ai";
import { defaultConnection } from "../connection";
import { dictionaryUpdate, dictionaryValidation, draftDictionary, emptyDictionary, type DictionaryDraft } from "../dictionary";
import { dictionaryLimits, dictionaryUpdate, dictionaryValidation, draftDictionary, emptyDictionary, type DictionaryDraft } from "../dictionary";
import type { DictionarySnapshot, DictionaryUpdate } from "../domain";

type StatusTarget = { captionId: string; kind: "source" | "publication" };
Expand Down Expand Up @@ -202,7 +202,7 @@ export const operatorMachine = setup({
connection: fromPromise(async ({ input, signal }: { input: { api: OperatorApi; settings: ConnectionSettings }; signal: AbortSignal }) => input.api.setConnection(input.settings, signal)),
},
guards: {
canAddWord: ({ context }) => (context.dictionaryDraft ?? draftDictionary(context.dictionary)).entries.length < 100,
canAddWord: ({ context }) => (context.dictionaryDraft ?? draftDictionary(context.dictionary)).entries.length < dictionaryLimits.maxEntries,
knownWord: ({ context, event }) => (event.type === "dictionary.word.changed" || event.type === "dictionary.word.enabled" || event.type === "dictionary.word.removed")
&& Number.isInteger(event.index) && event.index >= 0 && event.index < (context.dictionaryDraft ?? draftDictionary(context.dictionary)).entries.length,
validDictionary: ({ context }) => !dictionaryValidation(context.dictionaryDraft ?? draftDictionary(context.dictionary)),
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/presenters/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { displayChoices, displayPalettes } from "../display";
import type { OperatorSnapshot } from "../machines/operator";
import { presentDisplayPreview, type DisplayCaption, type DisplayPreviewParameters } from "./display";
import { presentDisclosure, type DisclosureParameters } from "./disclosure";
import { draftDictionary } from "../dictionary";
import { dictionaryLimits, draftDictionary } from "../dictionary";

const receivedTime = new Intl.DateTimeFormat("ja-JP", { hour: "2-digit", minute: "2-digit" });
const receivedDateTime = new Intl.DateTimeFormat("ja-JP", {
Expand Down Expand Up @@ -102,6 +102,7 @@ export interface AiParameters {
}

export interface DictionaryParameters {
variantsHint: string;
entries: { index: number; term: string; reading: string; variantsText: string; enabled: boolean }[];
countLabel: string;
empty: boolean;
Expand Down Expand Up @@ -385,8 +386,9 @@ export function presentOperator(snapshot: OperatorSnapshot, roomId?: string): Op
context.captions.map(caption => ({ id: caption.id,
text: caption.public_text ?? caption.text, speaker: caption.published ? caption.public_speaker : caption.speaker })), context.openedDisclosures),
dictionary: {
variantsHint: `任意・1行1件、${dictionaryLimits.maxVariants}件まで`,
entries: dictionaryDraft.entries.map((word, index) => ({ ...word, index })),
countLabel: `${dictionaryDraft.entries.length} / 100件`, empty: dictionaryDraft.entries.length === 0,
countLabel: `${dictionaryDraft.entries.length} / ${dictionaryLimits.maxEntries}件`, empty: dictionaryDraft.entries.length === 0,
disabled: dictionaryDisabled,
addDisabled: !snapshot.can({ type: "dictionary.word.added" }),
saveDisabled: dictionaryDisabled || !dictionaryDirty,
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/roots.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ it("edits a dictionary through Root and restores focus when the panel closes", a
await waitFor(() => expect(document.activeElement).toBe(trigger));
}, 20000);

it("accepts eighty supplementary characters in dictionary fields and retains an oversized draft", async () => {
const api: OperatorApi = {
load: vi.fn().mockResolvedValue({ visible: true, captions: [] }),
save: vi.fn(), setVisible: vi.fn(), sendTest: vi.fn(), setPublicationMode: vi.fn(), setDisplay: vi.fn(), setAi: vi.fn(), setConnection: vi.fn(),
setDictionary: vi.fn().mockImplementation(update => Promise.resolve({ revision: update.base_revision + 1, entries: update.entries })),
};
render(<OperatorRoot api={api} />);
const user = userEvent.setup();
await user.click(await screen.findByRole("button", { name: "単語辞書" }));
await user.click(screen.getByRole("button", { name: "単語を追加" }));
const term = screen.getByRole<HTMLInputElement>("textbox", { name: "1件目の表記" });
const text = "𠮷".repeat(80);
await user.click(term);
await user.paste(text);
expect(term.value).toBe(text);
await user.click(screen.getByRole("button", { name: "辞書を保存" }));
await screen.findByText("辞書を保存しました。");
expect(api.setDictionary).toHaveBeenLastCalledWith(expect.objectContaining({ entries: [expect.objectContaining({ term: text })] }), expect.any(AbortSignal));
await user.type(term, "𠮷");
await user.click(screen.getByRole("button", { name: "辞書を保存" }));
expect(term.value).toBe(text + "𠮷");
expect(api.setDictionary).toHaveBeenCalledTimes(1);
expect(screen.getByRole("alert").textContent).toContain("80文字以内");
});

it("focuses an editable word when the dictionary is full and restores its trigger", async () => {
const api: OperatorApi = {
load: vi.fn().mockResolvedValue({ visible: true, captions: [], dictionary: { revision: 1,
Expand Down
Loading
Loading