@lingjingai/scriptctl 0.15.0 → 0.19.1
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/README.md +1 -1
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +10 -3
- package/dist/common.js +82 -60
- package/dist/common.js.map +1 -1
- package/dist/domain/direct-core.d.ts +39 -4
- package/dist/domain/direct-core.js +317 -56
- package/dist/domain/direct-core.js.map +1 -1
- package/dist/help-text.js +15 -1
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.d.ts +12 -2
- package/dist/infra/providers.js +156 -108
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.js +352 -74
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/parse.js +104 -14
- package/dist/usecases/parse.js.map +1 -1
- package/dist/usecases/script.d.ts +2 -2
- package/dist/usecases/script.js +15 -11
- package/dist/usecases/script.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACTION_TYPE_VALUES, ACTOR_REUSE_SCENE_LIMIT, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, PROP_REUSE_SCENE_LIMIT, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
1
|
+
import { ACTION_TYPE_VALUES, ACTOR_REUSE_SCENE_LIMIT, CliError, DEFAULT_BATCH_MAX_CHARS, DEFAULT_BATCH_MIN_LINES, DEFAULT_BATCH_MODE, DEFAULT_BATCH_TARGET_LINES, DEFAULT_PROVIDER_ATTEMPTS, EXIT_NEEDS_AGENT, EXIT_RUNTIME, PROP_REUSE_SCENE_LIMIT, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, WORLDVIEW_VALUES, fmtId, } from "../common.js";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
3
|
// Regexes & lookup tables (shared with infra/converters.ts via re-export)
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -1133,13 +1133,6 @@ export async function providerExtractBatch(provider, sourceText, batch) {
|
|
|
1133
1133
|
return await provider.extractEpisode(sourceText, batch);
|
|
1134
1134
|
throw new Error("Provider has neither extractBatch nor extractEpisode");
|
|
1135
1135
|
}
|
|
1136
|
-
export async function providerExtractAssetCuration(provider, sourceText, script) {
|
|
1137
|
-
if (provider.extractAssetCuration) {
|
|
1138
|
-
const payload = await provider.extractAssetCuration(sourceText, script);
|
|
1139
|
-
return isDict(payload) ? payload : {};
|
|
1140
|
-
}
|
|
1141
|
-
return {};
|
|
1142
|
-
}
|
|
1143
1136
|
function sleepMs(ms) {
|
|
1144
1137
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1145
1138
|
}
|
|
@@ -1749,6 +1742,104 @@ export function parseAssetDoc(text, kind) {
|
|
|
1749
1742
|
return { actors, locations, props, speakers, state_definitions: stateDefs };
|
|
1750
1743
|
}
|
|
1751
1744
|
// ---------------------------------------------------------------------------
|
|
1745
|
+
// Asset doc serialization (inverse of parseAssetDoc / parseMetaDoc)
|
|
1746
|
+
//
|
|
1747
|
+
// Turn an assembled script back into the per-kind 资产 md so `parse
|
|
1748
|
+
// --extract-assets` can seed reviewable 人物/场景/道具/发声源.md from an
|
|
1749
|
+
// LLM-enriched script. Output must round-trip through parseAssetDoc: every
|
|
1750
|
+
// asset becomes a top-level `- 名: 描述` bullet, each state a ` - 状态名:`
|
|
1751
|
+
// sub-bullet. State appearance text is not stored on the script (states are
|
|
1752
|
+
// {state_id, state_name}); we emit the state name only and leave the appearance
|
|
1753
|
+
// blank for the human to fill — the file is a review seed, not the final word.
|
|
1754
|
+
// ---------------------------------------------------------------------------
|
|
1755
|
+
const _ASSET_DOC_HEADER = {
|
|
1756
|
+
actors: "人物",
|
|
1757
|
+
locations: "场景",
|
|
1758
|
+
props: "道具",
|
|
1759
|
+
speakers: "发声源",
|
|
1760
|
+
};
|
|
1761
|
+
const _ASSET_DOC_NAME_KEY = {
|
|
1762
|
+
actors: "actor_name",
|
|
1763
|
+
locations: "location_name",
|
|
1764
|
+
props: "prop_name",
|
|
1765
|
+
};
|
|
1766
|
+
// Collapse any whitespace run (incl. newlines) to a single space. A description
|
|
1767
|
+
// or single-value field spanning physical lines would break the line-oriented
|
|
1768
|
+
// grammar (continuation lines don't start with `-` / a known key → dropped).
|
|
1769
|
+
function _oneLine(s) {
|
|
1770
|
+
return s.replace(/\s+/g, " ").trim();
|
|
1771
|
+
}
|
|
1772
|
+
// Sanitize a name for emission. The md grammar can't represent a name containing
|
|
1773
|
+
// its own structural delimiters: `:`/`:` start a description (truncating the
|
|
1774
|
+
// name), `|` flags a flat `名|状态` state def (the asset disappears), and the meta
|
|
1775
|
+
// `主角:` value is re-split on `,`/`,`/`、`. None of these belong in a canonical
|
|
1776
|
+
// entity name, so replace them with spaces — the doc is a review seed and the
|
|
1777
|
+
// human fixes oddities; we just never emit a name that re-parses to something else.
|
|
1778
|
+
function _safeName(name) {
|
|
1779
|
+
return name.replace(/[\r\n::|,,、]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1780
|
+
}
|
|
1781
|
+
function _assetBullet(name, description) {
|
|
1782
|
+
const desc = _oneLine(description);
|
|
1783
|
+
return desc ? `- ${name}: ${desc}` : `- ${name}`;
|
|
1784
|
+
}
|
|
1785
|
+
export function serializeAssetDoc(script, kind) {
|
|
1786
|
+
const lines = [`# ${_ASSET_DOC_HEADER[kind]}`, ""];
|
|
1787
|
+
if (kind === "speakers") {
|
|
1788
|
+
for (const sp of asList(script["speakers"])) {
|
|
1789
|
+
if (!isDict(sp))
|
|
1790
|
+
continue;
|
|
1791
|
+
const name = _safeName(strOf(sp["display_name"]));
|
|
1792
|
+
if (!name)
|
|
1793
|
+
continue;
|
|
1794
|
+
const sourceKind = strOf(sp["source_kind"]).trim();
|
|
1795
|
+
// 发声源.md is for NON-人物 sources only; actor-kind speakers are real
|
|
1796
|
+
// characters already registered in 人物.md. Every non-actor kind round-trips
|
|
1797
|
+
// as `名(kind)` (parseSpeakerAsset reads the (...) annotation).
|
|
1798
|
+
if (!sourceKind || sourceKind === "actor")
|
|
1799
|
+
continue;
|
|
1800
|
+
lines.push(_assetBullet(`${name}(${sourceKind})`, strOf(sp["description"])));
|
|
1801
|
+
}
|
|
1802
|
+
return lines.join("\n") + "\n";
|
|
1803
|
+
}
|
|
1804
|
+
const nameKey = _ASSET_DOC_NAME_KEY[kind];
|
|
1805
|
+
for (const asset of asList(script[kind])) {
|
|
1806
|
+
if (!isDict(asset))
|
|
1807
|
+
continue;
|
|
1808
|
+
const name = _safeName(strOf(asset[nameKey]));
|
|
1809
|
+
if (!name)
|
|
1810
|
+
continue;
|
|
1811
|
+
lines.push(_assetBullet(name, strOf(asset["description"])));
|
|
1812
|
+
for (const stateName of compactStateNames(asset)) {
|
|
1813
|
+
const safeState = _safeName(stateName);
|
|
1814
|
+
if (!safeState)
|
|
1815
|
+
continue;
|
|
1816
|
+
// Bare sub-bullet (no trailing colon): the script stores no appearance text
|
|
1817
|
+
// for a state, and `- 名:` with an empty description fails _MD_ASSET_ENTRY_RE.
|
|
1818
|
+
lines.push(` - ${safeState}`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return lines.join("\n") + "\n";
|
|
1822
|
+
}
|
|
1823
|
+
export function serializeMetaDoc(script) {
|
|
1824
|
+
const lines = ["# 元信息", ""];
|
|
1825
|
+
const worldview = _oneLine(strOf(script["worldview"]));
|
|
1826
|
+
const style = _oneLine(strOf(script["style"]));
|
|
1827
|
+
const title = _oneLine(strOf(script["title"]));
|
|
1828
|
+
if (title)
|
|
1829
|
+
lines.push(`title: ${title}`);
|
|
1830
|
+
if (worldview)
|
|
1831
|
+
lines.push(`worldview: ${worldview}`);
|
|
1832
|
+
if (style)
|
|
1833
|
+
lines.push(`style: ${style}`);
|
|
1834
|
+
const leads = asList(script["actors"])
|
|
1835
|
+
.filter((a) => isDict(a) && strOf(a["role_type"]).trim() === "主角")
|
|
1836
|
+
.map((a) => _safeName(strOf(a["actor_name"])))
|
|
1837
|
+
.filter(Boolean);
|
|
1838
|
+
if (leads.length > 0)
|
|
1839
|
+
lines.push(`主角: ${leads.join(", ")}`);
|
|
1840
|
+
return lines.join("\n") + "\n";
|
|
1841
|
+
}
|
|
1842
|
+
// ---------------------------------------------------------------------------
|
|
1752
1843
|
// Compact / expand transforms
|
|
1753
1844
|
// ---------------------------------------------------------------------------
|
|
1754
1845
|
export function expandCompactEpisodeResult(result, episodePlan) {
|
|
@@ -1881,6 +1972,11 @@ export function expandCompactEpisodeResult(result, episodePlan) {
|
|
|
1881
1972
|
expanded["speakers"] = result["speakers"];
|
|
1882
1973
|
if ("state_definitions" in result)
|
|
1883
1974
|
expanded["state_definitions"] = result["state_definitions"];
|
|
1975
|
+
// Restore the synopsis carried through compaction (short key `syn`, or a
|
|
1976
|
+
// plain `synopsis` if the on-disk form was never compacted).
|
|
1977
|
+
const synopsis = readCarriedSynopsis(result);
|
|
1978
|
+
if (synopsis)
|
|
1979
|
+
expanded["synopsis"] = synopsis;
|
|
1884
1980
|
return expanded;
|
|
1885
1981
|
}
|
|
1886
1982
|
export function compactEpisodeResult(result) {
|
|
@@ -1967,7 +2063,14 @@ export function compactEpisodeResult(result) {
|
|
|
1967
2063
|
compactScene["a"] = compactActions;
|
|
1968
2064
|
compactScenes.push(compactScene);
|
|
1969
2065
|
}
|
|
1970
|
-
|
|
2066
|
+
const compacted = { sc: compactScenes };
|
|
2067
|
+
// Carry the synopsis through the compact round-trip (short key `syn`). Without
|
|
2068
|
+
// this, any synopsis written upstream (## 梗概 → batch result, or the episode
|
|
2069
|
+
// reduce) would silently evaporate the moment the result is persisted.
|
|
2070
|
+
const synopsis = strOf(result["synopsis"]).trim();
|
|
2071
|
+
if (synopsis)
|
|
2072
|
+
compacted["syn"] = synopsis;
|
|
2073
|
+
return compacted;
|
|
1971
2074
|
}
|
|
1972
2075
|
export function compactBatchResult(result) {
|
|
1973
2076
|
const compact = compactEpisodeResult(result);
|
|
@@ -2002,6 +2105,79 @@ export function compactBatchResult(result) {
|
|
|
2002
2105
|
}
|
|
2003
2106
|
return compact;
|
|
2004
2107
|
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Plan ONE level of the whole-script synopsis reduce.
|
|
2110
|
+
*
|
|
2111
|
+
* We can only char-group the items we actually have (their lengths are known);
|
|
2112
|
+
* a merged group's output length isn't known until the LLM produces it. So this
|
|
2113
|
+
* stays single-level and the orchestrator loops — call it on the episode
|
|
2114
|
+
* synopses, merge each group, then call it again on the merged outputs until it
|
|
2115
|
+
* returns fitsInOnePass, then run the final extractScriptSynopsis on whatever
|
|
2116
|
+
* survives. Pure: it never calls the provider.
|
|
2117
|
+
*
|
|
2118
|
+
* Boundaries: total ≤ threshold (or ≤1 item) → one pass; total ≫ threshold →
|
|
2119
|
+
* multiple bounded groups; every item alone already over threshold → no
|
|
2120
|
+
* grouping can make progress, so degrade to one pass rather than loop forever.
|
|
2121
|
+
*/
|
|
2122
|
+
export function planSynopsisReduction(synopses, thresholdChars, groupSize) {
|
|
2123
|
+
const items = asList(synopses).map((s) => strOf(s));
|
|
2124
|
+
const onePass = () => ({
|
|
2125
|
+
fitsInOnePass: true,
|
|
2126
|
+
groups: items.map((_, i) => [i]),
|
|
2127
|
+
});
|
|
2128
|
+
const total = items.reduce((sum, s) => sum + s.length, 0);
|
|
2129
|
+
if (items.length <= 1 || total <= thresholdChars)
|
|
2130
|
+
return onePass();
|
|
2131
|
+
const cap = Math.max(1, groupSize);
|
|
2132
|
+
const groups = [];
|
|
2133
|
+
let current = [];
|
|
2134
|
+
let currentChars = 0;
|
|
2135
|
+
for (let i = 0; i < items.length; i++) {
|
|
2136
|
+
const len = items[i].length;
|
|
2137
|
+
if (current.length > 0 && (current.length >= cap || currentChars + len > thresholdChars)) {
|
|
2138
|
+
groups.push(current);
|
|
2139
|
+
current = [];
|
|
2140
|
+
currentChars = 0;
|
|
2141
|
+
}
|
|
2142
|
+
current.push(i);
|
|
2143
|
+
currentChars += len;
|
|
2144
|
+
}
|
|
2145
|
+
if (current.length > 0)
|
|
2146
|
+
groups.push(current);
|
|
2147
|
+
// No progress possible (every item alone exceeds threshold → all singletons).
|
|
2148
|
+
if (groups.length >= items.length)
|
|
2149
|
+
return onePass();
|
|
2150
|
+
return { fitsInOnePass: false, groups };
|
|
2151
|
+
}
|
|
2152
|
+
// ---------------------------------------------------------------------------
|
|
2153
|
+
// Synopsis utilities (shared by providers + the reduce orchestration)
|
|
2154
|
+
// ---------------------------------------------------------------------------
|
|
2155
|
+
// Trim + drop-empty an unknown list of synopsis strings. Single source for the
|
|
2156
|
+
// "clean beats" rule, so MockProvider, AnthropicProvider, and the orchestrator
|
|
2157
|
+
// can never diverge on what counts as a non-empty beat.
|
|
2158
|
+
export function cleanSynopsisList(items) {
|
|
2159
|
+
return asList(items).map((s) => strOf(s).trim()).filter((s) => s.length > 0);
|
|
2160
|
+
}
|
|
2161
|
+
// Read the synopsis carried through the compact round-trip: the short key `syn`
|
|
2162
|
+
// (current) or a plain `synopsis` (uncompacted / legacy). One definition so the
|
|
2163
|
+
// key precedence can't drift between the expand path and the reuse readers.
|
|
2164
|
+
export function readCarriedSynopsis(d) {
|
|
2165
|
+
return isDict(d) ? strOf(d["syn"] ?? d["synopsis"]).trim() : "";
|
|
2166
|
+
}
|
|
2167
|
+
// Recursively sort object keys for a stable JSON serialization (cache keys /
|
|
2168
|
+
// content hashes). Canonical home for the helper duplicated in the script/parse
|
|
2169
|
+
// usecases — re-exported there for back-compat.
|
|
2170
|
+
export function sortDeep(value) {
|
|
2171
|
+
if (Array.isArray(value))
|
|
2172
|
+
return value.map((v) => sortDeep(v));
|
|
2173
|
+
if (isDict(value)) {
|
|
2174
|
+
const sorted = {};
|
|
2175
|
+
for (const k of Object.keys(value).sort())
|
|
2176
|
+
sorted[k] = sortDeep(value[k]);
|
|
2177
|
+
return sorted;
|
|
2178
|
+
}
|
|
2179
|
+
return value;
|
|
2180
|
+
}
|
|
2005
2181
|
// ---------------------------------------------------------------------------
|
|
2006
2182
|
// Normalize episode result
|
|
2007
2183
|
// ---------------------------------------------------------------------------
|
|
@@ -2368,7 +2544,10 @@ export function deterministicExtractMetadata(script) {
|
|
|
2368
2544
|
return {
|
|
2369
2545
|
confidence: "high",
|
|
2370
2546
|
worldview: "现代",
|
|
2371
|
-
|
|
2547
|
+
// Neutral default that mirrors the worldview enum — NOT a mock/test string,
|
|
2548
|
+
// since this baseline is also the real provider path's fallback when the
|
|
2549
|
+
// model omits the worldview section (see overlayMetadataSections).
|
|
2550
|
+
worldview_raw: "现代",
|
|
2372
2551
|
actors,
|
|
2373
2552
|
locations: asList(script["locations"]).map((loc) => ({
|
|
2374
2553
|
location_id: loc["location_id"],
|
|
@@ -2380,6 +2559,127 @@ export function deterministicExtractMetadata(script) {
|
|
|
2380
2559
|
})),
|
|
2381
2560
|
};
|
|
2382
2561
|
}
|
|
2562
|
+
// Split a metadata markdown document (see METADATA_MD_SPEC) into its raw pieces.
|
|
2563
|
+
// This is a DISTINCT grammar from parseMarkdownBatch (id-keyed `id | role | desc`
|
|
2564
|
+
// rows, not the name-keyed `名: 描述` asset registry), so it intentionally does
|
|
2565
|
+
// not share that parser. Strict on section identity: only an EXACT top-level
|
|
2566
|
+
// heading (世界观 / 人物 / 场景 / 道具, any number of leading `#`) opens a section,
|
|
2567
|
+
// so a stray sub-heading like "## 人物关系说明" can't hijack the bullets under it.
|
|
2568
|
+
// A worldview value written inline on the heading (`# 世界观:赛博朋克`) is still
|
|
2569
|
+
// captured. Pipe-split fields keep any extra pipes inside the description. Missing
|
|
2570
|
+
// fields yield "".
|
|
2571
|
+
export function parseMetadataMdSections(raw) {
|
|
2572
|
+
const actors = new Map();
|
|
2573
|
+
const locations = new Map();
|
|
2574
|
+
const props = new Map();
|
|
2575
|
+
let worldviewRaw = "";
|
|
2576
|
+
let section = "";
|
|
2577
|
+
for (const lineRaw of raw.split("\n")) {
|
|
2578
|
+
const line = lineRaw.trim();
|
|
2579
|
+
if (!line)
|
|
2580
|
+
continue;
|
|
2581
|
+
if (line.startsWith("#")) {
|
|
2582
|
+
const heading = line.replace(/^#+/, "").trim();
|
|
2583
|
+
const inlineWorldview = heading.match(/^世界观[::]\s*(.+)$/);
|
|
2584
|
+
if (heading === "人物")
|
|
2585
|
+
section = "actors";
|
|
2586
|
+
else if (heading === "场景")
|
|
2587
|
+
section = "locations";
|
|
2588
|
+
else if (heading === "道具")
|
|
2589
|
+
section = "props";
|
|
2590
|
+
else if (heading === "世界观")
|
|
2591
|
+
section = "worldview";
|
|
2592
|
+
else if (inlineWorldview) {
|
|
2593
|
+
section = "worldview";
|
|
2594
|
+
if (!worldviewRaw)
|
|
2595
|
+
worldviewRaw = inlineWorldview[1].trim();
|
|
2596
|
+
}
|
|
2597
|
+
else
|
|
2598
|
+
section = "";
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
if (section === "worldview") {
|
|
2602
|
+
if (!worldviewRaw)
|
|
2603
|
+
worldviewRaw = line;
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
if (section === "")
|
|
2607
|
+
continue;
|
|
2608
|
+
// Keep any extra pipes inside the description (the model may ignore the
|
|
2609
|
+
// no-pipe rule) by rejoining the trailing parts rather than dropping them.
|
|
2610
|
+
const parts = line.replace(/^[-*]\s*/, "").split("|").map((s) => s.trim());
|
|
2611
|
+
const id = parts[0] ?? "";
|
|
2612
|
+
if (!id)
|
|
2613
|
+
continue;
|
|
2614
|
+
if (section === "actors")
|
|
2615
|
+
actors.set(id, { role: parts[1] ?? "", description: parts.slice(2).join(" | ").trim() });
|
|
2616
|
+
else if (section === "locations")
|
|
2617
|
+
locations.set(id, parts.slice(1).join(" | ").trim());
|
|
2618
|
+
else
|
|
2619
|
+
props.set(id, parts.slice(1).join(" | ").trim());
|
|
2620
|
+
}
|
|
2621
|
+
return { worldviewRaw, actors, locations, props };
|
|
2622
|
+
}
|
|
2623
|
+
// True when nothing recognizable parsed — no worldview and no asset rows. The
|
|
2624
|
+
// provider boundary uses this to reject a non-conforming response (prose refusal,
|
|
2625
|
+
// wrong format) instead of silently degrading to a name-only baseline.
|
|
2626
|
+
export function metadataMdSectionsEmpty(s) {
|
|
2627
|
+
return !s.worldviewRaw.trim() && s.actors.size === 0 && s.locations.size === 0 && s.props.size === 0;
|
|
2628
|
+
}
|
|
2629
|
+
// Resolve a raw worldview line to a valid enum value, or null if it can't be
|
|
2630
|
+
// pinned down. Exact match wins; otherwise a substring match is accepted ONLY
|
|
2631
|
+
// when exactly one enum value appears, so a line naming two (e.g. "古代历史…古风
|
|
2632
|
+
// 架空…") stays ambiguous instead of resolving by array order. Null → caller
|
|
2633
|
+
// keeps its default.
|
|
2634
|
+
function coerceWorldview(worldviewRaw) {
|
|
2635
|
+
if (WORLDVIEW_VALUES.includes(worldviewRaw))
|
|
2636
|
+
return worldviewRaw;
|
|
2637
|
+
const hits = WORLDVIEW_VALUES.filter((v) => worldviewRaw.includes(v));
|
|
2638
|
+
return hits.length === 1 ? hits[0] : null;
|
|
2639
|
+
}
|
|
2640
|
+
// Overlay parsed markdown sections onto the deterministic baseline (which already
|
|
2641
|
+
// guarantees every script asset is present with a valid role/description). The
|
|
2642
|
+
// model's worldview/role/description are applied wherever usable, so a partial
|
|
2643
|
+
// response still yields a complete, schema-valid result.
|
|
2644
|
+
export function overlayMetadataSections(parsed, script) {
|
|
2645
|
+
const base = deterministicExtractMetadata(script);
|
|
2646
|
+
const worldviewRaw = parsed.worldviewRaw.trim();
|
|
2647
|
+
if (worldviewRaw) {
|
|
2648
|
+
base["worldview_raw"] = worldviewRaw;
|
|
2649
|
+
base["worldview"] = coerceWorldview(worldviewRaw) ?? base["worldview"];
|
|
2650
|
+
}
|
|
2651
|
+
// If the model is assigning roles at all, don't let the deterministic
|
|
2652
|
+
// "first actor = 主角" default survive for an actor it didn't mark as a lead —
|
|
2653
|
+
// otherwise an omitted first-actor line would leave the script with two 主角.
|
|
2654
|
+
const modelAssignsRoles = [...parsed.actors.values()].some((a) => ROLE_TYPE_VALUES.includes(a.role));
|
|
2655
|
+
for (const actor of base["actors"]) {
|
|
2656
|
+
const item = parsed.actors.get(strOf(actor["actor_id"]));
|
|
2657
|
+
if (item?.description)
|
|
2658
|
+
actor["description"] = item.description;
|
|
2659
|
+
if (item && ROLE_TYPE_VALUES.includes(item.role))
|
|
2660
|
+
actor["role_type"] = item.role;
|
|
2661
|
+
else if (modelAssignsRoles)
|
|
2662
|
+
actor["role_type"] = "配角";
|
|
2663
|
+
}
|
|
2664
|
+
for (const loc of base["locations"]) {
|
|
2665
|
+
const desc = parsed.locations.get(strOf(loc["location_id"]));
|
|
2666
|
+
if (desc)
|
|
2667
|
+
loc["description"] = desc;
|
|
2668
|
+
}
|
|
2669
|
+
for (const prop of base["props"]) {
|
|
2670
|
+
const desc = parsed.props.get(strOf(prop["prop_id"]));
|
|
2671
|
+
if (desc)
|
|
2672
|
+
prop["description"] = desc;
|
|
2673
|
+
}
|
|
2674
|
+
base["confidence"] = "high";
|
|
2675
|
+
return base;
|
|
2676
|
+
}
|
|
2677
|
+
// Parse a metadata markdown response into the deterministicExtractMetadata dict
|
|
2678
|
+
// shape. Total by design (always returns a complete dict); the provider boundary
|
|
2679
|
+
// guards conformance via metadataMdSectionsEmpty before calling the overlay.
|
|
2680
|
+
export function parseMarkdownMetadata(raw, script) {
|
|
2681
|
+
return overlayMetadataSections(parseMetadataMdSections(raw), script);
|
|
2682
|
+
}
|
|
2383
2683
|
export function applyMetadataToScript(script, metadata) {
|
|
2384
2684
|
script["worldview"] = metadata["worldview"];
|
|
2385
2685
|
script["worldview_raw"] = strOf(metadata["worldview_raw"]).trim();
|
|
@@ -2452,52 +2752,6 @@ function compactStateNames(asset) {
|
|
|
2452
2752
|
}
|
|
2453
2753
|
return out;
|
|
2454
2754
|
}
|
|
2455
|
-
function usagePayload(usage) {
|
|
2456
|
-
return {
|
|
2457
|
-
episode_count: usage.episodes.size,
|
|
2458
|
-
scene_count: usage.scenes.size,
|
|
2459
|
-
episodes: [...usage.episodes].sort().slice(0, 12),
|
|
2460
|
-
scenes: [...usage.scenes].sort().slice(0, 12),
|
|
2461
|
-
};
|
|
2462
|
-
}
|
|
2463
|
-
export function buildAssetCurationContext(script) {
|
|
2464
|
-
const locationUsage = sceneAssetUsage(script, "locations", "location_id");
|
|
2465
|
-
return {
|
|
2466
|
-
title: script["title"],
|
|
2467
|
-
guidance: [
|
|
2468
|
-
"Decide from the script context, not from asset names alone.",
|
|
2469
|
-
"Merge a location only when it is clearly the same stable space as an existing target location.",
|
|
2470
|
-
"Keep a location whenever no stable parent location is clear.",
|
|
2471
|
-
],
|
|
2472
|
-
locations: asList(script["locations"]).filter(isDict).map((loc) => ({
|
|
2473
|
-
location_id: loc["location_id"],
|
|
2474
|
-
location_name: loc["location_name"],
|
|
2475
|
-
description: loc["description"],
|
|
2476
|
-
aliases: isList(loc["aliases"]) ? loc["aliases"] : [],
|
|
2477
|
-
states: compactStateNames(loc),
|
|
2478
|
-
usage: usagePayload(locationUsage.get(strOf(loc["location_id"])) ?? { episodes: new Set(), scenes: new Set() }),
|
|
2479
|
-
examples: locationUsageExamples(script, strOf(loc["location_id"])),
|
|
2480
|
-
})),
|
|
2481
|
-
};
|
|
2482
|
-
}
|
|
2483
|
-
export function locationUsageExamples(script, locationId) {
|
|
2484
|
-
const examples = [];
|
|
2485
|
-
for (const ep of asList(script["episodes"])) {
|
|
2486
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2487
|
-
const refs = asList(sceneContext(scene)["locations"]);
|
|
2488
|
-
const found = refs.some((ref) => isDict(ref) && ref["location_id"] === locationId);
|
|
2489
|
-
if (!found)
|
|
2490
|
-
continue;
|
|
2491
|
-
const example = sceneExample(scene);
|
|
2492
|
-
if (example) {
|
|
2493
|
-
examples.push({ episode_id: ep["episode_id"], scene_id: scene["scene_id"], example });
|
|
2494
|
-
}
|
|
2495
|
-
if (examples.length >= 4)
|
|
2496
|
-
return examples;
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
return examples;
|
|
2500
|
-
}
|
|
2501
2755
|
function compactAssetRefs(refs, idKey, replacements, droppedIds) {
|
|
2502
2756
|
const compacted = [];
|
|
2503
2757
|
const seen = new Set();
|
|
@@ -3165,6 +3419,13 @@ export function mergeEpisodeResults(results, title) {
|
|
|
3165
3419
|
worldview: "",
|
|
3166
3420
|
worldview_raw: "",
|
|
3167
3421
|
style: "",
|
|
3422
|
+
// Whole-script overview placeholders — filled by the summary stage (the
|
|
3423
|
+
// synopsis reduce) after merge. Present-but-empty keeps consumer fields
|
|
3424
|
+
// stable and gives the summary stage a well-defined slot to write into.
|
|
3425
|
+
synopsis: "",
|
|
3426
|
+
theme: "",
|
|
3427
|
+
logline: "",
|
|
3428
|
+
main_characters: [],
|
|
3168
3429
|
actors,
|
|
3169
3430
|
locations,
|
|
3170
3431
|
props,
|