TL;DR よその雑な CSV/Excel を AI が解析し、決定的コードが矯正・分類・確定して列を1問1問確認するフロー。 AI は提案だけ、確定は決定的コード。不変条件は「データのある列を黙って消さない」。 全スライス実装・コミット完了:救済(S1)・判定(S2a)・会話フロー(S2b)・価格ロール(S3)・フォルダ簡素化相談(S4)・属性メモ(S5)・形式別再利用(S6)・開発者テレメトリ(TM)。 codex+多観点レビューで本物のバグを複数発掘→修正→実機ライブ確証。残るはデプロイ作業のみ(TMのDB反映・worker deploy・push/MR)。

§1 背景§2 ユーザーの流れ§3 列分類と質問§4 AI/コードの境界§5 導出パイプライン§6 スライス進捗§7 学習・再利用の実装§8 開発者テレメトリ§9 残り(デプロイ)§10 画像/PDF・伝票インテーク構想
§1 背景:なぜ作り直すか

在庫データを 新規/既存ワークスペースに丸ごと取り込む(オンボーディング・移行)体験の作り直し。

困りごと: 旧フローは「対応表でまとめて聞く」形。列が多いと分かりにくく、特に新規WSでは AI が属性提案を出し切れず、データのある列が無言で ignore に落ちて消える(実例: 40列中20列消失)。

方針: STEP2(列選択)/STEP3(新規属性登録) を「列を1つずつ確認する会話」に一本化。明確な列は自動確定、迷う列だけ1問ずつ。整形もその場で。原則は「AI は理解・提案・リコメンド、確定・適用・最終防衛は決定的コード」。

§2 ユーザーが見る流れ(+裏の実装)

テンプレートに合わない CSV/Excel を「一括登録」に入れると、シート本体が AiImportThread(会話スレッド)に切り替わる。各ステップに裏で動く関数を併記する。

ファイル投入 → 解析
テンプレ一致は決定的に従来ウィザードへ。不一致のみ AI 解析。解析アニメ(列を読み取り中/アイテム情報と見比べ中/整える準備中/確認することを整理中)。
detectStockTemplateMatch で分岐 → analyzeImportApi(OpenAI Responses API・zodTextFormat strict・store:false)。会社ブロックは useCompanyImportBlock が showPrice 反映済みで構築。
サマリー(明確 vs 要確認)
何を読み取ったか平易・短く。専門用語禁止。
「商品名をアイテム名として扱います。原価は仕入値として登録します。メーカーは新しい属性「メーカー」として保存します。単価は無視されます。全 5行 を読み取りました。」
AnalyzeResult.summary(AI 生成)。AI の情報共有(confirmations.kind==='other')だけ確認ピルに出す。
空列まとめ確認
空列だけ自動除外、ただし黙って消さず列名を出して1回確認。
空の列(空メモ)は取り込みません。
OK
dropEmptyColumns(table) → {table, droppedColumns:string[]}(落とした列の見出し名を返す)。
フォルダ構成の簡素化相談(S4・該当時のみ)
AI が「フォルダにすると階層が深くなりすぎる列」を見つけたら、その列をフォルダでなくアイテム情報にする提案を1回だけ確認。簡素化しない選択なら元のフォルダ列に戻す。
「メーカー」「カテゴリ」でフォルダを分けると 約120 フォルダになります。これらはアイテム情報として持つ方が探しやすいかもしれません。
この提案で整理するフォルダのままにする
AnalyzeResult.folderPlan(recommendSimplify/demoteColumns/projectedFolderCount)。sanitize が demote 列を folder→attribute 提案に変換。「フォルダのまま」を選ぶと effectiveResult の revert 分岐で元に戻し、属性も作らない。
前置き+進め方の2択
件数と目処を先出し。「一気に」は全部入れて最後に消す(迷ったら含める側)。
取り込むデータ列が N 件、うち確認が必要なのは M 件 です。
1つずつ確認するおすすめ通りで一気に進める
buildColumnPlans() で N(非ignore列)・M(kind!=='auto')を算出。進行は useColumnFlow reducer。
列ごと確認(自動確定外だけ・1問)
進捗「確認 X / M 件目」。性質で3+1バリアント(§3)。連続5件でキャップ。
ColumnQuestionView。回答は attrDecisions / overrides / priceDecisions / rejectedTransforms へ書く(単方向)。
最終レビュー(全列の対応表)
全列の対応先 Select(編集可)+「自動で直すところ(整形)」+「追加される選択肢」+「注意(文字化け・空アイテム名で落ちる行)」。戻る機能は持たず、ここで直す。
MappingPanel。表示は effectiveResult から導出した choiceBySource / transformRuleView / rescue.selectAdditions。
取り込み
新規アイテム情報をここで実際に作成。未取込で閉じたら全削除(孤児ゼロ)。
handleProceed: createAttributeTypeAndSync を逐次 await(Promise.all 禁止=NIDデッドロック前例)。createdAttrIdsRef + importCommittedRef でロールバック。checkCsvHeader 済みテーブルを onComplete でプレビューへ注入。
§3 列の分類(buildColumnPlans)と質問の作り

