架构增量设计:层③规则引擎(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,再配一个试验台页面。以后你想调,直接在页面上:

  1. 喂个真实样本(比如你自己的一个产品、一条真帖子);
  2. 点"跑一遍"看当前这个度出来的结果;
  3. 觉得太厚/太薄/太松/太夸张,直接改旋钮(改字数、改语气、改阈值、改提示词措辞),再跑一遍并排对比;
  4. 满意了存成新版本——不改一行代码、不重新部署、随时能回退到上一版。

为什么不重写:现有系统的骨架(分层、数据库、花钱守卫)全是对的,重写等于把能用的东西砸了重来,又慢又危险。我们只是在旁边挂一张新表 + 3 个接口 + 1 个页面,现有功能一行不动、上线不中断。

你将来能自己调的 6 个旋钮(详见 RULES.md):

⚠️ 重要:这 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/externalbackend/external、commit hash 钉版等要求。

2. action_rules 数据模型

2.1 表结构

新增表 action_rules,照现有 orm_models.py 的 SQLAlchemy 范式(SQLite M0 / PostgreSQL 云端双兼容):

类型约束说明
idIntegerPK, autoincrement代理主键(每一版一行,历史不覆盖)
rule_idString(16)NOT NULL, index业务规则标识:R1..R6
tenant_idString(64)NOT NULL, default "default", index租户隔离位。当前单租户 → 恒为 "default";云端多租户时按租户过滤(前向兼容,现在不启用逻辑,只留位)
configText(JSON)NOT NULL该规则全部可调项 + 提示词模板,序列化 JSON。提示词/阈值都在这里,代码里不出现写死值
versionIntegerNOT NULL该 (rule_id, tenant_id) 下自增版本号
statusString(16)NOT NULL, default "draft"版本状态机:drafttestingactivearchived同一 (rule_id, tenant_id) 只能有一行 active
updated_byString(64)NOT NULL改动人(服务端从登录态盖章,不信前端)
updated_atDateTimeserver_default now()落库时间

唯一性/索引约束:

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 — 建表 + 写默认字典(不改任何调用点)

Step B — 调用点改成 resolve_rule(带回落)

Step C — Seed 初值进库(把 _DEFAULTS 灌成 active 版本)

回滚:任一步出问题,resolve_rule 的回落逻辑保证"删掉库里的行 = 退回原常量行为"。Step C 可通过把 seed 行 status 改回 archived 或删行来回退。

_DEFAULTS 常量的去留:保留在代码里作为最终兜底(库彻底不可用时的 last-resort),但它不再是"生效值的来源"——生效值来自库。这满足"提示词/阈值不硬编码为生效路径"的铁律:代码里的常量只是灾难回落,正常路径永远读库。


4. 层⑤劈分(业务规则参数进库 vs 密钥/连接串留 .env)

Anderson 五层模型:层③规则 = 层⑤设置 = 数据库一行。但"设置"里要把两类东西切开,别混。
业务规则参数(→ action_rules 表 + 试验台)环境变量(→ .env,ARCHITECTURE §6 管的)
归属R1–R6 的旋钮:方向数、爆款阈值、翻译幅度、字数、模板粒度、每词搜几条、社媒域名名单、每条规则的 max_tokens密钥 / 连接串 / 基建开关
本项目实例direction_countrelevance 严格度allowed_social_hostsmax_keywords、各规则 prompt_templateDEEPSEEK_API_KEYANYSEARCH_API_KEYQWEN_API_KEYDATABASE_URLREDIS_URLLLM_DAILY_COST_CAP_USDLLM_MAX_TOKENS_PER_CALLLLM_MAX_INPUT_CHARS
谁能改有权限的运营 / Anderson,运行时在试验台改,存版本部署时定,改需重启
变更影响改度不改代码不重部署改基建,通常要重部署

刀切原则(本项目具体判定):

⚠️ 给 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 调用 = 花钱。必须:

  1. 复用 check_daily_cost(Gate-4 日成本上限)+ clamp_max_tokens(Gate-3)+ check_input_length(Gate-2);
  2. 复用现有 fastapi-limiter RateLimiter 做每用户/IP 限流;
  3. 额外给每 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(方向)→ searchLLM 生成的 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 → producetranslation + templategenerated_posts✅ 已落库成片带 HITL 确认状态机

需 QA 跑的真持久化跨阶段 E2E(禁 mock,打真库):

  1. 主接力链:上传素材 → 生成方向 → 搜索 → 打标 → 多选入库 → 刷新/重登 → 确认 trending_posts 读得回选中项 → 基于它翻译 → 生产成片 → 确认 generated_posts 读得回。
  2. HITL 断点:翻译 approve 后重登,确认 translations.status 持久;produce reject 后重登,确认 generated_posts 状态持久、可重生成。
  3. 规则层新增 E2E:在试验台存 R1 新版本 → 重登 → GET /api/rules 读得回新版本 → discovery 用新版本 config 跑(证明"改度真生效、真落库")。

唯一需盯的中转态:discovery 生成的 directions 目前只回前端不落库。判定:可接受(它是"给用户挑关键词的一次性推荐",不是需要下一阶段读回的最终产物;用户挑完的结果最终落 trending_posts)。但请 QA 在 E2E 里显式验证"方向页刷新后的行为"符合 PRD 预期,若 PRD 要求方向可保存/复用,则需补一张表——此处仅标注,不改设计。


8. 交付给 engineer 的落地清单(增量,不越界)


本次引用的 skills

由 v5 docs-to-html 离线生成 · 2026-07-14 23:03