zizi 96081a574c feat(aigc): W-G1 组B 落库组——9d trace 账本 + D11 就绪评分 + D9 反同质化(全非阻断)
把 worker 已产出、当前在边界被丢弃的富生成轨迹(9d)接过来落库,补"放了能管/追溯/不雷同"
三件 v0 能力,全部 best-effort 非阻断(口号"放了能管,非放了能拦")。三件全关=现行字节零变。

契约先行:
- contracts/api-schemas/aigc.yaml DifyCallbackReqVO 加 trace(object,nullable) additive 字段;
  顺带补 engineBundle 漂移对齐(Java 已加 yaml 漏)——守契约先行收口。

9d trace 账本(公共底座):
- Flyway V16.0.0:game_aigc_task 加 trace_json(JSON NULL)+readiness_score(SMALLINT NULL),
  additive 可空 AFTER failure_reason(接 V15 组A level,勿撞号);AigcTaskDO 加两映射字段。
- worker service.py:_extract_trace 从 run_studio result 抽 9d 子集(必填子集7项+可选段,camelCase),
  build_callback_payload 加 result 参可携 trace,handle_job 把 result 提到函数作用域传入(成败路都抽)。
- 后端 DifyCallbackTxService.persistTraceQuietly:succeeded 步骤⑦后 + failed markTaskFailed 后
  best-effort 落 trace_json;全程 try-catch 吞异常,绝不回滚三表写链/绝不阻断 PUBLISHED。

D11 就绪评分:
- 新建 ReadinessScorer:读 trace 算 0-100(playability 0.50/firstPlay 0.25/stability 0.15/efficiency 0.10,
  权重占位待校准;缺字段取中性 0.5,纯函数无副作用);算分独立 try-catch,失败仅 readiness 降级 NULL、
  不拖累 trace 落库(§7)。AigcTaskRespVO 加 readinessScore(BeanUtils 平铺自动映射)→ /task/page 零改透出。

D9 反同质化:
- 新建 worker agent_loop/dedup.py:移植 agent-loop-v1 run_batch.py:96 _norm_text(归一≈复制)
  + ledger.py:50 mint_design_id 思路(compute_sig 精确撞重键);重接 _dedup_gate 逻辑——解绑实例状态
  改 FS jsonl 登记表、engine 路取字段(title=brief 派生/theme=generic 占位)、落库改塞 trace.similarity。
- studio.py result 注入 similarity(AIGC_DEDUP_ENABLED 开关门控);撞重只 log.warn+dupHit=true,
  照常回调入 feed(v0 只观测避免误杀,九门已是硬地板)。

回滚:三件各一开关(aigc.trace.enabled 默认 false/D11 随 trace/AIGC_DEDUP_ENABLED),全关=逐字一致;
列 additive 可空,真回滚写 V17 drop(不改 V16,遵 V2.0.0 头注铁律)。不触公平性铁律(只读 gatespec)。

验证(mini-desktop /root/build-lane-a 隔离目录):
- Java:aigc-server 全模块 clean test 111/111 通过(0 fail/0 err/4 skip,BUILD SUCCESS);
  含新 DifyCallbackTxTraceTest 5/5(trace 落库/失败不阻断/D11 算分 pass-vs-fail/算分失败不阻断/RespVO 可见)
  + 既有 DifyCallbackServiceImplTest 13/13 零回归(坐实 traceEnabled=false 现行行为字节一致)。
- worker:wg1_groupb_test.py 17/17(9d trace 必填子集完整率 5/5=100% + D9 撞重/归一/不撞/坏行容错);
  真 venv 验 dedup/service/studio import 链健康 + result=None additive 兼容。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:04:02 +00:00

