Spaces:
Running
Running
| // src/components/EnrichmentPanel.tsx | |
| import { useState, useEffect } from 'react'; | |
| import apiClient from '../services/api'; | |
| interface ModelInfo { | |
| id: string; | |
| name: string; | |
| provider: string; | |
| } | |
| interface EnrichmentConfig { | |
| ai_enrichment: { | |
| available: boolean; | |
| available_models: ModelInfo[]; | |
| master_model_id: string | null; | |
| }; | |
| web_enrichment: { | |
| available: boolean; | |
| search_engine: string; | |
| api_key_configured: boolean; | |
| }; | |
| } | |
| interface EnrichmentStatus { | |
| is_running: boolean; | |
| progress: number; | |
| current_question: number; | |
| total_questions: number; | |
| generated_tiles: number; | |
| start_time: string | null; | |
| domain_id: string | null; | |
| } | |
| interface QuestionPreview { | |
| questions: string[]; | |
| domain_id: string; | |
| count: number; | |
| } | |
| interface KnowledgeTile { | |
| tile_id: string; | |
| domain_id: string; | |
| topic: string; | |
| content_preview: string; | |
| created_at: string; | |
| verification_mark: string; | |
| confidence_score: number; | |
| tags: string[]; | |
| coordinates?: number[]; | |
| } | |
| const EnrichmentPanel = () => { | |
| // Form state | |
| const [activeTab, setActiveTab] = useState<'ai' | 'web'>('ai'); | |
| const [domainId, setDomainId] = useState('medical'); | |
| const [numQuestions, setNumQuestions] = useState(10); | |
| const [focusAreas, setFocusAreas] = useState(''); | |
| const [promptModelId, setPromptModelId] = useState<string>(''); | |
| const [answerModelId, setAnswerModelId] = useState<string>('master'); | |
| const [webQuery, setWebQuery] = useState(''); | |
| const [maxResults, setMaxResults] = useState(5); | |
| // Enrichment state | |
| const [enrichmentStatus, setEnrichmentStatus] = useState<EnrichmentStatus | null>(null); | |
| const [config, setConfig] = useState<EnrichmentConfig | null>(null); | |
| const [questionPreview, setQuestionPreview] = useState<QuestionPreview | null>(null); | |
| const [generatedTiles, setGeneratedTiles] = useState<KnowledgeTile[]>([]); | |
| const [showGeneratedTiles, setShowGeneratedTiles] = useState(false); | |
| // UI state | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState(''); | |
| const [successMessage, setSuccessMessage] = useState(''); | |
| const [showPreview, setShowPreview] = useState(false); | |
| // Polling interval for status updates | |
| const [statusPollingInterval, setStatusPollingInterval] = useState<NodeJS.Timeout | null>(null); | |
| const [previouslyRunning, setPreviouslyRunning] = useState(false); | |
| // Fetch enrichment configuration | |
| const fetchConfig = async (retries = 3, delay = 2000) => { | |
| try { | |
| const response = await apiClient.get('/enrichment/config'); | |
| setConfig(response.data); | |
| } catch (err: any) { | |
| console.error(`Failed to fetch enrichment config (retries left: ${retries}):`, err); | |
| if (retries > 0) { | |
| setTimeout(() => fetchConfig(retries - 1, delay), delay); | |
| } | |
| } | |
| }; | |
| // Fetch generated tiles | |
| const fetchGeneratedTiles = async (domain: string) => { | |
| try { | |
| const response = await apiClient.get(`/knowledge/?domain_id=${domain}&page=1&page_size=50`); | |
| const tiles = response.data.tiles || []; | |
| // Sort by created_at descending (newest first) | |
| const sortedTiles = tiles.sort((a: KnowledgeTile, b: KnowledgeTile) => | |
| new Date(b.created_at).getTime() - new Date(a.created_at).getTime() | |
| ); | |
| setGeneratedTiles(sortedTiles); | |
| setShowGeneratedTiles(true); | |
| } catch (err: any) { | |
| console.error('Failed to fetch generated tiles:', err); | |
| } | |
| }; | |
| // Fetch enrichment status | |
| const fetchStatus = async () => { | |
| try { | |
| const response = await apiClient.get('/enrichment/status'); | |
| const currentStatus = response.data; | |
| setEnrichmentStatus(currentStatus); | |
| // Detect completion: was running, now stopped | |
| if (previouslyRunning && !currentStatus.is_running) { | |
| // Enrichment just completed! | |
| if (currentStatus.generated_tiles > 0 && currentStatus.domain_id) { | |
| setSuccessMessage( | |
| `✅ Enrichment completed! Generated ${currentStatus.generated_tiles} tiles. Loading results...` | |
| ); | |
| // Fetch the generated tiles | |
| setTimeout(() => { | |
| fetchGeneratedTiles(currentStatus.domain_id); | |
| }, 1000); | |
| } | |
| } | |
| // Track previous running state | |
| setPreviouslyRunning(currentStatus.is_running); | |
| // If enrichment is active, start polling | |
| if (currentStatus.is_running && !statusPollingInterval) { | |
| const interval = setInterval(fetchStatus, 2000); // Poll every 2 seconds | |
| setStatusPollingInterval(interval); | |
| } | |
| // If enrichment finished, stop polling | |
| if (!currentStatus.is_running && statusPollingInterval) { | |
| clearInterval(statusPollingInterval); | |
| setStatusPollingInterval(null); | |
| } | |
| } catch (err: any) { | |
| console.error('Failed to fetch enrichment status:', err); | |
| } | |
| }; | |
| // Initial data fetch | |
| useEffect(() => { | |
| fetchConfig(); | |
| fetchStatus(); | |
| // Cleanup polling on unmount | |
| return () => { | |
| if (statusPollingInterval) { | |
| clearInterval(statusPollingInterval); | |
| } | |
| }; | |
| }, []); | |
| // Start AI enrichment | |
| const handleStartAIEnrichment = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| setSuccessMessage(''); | |
| try { | |
| const requestData = { | |
| domain_id: domainId, | |
| num_questions: numQuestions, | |
| focus_areas: focusAreas ? focusAreas.split(',').map(s => s.trim()) : null, | |
| prompt_model_id: promptModelId || null, | |
| answer_model_id: answerModelId || null | |
| }; | |
| await apiClient.post('/enrichment/ai/start', requestData); | |
| setSuccessMessage('AI enrichment started successfully! Check the progress below.'); | |
| setShowPreview(false); | |
| // Start polling for status | |
| fetchStatus(); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`Failed to start AI enrichment: ${errorMessage}`); | |
| console.error(err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Start Web enrichment | |
| const handleStartWebEnrichment = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| setSuccessMessage(''); | |
| try { | |
| const requestData = { | |
| query: webQuery, | |
| domain_id: domainId, | |
| max_results: maxResults | |
| }; | |
| const response = await apiClient.post('/enrichment/web/start', requestData); | |
| setSuccessMessage( | |
| `Web enrichment completed! Created ${response.data.tiles_created} tiles from ${response.data.search_results} search results.` | |
| ); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`Failed to start web enrichment: ${errorMessage}`); | |
| console.error(err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Stop enrichment | |
| const handleStopEnrichment = async () => { | |
| try { | |
| await apiClient.post('/enrichment/stop'); | |
| setSuccessMessage('Enrichment stop requested. This may take a moment...'); | |
| fetchStatus(); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`Failed to stop enrichment: ${errorMessage}`); | |
| console.error(err); | |
| } | |
| }; | |
| // Preview questions | |
| const handlePreviewQuestions = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| setQuestionPreview(null); | |
| try { | |
| const requestData = { | |
| domain_id: domainId, | |
| num_questions: numQuestions, | |
| focus_areas: focusAreas ? focusAreas.split(',').map(s => s.trim()) : null, | |
| prompt_model_id: promptModelId || null, | |
| answer_model_id: answerModelId || null | |
| }; | |
| const response = await apiClient.post('/enrichment/ai/generate-questions', requestData); | |
| setQuestionPreview(response.data); | |
| setShowPreview(true); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`Failed to preview questions: ${errorMessage}`); | |
| console.error(err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="bg-gray-800 text-white p-6 rounded-lg shadow-lg"> | |
| <h2 className="text-2xl font-bold mb-4">🌱 Knowledge Base Enrichment</h2> | |
| {/* Error/Success Messages */} | |
| {error && ( | |
| <div className="bg-red-600 text-white p-3 rounded mb-4"> | |
| {error} | |
| </div> | |
| )} | |
| {successMessage && ( | |
| <div className="bg-green-600 text-white p-3 rounded mb-4"> | |
| {successMessage} | |
| </div> | |
| )} | |
| {/* Configuration Status */} | |
| {config && ( | |
| <div className="mb-6 p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-2">⚙️ Configuration Status</h3> | |
| <div className="grid grid-cols-2 gap-4 text-sm"> | |
| <div> | |
| <p className="text-gray-400">AI Enrichment</p> | |
| <p className={config.ai_enrichment.available ? 'text-green-400' : 'text-red-400'}> | |
| {config.ai_enrichment.available ? '✓ Available' : '✗ Unavailable'} | |
| </p> | |
| {config.ai_enrichment.available && ( | |
| <p className="text-xs text-gray-500"> | |
| {config.ai_enrichment.available_models.length} models available | |
| {config.ai_enrichment.master_model_id && ` (Master: ${config.ai_enrichment.master_model_id})`} | |
| </p> | |
| )} | |
| </div> | |
| <div> | |
| <p className="text-gray-400">Web Enrichment</p> | |
| <p className={config.web_enrichment.available ? 'text-green-400' : 'text-red-400'}> | |
| {config.web_enrichment.available ? '✓ Available' : '✗ Unavailable'} | |
| </p> | |
| {!config.web_enrichment.api_key_configured && ( | |
| <p className="text-xs text-yellow-400">⚠️ BRAVE_SEARCH_API_KEY not configured</p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Enrichment Status */} | |
| {enrichmentStatus?.is_running && ( | |
| <div className="mb-6 p-4 bg-blue-900 rounded border border-blue-500"> | |
| <h3 className="text-lg font-semibold mb-3">🔄 Enrichment in Progress</h3> | |
| <div className="space-y-2"> | |
| <div> | |
| <p className="text-sm text-gray-300">Domain: {enrichmentStatus.domain_id}</p> | |
| <p className="text-sm text-gray-300"> | |
| Question: {enrichmentStatus.current_question} / {enrichmentStatus.total_questions} | |
| </p> | |
| <p className="text-sm text-gray-300"> | |
| Tiles Generated: {enrichmentStatus.generated_tiles} | |
| </p> | |
| </div> | |
| <div> | |
| <div className="w-full bg-gray-600 rounded-full h-4"> | |
| <div | |
| className="bg-blue-500 h-4 rounded-full transition-all duration-500" | |
| style={{ width: `${enrichmentStatus.progress}%` }} | |
| ></div> | |
| </div> | |
| <p className="text-sm text-gray-300 mt-1"> | |
| Progress: {enrichmentStatus.progress.toFixed(1)}% | |
| </p> | |
| </div> | |
| <button | |
| onClick={handleStopEnrichment} | |
| className="mt-3 bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded" | |
| > | |
| ⛔ Stop Enrichment | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Tab Selection */} | |
| <div className="mb-4 flex space-x-2"> | |
| <button | |
| onClick={() => setActiveTab('ai')} | |
| className={`px-4 py-2 rounded ${ | |
| activeTab === 'ai' ? 'bg-blue-600' : 'bg-gray-700 hover:bg-gray-600' | |
| }`} | |
| > | |
| 🤖 AI Enrichment | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('web')} | |
| className={`px-4 py-2 rounded ${ | |
| activeTab === 'web' ? 'bg-blue-600' : 'bg-gray-700 hover:bg-gray-600' | |
| }`} | |
| > | |
| 🌐 Web Enrichment | |
| </button> | |
| </div> | |
| {/* AI Enrichment Form */} | |
| {activeTab === 'ai' && !enrichmentStatus?.is_running && ( | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">🤖 AI-Driven Enrichment</h3> | |
| <p className="text-sm text-gray-400 mb-4"> | |
| 指定したモデルで質問生成 → 回答生成 → 座標推定 → .iath保存 | |
| </p> | |
| <div className="space-y-4"> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-sm mb-1">🧠 Prompt Generation Model</label> | |
| <select | |
| value={promptModelId} | |
| onChange={(e) => setPromptModelId(e.target.value)} | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| > | |
| <option value="">Auto (Any available)</option> | |
| {config?.ai_enrichment.available_models.map((model) => ( | |
| <option key={model.id} value={model.id}> | |
| {model.id} ({model.provider}) | |
| </option> | |
| ))} | |
| </select> | |
| <p className="text-xs text-gray-400 mt-1">質問を生成するモデル</p> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">💬 Answer Generation Model</label> | |
| <select | |
| value={answerModelId} | |
| onChange={(e) => setAnswerModelId(e.target.value)} | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| > | |
| <option value="master">Master Model</option> | |
| {config?.ai_enrichment.available_models.map((model) => ( | |
| <option key={model.id} value={model.id}> | |
| {model.id} ({model.provider}) | |
| </option> | |
| ))} | |
| </select> | |
| <p className="text-xs text-gray-400 mt-1">回答を生成するモデル</p> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">Domain ID</label> | |
| <select | |
| value={domainId} | |
| onChange={(e) => setDomainId(e.target.value)} | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| > | |
| <option value="medical">Medical</option> | |
| <option value="general">General</option> | |
| <option value="legal">Legal</option> | |
| <option value="technology">Technology</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">Number of Questions</label> | |
| <input | |
| type="number" | |
| value={numQuestions} | |
| onChange={(e) => setNumQuestions(parseInt(e.target.value))} | |
| min="1" | |
| max="100" | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">Focus Areas (optional, comma-separated)</label> | |
| <input | |
| type="text" | |
| value={focusAreas} | |
| onChange={(e) => setFocusAreas(e.target.value)} | |
| placeholder="e.g., 基礎理論, 臨床応用, 最新研究" | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| /> | |
| <p className="text-xs text-gray-400 mt-1">Leave empty for broad coverage</p> | |
| </div> | |
| <div className="flex space-x-2"> | |
| <button | |
| onClick={handlePreviewQuestions} | |
| disabled={isLoading} | |
| className="bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded disabled:opacity-50" | |
| > | |
| 👁️ Preview Questions | |
| </button> | |
| <button | |
| onClick={handleStartAIEnrichment} | |
| disabled={isLoading || !config?.ai_enrichment.available} | |
| className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded font-semibold disabled:opacity-50" | |
| > | |
| {isLoading ? 'Starting...' : '▶️ Start AI Enrichment'} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Question Preview */} | |
| {showPreview && questionPreview && ( | |
| <div className="mt-4 p-4 bg-gray-600 rounded"> | |
| <h4 className="font-semibold mb-2">📝 Question Preview ({questionPreview.count} questions)</h4> | |
| <div className="max-h-64 overflow-y-auto space-y-2"> | |
| {questionPreview.questions.map((question, idx) => ( | |
| <div key={idx} className="text-sm bg-gray-700 p-2 rounded"> | |
| {idx + 1}. {question} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Web Enrichment Form */} | |
| {activeTab === 'web' && !enrichmentStatus?.is_running && ( | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">🌐 Web Search Enrichment</h3> | |
| <p className="text-sm text-gray-400 mb-4"> | |
| Brave Search API → Extract snippets → Convert to tiles → Save to .iath | |
| </p> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="block text-sm mb-1">Search Query</label> | |
| <input | |
| type="text" | |
| value={webQuery} | |
| onChange={(e) => setWebQuery(e.target.value)} | |
| placeholder="e.g., 最新の量子コンピュータ技術" | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">Domain ID</label> | |
| <select | |
| value={domainId} | |
| onChange={(e) => setDomainId(e.target.value)} | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| > | |
| <option value="medical">Medical</option> | |
| <option value="general">General</option> | |
| <option value="legal">Legal</option> | |
| <option value="technology">Technology</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">Max Results</label> | |
| <input | |
| type="number" | |
| value={maxResults} | |
| onChange={(e) => setMaxResults(parseInt(e.target.value))} | |
| min="1" | |
| max="20" | |
| className="w-full p-2 bg-gray-600 rounded text-white" | |
| /> | |
| </div> | |
| <button | |
| onClick={handleStartWebEnrichment} | |
| disabled={isLoading || !webQuery || !config?.web_enrichment.available} | |
| className="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded font-semibold disabled:opacity-50" | |
| > | |
| {isLoading ? 'Searching...' : '🔍 Start Web Enrichment'} | |
| </button> | |
| {!config?.web_enrichment.api_key_configured && ( | |
| <p className="text-yellow-400 text-sm"> | |
| ⚠️ Set BRAVE_SEARCH_API_KEY environment variable to enable web enrichment | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Generated Tiles Display */} | |
| {showGeneratedTiles && generatedTiles.length > 0 && ( | |
| <div className="mt-6 p-4 bg-green-900 bg-opacity-30 rounded border border-green-500"> | |
| <div className="flex justify-between items-center mb-3"> | |
| <h3 className="text-lg font-semibold">✨ Generated Knowledge Tiles ({generatedTiles.length})</h3> | |
| <button | |
| onClick={() => setShowGeneratedTiles(false)} | |
| className="text-sm text-gray-400 hover:text-white" | |
| > | |
| ✕ Close | |
| </button> | |
| </div> | |
| <div className="max-h-[600px] overflow-y-auto space-y-3"> | |
| {generatedTiles.slice(0, 20).map((tile) => ( | |
| <div | |
| key={tile.tile_id} | |
| className="bg-gray-800 p-4 rounded-lg border border-gray-600 hover:border-green-500 transition-colors" | |
| > | |
| <div className="flex justify-between items-start mb-2"> | |
| <h4 className="font-semibold text-green-400 flex-1">{tile.topic}</h4> | |
| <span className="text-xs bg-gray-700 px-2 py-1 rounded ml-2"> | |
| {tile.domain_id} | |
| </span> | |
| </div> | |
| <p className="text-sm text-gray-300 mb-2 line-clamp-3"> | |
| {tile.content_preview} | |
| </p> | |
| <div className="flex flex-wrap gap-2 text-xs text-gray-400"> | |
| <span>🎯 Confidence: {(tile.confidence_score * 100).toFixed(1)}%</span> | |
| <span>✓ {tile.verification_mark}</span> | |
| {tile.coordinates && ( | |
| <span title="6D Coordinates"> | |
| 📍 [{tile.coordinates.map(c => c.toFixed(2)).join(', ')}] | |
| </span> | |
| )} | |
| <span>🕐 {new Date(tile.created_at).toLocaleString()}</span> | |
| </div> | |
| {tile.tags && tile.tags.length > 0 && ( | |
| <div className="mt-2 flex flex-wrap gap-1"> | |
| {tile.tags.map((tag, idx) => ( | |
| <span key={idx} className="text-xs bg-blue-900 text-blue-300 px-2 py-0.5 rounded"> | |
| {tag} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| {generatedTiles.length > 20 && ( | |
| <p className="text-sm text-gray-400 mt-3 text-center"> | |
| Showing first 20 of {generatedTiles.length} tiles | |
| </p> | |
| )} | |
| <div className="mt-4 text-sm text-gray-400"> | |
| 💡 Tip: Navigate to <strong>Knowledge Panel</strong> to view all tiles with full details | |
| </div> | |
| </div> | |
| )} | |
| {/* Info Box */} | |
| <div className="mt-6 p-4 bg-blue-900 bg-opacity-30 rounded border border-blue-500"> | |
| <h4 className="font-semibold mb-2">ℹ️ How It Works</h4> | |
| <ul className="text-sm text-gray-300 space-y-1"> | |
| <li>• <strong>AI Enrichment:</strong> 任意のモデルで質問生成 → 任意のモデルで回答 → 自動保存</li> | |
| <li>• <strong>Model Selection:</strong> プロンプト生成と回答生成で異なるモデルを使い分け可能</li> | |
| <li>• <strong>Web Enrichment:</strong> Brave APIで検索 → Knowledge Tile形式に変換</li> | |
| <li>• すべてのタイルに6次元座標が自動割り当てされ.iathに保存されます</li> | |
| <li>• バックグラウンド実行で、いつでも停止可能</li> | |
| <li>• <strong className="text-green-400">完了時に自動的にタイルが表示されます</strong></li> | |
| </ul> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default EnrichmentPanel; | |