Files
2026-07-13 12:29:17 +08:00

170 lines
6.8 KiB
Markdown
Raw Permalink 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.
# Candidate Schema(候选项统一 schema
被这些子 skill 引用:`cheat-trends``cheat-recommend``cheat-init`、所有 `adapters/`
任何"待决定要不要做"的内容素材——不管来自手粘列表 / RSS / Notion / 平台热点抓取——都必须 normalize 成本 schema 之后才进入候选池。这是 `adapters/` 的输出契约。
字段设计参考博主项目的 `articles` 表 schema(私有项目,工具的方法论由此抽象而来)。
---
## 必填字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string (12 chars) | 稳定 hash`sha256(source + normalized_title + url_path)[:12]`。同一条素材在不同时间被抓到 → 同 id |
| `title` | string | 候选项的人类可读标题 |
| `source` | string | 来源标识,格式 `<adapter-type>:<source-name>`,例:`trend:hackernews``pool:notion-mybook``paste:manual` |
| `snapshot_text` | string | 候选项的全文或摘要——**这是打分的输入**,不是 url。adapter 必须负责把 url 拓展成可读文本 |
| `snapshot_at` | ISO 8601 | 抓取/录入这条 item 的时间 |
---
## 可选字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `url` | string | 原始链接(便于追溯) |
| `tier` | enum | `tier1` / `tier2` / `tier3` / `skip` / `risky` / `done`。粗分类,用于过滤 |
| `read_status` | enum | `unread` / `skimmed` / `deep_read` / `done`。处理状态 |
| `category` | string | 自定义分类标签(如"社会"、"家庭"、"学术" |
| `composite_score` | float | 用当前 rubric 打分得到的综合分(如已打) |
| `dimension_scores` | object | 各维度的整数分,键名对齐当前 rubric 的维度(如 `{"ER": 5, "HP": 4, ...}` |
| `scored_under_rubric_version` | string | 打分时用的 rubric 版本号 |
| `predicted_bucket` | string | 粗预测桶(如 `30-100w`),**注意:不是正式预测**——选题阶段的粗略估计,与 `predictions/*.md` 的 immutable 预测完全独立 |
| `predicted_reason` | string | 一句话理由 |
| `note` | string | 自由文本备注,例如"等节点再发"、"待重读"、"风险议题" |
| `rejected_at` / `rejected_reason` | ISO 8601 / string | 用户主动跳过此候选时记录 |
---
## JSON 范例
### Markdown 列表 adapter 输出
```json
{
"id": "a3f2c1d4e5b6",
"title": "为什么我们都讨厌主动联系朋友",
"source": "pool:markdown-list",
"snapshot_text": "[用户从 candidates.md 复制的全文]",
"snapshot_at": "2026-05-04T08:30:00+08:00",
"url": null,
"tier": "tier1",
"read_status": "skimmed",
"category": "社交",
"composite_score": 7.4,
"dimension_scores": {"ER": 4, "HP": 4, "QL": 5, "NA": 3, "AB": 5, "SR": 3, "SAT": 3},
"scored_under_rubric_version": "v0",
"predicted_bucket": "5-30w",
"predicted_reason": "ER=4+QL=5 强金句感,AB=5 普适,但 SR=3 议题不够强",
"note": ""
}
```
### Trend adapter 输出(HN
```json
{
"id": "8c4d92e1f0b3",
"title": "Show HN: I built a tool that predicts whether your video will go viral",
"source": "trend:hackernews",
"snapshot_text": "[文章全文 + 评论 top 5 的摘要]",
"snapshot_at": "2026-05-04T09:15:00+08:00",
"url": "https://news.ycombinator.com/item?id=12345678",
"tier": null,
"read_status": "unread",
"category": "tech-meta",
"composite_score": null,
"dimension_scores": null,
"scored_under_rubric_version": null
}
```
打分前 score 字段全部为 null——是预期的。`cheat-trends` 抓回来后会调 `cheat-score` 给每条算 composite。
---
## Markdown 表示(用户可见的存储格式)
候选池的默认存储是 `candidates.md`(人类可读),不是 JSON。每条 item 是一个 H3 entry
```markdown
### [tier1] 为什么我们都讨厌主动联系朋友
- **id**: a3f2c1d4e5b6
- **source**: pool:markdown-list
- **snapshot_at**: 2026-05-04
- **category**: 社交
- **composite (v0)**: 7.4 — ER=4 HP=4 QL=5 NA=3 AB=5 SR=3 SAT=3
- **predicted bucket**: 5-30w
- **note**:
> [snapshot_text 段,如有]
```
升级到 SQLite 之后(见 `cheat-status` 的升级触发),同样字段走 `articles` 表存储,markdown 视图自动从 DB 渲染。
---
## ID 稳定性的关键规则
**同一条素材在不同时间被不同 adapter 抓到 → 必须算出同 id**。这是去重的基础。
算法:
```python
import hashlib
def candidate_id(source: str, title: str, url: str = None) -> str:
normalized_title = title.strip().lower().replace(" ", "")
url_path = url.split("?")[0].rstrip("/") if url else ""
raw = f"{source.split(':')[0]}|{normalized_title}|{url_path}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
```
注意:
- `source` 取冒号前的 adapter type`trend:hackernews``trend`),不是具体 source name——同一标题被 HN 和 Reddit 都抓到,应判定为同一候选(避免重复打分)
- title 做了 lowercase + 去空格——避免 "Hello World" 和 "hello world" 被算成不同 id
- url 砍掉 query string—— `?utm_source=xxx` 不影响内容
---
## 去重协议
`cheat-trends` / `cheat-recommend` 在写入 `candidates.md` 前必须执行:
1. 计算新 item 的 id
2. 检查 `candidates.md` 是否已含此 id → 跳过
3. 检查 `predictions/*.md` 是否含此 id(已发过)→ 跳过
4. 检查 `.cheat-cache/trends-history.jsonl` 是否含此 id 且 `rejected_at != null` → 跳过(用户已主动拒绝过)
5. 通过则写入
`.cheat-cache/trends-history.jsonl` 是抓取历史的去重缓存,每行一个 JSON recordappend-only。被用户拒绝的候选会在这里保留 6 个月;之后允许重新出现(也许素材在新 rubric 下评估不同)。
---
## tier 的语义
| Tier | 含义 | 对应行动 |
|---|---|---|
| `tier1` | 强候选,应推荐 | 进入 `cheat-recommend` 排序池 |
| `tier2` | 中等,备选 | 进入排序池但权重低 |
| `tier3` | 弱候选,备而不用 | 不进推荐池,留作长尾 |
| `skip` | 用户主动跳过 | 不再出现 |
| `risky` | 议题敏感 / 平台风控风险 | 推荐时额外标注,需用户确认 |
| `done` | 已发布 | 移出候选池,由 prediction file 接管 |
**Cold-start 期间所有 item 默认是 `unread`/`null tier`**——直到用户或 `cheat-score` 给出 composite 后才能粗分类。**未打分的 item 不应出现在 `cheat-recommend` 输出**——避免推荐没读过的素材。
---
## adapter 实现契约
任何 `adapters/` 下的 adapter 都必须:
1. 实现 `fetch() → List[Candidate]` 接口(伪签名,实际是 markdown 文档描述的协议)
2. 输出符合本 schema 的 items
3. 自己负责把 url / 短摘要拓展成可读 `snapshot_text`——**adapter 不输出"光秃秃的 url"**
4. 优雅降级:如配置缺失(API key、cookie),返回空列表 + 在 stderr/log 写明原因,**不抛异常**
详见 `adapters/HOWTO.md`(待批次 3 写)。