Reports

TL;DR 再利用(前回の取り込みをルールだけ覚える)・コスト制御(会社×日の上限ゲート)・プライバシー(PIIを貯めない多重防御)の3つの横断テーマ。 共通する思想は「行の値(=お客さんの生データ)を貯めない/漏らさない側に倒す」。再利用もテレメトリもルールと件数だけを持ち、コストは原子的カウントで青天井を防ぐ。

目次 §1 3つの横断テーマ §2 形式別再利用 §3 コスト制御 §4 テレメトリとPIIマスク §5 DBモデル §6 cron §7 プライバシー多重防御
§1 3つの横断テーマ

機能の本筋(解析→会話→取り込み)の外側で、全体を支える「守り」が3つあります。いずれも「お客さんの生データを最小限しか持たない」という1つの価値観から出ています。

再利用
「前回と同じ形式」を覚えて2回目以降の質問を省く。覚えるのはルールだけ、行の値は持たない。
コスト制御
有料LLMの呼び出し回数を会社×日で上限管理。高額機能の暴走を止める防波堤。
プライバシー
改善用テレメトリはPIIをマスク・件数化し、別テーブルで90日だけ保持。opt-out可。
§2 形式別再利用(前回の取り込みを覚える)

同じ取引先・同じ様式のファイルを毎回1問ずつ確認するのは無駄。そこで確定内容を「ルールだけ」に圧縮して保存し、次回は照合して質問を飛ばします。3つの純関数が担当します。

関数役割
computeHeaderFingerprint / normalizeHeaderTokens / headerSetOverlap列名の指紋と重なり率(|A∩B| / max(|A|,|B|))。0.8以上で「同じ形式」。アイテム名照合と同じ正規化を使い、表記ゆれで別物にしない。
buildReuseDecisions確定内容 → ReuseDecisions(version / mode / headerTokens / columns / attributes / transforms / prices)。行の値・サンプルは一切参照しない。
applyReuseDecisions前回ルールを今回データに当て、AI解析と同じ形(AnalyzeResult相当)を返す。当てられなければ ok:false でAI解析へフォールバック。

データフロー

確定
buildReuseDecisions
ルール化
保存
commit → ImportSession.decisions.reuse
(入荷出荷は clientId も)
次回ドロップ
lookup
指紋+重なり率で照合
適用
applyReuseDecisions
ok:false ならAI解析へ
取引先キー(clientId)で誤マッチ防止: 入荷/出荷は列名だけだと「アイテム名/数量/単価」のような汎用ヘッダーが別の取引先の伝票にも当たってしまう。だから再利用の保存・照合は取引先が解決できる時だけ有効化し、@@index([companyId, mode, clientId]) で同じ取引先の同じ様式だけを引く。
孤児属性ガード: 前回の commit 後に削除/改名された属性へ書き込む事故を防ぐため、現存属性∪今回新規 に無い属性列は黙って当てず ignore に落とす(price/transform からも除外して整合)。
§3 コスト制御(暴走の防波堤)

LLMは呼ぶたびにお金がかかります。特に vision(gpt-4o)は1回が高額。万一バグや連打で呼び出しが暴走しても被害を限定するため、会社×日×機能の日次上限ゲートを置いています。

consumeAiDailyQuota の仕組み

// ① 行を用意(既存なら無視)
INSERT IGNORE INTO AiUsageCounterDB (id, companyId, dayKey, feature, count=0, ...)
// ② 条件付きUPDATE が「原子的ゲート」になる
UPDATE AiUsageCounterDB SET count = count + 1
  WHERE companyId=? AND dayKey=? AND feature=? AND count < cap

count < cap 付きのUPDATEで更新できた(affected>0)なら許可、上限到達で0行更新なら拒否(429)。これでTOCTOU(チェックと消費のすき間)を排除します。

機能上限/日/社方式
analyze200importSessionDB.count(当日・失敗除く)
extract(vision)200consumeAiDailyQuota('extract')
interpret-column1000consumeAiDailyQuota('interpret')
fail-open(失敗時は通す): カウンタ表が未デプロイ等でSQLが失敗しても、warnを出して true を返し本体を止めない。「ゲートはコストの保険であって、機能の必須要件ではない」という割り切り。逆にanalyzeは429判定がセッション作成の前にあり、成功に至ったセッション数で数える(fail-closed寄り)。
既知の非対称: クォータが2系統(analyze=セッション数え/interpret・extract=カウンタ)に分かれている。コメント上は将来「コスト制御ゲートウェイの利用台帳」へ統一する意図。dayKey はJSTの yyyy-MM-dd で日次リセット。
§4 テレメトリとPIIマスク(改善のための記録)

