feat(aigc): U5 giveup 真落盘 + U6 modify regenerate 取base源真消费(B4/B7)
6c6g 后端 Tier0 计划 U5+U6(docs/plans/2026-06-17-001-...)。 - U5 giveupNode 真落盘:gameRuntimeRoot 句柄 + attempts/verdict/escalationEvents 整包写盘 giveup-dump.json + K_GIVEUP_DUMP_PATH 回填真路径; best-effort 落盘失败仍正确 failed 终态;多级升档 stage3+ follow-up - U6 modify 不被卡部分: · base 源 dispatcher 据 baseVersionId(V19 持久化)反查 SourceProjectApi.fetchByVersionId→K_SOURCE_PROJECT(零新增列/不搬大 blob) · regenerate-module 缺 target fail-loud(防静默全量重生成)+ 真消费 base 源(buildRegenerateMessages 注 base 源 JSON+目标 behavior 聚焦,system 不变;create/repair 普通路零变) · deterministic guard-pending-E2 一字未动;严格 per-behavior splice-back 依赖结构化源=follow-up(诚实标注) - -api:SourceProjectApi.fetchByVersionId + DTO - 测试:aigc 46+studio 44=90 绿(+17 新);codex 评审 NO-MERGE→2 P0(缺target/伪收窄)全修后绿 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
069ae73e97
commit
679afa1c65
@ -11,6 +11,7 @@ import com.wanxiang.huijing.game.module.aigc.service.executor.PromptResourceLoad
|
||||
import com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.WorkerDispatchClient;
|
||||
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@ -105,13 +106,17 @@ public class AigcExecutorConfiguration {
|
||||
public SaaGraphDispatcher aigcSaaGraphDispatcher(AigcExecutorProperties properties,
|
||||
DifyCallbackService difyCallbackService,
|
||||
ObjectProvider<DataSource> dataSourceProvider,
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
|
||||
ObjectProvider<ObservationRegistry> observationRegistryProvider,
|
||||
ObjectProvider<SourceProjectApi> sourceProjectApiProvider) {
|
||||
// checkpoint(step5):软取 yudao 共享 DataSource(dynamic-datasource 路由源,primary=master)——saver 懒建于
|
||||
// 首个 dispatch(dispatcher=http 时本 Bean 在席但永不跑图/永不触 DB);DataSource 缺席则降级内存态。
|
||||
// observation:软取 Micrometer ObservationRegistry(actuator/micrometer 装配时在席;缺则用 NOOP=不埋点)。
|
||||
// U6/B7:软取 SourceProjectApi(studio @Primary 就地解析;modify/extend 路据 baseVersionId 反查 base 源注入 K_SOURCE_PROJECT)。
|
||||
// 缺席(单模块装配/studio 未在 classpath)→ modify 取源旁路(K_SOURCE_PROJECT 空串,与 create 同),不阻断装配。
|
||||
DataSource dataSource = dataSourceProvider.getIfAvailable();
|
||||
ObservationRegistry observationRegistry = observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP);
|
||||
return new SaaGraphDispatcher(properties, difyCallbackService, dataSource, observationRegistry);
|
||||
SourceProjectApi sourceProjectApi = sourceProjectApiProvider.getIfAvailable();
|
||||
return new SaaGraphDispatcher(properties, difyCallbackService, dataSource, observationRegistry, sourceProjectApi);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -12,6 +12,9 @@ import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallba
|
||||
import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.AigcExecutorProperties;
|
||||
import com.wanxiang.huijing.game.module.aigc.service.executor.GenerationDispatcher;
|
||||
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -84,6 +87,12 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
* 可为 null/NOOP(无 actuator/micrometer 装配态)→ 不埋点(图照常跑)。
|
||||
*/
|
||||
private final ObservationRegistry observationRegistry;
|
||||
/**
|
||||
* 源项目工件反查 seam(U6/B7 §5.6:modify/extend 取 base 源):aigc→studio-api,按 baseVersionId 反查 base 源 source_json
|
||||
* 注入 SAA state K_SOURCE_PROJECT(与 {@code DifyCallbackServiceImpl} 用同一 {@link SourceProjectApi} seam)。
|
||||
* <p><b>可为 null</b>(4 参旧构造/纯抽取单测态)→ modify 取 base 源整段旁路(K_SOURCE_PROJECT 缺省空串,与 U1 现行一致)。
|
||||
*/
|
||||
private final SourceProjectApi sourceProjectApi;
|
||||
|
||||
/**
|
||||
* 后台单线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。
|
||||
@ -117,7 +126,8 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
private volatile CompiledGraph compiledGraph;
|
||||
|
||||
/**
|
||||
* 构造(经 AigcExecutorConfiguration @Bean 调用)。
|
||||
* 4 参构造(保留:纯 trace 抽取单测 / 不接 modify 取源的装配态用)——{@code sourceProjectApi=null},
|
||||
* modify 取 base 源整段旁路(K_SOURCE_PROJECT 缺省空串,现行 create 路字节零变)。
|
||||
*
|
||||
* @param properties 执行器配置
|
||||
* @param difyCallbackService 回调唯一写入路径
|
||||
@ -126,10 +136,26 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
*/
|
||||
public SaaGraphDispatcher(AigcExecutorProperties properties, DifyCallbackService difyCallbackService,
|
||||
DataSource dataSource, ObservationRegistry observationRegistry) {
|
||||
this(properties, difyCallbackService, dataSource, observationRegistry, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 5 参构造(U6/B7:接 {@link SourceProjectApi} 反查 base 源;经 AigcExecutorConfiguration @Bean 调用)。
|
||||
*
|
||||
* @param properties 执行器配置
|
||||
* @param difyCallbackService 回调唯一写入路径
|
||||
* @param dataSource yudao 共享 DataSource(checkpoint 用;可 null=降级内存态)
|
||||
* @param observationRegistry 观测注册表(可 null/NOOP=不埋点)
|
||||
* @param sourceProjectApi 源项目反查 seam(U6 modify 取 base 源;可 null=取源旁路)
|
||||
*/
|
||||
public SaaGraphDispatcher(AigcExecutorProperties properties, DifyCallbackService difyCallbackService,
|
||||
DataSource dataSource, ObservationRegistry observationRegistry,
|
||||
SourceProjectApi sourceProjectApi) {
|
||||
this.properties = properties;
|
||||
this.difyCallbackService = difyCallbackService;
|
||||
this.dataSource = dataSource;
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.sourceProjectApi = sourceProjectApi;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -380,14 +406,82 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
throw new IllegalArgumentException("不支持的 modifyMode=\"" + modifyMode
|
||||
+ "\"(仅支持 " + SUPPORTED_MODIFY_MODES + ",§5.6);禁静默退回 create");
|
||||
}
|
||||
// 透传(含空串=create 路;renderRouter 据 modifyMode 是否空分流)。modifyPatch/baseVersionId/sourceProject 同透传。
|
||||
// 透传(含空串=create 路;renderRouter 据 modifyMode 是否空分流)。modifyPatch/baseVersionId 同透传。
|
||||
inputs.put(SaaStudioNodes.K_MODIFY_MODE, modifyMode);
|
||||
inputs.put(SaaStudioNodes.K_MODIFY_PATCH, strOf(job.get("modifyPatch"), ""));
|
||||
inputs.put(SaaStudioNodes.K_BASE_VERSION_ID, strOf(job.get("baseVersionId"), ""));
|
||||
inputs.put(SaaStudioNodes.K_SOURCE_PROJECT, strOf(job.get("sourceProject"), ""));
|
||||
String baseVersionId = strOf(job.get("baseVersionId"), "");
|
||||
inputs.put(SaaStudioNodes.K_BASE_VERSION_ID, baseVersionId);
|
||||
// ── U6/B7(§5.6 modify 取 base 源):modify/extend 路据 baseVersionId 反查 base 源 source_json 注入 K_SOURCE_PROJECT,
|
||||
// 使 modify 节点在 base 源上改/重生成。job 已带 sourceProject(罕见,executor 当前不带)则优先用之;
|
||||
// 否则经 SourceProjectApi 反查(aigc→studio-api seam)。create 路(modifyMode 空)不取源,K_SOURCE_PROJECT 空串=现行字节零变。
|
||||
inputs.put(SaaStudioNodes.K_SOURCE_PROJECT, resolveBaseSourceProject(job, modifyMode, baseVersionId, traceId));
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* U6/B7:解析注入 K_SOURCE_PROJECT 的 base 源(§5.6 modify 取 base 源)。
|
||||
* <ul>
|
||||
* <li>job 已显式带 {@code sourceProject}(非空)→ 直接用(最高优先,便于测试/未来 job 自带源);</li>
|
||||
* <li>create 路(modifyMode 空)→ 不取源,返回空串(现行 create 行为字节零变,不触 SourceProjectApi);</li>
|
||||
* <li>modify/extend 路 ∧ baseVersionId 非空 ∧ {@link #sourceProjectApi} 在席 → 反查 base 源 source_json 注入;</li>
|
||||
* <li>反查未命中({@code found=false})/ seam 缺席 / 反查异常 → 返回空串(base 无源),由 modify 节点的 U6 命门守卫
|
||||
* (regenerate-module 路 base 缺则 fail-loud,见 {@code SaaStudioNodes.guardRegenerateTarget})决断,<b>不在此处抛</b>
|
||||
* (派发侧旁路读异常不裸退图;deterministic 路本就 guard-pending-E2 不消费 base 源)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param job §6.1 job(可能带 sourceProject)
|
||||
* @param modifyMode 已校验的 modify 模式(空=create 路)
|
||||
* @param baseVersionId base 版本 ID 串(create 路空)
|
||||
* @param traceId 贯穿键(日志定位)
|
||||
* @return 注入 K_SOURCE_PROJECT 的 source_json;无则空串
|
||||
*/
|
||||
private String resolveBaseSourceProject(Map<String, Object> job, String modifyMode, String baseVersionId, String traceId) {
|
||||
// ① job 自带 sourceProject 优先(executor 当前不带;保留口子便于测试/未来 job 直供源)。
|
||||
String fromJob = strOf(job.get("sourceProject"), "");
|
||||
if (!fromJob.isEmpty()) {
|
||||
return fromJob;
|
||||
}
|
||||
// ② create 路(modifyMode 空)不取源——不触 SourceProjectApi,现行 create 字节零变。
|
||||
if (modifyMode.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
// ③ modify/extend 路:据 baseVersionId 反查 base 源(seam 缺席/baseVersionId 空/反查未命中 → 空串,由 modify 节点守卫决断)。
|
||||
if (sourceProjectApi == null) {
|
||||
log.warn("[saa-dispatch] modify 取 base 源跳过:SourceProjectApi seam 缺席(4 参构造态)traceId={}, mode={}", traceId, modifyMode);
|
||||
return "";
|
||||
}
|
||||
if (baseVersionId.isEmpty()) {
|
||||
log.warn("[saa-dispatch] modify 取 base 源跳过:baseVersionId 空 traceId={}, mode={}", traceId, modifyMode);
|
||||
return "";
|
||||
}
|
||||
Long versionId;
|
||||
try {
|
||||
versionId = Long.valueOf(baseVersionId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("[saa-dispatch] modify 取 base 源跳过:baseVersionId 非数字={} traceId={}", baseVersionId, traceId);
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
CommonResult<SourceProjectFetchRespDTO> resp = sourceProjectApi.fetchByVersionId(versionId);
|
||||
SourceProjectFetchRespDTO data = (resp == null) ? null : resp.getData();
|
||||
if (data == null || !Boolean.TRUE.equals(data.getFound())
|
||||
|| data.getSourceJson() == null || data.getSourceJson().isBlank()) {
|
||||
// base 无源(未落源/早期态):返空串。modify 节点 U6 命门(regenerate base 缺 fail-loud)据此明确报错,非静默全量重生成。
|
||||
log.warn("[saa-dispatch] modify 取 base 源未命中(base 无源行/源空)→ 注入空源 traceId={}, baseVersionId={}, mode={}",
|
||||
traceId, versionId, modifyMode);
|
||||
return "";
|
||||
}
|
||||
log.info("[saa-dispatch] modify 取 base 源命中并注入 K_SOURCE_PROJECT traceId={}, baseVersionId={}, sourceId={}, len={}, mode={}",
|
||||
traceId, versionId, data.getId(), data.getSourceJson().length(), modifyMode);
|
||||
return data.getSourceJson();
|
||||
} catch (Exception e) {
|
||||
// 反查异常(seam/Feign/DB 抖动):best-effort 旁路返空串,不裸退图(modify 节点守卫据空源决断)。
|
||||
log.warn("[saa-dispatch] modify 取 base 源反查异常 → 注入空源(不裸退图,由 modify 节点守卫决断)traceId={}, baseVersionId={}",
|
||||
traceId, versionId, e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* result state → {@link DifyCallbackReqVO}(形态严格对齐 Python worker build_callback_payload,service.py:91-123)。
|
||||
* <ul>
|
||||
|
||||
@ -259,6 +259,57 @@ final class SaaPrompts {
|
||||
return new String[]{system, user};
|
||||
}
|
||||
|
||||
/** base 源注入上限(防超长 prompt 撑爆上下文/计费;便宜模型上下文窗口有限)。超出则截断并标注。 */
|
||||
private static final int SOURCE_PROJECT_MAX_CHARS = 12000;
|
||||
|
||||
/**
|
||||
* P0-2(regenerate-module 路真消费 base 源·informed-by-base 重生成):在 {@link #buildMessages} 基础上,
|
||||
* 把 <b>base 源项目 JSON</b> + <b>目标 behavior 标识</b> + <b>改玩法意图</b> 注入 user prompt,让 LLM 在<b>既有结构</b>上
|
||||
* 重生成那一个 behavior(其余 behavior/config/assets 摘要保留),而非凭题面凭空全量重出。
|
||||
*
|
||||
* <p><b>为何只在 regenerate 路调本方法</b>:dispatcher 据 baseVersionId 反查 base 源注入 {@code K_SOURCE_PROJECT},
|
||||
* 但旧 {@link #buildMessages} 只取 enriched/feedback、<b>不消费 base 源</b> → LLM 看不到原 behaviors → 仍可能全量重出,
|
||||
* 「按 target 收窄」沦为伪契约(评审 P0-2)。本方法是 regenerate 路的<b>专用消费点</b>,把 base 源真喂进去。
|
||||
* <b>create/repair 普通生成路不调本方法</b>(仍走 {@link #buildMessages}),故普通路 prompt 字节零变。
|
||||
*
|
||||
* <p><b>诚实边界(B7/引擎线 follow-up)</b>:本期做到「base 源真喂 LLM + 目标 behavior 聚焦提示」(informed-by-base 重生成);
|
||||
* 严格 per-behavior「只替换目标 behavior 子树、其余字节不动的 <b>splice-back</b>」依赖生成 agent 真填结构化
|
||||
* {@code gameDefinition/behaviors}(现源多为最小骨架,跨单元),<b>本期不做</b>,留 <b>TODO(B7/engine-E2 splice-back)</b>。
|
||||
* 即本方法不伪造「已实现精准 per-behavior 重生成」,只确保「LLM 真看到 base 源、被引导聚焦目标 behavior」。
|
||||
*
|
||||
* @param briefText 题面(含设计稿,对齐 studio.py:216 的 enriched)。
|
||||
* @param retryFeedback 收窄回喂(modify 节点已写「只重 target、其余不动」+ intent;可空)。
|
||||
* @param gameRuntimeRoot game-runtime 根(读 few-shot 探针 factory)。
|
||||
* @param baseSourceJson base 源项目 JSON 串(dispatcher 反查注入 K_SOURCE_PROJECT;本方法仅在其非空时由调用方调用)。
|
||||
* @param targetBehavior 目标 behavior 标识(id/path;modify 已保证非空——见 P0-1 缺 target fail-loud)。
|
||||
* @param intent 改玩法意图(可空)。
|
||||
* @return [0]=system(与普通路同一 SYSTEM+few-shot,公平性铁律不改 prompt 语义),[1]=user(额外附 base 源 + 目标 behavior 聚焦段)。
|
||||
*/
|
||||
static String[] buildRegenerateMessages(String briefText, String retryFeedback, Path gameRuntimeRoot,
|
||||
String baseSourceJson, String targetBehavior, String intent) {
|
||||
// system 与普通路完全一致(注入同一 few-shot)——公平性铁律:只在 user 末尾附 base 源上下文,不动 SYSTEM 语义。
|
||||
String[] basemsgs = buildMessages(briefText, retryFeedback, gameRuntimeRoot);
|
||||
StringBuilder user = new StringBuilder(basemsgs[1]);
|
||||
// base 源真喂:在既有源项目结构上重生成目标 behavior(其余部件保留),把 base 源 JSON 原样附上(超长截断标注)。
|
||||
String src = baseSourceJson == null ? "" : baseSourceJson;
|
||||
boolean truncated = src.length() > SOURCE_PROJECT_MAX_CHARS;
|
||||
if (truncated) {
|
||||
src = src.substring(0, SOURCE_PROJECT_MAX_CHARS);
|
||||
}
|
||||
user.append("\n\n———\n【这是一次「在既有源项目上重生成单个玩法模块」的请求,不是从零新建】\n");
|
||||
user.append("下面是当前游戏的【源项目工件 JSON(base 源)】,请在其既有结构与其它部件不变的前提下,"
|
||||
+ "只重新生成目标玩法模块(behavior):target=").append(targetBehavior).append("。\n");
|
||||
if (intent != null && !intent.isEmpty()) {
|
||||
user.append("本次改动意图:").append(intent).append("。\n");
|
||||
}
|
||||
user.append("要求:① 读懂 base 源中已有的 behaviors/config/assets 等部件,保持目标 behavior 之外的部件语义不变(其余摘要保留);"
|
||||
+ "② 只对 target=").append(targetBehavior).append(" 这一个 behavior 做重生成/调整;"
|
||||
+ "③ 最终仍按上面的【输出格式】只产出一个完整、可独立装载的 ```js 工厂模块(含整局可玩闭环,不要只给片段/diff)。\n");
|
||||
user.append("【base 源 JSON").append(truncated ? "(过长已截断,仅供结构参考)" : "").append("】:\n")
|
||||
.append(src);
|
||||
return new String[]{basemsgs[0], user.toString()};
|
||||
}
|
||||
|
||||
/** 读 few-shot 探针 factory(读文件防漂移,对齐 prompt.py:_few_shot)。 */
|
||||
static String fewShot(Path gameRuntimeRoot) {
|
||||
try {
|
||||
|
||||
@ -250,7 +250,9 @@ public final class SaaStudioGraph {
|
||||
.addNode("escalate", node_async(SaaStudioNodes.escalateNode()))
|
||||
.addNode("repair", node_async(SaaStudioNodes.repairNode()))
|
||||
.addNode("emit", node_async(SaaStudioNodes.emitNode(gameRuntimeRoot)))
|
||||
.addNode("giveup", node_async(SaaStudioNodes.giveupNode()))
|
||||
// U5/B4:giveup 节点持 gameRuntimeRoot 句柄,放弃前把 attempts/verdict/escalationEvents 整包真落盘到
|
||||
// games/_wg1-gen/<gameId>/evidence/giveup-dump.json(best-effort,失败仅 warn 不二次崩,见 SaaStudioNodes.giveupNode)。
|
||||
.addNode("giveup", node_async(SaaStudioNodes.giveupNode(gameRuntimeRoot)))
|
||||
// -- 直边 --
|
||||
.addEdge(START, "render") // 图入口
|
||||
.addEdge("classify", "design") // classify→design(§6.2)
|
||||
|
||||
@ -450,7 +450,24 @@ final class SaaStudioNodes {
|
||||
.orElse(s.value(K_BRIEF, String.class).orElse(""));
|
||||
String feedback = s.value(K_FEEDBACK, String.class).orElse(null);
|
||||
|
||||
String[] msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot);
|
||||
// ── P0-2(regenerate-module 路真消费 base 源):仅当本轮是 regenerate-module 路(modifyMode=regenerate-module
|
||||
// ∧ role=fix〔modify 节点置〕)∧ base 源非空时,把 base 源 + 目标 behavior + intent 注入 prompt
|
||||
// (informed-by-base 重生成);否则(create / 普通 repair 失败回边)走原 buildMessages,prompt 字节零变。
|
||||
// target/intent 从 K_MODIFY_PATCH 复用 resolveBehaviorTarget 再取(单一事实源,与 modify 节点同口径;
|
||||
// modify 节点已经 P0-1 守卫保证 regenerate 路 target 非空)。
|
||||
String modifyMode = s.value(K_MODIFY_MODE, String.class).orElse("");
|
||||
String sourceProject = s.value(K_SOURCE_PROJECT, String.class).orElse("");
|
||||
boolean regeneratePath = isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty();
|
||||
String[] msgs;
|
||||
if (regeneratePath) {
|
||||
JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse(""));
|
||||
String target = resolveBehaviorTarget(patch);
|
||||
String intent = patch != null ? patch.path("payload").path("intent").asText("") : "";
|
||||
msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot,
|
||||
sourceProject, target, intent);
|
||||
} else {
|
||||
msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot);
|
||||
}
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
// A-1:generate(含 repair 轮,role=fix 时)LLM 调用套 180s 超时 + ≤3 重试;
|
||||
// 超限不裸退图——把错因写 feedback + factorySrc 留空(validate 判空过短 → 路由 repair/giveup,对齐 run.py:103-105)。
|
||||
@ -757,23 +774,87 @@ final class SaaStudioNodes {
|
||||
* giveup 节点:超救场阶梯仍未过门 → status=failed(失败原因取最后 feedback)。
|
||||
*
|
||||
* <p>固定架构扩展(execution §6.2/§6.3):额外落 {@link #K_GIVEUP_DUMP_PATH}(放弃前完整 dump 的落盘路径,Opus 离线读)。
|
||||
* <b>本期边界(B4)</b>:本节点产出确定性 dump 路径(按 gameId 派生)<b>初始逻辑</b>——保证 trace 链路有此键、可观测;
|
||||
* 「把所有 attempts 源码/verdict 整包真写盘到该路径」标 {@code TODO(B4)}(giveup 节点当前无 gameRuntimeRoot 句柄,
|
||||
* 真落盘待 B4 补节点入参 + 写盘逻辑)。节点<b>非空壳</b>:真置终态 + 真产 dump 路径键。
|
||||
*
|
||||
* <p><b>U5/B4 真落盘(本期收口)</b>:本节点持 {@code gameRuntimeRoot} 句柄(仿 {@link #scaffoldNode}/{@link #emitNode}
|
||||
* 工厂签名,由 {@code SaaStudioGraph.assemble} 传入),放弃前把 <b>state.attempts(全源码轨迹)+ verdict + escalationEvents +
|
||||
* 上下文(gameId/repairCount/failCount/modelTier/feedback)</b>序列化整包真写盘到
|
||||
* {@code gameRuntimeRoot/games/_wg1-gen/<gameId>/evidence/giveup-dump.json}(供 Opus 离线分析定位为何救场耗尽),
|
||||
* 并把 {@link #K_GIVEUP_DUMP_PATH} 回填为<b>真实落盘路径</b>(不再只产确定性派生路径占位)。
|
||||
*
|
||||
* <p><b>best-effort 纪律(命门)</b>:落盘失败<b>仅 warn、绝不二次崩</b>——dump 是放弃后的离线诊断旁路,写盘失败
|
||||
* (磁盘满/目录不可写/序列化异常)不得让 giveup 偏离正确终态。失败时仍置 {@code status=failed} + 正确 failureReason
|
||||
* 回调({@link #K_GIVEUP_DUMP_PATH} 回填确定性派生路径占位,标记「dump 未落盘」),与 {@link #emitNode} 读盘失败的
|
||||
* 防御式降级同款(生成成败由 status/failureReason 决定,dump 在其后、独立)。
|
||||
*
|
||||
* <p><b>多级升档 TODO(B4) follow-up</b>:本期维持单级救场(stage1→stage2,见 {@link #escalateNode});多级升档
|
||||
* (stage3+ 不同模型梯队)暂不做,留 follow-up(execution §6.3 单级阶梯已满足 ≥80% 目标,多级是增厚不在本期)。
|
||||
*
|
||||
* @param gameRuntimeRoot game-runtime 根(dump 落盘到其 games/_wg1-gen/<gameId>/evidence/ 下;与 emit/play 同根)。
|
||||
*/
|
||||
static NodeAction giveupNode() {
|
||||
static NodeAction giveupNode(Path gameRuntimeRoot) {
|
||||
return (OverAllState s) -> {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put(K_STATUS, "failed");
|
||||
String fb = s.value(K_FEEDBACK, String.class).orElse("超过最大修复轮数仍未过门");
|
||||
out.put(K_FAILURE, "max_repairs_exceeded: " + trunc(fb, 400));
|
||||
// dump 落盘路径(确定性派生;TODO(B4):真把 attempts/verdict 整包写到此路径喂 Opus 离线分析)。
|
||||
String gameId = s.value(K_GAME_ID, String.class).orElse("unknown");
|
||||
out.put(K_GIVEUP_DUMP_PATH, "games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json");
|
||||
// 确定性派生的相对路径占位(与 emit/play 落盘目录同构);落盘成功后回填为真实绝对路径,失败则用此占位(标 dump 未落盘)。
|
||||
String relPath = "games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json";
|
||||
String dumpPath = relPath;
|
||||
// B4 真落盘:把放弃前的完整轨迹整包写盘(best-effort,失败仅 warn 不二次崩,仍保正确 failed 终态)。
|
||||
try {
|
||||
Path evidenceDir = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("evidence");
|
||||
Files.createDirectories(evidenceDir); // 目录不存在则建(与 scaffold 落盘同款)
|
||||
Path dumpFile = evidenceDir.resolve("giveup-dump.json");
|
||||
// dump 内容:放弃终态全貌——attempts(全源码轨迹,已 JSON 数组串)+ verdict + escalationEvents + 上下文计数。
|
||||
// attempts/verdict/escalationEvents 在 state 内已是 JSON 串 → 解回树嵌入(避免双重转义产难读的转义串),解析失败则原样落串(best-effort 不丢)。
|
||||
com.fasterxml.jackson.databind.node.ObjectNode dump = MAPPER.createObjectNode();
|
||||
dump.put("gameId", gameId);
|
||||
dump.put("reason", "max_repairs_exceeded"); // 放弃原因桶(与 failureReason 同源)
|
||||
dump.put("failureReason", strVal(s, K_FAILURE, out)); // 完整失败原因(含最后 feedback 摘要)
|
||||
dump.put("repairCount", ((Number) s.value(K_REPAIR_COUNT, 0)).intValue());
|
||||
dump.put("failCount", ((Number) s.value(K_FAIL_COUNT, 0)).intValue());
|
||||
dump.put("modelTier", s.value(K_MODEL_TIER, String.class).orElse(""));
|
||||
dump.put("lastFeedback", s.value(K_FEEDBACK, String.class).orElse(""));
|
||||
dump.set("attempts", embedJsonOrText(s.value(K_ATTEMPTS, String.class).orElse("[]"))); // 全轮源码/usage/stage_fail 轨迹
|
||||
dump.set("verdict", embedJsonOrText(s.value(K_VERDICT, String.class).orElse(""))); // 末次九门 verdict
|
||||
dump.set("escalationEvents", embedJsonOrText(s.value(K_ESCALATION_EVENTS, String.class).orElse("[]"))); // 救场升档事件序列
|
||||
dump.set("playerPanel", embedJsonOrText(s.value(K_PLAYER_PANEL, String.class).orElse(""))); // player 软门评判(若有)
|
||||
dump.put("ts", java.time.Instant.now().toString());
|
||||
// 写盘:UTF-8、pretty(离线人读友好)。写成功 → dumpPath 回填真实绝对路径。
|
||||
Files.write(dumpFile, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(dump));
|
||||
dumpPath = dumpFile.toAbsolutePath().toString();
|
||||
} catch (Exception e) {
|
||||
// best-effort:落盘失败仅 warn,不二次崩——dumpPath 保留确定性派生占位(供 trace 链路有键、标 dump 未落盘)。
|
||||
System.err.println("[saa-giveup] giveup dump 落盘失败(best-effort,不影响 failed 终态)gameId="
|
||||
+ gameId + ",err=" + trunc(String.valueOf(e.getMessage()), 200));
|
||||
}
|
||||
out.put(K_GIVEUP_DUMP_PATH, dumpPath);
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「本节点 out 已写值,否则 state 旧值」的字符串(giveup dump 记 failureReason 用;out 优先因本轮已置 failed 终态)。
|
||||
*/
|
||||
private static String strVal(OverAllState s, String key, Map<String, Object> out) {
|
||||
if (out.containsKey(key) && out.get(key) != null) {
|
||||
return String.valueOf(out.get(key));
|
||||
}
|
||||
return s.value(key, String.class).orElse("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 state 内的 JSON 串嵌入 dump:能解析成 JSON 则嵌为子树(避免双重转义、离线可读);解析失败/空串则以文本节点原样落(best-effort 不丢内容)。
|
||||
*/
|
||||
private static JsonNode embedJsonOrText(String raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return MAPPER.nullNode();
|
||||
}
|
||||
JsonNode parsed = SaaPrompts.looseParse(raw); // 复用便宜模型脏 JSON 宽松解析口径
|
||||
return parsed != null ? parsed : com.fasterxml.jackson.databind.node.TextNode.valueOf(raw);
|
||||
}
|
||||
|
||||
// ====================== ⑧ classify 节点(固定架构新增:品类分类 + profile 三维,真 LLM) ======================
|
||||
|
||||
/**
|
||||
@ -947,13 +1028,52 @@ final class SaaStudioNodes {
|
||||
JsonNode patch = SaaPrompts.looseParse(patchStr);
|
||||
|
||||
if ("regenerate-module".equals(mode)) {
|
||||
// 重生成模块路:置 fix 角色 + 把 intent 作为 feedback 喂 generate(只重那一个 behavior,其余不动)。
|
||||
// 重生成模块路(U6/B7 不被卡部分):置 fix 角色 + 按 target 收窄到那一个 behavior 模块喂 generate(其余源不动)。
|
||||
String intent = patch != null ? patch.path("payload").path("intent").asText("") : "";
|
||||
// U6:按 modifyPatch.target(behavior id 优先,否则 path)定位被重生成的模块——回喂 feedback 明确「只重这一个 behavior」,
|
||||
// 非全量重出(generate 在 sourceProject 基础上重出,prompt 收窄到目标模块)。
|
||||
String target = resolveBehaviorTarget(patch); // 取 target.id(优先)/target.path;缺则空
|
||||
// ── P0-1(缺 target 强校验·绝不静默全量重生成):regenerate-module 的语义是「只重指定的那一个 behavior 模块」,
|
||||
// 故 target(id 或 path)必须显式非空——否则收窄无锚点、prompt 退化为「在源上重出整局」= 与全量重生成无异
|
||||
// (旧实现:target 空 → resolveBehaviorTarget 返空串 → guardRegenerateTarget 仅 !target.isEmpty() 才校验 →
|
||||
// 空 target 通过守卫、role=fix+feedback 路由 generate → 静默全量重生成)。本处<b>首行</b>强校验:缺 target
|
||||
// 立即 fail-loud(status=failed + 明确 failureReason),复用 modifyRouter 的 fail 分支路由 END,不进入收窄/generate。
|
||||
if (target.isEmpty()) {
|
||||
out.put(K_MODIFY_MODE, "deterministic"); // 复用 modifyRouter 的 fail 分支路由到 END(regenerate 缺 target=不可达 generate)
|
||||
out.put(K_STATUS, "failed");
|
||||
out.put(K_FAILURE, "modify regenerate-module 缺 target:modifyPatch.target 的 id 或 path 均为空,"
|
||||
+ "regenerate-module 须指定被重生成的目标 behavior(拒静默退化为全量重生成;请提供 target.id 或 target.path)");
|
||||
// base 源若有则透传保留(不丢),便于离线诊断。
|
||||
if (!spStr.isEmpty()) {
|
||||
out.put(K_SOURCE_PROJECT, spStr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// U6 命门:base 源缺失 / target 在 sourceProject.behaviors[] 无对应 → 明确错(非静默全量重生成),与确定性路 fail-loud 同纪律。
|
||||
String guardErr = guardRegenerateTarget(spStr, target);
|
||||
if (guardErr != null) {
|
||||
out.put(K_MODIFY_MODE, "deterministic"); // 复用 modifyRouter 的 fail 分支路由到 END(regenerate 守卫失败=不可达 generate)
|
||||
out.put(K_STATUS, "failed");
|
||||
out.put(K_FAILURE, guardErr);
|
||||
// base 源若有则透传保留(不丢),便于离线诊断。
|
||||
if (!spStr.isEmpty()) {
|
||||
out.put(K_SOURCE_PROJECT, spStr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out.put(K_ROLE, "fix");
|
||||
out.put(K_FEEDBACK, intent.isEmpty()
|
||||
? "请只重生成被指定的玩法模块(behavior),其余部件保持不变。"
|
||||
: ("请只重生成被指定的玩法模块(behavior),意图:" + intent + ";其余部件保持不变。"));
|
||||
// sourceProject 原样透传(generate 在其基础上重出那一模块;B7 完整应用时按 target 精确定位模块)。
|
||||
// 回喂收窄到目标 behavior:明确「只重生成被指定模块、其余 sourceProject 部件不动」(非全量重出)。
|
||||
StringBuilder fb = new StringBuilder("请在现有源项目基础上,只重新生成被指定的玩法模块(behavior)");
|
||||
if (!target.isEmpty()) {
|
||||
fb.append(":target=").append(target);
|
||||
}
|
||||
fb.append(",其余部件(其它 behaviors / config / assets / 关卡)保持不变");
|
||||
if (!intent.isEmpty()) {
|
||||
fb.append("。改玩法意图:").append(intent);
|
||||
}
|
||||
fb.append("。");
|
||||
out.put(K_FEEDBACK, fb.toString());
|
||||
// sourceProject 原样透传(generate 在其基础上重出那一模块;guard 已确保 base 源 + target 有效)。
|
||||
if (!spStr.isEmpty()) {
|
||||
out.put(K_SOURCE_PROJECT, spStr);
|
||||
}
|
||||
@ -985,6 +1105,70 @@ final class SaaStudioNodes {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* U6:从 modifyPatch 取 regenerate-module 的目标 behavior 标识——优先 {@code target.id},否则 {@code target.path}(缺则空串)。
|
||||
* 用于把回喂 feedback 收窄到那一个 behavior(非全量重出),并供 {@link #guardRegenerateTarget} 校验目标存在性。
|
||||
*/
|
||||
private static String resolveBehaviorTarget(JsonNode patch) {
|
||||
if (patch == null || !patch.isObject()) {
|
||||
return "";
|
||||
}
|
||||
JsonNode target = patch.path("target");
|
||||
String id = target.path("id").asText("").trim();
|
||||
if (!id.isEmpty()) {
|
||||
return id;
|
||||
}
|
||||
return target.path("path").asText("").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* U6 命门(取 base 源失败 / target 无对应源 → 明确错,非静默全量重生成):校验 regenerate-module 的前置——
|
||||
* <ul>
|
||||
* <li>base 源缺失({@code sourceProject} 空/非法)→ 报错(无可改的源,不能凭空全量重生成当 modify);</li>
|
||||
* <li>target 非空但 {@code sourceProject.behaviors[]} 内查无该 id/path 对应模块 → 报错(指向不存在 behavior,§U6 edge 用例)。</li>
|
||||
* </ul>
|
||||
* 校验通过返回 null;失败返回明确 failureReason(调用方据此置 failed 终态 + 路由 END,不静默退化为全量重生成)。
|
||||
*
|
||||
* <p><b>诚实边界</b>:behaviors 容器探测兼容两形态——{@code sourceProject.behaviors} 为数组(元素含 id/name/path)
|
||||
* 或 {@code sourceProject.gameDefinition.behaviors} 同形(源 schema 演进期两处都可能落 behaviors);两处都无 behaviors
|
||||
* 容器时<b>不卡死</b>(仅校验 base 源非空,target 存在性留 generate 自处置)——避免对「源骨架尚无 behaviors 段」的早期态误杀。
|
||||
*/
|
||||
private static String guardRegenerateTarget(String spStr, String target) {
|
||||
// ① base 源非空校验:regenerate 是「在现有源上重出一模块」,base 源缺失则无源可改(不静默当全量生成)。
|
||||
JsonNode sp = (spStr == null || spStr.isEmpty()) ? null : SaaPrompts.looseParse(spStr);
|
||||
if (sp == null || !sp.isObject()) {
|
||||
return "modify regenerate-module 取 base 源失败:sourceProject 为空或非法 JSON(无可重生成的 base 源,"
|
||||
+ "拒静默退化为全量重生成;请确认 baseVersionId 对应源已落库 game_source_project)";
|
||||
}
|
||||
// ② target 存在性校验:仅当源里确有 behaviors 容器时才校验(无容器=源骨架早期态,不误杀,留 generate 处置)。
|
||||
if (!target.isEmpty()) {
|
||||
JsonNode behaviors = sp.path("behaviors");
|
||||
if (!behaviors.isArray()) {
|
||||
behaviors = sp.path("gameDefinition").path("behaviors"); // 兼容 gameDefinition.behaviors 落点
|
||||
}
|
||||
if (behaviors.isArray() && behaviors.size() > 0 && !behaviorExists(behaviors, target)) {
|
||||
return "modify regenerate-module 目标 behavior 不存在:target=" + target
|
||||
+ " 在 sourceProject.behaviors[] 中查无对应模块(拒静默全量重生成;请确认 target.id/path 正确)";
|
||||
}
|
||||
}
|
||||
return null; // 校验通过
|
||||
}
|
||||
|
||||
/** 在 behaviors 数组里按 id/name/path 任一匹配 target(regenerate 目标存在性校验用)。 */
|
||||
private static boolean behaviorExists(JsonNode behaviors, String target) {
|
||||
for (JsonNode b : behaviors) {
|
||||
if (!b.isObject()) {
|
||||
continue;
|
||||
}
|
||||
if (target.equals(b.path("id").asText(null))
|
||||
|| target.equals(b.path("name").asText(null))
|
||||
|| target.equals(b.path("path").asText(null))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* P1-6(RFC6901 JSON Pointer 寻址):按 modifyPatch.target.path 把 payload.value 写进 sourceProject 一处(确定性编辑)。
|
||||
*
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
package com.wanxiang.huijing.game.module.aigc.saa;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SaaGraphDispatcherBaseSourceTest —— U6/B7 §5.6「modify 取 base 源」的<b>纯单元测试</b>(反射调 {@code buildInputs})。
|
||||
*
|
||||
* <p><b>不起图、不起 DB、不调 LLM</b>:mock {@link SourceProjectApi},反射调
|
||||
* {@code SaaGraphDispatcher.buildInputs(Map,String)} 私有方法,断 K_SOURCE_PROJECT 注入结果。
|
||||
*
|
||||
* <p>守门目标(U6 Test scenarios + 命门):
|
||||
* <ul>
|
||||
* <li>modify 路 ∧ baseVersionId → 经 SourceProjectApi 反查 base 源注入 K_SOURCE_PROJECT;</li>
|
||||
* <li>create 路(modifyMode 空)→ 不取源、不触 SourceProjectApi,K_SOURCE_PROJECT 空串(现行字节零变);</li>
|
||||
* <li>base 反查未命中(found=false)→ 注入空源(由 modify 节点守卫决断,非静默全量重生成);</li>
|
||||
* <li>job 自带 sourceProject → 优先用之、不触反查;</li>
|
||||
* <li>seam 缺席(sourceProjectApi=null)→ 取源旁路、空串、不崩。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 造梦AI(U6/B7 modify 取 base 源)
|
||||
*/
|
||||
class SaaGraphDispatcherBaseSourceTest {
|
||||
|
||||
/** 反射调被测私有方法 buildInputs(Map, String)。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> invokeBuildInputs(SaaGraphDispatcher d, Map<String, Object> job, String traceId)
|
||||
throws Exception {
|
||||
Method m = SaaGraphDispatcher.class.getDeclaredMethod("buildInputs", Map.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (Map<String, Object>) m.invoke(d, job, traceId);
|
||||
}
|
||||
|
||||
/** 用 mock SourceProjectApi 构造 dispatcher(其余依赖 null;buildInputs 只触碰 sourceProjectApi)。 */
|
||||
private static SaaGraphDispatcher dispatcherWith(SourceProjectApi api) {
|
||||
return new SaaGraphDispatcher(null, null, null, null, api);
|
||||
}
|
||||
|
||||
private static Map<String, Object> modifyJob(String mode, Object baseVersionId) {
|
||||
Map<String, Object> job = new HashMap<>();
|
||||
job.put("brief", "把跳跃改高");
|
||||
job.put("modifyMode", mode);
|
||||
job.put("modifyPatch", "{\"mode\":\"" + mode + "\"}");
|
||||
if (baseVersionId != null) {
|
||||
job.put("baseVersionId", baseVersionId);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
// ============================== 用例1:modify 路反查命中 → 注入 base 源 ==============================
|
||||
|
||||
@Test
|
||||
void buildInputs_modify_fetchesBaseSource_injectsSourceProject() throws Exception {
|
||||
SourceProjectApi api = mock(SourceProjectApi.class);
|
||||
SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO();
|
||||
data.setFound(Boolean.TRUE);
|
||||
data.setId(9001L);
|
||||
data.setSourceJson("{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"}]}");
|
||||
when(api.fetchByVersionId(eq(700L))).thenReturn(CommonResult.success(data));
|
||||
|
||||
Map<String, Object> inputs = invokeBuildInputs(dispatcherWith(api),
|
||||
modifyJob("regenerate-module", 700L), "trace-u6-1");
|
||||
|
||||
assertEquals("regenerate-module", inputs.get(SaaStudioNodes.K_MODIFY_MODE));
|
||||
assertEquals("700", inputs.get(SaaStudioNodes.K_BASE_VERSION_ID));
|
||||
assertTrue(((String) inputs.get(SaaStudioNodes.K_SOURCE_PROJECT)).contains("jump"),
|
||||
"modify 路应反查 base 源注入 K_SOURCE_PROJECT");
|
||||
verify(api).fetchByVersionId(700L);
|
||||
}
|
||||
|
||||
// ============================== 用例2:create 路不取源 ==============================
|
||||
|
||||
@Test
|
||||
void buildInputs_create_doesNotFetch_sourceProjectEmpty() throws Exception {
|
||||
SourceProjectApi api = mock(SourceProjectApi.class);
|
||||
Map<String, Object> job = new HashMap<>();
|
||||
job.put("brief", "一个躲避陨石的游戏"); // create 路:无 modifyMode
|
||||
|
||||
Map<String, Object> inputs = invokeBuildInputs(dispatcherWith(api), job, "trace-create-1");
|
||||
|
||||
assertEquals("", inputs.get(SaaStudioNodes.K_MODIFY_MODE), "create 路 modifyMode 空");
|
||||
assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT), "create 路不取源 → K_SOURCE_PROJECT 空串");
|
||||
verify(api, never()).fetchByVersionId(anyLong()); // 现行字节零变:create 路不触 SourceProjectApi
|
||||
}
|
||||
|
||||
// ============================== 用例3:base 反查未命中 → 注入空源(命门:非静默全量) ==============================
|
||||
|
||||
@Test
|
||||
void buildInputs_modify_baseNotFound_injectsEmpty() throws Exception {
|
||||
SourceProjectApi api = mock(SourceProjectApi.class);
|
||||
SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO();
|
||||
data.setFound(Boolean.FALSE); // base 无对应源行
|
||||
when(api.fetchByVersionId(eq(701L))).thenReturn(CommonResult.success(data));
|
||||
|
||||
Map<String, Object> inputs = invokeBuildInputs(dispatcherWith(api),
|
||||
modifyJob("regenerate-module", 701L), "trace-u6-miss");
|
||||
|
||||
assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT),
|
||||
"base 反查未命中 → 注入空源(由 modify 节点 U6 守卫 fail-loud,非静默全量重生成)");
|
||||
}
|
||||
|
||||
// ============================== 用例4:job 自带 sourceProject 优先 ==============================
|
||||
|
||||
@Test
|
||||
void buildInputs_jobCarriesSourceProject_takesPrecedence() throws Exception {
|
||||
SourceProjectApi api = mock(SourceProjectApi.class);
|
||||
Map<String, Object> job = modifyJob("regenerate-module", 700L);
|
||||
job.put("sourceProject", "{\"fromJob\":true}"); // job 已带源
|
||||
|
||||
Map<String, Object> inputs = invokeBuildInputs(dispatcherWith(api), job, "trace-u6-job");
|
||||
|
||||
assertEquals("{\"fromJob\":true}", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT),
|
||||
"job 自带 sourceProject 应优先用之");
|
||||
verify(api, never()).fetchByVersionId(anyLong()); // 不触反查
|
||||
}
|
||||
|
||||
// ============================== 用例5:seam 缺席(4 参构造态)→ 取源旁路、不崩 ==============================
|
||||
|
||||
@Test
|
||||
void buildInputs_seamAbsent_modifyDegradesEmpty() throws Exception {
|
||||
// 5 参构造传 null seam(等价 4 参旧构造态)。
|
||||
SaaGraphDispatcher d = new SaaGraphDispatcher(null, null, null, null, null);
|
||||
Map<String, Object> inputs = invokeBuildInputs(d, modifyJob("regenerate-module", 700L), "trace-u6-noseam");
|
||||
assertEquals("", inputs.get(SaaStudioNodes.K_SOURCE_PROJECT),
|
||||
"seam 缺席 → modify 取源旁路、K_SOURCE_PROJECT 空串(不崩)");
|
||||
}
|
||||
}
|
||||
@ -430,10 +430,15 @@ class SaaStudioGraphTopologyTest {
|
||||
// ③ 路由:modifyRouter → "fail"(图里 → END,不走 build)。
|
||||
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s),
|
||||
"deterministic 路由应为 fail(→END),不走 build");
|
||||
// 复核:regenerate-module 路不受影响,仍 → generate。
|
||||
// 复核:regenerate-module 路不受 P0-4 deterministic guard 影响,仍 → generate(本期可达)。
|
||||
// 注(U6/B7):regenerate-module 现要求 base 源非空(取 base 源失败 fail-loud,非静默全量重生成),故此处补 K_SOURCE_PROJECT
|
||||
// 非空基线(无 behaviors 容器 = 早期源骨架态,不触发 target 存在性守卫),坐实 regenerate ≠ deterministic-fail。
|
||||
// 注(U6 P0-1):regenerate-module 现还要求 target 非空(缺 target fail-loud,见 modify_regenerate_module_missing_target_fails_loud),
|
||||
// 故此处 patch 补 target(本测的意图是「regenerate ≠ deterministic-fail」,非测缺 target 路)。
|
||||
OverAllState s2 = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, "{\"mode\":\"regenerate-module\",\"payload\":{\"intent\":\"x\"}}"));
|
||||
SaaStudioNodes.K_MODIFY_PATCH, "{\"mode\":\"regenerate-module\",\"target\":{\"id\":\"jump\"},\"payload\":{\"intent\":\"x\"}}",
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, "{\"schemaVersion\":\"1.0\"}"));
|
||||
applyNode(s2, SaaStudioNodes.modifyNode());
|
||||
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s2));
|
||||
}
|
||||
@ -474,6 +479,200 @@ class SaaStudioGraphTopologyTest {
|
||||
|
||||
// ====================== ⑮ P1-5:escalate 后 modelTier=stage2 时 generate 选 stage2 强档模型 ======================
|
||||
|
||||
// ====================== ⑯ U5/B4:giveup 真落盘(dump 整包 + 路径回填 + best-effort) ======================
|
||||
|
||||
/**
|
||||
* U5/B4:救场耗尽 giveup 时把 attempts/verdict/escalationEvents 整包真写盘到
|
||||
* {@code <root>/games/_wg1-gen/<gameId>/evidence/giveup-dump.json},且 K_GIVEUP_DUMP_PATH 回填<b>真实绝对路径</b>、
|
||||
* giveup 仍正确置 failed 终态。
|
||||
*/
|
||||
@Test
|
||||
void giveup_node_writes_full_dump_and_backfills_real_path() throws Exception {
|
||||
java.nio.file.Path tmpRoot = java.nio.file.Files.createTempDirectory("saa-giveup-dump-test");
|
||||
try {
|
||||
String gameId = "saa-giveup-1";
|
||||
// 构造放弃终态桩:attempts(全轮轨迹)+ verdict(末次九门)+ escalationEvents(升档序列)+ 上下文计数。
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_GAME_ID, gameId,
|
||||
SaaStudioNodes.K_FEEDBACK, "九门 C_frame 始终未过:帧率探针读不到 tick",
|
||||
SaaStudioNodes.K_REPAIR_COUNT, 8,
|
||||
SaaStudioNodes.K_FAIL_COUNT, 8,
|
||||
SaaStudioNodes.K_MODEL_TIER, "stage2",
|
||||
SaaStudioNodes.K_ATTEMPTS,
|
||||
"[{\"attempt\":0,\"role\":\"code\",\"model\":\"m\",\"usage\":{\"in\":10,\"out\":20}},"
|
||||
+ "{\"attempt\":1,\"role\":\"fix\",\"model\":\"m\",\"stage_fail\":\"play\",\"usage\":{\"in\":5,\"out\":7}}]",
|
||||
SaaStudioNodes.K_VERDICT, "{\"pass\":false,\"guards\":{\"C_frame\":{\"pass\":false}}}",
|
||||
SaaStudioNodes.K_ESCALATION_EVENTS,
|
||||
"[{\"tierBefore\":\"stage1\",\"tierAfter\":\"stage2\",\"atFailCount\":5,\"ts\":\"2026-06-17T00:00:00Z\"}]"));
|
||||
applyNode(s, SaaStudioNodes.giveupNode(tmpRoot));
|
||||
|
||||
// ① 终态正确:status=failed + failureReason 含 max_repairs_exceeded。
|
||||
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""), "giveup 仍置 failed 终态");
|
||||
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("max_repairs_exceeded"));
|
||||
|
||||
// ② dump 路径回填为真实绝对路径,且文件真存在。
|
||||
String dumpPath = s.value(SaaStudioNodes.K_GIVEUP_DUMP_PATH, String.class).orElse("");
|
||||
assertTrue(dumpPath.endsWith("giveup-dump.json"), "dump 路径应指向 giveup-dump.json,实得=" + dumpPath);
|
||||
java.nio.file.Path expected = tmpRoot.resolve("games").resolve("_wg1-gen").resolve(gameId)
|
||||
.resolve("evidence").resolve("giveup-dump.json");
|
||||
assertEquals(expected.toAbsolutePath().toString(), dumpPath, "K_GIVEUP_DUMP_PATH 应回填真实绝对路径");
|
||||
assertTrue(java.nio.file.Files.exists(expected), "dump 文件应真落盘");
|
||||
|
||||
// ③ dump 内容含全 attempts/verdict/escalationEvents + 上下文(整包,不截断关键信息)。
|
||||
JsonNode dump = MAPPER.readTree(java.nio.file.Files.readAllBytes(expected));
|
||||
assertEquals(gameId, dump.path("gameId").asText());
|
||||
assertEquals("max_repairs_exceeded", dump.path("reason").asText());
|
||||
assertEquals(8, dump.path("failCount").asInt());
|
||||
assertEquals("stage2", dump.path("modelTier").asText());
|
||||
assertTrue(dump.path("attempts").isArray() && dump.path("attempts").size() == 2,
|
||||
"dump 应含全 attempts 轨迹(整包不截断)");
|
||||
assertEquals("play", dump.path("attempts").get(1).path("stage_fail").asText(), "attempts 嵌为子树(非转义串)");
|
||||
assertFalse(dump.path("verdict").path("pass").asBoolean(true), "dump 应含末次 verdict");
|
||||
assertTrue(dump.path("escalationEvents").isArray() && dump.path("escalationEvents").size() == 1,
|
||||
"dump 应含升档事件序列");
|
||||
} finally {
|
||||
// 清理临时目录(递归删)。
|
||||
deleteRecursively(tmpRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* U5/B4 best-effort:落盘失败(gameRuntimeRoot 指向一个<b>已存在的文件</b>,createDirectories 在其下建目录必失败)
|
||||
* → giveup <b>仍正确置 failed 终态</b>、不二次崩;K_GIVEUP_DUMP_PATH 回填确定性派生占位路径(标 dump 未落盘)。
|
||||
*/
|
||||
@Test
|
||||
void giveup_node_dump_write_failure_is_best_effort_no_crash() throws Exception {
|
||||
// 用一个普通文件当 "root":root.resolve("games/...") 在文件下建目录 → createDirectories 抛 → 落盘失败路径。
|
||||
java.nio.file.Path fileAsRoot = java.nio.file.Files.createTempFile("saa-giveup-notdir", ".tmp");
|
||||
try {
|
||||
String gameId = "saa-giveup-fail";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_GAME_ID, gameId,
|
||||
SaaStudioNodes.K_FEEDBACK, "x",
|
||||
SaaStudioNodes.K_ATTEMPTS, "[]"));
|
||||
// 不抛异常(best-effort)。
|
||||
applyNode(s, SaaStudioNodes.giveupNode(fileAsRoot));
|
||||
// 终态仍正确。
|
||||
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
|
||||
"落盘失败仍须保正确 failed 终态(best-effort 不二次崩)");
|
||||
// 路径回填确定性派生占位(相对路径,标 dump 未落盘)。
|
||||
assertEquals("games/_wg1-gen/" + gameId + "/evidence/giveup-dump.json",
|
||||
s.value(SaaStudioNodes.K_GIVEUP_DUMP_PATH, String.class).orElse(""),
|
||||
"落盘失败 → dumpPath 回填确定性派生占位(标 dump 未落盘)");
|
||||
} finally {
|
||||
java.nio.file.Files.deleteIfExists(fileAsRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归删临时目录(测试清理用)。 */
|
||||
private static void deleteRecursively(java.nio.file.Path p) throws Exception {
|
||||
if (!java.nio.file.Files.exists(p)) {
|
||||
return;
|
||||
}
|
||||
try (java.util.stream.Stream<java.nio.file.Path> walk = java.nio.file.Files.walk(p)) {
|
||||
walk.sorted(java.util.Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
java.nio.file.Files.deleteIfExists(path);
|
||||
} catch (Exception ignore) {
|
||||
// 清理 best-effort
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ⑰ U6/B7:regenerate-module 按 target 收窄 + base 缺拒 ======================
|
||||
|
||||
/**
|
||||
* U6/B7:regenerate-module 按 target(behavior id)收窄——回喂 feedback 明确「只重生成那一个 behavior、其余不动」,
|
||||
* 含 target 标识与 intent;base 源(含该 behavior)透传保留;路由 → generate(本期可达)。
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_narrows_by_target() throws Exception {
|
||||
// base 源含 behaviors[jump];target=jump 命中 → 收窄回喂、路由 generate。
|
||||
String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"},{\"id\":\"shoot\"}]}";
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"},"
|
||||
+ "\"payload\":{\"intent\":\"让跳跃更高更软\"}}";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch,
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, base));
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
|
||||
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""), "regenerate 置 role=fix");
|
||||
String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
|
||||
assertTrue(fb.contains("jump"), "feedback 应收窄到 target=jump,实得=" + fb);
|
||||
assertTrue(fb.contains("让跳跃更高更软"), "feedback 应含 intent");
|
||||
assertTrue(fb.contains("其余部件") || fb.contains("保持不变"), "feedback 应明确其余部件不动(非全量重出)");
|
||||
// base 源透传保留。
|
||||
assertTrue(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("").contains("shoot"),
|
||||
"base 源应透传保留(generate 在其基础上重出 jump)");
|
||||
// 路由 → generate(本期可达)。
|
||||
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* U6/B7 命门:regenerate-module 取 base 源失败(sourceProject 空)→ 明确错(非静默全量重生成),路由 fail(→END)。
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_missing_base_source_fails_loud() throws Exception {
|
||||
// base 源空(K_SOURCE_PROJECT 缺)→ 守卫报错、置 failed、路由 fail。
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"},"
|
||||
+ "\"payload\":{\"intent\":\"x\"}}";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch)); // 不给 K_SOURCE_PROJECT = base 源缺失
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
|
||||
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
|
||||
"base 源缺失应 fail-loud(非静默全量重生成)");
|
||||
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("base 源"),
|
||||
"failureReason 应明确指向 base 源缺失");
|
||||
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "base 缺 → 路由 fail(→END)");
|
||||
}
|
||||
|
||||
/**
|
||||
* U6/B7 命门:regenerate-module 的 target 在 sourceProject.behaviors[] 查无对应 behavior → 明确错(非静默全量重生成)。
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_unknown_target_fails_loud() throws Exception {
|
||||
// base 源含 behaviors[jump],但 target=nonexistent 查无 → 守卫报错。
|
||||
String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"}]}";
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"nonexistent\"},"
|
||||
+ "\"payload\":{\"intent\":\"x\"}}";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch,
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, base));
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
|
||||
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
|
||||
"target 指向不存在 behavior 应 fail-loud");
|
||||
assertTrue(s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("").contains("不存在"),
|
||||
"failureReason 应明确 target behavior 不存在");
|
||||
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* U6/B7 边界:base 源无 behaviors 容器(早期源骨架态)→ 不误杀(仅校验 base 源非空),路由 generate。
|
||||
* 守卫只在「源里确有 behaviors 容器」时才校验 target 存在性,避免对早期态误杀。
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_no_behaviors_container_not_killed() throws Exception {
|
||||
// base 源非空但无 behaviors 段(asset 节点产的最小骨架:schemaVersion + assets[])。
|
||||
String base = "{\"schemaVersion\":\"1.0\",\"assets\":[{\"id\":\"x\"}]}";
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"kind\":\"behavior\",\"id\":\"jump\"},"
|
||||
+ "\"payload\":{\"intent\":\"x\"}}";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch,
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, base));
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
// 不误杀:role=fix、路由 generate(target 存在性留 generate 处置)。
|
||||
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""));
|
||||
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s),
|
||||
"无 behaviors 容器(早期源骨架)不误杀,路由 generate");
|
||||
}
|
||||
|
||||
/**
|
||||
* P1-5:升档(escalate→stage2)后 generate 必须真切到更强模型,否则升档无实际效果。
|
||||
* 用纯静态选择器 {@link SaaStudioNodes#selectGenerateModelName}(与 generateNode 内模型对象选择同口径)<b>无网络</b>断言:
|
||||
@ -499,4 +698,106 @@ class SaaStudioGraphTopologyTest {
|
||||
assertFalse(m.codeStage2().equals(m.code()),
|
||||
"P1-5:stage2 code 模型须不同于(强于)stage1,否则升档无实际效果");
|
||||
}
|
||||
|
||||
// ====================== ⑱ U6 评审 P0-1/P0-2:缺 target fail-loud + base 源真喂 prompt ======================
|
||||
|
||||
/**
|
||||
* P0-1(缺 target 静默全量重生成 → fail-loud):regenerate-module 路 modifyPatch.target 的 id/path 均空时,
|
||||
* 旧实现(resolveBehaviorTarget 返空串、guardRegenerateTarget 仅 !target.isEmpty() 才校验)会通过守卫、role=fix
|
||||
* 路由 generate = 静默全量重生成。修后须<b>首行强校验</b>:缺 target 立即 status=failed + failureReason 说明
|
||||
* 「regenerate 缺 target」+ 路由 fail(→END),绝不进入收窄/generate。
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_missing_target_fails_loud() throws Exception {
|
||||
// base 源含 behaviors(充分),但 patch 完全不带 target(仅 intent)→ 缺 target 必须 fail-loud(非静默全量重生成)。
|
||||
String base = "{\"schemaVersion\":\"1.0\",\"behaviors\":[{\"id\":\"jump\"},{\"id\":\"shoot\"}]}";
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"payload\":{\"intent\":\"让跳跃更高\"}}"; // 无 target
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch,
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, base));
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
|
||||
assertEquals("failed", s.value(SaaStudioNodes.K_STATUS, String.class).orElse(""),
|
||||
"缺 target 必须 fail-loud(绝不静默全量重生成)");
|
||||
String reason = s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("");
|
||||
assertTrue(reason.contains("缺 target") || reason.contains("target"),
|
||||
"failureReason 应说明 regenerate 缺 target,实得=" + reason);
|
||||
// 未进入收窄路:role 不应被置 fix(不进入 generate)。
|
||||
assertEquals("fail", SaaStudioNodes.modifyRouter().apply(s), "缺 target → 路由 fail(→END),不可达 generate");
|
||||
}
|
||||
|
||||
/**
|
||||
* P0-1 边界:target.path 形态(无 id 但有 path)应视为有 target、不被缺-target 守卫误杀。
|
||||
* (resolveBehaviorTarget id 优先、否则 path;缺 target 守卫只在两者皆空时触发。)
|
||||
*/
|
||||
@Test
|
||||
void modify_regenerate_module_target_by_path_not_killed_by_missing_target_guard() throws Exception {
|
||||
// base 源无 behaviors 容器(早期源骨架)= 不触 target 存在性守卫;patch 用 target.path 形态 → 不应判缺 target。
|
||||
String base = "{\"schemaVersion\":\"1.0\"}";
|
||||
String patch = "{\"mode\":\"regenerate-module\",\"target\":{\"path\":\"/behaviors/0\"},"
|
||||
+ "\"payload\":{\"intent\":\"x\"}}";
|
||||
OverAllState s = newFullState(Map.of(
|
||||
SaaStudioNodes.K_MODIFY_MODE, "regenerate-module",
|
||||
SaaStudioNodes.K_MODIFY_PATCH, patch,
|
||||
SaaStudioNodes.K_SOURCE_PROJECT, base));
|
||||
applyNode(s, SaaStudioNodes.modifyNode());
|
||||
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""),
|
||||
"target.path 形态应视为有 target(不被缺-target 守卫误杀)");
|
||||
assertEquals("generate", SaaStudioNodes.modifyRouter().apply(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* P0-2(regenerate 路真消费 base 源·informed-by-base):buildRegenerateMessages(generateNode 在 regenerate 路所调)
|
||||
* 产出的 user prompt 必须含 base 源 JSON 关键内容 + 目标 behavior 标识 + intent,让 LLM 在既有结构上重那一个 behavior,
|
||||
* 而非凭题面凭空全量重出(修评审「按 target 收窄成伪契约」)。
|
||||
*/
|
||||
@Test
|
||||
void regenerate_prompt_carries_base_source_and_target() {
|
||||
String enriched = "题面:一个跳跃小游戏\n\n## 设计稿\n核心机制...";
|
||||
String feedback = "请在现有源项目基础上,只重新生成被指定的玩法模块(behavior):target=jump";
|
||||
String baseSource = "{\"schemaVersion\":\"1.0\",\"behaviors\":["
|
||||
+ "{\"id\":\"jump\",\"params\":{\"height\":120}},{\"id\":\"shoot\",\"params\":{\"rate\":3}}]}";
|
||||
String[] msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, DUMMY_ROOT,
|
||||
baseSource, "jump", "让跳跃更高更软");
|
||||
String user = msgs[1];
|
||||
// ① base 源真喂:user prompt 含源 JSON 的关键标识(behaviors / 各部件 id),LLM 能看到原结构。
|
||||
assertTrue(user.contains("behaviors"), "regenerate prompt 应含 base 源结构(behaviors),实得片段=" + tail(user));
|
||||
assertTrue(user.contains("\"id\":\"jump\"") && user.contains("\"id\":\"shoot\""),
|
||||
"regenerate prompt 应含 base 源各 behavior(含须保留的 shoot),坐实 LLM 看到既有结构");
|
||||
assertTrue(user.contains("height") || user.contains("120"),
|
||||
"regenerate prompt 应含 base 源细节(目标 behavior 既有参数),非仅题面");
|
||||
// ② 目标 behavior 聚焦:明确 target + 意图 + 「只重目标、其余不变」。
|
||||
assertTrue(user.contains("target=jump"), "regenerate prompt 应聚焦 target=jump");
|
||||
assertTrue(user.contains("让跳跃更高更软"), "regenerate prompt 应含改玩法意图");
|
||||
assertTrue(user.contains("其它部件") || user.contains("其余") || user.contains("不变"),
|
||||
"regenerate prompt 应提示其余部件保留(informed-by-base 而非全量重出)");
|
||||
// system 与普通路同一(公平性铁律:不改 SYSTEM 语义,只在 user 附 base 源)。
|
||||
assertEquals(SaaPrompts.buildMessages(enriched, feedback, DUMMY_ROOT)[0], msgs[0],
|
||||
"regenerate 路 system 必须与普通路同一 SYSTEM(不改 prompt 语义)");
|
||||
}
|
||||
|
||||
/**
|
||||
* P0-2 零回归:create/repair 普通生成路(buildMessages)的 prompt <b>不含</b> base 源——只在 regenerate 路注入。
|
||||
* 严格 additive 的字节级证据:普通路 user prompt 不出现「源项目」/「base 源」注入段。
|
||||
*/
|
||||
@Test
|
||||
void normal_path_prompt_has_no_base_source() {
|
||||
String enriched = "题面:一个跳跃小游戏\n\n## 设计稿\n核心机制...";
|
||||
// 普通 create 路(无 feedback)。
|
||||
String[] create = SaaPrompts.buildMessages(enriched, null, DUMMY_ROOT);
|
||||
assertFalse(create[1].contains("源项目工件") || create[1].contains("base 源") || create[1].contains("base 源 JSON"),
|
||||
"create 普通路 prompt 不应含 base 源注入(零回归)");
|
||||
// 普通 repair 失败回边(有 feedback,但非 regenerate 路)。
|
||||
String[] repair = SaaPrompts.buildMessages(enriched, "上一轮 validate 失败:源码过短", DUMMY_ROOT);
|
||||
assertFalse(repair[1].contains("源项目工件") || repair[1].contains("base 源"),
|
||||
"repair 普通路(失败回喂)prompt 不应含 base 源注入(零回归)");
|
||||
// 普通路 user prompt 仍是「实现下面这款游戏」开头(字节不变锚)。
|
||||
assertTrue(create[1].startsWith("请实现下面这款游戏"), "create 普通路 user prompt 开头不变");
|
||||
}
|
||||
|
||||
/** 取字符串尾部片段(断言失败时定位,避免打印超长 prompt)。 */
|
||||
private static String tail(String s) {
|
||||
return s == null ? "null" : (s.length() > 300 ? "..." + s.substring(s.length() - 300) : s);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.wanxiang.huijing.game.module.studio.api;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.enums.ApiConstants;
|
||||
@ -7,6 +8,7 @@ import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@ -91,4 +93,21 @@ public interface SourceProjectApi {
|
||||
@Operation(summary = "标孤儿(按源行 ID 精确定位置 status=2,不建包不动 currentVersion)")
|
||||
CommonResult<Boolean> markOrphanById(@RequestParam("id") Long id);
|
||||
|
||||
/**
|
||||
* 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):构建成功回填 version_id 后据此由版本反查源
|
||||
* (idx_version 命中),回传 source_json 供注入 SAA state K_SOURCE_PROJECT。
|
||||
*
|
||||
* <p><b>用途</b>:modify/extend 要在 base 版本的源项目上改/重生成。{@code SaaGraphDispatcher.buildInputs} 据已持久化抵达的
|
||||
* {@code baseVersionId} 调本 API 反查 base 源(aigc→studio-api seam,与 land/markBuilt 同一 seam),注入 K_SOURCE_PROJECT。
|
||||
*
|
||||
* <p><b>非阻断纪律</b>:反查是派发侧旁路读——version_id 无对应源行(base 未落源/早期态)→ {@code found=false}、
|
||||
* sourceJson=null(调用方据此明确「base 无源」,决断是否拒;不抛)。
|
||||
*
|
||||
* @param versionId base 产物版本 ID(project.game_version.id)
|
||||
* @return 反查结果(found + source_json + status),CommonResult 包裹;无对应源行 found=false
|
||||
*/
|
||||
@GetMapping(PREFIX + "/source-project/fetch-by-version")
|
||||
@Operation(summary = "按版本 ID 反查源工件 source_json(U6 modify 取 base 源;无则 found=false)")
|
||||
CommonResult<SourceProjectFetchRespDTO> fetchByVersionId(@RequestParam("versionId") Long versionId);
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.wanxiang.huijing.game.module.studio.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 源项目工件反查结果(U6/B7 §5.6:modify/extend 按 baseVersionId 反查 base 源 source_json)
|
||||
*
|
||||
* <p><b>为什么需要它</b>:modify/extend 要在「被改 base 版本的源项目」之上改/重生成(execution §5.6),故须按 base
|
||||
* 产物版本 ID 反查其源工件 JSON(idx_version)注入 SAA state K_SOURCE_PROJECT,使 modify 节点在 base 源上改。
|
||||
* 反查发生在 aigc 派发侧({@code SaaGraphDispatcher.buildInputs},baseVersionId 已随 V19 列持久化抵达),经本 seam 桥接。
|
||||
*
|
||||
* <p><b>边界</b>:只回传源态 + source_json(不回 sourceUrl 等存储细节);version_id 无对应源行 → {@code found=false}、
|
||||
* sourceJson 留 null(调用方据此明确「base 无源」而非静默空源;U6 命门:base 缺拒,非静默全量重生成)。
|
||||
*
|
||||
* @author 造梦AI(U6/B7 modify 取 base 源)
|
||||
*/
|
||||
@Data
|
||||
public class SourceProjectFetchRespDTO {
|
||||
|
||||
/**
|
||||
* 是否查到 base 版本对应的源行(true=查到、sourceJson 有值;false=无对应源行、sourceJson 为 null)
|
||||
*/
|
||||
private Boolean found;
|
||||
|
||||
/**
|
||||
* 源项目工件行 ID(game_source_project.id;found=false 时为 null)
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 源项目工件 JSON 全文(SourceProject schema;found=false 时为 null)——注入 SAA state K_SOURCE_PROJECT
|
||||
*/
|
||||
private String sourceJson;
|
||||
|
||||
/**
|
||||
* 源态:0草稿 1已构建 2孤儿 3已发布(found=false 时为 null;诊断用,反查不限态)
|
||||
*/
|
||||
private Integer status;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.wanxiang.huijing.game.module.studio.api;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.service.studio.SourceProjectService;
|
||||
@ -46,4 +47,10 @@ public class SourceProjectApiImpl implements SourceProjectApi {
|
||||
return success(sourceProjectService.markOrphanById(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<SourceProjectFetchRespDTO> fetchByVersionId(Long versionId) {
|
||||
// 反查 base 源(U6/B7 §5.6 modify 取 base 源):按版本 ID 反查 source_json,无对应源行 found=false
|
||||
return success(sourceProjectService.fetchByVersionId(versionId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.wanxiang.huijing.game.module.studio.service.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
|
||||
|
||||
@ -52,4 +53,15 @@ public interface SourceProjectService {
|
||||
*/
|
||||
boolean markOrphanById(Long id);
|
||||
|
||||
/**
|
||||
* 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):idx_version 命中回传 source_json。
|
||||
*
|
||||
* <p>只读、不限态(反查 base 源不要求 status=1,published/built 皆可读);version_id 无对应源行 → {@code found=false}、
|
||||
* sourceJson=null(调用方据此明确「base 无源」;不抛)。version_id 为 null → found=false。
|
||||
*
|
||||
* @param versionId base 产物版本 ID(= project.game_version.id)
|
||||
* @return 反查结果(found + source_json + status);无对应源行 found=false
|
||||
*/
|
||||
SourceProjectFetchRespDTO fetchByVersionId(Long versionId);
|
||||
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.GameSourceProjectDO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.GameSourceProjectMapper;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandRespDTO;
|
||||
import com.wanxiang.huijing.game.module.studio.enums.SourceProjectStatusEnum;
|
||||
@ -218,6 +219,38 @@ public class SourceProjectServiceImpl implements SourceProjectService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按产物版本 ID 反查源工件(U6/B7 §5.6:modify/extend 取 base 源):idx_version 命中回传 source_json。
|
||||
*
|
||||
* <p>只读、不限态(base 源 built/published 皆可读);version_id 为 null / 无对应源行 → found=false、sourceJson=null
|
||||
* (调用方据此明确「base 无源」,决断是否拒,不抛——非阻断旁路读)。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(readOnly = true, rollbackFor = Exception.class)
|
||||
public SourceProjectFetchRespDTO fetchByVersionId(Long versionId) {
|
||||
SourceProjectFetchRespDTO resp = new SourceProjectFetchRespDTO();
|
||||
if (versionId == null) {
|
||||
log.warn("[source-project] 反查 base 源入参 versionId=null,found=false");
|
||||
resp.setFound(Boolean.FALSE);
|
||||
return resp;
|
||||
}
|
||||
GameSourceProjectDO source = gameSourceProjectMapper.selectByVersionId(versionId);
|
||||
if (source == null) {
|
||||
// base 版本无对应源行(未落源/早期态):found=false,调用方据此明确「base 无源」(U6 命门:base 缺拒,非静默空源)
|
||||
log.info("[source-project] 反查 base 源未命中(version_id 无对应源行)versionId={}, found=false", versionId);
|
||||
resp.setFound(Boolean.FALSE);
|
||||
return resp;
|
||||
}
|
||||
resp.setFound(Boolean.TRUE);
|
||||
resp.setId(source.getId());
|
||||
resp.setSourceJson(source.getSourceJson());
|
||||
resp.setStatus(source.getStatus());
|
||||
log.info("[source-project] 反查 base 源命中 versionId={}, sourceId={}, status={}, sourceJsonLen={}",
|
||||
versionId, source.getId(), source.getStatus(),
|
||||
source.getSourceJson() == null ? 0 : source.getSourceJson().length());
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ============================== 私有:规范化 sha256 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@ -319,6 +319,46 @@ class SourceProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
verify(gameSourceProjectMapper, never()).insert(any(GameSourceProjectDO.class));
|
||||
}
|
||||
|
||||
// ============================== 用例:U6 fetchByVersionId 反查 base 源 ==============================
|
||||
|
||||
@Test
|
||||
void testFetchByVersionId_hit() {
|
||||
// idx_version 命中 → found=true + source_json + status。
|
||||
GameSourceProjectDO src = new GameSourceProjectDO();
|
||||
src.setId(3001L);
|
||||
src.setVersionId(700L);
|
||||
src.setSourceJson(SOURCE_JSON);
|
||||
src.setStatus(SourceProjectStatusEnum.BUILT.getStatus());
|
||||
when(gameSourceProjectMapper.selectByVersionId(700L)).thenReturn(src);
|
||||
|
||||
com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp =
|
||||
sourceProjectService.fetchByVersionId(700L);
|
||||
assertTrue(resp.getFound());
|
||||
assertEquals(3001L, resp.getId());
|
||||
assertEquals(SOURCE_JSON, resp.getSourceJson());
|
||||
assertEquals(SourceProjectStatusEnum.BUILT.getStatus(), resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchByVersionId_miss_returnsFoundFalse() {
|
||||
// version_id 无对应源行 → found=false、sourceJson=null(U6 命门:base 无源,调用方据此决断)。
|
||||
when(gameSourceProjectMapper.selectByVersionId(701L)).thenReturn(null);
|
||||
com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp =
|
||||
sourceProjectService.fetchByVersionId(701L);
|
||||
assertFalse(resp.getFound());
|
||||
assertNull(resp.getSourceJson());
|
||||
assertNull(resp.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchByVersionId_nullVersionId_returnsFoundFalse() {
|
||||
// versionId=null → found=false,不查 Mapper(前置短路)。
|
||||
com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO resp =
|
||||
sourceProjectService.fetchByVersionId(null);
|
||||
assertFalse(resp.getFound());
|
||||
verify(gameSourceProjectMapper, never()).selectByVersionId(any());
|
||||
}
|
||||
|
||||
// ============================== 私有构造 ==============================
|
||||
|
||||
private static SourceProjectLandReqDTO landReq() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user