commit 6a2632da70a37ed0fe04258f126663c4ed2a2f0c Author: yangqianqian <5845211314@qq.com> Date: Tue Jun 16 11:30:22 2026 +0800 baseline: Clover 独立仓库首次基线提交 将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 diff --git a/.claude/rules/api.md b/.claude/rules/api.md new file mode 100644 index 0000000..068838d --- /dev/null +++ b/.claude/rules/api.md @@ -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_id;worker 内查库→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) +- 禁止出现品类/数量/角度任何枚举常量 +- "看起来像枚举"的业务概念 → 做成数据不做成代码 diff --git a/.claude/rules/db.md b/.claude/rules/db.md new file mode 100644 index 0000000..2bf42c2 --- /dev/null +++ b/.claude/rules/db.md @@ -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 JOIN(MongoDB 只存 AI debug trace,业务不依赖) + +## 命名 +- 表名:snake_case 复数(users, generation_tasks, preference_events) +- 字段名:snake_case(created_at, workspace_id) +- 索引名:idx_表名_字段名 + +## 关键约束(架构方案) +- 任务主键:自增 BIGINT + mongo_trace_id VARCHAR(24) +- user_api_keys:UNIQUE(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_key(Fernet),**不存 url 字段**(token站固定自家站) +- users 表:删除 banana 的 credits 字段 +- eval_score:留 NULL(不接 banana 假评分) +- data_ownership:client_data / platform_asset(洞7分层归属) +- banned_words.level:auto_fix / soft_warn / hard_block(三级) + +## 枚举约束 +- DB 枚举约束要有对应代码侧常量文件(消除三套命名打架) +- 品类(products.category)是纯数据字段,禁止任何品类枚举常量 diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md new file mode 100644 index 0000000..e4fa63c --- /dev/null +++ b/.claude/rules/frontend.md @@ -0,0 +1,46 @@ +--- +paths: + - "src/components/**" + - "src/app/**" + - "src/pages/**" + - "src/stores/**" +--- + +# 前端开发规则(Clover · Next.js) + +> 承接 前端交互设计.md(5屏金标准)+ 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错误码 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f2bdb56 --- /dev/null +++ b/.claude/settings.json @@ -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'" + } + ] + } + ] + } +} diff --git a/.claude/skills/clover-loop/SKILL.md b/.claude/skills/clover-loop/SKILL.md new file mode 100644 index 0000000..c6e6ed6 --- /dev/null +++ b/.claude/skills/clover-loop/SKILL.md @@ -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 ` + +### 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 不调参 +- 软件测试过 ≠ 内容质量过 diff --git a/.claude/skills/clover-loop/scripts/loop_state.py b/.claude/skills/clover-loop/scripts/loop_state.py new file mode 100644 index 0000000..3cfbbde --- /dev/null +++ b/.claude/skills/clover-loop/scripts/loop_state.py @@ -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 [args]") + print(" create / advance / gate ") + print(" fail [error] / review ") + 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']}") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b10ff7e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Agent团队与循环任务设计.md b/Agent团队与循环任务设计.md new file mode 100644 index 0000000..bafc82d --- /dev/null +++ b/Agent团队与循环任务设计.md @@ -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 Cleanup):agent 干完任务立即 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 拓扑 + 协作模式写成可执行的开工 prompt(goal 模式)。 + +> 全部作业包完成后,按倩倩姐的常规要求:另起一个独立 AI 审核全部开工前流程,再开工。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8723a83 --- /dev/null +++ b/CLAUDE.md @@ -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. 技术栈 +- 后端统一 Python(FastAPI+Celery+双库+Redis),产品包的 JS 内核**重写成 Python** 融进 Clover。 +- 重写必须仔细对照防逻辑走样(TDD + agent团队 + 另一AI审核兜底)。 + +## 🔴 已拍板的产品决策(不可推翻) +- 生图:**gpt-image-2 主**(codeproxy中转站),Gemini 备。 +- 生图接口:优先 `/images/edits`(带产品参考图),加重试退避。 +- token:统一走自家中转站 url;key 按账号隔离,各人自录自用各算各的。 +- **模型只用最强档(倩倩姐2026-06-12拍板)**:Claude 系用 **4.8**(claude-opus-4-8),GPT 系用 **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环(北哥回填解读与落点.md),banana本给了最硬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 流有效的一次性 ticket,URL 只带 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。 diff --git a/Clover架构方案.md b/Clover架构方案.md new file mode 100644 index 0000000..5e28f7e --- /dev/null +++ b/Clover架构方案.md @@ -0,0 +1,286 @@ +# Clover 🍀 · 统一产品技术架构方案 + +> 项目代号:**Clover(三叶草)** —— 取"种草"之意,接小红书业务 + 倩倩姐设计稿的小草视觉。 +> 对外正式品牌名待定,开发期用此代号。 +> 版本:**v0.3(7洞对齐版,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_id,worker内部查库→解密→调模型,明文只在函数局部变量 +- 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+role,HS256签名防篡改、每请求验签) + 权限:读操作凭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_id;SSE推送 +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建表+矩阵号管理UI;L2人设层激活 +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-server,Playwright跑小红书自家JS算签名),但"系统替客户取数据"=违规+封号风险,只作**北哥内部自用插件、二期可选模块,不进对外标配** + - 官方蒲公英/聚光接口=二期看北哥企业资质再议 +- ✅ **软件收费(06-04洞4已拍板)**:MVP阶段软件**免费**,不做计费墙;token耗材费走自家token站、按账号key隔离,不在产品层计费。收费逻辑(底层麻烦)产品成熟后单独讨论 + +### 待拍板 +- **P1 短信服务商**:用阿里云短信还是腾讯云短信?(影响成本和接入,都需企业实名)(注:仅二期自助注册才需要,一期手动建北哥账号不阻塞) +- ~~P2 标杆链接读不到时的兜底~~ → **已由06-04洞1解决**:对外=截图手填(主通道),登录态采集作内部插件,不再是待拍板项 +- **P2 北哥账号开通**:一期北哥账号由我们手动建(已定,不接短信);自助注册留二期 diff --git a/PRD/PRD-0-总纲.md b/PRD/PRD-0-总纲.md new file mode 100644 index 0000000..9667c6d --- /dev/null +++ b/PRD/PRD-0-总纲.md @@ -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.md:gpt-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 diff --git a/PRD/PRD-前端.md b/PRD/PRD-前端.md new file mode 100644 index 0000000..5cf3b35 --- /dev/null +++ b/PRD/PRD-前端.md @@ -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页 / 移动端适配 diff --git a/PRD/PRD-后端.md b/PRD/PRD-后端.md new file mode 100644 index 0000000..389e283 --- /dev/null +++ b/PRD/PRD-后端.md @@ -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 走 header(Authorization),不走 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只放可发布正文) +- 可调prompt:products表字段,北哥可配(等北哥优质样本+判断标准注入,质量责任在北哥侧) + +### 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_benchmarks:1-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重试退避 +- SSE:Redis 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推送 + diff --git a/PRD/前端PRD交叉审核报告.md b/PRD/前端PRD交叉审核报告.md new file mode 100644 index 0000000..9e44dd4 --- /dev/null +++ b/PRD/前端PRD交叉审核报告.md @@ -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/飞轮注入提示 三项直接卡飞轮和生产链验收 diff --git a/PRD/后端PRD交叉审核报告.md b/PRD/后端PRD交叉审核报告.md new file mode 100644 index 0000000..5a43c83 --- /dev/null +++ b/PRD/后端PRD交叉审核报告.md @@ -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表 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..14d6ee4 --- /dev/null +++ b/backend/Dockerfile @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..5dc7cf1 --- /dev/null +++ b/backend/README.md @@ -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_id,worker内解密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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..5e4b20c --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/README.md b/backend/alembic/README.md new file mode 100644 index 0000000..47dc0c7 --- /dev/null +++ b/backend/alembic/README.md @@ -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 版本号统一管理,不允许手动改表 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0c5eb79 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1a162dc --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_banana_users_tables.py b/backend/alembic/versions/001_banana_users_tables.py new file mode 100644 index 0000000..0a06f21 --- /dev/null +++ b/backend/alembic/versions/001_banana_users_tables.py @@ -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") diff --git a/backend/alembic/versions/002_multitenant_workspace.py b/backend/alembic/versions/002_multitenant_workspace.py new file mode 100644 index 0000000..d37b2a8 --- /dev/null +++ b/backend/alembic/versions/002_multitenant_workspace.py @@ -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") diff --git a/backend/alembic/versions/003_business_tables.py b/backend/alembic/versions/003_business_tables.py new file mode 100644 index 0000000..abce009 --- /dev/null +++ b/backend/alembic/versions/003_business_tables.py @@ -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 无需清理 diff --git a/backend/alembic/versions/004_flywheel_preference_events.py b/backend/alembic/versions/004_flywheel_preference_events.py new file mode 100644 index 0000000..5f7112b --- /dev/null +++ b/backend/alembic/versions/004_flywheel_preference_events.py @@ -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 无需清理 diff --git a/backend/alembic/versions/005_product_image_path.py b/backend/alembic/versions/005_product_image_path.py new file mode 100644 index 0000000..d9f8f73 --- /dev/null +++ b/backend/alembic/versions/005_product_image_path.py @@ -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") diff --git a/backend/alembic/versions/006_image_role_varchar.py b/backend/alembic/versions/006_image_role_varchar.py new file mode 100644 index 0000000..91ad42e --- /dev/null +++ b/backend/alembic/versions/006_image_role_varchar.py @@ -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", + ) diff --git a/backend/alembic/versions/007_need_product_image.py b/backend/alembic/versions/007_need_product_image.py new file mode 100644 index 0000000..8d94c5f --- /dev/null +++ b/backend/alembic/versions/007_need_product_image.py @@ -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") diff --git a/backend/alembic/versions/008_brand_keyword.py b/backend/alembic/versions/008_brand_keyword.py new file mode 100644 index 0000000..1e4e584 --- /dev/null +++ b/backend/alembic/versions/008_brand_keyword.py @@ -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") diff --git a/backend/alembic/versions/009_benchmark_analyze_fields.py b/backend/alembic/versions/009_benchmark_analyze_fields.py new file mode 100644 index 0000000..dd9782d --- /dev/null +++ b/backend/alembic/versions/009_benchmark_analyze_fields.py @@ -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") diff --git a/backend/alembic/versions/010_fission_tasks_table.py b/backend/alembic/versions/010_fission_tasks_table.py new file mode 100644 index 0000000..c41a7fa --- /dev/null +++ b/backend/alembic/versions/010_fission_tasks_table.py @@ -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") diff --git a/backend/alembic/versions/011_task_benchmark_fission_fields.py b/backend/alembic/versions/011_task_benchmark_fission_fields.py new file mode 100644 index 0000000..05bdf6e --- /dev/null +++ b/backend/alembic/versions/011_task_benchmark_fission_fields.py @@ -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: 裂变来源任务ID(NULL=普通任务,非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 ID;NULL=普通任务,非NULL=裂变产出(第11环)", + ), + ) + + +def downgrade(): + op.drop_column("generation_tasks", "source_fission_id") + op.drop_column("generation_tasks", "benchmark_ids") diff --git a/backend/alembic/versions/012_product_target_audience.py b/backend/alembic/versions/012_product_target_audience.py new file mode 100644 index 0000000..3edf992 --- /dev/null +++ b/backend/alembic/versions/012_product_target_audience.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/README.md b/backend/app/api/v1/README.md new file mode 100644 index 0000000..81a99e1 --- /dev/null +++ b/backend/app/api/v1/README.md @@ -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 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/api_keys.py b/backend/app/api/v1/api_keys.py new file mode 100644 index 0000000..b509610 --- /dev/null +++ b/backend/app/api/v1/api_keys.py @@ -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 Key(Fernet 加密存储,只保存后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}) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..eaf1014 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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, + }) diff --git a/backend/app/api/v1/benchmarks.py b/backend/app/api/v1/benchmarks.py new file mode 100644 index 0000000..b0b405b --- /dev/null +++ b/backend/app/api/v1/benchmarks.py @@ -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}) diff --git a/backend/app/api/v1/delivery.py b/backend/app/api/v1/delivery.py new file mode 100644 index 0000000..44a8763 --- /dev/null +++ b/backend/app/api/v1/delivery.py @@ -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=(前端 window.open 用,C1坑修复) + 2. Authorization: Bearer (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, + ) diff --git a/backend/app/api/v1/products.py b/backend/app/api/v1/products.py new file mode 100644 index 0000000..29bb38d --- /dev/null +++ b/backend/app/api/v1/products.py @@ -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 解密(基石B);plain_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) diff --git a/backend/app/api/v1/review.py b/backend/app/api/v1/review.py new file mode 100644 index 0000000..27b826a --- /dev/null +++ b/backend/app/api/v1/review.py @@ -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"}) diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py new file mode 100644 index 0000000..4c0a82e --- /dev/null +++ b/backend/app/api/v1/stream.py @@ -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 → 拿 ticket(60s有效,一次性) + 2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?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 ticket(60s 有效,仅对该 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", + }, + ) diff --git a/backend/app/api/v1/task_actions.py b/backend/app/api/v1/task_actions.py new file mode 100644 index 0000000..d692999 --- /dev/null +++ b/backend/app/api/v1/task_actions.py @@ -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) diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py new file mode 100644 index 0000000..f014632 --- /dev/null +++ b/backend/app/api/v1/tasks.py @@ -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], + }) diff --git a/backend/app/api/v1/workspaces.py b/backend/app/api/v1/workspaces.py new file mode 100644 index 0000000..02b2bbe --- /dev/null +++ b/backend/app/api/v1/workspaces.py @@ -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, + }) diff --git a/backend/app/constants/README.md b/backend/app/constants/README.md new file mode 100644 index 0000000..e2800c5 --- /dev/null +++ b/backend/app/constants/README.md @@ -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提供商枚举(开放接口,不硬编码) diff --git a/backend/app/constants/__init__.py b/backend/app/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/constants/enums.py b/backend/app/constants/enums.py new file mode 100644 index 0000000..1591215 --- /dev/null +++ b/backend/app/constants/enums.py @@ -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 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..b42ce30 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,3 @@ +""" +app/core/__init__.py +""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..dc12d85 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..2df0a6c --- /dev/null +++ b/backend/app/core/database.py @@ -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"] diff --git a/backend/app/core/response.py b/backend/app/core/response.py new file mode 100644 index 0000000..6db91f8 --- /dev/null +++ b/backend/app/core/response.py @@ -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) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..2529fb5 --- /dev/null +++ b/backend/app/core/security.py @@ -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 "****" diff --git a/backend/app/core/sse_ticket.py b/backend/app/core/sse_ticket.py new file mode 100644 index 0000000..a1cdc28 --- /dev/null +++ b/backend/app/core/sse_ticket.py @@ -0,0 +1,43 @@ +""" +app/core/sse_ticket.py — SSE 一次性短票 (ticket) 签发与验证 +倩倩姐2026-06-08拍板:SSE 认证不传 JWT,改传 ticket。 +ticket 存 Redis,TTL 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,写入 Redis,TTL 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 diff --git a/backend/app/middleware/README.md b/backend/app/middleware/README.md new file mode 100644 index 0000000..9512d4e --- /dev/null +++ b/backend/app/middleware/README.md @@ -0,0 +1,5 @@ +# app/middleware/ + +中间件占位: +- workspace_guard.py # 所有接口强制注入workspace_id,防串数据(所有查询强制带此条件) +- auth_middleware.py # JWT验签(HS256,每请求验签) diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/workspace_guard.py b/backend/app/middleware/workspace_guard.py new file mode 100644 index 0000000..3b35f72 --- /dev/null +++ b/backend/app/middleware/workspace_guard.py @@ -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。""" + 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 diff --git a/backend/app/models/README.md b/backend/app/models/README.md new file mode 100644 index 0000000..bd7dc73 --- /dev/null +++ b/backend/app/models/README.md @@ -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_preferences(UI设置偏好) + +## Alembic 002 — 多租户基础(全新) +- workspace.py # workspaces +- workspace_member.py # workspace_members(user+workspace+角色) +- user_api_key.py # user_api_keys(Fernet加密,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_candidates(source=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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..5967a21 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/fission.py b/backend/app/models/fission.py new file mode 100644 index 0000000..37274ea --- /dev/null +++ b/backend/app/models/fission.py @@ -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"), + ) diff --git a/backend/app/models/flywheel.py b/backend/app/models/flywheel.py new file mode 100644 index 0000000..2421cdf --- /dev/null +++ b/backend/app/models/flywheel.py @@ -0,0 +1,97 @@ +""" +app/models/flywheel.py — preference_events / ai_call_logs +Alembic 003(ai_call_logs)+ Alembic 004(preference_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"), + ) diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..89a30b1 --- /dev/null +++ b/backend/app/models/product.py @@ -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: 目标人群(客户输入,透传进文案/生图 prompt,storyboard.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特征分析结果(JSON),AI解析后写入") + 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"), + ) diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..297c924 --- /dev/null +++ b/backend/app/models/task.py @@ -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 ID;NULL=普通任务") + 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 不用 enum:storyboard 角色名仍在演进,约束放应用层 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 + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..d3401ce --- /dev/null +++ b/backend/app/models/user.py @@ -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() diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py new file mode 100644 index 0000000..6db1c91 --- /dev/null +++ b/backend/app/models/workspace.py @@ -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 Key(Fernet 加密)。 + encrypted_key 只存密文,不存 url(token站固定自家站)。 + 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"), + ) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..4ed8eb9 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,3 @@ +""" +app/repositories/__init__.py +""" diff --git a/backend/app/repositories/base_workspace_repo.py b/backend/app/repositories/base_workspace_repo.py new file mode 100644 index 0000000..26a9ea4 --- /dev/null +++ b/backend/app/repositories/base_workspace_repo.py @@ -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 diff --git a/backend/app/services/README.md b/backend/app/services/README.md new file mode 100644 index 0000000..325ff81 --- /dev/null +++ b/backend/app/services/README.md @@ -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 # 去水印后处理(重编码+削像素水印) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/ai_engine/__init__.py b/backend/app/services/ai_engine/__init__.py new file mode 100644 index 0000000..05e933a --- /dev/null +++ b/backend/app/services/ai_engine/__init__.py @@ -0,0 +1,5 @@ +""" +AI 引擎包 +扒自:上线版 worker/src/copy.js + image.js(2026-06-09) +重写为 Python,逻辑对照JS版防走样 +""" diff --git a/backend/app/services/ai_engine/_score_prompt.py b/backend/app/services/ai_engine/_score_prompt.py new file mode 100644 index 0000000..1dfc70d --- /dev/null +++ b/backend/app/services/ai_engine/_score_prompt.py @@ -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}" + ) diff --git a/backend/app/services/ai_engine/_scoring_dims.py b/backend/app/services/ai_engine/_scoring_dims.py new file mode 100644 index 0000000..11a8ebd --- /dev/null +++ b/backend/app/services/ai_engine/_scoring_dims.py @@ -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 diff --git a/backend/app/services/ai_engine/_storyboard_data.py b/backend/app/services/ai_engine/_storyboard_data.py new file mode 100644 index 0000000..1c66973 --- /dev/null +++ b/backend/app/services/ai_engine/_storyboard_data.py @@ -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": "不要夸大效果,不要硬广式价格牌", + }, +} diff --git a/backend/app/services/ai_engine/_text_prompt.py b/backend/app/services/ai_engine/_text_prompt.py new file mode 100644 index 0000000..07e344b --- /dev/null +++ b/backend/app/services/ai_engine/_text_prompt.py @@ -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字(不含标题tags),3-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规则适度带表情)/ tags(list,3-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": "封面产品近景,内页核心卖点+真实使用场景。", + } diff --git a/backend/app/services/ai_engine/banned_word_checker.py b/backend/app/services/ai_engine/banned_word_checker.py new file mode 100644 index 0000000..87d52e9 --- /dev/null +++ b/backend/app/services/ai_engine/banned_word_checker.py @@ -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") + ] diff --git a/backend/app/services/ai_engine/constants.py b/backend/app/services/ai_engine/constants.py new file mode 100644 index 0000000..996ca13 --- /dev/null +++ b/backend/app/services/ai_engine/constants.py @@ -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条时用产品档案冷启动 diff --git a/backend/app/services/ai_engine/gemini_factory.py b/backend/app/services/ai_engine/gemini_factory.py new file mode 100644 index 0000000..a0c18a6 --- /dev/null +++ b/backend/app/services/ai_engine/gemini_factory.py @@ -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 缓存 client,loop 变了就重建。 + _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 兼容中转站(codeproxy):apiports 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) 返回可用 client;loop 变化则整池重建(避免跨 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 可能绑在已关闭的 loop(Celery 多次 asyncio.run),aclose 也可能报 + # "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 响应提取图片 bytes(b64 或 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 响应中未找到图片数据") diff --git a/backend/app/services/ai_engine/image_gen.py b/backend/app/services/ai_engine/image_gen.py new file mode 100644 index 0000000..93b70fd --- /dev/null +++ b/backend/app/services/ai_engine/image_gen.py @@ -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 内部有重试退避。 + 返回图片 bytes(PNG/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) diff --git a/backend/app/services/ai_engine/image_postprocessor.py b/backend/app/services/ai_engine/image_postprocessor.py new file mode 100644 index 0000000..e3f57e4 --- /dev/null +++ b/backend/app/services/ai_engine/image_postprocessor.py @@ -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 — 原始图片 bytes(PNG/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 diff --git a/backend/app/services/ai_engine/llm_scorer.py b/backend/app/services/ai_engine/llm_scorer.py new file mode 100644 index 0000000..47e559c --- /dev/null +++ b/backend/app/services/ai_engine/llm_scorer.py @@ -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], + } diff --git a/backend/app/services/ai_engine/package_exporter.py b/backend/app/services/ai_engine/package_exporter.py new file mode 100644 index 0000000..f5a1b47 --- /dev/null +++ b/backend/app/services/ai_engine/package_exporter.py @@ -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) diff --git a/backend/app/services/ai_engine/preference_aggregator.py b/backend/app/services/ai_engine/preference_aggregator.py new file mode 100644 index 0000000..1f5522b --- /dev/null +++ b/backend/app/services/ai_engine/preference_aggregator.py @@ -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) + } diff --git a/backend/app/services/ai_engine/prompt_composer.py b/backend/app/services/ai_engine/prompt_composer.py new file mode 100644 index 0000000..3e00a38 --- /dev/null +++ b/backend/app/services/ai_engine/prompt_composer.py @@ -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 — 产品档案 dict(name/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) diff --git a/backend/app/services/ai_engine/storyboard.py b/backend/app/services/ai_engine/storyboard.py new file mode 100644 index 0000000..860b387 --- /dev/null +++ b/backend/app/services/ai_engine/storyboard.py @@ -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]: + """ + 按图数返回分镜角色列表(扒 getNarrativeRoles,Q6对齐北哥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, + } diff --git a/backend/app/services/ai_engine/storyboard_templates.py b/backend/app/services/ai_engine/storyboard_templates.py new file mode 100644 index 0000000..9e40ac6 --- /dev/null +++ b/backend/app/services/ai_engine/storyboard_templates.py @@ -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 + diff --git a/backend/app/services/ai_engine/text_scoring.py b/backend/app/services/ai_engine/text_scoring.py new file mode 100644 index 0000000..f3bc9c8 --- /dev/null +++ b/backend/app/services/ai_engine/text_scoring.py @@ -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 diff --git a/backend/app/services/ai_engine/text_variants.py b/backend/app/services/ai_engine/text_variants.py new file mode 100644 index 0000000..2bb3420 --- /dev/null +++ b/backend/app/services/ai_engine/text_variants.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..d0f5c0b --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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, + } diff --git a/backend/app/services/flywheel_service.py b/backend/app/services/flywheel_service.py new file mode 100644 index 0000000..a299c07 --- /dev/null +++ b/backend/app/services/flywheel_service.py @@ -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), + } diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..f8b0281 --- /dev/null +++ b/backend/app/services/task_service.py @@ -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 Key(openai/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) diff --git a/backend/app/utils/README.md b/backend/app/utils/README.md new file mode 100644 index 0000000..37ce3bf --- /dev/null +++ b/backend/app/utils/README.md @@ -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) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/fernet_utils.py b/backend/app/utils/fernet_utils.py new file mode 100644 index 0000000..e5094cf --- /dev/null +++ b/backend/app/utils/fernet_utils.py @@ -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 "****" diff --git a/backend/app/utils/sse_utils.py b/backend/app/utils/sse_utils.py new file mode 100644 index 0000000..a4534f9 --- /dev/null +++ b/backend/app/utils/sse_utils.py @@ -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) diff --git a/backend/app/workers/README.md b/backend/app/workers/README.md new file mode 100644 index 0000000..f701b6d --- /dev/null +++ b/backend/app/workers/README.md @@ -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.gather(A/B/C三策略) + postprocess.py # 去水印后处理 diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py new file mode 100644 index 0000000..66487dc --- /dev/null +++ b/backend/app/workers/celery_app.py @@ -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"}, + }, +) diff --git a/backend/app/workers/packaging_task.py b/backend/app/workers/packaging_task.py new file mode 100644 index 0000000..02adcea --- /dev/null +++ b/backend/app/workers/packaging_task.py @@ -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() diff --git a/backend/app/workers/pipeline_io.py b/backend/app/workers/pipeline_io.py new file mode 100644 index 0000000..991ece8 --- /dev/null +++ b/backend/app/workers/pipeline_io.py @@ -0,0 +1,298 @@ +""" +app/workers/pipeline_io.py — 生产链 Step5-8 +Step5: 文案生成(generate_text_variants) +Step6: 图片生成(generate_storyboard_images,asyncio.gather) +Step7: 图片后处理(image_postprocessor) +Step8: 存 text_candidates / image_candidates → 更新状态 → 推 task_done +""" + +import asyncio +import json +import logging +import os + +logger = logging.getLogger(__name__) + + +def _resolve_image_path(img_path: str) -> str: + """ + 解析产品参考图路径,兼容绝对路径(新)与历史相对路径(旧)。 + 新数据存绝对路径(/app/uploads/...)直接返回; + 旧数据存相对路径(uploads/packages/...)锚定到 UPLOAD_ABS_ROOT 的父级, + 避免 worker(cwd=/) 解析失败。 + """ + if not img_path: + return "" + if os.path.isabs(img_path): + return img_path + from app.core.config import get_settings + # UPLOAD_ABS_ROOT=/app/uploads,其父级 /app 是相对路径(uploads/...)的锚点 + root_parent = os.path.dirname(get_settings().UPLOAD_ABS_ROOT.rstrip("/")) + return os.path.join(root_parent, img_path) + + +def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment: str, + push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool]: + """ + Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。 + S1: 存库前过滤——只存 passed且score>=90且banned_word_status!='hard_block' 的文案。 + 合格数 < task.text_count 时 needs_replenish=True(由主任务发起后台补充子任务)。 + 返回 (candidates_raw, next_seq, needs_replenish)。 + """ + import time + from app.services.ai_engine.text_variants import generate_text_variants + from app.models.product import BannedWord + from app.models.task import TextCandidate + from app.models.flywheel import AiCallLog + from app.models.workspace import UserApiKey + from app.constants.enums import CandidateSource, BannedWordStatus + from app.services.ai_engine.constants import QUALITY_PASS_SCORE + + banned_rows = db.query(BannedWord).filter( + BannedWord.workspace_id == workspace_id + ).all() + banned_dicts = [{"word": b.word, "level": b.level, "replacement": b.replacement} + for b in banned_rows] + + # 查 key_id(只取 id,不解密,不违反基石B) + key_row = db.query(UserApiKey).filter( + UserApiKey.user_id == task.operator_id, + UserApiKey.workspace_id == workspace_id, + ).first() + key_id = key_row.id if key_row else None + + t0 = time.monotonic() + llm_success = True + try: + candidates_raw = asyncio.run(generate_text_variants( + llm_client=clients, + product=product_dict, + count=task.text_count, + banned_word_rows=banned_dicts, + flywheel_context=flywheel_fragment, + )) + except Exception as exc: + llm_success = False + logger.error("generate_text_variants 失败: %s", exc) + candidates_raw = [] + latency_ms = int((time.monotonic() - t0) * 1000) + + # 写 ai_call_logs(留痕,不含明文key) + try: + log = AiCallLog( + workspace_id=workspace_id, + user_id=task.operator_id, + key_id=key_id, + task_id=task.id, + provider="apiports", + model=clients._model, + call_type="text", + success=llm_success, + latency_ms=latency_ms, + ) + db.add(log) + db.flush() + except Exception as log_exc: + logger.warning("ai_call_logs 写入失败(非阻断): %s", log_exc) + + # S1: 存库前过滤——只存合格文案(passed + score>=90 + 非hard_block) + seq = seq_start + saved_count = 0 + for i, c in enumerate(candidates_raw): + score = c.get("score", 0) + passed = c.get("passed", False) + bw_status = c.get("banned_word_status", "pass") + if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"): + logger.info( + "文案[%d] 过滤丢弃: passed=%s score=%s banned=%s", + i, passed, score, bw_status, + ) + continue + + tc = TextCandidate( + workspace_id=workspace_id, + task_id=task.id, + source=CandidateSource.AI, + angle_label=c.get("angle_label") or c.get("angle", ""), + content=json.dumps(c, ensure_ascii=False), + score_json=json.dumps(c.get("score_detail", []), ensure_ascii=False), + banned_word_status=BannedWordStatus(bw_status), + ) + db.add(tc) + db.flush() + saved_count += 1 + seq += 1 + push_fn(task.id, workspace_id, "text_candidate", { + "candidate_id": tc.id, "angle_label": tc.angle_label, + "content": c.get("content", ""), "score": score, + }, seq) + seq += 1 + push_fn(task.id, workspace_id, "text_progress", { + "done": saved_count, "total": task.text_count + }, seq) + + db.commit() + + # S1: 合格数不足时标记需要后台补充 + needs_replenish = saved_count < task.text_count + if needs_replenish: + logger.warning( + "文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充", + task.id, task.text_count, saved_count, + ) + return candidates_raw, seq, needs_replenish + + +def run_image_generation(db, clients, task, product_dict: dict, + push_fn, workspace_id: int, seq_start: int, + first_copy: dict, upload_base_path: str) -> int: + """ + Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。 + 返回 next_seq。 + """ + import time + from app.services.ai_engine.image_gen import generate_storyboard_images + from app.services.ai_engine.image_postprocessor import process_image + from app.models.task import ImageCandidate + from app.models.flywheel import AiCallLog + from app.models.workspace import UserApiKey + from app.constants.enums import ImageRole as IR + + # 取 key_id(不解密,不记录明文 key) + key_row = db.query(UserApiKey).filter( + UserApiKey.user_id == task.operator_id, + UserApiKey.workspace_id == workspace_id, + ).first() + key_id = key_row.id if key_row else None + + # TODO: 尺寸字段后续加产品级配置(products 表现无 aspect_ratio 字段) + # 本轮固定 '3:4'=1024×1536,与 gpt-image-2 原生尺寸一致,免后处理二次拉伸 + aspect_ratio = "3:4" + + # image_count=0 直接跳过(纯文案任务/测试),不空跑生图通道触发无谓失败日志。 + if not task.image_count or task.image_count <= 0: + logger.info("image_count=0,跳过生图: task_id=%s", task.id) + return seq_start + + reference_images: list[bytes] = [] + _img_path = _resolve_image_path(product_dict.get("image_path", "")) + if _img_path and os.path.isfile(_img_path): + try: + with open(_img_path, "rb") as _f: + reference_images = [_f.read()] + logger.info("产品参考图已加载:%s (%d bytes)", _img_path, len(reference_images[0])) + except Exception as _e: + logger.warning("产品参考图读取失败,退化为空列表:%s %s", _img_path, _e) + else: + logger.warning( + "product.image_path 未设置或文件不存在(%r),生图将以无参考图模式运行," + "可能导致产品包装跑偏。", _img_path + ) + + # 禁降级兜底:本次产品入镜但无参考图 → 硬失败,绝不降级纯文生图(建任务已拦一道,这是防绕过) + if getattr(task, "need_product_image", True) and not reference_images: + raise ValueError( + "本次产品入镜(need_product_image=True)但未获取到产品参考图," + "拒绝降级纯文生图。请确认产品已上传参考图。" + ) + + seq = seq_start + # 3套正交叙事 A/B/C,每套各 image_count 张独立生图 + for strategy in ("A", "B", "C"): + t0 = time.monotonic() + img_success = True + img_error_code = None + try: + image_results = asyncio.run(generate_storyboard_images( + client=clients, + note=first_copy, + product=product_dict, + image_count=task.image_count, + reference_images=reference_images or None, + strategy=strategy, + )) + except Exception as exc: + img_success = False + img_error_code = type(exc).__name__ + logger.error("generate_storyboard_images 套%s 失败: %s", strategy, exc) + image_results = [] + latency_ms = int((time.monotonic() - t0) * 1000) + + fail_count = 0 + first_img_error: str | None = None + for i, img_result in enumerate(image_results): + if img_result.get("error"): + fail_count += 1 + if first_img_error is None: + first_img_error = str(img_result["error"])[:32] + seq += 1 + push_fn(task.id, workspace_id, "batch_failed", { + "batch": img_result["role"], "reason": img_result["error"], + "strategy": strategy, "retryable": True, + }, seq) + continue + + raw_bytes = img_result["image_bytes"] + try: + processed = process_image(raw_bytes, aspect_ratio=aspect_ratio, resample_strength=1) + except Exception as e: + logger.warning("图片后处理失败,使用原图: %s", e) + processed = raw_bytes + + img_dir = os.path.join(upload_base_path, str(workspace_id), str(task.id)) + os.makedirs(img_dir, exist_ok=True) + filename = f"{strategy}_{i+1:02d}_{img_result['role']}.jpg" + img_path = os.path.join(img_dir, filename) + with open(img_path, "wb") as f: + f.write(processed) + + img_url = f"/uploads/{workspace_id}/{task.id}/{filename}" + + role_enum = IR.MAIN + try: + role_enum = IR(img_result["role"]) + except ValueError: + pass + + ic = ImageCandidate( + workspace_id=workspace_id, + task_id=task.id, + role=role_enum, + url=img_url, + seq=i + 1, + strategy=strategy, # 写入 A/B/C(非 hardcode) + ) + db.add(ic) + db.flush() + seq += 1 + push_fn(task.id, workspace_id, "image_candidate", { + "candidate_id": ic.id, "strategy": strategy, + "url": img_url, "role": img_result["role"], + }, seq) + seq += 1 + push_fn(task.id, workspace_id, "image_progress", { + "done": i + 1, "total": task.image_count, "strategy": strategy, + }, seq) + + # 写 ai_call_logs(每套一条,失败不阻断) + actual_provider = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt") + final_error_code = first_img_error or img_error_code + try: + img_log = AiCallLog( + workspace_id=workspace_id, + user_id=task.operator_id, + key_id=key_id, + task_id=task.id, + provider=actual_provider, + call_type="image", + success=(img_success and fail_count == 0), + latency_ms=latency_ms, + error_code=final_error_code, + ) + db.add(img_log) + db.flush() + except Exception as log_exc: + logger.warning("ai_call_logs(image) 套%s 写入失败(非阻断): %s", strategy, log_exc) + + db.commit() + return seq diff --git a/backend/app/workers/pipeline_steps.py b/backend/app/workers/pipeline_steps.py new file mode 100644 index 0000000..f1048c6 --- /dev/null +++ b/backend/app/workers/pipeline_steps.py @@ -0,0 +1,93 @@ +""" +app/workers/pipeline_steps.py — 生产链 Step1-4 +Step1: 查 DB(task/product) +Step2: 查 key → Fernet 解密(局部变量,不传出,基石B) +Step3: 构建 AI clients +Step4: 推 task_started SSE + 飞轮上下文 +""" + +import json +import logging + +logger = logging.getLogger(__name__) + + +def load_task_and_product(db, task_id: int): + """Step1: 查任务 + 产品,失败返 None 或抛异常。""" + from app.models.task import GenerationTask + from app.models.product import Product + + task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first() + if not task: + logger.error("task_id=%s not found", task_id) + return None, None + + product = db.query(Product).filter(Product.id == task.product_id).first() + if not product: + raise ValueError(f"product_id={task.product_id} not found") + return task, product + + +def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str: + """ + Step2: 查 key → Fernet 解密,返回 plain_key(只活在调用方局部变量)。 + 绝不打印、不持久化 plain_key(基石B)。 + """ + from app.models.workspace import UserApiKey + from app.utils.fernet_utils import decrypt_key + + api_key_row = db.query(UserApiKey).filter( + UserApiKey.user_id == operator_id, + UserApiKey.workspace_id == workspace_id, + UserApiKey.provider.in_(["openai", "apiports"]), # G6坑修复:接受主备通道名 + ).first() + if not api_key_row: + raise ValueError("用户未配置 API Key,请先录入") + return decrypt_key(api_key_row.encrypted_key) + + +def build_clients_and_clear_key(plain_key: str): + """ + Step3: 构建 AIClients,plain_key 传入后立即由调用方置 None。 + 返回 clients 对象。 + """ + from app.services.ai_engine.gemini_factory import build_ai_clients + return build_ai_clients(plain_key) + + +def build_product_dict(product) -> dict: + """把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。""" + return { + "name": product.name, + "category": product.category or "通用好物", + "selling_points": json.loads(product.selling_points or "[]"), + "style_tone": product.style_tone or "素人分享风", + "text_angles": json.loads(product.text_angles or "[]"), + "custom_prompt": product.custom_prompt or "", + "brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入) + "target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt + "image_path": product.image_path or "", # 产品参考图路径(前端上传后填入) + } + + +def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]: + """ + 查最近50条飞轮事件,聚合偏好上下文。 + 返回 (prompt_fragment, full_ctx)。 + """ + from app.models.flywheel import PreferenceEvent + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + + recent = db.query(PreferenceEvent).filter( + PreferenceEvent.workspace_id == workspace_id, + PreferenceEvent.product_id == product_id, + ).order_by(PreferenceEvent.id.desc()).limit(50).all() + + events_dicts = [ + {"signal_type": e.signal_type, "workspace_id": e.workspace_id, + "product_id": e.product_id, "angle_label": e.angle_label or "", + "signal_weight": e.signal_weight, "reason": e.reason or ""} + for e in recent + ] + ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id) + return ctx.get("prompt_fragment", ""), ctx diff --git a/backend/app/workers/replenish_task.py b/backend/app/workers/replenish_task.py new file mode 100644 index 0000000..895eacf --- /dev/null +++ b/backend/app/workers/replenish_task.py @@ -0,0 +1,100 @@ +""" +app/workers/replenish_task.py — 文案后台补充任务(S1: 先展示后台补铁律) + +合格文案(passed且score>=90且非hard_block)不足用户目标条数时, +run_generation_pipeline 已先展示合格的; 此任务异步补齐到目标数或达上限。 +只接收 task_id(基石B)。复用 pipeline_io.run_text_generation 的生成+过滤。 +""" + +import logging + +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + +# 补充上限:避免合格率低时无限补。最多补 MAX_REPLENISH_ROUNDS 轮。 +MAX_REPLENISH_ROUNDS = 3 + + +@celery_app.task( + bind=True, + name="app.workers.tasks.replenish_text_candidates", + max_retries=2, + default_retry_delay=30, + queue="generation", +) +def replenish_text_candidates(self, task_id: int) -> dict: + """后台补充合格文案到目标条数(或达补充上限)。 + + 每轮调用 run_text_generation 续生成,过滤后累加入库; + 已合格数 >= text_count 即停; 达 MAX_REPLENISH_ROUNDS 仍不足也停(不卡用户)。 + """ + from app.workers.tasks import _get_db, _push_event_sync + from app.workers.pipeline_io import run_text_generation + from app.workers.pipeline_steps import ( + load_task_and_product, + decrypt_user_key, + build_clients_and_clear_key, + build_product_dict, + load_flywheel_context, + ) + from app.models.task import TextCandidate + + db = _get_db() + try: + task, product = load_task_and_product(db, task_id) + if not task: + logger.warning("补充任务找不到 task_id=%s", task_id) + return {"task_id": task_id, "replenished": 0, "reason": "task_not_found"} + + # 已合格数(按入库的合格候选计) + existing = db.query(TextCandidate).filter( + TextCandidate.task_id == task_id + ).count() + target = task.text_count + if existing >= target: + return {"task_id": task_id, "replenished": 0, "reason": "already_enough"} + + workspace_id = task.workspace_id + plain_key = decrypt_user_key(db, task.operator_id, workspace_id) + clients = build_clients_and_clear_key(plain_key) + plain_key = None # 用完即清除,基石B + product_dict = build_product_dict(product) + flywheel_fragment, _ = load_flywheel_context( + db, workspace_id, task.product_id, product_dict + ) + + seq = 9000 # 补充事件 seq 段,避开主流程 + rounds = 0 + # 真实入库合格数以 DB count 为准,绝不用 len(raw)(那是原始生成数含被过滤的低分条)。 + # task47 实测踩坑:len(raw)=2 但 saved=0(都没过90),误判达标提前停,库里仍缺。 + current = existing + while current < target and rounds < MAX_REPLENISH_ROUNDS: + rounds += 1 + run_text_generation( + db, clients, task, product_dict, flywheel_fragment, + _push_event_sync, workspace_id, seq, + ) + # run_text_generation 内部已把本轮合格候选写库,这里重查真实库内条数 + db.expire_all() + current = db.query(TextCandidate).filter( + TextCandidate.task_id == task_id + ).count() + seq += 100 + logger.info( + "补充轮 %d: task_id=%s 库内合格=%d/%d", + rounds, task_id, current, target, + ) + + return { + "task_id": task_id, + "replenished": current - existing, + "rounds": rounds, + "final": current, + "target": target, + } + except Exception as exc: + logger.error("replenish_text_candidates failed: task_id=%s err=%s", task_id, exc) + raise self.retry(exc=exc) + finally: + db.close() diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py new file mode 100644 index 0000000..397a520 --- /dev/null +++ b/backend/app/workers/tasks.py @@ -0,0 +1,155 @@ +""" +app/workers/tasks.py — Celery 任务入口(编排层) +只接收 task_id(基石B)。具体步骤在 pipeline_steps / pipeline_io。 +打包任务在 packaging_task.py,此处 re-export 保持旧引用不变。 +""" + +import logging + +from app.workers.celery_app import celery_app +# re-export 保持旧代码 `from app.workers.tasks import build_delivery_package` 不变 +from app.workers.packaging_task import build_delivery_package # noqa: F401 +from app.workers.replenish_task import replenish_text_candidates # noqa: F401 + +logger = logging.getLogger(__name__) + + +def _get_db(): + """获取同步 DB Session(Celery worker 环境用同步)""" + from app.core.database import SessionLocal + return SessionLocal() + + +def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, seq: int): + """同步推 SSE 事件(worker 内部用)""" + import json as _json + import redis as redis_lib + from app.core.config import get_settings + s = get_settings() + r = redis_lib.from_url(s.REDIS_URL, decode_responses=True) + hist_key = f"sse:task:{task_id}:events" + channel = f"sse:task:{task_id}" + record = _json.dumps({"event": event, "data": data, "seq": seq, + "workspace_id": workspace_id}, ensure_ascii=False) + r.rpush(hist_key, record) + r.expire(hist_key, 3600) + r.publish(channel, record) + + +@celery_app.task( + bind=True, + name="app.workers.tasks.run_generation_pipeline", + max_retries=3, + default_retry_delay=30, + queue="generation", +) +def run_generation_pipeline(self, task_id: int) -> dict: + """ + 生产链主任务。只接收 task_id,绝不接收 key。 + 子步骤委托给 pipeline_steps(查库/解密/飞轮上下文) + 和 pipeline_io(文案/图片生成+存储)。 + """ + logger.info("run_generation_pipeline start: task_id=%s", task_id) + db = _get_db() + seq = 0 + workspace_id = 0 # G3坑修复:确保 except 里能用真实 workspace_id + + try: + from app.constants.enums import TaskStatus + from app.workers.pipeline_steps import ( + load_task_and_product, + decrypt_user_key, + build_clients_and_clear_key, + build_product_dict, + load_flywheel_context, + ) + from app.workers.pipeline_io import run_text_generation, run_image_generation + from app.core.config import get_settings + + # Step1: 查任务 + 产品 + task, product = load_task_and_product(db, task_id) + if not task: + return {"task_id": task_id, "status": "not_found"} + + workspace_id = task.workspace_id + + # Step2: Fernet 解密(局部变量,不传出) + plain_key = decrypt_user_key(db, task.operator_id, workspace_id) + + # Step3: 构建 AIClients + clients = build_clients_and_clear_key(plain_key) + plain_key = None # 用完即清除,基石B + + task.status = TaskStatus.GENERATING + db.commit() + + # Step4: 推 task_started + 飞轮上下文 + seq += 1 + _push_event_sync(task_id, workspace_id, "task_started", { + "task_id": task_id, "total_text": task.text_count, "total_image": task.image_count, + }, seq) + + product_dict = build_product_dict(product) + flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict) + + if flywheel_fragment: + seq += 1 + _push_event_sync(task_id, workspace_id, "flywheel_injected", { + "recent_preference": flywheel_ctx.get("recent_preference", ""), + "reject_reasons": flywheel_ctx.get("reject_reasons", []), + }, seq) + + # Step5: 文案生成(S1: 返回 needs_replenish=合格数<目标数,触发后台补充) + candidates_raw, seq, needs_replenish = run_text_generation( + db, clients, task, product_dict, flywheel_fragment, + _push_event_sync, workspace_id, seq, + ) + if needs_replenish: + # 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律) + # 注:candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数; + # 真实合格数以库内 saved_count 为准,补充子任务会按库内实际缺口补到够。 + logger.warning( + "文案合格数不足将后台补充: task_id=%s 本轮生成=%d 目标=%d", + task_id, len(candidates_raw), task.text_count, + ) + try: + replenish_text_candidates.delay(task_id) + except Exception as _re: + logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re) + + # Step6+7+8: 图片生成 + 后处理 + 存盘 + first_copy = candidates_raw[0] if candidates_raw else {} + settings = get_settings() + seq = run_image_generation( + db, clients, task, product_dict, + _push_event_sync, workspace_id, seq, + first_copy, settings.UPLOAD_BASE_PATH, + ) + + # 最终状态 + task_done + task.status = TaskStatus.PENDING_SELECTION + db.commit() + + seq += 1 + _push_event_sync(task_id, workspace_id, "task_done", { + "task_id": task_id, "status": "pending_selection" + }, seq) + + logger.info("run_generation_pipeline done: task_id=%s", task_id) + return {"task_id": task_id, "status": "pending_selection"} + + except Exception as exc: + logger.error("run_generation_pipeline failed: task_id=%s err=%s", task_id, exc) + try: + from app.models.task import GenerationTask as GT + from app.constants.enums import TaskStatus as TS + t = db.query(GT).filter(GT.id == task_id).first() + if t: + t.status = TS.PENDING + db.commit() + except Exception: + pass + _push_event_sync(task_id, workspace_id, "error", {"code": 50001, "message": str(exc)}, seq + 1) + raise self.retry(exc=exc) + finally: + db.close() diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..4a39f58 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,100 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + container_name: clover_api + ports: + - "8000:8000" + env_file: + - ./.env + environment: + - DATABASE_URL=mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DB} + - MONGO_URI=mongodb://mongo:27017/clover_trace + - REDIS_URL=redis://redis:6379/0 + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - .:/app + - uploads:/app/uploads + command: sh -c "alembic upgrade head && uvicorn main:app --host 0.0.0.0 --port 8000 --reload" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + worker: + build: + context: . + dockerfile: Dockerfile + container_name: clover_worker + env_file: + - ./.env + environment: + - DATABASE_URL=mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DB} + - MONGO_URI=mongodb://mongo:27017/clover_trace + - REDIS_URL=redis://redis:6379/0 + depends_on: + - mysql + - redis + volumes: + - .:/app + - uploads:/app/uploads + command: celery -A app.workers.celery_app worker -Q generation,packaging -c 2 -l info + + mysql: + image: mysql:8.0 + container_name: clover_mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DB} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + mongo: + image: mongo:7.0 + container_name: clover_mongo + ports: + - "27017:27017" + volumes: + - mongo_data:/data/db + + redis: + image: redis:7.4-alpine + container_name: clover_redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + + nginx: + image: nginx:1.27-alpine + container_name: clover_nginx + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + api: + condition: service_healthy + +volumes: + mysql_data: + mongo_data: + uploads: diff --git a/backend/fire_real.py b/backend/fire_real.py new file mode 100644 index 0000000..dcc817f --- /dev/null +++ b/backend/fire_real.py @@ -0,0 +1,16 @@ +"""真实数据端到端验证:倍分子素颜霜(用完即删)""" +from app.core.database import SessionLocal +from app.models.task import GenerationTask +from app.workers.tasks import run_generation_pipeline + +db = SessionLocal() +task = GenerationTask( + workspace_id=3, product_id=1, operator_id=4, + theme="倍分子素颜霜真实数据端到端", text_count=1, image_count=6, + track="ai", need_product_image=True, status="pending", +) +db.add(task); db.commit(); db.refresh(task) +tid = task.id; db.close() +print("NEW_TASK_ID=", tid) +run_generation_pipeline.delay(tid) +print("ENQUEUED") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..1ad3e67 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,95 @@ +""" +main.py — FastAPI 应用入口 +统一响应包络 / 全局异常处理 / 健康检查 / 路由注册 +""" + +import logging +import os + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from app.core.config import get_settings +from app.core.response import CloverHTTPException, ErrorCode + +logger = logging.getLogger(__name__) +settings = get_settings() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Clover API", + version="0.1.0", + docs_url="/docs" if settings.APP_ENV != "production" else None, + redoc_url=None, + ) + + # ── CORS ────────────────────────────────────────── + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 生产环境收窄到前端域名 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ── 全局异常处理(统一包络)────────────────────── + @app.exception_handler(CloverHTTPException) + async def clover_http_exception_handler(request: Request, exc: CloverHTTPException): + return JSONResponse( + status_code=exc.status_code, + content={"code": exc.biz_code, "message": exc.biz_message}, + ) + + @app.exception_handler(Exception) + async def generic_exception_handler(request: Request, exc: Exception): + logger.error("Unhandled exception: %s %s → %s", request.method, request.url, exc) + return JSONResponse( + status_code=500, + content={"code": ErrorCode.SERVER_ERROR, "message": "服务器内部错误"}, + ) + + # ── 健康检查(Docker 健康检查用)────────────────── + @app.get("/health") + async def health(): + return {"status": "ok"} + + # ── 路由注册(每个模块路由独立文件,这里汇总注册)─ + _register_routers(app) + + # ── 静态文件(G4坑修复:图片 /uploads 路由)────── + uploads_dir = os.path.join(os.path.dirname(__file__), "uploads") + os.makedirs(uploads_dir, exist_ok=True) + app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") + + return app + + +def _register_routers(app: FastAPI) -> None: + from app.api.v1.auth import router as auth_router + from app.api.v1.api_keys import router as api_keys_router + from app.api.v1.products import router as products_router + from app.api.v1.benchmarks import router as benchmarks_router + from app.api.v1.tasks import router as tasks_router + from app.api.v1.task_actions import router as task_actions_router + from app.api.v1.review import router as review_router + from app.api.v1.delivery import router as delivery_router + from app.api.v1.stream import router as stream_router + from app.api.v1.workspaces import router as workspaces_router + + prefix = "/api/v1" + app.include_router(auth_router, prefix=prefix) + app.include_router(api_keys_router, prefix=prefix) + app.include_router(products_router, prefix=prefix) + app.include_router(benchmarks_router, prefix=prefix) + app.include_router(tasks_router, prefix=prefix) + app.include_router(task_actions_router, prefix=prefix) + app.include_router(review_router, prefix=prefix) + app.include_router(delivery_router, prefix=prefix) + app.include_router(stream_router, prefix=prefix) + app.include_router(workspaces_router, prefix=prefix) + + +app = create_app() diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf new file mode 100644 index 0000000..55e46d2 --- /dev/null +++ b/backend/nginx/nginx.conf @@ -0,0 +1,36 @@ +# nginx/nginx.conf — Clover 反向代理配置 +# SSE 端点需关闭 proxy_buffering 才能实时透传 + +server { + listen 80; + server_name _; + + # ── SSE 端点:关闭缓冲,实时透传 ────────────────── + location ~* /api/v1/tasks/[^/]+/stream { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Authorization $http_authorization; + } + + # ── 普通 API 端点 ────────────────────────────────── + location /api/ { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization $http_authorization; + client_max_body_size 20m; + } + + # ── 健康检查 ─────────────────────────────────────── + location /health { + proxy_pass http://api:8000/health; + } +} diff --git a/backend/plans/prompt缩水诊断与补齐.md b/backend/plans/prompt缩水诊断与补齐.md new file mode 100644 index 0000000..58771ac --- /dev/null +++ b/backend/plans/prompt缩水诊断与补齐.md @@ -0,0 +1,35 @@ +# 生图/文案 Prompt 缩水诊断 + 补齐方案 + +> 倩倩姐 2026-06-08 提出:图质量还行但没用小红书风格 prompt,怀疑缩水。 +> 经逐函数对照产品包上线版(`万牛会L1准备/worker` src/copy.js + image.js),确认**确实缩水**。 +> banana 未使用(符合铁律:扒上线版 worker)。 + +## 一、结论先行 + +我之前扒的是**分镜骨架**(哪张图什么角色),漏了源头让内容"像小红书"的 prompt 内核。 +文案侧、生图侧**都缩水了**。源头 image.js 871行 / copy.js 690行,我的对应文件只有 203 / 201 行。 + +## 二、生图侧缩水清单(对照 image.js) + +| # | 源头有 | 位置 | 我的现状 | 影响 | +|---|--------|------|---------|------| +| G1 | STYLE_PROMPTS 风格预设(种草风/对比风/成分科普风) | image.js:26-29 | ❌ 完全没有 | 图不分风格,全一个样 | +| G2 | 叙事链路描述("6张标准种草链路:点击/痛点/核心证明…") | image.js:678-682 | ❌ 没注入 | 模型不知整组叙事逻辑 | +| G3 | 短卖点 + 短标签注入图 prompt | image.js:684-686 | ❌ 没注入 | 图与卖点脱节 | +| G4 | promptFromStoryboard 逐图完整字段(卖点/文案依据/画面主体/素材使用/禁止/排版要求) | image.js:323-334 | ⚠️ 只拼了角色+目标+主文字 | 每张图约束大幅缩水 | +| G5 | buildImageStoryboard 每角色细粒度 goal/overlay/assetUse/forbidden | image.js:222-322 | ⚠️ 部分(proof_strategy有,逐角色细节缺) | 角色指导粗糙 | +| G6 | compose_image_prompt 仍是 TODO 占位 | 我方 prompt_composer.py:84-109 | ❌ 死代码 | 入口没接通 | + +## 三、文案侧缩水清单(对照 copy.js) + +| # | 源头有 | 位置 | 我的现状 | 影响 | +|---|--------|------|---------|------| +| C1 | COPY_SYSTEM "小红书7步SOP+标题公式+卖点翻买点" | copy.js:10-30 | ⚠️ 我有自写版(佛系人设),非源头方法论 | 方法论不同源 | +| C2 | buildGenerationPrompt 候选池超量生成(targetCount*4)再筛 | copy.js:520-560 | ❌ 没有候选池超量机制 | 出稿多样性弱 | +| C3 | angleSlots 角度槽位(按品类分配,强制N条不同策略) | copy.js | ⚠️ 我有变量池ABC,非角度槽位 | 角度覆盖机制不同 | +| C4 | 避开历史标题/角度(previousCopies 注入) | copy.js:543-548 | ❌ 没有(飞轮有偏好但无"避重") | 重生成可能撞稿 | +| C5 | imageBrief/content 分离硬约束(配图建议不进正文) | copy.js:18 | ⚠️ 字段有,无强约束 | 正文可能混入执行提示 | + +## 四、待补范围(倩倩姐拍板) + +见下方 ExitPlanMode。 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d856528 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,38 @@ +# requirements.txt — Clover 后端依赖(pinned versions) +# ── Web 框架 ────────────────────────────────────────── +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +pydantic==2.10.3 +pydantic-settings==2.6.1 + +# ── 数据库 ─────────────────────────────────────────── +sqlalchemy==2.0.36 +alembic==1.14.0 +pymysql==1.1.1 +cryptography==44.0.0 + +# ── MongoDB ────────────────────────────────────────── +motor==3.6.0 + +# ── Redis + Celery ─────────────────────────────────── +redis==5.2.1 +celery==5.4.0 + +# ── JWT + 安全 ─────────────────────────────────────── +pyjwt==2.10.1 +# passlib 不带 [bcrypt] extra:该 extra 会拉最新 bcrypt(5.x),覆盖下面的 pin, +# 而 passlib 1.7.4 与 bcrypt 5.x 不兼容(password cannot be longer than 72 bytes)。 +# 显式锁 bcrypt 4.0.1。 +passlib==1.7.4 +bcrypt==4.0.1 + +# ── HTTP 客户端(AI 调用)──────────────────────────── +httpx==0.28.1 +aiohttp==3.11.10 + +# ── 图像处理(去水印)────────────────────────────── +pillow==11.0.0 + +# ── 工具 ───────────────────────────────────────────── +python-multipart==0.0.19 +python-dotenv==1.0.1 diff --git a/backend/scripts/real_image_check.py b/backend/scripts/real_image_check.py new file mode 100644 index 0000000..8cd8021 --- /dev/null +++ b/backend/scripts/real_image_check.py @@ -0,0 +1,103 @@ +"""真实出图验证(正式留存版,不带_tmp,勿当临时文件清理) +用法: cd Clover/backend && python3 scripts/real_image_check.py +key 只从 ../.env 读,不硬编码不打印明文。 +""" +from __future__ import annotations +import asyncio, base64, io, logging, os, sys, time +from pathlib import Path +import httpx + +ROOT = Path(__file__).resolve().parents[2] +_env = ROOT / ".env" +if _env.exists(): + for _l in _env.read_text().splitlines(): + _l = _l.strip() + if _l and not _l.startswith("#") and "=" in _l: + k, _, v = _l.partition("=") + os.environ.setdefault(k.strip(), v.strip()) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("img") + +TEST_PROMPT = "\n".join([ + "生成一张小红书可上传的独立竖版3:4图文海报,目标1024x1536,不是App截图。", + "产品:倍分子素颜霜(美妆护肤·素颜霜)。", + "画面角色:封面-钩子。画面主体:自然光生活场景,手持素颜霜或产品在桌面前景,像iPhone实拍封面。", + "图上文字:主标题「黄黑皮逆袭|伪素颜天花板」,点位「黄黑皮也能拥有的妈生好皮」。", + "核心卖点:黄黑皮提亮、伪素颜自然、不假白不卡纹、洗面奶就能卸、懒人必备。", + "成分:烟酰胺(改善暗沉)、水解珍珠(锁水保湿)、角鲨烷(不卡纹)。", + "视觉风格:小红书种草风,明亮干净,暖色调,模拟iPhone主摄浅景深。", + "重要限制:禁止生成App截图/界面元素;禁止肤色变白/功效前后对比;" + "避免美白/祛斑/速效/医用等违规词;必须保持产品包装与参考图一致。", +]) + +_REF = ROOT / "test/output/01_连通测试.png" + + +async def _apiports(key, url, ref, model, size): + payload = {"model": model, "prompt": TEST_PROMPT, "n": 1, "size": size, + "image": base64.b64encode(ref).decode()} + async with httpx.AsyncClient(timeout=300.0) as c: + r = await c.post(url, json=payload, headers={"Authorization": f"Bearer {key}"}) + if r.status_code != 200: + raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}") + return _parse(r.json()) + + +async def _codeproxy(key, base, ref, model, size): + files = [("model", (None, model)), ("prompt", (None, TEST_PROMPT)), + ("size", (None, size)), ("n", (None, "1")), + ("image[]", ("ref.png", io.BytesIO(ref), "image/png"))] + async with httpx.AsyncClient(timeout=300.0) as c: + r = await c.post(f"{base}/images/edits", files=files, + headers={"Authorization": f"Bearer {key}"}) + if r.status_code != 200: + raise RuntimeError(f"HTTP {r.status_code}: {r.text[:200]}") + return _parse(r.json()) + + +def _parse(j): + item = (j.get("data") or [{}])[0] + if "b64_json" in item: + return base64.b64decode(item["b64_json"]) + if "url" in item: + rr = httpx.get(item["url"], timeout=30.0); rr.raise_for_status() + return rr.content + raise ValueError(f"无法解析: {list(item.keys())}") + + +async def run(): + model = os.environ.get("MODEL_IMAGE", "gpt-image-2") + size = os.environ.get("IMAGE_SIZE", "1024x1536") + primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "apiports").lower() + fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "codeproxy").lower() + out_dir = Path(__file__).parent.parent / "verify_output" + out_dir.mkdir(parents=True, exist_ok=True) + ref = _REF.read_bytes() + t0 = time.time(); img = None; used = None + for prov in [primary, fallback]: + key = os.environ.get(f"{prov.upper()}_KEY", "") + url = os.environ.get(f"{prov.upper()}_BASE_URL", "") + if not key or not url: + continue + logger.info("[%s] 出图中 model=%s size=%s", prov, model, size) + try: + img = await (_apiports(key, url, ref, model, size) if prov == "apiports" + else _codeproxy(key, url, ref, model, size)) + used = prov; break + except Exception as e: + logger.error("[%s] 失败: %s", prov, e) + dt = time.time() - t0 + print("\n===== 出图结果 =====") + if img: + out = out_dir / "素颜霜_封面hook.png" + out.write_bytes(img) + print(f" provider: {used}\n 耗时: {dt:.1f}s\n 大小: {len(img)//1024}KB\n 路径: {out}") + else: + print(f" 耗时: {dt:.1f}s\n 状态: 全失败") + print("====================\n") + return img is not None + + +if __name__ == "__main__": + sys.exit(0 if asyncio.run(run()) else 1) diff --git a/backend/scripts/run_log.txt b/backend/scripts/run_log.txt new file mode 100644 index 0000000..beef5a6 --- /dev/null +++ b/backend/scripts/run_log.txt @@ -0,0 +1,13 @@ +2026-06-09 11:39:33,251 INFO [apiports] 出图中 model=gpt-image-2 size=1024x1536 +2026-06-09 11:41:04,233 INFO HTTP Request: POST https://www.apiports.com/v1/api/generate "HTTP/1.1 200 OK" +2026-06-09 11:41:04,236 ERROR [apiports] 失败: 无法解析: [] +2026-06-09 11:41:04,236 INFO [codeproxy] 出图中 model=gpt-image-2 size=1024x1536 +2026-06-09 11:42:33,485 INFO HTTP Request: POST https://codeproxy.dev/v1/images/edits "HTTP/1.1 200 OK" + +===== 出图结果 ===== + provider: codeproxy + 耗时: 181.2s + 大小: 2009KB + 路径: /Users/qiyu/Documents/企业培训项目/万牛会L1准备/北哥小红书产品/Clover/backend/verify_output/素颜霜_封面hook.png +==================== + diff --git a/backend/scripts/seed_data.py b/backend/scripts/seed_data.py new file mode 100644 index 0000000..cec43a8 --- /dev/null +++ b/backend/scripts/seed_data.py @@ -0,0 +1,156 @@ +""" +scripts/seed_data.py — 初始化北哥 workspace 种子数据 +建3账号(admin/supervisor/operator) + workspace_members + 预置素颜霜 + 5条违禁词 +幂等:已存在则跳过,可重复跑。 +用法:cd backend && python3 scripts/seed_data.py +""" +import json +import os +import sys + +# 把 backend/ 加入 path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.core.config import get_settings # noqa: E402 — path must be set first +from app.core.database import SessionLocal # noqa: E402 +from app.models.user import User # noqa: E402 +from app.models.workspace import Workspace, WorkspaceMember # noqa: E402 +from app.models.product import Product, BannedWord # noqa: E402 +from app.services.auth_service import hash_password # noqa: E402 + +get_settings() # 触发 .env 校验,缺变量早报错 + + +WORKSPACE_NAME = "北哥小红书车间" +WORKSPACE_SLUG = "beige-xhs" + +USERS = [ + {"username": "admin", "email": "admin@clover.local", "password": "Clover2026!", "role": "admin"}, + {"username": "supervisor", "email": "supervisor@clover.local", "password": "Clover2026!", "role": "supervisor"}, + {"username": "operator", "email": "operator@clover.local", "password": "Clover2026!", "role": "operator"}, +] + +PRODUCT = { + "name": "素颜霜(预置样本)", + "category": "素颜霜", + "source": "preset", + "selling_points": json.dumps(["遮瑕提亮", "持妆", "养肤成分"], ensure_ascii=False), + "style_tone": "素人分享、真实不浮夸", + "text_angles": json.dumps(["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"], ensure_ascii=False), + "custom_prompt": None, + "is_active": True, +} + +BANNED_WORDS = [ + {"word": "美白", "level": "auto_fix", "replacement": "提亮肤色"}, + {"word": "祛斑", "level": "auto_fix", "replacement": "改善暗沉"}, + {"word": "速效", "level": "soft_warn", "replacement": "温和渐进"}, + {"word": "医用", "level": "hard_block", "replacement": None}, + {"word": "药妆", "level": "hard_block", "replacement": None}, +] + + +def run(): + db = SessionLocal() + try: + # ── workspace ────────────────────────────────────── + ws = db.query(Workspace).filter(Workspace.slug == WORKSPACE_SLUG).first() + if not ws: + ws = Workspace(name=WORKSPACE_NAME, slug=WORKSPACE_SLUG, is_active=True) + db.add(ws) + db.flush() + print(f"[+] workspace 已建: {ws.name} (id={ws.id})") + else: + print(f"[=] workspace 已存在: id={ws.id}") + + # ── users + members ──────────────────────────────── + for u_spec in USERS: + user = db.query(User).filter(User.username == u_spec["username"]).first() + if not user: + user = User( + username=u_spec["username"], + email=u_spec["email"], + hashed_password=hash_password(u_spec["password"]), + is_active=True, + ) + db.add(user) + db.flush() + print(f"[+] user: {user.username}") + else: + print(f"[=] user 已存在: {user.username}") + + mem = db.query(WorkspaceMember).filter( + WorkspaceMember.workspace_id == ws.id, + WorkspaceMember.user_id == user.id, + ).first() + if not mem: + db.add(WorkspaceMember(workspace_id=ws.id, user_id=user.id, role=u_spec["role"])) + print(f" -> 绑定 role={u_spec['role']}") + + # ── 预置素颜霜 ───────────────────────────────────── + prod = db.query(Product).filter( + Product.workspace_id == ws.id, Product.name == PRODUCT["name"] + ).first() + if not prod: + prod = Product(workspace_id=ws.id, **PRODUCT) + db.add(prod) + print(f"[+] product: {prod.name}") + else: + print(f"[=] product 已存在: {prod.name}") + + # ── 违禁词 ───────────────────────────────────────── + for bw_spec in BANNED_WORDS: + existing = db.query(BannedWord).filter( + BannedWord.workspace_id == ws.id, BannedWord.word == bw_spec["word"] + ).first() + if not existing: + db.add(BannedWord(workspace_id=ws.id, **bw_spec)) + print(f"[+] banned_word: {bw_spec['word']} ({bw_spec['level']})") + else: + print(f"[=] banned_word 已存在: {bw_spec['word']}") + + # ── user_api_keys(R3:明文key只在加密瞬间用,不打印不落明文)── + apiports_key = os.environ.get("APIPORTS_KEY", "").strip() + if apiports_key: + from app.models.workspace import UserApiKey + from app.utils.fernet_utils import encrypt_key + for u_spec in USERS: + u = db.query(User).filter(User.username == u_spec["username"]).first() + if not u: + continue + existing_key = db.query(UserApiKey).filter( + UserApiKey.user_id == u.id, + UserApiKey.workspace_id == ws.id, + UserApiKey.provider == "apiports", + ).first() + encrypted = encrypt_key(apiports_key) + last4 = apiports_key[-4:] if len(apiports_key) >= 4 else "****" + if existing_key: + existing_key.encrypted_key = encrypted + existing_key.key_last4 = last4 + print(f"[=] api_key updated: {u.username} apiports ***{last4}") + else: + db.add(UserApiKey( + user_id=u.id, + workspace_id=ws.id, + provider="apiports", + encrypted_key=encrypted, + key_last4=last4, + )) + print(f"[+] api_key: {u.username} apiports ***{last4}") + apiports_key = None # 明文用完即清(基石B) + else: + print("[!] APIPORTS_KEY 未设置,跳过 user_api_keys 录入(部署时需手动录入或重跑种子)") + + db.commit() + print("\n种子数据初始化完成。") + except Exception as e: + db.rollback() + print(f"[ERROR] {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + run() diff --git a/backend/scripts/verify_real_text_gen.py b/backend/scripts/verify_real_text_gen.py new file mode 100644 index 0000000..ec93fb1 --- /dev/null +++ b/backend/scripts/verify_real_text_gen.py @@ -0,0 +1,93 @@ +""" +scripts/verify_real_text_gen.py — 真实文案生成验收脚本 +调 LLM 用素颜霜产品数据生成3条,原文打印给北哥人工核查。 +用法:cd backend && python3 scripts/verify_real_text_gen.py +读取 .env 里的 CODEPROXY_BASE_URL + CODEPROXY_KEY(中转站) +""" +import json +import os +import sys +import httpx +from pathlib import Path +from dotenv import load_dotenv + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 加载 .env +env_path = Path(__file__).parent.parent / ".env" +load_dotenv(env_path) + +from app.services.ai_engine._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array + +# 素颜霜产品数据(方法验证用,不依赖 DB) +PRODUCT = { + "name": "素颜霜", + "category": "美妆护肤", + "selling_points": ["遮瑕提亮", "持妆全天", "养肤成分温和"], + "style_tone": "素人分享、真实不浮夸", + "text_angles": ["痛点切入", "成分党", "使用场景", "质地肤感", "平价替代"], + "brand_keyword": "XX素颜霜", + "custom_prompt": "", +} + +COUNT = 3 + + +def main(): + # 主通道 apiports(实测真实可用,模型见 /v1/models) + base_url = (os.environ.get("APIPORTS_BASE_URL") or "").rstrip("/") + api_key = os.environ.get("APIPORTS_KEY", "") + # apiports 无 gpt-4o-mini,文案用 claude-sonnet-4-5(中文质量好) + model = os.environ.get("MODEL_TEXT", "claude-sonnet-4-5") + + if not base_url or not api_key: + print("[ERROR] .env 缺少 APIPORTS_BASE_URL 或 APIPORTS_KEY") + sys.exit(1) + + user_prompt = build_prompt(PRODUCT, COUNT) + + print("=" * 60) + print("SYSTEM PROMPT(前300字):") + print(COPY_SYSTEM[:300], "...") + print("=" * 60) + print("USER PROMPT(完整):") + print(user_prompt) + print("=" * 60) + print(f"调用 {base_url} 模型={model} 生成 {COUNT} 条文案...") + + resp = httpx.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json={ + "model": model, + "messages": [ + {"role": "system", "content": COPY_SYSTEM}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.9, + "max_tokens": 3000, + }, + timeout=120, + ) + resp.raise_for_status() + + raw = resp.json()["choices"][0]["message"]["content"] + print("\n===== 原始输出(供北哥核查)=====") + print(raw) + + copies = parse_json_array(raw) + if copies: + print(f"\n===== 解析成功,共 {len(copies)} 条 =====") + for i, c in enumerate(copies): + print(f"\n--- 第{i+1}条 [{c.get('angle','')}] ---") + print(f"标题:{c.get('title')}") + print(f"正文({len(c.get('content',''))}字):\n{c.get('content')}") + print(f"标签:{c.get('tags')}") + print(f"封面大字:{c.get('coverTitle')}") + else: + print("\n[WARN] JSON 解析失败,请人工看原始输出") + + +if __name__ == "__main__": + main() + diff --git a/backend/test_3sets_e2e.py b/backend/test_3sets_e2e.py new file mode 100644 index 0000000..0879bcc --- /dev/null +++ b/backend/test_3sets_e2e.py @@ -0,0 +1,26 @@ +"""Task#6 端到端真跑:3套正交配图。建真实task(product_id=1倍分子素颜霜)。 +image_count=2 → 每套2张(hook+product_closeup特写) ×3套 = 6张。 +验:①strategy=A/B/C各2张 ②尺寸1024×1536 ③品牌词进特写图 ④三套图真不同。""" +import sys +sys.path.insert(0, "/app") + +from app.core.database import SessionLocal +from app.models.task import GenerationTask +from app.constants.enums import TaskStatus + +db = SessionLocal() +t = GenerationTask( + workspace_id=3, product_id=1, operator_id=3, + theme="伪素颜·黄黑皮提亮·3套正交", + text_count=1, image_count=2, track="ai", + need_product_image=True, status=TaskStatus.PENDING, +) +db.add(t); db.commit(); db.refresh(t) +task_id = t.id +print(f"建task成功 task_id={task_id} image_count=2 (期望3套×2=6张)") +db.close() + +from app.workers.tasks import run_generation_pipeline +print(f"=== 同步执行 run_generation_pipeline({task_id}) ===") +result = run_generation_pipeline.apply(args=[task_id]).get() +print(f"=== 结果: {result} ===") diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3bf608e --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,56 @@ +""" +conftest.py — 测试环境 stub +在任何模块 import 之前注入环境变量,让 Settings 校验通过。 +只用于本地单元/集成测试,不影响生产。 +""" +import os +import sys +import types + +# ── 1. 注入最小可用配置(全假值,只让 Settings 构建通过)──────── +_TEST_ENV = { + "FERNET_KEY": "test-fernet-key-at-least-32-chars-long!!", + "JWT_SECRET": "test-jwt-secret-at-least-32-chars-long!!", + "DATABASE_URL": "mysql+pymysql://test:test@localhost/test", + "MONGO_URI": "mongodb://localhost:27017/test", + "REDIS_URL": "redis://localhost:6379/0", + "APP_ENV": "test", +} +for k, v in _TEST_ENV.items(): + os.environ.setdefault(k, v) + +# ── 2. 清掉 lru_cache,让 Settings 用刚注入的 env vars 重建 ───── +from app.core import config as _cfg +_cfg.get_settings.cache_clear() + +# ── 3. Stub motor(MongoDB client,本地没装)────────────────────── +if "motor" not in sys.modules: + motor_mod = types.ModuleType("motor") + motor_async = types.ModuleType("motor.motor_asyncio") + motor_async.AsyncIOMotorClient = object + sys.modules["motor"] = motor_mod + sys.modules["motor.motor_asyncio"] = motor_async + +# ── 4. Stub database engine(MySQL URL 但不连接)────────────────── +# database.py 顶层调 create_engine,pytest 环境没有 MySQL。 +# 只 stub engine 和 SessionLocal,不影响任何逻辑测试。 +import unittest.mock as _mock +import sqlalchemy + +_fake_engine = _mock.MagicMock() +_fake_session_class = _mock.MagicMock() +_fake_session_class.return_value.__enter__ = _mock.MagicMock(return_value=_mock.MagicMock()) +_fake_session_class.return_value.__exit__ = _mock.MagicMock(return_value=False) + +# 只在 database 模块还未 import 时 patch;若已 import 则直接替换属性 +if "app.core.database" not in sys.modules: + with _mock.patch("sqlalchemy.create_engine", return_value=_fake_engine): + with _mock.patch("sqlalchemy.orm.sessionmaker", return_value=_fake_session_class): + import app.core.database as _db_mod +else: + _db_mod = sys.modules["app.core.database"] + +# 确保 engine/SessionLocal 是 mock,防止实际连接 +_db_mod.engine = _fake_engine +_db_mod.SessionLocal = _fake_session_class + diff --git a/backend/tests/test_ai_engine.py b/backend/tests/test_ai_engine.py new file mode 100644 index 0000000..bbbd5ed --- /dev/null +++ b/backend/tests/test_ai_engine.py @@ -0,0 +1,253 @@ +""" +AI 引擎单元测试(核心逻辑覆盖) +对照 JS 版逻辑验证 Python 重写正确性 +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from app.services.ai_engine.text_scoring import score_copy, is_similar_copy, dedupe_copies +from app.services.ai_engine.banned_word_checker import check_and_fix, BannedWordEntry +from app.services.ai_engine.storyboard import get_narrative_roles, plan_image_set, clamp_count +from app.services.ai_engine.preference_aggregator import aggregate_preference_context, collect_preference_event +from app.services.ai_engine.prompt_composer import ( + compose_variants, compose_preference_context, compose_image_prompt, parse_model_output +) + +# ── 测试用 product ───────────────────────────────────────── +PRODUCT = { + "name": "倍分子素颜霜", + "category": "美妆护肤", + "selling_points": ["轻薄不厚重", "水润自然", "不卡粉"], + "keywords": ["素颜霜", "日常通勤"], + "style_tone": "素人分享风", + "text_angles": ["痛点切入", "场景型", "避坑型"], + "custom_prompt": "", + "target_audience": "上班族", +} + +GOOD_COPY = { + "title": "早八素颜也能有点气色!✨", + "content": "姐妹们,最近实测下来,轻薄不厚重是我最在意的。✅ 通勤路上随手一抹,水润自然有气色。不卡粉这点太加分了。整体就是那种上班族能马上代入的日常好物。", + "tags": ["#素颜霜", "#日常通勤", "#好物分享", "#真实测评"], + "angle": "场景型", + "buyingPoint": "轻薄不厚重,适合上班族", + "coverTitle": "早八素颜也能有点气色", + "imageBrief": "封面自然光上脸局部,内页质地推开+软性转化。", + "source": "ai", +} + + +# ── 五维打分 ─────────────────────────────────────────────── +class TestScoreCopy: + def test_good_copy_passes(self): + result = score_copy(GOOD_COPY, PRODUCT) + assert result["passed"] is True + assert result["score"] >= 90 + + def test_banned_word_kills_compliance(self): + bad = {**GOOD_COPY, "title": "美白素颜霜强推!", "content": GOOD_COPY["content"] + "美白效果显著"} + result = score_copy(bad, PRODUCT) + # 含美白 → 合规0分 + 总分不过 + assert result["passed"] is False + compliance = next(d for d in result["score_detail"] if d["item"] == "合规性") + assert compliance["score"] == 0 + + def test_score_detail_has_five_dims(self): + result = score_copy(GOOD_COPY, PRODUCT) + assert len(result["score_detail"]) == 5 + items = {d["item"] for d in result["score_detail"]} + assert "标题吸引力" in items + assert "情绪共鸣" in items + assert "买点表达" in items + assert "关键词覆盖" in items + assert "合规性" in items + + +# ── 去重 ─────────────────────────────────────────────────── +class TestDedup: + def test_identical_title_deduped(self): + a = {**GOOD_COPY} + b = {**GOOD_COPY, "content": "略有不同的内容,但标题一样"} + result = dedupe_copies([a, b]) + assert len(result) == 1 + + def test_different_angle_both_kept(self): + a = {**GOOD_COPY, "angle": "痛点切入"} + b = {**GOOD_COPY, "title": "通勤懒人必囤!换季不再干脸", "angle": "避坑型"} + result = dedupe_copies([a, b]) + assert len(result) == 2 + + def test_similar_body_deduped(self): + a = {**GOOD_COPY} + b = {**GOOD_COPY, "title": "不一样的标题", "content": GOOD_COPY["content"][:150] + "稍有变动"} + assert is_similar_copy(a, b) or len(dedupe_copies([a, b])) <= 2 + + +# ── 违禁词三级 ────────────────────────────────────────────── +class TestBannedWordChecker: + def test_hard_block(self): + # "美白"已改为 auto_fix(提亮肤色感),hard_block 用速效/医用等词 + result = check_and_fix("速效医用护肤品") + assert result.status == "hard_block" + + def test_meibaibanned_now_auto_fix(self): + # Q5对齐:美白→auto_fix 提亮肤色感(不再 hard_block) + result = check_and_fix("这款美白效果很好") + assert result.status == "auto_fixed" + assert result.fixed_text is not None + assert "美白" not in result.fixed_text + + def test_auto_fix(self): + result = check_and_fix("这款神器真的好用") + assert result.status == "auto_fixed" + assert result.fixed_text is not None + assert "神器" not in result.fixed_text + + def test_soft_warn(self): + result = check_and_fix("这绝对是最好的护肤品") + assert result.status == "soft_warn" + + def test_clean_text_passes(self): + result = check_and_fix("这款素颜霜轻薄水润,通勤必备。") + assert result.status == "pass" + + def test_custom_entries_override(self): + entries = [BannedWordEntry("必备", "hard_block")] + result = check_and_fix("通勤必备好物", entries) + assert result.status == "hard_block" + + +# ── storyboard ───────────────────────────────────────────── +class TestStoryboard: + def test_clamp_count(self): + assert clamp_count(0) == 1 + assert clamp_count(9) == 8 + assert clamp_count(3) == 3 + + def test_narrative_roles_3(self): + roles = get_narrative_roles(3) + assert len(roles) == 3 + assert roles[0]["role"] == "hook" + assert roles[-1]["role"] == "closer" + + def test_narrative_roles_6(self): + roles = get_narrative_roles(6) + assert len(roles) == 6 + # Q6对齐北哥套路:①hook ②product_closeup(单品特写) ③ingredient ④texture ⑤applied_proof ⑥closer + assert roles[0]["role"] == "hook" + assert roles[1]["role"] == "product_closeup" + assert roles[2]["role"] == "ingredient" + assert roles[4]["role"] == "applied_proof" + assert roles[5]["role"] == "closer" + + def test_narrative_roles_8(self): + roles = get_narrative_roles(8) + assert len(roles) == 8 + + def test_plan_image_set_structure(self): + plan = plan_image_set(GOOD_COPY, PRODUCT, image_count=3) + assert "storyboard" in plan + assert "base_prompt" in plan + assert len(plan["storyboard"]) == 3 + # 产品图锚点说明必须在 base_prompt 中 + assert "不可修改" in plan["base_prompt"] + + +# ── 飞轮聚合 ─────────────────────────────────────────────── +class TestPreferenceAggregator: + def test_cold_start_when_few_events(self): + result = aggregate_preference_context([], PRODUCT, workspace_id=1, product_id=1) + assert result["injected_count"] == 0 + assert "冷启动" in result["recent_preference"] + + def test_aggregate_top_angles(self): + events = [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""}, + {"signal_type": "approve", "workspace_id": 1, "product_id": 1, "angle_label": "场景型", "signal_weight": 5, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "痛点切入", "signal_weight": 3, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, "angle_label": "避坑型", "signal_weight": 3, "reason": ""}, + ] + result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1) + assert result["injected_count"] == 6 + assert "痛点切入" in result["recent_preference"] + assert "偏好角度" in result["prompt_fragment"] + + def test_reject_reason_in_prompt(self): + events = [ + {"signal_type": "reject_with_reason", "workspace_id": 1, "product_id": 1, + "angle_label": "", "signal_weight": -3, "reason": "标题太硬广"}, + *[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, + "angle_label": "痛点切入", "signal_weight": 3, "reason": ""} for _ in range(5)], + ] + result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1) + assert "标题太硬广" in result["prompt_fragment"] + + def test_product_isolation(self): + """不同 product_id 的事件不会混进来""" + events = [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 2, + "angle_label": "成分党", "signal_weight": 3, "reason": ""}, + *[{"signal_type": "text_select", "workspace_id": 1, "product_id": 1, + "angle_label": "场景型", "signal_weight": 3, "reason": ""} for _ in range(5)], + ] + result = aggregate_preference_context(events, PRODUCT, workspace_id=1, product_id=1) + assert "成分党" not in result["prompt_fragment"] + + def test_collect_event_structure(self): + event = collect_preference_event("text_select", user_id=1, workspace_id=1, product_id=1, angle_label="痛点切入") + assert event["signal_weight"] == 3 + assert event["data_ownership"] == "client_data" + + +# ── prompt_composer ──────────────────────────────────────── +class TestPromptComposer: + def test_compose_variants_returns_two_strings(self): + sys_p, user_p = compose_variants(PRODUCT, count=5) + assert isinstance(sys_p, str) and len(sys_p) > 0 + assert isinstance(user_p, str) and len(user_p) > 0 + + def test_compose_variants_includes_product_name(self): + _, user_p = compose_variants(PRODUCT, count=3) + assert "倍分子素颜霜" in user_p + + def test_compose_variants_includes_count(self): + _, user_p = compose_variants(PRODUCT, count=7) + assert "7" in user_p + + def test_compose_variants_flywheel_injected(self): + fragment = "偏好角度参考:场景型、痛点切入" + _, user_p = compose_variants(PRODUCT, count=3, flywheel_context=fragment) + assert "场景型" in user_p + + def test_compose_preference_context_delegates(self): + # compose_preference_context 应委托给 aggregate_preference_context + events = [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 1, + "angle_label": "场景型", "signal_weight": 3, "reason": ""} + for _ in range(6) + ] + result = compose_preference_context(events, PRODUCT, workspace_id=1, product_id=1) + assert "injected_count" in result + assert result["injected_count"] == 6 + assert "prompt_fragment" in result + + def test_parse_model_output_valid_json(self): + raw = '[{"title":"标题1","content":"正文","tags":[],"angle":"场景型"}]' + parsed = parse_model_output(raw) + assert len(parsed) == 1 and parsed[0]["title"] == "标题1" + + def test_parse_model_output_markdown_wrapped(self): + raw = "```json\n[{\"title\":\"a\",\"content\":\"b\",\"tags\":[]}]\n```" + parsed = parse_model_output(raw) + assert len(parsed) == 1 + + def test_compose_image_prompt_includes_role_and_product(self): + vs = {"style": "ins摆拍风", "color_palette": "米白+杏色", "base_prompt": "产品近景"} + prompt = compose_image_prompt("hook", vs, PRODUCT) + assert "hook" in prompt + assert "倍分子素颜霜" in prompt + assert "ins摆拍风" in prompt diff --git a/backend/tests/test_factory_and_exporter.py b/backend/tests/test_factory_and_exporter.py new file mode 100644 index 0000000..c0cd623 --- /dev/null +++ b/backend/tests/test_factory_and_exporter.py @@ -0,0 +1,141 @@ +""" +gemini_factory + package_exporter 单元测试 +""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import json +import tempfile +import zipfile +from pathlib import Path + +import pytest + +from app.services.ai_engine.gemini_factory import ( + build_ai_clients, _extract_image_bytes, _extract_gemini_image +) +from app.services.ai_engine.package_exporter import build_delivery_package + + +# ── gemini_factory ───────────────────────────────────────── +class TestGeminiFactory: + def test_build_clients_returns_ai_clients(self, monkeypatch): + monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com") + clients = build_ai_clients(plain_key="test_key_1234") + assert clients is not None + assert clients._model == clients._model_text + + def test_model_property_uses_env(self, monkeypatch): + monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com") + monkeypatch.setenv("MODEL_TEXT", "gpt-5.5") + clients = build_ai_clients(plain_key="test_key_1234") + assert clients._model_text == "gpt-5.5" + + def test_extract_image_bytes_b64(self): + import base64 + fake_png = b"\x89PNG fake" + b64 = base64.b64encode(fake_png).decode() + result = _extract_image_bytes({"data": [{"b64_json": b64}]}) + assert result == fake_png + + def test_extract_image_bytes_empty_raises(self): + with pytest.raises(ValueError): + _extract_image_bytes({"data": []}) + + def test_extract_gemini_image_success(self): + import base64 + fake_img = b"\xff\xd8\xff fake jpeg" + b64 = base64.b64encode(fake_img).decode() + resp = {"candidates": [{"content": {"parts": [{"inlineData": {"data": b64}}]}}]} + result = _extract_gemini_image(resp) + assert result == fake_img + + def test_extract_gemini_image_no_image_raises(self): + resp = {"candidates": [{"content": {"parts": [{"text": "no image"}]}}]} + with pytest.raises(ValueError): + _extract_gemini_image(resp) + + def test_key_not_in_repr(self, monkeypatch): + monkeypatch.setenv("IMAGE_API_BASE", "https://api.example.com") + clients = build_ai_clients(plain_key="super_secret_key_xyz") + # repr 不泄露 key + assert "super_secret_key_xyz" not in repr(clients) + + +# ── package_exporter ────────────────────────────────────── +class TestPackageExporter: + def _make_notes(self): + return [{ + "title": "早八素颜也能有气色", + "content": "姐妹们实测下来,轻薄不厚重是关键。✅ 通勤必备。", + "tags": ["#素颜霜", "#日常通勤"], + "images": [ + {"seq": 1, "role": "hook", "data": b"\xff\xd8\xff fake_jpg_1"}, + {"seq": 2, "role": "proof", "data": b"\xff\xd8\xff fake_jpg_2"}, + ], + "banned_word_status": "pass", + }] + + def test_creates_zip(self, tmp_path): + zip_path = build_delivery_package( + workspace_id=1, task_id=99, + notes=self._make_notes(), + base_path=str(tmp_path), + ) + assert Path(zip_path).exists() + assert zip_path.endswith(".zip") + + def test_zip_contains_required_files(self, tmp_path): + zip_path = build_delivery_package( + workspace_id=1, task_id=99, + notes=self._make_notes(), + base_path=str(tmp_path), + ) + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + # 应含文案.txt、发布清单、合规说明 + assert any("文案.txt" in n for n in names) + assert any("发布清单" in n for n in names) + assert any("合规说明" in n for n in names) + + def test_images_named_by_seq(self, tmp_path): + zip_path = build_delivery_package( + workspace_id=1, task_id=99, + notes=self._make_notes(), + base_path=str(tmp_path), + ) + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + # 图片按 seq 序号命名 + assert any("01_hook.jpg" in n for n in names) + assert any("02_proof.jpg" in n for n in names) + + def test_copy_txt_contains_title_and_tags(self, tmp_path): + zip_path = build_delivery_package( + workspace_id=1, task_id=99, + notes=self._make_notes(), + base_path=str(tmp_path), + ) + with zipfile.ZipFile(zip_path) as zf: + copy_txt = zf.read("note_01/文案.txt").decode("utf-8") + assert "早八素颜也能有气色" in copy_txt + assert "#素颜霜" in copy_txt + + def test_compliance_note_shows_status(self, tmp_path): + zip_path = build_delivery_package( + workspace_id=1, task_id=99, + notes=self._make_notes(), + base_path=str(tmp_path), + ) + with zipfile.ZipFile(zip_path) as zf: + compliance = zf.read("✅合规说明.txt").decode("utf-8") + assert "✅通过" in compliance + + def test_no_images_still_creates_package(self, tmp_path): + notes = [{**self._make_notes()[0], "images": []}] + zip_path = build_delivery_package( + workspace_id=1, task_id=100, + notes=notes, + base_path=str(tmp_path), + ) + assert Path(zip_path).exists() diff --git a/backend/tests/test_flywheel_wiring.py b/backend/tests/test_flywheel_wiring.py new file mode 100644 index 0000000..24952bb --- /dev/null +++ b/backend/tests/test_flywheel_wiring.py @@ -0,0 +1,135 @@ +""" +test_flywheel_wiring.py — 飞轮 Point1+Point2 验证 +Point1: 5种信号正确写入 preference_events(选文案/选图/审核 approve/reject/regenerate) +Point2: aggregator 返回明确 prompt_fragment(含偏好角度、打回原因,workspace 严格过滤) +无 Docker — 全程纯 Python 单元测试 +Point3+Point4 见 test_flywheel_wiring_p34.py +""" + +from unittest.mock import MagicMock + +SIGNAL_WEIGHTS_MAP = { + "text_select": 3, + "image_select": 3, + "approve": 5, + "reject_with_reason": -3, + "regenerate": -1, +} + +_PRODUCT = { + "name": "素颜霜", + "category": "护肤", + "selling_points": ["保湿", "天然"], + "style_tone": "素人分享风", + "text_angles": ["成分党", "懒人必备"], + "custom_prompt": "请保持真实素人口吻", +} + +_EVENTS_ENOUGH = [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 10, + "angle_label": "成分党", "signal_weight": 3, "reason": ""}, + {"signal_type": "approve", "workspace_id": 1, "product_id": 10, + "angle_label": "成分党", "signal_weight": 5, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 10, + "angle_label": "懒人必备", "signal_weight": 3, "reason": ""}, + {"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10, + "angle_label": "", "signal_weight": -3, "reason": "太像广告"}, + {"signal_type": "regenerate", "workspace_id": 1, "product_id": 10, + "angle_label": "", "signal_weight": -1, "reason": ""}, +] + + +# ══════════════════════════════════════════════════════════════ +# Point1: 五种信号写入结构验证 +# ══════════════════════════════════════════════════════════════ +class TestPoint1SignalTypes: + """验证 5 种信号都有对应 weight,且字段完整。""" + + def test_all_signal_types_have_weights(self): + from app.constants.enums import SIGNAL_WEIGHTS + required = {"text_select", "image_select", "approve", "reject_with_reason", "regenerate"} + missing = required - set(SIGNAL_WEIGHTS.keys()) + assert not missing, f"SIGNAL_WEIGHTS 缺少: {missing}" + + def test_positive_signals_weight_positive(self): + from app.constants.enums import SIGNAL_WEIGHTS + for sig in ("text_select", "image_select", "approve"): + assert SIGNAL_WEIGHTS[sig] > 0, f"{sig} weight 应 > 0,实际={SIGNAL_WEIGHTS[sig]}" + + def test_negative_signals_weight_negative(self): + from app.constants.enums import SIGNAL_WEIGHTS + for sig in ("reject_with_reason", "regenerate"): + assert SIGNAL_WEIGHTS[sig] < 0, f"{sig} weight 应 < 0,实际={SIGNAL_WEIGHTS[sig]}" + + def test_collect_preference_event_returns_complete_fields(self): + from app.services.ai_engine.preference_aggregator import collect_preference_event + event = collect_preference_event( + signal_type="text_select", + user_id=7, + workspace_id=1, + product_id=10, + angle_label="成分党", + ) + required_keys = {"signal_type", "signal_weight", "user_id", + "workspace_id", "product_id", "angle_label", + "reason", "data_ownership"} + assert required_keys.issubset(event.keys()), f"缺字段: {required_keys - event.keys()}" + + def test_collect_event_weight_matches_signal(self): + from app.services.ai_engine.preference_aggregator import collect_preference_event + ev = collect_preference_event("approve", 1, 1, 10) + assert ev["signal_weight"] == SIGNAL_WEIGHTS_MAP["approve"] + + +# ══════════════════════════════════════════════════════════════ +# Point2: aggregator 返回 prompt_fragment + workspace 严格过滤 +# ══════════════════════════════════════════════════════════════ +class TestPoint2AggregatorOutput: + """验证 aggregate_preference_context 返回结构与 workspace/product 隔离。""" + + def test_returns_required_keys(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + for k in ("prompt_fragment", "recent_preference", "reject_reasons", "injected_count"): + assert k in ctx, f"返回值缺字段: {k}" + + def test_prompt_fragment_not_empty_with_signals(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + assert ctx["prompt_fragment"].strip(), "有足够信号时 prompt_fragment 不应为空" + + def test_prompt_fragment_contains_top_angle(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + assert "成分党" in ctx["prompt_fragment"] # 权重最高角度 + + def test_reject_reasons_in_fragment(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + assert "太像广告" in ctx["prompt_fragment"] + + def test_workspace_isolation_filters_other_workspace(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + mixed = _EVENTS_ENOUGH + [ + {"signal_type": "text_select", "workspace_id": 2, "product_id": 10, + "angle_label": "workspace2专有角度", "signal_weight": 100, "reason": ""}, + ] + ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10) + assert "workspace2专有角度" not in ctx["prompt_fragment"] + + def test_product_isolation_filters_other_product(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + mixed = _EVENTS_ENOUGH + [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 99, + "angle_label": "product99专有角度", "signal_weight": 100, "reason": ""}, + ] + ctx = aggregate_preference_context(mixed, _PRODUCT, 1, 10) + assert "product99专有角度" not in ctx["prompt_fragment"] + + def test_cold_start_when_insufficient_signals(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + from app.services.ai_engine.constants import FLYWHEEL_COLD_START + few = _EVENTS_ENOUGH[:max(0, FLYWHEEL_COLD_START - 1)] + ctx = aggregate_preference_context(few, _PRODUCT, 1, 10) + assert "recent_preference" in ctx + assert ctx["injected_count"] == len(few) diff --git a/backend/tests/test_flywheel_wiring_p34.py b/backend/tests/test_flywheel_wiring_p34.py new file mode 100644 index 0000000..508b21e --- /dev/null +++ b/backend/tests/test_flywheel_wiring_p34.py @@ -0,0 +1,166 @@ +""" +test_flywheel_wiring_p34.py — 飞轮 Point3+Point4 验证 +Point3: pipeline 把 prompt_fragment 注入 user_prompt(prompt trace 含片段) +Point4: UI 层 flywheel_injected SSE 事件含 recent_preference + reject_reasons +无 Docker — 全程纯 Python 单元测试 +Point1+Point2 见 test_flywheel_wiring.py +""" + +from unittest.mock import MagicMock + +_PRODUCT = { + "name": "素颜霜", + "category": "护肤", + "selling_points": ["保湿", "天然"], + "style_tone": "素人分享风", + "text_angles": ["成分党", "懒人必备"], + "custom_prompt": "请保持真实素人口吻", +} + +_EVENTS_ENOUGH = [ + {"signal_type": "text_select", "workspace_id": 1, "product_id": 10, + "angle_label": "成分党", "signal_weight": 3, "reason": ""}, + {"signal_type": "approve", "workspace_id": 1, "product_id": 10, + "angle_label": "成分党", "signal_weight": 5, "reason": ""}, + {"signal_type": "text_select", "workspace_id": 1, "product_id": 10, + "angle_label": "懒人必备", "signal_weight": 3, "reason": ""}, + {"signal_type": "reject_with_reason","workspace_id": 1, "product_id": 10, + "angle_label": "", "signal_weight": -3, "reason": "太像广告"}, + {"signal_type": "regenerate", "workspace_id": 1, "product_id": 10, + "angle_label": "", "signal_weight": -1, "reason": ""}, +] + + +# ══════════════════════════════════════════════════════════════ +# Point3: pipeline 把 prompt_fragment 注入 user_prompt +# ══════════════════════════════════════════════════════════════ +class TestPoint3PromptInjection: + """验证 compose_variants 把 flywheel_context 正确注入 user_prompt。""" + + def test_compose_variants_appends_flywheel_to_user_prompt(self): + from app.services.ai_engine.prompt_composer import compose_variants + fragment = "【偏好角度参考】历史选择偏好:成分党" + _, user_prompt = compose_variants(_PRODUCT, 3, flywheel_context=fragment) + assert fragment in user_prompt, "flywheel_context 应出现在 user_prompt 中" + + def test_compose_variants_flywheel_not_in_system_prompt(self): + """飞轮片段不应覆盖 system_prompt(COPY_SYSTEM 质量护栏不能被干扰)""" + from app.services.ai_engine.prompt_composer import compose_variants + fragment = "飞轮注入内容UNIQUE123" + system_prompt, _ = compose_variants(_PRODUCT, 2, flywheel_context=fragment) + assert fragment not in system_prompt, "飞轮片段不应出现在 system_prompt" + + def test_compose_variants_empty_flywheel_still_valid(self): + """无飞轮片段时 compose_variants 仍正常返回 (system, user)""" + from app.services.ai_engine.prompt_composer import compose_variants + system_p, user_p = compose_variants(_PRODUCT, 3, flywheel_context="") + assert isinstance(system_p, str) and len(system_p) > 0 + assert isinstance(user_p, str) and len(user_p) > 0 + + def test_pipeline_fragment_contains_top_angle(self): + """pipeline 重现:ORM events → aggregate → fragment 含最高权重角度""" + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + + def make_event(sig, angle, weight, reason): + e = MagicMock() + e.signal_type = sig + e.workspace_id = 1 + e.product_id = 10 + e.angle_label = angle + e.signal_weight = weight + e.reason = reason + return e + + mock_events = [ + make_event("text_select", "成分党", 3, ""), + make_event("approve", "成分党", 5, ""), + make_event("reject_with_reason", "", -3, "太像广告"), + make_event("text_select", "懒人必备", 3, ""), + make_event("regenerate", "", -1, ""), + ] + events_dicts = [ + {"signal_type": e.signal_type, "workspace_id": e.workspace_id, + "product_id": e.product_id, "angle_label": e.angle_label or "", + "signal_weight": e.signal_weight, "reason": e.reason or ""} + for e in mock_events + ] + ctx = aggregate_preference_context(events_dicts, _PRODUCT, 1, 10) + fragment = ctx.get("prompt_fragment", "") + assert isinstance(fragment, str) + assert "成分党" in fragment + + +# ══════════════════════════════════════════════════════════════ +# Point4: UI 层 flywheel_injected SSE 事件结构验证 +# ══════════════════════════════════════════════════════════════ +class TestPoint4SseFlywheelEvent: + """验证 flywheel_injected SSE 事件包含 UI 展示所需字段。""" + + # FE useSse.ts flywheel_injected 事件期望字段 + FE_EXPECTED_FIELDS = {"recent_preference", "reject_reasons"} + + def test_aggregator_output_matches_sse_fields(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + missing = self.FE_EXPECTED_FIELDS - ctx.keys() + assert not missing, f"SSE flywheel_injected 缺少字段: {missing}" + + def test_recent_preference_is_nonempty_string(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + assert isinstance(ctx["recent_preference"], str) and ctx["recent_preference"].strip() + + def test_reject_reasons_is_list_of_strings(self): + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + ctx = aggregate_preference_context(_EVENTS_ENOUGH, _PRODUCT, 1, 10) + assert isinstance(ctx["reject_reasons"], list) + for r in ctx["reject_reasons"]: + assert isinstance(r, str) + + def test_reject_reasons_max_3(self): + """打回原因最多返回3条(FE 卡片布局限制)""" + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + many_rejects = [ + {"signal_type": "reject_with_reason", "workspace_id": 1, + "product_id": 10, "angle_label": "", "signal_weight": -3, + "reason": f"原因{i}"} + for i in range(10) + ] + _EVENTS_ENOUGH + ctx = aggregate_preference_context(many_rejects, _PRODUCT, 1, 10) + assert len(ctx["reject_reasons"]) <= 3 + + def test_flywheel_service_get_context_returns_sse_compatible_shape(self): + """flywheel_service.get_preference_context 返回结构与 FE PreferenceContext DTO 一致""" + from app.services.flywheel_service import get_preference_context + from app.constants.enums import SignalType + + def make_orm(sig, angle, reason): + e = MagicMock() + e.signal_type = sig + e.angle_label = angle + e.reason = reason + return e + + orm_events = [ + make_orm(SignalType.TEXT_SELECT, "成分党", ""), + make_orm(SignalType.TEXT_SELECT, "成分党", ""), + make_orm(SignalType.APPROVE, "成分党", ""), + make_orm(SignalType.REJECT_WITH_REASON, "", "太像广告"), + make_orm(SignalType.TEXT_SELECT, "懒人必备", ""), + ] + mock_db = MagicMock() + mq = MagicMock() + mock_db.query.return_value = mq + mq.filter.return_value = mq + mq.order_by.return_value = mq + mq.limit.return_value = mq + mq.all.return_value = orm_events + + result = get_preference_context(mock_db, workspace_id=1, product_id=10) + + fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"} + missing = fe_dto_keys - result.keys() + assert not missing, f"FE PreferenceContext DTO 缺字段: {missing}" + assert isinstance(result["recent_preference"], str) + assert isinstance(result["reject_reasons"], list) + assert isinstance(result["injected_count"], int) diff --git a/backend/tests/test_integration_seams.py b/backend/tests/test_integration_seams.py new file mode 100644 index 0000000..0ef8570 --- /dev/null +++ b/backend/tests/test_integration_seams.py @@ -0,0 +1,241 @@ +""" +test_integration_seams.py — 汇合联调接缝验证(无 Docker,不依赖 Redis/DB) +验证三条核心接缝: + 1. AIE→BE 接缝:pipeline_steps / pipeline_io 数据流契约 + 2. BE→FE 接缝:SSE 事件字段与 generationStore 期望字段对齐 + 3. FE→BE 接缝:API 请求 DTO 与 BE 路由入参对齐 + +原则:纯 Python/结构断言,不联网,不起服务。 +联调真实运行需 docker compose up + 真实 API Key。 +""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +import json + + +# ═══════════════════════════════════════════════════════════════ +# Seam 1 — AIE→BE: pipeline_steps 数据流 +# ═══════════════════════════════════════════════════════════════ + +class TestAiePipelineStepsContract: + """pipeline_steps.build_product_dict 产出符合 AI 引擎 dict 契约""" + + def _make_mock_product(self): + """模拟 ORM Product 对象(duck typing)""" + class P: + name = "倍分子素颜霜" + category = "美妆护肤" + selling_points = '["轻薄不厚重","水润自然"]' + style_tone = "素人分享风" + text_angles = '["痛点切入","场景型"]' + custom_prompt = "" + image_path = None # 前端图片上传完成前为空 + return P() + + def test_build_product_dict_keys(self): + from app.workers.pipeline_steps import build_product_dict + d = build_product_dict(self._make_mock_product()) + for key in ("name", "category", "selling_points", "style_tone", + "text_angles", "custom_prompt", "image_path"): + assert key in d, f"build_product_dict 缺字段: {key}" + + def test_build_product_dict_lists_parsed(self): + from app.workers.pipeline_steps import build_product_dict + d = build_product_dict(self._make_mock_product()) + assert isinstance(d["selling_points"], list) + assert isinstance(d["text_angles"], list) + + def test_build_product_dict_no_key_field(self): + """绝不含任何 key/token 相关字段(基石B)""" + from app.workers.pipeline_steps import build_product_dict + d = build_product_dict(self._make_mock_product()) + for forbidden in ("api_key", "key", "token", "secret", "encrypted"): + assert forbidden not in d, f"product_dict 不应含 {forbidden}" + + +# ═══════════════════════════════════════════════════════════════ +# Seam 2 — BE→FE: SSE 事件字段对齐 +# ═══════════════════════════════════════════════════════════════ + +class TestSseEventFieldAlignment: + """BE 推的 SSE 事件字段与 FE useSse handleEvent 期望的字段一一对应""" + + # FE useSse.ts 中 handleEvent 使用的字段(从 FE 代码提取) + EXPECTED_FIELDS = { + "task_started": {"total_text", "total_image"}, + "text_progress": {"done", "total"}, + "text_candidate": {"candidate_id", "angle_label", "content", "score"}, + "image_progress": {"done", "total"}, + "image_candidate": {"candidate_id", "strategy", "url", "role"}, + "batch_failed": {"batch", "reason", "retryable"}, + "flywheel_injected": {"recent_preference", "reject_reasons"}, + } + + # BE pipeline_io 实际推的事件字段(从代码提取) + ACTUAL_FIELDS = { + "task_started": { + "task_id": 1, "total_text": 5, "total_image": 3, + }, + "text_progress": { + "done": 1, "total": 5, + }, + "text_candidate": { + "candidate_id": 1, "angle_label": "场景型", + "content": "测试正文", "score": 88, + }, + "image_progress": { + "done": 1, "total": 3, + }, + "image_candidate": { + "candidate_id": 1, "strategy": "A", "url": "/uploads/ws/1/1/01_hook.jpg", + "role": "hook", + }, + "batch_failed": { + "batch": "hook", "reason": "502", "retryable": True, + }, + "flywheel_injected": { + "recent_preference": "最近偏好角度:场景型", + "reject_reasons": [], + }, + } + + def test_all_fe_required_fields_present(self): + """FE 期望的每个字段都在 BE 实际推的 data 中""" + for event_type, required_fields in self.EXPECTED_FIELDS.items(): + actual = self.ACTUAL_FIELDS.get(event_type, {}) + for field in required_fields: + assert field in actual, ( + f"SSE 事件 [{event_type}] 缺字段 [{field}],FE 会拿到 undefined" + ) + + def test_no_extra_pii_in_sse_events(self): + """SSE 事件不含 key/token/password 等敏感字段""" + for event_type, data in self.ACTUAL_FIELDS.items(): + for field in data: + assert "key" not in field.lower() and "token" not in field.lower(), ( + f"SSE 事件 [{event_type}] 含敏感字段 [{field}]" + ) + + +# ═══════════════════════════════════════════════════════════════ +# Seam 3 — FE→BE: CreateTaskRequest DTO 对齐 +# ═══════════════════════════════════════════════════════════════ + +class TestCreateTaskRequestContract: + """FE tasks/new/page.tsx 发的请求体与 BE CreateTaskRequest 契约对齐(独立 DTO 验证,不依赖 motor)""" + + @pytest.fixture(autouse=True) + def _stub_motor(self, monkeypatch): + """database.py 顶层 import motor 在测试环境可能缺失,mock 掉避免 ModuleNotFoundError""" + import sys + import types + if "motor" not in sys.modules: + motor_mod = types.ModuleType("motor") + motor_async = types.ModuleType("motor.motor_asyncio") + motor_async.AsyncIOMotorClient = object + sys.modules["motor"] = motor_mod + sys.modules["motor.motor_asyncio"] = motor_async + + def _get_dto(self): + from app.api.v1.tasks import CreateTaskRequest + return CreateTaskRequest + + def test_create_task_required_fields(self): + Req = self._get_dto() + req = Req(product_id=1, theme="今日主题", text_count=8, image_count=5, track="ai") + assert req.product_id == 1 + assert req.theme == "今日主题" + assert req.track == "ai" + + def test_count_validation(self): + import pydantic + Req = self._get_dto() + with pytest.raises(pydantic.ValidationError): + Req(product_id=1, text_count=0, track="ai") # 0 < 1 + with pytest.raises(pydantic.ValidationError): + Req(product_id=1, text_count=21, track="ai") # 21 > 20 + + def test_track_validation(self): + import pydantic + Req = self._get_dto() + with pytest.raises(pydantic.ValidationError): + Req(product_id=1, track="auto") # 非法 track + + def test_import_track_no_key_check(self): + """轨B import 字段校验通过(业务层 key 检查在 service,不在 DTO)""" + Req = self._get_dto() + req = Req(product_id=1, track="import") + assert req.track == "import" + + +# ═══════════════════════════════════════════════════════════════ +# Seam 4 — SSE ticket 流程 +# ═══════════════════════════════════════════════════════════════ + +class TestSseTicket: + """ticket 签发/消费/防重放逻辑(用 mock redis)""" + + def _make_mock_redis(self): + store: dict = {} + + class MockRedis: + def setex(self, key, ttl, value): + store[key] = value + + def getdel(self, key): + return store.pop(key, None) + + return MockRedis(), store + + def test_issue_and_consume(self): + from app.core.sse_ticket import issue_ticket, consume_ticket + r, _ = self._make_mock_redis() + ticket = issue_ticket(r, task_id=42, workspace_id=7) + result = consume_ticket(r, ticket) + assert result == (42, 7) + + def test_consume_once_only(self): + from app.core.sse_ticket import issue_ticket, consume_ticket + r, _ = self._make_mock_redis() + ticket = issue_ticket(r, task_id=1, workspace_id=1) + consume_ticket(r, ticket) # 第一次消费 + result = consume_ticket(r, ticket) # 第二次 → None + assert result is None + + def test_invalid_ticket_returns_none(self): + from app.core.sse_ticket import consume_ticket + r, _ = self._make_mock_redis() + assert consume_ticket(r, "fake-ticket") is None + + def test_empty_ticket_returns_none(self): + from app.core.sse_ticket import consume_ticket + r, _ = self._make_mock_redis() + assert consume_ticket(r, "") is None + + +# ═══════════════════════════════════════════════════════════════ +# Seam 5 — SSE sse_utils 格式化 +# ═══════════════════════════════════════════════════════════════ + +class TestSseFormat: + """format_sse 输出符合 text/event-stream 规范,FE EventSource 能解析""" + + def test_format_contains_event_and_data(self): + from app.utils.sse_utils import format_sse + out = format_sse("text_candidate", {"candidate_id": 1, "score": 92}, seq=3) + assert "event: text_candidate" in out + assert "data:" in out + assert "id: 3" in out + + def test_format_ends_with_double_newline(self): + from app.utils.sse_utils import format_sse + out = format_sse("task_done", {"status": "pending_selection"}) + assert out.endswith("\n\n") + + def test_data_is_valid_json(self): + from app.utils.sse_utils import format_sse + out = format_sse("heartbeat", {"ts": 1234567.0}) + data_line = [l for l in out.split("\n") if l.startswith("data:")][0] + json.loads(data_line[len("data:"):].strip()) # 不应抛异常 diff --git a/backend/verify_output/素颜霜_封面hook.png b/backend/verify_output/素颜霜_封面hook.png new file mode 100644 index 0000000..36e1f0d Binary files /dev/null and b/backend/verify_output/素颜霜_封面hook.png differ diff --git a/banana扒包与取长补短.md b/banana扒包与取长补短.md new file mode 100644 index 0000000..fe7fc7a --- /dev/null +++ b/banana扒包与取长补短.md @@ -0,0 +1,107 @@ +# banana 扒包 + 产品包✖️banana 取长补短 + +> 目的:决定 Clover 的技术地基——banana 哪些能用、产品包哪些能用、怎么结合。 +> 铁律:好的保留,不好的优化升级。 +> 扒包日期:2026-06-05|扒包人:CC + +--- + +## 一、banana 是什么(比记忆里强很多) + +它本身就是一个**完整的"小红书AI种草笔记生成平台"**,不是空骨架。 +技术栈:Next.js14前端 + FastAPI后端 + Celery队列 + MySQL(关系) + MongoDB(文档) + Redis(队列/SSE) + Docker6容器。 + +### 已完成且✅验证的能力 +- **认证**:注册/登录/JWT刷新/设备记录(auth完整) +- **任务流**:创建→Celery异步→SSE实时进度推送→结果展示(核心链路通) +- **积分系统**:余额/充值/流水/趋势(MVP模拟) +- **素材库**:CRUD/收藏/brand_tone字段 +- **上传**:magic number校验+扩展名白名单+路径穿越防护(安全已加固) +- **前端10页**:登录/概览/创建/历史/任务结果(SSE流)/图片编辑/最终效果/素材库/积分/个人中心 +- **AI抽象层**:AIProvider Protocol,mock/gemini可切换(架构支持插拔) + +### 半成品/待完成(PROGRESS.md说的) +- brand_tone 的 Alembic迁移没做 +- 前端交互细节没打磨 +- 端到端没完整自测;积分扣费没集成;AI图片持久化待做 +- 无测试覆盖、无CI/CD + +--- + +## 二、banana 的 AI 内核有多厚(决定取舍的关键) + +gemini_service.py 779行,能力清单: +- analyze_content 产品/参考分析(出overall_score) +- generate_text / generate_text_stream 文案生成(流式) +- generate_images / _generate_single_image 生图 +- **evaluate_schema 方案评分**(对应产品包打分,但只有1个总分,**比产品包五维浅**) +- edit_image 对话式改图 / apply_quick_action 快捷操作 +- **remove_watermark 去水印**(✅洞3刚需,banana已内置!) +- **extract_white_bg 白底图提取**(产品图标准化,加分项) +- _retry 重试退避(✅产品包缺的,banana有) +- gemini_image_client.generate_with_urls 带图URL生图(≈edits能力) + +⚠️ 关键短板:**banana的AI内核全绑Gemini**,没有产品包的"换角度/品类策略/去重/自动优化循环"那套业务深度。 +- banana打分=1个总分;产品包打分=五维(标题/情绪/买点/关键词/合规) +- banana无去重、无6品类话术、无storyboard 10角色分镜、无自动优化循环 +- banana的prompt在gemini_service里写死(但有prompt_template_doc的MongoDB集合,预留了可调位) + +### banana去水印实现真相(要标注) +remove_watermark 做法=**让Gemini重绘一张去水印图**(prompt: Remove all watermarks...reconstruct naturally)。 +- 优点:连像素级SynthID都重绘掉,比re-encode彻底 +- ⚠️风险:重绘会改变文字/构图,**对小红书海报的中文大标题是风险**(可能改字/糊字) +- 结论:可作为"去水印通道之一",但海报类要测;洞3的re-encode/transcode削水印是更安全的"无损"方案,二者可分级用 + +--- + +## 三、取长补短对比表(核心结论) + +| 能力维度 | 产品包 | banana | Clover怎么取 | +|---------|--------|--------|-------------| +| **文案业务内核**(打分/优化/去重/品类/角度) | ✅强(五维+循环+去重+6品类) | ⚠️弱(1个总分) | **取产品包** | +| **storyboard分镜**(10角色+品类证明) | ✅强 | ❌无 | **取产品包** | +| **违禁词三级+自动改写** | ✅强 | ❌无 | **取产品包** | +| **爆款裂变+参考度调节** | ✅强 | ❌无 | **取产品包** | +| **工程底座**(认证/队列/SSE/Docker) | ❌无(无状态Worker) | ✅强 | **取banana** | +| **多租户/JWT/用户体系** | ❌无 | ✅有(差workspace层) | **取banana,补租户层** | +| **异步队列**(解决生图4min阻塞) | ❌无(同步) | ✅Celery+SSE | **取banana** | +| **数据库/存档** | ❌无 | ✅MySQL+MongoDB | **取banana,补delivery_packages** | +| **去水印** | ❌无(洞3要新建) | ✅remove_watermark | **取banana,海报类加re-encode安全档** | +| **白底图/产品图标准化** | ⚠️product.js分析 | ✅extract_white_bg | **两者结合** | +| **重试退避** | ❌无(baseline证明需要) | ✅_retry | **取banana** | +| **AI Provider抽象**(可插拔) | ❌写死 | ✅Protocol | **取banana** | +| **生图主模型** | Gemini主/GPT备 | 全Gemini | **改:gpt-image-2主/Gemini备** | +| **可调prompt** | ⚠️prompt写死在代码 | ⚠️有MongoDB集合预留位 | **取banana预留位,做成北哥可调** | +| **积分计费** | ❌无 | ✅有(MVP模拟) | **MVP免费,先关闭,留接口** | +| **前端** | demo展示页(不复用) | 10页生产级 | **取banana骨架,按前端设计参考图.png改5步流程** | + +--- + +## 四、结论:Clover 全新建,从两源各扒零件重建 + +**🔴 架构定性(倩倩姐2026-06-05拍板,已落 Clover/CLAUDE.md):** +- Clover 是**全新独立项目**,在 `Clover/` 下从头建立。 +- banana 和产品包都是**只读素材源**,只复制不修改,绝不边盖边修。 +- 需要哪个零件 → 复制出来 → 在 Clover 里结合本项目迭代/优化/重建。 +- ~~以banana为底座~~(说法作废,那是边盖边修的错误架构)。 + +**从产品包扒(复制JS算法→Clover里重写Python):** +- 文案五维打分/自动优化循环/去重 / storyboard 10角色分镜 / 6品类话术 / 违禁词三级自动改写 / 爆款裂变+参考度调节 + +**从banana扒(复制Python模块→Clover里改造):** +- FastAPI+Celery+SSE 异步骨架 / JWT认证 / 双库模型范式 / AIProvider抽象层 / remove_watermark+extract_white_bg / _retry重试 / 上传安全加固 / 前端Next.js组件范式 + +**Clover自己新建(两源都没有):** +- workspace多租户层 / delivery_packages存档打包+导出 / preference_events飞轮 / token-url收口 / banned_words可配置表 / 按"前端设计参考图.png"的5步流程前端 + +**改造点:** +- 生图主模型改 gpt-image-2 主、Gemini 备;生图接口改 edits 优先 +- banana去水印(Gemini重绘)对海报中文标题有改字风险 → 加 re-encode 无损安全档分级用 +- 积分计费 MVP 免费先关闭,留接口 +- 可调prompt:参考banana的prompt_template_doc预留位,做成北哥可调,等北哥样本注入 + +### ⚠️ 待定决策(给倩倩姐)— 已拍板 +1. ~~JS→Python重写 vs JS微服务~~ → **拍板:Python重写融进Clover**(统一栈),但 banana/产品包文件夹只读复制,不在其上改。 +2. ~~banana半成品先跑通验证~~ → **作废**:不基于banana迭代,所以不存在"先跑通banana"。banana只读,扒模块时该模块逻辑要在Clover里重新验证。 + + diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..336c05f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,24 @@ +# Clover Frontend + +Next.js(React)— 5屏骨架 + +## 目录规划 +``` +frontend/src/ + app/ # Next.js App Router + components/ # 可复用组件 + lib/ # API client(按 openapi.yaml 生成) + types/ # TypeScript 类型 +``` + +## 5屏 +1. 管理配置台 — 产品档案/标杆笔记/违禁词/API Key录入 +2. 开任务 — 选产品+标杆+今天主题+设定数量,发起生成 +3. 挑文案+挑图 — SSE实时看生成进度,8选3文案/挑图,显示"本次已注入" +4. 确认预览 — 提交审核 +5. 审核台(组长) — 待审队列/通过/打回写原因 + +## 前端红线(不得违反) +- 不展示 Token 余额/用量 +- 不做积分中心/billing 页 +- 飞轮偏好信号不暴露独立 POST 给前端(防伪造) diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..c288791 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,24 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + images: { + domains: ['localhost'], + }, + async rewrites() { + const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'; + return [ + { + source: '/api/:path*', + destination: `${apiBase}/api/:path*`, + }, + { + // 图片等静态资源:后端 /uploads 由 StaticFiles 提供,前端代理过去 + // 否则 会请求前端3000端口导致404破图 + source: '/uploads/:path*', + destination: `${apiBase}/uploads/:path*`, + }, + ]; + }, +}; + +module.exports = nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a63cdac --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6222 @@ +{ + "name": "clover-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clover-frontend", + "version": "0.1.0", + "dependencies": { + "clsx": "^2.1.1", + "next": "14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.3.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.0.tgz", + "integrity": "sha512-4+70ELtSbRtYUuyRpAJmKC8NHBW2x1HMje9KO2Xd7IkoyucmV9SjgO+qeWMC0JWkRQXgydv1O7yKOK8nu/rITQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.0.tgz", + "integrity": "sha512-QkM01VPhwcupezVevy9Uyl1rmpg2PimhMjkb+ySmnPgSKUUM/PGGRQxdFgMpHv/JzQoC8kRySgKeM441GiizcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.0.tgz", + "integrity": "sha512-kHktLlw0AceuDnkVljJ/4lTJagLzDiO3klR1Fzl2APDFZ8r+aTxNaNcPmpp0xLMkgRwwk6sggYeqq0Rz9K4zzA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.0.tgz", + "integrity": "sha512-HFSDu7lb1U3RDxXNeKH3NGRR5KyTPBSUTuIOr9jXoAso7i76gNYvnTjbuzGVWt2X5izpH908gmOYWtI7un+JrA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.0.tgz", + "integrity": "sha512-iQsoWziO5ZMxDWZ4ZTCAc7hbJ1C9UDj/gATSqTaMjW2bJFwAsvf9UM79AKnljBl73uPZ+V0kH4rvnHTco4Ps2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.0.tgz", + "integrity": "sha512-0JOk2uzLUt8fJK5LpsKKZa74zAch7bJjjgJzR9aOMs231AlE4gPYzsSm430ckZitjPGKeH5bgDZjqwqJQKIS2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.0.tgz", + "integrity": "sha512-uYHkuTzX0NM6biKNp7hdKTf+BF0iMV254SxO0B8PgrQkxUBKGmk5ysHKB+FYBfdf9xei/t8OIKlXJs9ckD943A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.0.tgz", + "integrity": "sha512-paN89nLs2dTBDtfXWty1/NVPit+q6ldwdktixYSVwiiAz647QDCd+EIYqoiS+/rPG3oXs/A7rWcJK9HVqfnMVg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.0.tgz", + "integrity": "sha512-j1oiidZisnymYjawFqEfeGNcE22ZQ7lGUaa4pGOCVWrWeIDkPSj8zYgS9TzMNlg17Q3wSWCQC/F5uJAhSh7qcA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.0.tgz", + "integrity": "sha512-6ff6F4xb+QGD1jhx/dOT9Ot7PQ/GAYekV9ykwEh2EFS/cLTyU4Y3cXkX5cNtNIhpctS5NvyjW9gIksRNErYE0A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.0.tgz", + "integrity": "sha512-09DbG5vXAxz0eTFSf1uebWD36GF3D5toynRkgo2AlSrxwGZkWtJ1RhmrczRYQ17eD5bdo4FZ0ibiffdq5kc4vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz", + "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.369", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.369.tgz", + "integrity": "sha512-XM22K9FNaaCOvMMrBn1caIc8v0g6+pKt660ZbfQqUZvfil0hEzr8ZoiY7VcSLGM3L/x3rz5PqZrk+bKOOmVM9w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.0.tgz", + "integrity": "sha512-N0eQkn/wz557mIpW4JQWGEv4wGU8zvJ7emLHMS15uC18jjaU4kx6leR4U9QYT/eNghUZT7N9lBlfd8E4N0cp1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.2.0", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.0.tgz", + "integrity": "sha512-2T41HqJdKPqheR27ll7MFZ3gtTYvGew7cUc0PwPSyK9Ao5vvwpf9bYfP4V5YBGLckHF2kEGvrLte5BqLSv0s8g==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.0", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.0", + "@next/swc-darwin-x64": "14.2.0", + "@next/swc-linux-arm64-gnu": "14.2.0", + "@next/swc-linux-arm64-musl": "14.2.0", + "@next/swc-linux-x64-gnu": "14.2.0", + "@next/swc-linux-x64-musl": "14.2.0", + "@next/swc-win32-arm64-msvc": "14.2.0", + "@next/swc-win32-ia32-msvc": "14.2.0", + "@next/swc-win32-x64-msvc": "14.2.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..de44c2d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "clover-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "clsx": "^2.1.1", + "next": "14.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.3.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx new file mode 100644 index 0000000..5ed16e2 --- /dev/null +++ b/frontend/src/app/config/page.tsx @@ -0,0 +1,58 @@ +'use client'; +/** + * 屏1 配置台(管理员 /config) + * 产品库 / 标杆笔记 / 违禁词 / API Key 录入 + * 🔴 红线:key 只录不显余额(不做 Token 用量展示) + */ +import { useState } from 'react'; +import { ProductsTab } from '@/components/config/ProductsTab'; +import { BenchmarksTab } from '@/components/config/BenchmarksTab'; +import { BannedWordsTab } from '@/components/config/BannedWordsTab'; +import { ApiKeysTab } from '@/components/config/ApiKeysTab'; + +type TabKey = 'products' | 'benchmarks' | 'banned_words' | 'api_keys'; + +const TABS: { key: TabKey; label: string }[] = [ + { key: 'products', label: '产品档案' }, + { key: 'benchmarks', label: '标杆笔记' }, + { key: 'banned_words', label: '违禁词库' }, + { key: 'api_keys', label: 'API Key' }, +]; + +export default function ConfigPage() { + const [activeTab, setActiveTab] = useState('products'); + + return ( +
+

