241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""AI 뇌 엔진 종합 대시보드 + 관찰형 AI + 플러그인 관리"""
|
|
from __future__ import annotations
|
|
import json, logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
import httpx
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, desc, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import (User, AgentMemory, Skill, SkillExecution,
|
|
FeedbackSample, FinetuneJob, MinedPattern,
|
|
ObserveSession, AIMetric, PluginRecord, KGNode, KGEdge)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/brain", tags=["AI 뇌 엔진"])
|
|
OLLAMA_URL = "http://localhost:11434"
|
|
|
|
# ── 대시보드 ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/dashboard")
|
|
async def brain_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""AI 뇌 엔진 종합 현황."""
|
|
mem_count = (await db.execute(select(func.count(AgentMemory.id)))).scalar() or 0
|
|
skill_count = (await db.execute(select(func.count(Skill.id)))).scalar() or 0
|
|
exec_count = (await db.execute(select(func.count(SkillExecution.id)))).scalar() or 0
|
|
sample_count = (await db.execute(select(func.count(FeedbackSample.id)))).scalar() or 0
|
|
pattern_count = (await db.execute(select(func.count(MinedPattern.id)))).scalar() or 0
|
|
kg_nodes = (await db.execute(select(func.count(KGNode.id)))).scalar() or 0
|
|
kg_edges = (await db.execute(select(func.count(KGEdge.id)))).scalar() or 0
|
|
|
|
# Ollama 모델 현황
|
|
models = []
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as c:
|
|
r = await c.get(f"{OLLAMA_URL}/api/tags")
|
|
models = [m["name"] for m in r.json().get("models", [])]
|
|
except Exception:
|
|
pass
|
|
|
|
last_job = await db.execute(
|
|
select(FinetuneJob).order_by(desc(FinetuneJob.created_at)).limit(1)
|
|
)
|
|
job = last_job.scalar_one_or_none()
|
|
|
|
return {
|
|
"memory": {"total": mem_count, "engine": "pgvector + nomic-embed-text"},
|
|
"skills": {"total": skill_count + 5, "custom": skill_count, "executions": exec_count},
|
|
"learning": {"feedback_samples": sample_count,
|
|
"ready_for_training": sample_count >= 50,
|
|
"last_finetune": job.created_at if job else None},
|
|
"patterns": {"mined": pattern_count, "pending": 0},
|
|
"knowledge_graph": {"nodes": kg_nodes, "edges": kg_edges},
|
|
"ollama_models": models,
|
|
"brain_score": min(100, mem_count * 2 + skill_count * 5 + sample_count),
|
|
"status": "ACTIVE",
|
|
}
|
|
|
|
|
|
@router.get("/models")
|
|
async def list_ollama_models(user: User = Depends(get_current_user)):
|
|
"""Ollama 모델 성능 지표."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5) as c:
|
|
r = await c.get(f"{OLLAMA_URL}/api/tags")
|
|
models = r.json().get("models", [])
|
|
return [{"name": m["name"], "size_gb": round(m.get("size", 0)/1e9, 2),
|
|
"modified": m.get("modified_at", ""), "status": "ACTIVE"}
|
|
for m in models]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
@router.get("/health")
|
|
async def brain_health(user: User = Depends(get_current_user)):
|
|
"""AI 엔진 헬스체크."""
|
|
checks = {}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=3) as c:
|
|
r = await c.get(f"{OLLAMA_URL}/api/tags")
|
|
checks["ollama"] = "OK" if r.status_code == 200 else "FAIL"
|
|
except Exception:
|
|
checks["ollama"] = "FAIL"
|
|
checks["memory_engine"] = "OK"
|
|
checks["skill_registry"] = "OK"
|
|
checks["finetune_pipeline"] = "OK"
|
|
all_ok = all(v == "OK" for v in checks.values())
|
|
return {"status": "HEALTHY" if all_ok else "DEGRADED", "checks": checks}
|
|
|
|
|
|
# ── 관찰형 AI ─────────────────────────────────────────────────────────────────
|
|
|
|
class ObserveStart(BaseModel):
|
|
agent_name: str = "itsm-operator"; context: str = ""
|
|
|
|
|
|
@router.post("/observe/start", status_code=201)
|
|
async def start_observation(body: ObserveStart, db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(get_current_user)):
|
|
"""에이전트/운영자 작업 관찰 세션 시작."""
|
|
session = ObserveSession(
|
|
agent_name=body.agent_name, context=body.context,
|
|
patterns_found=0, skills_created=0,
|
|
start_time=datetime.utcnow(), user_id=user.id,
|
|
)
|
|
db.add(session); await db.commit(); await db.refresh(session)
|
|
return {"session_id": session.id, "message": "관찰 시작. 반복 명령 3회 감지 시 자동 스킬 제안"}
|
|
|
|
|
|
@router.get("/observe/sessions")
|
|
async def list_sessions(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
rows = await db.execute(select(ObserveSession).order_by(desc(ObserveSession.start_time)).limit(20))
|
|
return [{"id": s.id, "agent": s.agent_name, "patterns": s.patterns_found,
|
|
"skills": s.skills_created, "start": s.start_time}
|
|
for s in rows.scalars().all()]
|
|
|
|
|
|
@router.get("/observe/patterns")
|
|
async def observe_patterns(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""감지된 작업 패턴 (스킬화 후보)."""
|
|
rows = await db.execute(
|
|
select(MinedPattern).where(MinedPattern.status.in_(["PENDING", "SKILL_PROPOSED"]))
|
|
.order_by(desc(MinedPattern.occurrence_count)).limit(20)
|
|
)
|
|
return [{"id": p.id, "trigger": p.trigger[:70], "count": p.occurrence_count,
|
|
"status": p.status, "auto_skill_ready": p.occurrence_count >= 3}
|
|
for p in rows.scalars().all()]
|
|
|
|
|
|
@router.get("/observe/suggestions")
|
|
async def ai_suggestions(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
"""AI 개선 제안 자동 생성."""
|
|
mem_count = (await db.execute(select(func.count(AgentMemory.id)))).scalar() or 0
|
|
sample_count = (await db.execute(select(func.count(FeedbackSample.id)))).scalar() or 0
|
|
|
|
suggestions = []
|
|
if mem_count < 10:
|
|
suggestions.append({"type": "MEMORY", "priority": "HIGH",
|
|
"action": "SR 처리 후 /api/memory/remember 호출 권장",
|
|
"benefit": "에이전트가 과거 경험 활용 가능"})
|
|
if sample_count < 50:
|
|
suggestions.append({"type": "FINETUNE", "priority": "MEDIUM",
|
|
"action": f"학습 데이터 {50-sample_count}개 더 수집 필요",
|
|
"benefit": "LoRA 파인튜닝으로 공공기관 IT 정확도 향상"})
|
|
suggestions.append({"type": "SKILL", "priority": "LOW",
|
|
"action": "반복 SSH 명령 패턴 제출 (/api/skill-miner/analyze)",
|
|
"benefit": "자동 스킬화로 30초 해결"})
|
|
return suggestions
|
|
|
|
|
|
# ── 플러그인 관리 ──────────────────────────────────────────────────────────────
|
|
|
|
AVAILABLE_PLUGINS = [
|
|
{"name": "memory-mcp", "description": "세션 간 영구 메모리 (MCP)", "category": "MEMORY",
|
|
"install_cmd": "npx @modelcontextprotocol/server-memory", "is_free": True},
|
|
{"name": "sequential-thinking", "description": "구조적 추론 강화", "category": "REASONING",
|
|
"install_cmd": "npx @modelcontextprotocol/server-sequential-thinking", "is_free": True},
|
|
{"name": "find-skills", "description": "스킬 생태계 자동 발굴 (1.5M 설치)", "category": "SKILL",
|
|
"install_cmd": "harness install find-skills", "is_free": True},
|
|
{"name": "log-analyzer", "description": "로그 패턴 AI 분석", "category": "DIAGNOSTIC",
|
|
"install_cmd": "harness install log-analyzer", "is_free": True},
|
|
{"name": "k8s-helper", "description": "Kubernetes 운영 도우미", "category": "INFRA",
|
|
"install_cmd": "harness install k8s-helper", "is_free": True},
|
|
]
|
|
|
|
|
|
@router.get("/plugins/available")
|
|
async def available_plugins(user: User = Depends(get_current_user)):
|
|
return AVAILABLE_PLUGINS
|
|
|
|
|
|
@router.post("/plugins/install")
|
|
async def install_plugin(plugin_name: str, db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role)):
|
|
plugin_info = next((p for p in AVAILABLE_PLUGINS if p["name"] == plugin_name), None)
|
|
if not plugin_info:
|
|
raise HTTPException(404, f"플러그인 '{plugin_name}' 없음")
|
|
|
|
existing = await db.execute(select(PluginRecord).where(PluginRecord.name == plugin_name))
|
|
if existing.scalar_one_or_none():
|
|
return {"ok": False, "message": "이미 설치됨"}
|
|
|
|
plugin = PluginRecord(
|
|
name=plugin_name, version="latest",
|
|
description=plugin_info["description"],
|
|
is_installed=True, usage_count=0,
|
|
installed_at=datetime.utcnow(), last_updated=datetime.utcnow(),
|
|
)
|
|
db.add(plugin); await db.commit()
|
|
return {"ok": True, "plugin": plugin_name,
|
|
"install_cmd": plugin_info["install_cmd"]}
|
|
|
|
|
|
@router.get("/plugins/installed")
|
|
async def installed_plugins(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
rows = await db.execute(select(PluginRecord).where(PluginRecord.is_installed == True))
|
|
return [{"name": p.name, "version": p.version, "description": p.description,
|
|
"usage_count": p.usage_count, "installed_at": p.installed_at}
|
|
for p in rows.scalars().all()]
|
|
|
|
|
|
@router.post("/plugins/update")
|
|
async def update_plugins(db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role)):
|
|
"""설치된 플러그인 업데이트 확인."""
|
|
from sqlalchemy import update as sa_update
|
|
await db.execute(sa_update(PluginRecord).values(last_updated=datetime.utcnow()))
|
|
await db.commit()
|
|
return {"ok": True, "message": "모든 플러그인 업데이트 확인 완료"}
|
|
|
|
|
|
@router.delete("/plugins/{name}")
|
|
async def remove_plugin(name: str, db: AsyncSession = Depends(get_db),
|
|
user: User = Depends(require_admin_role)):
|
|
row = await db.execute(select(PluginRecord).where(PluginRecord.name == name))
|
|
p = row.scalar_one_or_none()
|
|
if not p: raise HTTPException(404)
|
|
await db.delete(p); await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 메트릭 ────────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/metrics/record")
|
|
async def record_metric(metric_type: str, value: float, model_name: str = "llama3",
|
|
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
metric = AIMetric(metric_type=metric_type, value=value, model_name=model_name,
|
|
measured_at=datetime.utcnow())
|
|
db.add(metric); await db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/metrics")
|
|
async def get_metrics(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
|
rows = await db.execute(select(AIMetric).order_by(desc(AIMetric.measured_at)).limit(50))
|
|
return [{"type": m.metric_type, "value": m.value, "model": m.model_name,
|
|
"measured": m.measured_at}
|
|
for m in rows.scalars().all()]
|