feat(ai-knowledge): P-C 质量门控可追溯,固化 Context Assembly Snapshot 到生成记录

专题-03 AI 检索串联第三期(P-C):AI 生成时把检索上下文组装结果固化为 Context Assembly Snapshot,内联进 generation.sourceSummary(jsonb,复用现有快照机制,无新表/无 DDL),作为质量评测可追溯输入(§8;门控评分本身归专题-04)。

- facade ContextAssembly 补 assemblySnapshot:status/chunkCount/retrievedKbIds/authorizationSnapshotIds/omittedSources(§8 可追溯)
- executor:检索组装提到 runtimeCommand 层,快照放进 command.inputSummary,经 command 流到 projection
- projection applyRuntimeResponse:把 contextAssembly merge 进 generation.sourceSummary 持久化(command/inputSummary null 防御)
- 整改 P-A 测试债:MuseAiTaskServiceTest 补 knowledgeRetrievalFacade mock(executor 自 P-A 起注入但未接桩,致 runtimeCommand 路径 14 例 NPE)

验证:facade 7 单测(含 snapshot 断言)+ projection 5 + task 40 防回归 + impl 11 回归 + ArchUnit 3 全绿。端到端真链路(真生成验 generation.sourceSummary 含 contextAssembly)验收待补。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-23 04:19:27 -07:00
parent 5659eb66c6
commit d07770c701
6 changed files with 98 additions and 10 deletions

View File

