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:
2025-12-03 01:22:57 +08:00
parent 91f7f41bc1
commit 1ed1dab78f
21 changed files with 1254 additions and 614 deletions

View File

@@ -16,6 +16,10 @@ from ..models.schemas import (
Step0Result,
DynamicStep1Result,
DynamicCausalChain,
DAGNode,
DAGEdge,
AttributeDAG,
DAGRelationship,
)
from ..prompts.attribute_prompt import (
get_step1_attributes_prompt,
@@ -23,6 +27,7 @@ from ..prompts.attribute_prompt import (
get_step0_category_analysis_prompt,
get_step1_dynamic_attributes_prompt,
get_step2_dynamic_causal_chain_prompt,
get_step2_dag_relationships_prompt,
)
from ..services.llm_service import ollama_provider, extract_json_from_response
@@ -39,14 +44,21 @@ FIXED_CATEGORIES = [
]
async def execute_step0(request: StreamAnalyzeRequest) -> Step0Result | None:
"""Execute Step 0 - LLM category analysis"""
if request.category_mode == CategoryMode.FIXED_ONLY:
return None
async def execute_step0(
request: StreamAnalyzeRequest,
exclude_categories: List[str] | None = None
) -> Step0Result | None:
"""Execute Step 0 - LLM category analysis
Called only for modes that need LLM to suggest categories:
- FIXED_PLUS_DYNAMIC
- CUSTOM_ONLY
- DYNAMIC_AUTO
"""
prompt = get_step0_category_analysis_prompt(
request.query,
request.suggested_category_count
request.suggested_category_count,
exclude_categories=exclude_categories
)
temperature = request.temperature if request.temperature is not None else 0.7
response = await ollama_provider.generate(
@@ -83,6 +95,34 @@ def resolve_final_categories(
)
return categories
elif request.category_mode == CategoryMode.FIXED_PLUS_DYNAMIC:
# Fixed categories + LLM suggested categories
categories = [
CategoryDefinition(
name=cat.name,
description=cat.description,
is_fixed=True,
order=i
)
for i, cat in enumerate(FIXED_CATEGORIES)
]
if step0_result:
# Filter out LLM categories that duplicate fixed category names
fixed_names = {cat.name for cat in FIXED_CATEGORIES}
added_count = 0
for cat in step0_result.categories:
if cat.name not in fixed_names:
categories.append(
CategoryDefinition(
name=cat.name,
description=cat.description,
is_fixed=False,
order=len(FIXED_CATEGORIES) + added_count
)
)
added_count += 1
return categories
elif request.category_mode == CategoryMode.CUSTOM_ONLY:
return step0_result.categories if step0_result else FIXED_CATEGORIES
@@ -192,17 +232,73 @@ def assemble_attribute_tree(query: str, chains: List[CausalChain]) -> AttributeN
return root
def build_dag_from_relationships(
query: str,
categories: List[CategoryDefinition],
attributes_by_category: dict,
relationships: List[DAGRelationship],
) -> AttributeDAG:
"""從屬性和關係建構 DAG"""
sorted_cats = sorted(categories, key=lambda x: x.order)
# 建立節點 - 每個屬性只出現一次
nodes: List[DAGNode] = []
node_id_map: dict = {} # (category, name) -> id
for cat in sorted_cats:
cat_name = cat.name
for idx, attr_name in enumerate(attributes_by_category.get(cat_name, [])):
node_id = f"{cat_name}_{idx}"
nodes.append(DAGNode(
id=node_id,
name=attr_name,
category=cat_name,
order=idx
))
node_id_map[(cat_name, attr_name)] = node_id
# 建立邊
edges: List[DAGEdge] = []
for rel in relationships:
source_key = (rel.source_category, rel.source)
target_key = (rel.target_category, rel.target)
if source_key in node_id_map and target_key in node_id_map:
edges.append(DAGEdge(
source_id=node_id_map[source_key],
target_id=node_id_map[target_key]
))
return AttributeDAG(
query=query,
categories=sorted_cats,
nodes=nodes,
edges=edges
)
async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[str, None]:
"""Generate SSE events with dynamic category support"""
try:
temperature = request.temperature if request.temperature is not None else 0.7
# ========== Step 0: Category Analysis (if needed) ==========
# Only these modes need LLM category analysis
needs_step0 = request.category_mode in [
CategoryMode.FIXED_PLUS_DYNAMIC,
CategoryMode.CUSTOM_ONLY,
CategoryMode.DYNAMIC_AUTO,
]
step0_result = None
if request.category_mode != CategoryMode.FIXED_ONLY:
if needs_step0:
yield f"event: step0_start\ndata: {json.dumps({'message': '分析類別...'}, ensure_ascii=False)}\n\n"
step0_result = await execute_step0(request)
# For FIXED_PLUS_DYNAMIC, exclude the fixed category names
exclude_cats = None
if request.category_mode == CategoryMode.FIXED_PLUS_DYNAMIC:
exclude_cats = [cat.name for cat in FIXED_CATEGORIES]
step0_result = await execute_step0(request, exclude_categories=exclude_cats)
if step0_result:
yield f"event: step0_complete\ndata: {json.dumps({'result': step0_result.model_dump()}, ensure_ascii=False)}\n\n"
@@ -227,58 +323,58 @@ async def generate_sse_events(request: StreamAnalyzeRequest) -> AsyncGenerator[s
yield f"event: step1_complete\ndata: {json.dumps({'result': step1_result.model_dump()}, ensure_ascii=False)}\n\n"
# ========== Step 2: Generate Causal Chains (Dynamic) ==========
causal_chains: List[DynamicCausalChain] = []
# ========== Step 2: Generate Relationships (DAG) ==========
yield f"event: relationships_start\ndata: {json.dumps({'message': '生成關係...'}, ensure_ascii=False)}\n\n"
for i in range(request.chain_count):
chain_index = i + 1
step2_prompt = get_step2_dag_relationships_prompt(
query=request.query,
categories=final_categories,
attributes_by_category=step1_result.attributes,
)
logger.info(f"Step 2 (relationships) prompt: {step2_prompt[:300]}")
yield f"event: chain_start\ndata: {json.dumps({'index': chain_index, 'total': request.chain_count, 'message': f'正在生成第 {chain_index}/{request.chain_count} 條因果鏈...'}, ensure_ascii=False)}\n\n"
relationships: List[DAGRelationship] = []
max_retries = 2
for attempt in range(max_retries):
try:
step2_response = await ollama_provider.generate(
step2_prompt, model=request.model, temperature=temperature
)
logger.info(f"Relationships response: {step2_response[:500]}")
step2_prompt = get_step2_dynamic_causal_chain_prompt(
query=request.query,
categories=final_categories,
attributes_by_category=step1_result.attributes,
existing_chains=[c.chain for c in causal_chains],
chain_index=chain_index,
)
rel_data = extract_json_from_response(step2_response)
raw_relationships = rel_data.get("relationships", [])
# Gradually increase temperature for diversity
chain_temperature = min(temperature + 0.05 * i, 1.0)
for rel in raw_relationships:
relationships.append(DAGRelationship(
source_category=rel.get("source_category", ""),
source=rel.get("source", ""),
target_category=rel.get("target_category", ""),
target=rel.get("target", ""),
))
break
except Exception as e:
logger.warning(f"Relationships attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
temperature = min(temperature + 0.1, 1.0)
max_retries = 2
chain = None
for attempt in range(max_retries):
try:
step2_response = await ollama_provider.generate(
step2_prompt, model=request.model, temperature=chain_temperature
)
logger.info(f"Chain {chain_index} response: {step2_response[:300]}")
yield f"event: relationships_complete\ndata: {json.dumps({'count': len(relationships)}, ensure_ascii=False)}\n\n"
chain_data = extract_json_from_response(step2_response)
chain = DynamicCausalChain(chain=chain_data)
break
except Exception as e:
logger.warning(f"Chain {chain_index} attempt {attempt + 1} failed: {e}")
if attempt < max_retries - 1:
chain_temperature = min(chain_temperature + 0.1, 1.0)
if chain:
causal_chains.append(chain)
yield f"event: chain_complete\ndata: {json.dumps({'index': chain_index, 'chain': chain.model_dump()}, ensure_ascii=False)}\n\n"
else:
yield f"event: chain_error\ndata: {json.dumps({'index': chain_index, 'error': f'生成失敗'}, ensure_ascii=False)}\n\n"
# ========== Assemble Final Tree (Dynamic) ==========
final_tree = assemble_dynamic_attribute_tree(request.query, causal_chains, final_categories)
# ========== Build DAG ==========
dag = build_dag_from_relationships(
query=request.query,
categories=final_categories,
attributes_by_category=step1_result.attributes,
relationships=relationships,
)
final_result = {
"query": request.query,
"step0_result": step0_result.model_dump() if step0_result else None,
"categories_used": [c.model_dump() for c in final_categories],
"step1_result": step1_result.model_dump(),
"causal_chains": [c.model_dump() for c in causal_chains],
"attributes": final_tree.model_dump(),
"relationships": [r.model_dump() for r in relationships],
"dag": dag.model_dump(),
}
yield f"event: done\ndata: {json.dumps(final_result, ensure_ascii=False)}\n\n"