baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

42
.claude/rules/api.md Normal file
View File

@@ -0,0 +1,42 @@
---
paths:
- "src/api/**"
- "src/routers/**"
- "src/services/**"
---
# API 层开发规则Clover 后端)
> 承接架构方案v0.3 基石 + API契约.md冲突以架构方案为准。
## 路由规范
- RESTful 命名:资源用名词复数(/tasks, /products, /api-keys
- 版本前缀:/api/v1/
- 控制器只做:参数校验 → 调 service → 格式化响应,**不含业务逻辑**
## 响应格式(统一包络)
- 成功:`{ "code": 0, "data": {...} }`
- 失败:`{ "code": <错误码>, "message": "用户可读信息" }`
- HTTP 状态码与业务 code 分离见契约§0七类错误码
## 安全基石B/C
- 所有用户输入必须校验(类型、长度、范围)
- SQL 查询使用参数化,禁止字符串拼接
- **API Key 绝不进 Celery 参数**,只传 task_idworker 内查库→Fernet解密→局部变量
- FERNET_KEY 走环境变量,绝不进代码库;解密不落盘、不打日志
- 敏感操作记审计日志ai_call_logs
## 多租户隔离(红线)
- 所有查询强制带 workspace_id 条件
- 写操作 + 切 workspace 必须查 workspace_members 校验权限
- workspace_id / product_id 从 JWT + 上下文自动带,前端不传(防越权)
- 所有 workspace 相关 Repo 继承 base_workspace_repo
## 错误处理官网V1坑3
- service 错误不许静默吞,至少打 error log
- AI/token站调用失败归因到个人 key错误码50002
- DB 枚举约束要有对应代码侧常量文件
## 业务参数不写死基石A
- 禁止出现品类/数量/角度任何枚举常量
- "看起来像枚举"的业务概念 → 做成数据不做成代码

49
.claude/rules/db.md Normal file
View File

@@ -0,0 +1,49 @@
---
paths:
- "src/models/**"
- "alembic/**"
- "migrations/**"
---
# 数据库规则Clover · MySQL + Alembic
> 承接架构方案v0.3 第三章16表。一期建14张matrix_accounts/preference_profile 二期预留。
## 迁移
- 每次 schema 变更创建迁移文件,不手动改数据库
- 迁移文件不可修改已提交的,只能新增
- 迁移必须包含 up 和 down
- 按序001(banana搬3张) → 002(多租户基础) → 003(业务7张) → 004(飞轮)
## 查询
- 禁止 SELECT *,明确列出字段
- 大表查询必须有索引支撑,新增查询模式时同步加索引
- N+1 问题:关联查询用 JOIN 或批量加载
- 飞轮聚合走 MySQL JOINMongoDB 只存 AI debug trace业务不依赖
## 命名
- 表名snake_case 复数users, generation_tasks, preference_events
- 字段名snake_casecreated_at, workspace_id
- 索引名idx_表名_字段名
## 关键约束(架构方案)
- 任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
- user_api_keysUNIQUE(user_id, workspace_id, provider)
- 所有业务表必须有 workspace_id多租户逻辑隔离
- preference_events 必须有 workspace_id + product_id跨公司隔离+按产品分开学)
## 状态机generation_tasks.status
pending → generating → pending_selection → pending_review → approved/rejected → archived
- 打回后回到 pending_selection
- 非法流转返错误码 40901
## 字段红线
- API Key 字段encrypted_keyFernet**不存 url 字段**token站固定自家站
- users 表:删除 banana 的 credits 字段
- eval_score留 NULL不接 banana 假评分)
- data_ownershipclient_data / platform_asset洞7分层归属
- banned_words.levelauto_fix / soft_warn / hard_block三级
## 枚举约束
- DB 枚举约束要有对应代码侧常量文件(消除三套命名打架)
- 品类products.category是纯数据字段禁止任何品类枚举常量

46
.claude/rules/frontend.md Normal file
View File

@@ -0,0 +1,46 @@
---
paths:
- "src/components/**"
- "src/app/**"
- "src/pages/**"
- "src/stores/**"
---
# 前端开发规则Clover · Next.js
> 承接 前端交互设计.md5屏金标准+ API契约.md §3 DTO。
## 组件规范
- 单一职责:一个组件只做一件事
- Props 必须定义类型TypeScript interface
- 避免 prop drilling 超 3 层,用 Context 或状态管理
## DTO 对接堵官网V1坑4
- 所有 DTO 字段严格按 API契约§3**不许自己假设嵌套结构**
- 后端响应与契约不符立即报 Lead不私下适配
- workspace_id / product_id 不在前端传
## SSE异步生图电影
- 按 event_seq 去重,断线重连补发历史事件
- 11类事件按契约§2渲染0/N进度 + 谁好谁先冒 + 单批失败重试 + 能离开
- flywheel_injected 事件 → 显"本次已注入:最近偏好/打回原因"
## 样式
- 用 CSS Modules 或 Tailwind不用全局样式
- 响应断点统一sm(640) md(768) lg(1024) xl(1280)
- 颜色/间距用 design token不硬编码
## 可访问性
- 交互元素必须有 aria-label
- 图片必须有 alt 文本
- 键盘导航可用
## 红线CLAUDE.md
- ❌ 不展示 Token 余额、不做积分页key 去中转站后台看)
- ❌ 飞轮隐形:无"训练AI"按钮,只在生成时显注入提示
- ❌ 不做"自动发布到小红书"——只到确认预览
- ❌ 不做移动端(一期)
## 状态管理
- 4 个 Store任务 / 候选 / 审核 / 全局
- 异步态用骨架屏错误态对接契约§0错误码

53
.claude/settings.json Normal file
View File

@@ -0,0 +1,53 @@
{
"//说明": "Clover 仓库根 Hooks 配置。四道质量门禁承接治理指南§9.3模板。退出码 0=放行 2=阻止 其他非零=脚本错误。母本在 启动包/.claude/。",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"hooks": {
"PreToolUse": [
{
"//门禁1": "危险命令拦截",
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "cmd=$(jq -r '.tool_input.command // empty'); if echo \"$cmd\" | grep -qE 'rm -rf|DROP TABLE|--force|--no-verify|reset --hard|git push.*main|git push.*master'; then echo \"危险命令被拦截: $cmd\" >&2; exit 2; fi; exit 0"
}
]
},
{
"//门禁2": "敏感文件保护 + 源目录只读保护",
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "file=$(jq -r '.tool_input.file_path // empty'); if echo \"$file\" | grep -qE '\\.env|\\.key|credentials|secrets|FERNET'; then echo \"禁止编辑敏感文件: $file\" >&2; exit 2; fi; if echo \"$file\" | grep -qE 'O2中间件平台/banana|产品包/worker|万牛会L1准备/worker'; then echo \"源目录只读,禁止修改(铁律): $file\" >&2; exit 2; fi; exit 0"
}
]
}
],
"PostToolUse": [
{
"//门禁3": "自动 lint",
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "file=$(jq -r '.tool_input.file_path // empty'); case \"$file\" in *.ts|*.tsx|*.js|*.jsx) npx eslint --fix \"$file\" 2>&1 || true;; *.py) ruff check --fix \"$file\" 2>&1 || true;; esac; exit 0"
}
]
}
],
"Stop": [
{
"//门禁4": "测试验证 + 临时文件提醒",
"hooks": [
{
"type": "command",
"command": "bash -c 'echo \"请确认是否已运行测试并通过(软件测试过≠内容质量过,内容需北哥抽检)。\" >&2; tmp=$(find . -name \"*_tmp*\" 2>/dev/null | head -5); if [ -n \"$tmp\" ]; then echo \"发现临时文件待清理:\" >&2; echo \"$tmp\" >&2; fi; exit 0'"
}
]
}
]
}
}

View File

