修复:加固BGM助手完整性与入口保护
冻结简化输入上限、请求体边界与未完成响应规则 拒绝不完整模型响应并补齐2000字和413校验 补充助手路由埋点、回归测试与长期决策记录
This commit is contained in:
@@ -5944,3 +5944,15 @@
|
||||
- 影响范围:画板音乐权威设计、BGM composer 与临时状态模型、`editorProjectClient`、`shared-contracts` 内部助手 DTO、`api-server` 登录态 Prompt 助手与正式 BGM BFF、正式 generation queue 载荷、`platform-audio` Suno body builder,以及 BGM 提交与端到端测试。SFX 继续使用 Vidu `audio1.0`、现有规范化、默认 Prompt、1500 字限制和时长契约,不应用 BGM 的 canonicalization 或 0 / 1 / 2 有效字符规则;本规则不修改 SpacetimeDB schema,也不改变或扩展 External v1 / OpenAPI 的背景音乐请求、异步语义和路由,Suno 三字段 body、固定模型 / 泥点展示和现有 LLM 原文日志策略不变。
|
||||
- 验证方式:TypeScript 与 Rust 对 CR / LF、CRLF、组合字符、ZWJ emoji、U+0085、U+200B、U+FEFF 和 199 / 200 / 201 code point 得出一致结果;首尾 U+0085 等 Unicode `White_Space` 被移除并同步反映到输入框,U+FEFF 与零宽字符不被误删。状态测试分别锁定全空白、0 / 1 / 2 个有效字符、预设追加、补全 / 简化失败不覆盖 canonical Prompt、单层交换撤销、迟到响应和同 dialog 双击;端到端断言 canonical 输入框、BFF、队列载荷、Suno body、记录和响应等值,且不存在隐藏 Prompt。SFX 请求体、默认 Prompt、1500 字限制和时长不变,External v1 契约测试无差异。文档阶段运行 `npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-04 BGM Prompt 助手补齐输入、完成状态与埋点边界
|
||||
|
||||
- 背景:BGM Prompt 助手基线已经固定四字段 envelope、完整 `response.text` JSON 解析和 `180 → 170` 简化轮次,但权威设计仍把所有 201 字以上输入视为可简化,也没有明确 OpenAI Chat 未完成响应、HTTP body 上限及成功路由埋点;这会让超长文本消耗共享模型额度,并可能把 `finish_reason = length / content_filter` 的偶然闭合 JSON 当作完整候选。
|
||||
- 决策:本条补充并取代上一条中“总数超过 200 即只允许简化”的无上限表述。一键简化只接受至少含 1 个有效字符且总数为 `201–2000` 的 canonical Prompt,最大值等于正式生成 200 字上限的 10 倍;超过 2000 时保留完整文本但禁用 AI 补全、一键简化和正式生成,服务端以现有 `400 BAD_REQUEST` 和字段 `currentPrompt` 拒绝。两个助手路由分别设置 `32 KiB` HTTP body 上限,body 超限保持 Axum `413 PAYLOAD_TOO_LARGE`,字符上限与 body 上限独立校验。
|
||||
- LLM 完成状态:在解析 envelope、canonicalize 候选或提取重试 `prompt` 前,使用 `platform-llm` 现有 API-kind-aware 未完成原因判断检查 OpenAI Chat `finish_reason`;去除外围空白并忽略 ASCII 大小写后的 `length` / `content_filter` 均不可信,即使正文形成合法 JSON 也不得接受或提取。补全遇到两者均直接失败;简化第一轮 `length` 只以冻结的 `originalPrompt` 进入 170 字轮,第一轮 `content_filter` 直接失败,第二轮出现任一未完成原因都最终失败。缺失、空值或未知自定义 reason 不因该字段单独拒绝,继续执行其余门禁,不采用 `stop` 白名单。`platform-llm` 只公开复用 predicate,不改变其它普通文本调用方的降级行为。
|
||||
- 限流与埋点:不增加 Prompt 助手专属的用户级、IP 级、时间窗口、令牌桶或本地额度限流,不新增功能级 `429` / `Retry-After`;现有 api-server 全局并发背压、前端防重复操作、Nginx 保护和上游真实 `429` 安全映射不变。两个成功路由进入 `tracking.rs` 静态映射:补全为 `editor_background_music_prompt_completion`,简化为 `editor_background_music_prompt_simplification`,两者均使用 `module_key = editor`、User scope;普通 route tracking 继续只记录成功响应并走现有本机 outbox,助手 handler 不同步写 SpacetimeDB。
|
||||
- 影响范围:BGM composer 动作状态与 client、`api-server` Prompt 助手路由和候选验收、`platform-llm` 公共未完成原因 predicate、route tracking 静态映射及相应测试;不修改 SFX、正式 BGM 200 字限制、canonicalization 算法、SpacetimeDB schema、External v1 / OpenAPI 或 LLM 原文日志策略。
|
||||
- 验证方式:覆盖 canonical 2000 / 2001、两个路由 `32 KiB` / `413`、补全与两轮简化的 `length` / `content_filter`、缺失和未知 reason 兼容、连续合法请求无功能级 `429`,以及两个成功路由的 event key、`editor` module 和 User scope;运行 `api-server` 与 `platform-llm` 定向测试、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`。
|
||||
|
||||
@@ -55,9 +55,11 @@
|
||||
| 空字符串(总数 0、有效字符数 0) | 禁止 | 禁止 | 禁止 |
|
||||
| 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 |
|
||||
| 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 |
|
||||
| 至少 1 个有效字符且总数超过 200 | 禁止 | 允许 | 禁止 |
|
||||
| 至少 1 个有效字符且总数为 201–2000 | 禁止 | 允许 | 禁止 |
|
||||
| 总数超过 2000 | 禁止 | 禁止 | 禁止 |
|
||||
|
||||
- canonical Prompt 满足不变量:`有效字符数 = 0` 当且仅当 `总字符数 = 0`。原始输入即使完全由 201 个或更多 Unicode `White_Space` 组成,canonicalization 后仍为空字符串,AI 补全、一键简化和正式生成全部禁止;不得按规范化前的长度把全空白输入归入“可简化”状态。
|
||||
- 一键简化最大输入固定为正式生成合法上限的 10 倍,即 `200 × 10 = 2000` 个 canonical Unicode code point。超过 2000 时必须完整保留当前文本供用户手动编辑,不得截断或丢失,但 AI 补全、一键简化和正式生成都不可用。
|
||||
|
||||
### 预设库与追加规则
|
||||
|
||||
@@ -125,6 +127,8 @@
|
||||
- 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。
|
||||
- 内部 envelope 的 `prompt` 字段只允许包含一条可直接写回输入框的中文 BGM Prompt;候选文本本身不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。
|
||||
- 补全与简化共用同一份内部结构化 envelope:`prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`。助手显式使用现有 OpenAI Chat 协议,envelope 由完整 `response.text` 中唯一一个 JSON object 承载;服务端只允许 `serde_json` 对完整文本做全量解析,允许 JSON object 外围存在 JSON whitespace,但不接受代码块、前后解释、多个 JSON 值或从字符串中截取 object,也不执行自动修复。助手请求不发送 function tools,不做运行时双协议 fallback;响应出现 tool call 时同样视为结构非法。envelope 只用于服务端解析和判断,不属于候选文本,不写回输入框,也不通过 BFF 暴露给客户端。
|
||||
- 服务端必须在解析 envelope、canonicalize 候选或提取任何 `prompt` 前检查 OpenAI Chat `finish_reason`。去除外围空白并忽略 ASCII 大小写后,`length` 和 `content_filter` 都属于未完成响应;即使正文恰好是合法四字段 JSON,也不得接受、解析或提取候选。`finish_reason` 缺失、为空或为未知自定义值时,不因该字段单独拒绝,继续执行其余结构与候选门禁;不得改为只允许 `stop`。
|
||||
- 补全遇到 `finish_reason = length` 或 `content_filter` 时均直接失败,不写回、不泄漏响应正文或候选,且仍只执行一个业务语义轮。
|
||||
- 补全候选先执行同一套首尾 Unicode 空白规范化,再由程序计算字符数。候选只有在四字段结构有效、canonical `prompt` 包含有效字符且不超过 200 个 Unicode code point,并且三个布尔字段依次为 `true / true / false` 时才通过;程序不使用关键词或未定义正则猜测候选格式、完整性或残句。通过后整段写回输入框并成为唯一最终 Prompt。
|
||||
- 补全只执行一个业务语义轮。`LlmClient` 在该轮内部按现有配置执行的 transport retry 不计为新增业务语义轮;该轮最终发生 transport、超时或上游失败时直接返回失败,不再发起内容修复轮。
|
||||
- 调用失败、结果为空、结构或判断不合格、格式非法或超限时保留请求前已经写回的规范化 Prompt,不写回部分结果;错误响应不得包含未通过候选或内部 envelope。
|
||||
@@ -132,11 +136,12 @@
|
||||
|
||||
### 一键简化
|
||||
|
||||
- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 总字符数超过 200 时允许调用。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。
|
||||
- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 至少含 1 个有效字符且总字符数为 201–2000 时允许调用。超过 2000 时完整保留文本,但不得调用简化 BFF。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。
|
||||
- 服务端在本次简化中冻结 `originalPrompt` 为入站 canonical Prompt,最多两个业务语义轮期间始终不变,作为内容保真参照。第一次业务语义轮使用 `currentPrompt = originalPrompt`,目标为 180 字。这里的 `originalPrompt` / `currentPrompt` 是服务端组装 LLM 简化模板时的内部变量;客户端简化 BFF DTO 仍只提交一个 `currentPrompt`,该入站值经 canonicalization 后同时成为内部 `originalPrompt` 和第一次内部 `currentPrompt`。每个业务语义轮内部由 `LlmClient` 按现有配置执行的 transport retry 不增加业务语义轮数。
|
||||
- 180 不是硬门槛。简化使用与补全相同的四字段内部结构化 envelope;envelope 不属于候选文本,也不得写回输入框或通过 BFF 暴露。响应不能解析为包含上述正确字段类型的对象时,本次候选不通过。
|
||||
- 候选“格式合法”专指 `isDirectWritebackFormat = true`:模型确认 canonical 候选只包含一条可直接写回输入框的中文 BGM Prompt,不包含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明。它与“内部结构可解析”是两个独立校验项;程序不得另用关键词或未定义的正则推断标题、解释等语义格式。
|
||||
- 第一次业务语义轮成功取得上游响应、但候选未通过任一通过条件时,自动进行唯一一个第二业务语义轮,目标为 170 字。只有当完整 `response.text` 已按上述规则全量解析为单个 JSON object 时,才允许从该 object 读取字符串 `prompt`;禁止从未完整解析的文本、代码块、解释或畸形 JSON 中做子串提取。若可提取的字符串 `prompt` canonicalize 后包含有效字符,第二次使用 `currentPrompt = canonicalize(第一次候选)`,即使第一次因其它字段缺失、超限、格式、完整性或残句判断而不通过;若完整响应无法解析为单个 object、object 中没有字符串 `prompt`,或候选 canonicalize 后不含有效字符,第二次回退使用 `currentPrompt = originalPrompt`。第二次业务语义轮中的 `originalPrompt` 始终仍是最初入站 canonical Prompt。
|
||||
- 第一轮 `finish_reason = length` 时整份响应不可信,不得接受或提取其中的 `prompt`;它只按“成功取得响应但候选不合格”进入唯一的 170 字轮,第二轮必须使用 `currentPrompt = originalPrompt`。第一轮 `finish_reason = content_filter` 时直接失败,不进入第二轮。第二轮出现 `length` 或 `content_filter` 时最终失败,不得发起第三轮。该规则优先于上一条的一般候选提取与回退规则。
|
||||
- 第一次业务语义轮最终发生 transport、超时或上游失败时直接返回失败,不进入 170 字内容修复轮;这类失败只应用该轮内部既有的 `LlmClient` transport retry。
|
||||
- 每次候选都先执行同一套首尾 Unicode 空白规范化,再计算实际字符数。第二次仍不合格时返回失败并保留请求前已经写回的规范化 Prompt,提示用户手动精简;错误响应不得包含第一次或第二次未通过候选、可提取的 `prompt` 或内部 envelope。最多两个业务语义轮,任何情况下都不得由程序截断到 200 字。
|
||||
- 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。
|
||||
@@ -244,9 +249,13 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
```
|
||||
|
||||
- 客户端不得提交 `maxChars`、`targetChars`、模型名、预设 ID、分组、颜色、内部模板或格式 / 完整性 / 残句判断;这些由服务端固定或由内部 LLM 结果产生。
|
||||
- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库或 SpacetimeDB。
|
||||
- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库,也不在 handler 中同步执行 SpacetimeDB 业务写入;成功路由的通用 tracking 仍由现有本机 outbox 异步承接。
|
||||
- 两个助手请求中的 `currentPrompt` 必须是前端已经写回输入框的 canonical Prompt;响应 `prompt` 也必须先执行同一 canonicalization,`charCount` 是响应 canonical Prompt 的 Unicode code point 数。
|
||||
- 补全与简化成功响应都只包含上面的 `prompt` / `charCount` 业务字段;内部四字段 envelope、`originalPrompt`、目标字数、业务语义轮信息和未通过候选均不得出现在成功或失败响应中。失败继续使用现有 API 错误 envelope。
|
||||
- 简化 BFF 只接受总字符数为 201–2000 且至少含 1 个有效字符的 canonical `currentPrompt`;超过 2000 返回现有 `400 BAD_REQUEST` 错误 envelope,并标记字段 `currentPrompt`。
|
||||
- 两个助手路由都设置 `32 KiB` HTTP 请求体上限。请求体超过该上限时保留 Axum `413 PAYLOAD_TOO_LARGE`,不得被通用 JSON rejection 降为 `400`;字符上限与原始 body 上限分别校验,不能互相替代。
|
||||
- 不增加 Prompt 助手专属的用户级、IP 级、时间窗口或令牌桶限流,也不新增本功能主动产生的 `429` / `Retry-After`。现有 api-server 全局并发背压、前端防重复操作、Nginx 保护和上游真实 `429` 的安全映射保持不变。
|
||||
- 两个成功路由必须进入 `tracking.rs` 显式静态映射:补全使用 `event_key = editor_background_music_prompt_completion`,简化使用 `event_key = editor_background_music_prompt_simplification`;两者都使用 `module_key = editor`、User scope。普通 route tracking 继续只记录成功响应,不在助手 handler 中新增同步埋点副作用。
|
||||
- BGM 正式提交必须使用输入框已经写回的 canonical Prompt,不得额外拼接用户不可见内容,也不得把空 Prompt 回退为“游戏背景音乐”。
|
||||
|
||||
统一响应:
|
||||
@@ -292,7 +301,9 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
- 在 `api-server` 增加登录态内部 BGM Prompt 助手 BFF:
|
||||
- `POST /api/editor/audios/background-music/prompts/completions`
|
||||
- `POST /api/editor/audios/background-music/prompts/simplifications`
|
||||
- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则和 200 字限制都基于 canonical Prompt。助手使用现有编辑器专用 LLM client、`gpt-5.4-mini` 默认配置和显式 OpenAI Chat 协议;补全固定执行一个业务语义轮,简化按 `180 -> 170` 最多两个业务语义轮,并按“一键简化”章节冻结 `originalPrompt`、派生每轮 `currentPrompt`。`LlmClient` 在单轮内部执行的 transport retry 不计入业务语义轮数,简化第一轮 transport、超时或上游失败不进入 170 字轮。服务端负责模板组装、canonical 字符校验、对完整 `response.text` 中单个 JSON object 的 `serde_json` 全量解析、补全与简化共用的内部 envelope 校验、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断,也不向客户端返回未通过候选。助手不发送 function tools,不接受 tool call,不从代码块或解释中截取 JSON,不自动修复,也不做运行时双协议 fallback。
|
||||
- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则、正式生成 200 字限制和简化 201–2000 字资格都基于 canonical Prompt。助手使用现有编辑器专用 LLM client、`gpt-5.4-mini` 默认配置和显式 OpenAI Chat 协议;补全固定执行一个业务语义轮,简化按 `180 -> 170` 最多两个业务语义轮,并按“一键简化”章节冻结 `originalPrompt`、派生每轮 `currentPrompt`。`LlmClient` 在单轮内部执行的 transport retry 不计入业务语义轮数,简化第一轮 transport、超时或上游失败不进入 170 字轮。服务端负责模板组装、在正文解析或候选提取前检查 `finish_reason`、canonical 字符校验、对完整 `response.text` 中单个 JSON object 的 `serde_json` 全量解析、补全与简化共用的内部 envelope 校验、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断,也不向客户端返回未通过候选。助手不发送 function tools,不接受 tool call,不从代码块或解释中截取 JSON,不自动修复,也不做运行时双协议 fallback。
|
||||
- `finish_reason` 检查复用并公开 `platform-llm` 现有 API-kind-aware 未完成原因 predicate;不得在 `LlmClient` 全局拒绝普通纯文本响应,也不得改变其它调用方既有的长文本降级行为。
|
||||
- 两个助手路由使用各自的 `32 KiB` body limit,并在 `tracking.rs` 中注册上述 User-scope 成功事件;不增加助手专属限流器、本地额度计数或功能级 `429`。
|
||||
- Prompt 助手继续复用 `LlmClient` 现有失败原文日志行为。本需求不增加请求级日志开关、脱敏、metadata-only 模式或相关上线门禁。
|
||||
- BGM generation BFF 在入站时防御性执行同一幂等 canonicalization,规范化后校验有效字符和 200 字限制,并把 canonical Prompt 写入正式 generation queue 请求载荷、持久化记录和响应;删除空 Prompt 默认回退。助手模板只用于生成输入框可见候选,不得进入正式队列或 Suno 请求。SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。
|
||||
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。
|
||||
@@ -318,11 +329,12 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。
|
||||
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
|
||||
- 刷新后 layout 能恢复音频生成器和音频图层。
|
||||
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,非空且达到 201 个 code point 时只能一键简化。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
|
||||
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,至少含 1 个有效字符且为 201–2000 个 code point 时只能一键简化,超过 2000 时三个动作全部禁止且完整保留文本。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
|
||||
- 三组 30 个 BGM 预设按本文固定文案写入或追加;点击前先删除首尾 Unicode `White_Space` 并写回,再基于规范化结果判断空值和末尾标点。重复点击和追加后超限不得丢失 canonical Prompt,内部空格和内部换行保持原位。
|
||||
- 预设展开后默认无缝慢速循环,桌面端左 / 中 / 右区域分别加速向左、暂停、加速向右,左右箭头同步加速;组件在触摸环境被渲染时可横向滚动并选择词条,本需求不恢复移动端图片画布入口。
|
||||
- AI 补全的输入和候选都先 canonicalize;补全只执行一个业务语义轮,候选必须通过共用四字段 envelope、有效字符、200 字和 `true / true / false` 三个判断后,才写回一条中文 canonical Prompt。失败、空值、结构或判断错误、格式错误或超限结果保留请求前已经写回的 canonical Prompt,错误响应不暴露候选,也不调用 Suno 或扣除正式音乐生成泥点。
|
||||
- 一键简化资格、180 / 170 目标后的实际字符数和候选校验都基于 canonical Prompt;第一次使用冻结的 `originalPrompt`,第二次优先处理第一次成功响应中可提取的非空 canonical 候选,否则回退处理 `originalPrompt`,且两个业务语义轮都以同一 `originalPrompt` 作保真参照。第一轮 transport、超时或上游失败直接失败,不进入第二轮;每轮内部的 `LlmClient` transport retry 不增加业务语义轮数。候选必须同时通过结构、有效字符、200 字、格式、完整性和残句校验;第二次仍失败时保留请求前已经写回的 canonical Prompt,不暴露任一未通过候选,程序不得截断。
|
||||
- OpenAI Chat `finish_reason = length / content_filter` 的正文即使形成合法四字段 JSON 也不能通过或被提取;补全遇到两者均直接失败,简化第一轮 `length` 只以冻结的 `originalPrompt` 进入 170 字轮、第一轮 `content_filter` 直接失败,第二轮遇到任一未完成 reason 都最终失败。缺失、空值和未知自定义 reason 继续执行其余门禁。
|
||||
- Prompt 助手协议测试必须证明请求显式使用 OpenAI Chat 且不发送 function tools;只接受完整 `response.text` 全量解析所得的单个 JSON object。外围 JSON whitespace 可以通过,代码块、前后解释、多个 JSON 值、畸形 JSON、仅能子串提取的 object 和任意 tool call 均失败,不触发自动修复或运行时协议 fallback。
|
||||
- Prompt 助手轮次测试必须区分业务语义轮和单轮内部 transport retry:补全始终只有一个业务语义轮;简化只有第一轮成功返回但候选不合格时才进入 170 字轮,第一轮 transport、超时或上游失败不进入第二轮。
|
||||
- AI 补全和简化成功均产生一层 canonical Prompt 交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销前先规范化当前输入并可在两个 canonical 版本间反复互换。
|
||||
@@ -330,5 +342,7 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
- BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,不锁整个画布或其它 dialog。
|
||||
- BGM 正式提交会删除首尾 Unicode `White_Space` 并同步写回输入框,不回退默认 Prompt;首尾 U+0085 等 `White_Space` 被删除,内部空格和 LF / CRLF 原样保留,U+200B、U+FEFF、组合字符和 ZWJ emoji 不被误删。canonical Prompt 在输入框、BFF、队列载荷、Suno body、生成记录和结果响应中完全一致,且没有用户不可见的前缀、后缀或模板。
|
||||
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
|
||||
- BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`。
|
||||
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
|
||||
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;SFX 的 Vidu body、默认 Prompt、时长与 1500 字限制无回归。
|
||||
- 本切片的定向前端、shared-contracts、`api-server`、`platform-audio` 和端到端 Prompt 等值测试通过,并执行 `npm run typecheck`、对应 Rust 定向测试、`npm run check:encoding` 与 `git diff --check`。
|
||||
|
||||
@@ -27,6 +27,8 @@ use crate::{
|
||||
const BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS: u32 = 1024;
|
||||
const BACKGROUND_MUSIC_PROMPT_MAX_CHARS: usize =
|
||||
platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS;
|
||||
const BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS: usize =
|
||||
platform_audio::BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS;
|
||||
const BACKGROUND_MUSIC_SIMPLIFICATION_MIN_EFFECTIVE_CHARS: usize = 1;
|
||||
const BACKGROUND_MUSIC_SIMPLIFICATION_FIRST_TARGET_CHARS: usize = 180;
|
||||
const BACKGROUND_MUSIC_SIMPLIFICATION_SECOND_TARGET_CHARS: usize = 170;
|
||||
@@ -109,6 +111,11 @@ fn validate_background_music_simplification_input(value: &str) -> Result<String,
|
||||
"currentPrompt 只有超过 {BACKGROUND_MUSIC_PROMPT_MAX_CHARS} 字符时才能简化"
|
||||
)));
|
||||
}
|
||||
if char_count > BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS {
|
||||
return Err(background_music_prompt_bad_request(format!(
|
||||
"currentPrompt 不能超过 {BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS} 字符"
|
||||
)));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
@@ -166,6 +173,9 @@ async fn run_background_music_prompt_simplification(
|
||||
))
|
||||
.await
|
||||
.map_err(map_background_music_prompt_llm_error)?;
|
||||
if has_background_music_prompt_finish_reason(&first_response, "content_filter") {
|
||||
return Err(background_music_simplification_invalid_response());
|
||||
}
|
||||
let first_candidate = inspect_background_music_prompt_candidate(&first_response);
|
||||
if let Some(accepted) = first_candidate.accepted {
|
||||
return Ok(accepted);
|
||||
@@ -208,12 +218,24 @@ fn build_background_music_prompt_llm_request(
|
||||
fn inspect_background_music_prompt_candidate(
|
||||
response: &LlmRunResponse,
|
||||
) -> BackgroundMusicPromptCandidateInspection {
|
||||
if response.finish_reason.as_deref().is_some_and(|reason| {
|
||||
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
|
||||
}) {
|
||||
return BackgroundMusicPromptCandidateInspection::default();
|
||||
}
|
||||
inspect_background_music_prompt_candidate_text(
|
||||
response.text.as_str(),
|
||||
!response.tool_calls.is_empty(),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_background_music_prompt_finish_reason(response: &LlmRunResponse, expected: &str) -> bool {
|
||||
response
|
||||
.finish_reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| reason.trim().eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
fn inspect_background_music_prompt_candidate_text(
|
||||
text: &str,
|
||||
has_tool_calls: bool,
|
||||
@@ -320,9 +342,16 @@ fn parse_json_payload<T>(
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
) -> Result<Json<T>, Response> {
|
||||
payload.map_err(|rejection| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(format!("请求体 JSON 不合法:{rejection}"))
|
||||
.into_response_with_context(Some(request_context))
|
||||
let status = if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
let mut error = AppError::from_status(status);
|
||||
if status == StatusCode::BAD_REQUEST {
|
||||
error = error.with_message(format!("请求体 JSON 不合法:{rejection}"));
|
||||
}
|
||||
error.into_response_with_context(Some(request_context))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -418,6 +447,18 @@ mod tests {
|
||||
.expect("201 canonical characters should be simplifiable"),
|
||||
"A".repeat(201)
|
||||
);
|
||||
assert!(
|
||||
validate_background_music_simplification_input(
|
||||
&"A".repeat(BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_background_music_simplification_input(
|
||||
&"A".repeat(BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS + 1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -655,6 +696,147 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_limit_counts_unicode_code_points_independently_from_body_bytes() {
|
||||
let (app, token) = authenticated_test_app(
|
||||
AppConfig::default(),
|
||||
"13800138251",
|
||||
"sess_bgm_prompt_simplification_input_limit",
|
||||
)
|
||||
.await;
|
||||
let escaped_non_bmp_code_point = "\\ud83d\\ude00";
|
||||
|
||||
let accepted_body = format!(
|
||||
r#"{{"currentPrompt":"{}"}}"#,
|
||||
escaped_non_bmp_code_point.repeat(BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS)
|
||||
);
|
||||
assert!(
|
||||
accepted_body.len()
|
||||
< crate::modules::platform_support::BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES
|
||||
);
|
||||
let (accepted_status, accepted_payload) = post_raw_prompt_request(
|
||||
app.clone(),
|
||||
SIMPLIFICATION_PATH,
|
||||
Some(token.as_str()),
|
||||
&accepted_body,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(accepted_status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(accepted_payload["error"]["code"], "SERVICE_UNAVAILABLE");
|
||||
|
||||
let rejected_body = format!(
|
||||
r#"{{"currentPrompt":"{}"}}"#,
|
||||
escaped_non_bmp_code_point.repeat(BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS + 1)
|
||||
);
|
||||
assert!(
|
||||
rejected_body.len()
|
||||
< crate::modules::platform_support::BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES
|
||||
);
|
||||
let (rejected_status, rejected_payload) = post_raw_prompt_request(
|
||||
app,
|
||||
SIMPLIFICATION_PATH,
|
||||
Some(token.as_str()),
|
||||
&rejected_body,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rejected_status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(rejected_payload["error"]["code"], "BAD_REQUEST");
|
||||
assert_eq!(
|
||||
rejected_payload["error"]["details"]["field"],
|
||||
"currentPrompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_assist_body_limit_is_inclusive_and_keeps_413_contract() {
|
||||
let (app, token) = authenticated_test_app(
|
||||
AppConfig::default(),
|
||||
"13800138252",
|
||||
"sess_bgm_prompt_body_limit",
|
||||
)
|
||||
.await;
|
||||
let limit =
|
||||
crate::modules::platform_support::BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES;
|
||||
let body_at_limit = prompt_request_body_with_exact_bytes(limit);
|
||||
let body_over_limit = prompt_request_body_with_exact_bytes(limit + 1);
|
||||
|
||||
for path in [COMPLETION_PATH, SIMPLIFICATION_PATH] {
|
||||
let (at_limit_status, at_limit_payload) = post_raw_prompt_request(
|
||||
app.clone(),
|
||||
path,
|
||||
Some(token.as_str()),
|
||||
&body_at_limit,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
at_limit_status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{path}: {at_limit_payload}"
|
||||
);
|
||||
assert_eq!(at_limit_payload["error"]["code"], "BAD_REQUEST");
|
||||
|
||||
let (over_limit_status, over_limit_payload) = post_raw_prompt_request(
|
||||
app.clone(),
|
||||
path,
|
||||
Some(token.as_str()),
|
||||
&body_over_limit,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
over_limit_status,
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"{path}: {over_limit_payload}"
|
||||
);
|
||||
assert_eq!(over_limit_payload["error"]["code"], "PAYLOAD_TOO_LARGE");
|
||||
assert_eq!(over_limit_payload["error"]["message"], "请求体过大");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consecutive_valid_requests_are_not_subject_to_a_feature_rate_limit() {
|
||||
let successful_response = MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response(valid_candidate_json("可直接写回的背景音乐描述")),
|
||||
};
|
||||
let mock = spawn_open_ended_mock_llm_server(Vec::new(), successful_response);
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
"13800138253",
|
||||
"sess_bgm_prompt_no_feature_rate_limit",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for (path, current_prompt) in [
|
||||
(COMPLETION_PATH, "森林冒险"),
|
||||
(COMPLETION_PATH, "海边小镇"),
|
||||
(SIMPLIFICATION_PATH, &"原".repeat(201)),
|
||||
(SIMPLIFICATION_PATH, &"曲".repeat(201)),
|
||||
] {
|
||||
responses.push(
|
||||
post_prompt_request(
|
||||
app.clone(),
|
||||
path,
|
||||
Some(token.as_str()),
|
||||
current_prompt,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
|
||||
let requests = mock.finish();
|
||||
assert_eq!(requests.len(), 4);
|
||||
for (status, payload) in responses {
|
||||
assert_eq!(status, StatusCode::OK, "{payload}");
|
||||
assert_ne!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_route_uses_canonical_prompt_and_returns_standard_envelope() {
|
||||
let candidate = valid_candidate_json("\u{0085}森林冒险背景音乐\u{0085}");
|
||||
@@ -724,6 +906,70 @@ mod tests {
|
||||
assert_eq!(mock.finish().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_rejects_incomplete_finish_reasons_before_accepting_candidate() {
|
||||
for (index, finish_reason) in [" LeNgTh ", " CONTENT_FILTER "].into_iter().enumerate() {
|
||||
let candidate = format!("不应泄露的未完成补全候选{index}");
|
||||
let mock = spawn_mock_llm_server(vec![MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response_with_finish_reason(
|
||||
valid_candidate_json(&candidate),
|
||||
Some(finish_reason),
|
||||
),
|
||||
}]);
|
||||
let phone_number = format!("1380013830{index}");
|
||||
let session_id = format!("sess_bgm_prompt_incomplete_completion_{index}");
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
&phone_number,
|
||||
&session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, payload) =
|
||||
post_prompt_request(app, COMPLETION_PATH, Some(token.as_str()), "森林冒险", true)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_GATEWAY, "{finish_reason}");
|
||||
assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR");
|
||||
assert!(!payload.to_string().contains(&candidate));
|
||||
assert_eq!(mock.finish().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completion_keeps_missing_empty_and_unknown_finish_reason_compatible() {
|
||||
for (index, finish_reason) in [None, Some(""), Some(" vendor_specific_done ")]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let candidate = format!("兼容补全候选{index}");
|
||||
let mock = spawn_mock_llm_server(vec![MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response_with_finish_reason(
|
||||
valid_candidate_json(&candidate),
|
||||
finish_reason,
|
||||
),
|
||||
}]);
|
||||
let phone_number = format!("1380013831{index}");
|
||||
let session_id = format!("sess_bgm_prompt_compatible_completion_{index}");
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
&phone_number,
|
||||
&session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, payload) =
|
||||
post_prompt_request(app, COMPLETION_PATH, Some(token.as_str()), "森林冒险", true)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "{finish_reason:?}: {payload}");
|
||||
assert_eq!(payload["data"]["prompt"], candidate);
|
||||
assert_eq!(mock.finish().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_retries_invalid_content_with_extracted_canonical_prompt() {
|
||||
let first_prompt = "\u{0085}第一轮可提取候选\u{0085}";
|
||||
@@ -782,6 +1028,142 @@ mod tests {
|
||||
assert_eq!(second_input["targetChars"], 170);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_retries_length_from_original_without_accepting_or_extracting_candidate()
|
||||
{
|
||||
let first_candidate = "不应采用的第一轮截断候选";
|
||||
let second_candidate = "第二轮完整候选";
|
||||
let mock = spawn_mock_llm_server(vec![
|
||||
MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response_with_finish_reason(
|
||||
valid_candidate_json(first_candidate),
|
||||
Some(" LeNgTh "),
|
||||
),
|
||||
},
|
||||
MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response(valid_candidate_json(second_candidate)),
|
||||
},
|
||||
]);
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
"13800138320",
|
||||
"sess_bgm_prompt_first_length",
|
||||
)
|
||||
.await;
|
||||
let original_prompt = "原".repeat(201);
|
||||
|
||||
let (status, payload) = post_prompt_request(
|
||||
app,
|
||||
SIMPLIFICATION_PATH,
|
||||
Some(token.as_str()),
|
||||
&original_prompt,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["data"]["prompt"], second_candidate);
|
||||
assert!(!payload.to_string().contains(first_candidate));
|
||||
let requests = mock.finish();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let second_input = user_message_json(&requests[1].body);
|
||||
assert_eq!(second_input["originalPrompt"], original_prompt);
|
||||
assert_eq!(second_input["currentPrompt"], original_prompt);
|
||||
assert_eq!(second_input["targetChars"], 170);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_content_filter_fails_without_starting_second_round() {
|
||||
let candidate = "不应泄露的安全过滤候选";
|
||||
let mock = spawn_mock_llm_server(vec![MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response_with_finish_reason(
|
||||
valid_candidate_json(candidate),
|
||||
Some(" CONTENT_FILTER "),
|
||||
),
|
||||
}]);
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
"13800138321",
|
||||
"sess_bgm_prompt_first_content_filter",
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, payload) = post_prompt_request(
|
||||
app,
|
||||
SIMPLIFICATION_PATH,
|
||||
Some(token.as_str()),
|
||||
&"原".repeat(201),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR");
|
||||
assert!(!payload.to_string().contains(candidate));
|
||||
assert_eq!(mock.finish().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_rejects_incomplete_second_round_without_candidate_leakage() {
|
||||
for (index, finish_reason) in ["length", "content_filter"].into_iter().enumerate() {
|
||||
let first_candidate = format!("第一轮待继续候选{index}");
|
||||
let second_candidate = format!("不应泄露的第二轮未完成候选{index}");
|
||||
let first_invalid = json!({
|
||||
"prompt": first_candidate,
|
||||
"isDirectWritebackFormat": true,
|
||||
"isContentComplete": false,
|
||||
"hasObviousFragment": false,
|
||||
})
|
||||
.to_string();
|
||||
let mock = spawn_mock_llm_server(vec![
|
||||
MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response(first_invalid),
|
||||
},
|
||||
MockLlmResponse {
|
||||
status_line: "200 OK",
|
||||
body: chat_text_response_with_finish_reason(
|
||||
valid_candidate_json(&second_candidate),
|
||||
Some(finish_reason),
|
||||
),
|
||||
},
|
||||
]);
|
||||
let phone_number = format!("1380013833{index}");
|
||||
let session_id = format!("sess_bgm_prompt_second_incomplete_{index}");
|
||||
let (app, token) = authenticated_test_app(
|
||||
editor_llm_test_config(mock.base_url.clone()),
|
||||
&phone_number,
|
||||
&session_id,
|
||||
)
|
||||
.await;
|
||||
let original_prompt = "原".repeat(201);
|
||||
|
||||
let (status, payload) = post_prompt_request(
|
||||
app,
|
||||
SIMPLIFICATION_PATH,
|
||||
Some(token.as_str()),
|
||||
&original_prompt,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_GATEWAY, "{finish_reason}");
|
||||
assert_eq!(payload["error"]["code"], "UPSTREAM_ERROR");
|
||||
let response_text = payload.to_string();
|
||||
assert!(!response_text.contains(&first_candidate));
|
||||
assert!(!response_text.contains(&second_candidate));
|
||||
let requests = mock.finish();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let second_input = user_message_json(&requests[1].body);
|
||||
assert_eq!(second_input["originalPrompt"], original_prompt);
|
||||
assert_eq!(second_input["currentPrompt"], first_candidate);
|
||||
assert_eq!(second_input["targetChars"], 170);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simplification_accepts_a_181_to_200_character_first_candidate_without_retry() {
|
||||
let accepted_prompt = "简".repeat(190);
|
||||
@@ -1032,15 +1414,25 @@ mod tests {
|
||||
}
|
||||
|
||||
fn chat_text_response(content: String) -> String {
|
||||
chat_text_response_with_finish_reason(content, Some("stop"))
|
||||
}
|
||||
|
||||
fn chat_text_response_with_finish_reason(
|
||||
content: String,
|
||||
finish_reason: Option<&str>,
|
||||
) -> String {
|
||||
let mut choice = json!({
|
||||
"message": {
|
||||
"content": content,
|
||||
},
|
||||
});
|
||||
if let Some(finish_reason) = finish_reason {
|
||||
choice["finish_reason"] = json!(finish_reason);
|
||||
}
|
||||
json!({
|
||||
"id": "chat_bgm_prompt_assist",
|
||||
"model": EDITOR_AGENT_GPT5_MODEL,
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": content,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
"choices": [choice],
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -1105,6 +1497,17 @@ mod tests {
|
||||
post_raw_prompt_request(app, path, token, &body, wants_envelope).await
|
||||
}
|
||||
|
||||
fn prompt_request_body_with_exact_bytes(total_bytes: usize) -> String {
|
||||
const PREFIX: &str = r#"{"currentPrompt":""#;
|
||||
const SUFFIX: &str = r#""}"#;
|
||||
assert!(total_bytes >= PREFIX.len() + SUFFIX.len());
|
||||
|
||||
let prompt_bytes = total_bytes - PREFIX.len() - SUFFIX.len();
|
||||
let body = format!("{PREFIX}{}{SUFFIX}", "A".repeat(prompt_bytes));
|
||||
assert_eq!(body.len(), total_bytes);
|
||||
body
|
||||
}
|
||||
|
||||
async fn post_raw_prompt_request(
|
||||
app: Router,
|
||||
path: &str,
|
||||
|
||||
@@ -116,6 +116,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::GONE => ("GONE", "资源已失效"),
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::PAYLOAD_TOO_LARGE => ("PAYLOAD_TOO_LARGE", "请求体过大"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
StatusCode::GATEWAY_TIMEOUT => ("GATEWAY_TIMEOUT", "上游服务请求超时"),
|
||||
StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"),
|
||||
@@ -124,3 +125,17 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
_ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payload_too_large_keeps_a_distinct_error_contract() {
|
||||
let error = AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE);
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert_eq!(error.code(), "PAYLOAD_TOO_LARGE");
|
||||
assert_eq!(error.message(), "请求体过大");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const HYPER3D_IMAGE_TO_MODEL_BODY_LIMIT_BYTES: usize = 56 * 1024 * 1024;
|
||||
pub(crate) const BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES: usize = 32 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
@@ -148,15 +149,25 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/editor/audios/background-music/prompts/completions",
|
||||
post(complete_editor_background_music_prompt).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(complete_editor_background_music_prompt)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/audios/background-music/prompts/simplifications",
|
||||
post(simplify_editor_background_music_prompt).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
post(simplify_editor_background_music_prompt)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
BACKGROUND_MUSIC_PROMPT_ASSIST_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
|
||||
@@ -360,6 +360,20 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrack
|
||||
("GET", "/api/assets/history") => {
|
||||
Some(route_spec("asset_history_view", "asset", User, "anonymous"))
|
||||
}
|
||||
("POST", "/api/editor/audios/background-music/prompts/completions") => Some(route_spec(
|
||||
"editor_background_music_prompt_completion",
|
||||
"editor",
|
||||
User,
|
||||
"anonymous",
|
||||
)),
|
||||
("POST", "/api/editor/audios/background-music/prompts/simplifications") => {
|
||||
Some(route_spec(
|
||||
"editor_background_music_prompt_simplification",
|
||||
"editor",
|
||||
User,
|
||||
"anonymous",
|
||||
))
|
||||
}
|
||||
("POST", "/api/llm/chat/completions") => {
|
||||
Some(route_spec("llm_request", "llm", User, "anonymous"))
|
||||
}
|
||||
@@ -566,6 +580,7 @@ fn is_known_static_route_segment(segment: &str) -> bool {
|
||||
| "asr"
|
||||
| "assets"
|
||||
| "auth"
|
||||
| "background-music"
|
||||
| "bidirection"
|
||||
| "bind-phone"
|
||||
| "browse-history"
|
||||
@@ -613,6 +628,7 @@ fn is_known_static_route_segment(segment: &str) -> bool {
|
||||
| "send-code"
|
||||
| "sessions"
|
||||
| "settings"
|
||||
| "simplifications"
|
||||
| "snapshot"
|
||||
| "speech"
|
||||
| "sse"
|
||||
@@ -845,6 +861,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_assist_routes_keep_explicit_user_tracking_specs() {
|
||||
for (path, event_key) in [
|
||||
(
|
||||
"/api/editor/audios/background-music/prompts/completions",
|
||||
"editor_background_music_prompt_completion",
|
||||
),
|
||||
(
|
||||
"/api/editor/audios/background-music/prompts/simplifications",
|
||||
"editor_background_music_prompt_simplification",
|
||||
),
|
||||
] {
|
||||
let spec = resolve_route_tracking_spec(&Method::POST, path)
|
||||
.expect("background music prompt assist route should be tracked");
|
||||
assert_eq!(spec.event_key, event_key);
|
||||
assert_eq!(spec.module_key, "editor");
|
||||
assert_eq!(
|
||||
spec.scope_kind,
|
||||
module_runtime::RuntimeTrackingScopeKind::User
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retired_play_paths_are_not_route_tracking_exclusions() {
|
||||
for path in [
|
||||
|
||||
@@ -31,11 +31,11 @@ pub use response::{
|
||||
extract_audio_urls, is_failed_task_status, is_pending_task_status, normalize_task_status,
|
||||
};
|
||||
pub use types::{
|
||||
AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
|
||||
DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio, EditorBackgroundMusicTaskRequest,
|
||||
EditorSoundEffectTaskRequest, MAX_GENERATED_AUDIO_BYTES, SUNO_DEFAULT_MODEL,
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS, SUNO_TAGS_MAX_CHARS,
|
||||
SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER,
|
||||
AudioTaskKind, AudioTaskResponse, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS,
|
||||
BackgroundMusicTaskRequest, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, DownloadedAudio,
|
||||
EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, MAX_GENERATED_AUDIO_BYTES,
|
||||
SUNO_DEFAULT_MODEL, SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, SUNO_PROMPT_MAX_CHARS,
|
||||
SUNO_TAGS_MAX_CHARS, SUNO_TITLE_MAX_CHARS, SoundEffectTaskRequest, VECTOR_ENGINE_PROVIDER,
|
||||
VECTOR_ENGINE_SUNO_PROVIDER, VECTOR_ENGINE_VIDU_PROVIDER, VIDU_AUDIO_MODEL,
|
||||
VIDU_PROMPT_MAX_CHARS, VectorEngineAudioSettings,
|
||||
};
|
||||
|
||||
@@ -101,6 +101,8 @@ pub const SUNO_DEFAULT_MODEL: &str = "chirp-v5";
|
||||
pub const VIDU_AUDIO_MODEL: &str = "audio1.0";
|
||||
pub const SUNO_PROMPT_MAX_CHARS: usize = 5_000;
|
||||
pub const SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS: usize = 200;
|
||||
pub const BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS: usize =
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS * 10;
|
||||
pub const SUNO_TITLE_MAX_CHARS: usize = 80;
|
||||
pub const SUNO_TAGS_MAX_CHARS: usize = 160;
|
||||
pub const VIDU_PROMPT_MAX_CHARS: usize = 1_500;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use platform_audio::{
|
||||
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
|
||||
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
AudioTaskKind, BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, BackgroundMusicTaskRequest,
|
||||
EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL,
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
|
||||
audio_mime_to_extension, background_music_prompt_char_count,
|
||||
background_music_prompt_effective_char_count, build_background_music_task_body,
|
||||
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
|
||||
@@ -11,6 +12,15 @@ use platform_audio::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn background_music_simplification_limit_is_ten_times_the_generation_limit() {
|
||||
assert_eq!(
|
||||
BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS,
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS * 10
|
||||
);
|
||||
assert_eq!(BACKGROUND_MUSIC_PROMPT_SIMPLIFICATION_MAX_CHARS, 2_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_canonicalization_is_idempotent() {
|
||||
let prompt = " \t\u{0085}\u{00a0}音 \r\n\u{200b}\u{feff}😀\u{2003}\n ";
|
||||
|
||||
@@ -693,10 +693,11 @@ struct PendingToolCall {
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
// 上游明确表示本轮输出被截断、过滤或失败的终态。这里刻意用黑名单而不是白名单:
|
||||
// 兼容网关常常不发这个字段或发自定义值,白名单会把它们全部误杀,而“保留缺失 reason
|
||||
// 的兼容路径”是本项检查的前提。新增上游时按需补充已知值即可。
|
||||
fn is_incomplete_finish_reason(api_kind: LlmApiKind, finish_reason: &str) -> bool {
|
||||
/// 判断上游是否明确给出了截断、过滤或失败的终态。
|
||||
///
|
||||
/// 这里刻意使用按 API 类型区分的黑名单而不是白名单:兼容网关常常不发这个字段或发
|
||||
/// 自定义值,白名单会把它们全部误杀。调用方应继续兼容缺失、空白和未知 reason。
|
||||
pub fn is_incomplete_finish_reason(api_kind: LlmApiKind, finish_reason: &str) -> bool {
|
||||
let reason = finish_reason.trim().to_ascii_lowercase();
|
||||
match api_kind {
|
||||
LlmApiKind::OpenAiChat => matches!(reason.as_str(), "length" | "content_filter"),
|
||||
@@ -3886,6 +3887,52 @@ mod tests {
|
||||
assert_eq!(LlmError::EmptyResponse.kind(), LlmErrorKind::EmptyResponse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_finish_reason_is_api_kind_aware_and_normalized() {
|
||||
for reason in ["length", " CONTENT_FILTER ", "LeNgTh"] {
|
||||
assert!(
|
||||
is_incomplete_finish_reason(LlmApiKind::OpenAiChat, reason),
|
||||
"OpenAI Chat should reject {reason:?}"
|
||||
);
|
||||
}
|
||||
for reason in ["incomplete", " FAILED ", "CaNcElLeD"] {
|
||||
assert!(
|
||||
is_incomplete_finish_reason(LlmApiKind::OpenAiResponses, reason),
|
||||
"OpenAI Responses should reject {reason:?}"
|
||||
);
|
||||
}
|
||||
for reason in ["max_tokens", " PAUSE_TURN ", "ReFuSaL"] {
|
||||
assert!(
|
||||
is_incomplete_finish_reason(LlmApiKind::Anthropic, reason),
|
||||
"Anthropic should reject {reason:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for api_kind in [
|
||||
LlmApiKind::OpenAiChat,
|
||||
LlmApiKind::OpenAiResponses,
|
||||
LlmApiKind::Anthropic,
|
||||
] {
|
||||
assert!(!is_incomplete_finish_reason(api_kind, ""));
|
||||
assert!(!is_incomplete_finish_reason(
|
||||
api_kind,
|
||||
" vendor_specific_done "
|
||||
));
|
||||
}
|
||||
assert!(!is_incomplete_finish_reason(
|
||||
LlmApiKind::OpenAiChat,
|
||||
"max_tokens"
|
||||
));
|
||||
assert!(!is_incomplete_finish_reason(
|
||||
LlmApiKind::OpenAiResponses,
|
||||
"length"
|
||||
));
|
||||
assert!(!is_incomplete_finish_reason(
|
||||
LlmApiKind::Anthropic,
|
||||
"content_filter"
|
||||
));
|
||||
}
|
||||
|
||||
struct MockResponse {
|
||||
status_line: &'static str,
|
||||
content_type: &'static str,
|
||||
|
||||
Reference in New Issue
Block a user