buildColumnPlans()(src/services/aiImport/columnDecision.ts・純関数)が各列を ColumnKind に分類する。自動確定は控えめに倒す(意図どおりを優先)。

// 分類ルール(優先順)
priceColumns に含む            → 'price'   // 最優先・ignore マッピングでも質問に出す
target ∈ {itemName,stock,nancoId,folder} → 'auto'   // システム列
attributeProposals に提案あり   → 'newAttr'
rescueNeeded に含む(型不一致)  → 'rescue'
既存名一致 && 整形不要 && confidence≥0.7 → 'auto'
それ以外                        → 'ambiguous'
target==='ignore'              → 除外(質問にも出さない)

UI 側(ColumnQuestionView)は kind で質問を出し分け、回答を既存ストアへ書く:

kind質問選択肢 → 書き込み先
newAttr「『樹高』を新しいアイテム情報 [型▼] として取り込む?」型その場変更・select は値チップ編集取り込む/取り込まない → attrDecisions.accepted
rescue「『重量』を取り込む? 値の形を整えてから取り込みます」整形して/取り込まない → rejectedTransforms / overrides
ambiguous「『区分』は ◯◯ でよい?」◯◯として/取り込まない → そのまま / overrides
price「『原価』は どの価格ですか?」推測ロールを既定ハイライト。権限なしはブロック訴求販売/仕入/使わない → priceDecisions
共通:「別の情報にする」→ インライン再マップ Select(既存+このフローの新規)→ overrides

進行管理は useColumnFlow reducer が {plans, queue, mode, cursor, capPrompted} だけを所有し、nextFlowStep(state) が「次に質問/キャップ/レビュー」を導出する純関数。回答データ自体は持たない(reducer は進行のみ・単方向)。COLUMN_FLOW_CAP=5。

§4 AI と決定的コードの境界

AI が出すのは 提案つきの構造化データだけ。それを決定的コードが矯正・救済し、全行適用と最終検証もコードが行う。

AI 出力(AnalyzeResult・zodTextFormat strict)

{
  columnMapping:  []  // 列→target(itemName/stock/nancoId/folder/attribute/ignore)+confidence
  attributeProposals: []  // 新規属性の型付き提案(priceは含めない)
  priceColumns:   []  // 価格列+suggestedRole(selling/purchase/null)
  transformRules: []  // nfkc/extractNumber/...(カタログのみ)
  confirmations:  []  summary  warnings
}

strict mode は z.record() 非対応 → 表記ゆれ統合は {from,to}[] 配列で表現。store:false 厳守(OpenAI 側に残さない=委託の扱い)。

決定的コードの矯正(sanitizeAnalyzeResult)

src/services/aiImport/contract.ts。AI 出力を信頼する前に意味的に矯正する“防波堤”:

§5 導出パイプライン(単方向・温存設計)

AI決定的コード