@@ -0,0 +1,67 @@
---
name: clover-loop
version: 1.0.0
description: |
Clover 内容生产闭环开发 SKILL改造自 ralph-loop
Memory is Poison, Tests are Cure —— 但 Clover 的 Test 不止单元测试,还有北哥的眼睛。
六阶段闭环:调研→约束→生成→验证→校准→闭环。质量过关=机器过滤+北哥抽检两阶段。
triggers:
- clover-loop
- 内容闭环
- 生产闭环
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- AskUserQuestion
---
# Clover Loop SKILL
> 核心:机器层(machine_passed)只是进抽检队列,北哥点头(human_passed)才是真过关。
## 六阶段
### P0 调研ralph-loop 没有,北哥"先调研"
读架构方案v0.3 + 对应 PRD + 扒包落盘,确认本任务边界。
不充分就进 P2 = 在错误的层做对的事PSR-L 教训)。
### P1 约束
冻结 task_spec输入/输出/验证命令/期望exit_code/质量阈值。
`python scripts/loop_state.py create <task_id>`
### P2 生成
写代码 + 写验证脚本必须含断言、返回exit_code
`python scripts/loop_state.py advance`
### P3 验证(机器层)
跑机器门禁lint / test / 违禁词 / 五维≥90 / 去重。
全过 → status=machine_passed → 进 P4不过 → 记失败,回 P2。
### P4 校准ralph-loop 没有,专治"分高但烂"
机器过的内容样本,按维度比对北哥标准;同质化/跑偏单独标记。
产出**抽检样本包**交北哥 → 北哥判断回写质量标准(喂飞轮)。
### P5 闭环
- 北哥抽检通过 → status=human_passed → 归档 + 结晶
- 北哥打回 / 验证不过 → 分析根因
- 同层撞墙 **2 次** → 强制 Pivot换层不换参记 pivots[]
- Pivot 后仍不过 / 北哥连续否决同方向 → status=blocked停循环请人介入
## 状态机(.clover-loop-state.json
5 态in_progress / machine_passed / human_passed / pivoted / blocked
关键machine_passed ≠ human_passed
## 命令
| 命令 | 功能 |
|------|------|
| /clover-loop | 启动六阶段闭环 |
| /clover-loop:status | 查当前状态 |
| /clover-loop:pivot | 手动触发换层 |
## 铁律
- 撞墙 2 次必 Pivot对齐 CLAUDE.md 失败2次
- 北哥不耐烦/连续否决 = 路径选错层的信号,立即 Pivot 不调参
- 软件测试过 ≠ 内容质量过

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Clover Loop State Manager改造自 ralph-loop state_manager
六阶段 + 5态 + 机器/人工双层验收 + Pivot 追踪。
"""
import json
import sys
from datetime import datetime
from pathlib import Path
STATE_FILE = ".clover-loop-state.json"
PHASES = {"0": "调研", "1": "约束", "2": "生成", "3": "验证", "4": "校准", "5": "闭环"}
def _path(work_dir=None):
base = Path(work_dir) if work_dir else Path.cwd()
return base / STATE_FILE
def load(work_dir=None):
p = _path(work_dir)
return json.load(open(p, encoding="utf-8")) if p.exists() else None
def save(state, work_dir=None):
state["updated_at"] = datetime.now().isoformat()
json.dump(state, open(_path(work_dir), "w", encoding="utf-8"),
indent=2, ensure_ascii=False)
def create(task_id, work_dir=None):
state = {
"task_id": task_id,
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"current_phase": 0,
"max_retries_per_layer": 2, # 单层撞墙2次→Pivot
"machine_gate": {"lint": None, "test": None, "banned_word": None,
"score": None, "dedup": None},
"human_review": {"status": "pending", "sampled": 0,
"approved": 0, "rejected": 0},
"status": "in_progress", # in_progress/machine_passed/human_passed/pivoted/blocked
"layer_attempts": {}, # {层名: 失败次数}
"pivots": [],
"logs": [],
}
save(state, work_dir)
return state
def advance(state, work_dir=None):
cur = state["current_phase"]
if cur < 5:
state["current_phase"] = cur + 1
save(state, work_dir)
return state
def machine_gate(state, key, result, work_dir=None):
"""记录机器门禁单项结果。全过则置 machine_passed。"""
state["machine_gate"][key] = result
g = state["machine_gate"]
passed = all(v in ("pass", True) or (isinstance(v, (int, float)) and v >= 90)
for v in g.values() if v is not None)
if passed and all(v is not None for v in g.values()):
state["status"] = "machine_passed"
save(state, work_dir)
return state
def record_fail(state, layer, error=None, work_dir=None):
"""记录某层失败。同层2次→强制Pivot。"""
state["layer_attempts"][layer] = state["layer_attempts"].get(layer, 0) + 1
state["logs"].append({"ts": datetime.now().isoformat(),
"layer": layer, "error": error})
if state["layer_attempts"][layer] >= state["max_retries_per_layer"]:
state["status"] = "pivoted"
state["pivots"].append({"layer": layer, "ts": datetime.now().isoformat(),
"reason": f"{layer}层撞墙{state['layer_attempts'][layer]}"})
save(state, work_dir)
return state
def human_review(state, action, work_dir=None):
"""北哥抽检approve/reject。approve累计达标→human_passed。"""
hr = state["human_review"]
hr["sampled"] += 1
if action == "approve":
hr["approved"] += 1
hr["status"] = "approved"
state["status"] = "human_passed"
elif action == "reject":
hr["rejected"] += 1
hr["status"] = "rejected"
state["status"] = "blocked" # 打回=停循环请人介入
save(state, work_dir)
return state
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: loop_state.py <create|load|advance|gate|fail|review> [args]")
print(" create <task_id> / advance / gate <key> <result>")
print(" fail <layer> [error] / review <approve|reject>")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "create":
print(json.dumps(create(sys.argv[2]), ensure_ascii=False, indent=2))
elif cmd == "load":
s = load()
print(json.dumps(s, ensure_ascii=False, indent=2) if s else "无状态")
elif cmd == "advance":
s = load()
if s:
advance(s)
print(f"进入 P{s['current_phase']} {PHASES[str(s['current_phase'])]}")
elif cmd == "gate":
s = load()
if s:
key, raw = sys.argv[2], sys.argv[3]
val = float(raw) if raw.replace(".", "").isdigit() else raw
machine_gate(s, key, val)
print(f"门禁 {key}={val}, status={s['status']}")
elif cmd == "fail":
s = load()
if s:
record_fail(s, sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else None)
print(f"{sys.argv[2]}层失败{s['layer_attempts'][sys.argv[2]]}次, status={s['status']}")
elif cmd == "review":
s = load()
if s:
human_review(s, sys.argv[2])
print(f"抽检{sys.argv[2]}, status={s['status']}")

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# 密钥 / 环境变量(绝不入库 — CLAUDE.md 红线key 收口环境变量)
.env
.env.*
!.env.example
*.key
*.pem
# Python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# Node / 前端
node_modules/
.next/
dist/
build/
# 本地数据 / 日志
logs/
*.log
.DS_Store
# Celery / Redis 本地
celerybeat-schedule
dump.rdb
.env.bak

View File

@@ -0,0 +1,174 @@
# Clover Agent 团队 + 循环任务设计(开工质量把关)
> 用途:开工前把"agent 怎么组队、循环任务怎么保质量"定死,作为 board 4 启动包 + board 7 编排的地基。
> 决策人:倩倩姐拍板机制,北哥定内容质量标准。
## 0. 三方源材料对照(逐字读完)
| 来源 | 给了什么 | 对 Clover 的用法 |
|------|---------|-----------------|
| 官方《Agent Team 详解》17页 | 架构原理 + 4 协作模式 + Hooks 门禁 + 成本控制 | 团队拓扑 + 主协作模式 |
| 官方《项目治理指南》41页 | 四级治理 + 质量四件套 + 全套可抄模板 | Clover 定级 L3 + 模板直接套 |
| ralph-loop 源码 | 循环任务最小实现(4Phase+状态机+重试) | 循环任务骨架 |
| 逆向项目档案(真跑过的6角色team) | PSR-L + 撞墙血泪 + 角色卡三段式 | 循环升级 + 角色卡规范 |
| 北哥会议录音 | goal模式 + 闭环架构 + 判断力锚架构 | 循环任务的"魂" |
| 官网V1经验萃取(自产) | 6保留 + 6坑 | 规避坑 + 继承机制 |
## 1. 把关结论速览
源材料读完,对 Clover 最关键的迭代是 3 条:
1. **循环任务**从 ralph-loop 的"测试即真理"升级成北哥的"闭环架构"——ralph-loop 只认 exit_code能跑就过北哥要"调研→生成→验证→**校准**→不过返工",且**质量过关的最终拍板权在人不在机器**。
2. **agent team** 主模式定为"全栈并行·契约优先"——integration-lead 先冻结 OpenAPI 契约,前后端再并行开工,堵住官网 V1"前端假设字段、联调爆炸"的坑。
3. **启动包必须提前备好**,启动日只 validate 不 generate——官网 V1 最大的坑是脚手架启动当天临时造、第一天下午才能安全开发;这次源材料给了全套可抄模板,彻底规避。
定级Clover 属治理指南的 **L3 协作级**5-15 人体量、多模块、有质量要求),继承 L3 全套(质量四件套 + Agent Team + CI
两个已拍板决策:
- 循环任务质量判断 = **机器过滤 + 北哥抽检两阶段**
- 团队规模 = **精简 5 角色**
## 2. 循环任务设计loop task
ralph-loop 骨架能用,但直接套到 Clover 有 3 个质量漏洞,补 3 道闸。
### 2.1 三个漏洞 → 三道闸
| ralph-loop 原版 | Clover 场景漏洞 | 补的闸 |
|----------------|----------------|--------|
| exit_code=0 就算过 | 文案能生成≠不同质化;图能出≠不跑偏 | 验收**两层**:机器先过滤(违禁词/五维评分≥90/去重) → **北哥人工抽检定标准** |
| 4 Phase 无"调研/校准" | 上来就生成,不先看清全流程 | 补成北哥闭环:**调研→生成→验证→校准→返工** |
| max_retries=3 硬切 | 撞墙3次还在原地换参数 | 逆向铁律:**撞墙2次必 Pivot**(换层不换参),对齐 CLAUDE.md 失败2次规则 |
核心一句:**"Memory is Poison, Tests are Cure"是对的,但 Clover 的 Test 不止单元测试,还有北哥的眼睛**——落在架构方案 v0.3 基石 D"人工判断标准落点"和飞轮四点验收。
### 2.2 Clover 闭环六阶段(升级版 ralph-loop
```
P0 调研 → 读架构方案v0.3 + PRD + 扒包落盘,确认本任务边界(对应北哥"先调研"
P1 约束 → 冻结 task_spec输入/输出/验证命令/期望exit_code/质量阈值
P2 生成 → 写代码 + 写验证脚本验证脚本必须含断言、返回exit_code
P3 验证 → 跑验证脚本机器层lint/test/违禁词/五维≥90/去重)
P4 校准 → ⭐新增:机器过的样本,按维度比对北哥标准;同质化/跑偏单独标记
P5 闭环 → 通过→归档+结晶不过→分析根因撞墙2次换层(Pivot),超限暂停请人介入
```
P4 校准是 ralph-loop 没有的,专治"分数高但实际烂"。校准产出**抽检样本包**交北哥,北哥的判断回写成质量标准(喂飞轮)。
### 2.3 状态机(沿用 ralph-loop 的 .clover-loop-state.json
```json
{
"task_id": "TASK-xxx", "current_phase": 2, "attempts": 1,
"max_retries_per_layer": 2, // 单层撞墙上限2次→Pivot
"machine_gate": {"lint":"pass","test":"pass","banned_word":"pass","score":92,"dedup":"pass"},
"human_review": {"status":"pending","sampled":0,"approved":0,"rejected":0},
"status": "in_progress", // in_progress / machine_passed / human_passed / pivoted / blocked
"pivots": [], "logs": []
}
```
关键差异:状态从 ralph-loop 的 2 态completed/max_retries_exceeded扩成 5 态,**machine_passed ≠ human_passed**——机器过只是进抽检队列,北哥点头才是真过。这就是"质量过关不是能下载"的落地。
### 2.4 触发与人介入
- 单层(同一个错误层)连续失败 **2 次** → 不再换参数,强制 Pivot 换思路/换层,记 `pivots[]`
- Pivot 后仍不过 或 北哥抽检打回 → status=blocked停循环结构化报告请北哥/倩倩姐介入。
- 北哥不耐烦/连续否决同方向 = **路径选错了层**的信号(逆向项目血泪),立即 Pivot 不要再调参。
## 3. Agent 团队编排agent team
### 3.1 拓扑Orchestrator-Workers精简 5 角色
```
┌─────────────┐
│ Lead/集成 │ 冻结契约+全局状态+最终验收(兼 integration-lead
└──────┬──────┘
┌────────────────┼────────────────┐
┌────▼────┐ ┌─────▼─────┐ ┌────▼─────┐
│ 后端 BE │ │ 前端 FE │ │ 审查 QA │ 审查兼测试运行
└─────────┘ └───────────┘ └──────────┘
└────────────────┬────────────────┘
┌──────▼──────┐
│ AI引擎 AIE │ 文案/生图/去水印核心(产品命脉,单列)
└─────────────┘
```
砍掉官网 V1 的独立"文档 agent",文档并入各 agent 的 standup 自我披露。AI 引擎从后端拆出来单列——它是 Clover 的命脉(文案内核/分镜/去水印),值得一个专人。
### 3.2 主协作模式全栈并行·契约优先Contract First
1. **Lead 先冻结接口契约**OpenAPI + TS 类型),契约里 `type:object` 不许留空(官网 V1 坑4
2. 契约冻结后BE / FE / AIE **并行开工**,互相不等。
3. integration-lead=Lead**开工当天做契约偏离全量扫描**,产出结构化偏离清单(官网 V1 保留3
### 3.3 审查模式:异构对抗(仅用于关键决策点)
PRD 审查、安全审计、架构选型用对抗三角Builder 出方案 → Critic魔鬼代言人找漏洞 → Synthesizer 综合。日常开发不用,避免 token 浪费。
### 3.4 成本控制(混合模型)
| 角色/任务 | 模型 | 理由 |
|----------|------|------|
| Lead / AI引擎核心 / 架构决策 | opus | 命脉,要最强推理 |
| 后端 / 前端 日常编码 | sonnet | 平衡 |
| QA 跑测试/分析日志 / 违禁词/评分 | haiku | 量大、逻辑简单、省钱 |
完成即关闭Aggressive Cleanupagent 干完任务立即 shutdown闲置 agent 维持上下文也烧 token。
### 3.5 质量门禁(继承 + 叠加)
- **Stop Hook** 强制跑测试(官网 V1 保留5不允许"我觉得对了"就算完。
- **但软件测试过 ≠ 内容质量过**:循环任务的内容产出叠加 §2 的机器过滤 + 北哥抽检。
## 4. 角色卡规范5 角色)
吸收逆向项目比官网 V1 更狠的"三段式 + 反模式",每张角色卡含 5 块:
1. **读什么**(必读文档,精确到文件路径)
2. **写什么**(产出物边界)
3. **依赖谁**(上游契约 / 下游消费方)
4. **强制 + 禁止**(禁止项极具体,如"❌ api 层不能 import 基础设施层"而非"别写坏代码"
5. **standup 自我披露**(每天写:文件清单/TODO/已知bug/契约不一致处)
| 角色 | 读 | 写 | 依赖 | 模型 |
|------|----|----|------|------|
| Lead/集成 | 架构方案v0.3 + 3份PRD + CLAUDE.md | 契约冻结 + 偏离清单 + 最终验收 | 全员 standup | opus |
| 后端 BE | PRD-后端 + rules/api.md + rules/db.md | API + 队列 + 存档 + 多租户 | Lead契约 | sonnet |
| 前端 FE | PRD-前端 + 前端交互设计.md + rules/frontend.md | 5屏 + 飞轮隐形 + 异步生图电影 | Lead契约 | sonnet |
| AI引擎 AIE | 产品包扒包落盘 + banana扒包 + PRD-后端Ch4 | 文案双轨/生图edits/去水印/评分 | Lead契约 | opus |
| 审查 QA | 全员产出 + 验证脚本 | 跑测试 + 契约符合 + 清理临时文件 | 全员代码 | haiku |
## 5. 反模式清单agent 必须 NOT 做)
吸收逆向项目的"反模式10条"思路,结合 Clover 红线:
1. ❌ 不动 banana / 产品包源目录(只读复制,不 cd 进去 Edit/Write——CLAUDE.md 铁律。
2. ❌ 不边盖边修Clover 是全新独立项目,不在 banana 上建。
3. ❌ 契约 `type:object` 不许留空——冻结前所有嵌套对象一级子字段必须定义官网V1坑4
4. ❌ service 错误不许静默吞——至少打 error log官网V1坑3
5. ❌ 同一层撞墙超 2 次还在调参——必须 Pivot 换层(逆向血泪 + CLAUDE.md失败2次
6. ❌ 前端不展示 Token 余额 / 不做积分页CLAUDE.md前端红线
7. ❌ 不自动发布到小红书——只到"确认预览",发布是人的动作。
8. ❌ 内容"评分高"不等于"质量过关"——必须过北哥抽检才算 human_passed。
9. ❌ 临时调试文件不许留——带 `_tmp` 后缀QA agent 收尾清理官网V1坑5
10. ❌ P0 模块不许无 plan 就开工——integration-lead 检查项standup 报"未plan的P0"官网V1坑6
11. ❌ 新建文件不超 100 行,单次编辑不超 100 行CLAUDE.md 约束)。
12. ❌ key 不进 Celery、不落盘明文——Fernet 加密只传 task_id架构方案基石B
## 6. 落到 board 的下一步
本文档把"怎么组队、循环怎么保质量"定死了,接下来:
- **board 4 启动包**(启动前一天备好,启动日只 validate
- 5 张角色卡 .md按 §4 规范展开)
- 契约 OpenAPI 初稿Lead 冻结用)
- CLAUDE.md 三层(全局/项目/模块)+ rules/(api/frontend/db)
- Hooks 配置(危险命令拦截/自动lint/Stop测试/敏感文件保护,模板已从源材料抄到)
- 循环任务 SKILL按 §2 六阶段,改造 ralph-loop+ state_manager
- 种子数据(品类/角度槽位/违禁词三级表)
- **board 5 执行约束**:把 §5 反模式 + 北哥抽检 SOP 固化。
- **board 6 验收清单**三道验收线内部1A生产链+1B飞轮四点 → 北哥质量过关 → 上线)。
- **board 7 Agent 编排**:把 §3 拓扑 + 协作模式写成可执行的开工 promptgoal 模式)。
> 全部作业包完成后,按倩倩姐的常规要求:另起一个独立 AI 审核全部开工前流程,再开工。

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# Clover 项目约束CLAUDE.md
> 本文件是 Clover 项目的事件级约束agent 团队开工必读。
> 倩倩姐口头红线当场落盘,不写当不存在。
## 🔴 架构铁律(最高优先级,违反即返工)
### 1. Clover 是全新独立项目
- 所有代码在 `北哥小红书产品/Clover/` 下从头建立。
- **不在 banana 上盖、不在产品包上盖、绝不边盖边修。**
### 2. banana / 产品包 = 只读素材源
- 两个来源文件夹**只做读取和复制****禁止任何修改**
- banana`/Users/qiyu/Documents/企业培训项目/O2中间件平台/banana`(只读)
- 产品包✅上线版:`/Users/qiyu/Documents/企业培训项目/万牛会L1准备/worker`**真正部署上线版,扒这个**
- 🔴产品包旧版 `万牛会L1准备/产品包/worker` 是开发副本/旧版,**不要扒**(copy/image比上线版旧100~200行)
- 工作方式:需要哪部分零件 → 复制出来 → 在 Clover 里结合本项目迭代/优化/重建。
- 不允许 `cd` 进这两个目录做 Edit/Write/git 操作。
### 3. 技术栈
- 后端统一 PythonFastAPI+Celery+双库+Redis产品包的 JS 内核**重写成 Python** 融进 Clover。
- 重写必须仔细对照防逻辑走样TDD + agent团队 + 另一AI审核兜底
## 🔴 已拍板的产品决策(不可推翻)
- 生图:**gpt-image-2 主**codeproxy中转站Gemini 备。
- 生图接口:优先 `/images/edits`(带产品参考图),加重试退避。
- token统一走自家中转站 urlkey 按账号隔离,各人自录自用各算各的。
- **模型只用最强档倩倩姐2026-06-12拍板**Claude 系用 **4.8**claude-opus-4-8GPT 系用 **5.5**;通道目前仍只用 apiports 那一套(文案暂不补 codeproxy 第二路径,等真出现频繁挂再说)。
- **模型兜底铁律倩倩姐2026-06-12拍板**claude-opus-4-8 一定可用。**若 gpt-5.5 探活不可用,回落到 claude-opus-4-8最强档绝不降级到 sonnet 或任何弱档;绝不偷偷降级当没事,探活不可用立即回报。**
- **品牌词由客户输入**(产品级字段,随产品固定),植入文案每条+生图特写图第2/6张
- 🔴 **品牌词植入位置=文案+图片文字排版层绝不P瓶身倩倩姐2026-06-15拍板**:瓶身=产品本身,忠于参考图原样,禁止改/加/P瓶身文字合规+真实双重要求)。品牌词"倍分子"出现在①文案每条②图片的标题/贴纸/标注等文字排版层不是非要印到瓶身上。AC9实测task61 hook图瓶身只印"素颜霜"=正确不是bug。曾误判要去P瓶身被驳回。
- **每用户并发上限 5 个任务**(客户侧实际跑几个再观察调整)。
- **生图出3套**:套间差异按 ①文案角度 ②视觉风格 ③叙事结构套1痛点先行/套2场景先行/套3成分背书先行三个正交维度拉开**不按封面分**,要不重复。
- **配图去AI化对齐大卫xhs倩倩姐2026-06-15拍板**:大卫 `小红书系统图片定制/xhs-tool/backend/infrastructure/imagePostProcess.js`只读素材源运营实测去AI化能力强Clover 后处理对齐它:①**尺寸可选**(RATIO_MAP多比例**±2%容差内不resize**避免无谓重采样),常规默认 **3:4=1024×1536**(gpt原生尺寸**不强缩1080×1440**核销表原S4方向作废) ②SynthID破除(缩2px裁1px边+亮度/饱和微调,开关控制) ③高保真重编码(jpeg quality100/chroma4:4:4去元数据) ④重试里支持选尺寸。**先打通单套配图(含去AI化+尺寸可选)再扩3套正交**。
- **配图三套叙事措辞=倩倩姐先起草**2026-06-15痛点先行/场景先行/成分背书先行的具体prompt措辞由倩倩姐起草给北哥过目不用临时占位糊弄扩3套时填入。
- **文案待提质量项倩倩姐2026-06-15**①80合格线是临时妥协方向是提生成质量顶分数不降标准 ②生成文案每条套路化重复"买不买跟我没关系"收尾句式要去模板化。详见记忆 clover-quality-line-80-temp。
- 🔴 **合格线=80禁止擅改倩倩姐2026-06-15二次拍板**`constants.py QUALITY_PASS_SCORE=80`。coder 曾擅自改85被驳回。提质量靠优化生成prompt顶分数**绝不靠提高门槛/降标准凑数**。改这个值必须倩倩姐当面拍板。
- 🔴 **卖点三套来源·系统通用化倩倩姐2026-06-15拍板P0**:系统是通用的,**产品靠上传才有,绝不内置写死任何具体产品**(素颜霜只是北哥一个客户的一个产品)。卖点三套来源(产品包本有Clover重写漏了):①**从产品图片提取**(vision读图→卖点/人群/成分/视觉系统,**走GPT现有通道最强档不补GEMINI_KEY**) ②**用户上传填写**(北哥文案3.txt即此套) ③**产品库内置**(fallback模板留口即可)。成分(烟酰胺等)**并入selling_points不单列字段**。配图文字图片**先做一致**,后续留**canvas排版口子**(底图+代码叠字)。北哥禁AI是怕查重+AI化**我们已做去AI化(对齐大卫xhs),此担忧已解决,不再当决策点**。
- **文案过滤=先展示后台补倩倩姐2026-06-12拍板**:合格文案(passed且≥90分且非hard_block)立即展示不卡用户;合格数不足用户要的条数时,**后台异步补充生成**直到够数或达上限。不是"只展示不补",也不是"凑不够就卡着不出"。
- **13环目标导向**原始是13环北哥回填解读与落点.mdbanana本给了最硬4环引擎(找竞品/写文案/配图/裂变)。这轮**补全第2环竞品爆款分析(=标杆8特征)+第11环裂变**两个缺失引擎环裂变单独做但在工作台内呈现1爆款→N套完整笔记包非只换文案
- 软件费MVP 免费,收费逻辑后续单独讨论(先留接口关闭)。
- 飞轮:**开放给北哥测试使用即启动**(用即喂料,不是埋点不激活)。
- 🔴 **飞轮是产品最大亮点·必须做扎实+前端必须呈现倩倩姐2026-06-16拍板**:飞轮不是后台默默跑的暗功能,要让客户**直观感到"确实进入飞轮了、确实越用越好"**。两条入口都要做:
- ①**改稿进飞轮D1最强信号必做**运营改AI稿→diff喂回飞轮。**入飞轮时机=先激进(改了就入)跑一周,北哥用后按真实信号质量校准是否加"过审才入"闸门**细案E18/E19。需补 SignalType.text_edit + TextCandidate.edited字段 + 改稿端点(走task_actions不碰review.py)。
- ②**AI评图分进飞轮E12做扎实版**AI评图分**只做"生成时筛选+前端展示"**,前端要呈现"这张图AI评了X分+已被选入飞轮"。**真正影响下次生成的飞轮权重只认真实信号(选了哪张/改了什么/过审与否不拿AI主观分当权重**——守住eval_score留NULL铁律AI分是展示层不是权重层。
- ③**前端呈现=轻量化关键节点反馈不做独立飞轮主视图大页E01**:在生成/选择/改稿/审核节点即时提示"已学习你的偏好""本次参考了你上周改的稿""飞轮已积累N条信号",嵌现有流程,纵切片友好。
- 🔴 **竞品笔记数据监控=不做倩倩姐2026-06-16拍板**E04竞品数据全监控/反推关键词=自动抓小红书,命中合规反爬红线,直接砍,不进任何期。
- 发布:不自动发小红书,产出"达人素材交付包"人工发。
- 数据归属:原始数据(输入+产出+素材包)归客户可导出;飞轮偏好归平台。
- 违禁词:三级处理(自动改写🟢/软提示🟡/硬拦截🔴),默认分级北哥可调。
- 链接采集:不做原始抓取(死路),走对外截图手填 / 内部登录态插件。
## 🔴 验收线
- **质量过关才算完,不是"能下载"就算完**,最终北哥认可。
## 🔴 前端红线
- **不展示 Token 余额/用量**key 和用量去中转站后台看,前端只录 key 不显示余额。
- 不做积分中心/billing 页MVP免费
## 🔴 安全红线
- **SSE 认证用一次性短票 ticket绝不把 JWT 放进 URL**倩倩姐2026-06-08拍板EventSource 不支持 header故 SSE 连接前先用 JWT 调一个签发接口换一张只活 30-60 秒、仅对该 SSE 流有效的一次性 ticketURL 只带 ticket`?ticket=`)。就算被日志/浏览器历史记下也立即失效、换不了别的接口。禁止 `?token=` 直传 JWT。
## 🔴 执行约束
- 🔴 **建法铁律(防打补丁循环·先读 启动包/执行约束.md §0**:①纵切片优先——先用最丑的方式打通端到端一条龙(登录→选品→出1文1图→审核→下载1个包),这条没真跑通禁止精修任何单块功能 ②"完成"唯一定义=验收清单某项真勾上(非"代码写完"),报完成必附对应验收项 ③验收清单当每日门禁,每阶段自问"多勾上哪一项了还是只是码更多了"。看进度问的是"到一条龙走通还差几步"不是"还要补几个洞"。
- 新建文件**目标 ≤100 行、上限 200 行**倩倩姐2026-06-08放宽ORM模型/枚举常量/内聚算法硬拆反而割裂可读性故放宽到200行内可接受**超200行必须拆**);单次编辑 ≤100 行超了先建框架再Edit补
- 质量标准/优化prompt 等北哥方案到位再填,架构先留可调位。
- 🔴 **本轮交付层级=全量倩倩姐2026-06-12拍板**:终点线=一条龙过质量关 + 补全第2环标杆分析 + 第11环裂变。KR1链路连通/KR2质量达标/KR3自主可验/KR4引擎补全 全绿才算"全勾完"。
- 🔴 **打断节奏=攒批集中问倩倩姐2026-06-12拍板**:自主连续推进,把"只有倩倩姐能判断"的点(某条文案过不过关/某图品牌词够不够清晰/要不要砍需求攒成批次每完成一个环集中问一次不一条条点yes。
- 🔴 **核销机制=孙总方法论2026-06-12录音提炼已结晶 ~/.claude/crystals/cc-methods/ai-collab-checkpoint-gating.md**:①双层核销表(总核销表+每模块分核销表),做完一项物理勾一项 ②每模块收尾产"压缩存档/end-of文件"记录做到什么程度,断点可续 ③小步快跑+高频check不攒大块 ④**验收标准写到90分清晰度**(模糊=放水=AI按40分自认通过) ⑤标准做减法砍到最少步 ⑥门禁要硬,不通过物理卡死 ⑦**绝不信AI第一遍自评**(实测谎报"都做了"实际20-30%),必独立交叉核对。
- 🔴 **工作模式=自运行核销机器倩倩姐2026-06-12拍板**:①**强制用workflow的agent team执行,不许自己瞎干** ②**必做交叉验证**(另起独立agent重核,不信第一遍) ③规则/验收清单**先全部列出来**再做(你要先看到规则) ④做完一个勾一个、任务只减不增 ⑤倩倩姐**只验最后结果**,过程中要她判断的点**攒成批集中问**,不一条条点yes。

286
Clover架构方案.md Normal file
View File

@@ -0,0 +1,286 @@
# Clover 🍀 · 统一产品技术架构方案
> 项目代号:**Clover三叶草** —— 取"种草"之意,接小红书业务 + 倩倩姐设计稿的小草视觉。
> 对外正式品牌名待定,开发期用此代号。
> 版本:**v0.37洞对齐版2026-06-04** —— 经9-agent团队5轮审核(R1多维独立+R2挑刺+R3交叉验证+R4一致性+R5汇总)对齐06-04的7洞决策修复13处脱节/漏洞
> 受众:产品负责人(可读)+ 开发团队(可落地)
> 关联:[../banana复用评估.md] [../产品架构蓝图.md] [../前端交互设计.md] [../采集框架.md] [test/各洞验证落盘.md]
>
> **v0.3修订摘要**M1标杆采集定性(对外截图手填) / M2 Celery不传key(修安全矛盾) / M3去水印+达人素材交付包 / M4文案双轨+可调prompt / M5 delivery_packages表 / M6 token-url收口自家站 / M7软件MVP免费 / M8违禁词三级处理+banned_words表 / M9飞轮权重填定 / M10偏好表data_ownership字段 / M11 JWT表述工程化 / M12-13可选优化
---
## 〇、一句话定性
一座"小红书内容工厂":北哥是第一个车间,将来郑州客户来了直接开新车间,互不干扰。
核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")。技术底座复用 banana 半成品,但新建干净仓库抽离。
---
## .5、基石约束(不可动摇,违反即返工)
> 经两轮外部AI审核 + 两轮自校验,沉淀出以下铁律。任何代码/设计违反这些 = 必须返工。
### A. "凡业务参数,一律不写死"(最高原则)
- **品类**不写死:预置素颜霜/精华/面膜 + 客户自助上传新品类(source区分)代码和prompt禁止出现品类枚举
- **生成数量**不写死:文案出几条、图出几张,由用户自己设定。飞轮信号强弱随用户设定走(设10选3=强信号设3选1=弱信号),系统不锁死
- **文案角度**不写死:痛点/成分/场景等角度跟着产品走,或引导客户自己输入,落进产品档案当可配置项
- 判断标准:任何"看起来像枚举/常量"的业务概念,先问"客户会不会想改?"会→做成数据不做成代码
### B. 安全铁律API Key
- 明文key**绝不进Celery参数**(Redis broker会存)。Celery只传task_idworker内部查库→解密→调模型明文只在函数局部变量
- key用Fernet加密存MySQL**解密用的FERNET_KEY走环境变量/密钥管理,绝不进代码库**(锁和钥匙不放一起)
- key解密不落盘、不打日志
- 每账号自录自用按key各算各的禁止合并站级key混算
### C. 多租户隔离
- workspace_id **逻辑隔离**(同一MySQL用workspace_id强制过滤)**不是物理隔离**(暂不做每客户独立DB)
- JWT放user_id/current_workspace_id/role当加速字段但**所有写操作+切换workspace必须查 workspace_members 校验当前权限**(读操作可信JWT加速)
### D. 采集↔生产接缝不能断漏洞C
- 采集框架里北哥团队填的"好图标准/好坏样本/质检标准"等**人工判断标准必须在Clover里有落点**进系统→帮组长审核→最终蒸馏成AI审核员
- 这是采集层和生产层的接缝,设计时必须打通,不能让采集成果悬空
### E. 飞轮验收必须可测(不靠"感觉更准"
- events正确写入 ✓ / aggregator返回明确prompt片段 ✓ / 下次Gemini prompt trace含该片段 ✓ / UI显示"本次已注入:最近偏好/打回原因" ✓
- 四点全过才算飞轮MVP达标
### F. 文档诚实
- 不放"N个agent生成"这类AI背书用工程化表述(JWT签名防篡改/短期有效/关键接口查membership),不用"写死不可伪造"这种话
---
## 一、整体架构总览
### 分层(从上到下)
```
浏览器(Next.js)
熟手选题→看生成→8选3文案→挑图→提审
组长:看审核队列→通过/打回(写原因)
管理员:维护品牌库/标杆/成员
│ HTTPS/SSE(流式推进度)
FastAPI(API层)
认证JWT(含workspace_id+roleHS256签名防篡改、每请求验签)
权限读操作凭JWT加速写操作+切换workspace必须查workspace_members校验权限(逻辑隔离)
Key解密Fernet解密只在worker内存活不落盘
│ 同步↓ 异步↓(推Celery队列)
业务应用层 Celery Worker(后台)
品牌库管理 ①分析标杆笔记
飞轮信号采集 ②批量生成文案(一次出5角度)
审核流转 ③并发生成图片(asyncio.gather)
偏好上下文组装 ④偏好重算(第二期激活)
│ │
数据层(双引擎)
MySQL(主存储,一期14表) MongoDB(只写AI debug trace)
业务全量数据/账号/权限 AI调用过程快照出错排查用
品牌库/候选/飞轮信号 业务逻辑不依赖它
API Key(Fernet加密) Redis(Celery队列+SSE推送通道)
```
### 一次任务的数据流转
```
1.管理员预置品牌库(产品档案+标杆笔记)
2.熟手发起任务:选产品+选标杆 → 后端检查有没有配key(没有→引导去配)
→ **只把task_id推入Celery队列(绝不推key遵基石B)**
3.Worker异步①分析标杆提8特征 ②生成5角度文案(动态注入卖点+违禁词+风格+偏好)
③并发生图A/B/C → 每步推SSE前端实时看进度
4.熟手8选3文案 → 飞轮信号入口1挑图 → 入口2
5.提交审核 → 组长看队列
6.组长通过(+5分,最重)/打回(写原因,下次自动注入prompt提醒AI别再犯)
7.归档(永久保留)
```
### 偏好飞轮怎么转(产品的魂)
```
【采信号】选文案+3 / 选图+3 / 审核通过+5(最重) / 打回写原因-3 / 重新生成-1 → 全进 preference_events 表
(此为开发起步默认权重,北哥调教后可配置校准)
【聚合】 下次生成查最近50条 → 哪个角度被选最多(L3个人) + 打回原因最近3条原文拼进prompt
不足5条信号 → 用产品档案冷启动
【三层继承】L1公司品牌基线 > L2矩阵号人设 > L3个人手感(运行时叠加有L3用L3没有往上兜底)
⚠️ 按产品维度分开学:素颜霜偏好不串到精华/面膜
【越用越准】1次=全靠静态基线均匀分布10次=知道你爱"痛点切入"30次=措辞从"供参考"升为明确指令
【诚实克制】banana的假评分(88分)MVP彻底不接eval_score留NULL等真评分(组长标准蒸馏)再装
```
---
## 二、核心设计决策(已拍板)
开发期不得推翻,变更需产品负责人重新确认。
| 决策项 | 结论 |
|---|---|
| 主存储 | MySQL存所有业务数据(飞轮聚合要JOIN)MongoDB只存AI debug trace |
| API Key | 每账号自录自用、Fernet加密、不同workspace各自独立禁止混算**token站url统一收口=自家站endpoint账号表只存encrypted_key不存url字段**(06-04洞4旧"兼容任意中转站url"已废止) |
| 软件费 | **MVP阶段免费不做计费墙**(06-04洞4)token耗材费走自家站按key隔离不在产品层计费收费逻辑产品成熟后单独议 |
| Key解密位置 | 只在Celery worker内存局部变量不落盘不打日志 |
| 多租户隔离 | workspace_id写进JWT所有查询强制带此条件(安全红线) |
| 任务主键 | MySQL自增BIGINT + mongo_trace_id VARCHAR(24)飞轮JOIN性能好 |
| 文案生成 | **双轨**(06-04洞2)轨A=自带强模型一次返5角度JSON(不串行不并发5次)轨B=客户导入外部文案(豆包等)直接进候选池跳过AI**客户可在产品档案配自调prompt** |
| 图片生成 | asyncio.gather并发一次A/B/C三策略各一张(复用banana) |
| 加密算法 | cryptography.fernet.Fernet环境变量FERNET_KEY全仓统一 |
| 策略命名 | 对外API用A/B/C内部method用minimal_edit系建中心常量文件消除三套打架 |
| 审核记录 | 不建独立notes表审核字段平铺generation_tasks打回原因存preference_events |
| 偏好快照 | preference_profile表预建结构MVP不写入实时查events第二期开物化缓存 |
| 品类动态 | products.category是纯数据字段代码和prompt禁止出现任何品类枚举 |
| **审核演进** | **先组长人工审为主→积累"过/不过+理由"蒸馏人工标准→生图够好+标准清晰后AI当审核员接班(第三期)** |
---
## 三、数据模型全览16张表 = 一期14张 + 二期预留2张
> 14张=banana搬3 + 多租户基础3(不含matrix_accounts) + 业务主体7 + 飞轮1。matrix_accounts、preference_profile 二期预留,一期不建。
> 06-04增补业务主体从5张增至7张(加 delivery_packages 素材交付包 + banned_words 违禁词库)。
```
Alembic 001 — 从banana搬3张(改造)
users 用户账号(删credits字段)
login_records 登录记录(无改)
user_preferences UI设置偏好(无改API Key另建表)
Alembic 002 — 多租户基础(全新)
workspaces 租户/公司(北哥=1个)
workspace_members 用户↔workspace+角色
user_api_keys 个人API Key(Fernet加密)
[matrix_accounts] 矩阵号 —— 二期预留,一期不建(矩阵号一期不做)
Alembic 003 — 业务主体7张(全新)
products 产品档案(卖点/违禁词/风格/调性/标签/文案角度/可调prompt/source/workspace_id)
benchmark_notes 标杆笔记(爆款链接/图/亮点说明一对多挂product)
generation_tasks 生产任务(状态机+审核字段平铺)
text_candidates 文案候选(角度数量用户设定MySQL主存储source=ai/import区分双轨)
image_candidates 图片候选(数量用户设定)
delivery_packages 达人素材交付包(洞6)id/workspace_id/product_id/theme/status(pending/ready/downloaded)/package_path/created_at
banned_words 违禁词库(洞5)word/level(auto_fix自动改/soft_warn软提示/hard_block硬拦截)/replacement/updatable/workspace_id(可覆盖)
Alembic 004 — 飞轮信号(全新)
preference_events 飞轮信号日志(所有信号全在此含data_ownership字段=client_data/platform_asset洞7分层归属)
[preference_profile] 偏好快照 —— 二期预留,一期不建(一期实时查events二期同步加data_ownership标记)
```
### 关键字段
**任务状态机**pending→generating→pending_selection→pending_review→approved/rejected→archived
打回后回到pending_selection修订轮次靠查events统计
**generation_tasks 审核字段(平铺,需明确定义)**
review_status / reviewer_id / reviewed_at / reject_reason / approved_at / archived_at
**preference_events**
- signal_type: text_select | image_select | approve | reject_with_reason | regenerate
- signal_weight(已定初始默认值,北哥调教后可校准):选文案+3 / 选图+3 / 审核通过+5(最重) / 打回-3 / 重新生成-1
- angle_label: 跟着产品的"文案角度"配置走,不写死
- reason: 打回原因原文不做AI归纳
- **data_ownership: client_data(原始输入产出,客户可导出) | platform_asset(飞轮蒸馏成果,归平台)** —— 洞7分层归属技术口子
- workspace_id + product_id: 都必须有(跨公司隔离 + 按产品分开学)
**user_api_keys**UNIQUE(user_id, workspace_id, provider)
**usage记录(一期必做,否则计费/排障断档)**每次AI调用记 usage(谁/用了多少token),存 ai_call_logs 或 mongo debug trace 的 usage 字段token站查询失败要有兜底调用失败归因到个人key
---
## 四、复用 / 复活 / 新造清单
开发第一步先看这张表:哪些直接搬、哪些改造、哪些新写。
### 🟢 直接复用(复制基本不改)
| banana模块 | 改动量 |
|---|---|
| gemini_service.py 核心AI方法 | 只改__init__ 4行AI调用方法一行不动 |
| json_stream_extractor.py 流式解析 | 加variants到watched_keys |
| constants SCHEMA_METHODS | 扩展补全 |
| orchestrator analyze流程 | 数据源从Mongo改读MySQL products/benchmark_notes |
| celery_tasks 任务框架 | task_id改BIGINT**只传task_id(绝不传key遵基石B)**worker内部查库取encrypted_key→FERNET_KEY解密加第4任务壳 |
| api/stream.py SSE框架 | 补发历史事件(修漏洞),结构不变 |
| prompt_composer.py | 新增compose_variants方法原方法保留 |
| docker-compose | 直接复用,调环境变量名 |
### 🟡 复活banana写好但没接线的死代码
| 死代码 | 怎么复活 |
|---|---|
| task_skills.py Invariants 8特征 | analyze后把8特征写入events.signal_meta飞轮统计维度从单一angle扩到8维(第三期) |
| loader.py PromptLoader版本alias | 新建variants模板走版本机制未来改prompt只改模板不改代码 |
| gemini_service evaluate_schema | MVP彻底不搬eval_score留NULL第二/三期接真评分(避免假信号污染飞轮) |
### 🔴 完全新造banana没有灵魂部件
| 新造模块 | 作用 |
|---|---|
| gemini_factory.py + Fernet解密 | 解决全局单例每任务用自己的key构建实例 |
| preference_collector.py | 飞轮3信号入口(选文案/选图/审核)写入events |
| preference_aggregator.py | 实时聚合events→prompt片段三层继承叠加 |
| api/products + benchmark_notes | 品牌库CRUD含source=preset预置品类 |
| api/matrix_accounts | 矩阵号CRUD(MVP前端可后做库和接口先建) |
| api/api_keys | 录入/删除/查询(只显后4位),首登强制引导 |
| middleware/workspace_guard | 所有接口强制注入workspace_id防串数据 |
| api/review | 组长待审列表/通过/打回 |
| generate_text_variants | 一次调用5角度JSON替代banana单条循环 |
| text_import_handler | 轨B导入外部文案(豆包等)直接进候选池跳过AI生成(洞2文案双轨) |
| banned_word_checker | 违禁词三级扫描:🟢自动改写/🟡软提示给建议词/🔴硬拦截,词库可配置(洞5) |
| image_postprocessor | 出图后处理:重编码去C2PA元数据+转JPG/轻重采样削像素水印+压缩(洞3去水印) |
| package_exporter | 生成达人素材交付包:按笔记分文件夹+图(01/02命名)+文案.txt+发布清单+合规说明(洞6) |
| _combine_benchmarks | 1-5篇标杆合并成综合reference |
| base_workspace_repo | 所有workspace相关Repo继承强制过滤 |
| Alembic 001-004 | 按序建14张表(一期) |
| constants/strategies.py | 统一命名中心文件,消除三套打架 |
---
## 五、构建顺序三期一期拆1A/1B
> 修正:原"一期3-4周"塞太满。拆成 1A生产链闭环 + 1B飞轮闭环先证明能产出再证明会学习。
### 一期·1A生产链闭环先让北哥能产出内容
手动建北哥账号(不接短信)、手动/截图录标杆、产品库、按用户设定数量出文案(双轨)、出图去水印、选择、审核、打包交付。
1. **基础设施**:建仓库+6容器Alembic 001-002(不含matrix_accounts)手动初始化北哥workspace+管理员
2. **品牌库**Alembic 003产品档案CRUD(含文案角度+可调prompt可配置)标杆笔记CRUD预置素颜霜1条初始化banned_words(MVP起步5词:美白/祛斑/速效/医用/药妆updatable北哥合规标准来了批量导入)
- ⚠️ 标杆录入主通道=**截图+手填亮点**(洞1实测确定可行);登录态采集=北哥内部插件,二期可选
3. **API Key录入**user_api_keys+Fernet+录入界面FERNET_KEY走环境变量token站url固定自家站没配key时按钮置灰引导
4. **核心生产链**中心常量文件GeminiService改造+gemini_factory(key只在worker内解密)analyze搬入**文案双轨(轨A生成5角度/轨B导入外部文案)****违禁词三级扫描**;并发生图(数量用户设定)Celery只传task_idSSE推送
5. **选择+审核+交付包**:选文案/选图/提审/审核队列/通过打回(审核字段明确定义)**出图后处理(去水印)**→**生成达人素材交付包**
- 出图后处理(洞3)图像库重编码去C2PA元数据 + 转JPG/轻重采样削像素水印 + 压缩瘦身,对客户透明
- 交付包结构(洞6):按笔记分文件夹,每夹含图(01/02/03命名防传错序)+文案.txt(标题+正文+标签),附📋发布清单.txt + ✅合规说明.txt(违禁词已过/已去水印)
- 验收北哥1名熟手不靠开发从选产品→生成→选择→组长审→**下载达人能直接发的素材包**,全程跑通
### 一期·1B飞轮闭环让产出会学习
1. Alembic 004(只建preference_events不建preference_profile)
2. 三信号入口接线(选文案/选图/审核)
3. compose_preference_context 实时聚合(最近50条workspace_id硬过滤)
4. 偏好+打回原因注入prompt
- 验收(四点全过)events正确写入 / aggregator返回明确片段 / 下次prompt trace含该片段 / UI显示"本次已注入"
**一期整体验收**北哥熟手独立跑完生产链飞轮选过3次后下次生成能看到偏好摘要并验证注入。
### 二期:多团队+对外销售再2-3周
> 留口子已在地基,二期只"打开注册开关",不回头改表。
1. 手机号+验证码自助注册(选短信服务商) + workspace创建 + 邀请成员
2. matrix_accounts建表+矩阵号管理UIL2人设层激活
3. preference_profile物化(events满10条触发重算加载<50ms)
4. 管理后台各workspace用量统计+飞轮健康度
### 三期:效果回流+AI审核员看业务
1. 发布效果回填(赞/收藏/评论→outcome_score加权)
2. L3 vs L1偏差检测(个人偏好偏离品牌基线>60%提示,防学坏)
3. Invariants 8维特征接线(飞轮统计从单一angle扩到8维)
4. **AI审核员**:用积累的"组长过不过+理由"蒸馏人工标准训练AI审核员逐步接班(补banana假评分窟窿呼应基石D)
---
## 六、待拍板判断点
### 已拍板2026-06-03
- ✅ 项目代号:**Clover**,文件夹建在 北哥小红书产品/Clover/
- ✅ 先做北哥单车间,但地基留口子,其他车间可快速打通
- ✅ 审核:先组长人工审为主 → 蒸馏人工标准 → 后期AI审核员接班
- ✅ 注册方式:**手机号+验证码**(对标小红书,已查证小红书就是手机号注册)。需接短信验证码服务商(阿里云/腾讯云SMS)
- ✅ 矩阵号:**一期不做**。L2人设层一期不激活飞轮一期只跑"L1品牌基线+L3个人手感"两层matrix_accounts表仍建好留口子二期补L2
- ✅ 排版:**不做独立排版环**第7环并入第6环——AI生图时直接把文字/版式排在图上(出成品图)
-**标杆采集通道06-04洞1已拍板实测3条真链接裸抓全返回风控假页**
- 对外SaaS一期主通道=**截图+手填亮点**(客户自己截图,零平台风险)
- 链接自动读取=**登录态采集方案**本地已有跑通代码n8n-nodes-xiaohongshu + xhs-sign-serverPlaywright跑小红书自家JS算签名但"系统替客户取数据"=违规+封号风险,只作**北哥内部自用插件、二期可选模块,不进对外标配**
- 官方蒲公英/聚光接口=二期看北哥企业资质再议
-**软件收费06-04洞4已拍板**MVP阶段软件**免费**不做计费墙token耗材费走自家token站、按账号key隔离不在产品层计费。收费逻辑(底层麻烦)产品成熟后单独讨论
### 待拍板
- **P1 短信服务商**:用阿里云短信还是腾讯云短信?(影响成本和接入,都需企业实名)(注:仅二期自助注册才需要,一期手动建北哥账号不阻塞)
- ~~P2 标杆链接读不到时的兜底~~ → **已由06-04洞1解决**:对外=截图手填(主通道),登录态采集作内部插件,不再是待拍板项
- **P2 北哥账号开通**:一期北哥账号由我们手动建(已定,不接短信);自助注册留二期

55
PRD/PRD-0-总纲.md Normal file
View File

@@ -0,0 +1,55 @@
# Clover PRD-0 总纲
> 3份PRD的"对齐层":前后端共享的目标/范围/流程/验收/里程碑,避免两份重复。
> **承接层**:决策见 `../Clover架构方案.md`(v0.3) + `../CLAUDE.md`;前端见 `PRD-前端.md`;后端见 `PRD-后端.md`。
> 读PRD顺序先读本总纲对齐全局 → 按角色分工读前端/后端 → 冲突以架构方案v0.3+CLAUDE.md为准。
## 1. 产品定位
- 一句话:北哥团队的小红书达人素材"中央厨房"多租户SaaS
- 界面名:龙石小红书内容生产平台 ; 项目代号Clover🍀
- 核心差异化 = **偏好飞轮**(越用越懂你的"老师傅")
## 2. 目标用户与三角色
- 内容生产方(北哥团队):熟手(运营·每天产内容) + 组长(审核) + 管理员(北哥/老板·配业务)
- 发布方(达人):只收素材包,不进系统
- 平台方(我们)收口token,持有飞轮偏好资产
## 3. 范围边界(做/不做)
- ✅做:①产品配置 ②生成文案(双轨) ③生成图 ④裂变 ⑤存档打包交付 + 飞轮(开放测试即启动)
- ❌不做:软件收费/达人进系统/自动发布小红书/原始链接裸抓/移动端
## 4. 核心用户旅程5屏·前后端共识
```
管理员配置台(偶尔) → 熟手:开任务→挑文案(出N选若干)→挑图(出N选若干,N用户设定,图≤8)→确认预览→提审
→ 组长:审核台 通过/打回 → 熟手导出打包 → 人工发/给达人
```
- 飞轮全程隐形记录(挑文案+3/挑图+3/通过+5/打回-3/重生成-1)
## 5. 全流程5环节定义
①产品配置(管理员录) ②生成文案(轨A一次5角度/轨B导入外部) ③生成图(gpt-image-2主+edits+分镜) ④裂变(1爆款→N笔记包) ⑤存档打包(达人素材交付包) ; 飞轮贯穿②③④
## 6. 关键产品决策(引用,不展开)
- 见架构方案§二决策表 + CLAUDE.mdgpt-image-2主/token-url收口/MVP免费/飞轮即用即启/违禁词三级/数据归属分层/质量过关才验收
## 7. 验收线(总·分三道)
1. **内部验收**(我们)一期1A生产链跑通(熟手独立从配置→生成→选择→组长审→下载能发的素材包) + 1B飞轮四点验收(events写入/聚合返片段/下次prompt含片段/UI显示已注入)
2. **北哥验收**:质量过关(不是能下载就算完,北哥认可内容质量)
3. **上线**:北哥验收通过 → 上线
- 🔴 质量标准/优化prompt 由北哥方案注入,架构留可调位
## 8. 名词表
- 达人素材交付包:按笔记分文件夹+图01/02命名+文案.txt+发布清单+合规说明,人工发
- 偏好飞轮:隐形记录用户选择,越用越懂,三层继承(L1品牌>L2人设二期>L3个人)
- storyboard分镜一套图N张各担叙事角色(钩子/痛点/证明/质感/背书/转化)
- 爆款参考度:裂变时学爆款的强弱(低30/中/高85防抄袭)
- 双轨轨A自家AI生成/轨B导入豆包等外部文案
## 9. 里程碑MVP切片·引用架构方案§五
| 期 | 范围 | 验收 |
|----|------|------|
| 一期1A | 生产链闭环(配置/文案双轨/生图去水印/选择/审核/打包) | 熟手独立跑通下载能发素材包 |
| 一期1B | 飞轮闭环(三信号入口/实时聚合/注入prompt) | 飞轮四点全过 |
| 二期 | 多团队对外(手机注册/矩阵号L2/偏好物化/管理后台) | 留口子已在地基 |
| 三期 | 效果回流+AI审核员(蒸馏组长标准接班) | 看业务 |
- 🔴 切法:先证明能产出(1A)→再证明会学习(1B)→再对外(二期)→再智能(三期)
- 一期不做但建表matrix_accounts/preference_profile

106
PRD/PRD-前端.md Normal file
View File

@@ -0,0 +1,106 @@
# Clover PRD-前端(界面名:龙石小红书内容生产平台)
> 前端技术框架。**承接层**5屏画面金标准见 `../../前端交互设计.md`(经倩倩姐确认),视觉见 `../../前端设计参考图.png`。本文①承接5屏②补工程细节③对接后端契约——**唯一冻结源 = `../启动包/API契约.md`**(端点§1 / SSE事件§2 / 错误码§0),不看 PRD-后端。
> 技术栈Next.js+React+TS+Tailwind+Zustand(范式扒banana/frontend,全新重建)。PC优先,最小宽度1280px,不做移动端。
> 🔴 红线(CLAUDE.md)不展示Token余额/不做积分页/key只录不显余额。
## 0. 一句话定位
前端就是"挑挑挑":挑文案(出N条选若干)→挑图(出N张选若干N=用户设定图最多8)→组长挑过不过。飞轮全程隐形记录。
## 1. 三角色 + 路由表
| 角色 | 默认首页 | 能进的页 |
|------|---------|---------|
| 熟手(运营) | /tasks/new | 开任务/挑文案/挑图/确认预览/历史 |
| 组长 | /review | 审核台/历史 |
| 管理员(北哥/老板) | /config | 配置台(产品/标杆/成员/违禁词)+全部 |
路由表:
- /login(登录+首登key引导) / /dashboard(工作台) / /tasks/new(开任务)
- /tasks/[id]/text(挑文案) / /images(挑图) / /confirm(确认预览·熟手提审前)
- /review(组长审核台) / /history(历史归档) / /config(配置台·管理员)
- 🔴 角色路由守卫:/review只组长+管理员;/config只管理员;越权重定向工作台
## 2. 全局布局(对齐设计图)
- 顶部:标题"龙石小红书内容生产平台"+右上角当前用户(运营-小李)
- 5步流程条(1开新任务/2选择文案/3选择图片/4确认文案/5成片发布)——熟手常驻;组长/管理员登录后不走此条(各进自己首页)
- 左侧8项导航(工作台/开新任务/选择文案/选择图片/建立任务/任务列表/历史归档/配置中心)
- 主区 + 右侧配置预览面板
- 🔴 底部原Token余额位置(设计图568320)→**移除**,替换为版本号/留白小草装饰
- 视觉:暖橙/奶油色+绿色小草
## 3. 五屏详述(承接前端交互设计.md
### 屏1 配置台(管理员·/config
- 产品库:素颜霜/面膜/精华卡片+[加产品](品类不写死,客户自助加)
- 每产品配:卖点/品牌词/违禁词(三级可调)/固定标签/风格调性/可调prompt/参考图
- **标杆笔记管理**(一期主通道=截图上传+手填亮点;列表展示;关联产品)
- 成员管理(workspace邀请/角色分配) ; key录入(各人自录,只录不显余额)
- → 飞轮的"出厂设置",配一次天天用
### 屏2 开新任务(熟手·/tasks/new·设计图主表单
- 选产品[素颜霜▾] + **今天主题输入框**(如"黄皮提亮·早八伪素颜",驱动当次方向,关键字段勿漏)
- 单条数量/生成数量(可+/-,数量不写死) + 生成方向
- 双轨入口轨A[开始批量生成文案] / 轨B[导入外部文案](粘贴豆包等)
- 右侧配置预览面板:产品名称/卖点/成分/质地/标签/参考图3张 + [编辑配置](点击→侧滑面板原地编辑,改完实时刷新预览)
- **生成前显示"本次已注入最近偏好X/打回原因Y"**(调GET /preference/context,基石E④)
- 底部"最近任务"横向卡(4个,任务名+状态+进度条)
### 屏3 挑文案(熟手·/tasks/[id]/text·飞轮第1次学
- 生成N条文案卡(可多选),每卡:标题+正文+标签+**五维分数**(标题/情绪/买点/关键词/合规,展示分数条+总分,数据来自后端scoreCopy)
- 轨B导入的文案标注来源区分(source=import角标)
- 选中触发飞轮+3(隐形,无"训练AI"按钮) ; [用这N条去生图→]
### 屏4 挑图+出成品(熟手·/tasks/[id]/images·飞轮第2次学
- N批图缩略图(可多选) ; 选中触发飞轮+3
- 选中图+文案→自动套版预览(封面+内页拼成笔记长相)
- [满意,交组长审→]
### 屏4.5 确认预览(熟手提审前·/tasks/[id]/confirm
- 熟手提审前最后预览整套笔记 ; [提交审核] ; [重新生成](飞轮-1)
- ⚠️ 注意:通过/打回是组长动作,不在此页(修正骨架视角错位)
### 屏5 组长审核台(组长·/review·飞轮最强信号
- 待审笔记列表(N):每条[预览]+[✓通过]+[✗打回+原因]
- 通过+5(权重最高,飞轮最强对信号) ; 打回必填原因(下次自动注入prompt提醒AI别再犯,-3)
- 打回原因输入:模态框,必填校验
- 通过→进成品库(熟手可去屏导出打包) ; 打回→任务回pending_selection
- 导出后人工去小红书/给达人发(工具到此为止,诚实边界:小红书无接口不自动发)
## 4. 异步生图电影 + 飞轮隐形 + 错误态
### 4.1 异步生图进度(承接交互设计.md §2·体验核心
对接后端SSE事件(契约§2)
- 点生图→不空白干等:显"正在生成N批…0/N [进度条] 预计30-60秒,可先去忙别的好了提醒"
- 并发跑,**谁好谁先冒**(好一批亮一批,对接 image_candidate 事件,每个带 event_seq) ; "3/N"实时计数
- 单批失败标红+[⟳重试](不拖垮其他批) ; "完成9/10,这9批够挑了[继续→]"(不强求全齐)
- 🔴 铁律:老demo"点完干等18分钟转圈到崩"绝不能再有
### 4.2 飞轮隐形运转(交互设计.md §3
- 前端**无任何"训练AI/打分"按钮**(会吓到运营),只如实把动作记给后端(带谁+哪账号)
- 三动作埋点:屏3勾文案+3 / 屏4勾图+3 / 屏5通过+5打回-3 / 屏4.5重生成-1
- "变聪明"在后端,前端只管操作顺滑+显示"本次已注入"
### 4.3 错误态UI对接契约§0 数字错误码,前端按 code 映射语义,**不硬编码字符串码**
| code | 语义 | 前端响应(非统一toast) |
|------|------|----------------------|
| 42201 | 业务校验失败(未配key/违禁词硬拦截) | 未配key→引导配key页/banner按钮置灰(首登强制引导);违禁词→显示触发词+级别,提交前后立即提示 |
| 50002 | AI/token站调用失败 | "生图通道繁忙"特殊提示(区别普通错误)+[重试] |
| 50001 | 服务端错误 | "生成失败"+[重试]按钮 |
| 40901 | 状态机非法流转 | 提示当前状态不可操作,刷新看最新态 |
| 40301 | 越权访问 workspace | "无权访问"提示,跳回工作区列表 |
| 40101 | 未认证/JWT失效 | 自动触发token刷新(扒banana已实现),失败则跳登录 |
| 40001 | 参数校验失败 | 表单字段级红字提示 |
> 前端维护一份 `errorCodeMap`(code→{文案,处理动作})常量,所有错误统一查表,违禁词/限速等细分信息从 `message` 字段读。
- 真实状况兜底(交互设计.md §4):关页面回来任务还在(后端有状态不靠前端内存)/多人各看各的
## 5. 工程细节
- 状态管理4Store(参banana)authStore(user/workspace_id/role/token) / taskStore(任务id/状态/已选文案图id) / generationStore(SSE进度/各图状态/已完成N) / preferenceStore(本次注入偏好摘要)
- SSE客户端(扒banana sse.ts,5次重连退避)+断线重连UI("连接断开重连中1/5…"+"重连失败任务后台仍在,可刷新")
- 组件库:流程条/文案卡(带五维分)/分镜图卡/配置预览面板/交付包预览/飞轮注入提示横幅/打回原因模态框/单图进度卡/审核操作栏。🔴 删除"Token余额组件"
- 骨架屏:文案卡/图卡/审核队列loading ; 表单校验:产品必选/主题字数限/数量范围
- API客户端(扒banana api.ts含token刷新) ; AuthGuard+角色守卫(扒banana)
- 可扒banana前端零件authStore范式/api.ts/sse.ts/AuthGuard/NextUI组件;表单字段全重建(Clover字段不同)
## 6. ❌不做CLAUDE.md红线
- Token余额/用量展示(去中转站后台看) / 积分中心billing页 / 移动端适配

114
PRD/PRD-后端.md Normal file
View File

@@ -0,0 +1,114 @@
# Clover PRD-后端
> 后端技术框架。**本文是"承接层"**:架构决策见 `../Clover架构方案.md`(v0.3,金标准)本文只做①引用架构决策②补PRD独有工程细节③承接扒包上线版新事实。
> 技术栈FastAPI + Celery + MySQL + MongoDB + Redis栈范式扒自banana全新重建
> 🔴 源码只读复制:扒**上线版** `万牛会L1准备/worker/src`(非旧版产品包) + banana。
## 0. 怎么读这份PRD
- 数据模型16表/基石ABCDEF/三期构建顺序/复用复活新造清单 → **见架构方案 §三/§〇.5/§五/§四**,本文不复制。
- 本文重点第3章API契约细节、第9章环境变量、第10章工程规范这些架构方案没细化
- 与架构方案冲突时:**以架构方案v0.3 + CLAUDE.md为准**。
## 1. 后端架构总览
- 分层API层 / Service层(AI引擎) / Model层(双库) / 队列层(Celery+SSE) —— 详见架构方案§一
- AIProvider抽象层(扒banana)**gpt-image-2主/Gemini备**,由环境变量 IMAGE_PROVIDER_PRIMARY 配置(不写死)
- 🔴 三条铁律入口(详见架构方案§〇.5)A业务参数不写死 / B key不进Celery+Fernet加密 / C workspace逻辑隔离
## 2. 数据模型
- **完整16表清单+字段+Alembic001-004分批 → 见架构方案§三**,本文不重抄。
- 🔴 PRD骨架旧表名修正(以架构方案为准)
- api_keys→**user_api_keys**(UNIQUE user_id+workspace_id+provider)
- tasks→**generation_tasks**(审核字段平铺:review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at)
- 无assets表→实为 **text_candidates + image_candidates**(text_candidates有source=ai/import区分双轨)
-**login_records**(扒banana) / 补 **ai_call_logs**(usage记录,按key计费排障基础,一期必做)
- data_ownership是**preference_events的字段**(client_data/platform_asset),不是独立表
- 任务状态机7态pending→generating→pending_selection→pending_review→approved/rejected→archived(打回回pending_selection)
- MongoDB**只存AI debug trace**(业务不依赖)可调prompt在 products 表字段,**不在Mongo**
- 关键索引(架构没列,PRD补)generation_tasks(workspace_id,status) / preference_events(workspace_id,product_id,created_at)
## 3. API 契约 → 唯一冻结源:`启动包/API契约.md`
> ⚠️ 契约统一:本 PRD **不再另写一套契约**。端点/响应包络/错误码/SSE事件/分页,
> 全部以 `Clover/启动包/API契约.md` 为唯一冻结源避免前后端分叉外部AI审核P0-1/2/8
> 开工首步Lead 据 API契约.md 生成并冻结 `Clover/contracts/openapi.yaml`"契约优先"落地,非口号)。
本 PRD 第3章只补**契约文档未覆盖的后端实现约定**(不重复端点定义):
- 任务软删用标记位不物理删DELETE /tasks/{id} 走 archived 态。
- 限流同用户并发生成任务数上限建议2可配生图每张~4min 异步不占连接。
- SSE token 走 headerAuthorization不走 query对齐契约纠 banana 旧法)。
- preference 写入**不暴露独立埋点端点**:选择/审核业务接口内部写 events前端只调业务动作外部AI审核P1-14
- preference 读GET /preference/context 已列入 API契约端点清单外部AI审核P2-13
## 4. AI 引擎(扒上线版 worker/src 重写Python·核心
> 算法逻辑整体扒上线版copy.js/image.js/split.js重写为Python逻辑照搬防走样(TDD对照)。
### 4.1 文案引擎扒copy.js上线版
- **轨A**:一次调强模型返**N角度JSON**(N=用户设定text_count不串行不并发新造generate_text_variants)——这是去同质化关键,勿照抄旧版单条循环。数量不写死(基石A)。
- **轨B**:导入外部文案(豆包等)直接进候选池跳过AI(text_import_handler,text_candidates.source=import,洞2)
- 保留(扒上线版重写Python集成进轨A)三层兜底Claude→Gemini→本地 / 五维打分90过线 / 自动优化循环 / **正文+开头去重(splitPublishableContent/contentSignature)** / **角度槽位(angleSlotsForCategory,作可迁移数据非代码常量)** / 内部提示剥离(content只放可发布正文)
- 可调promptproducts表字段北哥可配(等北哥优质样本+判断标准注入,质量责任在北哥侧)
### 4.2 生图引擎扒image.js上线版+改造)
- 扒保留storyboard 10角色分镜 / getNarrativeRoles(3/6/8张链路) / 6品类proofStrategy / 素材路由 / buildVisualSystem成组视觉
- 🔴 生图通道(扒上线版已成熟,直接用)
- 主备由 IMAGE_PROVIDER_PRIMARY=gpt/FALLBACK=gemini 配置(imageProviderOrder)
- GPT走 **edits带产品参考图**(requestGptImageWithReferences)**禁纯文生图防跑偏**(无产品图报错)
- 产品图=**不可修改商品锚点**,禁改包装/换产品/混历史产品(防串货)
- 🔴 补(上线版仍缺)**生图通道加重试退避**(gpt-image-2实测502,baseline验证必要)
- 🔴 一期执行模型(纠正混淆,外部AI审核P0-3/4)
- **一期只做 storyboard 分镜图**:一篇笔记出 N 张叙事图(N=用户设定image_count默认3**合法1-8**对齐worker getNarrativeRoles 3/6/8档链路**不是固定10选3**)
- 每张 storyboard 图走 provider 主备(gpt edits→gemini fallback) + 重试退避
- **A/B/C 三策略不在一期主链路**A/B/C 是"同一目标图的三种改图策略",留给二期的"单图重生成/改图"功能,不与 storyboard 分镜混跑(否则任务数×UI 爆)
- getNarrativeRoles 角色框架(hook/pain_scene/applied_proof/texture/scenario/tutorial/social_proof/closer)是可迁移数据,按 image_count 取前 N 个角色
### 4.3 裂变引擎扒split.js上线版
- 1爆款→N完整笔记包(标题+正文+标签+imagePlan分镜+维度+人群+场景+痛点)
- 爆款参考度调节(低30/中/高85防抄袭) ; generateImages=true串生图
- _combine_benchmarks1-5篇标杆合并成综合reference(新造)
### 4.4 去水印image_postprocessor·新造·洞3
- 🔴 主方案=**重编码去C2PA元数据+转JPG+轻重采样削像素水印+压缩**(Pillow,对客户透明);保留可视质量,**C2PA元数据可去除私有像素水印只能削弱不保证100%清除**(诚实表述,外部AI审核P1-9)
- 备选=banana remove_watermark(Gemini重绘)——⚠️对海报中文大字有改字风险,仅特殊场景用,非主通道
- eval_score**一期留NULL**,不接banana假评分(88分污染飞轮),等组长标准蒸馏真评分(三期)
## 5. 异步队列扒banana骨架
- Celery任务生图异步(每张~4min) / **只传task_id绝不传key(基石B)** / worker内查库取encrypted_key→FERNET_KEY解密→局部变量,不落盘不打日志
- gemini_factory每任务用自己key构建实例(新造,解决全局单例)
- 失败处理Celery task失败→更新任务状态→SSE推task_failed(带code) ; 生图502重试退避
- SSERedis Pub/Sub推进度(扒banana event_bus)
## 6. 多租户与安全基石BC,扒banana JWT
- JWT HS256每请求验签,放user_id/workspace_id/role加速字段
- 🔴 读写分层(基石C外部AI审核P1-7细化)
- **写操作+切workspace**:必须查 workspace_members 校验权限
- **列表读**:用 JWT 的 current_workspace_id 过滤即可(加速)
- **单资源读**(任务详情/候选详情/下载包):仍须校验 resource.workspace_id == current_workspace_id不能笼统信JWT(防越权读他人资源)
- base_workspace_repo所有workspace相关Repo继承,强制带workspace_id过滤(新造,防串数据)
- workspace_guard中间件FastAPI依赖注入,所有业务接口强制注入workspace_id
- token-url收口统一自家中转站endpoint**账号表只存encrypted_key不存url字段**(旧"兼容任意url"已废止)
- 上传安全(扒banana)magic number校验+扩展名白名单+路径穿越防护
## 7. 存档打包package_exporter·新造·洞6
- 达人素材交付包:按笔记分文件夹+图(01/02/03命名防错序)+文案.txt(标题+正文+标签)+📋发布清单.txt+✅合规说明.txt(违禁词已过/已去水印)
- delivery_packages表:status(pending/ready/downloaded)/package_path
- 数据可导出(归客户,client_data) ; 飞轮偏好归平台(platform_asset)
## 8. 偏好飞轮(新造·灵魂·开放测试即启动)
- 三信号入口(preference_collector写events):选文案+3/选图+3/通过+5/打回-3/重生成-1(初始默认权重,北哥可校准)
- 实时聚合(preference_aggregator)查最近50条→最常选角度+打回原因近3条原文拼进prompt;不足5条用产品档案冷启动
- 三层继承L1品牌基线>L2人设(二期)>L3个人手感;⚠️按产品维度分开学(素颜霜不串精华)
- 🔴 飞轮MVP验收四点(基石E,全过才达标)events正确写入✓ / aggregator返回明确prompt片段✓ / 下次prompt trace含该片段✓ / UI显示"本次已注入:最近偏好/打回原因"✓
## 9. 配置与环境(架构没列,PRD补
- 环境变量完整清单(必填项启动时校验,缺失则启动失败报错不静默)
- FERNET_KEY(必填,key解密,绝不进代码库) / JWT_SECRET(必填≥32字符) / DATABASE_URL / MONGO_URI / REDIS_URL
- IMAGE_PROVIDER_PRIMARY=gpt / IMAGE_PROVIDER_FALLBACK=gemini / IMAGE_API_BASE(自家中转站url) / IMAGE_MODEL=gpt-image-2
- CLAUDE_API_URL / GEMINI_API_URL(都收口自家站)
- 6容器Docker Compose(扒banana,调环境变量名) ; 文件存储:交付包路径规则uploads/packages/{workspace}/{task}/
## 10. 工程规范与可观测
- 健康检查 GET /health(Docker健康检查用)
- Alembic迁移001-004按序;回滚策略;FERNET_KEY缺失启动保护
- 可观测每次AI调用记ai_call_logs(谁/多少token,失败归因到个人key) ; MongoDB存调用trace排障
- 错误处理:统一错误码(§3.2) ; Celery失败SSE推送

View File

@@ -0,0 +1,34 @@
# 前端PRD交叉审核报告2026-06-05
> 审核方式agent对照 设计图 + 前端交互设计.md(5屏金标准) + 架构方案三角色旅程 + 后端PRD契约 + banana前端范式逐条核对前端PRD骨架。
> 重大发现:**已有 `前端交互设计.md` 是经倩倩姐确认的5屏金标准**(配置台/开任务/挑文案/挑图/组长审核+异步生图电影+飞轮隐形+6真实状况)前端PRD骨架没承接它→这是骨架"看不懂"的根因。铁律:好的保留,这份5屏设计原样吃进PRD。
## 审出问题11遗漏+4不一致+工程空白)
### 🔴 重大遗漏
1. **组长审核台**整体缺失(屏5):审核队列/预览/通过+5/打回写原因(飞轮最强信号)/审后跳转。三角色只画了熟手
2. **"今天主题"输入框**漏了(设计图屏2关键字段):驱动当次文案方向,缺了=无方向生成
3. **文案双轨轨B**前端入口缺(导入豆包等外部文案/source=import的UI区分)
4. **飞轮"本次已注入偏好"提示UI**缺(基石E验收点④,后端专开GET /preference/context供它调)
5. **SSE逐图进度电影**只一行(交互设计.md §2:N/10计数+好一批亮一批+单批失败标红重试+能离开降焦虑)
6. **配置预览面板"编辑配置"入口**交互未描述(跳转/侧滑/模态?)
7. **错误态UI**只"toast"一笔带过(后端7错误码各不同:KEY_NOT_CONFIGURED=引导配key页/BANNED_WORD=显违禁词级别/502重试等)
8. **管理员配置台**缺(屏1:产品档案/标杆/成员/违禁词CRUD)
9. **标杆笔记管理界面**缺(一期主通道=截图上传+手填亮点)
10. **"最近任务"底部区**缺(设计图底部4个进度条卡)
11. **五维评分**没说哪五维/怎么展示(数字/雷达/进度条)/数据来自哪字段
### 🟡 不一致
- C1:设计图有Token余额568320,PRD已正确标"不展示",但没说底部位置替换成什么
- C2:第6章组件库还残留"Token余额组件"(与3bis矛盾,删)
- C3:3.6"确认文案"混淆熟手/组长视角(通过打回是组长在独立审核台做,不是熟手确认页)
- C4:5步流程条对组长/管理员是否隐藏未说(组长主界面是审核队列不走1→5)
### 🟢 工程空白(建议补)
状态管理4Store(auth/task/generation/preference)/路由表/SSE断线重连UI/骨架屏/表单校验/响应式断点(建议最小1280px PC优先)/角色路由守卫(/review只组长管理员)/首登key引导流
## 🟦 CC处理判断
- 铁律好的保留:**前端交互设计.md的5屏设计原样吃进PRD**,不重新发明
- 设计图(5步流程条)与交互设计.md(5屏)合并:开新任务/选文案/选图/确认预览(熟手提审前)/组长审核台;配置台进左侧导航
- 前端PRD同样做承接层:引用交互设计.md的5屏画面+设计图视觉,补工程细节(路由/Store/错误态/角色守卫)+对接后端契约(SSE事件/错误码)
- 优先级:组长审核台/错误态UI/飞轮注入提示 三项直接卡飞轮和生产链验收

View File

@@ -0,0 +1,42 @@
# 后端PRD交叉审核报告2026-06-05
> 审核方式agent对照架构方案v0.3 + CLAUDE.md + 7洞落盘 + 扒包(上线版) 逐条核对后端PRD骨架完整性。
> 结论后端PRD骨架"只提到名词,没承接细节"架构方案v0.3里拍过板的细节几乎都没落进PRD——这是倩倩姐"看不懂"的根因。
## 审出的问题18遗漏+5不一致+10工程空白
### 🔴 重大遗漏架构v0.3拍了板但PRD没承接
1. 基石A 业务参数不写死(品类/数量/角度) 无落点
2. 基石B key机制不完整(缺FERNET_KEY环境变量/gemini_factory每任务建实例/解密不落盘)
3. 基石C 读写权限分层(写操作查workspace_members)+base_workspace_repo中间件 缺失
4. 基石D 采集↔生产接缝(人工判断标准落点) 完全没有
5. 基石E 飞轮四点验收 没列
6. 16表精确清单+Alembic001-004分批 缺(且PRD表名错:api_keys应为user_api_keys/tasks应为generation_tasks/无assets表应为text+image_candidates/无login_records/无ai_call_logs)
7. 任务状态机7态(pending→generating→pending_selection→pending_review→approved/rejected→archived) 缺
8. generation_tasks审核字段平铺(6字段,不建独立notes表) 缺
9. preference_events字段(signal_type5枚举/weight/angle_label/reason/data_ownership/workspace_id+product_id) 缺
10. 文案轨A"一次出5角度JSON"规格 缺(易被agent照抄产品包单条串行)
11. 文案轨B(导入外部文案text_import_handler+source字段) 完全缺
12. storyboard分镜与A/B/C并发两套生图逻辑如何整合 没说清
13. IMAGE_PROVIDER_PRIMARY环境变量配主备+禁纯文生图+产品锚点防串货 缺
14. 三期构建顺序1A/1B/二期/三期+一期不做项 缺
15. eval_score留NULL不接banana假评分 缺
16. 标杆采集通道分层(截图手填主/登录态插件内部) 缺
17. ai_call_logs(usage记录,按key计费排障基础) 缺
18. MongoDB只存debug trace(业务不依赖)边界 缺(PRD误把prompt_template放Mongo,实际可调prompt在products表)
### 🟡 不一致
- C1: PRD把data_ownership列成独立表,实际是preference_events的字段
- C2: 生图把产品包10角色/6品类 和 edits新接口 混在一起没说整合
- C3: 去水印主次反了(主=re-encode/Pillow,banana重绘是可选且海报有改字风险)
- C4: 三层兜底/五维打分要标明"扒产品包重写Python集成进轨A"
- C5: token-url收口缺反面约束(账号表不存url字段/旧兼容废止)
### 🟢 所有来源都没细化、PRD该补的工程细节
错误码规范/SSE事件类型清单/分页规范/限流(生图4min)/文件存储路径/DB索引/Alembic回滚+FERNET_KEY缺失启动保护/健康检查/环境变量完整清单/workspace_guard注入时机
## 🟦 CC的处理判断不照搬agent建议
- agent建议把后端PRD扩成14章、把架构方案§四§五原文照搬进PRD → **不采纳全照搬**
- 理由架构方案v0.3已是金标准,PRD原文复制=两份打架+维护噩梦
- **正确做法后端PRD做成"承接层"——①引用架构方案(不复制)②重点补PRD独有的工程细节(§四绿色那10项:错误码/SSE/分页/限流/索引/环境变量等)③把扒包上线版的生图新事实写进去**
- 数据模型这种基础:PRD用"引用架构方案§三 + 标注扒包修正点"方式,不重抄16表

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
# 系统依赖curl 供 healthcheck无需 libmysqlclient-dev用 pymysql 纯 Python 驱动)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000

30
backend/README.md Normal file
View File

@@ -0,0 +1,30 @@
# Clover Backend
FastAPI + Celery + MySQL + MongoDB + Redis
## 目录结构
```
backend/
app/
api/v1/ # 路由层auth/products/tasks/review/delivery
services/ # 业务逻辑层(按模块拆分)
models/ # SQLAlchemy ORM 模型14张表
middleware/ # workspace_guard 等中间件
workers/ # Celery 任务只传task_idworker内解密key
constants/ # 策略命名中心文件,消除打架
utils/ # Fernet加密、SSE工具等
alembic/
versions/ # 001-004 migration 脚本
tests/ # TDD 测试用例
```
## 依赖(需装)
见 requirements.txt待 BE agent 填)
## 启动
见 docker-compose.yml待 BE agent 填)
## 铁律提醒
- API Key 绝不进 Celery 参数,只传 task_id
- FERNET_KEY 走环境变量
- 所有查询强制带 workspace_id

45
backend/alembic.ini Normal file
View File

@@ -0,0 +1,45 @@
# Alembic 配置文件
# Clover 项目使用 alembic.ini 在 backend/ 目录
[alembic]
script_location = alembic
prepend_sys_path = .
# 从环境变量读取不硬编码基石B
sqlalchemy.url = %(DATABASE_URL)s
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

16
backend/alembic/README.md Normal file
View File

@@ -0,0 +1,16 @@
# alembic/
Alembic 数据库迁移脚本占位:
## 执行顺序
001 → 002 → 003 → 004有外键依赖必须按顺序
## 版本规划
- 001_from_banana.py # users/login_records/user_preferences从banana搬3张改造
- 002_multi_tenant_base.py # workspaces/workspace_members/user_api_keys
- 003_business_core.py # 业务主体7张products等
- 004_flywheel.py # preference_events
## 铁律
- matrix_accounts / preference_profile 一期不建(二期预留)
- Alembic 版本号统一管理,不允许手动改表

62
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,62 @@
"""
alembic/env.py — Alembic 运行环境配置
从环境变量读取 DATABASE_URL不硬编码。
"""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# ── 确保 app 包可导入 ────────────────────────────────
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.database import Base
import app.models # noqa: F401 — 触发所有模型注册
config = context.config
# 从环境变量覆盖 DATABASE_URL
db_url = os.environ.get("DATABASE_URL")
if db_url:
config.set_main_option("sqlalchemy.url", db_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,29 @@
"""
alembic/script.py.mako — 迁移文件模板
"""
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,64 @@
"""001_banana_users_tables
Revision ID: 001
Revises:
Create Date: 2026-06-09
Alembic 001: 从 banana 搬 3 张表users/login_records/user_preferences
删除 credits 字段,其余无改。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(64), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
sa.UniqueConstraint("email"),
)
op.create_table(
"login_records",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("ip_address", sa.String(64), nullable=True),
sa.Column("user_agent", sa.String(512), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_login_records_user_id", "login_records", ["user_id"])
op.create_table(
"user_preferences",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("preferences_json", sa.Text(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
)
def downgrade() -> None:
op.drop_table("user_preferences")
op.drop_index("idx_login_records_user_id", "login_records")
op.drop_table("login_records")
op.drop_table("users")

View File

@@ -0,0 +1,71 @@
"""002_multitenant_workspace
Revision ID: 002
Revises: 001
Create Date: 2026-06-09
Alembic 002: 多租户基础workspaces/workspace_members/user_api_keys
matrix_accounts 二期预留,一期不建。
user_api_keys 不存 url 字段token站固定自家站基石B
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"workspaces",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("slug", sa.String(64), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_table(
"workspace_members",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("role", sa.Enum("admin", "supervisor", "operator", name="userrole"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
)
op.create_index("idx_workspace_members_workspace_id", "workspace_members", ["workspace_id"])
op.create_table(
"user_api_keys",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("encrypted_key", sa.String(512), nullable=False),
sa.Column("key_last4", sa.String(4), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "workspace_id", "provider", name="uq_user_workspace_provider"),
)
op.create_index("idx_user_api_keys_workspace_id", "user_api_keys", ["workspace_id"])
def downgrade() -> None:
op.drop_index("idx_user_api_keys_workspace_id", "user_api_keys")
op.drop_table("user_api_keys")
op.drop_index("idx_workspace_members_workspace_id", "workspace_members")
op.drop_table("workspace_members")
op.drop_table("workspaces")
op.execute("DROP TYPE IF EXISTS userrole")

View File

@@ -0,0 +1,205 @@
"""003_business_tables
Revision ID: 003
Revises: 002
Create Date: 2026-06-09
Alembic 003: 业务主体7张表 + ai_call_logs
products / benchmark_notes / banned_words /
generation_tasks / text_candidates / image_candidates /
delivery_packages / ai_call_logs
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TASK_STATUS = sa.Enum(
"pending", "generating", "pending_selection",
"pending_review", "approved", "rejected", "archived",
name="taskstatus",
)
_REVIEW_STATUS = sa.Enum("pending", "approved", "rejected", name="reviewstatus")
_CANDIDATE_SOURCE = sa.Enum("ai", "import", name="candidatesource")
_BANNED_LEVEL = sa.Enum("auto_fix", "soft_warn", "hard_block", name="bannedwordlevel")
_BANNED_STATUS = sa.Enum("pass", "auto_fixed", "soft_warn", "hard_block", name="bannedwordstatus")
_IMAGE_ROLE = sa.Enum("hook", "pain", "proof", "quality", "credit", "convert", "main", name="imagerole")
_PKG_STATUS = sa.Enum("pending", "ready", "downloaded", name="packagestatus")
_PRODUCT_SOURCE = sa.Enum("preset", "custom", name="productsource")
def upgrade() -> None:
op.create_table(
"products",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("category", sa.String(64), nullable=True),
sa.Column("source", _PRODUCT_SOURCE, nullable=False, server_default="custom"),
sa.Column("selling_points", sa.Text(), nullable=True),
sa.Column("style_tone", sa.String(128), nullable=True),
sa.Column("text_angles", sa.Text(), nullable=True),
sa.Column("custom_prompt", sa.Text(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_products_workspace_id", "products", ["workspace_id"])
op.create_table(
"benchmark_notes",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("product_id", sa.BigInteger(), nullable=False),
sa.Column("screenshot_url", sa.String(512), nullable=True),
sa.Column("highlights", sa.Text(), nullable=True),
sa.Column("link_url", sa.String(512), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["products.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_benchmark_notes_product_id", "benchmark_notes", ["product_id"])
op.create_table(
"banned_words",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("word", sa.String(64), nullable=False),
sa.Column("level", _BANNED_LEVEL, nullable=False),
sa.Column("replacement", sa.String(128), nullable=True),
sa.Column("updatable", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_banned_words_workspace_id", "banned_words", ["workspace_id"])
op.create_table(
"generation_tasks",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("product_id", sa.BigInteger(), nullable=False),
sa.Column("operator_id", sa.BigInteger(), nullable=False),
sa.Column("theme", sa.String(256), nullable=True),
sa.Column("text_count", sa.Integer(), nullable=False, server_default="5"),
sa.Column("image_count", sa.Integer(), nullable=False, server_default="3"),
sa.Column("track", sa.String(16), nullable=False, server_default="ai"),
sa.Column("status", _TASK_STATUS, nullable=False, server_default="pending"),
sa.Column("mongo_trace_id", sa.String(24), nullable=True),
sa.Column("review_status", _REVIEW_STATUS, nullable=True),
sa.Column("reviewer_id", sa.BigInteger(), nullable=True),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("reject_reason", sa.Text(), nullable=True),
sa.Column("approved_at", sa.DateTime(), nullable=True),
sa.Column("archived_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.ForeignKeyConstraint(["operator_id"], ["users.id"]),
sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_generation_tasks_workspace_status", "generation_tasks", ["workspace_id", "status"])
op.create_table(
"text_candidates",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("task_id", sa.BigInteger(), nullable=False),
sa.Column("source", _CANDIDATE_SOURCE, nullable=False, server_default="ai"),
sa.Column("angle_label", sa.String(64), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("score_json", sa.Text(), nullable=True),
sa.Column("banned_word_status", _BANNED_STATUS, nullable=False, server_default="pass"),
sa.Column("eval_score", sa.Float(), nullable=True),
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_text_candidates_task_id", "text_candidates", ["task_id"])
op.create_table(
"image_candidates",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("task_id", sa.BigInteger(), nullable=False),
sa.Column("role", _IMAGE_ROLE, nullable=False, server_default="main"),
sa.Column("url", sa.String(512), nullable=True),
sa.Column("strategy", sa.String(4), nullable=True),
sa.Column("seq", sa.Integer(), nullable=False, server_default="1"),
sa.Column("is_selected", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("eval_score", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_image_candidates_task_id", "image_candidates", ["task_id"])
op.create_table(
"delivery_packages",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("task_id", sa.BigInteger(), nullable=False),
sa.Column("status", _PKG_STATUS, nullable=False, server_default="pending"),
sa.Column("package_path", sa.String(512), nullable=True),
sa.Column("download_url", sa.String(512), nullable=True),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"ai_call_logs",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("key_id", sa.BigInteger(), nullable=True),
sa.Column("task_id", sa.BigInteger(), nullable=True),
sa.Column("provider", sa.String(32), nullable=True),
sa.Column("model", sa.String(64), nullable=True),
sa.Column("call_type", sa.String(32), nullable=True),
sa.Column("prompt_tokens", sa.Integer(), nullable=True),
sa.Column("completion_tokens", sa.Integer(), nullable=True),
sa.Column("success", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("error_code", sa.String(32), nullable=True),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["key_id"], ["user_api_keys.id"]),
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_ai_call_logs_workspace_user", "ai_call_logs", ["workspace_id", "user_id"])
op.create_index("idx_ai_call_logs_task_id", "ai_call_logs", ["task_id"])
def downgrade() -> None:
op.drop_index("idx_ai_call_logs_task_id", "ai_call_logs")
op.drop_index("idx_ai_call_logs_workspace_user", "ai_call_logs")
op.drop_table("ai_call_logs")
op.drop_table("delivery_packages")
op.drop_index("idx_image_candidates_task_id", "image_candidates")
op.drop_table("image_candidates")
op.drop_index("idx_text_candidates_task_id", "text_candidates")
op.drop_table("text_candidates")
op.drop_index("idx_generation_tasks_workspace_status", "generation_tasks")
op.drop_table("generation_tasks")
op.drop_index("idx_banned_words_workspace_id", "banned_words")
op.drop_table("banned_words")
op.drop_index("idx_benchmark_notes_product_id", "benchmark_notes")
op.drop_table("benchmark_notes")
op.drop_index("idx_products_workspace_id", "products")
op.drop_table("products")
# MySQL 不支持 DROP TYPE枚举存 VARCHAR 无需清理

View File

@@ -0,0 +1,67 @@
"""004_flywheel_preference_events
Revision ID: 004
Revises: 003
Create Date: 2026-06-09
Alembic 004: 飞轮信号表
preference_events 一期建表写入
preference_profile 二期预留,一期不建
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_SIGNAL_TYPE = sa.Enum(
"text_select", "image_select", "approve",
"reject_with_reason", "regenerate",
name="signaltype",
)
_DATA_OWNERSHIP = sa.Enum("client_data", "platform_asset", name="dataownership")
def upgrade() -> None:
op.create_table(
"preference_events",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("workspace_id", sa.BigInteger(), nullable=False),
sa.Column("product_id", sa.BigInteger(), nullable=False),
sa.Column("task_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("signal_type", _SIGNAL_TYPE, nullable=False),
sa.Column("signal_weight", sa.Integer(), nullable=False),
sa.Column("candidate_id", sa.BigInteger(), nullable=True),
sa.Column("angle_label", sa.String(64), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("signal_meta", sa.Text(), nullable=True),
sa.Column(
"data_ownership", _DATA_OWNERSHIP,
nullable=False, server_default="client_data",
),
sa.Column(
"created_at", sa.DateTime(),
server_default=sa.text("now()"), nullable=False,
),
sa.ForeignKeyConstraint(["product_id"], ["products.id"]),
sa.ForeignKeyConstraint(["task_id"], ["generation_tasks.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_preference_events_ws_product_created",
"preference_events",
["workspace_id", "product_id", "created_at"],
)
# preference_profile 二期预留,一期不建
def downgrade() -> None:
op.drop_index("idx_preference_events_ws_product_created", "preference_events")
op.drop_table("preference_events")
# MySQL 不支持 DROP TYPE枚举存 VARCHAR 无需清理

View File

@@ -0,0 +1,27 @@
"""005_product_image_path — products 表加 image_path 列(产品参考图本地路径)
前端图片上传完成后BE 接口写入此字段pipeline_io 读取用于生图参考。
"""
from alembic import op
import sqlalchemy as sa
revision = "005"
down_revision = "004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"products",
sa.Column(
"image_path",
sa.String(512),
nullable=True,
comment="产品参考图本地文件路径(前端上传后写入,生图时作 reference_images 注入)",
),
)
def downgrade() -> None:
op.drop_column("products", "image_path")

View File

@@ -0,0 +1,45 @@
"""006_image_role_varchar — image_candidates.role 从 enum 改 varchar(32)
根因storyboard 角色名仍在演进hook/product_closeup/ingredient/texture/
applied_proof/closer/pain_scene/social_proof/scenario/tutorial…固定 enum
每加一个新角色就要迁一次,且旧 enum 漏了 product_closeup/ingredient 直接导致
落库 "Data truncated for column 'role'" → 生图任务无限重试卡死。
改 varchar 后落库不再受 enum 约束,角色名由 ImageRole 枚举在应用层定义。
"""
from alembic import op
import sqlalchemy as sa
revision = "006"
down_revision = "005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"image_candidates",
"role",
existing_type=sa.Enum(
"hook", "pain", "proof", "quality", "credit", "convert", "main",
name="role",
),
type_=sa.String(32),
existing_nullable=False,
server_default="hook",
comment="分镜角色名storyboard 输出,应用层 ImageRole 定义,不在 DB 约束)",
)
def downgrade() -> None:
op.alter_column(
"image_candidates",
"role",
existing_type=sa.String(32),
type_=sa.Enum(
"hook", "pain", "proof", "quality", "credit", "convert", "main",
name="role",
),
existing_nullable=False,
server_default="main",
)

View File

@@ -0,0 +1,33 @@
"""add need_product_image to generation_tasks
Revision ID: 007
Revises: 006
Create Date: 2026-06-08
本次产品是否入镜开关True=必须产品参考图(无图禁生成,不降级)False=允许纯文生图。
默认 True铁律生图优先带产品参考图
"""
from alembic import op
import sqlalchemy as sa
revision = "007"
down_revision = "006"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"generation_tasks",
sa.Column(
"need_product_image",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
comment="本次产品是否入镜:True需产品图(无图禁生成不降级)/False允许纯文生图",
),
)
def downgrade():
op.drop_column("generation_tasks", "need_product_image")

View File

@@ -0,0 +1,32 @@
"""add brand_keyword to products
Revision ID: 008
Revises: 007
Create Date: 2026-06-13
第5环品牌词字段客户输入植入文案每条+生图特写图(第2/6张)。
brand_keyword 是产品级字段随产品固定由客户录入非AI生成
"""
from alembic import op
import sqlalchemy as sa
revision = "008"
down_revision = "007"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"products",
sa.Column(
"brand_keyword",
sa.String(64),
nullable=True,
comment="品牌词(客户输入,植入文案每条+生图特写图第2/6张",
),
)
def downgrade():
op.drop_column("products", "brand_keyword")

View File

@@ -0,0 +1,44 @@
"""add features_json and analyze_status to benchmark_notes
Revision ID: 009
Revises: 008
Create Date: 2026-06-13
第2环标杆分析字段
- features_json: 存储8特征结构化分析结果TEXT JSON
- analyze_status: AI分析状态机 pending/analyzing/done/failed
"""
from alembic import op
import sqlalchemy as sa
revision = "009"
down_revision = "008"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"benchmark_notes",
sa.Column(
"features_json",
sa.Text,
nullable=True,
comment="爆款8特征分析结果JSON第2环AI解析后写入",
),
)
op.add_column(
"benchmark_notes",
sa.Column(
"analyze_status",
sa.String(20),
nullable=False,
server_default="pending",
comment="AI分析状态: pending/analyzing/done/failed",
),
)
def downgrade():
op.drop_column("benchmark_notes", "analyze_status")
op.drop_column("benchmark_notes", "features_json")

View File

@@ -0,0 +1,70 @@
"""create fission_tasks table
Revision ID: 010
Revises: 009
Create Date: 2026-06-13
第11环裂变引擎1爆款→N套完整笔记包非只换文案
fission_tasks 是裂变任务主表,挂载在工作台内呈现。
reference_level: low/mid/high参考程度
"""
from alembic import op
import sqlalchemy as sa
revision = "010"
down_revision = "009"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"fission_tasks",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column(
"workspace_id",
sa.BigInteger,
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
comment="多租户隔离",
),
sa.Column(
"source_note",
sa.Text,
nullable=True,
comment="爆款源笔记内容(文案+图描述JSON存储",
),
sa.Column(
"reference_level",
sa.String(10),
nullable=False,
server_default="mid",
comment="参考程度: low/mid/high",
),
sa.Column(
"fanout_count",
sa.Integer,
nullable=False,
server_default="3",
comment="裂变套数默认3套",
),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
comment="状态: pending/generating/done/failed",
),
sa.Column(
"created_at",
sa.DateTime,
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("idx_fission_tasks_workspace_id", "fission_tasks", ["workspace_id"])
def downgrade():
op.drop_index("idx_fission_tasks_workspace_id", table_name="fission_tasks")
op.drop_table("fission_tasks")

View File

@@ -0,0 +1,43 @@
"""add benchmark_ids and source_fission_id to generation_tasks
Revision ID: 011
Revises: 010
Create Date: 2026-06-13
合并第2环S12+第11环两个需求
- benchmark_ids: 关联标杆笔记ID列表TEXT存JSON list第2环标杆分析引用
- source_fission_id: 裂变来源任务IDNULL=普通任务非NULL=裂变产出第11环
"""
from alembic import op
import sqlalchemy as sa
revision = "011"
down_revision = "010"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"generation_tasks",
sa.Column(
"benchmark_ids",
sa.Text,
nullable=True,
comment="关联标杆笔记ID列表JSON list第2环标杆分析引用",
),
)
op.add_column(
"generation_tasks",
sa.Column(
"source_fission_id",
sa.Integer,
nullable=True,
comment="裂变来源fission_task IDNULL=普通任务非NULL=裂变产出第11环",
),
)
def downgrade():
op.drop_column("generation_tasks", "source_fission_id")
op.drop_column("generation_tasks", "benchmark_ids")

View File

@@ -0,0 +1,29 @@
"""012 products 表加 target_audience 字段
Revision ID: 012_product_target_audience
Revises: 011_task_benchmark_fission_fields
Create Date: 2026-06-15
"""
from alembic import op
import sqlalchemy as sa
revision = "012"
down_revision = "011"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"products",
sa.Column(
"target_audience",
sa.String(128),
nullable=True,
comment="目标人群,客户输入,透传进文案/生图prompt",
),
)
def downgrade():
op.drop_column("products", "target_audience")

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,11 @@
# app/api/v1/
路由层占位BE agent 按模块创建:
- auth.py # 登录/me/切workspace
- api_keys.py # API Key录入/列表/删除
- products.py # 产品档案CRUD
- benchmarks.py # 标杆笔记CRUD挂在products下
- tasks.py # 生产任务(发起/列表/详情/SSE/选候选)
- review.py # 组长审核(队列/通过/打回)
- delivery.py # 交付包生成+下载
- banned_words.py # 违禁词库CRUD

View File

View File

@@ -0,0 +1,130 @@
"""
app/api/v1/api_keys.py — API Key 路由
只录不显余额CLAUDE.md 红线)。
只返回 provider + key_last4不返回 encrypted_key。
url 字段不接收token站固定自家站架构方案§二
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, raise_not_found
from app.core.security import encrypt_api_key, mask_api_key
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.workspace import UserApiKey
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
# ── DTO ────────────────────────────────────────────────────
class CreateApiKeyRequest(BaseModel):
provider: str
api_key: str
@field_validator("provider")
@classmethod
def provider_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("provider 不能为空")
return v.strip().lower()
@field_validator("api_key")
@classmethod
def key_not_empty(cls, v: str) -> str:
if not v or len(v) < 8:
raise ValueError("api_key 无效")
return v
# 注:不接收 url 字段token站固定自家站基石B
def _format_key(k: UserApiKey) -> dict:
"""只显 provider + key_last4不暴露余额/用量/encrypted_key红线"""
return {
"id": k.id,
"provider": k.provider,
"key_last4": k.key_last4,
"created_at": k.created_at.isoformat(),
}
# ── 路由 ───────────────────────────────────────────────────
@router.get("")
def list_api_keys(
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""列出当前用户在此 workspace 的 API Key只显后4位"""
keys = (
db.query(UserApiKey)
.filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
)
.all()
)
return ok({"items": [_format_key(k) for k in keys]})
@router.post("")
def create_api_key(
body: CreateApiKeyRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""录入 API KeyFernet 加密存储只保存后4位明文用于展示"""
from app.core.response import raise_business
# 检查同 user+workspace+provider 是否已有
existing = (
db.query(UserApiKey)
.filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == body.provider,
)
.first()
)
if existing:
raise_business(f"已存在 {body.provider} 的 API Key请先删除再录入")
encrypted = encrypt_api_key(body.api_key)
key_obj = UserApiKey(
user_id=current_user.user_id,
workspace_id=current_user.workspace_id,
provider=body.provider,
encrypted_key=encrypted,
key_last4=mask_api_key(body.api_key),
)
db.add(key_obj)
db.commit()
db.refresh(key_obj)
logger.info("API key created: user=%s provider=%s", current_user.user_id, body.provider)
return ok(_format_key(key_obj))
@router.delete("/{key_id}")
def delete_api_key(
key_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""删除 API Key只能删自己的"""
key_obj = (
db.query(UserApiKey)
.filter(
UserApiKey.id == key_id,
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
)
.first()
)
if not key_obj:
raise_not_found("API Key 不存在")
db.delete(key_obj)
db.commit()
return ok({"deleted": key_id})

View File

@@ -0,0 +1,69 @@
"""
app/api/v1/auth.py — 认证路由
路由层只做:参数校验 → 调 service → 格式化响应(不含业务逻辑)
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, raise_unauthorized
from app.core.security import create_access_token, decode_access_token
from app.middleware.workspace_guard import CurrentUser, get_current_user
from app.models.user import User
from app.models.workspace import Workspace
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
# ── DTO ────────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
@field_validator("username", "password")
@classmethod
def not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("不能为空")
return v
# ── 路由 ───────────────────────────────────────────────────
@router.post("/login")
def login(body: LoginRequest, db: Session = Depends(get_db)):
"""登录,返回 JWT。密码校验在 service 层(此处调用)。"""
from app.services.auth_service import authenticate_user, build_user_response
user, workspace_id, role = authenticate_user(db, body.username, body.password)
token = create_access_token(user.id, workspace_id, role)
return ok({
"token": token,
"user": build_user_response(user, workspace_id, role),
})
@router.get("/me")
def get_me(
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""当前用户信息 + workspace + role。"""
user = db.query(User).filter(User.id == current_user.user_id).first()
if not user:
raise_unauthorized("用户不存在")
ws = db.query(Workspace).filter(Workspace.id == current_user.workspace_id).first()
return ok({
"id": user.id,
"username": user.username,
"email": user.email,
"current_workspace_id": current_user.workspace_id,
"role": current_user.role,
"workspace": {
"id": ws.id, "name": ws.name, "slug": ws.slug,
} if ws else None,
})

View File

@@ -0,0 +1,143 @@
"""
app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
主通道:截图+手填亮点(不做原始抓取)。
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
class BenchmarkCreate(BaseModel):
screenshot_url: str | None = None
highlights: str | None = None # 手填亮点(主通道)
link_url: str | None = None # 可选,不做自动抓取
class BannedWordCreate(BaseModel):
word: str
level: str # auto_fix | soft_warn | hard_block
replacement: str | None = None
updatable: bool = True
def _fmt_benchmark(b: BenchmarkNote) -> dict:
return {
"id": b.id, "product_id": b.product_id,
"screenshot_url": b.screenshot_url,
"highlights": b.highlights, "link_url": b.link_url,
"created_at": b.created_at.isoformat(),
}
def _fmt_banned(bw: BannedWord) -> dict:
return {
"id": bw.id, "word": bw.word, "level": bw.level,
"replacement": bw.replacement, "updatable": bw.updatable,
"workspace_id": bw.workspace_id,
}
# ── 标杆笔记 ────────────────────────────────────────────────
@router.get("/products/{product_id}/benchmarks")
def list_benchmarks(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
items = (
db.query(BenchmarkNote)
.filter(
BenchmarkNote.product_id == product_id,
BenchmarkNote.workspace_id == current_user.workspace_id,
)
.all()
)
return ok({"items": [_fmt_benchmark(b) for b in items]})
@router.post("/products/{product_id}/benchmarks")
def create_benchmark(
product_id: int, body: BenchmarkCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
b = BenchmarkNote(
workspace_id=current_user.workspace_id,
product_id=product_id,
screenshot_url=body.screenshot_url,
highlights=body.highlights,
link_url=body.link_url,
)
db.add(b); db.commit(); db.refresh(b)
return ok(_fmt_benchmark(b))
# ── 违禁词库 ────────────────────────────────────────────────
@router.get("/banned-words")
def list_banned_words(
page: int = 1, page_size: int = 50,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
q = db.query(BannedWord).filter(BannedWord.workspace_id == current_user.workspace_id)
total = q.count()
items = q.offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_banned(bw) for bw in items], total, page, page_size))
@router.post("/banned-words")
def create_banned_word(
body: BannedWordCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = BannedWord(
workspace_id=current_user.workspace_id,
word=body.word, level=body.level,
replacement=body.replacement, updatable=body.updatable,
)
db.add(bw); db.commit(); db.refresh(bw)
return ok(_fmt_banned(bw))
@router.put("/banned-words/{word_id}")
def update_banned_word(
word_id: int, body: BannedWordCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
if not bw:
raise_not_found("违禁词不存在")
if not bw.updatable:
from app.core.response import raise_forbidden
raise_forbidden("该违禁词不可修改")
bw.word = body.word; bw.level = body.level
bw.replacement = body.replacement; bw.updatable = body.updatable
db.commit(); db.refresh(bw)
return ok(_fmt_banned(bw))
@router.delete("/banned-words/{word_id}")
def delete_banned_word(
word_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
if not bw:
raise_not_found("违禁词不存在")
db.delete(bw); db.commit()
return ok({"deleted": word_id})

View File

@@ -0,0 +1,184 @@
"""
app/api/v1/delivery.py — 交付包路由
POST /tasks/{id}/package — 生成达人素材交付包
GET /delivery-packages/{id}/download — 下载status: pending/ready/downloaded
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌C1坑修复
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Query
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.task import DeliveryPackage, GenerationTask
logger = logging.getLogger(__name__)
router = APIRouter(tags=["delivery"])
class PackageRequest(BaseModel):
note_ids: list[int] = [] # 可选,指定要打包的内容
def _fmt_package(pkg: DeliveryPackage) -> dict:
return {
"id": pkg.id,
"status": pkg.status,
"download_url": pkg.download_url,
"expires_at": pkg.expires_at.isoformat() if pkg.expires_at else None,
}
@router.post("/tasks/{task_id}/package")
def create_package(
task_id: int, body: PackageRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""
生成达人素材交付包。
任务推入 Celery build_delivery_package 队列(只传 package_id
"""
task = db.query(GenerationTask).filter(
GenerationTask.id == task_id,
GenerationTask.workspace_id == current_user.workspace_id,
).first()
if not task:
raise_not_found("任务不存在")
if task.status not in ("approved", "pending_selection"):
from app.core.response import raise_business
raise_business("任务尚未生成完成,无法打包")
pkg = DeliveryPackage(
workspace_id=current_user.workspace_id,
task_id=task_id,
status="pending",
)
db.add(pkg)
db.commit()
db.refresh(pkg)
# 推 Celery 队列,只传 package_id基石B思路不传任何敏感信息
from app.workers.tasks import build_delivery_package
build_delivery_package.delay(pkg.id)
return ok({"delivery_package_id": pkg.id, "status": "pending"})
@router.get("/delivery-packages/{package_id}/download")
def get_package_download(
package_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""下载交付包status: pending/ready/downloaded"""
pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == current_user.workspace_id,
).first()
if not pkg:
raise_not_found("交付包不存在")
if pkg.status == "ready" and pkg.download_url:
# 标记为 downloaded只能下一次防重复公开 URL
pkg.status = "downloaded"
db.commit()
return ok(_fmt_package(pkg))
@router.post("/delivery-packages/{package_id}/download-token")
def create_download_token(
package_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""
C1坑修复签发一次性下载令牌60s有效
前端先用 JWT 调此接口,再用 token 直接 window.open/fetch无需在URL里传JWT。
"""
import secrets
import redis as sync_redis
from app.core.config import get_settings
pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == current_user.workspace_id,
).first()
if not pkg:
raise_not_found("交付包不存在")
if pkg.status not in ("ready", "downloaded"):
from app.core.response import raise_business
raise_business("交付包尚未准备好")
token = secrets.token_hex(32)
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
r.setex(f"dl_token:{token}", 60, str(package_id))
return ok({"token": token, "expires_in": 60})
@router.get("/delivery-packages/{package_id}/download-file")
def download_package_file(
package_id: int,
token: str = Query(default=""),
authorization: str = Header(default=""),
db: Session = Depends(get_db),
):
"""
直接下载交付包 zip 文件FileResponse
支持两种认证:
1. ?token=<download-token>(前端 window.open 用C1坑修复
2. Authorization: Bearer <JWT>API 调用用)
"""
import os
import redis as sync_redis
from app.core.config import get_settings
from app.core.security import decode_access_token
import jwt as pyjwt
# token 认证一次性60s有效跳过JWT
if token:
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
stored = r.getdel(f"dl_token:{token}")
if not stored or int(stored) != package_id:
from app.core.response import raise_business
raise_business("下载令牌无效或已过期")
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
else:
# JWT 认证
if not authorization or not authorization.startswith("Bearer "):
from app.core.response import raise_unauthorized
raise_unauthorized("缺少认证信息")
try:
payload = decode_access_token(authorization.split(" ", 1)[1])
except (pyjwt.PyJWTError, Exception):
from app.core.response import raise_unauthorized
raise_unauthorized("Token 无效")
workspace_id = int(payload["current_workspace_id"])
pkg = db.query(DeliveryPackage).filter(
DeliveryPackage.id == package_id,
DeliveryPackage.workspace_id == workspace_id,
).first()
if not pkg:
raise_not_found("交付包不存在")
if pkg.status not in ("ready", "downloaded"):
from app.core.response import raise_business
raise_business("交付包尚未准备好,请稍后重试")
if not pkg.package_path or not os.path.exists(pkg.package_path):
from app.core.response import raise_business
raise_business("交付包文件不存在,请重新打包")
filename = os.path.basename(pkg.package_path)
return FileResponse(
path=pkg.package_path,
media_type="application/zip",
filename=filename,
)

View File

@@ -0,0 +1,308 @@
"""
app/api/v1/products.py — 品牌库路由(管理员)
products / benchmark_notes / banned_words
category 是纯数据字段不在代码里做枚举基石A
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote, Product
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
# ── DTO ────────────────────────────────────────────────────
class ProductCreate(BaseModel):
name: str
category: str | None = None # 纯数据字段不做枚举基石A
source: str = "custom" # preset | custom
selling_points: list[str] = []
style_tone: str | None = None
text_angles: list[str] = [] # 用户设定不写死基石A
custom_prompt: str | None = None # 等北哥方案注入
banned_word_ids: list[int] = []
image_path: str | None = None # 产品参考图(可建档即带;通常走 upload-image 接口)
brand_keyword: str | None = None # 品牌词客户录入012:套2字段暴露
target_audience: str | None = None # 目标人群客户录入012:套2字段暴露
class BenchmarkCreate(BaseModel):
screenshot_url: str | None = None
highlights: str | None = None
link_url: str | None = None
class BannedWordCreate(BaseModel):
word: str
level: str # auto_fix | soft_warn | hard_block
replacement: str | None = None
updatable: bool = True
def _fmt_product(p: Product) -> dict:
import json
return {
"id": p.id, "name": p.name, "category": p.category,
"source": p.source, "is_active": p.is_active,
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
"style_tone": p.style_tone,
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
"custom_prompt": p.custom_prompt,
"image_path": p.image_path,
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
"target_audience": p.target_audience, # 012: 套2字段暴露
"created_at": p.created_at.isoformat(),
}
# ── 产品档案 ────────────────────────────────────────────────
@router.get("/products")
def list_products(
page: int = 1, page_size: int = 20, source: str | None = None,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
q = db.query(Product).filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
if source in ("preset", "custom"):
q = q.filter(Product.source == source)
total = q.count()
items = q.offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_product(p) for p in items], total, page, page_size))
@router.post("/products")
def create_product(
body: ProductCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
import json
p = Product(
workspace_id=current_user.workspace_id,
name=body.name, category=body.category, source=body.source,
selling_points=json.dumps(body.selling_points, ensure_ascii=False),
style_tone=body.style_tone,
text_angles=json.dumps(body.text_angles, ensure_ascii=False),
custom_prompt=body.custom_prompt,
image_path=body.image_path or None,
brand_keyword=body.brand_keyword or None, # 012: 套2字段
target_audience=body.target_audience or None, # 012: 套2字段
)
db.add(p)
db.commit()
db.refresh(p)
return ok(_fmt_product(p))
@router.get("/products/{product_id}")
def get_product(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p:
raise_not_found("产品不存在")
return ok(_fmt_product(p))
@router.put("/products/{product_id}")
def update_product(
product_id: int, body: ProductCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
import json
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p:
raise_not_found("产品不存在")
p.name = body.name; p.category = body.category; p.source = body.source
p.selling_points = json.dumps(body.selling_points, ensure_ascii=False)
p.style_tone = body.style_tone
p.text_angles = json.dumps(body.text_angles, ensure_ascii=False)
p.custom_prompt = body.custom_prompt
p.brand_keyword = body.brand_keyword or None # 012: 套2字段
p.target_audience = body.target_audience or None # 012: 套2字段
db.commit(); db.refresh(p)
return ok(_fmt_product(p))
@router.delete("/products/{product_id}")
def delete_product(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p:
raise_not_found("产品不存在")
p.is_active = False # 软删
db.commit()
return ok({"deleted": product_id})
# ── 产品参考图上传 ──────────────────────────────────────────
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
@router.post("/products/{product_id}/upload-image")
async def upload_product_image(
product_id: int,
file: UploadFile = File(...),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""
上传产品参考图(铁律:生图必须带产品图)。
文件类型JPEG/PNG/WebP大小上限 10 MB。
存储路径uploads/products/{workspace_id}/{product_id}/{filename}
写回 product.image_path生图管道读此字段构建 reference_images。
"""
from app.core.response import raise_business
from app.core.config import get_settings
import os, uuid
p = db.query(Product).filter(
Product.id == product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not p:
raise_not_found("产品不存在")
if file.content_type not in _ALLOWED_CONTENT_TYPES:
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制")
settings = get_settings()
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
# 存绝对路径:锚定 StaticFiles 根 /app/uploads避免 worker(cwd=/) 读不到。
abs_dir = os.path.join(settings.UPLOAD_ABS_ROOT, "products",
str(current_user.workspace_id), str(product_id))
os.makedirs(abs_dir, exist_ok=True)
filename = f"{uuid.uuid4().hex}{ext}"
save_path = os.path.join(abs_dir, filename)
with open(save_path, "wb") as f:
f.write(data)
p.image_path = save_path # 绝对路径worker 直接 open 可读
db.commit()
db.refresh(p)
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
return ok(_fmt_product(p))
# ── 套1 产品图视觉分析 ────────────────────────────────────────
_VISION_PROMPT = """你是小红书产品种草策划。请根据产品图分析卖点与人群。
硬性规则:只分析本次上传图片,不沿用历史案例,不默认护肤品。
返回JSON仅JSON无其他文字
{
"productName": "从包装识别,识别不到填空",
"category": "美妆护肤/个护护理/食品饮品/营养健康/家居生活/服饰穿搭/电商产品之一",
"sellingPoints": ["转成用户买点不要品牌空话3-6个"],
"targetAudience": "一句话描述核心人群",
"scenarios": ["使用场景2-4个"],
"keywords": ["小红书搜索关键词3-5个"],
"bannedWords": ["合规禁用词"],
"imageDirection": "这组图如何拍/排版,一句话",
"confidence": 0.9,
"source": "vision"
}"""
def _parse_vision_json(raw: str) -> dict:
"""从模型返回文本中提取JSON容错 markdown ```json 代码块)"""
import json, re
# 去掉 markdown 代码块包裹
cleaned = re.sub(r"```json\s*", "", raw)
cleaned = re.sub(r"```\s*", "", cleaned)
# 提取第一个 {...} 块
m = re.search(r"\{[\s\S]*\}", cleaned)
if not m:
raise ValueError("vision 返回内容中未找到 JSON")
return json.loads(m.group())
def _fallback_analysis(product_name: str = "") -> dict:
"""vision 失败时的文字兜底,保证接口不空手返回"""
return {
"productName": product_name or "产品",
"category": "电商产品",
"sellingPoints": ["使用方便", "适合日常场景"],
"targetAudience": "有明确使用场景和效率诉求的人群",
"scenarios": ["日常使用", "通勤出门"],
"keywords": [product_name or "好物", "真实测评", "种草分享"],
"bannedWords": ["美白", "祛斑", "速效", "医用", "药妆"],
"imageDirection": "产品白底图保证准确,真实场景图突出使用体验",
"confidence": 0.45,
"source": "fallback",
}
@router.post("/products/{product_id}/analyze-image")
async def analyze_product_image(
product_id: int,
file: UploadFile = File(...),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""
套1上传产品图 → GPT vision 读图 → 返回结构化卖点/人群分析。
key 链路:从当前用户 API key 解密基石Bplain_key 只活在局部变量。
"""
from app.core.response import raise_business
from app.models.workspace import UserApiKey
from app.utils.fernet_utils import decrypt_key
from app.services.ai_engine.gemini_factory import build_ai_clients
# 文件校验(复用 upload_product_image 同款规则)
if file.content_type not in _ALLOWED_CONTENT_TYPES:
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制")
# key 解密(照抄 pipeline_steps.decrypt_user_key基石B
api_key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider.in_(["openai", "apiports"]),
).first()
if not api_key_row:
raise_business("未配置 API Key请先在设置中录入")
plain_key = decrypt_key(api_key_row.encrypted_key)
clients = build_ai_clients(plain_key)
plain_key = None # 立即清零不传出基石B
try:
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
try:
result = _parse_vision_json(raw)
result["source"] = "vision"
except Exception as parse_err:
logger.warning("vision JSON parse failed, fallback: %s", parse_err)
result = _fallback_analysis()
result["warning"] = f"视觉分析解析失败,已使用文字兜底:{parse_err}"
except Exception as e:
logger.warning("vision_analyze failed, fallback: %s", e)
result = _fallback_analysis()
result["warning"] = f"视觉分析失败,已使用文字兜底:{e}"
finally:
await clients.aclose()
logger.info("analyze_product_image done: product_id=%s source=%s conf=%.2f",
product_id, result.get("source"), result.get("confidence", 0))
return ok(result)

View File

@@ -0,0 +1,140 @@
"""
app/api/v1/review.py — 审核路由(组长)
通过(+5最重) / 打回(写原因,-3回 pending_selection)
打回原因存 preference_events.reason不做 AI 归纳。
"""
import logging
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found, raise_state_invalid
from app.middleware.workspace_guard import CurrentUser, require_supervisor_or_above
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/review", tags=["review"])
class RejectRequest(BaseModel):
reason: str # 原文存入 preference_events不做 AI 归纳契约§3
@router.get("/queue")
def review_queue(
page: int = 1, page_size: int = 20,
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
db: Session = Depends(get_db),
):
"""待审队列pending_review 状态)。"""
q = (
db.query(GenerationTask)
.filter(
GenerationTask.workspace_id == current_user.workspace_id,
GenerationTask.status == "pending_review",
)
.order_by(GenerationTask.updated_at.asc())
)
total = q.count()
tasks = q.offset((page - 1) * page_size).limit(page_size).all()
items = [_fmt_queue_item(t, db) for t in tasks]
return ok(paginate(items, total, page, page_size))
def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
import json
from app.models.product import Product
product = db.query(Product).filter(Product.id == t.product_id).first()
operator = db.query(User).filter(User.id == t.operator_id).first()
selected_text = db.query(TextCandidate).filter(
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
).first()
selected_image = db.query(ImageCandidate).filter(
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
).first()
# content 列存 JSON dict解包取 title/content 分字段返回
text_parsed: dict = {}
if selected_text and selected_text.content:
try:
text_parsed = json.loads(selected_text.content)
except (json.JSONDecodeError, ValueError):
text_parsed = {"content": selected_text.content}
return {
"task_id": t.id,
"product_name": product.name if product else None,
"theme": t.theme,
"submitted_at": t.updated_at.isoformat(),
"operator_name": operator.username if operator else None,
"selected_text": {
"candidate_id": selected_text.id,
"angle_label": selected_text.angle_label,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""), # 纯正文
"tags": text_parsed.get("tags", []),
} if selected_text else None,
"selected_image": {
"candidate_id": selected_image.id,
"strategy": selected_image.strategy,
"url": selected_image.url,
} if selected_image else None,
}
@router.post("/{task_id}/approve")
def approve_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
db: Session = Depends(get_db),
):
"""通过(飞轮 +5最重状态 → approved"""
from app.services.flywheel_service import record_signal
task = db.query(GenerationTask).filter(
GenerationTask.id == task_id,
GenerationTask.workspace_id == current_user.workspace_id,
).first()
if not task:
raise_not_found("任务不存在")
if task.status != "pending_review":
raise_state_invalid("只有 pending_review 状态可审核")
task.status = "approved"
task.review_status = "approved"
task.reviewer_id = current_user.user_id
task.reviewed_at = datetime.now(timezone.utc)
task.approved_at = datetime.now(timezone.utc)
db.commit()
record_signal(db, current_user, task, "approve")
return ok({"task_id": task_id, "status": "approved"})
@router.post("/{task_id}/reject")
def reject_task(
task_id: int, body: RejectRequest,
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
db: Session = Depends(get_db),
):
"""打回(飞轮 -3回 pending_selection原因下次注入 prompt"""
from app.services.flywheel_service import record_signal
task = db.query(GenerationTask).filter(
GenerationTask.id == task_id,
GenerationTask.workspace_id == current_user.workspace_id,
).first()
if not task:
raise_not_found("任务不存在")
if task.status != "pending_review":
raise_state_invalid("只有 pending_review 状态可打回")
task.status = "pending_selection" # 打回回 pending_selection
task.review_status = "rejected"
task.reviewer_id = current_user.user_id
task.reviewed_at = datetime.now(timezone.utc)
task.reject_reason = body.reason
db.commit()
record_signal(db, current_user, task, "reject_with_reason", reason=body.reason)
return ok({"task_id": task_id, "status": "pending_selection"})

View File

@@ -0,0 +1,97 @@
"""
app/api/v1/stream.py — SSE ticket 签发 + SSE 流路由
安全红线倩倩姐2026-06-08拍板SSE 认证用一次性短票 ticket禁止 ?token= 直传 JWT。
流程:
1. FE 带 JWT 调 POST /tasks/{id}/sse-ticket → 拿 ticket60s有效一次性
2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?ticket=<ticket>
3. BE 验 ticket原子 getdel用后即失效换不了其他接口
"""
import logging
from typing import Annotated
import redis as sync_redis
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import get_db
from app.core.response import ok, raise_not_found, raise_unauthorized
from app.core.sse_ticket import consume_ticket, issue_ticket
from app.middleware.workspace_guard import CurrentUser, get_current_user
from app.models.task import GenerationTask
from app.utils.sse_utils import stream_events
logger = logging.getLogger(__name__)
router = APIRouter()
settings = get_settings()
# ── 签发接口(用 JWT 换 ticket────────────────────────────
@router.post("/tasks/{task_id}/sse-ticket")
def create_sse_ticket(
task_id: int,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""
签发一次性 SSE ticket60s 有效,仅对该 task 有效)。
FE 拿到 ticket 后立即建 EventSource不要存起来复用。
"""
task = (
db.query(GenerationTask)
.filter(
GenerationTask.id == task_id,
GenerationTask.workspace_id == current_user.workspace_id,
)
.first()
)
if not task:
raise_not_found("任务不存在")
r = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
ticket = issue_ticket(r, task_id, current_user.workspace_id)
return ok({"ticket": ticket, "expires_in": 60})
# ── SSE 流ticket 认证,绝不传 JWT─────────────────────
@router.get("/tasks/{task_id}/stream")
async def task_stream(
task_id: int,
ticket: str = Query(...),
last_seq: int = Query(default=0),
):
"""
SSE 实时进度推送。
ticket 验证:原子 getdel用后即失效无法重放、无法访问其他接口。
支持断线重连补发(?last_seq=<上次收到的seq>)。
断线重连时 FE 须重新调 sse-ticket 换新 ticket。
"""
r_sync = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
result = consume_ticket(r_sync, ticket)
if not result:
raise_unauthorized("ticket 无效或已过期")
verified_task_id, workspace_id = result
if verified_task_id != task_id:
raise_unauthorized("ticket 与任务不匹配")
redis_async = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
return StreamingResponse(
stream_events(
redis_client=redis_async,
task_id=task_id,
workspace_id=workspace_id,
last_seq=last_seq,
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

View File

@@ -0,0 +1,146 @@
"""
app/api/v1/task_actions.py — 任务操作端点
选文案 / 选图 / 导入文案 / 重新生成 / 提交审核 / 偏好上下文
DTOs 和辅助函数从 tasks.py 导入。
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, raise_not_found, raise_state_invalid
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
from app.api.v1.tasks import (
SelectCandidateRequest,
ImportTextRequest,
_fmt_text,
_fmt_image,
_check_task_ownership,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/{task_id}/text-candidates/select")
def select_text(
task_id: int, body: SelectCandidateRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""选文案(飞轮信号 text_select +3"""
from app.services.flywheel_service import record_signal
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
tc = db.query(TextCandidate).filter(
TextCandidate.id == body.candidate_id, TextCandidate.task_id == task_id
).first()
if not tc:
raise_not_found("文案候选不存在")
tc.is_selected = True
db.commit()
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
return ok({"selected": body.candidate_id})
@router.post("/{task_id}/image-candidates/select")
def select_image(
task_id: int, body: SelectCandidateRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""选图(飞轮信号 image_select +3"""
from app.services.flywheel_service import record_signal
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
ic = db.query(ImageCandidate).filter(
ImageCandidate.id == body.candidate_id, ImageCandidate.task_id == task_id
).first()
if not ic:
raise_not_found("图片候选不存在")
ic.is_selected = True
db.commit()
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
return ok({"selected": body.candidate_id})
@router.post("/{task_id}/import-text")
def import_text(
task_id: int, body: ImportTextRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""轨B导入外部文案直接进候选池跳过 AI 生成洞2"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
tc = TextCandidate(
workspace_id=current_user.workspace_id, task_id=task_id,
source="import", content=body.content, angle_label=body.angle_label,
)
db.add(tc)
db.commit()
db.refresh(tc)
return ok(_fmt_text(tc))
@router.post("/{task_id}/regenerate")
def regenerate(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""重新生成(飞轮信号 regenerate -1"""
from app.services.flywheel_service import record_signal
from app.services.task_service import enqueue_generation
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
record_signal(db, current_user, task, "regenerate")
enqueue_generation(task.id)
return ok({"task_id": task_id, "status": "regenerating"})
@router.post("/{task_id}/submit-review")
def submit_review(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""提交审核(状态 pending_selection → pending_review"""
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
if task.status != "pending_selection":
raise_state_invalid("只有 pending_selection 状态可提审")
task.status = "pending_review"
db.commit()
return ok({"task_id": task_id, "status": "pending_review"})
@router.get("/{task_id}/preference/context")
def get_preference_context(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""取本任务飞轮偏好上下文(最近选中样本+打回原因)。"""
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
from app.services.flywheel_service import get_preference_context
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
return ok(ctx)

191
backend/app/api/v1/tasks.py Normal file
View File

@@ -0,0 +1,191 @@
"""
app/api/v1/tasks.py — 任务路由(查询层)
发起任务 / 任务列表 / 任务详情
操作类端点(选文案/选图/导入/重生成/提审/偏好上下文)在 task_actions.py。
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
# ── DTO ────────────────────────────────────────────────────
class CreateTaskRequest(BaseModel):
product_id: int
benchmark_ids: list[int] = []
theme: str | None = None
text_count: int = 5 # 不写死基石A用户设定
image_count: int = 3 # 不写死基石A用户设定
track: str = "ai" # ai | import
need_product_image: bool = True # 本次产品是否入镜True=无图禁生成不降级
@field_validator("text_count", "image_count")
@classmethod
def positive(cls, v: int) -> int:
if v < 1 or v > 20:
raise ValueError("数量范围 1-20")
return v
@field_validator("track")
@classmethod
def valid_track(cls, v: str) -> str:
if v not in ("ai", "import"):
raise ValueError("track 必须是 ai 或 import")
return v
class SelectCandidateRequest(BaseModel):
candidate_id: int
class ImportTextRequest(BaseModel):
content: str
angle_label: str | None = None
# ── 格式化辅助 ──────────────────────────────────────────────
def _fmt_task(t: GenerationTask) -> dict:
return {
"id": t.id, "product_id": t.product_id, "theme": t.theme,
"status": t.status, "text_count": t.text_count, "image_count": t.image_count,
"track": t.track, "need_product_image": t.need_product_image,
"created_at": t.created_at.isoformat(),
}
def _score_array_to_obj(arr) -> dict:
"""score_json 是评分明细数组 [{item,score,max,note}]。
AI评委7维(2026-06-15新版痛点18/情绪18/买点18/钩子15/标题13/真实感13+合规5)
或旧机械5维均可处理。
total 直接对明细求和(不依赖固定key),透传 dims 给前端 ScoreDimBars 数据驱动渲染。
同时填旧5维key保持兼容(能映射的填映射不上的为0)。"""
# 旧5维 + 新7维维度名 → 旧key 的映射表(兼容新旧两套维度名)
legacy = {
# 旧机械维度
"标题吸引力": "title", "标题点击力": "title",
"情绪共鸣": "emotion", "情绪张力": "emotion",
"买点表达": "selling", "买点转化": "selling",
"关键词覆盖": "keyword", "合规性": "compliance",
# 新AI评委维度2026-06-15→ 最近似旧key
"痛点人群精准": "keyword", # 痛点人群精准语义最接近旧关键词(均为"内容精准度")
"开头钩子": "emotion", # 钩子即情绪抓点,归入 emotion
"真实感": "selling", # 真实感/买点转化同属"用户感知"维度
# "产品聚焦一件事" 已被"真实感"替换,保留映射避免旧存量数据报 KeyError
"产品聚焦一件事": "selling",
}
obj = {"title": 0, "emotion": 0, "selling": 0, "keyword": 0, "compliance": 0,
"total": 0, "dims": []}
if isinstance(arr, list):
total = 0
for d in arr:
if not isinstance(d, dict):
continue
item = d.get("item", "")
sc = d.get("score", 0) or 0
total += sc
obj["dims"].append({"item": item, "score": sc,
"max": d.get("max", 0), "note": d.get("note", "")})
key = legacy.get(item)
if key:
obj[key] = sc
obj["total"] = max(0, min(100, total))
return obj
def _fmt_text(tc: TextCandidate) -> dict:
import json
# content 列存整条 JSON dict含 title/content/tags/angle 等),解包后分字段返回
parsed: dict = {}
if tc.content:
try:
parsed = json.loads(tc.content)
except (json.JSONDecodeError, ValueError):
# 兼容旧数据:如果 content 已经是纯正文则原样返回
parsed = {"content": tc.content}
score_raw = json.loads(tc.score_json) if tc.score_json else None
return {
"candidate_id": tc.id,
"angle_label": tc.angle_label,
"title": parsed.get("title", ""),
"content": parsed.get("content", ""), # 纯正文,前端直接展示
"tags": parsed.get("tags", []),
"cover_title": parsed.get("coverTitle", ""),
"image_brief": parsed.get("imageBrief", ""),
"source": tc.source,
"score": _score_array_to_obj(score_raw), # 转成 {title,emotion,...,total,dims} 对象
"banned_word_status": tc.banned_word_status,
"is_selected": tc.is_selected,
# AI 评委总评verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
"summary": parsed.get("summary", ""), # 一句话总评含改进点
}
def _fmt_image(ic: ImageCandidate) -> dict:
return {
"candidate_id": ic.id, "role": ic.role, "url": ic.url,
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected,
}
def _check_task_ownership(task: GenerationTask | None, workspace_id: int) -> GenerationTask:
if not task or task.workspace_id != workspace_id:
raise_not_found("任务不存在")
return task
# ── 路由 ───────────────────────────────────────────────────
@router.post("")
def create_task(
body: CreateTaskRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""发起任务:校验有无 key → 只推 task_id 入队,绝不传 key。"""
from app.services.task_service import create_generation_task
task = create_generation_task(db, current_user, body)
return ok(_fmt_task(task))
@router.get("")
def list_tasks(
page: int = 1, page_size: int = 20,
status: list[str] | None = Query(default=None),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if status:
# 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(status))
total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
@router.get("/{task_id}")
def get_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
task = _check_task_ownership(task, current_user.workspace_id)
texts = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).all()
images = db.query(ImageCandidate).filter(ImageCandidate.task_id == task_id).all()
return ok({
**_fmt_task(task),
"text_candidates": [_fmt_text(tc) for tc in texts],
"image_candidates": [_fmt_image(ic) for ic in images],
})

View File

@@ -0,0 +1,43 @@
"""
app/api/v1/workspaces.py — workspace 切换路由
POST /workspaces/switch → /api/v1/workspaces/switch契约路径
从 auth.py 独立出来,避免 /auth 前缀错误。
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.security import create_access_token
from app.core.database import get_db
from app.core.response import ok, raise_forbidden
from app.middleware.workspace_guard import CurrentUser, get_current_user
from app.models.workspace import WorkspaceMember
router = APIRouter(tags=["workspaces"])
class SwitchWorkspaceRequest(BaseModel):
workspace_id: int
@router.post("/workspaces/switch")
def switch_workspace(
body: SwitchWorkspaceRequest,
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Session = Depends(get_db),
):
"""切换当前 workspace必须查 membership 校验)。"""
member = db.query(WorkspaceMember).filter(
WorkspaceMember.user_id == current_user.user_id,
WorkspaceMember.workspace_id == body.workspace_id,
).first()
if not member:
raise_forbidden("无权访问目标 workspace")
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
return ok({
"current_workspace_id": body.workspace_id,
"token": token,
})

View File

@@ -0,0 +1,8 @@
# app/constants/
中心常量文件,消除三套命名打架(架构方案§二决策):
- strategies.py # 图片策略命名对外API用A/B/C内部method用minimal_edit系
- signal_weights.py # 飞轮信号默认权重(可调,不写死)
- status.py # 任务状态机枚举
- roles.py # 角色枚举admin/supervisor/operator
- providers.py # AI提供商枚举开放接口不硬编码

View File

View File

@@ -0,0 +1,132 @@
"""
constants/enums.py — Clover 命名中心
DB枚举约束在此定义消除三套命名打架。
业务参数(品类/数量/角度不在此定义基石A
"""
from enum import Enum
# ── 用户角色 ────────────────────────────────────────────
class UserRole(str, Enum):
ADMIN = "admin"
SUPERVISOR = "supervisor"
OPERATOR = "operator"
# ── 任务状态机 ──────────────────────────────────────────
class TaskStatus(str, Enum):
PENDING = "pending"
GENERATING = "generating"
PENDING_SELECTION = "pending_selection"
PENDING_REVIEW = "pending_review"
APPROVED = "approved"
REJECTED = "rejected"
ARCHIVED = "archived"
# ── 审核状态generation_tasks 平铺字段)──────────────────
class ReviewStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
# ── 文案候选来源 ───────────────────────────────────────
class CandidateSource(str, Enum):
AI = "ai"
IMPORT = "import"
# ── 违禁词等级 ────────────────────────────────────────
class BannedWordLevel(str, Enum):
AUTO_FIX = "auto_fix"
SOFT_WARN = "soft_warn"
HARD_BLOCK = "hard_block"
# ── 违禁词扫描结果 ───────────────────────────────────
class BannedWordStatus(str, Enum):
PASS = "pass"
AUTO_FIXED = "auto_fixed"
SOFT_WARN = "soft_warn"
HARD_BLOCK = "hard_block"
# ── 飞轮信号类型 ─────────────────────────────────────
class SignalType(str, Enum):
TEXT_SELECT = "text_select"
IMAGE_SELECT = "image_select"
APPROVE = "approve"
REJECT_WITH_REASON = "reject_with_reason"
REGENERATE = "regenerate"
# ── 飞轮信号权重默认值(北哥可校准)─────────────────────
SIGNAL_WEIGHTS: dict[str, int] = {
SignalType.TEXT_SELECT: 3,
SignalType.IMAGE_SELECT: 3,
SignalType.APPROVE: 5,
SignalType.REJECT_WITH_REASON: -3,
SignalType.REGENERATE: -1,
}
# ── 数据归属 ──────────────────────────────────────────
class DataOwnership(str, Enum):
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
PLATFORM_ASSET = "platform_asset" # 飞轮蒸馏成果,归平台
# ── 交付包状态 ────────────────────────────────────────
class PackageStatus(str, Enum):
PENDING = "pending"
READY = "ready"
DOWNLOADED = "downloaded"
# ── 图片分镜角色G5坑修复对齐 storyboard.py 实际角色名)────
class ImageRole(str, Enum):
# storyboard 实际输出角色名
HOOK = "hook"
PAIN_SCENE = "pain_scene"
PRODUCT_CLOSEUP = "product_closeup"
INGREDIENT = "ingredient"
APPLIED_PROOF = "applied_proof"
TEXTURE = "texture"
SOCIAL_PROOF = "social_proof"
CLOSER = "closer"
SCENARIO = "scenario"
TUTORIAL = "tutorial"
# 旧值保留向后兼容
PAIN = "pain"
PROOF = "proof"
QUALITY = "quality"
CREDIT = "credit"
CONVERT = "convert"
MAIN = "main"
# ── AI 图片提供商 ─────────────────────────────────────
class ImageProvider(str, Enum):
GPT = "gpt"
GEMINI = "gemini"
# ── 产品来源 ──────────────────────────────────────────
class ProductSource(str, Enum):
PRESET = "preset"
CUSTOM = "custom"
# ── 错误码契约§0七类─────────────────────────────────
class ErrorCode:
SUCCESS = 0
PARAM_INVALID = 40001
UNAUTHORIZED = 40101
FORBIDDEN = 40301
NOT_FOUND = 40401
STATE_INVALID = 40901
BUSINESS_ERROR = 42201
SERVER_ERROR = 50001
AI_CALL_FAILED = 50002

View File

@@ -0,0 +1,3 @@
"""
app/core/__init__.py
"""

View File

@@ -0,0 +1,80 @@
"""
app/core/config.py — 环境变量加载 + 启动校验
必填项缺失则启动失败,不静默。
"""
import os
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# ── 必填(缺失启动保护)───────────────────────────
FERNET_KEY: str
JWT_SECRET: str
DATABASE_URL: str
MONGO_URI: str
REDIS_URL: str
# ── AI 提供商(不写死默认,可配置)──────────────────
IMAGE_PROVIDER_PRIMARY: str = "gpt"
IMAGE_PROVIDER_FALLBACK: str = "gemini"
IMAGE_API_BASE: str = ""
IMAGE_MODEL: str = "gpt-image-2"
MODEL_IMAGE: str = "gpt-image-2" # 别名,与 IMAGE_MODEL 同义
MODEL_TEXT: str = "claude-opus-4-8"
MODEL_VISION: str = "" # 视觉模型verify_real_image 脚本用)
CLAUDE_API_URL: str = ""
GEMINI_API_URL: str = ""
# ── 多 Provider Key各人自录测试/真实出图脚本用)──
APIPORTS_BASE_URL: str = ""
APIPORTS_KEY: str = ""
CODEPROXY_BASE_URL: str = ""
CODEPROXY_KEY: str = ""
# ── 应用配置 ──────────────────────────────────────
APP_ENV: str = "development"
JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = ""
# ── 并发限制(可配置,不写死)─────────────────────
MAX_CONCURRENT_TASKS_PER_USER: int = 2
# ── 文件存储路径 ──────────────────────────────────
UPLOAD_BASE_PATH: str = "uploads/packages"
# StaticFiles 实际服务的绝对目录(与 main.py app.mount("/uploads", .../uploads) 一致)。
# 容器内 main.py 在 /app/main.py故 uploads 绝对根 = /app/uploads。
# 所有写盘/读盘统一锚定此根,避免 api(cwd=/app) 与 worker(cwd=/) 相对路径解析不一致。
UPLOAD_ABS_ROOT: str = "/app/uploads"
model_config = {"env_file": ".env", "case_sensitive": True, "extra": "ignore"}
@field_validator("FERNET_KEY")
@classmethod
def fernet_key_not_empty(cls, v: str) -> str:
if not v or len(v) < 32:
raise ValueError("FERNET_KEY must be set and at least 32 chars")
return v
@field_validator("JWT_SECRET")
@classmethod
def jwt_secret_not_empty(cls, v: str) -> str:
if not v or len(v) < 32:
raise ValueError("JWT_SECRET must be at least 32 characters")
return v
def celery_broker(self) -> str:
return self.CELERY_BROKER_URL or self.REDIS_URL
def celery_backend(self) -> str:
return self.CELERY_RESULT_BACKEND or self.REDIS_URL
@lru_cache()
def get_settings() -> Settings:
"""单例配置,启动时校验一次。"""
return Settings()

View File

@@ -0,0 +1,53 @@
"""
app/core/database.py — SQLAlchemy 双库连接MySQL + MongoDB
"""
from motor.motor_asyncio import AsyncIOMotorClient
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import get_settings
settings = get_settings()
# ── MySQL主业务库──────────────────────────────────
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=(settings.APP_ENV == "development"),
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""所有 ORM 模型的基类。"""
pass
def get_db():
"""FastAPI 依赖注入:获取 DB Session用完自动关闭。"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# ── MongoDB只存 AI debug trace业务不依赖──────────
_mongo_client: AsyncIOMotorClient | None = None
def get_mongo_client() -> AsyncIOMotorClient:
global _mongo_client
if _mongo_client is None:
_mongo_client = AsyncIOMotorClient(settings.MONGO_URI)
return _mongo_client
def get_mongo_db():
"""获取 MongoDB 数据库实例clover_trace"""
client = get_mongo_client()
return client["clover_trace"]

View File

@@ -0,0 +1,63 @@
"""
app/core/response.py — 统一响应包络
成功:{ "code": 0, "data": {...} }
失败:{ "code": <错误码>, "message": "用户可读信息" }
HTTP 状态码与业务 code 分离契约§0
"""
from typing import Any
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from app.constants.enums import ErrorCode
def ok(data: Any = None) -> dict:
"""标准成功响应。"""
return {"code": ErrorCode.SUCCESS, "data": data}
def err(code: int, message: str) -> dict:
"""标准失败响应体(不含 HTTP 状态,由调用方决定)。"""
return {"code": code, "message": message}
def paginate(items: list, total: int, page: int, page_size: int) -> dict:
"""分页数据包装。"""
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
# ── 常用异常 ──────────────────────────────────────────────
class CloverHTTPException(HTTPException):
"""携带业务 code 的 HTTP 异常,供全局 handler 格式化。"""
def __init__(self, http_status: int, code: int, message: str):
super().__init__(status_code=http_status, detail=message)
self.biz_code = code
self.biz_message = message
def raise_not_found(message: str = "资源不存在") -> None:
raise CloverHTTPException(404, ErrorCode.NOT_FOUND, message)
def raise_forbidden(message: str = "无权限访问") -> None:
raise CloverHTTPException(403, ErrorCode.FORBIDDEN, message)
def raise_unauthorized(message: str = "未认证或 Token 失效") -> None:
raise CloverHTTPException(401, ErrorCode.UNAUTHORIZED, message)
def raise_business(message: str) -> None:
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
def raise_state_invalid(message: str = "状态机非法流转") -> None:
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)

View File

@@ -0,0 +1,73 @@
"""
app/core/security.py — JWT + Fernet 工具函数
FERNET_KEY 走环境变量绝不进代码库基石B
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
# ── Fernet 加密API Key 存取)──────────────────────────
_fernet: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = Fernet(settings.FERNET_KEY.encode())
return _fernet
def encrypt_api_key(plain_key: str) -> str:
"""加密 API Key返回密文字符串。绝不打印 plain_key。"""
return _get_fernet().encrypt(plain_key.encode()).decode()
def decrypt_api_key(encrypted_key: str) -> str:
"""
解密 API Key。只在 Celery worker 内部调用。
解密结果只活在调用函数的局部变量,不落盘、不打日志。
"""
try:
return _get_fernet().decrypt(encrypted_key.encode()).decode()
except InvalidToken:
logger.error("Fernet decrypt failed: invalid token (key may be rotated)")
raise ValueError("API key decryption failed")
# ── JWT ──────────────────────────────────────────────────
def create_access_token(
user_id: int,
workspace_id: int,
role: str,
expires_delta: timedelta | None = None,
) -> str:
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
)
payload: dict[str, Any] = {
"sub": str(user_id),
"user_id": user_id,
"current_workspace_id": workspace_id,
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def decode_access_token(token: str) -> dict[str, Any]:
"""验签并返回 payload。无效则抛 jwt.PyJWTError。"""
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
def mask_api_key(plain_key: str) -> str:
"""只返回后4位展示用不暴露完整 key。"""
return plain_key[-4:] if len(plain_key) >= 4 else "****"

View File

@@ -0,0 +1,43 @@
"""
app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证
倩倩姐2026-06-08拍板SSE 认证不传 JWT改传 ticket。
ticket 存 RedisTTL 60s验一次即删换不了其他接口。
"""
import secrets
from typing import Optional
TICKET_TTL_SECONDS = 60
TICKET_KEY_PREFIX = "sse_ticket:"
def _redis_key(ticket: str) -> str:
return f"{TICKET_KEY_PREFIX}{ticket}"
def issue_ticket(redis_client, task_id: int, workspace_id: int) -> str:
"""
签发一次性 SSE ticket写入 RedisTTL 60s。
返回 ticket 字符串32字节 hex共64字符
"""
ticket = secrets.token_hex(32)
value = f"{task_id}:{workspace_id}"
redis_client.setex(_redis_key(ticket), TICKET_TTL_SECONDS, value)
return ticket
def consume_ticket(redis_client, ticket: str) -> Optional[tuple[int, int]]:
"""
验证并消费 ticket用后即删一次性
成功返回 (task_id, workspace_id),失败返回 None。
"""
if not ticket:
return None
key = _redis_key(ticket)
value = redis_client.getdel(key) # 原子取出并删除
if not value:
return None
try:
task_id_str, ws_str = value.split(":", 1)
return int(task_id_str), int(ws_str)
except (ValueError, AttributeError):
return None

View File

@@ -0,0 +1,5 @@
# app/middleware/
中间件占位:
- workspace_guard.py # 所有接口强制注入workspace_id防串数据所有查询强制带此条件
- auth_middleware.py # JWT验签HS256每请求验签

View File

View File

@@ -0,0 +1,106 @@
"""
app/middleware/workspace_guard.py — 多租户隔离中间件
所有业务接口强制注入 workspace_id基石C
读操作信任 JWT写操作+切换 workspace 查 workspace_members 校验。
"""
import logging
from typing import Annotated
import jwt
from fastapi import Depends, Header
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import raise_forbidden, raise_unauthorized
from app.core.security import decode_access_token
from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__)
class CurrentUser:
"""JWT 解码后的请求上下文,注入到每个路由函数。"""
def __init__(
self,
user_id: int,
workspace_id: int,
role: str,
):
self.user_id = user_id
self.workspace_id = workspace_id
self.role = role
def _extract_token(authorization: str | None) -> str:
"""从 Authorization: Bearer <token> 中提取 token。"""
if not authorization or not authorization.startswith("Bearer "):
raise_unauthorized("缺少 Authorization header")
return authorization.split(" ", 1)[1]
async def get_current_user(
authorization: Annotated[str | None, Header()] = None,
) -> CurrentUser:
"""
FastAPI 依赖:解码 JWT返回 CurrentUser。
所有业务路由 Depends(get_current_user)。
"""
token = _extract_token(authorization)
try:
payload = decode_access_token(token)
except jwt.ExpiredSignatureError:
raise_unauthorized("Token 已过期")
except jwt.PyJWTError:
raise_unauthorized("Token 无效")
return CurrentUser(
user_id=int(payload["user_id"]),
workspace_id=int(payload["current_workspace_id"]),
role=payload["role"],
)
def require_write_permission(
current_user: Annotated[CurrentUser, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
) -> CurrentUser:
"""
写操作依赖:查 workspace_members 校验当前用户确实属于此 workspace。
读操作用 get_current_user 即可JWT 加速)。
"""
member = (
db.query(WorkspaceMember)
.filter(
WorkspaceMember.workspace_id == current_user.workspace_id,
WorkspaceMember.user_id == current_user.user_id,
)
.first()
)
if not member:
logger.warning(
"workspace permission denied: user=%s workspace=%s",
current_user.user_id,
current_user.workspace_id,
)
raise_forbidden("无权限访问此 workspace")
return current_user
def require_admin(
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
) -> CurrentUser:
"""仅管理员可访问的路由依赖。"""
if current_user.role != "admin":
raise_forbidden("需要管理员权限")
return current_user
def require_supervisor_or_above(
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
) -> CurrentUser:
"""组长及以上supervisor/admin"""
if current_user.role not in ("supervisor", "admin"):
raise_forbidden("需要组长或管理员权限")
return current_user

View File

@@ -0,0 +1,30 @@
# app/models/
SQLAlchemy ORM 模型占位Alembic 001-004 按顺序建:
## Alembic 001 — 从banana搬3张改造
- user.py # users删credits字段
- login_record.py # login_records
- user_preference.py # user_preferencesUI设置偏好
## Alembic 002 — 多租户基础(全新)
- workspace.py # workspaces
- workspace_member.py # workspace_membersuser+workspace+角色)
- user_api_key.py # user_api_keysFernet加密UNIQUE user_id+workspace_id+provider
## Alembic 003 — 业务主体7张全新
- product.py # products含text_angles/custom_prompt/source/workspace_id
- benchmark_note.py # benchmark_notes
- generation_task.py # generation_tasks状态机+审核字段平铺)
- text_candidate.py # text_candidatessource=ai/import双轨
- image_candidate.py # image_candidates
- delivery_package.py # delivery_packages
- banned_word.py # banned_words三级level
## Alembic 004 — 飞轮(全新)
- preference_event.py # preference_events含data_ownership字段
## 铁律
- 所有业务表有 workspace_id 字段
- generation_tasks 审核字段平铺review_status/reviewer_id/reviewed_at/reject_reason/approved_at/archived_at
- preference_events.signal_weight 初始默认值:选文案+3/选图+3/通过+5/打回-3/重生成-1

View File

@@ -0,0 +1,24 @@
"""
app/models/__init__.py — 统一导出所有模型Alembic autogenerate 能扫到
"""
from app.models.user import LoginRecord, User, UserPreference
from app.models.workspace import UserApiKey, Workspace, WorkspaceMember
from app.models.product import BannedWord, BenchmarkNote, Product
from app.models.task import (
DeliveryPackage,
GenerationTask,
ImageCandidate,
TextCandidate,
)
from app.models.flywheel import AiCallLog, PreferenceEvent
from app.models.fission import FissionTask
__all__ = [
"User", "LoginRecord", "UserPreference",
"Workspace", "WorkspaceMember", "UserApiKey",
"Product", "BenchmarkNote", "BannedWord",
"GenerationTask", "TextCandidate", "ImageCandidate",
"DeliveryPackage", "PreferenceEvent", "AiCallLog",
"FissionTask",
]

View File

@@ -0,0 +1,53 @@
"""
app/models/fission.py — fission_tasks
Alembic 010 第11环裂变引擎1爆款→N套完整笔记包
reference_level: low/mid/high参考程度
status: pending/generating/done/failed
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, DateTime, ForeignKey,
Index, Integer, String, Text, func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class FissionTask(Base):
"""裂变任务1爆款→N套完整笔记包第11环在工作台内呈现。"""
__tablename__ = "fission_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
# 爆款源笔记内容(文案+图描述JSON存储
source_note: Mapped[str | None] = mapped_column(
Text, comment="爆款源笔记内容(文案+图描述JSON存储"
)
# 参考程度low/mid/high
reference_level: Mapped[str] = mapped_column(
String(10), default="mid", nullable=False,
comment="参考程度: low/mid/high"
)
# 裂变套数默认3套
fanout_count: Mapped[int] = mapped_column(
Integer, default=3, nullable=False,
comment="裂变套数默认3套"
)
# 状态机
status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False,
comment="状态: pending/generating/done/failed"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_fission_tasks_workspace_id", "workspace_id"),
)

View File

@@ -0,0 +1,97 @@
"""
app/models/flywheel.py — preference_events / ai_call_logs
Alembic 003ai_call_logs+ Alembic 004preference_events
preference_profile 二期预留,一期不建。
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, DateTime, Enum, ForeignKey,
Index, Integer, String, Text, func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.constants.enums import DataOwnership, SignalType
from app.core.database import Base
class PreferenceEvent(Base):
"""
飞轮信号日志。
workspace_id + product_id 都必须有(跨公司隔离 + 按产品分开学)。
angle_label 跟着产品的文案角度走不写死基石A
"""
__tablename__ = "preference_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
product_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("products.id"), nullable=False
)
task_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
signal_type: Mapped[str] = mapped_column(
Enum(SignalType, values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
signal_weight: Mapped[int] = mapped_column(Integer, nullable=False)
candidate_id: Mapped[int | None] = mapped_column(BigInteger)
angle_label: Mapped[str | None] = mapped_column(String(64))
reason: Mapped[str | None] = mapped_column(Text) # 打回原因原文
signal_meta: Mapped[str | None] = mapped_column(Text) # JSON扩展用
data_ownership: Mapped[str] = mapped_column(
Enum(DataOwnership, values_callable=lambda x: [e.value for e in x]),
default=DataOwnership.CLIENT_DATA, nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index(
"idx_preference_events_ws_product_created",
"workspace_id", "product_id", "created_at",
),
)
class AiCallLog(Base):
"""
AI 调用记录usage + 排障基础)。
调用失败归因到个人 key错误码50002
绝不记录明文 key只记录 key_id。
"""
__tablename__ = "ai_call_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
key_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("user_api_keys.id")
)
task_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("generation_tasks.id")
)
provider: Mapped[str | None] = mapped_column(String(32))
model: Mapped[str | None] = mapped_column(String(64))
call_type: Mapped[str | None] = mapped_column(String(32)) # text/image/analyze
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
success: Mapped[bool] = mapped_column(default=True, nullable=False)
error_code: Mapped[str | None] = mapped_column(String(32))
latency_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_ai_call_logs_workspace_user", "workspace_id", "user_id"),
Index("idx_ai_call_logs_task_id", "task_id"),
)

View File

@@ -0,0 +1,110 @@
"""
app/models/product.py — products / benchmark_notes / banned_words
Alembic 003 业务主体1/3
products.category 是纯数据字段禁止任何品类枚举基石A
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger, DateTime, Enum, ForeignKey,
Index, Integer, String, Text, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.constants.enums import BannedWordLevel, ProductSource
from app.core.database import Base
class Product(Base):
"""产品档案(卖点/违禁词/风格/调性/文案角度/可调prompt/source"""
__tablename__ = "products"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(128), nullable=False)
# category 是纯数据字段不在代码里做枚举基石A
category: Mapped[str | None] = mapped_column(String(64))
source: Mapped[str] = mapped_column(
Enum(ProductSource, values_callable=lambda x: [e.value for e in x]),
default=ProductSource.CUSTOM, nullable=False,
)
selling_points: Mapped[str | None] = mapped_column(Text) # JSON数组
style_tone: Mapped[str | None] = mapped_column(String(128))
text_angles: Mapped[str | None] = mapped_column(Text) # JSON数组用户设定
custom_prompt: Mapped[str | None] = mapped_column(Text) # 等北哥方案注入
image_path: Mapped[str | None] = mapped_column(String(512)) # 产品参考图路径(前端上传后写入)
# 008: 品牌词(客户输入,植入文案每条+生图特写图第2/6张第5环
brand_keyword: Mapped[str | None] = mapped_column(String(64), comment="品牌词,客户录入,随产品固定")
# 012: 目标人群(客户输入,透传进文案/生图 promptstoryboard.py:52 原恒空现可填)
target_audience: Mapped[str | None] = mapped_column(String(128), comment="目标人群,客户输入,透传进文案/生图prompt")
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
back_populates="product", lazy="noload"
)
__table_args__ = (
Index("idx_products_workspace_id", "workspace_id"),
)
class BenchmarkNote(Base):
"""标杆笔记(截图+手填亮点为主通道)"""
__tablename__ = "benchmark_notes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
product_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
screenshot_url: Mapped[str | None] = mapped_column(String(512))
highlights: Mapped[str | None] = mapped_column(Text) # 手填亮点
link_url: Mapped[str | None] = mapped_column(String(512))
# 009: 第2环标杆分析字段
features_json: Mapped[str | None] = mapped_column(Text, comment="爆款8特征分析结果JSONAI解析后写入")
analyze_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False, comment="AI分析状态: pending/analyzing/done/failed")
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
product: Mapped["Product"] = relationship(back_populates="benchmark_notes")
__table_args__ = (
Index("idx_benchmark_notes_product_id", "product_id"),
)
class BannedWord(Base):
"""违禁词库三级auto_fix/soft_warn/hard_block"""
__tablename__ = "banned_words"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
word: Mapped[str] = mapped_column(String(64), nullable=False)
level: Mapped[str] = mapped_column(
Enum(BannedWordLevel, values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
replacement: Mapped[str | None] = mapped_column(String(128))
updatable: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_banned_words_workspace_id", "workspace_id"),
)

151
backend/app/models/task.py Normal file
View File

@@ -0,0 +1,151 @@
"""
app/models/task.py — generation_tasks / text_candidates / image_candidates / delivery_packages
Alembic 003 业务主体2/3
任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24)
eval_score 留 NULL不接 banana 假评分(基石)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger, Boolean, DateTime, Enum, Float, ForeignKey,
Index, Integer, String, Text, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.constants.enums import (
BannedWordStatus, CandidateSource, ImageRole,
PackageStatus, ReviewStatus, TaskStatus,
)
from app.core.database import Base
class GenerationTask(Base):
"""生产任务,审核字段平铺(不建独立审核表)。"""
__tablename__ = "generation_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
product_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("products.id"), nullable=False
)
operator_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id"), nullable=False
)
theme: Mapped[str | None] = mapped_column(String(256))
text_count: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
image_count: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
track: Mapped[str] = mapped_column(String(16), default="ai", nullable=False)
# 本次产品是否入镜True=必须用产品参考图(无图禁生成,不降级纯文生图)False=允许纯文生图
need_product_image: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
status: Mapped[str] = mapped_column(
Enum(TaskStatus, values_callable=lambda x: [e.value for e in x]),
default=TaskStatus.PENDING, nullable=False,
)
mongo_trace_id: Mapped[str | None] = mapped_column(String(24)) # MongoDB trace
# ── 审核字段(平铺)──────────────────────────────
review_status: Mapped[str | None] = mapped_column(
Enum(ReviewStatus, values_callable=lambda x: [e.value for e in x])
)
reviewer_id: Mapped[int | None] = mapped_column(BigInteger, ForeignKey("users.id"))
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
reject_reason: Mapped[str | None] = mapped_column(Text)
approved_at: Mapped[datetime | None] = mapped_column(DateTime)
archived_at: Mapped[datetime | None] = mapped_column(DateTime)
# 011: 第2环S12+第11环裂变
benchmark_ids: Mapped[str | None] = mapped_column(Text, comment="关联标杆笔记ID列表JSON list第2环引用")
source_fission_id: Mapped[int | None] = mapped_column(Integer, comment="裂变来源fission_task IDNULL=普通任务")
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
__table_args__ = (
Index("idx_generation_tasks_workspace_status", "workspace_id", "status"),
)
class TextCandidate(Base):
"""文案候选source=ai/import 区分双轨。eval_score 留 NULL。"""
__tablename__ = "text_candidates"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
task_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
)
source: Mapped[str] = mapped_column(
Enum(CandidateSource, values_callable=lambda x: [e.value for e in x]),
default=CandidateSource.AI, nullable=False,
)
angle_label: Mapped[str | None] = mapped_column(String(64))
content: Mapped[str | None] = mapped_column(Text)
score_json: Mapped[str | None] = mapped_column(Text) # 五维分 JSON
banned_word_status: Mapped[str] = mapped_column(
Enum(BannedWordStatus, values_callable=lambda x: [e.value for e in x]),
default=BannedWordStatus.PASS, nullable=False,
)
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_text_candidates_task_id", "task_id"),
)
class ImageCandidate(Base):
"""图片候选。eval_score 留 NULL。"""
__tablename__ = "image_candidates"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
task_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False
)
# role 用 varchar 不用 enumstoryboard 角色名仍在演进,约束放应用层 ImageRole
role: Mapped[str] = mapped_column(
String(32), default=ImageRole.HOOK.value, nullable=False,
)
url: Mapped[str | None] = mapped_column(String(512))
strategy: Mapped[str | None] = mapped_column(String(4)) # A/B/C二期
seq: Mapped[int] = mapped_column(Integer, default=1) # 分镜序号
is_selected: Mapped[bool] = mapped_column(default=False, nullable=False)
eval_score: Mapped[float | None] = mapped_column(Float) # 一期留 NULL
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_image_candidates_task_id", "task_id"),
)
class DeliveryPackage(Base):
"""达人素材交付包。"""
__tablename__ = "delivery_packages"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
task_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("generation_tasks.id"), nullable=False
)
status: Mapped[str] = mapped_column(
Enum(PackageStatus, values_callable=lambda x: [e.value for e in x]),
default=PackageStatus.PENDING, nullable=False,
)
package_path: Mapped[str | None] = mapped_column(String(512))
download_url: Mapped[str | None] = mapped_column(String(512))
expires_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)

View File

@@ -0,0 +1,68 @@
"""
app/models/user.py — users / login_records / user_preferences
Alembic 001从 banana 搬,删除 credits 字段(架构方案规定)。
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, Boolean, DateTime, ForeignKey,
Integer, String, Text, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
# 注:删除 banana 的 credits 字段(架构方案规定)
login_records: Mapped[list["LoginRecord"]] = relationship(
back_populates="user", lazy="noload"
)
class LoginRecord(Base):
__tablename__ = "login_records"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
ip_address: Mapped[str | None] = mapped_column(String(64))
user_agent: Mapped[str | None] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
user: Mapped["User"] = relationship(back_populates="login_records")
class UserPreference(Base):
"""UI 设置偏好(主题/语言等),不含 API Key。"""
__tablename__ = "user_preferences"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"),
unique=True, nullable=False,
)
preferences_json: Mapped[str | None] = mapped_column(Text) # JSON字符串
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
user: Mapped["User"] = relationship()

View File

@@ -0,0 +1,88 @@
"""
app/models/workspace.py — workspaces / workspace_members / user_api_keys
Alembic 002多租户基础全新建。
matrix_accounts 二期预留,一期不建。
"""
from datetime import datetime
from sqlalchemy import (
BigInteger, DateTime, Enum, ForeignKey,
Index, String, UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.constants.enums import UserRole
from app.core.database import Base
class Workspace(Base):
__tablename__ = "workspaces"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
members: Mapped[list["WorkspaceMember"]] = relationship(
back_populates="workspace", lazy="noload"
)
class WorkspaceMember(Base):
__tablename__ = "workspace_members"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(
Enum(UserRole, values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
workspace: Mapped["Workspace"] = relationship(back_populates="members")
__table_args__ = (
UniqueConstraint("workspace_id", "user_id", name="uq_workspace_member"),
)
class UserApiKey(Base):
"""
个人 API KeyFernet 加密)。
encrypted_key 只存密文,不存 urltoken站固定自家站
UNIQUE(user_id, workspace_id, provider)。
"""
__tablename__ = "user_api_keys"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
encrypted_key: Mapped[str] = mapped_column(String(512), nullable=False)
key_last4: Mapped[str] = mapped_column(String(4), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint(
"user_id", "workspace_id", "provider",
name="uq_user_workspace_provider",
),
Index("idx_user_api_keys_workspace_id", "workspace_id"),
)

View File

@@ -0,0 +1,3 @@
"""
app/repositories/__init__.py
"""

View File

@@ -0,0 +1,85 @@
"""
app/repositories/base_workspace_repo.py — 多租户基础 Repo
所有 workspace 相关 Repo 继承此类,强制过滤 workspace_id基石C
禁止 SELECT *明确字段db.md规范
"""
import logging
from typing import Any, Generic, TypeVar
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.response import raise_not_found
logger = logging.getLogger(__name__)
T = TypeVar("T")
class BaseWorkspaceRepo(Generic[T]):
"""
workspace 感知的基础 Repo。
子类设置 model_class所有查询自动注入 workspace_id 过滤。
"""
model_class: type[T]
def __init__(self, db: Session, workspace_id: int):
self.db = db
self.workspace_id = workspace_id
def _base_query(self):
"""所有查询的基础:强制带 workspace_id 过滤(多租户红线)。"""
return self.db.query(self.model_class).filter(
self.model_class.workspace_id == self.workspace_id # type: ignore[attr-defined]
)
def get_by_id(self, record_id: int) -> T | None:
"""按 ID 查单条,强制带 workspace_id 防越权读。"""
return self._base_query().filter(
self.model_class.id == record_id # type: ignore[attr-defined]
).first()
def get_by_id_or_404(self, record_id: int) -> T:
obj = self.get_by_id(record_id)
if not obj:
raise_not_found(f"{self.model_class.__name__} {record_id} 不存在")
return obj # type: ignore[return-value]
def list_all(
self,
offset: int = 0,
limit: int = 20,
filters: list[Any] | None = None,
) -> tuple[list[T], int]:
"""分页列表,返回 (items, total)。"""
q = self._base_query()
if filters:
q = q.filter(*filters)
total = q.count()
items = q.offset(offset).limit(limit).all()
return items, total
def create(self, obj: T) -> T:
"""插入一条记录,自动设置 workspace_id。"""
obj.workspace_id = self.workspace_id # type: ignore[attr-defined]
self.db.add(obj)
self.db.flush()
self.db.refresh(obj)
logger.debug("Created %s id=%s ws=%s", self.model_class.__name__, obj.id, self.workspace_id) # type: ignore[attr-defined]
return obj
def delete(self, obj: T) -> None:
"""物理删除(软删各子类自己处理 archived 态)。"""
self.db.delete(obj)
self.db.flush()
def save(self) -> None:
"""提交事务错误不静默官网V1坑3"""
try:
self.db.commit()
except Exception:
self.db.rollback()
logger.error("DB commit failed in %s ws=%s", self.model_class.__name__, self.workspace_id)
raise

View File

@@ -0,0 +1,13 @@
# app/services/
业务逻辑层占位,按模块:
- auth_service.py # JWT签发/验证
- workspace_service.py # workspace权限校验
- product_service.py # 产品档案业务逻辑
- task_service.py # 任务状态机流转
- review_service.py # 审核流转+飞轮信号写入
- preference_aggregator.py # 飞轮实时聚合最近50条→prompt片段
- preference_collector.py # 三信号入口写入 preference_events
- banned_word_checker.py # 违禁词三级扫描(🟢改写/🟡提示/🔴拦截)
- package_exporter.py # 生成达人素材交付包
- image_postprocessor.py # 去水印后处理(重编码+削像素水印)

View File

View File

@@ -0,0 +1,5 @@
"""
AI 引擎包
扒自:上线版 worker/src/copy.js + image.js2026-06-09
重写为 Python逻辑对照JS版防走样
"""

View File

@@ -0,0 +1,104 @@
"""
_score_prompt.py — AI 评委 prompt让模型真读文案不机械找词
评判标准忠于《富贵情绪营销理论》原文(口播一手来源),标实战补充出处。
6维满分分布倩倩姐2026-06-15拍板与 llm_scorer._DIM_MAX / constants.AI_DIM_WEIGHTS 三处同步):
痛点人群精准18 / 情绪张力18 / 买点转化18 / 开头钩子15 / 标题点击力13 / 真实感13 = 95
+ 合规5机械硬拦不进AI评委= 100
"真实感"替换旧"产品聚焦一件事(16)":富贵"很少提产品/前70%干货后30%植入"独立升维。
"""
from __future__ import annotations
# ── 评委人设 ──────────────────────────────────────────────
SCORER_PERSONA = """你是一位资深小红书内容操盘手,深谙富贵情绪营销理论。
你的本事是:扫一眼就知道一条笔记能不能打动目标用户、会不会被划走。
你不数关键词、不看有没有出现某个固定词——你读的是【这条文案对真实用户有没有杀伤力】。
你按下面6个维度给文案打分每一维都要给出【具体理由】指出好在哪/差在哪/怎么改,
理由必须针对这条文案的真实内容,不准说放之四海皆准的空话。"""
# ── 6 维评判标准按权重降序合规第7维由代码机械硬拦不在此────
# 标准依据:括号内标注[富贵原文]或[实战补充],前者来自口播一手来源,权威最高。
SCORING_DIMENSIONS = """
【维度1·痛点人群精准满分18】[富贵原文]
判断:"说的就是我"——文案描述的处境/困扰,目标用户读了会不会对号入座。
好:具体到某类人的某个真实生活瞬间,让人觉得被看穿,落在"我的大问题/我的处境/我的渴望"上。
差:泛泛而谈谁都能套(如"适合所有想变美的女生"或PUA用户、拿别人的惨状吓唬人。
依据:富贵"我的大问题→处境→渴望"内容骨架;人群越具体穿透力越强;"用户被宠成爹你PUA他不好使"
【维度2·情绪张力满分18】[富贵原文·第一性原理]
判断:有没有"成果/后果"双向情绪,而不是平铺直叙报卖点。
· 后果=过去没用它,产生了什么糟糕处境(勾起恐惧/懊悔)
· 成果=用了它之后,会变成什么样(给出期待/乐观)
好:同时有"后果路径(过去的痛)+成果路径(未来的好)",有一句能戳中人、读完有情绪余温。
差:全程客观介绍产品、无情绪、像说明书;或只单向吓唬、或只空喊美好。
依据:富贵"营销第一性原理就是情绪,没有情绪什么内容都不转化""成果是未来、后果是过去,要做这两种情绪"
【维度3·买点转化满分18】[富贵原文]
判断:产品卖点有没有翻译成用户能感知的场景化利益(人话),而不是品牌视角的功效/参数。
好:用户能想象到的使用场景和结果(如"出门前最后一步、同事问我今天气色真好")。
差:品牌语言/参数罗列(如"采用XX技术""含XX成分"),用户无感。
依据:富贵"卖点是品牌视角、买点是用户视角""用户买的不是产品,是使用场景背后被解决的问题"
【维度4·开头钩子满分15】[富贵原文]
判断:开头能不能让人停下来、想继续读。
好:开头第一句就直击用户的大问题/痛处/具体场景代入,每句都打要害。
差:开头是套话或铺垫半天不进正题,没有任何抓人的点。
依据:富贵"内容就是锋利的刀,一定要插用户的心窝子"
【维度5·标题点击力满分13】[富贵原文+实战补充]
判断:标题有没有让目标用户想点的诱因。
好:标题带具体人群/场景/痛点/情绪钩子,一眼觉得"和我有关、我想看",最好来自用户真实说法。
差:只有产品名、平淡无钩子、或太像广告。
依据:富贵"热评就是标题,从真实评论里抓用户的话"[原文];标题善用痛点/人群/效果/情绪词[实战补充]。
【维度6·真实感满分13】[富贵原文]
判断:整条文案是不是像真人分享,而不是品牌广告或功效说明书。
好:价值/场景/感受为主,产品自然带出;前段是干货或真实经历,后段才软性推荐;不开头就报规格价格。
差:通篇硬广、产品功效罗列当主体、开头就卖、语气像文案模板而非真实人设。
依据:富贵"我们很少提产品""前70%是价值/场景后30%才是产品";用户能感受到"这不像广告"
""".strip()
# ── 真实感已升为维度6满分13此处保留空字符串避免调用方引用报错 ──
REALNESS_NOTE = "" # 升为 SCORING_DIMENSIONS 维度6不再重复附加
# ── 输出格式约束 ──────────────────────────────────────────
SCORER_OUTPUT_FORMAT = """
读完这条文案后严格返回纯JSON对象不要markdown代码块、不要多余文字格式
{
"dims": [
{"item":"痛点人群精准","score":<0-18整数>,"reason":"<针对本条的具体理由30字内>"},
{"item":"情绪张力","score":<0-18>,"reason":"..."},
{"item":"买点转化","score":<0-18>,"reason":"..."},
{"item":"开头钩子","score":<0-15>,"reason":"..."},
{"item":"标题点击力","score":<0-13>,"reason":"..."},
{"item":"真实感","score":<0-13>,"reason":"..."}
],
"verdict":"<优秀|合格|不合格>",
"summary":"<一句话总评,说清这条最大的优点和最该改的点>"
}
打分要敢拉开差距:平庸文案该给中低分,不要清一色高分;优秀的地方也别吝啬给高分。
""".strip()
# 合规维度满分(机械硬拦,不进 AI 评委)
COMPLIANCE_MAX = 5
# AI 评委 6 维满分合计(用于把 0-95 折算/校验;+合规5=100
AI_DIMS_MAX = 95
def build_score_prompt(copy: dict, product: dict | None = None) -> str:
"""组装单条文案的评委 prompt。copy={title,content,...}product 提供品牌/品类语境。"""
title = str(copy.get("title", "")).strip()
content = str(copy.get("content", "")).strip()
ctx = ""
if product:
name = product.get("name") or product.get("title") or ""
brand = product.get("brand_keyword") or product.get("brand") or ""
cat = product.get("category") or ""
bits = [b for b in (f"产品:{name}", f"品牌词:{brand}", f"品类:{cat}") if b.split("", 1)[1]]
if bits:
ctx = "【产品语境】\n" + "\n".join(bits) + "\n\n"
return (
f"{SCORING_DIMENSIONS}\n\n"
f"{ctx}【待评文案】\n标题:{title}\n正文:{content}\n\n"
f"{SCORER_OUTPUT_FORMAT}"
)

View File

@@ -0,0 +1,92 @@
"""
_scoring_dims.py — 五维打分逻辑(单一职责:计算层)
词表常量 + 每维打分函数,由 text_scoring.score_copy 调用
"""
from __future__ import annotations
import re
from typing import Any
from .constants import SCORE_WEIGHTS, BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, INTERNAL_COPY_HINTS
_EMOTION_WORDS = ["谁懂", "绝了", "姐妹", "宝子", "挖到", "救星", "离不开",
"闭眼入", "", "不踩雷", "后悔没早买"]
_SCENE_WORDS = ["通勤", "上班", "办公室", "工位", "宿舍", "出门", "旅行",
"居家", "运动", "带娃", "约会", "熬夜", "换季", "饭后",
"早餐", "下午", "加班", "外食", "日常", "早八", "学生党", "新手", "宝妈"]
_CATEGORY_WORDS: dict[str, list[str]] = {
"美妆护肤": ["", "", "质地", "服帖", "清透", "水润", "自然", "上脸", "底妆"],
"个护护理": ["", "护理", "", "", "吸收", "滋润", "粗糙", "倒刺", "随身", "质地"],
"食品饮品": ["口感", "入口", "味道", "冲泡", "", "工位", "", "不腻", "清爽"],
"营养健康": ["成分", "日常", "坚持", "习惯", "补给", "安心", "配方"],
"家居生活": ["收纳", "省事", "清洁", "桌面", "厨房", "租房", "细节", "高频"],
"服饰穿搭": ["上身", "版型", "面料", "通勤", "显瘦", "搭配", "质感"],
"通用好物": ["实用", "场景", "细节", "省事", "日常", "高频"],
}
def _has_any(text: str, words: list[str]) -> bool:
return any(w.lower() in text.lower() for w in words)
def _cat_words(category: str) -> list[str]:
return _CATEGORY_WORDS.get(category, _CATEGORY_WORDS["通用好物"])
def score_title(title: str, cat_words: list[str], w: dict) -> dict:
ts = 0
if 6 <= len(title) <= 34: ts += 8
if re.search(r"[0-9一二三四五六七八九十几]|最近|这支|这个|每天|早八|学生党|新手|宝妈|懒人|伪素颜", title): ts += 5
if _has_any(title, _SCENE_WORDS): ts += 6
if _has_any(title, cat_words): ts += 5
if re.search(r"[!?]|救星|不踩雷|闭眼入|会回购|被问|惊喜|加分|常备|省心|实用|别乱选|放心|有气色", title): ts += 6
if re.search(r"救星|绝了|挖到|偷偷|被问|离不开|后悔|拿捏|香|包里|回购|常备|工位|换季", title): ts += 4
ts = min(ts, w["title"])
return {"item": "标题吸引力", "score": ts, "max": w["title"],
"note": "标题有明确人群/场景钩子" if ts >= 18 else "建议补充人群、场景或强钩子"}
def score_emotion(full: str, w: dict) -> dict:
es = 0
if _has_any(full, _EMOTION_WORDS): es += 10
if _has_any(full, _SCENE_WORDS): es += 8
if re.search(r"不假白|不卡|不搓泥|没底气|纠结|救|不黏|不腻|踩雷|麻烦|翻车", full): es += 7
if re.search(r"姐妹|宝子|室友|同事|我自己|实测|亲测|上脸|出门|工位|宿舍|家里", full): es += 5
if re.search(r"[✅✨🌿💧📦🔍🧡🪞🧴🍃🥹😭👍]", full): es += 3
es = min(es, w["emotion"])
return {"item": "情绪共鸣", "score": es, "max": w["emotion"],
"note": "口语感和痛点表达较充分" if es >= 18 else "建议增加真实痛点和口语化表达"}
def score_selling(copy: dict, full: str, selling_points: list, w: dict) -> dict:
bs = 0
matched = [pt for pt in selling_points if any(kw in full for kw in str(pt).split()[:3])]
if len(matched) >= 1: bs += 7
if len(matched) >= 2: bs += 4
if re.search(r"方便|自然|清爽|质地|口感|成分|实测|亲测|场景|随身|省事|高频|性价比|好用|适合|推荐", full): bs += 8
if copy.get("buyingPoint") or re.search(r"分钟|出门|通勤|办公室|宿舍|居家|换季|工位|旅行", full): bs += 9
if copy.get("coverTitle") or copy.get("imageBrief"): bs += 4
bs = min(bs, w["selling"])
return {"item": "买点表达", "score": bs, "max": w["selling"],
"note": "卖点已转成用户可感知买点" if bs >= 18 else "建议把功能卖点翻译成使用场景和结果"}
def score_keyword(copy: dict, tags: str, keywords: list, w: dict) -> dict:
ks = 0
matched = [k for k in keywords if str(k).replace("#", "") in tags + str(copy.get("content",""))]
if len(matched) >= 1: ks += 6
if len(matched) >= 2: ks += 5
if len(copy.get("tags", [])) >= 3: ks += 5
if "#" in tags: ks += 4
if len(copy.get("tags", [])) >= 5: ks += 4
ks = min(ks, w["keyword"])
note = f"覆盖:{''.join(matched)}" if matched else "建议补充品类词和长尾词"
return {"item": "关键词覆盖", "score": ks, "max": w["keyword"], "note": note}
def score_compliance(full: str, bwords: list[str], w: dict) -> tuple[dict, list[str]]:
found_banned = [bw for bw in bwords if bw.lower() in full.lower()]
found_hints = [hw for hw in INTERNAL_COPY_HINTS if hw in full]
cs = 0 if (found_banned or found_hints) else w["compliance"]
note = (f"含禁用词:{''.join(found_banned)}" if found_banned
else (f"正文混入内部提示:{''.join(found_hints)}" if found_hints else "未发现禁用词"))
return {"item": "合规性", "score": cs, "max": w["compliance"], "note": note}, found_banned + found_hints

View File

@@ -0,0 +1,64 @@
"""
_storyboard_data.py — 品类证明策略数据(纯数据,不含逻辑)
品类来自 product.category不枚举兜底用"通用好物"
"""
from __future__ import annotations
# 视觉违禁词替换规则(扒 sanitizeImagePlanText
SANITIZE_RULES: list[tuple[str, str]] = [
(r"before\s*&\s*after", "质地与肤感说明"),
(r"before\s*/?\s*after", "质地与肤感说明"),
(r"\bbefore\b", "质地状态"),
(r"\bafter\b", "上脸肤感"),
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
]
# 品类证明策略不写死枚举product.category 匹配,兜底通用)
PROOF_STRATEGIES: dict[str, dict] = {
"个护护理": {
"overlay_tpl": "{point}看得见",
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光",
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景",
"forbidden": "不要变白、祛斑、医学效果、before/after字样不要和封面同构图",
},
"美妆护肤": {
"overlay_tpl": "{point}看得见",
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态",
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材",
"forbidden": "不要变白、祛斑、医学效果、before/after字样",
},
"食品饮品": {
"overlay_tpl": "{point}一眼懂",
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色,真实桌面光线",
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、居家场景",
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
},
"营养健康": {
"overlay_tpl": "看清{point}",
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片",
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
},
"家居生活": {
"overlay_tpl": "{point}真省事",
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节",
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
},
"服饰穿搭": {
"overlay_tpl": "{point}有细节",
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部",
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
"forbidden": "不要护肤涂抹,不要过度精修模特感",
},
"通用好物": {
"overlay_tpl": "{point}清晰可见",
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
"asset_use": "产品图保证准确,参考图用于场景辅助",
"forbidden": "不要夸大效果,不要硬广式价格牌",
},
}

View File

@@ -0,0 +1,209 @@
"""
_text_prompt.py — 文案 prompt 组装 / JSON 解析 / 本地模板兜底
方法层(全品类共用):人设/变量池/5步框架/四段结构/反AI味规则
数据层(每产品各异):由 product dict 动态注入,代码不出现具体品牌/成分名
"""
from __future__ import annotations
import json
import random
import re
# ── Q4 佛系反推销人设全品类共用数据层产品名从product动态注入──────────
_PERSONA = """你是一个日常生活分享博主,不是品牌推广号。
核心人设:佛系、不推销、真实记录日常。
内核:不主动劝买,靠真实体验让读者自己动心;写的是生活,产品只是生活的一部分。
比例70% 写生活场景和使用感受30% 提产品,绝不颠倒。
收尾铁律:每条结尾方式必须不同,禁止使用"东西放这了/买不买跟我没关系"这类被用滥的固定句式。"""
# ── Q1 随机变量池 ABC反同质化核心每次随机抽组合N条不撞──────────────
# 方法层:框架固定,内容可扩展,绝不写死
_POOL_A_IDENTITY: list[str] = [
"上班族早八妆前随手抹",
"宿舍懒人护肤三分钟搞定",
"敏感肌妈妈哄完娃才有五分钟",
"学生党第一次用高价护肤品",
"素颜出门前最后一步",
]
_POOL_B_EMOTION: list[str] = [
"看到镜子里发现气色暗了一周",
"闺蜜问你最近皮肤怎么这么好",
"出门被催快点根本来不及叠瓶",
]
_POOL_C_FLAW: list[str] = [
"第一次用量太少了没啥感觉",
"包装简单到以为是山寨",
"价格摆在那以为会很油很厚",
]
def _pick_combo() -> dict[str, str]:
"""随机抽一组 ABC 变量每次生成调用一次N条各不相同"""
return {
"identity": random.choice(_POOL_A_IDENTITY),
"emotion": random.choice(_POOL_B_EMOTION),
"flaw": random.choice(_POOL_C_FLAW),
}
# ── Q5 negative词prompt级别负向约束不让AI写进正文 ─────────────────────
_NEGATIVE_WORDS = (
"神器、福音、救急单品、遮羞布、日常维稳、精简底妆、"
"不仅而且、焕发、守护、尽享、日常维稳、"
"按头安利、绝绝子、闭眼冲、杀疯了、YYDS"
)
# ── emoji 规则适度有表情倩倩姐2026-06-08拍板像真人发的小红书──────────
_EMOJI_RULES = """
【emoji表情适度有表情必须遵守
- 卖点小标题前加 emoji✅ 卖点1 / ✨ 卖点2 / 🌿 卖点3每条卖点1个符合语义
- 正文段落可点缀少量 emoji 烘托情绪(如 🥹 😭 🤍 💛每段最多1-2个不堆砌
- 结尾话题标签前后带表情,如 "#好物分享 🛒"
- emoji 服务情绪和分点,不要每句都加;整条正文 emoji 总量控制在 6-12 个
- 常用小红书 emoji 池:✅✨🌿💧🪞🧴📦🔍💛🤍🥹😭🛒(按语义选,不乱用)
""".strip()
# ── Q2 5步框架 + Q3 四段结构 ─────────────────────────────────────────────
_STRUCTURE_RULES = """
【5步框架必须严格遵循
① Hook暴击低价/痛点:第一句戳中场景或价格锚点,吊足读者好奇
② 痛点共鸣2-3句描写使用前的真实困境用上面抽到的起因情绪A·B
③ 救星登场:自然带出产品,口吻是"碰巧发现/朋友安利/囤货时顺手",不是"推荐给你"
④ 卖点罗列每条加✅小标题3条以内卖点翻译成使用感受不是功效列表
⑤ 收尾(每条必须从下方策略池随机选一种,同批次不得重复同一种,禁止"东西放这了/买不买跟我没关系"此类固定句式):
【收尾策略池·每条选不同策略】
A·留白式感受只说自己现在的状态不提买不买"反正现在素颜出门我不慌了"
B·反问读者把感受抛回给读者"你们护肤有没有那种一用就回不去的东西?"
C·场景延续把故事延伸到未来某个细节"下次同事再问我皮肤的事我就知道说啥了"
D·克制回购暗示轻描淡写说自己行为"第一罐用完了,已经在备第二罐"
E·纯记录收笔像日记最后一句不引导不评价"大概就这样,记录一下"
F·引导搜索仅在有品牌词时使用自然提一句"感兴趣可以搜搜『{品牌词}",不催单
【正文四段结构(必须)】
段1·痛点引入描写使用前的困境/触发场景身份场景A·起因情绪B
段2·实测记录真实使用过程带上小缺点C真实感来源
段3·种草核心产品带来的变化用感受描述而非功效声称
段4·引导收尾从收尾策略池随机选一种佛系口吻不强推末尾带1-2个相关话题标签
【字数】正文350-400字不含标题tags3-5处段落空行增强可读性
""".strip()
# ── Q8 标题公式5类结构每批次覆盖不同类型不重复──────────────────────
_TITLE_FORMULA = """
【标题公式5类每条用不同类型禁止同批重复
肤质型:「{肤质}+用了{产品}+{感受}」例→"油皮用了素颜霜整个夏天不脱妆"
价格型:「{价格锚点}+{产品}+{功效感受}」例→"三位数买到大牌平替,用完第一罐回购第二罐"
功效型:「{使用场景}+{产品}+{可感知变化}」例→"早起素颜出门靠这罐,同事问我最近皮肤怎么了"
夸张型:「疑问/感叹+{夸张感受}+{产品}」例→"这什么神奇产品,涂上去感觉素颜也能出门了"
标题党型:「{反常识/意外信息}+真相是{翻转}」例→"被人说皮肤变好了,没说的是我用了它一个月"
硬性约束标题≤20字禁止出现"绝绝子/YYDS/杀疯了";不直接写功效词(美白/祛斑等)。
""".strip()
_ANTI_AI = f"""
【反AI味必须遵守】
- 禁止固定开篇套话:不许"姐妹们/宝子们/今天给大家分享"开头
- 禁止以下AI味词出现在正文{_NEGATIVE_WORDS}
- 禁止人群重叠:{'{count}'}条文案中身份场景不能重复靠变量池ABC保证
- 禁止场景重复:同批次文案不能都是"早上上班"或都是"学生宿舍"
- 避免生硬推销词:按头安利/绝绝子/闭眼冲 不能出现
""".strip()
# ── 系统 prompt合并Q2/Q3/Q4/Q7/Q8数据层走build_prompt动态注入──────────
COPY_SYSTEM = f"""{_PERSONA}
合规红线(全品类通用):
- 禁用"美白、祛斑、速效、医用、药妆",不得暗示治疗或改善疾病
- 不得承诺效果不得出现before/after变白对比暗示
- 图文合规避免社交App界面、点赞评论等截图元素
{_ANTI_AI.format(count="N")}
{_TITLE_FORMULA}
{_EMOJI_RULES}
{_STRUCTURE_RULES}
返回纯JSON数组每条字段
title标题≤20字/ content正文严格350-400字按emoji规则适度带表情/ tagslist3-5个#话题)
angle本条角度标签/ coverTitle封面大字≤10字/ imageBrief配图方向
硬性格式只输出JSON不要markdown代码块字符串内用中文引号「」。"""
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
"""
组装文案生成 user_prompt。
数据层product 动态注入name/selling_points/style_tone/text_angles/custom_prompt
方法层:已在 COPY_SYSTEM 固定,这里只注入产品数据+随机变量
"""
name = product.get("name", "产品")
selling = "".join(product.get("selling_points") or ["核心卖点待录入"])
style = product.get("style_tone", "素人日常分享风")
angles = product.get("text_angles") or []
custom = (product.get("custom_prompt") or "").strip()
brand_kw = (product.get("brand_keyword") or "").strip()
# Q1每条抽一组随机变量传给模型作角色约束
combos = [_pick_combo() for _ in range(count)]
combos_text = "\n".join(
f"{i+1}条:身份场景=「{c['identity']}」·起因情绪=「{c['emotion']}」·小缺点=「{c['flaw']}"
for i, c in enumerate(combos)
)
angle_hint = f"文案角度要覆盖:{''.join(angles)}(每条用不同角度)。" if angles else ""
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
lines = [
f"产品:{name}",
f"核心卖点(必须翻译成用户能感知的生活化利益,禁止直接列功效词;翻译范例:'烟酰胺''熬夜后第二天脸不那么黄了''高保湿''涂上去一整天都没搓泥拔干'{selling}",
f"风格调性:{style}",
angle_hint,
brand_rule,
custom,
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
combos_text,
extra_rules,
f"\n请严格按5步框架+四段结构生成 {count}每条350-400字返回纯JSON数组。",
]
return "\n".join(l for l in lines if l)
def parse_json_array(raw: str) -> list[dict]:
"""从模型输出提取 JSON 数组(容错 markdown 包裹)"""
text = re.sub(r"```(?:json)?", "", raw).strip()
start, end = text.find("["), text.rfind("]")
if start == -1 or end == -1:
return []
try:
return json.loads(text[start:end + 1])
except json.JSONDecodeError:
return []
def build_local_drafts(product: dict, count: int) -> list[dict]:
"""本地模板兜底(保证永不空手,遵循四段结构)
角度从 text_angles 循环取保证N条角度各不同不被 dedupe_copies 吞掉)
"""
name = product.get("name", "产品")
points = product.get("selling_points") or ["使用方便", "真实可感知"]
# 从产品档案取角度池,不够就用通用角度兜底,保证每条都不同
_fallback_angles = ["生活场景型", "成分分析型", "使用感受型", "性价比型", "痛点切入型"]
angles_pool = product.get("text_angles") or _fallback_angles
for i in range(count):
c = _pick_combo()
# 循环取不同角度(角度相同的两条会被 dedupe_copies 过滤掉,所以必须不重复)
angle = angles_pool[i % len(angles_pool)]
yield {
"title": f"发现一个{name}{angle}用法",
"content": (
f"{c['identity']}{c['emotion']}\n\n"
f"用了一段时间,{points[i % len(points)]}这点最让我意外。\n\n"
f"说个小缺点:{c['flaw']},后来才摸到感觉。\n\n"
f"反正现在用顺手了。✅"
),
"tags": [f"#{name}", "#真实测评", "#好物分享"],
"angle": angle,
"coverTitle": f"{name}{angle}",
"imageBrief": "封面产品近景,内页核心卖点+真实使用场景。",
}

View File

@@ -0,0 +1,125 @@
"""
违禁词三级处理(扒 copy.js sanitizePlanningText 扩展为三级)
🟢 auto_fix = 自动改写replacement 字段给出替换词)
🟡 soft_warn = 软提示(返回建议词,不阻塞)
🔴 hard_block= 硬拦截(直接返回 None拦住发布
词库来自数据库 banned_words 表level + replacement 字段),
DB 未配时用本模块内置默认词库作冷启动。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Literal
BannedLevel = Literal["auto_fix", "soft_warn", "hard_block"]
@dataclass
class BannedWordEntry:
word: str
level: BannedLevel
replacement: str | None = None # auto_fix 时提供替换词
# ── 默认词库(北哥回填解读与落点 §4.3,数据库未配时使用)─
DEFAULT_BANNED_WORDS: list[BannedWordEntry] = [
# 功效违禁auto_fix改写成合规表达对应北哥"提亮肤色感/改善暗沉观感"
BannedWordEntry("美白", "auto_fix", "提亮肤色感"),
BannedWordEntry("祛斑", "auto_fix", "改善暗沉观感"),
# 功效违禁hard_block无法合规改写直接拦截
BannedWordEntry("速效", "hard_block"),
BannedWordEntry("医用", "hard_block"),
BannedWordEntry("药妆", "hard_block"),
BannedWordEntry("强效焕白", "hard_block"),
# 保证性词soft_warn
BannedWordEntry("绝对", "soft_warn"),
BannedWordEntry("第一名", "soft_warn"),
BannedWordEntry("再也不", "soft_warn"),
# 夸张词soft_warn
BannedWordEntry("杀疯了", "soft_warn"),
BannedWordEntry("秒杀", "soft_warn"),
BannedWordEntry("震撼", "soft_warn"),
# AI 味词auto_fix置换为口语表达同时在 _NEGATIVE_WORDS prompt负向约束里已禁止AI写进正文
BannedWordEntry("神器", "auto_fix", "好用的"),
BannedWordEntry("福音", "auto_fix", "适合的"),
BannedWordEntry("救急单品", "auto_fix", "随手备用的"),
BannedWordEntry("遮羞布", "auto_fix", "底妆感"), # 北哥原文补录
BannedWordEntry("不仅而且", "auto_fix", ",另外"),
BannedWordEntry("焕发", "auto_fix", "呈现"),
BannedWordEntry("守护", "auto_fix", ""),
BannedWordEntry("尽享", "auto_fix", "使用"),
BannedWordEntry("日常维稳", "auto_fix", "日常保养"),
BannedWordEntry("精简底妆", "auto_fix", "轻便底妆"),
# 视觉违禁hard_block文案含这些词不许过
BannedWordEntry("前后对比", "hard_block"),
BannedWordEntry("使用前后", "hard_block"),
BannedWordEntry("变白", "auto_fix", "自然光泽感"),
BannedWordEntry("瑕疵消失", "auto_fix", "妆感更服帖"),
]
@dataclass
class CheckResult:
text: str # 原文soft_warn/hard_block 场景下保持原文)
fixed_text: str | None # auto_fix 后的文本;其他级别为 None
status: Literal["pass", "auto_fixed", "soft_warn", "hard_block"]
found: list[dict] = field(default_factory=list)
# found 每项: {"word": str, "level": BannedLevel, "replacement": str|None}
def check_and_fix(
text: str,
entries: list[BannedWordEntry] | None = None,
) -> CheckResult:
"""
对一段文本做三级违禁词扫描。
entries优先用 DB 词条,为 None 时用默认词库。
"""
word_list = entries if entries is not None else DEFAULT_BANNED_WORDS
found: list[dict] = []
working = text
# 先扫描所有命中
for entry in word_list:
if entry.word.lower() in working.lower():
found.append({
"word": entry.word,
"level": entry.level,
"replacement": entry.replacement,
})
if not found:
return CheckResult(text=text, fixed_text=None, status="pass", found=[])
# 有 hard_block → 直接拦截
if any(f["level"] == "hard_block" for f in found):
return CheckResult(text=text, fixed_text=None, status="hard_block", found=found)
# 只有 soft_warn → 软提示,不改文字
if any(f["level"] == "soft_warn" for f in found) and \
all(f["level"] in ("soft_warn", "auto_fix") for f in found):
# 仍执行 auto_fix 改写,但结果状态是 soft_warn优先级高
for f in found:
if f["level"] == "auto_fix" and f["replacement"] is not None:
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
return CheckResult(text=text, fixed_text=working, status="soft_warn", found=found)
# 只有 auto_fix → 自动改写,返回 fixed_text
for f in found:
if f["level"] == "auto_fix" and f["replacement"] is not None:
working = re.sub(re.escape(f["word"]), f["replacement"], working, flags=re.IGNORECASE)
return CheckResult(text=text, fixed_text=working, status="auto_fixed", found=found)
def build_entries_from_db(rows: list[dict]) -> list[BannedWordEntry]:
"""把 DB banned_words 行转成 BannedWordEntry 列表"""
return [
BannedWordEntry(
word=r["word"],
level=r["level"],
replacement=r.get("replacement"),
)
for r in rows
if r.get("word") and r.get("level") in ("auto_fix", "soft_warn", "hard_block")
]

View File

@@ -0,0 +1,139 @@
"""
AI 引擎中心常量
扒自worker/src/copy.js + image.js 上线版
业务参数不写死基石A——分值权重可由产品档案配置覆盖
"""
# ── 合规红线 ──────────────────────────────────────────────
# 初始默认词库(数据库 banned_words 表可覆盖updatable=True
BANNED_WORDS_DEFAULT = ["美白", "祛斑", "速效", "医用", "药妆"]
BANNED_VISUAL_WORDS = [
"前后对比", "使用前后", "用前用后",
"before", "after", "变白", "瑕疵消失", "治疗前后",
]
# 内部提示词(不能混入正文 content 字段)
INTERNAL_COPY_HINTS = [
"配图建议", "图片方向", "内页规划", "适合做成",
"不要做促销海报", "配图说明", "封面建议",
]
# ── 机械五维打分基线仅轨B导入文案/降级回退用轨A已切 AI 评委 llm_score_copy─────
# 历史注轨A原用此5维2026-06-15切至7维AI评委(6维+合规)后此处只作轨B+回退占位。
# 关键词维度(keyword20)因 products 表无 keywords 字段导致 matched 恒空已知不准;
# 轨A不再依赖此权重轨B展示参考可接受。不按此调分真过关靠北哥抽检。
SCORE_WEIGHTS = {
"title": 25,
"emotion": 25,
"selling": 25,
"keyword": 20,
"compliance": 5,
}
# ── AI 评委 7 维满分分布倩倩姐2026-06-15拍板·与 llm_scorer._DIM_MAX/_score_prompt 三处同步)──
# 6维AI读分(痛点18+情绪18+买点18+钩子15+标题13+真实感13=95) + 合规5 = 100
# "真实感"=富贵"很少提产品/前70%干货后30%植入"原则,替换旧机械维度"产品聚焦一件事(16)"
AI_DIM_WEIGHTS = {
"痛点人群精准": 18,
"情绪张力": 18,
"买点转化": 18,
"开头钩子": 15,
"标题点击力": 13,
"真实感": 13,
"compliance": 5, # 机械硬拦,不进 AI 评委
}
# 过线分。倩倩姐2026-06-15拍板80是临时观察值(AI评委给分克制84文案实为合格)。
# 倩倩姐2026-06-15再次拍板维持80临时线不准擅自调85。方向=提生成质量顶分数,不降标准。
# 真过关靠北哥抽检;提质量方向=优化生成 prompt不靠提高门槛凑数。
QUALITY_PASS_SCORE = 80
# ── 文案去重阈值 ──────────────────────────────────────────
DEDUP_TITLE_THRESHOLD = 0.82 # 标题相似度≥此值判重
DEDUP_TITLE_CONTENT_TITLE = 0.65 # 标题+正文联合判重时的标题阈值
DEDUP_TITLE_CONTENT_BODY = 0.72 # 标题+正文联合判重时的正文阈值
# ── 自动优化循环 ──────────────────────────────────────────
MAX_OPTIMIZE_ROUNDS = 2 # 最多重生成轮次
# ── storyboard 分镜角色(枚举不写死数量)────────────────
# Q6: 北哥6张套路顺序 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
PAGE_ROLES = [
{"role": "hook", "name": "封面痛点大字", "focus": "负责点击:强情绪大字标题压痛点,产品露出,真实生活场景,像用户主动分享,不像广告海报"},
{"role": "product_closeup", "name": "单品特写", "focus": "负责种草锚点:单品高清特写+品牌词自然植入第2/6张都带品牌词强化记忆"},
{"role": "ingredient", "name": "成分拆解", "focus": "负责信任:核心成分信息、作用说明,避免医疗化和绝对化表达,信息清晰可信"},
{"role": "texture", "name": "质地展示", "focus": "负责种草:质地近景、涂抹过程、肤感说明,真实手部/桌面/日常光线"},
{"role": "applied_proof", "name": "上脸对比", "focus": "负责证明:可感知上脸效果,展示涂抹前后质地变化(不做肤色变白/瑕疵消失等违规暗示第5张"},
{"role": "closer", "name": "促单收尾", "focus": "负责转化:转化句+品牌词引导搜索品牌词成交软性收尾不硬广第6张再带一次品牌词"},
# 扩展角色8张链路用
{"role": "pain_scene", "name": "痛点共鸣", "focus": "负责共鸣:展示目标人群的真实困扰和使用前情境,但不做功效前后对比"},
{"role": "social_proof","name": "信任背书", "focus": "负责背书:多人反馈、囤货、复购等真实社交证据"},
{"role": "scenario", "name": "多场景演示", "focus": "负责代入:多场景使用展示,不做夸大效果承诺"},
{"role": "tutorial", "name": "使用教程", "focus": "负责降低门槛:简洁步骤、用量、注意事项"},
]
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29──────────
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
STYLE_PROMPTS = {
"xiaohongshu_cover": "小红书种草风独立3:4图文海报/素材图1024×1536构图明亮干净真实实拍质感醒目中文短标题文字在安全区内",
"comparison": "小红书说明对比风独立3:4图文海报/素材图1024×1536构图质地/场景/肤感左右或上下对比,信息层级清晰",
"ingredient": "小红书成分科普风独立3:4图文海报/素材图1024×1536构图成分卡片布局浅色商务美妆风避免医疗化表达",
}
STYLE_DEFAULT = "xiaohongshu_cover"
# ── 叙事链路说明(扒 image.js planImageSet narrativeText:677-681──
# 按图数告诉模型整组图的种草节奏,让每张各司其职不雷同
NARRATIVE_BY_COUNT = {
3: "3张极速链路第1张负责点击第2张是按品类变化的核心证明页第3张负责软性转化。",
6: "6张标准种草链路封面点击、单品特写带品牌词、成分信任、质地种草、上脸证明、促单转化每张画面和文字各司其职不重复。",
8: "8张沉浸测评链路点击、痛点共鸣、单品特写、成分、质地、上脸证明、背书、软性转化。",
}
# ── 3套正交叙事策略倩倩姐2026-06-15起草北哥过目版──────────────
# A痛点先行/B场景先行/C成分背书先行三套正交轴拉开差异
# 每套叙事链路注入 base_prompt 叙事链路段,替换 NARRATIVE_BY_COUNT 默认值
NARRATIVE_BY_STRATEGY = {
"A": (
'【套A·痛点先行】整组基调紧迫感、强对比、情绪共鸣文字短促带感叹号直戳"脸黄显疲惫""素颜不敢出门"'
'6张链路①痛点暴击封面强情绪大字直击暗黄/素颜焦虑)→ ②暗黄脸实拍对比(感叹号+对比词制造紧迫感)'
'→ ③单品特写+品牌词 → ④成分为什么能救暗黄(成分拆解+信任) → ⑤上脸提亮实证 → ⑥"别再顶着黄脸早八"软性促单。'
),
"B": (
'【套B·场景先行】整组基调轻松、生活化、代入感突出"快/省时/伪素颜自由",点到性价比不堆砌。'
'6张链路"早八来不及"场景封面(生活场景钩子) → ②手忙脚乱通勤场景(代入早八焦虑)'
'→ ③一抹搞定单品特写+品牌词 → ④养肤成分让你敢素颜 → ⑤30秒上脸效果 → ⑥"伪素颜自由+平价"软性促单。'
),
"C": (
'【套C·成分背书先行】整组基调专业、可信、真实测评感强调成分逻辑+前后对比+像有用户实证背书。'
'6张链路①成分权威封面核心成分信息锚定信任 → ②核心成分图解(作用说明+清晰可信)'
'→ ③单品特写+品牌词 → ④使用前后时间线对比 → ⑤真实上脸细节 → ⑥"成分党闭眼入"软性促单。'
),
}
# ── 生图通道 ──────────────────────────────────────────────
IMAGE_RETRY_ATTEMPTS = 3
IMAGE_RETRY_BACKOFF_BASE = 2.0 # 指数退避底数(秒)
IMAGE_SIZE_DEFAULT = "1024x1536"
# ── 生图合规负向约束(方法层常量,全品类共用,可扩展)───────────────────────
# 追加到每个 base_prompt 末尾,防模型脑补违禁词/真实品牌到包装
IMAGE_NEGATIVE_CONSTRAINTS = (
"【包装合规硬性禁止——必须严格遵守】"
"①包装/瓶身/标签上禁止出现任何违禁词:美白/whitening/祛斑/brightening/"
"医用/medical/drug/药妆/速效/instant中英文全禁"
"②禁止脑补任何真实品牌名或logo如水密码/WETCODE/兰蔻/SK-II等"
"产品包装只允许出现用户传入的指定品牌词,未传则画无字素瓶;"
"③英文功效词(ANTI-AGING/TONE-UP/BRIGHTENING/FIRMING等)禁止印在包装;"
"④如果提供了产品参考图,包装文字以参考图为准,不得自行添加或修改任何文字。"
"⑤背景纯净:禁止出现电子设备/笔记本电脑/键盘/手机/桌面杂物等无关物体(参考图若含此类背景一律不沿用),"
"只保留浅色简洁台面或产品定制场景,主体聚焦产品本身。"
)
# ── 飞轮信号权重(初始默认,北哥可校准)────────────────
FLYWHEEL_WEIGHTS = {
"text_select": 3,
"image_select": 3,
"approve": 5,
"reject_with_reason": -3,
"regenerate": -1,
}
FLYWHEEL_LOOKBACK = 50 # 聚合最近N条事件
FLYWHEEL_COLD_START = 5 # 信号不足N条时用产品档案冷启动

View File

@@ -0,0 +1,225 @@
"""
gemini_factory.py — 每任务构建独立的 AI client 实例
解决全局单例问题(扒 banana gemini_service.py __init__改造为每任务局部实例
铁律基石B
- 调用方只传 task_id不传 key
- 本模块在 worker 内部查库 → Fernet 解密 → 构建 client
- 解密结果只活在局部变量,函数返回后即销毁
- 绝不打印 / 记录 / 传递明文 key
"""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import httpx
logger = logging.getLogger(__name__)
@dataclass
class AIClients:
"""
一个任务专用的 AI client 集合。
worker 在任务开始时构建,任务结束后释放(局部变量,不存 Redis/DB
"""
# httpx AsyncClient 懒加载 + 按事件循环缓存Celery 每任务多次 asyncio.run
# 持久 client 会绑死到首个已关闭的 loop → "Event loop is closed"。
# 故只存 token/base按当前运行 loop 缓存 clientloop 变了就重建。
_gpt_token: str | None = field(default=None, repr=False)
_gpt_base: str | None = field(default=None, repr=False)
_gpt_client: httpx.AsyncClient | None = field(default=None, repr=False)
_gpt_client_loop_id: int | None = field(default=None, repr=False)
# 备用 OpenAI 兼容中转站codeproxyapiports 503 时真正切过去(独立 base+key
_alt_token: str | None = field(default=None, repr=False)
_alt_base: str | None = field(default=None, repr=False)
# 多 base/token 的 client 池key=(base,token的id),按 loop 失效重建
_client_pool: dict = field(default_factory=dict, repr=False)
_pool_loop_id: int | None = field(default=None, repr=False)
_gemini_key: str | None = field(default=None, repr=False) # 局部变量不打印
_model_image: str = "gpt-image-2"
_model_text: str = "claude-sonnet-4-5" # apiports无gpt-4o-mini,文案用claude中文质量好
def _client(self) -> httpx.AsyncClient:
"""主通道(apiports) client按当前事件循环缓存"""
return self._client_for(self._gpt_base, self._gpt_token)
def _client_for(self, base: str | None, token: str | None) -> httpx.AsyncClient:
"""按 (base, token) 返回可用 clientloop 变化则整池重建(避免跨 loop 复用)"""
if not token:
raise RuntimeError("GPT client 未初始化(缺 token")
loop_id = id(asyncio.get_running_loop())
if self._pool_loop_id != loop_id:
self._client_pool = {}
self._pool_loop_id = loop_id
ck = (base or "", token)
if ck not in self._client_pool:
self._client_pool[ck] = httpx.AsyncClient(
headers={"Authorization": f"Bearer {token}"},
base_url=base or None,
timeout=120.0,
)
return self._client_pool[ck]
# ── ImageClient 协议实现(供 image_gen.py 使用)────────
def _gpt_target(self, provider: str | None) -> tuple[str, str | None, httpx.AsyncClient]:
"""按 provider 选 (base, token, client)codeproxy 走备用站独立 base+key"""
if provider == "codeproxy" and self._alt_token:
base = (self._alt_base or os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
return base, self._alt_token, self._client_for(base, self._alt_token)
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
return base, self._gpt_token, self._client_for(self._gpt_base, self._gpt_token)
async def gpt_edits(self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None) -> bytes:
"""GPT edits endpoint带产品参考图禁纯文生图"""
import io
files: list[tuple] = [("prompt", (None, prompt))]
for i, img in enumerate(reference_images):
files.append(("image[]", (f"ref_{i}.png", io.BytesIO(img), "image/png")))
files.append(("size", (None, size)))
files.append(("model", (None, self._model_image)))
base, _, client = self._gpt_target(provider)
resp = await client.post(f"{base}/images/edits", files=files, timeout=120.0)
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes:
"""GPT 纯文生图(仅 ALLOW_TEXT_ONLY_IMAGE=true 时用)"""
base, _, client = self._gpt_target(provider)
payload = {"model": self._model_image, "prompt": prompt, "n": 1, "size": size}
resp = await client.post(f"{base}/images/generations", json=payload, timeout=120.0)
resp.raise_for_status()
return _extract_image_bytes(resp.json())
async def gemini_generate(self, prompt: str, reference_images: list[bytes], model: str) -> bytes:
"""Gemini 生图(备用通道)"""
if not self._gemini_key:
raise RuntimeError("Gemini key 未初始化")
gemini_base = os.environ.get("GEMINI_API_URL", "https://generativelanguage.googleapis.com/v1beta")
url = f"{gemini_base}/models/{model}:generateContent?key={self._gemini_key}"
parts: list[dict] = [{"text": prompt}]
for img in reference_images:
import base64
parts.append({"inline_data": {"mime_type": "image/png", "data": base64.b64encode(img).decode()}})
payload = {"contents": [{"role": "user", "parts": parts}], "generationConfig": {"responseModalities": ["IMAGE", "TEXT"]}}
async with httpx.AsyncClient() as client:
resp = await client.post(url, json=payload, timeout=120.0)
resp.raise_for_status()
return _extract_gemini_image(resp.json())
async def chat_complete(self, messages: list[dict], model: str | None = None, max_tokens: int = 4096, temperature: float = 0.75) -> str:
"""文字生成(文案生成用)"""
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
payload = {"model": model or self._model_text, "messages": messages, "max_tokens": max_tokens, "temperature": temperature}
# 单批≤4条文案正常 40-55s 返回apiports 网关 ~60s 上限。客户端超时设 75s
# 略高于网关上限即可过长如180s会在 apiports 卡顿时干等,拖慢整体。
timeout = float(os.environ.get("TEXT_LLM_TIMEOUT", "75"))
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"] or ""
async def gpt_vision_analyze(self, prompt: str, images: list[bytes], model: str | None = None) -> str:
"""
GPT/Claude vision 读产品图,返回 JSON 字符串。
messages content 混合 text + image_url(base64)OpenAI vision 格式。
model 默认最强档claude-opus-4-8绝不偷降级。
最多传 4 张图,避免超 token。
"""
import base64
content: list[dict] = [{"type": "text", "text": prompt}]
for img in images[:4]:
b64 = base64.b64encode(img).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
used_model = model or os.environ.get("MODEL_TEXT", "claude-opus-4-8")
messages = [{"role": "user", "content": content}]
base = (os.environ.get("IMAGE_API_BASE") or os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/")
payload = {
"model": used_model,
"messages": messages,
"max_tokens": 2048,
"temperature": 0.2,
}
resp = await self._client().post(f"{base}/chat/completions", json=payload, timeout=90.0)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"] or ""
# duck-type: text_variants._call_llm 用的属性
@property
def _model(self) -> str:
return self._model_text
async def aclose(self) -> None:
# client 可能绑在已关闭的 loopCelery 多次 asyncio.runaclose 也可能报
# "Event loop is closed",吞掉即可——进程级连接随 loop 关闭自然释放。
for c in list(self._client_pool.values()):
try:
await c.aclose()
except Exception:
pass
self._client_pool = {}
self._pool_loop_id = None
self._gpt_client = None
self._gpt_client_loop_id = None
def build_ai_clients(plain_key: str, gemini_key: str | None = None) -> AIClients:
"""
用解密后的明文 key 构建 AIClients。
只在 Celery worker 函数体内调用plain_key 是局部变量。
httpx client 不在此预创建(避免绑死到调用方 loop首次 await 时按 loop 懒建。
调用完成后 caller 负责 await clients.aclose()。
"""
gpt_base = (
os.environ.get("IMAGE_API_BASE") # 旧变量名
or os.environ.get("APIPORTS_BASE_URL") # .env 实际变量名
or ""
).rstrip("/")
# 备用站 codeproxy系统级 key非用户录入apiports 503 时切过去保生图成功
alt_base = (os.environ.get("CODEPROXY_BASE_URL") or "").rstrip("/")
alt_token = os.environ.get("CODEPROXY_KEY") or None
return AIClients(
_gpt_token=plain_key,
_gpt_base=gpt_base or None,
_alt_base=alt_base or None,
_alt_token=alt_token,
_gemini_key=gemini_key,
_model_image=os.environ.get("IMAGE_MODEL") or os.environ.get("MODEL_IMAGE", "gpt-image-2"),
_model_text=os.environ.get("MODEL_TEXT", "claude-opus-4-8"),
)
# ── 图片响应解析工具 ─────────────────────────────────────
def _extract_image_bytes(resp_json: dict) -> bytes:
"""从 OpenAI images API 响应提取图片 bytesb64 或 url"""
import base64
data = resp_json.get("data", [{}])
if not data:
raise ValueError("图片 API 返回空 data")
item = data[0]
if "b64_json" in item:
return base64.b64decode(item["b64_json"])
if "url" in item:
resp = httpx.get(item["url"], timeout=30.0)
resp.raise_for_status()
return resp.content
raise ValueError(f"无法解析图片响应:{list(item.keys())}")
def _extract_gemini_image(resp_json: dict) -> bytes:
"""从 Gemini generateContent 响应提取图片 bytes"""
import base64
candidates = resp_json.get("candidates", [])
for cand in candidates:
parts = cand.get("content", {}).get("parts", [])
for part in parts:
if "inlineData" in part:
return base64.b64decode(part["inlineData"]["data"])
raise ValueError("Gemini 响应中未找到图片数据")

View File

@@ -0,0 +1,175 @@
"""
生图通道 — gpt-image-2 主edits 带产品图) / Gemini 备 + 重试退避
扒自worker/src/image.js generateOneImage / requestProviderImage / imageProviderOrder
新增asyncio 重试退避上线版缺的banana 有 _retry 思路)
铁律:
- IMAGE_PROVIDER_PRIMARY/FALLBACK 走环境变量,不写死
- GPT 主通道必须有产品参考图,无图报错(禁纯文生图防产品跑偏)
- key 不在本模块,由 worker 传入构造好的 async HTTP client
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Protocol
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
from .storyboard import plan_image_set, sanitize_text
logger = logging.getLogger(__name__)
class ImageClient(Protocol):
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
async def gpt_edits(
self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None
) -> bytes: ...
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes: ...
async def gemini_generate(
self, prompt: str, reference_images: list[bytes], model: str
) -> bytes: ...
def _image_provider_order() -> list[str]:
"""从环境变量读主备顺序(扒 imageProviderOrder"""
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
seen: list[str] = []
for p in [primary, fallback]:
if p and p not in seen:
seen.append(p)
return seen
def _gemini_models() -> list[str]:
"""Gemini fallback 模型列表(多模型依次重试)"""
env_val = os.environ.get("GEMINI_IMAGE_MODELS", "gemini-2.0-flash-preview-image-generation,imagen-3.0-generate-002")
return [m.strip() for m in env_val.split(",") if m.strip()]
async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float = IMAGE_RETRY_BACKOFF_BASE) -> Any:
"""指数退避重试(扒 banana _retry 思路)"""
last_exc: Exception | None = None
for i in range(attempts):
try:
return await coro_fn()
except Exception as exc:
last_exc = exc
if i < attempts - 1:
wait = backoff ** i
logger.warning("生图失败第%d次,%.1fs后重试%s", i + 1, wait, exc)
await asyncio.sleep(wait)
raise RuntimeError(f"重试{attempts}次均失败") from last_exc
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
if reference_images:
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
if allow_text_only:
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
errors: list[str] = []
for model in _gemini_models():
try:
return await client.gemini_generate(prompt, reference_images, model)
except Exception as exc:
errors.append(f"{model}: {exc}")
raise RuntimeError("Gemini 全部模型失败:" + "".join(errors))
async def generate_one_image(
client: ImageClient,
prompt: str,
reference_images: list[bytes] | None = None,
) -> bytes:
"""
主入口:按主备顺序依次尝试,每个 provider 内部有重试退避。
返回图片 bytesPNG/JPEG
"""
refs = reference_images or []
providers = _image_provider_order()
errors: list[str] = []
for provider in providers:
try:
# apiports/codeproxy/openai 都是 OpenAI 兼容中转站,走 gpt 协议,
# 但传 provider 进去 → client 按 provider 切到对应中转站的 base+key。
# 这才是真主备apiports 503 → codeproxy 用独立 base+key 顶上。
if provider in ("apiports", "codeproxy", "openai"):
img = await _retry(lambda p=provider: _request_gpt(client, prompt, refs, p))
elif provider == "gpt":
img = await _retry(lambda: _request_gpt(client, prompt, refs, None))
elif provider == "gemini":
img = await _retry(lambda: _request_gemini(client, prompt, refs))
else:
raise ValueError(f"未知图片通道:{provider}")
return img
except Exception as exc:
errors.append(f"{provider}: {exc}")
logger.warning("图片通道 %s 失败,尝试下一个:%s", provider, exc)
raise RuntimeError("所有图片通道均失败:" + "".join(errors))
async def generate_storyboard_images(
client: ImageClient,
note: dict,
product: dict,
image_count: int = 3,
reference_images: list[bytes] | None = None,
analysis: dict | None = None,
strategy: str | None = None,
) -> list[dict]:
"""
按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
每项:{role, name, image_bytes, error}
"""
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
storyboard = plan["storyboard"]
base_prompt = plan["base_prompt"]
async def _gen_one(item: dict) -> dict:
# 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334每张差异化
brand_line = ""
if item.get("brand_keyword"):
brand_line = f"品牌词=「{item['brand_keyword']}」,{item.get('brand_keyword_rule','自然植入')}"
per_prompt = (
f"{base_prompt}\n"
f"本张名称={item['name']}"
f"本张目标={item.get('goal') or item['focus']}"
f"图上主文字=「{sanitize_text(item.get('overlay_text',''), 20)}」。"
f"使用卖点={item.get('selling_point','')}"
f"文案依据={item.get('source_basis','')}"
f"画面主体={item.get('visual_strategy','')}"
f"素材使用={item.get('asset_use','')}"
f"{brand_line}"
f"禁止事项={item.get('forbidden','')}"
"排版要求独立小红书3:4图文海报画面完整标题只出现一次不与其他页重复"
"中文文字少而清晰,主标题+最多3个短点位可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌"
"不要生成App截图或笔记详情页界面。"
)
try:
img_bytes = await generate_one_image(client, per_prompt, reference_images)
return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, "error": None}
except Exception as exc:
logger.error("分镜 %s 生图失败: %s", item["role"], exc)
return {"role": item["role"], "name": item["name"], "image_bytes": None, "error": str(exc)}
# 限并发apiports 图片接口有 QPS 限制6 张全并发会撞 429/503
concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2"))
sem = asyncio.Semaphore(max(1, concurrency))
async def _gen_guarded(item: dict) -> dict:
async with sem:
return await _gen_one(item)
results = await asyncio.gather(*(_gen_guarded(item) for item in storyboard))
return list(results)

View File

@@ -0,0 +1,177 @@
"""
图片后处理去AI化主路
对齐大卫 xhs-tool/backend/infrastructure/imagePostProcess.js运营实测去AI化版
主路 = 尺寸可选(±2%容差内不resize) + SynthID破除(可选) + 高保真重编码去元数据。
诚实声明C2PA 元数据可去除;私有像素水印(如 SynthID只能削弱不保证 100% 清除。
"""
from __future__ import annotations
import io
import logging
import os
logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageEnhance, ImageOps
_PILLOW_OK = True
except ImportError:
_PILLOW_OK = False
logger.warning("Pillow 未安装image_postprocessor 不可用")
# 比例映射表,对齐大卫 RATIO_MAP。key 为字符串如 '3:4'
RATIO_MAP: dict[str, tuple[int, int]] = {
"1:1": (1024, 1024),
"3:4": (1024, 1536), # gpt-image-2 原生尺寸,默认
"4:3": (1536, 1024),
"9:16": (864, 1536),
"16:9": (1536, 864),
}
# ±2% 容差内不做 resize避免无谓重采样对齐大卫 diff > 0.02 才 resize
_RATIO_TOLERANCE = 0.02
def _need_resize(actual_w: int, actual_h: int, target_w: int, target_h: int) -> bool:
"""判断实际比例与目标比例差距是否超出容差。"""
actual_ratio = actual_w / actual_h
target_ratio = target_w / target_h
diff = abs(actual_ratio - target_ratio) / target_ratio
return diff > _RATIO_TOLERANCE
def process_image(
image_bytes: bytes,
aspect_ratio: str = "3:4",
resample_strength: int = 1, # 0=不重采样, 1=轻采样(默认), 2=重采样
) -> bytes:
"""
处理单张图片。
参数:
image_bytes — 原始图片 bytesPNG/JPEG/WebP 等)
aspect_ratio — 目标比例,取 RATIO_MAP 的 key默认 '3:4'=1024×1536
resample_strength — 轻重采样削像素水印0/1/2默认 1=轻采样
返回 JPEG bytes无 EXIF/C2PA/XMP 元数据)。
失败时降级返回原图 bytes不抛异常对齐大卫 catch 返回原图)。
"""
if not _PILLOW_OK:
logger.error("Pillow 未安装,跳过后处理,返回原图")
return image_bytes
try:
img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
actual_w, actual_h = img.size
target = RATIO_MAP.get(aspect_ratio)
# --- Step1: 尺寸对齐±2% 容差内跳过 resize---
if target:
tw, th = target
if _need_resize(actual_w, actual_h, tw, th):
img = ImageOps.fit(img, (tw, th), method=Image.LANCZOS)
logger.debug("resize %dx%d%dx%d (ratio=%s)", actual_w, actual_h, tw, th, aspect_ratio)
# --- Step2: resample_strength 削像素水印(可选,默认轻采样)---
img = _apply_resample(img, resample_strength)
# --- Step3: SynthID 破除SYNTHID_HARD_MODE=1 才开,默认关)---
if os.environ.get("SYNTHID_HARD_MODE") == "1" and target:
img = _apply_synthid_break(img, target)
# --- Step4: 高保真 JPEG 重编码,去所有元数据 ---
buf = io.BytesIO()
img.save(
buf,
format="JPEG",
quality=100,
subsampling=0, # 4:4:4 chroma
optimize=True,
# 不传 exif/icc_profile/xmp = 不写入任何元数据
)
result = buf.getvalue()
logger.debug("后处理完成 %d B → %d B (ratio=%s)", len(image_bytes), len(result), aspect_ratio)
return result
except Exception as exc:
logger.warning("图片后处理失败,降级返回原图: %s", exc)
return image_bytes
def _apply_resample(img: "Image.Image", strength: int) -> "Image.Image":
"""
轻/重采样削像素级水印resample_strength 控制)。
0 — 不采样,仅靠重编码去元数据。
1 — 轻采样缩98%再回原尺寸,保视觉质量,削弱像素水印(对齐旧逻辑)。
2 — 重采样:两次缩放,削弱更多,轻微质量损失。
"""
if strength < 1:
return img
w, h = img.size
img = img.resize((int(w * 0.98), int(h * 0.98)), Image.LANCZOS)
img = img.resize((w, h), Image.LANCZOS)
if strength >= 2:
img = img.resize((int(w * 0.96), int(h * 0.96)), Image.LANCZOS)
img = img.resize((w, h), Image.LANCZOS)
return img
def _apply_synthid_break(img: "Image.Image", target: tuple[int, int]) -> "Image.Image":
"""
SynthID 破除SYNTHID_HARD_MODE=1 时调用):
对齐大卫逻辑 — 缩到(w-2,h-2)再裁掉1px边 + 亮度*1.005/饱和*0.998。
诚实声明:只能削弱 SynthID不保证 100% 清除。
"""
tw, th = target
img = ImageOps.fit(img, (tw - 2, th - 2), method=Image.LANCZOS)
# 裁掉1px边消除边缘水印残留
img = img.crop((1, 1, tw - 3, th - 3))
# 微调亮度/饱和(对齐大卫 modulate brightness/saturation
img = ImageEnhance.Brightness(img).enhance(1.005)
img = ImageEnhance.Color(img).enhance(0.998)
return img
def batch_process(
images: list[bytes],
aspect_ratio: str = "3:4",
resample_strength: int = 1,
) -> list[dict]:
"""
批量后处理。返回 [{index, data, error}],单张失败不阻塞其余。
"""
results = []
for i, img_bytes in enumerate(images):
try:
processed = process_image(img_bytes, aspect_ratio=aspect_ratio,
resample_strength=resample_strength)
results.append({"index": i, "data": processed, "error": None})
except Exception as exc:
logger.error("图片[%d]后处理失败: %s", i, exc)
results.append({"index": i, "data": img_bytes, "error": str(exc)})
return results
async def gemini_rewatermark_fallback(
client: "Any", # GeminiClient由 worker 注入
image_bytes: bytes,
) -> bytes:
"""
备选路Gemini 重绘去水印。
⚠️ 对海报中文大字有改字风险,仅特殊场景启用。
"""
prompt = (
"Remove all watermarks, text overlays, and digital signatures from this image. "
"Reconstruct any covered areas naturally to match the surrounding content. "
"Return a clean version of the same image without any watermarks."
)
try:
result = await client.gemini_generate(
prompt, [image_bytes], "gemini-2.0-flash-preview-image-generation"
)
return result
except Exception as exc:
logger.error("Gemini 去水印失败,降级返回原图: %s", exc)
return image_bytes

View File

@@ -0,0 +1,96 @@
"""
llm_scorer.py — AI 评委打分入口(让模型真读文案,替代机械找词)
合规第7维仍走机械硬拦(score_compliance)AI 读前6维给分+理由。
任何异常/解析失败 → 回退旧机械 score_copy绝不卡链路。
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any
from .constants import BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS, QUALITY_PASS_SCORE
from ._scoring_dims import score_compliance
from .text_scoring import score_copy
from ._score_prompt import SCORER_PERSONA, build_score_prompt, COMPLIANCE_MAX
logger = logging.getLogger(__name__)
# 6 个 AI 维度满分倩倩姐2026-06-15拍板·与 constants.AI_DIM_WEIGHTS/_score_prompt 三处同步)
# 痛点18+情绪18+买点18+钩子15+标题13+真实感13=95+合规5=100
# "真实感"替换旧"产品聚焦一件事",对齐富贵"很少提产品/前70%干货后30%植入"原则
_DIM_MAX = {
"痛点人群精准": 18, "情绪张力": 18, "买点转化": 18,
"开头钩子": 15, "标题点击力": 13, "真实感": 13,
}
# 评委合规相关默认权重(仅供 score_compliance 复用其内部硬拦逻辑)
_COMPLIANCE_W = {"compliance": COMPLIANCE_MAX}
def _parse_verdict(raw: str) -> dict | None:
"""从模型输出里抠出 JSON 对象,失败返 None。"""
s = raw.strip()
m = re.search(r"\{.*\}", s, re.DOTALL)
if not m:
return None
try:
obj = json.loads(m.group(0))
return obj if isinstance(obj, dict) and isinstance(obj.get("dims"), list) else None
except (json.JSONDecodeError, ValueError):
return None
async def llm_score_copy(
client: Any,
copy: dict[str, Any],
source: dict[str, Any],
banned_words: list[str] | None = None,
pass_score: int = QUALITY_PASS_SCORE,
) -> dict[str, Any]:
"""AI 评委读 1 条文案 → 6维分+理由,合规机械硬拦。返回与 score_copy 同结构。"""
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
full = f"{copy.get('title','')}\n{copy.get('content','')}\n{' '.join(str(t) for t in copy.get('tags',[]))}"
dim_comp, found_all = score_compliance(full, bwords, _COMPLIANCE_W)
prompt = build_score_prompt(copy, source)
raw = ""
backoff = [5, 10, 20]
for attempt in range(4):
try:
raw = await client.chat_complete(
messages=[{"role": "system", "content": SCORER_PERSONA},
{"role": "user", "content": prompt}],
model=client._model, max_tokens=1500, temperature=0.3,
)
break
except Exception as exc: # noqa: BLE001 — 含 httpx.HTTPStatusError 503/429
status = getattr(getattr(exc, "response", None), "status_code", 0)
if status in (503, 429) and attempt < 3:
await asyncio.sleep(backoff[min(attempt, 2)])
continue
logger.warning("AI评委调用失败回退机械打分: %s", exc)
return score_copy(copy, source, banned_words, pass_score=pass_score)
verdict = _parse_verdict(raw)
if not verdict:
logger.warning("AI评委输出解析失败回退机械打分。raw[:120]=%s", raw[:120])
return score_copy(copy, source, banned_words, pass_score=pass_score)
details: list[dict] = []
for d in verdict["dims"]:
item = str(d.get("item", "")).strip()
if item not in _DIM_MAX: # 只收白名单6维模型偶尔多吐"总分"等噪声项,丢弃
continue
mx = _DIM_MAX[item]
sc = max(0, min(mx, int(round(float(d.get("score", 0))))))
details.append({"item": item, "score": sc, "max": mx, "note": str(d.get("reason", ""))[:60]})
details.append(dim_comp)
total = max(0, min(100, sum(d["score"] for d in details)))
passed = (total >= pass_score) and not found_all
return {
"score": total, "score_detail": details, "passed": passed,
"banned_words_found": found_all,
"verdict": verdict.get("verdict", ""), "summary": str(verdict.get("summary", ""))[:120],
}

View File

@@ -0,0 +1,132 @@
"""
package_exporter.py — 达人素材交付包生成
架构方案§五 1A步骤5按笔记分文件夹 + 图(01/02/03) + 文案.txt + 发布清单 + 合规说明
路径规则uploads/packages/{workspace_id}/{task_id}/note_{n}/
"""
from __future__ import annotations
import json
import logging
import os
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# 文件夹结构
# uploads/packages/{workspace_id}/{task_id}/
# note_01/
# 01_hook.jpg # 按 seq 序号命名防传错序
# 02_proof.jpg
# 文案.txt # 标题 + 正文 + 标签
# note_02/
# ...
# 📋发布清单.txt
# ✅合规说明.txt
# package.zip # 最终打包文件
def build_delivery_package(
workspace_id: int,
task_id: int,
notes: list[dict], # 每条笔记,含 text_candidate + image_candidates
base_path: str = "uploads/packages",
) -> str:
"""
打包交付,返回 zip 文件的本地路径。
notes 格式:[{
"title": str, "content": str, "tags": list[str],
"images": [{"seq": int, "role": str, "data": bytes}],
"banned_word_status": str, # 合规说明用
}]
"""
package_dir = Path(base_path) / str(workspace_id) / str(task_id)
package_dir.mkdir(parents=True, exist_ok=True)
note_dirs: list[Path] = []
for idx, note in enumerate(notes, start=1):
note_dir = package_dir / f"note_{idx:02d}"
note_dir.mkdir(exist_ok=True)
note_dirs.append(note_dir)
# ── 图片文件(按 seq 序号命名)
for img in sorted(note.get("images", []), key=lambda x: x.get("seq", 0)):
seq = img.get("seq", idx)
role = img.get("role", "img")
fname = f"{seq:02d}_{role}.jpg"
img_data = img.get("data", b"")
if img_data:
(note_dir / fname).write_bytes(img_data)
# ── 文案.txt标题 + 正文 + 标签,达人可直接复制)
tags = note.get("tags") or []
body = note.get("content", "")
# 正文末尾如果 LLM 已写入 #话题 标签,不再重复追加(避免重复)
body_has_tags = bool(tags) and any(
t.strip("#") in body for t in tags if t
)
copy_lines = [
f"【标题】{note.get('title', '')}",
"",
body,
]
if tags and not body_has_tags:
copy_lines += ["", " ".join(tags)]
(note_dir / "文案.txt").write_text("\n".join(copy_lines), encoding="utf-8")
# ── 发布清单.txt
checklist_lines = [
"📋 发布清单",
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
f"任务ID{task_id}",
"",
]
for idx, note in enumerate(notes, start=1):
title = note.get("title", f"笔记{idx}")
n_images = len(note.get("images", []))
checklist_lines.append(f"note_{idx:02d} 标题:{title[:30]} 图片数:{n_images}")
checklist_lines += [
"",
"发布注意事项:",
"- 每条笔记图片按 01/02/03 顺序上传,避免传错序",
"- 文案.txt 中标题/正文/标签已区分,复制对应部分",
"- 品牌词已植入,请勿删除",
"- 不要添加链接(种品牌词,引导天猫搜索成交)",
]
(package_dir / "📋发布清单.txt").write_text("\n".join(checklist_lines), encoding="utf-8")
# ── 合规说明.txt
compliance_lines = [
"✅ 合规说明",
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"本批次内容已完成以下合规处理:",
"1. 违禁词扫描(美白/祛斑/速效/医用/药妆等)",
"2. 视觉违禁词处理(前后对比/变白等)",
"3. 图片去水印处理C2PA元数据已清除",
"",
"各笔记合规状态:",
]
for idx, note in enumerate(notes, start=1):
status = note.get("banned_word_status", "pass")
status_label = {"pass": "✅通过", "auto_fixed": "✅自动改写", "soft_warn": "⚠️软提示", "hard_block": "❌硬拦截"}.get(status, status)
compliance_lines.append(f"note_{idx:02d}{status_label}")
compliance_lines += [
"",
"C2PA元数据可去除私有像素水印只能削弱不保证100%清除。",
"如有合规疑问,请联系运营团队。",
]
(package_dir / "✅合规说明.txt").write_text("\n".join(compliance_lines), encoding="utf-8")
# ── 打 zip
zip_path = package_dir / "package.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for note_dir in note_dirs:
for fpath in sorted(note_dir.iterdir()):
zf.write(fpath, arcname=f"{note_dir.name}/{fpath.name}")
zf.write(package_dir / "📋发布清单.txt", arcname="📋发布清单.txt")
zf.write(package_dir / "✅合规说明.txt", arcname="✅合规说明.txt")
logger.info("delivery package built: %s (notes=%d)", zip_path, len(notes))
return str(zip_path)

View File

@@ -0,0 +1,145 @@
"""
偏好飞轮聚合preference_aggregator
扒自Clover架构方案.md §偏好飞轮怎么转 + PRD §8
三层继承L1 公司品牌基线 > L2 矩阵号人设(二期)> L3 个人手感
聚合最近 FLYWHEEL_LOOKBACK 条 events → prompt 片段注入文案生成
关键:
- 按 product_id 分开学(素颜霜偏好不串精华)
- 信号不足 FLYWHEEL_COLD_START 条时,用产品档案冷启动
- 返回结构对齐 API契约 GET /tasks/{id}/preference/context
"""
from __future__ import annotations
import logging
from collections import Counter
from typing import Any
from .constants import FLYWHEEL_LOOKBACK, FLYWHEEL_COLD_START
logger = logging.getLogger(__name__)
def aggregate_preference_context(
events: list[dict],
product: dict,
workspace_id: int,
product_id: int,
) -> dict:
"""
输入:最近 preference_events 行(已按 workspace_id+product_id 过滤)
输出:{recent_preference, reject_reasons, injected_count, prompt_fragment}
prompt_fragment 直接注入文案生成 prompt
"""
# 按 product_id 过滤(防串货)
relevant = [
e for e in events
if e.get("workspace_id") == workspace_id and e.get("product_id") == product_id
][:FLYWHEEL_LOOKBACK]
injected_count = len(relevant)
if injected_count < FLYWHEEL_COLD_START:
# 冷启动:用产品档案静态基线
return _cold_start(product, injected_count)
# ── 统计最常选角度text_select + approve 信号)
angle_counts: Counter = Counter()
reject_reasons: list[str] = []
for e in relevant:
sig_type = e.get("signal_type", "")
angle = str(e.get("angle_label", "")).strip()
weight = int(e.get("signal_weight", 1))
if sig_type in ("text_select", "approve") and angle:
angle_counts[angle] += weight
elif sig_type == "reject_with_reason":
reason = str(e.get("reason", "")).strip()
if reason:
reject_reasons.append(reason)
# 取权重最高的角度
top_angles = [a for a, _ in angle_counts.most_common(3)]
# 取最近3条打回原因
recent_rejects = reject_reasons[-3:] if reject_reasons else []
# ── 拼 prompt 片段三层继承L1>L2>L3一期只跑L1+L3
prompt_fragment = _build_prompt_fragment(top_angles, recent_rejects, product)
# ── 人类可读摘要(前端"本次已注入"显示)
if top_angles:
pref_summary = f"最近偏好角度:{''.join(top_angles)}(已选{injected_count}次信号)"
else:
pref_summary = f"已注入{injected_count}条偏好信号"
return {
"recent_preference": pref_summary,
"reject_reasons": recent_rejects,
"injected_count": injected_count,
"prompt_fragment": prompt_fragment, # 注入 generate_text_variants extra_rules
}
def _cold_start(product: dict, injected_count: int) -> dict:
"""信号不足时用产品档案基线"""
angles = product.get("text_angles") or []
style = product.get("style_tone", "素人分享风")
fragment = ""
if angles:
fragment = f"优先覆盖以下文案角度:{''.join(angles[:3])}。风格调性:{style}"
return {
"recent_preference": f"冷启动(历史信号{injected_count}条,不足{FLYWHEEL_COLD_START}条),使用产品档案基线",
"reject_reasons": [],
"injected_count": injected_count,
"prompt_fragment": fragment,
}
def _build_prompt_fragment(
top_angles: list[str],
reject_reasons: list[str],
product: dict,
) -> str:
"""
组装注入文案 prompt 的片段
越积累越精准1次=全靠基线10次=知道偏好角度30次=措辞从"供参考"升为明确指令
"""
lines: list[str] = []
if top_angles:
lines.append(f"【偏好角度参考】历史选择偏好:{''.join(top_angles)},请优先采用这些角度方向。")
if reject_reasons:
formatted = "".join(f"{r}" for r in reject_reasons)
lines.append(f"【打回原因参考】以下问题请主动规避:{formatted}")
# L1 品牌基线(产品档案 custom_prompt
custom = (product.get("custom_prompt") or "").strip()
if custom:
lines.append(f"【品牌基线】{custom}")
return "\n".join(lines)
def collect_preference_event(
signal_type: str,
user_id: int,
workspace_id: int,
product_id: int,
angle_label: str = "",
reason: str = "",
weights: dict[str, int] | None = None,
) -> dict:
"""
构造 preference_event 行(由业务接口内部调用,不暴露给前端)
返回待插 DB 的字段 dict
"""
from .constants import FLYWHEEL_WEIGHTS
w_map = weights or FLYWHEEL_WEIGHTS
weight = w_map.get(signal_type, 0)
return {
"signal_type": signal_type,
"signal_weight": weight,
"user_id": user_id,
"workspace_id": workspace_id,
"product_id": product_id,
"angle_label": angle_label,
"reason": reason,
"data_ownership": "client_data", # 原始行为信号归客户PRD §3 data_ownership
}

View File

@@ -0,0 +1,109 @@
"""
prompt_composer.py — 统一 prompt 组装入口≤100行
扒自banana prompts/service.py + worker/src/copy.js prompt 逻辑
Lead 指名接口compose_variants / compose_preference_context
组装逻辑委托:
_text_prompt.py → build_prompt (文案 prompt 主体)
preference_aggregator.py → aggregate_preference_context (飞轮上下文)
原则prompt 组装从这里进,不散落在 text_variants / generate_text_variants 里。
"""
from __future__ import annotations
import logging
from typing import Any
from ._text_prompt import build_prompt, COPY_SYSTEM
from .preference_aggregator import aggregate_preference_context
logger = logging.getLogger(__name__)
# ── 主接口 ────────────────────────────────────────────────────────────────────
def compose_variants(
product: dict,
count: int,
flywheel_context: str = "",
extra_rules: str = "",
) -> tuple[str, str]:
"""
一次出 count 角度文案的完整 prompt。
返回 (system_prompt, user_prompt)。
飞轮片段追加到 user_prompt 末尾(不改 system避免覆盖质量红线
参数:
product — 产品档案 dictname/selling_points/text_angles/custom_prompt 等)
count — 需要几条
flywheel_context— 由 compose_preference_context 返回的 prompt_fragment
extra_rules — 额外规则(优化循环重生成时传 hint
"""
combined_extra = "\n".join(filter(None, [flywheel_context, extra_rules]))
user_prompt = build_prompt(product, count, extra_rules=combined_extra)
logger.debug(
"compose_variants: product=%s count=%d flywheel_len=%d",
product.get("name", "?"), count, len(flywheel_context),
)
return COPY_SYSTEM, user_prompt
def compose_preference_context(
events: list[dict],
product: dict,
workspace_id: int,
product_id: int,
) -> dict:
"""
聚合偏好事件 → 可注入 prompt 的飞轮上下文。
返回结构(对齐 API契约 GET /tasks/{id}/preference/context
{
recent_preference: str, # 人类可读摘要(前端"本次已注入"显示)
reject_reasons: list, # 最近打回原因
injected_count: int, # 有效信号数
prompt_fragment: str, # 注入 compose_variants flywheel_context 的字符串
}
信号不足 FLYWHEEL_COLD_START 条时用产品档案冷启动。
按 workspace_id + product_id 双维过滤(素颜霜偏好不串精华)。
"""
return aggregate_preference_context(events, product, workspace_id, product_id)
# ── 辅助:解析模型返回的 JSON给 text_variants 调用,集中不散) ──────────────
def parse_model_output(raw: str) -> list[dict]:
"""从 LLM 原始输出提取 JSON 数组(容错 markdown 包裹)"""
from ._text_prompt import parse_json_array
return parse_json_array(raw)
# ── 辅助:图片 prompt 组装入口(预留,联调时填充)─────────────────────────────
def compose_image_prompt(
role_name: str,
visual_system: dict,
product: dict,
extra: str = "",
) -> str:
"""
单张分镜 prompt 组装(供 image_gen.generate_one_image 调用)。
TODO: 联调后从 storyboard.plan_image_set 取 base_prompt 注入。
role_name — 分镜角色hook / pain_scene / closer 等)
visual_system— build_visual_system 返回的视觉系统 dict
extra — 追加约束(飞轮图片偏好片段,二期接入)
"""
name = product.get("name", "产品")
style = visual_system.get("style", "")
palette = visual_system.get("color_palette", "")
base = visual_system.get("base_prompt", "")
lines = [
f"[{role_name}] 为产品「{name}」生成种草图。",
base and f"视觉基调:{base}",
style and f"摄影风格:{style}",
palette and f"色调:{palette}",
extra,
]
return "\n".join(l for l in lines if l)

View File

@@ -0,0 +1,197 @@
"""
storyboard 分镜引擎
扒自worker/src/image.js
- getNarrativeRoles按图数取分镜角色
- proof_strategy按品类定证明页策略品类不写死走数据驱动
- build_visual_system成组视觉统一
- plan_image_set组装最终分镜计划
"""
from __future__ import annotations
import re
from .constants import (
PAGE_ROLE_MAP, IMAGE_NEGATIVE_CONSTRAINTS,
STYLE_PROMPTS, STYLE_DEFAULT, NARRATIVE_BY_COUNT, NARRATIVE_BY_STRATEGY,
)
# sanitize_text 移至 templates腾行数此处 re-export 供 image_gen 沿用 import
from .storyboard_templates import role_template, proof_strategy, sanitize_text # noqa: F401
def clamp_count(value: int, fallback: int = 6, lo: int = 1, hi: int = 8) -> int:
try:
return max(lo, min(hi, int(value)))
except (TypeError, ValueError):
return fallback
def short_selling_points(points, fallback: str = "") -> str:
"""3个短卖点拼成 a / b / c扒 shortSellingPoints:112-120"""
src = points if isinstance(points, list) else str(points or "").split("")
clean = [sanitize_text(p, 18) for p in src if sanitize_text(p, 18)][:3]
return " / ".join(clean) if clean else sanitize_text(fallback, 28)
def short_tags(tags, keywords=None) -> str:
"""标签去#截断拼成 #a #b扒 shortTags:48-54"""
merged = list(tags or []) + list(keywords or [])
out = []
for t in merged:
c = sanitize_text(str(t), 12).lstrip("#")
if c:
out.append(f"#{c}")
return " ".join(out[:5])
def analyze_copy_for_image(note: dict, product: dict) -> dict:
"""
从文案+产品提取生图锚点(扒 analyzeCopyForImage:129-148
给每张图填 audience/pain/scene/hook让画面有真实代入而非空泛。
"""
text = f"{note.get('title','')}{note.get('coverTitle','')}{note.get('content','')}"
tags = [sanitize_text(str(t).lstrip('#'), 12) for t in (note.get("tags") or [])]
audience = sanitize_text(
product.get("target_audience")
or next((t for t in tags if re.search(r"党|人|妈妈|女生|学生|通勤|上班|办公室", t)), "")
or "目标用户", 18)
scene = sanitize_text(
next((t for t in tags if re.search(r"通勤|宿舍|上课|约会|出门|办公室|旅行|居家|工位", t)), "")
or "日常自然光场景", 18)
pain = sanitize_text(
next((w for w in re.split(r"[、,,。;;!?\n]", text)
if re.search(r"暗沉|没气色|假白|卡粉|搓泥|油|干|赶时间|预算|麻烦", w)), "")
or "日常使用痛点", 18)
hook = sanitize_text(note.get("coverTitle") or note.get("title") or f"{audience}{scene}", 18)
return {"audience": audience, "scene": scene, "pain": pain, "hook": hook}
def get_narrative_roles(image_count: int = 6) -> list[dict]:
"""
按图数返回分镜角色列表(扒 getNarrativeRolesQ6对齐北哥6张套路
≤3 张:极速链路 hook / applied_proof / closer
≤6 张:北哥标准链路 ①封面痛点大字 ②单品特写+品牌词 ③成分 ④质地 ⑤上脸对比 ⑥促单
>6 张:沉浸链路 + pain_scene / scenario / social_proof
"""
count = clamp_count(image_count)
m = PAGE_ROLE_MAP
if count <= 3:
sequence = ["hook", "applied_proof", "closer"]
elif count <= 6:
# Q6北哥6张标准顺序——品牌词在②(product_closeup)和⑥(closer)两次出现
sequence = ["hook", "product_closeup", "ingredient", "texture", "applied_proof", "closer"]
else:
# 8张沉浸链路在北哥6张基础上插入 pain_scene / social_proof
sequence = ["hook", "pain_scene", "product_closeup", "ingredient", "texture", "applied_proof", "social_proof", "closer"]
return [m[r] for r in sequence[:count] if r in m]
# ── proofStrategy 已移至 storyboard_templates.proof_strategy腾行数超200拆
def build_visual_system(product: dict, analysis: dict | None = None) -> dict:
"""
成组视觉统一(扒 buildVisualSystem
analysis 来自 product.js 分析结果visualIdentity可空
"""
identity = (analysis or {}).get("visualIdentity", {})
palette = (
"".join(identity["colorPalette"][:5])
if isinstance(identity.get("colorPalette"), list) and identity["colorPalette"]
else "提取产品包装主色,搭配浅色真实生活背景"
)
return {
"palette": palette,
"typography": identity.get("typographyStyle", "主标题清晰黑体或手写感标题,辅助文字便签/勾选标注,字重颜色保持同一体系"),
"sticker": identity.get("stickerLanguage", "少量箭头、放大镜、勾选、小表情、便签,不使用促销按钮"),
"layout": identity.get("layoutStyle", "同一组图片保持色调、光线、产品露出方式一致,每张图承担不同叙事角色"),
"texture": identity.get("materialTexture", "产品包装、质地、手背/上脸肤感要真实自然"),
"package_details": identity.get("packageDetails", "如果提供产品图,必须还原包装颜色、瓶身形状、标签方向和主视觉"),
"xhs_style_preset": identity.get("xhsStylePreset", "真实测评风/手写安利风/清单便签风"),
"symbol_system": identity.get("symbolSystem", "中等密度小红书种草符号:✅ ✨ 🌿 💧 🪞 🧴 📦 🔍 💛每张最多2-4个"),
"quality_rules": [
"同组图片字体体系相对一致,但不要像固定模板",
"每张压图文字必须服务当前叙事角色,不能重复封面标题",
"护肤品优先出现手背涂抹、质地微距、自然上脸局部或真实生活场景",
"人物真实自然有轻微皮肤纹理和生活感不要AI精修美女",
"禁止乱码、错别字、App底栏、Like评论分享、硬广价格牌、虚假功效before/after",
],
}
def plan_image_set(note: dict, product: dict, image_count: int = 3, analysis: dict | None = None, strategy: str | None = None) -> dict:
"""
组装分镜计划(主入口)
strategy: None=默认按图数叙事,'A'/'B'/'C'=三套正交叙事策略
返回:{requested_count, storyboard, visual_system, base_prompt}
storyboard 每项:{role, name, focus, overlay_text, prompt_for_item}
"""
count = clamp_count(image_count)
roles = get_narrative_roles(count)
visual = build_visual_system(product, analysis)
category = product.get("category", "通用好物")
points = product.get("selling_points") or ["核心买点"]
src = analyze_copy_for_image(note, product) # 文案锚点audience/pain/scene/hook
storyboard = []
brand_kw = sanitize_text(product.get("brand_keyword") or "", 12)
brand_roles = {"product_closeup", "closer"} # Q6第2/6张带品牌词
for i, role in enumerate(roles):
point = sanitize_text(points[i % len(points)], 18)
tpl = role_template(role["role"])
proof = proof_strategy(category, point) # 仅 applied_proof 用品类证明
# 填模板占位:每角色画面/文字各不同修缩水根因——不再全角色共用proof
fill = {
"audience": src["audience"], "pain": src["pain"], "scene": src["scene"],
"hook": src["hook"], "point": point, "brand": brand_kw or product.get("name", "产品"),
"proof_overlay": proof.get("overlay", point),
"proof_visual": proof.get("visual", ""),
"proof_forbidden": proof.get("forbidden", ""),
}
item = {
"role": role["role"],
"name": role["name"],
"focus": role["focus"],
"goal": sanitize_text(tpl["goal"].format(**fill), 40),
"overlay_text": sanitize_text(tpl["overlay"].format(**fill), 20),
"visual_strategy": sanitize_text(tpl["visual"].format(**fill), 120),
"source_basis": sanitize_text(tpl["basis"].format(**fill), 60),
"selling_point": point,
"asset_use": proof.get("asset_use", "产品图保证包装准确,参考图用于真实场景"),
"forbidden": sanitize_text(tpl["forbidden"].format(**fill), 60),
}
if brand_kw and role["role"] in brand_roles:
item["brand_keyword"] = brand_kw
item["brand_keyword_rule"] = "品牌词自然融入画面文字或产品露出,不做广告牌感"
storyboard.append(item)
# base_prompt 全局规则(扒 planImageSet basePrompt:682-690
product_name = product.get("name", "产品")
cover_title = sanitize_text(note.get("coverTitle") or note.get("title") or "", 18)
style_text = STYLE_PROMPTS.get(product.get("style_tone") or STYLE_DEFAULT, STYLE_PROMPTS[STYLE_DEFAULT])
# strategy非空时取对应正交叙事否则按图数取默认链路
narrative = NARRATIVE_BY_STRATEGY.get(strategy) if strategy else None
if not narrative:
narrative = NARRATIVE_BY_COUNT.get(count, NARRATIVE_BY_COUNT[6])
sp_text = short_selling_points(points, cover_title)
tag_text = short_tags(note.get("tags") or [], product.get("keywords") or [])
base_prompt = (
f"生成一张小红书可上传的独立3:4图文海报/素材图目标比例1024×1536可直接上传的独立图片不是提示词不是App截图。"
f"产品:{product_name}。短标题:{cover_title}"
f"短卖点:{sp_text}。短标签:{tag_text}"
f"叙事链路:{narrative}"
f"视觉风格:{style_text}"
f"成组视觉:主色={visual['palette']};字体={visual['typography']};贴纸={visual['sticker']}"
f"符号系统={visual['symbol_system']};产品还原={visual['package_details']}"
"重要限制中文文字少而清晰每张只允许一个主标题同一句话禁止重复出现正文点位最多3条"
"四周留安全边距文字不贴边不被裁切真实自然像实拍素材后排版降低AI味"
"禁止生成小红书App界面截图、Like/评论/分享/底栏/头像等社交元素;"
"禁止肤色变白、瑕疵消失、治疗前后等视觉暗示,允许安全的未推开/推开后质地状态对比;"
"如果提供产品图,产品是不可修改的真实商品锚点,禁止改名、换包装、混入其他产品。"
f"\n{IMAGE_NEGATIVE_CONSTRAINTS}"
)
return {
"requested_count": count,
"storyboard": storyboard,
"visual_system": visual,
"base_prompt": base_prompt,
}

View File

@@ -0,0 +1,174 @@
"""
角色差异化分镜模板
扒自worker/src/image.js buildImageStoryboard storyboardByRole(222-321)
每个分镜角色有【各自不同】的画面/文字/构图策略,这是"小红书风格不雷同"的根因。
之前缩水6张共用同一个品类 proof 策略 → 图全长一样。
模板里 {占位} 在 storyboard.py 运行时按文案/卖点填充。
字段含义(对齐 promptFromStoryboard 9 字段):
goal 本张目标(这张图要让用户产生什么动作/情绪)
overlay 图上主文字模板(每张不同,不重复封面标题)
visual 画面主体(构图、景别、道具、光线——这是不雷同的关键)
basis 文案依据(这张图从文案哪里来,给模型锚点)
forbidden 本张禁止事项
"""
from __future__ import annotations
import re
# ── sanitize扒 sanitizeImagePlanText防违禁视觉词进 prompt
_SANITIZE_RULES: list[tuple[str, str]] = [
(r"before\s*&\s*after", "质地与肤感说明"),
(r"before\s*/?\s*after", "质地与肤感说明"),
(r"\bbefore\b", "质地状态"),
(r"\bafter\b", "上脸肤感"),
(r"使用前后|用前用后|用前后|前后对比|使用前|使用后", "质地/场景/肤感说明"),
(r"功效对比|效果对比|改善对比", "质地/场景说明对比"),
(r"肤色变白|皮肤变白|变白|美白", "自然光泽感"),
(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑", "妆感更服帖"),
(r"治疗前后|治疗后|医美前后|治愈|修复受损", "日常使用场景说明"),
]
def sanitize_text(value: str, max_len: int = 56) -> str:
s = str(value)
for pattern, repl in _SANITIZE_RULES:
s = re.sub(pattern, repl, s, flags=re.IGNORECASE)
return re.sub(r"\s+", " ", s).strip()[:max_len]
# 北哥6张标准套 + 8张扩展角色每角色独立画面策略
ROLE_STORYBOARD_TPL: dict[str, dict] = {
"hook": {
"goal": "{audience}因为{pain}停下划走,产生点开欲",
"overlay": "{hook}",
"visual": "自然光生活场景,手持产品或产品在桌面前景,真实肤感/手部细节像iPhone随手实拍的封面不是海报",
"basis": "来自选中文案标题、人群{audience}、痛点{pain}",
"forbidden": "不要价格、不要重复后续卖点、不要App界面、不要广告海报感",
},
"product_closeup": {
"goal": "建立单品记忆锚点,让用户记住是哪个产品",
"overlay": "{brand}",
"visual": "单品高清特写居中,干净浅色台面,柔和顶光,瓶身/包装/标签清晰可读,品牌词自然出现在画面或瓶身",
"basis": "来自产品名和品牌词第2张和第6张都要带品牌词强化记忆",
"forbidden": "不要堆多个产品、不要花哨背景抢主体、不要改包装文字",
},
"ingredient": {
"goal": "用成分/配方信息建立信任,但不医疗化",
"overlay": "看清{point}",
"visual": "成分卡片式布局,产品+成分图标/短说明,浅色商务美妆风,信息层级清楚",
"basis": "来自卖点里的成分/功效点,理性表达不夸大",
"forbidden": "不要治疗/改善疾病承诺、不要医生背书、不要绝对化",
},
"texture": {
"goal": "让用户看到{point}的真实质感证据",
"overlay": "{point}看得见",
"visual": "手背或指尖涂抹质地微距,产品放在旁边,自然光,保留真实皮肤纹理,能看清延展和肤感",
"basis": "来自卖点里的质地/肤感描述",
"forbidden": "不要生成变白效果、不要医疗化对比、不要和封面同构图",
},
"applied_proof": {
"goal": "用可感知的上脸/使用证据证明{point}",
"overlay": "{proof_overlay}",
"visual": "{proof_visual}",
"basis": "来自核心卖点{point}和用户对效果的关注",
"forbidden": "{proof_forbidden}",
},
"closer": {
"goal": "用囤货/省钱情报/搜索暗号完成软性转化",
"overlay": "这波真的会囤 {brand}",
"visual": "拆箱、囤货角或产品放在日常物品旁,真实分享氛围,轻量搜索/品牌词暗号提示,再带一次品牌词引导成交",
"basis": "来自价格心智/选择理由,但不做硬广",
"forbidden": "不要大促价格牌、不要购买按钮、不要红黄电商风",
},
# ── 8张扩展角色 ──
"pain_scene": {
"goal": "让用户共鸣{pain}",
"overlay": "{pain}真的懂",
"visual": "{scene}里的真实困扰场景,产品作为解决方案线索出现,不做使用前后对比",
"basis": "来自文案痛点和目标人群",
"forbidden": "不要夸大焦虑、不要before/after",
},
"social_proof": {
"goal": "补足信任背书,让内容不像单方面推销",
"overlay": "身边人都在问",
"visual": "产品在包里/桌面/宿舍囤货角,配简短手写感反馈气泡,真实随手拍",
"basis": "来自评论区语言/选择理由,缺评论时用低调口碑表达",
"forbidden": "不要假造大量头像评论、不要App评论区截图",
},
"scenario": {
"goal": "展示{scene}以外的多场景使用代入",
"overlay": "这些场景都能用",
"visual": "2-3个生活小场景拼贴宿舍/通勤包/办公桌,产品贯穿其中,统一光线",
"basis": "来自目标人群的多场景使用需求",
"forbidden": "不要电商详情页拼贴、不要夸大效果",
},
"tutorial": {
"goal": "降低使用门槛,告诉用户怎么用",
"overlay": "三步就上手",
"visual": "三步手势教程:取量、点涂/使用、收尾,干净背景,产品在画面内",
"basis": "来自文案里的快速/懒人使用场景",
"forbidden": "不要复杂说明书、不要过多文字",
},
}
def role_template(role: str) -> dict:
"""取角色模板,未知角色用 applied_proof 兜底(和源头一致)"""
return ROLE_STORYBOARD_TPL.get(role, ROLE_STORYBOARD_TPL["applied_proof"])
# ── proofStrategy按品类定 applied_proof 证明页策略,扒 image.js:163-208
# 品类来自 product.category不硬编码枚举无匹配走"通用好物"兜底
PROOF_STRATEGIES: dict[str, dict] = {
"个护护理": {
"overlay_tpl": "{point}看得见",
"visual": "手部/身体局部使用证明:少量点涂、推开后吸收状态、真实纹理和自然光;产品只做辅助露出",
"asset_use": "优先使用实拍/参考图中的手部、干纹、涂抹、随身场景;产品图保证包装准确",
"forbidden": "不要变白、祛斑、医学效果、before/after字样不要和封面同构图",
},
"美妆护肤": {
"overlay_tpl": "{point}看得见",
"visual": "肤感/质地证明:手背、脸颊局部或质地微距,展示推开前后真实状态,保留皮肤纹理和自然光",
"asset_use": "优先使用实拍/参考图中的手背、上脸、质地素材;产品图辅助露出",
"forbidden": "不要变白、祛斑、医学效果、before/after字样不要和封面同构图",
},
"食品饮品": {
"overlay_tpl": "{point}一眼懂",
"visual": "冲泡/开袋/入口证明:展示包装、杯中状态、质地颜色或一口口感,真实桌面光线",
"asset_use": "产品图保证包装准确,参考图用于杯子、开袋、冲泡、办公室/居家场景",
"forbidden": "不要涂抹、不要护肤肤感、不要医疗健康承诺",
},
"营养健康": {
"overlay_tpl": "看清{point}",
"visual": "理性证明页:包装、成分表、使用场景和每日习惯卡片,信息清晰但不做治疗承诺",
"asset_use": "产品图和说明图用于成分/包装准确,参考图用于日常使用场景",
"forbidden": "不要治疗、改善疾病、速效、医生背书、前后对比",
},
"家居生活": {
"overlay_tpl": "{point}真省事",
"visual": "使用过程证明:展示痛点场景、产品介入和使用过程细节,强调顺手/收纳/效率",
"asset_use": "参考图用于真实家居环境,产品图保证外观准确",
"forbidden": "不要护肤涂抹,不要虚假夸大结果",
},
"服饰穿搭": {
"overlay_tpl": "{point}有细节",
"visual": "上身/材质证明:展示面料纹理、版型细节或普通身材上身局部,真实自然",
"asset_use": "参考图用于上身/搭配/材质,产品图保证款式颜色准确",
"forbidden": "不要护肤涂抹,不要过度精修模特感",
},
"通用好物": {
"overlay_tpl": "{point}清晰可见",
"visual": "产品使用场景证明:真实道具/场景,展示产品细节和使用过程",
"asset_use": "产品图保证准确,参考图用于场景辅助",
"forbidden": "不要夸大效果,不要硬广式价格牌",
},
}
def proof_strategy(category: str, point: str) -> dict:
"""取品类证明策略,无匹配用通用兜底(扒 proofStrategy"""
s = PROOF_STRATEGIES.get(category, PROOF_STRATEGIES["通用好物"]).copy()
s["overlay"] = s.pop("overlay_tpl", "{point}").format(point=point)
return s

View File

@@ -0,0 +1,93 @@
"""
text_scoring.py — 五维打分接口 + 去重≤100行
打分维度逻辑见 _scoring_dims.py
"""
from __future__ import annotations
import re
from difflib import SequenceMatcher
from typing import Any
from .constants import (
BANNED_WORDS_DEFAULT, BANNED_VISUAL_WORDS,
SCORE_WEIGHTS, QUALITY_PASS_SCORE,
DEDUP_TITLE_THRESHOLD, DEDUP_TITLE_CONTENT_TITLE, DEDUP_TITLE_CONTENT_BODY,
)
from ._scoring_dims import (
_cat_words, score_title, score_emotion, score_selling,
score_keyword, score_compliance,
)
def score_copy(
copy: dict[str, Any],
source: dict[str, Any],
banned_words: list[str] | None = None,
weights: dict[str, int] | None = None,
pass_score: int = QUALITY_PASS_SCORE,
) -> dict[str, Any]:
"""
五维打分标题25 / 情绪25 / 买点25 / 关键词20 / 合规5
返回:{score, score_detail, passed, banned_words_found}
"""
w = weights or SCORE_WEIGHTS
bwords = list(set((banned_words or []) + BANNED_WORDS_DEFAULT + BANNED_VISUAL_WORDS))
title = str(copy.get("title", ""))
content = str(copy.get("content", ""))
tags = " ".join(str(t) for t in copy.get("tags", []))
full = f"{title}\n{content}\n{tags}\n{copy.get('imageBrief','')}"
selling_points = source.get("selling_points", []) or []
keywords = source.get("keywords", []) or []
category = source.get("category", "通用好物")
cat_w = _cat_words(category)
dim_title = score_title(title, cat_w, w)
dim_emotion = score_emotion(full, w)
dim_selling = score_selling(copy, full, selling_points, w)
dim_keyword = score_keyword(copy, tags, keywords, w)
dim_compliance, found_all = score_compliance(full, bwords, w)
details = [dim_title, dim_emotion, dim_selling, dim_keyword, dim_compliance]
total = max(0, min(100, sum(d["score"] for d in details)))
passed = (total >= pass_score) and not found_all
return {"score": total, "score_detail": details, "passed": passed, "banned_words_found": found_all}
def _sim(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def _copy_signature(copy: dict) -> str:
content = str(copy.get("content", ""))
opening = re.sub(r"\s+", "", content[:30])
return f"{copy.get('title', '')}|{opening}"
def is_similar_copy(a: dict, b: dict) -> bool:
"""同质化判重标题≥0.82 OR 标题≥0.65且正文≥0.72"""
t = _sim(str(a.get("title", "")), str(b.get("title", "")))
if t >= DEDUP_TITLE_THRESHOLD:
return True
if t >= DEDUP_TITLE_CONTENT_TITLE:
if _sim(str(a.get("content",""))[:200], str(b.get("content",""))[:200]) >= DEDUP_TITLE_CONTENT_BODY:
return True
return False
def dedupe_copies(copies: list[dict], previous: list[dict] | None = None) -> list[dict]:
"""本轮内互去重 + 与历史去重 + angle 去重"""
history = previous or []
kept: list[dict] = []
used_angles: set[str] = set()
for c in copies:
sig = _copy_signature(c)
if any(_copy_signature(h) == sig for h in history): continue
if any(is_similar_copy(c, h) for h in history): continue
if any(is_similar_copy(c, k) for k in kept): continue
angle = str(c.get("angle", "")).strip()
if angle and angle in used_angles: continue
if angle: used_angles.add(angle)
kept.append(c)
return kept

View File

@@ -0,0 +1,186 @@
"""
text_variants.py — 文案双轨主入口≤100行
轨A: generate_text_variants — 调 LLM 出 N 角度 JSON
轨B: text_import_handler — 导入外部文案进候选池
prompt 组装/解析见 _text_prompt.py评分/去重见 text_scoring.py
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any
from .constants import MAX_OPTIMIZE_ROUNDS
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
from .text_scoring import score_copy, dedupe_copies
from .llm_scorer import llm_score_copy
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
logger = logging.getLogger(__name__)
async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
"""统一 LLM 调用client 由 worker 注入,隔离 key。
G1坑修复AIClients 没有 .chat.completions正确方法是 .chat_complete()
S8: 503/429 指数退避重试最多3次2^attempt 秒),其他异常直接降级返 ''
max_tokens 由调用方按批量缩放opus 会尽量填满输出空间8192 token 的生成
单批 >60s 必撞 apiports 网关上限返 503(task46 实测每请求恰挂 ~61s)。实测单条
max_tokens=1500~2500 仅 16~18s。故按条数动态收墙钟压进 60s 网关窗口内。
"""
import httpx
# 倩倩姐2026-06-13拍板"加大重试+拉长退避"apiports负载波动时单条opus也会被
# 拖过60s返503短退避(1/2/4s)赶不开高负载窗口。故重试5次、退避拉长到最长30s
# 给中转站负载回落留时间。墙钟换稳定(MVP免费阶段可接受)。
max_attempts = 5
backoff = [5, 10, 20, 30] # 第1~4次重试前等待秒数拉长跨过apiports高负载窗口
for attempt in range(max_attempts):
try:
return await client.chat_complete(
messages=[
{"role": "system", "content": COPY_SYSTEM},
{"role": "user", "content": prompt},
],
model=client._model,
max_tokens=max_tokens,
temperature=0.75,
)
except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else 0
if status in (503, 429) and attempt < max_attempts - 1:
wait = backoff[min(attempt, len(backoff) - 1)]
logger.warning(
"LLM 返回 %s,第%d/%d次重试,等待 %ds: %s",
status, attempt + 1, max_attempts - 1, wait, exc,
)
await asyncio.sleep(wait)
continue
logger.error("LLM HTTP错误(不可重试或已达上限): %s: %s", type(exc).__name__, exc)
return ""
except Exception as exc:
# 其他异常(超时/网络断开等)不重试,直接降级
logger.error("LLM 调用失败: %s: %s", type(exc).__name__, exc)
return ""
return ""
# apiports 网关单次响应有 ~60s 上限claude 一次生成 >4 条长文案会超时返 503。
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str) -> list[dict]:
"""生成一批 batch_n 条含解析重试最多2次。失败返回空列表。
max_tokens 按条数缩放(每条约 1800 token封顶 8192),压进 apiports 60s 网关窗口。"""
batch_max_tokens = min(8192, max(1800, batch_n * 1800))
for attempt in range(2):
raw = await _call_llm(llm_client, build_prompt(product, batch_n, extra_rules=extra), batch_max_tokens)
parsed = parse_json_array(raw)
if parsed:
return parsed
logger.warning("文案批(%d条)第%d次解析失败%s", batch_n, attempt + 1,
",重试" if attempt == 0 else ",放弃本批")
return []
async def _generate_in_batches(llm_client: Any, product: dict, count: int, extra: str) -> list[dict]:
"""把 count 条按 TEXT_BATCH_SIZE 分批,串行调用合并。
串行而非并发opus 单批就慢(~300s)且 apiports 限并发,多批 gather 会触发
大面积 503 雪崩(task45 实测)。故改串行,墙钟换稳定。"""
sizes: list[int] = []
remaining = count
while remaining > 0:
n = min(TEXT_BATCH_SIZE, remaining)
sizes.append(n)
remaining -= n
collected: list[dict] = []
for n in sizes:
r = await _generate_one_batch(llm_client, product, n, extra)
collected.extend(r)
return collected
async def generate_text_variants(
llm_client: Any,
product: dict,
count: int,
previous_copies: list[dict] | None = None,
banned_word_rows: list[dict] | None = None,
flywheel_context: str = "",
) -> list[dict]:
"""轨A一次出 count 条不同角度文案,三层兜底,自动优化循环"""
banned_entries = build_entries_from_db(banned_word_rows or [])
extra = flywheel_context
copies: list[dict] = await _generate_in_batches(llm_client, product, count, extra)
if not copies:
copies = list(build_local_drafts(product, count)) # generator → list
candidates: list[dict] = []
for c in copies:
ban: CheckResult = check_and_fix(
f"{c.get('title','')} {c.get('content','')}",
banned_entries or None,
)
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
"passed": scored["passed"], "banned_word_status": ban.status,
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
if ban.status == "auto_fixed" and ban.fixed_text:
c["content"] = ban.fixed_text
candidates.append(c)
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
# 优化轮默认关闭apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503
# 严重拖慢出文案(实测 +100s+)。质量优化等北哥 prompt 方案到位再开(架构已留位)。
optimize_enabled = os.environ.get("TEXT_OPTIMIZE_ENABLED", "false").lower() == "true"
rounds = MAX_OPTIMIZE_ROUNDS if optimize_enabled else 0
for _ in range(rounds):
if not failed:
break
# 优化轮也受 60s 网关上限约束:一次最多重生成 TEXT_BATCH_SIZE 条
batch_failed = failed[:TEXT_BATCH_SIZE]
hint = "\n".join(
f"标题「{c['title']}{c['score']}分,需改进:" +
"".join(d["note"] for d in c.get("score_detail", []) if d["score"] < d["max"] * 0.72)
for c in batch_failed
)
raw2 = await _call_llm(llm_client, build_prompt(
product, len(batch_failed),
extra_rules=f"以下文案未达标,请重新生成并改进:\n{hint}\n不要重复已有标题和角度。",
), min(8192, max(1800, len(batch_failed) * 1800)))
if not raw2:
# LLM 失败(如 503/超时):优化是锦上添花,原始候选已够用,不再耗时重试
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
break
for nc in parse_json_array(raw2):
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
"passed": sc2["passed"], "banned_word_status": "pass",
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})
candidates.append(nc)
failed = [c for c in candidates if not c["passed"]]
return dedupe_copies(candidates, previous_copies or [])[:count]
def text_import_handler(
raw_text: str,
product: dict,
banned_word_rows: list[dict] | None = None,
) -> dict:
"""轨B导入外部文案豆包等直接进候选池source=import"""
banned_entries = build_entries_from_db(banned_word_rows or [])
lines = raw_text.strip().splitlines()
title = lines[0].strip() if lines else ""
content = "\n".join(lines[1:]).strip() if len(lines) > 1 else raw_text.strip()
candidate: dict = {"title": title, "content": content, "tags": [], "angle": "import",
"buyingPoint": "", "coverTitle": title, "imageBrief": "", "source": "import"}
ban = check_and_fix(f"{title} {content}", banned_entries or None)
# 轨B(导入外部文案)走机械 score_copy 而非 AI 评委:导入的是用户自带成品,评分仅作
# 参考展示不卡发布;且本函数同步、改 await 会扩大到调用方。AI 评委只用于轨A生成链路。
scored = score_copy(candidate, product, [e.word for e in banned_entries])
candidate.update({"score": scored["score"], "score_detail": scored["score_detail"],
"passed": scored["passed"], "banned_word_status": ban.status})
return candidate

View File

@@ -0,0 +1,71 @@
"""
app/services/auth_service.py — 认证 service
密码哈希校验、用户查找、响应格式化。
路由层不含业务逻辑,全在此。
"""
import logging
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.response import raise_unauthorized
from app.middleware.workspace_guard import CurrentUser
from app.models.user import User
from app.models.workspace import WorkspaceMember
logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def authenticate_user(
db: Session, username: str, password: str
) -> tuple[User, int, str]:
"""
验证用户名+密码,返回 (user, workspace_id, role)。
失败抛 CloverHTTPException 40101。
"""
user = db.query(User).filter(
User.username == username, User.is_active == True
).first()
if not user or not verify_password(password, user.hashed_password):
raise_unauthorized("用户名或密码错误")
# 取用户所在的第一个 workspace手动建账号场景只有一个
member = (
db.query(WorkspaceMember)
.filter(WorkspaceMember.user_id == user.id)
.first()
)
if not member:
raise_unauthorized("用户未加入任何 workspace请联系管理员")
# 记录登录
try:
from app.models.user import LoginRecord
db.add(LoginRecord(user_id=user.id))
db.commit()
except Exception:
logger.warning("Failed to write login_record for user=%s", user.id)
db.rollback()
return user, member.workspace_id, member.role
def build_user_response(user: User, workspace_id: int, role: str) -> dict:
"""格式化用户响应体契约§4 DTO"""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"current_workspace_id": workspace_id,
"role": role,
}

View File

@@ -0,0 +1,121 @@
"""
app/services/flywheel_service.py — 飞轮信号写入 + 偏好上下文聚合
preference_collector三信号入口选文案/选图/审核)写入 preference_events。
preference_aggregator查最近50条 → 最常选角度 + 打回原因近3条原文拼 prompt。
飞轮不暴露独立埋点端点,只由业务接口内部调用(契约红线)。
"""
import logging
from typing import Any
from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
from app.middleware.workspace_guard import CurrentUser
from app.models.flywheel import PreferenceEvent
from app.models.task import GenerationTask
logger = logging.getLogger(__name__)
# 实时聚合窗口最近50条事件
_AGGREGATION_WINDOW = 50
# 冷启动阈值不足5条信号用产品档案冷启动
_COLD_START_THRESHOLD = 5
def record_signal(
db: Session,
current_user: CurrentUser,
task: GenerationTask,
signal_type: str,
candidate_id: int | None = None,
angle_label: str | None = None,
reason: str | None = None,
) -> None:
"""
写入飞轮信号。
workspace_id + product_id 都必须有基石C + 按产品分开学)。
signal_weight 用枚举默认值,北哥可校准。
data_ownership 默认 client_data选择行为归客户
"""
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
event = PreferenceEvent(
workspace_id=current_user.workspace_id,
product_id=task.product_id,
task_id=task.id,
user_id=current_user.user_id,
signal_type=signal_type,
signal_weight=weight,
candidate_id=candidate_id,
angle_label=angle_label,
reason=reason,
data_ownership=DataOwnership.CLIENT_DATA,
)
try:
db.add(event)
db.commit()
logger.info(
"Flywheel signal: type=%s weight=%s user=%s product=%s",
signal_type, weight, current_user.user_id, task.product_id,
)
except Exception:
db.rollback()
logger.error(
"Failed to write preference_event: type=%s user=%s",
signal_type, current_user.user_id,
)
raise
def get_preference_context(
db: Session, workspace_id: int, product_id: int
) -> dict[str, Any]:
"""
实时聚合偏好上下文最近50条 events
返回recent_preference摘要 + reject_reasons近3条 + injected_count。
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt
按 workspace_id + product_id 严格过滤不串数据基石C
"""
recent = (
db.query(PreferenceEvent)
.filter(
PreferenceEvent.workspace_id == workspace_id,
PreferenceEvent.product_id == product_id,
)
.order_by(desc(PreferenceEvent.created_at))
.limit(_AGGREGATION_WINDOW)
.all()
)
if len(recent) < _COLD_START_THRESHOLD:
return {
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
"reject_reasons": [],
"injected_count": len(recent),
}
# 统计最常被选中的角度
angle_counts: dict[str, int] = {}
for ev in recent:
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE) and ev.angle_label:
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
if top_angles:
pref_desc = "".join(f"{a}(已选{c}次)" for a, c in top_angles)
preference_summary = f"最近偏好:{pref_desc}"
else:
preference_summary = "暂无明显角度偏好"
# 取最近3条打回原因原文不做 AI 归纳契约§3
reject_reasons = [
ev.reason for ev in recent
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
][:3]
return {
"recent_preference": preference_summary,
"reject_reasons": reject_reasons,
"injected_count": len(recent),
}

View File

@@ -0,0 +1,86 @@
"""
app/services/task_service.py — 任务创建 service
校验有无 key → 建 GenerationTask → 只推 task_id 入队,绝不传 key基石B
"""
import logging
from sqlalchemy.orm import Session
from app.core.response import raise_business
from app.middleware.workspace_guard import CurrentUser
from app.models.task import GenerationTask
from app.models.workspace import UserApiKey
logger = logging.getLogger(__name__)
def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
"""校验用户在此 workspace 是否有可用 API Keyopenai/apiports均可没有则引导去配置。"""
key = (
db.query(UserApiKey)
.filter(
UserApiKey.user_id == user_id,
UserApiKey.workspace_id == workspace_id,
UserApiKey.provider.in_(["openai", "apiports"]), # G6坑修复接受主备通道名
)
.first()
)
if not key:
raise_business("尚未配置 API Key请先在设置中录入")
def create_generation_task(
db: Session,
current_user: CurrentUser,
body, # CreateTaskRequest
) -> GenerationTask:
"""
建 GenerationTask 并推 Celery 队列。
只传 task_id绝不传 key基石B
"""
if body.track == "ai":
# 轨A先检查有没有 key
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
need_img = getattr(body, "need_product_image", True)
if need_img:
from app.models.product import Product
product = db.query(Product).filter(
Product.id == body.product_id,
Product.workspace_id == current_user.workspace_id,
).first()
if not product:
raise_business("产品不存在")
if not (product.image_path or "").strip():
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
task = GenerationTask(
workspace_id=current_user.workspace_id,
product_id=body.product_id,
operator_id=current_user.user_id,
theme=body.theme,
text_count=body.text_count,
image_count=body.image_count,
track=body.track,
need_product_image=need_img,
status="pending",
)
db.add(task)
db.commit()
db.refresh(task)
logger.info("GenerationTask created: id=%s ws=%s", task.id, current_user.workspace_id)
if body.track == "ai":
enqueue_generation(task.id)
return task
def enqueue_generation(task_id: int) -> None:
"""只推 task_id 入队,绝不推 key基石B"""
from app.workers.tasks import run_generation_pipeline
run_generation_pipeline.delay(task_id)
logger.info("Enqueued task_id=%s", task_id)

View File

@@ -0,0 +1,7 @@
# app/utils/
工具层占位:
- fernet_utils.py # Fernet加密/解密FERNET_KEY走环境变量绝不进代码库
- sse_utils.py # SSE推送工具补发历史事件前端按event_seq去重
- pagination.py # 统一分页工具
- ai_usage_logger.py # AI调用用量记录每次调用记usage归因到个人key

View File

View File

@@ -0,0 +1,49 @@
"""
app/utils/fernet_utils.py — Fernet 加解密工具(按 Lead 规范路径)
FERNET_KEY 走环境变量绝不进代码库基石B
此模块是 app/core/security.py 中 Fernet 功能的独立导出,
供 AIE / worker 层直接 import无需依赖 FastAPI 上下文。
"""
import logging
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import get_settings
logger = logging.getLogger(__name__)
_fernet_instance: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet_instance
if _fernet_instance is None:
settings = get_settings()
_fernet_instance = Fernet(settings.FERNET_KEY.encode())
return _fernet_instance
def encrypt_key(plain_key: str) -> str:
"""
加密 API Key返回密文字符串。
调用方绝不打印 plain_key基石B
"""
return _get_fernet().encrypt(plain_key.encode()).decode()
def decrypt_key(encrypted_key: str) -> str:
"""
解密 API Key。只在 Celery worker 内部调用。
解密结果只活在调用函数的局部变量,不落盘、不打日志。
"""
try:
return _get_fernet().decrypt(encrypted_key.encode()).decode()
except InvalidToken:
logger.error("Fernet decryption failed: token invalid or key rotated")
raise ValueError("API key decryption failed")
def mask_key(plain_key: str) -> str:
"""只返回后4位展示用不暴露完整 key"""
return plain_key[-4:] if len(plain_key) >= 4 else "****"

View File

@@ -0,0 +1,122 @@
"""
app/utils/sse_utils.py — SSE 工具函数(按 Lead 规范路径)
补发历史事件、event_seq 去重、Redis pub/sub 推送。
供 api/v1/stream.py 和 AIE worker 共用。
"""
import asyncio
import json
import logging
from typing import Any, AsyncGenerator
from app.core.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
# SSE 事件类型契约§2全列
SSE_EVENT_TYPES = frozenset({
"task_started", "analyze_done", "text_progress", "text_candidate",
"image_progress", "image_candidate", "flywheel_injected",
"batch_failed", "task_done", "error", "heartbeat",
})
_HISTORY_KEY_TPL = "sse:task:{task_id}:events"
_CHANNEL_TPL = "sse:task:{task_id}"
_HISTORY_TTL_SECONDS = 3600 # 历史事件保留 1h
def format_sse(event: str, data: dict, seq: int | None = None) -> str:
"""格式化单条 SSE 消息text/event-stream 格式)。"""
payload = json.dumps(data, ensure_ascii=False)
lines = [f"event: {event}", f"data: {payload}"]
if seq is not None:
lines.append(f"id: {seq}")
return "\n".join(lines) + "\n\n"
async def push_event(
redis_client,
task_id: int,
workspace_id: int,
event: str,
data: dict[str, Any],
seq: int,
) -> None:
"""
推送事件到 Redis
1. 追加到历史 list供断线重连补发
2. Publish 到 channel供在线客户端实时收
"""
if event not in SSE_EVENT_TYPES:
logger.warning("Unknown SSE event type: %s", event)
record = {"event": event, "data": data, "seq": seq, "workspace_id": workspace_id}
payload = json.dumps(record, ensure_ascii=False)
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
channel = _CHANNEL_TPL.format(task_id=task_id)
await redis_client.rpush(hist_key, payload)
await redis_client.expire(hist_key, _HISTORY_TTL_SECONDS)
await redis_client.publish(channel, payload)
async def get_history_events(
redis_client, task_id: int, after_seq: int
) -> list[dict]:
"""取 task 历史事件中 seq > after_seq 的部分(断线重连补发)。"""
hist_key = _HISTORY_KEY_TPL.format(task_id=task_id)
try:
raw_list = await redis_client.lrange(hist_key, 0, -1)
result = []
for raw in raw_list:
ev = json.loads(raw)
if ev.get("seq", 0) > after_seq:
result.append(ev)
return result
except Exception as exc:
logger.warning("get_history_events failed task=%s: %s", task_id, exc)
return []
async def stream_events(
redis_client,
task_id: int,
workspace_id: int,
last_seq: int = 0,
heartbeat_interval: int = 25,
) -> AsyncGenerator[str, None]:
"""
主 SSE 生成器:
1. 先补发 last_seq 之后的历史事件
2. 再订阅 Redis channel 实时推新事件
3. 每 heartbeat_interval 秒推一次保活 heartbeat
"""
# 1. 补发历史
history = await get_history_events(redis_client, task_id, last_seq)
for ev in history:
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
# 2. 实时订阅
pubsub = redis_client.pubsub()
channel = _CHANNEL_TPL.format(task_id=task_id)
await pubsub.subscribe(channel)
elapsed = 0
try:
while True:
msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if msg and msg["type"] == "message":
ev = json.loads(msg["data"])
if ev.get("workspace_id") != workspace_id:
continue # 防越权订阅他人任务
yield format_sse(ev["event"], ev["data"], ev.get("seq"))
if ev["event"] in ("task_done", "error"):
break
else:
elapsed += 1
if elapsed >= heartbeat_interval:
elapsed = 0
yield format_sse("heartbeat", {"ts": asyncio.get_event_loop().time()})
finally:
await pubsub.unsubscribe(channel)

View File

@@ -0,0 +1,11 @@
# app/workers/
Celery worker 占位:
- celery_app.py # Celery实例配置broker=Redis
- task_runner.py # 主任务只接收task_id → 查库→FERNET_KEY解密key → 调模型
# 铁律明文key绝不进Celery参数只在函数局部变量不落盘不打日志
- subtasks/
analyze.py # 分析标杆笔记8特征
generate_text.py # 文案双轨轨A一次5角度JSON/轨B跳过
generate_image.py # 并发生图asyncio.gatherA/B/C三策略
postprocess.py # 去水印后处理

View File

View File

@@ -0,0 +1,36 @@
"""
app/workers/celery_app.py — Celery 任务框架壳
铁律:只传 task_id绝不传 key基石B
worker 内查库 → Fernet 解密 → 局部变量,不落盘不打日志。
"""
import logging
from celery import Celery
from app.core.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
celery_app = Celery(
"clover",
broker=settings.celery_broker(),
backend=settings.celery_backend(),
include=["app.workers.tasks", "app.workers.replenish_task"],
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="Asia/Shanghai",
enable_utc=True,
task_track_started=True,
task_acks_late=True, # 任务处理完才 ACK防丢失
worker_prefetch_multiplier=1, # 一次只取1条防长任务堆积
task_routes={
"app.workers.tasks.run_generation_pipeline": {"queue": "generation"},
"app.workers.tasks.build_delivery_package": {"queue": "packaging"},
},
)

View File

@@ -0,0 +1,104 @@
"""
app/workers/packaging_task.py — 交付打包 Celery 任务
build_delivery_package查已选文案+图片 → package_exporter → 存路径
"""
import json
import logging
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
def _get_db():
from app.core.database import SessionLocal
return SessionLocal()
@celery_app.task(
bind=True,
name="app.workers.tasks.build_delivery_package",
max_retries=2,
default_retry_delay=10,
queue="packaging",
)
def build_delivery_package(self, package_id: int) -> dict:
"""打包交付任务。查 delivery_packages → 收集笔记 → package_exporter"""
logger.info("build_delivery_package start: package_id=%s", package_id)
db = _get_db()
try:
from app.models.task import DeliveryPackage, TextCandidate, ImageCandidate
from app.constants.enums import PackageStatus
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
if not pkg:
raise ValueError(f"package_id={package_id} not found")
workspace_id = pkg.workspace_id
task_id = pkg.task_id
from app.core.config import get_settings
settings = get_settings()
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
selected_text = db.query(TextCandidate).filter(
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
).first()
# 整套全打倩倩姐2026-06-08拍板一条笔记的全部图按 seq 排序进包,
# 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。
selected_images = db.query(ImageCandidate).filter(
ImageCandidate.task_id == task_id,
).order_by(ImageCandidate.seq).all()
if not selected_text:
raise ValueError("无已选文案,请先选择文案")
text_data = json.loads(selected_text.content or "{}")
images_data = []
for ic in selected_images:
img_bytes = b""
if ic.url:
# url 形如 /uploads/ws/task/file.jpg本身已含 uploads 前缀。
# 工作目录是 /app直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
rel = ic.url.lstrip("/")
abs_path = rel
try:
with open(abs_path, "rb") as f:
img_bytes = f.read()
except OSError as e:
logger.warning("图片读取失败,跳过:%s %s", abs_path, e)
images_data.append({
"seq": ic.seq,
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
"data": img_bytes,
})
notes = [{
"title": text_data.get("title", ""),
"content": text_data.get("content", ""),
"tags": text_data.get("tags", []),
"images": images_data,
"banned_word_status": (selected_text.banned_word_status.value
if hasattr(selected_text.banned_word_status, "value")
else str(selected_text.banned_word_status)),
}]
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
packages_base = f"{upload_base}/packages"
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
pkg.package_path = zip_path
pkg.download_url = f"/api/v1/delivery-packages/{package_id}/download-file"
pkg.status = PackageStatus.READY
db.commit()
logger.info("delivery package ready: package_id=%s path=%s", package_id, zip_path)
return {"package_id": package_id, "status": "ready", "path": zip_path}
except Exception as exc:
logger.error("build_delivery_package failed: package_id=%s err=%s", package_id, exc)
raise self.retry(exc=exc)
finally:
db.close()

Some files were not shown because too many files have changed in this diff Show More