配置台

+ + {/* Tab 导航 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab 内容 */} +
+ {activeTab === 'products' && } + {activeTab === 'benchmarks' && } + {activeTab === 'banned_words' && } + {activeTab === 'api_keys' && } +
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..c28626f --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,51 @@ +'use client'; +/** + * /dashboard — 工作台(各角色登录后的中转落地页) + * 按角色展示对应入口 + */ +import Link from 'next/link'; +import { useAuthStore } from '@/stores/authStore'; + +export default function DashboardPage() { + const { user, role } = useAuthStore(); + + return ( +
+

+ 你好,{user?.username ?? '朋友'} +

+

+ {role === 'admin' && '你是管理员,可管理产品配置和审核'} + {role === 'supervisor' && '你是组长,负责审核待发笔记'} + {role === 'operator' && '今天要生成什么主题?'} +

+ +
+ {role !== 'supervisor' && ( + + )} + {(role === 'supervisor' || role === 'admin') && ( + + )} + {role === 'admin' && ( + + )} + +
+
+ ); +} + +function DashCard({ href, icon, label, desc }: { + href: string; icon: string; label: string; desc: string; +}) { + return ( + + +

{label}

+

{desc}

+ + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..49851c9 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,8 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* 最小宽度 1280px,不做移动端 */ +body { + min-width: 1280px; +} diff --git a/frontend/src/app/history/components.tsx b/frontend/src/app/history/components.tsx new file mode 100644 index 0000000..ed64600 --- /dev/null +++ b/frontend/src/app/history/components.tsx @@ -0,0 +1,174 @@ +'use client'; +/** + * HistoryTable / HistorySkeleton / Pagination / DownloadButton + * 历史归档页子组件(按约束拆出 ≤100行) + * + * C1修复:下载走 fetch+blob,不用 window.open。 + * window.open 无法附带 Authorization header,会 401。 + * 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。 + */ +import { useState } from 'react'; +import Link from 'next/link'; +import { clsx } from 'clsx'; +import { TaskListItem } from '@/types'; +import { api } from '@/lib/api'; + +const STATUS_LABEL: Record = { + approved: { text: '已通过', color: 'text-green-600 bg-green-50' }, + archived: { text: '已归档', color: 'text-gray-500 bg-gray-100' }, + rejected: { text: '被打回', color: 'text-red-500 bg-red-50' }, +}; + +// --- DownloadButton --- 触发打包→轮询→下载 zip +function DownloadButton({ taskId }: { taskId: number }) { + const [state, setState] = useState<'idle' | 'packaging' | 'done' | 'error'>('idle'); + const [pkgId, setPkgId] = useState(null); + + async function handleDownload() { + setState('packaging'); + try { + // 1. 触发打包 + const pkg = await api.post<{ delivery_package_id: number }>(`/api/v1/tasks/${taskId}/package`, {}); + const id = pkg.delivery_package_id; + setPkgId(id); + // 2. 轮询 status(最多20次,每2s) + for (let i = 0; i < 20; i++) { + await new Promise((r) => setTimeout(r, 2000)); + const info = await api.get<{ status: string }>(`/api/v1/delivery-packages/${id}/download`); + if (info.status === 'ready' || info.status === 'downloaded') { + // 3. fetch+blob 下载(带 Authorization header,避免 window.open 401) + const blob = await api.getBlob(`/api/v1/delivery-packages/${id}/download-file`); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `delivery-${id}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setState('done'); + return; + } + } + setState('error'); + } catch { setState('error'); } + } + + if (state === 'done') return 已下载; + if (state === 'error') return 失败,刷新重试; + return ( + + ); +} + +// --- HistoryTable --- +export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) { + return ( +
+ + + + + + + + + + + + + {tasks.map((task, i) => ( + + + + + + + + + ))} + +
任务状态文案图片创建时间操作
+ {task.theme} + + + {STATUS_LABEL[task.status]?.text ?? task.status} + + {task.text_count} 条{task.image_count} 张 + {new Date(task.created_at).toLocaleDateString('zh-CN')} + +
+ + 查看 + + {task.status === 'approved' && } +
+
+
+ ); +} + +// --- HistorySkeleton --- +export function HistorySkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +// --- Pagination --- +export function Pagination({ + page, total, onChange, +}: { page: number; total: number; onChange: (p: number) => void }) { + return ( +
+ + + 第 {page} / {total} 页 + + +
+ ); +} diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx new file mode 100644 index 0000000..0e59f1a --- /dev/null +++ b/frontend/src/app/history/page.tsx @@ -0,0 +1,95 @@ +'use client'; +/** + * 历史归档 /history + * 角色:熟手 + 组长(管理员也能进) + * 数据:GET /api/v1/tasks?status=approved,archived,rejected + */ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { TaskListItem, PaginatedResponse } from '@/types'; +import { getErrorAction } from '@/types/errors'; +import { clsx } from 'clsx'; +import { HistoryTable, HistorySkeleton, Pagination } from './components'; + +const HISTORY_STATUSES = ['approved', 'archived', 'rejected']; +const STATUS_LABELS: Record = { + approved: '已通过', archived: '已归档', rejected: '被打回', +}; +const PAGE_SIZE = 20; + +export default function HistoryPage() { + const [tasks, setTasks] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState('all'); + + const fetchHistory = useCallback(async (p: number, status: string) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) }); + if (status !== 'all') params.append('status', status); + else HISTORY_STATUSES.forEach((s) => params.append('status', s)); + const res = await api.get>(`/api/v1/tasks?${params}`); + setTasks(res.items); + setTotal(res.total); + } catch (e: unknown) { + const ae = e as { code?: number }; + setError(getErrorAction(ae.code ?? 50001).message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+
+

历史归档

+ 共 {total} 条 +
+ + {/* 状态筛选 */} +
+ {[{ key: 'all', label: '全部' }, ...HISTORY_STATUSES.map((s) => ({ + key: s, label: STATUS_LABELS[s], + }))].map((opt) => ( + + ))} +
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? : tasks.length === 0 ? ( +
暂无历史记录
+ ) : ( + + )} + + {totalPages > 1 && } +
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..9efe792 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import { AuthGuard } from '@/components/AuthGuard'; +import { Sidebar } from '@/components/layout/Sidebar'; + +export const metadata: Metadata = { + title: '龙石小红书内容生产平台', + description: '小红书内容生产工具', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ {/* 顶部 Header */} +
+

+ 龙石小红书内容生产平台 +

+ +
+ +
+ +
+ {children} +
+
+
+
+ + + ); +} + +/** 右上角当前用户展示(Server Component 不可用 Store,Client 组件内联) */ +function UserBadge() { + // 通过 Client 组件获取 store 数据,此处为 Server Component 占位 + return ; +} + +// 内联 Client 组件,避免额外文件 +import UserBadgeClient from '@/components/layout/UserBadge'; diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..66bc08a --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,86 @@ +'use client'; +// /login — 登录页,按角色跳转首页(admin→/config, supervisor→/review, operator→/tasks/new) +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuthStore } from '@/stores/authStore'; +import { api } from '@/lib/api'; +import { LoginRequest, LoginResponse, ApiError } from '@/types/index'; +import { getErrorAction } from '@/types/errors'; + +const INPUT_CLS = 'w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange transition-colors'; + +export default function LoginPage() { + const router = useRouter(); + const { login, role } = useAuthStore(); + const [form, setForm] = useState({ username: '', password: '' }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!form.username || !form.password) { + setError('请填写用户名和密码'); + return; + } + setLoading(true); + setError(''); + try { + const data = await api.post('/api/v1/auth/login', form); + login(data.token, data.user); + // 按角色跳对应首页 + const dest = + data.user.role === 'admin' + ? '/config' + : data.user.role === 'supervisor' + ? '/review' + : '/tasks/new'; + router.replace(dest); + } catch (err) { + const apiErr = err as ApiError; + const action = getErrorAction(apiErr?.code); + setError(apiErr?.message || action.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +

龙石内容生产平台

+

小红书内容工具

+
+ +
+
+ + setForm((f) => ({ ...f, username: e.target.value }))} + className={INPUT_CLS} aria-required="true" /> +
+ +
+ + setForm((f) => ({ ...f, password: e.target.value }))} + className={INPUT_CLS} aria-required="true" /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..5521c34 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from 'next/navigation'; + +/** + * 根路由 / → 重定向到 /dashboard + * AuthGuard 会根据登录态再跳到对应首页 + */ +export default function RootPage() { + redirect('/dashboard'); +} diff --git a/frontend/src/app/review/page.tsx b/frontend/src/app/review/page.tsx new file mode 100644 index 0000000..1560076 --- /dev/null +++ b/frontend/src/app/review/page.tsx @@ -0,0 +1,98 @@ +'use client'; +// 屏5 组长审核台:通过(+5) / 打回必填原因(-3) +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { ReviewQueueItem, RejectRequest, PaginatedResponse, ApiError } from '@/types/index'; +import { RejectModal } from '@/components/review/RejectModal'; +import { ReviewCard, ReviewSkeleton } from '@/components/review/ReviewCard'; +import { getErrorAction } from '@/types/errors'; + +export default function ReviewPage() { + const [queue, setQueue] = useState([]); + const [loading, setLoading] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + useEffect(() => { loadQueue(); }, []); + + async function loadQueue() { + setLoading(true); + try { + const data = await api.get>('/api/v1/review/queue'); + setQueue(data.items); + } finally { + setLoading(false); + } + } + + function showToast(message: string, type: 'success' | 'error') { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + } + + async function handleApprove(taskId: number) { + try { + await api.post(`/api/v1/review/${taskId}/approve`); + setQueue((q) => q.filter((item) => item.task_id !== taskId)); + showToast('已通过,进入成品库', 'success'); + } catch (err) { + const apiErr = err as ApiError; + showToast(apiErr?.message || getErrorAction(apiErr?.code).message, 'error'); + } + } + + async function handleReject(taskId: number, reason: string) { + const req: RejectRequest = { reason }; + await api.post(`/api/v1/review/${taskId}/reject`, req); + setQueue((q) => q.filter((item) => item.task_id !== taskId)); + showToast('已打回,任务回到挑选状态', 'success'); + } + + return ( +
+
+

