Reports

TL;DR 各モジュールの責務と呼び出し関係。中心は contract.ts(型の背骨)で、AnalyzeResult という1つの型が「取り込み方針」のすべてを内包する。 ブラウザ側(UI/フック/クライアント)とサーバー側(API/ハンドラ)は HTTP の5本で繋がり、両者は 共有ロジック層の同じ型・同じ関数を使う。 「AI 出力を信じず検算する関門」は sanitize〜 という名前で必ず登場する。

目次 §1 全体の依存方向 §2 共有ロジック層 §3 サーバー層 §4 クライアント層 §5 UI層 §6 フック層 §7 呼び出し関係(俯瞰) §8 contract.ts が背骨
§1 全体の依存方向(誰が誰を知っているか)

依存は基本「外側 → 内側」。UI はクライアントを呼び、クライアントは HTTP で API を呼び、API はハンドラを呼び、ハンドラは共有ロジックと DB を呼ぶ。内側(共有ロジック・型)は外側を一切知りません。だから共有ロジックは純粋関数としてテストでき、ブラウザ・サーバーの両方から安全に使えます。

ブラウザ側 共有ロジック(両側) サーバー側 DB
UI
AiImportThread / AiFlow
画面と会話の状態機械
フック
useColumnFlow 他
進行状態
クライアント
aiImport_repository
API呼び出し
API
pages/api/v1/ai-import
認証・検証
ハンドラ
*Handler
処理本体
DB
Prisma
セッション等
共有ロジック層(contract / applyAnalysis / columnDecision / …)— UI とハンドラの両方から呼ばれる
型の定義・プロンプト生成・AI出力の矯正・変換の実装・列の分類。ここに業務の頭脳が集中している。
§2 共有ロジック層 src/services/aiImport/

このシステムで一番濃い層。純粋関数(副作用なし)の集まりで、ブラウザでもサーバーでも同じものを使います。

モジュール主なエクスポート役割(入力 → 出力)
contract.ts 背骨AnalyzeResultSchema / sanitizeAnalyzeResult / 各種型取り込み方針の型(Zod)。AI出力を意味的に検算・矯正して返す(§8)。
buildAnalyzePrompt.tsbuildAnalyzeSystemPrompt / buildAnalyzeUserPrompt会社情報+モード契約 → AIへ渡すプロンプト文字列。
applyAnalysis.tsapplyAnalysisToRows / applyTransform / classifyTransform / selectVisibleConfirmations確定方針+元データ全行 → nanco正規ヘッダー付きの表。変換の実装本体と破壊性分類。
cleanTable.tsdropEmptyColumns表 → 全空列を落とした表+落とした列名。解析前の前処理。
detectTransforms.tsdetectColumnRescue列値+対応先の型 → 必要な救済整形と文字化け有無。全行を見た決定的判定。
columnDecision.tsbuildColumnPlans / columnsNeedingConfirmation / CONFIDENT_THRESHOLD=0.7矯正済み方針 → 列ごとの計画(auto/newAttr/rescue/ambiguous/price)。質問する列を決める。
interpretColumn.tsbuildInterpret*Prompt / sanitizeInterpretResult / 型列カードの自由入力(指示文)を1ショットで解釈するスキーマ・プロンプト・矯正。
extractDocument.tsExtractedDocumentSchema / buildExtract*Prompt / sanitizeExtractedDocument画像/PDF抽出(vision)の型・プロンプト・矯正。④で詳述。
headerFingerprint.tscomputeHeaderFingerprint / normalizeHeaderTokens / headerSetOverlap列名の指紋と重なり率。「前回と同じ形式か」を測る。⑤で詳述。
buildReuseDecisions.ts / applyReuseDecisions.ts同名関数確定内容を「ルールだけ」に圧縮 / 前回ルールを今回に当てる。
maskTelemetry.tsmaskTelemetryValue / resolvePiiColumns / buildTelemetrySamples ほかテレメトリのPIIマスク・件数化(サーバー専用)。

キーになる関数3つ(覚えるならこれ)

sanitizeAnalyzeResult()
AIの提案を検算して矯正する関門。範囲外の列番号・架空の属性名を除去し、消えそうな列を救済。原則2の番人。
buildColumnPlans()
列を5種に分類。自動確定できる列はキューに入れず、迷う列だけ質問に回す。会話の設計図。
applyAnalysisToRows()
最後に全行を正規の表へ。列順の不変条件(nancoID→フォルダ→アイテム名→在庫数→属性)を守る。
列順の不変条件があるのは、既存の取り込み処理がエラーを「位置」で突き合わせるため。nancoID列が右にあると照合エラーが無言で握り潰され「更新のつもりが重複作成」になる。だから applyAnalysisToRows が必ず決まった順に並べ直す。
§3 サーバー層(API入口 → ハンドラ → 外部)

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→sanitizeInterpretResultOpenAI cheap
lookupReuse会社確認→メンバー検証→過去 committed セッションを引き重なり率で照合Prisma のみ(LLMなし)