機能を改善するには「どんなデータで何が起きたか」の記録が要ります。が、それがお客さんの生データを溜め込む穴になっては本末転倒。maskTelemetry.ts(サーバー専用)が「漏らさない側に倒す」設計で記録します。

関数何をするか
maskTelemetryValue値を sha256 先頭12桁の #... に決定的マスク(復元不可)。同値→同マスクなので「種類の多さ」傾向は残る。
detectPiiColumnsHeuristicヘッダー名がPII語(氏名/取引先/電話/住所/メール…)に一致、または「text かつ充填率≥0.5 かつ ほぼ一意」ならPII。迷ったら伏せる(偽陽性優先)。
resolvePiiColumnsAIが返したPII列 ∪ ヒューリスティック = マスク対象の最終集合。
buildTelemetrySamples最大30行のサンプル。PII列だけマスク、それ以外は構造把握用に素の値。
sanitizeAnalyzeResultForTelemetry方針をallow-list で「構造・件数のみ」に射影。取引先名・選択肢の値・自由記述・理由文は落とす。選択肢は件数だけ。
記録の鉄則3つ(analyze/commit 共通): ① 会社が aiTelemetryOptOut なら何も記録しない ② append-only($transaction 不使用)③ 失敗は握り潰す(テレメトリで本体を絶対に落とさない)。commitの会話ログは「列ヘッダー名+決定ラベルのみ・原文値なし」。
omit(特定フィールドを除く)でなく allow-list(残すものを列挙)にしているのは、将来フィールドが増えても既定で漏らさないため。「うっかり新フィールドが素通りする」事故を構造的に防ぐ。
§5 DBモデル(追加された3つ)

ImportSessionDB(1回の取り込みセッション)

会話の決定・transformRules・統合プラン(decisions JSON)を持ち、リロード復帰や再利用の源泉になる。本ブランチで clientId(再利用の取引先キー)を追加し、@@index([companyId, mode, clientId]) を張った。status は文字列で draft → awaiting_confirm → committed(失敗は failed)。

ImportTelemetryDB(改善用・別テーブル)

本体と分離した記録専用テーブル。mode / phase(analyze|commit) / samples(PIIマスク済) / conversation(値なし) / analyzeResult(構造のみ) / metrics / piiMasked。@@index([createdAt]) は cron 削除用。companyId のみ onDelete: Cascade、sessionId は FK を張らず index のみ。

AiUsageCounterDB(コスト制御)

会社×日×機能の利用回数。@@unique([companyId, dayKey, feature]) が INSERT IGNORE のキー。セッションに紐づかないので全経路をカバー。「将来のメータリング層の種」。

テレメトリ/カウンタが FKを張らないのは、別テーブル運用と cron 削除の柔軟性のため。参照整合性は Prisma 層に留める割り切り。
§6 cron(古いテレメトリの掃除)

cloudflare/import-telemetry-cleanup は Cloudflare Cron Trigger のワーカー。ImportTelemetryDB の90日超レコードを毎日削除します。

未デプロイ: この cron worker はまだ稼働していない(npm install + deploy:stg + secret DATABASE_URL が残作業)。また将来 AiUsageCounterDB の古い行も同じ cron で掃除する案がある(行は極小なので低優先)。
§7 プライバシー多重防御まとめ

「お客さんの生データを貯めない/漏らさない」が、独立した複数の層で守られています。1つ破れても次が止める多重防御。

  1. 解析自体: 全行をAIに渡さず要約だけ。OpenAI呼び出しは store:false(会話を保存させない)。
  2. 抽出(extract): 読み取るだけで保存しない。任意URLを拒否(data:image/ のみ)。
  3. 再利用: ReuseDecisions は構造的に行の値を持てない(ルールと選択肢定義だけ)。
  4. テレメトリ: opt-out尊重・決定的マスク・allow-list射影・ヒューリスティック広め検出・別テーブル90日削除。
  5. 越境防止: 全APIで会社メンバーシップ検証(IDOR対策)。再利用の取引先キーも自社所属を検証。
本ブランチの残作業はデプロイ系のみ: db:push →PlanetScale Deploy Request(AiUsageCounterDB 反映)、cron worker のデプロイ、push/MR(GitLab・Target staging)。コードは fail-open 設計なのでDB反映前でも動きます(クォータ発火だけが反映後)。