json-repair.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. /**
  2. * JSON parsing with fallback strategies for AI-generated responses.
  3. */
  4. import { jsonrepair } from 'jsonrepair';
  5. import { createLogger } from '@/lib/logger';
  6. const log = createLogger('Generation');
  7. export function parseJsonResponse<T>(response: string): T | null {
  8. // Strategy 1: Try to extract JSON from markdown code blocks (may have multiple)
  9. const codeBlockMatches = response.matchAll(/```(?:json)?\s*([\s\S]*?)```/g);
  10. for (const match of codeBlockMatches) {
  11. const extracted = match[1].trim();
  12. // Only try if it looks like JSON (starts with { or [)
  13. if (extracted.startsWith('{') || extracted.startsWith('[')) {
  14. const result = tryParseJson<T>(extracted);
  15. if (result !== null) {
  16. log.debug('Successfully parsed JSON from code block');
  17. return result;
  18. }
  19. }
  20. }
  21. // Strategy 2: Try to find JSON structure directly in response (no code block)
  22. // Look for array or object start
  23. const jsonStartArray = response.indexOf('[');
  24. const jsonStartObject = response.indexOf('{');
  25. if (jsonStartArray !== -1 || jsonStartObject !== -1) {
  26. // Prefer the structure that appears first
  27. const startIndex =
  28. jsonStartArray === -1
  29. ? jsonStartObject
  30. : jsonStartObject === -1
  31. ? jsonStartArray
  32. : Math.min(jsonStartArray, jsonStartObject);
  33. // Find the matching close bracket
  34. let depth = 0;
  35. let endIndex = -1;
  36. let inString = false;
  37. let escapeNext = false;
  38. for (let i = startIndex; i < response.length; i++) {
  39. const char = response[i];
  40. if (escapeNext) {
  41. escapeNext = false;
  42. continue;
  43. }
  44. if (char === '\\' && inString) {
  45. escapeNext = true;
  46. continue;
  47. }
  48. if (char === '"' && !escapeNext) {
  49. inString = !inString;
  50. continue;
  51. }
  52. if (!inString) {
  53. if (char === '[' || char === '{') depth++;
  54. else if (char === ']' || char === '}') {
  55. depth--;
  56. if (depth === 0) {
  57. endIndex = i;
  58. break;
  59. }
  60. }
  61. }
  62. }
  63. if (endIndex !== -1) {
  64. const jsonStr = response.substring(startIndex, endIndex + 1);
  65. const result = tryParseJson<T>(jsonStr);
  66. if (result !== null) {
  67. log.debug('Successfully parsed JSON from response body');
  68. return result;
  69. }
  70. }
  71. }
  72. // Strategy 3: Last resort - try the whole response
  73. const result = tryParseJson<T>(response.trim());
  74. if (result !== null) {
  75. log.debug('Successfully parsed raw response as JSON');
  76. return result;
  77. }
  78. log.error('Failed to parse JSON from response');
  79. log.error('Raw response (first 500 chars):', response.substring(0, 500));
  80. return null;
  81. }
  82. /**
  83. * Try to parse JSON with various fixes for common AI response issues
  84. */
  85. export function tryParseJson<T>(jsonStr: string): T | null {
  86. // Attempt 1: Try parsing as-is
  87. try {
  88. return JSON.parse(jsonStr) as T;
  89. } catch {
  90. // Continue to fix attempts
  91. }
  92. // Attempt 2: Fix common JSON issues from AI responses
  93. try {
  94. let fixed = jsonStr;
  95. // Fix 1: Handle LaTeX-style escapes that break JSON (e.g., \frac, \left, \right, \times, etc.)
  96. // These are common in math content and need to be double-escaped
  97. // Match backslash followed by letters (LaTeX commands) inside strings,
  98. // but skip valid JSON escape sequences (\b, \f, \n, \r, \t, \u)
  99. fixed = fixed.replace(/"([^"\\]*(?:\\.[^"\\]*)*)"/g, (_match, content) => {
  100. // Double-escape backslash+letter ONLY for non-JSON-escape letters
  101. const fixedContent = content.replace(/\\([a-zA-Z])/g, (_m: string, ch: string) => {
  102. // Preserve valid JSON escape sequences
  103. if ('bfnrtu'.includes(ch)) return `\\${ch}`;
  104. return `\\\\${ch}`;
  105. });
  106. return `"${fixedContent}"`;
  107. });
  108. // Fix 2: Fix other invalid escape sequences (e.g., \S, \L, etc.)
  109. // Valid JSON escapes: \", \\, \/, \b, \f, \n, \r, \t, \uXXXX
  110. fixed = fixed.replace(/\\([^"\\\/bfnrtu\n\r])/g, (match, char) => {
  111. // If it's a letter, it's likely a LaTeX command
  112. if (/[a-zA-Z]/.test(char)) {
  113. return '\\\\' + char;
  114. }
  115. return match;
  116. });
  117. // Fix 3: Try to fix truncated JSON arrays/objects
  118. const trimmed = fixed.trim();
  119. if (trimmed.startsWith('[') && !trimmed.endsWith(']')) {
  120. const lastCompleteObj = fixed.lastIndexOf('}');
  121. if (lastCompleteObj > 0) {
  122. fixed = fixed.substring(0, lastCompleteObj + 1) + ']';
  123. log.warn('Fixed truncated JSON array');
  124. }
  125. } else if (trimmed.startsWith('{') && !trimmed.endsWith('}')) {
  126. // Try to close incomplete object
  127. const openBraces = (fixed.match(/{/g) || []).length;
  128. const closeBraces = (fixed.match(/}/g) || []).length;
  129. if (openBraces > closeBraces) {
  130. fixed += '}'.repeat(openBraces - closeBraces);
  131. log.warn('Fixed truncated JSON object');
  132. }
  133. }
  134. return JSON.parse(fixed) as T;
  135. } catch {
  136. // Continue to next attempt
  137. }
  138. // Attempt 3: Use jsonrepair to fix malformed JSON (e.g. unescaped quotes in Chinese text)
  139. try {
  140. const repaired = jsonrepair(jsonStr);
  141. return JSON.parse(repaired) as T;
  142. } catch {
  143. // Continue to next attempt
  144. }
  145. // Attempt 4: More aggressive fixing - remove control characters
  146. try {
  147. let fixed = jsonStr;
  148. // Remove or escape control characters
  149. fixed = fixed.replace(/[\x00-\x1F\x7F]/g, (char) => {
  150. switch (char) {
  151. case '\n':
  152. return '\\n';
  153. case '\r':
  154. return '\\r';
  155. case '\t':
  156. return '\\t';
  157. default:
  158. return '';
  159. }
  160. });
  161. return JSON.parse(fixed) as T;
  162. } catch {
  163. return null;
  164. }
  165. }