@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -2368,7 +2544,10 @@ export function deterministicExtractMetadata(script) {
2368
2544
  return {
2369
2545
  confidence: "high",
2370
2546
  worldview: "现代",
2371
- worldview_raw: "mock provider generated metadata for CLI tests",
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,