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:
parent
ca045e3b13
commit
916d6c877f
@ -67,7 +67,7 @@ flowchart LR
|
||||
| 触及 | 新增瞬态全文表(DDL)、executor、`MuseAiRuntimeProjectionService`(事件写入)、`MuseAiTaskStreamServiceImpl`(读侧 chunk)、采纳/放弃 owner(purge) |
|
||||
|
||||
**动作**
|
||||
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 核验确认)**:当前最大 V34;S6 的 `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 内可续)。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -74,7 +74,40 @@ public interface MuseAiRuntimeClient {
|
||||
Long costCents,
|
||||
String providerRequestId,
|
||||
String finishReason,
|
||||
Instant completedAt) {
|
||||
Instant completedAt,
|
||||
// fullOutput 承载 provider 完整正文,仅进程内传递(供 S7b 瞬态通路/SSE 送达),
|
||||
// 永不持久化到任何列/表:落库只用 outputSummary 摘要;DO/mapper 一律不得引用此字段。
|
||||
String fullOutput) {
|
||||
|
||||
// 旧构造(不带 fullOutput):保持既有调用点(shadow 候选、历史测试桩)不变,完整正文缺省 null。
|
||||
// 须显式 public:record 内显式声明的构造不继承类型的 public,缺省为包级,跨包调用点会编译失败。
|
||||
public RuntimeResult(String correlationId,
|
||||
String status,
|
||||
Map<String, Object> outputSummary,
|
||||
Map<String, Object> tokenUsage,
|
||||
Long costCents,
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user