+ 待审笔记{queue.length > 0 && ({queue.length})} +

+
+ + {/* Toast 提示 */} + {toast && ( +
+ {toast.message} +
+ )} + + {loading ? ( + + ) : queue.length === 0 ? ( +
+ + 暂无待审笔记 +
+ ) : ( +
+ {queue.map((item) => ( + handleApprove(item.task_id)} + onReject={() => setRejectTarget(item.task_id)} + /> + ))} +
+ )} + +
+ 通过的笔记进入成品库,运营可导出文案+图+标签,人工去小红书/达人处发布。 +
+ + {rejectTarget !== null && ( + handleReject(rejectTarget, reason)} + onClose={() => setRejectTarget(null)} /> + )} +
+ ); +} diff --git a/frontend/src/app/tasks/[id]/confirm/page.tsx b/frontend/src/app/tasks/[id]/confirm/page.tsx new file mode 100644 index 0000000..b241e24 --- /dev/null +++ b/frontend/src/app/tasks/[id]/confirm/page.tsx @@ -0,0 +1,91 @@ +'use client'; +// 屏4.5 确认预览:提交审核 / 重新生成(飞轮-1) +import { useRouter, useParams } from 'next/navigation'; +import { ProgressBar } from '@/components/layout/ProgressBar'; +import { ConfirmActionBar } from '@/components/tasks/ConfirmActionBar'; +import { ConfirmPreviewImages } from '@/components/tasks/ConfirmPreviewImages'; +import { useTaskStore } from '@/stores/taskStore'; +import { useGenerationStore } from '@/stores/generationStore'; +import { api } from '@/lib/api'; +import { useState } from 'react'; + +export default function ConfirmPage() { + const params = useParams<{ id: string }>(); + const taskId = params?.id ? Number(params.id) : null; + const router = useRouter(); + const { selectedTextIds, selectedImageIds } = useTaskStore(); + // imageCandidates 平铺(契约 image_candidate 事件无 batch 字段) + const { textCandidates, imageCandidates } = useGenerationStore(); + const [submitting, setSubmitting] = useState(false); + const [regenerating, setRegenerating] = useState(false); + + const selectedTexts = textCandidates.filter((c) => selectedTextIds.includes(c.candidate_id)); + const selectedImages = imageCandidates.filter((c) => selectedImageIds.includes(c.candidate_id)); + + async function handleSubmitReview() { + if (!taskId) return; + setSubmitting(true); + try { + await api.post(`/api/v1/tasks/${taskId}/submit-review`); + router.push('/review'); + } finally { + setSubmitting(false); + } + } + + async function handleRegenerate() { + if (!taskId) return; + setRegenerating(true); + try { + await api.post(`/api/v1/tasks/${taskId}/regenerate`); + router.push(`/tasks/${taskId}/text`); + } finally { + setRegenerating(false); + } + } + + return ( +
+ + +
+

