thread-master/docs/product/scout-topic-quality/spec.md

294 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spec: 話題海巡品質、分數排序與批次分頁
> Status: `approved`
> Source: `docs/product/scout-topic-quality/requirements.md`
> Last updated: `2026-08-10`
## 1. 摘要
每次話題海巡都建立獨立批次,以「去重並通過關聯性門檻的結果數」判斷是否達成目標;不足時按固定階段擴大候選,不以離題貼文補量。完成批次的結果固定以關聯 score 由高到低排列,貼文時間只作同分決勝,批次清單與批次結果均由伺服器提供獨立分頁。畫面同時明確區分貼文時間與掃入時間,並顯示批次目標、實得及不足原因。
## 2. 名詞與實體
| 名詞 | 定義 |
|------|------|
| 話題Theme | 使用者想參與的主題與可重用功課;以 `theme_key` 識別,不代表一次執行。 |
| 海巡批次Scout Run | 一次不可混同的海巡執行;每次執行都有新的 `run_id`,即使話題相同也不共用。 |
| 原始候選Raw candidate | 外部來源回傳、尚未經網址正規化、去重與關聯性判定的項目。 |
| canonical identity | 從 Threads 貼文 shortcode 得到的穩定身分不同網域、query string、fragment 或網址別名仍視為同一貼文。 |
| 合格結果Eligible result | canonical identity 唯一、未曾向同一使用者呈現,且通過話題核心與分類門檻的貼文;年代不作硬淘汰。 |
| 貼文時間(`posted_at` | Threads 原文發佈時間UTC unix nanoseconds來源無法可靠提供時為 0省略。 |
| 掃入時間(`created_at` | 合格結果寫入本系統的時間UTC unix nanoseconds不得冒充貼文時間。 |
| 目標數(`target_count` | 使用者希望該批次取得的合格結果數,預設 20最小 1、最大 40。 |
| 待處理數(`pending_count` | 整個批次中狀態為 `new``drafted` 的結果數,不受目前頁面影響。 |
| 不足原因 | 批次完成但合格數未達目標時,說明候選來源、重複、低關聯或安全上限造成的缺口。 |
### 2.1 Scout Run 欄位
| 欄位 | 型別 | 語意 |
|------|------|------|
| `id` | string | 每次執行唯一且不可重用的 run ID。 |
| `job_id` | string | 執行此批次的背景任務 ID。 |
| `theme_key``theme_label` | string | 可重用話題識別與顯示名稱。 |
| `intent``mode` | string | 本次不可變的原始意圖與海巡模式。 |
| `brand_id` | string? | 所屬品牌;純話題模式可省略。 |
| `target_count` | int | 正規化後的合格結果目標。 |
| `status` | enum | `queued`、`running`、`succeeded`、`failed`、`cancelled`。 |
| `searched_count` | int | 所有階段實際檢視的原始候選數。 |
| `duplicate_count` | int | 批次內重複或同一使用者已看過而排除的候選數。 |
| `irrelevant_count` | int | 未通過話題核心或分類門檻的候選數。 |
| `eligible_count``pending_count` | int | 整批合格總數與待處理總數。 |
| `shortfall_count` | int | `max(target_count - eligible_count, 0)`。 |
| `shortfall_reasons` | string[] | 缺口原因代碼;達標時為空陣列。 |
| `created_at``started_at``completed_at` | int64 | UTC unix nanoseconds未發生的階段時間為 0省略。 |
### 2.2 不足原因代碼
| 代碼 | 顯示語意 |
|------|----------|
| `source_exhausted` | 已用完本次可用搜尋階段,來源沒有更多候選。 |
| `duplicate_exhausted` | 大量候選與本批次或歷史結果重複。 |
| `relevance_exhausted` | 大量候選未通過主題核心或分類門檻。 |
| `source_unavailable` | 至少一個可用來源失敗,但其他來源仍讓批次完成。 |
| `limit_reached` | 已達本次安全檢視上限,停止繼續擴張。 |
可同時回傳多個代碼。對未達標批次,至少要有一個代碼;畫面以本地化文字顯示,不直接把代碼當文案。
## 3. 狀態機
### 3.1 海巡批次
```text
queued --> running : worker 成功認領對應 job
queued --> cancelled : job 在執行前取消
running --> succeeded: 搜尋管線正常結束;是否達標由 shortfall_count 表示
running --> failed : 無法完成本次管線或無法可靠保存最終結果
running --> cancelled: worker 收到已生效的取消訊號
```
- 所有批次狀態更新都必須以「目前狀態」作 guarded update終態不可退回執行中。
- `succeeded` 代表技術上正常完成,不保證達標;`shortfall_count > 0` 時畫面顯示「完成但不足」。
- `failed` 不發佈半套結果;保留錯誤摘要與計數,讓批次可診斷。
### 3.2 貼文處理狀態
沿用既有 `new → drafted → queued/published``new/drafted → skipped` 行為。狀態變更不參與結果排序;每次變更後,批次 `pending_count` 必須反映整批最新值。
## 4. 使用者流程(行為)
### 流程 A建立並執行話題海巡
1. 使用者輸入話題、審閱搜尋詞並設定目標數。
2. 系統正規化目標數,建立新的 Scout Run再建立與其綁定的背景 job相同 `theme_key` 再執行也必須取得新的 `run_id`
3. 建立成功後回傳 job 與 run前端可將新批次顯示為「等待中」但不切離使用者目前正在閱讀的其他批次。
4. worker 認領 job 後,以 run ID 作為 job 的業務關聯,將批次 guarded 更新為 `running`
5. worker 完成搜尋後一次發佈該批次的最終合格結果與統計,再將 run 與 job 更新為終態。
6. 若建立 run 成功但 job 排程失敗run 必須轉為 `failed`,不得永久停留在 `queued`
### 流程 B候選搜尋、去重與補量
1. 系統從使用者批准的搜尋詞開始,逐詞取得候選。
2. 每個候選先正規化 URL 並產生 canonical identity無法識別為 Threads 貼文者直接排除。
3. 同一批次內 identity 重複,或 identity 已存在於同一使用者保留中的歷史 Scout 結果,皆計入 `duplicate_count`,不計入目標,也不覆寫舊批次歸屬。
4. 通過去重後進行話題核心與分類判定;貼文年代不再硬擋,只有合格結果計入 `eligible_count`
5. 若合格數未達目標,依序執行:
1. 第一階段:既有逐詞 fan-out 搜尋。
2. 第二階段:同一主要來源提高每詞候選量。
3. 若使用者只批准一個 activity 搜尋詞,且前兩階段仍不足:依原始 intent 追加最多四組合規短查詢;這是同一來源的有限召回補強,不放寬話題簽章。
4. 第三階段:若另一個既有來源可用,以相同話題核心補足缺口。
6. 每個階段都必須立即去重與判定合格數;不得先按原始候選數截斷,也不得到最後才發現過濾後不足。
7. 達到合格目標立即停止;否則在三階段耗盡或全批最多檢視 320 筆原始候選時停止。
8. 單一搜尋詞在單一來源、單一階段最多要求 20 筆;不得無限重試,不引進第三種來源。
9. 某一來源失敗但仍有其他來源可完成管線時,批次可以成功並記錄 `source_unavailable`;所有可用來源均無法工作時,批次失敗。
10. 合格數不足時,不降低關聯性門檻;系統根據排除統計回傳一個或多個不足原因。
### 流程 C關聯性判定
1. 系統從原始 intent 與使用者批准的搜尋詞建立「話題簽章」,把「推薦、心得、分享、最新、熱門、請問」等口語錨點排除在主題證據之外。
2. 人名、作品名、品牌名及明確專有名詞為必要實體;候選必須命中該實體或批准的明確別名。
3. 多概念意圖切成必要概念群,候選必須至少命中每一群的一個核心表達。例如「外包/後端工程師」不能只因含「工程師」就入選。
4. 搜尋來源提供的語意相關或排名只能擴大候選、產生分數與說明,不能繞過話題簽章。
5. 純廣告、導航頁、無正文、分類為 noise或與本模式明確不相容的候選不得入選。
6. `match_reason` 至少包含命中的核心概念;來源 track、SERP rank、時效或分類可以補充但不能作為唯一理由。
7. `score` 是所有模式結果的主要排序鍵;貼文時間、掃入時間與 ID 只作同分的穩定決勝。超過軟時效的貼文可以保留,但只在 score 中降權。
### 流程 D瀏覽海巡批次
1. 進入 Scout 頁面時,前端只載入批次第 1 頁,預設每頁 10 筆。
2. 批次依 `created_at DESC, id DESC` 穩定排序job 進度更新不得改變批次順序。
3. 每列顯示話題名稱、狀態、目標/實得、待處理數及建立時間;不足時顯示缺口提示。
4. 使用者切換批次時,結果頁碼重設為 1載入該批次結果批次頁碼保持不變。
5. 背景新批次完成時,若使用者正在閱讀其他批次,畫面只提示「有新批次」,不自動重抓列表、不改變目前批次或頁碼。使用者主動重新整理批次後才看到新的第 1 頁。
6. 歷史批次沒有自動隱藏期限,直到使用者主動刪除。
### 流程 E瀏覽批次結果與時間
1. 批次為 `queued``running` 時顯示進度與目前計數,不顯示尚未完成的半套結果。
2. 完成後只載入所選結果頁,預設每頁 8 筆。
3. 結果先依 `score DESC`
4. 相同 score 時,已知 `posted_at` 先於未知;已知時間依 `posted_at DESC`,再依 `created_at DESC, id DESC`
5. `outreach_status`、待處理與否都不得改變上述排序;舊貼文不因日期被淘汰。
6. 每個結果列顯示「貼文時間:…」或「貼文時間未知」;目前貼文明細採相同標示。
7. 若明細顯示 `created_at`,文案必須是「掃入時間:…」,並與貼文時間分列或清楚分隔。
8. 翻到另一頁時,預設選取該頁第一筆;若刪除最後一頁的最後一筆,頁碼退到新的最後有效頁。
### 流程 F刪除批次
1. 使用者刪除終態批次時,系統刪除該 run 及其結果,並回傳成功。
2. `queued``running` 批次不可直接刪除;使用者需先透過既有 job 取消流程使其進入終態。
3. 刪除批次不刪除可重用的話題功課;話題功課仍使用自己的刪除操作。
4. 刪除目前頁最後一個批次後,前端退到新的最後有效批次頁;若目前選取被刪除,選取該頁第一筆,無批次則顯示空狀態。
## 5. 介面契約
### 5.1 HTTP / API
- Auth以下端點全部需要會員 JWT`Authorization: Bearer <member JWT>`)與既有 AuthJWT middleware。
- Ownership只能讀寫 JWT owner 的 run、post 與 homework非本人 ID 對外回傳 not found不洩漏資源是否存在。
- Envelope沿用全域 `code/message/data/error`;成功碼 `102000`
- 列表request 使用 `page``pageSize`response 為 `list``pagination { page, pageSize, total, totalPages }`。
- 分頁正規化:`page < 1` 視為 1`pageSize < 1` 使用端點預設值最大 50
- 時間所有時間欄位皆為 UTC unix nanoseconds `int64`
- 外部 AI採集 provider token 只由 worker 端取得不接受會員 JWT 代替也不回傳前端
| 呼叫方向 | Authorization / credential | 可存取範圍 |
|----------|----------------------------|------------|
| Web Scout API | 會員 JWT | 僅目前 owner runposthomework job |
| Worker ScoutJob 內部服務 | worker 已認領 job owner run context | 僅處理該次不可變 payload 指定的 run |
| Worker 外部搜尋AI provider | 伺服器端 provider token 或已加密 crawler session | 僅取得候選不得以會員 JWT 代替或傳回瀏覽器 |
| Method | Path | 用途 | 主要 request/response 欄位 |
|--------|------|------|---------------------------|
| POST | `/api/v1/scout/scan` | 建立獨立 run 並排程 job | request既有 `brief`response`job`、`run`。 |
| GET | `/api/v1/scout/runs` | 分頁列出海巡批次 | query`page`、`pageSize`、可選 `brand_id`、`mode`response`list<ScoutRunPublic>`、`pagination`。 |
| GET | `/api/v1/scout/runs/:runId/posts` | 分頁列出單一批次結果 | query`page`、`pageSize`response`run`、`list<ScoutPostPublic>`、`pagination`。 |
| DELETE | `/api/v1/scout/runs/:runId` | 刪除終態批次及其結果 | response`ok=true`;執行中回 409。 |
| GET | `/api/v1/scout/posts` | 舊呼叫者相容列表 | 暫時保留既有輸出Scout 主頁不得再用它組批次或做 client-side 分頁。 |
| DELETE | `/api/v1/scout/themes/:themeKey` | 舊版相容刪除 | 僅供既有呼叫相容;新 UI 使用 run 刪除,不再把 theme 當 run。 |
#### ScoutRunPublic
回傳第 2.1 節所有 run 欄位;另可包含本地化前的 job error summary但不得包含 provider 憑證、原始 payload 或內部堆疊。
#### ScoutPostPublic 變更
- 新增必填 `run_id`legacy 結果可回 synthetic legacy run ID
- 保留 `theme_key` 表示話題,不再用來判斷一次執行。
- 保留 `posted_at``created_at`,語意依第 2 節,不新增模糊的 `time` 欄位。
#### 錯誤碼 scope
| HTTP | code scope | 情境 |
|------|------------|------|
| 400 | `400xxx` | brief、目標數或 filter 無法接受。 |
| 401 | `401xxx` | 缺少或無效會員 JWT。 |
| 404 | `404xxx` | runpost 不存在或不屬於目前 owner。 |
| 409 | `409xxx` | 刪除仍在 queued/running 的 run或狀態競爭。 |
| 503 | `503xxx` | Scout 模組或全部搜尋來源不可用。 |
### 5.2 前端頁面 / 導覽
| 路由 / hash | 目的 | 主要操作 |
|-------------|------|----------|
| `/scout` | 話題功課、批次與結果工作台 | 建立海巡、翻批次頁、選批次、翻結果頁、查看時間、草擬/略過/發佈、刪除批次。 |
- Scout repository 將 `listPosts(): ScoutPost[]` 的主頁用法替換為 `listRuns(query): Page<ScoutRun>``listRunPosts(runId, query): Page<ScoutPost>`
- mock 與 live 必須實作相同方法、分頁形狀、排序與時間未知規則;不得由頁面再對全量陣列切頁模擬 live 行為。
- 批次 pager 與結果 pager 使用獨立 state任何一方換頁不得重設另一方。
- 列表時間使用既有本地時區格式器,但欄位來源只能是 `posted_at`;相對或絕對顯示不得改變排序語意。
### 5.3 Job / 背景任務
| Template type | Worker | 觸發 | 成功結果 |
|---------------|--------|------|----------|
| `scout_scan` | 既有 backend worker | POST scan 建立 run 後排程 | 批次結果與統計一次發佈run 與 job 成為終態。 |
- job `RefID` 改為 `run_id`;不可再以 `theme_key` 代表一次執行。
- job payload 維持不可變 brief並包含 `run_id`worker 必須驗證 payload run ID 與 RefID 一致。
- job 狀態及進度更新沿用 guarded updateRedis lock value 持續為 `workerID`
- 進度摘要至少區分「準備來源」、「第 N 階段搜尋」、「篩選/去重」、「發佈結果」,並可顯示目前合格數/目標數。
- worker 失敗時,同步把仍為 queued/running 的 run guarded 更新為 failed不得覆蓋已取消或已完成的 run。
## 6. 與現況對照
| 能力 | Retain | Replace | Remove | 備註 |
|------|--------|---------|--------|------|
| 使用者審閱後的多搜尋詞 fan-out | ✓ | | | 搜尋詞仍由使用者確認。 |
| 兩個既有搜尋來源與 dev/live 選擇 | ✓ | | | 不新增第三來源。 |
| canonical Threads shortcode 去重 | ✓ | | | 擴展成批次內、歷史已見及跨頁一致規則。 |
| 話題核心硬閘與分類 noise 排除 | ✓ | ✓ | | 保留基礎,改以原始 intent 的必要概念群約束所有擴展詞。 |
| 依原始候選數判斷 target 並提前截斷 | | ✓ | ✓ | 改為每階段去重、判定後的 eligible count。 |
| activity 先按 scorepending 排序 | | ✓ | ✓ | 改為所有模式 score 主排序,同分才用 posted_at、created_at、ID 決勝。 |
| `created_at` 作為貼文時間顯示或排序 fallback | | ✓ | ✓ | 只可標成掃入時間;未知 posted_at 不偽造。 |
| `theme_key` 作為執行批次識別 | | ✓ | ✓ | theme 保留,新建不可重用的 run ID。 |
| 前端載入全量 posts 後組 runGroups | | ✓ | ✓ | 改用 run 列表端點與伺服器分頁。 |
| 前端對全量 runQueue 做 pageSlice | | ✓ | ✓ | 改用單一 run results 分頁端點。 |
| outreach 狀態、草稿、略過與發佈 | ✓ | | | 狀態不再影響 score 排序。 |
| Homework話題功課 | ✓ | | | 與 run 生命週期分離。 |
| 舊 `GET /scout/posts` | ✓ | | | 暫時供其他頁面相容,不作 Scout 主頁資料源。 |
### 6.1 舊資料相容
- 缺少 `run_id` 的既有貼文按 owner`theme_key` 暴露為一個唯讀 synthetic legacy run無 theme key 時使用穩定的 legacy fallback key。
- legacy run 的 `created_at` 取該組最晚掃入時間,`eligible_count` 與 `pending_count` 從完整組別計算。
- 無法還原同一 theme 過去究竟執行過幾次,因此不得虛構多個歷史批次。
- legacy 結果仍依本規格的 score、同分時間及 ID 穩定排序,仍需 canonical 去重。
- synthetic legacy run 可整組刪除,但不可重新執行或改寫為新 run。
## 7. 非功能
- **安全 / 權限:**所有查詢和刪除都以 owner UID 限定run ID、theme key 或 post ID 不得繞過 owner filter。
- **錯誤碼 scope**沿用第 5.1 節;外部來源細節只寫入安全日誌,使用者訊息不得包含 token、request header 或內部堆疊。
- **可觀測性:**每批記錄 searched、duplicate、irrelevant、eligible、shortfall 與各階段耗時job 進度摘要可讓 UI 說明目前進展。
- **效能邊界:**target 最大 40、每詞每來源每階段最大 20、整批最多檢視 320 筆run 與 result pageSize 最大 50。
- **列表效能:**run 列表不得先載入其全部 posts結果列表不得先載入其他 run 的 posts。每次列表查詢只回目前頁與完整 total。
- **穩定性:**run 結果 membership 在成功發佈後不可由後續掃描改寫post 處理狀態可變,但不得影響排序鍵。
- **原子可見性:**使用者只能看到完整發佈的成功結果集failed run 不暴露部分寫入結果。
- **確定性:**所有列表必須有唯一 tie-breaker相同資料與相同分頁參數需回傳相同順序。
- **品質驗收:**人工標註集至少 30 組,涵蓋人物/作品、事件、產業與多詞意圖;計算 precision@10 與達標率。
## 8. 資料保存
| 實體 | 關鍵欄位 | 生命週期 |
|------|----------|----------|
| Scout Run | id、owner_uid、job_id、theme_key、intent、target、status、完整計數、時間 | 建立後保留至使用者主動刪除終態、執行輸入及搜尋診斷不可改寫eligible_countpending_count 可隨單筆刪除或處理狀態更新。 |
| Scout Post | id、run_id、canonical identity、owner_uid、正文、match reason、posted_at、created_at、outreach status | 隨所屬 run 發佈;處理狀態可更新;刪除單筆或 run 時移除。 |
| Homework | owner_uid、theme_key、brief、created_at | 可跨多個 run 重用;不因 run 刪除而消失。 |
| Job | id、owner_uid、RefID=run_id、immutable payload、guarded status | 沿用 jobs 模組既有保留與終態規則。 |
### 8.1 唯一性與計數
- 新結果的唯一性至少為 owner UIDcanonical identity再次搜尋到時列入 duplicate不覆寫先前 post 的 `run_id`、時間或處理狀態。
- 單一 run 內每個 post ID 只出現一次pagination total 以去重後的完整 run membership 計算。
- `eligible_count` 等於該 run 發佈時的結果總數;刪除單筆後同步減少 total 與 eligible_count。
- `pending_count` 由整批狀態計算,任何單筆狀態異動後不得只用目前頁推算。
- `posted_at` 只接受來源可辨識的時間0、無效值或明顯未來時間視為未知。不得以 `created_at` 回填。
## 9. 驗收場景Given / When / Then
1. **Given** 目標為 20第一階段取得 20 個原始候選但只有 7 個合格,**When** 管線判定數量,**Then** 必須繼續下一階段,而不是以原始候選達 20 提前停止。
2. **Given** 所有階段最終只有 13 個合格結果,**When** run 成功結束,**Then** `eligible_count=13`、`shortfall_count=7`,至少一個不足原因可顯示,且沒有離題補量。
3. **Given** 「外包 後端工程師」話題,候選只談一般工程師求職,**When** 關聯性判定,**Then** 因未同時保留外包與後端概念而排除。
4. **Given** 「鬼滅之刃」話題,候選只含泛用「動畫推薦」,**When** 關聯性判定,**Then** 不得以推薦錨點入選。
5. **Given** 同一 Threads shortcode 分別來自 `threads.net`、`www.threads.net` 並帶不同 query**When** 同批或後續批次搜尋,**Then** 只保留一筆,其他計入 duplicate。
6. **Given** 某貼文已存在於舊 run**When** 新 run 再次搜尋到它,**Then** 舊 run 歸屬與狀態不被覆寫,新 run 繼續補找其他候選。
7. **Given** 同批有多個不同 score 與 outreach status 的結果,**When** 列出結果,**Then** score 嚴格由高到低status 不得插隊。
8. **Given** 同分的三筆結果中一筆無 `posted_at`**When** 列出結果,**Then** 未知時間貼文排在已知時間之後,並顯示「貼文時間未知」。
9. **Given** 兩筆 score 與 `posted_at` 完全相同,**When** 重複取得第 1、2 頁,**Then** 以 ID 決勝,順序確定且跨頁無重複或遺漏。
10. **Given** 同一 theme 連續執行兩次,**When** 列出批次,**Then** 出現兩個不同 run ID較新的 run 在前,先前結果不被新結果搬走。
11. **Given** 批次共 27 筆、其中 9 筆 pending而目前結果頁只有 8 筆,**When** 顯示批次列,**Then** total 為 27、pending 為 9。
12. **Given** 使用者停在批次第 3 頁並閱讀某 run**When** 新 run 完成,**Then** 只顯示新批次提示,目前頁與選取不變。
13. **Given** 使用者切換到另一 run**When** 載入其結果,**Then** 結果頁回到 1批次頁碼不變。
14. **Given** 結果最後一頁只剩一筆,**When** 使用者刪除該筆,**Then** 前端退到新的最後有效頁,不顯示失效空白頁。
15. **Given** run 仍為 running**When** 使用者要求刪除,**Then** 回 409 且資料不變。
16. **Given** live 與 mock 使用相同測試資料,**When** 執行批次、時間與分頁案例,**Then** 兩者回傳相同排序、pagination 與未知時間語意。
17. **Given** 至少 30 組人工標註題組,**When** 執行品質驗收,**Then** precision@10 平均至少 0.8,且來源存在足量合格候選的題組至少 90% 達成 target。
## 10. 開放規格問題
1. 無阻塞問題。需求批准時已接受「所有處理狀態保留在批次總數」與「歷史批次保留至主動刪除」兩項預設。
## 11. 批准
- [x] Spec 已批准2026-08-09使用者