共通の足回り

aiClient/index.ts
OpenAI Responses API ラッパー。structured() がZodスキーマ付き構造化出力を返す。store:false 強制(会話を保存しない)。tier→model(strong=gpt-4o / cheap=gpt-4o-mini)。
aiUsage/dailyQuota.ts
consumeAiDailyQuota。会社×日×機能の原子的カウントで上限ゲート。失敗時はfail-open(本体を止めない)。
aiImport/recordTelemetry.ts
改善用記録の追記。opt-out尊重・PIIマスク・失敗は握り潰す(テレメトリで本体を絶対落とさない)。
company/usage.ts
assertCanWrite。VIEWERロール拒否+サブスク有効性。有料LLMの前に必ず通す。

全ハンドラ共通の安全弁: ①会社解決(無ければ404)②userDB.findFirst({firebaseId, companyId}) で越境(IDOR)防止(非メンバーは403)③LLM/書き込みは assertCanWrite。lookup は読み取りなのでメンバー検証はするが assertCanWrite はしない。

§4 クライアント層 aiImport_repository.ts

5つのAPIを叩くだけの薄いラッパー。戻り値は axiosPost が res.data.message を取り出して返します。

クライアント関数→ エンドポイント戻り値中断
analyzeImportApiPOST /ai-import/analyze{ sessionId, result }AbortSignal
interpretColumnApiPOST /ai-import/interpret-columnInterpretColumnResultAbortSignal
extractDocumentTableApiPOST /ai-import/extractSanitizedExtractedDocumentAbortSignal
commitImportApiPOST /ai-import/commit{ ok }—
lookupReuseApiPOST /ai-import/lookup{ decisions }—
解析・抽出・自由入力解釈は数十秒かかりうるので、シートを閉じたら fetch を中断(AbortSignal)したい。ところが axiosPost は「headers が無いと第3引数を丸ごと捨てる」仕様なので、signal を効かせるには headers: {} を必ず添える、という地味なお約束がある(リポジトリ内コメントに明記)。
§5 UI層(会話の見た目と状態機械)

コンポーネント構成

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回避」の核心。
§6 フック層(進行状態だけを持つ)
hook/useColumnFlow.ts
「いま何列目を聞いているか」だけを持つ useReducer。回答そのものは持たず、UI側が決定ストアに書く(単方向)。アクション: reset / chooseMode / advance / continuePastCap / skipRest。質問は5列でいったん「まだ続けますか?」(cap=5)。
tasks/shared/useAiTradeImportFlow.ts
入荷/出荷向けにAIライフサイクルを切り出したフック。ドロップ振り分け(画像/CSV/テンプレ適合)・vision抽出・AI同意・取込成功時の再利用保存・孤児属性ロールバック。④で詳述。
単方向設計: useColumnFlow は「順番」だけ、AiImportThread の各 state(attrDecisions / overrides / priceDecisions …)が「回答内容」を持つ。フックと本体で責務を割って、どちらが真実かを明確にしている。
§7 呼び出し関係(俯瞰)

「解析」の1往復で、どのクラスがどの順に動くか。番号が呼び出し順です。

AiImportThread → analyzeImportApi
会社情報ブロックと列統計を持って解析を依頼。
クライアント層が POST /ai-import/analyze
analyze.ts → analyzeImportData
入口で認証・Zod検証・サイズ上限を通してハンドラへ。
API層 → ハンドラ層
analyzeImportData → buildAnalyze*Prompt → aiClient.structured
プロンプトを組み、OpenAIに構造化出力(AnalyzeResult)を要求。
ハンドラ → 共有ロジック → 外部LLM
analyzeImportData → sanitizeAnalyzeResult
AI出力を検算・矯正(消える列の救済もここ)。
ハンドラ → 共有ロジック(contract.ts)
→ importSessionDB.update / recordAnalyzeTelemetry
矯正済み方針をセッションに保存し、改善用記録を追記。
ハンドラ → DB
AiImportThread ← result
受け取った方針で buildColumnPlans を回し、会話の質問を組み立てる。
UI に戻り、列計画 → 会話へ
§8 contract.ts が背骨:AnalyzeResult の中身

このシステムを一番速く理解する近道は「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つの型に同居しています。各部分の詳しい使い道は次のページへ。