AI 解析
analyzeImportApi
列マッピング・提案・価格列・確認質問を構造化出力
sanitize
sanitizeAnalyzeResult
矯正・救済・価格分離
分類
buildColumnPlans
auto/newAttr/rescue/ambiguous/price
会話フロー
useColumnFlow + ColumnQuestionView
回答 → 4ストアへ
最終マッピング
effectiveResult (useMemo)
提案採否+override+価格ロールを合成
整形検知
rescue (detectColumnRescue)
型ドリブン・文字化け・選択肢追加
試走
trial (applyAnalysisToRows + checkCsvHeader)
全行変換+ヘッダー検証
取り込み
handleProceed → onComplete
属性逐次作成・孤児ロールバック

温存設計が肝。 回答は attrDecisions/overrides/priceDecisions/rejectedTransforms の4ストアに書き、effectiveResult useMemo が最終 columnMapping を一方向に導出。S2b/S3 の改修でもこの導出(effectiveResult→rescue→trial→handleProceed)と MappingPanel は触らず、置き換えたのは「質問オーケストレーション」だけ。これで巨大ファイル改修の回帰リスクを最小化している。

§6 実装スライスと主な変更

計画の依存順 S1 → S2 → {S3 / S4 / S5 / TM} → S6 どおりに実装。全スライスがコミット済み(未 push)。

スライス主な変更(ファイル/関数)状態コミット
S1 救済sanitizeAnalyzeResult に columnStats フォールバック・buildFallbackProposal/プロンプト nudge完了・E2E37886296a
S2a 判定columnDecision.ts(buildColumnPlans)/dropEmptyColumns 名前返し完了1714ecff8
staging 取込最新 staging マージ(衝突2解消・本体テスト緑)完了334186480
S2b 会話フローuseColumnFlow reducer + AiImportThread 中核改修(MsgBody/3バリアント質問/2モード/キャップ/CSV_MAPPING ファネル)完了・E2E35f792850
S3 価格priceColumns 契約+price 質問+priceDecisions+ロール解決/作成+排他完了・E2E739d81d62
S6 形式別再利用headerFingerprint 集合化+ReuseDecisions 契約+build/applyReuseDecisions+commit/lookupHandler+API+client+UI(解析前 lookup→確認ピル→確定時 commit)完了・E2Ea20ea01f0
レビュー修正①入力上限・選択肢正規化・重複見出し警告・commit ids 固定完了c1f36ca0a
レビュー修正②ultrareview:再利用「同じでいく」で属性が二重作成/override 後の孤児属性作成/updateAttr の stale closure を effectiveResult.columnMapping 基点に統一して解消完了・ライブ確証9c7dd1b2b
レビュー修正③codex#3:再利用保存を取込「成功」に限定(upsertAllRows: Promise<boolean>+importSucceededAtom)。中断/失敗では保存しない完了・E2E9b98fc252
S4 フォルダ相談folderPlan 契約+sanitize の folder→attribute 降格+revert 分岐+確認 UI(demote 提案時のみ)完了・E2E(A)7d4a7cc3e
S5 属性メモItemAttributeTypeDB.description(独立カラム・nullable)+AI 自動下書き+設定 UI 編集。DB 反映済み完了・DB反映済7cb8217c4
TM テレメトリTier1 PostHog 構造イベント+Tier2 ImportTelemetryDB(PII マスク・90日削除・opt-out・cron worker)完了・DB未反映31d166b37

設計原典:~/.claude/plans/ai-import-column-chat-flow.md(仕様)/ai-import-implementation-plan.md(15エージェントのワークフローで精査)。各スライス後に type-check / unit / lint / 実機E2E。E2E は webpack dev(turbopack の next/font バグ回避)+ claude-in-chrome で実施。S4/S6/レビュー修正は /code-review(codex+多観点)で本物のバグを捕捉→修正→ライブ確証する運用を徹底した。

§7 「使うほど楽」をどう実現するか

1問1問で決めたことを永続化し、会社ごと・形式ごとに学習を貯める。grill-with-docs と同じ思想=やり取りが「会社の取り込みナレッジ」を育てる。2層に分ける。

B層:形式別 decision(速い再利用キャッシュ)

