Files

1 line
76 KiB
JSON

{"repo": "banixx/nodges", "files": {"src/utils/LLMService.ts": {"1d177e5d29413112f5bb772b7e53f893c565f544": {"t": 1785596059.2383485, "n": 75005, "h": "dd10882b261dda80", "c": "import { GraphData, GraphDataSchema } from '../types';\nimport { zodToJsonSchema } from 'zod-to-json-schema';\nimport { deduplicateGraph } from './VectorStoreManager';\n\nimport build6PromptRaw from '../prompts/build_6_prompt.md?raw';\nimport build10PromptRaw from '../prompts/build_10_prompt.md?raw';\nimport build10KeywordPromptRaw from '../prompts/build_10_keyword_prompt.md?raw';\nimport build10SparqlPromptRaw from '../prompts/build_10_sparql_prompt.md?raw';\nimport build10ExpansionPromptRaw from '../prompts/build_10_expansion_prompt.md?raw';\n\nexport type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'ollama' | 'lmstudio';\n\nexport interface Build10Config {\n grounding: 'none' | 'wikidata' | 'rag' | 'dedup';\n qualityAssurance: 'none' | 'critic' | 'human';\n ratingMethod: 'llm' | 'taxonomy' | 'embeddings';\n}\n\nexport interface LLMModel {\n id: string;\n name: string;\n}\n\nexport class LLMService {\n public static readonly PROVIDERS: { id: LLMProvider; name: string }[] = [\n { id: 'openrouter', name: 'OpenRouter' },\n { id: 'openai', name: 'OpenAI' },\n { id: 'anthropic', name: 'Anthropic' },\n { id: 'ollama', name: 'Ollama (Lokal)' },\n { id: 'lmstudio', name: 'LM Studio (Lokal)' }\n ];\n\n public static readonly PROVIDER_MODELS: Record<LLMProvider, LLMModel[]> = {\n openrouter: [\n { id: 'kwaipilot/kat-coder-pro-v2.5', name: 'kwaipilot: Kat Coder Pro V2.5' },\n { id: 'openai/gpt-5.6-luna', name: 'OpenAI: GPT-5.6 Luna' },\n { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek: DeepSeek V4 Flash' },\n { id: 'qwen/qwen-2.5-72b-instruct', name: 'Qwen2.5 72B Instruct' },\n { id: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', name: 'NVIDIA: Llama 3.3 Nemotron Super 49B V1.5' },\n { id: 'qwen/qwen3-vl-32b-instruct', name: 'Qwen: Qwen3 VL 32B Instruct' },\n { id: 'tencent/hunyuan-a13b-instruct', name: 'Tencent: Hunyuan A13B Instruct' },\n { id: 'qwen/qwen-plus', name: 'Qwen: Qwen-Plus' },\n { id: 'deepseek/deepseek-chat', name: 'DeepSeek: DeepSeek V3' },\n { id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek: DeepSeek V4 Pro' },\n { id: 'qwen/qwen3.5-35b-a3b', name: 'Qwen: Qwen3.5-35B-A3B' },\n { id: 'qwen/qwen3.7-plus', name: 'Qwen: Qwen3.7 Plus' },\n { id: 'aion-labs/aion-3.0-mini', name: 'AionLabs: Aion-3.0-Mini' },\n { id: 'mistralai/mistral-large-2512', name: 'Mistral: Mistral Large 3 2512' },\n { id: 'google/gemini-3.1-flash-lite', name: 'Google: Gemini 3.1 Flash Lite' },\n { id: 'openai/gpt-4.1-mini', name: 'OpenAI: GPT-4.1 Mini' },\n { id: 'morph/morph-v3-large', name: 'Morph: Morph V3 Large' },\n { id: 'moonshotai/kimi-k2.5', name: 'MoonshotAI: Kimi K2.5' },\n { id: 'google/gemini-2.5-flash', name: 'Google: Gemini 2.5 Flash' },\n { id: 'x-ai/grok-4.20', name: 'xAI: Grok 4.20' },\n { id: 'deepseek/deepseek-r1', name: 'DeepSeek: R1' },\n { id: 'qwen/qwen3-coder-plus', name: 'Qwen: Qwen3 Coder Plus' },\n { id: 'qwen/qwen3.7-max', name: 'Qwen: Qwen3.7 Max' },\n { id: 'qwen/qwen3-max', name: 'Qwen: Qwen3 Max' },\n { id: 'anthropic/claude-haiku-4.5', name: 'Anthropic: Claude Haiku 4.5' },\n { id: 'openai/gpt-oss-safeguard-20b', name: 'OpenAI: GPT OSS Safeguard 20B' },\n { id: 'inception/mercury-2', name: 'Inception: Mercury 2' },\n { id: 'xiaomi/mimo-v2.5', name: 'Xiaomi: MiMo V2.5' },\n { id: 'poolside/laguna-m.1', name: 'Poolside: Laguna M.1' }\n ],\n openai: [\n { id: 'gpt-4o-mini', name: 'GPT-4o Mini' },\n { id: 'gpt-4o', name: 'GPT-4o' },\n { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' }\n ],\n anthropic: [\n { id: 'claude-4-5-haiku-2026', name: 'Claude 4.5 Haiku' },\n { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },\n { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' }\n ],\n ollama: [\n { id: 'qwen2.5:14b', name: 'Qwen 2.5 14B' },\n { id: 'llama3.1:8b', name: 'Llama 3.1 8B' },\n { id: 'mistral-nemo:12b', name: 'Mistral Nemo 12B' }\n ],\n lmstudio: [\n { id: 'local-model', name: 'LM Studio loaded model' }\n ]\n };\n\n /**\n * Get the active provider from localStorage (defaults to 'openrouter')\n */\n public static getActiveProvider(): LLMProvider {\n const provider = localStorage.getItem('llm_provider') as LLMProvider;\n if (provider && this.PROVIDERS.some(p => p.id === provider)) {\n return provider;\n }\n return 'openrouter';\n }\n\n /**\n * Set the active provider in localStorage\n */\n public static setActiveProvider(provider: LLMProvider): void {\n localStorage.setItem('llm_provider', provider);\n }\n\n /**\n * Get the active model for a provider from localStorage\n */\n public static getActiveModel(provider: LLMProvider): string {\n const savedModel = localStorage.getItem(`llm_model_${provider}`);\n if (savedModel) {\n return savedModel;\n }\n // Fallbacks\n if (provider === 'openrouter') return 'morph/morph-v3-large';\n if (provider === 'openai') return 'gpt-4o-mini';\n if (provider === 'anthropic') return 'claude-3-5-sonnet-20241022';\n if (provider === 'ollama') return 'qwen2.5:14b';\n if (provider === 'lmstudio') return 'local-model';\n return '';\n }\n\n /**\n * Set the active model for a provider in localStorage\n */\n public static setActiveModel(provider: LLMProvider, model: string): void {\n localStorage.setItem(`llm_model_${provider}`, model);\n }\n\n public static getApiKey(provider: LLMProvider): string | null {\n if (provider === 'ollama' || provider === 'lmstudio') {\n return 'local';\n }\n // Migration of old openrouter key if needed\n if (provider === 'openrouter') {\n const oldKey = localStorage.getItem('openrouter_api_key');\n const newKey = localStorage.getItem('llm_key_openrouter');\n if (oldKey && !newKey) {\n localStorage.setItem('llm_key_openrouter', oldKey);\n localStorage.removeItem('openrouter_api_key');\n return oldKey;\n }\n \n const localKey = localStorage.getItem('llm_key_openrouter');\n if (localKey) return localKey;\n \n if (import.meta.env && import.meta.env.VITE_OPENROUTER_API_KEY) {\n return import.meta.env.VITE_OPENROUTER_API_KEY;\n }\n \n // Temporaerer Key fuer öffentliche Nutzung ohne eigene Eingabe\n return '';\n }\n \n const localKey = localStorage.getItem(`llm_key_${provider}`);\n if (localKey) return localKey;\n \n if (provider === 'openai' && import.meta.env && import.meta.env.VITE_OPENAI_API_KEY) {\n return import.meta.env.VITE_OPENAI_API_KEY;\n }\n if (provider === 'anthropic' && import.meta.env && import.meta.env.VITE_ANTHROPIC_API_KEY) {\n return import.meta.env.VITE_ANTHROPIC_API_KEY;\n }\n \n return null;\n }\n\n /**\n * Saves the API key for a provider to localStorage.\n */\n public static setApiKey(provider: LLMProvider, key: string): void {\n localStorage.setItem(`llm_key_${provider}`, key.trim());\n }\n\n /**\n * Removes the API key for a provider from localStorage.\n */\n public static clearApiKey(provider: LLMProvider): void {\n localStorage.removeItem(`llm_key_${provider}`);\n if (provider === 'openrouter') {\n localStorage.removeItem('openrouter_api_key');\n }\n }\n\n /**\n * Fetches models from OpenRouter API or returns cached/fallback models.\n */\n public static async fetchOpenRouterModels(): Promise<LLMModel[]> {\n // Testweise auf 5 Mittelklasse-Modelle eingeschränkt\n return this.PROVIDER_MODELS.openrouter;\n }\n\n /**\n * Fetches models from the provider, supporting local Ollama/LM Studio endpoints.\n */\n public static async fetchModelsForProvider(provider: LLMProvider): Promise<LLMModel[]> {\n if (provider === 'openrouter') {\n return this.fetchOpenRouterModels();\n }\n if (provider === 'ollama' || provider === 'lmstudio') {\n const port = provider === 'ollama' ? '11434' : '1234';\n try {\n const res = await fetch(`http://localhost:${port}/v1/models`);\n if (res.ok) {\n const json = await res.json();\n if (json.data && Array.isArray(json.data)) {\n return json.data.map((m: any) => ({\n id: m.id,\n name: m.id\n }));\n }\n }\n } catch (e) {\n console.warn(`[LLMService] Could not fetch local models from ${provider}, using fallbacks`);\n }\n }\n return this.PROVIDER_MODELS[provider] || [];\n }\n\n /**\n * Saves a debug file to the local filesystem via the Vite dev server plugin.\n */\n private static async _saveDebugFile(filename: string, content: string | object): Promise<void> {\n try {\n const fileContent = typeof content === 'string' ? content : JSON.stringify(content, null, 2);\n await fetch('/api/save_graph', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename, content: fileContent })\n });\n } catch (e) {\n console.warn('[LLMService] Failed to save debug file:', filename, e);\n }\n }\n\n /**\n * Converts a permissive JSON schema into a strict schema required by OpenAI/OpenRouter (strict: true).\n * This recursively removes any invalid constraints and sets additionalProperties: false.\n */\n private static makeSchemaStrict(schema: any): any {\n if (typeof schema !== 'object' || schema === null) return schema;\n const newSchema = { ...schema };\n \n // Remove properties that cause errors with strict schema\n delete newSchema.default;\n \n if (newSchema.type === 'object') {\n // Fix: Do not set additionalProperties to false if it's already an object (z.record)\n if (newSchema.additionalProperties === true || newSchema.additionalProperties === undefined) {\n newSchema.additionalProperties = false;\n } else if (typeof newSchema.additionalProperties === 'object') {\n newSchema.additionalProperties = this.makeSchemaStrict(newSchema.additionalProperties);\n }\n\n if (newSchema.properties) {\n for (const key in newSchema.properties) {\n newSchema.properties[key] = this.makeSchemaStrict(newSchema.properties[key]);\n }\n }\n } else if (newSchema.type === 'array' && newSchema.items) {\n newSchema.items = this.makeSchemaStrict(newSchema.items);\n } else if (newSchema.anyOf) {\n newSchema.anyOf = newSchema.anyOf.map((s: any) => this.makeSchemaStrict(s));\n } else if (newSchema.allOf) {\n newSchema.allOf = newSchema.allOf.map((s: any) => this.makeSchemaStrict(s));\n } else if (newSchema.oneOf) {\n newSchema.oneOf = newSchema.oneOf.map((s: any) => this.makeSchemaStrict(s));\n }\n return newSchema;\n }\n\n /**\n * Sends a prompt to the LLM and parses the JSON response into GraphData.\n */\n private static async _executeLLMCall(\n systemPrompt: string,\n userPrompt: string,\n provider: LLMProvider,\n model: string,\n jsonSchema?: any,\n options: { temperature?: number, top_p?: number, top_k?: number } = {}\n ): Promise<GraphData> {\n let apiKey = this.getApiKey(provider);\n let apiUrl = '';\n\n if (provider === 'openrouter') {\n if (!apiKey) {\n apiUrl = 'https://pure-peacock-1215.banixx.deno.net/';\n apiKey = 'proxy-mode'; // Deno Deploy Proxy benötigt keinen Client-Key\n } else {\n apiUrl = 'https://openrouter.ai/api/v1/chat/completions';\n }\n } else if (provider === 'openai') {\n apiUrl = 'https://api.openai.com/v1/chat/completions';\n } else if (provider === 'anthropic') {\n apiUrl = 'https://api.anthropic.com/v1/messages';\n } else if (provider === 'ollama') {\n apiUrl = 'http://localhost:11434/v1/chat/completions';\n } else if (provider === 'lmstudio') {\n apiUrl = 'http://localhost:1234/v1/chat/completions';\n }\n\n if (!apiKey) {\n throw new Error(`Kein API-Key für ${provider} gefunden. Bitte gib deinen API-Key ein.`);\n }\n\n let responseText = '';\n let rawApiData: any = null;\n\n try {\n if (provider === 'openrouter') {\n // First attempt: multi-turn (system + user)\n let response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'HTTP-Referer': window.location.href,\n 'X-Title': 'Nodges 3D Graph',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n model: model,\n provider: { data_collection: 'deny' },\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt }\n ],\n // Use json_object because json_schema strictly forbids dynamic properties (which Nodges relies on)\n response_format: { type: 'json_object' },\n ...options\n })\n });\n\n // Fallback: If model doesn't support multi-turn, retry with single message\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n const errMsg = errorData.error?.message || response.statusText;\n \n if (response.status === 429) {\n throw new Error(`Rate-Limit erreicht (429). Das Modell ist aktuell überlastet oder dein Limit ist erschöpft. Bitte wechsle das Modell oder warte einen Moment.`);\n }\n \n if (response.status === 400) {\n console.warn(`[LLMService] Model ${model} returned 400. Retrying with single message and standard json_object fallback...`);\n const combinedPrompt = `${systemPrompt}\\n\\n---\\nUSER REQUEST:\\n${userPrompt}`;\n response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'HTTP-Referer': window.location.href,\n 'X-Title': 'Nodges 3D Graph',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n model: model,\n provider: { data_collection: 'deny' },\n messages: [\n { role: 'user', content: combinedPrompt }\n ],\n response_format: { type: 'json_object' },\n ...options\n })\n });\n if (!response.ok) {\n const retryError = await response.json().catch(() => ({}));\n throw new Error(`OpenRouter Fehler: ${response.status} - ${retryError.error?.message || response.statusText}`);\n }\n } else {\n throw new Error(`OpenRouter Fehler: ${response.status} - ${errMsg}`);\n }\n }\n\n rawApiData = await response.json();\n responseText = rawApiData?.choices?.[0]?.message?.content;\n \n // Automatic retry if structured outputs (json_schema) failed silently (common with vLLM / OpenRouter)\n if (!responseText && jsonSchema) {\n console.warn(`[LLMService] Model ${model} returned empty content with json_schema. Retrying with json_object fallback...`);\n const combinedPrompt = `${systemPrompt}\\n\\n---\\nUSER REQUEST:\\n${userPrompt}\\n\\nWICHTIG: Antworte AUSSCHLIESSLICH in validem JSON!`;\n response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'HTTP-Referer': window.location.href,\n 'X-Title': 'Nodges 3D Graph',\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n model: model,\n provider: { data_collection: 'deny' },\n messages: [\n { role: 'user', content: combinedPrompt }\n ],\n response_format: { type: 'json_object' },\n ...options\n })\n });\n \n if (response.ok) {\n rawApiData = await response.json();\n responseText = rawApiData?.choices?.[0]?.message?.content;\n }\n }\n\n if (!responseText) {\n const choice = rawApiData?.choices?.[0];\n this._saveDebugFile(`LLM_ERROR_NO_CHOICES_${Date.now()}.json`, rawApiData).catch(() => {});\n throw new Error(`Die API hat keine gültigen 'choices' zurückgegeben. Refusal: ${choice?.message?.refusal || 'none'}, Finish: ${choice?.finish_reason || 'unknown'}. (Details gespeichert in LLM_ERROR_NO_CHOICES)`);\n }\n\n } else if (provider === 'openai') {\n const response = await fetch('https://api.openai.com/v1/chat/completions', {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${apiKey}`,\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify({\n model: model,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt }\n ],\n // Use json_object because json_schema strictly forbids dynamic properties (which Nodges relies on)\n response_format: { type: 'json_object' },\n ...(options.temperature !== undefined && { temperature: options.temperature }),\n ...(options.top_p !== undefined && { top_p: options.top_p })\n })\n });\n\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n if (response.status === 429) {\n throw new Error(`Rate-Limit erreicht (429). Das OpenAI Modell ist überlastet oder dein Kontingent ist erschöpft.`);\n }\n throw new Error(`OpenAI API Fehler: ${response.status} - ${errorData.error?.message || response.statusText}`);\n }\n\n rawApiData = await response.json();\n responseText = rawApiData?.choices?.[0]?.message?.content;\n if (!responseText) {\n throw new Error(`Die API hat keine gültigen 'choices' zurückgegeben.`);\n }\n\n } else if (provider === 'anthropic') {\n const response = await fetch('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n 'anthropic-dangerous-direct-browser-access': 'true'\n },\n body: JSON.stringify({\n model: model,\n max_tokens: 8000,\n system: systemPrompt,\n messages: [\n { role: 'user', content: userPrompt }\n ],\n ...options\n })\n });\n\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n if (response.status === 429) {\n throw new Error(`Rate-Limit erreicht (429). Das Anthropic Modell ist überlastet oder dein Kontingent ist erschöpft.`);\n }\n throw new Error(`Anthropic API Fehler: ${response.status} - ${errorData.error?.message || response.statusText}`);\n }\n\n rawApiData = await response.json();\n responseText = rawApiData?.content?.[0]?.text;\n if (!responseText) {\n throw new Error(`Die API hat keine gültigen 'content' Blöcke zurückgegeben.`);\n }\n } else if (provider === 'ollama' || provider === 'lmstudio') {\n const bodyObj: any = {\n model: model,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt }\n ],\n temperature: options.temperature ?? 0.2,\n ...(options.top_p !== undefined && { top_p: options.top_p }),\n ...(options.top_k !== undefined && { top_k: options.top_k })\n };\n if (jsonSchema) {\n bodyObj.response_format = {\n type: 'json_schema',\n json_schema: {\n name: 'GraphData',\n strict: true,\n schema: jsonSchema\n }\n };\n }\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json'\n },\n body: JSON.stringify(bodyObj)\n });\n\n if (!response.ok) {\n let errorMsg = response.statusText;\n try {\n const errorData = await response.json();\n errorMsg = errorData.error?.message || errorMsg;\n } catch (e) {}\n throw new Error(`${provider} API Fehler: ${response.status} - ${errorMsg}`);\n }\n\n rawApiData = await response.json();\n responseText = rawApiData?.choices?.[0]?.message?.content;\n if (!responseText) {\n throw new Error(`Die API hat keine gültigen 'choices' zurückgegeben.`);\n }\n }\n } catch (error: any) {\n // Fange explizit AbortErrors (Timeouts) und gebe eine nutzerfreundliche Meldung\n if (error.name === 'AbortError' || (error.message && error.message.toLowerCase().includes('aborted'))) {\n throw new Error('Die Anfrage an die KI hat zu lange gedauert und wurde abgebrochen (Timeout). Bitte versuche ein anderes Modell oder eine kürzere Anfrage.');\n }\n // Ansonsten den Fehler weiterwerfen\n throw error;\n }\n\n if (!responseText) {\n throw new Error('Das Modell hat keine Antwort zurückgegeben.');\n }\n\n let cleanResponse = responseText.trim();\n \n // Extract JSON block if wrapped in markdown\n const markdownMatch = cleanResponse.match(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i);\n if (markdownMatch && markdownMatch[1]) {\n cleanResponse = markdownMatch[1].trim();\n } else {\n // Intelligent Regex Extractor for JSON\n // Finds the first { and the corresponding } even with nested braces\n try {\n const firstBrace = cleanResponse.indexOf('{');\n const lastBrace = cleanResponse.lastIndexOf('}');\n if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {\n cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);\n }\n } catch (regexError) {\n console.warn(\"[LLMService] Fallback Regex extraction failed, using raw response\");\n }\n }\n\n let parsedData: any;\n try {\n parsedData = JSON.parse(cleanResponse);\n } catch (e) {\n console.error(\"Failed to parse JSON:\", responseText);\n this._saveDebugFile(`LLM_ERROR_RAW_${Date.now()}.txt`, responseText).catch(() => {});\n throw new Error('Das Modell hat kein gültiges JSON zurückgegeben. Die Roheingabe wurde als LLM_ERROR_RAW gespeichert.');\n }\n\n // Fix common LLM issue where it wraps output in the schema name\n if (parsedData.GraphDataSchema && typeof parsedData.GraphDataSchema === 'object') {\n parsedData = parsedData.GraphDataSchema;\n }\n\n // Flexiblere Validierung: Es muss ENTWEDER data.entities/relationships geben ODER ein dataModel ODER visualMappings (fuer Multi-Step) ODER eine SPARQL query ODER Keywords\n const hasData = parsedData.data && Array.isArray(parsedData.data.entities) && Array.isArray(parsedData.data.relationships);\n const hasDataModel = parsedData.dataModel && typeof parsedData.dataModel === 'object';\n const hasVisualMappings = parsedData.visualMappings && typeof parsedData.visualMappings === 'object';\n const hasQuery = typeof parsedData.query === 'string';\n const hasKeywords = Array.isArray(parsedData.entities) || Array.isArray(parsedData.properties);\n const hasQuestion = typeof parsedData.question === 'string';\n\n if (!hasData && !hasDataModel && !hasVisualMappings && !hasQuery && !hasKeywords && !hasQuestion) {\n console.error(\"Invalid JSON structure:\", parsedData);\n this._saveDebugFile(`LLM_ERROR_STRUCTURE_${Date.now()}.json`, parsedData).catch(() => {});\n throw new Error('Das generierte JSON hat nicht die erwartete Struktur. Es wurde als LLM_ERROR_STRUCTURE gespeichert.');\n }\n\n // Guarantee data.entities and data.relationships always exist\n if (!parsedData.data) {\n parsedData.data = { entities: [], relationships: [] };\n }\n if (!Array.isArray(parsedData.data.entities)) {\n parsedData.data.entities = [];\n }\n if (!Array.isArray(parsedData.data.relationships)) {\n parsedData.data.relationships = [];\n }\n\n // --- Inject API metadata (Usage, tokens, etc.) ---\n if (!parsedData.metadata) {\n parsedData.metadata = {};\n }\n if (rawApiData) {\n parsedData.metadata.apiResponse = {\n id: rawApiData.id || null,\n created: rawApiData.created || null,\n model: rawApiData.model || null,\n usage: rawApiData.usage || null,\n system_fingerprint: rawApiData.system_fingerprint || null\n };\n \n // For Anthropic specifically\n if (rawApiData.usage && !rawApiData.usage.total_tokens) {\n rawApiData.usage.total_tokens = (rawApiData.usage.input_tokens || 0) + (rawApiData.usage.output_tokens || 0);\n }\n }\n\n return parsedData as GraphData;\n }\n\n public static async generateGraphData(\n prompt: string, \n provider: LLMProvider, \n model: string,\n formatFile: string = import.meta.env.BASE_URL + 'nodges_build_4.md'\n ): Promise<GraphData> {\n let systemPrompt = '';\n try {\n const promptResponse = await fetch(formatFile);\n if (!promptResponse.ok) {\n throw new Error(`Konnte Format-Datei ${formatFile} nicht laden. HTTP Status: ` + promptResponse.status);\n }\n systemPrompt = await promptResponse.text();\n } catch (error) {\n console.error('Fehler beim Laden der Format-Datei:', error);\n throw new Error(`Konnte Format-Datei nicht laden. Stelle sicher, dass ${formatFile} existiert.`);\n }\n\n return this._executeLLMCall(systemPrompt, prompt, provider, model);\n }\n\n public static async generateGraphDataMultiStepBuild5(\n prompt: string, \n provider: LLMProvider, \n model: string,\n onProgress?: (msg: string) => void,\n onStepComplete?: (stepNumber: number, stepName: string, content: string, extension: string) => void\n ): Promise<GraphData> {\n // Step 1: Schema / Ontologie\n const schemaPromptFile = import.meta.env.BASE_URL + 'prompts/build_5_ontology_prompt.md';\n let schemaSystemPrompt = '';\n try {\n const res = await fetch(schemaPromptFile);\n if (!res.ok) throw new Error();\n schemaSystemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Schema-Datei nicht laden: ${schemaPromptFile}`);\n }\n \n if (onProgress) onProgress('Schritt 1/3: Ontologie (Schema) wird entworfen...');\n \n const step1UserPrompt = `Erstelle ein dataModel (Ontologie) basierend auf der folgenden Anfrage:\\n\\n${prompt}`;\n await this._saveDebugFile(`debug_build5_step1_prompt_${Date.now()}.md`, `SYSTEM:\\n${schemaSystemPrompt}\\n\\nUSER:\\n${step1UserPrompt}`);\n const step1Data = await this._executeLLMCall(schemaSystemPrompt, step1UserPrompt, provider, model);\n await this._saveDebugFile(`debug_build5_step1_result_${Date.now()}.json`, step1Data);\n if (onStepComplete) {\n onStepComplete(1, \"Build5_Ontology\", JSON.stringify(step1Data, null, 2), \"json\");\n }\n \n // Step 2: Data\n if (onProgress) onProgress('Schritt 2/3: Datenpunkte werden generiert...');\n \n const dataPromptFile = import.meta.env.BASE_URL + 'prompts/build_5_data_prompt.md';\n let dataSystemPrompt = '';\n try {\n const res = await fetch(dataPromptFile);\n if (!res.ok) throw new Error();\n dataSystemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Data-Prompt nicht laden: ${dataPromptFile}`);\n }\n\n const step2UserPrompt = `Nutze EXAKT das folgende Schema (Ontologie), um die Daten zu generieren:\\n\\n${JSON.stringify(step1Data, null, 2)}\\n\\nBefuelle nun die data.entities und data.relationships Arrays basierend auf der Originalanfrage:\\n${prompt}`;\n await this._saveDebugFile(`debug_build5_step2_prompt_${Date.now()}.md`, `SYSTEM:\\n${dataSystemPrompt}\\n\\nUSER:\\n${step2UserPrompt}`);\n const step2Data = await this._executeLLMCall(dataSystemPrompt, step2UserPrompt, provider, model);\n await this._saveDebugFile(`debug_build5_step2_result_${Date.now()}.json`, step2Data);\n if (onStepComplete) {\n onStepComplete(2, \"Build5_Data\", JSON.stringify(step2Data, null, 2), \"json\");\n }\n \n // Merge Step 1 and Step 2\n const mergedData = { ...step1Data, ...step2Data };\n \n // Step 3: Visual Mappings\n if (onProgress) onProgress('Schritt 3/3: Visuelles Mapping (Build 5) wird berechnet...');\n \n const visualPromptFile = import.meta.env.BASE_URL + 'prompts/build_5_visual_prompt.md';\n let visualSystemPrompt = '';\n try {\n const res = await fetch(visualPromptFile);\n if (!res.ok) throw new Error();\n visualSystemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Visual-Prompt nicht laden: ${visualPromptFile}`);\n }\n\n const step3UserPrompt = `Erstelle die visuellen Mappings fuer diesen Datensatz:\\n\\n${JSON.stringify(mergedData, null, 2)}`;\n await this._saveDebugFile(`debug_build5_step3_prompt_${Date.now()}.md`, `SYSTEM:\\n${visualSystemPrompt}\\n\\nUSER:\\n${step3UserPrompt}`);\n const step3Data = await this._executeLLMCall(visualSystemPrompt, step3UserPrompt, provider, model);\n await this._saveDebugFile(`debug_build5_step3_result_${Date.now()}.json`, step3Data);\n if (onStepComplete) {\n onStepComplete(3, \"Build5_Visuals\", JSON.stringify(step3Data, null, 2), \"json\");\n }\n \n // Final Merge\n return { ...mergedData, ...step3Data };\n }\n\n public static async generateGraphDataBuild6(\n prompt: string, \n provider: LLMProvider, \n model: string,\n onProgress?: (msg: string) => void,\n llmOptions: { temperature?: number, top_p?: number, top_k?: number } = {}\n ): Promise<GraphData> {\n const promptFile = import.meta.env.BASE_URL + 'prompts/build_6_prompt.md';\n let systemPrompt = '';\n try {\n const res = await fetch(promptFile);\n if (!res.ok) throw new Error();\n systemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Prompt-Datei nicht laden: ${promptFile}`);\n }\n\n if (onProgress) onProgress('Generiere Netzwerk (Ontologie, Daten & Visuals in einem Schritt)...');\n\n // Kompaktes Beispiel-JSON statt vollem Zod-Schema (verstaendlicher fuer alle Modelle)\n const exampleStructure = `\n=== ZIEL-STRUKTUR (Beispiel) ===\nDein JSON MUSS exakt diese Top-Level-Struktur haben:\n{\n \"system\": \"<Thema>\",\n \"metadata\": {\n \"schemaVersion\": \"5.0\",\n \"description\": \"...\",\n \"competencyQuestions\": [\"...\", \"...\"]\n },\n \"dataModel\": {\n \"entities\": {\n \"<TypName>\": { \"properties\": { \"<propName>\": { \"type\": \"continuous\", \"range\": [0, 100] }, \"position\": { \"type\": \"spatial\" } } }\n },\n \"relationships\": {\n \"<KantenTyp>\": { \"properties\": {} }\n }\n },\n \"data\": {\n \"entities\": [\n { \n \"id\": \"unique_id\", \n \"type\": \"<TypName>\", \n \"label\": \"...\", \n \"<propName>\": 42, \n \"position\": { \"x\": 0, \"y\": 0, \"z\": 0 },\n \"temporal\": {\n \"validFrom\": 1900,\n \"validTo\": 1950,\n \"history\": [\n { \"timestamp\": 1920, \"changes\": { \"<propName>\": 80 } }\n ]\n }\n }\n ],\n \"relationships\": [\n { \n \"id\": \"rel_1\", \n \"type\": \"<KantenTyp>\", \n \"source\": \"id_a\", \n \"target\": \"id_b\", \n \"label\": \"...\",\n \"temporal\": {\n \"validFrom\": 1900,\n \"validTo\": 1950\n }\n }\n ]\n },\n \"visualMappings\": {\n \"defaultPresets\": {\n \"global_node\": {\n \"size\": { \"source\": \"<propName>\", \"function\": \"linear\", \"range\": [0.5, 3] },\n \"color\": { \"source\": \"kategorie\", \"function\": \"categorical\" },\n \"geometry\": { \"source\": \"constant\", \"function\": \"constant\", \"params\": { \"geometry\": \"sphere\" } },\n \"position\": { \"source\": \"position\", \"function\": \"constant\" }\n },\n \"global_edge\": {\n \"color\": { \"source\": \"type\", \"function\": \"categorical\" },\n \"thickness\": { \"source\": \"constant\", \"function\": \"constant\", \"params\": { \"value\": 0.1 } }\n }\n }\n }\n}\nWICHTIG: \"system\", \"metadata\", \"data\" (mit entities+relationships Array) und \"visualMappings\" sind PFLICHT-Felder!\n=================================`;\n systemPrompt += exampleStructure;\n\n // Erzeuge das strukturierte JSON-Schema fuer OpenRouter/OpenAI API\n const jsonSchema = zodToJsonSchema(GraphDataSchema, 'GraphDataSchema');\n // Zod packt das Schema oft in ein definitions-Objekt, wir wollen das eigentliche Schema uebergeben\n let schemaToPass = (jsonSchema as any).definitions?.GraphDataSchema || jsonSchema;\n // APIs (wie OpenAI) akzeptieren das $schema Feld nicht\n delete schemaToPass.$schema;\n \n // Mache das Schema \"strict: true\" kompatibel (additionalProperties: false)\n schemaToPass = this.makeSchemaStrict(schemaToPass);\n\n await this._saveDebugFile(`debug_build6_prompt_${Date.now()}.md`, `SYSTEM:\\n${systemPrompt}\\n\\nUSER:\\n${prompt}`);\n const resultData = await this._executeLLMCall(systemPrompt, prompt, provider, model, schemaToPass, llmOptions);\n await this._saveDebugFile(`debug_build6_result_${Date.now()}.json`, resultData);\n \n return resultData;\n }\n public static async askClarification(\n history: {role: 'user'|'assistant', content: string}[],\n provider: LLMProvider,\n model: string\n ): Promise<string> {\n let systemPrompt = \"Du bist ein erfahrener Datenarchitekt für Nodges. Der Nutzer möchte einen 3D-Graphen generieren lassen.\\n\";\n systemPrompt += \"Lies den bisherigen Verlauf. Wenn die Nutzeranfrage abstrakt, vage oder zu generisch ist (z.B. 'Politik', 'Wirtschaft'), stelle EINE gezielte, kurze Rückfrage, um herauszufinden, welche KONKRETEN Instanzen (z.B. Politiker, Parteien, Firmen) er visualisieren möchte.\\n\";\n systemPrompt += \"Stelle nur eine kurze, freundliche Gegenfrage. Max 2 Sätze.\\n\";\n systemPrompt += \"Gib deine Antwort zwingend im JSON Format zurück: { \\\"question\\\": \\\"Deine Frage...\\\" }\";\n\n const userPrompt = history.map(h => `${h.role === 'user' ? 'USER' : 'ASSISTANT'}: ${h.content}`).join('\\n\\n');\n \n const res: any = await this._executeLLMCall(systemPrompt, userPrompt, provider, model);\n if (res && res.question) return res.question;\n return \"Kannst du das etwas genauer spezifizieren?\";\n }\n\n public static async generateGraphDataBuild8(\n prompt: string, \n provider: LLMProvider, \n model: string,\n onProgress?: (msg: string) => void,\n onStepComplete?: (stepNumber: number, stepName: string, content: string, extension: string) => void\n ): Promise<GraphData> {\n // Schritt 1: Keywords extrahieren\n if (onProgress) onProgress('Schritt 1/5: Analysiere Anfrage für Wikidata-Faktencheck...');\n \n const keywordPromptFile = import.meta.env.BASE_URL + 'prompts/build_8_keyword_prompt.md';\n let keywordSystemPrompt = 'Extrahiere Entitäten und Properties als JSON {\"entities\":[], \"properties\":[]}. Übersetze auf Englisch.';\n try {\n const res = await fetch(keywordPromptFile);\n if (res.ok) keywordSystemPrompt = await res.text();\n } catch (e) { console.warn(e); }\n\n const keywordData: any = await this._executeLLMCall(keywordSystemPrompt, `Anfrage: ${prompt}`, provider, model);\n await this._saveDebugFile(`debug_build7_step1_keywords_${Date.now()}.json`, keywordData);\n if (onStepComplete) onStepComplete(1, \"Keywords\", JSON.stringify(keywordData, null, 2), \"json\");\n\n // Schritt 2: Wikidata Search API\n if (onProgress) onProgress('Schritt 2/5: Suche exakte Wikidata-IDs...');\n const contextLines: string[] = [];\n \n const searchWikidata = async (term: string, type: 'item' | 'property') => {\n try {\n const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(term)}&language=en&type=${type}&limit=4&format=json&origin=*`;\n const res = await fetch(url);\n const json = await res.json();\n if (json.search && json.search.length > 0) {\n const hits = json.search.map((s: any) => `${s.id} (${s.label}: ${s.description || ''})`).join(' | ');\n contextLines.push(`- ${type.toUpperCase()} \"${term}\": ${hits}`);\n }\n } catch (e) { console.warn(\"Wikidata search error\", e); }\n };\n\n const entities = Array.isArray(keywordData.entities) ? keywordData.entities : [];\n const properties = Array.isArray(keywordData.properties) ? keywordData.properties : [];\n \n await Promise.all([\n ...entities.map((e: string) => searchWikidata(e, 'item')),\n ...properties.map((p: string) => searchWikidata(p, 'property'))\n ]);\n \n const wikidataContext = contextLines.join('\\n');\n await this._saveDebugFile(`debug_build7_step2_context_${Date.now()}.txt`, wikidataContext);\n if (onStepComplete) onStepComplete(2, \"Faktencheck_Live_Suche\", wikidataContext, \"txt\");\n\n // Schritt 3: Text zu SPARQL\n if (onProgress) onProgress('Schritt 3/5: Generiere exakte SPARQL-Abfrage...');\n \n const sparqlPromptFile = import.meta.env.BASE_URL + 'prompts/build_8_sparql_prompt.md';\n let sparqlSystemPrompt = '';\n try {\n const res = await fetch(sparqlPromptFile);\n if (!res.ok) throw new Error();\n sparqlSystemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte SPARQL-Prompt nicht laden: ${sparqlPromptFile}`);\n }\n\n const sparqlUserPrompt = `Nutzeranfrage: ${prompt}\\n\\n=== GEFUNDENE WIKIDATA-IDs (FAKTENCHECK) ===\\nHier sind die echten IDs für diese Anfrage aus der Live-Suche. Nutze ZWINGEND diese Q-IDs und P-IDs für den Aufbau der SPARQL-Query. Rate keine IDs!\\n\\n${wikidataContext}`;\n await this._saveDebugFile(`debug_build7_step3_prompt_${Date.now()}.md`, `SYSTEM:\\n${sparqlSystemPrompt}\\n\\nUSER:\\n${sparqlUserPrompt}`);\n \n const sparqlData: any = await this._executeLLMCall(sparqlSystemPrompt, sparqlUserPrompt, provider, model);\n await this._saveDebugFile(`debug_build7_step1_result_${Date.now()}.json`, sparqlData);\n \n const sparqlQuery = sparqlData.query || sparqlData.sparql;\n if (!sparqlQuery) {\n throw new Error('Das LLM hat keine valide SPARQL-Abfrage (Feld \"query\") zurückgegeben.');\n }\n\n if (onStepComplete) {\n onStepComplete(3, \"Generierte_SPARQL_Abfrage\", sparqlQuery, \"sparql\");\n }\n\n // Schritt 4: Wikidata Abfrage\n if (onProgress) onProgress('Schritt 4/5: Frage Live-Daten von Wikidata ab...');\n const WIKIDATA_ENDPOINT = 'https://query.wikidata.org/sparql';\n let rawWikidataResponse = null;\n try {\n const response = await fetch(WIKIDATA_ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'Accept': 'application/sparql-results+json',\n 'User-Agent': 'Nodges/Build-8 (localhost)'\n },\n body: new URLSearchParams({ query: sparqlQuery })\n });\n\n if (!response.ok) {\n throw new Error(`Wikidata HTTP Error: ${response.status}`);\n }\n\n rawWikidataResponse = await response.json();\n await this._saveDebugFile(`debug_build7_step4_wikidata_${Date.now()}.json`, rawWikidataResponse);\n\n if (onStepComplete) onStepComplete(4, \"Wikidata_Rohdaten\", JSON.stringify(rawWikidataResponse, null, 2), \"json\");\n } catch (error: any) {\n throw new Error(`Fehler bei der Wikidata-Abfrage: ${error.message}`);\n }\n\n if (!rawWikidataResponse || !rawWikidataResponse.results || !rawWikidataResponse.results.bindings || rawWikidataResponse.results.bindings.length === 0) {\n throw new Error('Wikidata hat für diese Abfrage keine Ergebnisse gefunden. Bitte versuche einen anderen Suchbegriff.');\n }\n\n // Schritt 5: Tabellendaten in Nodges JSON mappen\n if (onProgress) onProgress('Schritt 5/5: Transformiere Wikidata-Rohdaten in Nodges 3D-Graph...');\n \n const mappingPromptFile = import.meta.env.BASE_URL + 'prompts/build_8_mapping_prompt.md';\n let mappingSystemPrompt = '';\n try {\n const res = await fetch(mappingPromptFile);\n if (!res.ok) throw new Error();\n mappingSystemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Mapping-Prompt nicht laden: ${mappingPromptFile}`);\n }\n\n // Komprimiere und reduziere Bindings, um Token-Limits und Endlos-Ladezeiten zu verhindern\n const limitedBindings = rawWikidataResponse.results.bindings.slice(0, 40).map((b: any) => {\n const clean: any = {};\n for (const key in b) {\n clean[key] = b[key].value;\n }\n return clean;\n });\n const mappingUserPrompt = `Ursprüngliche Anfrage: ${prompt}\\n\\nHier sind die abgerufenen Wikidata-Ergebnisse (JSON):\\n${JSON.stringify(limitedBindings, null, 2)}`;\n \n const fullPromptLog = `=== SYSTEM PROMPT ===\\n${mappingSystemPrompt}\\n\\n=== USER PROMPT ===\\n${mappingUserPrompt}`;\n if (onStepComplete) onStepComplete(5, \"Mapping_Prompt_an_LLM\", fullPromptLog, \"md\");\n\n await this._saveDebugFile(`debug_build7_step5_prompt_${Date.now()}.md`, `SYSTEM:\\n${mappingSystemPrompt}\\n\\nUSER:\\n${mappingUserPrompt}`);\n \n const finalGraphData = await this._executeLLMCall(mappingSystemPrompt, mappingUserPrompt, provider, model);\n \n await this._saveDebugFile(`debug_build7_step5_result_${Date.now()}.json`, finalGraphData);\n \n if (onStepComplete) onStepComplete(6, \"Generiertes_Graph_JSON\", JSON.stringify(finalGraphData, null, 2), \"json\");\n \n return finalGraphData;\n }\n\n public static async refineGraphData(\n existingData: GraphData,\n prompt: string, \n provider: LLMProvider, \n model: string,\n formatFile: string = import.meta.env.BASE_URL + 'prompts/refine_prompt.md',\n onProgress?: (msg: string) => void\n ): Promise<GraphData> {\n let systemPrompt = '';\n try {\n const res = await fetch(formatFile);\n if (!res.ok) throw new Error();\n systemPrompt = await res.text();\n } catch {\n throw new Error(`Konnte Format-Datei nicht laden: ${formatFile}`);\n }\n\n if (onProgress) onProgress('Analysiere Graphen und wende Änderungen an...');\n\n const refineUserPrompt = `\nHier ist ein bestehender Datensatz (Graph). Deine Aufgabe ist es, diesen basierend auf der neuen User-Anweisung zu veraendern oder zu erweitern.\nGib IMMER den kompletten Graphen (inklusive der unveraenderten Teile) zurueck! Behalte bestehende IDs bei.\n\n=== BESTEHENDER GRAPH ===\n${JSON.stringify(existingData, null, 2)}\n=========================\n\nUSER ANWEISUNG: ${prompt}\n`;\n\n await this._saveDebugFile(`debug_refine_prompt_${Date.now()}.md`, `SYSTEM:\\n${systemPrompt}\\n\\nUSER:\\n${refineUserPrompt}`);\n const resultData = await this._executeLLMCall(systemPrompt, refineUserPrompt, provider, model);\n await this._saveDebugFile(`debug_refine_result_${Date.now()}.json`, resultData);\n return resultData;\n }\n\n /**\n * Generates a vector embedding for a given text using the specified provider and model.\n */\n public static async generateEmbedding(\n text: string,\n provider: LLMProvider,\n model: string = 'google/gemini-embedding-2'\n ): Promise<number[]> {\n let apiKey = this.getApiKey(provider);\n let apiUrl = '';\n\n if (provider === 'openrouter') {\n if (!apiKey) {\n apiUrl = 'https://pure-peacock-1215.banixx.deno.net/';\n apiKey = 'proxy-mode';\n } else {\n apiUrl = 'https://openrouter.ai/api/v1/embeddings';\n }\n } else if (provider === 'openai') {\n apiUrl = 'https://api.openai.com/v1/embeddings';\n if (!model || model === 'google/gemini-embedding-2') {\n model = 'text-embedding-3-small';\n }\n } else if (provider === 'ollama') {\n apiUrl = 'http://localhost:11434/v1/embeddings';\n if (!model || model === 'google/gemini-embedding-2') {\n model = 'nomic-embed-text';\n }\n } else if (provider === 'lmstudio') {\n apiUrl = 'http://localhost:1234/v1/embeddings';\n if (!model || model === 'google/gemini-embedding-2') {\n model = 'local-model';\n }\n } else {\n throw new Error(`Embedding-Generierung fuer Provider '${provider}' wird nicht unterstuetzt.`);\n }\n\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json'\n };\n\n if (provider === 'openrouter') {\n headers['Authorization'] = `Bearer ${apiKey}`;\n headers['HTTP-Referer'] = window.location.href;\n headers['X-Title'] = 'Nodges 3D Graph';\n } else if (provider === 'openai') {\n headers['Authorization'] = `Bearer ${apiKey}`;\n }\n\n const response = await fetch(apiUrl, {\n method: 'POST',\n headers: headers,\n body: JSON.stringify({\n model: model,\n input: text\n })\n });\n\n if (!response.ok) {\n const errData = await response.json().catch(() => ({}));\n const errMsg = errData.error?.message || response.statusText;\n throw new Error(`Embedding API Fehler (${response.status}): ${errMsg}`);\n }\n\n const result = await response.json();\n if (result.data && result.data[0] && result.data[0].embedding) {\n return result.data[0].embedding;\n }\n throw new Error('Ungueltige Antwort von der Embedding API erhalten.');\n }\n\n /**\n * Build 10: Generates graph data using a modular configurable pipeline.\n */\n public static async generateGraphDataBuild10(\n prompt: string,\n config: Build10Config,\n provider: LLMProvider,\n model: string,\n devPrefix: string | null,\n onProgress?: (msg: string) => void,\n onStepComplete?: (stepNumber: number, stepName: string, content: string, extension: string) => void,\n llmOptions: { temperature?: number, top_p?: number, top_k?: number } = {}\n ): Promise<GraphData> {\n let finalPrompt = prompt;\n \n const ts = new Date().toISOString().replace(/[:.]/g, '-');\n const saveStep = async (stepNum: number, name: string, content: any, ext: string) => {\n if (!devPrefix) return; // Wenn Dev Create inaktiv ist, GAR NICHTS speichern! Keine b10-Dateien, keine Downloads!\n const filename = `../b10/${devPrefix}_${stepNum}_${name}_${ts}.${ext}`;\n const isJson = typeof content !== 'string';\n const strContent = isJson ? JSON.stringify(content, null, 2) : content;\n await this._saveDebugFile(filename, isJson ? content : strContent);\n if (onStepComplete) onStepComplete(stepNum, name, strContent, ext);\n };\n\n // Step 1: Grounding / Wissensquelle\n let groundingContext = '';\n if (config.grounding === 'wikidata') {\n if (onProgress) onProgress('Schritt 1/4: Führe Wikidata-Faktencheck aus...');\n try {\n if (onProgress) onProgress('Wikidata Schritt 1: Extrahiere Suchbegriffe...');\n let keywordSystemPrompt = 'Extrahiere Entitäten und Properties als JSON {\"entities\":[], \"properties\":[]}. Übersetze auf Englisch.';\n if (build10KeywordPromptRaw) {\n keywordSystemPrompt = build10KeywordPromptRaw;\n }\n const keywordData: any = await this._executeLLMCall(keywordSystemPrompt, `Anfrage: ${prompt}`, provider, model, undefined, llmOptions);\n await saveStep(1, \"Build10_Wikidata_Keywords\", keywordData, \"json\");\n\n if (onProgress) onProgress('Wikidata Schritt 2: Suche Wikidata-IDs...');\n const contextLines: string[] = [];\n const searchWikidata = async (term: string, type: 'item' | 'property') => {\n try {\n const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(term)}&language=en&type=${type}&limit=4&format=json&origin=*`;\n const res = await fetch(url);\n const json = await res.json();\n if (json.search && json.search.length > 0) {\n const hits = json.search.map((s: any) => `${s.id} (${s.label}: ${s.description || ''})`).join(' | ');\n contextLines.push(`- ${type.toUpperCase()} \"${term}\": ${hits}`);\n } else {\n contextLines.push(`- ${type.toUpperCase()} \"${term}\": Keine direkten Treffer gefunden. Du kannst eine Standard-Property oder eine bekannte Q-ID erraten, falls du dir 100% sicher bist.`);\n }\n } catch (e) { console.warn(\"Wikidata search error\", e); }\n };\n const entities = Array.isArray(keywordData.entities) ? keywordData.entities : [];\n const properties = Array.isArray(keywordData.properties) ? keywordData.properties : [];\n await Promise.all([\n ...entities.map((e: string) => searchWikidata(e, 'item')),\n ...properties.map((p: string) => searchWikidata(p, 'property'))\n ]);\n const wikidataContext = contextLines.join('\\n');\n await saveStep(2, \"Build10_Wikidata_Suche\", wikidataContext, \"txt\");\n\n let sparqlSystemPrompt = build10SparqlPromptRaw || '';\n let currentSparqlUserPrompt = `Nutzeranfrage: ${prompt}\\n\\n=== GEFUNDENE WIKIDATA-IDs ===\\n${wikidataContext}`;\n let sparqlQuery = '';\n \n let maxRetries = 2;\n let attempt = 0;\n let success = false;\n \n while (attempt < maxRetries && !success) {\n attempt++;\n if (onProgress) onProgress(`Wikidata Schritt 3: Generiere SPARQL (Versuch ${attempt}/${maxRetries})...`);\n \n const sparqlData: any = await this._executeLLMCall(sparqlSystemPrompt, currentSparqlUserPrompt, provider, model, undefined, llmOptions);\n await saveStep(3, `Build10_Wikidata_SPARQL_Raw_v${attempt}`, sparqlData, \"json\");\n sparqlQuery = sparqlData.query || sparqlData.sparql;\n \n if (!sparqlQuery) {\n currentSparqlUserPrompt += `\\n\\nFEHLER: Du hast kein valides JSON mit dem Key \"query\" zurückgegeben. Bitte korrigiere dein Format.`;\n continue;\n }\n \n await saveStep(3, `Build10_Wikidata_SPARQL_v${attempt}`, sparqlQuery, \"sparql\");\n if (onProgress) onProgress(`Wikidata Schritt 4: Rufe Live-Daten ab (Versuch ${attempt})...`);\n \n const WIKIDATA_ENDPOINT = 'https://query.wikidata.org/sparql';\n const response = await fetch(WIKIDATA_ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'Accept': 'application/sparql-results+json',\n 'User-Agent': 'Nodges/Build-10 (localhost)'\n },\n body: new URLSearchParams({ query: sparqlQuery })\n });\n \n if (response.ok) {\n const rawWikidataResponse = await response.json();\n if (rawWikidataResponse.results && rawWikidataResponse.results.bindings) {\n if (rawWikidataResponse.results.bindings.length > 0) {\n const limitedBindings = rawWikidataResponse.results.bindings.slice(0, 30).map((b: any) => {\n const clean: any = {};\n for (const key in b) {\n clean[key] = b[key].value;\n }\n return clean;\n });\n groundingContext = `=== WIKIDATA FAKTEN-BASIS ===\\n${JSON.stringify(limitedBindings, null, 2)}`;\n await saveStep(4, `Build10_Wikidata_Rohdaten_v${attempt}`, rawWikidataResponse, \"json\");\n success = true;\n } else {\n if (onProgress) onProgress(`Wikidata Schritt 4: 0 Ergebnisse. Fordere Korrektur an...`);\n currentSparqlUserPrompt += `\\n\\n=== LETZTER VERSUCH ===\\nDeine generierte Query lief zwar fehlerfrei, gab aber exakt 0 Ergebnisse von Wikidata zurück. Das bedeutet, deine Query ist logisch falsch oder zu spezifisch. Hast du möglicherweise die Subjekt/Objekt-Richtung vertauscht, falsche Q-IDs gewählt oder P31 bei einem Einzelobjekt verwendet? Überarbeite die Query grundlegend.`;\n }\n }\n } else {\n const errorText = await response.text();\n if (onProgress) onProgress(`Wikidata Schritt 4: Syntax-Fehler. Fordere Korrektur an...`);\n currentSparqlUserPrompt += `\\n\\n=== LETZTER VERSUCH ===\\nDeine generierte Query erzeugte einen Syntax-Fehler auf dem Server: ${errorText.substring(0, 200)}. Bitte korrigiere die Query.`;\n }\n }\n \n if (!success && !groundingContext) {\n throw new Error(\"Wikidata lieferte auch nach mehrfachen Versuchen keine validen Ergebnisse.\");\n }\n } catch (e: any) {\n console.warn('Wikidata grounding failed, falling back to LLM-only', e);\n if (onProgress) onProgress(`Wikidata-Grounding fehlgeschlagen: ${e.message}. Fahre ohne Grounding fort...`);\n }\n }\n\n if (groundingContext) {\n finalPrompt += `\\n\\n${groundingContext}\\nNutze ZWINGEND diese Wikidata-Faktenbasis fuer die Erstellung des Graphen.`;\n }\n\n // Step 2: JSON Generierung\n if (onProgress) onProgress('Schritt 2/4: Generiere Netzwerk-Struktur...');\n let systemPrompt = build10PromptRaw;\n \n if (!systemPrompt) {\n throw new Error(`Konnte Prompt-Datei nicht laden: public/prompts/build_10_prompt.md (Raw Import fehlgeschlagen)`);\n }\n\n if (config.ratingMethod === 'taxonomy') {\n systemPrompt += `\\n\\n=== ERZWUNGENE BEZIEHUNGS-TAXONOMIE ===\nDu MUSS fuer jede Kante in 'data.relationships' einen der folgenden Typen verwenden:\n- \"influences\"\n- \"prevents\"\n- \"supports\"\n- \"competes_with\"\n- \"part_of\"\n\nJede Beziehung MUSS eine 'strength' Eigenschaft im Bereich 1 bis 5 (Ganzzahlen) haben.\nDefiniere diese 'strength' Eigenschaft auch in der 'dataModel.relationships' fuer jeden Kanten-Typ (Typ: continuous, Bereich [1, 5]).\n======================================`;\n } else if (config.ratingMethod === 'embeddings') {\n systemPrompt += `\\n\\n=== HINWEIS ZUR BEZIEHUNGS-STAERKE ===\nKonzentriere dich auf das Finden korrekter Entitaeten und Verbindungen. Die Staerke/Intensitaet der Kanten wird spaeter automatisch ueber Vektor-Embeddings berechnet.\nGib Kanten trotzdem ein 'strength' Feld im Schema und in den Daten mit, der Wert kann initial 50 sein.\n====================================`;\n } else {\n systemPrompt += `\\n\\n=== HINWEIS ZUR BEZIEHUNGS-STAERKE ===\nSchaetze die Beziehungsstaerken ('strength') frei auf einer Skala von 0 bis 100. Definiere diese Eigenschaft im dataModel.\n====================================`;\n }\n\n const exampleStructure = `\n=== ZIEL-STRUKTUR ===\n{\n \"system\": \"${prompt.substring(0, 30).replace(/\"/g, '')}\",\n \"metadata\": {\n \"schemaVersion\": \"5.0\",\n \"description\": \"...\"\n },\n \"dataModel\": {\n \"entities\": {\n \"Concept\": { \"properties\": { \"importance\": { \"type\": \"continuous\", \"range\": [0, 100] }, \"position\": { \"type\": \"spatial\" } } }\n },\n \"relationships\": {\n \"related\": { \"properties\": { \"strength\": { \"type\": \"continuous\", \"range\": [0, 100] } } }\n }\n },\n \"data\": {\n \"entities\": [\n { \n \"id\": \"e1\", \n \"type\": \"Concept\", \n \"label\": \"...\", \n \"importance\": 80, \n \"position\": { \"x\": 0, \"y\": 0, \"z\": 0 },\n \"temporal\": {\n \"validFrom\": 1900,\n \"validTo\": 1950,\n \"history\": [\n { \"timestamp\": 1920, \"changes\": { \"importance\": 90 } }\n ]\n }\n }\n ],\n \"relationships\": [\n { \n \"id\": \"r1\", \n \"type\": \"related\", \n \"source\": \"e1\", \n \"target\": \"e2\", \n \"label\": \"...\", \n \"strength\": 60,\n \"temporal\": {\n \"validFrom\": 1900,\n \"validTo\": 1950\n }\n }\n ]\n },\n \"visualMappings\": {\n \"defaultPresets\": {\n \"global_node\": {\n \"size\": { \"source\": \"importance\", \"function\": \"linear\", \"range\": [0.5, 3] },\n \"position\": { \"source\": \"position\", \"function\": \"constant\" }\n },\n \"global_edge\": {\n \"thickness\": { \"source\": \"strength\", \"function\": \"linear\", \"range\": [0.1, 1.5] }\n }\n }\n }\n}\nWICHTIG: \"system\", \"metadata\", \"dataModel\", \"data\", \"visualMappings\" sind Pflichtfelder!`;\n\n systemPrompt += exampleStructure;\n\n const jsonSchema = zodToJsonSchema(GraphDataSchema, 'GraphDataSchema');\n let schemaToPass = (jsonSchema as any).definitions?.GraphDataSchema || jsonSchema;\n delete schemaToPass.$schema;\n schemaToPass = this.makeSchemaStrict(schemaToPass);\n\n let graphData = await this._executeLLMCall(systemPrompt, finalPrompt, provider, model, schemaToPass, llmOptions);\n await saveStep(5, \"Build10_Raw_Graph\", graphData, \"json\");\n\n // Step 3: Qualitätssicherung - Kritiker\n if (config.qualityAssurance === 'critic') {\n if (onProgress) onProgress('Schritt 3/4: Kritiker-LLM prüft den Graphen auf Plausibilität...');\n const criticSystemPrompt = `Du bist ein erfahrener Datenarchitekt. Der Benutzer liefert dir ein JSON-Dokument, das ein Nodges-Netzwerk darstellt.\nDeine Aufgabe ist es:\n1. Pruefe, ob alle 'source' und 'target' Felder in 'data.relationships' auf existierende Entitaeten in 'data.entities' verweisen. Falls nicht, entferne die fehlerhafte Kante.\n2. Pruefe, ob die Werte fuer 'strength' oder andere kontinuierliche Eigenschaften plausibel sind und im angegebenen Range des dataModel liegen.\n3. Behebe etwaige Schema-Konformitaetsfehler.\nGib ausschliesslich das korrigierte JSON-Objekt zurueck, ohne Erklaerungen oder Markdown-Formatierungen ausserhalb des JSON.`;\n\n try {\n const criticData = await this._executeLLMCall(criticSystemPrompt, JSON.stringify(graphData), provider, model, schemaToPass, llmOptions);\n graphData = criticData;\n await saveStep(6, \"Build10_Critic_Corrected_Graph\", graphData, \"json\");\n } catch (e: any) {\n console.warn('Critic step failed, using raw graph', e);\n if (onProgress) onProgress(`Kritiker-Schritt fehlgeschlagen: ${e.message}. Verwende rohen Graphen...`);\n }\n }\n\n // Deduplizierung (Grounding: dedup)\n if (config.grounding === 'dedup') {\n if (onProgress) onProgress('Deduplizierung: Führe semantische Entity Resolution aus...');\n try {\n const deduped = await deduplicateGraph(graphData, provider, 'google/gemini-embedding-2', 0.85, onProgress);\n graphData = deduped;\n await saveStep(7, \"Build10_Deduped_Graph\", graphData, \"json\");\n } catch (e: any) {\n console.warn('Deduplication failed', e);\n }\n }\n\n // Step 4: Bewertungsmethode - Kosinus-Ähnlichkeit\n if (config.ratingMethod === 'embeddings') {\n if (onProgress) onProgress('Schritt 4/4: Berechne Kantenstärken über Kosinus-Ähnlichkeit...');\n try {\n const embeddingCache = new Map<string, number[]>();\n const getEmbeddingCached = async (text: string): Promise<number[]> => {\n if (embeddingCache.has(text)) return embeddingCache.get(text)!;\n const embModel = provider === 'openai' ? 'text-embedding-3-small' : (provider === 'ollama' ? 'nomic-embed-text' : 'google/gemini-embedding-2');\n const emb = await this.generateEmbedding(text, provider, embModel);\n embeddingCache.set(text, emb);\n return emb;\n };\n\n const cosineSimilarity = (a: number[], b: number[]): number => {\n let dot = 0.0, normA = 0.0, normB = 0.0;\n for (let i = 0; i < a.length; i++) {\n dot += a[i] * b[i];\n normA += a[i] * a[i];\n normB += b[i] * b[i];\n }\n return normA === 0 || normB === 0 ? 0 : dot / (Math.sqrt(normA) * Math.sqrt(normB));\n };\n\n const entitiesMap = new Map<string, any>();\n graphData.data.entities.forEach(e => entitiesMap.set(e.id, e));\n\n for (const rel of graphData.data.relationships) {\n const sourceNode = entitiesMap.get(rel.source || '');\n const targetNode = entitiesMap.get(rel.target || '');\n if (sourceNode && targetNode) {\n const labelA = sourceNode.label || sourceNode.id;\n const labelB = targetNode.label || targetNode.id;\n if (onProgress) onProgress(`Berechne Ähnlichkeit: \"${labelA}\" <-> \"${labelB}\"...`);\n const embA = await getEmbeddingCached(labelA);\n const embB = await getEmbeddingCached(labelB);\n const sim = cosineSimilarity(embA, embB);\n const score = Math.max(0, Math.min(100, Math.round(sim * 100)));\n rel.strength = score;\n }\n }\n \n const gd = graphData as any;\n if (!gd.dataModel) gd.dataModel = {};\n if (!gd.dataModel.relationships) gd.dataModel.relationships = {};\n \n const rels = gd.dataModel.relationships as any;\n const relTypes = new Set(gd.data.relationships.map((r: any) => r.type));\n relTypes.forEach((t: any) => {\n if (t) {\n if (!rels[t]) {\n rels[t] = { properties: {} };\n }\n if (!rels[t].properties) {\n rels[t].properties = {};\n }\n rels[t].properties.strength = {\n type: 'continuous',\n range: [0, 100]\n };\n }\n });\n\n if (!gd.visualMappings) gd.visualMappings = { defaultPresets: {} };\n if (!gd.visualMappings.defaultPresets) gd.visualMappings.defaultPresets = {};\n if (!gd.visualMappings.defaultPresets.global_edge) {\n gd.visualMappings.defaultPresets.global_edge = {};\n }\n gd.visualMappings.defaultPresets.global_edge.thickness = {\n source: 'strength',\n function: 'linear',\n range: [0.1, 1.5]\n };\n\n await saveStep(9, \"similarity_graph\", graphData, \"json\");\n if (onStepComplete) onStepComplete(8, \"Build10_Similarity_Scored_Graph\", JSON.stringify(graphData, null, 2), \"json\");\n } catch (e: any) {\n console.warn('Similarity scoring failed', e);\n if (onProgress) onProgress(`Ähnlichkeitsberechnung fehlgeschlagen: ${e.message}. Verwende LLM-Schätzung...`);\n }\n }\n\n return graphData;\n }\n\n public static async expandGraphNodeBuild10(\n nodeLabel: string,\n nodeQId: string,\n existingGraphData: GraphData,\n provider: LLMProvider = 'openrouter',\n model: string = 'google/gemini-2.5-flash-001',\n onProgress?: (msg: string) => void\n ): Promise<GraphData> {\n let qId = nodeQId;\n if (!qId) {\n if (onProgress) onProgress(`Wikidata: Suche Q-ID für \"${nodeLabel}\"...`);\n try {\n const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(nodeLabel)}&language=en&limit=1&format=json&origin=*`;\n const res = await fetch(url);\n const json = await res.json();\n if (json.search && json.search.length > 0) {\n qId = json.search[0].id;\n if (onProgress) onProgress(`Wikidata: Q-ID gefunden -> ${qId}`);\n } else {\n throw new Error(`Keine Wikidata-ID für \"${nodeLabel}\" gefunden.`);\n }\n } catch (e: any) {\n throw new Error(`Fehler bei der ID-Suche: ${e.message}`);\n }\n }\n\n const expansionSystemPrompt = build10ExpansionPromptRaw || '';\n let currentSparqlUserPrompt = `Nutzer wünscht einen Deep Dive für den Knoten \"${nodeLabel}\" mit der ID ${qId}. Erstelle die Abfrage.`;\n \n let sparqlQuery = '';\n let maxRetries = 2;\n let attempt = 0;\n let success = false;\n let groundingContext = '';\n\n while (attempt < maxRetries && !success) {\n attempt++;\n if (onProgress) onProgress(`Schritt 1/2: Generiere SPARQL Deep-Dive für ${nodeLabel} (Versuch ${attempt}/${maxRetries})...`);\n \n const sparqlData: any = await this._executeLLMCall(expansionSystemPrompt, currentSparqlUserPrompt, provider, model);\n sparqlQuery = sparqlData.query || sparqlData.sparql;\n \n if (!sparqlQuery) {\n currentSparqlUserPrompt += `\\n\\nFEHLER: Du hast kein valides JSON mit dem Key \"query\" zurückgegeben. Bitte korrigiere dein Format.`;\n continue;\n }\n\n if (onProgress) onProgress(`Schritt 2/2: Lade Live-Daten für Deep Dive (Versuch ${attempt})...`);\n const WIKIDATA_ENDPOINT = 'https://query.wikidata.org/sparql';\n const response = await fetch(WIKIDATA_ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n 'Accept': 'application/sparql-results+json',\n 'User-Agent': 'Nodges/Build-10 (localhost)'\n },\n body: new URLSearchParams({ query: sparqlQuery })\n });\n\n if (response.ok) {\n const rawWikidataResponse = await response.json();\n if (rawWikidataResponse.results && rawWikidataResponse.results.bindings) {\n if (rawWikidataResponse.results.bindings.length > 0) {\n const limitedBindings = rawWikidataResponse.results.bindings.slice(0, 100).map((b: any) => {\n const clean: any = {};\n for (const key in b) {\n clean[key] = b[key].value;\n }\n return clean;\n });\n groundingContext = `=== WIKIDATA DEEP-DIVE FAKTEN FÜR ${nodeLabel} (${qId}) ===\\n${JSON.stringify(limitedBindings, null, 2)}`;\n success = true;\n } else {\n if (onProgress) onProgress(`Wikidata: 0 Ergebnisse. Fordere Korrektur an...`);\n currentSparqlUserPrompt += `\\n\\n=== LETZTER VERSUCH ===\\nDeine generierte Query lief zwar fehlerfrei, gab aber exakt 0 Ergebnisse zurück. Überprüfe die Query.`;\n }\n }\n } else {\n const errorText = await response.text();\n if (onProgress) onProgress(`Wikidata: Syntax-Fehler. Fordere Korrektur an...`);\n currentSparqlUserPrompt += `\\n\\n=== LETZTER VERSUCH ===\\nDeine generierte Query erzeugte einen Syntax-Fehler auf dem Server: ${errorText.substring(0, 200)}. Bitte korrigiere die Query. Stelle sicher, dass du keine überflüssigen Klammern benutzt.`;\n }\n }\n\n if (!success && !groundingContext) {\n throw new Error(`Wikidata Server-Fehler oder leere Ergebnisse nach ${maxRetries} Versuchen.`);\n }\n\n if (onProgress) onProgress('Generiere neues Graph-JSON aus den Daten...');\n const build6PromptRawStr = build6PromptRaw || '';\n \n const finalPrompt = `Wir machen einen Deep Dive für den Knoten \"${nodeLabel}\".\n${groundingContext}\nErstelle die Entities und Relationships für diese neuen Daten. Halte dich an das mitgelieferte bestehende Datenmodell! Achte darauf, dass der Hauptknoten \"${nodeLabel}\" vorhanden ist und verknüpfe die neuen Fakten mit ihm. Falls die Fakten Wikidata-URIs enthalten, extrahiere die Q-ID und lege sie als \"wikidata_id\" an.\n\nHier ist das bestehende Datenmodell deines Netzwerks:\n${JSON.stringify(existingGraphData.dataModel, null, 2)}`;\n\n const nodgesDataSchema = zodToJsonSchema(GraphDataSchema);\n let schemaToPass: any = JSON.parse(JSON.stringify(nodgesDataSchema));\n delete schemaToPass.$schema;\n \n const newGraphData = await this._executeLLMCall(build6PromptRawStr, finalPrompt, provider, model, this.makeSchemaStrict(schemaToPass));\n \n return newGraphData;\n }\n}\n"}}}}