资源分类读显示口径下沉到 Rust:Agent 投影与 UI 同构
- 新增 `game_creation_app_asset_effective_category`:读显示口径的唯一实现——落盘 `category` 权威,仅在落盘值为 `unclassified` 且该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,其余信任落盘值 - `GameCreationAppAssetManifestEntry` 的反序列化刻意不做自愈:反序列化结果就是落盘原值,「编辑标签」面板要靠它原样回写;自愈只属于读显示口径 - Agent 资源投影 `bridge_registered_resource` 不再直接透传 `asset.category`,改走有效分类,修掉真机 122 条资产里 55 条「UI 显示 UI 交互、Agent 读到待归类」的跨端口径分歧 - 新增 Rust 决策矩阵 `EFFECTIVE_CATEGORY_CONTRACT` 与用例 `asset_effective_category_follows_the_shared_contract_matrix`,矩阵是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源 - 新增跨语言契约:`assetKindCanonicalMapping.test.ts` 解析 Rust 源码里的决策矩阵与 canonical kind→栏目表,逐条喂给 TS `gameCreationAppAssetCategory` 对照,任一侧改口径都会红 - `direct_tool_bridge` 新增 Agent 侧回归用例:`kind:"ui" + category:"unclassified"` 必须投影成 `ui-interaction` - 既有用例 `..._projects_manifest_classification_verbatim` 更名为 `..._keeps_explicit_manifest_classification`:逐字透传只在落盘值不是 `unclassified` 时成立,旧名字会与新的有效分类语义矛盾
This commit is contained in:
@@ -1161,7 +1161,11 @@ fn bridge_registered_resource(
|
||||
"localPath": asset.local_path,
|
||||
"kind": asset.kind,
|
||||
"mediaType": asset.media_type,
|
||||
"category": asset.category,
|
||||
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
|
||||
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
@@ -3087,8 +3091,12 @@ mod tests {
|
||||
assert!(!serialized.contains("private.invalid"));
|
||||
}
|
||||
|
||||
/// 落盘分类是明确值时,Agent 侧原样透传。
|
||||
///
|
||||
/// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是
|
||||
/// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。)
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_manifest_classification_verbatim() {
|
||||
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: "spec".to_string(),
|
||||
@@ -3123,6 +3131,43 @@ mod tests {
|
||||
assert!(!serialized.contains("private model"));
|
||||
}
|
||||
|
||||
/// Agent 侧必须与 UI 看到同一个分类口径。
|
||||
///
|
||||
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
|
||||
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
|
||||
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "ui-1".to_string(),
|
||||
kind: "ui".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/UI 设计 1.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category:
|
||||
shared_contracts::game_creation_app::GameCreationAppAssetCategory::Unclassified,
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "ui");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_success_warnings_are_redacted_before_serialization() {
|
||||
let root = tempfile::tempdir().expect("warning redaction root");
|
||||
|
||||
@@ -27,9 +27,10 @@ use sha2::Digest;
|
||||
use shared_contracts::error_reports::ErrorReportLogInput;
|
||||
use shared_contracts::game_creation_app::{
|
||||
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
|
||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
||||
game_creation_app_asset_effective_category, new_game_creation_app_manifest,
|
||||
new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
GameCreationAgentCapabilityDescriptor,
|
||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
canonicalGameCreationAppAssetKind,
|
||||
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
|
||||
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
|
||||
type GameCreationAppAssetCategory,
|
||||
gameCreationAppAssetCategory,
|
||||
gameCreationAppAssetCategoryForKind,
|
||||
type GameCreationAppCanonicalAssetKind,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -50,6 +52,54 @@ function rustCanonicalKinds(): string[] {
|
||||
return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!);
|
||||
}
|
||||
|
||||
/** Rust 的 `GameCreationAppAssetCategory` 变体名 → kebab-case 线上值。 */
|
||||
function rustCategorySocketName(variant: string) {
|
||||
return variant.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
/** 从 Rust 侧 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 里解析出 kind → 栏目表。 */
|
||||
function rustAssetCategoryByKind(): Map<string, string> {
|
||||
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
|
||||
const start = source.indexOf(
|
||||
'pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND',
|
||||
);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
// 该表有多种排版(单行、以及 kind 与变体各占一行的多行条目),先去空白再逐条取。
|
||||
const body = source
|
||||
.slice(start, source.indexOf('];', start))
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
const table = new Map<string, string>();
|
||||
for (const entry of body.matchAll(
|
||||
/\("([^"]+)",GameCreationAppAssetCategory::(\w+),?\)/g,
|
||||
)) {
|
||||
table.set(entry[1]!, rustCategorySocketName(entry[2]!));
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Rust 侧解析「有效分类」的决策矩阵。
|
||||
*
|
||||
* 读时自愈(落盘 `unclassified` 而 kind 能派生出明确分类时采用派生值)在两侧必须只有
|
||||
* 一个口径:TS 侧 `gameCreationAppAssetCategory`、Rust 侧
|
||||
* `game_creation_app_asset_effective_category`(Agent 投影走这条)。矩阵是唯一真源,
|
||||
* 写在 Rust 源码里、由 Rust 单测逐条断言,本文件解析同一份矩阵再喂给 TS 实现对照,
|
||||
* 两侧各写一份的话会重新分叉。
|
||||
*/
|
||||
function rustEffectiveCategoryContract(): Array<[string, string, string]> {
|
||||
const source = readFileSync(RUST_CONTRACT_PATH, 'utf8');
|
||||
const start = source.indexOf('const EFFECTIVE_CATEGORY_CONTRACT');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = source.slice(start, source.indexOf('];', start));
|
||||
|
||||
const rows: Array<[string, string, string]> = [];
|
||||
for (const row of body.matchAll(/\("([^"]*)",\s*"([^"]*)",\s*"([^"]*)"\)/g)) {
|
||||
rows.push([row[1]!, row[2]!, row[3]!]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */
|
||||
const OBSERVED_ALIAS_INPUTS = [
|
||||
'game-background',
|
||||
@@ -100,6 +150,49 @@ describe('资源 kind 别名表跨语言一致性', () => {
|
||||
expect(canonical).toContain(canonicalGameCreationAppAssetKind(kind));
|
||||
}
|
||||
});
|
||||
|
||||
test('两侧 canonical kind → 栏目表逐条一致', () => {
|
||||
const rustTable = rustAssetCategoryByKind();
|
||||
expect([...rustTable.keys()].sort()).toEqual(
|
||||
[...GAME_CREATION_APP_CANONICAL_ASSET_KINDS].sort(),
|
||||
);
|
||||
|
||||
const mismatches: Array<{ kind: string; ts: string; rust: string }> = [];
|
||||
for (const [kind, rustCategory] of rustTable) {
|
||||
const tsCategory =
|
||||
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND[
|
||||
kind as GameCreationAppCanonicalAssetKind
|
||||
];
|
||||
if (tsCategory !== rustCategory) {
|
||||
mismatches.push({ kind, ts: tsCategory, rust: rustCategory });
|
||||
}
|
||||
}
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
|
||||
test('读时自愈决策矩阵两侧一致(单一真源在 Rust 侧)', () => {
|
||||
const rows = rustEffectiveCategoryContract();
|
||||
// 矩阵必须覆盖:落盘明确值、落盘 unclassified + kind 可明确分类、
|
||||
// 落盘 unclassified + 派生结果本来就是 unclassified、大写写侧字面量。
|
||||
expect(rows.length).toBeGreaterThanOrEqual(8);
|
||||
|
||||
const mismatches: Array<{
|
||||
kind: string;
|
||||
persisted: string;
|
||||
expected: string;
|
||||
ts: string;
|
||||
}> = [];
|
||||
for (const [kind, persisted, expected] of rows) {
|
||||
const ts = gameCreationAppAssetCategory({
|
||||
kind,
|
||||
category: persisted as GameCreationAppAssetCategory,
|
||||
});
|
||||
if (ts !== expected) {
|
||||
mismatches.push({ kind, persisted, expected, ts });
|
||||
}
|
||||
}
|
||||
expect(mismatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('真机出现的 kind 归类口径', () => {
|
||||
|
||||
@@ -503,6 +503,10 @@ pub struct GameCreationAppAssetManifestEntry {
|
||||
|
||||
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
|
||||
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。
|
||||
///
|
||||
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
|
||||
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
|
||||
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreationAppAssetManifestEntryWire {
|
||||
@@ -661,6 +665,34 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA
|
||||
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
|
||||
}
|
||||
|
||||
/// manifest 资产的**有效分类**(读显示口径)。
|
||||
///
|
||||
/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,由
|
||||
/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析
|
||||
/// `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住,两侧不许各写一份。
|
||||
///
|
||||
/// 规则:落盘 `category` 是权威值;**唯一例外**是落盘值 `unclassified` 而该资产 `kind`
|
||||
/// 能派生出明确的非 `unclassified` 分类时采用派生值。这条例外用于自愈历史上被系统误写成
|
||||
/// `unclassified` 的存量(典型是 `kind:"ui"` / `kind:"UI"` 因 kind 不在 canonical 目录而
|
||||
/// 落到 `image -> unclassified`),不写迁移脚本且永久生效;`kind` 派生结果本身就是
|
||||
/// `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。
|
||||
///
|
||||
/// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值
|
||||
/// (`GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」
|
||||
/// 面板会把自愈值写回去,把「只改标签」变成静默改分类。
|
||||
pub fn game_creation_app_asset_effective_category(
|
||||
kind: &str,
|
||||
persisted: GameCreationAppAssetCategory,
|
||||
) -> GameCreationAppAssetCategory {
|
||||
if persisted == GameCreationAppAssetCategory::Unclassified {
|
||||
let derived_from_kind = game_creation_app_asset_category_for_kind(kind);
|
||||
if derived_from_kind != GameCreationAppAssetCategory::Unclassified {
|
||||
return derived_from_kind;
|
||||
}
|
||||
}
|
||||
persisted
|
||||
}
|
||||
|
||||
pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec<String> {
|
||||
let mut normalized = Vec::new();
|
||||
for tag in tags {
|
||||
@@ -2128,6 +2160,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 有效分类(读显示)决策矩阵:`(kind, 落盘 category, 有效 category)`。
|
||||
///
|
||||
/// **这是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源**:本表由下面的
|
||||
/// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言,
|
||||
/// 同时被 TS 侧
|
||||
/// `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析出来喂给
|
||||
/// `gameCreationAppAssetCategory` 对照。两侧各写一份矩阵就会重新分叉成
|
||||
/// 「UI 显示 ui-interaction、Agent 读 unclassified」的两套口径。
|
||||
const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [
|
||||
// 落盘值合法且明确 → 落盘值为准。
|
||||
("character", "character", "character"),
|
||||
("ui", "scene", "scene"),
|
||||
("image", "audio", "audio"),
|
||||
// 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。
|
||||
("ui", "unclassified", "ui-interaction"),
|
||||
("character", "unclassified", "character"),
|
||||
// kind 派生结果本身就是 unclassified → 信任落盘值。
|
||||
("image", "unclassified", "unclassified"),
|
||||
("unknown-kind", "unclassified", "unclassified"),
|
||||
("video", "unclassified", "unclassified"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn asset_effective_category_follows_the_shared_contract_matrix() {
|
||||
for (kind, persisted, expected) in EFFECTIVE_CATEGORY_CONTRACT {
|
||||
let persisted = game_creation_app_asset_category_from_str(persisted)
|
||||
.unwrap_or_else(|| panic!("矩阵里的落盘分类非法:{persisted}"));
|
||||
let expected = game_creation_app_asset_category_from_str(expected)
|
||||
.unwrap_or_else(|| panic!("矩阵里的期望分类非法:{expected}"));
|
||||
assert_eq!(
|
||||
game_creation_app_asset_effective_category(kind, persisted),
|
||||
expected,
|
||||
"有效分类矩阵不一致:kind={kind} persisted={persisted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() {
|
||||
let character = asset_entry_from_json(asset_entry_json("character"));
|
||||
|
||||
Reference in New Issue
Block a user