AGC 壳把资源 kind 留痕接到 app_log! 并把各解析边界统一为严格口径

- main.rs:新增 register_non_canonical_asset_kind_reporter(),把 shared-contracts 的原始串与上下文落到 app_log!,按 (原始串, 上下文) 去重;main() 启动时注册。
- commands.rs:imported_platform_asset_kind 改为直接 parse_with_context,不再先经别名表收口;用例改为断言 UI / ui / mystery / game-art / ui-prototype 等非 canonical 值一律落 unknown。
- assets.rs:画板/上传等用例的 kind 断言改用 GameCreationAppAssetKind::ALL 穷举口径。
- project/manifest.rs:读 manifest 的留痕去重键由「只有原始 kind」改成「原始 kind + 资产身份」,避免第二个写旧值的资产被静默吞掉。
- project/manifest.rs:from_str_lossy 调用点改名 from_str_or_unknown。
- agent/generation/canvas_generation.rs:画板 assetKind 的兜底分支改为 parse_with_context,非 canonical 值留痕收口为 unknown。
- project/version_resource_replacement.rs:subtypeEqual 改为 canonical kind 直接比较,去掉中间归一。
This commit is contained in:
2026-09-17 15:44:02 +08:00
parent 6782d0ea23
commit b56566f40e
6 changed files with 86 additions and 49 deletions
@@ -490,7 +490,10 @@ pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreation
"art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
"publication-material" => GameCreationAppAssetKind::PublicationMaterial,
"game-background" | "scene" => GameCreationAppAssetKind::Scene,
_ => GameCreationAppAssetKind::Unknown,
// 画板/平台词汇表(`AGENT_RUNTIME_CANVAS_ASSET_KINDS`)到 manifest kind 的**唯一**
// 映射点:认不出的原值收口成 `Unknown` 并留痕(回调接到壳层 `app_log!`),
// manifest 侧不再有第二张别名表。
_ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_kind"),
}
}
@@ -2235,8 +2235,7 @@ mod tests {
for layer_type in ["序列", "音频", "角色", "场景", "UI", "图层", "其他"] {
let kind = infer_canvas_export_asset_kind(&layer(layer_type), "layer.png");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind.as_str()),
GameCreationAppAssetKind::ALL.contains(&kind),
"非 canonical kind: {kind}layer_type={layer_type}"
);
for legacy in ["ui", "animation", "asset"] {
@@ -2276,12 +2275,7 @@ mod tests {
assert_ne!(kind, "uploaded", "{file_name} 写回了来源词");
assert_ne!(kind, "ui", "{file_name} 写回了非 canonical 的 `ui`");
assert!(
shared_contracts::game_creation_app::GAME_CREATION_APP_CANONICAL_ASSET_KINDS
.contains(&kind.as_str())
|| matches!(
kind,
GameCreationAppAssetKind::Font | GameCreationAppAssetKind::Unknown
),
GameCreationAppAssetKind::ALL.contains(&kind),
"非 canonical kind: {kind}{file_name}"
);
}
@@ -3245,8 +3245,11 @@ mod agent_asset_import_tests {
);
}
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**
/// 不出才退回中性 `image`(→ 「待归类」),**永远不许回退成常量 `ui`**。
/// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**只接受 canonical 值**
/// 不出的原值收口成 `unknown`(→ 「待归类」)并留痕原始串,**永远不许回退成常量 `ui`**。
///
/// kind 没有别名表:平台侧写 `"UI"` / `"ui"` / `"mystery"` 时判据是"这不是合法 kind"
/// 而不是"帮它归一成 `ui-design`";缺失/空白才退回中性的 `image`。
///
/// 变异验证:把 `imported_platform_asset_kind` 改成忽略入参、返回 `"ui"` 的实现,
/// 本用例必须变红(`character` / `scene` / 空值 / 未知值四组断言都会失败)。
@@ -3266,20 +3269,31 @@ mod agent_asset_import_tests {
imported_platform_asset_kind(Some("icon-spec")),
Kind::IconSpec
);
// 平台确实说它是 UI 设计稿时,才允许落 `ui-design`(→ 「UI 交互」)
// 大写 `UI` 与旧值 `ui` 都要归一到同一口径,但**不允许由我们替它默认**。
assert_eq!(imported_platform_asset_kind(Some("UI")), Kind::UiDesign);
assert_eq!(imported_platform_asset_kind(Some("ui")), Kind::UiDesign);
// 缺失 / 空白 / 未知值一律落 canonical `image` 兜底(→ 「待归类」)。
// 平台确实说它是 UI 设计稿时`ui-design`)才落 `UiDesign`(→ 「UI 交互」)
assert_eq!(
imported_platform_asset_kind(Some("ui-design")),
Kind::UiDesign
);
// 缺失 / 空白退回中性的 `image`(→ 「待归类」)。
assert_eq!(imported_platform_asset_kind(None), Kind::Image);
assert_eq!(imported_platform_asset_kind(Some(" ")), Kind::Image);
assert_eq!(imported_platform_asset_kind(Some("mystery")), Kind::Image);
// `kind` 是外部输入,原型链键必须走 `image` 兜底,不能取到原型上的值
assert_eq!(imported_platform_asset_kind(Some("__proto__")), Kind::Image);
assert_eq!(
imported_platform_asset_kind(Some("constructor")),
Kind::Image
);
// 非 canonical 原值(含大小写变体、旧别名与原型链键)一律收口成 `unknown`:
// 判据落在「这不是合法 kind」,原值只出现在留痕日志里
for raw in [
"UI",
"ui",
"mystery",
"game-art",
"ui-prototype",
"__proto__",
"constructor",
] {
assert_eq!(
imported_platform_asset_kind(Some(raw)),
Kind::Unknown,
"{raw} 不是 canonical kind,不得被归一"
);
}
}
/// 反查门禁:两处平台素材导入必须继续用 `imported_platform_asset_kind` 解析 `kind`
@@ -4164,30 +4178,22 @@ pub(crate) fn import_local_project_assets_for_agent(
}
/// 平台素材导入落盘的 manifest `kind`:**有真实类型就用真实类型**,判不出才退回中性的
/// `image`(派生 `unclassified` → 「待归类」)
/// `image`(派生 `unclassified` → 「待归类」),认不出的原值落 `unknown`(同样「待归类」)
/// 并在日志里留下原始串。
///
/// 这里绝不允许回退成 `ui`:账户素材库记录与网页项目画布响应本来就带 `assetKind`
/// `AgentEditorAssetRecord::asset_kind` / 响应字段 `assetKind`),把它换成常量等于丢掉
/// 唯一一条"这东西是什么"的事实,并把角色立绘、怪物、道具、场景图一并钉进「UI 交互」栏
/// `ui → ui-design → ui-interaction`)。落盘 `category` 一旦是非 `unclassified` 值,
/// 读时自愈永远救不回来,所以宁可写 `image`(待归类)也不能写假 `ui`。
///
/// kind 只有一条口径:严格解析,不做别名归一、不做迁移(`Kind.ALL` 是唯一词汇表)。
fn imported_platform_asset_kind(platform_kind: Option<&str>) -> GameCreationAppAssetKind {
match platform_kind.map(str::trim).filter(|kind| !kind.is_empty()) {
// 平台是外部系统,它的 `assetKind` 词汇(含大写 `UI` 这类变体)由共享契约统一收口到
// canonical 值;这里只把收口结果转成正式枚举,写入侧不再产出裸字符串。
Some(kind) => {
let canonical =
shared_contracts::game_creation_app::canonical_game_creation_app_asset_kind(kind);
if canonical != kind {
// 收口会抹掉原值,而 kind 本身是严格解析(不 fallback、不迁移),
// 所以非 canonical 的原值必须在这里留痕:这是后续定位"平台侧谁还在写
// legacy kind"的唯一线索。
app_log!(
"平台 assetKind 不是 canonical 值,已按共享契约收口:rawKind={kind} canonicalKind={canonical}"
);
}
GameCreationAppAssetKind::parse_with_context(canonical, "platform.asset_kind")
}
// 平台是外部系统,但 kind 口径**只有一条**:严格解析 + 非 canonical 原值留痕
// (回调已由壳层注册成 `app_log!`,见 `main.rs`)。这里不再做别名收口,
// 认不出的值就是 `Unknown` → 「待归类」,原值只出现在日志里。
Some(kind) => GameCreationAppAssetKind::parse_with_context(kind, "platform.asset_kind"),
None => GameCreationAppAssetKind::Image,
}
}
@@ -240,6 +240,36 @@ macro_rules! app_log {
}};
}
/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。
///
/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时,
/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里,
/// 以便回查"还有谁在写 legacy kind"。kind 本身只接受严格值,不做别名、不做迁移。
///
/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉;
/// 去重后"有哪些 legacy 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看
/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。
fn register_non_canonical_asset_kind_reporter() {
static REPORTED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter(
|raw_kind: &str, context: &str| {
let key = format!("{context}\u{1}{raw_kind}");
let first_seen = REPORTED
.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
.lock()
.map(|mut reported| reported.insert(key))
.unwrap_or(true);
if !first_seen {
return;
}
app_log!(
"GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknownrawKind={raw_kind} context={context}"
);
},
);
}
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
mod agent;
@@ -2428,6 +2458,7 @@ mod async_runtime_stack_tests {
#[cfg(not(test))]
fn main() {
install_agent_runtime_async_runtime_with_deep_stack();
register_non_canonical_asset_kind_reporter();
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) {
std::process::exit(exit_code);
@@ -1951,14 +1951,18 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
/// 就丢了,所以这里在读到的原始 JSON 里回捞一遍,把原值连同资产 ID、路径写进应用日志
/// `diagnostics/application.log`),供后续定位"谁还在写 legacy kind"。
///
/// 同一个原值每次进程只记一次:manifest 读得很频繁,不去重会把其它诊断刷掉。
/// 同一份 manifest 里的同一个资产每次进程只记一次:manifest 读得很频繁,不去重会把其它诊断
/// 刷掉。去重键**含资产身份**而不是只有原值——只按原值去重会让"第二个还在写 legacy kind 的
/// 资产"静默消失(包括另一份项目文件里的资产),那就丢了这条日志唯一的增量信息。
fn log_non_canonical_manifest_asset_kinds(payload: &str, source_path: &Path, label: &str) {
static LOGGED_RAW_KINDS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
static LOGGED_OFFENDERS: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
for (raw_kind, asset_id, local_path) in non_canonical_manifest_asset_kinds(payload) {
let logged = LOGGED_RAW_KINDS.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
let logged = LOGGED_OFFENDERS.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
let first_seen = logged
.lock()
.map(|mut logged| logged.insert(raw_kind.clone()))
.map(|mut logged| {
logged.insert(format!("{}\u{1}{asset_id}\u{1}{local_path}", raw_kind))
})
.unwrap_or(true);
if !first_seen {
continue;
@@ -1985,7 +1989,7 @@ fn non_canonical_manifest_asset_kinds(payload: &str) -> Vec<(String, String, Str
continue;
};
if raw_kind.trim() == GameCreationAppAssetKind::Unknown.as_str()
|| GameCreationAppAssetKind::from_str_lossy(raw_kind)
|| GameCreationAppAssetKind::from_str_or_unknown(raw_kind)
!= GameCreationAppAssetKind::Unknown
{
continue;
@@ -1,8 +1,7 @@
use super::*;
use shared_contracts::game_creation_app::{
canonical_game_creation_app_asset_kind, game_creation_app_asset_effective_category,
GameCreationAppAssetManifestEntry,
game_creation_app_asset_effective_category, GameCreationAppAssetManifestEntry,
};
/// 版本级资源替换(**直接替换**口径)。
@@ -208,7 +207,8 @@ fn version_resource_size_spec_equal(
/// 三项兼容性判据(后端是权威判据,前端只做呈现)。
///
/// - `categoryEqual`:功能分类相等,用**读时自愈**口径(PRD §5.3「分类取值优先级」收口);
/// - `subtypeEqual`canonical `kind` 相等(别名表在 `shared-contracts`)——以上两项是硬门禁;
/// - `subtypeEqual``kind` 严格相等(kind 没有别名表,非 canonical 原值一律收口成
/// `unknown`)——以上两项是硬门禁;
/// - `sizeSpecEqual`:见 [`version_resource_size_spec_equal`],只作提示。
fn version_resource_compatibility(
source: &GameCreationAppAssetManifestEntry,
@@ -217,8 +217,7 @@ fn version_resource_compatibility(
ProjectVersionResourceCompatibility {
category_equal: game_creation_app_asset_effective_category(&source.kind, source.category)
== game_creation_app_asset_effective_category(&replacement.kind, replacement.category),
subtype_equal: canonical_game_creation_app_asset_kind(&source.kind)
== canonical_game_creation_app_asset_kind(&replacement.kind),
subtype_equal: source.kind == replacement.kind,
size_spec_equal: version_resource_size_spec_equal(source, replacement),
}
}