feat: Add Expert Transformation Agent with multi-expert perspective system
- Backend: Add expert transformation router with 3-step SSE pipeline - Step 0: Generate diverse expert team (random domains) - Step 1: Each expert generates keywords for attributes - Step 2: Batch generate descriptions for expert keywords - Backend: Add simplified prompts for reliable JSON output - Frontend: Add TransformationPanel with React Flow visualization - Frontend: Add TransformationInputPanel for expert configuration - Expert count (2-8), keywords per expert (1-3) - Custom expert domains support - Frontend: Add expert keyword nodes with expert badges - Frontend: Improve description card layout (wider cards, more spacing) - Frontend: Add fallback for missing descriptions with visual indicators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .routers import attributes
|
from .routers import attributes, transformation, expert_transformation
|
||||||
from .services.llm_service import ollama_provider
|
from .services.llm_service import ollama_provider
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(attributes.router)
|
app.include_router(attributes.router)
|
||||||
|
app.include_router(transformation.router)
|
||||||
|
app.include_router(expert_transformation.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -131,3 +131,92 @@ class DAGRelationship(BaseModel):
|
|||||||
source: str # source attribute name
|
source: str # source attribute name
|
||||||
target_category: str
|
target_category: str
|
||||||
target: str # target attribute name
|
target: str # target attribute name
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Transformation Agent schemas =====
|
||||||
|
|
||||||
|
class TransformationRequest(BaseModel):
|
||||||
|
"""Transformation Agent 請求"""
|
||||||
|
query: str # 原始查詢 (e.g., "腳踏車")
|
||||||
|
category: str # 類別名稱 (e.g., "功能")
|
||||||
|
attributes: List[str] # 該類別的屬性列表
|
||||||
|
model: Optional[str] = None
|
||||||
|
temperature: Optional[float] = 0.7
|
||||||
|
keyword_count: int = 3 # 要生成的新關鍵字數量
|
||||||
|
|
||||||
|
|
||||||
|
class TransformationDescription(BaseModel):
|
||||||
|
"""單一轉換描述"""
|
||||||
|
keyword: str # 新關鍵字
|
||||||
|
description: str # 與 query 結合的描述
|
||||||
|
|
||||||
|
|
||||||
|
class TransformationCategoryResult(BaseModel):
|
||||||
|
"""單一類別的轉換結果"""
|
||||||
|
category: str
|
||||||
|
original_attributes: List[str] # 原始屬性
|
||||||
|
new_keywords: List[str] # 新生成的關鍵字
|
||||||
|
descriptions: List[TransformationDescription]
|
||||||
|
|
||||||
|
|
||||||
|
class TransformationDAGResult(BaseModel):
|
||||||
|
"""完整 Transformation 結果"""
|
||||||
|
query: str
|
||||||
|
results: List[TransformationCategoryResult]
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Expert Transformation Agent schemas =====
|
||||||
|
|
||||||
|
class ExpertProfile(BaseModel):
|
||||||
|
"""專家檔案"""
|
||||||
|
id: str # e.g., "expert-0"
|
||||||
|
name: str # e.g., "藥師"
|
||||||
|
domain: str # e.g., "醫療與健康"
|
||||||
|
perspective: Optional[str] = None # e.g., "從藥物與健康管理角度思考"
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertKeyword(BaseModel):
|
||||||
|
"""專家視角生成的關鍵字"""
|
||||||
|
keyword: str # 關鍵字本身
|
||||||
|
expert_id: str # 哪個專家生成的
|
||||||
|
expert_name: str # 專家名稱(冗餘,方便前端)
|
||||||
|
source_attribute: str # 來自哪個原始屬性
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertTransformationDescription(BaseModel):
|
||||||
|
"""專家關鍵字的描述"""
|
||||||
|
keyword: str
|
||||||
|
expert_id: str
|
||||||
|
expert_name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertTransformationCategoryResult(BaseModel):
|
||||||
|
"""單一類別的轉換結果(專家版)"""
|
||||||
|
category: str
|
||||||
|
original_attributes: List[str]
|
||||||
|
expert_keywords: List[ExpertKeyword] # 所有專家生成的關鍵字
|
||||||
|
descriptions: List[ExpertTransformationDescription]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertTransformationDAGResult(BaseModel):
|
||||||
|
"""完整轉換結果(專家版)"""
|
||||||
|
query: str
|
||||||
|
experts: List[ExpertProfile] # 使用的專家列表
|
||||||
|
results: List[ExpertTransformationCategoryResult]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpertTransformationRequest(BaseModel):
|
||||||
|
"""Expert Transformation Agent 請求"""
|
||||||
|
query: str
|
||||||
|
category: str
|
||||||
|
attributes: List[str]
|
||||||
|
|
||||||
|
# Expert parameters
|
||||||
|
expert_count: int = 3 # 專家數量 (2-8)
|
||||||
|
keywords_per_expert: int = 1 # 每個專家為每個屬性生成幾個關鍵字 (1-3)
|
||||||
|
custom_experts: Optional[List[str]] = None # 用戶指定專家 ["藥師", "工程師"]
|
||||||
|
|
||||||
|
# LLM parameters
|
||||||
|
model: Optional[str] = None
|
||||||
|
temperature: Optional[float] = 0.7
|
||||||
|
|||||||
78
backend/app/prompts/expert_transformation_prompt.py
Normal file
78
backend/app/prompts/expert_transformation_prompt.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Expert Transformation Agent 提示詞模組"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_expert_generation_prompt(
|
||||||
|
query: str,
|
||||||
|
categories: List[str],
|
||||||
|
expert_count: int,
|
||||||
|
custom_experts: Optional[List[str]] = None
|
||||||
|
) -> str:
|
||||||
|
"""Step 0: 生成專家團隊(不依賴主題,純隨機多元)"""
|
||||||
|
custom_text = ""
|
||||||
|
if custom_experts and len(custom_experts) > 0:
|
||||||
|
custom_text = f"(已指定:{', '.join(custom_experts[:expert_count])})"
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
隨機組建 {expert_count} 個來自完全不同領域的專家團隊{custom_text}。
|
||||||
|
|
||||||
|
回傳 JSON:
|
||||||
|
{{"experts": [{{"id": "expert-0", "name": "職業", "domain": "領域", "perspective": "角度"}}, ...]}}
|
||||||
|
|
||||||
|
規則:
|
||||||
|
- id 為 expert-0 到 expert-{expert_count - 1}
|
||||||
|
- name 填寫職業名稱(非人名),2-5字
|
||||||
|
- 各專家的 domain 必須來自截然不同的領域,越多元越好"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_expert_keyword_generation_prompt(
|
||||||
|
category: str,
|
||||||
|
attribute: str,
|
||||||
|
experts: List[dict], # List[ExpertProfile]
|
||||||
|
keywords_per_expert: int = 1
|
||||||
|
) -> str:
|
||||||
|
"""Step 1: 專家視角關鍵字生成"""
|
||||||
|
experts_info = ", ".join([f"{exp['id']}:{exp['name']}({exp['domain']})" for exp in experts])
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
專家團隊:{experts_info}
|
||||||
|
屬性:「{attribute}」({category})
|
||||||
|
|
||||||
|
每位專家從自己的專業視角為此屬性生成 {keywords_per_expert} 個創新關鍵字(2-6字)。
|
||||||
|
關鍵字要反映該專家領域的獨特思考方式。
|
||||||
|
|
||||||
|
回傳 JSON:
|
||||||
|
{{"keywords": [{{"keyword": "詞彙", "expert_id": "expert-X", "expert_name": "名稱"}}, ...]}}
|
||||||
|
|
||||||
|
共需 {len(experts) * keywords_per_expert} 個關鍵字。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_expert_batch_description_prompt(
|
||||||
|
query: str,
|
||||||
|
category: str,
|
||||||
|
expert_keywords: List[dict] # List[ExpertKeyword]
|
||||||
|
) -> str:
|
||||||
|
"""Step 2: 批次生成專家關鍵字的描述"""
|
||||||
|
keywords_info = ", ".join([
|
||||||
|
f"{kw['expert_name']}:{kw['keyword']}"
|
||||||
|
for kw in expert_keywords
|
||||||
|
])
|
||||||
|
|
||||||
|
# 建立 keyword -> (expert_id, expert_name) 的對照
|
||||||
|
keyword_expert_map = ", ".join([
|
||||||
|
f"{kw['keyword']}→{kw['expert_id']}/{kw['expert_name']}"
|
||||||
|
for kw in expert_keywords
|
||||||
|
])
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
物件:「{query}」
|
||||||
|
關鍵字(專家:詞彙):{keywords_info}
|
||||||
|
對照:{keyword_expert_map}
|
||||||
|
|
||||||
|
為每個關鍵字生成創新描述(15-30字),說明如何將該概念應用到「{query}」上。
|
||||||
|
|
||||||
|
回傳 JSON:
|
||||||
|
{{"descriptions": [{{"keyword": "詞彙", "expert_id": "expert-X", "expert_name": "名稱", "description": "應用描述"}}, ...]}}
|
||||||
|
|
||||||
|
共需 {len(expert_keywords)} 個描述。"""
|
||||||
97
backend/app/prompts/transformation_prompt.py
Normal file
97
backend/app/prompts/transformation_prompt.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Transformation Agent 提示詞模組"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def get_keyword_generation_prompt(
|
||||||
|
category: str,
|
||||||
|
attributes: List[str],
|
||||||
|
keyword_count: int = 3
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Step 1: 生成新關鍵字
|
||||||
|
|
||||||
|
給定類別和現有屬性,生成全新的、有創意的關鍵字。
|
||||||
|
不考慮原始查詢,只專注於類別本身可能的延伸。
|
||||||
|
"""
|
||||||
|
attrs_text = "、".join(attributes)
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
你是一個創意發想專家。給定一個類別和該類別下的現有屬性,請生成全新的、有創意的關鍵字或描述片段。
|
||||||
|
|
||||||
|
【類別】{category}
|
||||||
|
【現有屬性】{attrs_text}
|
||||||
|
|
||||||
|
【重要規則】
|
||||||
|
1. 生成 {keyword_count} 個全新的關鍵字
|
||||||
|
2. 關鍵字必須符合「{category}」這個類別的範疇
|
||||||
|
3. 關鍵字要有創意,不能與現有屬性重複或太相似
|
||||||
|
4. 不要考慮任何特定物件,只專注於這個類別本身可能的延伸
|
||||||
|
5. 每個關鍵字應該是 2-6 個字的詞彙或短語
|
||||||
|
|
||||||
|
只回傳 JSON:
|
||||||
|
{{
|
||||||
|
"keywords": ["關鍵字1", "關鍵字2", "關鍵字3"]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_description_generation_prompt(
|
||||||
|
query: str,
|
||||||
|
category: str,
|
||||||
|
keyword: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Step 2: 結合原始查詢生成描述
|
||||||
|
|
||||||
|
用新關鍵字創造一個與原始查詢相關的創新應用描述。
|
||||||
|
"""
|
||||||
|
return f"""/no_think
|
||||||
|
你是一個創新應用專家。請將一個新的關鍵字概念應用到特定物件上,創造出創新的應用描述。
|
||||||
|
|
||||||
|
【物件】{query}
|
||||||
|
【類別】{category}
|
||||||
|
【新關鍵字】{keyword}
|
||||||
|
|
||||||
|
【任務】
|
||||||
|
請用「{keyword}」這個概念,為「{query}」創造一個創新的應用描述。
|
||||||
|
描述應該是一個完整的句子或短語,說明如何將這個新概念應用到物件上。
|
||||||
|
|
||||||
|
【範例格式】
|
||||||
|
- 如果物件是「腳踏車」,關鍵字是「監視」,可以生成「腳踏車監視騎乘者的身體健康狀況」
|
||||||
|
- 如果物件是「雨傘」,關鍵字是「發電」,可以生成「雨傘利用雨滴撞擊發電」
|
||||||
|
|
||||||
|
只回傳 JSON:
|
||||||
|
{{
|
||||||
|
"description": "創新應用描述"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_batch_description_prompt(
|
||||||
|
query: str,
|
||||||
|
category: str,
|
||||||
|
keywords: List[str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
批次生成描述(可選的優化版本,一次處理多個關鍵字)
|
||||||
|
"""
|
||||||
|
keywords_text = "、".join(keywords)
|
||||||
|
keywords_json = ", ".join([f'"{k}"' for k in keywords])
|
||||||
|
|
||||||
|
return f"""/no_think
|
||||||
|
你是一個創新應用專家。請將多個新的關鍵字概念應用到特定物件上,為每個關鍵字創造創新的應用描述。
|
||||||
|
|
||||||
|
【物件】{query}
|
||||||
|
【類別】{category}
|
||||||
|
【新關鍵字】{keywords_text}
|
||||||
|
|
||||||
|
【任務】
|
||||||
|
為每個關鍵字創造一個與「{query}」相關的創新應用描述。
|
||||||
|
每個描述應該是一個完整的句子或短語。
|
||||||
|
|
||||||
|
只回傳 JSON:
|
||||||
|
{{
|
||||||
|
"descriptions": [
|
||||||
|
{{"keyword": "關鍵字1", "description": "描述1"}},
|
||||||
|
{{"keyword": "關鍵字2", "description": "描述2"}}
|
||||||
|
]
|
||||||
|
}}"""
|
||||||
185
backend/app/routers/expert_transformation.py
Normal file
185
backend/app/routers/expert_transformation.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Expert Transformation Agent 路由模組"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import AsyncGenerator, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from ..models.schemas import (
|
||||||
|
ExpertTransformationRequest,
|
||||||
|
ExpertProfile,
|
||||||
|
ExpertKeyword,
|
||||||
|
ExpertTransformationCategoryResult,
|
||||||
|
ExpertTransformationDescription,
|
||||||
|
)
|
||||||
|
from ..prompts.expert_transformation_prompt import (
|
||||||
|
get_expert_generation_prompt,
|
||||||
|
get_expert_keyword_generation_prompt,
|
||||||
|
get_expert_batch_description_prompt,
|
||||||
|
)
|
||||||
|
from ..services.llm_service import ollama_provider, extract_json_from_response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/expert-transformation", tags=["expert-transformation"])
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_expert_transformation_events(
|
||||||
|
request: ExpertTransformationRequest,
|
||||||
|
all_categories: List[str] # For expert generation context
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Generate SSE events for expert transformation process"""
|
||||||
|
try:
|
||||||
|
temperature = request.temperature if request.temperature is not None else 0.7
|
||||||
|
model = request.model
|
||||||
|
|
||||||
|
# ========== Step 0: Generate expert team ==========
|
||||||
|
yield f"event: expert_start\ndata: {json.dumps({'message': '正在組建專家團隊...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
experts: List[ExpertProfile] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
expert_prompt = get_expert_generation_prompt(
|
||||||
|
query=request.query,
|
||||||
|
categories=all_categories,
|
||||||
|
expert_count=request.expert_count,
|
||||||
|
custom_experts=request.custom_experts
|
||||||
|
)
|
||||||
|
logger.info(f"Expert prompt: {expert_prompt[:200]}")
|
||||||
|
|
||||||
|
expert_response = await ollama_provider.generate(
|
||||||
|
expert_prompt, model=model, temperature=temperature
|
||||||
|
)
|
||||||
|
logger.info(f"Expert response: {expert_response[:500]}")
|
||||||
|
|
||||||
|
expert_data = extract_json_from_response(expert_response)
|
||||||
|
experts_raw = expert_data.get("experts", [])
|
||||||
|
|
||||||
|
for exp in experts_raw:
|
||||||
|
if isinstance(exp, dict) and all(k in exp for k in ["id", "name", "domain"]):
|
||||||
|
experts.append(ExpertProfile(**exp))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate experts: {e}")
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': f'專家團隊生成失敗: {str(e)}'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
yield f"event: expert_complete\ndata: {json.dumps({'experts': [e.model_dump() for e in experts]}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
if not experts:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': '無法生成專家團隊'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# ========== Step 1: Generate keywords from expert perspectives ==========
|
||||||
|
yield f"event: keyword_start\ndata: {json.dumps({'message': f'專家團隊為「{request.category}」的屬性生成關鍵字...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
all_expert_keywords: List[ExpertKeyword] = []
|
||||||
|
|
||||||
|
# For each attribute, ask all experts to generate keywords
|
||||||
|
for attr_index, attribute in enumerate(request.attributes):
|
||||||
|
try:
|
||||||
|
kw_prompt = get_expert_keyword_generation_prompt(
|
||||||
|
category=request.category,
|
||||||
|
attribute=attribute,
|
||||||
|
experts=[e.model_dump() for e in experts],
|
||||||
|
keywords_per_expert=request.keywords_per_expert
|
||||||
|
)
|
||||||
|
logger.info(f"Keyword prompt for '{attribute}': {kw_prompt[:300]}")
|
||||||
|
|
||||||
|
kw_response = await ollama_provider.generate(
|
||||||
|
kw_prompt, model=model, temperature=temperature
|
||||||
|
)
|
||||||
|
logger.info(f"Keyword response for '{attribute}': {kw_response[:500]}")
|
||||||
|
|
||||||
|
kw_data = extract_json_from_response(kw_response)
|
||||||
|
keywords_raw = kw_data.get("keywords", [])
|
||||||
|
|
||||||
|
# Add source_attribute to each keyword
|
||||||
|
for kw in keywords_raw:
|
||||||
|
if isinstance(kw, dict) and all(k in kw for k in ["keyword", "expert_id", "expert_name"]):
|
||||||
|
all_expert_keywords.append(ExpertKeyword(
|
||||||
|
keyword=kw["keyword"],
|
||||||
|
expert_id=kw["expert_id"],
|
||||||
|
expert_name=kw["expert_name"],
|
||||||
|
source_attribute=attribute
|
||||||
|
))
|
||||||
|
|
||||||
|
# Emit progress
|
||||||
|
yield f"event: keyword_progress\ndata: {json.dumps({'attribute': attribute, 'count': len(keywords_raw)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to generate keywords for '{attribute}': {e}")
|
||||||
|
yield f"event: keyword_progress\ndata: {json.dumps({'attribute': attribute, 'count': 0, 'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
# Continue with next attribute instead of stopping
|
||||||
|
|
||||||
|
yield f"event: keyword_complete\ndata: {json.dumps({'total_keywords': len(all_expert_keywords)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
if not all_expert_keywords:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': '無法生成關鍵字'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# ========== Step 2: Generate descriptions for each expert keyword ==========
|
||||||
|
yield f"event: description_start\ndata: {json.dumps({'message': '為專家關鍵字生成創新應用描述...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
descriptions: List[ExpertTransformationDescription] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
desc_prompt = get_expert_batch_description_prompt(
|
||||||
|
query=request.query,
|
||||||
|
category=request.category,
|
||||||
|
expert_keywords=[kw.model_dump() for kw in all_expert_keywords]
|
||||||
|
)
|
||||||
|
logger.info(f"Description prompt: {desc_prompt[:300]}")
|
||||||
|
|
||||||
|
desc_response = await ollama_provider.generate(
|
||||||
|
desc_prompt, model=model, temperature=temperature
|
||||||
|
)
|
||||||
|
logger.info(f"Description response: {desc_response[:500]}")
|
||||||
|
|
||||||
|
desc_data = extract_json_from_response(desc_response)
|
||||||
|
descriptions_raw = desc_data.get("descriptions", [])
|
||||||
|
|
||||||
|
for desc in descriptions_raw:
|
||||||
|
if isinstance(desc, dict) and all(k in desc for k in ["keyword", "expert_id", "expert_name", "description"]):
|
||||||
|
descriptions.append(ExpertTransformationDescription(**desc))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to generate descriptions: {e}")
|
||||||
|
# Continue without descriptions - at least we have keywords
|
||||||
|
|
||||||
|
yield f"event: description_complete\ndata: {json.dumps({'count': len(descriptions)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# ========== Build final result ==========
|
||||||
|
result = ExpertTransformationCategoryResult(
|
||||||
|
category=request.category,
|
||||||
|
original_attributes=request.attributes,
|
||||||
|
expert_keywords=all_expert_keywords,
|
||||||
|
descriptions=descriptions
|
||||||
|
)
|
||||||
|
|
||||||
|
final_data = {
|
||||||
|
"result": result.model_dump(),
|
||||||
|
"experts": [e.model_dump() for e in experts]
|
||||||
|
}
|
||||||
|
yield f"event: done\ndata: {json.dumps(final_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Expert transformation error: {e}", exc_info=True)
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/category")
|
||||||
|
async def expert_transform_category(request: ExpertTransformationRequest):
|
||||||
|
"""處理單一類別的專家視角轉換"""
|
||||||
|
# Extract all categories from request (should be passed separately in production)
|
||||||
|
# For now, use just the single category
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_expert_transformation_events(request, [request.category]),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
116
backend/app/routers/transformation.py
Normal file
116
backend/app/routers/transformation.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""Transformation Agent 路由模組"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import AsyncGenerator, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from ..models.schemas import (
|
||||||
|
TransformationRequest,
|
||||||
|
TransformationCategoryResult,
|
||||||
|
TransformationDescription,
|
||||||
|
)
|
||||||
|
from ..prompts.transformation_prompt import (
|
||||||
|
get_keyword_generation_prompt,
|
||||||
|
get_batch_description_prompt,
|
||||||
|
)
|
||||||
|
from ..services.llm_service import ollama_provider, extract_json_from_response
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/transformation", tags=["transformation"])
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_transformation_events(
|
||||||
|
request: TransformationRequest
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Generate SSE events for transformation process"""
|
||||||
|
try:
|
||||||
|
temperature = request.temperature if request.temperature is not None else 0.7
|
||||||
|
model = request.model
|
||||||
|
|
||||||
|
# ========== Step 1: Generate new keywords ==========
|
||||||
|
yield f"event: keyword_start\ndata: {json.dumps({'message': f'為「{request.category}」生成新關鍵字...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
keyword_prompt = get_keyword_generation_prompt(
|
||||||
|
category=request.category,
|
||||||
|
attributes=request.attributes,
|
||||||
|
keyword_count=request.keyword_count
|
||||||
|
)
|
||||||
|
logger.info(f"Keyword prompt: {keyword_prompt[:200]}")
|
||||||
|
|
||||||
|
keyword_response = await ollama_provider.generate(
|
||||||
|
keyword_prompt, model=model, temperature=temperature
|
||||||
|
)
|
||||||
|
logger.info(f"Keyword response: {keyword_response[:500]}")
|
||||||
|
|
||||||
|
keyword_data = extract_json_from_response(keyword_response)
|
||||||
|
new_keywords = keyword_data.get("keywords", [])
|
||||||
|
|
||||||
|
yield f"event: keyword_complete\ndata: {json.dumps({'keywords': new_keywords}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
if not new_keywords:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': '無法生成新關鍵字'}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
# ========== Step 2: Generate descriptions for each keyword ==========
|
||||||
|
yield f"event: description_start\ndata: {json.dumps({'message': '生成創新應用描述...'}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# Use batch description prompt for efficiency
|
||||||
|
desc_prompt = get_batch_description_prompt(
|
||||||
|
query=request.query,
|
||||||
|
category=request.category,
|
||||||
|
keywords=new_keywords
|
||||||
|
)
|
||||||
|
logger.info(f"Description prompt: {desc_prompt[:300]}")
|
||||||
|
|
||||||
|
desc_response = await ollama_provider.generate(
|
||||||
|
desc_prompt, model=model, temperature=temperature
|
||||||
|
)
|
||||||
|
logger.info(f"Description response: {desc_response[:500]}")
|
||||||
|
|
||||||
|
desc_data = extract_json_from_response(desc_response)
|
||||||
|
descriptions_raw = desc_data.get("descriptions", [])
|
||||||
|
|
||||||
|
# Convert to TransformationDescription objects
|
||||||
|
descriptions: List[TransformationDescription] = []
|
||||||
|
for desc in descriptions_raw:
|
||||||
|
if isinstance(desc, dict) and "keyword" in desc and "description" in desc:
|
||||||
|
descriptions.append(TransformationDescription(
|
||||||
|
keyword=desc["keyword"],
|
||||||
|
description=desc["description"]
|
||||||
|
))
|
||||||
|
|
||||||
|
yield f"event: description_complete\ndata: {json.dumps({'count': len(descriptions)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
# ========== Build final result ==========
|
||||||
|
result = TransformationCategoryResult(
|
||||||
|
category=request.category,
|
||||||
|
original_attributes=request.attributes,
|
||||||
|
new_keywords=new_keywords,
|
||||||
|
descriptions=descriptions
|
||||||
|
)
|
||||||
|
|
||||||
|
final_data = {
|
||||||
|
"result": result.model_dump()
|
||||||
|
}
|
||||||
|
yield f"event: done\ndata: {json.dumps(final_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transformation error: {e}")
|
||||||
|
yield f"event: error\ndata: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/category")
|
||||||
|
async def transform_category(request: TransformationRequest):
|
||||||
|
"""處理單一類別的轉換"""
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_transformation_events(request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { ConfigProvider, Layout, theme, Typography, Space } from 'antd';
|
import { ConfigProvider, Layout, theme, Typography, Space, Tabs } from 'antd';
|
||||||
import { ApartmentOutlined } from '@ant-design/icons';
|
import { ApartmentOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import { ThemeToggle } from './components/ThemeToggle';
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
import { InputPanel } from './components/InputPanel';
|
import { InputPanel } from './components/InputPanel';
|
||||||
|
import { TransformationInputPanel } from './components/TransformationInputPanel';
|
||||||
import { MindmapPanel } from './components/MindmapPanel';
|
import { MindmapPanel } from './components/MindmapPanel';
|
||||||
|
import { TransformationPanel } from './components/TransformationPanel';
|
||||||
import { useAttribute } from './hooks/useAttribute';
|
import { useAttribute } from './hooks/useAttribute';
|
||||||
|
import { getModels } from './services/api';
|
||||||
import type { MindmapDAGRef } from './components/MindmapDAG';
|
import type { MindmapDAGRef } from './components/MindmapDAG';
|
||||||
|
import type { TransformationDAGRef } from './components/TransformationDAG';
|
||||||
import type { CategoryMode } from './types';
|
import type { CategoryMode } from './types';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -18,12 +22,51 @@ interface VisualSettings {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isDark, setIsDark] = useState(true);
|
const [isDark, setIsDark] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('attribute');
|
||||||
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
|
const { loading, progress, error, currentResult, history, analyze, loadFromHistory } = useAttribute();
|
||||||
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
|
const [visualSettings, setVisualSettings] = useState<VisualSettings>({
|
||||||
nodeSpacing: 32,
|
nodeSpacing: 32,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
});
|
});
|
||||||
const mindmapRef = useRef<MindmapDAGRef>(null);
|
const mindmapRef = useRef<MindmapDAGRef>(null);
|
||||||
|
const transformationRef = useRef<TransformationDAGRef>(null);
|
||||||
|
|
||||||
|
// Transformation Agent settings
|
||||||
|
const [transformModel, setTransformModel] = useState<string>('');
|
||||||
|
const [transformTemperature, setTransformTemperature] = useState<number>(0.7);
|
||||||
|
const [expertConfig, setExpertConfig] = useState<{
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
}>({
|
||||||
|
expert_count: 3,
|
||||||
|
keywords_per_expert: 1,
|
||||||
|
custom_experts: undefined,
|
||||||
|
});
|
||||||
|
const [customExpertsInput, setCustomExpertsInput] = useState('');
|
||||||
|
const [shouldStartTransform, setShouldStartTransform] = useState(false);
|
||||||
|
const [transformLoading, setTransformLoading] = useState(false);
|
||||||
|
|
||||||
|
// Available models from API
|
||||||
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch models on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchModels() {
|
||||||
|
try {
|
||||||
|
const response = await getModels();
|
||||||
|
setAvailableModels(response.models);
|
||||||
|
// Set default model for transformation if not set
|
||||||
|
if (response.models.length > 0 && !transformModel) {
|
||||||
|
const defaultModel = response.models.find((m) => m.includes('qwen3')) || response.models[0];
|
||||||
|
setTransformModel(defaultModel);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch models:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchModels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAnalyze = async (
|
const handleAnalyze = async (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -41,6 +84,10 @@ function App() {
|
|||||||
mindmapRef.current?.resetView();
|
mindmapRef.current?.resetView();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTransform = useCallback(() => {
|
||||||
|
setShouldStartTransform(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
@@ -82,7 +129,7 @@ function App() {
|
|||||||
backgroundClip: 'text',
|
backgroundClip: 'text',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Attribute Agent
|
Novelty Seeking
|
||||||
</Title>
|
</Title>
|
||||||
</Space>
|
</Space>
|
||||||
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
<ThemeToggle isDark={isDark} onToggle={setIsDark} />
|
||||||
@@ -95,13 +142,58 @@ function App() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MindmapPanel
|
<Tabs
|
||||||
ref={mindmapRef}
|
activeKey={activeTab}
|
||||||
data={currentResult}
|
onChange={setActiveTab}
|
||||||
loading={loading}
|
style={{ height: '100%' }}
|
||||||
error={error}
|
tabBarStyle={{ marginBottom: 8 }}
|
||||||
isDark={isDark}
|
items={[
|
||||||
visualSettings={visualSettings}
|
{
|
||||||
|
key: 'attribute',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<ApartmentOutlined style={{ marginRight: 8 }} />
|
||||||
|
Attribute Agent
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||||
|
<MindmapPanel
|
||||||
|
ref={mindmapRef}
|
||||||
|
data={currentResult}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
isDark={isDark}
|
||||||
|
visualSettings={visualSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'transformation',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||||
|
Transformation Agent
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||||
|
<TransformationPanel
|
||||||
|
ref={transformationRef}
|
||||||
|
attributeData={currentResult}
|
||||||
|
isDark={isDark}
|
||||||
|
model={transformModel}
|
||||||
|
temperature={transformTemperature}
|
||||||
|
expertConfig={expertConfig}
|
||||||
|
shouldStartTransform={shouldStartTransform}
|
||||||
|
onTransformComplete={() => setShouldStartTransform(false)}
|
||||||
|
onLoadingChange={setTransformLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
<Sider
|
<Sider
|
||||||
@@ -112,17 +204,35 @@ function App() {
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<InputPanel
|
{activeTab === 'attribute' ? (
|
||||||
loading={loading}
|
<InputPanel
|
||||||
progress={progress}
|
loading={loading}
|
||||||
history={history}
|
progress={progress}
|
||||||
currentResult={currentResult}
|
history={history}
|
||||||
onAnalyze={handleAnalyze}
|
currentResult={currentResult}
|
||||||
onLoadHistory={loadFromHistory}
|
onAnalyze={handleAnalyze}
|
||||||
onResetView={handleResetView}
|
onLoadHistory={loadFromHistory}
|
||||||
visualSettings={visualSettings}
|
onResetView={handleResetView}
|
||||||
onVisualSettingsChange={setVisualSettings}
|
visualSettings={visualSettings}
|
||||||
/>
|
onVisualSettingsChange={setVisualSettings}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TransformationInputPanel
|
||||||
|
onTransform={handleTransform}
|
||||||
|
loading={transformLoading}
|
||||||
|
hasData={!!currentResult}
|
||||||
|
isDark={isDark}
|
||||||
|
model={transformModel}
|
||||||
|
temperature={transformTemperature}
|
||||||
|
expertConfig={expertConfig}
|
||||||
|
customExpertsInput={customExpertsInput}
|
||||||
|
onModelChange={setTransformModel}
|
||||||
|
onTemperatureChange={setTransformTemperature}
|
||||||
|
onExpertConfigChange={setExpertConfig}
|
||||||
|
onCustomExpertsInputChange={setCustomExpertsInput}
|
||||||
|
availableModels={availableModels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Sider>
|
</Sider>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
106
frontend/src/components/TransformationDAG.tsx
Normal file
106
frontend/src/components/TransformationDAG.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { forwardRef, useImperativeHandle } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
useReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
Background,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TransformationDAGResult,
|
||||||
|
ExpertTransformationDAGResult,
|
||||||
|
CategoryDefinition
|
||||||
|
} from '../types';
|
||||||
|
import {
|
||||||
|
transformationNodeTypes,
|
||||||
|
useTransformationLayout,
|
||||||
|
useExpertTransformationLayout
|
||||||
|
} from './transformation';
|
||||||
|
import '../styles/mindmap.css';
|
||||||
|
|
||||||
|
interface TransformationDAGProps {
|
||||||
|
data: TransformationDAGResult | ExpertTransformationDAGResult;
|
||||||
|
categories: CategoryDefinition[];
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationDAGRef {
|
||||||
|
resetView: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransformationDAGInner = forwardRef<TransformationDAGRef, TransformationDAGProps>(
|
||||||
|
({ data, categories, isDark }, ref) => {
|
||||||
|
const { setViewport } = useReactFlow();
|
||||||
|
|
||||||
|
// Check if data is ExpertTransformationDAGResult by checking for 'experts' property
|
||||||
|
const isExpertTransformation = 'experts' in data;
|
||||||
|
|
||||||
|
// Use appropriate layout hook based on data type
|
||||||
|
const regularLayout = useTransformationLayout(
|
||||||
|
!isExpertTransformation ? (data as TransformationDAGResult) : null,
|
||||||
|
categories,
|
||||||
|
{ isDark, fontSize: 13 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const expertLayout = useExpertTransformationLayout(
|
||||||
|
isExpertTransformation ? (data as ExpertTransformationDAGResult) : null,
|
||||||
|
categories,
|
||||||
|
{ isDark, fontSize: 13 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { nodes, edges } = isExpertTransformation ? expertLayout : regularLayout;
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
resetView: () => {
|
||||||
|
setViewport({ x: 50, y: 50, zoom: 1 }, { duration: 300 });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[setViewport]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={transformationNodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.3 }}
|
||||||
|
minZoom={0.3}
|
||||||
|
maxZoom={2}
|
||||||
|
defaultViewport={{ x: 50, y: 50, zoom: 1 }}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag
|
||||||
|
zoomOnScroll
|
||||||
|
zoomOnPinch
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
color={isDark ? '#333' : '#e0e0e0'}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TransformationDAGInner.displayName = 'TransformationDAGInner';
|
||||||
|
|
||||||
|
export const TransformationDAG = forwardRef<TransformationDAGRef, TransformationDAGProps>(
|
||||||
|
(props, ref) => (
|
||||||
|
<div
|
||||||
|
className={`mindmap-container ${props.isDark ? 'mindmap-dark' : 'mindmap-light'}`}
|
||||||
|
>
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<TransformationDAGInner {...props} ref={ref} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
TransformationDAG.displayName = 'TransformationDAG';
|
||||||
156
frontend/src/components/TransformationInputPanel.tsx
Normal file
156
frontend/src/components/TransformationInputPanel.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Card, Select, Slider, Typography, Space, Button, Divider } from 'antd';
|
||||||
|
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||||
|
import { ExpertConfigPanel } from './transformation';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface TransformationInputPanelProps {
|
||||||
|
onTransform: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
hasData: boolean;
|
||||||
|
isDark: boolean;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
expertConfig: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
};
|
||||||
|
customExpertsInput: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
|
onTemperatureChange: (temperature: number) => void;
|
||||||
|
onExpertConfigChange: (config: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
}) => void;
|
||||||
|
onCustomExpertsInputChange: (value: string) => void;
|
||||||
|
availableModels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransformationInputPanel: React.FC<TransformationInputPanelProps> = ({
|
||||||
|
onTransform,
|
||||||
|
loading,
|
||||||
|
hasData,
|
||||||
|
isDark,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
expertConfig,
|
||||||
|
customExpertsInput,
|
||||||
|
onModelChange,
|
||||||
|
onTemperatureChange,
|
||||||
|
onExpertConfigChange,
|
||||||
|
onCustomExpertsInputChange,
|
||||||
|
availableModels,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
||||||
|
Transformation Agent
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
使用專家視角生成創新關鍵字
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
|
||||||
|
{/* LLM Settings */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title="LLM 設定"
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||||
|
border: `1px solid ${isDark ? '#434343' : '#d9d9d9'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Text style={{ fontSize: 12 }}>模型</Text>
|
||||||
|
<Select
|
||||||
|
value={model}
|
||||||
|
onChange={onModelChange}
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 12 }}>Temperature</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: isDark ? '#1890ff' : '#1890ff' }}>
|
||||||
|
{temperature.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
value={temperature}
|
||||||
|
onChange={onTemperatureChange}
|
||||||
|
marks={{
|
||||||
|
0: '0',
|
||||||
|
0.5: '0.5',
|
||||||
|
1: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Expert Configuration */}
|
||||||
|
<ExpertConfigPanel
|
||||||
|
expertCount={expertConfig.expert_count}
|
||||||
|
keywordsPerExpert={expertConfig.keywords_per_expert}
|
||||||
|
customExperts={customExpertsInput}
|
||||||
|
onChange={(config) => {
|
||||||
|
onExpertConfigChange(config);
|
||||||
|
onCustomExpertsInputChange(config.custom_experts?.join(', ') || '');
|
||||||
|
}}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
|
||||||
|
{/* Transform Button */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={onTransform}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!hasData || loading}
|
||||||
|
>
|
||||||
|
{loading ? '轉換中...' : '開始轉換'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!hasData && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
background: isDark ? '#2b1d16' : '#fff7e6',
|
||||||
|
border: `1px solid ${isDark ? '#594214' : '#ffd591'}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="warning" style={{ fontSize: 12 }}>
|
||||||
|
請先在 Attribute Agent 分頁執行分析
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
259
frontend/src/components/TransformationPanel.tsx
Normal file
259
frontend/src/components/TransformationPanel.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { forwardRef, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { Empty, Spin, Button, Progress, Card, Space, Typography, Tag } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import type { AttributeDAG, ExpertTransformationInput } from '../types';
|
||||||
|
import { TransformationDAG } from './TransformationDAG';
|
||||||
|
import type { TransformationDAGRef } from './TransformationDAG';
|
||||||
|
import { useExpertTransformation } from '../hooks/useExpertTransformation';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface TransformationPanelProps {
|
||||||
|
attributeData: AttributeDAG | null;
|
||||||
|
isDark: boolean;
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
expertConfig: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
};
|
||||||
|
shouldStartTransform: boolean;
|
||||||
|
onTransformComplete: () => void;
|
||||||
|
onLoadingChange: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransformationPanel = forwardRef<TransformationDAGRef, TransformationPanelProps>(
|
||||||
|
({ attributeData, isDark, model, temperature, expertConfig, shouldStartTransform, onTransformComplete, onLoadingChange }, ref) => {
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
progress,
|
||||||
|
results,
|
||||||
|
transformAll,
|
||||||
|
clearResults,
|
||||||
|
} = useExpertTransformation({ model, temperature });
|
||||||
|
|
||||||
|
// Notify parent of loading state changes
|
||||||
|
useEffect(() => {
|
||||||
|
onLoadingChange(loading);
|
||||||
|
}, [loading, onLoadingChange]);
|
||||||
|
|
||||||
|
// Build expert transformation input from attribute data
|
||||||
|
const transformationInput = useMemo((): ExpertTransformationInput | null => {
|
||||||
|
if (!attributeData) return null;
|
||||||
|
|
||||||
|
const attributesByCategory: Record<string, string[]> = {};
|
||||||
|
for (const node of attributeData.nodes) {
|
||||||
|
if (!attributesByCategory[node.category]) {
|
||||||
|
attributesByCategory[node.category] = [];
|
||||||
|
}
|
||||||
|
attributesByCategory[node.category].push(node.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: attributeData.query,
|
||||||
|
categories: attributeData.categories,
|
||||||
|
attributesByCategory,
|
||||||
|
expertConfig,
|
||||||
|
};
|
||||||
|
}, [attributeData, expertConfig]);
|
||||||
|
|
||||||
|
const handleTransform = useCallback(async () => {
|
||||||
|
if (transformationInput) {
|
||||||
|
await transformAll(transformationInput);
|
||||||
|
onTransformComplete();
|
||||||
|
}
|
||||||
|
}, [transformationInput, transformAll, onTransformComplete]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
clearResults();
|
||||||
|
}, [clearResults]);
|
||||||
|
|
||||||
|
// Handle shouldStartTransform trigger from control panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldStartTransform && transformationInput && !loading && !results) {
|
||||||
|
handleTransform();
|
||||||
|
}
|
||||||
|
}, [shouldStartTransform, transformationInput, loading, results, handleTransform]);
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progressPercent = useMemo(() => {
|
||||||
|
if (!transformationInput || progress.step === 'idle') return 0;
|
||||||
|
const totalCategories = transformationInput.categories.length;
|
||||||
|
if (totalCategories === 0) return 0;
|
||||||
|
const completed = progress.processedCategories.length;
|
||||||
|
return Math.round((completed / totalCategories) * 100);
|
||||||
|
}, [transformationInput, progress]);
|
||||||
|
|
||||||
|
// No attribute data - show empty state
|
||||||
|
if (!attributeData) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Empty
|
||||||
|
description="請先在 Attribute Agent 分頁執行分析"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">
|
||||||
|
Transformation Agent 需要 Attribute Agent 的分析結果
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has attribute data but no results yet - show ready message
|
||||||
|
if (!results && !loading) {
|
||||||
|
const categoryCount = attributeData.categories.length;
|
||||||
|
const nodeCount = attributeData.nodes.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 400,
|
||||||
|
background: isDark ? '#1f1f1f' : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="large">
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
準備轉換:{attributeData.query}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="blue">{categoryCount} 個類別</Tag>
|
||||||
|
<Tag color="green">{nodeCount} 個屬性</Tag>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary">
|
||||||
|
專家團隊將從不同視角為每個屬性生成創新關鍵字並產生創新應用描述
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
請在右側控制面板配置專家參數並開始轉換
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state - show progress with expert info
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 450,
|
||||||
|
background: isDark ? '#1f1f1f' : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ fontSize: 16 }}>
|
||||||
|
{progress.message || '處理中...'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{progress.currentCategory && (
|
||||||
|
<Tag color="processing">{progress.currentCategory}</Tag>
|
||||||
|
)}
|
||||||
|
{progress.experts && progress.experts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
專家團隊:
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Space wrap size="small">
|
||||||
|
{progress.experts.map((expert) => (
|
||||||
|
<Tag key={expert.id} color="blue" style={{ fontSize: 11 }}>
|
||||||
|
{expert.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{progress.currentAttribute && (
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
當前屬性:{progress.currentAttribute}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Progress
|
||||||
|
percent={progressPercent}
|
||||||
|
status="active"
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#108ee9',
|
||||||
|
'100%': '#87d068',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text type="secondary">
|
||||||
|
已完成 {progress.processedCategories.length} / {transformationInput?.categories.length || 0} 個類別
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has results - show DAG visualization
|
||||||
|
if (results) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
重新轉換
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<TransformationDAG
|
||||||
|
ref={ref}
|
||||||
|
data={results}
|
||||||
|
categories={attributeData.categories}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback - should not reach here
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TransformationPanel.displayName = 'TransformationPanel';
|
||||||
@@ -50,9 +50,6 @@ export function useDAGLayout(
|
|||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
const sortedCategories = [...data.categories].sort((a, b) => a.order - b.order);
|
const sortedCategories = [...data.categories].sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
// Unified column spacing
|
|
||||||
const columnGap = 60; // Consistent gap between ALL adjacent elements
|
|
||||||
|
|
||||||
// Build category color map
|
// Build category color map
|
||||||
const categoryColors: Record<string, { fill: string; stroke: string }> = {};
|
const categoryColors: Record<string, { fill: string; stroke: string }> = {};
|
||||||
sortedCategories.forEach((cat, index) => {
|
sortedCategories.forEach((cat, index) => {
|
||||||
|
|||||||
185
frontend/src/components/transformation/ExpertConfigPanel.tsx
Normal file
185
frontend/src/components/transformation/ExpertConfigPanel.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Card, Slider, Input, Space, Typography, Divider, Tag } from 'antd';
|
||||||
|
import { TeamOutlined, BulbOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface ExpertConfigPanelProps {
|
||||||
|
expertCount: number;
|
||||||
|
keywordsPerExpert: number;
|
||||||
|
customExperts: string;
|
||||||
|
onChange: (config: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
}) => void;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpertConfigPanel: React.FC<ExpertConfigPanelProps> = ({
|
||||||
|
expertCount,
|
||||||
|
keywordsPerExpert,
|
||||||
|
customExperts,
|
||||||
|
onChange,
|
||||||
|
isDark,
|
||||||
|
}) => {
|
||||||
|
const handleExpertCountChange = (value: number) => {
|
||||||
|
onChange({
|
||||||
|
expert_count: value,
|
||||||
|
keywords_per_expert: keywordsPerExpert,
|
||||||
|
custom_experts: customExperts ? customExperts.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeywordsPerExpertChange = (value: number) => {
|
||||||
|
onChange({
|
||||||
|
expert_count: expertCount,
|
||||||
|
keywords_per_expert: value,
|
||||||
|
custom_experts: customExperts ? customExperts.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomExpertsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
onChange({
|
||||||
|
expert_count: expertCount,
|
||||||
|
keywords_per_expert: keywordsPerExpert,
|
||||||
|
custom_experts: value ? value.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate expected keywords per attribute
|
||||||
|
const expectedKeywordsPerAttribute = expertCount * keywordsPerExpert;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
background: isDark ? '#1f1f1f' : '#fafafa',
|
||||||
|
border: `1px solid ${isDark ? '#434343' : '#d9d9d9'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
|
<TeamOutlined style={{ marginRight: 8 }} />
|
||||||
|
專家配置
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
配置專家團隊的參數以生成多元化的創新關鍵字
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text>專家數量</Text>
|
||||||
|
<Tag color="blue">{expertCount} 位專家</Tag>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={2}
|
||||||
|
max={8}
|
||||||
|
value={expertCount}
|
||||||
|
onChange={handleExpertCountChange}
|
||||||
|
marks={{
|
||||||
|
2: '2',
|
||||||
|
4: '4',
|
||||||
|
6: '6',
|
||||||
|
8: '8',
|
||||||
|
}}
|
||||||
|
tooltip={{ formatter: (value) => `${value} 位專家` }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
控制生成幾個不同領域的專家(2-8位)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text>每專家關鍵字數</Text>
|
||||||
|
<Tag color="green">{keywordsPerExpert} 個/專家</Tag>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
value={keywordsPerExpert}
|
||||||
|
onChange={handleKeywordsPerExpertChange}
|
||||||
|
marks={{
|
||||||
|
1: '1',
|
||||||
|
2: '2',
|
||||||
|
3: '3',
|
||||||
|
}}
|
||||||
|
tooltip={{ formatter: (value) => `${value} 個關鍵字` }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
每位專家為每個屬性生成的關鍵字數量(1-3個)
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Text>指定專家領域(選填)</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="例:藥師, 工程師, 政治家(以逗號分隔)"
|
||||||
|
value={customExperts}
|
||||||
|
onChange={handleCustomExpertsChange}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
手動指定專家類型,系統會優先使用並自動補充不足的專家
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: isDark ? '#141414' : '#f0f2f5',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<BulbOutlined style={{ color: '#faad14' }} />
|
||||||
|
<Text strong>預計生成</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ paddingLeft: 24 }}>
|
||||||
|
<Text>
|
||||||
|
每個屬性將生成{' '}
|
||||||
|
<Text strong style={{ color: '#1890ff' }}>
|
||||||
|
{expectedKeywordsPerAttribute}
|
||||||
|
</Text>{' '}
|
||||||
|
個關鍵字
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
({expertCount} 位專家 × {keywordsPerExpert} 個/專家)
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expectedKeywordsPerAttribute > 10 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: isDark ? '#2b1d16' : '#fff7e6',
|
||||||
|
border: `1px solid ${isDark ? '#594214' : '#ffd591'}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="warning" style={{ fontSize: 12 }}>
|
||||||
|
⚠️ 關鍵字數量較多,可能需要較長處理時間
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
frontend/src/components/transformation/index.ts
Normal file
29
frontend/src/components/transformation/index.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { KeywordNode } from './nodes/KeywordNode';
|
||||||
|
import { ExpertKeywordNode } from './nodes/ExpertKeywordNode';
|
||||||
|
import { DescriptionNode } from './nodes/DescriptionNode';
|
||||||
|
import { CategoryNode } from './nodes/CategoryNode';
|
||||||
|
import { OriginalAttributeNode } from './nodes/OriginalAttributeNode';
|
||||||
|
import { DividerNode } from './nodes/DividerNode';
|
||||||
|
import { QueryNode } from '../dag/nodes/QueryNode';
|
||||||
|
|
||||||
|
export const transformationNodeTypes = {
|
||||||
|
query: QueryNode,
|
||||||
|
category: CategoryNode,
|
||||||
|
keyword: KeywordNode,
|
||||||
|
expertKeyword: ExpertKeywordNode,
|
||||||
|
description: DescriptionNode,
|
||||||
|
originalAttribute: OriginalAttributeNode,
|
||||||
|
divider: DividerNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
KeywordNode,
|
||||||
|
ExpertKeywordNode,
|
||||||
|
DescriptionNode,
|
||||||
|
CategoryNode,
|
||||||
|
OriginalAttributeNode,
|
||||||
|
DividerNode
|
||||||
|
};
|
||||||
|
export { useTransformationLayout } from './useTransformationLayout';
|
||||||
|
export { useExpertTransformationLayout } from './useExpertTransformationLayout';
|
||||||
|
export { ExpertConfigPanel } from './ExpertConfigPanel';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface CategoryNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
attributeCount: number;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CategoryNode = memo(({ data }: CategoryNodeProps) => {
|
||||||
|
const { label, color, isDark } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '10px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: `linear-gradient(135deg, ${color} 0%, ${color}dd 100%)`,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
boxShadow: `0 3px 10px ${isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CategoryNode.displayName = 'CategoryNode';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
|
interface DescriptionNodeProps {
|
||||||
|
data: {
|
||||||
|
keyword: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DescriptionNode = memo(({ data }: DescriptionNodeProps) => {
|
||||||
|
const { keyword, description, isDark } = data;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(135deg, rgba(82, 196, 26, 0.2) 0%, rgba(82, 196, 26, 0.1) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(82, 196, 26, 0.15) 0%, rgba(82, 196, 26, 0.05) 100%)',
|
||||||
|
border: `2px solid ${isHovered ? '#52c41a' : '#52c41a80'}`,
|
||||||
|
color: isDark ? '#fff' : '#333',
|
||||||
|
fontSize: '12px',
|
||||||
|
width: 400,
|
||||||
|
minHeight: 50,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
boxShadow: isHovered
|
||||||
|
? `0 4px 12px ${isDark ? 'rgba(82, 196, 26, 0.4)' : 'rgba(82, 196, 26, 0.25)'}`
|
||||||
|
: `0 2px 6px ${isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
paddingBottom: 6,
|
||||||
|
borderBottom: `1px solid ${isDark ? 'rgba(82, 196, 26, 0.3)' : 'rgba(82, 196, 26, 0.2)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
創新應用
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#52c41a',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
lineHeight: 1.5,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DescriptionNode.displayName = 'DescriptionNode';
|
||||||
26
frontend/src/components/transformation/nodes/DividerNode.tsx
Normal file
26
frontend/src/components/transformation/nodes/DividerNode.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface DividerNodeProps {
|
||||||
|
data: {
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DividerNode = memo(({ data }: DividerNodeProps) => {
|
||||||
|
const { isDark } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 2,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.1) 50%, transparent 100%)'
|
||||||
|
: 'linear-gradient(90deg, transparent 0%, rgba(0,0,0,0.08) 50%, transparent 100%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DividerNode.displayName = 'DividerNode';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
|
interface ExpertKeywordNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
expertName: string;
|
||||||
|
expertId: string;
|
||||||
|
color: string;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpertKeywordNode = memo(({ data }: ExpertKeywordNodeProps) => {
|
||||||
|
const { label, expertName, color, isDark } = data;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: '6px 12px',
|
||||||
|
paddingRight: '36px',
|
||||||
|
marginTop: 18,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(255, 255, 255, 0.1) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.1) 0%, rgba(0, 0, 0, 0.03) 100%)',
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
borderWidth: isHovered ? 3 : 2,
|
||||||
|
color: isDark ? '#fff' : '#333',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
filter: isHovered ? 'brightness(1.1)' : 'none',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 2px 8px rgba(24, 144, 255, 0.2)'
|
||||||
|
: '0 2px 8px rgba(24, 144, 255, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Expert Badge - positioned above the node */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -18,
|
||||||
|
left: 0,
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: isDark
|
||||||
|
? 'rgba(24, 144, 255, 0.8)'
|
||||||
|
: 'rgba(24, 144, 255, 0.9)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expertName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyword Label */}
|
||||||
|
<div>{label}</div>
|
||||||
|
|
||||||
|
{/* NEW Badge - positioned below the node */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -14,
|
||||||
|
right: 0,
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: 'linear-gradient(135deg, #52c41a 0%, #73d13d 100%)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '9px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ExpertKeywordNode.displayName = 'ExpertKeywordNode';
|
||||||
65
frontend/src/components/transformation/nodes/KeywordNode.tsx
Normal file
65
frontend/src/components/transformation/nodes/KeywordNode.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
|
interface KeywordNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeywordNode = memo(({ data }: KeywordNodeProps) => {
|
||||||
|
const { label, color, isDark } = data;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
padding: '6px 12px',
|
||||||
|
paddingRight: '36px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isDark
|
||||||
|
? 'linear-gradient(135deg, rgba(250, 173, 20, 0.15) 0%, rgba(255, 255, 255, 0.1) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(250, 173, 20, 0.1) 0%, rgba(0, 0, 0, 0.03) 100%)',
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
borderWidth: isHovered ? 3 : 2,
|
||||||
|
color: isDark ? '#fff' : '#333',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
filter: isHovered ? 'brightness(1.1)' : 'none',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 2px 8px rgba(250, 173, 20, 0.2)'
|
||||||
|
: '0 2px 8px rgba(250, 173, 20, 0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
padding: '2px 5px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '9px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
KeywordNode.displayName = 'KeywordNode';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface OriginalAttributeNodeProps {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
isDark: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OriginalAttributeNode = memo(({ data }: OriginalAttributeNodeProps) => {
|
||||||
|
const { label, color, isDark } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isDark
|
||||||
|
? `linear-gradient(135deg, ${color}33 0%, ${color}1a 100%)`
|
||||||
|
: `linear-gradient(135deg, ${color}22 0%, ${color}11 100%)`,
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
color: isDark ? 'rgba(255,255,255,0.95)' : 'rgba(0,0,0,0.85)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
userSelect: 'none',
|
||||||
|
boxShadow: isDark
|
||||||
|
? '0 2px 6px rgba(0,0,0,0.3)'
|
||||||
|
: '0 2px 6px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OriginalAttributeNode.displayName = 'OriginalAttributeNode';
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Node, Edge } from '@xyflow/react';
|
||||||
|
import type { ExpertTransformationDAGResult, CategoryDefinition } from '../../types';
|
||||||
|
|
||||||
|
interface LayoutConfig {
|
||||||
|
isDark: boolean;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_PALETTE = [
|
||||||
|
{ dark: '#177ddc', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#854eca', light: '#722ed1' }, // purple
|
||||||
|
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
|
||||||
|
{ dark: '#d87a16', light: '#fa8c16' }, // orange
|
||||||
|
{ dark: '#49aa19', light: '#52c41a' }, // green
|
||||||
|
{ dark: '#1677ff', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
|
||||||
|
{ dark: '#faad14', light: '#ffc53d' }, // gold
|
||||||
|
];
|
||||||
|
|
||||||
|
// Estimate description card height based on text length
|
||||||
|
function estimateDescriptionHeight(description: string): number {
|
||||||
|
const cardWidth = 400;
|
||||||
|
const padding = 24;
|
||||||
|
const headerHeight = 32;
|
||||||
|
const charPerLine = Math.floor(cardWidth / 14);
|
||||||
|
const lineHeight = 20;
|
||||||
|
const lines = Math.ceil(description.length / charPerLine);
|
||||||
|
return Math.min(padding + headerHeight + lines * lineHeight, 140);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExpertTransformationLayout(
|
||||||
|
data: ExpertTransformationDAGResult | null,
|
||||||
|
categories: CategoryDefinition[],
|
||||||
|
config: LayoutConfig
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!data || data.results.length === 0) {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isDark, fontSize = 13 } = config;
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
const colStep = 140;
|
||||||
|
const categoryRowGap = 120;
|
||||||
|
const minItemGap = 12;
|
||||||
|
const expertKeywordGap = 24; // gap between expert keywords
|
||||||
|
|
||||||
|
const queryX = 0;
|
||||||
|
const categoryX = colStep;
|
||||||
|
const originalAttrX = colStep * 2;
|
||||||
|
const keywordX = colStep * 3.2;
|
||||||
|
const descriptionX = colStep * 4.8;
|
||||||
|
|
||||||
|
// Build category color map
|
||||||
|
const categoryColors: Record<string, string> = {};
|
||||||
|
categories.forEach((cat, index) => {
|
||||||
|
const paletteIndex = index % COLOR_PALETTE.length;
|
||||||
|
categoryColors[cat.name] = isDark
|
||||||
|
? COLOR_PALETTE[paletteIndex].dark
|
||||||
|
: COLOR_PALETTE[paletteIndex].light;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-calculate layouts for each category
|
||||||
|
interface CategoryLayout {
|
||||||
|
attributeGroups: Array<{
|
||||||
|
attribute: string;
|
||||||
|
expertKeywords: Array<{
|
||||||
|
keyword: string;
|
||||||
|
expertName: string;
|
||||||
|
expertId: string;
|
||||||
|
yOffset: number;
|
||||||
|
}>;
|
||||||
|
descriptionYPositions: number[];
|
||||||
|
totalHeight: number;
|
||||||
|
}>;
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLayouts: CategoryLayout[] = data.results.map((result) => {
|
||||||
|
// Group expert keywords by source_attribute
|
||||||
|
const attributeMap = new Map<string, typeof result.expert_keywords>();
|
||||||
|
result.expert_keywords.forEach((kw) => {
|
||||||
|
if (!attributeMap.has(kw.source_attribute)) {
|
||||||
|
attributeMap.set(kw.source_attribute, []);
|
||||||
|
}
|
||||||
|
attributeMap.get(kw.source_attribute)!.push(kw);
|
||||||
|
});
|
||||||
|
|
||||||
|
const attributeGroups: CategoryLayout['attributeGroups'] = [];
|
||||||
|
let categoryTotalHeight = 0;
|
||||||
|
|
||||||
|
// Process each source attribute group
|
||||||
|
result.original_attributes.forEach((attr) => {
|
||||||
|
const expertKeywords = attributeMap.get(attr) || [];
|
||||||
|
const keywordPositions: typeof attributeGroups[0]['expertKeywords'] = [];
|
||||||
|
const descYPositions: number[] = [];
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
expertKeywords.forEach((kw) => {
|
||||||
|
keywordPositions.push({
|
||||||
|
keyword: kw.keyword,
|
||||||
|
expertName: kw.expert_name,
|
||||||
|
expertId: kw.expert_id,
|
||||||
|
yOffset: currentY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find matching description
|
||||||
|
const matchingDesc = result.descriptions.find(
|
||||||
|
(d) => d.keyword === kw.keyword && d.expert_id === kw.expert_id
|
||||||
|
);
|
||||||
|
const descHeight = matchingDesc
|
||||||
|
? estimateDescriptionHeight(matchingDesc.description)
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
descYPositions.push(currentY);
|
||||||
|
currentY += descHeight + expertKeywordGap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupHeight = currentY > 0 ? currentY - expertKeywordGap + minItemGap : 0;
|
||||||
|
|
||||||
|
attributeGroups.push({
|
||||||
|
attribute: attr,
|
||||||
|
expertKeywords: keywordPositions,
|
||||||
|
descriptionYPositions: descYPositions,
|
||||||
|
totalHeight: groupHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
categoryTotalHeight += groupHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { attributeGroups, totalHeight: categoryTotalHeight };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total height for query centering
|
||||||
|
const totalHeight = categoryLayouts.reduce(
|
||||||
|
(sum, layout, i) =>
|
||||||
|
sum + layout.totalHeight + (i < categoryLayouts.length - 1 ? categoryRowGap : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Query node (centered vertically)
|
||||||
|
const queryY = totalHeight / 2 - 20;
|
||||||
|
nodes.push({
|
||||||
|
id: 'query-node',
|
||||||
|
type: 'query',
|
||||||
|
position: { x: queryX, y: queryY },
|
||||||
|
data: {
|
||||||
|
label: data.query,
|
||||||
|
isDark,
|
||||||
|
fontSize,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track current Y position
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
// Process each category result
|
||||||
|
data.results.forEach((result, catIndex) => {
|
||||||
|
const categoryId = `category-${catIndex}`;
|
||||||
|
const color = categoryColors[result.category] || '#666';
|
||||||
|
const layout = categoryLayouts[catIndex];
|
||||||
|
|
||||||
|
// Category Y position (centered within its group)
|
||||||
|
const categoryY = currentY + layout.totalHeight / 2 - 20;
|
||||||
|
|
||||||
|
// Add category node
|
||||||
|
nodes.push({
|
||||||
|
id: categoryId,
|
||||||
|
type: 'category',
|
||||||
|
position: { x: categoryX, y: categoryY },
|
||||||
|
data: {
|
||||||
|
label: result.category,
|
||||||
|
color,
|
||||||
|
attributeCount: result.original_attributes.length,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from query to category
|
||||||
|
edges.push({
|
||||||
|
id: `edge-query-${categoryId}`,
|
||||||
|
source: 'query-node',
|
||||||
|
target: categoryId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: color, strokeWidth: 2, opacity: 0.6 },
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process each attribute group
|
||||||
|
layout.attributeGroups.forEach((group, attrIndex) => {
|
||||||
|
const attrId = `orig-${catIndex}-${attrIndex}`;
|
||||||
|
const attrY = currentY + group.totalHeight / 2 - 12;
|
||||||
|
|
||||||
|
// Add original attribute node
|
||||||
|
nodes.push({
|
||||||
|
id: attrId,
|
||||||
|
type: 'originalAttribute',
|
||||||
|
position: { x: originalAttrX, y: attrY },
|
||||||
|
data: {
|
||||||
|
label: group.attribute,
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from category to original attribute
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${categoryId}-${attrId}`,
|
||||||
|
source: categoryId,
|
||||||
|
target: attrId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: color, strokeWidth: 1, opacity: 0.3 },
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add expert keyword and description nodes
|
||||||
|
group.expertKeywords.forEach((kw, kwIndex) => {
|
||||||
|
const keywordId = `keyword-${catIndex}-${attrIndex}-${kwIndex}`;
|
||||||
|
const keywordY = currentY + kw.yOffset;
|
||||||
|
|
||||||
|
// Expert keyword node
|
||||||
|
nodes.push({
|
||||||
|
id: keywordId,
|
||||||
|
type: 'expertKeyword',
|
||||||
|
position: { x: keywordX, y: keywordY },
|
||||||
|
data: {
|
||||||
|
label: kw.keyword,
|
||||||
|
expertName: kw.expertName,
|
||||||
|
expertId: kw.expertId,
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from original attribute to expert keyword
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${attrId}-${keywordId}`,
|
||||||
|
source: attrId,
|
||||||
|
target: keywordId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: {
|
||||||
|
stroke: '#1890ff',
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: 0.7,
|
||||||
|
strokeDasharray: '5,5',
|
||||||
|
},
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find matching description - match by expert_id and source_attribute
|
||||||
|
// (more lenient than exact keyword match since LLM may return slightly different text)
|
||||||
|
const matchingDesc = result.descriptions.find(
|
||||||
|
(d) => d.expert_id === kw.expertId &&
|
||||||
|
(d.keyword === kw.keyword ||
|
||||||
|
d.keyword.includes(kw.keyword) ||
|
||||||
|
kw.keyword.includes(d.keyword))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Always show a description node (use fallback if not found)
|
||||||
|
const descId = `desc-${catIndex}-${attrIndex}-${kwIndex}`;
|
||||||
|
const descY = currentY + group.descriptionYPositions[kwIndex];
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: descId,
|
||||||
|
type: 'description',
|
||||||
|
position: { x: descriptionX, y: descY },
|
||||||
|
data: {
|
||||||
|
keyword: matchingDesc?.keyword || kw.keyword,
|
||||||
|
description: matchingDesc?.description || '(描述生成中...)',
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from expert keyword to description (always show)
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${keywordId}-${descId}`,
|
||||||
|
source: keywordId,
|
||||||
|
target: descId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: {
|
||||||
|
stroke: matchingDesc ? '#52c41a' : '#999',
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: matchingDesc ? 0.6 : 0.3,
|
||||||
|
strokeDasharray: matchingDesc ? undefined : '4,4',
|
||||||
|
},
|
||||||
|
animated: !matchingDesc,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
currentY += group.totalHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add divider line between categories (except after last one)
|
||||||
|
if (catIndex < data.results.length - 1) {
|
||||||
|
const dividerY = currentY + categoryRowGap / 2 - 1;
|
||||||
|
nodes.push({
|
||||||
|
id: `divider-${catIndex}`,
|
||||||
|
type: 'divider',
|
||||||
|
position: { x: queryX, y: dividerY },
|
||||||
|
data: {
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
style: {
|
||||||
|
width: descriptionX + 400,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentY += categoryRowGap;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}, [data, categories, config]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { Node, Edge } from '@xyflow/react';
|
||||||
|
import type { TransformationDAGResult, CategoryDefinition } from '../../types';
|
||||||
|
|
||||||
|
interface LayoutConfig {
|
||||||
|
isDark: boolean;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_PALETTE = [
|
||||||
|
{ dark: '#177ddc', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#854eca', light: '#722ed1' }, // purple
|
||||||
|
{ dark: '#13a8a8', light: '#13c2c2' }, // cyan
|
||||||
|
{ dark: '#d87a16', light: '#fa8c16' }, // orange
|
||||||
|
{ dark: '#49aa19', light: '#52c41a' }, // green
|
||||||
|
{ dark: '#1677ff', light: '#1890ff' }, // blue
|
||||||
|
{ dark: '#eb2f96', light: '#f759ab' }, // magenta
|
||||||
|
{ dark: '#faad14', light: '#ffc53d' }, // gold
|
||||||
|
];
|
||||||
|
|
||||||
|
// Estimate description card height based on text length
|
||||||
|
function estimateDescriptionHeight(description: string): number {
|
||||||
|
const cardWidth = 320; // increased card width
|
||||||
|
const padding = 20; // top + bottom padding
|
||||||
|
const headerHeight = 30; // header section with keyword
|
||||||
|
const charPerLine = Math.floor(cardWidth / 13); // ~13px per Chinese char
|
||||||
|
const lineHeight = 18; // line height in px
|
||||||
|
const lines = Math.ceil(description.length / charPerLine);
|
||||||
|
// Cap at reasonable height to prevent huge gaps
|
||||||
|
return Math.min(padding + headerHeight + lines * lineHeight, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransformationLayout(
|
||||||
|
data: TransformationDAGResult | null,
|
||||||
|
categories: CategoryDefinition[],
|
||||||
|
config: LayoutConfig
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!data || data.results.length === 0) {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isDark, fontSize = 13 } = config;
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
const colStep = 140;
|
||||||
|
const categoryRowGap = 120; // large gap between different categories
|
||||||
|
const minItemGap = 12; // minimum gap between transformation items
|
||||||
|
const origAttrRowStep = 36; // step for original attributes (same visual rhythm)
|
||||||
|
|
||||||
|
const queryX = 0;
|
||||||
|
const categoryX = colStep;
|
||||||
|
const originalAttrX = colStep * 2;
|
||||||
|
const keywordX = colStep * 3.2; // increased gap from original attributes
|
||||||
|
const descriptionX = colStep * 4.8; // moved further right for wider cards
|
||||||
|
|
||||||
|
// Build category color map
|
||||||
|
const categoryColors: Record<string, string> = {};
|
||||||
|
categories.forEach((cat, index) => {
|
||||||
|
const paletteIndex = index % COLOR_PALETTE.length;
|
||||||
|
categoryColors[cat.name] = isDark
|
||||||
|
? COLOR_PALETTE[paletteIndex].dark
|
||||||
|
: COLOR_PALETTE[paletteIndex].light;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-calculate all description heights and keyword Y positions for each category
|
||||||
|
interface CategoryLayout {
|
||||||
|
keywordYPositions: number[];
|
||||||
|
origAttrYPositions: number[];
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLayouts: CategoryLayout[] = data.results.map((result) => {
|
||||||
|
// Calculate keyword/description Y positions based on description heights
|
||||||
|
const keywordYPositions: number[] = [];
|
||||||
|
let currentKeywordY = 0;
|
||||||
|
|
||||||
|
result.new_keywords.forEach((keyword) => {
|
||||||
|
keywordYPositions.push(currentKeywordY);
|
||||||
|
|
||||||
|
// Find matching description to calculate height
|
||||||
|
const matchingDesc = result.descriptions.find((d) => d.keyword === keyword);
|
||||||
|
const descHeight = matchingDesc
|
||||||
|
? estimateDescriptionHeight(matchingDesc.description)
|
||||||
|
: 50;
|
||||||
|
|
||||||
|
// Next keyword starts after this description
|
||||||
|
currentKeywordY += descHeight + minItemGap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const keywordTotalHeight = currentKeywordY > 0 ? currentKeywordY - minItemGap : 0;
|
||||||
|
|
||||||
|
// Calculate original attribute positions to match the same total height
|
||||||
|
const origCount = result.original_attributes.length;
|
||||||
|
const origAttrYPositions: number[] = [];
|
||||||
|
|
||||||
|
if (origCount > 0) {
|
||||||
|
// Distribute original attributes evenly across the same height as keywords
|
||||||
|
const effectiveHeight = Math.max(keywordTotalHeight, origCount * origAttrRowStep);
|
||||||
|
const origStep = origCount > 1 ? effectiveHeight / (origCount - 1) : 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < origCount; i++) {
|
||||||
|
origAttrYPositions.push(i * (origCount > 1 ? origStep : 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalHeight = Math.max(keywordTotalHeight, origCount * origAttrRowStep);
|
||||||
|
|
||||||
|
return { keywordYPositions, origAttrYPositions, totalHeight };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total height for query centering
|
||||||
|
const totalHeight = categoryLayouts.reduce(
|
||||||
|
(sum, layout, i) =>
|
||||||
|
sum + layout.totalHeight + (i < categoryLayouts.length - 1 ? categoryRowGap : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Query node (centered vertically)
|
||||||
|
const queryY = totalHeight / 2 - 20;
|
||||||
|
nodes.push({
|
||||||
|
id: 'query-node',
|
||||||
|
type: 'query',
|
||||||
|
position: { x: queryX, y: queryY },
|
||||||
|
data: {
|
||||||
|
label: data.query,
|
||||||
|
isDark,
|
||||||
|
fontSize,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track current Y position
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
// Process each category result
|
||||||
|
data.results.forEach((result, catIndex) => {
|
||||||
|
const categoryId = `category-${catIndex}`;
|
||||||
|
const color = categoryColors[result.category] || '#666';
|
||||||
|
const layout = categoryLayouts[catIndex];
|
||||||
|
|
||||||
|
// Category Y position (centered within its group)
|
||||||
|
const categoryY = currentY + layout.totalHeight / 2 - 20;
|
||||||
|
|
||||||
|
// Add category node
|
||||||
|
nodes.push({
|
||||||
|
id: categoryId,
|
||||||
|
type: 'category',
|
||||||
|
position: { x: categoryX, y: categoryY },
|
||||||
|
data: {
|
||||||
|
label: result.category,
|
||||||
|
color,
|
||||||
|
attributeCount: result.original_attributes.length,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from query to category
|
||||||
|
edges.push({
|
||||||
|
id: `edge-query-${categoryId}`,
|
||||||
|
source: 'query-node',
|
||||||
|
target: categoryId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: color, strokeWidth: 2, opacity: 0.6 },
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add original attribute nodes (distributed to match keyword spacing)
|
||||||
|
result.original_attributes.forEach((attr, attrIndex) => {
|
||||||
|
const attrId = `orig-${catIndex}-${attrIndex}`;
|
||||||
|
const attrY = currentY + (layout.origAttrYPositions[attrIndex] || 0);
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: attrId,
|
||||||
|
type: 'originalAttribute',
|
||||||
|
position: { x: originalAttrX, y: attrY },
|
||||||
|
data: {
|
||||||
|
label: attr,
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from category to original attribute
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${categoryId}-${attrId}`,
|
||||||
|
source: categoryId,
|
||||||
|
target: attrId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: color, strokeWidth: 1, opacity: 0.3 },
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keyword and description nodes with smart Y positions
|
||||||
|
result.new_keywords.forEach((keyword, kwIndex) => {
|
||||||
|
const keywordId = `keyword-${catIndex}-${kwIndex}`;
|
||||||
|
const keywordY = currentY + layout.keywordYPositions[kwIndex];
|
||||||
|
|
||||||
|
// Keyword node
|
||||||
|
nodes.push({
|
||||||
|
id: keywordId,
|
||||||
|
type: 'keyword',
|
||||||
|
position: { x: keywordX, y: keywordY },
|
||||||
|
data: {
|
||||||
|
label: keyword,
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from category to keyword
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${categoryId}-${keywordId}`,
|
||||||
|
source: categoryId,
|
||||||
|
target: keywordId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: {
|
||||||
|
stroke: '#faad14',
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: 0.7,
|
||||||
|
strokeDasharray: '5,5',
|
||||||
|
},
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find matching description
|
||||||
|
const matchingDesc = result.descriptions.find((d) => d.keyword === keyword);
|
||||||
|
if (matchingDesc) {
|
||||||
|
const descId = `desc-${catIndex}-${kwIndex}`;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: descId,
|
||||||
|
type: 'description',
|
||||||
|
position: { x: descriptionX, y: keywordY },
|
||||||
|
data: {
|
||||||
|
keyword: matchingDesc.keyword,
|
||||||
|
description: matchingDesc.description,
|
||||||
|
color,
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edge from keyword to description
|
||||||
|
edges.push({
|
||||||
|
id: `edge-${keywordId}-${descId}`,
|
||||||
|
source: keywordId,
|
||||||
|
target: descId,
|
||||||
|
type: 'smoothstep',
|
||||||
|
style: { stroke: '#52c41a', strokeWidth: 2, opacity: 0.6 },
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Y position for next category
|
||||||
|
currentY += layout.totalHeight;
|
||||||
|
|
||||||
|
// Add divider line between categories (except after last one)
|
||||||
|
if (catIndex < data.results.length - 1) {
|
||||||
|
const dividerY = currentY + categoryRowGap / 2 - 1;
|
||||||
|
nodes.push({
|
||||||
|
id: `divider-${catIndex}`,
|
||||||
|
type: 'divider',
|
||||||
|
position: { x: queryX, y: dividerY },
|
||||||
|
data: {
|
||||||
|
isDark,
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
style: {
|
||||||
|
width: descriptionX + 400,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += categoryRowGap;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}, [data, categories, config]);
|
||||||
|
}
|
||||||
238
frontend/src/hooks/useExpertTransformation.ts
Normal file
238
frontend/src/hooks/useExpertTransformation.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { expertTransformCategoryStream } from '../services/api';
|
||||||
|
import type {
|
||||||
|
ExpertTransformationInput,
|
||||||
|
ExpertTransformationProgress,
|
||||||
|
ExpertTransformationCategoryResult,
|
||||||
|
ExpertTransformationDAGResult,
|
||||||
|
ExpertProfile,
|
||||||
|
CategoryDefinition,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
interface UseExpertTransformationOptions {
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExpertTransformation(options: UseExpertTransformationOptions = {}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<ExpertTransformationProgress>({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
const [results, setResults] = useState<ExpertTransformationDAGResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Global expert team - generated once and shared across all categories
|
||||||
|
const [experts, setExperts] = useState<ExpertProfile[] | null>(null);
|
||||||
|
|
||||||
|
const transformCategory = useCallback(
|
||||||
|
async (
|
||||||
|
query: string,
|
||||||
|
category: CategoryDefinition,
|
||||||
|
attributes: string[],
|
||||||
|
expertConfig: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
result: ExpertTransformationCategoryResult | null;
|
||||||
|
experts: ExpertProfile[];
|
||||||
|
}> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let categoryExperts: ExpertProfile[] = [];
|
||||||
|
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'expert',
|
||||||
|
currentCategory: category.name,
|
||||||
|
message: `組建專家團隊...`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expertTransformCategoryStream(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
category: category.name,
|
||||||
|
attributes,
|
||||||
|
expert_count: expertConfig.expert_count,
|
||||||
|
keywords_per_expert: expertConfig.keywords_per_expert,
|
||||||
|
custom_experts: expertConfig.custom_experts,
|
||||||
|
model: options.model,
|
||||||
|
temperature: options.temperature,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onExpertStart: () => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'expert',
|
||||||
|
message: `正在組建專家團隊...`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onExpertComplete: (expertsData) => {
|
||||||
|
categoryExperts = expertsData;
|
||||||
|
setExperts(expertsData);
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
experts: expertsData,
|
||||||
|
message: `專家團隊組建完成(${expertsData.length}位專家)`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onKeywordStart: () => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'keyword',
|
||||||
|
message: `專家團隊為「${category.name}」的屬性生成關鍵字...`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onKeywordProgress: (data) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentAttribute: data.attribute,
|
||||||
|
message: `為「${data.attribute}」生成了 ${data.count} 個關鍵字`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onKeywordComplete: (totalKeywords) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
message: `共生成了 ${totalKeywords} 個專家關鍵字`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDescriptionStart: () => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'description',
|
||||||
|
message: `為「${category.name}」的專家關鍵字生成創新描述...`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDescriptionComplete: (count) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
message: `生成了 ${count} 個創新描述`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDone: (data) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'done',
|
||||||
|
processedCategories: [...prev.processedCategories, category.name],
|
||||||
|
message: `「${category.name}」處理完成`,
|
||||||
|
}));
|
||||||
|
resolve({
|
||||||
|
result: data.result,
|
||||||
|
experts: data.experts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'error',
|
||||||
|
error: err,
|
||||||
|
message: `處理「${category.name}」時發生錯誤`,
|
||||||
|
}));
|
||||||
|
resolve({
|
||||||
|
result: null,
|
||||||
|
experts: categoryExperts,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).catch((err) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'error',
|
||||||
|
error: err.message,
|
||||||
|
message: `處理「${category.name}」時發生錯誤`,
|
||||||
|
}));
|
||||||
|
resolve({
|
||||||
|
result: null,
|
||||||
|
experts: categoryExperts,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[options.model, options.temperature]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transformAll = useCallback(
|
||||||
|
async (input: ExpertTransformationInput) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
setExperts(null);
|
||||||
|
setProgress({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '開始處理...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryResults: ExpertTransformationCategoryResult[] = [];
|
||||||
|
let globalExperts: ExpertProfile[] = [];
|
||||||
|
|
||||||
|
// Process each category sequentially
|
||||||
|
for (const category of input.categories) {
|
||||||
|
const attributes = input.attributesByCategory[category.name] || [];
|
||||||
|
if (attributes.length === 0) continue;
|
||||||
|
|
||||||
|
const { result, experts: categoryExperts } = await transformCategory(
|
||||||
|
input.query,
|
||||||
|
category,
|
||||||
|
attributes,
|
||||||
|
input.expertConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store global experts from first category
|
||||||
|
if (globalExperts.length === 0 && categoryExperts.length > 0) {
|
||||||
|
globalExperts = categoryExperts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
categoryResults.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final result
|
||||||
|
const finalResult: ExpertTransformationDAGResult = {
|
||||||
|
query: input.query,
|
||||||
|
experts: globalExperts,
|
||||||
|
results: categoryResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
setResults(finalResult);
|
||||||
|
setLoading(false);
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'done',
|
||||||
|
message: '所有類別處理完成',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return finalResult;
|
||||||
|
},
|
||||||
|
[transformCategory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearResults = useCallback(() => {
|
||||||
|
setResults(null);
|
||||||
|
setError(null);
|
||||||
|
setExperts(null);
|
||||||
|
setProgress({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
progress,
|
||||||
|
results,
|
||||||
|
error,
|
||||||
|
experts,
|
||||||
|
transformCategory,
|
||||||
|
transformAll,
|
||||||
|
clearResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
175
frontend/src/hooks/useTransformation.ts
Normal file
175
frontend/src/hooks/useTransformation.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { transformCategoryStream } from '../services/api';
|
||||||
|
import type {
|
||||||
|
TransformationInput,
|
||||||
|
TransformationProgress,
|
||||||
|
TransformationCategoryResult,
|
||||||
|
TransformationDAGResult,
|
||||||
|
CategoryDefinition,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
interface UseTransformationOptions {
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
keywordCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransformation(options: UseTransformationOptions = {}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<TransformationProgress>({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
const [results, setResults] = useState<TransformationDAGResult | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const transformCategory = useCallback(
|
||||||
|
async (
|
||||||
|
query: string,
|
||||||
|
category: CategoryDefinition,
|
||||||
|
attributes: string[]
|
||||||
|
): Promise<TransformationCategoryResult | null> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'keyword',
|
||||||
|
currentCategory: category.name,
|
||||||
|
message: `為「${category.name}」生成新關鍵字...`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
transformCategoryStream(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
category: category.name,
|
||||||
|
attributes,
|
||||||
|
model: options.model,
|
||||||
|
temperature: options.temperature,
|
||||||
|
keyword_count: options.keywordCount || 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onKeywordStart: () => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'keyword',
|
||||||
|
message: `為「${category.name}」生成新關鍵字...`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onKeywordComplete: (keywords) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
message: `生成了 ${keywords.length} 個新關鍵字`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDescriptionStart: () => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'description',
|
||||||
|
message: `為「${category.name}」生成創新描述...`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDescriptionComplete: (count) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
message: `生成了 ${count} 個創新描述`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onDone: (result) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'done',
|
||||||
|
processedCategories: [...prev.processedCategories, category.name],
|
||||||
|
message: `「${category.name}」處理完成`,
|
||||||
|
}));
|
||||||
|
resolve(result);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'error',
|
||||||
|
error: err,
|
||||||
|
message: `處理「${category.name}」時發生錯誤`,
|
||||||
|
}));
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).catch((err) => {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'error',
|
||||||
|
error: err.message,
|
||||||
|
message: `處理「${category.name}」時發生錯誤`,
|
||||||
|
}));
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[options.model, options.temperature, options.keywordCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transformAll = useCallback(
|
||||||
|
async (input: TransformationInput) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResults(null);
|
||||||
|
setProgress({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '開始處理...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryResults: TransformationCategoryResult[] = [];
|
||||||
|
|
||||||
|
// Process each category sequentially
|
||||||
|
for (const category of input.categories) {
|
||||||
|
const attributes = input.attributesByCategory[category.name] || [];
|
||||||
|
if (attributes.length === 0) continue;
|
||||||
|
|
||||||
|
const result = await transformCategory(input.query, category, attributes);
|
||||||
|
if (result) {
|
||||||
|
categoryResults.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final result
|
||||||
|
const finalResult: TransformationDAGResult = {
|
||||||
|
query: input.query,
|
||||||
|
results: categoryResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
setResults(finalResult);
|
||||||
|
setLoading(false);
|
||||||
|
setProgress((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step: 'done',
|
||||||
|
message: '所有類別處理完成',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return finalResult;
|
||||||
|
},
|
||||||
|
[transformCategory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearResults = useCallback(() => {
|
||||||
|
setResults(null);
|
||||||
|
setError(null);
|
||||||
|
setProgress({
|
||||||
|
step: 'idle',
|
||||||
|
currentCategory: '',
|
||||||
|
processedCategories: [],
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
progress,
|
||||||
|
results,
|
||||||
|
error,
|
||||||
|
transformCategory,
|
||||||
|
transformAll,
|
||||||
|
clearResults,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,12 @@ import type {
|
|||||||
Step0Result,
|
Step0Result,
|
||||||
CategoryDefinition,
|
CategoryDefinition,
|
||||||
DynamicStep1Result,
|
DynamicStep1Result,
|
||||||
DAGStreamAnalyzeResponse
|
DAGStreamAnalyzeResponse,
|
||||||
|
TransformationRequest,
|
||||||
|
TransformationCategoryResult,
|
||||||
|
ExpertTransformationRequest,
|
||||||
|
ExpertTransformationCategoryResult,
|
||||||
|
ExpertProfile
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
// 自動使用當前瀏覽器的 hostname,支援遠端存取
|
||||||
@@ -114,3 +119,183 @@ export async function getModels(): Promise<ModelListResponse> {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Transformation Agent API =====
|
||||||
|
|
||||||
|
export interface TransformationSSECallbacks {
|
||||||
|
onKeywordStart?: () => void;
|
||||||
|
onKeywordComplete?: (keywords: string[]) => void;
|
||||||
|
onDescriptionStart?: () => void;
|
||||||
|
onDescriptionComplete?: (count: number) => void;
|
||||||
|
onDone?: (result: TransformationCategoryResult) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformCategoryStream(
|
||||||
|
request: TransformationRequest,
|
||||||
|
callbacks: TransformationSSECallbacks
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/transformation/category`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// 解析 SSE 事件
|
||||||
|
const lines = buffer.split('\n\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const chunk of lines) {
|
||||||
|
if (!chunk.trim()) continue;
|
||||||
|
|
||||||
|
const eventMatch = chunk.match(/event: (\w+)/);
|
||||||
|
const dataMatch = chunk.match(/data: (.+)/s);
|
||||||
|
|
||||||
|
if (eventMatch && dataMatch) {
|
||||||
|
const eventType = eventMatch[1];
|
||||||
|
try {
|
||||||
|
const eventData = JSON.parse(dataMatch[1]);
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'keyword_start':
|
||||||
|
callbacks.onKeywordStart?.();
|
||||||
|
break;
|
||||||
|
case 'keyword_complete':
|
||||||
|
callbacks.onKeywordComplete?.(eventData.keywords);
|
||||||
|
break;
|
||||||
|
case 'description_start':
|
||||||
|
callbacks.onDescriptionStart?.();
|
||||||
|
break;
|
||||||
|
case 'description_complete':
|
||||||
|
callbacks.onDescriptionComplete?.(eventData.count);
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
callbacks.onDone?.(eventData.result);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
callbacks.onError?.(eventData.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE event:', e, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Expert Transformation Agent API =====
|
||||||
|
|
||||||
|
export interface ExpertTransformationSSECallbacks {
|
||||||
|
onExpertStart?: () => void;
|
||||||
|
onExpertComplete?: (experts: ExpertProfile[]) => void;
|
||||||
|
onKeywordStart?: () => void;
|
||||||
|
onKeywordProgress?: (data: { attribute: string; count: number }) => void;
|
||||||
|
onKeywordComplete?: (totalKeywords: number) => void;
|
||||||
|
onDescriptionStart?: () => void;
|
||||||
|
onDescriptionComplete?: (count: number) => void;
|
||||||
|
onDone?: (data: { result: ExpertTransformationCategoryResult; experts: ExpertProfile[] }) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expertTransformCategoryStream(
|
||||||
|
request: ExpertTransformationRequest,
|
||||||
|
callbacks: ExpertTransformationSSECallbacks
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/expert-transformation/category`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('No response body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// 解析 SSE 事件
|
||||||
|
const lines = buffer.split('\n\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const chunk of lines) {
|
||||||
|
if (!chunk.trim()) continue;
|
||||||
|
|
||||||
|
const eventMatch = chunk.match(/event: (\w+)/);
|
||||||
|
const dataMatch = chunk.match(/data: (.+)/s);
|
||||||
|
|
||||||
|
if (eventMatch && dataMatch) {
|
||||||
|
const eventType = eventMatch[1];
|
||||||
|
try {
|
||||||
|
const eventData = JSON.parse(dataMatch[1]);
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'expert_start':
|
||||||
|
callbacks.onExpertStart?.();
|
||||||
|
break;
|
||||||
|
case 'expert_complete':
|
||||||
|
callbacks.onExpertComplete?.(eventData.experts);
|
||||||
|
break;
|
||||||
|
case 'keyword_start':
|
||||||
|
callbacks.onKeywordStart?.();
|
||||||
|
break;
|
||||||
|
case 'keyword_progress':
|
||||||
|
callbacks.onKeywordProgress?.(eventData);
|
||||||
|
break;
|
||||||
|
case 'keyword_complete':
|
||||||
|
callbacks.onKeywordComplete?.(eventData.total_keywords);
|
||||||
|
break;
|
||||||
|
case 'description_start':
|
||||||
|
callbacks.onDescriptionStart?.();
|
||||||
|
break;
|
||||||
|
case 'description_complete':
|
||||||
|
callbacks.onDescriptionComplete?.(eventData.count);
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
callbacks.onDone?.(eventData);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
callbacks.onError?.(eventData.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE event:', e, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,3 +151,113 @@ export interface DAGStreamAnalyzeResponse {
|
|||||||
relationships: DAGRelationship[];
|
relationships: DAGRelationship[];
|
||||||
dag: AttributeDAG;
|
dag: AttributeDAG;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Transformation Agent types =====
|
||||||
|
|
||||||
|
export interface TransformationRequest {
|
||||||
|
query: string;
|
||||||
|
category: string;
|
||||||
|
attributes: string[];
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
keyword_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationDescription {
|
||||||
|
keyword: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationCategoryResult {
|
||||||
|
category: string;
|
||||||
|
original_attributes: string[];
|
||||||
|
new_keywords: string[];
|
||||||
|
descriptions: TransformationDescription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationDAGResult {
|
||||||
|
query: string;
|
||||||
|
results: TransformationCategoryResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationProgress {
|
||||||
|
step: 'idle' | 'keyword' | 'description' | 'done' | 'error';
|
||||||
|
currentCategory: string;
|
||||||
|
processedCategories: string[];
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformationInput {
|
||||||
|
query: string;
|
||||||
|
categories: CategoryDefinition[];
|
||||||
|
attributesByCategory: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Expert Transformation Agent types =====
|
||||||
|
|
||||||
|
export interface ExpertProfile {
|
||||||
|
id: string; // "expert-0"
|
||||||
|
name: string; // "藥師"
|
||||||
|
domain: string; // "醫療與健康"
|
||||||
|
perspective?: string; // "從藥物與健康管理角度思考"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertKeyword {
|
||||||
|
keyword: string;
|
||||||
|
expert_id: string;
|
||||||
|
expert_name: string;
|
||||||
|
source_attribute: string; // 來自哪個原始屬性
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationDescription {
|
||||||
|
keyword: string;
|
||||||
|
expert_id: string;
|
||||||
|
expert_name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationCategoryResult {
|
||||||
|
category: string;
|
||||||
|
original_attributes: string[];
|
||||||
|
expert_keywords: ExpertKeyword[];
|
||||||
|
descriptions: ExpertTransformationDescription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationDAGResult {
|
||||||
|
query: string;
|
||||||
|
experts: ExpertProfile[];
|
||||||
|
results: ExpertTransformationCategoryResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationRequest {
|
||||||
|
query: string;
|
||||||
|
category: string;
|
||||||
|
attributes: string[];
|
||||||
|
expert_count: number; // 2-8
|
||||||
|
keywords_per_expert: number; // 1-3
|
||||||
|
custom_experts?: string[]; // ["藥師", "工程師"]
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationProgress {
|
||||||
|
step: 'idle' | 'expert' | 'keyword' | 'description' | 'done' | 'error';
|
||||||
|
currentCategory: string;
|
||||||
|
processedCategories: string[];
|
||||||
|
experts?: ExpertProfile[];
|
||||||
|
currentAttribute?: string;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpertTransformationInput {
|
||||||
|
query: string;
|
||||||
|
categories: CategoryDefinition[];
|
||||||
|
attributesByCategory: Record<string, string[]>;
|
||||||
|
expertConfig: {
|
||||||
|
expert_count: number;
|
||||||
|
keywords_per_expert: number;
|
||||||
|
custom_experts?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user