取り込み確定時に「列の対応・整形・属性/価格の決定」をルールとして保存し、次回同じ形式を1クリック再利用する。実装の核は次の4つ。

① 形式の照合キー=列名集合の重なり率(実装済み)

djb2 ハッシュ(厳密一致)では列の増減・並び替えで別物になる。そこで順序非依存の集合+重なり率に変えた(headerFingerprint.ts)。

normalizeHeaderTokens(headers): string[]   // NFC正規化・空除去・dedup・昇順
headerSetOverlap(a, b) = |A∩B| / max(|A|,|B|)
HEADER_OVERLAP_THRESHOLD = 0.8            // 列1つ増減程度なら「同じ系」

確認(③)を安全網にするので、攻めに拾って再利用ヒット率を上げる。

② 永続化スキーマ=ルールのみ(実装済み)

ReuseDecisions(contract.ts)。列は sourceIndex でなく「正規化した見出し名」をキーにする(並び替え・列増減があっても新データに当てられる)。行の値は一切持たない。

ReuseDecisions = {
  version: 1, mode, headerTokens: string[],       // ↑照合キー
  columns:    [{ header, target, attributeName }] // 見出し名→対応先
  attributes: [{ name, type, options }]           // 作る新規属性(optionsは定義・行値ではない)
  transforms: [{ header, kind }]                  // 適用した整形
  prices:     [{ header, role }]                  // 価格ロール割当
}

組み立ては buildReuseDecisions()(純関数)。確定状態(effectiveResult の columnMapping・採用属性・効く整形・価格ロール)を見出し名キーに変換するだけで、rawBody(行の値)を一切参照しない。ユニットテストで「シリアライズに値が混入しない・キーは7つのみ」を assert。

③ 置き場=ImportSessionDB(db:push 不要)

既存の ImportSessionDB が status(draft/awaiting_confirm/committed/…)と decisions:Json を既に持つ。S6 はスキーマ変更なしで、確定時に status='committed' + decisions.reuse = ReuseDecisions を書く(manifest に作成した attributeTypeIds も)。

④ 照合・適用・フォールバック(実装済み)

  1. lookup:解析前に (companyId, mode) の committed セッションを引き、headerSetOverlap(新, 各) が閾値以上で最大のものを選ぶ(LLM 非呼び出し=速い・無料)。
  2. 提示(確認1クリック):「前回この形式はこう取り込みました。同じでいいですか?」+ピル [同じでいく / 見直す](AiImportThread reusePrompt)。将来 信頼した形式を確認なしで自動適用する「卒業」フラグは v1 では未実装(ReuseDecisions に該当フラグ無し)。
  3. 適用:applyReuseDecisions が見出し名で新データの列にマッピングを当て、AnalyzeResult 相当を作る。
  4. フォールバック:applyReuseDecisions が新データに当てられない場合(前回に無い列がある/itemName が対応しない)は {ok:false, reason} を返し、確認ピルを出さずそのまま通常の AI 解析へ落ちる(「いつもの形式と違うようなので、AI が内容を読み取ります」)。lookup の例外も同様にAI解析へ。再利用が成立した側でも、整形要否/選択肢追加/文字化け等のデータ整合チェックは導出パイプライン(rescue/trial)で常に走り、checkCsvHeader 失敗は最終レビューで trial.error として表示される(専用の「読み取り直します」メッセージや自動再解析は無い)。

mode 非依存に作るので、入荷/出荷の AI 化時もそのまま効く。出荷の Amazon/楽天/顧客別フォーマットも headerTokens の重なりで自然に分かれる。

レビューで捕まえた本物のバグ(修正済・ライブ確証): 再利用の素朴な実装には、確認(③)を通っても気づきにくい罠があった。ItemAttributeTypeDB に unique(companyId,name) が無いため、 ①「前回と同じでいく」を選ぶと前回作った属性が二重作成される、②採用後にマッピングで ignore/別情報へ変えた列の孤児属性まで作る の2件。作成対象を生の attrDecisions でなく effectiveResult.columnMapping+既存名除外から導出するよう統一して解消(9c7dd1b2b)。さらに保存を取込「成功」に限定し、中断/失敗では reuse を残さないようにした(9b98fc252)。

