{"repo": "82mandoo-create/dev-sds", "files": {"backend/app/api/v1/ai_settings.py": {"206a43b9b98996bbc3f59dfa388e52ef9d0c635d": {"t": 1785746923.2250218, "n": 21411, "h": "b79999d1f2273f4b", "c": "from fastapi import APIRouter, Depends, HTTPException, status\nfrom sqlalchemy.orm import Session\nfrom typing import List, Optional\nfrom datetime import datetime\nfrom pydantic import BaseModel, Field\nfrom app.core.database import get_db\nfrom app.models.user import User\nfrom app.models.ai_config import AIConfig, AIProvider, AIConfigStatus\nfrom app.utils.deps import get_current_active_user, require_admin\n\nrouter = APIRouter(prefix=\"/ai-settings\", tags=[\"AI Settings\"])\n\n\n# ─────────────────────── Schemas ───────────────────────\n\nclass AIConfigCreate(BaseModel):\n name: str = Field(..., min_length=1, max_length=100)\n provider: AIProvider\n model_name: str = Field(..., min_length=1, max_length=100)\n api_key: Optional[str] = None\n api_base_url: Optional[str] = None\n api_version: Optional[str] = None\n deployment_name: Optional[str] = None\n is_active: bool = True\n is_default: bool = False\n max_tokens: int = Field(default=2000, ge=100, le=32000)\n temperature: float = Field(default=0.3, ge=0.0, le=2.0)\n use_for_pc_analysis: bool = True\n use_for_cert_prediction: bool = True\n use_for_security_insights: bool = True\n use_for_anomaly_detection: bool = True\n description: Optional[str] = None\n\n\nclass AIConfigUpdate(BaseModel):\n name: Optional[str] = Field(None, min_length=1, max_length=100)\n model_name: Optional[str] = Field(None, min_length=1, max_length=100)\n api_key: Optional[str] = None\n api_base_url: Optional[str] = None\n api_version: Optional[str] = None\n deployment_name: Optional[str] = None\n is_active: Optional[bool] = None\n is_default: Optional[bool] = None\n max_tokens: Optional[int] = Field(None, ge=100, le=32000)\n temperature: Optional[float] = Field(None, ge=0.0, le=2.0)\n use_for_pc_analysis: Optional[bool] = None\n use_for_cert_prediction: Optional[bool] = None\n use_for_security_insights: Optional[bool] = None\n use_for_anomaly_detection: Optional[bool] = None\n description: Optional[str] = None\n\n\ndef _mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"API 키의 중간 부분을 마스킹하여 반환\"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"****\"\n return key[:4] + \"****\" + key[-4:]\n\n\ndef _config_to_dict(cfg: AIConfig, mask_key: bool = True) -> dict:\n return {\n \"id\": cfg.id,\n \"name\": cfg.name,\n \"provider\": cfg.provider,\n \"model_name\": cfg.model_name,\n \"api_key\": _mask_api_key(cfg.api_key) if mask_key else cfg.api_key,\n \"api_key_set\": bool(cfg.api_key),\n \"api_base_url\": cfg.api_base_url,\n \"api_version\": cfg.api_version,\n \"deployment_name\": cfg.deployment_name,\n \"is_active\": cfg.is_active,\n \"is_default\": cfg.is_default,\n \"status\": cfg.status,\n \"max_tokens\": cfg.max_tokens,\n \"temperature\": cfg.temperature,\n \"use_for_pc_analysis\": cfg.use_for_pc_analysis,\n \"use_for_cert_prediction\": cfg.use_for_cert_prediction,\n \"use_for_security_insights\": cfg.use_for_security_insights,\n \"use_for_anomaly_detection\": cfg.use_for_anomaly_detection,\n \"last_test_at\": cfg.last_test_at.isoformat() if cfg.last_test_at else None,\n \"last_test_result\": cfg.last_test_result,\n \"last_test_success\": cfg.last_test_success,\n \"total_requests\": cfg.total_requests,\n \"failed_requests\": cfg.failed_requests,\n \"last_used_at\": cfg.last_used_at.isoformat() if cfg.last_used_at else None,\n \"description\": cfg.description,\n \"created_at\": cfg.created_at.isoformat() if cfg.created_at else None,\n \"updated_at\": cfg.updated_at.isoformat() if cfg.updated_at else None,\n }\n\n\n# ─────────────────────── Endpoints ───────────────────────\n\n@router.get(\"/\")\nasync def list_ai_configs(\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"등록된 AI 설정 목록 조회 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n configs = db.query(AIConfig).order_by(AIConfig.is_default.desc(), AIConfig.created_at.desc()).all()\n return {\n \"total\": len(configs),\n \"data\": [_config_to_dict(c) for c in configs],\n }\n\n\n@router.get(\"/providers\")\nasync def get_provider_info(\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"지원 AI 제공자 목록 및 모델 정보 반환\"\"\"\n return {\n \"providers\": [\n {\n \"key\": \"openai\",\n \"label\": \"OpenAI (ChatGPT)\",\n \"icon\": \"🤖\",\n \"color\": \"#10a37f\",\n \"description\": \"OpenAI GPT-4, GPT-4o, GPT-3.5 Turbo 등\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o\", \"label\": \"GPT-4o (최신, 고성능)\"},\n {\"id\": \"gpt-4o-mini\", \"label\": \"GPT-4o Mini (빠르고 저렴)\"},\n {\"id\": \"gpt-4-turbo\", \"label\": \"GPT-4 Turbo\"},\n {\"id\": \"gpt-4\", \"label\": \"GPT-4\"},\n {\"id\": \"gpt-3.5-turbo\", \"label\": \"GPT-3.5 Turbo (경제적)\"},\n ],\n \"api_key_placeholder\": \"sk-proj-...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://platform.openai.com/api-keys\",\n },\n {\n \"key\": \"gemini\",\n \"label\": \"Google Gemini\",\n \"icon\": \"✨\",\n \"color\": \"#4285f4\",\n \"description\": \"Google의 최신 멀티모달 AI 모델\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.0-flash\", \"label\": \"Gemini 2.0 Flash (최신, 빠름)\"},\n {\"id\": \"gemini-2.0-flash-lite\", \"label\": \"Gemini 2.0 Flash-Lite (초경량)\"},\n {\"id\": \"gemini-1.5-pro\", \"label\": \"Gemini 1.5 Pro (고성능)\"},\n {\"id\": \"gemini-1.5-flash\", \"label\": \"Gemini 1.5 Flash (균형)\"},\n {\"id\": \"gemini-1.5-flash-8b\", \"label\": \"Gemini 1.5 Flash-8B (경량)\"},\n ],\n \"api_key_placeholder\": \"AIza...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://aistudio.google.com/app/apikey\",\n },\n {\n \"key\": \"claude\",\n \"label\": \"Anthropic Claude\",\n \"icon\": \"🧠\",\n \"color\": \"#d97706\",\n \"description\": \"Anthropic Claude 3.x 시리즈 - 안전하고 정확한 분석\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"claude-opus-4-5\", \"label\": \"Claude Opus 4.5 (최고 성능)\"},\n {\"id\": \"claude-sonnet-4-5\", \"label\": \"Claude Sonnet 4.5 (균형)\"},\n {\"id\": \"claude-3-5-sonnet-20241022\", \"label\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-20241022\", \"label\": \"Claude 3.5 Haiku (빠름)\"},\n {\"id\": \"claude-3-opus-20240229\", \"label\": \"Claude 3 Opus\"},\n {\"id\": \"claude-3-haiku-20240307\", \"label\": \"Claude 3 Haiku (경제적)\"},\n ],\n \"api_key_placeholder\": \"sk-ant-...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://console.anthropic.com/settings/keys\",\n },\n {\n \"key\": \"ollama\",\n \"label\": \"Ollama (로컬 AI)\",\n \"icon\": \"🖥️\",\n \"color\": \"#7c3aed\",\n \"description\": \"로컬 서버에 자체 구축한 오픈소스 AI 모델\",\n \"requires_api_key\": False,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"llama3.2\", \"label\": \"Llama 3.2 (Meta)\"},\n {\"id\": \"llama3.1\", \"label\": \"Llama 3.1 (Meta)\"},\n {\"id\": \"qwen2.5\", \"label\": \"Qwen 2.5 (Alibaba)\"},\n {\"id\": \"mistral\", \"label\": \"Mistral 7B\"},\n {\"id\": \"gemma2\", \"label\": \"Gemma 2 (Google)\"},\n {\"id\": \"phi3\", \"label\": \"Phi-3 (Microsoft)\"},\n {\"id\": \"deepseek-r1\", \"label\": \"DeepSeek-R1\"},\n {\"id\": \"custom\", \"label\": \"직접 입력 (커스텀 모델명)\"},\n ],\n \"api_key_placeholder\": None,\n \"base_url_placeholder\": \"http://localhost:11434\",\n \"docs_url\": \"https://ollama.com\",\n },\n {\n \"key\": \"azure_openai\",\n \"label\": \"Azure OpenAI\",\n \"icon\": \"☁️\",\n \"color\": \"#0078d4\",\n \"description\": \"Microsoft Azure에서 호스팅하는 OpenAI 서비스\",\n \"requires_api_key\": True,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"gpt-4o\", \"label\": \"GPT-4o (Azure 배포)\"},\n {\"id\": \"gpt-4\", \"label\": \"GPT-4 (Azure 배포)\"},\n {\"id\": \"gpt-35-turbo\", \"label\": \"GPT-3.5 Turbo (Azure 배포)\"},\n ],\n \"api_key_placeholder\": \"Azure API Key\",\n \"base_url_placeholder\": \"https://.openai.azure.com\",\n \"docs_url\": \"https://portal.azure.com\",\n },\n {\n \"key\": \"custom\",\n \"label\": \"커스텀 (OpenAI 호환)\",\n \"icon\": \"⚙️\",\n \"color\": \"#6b7280\",\n \"description\": \"OpenAI API 형식을 지원하는 커스텀 엔드포인트\",\n \"requires_api_key\": True,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"custom\", \"label\": \"직접 입력\"},\n ],\n \"api_key_placeholder\": \"API Key (없으면 빈칸))\",\n \"base_url_placeholder\": \"http://your-server/v1\",\n \"docs_url\": None,\n },\n ]\n }\n\n\n@router.post(\"/\", status_code=status.HTTP_201_CREATED)\nasync def create_ai_config(\n data: AIConfigCreate,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 등록 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n\n # is_default = True 설정 시 기존 default 해제\n if data.is_default:\n db.query(AIConfig).filter(AIConfig.is_default == True).update({\"is_default\": False})\n\n cfg = AIConfig(\n **data.model_dump(),\n status=AIConfigStatus.INACTIVE,\n created_by=current_user.id,\n )\n db.add(cfg)\n db.commit()\n db.refresh(cfg)\n return _config_to_dict(cfg)\n\n\n@router.get(\"/{config_id}\")\nasync def get_ai_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 단건 조회\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n return _config_to_dict(cfg)\n\n\n@router.put(\"/{config_id}\")\nasync def update_ai_config(\n config_id: int,\n data: AIConfigUpdate,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 수정 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n\n update_data = data.model_dump(exclude_unset=True)\n\n # is_default = True 설정 시 기존 default 해제\n if update_data.get(\"is_default\"):\n db.query(AIConfig).filter(\n AIConfig.is_default == True,\n AIConfig.id != config_id\n ).update({\"is_default\": False})\n\n for key, value in update_data.items():\n setattr(cfg, key, value)\n\n cfg.updated_at = datetime.utcnow()\n db.commit()\n db.refresh(cfg)\n return _config_to_dict(cfg)\n\n\n@router.delete(\"/{config_id}\")\nasync def delete_ai_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 삭제 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n db.delete(cfg)\n db.commit()\n return {\"message\": \"삭제되었습니다.\"}\n\n\n@router.post(\"/{config_id}/test\")\nasync def test_ai_connection(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI API 연결 테스트\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n\n cfg.status = AIConfigStatus.TESTING\n db.commit()\n\n result = await _run_connection_test(cfg)\n\n cfg.last_test_at = datetime.utcnow()\n cfg.last_test_success = result[\"success\"]\n cfg.last_test_result = result[\"message\"]\n cfg.status = AIConfigStatus.ACTIVE if result[\"success\"] else AIConfigStatus.ERROR\n db.commit()\n db.refresh(cfg)\n\n return {\n \"success\": result[\"success\"],\n \"message\": result[\"message\"],\n \"response_preview\": result.get(\"preview\"),\n \"latency_ms\": result.get(\"latency_ms\"),\n \"config\": _config_to_dict(cfg),\n }\n\n\n@router.post(\"/{config_id}/set-default\")\nasync def set_default_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"기본 AI 설정 변경\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n if not cfg.is_active:\n raise HTTPException(status_code=400, detail=\"비활성화된 설정은 기본으로 설정할 수 없습니다.\")\n\n db.query(AIConfig).filter(AIConfig.is_default == True).update({\"is_default\": False})\n cfg.is_default = True\n db.commit()\n return {\"message\": f\"'{cfg.name}'이(가) 기본 AI로 설정되었습니다.\"}\n\n\n@router.get(\"/stats/summary\")\nasync def get_ai_stats(\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 통계 요약\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n \n total = db.query(AIConfig).count()\n active = db.query(AIConfig).filter(AIConfig.is_active == True).count()\n by_provider = {}\n for cfg in db.query(AIConfig).all():\n p = cfg.provider\n if p not in by_provider:\n by_provider[p] = 0\n by_provider[p] += 1\n\n default_cfg = db.query(AIConfig).filter(AIConfig.is_default == True).first()\n\n return {\n \"total\": total,\n \"active\": active,\n \"inactive\": total - active,\n \"by_provider\": by_provider,\n \"default\": _config_to_dict(default_cfg) if default_cfg else None,\n }\n\n\n# ─────────────────────── Connection Test Logic ───────────────────────\n\nasync def _run_connection_test(cfg: AIConfig) -> dict:\n \"\"\"실제 AI 제공자에 테스트 요청 전송\"\"\"\n import time\n start = time.time()\n test_prompt = \"AssetGuard 시스템 연결 테스트입니다. '연결 성공'이라고 한국어로 짧게 답하세요.\"\n\n try:\n if cfg.provider == AIProvider.OPENAI:\n result = await _test_openai(cfg, test_prompt)\n elif cfg.provider == AIProvider.GEMINI:\n result = await _test_gemini(cfg, test_prompt)\n elif cfg.provider == AIProvider.CLAUDE:\n result = await _test_claude(cfg, test_prompt)\n elif cfg.provider == AIProvider.OLLAMA:\n result = await _test_ollama(cfg, test_prompt)\n elif cfg.provider == AIProvider.AZURE_OPENAI:\n result = await _test_azure_openai(cfg, test_prompt)\n elif cfg.provider == AIProvider.CUSTOM:\n result = await _test_custom(cfg, test_prompt)\n else:\n result = {\"success\": False, \"message\": \"지원하지 않는 제공자입니다.\"}\n\n result[\"latency_ms\"] = round((time.time() - start) * 1000)\n return result\n except Exception as e:\n return {\n \"success\": False,\n \"message\": f\"연결 테스트 중 오류 발생: {str(e)}\",\n \"latency_ms\": round((time.time() - start) * 1000),\n }\n\n\nasync def _test_openai(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncOpenAI\n client = AsyncOpenAI(api_key=cfg.api_key)\n resp = await client.chat.completions.create(\n model=cfg.model_name,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"OpenAI 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"OpenAI 연결 실패: {str(e)}\"}\n\n\nasync def _test_gemini(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{cfg.model_name}:generateContent\"\n headers = {\"Content-Type\": \"application/json\"}\n payload = {\n \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n \"generationConfig\": {\"maxOutputTokens\": 50}\n }\n async with httpx.AsyncClient(timeout=15) as client:\n resp = await client.post(\n url,\n json=payload,\n params={\"key\": cfg.api_key},\n headers=headers,\n )\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"]\n return {\"success\": True, \"message\": f\"Gemini 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Gemini 연결 실패: {str(e)}\"}\n\n\nasync def _test_claude(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n headers = {\n \"x-api-key\": cfg.api_key,\n \"anthropic-version\": \"2023-06-01\",\n \"content-type\": \"application/json\",\n }\n payload = {\n \"model\": cfg.model_name,\n \"max_tokens\": 50,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n }\n async with httpx.AsyncClient(timeout=15) as client:\n resp = await client.post(\n \"https://api.anthropic.com/v1/messages\",\n json=payload,\n headers=headers,\n )\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"content\"][0][\"text\"]\n return {\"success\": True, \"message\": f\"Claude 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Claude 연결 실패: {str(e)}\"}\n\n\nasync def _test_ollama(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n base_url = (cfg.api_base_url or \"http://localhost:11434\").rstrip(\"/\")\n payload = {\n \"model\": cfg.model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"stream\": False,\n }\n async with httpx.AsyncClient(timeout=30) as client:\n resp = await client.post(f\"{base_url}/api/chat\", json=payload)\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"message\"][\"content\"]\n return {\"success\": True, \"message\": f\"Ollama 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Ollama 연결 실패: {str(e)} (서버가 실행 중인지 확인하세요)\"}\n\n\nasync def _test_azure_openai(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncAzureOpenAI\n client = AsyncAzureOpenAI(\n api_key=cfg.api_key,\n azure_endpoint=cfg.api_base_url,\n api_version=cfg.api_version or \"2024-02-01\",\n )\n deployment = cfg.deployment_name or cfg.model_name\n resp = await client.chat.completions.create(\n model=deployment,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"Azure OpenAI 연결 성공 (배포: {deployment})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Azure OpenAI 연결 실패: {str(e)}\"}\n\n\nasync def _test_custom(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncOpenAI\n client = AsyncOpenAI(\n api_key=cfg.api_key or \"dummy\",\n base_url=cfg.api_base_url,\n )\n resp = await client.chat.completions.create(\n model=cfg.model_name,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"커스텀 엔드포인트 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"커스텀 엔드포인트 연결 실패: {str(e)}\"}\n"}, "647e0b8370cf32175ce0835f0efabf3e4f202bd0": {"t": 1785769060.8037548, "n": 21411, "h": "b79999d1f2273f4b", "c": "from fastapi import APIRouter, Depends, HTTPException, status\nfrom sqlalchemy.orm import Session\nfrom typing import List, Optional\nfrom datetime import datetime\nfrom pydantic import BaseModel, Field\nfrom app.core.database import get_db\nfrom app.models.user import User\nfrom app.models.ai_config import AIConfig, AIProvider, AIConfigStatus\nfrom app.utils.deps import get_current_active_user, require_admin\n\nrouter = APIRouter(prefix=\"/ai-settings\", tags=[\"AI Settings\"])\n\n\n# ─────────────────────── Schemas ───────────────────────\n\nclass AIConfigCreate(BaseModel):\n name: str = Field(..., min_length=1, max_length=100)\n provider: AIProvider\n model_name: str = Field(..., min_length=1, max_length=100)\n api_key: Optional[str] = None\n api_base_url: Optional[str] = None\n api_version: Optional[str] = None\n deployment_name: Optional[str] = None\n is_active: bool = True\n is_default: bool = False\n max_tokens: int = Field(default=2000, ge=100, le=32000)\n temperature: float = Field(default=0.3, ge=0.0, le=2.0)\n use_for_pc_analysis: bool = True\n use_for_cert_prediction: bool = True\n use_for_security_insights: bool = True\n use_for_anomaly_detection: bool = True\n description: Optional[str] = None\n\n\nclass AIConfigUpdate(BaseModel):\n name: Optional[str] = Field(None, min_length=1, max_length=100)\n model_name: Optional[str] = Field(None, min_length=1, max_length=100)\n api_key: Optional[str] = None\n api_base_url: Optional[str] = None\n api_version: Optional[str] = None\n deployment_name: Optional[str] = None\n is_active: Optional[bool] = None\n is_default: Optional[bool] = None\n max_tokens: Optional[int] = Field(None, ge=100, le=32000)\n temperature: Optional[float] = Field(None, ge=0.0, le=2.0)\n use_for_pc_analysis: Optional[bool] = None\n use_for_cert_prediction: Optional[bool] = None\n use_for_security_insights: Optional[bool] = None\n use_for_anomaly_detection: Optional[bool] = None\n description: Optional[str] = None\n\n\ndef _mask_api_key(key: Optional[str]) -> Optional[str]:\n \"\"\"API 키의 중간 부분을 마스킹하여 반환\"\"\"\n if not key:\n return None\n if len(key) <= 8:\n return \"****\"\n return key[:4] + \"****\" + key[-4:]\n\n\ndef _config_to_dict(cfg: AIConfig, mask_key: bool = True) -> dict:\n return {\n \"id\": cfg.id,\n \"name\": cfg.name,\n \"provider\": cfg.provider,\n \"model_name\": cfg.model_name,\n \"api_key\": _mask_api_key(cfg.api_key) if mask_key else cfg.api_key,\n \"api_key_set\": bool(cfg.api_key),\n \"api_base_url\": cfg.api_base_url,\n \"api_version\": cfg.api_version,\n \"deployment_name\": cfg.deployment_name,\n \"is_active\": cfg.is_active,\n \"is_default\": cfg.is_default,\n \"status\": cfg.status,\n \"max_tokens\": cfg.max_tokens,\n \"temperature\": cfg.temperature,\n \"use_for_pc_analysis\": cfg.use_for_pc_analysis,\n \"use_for_cert_prediction\": cfg.use_for_cert_prediction,\n \"use_for_security_insights\": cfg.use_for_security_insights,\n \"use_for_anomaly_detection\": cfg.use_for_anomaly_detection,\n \"last_test_at\": cfg.last_test_at.isoformat() if cfg.last_test_at else None,\n \"last_test_result\": cfg.last_test_result,\n \"last_test_success\": cfg.last_test_success,\n \"total_requests\": cfg.total_requests,\n \"failed_requests\": cfg.failed_requests,\n \"last_used_at\": cfg.last_used_at.isoformat() if cfg.last_used_at else None,\n \"description\": cfg.description,\n \"created_at\": cfg.created_at.isoformat() if cfg.created_at else None,\n \"updated_at\": cfg.updated_at.isoformat() if cfg.updated_at else None,\n }\n\n\n# ─────────────────────── Endpoints ───────────────────────\n\n@router.get(\"/\")\nasync def list_ai_configs(\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"등록된 AI 설정 목록 조회 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n configs = db.query(AIConfig).order_by(AIConfig.is_default.desc(), AIConfig.created_at.desc()).all()\n return {\n \"total\": len(configs),\n \"data\": [_config_to_dict(c) for c in configs],\n }\n\n\n@router.get(\"/providers\")\nasync def get_provider_info(\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"지원 AI 제공자 목록 및 모델 정보 반환\"\"\"\n return {\n \"providers\": [\n {\n \"key\": \"openai\",\n \"label\": \"OpenAI (ChatGPT)\",\n \"icon\": \"🤖\",\n \"color\": \"#10a37f\",\n \"description\": \"OpenAI GPT-4, GPT-4o, GPT-3.5 Turbo 등\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"gpt-4o\", \"label\": \"GPT-4o (최신, 고성능)\"},\n {\"id\": \"gpt-4o-mini\", \"label\": \"GPT-4o Mini (빠르고 저렴)\"},\n {\"id\": \"gpt-4-turbo\", \"label\": \"GPT-4 Turbo\"},\n {\"id\": \"gpt-4\", \"label\": \"GPT-4\"},\n {\"id\": \"gpt-3.5-turbo\", \"label\": \"GPT-3.5 Turbo (경제적)\"},\n ],\n \"api_key_placeholder\": \"sk-proj-...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://platform.openai.com/api-keys\",\n },\n {\n \"key\": \"gemini\",\n \"label\": \"Google Gemini\",\n \"icon\": \"✨\",\n \"color\": \"#4285f4\",\n \"description\": \"Google의 최신 멀티모달 AI 모델\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"gemini-2.0-flash\", \"label\": \"Gemini 2.0 Flash (최신, 빠름)\"},\n {\"id\": \"gemini-2.0-flash-lite\", \"label\": \"Gemini 2.0 Flash-Lite (초경량)\"},\n {\"id\": \"gemini-1.5-pro\", \"label\": \"Gemini 1.5 Pro (고성능)\"},\n {\"id\": \"gemini-1.5-flash\", \"label\": \"Gemini 1.5 Flash (균형)\"},\n {\"id\": \"gemini-1.5-flash-8b\", \"label\": \"Gemini 1.5 Flash-8B (경량)\"},\n ],\n \"api_key_placeholder\": \"AIza...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://aistudio.google.com/app/apikey\",\n },\n {\n \"key\": \"claude\",\n \"label\": \"Anthropic Claude\",\n \"icon\": \"🧠\",\n \"color\": \"#d97706\",\n \"description\": \"Anthropic Claude 3.x 시리즈 - 안전하고 정확한 분석\",\n \"requires_api_key\": True,\n \"requires_base_url\": False,\n \"models\": [\n {\"id\": \"claude-opus-4-5\", \"label\": \"Claude Opus 4.5 (최고 성능)\"},\n {\"id\": \"claude-sonnet-4-5\", \"label\": \"Claude Sonnet 4.5 (균형)\"},\n {\"id\": \"claude-3-5-sonnet-20241022\", \"label\": \"Claude 3.5 Sonnet\"},\n {\"id\": \"claude-3-5-haiku-20241022\", \"label\": \"Claude 3.5 Haiku (빠름)\"},\n {\"id\": \"claude-3-opus-20240229\", \"label\": \"Claude 3 Opus\"},\n {\"id\": \"claude-3-haiku-20240307\", \"label\": \"Claude 3 Haiku (경제적)\"},\n ],\n \"api_key_placeholder\": \"sk-ant-...\",\n \"base_url_placeholder\": None,\n \"docs_url\": \"https://console.anthropic.com/settings/keys\",\n },\n {\n \"key\": \"ollama\",\n \"label\": \"Ollama (로컬 AI)\",\n \"icon\": \"🖥️\",\n \"color\": \"#7c3aed\",\n \"description\": \"로컬 서버에 자체 구축한 오픈소스 AI 모델\",\n \"requires_api_key\": False,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"llama3.2\", \"label\": \"Llama 3.2 (Meta)\"},\n {\"id\": \"llama3.1\", \"label\": \"Llama 3.1 (Meta)\"},\n {\"id\": \"qwen2.5\", \"label\": \"Qwen 2.5 (Alibaba)\"},\n {\"id\": \"mistral\", \"label\": \"Mistral 7B\"},\n {\"id\": \"gemma2\", \"label\": \"Gemma 2 (Google)\"},\n {\"id\": \"phi3\", \"label\": \"Phi-3 (Microsoft)\"},\n {\"id\": \"deepseek-r1\", \"label\": \"DeepSeek-R1\"},\n {\"id\": \"custom\", \"label\": \"직접 입력 (커스텀 모델명)\"},\n ],\n \"api_key_placeholder\": None,\n \"base_url_placeholder\": \"http://localhost:11434\",\n \"docs_url\": \"https://ollama.com\",\n },\n {\n \"key\": \"azure_openai\",\n \"label\": \"Azure OpenAI\",\n \"icon\": \"☁️\",\n \"color\": \"#0078d4\",\n \"description\": \"Microsoft Azure에서 호스팅하는 OpenAI 서비스\",\n \"requires_api_key\": True,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"gpt-4o\", \"label\": \"GPT-4o (Azure 배포)\"},\n {\"id\": \"gpt-4\", \"label\": \"GPT-4 (Azure 배포)\"},\n {\"id\": \"gpt-35-turbo\", \"label\": \"GPT-3.5 Turbo (Azure 배포)\"},\n ],\n \"api_key_placeholder\": \"Azure API Key\",\n \"base_url_placeholder\": \"https://.openai.azure.com\",\n \"docs_url\": \"https://portal.azure.com\",\n },\n {\n \"key\": \"custom\",\n \"label\": \"커스텀 (OpenAI 호환)\",\n \"icon\": \"⚙️\",\n \"color\": \"#6b7280\",\n \"description\": \"OpenAI API 형식을 지원하는 커스텀 엔드포인트\",\n \"requires_api_key\": True,\n \"requires_base_url\": True,\n \"models\": [\n {\"id\": \"custom\", \"label\": \"직접 입력\"},\n ],\n \"api_key_placeholder\": \"API Key (없으면 빈칸))\",\n \"base_url_placeholder\": \"http://your-server/v1\",\n \"docs_url\": None,\n },\n ]\n }\n\n\n@router.post(\"/\", status_code=status.HTTP_201_CREATED)\nasync def create_ai_config(\n data: AIConfigCreate,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 등록 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n\n # is_default = True 설정 시 기존 default 해제\n if data.is_default:\n db.query(AIConfig).filter(AIConfig.is_default == True).update({\"is_default\": False})\n\n cfg = AIConfig(\n **data.model_dump(),\n status=AIConfigStatus.INACTIVE,\n created_by=current_user.id,\n )\n db.add(cfg)\n db.commit()\n db.refresh(cfg)\n return _config_to_dict(cfg)\n\n\n@router.get(\"/{config_id}\")\nasync def get_ai_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 단건 조회\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n return _config_to_dict(cfg)\n\n\n@router.put(\"/{config_id}\")\nasync def update_ai_config(\n config_id: int,\n data: AIConfigUpdate,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 수정 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n\n update_data = data.model_dump(exclude_unset=True)\n\n # is_default = True 설정 시 기존 default 해제\n if update_data.get(\"is_default\"):\n db.query(AIConfig).filter(\n AIConfig.is_default == True,\n AIConfig.id != config_id\n ).update({\"is_default\": False})\n\n for key, value in update_data.items():\n setattr(cfg, key, value)\n\n cfg.updated_at = datetime.utcnow()\n db.commit()\n db.refresh(cfg)\n return _config_to_dict(cfg)\n\n\n@router.delete(\"/{config_id}\")\nasync def delete_ai_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 삭제 (admin 전용)\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n db.delete(cfg)\n db.commit()\n return {\"message\": \"삭제되었습니다.\"}\n\n\n@router.post(\"/{config_id}/test\")\nasync def test_ai_connection(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI API 연결 테스트\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n\n cfg.status = AIConfigStatus.TESTING\n db.commit()\n\n result = await _run_connection_test(cfg)\n\n cfg.last_test_at = datetime.utcnow()\n cfg.last_test_success = result[\"success\"]\n cfg.last_test_result = result[\"message\"]\n cfg.status = AIConfigStatus.ACTIVE if result[\"success\"] else AIConfigStatus.ERROR\n db.commit()\n db.refresh(cfg)\n\n return {\n \"success\": result[\"success\"],\n \"message\": result[\"message\"],\n \"response_preview\": result.get(\"preview\"),\n \"latency_ms\": result.get(\"latency_ms\"),\n \"config\": _config_to_dict(cfg),\n }\n\n\n@router.post(\"/{config_id}/set-default\")\nasync def set_default_config(\n config_id: int,\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"기본 AI 설정 변경\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n cfg = db.query(AIConfig).filter(AIConfig.id == config_id).first()\n if not cfg:\n raise HTTPException(status_code=404, detail=\"설정을 찾을 수 없습니다.\")\n if not cfg.is_active:\n raise HTTPException(status_code=400, detail=\"비활성화된 설정은 기본으로 설정할 수 없습니다.\")\n\n db.query(AIConfig).filter(AIConfig.is_default == True).update({\"is_default\": False})\n cfg.is_default = True\n db.commit()\n return {\"message\": f\"'{cfg.name}'이(가) 기본 AI로 설정되었습니다.\"}\n\n\n@router.get(\"/stats/summary\")\nasync def get_ai_stats(\n db: Session = Depends(get_db),\n current_user: User = Depends(get_current_active_user),\n):\n \"\"\"AI 설정 통계 요약\"\"\"\n if current_user.role != \"admin\":\n raise HTTPException(status_code=403, detail=\"관리자만 접근 가능합니다.\")\n \n total = db.query(AIConfig).count()\n active = db.query(AIConfig).filter(AIConfig.is_active == True).count()\n by_provider = {}\n for cfg in db.query(AIConfig).all():\n p = cfg.provider\n if p not in by_provider:\n by_provider[p] = 0\n by_provider[p] += 1\n\n default_cfg = db.query(AIConfig).filter(AIConfig.is_default == True).first()\n\n return {\n \"total\": total,\n \"active\": active,\n \"inactive\": total - active,\n \"by_provider\": by_provider,\n \"default\": _config_to_dict(default_cfg) if default_cfg else None,\n }\n\n\n# ─────────────────────── Connection Test Logic ───────────────────────\n\nasync def _run_connection_test(cfg: AIConfig) -> dict:\n \"\"\"실제 AI 제공자에 테스트 요청 전송\"\"\"\n import time\n start = time.time()\n test_prompt = \"AssetGuard 시스템 연결 테스트입니다. '연결 성공'이라고 한국어로 짧게 답하세요.\"\n\n try:\n if cfg.provider == AIProvider.OPENAI:\n result = await _test_openai(cfg, test_prompt)\n elif cfg.provider == AIProvider.GEMINI:\n result = await _test_gemini(cfg, test_prompt)\n elif cfg.provider == AIProvider.CLAUDE:\n result = await _test_claude(cfg, test_prompt)\n elif cfg.provider == AIProvider.OLLAMA:\n result = await _test_ollama(cfg, test_prompt)\n elif cfg.provider == AIProvider.AZURE_OPENAI:\n result = await _test_azure_openai(cfg, test_prompt)\n elif cfg.provider == AIProvider.CUSTOM:\n result = await _test_custom(cfg, test_prompt)\n else:\n result = {\"success\": False, \"message\": \"지원하지 않는 제공자입니다.\"}\n\n result[\"latency_ms\"] = round((time.time() - start) * 1000)\n return result\n except Exception as e:\n return {\n \"success\": False,\n \"message\": f\"연결 테스트 중 오류 발생: {str(e)}\",\n \"latency_ms\": round((time.time() - start) * 1000),\n }\n\n\nasync def _test_openai(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncOpenAI\n client = AsyncOpenAI(api_key=cfg.api_key)\n resp = await client.chat.completions.create(\n model=cfg.model_name,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"OpenAI 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"OpenAI 연결 실패: {str(e)}\"}\n\n\nasync def _test_gemini(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/{cfg.model_name}:generateContent\"\n headers = {\"Content-Type\": \"application/json\"}\n payload = {\n \"contents\": [{\"parts\": [{\"text\": prompt}]}],\n \"generationConfig\": {\"maxOutputTokens\": 50}\n }\n async with httpx.AsyncClient(timeout=15) as client:\n resp = await client.post(\n url,\n json=payload,\n params={\"key\": cfg.api_key},\n headers=headers,\n )\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"]\n return {\"success\": True, \"message\": f\"Gemini 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Gemini 연결 실패: {str(e)}\"}\n\n\nasync def _test_claude(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n headers = {\n \"x-api-key\": cfg.api_key,\n \"anthropic-version\": \"2023-06-01\",\n \"content-type\": \"application/json\",\n }\n payload = {\n \"model\": cfg.model_name,\n \"max_tokens\": 50,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n }\n async with httpx.AsyncClient(timeout=15) as client:\n resp = await client.post(\n \"https://api.anthropic.com/v1/messages\",\n json=payload,\n headers=headers,\n )\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"content\"][0][\"text\"]\n return {\"success\": True, \"message\": f\"Claude 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Claude 연결 실패: {str(e)}\"}\n\n\nasync def _test_ollama(cfg: AIConfig, prompt: str) -> dict:\n try:\n import httpx\n base_url = (cfg.api_base_url or \"http://localhost:11434\").rstrip(\"/\")\n payload = {\n \"model\": cfg.model_name,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"stream\": False,\n }\n async with httpx.AsyncClient(timeout=30) as client:\n resp = await client.post(f\"{base_url}/api/chat\", json=payload)\n resp.raise_for_status()\n data = resp.json()\n preview = data[\"message\"][\"content\"]\n return {\"success\": True, \"message\": f\"Ollama 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Ollama 연결 실패: {str(e)} (서버가 실행 중인지 확인하세요)\"}\n\n\nasync def _test_azure_openai(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncAzureOpenAI\n client = AsyncAzureOpenAI(\n api_key=cfg.api_key,\n azure_endpoint=cfg.api_base_url,\n api_version=cfg.api_version or \"2024-02-01\",\n )\n deployment = cfg.deployment_name or cfg.model_name\n resp = await client.chat.completions.create(\n model=deployment,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"Azure OpenAI 연결 성공 (배포: {deployment})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"Azure OpenAI 연결 실패: {str(e)}\"}\n\n\nasync def _test_custom(cfg: AIConfig, prompt: str) -> dict:\n try:\n from openai import AsyncOpenAI\n client = AsyncOpenAI(\n api_key=cfg.api_key or \"dummy\",\n base_url=cfg.api_base_url,\n )\n resp = await client.chat.completions.create(\n model=cfg.model_name,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=50,\n temperature=0,\n )\n preview = resp.choices[0].message.content\n return {\"success\": True, \"message\": f\"커스텀 엔드포인트 연결 성공 (모델: {cfg.model_name})\", \"preview\": preview}\n except Exception as e:\n return {\"success\": False, \"message\": f\"커스텀 엔드포인트 연결 실패: {str(e)}\"}\n"}}}}