diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index af11bee..7556ab2 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -32,7 +32,7 @@ - **データの場所をコードに書かない**.目録・ストア・取り込み元・計算サーバの場所は設定ファイル(`config.py` が読む)から受け取る.テスト用の既定値もコードに置かない - **CLI の出力・ログ・例外メッセージに出すのは,件数・研究用 ID・SHA-256・種類名・所見の値・設定で付けた名前だけ**.実際の患者 ID・対応表の中身・画像の中身は出さない.元のファイル名(`source_name`)は既定では出さない -- **対応表(`identity.sqlite`)を開くのは取り込みの処理だけ**にする.他のモジュールから対応表を参照しない +- **対応表(`identity.sqlite`)を開くのは取り込みの処理だけ**にする.他のモジュールから対応表を参照しない(例外: `tiasdata backup` が複製と点検のために開く.中身は表示しない) - **原本(取り込み元)に書き込まない**.取り込み元は読み取り専用で開き,ストア・目録にだけ書く - **ストアのファイルを書き換えない・消さない**.ストアへの書き込みは「同じ名前が無ければコピーし,コピー後にハッシュを照合する」だけにする - `print` ではなく `logging` を使う.ただし CLI の結果表示(件数の表等)は標準出力に書いてよい diff --git a/CLAUDE.md b/CLAUDE.md index b851cc9..de785d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ ## 開発進捗 -最新: PLAN_02 Step 5(バンドル.`tiasdata export`・`tiasdata.load`)完了.次は Step 1〜5 を main に取り込み,Step 6(研究室 PC での運用開始) +最新: 要確認リスト(受け取ったデータを全部入れて印を付ける)と `tiasdata backup` を実装.次は Step 6 の続き(研究室 PC で init・再取り込み・版 1・バックアップ) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index cee77e2..18a5cd6 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -52,10 +52,10 @@ ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe sync --no-dev --extra import" ``` -更新するとき: +更新するとき(`init` は新しいマイグレーションがあれば適用する.無ければ何もしない): ```bash -ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && git pull && C:\Users\Papin\.local\bin\uv.exe sync --no-dev --extra import" +ssh -n labpc "cd C:\Users\Papin\source\TIASDataHub && git pull && C:\Users\Papin\.local\bin\uv.exe sync --no-dev --extra import && C:\Users\Papin\.local\bin\uv.exe run --no-dev --extra import tiasdata init" ``` - ※ clone・`uv sync` は未検証(立ち上げの成果物を `main` に取り込んだ後に実行する).デプロイキーでの接続(`git ls-remote`)は 2026-10-02 に確認済み diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index b8c885d..9a56564 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -23,19 +23,20 @@ | --- | --- | | `uv run tiasdata --version` | 版を表示する | | `uv run tiasdata init` | 設定ファイルに従って目録(`catalog.sqlite`)・`store/`・`bundles/`・`backups/`・対応表(`identity.sqlite`)を作り,スキーマのマイグレーションを適用する.何度実行してもよい(不足分だけ適用する) | -| `uv run tiasdata import <取り込み元の名前>` | 設定ファイルの `[sources.<名前>]` を取り込む(保留リスト `holds/<名前>.toml` が必要.空でよい).`--show-source-names` で問題のあったファイルの名前・対応表の行番号を表示する | +| `uv run tiasdata import <取り込み元の名前>` | 設定ファイルの `[sources.<名前>]` を取り込む(要確認リスト `reviews/<名前>.toml` が必要.空でよい.要確認の番号も印を付けて登録する).`--show-source-names` で問題のあったファイルの名前・対応表の行番号を表示する | | `uv run tiasdata stats` | 症例・撮影・ファイル・所見の件数を表示する | | `uv run tiasdata check` | ストア・目録の点検(凍結した版の manifest の照合を含む).問題が 1 件以上なら終了コード 1 | | `uv run tiasdata dataset list` | データセットと版の一覧 | | `uv run tiasdata dataset show <名前>` | 版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | | `uv run tiasdata dataset freeze <名前> [--note TEXT]` | `datasets/<名前>.toml` を目録に当てはめて新しい版を作る(構成が同じなら作らない).**リポジトリに未コミットの変更があると止まる**(研究室 PC は pull するだけなので通常は問題ない) | +| `uv run tiasdata backup` | 目録と対応表をそれぞれのフォルダの `backups/` に複製し,整合性を点検する(取り込み・版の作成・編集の後に実行する) | | `uv run tiasdata export <名前> --version <版>` | 凍結した版をバンドル(`bundles/<名前>/v<版>/`)に書き出す.既にあれば照合して使う | - 設定ファイルは環境変数 `TIASDATA_CONFIG` のパス,無ければ `~/.config/tiasdata/config.toml`(キーは [SPEC_01「設定ファイル」](../04_SPEC/SPEC_01_アーキテクチャ設計.md)) - 手元で試すときは一時フォルダに設定ファイルを作って指す(例: `TIASDATA_CONFIG="$TMPDIR/tiasdata-try/config.toml" uv run tiasdata init`.臨床データは使わない) - 研究室 PC では `ssh labpc "cd C:\Users\Papin\source\TIASDataHub && C:\Users\Papin\.local\bin\uv.exe run tiasdata init"` の形で実行する([ENV_02](ENV_02_環境構築手順.md)) - 失敗時は標準エラーに 1 行を出し,終了コード 1(入力・データの問題)または 2(引数の誤り)で終わる -- `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(保留リストを読むため) +- `import` は openpyxl が要る(手元は `uv sync` で入る.研究室 PC は `uv sync --no-dev --extra import`).リポジトリの clone から実行する(要確認リストを読むため) - 使う側(Python)は `tiasdata.load("<名前>", version=<版>)` でバンドルを開く.置き場所は環境変数 `TIASDATA_BUNDLES`(既定 `~/data/tiasdata`) - 残りのサブコマンド(受け渡し・バックアップ等)は実装した Step で追記する diff --git "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" index 33ea87a..1794ff9 100644 --- "a/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ "b/docs/03_PLAN/PLAN_01_\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" @@ -53,7 +53,7 @@ - ラベルは現在フォルダ名で表現されている.基盤に登録するときは,フォルダ名から読み取ったラベルを目録の項目として記録する - 画像の番号(ファイル名)と,DeepTIAS の出力側の患者 ID・何枚目の画像かの対応は,研究室 PC の対応表(`E:\clinical-data\DeepTIAS後_ID_番号.xlsx`)にある.2026-10-03 に件数だけを照合し,1085 枚が約 450 人分(2 枚以上の患者が 239 人・874 枚)であることが分かった(TIASJudgeModel の「1 患者 1 枚」の前提は誤りで,ユーザーも確認済み).患者単位で扱うには,この対応表を取り込みに使う必要がある - DeepTIAS の出力(元は患者 ID のフォルダ `DeepTIAS後_亥鼻_村上_2025.08修正`)は,2026-10-03 に舌色データセットで使った画像の分だけを番号ごとのフォルダへ移し,残り(使わない出力 約 8GB)を削除した(元のデータは研究室のサーバにある).各番号の画像は `2-cropped` × `3-resized`(127 超)で作り直したものと画素で一致する(完全一致 1073・違う画素 1% 未満 12).経緯は `docs/PROGRESS.md` -- 舌色データセットには**同じ写真の重複**が 5 組ある(2026-10-03 確認).ファイルが同一で対応表の患者 ID が違う 2 組(122/184・548/566.ラベルは同じ),ほぼ同じ画像(違う画素約 0.7%)が連番で 2 回登録された 3 組(381/382・508/509・515/516.うち 381/382 は程度,515/516 は舌色のラベルが違う).382・509・516 のフォルダの DeepTIAS 出力は,それぞれ 381・508・515 の出力のコピー(元の出力は見つからなかった).扱いは,確かめられるまで 9 枚を保留にする(2026-10-06.`holds/tiasjudgemodel.toml`・PLAN_02) +- 舌色データセットには**同じ写真の重複**が 5 組ある(2026-10-03 確認).ファイルが同一で対応表の患者 ID が違う 2 組(122/184・548/566.ラベルは同じ),ほぼ同じ画像(違う画素約 0.7%)が連番で 2 回登録された 3 組(381/382・508/509・515/516.うち 381/382 は程度,515/516 は舌色のラベルが違う).382・509・516 のフォルダの DeepTIAS 出力は,それぞれ 381・508・515 の出力のコピー(元の出力は見つからなかった).扱いは,受け取ったとおり 1085 枚すべてを目録に入れ,9 枚に要確認の印を付ける(2026-10-08.`reviews/tiasjudgemodel.toml`・PLAN_02) ### 表せるようにする関係 (Relationships) diff --git "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" index 76fb8d0..7ed5119 100644 --- "a/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" +++ "b/docs/03_PLAN/PLAN_02_\351\226\213\347\231\272\343\202\271\343\203\206\343\203\203\343\203\227.md" @@ -14,19 +14,18 @@ | --- | --- | --- | | 1 | **設定と目録の土台**: `config.py`(設定ファイルの読み込み・検証),`catalog/`(接続・マイグレーションの適用),`catalog/migrations/catalog/0001`(SPEC_02 の全表と最初の種類)・`identity/0001`(対応表),`tiasdata init`(2026-10-06 完了) | 一時ディレクトリの設定ファイルから `init` で目録ができ,`PRAGMA user_version` が最新になる.テスト(設定ファイル・マイグレーション)が通る | | 2 | **ストア**: `store.py`(ハッシュ・コピー・照合),テスト用のダミーデータ(小さい画像・取り込み元のフォルダ)を作る `conftest.py`(2026-10-06 完了) | 同じ中身が 1 つにまとまり,コピー後の照合・上書きしないことがテストで確かめられる | -| 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check`(2026-10-06 完了) | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.保留リストの番号を飛ばし,保留の件数を報告する.出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | +| 3 | **取り込みと点検**: `kinds/`(`tongue_color` の値の型),`importers/tongue_color_folders`(9 フォルダ+対応表の xlsx → 症例・撮影・ファイル・所見・由来.患者 ID は `identity.sqlite` へ),`tiasdata import`・`stats`・`check`(2026-10-06 完了) | ダミーの 9 フォルダと対応表を取り込め,同じ患者の複数枚が同じ症例にまとまる.2 回実行しても同じ結果になる.想定外の構成・対応表に無い番号で停止する.要確認リストの番号も印を付けて登録する(2026-10-08 に変更.当初は飛ばしていた).出力に患者 ID・元のファイル名が出ない.`check` がストアの書き込み可能なファイル(読み取り専用が外れたもの)の数を報告する | | 4 | **データセットと版**: `datasets/tongue_color.toml`,`datasets.py`(定義の評価・版の作成),`tiasdata dataset freeze`・`list`・`show` | 所見を足す・訂正しても過去の版の構成が変わらない.構成が同じなら版を作らない.ダミーの所見の種類を 1 つ足しても既存の表を変えずに済む(2026-10-06 完了) | | 5 | **バンドル**: `bundle.py`(読み込み・検証)・`export.py`(書き出し),`tiasdata export`,公開 API(`load`・`open_bundle`・`Bundle.fingerprint`) | 書き出したバンドルを目録なしで読める.改変・欠けを検出する.同じ版から同じ `manifest.csv` が得られる(2026-10-08 完了) | -| 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.取り込み 1076 枚+保留 9 枚が 1085 枚・9 グループで TIASJudgeModel の TEST_01 と一致し,症例が約 450(2026-10-03 の照合値)になる.`check` が問題 0 件 | +| 6 | **研究室 PC での運用開始**: 立ち上げの成果物と Step 1〜5 を `main` に取り込む → 研究室 PC に GitHub から clone・設定ファイル作成・`init` → 舌色データ(1085 枚)を取り込み → `check`・`stats` → 版 1 を作成 → `tiasdata backup` | すべて手元 PC から SSH で行える.取り込んだ 1085 枚(うち撮影にひも付かないファイル 2・番号 4)が 9 グループで TIASJudgeModel の TEST_01 と一致する.版 1 は撮影にひも付かない 2 ファイルを除く 1081 枚.`check` が問題 0 件 | | 7 | **計算サーバへの受け渡し**: `deliver.py`(tar・scp・展開・`sha256sum -c`・記録),`tiasdata deliver` | 版 1 を Newton・Archimedes に渡し,両方で照合が通り,`deliveries` に記録される.データが手元 PC を通らない | -| 8 | **TIASJudgeModel の切り替え**: TIASJudgeModel にサブモジュールを追加し,`tiasdata.load("tongue_color", version=1)` から現行の manifest の形に変換して学習・評価に使う(TIASJudgeModel 側の作業ブランチで行う) | 変換した manifest の件数・`label`・`severity` の内訳が,現行の manifest から保留の 9 枚を除いたものと一致し,`patient_id` が患者単位(約 450 人)になる.患者単位の分割で交差検証をやり直し,現行(画像単位)の成績との差を記録する.実験記録にバンドルの指紋が残る.研究室 PC の固定パスを書かずに済む | +| 8 | **TIASJudgeModel の切り替え**: TIASJudgeModel にサブモジュールを追加し,`tiasdata.load("tongue_color", version=1)` から現行の manifest の形に変換して学習・評価に使う(TIASJudgeModel 側の作業ブランチで行う) | 変換した manifest の件数・`label`・`severity` の内訳が,現行の manifest から,撮影にひも付かない同じ中身の 2 組(122/184・548/566 の 4 枚)を除いたものと一致し,`patient_id` が患者単位(約 450 人)になる.患者単位の分割で交差検証をやり直し,現行(画像単位)の成績との差を記録する.実験記録にバンドルの指紋が残る.研究室 PC の固定パスを書かずに済む | - Step 6 の前に,立ち上げの成果物(`chore/project-setup`)を `main` に取り込む -- **舌色データセットの重複 5 組の扱い**(2026-10-06 のユーザー判断): 基盤では正解を推測で決めず,確かめられるまで**保留**にする.保留リスト `holds/tiasjudgemodel.toml`(SPEC_03「保留リスト」)に 9 枚を載せ,取り込みで飛ばす - - ファイルが同一で患者 ID が食い違う 2 組(122/184・548/566): 両方とも保留.研究室のサーバの元のデータでどちらの患者かを確かめる - - ラベルが違う 2 組(381/382・515/516): 両方とも保留.評価者に正しいラベルを確かめる - - ラベルが同じ重複の 1 組(508/509): DeepTIAS の出力と完全一致する 508 を残し,509 だけ保留 - - 確かめ終わったらリストから外して取り込み直し,データセットの新しい版を作る +- **舌色データセットの重複 5 組の扱い**(2026-10-06 に「保留」と決め,2026-10-08 に「受け取ったとおり全部入れて要確認の印を付ける」に変更.ユーザー判断): 基盤では正解を推測で決めない.要確認リスト `reviews/tiasjudgemodel.toml`(SPEC_03「要確認リスト」)に 9 枚を載せ,取り込みで印を付ける.問題には後で対応する + - ファイルが同一で患者 ID が食い違う 2 組(122/184・548/566): ファイルを 1 つずつ撮影(患者)にひも付けずに登録.研究室のサーバの元のデータでどちらの患者かを確かめる.確定するまで版に入らない + - ラベルが違う 2 組(381/382・515/516): 先生のラベルのまま登録し,版にも入れる.評価者に正しいラベルを確かめる + - ラベルが同じ重複の 1 組(508/509): 509 も登録し,版にも入れる(DeepTIAS の出力と完全一致するのは 508) - Step 6・7 はコード(Step 7 の `deliver`)と運用(研究室 PC・サーバでの実行)が混ざる.運用の手順と結果は ENV_02・ENV_03 に記録する - Step 8 の終了時点で PLAN_01 の成功基準 1〜7 を確認する @@ -40,7 +39,7 @@ | DeepTIAS の派生データ | 撮影画像・カラーチャート画像と,DeepTIAS の出力(色補正後の画像・マスク・`analysis.csv`)を由来付きで登録する.旧 exe(2021 年 12 月版)と現行ソースの出力を版で区別する.取り込み元は整理済みの `DeepTIAS_TIASJudgeModel\<番号>\`(382・509・516 は元の出力ではなくコピーである点を由来に記録する) | | 実際の患者 ID を持つデータ | 対応表(`identity.sqlite`)を使う取り込み.同じ患者の複数枚・経時的な撮影.倫理審査の保管ルールの確認が前提 | | 他の所見 | 苔の色・厚さ,問診・体質等の種類を追加する | -| バックアップ | 目録・ストアを別の媒体に退避する方法を決めて手順化する(ストアのファイルは読み取り専用である点に注意) | +| バックアップ | `tiasdata backup`(同じドライブの中への複製)は 2026-10-08 に実装済み.目録・対応表・ストアを別の媒体に退避する方法を決めて手順化する(ストアのファイルは読み取り専用である点に注意) | | 編集のコマンド | ラベルの訂正・撮影の患者の付け替え・ファイルの種類の変更等.どのコマンドもまず試走で影響(変わる件数・影響する版・**非破壊か破壊的か**)を表示し,破壊的な編集(過去の版の中身が変わる・データが消える)は明示の指定(例: `--allow-destructive`)が無いと実行しない.編集はいつ・何を・なぜを履歴の表に残す(2026-10-08 のユーザー判断.必要になった時点で作る) | | データの削除 | 同意の撤回等でデータを本当に消す必要がある場合の専用コマンド(例: `tiasdata purge`).影響する版を確かめ,削除を目録に記録してから,読み取り専用を外してストアから消す(SPEC_01「ストアの規則」) | | TIASjudge・DeepTIAS からの利用 | 使う側を増やす | diff --git "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" index 062e1ed..631e15f 100644 --- "a/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" +++ "b/docs/04_SPEC/SPEC_01_\343\202\242\343\203\274\343\202\255\343\203\206\343\202\257\343\203\201\343\203\243\350\250\255\350\250\210.md" @@ -69,7 +69,7 @@ │ ├── deliver.py # 計算サーバへの受け渡し │ └── cli.py # エントリポイント ├── datasets/ # データセットの定義(TOML) -├── holds/ # 取り込み元ごとの保留リスト(TOML.SPEC_03「保留リスト」) +├── reviews/ # 取り込み元ごとの要確認リスト(TOML.SPEC_03「要確認リスト」) ├── tests/ # ダミーデータのみで動くテスト └── docs/ ``` diff --git "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" index 4ecb9d0..caef60b 100644 --- "a/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" +++ "b/docs/04_SPEC/SPEC_02_\343\203\207\343\203\274\343\202\277\343\203\242\343\203\207\343\203\253.md" @@ -43,7 +43,7 @@ - 同じ患者の複数枚が同じ日の撮影か別の日の撮影かは,現在のデータからは分からないため,撮影日(`captured_on`)は NULL にする - 研究用 ID は番号順に振る.症例 `S` は目録と対応表の両方の最大番号 + 1 から,撮影 `C` は目録の最大番号 + 1 から(一度振った ID は変えない) - 患者 ID は数字の値を先頭の 0 をそろえた文字列にする(`123`・`123.0`・`"0123"` は同じ患者.Excel の数値のセルは先頭の 0 がもともと落ちるため) -- 対応表 xlsx の読み方(2026-10-06 のユーザー判断): シートは 1 枚に限る.1 行目は見出し.番号(1 列目)が整数でない行(空行を除く)は使わず,件数を報告する.使う番号について,対応表に無い・同じ番号の行が複数ある(患者 ID が同じでも)・患者 ID が数字でない場合は停止する.保留した番号はこれらの確認の対象外 +- 対応表 xlsx の読み方(2026-10-06 のユーザー判断): シートは 1 枚に限る.1 行目は見出し.番号(1 列目)が整数でない行(空行を除く)は使わず,件数を報告する.使う番号について,対応表に無い・同じ番号の行が複数ある(患者 ID が同じでも)・患者 ID が数字でない場合は停止する.撮影にひも付けない番号(同じ中身ですべて要確認の番号)はこれらの確認の対象外.撮影にひも付く要確認の番号は対象 - 撮影の `device` は `TIAS`.所見の `source` は `tiasjudgemodel_folder`.由来は画像 1 枚につき 1 行で,出力の `role` は `cropped` ## 表の定義 (Tables) @@ -68,6 +68,7 @@ | `tongue_crop_masked` | `asset` | 色補正後の画像を舌の枠で切り出し,マスク外を黒 (0,0,0) にしたもの(DeepTIAS Image&Calib の `2-cropped` × `3-resized`.現在の舌色データ) | | `tongue_mask` | `asset` | 舌のマスク画像 | | `tongue_color` | `annotation` | 舌色 5 分類+程度.値は `{"label": , "severity": "strong" \| "mild" \| "normal"}`.slug は `pale`・`light_red`・`red`・`dark_red`・`purple`(TIASJudgeModel の SPEC_01「クラス定義」と同じ).`light_red` の程度は `normal` だけ,それ以外の 4 つは `strong` か `mild`(元のデータでも淡紅は「淡紅(正常)」だけ).未知のキー・値は拒否する | +| `review` | `annotation` | 要確認の印(2026-10-08.catalog/0002).値は `{"reason": str, "resolve": str, "since": "YYYY-MM-DD", "numbers": [int, ...]}`(numbers は取り込み元での番号.1 個以上・昇順・重複なし).source は `review_list`.撮影またはファイルに付く(SPEC_03「要確認リスト」) | ### subjects(症例) @@ -102,6 +103,7 @@ - ストア上のパスは `store//.` で決まるため,目録には保存しない - 同じ中身のファイルを 2 度取り込むと,同じ行を指す(重複登録しない).ただし別の撮影・別の種類・**別の拡張子**として登録しようとした場合は停止する(ストアでは拡張子が違うと別のファイルになるが,assets は SHA-256 で 1 行のため) +- 取り込み元で同じ中身が複数の番号にあり,すべて要確認の場合は,`capture_id` を NULL にしてファイルだけを登録し,所見はファイル(`annotations.sha256`)に付ける(患者が確定していないため.SPEC_03「要確認リスト」) ### derivations・derivation_inputs・derivation_outputs(由来) @@ -178,7 +180,7 @@ | `created_at` | — | - 同じ患者の別の撮影を取り込むと,同じ研究用 ID にひも付く(複数枚・経時的な撮影に対応する) -- CLI は対応表の中身を表示しない.対応表を開くのは取り込みのときだけ +- CLI は対応表の中身を表示しない.対応表を開くのは取り込みのときと,`tiasdata backup` で複製・点検するときだけ - 匿名化の方法・対応表の保管ルールは倫理審査の保管ルールを確認して確定する([PLAN_01「人間が確認すること」](../03_PLAN/PLAN_01_要件定義書.md)) ## 拡張の例 (Extension Examples) diff --git "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" index 5b67a18..490fd20 100644 --- "a/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" +++ "b/docs/04_SPEC/SPEC_03_\343\203\207\343\203\274\343\202\277\343\202\273\343\203\203\343\203\210\343\201\250\345\217\227\343\201\221\346\270\241\343\201\227.md" @@ -33,7 +33,8 @@ - 行の順序は `subject_id`・`capture_id`・`sha256` の順に並べる(同じ構成なら同じ `manifest.csv` になるようにする) - 未コミットの変更の確認は,目録を見る前に行う(`git status --porcelain --untracked-files=all` が空でなければ停止).`hub_commit` は `git rev-parse HEAD` - **構成の比較**: 定義の全文が同じで,かつ採用したファイルと所見(所見の行の ID)の組が同じなら版を作らない.そのため,同じ値への訂正でも所見の行が変わるので新しい版になる(manifest の SHA-256 は直前の版と同じになる).定義は説明だけの変更でも新しい版になる -- 条件に合うファイルが 0 件なら停止する.撮影にひも付かないファイルが入る場合も停止する(`subject_id`・`capture_id` を決められないため) +- 条件に合うファイルのうち撮影にひも付かないもの(`subject_id`・`capture_id` を決められない.例: 患者が確定していない要確認のファイル)は除き,「撮影にひも付かないため除いた」件数を報告する(2026-10-08 に「停止」から変更).除いた結果 0 件なら停止する +- 要確認の印(所見 `review`)の付いた撮影も版に入れる(除く条件は必要になったら足す) - 所見の列は定義の `[columns]` に書いた順に並べる.値は文字列ならそのまま,無ければ空,それ以外は JSON の表記 - 定義の `description` は必須.目録の `datasets.description` は最初の版のときの値のままにする(版ごとの定義の全文は `dataset_versions.definition` に残る) - 種類との照合(定義の種類・項目が kinds と値の型にあるか)は版を作るときだけ行う.過去の版の manifest の作り直しには使わない(種類の定義が後で変わっても作り直せるようにするため) @@ -132,34 +133,35 @@ | コマンド | 役割 | | --- | --- | | `tiasdata init` | 設定ファイルに従って目録・ストアを作り,マイグレーションを適用する | -| `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`).保留リストの番号は飛ばし,件数を報告する | +| `tiasdata import <取り込み元>` | 取り込み元を読み,ファイルをストアにコピーして症例・撮影・ファイル・所見・由来を登録する(取り込み元の形ごとに importer を用意する.最初は舌色の 9 フォルダ `tongue_color_folders`).要確認リストの番号も印を付けて登録する(下記「要確認リスト」) | | `tiasdata dataset list` / `show <名前>` | データセットと版の一覧・件数.定義だけあって版の無いものは「版なし」,定義ファイルを消したものは「(定義ファイルなし)」.show は版ごとの件数・作成日時・hub_commit・manifest の SHA-256・備考 | | `tiasdata dataset freeze <名前> [--note TEXT]` | 新しい版を作る(作った版の番号と件数,または「構成・定義が同じなので版を作らなかった」) | | `tiasdata export <名前> --version <版>` | バンドルを `bundles/` に書き出す(上記「書き出し」).書き出した件数・合計の大きさ・manifest の SHA-256,または「既にあったバンドルを照合して使った」を表示する | | `tiasdata deliver <名前> --version <版> --to <サーバ名>` | 計算サーバへ渡す | -| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,凍結した版の manifest の不一致(全版について記録した定義と構成から manifest を作り直し,記録した SHA-256 と照合する.ファイルの種類や撮影の症例が書き換えられた場合に見つかる),読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.対応表は開かない | -| `tiasdata stats` | 症例数(撮影が 2 つ以上の症例数を含む)・撮影数,種類ごとのファイル数,訂正されていない所見の項目ごと・組み合わせごとの件数,訂正された所見の数.対応表は開かない | -| `tiasdata backup` | 目録を `backups/catalog-<日時>.sqlite` に複製する(SQLite のバックアップ API を使う) | +| `tiasdata check` | 点検: ストアのファイルのハッシュの照合,目録にあってストアに無いファイル・その逆,所見の参照切れ,凍結した版の構成の欠け,凍結した版の manifest の不一致(全版について記録した定義と構成から manifest を作り直し,記録した SHA-256 と照合する.ファイルの種類や撮影の症例が書き換えられた場合に見つかる),読み取り専用が外れたストアのファイル,書きかけの一時ファイル(`.tmp-`),ストアの想定外のファイル・フォルダ.件数だけを表示し,問題が 1 件以上なら終了コード 1.撮影にひも付かないファイルは件数を表示するが問題として数えない.対応表は開かない | +| `tiasdata stats` | 症例数(撮影が 2 つ以上の症例数を含む)・撮影数,種類ごとのファイル数,訂正されていない所見の項目ごと・組み合わせごとの件数,訂正された所見の数,撮影にひも付かないファイルの数,要確認の印の数(撮影に付いたもの・ファイルに付いたもの.値ごとの内訳は出さない).対応表は開かない | +| `tiasdata backup` | 目録を `/backups/catalog-.sqlite` に,対応表を**対応表のフォルダの** `backups/identity-<同じ日時>.sqlite` に複製する(SQLite のバックアップ API.対応表を目録のフォルダに置かないため).複製に `integrity_check` をかけてスキーマの版が最新か確かめ,件数を表示する(パスは出さない).同じ名前があれば何も書かずに停止し,上書きしない(一時ファイル → 点検 → 正式な名前).同じドライブの中の複製なので,ドライブの故障には別の媒体への退避が要る(PLAN_02「バックアップ」) | - 取り込みは何度実行しても同じ結果になるようにする(既に登録済みのファイルは飛ばす) - 登録・版の作成は 1 つのトランザクションで行い,途中で失敗したら目録・対応表に何も残さない(取り込みでは対応表を目録の接続に ATTACH して 1 つのトランザクションにする).ストアへのコピーはトランザクションの外なので,失敗時にコピー済みのファイルはストアに残る.これは次に取り込みが成功するまで `check` が「ストアにあって目録に無いファイル」として報告し,次の取り込みで再利用される -- 取り込みは,書き込む前に取り込み元の構成・保留リスト・対応表・同じ中身の画像が複数の番号に無いかをすべて検証する -- `import` の出力: 取り込み元の画像の件数,保留で飛ばした件数,対応表で使わない行の件数,登録済みで飛ばした件数,登録した件数(症例・撮影・ファイル・所見・由来),ストアにコピーした件数 +- 取り込みは,書き込む前に取り込み元の構成・要確認リスト・対応表・同じ中身の画像が複数の番号に無いか(あれば,すべて要確認リストにあるか)をすべて検証する +- `import` の出力: 取り込み元の画像の件数,要確認リストの番号の数,対応表で使わない行の件数,登録済みで飛ばした件数,登録した件数(症例・撮影・ファイル・所見・由来),要確認として登録した件数(うち撮影にひも付けないファイル・番号の数),既存の撮影・ファイルに要確認の印を足した件数,要確認リストから外れたが印が残っている件数,ストアにコピーした件数 - `--show-source-names`(`import`)を付けたときだけ,問題のあったファイルの元の名前・番号と,対応表で使わない行の行番号を最大 20 件まで表示する.患者 ID はどの場合も表示しない -- 取り込みはリポジトリの clone から実行する(保留リスト `holds/` をパッケージの位置からリポジトリのルートを辿って読むため).openpyxl が要る(研究室 PC では `uv sync --no-dev --extra import`) +- 取り込みはリポジトリの clone から実行する(要確認リスト `reviews/` をパッケージの位置からリポジトリのルートを辿って読むため).openpyxl が要る(研究室 PC では `uv sync --no-dev --extra import`) - 問題を見つけたら黙って飛ばさず,件数と理由を表示して停止する(`check`・`stats` は止めずに件数を報告する) -## 保留リスト (Hold List) +## 要確認リスト (Review List) -取り込み元のデータに,基盤では正解を決められない問題(同じ写真の重複・患者 ID やラベルの食い違い等)があるとき,確かめられるまで取り込まずに**保留**にする.推測で片方を選んで版に固定しないため. +取り込み元のデータに,基盤では正解を決められない問題(同じ写真の重複・患者 ID やラベルの食い違い等)があるとき,その番号を**要確認**として記録する.受け取ったデータは問題のあるものも含めて受け取ったとおりに目録へ入れ,問題には後で対応する(2026-10-08 のユーザー判断.それまでは「保留」として取り込みで飛ばしていた).推測で片方を選んで正解として固定しないため. -- `holds/<取り込み元の名前>.toml` に置く(リポジトリで管理し,PR で変更する).患者 ID は書かない -- 1 件ごとに `number`(取り込み元での番号)・`reason`(理由)・`resolve`(確かめる方法)・`since`(保留にした日)を書く -- `tiasdata import` は保留の番号を飛ばし,保留の件数を報告する(黙って外さない).保留リストにあるのに取り込み元に無い番号は停止する(リストの誤りを見逃さないため) -- **保留リストのファイルが無い取り込み元は停止する**(設定で取り込み元の名前を誤ったときに保留が効かずに取り込まれるのを防ぐため.2026-10-06 のユーザー判断).保留が無い取り込み元には,空の保留リスト(`[[hold]]` が 0 件)を PR で置く +- `reviews/<取り込み元の名前>.toml` に置く(リポジトリで管理し,PR で変更する).患者 ID は書かない +- 1 件ごとに `[[review]]` に `number`(取り込み元での番号)・`reason`(理由)・`resolve`(確かめる方法)・`since`(要確認にした日)を書く +- `tiasdata import` は要確認の番号も登録し,**要確認の印**(所見 `review`.SPEC_02)を付ける.要確認リストにあるのに取り込み元に無い番号は停止する(リストの誤りを見逃さないため) +- **同じ中身(SHA-256)の画像が複数の番号にあり,その番号がすべて要確認リストにある場合**は,ファイルを 1 つだけ登録して**撮影(患者)にひも付けない**.舌色のラベルと要確認の印はファイルに付け,印の `numbers` に全番号を入れる.番号どうしでラベルが違えば停止する.要確認リストに無い番号が含まれれば今までどおり停止する +- 登録済みの撮影・ファイルが要確認リストに載っていて有効な印が無ければ,印を足す(書き足すだけ).要確認リストから外れたのに印が残っているものは件数を報告するだけ(印を外すのは将来の編集のコマンド.PLAN_02「編集のコマンド」) +- **要確認リストのファイルが無い取り込み元は停止する**(設定で取り込み元の名前を誤ったときに要確認が効かずに取り込まれるのを防ぐため).要確認が無い取り込み元には,空の要確認リスト(`[[review]]` が 0 件)を PR で置く - 形の誤り(未知のキー・必須キーの欠落・番号が 1 未満・番号の重複・`since` が日付でない)は停止する -- 確かめ終わったら行を消して取り込み直し,データセットの新しい版を作る.過去の版は変わらない -- 最初の保留リストは `holds/tiasjudgemodel.toml`(舌色データセットの重複 5 組のうち 9 枚.PLAN_02「舌色データセットの重複 5 組の扱い」) +- 最初の要確認リストは `reviews/tiasjudgemodel.toml`(舌色データセットの重複 5 組のうち 9 枚.PLAN_02「舌色データセットの重複 5 組の扱い」) ## 出力の規則 (Output Rules) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d268073..f94b8ae 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -47,3 +47,7 @@ - [x] **編集の扱いを決めた**: 操作は Claude に指示して行う運用なので,ラベル・患者・ファイルの情報は編集できるようにし,破壊的な編集(過去の版の中身が変わる・データが消える)のときは実行前に警告してユーザーが決める方針にした(2026-10-08).凍結した版の記録そのものは今までどおり書き換え禁止 - 判断: 当面は今の形のまま運用する(症例・撮影・ファイルの表はスキーマ上は書き換えられ,書き換えると check が過去の版の manifest の不一致として見つける).編集のコマンド(試走・破壊的の判定・履歴)は必要になった時点で作る(PLAN_02「マイルストーン 2 以降の候補」).Claude は目録を SQL で直接編集しない(lab-pc-operation) - これで Step 4 の未決(assets・captures の書き換えを禁止するか)は「禁止せず,編集のコマンドと警告で扱う」に決着 +- [x] **要確認リストとバックアップ**: 先生から受け取った 1085 枚を問題のあるものも含めて全部目録に入れ,問題には後で対応する方針にした(2026-10-08.ユーザー判断).保留リスト(`holds/`.飛ばす)を要確認リスト(`reviews/`.印を付けて取り込む)に変え,所見の種類 `review`(catalog/0002)を追加.同じ中身ですべて要確認の番号は,ファイルを 1 つだけ撮影にひも付けずに登録する.データセットは撮影にひも付かないファイルを除いて件数を報告する.あわせて `tiasdata backup`(目録と対応表をそれぞれの backups に複製・integrity_check)を実装 + - 設計判断: 要確認の印の付いた撮影も版に入れる(版 1 は 1081 枚の見込み).患者が食い違う同じ中身の 2 組(122/184・548/566)は患者を作らず版に入らない.登録済みに印を足すのは書き足すだけ.対応表のバックアップは対応表のフォルダに置く(目録と分けるため) + - 研究室 PC の手順: `git pull` → `uv sync --no-dev --extra import` → `tiasdata init`(0002 を適用)→ `tiasdata import tiasjudgemodel`(既存 1076 枚は飛ばし 9 枚を追加)→ `check`・`stats` → 版 1 → `backup` + - 未実装・後段: `importers/tongue_color_folders.py` が約 1000 行(走査・計画と照合・登録でモジュールを分けるとよい).別の媒体への退避は未定.書き出し先のパスが Windows の上限(260 文字)を超えると export がスタックトレースで落ちる(研究室 PC のパスでは当たらない見込み) diff --git a/holds/tiasjudgemodel.toml b/holds/tiasjudgemodel.toml deleted file mode 100644 index 59cac3e..0000000 --- a/holds/tiasjudgemodel.toml +++ /dev/null @@ -1,60 +0,0 @@ -# 取り込み元 tiasjudgemodel(舌色データセット)の保留リスト -# -# ここに載せた番号(画像のファイル名の番号)は取り込みで飛ばし,件数を報告する(SPEC_03「保留リスト」). -# 確かめ終わったら行を消して取り込み直し,データセットの新しい版を作る.過去の版は変わらない. -# 患者 ID は書かない(番号はファイル名の連番で,個人をたどれる情報を含まない). -# 経緯は docs/03_PLAN/PLAN_01_要件定義書.md「現在あるデータ」・docs/PROGRESS.md(2026-10-03) - -[[hold]] -number = 122 -reason = "184 と同じ写真(ファイルが同一).対応表では 184 と別の患者 ID になっている" -resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" -since = "2026-10-06" - -[[hold]] -number = 184 -reason = "122 と同じ写真(ファイルが同一).対応表では 122 と別の患者 ID になっている" -resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" -since = "2026-10-06" - -[[hold]] -number = 548 -reason = "566 と同じ写真(ファイルが同一).対応表では 566 と別の患者 ID になっている" -resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" -since = "2026-10-06" - -[[hold]] -number = 566 -reason = "548 と同じ写真(ファイルが同一).対応表では 548 と別の患者 ID になっている" -resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" -since = "2026-10-06" - -[[hold]] -number = 381 -reason = "382 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" -resolve = "評価者にどちらのラベルが正しいかを確かめる" -since = "2026-10-06" - -[[hold]] -number = 382 -reason = "381 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" -resolve = "評価者にどちらのラベルが正しいかを確かめる" -since = "2026-10-06" - -[[hold]] -number = 515 -reason = "516 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" -resolve = "評価者にどちらのラベルが正しいかを確かめる" -since = "2026-10-06" - -[[hold]] -number = 516 -reason = "515 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" -resolve = "評価者にどちらのラベルが正しいかを確かめる" -since = "2026-10-06" - -[[hold]] -number = 509 -reason = "508 と同じ写真を処理し直した重複(違う画素 約 0.7%.ラベルは同じ).DeepTIAS の出力と完全一致するのは 508" -resolve = "不要(508 を残す).重複なので保留のままでよい" -since = "2026-10-06" diff --git a/reviews/tiasjudgemodel.toml b/reviews/tiasjudgemodel.toml new file mode 100644 index 0000000..128f0c0 --- /dev/null +++ b/reviews/tiasjudgemodel.toml @@ -0,0 +1,62 @@ +# 取り込み元 tiasjudgemodel(舌色データセット)の要確認リスト +# +# ここに載せた番号(画像のファイル名の番号)は,取り込みで要確認の印(所見の種類 review)を付けて +# 登録する(SPEC_03「要確認リスト」).同じ中身の画像が複数の番号にあり,その番号がすべてここに +# 載っているときは,ファイルを 1 つだけ登録して撮影(患者)にひも付けない. +# 確かめ終わったら行を消し,印は編集のコマンドで外す(リストから外しても印は自動では外れない). +# 患者 ID は書かない(番号はファイル名の連番で,個人をたどれる情報を含まない). +# 経緯は docs/03_PLAN/PLAN_01_要件定義書.md「現在あるデータ」・docs/PROGRESS.md(2026-10-03・2026-10-08) + +[[review]] +number = 122 +reason = "184 と同じ写真(ファイルが同一).対応表では 184 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[review]] +number = 184 +reason = "122 と同じ写真(ファイルが同一).対応表では 122 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[review]] +number = 548 +reason = "566 と同じ写真(ファイルが同一).対応表では 566 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[review]] +number = 566 +reason = "548 と同じ写真(ファイルが同一).対応表では 548 と別の患者 ID になっている" +resolve = "研究室のサーバの元のデータで,どちらの患者の写真かを確かめる" +since = "2026-10-06" + +[[review]] +number = 381 +reason = "382 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[review]] +number = 382 +reason = "381 とほぼ同じ画像(違う画素 約 0.7%)で,程度のラベルが違う(381: 紅_ややあり / 382: 紅_あり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[review]] +number = 515 +reason = "516 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[review]] +number = 516 +reason = "515 とほぼ同じ画像(違う画素 約 0.7%)で,舌色のラベルが違う(515: 紫_ややあり / 516: 暗赤紅_ややあり)" +resolve = "評価者にどちらのラベルが正しいかを確かめる" +since = "2026-10-06" + +[[review]] +number = 509 +reason = "508 と同じ写真を処理し直した重複(違う画素 約 0.7%.ラベルは同じ).DeepTIAS の出力と完全一致するのは 508" +resolve = "不要(508 を残す).重複なので要確認のままでよい" +since = "2026-10-06" diff --git a/src/tiasdata/backup.py b/src/tiasdata/backup.py new file mode 100644 index 0000000..67675ab --- /dev/null +++ b/src/tiasdata/backup.py @@ -0,0 +1,176 @@ +"""目録と対応表のバックアップ(`tiasdata backup`.SPEC_03「CLI」). + +目録(`catalog.sqlite`)は `/backups/catalog-<日時>.sqlite` に,対応表 +(`identity.sqlite`)は**対応表のフォルダの** `backups/identity-<日時>.sqlite` に複製する +(対応表を目録のフォルダに置かないため.SPEC_01「構成要素」).日時は UTC の +`YYYYmmddTHHMMSSZ` で,2 つの複製で同じ値にする. + +複製は「一時ファイルに SQLite のバックアップ API で書く → `PRAGMA integrity_check` とスキーマの +版を確かめる → 正式な名前にする」の順で行い,書きかけを正式な名前で残さない.既存のファイルは +上書きしない(同じ名前があれば停止する).出力にはファイル名と件数だけを出す(パスは出さない). +""" + +import os +import sqlite3 +import tempfile +from contextlib import closing +from datetime import UTC, datetime +from pathlib import Path + +from pydantic import BaseModel, ConfigDict + +from tiasdata import catalog +from tiasdata.catalog import backup as catalog_backup +from tiasdata.config import Config + +BACKUPS_DIRNAME = "backups" +TEMP_PREFIX = ".tmp-" +_TIMESTAMP_FORMAT = "%Y%m%dT%H%M%SZ" + + +class BackupFile(BaseModel): + """1 つの複製の結果. + + Attributes: + filename: 複製のファイル名(`backups/` の中.パスは含まない). + counts: 表の名前 → 行の数. + """ + + model_config = ConfigDict(frozen=True) + + filename: str + counts: dict[str, int] + + +class BackupResult(BaseModel): + """バックアップの結果(どちらの複製も `PRAGMA integrity_check` が ok だったもの). + + Attributes: + timestamp: 複製の名前に付けた日時(UTC の `YYYYmmddTHHMMSSZ`). + catalog: 目録の複製. + identity: 対応表の複製. + """ + + model_config = ConfigDict(frozen=True) + + timestamp: str + catalog: BackupFile + identity: BackupFile + + +def backup_timestamp(now: datetime | None = None) -> str: + """複製の名前に付ける日時(UTC の `YYYYmmddTHHMMSSZ`)を返す. + + Args: + now: 日時(省略時は現在.タイムゾーンなしは UTC とみなす). + + Returns: + `20261008T090000Z` の形の文字列. + """ + moment = now if now is not None else datetime.now(UTC) + if moment.tzinfo is not None: + moment = moment.astimezone(UTC) + return moment.strftime(_TIMESTAMP_FORMAT) + + +def _publish(tmp_path: Path, target: Path) -> None: + # 一時ファイルを正式な名前にする.既に同じ名前があれば上書きせずに停止する(store と同じ方法) + try: + if os.name == "nt": + # Windows の rename は移動先があると FileExistsError になり,上書きしない + os.rename(tmp_path, target) + else: + # POSIX の rename は黙って上書きするため,既存があれば失敗する link を使う + os.link(tmp_path, target) + tmp_path.unlink() + except FileExistsError: + raise ValueError(f"バックアップ {target.name} が既にある(上書きしない)") from None + + +def _make_copy(source: Path, directory: Path, label: str, series: catalog.Series) -> Path: + """一時ファイルに複製し,整合性とスキーマの版を確かめて一時ファイルのパスを返す.""" + fd, tmp_name = tempfile.mkstemp(prefix=TEMP_PREFIX, suffix=".sqlite", dir=directory) + os.close(fd) + tmp_path = Path(tmp_name) + try: + try: + catalog_backup.copy_database(source, tmp_path) + except sqlite3.Error as e: + # SQLite のメッセージは理由(ロック・容量等)だけで,パスや値を含まない + raise ValueError(f"{label}を複製できない({type(e).__name__}: {e})") from e + problems = catalog_backup.integrity_problems(tmp_path) + if problems: + raise ValueError( + f"{label}の複製の整合性の点検(integrity_check)が ok でない({problems} 件)." + "複製は残さない" + ) + version = catalog_backup.user_version(tmp_path) + latest = catalog.latest_version(series) + if version != latest: + raise ValueError( + f"{label}の複製のスキーマの版 {version} が最新 {latest} でない.複製は残さない" + ) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path + + +def _require_sources(config: Config) -> None: + # init 前・スキーマが古いときは複製せずに 1 行のエラーにする + paths = config.catalog + with closing(catalog.connect_latest(paths.catalog_path)): + pass + if not paths.identity.is_file(): + raise ValueError("対応表が無い.先に `tiasdata init` を実行する") + with closing(catalog.connect(paths.identity)) as conn: + catalog.require_latest(conn, "identity") + + +def run_backup(config: Config, *, now: datetime | None = None) -> BackupResult: + """目録と対応表を,それぞれのフォルダの `backups/` に複製して点検する. + + Args: + config: 設定. + now: 複製の名前に付ける日時(省略時は現在). + + Returns: + バックアップの結果(ファイル名と件数). + + Raises: + ValueError: 目録・対応表が無い,スキーマが最新でない,同じ名前の複製が既にある, + または複製の整合性の点検が ok でない(いずれも正式な名前の複製は残さない. + 2 つ目の複製を正式な名前にするところで失敗した場合だけ,1 つ目は残る). + """ + _require_sources(config) + paths = config.catalog + timestamp = backup_timestamp(now) + catalog_dir = paths.backups_dir + identity_dir = paths.identity.parent / BACKUPS_DIRNAME + catalog_dir.mkdir(exist_ok=True) + identity_dir.mkdir(exist_ok=True) + catalog_target = catalog_dir / f"catalog-{timestamp}.sqlite" + identity_target = identity_dir / f"identity-{timestamp}.sqlite" + for target in (catalog_target, identity_target): + if target.exists(): + raise ValueError(f"バックアップ {target.name} が既にある(上書きしない)") + + temps: list[Path] = [] + try: + catalog_tmp = _make_copy(paths.catalog_path, catalog_dir, "目録", "catalog") + temps.append(catalog_tmp) + identity_tmp = _make_copy(paths.identity, identity_dir, "対応表", "identity") + temps.append(identity_tmp) + catalog_counts = catalog_backup.count_catalog_rows(catalog_tmp) + identity_counts = {"identity_map": catalog_backup.count_identity_rows(identity_tmp)} + _publish(catalog_tmp, catalog_target) + _publish(identity_tmp, identity_target) + except BaseException: + for tmp in temps: + tmp.unlink(missing_ok=True) + raise + return BackupResult( + timestamp=timestamp, + catalog=BackupFile(filename=catalog_target.name, counts=catalog_counts), + identity=BackupFile(filename=identity_target.name, counts=identity_counts), + ) diff --git a/src/tiasdata/catalog/__init__.py b/src/tiasdata/catalog/__init__.py index c0a429e..1771a42 100644 --- a/src/tiasdata/catalog/__init__.py +++ b/src/tiasdata/catalog/__init__.py @@ -17,6 +17,7 @@ from tiasdata.catalog.queries import ( count_annotation_values, count_assets_by_kind, + count_assets_without_capture, count_broken_dataset_members, count_captures, count_dangling_annotations, @@ -24,6 +25,7 @@ count_subjects, count_subjects_with_multiple_captures, count_superseded_annotations, + count_valid_annotations_by_target, list_asset_files, ) @@ -35,6 +37,7 @@ "connect_latest", "count_annotation_values", "count_assets_by_kind", + "count_assets_without_capture", "count_broken_dataset_members", "count_captures", "count_dangling_annotations", @@ -42,6 +45,7 @@ "count_subjects", "count_subjects_with_multiple_captures", "count_superseded_annotations", + "count_valid_annotations_by_target", "latest_version", "list_asset_files", "require_latest", diff --git a/src/tiasdata/catalog/backup.py b/src/tiasdata/catalog/backup.py new file mode 100644 index 0000000..70f3673 --- /dev/null +++ b/src/tiasdata/catalog/backup.py @@ -0,0 +1,96 @@ +"""SQLite の DB の複製(バックアップ API)と,複製した DB の点検・件数. + +目録(`catalog.sqlite`)と対応表(`identity.sqlite`)の複製に使う(`tiasdata backup`). +対応表を開くのは取り込みの処理だけという規則(coding-style.md)の例外として,複製と点検の +ためにだけ開く.中身(患者 ID)は読まず,行の数だけを返す. +""" + +import sqlite3 +from contextlib import closing +from pathlib import Path + + +def _connect_read_only(path: Path) -> sqlite3.Connection: + # 複製元・点検する複製は書き換えない(無ければ作らない) + uri = f"{path.resolve().as_uri()}?mode=ro" + try: + return sqlite3.connect(uri, uri=True) + except sqlite3.OperationalError as e: + raise ValueError(f"DB を開けない: {e}") from e + + +def copy_database(source: Path, target: Path) -> None: + """SQLite のバックアップ API(`Connection.backup`)で DB を複製する. + + ファイルのコピーと違い,書き込みの途中の状態を写さない(複製元の 1 つの時点の内容になる). + + Args: + source: 複製元の DB. + target: 複製先のファイル(空のファイルか,無いこと). + + Raises: + ValueError: 複製元を開けない. + sqlite3.Error: 複製に失敗した. + """ + with closing(_connect_read_only(source)) as src, closing(sqlite3.connect(target)) as dst: + src.backup(dst) + + +def integrity_problems(path: Path) -> int: + """DB に `PRAGMA integrity_check` をかけ,問題の数を返す(0 なら ok). + + 問題の内容(表・ページの位置)は返さない. + + Args: + path: 点検する DB. + + Returns: + 問題の数. + """ + with closing(_connect_read_only(path)) as conn: + rows = conn.execute("PRAGMA integrity_check").fetchall() + if rows == [("ok",)]: + return 0 + return len(rows) + + +def user_version(path: Path) -> int: + """DB のスキーマの版(`PRAGMA user_version`)を返す.""" + with closing(_connect_read_only(path)) as conn: + return conn.execute("PRAGMA user_version").fetchone()[0] + + +# 目録の件数として表示する表(表示名 → SQL).値の埋め込みを避けるため SQL ごと固定する +_CATALOG_COUNTS: dict[str, str] = { + "subjects": "SELECT COUNT(*) FROM subjects", + "captures": "SELECT COUNT(*) FROM captures", + "assets": "SELECT COUNT(*) FROM assets", + "annotations": "SELECT COUNT(*) FROM annotations", + "dataset_versions": "SELECT COUNT(*) FROM dataset_versions", +} + + +def count_catalog_rows(path: Path) -> dict[str, int]: + """目録の DB の主な表の行の数を返す(症例・撮影・ファイル・所見・版). + + Args: + path: 目録の DB(複製したもの). + + Returns: + 表の名前 → 行の数. + """ + with closing(_connect_read_only(path)) as conn: + return {name: conn.execute(sql).fetchone()[0] for name, sql in _CATALOG_COUNTS.items()} + + +def count_identity_rows(path: Path) -> int: + """対応表の DB の行の数を返す(中身は読まない). + + Args: + path: 対応表の DB(複製したもの). + + Returns: + `identity_map` の行の数. + """ + with closing(_connect_read_only(path)) as conn: + return conn.execute("SELECT COUNT(*) FROM identity_map").fetchone()[0] diff --git a/src/tiasdata/catalog/migrations/catalog/0002_review_kind.sql b/src/tiasdata/catalog/migrations/catalog/0002_review_kind.sql new file mode 100644 index 0000000..748d666 --- /dev/null +++ b/src/tiasdata/catalog/migrations/catalog/0002_review_kind.sql @@ -0,0 +1,9 @@ +-- 所見の種類 review(要確認の印)を追加する(2026-10-08 のユーザー判断.SPEC_02「kinds」). +-- 取り込み元のデータに基盤では正解を決められない問題があるものに,要確認リストから付ける. +-- apply_migrations() が BEGIN・COMMIT で囲み,PRAGMA user_version を更新する. +-- 適用済みのマイグレーションは編集しない(変更は 0003_*.sql を追加して行う). + +INSERT INTO kinds (kind, category, description, value_schema_version) VALUES + ('review', 'annotation', + '要確認の印.値は {"reason": 理由, "resolve": 確かめる方法, "since": YYYY-MM-DD, "numbers": [取り込み元での番号, ...]}', + 1); diff --git a/src/tiasdata/catalog/queries.py b/src/tiasdata/catalog/queries.py index 9d44f4b..90059b4 100644 --- a/src/tiasdata/catalog/queries.py +++ b/src/tiasdata/catalog/queries.py @@ -81,6 +81,30 @@ return count +def count_assets_without_capture(conn: sqlite3.Connection) -> int: + """撮影にひも付かない(`capture_id` が NULL の)ファイルの数.""" + (count,) = conn.execute("SELECT COUNT(*) FROM assets WHERE capture_id IS NULL").fetchone() + return count + + +def count_valid_annotations_by_target(conn: sqlite3.Connection, kind: str) -> tuple[int, int]: + """種類が一致する有効な所見(訂正されていないもの)の数を,付いた先ごとに数える. + + Args: + conn: 目録への接続. + kind: 所見の種類. + + Returns: + (撮影に付いたものの数, ファイルに付いたものの数). + """ + (on_captures, on_files) = conn.execute( + "SELECT COALESCE(SUM(capture_id IS NOT NULL), 0), COALESCE(SUM(sha256 IS NOT NULL), 0)" + " FROM annotations WHERE kind = ? AND superseded_by IS NULL", + (kind,), + ).fetchone() + return on_captures, on_files + + def list_asset_files(conn: sqlite3.Connection) -> list[tuple[str, str]]: """目録の全ファイルの(SHA-256, 拡張子)を SHA-256 の順に返す(点検用).""" rows = conn.execute("SELECT sha256, ext FROM assets ORDER BY sha256") diff --git a/src/tiasdata/catalog/records.py b/src/tiasdata/catalog/records.py index ce9b135..17aa55b 100644 --- a/src/tiasdata/catalog/records.py +++ b/src/tiasdata/catalog/records.py @@ -155,6 +155,59 @@ return [value for (value,) in rows] +def file_annotation_values( + conn: sqlite3.Connection, sha256: str, kind: str, source: str +) -> list[str]: + """ファイルに付いた所見のうち,種類と由来が一致するものの値(JSON の文字列)を返す. + + 訂正された行(`superseded_by` あり)も含める(取り込み元から読んだ元の値と照合するため). + """ + rows = conn.execute( + "SELECT value FROM annotations" + " WHERE sha256 = ? AND kind = ? AND source = ? ORDER BY annotation_id", + (sha256, kind, source), + ) + return [value for (value,) in rows] + + +def has_valid_annotation( + conn: sqlite3.Connection, + kind: str, + *, + capture_id: str | None = None, + sha256: str | None = None, +) -> bool: + """撮影またはファイルに,種類が一致する有効な所見(訂正されていないもの)があるか. + + Args: + conn: 目録への接続. + kind: 所見の種類. + capture_id: 撮影 ID(撮影に付いた所見を見るとき). + sha256: ファイルの SHA-256(ファイルに付いた所見を見るとき). + + Returns: + 有効な所見があれば True. + + Raises: + RuntimeError: 撮影 ID と SHA-256 の両方を指定した,またはどちらも指定しなかった. + """ + if (capture_id is None) == (sha256 is None): + raise RuntimeError("capture_id と sha256 のどちらか一方だけを指定する") + if capture_id is not None: + row = conn.execute( + "SELECT 1 FROM annotations" + " WHERE capture_id = ? AND kind = ? AND superseded_by IS NULL LIMIT 1", + (capture_id, kind), + ).fetchone() + else: + row = conn.execute( + "SELECT 1 FROM annotations" + " WHERE sha256 = ? AND kind = ? AND superseded_by IS NULL LIMIT 1", + (sha256, kind), + ).fetchone() + return row is not None + + def insert_subject(conn: sqlite3.Connection, subject_id: str, created_at: str) -> None: """症例を登録する.""" conn.execute( @@ -219,6 +272,31 @@ return int(cursor.lastrowid) +def insert_file_annotation( + conn: sqlite3.Connection, + *, + kind: str, + sha256: str, + value_json: str, + source: str, + created_at: str, + annotator: str | None = None, +) -> int: + """ファイルに付く所見を追加する(値は `kinds` の型で検証済みの JSON の文字列). + + 撮影にひも付かないファイル(`assets.capture_id` が NULL)の所見に使う. + + Returns: + 追加した行の `annotation_id`. + """ + cursor = conn.execute( + "INSERT INTO annotations (kind, sha256, value, annotator, source, created_at)" + " VALUES (?, ?, ?, ?, ?, ?)", + (kind, sha256, value_json, annotator, source, created_at), + ) + return int(cursor.lastrowid) + + def insert_derivation( conn: sqlite3.Connection, *, diff --git a/src/tiasdata/check.py b/src/tiasdata/check.py index c4227c1..821dbfc 100644 --- a/src/tiasdata/check.py +++ b/src/tiasdata/check.py @@ -30,6 +30,8 @@ dataset_versions: 凍結した版の数(manifest を作り直して照合した数). manifest_mismatch: 構成から作り直した manifest の SHA-256 が版の記録と一致しない (または作り直せない)版の数. + unlinked_files: 撮影にひも付かないファイルの数(同じ中身で患者の決まらないもの等. + 要確認リストから意図して登録するため,問題には数えない). """ catalog_files: int @@ -45,6 +47,7 @@ broken_dataset_members: int dataset_versions: int manifest_mismatch: int + unlinked_files: int = 0 @property def problems(self) -> int: @@ -97,4 +100,5 @@ broken_dataset_members=catalog.count_broken_dataset_members(conn), dataset_versions=dataset_versions, manifest_mismatch=manifest_mismatch, + unlinked_files=catalog.count_assets_without_capture(conn), ) diff --git a/src/tiasdata/cli.py b/src/tiasdata/cli.py index 6a2eb69..21e84d9 100644 --- a/src/tiasdata/cli.py +++ b/src/tiasdata/cli.py @@ -6,10 +6,11 @@ from pydantic import ValidationError -from tiasdata import __version__, catalog, datasets, export +from tiasdata import __version__, backup, catalog, datasets, export from tiasdata.check import CheckResult, run_check from tiasdata.config import load_config from tiasdata.importers import ImportResult, run_import +from tiasdata.kinds import REVIEW_KIND from tiasdata.manifest import validation_error_details from tiasdata.stats import CatalogStats, collect_stats @@ -46,7 +47,9 @@ # 件数と設定で付けた名前だけを出す(患者 ID・元のファイル名・パスは出さない) print(f"取り込み元: {result.source_name}({result.importer})") print(f"取り込み元の画像: {result.found} 件") - print(f"保留で飛ばした: {result.held} 件(保留リスト {result.hold_filename})") + print( + f"要確認リストの番号: {result.review_numbers} 件(要確認リスト {result.review_filename})" + ) # 対応表の行番号は位置だが,番号と同じく既定では出さない(--show-source-names で出す) unused_rows = result.unused_map_rows unused_note = "" @@ -61,6 +64,12 @@ f"ファイル {result.assets_added} 件・所見 {result.annotations_added} 件・" f"由来 {result.derivations_added} 件" ) + print( + f"要確認として登録: {result.reviewed_added} 件(うち撮影にひも付けないファイル " + f"{result.unlinked_files_added} 件・番号 {result.unlinked_numbers_added} 個)" + ) + print(f"既存の撮影・ファイルに要確認の印を足した: {result.review_marks_added} 件") + print(f"要確認リストから外れたが印が残っている: {result.review_marks_stale} 件") print(f"ストアにコピー: {result.copied_to_store} 件") @@ -80,8 +89,10 @@ print("ファイル(種類ごと):") for kind, count in stats.assets_by_kind.items(): print(f" {kind}: {count} 件") + print(f"撮影にひも付かないファイル: {stats.assets_without_capture} 件") print("所見(訂正されていないもの):") - if not stats.annotations: + review_marks = stats.review_marks_on_captures + stats.review_marks_on_files + if not stats.annotations and not review_marks: print(" なし") for kind, summary in stats.annotations.items(): print(f" {kind}: {summary.total} 件") @@ -91,6 +102,13 @@ print(" 組み合わせ:") for value, count in summary.by_value.items(): print(f" {value}: {count} 件") + if review_marks: + # 要確認の印は件数だけを出す(値は理由の全文で,値ごとの内訳にならない) + print( + f" {REVIEW_KIND}: {review_marks} 件" + f"(撮影に付いたもの {stats.review_marks_on_captures} 件・" + f"ファイルに付いたもの {stats.review_marks_on_files} 件)" + ) print(f"訂正された所見: {stats.superseded_annotations} 件") @@ -127,6 +145,7 @@ f"凍結した版の manifest の不一致: {result.manifest_mismatch} 件" f"(版 {result.dataset_versions} 件)" ) + print(f"撮影にひも付かないファイル(問題には数えない): {result.unlinked_files} 件") # manifest の SHA-256 は一覧では先頭だけを出す(照合には `show` の全桁を使う) @@ -189,8 +208,9 @@ print( f"ファイル {result.members} 件・所見 {result.member_annotations} 件" f"(種類が一致したファイル {result.candidates} 件・必須の所見が無く除いた " - f"{result.candidates - result.members} 件)" + f"{result.candidates - result.members - result.unlinked} 件)" ) + print(f"撮影にひも付かないため除いた: {result.unlinked} 件") print(f"manifest の SHA-256: {result.manifest_sha256}") return 0 @@ -211,6 +231,22 @@ return 0 +def _cmd_backup(args: argparse.Namespace) -> int: + config = load_config() + result = backup.run_backup(config) + # パスは出さない(SPEC_03「出力の規則」).ファイル名(日時)と件数だけ + counts = result.catalog.counts + print(f"目録を複製した: {result.catalog.filename}(目録のフォルダの backups/.整合性 ok)") + print( + f" 症例 {counts['subjects']} 件・撮影 {counts['captures']} 件・" + f"ファイル {counts['assets']} 件・所見 {counts['annotations']} 件・" + f"版 {counts['dataset_versions']} 件" + ) + print(f"対応表を複製した: {result.identity.filename}(対応表のフォルダの backups/.整合性 ok)") + print(f" 行 {result.identity.counts['identity_map']} 件") + return 0 + + def _print_migration(label: str, result: catalog.MigrationResult) -> None: if result.applied: print(f"{label}: スキーマの版 {result.before} → {result.after}({result.applied} 件適用)") @@ -240,7 +276,8 @@ "import", help="取り込み元を読み,ストアにコピーして目録に登録する", description="設定ファイルで名前を付けた取り込み元を読み,ファイルをストアにコピーして" - "症例・撮影・ファイル・所見・由来を登録する.保留リスト(holds/<名前>.toml)の番号は飛ばす." + "症例・撮影・ファイル・所見・由来を登録する.要確認リスト(reviews/<名前>.toml)の番号には" + "要確認の印を付ける(同じ中身が要確認の複数の番号にあれば,撮影にひも付けずに登録する)." "既に登録済みのファイルは飛ばす(何度実行しても同じ結果になる)", ) import_.add_argument("source", metavar="<取り込み元>", help="設定ファイルの sources.<名前>") @@ -321,6 +358,16 @@ help="書き出す版(必須)", ) export_.set_defaults(handler=_cmd_export) + + backup_ = subparsers.add_parser( + "backup", + help="目録と対応表をそれぞれの backups/ に複製して点検する", + description="目録を目録のフォルダの backups/catalog-.sqlite に,対応表を" + "対応表のフォルダの backups/identity-<同じ日時>.sqlite に,SQLite のバックアップ API で" + "複製する.複製に integrity_check をかけて ok を確かめ,件数を表示する." + "同じ名前があれば上書きせずに停止する", + ) + backup_.set_defaults(handler=_cmd_backup) return parser diff --git a/src/tiasdata/datasets.py b/src/tiasdata/datasets.py index 0ff1f7e..9ac24da 100644 --- a/src/tiasdata/datasets.py +++ b/src/tiasdata/datasets.py @@ -2,7 +2,7 @@ 定義(`datasets/<名前>.toml`)は個人情報を含まないためリポジトリで管理する(PR で変更する). 実行時は,このパッケージの位置からリポジトリのルートを辿って `datasets/` を見つける -(`importers.holds` の保留リストと同じ.版の作成はリポジトリの clone から実行する). +(`importers.reviews` の要確認リストと同じ.版の作成はリポジトリの clone から実行する). 版とその構成は凍結される(UPDATE・DELETE しない).構成(ファイルと採用した所見の ID)と 定義の内容が直前の版と同じなら新しい版を作らない. @@ -252,6 +252,7 @@ candidates: 種類が一致したファイルの数. members: 版に入れるファイル(SHA-256 の順). member_annotations: 採用した所見(ファイルの SHA-256・所見の ID の順). + unlinked: 条件に合うが,撮影にひも付かないため版に入れなかったファイルの数. """ model_config = ConfigDict(frozen=True) @@ -259,11 +260,12 @@ candidates: int members: list[MemberAssetRow] member_annotations: list[MemberAnnotationRow] + unlinked: int = 0 @property def excluded(self) -> int: """必須の所見が無いため版に入れなかったファイルの数.""" - return self.candidates - len(self.members) + return self.candidates - len(self.members) - self.unlinked def key(self) -> CompositionKey: """構成の比較に使う値(ファイルの SHA-256 と,採用した所見の ID).""" @@ -302,7 +304,8 @@ ファイルごとに,その撮影に付いた所見とファイルに付いた所見のうち有効なもの(訂正されて いないもの)を種類ごとに集める.`require` の種類がすべてあるファイルを版に入れ, - `require`・`columns` の種類の所見を採用する. + `require`・`columns` の種類の所見を採用する.条件に合うファイルのうち撮影にひも付かない + もの(manifest の `subject_id`・`capture_id` を決められないもの)は版に入れず,数を返す. Args: definition: 定義. @@ -314,7 +317,7 @@ Raises: ValueError: 版に入れるファイルに同じ種類の有効な所見が 2 つ以上ある(どちらを使うかを - 黙って決めない.SPEC_03),または版に入れるファイルが撮影にひも付いていない. + 黙って決めない.SPEC_03). """ by_capture: dict[tuple[str, str], list[AnnotationRow]] = defaultdict(list) by_file: dict[tuple[str, str], list[AnnotationRow]] = defaultdict(list) @@ -328,7 +331,7 @@ members: list[MemberAssetRow] = [] member_annotations: list[MemberAnnotationRow] = [] conflicts: list[str] = [] - no_capture: list[str] = [] + unlinked = 0 for asset in sorted(assets, key=lambda a: a.sha256): found = { kind: [ @@ -339,13 +342,15 @@ } if any(not found[kind] for kind in definition.select.require): continue + if asset.capture_id is None or asset.subject_id is None: + # 版に入れないので,所見の重複の確認より先に除く(2026-10-08 のユーザー判断. + # 同じ中身で患者の食い違うファイル等.除いた数は表示する) + unlinked += 1 + continue duplicated = [kind for kind, rows in found.items() if len(rows) > 1] if duplicated: conflicts.extend(f"{_asset_label(asset)} の {kind}" for kind in duplicated) continue - if asset.capture_id is None or asset.subject_id is None: - no_capture.append(asset.sha256[:_SHORT_SHA256]) - continue members.append(asset) member_annotations.extend( MemberAnnotationRow(member_sha256=asset.sha256, annotation=rows[0]) @@ -359,15 +364,12 @@ f"{len(conflicts)} 件ある(例: {_examples(conflicts)})." "所見を訂正して 1 つにしてから版を作る" ) - if no_capture: - raise ValueError( - f"データセット '{definition.name}': 撮影にひも付いていないファイルが " - f"{len(no_capture)} 件ある(例: {_examples(no_capture)})." - "manifest の subject_id・capture_id を決められない" - ) member_annotations.sort(key=lambda a: (a.member_sha256, a.annotation.annotation_id)) return Composition( - candidates=len(assets), members=members, member_annotations=member_annotations + candidates=len(assets), + members=members, + member_annotations=member_annotations, + unlinked=unlinked, ) @@ -430,6 +432,7 @@ candidates: 種類が一致したファイルの数. members: 版のファイルの数. member_annotations: 採用した所見の数. + unlinked: 条件に合うが,撮影にひも付かないため除いたファイルの数. manifest_sha256: 版の manifest.csv の SHA-256. """ @@ -441,6 +444,7 @@ candidates: int members: int member_annotations: int + unlinked: int manifest_sha256: str @@ -505,7 +509,8 @@ if not composition.members: raise ValueError( f"データセット '{definition.name}': 条件に合うファイルが 0 件" - f"(種類が一致したファイル {composition.candidates} 件)" + f"(種類が一致したファイル {composition.candidates} 件・" + f"撮影にひも付かないため除いた {composition.unlinked} 件)" ) data = build_manifest( manifest_entries(composition.members, composition.member_annotations), @@ -559,6 +564,7 @@ candidates=composition.candidates, members=len(composition.members), member_annotations=len(composition.member_annotations), + unlinked=composition.unlinked, manifest_sha256=record.manifest_sha256, ) diff --git a/src/tiasdata/importers/__init__.py b/src/tiasdata/importers/__init__.py index f1c02af..6d03d8a 100644 --- a/src/tiasdata/importers/__init__.py +++ b/src/tiasdata/importers/__init__.py @@ -10,7 +10,7 @@ from tiasdata import catalog from tiasdata.catalog import identity from tiasdata.config import Config -from tiasdata.importers import holds as holds_module +from tiasdata.importers import reviews as reviews_module from tiasdata.importers import tongue_color_folders from tiasdata.importers.base import ImportContext, Importer, ImportResult @@ -24,7 +24,7 @@ config: Config, source_name: str, *, - holds_dir: Path | None = None, + reviews_dir: Path | None = None, show_source_names: bool = False, ) -> ImportResult: """設定ファイルの取り込み元を名前で指定して取り込む. @@ -35,14 +35,14 @@ Args: config: 設定. source_name: 取り込み元の名前(`sources.<名前>`). - holds_dir: 保留リストのフォルダ.省略時はリポジトリの `holds/`. + reviews_dir: 要確認リストのフォルダ.省略時はリポジトリの `reviews/`. show_source_names: エラーメッセージに取り込み元での番号・名前を出すか. Returns: 取り込みの結果(件数). Raises: - ValueError: 取り込み元・importer が無い,目録・対応表が無いか古い,保留リストの誤り, + ValueError: 取り込み元・importer が無い,目録・対応表が無いか古い,要確認リストの誤り, 取り込み元・対応表の不備,または登録済みの内容との食い違い. """ source = config.sources.get(source_name) @@ -54,16 +54,16 @@ f"取り込み元 '{source_name}' の importer '{source.importer}' が無い" f"(使えるもの: {', '.join(sorted(IMPORTERS))})" ) - # 保留リストの場所は呼び出し時に引く(テストで差し替えられるようにモジュール経由で呼ぶ) - directory = holds_dir if holds_dir is not None else holds_module.default_holds_dir() - holds = holds_module.load_holds(directory, source_name) + # 要確認リストの場所は呼び出し時に引く(テストで差し替えられるようにモジュール経由で呼ぶ) + directory = reviews_dir if reviews_dir is not None else reviews_module.default_reviews_dir() + reviews = reviews_module.load_reviews(directory, source_name) paths = config.catalog context = ImportContext( source_name=source_name, source=source, store_dir=paths.store_dir, - holds=holds, + reviews=reviews, show_source_names=show_source_names, ) with closing(catalog.connect_latest(paths.catalog_path)) as conn: diff --git a/src/tiasdata/importers/base.py b/src/tiasdata/importers/base.py index 17722b2..19191b4 100644 --- a/src/tiasdata/importers/base.py +++ b/src/tiasdata/importers/base.py @@ -6,7 +6,7 @@ from typing import Protocol from tiasdata.config import SourceConfig -from tiasdata.importers.holds import Holds +from tiasdata.importers.reviews import Reviews @dataclass(frozen=True) @@ -17,7 +17,7 @@ source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`). source: 取り込み元の設定. store_dir: ストアのフォルダ. - holds: 保留の番号. + reviews: 要確認リスト(番号 → 理由等). show_source_names: エラーメッセージに取り込み元の番号・名前を出すか (原因の特定に必要なときだけ明示する.error-handling.md). """ @@ -25,7 +25,7 @@ source_name: str source: SourceConfig store_dir: Path - holds: Holds + reviews: Reviews show_source_names: bool = False @@ -36,26 +36,32 @@ Attributes: source_name: 取り込み元の名前. importer: importer の名前. - found: 取り込み元で見つけた画像の数(保留を含む). - held: 保留リストにより飛ばした数. - hold_filename: 保留リストのファイル名(`holds/<名前>.toml`). + found: 取り込み元で見つけた画像の数(要確認の番号を含む). + review_numbers: 要確認リストの番号の数. + review_filename: 要確認リストのファイル名(`reviews/<名前>.toml`). unused_map_rows: 対応表で番号が整数でないため使わなかった行の行番号(xlsx の 1 始まり. 位置であり対応表の中身ではない.表示は件数だけで,行番号は `--show-source-names` を指定したときだけ出す). - skipped_existing: 既に登録済みで飛ばした数. + skipped_existing: 既に登録済みで飛ばした数(撮影にひも付けないファイルは 1 つで 1 件). copied_to_store: ストアにコピーした数(同じ中身が既にあったものは含まない). subjects_added: 登録した症例の数. captures_added: 登録した撮影の数. assets_added: 登録したファイルの数. - annotations_added: 登録した所見の数. + annotations_added: 登録した所見の数(要確認の印を含む). derivations_added: 登録した由来の数. + reviewed_added: 要確認として登録した数(撮影と,撮影にひも付けないファイル). + unlinked_files_added: 登録した撮影にひも付けないファイルの数. + unlinked_numbers_added: 登録した撮影にひも付けないファイルの,取り込み元での番号の数. + review_marks_added: 登録済みの撮影・撮影にひも付けないファイルに足した要確認の印の数. + review_marks_stale: 要確認リストから外れたのに有効な要確認の印が残っている撮影の数 + (印は外さない.外すのは編集のコマンド). """ source_name: str importer: str found: int - held: int - hold_filename: str + review_numbers: int + review_filename: str unused_map_rows: tuple[int, ...] skipped_existing: int copied_to_store: int @@ -64,6 +70,11 @@ assets_added: int annotations_added: int derivations_added: int + reviewed_added: int + unlinked_files_added: int + unlinked_numbers_added: int + review_marks_added: int + review_marks_stale: int class Importer(Protocol): diff --git a/src/tiasdata/importers/holds.py b/src/tiasdata/importers/holds.py deleted file mode 100644 index b2aedfc..0000000 --- a/src/tiasdata/importers/holds.py +++ /dev/null @@ -1,136 +0,0 @@ -"""保留リスト(`holds/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「保留リスト」). - -保留リストは個人情報を含まないためリポジトリで管理する(PR で変更する). -実行時は,リポジトリのルート(`repo.find_repo_root()`)の `holds/` を見つける. -パッケージとして別の場所に入れた場合は見つからないので,取り込みはリポジトリの clone から -実行する. -""" - -import re -import tomllib -from datetime import date -from pathlib import Path -from typing import NamedTuple - -from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError - -from tiasdata import repo - -HOLDS_DIRNAME = "holds" -# 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない -_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") - - -class HoldEntry(BaseModel): - """保留リストの 1 件(`[[hold]]`). - - Attributes: - number: 取り込み元での番号. - reason: 保留にした理由. - resolve: 確かめる方法. - since: 保留にした日. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - number: StrictInt = Field(ge=1) - reason: str = Field(min_length=1) - resolve: str = Field(min_length=1) - since: date - - -class HoldFile(BaseModel): - """保留リストのファイル全体.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - hold: list[HoldEntry] = [] - - -class Holds(NamedTuple): - """取り込み元の保留の番号. - - Attributes: - numbers: 保留にする番号. - filename: 保留リストのファイル名(リポジトリの `holds/` からの相対.表示用). - """ - - numbers: frozenset[int] - filename: str - - -def default_holds_dir() -> Path: - """リポジトリの `holds/` フォルダを返す. - - リポジトリのルートは `repo.find_repo_root()` で見つける. - - Returns: - `holds/` のパス. - - Raises: - ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて - 実行した等),またはルートに `holds/` が無い. - """ - try: - root = repo.find_repo_root() - except ValueError as e: - # 取り込みの文脈で対処が分かるように,保留リストの場所として言い直す - raise ValueError( - "保留リスト(holds/)の場所が分からない." - "取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)" - ) from e - holds_dir = root / HOLDS_DIRNAME - if not holds_dir.is_dir(): - raise ValueError(f"リポジトリに {HOLDS_DIRNAME}/ が無い(保留リストを置くフォルダ)") - return holds_dir - - -def load_holds(holds_dir: Path, source_name: str) -> Holds: - """取り込み元の保留リストを読んで検証する. - - ファイルが無ければ停止する(設定の取り込み元の名前を誤ると,保留が効かないまま取り込まれる - ため).保留の無い取り込み元には空の保留リスト(`[[hold]]` が 0 件,または空のファイル)を置く. - - Args: - holds_dir: 保留リストのフォルダ. - source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`). - - Returns: - 保留の番号. - - Raises: - ValueError: 取り込み元の名前がファイル名に使えない,保留リストのファイルが無い, - TOML として読めない, - 形が誤っている(未知のキー・必須キーの欠落・番号が 1 未満等), - または番号が重複している. - """ - if not _SOURCE_NAME_PATTERN.match(source_name): - raise ValueError( - f"取り込み元の名前 '{source_name}' は英数字・_・- だけにする" - "(保留リストのファイル名に使う)" - ) - filename = f"{HOLDS_DIRNAME}/{source_name}.toml" - path = holds_dir / f"{source_name}.toml" - if not path.is_file(): - raise ValueError( - f"保留リスト {filename} が無い.保留が無い取り込み元は空の保留リストを置く" - ) - try: - data = tomllib.loads(path.read_text(encoding="utf-8")) - except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: - raise ValueError(f"保留リスト {filename} を TOML として読めない: {e}") from e - try: - hold_file = HoldFile.model_validate(data) - except ValidationError as e: - details = "; ".join( - f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" - for error in e.errors(include_input=False, include_url=False) - ) - raise ValueError(f"保留リスト {filename} の形が誤っている: {details}") from e - - numbers = [entry.number for entry in hold_file.hold] - duplicated = sorted({n for n in numbers if numbers.count(n) > 1}) - if duplicated: - # 保留リストの番号はファイル名の連番で個人をたどれないため,リストの誤りの特定に表示する - raise ValueError(f"保留リスト {filename} に同じ番号が 2 回以上ある: {duplicated}") - return Holds(numbers=frozenset(numbers), filename=filename) diff --git a/src/tiasdata/importers/reviews.py b/src/tiasdata/importers/reviews.py new file mode 100644 index 0000000..b3c24aa --- /dev/null +++ b/src/tiasdata/importers/reviews.py @@ -0,0 +1,144 @@ +"""要確認リスト(`reviews/<取り込み元の名前>.toml`)の読み込みと検証(SPEC_03「要確認リスト」). + +要確認リストに載せた番号は,取り込みで要確認の印(所見の種類 `review`)を付けて登録する +(2026-10-08 のユーザー判断.それまでは「保留」として取り込みで飛ばしていた). +要確認リストは個人情報を含まないためリポジトリで管理する(PR で変更する). +実行時は,リポジトリのルート(`repo.find_repo_root()`)の `reviews/` を見つける. +パッケージとして別の場所に入れた場合は見つからないので,取り込みはリポジトリの clone から +実行する. +""" + +import re +import tomllib +from datetime import date +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, ValidationError + +from tiasdata import repo + +REVIEWS_DIRNAME = "reviews" +# 取り込み元の名前をファイル名に使うため,パスの区切り等を含む名前は受け付けない +_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +class ReviewEntry(BaseModel): + """要確認リストの 1 件(`[[review]]`). + + Attributes: + number: 取り込み元での番号. + reason: 要確認にした理由. + resolve: 確かめる方法. + since: 要確認にした日. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + number: StrictInt = Field(ge=1) + reason: str = Field(min_length=1) + resolve: str = Field(min_length=1) + since: date + + +class ReviewFile(BaseModel): + """要確認リストのファイル全体.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + review: list[ReviewEntry] = [] + + +class Reviews(NamedTuple): + """取り込み元の要確認の番号. + + Attributes: + entries: 番号 → 要確認リストの 1 件. + filename: 要確認リストのファイル名(リポジトリの `reviews/` からの相対.表示用). + """ + + entries: dict[int, ReviewEntry] + filename: str + + @property + def numbers(self) -> frozenset[int]: + """要確認の番号.""" + return frozenset(self.entries) + + +def default_reviews_dir() -> Path: + """リポジトリの `reviews/` フォルダを返す. + + リポジトリのルートは `repo.find_repo_root()` で見つける. + + Returns: + `reviews/` のパス. + + Raises: + ValueError: リポジトリのルートが見つからない(パッケージとして別の場所に入れて + 実行した等),またはルートに `reviews/` が無い. + """ + try: + root = repo.find_repo_root() + except ValueError as e: + # 取り込みの文脈で対処が分かるように,要確認リストの場所として言い直す + raise ValueError( + "要確認リスト(reviews/)の場所が分からない." + "取り込みは TIASDataHub のリポジトリの clone から実行する(uv run tiasdata import …)" + ) from e + reviews_dir = root / REVIEWS_DIRNAME + if not reviews_dir.is_dir(): + raise ValueError(f"リポジトリに {REVIEWS_DIRNAME}/ が無い(要確認リストを置くフォルダ)") + return reviews_dir + + +def load_reviews(reviews_dir: Path, source_name: str) -> Reviews: + """取り込み元の要確認リストを読んで検証する. + + ファイルが無ければ停止する(設定の取り込み元の名前を誤ると,要確認の印が付かないまま + 取り込まれるため).要確認の無い取り込み元には空の要確認リスト(`[[review]]` が 0 件, + または空のファイル)を置く. + + Args: + reviews_dir: 要確認リストのフォルダ. + source_name: 取り込み元の名前(設定ファイルの `sources.<名前>`). + + Returns: + 要確認の番号. + + Raises: + ValueError: 取り込み元の名前がファイル名に使えない,要確認リストのファイルが無い, + TOML として読めない, + 形が誤っている(未知のキー・必須キーの欠落・番号が 1 未満等), + または番号が重複している. + """ + if not _SOURCE_NAME_PATTERN.match(source_name): + raise ValueError( + f"取り込み元の名前 '{source_name}' は英数字・_・- だけにする" + "(要確認リストのファイル名に使う)" + ) + filename = f"{REVIEWS_DIRNAME}/{source_name}.toml" + path = reviews_dir / f"{source_name}.toml" + if not path.is_file(): + raise ValueError( + f"要確認リスト {filename} が無い.要確認が無い取り込み元は空の要確認リストを置く" + ) + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e: + raise ValueError(f"要確認リスト {filename} を TOML として読めない: {e}") from e + try: + review_file = ReviewFile.model_validate(data) + except ValidationError as e: + details = "; ".join( + f"{'.'.join(str(part) for part in error['loc']) or '(全体)'}: {error['msg']}" + for error in e.errors(include_input=False, include_url=False) + ) + raise ValueError(f"要確認リスト {filename} の形が誤っている: {details}") from e + + numbers = [entry.number for entry in review_file.review] + duplicated = sorted({n for n in numbers if numbers.count(n) > 1}) + if duplicated: + # 要確認リストの番号はファイル名の連番で個人をたどれないため,リストの誤りの特定に表示する + raise ValueError(f"要確認リスト {filename} に同じ番号が 2 回以上ある: {duplicated}") + return Reviews(entries={entry.number: entry for entry in review_file.review}, filename=filename) diff --git a/src/tiasdata/importers/tongue_color_folders.py b/src/tiasdata/importers/tongue_color_folders.py index 05bc5ce..d40c954 100644 --- a/src/tiasdata/importers/tongue_color_folders.py +++ b/src/tiasdata/importers/tongue_color_folders.py @@ -9,6 +9,12 @@ - 患者 ID ごとに症例を 1 つ(患者 ID は対応表 `identity.sqlite` にだけ入れる) - 画像 1 枚ごとに撮影を 1 つ(撮影日は不明なので NULL,撮影装置は `TIAS`) - ファイル(種類 `tongue_crop_masked`)・撮影に付く所見 `tongue_color`・由来(DeepTIAS.入力なし) +- 要確認リスト(`reviews/<取り込み元の名前>.toml`)の番号には,撮影に要確認の印(所見 `review`) + +同じ中身(同じ SHA-256)の画像が複数の番号にあり,その番号がすべて要確認リストに載っている場合は, +どの患者の写真かを決められないため,ファイルを 1 つだけ登録して撮影にひも付けない(症例・撮影・ +対応表の行を作らない).所見 `tongue_color`・`review` はファイルに付ける +(2026-10-08 のユーザー判断). 想定外の構成・対応表の不備は黙って飛ばさずに停止する(error-handling.md). 例外メッセージには患者 ID・元のファイル名を出さない.取り込み元での番号は @@ -25,11 +31,13 @@ from pathlib import Path from typing import NamedTuple +from pydantic import BaseModel + from tiasdata import store from tiasdata.catalog import identity, records, write_transaction from tiasdata.importers.base import ImportContext, ImportResult -from tiasdata.importers.holds import Holds -from tiasdata.kinds import TongueColor, dump_annotation_value +from tiasdata.importers.reviews import ReviewEntry, Reviews +from tiasdata.kinds import REVIEW_KIND, Review, TongueColor, dump_annotation_value logger = logging.getLogger(__name__) @@ -52,6 +60,8 @@ ANNOTATION_KIND = "tongue_color" # 所見の由来: TIASJudgeModel のフォルダ名から読み取った(SPEC_02「annotations」) ANNOTATION_SOURCE = "tiasjudgemodel_folder" +# 要確認の印(所見の種類 REVIEW_KIND)の由来: 要確認リストから付けた +REVIEW_SOURCE = "review_list" DEVICE = "TIAS" # 由来(SPEC_02「derivations」).元の撮影画像は基盤に無いため入力なしで登録する @@ -88,13 +98,60 @@ value: TongueColor +class HashedImage(NamedTuple): + """SHA-256 を計算した取り込み元の画像.""" + + image: SourceImage + sha256: str + ext: str + + class PlannedImage(NamedTuple): - """登録の対象と決めた画像(ハッシュ・患者 ID を引いた後).""" + """撮影にひも付けて登録する画像(ハッシュ・患者 ID を引いた後). + + Attributes: + image: 取り込み元の画像. + sha256: 中身の SHA-256. + ext: 拡張子. + patient_key: 患者 ID(対応表 `identity.sqlite` にだけ入れる). + review: 要確認の印の値(要確認リストに無ければ None). + """ image: SourceImage sha256: str ext: str patient_key: str + review: Review | None + + +class PlannedUnlinked(NamedTuple): + """撮影にひも付けずに登録するファイル(同じ中身が要確認リストの複数の番号にあるもの). + + Attributes: + images: 同じ中身の画像(番号の昇順.2 つ以上). + sha256: 中身の SHA-256. + ext: 拡張子. + value: フォルダ名から読み取った所見の値(どの番号でも同じ). + review: 要確認の印の値(`numbers` はすべての番号). + """ + + images: tuple[SourceImage, ...] + sha256: str + ext: str + value: TongueColor + review: Review + + @property + def first(self) -> SourceImage: + """番号の最も小さい画像(ファイルを読む元・`source_name` に使う).""" + return self.images[0] + + +class ImportPlan(NamedTuple): + """登録の対象(撮影にひも付けるもの・ひも付けないもの).""" + + linked: list[PlannedImage] + unlinked: list[PlannedUnlinked] # --------------------------------------------------------------------------- @@ -201,32 +258,47 @@ return images, problems -def apply_holds( - source_name: str, images: Sequence[SourceImage], holds: Holds -) -> tuple[list[SourceImage], int]: - """保留リストの番号を除く. +def check_review_numbers(source_name: str, images: Sequence[SourceImage], reviews: Reviews) -> None: + """要確認リストの番号がすべて取り込み元にあることを確かめる. Args: source_name: 取り込み元の名前(メッセージ用). images: 取り込み元の画像. - holds: 保留の番号. - - Returns: - 保留を除いた画像と,保留にした数. + reviews: 要確認の番号. Raises: - ValueError: 保留リストにあるのに取り込み元に無い番号がある(リストの誤り). + ValueError: 要確認リストにあるのに取り込み元に無い番号がある(リストの誤り). """ numbers = {image.number for image in images} - missing = sorted(holds.numbers - numbers) + missing = sorted(reviews.numbers - numbers) if missing: - # 保留リストの番号はリポジトリにある値なので,リストの誤りの特定に表示する + # 要確認リストの番号はリポジトリにある値なので,リストの誤りの特定に表示する raise ValueError( - f"保留リスト {holds.filename} の番号のうち,取り込み元 '{source_name}' に無いものが " - f"{len(missing)} 件ある: {missing}" + f"要確認リスト {reviews.filename} の番号のうち," + f"取り込み元 '{source_name}' に無いものが {len(missing)} 件ある: {missing}" ) - targets = [image for image in images if image.number not in holds.numbers] - return targets, len(images) - len(targets) + + +def review_value(entries: Sequence[ReviewEntry]) -> Review: + """要確認リストの行から,要確認の印の値を作る. + + 同じ中身の画像が複数の番号にある場合は,番号の最も小さい行の理由・確かめ方・日付を使い, + `numbers` にすべての番号を入れる(1 つのファイルに印を 1 つだけ付けるため). + + Args: + entries: 要確認リストの行(1 つ以上). + + Returns: + 要確認の印の値. + """ + ordered = sorted(entries, key=lambda entry: entry.number) + first = ordered[0] + return Review( + reason=first.reason, + resolve=first.resolve, + since=first.since.isoformat(), + numbers=[entry.number for entry in ordered], + ) def read_patient_map_rows(path: Path) -> list[tuple[object, ...]]: @@ -361,7 +433,7 @@ Args: source_name: 取り込み元の名前(メッセージ用). - images: 登録する画像(保留を除いたもの). + images: 撮影にひも付けて登録する画像(撮影にひも付けないものを除いたもの). patient_map: 番号 → 患者 ID の一覧(`parse_patient_map` の `keys`). show_source_names: エラーメッセージに番号を出すか. @@ -400,58 +472,162 @@ return result -def plan_images( - source_name: str, - images: Sequence[SourceImage], - patients: dict[int, str], - *, - show_source_names: bool = False, -) -> list[PlannedImage]: - """登録する画像の SHA-256 を計算し,同じ中身が複数の番号にないかを確かめる. - - 同じ中身は目録では 1 つのファイルになり,2 つの撮影に属することができないため停止する - (SPEC_02「assets」). +def hash_images(images: Sequence[SourceImage]) -> list[HashedImage]: + """取り込み元の画像の SHA-256 を計算する(番号の順を保つ). Args: - source_name: 取り込み元の名前(メッセージ用). - images: 登録する画像(保留を除いたもの). - patients: 番号 → 患者 ID. - show_source_names: エラーメッセージに番号を出すか. + images: 取り込み元の画像. Returns: - 番号順の登録の対象. + SHA-256・拡張子を付けた画像. Raises: - ValueError: 同じ中身の画像が複数の番号にある,または拡張子が不正. + ValueError: 拡張子が不正. """ - planned = [ - PlannedImage( + return [ + HashedImage( image=image, sha256=store.sha256_file(image.path), ext=store.normalize_ext(image.path.suffix), - patient_key=patients[image.number], ) for image in images ] - by_sha256: dict[str, list[int]] = defaultdict(list) - for item in planned: - by_sha256[item.sha256].append(item.image.number) - groups = {sha256: numbers for sha256, numbers in by_sha256.items() if len(numbers) > 1} - if groups: - shown = ", ".join(sorted(groups)[:3]) - numbers = sorted(n for group in groups.values() for n in group) + + +def _describe_groups(groups: dict[str, list[HashedImage]]) -> str: + # 組の数と SHA-256 の先頭 3 つ(1 行に収めるため.SHA-256 は既定で出してよい値) + shown = ", ".join(sorted(groups)[:3]) + return f"({len(groups)} 組.SHA-256 {shown}{' …' if len(groups) > 3 else ''})" + + +def _groups_hint(groups: dict[str, list[HashedImage]], show: bool) -> str: + numbers = sorted(item.image.number for items in groups.values() for item in items) + return _hint(numbers, show) + + +def split_shared_contents( + source_name: str, + hashed: Sequence[HashedImage], + reviews: Reviews, + *, + show_source_names: bool = False, +) -> tuple[list[HashedImage], list[PlannedUnlinked]]: + """同じ中身が複数の番号にある画像を,撮影にひも付けないファイルとして分ける. + + 同じ中身は目録では 1 つのファイルになり,2 つの撮影に属することができない(SPEC_02「assets」). + その番号がすべて要確認リストに載っていれば,どの患者の写真かを決められないものとして + 撮影にひも付けずに登録する.1 つでも要確認リストに無い番号があれば停止する. + + Args: + source_name: 取り込み元の名前(メッセージ用). + hashed: SHA-256 を計算した取り込み元の画像(番号の順). + reviews: 要確認の番号. + show_source_names: エラーメッセージに番号を出すか. + + Returns: + 撮影にひも付けて登録する画像(番号の順)と,撮影にひも付けないファイル(番号の順). + + Raises: + ValueError: 同じ中身の画像が,要確認リストに無い番号を含む複数の番号にある, + または同じ中身の画像のラベル(フォルダ)が番号によって違う. + """ + by_sha256: dict[str, list[HashedImage]] = defaultdict(list) + for item in hashed: + by_sha256[item.sha256].append(item) + groups = {sha256: items for sha256, items in by_sha256.items() if len(items) > 1} + + not_reviewed = { + sha256: items + for sha256, items in groups.items() + if any(item.image.number not in reviews.numbers for item in items) + } + if not_reviewed: raise ValueError( - f"取り込み元 '{source_name}': 同じ中身の画像が複数の番号にある({len(groups)} 組." - f"SHA-256 {shown}{' …' if len(groups) > 3 else ''})." - "保留リストに載せるか取り込み元を直す" - f"{_hint(numbers, show_source_names)}" + f"取り込み元 '{source_name}': 同じ中身の画像が複数の番号にある" + f"{_describe_groups(not_reviewed)}." + "同じ中身の番号をすべて要確認リストに載せるか,取り込み元を直す" + f"{_groups_hint(not_reviewed, show_source_names)}" ) - return planned + + conflicting = { + sha256: items + for sha256, items in groups.items() + if len({item.image.value for item in items}) > 1 + } + if conflicting: + # 1 つのファイルに付ける舌色の所見を,どちらの番号のラベルにするかを黙って決めない + raise ValueError( + f"取り込み元 '{source_name}': 同じ中身の画像のラベルが番号によって違う" + f"{_describe_groups(conflicting)}." + "取り込み元を確かめる" + f"{_groups_hint(conflicting, show_source_names)}" + ) + + unlinked = [ + PlannedUnlinked( + images=tuple(item.image for item in items), + sha256=sha256, + ext=items[0].ext, + value=items[0].image.value, + review=review_value([reviews.entries[item.image.number] for item in items]), + ) + for sha256, items in groups.items() + ] + unlinked.sort(key=lambda group: group.first.number) + linked = [item for item in hashed if item.sha256 not in groups] + return linked, unlinked + + +def plan_images( + linked: Sequence[HashedImage], patients: dict[int, str], reviews: Reviews +) -> list[PlannedImage]: + """撮影にひも付けて登録する画像に,患者 ID と要確認の印を付ける. + + Args: + linked: 撮影にひも付けて登録する画像(`split_shared_contents` の結果). + patients: 番号 → 患者 ID(`resolve_patients` の結果). + reviews: 要確認の番号. + + Returns: + 番号順の登録の対象. + """ + return [ + PlannedImage( + image=item.image, + sha256=item.sha256, + ext=item.ext, + patient_key=patients[item.image.number], + review=( + review_value([reviews.entries[item.image.number]]) + if item.image.number in reviews.numbers + else None + ), + ) + for item in linked + ] # --------------------------------------------------------------------------- # 目録への登録 # --------------------------------------------------------------------------- +def _common_mismatches( + context: ImportContext, existing: records.AssetRecord, ext: str, source_name: str +) -> list[str]: + mismatches: list[str] = [] + if existing.kind != ASSET_KIND: + mismatches.append("ファイルの種類") + if existing.ext != ext: + mismatches.append("拡張子") + if existing.source != context.source_name or existing.source_name != source_name: + mismatches.append("取り込み元での名前") + return mismatches + + +def _is_only_value(values_json: Sequence[str], value: TongueColor) -> bool: + # 登録済みの舌色の所見がちょうど 1 つで,今回の値と同じか(JSON の文字列の書式の差は無視する) + return [json.loads(v) for v in values_json] == [value.model_dump(mode="json")] + + def _registered_mismatches( conn: sqlite3.Connection, context: ImportContext, @@ -459,13 +635,7 @@ existing: records.AssetRecord, ) -> list[str]: # 同じ中身が登録済みなら飛ばしてよいのは,登録済みの内容が今回の内容と同じときだけ - mismatches: list[str] = [] - if existing.kind != ASSET_KIND: - mismatches.append("ファイルの種類") - if existing.ext != item.ext: - mismatches.append("拡張子") - if existing.source != context.source_name or existing.source_name != item.image.source_name: - mismatches.append("取り込み元での名前") + mismatches = _common_mismatches(context, existing, item.ext, item.image.source_name) capture = records.get_capture(conn, existing.capture_id) if existing.capture_id else None if capture is None: mismatches.append("撮影") @@ -477,52 +647,151 @@ values = records.capture_annotation_values( conn, capture.capture_id, ANNOTATION_KIND, ANNOTATION_SOURCE ) - if [json.loads(value) for value in values] != [item.image.value.model_dump(mode="json")]: + if not _is_only_value(values, item.image.value): mismatches.append("所見の値") return mismatches -def _check_against_catalog( - conn: sqlite3.Connection, context: ImportContext, planned: Sequence[PlannedImage] -) -> tuple[list[PlannedImage], int]: - """登録済みのものを除き,新しく登録するものを返す(食い違いがあれば停止).""" - new: list[PlannedImage] = [] - skipped = 0 - for item in planned: - existing = records.get_asset(conn, item.sha256) - if existing is not None: - mismatches = _registered_mismatches(conn, context, item, existing) - if mismatches: - raise ValueError( - f"取り込み元 '{context.source_name}': " - f"登録済みのファイル(SHA-256 {item.sha256})と" - f"{'・'.join(mismatches)}が食い違う.登録済みの内容は書き換えないので," - "取り込み元・対応表・保留リストを確かめる" - f"{_hint([item.image.number], context.show_source_names)}" - ) - skipped += 1 - continue - replaced = records.find_asset_sha256_by_source_name( - conn, context.source_name, item.image.source_name +def _unlinked_mismatches( + conn: sqlite3.Connection, + context: ImportContext, + item: PlannedUnlinked, + existing: records.AssetRecord, +) -> list[str]: + # 撮影にひも付けないファイルは,撮影が無いこと・ファイルに付いた舌色の所見を照合する + mismatches = _common_mismatches(context, existing, item.ext, item.first.source_name) + if existing.capture_id is not None: + mismatches.append("撮影(登録済みのファイルは撮影にひも付いている)") + values = records.file_annotation_values(conn, item.sha256, ANNOTATION_KIND, ANNOTATION_SOURCE) + if not _is_only_value(values, item.value): + mismatches.append("所見の値") + return mismatches + + +def _raise_mismatch( + context: ImportContext, sha256: str, mismatches: Sequence[str], numbers: Sequence[int] +) -> None: + raise ValueError( + f"取り込み元 '{context.source_name}': " + f"登録済みのファイル(SHA-256 {sha256})と" + f"{'・'.join(mismatches)}が食い違う.登録済みの内容は書き換えないので," + "取り込み元・対応表・要確認リストを確かめる" + f"{_hint(list(numbers), context.show_source_names)}" + ) + + +def _check_source_name_unchanged( + conn: sqlite3.Connection, context: ImportContext, image: SourceImage +) -> None: + replaced = records.find_asset_sha256_by_source_name( + conn, context.source_name, image.source_name + ) + if replaced: + raise ValueError( + f"取り込み元 '{context.source_name}': 登録済みのファイル(SHA-256 {replaced[0]})と" + "同じ名前のファイルの中身が変わっている.登録済みの内容は書き換えないので," + "取り込み元を確かめる" + f"{_hint([image.number], context.show_source_names)}" ) - if replaced: - raise ValueError( - f"取り込み元 '{context.source_name}': 登録済みのファイル(SHA-256 {replaced[0]})と" - "同じ名前のファイルの中身が変わっている.登録済みの内容は書き換えないので," - "取り込み元を確かめる" - f"{_hint([item.image.number], context.show_source_names)}" + + +class _ReviewMark(NamedTuple): + """登録済みの撮影・ファイルに足す要確認の印.""" + + capture_id: str | None + sha256: str | None + review: Review + + +class _CatalogCheck(NamedTuple): + """目録と照合した結果. + + Attributes: + linked: 新しく撮影にひも付けて登録する画像. + unlinked: 新しく撮影にひも付けずに登録するファイル. + skipped: 登録済みで飛ばした数(撮影にひも付けないファイルは 1 つで 1 件). + marks: 登録済みの撮影・ファイルに足す要確認の印. + stale_marks: 要確認リストから外れたのに有効な印が残っている登録済みの撮影の数. + """ + + linked: list[PlannedImage] + unlinked: list[PlannedUnlinked] + skipped: int + marks: list[_ReviewMark] + stale_marks: int + + +def _check_against_catalog( + conn: sqlite3.Connection, context: ImportContext, plan: ImportPlan +) -> _CatalogCheck: + """登録済みのものを除き,新しく登録するものと足す印を返す(食い違いがあれば停止). + + 要確認リストに載っている登録済みの撮影・ファイルに有効な印が無ければ,印を足す + (書き足すだけで既存の行は書き換えない).要確認リストから外れたのに有効な印が残っている + 撮影は数えるだけにする(印を外すのは編集のコマンド.PLAN_02「編集のコマンド」). + """ + linked: list[PlannedImage] = [] + unlinked: list[PlannedUnlinked] = [] + marks: list[_ReviewMark] = [] + skipped = 0 + stale = 0 + for item in plan.linked: + existing = records.get_asset(conn, item.sha256) + if existing is None: + _check_source_name_unchanged(conn, context, item.image) + linked.append(item) + continue + mismatches = _registered_mismatches(conn, context, item, existing) + if mismatches: + _raise_mismatch(context, item.sha256, mismatches, [item.image.number]) + skipped += 1 + # 照合を通ったので撮影がある + capture_id = existing.capture_id + marked = records.has_valid_annotation(conn, REVIEW_KIND, capture_id=capture_id) + if item.review is not None and not marked: + marks.append(_ReviewMark(capture_id=capture_id, sha256=None, review=item.review)) + elif item.review is None and marked: + stale += 1 + + for group in plan.unlinked: + existing = records.get_asset(conn, group.sha256) + if existing is None: + for image in group.images: + _check_source_name_unchanged(conn, context, image) + unlinked.append(group) + continue + mismatches = _unlinked_mismatches(conn, context, group, existing) + if mismatches: + _raise_mismatch( + context, group.sha256, mismatches, [image.number for image in group.images] ) - new.append(item) - return new, skipped + skipped += 1 + if not records.has_valid_annotation(conn, REVIEW_KIND, sha256=group.sha256): + marks.append(_ReviewMark(capture_id=None, sha256=group.sha256, review=group.review)) + return _CatalogCheck( + linked=linked, unlinked=unlinked, skipped=skipped, marks=marks, stale_marks=stale + ) class _RegisterCounts(NamedTuple): subjects: int copied: int + annotations: int + + +def _copy_to_store(context: ImportContext, image: SourceImage, sha256: str) -> store.StoredFile: + # ストアへのコピーはトランザクションの外の操作.登録が失敗しても残るが, + # 目録に無いストアのファイルとして tiasdata check で見え,次の取り込みで再利用される + stored = store.add_file(context.store_dir, image.path) + if stored.sha256 != sha256: + raise ValueError( + f"取り込みの途中で取り込み元のファイルの中身が変わった(SHA-256 {sha256})" + ) + return stored def _register( - conn: sqlite3.Connection, context: ImportContext, new: Sequence[PlannedImage] + conn: sqlite3.Connection, context: ImportContext, checked: _CatalogCheck ) -> _RegisterCounts: now = records.utc_timestamp() # 研究用 ID は目録と対応表の両方の最大の次から振る(目録を作り直した場合等にも重ならないように) @@ -532,7 +801,8 @@ next_capture = records.max_capture_number(conn) + 1 subjects_added = 0 copied = 0 - for item in new: + annotations = 0 + for item in checked.linked: subject_id = identity.find_subject_id(conn, item.patient_key) if subject_id is None: subject_id = records.format_id(records.SUBJECT_PREFIX, next_subject) @@ -542,32 +812,80 @@ records.insert_subject(conn, subject_id, now) subjects_added += 1 - # ストアへのコピーはトランザクションの外の操作.登録が失敗しても残るが, - # 目録に無いストアのファイルとして tiasdata check で見え,次の取り込みで再利用される - stored = store.add_file(context.store_dir, item.image.path) - if stored.sha256 != item.sha256: - raise ValueError( - f"取り込みの途中で取り込み元のファイルの中身が変わった(SHA-256 {item.sha256})" - ) + stored = _copy_to_store(context, item.image, item.sha256) copied += int(stored.copied) capture_id = records.format_id(records.CAPTURE_PREFIX, next_capture) next_capture += 1 - _insert_capture(conn, context, item, stored, subject_id, capture_id, now) - return _RegisterCounts(subjects=subjects_added, copied=copied) + annotations += _insert_capture(conn, context, item, stored, subject_id, capture_id, now) + + for group in checked.unlinked: + stored = _copy_to_store(context, group.first, group.sha256) + copied += int(stored.copied) + annotations += _insert_unlinked(conn, context, group, stored, now) + + for mark in checked.marks: + _insert_review(conn, mark.review, now, capture_id=mark.capture_id, sha256=mark.sha256) + annotations += 1 + return _RegisterCounts(subjects=subjects_added, copied=copied, annotations=annotations) -def _insert_capture( +def _insert_annotation( + conn: sqlite3.Connection, + kind: str, + value: BaseModel, + source: str, + now: str, + *, + capture_id: str | None = None, + sha256: str | None = None, +) -> None: + # 撮影にひも付くものは撮影に,ひも付かないファイルはファイルに所見を付ける(SPEC_02) + value_json = dump_annotation_value(value) + if capture_id is not None: + records.insert_capture_annotation( + conn, + kind=kind, + capture_id=capture_id, + value_json=value_json, + source=source, + created_at=now, + ) + elif sha256 is not None: + records.insert_file_annotation( + conn, + kind=kind, + sha256=sha256, + value_json=value_json, + source=source, + created_at=now, + ) + else: + raise RuntimeError(f"所見 {kind} を付ける撮影・ファイルが無い") + + +def _insert_review( + conn: sqlite3.Connection, + review: Review, + now: str, + *, + capture_id: str | None = None, + sha256: str | None = None, +) -> None: + _insert_annotation( + conn, REVIEW_KIND, review, REVIEW_SOURCE, now, capture_id=capture_id, sha256=sha256 + ) + + +def _insert_asset_with_derivation( conn: sqlite3.Connection, context: ImportContext, - item: PlannedImage, stored: store.StoredFile, - subject_id: str, - capture_id: str, + *, + capture_id: str | None, + source_name: str, now: str, ) -> None: - # 画像 1 枚 = 撮影 1 つ・ファイル 1 つ・所見 1 つ・由来 1 つ(SPEC_02) - records.insert_capture(conn, capture_id, subject_id, captured_on=None, device=DEVICE) records.insert_asset( conn, records.AssetRecord( @@ -577,18 +895,10 @@ ext=stored.ext, size_bytes=stored.size_bytes, source=context.source_name, - source_name=item.image.source_name, + source_name=source_name, ), now, ) - records.insert_capture_annotation( - conn, - kind=ANNOTATION_KIND, - capture_id=capture_id, - value_json=dump_annotation_value(item.image.value), - source=ANNOTATION_SOURCE, - created_at=now, - ) records.insert_derivation( conn, tool=DERIVATION_TOOL, @@ -599,12 +909,56 @@ ) +def _insert_capture( + conn: sqlite3.Connection, + context: ImportContext, + item: PlannedImage, + stored: store.StoredFile, + subject_id: str, + capture_id: str, + now: str, +) -> int: + # 画像 1 枚 = 撮影 1 つ・ファイル 1 つ・由来 1 つ・所見 1 つ(要確認なら 2 つ)(SPEC_02). + # 追加した所見の数を返す + records.insert_capture(conn, capture_id, subject_id, captured_on=None, device=DEVICE) + _insert_asset_with_derivation( + conn, context, stored, capture_id=capture_id, source_name=item.image.source_name, now=now + ) + _insert_annotation( + conn, ANNOTATION_KIND, item.image.value, ANNOTATION_SOURCE, now, capture_id=capture_id + ) + if item.review is None: + return 1 + _insert_review(conn, item.review, now, capture_id=capture_id) + return 2 + + +def _insert_unlinked( + conn: sqlite3.Connection, + context: ImportContext, + group: PlannedUnlinked, + stored: store.StoredFile, + now: str, +) -> int: + # 撮影にひも付けないファイル: ファイル 1 つ・由来 1 つ・ファイルに付く所見 2 つ(舌色と + # 要確認).症例・撮影・対応表の行は作らない.追加した所見の数を返す + _insert_asset_with_derivation( + conn, context, stored, capture_id=None, source_name=group.first.source_name, now=now + ) + _insert_annotation( + conn, ANNOTATION_KIND, group.value, ANNOTATION_SOURCE, now, sha256=stored.sha256 + ) + _insert_review(conn, group.review, now, sha256=stored.sha256) + return 2 + + def run(conn: sqlite3.Connection, context: ImportContext) -> ImportResult: """舌色データセットを取り込む. - 取り込み元・対応表・保留リストをすべて検証してから,1 つのトランザクションで目録と + 取り込み元・対応表・要確認リストをすべて検証してから,1 つのトランザクションで目録と 対応表(ATTACH 済み)に登録する.途中で失敗したら目録・対応表には何も残らない. - 既に登録済みのファイル(同じ SHA-256)は,登録済みの内容と一致すれば飛ばす. + 既に登録済みのファイル(同じ SHA-256)は,登録済みの内容と一致すれば飛ばす + (要確認リストに載っているのに有効な印が無ければ,印だけを足す). Args: conn: 目録の接続(対応表を `identity` として ATTACH 済み.トランザクションの外). @@ -614,7 +968,7 @@ 取り込みの結果(件数). Raises: - ValueError: 設定・取り込み元の構成・対応表・保留リストの不備,または登録済みの内容との + ValueError: 設定・取り込み元の構成・対応表・要確認リストの不備,または登録済みの内容との 食い違い. """ source = context.source @@ -624,30 +978,45 @@ ) show = context.show_source_names images = scan_source(context.source_name, source.path, show_source_names=show) - targets, held = apply_holds(context.source_name, images, context.holds) + check_review_numbers(context.source_name, images, context.reviews) patient_map = parse_patient_map(read_patient_map_rows(source.patient_map)) - patients = resolve_patients( - context.source_name, targets, patient_map.keys, show_source_names=show + hashed = hash_images(images) + linked, unlinked = split_shared_contents( + context.source_name, hashed, context.reviews, show_source_names=show ) - planned = plan_images(context.source_name, targets, patients, show_source_names=show) + # 撮影にひも付けない番号は患者を決めないので,対応表の確認の対象にしない + patients = resolve_patients( + context.source_name, + [item.image for item in linked], + patient_map.keys, + show_source_names=show, + ) + plan = ImportPlan(linked=plan_images(linked, patients, context.reviews), unlinked=unlinked) with write_transaction(conn): - new, skipped = _check_against_catalog(conn, context, planned) - counts = _register(conn, context, new) - logger.info("取り込み元 '%s': %d 件を登録した", context.source_name, len(new)) + checked = _check_against_catalog(conn, context, plan) + counts = _register(conn, context, checked) + added = len(checked.linked) + len(checked.unlinked) + logger.info("取り込み元 '%s': %d 件を登録した", context.source_name, added) + reviewed_linked = sum(1 for item in checked.linked if item.review is not None) return ImportResult( source_name=context.source_name, importer=NAME, found=len(images), - held=held, - hold_filename=context.holds.filename, + review_numbers=len(context.reviews.numbers), + review_filename=context.reviews.filename, unused_map_rows=patient_map.unused_rows, - skipped_existing=skipped, + skipped_existing=checked.skipped, copied_to_store=counts.copied, subjects_added=counts.subjects, - captures_added=len(new), - assets_added=len(new), - annotations_added=len(new), - derivations_added=len(new), + captures_added=len(checked.linked), + assets_added=added, + annotations_added=counts.annotations, + derivations_added=added, + reviewed_added=reviewed_linked + len(checked.unlinked), + unlinked_files_added=len(checked.unlinked), + unlinked_numbers_added=sum(len(group.images) for group in checked.unlinked), + review_marks_added=len(checked.marks), + review_marks_stale=checked.stale_marks, ) diff --git a/src/tiasdata/kinds/__init__.py b/src/tiasdata/kinds/__init__.py index 2f075b9..99f473e 100644 --- a/src/tiasdata/kinds/__init__.py +++ b/src/tiasdata/kinds/__init__.py @@ -9,10 +9,12 @@ from pydantic import BaseModel +from tiasdata.kinds.review import REVIEW_KIND, Review from tiasdata.kinds.tongue_color import TongueColor # 所見の種類名 → 値の型 ANNOTATION_VALUE_TYPES: dict[str, type[BaseModel]] = { + REVIEW_KIND: Review, "tongue_color": TongueColor, } @@ -68,6 +70,8 @@ __all__ = [ "ANNOTATION_VALUE_TYPES", + "REVIEW_KIND", + "Review", "TongueColor", "annotation_value_type", "dump_annotation_value", diff --git a/src/tiasdata/kinds/review.py b/src/tiasdata/kinds/review.py new file mode 100644 index 0000000..49b2c8d --- /dev/null +++ b/src/tiasdata/kinds/review.py @@ -0,0 +1,52 @@ +"""所見の種類 `review`(要確認の印)の値の型(SPEC_02「kinds」). + +取り込み元のデータに,基盤では正解を決められない問題(同じ写真の重複・患者 ID やラベルの +食い違い等)があるものに付ける.取り込みでは要確認リスト(`reviews/<取り込み元の名前>.toml`) +から付ける(SPEC_03「要確認リスト」). +""" + +from datetime import date + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +# 所見の種類名(kinds 表の行は catalog/0002_review_kind.sql) +REVIEW_KIND = "review" + +_DATE_PATTERN = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + + +class Review(BaseModel): + """要確認の印の値(`{"reason": ..., "resolve": ..., "since": ..., "numbers": [...]}`). + + Attributes: + reason: 要確認にした理由. + resolve: 確かめる方法. + since: 要確認にした日(`YYYY-MM-DD`). + numbers: 取り込み元での番号(昇順・重複なし・1 以上).同じ中身のファイルが複数の番号に + あったときは,そのすべての番号. + """ + + # 未知のキーは拒否する(綴りの誤りで値が黙って欠けるのを防ぐ) + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + reason: str = Field(min_length=1) + resolve: str = Field(min_length=1) + # JSON から読んだ値と同じ形で比べられるように,日付は文字列のまま持つ + since: str = Field(pattern=_DATE_PATTERN) + numbers: list[int] = Field(min_length=1) + + @field_validator("since") + @classmethod + def _valid_date(cls, value: str) -> str: + date.fromisoformat(value) + return value + + @field_validator("numbers") + @classmethod + def _sorted_unique_positive(cls, value: list[int]) -> list[int]: + # 同じ印が常に同じ JSON になるように,並びを固定する + if any(number < 1 for number in value): + raise ValueError("numbers は 1 以上") + if value != sorted(set(value)): + raise ValueError("numbers は昇順・重複なしで並べる") + return value diff --git a/src/tiasdata/repo.py b/src/tiasdata/repo.py index 8cc2d2b..449e600 100644 --- a/src/tiasdata/repo.py +++ b/src/tiasdata/repo.py @@ -29,7 +29,7 @@ """本リポジトリのルートを返す. このパッケージの位置から親のフォルダを辿り,`[project] name = "tiasdata"` の - `pyproject.toml` があるフォルダをルートとみなす(`importers.holds`・`datasets` が使う). + `pyproject.toml` があるフォルダをルートとみなす(`importers.reviews`・`datasets` が使う). Returns: リポジトリのルート. diff --git a/src/tiasdata/stats.py b/src/tiasdata/stats.py index c9974bc..bb80a6c 100644 --- a/src/tiasdata/stats.py +++ b/src/tiasdata/stats.py @@ -8,7 +8,7 @@ from collections import Counter from dataclasses import dataclass -from tiasdata import catalog +from tiasdata import catalog, kinds @dataclass(frozen=True) @@ -35,8 +35,14 @@ captures: 撮影の数. subjects_with_multiple_captures: 撮影を 2 つ以上持つ症例の数. assets_by_kind: ファイルの種類 → ファイル数. - annotations: 所見の種類 → 集計. + annotations: 所見の種類 → 集計(要確認の印 `review` は含めない). superseded_annotations: 訂正された所見の数. + assets_without_capture: 撮影にひも付かないファイルの数. + review_marks_on_captures: 撮影に付いた有効な要確認の印の数. + review_marks_on_files: ファイルに付いた有効な要確認の印の数. + + 要確認の印は件数(`review_marks_on_captures`・`review_marks_on_files`)だけを持つ. + 値は理由(reason)の全文等で,値ごとに数えても内訳にならないため. """ subjects: int @@ -45,6 +51,9 @@ assets_by_kind: dict[str, int] annotations: dict[str, AnnotationStats] superseded_annotations: int + assets_without_capture: int + review_marks_on_captures: int + review_marks_on_files: int def summarize_annotation_values(values: dict[str, int]) -> AnnotationStats: @@ -86,6 +95,7 @@ Returns: 集計. """ + on_captures, on_files = catalog.count_valid_annotations_by_target(conn, kinds.REVIEW_KIND) return CatalogStats( subjects=catalog.count_subjects(conn), captures=catalog.count_captures(conn), @@ -94,6 +104,10 @@ annotations={ kind: summarize_annotation_values(values) for kind, values in catalog.count_annotation_values(conn).items() + if kind != kinds.REVIEW_KIND }, superseded_annotations=catalog.count_superseded_annotations(conn), + assets_without_capture=catalog.count_assets_without_capture(conn), + review_marks_on_captures=on_captures, + review_marks_on_files=on_files, ) diff --git a/tests/catalog/test_migrate.py b/tests/catalog/test_migrate.py index dd95c5b..6977651 100644 --- a/tests/catalog/test_migrate.py +++ b/tests/catalog/test_migrate.py @@ -64,7 +64,7 @@ ) catalog_conn.commit() catalog.apply_migrations(catalog_conn, "catalog") - assert catalog_conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + assert catalog_conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 6 assert catalog_conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 @@ -163,3 +163,84 @@ r"|COMMIT(\s+TRANSACTION)?|END\s+TRANSACTION|ROLLBACK)\s*;" ) assert re.search(pattern, code, re.M) is None, m.name + + +# --------------------------------------------------------------------------- +# catalog/0002: 所見の種類 review(2026-10-08 のユーザー判断) +# --------------------------------------------------------------------------- +def _review_kind(conn: sqlite3.Connection) -> tuple | None: + return conn.execute( + "SELECT kind, category, value_schema_version FROM kinds WHERE kind = 'review'" + ).fetchone() + + +def test_catalog_latest_version_includes_review_kind() -> None: + names = [m.name for m in migrate.load_migrations("catalog")] + assert len(names) >= 2 + assert names[1].startswith("0002_") + + +def test_review_kind_from_empty_db(tmp_path: Path) -> None: + # 0 → 最新: review は annotation・value_schema_version=1 + conn = catalog.connect(tmp_path / "db.sqlite", create=True) + try: + result = catalog.apply_migrations(conn, "catalog") + assert result.before == 0 + assert result.after == catalog.latest_version("catalog") + assert _review_kind(conn) == ("review", "annotation", 1) + finally: + conn.close() + + +def test_review_kind_from_version_1_keeps_existing_rows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # 1 → 最新: 研究室 PC の目録(0001 だけ適用・取り込み済み)に 0002 を足す + path = tmp_path / "db.sqlite" + first_only = migrate.load_migrations("catalog")[:1] + with monkeypatch.context() as m: + m.setattr(migrate, "load_migrations", lambda series: first_only) + conn = catalog.connect(path, create=True) + catalog.apply_migrations(conn, "catalog") + assert catalog.schema_version(conn) == 1 + assert _review_kind(conn) is None + conn.execute( + "INSERT INTO subjects (subject_id, created_at)" + " VALUES ('S000001', '2026-10-06T00:00:00Z')" + ) + conn.execute("INSERT INTO captures (capture_id, subject_id) VALUES ('C000001', 'S000001')") + conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at) VALUES" + " ('tongue_color', 'C000001', '{\"label\":\"red\",\"severity\":\"mild\"}', 'x'," + " '2026-10-06T00:00:00Z')" + ) + conn.commit() + kinds_before = conn.execute("SELECT * FROM kinds ORDER BY kind").fetchall() + conn.close() + + conn = catalog.connect(path) + try: + result = catalog.apply_migrations(conn, "catalog") + latest = catalog.latest_version("catalog") + assert result == MigrationResult(1, latest) + assert catalog.schema_version(conn) == latest + assert _review_kind(conn) == ("review", "annotation", 1) + kinds_after = conn.execute("SELECT * FROM kinds WHERE kind != 'review' ORDER BY kind") + assert kinds_after.fetchall() == kinds_before + assert conn.execute("SELECT COUNT(*) FROM subjects").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM captures").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM annotations").fetchone()[0] == 1 + # review の所見を付けられる(外部キー kinds を満たす) + conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at) VALUES" + " ('review', 'C000001', '{}', 'review_list', '2026-10-08T00:00:00Z')" + ) + conn.commit() + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + + # 再適用しても review の行は 1 つのまま + again = catalog.apply_migrations(conn, "catalog") + assert again.applied == 0 + assert conn.execute("SELECT COUNT(*) FROM kinds WHERE kind = 'review'").fetchone()[0] == 1 + finally: + conn.close() diff --git a/tests/catalog/test_queries.py b/tests/catalog/test_queries.py index 4158592..3df8a96 100644 --- a/tests/catalog/test_queries.py +++ b/tests/catalog/test_queries.py @@ -6,5 +6,5 @@ def test_count_kinds_after_initial_migration(catalog_conn: sqlite3.Connection) -> None: - # SPEC_02「kinds」: 最初に用意する種類は asset 4 件・annotation 1 件 - assert catalog.count_kinds(catalog_conn) == {"annotation": 1, "asset": 4} + # SPEC_02「kinds」: 最初に用意する種類は asset 4 件・annotation 1 件.0002 で review を足した + assert catalog.count_kinds(catalog_conn) == {"annotation": 2, "asset": 4} diff --git a/tests/catalog/test_schema.py b/tests/catalog/test_schema.py index 2b5daf0..7cdf235 100644 --- a/tests/catalog/test_schema.py +++ b/tests/catalog/test_schema.py @@ -198,6 +198,7 @@ ("tongue_crop_masked", "asset"), ("tongue_mask", "asset"), ("tongue_color", "annotation"), + ("review", "annotation"), } def test_only_annotation_kinds_have_value_schema_version( diff --git a/tests/conftest.py b/tests/conftest.py index bcb816d..c1c857f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,33 +153,33 @@ return DummyPatientMap(path=path, patient_keys=patient_keys) -def write_holds(holds_dir: Path, source_name: str, numbers: list[int]) -> Path: - """保留リスト `/.toml` を書く.""" - holds_dir.mkdir(parents=True, exist_ok=True) +def write_reviews(reviews_dir: Path, source_name: str, numbers: list[int]) -> Path: + """要確認リスト `/.toml` を書く.""" + reviews_dir.mkdir(parents=True, exist_ok=True) entries = "".join( - f'[[hold]]\nnumber = {number}\nreason = "テスト用の保留"\n' + f'[[review]]\nnumber = {number}\nreason = "テスト用の要確認"\n' f'resolve = "テスト用"\nsince = "2026-10-06"\n\n' for number in numbers ) - path = holds_dir / f"{source_name}.toml" + path = reviews_dir / f"{source_name}.toml" path.write_text(entries, encoding="utf-8") return path @pytest.fixture -def holds_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """一時フォルダの保留リストのフォルダ(リポジトリの `holds/` の代わりに使わせる). +def reviews_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """一時フォルダの要確認リストのフォルダ(リポジトリの `reviews/` の代わりに使わせる). - `tiasdata.importers.holds.default_holds_dir` を差し替えるので,同じプロセスで呼ぶ + `tiasdata.importers.reviews.default_reviews_dir` を差し替えるので,同じプロセスで呼ぶ `run_import`・`cli.main` はこのフォルダを読む(subprocess で起動した CLI には効かない). - 保留リストは `write_holds(holds_dir, <取り込み元の名前>, [番号, ...])` で書く - (保留リストのファイルが無い取り込み元の取り込みは停止する.保留が無いなら `[]` で書く). + 要確認リストは `write_reviews(reviews_dir, <取り込み元の名前>, [番号, ...])` で書く + (要確認リストのファイルが無い取り込み元の取り込みは停止する.要確認が無いなら `[]` で書く). """ - from tiasdata.importers import holds + from tiasdata.importers import reviews - directory = tmp_path / "holds" + directory = tmp_path / "reviews" directory.mkdir() - monkeypatch.setattr(holds, "default_holds_dir", lambda: directory) + monkeypatch.setattr(reviews, "default_reviews_dir", lambda: directory) return directory @@ -191,7 +191,7 @@ source_name: str source: DummyTongueColorSource patient_map: DummyPatientMap - holds_dir: Path + reviews_dir: Path catalog_root: Path identity_path: Path @@ -202,15 +202,15 @@ monkeypatch: pytest.MonkeyPatch, tongue_color_source: DummyTongueColorSource, tongue_color_patient_map: DummyPatientMap, - holds_dir: Path, + reviews_dir: Path, ) -> ImportEnv: """ダミーの取り込み元 `dummy_tongue` を設定した設定ファイル(`TIASDATA_CONFIG` で指す). - `tiasdata init` はまだ実行していない.保留リストは空(`[[hold]]` が 0 件のファイル. - 保留リストのファイルが無いと取り込みは停止する). + `tiasdata init` はまだ実行していない.要確認リストは空(`[[review]]` が 0 件のファイル. + 要確認リストのファイルが無いと取り込みは停止する). """ source_name = "dummy_tongue" - write_holds(holds_dir, source_name, []) + write_reviews(reviews_dir, source_name, []) catalog_root = tmp_path / "data" / "tiasdata" identity_path = tmp_path / "data" / "identity" / "identity.sqlite" config_path = tmp_path / "conf" / "config.toml" @@ -231,7 +231,7 @@ source_name=source_name, source=tongue_color_source, patient_map=tongue_color_patient_map, - holds_dir=holds_dir, + reviews_dir=reviews_dir, catalog_root=catalog_root, identity_path=identity_path, ) diff --git a/tests/importers/test_holds.py b/tests/importers/test_holds.py deleted file mode 100644 index 0b22edd..0000000 --- a/tests/importers/test_holds.py +++ /dev/null @@ -1,92 +0,0 @@ -"""保留リスト `holds/<取り込み元の名前>.toml` の読み込み(SPEC_03「保留リスト」・PLAN_02). - -- 1 件ごとに number・reason・resolve・since を書く -- ファイルが無ければ停止する.空のファイル・`[[hold]]` 0 件は保留 0 件(ユーザー承認済み) -- 形の誤り・番号の重複は停止する -- 最初の保留リスト `holds/tiasjudgemodel.toml` は舌色データセットの重複 5 組のうち 9 枚 -""" - -from pathlib import Path - -import pytest -from conftest import write_holds - -from tiasdata.importers.holds import default_holds_dir, load_holds - -ENTRY = '[[hold]]\nnumber = {n}\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' - - -def _write(directory: Path, name: str, text: str) -> Path: - path = directory / f"{name}.toml" - path.write_text(text, encoding="utf-8") - return path - - -class TestLoadHolds: - def test_numbers_are_loaded(self, tmp_path: Path) -> None: - write_holds(tmp_path, "src", [5, 2, 9]) - holds = load_holds(tmp_path, "src") - assert holds.numbers == frozenset({2, 5, 9}) - assert holds.filename.endswith("src.toml") - - @pytest.mark.parametrize("text", ["", "# 保留なし\n"]) - def test_empty_file_means_no_holds(self, tmp_path: Path, text: str) -> None: - _write(tmp_path, "src", text) - assert load_holds(tmp_path, "src").numbers == frozenset() - - def test_missing_file_stops(self, tmp_path: Path) -> None: - with pytest.raises(ValueError): - load_holds(tmp_path, "src") - - def test_message_does_not_contain_path(self, tmp_path: Path) -> None: - with pytest.raises(ValueError) as excinfo: - load_holds(tmp_path, "src") - assert str(tmp_path) not in str(excinfo.value) - assert tmp_path.as_posix() not in str(excinfo.value) - - @pytest.mark.parametrize( - "text", - [ - pytest.param("[[hold]]\nnumber = 3\n", id="missing-fields"), - pytest.param(ENTRY.format(n='"3"'), id="number-as-string"), - pytest.param(ENTRY.format(n="3.0"), id="number-as-float"), - pytest.param(ENTRY.format(n="0"), id="number-zero"), - pytest.param(ENTRY.format(n="-1"), id="number-negative"), - pytest.param(ENTRY.format(n="true"), id="number-as-bool"), - pytest.param(ENTRY.format(n="3") + 'extra = "x"\n', id="unknown-key"), - pytest.param( - '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = "いつか"\n', - id="since-not-date", - ), - pytest.param( - '[[hold]]\nnumber = 3\nreason = ""\nresolve = "s"\nsince = 2026-10-06\n', - id="empty-reason", - ), - pytest.param('[[hold]]\nnumber = 3\nreason = "r\n', id="broken-toml"), - pytest.param("[holds]\nnumber = 3\n", id="unknown-table"), - pytest.param(ENTRY.format(n=3) + ENTRY.format(n=3), id="duplicated-number"), - ], - ) - def test_malformed_file_stops(self, tmp_path: Path, text: str) -> None: - _write(tmp_path, "src", text) - with pytest.raises(ValueError): - load_holds(tmp_path, "src") - - @pytest.mark.parametrize("name", ["../src", "a/b", "a\\b", "", "名前"]) - def test_source_name_unusable_as_filename_stops(self, tmp_path: Path, name: str) -> None: - with pytest.raises(ValueError): - load_holds(tmp_path, name) - - -class TestRepositoryHolds: - def test_default_holds_dir_is_repository_holds(self) -> None: - directory = default_holds_dir() - assert directory.name == "holds" - assert (directory.parent / "pyproject.toml").is_file() - - def test_tiasjudgemodel_hold_list_matches_plan(self) -> None: - # PLAN_02「舌色データセットの重複 5 組の扱い」: - # 122/184・548/566・381/382・515/516 の両方と 509(508 は残す) - holds = load_holds(default_holds_dir(), "tiasjudgemodel") - assert holds.numbers == frozenset({122, 184, 548, 566, 381, 382, 515, 516, 509}) - assert 508 not in holds.numbers diff --git a/tests/importers/test_reviews.py b/tests/importers/test_reviews.py new file mode 100644 index 0000000..9556f83 --- /dev/null +++ b/tests/importers/test_reviews.py @@ -0,0 +1,123 @@ +"""要確認リスト `reviews/<取り込み元の名前>.toml` の読み込み(SPEC_03「要確認リスト」・PLAN_02). + +- 1 件ごとに number・reason・resolve・since を書く +- ファイルが無ければ停止する.空のファイル・`[[review]]` 0 件は要確認 0 件(ユーザー承認済み) +- 形の誤り・番号の重複は停止する +- 最初の要確認リスト `reviews/tiasjudgemodel.toml` は舌色データセットの重複 5 組のうち 9 枚 +""" + +from pathlib import Path + +import pytest +from conftest import write_reviews + +from tiasdata.importers.reviews import default_reviews_dir, load_reviews + +ENTRY = '[[review]]\nnumber = {n}\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' + + +def _write(directory: Path, name: str, text: str) -> Path: + path = directory / f"{name}.toml" + path.write_text(text, encoding="utf-8") + return path + + +class TestLoadReviews: + def test_numbers_are_loaded(self, tmp_path: Path) -> None: + write_reviews(tmp_path, "src", [5, 2, 9]) + reviews = load_reviews(tmp_path, "src") + assert reviews.numbers == frozenset({2, 5, 9}) + assert reviews.filename.endswith("src.toml") + + @pytest.mark.parametrize("text", ["", "# 要確認なし\n"]) + def test_empty_file_means_no_reviews(self, tmp_path: Path, text: str) -> None: + _write(tmp_path, "src", text) + assert load_reviews(tmp_path, "src").numbers == frozenset() + + def test_missing_file_stops(self, tmp_path: Path) -> None: + with pytest.raises(ValueError): + load_reviews(tmp_path, "src") + + def test_message_does_not_contain_path(self, tmp_path: Path) -> None: + with pytest.raises(ValueError) as excinfo: + load_reviews(tmp_path, "src") + assert str(tmp_path) not in str(excinfo.value) + assert tmp_path.as_posix() not in str(excinfo.value) + + @pytest.mark.parametrize( + "text", + [ + pytest.param("[[review]]\nnumber = 3\n", id="missing-fields"), + pytest.param(ENTRY.format(n='"3"'), id="number-as-string"), + pytest.param(ENTRY.format(n="3.0"), id="number-as-float"), + pytest.param(ENTRY.format(n="0"), id="number-zero"), + pytest.param(ENTRY.format(n="-1"), id="number-negative"), + pytest.param(ENTRY.format(n="true"), id="number-as-bool"), + pytest.param(ENTRY.format(n="3") + 'extra = "x"\n', id="unknown-key"), + pytest.param( + '[[review]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = "いつか"\n', + id="since-not-date", + ), + pytest.param( + '[[review]]\nnumber = 3\nreason = ""\nresolve = "s"\nsince = 2026-10-06\n', + id="empty-reason", + ), + pytest.param('[[review]]\nnumber = 3\nreason = "r\n', id="broken-toml"), + pytest.param("[reviews]\nnumber = 3\n", id="unknown-table"), + pytest.param(ENTRY.format(n=3) + ENTRY.format(n=3), id="duplicated-number"), + ], + ) + def test_malformed_file_stops(self, tmp_path: Path, text: str) -> None: + _write(tmp_path, "src", text) + with pytest.raises(ValueError): + load_reviews(tmp_path, "src") + + @pytest.mark.parametrize("name", ["../src", "a/b", "a\\b", "", "名前"]) + def test_source_name_unusable_as_filename_stops(self, tmp_path: Path, name: str) -> None: + with pytest.raises(ValueError): + load_reviews(tmp_path, name) + + +class TestRepositoryReviews: + def test_default_reviews_dir_is_repository_reviews(self) -> None: + directory = default_reviews_dir() + assert directory.name == "reviews" + assert (directory.parent / "pyproject.toml").is_file() + + def test_tiasjudgemodel_review_list_matches_plan(self) -> None: + # PLAN_02「舌色データセットの重複 5 組の扱い」: + # 122/184・548/566・381/382・515/516 の両方と 509(508 は残す) + reviews = load_reviews(default_reviews_dir(), "tiasjudgemodel") + assert reviews.numbers == frozenset({122, 184, 548, 566, 381, 382, 515, 516, 509}) + assert 508 not in reviews.numbers + + def test_repository_has_no_holds_dir(self) -> None: + # 2026-10-08: 保留リスト(holds/)は要確認リスト(reviews/)に置き換えた + assert not (default_reviews_dir().parent / "holds").exists() + + def test_repository_review_list_uses_lf(self) -> None: + data = (default_reviews_dir() / "tiasjudgemodel.toml").read_bytes() + assert b"\r\n" not in data + + +class TestReviewEntries: + def test_entries_keep_reason_resolve_and_since(self, tmp_path: Path) -> None: + _write( + tmp_path, + "src", + '[[review]]\nnumber = 4\nreason = "理由"\nresolve = "確かめ方"\nsince = 2026-10-06\n', + ) + entry = load_reviews(tmp_path, "src").entries[4] + assert (entry.number, entry.reason, entry.resolve, entry.since.isoformat()) == ( + 4, + "理由", + "確かめ方", + "2026-10-06", + ) + + def test_message_for_duplicated_number_is_one_line(self, tmp_path: Path) -> None: + _write(tmp_path, "src", ENTRY.format(n=3) + ENTRY.format(n=3)) + with pytest.raises(ValueError) as excinfo: + load_reviews(tmp_path, "src") + assert "\n" not in str(excinfo.value) + assert str(tmp_path) not in str(excinfo.value) diff --git a/tests/importers/test_tongue_color_folders.py b/tests/importers/test_tongue_color_folders.py index b087ab5..4c77217 100644 --- a/tests/importers/test_tongue_color_folders.py +++ b/tests/importers/test_tongue_color_folders.py @@ -2,12 +2,12 @@ 仕様: - PLAN_02 Step 3 の完了条件(9 フォルダと対応表を取り込める.同じ患者の複数枚が同じ症例にまとまる. - 2 回実行しても同じ結果.想定外の構成・対応表に無い番号で停止.保留リストの番号を飛ばし件数を報告) + 2 回実行しても同じ結果.想定外の構成・対応表に無い番号で停止.要確認リストの番号に印) - SPEC_02(症例は患者ごと・撮影は画像ごと.ID の書式.所見・由来.患者 ID は対応表にだけ入れる. 同じ中身で別の撮影になるものは停止) -- SPEC_03(import.保留リスト.登録は 1 トランザクション.問題は件数と理由を示して停止) +- SPEC_03(import.要確認リスト.登録は 1 トランザクション.問題は件数と理由を示して停止) - testing.md 必須対象 3(取り込み)・6(出力の規則) -- ユーザー承認済みの追加(対応表の番号が整数でない行は使わず件数を報告.保留リストのファイルが +- ユーザー承認済みの追加(対応表の番号が整数でない行は使わず件数を報告.要確認リストのファイルが 無ければ停止.患者 ID の数字は先頭の 0 をそろえる) """ @@ -26,8 +26,8 @@ TONGUE_COLOR_FOLDERS, ImportEnv, write_dummy_png, - write_holds, write_patient_map, + write_reviews, ) from tiasdata.cli import main @@ -149,7 +149,7 @@ assert result.source_name == "dummy_tongue" assert result.importer == "tongue_color_folders" assert result.found == 18 - assert result.held == 0 + assert result.review_numbers == 0 assert result.skipped_existing == 0 assert result.copied_to_store == 18 # 症例は患者ごと(ダミーは 7 人),撮影・ファイル・所見・由来は画像ごと @@ -408,116 +408,113 @@ # --------------------------------------------------------------------------- -# 保留リスト +# 要確認リスト # --------------------------------------------------------------------------- -class TestHolds: - def test_held_numbers_are_skipped_and_counted(self, import_env: ImportEnv) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) +class TestReviews: + def test_review_numbers_are_registered_with_mark(self, import_env: ImportEnv) -> None: + # 2026-10-08 の仕様変更: 要確認リストの番号も登録し,撮影に要確認の印を付ける + write_reviews(import_env.reviews_dir, "dummy_tongue", [3, 10]) _init() result = _import(import_env) assert result.found == 18 - assert result.held == 2 - assert result.hold_filename.endswith("dummy_tongue.toml") - assert result.captures_added == 16 - assert result.assets_added == 16 - assert set(_captures_by_number(import_env)) == set(range(1, 19)) - {3, 10} - assert _counts(import_env)["captures"] == 16 + assert result.review_numbers == 2 + assert result.review_filename.endswith("dummy_tongue.toml") + assert result.captures_added == 18 + assert result.assets_added == 18 + assert result.reviewed_added == 2 + assert result.annotations_added == 20 + assert set(_captures_by_number(import_env)) == set(range(1, 19)) + assert _counts(import_env)["captures"] == 18 - def test_held_image_is_not_copied_to_store(self, import_env: ImportEnv) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3]) + def test_reviewed_image_is_copied_to_store(self, import_env: ImportEnv) -> None: + write_reviews(import_env.reviews_dir, "dummy_tongue", [3]) _init() _import(import_env) - held = import_env.source.images[2] - assert held.number == 3 - sha256 = hashlib.sha256(held.path.read_bytes()).hexdigest() + reviewed = import_env.source.images[2] + assert reviewed.number == 3 + sha256 = hashlib.sha256(reviewed.path.read_bytes()).hexdigest() store_dir = import_env.catalog_root / "store" - assert not list(store_dir.rglob(f"{sha256}.*")) + assert list(store_dir.rglob(f"{sha256}.*")) - def test_removed_from_list_is_imported_on_rerun(self, import_env: ImportEnv) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) + def test_removed_from_list_keeps_mark_and_is_reported(self, import_env: ImportEnv) -> None: + write_reviews(import_env.reviews_dir, "dummy_tongue", [3, 10]) _init() _import(import_env) before = _captures_by_number(import_env) - write_holds(import_env.holds_dir, "dummy_tongue", []) + write_reviews(import_env.reviews_dir, "dummy_tongue", []) result = _import(import_env) - assert result.held == 0 - assert result.skipped_existing == 16 - assert result.captures_added == 2 - after = _captures_by_number(import_env) - assert set(after) == set(range(1, 19)) - for number, ids in before.items(): - assert after[number] == ids - # 保留を外した画像も患者ごとの症例に入る - keys = import_env.patient_map.patient_keys - identity_map = _identity_map(import_env) - for number in (3, 10): - assert after[number][2] == identity_map[keys[number]] + assert result.review_numbers == 0 + assert result.skipped_existing == 18 + assert result.captures_added == 0 + assert result.review_marks_stale == 2 + assert _captures_by_number(import_env) == before def test_number_in_list_but_not_in_source_stops(self, import_env: ImportEnv) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3, 99]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [3, 99]) _init() with pytest.raises(ValueError) as excinfo: _import(import_env) _assert_nothing_registered(import_env) _assert_no_leak(str(excinfo.value), import_env) - def test_missing_hold_file_stops(self, import_env: ImportEnv) -> None: - (import_env.holds_dir / "dummy_tongue.toml").unlink() + def test_missing_review_file_stops(self, import_env: ImportEnv) -> None: + (import_env.reviews_dir / "dummy_tongue.toml").unlink() _init() with pytest.raises(ValueError): _import(import_env) _assert_nothing_registered(import_env) - def test_hold_file_of_other_source_does_not_count(self, import_env: ImportEnv) -> None: - (import_env.holds_dir / "dummy_tongue.toml").unlink() - write_holds(import_env.holds_dir, "other_source", []) + def test_review_file_of_other_source_does_not_count(self, import_env: ImportEnv) -> None: + (import_env.reviews_dir / "dummy_tongue.toml").unlink() + write_reviews(import_env.reviews_dir, "other_source", []) _init() with pytest.raises(ValueError): _import(import_env) _assert_nothing_registered(import_env) - @pytest.mark.parametrize("content", ["", "# 保留なし\n"]) - def test_empty_hold_file_means_no_holds(self, import_env: ImportEnv, content: str) -> None: - (import_env.holds_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") + @pytest.mark.parametrize("content", ["", "# 要確認なし\n"]) + def test_empty_review_file_means_no_reviews(self, import_env: ImportEnv, content: str) -> None: + (import_env.reviews_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") _init() result = _import(import_env) - assert result.held == 0 + assert result.review_numbers == 0 assert result.captures_added == 18 @pytest.mark.parametrize( "content", [ - pytest.param("[[hold]]\nnumber = 3\n", id="missing-fields"), + pytest.param("[[review]]\nnumber = 3\n", id="missing-fields"), pytest.param( - '[[hold]]\nnumber = "3"\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + '[[review]]\nnumber = "3"\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', id="number-as-string", ), pytest.param( - '[[hold]]\nnumber = 0\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + '[[review]]\nnumber = 0\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', id="number-zero", ), pytest.param( - '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n' + '[[review]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n' 'extra = "x"\n', id="unknown-key", ), - pytest.param('[[hold]]\nnumber = 3\nreason = "r\n', id="broken-toml"), + pytest.param('[[review]]\nnumber = 3\nreason = "r\n', id="broken-toml"), pytest.param( - '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' - '[[hold]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', + '[[review]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n\n' + '[[review]]\nnumber = 3\nreason = "r"\nresolve = "s"\nsince = 2026-10-06\n', id="duplicated-number", ), - pytest.param("[holds]\nnumber = 3\n", id="unknown-table"), + pytest.param("[reviews]\nnumber = 3\n", id="unknown-table"), ], ) - def test_malformed_hold_file_stops(self, import_env: ImportEnv, content: str) -> None: - (import_env.holds_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") + def test_malformed_review_file_stops(self, import_env: ImportEnv, content: str) -> None: + (import_env.reviews_dir / "dummy_tongue.toml").write_text(content, encoding="utf-8") _init() with pytest.raises(ValueError) as excinfo: _import(import_env) _assert_nothing_registered(import_env) - assert "\n" not in str(excinfo.value).strip() or True # CLI で 1 行にする(CLI のテスト) + # CLI が 1 行で出せるように,メッセージに改行を含めない + assert "\n" not in str(excinfo.value) # --------------------------------------------------------------------------- @@ -614,6 +611,14 @@ return [] +def _same_content_two_numbers_same_label(env: ImportEnv) -> list[str]: + # 番号 1 と同じフォルダ(同じラベル)に,同じ中身の 19 を置く + first = env.source.images[0] + (first.path.parent / "19.png").write_bytes(first.path.read_bytes()) + _rewrite_map(env, _default_rows(env) + [(19, 1, "990006")]) + return [] + + def _missing_map_file(env: ImportEnv) -> list[str]: env.patient_map.path.unlink() return [] @@ -671,36 +676,41 @@ _import(import_env) assert _dumps(import_env) == before - def test_held_number_need_not_be_resolvable_in_map(self, import_env: ImportEnv) -> None: - # 患者 ID が数字でない行でも,保留にした番号(使わない番号)なら止まらない - rows = _default_rows(import_env) + def test_unlinked_number_need_not_be_resolvable_in_map(self, import_env: ImportEnv) -> None: + # 撮影にひも付けない番号(同じ中身で,すべて要確認)は患者を決めないので, + # 患者 ID が数字でない行でも止まらない + _same_content_two_numbers_same_label(import_env) + rows = _default_rows(import_env) + [(19, 1, "ABCzz9")] rows[0] = (1, 1, "ABCzz9") _rewrite_map(import_env, rows) - write_holds(import_env.holds_dir, "dummy_tongue", [1]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) _init() result = _import(import_env) - assert result.held == 1 assert result.captures_added == 17 + assert result.unlinked_files_added == 1 - def test_same_content_two_numbers_can_proceed_when_one_is_held( + def test_same_content_two_numbers_stops_when_only_one_is_reviewed( self, import_env: ImportEnv ) -> None: - # 重複を保留リストに載せれば取り込める(PLAN_02「舌色データセットの重複 5 組の扱い」) + # 2026-10-08 の仕様変更: 同じ中身の番号がすべて要確認リストに無ければ停止する _same_content_two_numbers(import_env) - write_holds(import_env.holds_dir, "dummy_tongue", [19]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [19]) _init() - result = _import(import_env) - assert result.found == 19 - assert result.held == 1 - assert result.captures_added == 18 + with pytest.raises(ValueError): + _import(import_env) + _assert_nothing_registered(import_env) - def test_same_content_both_held(self, import_env: ImportEnv) -> None: - _same_content_two_numbers(import_env) - write_holds(import_env.holds_dir, "dummy_tongue", [1, 19]) + def test_same_content_both_reviewed_is_registered_without_capture( + self, import_env: ImportEnv + ) -> None: + _same_content_two_numbers_same_label(import_env) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) _init() result = _import(import_env) - assert result.held == 2 assert result.captures_added == 17 + assert result.assets_added == 18 + assert result.unlinked_files_added == 1 + assert result.unlinked_numbers_added == 2 def test_catalog_not_initialized_stops(self, import_env: ImportEnv) -> None: with pytest.raises(ValueError): @@ -768,7 +778,7 @@ 'importer = "tongue_color_folders"\n' f"patient_map = '{import_env.patient_map.path.as_posix()}'\n" ) - write_holds(import_env.holds_dir, "dummy_copy", []) + write_reviews(import_env.reviews_dir, "dummy_copy", []) with pytest.raises(ValueError): run_import(load_config(import_env.config_path), "dummy_copy") assert _dumps(import_env) == before @@ -810,11 +820,19 @@ def test_failure_on_second_import_keeps_first_import( self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch ) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3, 10, 15]) + # 2026-10-08 の仕様変更で要確認の番号も登録するため,一部の画像を外して取り込んでおく + moved = { + image.path: image.path.read_bytes() + for image in import_env.source.images + if image.number in (3, 10, 15) + } + for path in moved: + path.unlink() _init() _import(import_env) before = _dumps(import_env) - write_holds(import_env.holds_dir, "dummy_tongue", []) + for path, data in moved.items(): + path.write_bytes(data) self._fail_on_nth(monkeypatch, 2, sqlite3.IntegrityError("constraint failed")) with pytest.raises(ValueError): _import(import_env) @@ -829,11 +847,11 @@ with pytest.raises(ValueError): _import(import_env) monkeypatch.undo() - # monkeypatch.undo() で TIASDATA_CONFIG・holds の差し替えも戻るので明示的に指定する + # monkeypatch.undo() で TIASDATA_CONFIG・reviews の差し替えも戻るので明示的に指定する result = run_import( load_config(import_env.config_path), import_env.source_name, - holds_dir=import_env.holds_dir, + reviews_dir=import_env.reviews_dir, ) assert result.captures_added == 18 assert _counts(import_env)["captures"] == 18 @@ -945,3 +963,492 @@ assert "ABCzz9" not in str(excinfo.value) for key in DUMMY_KEYS: assert key not in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# 要確認の印と,撮影にひも付けないファイル(2026-10-08 のユーザー判断) +# --------------------------------------------------------------------------- +LIGHT_RED = "淡紅(正常)" +SHARED_KEY = "990088" # 撮影にひも付けない番号 19 の対応表の患者 ID(対応表に入らないこと) + + +def _write_review_entries(env: ImportEnv, entries: list[tuple[int, str, str, str]]) -> None: + """要確認リストを (番号, reason, resolve, since) で書く(番号ごとに違う理由を付けるため).""" + text = "".join( + f'[[review]]\nnumber = {number}\nreason = "{reason}"\nresolve = "{resolve}"\n' + f"since = {since}\n\n" + for number, reason, resolve, since in entries + ) + (env.reviews_dir / f"{env.source_name}.toml").write_bytes(text.encode("utf-8")) + + +def _share_content(env: ImportEnv, *numbers: int, folder: str = LIGHT_RED) -> str: + """番号 1 と同じ中身の画像を `folder` に `numbers` の名前で置き,対応表に行を足す. + + Returns: + 共有する中身の SHA-256. + """ + first = env.source.images[0] + assert first.number == 1 + data = first.path.read_bytes() + for number in numbers: + (env.source.root / folder / f"{number}.png").write_bytes(data) + _rewrite_map(env, _default_rows(env) + [(n, 1, SHARED_KEY) for n in numbers]) + return hashlib.sha256(data).hexdigest() + + +def _sha_of(env: ImportEnv, number: int) -> str: + image = next(i for i in env.source.images if i.number == number) + return hashlib.sha256(image.path.read_bytes()).hexdigest() + + +def _annotations_of( + env: ImportEnv, *, capture_id: str | None = None, sha256: str | None = None +) -> list[tuple[str, dict, str, int | None]]: + """撮影またはファイルに付いた所見の (kind, 値, source, superseded_by).""" + column, key = ("capture_id", capture_id) if capture_id else ("sha256", sha256) + with closing(_catalog(env)) as conn: + rows = conn.execute( + f"SELECT kind, value, source, superseded_by FROM annotations WHERE {column} = ?" + " ORDER BY annotation_id", + (key,), + ).fetchall() + return [(kind, json.loads(value), source, sup) for kind, value, source, sup in rows] + + +def _review_rows(env: ImportEnv) -> list[tuple[str | None, str | None, dict]]: + with closing(_catalog(env)) as conn: + rows = conn.execute( + "SELECT capture_id, sha256, value FROM annotations WHERE kind = 'review'" + " ORDER BY annotation_id" + ).fetchall() + return [(cap, sha, json.loads(value)) for cap, sha, value in rows] + + +class TestReviewMarks: + def test_mark_value_on_capture(self, import_env: ImportEnv) -> None: + entries = [ + (3, "理由3", "確かめ方3", "2026-10-06"), + (10, "理由10", "確かめ方10", "2026-10-07"), + ] + _write_review_entries(import_env, entries) + _init() + _import(import_env) + captures = _captures_by_number(import_env) + for number, reason, resolve, since in entries: + reviews = [ + row + for row in _annotations_of(import_env, capture_id=captures[number][1]) + if row[0] == "review" + ] + value = {"reason": reason, "resolve": resolve, "since": since, "numbers": [number]} + assert reviews == [("review", value, "review_list", None)] + # 印は撮影に付く(ファイルには付かない).要確認でない撮影には付かない + assert [(cap, sha) for cap, sha, _ in _review_rows(import_env)] == [ + (captures[3][1], None), + (captures[10][1], None), + ] + + def test_reviewed_capture_has_normal_rows(self, import_env: ImportEnv) -> None: + # 要確認の番号もふつうに症例・撮影・ファイル・tongue_color・由来を登録する + write_reviews(import_env.reviews_dir, "dummy_tongue", [3]) + _init() + _import(import_env) + sha, capture_id, subject_id = _captures_by_number(import_env)[3] + assert sha == _sha_of(import_env, 3) + assert _identity_map(import_env)[import_env.patient_map.patient_keys[3]] == subject_id + kinds = [row[0] for row in _annotations_of(import_env, capture_id=capture_id)] + assert sorted(kinds) == ["review", "tongue_color"] + with closing(_catalog(import_env)) as conn: + outputs = conn.execute( + "SELECT COUNT(*) FROM derivation_outputs WHERE sha256 = ?", (sha,) + ).fetchone()[0] + assert outputs == 1 + + def test_mark_is_added_to_registered_capture(self, import_env: ImportEnv) -> None: + # 登録済みの撮影がリストに載り,有効な印が無ければ印だけを足す(書き足すのみ) + _init() + _import(import_env) + before_catalog, before_identity = _dumps(import_env) + before_counts = _counts(import_env) + + write_reviews(import_env.reviews_dir, "dummy_tongue", [3]) + result = _import(import_env) + assert result.review_marks_added == 1 + assert result.skipped_existing == 18 + assert result.captures_added == 0 + assert result.assets_added == 0 + assert result.annotations_added == 1 + assert result.reviewed_added == 0 + assert result.review_marks_stale == 0 + + after_catalog, after_identity = _dumps(import_env) + assert after_identity == before_identity + # 既存の行はそのまま(増えたのは所見 1 行だけ) + assert set(before_catalog) <= set(after_catalog) + assert len(after_catalog) == len(before_catalog) + 1 + after_counts = _counts(import_env) + assert after_counts == {**before_counts, "annotations": before_counts["annotations"] + 1} + capture_id = _captures_by_number(import_env)[3][1] + assert [(cap, value["numbers"]) for cap, _, value in _review_rows(import_env)] == [ + (capture_id, [3]) + ] + + # もう一度実行しても印は重ならない + again = _import(import_env) + assert again.review_marks_added == 0 + assert again.annotations_added == 0 + assert _dumps(import_env) == (after_catalog, after_identity) + + def test_stale_mark_is_kept_and_counted_each_run(self, import_env: ImportEnv) -> None: + write_reviews(import_env.reviews_dir, "dummy_tongue", [3, 10]) + _init() + _import(import_env) + before = _dumps(import_env) + write_reviews(import_env.reviews_dir, "dummy_tongue", [10]) + for _ in range(2): + result = _import(import_env) + assert result.review_marks_stale == 1 + assert result.review_marks_added == 0 + assert result.annotations_added == 0 + # 印は外さない(外すのは編集のコマンド) + assert _dumps(import_env) == before + + def test_superseded_mark_counts_as_no_mark(self, import_env: ImportEnv) -> None: + # 有効な印(訂正されていないもの)が無ければ足す + write_reviews(import_env.reviews_dir, "dummy_tongue", [3]) + _init() + _import(import_env) + capture_id = _captures_by_number(import_env)[3][1] + with closing(_catalog(import_env)) as conn: + (old_id,) = conn.execute( + "SELECT annotation_id FROM annotations WHERE kind = 'review'" + ).fetchone() + # 印を訂正した状態を作る(訂正先の行を足して superseded_by を埋める) + cursor = conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + " SELECT kind, capture_id, value, 'test_edit', created_at FROM annotations" + " WHERE annotation_id = ?", + (old_id,), + ) + conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (cursor.lastrowid, old_id), + ) + # 訂正先も訂正済みにして,有効な印が無い状態にする + cursor2 = conn.execute( + "INSERT INTO annotations (kind, capture_id, value, source, created_at)" + ' SELECT \'tongue_color\', capture_id, \'{"label":"red","severity":"mild"}\',' + " 'test_edit', created_at FROM annotations WHERE annotation_id = ?", + (old_id,), + ) + conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", + (cursor2.lastrowid, cursor.lastrowid), + ) + conn.commit() + before_rows = _review_rows(import_env) + result = _import(import_env) + assert result.review_marks_added == 1 + rows = _review_rows(import_env) + assert rows[: len(before_rows)] == before_rows + assert len(rows) == len(before_rows) + 1 + assert rows[-1][0] == capture_id + + def test_review_number_linked_still_needs_patient_map(self, import_env: ImportEnv) -> None: + # 撮影にひも付く要確認の番号は対応表の確認の対象のまま + write_reviews(import_env.reviews_dir, "dummy_tongue", [5]) + rows = [row for row in _default_rows(import_env) if row[0] != 5] + _rewrite_map(import_env, rows) + _init() + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + _assert_no_leak(str(excinfo.value), import_env) + + +class TestUnlinkedFiles: + def test_registered_once_without_capture(self, import_env: ImportEnv) -> None: + sha = _share_content(import_env, 19) + _write_review_entries( + import_env, + [ + (19, "理由19", "確かめ方19", "2026-10-07"), + (1, "理由1", "確かめ方1", "2026-10-06"), + ], + ) + _init() + result = _import(import_env) + assert result.found == 19 + assert result.review_numbers == 2 + assert result.captures_added == 17 + assert result.assets_added == 18 + assert result.derivations_added == 18 + # 撮影 17 × tongue_color + ファイルに tongue_color・review + assert result.annotations_added == 19 + assert result.reviewed_added == 1 + assert result.unlinked_files_added == 1 + assert result.unlinked_numbers_added == 2 + assert result.review_marks_added == 0 + + with closing(_catalog(import_env)) as conn: + rows = conn.execute( + "SELECT capture_id, source, source_name, kind FROM assets WHERE sha256 = ?", (sha,) + ).fetchall() + outputs = conn.execute( + "SELECT COUNT(*) FROM derivation_outputs WHERE sha256 = ?", (sha,) + ).fetchone()[0] + # ファイルは 1 つ,撮影なし.source_name は番号の小さい方 + assert rows == [(None, "dummy_tongue", f"{LIGHT_RED}/1.png", "tongue_crop_masked")] + assert outputs == 1 + assert list((import_env.catalog_root / "store").rglob(f"{sha}.*")) + # 所見はファイルに付く.review の numbers は全番号,reason 等は番号の最も小さい行 + review = { + "reason": "理由1", + "resolve": "確かめ方1", + "since": "2026-10-06", + "numbers": [1, 19], + } + tongue = {"label": "light_red", "severity": "normal"} + assert _annotations_of(import_env, sha256=sha) == [ + ("tongue_color", tongue, "tiasjudgemodel_folder", None), + ("review", review, "review_list", None), + ] + # 番号 1・19 の撮影は無い + assert set(_captures_by_number(import_env)) == set(range(2, 19)) + + def test_no_subject_capture_or_identity_rows(self, import_env: ImportEnv) -> None: + _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + _import(import_env) + # 番号 19 の患者(対応表にだけある)の行・症例は作らない + assert SHARED_KEY not in _identity_map(import_env) + counts = _counts(import_env) + assert counts["captures"] == 17 + # 番号 1 の患者 990001 は番号 8・15 の撮影で残る + assert counts["subjects"] == 7 + assert counts["identity_map"] == 7 + with closing(_catalog(import_env)) as conn: + orphans = conn.execute( + "SELECT COUNT(*) FROM subjects WHERE subject_id NOT IN" + " (SELECT subject_id FROM captures)" + ).fetchone()[0] + assert orphans == 0 + + def test_patient_whose_only_image_is_unlinked_gets_no_subject( + self, import_env: ImportEnv + ) -> None: + # 番号 1 を専用の患者にすると,その患者の症例・対応表の行は作られない + _share_content(import_env, 19) + rows = _default_rows(import_env) + [(19, 1, SHARED_KEY)] + rows[0] = (1, 1, "990077") + _rewrite_map(import_env, rows) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + result = _import(import_env) + assert result.subjects_added == 7 + assert "990077" not in _identity_map(import_env) + + def test_three_numbers_with_same_content(self, import_env: ImportEnv) -> None: + sha = _share_content(import_env, 19, 20) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19, 20]) + _init() + result = _import(import_env) + assert result.unlinked_files_added == 1 + assert result.unlinked_numbers_added == 3 + reviews = [row for row in _annotations_of(import_env, sha256=sha) if row[0] == "review"] + assert [row[1]["numbers"] for row in reviews] == [[1, 19, 20]] + + def test_unlinked_and_linked_reviews_together(self, import_env: ImportEnv) -> None: + sha = _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 5, 19]) + _init() + result = _import(import_env) + assert result.reviewed_added == 2 + assert result.unlinked_files_added == 1 + assert result.unlinked_numbers_added == 2 + capture_5 = _captures_by_number(import_env)[5][1] + marks = sorted((str(cap), str(s), v["numbers"]) for cap, s, v in _review_rows(import_env)) + assert marks == sorted([(capture_5, "None", [5]), ("None", sha, [1, 19])]) + + def test_unlinked_import_is_idempotent(self, import_env: ImportEnv) -> None: + _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + _import(import_env) + before = _dumps(import_env) + store_dir = import_env.catalog_root / "store" + store_before = sorted(p.relative_to(store_dir) for p in store_dir.rglob("*")) + result = _import(import_env) + assert result.skipped_existing == 18 + assert result.assets_added == 0 + assert result.annotations_added == 0 + assert result.copied_to_store == 0 + assert result.unlinked_files_added == 0 + assert result.unlinked_numbers_added == 0 + assert result.reviewed_added == 0 + assert result.review_marks_added == 0 + assert result.review_marks_stale == 0 + assert _dumps(import_env) == before + assert sorted(p.relative_to(store_dir) for p in store_dir.rglob("*")) == store_before + + def test_same_content_with_different_labels_stops(self, import_env: ImportEnv) -> None: + # 同じ中身で番号どうしのラベルが違えば,すべて要確認でも停止する + _share_content(import_env, 19, folder="紅_あり") + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + _assert_no_leak(str(excinfo.value), import_env, "19.png", "1.png", SHARED_KEY) + + def test_same_content_with_unreviewed_number_stops(self, import_env: ImportEnv) -> None: + # 3 つのうち 1 つでも要確認リストに無ければ停止する + _share_content(import_env, 19, 20) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + with pytest.raises(ValueError) as excinfo: + _import(import_env) + _assert_nothing_registered(import_env) + _assert_no_leak(str(excinfo.value), import_env, "20.png", SHARED_KEY) + + def test_registered_capture_becoming_shared_stops(self, import_env: ImportEnv) -> None: + # 撮影にひも付けて登録済みの中身が,要確認の複数の番号に現れた: 書き換えずに停止 + _init() + _import(import_env) + before = _dumps(import_env) + _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + with pytest.raises(ValueError) as excinfo: + _import(import_env) + assert _dumps(import_env) == before + _assert_no_leak(str(excinfo.value), import_env, "1.png", "19.png", SHARED_KEY) + + def test_registered_unlinked_becoming_linked_stops(self, import_env: ImportEnv) -> None: + # 撮影にひも付けずに登録済みの中身が,1 つの番号だけになった: 書き換えずに停止 + _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + _import(import_env) + before = _dumps(import_env) + (import_env.source.root / LIGHT_RED / "19.png").unlink() + write_reviews(import_env.reviews_dir, "dummy_tongue", [1]) + with pytest.raises(ValueError) as excinfo: + _import(import_env) + assert _dumps(import_env) == before + _assert_no_leak(str(excinfo.value), import_env, "1.png") + + def test_registered_unlinked_with_changed_label_stops(self, import_env: ImportEnv) -> None: + # 撮影にひも付けない登録済みのファイルのラベル(フォルダ)が両方とも変わった + _share_content(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _init() + _import(import_env) + before = _dumps(import_env) + root = import_env.source.root + for number in (1, 19): + shutil.move(root / LIGHT_RED / f"{number}.png", root / "紅_あり" / f"{number}.png") + with pytest.raises(ValueError): + _import(import_env) + assert _dumps(import_env) == before + + +class TestLabPcFlow: + """研究室 PC と同じ流れ(2026-10-08 のユーザー判断). + + 要確認の番号を除いて取り込み済み(0001 だけの目録・印なし)→ 新しいコードで init + (0002 を適用)→ import.既存の撮影 ID・症例 ID・対応表は変わらず,要確認の分だけ増える. + """ + + def _old_import(self, env: ImportEnv, monkeypatch: pytest.MonkeyPatch, held: set[int]) -> None: + from tiasdata.catalog import migrate + + original = migrate.load_migrations + first_only = original("catalog")[:1] + moved: dict[Path, bytes] = {} + for folder in TONGUE_COLOR_FOLDERS: + for path in (env.source.root / folder).iterdir(): + if int(path.stem) in held: + moved[path] = path.read_bytes() + for path in moved: + path.unlink() + write_reviews(env.reviews_dir, env.source_name, []) + with monkeypatch.context() as m: + # 0002 を知らない以前のコードの目録(スキーマの版 1)を作る + m.setattr( + migrate, + "load_migrations", + lambda series: first_only if series == "catalog" else original(series), + ) + _init() + _import(env) + with closing(_catalog(env)) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM kinds WHERE kind = 'review'").fetchone() == ( + 0, + ) + for path, data in moved.items(): + path.write_bytes(data) + + def test_existing_ids_unchanged_and_only_reviewed_added( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + shared_sha = _share_content(import_env, 19) + held = {1, 5, 19} + self._old_import(import_env, monkeypatch, held) + before_catalog, before_identity = _dumps(import_env) + before_captures = _captures_by_number(import_env) + before_identity_map = _identity_map(import_env) + assert set(before_captures) == set(range(2, 19)) - {5} + + # 新しいコード: init で 0002 を適用してから,要確認リストを付けて取り込む + _init() + with closing(_catalog(import_env)) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] >= 2 + write_reviews(import_env.reviews_dir, "dummy_tongue", sorted(held)) + result = _import(import_env) + assert result.found == 19 + assert result.skipped_existing == 16 + assert result.captures_added == 1 + assert result.subjects_added == 0 + assert result.assets_added == 2 + assert result.unlinked_files_added == 1 + assert result.unlinked_numbers_added == 2 + assert result.reviewed_added == 2 + assert result.review_marks_added == 0 + assert result.review_marks_stale == 0 + + after_catalog, after_identity = _dumps(import_env) + # 既存の行はそのまま(目録は追加だけ.対応表は変わらない) + assert set(before_catalog) <= set(after_catalog) + assert after_identity == before_identity + after_captures = _captures_by_number(import_env) + for number, ids in before_captures.items(): + assert after_captures[number] == ids + assert _identity_map(import_env) == before_identity_map + # 番号 5 は既存の患者(番号 12 と同じ)の症例に新しい撮影として入る + _, capture5, subject5 = after_captures[5] + assert subject5 == before_identity_map[import_env.patient_map.patient_keys[5]] + assert capture5 not in {cap for _, cap, _ in before_captures.values()} + marks = sorted((str(cap), str(s), v["numbers"]) for cap, s, v in _review_rows(import_env)) + assert marks == sorted([(capture5, "None", [5]), ("None", shared_sha, [1, 19])]) + assert SHARED_KEY not in _identity_map(import_env) + + # もう一度実行しても変わらない + again = _import(import_env) + assert again.assets_added == 0 + assert again.annotations_added == 0 + assert again.review_marks_added == 0 + assert _dumps(import_env) == (after_catalog, after_identity) + + def test_import_before_new_init_stops( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + # 新しいコードで init(0002 の適用)をする前の取り込みは停止し,何も変えない + self._old_import(import_env, monkeypatch, {1}) + before = _dumps(import_env) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1]) + with pytest.raises(ValueError, match="init"): + _import(import_env) + assert _dumps(import_env) == before diff --git a/tests/kinds/test_review.py b/tests/kinds/test_review.py new file mode 100644 index 0000000..94cf89f --- /dev/null +++ b/tests/kinds/test_review.py @@ -0,0 +1,89 @@ +"""所見の種類 `review`(要確認の印)の値の型(2026-10-08 のユーザー判断.testing.md 必須対象 4). + +- 値は `{"reason": str, "resolve": str, "since": "YYYY-MM-DD", "numbers": [int, ...]}` +- 未知のキーは拒否.strict(型の暗黙の変換をしない) +- since は日付.numbers は 1 個以上・1 以上・昇順・重複なし +""" + +import json + +import pytest +from pydantic import ValidationError + +from tiasdata.kinds import ( + REVIEW_KIND, + Review, + annotation_value_type, + dump_annotation_value, + validate_annotation_value, +) + +VALID = {"reason": "理由", "resolve": "確かめ方", "since": "2026-10-06", "numbers": [122, 184]} + + +def _with(**changes: object) -> dict[str, object]: + value = dict(VALID) + value.update(changes) + return value + + +class TestValidValues: + def test_kind_name_is_review(self) -> None: + assert REVIEW_KIND == "review" + assert annotation_value_type("review") is Review + + def test_valid_value_is_accepted(self) -> None: + value = validate_annotation_value("review", VALID) + assert isinstance(value, Review) + assert value.numbers == [122, 184] + assert value.since == "2026-10-06" + + def test_single_number_is_accepted(self) -> None: + assert validate_annotation_value("review", _with(numbers=[1])).numbers == [1] + + def test_dump_is_stable_json_and_round_trips(self) -> None: + value = validate_annotation_value("review", VALID) + text = dump_annotation_value(value) + assert json.loads(text) == VALID + # 同じ値は常に同じ文字列(キーの順を固定し空白を入れない) + reordered = dict(reversed(list(VALID.items()))) + assert dump_annotation_value(validate_annotation_value("review", reordered)) == text + assert " " not in text.replace("確かめ方", "").replace("理由", "") + + +class TestRejected: + @pytest.mark.parametrize( + "value", + [ + pytest.param(_with(extra="x"), id="unknown-key"), + pytest.param({k: v for k, v in VALID.items() if k != "reason"}, id="missing-reason"), + pytest.param({k: v for k, v in VALID.items() if k != "resolve"}, id="missing-resolve"), + pytest.param({k: v for k, v in VALID.items() if k != "since"}, id="missing-since"), + pytest.param({k: v for k, v in VALID.items() if k != "numbers"}, id="missing-numbers"), + pytest.param(_with(reason=""), id="empty-reason"), + pytest.param(_with(resolve=""), id="empty-resolve"), + pytest.param(_with(reason=1), id="reason-not-str"), + pytest.param(_with(since="2026/10/06"), id="since-slash"), + pytest.param(_with(since="2026-13-01"), id="since-invalid-month"), + pytest.param(_with(since="2026-02-30"), id="since-invalid-day"), + pytest.param(_with(since="2026-10-06T00:00:00"), id="since-datetime"), + pytest.param(_with(since="いつか"), id="since-not-date"), + pytest.param(_with(numbers=[]), id="numbers-empty"), + pytest.param(_with(numbers=[0]), id="numbers-zero"), + pytest.param(_with(numbers=[-1, 3]), id="numbers-negative"), + pytest.param(_with(numbers=[184, 122]), id="numbers-not-sorted"), + pytest.param(_with(numbers=[122, 122]), id="numbers-duplicated"), + pytest.param(_with(numbers=["122"]), id="numbers-str-strict"), + pytest.param(_with(numbers=[1.0]), id="numbers-float-strict"), + pytest.param(_with(numbers=[True]), id="numbers-bool-strict"), + pytest.param(_with(numbers=122), id="numbers-not-list"), + ], + ) + def test_invalid_value_is_rejected(self, value: dict[str, object]) -> None: + with pytest.raises(ValidationError): + validate_annotation_value("review", value) + + def test_value_is_frozen(self) -> None: + value = validate_annotation_value("review", VALID) + with pytest.raises(ValidationError): + value.reason = "別" # type: ignore[misc] diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..712abb3 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,342 @@ +"""目録と対応表のバックアップ(`tiasdata backup`.2026-10-08 のユーザー判断). + +- 目録は `/backups/catalog-.sqlite`,対応表は + **対応表のフォルダの** `backups/identity-<同じ日時>.sqlite` に,SQLite のバックアップ API で + 複製する +- 複製に `integrity_check` が ok・スキーマの版が最新であることを確かめ,件数を表示する + (パスは出さない) +- 書き込む前に 2 つとも同じ名前が無いことを確かめ,あれば何も書かずに停止する(上書きしない) +- 一時ファイル → 点検 → 正式な名前.失敗時は一時ファイルを消す +- init 前・スキーマが古いときは 1 行のエラー +- `run_backup(config, *, now=None)` で日時を固定できる +""" + +import sqlite3 +from contextlib import closing +from datetime import UTC, datetime, timedelta, timezone +from pathlib import Path + +import pytest +from conftest import ImportEnv, write_reviews + +from tiasdata import backup, catalog +from tiasdata.backup import backup_timestamp, run_backup +from tiasdata.catalog import backup as catalog_backup +from tiasdata.cli import main +from tiasdata.config import load_config +from tiasdata.importers import run_import + +NOW = datetime(2026, 10, 8, 9, 0, 0, tzinfo=UTC) +STAMP = "20261008T090000Z" +DUMMY_KEYS = [str(990001 + i) for i in range(7)] + + +def _catalog_backups(env: ImportEnv) -> Path: + return env.catalog_root / "backups" + + +def _identity_backups(env: ImportEnv) -> Path: + return env.identity_path.parent / "backups" + + +def _files(directory: Path) -> list[str]: + return sorted(p.name for p in directory.iterdir()) if directory.is_dir() else [] + + +def _dump(path: Path) -> list[str]: + with closing(sqlite3.connect(path)) as conn: + return list(conn.iterdump()) + + +def _version(path: Path) -> int: + with closing(sqlite3.connect(path)) as conn: + return conn.execute("PRAGMA user_version").fetchone()[0] + + +def _init() -> None: + assert main(["init"]) == 0 + + +def _assert_nothing_written(env: ImportEnv) -> None: + assert _files(_catalog_backups(env)) == [] + assert _files(_identity_backups(env)) == [] + + +def _assert_no_paths(text: str, env: ImportEnv) -> None: + root = env.config_path.parents[1] + for form in (str(root), root.as_posix(), str(root.resolve())): + assert form not in text + assert "catalog.sqlite" not in text + assert "identity.sqlite" not in text + for key in DUMMY_KEYS: + assert key not in text + + +class TestTimestamp: + def test_utc_format(self) -> None: + assert backup_timestamp(NOW) == STAMP + + def test_other_timezone_is_converted_to_utc(self) -> None: + jst = timezone(timedelta(hours=9)) + assert backup_timestamp(datetime(2026, 10, 8, 18, 0, 0, tzinfo=jst)) == STAMP + + def test_naive_is_treated_as_utc(self) -> None: + assert backup_timestamp(datetime(2026, 10, 8, 9, 0, 0)) == STAMP + + def test_default_is_now(self) -> None: + before = datetime.now(UTC).replace(microsecond=0) + stamp = backup_timestamp() + after = datetime.now(UTC) + moment = datetime.strptime(stamp, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC) + assert before <= moment <= after + + +class TestRunBackup: + def test_copies_to_each_backups_folder(self, import_env: ImportEnv) -> None: + write_reviews(import_env.reviews_dir, "dummy_tongue", [3]) + _init() + run_import(load_config(import_env.config_path), import_env.source_name) + catalog_before = _dump(import_env.catalog_root / "catalog.sqlite") + identity_before = import_env.identity_path.read_bytes() + + result = run_backup(load_config(import_env.config_path), now=NOW) + + assert result.timestamp == STAMP + assert result.catalog.filename == f"catalog-{STAMP}.sqlite" + assert result.identity.filename == f"identity-{STAMP}.sqlite" + # 目録は目録のフォルダの backups/,対応表は対応表のフォルダの backups/(別の場所) + assert _files(_catalog_backups(import_env)) == [f"catalog-{STAMP}.sqlite"] + assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"] + assert _catalog_backups(import_env) != _identity_backups(import_env) + + catalog_copy = _catalog_backups(import_env) / result.catalog.filename + identity_copy = _identity_backups(import_env) / result.identity.filename + # 中身は元と同じ.スキーマの版は最新 + assert _dump(catalog_copy) == catalog_before + assert _dump(identity_copy) == _dump(import_env.identity_path) + assert _version(catalog_copy) == catalog.latest_version("catalog") + assert _version(identity_copy) == catalog.latest_version("identity") + assert catalog_backup.integrity_problems(catalog_copy) == 0 + assert catalog_backup.integrity_problems(identity_copy) == 0 + # 複製元は変わらない + assert _dump(import_env.catalog_root / "catalog.sqlite") == catalog_before + assert import_env.identity_path.read_bytes() == identity_before + + # 件数(撮影 18・ファイル 18・所見 18 + 要確認の印 1・症例 7・対応表 7) + assert result.catalog.counts == { + "subjects": 7, + "captures": 18, + "assets": 18, + "annotations": 19, + "dataset_versions": 0, + } + assert result.identity.counts == {"identity_map": 7} + + def test_copy_can_be_opened_as_latest_catalog(self, import_env: ImportEnv) -> None: + _init() + result = run_backup(load_config(import_env.config_path), now=NOW) + copy = _catalog_backups(import_env) / result.catalog.filename + with closing(catalog.connect_latest(copy)) as conn: + assert catalog.count_captures(conn) == 0 + + def test_empty_catalog_counts_are_zero(self, import_env: ImportEnv) -> None: + _init() + result = run_backup(load_config(import_env.config_path), now=NOW) + assert set(result.catalog.counts.values()) == {0} + assert result.identity.counts == {"identity_map": 0} + + def test_different_times_make_separate_copies(self, import_env: ImportEnv) -> None: + _init() + config = load_config(import_env.config_path) + run_backup(config, now=NOW) + run_backup(config, now=NOW + timedelta(seconds=1)) + assert _files(_catalog_backups(import_env)) == [ + f"catalog-{STAMP}.sqlite", + "catalog-20261008T090001Z.sqlite", + ] + assert len(_files(_identity_backups(import_env))) == 2 + + def test_no_temporary_files_remain(self, import_env: ImportEnv) -> None: + _init() + run_backup(load_config(import_env.config_path), now=NOW) + for directory in (_catalog_backups(import_env), _identity_backups(import_env)): + assert not [n for n in _files(directory) if n.startswith(backup.TEMP_PREFIX)] + + +class TestDoesNotOverwrite: + @pytest.mark.parametrize("which", ["catalog", "identity"]) + def test_existing_name_stops_without_writing(self, import_env: ImportEnv, which: str) -> None: + _init() + if which == "catalog": + existing = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite" + other = _identity_backups(import_env) + else: + existing = _identity_backups(import_env) / f"identity-{STAMP}.sqlite" + other = _catalog_backups(import_env) + existing.parent.mkdir(parents=True, exist_ok=True) + existing.write_bytes(b"existing-backup") + + with pytest.raises(ValueError) as excinfo: + run_backup(load_config(import_env.config_path), now=NOW) + message = str(excinfo.value) + assert "\n" not in message + _assert_no_paths(message, import_env) + # 既存のファイルは変わらず,もう一方も書かない(一時ファイルも残らない) + assert existing.read_bytes() == b"existing-backup" + assert _files(existing.parent) == [existing.name] + assert _files(other) == [] + + def test_second_run_at_same_time_stops(self, import_env: ImportEnv) -> None: + _init() + config = load_config(import_env.config_path) + run_backup(config, now=NOW) + catalog_copy = _catalog_backups(import_env) / f"catalog-{STAMP}.sqlite" + before = catalog_copy.read_bytes() + with pytest.raises(ValueError): + run_backup(config, now=NOW) + assert catalog_copy.read_bytes() == before + assert _files(_catalog_backups(import_env)) == [catalog_copy.name] + assert _files(_identity_backups(import_env)) == [f"identity-{STAMP}.sqlite"] + + +class TestFailures: + def test_before_init_stops(self, import_env: ImportEnv) -> None: + with pytest.raises(ValueError) as excinfo: + run_backup(load_config(import_env.config_path), now=NOW) + assert "\n" not in str(excinfo.value) + _assert_no_paths(str(excinfo.value), import_env) + _assert_nothing_written(import_env) + + def test_missing_identity_stops(self, import_env: ImportEnv) -> None: + _init() + import_env.identity_path.unlink() + with pytest.raises(ValueError) as excinfo: + run_backup(load_config(import_env.config_path), now=NOW) + _assert_no_paths(str(excinfo.value), import_env) + _assert_nothing_written(import_env) + + @pytest.mark.parametrize("which", ["catalog", "identity"]) + def test_old_schema_stops(self, import_env: ImportEnv, which: str) -> None: + _init() + path = import_env.catalog_root / "catalog.sqlite" if which == "catalog" else None + path = path or import_env.identity_path + with closing(sqlite3.connect(path)) as conn: + conn.execute("PRAGMA user_version = 0") + with pytest.raises(ValueError, match="init") as excinfo: + run_backup(load_config(import_env.config_path), now=NOW) + assert "\n" not in str(excinfo.value) + _assert_no_paths(str(excinfo.value), import_env) + _assert_nothing_written(import_env) + + def test_integrity_problem_leaves_nothing( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + monkeypatch.setattr(catalog_backup, "integrity_problems", lambda path: 2) + with pytest.raises(ValueError, match="integrity_check"): + run_backup(load_config(import_env.config_path), now=NOW) + _assert_nothing_written(import_env) + + def test_integrity_problem_in_identity_copy_removes_catalog_copy( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + original = catalog_backup.integrity_problems + calls = {"n": 0} + + def second_fails(path: Path) -> int: + calls["n"] += 1 + return 1 if calls["n"] == 2 else original(path) + + monkeypatch.setattr(catalog_backup, "integrity_problems", second_fails) + with pytest.raises(ValueError): + run_backup(load_config(import_env.config_path), now=NOW) + assert calls["n"] == 2 + _assert_nothing_written(import_env) + + def test_copy_with_old_version_leaves_nothing( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + monkeypatch.setattr(catalog_backup, "user_version", lambda path: 0) + with pytest.raises(ValueError): + run_backup(load_config(import_env.config_path), now=NOW) + _assert_nothing_written(import_env) + + def test_sqlite_error_during_copy_leaves_nothing( + self, import_env: ImportEnv, monkeypatch: pytest.MonkeyPatch + ) -> None: + _init() + original = catalog_backup.copy_database + calls = {"n": 0} + + def second_fails(source: Path, target: Path) -> None: + calls["n"] += 1 + if calls["n"] == 2: + raise sqlite3.OperationalError("database is locked") + original(source, target) + + monkeypatch.setattr(catalog_backup, "copy_database", second_fails) + with pytest.raises(ValueError) as excinfo: + run_backup(load_config(import_env.config_path), now=NOW) + assert "\n" not in str(excinfo.value) + _assert_no_paths(str(excinfo.value), import_env) + _assert_nothing_written(import_env) + + +class TestBackupCommand: + def test_prints_names_and_counts_without_paths( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + _init() + run_import(load_config(import_env.config_path), import_env.source_name) + capsys.readouterr() + assert main(["backup"]) == 0 + captured = capsys.readouterr() + assert captured.err == "" + out = captured.out + catalog_names = _files(_catalog_backups(import_env)) + identity_names = _files(_identity_backups(import_env)) + assert len(catalog_names) == len(identity_names) == 1 + assert catalog_names[0] in out + assert identity_names[0] in out + # 2 つの複製の日時は同じ + assert catalog_names[0].removeprefix("catalog-") == identity_names[0].removeprefix( + "identity-" + ) + assert "撮影 18 件" in out + assert "行 7 件" in out + assert "ok" in out + _assert_no_paths(out, import_env) + + def test_same_name_exits_1_with_one_line( + self, + import_env: ImportEnv, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + _init() + monkeypatch.setattr(backup, "backup_timestamp", lambda now=None: STAMP) + assert main(["backup"]) == 0 + capsys.readouterr() + assert main(["backup"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert len(captured.err.strip().splitlines()) == 1 + assert captured.err.startswith("エラー: ") + assert "Traceback" not in captured.err + _assert_no_paths(captured.err, import_env) + + def test_before_init_exits_1_with_one_line( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["backup"]) == 1 + captured = capsys.readouterr() + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + _assert_no_paths(captured.err, import_env) + + def test_unknown_argument_exits_2(self, import_env: ImportEnv) -> None: + with pytest.raises(SystemExit) as excinfo: + main(["backup", "--to", "x"]) + assert excinfo.value.code == 2 diff --git a/tests/test_check.py b/tests/test_check.py index f22728f..6a23c88 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -301,3 +301,29 @@ assert main(["check"]) == 1 out = capsys.readouterr().out assert "凍結した版の manifest の不一致: 1 件(版 1 件)" in out + + +class TestUnlinkedFiles: + def test_unlinked_file_is_not_a_problem( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + # 2026-10-08: 撮影にひも付かないファイル(要確認リストから意図して登録)は問題に数えない + from conftest import write_patient_map, write_reviews + + first = import_env.source.images[0] + (first.path.parent / "19.png").write_bytes(first.path.read_bytes()) + rows = [(n, 1, k) for n, k in sorted(import_env.patient_map.patient_keys.items())] + write_patient_map(import_env.patient_map.path, [*rows, (19, 1, "990088")]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 19]) + _setup(import_env) + result = _check(import_env) + assert result.unlinked_files == 1 + assert result.catalog_files == 18 + assert result.hash_ok == 18 + assert result.dangling_annotations == 0 + assert result.problems == 0 + capsys.readouterr() + assert main(["check"]) == 0 + out = capsys.readouterr().out + assert "撮影にひも付かないファイル(問題には数えない): 1 件" in out + assert "問題: 0 件" in out diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bf6105..0c1ce44 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,8 +14,8 @@ TONGUE_COLOR_FOLDERS, ImportEnv, write_dummy_png, - write_holds, write_patient_map, + write_reviews, ) from tiasdata import catalog @@ -82,7 +82,7 @@ p = _paths(tmp_path) with sqlite3.connect(p["catalog"]) as conn: assert catalog.schema_version(conn) == catalog.latest_version("catalog") - assert conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 5 + assert conn.execute("SELECT COUNT(*) FROM kinds").fetchone()[0] == 6 with sqlite3.connect(p["identity"]) as conn: assert catalog.schema_version(conn) == catalog.latest_version("identity") @@ -119,7 +119,7 @@ latest = catalog.latest_version("catalog") assert f"{latest}" in out assert "asset 4" in out - assert "annotation 1" in out + assert "annotation 2" in out def test_init_output_contains_no_paths( self, tmp_path: Path, config_env: Path, capsys: pytest.CaptureFixture[str] @@ -325,15 +325,15 @@ for text in (imported.out, stats.out, checked.out): _assert_no_personal_info(text, import_env) - def test_import_reports_held_count( + def test_import_reports_review_count( self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] ) -> None: - write_holds(import_env.holds_dir, "dummy_tongue", [3, 10]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [3, 10]) assert main(["init"]) == 0 capsys.readouterr() assert main(["import", "dummy_tongue"]) == 0 out = capsys.readouterr().out - assert "2 件" in _line_with(out, "保留") + assert "2 件" in _line_with(out, "要確認として登録") _assert_no_personal_info(out, import_env) def test_second_import_reports_skipped( @@ -372,7 +372,7 @@ @pytest.mark.parametrize( "case", - ["unknown-file", "number-not-in-map", "non-numeric-patient", "hold-not-in-source"], + ["unknown-file", "number-not-in-map", "non-numeric-patient", "review-not-in-source"], ) def test_import_error_is_one_line_without_personal_info( self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str], case: str @@ -390,7 +390,7 @@ write_patient_map(import_env.patient_map.path, rows) planted.append("LEAKID") else: - write_holds(import_env.holds_dir, "dummy_tongue", [99]) + write_reviews(import_env.reviews_dir, "dummy_tongue", [99]) assert main(["init"]) == 0 capsys.readouterr() @@ -463,7 +463,7 @@ def test_stats_and_check_in_subprocess_are_utf8( self, import_env: ImportEnv, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - # 取り込みは同じプロセスで行う(保留リストの差し替えは別プロセスに効かないため) + # 取り込みは同じプロセスで行う(要確認リストの差し替えは別プロセスに効かないため) assert main(["init"]) == 0 assert main(["import", "dummy_tongue"]) == 0 for command in ("stats", "check"): @@ -782,3 +782,157 @@ with pytest.raises(SystemExit) as exc: main(args) assert exc.value.code == 2 + + +# --------------------------------------------------------------------------- +# 要確認の印・撮影にひも付かないファイルの表示(2026-10-08 のユーザー判断) +# --------------------------------------------------------------------------- +_REASON = "理由のテキストLEAKzz" + + +def _share_first_image(env: ImportEnv, number: int) -> None: + # 番号 1 と同じ中身を同じフォルダ(同じラベル)に置き,対応表に行を足す + first = env.source.images[0] + (first.path.parent / f"{number}.png").write_bytes(first.path.read_bytes()) + write_patient_map(env.patient_map.path, [*_map_rows(env), (number, 1, "990088")]) + + +def _write_reviews_with_reason(env: ImportEnv, numbers: list[int]) -> None: + text = "".join( + f'[[review]]\nnumber = {n}\nreason = "{_REASON}"\nresolve = "確かめ方zz"\n' + "since = 2026-10-06\n\n" + for n in numbers + ) + (env.reviews_dir / "dummy_tongue.toml").write_bytes(text.encode("utf-8")) + + +def _assert_review_safe(text: str, env: ImportEnv) -> None: + _assert_no_personal_info(text, env) + assert "990088" not in text + assert "保留" not in text + assert "hold" not in text + + +class TestReviewOutput: + def test_import_stats_check_freeze_with_reviews( + self, import_env: ImportEnv, datasets_env, capsys: pytest.CaptureFixture[str] + ) -> None: + _share_first_image(import_env, 19) + _write_reviews_with_reason(import_env, [1, 5, 19]) + assert main(["init"]) == 0 + capsys.readouterr() + + assert main(["import", "dummy_tongue"]) == 0 + out = capsys.readouterr().out + assert "3 件" in _line_with(out, "要確認リストの番号") + assert "reviews/dummy_tongue.toml" in _line_with(out, "要確認リストの番号") + assert _line_with(out, "要確認として登録") == ( + "要確認として登録: 2 件(うち撮影にひも付けないファイル 1 件・番号 2 個)" + ) + assert _line_with(out, "既存の撮影・ファイルに要確認の印を足した") == ( + "既存の撮影・ファイルに要確認の印を足した: 0 件" + ) + assert _line_with(out, "要確認リストから外れたが印が残っている") == ( + "要確認リストから外れたが印が残っている: 0 件" + ) + assert _REASON not in out + _assert_review_safe(out, import_env) + + assert main(["stats"]) == 0 + out = capsys.readouterr().out + assert _line_with(out, "撮影にひも付かないファイル") == "撮影にひも付かないファイル: 1 件" + assert _line_with(out, "review").strip() == ( + "review: 2 件(撮影に付いたもの 1 件・ファイルに付いたもの 1 件)" + ) + # review は件数だけ(reason の全文等の内訳を出さない) + assert _REASON not in out + assert "確かめ方zz" not in out + assert "2026-10-06" not in out + _assert_review_safe(out, import_env) + + assert main(["check"]) == 0 + out = capsys.readouterr().out + assert "撮影にひも付かないファイル(問題には数えない): 1 件" in out + assert "問題: 0 件" in out + _assert_review_safe(out, import_env) + + assert main(["dataset", "freeze", "tongue_color"]) == 0 + out = capsys.readouterr().out + assert "版 1 を作った" in out + # 印の付いた撮影(番号 5)は版に入り,撮影にひも付かないファイルは除く + assert "ファイル 17 件" in out + assert "必須の所見が無く除いた 0 件" in out + assert ( + _line_with(out, "撮影にひも付かないため除いた") == "撮影にひも付かないため除いた: 1 件" + ) + _assert_review_safe(out, import_env) + assert _rebuild(import_env, 1).count(b"\n") == 18 + + def test_import_reports_added_and_stale_marks( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + _write_reviews_with_reason(import_env, [3, 10]) + capsys.readouterr() + assert main(["import", "dummy_tongue"]) == 0 + out = capsys.readouterr().out + assert "2 件" in _line_with(out, "既存の撮影・ファイルに要確認の印を足した") + assert "0 件" in _line_with(out, "要確認として登録") + + _write_reviews_with_reason(import_env, [10]) + assert main(["import", "dummy_tongue"]) == 0 + out = capsys.readouterr().out + assert "0 件" in _line_with(out, "既存の撮影・ファイルに要確認の印を足した") + assert _line_with(out, "要確認リストから外れたが印が残っている") == ( + "要確認リストから外れたが印が残っている: 1 件" + ) + _assert_review_safe(out, import_env) + + def test_stats_without_marks_has_no_review_line( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + assert main(["import", "dummy_tongue"]) == 0 + capsys.readouterr() + assert main(["stats"]) == 0 + out = capsys.readouterr().out + assert "review" not in out + assert _line_with(out, "撮影にひも付かないファイル") == "撮影にひも付かないファイル: 0 件" + + def test_stats_of_empty_catalog_shows_none( + self, config_env: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["stats"]) == 0 + out = capsys.readouterr().out + assert " なし" in out + assert "review" not in out + + def test_different_labels_error_is_one_line( + self, import_env: ImportEnv, capsys: pytest.CaptureFixture[str] + ) -> None: + first = import_env.source.images[0] + (import_env.source.root / "紅_あり" / "19.png").write_bytes(first.path.read_bytes()) + write_patient_map(import_env.patient_map.path, [*_map_rows(import_env), (19, 1, "990088")]) + _write_reviews_with_reason(import_env, [1, 19]) + assert main(["init"]) == 0 + capsys.readouterr() + assert main(["import", "dummy_tongue"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert len(captured.err.strip().splitlines()) == 1 + assert "Traceback" not in captured.err + _assert_review_safe(captured.err, import_env) + + def test_import_help_mentions_reviews_not_holds( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit) as excinfo: + main(["import", "--help"]) + assert excinfo.value.code == 0 + out = capsys.readouterr().out + assert "要確認" in out + assert "保留" not in out + assert "holds" not in out diff --git a/tests/test_datasets.py b/tests/test_datasets.py index 1a3ce66..f445407 100644 --- a/tests/test_datasets.py +++ b/tests/test_datasets.py @@ -645,21 +645,21 @@ _freeze(catalog_conn, _loaded(tmp_path)) self._assert_nothing_created(catalog_conn) - def test_file_without_capture_stops(self, tmp_path: Path, cat: Cat) -> None: + def test_file_without_capture_is_excluded_and_counted(self, tmp_path: Path, cat: Cat) -> None: + # 2026-10-08 の仕様変更: 撮影にひも付かないファイルは停止せずに除き,数を報告する sha = cat.asset(20, None) cat.annotate(_tc("red", "mild"), sha256=sha) # ファイルに付いた所見で条件に合う - with pytest.raises(ValueError, match="撮影") as exc: - _freeze(cat.conn, _loaded(tmp_path)) - assert LEAK_KEY not in str(exc.value) - assert "original" not in str(exc.value) - self._assert_nothing_created(cat.conn) + result = _freeze(cat.conn, _loaded(tmp_path)) + assert result.members == 3 + assert result.unlinked == 1 - def test_file_without_capture_and_no_require_stops(self, tmp_path: Path, cat: Cat) -> None: + def test_file_without_capture_and_no_require_is_excluded( + self, tmp_path: Path, cat: Cat + ) -> None: cat.asset(21, None) text = TONGUE_COLOR_TOML.replace('require = ["tongue_color"]', "require = []") - with pytest.raises(ValueError, match="撮影"): - _freeze(cat.conn, _loaded(tmp_path, text)) - self._assert_nothing_created(cat.conn) + result = _freeze(cat.conn, _loaded(tmp_path, text)) + assert result.unlinked == 1 def test_file_without_capture_not_matching_is_ignored(self, tmp_path: Path, cat: Cat) -> None: cat.asset(22, None) # 必須の所見が無いので版に入らない @@ -914,3 +914,96 @@ assert len(composition.members) == 3 assert composition.excluded == 1 assert all(_count(cat.conn, t) == 0 for t in FROZEN_TABLES) + + +# --------------------------------------------------------------------------- +# 撮影にひも付かないファイル・要確認の印(2026-10-08 のユーザー判断) +# --------------------------------------------------------------------------- +def _review(numbers: list[int]) -> str: + return kinds.dump_annotation_value( + kinds.validate_annotation_value( + "review", + {"reason": "理由", "resolve": "確かめ方", "since": "2026-10-06", "numbers": numbers}, + ) + ) + + +class TestUnlinkedAndReview: + def test_unlinked_with_duplicated_annotations_is_excluded_not_stopped( + self, tmp_path: Path, cat: Cat + ) -> None: + # 版に入れないファイルは所見の重複の確認の対象にしない + sha = cat.asset(20, None) + cat.annotate(_tc("red", "mild"), sha256=sha) + cat.annotate(_tc("pale", "mild"), sha256=sha) + result = _freeze(cat.conn, _loaded(tmp_path)) + assert result.created + assert result.members == 3 + assert result.unlinked == 1 + assert result.candidates == 5 + manifest = rebuild_manifest(cat.conn, "tongue_color", 1) + assert sha.encode() not in manifest + + def test_composition_reports_unlinked_separately_from_excluded( + self, tmp_path: Path, cat: Cat + ) -> None: + sha = cat.asset(20, None) + cat.annotate(_tc("red", "mild"), sha256=sha) + cat.asset(21, None) # 必須の所見が無い(撮影にひも付かないため除いた,には数えない) + composition = collect_composition(cat.conn, _loaded(tmp_path).definition) + assert composition.candidates == 6 + assert len(composition.members) == 3 + assert composition.unlinked == 1 + # C000002 の 1 件と,所見の無い撮影なしのファイル 1 件 + assert composition.excluded == 2 + + def test_only_unlinked_files_stops_with_zero( + self, tmp_path: Path, catalog_conn: sqlite3.Connection + ) -> None: + c = Cat(catalog_conn) + for seed in (1, 2): + sha = c.asset(seed, None) + c.annotate(_tc("red", "mild"), sha256=sha) + with pytest.raises(ValueError, match="0 件") as excinfo: + _freeze(catalog_conn, _loaded(tmp_path)) + message = str(excinfo.value) + assert "2 件" in message + assert "\n" not in message + assert LEAK_KEY not in message + assert "original" not in message + for table in FROZEN_TABLES: + assert _count(catalog_conn, table) == 0 + + def test_review_marked_capture_is_in_version(self, tmp_path: Path, cat: Cat) -> None: + # 印による除外はしない(印の付いた撮影も版に入る).印は版の列にならない + cat.annotate(_review([3]), kind="review", capture_id="C000003") + result = _freeze(cat.conn, _loaded(tmp_path)) + assert result.members == 3 + assert result.member_annotations == 3 + rows = {r["capture_id"]: r for r in _rows(rebuild_manifest(cat.conn, "tongue_color", 1))} + assert "C000003" in rows + assert not any("review" in column for column in rows["C000003"]) + + def test_marks_and_unlinked_files_added_later_keep_past_version( + self, tmp_path: Path, cat: Cat + ) -> None: + # 必須対象 1: 版の後に印・撮影にひも付かないファイルを足しても,版 1 は変わらず + # 構成も変わらないので新しい版は作らない + loaded = _loaded(tmp_path) + v1 = _freeze(cat.conn, loaded) + snapshot = _snapshot(cat.conn, "tongue_color", 1) + manifest_v1 = rebuild_manifest(cat.conn, "tongue_color", 1) + + cat.annotate(_review([3]), kind="review", capture_id="C000003") + sha = cat.asset(30, None) + cat.annotate(_tc("red", "mild"), sha256=sha) + cat.annotate(_review([30, 31]), kind="review", sha256=sha) + + again = _freeze(cat.conn, loaded) + assert not again.created + assert again.version == 1 + assert again.unlinked == 1 + assert again.manifest_sha256 == v1.manifest_sha256 + assert _snapshot(cat.conn, "tongue_color", 1) == snapshot + assert rebuild_manifest(cat.conn, "tongue_color", 1) == manifest_v1 + assert count_manifest_mismatches(cat.conn) == (1, 0) diff --git a/tests/test_stats.py b/tests/test_stats.py index 9de2130..0c8bce1 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -71,13 +71,79 @@ assert not import_env.identity_path.exists() -def test_held_images_are_not_counted(import_env: ImportEnv) -> None: - from conftest import write_holds +def test_reviewed_images_are_counted_with_marks(import_env: ImportEnv) -> None: + # 2026-10-08 の仕様変更: 要確認の番号も登録し,撮影に要確認の印を付ける + from conftest import write_reviews - write_holds(import_env.holds_dir, "dummy_tongue", [1, 2]) # light_red の 2 枚 + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 2]) # light_red の 2 枚 assert main(["init"]) == 0 run_import(load_config(import_env.config_path), import_env.source_name) with closing(_open(import_env)) as conn: stats = collect_stats(conn) - assert stats.captures == 16 - assert "light_red" not in stats.annotations["tongue_color"].by_field["label"] + assert stats.captures == 18 + assert stats.review_marks_on_captures == 2 + assert stats.review_marks_on_files == 0 + assert stats.assets_without_capture == 0 + + +def _share_first_image(env: ImportEnv, number: int) -> None: + # 番号 1 と同じ中身を同じフォルダ(同じラベル)に置き,対応表に行を足す + from conftest import write_patient_map + + first = env.source.images[0] + (first.path.parent / f"{number}.png").write_bytes(first.path.read_bytes()) + rows = [(n, 1, key) for n, key in sorted(env.patient_map.patient_keys.items())] + write_patient_map(env.patient_map.path, [*rows, (number, 1, "990088")]) + + +def test_unlinked_files_and_review_marks_are_counted(import_env: ImportEnv) -> None: + # 2026-10-08: 撮影にひも付かないファイルの件数,review の印の件数(撮影・ファイル). + # review は所見の種類ごとの内訳に入れない + from conftest import write_reviews + + _share_first_image(import_env, 19) + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 5, 19]) + assert main(["init"]) == 0 + run_import(load_config(import_env.config_path), import_env.source_name) + with closing(_open(import_env)) as conn: + stats = collect_stats(conn) + assert stats.captures == 17 + assert stats.assets_by_kind["tongue_crop_masked"] == 18 + assert stats.assets_without_capture == 1 + assert stats.review_marks_on_captures == 1 + assert stats.review_marks_on_files == 1 + assert set(stats.annotations) == {"tongue_color"} + # 撮影 17 件と撮影にひも付かないファイル 1 件の舌色 + assert stats.annotations["tongue_color"].total == 18 + + +def test_superseded_review_mark_is_not_counted(import_env: ImportEnv) -> None: + from conftest import write_reviews + + write_reviews(import_env.reviews_dir, "dummy_tongue", [1, 2]) + assert main(["init"]) == 0 + run_import(load_config(import_env.config_path), import_env.source_name) + with closing(_open(import_env)) as conn: + ids = [ + i + for (i,) in conn.execute( + "SELECT annotation_id FROM annotations WHERE kind = 'review' ORDER BY annotation_id" + ) + ] + conn.execute( + "UPDATE annotations SET superseded_by = ? WHERE annotation_id = ?", (ids[1], ids[0]) + ) + conn.commit() + stats = collect_stats(conn) + assert stats.review_marks_on_captures == 1 + assert stats.review_marks_on_files == 0 + assert stats.superseded_annotations == 1 + + +def test_empty_catalog_has_no_unlinked_or_marks(import_env: ImportEnv) -> None: + assert main(["init"]) == 0 + with closing(_open(import_env)) as conn: + stats = collect_stats(conn) + assert stats.assets_without_capture == 0 + assert stats.review_marks_on_captures == 0 + assert stats.review_marks_on_files == 0