Fix datetime deprecation warnings in Phase 8 modules
- Update all datetime.utcnow() to datetime.now(UTC) for Python 3.12+ compatibility - Update all datetime.utcfromtimestamp() to datetime.fromtimestamp(..., UTC) - Fix dataclass default_factory to use lambda: datetime.now(UTC) - Update auth, audit, billing, and realtime modules - Add UTC import from datetime module - Update pytest configuration to include pytest-asyncio - All 28 Phase 8 enterprise tests pass with no warnings Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
773
ontology_platform/ont_platform/api/phase7_app.py
Normal file
773
ontology_platform/ont_platform/api/phase7_app.py
Normal file
@@ -0,0 +1,773 @@
|
||||
"""Phase 7 FastAPI application: LLM End-to-End Integration.
|
||||
|
||||
Features:
|
||||
- Direct LLM integration (OpenAI, Anthropic, Local)
|
||||
- Response streaming (Server-Sent Events)
|
||||
- Redis caching with TTL
|
||||
- RAG + LLM unified pipeline
|
||||
- Multiple LLM provider support
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional, Dict, Any, AsyncGenerator
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, Query, Request, Depends
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
REDIS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REDIS_AVAILABLE = False
|
||||
|
||||
from ont_platform.core.graph.neo4j_adapter import Neo4jAdapter, Neo4jConfig
|
||||
from ont_platform.core.graph.entity_resolver import EntityResolver
|
||||
from ont_platform.core.graph.subgraph_retriever import SubgraphRetriever
|
||||
from ont_platform.core.graph.pattern_matcher import PatternMatcher
|
||||
from ont_platform.core.graph.graph_analytics import GraphAnalytics
|
||||
from ont_platform.llm.llm_integration import LLMManager, LLMConfig, LLMProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class AskRequest(BaseModel):
|
||||
"""LLM query request."""
|
||||
query: str = Field(..., description="사용자 질문")
|
||||
context_hops: int = Field(2, description="그래프 컨텍스트 깊이")
|
||||
use_cache: bool = Field(True, description="캐시 사용 여부")
|
||||
temperature: Optional[float] = Field(None, description="LLM 온도 (0~1)")
|
||||
max_tokens: Optional[int] = Field(None, description="최대 토큰 수")
|
||||
|
||||
|
||||
class AskResponse(BaseModel):
|
||||
"""LLM query response."""
|
||||
query: str
|
||||
answer: str
|
||||
context_size: int
|
||||
relevant_entities: list[str]
|
||||
latency_ms: float
|
||||
cached: bool = False
|
||||
model: str
|
||||
provider: str
|
||||
|
||||
|
||||
class StreamingAskRequest(BaseModel):
|
||||
"""Streaming LLM query request."""
|
||||
query: str
|
||||
context_hops: int = 2
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
|
||||
|
||||
class RAGMetadata(BaseModel):
|
||||
"""RAG metadata."""
|
||||
query: str
|
||||
context_nodes: int
|
||||
relevant_entities: list[str]
|
||||
extraction_time_ms: float
|
||||
llm_provider: str
|
||||
llm_model: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Global Instances
|
||||
# ============================================================================
|
||||
|
||||
_neo4j_adapter: Optional[Neo4jAdapter] = None
|
||||
_entity_resolver: Optional[EntityResolver] = None
|
||||
_subgraph_retriever: Optional[SubgraphRetriever] = None
|
||||
_pattern_matcher: Optional[PatternMatcher] = None
|
||||
_graph_analytics: Optional[GraphAnalytics] = None
|
||||
_llm_manager: Optional[LLMManager] = None
|
||||
_redis_client: Optional[redis.Redis] = None
|
||||
|
||||
# ============================================================================
|
||||
# FastAPI App
|
||||
# ============================================================================
|
||||
|
||||
app = FastAPI(
|
||||
title="Ontology Platform - Phase 7 LLM Integration",
|
||||
description="LLM End-to-End Integration with Streaming & Caching",
|
||||
version="0.7.0",
|
||||
)
|
||||
|
||||
llm_router = APIRouter(prefix="/api/v1/llm", tags=["llm"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Initialization Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def get_neo4j_adapter() -> Neo4jAdapter:
|
||||
"""Get or create Neo4j adapter instance."""
|
||||
global _neo4j_adapter
|
||||
if _neo4j_adapter is None:
|
||||
config = Neo4jConfig(
|
||||
uri="bolt://localhost:7687",
|
||||
username="neo4j",
|
||||
password="ontology123",
|
||||
)
|
||||
_neo4j_adapter = Neo4jAdapter(config)
|
||||
if not await _neo4j_adapter.connect():
|
||||
logger.warning("Neo4j not available")
|
||||
else:
|
||||
try:
|
||||
await _neo4j_adapter.initialize_embedder()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize embedder: {e}")
|
||||
return _neo4j_adapter
|
||||
|
||||
|
||||
async def get_components():
|
||||
"""Initialize all graph components."""
|
||||
global (
|
||||
_entity_resolver,
|
||||
_subgraph_retriever,
|
||||
_pattern_matcher,
|
||||
_graph_analytics,
|
||||
)
|
||||
|
||||
adapter = await get_neo4j_adapter()
|
||||
|
||||
if _entity_resolver is None:
|
||||
_entity_resolver = EntityResolver()
|
||||
await _entity_resolver.initialize_embedder()
|
||||
|
||||
if _subgraph_retriever is None:
|
||||
_subgraph_retriever = SubgraphRetriever(adapter)
|
||||
|
||||
if _pattern_matcher is None:
|
||||
_pattern_matcher = PatternMatcher(adapter)
|
||||
|
||||
if _graph_analytics is None:
|
||||
_graph_analytics = GraphAnalytics(adapter)
|
||||
|
||||
return {
|
||||
"adapter": adapter,
|
||||
"resolver": _entity_resolver,
|
||||
"retriever": _subgraph_retriever,
|
||||
"matcher": _pattern_matcher,
|
||||
"analytics": _graph_analytics,
|
||||
}
|
||||
|
||||
|
||||
async def get_llm_manager() -> LLMManager:
|
||||
"""Get or create LLM manager instance."""
|
||||
global _llm_manager
|
||||
if _llm_manager is None:
|
||||
# Default to OpenAI, but can be overridden via environment
|
||||
config = LLMConfig(
|
||||
provider=LLMProvider.OPENAI,
|
||||
api_key=None, # Will use OPENAI_API_KEY env
|
||||
model="gpt-4",
|
||||
temperature=0.7,
|
||||
max_tokens=500,
|
||||
)
|
||||
_llm_manager = LLMManager(config)
|
||||
return _llm_manager
|
||||
|
||||
|
||||
async def get_redis_client() -> Optional[redis.Redis]:
|
||||
"""Get or create Redis client instance."""
|
||||
global _redis_client
|
||||
if not REDIS_AVAILABLE:
|
||||
return None
|
||||
|
||||
if _redis_client is None:
|
||||
try:
|
||||
_redis_client = await redis.from_url(
|
||||
"redis://localhost:6379",
|
||||
decode_responses=True
|
||||
)
|
||||
await _redis_client.ping()
|
||||
logger.info("Redis connected successfully")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis not available: {e}")
|
||||
_redis_client = None
|
||||
return _redis_client
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cache Utilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _generate_cache_key(query: str, context_hops: int) -> str:
|
||||
"""Generate cache key from query and context."""
|
||||
key_data = f"{query}:{context_hops}"
|
||||
key_hash = hashlib.sha256(key_data.encode()).hexdigest()[:16]
|
||||
return f"phase7:rag:{key_hash}"
|
||||
|
||||
|
||||
async def _get_cached_response(
|
||||
redis_client: Optional[redis.Redis],
|
||||
cache_key: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve cached response from Redis."""
|
||||
if not redis_client:
|
||||
return None
|
||||
|
||||
try:
|
||||
cached = await redis_client.get(cache_key)
|
||||
if cached:
|
||||
logger.info(f"Cache hit: {cache_key}")
|
||||
return json.loads(cached)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache retrieval failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _cache_response(
|
||||
redis_client: Optional[redis.Redis],
|
||||
cache_key: str,
|
||||
response: Dict[str, Any],
|
||||
ttl_hours: int = 1
|
||||
) -> bool:
|
||||
"""Cache response in Redis."""
|
||||
if not redis_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
ttl_seconds = ttl_hours * 3600
|
||||
await redis_client.setex(
|
||||
cache_key,
|
||||
ttl_seconds,
|
||||
json.dumps(response, default=str)
|
||||
)
|
||||
logger.info(f"Cached response: {cache_key} (TTL: {ttl_hours}h)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Cache storage failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RAG Context Extraction
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def extract_rag_context(
|
||||
query: str,
|
||||
context_hops: int = 2,
|
||||
max_entities: int = 100,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract RAG context from knowledge graph."""
|
||||
start_time = time.time()
|
||||
components = await get_components()
|
||||
|
||||
try:
|
||||
# 1. Find relevant entities by semantic similarity
|
||||
# Using entity resolver's embedding capability
|
||||
retriever = components["retriever"]
|
||||
|
||||
# For now, retrieve a default context
|
||||
# In production, would search by query semantic similarity
|
||||
context_data = {
|
||||
"query": query,
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"relevant_entities": [],
|
||||
}
|
||||
|
||||
# Try to get context from first few entities as example
|
||||
try:
|
||||
# Get graph statistics to find some entities
|
||||
analytics = components["analytics"]
|
||||
stats = await analytics.get_graph_statistics()
|
||||
|
||||
if stats.get("total_nodes", 0) > 0:
|
||||
# Get influential entities as relevant context
|
||||
influential = await analytics.find_influential_entities(top_n=5)
|
||||
context_data["relevant_entities"] = [
|
||||
e.get("label", f"Entity_{e.get('entity_id')}")
|
||||
for e in influential
|
||||
]
|
||||
context_data["nodes"] = influential[:max_entities]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract context: {e}")
|
||||
context_data["relevant_entities"] = []
|
||||
|
||||
extraction_time = (time.time() - start_time) * 1000
|
||||
context_data["extraction_time_ms"] = extraction_time
|
||||
|
||||
return context_data
|
||||
except Exception as e:
|
||||
logger.error(f"RAG context extraction failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RAG Prompt Building
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_rag_prompt_for_llm(
|
||||
query: str,
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Build structured prompt with RAG context for LLM."""
|
||||
relevant_entities = context.get("relevant_entities", [])
|
||||
nodes = context.get("nodes", [])
|
||||
|
||||
# Build context section
|
||||
context_str = ""
|
||||
if relevant_entities:
|
||||
context_str += "관련 엔티티:\n"
|
||||
for entity in relevant_entities[:10]:
|
||||
if isinstance(entity, dict):
|
||||
label = entity.get("label", "Unknown")
|
||||
entity_type = entity.get("type", "Unknown")
|
||||
else:
|
||||
label = str(entity)
|
||||
entity_type = "Unknown"
|
||||
context_str += f"- {label} ({entity_type})\n"
|
||||
|
||||
if nodes:
|
||||
context_str += "\n그래프 정보:\n"
|
||||
for node in nodes[:5]:
|
||||
if isinstance(node, dict):
|
||||
label = node.get("label", "Unknown")
|
||||
context_str += f"- {label}\n"
|
||||
|
||||
# Build system prompt with context
|
||||
prompt = f"""당신은 지식 그래프 기반 질문 답변 어시스턴트입니다.
|
||||
다음 지식 그래프 정보를 참고하여 질문에 답변해주세요.
|
||||
|
||||
=== 지식 그래프 컨텍스트 ===
|
||||
{context_str if context_str else "컨텍스트 없음"}
|
||||
|
||||
=== 사용자 질문 ===
|
||||
{query}
|
||||
|
||||
위의 지식 그래프 정보를 바탕으로 명확하고 정확한 답변을 제공해주세요."""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LLM Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@llm_router.post("/ask", response_model=AskResponse)
|
||||
async def ask_llm(request: AskRequest) -> AskResponse:
|
||||
"""
|
||||
LLM에 질문을 하고 캐시된 응답을 반환합니다.
|
||||
|
||||
- RAG 컨텍스트 자동 추출
|
||||
- Redis 캐싱 (기본 1시간 TTL)
|
||||
- 단일 응답 반환
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# 캐시 확인
|
||||
redis_client = await get_redis_client()
|
||||
cache_key = _generate_cache_key(request.query, request.context_hops)
|
||||
|
||||
if request.use_cache:
|
||||
cached = await _get_cached_response(redis_client, cache_key)
|
||||
if cached:
|
||||
cached["cached"] = True
|
||||
cached["latency_ms"] = (time.time() - start_time) * 1000
|
||||
return AskResponse(**cached)
|
||||
|
||||
try:
|
||||
# RAG 컨텍스트 추출
|
||||
context_start = time.time()
|
||||
context = await extract_rag_context(
|
||||
request.query,
|
||||
context_hops=request.context_hops
|
||||
)
|
||||
context_time = (time.time() - context_start) * 1000
|
||||
|
||||
# 프롬프트 생성
|
||||
prompt = _build_rag_prompt_for_llm(request.query, context)
|
||||
|
||||
# LLM 호출
|
||||
llm_manager = await get_llm_manager()
|
||||
llm_start = time.time()
|
||||
|
||||
# LLM 설정 업데이트 (요청으로부터)
|
||||
if request.temperature is not None:
|
||||
llm_manager.config.temperature = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
llm_manager.config.max_tokens = request.max_tokens
|
||||
|
||||
answer = await llm_manager.generate(prompt, stream=False)
|
||||
llm_time = (time.time() - llm_start) * 1000
|
||||
|
||||
# 응답 생성
|
||||
response_data = {
|
||||
"query": request.query,
|
||||
"answer": answer,
|
||||
"context_size": len(context.get("nodes", [])),
|
||||
"relevant_entities": context.get("relevant_entities", []),
|
||||
"latency_ms": (time.time() - start_time) * 1000,
|
||||
"cached": False,
|
||||
"model": llm_manager.config.model,
|
||||
"provider": llm_manager.config.provider.value,
|
||||
}
|
||||
|
||||
# 응답 캐시
|
||||
if request.use_cache:
|
||||
await _cache_response(redis_client, cache_key, response_data)
|
||||
|
||||
logger.info(
|
||||
f"LLM query completed. "
|
||||
f"Context: {context_time:.1f}ms, "
|
||||
f"LLM: {llm_time:.1f}ms, "
|
||||
f"Total: {response_data['latency_ms']:.1f}ms"
|
||||
)
|
||||
|
||||
return AskResponse(**response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM query failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@llm_router.post("/ask/stream")
|
||||
async def ask_llm_stream(request: StreamingAskRequest):
|
||||
"""
|
||||
LLM에 질문을 하고 스트리밍 응답을 반환합니다.
|
||||
|
||||
- Server-Sent Events (SSE) 기반 스트리밍
|
||||
- 실시간 토큰 전달
|
||||
- 메타데이터 포함
|
||||
"""
|
||||
|
||||
async def stream_generator() -> AsyncGenerator[str, None]:
|
||||
"""Stream LLM response tokens."""
|
||||
try:
|
||||
# RAG 컨텍스트 추출
|
||||
context = await extract_rag_context(
|
||||
request.query,
|
||||
context_hops=request.context_hops
|
||||
)
|
||||
|
||||
# 메타데이터 전송
|
||||
metadata = {
|
||||
"type": "metadata",
|
||||
"query": request.query,
|
||||
"context_nodes": len(context.get("nodes", [])),
|
||||
"relevant_entities": context.get("relevant_entities", []),
|
||||
"extraction_time_ms": context.get("extraction_time_ms", 0),
|
||||
}
|
||||
yield f"data: {json.dumps(metadata)}\n\n"
|
||||
|
||||
# 프롬프트 생성
|
||||
prompt = _build_rag_prompt_for_llm(request.query, context)
|
||||
|
||||
# LLM 스트리밍 호출
|
||||
llm_manager = await get_llm_manager()
|
||||
|
||||
if request.temperature is not None:
|
||||
llm_manager.config.temperature = request.temperature
|
||||
if request.max_tokens is not None:
|
||||
llm_manager.config.max_tokens = request.max_tokens
|
||||
|
||||
# 토큰 스트리밍
|
||||
token_count = 0
|
||||
async for token in llm_manager.generate_stream(prompt):
|
||||
token_data = {
|
||||
"type": "token",
|
||||
"content": token,
|
||||
"token_index": token_count,
|
||||
}
|
||||
yield f"data: {json.dumps(token_data)}\n\n"
|
||||
token_count += 1
|
||||
|
||||
# 완료 신호
|
||||
completion = {
|
||||
"type": "complete",
|
||||
"total_tokens": token_count,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
yield f"data: {json.dumps(completion)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
error_data = {
|
||||
"type": "error",
|
||||
"message": str(e),
|
||||
}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
logger.error(f"Streaming error: {e}")
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@llm_router.post("/ask/metadata")
|
||||
async def get_rag_metadata(request: AskRequest) -> RAGMetadata:
|
||||
"""
|
||||
RAG 추출 메타데이터만 반환 (LLM 호출 없음).
|
||||
|
||||
- 컨텍스트 추출 시간만 측정
|
||||
- 응답 최소화 (메타데이터만)
|
||||
"""
|
||||
try:
|
||||
context_start = time.time()
|
||||
context = await extract_rag_context(
|
||||
request.query,
|
||||
context_hops=request.context_hops
|
||||
)
|
||||
extraction_time = (time.time() - context_start) * 1000
|
||||
|
||||
llm_manager = await get_llm_manager()
|
||||
|
||||
return RAGMetadata(
|
||||
query=request.query,
|
||||
context_nodes=len(context.get("nodes", [])),
|
||||
relevant_entities=context.get("relevant_entities", []),
|
||||
extraction_time_ms=extraction_time,
|
||||
llm_provider=llm_manager.config.provider.value,
|
||||
llm_model=llm_manager.config.model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Metadata retrieval failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@llm_router.post("/configure")
|
||||
async def configure_llm(
|
||||
provider: str = Query(..., description="LLM Provider: openai, anthropic, local"),
|
||||
model: str = Query(..., description="Model name"),
|
||||
api_key: Optional[str] = Query(None, description="API key (optional)"),
|
||||
temperature: float = Query(0.7, ge=0.0, le=2.0),
|
||||
max_tokens: int = Query(500, ge=1, le=4000),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
LLM 설정 변경.
|
||||
|
||||
- Provider 변경 (OpenAI, Anthropic, Local)
|
||||
- 모델 선택
|
||||
- 온도/토큰 조정
|
||||
"""
|
||||
global _llm_manager
|
||||
|
||||
try:
|
||||
provider_enum = LLMProvider[provider.upper()]
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider: {provider}. "
|
||||
f"Choose from: {[p.value for p in LLMProvider]}"
|
||||
)
|
||||
|
||||
try:
|
||||
config = LLMConfig(
|
||||
provider=provider_enum,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
base_url="http://localhost:1234/v1" if provider_enum == LLMProvider.LOCAL else None,
|
||||
)
|
||||
_llm_manager = LLMManager(config)
|
||||
|
||||
return {
|
||||
"status": "configured",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"LLM configuration failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@llm_router.get("/info")
|
||||
async def get_llm_info() -> Dict[str, Any]:
|
||||
"""Get current LLM configuration and status."""
|
||||
try:
|
||||
llm_manager = await get_llm_manager()
|
||||
redis_client = await get_redis_client()
|
||||
|
||||
return {
|
||||
"llm_provider": llm_manager.config.provider.value,
|
||||
"llm_model": llm_manager.config.model,
|
||||
"temperature": llm_manager.config.temperature,
|
||||
"max_tokens": llm_manager.config.max_tokens,
|
||||
"redis_available": redis_client is not None,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get LLM info: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cache Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@llm_router.delete("/cache")
|
||||
async def clear_cache() -> Dict[str, str]:
|
||||
"""모든 RAG 캐시 삭제."""
|
||||
redis_client = await get_redis_client()
|
||||
if not redis_client:
|
||||
return {"status": "redis_unavailable"}
|
||||
|
||||
try:
|
||||
cursor = 0
|
||||
deleted = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis_client.scan(
|
||||
cursor,
|
||||
match="phase7:rag:*",
|
||||
count=100
|
||||
)
|
||||
|
||||
if keys:
|
||||
await redis_client.delete(*keys)
|
||||
deleted += len(keys)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"deleted_keys": str(deleted),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Cache clearing failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@llm_router.get("/cache/info")
|
||||
async def get_cache_info() -> Dict[str, Any]:
|
||||
"""캐시 통계."""
|
||||
redis_client = await get_redis_client()
|
||||
if not redis_client:
|
||||
return {"redis_available": False}
|
||||
|
||||
try:
|
||||
info = await redis_client.info()
|
||||
cursor = 0
|
||||
cache_keys = 0
|
||||
|
||||
while True:
|
||||
cursor, keys = await redis_client.scan(
|
||||
cursor,
|
||||
match="phase7:rag:*",
|
||||
count=100
|
||||
)
|
||||
cache_keys += len(keys)
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
return {
|
||||
"redis_available": True,
|
||||
"used_memory_mb": info.get("used_memory", 0) / (1024 * 1024),
|
||||
"cache_keys": cache_keys,
|
||||
"redis_version": info.get("redis_version", "unknown"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Cache info retrieval failed: {e}")
|
||||
return {"redis_available": False, "error": str(e)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Health & Info Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""헬스 체크."""
|
||||
try:
|
||||
adapter = await get_neo4j_adapter()
|
||||
neo4j_ok = adapter is not None and adapter.driver is not None
|
||||
|
||||
redis_client = await get_redis_client()
|
||||
redis_ok = redis_client is not None
|
||||
|
||||
llm_manager = await get_llm_manager()
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "0.7.0",
|
||||
"neo4j": "connected" if neo4j_ok else "disconnected",
|
||||
"redis": "available" if redis_ok else "unavailable",
|
||||
"llm_provider": llm_manager.config.provider.value,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
async def get_platform_info() -> Dict[str, Any]:
|
||||
"""플랫폼 정보."""
|
||||
try:
|
||||
adapter = await get_neo4j_adapter()
|
||||
components = await get_components()
|
||||
llm_manager = await get_llm_manager()
|
||||
|
||||
# Graph stats
|
||||
analytics = components.get("analytics")
|
||||
try:
|
||||
stats = await analytics.get_graph_statistics() if analytics else {}
|
||||
except:
|
||||
stats = {}
|
||||
|
||||
return {
|
||||
"platform": "Ontology System Construction Platform",
|
||||
"phase": "7 (LLM Integration)",
|
||||
"version": "0.7.0",
|
||||
"components": {
|
||||
"neo4j": "ok" if adapter else "unavailable",
|
||||
"entity_resolver": "ok" if components.get("resolver") else "unavailable",
|
||||
"subgraph_retriever": "ok" if components.get("retriever") else "unavailable",
|
||||
"pattern_matcher": "ok" if components.get("matcher") else "unavailable",
|
||||
"graph_analytics": "ok" if components.get("analytics") else "unavailable",
|
||||
"llm_manager": "ok" if llm_manager else "unavailable",
|
||||
},
|
||||
"graph_stats": stats,
|
||||
"llm_config": {
|
||||
"provider": llm_manager.config.provider.value,
|
||||
"model": llm_manager.config.model,
|
||||
"temperature": llm_manager.config.temperature,
|
||||
"max_tokens": llm_manager.config.max_tokens,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Info retrieval failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Router Registration
|
||||
# ============================================================================
|
||||
|
||||
app.include_router(llm_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
398
ontology_platform/ont_platform/api/phase8_app.py
Normal file
398
ontology_platform/ont_platform/api/phase8_app.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Phase 8 FastAPI 애플리케이션: 멀티테넌트 엔터프라이즈 기능.
|
||||
|
||||
기능:
|
||||
- 멀티테넌트 지원 (조직 격리)
|
||||
- WebSocket 실시간 업데이트
|
||||
- 감시 로그 및 규정 준수
|
||||
- 비용 관리 및 할당량
|
||||
- 역할 기반 액세스 제어
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import (
|
||||
FastAPI,
|
||||
APIRouter,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
HTTPException,
|
||||
Depends,
|
||||
Query,
|
||||
Header,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ont_platform.auth.models import Organization, CurrentUser
|
||||
from ont_platform.auth.auth import (
|
||||
JWTAuth,
|
||||
APIKeyAuth,
|
||||
AuthService,
|
||||
get_current_user,
|
||||
)
|
||||
from ont_platform.auth.rbac import RBAC, Permission, require_permission
|
||||
from ont_platform.audit.logger import AuditLogger
|
||||
from ont_platform.audit.models import AuditAction, ResourceType
|
||||
from ont_platform.billing.calculator import CostCalculator
|
||||
from ont_platform.billing.models import OperationType
|
||||
from ont_platform.realtime.websocket import ConnectionManager
|
||||
from ont_platform.realtime.broadcaster import EventBroadcaster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# FastAPI 앱
|
||||
app = FastAPI(
|
||||
title="Ontology Platform - Phase 8 Enterprise",
|
||||
description="멀티테넌트 엔터프라이즈 기능 지원",
|
||||
version="0.8.0",
|
||||
)
|
||||
|
||||
# 라우터
|
||||
auth_router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
org_router = APIRouter(prefix="/org", tags=["organization"])
|
||||
users_router = APIRouter(prefix="/users", tags=["users"])
|
||||
audit_router = APIRouter(prefix="/audit", tags=["audit"])
|
||||
billing_router = APIRouter(prefix="/billing", tags=["billing"])
|
||||
ws_router = APIRouter(tags=["websocket"])
|
||||
|
||||
# 전역 인스턴스
|
||||
connection_manager = ConnectionManager()
|
||||
broadcaster = EventBroadcaster(connection_manager)
|
||||
audit_logger = AuditLogger()
|
||||
cost_calculator = CostCalculator()
|
||||
rbac = RBAC()
|
||||
|
||||
# 조직 저장소 (테스트용 메모리)
|
||||
organizations: Dict[str, Organization] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 인증 엔드포인트
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@auth_router.post("/login")
|
||||
async def login(
|
||||
email: str = Query(...),
|
||||
password: str = Query(...),
|
||||
org_id: str = Query(...),
|
||||
) -> Dict[str, Any]:
|
||||
"""사용자 로그인."""
|
||||
try:
|
||||
user, token = await AuthService.login(org_id, email, password)
|
||||
|
||||
# 감시 로그
|
||||
await audit_logger.log_action(
|
||||
org_id=org_id,
|
||||
user_id=user.id,
|
||||
action=AuditAction.USER_LOGIN,
|
||||
resource_type=ResourceType.USER,
|
||||
resource_id=user.id,
|
||||
status="success",
|
||||
)
|
||||
|
||||
# 비용 기록
|
||||
await cost_calculator.record_usage(
|
||||
org_id=org_id,
|
||||
user_id=user.id,
|
||||
operation_type=OperationType.API_CALL,
|
||||
quantity=1,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"token": token,
|
||||
"user": user.to_dict(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Login failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
|
||||
@auth_router.post("/register-org")
|
||||
async def register_organization(
|
||||
name: str = Query(...),
|
||||
) -> Dict[str, Any]:
|
||||
"""새 조직 등록."""
|
||||
org = Organization(name=name)
|
||||
organizations[org.id] = org
|
||||
|
||||
logger.info(f"Organization registered: {org.id}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"org_id": org.id,
|
||||
"name": org.name,
|
||||
"subscription_tier": org.subscription_tier,
|
||||
}
|
||||
|
||||
|
||||
@auth_router.post("/api-key")
|
||||
async def create_api_key(
|
||||
name: str = Query(...),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""API 키 생성."""
|
||||
# 권한 확인
|
||||
rbac.check_permission(current_user.role, Permission.MANAGE_API_KEYS.value)
|
||||
|
||||
# API 키 생성
|
||||
api_key_record = await AuthService.create_api_key(
|
||||
org_id=current_user.org_id,
|
||||
user_id=current_user.user_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# 감시 로그
|
||||
await audit_logger.log_action(
|
||||
org_id=current_user.org_id,
|
||||
user_id=current_user.user_id,
|
||||
action=AuditAction.API_KEY_CREATED,
|
||||
resource_type=ResourceType.API_KEY,
|
||||
resource_id=api_key_record.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"api_key_id": api_key_record.id,
|
||||
"name": api_key_record.name,
|
||||
"created_at": api_key_record.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 조직 엔드포인트
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@org_router.get("/info")
|
||||
async def get_organization_info(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""조직 정보 조회."""
|
||||
org = organizations.get(current_user.org_id)
|
||||
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
return {
|
||||
"org_id": org.id,
|
||||
"name": org.name,
|
||||
"subscription_tier": org.subscription_tier,
|
||||
"created_at": org.created_at.isoformat(),
|
||||
"is_active": org.is_active,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 감시 로그 엔드포인트
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@audit_router.get("/logs")
|
||||
async def get_audit_logs(
|
||||
limit: int = Query(100, le=1000),
|
||||
offset: int = Query(0),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""감시 로그 조회."""
|
||||
# 권한 확인
|
||||
rbac.check_permission(current_user.role, Permission.VIEW_AUDIT_LOG.value)
|
||||
|
||||
logs, total = await audit_logger.query_logs(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"logs": [log.to_dict() for log in logs],
|
||||
}
|
||||
|
||||
|
||||
@audit_router.get("/audit-trail/{resource_id}")
|
||||
async def get_audit_trail(
|
||||
resource_id: str,
|
||||
limit: int = Query(100, le=1000),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""리소스 감시 이력 조회."""
|
||||
# 권한 확인
|
||||
rbac.check_permission(current_user.role, Permission.VIEW_AUDIT_LOG.value)
|
||||
|
||||
logs = await audit_logger.get_audit_trail(
|
||||
org_id=current_user.org_id,
|
||||
resource_id=resource_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"resource_id": resource_id,
|
||||
"total": len(logs),
|
||||
"logs": [log.to_dict() for log in logs],
|
||||
}
|
||||
|
||||
|
||||
@audit_router.get("/statistics")
|
||||
async def get_audit_statistics(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""감시 통계 조회."""
|
||||
# 권한 확인
|
||||
rbac.check_permission(current_user.role, Permission.VIEW_AUDIT_LOG.value)
|
||||
|
||||
stats = await audit_logger.get_statistics(
|
||||
org_id=current_user.org_id,
|
||||
days=days,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"statistics": stats,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 비용 관리 엔드포인트
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@billing_router.get("/usage")
|
||||
async def get_usage_statistics(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""사용량 통계 조회."""
|
||||
stats = await cost_calculator.get_usage_statistics(
|
||||
org_id=current_user.org_id,
|
||||
period_days=days,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"statistics": stats.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
@billing_router.get("/forecast")
|
||||
async def get_cost_forecast(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""비용 예측 조회."""
|
||||
forecast = await cost_calculator.get_cost_forecast(
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"forecast": forecast,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket 엔드포인트
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@ws_router.websocket("/ws/{org_id}")
|
||||
async def websocket_endpoint(
|
||||
org_id: str,
|
||||
websocket: WebSocket,
|
||||
token: Optional[str] = None,
|
||||
):
|
||||
"""WebSocket 실시간 업데이트.
|
||||
|
||||
Usage:
|
||||
ws://localhost:8000/ws/{org_id}?token={jwt_token}
|
||||
"""
|
||||
# 토큰 검증
|
||||
if token:
|
||||
try:
|
||||
payload = JWTAuth.verify_token(token)
|
||||
if payload.org_id != org_id:
|
||||
await websocket.close(code=4003, reason="Org mismatch")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket auth failed: {e}")
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
await connection_manager.connect(org_id, websocket)
|
||||
|
||||
try:
|
||||
# 연결 유지
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
logger.debug(f"WebSocket message from {org_id}: {data}")
|
||||
|
||||
# 간단한 ping/pong
|
||||
if data == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
await connection_manager.disconnect(websocket)
|
||||
logger.info(f"WebSocket disconnected: {org_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
await connection_manager.disconnect(websocket)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 헬스 체크
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> Dict[str, Any]:
|
||||
"""헬스 체크."""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "0.8.0",
|
||||
"phase": "8 (Enterprise)",
|
||||
"components": {
|
||||
"auth": "ok",
|
||||
"audit": "ok",
|
||||
"billing": "ok",
|
||||
"websocket": f"{connection_manager.get_connection_count()} connections",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
async def get_platform_info() -> Dict[str, Any]:
|
||||
"""플랫폼 정보."""
|
||||
return {
|
||||
"platform": "Ontology System Construction Platform",
|
||||
"phase": "8 (Enterprise)",
|
||||
"version": "0.8.0",
|
||||
"features": {
|
||||
"multitenant": True,
|
||||
"websocket": True,
|
||||
"audit_logging": True,
|
||||
"billing": True,
|
||||
"rbac": True,
|
||||
},
|
||||
"organizations": len(organizations),
|
||||
"active_websocket_connections": connection_manager.get_connection_count(),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 라우터 등록
|
||||
# ============================================================================
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(org_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(audit_router)
|
||||
app.include_router(billing_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8002)
|
||||
Reference in New Issue
Block a user