确认预览

+ +
+ {/* 已选文案 */} +
+

已选文案

+ {selectedTexts.length === 0 ? ( +

暂无选中文案

+ ) : ( +
+ {selectedTexts.map((c) => ( +
+ {c.angle_label} +

+ {c.content} +

+ 总分 {c.score.total} +
+ ))} +
+ )} +
+ + {/* 已选图 */} + +
+ + {/* 操作栏 */} + 0} + onSubmit={handleSubmitReview} + onRegenerate={handleRegenerate} + /> +
+
+ ); +} diff --git a/frontend/src/app/tasks/[id]/images/page.tsx b/frontend/src/app/tasks/[id]/images/page.tsx new file mode 100644 index 0000000..1b51795 --- /dev/null +++ b/frontend/src/app/tasks/[id]/images/page.tsx @@ -0,0 +1,86 @@ +'use client'; +// 屏4 挑图:0/N异步电影 + 平铺选图 + 单批重试(一期=整体重生) + 能离开 +import { useRouter, useParams } from 'next/navigation'; +import { ProgressBar } from '@/components/layout/ProgressBar'; +import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; +import { ImageProgressCardSkeleton } from '@/components/tasks/ImageProgressCard'; +import { ImageProgressHeader } from '@/components/tasks/ImageProgressHeader'; +import { ImageActionBar } from '@/components/tasks/ImageActionBar'; +import { SseStatusBar } from '@/components/tasks/SseStatusBar'; +import { useSse } from '@/hooks/useSse'; +import { useGenerationStore } from '@/stores/generationStore'; +import { useTaskStore } from '@/stores/taskStore'; +import { api } from '@/lib/api'; + +export default function ImageSelectionPage() { + const params = useParams<{ id: string }>(); + const taskId = params?.id ? Number(params.id) : null; + const router = useRouter(); + + const { imageCandidates, imageDone, imageTotal, failedBatches, flywheelInjected } = useGenerationStore(); + const { selectedImageIds, toggleImageSelection } = useTaskStore(); + const { connectionStatus, reconnectAttempt } = useSse(taskId); + + const pendingCount = imageTotal - imageDone; + const allDone = imageTotal > 0 && pendingCount === 0; + + async function handleRetry(_batchRole: string) { + // 一期 regenerate = 整体重生,契约无 batch 字段,不发 body + // 二期按批重生时再扩展,_batchRole 目前仅作入参占位(UI 提示用) + if (!taskId) return; + await api.post(`/api/v1/tasks/${taskId}/regenerate`); + } + + async function handleProceedToConfirm() { + if (!taskId || selectedImageIds.length === 0) return; + for (const candidateId of selectedImageIds) { + await api.post(`/api/v1/tasks/${taskId}/image-candidates/select`, { candidate_id: candidateId }); + } + router.push(`/tasks/${taskId}/confirm`); + } + + return ( +
+ + {flywheelInjected && ( + + )} + +
+ + {/* 图片网格(谁好谁先冒) */} +
+ {imageCandidates.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ) + : imageCandidates.map((c) => ( +
toggleImageSelection(c.candidate_id)} + className={`relative rounded-xl overflow-hidden border cursor-pointer transition-all + ${selectedImageIds.includes(c.candidate_id) ? 'border-brand-orange ring-2 ring-brand-orange' : 'border-border-default hover:border-brand-orange/50'}`} + aria-label={`图片 ${c.role},${selectedImageIds.includes(c.candidate_id) ? '已选中' : '未选中'}`} + > + {c.role} +

{c.role}

+
+ ))} +
+ {/* 失败批次提示 */} + {failedBatches.length > 0 && allDone && ( +
+ 有 {failedBatches.length} 批没出来。 + {failedBatches.filter((b) => b.retryable).map((b) => ( + + ))} +
+ )} + {imageCandidates.length > 0 && ( + + )} +
+
+ ); +} diff --git a/frontend/src/app/tasks/[id]/text/page.tsx b/frontend/src/app/tasks/[id]/text/page.tsx new file mode 100644 index 0000000..6994460 --- /dev/null +++ b/frontend/src/app/tasks/[id]/text/page.tsx @@ -0,0 +1,94 @@ +'use client'; +// 屏3 挑文案:SSE实时+五维分+多选+飞轮隐形 +import { useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { ProgressBar } from '@/components/layout/ProgressBar'; +import { FlywheelBannerFromSse } from '@/components/FlywheelBanner'; +import { TextCandidateCard, TextCandidateCardSkeleton } from '@/components/tasks/TextCandidateCard'; +import { SseStatusBar } from '@/components/tasks/SseStatusBar'; +import { useSse } from '@/hooks/useSse'; +import { useGenerationStore } from '@/stores/generationStore'; +import { useTaskStore } from '@/stores/taskStore'; +import { api } from '@/lib/api'; + +export default function TextSelectionPage() { + const params = useParams<{ id: string }>(); + const taskId = params?.id ? Number(params.id) : null; + const router = useRouter(); + + const { textCandidates, textDone, textTotal, flywheelInjected } = useGenerationStore(); + const { selectedTextIds, toggleTextSelection, setCurrentTask } = useTaskStore(); + const { connectionStatus, reconnectAttempt } = useSse(taskId); + + useEffect(() => { + if (taskId) setCurrentTask(taskId, 'generating'); + }, [taskId]); + + async function handleProceedToImages() { + if (!taskId || selectedTextIds.length === 0) return; + for (const candidateId of selectedTextIds) { + await api.post(`/api/v1/tasks/${taskId}/text-candidates/select`, { candidate_id: candidateId }); + } + router.push(`/tasks/${taskId}/images`); + } + + const isGenerating = textDone < textTotal || textTotal === 0; + const showSkeleton = textCandidates.length === 0 && isGenerating; + + return ( +
+ + {flywheelInjected && ( + + )} + +
+
+

