Spaces:
Sleeping
Sleeping
| // src/components/NurseLogPanel.tsx | |
| import { useState, useEffect } from 'react'; | |
| import apiClient from '../services/api'; | |
| interface SuccessionStatus { | |
| status: string; | |
| should_trigger: boolean; | |
| high_quality_count: number; | |
| threshold: number; | |
| message: string; | |
| } | |
| interface SuccessionStats { | |
| total_inferences: number; | |
| high_quality_inferences: number; | |
| succession_count: number; | |
| domain_stats: Record<string, { | |
| total: number; | |
| high_quality: number; | |
| avg_confidence: number; | |
| }>; | |
| next_succession_at: number | null; | |
| } | |
| interface InferenceRecord { | |
| timestamp: string; | |
| domain_id: string; | |
| prompt: string; | |
| response: string; | |
| confidence: number; | |
| source_type: string; | |
| } | |
| interface SuccessionHistoryRecord { | |
| timestamp: string; | |
| generation: number; | |
| training_data_count: number; | |
| domain_id: string | null; | |
| exports: Record<string, string>; | |
| } | |
| interface ExportedFile { | |
| filename: string; | |
| size_mb: number; | |
| created_at: string; | |
| modified_at: string; | |
| } | |
| const NurseLogPanel = () => { | |
| // State | |
| const [activeTab, setActiveTab] = useState<'status' | 'history' | 'exports'>('status'); | |
| const [successionStatus, setSuccessionStatus] = useState<SuccessionStatus | null>(null); | |
| const [stats, setStats] = useState<SuccessionStats | null>(null); | |
| const [inferenceHistory, setInferenceHistory] = useState<InferenceRecord[]>([]); | |
| const [successionHistory, setSuccessionHistory] = useState<SuccessionHistoryRecord[]>([]); | |
| const [exportedFiles, setExportedFiles] = useState<ExportedFile[]>([]); | |
| // Filters | |
| const [domainFilter, setDomainFilter] = useState<string>(''); | |
| const [minConfidence, setMinConfidence] = useState<number>(0.8); | |
| const [historyLimit, setHistoryLimit] = useState<number>(50); | |
| // UI State | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState(''); | |
| const [successMessage, setSuccessMessage] = useState(''); | |
| // Fetch succession status | |
| const fetchStatus = async () => { | |
| try { | |
| const response = await apiClient.get('/succession/status'); | |
| setSuccessionStatus(response.data); | |
| } catch (err: any) { | |
| console.error('Failed to fetch succession status:', err); | |
| } | |
| }; | |
| // Fetch stats | |
| const fetchStats = async () => { | |
| try { | |
| const response = await apiClient.get('/succession/stats'); | |
| setStats(response.data); | |
| } catch (err: any) { | |
| console.error('Failed to fetch stats:', err); | |
| } | |
| }; | |
| // Fetch inference history | |
| const fetchInferenceHistory = async () => { | |
| try { | |
| const params: any = { | |
| limit: historyLimit, | |
| min_confidence: minConfidence | |
| }; | |
| if (domainFilter) { | |
| params.domain_id = domainFilter; | |
| } | |
| const response = await apiClient.get('/succession/history/inference', { params }); | |
| setInferenceHistory(response.data.records); | |
| } catch (err: any) { | |
| console.error('Failed to fetch inference history:', err); | |
| } | |
| }; | |
| // Fetch succession history | |
| const fetchSuccessionHistory = async () => { | |
| try { | |
| const response = await apiClient.get('/succession/history/succession'); | |
| setSuccessionHistory(response.data.history); | |
| } catch (err: any) { | |
| console.error('Failed to fetch succession history:', err); | |
| } | |
| }; | |
| // Fetch exported files | |
| const fetchExportedFiles = async () => { | |
| try { | |
| const response = await apiClient.get('/succession/exports'); | |
| setExportedFiles(response.data.files); | |
| } catch (err: any) { | |
| console.error('Failed to fetch exported files:', err); | |
| } | |
| }; | |
| // Initial data fetch | |
| useEffect(() => { | |
| fetchStatus(); | |
| fetchStats(); | |
| fetchInferenceHistory(); | |
| fetchSuccessionHistory(); | |
| fetchExportedFiles(); | |
| }, []); | |
| // Trigger succession | |
| const handleTriggerSuccession = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| setSuccessMessage(''); | |
| try { | |
| const requestData = { | |
| domain_id: domainFilter || null, | |
| min_confidence: minConfidence | |
| }; | |
| const response = await apiClient.post('/succession/trigger', requestData); | |
| setSuccessMessage( | |
| `世代交代成功!${response.data.count}件のトレーニングデータをエクスポートしました。` | |
| ); | |
| // Refresh data | |
| fetchStatus(); | |
| fetchStats(); | |
| fetchSuccessionHistory(); | |
| fetchExportedFiles(); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`世代交代失敗: ${errorMessage}`); | |
| console.error(err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Create standalone package | |
| const handleCreateStandalonePackage = async () => { | |
| setIsLoading(true); | |
| setError(''); | |
| setSuccessMessage(''); | |
| try { | |
| const requestData = { | |
| domain_id: domainFilter || null, | |
| min_confidence: minConfidence | |
| }; | |
| const response = await apiClient.post('/succession/create-standalone-package', requestData); | |
| setSuccessMessage( | |
| `スタンドアロンパッケージ生成成功!\nパス: ${response.data.package_path}\nサイズ: ${response.data.size_mb} MB` | |
| ); | |
| fetchExportedFiles(); | |
| } catch (err: any) { | |
| const errorMessage = err.response?.data?.detail || err.message; | |
| setError(`パッケージ生成失敗: ${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">🏥 NurseLog System (世代交代管理)</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 whitespace-pre-line"> | |
| {successMessage} | |
| </div> | |
| )} | |
| {/* Succession Status Card */} | |
| {successionStatus && ( | |
| <div className={`mb-6 p-4 rounded border ${ | |
| successionStatus.should_trigger | |
| ? 'bg-green-900 border-green-500' | |
| : 'bg-gray-700 border-gray-600' | |
| }`}> | |
| <h3 className="text-lg font-semibold mb-2">📊 世代交代ステータス</h3> | |
| <div className="grid grid-cols-3 gap-4 text-sm"> | |
| <div> | |
| <p className="text-gray-400">高品質推論数</p> | |
| <p className="text-2xl font-bold">{successionStatus.high_quality_count}</p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400">閾値</p> | |
| <p className="text-2xl font-bold">{successionStatus.threshold}</p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400">状態</p> | |
| <p className={`text-lg font-semibold ${ | |
| successionStatus.should_trigger ? 'text-green-400' : 'text-yellow-400' | |
| }`}> | |
| {successionStatus.should_trigger ? '✓ 準備完了' : '⏳ データ蓄積中'} | |
| </p> | |
| </div> | |
| </div> | |
| <p className="text-sm text-gray-300 mt-3">{successionStatus.message}</p> | |
| </div> | |
| )} | |
| {/* Stats Card */} | |
| {stats && ( | |
| <div className="mb-6 p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">📈 統計情報</h3> | |
| <div className="grid grid-cols-4 gap-4 text-sm mb-4"> | |
| <div> | |
| <p className="text-gray-400">総推論数</p> | |
| <p className="text-xl font-bold">{stats.total_inferences}</p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400">高品質推論</p> | |
| <p className="text-xl font-bold text-green-400">{stats.high_quality_inferences}</p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400">世代交代回数</p> | |
| <p className="text-xl font-bold text-blue-400">{stats.succession_count}</p> | |
| </div> | |
| <div> | |
| <p className="text-gray-400">次回閾値</p> | |
| <p className="text-xl font-bold">{stats.next_succession_at || 'N/A'}</p> | |
| </div> | |
| </div> | |
| {/* Domain Stats */} | |
| <div className="mt-4"> | |
| <p className="text-sm text-gray-400 mb-2">ドメイン別統計:</p> | |
| <div className="space-y-2"> | |
| {Object.entries(stats.domain_stats).map(([domain, domainStats]) => ( | |
| <div key={domain} className="bg-gray-600 p-2 rounded text-xs"> | |
| <span className="font-semibold">{domain}</span>: | |
| <span className="ml-2">総数 {domainStats.total}</span> | |
| <span className="ml-2 text-green-400">高品質 {domainStats.high_quality}</span> | |
| <span className="ml-2 text-blue-400">平均信頼度 {domainStats.avg_confidence.toFixed(2)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Action Buttons */} | |
| <div className="mb-6 flex space-x-2"> | |
| <button | |
| onClick={handleTriggerSuccession} | |
| disabled={isLoading} | |
| className="bg-green-600 hover:bg-green-700 text-white px-6 py-3 rounded font-semibold disabled:opacity-50" | |
| > | |
| {isLoading ? '処理中...' : '🔄 世代交代トリガー'} | |
| </button> | |
| <button | |
| onClick={handleCreateStandalonePackage} | |
| disabled={isLoading} | |
| className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded font-semibold disabled:opacity-50" | |
| > | |
| 📦 スタンドアロンパッケージ生成 | |
| </button> | |
| </div> | |
| {/* Filters */} | |
| <div className="mb-4 p-4 bg-gray-700 rounded"> | |
| <h4 className="font-semibold mb-2">🔍 フィルター</h4> | |
| <div className="grid grid-cols-3 gap-4"> | |
| <div> | |
| <label className="block text-sm mb-1">ドメイン</label> | |
| <select | |
| value={domainFilter} | |
| onChange={(e) => setDomainFilter(e.target.value)} | |
| className="w-full p-2 bg-gray-600 rounded text-white text-sm" | |
| > | |
| <option value="">全ドメイン</option> | |
| <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">最小信頼度</label> | |
| <input | |
| type="number" | |
| value={minConfidence} | |
| onChange={(e) => setMinConfidence(parseFloat(e.target.value))} | |
| min="0" | |
| max="1" | |
| step="0.1" | |
| className="w-full p-2 bg-gray-600 rounded text-white text-sm" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm mb-1">表示件数</label> | |
| <input | |
| type="number" | |
| value={historyLimit} | |
| onChange={(e) => setHistoryLimit(parseInt(e.target.value))} | |
| min="10" | |
| max="1000" | |
| className="w-full p-2 bg-gray-600 rounded text-white text-sm" | |
| /> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => { | |
| fetchInferenceHistory(); | |
| fetchSuccessionHistory(); | |
| }} | |
| className="mt-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm" | |
| > | |
| 🔄 フィルター適用 | |
| </button> | |
| </div> | |
| {/* Tab Selection */} | |
| <div className="mb-4 flex space-x-2"> | |
| <button | |
| onClick={() => setActiveTab('status')} | |
| className={`px-4 py-2 rounded ${ | |
| activeTab === 'status' ? 'bg-blue-600' : 'bg-gray-700 hover:bg-gray-600' | |
| }`} | |
| > | |
| 📊 ステータス | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('history')} | |
| className={`px-4 py-2 rounded ${ | |
| activeTab === 'history' ? 'bg-blue-600' : 'bg-gray-700 hover:bg-gray-600' | |
| }`} | |
| > | |
| 📜 履歴 | |
| </button> | |
| <button | |
| onClick={() => setActiveTab('exports')} | |
| className={`px-4 py-2 rounded ${ | |
| activeTab === 'exports' ? 'bg-blue-600' : 'bg-gray-700 hover:bg-gray-600' | |
| }`} | |
| > | |
| 📁 エクスポート | |
| </button> | |
| </div> | |
| {/* Status Tab */} | |
| {activeTab === 'status' && ( | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">📊 現在のステータス</h3> | |
| <p className="text-sm text-gray-300"> | |
| 上記の統計情報とステータスカードを参照してください。 | |
| </p> | |
| </div> | |
| )} | |
| {/* History Tab */} | |
| {activeTab === 'history' && ( | |
| <div className="space-y-4"> | |
| {/* Inference History */} | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">🧠 推論履歴 ({inferenceHistory.length}件)</h3> | |
| <div className="max-h-96 overflow-y-auto space-y-2"> | |
| {inferenceHistory.length === 0 ? ( | |
| <p className="text-gray-400 text-sm">履歴がありません</p> | |
| ) : ( | |
| inferenceHistory.map((record, idx) => ( | |
| <div key={idx} className="bg-gray-600 p-3 rounded text-sm"> | |
| <div className="flex justify-between mb-1"> | |
| <span className="font-semibold">{record.domain_id}</span> | |
| <span className="text-xs text-gray-400"> | |
| {new Date(record.timestamp).toLocaleString('ja-JP')} | |
| </span> | |
| </div> | |
| <p className="text-xs text-gray-300 mb-1"> | |
| <strong>Q:</strong> {record.prompt.substring(0, 100)}... | |
| </p> | |
| <div className="flex justify-between text-xs"> | |
| <span className={`font-semibold ${ | |
| record.confidence >= 0.8 ? 'text-green-400' : 'text-yellow-400' | |
| }`}> | |
| 信頼度: {record.confidence.toFixed(2)} | |
| </span> | |
| <span className="text-gray-400">{record.source_type}</span> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Succession History */} | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">🔄 世代交代履歴 ({successionHistory.length}回)</h3> | |
| <div className="max-h-64 overflow-y-auto space-y-2"> | |
| {successionHistory.length === 0 ? ( | |
| <p className="text-gray-400 text-sm">世代交代履歴がありません</p> | |
| ) : ( | |
| successionHistory.map((record, idx) => ( | |
| <div key={idx} className="bg-gray-600 p-3 rounded text-sm"> | |
| <div className="flex justify-between mb-1"> | |
| <span className="font-semibold">第{record.generation}世代</span> | |
| <span className="text-xs text-gray-400"> | |
| {new Date(record.timestamp).toLocaleString('ja-JP')} | |
| </span> | |
| </div> | |
| <p className="text-xs text-gray-300"> | |
| 訓練データ: {record.training_data_count}件 | |
| {record.domain_id && ` | ドメイン: ${record.domain_id}`} | |
| </p> | |
| <p className="text-xs text-gray-400 mt-1"> | |
| エクスポート: {Object.keys(record.exports).join(', ')} | |
| </p> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Exports Tab */} | |
| {activeTab === 'exports' && ( | |
| <div className="p-4 bg-gray-700 rounded"> | |
| <h3 className="text-lg font-semibold mb-3">📁 エクスポートファイル ({exportedFiles.length}件)</h3> | |
| <div className="max-h-96 overflow-y-auto space-y-2"> | |
| {exportedFiles.length === 0 ? ( | |
| <p className="text-gray-400 text-sm">エクスポートファイルがありません</p> | |
| ) : ( | |
| exportedFiles.map((file, idx) => ( | |
| <div key={idx} className="bg-gray-600 p-3 rounded text-sm flex justify-between items-center"> | |
| <div> | |
| <p className="font-semibold">{file.filename}</p> | |
| <p className="text-xs text-gray-400"> | |
| {file.size_mb} MB | 作成: {new Date(file.created_at).toLocaleString('ja-JP')} | |
| </p> | |
| </div> | |
| <a | |
| href={`/api/succession/exports/${file.filename}`} | |
| download | |
| className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs" | |
| > | |
| ⬇️ ダウンロード | |
| </a> | |
| </div> | |
| )) | |
| )} | |
| </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">ℹ️ NurseLog Systemとは</h4> | |
| <ul className="text-sm text-gray-300 space-y-1"> | |
| <li>• 師匠モデルの高品質な推論を自動記録</li> | |
| <li>• 閾値到達で世代交代トリガー(訓練データ自動エクスポート)</li> | |
| <li>• JSONL, CSV, Parquet, HuggingFace Datasets形式に対応</li> | |
| <li>• スタンドアロンパッケージで完全な推論エンジンを配布可能</li> | |
| <li>• 推論履歴と世代交代履歴を完全に追跡</li> | |
| </ul> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default NurseLogPanel; | |