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 海巡批次
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:建立並執行話題海巡
- 使用者輸入話題、審閱搜尋詞並設定目標數。
- 系統正規化目標數,建立新的 Scout Run,再建立與其綁定的背景 job;相同
theme_key 再執行也必須取得新的 run_id。
- 建立成功後回傳 job 與 run,前端可將新批次顯示為「等待中」,但不切離使用者目前正在閱讀的其他批次。
- worker 認領 job 後,以 run ID 作為 job 的業務關聯,將批次 guarded 更新為
running。
- worker 完成搜尋後一次發佈該批次的最終合格結果與統計,再將 run 與 job 更新為終態。
- 若建立 run 成功但 job 排程失敗,run 必須轉為
failed,不得永久停留在 queued。
流程 B:候選搜尋、去重與補量
- 系統從使用者批准的搜尋詞開始,逐詞取得候選。
- 每個候選先正規化 URL 並產生 canonical identity;無法識別為 Threads 貼文者直接排除。
- 同一批次內 identity 重複,或 identity 已存在於同一使用者保留中的歷史 Scout 結果,皆計入
duplicate_count,不計入目標,也不覆寫舊批次歸屬。
- 通過去重後進行話題核心與分類判定;貼文年代不再硬擋,只有合格結果計入
eligible_count。
- 若合格數未達目標,依序執行:
- 第一階段:既有逐詞 fan-out 搜尋。
- 第二階段:同一主要來源提高每詞候選量。
- 若使用者只批准一個 activity 搜尋詞,且前兩階段仍不足:依原始 intent 追加最多四組合規短查詢;這是同一來源的有限召回補強,不放寬話題簽章。
- 第三階段:若另一個既有來源可用,以相同話題核心補足缺口。
- 每個階段都必須立即去重與判定合格數;不得先按原始候選數截斷,也不得到最後才發現過濾後不足。
- 達到合格目標立即停止;否則在三階段耗盡或全批最多檢視 320 筆原始候選時停止。
- 單一搜尋詞在單一來源、單一階段最多要求 20 筆;不得無限重試,不引進第三種來源。
- 某一來源失敗但仍有其他來源可完成管線時,批次可以成功並記錄
source_unavailable;所有可用來源均無法工作時,批次失敗。
- 合格數不足時,不降低關聯性門檻;系統根據排除統計回傳一個或多個不足原因。
流程 C:關聯性判定
- 系統從原始 intent 與使用者批准的搜尋詞建立「話題簽章」,把「推薦、心得、分享、最新、熱門、請問」等口語錨點排除在主題證據之外。
- 人名、作品名、品牌名及明確專有名詞為必要實體;候選必須命中該實體或批准的明確別名。
- 多概念意圖切成必要概念群,候選必須至少命中每一群的一個核心表達。例如「外包/後端工程師」不能只因含「工程師」就入選。
- 搜尋來源提供的語意相關或排名只能擴大候選、產生分數與說明,不能繞過話題簽章。
- 純廣告、導航頁、無正文、分類為 noise,或與本模式明確不相容的候選不得入選。
match_reason 至少包含命中的核心概念;來源 track、SERP rank、時效或分類可以補充,但不能作為唯一理由。
score 是所有模式結果的主要排序鍵;貼文時間、掃入時間與 ID 只作同分的穩定決勝。超過軟時效的貼文可以保留,但只在 score 中降權。
流程 D:瀏覽海巡批次
- 進入 Scout 頁面時,前端只載入批次第 1 頁,預設每頁 10 筆。
- 批次依
created_at DESC, id DESC 穩定排序;job 進度更新不得改變批次順序。
- 每列顯示話題名稱、狀態、目標/實得、待處理數及建立時間;不足時顯示缺口提示。
- 使用者切換批次時,結果頁碼重設為 1,載入該批次結果;批次頁碼保持不變。
- 背景新批次完成時,若使用者正在閱讀其他批次,畫面只提示「有新批次」,不自動重抓列表、不改變目前批次或頁碼。使用者主動重新整理批次後才看到新的第 1 頁。
- 歷史批次沒有自動隱藏期限,直到使用者主動刪除。
流程 E:瀏覽批次結果與時間
- 批次為
queued 或 running 時顯示進度與目前計數,不顯示尚未完成的半套結果。
- 完成後只載入所選結果頁,預設每頁 8 筆。
- 結果先依
score DESC。
- 相同 score 時,已知
posted_at 先於未知;已知時間依 posted_at DESC,再依 created_at DESC, id DESC。
outreach_status、待處理與否都不得改變上述排序;舊貼文不因日期被淘汰。
- 每個結果列顯示「貼文時間:…」或「貼文時間未知」;目前貼文明細採相同標示。
- 若明細顯示
created_at,文案必須是「掃入時間:…」,並與貼文時間分列或清楚分隔。
- 翻到另一頁時,預設選取該頁第一筆;若刪除最後一頁的最後一筆,頁碼退到新的最後有效頁。
流程 F:刪除批次
- 使用者刪除終態批次時,系統刪除該 run 及其結果,並回傳成功。
queued 或 running 批次不可直接刪除;使用者需先透過既有 job 取消流程使其進入終態。
- 刪除批次不刪除可重用的話題功課;話題功課仍使用自己的刪除操作。
- 刪除目前頁最後一個批次後,前端退到新的最後有效批次頁;若目前選取被刪除,選取該頁第一筆,無批次則顯示空狀態。
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 的 run、post、homework 與 job。 |
| Worker → Scout/Job 內部服務 |
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 |
run/post 不存在或不屬於目前 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 update;Redis 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 先按 score/pending 排序 |
|
✓ |
✓ |
改為所有模式 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_count/pending_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 UID+canonical 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)
- Given 目標為 20,第一階段取得 20 個原始候選但只有 7 個合格,When 管線判定數量,Then 必須繼續下一階段,而不是以原始候選達 20 提前停止。
- Given 所有階段最終只有 13 個合格結果,When run 成功結束,Then
eligible_count=13、shortfall_count=7,至少一個不足原因可顯示,且沒有離題補量。
- Given 「外包 後端工程師」話題,候選只談一般工程師求職,When 關聯性判定,Then 因未同時保留外包與後端概念而排除。
- Given 「鬼滅之刃」話題,候選只含泛用「動畫推薦」,When 關聯性判定,Then 不得以推薦錨點入選。
- Given 同一 Threads shortcode 分別來自
threads.net、www.threads.net 並帶不同 query,When 同批或後續批次搜尋,Then 只保留一筆,其他計入 duplicate。
- Given 某貼文已存在於舊 run,When 新 run 再次搜尋到它,Then 舊 run 歸屬與狀態不被覆寫,新 run 繼續補找其他候選。
- Given 同批有多個不同 score 與 outreach status 的結果,When 列出結果,Then score 嚴格由高到低,status 不得插隊。
- Given 同分的三筆結果中一筆無
posted_at,When 列出結果,Then 未知時間貼文排在已知時間之後,並顯示「貼文時間未知」。
- Given 兩筆 score 與
posted_at 完全相同,When 重複取得第 1、2 頁,Then 以 ID 決勝,順序確定且跨頁無重複或遺漏。
- Given 同一 theme 連續執行兩次,When 列出批次,Then 出現兩個不同 run ID,較新的 run 在前,先前結果不被新結果搬走。
- Given 批次共 27 筆、其中 9 筆 pending,而目前結果頁只有 8 筆,When 顯示批次列,Then total 為 27、pending 為 9。
- Given 使用者停在批次第 3 頁並閱讀某 run,When 新 run 完成,Then 只顯示新批次提示,目前頁與選取不變。
- Given 使用者切換到另一 run,When 載入其結果,Then 結果頁回到 1,批次頁碼不變。
- Given 結果最後一頁只剩一筆,When 使用者刪除該筆,Then 前端退到新的最後有效頁,不顯示失效空白頁。
- Given run 仍為 running,When 使用者要求刪除,Then 回 409 且資料不變。
- Given live 與 mock 使用相同測試資料,When 執行批次、時間與分頁案例,Then 兩者回傳相同排序、pagination 與未知時間語意。
- Given 至少 30 組人工標註題組,When 執行品質驗收,Then precision@10 平均至少 0.8,且來源存在足量合格候選的題組至少 90% 達成 target。
10. 開放規格問題
- 無阻塞問題。需求批准時已接受「所有處理狀態保留在批次總數」與「歷史批次保留至主動刪除」兩項預設。
11. 批准