Spaces:
Sleeping
Sleeping
| """ | |
| Knowledge Base API | |
| 知識タイル一覧表示と検証マーク機構 | |
| """ | |
| from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File | |
| from fastapi.responses import FileResponse, StreamingResponse | |
| from typing import List, Optional | |
| from datetime import datetime | |
| import os | |
| import json | |
| import io | |
| from sqlalchemy.orm import Session | |
| import logging | |
| from uuid import uuid4 | |
| from backend.app.middleware.auth import get_current_user_optional, get_current_user, User | |
| from backend.app.config import settings | |
| from backend.app.database.session import get_db | |
| from backend.app.services.knowledge_service import KnowledgeService, get_knowledge_service | |
| from backend.app.schemas.knowledge import KnowledgeTile, KnowledgeListResponse, KnowledgeDetailResponse, EditRequest, VerificationMark | |
| from backend.app.utils.iath_encoder import IathEncoder | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| async def list_knowledge_tiles( | |
| domain_id: Optional[str] = Query(None, description="ドメインでフィルタ"), | |
| verification_type: Optional[str] = Query(None, description="検証タイプでフィルタ"), | |
| search: Optional[str] = Query(None, description="検索クエリ"), | |
| page: int = Query(1, ge=1, description="ページ番号"), | |
| page_size: int = Query(20, ge=1, le=100, description="ページサイズ"), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| tiles_orm, total_count = service.list_tiles( | |
| db=db, page=page, page_size=page_size, | |
| domain_id=domain_id, verification_type=verification_type, search=search | |
| ) | |
| tiles_pydantic = [KnowledgeTile.from_orm(t) for t in tiles_orm] | |
| return KnowledgeListResponse( | |
| tiles=tiles_pydantic, | |
| total_count=total_count, | |
| page=page, | |
| page_size=page_size, | |
| has_more=(page * page_size) < total_count | |
| ) | |
| async def get_knowledge_tile( | |
| tile_id: str, | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| tile_orm = service.get_tile(db, tile_id=tile_id) | |
| if not tile_orm: | |
| raise HTTPException(status_code=404, detail="Knowledge tile not found") | |
| tile_pydantic = KnowledgeTile.from_orm(tile_orm) | |
| return KnowledgeDetailResponse( | |
| tile=tile_pydantic, | |
| full_content=tile_orm.content, | |
| # TODO: Implement sources, related_tiles, and edit_history from DB | |
| sources=[], | |
| related_tiles=[], | |
| edit_history=[] | |
| ) | |
| async def update_knowledge_tile( | |
| tile_id: str, | |
| request: EditRequest, | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| updated_tile_orm = service.update_tile( | |
| db=db, tile_id=tile_id, content=request.content, user=current_user | |
| ) | |
| if not updated_tile_orm: | |
| raise HTTPException(status_code=404, detail="Knowledge tile not found") | |
| return KnowledgeTile.from_orm(updated_tile_orm) | |
| async def get_coordinates_for_3d_visualization( | |
| domain_id: Optional[str] = Query(None, description="特定ドメインのみ取得"), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| """ | |
| 3D可視化用の座標データを取得 | |
| 座標を持つタイルのみを返し、必要最小限の情報のみを含める | |
| """ | |
| # Fetch all tiles (large page size to get all) | |
| tiles_orm, _ = service.list_tiles(db=db, page_size=10000, domain_id=domain_id) | |
| # Filter tiles that have coordinates and extract minimal data | |
| coordinates_data = [] | |
| for tile in tiles_orm: | |
| if tile.coordinates: # Only include tiles with coordinates | |
| coordinates_data.append({ | |
| "tile_id": tile.id, | |
| "topic": tile.topic, | |
| "domain_id": tile.domain_id, | |
| "coordinates": tile.coordinates, # [x, y, z, c, g, v] | |
| "confidence_score": tile.confidence_score, | |
| "verification_type": tile.verification_type | |
| }) | |
| return { | |
| "tiles": coordinates_data, | |
| "count": len(coordinates_data), | |
| "domain_id": domain_id or "all" | |
| } | |
| async def export_db_json( | |
| domain_id: Optional[str] = Query(None, description="特定ドメインのみエクスポート"), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| # Fetch all tiles for export | |
| tiles_orm, _ = service.list_tiles(db=db, page_size=10000, domain_id=domain_id) # A large page size to get all | |
| tiles_pydantic = [KnowledgeTile.from_orm(t).dict() for t in tiles_orm] | |
| export_data = { | |
| "metadata": { | |
| "export_date": datetime.now().isoformat(), | |
| "source": "NullAI Knowledge Base", | |
| "domain_filter": domain_id or "all", | |
| "tile_count": len(tiles_pydantic) | |
| }, | |
| "tiles": tiles_pydantic | |
| } | |
| json_str = json.dumps(export_data, indent=2, ensure_ascii=False, default=str) | |
| return StreamingResponse( | |
| io.BytesIO(json_str.encode('utf-8')), | |
| media_type="application/json", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename=null_ai_knowledge_{datetime.now().strftime('%Y%m%d')}.json" | |
| } | |
| ) | |
| async def export_db_iath( | |
| domain_id: Optional[str] = Query(None, description="特定ドメインのみエクスポート"), | |
| precision: str = Query("standard", description="精度レベル: standard または high_precision"), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| """ | |
| .iath形式でエクスポート (dendritic-memory-editor互換) | |
| JSON version 2形式で出力します | |
| precision: | |
| - standard: List Viewからの標準エクスポート | |
| - high_precision: 3D Space Viewからの高精度エクスポート | |
| """ | |
| logger.info(f"Exporting to .iath format (domain={domain_id}, precision={precision})") | |
| # Fetch all tiles for export | |
| tiles_orm, _ = service.list_tiles(db=db, page_size=10000, domain_id=domain_id) | |
| # Map domain_id to domain name | |
| domain_name_map = { | |
| "medical": "Medical", | |
| "ai_fundamentals": "AI Fundamentals", | |
| "logic_reasoning": "Logic & Reasoning", | |
| "computer_science_theory": "Computer Science", | |
| "engineering": "Engineering", | |
| "philosophy": "Philosophy", | |
| "law": "Law" | |
| } | |
| domain_name = domain_name_map.get(domain_id, "General") if domain_id else "General" | |
| # Map domain_id to domain code | |
| domain_code_map = { | |
| "medical": 1, | |
| "ai_fundamentals": 2, | |
| "logic_reasoning": 3, | |
| "computer_science_theory": 4, | |
| "engineering": 5, | |
| "philosophy": 6, | |
| "law": 7 | |
| } | |
| domain_code = domain_code_map.get(domain_id, 1) if domain_id else 1 | |
| # Convert tiles to .iath JSON format (version 2) | |
| iath_tiles = [] | |
| for tile in tiles_orm: | |
| # Parse coordinates | |
| coordinates = tile.coordinates | |
| if not coordinates or len(coordinates) < 3: | |
| # Generate basic coordinates if missing | |
| confidence = tile.confidence_score if tile.confidence_score else 0.5 | |
| coordinates = [ | |
| 50.0 + (hash(tile.id) % 100) / 2, # x: pseudo-random positioning | |
| 50.0 + (hash(tile.id[::-1]) % 100) / 2, # y: pseudo-random positioning | |
| 10.0 + confidence * 20 # z: based on confidence | |
| ] | |
| logger.warning(f"Tile {tile.id} missing coordinates, generated: {coordinates}") | |
| # Extract x, y, z from coordinates | |
| x = round(coordinates[0], 2) if len(coordinates) > 0 else 50.0 | |
| y = round(coordinates[1], 2) if len(coordinates) > 1 else 50.0 | |
| z = round(coordinates[2], 2) if len(coordinates) > 2 else 10.0 | |
| # Map verification types to dendritic-memory-editor compatible values | |
| verification_map = { | |
| "ai": "pending_review", | |
| "community": "community", | |
| "expert": "expert_verified", | |
| "multi_expert": "expert_verified", | |
| "none": "pending_review" | |
| } | |
| verification_status = verification_map.get(tile.verification_type, "pending_review") | |
| # Map author marks to dendritic-memory-editor compatible values | |
| author_mark_map = { | |
| "ai": "community", | |
| "community": "community", | |
| "expert": "expert", | |
| "multi_expert": "expert", | |
| "none": "community" | |
| } | |
| author_mark = author_mark_map.get(tile.verification_type, "community") | |
| # Generate author_id if not present (dendritic-memory-editor expects this field) | |
| author_id = tile.author_id if hasattr(tile, 'author_id') and tile.author_id else "system" | |
| # Build .iath compatible tile structure (version 2 format) | |
| iath_tile = { | |
| "id": tile.id, | |
| "title": tile.topic, | |
| "coordinates": { | |
| "x": x, | |
| "y": y, | |
| "z": z | |
| }, | |
| "content": tile.content, | |
| "verification_status": verification_status, | |
| "created_at": tile.created_at.strftime("%Y-%m-%d %H:%M:%S") if tile.created_at else datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| "updated_at": tile.updated_at.strftime("%Y-%m-%d %H:%M:%S") if tile.updated_at else datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| "author_id": author_id, | |
| "author_mark": author_mark | |
| } | |
| iath_tiles.append(iath_tile) | |
| # Create version 2 JSON format | |
| export_data = { | |
| "version": 2, | |
| "header": { | |
| "domain_code": domain_code, | |
| "tile_count": len(iath_tiles), | |
| "created_at": datetime.now().isoformat() + "Z", | |
| "domain": domain_name | |
| }, | |
| "tiles": iath_tiles | |
| } | |
| # Convert to JSON | |
| json_str = json.dumps(export_data, ensure_ascii=False, indent=None) | |
| logger.info(f"Successfully created .iath JSON with {len(iath_tiles)} tiles") | |
| # Generate filename | |
| domain_suffix = f"_{domain_name.replace(' ', '_')}" if domain_id else "" | |
| filename = f"{domain_name.replace(' ', '_')}_tiles_{int(datetime.now().timestamp() * 1000)}.iath" | |
| return StreamingResponse( | |
| io.BytesIO(json_str.encode('utf-8')), | |
| media_type="application/json", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename={filename}" | |
| } | |
| ) | |
| async def update_tile_domain( | |
| tile_id: str, | |
| domain_id: str = Query(..., description="新しいドメインID"), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| """ | |
| 知識タイルのドメインを更新 (List Viewからのドメイン編集機能) | |
| """ | |
| tile_orm = service.get_tile(db, tile_id=tile_id) | |
| if not tile_orm: | |
| raise HTTPException(status_code=404, detail="Knowledge tile not found") | |
| # Update domain | |
| tile_orm.domain_id = domain_id | |
| tile_orm.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(tile_orm) | |
| logger.info(f"Updated tile {tile_id} domain to {domain_id}") | |
| return KnowledgeTile.from_orm(tile_orm) | |
| async def import_iath_file( | |
| file: UploadFile = File(...), | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| service: KnowledgeService = Depends(get_knowledge_service) | |
| ): | |
| """ | |
| .iath ファイルをインポートしてデータベースに保存 | |
| - JSON version 2 形式の .iath ファイルをサポート | |
| - 既存のタイルIDがある場合は更新、ない場合は新規作成 | |
| - エラーが発生したタイルは個別にログに記録 | |
| """ | |
| try: | |
| # ファイル内容を読み込む | |
| content = await file.read() | |
| iath_data = json.loads(content.decode('utf-8')) | |
| # .iath フォーマットを検証 | |
| if not iath_data.get("version") or not iath_data.get("header") or not iath_data.get("tiles"): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Invalid .iath file format. Required fields: version, header, tiles" | |
| ) | |
| if not isinstance(iath_data["tiles"], list): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Invalid .iath file format. 'tiles' must be an array" | |
| ) | |
| # ヘッダー情報を取得 | |
| header = iath_data["header"] | |
| domain_name = header.get("domain", "General") | |
| # ドメイン名からドメインIDにマッピング | |
| domain_name_to_id = { | |
| "Medical": "medical", | |
| "AI Fundamentals": "ai_fundamentals", | |
| "Logic & Reasoning": "logic_reasoning", | |
| "Computer Science": "computer_science_theory", | |
| "Engineering": "engineering", | |
| "Philosophy": "philosophy", | |
| "Law": "law", | |
| "General": "general" | |
| } | |
| domain_id = domain_name_to_id.get(domain_name, "general") | |
| imported_count = 0 | |
| updated_count = 0 | |
| error_count = 0 | |
| errors = [] | |
| logger.info(f"Starting import of {len(iath_data['tiles'])} tiles from domain: {domain_name}") | |
| # 各タイルをインポート | |
| for tile_data in iath_data["tiles"]: | |
| try: | |
| tile_id = tile_data.get("id") | |
| if not tile_id: | |
| tile_id = str(uuid4()) | |
| # コンテンツを取得 | |
| content_text = tile_data.get("content", "") | |
| topic = tile_data.get("title", "") | |
| if not content_text and not topic: | |
| error_msg = f"Tile {tile_id}: No content or title found" | |
| logger.warning(error_msg) | |
| errors.append(error_msg) | |
| error_count += 1 | |
| continue | |
| # 座標を取得 | |
| coords = tile_data.get("coordinates", {}) | |
| coordinates = [ | |
| coords.get("x", 50.0), | |
| coords.get("y", 50.0), | |
| coords.get("z", 50.0) | |
| ] | |
| # verification_status を verification_type にマッピング | |
| verification_map = { | |
| "pending_review": "ai", | |
| "community": "community", | |
| "expert_verified": "expert", | |
| "multi_expert": "multi_expert" | |
| } | |
| verification_status = tile_data.get("verification_status", "pending_review") | |
| verification_type = verification_map.get(verification_status, "ai") | |
| # author_mark を検証 | |
| author_mark = tile_data.get("author_mark", "community") | |
| if author_mark == "expert": | |
| verification_type = "expert" | |
| # 既存のタイルをチェック | |
| existing_tile = service.get_tile(db, tile_id=tile_id) | |
| if existing_tile: | |
| # 既存タイルを更新 | |
| existing_tile.domain_id = domain_id | |
| existing_tile.topic = topic or existing_tile.topic | |
| existing_tile.content = content_text | |
| existing_tile.coordinates = coordinates | |
| existing_tile.verification_type = verification_type | |
| existing_tile.updated_at = datetime.utcnow() | |
| db.commit() | |
| db.refresh(existing_tile) | |
| updated_count += 1 | |
| logger.info(f"Updated tile {tile_id}") | |
| else: | |
| # 新規タイルを作成 | |
| from backend.app.database.models import KnowledgeTileModel | |
| new_tile = KnowledgeTileModel( | |
| id=tile_id, | |
| domain_id=domain_id, | |
| topic=topic or "Imported", | |
| content=content_text, | |
| coordinates=coordinates, | |
| verification_type=verification_type, | |
| confidence_score=0.8, | |
| author_id=current_user.id if current_user else None, | |
| created_at=datetime.utcnow(), | |
| updated_at=datetime.utcnow() | |
| ) | |
| db.add(new_tile) | |
| db.commit() | |
| db.refresh(new_tile) | |
| imported_count += 1 | |
| logger.info(f"Imported new tile {tile_id}") | |
| except Exception as tile_error: | |
| error_msg = f"Tile {tile_data.get('id', 'unknown')}: {str(tile_error)}" | |
| logger.error(error_msg, exc_info=True) | |
| errors.append(error_msg) | |
| error_count += 1 | |
| # 次のタイルに続行 | |
| continue | |
| return { | |
| "success": True, | |
| "domain": domain_name, | |
| "total_tiles": len(iath_data["tiles"]), | |
| "imported": imported_count, | |
| "updated": updated_count, | |
| "errors": error_count, | |
| "error_details": errors if errors else None, | |
| "message": f"Successfully processed {imported_count + updated_count} tiles from {domain_name}" | |
| } | |
| except json.JSONDecodeError as e: | |
| logger.error(f"JSON decode error: {e}", exc_info=True) | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid JSON format: {str(e)}" | |
| ) | |
| except Exception as e: | |
| logger.error(f"Import error: {e}", exc_info=True) | |
| raise HTTPException( | |
| status_code=500, | |
| detail=f"Failed to import .iath file: {str(e)}" | |
| ) | |