diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 54bd81539..4092c0ee5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index f61e5b1c3..3eb5b782f 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/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`。 diff --git a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs index 6c5ddd1b5..fac8eba53 100644 --- a/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs +++ b/server-rs/crates/api-server/src/editor_background_music_prompt_assist.rs @@ -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 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( payload: Result, JsonRejection>, ) -> Result, 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, diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ac061d6d9..41cdf2794 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -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(), "请求体过大"); + } +} diff --git a/server-rs/crates/api-server/src/modules/platform_support.rs b/server-rs/crates/api-server/src/modules/platform_support.rs index 116a87192..7bbee58cc 100644 --- a/server-rs/crates/api-server/src/modules/platform_support.rs +++ b/server-rs/crates/api-server/src/modules/platform_support.rs @@ -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 { Router::new() @@ -148,15 +149,25 @@ pub fn router(state: AppState) -> Router { ) .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}", diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 962876712..1dd3c4144 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -360,6 +360,20 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { 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 [ diff --git a/server-rs/crates/platform-audio/src/lib.rs b/server-rs/crates/platform-audio/src/lib.rs index 0b9d64a03..1a1af2d55 100644 --- a/server-rs/crates/platform-audio/src/lib.rs +++ b/server-rs/crates/platform-audio/src/lib.rs @@ -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, }; diff --git a/server-rs/crates/platform-audio/src/types.rs b/server-rs/crates/platform-audio/src/types.rs index 18760894c..9a7befe54 100644 --- a/server-rs/crates/platform-audio/src/types.rs +++ b/server-rs/crates/platform-audio/src/types.rs @@ -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; diff --git a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs index 94262d259..f2a6033ef 100644 --- a/server-rs/crates/platform-audio/tests/vector_engine_audio.rs +++ b/server-rs/crates/platform-audio/tests/vector_engine_audio.rs @@ -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 "; diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 51857995b..987ade07c 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -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,