TL;DR 再利用(前回の取り込みをルールだけ覚える)・コスト制御(会社×日の上限ゲート)・プライバシー(PIIを貯めない多重防御)の3つの横断テーマ。 共通する思想は「行の値(=お客さんの生データ)を貯めない/漏らさない側に倒す」。再利用もテレメトリもルールと件数だけを持ち、コストは原子的カウントで青天井を防ぐ。
機能の本筋(解析→会話→取り込み)の外側で、全体を支える「守り」が3つあります。いずれも「お客さんの生データを最小限しか持たない」という1つの価値観から出ています。
同じ取引先・同じ様式のファイルを毎回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解析へフォールバック。 |
@@index([companyId, mode, clientId]) で同じ取引先の同じ様式だけを引く。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(チェックと消費のすき間)を排除します。
| 機能 | 上限/日/社 | 方式 |
|---|---|---|
| analyze | 200 | importSessionDB.count(当日・失敗除く) |
| extract(vision) | 200 | consumeAiDailyQuota('extract') |
| interpret-column | 1000 | consumeAiDailyQuota('interpret') |
true を返し本体を止めない。「ゲートはコストの保険であって、機能の必須要件ではない」という割り切り。逆にanalyzeは429判定がセッション作成の前にあり、成功に至ったセッション数で数える(fail-closed寄り)。dayKey はJSTの yyyy-MM-dd で日次リセット。機能を改善するには「どんなデータで何が起きたか」の記録が要ります。が、それがお客さんの生データを溜め込む穴になっては本末転倒。maskTelemetry.ts(サーバー専用)が「漏らさない側に倒す」設計で記録します。
| 関数 | 何をするか |
|---|---|
maskTelemetryValue | 値を sha256 先頭12桁の #... に決定的マスク(復元不可)。同値→同マスクなので「種類の多さ」傾向は残る。 |
detectPiiColumnsHeuristic | ヘッダー名がPII語(氏名/取引先/電話/住所/メール…)に一致、または「text かつ充填率≥0.5 かつ ほぼ一意」ならPII。迷ったら伏せる(偽陽性優先)。 |
resolvePiiColumns | AIが返したPII列 ∪ ヒューリスティック = マスク対象の最終集合。 |
buildTelemetrySamples | 最大30行のサンプル。PII列だけマスク、それ以外は構造把握用に素の値。 |
sanitizeAnalyzeResultForTelemetry | 方針をallow-list で「構造・件数のみ」に射影。取引先名・選択肢の値・自由記述・理由文は落とす。選択肢は件数だけ。 |
aiTelemetryOptOut なら何も記録しない ② append-only($transaction 不使用)③ 失敗は握り潰す(テレメトリで本体を絶対に落とさない)。commitの会話ログは「列ヘッダー名+決定ラベルのみ・原文値なし」。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 のキー。セッションに紐づかないので全経路をカバー。「将来のメータリング層の種」。
cloudflare/import-telemetry-cleanup は Cloudflare Cron Trigger のワーカー。ImportTelemetryDB の90日超レコードを毎日削除します。
deleteMany、最大100バッチ(=1回最大10万件で暴走止め)。where: { createdAt: { lt: cutoff } } で @@index([createdAt]) が効く。npm install + deploy:stg + secret DATABASE_URL が残作業)。また将来 AiUsageCounterDB の古い行も同じ cron で掃除する案がある(行は極小なので低優先)。「お客さんの生データを貯めない/漏らさない」が、独立した複数の層で守られています。1つ破れても次が止める多重防御。
store:false(会話を保存させない)。data:image/ のみ)。ReuseDecisions は構造的に行の値を持てない(ルールと選択肢定義だけ)。db:push →PlanetScale Deploy Request(AiUsageCounterDB 反映)、cron worker のデプロイ、push/MR(GitLab・Target staging)。コードは fail-open 設計なのでDB反映前でも動きます(クォータ発火だけが反映後)。