generate.ts 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
  1. import {
  2. VideoInputSchema,
  3. detectInputFormat,
  4. LLMClient,
  5. getParsePrompt,
  6. type VideoInput,
  7. type TemplateType,
  8. } from "@pipeline/shared";
  9. import { applyLengthLimits, sanitizeVideoInput, JSON_TRUNCATION_NUDGE } from "./postprocess.js";
  10. import type { TextModuleLlmConfig } from "./types.js";
  11. /**
  12. * LLM text layer — turns raw text (from a collector or the user) into a
  13. * validated, sanitized VideoInput. Ports the input-normalization section of the
  14. * legacy parse stage verbatim:
  15. * - if the text is already valid VideoInputSchema JSON, use it directly;
  16. * - otherwise call the LLM with the template/source parse prompt, stripping
  17. * code fences, retrying once with a conciseness nudge on JSON-parse failure
  18. * (finish_reason="length" truncation is common for large trending lists);
  19. * - clip over-budget fields and strip unsupported glyphs before returning.
  20. *
  21. * Reuses the proven shared prompt (getParsePrompt) unchanged — the data-source
  22. * metadata is merged authoritatively in the assembly layer, so the prompt does
  23. * not need to change for the typed-repos flow.
  24. */
  25. export async function generateVideoInput(
  26. text: string,
  27. template: TemplateType,
  28. llm: TextModuleLlmConfig,
  29. source: string | undefined,
  30. skipLlm?: boolean
  31. ): Promise<VideoInput> {
  32. const detected = detectInputFormat(text);
  33. if (detected.format === "valid-schema") {
  34. return sanitizeVideoInput(detected.parsed!);
  35. }
  36. if (skipLlm || !(llm.apiKey || process.env.OPENAI_API_KEY)) {
  37. throw new Error(
  38. `Input is not valid VideoInputSchema JSON and AI processing is disabled. ` +
  39. `Provide structured JSON matching VideoInputSchema or enable LLM processing (set OPENAI_API_KEY or remove --skip-llm).`
  40. );
  41. }
  42. const client = new LLMClient(llm);
  43. const systemPrompt = getParsePrompt(template, source);
  44. const stripFences = (s: string) =>
  45. s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
  46. let aiParsed: unknown;
  47. let lastFinish: string | null = null;
  48. let lastRaw = "";
  49. for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
  50. const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
  51. const { content, finishReason } = await client.chat(systemPrompt, userMessage);
  52. lastFinish = finishReason;
  53. lastRaw = stripFences(content);
  54. try {
  55. aiParsed = JSON.parse(lastRaw);
  56. } catch {
  57. // not valid JSON yet — fall through to retry, or to the final error below
  58. }
  59. }
  60. if (aiParsed === undefined) {
  61. throw new Error(
  62. `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
  63. `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
  64. );
  65. }
  66. aiParsed = applyLengthLimits(aiParsed, template);
  67. const validationResult = VideoInputSchema.safeParse(aiParsed);
  68. if (!validationResult.success) {
  69. throw new Error(`AI output does not match VideoInputSchema: ${validationResult.error.message}`);
  70. }
  71. return sanitizeVideoInput(validationResult.data);
  72. }