# 内容流水线编排设计 把「AI 外快」跑成一条可审计的流水线:员工分工、人工闸门、审核队列、数据回流。 设计目标不是「无人值守」,是**把人的操作压到每天三次点击,并且让每一次点击都有账可查**。 本次交付的代码(全部是新增文件,未改动任何现有文件): | 文件 | 作用 | 状态 | |---|---|---| | `lib/content.mjs` | 数据层:状态机 + 队列 + 回采 + 权重表 | 已实现,已测 | | `lib/usage.mjs` | 读本地账本(`~/.dsh/.dshw-usage.json`) | 已实现,已测 | | `lib/content-tools.js` | Agent 平面:10 个工具 + 1 道闸门 | 已实现,已测 | | `test/content.smoke.mjs` | 41 项自检 | 41/41 通过 | --- ## 1. 一句话结论 视频里那七种外快之所以不能全自动,是因为利润长在「分发 × 信任 × 结算」上。 这条流水线的做法是:**把生产段全部自动化,把不可自动化的那一环用代码钉死在一个位置上**——`content_gate` 这个工具,运行时保证只有真人能调用它。员工无论被怎么提示、被注入什么,都推不动 `review → queued`。 这不是靠提示词要求模型自觉,是 `ctx.tools.guard()` 在运行时拦。这是整份设计的地基。 --- ## 2. 员工编制(角色划分) 五人,其中三人专为这条流水线新增: | 工位 | id | 职责 | 能调用 | 不允许 | |---|---|---|---|---| | 情报员工·阿研 | `scout` | 素材入站:找素材、核验授权、登记 | `content_new`、检索类 | 不评分、不写稿 | | **选题员工·阿筛** | `select` | 打分、排序、给先验 | `content_score`、`weights(read)` | 不写稿、不改别人的分 | | **成稿员工·阿写** | `write` | 脚本 + 标题 | `content_script` | 不改分、不决定发布 | | **数据员工·阿析** | `measure` | 回采、对账、重算权重 | `metrics_pull`、`weights(update)` | 不美化数据、样本不足不下结论 | | 审核员工·阿审 | `reviewer` | 代码审核(与本线无关,保留) | bash、fs | 不替人改代码 | **合成和发布不设员工**,理由不同: - 合成(切片、转码、字幕烧录)是确定性任务,`ffmpeg` 做得又快又准还免费。**每一次调用模型都是在烧你的 token**,所以能用代码做的绝不外包给模型。`content_render` 只登记参数,真正的转码交给 `bin/render.mjs`。 - 发布是不可逆的外部动作,且是平台风控的正面靶子。`content_publish` 只写本地发布包,不接触任何平台接口。 **互斥靠阶段契约,不靠身份校验**:一条内容的每个阶段只有一个入口,且只接受特定的前置阶段(比如 `content_score` 只认 `ingested`)。为什么不按员工名字校验权限?因为名字是人改着玩的,而且 `exec.agent` 的内部字段会随宿主版本变;阶段是数据的一部分,不会背叛你。 --- ## 3. 阶段契约(状态机) ``` ingested ──score──▶ scored ──script──▶ scripted ──render──▶ rendered │ ▼ archived ◀──measured ◀──metrics_pull── published ◀──publish── queued ▲ review ──content_gate──┘(只有人) │ └──kill──▶ killed ``` | 阶段 | 谁能推动 | 前置条件 | 产物 | |---|---|---|---| | `ingested` | 阿研 | 授权凭证必须填 | `item.json` | | `scored` | 阿筛 | 只在 `ingested` 可评,一条只评一次 | `score` 写死 | | `scripted` | 阿写 | 必须先有分 | `script.md` | | `rendered` | 确定性代码 | 必须先有稿 | `render.json` | | `review` | 自动进入 | — | 等人 | | `queued` | **人** | 署名裁决 | `gate.by` 落盘 | | `published` | 人 | 必须已有 `gate.by` | `publish.json` | | `measured` | 阿析 | 首次回采 | metrics | | `archived` / `killed` | 任意 | 终点 | — | `advance()` 在代码层拒绝从 `review` 直接推进。`content_publish` 在 `execute()` 里再查一次磁盘上的 `gate.by`——**双保险**,因为 guard 只能同步判断,读盘的规则必须放在工具里。 --- ## 4. 审核队列数据结构 ``` $DSH_HOME/team/ ├── employees.json 花名册(现有) ├── journal.jsonl 工作日志(现有) ├── content/ │ ├── index.jsonl 摘要索引(追加式,一行一条,面板和 agent 都读它) │ └── 20261003-fashion-haul-001/ │ ├── item.json 当前态(原子写:临时文件 + rename) │ ├── events.jsonl 状态转移流水(追加式,可审计) │ ├── script.md 脚本 │ ├── render.json 切片与渲染参数 │ └── publish.json 本地发布包 └── metrics/ ├── 20261003-fashion-haul-001.json └── weights.json 权重表 ``` `item.json`(节选,真实字段): ```json { "id": "20261003-fashion-haul-001", "stage": "review", "revision": 4, "owner": "阿写", "source": { "kind": "campaign", "ref": "https://...", "license": "camp-42" }, "score": { "hook": 90, "emotion": 60, "info": 30, "total": 60, "by": "select", "weightsVersion": 3, "adjusted": 74, "multiplier": 1.24 }, "features": { "topic": "fashion-haul", "hookType": "question", "durationSec": 28, "hasFace": false, "captionStyle": "karaoke", "publishHour": null }, "artifacts": [{ "kind": "script", "path": ".../script.md", "bytes": 812 }], "targets": [], "gate": { "required": true, "decision": null, "by": null, "at": null, "note": "" }, "cost": { "cny": 0.75, "currency": "CNY", "tokens": 1500, "byStage": { "scripted": 0.5, "scored": 0.25 }, "observed": [{ "at": "...", "cny": 0.5, "tokens": 1000, "model": "deepseek-flash" }] } } ``` 三个字段承载设计意图: - `source.license` —— 切片别人内容时,「凭什么能发」的唯一凭证。**登记时不填,事后补不回来**。 - `score` —— 发布时冻结。没有它,播放数据无法归因。 - `gate.by` —— 有人签了名,才允许离开 `review`。 `events.jsonl` 一行: ```json {"at":"2026-10-03T11:02:41.123Z","from":"rendered","to":"review","actor":"合成","reason":"成片待审","evidence":["out/xxx.mp4"]} ``` `revision` 是乐观并发锁:状态被别的员工推进过,写入会被拒并提示重读,而不是让两人互相覆盖结论。 --- ## 5. 数据回流字段 `metrics/.json`: ```json { "itemId": "20261003-fashion-haul-001", "pulledAt": "2026-10-04T09:00:12.000Z", "features": { "...发布时刻冻结的特征向量..." }, "scoreAtGate": 60, "platforms": { "douyin": { "views": 12000, "likes": 800, "comments": 42, "shares": 90, "saves": 130, "revenueUsd": 12, "revenueSource": "manual", "capturedAt": "..." } }, "outcome": { "viewsTotal": 12000, "revenueUsd": 12.0, "tokenCostCny": 0.75, "rate": null, "netUsd": null } } ``` **两条冻结规则,这是整个闭环能不能成立的技术命门**: 1. 特征向量在**首次回采时**从 `item.json` 冻结进 metrics。发布之后改特征不影响归因——所以改了也白改。 2. 分数在**评分时**冻结,`content_score` 对同一条拒绝二次评分。 没有这两条,`weights.json` 就是把「编辑过的特征」和「当时的播放量」对到一起,归因全废。 `metrics/weights.json`: ```json { "version": 4, "updatedAt": "...", "method": "median-ratio", "samples": 42, "minSamples": 5, "baselineViews": 3200, "buckets": { "hookType": { "question": { "n": 12, "medianViews": 5760, "ratio": 1.8 }, "list": { "n": 6, "medianViews": 2240, "ratio": 0.7 } }, "publishHour": { "19": { "n": 9, "medianViews": 4800, "ratio": 1.5 } } } } ``` 更新规则:`ratio = 该桶播放量中位数 / 全样本中位数`;**样本数不足 `minSamples` 的桶直接不写入**——宁可用不上,也不让 2 条数据决定明天的选题。不训练、不拟合,全表可解释,每行都能手算出来。 `applyWeights()` 把命中的桶取**几何平均**当乘数。为什么不是相乘?三个 1.5 倍的桶相乘会变成 3.4 倍的「神话」,几何平均不会被维度数量放大。 --- ## 6. 成本核算:把「cover 掉 token 钱」变成数字 本地账本 `~/.dsh/.dshw-usage.json` 里有逐 turn 的真实成本(CNY)和余额。`lib/usage.mjs` 读它,`defineTool()` 包装器在每次工具调用前后各读一次,把差额记到这条内容上: ``` $ node -e "..." # 或直接看 cost_report 本地账本:$DSH_HOME/.dshw-usage.json 今日支出:0.8 CNY(来源:history) 余额:36.33 CNY 队列成本:12 条|48,200 tokens|3.42 CNY 阶段分布:ingested 4|review 3|published 2|measured 3 ``` 账本读不到时只打一行「账本读不到」,**不编数字**,流水线照常跑。 币种不混算:平台收入是美元、账本是人民币,**没有硬编码汇率**。只有你显式设定 `DSH_USD_CNY` 时才算净额,否则两个原值都留着、`netUsd` 为 `null`。编一个汇率出来只会让你以为自己在赚钱。 达标线(用视频里给的单价口径):$1 / 1000 views → 覆盖每月 ¥50 token ≈ 需要 5–7 万 views/月 ≈ 日更 3 条、每条 600–800 播放。这是可验证的量级。 --- ## 7. 工具清单(10 个) | 工具 | 归属 | 说明 | |---|---|---| | `content_new` | 阿研 | 登记素材,必须填授权 | | `content_score` | 阿筛 | 打分 + 按历史权重给修正分 | | `content_script` | 阿写 | 落盘脚本与标题 | | `content_render` | 任何员工/人 | 只登记渲染参数,不调 ffmpeg | | `content_queue` | 全员 | 看队列;带 id 看详情和轨迹 | | `content_gate` | **仅真人** | approve / kill,闸门在此 | | `content_publish` | 人 | 写本地发布包,不接触平台 | | `metrics_pull` | 阿析 | 回采并冻结特征 | | `weights` | 阿筛/阿析 | `read` 取先验 / `update` 重算 | | `cost_report` | 全员 | 账本 + 队列成本 | 闸门(`ctx.tools.guard`,同步、单调、不可被下层放行): ```js ctx.tools.guard((exec) => { if (exec?.name !== 'content_gate') return undefined if (exec?.agent !== undefined) { return 'content_gate 是人工闸门:只有用户本人能裁决。' } return undefined }) ``` --- ## 8. 编排节拍 员工不是常驻进程——它们是被创建出来的会话。所以节拍靠 DSH 自带的定时提醒把员工叫醒: | 时间 | 谁 | 动作 | 产物 | |---|---|---|---| | 08:00 | 阿研 | 扫素材源、核授权、`content_new` | N 条 `ingested` | | 08:30 | 阿筛 | `weights(read)` → `content_score` | N 条 `scored` | | 09:00 | 阿写 | 给 top K 写稿 `content_script` | `script.md` | | 09:20 | 代码 | `content_render` + `bin/render.mjs` | 成片 → `review` | | 中午 | **人** | `content_queue` → `content_gate` | 放行进 `queued` | | 排期 | **人** | `content_publish` 确认,然后本人发布 | `published` | | 次日 09:00 | 阿析 | `metrics_pull` → `weights(update)` | 权重表 +1 版 | **人每天的操作面只有三下**:早上扫一眼队列、中午点裁决、排期前确认。其余全是员工和代码。 --- ## 9. 接入步骤(三处一行的改动) 我没有改你任何现有文件(这个目录不是 git 仓库,没有回滚网,所以宁可让你自己 apply)。 **① `package.json`** —— exports 加一条: ```json "exports": { ".": "./lib/index.js", "./tools": "./lib/tools.js", "./content": "./lib/content-tools.js", "./package.json": "./package.json" } ``` **② `cordis.patch.yml`** —— 每个员工块末尾加一行(和现有 `team-tools` 完全同构): ```yaml - id: content-tools name: 'dsh-team-employees/content' ``` 再按第 2 节的编制复制三个 `preset-employee-*` 块,`order` 取 22 / 23 / 24,人设建议:
阿筛 / 阿写 / 阿析 的工牌文案 **阿筛(选题)** ``` 你是「阿筛」,团队里的选题员工。你的工牌只有三件事: 1. 先读历史:开工前调用 weights(action=read)。表是空的就明说「还没有样本,本次纯靠判断」。 2. 打分:给 hook / emotion / info 三个 0-100 的分,写清为什么。分要能被人反驳——「感觉不错」不是理由。 3. 排序:标出你打算先做哪几条、放弃了哪几条、为什么。 你不做的事:不写脚本、不改别人打过的分、不替用户决定发不发。 你的分会被冻结并与真实播放量对账,所以别讨好谁,打你真实的判断。 ``` **阿写(成稿)** ``` 你是「阿写」,团队里的成稿员工。你的工牌只有三件事: 1. 按分写稿:只写已经评过分的条目。前三秒必须把人钉住。 2. 写人话:不要「让我们一起探索」这种句子。写完自己读一遍,像不像一个人在说话。 3. 交出可拍的东西:脚本 + 标题(不超过 20 字)。 你不做的事:不改分数、不改授权信息、不自己决定发布。 ``` **阿析(数据)** ``` 你是「阿析」,团队里的数据员工。你的工牌只有三件事: 1. 回采:抄回真实数据,来源如实写(手工抄写 manual,接口取写 api)。 2. 对账:找出「评分高但没人看」和「评分低但爆了」的条目,各举例子。 3. 重算权重:样本够了再 update;不够就明说不够,不许硬凑结论。 你不做的事:不美化数据、不替爆款找借口、不在样本不足时下结论。 一条数据改变不了什么,三十条才说明问题。 ```
**③ `$DSH_HOME/team/employees.json`** —— 加三条: ```json { "id": "select", "preset": "select", "name": "阿筛", "role": "选题员工", "model": "", "note": "只打分、只排序。不下场写稿。" }, { "id": "write", "preset": "write", "name": "阿写", "role": "成稿员工", "model": "", "note": "只写稿。不改分、不决定发布。" }, { "id": "measure", "preset": "measure", "name": "阿析", "role": "数据员工", "model": "", "note": "只回采和对账。样本不足不下结论。" } ``` **验证**(不启动 DSH 就能跑): ```sh node test/content.smoke.mjs # 41/41 node test/smoke.mjs # 11/11,确认没碰坏原来的 ``` 装完完全退出 DSH 再打开,新建会话的模式下拉里应出现三位新员工。 --- ## 10. 已知边界 - **闸门只挡 agent,不挡你**。你自己在会话里点 approve 就是放行——系统假设你会看。它防的是「员工自我放行」,不是防你手滑。 - **`exec.agent` 是宿主的内部字段**。它是唯一能区分「人 vs 员工」的手段,本设计押在它上面。升级 DSH 后请跑一遍自检确认闸门还在拦。 - **账本是 best-effort**。文件格式是宿主写的,不是公开契约;读不到就退回 0,不影响流水线。 - **`bin/render.mjs` 还没写**。`content_render` 只落参数;真正的 ffmpeg 转码是下一步(纯确定性,好写)。 - **面板还没接**。`/team` 目前只显示人和日志;队列、成本、待审清单需要往 `lib/index.js` 加一段渲染,或在 `docs` 之后单独做。 - **不做的事**:不代发、不批量多账号、不绕平台风控。TikTok 2025-09-13 起明文禁止「用于绕过系统的自动化工具」;这条流水线刻意把发布留给人和平台原生排期,就是为了不踩这条线。