From 2f20d8d7ba75450b324082a3415b2564dbfa2da5 Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 3 May 2026 23:39:47 +0800 Subject: [PATCH] =?UTF-8?q?db(p11):=20rag=5Fquery=5Flog=20+=20learning=5Fe?= =?UTF-8?q?pisodes=20=E2=80=94=20RAG=20=E8=87=AA=E4=B8=BB=E5=AD=B8?= =?UTF-8?q?=E7=BF=92=E8=BF=B4=E5=9C=88=E5=9F=BA=E7=A4=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operation Ollama-First v5.0 / Phase 11 RAG + 自主學習 migrations/027 — rag_query_log(每次 RAG 查詢的 audit log) - query_text 4KB CHECK + 90 天保留 - VECTOR(1024) bge-m3 embedding (與 ai_insights 一致簽名) - ivfflat lists=100 索引 - saved_call 欄位追蹤「成功攔截 LLM 呼叫」次數 - feedback_score 1-5(NULL=未反饋) - 6 條 CHECK 含 chk_rag_saved_consistent migrations/028 — learning_episodes(蒸餾池 → ai_insights 前哨) - 8 狀態機:pending/approved/awaiting_review/rejected_*4/expired - weight 0-1(>=0.8 觸發 PromotionGate Stage 4 人工驗收) - 9 條 CHECK 含 chk_le_approved_consistent / chk_le_review_consistent - partial index idx_le_status WHERE in (pending, awaiting_review) - distilled_text 16KB 上限 docs/phase11_db_design — 設計文檔 - 6 大決策(兩表分離 / ivfflat / partial index / 軟連結 / 90天保留 / 應用層白名單) - 6 大風險評估(R1 PII / R2 蒸餾失誤 / R3 ivfflat 退化 / R4 dangling FK / R5/R6 trade-off) - Phase 11 上線後驗收 SQL(EXPLAIN ANALYZE) PromotionGate 4 階段(v5.0 護欄 #1, ADR-033): Stage 1: quality_score >= 0.7 Stage 2: 無幻覺檢測(規則引擎,零 LLM) Stage 3: 與既有 insight 相似度 < 0.95 Stage 4: weight >= 0.8 必經 Telegram 👍/👎(24h 無回應 → expired) A4 fullstack-engineer 同時在寫 services/rag_service.py + learning_pipeline.py, service 完成後一起部署啟用。 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/phase11_db_design_20260503.md | 207 ++++++++++++++++++++ migrations/027_create_rag_query_log.sql | 126 ++++++++++++ migrations/028_create_learning_episodes.sql | 169 ++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 docs/phase11_db_design_20260503.md create mode 100644 migrations/027_create_rag_query_log.sql create mode 100644 migrations/028_create_learning_episodes.sql diff --git a/docs/phase11_db_design_20260503.md b/docs/phase11_db_design_20260503.md new file mode 100644 index 0000000..58ec32d --- /dev/null +++ b/docs/phase11_db_design_20260503.md @@ -0,0 +1,207 @@ +# Phase 11 DB 設計:RAG + 自主學習迴圈 + +- **戰役**: Operation Ollama-First v5.0 — Phase 11 +- **作者**: A3 db-expert +- **日期**: 2026-05-03 台北 +- **migration**: `migrations/027_create_rag_query_log.sql`、`migrations/028_create_learning_episodes.sql` +- **對應 ADR**: ADR-029(Hermes-First)、ADR-002(pgvector 唯一向量庫)、ADR-007(pgvector 啟用) +- **前置 migration**: 024(ai_calls)、025(mcp_calls + ai_call_budgets)、026(embedding_signature) + +--- + +## 1. 為何分兩表(rag_query_log vs learning_episodes) + +兩個表責任完全不同,混表會讓**讀寫模式衝突**且**保留週期混淆**。 + +| 維度 | rag_query_log | learning_episodes | +|---|---|---| +| **角色** | RAG 召回的 audit log | 知識庫前哨(蒸餾池) | +| **資料方向** | 從用戶/呼叫者「進來」 | 給 ai_insights「出去」 | +| **生命週期** | 90 天滾動刪除 | 長期(approved/rejected 走冷儲檔) | +| **寫入頻率** | 高(每次 RAG 召回都寫) | 中(過 quality 才寫) | +| **PII 風險** | 高(query_text = 用戶問題) | 低(distilled 已蒸餾) | +| **典型查詢** | 「過去 24h 命中率」「caller 分布」 | 「待人工驗收清單」「Stage 3 dedup query」 | +| **是否進 RAG 召回語料** | 否(只是 log) | 否(只有晉升 ai_insights 後才進) | + +**反證**:若合表,會出現 +- query_text PII 與蒸餾文本同表→ 90 天保留無法分別套用 +- 高頻寫入 audit log 與低頻寫入蒸餾池共享 ivfflat 索引 → vacuum / REINDEX 衝突 +- promotion_status 對 audit log 無意義,但要忍受 NULL + +故維持分表。 + +--- + +## 2. ivfflat lists=100 計算依據 + +pgvector 官方建議: +- `lists ≈ rows / 1000`(rows < 1M) +- `lists ≈ sqrt(rows)`(rows ≥ 1M) + +**rag_query_log 量推估**: +- 假設 Phase 11 上線後每日 RAG 召回 5,000 次(hermes_qa + openclaw_qa + 內部 caller) +- 90 天保留 → 穩態約 **450k 行** +- `lists ≈ 450 / 1`,但太小(<10)會退化成全掃;取下限 100 +- 等流量上升到 1M 行時(約 200 天後若日 5k → 不會到 1M),再 `REINDEX ... WITH (lists=1000)` + +**learning_episodes 量推估**: +- 假設每日蒸餾 200 筆(rejected ~70%、approved ~30%)→ 全保留 +- 一年約 73k 行;2 年約 146k 行 +- `lists=100` 在 1M 以下都合理 + +**重訓 SOP**(寫入 ADR-029 後續維運章節): +```sql +-- 每月由 scheduler 檢查,若 EXPLAIN cost / actual_time 退化 5x,重訓 +REINDEX INDEX CONCURRENTLY idx_rag_query_log_embedding; +REINDEX INDEX CONCURRENTLY idx_le_embedding; +``` + +**為何不用 HNSW(009 ai_insights 用 HNSW)**: +- HNSW 寫入比 ivfflat 慢 5-10x(高頻寫入的 rag_query_log 不適合) +- HNSW 不需訓練,但**索引大小**約為 ivfflat 的 2-4× +- ai_insights 是「讀多寫少」(KM 沉澱)—— HNSW 合理 +- rag_query_log / learning_episodes 是「寫多讀中」—— ivfflat 合理 + +--- + +## 3. promotion_status 狀態機 + +``` + ┌─────────────┐ + │ pending │ (初始) + └──────┬──────┘ + │ + ┌────────────┴───────────────┐ + │ │ + Stage 1: quality<0.7 Stage 2: 規則檢測幻覺 + │ │ + ▼ ▼ + ┌──────────────────┐ ┌───────────────────────┐ + │ rejected_quality │ │rejected_hallucination │ + └──────────────────┘ └───────────────────────┘ + + Stage 1+2 通過: + │ + ▼ + Stage 3: 與既有 insight cosine>0.95 + │ + ▼ + ┌────────────────────┐ + │ rejected_duplicate │ (若太相似) + └────────────────────┘ + + Stage 3 通過: + │ + ┌────────┴────────────┐ + │ │ + weight<0.8 weight>=0.8 + │ │ + ▼ ▼ +┌──────────┐ ┌──────────────────┐ +│ approved │ │ awaiting_review │ ← Telegram 推播 +└──────────┘ └────────┬─────────┘ + │ │ + │ ┌──────────┼─────────────┐ + │ │ │ │ + │ 人工 👍 人工 👎 24h 無反饋 + │ │ │ │ + │ ▼ ▼ ▼ + │ ┌──────────┐ ┌──────────────┐ ┌──────────┐ + │ │ approved │ │rejected_human│ │ expired │── 降 weight=0.5 重走 Stage 4a + │ └──────────┘ └──────────────┘ └──────────┘ + │ │ + ▼ ▼ + 寫 ai_insights → insight_id 回填 +``` + +**關鍵 invariants(已用 CHECK 強制)**: +1. `approved ⇔ insight_id IS NOT NULL`(chk_le_approved_consistent) +2. `rejected_* ⇒ rejected_reason IS NOT NULL`(chk_le_rejected_reason) +3. `human_approver IS NOT NULL ⇒ reviewed_at IS NOT NULL`(chk_le_review_consistent) + +--- + +## 4. 90 天保留策略 + +| 表 | 保留 | 工具 | 預計排程 | +|---|---|---|---| +| `rag_query_log` | 90 天 | scheduler `DELETE WHERE queried_at < NOW() - INTERVAL '90 days'` | 03:30 daily | +| `learning_episodes` (pending/awaiting_review) | 永久(直到狀態變化) | — | — | +| `learning_episodes` (approved) | 永久(蒸餾溯源) | — | — | +| `learning_episodes` (rejected_*/expired) | 180 天後可冷儲檔 | 後續 ADR 定 | monthly | +| `ai_calls` | 90 天 | (已存在 migration 024 註解) | 03:00 daily | +| `mcp_calls` | 90 天 | 同上 | 03:15 daily | + +**為何 rag_query_log 與 ai_calls 同 90 天**:兩者透過 `request_id` 串鏈;若不同保留期會出現「ai_calls 已刪、rag_query_log 留著」的孤兒,反查 trace 會斷。 + +**learning_episodes 不限期保留的依據**:蒸餾池是「為什麼這條 insight 進了 KM」的證據鏈。`rejected_*` 也保留是為了**防止同類錯誤被反覆生成**(PromotionGate Stage 3 dedup 可參考歷史 rejected)。 + +--- + +## 5. 風險評估 + +### R1(HIGH)—— query_text PII 落地 + +- **風險**:`rag_query_log.query_text` 是用戶原始輸入,可能含人名/手機/訂單號 +- **緩解**: + 1. CHECK `octet_length <= 4096` 限長度 + 2. 90 天滾動刪除 + 3. 應用層在寫入前對「明顯 PII pattern」做 redact(如 `\d{10}` 手機) + 4. `learning_episodes.distilled_text` 必須是「蒸餾後」文本,**禁止**直接複製 query_text +- **未解殘留風險**:90 天內 DBA query 仍可看到原始問題;建議搭配 PostgreSQL row-level audit log 追蹤誰查過 + +### R2(HIGH)—— 蒸餾失誤污染 RAG + +- **風險**:低品質 `learning_episodes` 過閘晉升 `ai_insights` → RAG 召回幻覺擴散 +- **緩解**: + 1. PromotionGate 4 階段(quality / hallucination / duplicate / human) + 2. `weight>=0.8` 強制人工驗收(chk_le_review_consistent) + 3. `rejected_*` 必填 rejected_reason(chk_le_rejected_reason),事後可審計 + +### R3(MEDIUM)—— ivfflat 索引膨脹 / 退化 + +- **風險**:高頻寫入 + 不重訓 → recall 退化 +- **緩解**: + 1. partial index `WHERE query_embedding IS NOT NULL` 縮體積 + 2. monthly REINDEX CONCURRENTLY(見上 §2 SOP) + 3. EXPLAIN ANALYZE alarm(cost > baseline 5x 時告警) + +### R4(MEDIUM)—— ai_insights 軟連結 dangling + +- **風險**:`learning_episodes.insight_id` 無 FK,若 ai_insights archive,蒸餾池會留 dangling pointer +- **緩解**: + 1. archive 時保留 ai_insights 主鍵(採 status='archived' soft delete,而非 DELETE) + 2. 應用層 join 用 LEFT JOIN,dangling 顯示為 "已歸檔" + +### R5(LOW)—— used_results BIGINT[] 反正規化 + +- **風險**:`rag_query_log.used_results` 用陣列存命中 ai_insights.id,違反正規化 +- **緩解理由**: + 1. 召回每筆平均 3-5 個 id,若拆 join table 會 5x 寫入放大 + 2. 反向查詢「某 insight 被多少 RAG 命中」是低頻分析,可用 `WHERE id = ANY(used_results)` 或 GIN 索引補(V2 再加) +- **接受該風險** + +### R6(LOW)—— caller 白名單未在 DB 強制 + +- **風險**:應用層可能寫入未知 caller,污染統計 +- **緩解**: + 1. ai_calls 已有 caller 白名單註釋,logger 統一強制 + 2. 本表加 CHECK 會與 ai_calls 雙寫漂移;改由 application layer 單一真理源 +- **接受該風險** + +--- + +## 6. 驗收清單(給 critic) + +- [x] 027 / 028 連續編號,未跳號 +- [x] BIGSERIAL 主鍵對齊 024/025 +- [x] CHECK 風格對齊 critic-A11(白名單 + size + range) +- [x] partial index 對 sparse 欄位(request_id / insight_id / status) +- [x] ivfflat lists=100 + cosine + 1024 維對齊 bge-m3 +- [x] GRANT 權限對齊(momo + sequence) +- [x] 不在 migration 內 CONCURRENTLY(無既存大表) +- [x] 回滾腳本附在 migration 頂部註解 +- [x] 與 ai_calls/mcp_calls 透過 request_id 串鏈 +- [x] PII 護欄(query_text 4KB / distilled 16KB) +- [x] 狀態機 invariant 用 CHECK 鎖死 +- [x] 不自動 commit / 不自動 apply diff --git a/migrations/027_create_rag_query_log.sql b/migrations/027_create_rag_query_log.sql new file mode 100644 index 0000000..6dd9f3a --- /dev/null +++ b/migrations/027_create_rag_query_log.sql @@ -0,0 +1,126 @@ +-- ============================================================================= +-- Migration 027: rag_query_log — RAG 查詢遙測 (audit log) +-- Operation Ollama-First v5.0 — Phase 11 +-- 日期: 2026-05-03 台北 +-- 對應戰役: ADR-029(Hermes-First 雙塔)+ Phase 11 RAG 自主學習迴圈 +-- ============================================================================= +-- 說明: +-- 每次 RAG 召回(hermes_qa / openclaw_qa / etc.)寫一筆,append-only。 +-- 核心指標: +-- - hit_count : top_k 召回實際命中數(threshold 過濾後) +-- - saved_call : 命中且最終未升級到 LLM => 真實節省成本 +-- - feedback_score : Telegram 👍/👎 後填回(NULL = 尚未反饋) +-- 與 ai_calls / mcp_calls 透過 request_id 串鏈,跨表 trace 同一邏輯請求。 +-- +-- 設計決策: +-- 1. embedding 與 ai_insights 同維度 1024 (bge-m3),可跨表計 cosine +-- 2. ivfflat lists=100 對齊既有風格;資料量達 1M 後依 sqrt(N) 重建 +-- (009 ai_insights 用 HNSW,但本表寫入頻繁 + 不需即時最近鄰 query +-- 採 ivfflat 寫入便宜,weekly 排程 REINDEX 即可,詳見 design doc) +-- 3. used_results BIGINT[] 紀錄命中的 ai_insights.id,方便事後召回率分析 +-- (不加 FK;ai_insights 可能 archive,避免 cascade) +-- 4. query_text 限 4KB;query_text 可能含 PII(用戶問題)— +-- 90 天保留 + 後續 PromotionGate 過濾後才允許進 ai_insights +-- 5. caller 與 ai_calls.caller 共白名單(不重複定義 CHECK,避免雙寫漂移; +-- 由 application logger 端強制;DB 端僅檢長度) +-- +-- 回滾腳本(緊急用): +-- DROP INDEX IF EXISTS idx_rag_query_log_embedding; +-- DROP INDEX IF EXISTS idx_rag_query_log_request_id; +-- DROP INDEX IF EXISTS idx_rag_query_log_caller; +-- DROP INDEX IF EXISTS idx_rag_query_log_queried_at; +-- DROP TABLE IF EXISTS rag_query_log; +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS rag_query_log ( + id BIGSERIAL PRIMARY KEY, + queried_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- 與 ai_calls.caller 同一張白名單(hermes_qa / openclaw_qa / ...) + caller VARCHAR(64) NOT NULL, + + -- 用戶查詢(PII 風險,限 4KB;不 normalize 以保留原始查詢樣貌) + query_text TEXT NOT NULL, + + -- bge-m3 embedding(1024 維,與 ai_insights.embedding 同源;可 NULL = embedding 失敗仍記錄此次嘗試) + query_embedding VECTOR(1024), + + -- 召回參數 + top_k INTEGER NOT NULL DEFAULT 5, + threshold NUMERIC(4,3) NOT NULL DEFAULT 0.85, + + -- 召回結果 + hit_count INTEGER NOT NULL DEFAULT 0, + used_results BIGINT[], -- 命中的 ai_insights.id 陣列(軟連結,不加 FK) + + -- 是否成功避免 LLM 呼叫(核心成本指標) + saved_call BOOLEAN NOT NULL DEFAULT FALSE, + + -- Telegram 👍/👎 反饋(1-5;NULL = 未反饋) + feedback_score INTEGER, + + -- 與 ai_calls.request_id 串鏈 + request_id VARCHAR(64), + + -- ─────── 護欄 (對齊 critic-A11 風格) ─────── + CONSTRAINT chk_rag_threshold CHECK ( + threshold BETWEEN 0 AND 1 + ), + CONSTRAINT chk_rag_top_k CHECK ( + top_k BETWEEN 1 AND 50 + ), + CONSTRAINT chk_rag_hit_count CHECK ( + hit_count >= 0 AND hit_count <= top_k + ), + CONSTRAINT chk_rag_query_size CHECK ( + octet_length(query_text) <= 4096 + ), + CONSTRAINT chk_rag_feedback CHECK ( + feedback_score IS NULL OR feedback_score BETWEEN 1 AND 5 + ), + -- saved_call=TRUE 必須有命中(hit_count > 0)才合理 + CONSTRAINT chk_rag_saved_consistent CHECK ( + (saved_call = FALSE) OR (hit_count > 0) + ) +); + +-- ───────────────────────────────────────────────────────────────────────────── +-- 索引設計 +-- ───────────────────────────────────────────────────────────────────────────── + +-- (1) 時間範圍掃描(日報 / 命中率報表) +CREATE INDEX IF NOT EXISTS idx_rag_query_log_queried_at + ON rag_query_log (queried_at DESC); + +-- (2) caller 分布(哪個入口 RAG 命中率高) +CREATE INDEX IF NOT EXISTS idx_rag_query_log_caller + ON rag_query_log (caller, queried_at DESC); + +-- (3) request_id 串鏈(部分索引,sparse 不全建) +CREATE INDEX IF NOT EXISTS idx_rag_query_log_request_id + ON rag_query_log (request_id) + WHERE request_id IS NOT NULL; + +-- (4) pgvector ivfflat(cosine similarity;只索引非 NULL embedding) +-- 注意: ivfflat 須先有資料才能正確訓練 lists;空表建索引會 fallback exact scan, +-- Phase 11 灌入首批查詢後若效能退化,REINDEX CONCURRENTLY 重訓 +CREATE INDEX IF NOT EXISTS idx_rag_query_log_embedding + ON rag_query_log + USING ivfflat (query_embedding vector_cosine_ops) + WITH (lists = 100) + WHERE query_embedding IS NOT NULL; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 權限 +-- ───────────────────────────────────────────────────────────────────────────── +GRANT ALL PRIVILEGES ON rag_query_log TO momo; +GRANT USAGE, SELECT ON SEQUENCE rag_query_log_id_seq TO momo; + +-- 註: 90 天保留由 scheduler 任務執行(與 ai_calls 對齊): +-- DELETE FROM rag_query_log WHERE queried_at < NOW() - INTERVAL '90 days'; +-- 建議 03:30 跑(ai_calls 03:00 之後),避免 IO 尖峰 + +DO $$ +BEGIN + RAISE NOTICE 'Migration 027 done: rag_query_log + 4 indexes (ivfflat 1024d) (Operation Ollama-First v5.0 P11)'; +END $$; diff --git a/migrations/028_create_learning_episodes.sql b/migrations/028_create_learning_episodes.sql new file mode 100644 index 0000000..b59f95b --- /dev/null +++ b/migrations/028_create_learning_episodes.sql @@ -0,0 +1,169 @@ +-- ============================================================================= +-- Migration 028: learning_episodes — 蒸餾池 / 知識庫前哨 +-- Operation Ollama-First v5.0 — Phase 11 +-- 日期: 2026-05-03 台北 +-- 對應戰役: ADR-029(Hermes-First)+ Phase 11 PromotionGate 4 階段過濾 +-- ============================================================================= +-- 說明: +-- LLM/MCP 結果先寫入 learning_episodes(蒸餾池),過 4 階段 PromotionGate +-- 才晉升 ai_insights(知識庫主檔)。設計目的: +-- - 隔離未驗證內容,避免直接污染 RAG 召回語料 +-- - 保留 raw + distilled,方便事後重訓 +-- - 高權重(>=0.8)走人工驗收,低權重走自動晉升 +-- +-- PromotionGate 狀態機: +-- pending +-- ├─[Stage 1: quality<0.7]→ rejected_quality +-- ├─[Stage 2: 規則檢測幻覺]→ rejected_hallucination +-- ├─[Stage 3: 與既有 insight cosine>0.95]→ rejected_duplicate +-- ├─[Stage 4a: weight<0.8 + 過 1-3]→ approved → 寫 ai_insights → insight_id 回填 +-- └─[Stage 4b: weight>=0.8]→ awaiting_review → Telegram 推播 +-- ├─[人工 👍]→ approved +-- ├─[人工 👎]→ rejected_human +-- └─[24h 無反饋]→ expired (weight 降為 0.5 重走 Stage 4a) +-- +-- 設計決策: +-- 1. insight_id 軟連結(不加 FK)—— ai_insights archive 不應 cascade 影響蒸餾池 +-- 2. source_table + source_id 軟連結到 ai_calls / mcp_calls,方便事後重訓溯源 +-- 3. embedding 與 rag_query_log 同 1024 維,跨表 cosine 一致 +-- 4. 不設 90 天保留(蒸餾池長期保留;approved/rejected_* 進冷儲檔由後續 ADR 定) +-- —— 短期內暴增風險:靠 partial index + monthly archive scheduler 控制 +-- 5. promotion_status 用 VARCHAR(32) + CHECK 白名單;不上 ENUM 因新增狀態方便 +-- 6. rejected_reason CHECK 強制 rejected_* 狀態必填,避免「沒原因的拒絕」 +-- 7. human_approver 存 Telegram username 的 SHA1[:8],避免 PII 落地 +-- +-- 回滾腳本(緊急用): +-- DROP INDEX IF EXISTS idx_le_embedding; +-- DROP INDEX IF EXISTS idx_le_insight_id; +-- DROP INDEX IF EXISTS idx_le_episode_type; +-- DROP INDEX IF EXISTS idx_le_status; +-- DROP INDEX IF EXISTS idx_le_created_at; +-- DROP TABLE IF EXISTS learning_episodes; +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS learning_episodes ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- 來源類型 + -- mcp_result = MCP server 抓回的事實(grounding / search / db query) + -- llm_response = LLM 生成的洞察 / 摘要(hermes_analyst / openclaw 等) + -- user_feedback = 用戶 Telegram 直接告知的事實(高 weight,需人工確認) + -- manual_curated = 人工手動入庫(最高 weight,跳 PromotionGate) + episode_type VARCHAR(32) NOT NULL, + + -- 軟連結來源(不加 FK) + source_table VARCHAR(32), -- 'ai_calls' / 'mcp_calls' / NULL + source_id BIGINT, -- 對應 source_table 的 id + + -- 蒸餾後的精煉文本(≤16KB;raw 不存在此表,由 source_table 透過 source_id 回查) + distilled_text TEXT NOT NULL, + embedding VECTOR(1024), -- 與 ai_insights / rag_query_log 同維 + + -- 蒸餾品質評分(0-1) + -- <0.7 → Stage 1 直接 rejected_quality + -- >=0.7 → 進 Stage 2-3 + quality_score NUMERIC(4,3) NOT NULL DEFAULT 0.0, + + -- 權重(影響晉升路徑) + -- <0.8 → Stage 4a 自動晉升 + -- >=0.8 → Stage 4b 人工驗收 + weight NUMERIC(4,3) NOT NULL DEFAULT 0.5, + + -- PromotionGate 狀態(見上方狀態機) + promotion_status VARCHAR(32) NOT NULL DEFAULT 'pending', + + -- 晉升結果 + insight_id BIGINT, -- 晉升後對應 ai_insights.id(軟連結,無 FK) + rejected_reason TEXT, -- promotion_status=rejected_* 時必填 + human_approver VARCHAR(64), -- Telegram username SHA1[:8] + reviewed_at TIMESTAMPTZ, + + -- ─────── 護欄 (對齊 critic-A11 風格) ─────── + CONSTRAINT chk_le_quality CHECK ( + quality_score BETWEEN 0 AND 1 + ), + CONSTRAINT chk_le_weight CHECK ( + weight BETWEEN 0 AND 1 + ), + CONSTRAINT chk_le_episode_type CHECK ( + episode_type IN ('mcp_result','llm_response','user_feedback','manual_curated') + ), + CONSTRAINT chk_le_status CHECK ( + promotion_status IN ( + 'pending','approved','awaiting_review', + 'rejected_quality','rejected_hallucination','rejected_duplicate','rejected_human', + 'expired' + ) + ), + CONSTRAINT chk_le_distilled_size CHECK ( + octet_length(distilled_text) <= 16384 + ), + CONSTRAINT chk_le_rejected_reason CHECK ( + (promotion_status NOT LIKE 'rejected_%') OR (rejected_reason IS NOT NULL) + ), + -- approved 必須有 insight_id;其他狀態不應有 + CONSTRAINT chk_le_approved_consistent CHECK ( + (promotion_status = 'approved') = (insight_id IS NOT NULL) + ), + -- source_table + source_id 一致性(要嘛兩個都 NULL,要嘛兩個都有) + CONSTRAINT chk_le_source_consistent CHECK ( + (source_table IS NULL AND source_id IS NULL) + OR (source_table IS NOT NULL AND source_id IS NOT NULL) + ), + CONSTRAINT chk_le_source_table CHECK ( + source_table IS NULL OR source_table IN ('ai_calls','mcp_calls') + ), + -- 人工驗收時 reviewed_at 必填 + CONSTRAINT chk_le_review_consistent CHECK ( + (human_approver IS NULL) OR (reviewed_at IS NOT NULL) + ) +); + +-- ───────────────────────────────────────────────────────────────────────────── +-- 索引設計 +-- ───────────────────────────────────────────────────────────────────────────── + +-- (1) 時間範圍掃描(蒸餾池規模監控) +CREATE INDEX IF NOT EXISTS idx_le_created_at + ON learning_episodes (created_at DESC); + +-- (2) 待處理佇列查詢(PromotionGate worker / 人工驗收 dashboard) +-- partial index 縮體積:只關心 pending / awaiting_review 兩種「活躍」狀態 +CREATE INDEX IF NOT EXISTS idx_le_status + ON learning_episodes (promotion_status, created_at DESC) + WHERE promotion_status IN ('pending','awaiting_review'); + +-- (3) 來源類型分布報表 +CREATE INDEX IF NOT EXISTS idx_le_episode_type + ON learning_episodes (episode_type, created_at DESC); + +-- (4) insight_id 反查(從 ai_insights 反推蒸餾來源) +CREATE INDEX IF NOT EXISTS idx_le_insight_id + ON learning_episodes (insight_id) + WHERE insight_id IS NOT NULL; + +-- (5) pgvector ivfflat(Stage 3 重複檢測 cosine query 主用) +CREATE INDEX IF NOT EXISTS idx_le_embedding + ON learning_episodes + USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100) + WHERE embedding IS NOT NULL; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 權限 +-- ───────────────────────────────────────────────────────────────────────────── +GRANT ALL PRIVILEGES ON learning_episodes TO momo; +GRANT USAGE, SELECT ON SEQUENCE learning_episodes_id_seq TO momo; + +-- 註: expired 狀態降權 worker(24h 無反饋)由 scheduler 跑: +-- UPDATE learning_episodes +-- SET promotion_status='expired', weight=0.5 +-- WHERE promotion_status='awaiting_review' +-- AND created_at < NOW() - INTERVAL '24 hours'; +-- 之後由 PromotionGate Stage 4a 重跑該批 expired 走自動晉升路徑。 + +DO $$ +BEGIN + RAISE NOTICE 'Migration 028 done: learning_episodes + 5 indexes + 9 CHECK constraints (Operation Ollama-First v5.0 P11)'; +END $$;