@lingjingai/scriptctl 0.15.0 → 0.19.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.
@@ -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
- return { sc: compactScenes };
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
  // ---------------------------------------------------------------------------
@@ -2380,6 +2556,95 @@ export function deterministicExtractMetadata(script) {
2380
2556
  })),
2381
2557
  };
2382
2558
  }
2559
+ // Split a metadata markdown document (see METADATA_MD_SPEC) into its raw pieces.
2560
+ // Tolerant by design: unknown headings reset the section, non-bullet lines under
2561
+ // asset sections are ignored, and a missing field just yields "". The id-keyed
2562
+ // maps and the raw worldview line are validated/backfilled by the caller.
2563
+ function parseMetadataMdSections(raw) {
2564
+ const actors = new Map();
2565
+ const locations = new Map();
2566
+ const props = new Map();
2567
+ let worldviewRaw = "";
2568
+ let section = "";
2569
+ for (const lineRaw of raw.split("\n")) {
2570
+ const line = lineRaw.trim();
2571
+ if (!line)
2572
+ continue;
2573
+ if (line.startsWith("#")) {
2574
+ const heading = line.replace(/^#+/, "").trim();
2575
+ if (heading.includes("世界观"))
2576
+ section = "worldview";
2577
+ else if (heading.includes("人物"))
2578
+ section = "actors";
2579
+ else if (heading.includes("场景"))
2580
+ section = "locations";
2581
+ else if (heading.includes("道具"))
2582
+ section = "props";
2583
+ else
2584
+ section = "";
2585
+ continue;
2586
+ }
2587
+ if (section === "worldview") {
2588
+ if (!worldviewRaw)
2589
+ worldviewRaw = line;
2590
+ continue;
2591
+ }
2592
+ if (section === "")
2593
+ continue;
2594
+ const body = line.replace(/^[-*]\s*/, "").trim();
2595
+ const parts = body.split("|").map((s) => s.trim());
2596
+ const id = parts[0] ?? "";
2597
+ if (!id)
2598
+ continue;
2599
+ if (section === "actors")
2600
+ actors.set(id, { role: parts[1] ?? "", description: parts[2] ?? "" });
2601
+ else if (section === "locations")
2602
+ locations.set(id, parts[1] ?? "");
2603
+ else
2604
+ props.set(id, parts[1] ?? "");
2605
+ }
2606
+ return { worldviewRaw, actors, locations, props };
2607
+ }
2608
+ // Parse a metadata markdown response into the same dict shape as
2609
+ // deterministicExtractMetadata. Strategy: start from the deterministic baseline
2610
+ // (guarantees every script asset is present with a valid role/description), then
2611
+ // overlay the model's worldview/role/description wherever it supplied a usable
2612
+ // value. So a truncated or partial response still produces a complete,
2613
+ // schema-valid result — missing assets simply keep their deterministic default.
2614
+ export function parseMarkdownMetadata(raw, script) {
2615
+ const base = deterministicExtractMetadata(script);
2616
+ const parsed = parseMetadataMdSections(raw);
2617
+ const worldviewRaw = parsed.worldviewRaw.trim();
2618
+ if (worldviewRaw) {
2619
+ const matched = WORLDVIEW_VALUES.includes(worldviewRaw)
2620
+ ? worldviewRaw
2621
+ : WORLDVIEW_VALUES.find((v) => worldviewRaw.includes(v));
2622
+ if (matched)
2623
+ base["worldview"] = matched;
2624
+ base["worldview_raw"] = worldviewRaw;
2625
+ }
2626
+ for (const actor of base["actors"]) {
2627
+ const item = parsed.actors.get(strOf(actor["actor_id"]));
2628
+ if (!item)
2629
+ continue;
2630
+ if (ROLE_TYPE_VALUES.includes(item.role))
2631
+ actor["role_type"] = item.role;
2632
+ if (item.description)
2633
+ actor["description"] = item.description;
2634
+ }
2635
+ for (const loc of base["locations"]) {
2636
+ const desc = parsed.locations.get(strOf(loc["location_id"]));
2637
+ if (desc)
2638
+ loc["description"] = desc;
2639
+ }
2640
+ for (const prop of base["props"]) {
2641
+ const desc = parsed.props.get(strOf(prop["prop_id"]));
2642
+ if (desc)
2643
+ prop["description"] = desc;
2644
+ }
2645
+ base["confidence"] = "high";
2646
+ return base;
2647
+ }
2383
2648
  export function applyMetadataToScript(script, metadata) {
2384
2649
  script["worldview"] = metadata["worldview"];
2385
2650
  script["worldview_raw"] = strOf(metadata["worldview_raw"]).trim();
@@ -2452,52 +2717,6 @@ function compactStateNames(asset) {
2452
2717
  }
2453
2718
  return out;
2454
2719
  }
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
2720
  function compactAssetRefs(refs, idKey, replacements, droppedIds) {
2502
2721
  const compacted = [];
2503
2722
  const seen = new Set();
@@ -3165,6 +3384,13 @@ export function mergeEpisodeResults(results, title) {
3165
3384
  worldview: "",
3166
3385
  worldview_raw: "",
3167
3386
  style: "",
3387
+ // Whole-script overview placeholders — filled by the summary stage (the
3388
+ // synopsis reduce) after merge. Present-but-empty keeps consumer fields
3389
+ // stable and gives the summary stage a well-defined slot to write into.
3390
+ synopsis: "",
3391
+ theme: "",
3392
+ logline: "",
3393
+ main_characters: [],
3168
3394
  actors,
3169
3395
  locations,
3170
3396
  props,