@lingjingai/scriptctl 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changes/0.32.0.md +30 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +70 -0
- package/dist/common.js.map +1 -1
- package/dist/domain/direct/runner.d.ts +3 -1
- package/dist/domain/direct/runner.js +20 -4
- package/dist/domain/direct/runner.js.map +1 -1
- package/dist/domain/direct/stage.d.ts +23 -1
- package/dist/domain/direct/stage.js +2 -0
- package/dist/domain/direct/stage.js.map +1 -1
- package/dist/domain/direct/stages/asset-curation.js +3 -2
- package/dist/domain/direct/stages/asset-curation.js.map +1 -1
- package/dist/domain/direct/stages/index.d.ts +2 -1
- package/dist/domain/direct/stages/index.js +3 -1
- package/dist/domain/direct/stages/index.js.map +1 -1
- package/dist/domain/direct/stages/state-binding.d.ts +2 -0
- package/dist/domain/direct/stages/state-binding.js +9 -0
- package/dist/domain/direct/stages/state-binding.js.map +1 -0
- package/dist/domain/direct-core.d.ts +57 -0
- package/dist/domain/direct-core.js +3469 -112
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/help-text.js +55 -1
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.d.ts +9 -0
- package/dist/infra/providers.js +442 -3
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.d.ts +1 -0
- package/dist/usecases/direct.js +1287 -54
- package/dist/usecases/direct.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACTION_TYPE_VALUES, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
1
|
+
import { ACTION_TYPE_VALUES, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, SPEAKER_SOURCE_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Regexes & lookup tables (shared with infra/converters.ts via re-export)
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -2872,161 +2872,2942 @@ export function sceneAssetUsage(script, refKey, idKey) {
|
|
|
2872
2872
|
}
|
|
2873
2873
|
return usage;
|
|
2874
2874
|
}
|
|
2875
|
-
function
|
|
2875
|
+
function actionPreviewForAsset(script, kind, assetId, limit = 1) {
|
|
2876
|
+
const idKey = assetIdKey(kind);
|
|
2877
|
+
const refKey = assetListKey(kind);
|
|
2878
|
+
const previews = [];
|
|
2879
|
+
for (const ep of asList(script["episodes"])) {
|
|
2880
|
+
const episodeId = strOf(ep["episode_id"]);
|
|
2881
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2882
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
2883
|
+
const ctx = sceneContext(scene);
|
|
2884
|
+
const inScene = asList(ctx[refKey]).some((ref) => isDict(ref) && strOf(ref[idKey]) === assetId);
|
|
2885
|
+
if (!inScene)
|
|
2886
|
+
continue;
|
|
2887
|
+
const action = asList(scene["actions"]).map((it) => strOf(it["content"]).trim()).find((it) => it);
|
|
2888
|
+
previews.push(`${episodeId}/${sceneId}: ${action ? action.slice(0, 100) : "(no action)"}`);
|
|
2889
|
+
if (previews.length >= limit)
|
|
2890
|
+
return previews;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
return previews;
|
|
2894
|
+
}
|
|
2895
|
+
function emptyCurationEvidence() {
|
|
2896
|
+
return {
|
|
2897
|
+
sceneRefs: new Set(),
|
|
2898
|
+
actionRefs: new Set(),
|
|
2899
|
+
speakingScenes: new Set(),
|
|
2900
|
+
dialogueCount: 0,
|
|
2901
|
+
actionMentionCount: 0,
|
|
2902
|
+
speakerIds: new Set(),
|
|
2903
|
+
examples: [],
|
|
2904
|
+
dialogueExamples: [],
|
|
2905
|
+
coActors: new Set(),
|
|
2906
|
+
coLocations: new Set(),
|
|
2907
|
+
coProps: new Set(),
|
|
2908
|
+
};
|
|
2909
|
+
}
|
|
2910
|
+
function pushLimitedUnique(out, value, limit) {
|
|
2911
|
+
const text = value.trim();
|
|
2912
|
+
if (!text || out.includes(text) || out.length >= limit)
|
|
2913
|
+
return;
|
|
2914
|
+
out.push(text);
|
|
2915
|
+
}
|
|
2916
|
+
function speakerSourceMap(script) {
|
|
2917
|
+
const out = new Map();
|
|
2918
|
+
for (const speaker of asList(script["speakers"])) {
|
|
2919
|
+
if (!isDict(speaker))
|
|
2920
|
+
continue;
|
|
2921
|
+
const speakerId = strOf(speaker["speaker_id"]);
|
|
2922
|
+
if (speakerId)
|
|
2923
|
+
out.set(speakerId, speaker);
|
|
2924
|
+
}
|
|
2925
|
+
return out;
|
|
2926
|
+
}
|
|
2927
|
+
function actionSpeakerIds(action) {
|
|
2928
|
+
const ids = [];
|
|
2929
|
+
uniqueAdd(ids, strOf(action["speaker_id"]));
|
|
2930
|
+
for (const speaker of asList(action["speakers"])) {
|
|
2931
|
+
if (isDict(speaker))
|
|
2932
|
+
uniqueAdd(ids, strOf(speaker["speaker_id"]));
|
|
2933
|
+
}
|
|
2934
|
+
for (const line of asList(action["lines"])) {
|
|
2935
|
+
if (isDict(line))
|
|
2936
|
+
uniqueAdd(ids, strOf(line["speaker_id"]));
|
|
2937
|
+
}
|
|
2938
|
+
return ids;
|
|
2939
|
+
}
|
|
2940
|
+
function assetNameMaps(script) {
|
|
2941
|
+
const maps = {
|
|
2942
|
+
actor: new Map(),
|
|
2943
|
+
location: new Map(),
|
|
2944
|
+
prop: new Map(),
|
|
2945
|
+
};
|
|
2946
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
2947
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
2948
|
+
if (!isDict(asset))
|
|
2949
|
+
continue;
|
|
2950
|
+
const id = strOf(asset[assetIdKey(kind)]);
|
|
2951
|
+
const name = strOf(asset[assetNameKey(kind)]).trim();
|
|
2952
|
+
if (id && name)
|
|
2953
|
+
maps[kind].set(id, name);
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
return maps;
|
|
2957
|
+
}
|
|
2958
|
+
function findMentionedAssetIds(content, names) {
|
|
2876
2959
|
const out = [];
|
|
2877
|
-
|
|
2878
|
-
|
|
2960
|
+
const text = content.trim();
|
|
2961
|
+
if (!text)
|
|
2962
|
+
return out;
|
|
2963
|
+
for (const [id, name] of names) {
|
|
2964
|
+
if (name.length >= 2 && text.includes(name))
|
|
2965
|
+
out.push(id);
|
|
2966
|
+
}
|
|
2967
|
+
return out;
|
|
2968
|
+
}
|
|
2969
|
+
function ensureEvidence(map, id) {
|
|
2970
|
+
if (!map.has(id))
|
|
2971
|
+
map.set(id, emptyCurationEvidence());
|
|
2972
|
+
return map.get(id);
|
|
2973
|
+
}
|
|
2974
|
+
function buildCurationEvidence(script) {
|
|
2975
|
+
const evidence = {
|
|
2976
|
+
actor: new Map(),
|
|
2977
|
+
location: new Map(),
|
|
2978
|
+
prop: new Map(),
|
|
2979
|
+
};
|
|
2980
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
2981
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
2982
|
+
if (!isDict(asset))
|
|
2983
|
+
continue;
|
|
2984
|
+
const id = strOf(asset[assetIdKey(kind)]);
|
|
2985
|
+
if (id)
|
|
2986
|
+
evidence[kind].set(id, emptyCurationEvidence());
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
const names = assetNameMaps(script);
|
|
2990
|
+
const speakers = speakerSourceMap(script);
|
|
2991
|
+
for (const speaker of speakers.values()) {
|
|
2992
|
+
if (strOf(speaker["source_kind"]) !== "actor")
|
|
2879
2993
|
continue;
|
|
2880
|
-
const
|
|
2881
|
-
|
|
2882
|
-
|
|
2994
|
+
const actorId = strOf(speaker["source_id"]);
|
|
2995
|
+
const speakerId = strOf(speaker["speaker_id"]);
|
|
2996
|
+
if (actorId && speakerId)
|
|
2997
|
+
ensureEvidence(evidence.actor, actorId).speakerIds.add(speakerId);
|
|
2998
|
+
}
|
|
2999
|
+
for (const ep of asList(script["episodes"])) {
|
|
3000
|
+
const episodeId = strOf(ep["episode_id"]);
|
|
3001
|
+
for (const scene of asList(ep["scenes"])) {
|
|
3002
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
3003
|
+
const sceneRef = `${episodeId || "-"}${sceneId ? `/${sceneId}` : ""}`;
|
|
3004
|
+
const ctx = sceneContext(scene);
|
|
3005
|
+
const actorIds = asList(ctx["actors"]).map((ref) => strOf(ref["actor_id"])).filter((id) => id);
|
|
3006
|
+
const locationIds = asList(ctx["locations"]).map((ref) => strOf(ref["location_id"])).filter((id) => id);
|
|
3007
|
+
const propIds = asList(ctx["props"]).map((ref) => strOf(ref["prop_id"])).filter((id) => id);
|
|
3008
|
+
for (const actorId of actorIds) {
|
|
3009
|
+
const item = ensureEvidence(evidence.actor, actorId);
|
|
3010
|
+
item.sceneRefs.add(sceneRef);
|
|
3011
|
+
for (const locationId of locationIds) {
|
|
3012
|
+
const name = names.location.get(locationId);
|
|
3013
|
+
if (name)
|
|
3014
|
+
item.coLocations.add(`${locationId}:${name}`);
|
|
3015
|
+
}
|
|
3016
|
+
for (const propId of propIds) {
|
|
3017
|
+
const name = names.prop.get(propId);
|
|
3018
|
+
if (name)
|
|
3019
|
+
item.coProps.add(`${propId}:${name}`);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
for (const locationId of locationIds) {
|
|
3023
|
+
const item = ensureEvidence(evidence.location, locationId);
|
|
3024
|
+
item.sceneRefs.add(sceneRef);
|
|
3025
|
+
for (const otherLocationId of locationIds) {
|
|
3026
|
+
if (otherLocationId === locationId)
|
|
3027
|
+
continue;
|
|
3028
|
+
const name = names.location.get(otherLocationId);
|
|
3029
|
+
if (name)
|
|
3030
|
+
item.coLocations.add(`${otherLocationId}:${name}`);
|
|
3031
|
+
}
|
|
3032
|
+
for (const actorId of actorIds) {
|
|
3033
|
+
const name = names.actor.get(actorId);
|
|
3034
|
+
if (name)
|
|
3035
|
+
item.coActors.add(`${actorId}:${name}`);
|
|
3036
|
+
}
|
|
3037
|
+
for (const propId of propIds) {
|
|
3038
|
+
const name = names.prop.get(propId);
|
|
3039
|
+
if (name)
|
|
3040
|
+
item.coProps.add(`${propId}:${name}`);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
for (const propId of propIds) {
|
|
3044
|
+
const item = ensureEvidence(evidence.prop, propId);
|
|
3045
|
+
item.sceneRefs.add(sceneRef);
|
|
3046
|
+
for (const actorId of actorIds) {
|
|
3047
|
+
const name = names.actor.get(actorId);
|
|
3048
|
+
if (name)
|
|
3049
|
+
item.coActors.add(`${actorId}:${name}`);
|
|
3050
|
+
}
|
|
3051
|
+
for (const locationId of locationIds) {
|
|
3052
|
+
const name = names.location.get(locationId);
|
|
3053
|
+
if (name)
|
|
3054
|
+
item.coLocations.add(`${locationId}:${name}`);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
for (let actionIndex = 0; actionIndex < asList(scene["actions"]).length; actionIndex++) {
|
|
3058
|
+
const action = asList(scene["actions"])[actionIndex];
|
|
3059
|
+
if (!isDict(action))
|
|
3060
|
+
continue;
|
|
3061
|
+
const content = strOf(action["content"]).replace(/\s+/g, " ").trim();
|
|
3062
|
+
const actionRef = `${sceneRef}#${actionIndex}`;
|
|
3063
|
+
const actorId = strOf(action["actor_id"]);
|
|
3064
|
+
if (actorId) {
|
|
3065
|
+
const item = ensureEvidence(evidence.actor, actorId);
|
|
3066
|
+
item.actionRefs.add(actionRef);
|
|
3067
|
+
item.actionMentionCount += 1;
|
|
3068
|
+
pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
|
|
3069
|
+
}
|
|
3070
|
+
const speakerActorIds = new Set();
|
|
3071
|
+
for (const speakerId of actionSpeakerIds(action)) {
|
|
3072
|
+
const speaker = speakers.get(speakerId);
|
|
3073
|
+
if (!speaker || strOf(speaker["source_kind"]) !== "actor")
|
|
3074
|
+
continue;
|
|
3075
|
+
const sourceId = strOf(speaker["source_id"]);
|
|
3076
|
+
if (sourceId)
|
|
3077
|
+
speakerActorIds.add(sourceId);
|
|
3078
|
+
}
|
|
3079
|
+
for (const sourceId of speakerActorIds) {
|
|
3080
|
+
const item = ensureEvidence(evidence.actor, sourceId);
|
|
3081
|
+
item.actionRefs.add(actionRef);
|
|
3082
|
+
item.speakingScenes.add(sceneRef);
|
|
3083
|
+
item.dialogueCount += 1;
|
|
3084
|
+
pushLimitedUnique(item.dialogueExamples, `${actionRef}: ${content.slice(0, 120)}`, 3);
|
|
3085
|
+
}
|
|
3086
|
+
for (const mentionedActorId of findMentionedAssetIds(content, names.actor)) {
|
|
3087
|
+
const item = ensureEvidence(evidence.actor, mentionedActorId);
|
|
3088
|
+
item.actionMentionCount += 1;
|
|
3089
|
+
item.actionRefs.add(actionRef);
|
|
3090
|
+
pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
|
|
3091
|
+
}
|
|
3092
|
+
for (const mentionedPropId of findMentionedAssetIds(content, names.prop)) {
|
|
3093
|
+
const item = ensureEvidence(evidence.prop, mentionedPropId);
|
|
3094
|
+
item.actionRefs.add(actionRef);
|
|
3095
|
+
pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
|
|
3096
|
+
}
|
|
3097
|
+
for (const mentionedLocationId of findMentionedAssetIds(content, names.location)) {
|
|
3098
|
+
const item = ensureEvidence(evidence.location, mentionedLocationId);
|
|
3099
|
+
item.actionRefs.add(actionRef);
|
|
3100
|
+
pushLimitedUnique(item.examples, `${actionRef}: ${content.slice(0, 120)}`, 3);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
return evidence;
|
|
3106
|
+
}
|
|
3107
|
+
function stateCatalog(asset) {
|
|
3108
|
+
return asList(asset["states"]).map((state) => ({
|
|
3109
|
+
state_id: state["state_id"],
|
|
3110
|
+
state_name: state["state_name"],
|
|
3111
|
+
description: state["description"],
|
|
3112
|
+
}));
|
|
3113
|
+
}
|
|
3114
|
+
function clusterMembersByAssetId(clusters) {
|
|
3115
|
+
const out = new Map();
|
|
3116
|
+
for (const cluster of clusters) {
|
|
3117
|
+
const members = asList(cluster["members"]).filter(isDict);
|
|
3118
|
+
const names = members.map((member) => strOf(member["name"]).trim()).filter((name) => name);
|
|
3119
|
+
for (const member of members) {
|
|
3120
|
+
const id = strOf(member["id"]);
|
|
3121
|
+
const name = strOf(member["name"]).trim();
|
|
3122
|
+
if (!id)
|
|
3123
|
+
continue;
|
|
3124
|
+
out.set(id, names.filter((candidate) => candidate && candidate !== name).slice(0, 8));
|
|
3125
|
+
}
|
|
2883
3126
|
}
|
|
2884
3127
|
return out;
|
|
2885
3128
|
}
|
|
2886
|
-
function
|
|
2887
|
-
const
|
|
3129
|
+
function scopeCandidatesForLocation(name, relatedNames, coLocationNames = []) {
|
|
3130
|
+
const scopes = [];
|
|
3131
|
+
const addScope = (value) => {
|
|
3132
|
+
const scope = value.trim();
|
|
3133
|
+
if (scope)
|
|
3134
|
+
uniqueAdd(scopes, scope);
|
|
3135
|
+
};
|
|
3136
|
+
for (const candidate of [name, ...relatedNames]) {
|
|
3137
|
+
const dotIndex = candidate.indexOf("·");
|
|
3138
|
+
if (dotIndex > 0)
|
|
3139
|
+
addScope(candidate.slice(0, dotIndex));
|
|
3140
|
+
const spacedIndex = candidate.indexOf(" ");
|
|
3141
|
+
if (spacedIndex > 0)
|
|
3142
|
+
addScope(candidate.slice(0, spacedIndex));
|
|
3143
|
+
}
|
|
3144
|
+
for (const candidate of coLocationNames) {
|
|
3145
|
+
if (isLikelyScopeMarkerName(candidate))
|
|
3146
|
+
addScope(candidate);
|
|
3147
|
+
}
|
|
3148
|
+
return scopes.slice(0, 4);
|
|
3149
|
+
}
|
|
3150
|
+
function idNameValuesToNames(values) {
|
|
3151
|
+
const names = [];
|
|
3152
|
+
for (const value of values) {
|
|
3153
|
+
const index = value.indexOf(":");
|
|
3154
|
+
const name = index >= 0 ? value.slice(index + 1).trim() : value.trim();
|
|
3155
|
+
if (name)
|
|
3156
|
+
uniqueAdd(names, name);
|
|
3157
|
+
}
|
|
3158
|
+
return names;
|
|
3159
|
+
}
|
|
3160
|
+
function idNameValuesToPairs(values) {
|
|
3161
|
+
const pairs = [];
|
|
2888
3162
|
const seen = new Set();
|
|
2889
|
-
for (const
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
const
|
|
2893
|
-
if (!
|
|
3163
|
+
for (const value of values) {
|
|
3164
|
+
const index = value.indexOf(":");
|
|
3165
|
+
const id = index >= 0 ? value.slice(0, index).trim() : "";
|
|
3166
|
+
const name = index >= 0 ? value.slice(index + 1).trim() : value.trim();
|
|
3167
|
+
if (!id || !name)
|
|
2894
3168
|
continue;
|
|
2895
|
-
const
|
|
2896
|
-
const stateId = nextId !== rawId ? null : ref["state_id"];
|
|
2897
|
-
const key = `${nextId}::${strOf(stateId)}`;
|
|
3169
|
+
const key = `${id}:${name}`;
|
|
2898
3170
|
if (seen.has(key))
|
|
2899
3171
|
continue;
|
|
2900
3172
|
seen.add(key);
|
|
2901
|
-
|
|
3173
|
+
pairs.push({ id, name });
|
|
2902
3174
|
}
|
|
2903
|
-
return
|
|
3175
|
+
return pairs;
|
|
2904
3176
|
}
|
|
2905
|
-
function
|
|
2906
|
-
const
|
|
2907
|
-
|
|
2908
|
-
if (!alias || alias === targetName)
|
|
2909
|
-
return;
|
|
2910
|
-
if (!isList(target["aliases"]))
|
|
2911
|
-
target["aliases"] = [];
|
|
2912
|
-
uniqueAdd(target["aliases"], alias);
|
|
3177
|
+
function isLikelyScopeMarkerName(nameRaw) {
|
|
3178
|
+
const name = cleanName(nameRaw);
|
|
3179
|
+
return /(?:世界|领域|国度|界域|时空|宇宙|位面)$/.test(name) || /(?:仙界|神界|魔界|妖界|人间|凡间|地府)$/.test(name);
|
|
2913
3180
|
}
|
|
2914
|
-
|
|
2915
|
-
if (
|
|
3181
|
+
function hasScopedLocationCue(name, coLocationNames) {
|
|
3182
|
+
if (!name || name.includes("·"))
|
|
3183
|
+
return false;
|
|
3184
|
+
return coLocationNames.some((candidate) => isLikelyScopeMarkerName(candidate));
|
|
3185
|
+
}
|
|
3186
|
+
function stripLocationScope(nameRaw) {
|
|
3187
|
+
const name = cleanName(nameRaw);
|
|
3188
|
+
const dotIndex = name.indexOf("·");
|
|
3189
|
+
if (dotIndex > 0 && dotIndex < name.length - 1) {
|
|
3190
|
+
return { scope: name.slice(0, dotIndex), base: name.slice(dotIndex + 1) };
|
|
3191
|
+
}
|
|
3192
|
+
return { scope: "", base: name };
|
|
3193
|
+
}
|
|
3194
|
+
function locationParentCandidates(script, sourceId, sourceNameRaw) {
|
|
3195
|
+
const source = stripLocationScope(sourceNameRaw);
|
|
3196
|
+
if (!source.base)
|
|
3197
|
+
return [];
|
|
3198
|
+
const out = [];
|
|
3199
|
+
for (const loc of asList(script["locations"])) {
|
|
3200
|
+
if (!isDict(loc))
|
|
3201
|
+
continue;
|
|
3202
|
+
const id = strOf(loc["location_id"]);
|
|
3203
|
+
const rawName = strOf(loc["location_name"]);
|
|
3204
|
+
if (!id || id === sourceId)
|
|
3205
|
+
continue;
|
|
3206
|
+
const candidate = stripLocationScope(rawName);
|
|
3207
|
+
if (!candidate.base || candidate.base === source.base)
|
|
3208
|
+
continue;
|
|
3209
|
+
const sameScope = !source.scope || !candidate.scope || source.scope === candidate.scope;
|
|
3210
|
+
if (!sameScope)
|
|
3211
|
+
continue;
|
|
3212
|
+
const childContainsParent = source.base.length > candidate.base.length + 1 && source.base.includes(candidate.base);
|
|
3213
|
+
const parentContainsChild = candidate.base.length > source.base.length + 1 && candidate.base.includes(source.base);
|
|
3214
|
+
if (childContainsParent || parentContainsChild)
|
|
3215
|
+
uniqueAdd(out, `${id}:${rawName}`);
|
|
3216
|
+
if (out.length >= 6)
|
|
3217
|
+
break;
|
|
3218
|
+
}
|
|
3219
|
+
return out;
|
|
3220
|
+
}
|
|
3221
|
+
function locationGranularityHint(nameRaw, parentCandidates) {
|
|
3222
|
+
const { scope, base } = stripLocationScope(nameRaw);
|
|
3223
|
+
if (isLikelyScopeMarkerName(base))
|
|
3224
|
+
return "scope_marker";
|
|
3225
|
+
if (scope && base)
|
|
3226
|
+
return "scoped_set";
|
|
3227
|
+
if (parentCandidates.length === 0)
|
|
3228
|
+
return "";
|
|
3229
|
+
if (/(?:门口|门外|门内|门前|门后|入口|出口|走廊|过道|附近|旁边|角落|边缘|上空|空中|屋顶|天台)$/.test(base)) {
|
|
3230
|
+
return "micro_or_transition_space";
|
|
3231
|
+
}
|
|
3232
|
+
return "parent_child_candidate";
|
|
3233
|
+
}
|
|
3234
|
+
function scopedLocationRenameSuggestions(nameRaw, coLocationNames) {
|
|
3235
|
+
const { base } = stripLocationScope(nameRaw);
|
|
3236
|
+
if (!base)
|
|
3237
|
+
return [];
|
|
3238
|
+
const out = [];
|
|
3239
|
+
for (const candidate of coLocationNames) {
|
|
3240
|
+
if (!isLikelyScopeMarkerName(candidate))
|
|
3241
|
+
continue;
|
|
3242
|
+
uniqueAdd(out, `${candidate}·${base}`);
|
|
3243
|
+
if (out.length >= 4)
|
|
3244
|
+
break;
|
|
3245
|
+
}
|
|
3246
|
+
return out;
|
|
3247
|
+
}
|
|
3248
|
+
const ACTOR_FORM_MARKERS = [
|
|
3249
|
+
"Q版",
|
|
3250
|
+
"幻影",
|
|
3251
|
+
"兽形",
|
|
3252
|
+
"本体",
|
|
3253
|
+
"真身",
|
|
3254
|
+
"本尊",
|
|
3255
|
+
"伪装",
|
|
3256
|
+
"人形",
|
|
3257
|
+
"神兽",
|
|
3258
|
+
"分身",
|
|
3259
|
+
"影子",
|
|
3260
|
+
"幼年",
|
|
3261
|
+
"老年",
|
|
3262
|
+
"黑化",
|
|
3263
|
+
"魔化",
|
|
3264
|
+
"觉醒",
|
|
3265
|
+
];
|
|
3266
|
+
function actorFormMarker(nameRaw) {
|
|
3267
|
+
const name = cleanName(nameRaw);
|
|
3268
|
+
for (const marker of ACTOR_FORM_MARKERS) {
|
|
3269
|
+
if (name.includes(marker))
|
|
3270
|
+
return marker;
|
|
3271
|
+
}
|
|
3272
|
+
return "";
|
|
3273
|
+
}
|
|
3274
|
+
function compactEvidenceText(asset, evidence) {
|
|
3275
|
+
const parts = [
|
|
3276
|
+
strOf(asset["description"]),
|
|
3277
|
+
...asList(asset["aliases"]).map((alias) => strOf(alias)),
|
|
3278
|
+
...asList(asset["states"]).flatMap((state) => [strOf(state["state_name"]), strOf(state["description"])]),
|
|
3279
|
+
...evidence.examples,
|
|
3280
|
+
...evidence.dialogueExamples,
|
|
3281
|
+
];
|
|
3282
|
+
return parts.join(" ");
|
|
3283
|
+
}
|
|
3284
|
+
function actorIdentityCandidates(script, sourceId, sourceNameRaw, evidence) {
|
|
3285
|
+
const sourceName = cleanName(sourceNameRaw);
|
|
3286
|
+
if (!sourceId || !sourceName)
|
|
3287
|
+
return [];
|
|
3288
|
+
const sourceAsset = assetMapById(script, "actor").get(sourceId) ?? {};
|
|
3289
|
+
const evidenceText = compactEvidenceText(sourceAsset, evidence);
|
|
3290
|
+
const marker = actorFormMarker(sourceName);
|
|
3291
|
+
const out = [];
|
|
3292
|
+
for (const actor of asList(script["actors"])) {
|
|
3293
|
+
if (!isDict(actor))
|
|
3294
|
+
continue;
|
|
3295
|
+
const id = strOf(actor["actor_id"]);
|
|
3296
|
+
const name = cleanName(strOf(actor["actor_name"]));
|
|
3297
|
+
if (!id || id === sourceId || !name)
|
|
3298
|
+
continue;
|
|
3299
|
+
const candidateMarker = actorFormMarker(name);
|
|
3300
|
+
const sourceWrapsCandidate = sourceName !== name && sourceName.includes(name) && (marker || sourceName.length > name.length + 1);
|
|
3301
|
+
const candidateWrapsSource = sourceName !== name && name.includes(sourceName) && (candidateMarker || name.length > sourceName.length + 1);
|
|
3302
|
+
const textMentionsCandidate = name.length >= 2 && evidenceText.includes(name);
|
|
3303
|
+
const nicknameCandidate = sourceName.length === 2 &&
|
|
3304
|
+
/^(?:小|老|阿)/.test(sourceName) &&
|
|
3305
|
+
name.length >= 2 &&
|
|
3306
|
+
name.includes(sourceName.slice(1));
|
|
3307
|
+
if (sourceWrapsCandidate)
|
|
3308
|
+
uniqueAdd(out, `${id}:${name}:form_base`);
|
|
3309
|
+
else if (candidateWrapsSource)
|
|
3310
|
+
uniqueAdd(out, `${id}:${name}:form_variant`);
|
|
3311
|
+
else if (textMentionsCandidate)
|
|
3312
|
+
uniqueAdd(out, `${id}:${name}:mentioned_in_evidence`);
|
|
3313
|
+
else if (nicknameCandidate)
|
|
3314
|
+
uniqueAdd(out, `${id}:${name}:nickname_candidate`);
|
|
3315
|
+
if (out.length >= 6)
|
|
3316
|
+
break;
|
|
3317
|
+
}
|
|
3318
|
+
return out;
|
|
3319
|
+
}
|
|
3320
|
+
function propContinuityHints(asset, evidence) {
|
|
3321
|
+
const hints = [];
|
|
3322
|
+
if (asList(asset["states"]).length > 0)
|
|
3323
|
+
uniqueAdd(hints, "stateful");
|
|
3324
|
+
if (evidence.sceneRefs.size >= 2)
|
|
3325
|
+
uniqueAdd(hints, "multi_scene");
|
|
3326
|
+
if (evidence.actionRefs.size >= 2)
|
|
3327
|
+
uniqueAdd(hints, "action_referenced");
|
|
3328
|
+
if (propHasSingleOwnerActor(evidence))
|
|
3329
|
+
uniqueAdd(hints, "single_owner_actor");
|
|
3330
|
+
if (evidence.sceneRefs.size <= 1 && evidence.actionRefs.size <= 1 && asList(asset["states"]).length === 0)
|
|
3331
|
+
uniqueAdd(hints, "low_continuity");
|
|
3332
|
+
return hints;
|
|
3333
|
+
}
|
|
3334
|
+
function propBoundaryHint(asset, evidence) {
|
|
3335
|
+
if (asList(asset["states"]).length > 0)
|
|
3336
|
+
return "hero_candidate_stateful";
|
|
3337
|
+
if (evidence.sceneRefs.size >= 3 || evidence.actionRefs.size >= 4)
|
|
3338
|
+
return "hero_candidate_recurring";
|
|
3339
|
+
if (evidence.sceneRefs.size >= 2 && evidence.actionRefs.size >= 2)
|
|
3340
|
+
return "possible_continuity_prop";
|
|
3341
|
+
if (propHasSingleOwnerActor(evidence))
|
|
3342
|
+
return "actor_bound_review";
|
|
3343
|
+
return "minor_or_context_only";
|
|
3344
|
+
}
|
|
3345
|
+
function curationDecisionRequirement(kind, sceneCount, relatedNames, name, coLocationNames, extraSignals = []) {
|
|
3346
|
+
if (kind === "prop")
|
|
3347
|
+
return "prop_exhaustive";
|
|
3348
|
+
if (kind === "actor" && sceneCount <= 1)
|
|
3349
|
+
return "low_frequency_actor";
|
|
3350
|
+
if (kind === "actor" && extraSignals.length > 0)
|
|
3351
|
+
return "actor_identity_or_form_candidate";
|
|
3352
|
+
if (kind === "location" && hasScopedLocationCue(name, coLocationNames))
|
|
3353
|
+
return "scoped_location";
|
|
3354
|
+
if (kind === "location" && extraSignals.length > 0)
|
|
3355
|
+
return "location_granularity_review";
|
|
3356
|
+
if (kind === "location" && relatedNames.length > 0)
|
|
3357
|
+
return "clustered_location";
|
|
3358
|
+
return "";
|
|
3359
|
+
}
|
|
3360
|
+
function compactSet(values, limit) {
|
|
3361
|
+
return [...values].filter((value) => value).slice(0, limit);
|
|
3362
|
+
}
|
|
3363
|
+
function singleIdNameValue(values) {
|
|
3364
|
+
const items = [...values].filter((value) => value.trim());
|
|
3365
|
+
return items.length === 1 ? items[0] : "";
|
|
3366
|
+
}
|
|
3367
|
+
function compactAssetForCuration(script, kind, asset, usage, evidenceByKind, relatedById, crossKindNames) {
|
|
3368
|
+
const id = strOf(asset[assetIdKey(kind)]);
|
|
3369
|
+
const itemUsage = usage.get(id) ?? { episodes: new Set(), scenes: new Set() };
|
|
3370
|
+
const evidence = evidenceByKind[kind].get(id) ?? emptyCurationEvidence();
|
|
3371
|
+
const relatedNames = relatedById.get(id) ?? [];
|
|
3372
|
+
const name = strOf(asset[assetNameKey(kind)]).trim();
|
|
3373
|
+
const coLocationNames = kind === "location" ? idNameValuesToNames(evidence.coLocations) : [];
|
|
3374
|
+
const actorIdentity = kind === "actor" ? actorIdentityCandidates(script, id, name, evidence) : [];
|
|
3375
|
+
const locationParents = kind === "location" ? locationParentCandidates(script, id, name) : [];
|
|
3376
|
+
const sceneCount = sceneCountFromUsage(itemUsage);
|
|
3377
|
+
return {
|
|
3378
|
+
kind,
|
|
3379
|
+
id,
|
|
3380
|
+
name: asset[assetNameKey(kind)],
|
|
3381
|
+
aliases: asset["aliases"],
|
|
3382
|
+
description: asset["description"],
|
|
3383
|
+
states: stateCatalog(asset),
|
|
3384
|
+
scene_count: sceneCount,
|
|
3385
|
+
episode_count: itemUsage.episodes.size,
|
|
3386
|
+
state_count: stateCatalog(asset).length,
|
|
3387
|
+
dialogue_count: evidence.dialogueCount,
|
|
3388
|
+
speaking_scene_count: evidence.speakingScenes.size,
|
|
3389
|
+
action_mention_count: evidence.actionMentionCount,
|
|
3390
|
+
action_ref_count: evidence.actionRefs.size,
|
|
3391
|
+
speaker_ids: compactSet(evidence.speakerIds, 6),
|
|
3392
|
+
scene_refs: compactSet(evidence.sceneRefs, 8),
|
|
3393
|
+
related_names: relatedNames,
|
|
3394
|
+
related_assets: kind === "location" ? locationParents : [],
|
|
3395
|
+
identity_candidates: actorIdentity,
|
|
3396
|
+
form_marker: kind === "actor" ? actorFormMarker(name) : "",
|
|
3397
|
+
scope_candidates: kind === "location" ? scopeCandidatesForLocation(name, relatedNames, coLocationNames) : [],
|
|
3398
|
+
scope_rename_suggestions: kind === "location" ? scopedLocationRenameSuggestions(name, coLocationNames) : [],
|
|
3399
|
+
parent_locations: locationParents,
|
|
3400
|
+
granularity_hint: kind === "location" ? locationGranularityHint(name, locationParents) : "",
|
|
3401
|
+
cross_kind_same_names: crossKindNames.get(`${kind}:${id}`) ?? [],
|
|
3402
|
+
co_actors: compactSet(evidence.coActors, 5),
|
|
3403
|
+
owner_actor: kind === "prop" ? singleIdNameValue(evidence.coActors) : "",
|
|
3404
|
+
co_locations: compactSet(evidence.coLocations, 5),
|
|
3405
|
+
co_props: compactSet(evidence.coProps, 5),
|
|
3406
|
+
continuity_hints: kind === "prop" ? propContinuityHints(asset, evidence) : [],
|
|
3407
|
+
prop_boundary_hint: kind === "prop" ? propBoundaryHint(asset, evidence) : "",
|
|
3408
|
+
decision_required: curationDecisionRequirement(kind, sceneCount, relatedNames, name, coLocationNames, kind === "actor" ? actorIdentity : locationParents),
|
|
3409
|
+
examples: actionPreviewForAsset(script, kind, id),
|
|
3410
|
+
action_examples: evidence.examples,
|
|
3411
|
+
dialogue_examples: evidence.dialogueExamples,
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
3414
|
+
function similarAssetClusters(script, kind) {
|
|
3415
|
+
const items = asList(script[assetListKey(kind)]).filter(isDict);
|
|
3416
|
+
const clusters = [];
|
|
3417
|
+
for (let i = 0; i < items.length; i++) {
|
|
3418
|
+
const left = items[i];
|
|
3419
|
+
const leftName = strOf(left[assetNameKey(kind)]);
|
|
3420
|
+
const leftKey = canonicalAssetNameKey(kind, leftName).split("::")[1] ?? "";
|
|
3421
|
+
const members = [{ id: left[assetIdKey(kind)], name: leftName }];
|
|
3422
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
3423
|
+
const right = items[j];
|
|
3424
|
+
const rightName = strOf(right[assetNameKey(kind)]);
|
|
3425
|
+
const rightKey = canonicalAssetNameKey(kind, rightName).split("::")[1] ?? "";
|
|
3426
|
+
if (!leftKey || !rightKey)
|
|
3427
|
+
continue;
|
|
3428
|
+
if (leftKey.includes(rightKey) || rightKey.includes(leftKey)) {
|
|
3429
|
+
members.push({ id: right[assetIdKey(kind)], name: rightName });
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
if (members.length > 1)
|
|
3433
|
+
clusters.push({ kind, members });
|
|
3434
|
+
if (clusters.length >= 80)
|
|
3435
|
+
break;
|
|
3436
|
+
}
|
|
3437
|
+
return clusters;
|
|
3438
|
+
}
|
|
3439
|
+
function crossKindNameHints(script) {
|
|
3440
|
+
const byName = new Map();
|
|
3441
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
3442
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
3443
|
+
if (!isDict(asset))
|
|
3444
|
+
continue;
|
|
3445
|
+
const id = strOf(asset[assetIdKey(kind)]);
|
|
3446
|
+
const name = strOf(asset[assetNameKey(kind)]).trim();
|
|
3447
|
+
if (!id || !name)
|
|
3448
|
+
continue;
|
|
3449
|
+
const key = canonicalAssetNameKey(kind, name).split("::")[1] ?? name;
|
|
3450
|
+
const item = `${kind}:${id}:${name}`;
|
|
3451
|
+
const list = byName.get(key) ?? [];
|
|
3452
|
+
list.push(item);
|
|
3453
|
+
byName.set(key, list);
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
const out = new Map();
|
|
3457
|
+
for (const list of byName.values()) {
|
|
3458
|
+
if (list.length < 2)
|
|
3459
|
+
continue;
|
|
3460
|
+
for (const item of list) {
|
|
3461
|
+
const [kind, id] = item.split(":");
|
|
3462
|
+
if (!kind || !id)
|
|
3463
|
+
continue;
|
|
3464
|
+
out.set(`${kind}:${id}`, list.filter((candidate) => candidate !== item).slice(0, 6));
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
return out;
|
|
3468
|
+
}
|
|
3469
|
+
function normalizeAssetKindSelection(kinds) {
|
|
3470
|
+
if (!kinds || kinds.length === 0)
|
|
3471
|
+
return ["actor", "location", "prop"];
|
|
3472
|
+
const out = [];
|
|
3473
|
+
for (const kind of kinds) {
|
|
3474
|
+
if (out.includes(kind))
|
|
3475
|
+
continue;
|
|
3476
|
+
out.push(kind);
|
|
3477
|
+
}
|
|
3478
|
+
return out;
|
|
3479
|
+
}
|
|
3480
|
+
function selectedAssetSections(kinds) {
|
|
3481
|
+
const sections = [];
|
|
3482
|
+
if (kinds.includes("actor"))
|
|
3483
|
+
sections.push(["actors", "actor"]);
|
|
3484
|
+
if (kinds.includes("location"))
|
|
3485
|
+
sections.push(["locations", "location"]);
|
|
3486
|
+
if (kinds.includes("prop"))
|
|
3487
|
+
sections.push(["props", "prop"]);
|
|
3488
|
+
return sections;
|
|
3489
|
+
}
|
|
3490
|
+
export const ASSET_GROUP_MAX_ITEMS = 8;
|
|
3491
|
+
export const ASSET_GROUP_MAX_CONTEXT_CHARS = 22000;
|
|
3492
|
+
function selectedAssetIds(selection, kind) {
|
|
3493
|
+
const ids = selection?.[kind];
|
|
3494
|
+
if (!ids || ids.length === 0)
|
|
3495
|
+
return null;
|
|
3496
|
+
return new Set(ids);
|
|
3497
|
+
}
|
|
3498
|
+
function filterAssetsForCuration(script, kind, selection) {
|
|
3499
|
+
const ids = selectedAssetIds(selection, kind);
|
|
3500
|
+
const idKey = assetIdKey(kind);
|
|
3501
|
+
return asList(script[assetListKey(kind)])
|
|
3502
|
+
.filter(isDict)
|
|
3503
|
+
.filter((asset) => !ids || ids.has(strOf(asset[idKey])));
|
|
3504
|
+
}
|
|
3505
|
+
export function buildAssetCurationContext(script, kinds, selection) {
|
|
3506
|
+
const selectedKinds = normalizeAssetKindSelection(kinds);
|
|
3507
|
+
const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
|
|
3508
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
3509
|
+
const propUsage = sceneAssetUsage(script, "props", "prop_id");
|
|
3510
|
+
const evidence = buildCurationEvidence(script);
|
|
3511
|
+
const clusters = selectedKinds.flatMap((kind) => similarAssetClusters(script, kind));
|
|
3512
|
+
const relatedById = clusterMembersByAssetId(clusters);
|
|
3513
|
+
const crossKindNames = crossKindNameHints(script);
|
|
3514
|
+
return {
|
|
3515
|
+
title: script["title"],
|
|
3516
|
+
asset_kinds: selectedKinds,
|
|
3517
|
+
policy: [
|
|
3518
|
+
"Profile: align with a human-refined asset boundary.",
|
|
3519
|
+
"Actors: keep named, speaking, visually identifiable, or plot-functional roles even when low-frequency.",
|
|
3520
|
+
"Actor forms: move Q版/幻影/兽形/本体/真身/伪装 forms into states on the base actor.",
|
|
3521
|
+
"Actor identity hints list possible base actors or aliases; merge/form-state only when evidence supports the same identity.",
|
|
3522
|
+
"Locations: keep distinct production sets; do not blindly merge interiors, doors, rooftops, underground levels, warehouses, fields, pens, or sky/air variants.",
|
|
3523
|
+
"Locations: parent/gran hints mark possible parent-child or micro/transition spaces; merge only non-distinct transitional/angle-only spaces into the parent.",
|
|
3524
|
+
"Location names: prefer world/domain-scoped canonical names when the source distinguishes worlds or realms.",
|
|
3525
|
+
"Location field scopeHint lists world/realm/domain markers co-occurring with an unscoped location; renameHint gives scoped canonical-name suggestions.",
|
|
3526
|
+
"Props: strict refined prop boundary. Most visible or plot-mentioned objects should stay in action text or actor/location state, not standalone props.",
|
|
3527
|
+
`Every KEEP prop reason must start like category=signature_instance; using one of ${propKeepCategoryList()}, followed by evidence.`,
|
|
3528
|
+
"Props: KEEP named/signature physical instances when they have standalone continuity, are exchanged/carried/inspected as the same object, carry readable evidence/rules/secrets, drive transformations/conflicts, or function as recurring character/faction equipment.",
|
|
3529
|
+
"Props: do not KEEP merely because an object is repeated, plot-functional, held, used, UI/display content, ordinary document/bill, food, or set dressing.",
|
|
3530
|
+
"Props: body marks and worn appearance should become actor state; detachable named devices/weapons/tools/accessories with continuity may stay props.",
|
|
3531
|
+
"Prop field cont/boundary gives continuity hints only; the model still decides KEEP/DROP/STATE from the evidence lines.",
|
|
3532
|
+
],
|
|
3533
|
+
coverage_policy: {
|
|
3534
|
+
props: "every prop line is decision-required",
|
|
3535
|
+
actors: "actors with scene_count <= 1 or identity/form hints are decision-required",
|
|
3536
|
+
locations: "locations with scopeHint, parent/granularity hints, or similar/name clusters are decision-required",
|
|
3537
|
+
},
|
|
3538
|
+
assets: {
|
|
3539
|
+
actors: selectedKinds.includes("actor") ? filterAssetsForCuration(script, "actor", selection).map((asset) => compactAssetForCuration(script, "actor", asset, actorUsage, evidence, relatedById, crossKindNames)) : [],
|
|
3540
|
+
locations: selectedKinds.includes("location") ? filterAssetsForCuration(script, "location", selection).map((asset) => compactAssetForCuration(script, "location", asset, locationUsage, evidence, relatedById, crossKindNames)) : [],
|
|
3541
|
+
props: selectedKinds.includes("prop") ? filterAssetsForCuration(script, "prop", selection).map((asset) => compactAssetForCuration(script, "prop", asset, propUsage, evidence, relatedById, crossKindNames)) : [],
|
|
3542
|
+
},
|
|
3543
|
+
speakers: selectedKinds.includes("actor") ? asList(script["speakers"]).filter(isDict).map((speaker) => ({
|
|
3544
|
+
speaker_id: speaker["speaker_id"],
|
|
3545
|
+
display_name: speaker["display_name"],
|
|
3546
|
+
source_kind: speaker["source_kind"],
|
|
3547
|
+
source_id: speaker["source_id"],
|
|
3548
|
+
})) : [],
|
|
3549
|
+
similar_clusters: clusters,
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
function compactCurationText(value, maxChars = 160) {
|
|
3553
|
+
const text = strOf(value)
|
|
3554
|
+
.replace(/\s+/g, " ")
|
|
3555
|
+
.replace(/\|/g, "/")
|
|
3556
|
+
.trim();
|
|
3557
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
|
|
3558
|
+
}
|
|
3559
|
+
function assetPrefixForCuration(kind) {
|
|
3560
|
+
if (kind === "actor")
|
|
3561
|
+
return "A";
|
|
3562
|
+
if (kind === "location")
|
|
3563
|
+
return "L";
|
|
3564
|
+
if (kind === "prop")
|
|
3565
|
+
return "P";
|
|
3566
|
+
return "X";
|
|
3567
|
+
}
|
|
3568
|
+
function formatCurationStates(states) {
|
|
3569
|
+
const items = asList(states)
|
|
3570
|
+
.filter(isDict)
|
|
3571
|
+
.map((state) => {
|
|
3572
|
+
const id = compactCurationText(state["state_id"], 40);
|
|
3573
|
+
const name = compactCurationText(state["state_name"], 80);
|
|
3574
|
+
if (!id || !name)
|
|
3575
|
+
return "";
|
|
3576
|
+
return `${id}:${name}`;
|
|
3577
|
+
})
|
|
3578
|
+
.filter((item) => item);
|
|
3579
|
+
return items.length > 0 ? items.join(",") : "-";
|
|
3580
|
+
}
|
|
3581
|
+
function formatCurationAliases(aliases) {
|
|
3582
|
+
const items = asList(aliases)
|
|
3583
|
+
.map((item) => compactCurationText(item, 60))
|
|
3584
|
+
.filter((item) => item);
|
|
3585
|
+
return items.length > 0 ? items.join(",") : "-";
|
|
3586
|
+
}
|
|
3587
|
+
function formatCurationList(value, limit = 6) {
|
|
3588
|
+
const items = asList(value)
|
|
3589
|
+
.map((item) => compactCurationText(item, 70))
|
|
3590
|
+
.filter((item) => item)
|
|
3591
|
+
.slice(0, limit);
|
|
3592
|
+
return items.length > 0 ? items.join(",") : "-";
|
|
3593
|
+
}
|
|
3594
|
+
function formatCurationAssetLine(asset) {
|
|
3595
|
+
const kind = strOf(asset["kind"]);
|
|
3596
|
+
const id = compactCurationText(asset["id"], 40);
|
|
3597
|
+
const name = compactCurationText(asset["name"], 80);
|
|
3598
|
+
const sceneCount = Number(asset["scene_count"] ?? 0);
|
|
3599
|
+
const episodeCount = Number(asset["episode_count"] ?? 0);
|
|
3600
|
+
const stateCount = Number(asset["state_count"] ?? 0);
|
|
3601
|
+
const requirement = compactCurationText(asset["decision_required"], 80);
|
|
3602
|
+
const required = Boolean(requirement);
|
|
3603
|
+
const parts = [
|
|
3604
|
+
assetPrefixForCuration(kind),
|
|
3605
|
+
id,
|
|
3606
|
+
name,
|
|
3607
|
+
`req=${requirement ? `Y:${requirement}` : "-"}`,
|
|
3608
|
+
`sc=${Number.isFinite(sceneCount) ? sceneCount : 0}`,
|
|
3609
|
+
`ep=${Number.isFinite(episodeCount) ? episodeCount : 0}`,
|
|
3610
|
+
`st=${Number.isFinite(stateCount) ? stateCount : 0}`,
|
|
3611
|
+
`alias=${formatCurationAliases(asset["aliases"])}`,
|
|
3612
|
+
`states=${formatCurationStates(asset["states"])}`,
|
|
3613
|
+
];
|
|
3614
|
+
if (kind === "actor") {
|
|
3615
|
+
parts.push(`dlg=${Number(asset["dialogue_count"] ?? 0) || 0}`, `sp_sc=${Number(asset["speaking_scene_count"] ?? 0) || 0}`, `mention=${Number(asset["action_mention_count"] ?? 0) || 0}`, `spk=${formatCurationList(asset["speaker_ids"], 5)}`, `form=${compactCurationText(asset["form_marker"], 40) || "-"}`, `identity=${formatCurationList(asset["identity_candidates"], 6)}`);
|
|
3616
|
+
}
|
|
3617
|
+
else if (kind === "location") {
|
|
3618
|
+
parts.push(`scope=${formatCurationList(asset["scope_candidates"], 4)}`, `scopeHint=${formatCurationList(asset["scope_candidates"], 4)}`, `renameHint=${formatCurationList(asset["scope_rename_suggestions"], 4)}`, `parent=${formatCurationList(asset["parent_locations"], 6)}`, `gran=${compactCurationText(asset["granularity_hint"], 60) || "-"}`, `related=${formatCurationList(asset["related_names"], 6)}`, `coL=${formatCurationList(asset["co_locations"], 4)}`, `coA=${formatCurationList(asset["co_actors"], 4)}`, `coP=${formatCurationList(asset["co_props"], 4)}`);
|
|
3619
|
+
}
|
|
3620
|
+
else if (kind === "prop") {
|
|
3621
|
+
parts.push(`actrefs=${Number(asset["action_ref_count"] ?? 0) || 0}`, `cont=${formatCurationList(asset["continuity_hints"], 5)}`, `boundary=${compactCurationText(asset["prop_boundary_hint"], 60) || "-"}`, `same=${formatCurationList(asset["cross_kind_same_names"], 4)}`, `ownerA=${compactCurationText(asset["owner_actor"], 80) || "-"}`, `coA=${formatCurationList(asset["co_actors"], 4)}`, `coL=${formatCurationList(asset["co_locations"], 4)}`);
|
|
3622
|
+
}
|
|
3623
|
+
parts.push(`desc=${compactCurationText(asset["description"], 120) || "-"}`);
|
|
3624
|
+
const line = parts.join(" | ");
|
|
3625
|
+
const lines = [line];
|
|
3626
|
+
const pushExamples = (label, values, limit) => {
|
|
3627
|
+
if (limit <= 0)
|
|
3628
|
+
return;
|
|
3629
|
+
for (const example of asList(values).slice(0, limit)) {
|
|
3630
|
+
const text = compactCurationText(example, 96);
|
|
3631
|
+
if (text)
|
|
3632
|
+
lines.push(` ${label}=${text}`);
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
if (kind === "actor") {
|
|
3636
|
+
pushExamples("dlg_ex", asset["dialogue_examples"], required ? 2 : 0);
|
|
3637
|
+
pushExamples("act_ex", asset["action_examples"], required ? 1 : 0);
|
|
3638
|
+
}
|
|
3639
|
+
else if (kind === "location") {
|
|
3640
|
+
pushExamples("scene_ex", asset["examples"], required ? 1 : 0);
|
|
3641
|
+
pushExamples("act_ex", asset["action_examples"], required ? 1 : 0);
|
|
3642
|
+
}
|
|
3643
|
+
else if (kind === "prop") {
|
|
3644
|
+
pushExamples("act_ex", asset["action_examples"], 1);
|
|
3645
|
+
pushExamples("scene_ex", asset["examples"], 1);
|
|
3646
|
+
}
|
|
3647
|
+
return lines;
|
|
3648
|
+
}
|
|
3649
|
+
function requiredCurationIds(items) {
|
|
3650
|
+
return items
|
|
3651
|
+
.map((asset) => compactCurationText(asset["decision_required"], 80) ? compactCurationText(asset["id"], 40) : "")
|
|
3652
|
+
.filter((id) => id);
|
|
3653
|
+
}
|
|
3654
|
+
function pushRequiredCurationIdLines(lines, label, ids) {
|
|
3655
|
+
if (ids.length === 0) {
|
|
3656
|
+
lines.push(`- ${label}: -`);
|
|
2916
3657
|
return;
|
|
2917
|
-
const locations = asList(script["locations"]);
|
|
2918
|
-
const byId = new Map();
|
|
2919
|
-
for (const loc of locations) {
|
|
2920
|
-
if (isDict(loc))
|
|
2921
|
-
byId.set(strOf(loc["location_id"]), loc);
|
|
2922
3658
|
}
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
const
|
|
2926
|
-
|
|
3659
|
+
const chunkSize = 36;
|
|
3660
|
+
for (let index = 0; index < ids.length; index += chunkSize) {
|
|
3661
|
+
const suffix = index === 0 ? "" : "+";
|
|
3662
|
+
lines.push(`- ${label}${suffix}: ${ids.slice(index, index + chunkSize).join(",")}`);
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
export function formatAssetCurationContextText(context) {
|
|
3666
|
+
const lines = [];
|
|
3667
|
+
lines.push("# Asset Curation Context");
|
|
3668
|
+
lines.push(`title: ${compactCurationText(context["title"], 100) || "-"}`);
|
|
3669
|
+
const kinds = normalizeAssetKindSelection(asList(context["asset_kinds"]));
|
|
3670
|
+
lines.push(`asset_kinds: ${kinds.join(",")}`);
|
|
3671
|
+
lines.push("");
|
|
3672
|
+
lines.push("policy:");
|
|
3673
|
+
for (const item of asList(context["policy"])) {
|
|
3674
|
+
const text = compactCurationText(item, 160);
|
|
3675
|
+
if (text)
|
|
3676
|
+
lines.push(`- ${text}`);
|
|
3677
|
+
}
|
|
3678
|
+
const coveragePolicy = asDict(context["coverage_policy"]);
|
|
3679
|
+
const coverageLines = [
|
|
3680
|
+
compactCurationText(coveragePolicy["props"], 160),
|
|
3681
|
+
compactCurationText(coveragePolicy["actors"], 160),
|
|
3682
|
+
compactCurationText(coveragePolicy["locations"], 160),
|
|
3683
|
+
].filter((line) => line);
|
|
3684
|
+
if (coverageLines.length > 0) {
|
|
3685
|
+
lines.push("");
|
|
3686
|
+
lines.push("coverage:");
|
|
3687
|
+
for (const line of coverageLines)
|
|
3688
|
+
lines.push(`- ${line}`);
|
|
3689
|
+
}
|
|
3690
|
+
const assets = asDict(context["assets"]);
|
|
3691
|
+
const sections = selectedAssetSections(kinds).map(([key]) => [key, `${key}:`]);
|
|
3692
|
+
lines.push("");
|
|
3693
|
+
lines.push("required_decision_ids:");
|
|
3694
|
+
for (const [key] of sections) {
|
|
3695
|
+
pushRequiredCurationIdLines(lines, key, requiredCurationIds(asList(assets[key]).filter(isDict)));
|
|
3696
|
+
}
|
|
3697
|
+
for (const [key, header] of sections) {
|
|
3698
|
+
lines.push("");
|
|
3699
|
+
lines.push(header);
|
|
3700
|
+
const items = asList(assets[key]).filter(isDict);
|
|
3701
|
+
if (items.length === 0) {
|
|
3702
|
+
lines.push("-");
|
|
3703
|
+
continue;
|
|
3704
|
+
}
|
|
3705
|
+
for (const asset of items)
|
|
3706
|
+
lines.push(...formatCurationAssetLine(asset));
|
|
3707
|
+
}
|
|
3708
|
+
if (kinds.includes("actor")) {
|
|
3709
|
+
lines.push("");
|
|
3710
|
+
lines.push("speakers:");
|
|
3711
|
+
const speakers = asList(context["speakers"]).filter(isDict);
|
|
3712
|
+
if (speakers.length === 0) {
|
|
3713
|
+
lines.push("-");
|
|
3714
|
+
}
|
|
3715
|
+
else {
|
|
3716
|
+
for (const speaker of speakers) {
|
|
3717
|
+
const sourceKind = compactCurationText(speaker["source_kind"], 40) || "-";
|
|
3718
|
+
const sourceId = compactCurationText(speaker["source_id"], 40) || "-";
|
|
3719
|
+
lines.push([
|
|
3720
|
+
"S",
|
|
3721
|
+
compactCurationText(speaker["speaker_id"], 50),
|
|
3722
|
+
compactCurationText(speaker["display_name"], 80),
|
|
3723
|
+
`${sourceKind}:${sourceId}`,
|
|
3724
|
+
].join(" | "));
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
lines.push("");
|
|
3729
|
+
lines.push("similar_clusters:");
|
|
3730
|
+
const clusters = asList(context["similar_clusters"]).filter(isDict);
|
|
3731
|
+
if (clusters.length === 0) {
|
|
3732
|
+
lines.push("-");
|
|
3733
|
+
}
|
|
3734
|
+
else {
|
|
3735
|
+
for (const cluster of clusters) {
|
|
3736
|
+
const members = asList(cluster["members"]).filter(isDict).map((member) => {
|
|
3737
|
+
const id = compactCurationText(member["id"], 40);
|
|
3738
|
+
const name = compactCurationText(member["name"], 80);
|
|
3739
|
+
return id && name ? `${id}:${name}` : "";
|
|
3740
|
+
}).filter((member) => member);
|
|
3741
|
+
if (members.length > 0)
|
|
3742
|
+
lines.push(`C ${compactCurationText(cluster["kind"], 20)} | ${members.join(" ~ ")}`);
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
return `${lines.join("\n")}\n`;
|
|
3746
|
+
}
|
|
3747
|
+
export function buildAssetCurationContextText(script, kinds, selection) {
|
|
3748
|
+
return formatAssetCurationContextText(buildAssetCurationContext(script, kinds, selection));
|
|
3749
|
+
}
|
|
3750
|
+
export function buildAssetGroupingContextText(script, kind) {
|
|
3751
|
+
const lines = [];
|
|
3752
|
+
lines.push("# Asset Grouping Context");
|
|
3753
|
+
lines.push(`kind: ${kind}`);
|
|
3754
|
+
lines.push("");
|
|
3755
|
+
lines.push("task:");
|
|
3756
|
+
lines.push("- Group asset ids that should be reviewed together in the later full curation step.");
|
|
3757
|
+
lines.push("- This is not the curation decision step. Do not decide keep/drop/state/rename.");
|
|
3758
|
+
lines.push("- Use names only. Omit an id only when it is an obvious duplicate of a retained id with the same normalized name.");
|
|
3759
|
+
lines.push(`- Each <group> may contain at most ${ASSET_GROUP_MAX_ITEMS} ids.`);
|
|
3760
|
+
lines.push("- Every non-duplicate id must appear exactly once.");
|
|
3761
|
+
lines.push("");
|
|
3762
|
+
lines.push("output:");
|
|
3763
|
+
lines.push(`- One line per group: <group>${kind} <id> [id...] # short reason</group>`);
|
|
3764
|
+
lines.push("- No JSON, markdown tables, headings, or prose outside <group> tags.");
|
|
3765
|
+
lines.push("");
|
|
3766
|
+
lines.push("assets:");
|
|
3767
|
+
const idKey = assetIdKey(kind);
|
|
3768
|
+
const nameKey = assetNameKey(kind);
|
|
3769
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
3770
|
+
if (!isDict(asset))
|
|
3771
|
+
continue;
|
|
3772
|
+
const id = compactCurationText(asset[idKey], 40);
|
|
3773
|
+
const name = compactCurationText(asset[nameKey], 100);
|
|
3774
|
+
if (id && name)
|
|
3775
|
+
lines.push(`${assetPrefixForCuration(kind)} | ${id} | ${name}`);
|
|
3776
|
+
}
|
|
3777
|
+
return `${lines.join("\n")}\n`;
|
|
3778
|
+
}
|
|
3779
|
+
function assetIdsForCurationKind(script, kind) {
|
|
3780
|
+
const idKey = assetIdKey(kind);
|
|
3781
|
+
return asList(script[assetListKey(kind)])
|
|
3782
|
+
.filter(isDict)
|
|
3783
|
+
.map((asset) => strOf(asset[idKey]).trim())
|
|
3784
|
+
.filter((id) => id);
|
|
3785
|
+
}
|
|
3786
|
+
function selectionForCurationIds(kind, ids) {
|
|
3787
|
+
if (kind === "actor")
|
|
3788
|
+
return { actor: ids };
|
|
3789
|
+
if (kind === "location")
|
|
3790
|
+
return { location: ids };
|
|
3791
|
+
return { prop: ids };
|
|
3792
|
+
}
|
|
3793
|
+
export function buildPrimaryAssetCurationChunks(script, kind, options = { maxItems: ASSET_GROUP_MAX_ITEMS, maxContextChars: ASSET_GROUP_MAX_CONTEXT_CHARS }) {
|
|
3794
|
+
const ids = assetIdsForCurationKind(script, kind);
|
|
3795
|
+
const chunks = [];
|
|
3796
|
+
let current = [];
|
|
3797
|
+
const maxItems = Math.max(1, Math.floor(options.maxItems) || 1);
|
|
3798
|
+
const maxContextChars = Math.max(1000, Math.floor(options.maxContextChars) || 1000);
|
|
3799
|
+
const contextLength = (values) => buildAssetCurationContextText(script, [kind], selectionForCurationIds(kind, values)).length;
|
|
3800
|
+
for (const id of ids) {
|
|
3801
|
+
const next = [...current, id];
|
|
3802
|
+
const tooMany = next.length > maxItems;
|
|
3803
|
+
const tooLong = current.length > 0 && contextLength(next) > maxContextChars;
|
|
3804
|
+
if (tooMany || tooLong) {
|
|
3805
|
+
chunks.push(current);
|
|
3806
|
+
current = [id];
|
|
3807
|
+
}
|
|
3808
|
+
else {
|
|
3809
|
+
current = next;
|
|
3810
|
+
}
|
|
3811
|
+
if (current.length === 1 && contextLength(current) > maxContextChars) {
|
|
3812
|
+
chunks.push(current);
|
|
3813
|
+
current = [];
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
if (current.length > 0)
|
|
3817
|
+
chunks.push(current);
|
|
3818
|
+
return chunks;
|
|
3819
|
+
}
|
|
3820
|
+
export function extractAssetGroupingExpressions(rawText) {
|
|
3821
|
+
const groups = [];
|
|
3822
|
+
const re = /<group>([\s\S]*?)<\/group>/g;
|
|
3823
|
+
let outside = "";
|
|
3824
|
+
let lastIndex = 0;
|
|
3825
|
+
let match;
|
|
3826
|
+
while ((match = re.exec(rawText)) !== null) {
|
|
3827
|
+
outside += rawText.slice(lastIndex, match.index);
|
|
3828
|
+
groups.push(strOf(match[1]).trim());
|
|
3829
|
+
lastIndex = match.index + match[0].length;
|
|
3830
|
+
}
|
|
3831
|
+
outside += rawText.slice(lastIndex);
|
|
3832
|
+
if (outside.trim()) {
|
|
3833
|
+
curationBlocked("Asset grouping response must contain only <group>...</group> lines.", [outside.trim().slice(0, 200)]);
|
|
3834
|
+
}
|
|
3835
|
+
if (groups.length === 0) {
|
|
3836
|
+
curationBlocked("Asset grouping response did not contain any <group> expressions.", [rawText.slice(0, 200)]);
|
|
3837
|
+
}
|
|
3838
|
+
return groups;
|
|
3839
|
+
}
|
|
3840
|
+
function parseAssetGroupingExpression(expression) {
|
|
3841
|
+
const hashIndex = expression.lastIndexOf("#");
|
|
3842
|
+
if (hashIndex < 0)
|
|
3843
|
+
curationBlocked("Asset grouping expression is missing reason.", [expression]);
|
|
3844
|
+
const command = expression.slice(0, hashIndex).trim();
|
|
3845
|
+
const reason = expression.slice(hashIndex + 1).trim();
|
|
3846
|
+
if (!reason)
|
|
3847
|
+
curationBlocked("Asset grouping expression is missing reason.", [expression]);
|
|
3848
|
+
const tokens = command.split(/\s+/).filter((token) => token);
|
|
3849
|
+
const kind = strOf(tokens[0]).toLowerCase();
|
|
3850
|
+
if (kind !== "actor" && kind !== "location" && kind !== "prop") {
|
|
3851
|
+
curationBlocked("Asset grouping expression has unknown kind.", [expression]);
|
|
3852
|
+
}
|
|
3853
|
+
const ids = tokens.slice(1);
|
|
3854
|
+
if (ids.length === 0)
|
|
3855
|
+
curationBlocked("Asset grouping expression needs at least one id.", [expression]);
|
|
3856
|
+
if (ids.length > ASSET_GROUP_MAX_ITEMS) {
|
|
3857
|
+
curationBlocked(`Asset grouping expression exceeds ${ASSET_GROUP_MAX_ITEMS} ids.`, [expression]);
|
|
3858
|
+
}
|
|
3859
|
+
return { kind, ids, reason };
|
|
3860
|
+
}
|
|
3861
|
+
export function parseAssetGroupingExpressions(rawText) {
|
|
3862
|
+
const expressions = extractAssetGroupingExpressions(rawText);
|
|
3863
|
+
const groups = expressions.map((expression) => parseAssetGroupingExpression(expression));
|
|
3864
|
+
return {
|
|
3865
|
+
version: 1,
|
|
3866
|
+
format: "asset-grouping-v1",
|
|
3867
|
+
expression_text: rawText,
|
|
3868
|
+
expressions,
|
|
3869
|
+
groups,
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
function assetGroupingNameKey(kind, rawName) {
|
|
3873
|
+
return canonicalAssetNameKey(kind, rawName).split("::")[1] ?? cleanName(rawName);
|
|
3874
|
+
}
|
|
3875
|
+
export function validateAssetGroupingPlan(script, kind, rawPlan) {
|
|
3876
|
+
const inputIds = assetIdsForCurationKind(script, kind);
|
|
3877
|
+
const inputIdSet = new Set(inputIds);
|
|
3878
|
+
const assetsById = assetMapById(script, kind);
|
|
3879
|
+
const groups = [];
|
|
3880
|
+
const mentioned = new Set();
|
|
3881
|
+
const normalizations = [];
|
|
3882
|
+
const invalid = [];
|
|
3883
|
+
for (const group of asList(rawPlan["groups"]).filter(isDict)) {
|
|
3884
|
+
const groupKind = strOf(group["kind"]);
|
|
3885
|
+
if (groupKind !== kind) {
|
|
3886
|
+
invalid.push(`wrong kind ${groupKind || "<empty>"} for ${kind}`);
|
|
3887
|
+
continue;
|
|
3888
|
+
}
|
|
3889
|
+
const rawIds = asList(group["ids"]).map((id) => strOf(id).trim()).filter((id) => id);
|
|
3890
|
+
if (rawIds.length === 0 || rawIds.length > ASSET_GROUP_MAX_ITEMS) {
|
|
3891
|
+
invalid.push(`${kind} group size ${rawIds.length}`);
|
|
3892
|
+
continue;
|
|
3893
|
+
}
|
|
3894
|
+
const ids = [];
|
|
3895
|
+
const seenInGroup = new Set();
|
|
3896
|
+
for (const id of rawIds) {
|
|
3897
|
+
if (!inputIdSet.has(id)) {
|
|
3898
|
+
normalizations.push({ kind, id, action: "drop_unknown_id", reason: "grouping expression referenced an id not present in the asset table" });
|
|
3899
|
+
continue;
|
|
3900
|
+
}
|
|
3901
|
+
if (seenInGroup.has(id) || mentioned.has(id)) {
|
|
3902
|
+
normalizations.push({ kind, id, action: "drop_duplicate_mention", reason: "grouping expression mentioned this id more than once; first mention kept" });
|
|
3903
|
+
continue;
|
|
3904
|
+
}
|
|
3905
|
+
seenInGroup.add(id);
|
|
3906
|
+
mentioned.add(id);
|
|
3907
|
+
ids.push(id);
|
|
3908
|
+
}
|
|
3909
|
+
if (ids.length === 0) {
|
|
3910
|
+
normalizations.push({ kind, action: "drop_empty_group", reason: "all ids in the group were already mentioned or invalid" });
|
|
3911
|
+
continue;
|
|
3912
|
+
}
|
|
3913
|
+
groups.push({ kind, ids, reason: strOf(group["reason"]).trim() || "name-related group" });
|
|
3914
|
+
}
|
|
3915
|
+
if (invalid.length > 0)
|
|
3916
|
+
curationBlocked("Asset grouping plan is invalid.", invalid.slice(0, 40));
|
|
3917
|
+
const retainedByName = new Map();
|
|
3918
|
+
for (const id of mentioned) {
|
|
3919
|
+
const asset = assetsById.get(id);
|
|
3920
|
+
if (!asset)
|
|
3921
|
+
continue;
|
|
3922
|
+
const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
|
|
3923
|
+
if (!key)
|
|
3924
|
+
continue;
|
|
3925
|
+
const list = retainedByName.get(key) ?? [];
|
|
3926
|
+
list.push(id);
|
|
3927
|
+
retainedByName.set(key, list);
|
|
3928
|
+
}
|
|
3929
|
+
const duplicateMerges = [];
|
|
3930
|
+
const omittedInvalid = [];
|
|
3931
|
+
for (const id of inputIds) {
|
|
3932
|
+
if (mentioned.has(id))
|
|
3933
|
+
continue;
|
|
3934
|
+
const asset = assetsById.get(id);
|
|
3935
|
+
if (!asset)
|
|
3936
|
+
continue;
|
|
3937
|
+
const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
|
|
3938
|
+
const targets = key ? retainedByName.get(key) ?? [] : [];
|
|
3939
|
+
if (targets.length === 0) {
|
|
3940
|
+
omittedInvalid.push(`${kind}:${id}:${strOf(asset[assetNameKey(kind)])}`);
|
|
3941
|
+
continue;
|
|
3942
|
+
}
|
|
3943
|
+
duplicateMerges.push({
|
|
3944
|
+
kind,
|
|
3945
|
+
source_id: id,
|
|
3946
|
+
decision: "merge",
|
|
3947
|
+
target_kind: kind,
|
|
3948
|
+
target_id: targets[0],
|
|
3949
|
+
reason: "name-level duplicate omitted by asset grouping",
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
if (omittedInvalid.length > 0) {
|
|
3953
|
+
curationBlocked("Asset grouping omitted non-duplicate asset ids.", omittedInvalid.slice(0, 40));
|
|
3954
|
+
}
|
|
3955
|
+
return {
|
|
3956
|
+
version: 1,
|
|
3957
|
+
format: "asset-grouping-v1",
|
|
3958
|
+
kind,
|
|
3959
|
+
expression_text: rawPlan["expression_text"],
|
|
3960
|
+
expressions: rawPlan["expressions"],
|
|
3961
|
+
groups,
|
|
3962
|
+
duplicate_merge_decisions: duplicateMerges,
|
|
3963
|
+
normalizations,
|
|
3964
|
+
summary: {
|
|
3965
|
+
input_count: inputIds.length,
|
|
3966
|
+
group_count: groups.length,
|
|
3967
|
+
mentioned_count: mentioned.size,
|
|
3968
|
+
omitted_duplicate_count: duplicateMerges.length,
|
|
3969
|
+
normalization_count: normalizations.length,
|
|
3970
|
+
},
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
export function groupedAssetCurationChunks(script, kind, groups, options = { maxItems: ASSET_GROUP_MAX_ITEMS, maxContextChars: ASSET_GROUP_MAX_CONTEXT_CHARS }) {
|
|
3974
|
+
const chunks = [];
|
|
3975
|
+
const seen = new Set();
|
|
3976
|
+
const existing = new Set(assetIdsForCurationKind(script, kind));
|
|
3977
|
+
const pushSplitGroup = (ids) => {
|
|
3978
|
+
let current = [];
|
|
3979
|
+
const maxItems = Math.max(1, Math.floor(options.maxItems) || 1);
|
|
3980
|
+
const maxContextChars = Math.max(1000, Math.floor(options.maxContextChars) || 1000);
|
|
3981
|
+
const contextLength = (values) => buildAssetCurationContextText(script, [kind], selectionForCurationIds(kind, values)).length;
|
|
3982
|
+
for (const id of ids) {
|
|
3983
|
+
const next = [...current, id];
|
|
3984
|
+
if (current.length > 0 && (next.length > maxItems || contextLength(next) > maxContextChars)) {
|
|
3985
|
+
chunks.push(current);
|
|
3986
|
+
current = [id];
|
|
3987
|
+
}
|
|
3988
|
+
else {
|
|
3989
|
+
current = next;
|
|
3990
|
+
}
|
|
3991
|
+
if (current.length === 1 && contextLength(current) > maxContextChars) {
|
|
3992
|
+
chunks.push(current);
|
|
3993
|
+
current = [];
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
if (current.length > 0)
|
|
3997
|
+
chunks.push(current);
|
|
3998
|
+
};
|
|
3999
|
+
for (const group of groups) {
|
|
4000
|
+
if (strOf(group["kind"]) !== kind)
|
|
4001
|
+
continue;
|
|
4002
|
+
const ids = asList(group["ids"]).map((id) => strOf(id).trim()).filter((id) => id && existing.has(id) && !seen.has(id));
|
|
4003
|
+
for (const id of ids)
|
|
4004
|
+
seen.add(id);
|
|
4005
|
+
if (ids.length > 0)
|
|
4006
|
+
pushSplitGroup(ids);
|
|
4007
|
+
}
|
|
4008
|
+
const missing = assetIdsForCurationKind(script, kind).filter((id) => !seen.has(id));
|
|
4009
|
+
if (missing.length > 0)
|
|
4010
|
+
pushSplitGroup(missing);
|
|
4011
|
+
return chunks;
|
|
4012
|
+
}
|
|
4013
|
+
export function buildAssetCurationRepairContextText(opts) {
|
|
4014
|
+
const lines = [];
|
|
4015
|
+
lines.push("# Asset Curation Repair Context");
|
|
4016
|
+
lines.push("");
|
|
4017
|
+
lines.push("Task: repair only the listed broken or missing curation expressions.");
|
|
4018
|
+
lines.push("- Output only <exp>...</exp> lines.");
|
|
4019
|
+
lines.push("- Output expressions only for repair_required_source_keys.");
|
|
4020
|
+
lines.push("- Keep already accepted decisions unless the same source is listed for repair.");
|
|
4021
|
+
lines.push("- Use the same expression grammar and policy as the base context.");
|
|
4022
|
+
lines.push("- If no listed source needs a replacement expression, output exactly <exp>NOOP # no asset curation changes</exp>.");
|
|
4023
|
+
lines.push("");
|
|
4024
|
+
lines.push("repair_required_source_keys:");
|
|
4025
|
+
if (opts.repairSourceKeys.length === 0)
|
|
4026
|
+
lines.push("-");
|
|
4027
|
+
else
|
|
4028
|
+
for (const key of opts.repairSourceKeys)
|
|
4029
|
+
lines.push(`- ${key}`);
|
|
4030
|
+
lines.push("");
|
|
4031
|
+
lines.push("rejected_expressions:");
|
|
4032
|
+
if (opts.rejectedExpressions.length === 0) {
|
|
4033
|
+
lines.push("-");
|
|
4034
|
+
}
|
|
4035
|
+
else {
|
|
4036
|
+
const repairKeys = new Set(opts.repairSourceKeys);
|
|
4037
|
+
for (const item of opts.rejectedExpressions) {
|
|
4038
|
+
const sourceKey = strOf(item["source_key"]);
|
|
4039
|
+
lines.push(`- index=${strOf(item["index"])} source=${sourceKey || "-"} error=${compactCurationText(item["error"], 220)}`);
|
|
4040
|
+
if (sourceKey && repairKeys.has(sourceKey)) {
|
|
4041
|
+
lines.push(` raw=${compactCurationText(item["raw"], 360)}`);
|
|
4042
|
+
}
|
|
4043
|
+
else {
|
|
4044
|
+
lines.push(" raw=[omitted: invalid output was not tied to one repair source; use repair_required_source_keys and focused context below]");
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
const repairKeys = new Set(opts.repairSourceKeys);
|
|
4049
|
+
const acceptedForSameSource = opts.acceptedDecisions.filter((decision) => repairKeys.has(curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]))));
|
|
4050
|
+
lines.push("");
|
|
4051
|
+
lines.push("accepted_decisions_for_repair_sources:");
|
|
4052
|
+
if (acceptedForSameSource.length === 0) {
|
|
4053
|
+
lines.push("-");
|
|
4054
|
+
}
|
|
4055
|
+
else {
|
|
4056
|
+
for (const decision of acceptedForSameSource)
|
|
4057
|
+
lines.push(`- ${renderAssetCurationDecisionExpression(decision)}`);
|
|
4058
|
+
}
|
|
4059
|
+
lines.push("");
|
|
4060
|
+
lines.push("base_context:");
|
|
4061
|
+
lines.push(opts.baseContextText.trim());
|
|
4062
|
+
return `${lines.join("\n")}\n`;
|
|
4063
|
+
}
|
|
4064
|
+
function curationCoverageRequirements(script) {
|
|
4065
|
+
const requirements = {
|
|
4066
|
+
actor: new Map(),
|
|
4067
|
+
location: new Map(),
|
|
4068
|
+
prop: new Map(),
|
|
4069
|
+
};
|
|
4070
|
+
const evidence = buildCurationEvidence(script);
|
|
4071
|
+
const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
|
|
4072
|
+
for (const actor of asList(script["actors"])) {
|
|
4073
|
+
if (!isDict(actor))
|
|
4074
|
+
continue;
|
|
4075
|
+
const actorId = strOf(actor["actor_id"]);
|
|
4076
|
+
if (!actorId)
|
|
4077
|
+
continue;
|
|
4078
|
+
const actorEvidence = evidence.actor.get(actorId) ?? emptyCurationEvidence();
|
|
4079
|
+
const identityCandidates = actorIdentityCandidates(script, actorId, strOf(actor["actor_name"]), actorEvidence);
|
|
4080
|
+
const sceneCount = sceneCountFromUsage(actorUsage.get(actorId) ?? { episodes: new Set(), scenes: new Set() });
|
|
4081
|
+
const requirement = curationDecisionRequirement("actor", sceneCount, [], strOf(actor["actor_name"]).trim(), [], identityCandidates);
|
|
4082
|
+
if (requirement)
|
|
4083
|
+
requirements.actor.set(actorId, requirement);
|
|
4084
|
+
}
|
|
4085
|
+
for (const prop of asList(script["props"])) {
|
|
4086
|
+
if (!isDict(prop))
|
|
4087
|
+
continue;
|
|
4088
|
+
const propId = strOf(prop["prop_id"]);
|
|
4089
|
+
if (propId)
|
|
4090
|
+
requirements.prop.set(propId, "prop_exhaustive");
|
|
4091
|
+
}
|
|
4092
|
+
const locationRelatedById = clusterMembersByAssetId(similarAssetClusters(script, "location"));
|
|
4093
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
4094
|
+
for (const location of asList(script["locations"])) {
|
|
4095
|
+
if (!isDict(location))
|
|
4096
|
+
continue;
|
|
4097
|
+
const locationId = strOf(location["location_id"]);
|
|
4098
|
+
if (!locationId)
|
|
4099
|
+
continue;
|
|
4100
|
+
const relatedNames = locationRelatedById.get(locationId) ?? [];
|
|
4101
|
+
const locationEvidence = evidence.location.get(locationId) ?? emptyCurationEvidence();
|
|
4102
|
+
const name = strOf(location["location_name"]).trim();
|
|
4103
|
+
const sceneCount = sceneCountFromUsage(locationUsage.get(locationId) ?? { episodes: new Set(), scenes: new Set() });
|
|
4104
|
+
const parentCandidates = locationParentCandidates(script, locationId, name);
|
|
4105
|
+
const requirement = curationDecisionRequirement("location", sceneCount, relatedNames, name, idNameValuesToNames(locationEvidence.coLocations), parentCandidates);
|
|
4106
|
+
if (requirement)
|
|
4107
|
+
requirements.location.set(locationId, requirement);
|
|
4108
|
+
}
|
|
4109
|
+
return requirements;
|
|
4110
|
+
}
|
|
4111
|
+
function implicitAuditCandidates(script, decisions) {
|
|
4112
|
+
const requirements = curationCoverageRequirements(script);
|
|
4113
|
+
const decided = curationDecisionSourceSet(decisions);
|
|
4114
|
+
const evidence = buildCurationEvidence(script);
|
|
4115
|
+
const candidates = [];
|
|
4116
|
+
for (const actor of asList(script["actors"])) {
|
|
4117
|
+
if (!isDict(actor))
|
|
4118
|
+
continue;
|
|
4119
|
+
const actorId = strOf(actor["actor_id"]);
|
|
4120
|
+
if (!actorId || decided.has(curationSourceKey("actor", actorId)))
|
|
4121
|
+
continue;
|
|
4122
|
+
if (requirements.actor.has(actorId))
|
|
4123
|
+
continue;
|
|
4124
|
+
if (hasEnumeratedActorName(strOf(actor["actor_name"]))) {
|
|
4125
|
+
candidates.push({ kind: "actor", id: actorId, reason: "implicit_keep_enumerated_actor_review" });
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
for (const location of asList(script["locations"])) {
|
|
4129
|
+
if (!isDict(location))
|
|
4130
|
+
continue;
|
|
4131
|
+
const locationId = strOf(location["location_id"]);
|
|
4132
|
+
if (!locationId || decided.has(curationSourceKey("location", locationId)))
|
|
4133
|
+
continue;
|
|
4134
|
+
if (requirements.location.has(locationId))
|
|
4135
|
+
continue;
|
|
4136
|
+
const item = evidence.location.get(locationId) ?? emptyCurationEvidence();
|
|
4137
|
+
if (hasScopedLocationCue(strOf(location["location_name"]), idNameValuesToNames(item.coLocations))) {
|
|
4138
|
+
candidates.push({ kind: "location", id: locationId, reason: "implicit_keep_scoped_location_canonical_review" });
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
return candidates;
|
|
4142
|
+
}
|
|
4143
|
+
function curationDecisionSourceSet(decisions) {
|
|
4144
|
+
const out = new Set();
|
|
4145
|
+
for (const decision of decisions) {
|
|
4146
|
+
const kind = strOf(decision["kind"]);
|
|
4147
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4148
|
+
if (kind && sourceId)
|
|
4149
|
+
out.add(curationSourceKey(kind, sourceId));
|
|
4150
|
+
}
|
|
4151
|
+
return out;
|
|
4152
|
+
}
|
|
4153
|
+
function missingCurationRequirements(script, decisions) {
|
|
4154
|
+
const requirements = curationCoverageRequirements(script);
|
|
4155
|
+
const decided = curationDecisionSourceSet(decisions);
|
|
4156
|
+
const missing = [];
|
|
4157
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
4158
|
+
for (const [id, reason] of requirements[kind]) {
|
|
4159
|
+
if (!decided.has(curationSourceKey(kind, id)))
|
|
4160
|
+
missing.push({ kind, id, reason });
|
|
4161
|
+
}
|
|
4162
|
+
}
|
|
4163
|
+
return missing;
|
|
4164
|
+
}
|
|
4165
|
+
export function missingAssetCurationRequiredSourceKeys(script, rawPlan) {
|
|
4166
|
+
return missingCurationRequirements(script, normalizedCurationDecisions(rawPlan))
|
|
4167
|
+
.map((item) => curationSourceKey(item.kind, item.id));
|
|
4168
|
+
}
|
|
4169
|
+
export function assetCurationAuditCandidateSourceKeys(script, rawPlan) {
|
|
4170
|
+
return implicitAuditCandidates(script, normalizedCurationDecisions(rawPlan))
|
|
4171
|
+
.map((item) => curationSourceKey(item.kind, item.id));
|
|
4172
|
+
}
|
|
4173
|
+
export function assetCurationAuditSourceKeys(script, rawPlan) {
|
|
4174
|
+
const decisions = normalizedCurationDecisions(rawPlan);
|
|
4175
|
+
normalizeCurationPlanTargets(script, decisions);
|
|
4176
|
+
const evidence = buildCurationEvidence(script);
|
|
4177
|
+
const clusters = [
|
|
4178
|
+
...similarAssetClusters(script, "actor"),
|
|
4179
|
+
...similarAssetClusters(script, "location"),
|
|
4180
|
+
...similarAssetClusters(script, "prop"),
|
|
4181
|
+
];
|
|
4182
|
+
const relatedById = clusterMembersByAssetId(clusters);
|
|
4183
|
+
const keys = [];
|
|
4184
|
+
for (const decision of decisions) {
|
|
4185
|
+
const kind = strOf(decision["kind"]);
|
|
4186
|
+
if (kind !== "actor" && kind !== "location" && kind !== "prop")
|
|
4187
|
+
continue;
|
|
4188
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4189
|
+
if (!sourceId || !auditCandidateReason(script, decision, evidence, relatedById))
|
|
4190
|
+
continue;
|
|
4191
|
+
uniqueAdd(keys, curationSourceKey(kind, sourceId));
|
|
4192
|
+
}
|
|
4193
|
+
for (const missing of missingCurationRequirements(script, decisions)) {
|
|
4194
|
+
uniqueAdd(keys, curationSourceKey(missing.kind, missing.id));
|
|
4195
|
+
}
|
|
4196
|
+
for (const candidate of implicitAuditCandidates(script, decisions)) {
|
|
4197
|
+
uniqueAdd(keys, curationSourceKey(candidate.kind, candidate.id));
|
|
4198
|
+
}
|
|
4199
|
+
return keys;
|
|
4200
|
+
}
|
|
4201
|
+
function assertCurationCoverage(script, decisions) {
|
|
4202
|
+
const requirements = curationCoverageRequirements(script);
|
|
4203
|
+
const decided = curationDecisionSourceSet(decisions);
|
|
4204
|
+
const missing = [];
|
|
4205
|
+
const invalidPropKeeps = [];
|
|
4206
|
+
const requiredCounts = {};
|
|
4207
|
+
const missingCounts = {};
|
|
4208
|
+
const propKeepCategories = {};
|
|
4209
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
4210
|
+
requiredCounts[kind] = requirements[kind].size;
|
|
4211
|
+
missingCounts[kind] = 0;
|
|
4212
|
+
for (const [id, reason] of requirements[kind]) {
|
|
4213
|
+
if (decided.has(curationSourceKey(kind, id)))
|
|
4214
|
+
continue;
|
|
4215
|
+
missingCounts[kind] += 1;
|
|
4216
|
+
missing.push(`${kind}:${id} (${reason})`);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
for (const decision of decisions) {
|
|
4220
|
+
if (strOf(decision["kind"]) !== "prop" || strOf(decision["decision"]) !== "keep")
|
|
4221
|
+
continue;
|
|
4222
|
+
const category = propKeepCategory(decision["reason"]);
|
|
4223
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4224
|
+
if (!category) {
|
|
4225
|
+
invalidPropKeeps.push(`prop:${sourceId || "<empty>"} (${strOf(decision["reason"]).slice(0, 120) || "missing category"})`);
|
|
4226
|
+
continue;
|
|
4227
|
+
}
|
|
4228
|
+
propKeepCategories[category] = (propKeepCategories[category] ?? 0) + 1;
|
|
4229
|
+
}
|
|
4230
|
+
if (missing.length > 0) {
|
|
4231
|
+
curationBlocked("Curation plan is missing required asset decisions.", missing.slice(0, 80));
|
|
4232
|
+
}
|
|
4233
|
+
if (invalidPropKeeps.length > 0) {
|
|
4234
|
+
curationBlocked(`Prop KEEP decisions must include category=<${propKeepCategoryList()}> in the reason.`, invalidPropKeeps.slice(0, 80));
|
|
4235
|
+
}
|
|
4236
|
+
return {
|
|
4237
|
+
required: requiredCounts,
|
|
4238
|
+
missing: missingCounts,
|
|
4239
|
+
prop_keep_categories: propKeepCategories,
|
|
4240
|
+
policy: {
|
|
4241
|
+
props: "exhaustive",
|
|
4242
|
+
actors: "scene_count <= 1",
|
|
4243
|
+
locations: "scoped locations and similar/name cluster members",
|
|
4244
|
+
},
|
|
4245
|
+
};
|
|
4246
|
+
}
|
|
4247
|
+
function normalizedCurationDecisions(rawPlan) {
|
|
4248
|
+
const payload = isDict(rawPlan) ? rawPlan : {};
|
|
4249
|
+
return dedupeCurationDecisions(asList(payload["decisions"]).filter(isDict).map((item) => normalizeCurationDecision(item)));
|
|
4250
|
+
}
|
|
4251
|
+
function hasEnumeratedActorName(nameRaw) {
|
|
4252
|
+
const name = cleanName(nameRaw);
|
|
4253
|
+
return name.length > 1 && /(?:[甲乙丙丁戊己庚辛壬癸]|[A-Z]|[0-9]+)$/.test(name);
|
|
4254
|
+
}
|
|
4255
|
+
const PROP_KEEP_CATEGORIES = new Set([
|
|
4256
|
+
"readable_evidence",
|
|
4257
|
+
"container_device",
|
|
4258
|
+
"transformation_key",
|
|
4259
|
+
"exchanged_object",
|
|
4260
|
+
"signature_instance",
|
|
4261
|
+
"character_bound_core",
|
|
4262
|
+
"signature_weapon",
|
|
4263
|
+
"visual_symbol",
|
|
4264
|
+
"set_device",
|
|
4265
|
+
]);
|
|
4266
|
+
function propKeepCategory(reasonRaw) {
|
|
4267
|
+
const reason = strOf(reasonRaw);
|
|
4268
|
+
const match = /\bcategory=([a-z_]+)\b/.exec(reason);
|
|
4269
|
+
if (!match)
|
|
4270
|
+
return "";
|
|
4271
|
+
const category = match[1];
|
|
4272
|
+
return PROP_KEEP_CATEGORIES.has(category) ? category : "";
|
|
4273
|
+
}
|
|
4274
|
+
function propKeepCategoryList() {
|
|
4275
|
+
return [...PROP_KEEP_CATEGORIES].join("|");
|
|
4276
|
+
}
|
|
4277
|
+
function propHasSingleOwnerActor(item) {
|
|
4278
|
+
return singleIdNameValue(item.coActors) !== "";
|
|
4279
|
+
}
|
|
4280
|
+
function propHasHeroRescueEvidence(asset, item) {
|
|
4281
|
+
if (propHasSingleOwnerActor(item)) {
|
|
4282
|
+
return asList(asset["states"]).length > 0 || (item.sceneRefs.size >= 2 && item.actionRefs.size >= 2) || item.actionRefs.size >= 4;
|
|
4283
|
+
}
|
|
4284
|
+
return asList(asset["states"]).length > 0 || item.sceneRefs.size >= 3 || (item.sceneRefs.size >= 2 && item.actionRefs.size >= 2);
|
|
4285
|
+
}
|
|
4286
|
+
function auditCandidateReason(script, decision, evidence, relatedById) {
|
|
4287
|
+
const kind = strOf(decision["kind"]);
|
|
4288
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4289
|
+
const action = strOf(decision["decision"]);
|
|
4290
|
+
if (kind === "actor") {
|
|
4291
|
+
const item = evidence.actor.get(sourceId) ?? emptyCurationEvidence();
|
|
4292
|
+
const asset = assetMapById(script, "actor").get(sourceId);
|
|
4293
|
+
if (action === "drop" || action === "move_to_speaker") {
|
|
4294
|
+
if (item.dialogueCount > 0 || item.speakingScenes.size > 0)
|
|
4295
|
+
return "speaking_actor_removed_or_speaker";
|
|
4296
|
+
}
|
|
4297
|
+
if (action === "keep" && asset) {
|
|
4298
|
+
if (hasEnumeratedActorName(strOf(asset["actor_name"])))
|
|
4299
|
+
return "kept_enumerated_actor_review";
|
|
4300
|
+
const identity = actorIdentityCandidates(script, sourceId, strOf(asset["actor_name"]), item);
|
|
4301
|
+
if (identity.length > 0)
|
|
4302
|
+
return "kept_actor_identity_or_form_review";
|
|
4303
|
+
if (item.sceneRefs.size <= 1 && item.dialogueCount === 0 && item.actionMentionCount === 0)
|
|
4304
|
+
return "kept_low_evidence_actor_review";
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
if (kind === "location" && (action === "drop" || action === "merge")) {
|
|
4308
|
+
const asset = assetMapById(script, "location").get(sourceId);
|
|
4309
|
+
if (!asset)
|
|
4310
|
+
return "";
|
|
4311
|
+
const item = evidence.location.get(sourceId) ?? emptyCurationEvidence();
|
|
4312
|
+
if (asList(asset["states"]).length > 0 || item.sceneRefs.size > 1)
|
|
4313
|
+
return "stateful_or_multiscene_location_removed_or_merged";
|
|
4314
|
+
}
|
|
4315
|
+
if (kind === "location" && action === "keep") {
|
|
4316
|
+
const asset = assetMapById(script, "location").get(sourceId);
|
|
4317
|
+
if (!asset)
|
|
4318
|
+
return "";
|
|
4319
|
+
const item = evidence.location.get(sourceId) ?? emptyCurationEvidence();
|
|
4320
|
+
const relatedNames = relatedById.get(sourceId) ?? [];
|
|
4321
|
+
if (hasScopedLocationCue(strOf(asset["location_name"]), idNameValuesToNames(item.coLocations)))
|
|
4322
|
+
return "kept_scoped_location_canonical_review";
|
|
4323
|
+
if (locationParentCandidates(script, sourceId, strOf(asset["location_name"])).length > 0)
|
|
4324
|
+
return "kept_parent_child_location_review";
|
|
4325
|
+
if (relatedNames.length > 0 && item.sceneRefs.size <= 2)
|
|
4326
|
+
return "kept_clustered_location_canonical_review";
|
|
4327
|
+
}
|
|
4328
|
+
if (kind === "prop" && action === "keep") {
|
|
4329
|
+
const item = evidence.prop.get(sourceId) ?? emptyCurationEvidence();
|
|
4330
|
+
if (!propKeepCategory(decision["reason"]))
|
|
4331
|
+
return "kept_prop_missing_keep_category_review";
|
|
4332
|
+
if (propHasSingleOwnerActor(item))
|
|
4333
|
+
return "kept_owner_bound_prop_state_review";
|
|
4334
|
+
return "kept_prop_strict_review";
|
|
4335
|
+
}
|
|
4336
|
+
if (kind === "prop" && (action === "drop" || action === "move_to_state" || action === "merge")) {
|
|
4337
|
+
const asset = assetMapById(script, "prop").get(sourceId);
|
|
4338
|
+
if (!asset)
|
|
4339
|
+
return "";
|
|
4340
|
+
const item = evidence.prop.get(sourceId) ?? emptyCurationEvidence();
|
|
4341
|
+
if (propHasHeroRescueEvidence(asset, item))
|
|
4342
|
+
return "dropped_or_moved_prop_hero_rescue_review";
|
|
4343
|
+
}
|
|
4344
|
+
return "";
|
|
4345
|
+
}
|
|
4346
|
+
export function buildAssetCurationAuditContextText(script, primaryPlan, selectedSourceKeys) {
|
|
4347
|
+
const decisions = normalizedCurationDecisions(primaryPlan);
|
|
4348
|
+
normalizeCurationPlanTargets(script, decisions);
|
|
4349
|
+
const missingRequired = missingCurationRequirements(script, decisions);
|
|
4350
|
+
const implicitAudit = implicitAuditCandidates(script, decisions);
|
|
4351
|
+
const selected = selectedSourceKeys && selectedSourceKeys.length > 0 ? new Set(selectedSourceKeys) : null;
|
|
4352
|
+
const includeCandidate = (kind, id) => !selected || selected.has(curationSourceKey(kind, id));
|
|
4353
|
+
const evidence = buildCurationEvidence(script);
|
|
4354
|
+
const actorUsage = sceneAssetUsage(script, "actors", "actor_id");
|
|
4355
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
4356
|
+
const propUsage = sceneAssetUsage(script, "props", "prop_id");
|
|
4357
|
+
const clusters = [
|
|
4358
|
+
...similarAssetClusters(script, "actor"),
|
|
4359
|
+
...similarAssetClusters(script, "location"),
|
|
4360
|
+
...similarAssetClusters(script, "prop"),
|
|
4361
|
+
];
|
|
4362
|
+
const relatedById = clusterMembersByAssetId(clusters);
|
|
4363
|
+
const crossKindNames = crossKindNameHints(script);
|
|
4364
|
+
const usageByKind = {
|
|
4365
|
+
actor: actorUsage,
|
|
4366
|
+
location: locationUsage,
|
|
4367
|
+
prop: propUsage,
|
|
4368
|
+
};
|
|
4369
|
+
const lines = [];
|
|
4370
|
+
lines.push("# Asset Curation Audit Context");
|
|
4371
|
+
lines.push("mode: audit-overrides-only");
|
|
4372
|
+
lines.push("policy:");
|
|
4373
|
+
lines.push("- Return only <exp>...</exp> lines. The first character of the response must be <.");
|
|
4374
|
+
lines.push("- Do not write headings, bullet explanations, candidate reviews, or prose outside expressions.");
|
|
4375
|
+
lines.push("- Output expressions only for listed candidates when the primary decision should be corrected or when the candidate is marked primary=MISSING.");
|
|
4376
|
+
lines.push("- Otherwise output exactly <exp>NOOP # no asset curation changes</exp>.");
|
|
4377
|
+
lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=MISSING and primary=IMPLICIT_KEEP sources are added.");
|
|
4378
|
+
lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is a generic numbered role, anonymous crowd, pure voice label, or duplicate identity.");
|
|
4379
|
+
lines.push("- For primary KEEP location candidates, output RENAME/MERGE only when canonical scope or duplicate/parent-child granularity is wrong; otherwise leave it unchanged.");
|
|
4380
|
+
lines.push(`- For primary KEEP prop candidates, output DROP/MERGE/STATE/RENAME when the prop is too broad; output KEEP only to add/replace a valid category reason from ${propKeepCategoryList()}.`);
|
|
4381
|
+
lines.push("- For primary DROP/MERGE/STATE prop candidates, output KEEP/RENAME/MERGE only when standalone hero-prop continuity is stronger than the primary decision.");
|
|
4382
|
+
lines.push(`- Every KEEP prop reason must start like category=signature_instance; using one of ${propKeepCategoryList()}, followed by concrete evidence.`);
|
|
4383
|
+
lines.push("- Review props with a strict refined prop boundary: keep named/signature physical instances with continuity, including readable evidence, devices, transformation keys, exchanged objects, signature weapons/tools, visual symbols, and recurring character-bound core props.");
|
|
4384
|
+
lines.push("- Drop UI, ordinary documents, food, generic set dressing, and context-only objects.");
|
|
4385
|
+
lines.push("- When a prop line has ownerA=actor_id:name, use STATE only for body marks or worn appearance; detachable named devices/weapons/tools/accessories with continuity may remain KEEP.");
|
|
4386
|
+
lines.push("- For STATE/PROP/MERGE targets, use only ids visible in the candidate line/context such as identity=, parent=, same=, coA=, coL=, or cluster ids. If no valid target id is visible, choose DROP instead of guessing.");
|
|
4387
|
+
lines.push("");
|
|
4388
|
+
lines.push("candidates:");
|
|
4389
|
+
let count = 0;
|
|
4390
|
+
for (const decision of decisions) {
|
|
4391
|
+
const kind = strOf(decision["kind"]);
|
|
4392
|
+
if (kind !== "actor" && kind !== "location" && kind !== "prop")
|
|
4393
|
+
continue;
|
|
4394
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4395
|
+
if (!includeCandidate(kind, sourceId))
|
|
4396
|
+
continue;
|
|
4397
|
+
const reason = auditCandidateReason(script, decision, evidence, relatedById);
|
|
4398
|
+
if (!sourceId || !reason)
|
|
4399
|
+
continue;
|
|
4400
|
+
const asset = assetMapById(script, kind).get(sourceId);
|
|
4401
|
+
if (!asset)
|
|
4402
|
+
continue;
|
|
4403
|
+
count += 1;
|
|
4404
|
+
lines.push(`candidate ${count}: primary=${renderAssetCurationDecisionExpression(decision)} | audit_reason=${reason}`);
|
|
4405
|
+
const compact = compactAssetForCuration(script, kind, asset, usageByKind[kind], evidence, relatedById, crossKindNames);
|
|
4406
|
+
for (const line of formatCurationAssetLine(compact))
|
|
4407
|
+
lines.push(` ${line}`);
|
|
4408
|
+
}
|
|
4409
|
+
for (const missing of missingRequired) {
|
|
4410
|
+
if (!includeCandidate(missing.kind, missing.id))
|
|
4411
|
+
continue;
|
|
4412
|
+
const asset = assetMapById(script, missing.kind).get(missing.id);
|
|
4413
|
+
if (!asset)
|
|
4414
|
+
continue;
|
|
4415
|
+
count += 1;
|
|
4416
|
+
lines.push(`candidate ${count}: primary=MISSING ${missing.kind}:${missing.id} | audit_reason=missing_required_decision:${missing.reason}`);
|
|
4417
|
+
const compact = compactAssetForCuration(script, missing.kind, asset, usageByKind[missing.kind], evidence, relatedById, crossKindNames);
|
|
4418
|
+
for (const line of formatCurationAssetLine(compact))
|
|
4419
|
+
lines.push(` ${line}`);
|
|
4420
|
+
}
|
|
4421
|
+
for (const candidate of implicitAudit) {
|
|
4422
|
+
if (!includeCandidate(candidate.kind, candidate.id))
|
|
4423
|
+
continue;
|
|
4424
|
+
const asset = assetMapById(script, candidate.kind).get(candidate.id);
|
|
4425
|
+
if (!asset)
|
|
4426
|
+
continue;
|
|
4427
|
+
count += 1;
|
|
4428
|
+
lines.push(`candidate ${count}: primary=IMPLICIT_KEEP ${candidate.kind}:${candidate.id} | audit_reason=${candidate.reason}`);
|
|
4429
|
+
const compact = compactAssetForCuration(script, candidate.kind, asset, usageByKind[candidate.kind], evidence, relatedById, crossKindNames);
|
|
4430
|
+
for (const line of formatCurationAssetLine(compact))
|
|
4431
|
+
lines.push(` ${line}`);
|
|
4432
|
+
}
|
|
4433
|
+
if (count === 0)
|
|
4434
|
+
lines.push("-");
|
|
4435
|
+
lines.push("");
|
|
4436
|
+
lines.push(`candidate_count: ${count}`);
|
|
4437
|
+
return `${lines.join("\n")}\n`;
|
|
4438
|
+
}
|
|
4439
|
+
function curationDecisionBySource(decisions) {
|
|
4440
|
+
const out = new Map();
|
|
4441
|
+
for (const decision of decisions) {
|
|
4442
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
4443
|
+
if (key)
|
|
4444
|
+
out.set(key, decision);
|
|
4445
|
+
}
|
|
4446
|
+
return out;
|
|
4447
|
+
}
|
|
4448
|
+
function locationCanonicalizationCandidates(script, decisions) {
|
|
4449
|
+
const evidence = buildCurationEvidence(script);
|
|
4450
|
+
const relatedById = clusterMembersByAssetId(similarAssetClusters(script, "location"));
|
|
4451
|
+
const bySource = curationDecisionBySource(decisions);
|
|
4452
|
+
const out = [];
|
|
4453
|
+
for (const location of asList(script["locations"])) {
|
|
4454
|
+
if (!isDict(location))
|
|
4455
|
+
continue;
|
|
4456
|
+
const id = strOf(location["location_id"]);
|
|
4457
|
+
if (!id)
|
|
4458
|
+
continue;
|
|
4459
|
+
const item = evidence.location.get(id) ?? emptyCurationEvidence();
|
|
4460
|
+
const relatedNames = relatedById.get(id) ?? [];
|
|
4461
|
+
const hasScope = hasScopedLocationCue(strOf(location["location_name"]), idNameValuesToNames(item.coLocations));
|
|
4462
|
+
const hasParentGranularity = locationParentCandidates(script, id, strOf(location["location_name"])).length > 0;
|
|
4463
|
+
const hasCluster = relatedNames.length > 0;
|
|
4464
|
+
if (!hasScope && !hasParentGranularity && !hasCluster)
|
|
4465
|
+
continue;
|
|
4466
|
+
const decision = bySource.get(curationSourceKey("location", id));
|
|
4467
|
+
const reason = hasScope
|
|
4468
|
+
? "location_scope_canonicalization"
|
|
4469
|
+
: hasParentGranularity
|
|
4470
|
+
? "location_granularity_canonicalization"
|
|
4471
|
+
: "location_cluster_canonicalization";
|
|
4472
|
+
out.push({
|
|
4473
|
+
id,
|
|
4474
|
+
reason,
|
|
4475
|
+
primary: decision ? renderAssetCurationDecisionExpression(decision) : `IMPLICIT_KEEP location:${id}`,
|
|
4476
|
+
});
|
|
4477
|
+
}
|
|
4478
|
+
return out;
|
|
4479
|
+
}
|
|
4480
|
+
export function locationCanonicalizationCandidateSourceKeys(script, rawPlan) {
|
|
4481
|
+
const decisions = normalizedCurationDecisions(rawPlan);
|
|
4482
|
+
return locationCanonicalizationCandidates(script, decisions).map((item) => curationSourceKey("location", item.id));
|
|
4483
|
+
}
|
|
4484
|
+
function locationCanonicalizationCoverage(script, decisions) {
|
|
4485
|
+
const candidates = locationCanonicalizationCandidates(script, decisions);
|
|
4486
|
+
const decided = curationDecisionSourceSet(decisions);
|
|
4487
|
+
const requiredScoped = candidates.filter((candidate) => candidate.reason === "location_scope_canonicalization");
|
|
4488
|
+
const missing = requiredScoped
|
|
4489
|
+
.map((candidate) => curationSourceKey("location", candidate.id))
|
|
4490
|
+
.filter((key) => key && !decided.has(key));
|
|
4491
|
+
if (missing.length > 0) {
|
|
4492
|
+
curationBlocked("Location canonicalization is missing required scoped location coverage.", missing.slice(0, 80));
|
|
4493
|
+
}
|
|
4494
|
+
return {
|
|
4495
|
+
location_canonical_candidates: candidates.length,
|
|
4496
|
+
location_canonical_required_scoped: requiredScoped.length,
|
|
4497
|
+
location_canonical_missing_required_scoped: missing.length,
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
export function buildLocationCanonicalizationAuditContextText(script, rawPlan, selectedSourceKeys) {
|
|
4501
|
+
const decisions = normalizedCurationDecisions(rawPlan);
|
|
4502
|
+
normalizeCurationPlanTargets(script, decisions);
|
|
4503
|
+
const evidence = buildCurationEvidence(script);
|
|
4504
|
+
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
4505
|
+
const clusters = similarAssetClusters(script, "location");
|
|
4506
|
+
const relatedById = clusterMembersByAssetId(clusters);
|
|
4507
|
+
const crossKindNames = crossKindNameHints(script);
|
|
4508
|
+
const selected = selectedSourceKeys && selectedSourceKeys.length > 0 ? new Set(selectedSourceKeys) : null;
|
|
4509
|
+
const candidates = locationCanonicalizationCandidates(script, decisions)
|
|
4510
|
+
.filter((candidate) => !selected || selected.has(curationSourceKey("location", candidate.id)));
|
|
4511
|
+
const lines = [];
|
|
4512
|
+
lines.push("# Location Canonicalization Audit Context");
|
|
4513
|
+
lines.push("mode: location-rename-merge-only");
|
|
4514
|
+
lines.push("policy:");
|
|
4515
|
+
lines.push("- Return only <exp>...</exp> lines. The first character of the response must be <.");
|
|
4516
|
+
lines.push("- Output only RENAME location, MERGE location, or NOOP. Do not output KEEP, DROP, STATE, PROP, SPEAKER, actor, or prop operations.");
|
|
4517
|
+
lines.push("- For unchanged candidates, output nothing. KEEP is invalid in this pass and must not be used to confirm a location.");
|
|
4518
|
+
lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=IMPLICIT_KEEP sources are added.");
|
|
4519
|
+
lines.push("- Prefer scoped canonical names when a location co-occurs with a world/realm/domain marker and the unscoped name would be ambiguous.");
|
|
4520
|
+
lines.push("- For candidates with scopeHint, prefer RENAME to `<scopeHint>·<name>` unless it should MERGE into an existing scoped duplicate or the name is intentionally cross-scope.");
|
|
4521
|
+
lines.push("- Use RENAME for the same production set whose name lacks useful scope. Use MERGE only for duplicate aliases of the same production set.");
|
|
4522
|
+
lines.push("- For candidates with parent/gran hints, MERGE only when the child is a non-distinct transition, doorway, angle, nearby, or overhead view of the parent; leave distinct shootable sets unchanged.");
|
|
4523
|
+
lines.push("- If no listed candidate needs canonicalization, output exactly <exp>NOOP # no location canonicalization changes</exp>.");
|
|
4524
|
+
lines.push("");
|
|
4525
|
+
lines.push("required_scoped_location_ids:");
|
|
4526
|
+
pushRequiredCurationIdLines(lines, "locations", candidates.filter((candidate) => candidate.reason === "location_scope_canonicalization").map((candidate) => candidate.id));
|
|
4527
|
+
lines.push("");
|
|
4528
|
+
lines.push("candidates:");
|
|
4529
|
+
let count = 0;
|
|
4530
|
+
for (const candidate of candidates) {
|
|
4531
|
+
const asset = assetMapById(script, "location").get(candidate.id);
|
|
4532
|
+
if (!asset)
|
|
4533
|
+
continue;
|
|
4534
|
+
count += 1;
|
|
4535
|
+
lines.push(`candidate ${count}: primary=${candidate.primary} | audit_reason=${candidate.reason}`);
|
|
4536
|
+
const compact = compactAssetForCuration(script, "location", asset, locationUsage, evidence, relatedById, crossKindNames);
|
|
4537
|
+
for (const line of formatCurationAssetLine(compact))
|
|
4538
|
+
lines.push(` ${line}`);
|
|
4539
|
+
}
|
|
4540
|
+
if (count === 0)
|
|
4541
|
+
lines.push("-");
|
|
4542
|
+
lines.push("");
|
|
4543
|
+
lines.push("similar_location_clusters:");
|
|
4544
|
+
if (clusters.length === 0) {
|
|
4545
|
+
lines.push("-");
|
|
4546
|
+
}
|
|
4547
|
+
else {
|
|
4548
|
+
for (const cluster of clusters) {
|
|
4549
|
+
const members = asList(cluster["members"]).filter(isDict).map((member) => {
|
|
4550
|
+
const id = compactCurationText(member["id"], 40);
|
|
4551
|
+
const name = compactCurationText(member["name"], 80);
|
|
4552
|
+
return id && name ? `${id}:${name}` : "";
|
|
4553
|
+
}).filter((member) => member);
|
|
4554
|
+
if (members.length > 0)
|
|
4555
|
+
lines.push(`C location | ${members.join(" ~ ")}`);
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
lines.push("");
|
|
4559
|
+
lines.push(`candidate_count: ${count}`);
|
|
4560
|
+
return `${lines.join("\n")}\n`;
|
|
4561
|
+
}
|
|
4562
|
+
export function assertLocationCanonicalizationPlan(rawPlan) {
|
|
4563
|
+
const invalid = [];
|
|
4564
|
+
for (const decision of normalizedCurationDecisions(rawPlan)) {
|
|
4565
|
+
const kind = strOf(decision["kind"]);
|
|
4566
|
+
const action = strOf(decision["decision"]);
|
|
4567
|
+
const sourceId = strOf(decision["source_id"]);
|
|
4568
|
+
if (kind !== "location" || (action !== "rename" && action !== "merge")) {
|
|
4569
|
+
invalid.push(`${kind || "<empty>"}:${sourceId || "<empty>"} ${action || "<empty>"}`);
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4572
|
+
if (invalid.length > 0) {
|
|
4573
|
+
curationBlocked("Location canonicalization audit may only output location RENAME/MERGE expressions or NOOP.", invalid);
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
export function combineLocationCanonicalizationPlan(script, basePlan, locationPlan, allowedNewLocationSources = []) {
|
|
4577
|
+
assertLocationCanonicalizationPlan(locationPlan);
|
|
4578
|
+
const baseDecisions = asList(basePlan["decisions"]).filter(isDict);
|
|
4579
|
+
const locationDecisions = asList(locationPlan["decisions"]).filter(isDict);
|
|
4580
|
+
const baseKeys = curationDecisionSourceSet(baseDecisions);
|
|
4581
|
+
const allowedNewKeys = new Set(allowedNewLocationSources);
|
|
4582
|
+
let addedLocationDecisions = 0;
|
|
4583
|
+
for (const decision of locationDecisions) {
|
|
4584
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
4585
|
+
if (!baseKeys.has(key) && allowedNewKeys.has(key))
|
|
4586
|
+
addedLocationDecisions += 1;
|
|
4587
|
+
}
|
|
4588
|
+
const combined = combineAssetCurationPlans(basePlan, locationPlan, allowedNewLocationSources);
|
|
4589
|
+
const locationCoverage = locationCanonicalizationCoverage(script, normalizedCurationDecisions(combined));
|
|
4590
|
+
const primaryExpressionText = strOf(basePlan["primary_expression_text"]).trim();
|
|
4591
|
+
const auditExpressionText = strOf(basePlan["audit_expression_text"]).trim();
|
|
4592
|
+
const locationExpressionText = strOf(locationPlan["expression_text"]).trim();
|
|
4593
|
+
const expressionTexts = [
|
|
4594
|
+
strOf(basePlan["expression_text"]).trim(),
|
|
4595
|
+
locationExpressionText,
|
|
4596
|
+
].filter((text) => text);
|
|
4597
|
+
const auditSummary = isDict(basePlan["audit_summary"]) ? { ...basePlan["audit_summary"] } : {};
|
|
4598
|
+
auditSummary["location_canonical_decisions"] = locationDecisions.length;
|
|
4599
|
+
auditSummary["location_canonical_added_decisions"] = addedLocationDecisions;
|
|
4600
|
+
auditSummary["location_canonical_noop"] = locationDecisions.length === 0;
|
|
4601
|
+
Object.assign(auditSummary, locationCoverage);
|
|
4602
|
+
return {
|
|
4603
|
+
...combined,
|
|
4604
|
+
primary_expression_text: primaryExpressionText || strOf(basePlan["expression_text"]),
|
|
4605
|
+
audit_expression_text: auditExpressionText,
|
|
4606
|
+
location_canonical_expression_text: locationExpressionText,
|
|
4607
|
+
expression_text: expressionTexts.join("\n"),
|
|
4608
|
+
primary_expressions: asList(basePlan["primary_expressions"]),
|
|
4609
|
+
audit_expressions: asList(basePlan["audit_expressions"]),
|
|
4610
|
+
location_canonical_expressions: asList(locationPlan["expressions"]),
|
|
4611
|
+
expressions: [...asList(basePlan["expressions"]), ...asList(locationPlan["expressions"])],
|
|
4612
|
+
audit_summary: auditSummary,
|
|
4613
|
+
};
|
|
4614
|
+
}
|
|
4615
|
+
function compactStateNames(asset) {
|
|
4616
|
+
const out = [];
|
|
4617
|
+
for (const state of asList(asset["states"])) {
|
|
4618
|
+
if (!isDict(state))
|
|
4619
|
+
continue;
|
|
4620
|
+
const n = strOf(state["state_name"]).trim();
|
|
4621
|
+
if (n)
|
|
4622
|
+
out.push(n);
|
|
4623
|
+
}
|
|
4624
|
+
return out;
|
|
4625
|
+
}
|
|
4626
|
+
function compactAssetRefs(refs, idKey, replacements, droppedIds) {
|
|
4627
|
+
const compacted = [];
|
|
4628
|
+
const seen = new Set();
|
|
4629
|
+
for (const ref of asList(refs)) {
|
|
4630
|
+
if (!isDict(ref))
|
|
4631
|
+
continue;
|
|
4632
|
+
const rawId = strOf(ref[idKey]);
|
|
4633
|
+
const hasReplacement = replacements.has(rawId);
|
|
4634
|
+
if (!rawId || (!hasReplacement && droppedIds.has(rawId)))
|
|
4635
|
+
continue;
|
|
4636
|
+
const nextId = replacements.get(rawId) ?? rawId;
|
|
4637
|
+
const stateId = nextId !== rawId ? null : ref["state_id"];
|
|
4638
|
+
const key = `${nextId}::${strOf(stateId)}`;
|
|
4639
|
+
if (seen.has(key))
|
|
4640
|
+
continue;
|
|
4641
|
+
seen.add(key);
|
|
4642
|
+
compacted.push({ [idKey]: nextId, state_id: stateId });
|
|
4643
|
+
}
|
|
4644
|
+
return compacted;
|
|
4645
|
+
}
|
|
4646
|
+
function assetListKey(kind) {
|
|
4647
|
+
if (kind === "actor")
|
|
4648
|
+
return "actors";
|
|
4649
|
+
if (kind === "location")
|
|
4650
|
+
return "locations";
|
|
4651
|
+
return "props";
|
|
4652
|
+
}
|
|
4653
|
+
function assetIdKey(kind) {
|
|
4654
|
+
if (kind === "actor")
|
|
4655
|
+
return "actor_id";
|
|
4656
|
+
if (kind === "location")
|
|
4657
|
+
return "location_id";
|
|
4658
|
+
return "prop_id";
|
|
4659
|
+
}
|
|
4660
|
+
function assetNameKey(kind) {
|
|
4661
|
+
if (kind === "actor")
|
|
4662
|
+
return "actor_name";
|
|
4663
|
+
if (kind === "location")
|
|
4664
|
+
return "location_name";
|
|
4665
|
+
return "prop_name";
|
|
4666
|
+
}
|
|
4667
|
+
function addAssetAlias(target, kind, aliasRaw) {
|
|
4668
|
+
const alias = cleanName(aliasRaw);
|
|
4669
|
+
const targetName = strOf(target[assetNameKey(kind)]).trim();
|
|
4670
|
+
if (!alias || alias === targetName)
|
|
4671
|
+
return;
|
|
4672
|
+
if (!isList(target["aliases"]))
|
|
4673
|
+
target["aliases"] = [];
|
|
4674
|
+
uniqueAdd(target["aliases"], alias);
|
|
4675
|
+
}
|
|
4676
|
+
function assetMapById(script, kind) {
|
|
4677
|
+
const out = new Map();
|
|
4678
|
+
for (const item of asList(script[assetListKey(kind)])) {
|
|
4679
|
+
if (!isDict(item))
|
|
4680
|
+
continue;
|
|
4681
|
+
const id = strOf(item[assetIdKey(kind)]).trim();
|
|
4682
|
+
if (id)
|
|
4683
|
+
out.set(id, item);
|
|
4684
|
+
}
|
|
4685
|
+
return out;
|
|
4686
|
+
}
|
|
4687
|
+
function nextNumericId(existing, prefix) {
|
|
4688
|
+
let max = 0;
|
|
4689
|
+
const re = new RegExp(`^${prefix}_(\\d+)$`);
|
|
4690
|
+
for (const id of existing) {
|
|
4691
|
+
const match = re.exec(id);
|
|
4692
|
+
if (!match)
|
|
4693
|
+
continue;
|
|
4694
|
+
max = Math.max(max, Number(match[1] ?? 0));
|
|
4695
|
+
}
|
|
4696
|
+
return fmtId(prefix, max + 1);
|
|
4697
|
+
}
|
|
4698
|
+
function nextAssetId(script, kind) {
|
|
4699
|
+
const prefix = kind === "actor" ? "act" : kind === "location" ? "loc" : "prp";
|
|
4700
|
+
return nextNumericId(assetMapById(script, kind).keys(), prefix);
|
|
4701
|
+
}
|
|
4702
|
+
function nextStateId(script) {
|
|
4703
|
+
const ids = [];
|
|
4704
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
4705
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
4706
|
+
for (const state of asList(asset["states"])) {
|
|
4707
|
+
const id = strOf(state["state_id"]).trim();
|
|
4708
|
+
if (id)
|
|
4709
|
+
ids.push(id);
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
return nextNumericId(ids, "st");
|
|
4714
|
+
}
|
|
4715
|
+
function appendState(asset, script, stateNameRaw, descriptionRaw = "") {
|
|
4716
|
+
const stateName = strOf(stateNameRaw).trim();
|
|
4717
|
+
if (!stateName) {
|
|
4718
|
+
throw new CliError("DIRECT CURATION BLOCKED: invalid state name", "Invalid state name.", {
|
|
4719
|
+
exitCode: EXIT_NEEDS_AGENT,
|
|
4720
|
+
required: ["non-empty state_name"],
|
|
4721
|
+
received: ["empty state_name"],
|
|
4722
|
+
nextSteps: ["Inspect asset_curation.json and rerun direct init after fixing the provider plan."],
|
|
4723
|
+
});
|
|
4724
|
+
}
|
|
4725
|
+
if (!isList(asset["states"]))
|
|
4726
|
+
asset["states"] = [];
|
|
4727
|
+
for (const state of asList(asset["states"])) {
|
|
4728
|
+
if (strOf(state["state_name"]).trim() === stateName)
|
|
4729
|
+
return strOf(state["state_id"]);
|
|
4730
|
+
}
|
|
4731
|
+
const state = { state_id: nextStateId(script), state_name: stateName };
|
|
4732
|
+
const description = strOf(descriptionRaw).trim();
|
|
4733
|
+
if (description)
|
|
4734
|
+
state["description"] = description;
|
|
4735
|
+
asset["states"].push(state);
|
|
4736
|
+
return strOf(state["state_id"]);
|
|
4737
|
+
}
|
|
4738
|
+
function speakerIdForActor(actorId) {
|
|
4739
|
+
return `spk_${actorId}`;
|
|
4740
|
+
}
|
|
4741
|
+
function nextSpeakerId(script, sourceKind) {
|
|
4742
|
+
const prefix = `spk_${sourceKind}`;
|
|
4743
|
+
let max = 0;
|
|
4744
|
+
const re = new RegExp(`^${prefix}_(\\d+)$`);
|
|
4745
|
+
for (const speaker of asList(script["speakers"])) {
|
|
4746
|
+
const id = strOf(speaker["speaker_id"]);
|
|
4747
|
+
const match = re.exec(id);
|
|
4748
|
+
if (!match)
|
|
4749
|
+
continue;
|
|
4750
|
+
max = Math.max(max, Number(match[1] ?? 0));
|
|
4751
|
+
}
|
|
4752
|
+
return `${prefix}_${String(max + 1).padStart(3, "0")}`;
|
|
4753
|
+
}
|
|
4754
|
+
function addLocationAlias(target, aliasRaw) {
|
|
4755
|
+
const alias = cleanName(aliasRaw);
|
|
4756
|
+
const targetName = strOf(target["location_name"]).trim();
|
|
4757
|
+
if (!alias || alias === targetName)
|
|
4758
|
+
return;
|
|
4759
|
+
if (!isList(target["aliases"]))
|
|
4760
|
+
target["aliases"] = [];
|
|
4761
|
+
uniqueAdd(target["aliases"], alias);
|
|
4762
|
+
}
|
|
4763
|
+
export function applyLocationMerges(script, replacements) {
|
|
4764
|
+
if (replacements.size === 0)
|
|
4765
|
+
return;
|
|
4766
|
+
const locations = asList(script["locations"]);
|
|
4767
|
+
const byId = new Map();
|
|
4768
|
+
for (const loc of locations) {
|
|
4769
|
+
if (isDict(loc))
|
|
4770
|
+
byId.set(strOf(loc["location_id"]), loc);
|
|
4771
|
+
}
|
|
4772
|
+
for (const [oldId, targetId] of replacements) {
|
|
4773
|
+
const old = byId.get(oldId);
|
|
4774
|
+
const target = byId.get(targetId);
|
|
4775
|
+
if (!old || !target)
|
|
4776
|
+
continue;
|
|
4777
|
+
addLocationAlias(target, strOf(old["location_name"]));
|
|
4778
|
+
for (const alias of asList(old["aliases"])) {
|
|
4779
|
+
addLocationAlias(target, strOf(alias));
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
script["locations"] = locations.filter((loc) => isDict(loc) && !replacements.has(strOf(loc["location_id"])));
|
|
4783
|
+
}
|
|
4784
|
+
function sceneCountFromUsage(usage) {
|
|
4785
|
+
return usage.scenes.size;
|
|
4786
|
+
}
|
|
4787
|
+
export function actorCurationDecisions(script) {
|
|
4788
|
+
const usage = sceneAssetUsage(script, "actors", "actor_id");
|
|
4789
|
+
const decisions = [];
|
|
4790
|
+
for (const actor of asList(script["actors"])) {
|
|
4791
|
+
if (!isDict(actor))
|
|
4792
|
+
continue;
|
|
4793
|
+
const actorId = strOf(actor["actor_id"]);
|
|
4794
|
+
const u = usage.get(actorId) ?? { episodes: new Set(), scenes: new Set() };
|
|
4795
|
+
const sceneCount = sceneCountFromUsage(u);
|
|
4796
|
+
const decision = "keep";
|
|
4797
|
+
const reason = `保留:出现在 ${sceneCount} 个 scene。`;
|
|
4798
|
+
decisions.push({ actor_id: actorId, name: actor["actor_name"], scene_count: sceneCount, decision, reason });
|
|
4799
|
+
}
|
|
4800
|
+
return decisions;
|
|
4801
|
+
}
|
|
4802
|
+
export function propCurationDecisions(script) {
|
|
4803
|
+
const usage = sceneAssetUsage(script, "props", "prop_id");
|
|
4804
|
+
const decisions = [];
|
|
4805
|
+
for (const prop of asList(script["props"])) {
|
|
4806
|
+
if (!isDict(prop))
|
|
4807
|
+
continue;
|
|
4808
|
+
const propId = strOf(prop["prop_id"]);
|
|
4809
|
+
const u = usage.get(propId) ?? { episodes: new Set(), scenes: new Set() };
|
|
4810
|
+
const sceneCount = sceneCountFromUsage(u);
|
|
4811
|
+
const decision = "keep";
|
|
4812
|
+
const reason = `保留:出现在 ${sceneCount} 个 scene。`;
|
|
4813
|
+
decisions.push({ prop_id: propId, name: prop["prop_name"], scene_count: sceneCount, decision, reason });
|
|
4814
|
+
}
|
|
4815
|
+
return decisions;
|
|
4816
|
+
}
|
|
4817
|
+
function rewriteStateChanges(changes, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
4818
|
+
const rewritten = [];
|
|
4819
|
+
for (const change of asList(changes)) {
|
|
4820
|
+
if (!isDict(change))
|
|
4821
|
+
continue;
|
|
4822
|
+
const targetKind = strOf(change["target_kind"]);
|
|
4823
|
+
const targetId = strOf(change["target_id"]);
|
|
4824
|
+
if (targetKind === "actor" && droppedActorIds.has(targetId))
|
|
4825
|
+
continue;
|
|
4826
|
+
if (targetKind === "prop" && droppedPropIds.has(targetId))
|
|
4827
|
+
continue;
|
|
4828
|
+
const next = { ...change };
|
|
4829
|
+
if (targetKind === "location" && locationReplacements.has(targetId)) {
|
|
4830
|
+
next["target_id"] = locationReplacements.get(targetId);
|
|
4831
|
+
delete next["from_state_id"];
|
|
4832
|
+
delete next["to_state_id"];
|
|
4833
|
+
}
|
|
4834
|
+
rewritten.push(next);
|
|
4835
|
+
}
|
|
4836
|
+
return rewritten;
|
|
4837
|
+
}
|
|
4838
|
+
function rewriteTransitionPrompt(transition, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
4839
|
+
if (!isDict(transition))
|
|
4840
|
+
return transition;
|
|
4841
|
+
const targetKind = strOf(transition["target_kind"]);
|
|
4842
|
+
const targetId = strOf(transition["target_id"]);
|
|
4843
|
+
if (targetKind === "actor" && droppedActorIds.has(targetId))
|
|
4844
|
+
return null;
|
|
4845
|
+
if (targetKind === "prop" && droppedPropIds.has(targetId))
|
|
4846
|
+
return null;
|
|
4847
|
+
const next = { ...transition };
|
|
4848
|
+
if (targetKind === "location" && locationReplacements.has(targetId)) {
|
|
4849
|
+
next["target_id"] = locationReplacements.get(targetId);
|
|
4850
|
+
}
|
|
4851
|
+
return next;
|
|
4852
|
+
}
|
|
4853
|
+
function applyAssetCurationRefs(script, locationReplacements, droppedPropIds, droppedActorIds) {
|
|
4854
|
+
if (locationReplacements.size === 0 && droppedPropIds.size === 0 && droppedActorIds.size === 0)
|
|
4855
|
+
return;
|
|
4856
|
+
for (const ep of asList(script["episodes"])) {
|
|
4857
|
+
for (const scene of asList(ep["scenes"])) {
|
|
4858
|
+
const ctx = sceneContext(scene);
|
|
4859
|
+
if (locationReplacements.size > 0) {
|
|
4860
|
+
ctx["locations"] = compactAssetRefs(ctx["locations"], "location_id", locationReplacements, new Set());
|
|
4861
|
+
}
|
|
4862
|
+
if (droppedActorIds.size > 0) {
|
|
4863
|
+
ctx["actors"] = compactAssetRefs(ctx["actors"], "actor_id", new Map(), droppedActorIds);
|
|
4864
|
+
}
|
|
4865
|
+
if (droppedPropIds.size > 0) {
|
|
4866
|
+
ctx["props"] = compactAssetRefs(ctx["props"], "prop_id", new Map(), droppedPropIds);
|
|
4867
|
+
}
|
|
4868
|
+
setSceneContext(scene, ctx);
|
|
4869
|
+
for (const action of asList(scene["actions"])) {
|
|
4870
|
+
if (!isDict(action))
|
|
4871
|
+
continue;
|
|
4872
|
+
if (droppedActorIds.has(strOf(action["actor_id"])))
|
|
4873
|
+
delete action["actor_id"];
|
|
4874
|
+
if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
|
|
4875
|
+
action["state_changes"] = rewriteStateChanges(action["state_changes"], locationReplacements, droppedPropIds, droppedActorIds);
|
|
4876
|
+
}
|
|
4877
|
+
if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
|
|
4878
|
+
const transition = rewriteTransitionPrompt(action["transition_prompt"], locationReplacements, droppedPropIds, droppedActorIds);
|
|
4879
|
+
if (transition === null)
|
|
4880
|
+
delete action["transition_prompt"];
|
|
4881
|
+
else
|
|
4882
|
+
action["transition_prompt"] = transition;
|
|
4883
|
+
}
|
|
4884
|
+
}
|
|
4885
|
+
}
|
|
4886
|
+
}
|
|
4887
|
+
for (const speaker of asList(script["speakers"])) {
|
|
4888
|
+
if (!isDict(speaker))
|
|
4889
|
+
continue;
|
|
4890
|
+
const sourceKind = strOf(speaker["source_kind"]);
|
|
4891
|
+
const sourceId = strOf(speaker["source_id"]);
|
|
4892
|
+
if (sourceKind === "actor" && droppedActorIds.has(sourceId)) {
|
|
4893
|
+
speaker["source_kind"] = "other";
|
|
4894
|
+
speaker["source_id"] = null;
|
|
4895
|
+
}
|
|
4896
|
+
else if (sourceKind === "location" && locationReplacements.has(sourceId)) {
|
|
4897
|
+
speaker["source_id"] = locationReplacements.get(sourceId);
|
|
4898
|
+
}
|
|
4899
|
+
else if (sourceKind === "prop" && droppedPropIds.has(sourceId)) {
|
|
4900
|
+
speaker["source_kind"] = "other";
|
|
4901
|
+
speaker["source_id"] = null;
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
}
|
|
4905
|
+
function curationBlocked(message, received) {
|
|
4906
|
+
throw new CliError("DIRECT CURATION BLOCKED: invalid provider plan", message, {
|
|
4907
|
+
exitCode: EXIT_NEEDS_AGENT,
|
|
4908
|
+
required: ["valid asset curation plan referencing existing ids"],
|
|
4909
|
+
received,
|
|
4910
|
+
nextSteps: ["Inspect asset_curation.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
|
|
4911
|
+
});
|
|
4912
|
+
}
|
|
4913
|
+
function quoteCurationExpressionString(value) {
|
|
4914
|
+
return `"${strOf(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
4915
|
+
}
|
|
4916
|
+
function unquoteCurationExpressionString(value) {
|
|
4917
|
+
let out = "";
|
|
4918
|
+
let escaped = false;
|
|
4919
|
+
for (const ch of value) {
|
|
4920
|
+
if (escaped) {
|
|
4921
|
+
if (ch !== "\\" && ch !== "\"") {
|
|
4922
|
+
curationBlocked("Unsupported escape sequence in curation expression string.", [`\\${ch}`]);
|
|
4923
|
+
}
|
|
4924
|
+
out += ch;
|
|
4925
|
+
escaped = false;
|
|
4926
|
+
}
|
|
4927
|
+
else if (ch === "\\") {
|
|
4928
|
+
escaped = true;
|
|
4929
|
+
}
|
|
4930
|
+
else {
|
|
4931
|
+
out += ch;
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
if (escaped)
|
|
4935
|
+
curationBlocked("Unterminated escape sequence in curation expression string.", [value]);
|
|
4936
|
+
return out;
|
|
4937
|
+
}
|
|
4938
|
+
function splitCurationExpressionReason(expression) {
|
|
4939
|
+
let inQuote = false;
|
|
4940
|
+
let escaped = false;
|
|
4941
|
+
for (let i = 0; i < expression.length; i++) {
|
|
4942
|
+
const ch = expression[i];
|
|
4943
|
+
if (escaped) {
|
|
4944
|
+
escaped = false;
|
|
4945
|
+
continue;
|
|
4946
|
+
}
|
|
4947
|
+
if (ch === "\\") {
|
|
4948
|
+
escaped = true;
|
|
4949
|
+
continue;
|
|
4950
|
+
}
|
|
4951
|
+
if (ch === "\"") {
|
|
4952
|
+
inQuote = !inQuote;
|
|
4953
|
+
continue;
|
|
4954
|
+
}
|
|
4955
|
+
if (ch === "#" && !inQuote) {
|
|
4956
|
+
const command = expression.slice(0, i).trim();
|
|
4957
|
+
const reason = expression.slice(i + 1).trim();
|
|
4958
|
+
if (!command)
|
|
4959
|
+
curationBlocked("Curation expression is missing an operation before # reason.", [expression]);
|
|
4960
|
+
if (!reason)
|
|
4961
|
+
curationBlocked("Curation expression is missing reason text after #.", [expression]);
|
|
4962
|
+
return { command, reason };
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
curationBlocked("Curation expression must include `# reason`.", [expression]);
|
|
4966
|
+
}
|
|
4967
|
+
function parseCurationExpression(expression) {
|
|
4968
|
+
if (expression.includes("\n") || expression.includes("\r")) {
|
|
4969
|
+
curationBlocked("Each curation expression must be a single line.", [expression]);
|
|
4970
|
+
}
|
|
4971
|
+
const { command, reason } = splitCurationExpressionReason(expression.trim());
|
|
4972
|
+
if (/^NOOP$/i.test(command))
|
|
4973
|
+
return null;
|
|
4974
|
+
let match = /^MERGE\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
|
|
4975
|
+
if (match) {
|
|
4976
|
+
const kind = match[1].toLowerCase();
|
|
4977
|
+
return { kind, source_id: match[2], decision: "merge", target_kind: kind, target_id: match[3], reason };
|
|
4978
|
+
}
|
|
4979
|
+
match = /^MERGE\s+(actor|location|prop)\s+(\S+)\s+->\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
|
|
4980
|
+
if (match) {
|
|
4981
|
+
const kind = match[1].toLowerCase();
|
|
4982
|
+
const targetKind = match[3].toLowerCase();
|
|
4983
|
+
if (targetKind === kind) {
|
|
4984
|
+
return { kind, source_id: match[2], decision: "merge", target_kind: kind, target_id: match[4], reason };
|
|
4985
|
+
}
|
|
4986
|
+
if (targetKind === "prop") {
|
|
4987
|
+
return {
|
|
4988
|
+
kind,
|
|
4989
|
+
source_id: match[2],
|
|
4990
|
+
decision: "move_to_prop",
|
|
4991
|
+
target_kind: "prop",
|
|
4992
|
+
target_id: match[4],
|
|
4993
|
+
new_name: "",
|
|
4994
|
+
reason,
|
|
4995
|
+
};
|
|
4996
|
+
}
|
|
4997
|
+
curationBlocked("Cross-kind MERGE is only supported when the target is an existing prop.", [expression]);
|
|
4998
|
+
}
|
|
4999
|
+
match = /^DROP\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
|
|
5000
|
+
if (match)
|
|
5001
|
+
return { kind: match[1].toLowerCase(), source_id: match[2], decision: "drop", reason };
|
|
5002
|
+
match = /^KEEP\s+(actor|location|prop)\s+(\S+)$/i.exec(command);
|
|
5003
|
+
if (match)
|
|
5004
|
+
return { kind: match[1].toLowerCase(), source_id: match[2], decision: "keep", reason };
|
|
5005
|
+
match = /^RENAME\s+(actor|location|prop)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
|
|
5006
|
+
if (match) {
|
|
5007
|
+
return {
|
|
5008
|
+
kind: match[1].toLowerCase(),
|
|
5009
|
+
source_id: match[2],
|
|
5010
|
+
decision: "rename",
|
|
5011
|
+
new_name: unquoteCurationExpressionString(match[3]),
|
|
5012
|
+
reason,
|
|
5013
|
+
};
|
|
5014
|
+
}
|
|
5015
|
+
match = /^STATE\s+(actor|location|prop)\s+(\S+)\s+->\s+(actor|location|prop)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
|
|
5016
|
+
if (match) {
|
|
5017
|
+
return {
|
|
5018
|
+
kind: match[1].toLowerCase(),
|
|
5019
|
+
source_id: match[2],
|
|
5020
|
+
decision: "move_to_state",
|
|
5021
|
+
target_kind: match[3].toLowerCase(),
|
|
5022
|
+
target_id: match[4],
|
|
5023
|
+
state_name: unquoteCurationExpressionString(match[5]),
|
|
5024
|
+
reason,
|
|
5025
|
+
};
|
|
5026
|
+
}
|
|
5027
|
+
match = /^SPEAKER\s+(actor|location|prop)\s+(\S+)\s+->\s+(system|broadcast|group|other)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
|
|
5028
|
+
if (match) {
|
|
5029
|
+
return {
|
|
5030
|
+
kind: match[1].toLowerCase(),
|
|
5031
|
+
source_id: match[2],
|
|
5032
|
+
decision: "move_to_speaker",
|
|
5033
|
+
speaker_kind: match[3].toLowerCase(),
|
|
5034
|
+
new_name: unquoteCurationExpressionString(match[4]),
|
|
5035
|
+
reason,
|
|
5036
|
+
};
|
|
5037
|
+
}
|
|
5038
|
+
match = /^SPEAKER\s+(actor|location|prop)\s+(\S+)\s+(system|broadcast|group|other)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
|
|
5039
|
+
if (match) {
|
|
5040
|
+
return {
|
|
5041
|
+
kind: match[1].toLowerCase(),
|
|
5042
|
+
source_id: match[2],
|
|
5043
|
+
decision: "move_to_speaker",
|
|
5044
|
+
speaker_kind: match[3].toLowerCase(),
|
|
5045
|
+
new_name: unquoteCurationExpressionString(match[4]),
|
|
5046
|
+
reason,
|
|
5047
|
+
};
|
|
5048
|
+
}
|
|
5049
|
+
match = /^PROP\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
|
|
5050
|
+
if (match) {
|
|
5051
|
+
const target = match[3];
|
|
5052
|
+
const decision = {
|
|
5053
|
+
kind: match[1].toLowerCase(),
|
|
5054
|
+
source_id: match[2],
|
|
5055
|
+
decision: "move_to_prop",
|
|
5056
|
+
target_kind: "prop",
|
|
5057
|
+
new_name: unquoteCurationExpressionString(match[4]),
|
|
5058
|
+
reason,
|
|
5059
|
+
};
|
|
5060
|
+
if (target.toUpperCase() !== "NEW")
|
|
5061
|
+
decision["target_id"] = target;
|
|
5062
|
+
return decision;
|
|
5063
|
+
}
|
|
5064
|
+
curationBlocked("Unknown or malformed curation expression.", [expression]);
|
|
5065
|
+
}
|
|
5066
|
+
export function extractAssetCurationExpressions(rawText) {
|
|
5067
|
+
const expressions = [];
|
|
5068
|
+
const re = /<exp>([\s\S]*?)<\/exp>/g;
|
|
5069
|
+
let outside = "";
|
|
5070
|
+
let lastIndex = 0;
|
|
5071
|
+
let match;
|
|
5072
|
+
while ((match = re.exec(rawText)) !== null) {
|
|
5073
|
+
outside += rawText.slice(lastIndex, match.index);
|
|
5074
|
+
expressions.push(strOf(match[1]).trim());
|
|
5075
|
+
lastIndex = match.index + match[0].length;
|
|
5076
|
+
}
|
|
5077
|
+
outside += rawText.slice(lastIndex);
|
|
5078
|
+
if (outside.trim()) {
|
|
5079
|
+
curationBlocked("Asset curation response must contain only <exp>...</exp> lines.", [outside.trim().slice(0, 200)]);
|
|
5080
|
+
}
|
|
5081
|
+
if (expressions.length === 0) {
|
|
5082
|
+
curationBlocked("Asset curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
|
|
5083
|
+
}
|
|
5084
|
+
return expressions;
|
|
5085
|
+
}
|
|
5086
|
+
function curationErrorText(error) {
|
|
5087
|
+
const e = error;
|
|
5088
|
+
return `${e?.name ?? "Error"}: ${e?.message ?? ""}`.trim();
|
|
5089
|
+
}
|
|
5090
|
+
export function guessAssetCurationExpressionSourceKey(rawExpression) {
|
|
5091
|
+
const match = /^\s*(?:KEEP|MERGE|DROP|RENAME|STATE|SPEAKER|PROP)\s+(actor|location|prop)\s+(\S+)/i.exec(rawExpression);
|
|
5092
|
+
if (!match)
|
|
5093
|
+
return null;
|
|
5094
|
+
const kind = match[1].toLowerCase();
|
|
5095
|
+
const sourceId = strOf(match[2]).trim();
|
|
5096
|
+
return sourceId ? curationSourceKey(kind, sourceId) : null;
|
|
5097
|
+
}
|
|
5098
|
+
function extractAssetCurationExpressionsTolerant(rawText) {
|
|
5099
|
+
const expressions = [];
|
|
5100
|
+
const rejected = [];
|
|
5101
|
+
const re = /<exp>([\s\S]*?)<\/exp>/g;
|
|
5102
|
+
let outside = "";
|
|
5103
|
+
let lastIndex = 0;
|
|
5104
|
+
let match;
|
|
5105
|
+
while ((match = re.exec(rawText)) !== null) {
|
|
5106
|
+
outside += rawText.slice(lastIndex, match.index);
|
|
5107
|
+
expressions.push({ index: expressions.length, raw: strOf(match[1]).trim() });
|
|
5108
|
+
lastIndex = match.index + match[0].length;
|
|
5109
|
+
}
|
|
5110
|
+
outside += rawText.slice(lastIndex);
|
|
5111
|
+
if (outside.trim()) {
|
|
5112
|
+
rejected.push({
|
|
5113
|
+
index: -1,
|
|
5114
|
+
raw: outside.trim().slice(0, 2000),
|
|
5115
|
+
error: "Asset curation response must contain only <exp>...</exp> lines.",
|
|
5116
|
+
source_key: guessAssetCurationExpressionSourceKey(outside),
|
|
5117
|
+
});
|
|
5118
|
+
}
|
|
5119
|
+
if (expressions.length === 0) {
|
|
5120
|
+
rejected.push({
|
|
5121
|
+
index: -1,
|
|
5122
|
+
raw: rawText.slice(0, 2000),
|
|
5123
|
+
error: "Asset curation response did not contain any <exp> expressions.",
|
|
5124
|
+
source_key: guessAssetCurationExpressionSourceKey(rawText),
|
|
5125
|
+
});
|
|
5126
|
+
}
|
|
5127
|
+
return { expressions, rejected };
|
|
5128
|
+
}
|
|
5129
|
+
export function parseAssetCurationExpressionsTolerant(rawText) {
|
|
5130
|
+
const extracted = extractAssetCurationExpressionsTolerant(rawText);
|
|
5131
|
+
const decisions = [];
|
|
5132
|
+
const acceptedExpressions = [];
|
|
5133
|
+
const rejected = [...extracted.rejected];
|
|
5134
|
+
const noopIndexes = [];
|
|
5135
|
+
for (const expression of extracted.expressions) {
|
|
5136
|
+
try {
|
|
5137
|
+
const decision = parseCurationExpression(expression.raw);
|
|
5138
|
+
if (decision === null) {
|
|
5139
|
+
noopIndexes.push(expression.index);
|
|
5140
|
+
}
|
|
5141
|
+
else {
|
|
5142
|
+
decisions.push(decision);
|
|
5143
|
+
acceptedExpressions.push(expression.raw);
|
|
5144
|
+
}
|
|
5145
|
+
}
|
|
5146
|
+
catch (error) {
|
|
5147
|
+
rejected.push({
|
|
5148
|
+
index: expression.index,
|
|
5149
|
+
raw: expression.raw,
|
|
5150
|
+
error: curationErrorText(error),
|
|
5151
|
+
source_key: guessAssetCurationExpressionSourceKey(expression.raw),
|
|
5152
|
+
});
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
if (noopIndexes.length > 0 && decisions.length > 0) {
|
|
5156
|
+
for (const index of noopIndexes) {
|
|
5157
|
+
const expression = extracted.expressions.find((item) => item.index === index);
|
|
5158
|
+
rejected.push({
|
|
5159
|
+
index,
|
|
5160
|
+
raw: expression?.raw ?? "NOOP",
|
|
5161
|
+
error: "NOOP cannot be combined with actionable curation expressions.",
|
|
5162
|
+
source_key: null,
|
|
5163
|
+
});
|
|
5164
|
+
}
|
|
5165
|
+
}
|
|
5166
|
+
else if (noopIndexes.length > 1) {
|
|
5167
|
+
for (const index of noopIndexes.slice(1)) {
|
|
5168
|
+
const expression = extracted.expressions.find((item) => item.index === index);
|
|
5169
|
+
rejected.push({
|
|
5170
|
+
index,
|
|
5171
|
+
raw: expression?.raw ?? "NOOP",
|
|
5172
|
+
error: "Curation response can contain at most one NOOP expression.",
|
|
5173
|
+
source_key: null,
|
|
5174
|
+
});
|
|
5175
|
+
}
|
|
5176
|
+
acceptedExpressions.push("NOOP # no asset curation changes");
|
|
5177
|
+
}
|
|
5178
|
+
else if (noopIndexes.length === 1) {
|
|
5179
|
+
const expression = extracted.expressions.find((item) => item.index === noopIndexes[0]);
|
|
5180
|
+
acceptedExpressions.push(expression?.raw ?? "NOOP # no asset curation changes");
|
|
5181
|
+
}
|
|
5182
|
+
return {
|
|
5183
|
+
version: 3,
|
|
5184
|
+
format: "asset-curation-exp-v1",
|
|
5185
|
+
expression_text: rawText,
|
|
5186
|
+
expressions: acceptedExpressions.length > 0 ? acceptedExpressions : extracted.expressions.map((item) => item.raw),
|
|
5187
|
+
accepted_expressions: acceptedExpressions,
|
|
5188
|
+
rejected_expressions: rejected,
|
|
5189
|
+
decisions,
|
|
5190
|
+
partial: rejected.length > 0,
|
|
5191
|
+
};
|
|
5192
|
+
}
|
|
5193
|
+
export function parseAssetCurationExpressions(rawText) {
|
|
5194
|
+
const parsed = parseAssetCurationExpressionsTolerant(rawText);
|
|
5195
|
+
const rejected = asList(parsed["rejected_expressions"]).filter(isDict);
|
|
5196
|
+
if (rejected.length > 0) {
|
|
5197
|
+
const first = rejected[0];
|
|
5198
|
+
curationBlocked(strOf(first["error"]) || "Curation expression parse failed.", [strOf(first["raw"]).slice(0, 200)]);
|
|
5199
|
+
}
|
|
5200
|
+
const expressions = asList(parsed["accepted_expressions"]);
|
|
5201
|
+
const decisions = asList(parsed["decisions"]).filter(isDict);
|
|
5202
|
+
return {
|
|
5203
|
+
version: 3,
|
|
5204
|
+
format: "asset-curation-exp-v1",
|
|
5205
|
+
expression_text: rawText,
|
|
5206
|
+
expressions,
|
|
5207
|
+
decisions,
|
|
5208
|
+
};
|
|
5209
|
+
}
|
|
5210
|
+
function renderAssetCurationDecisionExpression(decision) {
|
|
5211
|
+
const kind = strOf(decision["kind"]);
|
|
5212
|
+
const sourceId = strOf(decision["source_id"]);
|
|
5213
|
+
const action = strOf(decision["decision"]);
|
|
5214
|
+
const reason = strOf(decision["reason"]).trim() || "curation decision";
|
|
5215
|
+
if (action === "merge")
|
|
5216
|
+
return `MERGE ${kind} ${sourceId} -> ${strOf(decision["target_id"])} # ${reason}`;
|
|
5217
|
+
if (action === "drop")
|
|
5218
|
+
return `DROP ${kind} ${sourceId} # ${reason}`;
|
|
5219
|
+
if (action === "keep")
|
|
5220
|
+
return `KEEP ${kind} ${sourceId} # ${reason}`;
|
|
5221
|
+
if (action === "rename")
|
|
5222
|
+
return `RENAME ${kind} ${sourceId} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
|
|
5223
|
+
if (action === "move_to_state") {
|
|
5224
|
+
return `STATE ${kind} ${sourceId} -> ${strOf(decision["target_kind"])} ${strOf(decision["target_id"])} ${quoteCurationExpressionString(decision["state_name"])} # ${reason}`;
|
|
5225
|
+
}
|
|
5226
|
+
if (action === "move_to_speaker") {
|
|
5227
|
+
return `SPEAKER ${kind} ${sourceId} -> ${strOf(decision["speaker_kind"])} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
|
|
5228
|
+
}
|
|
5229
|
+
if (action === "move_to_prop") {
|
|
5230
|
+
const target = strOf(decision["target_id"]).trim() || "NEW";
|
|
5231
|
+
return `PROP ${kind} ${sourceId} -> ${target} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
|
|
5232
|
+
}
|
|
5233
|
+
curationBlocked("Cannot render unknown curation decision.", [`decision: ${action || "<empty>"}`]);
|
|
5234
|
+
}
|
|
5235
|
+
export function formatAssetCurationDecisionExpressions(plan) {
|
|
5236
|
+
const decisions = asList(plan["decisions"]).filter(isDict);
|
|
5237
|
+
if (decisions.length === 0)
|
|
5238
|
+
return "<exp>NOOP # no asset curation changes</exp>";
|
|
5239
|
+
return `${decisions.map((decision) => `<exp>${renderAssetCurationDecisionExpression(decision)}</exp>`).join("\n")}\n`;
|
|
5240
|
+
}
|
|
5241
|
+
export function combineAssetCurationExpressionPlans(plans) {
|
|
5242
|
+
const decisions = plans.flatMap((plan) => asList(plan["decisions"]).filter(isDict));
|
|
5243
|
+
const rawExpressions = plans.flatMap((plan) => asList(plan["expressions"]));
|
|
5244
|
+
const actionableExpressions = rawExpressions.filter((expression) => !/^NOOP(?:\s|#|$)/.test(expression.trim()));
|
|
5245
|
+
const expressions = actionableExpressions.length > 0 ? actionableExpressions : ["NOOP # no asset curation changes"];
|
|
5246
|
+
return {
|
|
5247
|
+
version: 3,
|
|
5248
|
+
format: "asset-curation-exp-v1",
|
|
5249
|
+
expression_text: formatAssetCurationDecisionExpressions({ decisions }),
|
|
5250
|
+
expressions,
|
|
5251
|
+
decisions,
|
|
5252
|
+
};
|
|
5253
|
+
}
|
|
5254
|
+
export function combinePrimaryAssetCurationPlans(plans) {
|
|
5255
|
+
const combined = combineAssetCurationExpressionPlans(plans);
|
|
5256
|
+
const expressionTextByPlan = plans
|
|
5257
|
+
.map((plan) => strOf(plan["expression_text"]).trim())
|
|
5258
|
+
.filter((text) => text);
|
|
5259
|
+
return {
|
|
5260
|
+
...combined,
|
|
5261
|
+
primary_parts: plans.map((plan) => ({
|
|
5262
|
+
expression_text: strOf(plan["expression_text"]),
|
|
5263
|
+
expressions: asList(plan["expressions"]),
|
|
5264
|
+
decision_count: asList(plan["decisions"]).filter(isDict).length,
|
|
5265
|
+
})),
|
|
5266
|
+
primary_part_expression_text: expressionTextByPlan.join("\n"),
|
|
5267
|
+
};
|
|
5268
|
+
}
|
|
5269
|
+
export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = []) {
|
|
5270
|
+
const primaryDecisions = asList(primaryPlan["decisions"]).filter(isDict);
|
|
5271
|
+
const auditDecisions = asList(auditPlan["decisions"]).filter(isDict);
|
|
5272
|
+
const primaryKeys = curationDecisionSourceSet(primaryDecisions);
|
|
5273
|
+
const allowedNewKeys = new Set(allowedNewAuditSources);
|
|
5274
|
+
const invalidAuditSources = [];
|
|
5275
|
+
let addedAuditDecisions = 0;
|
|
5276
|
+
for (const decision of auditDecisions) {
|
|
5277
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
5278
|
+
if (primaryKeys.has(key))
|
|
5279
|
+
continue;
|
|
5280
|
+
if (allowedNewKeys.has(key)) {
|
|
5281
|
+
addedAuditDecisions += 1;
|
|
5282
|
+
continue;
|
|
5283
|
+
}
|
|
5284
|
+
invalidAuditSources.push(key);
|
|
5285
|
+
}
|
|
5286
|
+
if (invalidAuditSources.length > 0) {
|
|
5287
|
+
curationBlocked("Audit curation expressions may only override primary decision sources or listed missing required sources.", invalidAuditSources);
|
|
5288
|
+
}
|
|
5289
|
+
const primaryExpressions = asList(primaryPlan["expressions"]);
|
|
5290
|
+
const auditExpressions = asList(auditPlan["expressions"]);
|
|
5291
|
+
return {
|
|
5292
|
+
version: 3,
|
|
5293
|
+
format: "asset-curation-exp-v1",
|
|
5294
|
+
primary_expression_text: strOf(primaryPlan["expression_text"]),
|
|
5295
|
+
audit_expression_text: strOf(auditPlan["expression_text"]),
|
|
5296
|
+
expression_text: [strOf(primaryPlan["expression_text"]).trim(), strOf(auditPlan["expression_text"]).trim()].filter((text) => text).join("\n"),
|
|
5297
|
+
primary_expressions: primaryExpressions,
|
|
5298
|
+
audit_expressions: auditExpressions,
|
|
5299
|
+
expressions: [...primaryExpressions, ...auditExpressions],
|
|
5300
|
+
decisions: [...primaryDecisions, ...auditDecisions],
|
|
5301
|
+
audit_summary: {
|
|
5302
|
+
primary_decisions: primaryDecisions.length,
|
|
5303
|
+
audit_decisions: auditDecisions.length,
|
|
5304
|
+
audit_added_decisions: addedAuditDecisions,
|
|
5305
|
+
audit_noop: auditDecisions.length === 0,
|
|
5306
|
+
},
|
|
5307
|
+
};
|
|
5308
|
+
}
|
|
5309
|
+
function normalizeCurationDecision(item) {
|
|
5310
|
+
const kind = strOf(item["kind"]);
|
|
5311
|
+
const sourceId = strOf(item["source_id"]);
|
|
5312
|
+
const decision = strOf(item["decision"]);
|
|
5313
|
+
const reason = strOf(item["reason"]).trim();
|
|
5314
|
+
if (kind !== "actor" && kind !== "location" && kind !== "prop")
|
|
5315
|
+
curationBlocked("Unknown curation kind.", [`kind: ${kind || "<empty>"}`]);
|
|
5316
|
+
if (!sourceId)
|
|
5317
|
+
curationBlocked("Curation decision is missing source_id.", [`decision: ${decision || "<empty>"}`]);
|
|
5318
|
+
const validDecisions = new Set(["keep", "merge", "drop", "move_to_speaker", "move_to_prop", "move_to_state", "rename"]);
|
|
5319
|
+
if (!validDecisions.has(decision))
|
|
5320
|
+
curationBlocked("Unknown curation decision.", [`decision: ${decision || "<empty>"}`]);
|
|
5321
|
+
if (!reason)
|
|
5322
|
+
curationBlocked("Curation decision is missing reason.", [`${kind}:${sourceId}`]);
|
|
5323
|
+
return {
|
|
5324
|
+
kind,
|
|
5325
|
+
source_id: sourceId,
|
|
5326
|
+
decision,
|
|
5327
|
+
target_id: strOf(item["target_id"]).trim(),
|
|
5328
|
+
target_kind: strOf(item["target_kind"]).trim(),
|
|
5329
|
+
new_name: cleanName(item["new_name"]),
|
|
5330
|
+
speaker_kind: strOf(item["speaker_kind"]).trim(),
|
|
5331
|
+
state_name: strOf(item["state_name"]).trim(),
|
|
5332
|
+
reason,
|
|
5333
|
+
};
|
|
5334
|
+
}
|
|
5335
|
+
function assertAssetExists(script, kind, id) {
|
|
5336
|
+
const asset = assetMapById(script, kind).get(id);
|
|
5337
|
+
if (!asset)
|
|
5338
|
+
curationBlocked("Curation decision references an unknown asset.", [`${kind}:${id}`]);
|
|
5339
|
+
return asset;
|
|
5340
|
+
}
|
|
5341
|
+
function assertMergeAcyclic(decisions) {
|
|
5342
|
+
const edges = new Map();
|
|
5343
|
+
for (const decision of decisions) {
|
|
5344
|
+
if (decision["decision"] !== "merge")
|
|
5345
|
+
continue;
|
|
5346
|
+
const kind = strOf(decision["kind"]);
|
|
5347
|
+
const sourceId = strOf(decision["source_id"]);
|
|
5348
|
+
const targetId = strOf(decision["target_id"]);
|
|
5349
|
+
if (sourceId && targetId)
|
|
5350
|
+
edges.set(`${kind}:${sourceId}`, `${kind}:${targetId}`);
|
|
5351
|
+
}
|
|
5352
|
+
for (const start of edges.keys()) {
|
|
5353
|
+
const seen = new Set();
|
|
5354
|
+
let cursor = start;
|
|
5355
|
+
while (edges.has(cursor)) {
|
|
5356
|
+
if (seen.has(cursor))
|
|
5357
|
+
curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${cursor}`]);
|
|
5358
|
+
seen.add(cursor);
|
|
5359
|
+
cursor = edges.get(cursor);
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
5362
|
+
}
|
|
5363
|
+
function isAssetKindValue(value) {
|
|
5364
|
+
return value === "actor" || value === "location" || value === "prop";
|
|
5365
|
+
}
|
|
5366
|
+
function curationSourceKey(kind, sourceId) {
|
|
5367
|
+
return `${kind}:${sourceId}`;
|
|
5368
|
+
}
|
|
5369
|
+
function buildCurationDecisionIndex(decisions) {
|
|
5370
|
+
const decisionBySource = new Map();
|
|
5371
|
+
const bySource = new Map();
|
|
5372
|
+
for (const decision of decisions) {
|
|
5373
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
5374
|
+
if (decisionBySource.has(key))
|
|
5375
|
+
curationBlocked("Curation plan has duplicate decisions for one source asset.", [key]);
|
|
5376
|
+
decisionBySource.set(key, strOf(decision["decision"]));
|
|
5377
|
+
bySource.set(key, decision);
|
|
5378
|
+
}
|
|
5379
|
+
return bySource;
|
|
5380
|
+
}
|
|
5381
|
+
function dedupeCurationDecisions(decisions) {
|
|
5382
|
+
const lastIndexBySource = new Map();
|
|
5383
|
+
const duplicateCounts = new Map();
|
|
5384
|
+
for (let index = 0; index < decisions.length; index++) {
|
|
5385
|
+
const decision = decisions[index];
|
|
5386
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
5387
|
+
if (lastIndexBySource.has(key))
|
|
5388
|
+
duplicateCounts.set(key, (duplicateCounts.get(key) ?? 1) + 1);
|
|
5389
|
+
lastIndexBySource.set(key, index);
|
|
5390
|
+
}
|
|
5391
|
+
const out = [];
|
|
5392
|
+
for (let index = 0; index < decisions.length; index++) {
|
|
5393
|
+
const decision = decisions[index];
|
|
5394
|
+
const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
5395
|
+
if (lastIndexBySource.get(key) !== index)
|
|
5396
|
+
continue;
|
|
5397
|
+
if (duplicateCounts.has(key)) {
|
|
5398
|
+
appendCurationNormalizationReason(decision, "deterministic duplicate normalization kept the last decision for this source asset.");
|
|
5399
|
+
}
|
|
5400
|
+
out.push(decision);
|
|
5401
|
+
}
|
|
5402
|
+
return out;
|
|
5403
|
+
}
|
|
5404
|
+
function mergeTargetKind(decision, fallbackKind) {
|
|
5405
|
+
const raw = strOf(decision["target_kind"]) || fallbackKind;
|
|
5406
|
+
return isAssetKindValue(raw) ? raw : null;
|
|
5407
|
+
}
|
|
5408
|
+
function chooseCanonicalMergeTarget(script, kind, ids) {
|
|
5409
|
+
const usage = sceneAssetUsage(script, assetListKey(kind), assetIdKey(kind));
|
|
5410
|
+
const byId = assetMapById(script, kind);
|
|
5411
|
+
const sorted = [...ids].sort((left, right) => {
|
|
5412
|
+
const leftUsage = usage.get(left) ?? { episodes: new Set(), scenes: new Set() };
|
|
5413
|
+
const rightUsage = usage.get(right) ?? { episodes: new Set(), scenes: new Set() };
|
|
5414
|
+
const sceneDelta = rightUsage.scenes.size - leftUsage.scenes.size;
|
|
5415
|
+
if (sceneDelta !== 0)
|
|
5416
|
+
return sceneDelta;
|
|
5417
|
+
const episodeDelta = rightUsage.episodes.size - leftUsage.episodes.size;
|
|
5418
|
+
if (episodeDelta !== 0)
|
|
5419
|
+
return episodeDelta;
|
|
5420
|
+
const leftName = strOf(byId.get(left)?.[assetNameKey(kind)]).trim();
|
|
5421
|
+
const rightName = strOf(byId.get(right)?.[assetNameKey(kind)]).trim();
|
|
5422
|
+
const lengthDelta = leftName.length - rightName.length;
|
|
5423
|
+
if (lengthDelta !== 0)
|
|
5424
|
+
return lengthDelta;
|
|
5425
|
+
return left.localeCompare(right);
|
|
5426
|
+
});
|
|
5427
|
+
return sorted[0] ?? ids[0] ?? "";
|
|
5428
|
+
}
|
|
5429
|
+
function normalizeMergeCycles(script, decisions) {
|
|
5430
|
+
const decisionsBySource = buildCurationDecisionIndex(decisions);
|
|
5431
|
+
let changed = true;
|
|
5432
|
+
while (changed) {
|
|
5433
|
+
changed = false;
|
|
5434
|
+
for (const decision of decisions) {
|
|
5435
|
+
if (decision["decision"] !== "merge")
|
|
5436
|
+
continue;
|
|
5437
|
+
const kindRaw = strOf(decision["kind"]);
|
|
5438
|
+
if (!isAssetKindValue(kindRaw))
|
|
5439
|
+
continue;
|
|
5440
|
+
const path = [];
|
|
5441
|
+
const seenIndex = new Map();
|
|
5442
|
+
let cursor = curationSourceKey(kindRaw, strOf(decision["source_id"]));
|
|
5443
|
+
while (cursor) {
|
|
5444
|
+
if (seenIndex.has(cursor)) {
|
|
5445
|
+
const cycleKeys = path.slice(seenIndex.get(cursor));
|
|
5446
|
+
const cycleIds = cycleKeys.map((key) => key.split(":")[1] ?? "").filter((id) => id);
|
|
5447
|
+
const canonicalId = chooseCanonicalMergeTarget(script, kindRaw, cycleIds);
|
|
5448
|
+
for (const key of cycleKeys) {
|
|
5449
|
+
const cycleDecision = decisionsBySource.get(key);
|
|
5450
|
+
if (!cycleDecision)
|
|
5451
|
+
continue;
|
|
5452
|
+
const sourceId = strOf(cycleDecision["source_id"]);
|
|
5453
|
+
if (sourceId === canonicalId) {
|
|
5454
|
+
cycleDecision["decision"] = "keep";
|
|
5455
|
+
cycleDecision["target_id"] = "";
|
|
5456
|
+
cycleDecision["target_kind"] = "";
|
|
5457
|
+
cycleDecision["reason"] = `${strOf(cycleDecision["reason"])};deterministic cycle normalization kept the canonical target.`;
|
|
5458
|
+
ensureNormalizedPropKeepCategory(cycleDecision, "deterministic merge-cycle canonical prop.");
|
|
5459
|
+
}
|
|
5460
|
+
else {
|
|
5461
|
+
cycleDecision["target_kind"] = kindRaw;
|
|
5462
|
+
cycleDecision["target_id"] = canonicalId;
|
|
5463
|
+
cycleDecision["reason"] = `${strOf(cycleDecision["reason"])};deterministic cycle normalization selected ${kindRaw}:${canonicalId}.`;
|
|
5464
|
+
}
|
|
5465
|
+
}
|
|
5466
|
+
changed = true;
|
|
5467
|
+
break;
|
|
5468
|
+
}
|
|
5469
|
+
seenIndex.set(cursor, path.length);
|
|
5470
|
+
path.push(cursor);
|
|
5471
|
+
const current = decisionsBySource.get(cursor);
|
|
5472
|
+
if (!current || current["decision"] !== "merge")
|
|
5473
|
+
break;
|
|
5474
|
+
const targetKind = mergeTargetKind(current, kindRaw);
|
|
5475
|
+
if (targetKind !== kindRaw)
|
|
5476
|
+
break;
|
|
5477
|
+
const targetId = strOf(current["target_id"]);
|
|
5478
|
+
if (!targetId)
|
|
5479
|
+
break;
|
|
5480
|
+
cursor = curationSourceKey(kindRaw, targetId);
|
|
5481
|
+
}
|
|
5482
|
+
if (changed)
|
|
5483
|
+
break;
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
}
|
|
5487
|
+
function resolveStableCurationTarget(decisionsBySource, targetKind, targetId, sourceLabel) {
|
|
5488
|
+
let cursor = targetId;
|
|
5489
|
+
const seen = new Set();
|
|
5490
|
+
while (cursor) {
|
|
5491
|
+
const key = curationSourceKey(targetKind, cursor);
|
|
5492
|
+
if (seen.has(key))
|
|
5493
|
+
curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${key}`]);
|
|
5494
|
+
seen.add(key);
|
|
5495
|
+
const targetDecision = decisionsBySource.get(key);
|
|
5496
|
+
if (!targetDecision)
|
|
5497
|
+
return cursor;
|
|
5498
|
+
const action = strOf(targetDecision["decision"]);
|
|
5499
|
+
if (action === "keep" || action === "rename")
|
|
5500
|
+
return cursor;
|
|
5501
|
+
if (isSelfStateDecision(targetDecision))
|
|
5502
|
+
return cursor;
|
|
5503
|
+
if (action !== "merge") {
|
|
5504
|
+
curationBlocked("Curation target is not stable.", [`${sourceLabel} -> ${targetKind}:${targetId} (${action})`]);
|
|
5505
|
+
}
|
|
5506
|
+
const nextKind = strOf(targetDecision["target_kind"]) || targetKind;
|
|
5507
|
+
if (nextKind !== targetKind) {
|
|
5508
|
+
curationBlocked("merge decision target_kind must match source kind.", [`${targetKind}:${cursor} -> ${nextKind}:${targetDecision["target_id"]}`]);
|
|
5509
|
+
}
|
|
5510
|
+
const nextId = strOf(targetDecision["target_id"]);
|
|
5511
|
+
if (!nextId || nextId === cursor) {
|
|
5512
|
+
curationBlocked("merge decision requires a different target_id.", [`${targetKind}:${cursor}`]);
|
|
5513
|
+
}
|
|
5514
|
+
cursor = nextId;
|
|
5515
|
+
}
|
|
5516
|
+
return targetId;
|
|
5517
|
+
}
|
|
5518
|
+
function isSelfStateDecision(decision) {
|
|
5519
|
+
if (strOf(decision["decision"]) !== "move_to_state")
|
|
5520
|
+
return false;
|
|
5521
|
+
const kind = strOf(decision["kind"]);
|
|
5522
|
+
const sourceId = strOf(decision["source_id"]);
|
|
5523
|
+
const targetKind = strOf(decision["target_kind"]);
|
|
5524
|
+
const targetId = strOf(decision["target_id"]);
|
|
5525
|
+
return kind === targetKind && sourceId === targetId && isAssetKindValue(kind) && sourceId.length > 0;
|
|
5526
|
+
}
|
|
5527
|
+
function resolveStableCurationStateTarget(decisionsBySource, targetKind, targetId, sourceLabel) {
|
|
5528
|
+
let cursorKind = targetKind;
|
|
5529
|
+
let cursorId = targetId;
|
|
5530
|
+
const seen = new Set();
|
|
5531
|
+
while (cursorId) {
|
|
5532
|
+
const key = curationSourceKey(cursorKind, cursorId);
|
|
5533
|
+
if (seen.has(key))
|
|
5534
|
+
curationBlocked("Curation state target plan contains a cycle.", [`cycle at ${key}`]);
|
|
5535
|
+
seen.add(key);
|
|
5536
|
+
const targetDecision = decisionsBySource.get(key);
|
|
5537
|
+
if (!targetDecision)
|
|
5538
|
+
return { kind: cursorKind, id: cursorId };
|
|
5539
|
+
const action = strOf(targetDecision["decision"]);
|
|
5540
|
+
if (action === "keep" || action === "rename")
|
|
5541
|
+
return { kind: cursorKind, id: cursorId };
|
|
5542
|
+
if (isSelfStateDecision(targetDecision))
|
|
5543
|
+
return { kind: cursorKind, id: cursorId };
|
|
5544
|
+
if (action === "merge") {
|
|
5545
|
+
const nextKind = strOf(targetDecision["target_kind"]) || cursorKind;
|
|
5546
|
+
if (nextKind !== cursorKind) {
|
|
5547
|
+
curationBlocked("merge decision target_kind must match source kind.", [`${cursorKind}:${cursorId} -> ${nextKind}:${targetDecision["target_id"]}`]);
|
|
5548
|
+
}
|
|
5549
|
+
const nextId = strOf(targetDecision["target_id"]);
|
|
5550
|
+
if (!nextId || nextId === cursorId) {
|
|
5551
|
+
curationBlocked("merge decision requires a different target_id.", [`${cursorKind}:${cursorId}`]);
|
|
5552
|
+
}
|
|
5553
|
+
cursorId = nextId;
|
|
5554
|
+
continue;
|
|
5555
|
+
}
|
|
5556
|
+
if (action === "move_to_state") {
|
|
5557
|
+
const nextKind = strOf(targetDecision["target_kind"]);
|
|
5558
|
+
const nextId = strOf(targetDecision["target_id"]);
|
|
5559
|
+
if (!isAssetKindValue(nextKind) || !nextId) {
|
|
5560
|
+
curationBlocked("move_to_state requires target_kind and target_id.", [`${sourceLabel} -> ${nextKind}:${nextId}`]);
|
|
5561
|
+
}
|
|
5562
|
+
if (nextKind === cursorKind && nextId === cursorId) {
|
|
5563
|
+
curationBlocked("move_to_state requires a different target asset.", [`${cursorKind}:${cursorId}`]);
|
|
5564
|
+
}
|
|
5565
|
+
cursorKind = nextKind;
|
|
5566
|
+
cursorId = nextId;
|
|
5567
|
+
continue;
|
|
5568
|
+
}
|
|
5569
|
+
curationBlocked("Curation state target is not stable.", [`${sourceLabel} -> ${cursorKind}:${targetId} (${action})`]);
|
|
5570
|
+
}
|
|
5571
|
+
return { kind: targetKind, id: targetId };
|
|
5572
|
+
}
|
|
5573
|
+
function appendCurationNormalizationReason(decision, detail) {
|
|
5574
|
+
const reason = strOf(decision["reason"]).trim();
|
|
5575
|
+
decision["reason"] = reason ? `${reason};${detail}` : detail;
|
|
5576
|
+
}
|
|
5577
|
+
function ensureNormalizedPropKeepCategory(decision, detail) {
|
|
5578
|
+
if (strOf(decision["kind"]) !== "prop" || strOf(decision["decision"]) !== "keep")
|
|
5579
|
+
return;
|
|
5580
|
+
if (propKeepCategory(decision["reason"]))
|
|
5581
|
+
return;
|
|
5582
|
+
const reason = strOf(decision["reason"]).trim();
|
|
5583
|
+
decision["reason"] = `category=signature_instance; ${detail}${reason ? ` (${reason})` : ""}`;
|
|
5584
|
+
}
|
|
5585
|
+
function clearCurationTargetFields(decision) {
|
|
5586
|
+
decision["target_id"] = "";
|
|
5587
|
+
decision["target_kind"] = "";
|
|
5588
|
+
}
|
|
5589
|
+
function normalizeMergeToDownstreamAction(decisionsBySource, decision, targetDecision, sourceLabel) {
|
|
5590
|
+
const downstreamAction = strOf(targetDecision["decision"]);
|
|
5591
|
+
if (downstreamAction === "drop") {
|
|
5592
|
+
decision["decision"] = "drop";
|
|
5593
|
+
clearCurationTargetFields(decision);
|
|
5594
|
+
appendCurationNormalizationReason(decision, "deterministic target normalization: merge target is dropped, so source is dropped too.");
|
|
5595
|
+
return true;
|
|
5596
|
+
}
|
|
5597
|
+
if (downstreamAction === "move_to_speaker") {
|
|
5598
|
+
decision["decision"] = "move_to_speaker";
|
|
5599
|
+
clearCurationTargetFields(decision);
|
|
5600
|
+
decision["speaker_kind"] = targetDecision["speaker_kind"];
|
|
5601
|
+
decision["new_name"] = targetDecision["new_name"];
|
|
5602
|
+
appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to speaker, so source follows.");
|
|
5603
|
+
return true;
|
|
5604
|
+
}
|
|
5605
|
+
if (downstreamAction === "move_to_prop") {
|
|
5606
|
+
const targetKind = strOf(targetDecision["target_kind"]) || "prop";
|
|
5607
|
+
if (targetKind !== "prop") {
|
|
5608
|
+
curationBlocked("move_to_prop target_kind must be prop.", [`${sourceLabel} -> ${targetKind}:${targetDecision["target_id"]}`]);
|
|
5609
|
+
}
|
|
5610
|
+
const targetId = strOf(targetDecision["target_id"]);
|
|
5611
|
+
decision["decision"] = "move_to_prop";
|
|
5612
|
+
decision["target_kind"] = "prop";
|
|
5613
|
+
decision["target_id"] = targetId
|
|
5614
|
+
? resolveStableCurationTarget(decisionsBySource, "prop", targetId, sourceLabel)
|
|
5615
|
+
: "";
|
|
5616
|
+
decision["new_name"] = targetDecision["new_name"];
|
|
5617
|
+
appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to prop, so source follows.");
|
|
5618
|
+
return true;
|
|
5619
|
+
}
|
|
5620
|
+
if (downstreamAction === "move_to_state") {
|
|
5621
|
+
const targetKind = strOf(targetDecision["target_kind"]);
|
|
5622
|
+
const targetId = strOf(targetDecision["target_id"]);
|
|
5623
|
+
if (!isAssetKindValue(targetKind) || !targetId) {
|
|
5624
|
+
curationBlocked("move_to_state requires target_kind and target_id.", [`${sourceLabel} -> ${targetKind}:${targetId}`]);
|
|
5625
|
+
}
|
|
5626
|
+
decision["decision"] = "move_to_state";
|
|
5627
|
+
decision["target_kind"] = targetKind;
|
|
5628
|
+
decision["target_id"] = resolveStableCurationTarget(decisionsBySource, targetKind, targetId, sourceLabel);
|
|
5629
|
+
decision["state_name"] = targetDecision["state_name"];
|
|
5630
|
+
decision["new_name"] = targetDecision["new_name"];
|
|
5631
|
+
appendCurationNormalizationReason(decision, "deterministic target normalization: merge target moves to state, so source follows.");
|
|
5632
|
+
return true;
|
|
5633
|
+
}
|
|
5634
|
+
return false;
|
|
5635
|
+
}
|
|
5636
|
+
function findMergeDownstreamAction(decisionsBySource, targetKind, targetId) {
|
|
5637
|
+
let cursor = targetId;
|
|
5638
|
+
const seen = new Set();
|
|
5639
|
+
while (cursor) {
|
|
5640
|
+
const key = curationSourceKey(targetKind, cursor);
|
|
5641
|
+
if (seen.has(key))
|
|
5642
|
+
curationBlocked("Curation merge plan contains a cycle.", [`cycle at ${key}`]);
|
|
5643
|
+
seen.add(key);
|
|
5644
|
+
const targetDecision = decisionsBySource.get(key);
|
|
5645
|
+
if (!targetDecision)
|
|
5646
|
+
return null;
|
|
5647
|
+
const targetAction = strOf(targetDecision["decision"]);
|
|
5648
|
+
if (targetAction === "keep" || targetAction === "rename")
|
|
5649
|
+
return null;
|
|
5650
|
+
if (isSelfStateDecision(targetDecision))
|
|
5651
|
+
return null;
|
|
5652
|
+
if (targetAction !== "merge")
|
|
5653
|
+
return targetDecision;
|
|
5654
|
+
const nextKind = strOf(targetDecision["target_kind"]) || targetKind;
|
|
5655
|
+
if (nextKind !== targetKind) {
|
|
5656
|
+
curationBlocked("merge decision target_kind must match source kind.", [`${targetKind}:${cursor} -> ${nextKind}:${targetDecision["target_id"]}`]);
|
|
5657
|
+
}
|
|
5658
|
+
const nextId = strOf(targetDecision["target_id"]);
|
|
5659
|
+
if (!nextId || nextId === cursor) {
|
|
5660
|
+
curationBlocked("merge decision requires a different target_id.", [`${targetKind}:${cursor}`]);
|
|
5661
|
+
}
|
|
5662
|
+
cursor = nextId;
|
|
5663
|
+
}
|
|
5664
|
+
return null;
|
|
5665
|
+
}
|
|
5666
|
+
function normalizeCurationPlanTargets(script, decisions) {
|
|
5667
|
+
normalizeMergeCycles(script, decisions);
|
|
5668
|
+
const decisionsBySource = buildCurationDecisionIndex(decisions);
|
|
5669
|
+
for (const decision of decisions) {
|
|
5670
|
+
const action = strOf(decision["decision"]);
|
|
5671
|
+
if (action !== "merge" && action !== "move_to_state" && action !== "move_to_prop")
|
|
5672
|
+
continue;
|
|
5673
|
+
const targetId = strOf(decision["target_id"]);
|
|
5674
|
+
if (!targetId)
|
|
5675
|
+
continue;
|
|
5676
|
+
const targetKindRaw = strOf(decision["target_kind"]) || (action === "move_to_prop" ? "prop" : strOf(decision["kind"]));
|
|
5677
|
+
if (!isAssetKindValue(targetKindRaw))
|
|
5678
|
+
continue;
|
|
5679
|
+
const sourceLabel = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
|
|
5680
|
+
if (action === "merge") {
|
|
5681
|
+
const downstreamAction = findMergeDownstreamAction(decisionsBySource, targetKindRaw, targetId);
|
|
5682
|
+
if (downstreamAction && normalizeMergeToDownstreamAction(decisionsBySource, decision, downstreamAction, sourceLabel)) {
|
|
5683
|
+
continue;
|
|
5684
|
+
}
|
|
5685
|
+
}
|
|
5686
|
+
if (action === "move_to_state") {
|
|
5687
|
+
const resolved = resolveStableCurationStateTarget(decisionsBySource, targetKindRaw, targetId, sourceLabel);
|
|
5688
|
+
if (resolved.kind !== targetKindRaw || resolved.id !== targetId) {
|
|
5689
|
+
appendCurationNormalizationReason(decision, `deterministic state target normalization: target resolved to ${resolved.kind}:${resolved.id}.`);
|
|
5690
|
+
}
|
|
5691
|
+
decision["target_kind"] = resolved.kind;
|
|
5692
|
+
decision["target_id"] = resolved.id;
|
|
2927
5693
|
continue;
|
|
2928
|
-
addLocationAlias(target, strOf(old["location_name"]));
|
|
2929
|
-
for (const alias of asList(old["aliases"])) {
|
|
2930
|
-
addLocationAlias(target, strOf(alias));
|
|
2931
5694
|
}
|
|
5695
|
+
const resolvedTargetId = resolveStableCurationTarget(decisionsBySource, targetKindRaw, targetId, sourceLabel);
|
|
5696
|
+
decision["target_kind"] = targetKindRaw;
|
|
5697
|
+
decision["target_id"] = resolvedTargetId;
|
|
2932
5698
|
}
|
|
2933
|
-
script["locations"] = locations.filter((loc) => isDict(loc) && !replacements.has(strOf(loc["location_id"])));
|
|
2934
|
-
}
|
|
2935
|
-
function sceneCountFromUsage(usage) {
|
|
2936
|
-
return usage.scenes.size;
|
|
2937
5699
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
5700
|
+
function rewriteSpeakerRefs(value, speakerReplacements) {
|
|
5701
|
+
if (!isList(value) || speakerReplacements.size === 0)
|
|
5702
|
+
return value;
|
|
5703
|
+
const out = [];
|
|
5704
|
+
const seen = new Set();
|
|
5705
|
+
for (const item of asList(value)) {
|
|
5706
|
+
if (!isDict(item))
|
|
2943
5707
|
continue;
|
|
2944
|
-
const
|
|
2945
|
-
const
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
5708
|
+
const raw = strOf(item["speaker_id"]);
|
|
5709
|
+
const speakerId = speakerReplacements.get(raw) ?? raw;
|
|
5710
|
+
if (!speakerId || seen.has(speakerId))
|
|
5711
|
+
continue;
|
|
5712
|
+
seen.add(speakerId);
|
|
5713
|
+
out.push({ ...item, speaker_id: speakerId });
|
|
2950
5714
|
}
|
|
2951
|
-
return
|
|
5715
|
+
return out;
|
|
2952
5716
|
}
|
|
2953
|
-
|
|
2954
|
-
const
|
|
2955
|
-
const
|
|
2956
|
-
for (const
|
|
2957
|
-
if (!isDict(
|
|
5717
|
+
function compactRefsWithStateMap(refs, idKey, replacements, droppedIds, stateReplacements) {
|
|
5718
|
+
const compacted = [];
|
|
5719
|
+
const seen = new Set();
|
|
5720
|
+
for (const ref of asList(refs)) {
|
|
5721
|
+
if (!isDict(ref))
|
|
2958
5722
|
continue;
|
|
2959
|
-
const
|
|
2960
|
-
const
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
const
|
|
2964
|
-
|
|
5723
|
+
const rawId = strOf(ref[idKey]);
|
|
5724
|
+
const hasReplacement = replacements.has(rawId);
|
|
5725
|
+
if (!rawId || (!hasReplacement && droppedIds.has(rawId)))
|
|
5726
|
+
continue;
|
|
5727
|
+
const nextId = replacements.get(rawId) ?? rawId;
|
|
5728
|
+
const rawState = strOf(ref["state_id"]);
|
|
5729
|
+
const stateId = stateReplacements.get(rawState) ?? (nextId !== rawId ? "" : rawState);
|
|
5730
|
+
const key = `${nextId}::${stateId}`;
|
|
5731
|
+
if (seen.has(key))
|
|
5732
|
+
continue;
|
|
5733
|
+
seen.add(key);
|
|
5734
|
+
compacted.push({ [idKey]: nextId, state_id: stateId || null });
|
|
2965
5735
|
}
|
|
2966
|
-
return
|
|
5736
|
+
return compacted;
|
|
2967
5737
|
}
|
|
2968
|
-
function
|
|
5738
|
+
function rewriteCurationStateChanges(changes, replacementsByKind, droppedByKind, stateReplacements) {
|
|
2969
5739
|
const rewritten = [];
|
|
2970
5740
|
for (const change of asList(changes)) {
|
|
2971
5741
|
if (!isDict(change))
|
|
2972
5742
|
continue;
|
|
2973
5743
|
const targetKind = strOf(change["target_kind"]);
|
|
2974
|
-
|
|
2975
|
-
if (targetKind === "actor" && droppedActorIds.has(targetId))
|
|
5744
|
+
if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
|
|
2976
5745
|
continue;
|
|
2977
|
-
|
|
5746
|
+
const targetId = strOf(change["target_id"]);
|
|
5747
|
+
const nextTargetId = replacementsByKind[targetKind].get(targetId) ?? targetId;
|
|
5748
|
+
if (nextTargetId === targetId && droppedByKind[targetKind].has(targetId))
|
|
2978
5749
|
continue;
|
|
2979
|
-
const next = { ...change };
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
5750
|
+
const next = { ...change, target_id: nextTargetId };
|
|
5751
|
+
const fromStateId = strOf(change["from_state_id"]);
|
|
5752
|
+
const toStateId = strOf(change["to_state_id"]);
|
|
5753
|
+
if (fromStateId) {
|
|
5754
|
+
const mapped = stateReplacements.get(fromStateId);
|
|
5755
|
+
if (mapped)
|
|
5756
|
+
next["from_state_id"] = mapped;
|
|
5757
|
+
else if (nextTargetId !== targetId)
|
|
5758
|
+
delete next["from_state_id"];
|
|
5759
|
+
}
|
|
5760
|
+
if (toStateId) {
|
|
5761
|
+
const mapped = stateReplacements.get(toStateId);
|
|
5762
|
+
if (mapped)
|
|
5763
|
+
next["to_state_id"] = mapped;
|
|
5764
|
+
else if (nextTargetId !== targetId)
|
|
5765
|
+
delete next["to_state_id"];
|
|
2984
5766
|
}
|
|
2985
5767
|
rewritten.push(next);
|
|
2986
5768
|
}
|
|
2987
5769
|
return rewritten;
|
|
2988
5770
|
}
|
|
2989
|
-
function
|
|
5771
|
+
function rewriteCurationTransition(transition, replacementsByKind, droppedByKind) {
|
|
2990
5772
|
if (!isDict(transition))
|
|
2991
5773
|
return transition;
|
|
2992
5774
|
const targetKind = strOf(transition["target_kind"]);
|
|
5775
|
+
if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
|
|
5776
|
+
return transition;
|
|
2993
5777
|
const targetId = strOf(transition["target_id"]);
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
if (targetKind === "prop" && droppedPropIds.has(targetId))
|
|
5778
|
+
const nextTargetId = replacementsByKind[targetKind].get(targetId) ?? targetId;
|
|
5779
|
+
if (nextTargetId === targetId && droppedByKind[targetKind].has(targetId))
|
|
2997
5780
|
return null;
|
|
2998
|
-
|
|
2999
|
-
if (targetKind === "location" && locationReplacements.has(targetId)) {
|
|
3000
|
-
next["target_id"] = locationReplacements.get(targetId);
|
|
3001
|
-
}
|
|
3002
|
-
return next;
|
|
5781
|
+
return { ...transition, target_id: nextTargetId };
|
|
3003
5782
|
}
|
|
3004
|
-
function
|
|
3005
|
-
if (locationReplacements.size === 0 && droppedPropIds.size === 0 && droppedActorIds.size === 0)
|
|
3006
|
-
return;
|
|
5783
|
+
function rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateReplacements, speakerReplacements) {
|
|
3007
5784
|
for (const ep of asList(script["episodes"])) {
|
|
3008
5785
|
for (const scene of asList(ep["scenes"])) {
|
|
3009
5786
|
const ctx = sceneContext(scene);
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
if (droppedActorIds.size > 0) {
|
|
3014
|
-
ctx["actors"] = compactAssetRefs(ctx["actors"], "actor_id", new Map(), droppedActorIds);
|
|
3015
|
-
}
|
|
3016
|
-
if (droppedPropIds.size > 0) {
|
|
3017
|
-
ctx["props"] = compactAssetRefs(ctx["props"], "prop_id", new Map(), droppedPropIds);
|
|
3018
|
-
}
|
|
5787
|
+
ctx["actors"] = compactRefsWithStateMap(ctx["actors"], "actor_id", replacementsByKind.actor, droppedByKind.actor, stateReplacements);
|
|
5788
|
+
ctx["locations"] = compactRefsWithStateMap(ctx["locations"], "location_id", replacementsByKind.location, droppedByKind.location, stateReplacements);
|
|
5789
|
+
ctx["props"] = compactRefsWithStateMap(ctx["props"], "prop_id", replacementsByKind.prop, droppedByKind.prop, stateReplacements);
|
|
3019
5790
|
setSceneContext(scene, ctx);
|
|
3020
5791
|
for (const action of asList(scene["actions"])) {
|
|
3021
5792
|
if (!isDict(action))
|
|
3022
5793
|
continue;
|
|
3023
|
-
|
|
5794
|
+
const actorId = strOf(action["actor_id"]);
|
|
5795
|
+
if (replacementsByKind.actor.has(actorId))
|
|
5796
|
+
action["actor_id"] = replacementsByKind.actor.get(actorId);
|
|
5797
|
+
else if (droppedByKind.actor.has(actorId))
|
|
3024
5798
|
delete action["actor_id"];
|
|
5799
|
+
const speakerId = strOf(action["speaker_id"]);
|
|
5800
|
+
if (speakerReplacements.has(speakerId))
|
|
5801
|
+
action["speaker_id"] = speakerReplacements.get(speakerId);
|
|
5802
|
+
action["speakers"] = rewriteSpeakerRefs(action["speakers"], speakerReplacements);
|
|
5803
|
+
action["lines"] = rewriteSpeakerRefs(action["lines"], speakerReplacements);
|
|
3025
5804
|
if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
|
|
3026
|
-
action["state_changes"] =
|
|
5805
|
+
action["state_changes"] = rewriteCurationStateChanges(action["state_changes"], replacementsByKind, droppedByKind, stateReplacements);
|
|
5806
|
+
if (asList(action["state_changes"]).length === 0)
|
|
5807
|
+
delete action["state_changes"];
|
|
3027
5808
|
}
|
|
3028
5809
|
if (action["transition_prompt"] !== undefined && action["transition_prompt"] !== null) {
|
|
3029
|
-
const transition =
|
|
5810
|
+
const transition = rewriteCurationTransition(action["transition_prompt"], replacementsByKind, droppedByKind);
|
|
3030
5811
|
if (transition === null)
|
|
3031
5812
|
delete action["transition_prompt"];
|
|
3032
5813
|
else
|
|
@@ -3035,23 +5816,241 @@ function applyAssetCurationRefs(script, locationReplacements, droppedPropIds, dr
|
|
|
3035
5816
|
}
|
|
3036
5817
|
}
|
|
3037
5818
|
}
|
|
5819
|
+
}
|
|
5820
|
+
function copyStatesForMerge(script, kind, source, target, stateReplacements) {
|
|
5821
|
+
for (const state of asList(source["states"])) {
|
|
5822
|
+
const sourceStateId = strOf(state["state_id"]);
|
|
5823
|
+
const stateName = strOf(state["state_name"]).trim();
|
|
5824
|
+
if (!sourceStateId || !stateName)
|
|
5825
|
+
continue;
|
|
5826
|
+
const targetStateId = appendState(target, script, stateName, state["description"]);
|
|
5827
|
+
stateReplacements.set(sourceStateId, targetStateId);
|
|
5828
|
+
}
|
|
5829
|
+
addAssetAlias(target, kind, strOf(source[assetNameKey(kind)]));
|
|
5830
|
+
for (const alias of asList(source["aliases"]))
|
|
5831
|
+
addAssetAlias(target, kind, alias);
|
|
5832
|
+
}
|
|
5833
|
+
function upsertPropFromAsset(script, source, sourceKind, decision) {
|
|
5834
|
+
const targetId = strOf(decision["target_id"]);
|
|
5835
|
+
if (targetId) {
|
|
5836
|
+
assertAssetExists(script, "prop", targetId);
|
|
5837
|
+
return targetId;
|
|
5838
|
+
}
|
|
5839
|
+
const propId = nextAssetId(script, "prop");
|
|
5840
|
+
const name = cleanName(decision["new_name"]) || strOf(source[assetNameKey(sourceKind)]);
|
|
5841
|
+
const prop = { prop_id: propId, prop_name: name, description: source["description"] ?? "" };
|
|
5842
|
+
script["props"].push(prop);
|
|
5843
|
+
return propId;
|
|
5844
|
+
}
|
|
5845
|
+
function upsertSpeakerForMove(script, source, sourceKind, decision) {
|
|
5846
|
+
const sourceId = strOf(source[assetIdKey(sourceKind)]);
|
|
5847
|
+
const sourceKindForSpeaker = strOf(decision["speaker_kind"]) || "other";
|
|
5848
|
+
if (!SPEAKER_SOURCE_KINDS.has(sourceKindForSpeaker) || sourceKindForSpeaker === "actor" || sourceKindForSpeaker === "location" || sourceKindForSpeaker === "prop") {
|
|
5849
|
+
curationBlocked("move_to_speaker requires system/broadcast/group/other speaker_kind.", [`speaker_kind: ${sourceKindForSpeaker || "<empty>"}`]);
|
|
5850
|
+
}
|
|
5851
|
+
const displayName = cleanName(decision["new_name"]) || strOf(source[assetNameKey(sourceKind)]);
|
|
5852
|
+
const existingSameSpeaker = asList(script["speakers"]).find((speaker) => strOf(speaker["display_name"]).trim() === displayName && strOf(speaker["source_kind"]).trim() === sourceKindForSpeaker);
|
|
5853
|
+
if (existingSameSpeaker)
|
|
5854
|
+
return strOf(existingSameSpeaker["speaker_id"]);
|
|
5855
|
+
if (sourceKind === "actor") {
|
|
5856
|
+
const actorSpeakerId = speakerIdForActor(sourceId);
|
|
5857
|
+
const existingActorSpeaker = asList(script["speakers"]).find((speaker) => strOf(speaker["speaker_id"]) === actorSpeakerId);
|
|
5858
|
+
if (existingActorSpeaker) {
|
|
5859
|
+
existingActorSpeaker["source_kind"] = sourceKindForSpeaker;
|
|
5860
|
+
existingActorSpeaker["source_id"] = null;
|
|
5861
|
+
existingActorSpeaker["display_name"] = displayName;
|
|
5862
|
+
return actorSpeakerId;
|
|
5863
|
+
}
|
|
5864
|
+
}
|
|
5865
|
+
const speakerId = nextSpeakerId(script, sourceKindForSpeaker);
|
|
5866
|
+
const speaker = {
|
|
5867
|
+
speaker_id: speakerId,
|
|
5868
|
+
display_name: displayName,
|
|
5869
|
+
source_kind: sourceKindForSpeaker,
|
|
5870
|
+
source_id: null,
|
|
5871
|
+
voice_desc: "",
|
|
5872
|
+
};
|
|
5873
|
+
if (!isList(script["speakers"]))
|
|
5874
|
+
script["speakers"] = [];
|
|
5875
|
+
script["speakers"].push(speaker);
|
|
5876
|
+
return speakerId;
|
|
5877
|
+
}
|
|
5878
|
+
function replaceAssetListsAfterCuration(script, droppedByKind) {
|
|
5879
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
5880
|
+
const idKey = assetIdKey(kind);
|
|
5881
|
+
script[assetListKey(kind)] = asList(script[assetListKey(kind)]).filter((asset) => isDict(asset) && !droppedByKind[kind].has(strOf(asset[idKey])));
|
|
5882
|
+
}
|
|
5883
|
+
}
|
|
5884
|
+
function summarizeActionableCuration(decisions) {
|
|
5885
|
+
const summary = {};
|
|
5886
|
+
for (const decision of decisions) {
|
|
5887
|
+
const key = `${decision["kind"]}_${decision["decision"]}`;
|
|
5888
|
+
summary[key] = (summary[key] ?? 0) + 1;
|
|
5889
|
+
}
|
|
5890
|
+
return summary;
|
|
5891
|
+
}
|
|
5892
|
+
function applyAssetCurationDecisions(script, decisions, coverageSummary, defaultPolicy) {
|
|
5893
|
+
const actorsBefore = asList(script["actors"]).length;
|
|
5894
|
+
const locationsBefore = asList(script["locations"]).length;
|
|
5895
|
+
const propsBefore = asList(script["props"]).length;
|
|
5896
|
+
if (!isList(script["props"]))
|
|
5897
|
+
script["props"] = [];
|
|
5898
|
+
if (!isList(script["speakers"]))
|
|
5899
|
+
script["speakers"] = [];
|
|
5900
|
+
const replacementsByKind = {
|
|
5901
|
+
actor: new Map(),
|
|
5902
|
+
location: new Map(),
|
|
5903
|
+
prop: new Map(),
|
|
5904
|
+
};
|
|
5905
|
+
const droppedByKind = {
|
|
5906
|
+
actor: new Set(),
|
|
5907
|
+
location: new Set(),
|
|
5908
|
+
prop: new Set(),
|
|
5909
|
+
};
|
|
5910
|
+
const stateReplacements = new Map();
|
|
5911
|
+
const speakerReplacements = new Map();
|
|
5912
|
+
for (const decision of decisions) {
|
|
5913
|
+
const kind = strOf(decision["kind"]);
|
|
5914
|
+
const sourceId = strOf(decision["source_id"]);
|
|
5915
|
+
const source = assertAssetExists(script, kind, sourceId);
|
|
5916
|
+
const action = strOf(decision["decision"]);
|
|
5917
|
+
if (action === "rename") {
|
|
5918
|
+
const newName = cleanName(decision["new_name"]);
|
|
5919
|
+
if (!newName)
|
|
5920
|
+
curationBlocked("rename decision requires new_name.", [`${kind}:${sourceId}`]);
|
|
5921
|
+
const oldName = strOf(source[assetNameKey(kind)]);
|
|
5922
|
+
source[assetNameKey(kind)] = newName;
|
|
5923
|
+
addAssetAlias(source, kind, oldName);
|
|
5924
|
+
}
|
|
5925
|
+
else if (action === "merge") {
|
|
5926
|
+
const targetId = strOf(decision["target_id"]);
|
|
5927
|
+
const targetKind = strOf(decision["target_kind"]) || kind;
|
|
5928
|
+
if (targetKind !== kind)
|
|
5929
|
+
curationBlocked("merge decision target_kind must match source kind.", [`${kind}:${sourceId} -> ${targetKind}:${targetId}`]);
|
|
5930
|
+
if (!targetId || targetId === sourceId)
|
|
5931
|
+
curationBlocked("merge decision requires a different target_id.", [`${kind}:${sourceId}`]);
|
|
5932
|
+
const target = assertAssetExists(script, kind, targetId);
|
|
5933
|
+
copyStatesForMerge(script, kind, source, target, stateReplacements);
|
|
5934
|
+
replacementsByKind[kind].set(sourceId, targetId);
|
|
5935
|
+
droppedByKind[kind].add(sourceId);
|
|
5936
|
+
if (kind === "actor")
|
|
5937
|
+
speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(targetId));
|
|
5938
|
+
}
|
|
5939
|
+
else if (action === "drop") {
|
|
5940
|
+
droppedByKind[kind].add(sourceId);
|
|
5941
|
+
}
|
|
5942
|
+
else if (action === "move_to_prop") {
|
|
5943
|
+
const propId = upsertPropFromAsset(script, source, kind, decision);
|
|
5944
|
+
if (kind === "prop") {
|
|
5945
|
+
replacementsByKind.prop.set(sourceId, propId);
|
|
5946
|
+
}
|
|
5947
|
+
else {
|
|
5948
|
+
droppedByKind[kind].add(sourceId);
|
|
5949
|
+
}
|
|
5950
|
+
if (kind === "actor")
|
|
5951
|
+
speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(sourceId));
|
|
5952
|
+
addAssetAlias(assertAssetExists(script, "prop", propId), "prop", strOf(source[assetNameKey(kind)]));
|
|
5953
|
+
}
|
|
5954
|
+
else if (action === "move_to_speaker") {
|
|
5955
|
+
const speakerId = upsertSpeakerForMove(script, source, kind, decision);
|
|
5956
|
+
if (kind === "actor")
|
|
5957
|
+
speakerReplacements.set(speakerIdForActor(sourceId), speakerId);
|
|
5958
|
+
droppedByKind[kind].add(sourceId);
|
|
5959
|
+
}
|
|
5960
|
+
else if (action === "move_to_state") {
|
|
5961
|
+
const targetKind = strOf(decision["target_kind"]);
|
|
5962
|
+
const targetId = strOf(decision["target_id"]);
|
|
5963
|
+
if (targetKind !== "actor" && targetKind !== "location" && targetKind !== "prop")
|
|
5964
|
+
curationBlocked("move_to_state requires target_kind actor/location/prop.", [`${kind}:${sourceId}`]);
|
|
5965
|
+
const target = assertAssetExists(script, targetKind, targetId);
|
|
5966
|
+
const stateName = strOf(decision["state_name"]) || cleanName(decision["new_name"]) || strOf(source[assetNameKey(kind)]);
|
|
5967
|
+
const stateId = appendState(target, script, stateName, source["description"]);
|
|
5968
|
+
if (targetKind === kind && targetId === sourceId)
|
|
5969
|
+
continue;
|
|
5970
|
+
addAssetAlias(target, targetKind, strOf(source[assetNameKey(kind)]));
|
|
5971
|
+
for (const alias of asList(source["aliases"]))
|
|
5972
|
+
addAssetAlias(target, targetKind, alias);
|
|
5973
|
+
if (targetKind === kind)
|
|
5974
|
+
replacementsByKind[kind].set(sourceId, targetId);
|
|
5975
|
+
droppedByKind[kind].add(sourceId);
|
|
5976
|
+
for (const state of asList(source["states"])) {
|
|
5977
|
+
const sourceStateId = strOf(state["state_id"]);
|
|
5978
|
+
if (sourceStateId)
|
|
5979
|
+
stateReplacements.set(sourceStateId, stateId);
|
|
5980
|
+
}
|
|
5981
|
+
if (kind === "actor" && targetKind === "actor")
|
|
5982
|
+
speakerReplacements.set(speakerIdForActor(sourceId), speakerIdForActor(targetId));
|
|
5983
|
+
}
|
|
5984
|
+
}
|
|
5985
|
+
rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateReplacements, speakerReplacements);
|
|
5986
|
+
replaceAssetListsAfterCuration(script, droppedByKind);
|
|
5987
|
+
const replacedSpeakerIds = new Set();
|
|
5988
|
+
for (const [sourceSpeakerId, targetSpeakerId] of speakerReplacements) {
|
|
5989
|
+
if (sourceSpeakerId && targetSpeakerId && sourceSpeakerId !== targetSpeakerId)
|
|
5990
|
+
replacedSpeakerIds.add(sourceSpeakerId);
|
|
5991
|
+
}
|
|
5992
|
+
if (replacedSpeakerIds.size > 0) {
|
|
5993
|
+
script["speakers"] = asList(script["speakers"]).filter((speaker) => isDict(speaker) && !replacedSpeakerIds.has(strOf(speaker["speaker_id"])));
|
|
5994
|
+
}
|
|
3038
5995
|
for (const speaker of asList(script["speakers"])) {
|
|
3039
5996
|
if (!isDict(speaker))
|
|
3040
5997
|
continue;
|
|
3041
5998
|
const sourceKind = strOf(speaker["source_kind"]);
|
|
3042
5999
|
const sourceId = strOf(speaker["source_id"]);
|
|
3043
|
-
if (sourceKind === "actor"
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
speaker["source_kind"] = "other";
|
|
3052
|
-
speaker["source_id"] = null;
|
|
6000
|
+
if (sourceKind === "actor" || sourceKind === "location" || sourceKind === "prop") {
|
|
6001
|
+
if (droppedByKind[sourceKind].has(sourceId)) {
|
|
6002
|
+
speaker["source_kind"] = "other";
|
|
6003
|
+
speaker["source_id"] = null;
|
|
6004
|
+
}
|
|
6005
|
+
else if (replacementsByKind[sourceKind].has(sourceId)) {
|
|
6006
|
+
speaker["source_id"] = replacementsByKind[sourceKind].get(sourceId);
|
|
6007
|
+
}
|
|
3053
6008
|
}
|
|
3054
6009
|
}
|
|
6010
|
+
const actorsAfter = asList(script["actors"]).length;
|
|
6011
|
+
const locationsAfter = asList(script["locations"]).length;
|
|
6012
|
+
const propsAfter = asList(script["props"]).length;
|
|
6013
|
+
const totalBefore = actorsBefore + locationsBefore + propsBefore;
|
|
6014
|
+
const explicitKeepCount = decisions.filter((decision) => decision["decision"] === "keep").length;
|
|
6015
|
+
return {
|
|
6016
|
+
version: 3,
|
|
6017
|
+
summary: {
|
|
6018
|
+
actors_before: actorsBefore,
|
|
6019
|
+
actors_after: actorsAfter,
|
|
6020
|
+
actors_removed: actorsBefore - actorsAfter,
|
|
6021
|
+
props_before: propsBefore,
|
|
6022
|
+
props_after: propsAfter,
|
|
6023
|
+
props_removed: propsBefore - propsAfter,
|
|
6024
|
+
locations_before: locationsBefore,
|
|
6025
|
+
locations_after: locationsAfter,
|
|
6026
|
+
locations_merged: locationsBefore - locationsAfter,
|
|
6027
|
+
decisions: decisions.length,
|
|
6028
|
+
explicit_keep_count: explicitKeepCount,
|
|
6029
|
+
implicit_keep_count: Math.max(0, totalBefore - decisions.length),
|
|
6030
|
+
default_policy: defaultPolicy,
|
|
6031
|
+
coverage: coverageSummary,
|
|
6032
|
+
actions: summarizeActionableCuration(decisions),
|
|
6033
|
+
},
|
|
6034
|
+
decisions,
|
|
6035
|
+
};
|
|
6036
|
+
}
|
|
6037
|
+
export function applyAssetCurationPlan(script, rawPlan) {
|
|
6038
|
+
const decisions = normalizedCurationDecisions(rawPlan);
|
|
6039
|
+
normalizeCurationPlanTargets(script, decisions);
|
|
6040
|
+
const coverageSummary = assertCurationCoverage(script, decisions);
|
|
6041
|
+
assertMergeAcyclic(decisions);
|
|
6042
|
+
return applyAssetCurationDecisions(script, decisions, coverageSummary, "required assets must have explicit decisions; optional omitted actors/locations are kept");
|
|
6043
|
+
}
|
|
6044
|
+
export function applyAssetGroupingDuplicateMerges(script, rawPlan) {
|
|
6045
|
+
const decisions = normalizedCurationDecisions(rawPlan);
|
|
6046
|
+
const invalid = decisions
|
|
6047
|
+
.filter((decision) => decision["decision"] !== "merge")
|
|
6048
|
+
.map((decision) => `${strOf(decision["kind"])}:${strOf(decision["source_id"])}:${strOf(decision["decision"])}`);
|
|
6049
|
+
if (invalid.length > 0)
|
|
6050
|
+
curationBlocked("Asset grouping can only apply duplicate merge decisions.", invalid.slice(0, 40));
|
|
6051
|
+
normalizeCurationPlanTargets(script, decisions);
|
|
6052
|
+
assertMergeAcyclic(decisions);
|
|
6053
|
+
return applyAssetCurationDecisions(script, decisions, { skipped: true, reason: "asset grouping duplicate merge runs before full curation coverage" }, "asset grouping may omit only exact normalized-name duplicates; omitted duplicates are merged before full curation");
|
|
3055
6054
|
}
|
|
3056
6055
|
export function normalizeAssetCuration(script, rawCuration) {
|
|
3057
6056
|
const payload = isDict(rawCuration) ? rawCuration : {};
|
|
@@ -3121,6 +6120,9 @@ export function normalizeAssetCuration(script, rawCuration) {
|
|
|
3121
6120
|
};
|
|
3122
6121
|
}
|
|
3123
6122
|
export function curateScriptAssets(script, rawCuration = null) {
|
|
6123
|
+
if (isDict(rawCuration) && isList(rawCuration["decisions"])) {
|
|
6124
|
+
return applyAssetCurationPlan(script, rawCuration);
|
|
6125
|
+
}
|
|
3124
6126
|
const actorsBefore = asList(script["actors"]).length;
|
|
3125
6127
|
const propsBefore = asList(script["props"]).length;
|
|
3126
6128
|
const locationsBefore = asList(script["locations"]).length;
|
|
@@ -3168,6 +6170,361 @@ export function curateScriptAssets(script, rawCuration = null) {
|
|
|
3168
6170
|
locations: locationDecisions,
|
|
3169
6171
|
};
|
|
3170
6172
|
}
|
|
6173
|
+
// ---------------------------------------------------------------------------
|
|
6174
|
+
// State binding
|
|
6175
|
+
// ---------------------------------------------------------------------------
|
|
6176
|
+
function stateBindingBlocked(message, received) {
|
|
6177
|
+
throw new CliError("DIRECT STATE BINDING BLOCKED: invalid provider plan", message, {
|
|
6178
|
+
exitCode: EXIT_NEEDS_AGENT,
|
|
6179
|
+
required: ["valid state binding plan referencing existing scene/action/asset/state ids"],
|
|
6180
|
+
received,
|
|
6181
|
+
nextSteps: ["Inspect state_binding.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
|
|
6182
|
+
});
|
|
6183
|
+
}
|
|
6184
|
+
export function normalizeStateBindingPlanShape(rawPlan) {
|
|
6185
|
+
if (!isDict(rawPlan))
|
|
6186
|
+
return null;
|
|
6187
|
+
const scenes = rawPlan["scenes"];
|
|
6188
|
+
if (isList(scenes))
|
|
6189
|
+
return rawPlan;
|
|
6190
|
+
if (typeof scenes === "string") {
|
|
6191
|
+
const text = scenes.trim();
|
|
6192
|
+
if (!text)
|
|
6193
|
+
return rawPlan;
|
|
6194
|
+
try {
|
|
6195
|
+
const parsed = JSON.parse(text);
|
|
6196
|
+
if (isList(parsed))
|
|
6197
|
+
return { ...rawPlan, scenes: parsed };
|
|
6198
|
+
}
|
|
6199
|
+
catch {
|
|
6200
|
+
return rawPlan;
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6203
|
+
return rawPlan;
|
|
6204
|
+
}
|
|
6205
|
+
function stateIdsByAssetId(script) {
|
|
6206
|
+
const out = new Map();
|
|
6207
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
6208
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
6209
|
+
const assetId = strOf(asset[assetIdKey(kind)]);
|
|
6210
|
+
if (!assetId)
|
|
6211
|
+
continue;
|
|
6212
|
+
out.set(assetId, new Set(asList(asset["states"]).map((state) => strOf(state["state_id"])).filter((id) => id)));
|
|
6213
|
+
}
|
|
6214
|
+
}
|
|
6215
|
+
return out;
|
|
6216
|
+
}
|
|
6217
|
+
function stateNameById(asset, stateId) {
|
|
6218
|
+
for (const state of asList(asset["states"])) {
|
|
6219
|
+
if (strOf(state["state_id"]) === stateId)
|
|
6220
|
+
return strOf(state["state_name"]);
|
|
6221
|
+
}
|
|
6222
|
+
return "";
|
|
6223
|
+
}
|
|
6224
|
+
function bindableDefaultStateId(asset) {
|
|
6225
|
+
const states = asList(asset["states"]);
|
|
6226
|
+
if (states.length === 0)
|
|
6227
|
+
return "";
|
|
6228
|
+
const defaultState = states.find((state) => strOf(state["state_name"]).trim() === "默认");
|
|
6229
|
+
if (defaultState)
|
|
6230
|
+
return strOf(defaultState["state_id"]);
|
|
6231
|
+
if (states.length === 1)
|
|
6232
|
+
return strOf(states[0]["state_id"]);
|
|
6233
|
+
return "";
|
|
6234
|
+
}
|
|
6235
|
+
export function ensureDefaultStates(script) {
|
|
6236
|
+
let added = 0;
|
|
6237
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
6238
|
+
for (const asset of asList(script[assetListKey(kind)])) {
|
|
6239
|
+
const states = asList(asset["states"]);
|
|
6240
|
+
if (states.length <= 1)
|
|
6241
|
+
continue;
|
|
6242
|
+
if (states.some((state) => strOf(state["state_name"]).trim() === "默认"))
|
|
6243
|
+
continue;
|
|
6244
|
+
appendState(asset, script, "默认", asset["description"]);
|
|
6245
|
+
added += 1;
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
6248
|
+
return { default_states_added: added };
|
|
6249
|
+
}
|
|
6250
|
+
export function bindDefaultStateRefsInScript(script) {
|
|
6251
|
+
const assetMaps = {
|
|
6252
|
+
actor: assetMapById(script, "actor"),
|
|
6253
|
+
location: assetMapById(script, "location"),
|
|
6254
|
+
prop: assetMapById(script, "prop"),
|
|
6255
|
+
};
|
|
6256
|
+
let bound = 0;
|
|
6257
|
+
let unresolved = 0;
|
|
6258
|
+
for (const ep of asList(script["episodes"])) {
|
|
6259
|
+
for (const scene of asList(ep["scenes"])) {
|
|
6260
|
+
const ctx = sceneContext(scene);
|
|
6261
|
+
for (const kind of ["actor", "location", "prop"]) {
|
|
6262
|
+
const idKey = assetIdKey(kind);
|
|
6263
|
+
const refKey = assetListKey(kind);
|
|
6264
|
+
for (const ref of asList(ctx[refKey])) {
|
|
6265
|
+
if (!isDict(ref) || ref["state_id"])
|
|
6266
|
+
continue;
|
|
6267
|
+
const asset = assetMaps[kind].get(strOf(ref[idKey]));
|
|
6268
|
+
if (!asset || asList(asset["states"]).length === 0)
|
|
6269
|
+
continue;
|
|
6270
|
+
const stateId = bindableDefaultStateId(asset);
|
|
6271
|
+
if (stateId) {
|
|
6272
|
+
ref["state_id"] = stateId;
|
|
6273
|
+
bound += 1;
|
|
6274
|
+
}
|
|
6275
|
+
else {
|
|
6276
|
+
unresolved += 1;
|
|
6277
|
+
}
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
setSceneContext(scene, ctx);
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
return { default_or_unique_refs_bound: bound, unresolved_stateful_refs: unresolved };
|
|
6284
|
+
}
|
|
6285
|
+
function compactStateBindingAsset(kind, asset) {
|
|
6286
|
+
return {
|
|
6287
|
+
kind,
|
|
6288
|
+
id: asset[assetIdKey(kind)],
|
|
6289
|
+
name: asset[assetNameKey(kind)],
|
|
6290
|
+
description: asset["description"],
|
|
6291
|
+
states: stateCatalog(asset),
|
|
6292
|
+
};
|
|
6293
|
+
}
|
|
6294
|
+
function stateBindingAssetRefIds(sceneContexts, refKey) {
|
|
6295
|
+
const ids = new Set();
|
|
6296
|
+
for (const scene of sceneContexts) {
|
|
6297
|
+
const refs = asDict(scene["refs"]);
|
|
6298
|
+
for (const ref of asList(refs[refKey])) {
|
|
6299
|
+
const id = strOf(ref["id"]);
|
|
6300
|
+
if (id)
|
|
6301
|
+
ids.add(id);
|
|
6302
|
+
}
|
|
6303
|
+
}
|
|
6304
|
+
return ids;
|
|
6305
|
+
}
|
|
6306
|
+
function sceneNeedsLlmStateBinding(sceneContext) {
|
|
6307
|
+
const refs = asDict(sceneContext["refs"]);
|
|
6308
|
+
for (const refKey of ["actors", "locations", "props"]) {
|
|
6309
|
+
for (const ref of asList(refs[refKey])) {
|
|
6310
|
+
if (asList(ref["states"]).length > 1)
|
|
6311
|
+
return true;
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
return false;
|
|
6315
|
+
}
|
|
6316
|
+
function sceneBindingContext(script, ep, scene) {
|
|
6317
|
+
const actorMap = assetMapById(script, "actor");
|
|
6318
|
+
const locationMap = assetMapById(script, "location");
|
|
6319
|
+
const propMap = assetMapById(script, "prop");
|
|
6320
|
+
const ctx = sceneContext(scene);
|
|
6321
|
+
const refsFor = (kind) => {
|
|
6322
|
+
const idKey = assetIdKey(kind);
|
|
6323
|
+
const refKey = assetListKey(kind);
|
|
6324
|
+
const assets = kind === "actor" ? actorMap : kind === "location" ? locationMap : propMap;
|
|
6325
|
+
return asList(ctx[refKey]).map((ref) => {
|
|
6326
|
+
const asset = assets.get(strOf(ref[idKey]));
|
|
6327
|
+
return {
|
|
6328
|
+
id: ref[idKey],
|
|
6329
|
+
name: asset ? asset[assetNameKey(kind)] : "",
|
|
6330
|
+
current_state_id: ref["state_id"] ?? null,
|
|
6331
|
+
current_state_name: asset && ref["state_id"] ? stateNameById(asset, strOf(ref["state_id"])) : "",
|
|
6332
|
+
states: asset ? stateCatalog(asset) : [],
|
|
6333
|
+
};
|
|
6334
|
+
});
|
|
6335
|
+
};
|
|
6336
|
+
return {
|
|
6337
|
+
episode_id: ep["episode_id"],
|
|
6338
|
+
scene_id: scene["scene_id"],
|
|
6339
|
+
environment: scene["environment"],
|
|
6340
|
+
refs: {
|
|
6341
|
+
actors: refsFor("actor"),
|
|
6342
|
+
locations: refsFor("location"),
|
|
6343
|
+
props: refsFor("prop"),
|
|
6344
|
+
},
|
|
6345
|
+
actions: asList(scene["actions"]).map((action, index) => ({
|
|
6346
|
+
index,
|
|
6347
|
+
type: action["type"],
|
|
6348
|
+
content: action["content"],
|
|
6349
|
+
actor_id: action["actor_id"],
|
|
6350
|
+
speaker_id: action["speaker_id"],
|
|
6351
|
+
emotion: action["emotion"],
|
|
6352
|
+
})),
|
|
6353
|
+
};
|
|
6354
|
+
}
|
|
6355
|
+
export function buildStateBindingContexts(script, chunkSize = 10) {
|
|
6356
|
+
const scenes = [];
|
|
6357
|
+
for (const ep of asList(script["episodes"])) {
|
|
6358
|
+
for (const scene of asList(ep["scenes"])) {
|
|
6359
|
+
const sceneContext = sceneBindingContext(script, ep, scene);
|
|
6360
|
+
if (sceneNeedsLlmStateBinding(sceneContext))
|
|
6361
|
+
scenes.push(sceneContext);
|
|
6362
|
+
}
|
|
6363
|
+
}
|
|
6364
|
+
const actorMap = assetMapById(script, "actor");
|
|
6365
|
+
const locationMap = assetMapById(script, "location");
|
|
6366
|
+
const propMap = assetMapById(script, "prop");
|
|
6367
|
+
const chunks = [];
|
|
6368
|
+
const size = Math.max(1, Math.floor(chunkSize) || 1);
|
|
6369
|
+
for (let i = 0; i < scenes.length; i += size) {
|
|
6370
|
+
const chunkScenes = scenes.slice(i, i + size);
|
|
6371
|
+
const actorIds = stateBindingAssetRefIds(chunkScenes, "actors");
|
|
6372
|
+
const locationIds = stateBindingAssetRefIds(chunkScenes, "locations");
|
|
6373
|
+
const propIds = stateBindingAssetRefIds(chunkScenes, "props");
|
|
6374
|
+
const assetCatalog = {
|
|
6375
|
+
actors: [...actorIds].map((id) => actorMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("actor", asset)),
|
|
6376
|
+
locations: [...locationIds].map((id) => locationMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("location", asset)),
|
|
6377
|
+
props: [...propIds].map((id) => propMap.get(id)).filter(isDict).map((asset) => compactStateBindingAsset("prop", asset)),
|
|
6378
|
+
};
|
|
6379
|
+
chunks.push({
|
|
6380
|
+
title: script["title"],
|
|
6381
|
+
policy: [
|
|
6382
|
+
"Bind each scene asset ref to the best existing state_id when the actions/context show a durable visual state.",
|
|
6383
|
+
"Use null only when no existing state fits and no default/unique state should apply.",
|
|
6384
|
+
"Emit state_changes only for explicit transformation, outfit, damage, breakage, repair, or form-change actions.",
|
|
6385
|
+
"Do not invent new states or assets.",
|
|
6386
|
+
],
|
|
6387
|
+
assets: assetCatalog,
|
|
6388
|
+
scenes: chunkScenes,
|
|
6389
|
+
});
|
|
6390
|
+
}
|
|
6391
|
+
return chunks;
|
|
6392
|
+
}
|
|
6393
|
+
function findSceneById(script, sceneId) {
|
|
6394
|
+
for (const ep of asList(script["episodes"])) {
|
|
6395
|
+
for (const scene of asList(ep["scenes"])) {
|
|
6396
|
+
if (strOf(scene["scene_id"]) === sceneId)
|
|
6397
|
+
return scene;
|
|
6398
|
+
}
|
|
6399
|
+
}
|
|
6400
|
+
return null;
|
|
6401
|
+
}
|
|
6402
|
+
function resolveStateForAsset(stateIdsByAsset, assetId, stateIdRaw, label, mode, stats) {
|
|
6403
|
+
if (stateIdRaw === null || stateIdRaw === undefined || stateIdRaw === "")
|
|
6404
|
+
return null;
|
|
6405
|
+
const stateId = strOf(stateIdRaw);
|
|
6406
|
+
if (!(stateIdsByAsset.get(assetId) ?? new Set()).has(stateId)) {
|
|
6407
|
+
if (mode === "skip") {
|
|
6408
|
+
if (label.startsWith("state_changes."))
|
|
6409
|
+
stats.invalid_state_changes_skipped += 1;
|
|
6410
|
+
else
|
|
6411
|
+
stats.invalid_scene_refs_skipped += 1;
|
|
6412
|
+
return undefined;
|
|
6413
|
+
}
|
|
6414
|
+
stateBindingBlocked("State binding references a state_id that does not belong to the asset.", [`${label}: ${assetId}/${stateId}`]);
|
|
6415
|
+
}
|
|
6416
|
+
return stateId;
|
|
6417
|
+
}
|
|
6418
|
+
function applyStateRefsForScene(ctx, planScene, refKey, idKey, stateIdsByAsset, mode, stats) {
|
|
6419
|
+
const refs = asList(ctx[refKey]);
|
|
6420
|
+
const byId = new Map(refs.map((ref) => [strOf(ref[idKey]), ref]));
|
|
6421
|
+
let bound = 0;
|
|
6422
|
+
for (const planned of asList(planScene[refKey])) {
|
|
6423
|
+
if (!isDict(planned))
|
|
6424
|
+
continue;
|
|
6425
|
+
const assetId = strOf(planned[idKey]);
|
|
6426
|
+
const ref = byId.get(assetId);
|
|
6427
|
+
if (!ref) {
|
|
6428
|
+
if (mode === "skip") {
|
|
6429
|
+
stats.invalid_scene_refs_skipped += 1;
|
|
6430
|
+
continue;
|
|
6431
|
+
}
|
|
6432
|
+
stateBindingBlocked("State binding references an asset not present in the scene.", [`${refKey}:${assetId}`]);
|
|
6433
|
+
}
|
|
6434
|
+
const stateId = resolveStateForAsset(stateIdsByAsset, assetId, planned["state_id"], `${refKey}.state_id`, mode, stats);
|
|
6435
|
+
if (stateId === undefined)
|
|
6436
|
+
continue;
|
|
6437
|
+
if (stateId && ref["state_id"] !== stateId) {
|
|
6438
|
+
ref["state_id"] = stateId;
|
|
6439
|
+
bound += 1;
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
return bound;
|
|
6443
|
+
}
|
|
6444
|
+
function sceneHasAssetRef(ctx, kind, assetId) {
|
|
6445
|
+
const idKey = assetIdKey(kind);
|
|
6446
|
+
const refs = asList(ctx[assetListKey(kind)]);
|
|
6447
|
+
return refs.some((ref) => strOf(ref[idKey]) === assetId);
|
|
6448
|
+
}
|
|
6449
|
+
export function applyStateBindingPlan(script, rawPlan, options = {}) {
|
|
6450
|
+
const payload = isDict(rawPlan) ? rawPlan : {};
|
|
6451
|
+
const stateIdsByAsset = stateIdsByAssetId(script);
|
|
6452
|
+
const mode = options.invalidStateMode ?? "reject";
|
|
6453
|
+
const stats = { invalid_scene_refs_skipped: 0, invalid_state_changes_skipped: 0 };
|
|
6454
|
+
let sceneRefsBound = 0;
|
|
6455
|
+
let stateChangesApplied = 0;
|
|
6456
|
+
for (const planScene of asList(payload["scenes"])) {
|
|
6457
|
+
if (!isDict(planScene))
|
|
6458
|
+
continue;
|
|
6459
|
+
const sceneId = strOf(planScene["scene_id"]);
|
|
6460
|
+
const scene = findSceneById(script, sceneId);
|
|
6461
|
+
if (!scene)
|
|
6462
|
+
stateBindingBlocked("State binding references an unknown scene_id.", [`scene_id: ${sceneId || "<empty>"}`]);
|
|
6463
|
+
const ctx = sceneContext(scene);
|
|
6464
|
+
sceneRefsBound += applyStateRefsForScene(ctx, planScene, "actors", "actor_id", stateIdsByAsset, mode, stats);
|
|
6465
|
+
sceneRefsBound += applyStateRefsForScene(ctx, planScene, "locations", "location_id", stateIdsByAsset, mode, stats);
|
|
6466
|
+
sceneRefsBound += applyStateRefsForScene(ctx, planScene, "props", "prop_id", stateIdsByAsset, mode, stats);
|
|
6467
|
+
setSceneContext(scene, ctx);
|
|
6468
|
+
const actions = asList(scene["actions"]);
|
|
6469
|
+
for (const change of asList(planScene["state_changes"])) {
|
|
6470
|
+
if (!isDict(change))
|
|
6471
|
+
continue;
|
|
6472
|
+
const actionIndex = Number(change["action_index"]);
|
|
6473
|
+
const action = actions[actionIndex];
|
|
6474
|
+
if (!Number.isInteger(actionIndex) || !action) {
|
|
6475
|
+
if (mode === "skip") {
|
|
6476
|
+
stats.invalid_state_changes_skipped += 1;
|
|
6477
|
+
continue;
|
|
6478
|
+
}
|
|
6479
|
+
stateBindingBlocked("State binding references an unknown action_index.", [`${sceneId}#${change["action_index"]}`]);
|
|
6480
|
+
}
|
|
6481
|
+
const targetKind = strOf(change["target_kind"]);
|
|
6482
|
+
if (!SCRIPT_TARGET_KINDS.has(targetKind))
|
|
6483
|
+
stateBindingBlocked("State change target_kind is invalid.", [`target_kind: ${targetKind || "<empty>"}`]);
|
|
6484
|
+
const targetId = strOf(change["target_id"]);
|
|
6485
|
+
if (!sceneHasAssetRef(ctx, targetKind, targetId)) {
|
|
6486
|
+
if (mode === "skip") {
|
|
6487
|
+
stats.invalid_state_changes_skipped += 1;
|
|
6488
|
+
continue;
|
|
6489
|
+
}
|
|
6490
|
+
stateBindingBlocked("State change references an asset not present in the scene.", [`${sceneId}#${actionIndex}:${targetKind}:${targetId}`]);
|
|
6491
|
+
}
|
|
6492
|
+
const toStateId = resolveStateForAsset(stateIdsByAsset, targetId, change["to_state_id"], "state_changes.to_state_id", mode, stats);
|
|
6493
|
+
if (toStateId === undefined)
|
|
6494
|
+
continue;
|
|
6495
|
+
if (!toStateId) {
|
|
6496
|
+
if (mode === "skip") {
|
|
6497
|
+
stats.invalid_state_changes_skipped += 1;
|
|
6498
|
+
continue;
|
|
6499
|
+
}
|
|
6500
|
+
stateBindingBlocked("State change requires to_state_id.", [`${sceneId}#${actionIndex}`]);
|
|
6501
|
+
}
|
|
6502
|
+
const fromStateId = resolveStateForAsset(stateIdsByAsset, targetId, change["from_state_id"], "state_changes.from_state_id", mode, stats);
|
|
6503
|
+
if (fromStateId === undefined)
|
|
6504
|
+
continue;
|
|
6505
|
+
const effectiveFrom = strOf(change["effective_from"] || "after_action");
|
|
6506
|
+
if (effectiveFrom !== "before_action" && effectiveFrom !== "after_action")
|
|
6507
|
+
stateBindingBlocked("State change effective_from is invalid.", [`effective_from: ${effectiveFrom}`]);
|
|
6508
|
+
const nextChange = {
|
|
6509
|
+
target_kind: targetKind,
|
|
6510
|
+
target_id: targetId,
|
|
6511
|
+
from_state_id: fromStateId || "",
|
|
6512
|
+
to_state_id: toStateId,
|
|
6513
|
+
effective_from: effectiveFrom,
|
|
6514
|
+
};
|
|
6515
|
+
const existing = asList(action["state_changes"]).filter((item) => !(strOf(item["target_kind"]) === targetKind && strOf(item["target_id"]) === targetId));
|
|
6516
|
+
existing.push(nextChange);
|
|
6517
|
+
action["state_changes"] = existing;
|
|
6518
|
+
stateChangesApplied += 1;
|
|
6519
|
+
}
|
|
6520
|
+
}
|
|
6521
|
+
return {
|
|
6522
|
+
scene_refs_bound: sceneRefsBound,
|
|
6523
|
+
state_changes_applied: stateChangesApplied,
|
|
6524
|
+
invalid_scene_refs_skipped: stats.invalid_scene_refs_skipped,
|
|
6525
|
+
invalid_state_changes_skipped: stats.invalid_state_changes_skipped,
|
|
6526
|
+
};
|
|
6527
|
+
}
|
|
3171
6528
|
function emptyAssetNormalizationReport() {
|
|
3172
6529
|
return {
|
|
3173
6530
|
version: 1,
|