架构增量设计:层③规则引擎(action_rules)— social-media-organic-tool
本文件是增量设计,不覆盖已有 ARCHITECTURE.md。 只描述"给成熟系统加一层规则引擎"要新增/迁移什么,不动现有分层、不动现有表、不动现有 llm_guard。 产出方:solution-architect。审签方:Anderson(董事长)。落地方:backend-engineer + frontend-engineer。验收方:qa-engineer + GM。
0. 给 Anderson 的大白话(为什么是加一层,不是重写)
现状:这个工具已经上线在用了。它做的每件"聪明活"——给你推荐搜索方向、判断哪条帖子算爆款、把产品翻译改写成地道社媒文案、套模板生成成品——背后都靠一段写死在代码里的"指令"(提示词)和几个写死的"数字"(比如"每个关键词搜几条""什么算爆款")。
问题:这些"度"当初是工程师凭经验拍的,写死在代码里,你想调只能改代码重新部署。一旦某个度写错了(比如推荐的方向太泛、爆款判太松、翻译改写太夸张),你只能干看着,提不出怎么改,工具就"不好用"。
这一层解决什么:把这 6 处"度"从代码里搬进数据库的一张新表 action_rules,再配一个试验台页面。以后你想调,直接在页面上:
- 喂个真实样本(比如你自己的一个产品、一条真帖子);
- 点"跑一遍"看当前这个度出来的结果;
- 觉得太厚/太薄/太松/太夸张,直接改旋钮(改字数、改语气、改阈值、改提示词措辞),再跑一遍并排对比;
- 满意了存成新版本——不改一行代码、不重新部署、随时能回退到上一版。
为什么不重写:现有系统的骨架(分层、数据库、花钱守卫)全是对的,重写等于把能用的东西砸了重来,又慢又危险。我们只是在旁边挂一张新表 + 3 个接口 + 1 个页面,现有功能一行不动、上线不中断。
你将来能自己调的 6 个旋钮(详见 RULES.md):
- 🎛 R1 搜索方向:方向推得泛还是准、覆盖面多宽
- 🎛 R2 爆款打标:什么算"爆款钩子"、相关度判多严
- 🎛 R3 翻译改写:平台风格、改写幅度(保守 ↔ 大改)
- 🎛 R4 成片文案:语气、长度
- 🎛 R5 逆向模板:模板抽象到多细
- 🎛 R6 搜索抓取:每个词搜几条、哪些网站算社媒、什么排前面
⚠️ 重要:这 6 个旋钮的初值,是我们凭经验给的"合理起步值",标记为 🔴 理论(还没在真数据上验证过)。真正合适的"度"要靠你拿自己的真实样本在试验台里试出来——度是试出来的,不是拍脑袋定死的。这正是这一层的价值。
1. 复用决策(增量改造的裁决:复用什么 / 只新增什么)
这是"加一层"而非"重写"的显式裁决。默认立场:最大化复用现有系统,只补唯一真缺口 action_rules 表。
| 组件 | 裁决 | 依据 |
|---|---|---|
| 六边形分层(adapters/application/domain/composition/config/middleware) | 全部复用,零改动 | 现状已核实:干净分层、102 个 .py、54 测试文件,架构本身是对的 |
| 12 张现有表(materials/trending_posts/templates/…) | 全部复用,零改动 | 规则层只新增 1 张表,不动任何现有表结构 |
src/middleware/llm_guard.py(check_daily_cost / check_input_length / clamp_max_tokens) | 复用做 dryrun 限流 | 已核实四门守卫存在;试验台 dryrun 直接挂这三个函数做限流 + max_tokens,绝不另造 |
Repository 端口 + SQLAlchemy ORM(orm_models.py 已 SQLite/PG 双兼容) | 复用范式 | 新表 ActionRuleORM 照现有 ORM 写法加,走现有 session/migration 通道 |
LLM 调用路径(6 处 use_case 里的 llm.complete(system_prompt=…)) | 改成从库读提示词 | 唯一代码改动:把写死的 _XXX_SYSTEM 常量改成"先查 action_rules,库空回落原常量" |
action_rules 表 | ⭐新增(唯一真缺口) | GM 已核实此表不存在,是本次唯一要设计的新结构 |
| 3 个试验台接口(GET /api/rules、dryrun、versions) | 新增 | 照 COMPONENT-BACKEND.md §4 |
前端试验台 test-bench.js | 新增(装标准件) | scripts/install-test-bench.sh 装真相源,不手改副本 |
项目主基调:brownfield(增量改造)。这不是新项目的复用裁决,而是"给成熟系统挂一层"的复用裁决——上表的汇总结果是:99% 复用 + 挂 1 张新表 + 6 处从常量改为读库(带回落)。
本增量不涉及 fork 外部仓库,故无frontend/external、backend/external、commit hash 钉版等要求。
2. action_rules 数据模型
2.1 表结构
新增表 action_rules,照现有 orm_models.py 的 SQLAlchemy 范式(SQLite M0 / PostgreSQL 云端双兼容):
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
id | Integer | PK, autoincrement | 代理主键(每一版一行,历史不覆盖) |
rule_id | String(16) | NOT NULL, index | 业务规则标识:R1..R6 |
tenant_id | String(64) | NOT NULL, default "default", index | 租户隔离位。当前单租户 → 恒为 "default";云端多租户时按租户过滤(前向兼容,现在不启用逻辑,只留位) |
config | Text(JSON) | NOT NULL | 该规则全部可调项 + 提示词模板,序列化 JSON。提示词/阈值都在这里,代码里不出现写死值 |
version | Integer | NOT NULL | 该 (rule_id, tenant_id) 下自增版本号 |
status | String(16) | NOT NULL, default "draft" | 版本状态机:draft→testing→active→archived。同一 (rule_id, tenant_id) 只能有一行 active |
updated_by | String(64) | NOT NULL | 改动人(服务端从登录态盖章,不信前端) |
updated_at | DateTime | server_default now() | 落库时间 |
唯一性/索引约束:
- 复合唯一:
UNIQUE(rule_id, tenant_id, version)— 防同版本重复写。 - 查询索引:
INDEX(rule_id, tenant_id, status)— "取某规则当前生效版本"是热路径。 - "单一生效版"不变量:存新
active版本时,在同一事务内把该 (rule_id, tenant_id) 旧的active置为archived。用事务保证,不用数据库触发器(保持 SQLite/PG 一致)。
2.2 config JSON 形状(逐规则)
每条规则的 config 是自描述 JSON,含两类字段:旋钮参数 + prompt_template(提示词模板,含 {占位符})。示例(R1,完整定义见 RULES.md):
{
"knobs": { "direction_count_min": 3, "direction_count_max": 5, "max_tokens": 1000,
"anchor_intent": "commercial_review" },
"prompt_template": "You are a social media MARKETING researcher ... Return JSON array: [...]"
}
阈值型规则(R6)的 config 只有knobs、无prompt_template(它不是 LLM 调用): ``json { "knobs": { "max_keywords": 5, "max_results_cap": 10, "allowed_social_hosts": ["x.com","reddit.com","..."], "rank_score_base": 10, "rank_score_mult": 10 } }``
2.3 读取契约(运行时如何用)
在 6 处动作调用点,统一走一个薄 helper(由 backend-engineer 实现,放 application/rules/ 或复用现有 use_case 依赖注入):
resolve_rule(rule_id, tenant_id="default") ->
查 action_rules WHERE rule_id=? AND tenant_id=? AND status="active" ORDER BY version DESC LIMIT 1
命中 → 返回该行 config(JSON 反序列化)
未命中(库空/未 seed)→ 回落到代码里的 _DEFAULTS[rule_id](原常量,只作 fallback 存在)
关键设计:回落保证上线不中断。表建好但没 seed、或某规则从未存过版本时,resolve_rule 回落到原常量,系统行为与当前完全一致——因此这层可以先部署表和代码、后逐条 seed,任何时刻都不会因为"库里没这条规则"而崩。
3. 迁移策略(硬编码常量 → 库,零停机)
原则:现有硬编码常量降级为"默认值 + seed 源",代码改成从库读、库空回落默认。上线全程不中断。
分三步,每步可独立部署、可回滚:
Step A — 建表 + 写默认字典(不改任何调用点)
- 新增
ActionRuleORM+ migration 建action_rules表。 - 把 6 处
_DIRECTIONS_SYSTEM/_TAG_SYSTEM/_TRANSLATE_SYSTEM/_GENERATE_SYSTEM/REVERSE_SYSTEM_PROMPT/ anysearch 常量,原封不动抽进一个集中的_DEFAULTS: dict[rule_id, config](domain 层,纯数据,无行为)。此步系统行为零变化。
Step B — 调用点改成 resolve_rule(带回落)
- 6 处调用点从"直接引用常量"改成"
config = resolve_rule("R1"); system = config['prompt_template'].format(...); max_tokens = clamp_max_tokens(config['knobs']['max_tokens'], settings)"。 - 因表未 seed →
resolve_rule全部回落_DEFAULTS→ 行为仍与当前完全一致。此步可安全上线。
Step C — Seed 初值进库(把 _DEFAULTS 灌成 active 版本)
- 一次性 seed 脚本 / migration:对每个
rule_id,以_DEFAULTS[rule_id]为 config 写入version=1, status="active", updated_by="system-seed", tenant_id="default"。 - 此后
resolve_rule命中库值(与默认字典内容一致 → 行为仍不变),Anderson 从试验台改的新版本从此生效。
回滚:任一步出问题,resolve_rule的回落逻辑保证"删掉库里的行 = 退回原常量行为"。Step C 可通过把 seed 行status改回archived或删行来回退。
_DEFAULTS 常量的去留:保留在代码里作为最终兜底(库彻底不可用时的 last-resort),但它不再是"生效值的来源"——生效值来自库。这满足"提示词/阈值不硬编码为生效路径"的铁律:代码里的常量只是灾难回落,正常路径永远读库。
4. 层⑤劈分(业务规则参数进库 vs 密钥/连接串留 .env)
Anderson 五层模型:层③规则 = 层⑤设置 = 数据库一行。但"设置"里要把两类东西切开,别混。
业务规则参数(→ action_rules 表 + 试验台) | 环境变量(→ .env,ARCHITECTURE §6 管的) | |
|---|---|---|
| 归属 | R1–R6 的旋钮:方向数、爆款阈值、翻译幅度、字数、模板粒度、每词搜几条、社媒域名名单、每条规则的 max_tokens | 密钥 / 连接串 / 基建开关 |
| 本项目实例 | direction_count、relevance 严格度、allowed_social_hosts、max_keywords、各规则 prompt_template | DEEPSEEK_API_KEY、ANYSEARCH_API_KEY、QWEN_API_KEY、DATABASE_URL、REDIS_URL、LLM_DAILY_COST_CAP_USD、LLM_MAX_TOKENS_PER_CALL、LLM_MAX_INPUT_CHARS |
| 谁能改 | 有权限的运营 / Anderson,运行时在试验台改,存版本 | 部署时定,改需重启 |
| 变更影响 | 改度不改代码不重部署 | 改基建,通常要重部署 |
刀切原则(本项目具体判定):
- 进库:R1–R6 全部旋钮 + 6 个
prompt_template。这些"度错了会毁价值、需要试着调"。 - 留 .env:
LLM_DAILY_COST_CAP_USD/LLM_MAX_TOKENS_PER_CALL/LLM_MAX_INPUT_CHARS是全局安全护栏(花钱红线),不是业务度,继续留 settings/.env,由 llm_guard 全局强制。每条规则 config 里的max_tokens是"该动作希望用多少",但最终经clamp_max_tokens被 .env 的全局上限夹住——业务旋钮不能突破安全护栏。
⚠️ 给 backend-engineer 的硬约束:别把任何密钥/连接串塞进 action_rules;别把 R1–R6 的业务旋钮塞进 .env。 两者混淆 = 打回。
5. 试验台 3 接口(概览,详见 API-CONTRACT-RULES.md)
照 COMPONENT-BACKEND.md §4,新增 3 个接口(全部挂现有登录态鉴权 + llm_guard 限流):
| 方法 | 路径 | 一句话 |
|---|---|---|
| GET | /api/rules | 拉当前 tenant 的 R1–R6 规则清单(含当前 active config + 三态状态) |
| POST | /api/rules/{id}/dryrun | 用传入的 config(可能是未存的调整值)对 sample 跑一次,返回 output,不落库、不计正式账、必限流 + max_tokens |
| POST | /api/rules/{id}/versions | 把 config 存为新版本(version 自增),旧 active 归档,可回退 |
dryrun 的花钱红线(硬约束):dryrun 会触发真实 LLM 调用 = 花钱。必须:
- 复用
check_daily_cost(Gate-4 日成本上限)+clamp_max_tokens(Gate-3)+check_input_length(Gate-2); - 复用现有
fastapi-limiterRateLimiter 做每用户/IP 限流; - 额外给每 tenant 每日 dryrun 次数上限(防"改旋钮上瘾"刷爆费用)。
R6(纯阈值/过滤规则,非 LLM)的 dryrun 不花钱:用传入 config 对 sample 跑一遍 anysearch 过滤/排序逻辑,返回"哪些被留下、engagement_score 怎么算"的结果即可,无需 LLM 限流(但仍限流防刷)。
6. 护栏:哪些动作不配规则(元规则,防过度工程)
按 RULES.md §0 元规则逐一判定,以下标「否」,不配旋钮(硬塞 = 违反 YAGNI):
| 动作 | 判定 | 理由 |
|---|---|---|
assistant_use_case.py:38 _SYSTEM_TEMPLATE(AI 助手人设) | 否 / 低优先 | 已核实:它是固定人设 + 静态 page→知识 映射的 in-app 帮助,输出不进入生产内容、不影响素材/爆款/成品价值。"度"错了顶多帮助文案生硬,不"毁价值"。不配旋钮。若将来要改助手文案,走"后端下发 tour/page 文案"的既有轻通道(COMPONENT-BACKEND §2/§3),不进 action_rules |
| annotations / users / materials CRUD、下载 ZIP、周计划槽位排布(确定性算法)、发布任务状态机 | 否 | 纯 CRUD / 固定业务逻辑 / 无"度"可调 → 配规则 = 过度工程 |
| llm_guard 的三门(日成本/输入长度/max_tokens) | 否(留 .env) | 是全局安全护栏,非业务度,见 §4 |
元规则本身是"第一条规则":哪些动作要旋钮 = 只有"度错毁价值的 LLM/判断动作"。本项目精确命中 6 条(R1–R6),其余一律不配。
7. 附:跨阶段数据流落库审计(次要项,只标注不改设计)
GM 已判定本项目跨阶段"总病根"不严重(基本已落库)。此段只核一遍 discovery→material→translate→produce 接力产物是否真落库、有无只存前端内存的断点,列出需 QA 跑真持久化 E2E 的链路。不改任何设计。
核查结论(基于已读源码 + 已知 12 张表):
| 接力段 | 上一步产物 | 落库表 | 落库状态 | 备注 |
|---|---|---|---|---|
| material → discovery | 素材(product_name/卖点/图/document_text) | materials | ✅ 已落库 | get_search_directions 按 material_id 读库,非内存 |
| discovery(方向)→ search | LLM 生成的 directions | ⚠️ 前端持有 | 中转态 | directions 返回前端供用户选关键词。这是"中间推荐"不是"跨阶段最终态",用户选完关键词才进下一步,可接受——但需 QA 确认"刷新页面后是否要求重生成"符合预期 |
| search → tag → select | 抓到的 posts + 标签 | trending_posts(select_posts bulk_insert) | ✅ 已落库 | 用户多选后 select_posts 真 bulk_insert 进 trending_posts,含 tags。跨阶段接力落库 |
| trending → translate | 选中的爆款 / 素材 | translations | ✅ 已落库 | 翻译带 HITL 状态机(approve/reject),状态进库 |
| translate → produce | translation + template | generated_posts | ✅ 已落库 | 成片带 HITL 确认状态机 |
需 QA 跑的真持久化跨阶段 E2E(禁 mock,打真库):
- 主接力链:上传素材 → 生成方向 → 搜索 → 打标 → 多选入库 → 刷新/重登 → 确认
trending_posts读得回选中项 → 基于它翻译 → 生产成片 → 确认generated_posts读得回。 - HITL 断点:翻译 approve 后重登,确认
translations.status持久;produce reject 后重登,确认generated_posts状态持久、可重生成。 - 规则层新增 E2E:在试验台存 R1 新版本 → 重登 →
GET /api/rules读得回新版本 → discovery 用新版本 config 跑(证明"改度真生效、真落库")。
唯一需盯的中转态:discovery 生成的 directions 目前只回前端不落库。判定:可接受(它是"给用户挑关键词的一次性推荐",不是需要下一阶段读回的最终产物;用户挑完的结果最终落 trending_posts)。但请 QA 在 E2E 里显式验证"方向页刷新后的行为"符合 PRD 预期,若 PRD 要求方向可保存/复用,则需补一张表——此处仅标注,不改设计。
8. 交付给 engineer 的落地清单(增量,不越界)
- backend-engineer:建
action_rules表 +ActionRuleORM;抽_DEFAULTS;实现resolve_rule(带回落);6 处调用点改读库;实现 3 接口(dryrun 挂 llm_guard 三门 + limiter + 每日 dryrun 上限);seed 脚本。TDD:先写"库空回落=原行为"测试(RED)再改。brownfield 铁律:改完 54 个现有测试必须全绿。 - frontend-engineer:
bash scripts/install-test-bench.sh <前端静态目录>装test-bench.js;配window.AOS_RULES_API="/api/rules"+AOS_ANNOTATE_AUTH同款 JWT getter;挂到"设置/规则试验台"入口(只给运营/Anderson,别链进普通用户主流程)。不手改标准件副本,要改改真相源重装。 - 两者都不越界:backend 不碰前端目录,frontend 不碰后端目录。
本次引用的 skills
cost-aware-llm-pipeline— dryrun 的限流 + max_tokens + 每日次数上限设计context-budget— 提示词模板外置后仍受输入长度门约束architecture-decision-records— 增量改造的复用裁决表结构database-migrations— action_rules 建表 + seed 的零停机三步迁移postgres-patterns/python-patterns— ORM 范式与 SQLite/PG 双兼容、config JSON 存取logic-first-ui— 试验台"喂样本→跑→评→调→存版本"的调试闭环