From 4d05d144f8378852885d29ed69ed29c8148a47ba Mon Sep 17 00:00:00 2001 From: Kurvaz Date: Mon, 19 Jan 2026 03:41:09 -0700 Subject: [PATCH] Use jsonHealing to improve consistency --- package-lock.json | 10 ++ package.json | 1 + src/lib/services/ai/actionChoices.ts | 46 +++---- src/lib/services/ai/agenticRetrieval.ts | 5 +- src/lib/services/ai/classifier.ts | 78 +++++------- src/lib/services/ai/context.ts | 7 +- src/lib/services/ai/entryRetrieval.ts | 16 +-- src/lib/services/ai/imagePrompt.ts | 45 +++---- src/lib/services/ai/interactiveLorebook.ts | 9 +- src/lib/services/ai/jsonHealing.ts | 24 ++++ src/lib/services/ai/loreManagement.ts | 5 +- src/lib/services/ai/memory.ts | 136 +++++++++------------ src/lib/services/ai/scenario.ts | 46 +------ src/lib/services/ai/styleReviewer.ts | 83 ++++++------- src/lib/services/ai/suggestions.ts | 46 +++---- 15 files changed, 242 insertions(+), 315 deletions(-) create mode 100644 src/lib/services/ai/jsonHealing.ts diff --git a/package-lock.json b/package-lock.json index 65c2e37..104985e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "gpt-tokenizer": "^3.4.0", "harper.js": "^1.2.0", "html5-qrcode": "^2.3.8", + "jsonrepair": "^3.13.2", "lucide-svelte": "^0.468.0", "marked": "^17.0.1" }, @@ -1930,6 +1931,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jsonrepair": { + "version": "3.13.2", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", + "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/package.json b/package.json index bd93c93..f05c450 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "gpt-tokenizer": "^3.4.0", "harper.js": "^1.2.0", "html5-qrcode": "^2.3.8", + "jsonrepair": "^3.13.2", "lucide-svelte": "^0.468.0", "marked": "^17.0.1" }, diff --git a/src/lib/services/ai/actionChoices.ts b/src/lib/services/ai/actionChoices.ts index 9acc193..fa436e7 100644 --- a/src/lib/services/ai/actionChoices.ts +++ b/src/lib/services/ai/actionChoices.ts @@ -3,6 +3,7 @@ import type { StoryEntry, Character, Location, Item, StoryBeat, Entry, Generatio import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -209,33 +210,26 @@ Match their vocabulary, tone, and phrasing patterns.`; } private parseChoices(content: string): ActionChoicesResult { - try { - let jsonStr = content.trim(); - if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7); - if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3); - if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3); - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - const choices: ActionChoice[] = []; - - if (Array.isArray(parsed.choices)) { - for (const c of parsed.choices.slice(0, 4)) { - if (c.text) { - choices.push({ - text: c.text, - type: ['action', 'dialogue', 'examine', 'move'].includes(c.type) - ? c.type - : 'action', - }); - } - } - } - - return { choices }; - } catch (e) { - log('Failed to parse choices:', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse choices'); return { choices: [] }; } + + const choices: ActionChoice[] = []; + if (Array.isArray(parsed.choices)) { + for (const c of parsed.choices.slice(0, 4)) { + if (c.text) { + choices.push({ + text: c.text, + type: ['action', 'dialogue', 'examine', 'move'].includes(c.type) + ? c.type + : 'action', + }); + } + } + } + + return { choices }; } } diff --git a/src/lib/services/ai/agenticRetrieval.ts b/src/lib/services/ai/agenticRetrieval.ts index 4abb03a..26662a7 100644 --- a/src/lib/services/ai/agenticRetrieval.ts +++ b/src/lib/services/ai/agenticRetrieval.ts @@ -14,6 +14,7 @@ import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import type { ReasoningEffort } from '$lib/types'; import { promptService, type PromptContext, type StoryMode, type POV, type Tense } from '$lib/services/prompts'; +import { parseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -304,7 +305,7 @@ export class AgenticRetrievalService { if (toolCall.function.name === 'finish_retrieval') { complete = true; - const args = JSON.parse(toolCall.function.arguments); + const args = parseJsonWithHealing>(toolCall.function.arguments); retrievedContext = args.summary; } @@ -390,7 +391,7 @@ export class AgenticRetrievalService { onQueryChapter?: (chapterNumber: number, question: string) => Promise, onQueryChapters?: (startChapter: number, endChapter: number, question: string) => Promise ): Promise { - const args = JSON.parse(toolCall.function.arguments); + const args = parseJsonWithHealing>(toolCall.function.arguments); log('Executing retrieval tool:', toolCall.function.name, args); switch (toolCall.function.name) { diff --git a/src/lib/services/ai/classifier.ts b/src/lib/services/ai/classifier.ts index e3c27de..b73dbad 100644 --- a/src/lib/services/ai/classifier.ts +++ b/src/lib/services/ai/classifier.ts @@ -3,6 +3,7 @@ import type { Character, Location, Item, StoryBeat, StoryEntry, TimeTracker, Gen import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -354,54 +355,39 @@ ${formattedEntries} } private parseClassificationResponse(content: string): ClassificationResult { - // Try to extract JSON from the response - let jsonStr = content.trim(); - - // Handle markdown code blocks - if (jsonStr.startsWith('```json')) { - jsonStr = jsonStr.slice(7); - } else if (jsonStr.startsWith('```')) { - jsonStr = jsonStr.slice(3); - } - if (jsonStr.endsWith('```')) { - jsonStr = jsonStr.slice(0, -3); - } - jsonStr = jsonStr.trim(); - - try { - const parsed = JSON.parse(jsonStr); - - // Validate and normalize the structure - return { - entryUpdates: { - characterUpdates: Array.isArray(parsed.entryUpdates?.characterUpdates) - ? parsed.entryUpdates.characterUpdates : [], - locationUpdates: Array.isArray(parsed.entryUpdates?.locationUpdates) - ? parsed.entryUpdates.locationUpdates : [], - itemUpdates: Array.isArray(parsed.entryUpdates?.itemUpdates) - ? parsed.entryUpdates.itemUpdates : [], - storyBeatUpdates: Array.isArray(parsed.entryUpdates?.storyBeatUpdates) - ? parsed.entryUpdates.storyBeatUpdates : [], - newCharacters: Array.isArray(parsed.entryUpdates?.newCharacters) - ? parsed.entryUpdates.newCharacters : [], - newLocations: Array.isArray(parsed.entryUpdates?.newLocations) - ? parsed.entryUpdates.newLocations : [], - newItems: Array.isArray(parsed.entryUpdates?.newItems) - ? parsed.entryUpdates.newItems : [], - newStoryBeats: Array.isArray(parsed.entryUpdates?.newStoryBeats) - ? parsed.entryUpdates.newStoryBeats : [], - }, - scene: { - currentLocationName: parsed.scene?.currentLocationName ?? null, - presentCharacterNames: Array.isArray(parsed.scene?.presentCharacterNames) - ? parsed.scene.presentCharacterNames : [], - timeProgression: parsed.scene?.timeProgression ?? 'none', - }, - }; - } catch (e) { - log('Failed to parse classification JSON', e, 'Content:', jsonStr.substring(0, 200)); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse classification JSON, Content:', content.substring(0, 200)); return this.getEmptyResult(); } + + // Validate and normalize the structure + return { + entryUpdates: { + characterUpdates: Array.isArray(parsed.entryUpdates?.characterUpdates) + ? parsed.entryUpdates.characterUpdates : [], + locationUpdates: Array.isArray(parsed.entryUpdates?.locationUpdates) + ? parsed.entryUpdates.locationUpdates : [], + itemUpdates: Array.isArray(parsed.entryUpdates?.itemUpdates) + ? parsed.entryUpdates.itemUpdates : [], + storyBeatUpdates: Array.isArray(parsed.entryUpdates?.storyBeatUpdates) + ? parsed.entryUpdates.storyBeatUpdates : [], + newCharacters: Array.isArray(parsed.entryUpdates?.newCharacters) + ? parsed.entryUpdates.newCharacters : [], + newLocations: Array.isArray(parsed.entryUpdates?.newLocations) + ? parsed.entryUpdates.newLocations : [], + newItems: Array.isArray(parsed.entryUpdates?.newItems) + ? parsed.entryUpdates.newItems : [], + newStoryBeats: Array.isArray(parsed.entryUpdates?.newStoryBeats) + ? parsed.entryUpdates.newStoryBeats : [], + }, + scene: { + currentLocationName: parsed.scene?.currentLocationName ?? null, + presentCharacterNames: Array.isArray(parsed.scene?.presentCharacterNames) + ? parsed.scene.presentCharacterNames : [], + timeProgression: parsed.scene?.timeProgression ?? 'none', + }, + }; } private getEmptyResult(): ClassificationResult { diff --git a/src/lib/services/ai/context.ts b/src/lib/services/ai/context.ts index f7e9f1b..b49a669 100644 --- a/src/lib/services/ai/context.ts +++ b/src/lib/services/ai/context.ts @@ -12,6 +12,7 @@ import type { Character, Location, Item, StoryBeat, StoryEntry, Chapter } from ' import { settings } from '$lib/stores/settings.svelte'; import type { OpenAIProvider as OpenAIProvider } from './openrouter'; import { promptService } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -353,13 +354,13 @@ export class ContextBuilder { }); // Parse response as JSON array - const match = response.content.match(/\[[\d,\s]*\]/); - if (!match) { + const parsed = tryParseJsonWithHealing(response.content); + if (!parsed || !Array.isArray(parsed)) { log('Failed to parse LLM selection response'); return []; } - const selectedIndices: number[] = JSON.parse(match[0]); + const selectedIndices: number[] = parsed.filter(n => typeof n === 'number'); const selectedEntries: RelevantEntry[] = []; for (const idx of selectedIndices) { diff --git a/src/lib/services/ai/entryRetrieval.ts b/src/lib/services/ai/entryRetrieval.ts index 549c9fc..c6289e2 100644 --- a/src/lib/services/ai/entryRetrieval.ts +++ b/src/lib/services/ai/entryRetrieval.ts @@ -13,6 +13,7 @@ import type { OpenAIProvider as OpenAIProvider } from './openrouter'; import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; /** * Live world state - the actively tracked entities that should always be Tier 1 @@ -604,17 +605,10 @@ export class EntryRetrievalService { // Parse response - look for JSON array of numbers let selectedIndices: number[] = []; - // Try to extract JSON array - const jsonMatch = response.content.match(/\[[\d,\s]*\]/); - if (jsonMatch) { - try { - const parsed = JSON.parse(jsonMatch[0]); - if (Array.isArray(parsed)) { - selectedIndices = parsed.filter(n => typeof n === 'number' && n > 0); - } - } catch { - log('Failed to parse JSON array'); - } + // Try to parse with JSON healing + const parsed = tryParseJsonWithHealing(response.content); + if (parsed && Array.isArray(parsed)) { + selectedIndices = parsed.filter(n => typeof n === 'number' && n > 0); } // Fallback: extract any numbers from the response diff --git a/src/lib/services/ai/imagePrompt.ts b/src/lib/services/ai/imagePrompt.ts index 5e73196..f568385 100644 --- a/src/lib/services/ai/imagePrompt.ts +++ b/src/lib/services/ai/imagePrompt.ts @@ -10,6 +10,7 @@ import type { OpenAIProvider } from './openrouter'; import type { Character } from '$lib/types'; import { buildExtraBody } from './requestOverrides'; import { settings } from '$lib/stores/settings.svelte'; +import { tryParseJsonWithHealing } from './jsonHealing'; /** * Represents a scene identified as suitable for image generation. @@ -201,39 +202,25 @@ export class ImagePromptService { * Parse the AI response into structured imageable scenes. */ private parseResponse(content: string): ImageableScene[] { - try { - // Try to extract JSON from the response - const jsonMatch = content.match(/\[[\s\S]*\]/); - if (!jsonMatch) { - if (this.debug) { - console.log('[ImagePrompt] No JSON array found in response'); - } - return []; - } - - const parsed = JSON.parse(jsonMatch[0]); - - if (!Array.isArray(parsed)) { - return []; - } - - // Validate and filter the parsed data - return parsed - .filter(item => this.isValidScene(item)) - .map(item => ({ - prompt: String(item.prompt), - sourceText: String(item.sourceText), - sceneType: this.normalizeSceneType(item.sceneType), - priority: Math.min(10, Math.max(1, Number(item.priority) || 5)), - characters: this.normalizeCharacters(item.characters), - generatePortrait: Boolean(item.generatePortrait), - })); - } catch (error) { + const parsed = tryParseJsonWithHealing(content); + if (!parsed || !Array.isArray(parsed)) { if (this.debug) { - console.log('[ImagePrompt] Failed to parse response:', error); + console.log('[ImagePrompt] Failed to parse response as array'); } return []; } + + // Validate and filter the parsed data + return parsed + .filter(item => this.isValidScene(item)) + .map(item => ({ + prompt: String((item as any).prompt), + sourceText: String((item as any).sourceText), + sceneType: this.normalizeSceneType((item as any).sceneType), + priority: Math.min(10, Math.max(1, Number((item as any).priority) || 5)), + characters: this.normalizeCharacters((item as any).characters), + generatePortrait: Boolean((item as any).generatePortrait), + })); } /** diff --git a/src/lib/services/ai/interactiveLorebook.ts b/src/lib/services/ai/interactiveLorebook.ts index adc5f8f..602487e 100644 --- a/src/lib/services/ai/interactiveLorebook.ts +++ b/src/lib/services/ai/interactiveLorebook.ts @@ -8,6 +8,7 @@ import type { VaultLorebookEntry, EntryType, EntryInjectionMode } from '$lib/typ import { settings, getDefaultInteractiveLorebookSettings, type InteractiveLorebookSettings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; // Event types for progress updates export type StreamEvent = @@ -629,11 +630,9 @@ export class InteractiveLorebookService { toolCall: ToolCall, entries: VaultLorebookEntry[] ): { result: string; pendingChange?: PendingChange; parsedArgs: Record } { - let args: Record; - try { - args = JSON.parse(toolCall.function.arguments); - } catch (e) { - log('Failed to parse tool call arguments:', toolCall.function.arguments, e); + const args = tryParseJsonWithHealing>(toolCall.function.arguments); + if (!args) { + log('Failed to parse tool call arguments:', toolCall.function.arguments); return { result: JSON.stringify({ error: 'Invalid tool call arguments - malformed JSON' }), parsedArgs: {}, diff --git a/src/lib/services/ai/jsonHealing.ts b/src/lib/services/ai/jsonHealing.ts new file mode 100644 index 0000000..04aa844 --- /dev/null +++ b/src/lib/services/ai/jsonHealing.ts @@ -0,0 +1,24 @@ +import { jsonrepair } from 'jsonrepair'; + +/** + * Parses JSON with automatic repair for common LLM output issues. + * Handles: missing quotes, trailing commas, markdown code blocks, + * truncation, single quotes, Python constants, and more. + * + * @throws {SyntaxError} If JSON cannot be repaired + */ +export function parseJsonWithHealing(content: string): T { + const repaired = jsonrepair(content.trim()); + return JSON.parse(repaired) as T; +} + +/** + * Safe version that returns null on failure instead of throwing. + */ +export function tryParseJsonWithHealing(content: string): T | null { + try { + return parseJsonWithHealing(content); + } catch { + return null; + } +} diff --git a/src/lib/services/ai/loreManagement.ts b/src/lib/services/ai/loreManagement.ts index e7c0a0b..e45239c 100644 --- a/src/lib/services/ai/loreManagement.ts +++ b/src/lib/services/ai/loreManagement.ts @@ -27,6 +27,7 @@ import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import type { ReasoningEffort } from '$lib/types'; import { promptService, type PromptContext, type StoryMode, type POV, type Tense } from '$lib/services/prompts'; +import { parseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -636,7 +637,7 @@ export class LoreManagementService { } private async executeTool(toolCall: ToolCall, context: ToolExecutionContext): Promise { - const args = JSON.parse(toolCall.function.arguments); + const args = parseJsonWithHealing>(toolCall.function.arguments); log('Executing tool:', toolCall.function.name, args); switch (toolCall.function.name) { @@ -663,7 +664,7 @@ export class LoreManagementService { } case 'create_entry': { - const newEntry = this.createEntry(context.storyId, context.branchId, args); + const newEntry = this.createEntry(context.storyId, context.branchId, args as any); context.entries.push(newEntry); await context.onCreateEntry(newEntry); this.changes.push({ type: 'create', entry: newEntry }); diff --git a/src/lib/services/ai/memory.ts b/src/lib/services/ai/memory.ts index e72a79c..e0c9912 100644 --- a/src/lib/services/ai/memory.ts +++ b/src/lib/services/ai/memory.ts @@ -3,6 +3,7 @@ import type { Chapter, StoryEntry, MemoryConfig, TimeTracker, GenerationPreset } import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext, type StoryMode, type POV, type Tense } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; // Format time tracker for display in context (always shows full format) function formatTime(time: TimeTracker | null): string { @@ -424,74 +425,51 @@ NOTE: Only use for reference. This is NOT what you will be summarizing. startIndex: number, entryCount: number ): ChapterAnalysis { - try { - let jsonStr = content.trim(); - if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7); - if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3); - if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3); - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - - // Handle both old format (optimalEndIndex) and new format (chapterEnd) - // chapterEnd is 1-based message ID, optimalEndIndex is relative to startIndex - let endIndex: number; - if (parsed.chapterEnd !== undefined) { - // New format: chapterEnd is absolute 1-based message ID - // Convert to 0-based array index - endIndex = Math.min(Math.max(startIndex + 1, parsed.chapterEnd), startIndex + entryCount); - } else if (parsed.optimalEndIndex !== undefined) { - // Old format: relative index within the chunk - const relativeIndex = Math.min(Math.max(1, parsed.optimalEndIndex), entryCount); - endIndex = startIndex + relativeIndex; - } else { - // Fallback: use end of range - endIndex = startIndex + entryCount; - } - - log('Parsed chapter endpoint', { - chapterEnd: parsed.chapterEnd, - optimalEndIndex: parsed.optimalEndIndex, - startIndex, - entryCount, - finalEndIndex: endIndex, - }); - - return { - shouldCreateChapter: true, - optimalEndIndex: endIndex, - suggestedTitle: parsed.suggestedTitle || null, - }; - } catch (e) { - log('Failed to parse chapter analysis:', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse chapter analysis'); return { shouldCreateChapter: true, optimalEndIndex: startIndex + entryCount, suggestedTitle: null, }; } + + // Handle both old format (optimalEndIndex) and new format (chapterEnd) + // chapterEnd is 1-based message ID, optimalEndIndex is relative to startIndex + let endIndex: number; + if (parsed.chapterEnd !== undefined) { + // New format: chapterEnd is absolute 1-based message ID + // Convert to 0-based array index + endIndex = Math.min(Math.max(startIndex + 1, parsed.chapterEnd), startIndex + entryCount); + } else if (parsed.optimalEndIndex !== undefined) { + // Old format: relative index within the chunk + const relativeIndex = Math.min(Math.max(1, parsed.optimalEndIndex), entryCount); + endIndex = startIndex + relativeIndex; + } else { + // Fallback: use end of range + endIndex = startIndex + entryCount; + } + + log('Parsed chapter endpoint', { + chapterEnd: parsed.chapterEnd, + optimalEndIndex: parsed.optimalEndIndex, + startIndex, + entryCount, + finalEndIndex: endIndex, + }); + + return { + shouldCreateChapter: true, + optimalEndIndex: endIndex, + suggestedTitle: parsed.suggestedTitle || null, + }; } private parseChapterSummary(content: string): ChapterSummary { - try { - let jsonStr = content.trim(); - if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7); - if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3); - if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3); - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - return { - summary: parsed.summary || 'Summary unavailable.', - title: parsed.title || 'Untitled Chapter', - keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [], - characters: Array.isArray(parsed.characters) ? parsed.characters : [], - locations: Array.isArray(parsed.locations) ? parsed.locations : [], - plotThreads: Array.isArray(parsed.plotThreads) ? parsed.plotThreads : [], - emotionalTone: parsed.emotionalTone || 'neutral', - }; - } catch (e) { - log('Failed to parse chapter summary:', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse chapter summary'); return { summary: 'Summary unavailable.', title: 'Untitled Chapter', @@ -502,28 +480,32 @@ NOTE: Only use for reference. This is NOT what you will be summarizing. emotionalTone: 'neutral', }; } + + return { + summary: parsed.summary || 'Summary unavailable.', + title: parsed.title || 'Untitled Chapter', + keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [], + characters: Array.isArray(parsed.characters) ? parsed.characters : [], + locations: Array.isArray(parsed.locations) ? parsed.locations : [], + plotThreads: Array.isArray(parsed.plotThreads) ? parsed.plotThreads : [], + emotionalTone: parsed.emotionalTone || 'neutral', + }; } private parseRetrievalDecision(content: string, maxChapters: number): RetrievalDecision { - try { - let jsonStr = content.trim(); - if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7); - if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3); - if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3); - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - const ids = Array.isArray(parsed.relevantChapterIds) - ? parsed.relevantChapterIds.slice(0, maxChapters) - : []; - const queries = Array.isArray(parsed.queries) - ? parsed.queries.slice(0, maxChapters) - : []; - - return { relevantChapterIds: ids, queries }; - } catch (e) { - log('Failed to parse retrieval decision:', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse retrieval decision'); return { relevantChapterIds: [], queries: [] }; } + + const ids = Array.isArray(parsed.relevantChapterIds) + ? parsed.relevantChapterIds.slice(0, maxChapters) + : []; + const queries = Array.isArray(parsed.queries) + ? parsed.queries.slice(0, maxChapters) + : []; + + return { relevantChapterIds: ids, queries }; } } diff --git a/src/lib/services/ai/scenario.ts b/src/lib/services/ai/scenario.ts index a1af5e5..246f485 100644 --- a/src/lib/services/ai/scenario.ts +++ b/src/lib/services/ai/scenario.ts @@ -5,6 +5,7 @@ import { buildExtraBody } from './requestOverrides'; import type { Message } from './types'; import type { StoryMode, POV, Character, Location, Item } from '$lib/types'; import { promptService, type PromptContext } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -348,49 +349,14 @@ class ScenarioService { } /** - * Robust JSON parsing that handles various markdown code block formats. + * Robust JSON parsing using jsonrepair for automatic healing of malformed JSON. */ private parseJsonResponse(content: string): T | null { - let jsonStr = content.trim(); - - // Method 1: Strip markdown code blocks if the response is wrapped in them - if (jsonStr.startsWith('```')) { - // Remove opening ``` with optional language identifier - jsonStr = jsonStr.replace(/^```(?:json|JSON)?\s*\n?/, ''); - // Remove closing ``` - jsonStr = jsonStr.replace(/\n?```\s*$/, ''); - jsonStr = jsonStr.trim(); + const result = tryParseJsonWithHealing(content); + if (!result) { + log('JSON parsing failed for:', content.substring(0, 200)); } - - // Method 2: Try to parse as-is - try { - return JSON.parse(jsonStr) as T; - } catch { - // Continue to other methods - } - - // Method 3: Extract JSON object {...} from anywhere in the string - const objectMatch = content.match(/\{[\s\S]*\}/); - if (objectMatch) { - try { - return JSON.parse(objectMatch[0]) as T; - } catch { - // Continue - } - } - - // Method 4: Extract JSON array [...] from anywhere in the string - const arrayMatch = content.match(/\[[\s\S]*\]/); - if (arrayMatch) { - try { - return JSON.parse(arrayMatch[0]) as T; - } catch { - // Continue - } - } - - log('All JSON parsing methods failed for:', content.substring(0, 200)); - return null; + return result; } private buildSettingLorebookContext( diff --git a/src/lib/services/ai/styleReviewer.ts b/src/lib/services/ai/styleReviewer.ts index a86e648..c9b9edb 100644 --- a/src/lib/services/ai/styleReviewer.ts +++ b/src/lib/services/ai/styleReviewer.ts @@ -3,6 +3,7 @@ import type { StoryEntry, GenerationPreset } from '$lib/types'; import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext, type StoryMode, type POV, type Tense } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -139,56 +140,42 @@ export class StyleReviewerService { } private parseAnalysisResponse(content: string, entryCount: number): StyleReviewResult { - try { - let jsonStr = content.trim(); - - // Handle markdown code blocks - if (jsonStr.startsWith('```json')) { - jsonStr = jsonStr.slice(7); - } else if (jsonStr.startsWith('```')) { - jsonStr = jsonStr.slice(3); - } - if (jsonStr.endsWith('```')) { - jsonStr = jsonStr.slice(0, -3); - } - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - - const phrases: PhraseAnalysis[] = []; - const rawItems = Array.isArray(parsed.phrases) - ? parsed.phrases - : (Array.isArray(parsed.issues) ? parsed.issues : []); - - for (const item of rawItems.slice(0, 10)) { - const phraseText = item.phrase ?? item.description; - if (phraseText) { - phrases.push({ - phrase: phraseText, - frequency: typeof item.frequency === 'number' - ? item.frequency - : (typeof item.occurrences === 'number' ? item.occurrences : 2), - severity: ['low', 'medium', 'high'].includes(item.severity) ? item.severity : 'low', - alternatives: Array.isArray(item.alternatives) - ? item.alternatives.slice(0, 3) - : (Array.isArray(item.suggestions) ? item.suggestions.slice(0, 3) : []), - contexts: Array.isArray(item.contexts) - ? item.contexts.slice(0, 2) - : (Array.isArray(item.examples) ? item.examples.slice(0, 2) : []), - }); - } - } - - return { - phrases, - overallAssessment: parsed.overallAssessment || parsed.summary || '', - reviewedEntryCount: entryCount, - timestamp: Date.now(), - }; - } catch (e) { - log('Failed to parse style analysis JSON', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse style analysis JSON'); return this.getEmptyResult(); } + + const phrases: PhraseAnalysis[] = []; + const rawItems = Array.isArray(parsed.phrases) + ? parsed.phrases + : (Array.isArray(parsed.issues) ? parsed.issues : []); + + for (const item of rawItems.slice(0, 10)) { + const phraseText = item.phrase ?? item.description; + if (phraseText) { + phrases.push({ + phrase: phraseText, + frequency: typeof item.frequency === 'number' + ? item.frequency + : (typeof item.occurrences === 'number' ? item.occurrences : 2), + severity: ['low', 'medium', 'high'].includes(item.severity) ? item.severity : 'low', + alternatives: Array.isArray(item.alternatives) + ? item.alternatives.slice(0, 3) + : (Array.isArray(item.suggestions) ? item.suggestions.slice(0, 3) : []), + contexts: Array.isArray(item.contexts) + ? item.contexts.slice(0, 2) + : (Array.isArray(item.examples) ? item.examples.slice(0, 2) : []), + }); + } + } + + return { + phrases, + overallAssessment: parsed.overallAssessment || parsed.summary || '', + reviewedEntryCount: entryCount, + timestamp: Date.now(), + }; } private getEmptyResult(): StyleReviewResult { diff --git a/src/lib/services/ai/suggestions.ts b/src/lib/services/ai/suggestions.ts index 2be1d28..4e45f0b 100644 --- a/src/lib/services/ai/suggestions.ts +++ b/src/lib/services/ai/suggestions.ts @@ -3,6 +3,7 @@ import type { StoryEntry, StoryBeat, Entry, GenerationPreset } from '$lib/types' import { settings } from '$lib/stores/settings.svelte'; import { buildExtraBody } from './requestOverrides'; import { promptService, type PromptContext, type StoryMode, type POV, type Tense } from '$lib/services/prompts'; +import { tryParseJsonWithHealing } from './jsonHealing'; const DEBUG = true; @@ -139,33 +140,26 @@ export class SuggestionsService { } private parseSuggestions(content: string): SuggestionsResult { - try { - let jsonStr = content.trim(); - if (jsonStr.startsWith('```json')) jsonStr = jsonStr.slice(7); - if (jsonStr.startsWith('```')) jsonStr = jsonStr.slice(3); - if (jsonStr.endsWith('```')) jsonStr = jsonStr.slice(0, -3); - jsonStr = jsonStr.trim(); - - const parsed = JSON.parse(jsonStr); - const suggestions: StorySuggestion[] = []; - - if (Array.isArray(parsed.suggestions)) { - for (const s of parsed.suggestions.slice(0, 3)) { - if (s.text) { - suggestions.push({ - text: s.text, - type: ['action', 'dialogue', 'revelation', 'twist'].includes(s.type) - ? s.type - : 'action', - }); - } - } - } - - return { suggestions }; - } catch (e) { - log('Failed to parse suggestions:', e); + const parsed = tryParseJsonWithHealing>(content); + if (!parsed) { + log('Failed to parse suggestions'); return { suggestions: [] }; } + + const suggestions: StorySuggestion[] = []; + if (Array.isArray(parsed.suggestions)) { + for (const s of parsed.suggestions.slice(0, 3)) { + if (s.text) { + suggestions.push({ + text: s.text, + type: ['action', 'dialogue', 'revelation', 'twist'].includes(s.type) + ? s.type + : 'action', + }); + } + } + } + + return { suggestions }; } }