feat(ai): S7a 捕获 provider 完整正文 + 护栏/审上移到完整正文

分支①第一步(provider 无关):RuntimeResult 加仅内存 fullOutput 字段承载 provider 完整正文
(供 S7b 瞬态通路),两个 real client(New-API/Dify chat+workflow)在派生 60/80 字摘要前捕获
完整正文并跑护栏,通过才透出。护栏 guardFullOutput 纯静态 provider 无关:空/超长/凭据痕迹/
疑似截断(≥60 字未以句末标点结束)→失败可重试,不信 provider finishReason(Dify 硬编码常量、
New-API 只记录)。「审」输入从脱敏摘要上移到 result.fullOutput(缺省回退摘要,兼容 shadow/历史)。

主权红线(三层亲验):content_snapshot.content 仍写摘要(resolveContentText,不变);完整正文仅
作 review() 局部变量,findings 落库只含布尔/marker 名/长度、审 ID 指纹用 hashCode+length 不可逆,
无正文明文入库;fullOutput 无任何 set/put/序列化/持久化出口,toString 只打 length。

验证:ai 模块 79 用例全绿(MuseAiCandidateReviewServiceTest 11 / MuseAiRuntimeClientTest 22 /
MuseAiRuntimeProjectionServiceTest 6 / MuseAiTaskServiceTest 40),BUILD SUCCESS。改动严格 7 文件。
真实生成经护栏的活体验证并入 M1(S7b real-PG)。附:DDL 协调 S7b 瞬态表改 V36(S6 batch 占 V35)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-07 23:59:27 -07:00
parent ca045e3b13
commit 916d6c877f
8 changed files with 293 additions and 7 deletions

View File

