db(p11): rag_query_log + learning_episodes — RAG 自主學習迴圈基礎
All checks were successful
CD Pipeline / deploy (push) Successful in 3m30s

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) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-03 23:39:47 +08:00
parent 943de8466c
commit 2f20d8d7ba
3 changed files with 502 additions and 0 deletions

View File

@@ -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-029Hermes-First、ADR-002pgvector 唯一向量庫、ADR-007pgvector 啟用)
- **前置 migration**: 024ai_calls、025mcp_calls + ai_call_budgets、026embedding_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;
```
**為何不用 HNSW009 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. 風險評估
### R1HIGH—— 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 追蹤誰查過
### R2HIGH—— 蒸餾失誤污染 RAG
- **風險**:低品質 `learning_episodes` 過閘晉升 `ai_insights` → RAG 召回幻覺擴散
- **緩解**
1. PromotionGate 4 階段quality / hallucination / duplicate / human
2. `weight>=0.8` 強制人工驗收chk_le_review_consistent
3. `rejected_*` 必填 rejected_reasonchk_le_rejected_reason事後可審計
### R3MEDIUM—— ivfflat 索引膨脹 / 退化
- **風險**:高頻寫入 + 不重訓 → recall 退化
- **緩解**
1. partial index `WHERE query_embedding IS NOT NULL` 縮體積
2. monthly REINDEX CONCURRENTLY見上 §2 SOP
3. EXPLAIN ANALYZE alarmcost > baseline 5x 時告警)
### R4MEDIUM—— 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 JOINdangling 顯示為 "已歸檔"
### R5LOW—— 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 再加)
- **接受該風險**
### R6LOW—— 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

View File

@@ -0,0 +1,126 @@
-- =============================================================================
-- Migration 027: rag_query_log — RAG 查詢遙測 (audit log)
-- Operation Ollama-First v5.0 — Phase 11
-- 日期: 2026-05-03 台北
-- 對應戰役: ADR-029Hermes-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方便事後召回率分析
-- (不加 FKai_insights 可能 archive避免 cascade
-- 4. query_text 限 4KBquery_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 embedding1024 維,與 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-5NULL = 未反饋)
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 ivfflatcosine 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 $$;

View File

@@ -0,0 +1,169 @@
-- =============================================================================
-- Migration 028: learning_episodes — 蒸餾池 / 知識庫前哨
-- Operation Ollama-First v5.0 — Phase 11
-- 日期: 2026-05-03 台北
-- 對應戰役: ADR-029Hermes-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
-- 蒸餾後的精煉文本≤16KBraw 不存在此表,由 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 ivfflatStage 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 狀態降權 worker24h 無反饋)由 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 $$;