action-parser.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /**
  2. * Action Parser - converts structured JSON Array output to Action[]
  3. *
  4. * Bridges the stateless-generate parser (used for online streaming) with the
  5. * offline generation pipeline, producing typed Action objects that preserve
  6. * the original interleaving order from the LLM output.
  7. *
  8. * For complete (non-streaming) responses, uses JSON.parse with partial-json
  9. * fallback for robustness.
  10. */
  11. import type { Action, ActionType } from '@/lib/types/action';
  12. import { SLIDE_ONLY_ACTIONS } from '@/lib/types/action';
  13. import { nanoid } from 'nanoid';
  14. import { parse as parsePartialJson, Allow } from 'partial-json';
  15. import { jsonrepair } from 'jsonrepair';
  16. import { createLogger } from '@/lib/logger';
  17. const log = createLogger('ActionParser');
  18. /**
  19. * Strip markdown code fences (```json ... ``` or ``` ... ```) from a response string.
  20. */
  21. function stripCodeFences(text: string): string {
  22. // Remove opening ```json or ``` and closing ```
  23. return text.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?\s*```\s*$/i, '');
  24. }
  25. /**
  26. * Parse a complete LLM response in JSON Array format into an ordered Action[] array.
  27. *
  28. * Expected format (new):
  29. * [{"type":"action","name":"spotlight","params":{"elementId":"..."}},
  30. * {"type":"text","content":"speech content"},...]
  31. *
  32. * Also supports legacy format:
  33. * [{"type":"action","tool_name":"spotlight","parameters":{"elementId":"..."}},...]
  34. *
  35. * Text items become `speech` actions; action items are converted to their
  36. * respective action types (spotlight, discussion, etc.).
  37. * The original interleaving order is preserved.
  38. */
  39. export function parseActionsFromStructuredOutput(
  40. response: string,
  41. sceneType?: string,
  42. allowedActions?: string[],
  43. ): Action[] {
  44. // Step 1: Strip markdown code fences if present
  45. const cleaned = stripCodeFences(response.trim());
  46. // Step 2: Find the JSON array range
  47. const startIdx = cleaned.indexOf('[');
  48. const endIdx = cleaned.lastIndexOf(']');
  49. if (startIdx === -1) {
  50. log.warn('No JSON array found in response');
  51. return [];
  52. }
  53. const jsonStr = endIdx > startIdx ? cleaned.slice(startIdx, endIdx + 1) : cleaned.slice(startIdx); // unclosed array — let partial-json handle it
  54. // Step 3: Parse — try JSON.parse first, then jsonrepair, fallback to partial-json
  55. let items: unknown[];
  56. try {
  57. items = JSON.parse(jsonStr);
  58. } catch {
  59. // Try jsonrepair to fix malformed JSON (e.g. unescaped quotes in Chinese text)
  60. try {
  61. items = JSON.parse(jsonrepair(jsonStr));
  62. log.info('Recovered malformed JSON via jsonrepair');
  63. } catch {
  64. try {
  65. items = parsePartialJson(
  66. jsonStr,
  67. Allow.ARR | Allow.OBJ | Allow.STR | Allow.NUM | Allow.BOOL | Allow.NULL,
  68. );
  69. } catch (e) {
  70. log.warn('Failed to parse JSON array:', (e as Error).message);
  71. return [];
  72. }
  73. }
  74. }
  75. if (!Array.isArray(items)) {
  76. log.warn('Parsed result is not an array');
  77. return [];
  78. }
  79. // Step 4: Convert items to Action[]
  80. const actions: Action[] = [];
  81. for (const item of items) {
  82. if (!item || typeof item !== 'object' || !('type' in item)) continue;
  83. const typedItem = item as Record<string, unknown>;
  84. if (typedItem.type === 'text') {
  85. const text = ((typedItem.content as string) || '').trim();
  86. if (text) {
  87. actions.push({
  88. id: `action_${nanoid(8)}`,
  89. type: 'speech',
  90. text,
  91. });
  92. }
  93. } else if (typedItem.type === 'action') {
  94. try {
  95. // Support both new format (name/params) and legacy format (tool_name/parameters)
  96. const actionName = typedItem.name || typedItem.tool_name;
  97. const actionParams = (typedItem.params || typedItem.parameters || {}) as Record<
  98. string,
  99. unknown
  100. >;
  101. actions.push({
  102. id: (typedItem.action_id || typedItem.tool_id || `action_${nanoid(8)}`) as string,
  103. type: actionName as Action['type'],
  104. ...actionParams,
  105. } as Action);
  106. } catch (_e) {
  107. log.warn('Invalid action item, skipping:', JSON.stringify(typedItem).slice(0, 100));
  108. }
  109. }
  110. }
  111. // Step 5: Post-processing — discussion must be the last action, and at most one
  112. const discussionIdx = actions.findIndex((a) => a.type === 'discussion');
  113. if (discussionIdx !== -1 && discussionIdx < actions.length - 1) {
  114. actions.splice(discussionIdx + 1);
  115. }
  116. // Step 6: Filter out slide-only actions for non-slide scenes (defense in depth)
  117. let result = actions;
  118. if (sceneType && sceneType !== 'slide') {
  119. const before = result.length;
  120. result = result.filter((a) => !SLIDE_ONLY_ACTIONS.includes(a.type as ActionType));
  121. if (result.length < before) {
  122. log.info(`Stripped ${before - result.length} slide-only action(s) from ${sceneType} scene`);
  123. }
  124. }
  125. // Step 7: Filter by allowedActions whitelist (defense in depth for role-based isolation)
  126. // Catches hallucinated actions not in the agent's permitted set, e.g. a student agent
  127. // mimicking spotlight/laser after seeing teacher actions in chat history.
  128. if (allowedActions && allowedActions.length > 0) {
  129. const before = result.length;
  130. result = result.filter((a) => a.type === 'speech' || allowedActions.includes(a.type));
  131. if (result.length < before) {
  132. log.info(
  133. `Stripped ${before - result.length} disallowed action(s) by allowedActions whitelist`,
  134. );
  135. }
  136. }
  137. return result;
  138. }