@ -67,7 +67,7 @@ flowchart LR
| 触及 | 新增瞬态全文表DDL、executor、`MuseAiRuntimeProjectionService`(事件写入)、`MuseAiTaskStreamServiceImpl`(读侧 chunk、采纳/放弃 ownerpurge |
**动作**
1. **瞬态全文存储(仿 `MuseAiRuntimePayloadStore`**:新增 `sql/muse/V35__ai_generated_fulltext_transient.sql` 建独立表key by taskId、加密载荷列、`expires_at` TTL、租户列DAO/Store 对齐 `MuseAiRuntimePayloadStore`L24-46的加密 + 短 TTL 语义。**表注释写明「瞬态出向完整正文TTL + 决定即清,非系统记录,永不作为正文来源落 Canonical」**。
1. **瞬态全文存储(仿 `MuseAiRuntimePayloadStore`**:新增 `sql/muse/V36__ai_generated_fulltext_transient.sql` 建独立表key by taskId、加密载荷列、`expires_at` TTL、租户列。**DDL 版本协调S6 核验确认)**:当前最大 V34S6 的 `runtime_batch_id` 迁移占 V35本瞬态表占 V36若实际提交顺序不同按提交时最大版本+1 顺延、勿撞号。DAO/Store 对齐 `MuseAiRuntimePayloadStore`L24-46的加密 + 短 TTL 语义。**表注释写明「瞬态出向完整正文TTL + 决定即清,非系统记录,永不作为正文来源落 Canonical」**。
2. **executor 写入 + 终态 purge**`MuseAiRuntimeJobExecutor.execute`L75-95在拿到 `RuntimeResult` 后、投影前,把 `fullOutput` 写入瞬态存储;沿用现有终态 `finally`L62-64 `runtimePayloadStore.remove` 同处)追加瞬态全文的兜底清理(异常/超时也不残留)。
3. **事件表发 chunk 引用行**:放宽 `MuseAiRuntimeProjectionService.appendTaskEvent``isValidTerminalEvent`L319-322以允许写**一个非终态 chunk 事件**——`sequence_no` 排在 done 之前,`payload_summary` 只放**指向瞬态存储的引用**(如 `{contentRef, sequenceNo}`**不含正文**。保持每任务至多一条终态的部分唯一索引不变chunk 非终态,不受该约束)。
4. **SSE 读侧回取**`MuseAiTaskStreamServiceImpl.chunkData`L279-285遇到带 `contentRef` 的 chunk 行时回瞬态存储取完整正文填 `content`;缓存缺失(已 purge/过期)→ `content=""`(用户已决定,无害)。回放/轮询/重连语义不变(重放 chunk 引用行→再回取TTL 内可续)。

View File

@ -30,6 +30,63 @@ public class MuseAiCandidateReviewService {
/** 输出合规扫描的敏感凭据痕迹关键字(出现即判定为不可入正文的合规风险,防止把泄露密钥的输出写进正式正文)。 */
private static final List<String> CREDENTIAL_MARKERS = List.of(
"bearer ", "authorization:", "api_key", "apikey", "secret", "-----begin");
/** 疑似截断的最小可判长度(字符);短于此的输出不做截断判定,避免对合法短回复误报。 */
private static final int TRUNCATION_MIN_LENGTH = 60;
/** 视为正常结尾的句末/收尾标点(中英文句末符 + 收尾引号/括号);正文达可判长度却非此类结尾即疑似截断。 */
private static final String TERMINAL_CHARS = "。!?…;;.!?”’\"'」』】))》>";
/**
* provider 无关的完整正文护栏(S7a 上移到捕获处): provider <b>完整正文</b>完整性/截断校验凭据痕迹合规扫描
* 供两个真实 runtime adapter success 分支派生 60/80 字摘要<strong>之前</strong>调用;不通过即判失败(可重试),
* 完整正文绝不透出下游绝不落库
*
* <p><b>WHY 不信 provider finishReason</b>:Dify 完成原因是硬编码常量New-API 只记录不设门,均不反映真实完成度;
* 截断只能靠对完整正文本身的长度/结构校验判定( {@link #isSuspectedTruncation})</p>
*
* @param fullOutput provider 完整正文(仅内存;调用方不得持久化,日志只可打长度/hash)
* @return 护栏结论;{@code passed=false} 时携带脱敏原因码(empty/oversize/credential_marker/truncated)
*/
public static OutputGuardResult guardFullOutput(String fullOutput) {
String text = fullOutput == null ? "" : fullOutput;
if (!StringUtils.hasText(text)) {
return OutputGuardResult.reject("empty");
}
if (text.length() > MAX_CONTENT_LENGTH) {
return OutputGuardResult.reject("oversize");
}
// 合规先于截断:凭据痕迹是硬合规风险,单独原因码便于脱敏排障
if (containsCredentialMarker(text)) {
return OutputGuardResult.reject("credential_marker");
}
if (isSuspectedTruncation(text)) {
return OutputGuardResult.reject("truncated");
}
return OutputGuardResult.ok();
}
/** 凭据痕迹扫描:命中任一凭据关键字即为真(单一来源 {@link #CREDENTIAL_MARKERS},与 review 合规扫描同口径)。 */
private static boolean containsCredentialMarker(String text) {
String lower = text.toLowerCase(Locale.ROOT);
for (String marker : CREDENTIAL_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
/**
* 疑似截断判定:完整正文达到可判长度({@code >= TRUNCATION_MIN_LENGTH})却未以句末/收尾标点结束,视为疑似截断
* 短输出不做判定(合法短回复亦可能无标点)纯长度/结构启发,不依赖 provider 完成原因
*/
private static boolean isSuspectedTruncation(String text) {
String normalized = text == null ? "" : text.strip();
if (normalized.length() < TRUNCATION_MIN_LENGTH) {
return false;
}
char last = normalized.charAt(normalized.length() - 1);
return TERMINAL_CHARS.indexOf(last) < 0;
}
/**
* 对一段生成正文执行三项审查
@ -56,7 +113,7 @@ public class MuseAiCandidateReviewService {
}
boolean compliancePassed = complianceFindings.isEmpty();
// 2) 静态检查:正文非空长度在合理区间(防止超长/空白异常输出)
// 2) 静态检查:正文非空长度在合理区间且结构完整(非疑似截断)审对象为完整正文时,截断即在此拦下
List<String> staticFindings = new ArrayList<>();
if (!StringUtils.hasText(text)) {
staticFindings.add("blank_text");
@ -64,6 +121,9 @@ public class MuseAiCandidateReviewService {
if (text.length() > MAX_CONTENT_LENGTH) {
staticFindings.add("oversize:" + text.length());
}
if (isSuspectedTruncation(text)) {
staticFindings.add("truncated:" + text.length());
}
boolean staticPassed = staticFindings.isEmpty();
// 3) 许可限制快照:透传来源许可限制(AI 自生成无来源限制时为空列表,语义=无限制)
@ -101,4 +161,27 @@ public class MuseAiCandidateReviewService {
public record CandidateReview(boolean passed, String outputComplianceResultId, String staticCheckResultId,
List<String> licenseRestrictions, Map<String, Object> findings) {
}
/**
* 完整正文护栏结论( {@link #guardFullOutput})
*
* @param passed 是否通过;true 方可透出 fullOutput 到下游
* @param reasonCode 脱敏原因码(passed=true 时为 null;否则 empty/oversize/credential_marker/truncated)
*/
public record OutputGuardResult(boolean passed, String reasonCode) {
// 工厂方法不能命名为 passed():会与 record 组件 passed 的自动访问器( public)冲突
private static OutputGuardResult ok() {
return new OutputGuardResult(true, null);
}
private static OutputGuardResult reject(String reasonCode) {
return new OutputGuardResult(false, reasonCode);
}
/** 是否因疑似截断不通过(供 adapter 在截断/合规之间选择错误码)。 */
public boolean truncated() {
return "truncated".equals(reasonCode);
}
}
}

View File

@ -253,8 +253,11 @@ public class MuseAiRuntimeProjectionService {
String candidateType) {
// 解析候选正文(脱敏 summary;完整 provider 原文按数据主权不落库)+ 真实:输出合规/静态检查/许可快照
String contentText = resolveContentText(result);
// S7a:上移到 provider 完整正文审对象取 result.fullOutput(仅内存绝不落库),缺省回退摘要(兼容 shadow/历史结果);
// content_snapshot.content 仍写脱敏摘要(见下方 contentSnapshot),完整正文不进任何列
String reviewText = reviewText(result, contentText);
MuseAiCandidateReviewService.CandidateReview review =
candidateReviewService.review(task.getId(), contentText, sourceLicenseRestrictions(task));
candidateReviewService.review(task.getId(), reviewText, sourceLicenseRestrictions(task));
MuseAiSuggestionDO suggestion = new MuseAiSuggestionDO();
suggestion.setWorkId(task.getWorkId());
suggestion.setChapterId(task.getChapterId());
@ -332,6 +335,17 @@ public class MuseAiRuntimeProjectionService {
return snapshot;
}
/**
* 输入:优先 provider 完整正文(result.fullOutput,仅内存不落库),为空时回退脱敏摘要
* 使先审后入真实审的是完整正文;fullOutput 缺省(shadow 候选/历史结果/测试桩)时按原摘要口径审,行为兼容
*/
private String reviewText(MuseAiRuntimeClient.RuntimeResult result, String fallbackSummary) {
if (result != null && StringUtils.hasText(result.fullOutput())) {
return result.fullOutput();
}
return fallbackSummary;
}
/** 从 runtime outputSummary 解析候选正文文本(优先 summary,再 content/text)。 */
private String resolveContentText(MuseAiRuntimeClient.RuntimeResult result) {
if (result == null || result.outputSummary() == null) {

View File

@ -68,6 +68,20 @@ public interface MuseAiRuntimeClient {
}
record RuntimeResult(String correlationId,
String status,
Map<String, Object> outputSummary,
Map<String, Object> tokenUsage,
Long costCents,
String providerRequestId,
String finishReason,
Instant completedAt,
// fullOutput 承载 provider 完整正文仅进程内传递 S7b 瞬态通路/SSE 送达
// 永不持久化到任何列/落库只用 outputSummary 摘要DO/mapper 一律不得引用此字段
String fullOutput) {
// 旧构造不带 fullOutput保持既有调用点shadow 候选历史测试桩不变完整正文缺省 null
// 须显式 publicrecord 内显式声明的构造不继承类型的 public缺省为包级跨包调用点会编译失败
public RuntimeResult(String correlationId,
String status,
Map<String, Object> outputSummary,
Map<String, Object> tokenUsage,
@ -75,6 +89,25 @@ public interface MuseAiRuntimeClient {
String providerRequestId,
String finishReason,
Instant completedAt) {
this(correlationId, status, outputSummary, tokenUsage, costCents, providerRequestId,
finishReason, completedAt, null);
}
@Override
public String toString() {
// 主权红线完整正文只允许进程内传递record 默认 toString 会展开 fullOutput 明文
// 这里改为只暴露长度指纹杜绝完整正文经日志/异常泄漏对齐 RuntimeCommand 的脱敏范式
return "RuntimeResult[correlationId=" + correlationId
+ ", status=" + status
+ ", outputSummary=" + outputSummary
+ ", tokenUsage=" + tokenUsage
+ ", costCents=" + costCents
+ ", providerRequestId=" + providerRequestId
+ ", finishReason=" + finishReason
+ ", completedAt=" + completedAt
+ ", fullOutputLength=" + (fullOutput == null ? 0 : fullOutput.length())
+ "]";
}
}
record RuntimeFailure(String correlationId,

View File

@ -1,6 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.StringUtils;
@ -232,12 +233,19 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_response", null, "AI_DIFY_BAD_RESPONSE",
"AI Dify response missing answer", false, null);
}
// S7a 护栏上移:Dify 完成原因是硬编码常量不可信,截断只能靠对完整正文本身校验;派生摘要前先过护栏
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(answer);
if (!guard.passed()) {
return guardFailure(command, guard);
}
String providerRequestId = firstText(root.path("message_id"), root.path("task_id"), root.path("id"));
Map<String, Object> outputSummary = outputSummary(command, providerRef, answer,
providerRequestId, "message");
// fullOutput=answer:仅进程内承载 Dify 完整正文( S7b);outputSummary 仍只带 80 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
tokenUsage(root.path("metadata").path("usage")), null, providerRequestId, "message",
Instant.now()), null);
Instant.now(), answer), null);
}
private RuntimeResponse workflowSuccess(RuntimeCommand command,
@ -255,12 +263,19 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_response", null, "AI_DIFY_BAD_RESPONSE",
"AI Dify workflow response missing outputs", false, null);
}
// S7a 护栏上移:workflow status=succeeded 亦不足以证明正文完整,截断/合规须对完整正文本身校验
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(text);
if (!guard.passed()) {
return guardFailure(command, guard);
}
String providerRequestId = firstText(root.path("workflow_run_id"), root.path("task_id"),
data.path("id"), data.path("workflow_id"));
Map<String, Object> outputSummary = outputSummary(command, providerRef, text,
providerRequestId, "workflow");
// fullOutput=text:仅进程内承载 Dify workflow 完整正文( S7b);outputSummary 仍只带 80 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
workflowUsage(data), null, providerRequestId, status, Instant.now()), null);
workflowUsage(data), null, providerRequestId, status, Instant.now(), text), null);
}
private Map<String, Object> outputSummary(RuntimeCommand command,
@ -388,6 +403,14 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
errorCode, sanitizeMessage(sanitizedMessage), retryable, nextRetryAt, Instant.now()));
}
private RuntimeResponse guardFailure(RuntimeCommand command,
MuseAiCandidateReviewService.OutputGuardResult guard) {
// 完整正文护栏不通过按可重试处理(裁决);只落脱敏错误码,绝不带完整正文
String errorCode = guard.truncated() ? "AI_DIFY_OUTPUT_TRUNCATED" : "AI_DIFY_OUTPUT_NONCOMPLIANT";
return failure(command, "provider_bad_response", null, errorCode,
"AI Dify output failed integrity or compliance guard", true, null);
}
private String outputText(JsonNode outputs) {
if (outputs == null || outputs.isMissingNode() || outputs.isNull()) {
return null;

View File

@ -1,6 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.StringUtils;
@ -173,6 +174,13 @@ public class RealNewApiMuseAiRuntimeClient implements MuseAiRuntimeClient {
if (!StringUtils.hasText(summary)) {
return badResponse(command, response.statusCode());
}
// S7a 护栏上移:在派生 60 字摘要之前, provider 完整正文做完整性/截断 + 凭据合规扫描;
// 疑似截断或含凭据痕迹即判失败可重试,不可用/不合规的完整正文绝不塞进 fullOutput 透出下游
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(content);
if (!guard.passed()) {
return guardFailure(command, response.statusCode(), guard);
}
String finishReason = choice == null ? null : textOrNull(choice.path("finish_reason"));
Map<String, Object> outputSummary = new LinkedHashMap<>();
outputSummary.put("summary", summary);
@ -183,14 +191,24 @@ public class RealNewApiMuseAiRuntimeClient implements MuseAiRuntimeClient {
command == null ? null : command.runtimePermissionEnvelopeId());
outputSummary.put("finishReason", finishReason);
String providerRequestId = response.headers().firstValue("X-Oneapi-Request-Id").orElse(null);
// fullOutput=content:仅进程内承载 provider 完整正文( S7b 瞬态通路);outputSummary 仍只带 60 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
tokenUsage(root.path("usage")), null, providerRequestId, finishReason, Instant.now()), null);
tokenUsage(root.path("usage")), null, providerRequestId, finishReason, Instant.now(), content), null);
} catch (RuntimeException ex) {
return failure(command, "provider_bad_response", response.statusCode(), "AI_NEW_API_BAD_RESPONSE",
"AI New-API response could not be parsed", false, null);
}
}
private RuntimeResponse guardFailure(RuntimeCommand command, Integer providerStatusCode,
MuseAiCandidateReviewService.OutputGuardResult guard) {
// 完整正文护栏不通过按可重试处理(裁决):截断重生成可能补全;含凭据重生成可能不再泄漏
// 只落脱敏错误码,绝不带完整正文;failureType provider_bad_response(不在 nonRetryable ,retryable 由本标志决定)
String errorCode = guard.truncated() ? "AI_NEW_API_OUTPUT_TRUNCATED" : "AI_NEW_API_OUTPUT_NONCOMPLIANT";
return failure(command, "provider_bad_response", providerStatusCode, errorCode,
"AI New-API output failed integrity or compliance guard", true, null);
}
private RuntimeResponse badResponse(RuntimeCommand command, Integer providerStatusCode) {
return failure(command, "provider_bad_response", providerStatusCode, "AI_NEW_API_BAD_RESPONSE",
"AI New-API response missing assistant content", false, null);

View File

@ -60,4 +60,60 @@ class MuseAiCandidateReviewServiceTest {
assertFalse(review.passed());
assertEquals(List.of(), review.licenseRestrictions());
}
// >60 字且无句末标点 疑似截断;不含任何凭据痕迹
private static final String TRUNCATED_FULL_TEXT =
"雨落在青石板上溅起细小的水花她撑着伞站在巷口望着远处那扇熟悉的木门心里翻涌着说不清的情绪"
+ "许多年前的往事一幕幕浮现她深吸一口气终于抬起脚步朝着那扇门缓缓走了过去然后她";
// 同样长度以句号收尾 结构完整
private static final String COMPLETE_FULL_TEXT = TRUNCATED_FULL_TEXT + "轻轻推开了那扇门。";
@Test
void should_guardFullOutput_passForCompleteText() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(COMPLETE_FULL_TEXT);
assertTrue(guard.passed(), "结构完整、无凭据痕迹的完整正文应通过护栏");
assertFalse(guard.truncated());
}
@Test
void should_guardFullOutput_failForTruncatedText() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(TRUNCATED_FULL_TEXT);
assertFalse(guard.passed(), "达可判长度却无句末标点的完整正文应判疑似截断");
assertTrue(guard.truncated());
assertEquals("truncated", guard.reasonCode());
}
@Test
void should_guardFullOutput_failForCredentialMarker() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput("这里混入了 api_key 泄露的痕迹。");
assertFalse(guard.passed(), "含凭据痕迹的完整正文不得通过护栏");
assertFalse(guard.truncated(), "凭据泄露不应被误判为截断");
assertEquals("credential_marker", guard.reasonCode());
}
@Test
void should_guardFullOutput_failForEmpty() {
assertFalse(MuseAiCandidateReviewService.guardFullOutput(" ").passed());
assertFalse(MuseAiCandidateReviewService.guardFullOutput(null).passed());
}
@Test
void should_fail_review_forTruncatedFullText() {
// 上移到完整正文后,疑似截断的完整正文不得通过 不给审结果 ID 候选不可合并
CandidateReview review = service.review(1L, TRUNCATED_FULL_TEXT, List.of());
assertFalse(review.passed(), "疑似截断的完整正文不应通过静态检查");
assertNull(review.outputComplianceResultId());
assertNull(review.staticCheckResultId());
}
@Test
void should_pass_review_forCompleteFullText() {
CandidateReview review = service.review(1L, COMPLETE_FULL_TEXT, List.of());
assertTrue(review.passed(), "结构完整的完整正文应通过审查");
assertNotNull(review.outputComplianceResultId());
assertNotNull(review.staticCheckResultId());
}
}