@ -143,11 +143,17 @@ public class MuseAiRuntimeJobExecutor {
Map<String, Object> inputSummary = parseJsonMap(job.getInputSummary());
String correlationId = StringUtils.hasText(job.getCorrelationId()) ? job.getCorrelationId() : task.getCorrelationId();
String userInstruction = runtimePayloadStore.get(runtimeTenantId(job), job.getId());
// 检索作品绑定知识库组装上下文(L3),并把 Context Assembly Snapshot 放进 inputSummary,
// command 流到 projection 持久化到生成记录(专题-03 §8 质量评测可追溯输入)
KnowledgeRetrievalFacade.ContextAssembly knowledgeContext = assembleKnowledgeContext(task, job, userInstruction);
if (!knowledgeContext.assemblySnapshot().isEmpty()) {
inputSummary.put("contextAssembly", knowledgeContext.assemblySnapshot());
}
return new MuseAiRuntimeClient.RuntimeCommand(job.getCommandId(), String.valueOf(task.getId()),
String.valueOf(job.getId()), correlationId, nextAttemptNo(job), modelKey(inputSummary),
promptTemplateVersion(inputSummary), inputSummary, job.getSourceSnapshotId(),
job.getRuntimePermissionEnvelopeId(), executionPrompt(inputSummary, task, job,
userInstruction, hasRuntimePayloadRequirement(inputSummary)),
userInstruction, hasRuntimePayloadRequirement(inputSummary), knowledgeContext),
runtimePolicyProvider.timeoutPolicy(),
runtimePolicyProvider.retryPolicy());
}
@ -157,7 +163,8 @@ public class MuseAiRuntimeJobExecutor {
}
private String executionPrompt(Map<String, Object> inputSummary, MuseAiGenerationDO task, MuseAiJobDO job,
String userInstruction, boolean requiresUserInstruction) {
String userInstruction, boolean requiresUserInstruction,
KnowledgeRetrievalFacade.ContextAssembly knowledgeContext) {
if (requiresUserInstruction && !StringUtils.hasText(userInstruction)) {
return null;
}
@ -171,8 +178,7 @@ public class MuseAiRuntimeJobExecutor {
metadata.put("modelKey", modelKey(inputSummary));
metadata.put("promptTemplateVersion", promptTemplateVersion(inputSummary));
metadata.put("sourceSummary", inputSummary.get("sourceSummary"));
// 检索作品绑定知识库的授权片段,填入上下文 Layer 3(专题-03 §4.2);检索失败/无命中由 facade fail-closed 降级,不阻断生成
KnowledgeRetrievalFacade.ContextAssembly knowledgeContext = assembleKnowledgeContext(task, job, userInstruction);
// 检索结果(L3)已由调用方 assembleKnowledgeContext 组装并传入;检索失败/无命中由 facade fail-closed 降级,不阻断生成
metadata.put("knowledgeChunkCount", knowledgeContext.chunkCount());
if (knowledgeContext.omittedSourceCount() > 0) {
// 被授权门(§4.3 用途/来源状态) token 预算过滤掉的来源数,透出供审计与质量门控可见(专题-03 §4 omittedSources)

View File

@ -86,6 +86,8 @@ public class MuseAiRuntimeProjectionService {
task.setErrorMessage(succeeded ? null : errorMessage(failure));
task.setRetryCount(succeeded ? task.getRetryCount() : nextRetryCount(job));
task.setFinishedAt(succeeded || !retryable ? LocalDateTime.now() : null);
// P-C: 固化 Context Assembly Snapshot 到生成记录(专题-03 §8 质量评测可追溯输入);快照由 executor 放入 command.inputSummary
mergeContextAssembly(task, command);
generationMapper.updateById(task);
}
job.setStatus(terminalOrRetryStatus);
@ -104,6 +106,20 @@ public class MuseAiRuntimeProjectionService {
}
}
/** 把 executor 放入 command.inputSummary 的 Context Assembly Snapshot 固化进生成记录 sourceSummary(专题-03 §8 质量评测可追溯输入)。 */
private void mergeContextAssembly(MuseAiGenerationDO task, MuseAiRuntimeClient.RuntimeCommand command) {
if (command == null || command.inputSummary() == null) {
return;
}
Object contextAssembly = command.inputSummary().get("contextAssembly");
if (contextAssembly == null) {
return;
}
Map<String, Object> sourceSummary = parseJsonMap(task.getSourceSummary());
sourceSummary.put("contextAssembly", contextAssembly);
task.setSourceSummary(JsonUtils.toJsonString(sourceSummary));
}
private MuseAiJobDO lockMutableJob(MuseAiJobDO job) {
if (job == null || job.getId() == null) {
return null;

View File

@ -1,6 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.OmittedSource;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalRequest;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalResult;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievedChunk;
@ -9,8 +10,11 @@ import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* 基于 knowledge **对外 API** 的检索上下文组装 adapter(BC 合规,专题-03 P-A)
@ -39,17 +43,42 @@ public class DefaultKnowledgeRetrievalFacade implements KnowledgeRetrievalFacade
return ContextAssembly.empty("retrieval_null");
}
int omittedSourceCount = result.omittedSources() == null ? 0 : result.omittedSources().size();
Map<String, Object> snapshot = buildSnapshot(result);
if (!result.hasChunks()) {
return ContextAssembly.empty(result.omittedReason(), omittedSourceCount);
return ContextAssembly.empty(result.omittedReason(), omittedSourceCount, snapshot);
}
String section = formatChunks(result.chunks());
return new ContextAssembly(section, result.chunks().size(), omittedSourceCount, null);
return new ContextAssembly(section, result.chunks().size(), omittedSourceCount, null, snapshot);
} catch (RuntimeException ex) {
// fail-closed 降级:检索故障绝不阻断主生成链(检索是增强非硬依赖)
return ContextAssembly.empty("retrieval_error");
}
}
/** 构造 Context Assembly Snapshot 结构化快照(专题-03 §8 质量评测可追溯输入:命中来源/授权快照/被省略来源)。 */
private Map<String, Object> buildSnapshot(RetrievalResult result) {
Map<String, Object> snapshot = new LinkedHashMap<>();
// §8 Context Assembly Snapshot 状态:本次组装即 active(stale/blocked 流转由来源事件驱动,属后续)
snapshot.put("status", "active");
List<RetrievedChunk> chunks = result.chunks() == null ? List.of() : result.chunks();
snapshot.put("chunkCount", chunks.size());
// 命中来源 kb + 授权快照(去重去 null),供质量评测/审计追溯本次生成依据
snapshot.put("retrievedKbIds", chunks.stream().map(RetrievedChunk::sourceKbId)
.filter(Objects::nonNull).distinct().toList());
snapshot.put("authorizationSnapshotIds", chunks.stream().map(RetrievedChunk::authorizationSnapshotId)
.filter(StringUtils::hasText).distinct().toList());
// 被授权门/token 预算过滤掉的来源明细(omittedSources,§4)
List<OmittedSource> omitted = result.omittedSources() == null ? List.of() : result.omittedSources();
if (!omitted.isEmpty()) {
snapshot.put("omittedSources", omitted.stream()
.map(o -> Map.of("kbId", String.valueOf(o.kbId()),
"reason", o.reason() == null ? "" : o.reason(),
"sourceStatus", o.sourceStatus() == null ? "" : o.sourceStatus()))
.toList());
}
return snapshot;
}
/** 把检索片段格式化成可拼进 prompt 的 L3 上下文文本(带 kbId/相似度来源归因,符合 §5.3 可追溯)。 */
private String formatChunks(List<RetrievedChunk> chunks) {
StringBuilder sb = new StringBuilder("Authorized knowledge context (retrieved from work-bound knowledge bases):");

View File

@ -1,5 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import java.util.Map;
/**
* AI 侧知识检索上下文组装 facade(专题-03 P-A,BC 合规消费 knowledge-api)
*
@ -32,19 +34,28 @@ public interface KnowledgeRetrievalFacade {
* @param chunkCount 命中片段数
* @param omittedSourceCount 被授权门/token 预算过滤掉的来源数(专题-03 §4 omittedSources,供审计透明)
* @param omittedReason 无命中/降级原因(供审计;有命中时为 null)
* @param assemblySnapshot Context Assembly Snapshot 结构化快照(专题-03 §8 质量评测可追溯输入:
* status/chunkCount/retrievedKbIds/authorizationSnapshotIds/omittedSources),供固化到生成记录;可空 Map
*/
record ContextAssembly(String promptSection, int chunkCount, int omittedSourceCount, String omittedReason) {
record ContextAssembly(String promptSection, int chunkCount, int omittedSourceCount, String omittedReason,
Map<String, Object> assemblySnapshot) {
public static ContextAssembly empty(String omittedReason) {
return new ContextAssembly(null, 0, 0, omittedReason);
return new ContextAssembly(null, 0, 0, omittedReason, Map.of());
}
public static ContextAssembly empty(String omittedReason, int omittedSourceCount) {
return new ContextAssembly(null, 0, omittedSourceCount, omittedReason);
public static ContextAssembly empty(String omittedReason, int omittedSourceCount,
Map<String, Object> assemblySnapshot) {
return new ContextAssembly(null, 0, omittedSourceCount, omittedReason,
assemblySnapshot == null ? Map.of() : assemblySnapshot);
}
public boolean hasContext() {
return promptSection != null && !promptSection.isBlank();
}
public Map<String, Object> assemblySnapshot() {
return assemblySnapshot == null ? Map.of() : assemblySnapshot;
}
}
}

View File

@ -137,6 +137,8 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
private SecurityRuntimePermissionFacade securityRuntimePermissionFacade;
@Mock
private MuseAiRuntimePolicyProvider runtimePolicyProvider;
@Mock
private cn.iocoder.muse.module.ai.application.muse.facade.KnowledgeRetrievalFacade knowledgeRetrievalFacade;
@BeforeEach
void wireNestedServices() {
@ -151,6 +153,10 @@ class MuseAiTaskServiceTest extends BaseMockitoUnitTest {
ReflectionTestUtils.setField(runtimeJobExecutor, "generationMapper", generationMapper);
ReflectionTestUtils.setField(runtimeJobExecutor, "jobMapper", jobMapper);
ReflectionTestUtils.setField(runtimeJobExecutor, "runtimePayloadStore", runtimePayloadStore);
// P-A/P-B/P-C:executor runtimeCommand facade 检索组装 L3 上下文;单测默认 stub 为无命中(empty),prompt 退化为 metadata-only(不影响既有 contains 断言)
ReflectionTestUtils.setField(runtimeJobExecutor, "knowledgeRetrievalFacade", knowledgeRetrievalFacade);
lenient().when(knowledgeRetrievalFacade.assembleContext(any())).thenReturn(
cn.iocoder.muse.module.ai.application.muse.facade.KnowledgeRetrievalFacade.ContextAssembly.empty("no_binding"));
ReflectionTestUtils.setField(runtimeProjectionService, "generationMapper", generationMapper);
ReflectionTestUtils.setField(runtimeProjectionService, "jobMapper", jobMapper);
ReflectionTestUtils.setField(runtimeProjectionService, "suggestionMapper", suggestionMapper);

View File

@ -13,6 +13,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@ -53,6 +54,25 @@ class DefaultKnowledgeRetrievalFacadeTest extends BaseMockitoUnitTest {
assertTrue(result.promptSection().contains("kbId=5001"));
assertTrue(result.promptSection().contains("owner=user"));
assertTrue(result.promptSection().contains("开头要抓人"));
// §8 Context Assembly Snapshot:状态 + 命中来源 kb + 授权快照可追溯
assertEquals("active", result.assemblySnapshot().get("status"));
assertEquals(List.of(5001L), result.assemblySnapshot().get("retrievedKbIds"));
assertEquals(List.of("auth-1"), result.assemblySnapshot().get("authorizationSnapshotIds"));
}
@Test
void should_snapshotCarryOmittedSources_whenAllFiltered() {
when(knowledgeRetrievalApi.retrieveForWork(any(RetrievalRequest.class)))
.thenReturn(RetrievalResult.empty("not_authorized",
List.of(new OmittedSource(5001L, "not_authorized", "revoked"))));
ContextAssembly result = facade.assembleContext(req());
// empty 也带 assemblySnapshot,记录被过滤来源(§8 可追溯 + §4 omittedSources)
assertEquals("active", result.assemblySnapshot().get("status"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> omitted = (List<Map<String, Object>>) result.assemblySnapshot().get("omittedSources");
assertEquals(1, omitted.size());
assertEquals("not_authorized", omitted.get(0).get("reason"));
assertEquals("revoked", omitted.get(0).get("sourceStatus"));
}
@Test