@lingjingai/scriptctl 0.19.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.
@@ -154,6 +154,18 @@ export declare function clipText(text: unknown, limit?: number): string;
154
154
  export declare function sceneExample(scene: Dict, limit?: number): string;
155
155
  export declare function buildMetadataContext(script: Dict): Dict;
156
156
  export declare function deterministicExtractMetadata(script: Dict): Dict;
157
+ export interface MetadataMdSections {
158
+ worldviewRaw: string;
159
+ actors: Map<string, {
160
+ role: string;
161
+ description: string;
162
+ }>;
163
+ locations: Map<string, string>;
164
+ props: Map<string, string>;
165
+ }
166
+ export declare function parseMetadataMdSections(raw: string): MetadataMdSections;
167
+ export declare function metadataMdSectionsEmpty(s: MetadataMdSections): boolean;
168
+ export declare function overlayMetadataSections(parsed: MetadataMdSections, script: Dict): Dict;
157
169
  export declare function parseMarkdownMetadata(raw: string, script: Dict): Dict;
158
170
  export declare function applyMetadataToScript(script: Dict, metadata: Dict): void;
159
171
  interface AssetUsage {
@@ -2544,7 +2544,10 @@ export function deterministicExtractMetadata(script) {
2544
2544
  return {
2545
2545
  confidence: "high",
2546
2546
  worldview: "现代",
2547
- 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: "现代",
2548
2551
  actors,
2549
2552
  locations: asList(script["locations"]).map((loc) => ({
2550
2553
  location_id: loc["location_id"],
@@ -2557,10 +2560,15 @@ export function deterministicExtractMetadata(script) {
2557
2560
  };
2558
2561
  }
2559
2562
  // 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) {
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) {
2564
2572
  const actors = new Map();
2565
2573
  const locations = new Map();
2566
2574
  const props = new Map();
@@ -2572,14 +2580,20 @@ function parseMetadataMdSections(raw) {
2572
2580
  continue;
2573
2581
  if (line.startsWith("#")) {
2574
2582
  const heading = line.replace(/^#+/, "").trim();
2575
- if (heading.includes("世界观"))
2576
- section = "worldview";
2577
- else if (heading.includes("人物"))
2583
+ const inlineWorldview = heading.match(/^世界观[::]\s*(.+)$/);
2584
+ if (heading === "人物")
2578
2585
  section = "actors";
2579
- else if (heading.includes("场景"))
2586
+ else if (heading === "场景")
2580
2587
  section = "locations";
2581
- else if (heading.includes("道具"))
2588
+ else if (heading === "道具")
2582
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
+ }
2583
2597
  else
2584
2598
  section = "";
2585
2599
  continue;
@@ -2591,46 +2605,61 @@ function parseMetadataMdSections(raw) {
2591
2605
  }
2592
2606
  if (section === "")
2593
2607
  continue;
2594
- const body = line.replace(/^[-*]\s*/, "").trim();
2595
- const parts = body.split("|").map((s) => s.trim());
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());
2596
2611
  const id = parts[0] ?? "";
2597
2612
  if (!id)
2598
2613
  continue;
2599
2614
  if (section === "actors")
2600
- actors.set(id, { role: parts[1] ?? "", description: parts[2] ?? "" });
2615
+ actors.set(id, { role: parts[1] ?? "", description: parts.slice(2).join(" | ").trim() });
2601
2616
  else if (section === "locations")
2602
- locations.set(id, parts[1] ?? "");
2617
+ locations.set(id, parts.slice(1).join(" | ").trim());
2603
2618
  else
2604
- props.set(id, parts[1] ?? "");
2619
+ props.set(id, parts.slice(1).join(" | ").trim());
2605
2620
  }
2606
2621
  return { worldviewRaw, actors, locations, props };
2607
2622
  }
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) {
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) {
2615
2645
  const base = deterministicExtractMetadata(script);
2616
- const parsed = parseMetadataMdSections(raw);
2617
2646
  const worldviewRaw = parsed.worldviewRaw.trim();
2618
2647
  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
2648
  base["worldview_raw"] = worldviewRaw;
2649
+ base["worldview"] = coerceWorldview(worldviewRaw) ?? base["worldview"];
2625
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));
2626
2655
  for (const actor of base["actors"]) {
2627
2656
  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)
2657
+ if (item?.description)
2633
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"] = "配角";
2634
2663
  }
2635
2664
  for (const loc of base["locations"]) {
2636
2665
  const desc = parsed.locations.get(strOf(loc["location_id"]));
@@ -2645,6 +2674,12 @@ export function parseMarkdownMetadata(raw, script) {
2645
2674
  base["confidence"] = "high";
2646
2675
  return base;
2647
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
+ }
2648
2683
  export function applyMetadataToScript(script, metadata) {
2649
2684
  script["worldview"] = metadata["worldview"];
2650
2685
  script["worldview_raw"] = strOf(metadata["worldview_raw"]).trim();