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:
parent
5659eb66c6
commit
d07770c701
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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):");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user