feat: Migrate to React Flow and add Fixed + Dynamic category mode
Frontend: - Migrate MindmapDAG from D3.js to React Flow (@xyflow/react) - Add custom node components (QueryNode, CategoryHeaderNode, AttributeNode) - Add useDAGLayout hook for column-based layout - Add "AI" badge for LLM-suggested categories - Update CategorySelector with Fixed + Dynamic mode option - Improve dark/light theme support Backend: - Add FIXED_PLUS_DYNAMIC category mode - Filter duplicate category names in LLM suggestions - Update prompts to exclude fixed categories when suggesting new ones - Improve LLM service with better error handling and logging - Auto-remove /no_think prefix for non-Qwen models - Add smart JSON format detection for model compatibility - Improve JSON extraction with multiple parsing strategies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
@@ -8,6 +9,8 @@ import httpx
|
||||
from ..config import settings
|
||||
from ..models.schemas import AttributeNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
@abstractmethod
|
||||
@@ -35,34 +38,56 @@ class OllamaProvider(LLMProvider):
|
||||
model = model or settings.default_model
|
||||
url = f"{self.base_url}/api/generate"
|
||||
|
||||
# Remove /no_think prefix for non-qwen models (it's qwen-specific)
|
||||
clean_prompt = prompt
|
||||
if not model.lower().startswith("qwen") and prompt.startswith("/no_think"):
|
||||
clean_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "")
|
||||
logger.info(f"Removed /no_think prefix for model {model}")
|
||||
|
||||
# Models known to support JSON format well
|
||||
json_capable_models = ["qwen", "llama", "mistral", "gemma", "phi"]
|
||||
model_lower = model.lower()
|
||||
use_json_format = any(m in model_lower for m in json_capable_models)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"prompt": clean_prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
},
|
||||
}
|
||||
|
||||
# Only use format: json for models that support it
|
||||
if use_json_format:
|
||||
payload["format"] = "json"
|
||||
else:
|
||||
logger.info(f"Model {model} may not support JSON format, requesting without format constraint")
|
||||
|
||||
# Retry logic for larger models that may return empty responses
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
logger.info(f"LLM request attempt {attempt + 1}/{max_retries} to model {model}")
|
||||
response = await self.client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
response_text = result.get("response", "")
|
||||
|
||||
logger.info(f"LLM response (first 500 chars): {response_text[:500] if response_text else '(empty)'}")
|
||||
|
||||
# Check if response is valid (not empty or just "{}")
|
||||
if response_text and response_text.strip() not in ["", "{}", "{ }"]:
|
||||
return response_text
|
||||
|
||||
logger.warning(f"Empty or invalid response on attempt {attempt + 1}, retrying...")
|
||||
|
||||
# If empty, retry with slightly higher temperature
|
||||
if attempt < max_retries - 1:
|
||||
payload["options"]["temperature"] = min(temperature + 0.1 * (attempt + 1), 1.0)
|
||||
|
||||
# Return whatever we got on last attempt
|
||||
logger.error(f"All {max_retries} attempts returned empty response from model {model}")
|
||||
return response_text
|
||||
|
||||
async def list_models(self) -> List[str]:
|
||||
@@ -124,21 +149,46 @@ class OpenAICompatibleProvider(LLMProvider):
|
||||
|
||||
def extract_json_from_response(response: str) -> dict:
|
||||
"""Extract JSON from LLM response, handling markdown code blocks and extra whitespace."""
|
||||
# Remove markdown code blocks if present
|
||||
if not response or not response.strip():
|
||||
logger.error("LLM returned empty response")
|
||||
raise ValueError("LLM returned empty response - the model may not support JSON format or the prompt was unclear")
|
||||
|
||||
json_str = response
|
||||
|
||||
# Try multiple extraction strategies
|
||||
extraction_attempts = []
|
||||
|
||||
# Strategy 1: Look for markdown code blocks
|
||||
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", response)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
json_str = response
|
||||
extraction_attempts.append(json_match.group(1))
|
||||
|
||||
# Clean up: remove extra whitespace, normalize spaces
|
||||
json_str = json_str.strip()
|
||||
# Remove trailing whitespace before closing braces/brackets
|
||||
json_str = re.sub(r'\s+([}\]])', r'\1', json_str)
|
||||
# Remove multiple spaces/tabs/newlines
|
||||
json_str = re.sub(r'[\t\n\r]+', ' ', json_str)
|
||||
# Strategy 2: Look for JSON object pattern { ... }
|
||||
json_obj_match = re.search(r'(\{[\s\S]*\})', response)
|
||||
if json_obj_match:
|
||||
extraction_attempts.append(json_obj_match.group(1))
|
||||
|
||||
return json.loads(json_str)
|
||||
# Strategy 3: Original response
|
||||
extraction_attempts.append(response)
|
||||
|
||||
# Try each extraction attempt
|
||||
for attempt_str in extraction_attempts:
|
||||
# Clean up: remove extra whitespace, normalize spaces
|
||||
cleaned = attempt_str.strip()
|
||||
# Remove trailing whitespace before closing braces/brackets
|
||||
cleaned = re.sub(r'\s+([}\]])', r'\1', cleaned)
|
||||
# Normalize newlines but keep structure
|
||||
cleaned = re.sub(r'[\t\r]+', ' ', cleaned)
|
||||
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# All attempts failed
|
||||
logger.error(f"Failed to parse JSON from response")
|
||||
logger.error(f"Raw response: {response[:1000]}")
|
||||
raise ValueError(f"Failed to parse LLM response as JSON. The model may not support structured output. Raw response: {response[:300]}...")
|
||||
|
||||
|
||||
def parse_attribute_response(response: str) -> AttributeNode:
|
||||
|
||||
Reference in New Issue
Block a user