View File

@ -137,6 +137,9 @@ class MuseAiRuntimeClientTest {
String outputJson = JsonUtils.toJsonString(response.result().outputSummary());
assertTrue(outputJson.contains("第一句摘要"));
assertFalse(outputJson.contains("第二句很长"));
// S7a:fullOutput 承载 provider 完整正文(仅内存);落库摘要仍只 60 不含后文, fullOutput 不进 outputSummary JSON
assertEquals("第一句摘要。第二句很长,不能作为全文原样返回。", response.result().fullOutput());
assertTrue(response.result().fullOutput().contains("第二句很长"));
RecordedRequest request = recordedRequests.getFirst();
assertEquals("/v1/chat/completions", request.path());
@ -389,6 +392,9 @@ class MuseAiRuntimeClientTest {
assertEquals("dify-msg-1", dify.result().providerRequestId());
assertEquals("dify", dify.result().outputSummary().get("provider"));
assertEquals("untraceable_provider_context", dify.result().outputSummary().get("traceability"));
// S7a:两个 adapter 成功分支都把 provider 完整正文接出到 fullOutput(仅内存)
assertEquals("New API 摘要。", legacy.result().fullOutput());
assertEquals("Dify 摘要。", dify.result().fullOutput());
}
@Test
@ -440,6 +446,8 @@ class MuseAiRuntimeClientTest {
assertEquals("wf-run-1", response.result().providerRequestId());
assertEquals(11, response.result().tokenUsage().get("totalTokens"));
assertEquals("requires_new_api_attribution", response.result().tokenUsage().get("attributionStatus"));
// S7a:workflow 完整正文接出到 fullOutput(仅内存)
assertEquals("工作流输出摘要。", response.result().fullOutput());
RecordedRequest request = recordedRequests.getFirst();
assertEquals("/workflows/workflow-1/run", request.path());
assertEquals("Bearer dify-unit-test-key", request.header("Authorization"));
@ -466,6 +474,57 @@ class MuseAiRuntimeClientTest {
assertFalse(unknownRef.failure().retryable());
}
// S7a 护栏样本:>60 字且无句末标点 疑似截断;不含任何凭据痕迹
private static final String TRUNCATED_OUTPUT_SAMPLE =
"雨落在青石板上溅起细小的水花她撑着伞站在巷口望着远处那扇熟悉的木门心里翻涌着说不清的情绪"
+ "许多年前的往事一幕幕浮现她深吸一口气终于抬起脚步朝着那扇门缓缓走了过去然后她";
@Test
void should_failClosed_when_newApiOutputIsTruncated() throws Exception {
// 完整正文达可判长度却无句末标点 护栏判疑似截断,判失败可重试不产候选(不信 provider finishReason)
startServer(200, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\""
+ TRUNCATED_OUTPUT_SAMPLE + "\"}}]}", null);
RealNewApiMuseAiRuntimeClient client = new RealNewApiMuseAiRuntimeClient(newApiProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(Map.of("taskType", "continue_writing")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_NEW_API_OUTPUT_TRUNCATED", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
@Test
void should_failClosed_when_newApiOutputContainsCredentialMarker() throws Exception {
// 完整正文含凭据痕迹 护栏判不合规,判失败可重试不产候选
startServer(200, """
{"choices":[{"message":{"role":"assistant","content":"这是生成的正文,其中混入了 api_key 泄露的痕迹。"}}]}
""", null);
RealNewApiMuseAiRuntimeClient client = new RealNewApiMuseAiRuntimeClient(newApiProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(Map.of("taskType", "generation")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_NEW_API_OUTPUT_NONCOMPLIANT", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
@Test
void should_failClosed_when_difyOutputIsTruncated() throws Exception {
// 护栏 provider 无关:Dify 完整正文疑似截断同样判失败可重试
startServer(exchange -> writeJson(exchange, 200,
"{\"message_id\":\"dify-msg-2\",\"answer\":\"" + TRUNCATED_OUTPUT_SAMPLE + "\"}", null));
RealDifyMuseAiRuntimeClient client = new RealDifyMuseAiRuntimeClient(difyProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_DIFY_OUTPUT_TRUNCATED", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
private MuseAiRuntimeClient.RuntimeCommand command(Map<String, Object> inputSummary) {
return new MuseAiRuntimeClient.RuntimeCommand(
"cmd-real-1", "3001", "4001", "corr-real-1", 1, "model-a", "prompt-v9",