TL;DR
各モジュールの責務と呼び出し関係。中心は contract.ts(型の背骨)で、AnalyzeResult という1つの型が「取り込み方針」のすべてを内包する。
ブラウザ側(UI/フック/クライアント)とサーバー側(API/ハンドラ)は HTTP の5本で繋がり、両者は 共有ロジック層の同じ型・同じ関数を使う。
「AI 出力を信じず検算する関門」は sanitize〜 という名前で必ず登場する。
依存は基本「外側 → 内側」。UI はクライアントを呼び、クライアントは HTTP で API を呼び、API はハンドラを呼び、ハンドラは共有ロジックと DB を呼ぶ。内側(共有ロジック・型)は外側を一切知りません。だから共有ロジックは純粋関数としてテストでき、ブラウザ・サーバーの両方から安全に使えます。
src/services/aiImport/このシステムで一番濃い層。純粋関数(副作用なし)の集まりで、ブラウザでもサーバーでも同じものを使います。
| モジュール | 主なエクスポート | 役割(入力 → 出力) |
|---|---|---|
contract.ts 背骨 | AnalyzeResultSchema / sanitizeAnalyzeResult / 各種型 | 取り込み方針の型(Zod)。AI出力を意味的に検算・矯正して返す(§8)。 |
buildAnalyzePrompt.ts | buildAnalyzeSystemPrompt / buildAnalyzeUserPrompt | 会社情報+モード契約 → AIへ渡すプロンプト文字列。 |
applyAnalysis.ts | applyAnalysisToRows / applyTransform / classifyTransform / selectVisibleConfirmations | 確定方針+元データ全行 → nanco正規ヘッダー付きの表。変換の実装本体と破壊性分類。 |
cleanTable.ts | dropEmptyColumns | 表 → 全空列を落とした表+落とした列名。解析前の前処理。 |
detectTransforms.ts | detectColumnRescue | 列値+対応先の型 → 必要な救済整形と文字化け有無。全行を見た決定的判定。 |
columnDecision.ts | buildColumnPlans / columnsNeedingConfirmation / CONFIDENT_THRESHOLD=0.7 | 矯正済み方針 → 列ごとの計画(auto/newAttr/rescue/ambiguous/price)。質問する列を決める。 |
interpretColumn.ts | buildInterpret*Prompt / sanitizeInterpretResult / 型 | 列カードの自由入力(指示文)を1ショットで解釈するスキーマ・プロンプト・矯正。 |
extractDocument.ts | ExtractedDocumentSchema / buildExtract*Prompt / sanitizeExtractedDocument | 画像/PDF抽出(vision)の型・プロンプト・矯正。④で詳述。 |
headerFingerprint.ts | computeHeaderFingerprint / normalizeHeaderTokens / headerSetOverlap | 列名の指紋と重なり率。「前回と同じ形式か」を測る。⑤で詳述。 |
buildReuseDecisions.ts / applyReuseDecisions.ts | 同名関数 | 確定内容を「ルールだけ」に圧縮 / 前回ルールを今回に当てる。 |
maskTelemetry.ts | maskTelemetryValue / resolvePiiColumns / buildTelemetrySamples ほか | テレメトリのPIIマスク・件数化(サーバー専用)。 |
applyAnalysisToRows が必ず決まった順に並べ直す。API入口(pages/api/v1/ai-import/*.ts)は「認証・入力検証・サイズ上限」だけ担い、実処理はハンドラ(src/services/server/aiImport/*Handler.ts)に委譲します。ハンドラは DI 可能(deps.prismaClient / deps.aiClient)でテストしやすい形。
| ハンドラ | 処理の骨子 | 外部呼び出し |
|---|---|---|
analyzeImportData | 会社確認→メンバー検証→権限→日次クォータ(200)→セッション作成→プロンプト→LLM→sanitizeAnalyzeResult→保存→テレメトリ | OpenAI strong + Prisma |
commitImportSession | 会社確認→権限→(clientId検証)→セッションを committed に→再利用ルール保存→テレメトリ | Prisma のみ(LLMなし) |
extractDocumentTable | 会社確認→権限→日次クォータ(200)→プロンプト→vision LLM→sanitizeExtractedDocument。セッションは作らない | OpenAI strong/vision |
interpretColumnInstruction | 会社確認→権限→日次クォータ(1000)→プロンプト→cheap LLM→sanitizeInterpretResult | OpenAI cheap |
lookupReuse | 会社確認→メンバー検証→過去 committed セッションを引き重なり率で照合 | Prisma のみ(LLMなし) |
structured() がZodスキーマ付き構造化出力を返す。store:false 強制(会話を保存しない)。tier→model(strong=gpt-4o / cheap=gpt-4o-mini)。consumeAiDailyQuota。会社×日×機能の原子的カウントで上限ゲート。失敗時はfail-open(本体を止めない)。assertCanWrite。VIEWERロール拒否+サブスク有効性。有料LLMの前に必ず通す。全ハンドラ共通の安全弁: ①会社解決(無ければ404)②userDB.findFirst({firebaseId, companyId}) で越境(IDOR)防止(非メンバーは403)③LLM/書き込みは assertCanWrite。lookup は読み取りなのでメンバー検証はするが assertCanWrite はしない。
aiImport_repository.ts5つのAPIを叩くだけの薄いラッパー。戻り値は axiosPost が res.data.message を取り出して返します。
| クライアント関数 | → エンドポイント | 戻り値 | 中断 |
|---|---|---|---|
analyzeImportApi | POST /ai-import/analyze | { sessionId, result } | AbortSignal |
interpretColumnApi | POST /ai-import/interpret-column | InterpretColumnResult | AbortSignal |
extractDocumentTableApi | POST /ai-import/extract | SanitizedExtractedDocument | AbortSignal |
commitImportApi | POST /ai-import/commit | { ok } | — |
lookupReuseApi | POST /ai-import/lookup | { decisions } | — |
axiosPost は「headers が無いと第3引数を丸ごと捨てる」仕様なので、signal を効かせるには headers: {} を必ず添える、という地味なお約束がある(リポジトリ内コメントに明記)。CSVImportModal // 司令塔: テンプレ判定で従来 or AI に振り分け
├─ AiImportThread // 会話フロー本体・状態機械(1,938行)
│ └─ AiThread // 会話容器・自動スクロール
│ ├─ AiProse / UserEcho // AIの地の文 / ユーザー操作のエコー
│ ├─ AiPill / AiPanel // 文中の選択肢ピル / 埋め込みカード
│ ├─ AiAnalyzingCard // 「作業の可視化」(スピナーでなく)
│ ├─ 型ピッカー+AiChipsInput // 列カード: 種類選択・選択肢編集
│ └─ AiInstructionInput // 列カードの自由入力(AIに指示)
└─ CSVImportProgress // 確定後に流れ込む既存ウィザード
AI の発言は吹き出しを使わず地の文(V1散文スタイル)、ユーザー操作だけティールの吹き出しでエコー、選択肢は文中のピル、という統一ルール。components/Common/AiFlow/* はこれらの見た目だけを持つ再利用部品で、状態は持ちません(持つのは AiImportThread)。
中心は msgs: Msg[](メッセージ配列)。回答ハンドラが次のメッセージを push することで会話が前進する「手続き的な状態機械」です。mode(stock/receiving/shipping)はUI構造を変えず、主にデータ経路の引数として効きます。詳しい遷移は ③在庫CSVの全行程。
<X/> ではなく {renderColumnQuestion(...)} という「関数呼び出し」で行う。<X/> だと親再描画のたびにカードが作り直され、入力中の文字とフォーカスが飛ぶため。同じ理由で自由入力欄・選択肢入力は値をローカルstateで持つ。これが「remount回避」の核心。useReducer。回答そのものは持たず、UI側が決定ストアに書く(単方向)。アクション: reset / chooseMode / advance / continuePastCap / skipRest。質問は5列でいったん「まだ続けますか?」(cap=5)。useColumnFlow は「順番」だけ、AiImportThread の各 state(attrDecisions / overrides / priceDecisions …)が「回答内容」を持つ。フックと本体で責務を割って、どちらが真実かを明確にしている。「解析」の1往復で、どのクラスがどの順に動くか。番号が呼び出し順です。
/ai-import/analyzebuildColumnPlans を回し、会話の質問を組み立てる。このシステムを一番速く理解する近道は「AnalyzeResult という1つの型に何が入っているか」を見ることです。AIが返すのもこれ、矯正するのもこれ、会話の質問を組むのもこれ、最後に適用するのもこれ。すべてがこの型を中心に回ります。
AnalyzeResult {
columnMapping[] // 各列 → 何に割り当てるか(itemName/stock/nancoId/folder/attribute/ignore)+確信度
transformRules[] // 各列にかける変換の種類(nfkc/extractNumber/normalizeDate/cleanText)
attributeProposals[] // 新しいアイテム情報の提案(型・選択肢・おすすめ理由・説明メモ)
priceColumns[] // 価格列の検知(売値/仕入値ロールの提案)
piiColumns[] // 個人情報を含みうる列(マスク用・割り当てには影響しない)
folderPlan // フォルダ階層の簡素化提案(深い階層を属性に格下げ)
documentFields // 伝票ヘッダー(取引先名/伝票番号/日付)※入荷出荷用
confirmations[] // ユーザーに確認したいこと(破壊的変換 datafix / その他 other)
summary / warnings // やさしい要約 / 読めなかった列の警告
}
つまり「列の話(columnMapping/transformRules/attributeProposals/priceColumns)」「文書の話(documentFields)」「階層の話(folderPlan)」「会話の話(confirmations/summary/warnings)」「守りの話(piiColumns)」が1つの型に同居しています。各部分の詳しい使い道は次のページへ。