145 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""wg1_groupb_test.py —— W-G1 组B worker 侧自动化用例9d trace 抽取完整率 + D9 反同质化)。
【为什么 __main__ 可跑而非 pytest对齐 m3_player_test.py 范式】worker venv 在 mini-desktop本测试不依赖
agentscope/openaiservice.py 顶部会拉 openai6c6g 无),故用 AST 隔离加载 service._extract_trace
(函数体零外部依赖,纯 dict 操作dedup.py 只用 stdlib——本测试在 6c6g 裸 python3 即可跑。
【覆盖执行版 §8 worker 侧 4 条】
① 9d trace 完整率:抽现成 run_studio 样本 → 必填子集七项完整率 100%(按必填子集口径)。
② D9 完全相同 config 撞重:同 config×2 → 第二次命中 → dupHit=true仍照常不阻断由 studio 侧保证)。
③ D9 归一撞重title 空白差异 → norm_text 归一后相等 → 撞重判定一致。
④ D9 不同 config 不撞:异 config → sig 不同 → dupHit=false。
运行python3 wg1/gen-worker/wg1_groupb_test.py退出码 0=全过,非 0=有失败)。
"""
import ast
import glob
import json
import os
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent # wg1/gen-worker
RESULTS = ROOT / "results"
# 必填子集(执行版 §5.3run_studio 当前路径无条件产出 = 完整率分母camelCase 抽取后键名)
REQUIRED_SUBSET = ["pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"]
_passed = 0
_failed = 0
def _check(name, cond, detail=""):
"""轻量断言:记录通过/失败并打印(不中断,跑完全部用例再汇总退出码)。"""
global _passed, _failed
if cond:
_passed += 1
print(f" [PASS] {name} {detail}")
else:
_failed += 1
print(f" [FAIL] {name} {detail}")
def _load_extract_trace():
"""AST 隔离加载 service._extract_trace绕开 service.py 顶部 openai/agentscope import"""
src = (ROOT / "worker" / "service.py").read_text(encoding="utf-8")
tree = ast.parse(src)
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == "_extract_trace":
ns = {}
exec(ast.get_source_segment(src, node), ns) # 函数体零外部依赖
return ns["_extract_trace"]
raise RuntimeError("service._extract_trace 未找到")
def test_trace_completeness(extract):
"""① 9d trace 完整率:抽现成 run_studio 样本 → 必填子集七项 100% 非空(执行版 §8"""
print("\n=== 用例①9d trace 抽取完整率(必填子集口径)===")
# run_studio 产物形态样本 = *.stage*.jsonresult 字面量恒含必填子集;裸 *.json 是旧 run.py 形态,路径未定不纳入)
samples = sorted(glob.glob(str(RESULTS / "*.stage*.json")))
_check("run_studio 形态样本存在", len(samples) >= 1, f"样本数={len(samples)}")
ok = 0
for f in samples:
d = json.load(open(f, encoding="utf-8"))
t = extract(d)
miss = [k for k in REQUIRED_SUBSET if t.get(k) is None]
if not (isinstance(t.get("attempts"), list) and len(t["attempts"]) >= 1):
miss.append("attempts(empty)")
# 每条 attempt 须带 guards 逐门verdict.guards 真身)
a0 = t["attempts"][0] if t.get("attempts") else {}
if not isinstance(a0.get("guards"), dict):
miss.append("attempts[0].guards")
if not miss:
ok += 1
else:
print(f" {os.path.basename(f)} 缺={miss}")
rate = (100 * ok // len(samples)) if samples else 0
_check("必填子集完整率 100%", samples and ok == len(samples), f"{ok}/{len(samples)} = {rate}%")
# camelCase 化抽样校验snake→camelwall_s→wallS / game_id→gameId / stage_fail→stageFail
if samples:
t = extract(json.load(open(samples[0], encoding="utf-8")))
_check("camelCase 化wallS/gameId", "wallS" in t and "gameId" in t)
_check("attempts.guards 逐门保留",
isinstance(t["attempts"][0].get("guards"), dict))
# 空/None 兜底
_check("空 result 返回 None", extract(None) is None and extract({}) is None)
def test_dedup():
"""②③④ D9 反同质化:完全相同撞重 / 归一撞重 / 不同不撞(执行版 §8"""
print("\n=== 用例②③④D9 反同质化告警 ===")
sys.path.insert(0, str(ROOT / "worker"))
from agent_loop import dedup # 只用 stdlib无 agentscope
# 隔离登记表(不污染真 results/_dedup_registry.jsonl
tmp = tempfile.mkdtemp()
dedup.DEDUP_REGISTRY = Path(tmp) / "_dedup_registry.jsonl"
# 用例④(先建基线):不同 config 不撞 + sig 不同
s1 = dedup.check_similarity("躲避陨石的游戏", "generic", "trace-1", "stage1")
s2 = dedup.check_similarity("消除方块的游戏", "generic", "trace-2", "stage1")
_check("D9 首款不撞", s1["dupHit"] is False, f"dupHit={s1['dupHit']}")
_check("D9 不同 config 不撞", s2["dupHit"] is False)
_check("D9 不同 config sig 不同", s1["sig"] != s2["sig"])
# 用例②:完全相同 config 撞重 → dupHit=true + dupWith 指向首款
s3 = dedup.check_similarity("躲避陨石的游戏", "generic", "trace-3", "stage1")
_check("D9 完全相同 config 撞重", s3["dupHit"] is True, f"dupWith={s3['dupWith']}")
_check("D9 dupWith 指向首款 trace-1", s3["dupWith"] == "trace-1")
# 用例③归一撞重title 尾空白差异norm_text 归一后相等)
s4 = dedup.check_similarity("躲避陨石的游戏 ", "generic", "trace-4", "stage1")
_check("D9 归一撞重(空白差异判定一致)", s4["dupHit"] is True)
_check("D9 norm_text 去空白", dedup.norm_text("点点乐 ") == dedup.norm_text("点点乐"))
_check("D9 norm_text 去英文标点+casefold",
dedup.norm_text("Hello, World!") == dedup.norm_text("helloworld"))
# 自身重投不撞自己(同 traceId 排除)
s5 = dedup.check_similarity("消除方块的游戏", "generic", "trace-2", "stage1")
_check("D9 同 traceId 重投不撞自己", s5["dupHit"] is False)
# 登记表坏行容错(降级不抛 → dupHit=False/None不阻断
dedup.DEDUP_REGISTRY.write_text("not-json\n{bad\n", encoding="utf-8")
s6 = dedup.check_similarity("某游戏", "generic", "trace-9", "stage1")
_check("D9 登记表坏行容错(降级不阻断)", s6["dupHit"] in (False, None), f"dupHit={s6['dupHit']}")
# compute_sig 可复现 + 64 位 hex
_check("compute_sig 可复现", dedup.compute_sig("a", "b", "c") == dedup.compute_sig("a", "b", "c"))
_check("compute_sig 64 位 hex", len(dedup.compute_sig("a", "b", "c")) == 64)
def main():
print("W-G1 组B worker 侧自动化用例9d trace 抽取 + D9 反同质化)")
extract = _load_extract_trace()
test_trace_completeness(extract)
test_dedup()
print(f"\n=== 汇总:通过 {_passed} / 失败 {_failed} ===")
sys.exit(0 if _failed == 0 else 1)
if __name__ == "__main__":
main()