# 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 的 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）

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／使用者
