TL;DR よその雑な CSV/Excel を AI が解析し、決定的コードが矯正・分類・確定して列を1問1問確認するフロー。 AI は提案だけ、確定は決定的コード。不変条件は「データのある列を黙って消さない」。 全スライス実装・コミット完了:救済(S1)・判定(S2a)・会話フロー(S2b)・価格ロール(S3)・フォルダ簡素化相談(S4)・属性メモ(S5)・形式別再利用(S6)・開発者テレメトリ(TM)。 codex+多観点レビューで本物のバグを複数発掘→修正→実機ライブ確証。残るはデプロイ作業のみ(TMのDB反映・worker deploy・push/MR)。
在庫データを 新規/既存ワークスペースに丸ごと取り込む(オンボーディング・移行)体験の作り直し。
困りごと: 旧フローは「対応表でまとめて聞く」形。列が多いと分かりにくく、特に新規WSでは AI が属性提案を出し切れず、データのある列が無言で ignore に落ちて消える(実例: 40列中20列消失)。
方針: STEP2(列選択)/STEP3(新規属性登録) を「列を1つずつ確認する会話」に一本化。明確な列は自動確定、迷う列だけ1問ずつ。整形もその場で。原則は「AI は理解・提案・リコメンド、確定・適用・最終防衛は決定的コード」。
テンプレートに合わない CSV/Excel を「一括登録」に入れると、シート本体が AiImportThread(会話スレッド)に切り替わる。各ステップに裏で動く関数を併記する。
detectStockTemplateMatch で分岐 → analyzeImportApi(OpenAI Responses API・zodTextFormat strict・store:false)。会社ブロックは useCompanyImportBlock が showPrice 反映済みで構築。AnalyzeResult.summary(AI 生成)。AI の情報共有(confirmations.kind==='other')だけ確認ピルに出す。dropEmptyColumns(table) → {table, droppedColumns:string[]}(落とした列の見出し名を返す)。AnalyzeResult.folderPlan(recommendSimplify/demoteColumns/projectedFolderCount)。sanitize が demote 列を folder→attribute 提案に変換。「フォルダのまま」を選ぶと effectiveResult の revert 分岐で元に戻し、属性も作らない。buildColumnPlans() で N(非ignore列)・M(kind!=='auto')を算出。進行は useColumnFlow reducer。ColumnQuestionView。回答は attrDecisions / overrides / priceDecisions / rejectedTransforms へ書く(単方向)。MappingPanel。表示は effectiveResult から導出した choiceBySource / transformRuleView / rescue.selectAdditions。handleProceed: createAttributeTypeAndSync を逐次 await(Promise.all 禁止=NIDデッドロック前例)。createdAttrIdsRef + importCommittedRef でロールバック。checkCsvHeader 済みテーブルを onComplete でプレビューへ注入。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。
AI が出すのは 提案つきの構造化データだけ。それを決定的コードが矯正・救済し、全行適用と最終検証もコードが行う。
{
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 側に残さない=委託の扱い)。
src/services/aiImport/contract.ts。AI 出力を信頼する前に意味的に矯正する“防波堤”:
sourceIndex・架空の attributeName を除去(→ warnings)columnStats.guessedType から型推定し新規提案に変換(黙って ignore に落とさない)folderPlan.demoteColumns を検証つきで folder→attribute 提案に変換(フォルダ列のみ・20字以内・既存衝突はフォルダのまま)attributeProposals[].description を trim/2000字/空→null に正規化(normalizeProposalDescription)piiColumns を範囲検証・dedup。テレメトリ送信時のマスク対象に使う(AI∪ヒューリスティック)AI決定的コード
温存設計が肝。 回答は attrDecisions/overrides/priceDecisions/rejectedTransforms の4ストアに書き、effectiveResult useMemo が最終 columnMapping を一方向に導出。S2b/S3 の改修でもこの導出(effectiveResult→rescue→trial→handleProceed)と MappingPanel は触らず、置き換えたのは「質問オーケストレーション」だけ。これで巨大ファイル改修の回帰リスクを最小化している。
計画の依存順 S1 → S2 → {S3 / S4 / S5 / TM} → S6 どおりに実装。全スライスがコミット済み(未 push)。
| スライス | 主な変更(ファイル/関数) | 状態 | コミット |
|---|---|---|---|
| S1 救済 | sanitizeAnalyzeResult に columnStats フォールバック・buildFallbackProposal/プロンプト nudge | 完了・E2E | 37886296a |
| S2a 判定 | columnDecision.ts(buildColumnPlans)/dropEmptyColumns 名前返し | 完了 | 1714ecff8 |
| staging 取込 | 最新 staging マージ(衝突2解消・本体テスト緑) | 完了 | 334186480 |
| S2b 会話フロー | useColumnFlow reducer + AiImportThread 中核改修(MsgBody/3バリアント質問/2モード/キャップ/CSV_MAPPING ファネル) | 完了・E2E | 35f792850 |
| S3 価格 | priceColumns 契約+price 質問+priceDecisions+ロール解決/作成+排他 | 完了・E2E | 739d81d62 |
| S6 形式別再利用 | headerFingerprint 集合化+ReuseDecisions 契約+build/applyReuseDecisions+commit/lookupHandler+API+client+UI(解析前 lookup→確認ピル→確定時 commit) | 完了・E2E | a20ea01f0 |
| レビュー修正① | 入力上限・選択肢正規化・重複見出し警告・commit ids 固定 | 完了 | c1f36ca0a |
| レビュー修正② | ultrareview:再利用「同じでいく」で属性が二重作成/override 後の孤児属性作成/updateAttr の stale closure を effectiveResult.columnMapping 基点に統一して解消 | 完了・ライブ確証 | 9c7dd1b2b |
| レビュー修正③ | codex#3:再利用保存を取込「成功」に限定(upsertAllRows: Promise<boolean>+importSucceededAtom)。中断/失敗では保存しない | 完了・E2E | 9b98fc252 |
| 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+多観点)で本物のバグを捕捉→修正→ライブ確証する運用を徹底した。
1問1問で決めたことを永続化し、会社ごと・形式ごとに学習を貯める。grill-with-docs と同じ思想=やり取りが「会社の取り込みナレッジ」を育てる。2層に分ける。
取り込み確定時に「列の対応・整形・属性/価格の決定」をルールとして保存し、次回同じ形式を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 が status(draft/awaiting_confirm/committed/…)と decisions:Json を既に持つ。S6 はスキーマ変更なしで、確定時に status='committed' + decisions.reuse = ReuseDecisions を書く(manifest に作成した attributeTypeIds も)。
(companyId, mode) の committed セッションを引き、headerSetOverlap(新, 各) が閾値以上で最大のものを選ぶ(LLM 非呼び出し=速い・無料)。[同じでいく / 見直す](AiImportThread reusePrompt)。将来 信頼した形式を確認なしで自動適用する「卒業」フラグは v1 では未実装(ReuseDecisions に該当フラグ無し)。applyReuseDecisions が見出し名で新データの列にマッピングを当て、AnalyzeResult 相当を作る。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)。
「この属性は何か」を会社の辞書として持つ。B層が「列→属性の変換」を再現するのに対し、A層は「その属性が何か」を説明する=別レイヤーで衝突しない。
ItemAttributeTypeDB.description 列を追加(nullable・@db.Text・details JSON でなく独立 top-level カラム)。schema.test.prisma は pnpm gen:test-schema で自動生成(手編集禁止)。本番 DB 反映済み。buildCompanyBlock が description を AI 解析プロンプトに同梱 → 次回以降のマッピング精度が上がる。将来は値の自動補完/算出(登録日+ルール→賞味期限 等)の素地にも。buildReuseDecisions が rawBody を参照しない設計+テストで assert。select の選択肢は属性定義であり行値ではないので保持可。AI 送信は store:false。A層 description も「属性の意味」で値ではない。
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・サーバー専用)detectPiiColumnsHeuristic:氏名/担当/取引先/電話/メール/住所ヘッダー+「高充填でほぼ一意の text 列」を PII 判定。迷ったら伏せる側に倒す。resolvePiiColumns = AI 提案(piiColumns) ∪ ヒューリスティック。どちらかが疑えばマスク。maskTelemetryValue:# + sha256 hex 先頭12桁(`#${h}`・決定的・復元不可)。PII 列のみマスクし、型・分布は残す。recordAnalyzeTelemetry/recordCommitTelemetry(append-only・opt-out ガード・例外は握り潰し=本処理を絶対に止めない・DI)。opt-out は CompanyDB.aiTelemetryOptOut+settings API(v1 は UI スイッチ無し)。自動削除は cloudflare/import-telemetry-cleanup(cron 23 4 * * *・90日超を1000件バッチ削除)。設計簡素化として、クライアント会話ログ専用 API は作らず commitHandler でサーバー記録に寄せた。
コードは全スライス完成・コミット済み。ここから先は DB 反映 → worker デプロイ → push/MR という手作業(多くはユーザー操作)。
pnpm run db:push → PlanetScale Deploy Request → 通過後マージ。S5(description) は反映済みなので、差分は ImportTelemetryDB + CompanyDB.aiTelemetryOptOut のみ。P2022 回避に順序厳守。cd cloudflare/import-telemetry-cleanup && npm install && npm run deploy:stg(本番は deploy:prd)+ wrangler secret put DATABASE_URL(各環境)。staging。push・MR はユーザー実施。単価 を priceColumns に入れず無視する例あり(原価 は検知)。お金の列を漏れなく拾うにはプロンプト強化の余地。現状は CSV/Excel 前提。だが現場の主流は「箱に入った納品書・出荷表を、フォーマットがバラバラのままスキャン/写真/PDF で投げる」になる見込み。今の構成で耐えるかを実コードで監査した結論を残す。
下流(正規化テーブル→確認→取込)は入力形式に非依存でそのまま再利用できる(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)決定的コード(既存資産)
taskDetailCsvParser が既に 取引先/入荷番号・出荷番号/入荷日・出荷日/アイテム名 を解釈(HEADER_CLIENT ほか)。clientImportUtils.resolveImportClient / resolveNewClientsByName(取込時に再検索・既存再利用で二重作成防止)。createImportItemsForNewRows(name/folderPath/attributeCells → itemId マップ)。useImportAttributePricing(入荷 PURCHASE / 出荷 SELLING)。ShippingTaskDB(clientId+キャッシュ・shipmentNumber・targetAt)+ tradeTaskCreateSchema。AiImageInput / OpenAiClient.toOpenAiMessage(画像→content配列・テスト済み)。image_repository / SingleImageUpload)。lookupReuse は where:{companyId, mode, status:'committed'} のみで絞り、取引先もレイアウトも見ない。[品名,数量,単価] のような汎用ヘッダーは別取引先でも重なり率 1.0 → A社のマッピングをZ社の伝票に誤適用しうる。applyReuseDecisions は新列/itemName欠落しか検証せず属性整合を見ない。headerFingerprint は保存済みなのに lookup で未使用(列の並び替えも素通り)。
| スライス | 内容 | 工数 |
|---|---|---|
| G0 安全網 | S6誤マッチのガード(汎用/短ヘッダー時は確認を強制 or 取引先未確定なら再利用を提示しない)。先行する小さな保険 | 小 |
| G1 入荷/出荷AIフロー | mode:'stock' 固定を解除し、AI会話フローを入荷/出荷へ。既存パーサ概念+createImportItemsForNewRows+取引先解決+価格ロール+タスクグルーピングに橋渡し。CSV入荷/出荷でも価値が出る本丸 | 大 |
| G2 documentFields | AnalyzeResult に文書フィールド(取引先名/伝票番号/日付)を追加。vision抽出が「文書フィールド+明細表」を返す。ShippingTaskDB へ既存の取引先解決で接続 | 中 |
| G3 vision 入口 | accept 拡張+PDFページ画像化+抽出プロンプト。アダプタ既存ゆえ小 | 小 |
| G4 再利用の再キー化 | 再利用を取引先(clientId)+レイアウト指紋キーへ。誤マッチ解消+仕入先別の“使うほど楽” | 中 |
推奨順序:G0(保険・小)→ G1(本丸・大)→ G2 → G3 → G4。vision(G3) は最小、本丸は G1。詳細計画:~/.claude/plans/ai-import-document-intake.md。