+ 挑文案{textTotal > 0 && 已生成 {textDone}/{textTotal} 条} +

+ +
+ + {/* SSE 进度提示(生成中且无内容时) */} + {showSkeleton && ( +
+ 正在生成文案… 预计 15-30 秒,好了会更新,可先去忙别的 +
+ )} + + {/* 文案卡网格(谁好谁先冒) */} +
+ {showSkeleton + ? Array.from({ length: 4 }).map((_, i) => ) + : textCandidates.map((c) => ( + + ))} +
+ + {/* 底部操作栏 */} + {textCandidates.length > 0 && ( +
+ + 已选 {selectedTextIds.length} 条 + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/tasks/new/page.tsx b/frontend/src/app/tasks/new/page.tsx new file mode 100644 index 0000000..361005c --- /dev/null +++ b/frontend/src/app/tasks/new/page.tsx @@ -0,0 +1,99 @@ +'use client'; +/** + * 屏2 开新任务(熟手 /tasks/new) + * 使用 NewTaskForm + ConfigPreviewPanel + RecentTasksBar + FlywheelBanner + */ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ProgressBar } from '@/components/layout/ProgressBar'; +import { FlywheelBanner } from '@/components/FlywheelBanner'; +import { ConfigPreviewPanel } from '@/components/tasks/ConfigPreviewPanel'; +import { RecentTasksBar } from '@/components/tasks/RecentTasksBar'; +import { NewTaskForm } from '@/components/tasks/NewTaskForm'; +import { api } from '@/lib/api'; +import { Product, CreateTaskRequest } from '@/types/index'; +import { PaginatedResponse } from '@/types/index'; +import { usePreferenceStore } from '@/stores/preferenceStore'; +import { getErrorAction } from '@/types/errors'; +import { ApiError } from '@/types/index'; + +export default function NewTaskPage() { + const router = useRouter(); + const { context } = usePreferenceStore(); + const [products, setProducts] = useState([]); + const [selectedProduct, setSelectedProduct] = useState(null); + const [form, setForm] = useState>({ + benchmark_ids: [], + theme: '', + text_count: 8, + image_count: 6, + track: 'ai', + need_product_image: true, + }); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { loadProducts(); }, []); + + async function loadProducts() { + try { + const data = await api.get>('/api/v1/products'); + setProducts(data.items); + if (data.items.length > 0) setSelectedProduct(data.items[0]); + } catch { + // ignore + } + } + + async function handleSubmit(e: React.FormEvent, track: 'ai' | 'import') { + e.preventDefault(); + if (!selectedProduct) { setError('请选择产品'); return; } + if (!form.theme.trim()) { setError('请填写今天主题'); return; } + // 禁降级:产品入镜但该产品没传参考图 → 拦在前端,引导去上传 + if (form.need_product_image && !selectedProduct.image_path) { + setError('该产品还没上传参考图,无法生成产品入镜内容。请先到「配置-产品库」上传产品图,或关闭下方「产品入镜」开关。'); + return; + } + setSubmitting(true); + setError(''); + try { + const req: CreateTaskRequest = { product_id: selectedProduct.id, ...form, track }; + const data = await api.post<{ id: number }>('/api/v1/tasks', req); + router.push(`/tasks/${data.id}/text`); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr?.message || getErrorAction(apiErr?.code).message); + } finally { + setSubmitting(false); + } + } + + return ( +
+ + {context && } + +
+ {/* 主表单区 */} +
+

开新任务

+ setForm((f) => ({ ...f, ...patch }))} + submitting={submitting} + error={error} + onSubmitAi={(e) => handleSubmit(e, 'ai')} + onSubmitImport={(e) => handleSubmit(e, 'import')} + /> +
+
+ + {/* 右侧配置预览面板 */} + {selectedProduct && } +
+
+ ); +} diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx new file mode 100644 index 0000000..2cc4f1a --- /dev/null +++ b/frontend/src/components/AuthGuard.tsx @@ -0,0 +1,76 @@ +'use client'; +/** + * AuthGuard — 路由守卫 + * - 未登录 → /login + * - 越权(组长访问 /config,非管理员等)→ /dashboard + * 扒 banana AuthGuard 范式,全新重建 + */ +import { useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useAuthStore } from '@/stores/authStore'; +import { UserRole } from '@/types/index'; + +// 路由 → 最低所需角色 +const ROUTE_ROLES: Record = { + '/config': ['admin'], + '/review': ['admin', 'supervisor'], + '/history': ['admin', 'supervisor', 'operator'], + '/tasks/new': ['admin', 'operator'], + '/dashboard': ['admin', 'supervisor', 'operator'], +}; + +function getRequiredRoles(pathname: string): UserRole[] | null { + // 精确匹配或前缀匹配(如 /tasks/[id]/text) + if (ROUTE_ROLES[pathname]) return ROUTE_ROLES[pathname]; + for (const [prefix, roles] of Object.entries(ROUTE_ROLES)) { + if (pathname.startsWith(prefix + '/')) return roles; + } + return null; // 公开路由(/login 等) +} + +interface AuthGuardProps { + children: React.ReactNode; +} + +export function AuthGuard({ children }: AuthGuardProps) { + const router = useRouter(); + const pathname = usePathname(); + const { isAuthenticated, role, isLoading, fetchMe, endLoading } = useAuthStore(); + + useEffect(() => { + // 首屏校验:有 token 且 store 未初始化 → 拉 me;无 token → 结束 loading 让守卫放行判断 + const token = typeof window !== 'undefined' ? localStorage.getItem('clover_token') : null; + if (token && !isAuthenticated && isLoading) { + fetchMe(); + } else if (!token && isLoading) { + endLoading(); + } + }, [isAuthenticated, isLoading, fetchMe, endLoading]); + + useEffect(() => { + if (isLoading) return; + + // 公开路由直接放行 + if (pathname === '/login') return; + + if (!isAuthenticated) { + router.replace('/login'); + return; + } + + const required = getRequiredRoles(pathname); + if (required && role && !required.includes(role)) { + router.replace('/dashboard'); + } + }, [isAuthenticated, role, isLoading, pathname, router]); + + if (isLoading) { + return ( +
+
加载中…
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/components/FlywheelBanner.tsx b/frontend/src/components/FlywheelBanner.tsx new file mode 100644 index 0000000..8a15808 --- /dev/null +++ b/frontend/src/components/FlywheelBanner.tsx @@ -0,0 +1,65 @@ +'use client'; +/** + * FlywheelBanner — 飞轮注入提示横幅 + * 飞轮隐形:无"训练AI"按钮,只在生成时显示"本次已注入" + * 对应 flywheel_injected SSE 事件 + GET /preference/context + */ +import { PreferenceContext } from '@/types/index'; + +interface FlywheelBannerProps { + context: PreferenceContext; +} + +export function FlywheelBanner({ context }: FlywheelBannerProps) { + if (!context.injected_count && !context.recent_preference) return null; + + return ( +
+ +
+ 本次已注入: + {context.recent_preference && ( + {context.recent_preference} + )} + {context.reject_reasons?.length > 0 && ( + + · 打回原因:{context.reject_reasons.slice(0, 2).join(';')} + + )} +
+
+ ); +} + +/** + * FlywheelBannerFromSse — SSE flywheel_injected 事件版本 + */ +interface FlywheelBannerFromSseProps { + recentPreference: string; + rejectReasons: string[]; +} + +export function FlywheelBannerFromSse({ recentPreference, rejectReasons }: FlywheelBannerFromSseProps) { + return ( +
+ +
+ 本次已注入: + {recentPreference && {recentPreference}} + {rejectReasons?.length > 0 && ( + + · 打回原因:{rejectReasons.slice(0, 2).join(';')} + + )} +
+
+ ); +} diff --git a/frontend/src/components/config/ApiKeysTab.tsx b/frontend/src/components/config/ApiKeysTab.tsx new file mode 100644 index 0000000..33bb89b --- /dev/null +++ b/frontend/src/components/config/ApiKeysTab.tsx @@ -0,0 +1,98 @@ +'use client'; +// ApiKeysTab — API Key 录入(只录不显余额,CLAUDE.md红线) +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { ApiKey, CreateApiKeyRequest, PaginatedResponse, ApiError } from '@/types/index'; +import { getErrorAction } from '@/types/errors'; + +export function ApiKeysTab() { + const [keys, setKeys] = useState([]); + const [form, setForm] = useState({ provider: 'openai', api_key: '' }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { loadKeys(); }, []); + + async function loadKeys() { + setLoading(true); + try { + const data = await api.get>('/api/v1/api-keys'); + setKeys(data.items); + } catch { /* silent */ } finally { setLoading(false); } + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!form.api_key.trim()) { setError('请填写 API Key'); return; } + setSaving(true); setError(''); + try { + await api.post('/api/v1/api-keys', form); + setForm({ provider: 'openai', api_key: '' }); + await loadKeys(); + } catch (err) { + const apiErr = err as ApiError; + setError(apiErr?.message || getErrorAction(apiErr?.code).message); + } finally { setSaving(false); } + } + + async function handleDelete(id: number) { + if (!confirm('确认删除此 Key?')) return; + try { + await api.delete(`/api/v1/api-keys/${id}`); + setKeys((ks) => ks.filter((k) => k.id !== id)); + } catch { /* ignore */ } + } + + return ( +
+