A層:属性の意味メモ(description・実装済み・DB反映済)

「この属性は何か」を会社の辞書として持つ。B層が「列→属性の変換」を再現するのに対し、A層は「その属性が何か」を説明する=別レイヤーで衝突しない。

  1. ItemAttributeTypeDB.description 列を追加(nullable・@db.Text・details JSON でなく独立 top-level カラム)。schema.test.prisma は pnpm gen:test-schema で自動生成(手編集禁止)。本番 DB 反映済み。
  2. 新規属性を作るとき、AI が列名+サンプル+やり取りから説明文を自動下書きして保存(ユーザーはゼロ手間)。例「売価に使う数値。1点あたりの販売単価」。設定/最終レビューで編集可(空→null 正規化)。
  3. buildCompanyBlock が description を AI 解析プロンプトに同梱 → 次回以降のマッピング精度が上がる。将来は値の自動補完/算出(登録日+ルール→賞味期限 等)の素地にも。
プライバシーの不変条件(実装で担保): 再利用で保存するのはルールだけ(列名・対応先・型・整形種別・選択肢定義)。行の値・サンプル行は保存しない=buildReuseDecisions が rawBody を参照しない設計+テストで assert。select の選択肢は属性定義であり行値ではないので保持可。AI 送信は store:false。A層 description も「属性の意味」で値ではない。
§8 開発者テレメトリ(TM):プライバシー最優先で精度を測る

AI 解析の精度や離脱を測りたいが、顧客の生データ(氏名・取引先・電話・住所…)を貯めないことが最優先。2層に分けてマスク前提で記録する。

層中身送信/保存
Tier1 構造イベント件数・フラグのみ(164_ai_import_analyzed/165_ai_import_proceeded/166_ai_import_failed)。値・属性名・ファイル名は禁止PostHog
Tier2 サンプル+決定ImportTelemetryDB に PII マスク済みサンプル(最大30行・problem 行優先)+最終決定(ReuseDecisions)+メトリクス自社 DB・90日で自動削除

マスクの仕組み(maskTelemetry.ts・サーバー専用)

opt-out は CompanyDB.aiTelemetryOptOut+settings API(v1 は UI スイッチ無し)。自動削除は cloudflare/import-telemetry-cleanup(cron 23 4 * * *・90日超を1000件バッチ削除)。設計簡素化として、クライアント会話ログ専用 API は作らず commitHandler でサーバー記録に寄せた。

§9 残り:実装はゼロ、デプロイ作業のみ

コードは全スライス完成・コミット済み。ここから先は DB 反映 → worker デプロイ → push/MR という手作業(多くはユーザー操作)。

  1. TM の DB 反映: ブランチで pnpm run db:push → PlanetScale Deploy Request → 通過後マージ。S5(description) は反映済みなので、差分は ImportTelemetryDB + CompanyDB.aiTelemetryOptOut のみ。P2022 回避に順序厳守。
  2. Cloudflare worker デプロイ: cd cloudflare/import-telemetry-cleanup && npm install && npm run deploy:stg(本番は deploy:prd)+ wrangler secret put DATABASE_URL(各環境)。
  3. push / MR: GitLab・Target は staging。push・MR はユーザー実施。
  4. 任意 E2E(DB 反映後): S5 説明の自動下書き/TM の PII マスク・opt-out 確認/S4 Branch B(全部フォルダ=revert・LLM 変動で未ライブ)。

既知の弱点(コードでなく AI 出力品質)

§10 構想:画像/PDF・伝票インテーク (未着手・設計検討 2026-06-22)

現状は CSV/Excel 前提。だが現場の主流は「箱に入った納品書・出荷表を、フォーマットがバラバラのままスキャン/写真/PDF で投げる」になる見込み。今の構成で耐えるかを実コードで監査した結論を残す。

結論:テーブル前提は“境界の下”に閉じており、vision は配線済み

