资源分类读显示口径下沉到 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,
|
"localPath": asset.local_path,
|
||||||
"kind": asset.kind,
|
"kind": asset.kind,
|
||||||
"mediaType": asset.media_type,
|
"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,
|
"tags": asset.tags,
|
||||||
"canvasProjectId": asset.source.canvas_project_id,
|
"canvasProjectId": asset.source.canvas_project_id,
|
||||||
"resourceId": asset.source.resource_id,
|
"resourceId": asset.source.resource_id,
|
||||||
@@ -3087,8 +3091,12 @@ mod tests {
|
|||||||
assert!(!serialized.contains("private.invalid"));
|
assert!(!serialized.contains("private.invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 落盘分类是明确值时,Agent 侧原样透传。
|
||||||
|
///
|
||||||
|
/// (用例名从 `..._verbatim` 改成 `..._keeps_explicit...`:透传只在落盘值不是
|
||||||
|
/// `unclassified` 时成立,落盘 `unclassified` 要按读显示口径自愈,见下一个用例。)
|
||||||
#[test]
|
#[test]
|
||||||
fn bridge_registered_resource_projects_manifest_classification_verbatim() {
|
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||||
let asset = GameCreationAppAssetManifestEntry {
|
let asset = GameCreationAppAssetManifestEntry {
|
||||||
id: "spec-1".to_string(),
|
id: "spec-1".to_string(),
|
||||||
kind: "spec".to_string(),
|
kind: "spec".to_string(),
|
||||||
@@ -3123,6 +3131,43 @@ mod tests {
|
|||||||
assert!(!serialized.contains("private model"));
|
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]
|
#[test]
|
||||||
fn bridge_success_warnings_are_redacted_before_serialization() {
|
fn bridge_success_warnings_are_redacted_before_serialization() {
|
||||||
let root = tempfile::tempdir().expect("warning redaction root");
|
let root = tempfile::tempdir().expect("warning redaction root");
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ use sha2::Digest;
|
|||||||
use shared_contracts::error_reports::ErrorReportLogInput;
|
use shared_contracts::error_reports::ErrorReportLogInput;
|
||||||
use shared_contracts::game_creation_app::{
|
use shared_contracts::game_creation_app::{
|
||||||
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
|
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,
|
game_creation_app_asset_effective_category, new_game_creation_app_manifest,
|
||||||
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
|
new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
|
||||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||||
|
GameCreationAgentCapabilityDescriptor,
|
||||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
canonicalGameCreationAppAssetKind,
|
canonicalGameCreationAppAssetKind,
|
||||||
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
|
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND,
|
||||||
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
|
GAME_CREATION_APP_CANONICAL_ASSET_KINDS,
|
||||||
|
type GameCreationAppAssetCategory,
|
||||||
|
gameCreationAppAssetCategory,
|
||||||
gameCreationAppAssetCategoryForKind,
|
gameCreationAppAssetCategoryForKind,
|
||||||
type GameCreationAppCanonicalAssetKind,
|
type GameCreationAppCanonicalAssetKind,
|
||||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||||
@@ -50,6 +52,54 @@ function rustCanonicalKinds(): string[] {
|
|||||||
return Array.from(body.matchAll(/"([^"]+)"/g)).map((match) => match[1]!);
|
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 输入被改写成什么」)。 */
|
/** TS 侧的别名表(未导出,用 canonical 目录探测「非 canonical 输入被改写成什么」)。 */
|
||||||
const OBSERVED_ALIAS_INPUTS = [
|
const OBSERVED_ALIAS_INPUTS = [
|
||||||
'game-background',
|
'game-background',
|
||||||
@@ -100,6 +150,49 @@ describe('资源 kind 别名表跨语言一致性', () => {
|
|||||||
expect(canonical).toContain(canonicalGameCreationAppAssetKind(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 归类口径', () => {
|
describe('真机出现的 kind 归类口径', () => {
|
||||||
|
|||||||
@@ -503,6 +503,10 @@ pub struct GameCreationAppAssetManifestEntry {
|
|||||||
|
|
||||||
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
|
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
|
||||||
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。
|
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。
|
||||||
|
///
|
||||||
|
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
|
||||||
|
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
|
||||||
|
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct GameCreationAppAssetManifestEntryWire {
|
struct GameCreationAppAssetManifestEntryWire {
|
||||||
@@ -661,6 +665,34 @@ pub fn game_creation_app_asset_category_for_kind(kind: &str) -> GameCreationAppA
|
|||||||
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
|
.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> {
|
pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec<String> {
|
||||||
let mut normalized = Vec::new();
|
let mut normalized = Vec::new();
|
||||||
for tag in tags {
|
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]
|
#[test]
|
||||||
fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() {
|
fn asset_manifest_entry_defaults_category_and_tags_for_legacy_payloads() {
|
||||||
let character = asset_entry_from_json(asset_entry_json("character"));
|
let character = asset_entry_from_json(asset_entry_json("character"));
|
||||||
|
|||||||
Reference in New Issue
Block a user