+ API Key 由各人自录,余额和用量请到中转站后台查看。 +

+
+
+ + +
+
+ + setForm((f) => ({ ...f, api_key: e.target.value }))} + placeholder="sk-..." + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange" + aria-label="输入 API Key" + /> +
+ +
+ {error &&

{error}

} + {loading ? ( +
加载中…
+ ) : ( +
    + {keys.map((k) => ( +
  • + + {k.provider} + …{k.key_last4} + + +
  • + ))} + {keys.length === 0 && ( +
  • 暂无 Key,请先录入
  • + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/config/BannedWordsTab.tsx b/frontend/src/components/config/BannedWordsTab.tsx new file mode 100644 index 0000000..4b82413 --- /dev/null +++ b/frontend/src/components/config/BannedWordsTab.tsx @@ -0,0 +1,90 @@ +'use client'; +/** + * BannedWordsTab — 违禁词库管理(三级可调) + */ +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { BannedWord, CreateBannedWordRequest, BannedWordLevel } from '@/types/index'; +import { PaginatedResponse } from '@/types/index'; +import { BannedWordsTable } from './BannedWordsTable'; + +export function BannedWordsTab() { + const [words, setWords] = useState([]); + const [loading, setLoading] = useState(false); + const [form, setForm] = useState({ + word: '', level: 'soft_warn', replacement: null, updatable: true, + }); + const [saving, setSaving] = useState(false); + + useEffect(() => { loadWords(); }, []); + + async function loadWords() { + setLoading(true); + try { + const data = await api.get>('/api/v1/banned-words'); + setWords(data.items); + } finally { setLoading(false); } + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!form.word.trim()) return; + setSaving(true); + try { + await api.post('/api/v1/banned-words', form); + setForm({ word: '', level: 'soft_warn', replacement: null, updatable: true }); + await loadWords(); + } finally { setSaving(false); } + } + + async function handleDelete(id: number) { + if (!confirm('确认删除此违禁词?')) return; + await api.delete(`/api/v1/banned-words/${id}`); + setWords((ws) => ws.filter((w) => w.id !== id)); + } + + return ( +
+

+ 三级:自动改写(替换)/ 软提示(生成后提醒)/ 硬拦截(生成前阻止) +

+
+
+ + setForm((f) => ({ ...f, word: e.target.value }))} + className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange w-32" + aria-required="true" + /> +
+
+ + +
+
+ + setForm((f) => ({ ...f, replacement: e.target.value || null }))} + placeholder="如:提亮" + className="border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange w-28" + /> +
+ +
+ {loading ? ( +
加载中…
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/config/BannedWordsTable.tsx b/frontend/src/components/config/BannedWordsTable.tsx new file mode 100644 index 0000000..88adbe3 --- /dev/null +++ b/frontend/src/components/config/BannedWordsTable.tsx @@ -0,0 +1,54 @@ +'use client'; +/** + * BannedWordsTable — 违禁词列表表格 + */ +import { BannedWord, BannedWordLevel } from '@/types/index'; + +const LEVEL_LABELS: Record = { + auto_fix: { label: '🟢 自动改写', color: 'text-status-success bg-green-50' }, + soft_warn: { label: '🟡 软提示', color: 'text-status-warning bg-yellow-50' }, + hard_block: { label: '🔴 硬拦截', color: 'text-status-error bg-red-50' }, +}; + +interface BannedWordsTableProps { + words: BannedWord[]; + onDelete: (id: number) => void; +} + +export function BannedWordsTable({ words, onDelete }: BannedWordsTableProps) { + return ( + + + + + + + + + + + {words.map((w) => ( + + + + + + + ))} + {words.length === 0 && ( + + )} + +
级别替换词操作
{w.word} + + {LEVEL_LABELS[w.level].label} + + {w.replacement ?? '—'} + {w.updatable && ( + + )} +
暂无违禁词
+ ); +} diff --git a/frontend/src/components/config/BenchmarkForm.tsx b/frontend/src/components/config/BenchmarkForm.tsx new file mode 100644 index 0000000..b631b04 --- /dev/null +++ b/frontend/src/components/config/BenchmarkForm.tsx @@ -0,0 +1,66 @@ +'use client'; +/** + * BenchmarkForm — 标杆笔记上传表单 + */ +import { useState } from 'react'; +import { api } from '@/lib/api'; +import { CreateBenchmarkRequest } from '@/types/index'; + +interface BenchmarkFormProps { + productId: number; + onSaved: () => void; + onCancel: () => void; +} + +export function BenchmarkForm({ productId, onSaved, onCancel }: BenchmarkFormProps) { + const [form, setForm] = useState({ + screenshot_url: '', highlights: '', link_url: null, + }); + const [saving, setSaving] = useState(false); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + if (!form.screenshot_url || !form.highlights) return; + setSaving(true); + try { + await api.post(`/api/v1/products/${productId}/benchmarks`, form); + onSaved(); + } finally { + setSaving(false); + } + } + + return ( +
+

上传标杆笔记

+
+ + setForm((f) => ({ ...f, screenshot_url: e.target.value }))} + placeholder="https://..." + className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange" + required aria-required="true" + /> +
+
+ +