下流(正規化テーブル→確認→取込)は入力形式に非依存でそのまま再利用できる(CSV由来か画像由来かを問わない)。AIアダプタも画像入力対応済み(AiImageInput/toOpenAiMessage・既定 gpt-4o)。画像を読ませること自体はほぼ追加コストゼロ。本当の山は次の3点で、「テーブルがガチガチだから無理」という直感はむしろ逆。

ギャップ中身重さ
G1 入荷/出荷へAIフロー展開AI会話フローは今 mode:'stock' 固定。入荷/出荷は ReceivingCsvImportMultipleModal/ShippingCsvImportMultipleModal+専用フックの別サブシステムで、このAI体験に乗っていない(契約・再利用は mode 非依存に作ってあり素地はある)大
G2 伝票の文書モデル納品書=「ヘッダー(取引先/日付/伝票番号)+明細表」。取引先・伝票番号は列でなくタスクに紐づくメタ。今の解析契約は列マッピングのみ → vision抽出が「文書フィールド+明細表」の2本立てを返す必要中
G3 vision 入口アップロードの accept に画像/PDF を追加し、PDFはページ画像化して toOpenAiMessage へ。アダプタが対応済みなので小さい小

データの流れ(伝票インテーク)

AI(vision)決定的コード(既存資産)

入口
FileUploadField + PDF→画像
写真/スキャン/PDF を受け、PDFはページをラスタライズ
vision 抽出
aiClient(gpt-4o) + toOpenAiMessage
{ documentFields, lineItems } を構造化出力
取引先解決
clientImportUtils.resolveImportClient
取引先名→clientId(既存・再検索/重複防止つき)
タスク作成
tradeTaskCreateSchema → ShippingTaskDB
clientId/shipmentNumber/targetAt を流し込む
明細→列フロー
AiImportThread(入荷/出荷modeへ拡張)
既存の確認フロー・導出パイプラインに無改修で乗せる
取込
createImportItemsForNewRows + 価格ロール
入荷=PURCHASE / 出荷=SELLING(既存)

既存資産で“そのまま使える”もの(監査で確認)

⚠ S6 再利用の誤マッチ(実コードで確認・伝票では危険): lookupReuse は where:{companyId, mode, status:'committed'} のみで絞り、取引先もレイアウトも見ない。[品名,数量,単価] のような汎用ヘッダーは別取引先でも重なり率 1.0 → A社のマッピングをZ社の伝票に誤適用しうる。applyReuseDecisions は新列/itemName欠落しか検証せず属性整合を見ない。headerFingerprint は保存済みなのに lookup で未使用(列の並び替えも素通り)。
改善(G4): 再利用キーを「列名トークンの重なり」から 取引先(clientId)+レイアウト指紋 に変える。「この仕入先のこの様式は前回こう取り込んだ」で当てる。S6の“使うほど楽”の思想は流用し、キーだけ差し替える。これは G2(取引先抽出)が入って初めて可能になる本質的改善で、画像対応の副産物ではない。

スライス分解(詳細は plan ドキュメント)

スライス内容工数
G0 安全網S6誤マッチのガード(汎用/短ヘッダー時は確認を強制 or 取引先未確定なら再利用を提示しない)。先行する小さな保険小
G1 入荷/出荷AIフローmode:'stock' 固定を解除し、AI会話フローを入荷/出荷へ。既存パーサ概念+createImportItemsForNewRows+取引先解決+価格ロール+タスクグルーピングに橋渡し。CSV入荷/出荷でも価値が出る本丸大
G2 documentFieldsAnalyzeResult に文書フィールド(取引先名/伝票番号/日付)を追加。vision抽出が「文書フィールド+明細表」を返す。ShippingTaskDB へ既存の取引先解決で接続中
G3 vision 入口accept 拡張+PDFページ画像化+抽出プロンプト。アダプタ既存ゆえ小小
G4 再利用の再キー化再利用を取引先(clientId)+レイアウト指紋キーへ。誤マッチ解消+仕入先別の“使うほど楽”中

推奨順序:G0(保険・小)→ G1(本丸・大)→ G2 → G3 → G4。vision(G3) は最小、本丸は G1。詳細計画:~/.claude/plans/ai-import-document-intake.md。