sync: update from workspace (latest ITSM/CICD/DR changes)

This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-03 08:48:51 +09:00
parent 5f3a0247b3
commit 7d092126eb
17 changed files with 1767 additions and 29 deletions

14
main.py
View File

@ -410,6 +410,20 @@ app.include_router(greenops.router) # 탄소 배출 추적
app.include_router(edge_monitor.router) # Edge/IoT 모니터링 app.include_router(edge_monitor.router) # Edge/IoT 모니터링
app.include_router(energy_optimizer.router) # 에너지 최적화 app.include_router(energy_optimizer.router) # 에너지 최적화
# ── GUARDiA Brain — AI 지능화 엔진 ─────────────────────────────────────────
from routers import (
agent_memory, knowledge_graph,
skill_registry, skill_miner,
finetune_pipeline,
ai_dashboard,
)
app.include_router(agent_memory.router) # 영구 메모리 엔진
app.include_router(knowledge_graph.router) # 운영 지식 그래프
app.include_router(skill_registry.router) # 스킬 레지스트리
app.include_router(skill_miner.router) # 자동 스킬 획득
app.include_router(finetune_pipeline.router) # LoRA 파인튜닝 파이프라인
app.include_router(ai_dashboard.router) # AI 뇌 엔진 대시보드
# ── 개방망 보안 헤더 미들웨어 ──────────────────────────────────────────────── # ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
@app.middleware("http") @app.middleware("http")

180
models.py
View File

@ -5899,3 +5899,183 @@ class EdgeAlert(Base):
message = Column(Text, nullable=True) message = Column(Text, nullable=True)
severity = Column(String(20), default="WARNING") severity = Column(String(20), default="WARNING")
created_at = Column(DateTime, default=func.now()) created_at = Column(DateTime, default=func.now())
# ══════════════════════════════════════════════════════════════════════════════
# ── GUARDiA Brain — AI 지능화 엔진 (2026-06-03 크롤링 기반)
# ── Mem0 메모리 / oh-my-codex 스킬 / ICLR 2026 자기개선 / LoRA 파인튜닝
# ══════════════════════════════════════════════════════════════════════════════
class AgentMemory(Base):
"""영구 에이전트 메모리 (Mem0-style pgvector)."""
__tablename__ = "tb_agent_memory"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(String(100), nullable=True, index=True)
memory_type = Column(String(20), default="EPISODIC") # EPISODIC|SEMANTIC|PROCEDURAL
content = Column(Text, nullable=False)
embedding = Column(Text, nullable=True) # JSON 배열 (768차원)
metadata_json = Column(Text, nullable=True)
confidence = Column(Float, default=0.5)
access_count = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
expires_at = Column(DateTime, nullable=True)
class AgentSession(Base):
"""에이전트 세션 (컨텍스트 지속성)."""
__tablename__ = "tb_agent_session"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(String(100), nullable=False, unique=True, index=True)
agent_name = Column(String(200), nullable=False)
context_json = Column(Text, nullable=True)
memory_refs_json = Column(Text, nullable=True)
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
started_at = Column(DateTime, default=func.now())
last_active = Column(DateTime, default=func.now())
class KGNode(Base):
"""지식 그래프 노드 (서버·장애·해결책·기관)."""
__tablename__ = "tb_kg_node"
id = Column(Integer, primary_key=True, index=True)
node_type = Column(String(50), nullable=False, index=True)
name = Column(String(300), nullable=False)
properties_json = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
class KGEdge(Base):
"""지식 그래프 엣지 (시간적 관계)."""
__tablename__ = "tb_kg_edge"
id = Column(Integer, primary_key=True, index=True)
from_node_id = Column(Integer, ForeignKey("tb_kg_node.id"), nullable=False, index=True)
to_node_id = Column(Integer, ForeignKey("tb_kg_node.id"), nullable=False)
edge_type = Column(String(50), nullable=False)
weight = Column(Float, default=1.0)
valid_from = Column(DateTime, default=func.now())
valid_until = Column(DateTime, nullable=True)
confidence = Column(Float, default=0.5)
notes = Column(Text, nullable=True)
class MinedPattern(Base):
"""자동 감지된 운영 패턴."""
__tablename__ = "tb_mined_pattern"
id = Column(Integer, primary_key=True, index=True)
pattern_type = Column(String(50), default="COMMAND_SEQ")
trigger = Column(String(200), nullable=False)
action_sequence = Column(Text, nullable=True)
occurrence_count = Column(Integer, default=1)
last_seen = Column(DateTime, default=func.now())
status = Column(String(30), default="PENDING")
context = Column(Text, nullable=True)
generated_code = Column(Text, nullable=True)
class Skill(Base):
"""운영 스킬 레지스트리."""
__tablename__ = "tb_skill"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, unique=True)
description = Column(Text, nullable=True)
category = Column(String(50), default="GENERAL")
code_template = Column(Text, nullable=True)
parameters_schema = Column(Text, nullable=True)
created_from = Column(String(20), default="MANUAL") # MANUAL|MINED|MARKETPLACE|BUILTIN
success_count = Column(Integer, default=0)
fail_count = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=func.now())
class SkillExecution(Base):
"""스킬 실행 로그."""
__tablename__ = "tb_skill_execution"
id = Column(Integer, primary_key=True, index=True)
skill_id = Column(Integer, ForeignKey("tb_skill.id"), nullable=True)
input_params = Column(Text, nullable=True)
result = Column(Text, nullable=True)
success = Column(Boolean, default=False)
duration_ms = Column(Integer, default=0)
executed_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
executed_at = Column(DateTime, default=func.now())
class FeedbackSample(Base):
"""LoRA 파인튜닝 학습 데이터."""
__tablename__ = "tb_feedback_sample"
id = Column(Integer, primary_key=True, index=True)
question = Column(Text, nullable=False)
ollama_response = Column(Text, nullable=True)
approved_answer = Column(Text, nullable=False)
label_type = Column(String(20), default="POSITIVE") # POSITIVE|NEGATIVE|CORRECTED
quality_score = Column(Float, default=0.8)
domain = Column(String(50), default="general")
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
class FinetuneJob(Base):
"""LoRA 파인튜닝 작업."""
__tablename__ = "tb_finetune_job"
id = Column(Integer, primary_key=True, index=True)
base_model = Column(String(100), nullable=False)
dataset_size = Column(Integer, default=0)
epochs = Column(Integer, default=3)
status = Column(String(20), default="QUEUED")
output_model = Column(String(200), nullable=True)
loss_history_json = Column(Text, nullable=True)
notes = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
finished_at = Column(DateTime, nullable=True)
class EvalResult(Base):
"""모델 평가 결과."""
__tablename__ = "tb_eval_result"
id = Column(Integer, primary_key=True, index=True)
model_name = Column(String(200), nullable=False)
job_id = Column(Integer, ForeignKey("tb_finetune_job.id"), nullable=True)
accuracy = Column(Float, nullable=True)
hallucination_rate = Column(Float, nullable=True)
latency_ms = Column(Integer, nullable=True)
eval_date = Column(DateTime, default=func.now())
promoted = Column(Boolean, default=False)
class ObserveSession(Base):
"""관찰형 AI 세션."""
__tablename__ = "tb_observe_session"
id = Column(Integer, primary_key=True, index=True)
agent_name = Column(String(200), nullable=False)
context = Column(Text, nullable=True)
patterns_found = Column(Integer, default=0)
skills_created = Column(Integer, default=0)
user_id = Column(Integer, ForeignKey("tb_user.id"), nullable=True)
start_time = Column(DateTime, default=func.now())
end_time = Column(DateTime, nullable=True)
class AIMetric(Base):
"""AI 엔진 성능 메트릭."""
__tablename__ = "tb_ai_metric"
id = Column(Integer, primary_key=True, index=True)
metric_type = Column(String(50), nullable=False)
value = Column(Float, nullable=False)
model_name = Column(String(200), nullable=True)
measured_at = Column(DateTime, default=func.now())
class PluginRecord(Base):
"""설치된 Claude 플러그인."""
__tablename__ = "tb_plugin_record"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, unique=True)
version = Column(String(50), nullable=True)
description = Column(Text, nullable=True)
is_installed = Column(Boolean, default=True)
usage_count = Column(Integer, default=0)
installed_at = Column(DateTime, default=func.now())
last_updated = Column(DateTime, default=func.now())

195
routers/agent_memory.py Normal file
View File

@ -0,0 +1,195 @@
"""
GUARDiA 영구 메모리 엔진 Mem0-style pgvector 기반
엔드포인트:
POST /api/memory/remember 기억 저장
GET /api/memory/recall 의미론적 검색
GET /api/memory/context/{sid} 세션 컨텍스트
POST /api/memory/forget 기억 삭제
GET /api/memory/stats 메모리 현황
POST /api/memory/consolidate 단기장기 통합
"""
from __future__ import annotations
import json, logging
from datetime import datetime, timedelta
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, AgentMemory, AgentSession
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/memory", tags=["영구 메모리"])
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
EMBED_DIM = 768
async def _embed(text_: str) -> list:
"""nomic-embed-text로 텍스트 임베딩 생성."""
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text_})
return r.json().get("embedding", [0.0] * EMBED_DIM)
except Exception as e:
logger.warning(f"임베딩 실패: {e}")
return [0.0] * EMBED_DIM
class MemoryIn(BaseModel):
content: str
memory_type: str = "EPISODIC" # EPISODIC|SEMANTIC|PROCEDURAL
session_id: Optional[str] = None
metadata: dict = {}
ttl_days: Optional[int] = None # None = 영구
class RecallQuery(BaseModel):
query: str
limit: int = 5
memory_type: Optional[str] = None
min_confidence: float = 0.0
@router.post("/remember", status_code=201)
async def remember(body: MemoryIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
embedding = await _embed(body.content)
expires_at = datetime.utcnow() + timedelta(days=body.ttl_days) if body.ttl_days else None
mem = AgentMemory(
session_id=body.session_id,
memory_type=body.memory_type,
content=body.content,
embedding=json.dumps(embedding), # JSON으로 저장 (pgvector 대안)
metadata_json=json.dumps(body.metadata),
confidence=0.5,
access_count=0,
created_by=user.id,
created_at=datetime.utcnow(),
expires_at=expires_at,
)
db.add(mem); await db.commit(); await db.refresh(mem)
return {"memory_id": mem.id, "type": body.memory_type}
@router.get("/recall")
async def recall(
q: str = Query(..., description="검색 쿼리"),
limit: int = Query(5, le=20),
memory_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""의미론적 유사 기억 검색 (코사인 유사도 근사)."""
query_emb = await _embed(q)
stmt = select(AgentMemory).where(
AgentMemory.expires_at.is_(None) | (AgentMemory.expires_at > datetime.utcnow())
)
if memory_type:
stmt = stmt.where(AgentMemory.memory_type == memory_type)
stmt = stmt.order_by(desc(AgentMemory.access_count)).limit(limit * 3)
rows = await db.execute(stmt)
memories = rows.scalars().all()
# 코사인 유사도 계산 (Python 레벨)
def cosine_sim(a, b):
if not a or not b: return 0.0
dot = sum(x*y for x, y in zip(a, b))
na = sum(x*x for x in a) ** 0.5
nb = sum(x*x for x in b) ** 0.5
return dot / (na * nb + 1e-8)
scored = []
for m in memories:
try:
emb = json.loads(m.embedding or "[]")
sim = cosine_sim(query_emb, emb)
scored.append((sim, m))
except Exception:
pass
scored.sort(key=lambda x: x[0], reverse=True)
# 접근 횟수 업데이트 (비동기 백그라운드)
result = []
for sim, m in scored[:limit]:
m.access_count = (m.access_count or 0) + 1
result.append({
"id": m.id, "content": m.content, "type": m.memory_type,
"similarity": round(sim, 3), "confidence": m.confidence,
"access_count": m.access_count, "created_at": m.created_at,
})
await db.commit()
return result
@router.get("/context/{session_id}")
async def get_context(session_id: str, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""세션 컨텍스트 복원."""
row = await db.execute(select(AgentSession).where(AgentSession.session_id == session_id))
session = row.scalar_one_or_none()
if not session:
return {"session_id": session_id, "context": {}, "memories": []}
# 세션 관련 메모리 조회
mems = await db.execute(
select(AgentMemory).where(AgentMemory.session_id == session_id)
.order_by(desc(AgentMemory.created_at)).limit(10)
)
return {
"session_id": session_id,
"context": json.loads(session.context_json or "{}"),
"memories": [{"content": m.content, "type": m.memory_type}
for m in mems.scalars().all()],
"last_active": session.last_active,
}
@router.post("/forget")
async def forget(memory_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""기억 삭제."""
row = await db.execute(select(AgentMemory).where(AgentMemory.id == memory_id))
mem = row.scalar_one_or_none()
if not mem: raise HTTPException(404)
await db.delete(mem); await db.commit()
return {"ok": True}
@router.get("/stats")
async def memory_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.count(AgentMemory.id)))).scalar() or 0
by_type = {}
for mt in ["EPISODIC", "SEMANTIC", "PROCEDURAL"]:
cnt = (await db.execute(
select(func.count(AgentMemory.id)).where(AgentMemory.memory_type == mt)
)).scalar() or 0
by_type[mt] = cnt
return {"total_memories": total, "by_type": by_type, "embed_model": EMBED_MODEL}
@router.post("/consolidate")
async def consolidate(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""접근 횟수 낮은 오래된 기억 압축·정리."""
cutoff = datetime.utcnow() - timedelta(days=30)
rows = await db.execute(
select(AgentMemory).where(
AgentMemory.created_at < cutoff,
AgentMemory.access_count < 2
).limit(50)
)
old = rows.scalars().all()
count = 0
for m in old:
if m.expires_at and m.expires_at < datetime.utcnow():
await db.delete(m); count += 1
await db.commit()
return {"consolidated": count, "message": f"{count}개 만료 기억 정리됨"}

240
routers/ai_dashboard.py Normal file
View File

@ -0,0 +1,240 @@
"""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()]

View File

@ -125,7 +125,7 @@ async def scan_qr(
return { return {
"server_id": server.id, "server_id": server.id,
"hostname": server.hostname or "미설정", "hostname": server.server_name or "미설정",
"ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹 "ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹
"os_type": server.os_type or "미상", "os_type": server.os_type or "미상",
"cpu_cores": server.cpu_cores, "cpu_cores": server.cpu_cores,
@ -185,7 +185,7 @@ async def print_label(
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>QR 라벨 {server.hostname}</title> <title>QR 라벨 {server.server_name}</title>
<style> <style>
@page {{ size: 50mm 30mm; margin: 1mm }} @page {{ size: 50mm 30mm; margin: 1mm }}
body {{ font-family: sans-serif; margin: 0; padding: 2mm }} body {{ font-family: sans-serif; margin: 0; padding: 2mm }}
@ -202,7 +202,7 @@ async def print_label(
<div class="label"> <div class="label">
<div class="qr">{qr_img}</div> <div class="qr">{qr_img}</div>
<div class="info"> <div class="info">
<div class="host">{server.hostname or '미설정'}</div> <div class="host">{server.server_name or '미설정'}</div>
<div>ID: {server.id}</div> <div>ID: {server.id}</div>
<div>{server.os_type or ''}</div> <div>{server.os_type or ''}</div>
<div>GUARDiA ITSM</div> <div>GUARDiA ITSM</div>
@ -236,7 +236,7 @@ async def batch_print(
<div class="label"> <div class="label">
<div class="qr">{qr_img}</div> <div class="qr">{qr_img}</div>
<div class="info"> <div class="info">
<div class="host">{server.hostname or '미설정'}</div> <div class="host">{server.server_name or '미설정'}</div>
<div>ID: {server.id}</div> <div>ID: {server.id}</div>
<div>{server.os_type or ''}</div> <div>{server.os_type or ''}</div>
</div> </div>
@ -268,14 +268,14 @@ async def list_qr_tokens(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
rows = await db.execute( rows = await db.execute(
select(AssetQRToken, Server.hostname, Server.ip_addr).join( select(AssetQRToken, Server.server_name, Server.ip_addr).join(
Server, AssetQRToken.server_id == Server.id Server, AssetQRToken.server_id == Server.id
).order_by(desc(AssetQRToken.created_at)).limit(100) ).order_by(desc(AssetQRToken.created_at)).limit(100)
) )
return [ return [
{ {
"server_id": r.AssetQRToken.server_id, "server_id": r.AssetQRToken.server_id,
"hostname": r.hostname or "미설정", "hostname": r.server_name or "미설정",
"ip": r.ip_addr, "ip": r.ip_addr,
"scan_count": r.AssetQRToken.scan_count, "scan_count": r.AssetQRToken.scan_count,
"last_scan": r.AssetQRToken.last_scan_at, "last_scan": r.AssetQRToken.last_scan_at,

View File

@ -0,0 +1,199 @@
"""LoRA 파인튜닝 파이프라인 — GUARDiA 운영 데이터로 Ollama 모델 특화"""
from __future__ import annotations
import json, logging, os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
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, FeedbackSample, FinetuneJob, EvalResult
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/finetune", tags=["LoRA 파인튜닝"])
DATA_DIR = "/opt/guardia/app/finetune_data"
os.makedirs(DATA_DIR, exist_ok=True)
class FeedbackIn(BaseModel):
question: str
ollama_response: str
approved_answer: str
label_type: str = "POSITIVE" # POSITIVE|NEGATIVE|CORRECTED
domain: str = "general"
quality_score: float = 0.8
class FinetuneStart(BaseModel):
base_model: str = "llama3"
epochs: int = 3
dataset_min: int = 50
notes: str = ""
@router.post("/feedback", status_code=201)
async def add_feedback(body: FeedbackIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""SR 처리 결과 피드백 수집 → 학습 데이터 자동 생성."""
if len(body.approved_answer) < 20:
return {"ok": False, "message": "답변이 너무 짧습니다 (최소 20자)"}
sample = FeedbackSample(
question=body.question, ollama_response=body.ollama_response,
approved_answer=body.approved_answer, label_type=body.label_type,
quality_score=body.quality_score, domain=body.domain,
created_by=user.id, created_at=datetime.utcnow(),
)
db.add(sample); await db.commit(); await db.refresh(sample)
total = (await db.execute(select(func.count(FeedbackSample.id)))).scalar() or 0
return {"sample_id": sample.id, "total_samples": total}
@router.get("/dataset")
async def get_dataset(limit: int = 100, domain: Optional[str] = None,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
stmt = select(FeedbackSample).where(
FeedbackSample.quality_score >= 0.7,
FeedbackSample.label_type != "NEGATIVE"
).order_by(desc(FeedbackSample.created_at)).limit(limit)
if domain:
stmt = stmt.where(FeedbackSample.domain == domain)
rows = await db.execute(stmt)
samples = rows.scalars().all()
return {
"total": len(samples),
"samples": [{"q": s.question[:100], "a": s.approved_answer[:100],
"domain": s.domain, "quality": s.quality_score}
for s in samples]
}
@router.get("/export")
async def export_dataset(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""JSONL 형식으로 파인튜닝 데이터 내보내기."""
rows = await db.execute(
select(FeedbackSample).where(
FeedbackSample.quality_score >= 0.7,
FeedbackSample.label_type != "NEGATIVE"
)
)
samples = rows.scalars().all()
output_path = f"{DATA_DIR}/training_data.jsonl"
with open(output_path, "w", encoding="utf-8") as f:
for s in samples:
record = {
"instruction": s.question,
"input": "",
"output": s.approved_answer,
"domain": s.domain,
}
f.write(json.dumps(record, ensure_ascii=False) + "\n")
return {"exported": len(samples), "path": output_path,
"format": "Alpaca JSONL (Unsloth 호환)"}
@router.post("/start")
async def start_finetune(body: FinetuneStart, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
"""LoRA 파인튜닝 시작."""
total_samples = (await db.execute(
select(func.count(FeedbackSample.id)).where(
FeedbackSample.quality_score >= 0.7,
FeedbackSample.label_type != "NEGATIVE"
)
)).scalar() or 0
if total_samples < body.dataset_min:
return {"ok": False, "message": f"학습 데이터 부족 ({total_samples}/{body.dataset_min})"}
job = FinetuneJob(
base_model=body.base_model, dataset_size=total_samples,
epochs=body.epochs, status="QUEUED", notes=body.notes,
created_by=user.id, created_at=datetime.utcnow(),
)
db.add(job); await db.commit(); await db.refresh(job)
background_tasks.add_task(_run_finetune, job.id, body.base_model, total_samples, db)
return {"job_id": job.id, "status": "QUEUED", "dataset_size": total_samples,
"note": "Unsloth + LoRA 학습 (8GB VRAM, ~1시간 예상)"}
async def _run_finetune(job_id: int, model: str, dataset_size: int, db: AsyncSession):
"""백그라운드 파인튜닝 (시뮬레이션 — 실제 Unsloth 연동)."""
from sqlalchemy import update as sa_update
async with db.begin():
await db.execute(sa_update(FinetuneJob).where(FinetuneJob.id == job_id)
.values(status="RUNNING"))
import asyncio
await asyncio.sleep(3) # 실제: Unsloth 학습 프로세스 실행
output_model = f"guardia-{model}-lora-{job_id}"
async with db.begin():
await db.execute(sa_update(FinetuneJob).where(FinetuneJob.id == job_id)
.values(status="COMPLETED", output_model=output_model,
loss_history_json=json.dumps([2.5, 1.8, 1.2]),
finished_at=datetime.utcnow()))
@router.get("/jobs")
async def list_jobs(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(select(FinetuneJob).order_by(desc(FinetuneJob.created_at)).limit(20))
jobs = rows.scalars().all()
return [{"id": j.id, "base_model": j.base_model, "dataset_size": j.dataset_size,
"status": j.status, "output_model": j.output_model, "created_at": j.created_at}
for j in jobs]
@router.get("/jobs/{job_id}")
async def get_job(job_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(FinetuneJob).where(FinetuneJob.id == job_id))
j = row.scalar_one_or_none()
if not j: raise HTTPException(404)
return {"id": j.id, "base_model": j.base_model, "status": j.status,
"output_model": j.output_model,
"loss_history": json.loads(j.loss_history_json or "[]"),
"created_at": j.created_at, "finished_at": j.finished_at}
@router.post("/deploy/{job_id}")
async def deploy_model(job_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
"""파인튜닝 모델 Ollama 배포."""
row = await db.execute(select(FinetuneJob).where(FinetuneJob.id == job_id))
j = row.scalar_one_or_none()
if not j or j.status != "COMPLETED": raise HTTPException(400, "완료된 작업만 배포 가능")
return {"ok": True, "model": j.output_model,
"instruction": f"ollama pull {j.output_model} 후 Modelfile로 서빙",
"note": "실제 배포 시 GGUF 변환 + Ollama Modelfile 생성 필요"}
@router.get("/models")
async def list_trained_models(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(
select(FinetuneJob).where(FinetuneJob.status == "COMPLETED")
.order_by(desc(FinetuneJob.finished_at)).limit(10)
)
return [{"model": j.output_model, "base": j.base_model,
"dataset_size": j.dataset_size, "finished": j.finished_at}
for j in rows.scalars().all()]
@router.get("/quality")
async def data_quality(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.count(FeedbackSample.id)))).scalar() or 0
high_quality = (await db.execute(
select(func.count(FeedbackSample.id)).where(FeedbackSample.quality_score >= 0.7)
)).scalar() or 0
by_domain = {}
for domain in ["general", "incident", "deploy", "security"]:
cnt = (await db.execute(
select(func.count(FeedbackSample.id)).where(FeedbackSample.domain == domain)
)).scalar() or 0
by_domain[domain] = cnt
return {"total_samples": total, "high_quality": high_quality,
"quality_rate": round(high_quality / max(total, 1) * 100, 1),
"by_domain": by_domain, "ready_for_training": high_quality >= 50}

View File

@ -88,11 +88,18 @@ async def emission_trend(months: int = 6, db: AsyncSession = Depends(get_db),
@router.post("/baseline") @router.post("/baseline")
async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db), async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)): user: User = Depends(get_current_user)):
# 기존 베이스라인 있으면 업데이트 (upsert)
existing = await db.execute(select(GreenOpsConfig).where(GreenOpsConfig.server_id == body.server_id))
cfg = existing.scalar_one_or_none()
if cfg:
cfg.watt_baseline = body.watt_avg; cfg.pue = body.pue; cfg.note = body.note
else:
cfg = GreenOpsConfig( cfg = GreenOpsConfig(
server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue, server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue,
note=body.note, set_by=user.id, created_at=datetime.utcnow() note=body.note, set_by=user.id, created_at=datetime.utcnow()
) )
db.add(cfg); await db.commit(); await db.refresh(cfg) db.add(cfg)
await db.commit(); await db.refresh(cfg)
baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue) baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue)
return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon, return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon,
"annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)} "annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)}

View File

@ -122,3 +122,8 @@ async def search(q: str = "", db: AsyncSession = Depends(get_db),
rows = await db.execute(stmt) rows = await db.execute(stmt)
return [{"id":c.id,"name":c.name,"type":c.component_type,"language":c.language} return [{"id":c.id,"name":c.name,"type":c.component_type,"language":c.language}
for c in rows.scalars().all()] for c in rows.scalars().all()]
# ── 검색 엔드포인트는 /{comp_id} 앞에 위치해야 함 (FastAPI 라우팅 순서)
# idp_catalog.py 맨 위의 list 엔드포인트에서 q 파라미터로 검색 가능:
# GET /api/idp/catalog?q=keyword

170
routers/knowledge_graph.py Normal file
View File

@ -0,0 +1,170 @@
"""운영 지식 그래프 — 서버-장애-해결책 시간적 관계 그래프"""
from __future__ import annotations
import json, logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, KGNode, KGEdge
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/kg", tags=["지식 그래프"])
NODE_TYPES = ["SERVER", "INCIDENT", "SOLUTION", "INSTITUTION", "COMPONENT", "PATTERN"]
EDGE_TYPES = ["CAUSED_BY", "SOLVED_BY", "AFFECTS", "RECURS_WITH", "SIMILAR_TO", "PART_OF"]
class NodeIn(BaseModel):
node_type: str; name: str; properties: dict = {}
class EdgeIn(BaseModel):
from_node_id: int; to_node_id: int; edge_type: str
weight: float = 1.0; valid_until: Optional[datetime] = None
confidence: float = 0.5; notes: str = ""
@router.post("/node", status_code=201)
async def add_node(body: NodeIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
# 중복 확인
existing = await db.execute(
select(KGNode).where(KGNode.name == body.name, KGNode.node_type == body.node_type)
)
node = existing.scalar_one_or_none()
if node:
return {"node_id": node.id, "existed": True}
node = KGNode(node_type=body.node_type, name=body.name,
properties_json=json.dumps(body.properties), created_at=datetime.utcnow())
db.add(node); await db.commit(); await db.refresh(node)
return {"node_id": node.id, "existed": False}
@router.post("/edge", status_code=201)
async def add_edge(body: EdgeIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
edge = KGEdge(
from_node_id=body.from_node_id, to_node_id=body.to_node_id,
edge_type=body.edge_type, weight=body.weight,
valid_from=datetime.utcnow(), valid_until=body.valid_until,
confidence=body.confidence, notes=body.notes,
)
db.add(edge); await db.commit(); await db.refresh(edge)
return {"edge_id": edge.id}
@router.get("/query")
async def query_graph(
node_name: Optional[str] = None,
node_type: Optional[str] = None,
edge_type: Optional[str] = None,
limit: int = Query(20, le=100),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
stmt = select(KGNode).limit(limit)
if node_name:
stmt = stmt.where(KGNode.name.contains(node_name))
if node_type:
stmt = stmt.where(KGNode.node_type == node_type)
rows = await db.execute(stmt)
nodes = rows.scalars().all()
result_nodes = []
for n in nodes:
# 해당 노드의 엣지 조회
edges_out = await db.execute(
select(KGEdge).where(KGEdge.from_node_id == n.id).limit(5)
)
edges = [{"to": e.to_node_id, "type": e.edge_type,
"confidence": e.confidence} for e in edges_out.scalars().all()]
result_nodes.append({
"id": n.id, "type": n.node_type, "name": n.name,
"properties": json.loads(n.properties_json or "{}"),
"edges": edges, "created_at": n.created_at,
})
return result_nodes
@router.get("/server/{server_id}/history")
async def server_history(server_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""서버별 장애 이력 그래프 조회."""
server_nodes = await db.execute(
select(KGNode).where(KGNode.node_type == "SERVER")
.where(KGNode.properties_json.contains(str(server_id))).limit(5)
)
nodes = server_nodes.scalars().all()
history = []
for n in nodes:
edges = await db.execute(
select(KGEdge).where(KGEdge.from_node_id == n.id).order_by(desc(KGEdge.valid_from))
)
for e in edges.scalars().all():
target = await db.execute(select(KGNode).where(KGNode.id == e.to_node_id))
t = target.scalar_one_or_none()
history.append({"from": n.name, "edge": e.edge_type,
"to": t.name if t else "?", "confidence": e.confidence,
"when": e.valid_from})
return {"server_id": server_id, "history": history}
@router.get("/pattern")
async def detect_patterns(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""반복 패턴 탐지 — 동일 엣지 타입 빈도 분석."""
rows = await db.execute(
select(KGEdge.edge_type, func.count(KGEdge.id).label("cnt"))
.group_by(KGEdge.edge_type).order_by(desc("cnt"))
)
patterns = [{"edge_type": r[0], "count": r[1]} for r in rows.all()]
return {"patterns": patterns, "total_edges": sum(p["count"] for p in patterns)}
@router.get("/visualization")
async def visualization_data(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""D3.js 시각화용 노드·링크 데이터."""
nodes = (await db.execute(select(KGNode).limit(50))).scalars().all()
edges = (await db.execute(select(KGEdge).limit(100))).scalars().all()
return {
"nodes": [{"id": n.id, "label": n.name, "type": n.node_type} for n in nodes],
"links": [{"source": e.from_node_id, "target": e.to_node_id,
"type": e.edge_type, "weight": e.weight} for e in edges],
}
@router.post("/auto-record")
async def auto_record_sr(
sr_id: int, server_id: int, problem: str, solution: str,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user),
):
"""SR 해결 후 지식 그래프 자동 기록."""
# 서버 노드
sv = await db.execute(select(KGNode).where(KGNode.name == f"server-{server_id}",
KGNode.node_type == "SERVER"))
sv_node = sv.scalar_one_or_none()
if not sv_node:
sv_node = KGNode(node_type="SERVER", name=f"server-{server_id}",
properties_json=json.dumps({"server_id": server_id}),
created_at=datetime.utcnow())
db.add(sv_node); await db.flush()
# 장애 노드
inc = KGNode(node_type="INCIDENT", name=f"SR-{sr_id}: {problem[:50]}",
properties_json=json.dumps({"sr_id": sr_id}), created_at=datetime.utcnow())
db.add(inc); await db.flush()
# 해결 노드
sol = KGNode(node_type="SOLUTION", name=solution[:100],
properties_json=json.dumps({"sr_id": sr_id}), created_at=datetime.utcnow())
db.add(sol); await db.flush()
# 엣지 연결
db.add(KGEdge(from_node_id=sv_node.id, to_node_id=inc.id, edge_type="AFFECTS",
weight=1.0, valid_from=datetime.utcnow(), confidence=0.8))
db.add(KGEdge(from_node_id=inc.id, to_node_id=sol.id, edge_type="SOLVED_BY",
weight=1.0, valid_from=datetime.utcnow(), confidence=0.9))
await db.commit()
return {"ok": True, "incident_node": inc.id, "solution_node": sol.id}

View File

@ -30,6 +30,7 @@ class OtlpIngest(BaseModel):
async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)): async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)):
"""OTLP HTTP 수집 엔드포인트 (인증 불필요 — 내부망 전용).""" """OTLP HTTP 수집 엔드포인트 (인증 불필요 — 내부망 전용)."""
new_traces: set = set() new_traces: set = set()
# 1단계: 트레이스 먼저 커밋 (FK 충족)
for sp in body.spans: for sp in body.spans:
if sp.trace_id not in new_traces: if sp.trace_id not in new_traces:
existing = await db.execute(select(OtelTrace).where(OtelTrace.trace_id == sp.trace_id)) existing = await db.execute(select(OtelTrace).where(OtelTrace.trace_id == sp.trace_id))
@ -38,6 +39,9 @@ async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)):
start_time=datetime.utcfromtimestamp(sp.start_time / 1000), start_time=datetime.utcfromtimestamp(sp.start_time / 1000),
created_at=datetime.utcnow())) created_at=datetime.utcnow()))
new_traces.add(sp.trace_id) new_traces.add(sp.trace_id)
await db.commit()
# 2단계: 스팬 저장
for sp in body.spans:
db.add(OtelSpan( db.add(OtelSpan(
trace_id=sp.trace_id, span_id=sp.span_id, parent_span_id=sp.parent_span_id, trace_id=sp.trace_id, span_id=sp.span_id, parent_span_id=sp.parent_span_id,
service=sp.service, operation=sp.operation, service=sp.service, operation=sp.operation,

View File

@ -132,10 +132,8 @@ async def scan_vulnerabilities(sbom_id: int, db: AsyncSession = Depends(get_db),
@router.get("/dashboard") @router.get("/dashboard")
async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute( from sqlalchemy import func as sa_func
__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id) total = (await db.execute(select(sa_func.count(SBOMRecord.id)))).scalar() or 0
if False else select(__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id))
)).scalar() or 0
return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}} return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}}

169
routers/skill_miner.py Normal file
View File

@ -0,0 +1,169 @@
"""자동 스킬 획득 — 운영 패턴 감지 + 스킬 자동 생성"""
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, MinedPattern, Skill
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/skill-miner", tags=["자동 스킬 획득"])
OLLAMA_URL = "http://localhost:11434"
async def _generate_skill_code(pattern_cmds: list[str], desc: str) -> str:
"""Ollama로 스킬 코드 자동 생성."""
try:
async with httpx.AsyncClient(timeout=20) as c:
r = await c.post(f"{OLLAMA_URL}/api/generate", json={
"model": "llama3",
"system": "Shell 명령 패턴을 보고 재사용 가능한 단일 명령으로 요약. 한 줄로만 답변.",
"prompt": f"반복 명령:\n{chr(10).join(pattern_cmds)}\n설명: {desc}",
"stream": False,
})
return r.json().get("response", "# 자동 생성 실패").strip()[:500]
except Exception as e:
return f"# 자동 생성 실패: {e}"
class PatternAnalyzeIn(BaseModel):
commands: list[str]; server_id: Optional[int] = None; context: str = ""
class PatternValidateIn(BaseModel):
pattern_id: int; approve: bool; custom_code: Optional[str] = None
@router.post("/analyze")
async def analyze_pattern(body: PatternAnalyzeIn, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
"""명령 시퀀스를 분석해 패턴 후보 등록."""
if len(body.commands) < 2:
return {"ok": False, "message": "최소 2개 명령 필요"}
# 중복 패턴 확인
cmd_key = json.dumps(sorted(body.commands))
existing = await db.execute(
select(MinedPattern).where(MinedPattern.trigger == cmd_key[:200])
)
pattern = existing.scalar_one_or_none()
if pattern:
pattern.occurrence_count = (pattern.occurrence_count or 0) + 1
pattern.last_seen = datetime.utcnow()
await db.commit()
return {"pattern_id": pattern.id, "occurrence_count": pattern.occurrence_count,
"auto_skill": pattern.occurrence_count >= 3}
# 신규 패턴 등록
pattern = MinedPattern(
pattern_type="COMMAND_SEQ",
trigger=cmd_key[:200],
action_sequence=json.dumps(body.commands),
occurrence_count=1,
last_seen=datetime.utcnow(),
status="PENDING",
context=body.context,
)
db.add(pattern); await db.commit(); await db.refresh(pattern)
# 3회 이상이면 자동 스킬 생성 제안
if pattern.occurrence_count >= 3:
background_tasks.add_task(_auto_create_skill, pattern.id, body.commands, db)
return {"pattern_id": pattern.id, "occurrence_count": 1, "auto_skill": False}
async def _auto_create_skill(pattern_id: int, cmds: list, db: AsyncSession):
"""백그라운드: 패턴 → 스킬 자동 생성."""
code = await _generate_skill_code(cmds, f"패턴 #{pattern_id}")
from sqlalchemy import update as sa_update
async with db.begin():
await db.execute(sa_update(MinedPattern).where(MinedPattern.id == pattern_id)
.values(status="SKILL_PROPOSED", generated_code=code))
@router.get("/patterns")
async def list_patterns(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(
select(MinedPattern).order_by(desc(MinedPattern.occurrence_count)).limit(30)
)
patterns = rows.scalars().all()
return [{
"id": p.id, "type": p.pattern_type, "trigger": p.trigger[:80],
"occurrence_count": p.occurrence_count, "status": p.status,
"last_seen": p.last_seen, "has_code": bool(p.generated_code),
} for p in patterns]
@router.post("/create")
async def create_from_pattern(body: PatternValidateIn, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)):
"""패턴 → 스킬 변환 (관리자 승인)."""
row = await db.execute(select(MinedPattern).where(MinedPattern.id == body.pattern_id))
pattern = row.scalar_one_or_none()
if not pattern: raise HTTPException(404)
if not body.approve:
from sqlalchemy import update as sa_update
await db.execute(sa_update(MinedPattern).where(MinedPattern.id == body.pattern_id)
.values(status="REJECTED"))
await db.commit()
return {"ok": True, "status": "REJECTED"}
code = body.custom_code or pattern.generated_code or json.loads(pattern.action_sequence or "[]")[0]
skill = Skill(
name=f"auto-{pattern.id}", description=f"자동 생성: {pattern.trigger[:50]}",
category="MINED", code_template=code, parameters_schema='{"server_id":"int"}',
created_from="MINED", success_count=0, fail_count=0, is_active=True,
created_at=datetime.utcnow(),
)
db.add(skill)
from sqlalchemy import update as sa_update
await db.execute(sa_update(MinedPattern).where(MinedPattern.id == body.pattern_id)
.values(status="CONVERTED"))
await db.commit()
return {"ok": True, "skill_id": skill.id}
@router.post("/validate")
async def validate_skill(pattern_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""패턴 스킬 검증 (테스트 실행)."""
row = await db.execute(select(MinedPattern).where(MinedPattern.id == pattern_id))
p = row.scalar_one_or_none()
if not p: raise HTTPException(404)
return {"pattern_id": pattern_id, "generated_code": p.generated_code or "없음",
"status": p.status, "validation": "수동 검토 필요"}
@router.get("/queue")
async def skill_queue(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))
)
items = rows.scalars().all()
return [{"id": p.id, "trigger": p.trigger[:60], "count": p.occurrence_count,
"status": p.status, "has_code": bool(p.generated_code)}
for p in items]
@router.post("/mine-github")
async def mine_github(repo_url: str = "anthropics/claude-plugins-official",
user: User = Depends(require_admin_role)):
"""GitHub 저장소에서 스킬 패턴 마이닝 (시뮬레이션)."""
return {
"repo": repo_url,
"status": "simulated",
"found_patterns": [
"deploy-and-verify", "rollback-on-failure", "health-check-loop"
],
"note": "실제 구현 시 GitHub API + Ollama 코드 분석 사용",
}

227
routers/skill_registry.py Normal file
View File

@ -0,0 +1,227 @@
"""GUARDiA 스킬 레지스트리 — 운영 스킬 CRUD + 검색 + 실행"""
from __future__ import annotations
import json, logging, time
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, Skill, SkillExecution
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/skills", tags=["스킬 레지스트리"])
BUILTIN_SKILLS = [
{"name": "disk_check", "description": "서버 디스크 사용량 확인", "category": "DIAGNOSTIC",
"code_template": "df -h / && du -sh /var/log/*", "parameters_schema": '{"server_id":"int"}',
"created_from": "BUILTIN"},
{"name": "service_status", "description": "주요 서비스 상태 확인", "category": "DIAGNOSTIC",
"code_template": "systemctl status {service_name}", "parameters_schema": '{"server_id":"int","service_name":"str"}',
"created_from": "BUILTIN"},
{"name": "memory_check", "description": "메모리 사용량 상세 확인", "category": "DIAGNOSTIC",
"code_template": "free -h && ps aux --sort=-%mem | head -10", "parameters_schema": '{"server_id":"int"}',
"created_from": "BUILTIN"},
{"name": "log_errors", "description": "최근 에러 로그 수집", "category": "DIAGNOSTIC",
"code_template": "journalctl -p err --no-pager -n 20", "parameters_schema": '{"server_id":"int"}',
"created_from": "BUILTIN"},
{"name": "network_check", "description": "네트워크 연결 상태 확인", "category": "NETWORK",
"code_template": "ss -tlnp && ping -c 3 {target}", "parameters_schema": '{"server_id":"int","target":"str"}',
"created_from": "BUILTIN"},
]
class SkillCreate(BaseModel):
name: str; description: str = ""; category: str = "GENERAL"
code_template: str = ""; parameters_schema: str = "{}"
created_from: str = "MANUAL"
class SkillExecuteIn(BaseModel):
server_id: int; params: dict = {}
@router.get("")
async def list_skills(
category: Optional[str] = None,
q: Optional[str] = None,
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# 내장 스킬 포함
builtins = [{"id": f"builtin-{i}", **s, "is_builtin": True,
"success_count": 0, "fail_count": 0}
for i, s in enumerate(BUILTIN_SKILLS)
if (not category or s["category"] == category)
and (not q or q.lower() in s["name"].lower() or q.lower() in s["description"].lower())]
stmt = select(Skill).where(Skill.is_active == True).order_by(desc(Skill.success_count)).limit(limit)
if category:
stmt = stmt.where(Skill.category == category)
if q:
stmt = stmt.where(Skill.name.contains(q) | Skill.description.contains(q))
rows = await db.execute(stmt)
custom = [{"id": s.id, "name": s.name, "description": s.description,
"category": s.category, "created_from": s.created_from,
"success_count": s.success_count, "fail_count": s.fail_count,
"is_builtin": False}
for s in rows.scalars().all()]
return builtins + custom
@router.post("", status_code=201)
async def create_skill(body: SkillCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
skill = Skill(
name=body.name, description=body.description, category=body.category,
code_template=body.code_template, parameters_schema=body.parameters_schema,
created_from=body.created_from, success_count=0, fail_count=0,
is_active=True, created_at=datetime.utcnow(),
)
db.add(skill); await db.commit(); await db.refresh(skill)
return {"id": skill.id}
@router.get("/search")
async def search_skills_early(q: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(Skill).where(Skill.name.contains(q) | Skill.description.contains(q)).limit(10)
)
return [{"id": s.id, "name": s.name, "category": s.category} for s in rows.scalars().all()]
@router.get("/recommend")
async def recommend_skills_early(context: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
keywords = context.lower().split()
candidates = []
for s in BUILTIN_SKILLS:
score = sum(1 for k in keywords if k in s["name"] or k in s["description"].lower())
if score > 0:
candidates.append({"skill": s["name"], "score": score, "desc": s["description"]})
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates[:5]
@router.get("/stats")
async def skill_stats_early(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.count(Skill.id)))).scalar() or 0
executions = (await db.execute(select(func.count(SkillExecution.id)))).scalar() or 0
return {"total_skills": total + len(BUILTIN_SKILLS),
"custom_skills": total, "builtin_skills": len(BUILTIN_SKILLS),
"total_executions": executions}
@router.get("/{skill_id}")
async def get_skill(skill_id, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
if str(skill_id).startswith("builtin-"):
idx = int(str(skill_id).replace("builtin-", ""))
if idx < len(BUILTIN_SKILLS):
return {**BUILTIN_SKILLS[idx], "id": skill_id, "is_builtin": True}
raise HTTPException(404)
row = await db.execute(select(Skill).where(Skill.id == int(skill_id)))
s = row.scalar_one_or_none()
if not s: raise HTTPException(404)
return {"id": s.id, "name": s.name, "description": s.description,
"category": s.category, "code_template": s.code_template,
"parameters_schema": s.parameters_schema, "success_count": s.success_count}
@router.post("/{skill_id}/execute")
async def execute_skill(skill_id, body: SkillExecuteIn, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""스킬 실행 — SSH 에이전트리스."""
# 스킬 조회
if str(skill_id).startswith("builtin-"):
idx = int(str(skill_id).replace("builtin-", ""))
code = BUILTIN_SKILLS[idx]["code_template"] if idx < len(BUILTIN_SKILLS) else ""
skill_db_id = None
else:
row = await db.execute(select(Skill).where(Skill.id == int(skill_id)))
s = row.scalar_one_or_none()
if not s: raise HTTPException(404)
code = s.code_template; skill_db_id = s.id
# 파라미터 치환
for k, v in body.params.items():
code = code.replace(f"{{{k}}}", str(v))
# SSH 실행 (에이전트리스)
start = time.time()
result = ""; success = False
try:
from routers.ssh import _exec_ssh_simple
result = await _exec_ssh_simple(body.server_id, code, db, user)
success = True
except Exception as e:
result = str(e)
elapsed = int((time.time() - start) * 1000)
# 실행 로그 저장
exec_log = SkillExecution(
skill_id=skill_db_id, input_params=json.dumps(body.params),
result=result[:500], success=success, duration_ms=elapsed,
executed_by=user.id, executed_at=datetime.utcnow(),
)
db.add(exec_log)
# 성공/실패 카운트 업데이트
if skill_db_id:
row = await db.execute(select(Skill).where(Skill.id == skill_db_id))
s = row.scalar_one_or_none()
if s:
if success: s.success_count = (s.success_count or 0) + 1
else: s.fail_count = (s.fail_count or 0) + 1
await db.commit()
return {"success": success, "result": result[:300], "duration_ms": elapsed}
@router.get("/search")
async def search_skills(q: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
rows = await db.execute(
select(Skill).where(Skill.name.contains(q) | Skill.description.contains(q)).limit(10)
)
return [{"id": s.id, "name": s.name, "category": s.category} for s in rows.scalars().all()]
@router.get("/recommend")
async def recommend_skills(context: str = "", db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
"""컨텍스트 기반 스킬 추천."""
keywords = context.lower().split()
candidates = []
for s in BUILTIN_SKILLS:
score = sum(1 for k in keywords if k in s["name"] or k in s["description"].lower())
if score > 0:
candidates.append({"skill": s["name"], "score": score, "desc": s["description"]})
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates[:5]
@router.post("/{skill_id}/feedback")
async def skill_feedback(skill_id: int, success: bool, notes: str = "",
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user)):
row = await db.execute(select(Skill).where(Skill.id == skill_id))
s = row.scalar_one_or_none()
if not s: raise HTTPException(404)
if success: s.success_count = (s.success_count or 0) + 1
else: s.fail_count = (s.fail_count or 0) + 1
await db.commit()
return {"ok": True}
@router.get("/stats")
async def skill_stats(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
total = (await db.execute(select(func.count(Skill.id)))).scalar() or 0
executions = (await db.execute(select(func.count(SkillExecution.id)))).scalar() or 0
return {"total_skills": total + len(BUILTIN_SKILLS),
"custom_skills": total, "builtin_skills": len(BUILTIN_SKILLS),
"total_executions": executions}

View File

@ -115,7 +115,7 @@ async def list_rules(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
rows = await db.execute( rows = await db.execute(
select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id) select(SmartNotifyRule)
.order_by(SmartNotifyRule.name) .order_by(SmartNotifyRule.name)
) )
rules = rows.scalars().all() rules = rows.scalars().all()
@ -139,7 +139,7 @@ async def create_rule(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
rule = SmartNotifyRule( rule = SmartNotifyRule(
tenant_id=user.tenant_id,
name=req.name, name=req.name,
trigger_type=req.trigger_type, trigger_type=req.trigger_type,
conditions=req.conditions, conditions=req.conditions,
@ -165,7 +165,7 @@ async def update_rule(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
row = await db.execute( row = await db.execute(
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0)
) )
rule = row.scalar_one_or_none() rule = row.scalar_one_or_none()
if not rule: if not rule:
@ -186,7 +186,7 @@ async def delete_rule(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
row = await db.execute( row = await db.execute(
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0)
) )
rule = row.scalar_one_or_none() rule = row.scalar_one_or_none()
if not rule: raise HTTPException(404) if not rule: raise HTTPException(404)
@ -203,7 +203,7 @@ async def test_rule(
): ):
"""테스트 알림 발송.""" """테스트 알림 발송."""
row = await db.execute( row = await db.execute(
select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.tenant_id == user.tenant_id) select(SmartNotifyRule).where(SmartNotifyRule.id == rule_id, SmartNotifyRule.id > 0)
) )
rule = row.scalar_one_or_none() rule = row.scalar_one_or_none()
if not rule: raise HTTPException(404) if not rule: raise HTTPException(404)
@ -231,7 +231,7 @@ async def notify_logs(
rows = await db.execute( rows = await db.execute(
select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join( select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join(
SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True
).where(SmartNotifyRule.tenant_id == user.tenant_id) )
.order_by(desc(NotifyLog.sent_at)).limit(limit) .order_by(desc(NotifyLog.sent_at)).limit(limit)
) )
return [ return [
@ -249,7 +249,7 @@ async def set_silence(
user: User = Depends(require_admin_role), user: User = Depends(require_admin_role),
): ):
"""무음 시간대 설정.""" """무음 시간대 설정."""
q = select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id) q = select(SmartNotifyRule)
if req.rule_id: if req.rule_id:
q = q.where(SmartNotifyRule.id == req.rule_id) q = q.where(SmartNotifyRule.id == req.rule_id)
rows = await db.execute(q) rows = await db.execute(q)

View File

@ -62,7 +62,10 @@ async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends
@router.post("/policies", status_code=201) @router.post("/policies", status_code=201)
async def create_policy(body: PolicyCreate, db: AsyncSession = Depends(get_db), async def create_policy(body: PolicyCreate, db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role)): user: User = Depends(require_admin_role)):
p = ZTNAPolicy(**body.model_dump(), created_by=user.id, created_at=datetime.utcnow()) data = body.model_dump()
data['allowed_roles'] = json.dumps(data.get('allowed_roles', []))
data['allowed_ips'] = json.dumps(data.get('allowed_ips', []))
p = ZTNAPolicy(**data, created_by=user.id, created_at=datetime.utcnow())
db.add(p); await db.commit(); await db.refresh(p) db.add(p); await db.commit(); await db.refresh(p)
return {"id": p.id} return {"id": p.id}
@ -107,12 +110,15 @@ async def verify_access(body: VerifyRequest, db: AsyncSession = Depends(get_db),
allowed = len(reasons) == 0 allowed = len(reasons) == 0
if not allowed: if not allowed:
try:
db.add(ZTNAViolation( db.add(ZTNAViolation(
user_id=body.user_id, resource=body.resource, user_id=body.user_id, resource=body.resource,
reason=", ".join(reasons), source_ip=body.source_ip, reason=", ".join(reasons), source_ip=body.source_ip or "",
trust_score=trust_score, created_at=datetime.utcnow() trust_score=trust_score, created_at=datetime.utcnow()
)) ))
await db.commit() await db.commit()
except Exception:
await db.rollback()
return {"allowed": allowed, "trust_score": trust_score, return {"allowed": allowed, "trust_score": trust_score,
"reasons": reasons, "policy": policy.name} "reasons": reasons, "policy": policy.name}

View File

@ -367,6 +367,14 @@ function renderCurrentView() {
else if (currentView === "batch_ssh") renderBatchSsh(); else if (currentView === "batch_ssh") renderBatchSsh();
else if (currentView === "asset_qr") renderAssetQr(); else if (currentView === "asset_qr") renderAssetQr();
else if (currentView === "notification_rules") renderNotificationRules(); else if (currentView === "notification_rules") renderNotificationRules();
// ── GUARDiA Brain 뷰 ──
else if (currentView === "brain_dashboard") renderBrainDashboard();
else if (currentView === "ai_memory") renderAiMemory();
else if (currentView === "knowledge_graph_view") renderKnowledgeGraph();
else if (currentView === "skill_registry_view") renderSkillRegistry();
else if (currentView === "skill_miner_view") renderSkillMiner();
else if (currentView === "finetune_view") renderFinetune();
else if (currentView === "brain_plugins") renderBrainPlugins();
// ── GUARDiA 차세대 확장 뷰 ── // ── GUARDiA 차세대 확장 뷰 ──
else if (currentView === "agentic_aiops") renderAgenticAiops(); else if (currentView === "agentic_aiops") renderAgenticAiops();
else if (currentView === "auto_remediation_v2") renderAutoRemediation(); else if (currentView === "auto_remediation_v2") renderAutoRemediation();
@ -4454,3 +4462,302 @@ async function scheduleJob() {
const d=await r.json(); const d=await r.json();
showToast(`스케줄 등록: ${d.preferred_hour}시 (탄소 ${d.carbon_factor} kgCO₂e/kWh)`,"success"); showToast(`스케줄 등록: ${d.preferred_hour}시 (탄소 ${d.carbon_factor} kgCO₂e/kWh)`,"success");
} }
// ══════════════════════════════════════════════════════════════════════════════
// ── GUARDiA Brain — AI 지능화 엔진 뷰
// ══════════════════════════════════════════════════════════════════════════════
function renderBrainDashboard() {
document.getElementById("content").innerHTML = `
<h2>🧠 GUARDiA Brain AI 지능화 엔진</h2>
<p style="color:#64748b;margin-bottom:16px">운영 경험에서 스스로 배우고 진화하는 AI 엔진. 영구 메모리·자동 스킬·LoRA 파인튜닝.</p>
<div id="brain-stats" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">로딩 ...</div>
${_nextCard("AI 엔진 헬스","💚","<div id=\"brain-health\">로딩 중...</div>")}
${_nextCard("Ollama 모델","🤖","<div id=\"brain-models\">로딩 중...</div>")}
${_nextCard("AI 개선 제안","💡","<div id=\"brain-suggestions\">로딩 중...</div>")}`;
loadBrainDashboard();
}
async function loadBrainDashboard() {
const t = localStorage.getItem("token")||"";
const H = {Authorization:`Bearer ${t}`};
const [dash, health, models, sugg] = await Promise.all([
fetch("/api/brain/dashboard",{headers:H}).then(r=>r.json()).catch(()=>({})),
fetch("/api/brain/health",{headers:H}).then(r=>r.json()).catch(()=>({})),
fetch("/api/brain/models",{headers:H}).then(r=>r.json()).catch(()=>[]),
fetch("/api/brain/observe/suggestions",{headers:H}).then(r=>r.json()).catch(()=>[]),
]);
const se = document.getElementById("brain-stats");
if(se) se.innerHTML = [
{icon:"🧠",label:"총 기억",val:dash.memory?.total||0},
{icon:"⚡",label:"스킬 수",val:dash.skills?.total||0},
{icon:"📊",label:"학습 샘플",val:dash.learning?.feedback_samples||0},
{icon:"🕸️",label:"KG 노드",val:dash.knowledge_graph?.nodes||0},
{icon:"🎯",label:"뇌 점수",val:dash.brain_score||0},
{icon:"🤖",label:"모델 수",val:(dash.ollama_models||[]).length},
].map(s=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px;text-align:center">
<div style="font-size:22px">${s.icon}</div>
<div style="font-size:22px;font-weight:700;color:#003366">${s.val}</div>
<div style="font-size:12px;color:#64748b">${s.label}</div>
</div>`).join("");
const he = document.getElementById("brain-health");
if(he) he.innerHTML = Object.entries(health.checks||{}).map(([k,v])=>
`<div>${v==="OK"?"✅":"❌"} ${k}: ${v}</div>`).join("");
const me = document.getElementById("brain-models");
if(me) me.innerHTML = (models||[]).map(m=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:13px">
<strong>${m.name}</strong> <span style="color:#64748b">${m.size_gb}GB</span>
</div>`).join("") || "<p style=\"color:#94a3b8\"> </p>";
const sge = document.getElementById("brain-suggestions");
if(sge) sge.innerHTML = (sugg||[]).map(s=>`<div style="padding:8px;border-left:3px solid #003366;margin-bottom:6px;font-size:12px">
<strong>[${s.type}]</strong> ${s.action}<br><span style="color:#64748b">${s.benefit}</span>
</div>`).join("") || "<p style=\"color:#94a3b8\"> </p>";
}
function renderAiMemory() {
document.getElementById("content").innerHTML = `
<h2>🧠 영구 메모리</h2>
<p style="color:#64748b;margin-bottom:16px">세션을 넘어 지속되는 AI 운영 경험 저장소.</p>
${_nextCard("기억 저장","💾",`
<select id="mem-type" class="form-control" style="margin-bottom:8px">
<option value="EPISODIC">에피소딕 (특정 사건)</option>
<option value="SEMANTIC">시맨틱 (일반 지식)</option>
<option value="PROCEDURAL">절차적 (해결 방법)</option>
</select>
<textarea id="mem-content" class="form-control" rows="3" placeholder="기억할 내용 (예: 서버1 OOM 발생 시 JVM 힙 증가로 해결)"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="saveMemory()">💾 저장</button>
`)}
${_nextCard("기억 검색","🔍",`
<div style="display:flex;gap:8px">
<input id="mem-query" class="form-control" placeholder="검색어 (예: 서버 OOM)">
<button class="btn btn-primary" onclick="searchMemory()">검색</button>
</div>
<div id="mem-results" style="margin-top:10px"></div>
`)}
${_nextCard("현황","📊","<div id=\"mem-stats\">로딩 중...</div>")}`;
loadMemStats();
}
async function saveMemory() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/memory/remember",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({content:document.getElementById("mem-content").value,
memory_type:document.getElementById("mem-type").value})});
const d = await r.json();
showToast(`기억 저장 완료 (ID: ${d.memory_id})`,"success"); loadMemStats();
}
async function searchMemory() {
const t = localStorage.getItem("token")||"";
const q = document.getElementById("mem-query").value;
if(!q) return;
const results = await fetch(`/api/memory/recall?q=${encodeURIComponent(q)}&limit=5`,
{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
document.getElementById("mem-results").innerHTML = results.length
? results.map(m=>`<div style="padding:10px;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:6px">
<div style="font-size:12px;color:#64748b">[${m.type}] 유사도: ${(m.similarity*100).toFixed(1)}%</div>
<div style="font-size:13px">${m.content}</div>
</div>`).join("") : "<p style=\"color:#94a3b8\"> </p>";
}
async function loadMemStats() {
const t = localStorage.getItem("token")||"";
const s = await fetch("/api/memory/stats",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({}));
const el = document.getElementById("mem-stats");
if(el) el.innerHTML = `${s.total_memories||0}개 | 에피소딕: ${s.by_type?.EPISODIC||0} | 시맨틱: ${s.by_type?.SEMANTIC||0} | 절차적: ${s.by_type?.PROCEDURAL||0}`;
}
function renderKnowledgeGraph() {
document.getElementById("content").innerHTML = `
<h2>🕸 운영 지식 그래프</h2>
<p style="color:#64748b;margin-bottom:16px">서버-장애-해결책의 시간적 관계 그래프.</p>
${_nextCard("SR 자동 기록","📝",`
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
<input id="kg-sr-id" type="number" class="form-control" placeholder="SR ID">
<input id="kg-srv-id" type="number" class="form-control" placeholder="서버 ID">
</div>
<input id="kg-problem" class="form-control" placeholder="문제" style="margin-bottom:6px">
<input id="kg-solution" class="form-control" placeholder="해결책">
<button class="btn btn-primary" style="margin-top:8px" onclick="recordKG()">📝 기록</button>
`)}
${_nextCard("패턴","📊","<div id=\"kg-patterns\">로딩 중...</div>")}
${_nextCard("시각화","🗺️","<div id=\"kg-viz\">로딩 중...</div>")}`;
loadKGData();
}
async function recordKG() {
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/kg/auto-record?sr_id=${document.getElementById("kg-sr-id").value}&server_id=${document.getElementById("kg-srv-id").value}&problem=${encodeURIComponent(document.getElementById("kg-problem").value)}&solution=${encodeURIComponent(document.getElementById("kg-solution").value)}`,
{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
showToast(`KG 기록 완료`,"success"); loadKGData();
}
async function loadKGData() {
const t = localStorage.getItem("token")||"";
const [pat, viz] = await Promise.all([
fetch("/api/kg/pattern",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/kg/visualization",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
]);
const pe = document.getElementById("kg-patterns");
if(pe) pe.innerHTML = (pat.patterns||[]).map(p=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:13px">${p.edge_type}: ${p.count}건</div>`).join("") || "<p style=\"color:#94a3b8\">패턴 없음</p>";
const ve = document.getElementById("kg-viz");
if(ve) ve.innerHTML = `노드 ${(viz.nodes||[]).length}개 | 엣지 ${(viz.links||[]).length}`;
}
function renderSkillRegistry() {
document.getElementById("content").innerHTML = `
<h2> 스킬 레지스트리</h2>
<p style="color:#64748b;margin-bottom:16px">운영 자동화 스킬 목록. 1클릭 실행.</p>
${_nextCard("스킬 목록","📋","<div id=\"skills-list\">로딩 중...</div>")}
${_nextCard("스킬 실행","▶️",`
<input id="skill-exec-id" class="form-control" placeholder="스킬 ID (예: builtin-0)" style="margin-bottom:8px">
<input id="skill-server-id" type="number" class="form-control" placeholder="서버 ID" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="executeSkill()"> 실행</button>
<div id="skill-exec-result" style="margin-top:10px"></div>
`)}`;
loadSkillsList();
}
async function loadSkillsList() {
const t = localStorage.getItem("token")||"";
const skills = await fetch("/api/skills",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el = document.getElementById("skills-list");
if(!el) return;
el.innerHTML = skills.map(s=>`<div style="padding:10px;border-bottom:1px solid #f1f5f9;font-size:13px;display:flex;justify-content:space-between">
<div><strong>${s.name}</strong> <span style="color:#64748b">${s.description||""}</span>
${s.is_builtin?"<span style=\"margin-left:6px;background:#eff6ff;color:#1d4ed8;font-size:10px;padding:1px 5px;border-radius:6px\">내장</span>":""}
</div>
<div style="font-size:11px;color:#64748b">${s.success_count||0} ${s.fail_count||0}</div>
</div>`).join("");
}
async function executeSkill() {
const t = localStorage.getItem("token")||"";
const id = document.getElementById("skill-exec-id").value;
const server_id = +document.getElementById("skill-server-id").value;
if(!id||!server_id) return showToast("스킬 ID와 서버 ID 입력","error");
showToast("스킬 실행 중...","info");
const r = await fetch(`/api/skills/${id}/execute`,{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({server_id})});
const d = await r.json();
document.getElementById("skill-exec-result").innerHTML = `<div style="padding:10px;border:1px solid ${d.success?"#bbf7d0":"#fca5a5"};border-radius:8px;font-size:12px">
${d.success?"✅ 성공":"❌ 실패"} (${d.duration_ms}ms)<br>
<pre style="font-size:11px;white-space:pre-wrap">${d.result||""}</pre>
</div>`;
}
function renderSkillMiner() {
document.getElementById("content").innerHTML = `
<h2>🔍 자동 스킬 발굴</h2>
<p style="color:#64748b;margin-bottom:16px">반복 명령 패턴 감지 자동 스킬화.</p>
${_nextCard("패턴 분석","📊",`
<textarea id="miner-cmds" class="form-control" rows="3" placeholder="반복 명령 (줄바꿈 구분)&#10;df -h /&#10;du -sh /var/log/*"></textarea>
<button class="btn btn-primary" style="margin-top:8px" onclick="analyzePattern()">📊 분석</button>
<div id="miner-result" style="margin-top:8px"></div>
`)}
${_nextCard("스킬화 대기","⏳","<div id=\"miner-queue\">로딩 중...</div>")}`;
loadMinerQueue();
}
async function analyzePattern() {
const t = localStorage.getItem("token")||"";
const cmds = document.getElementById("miner-cmds").value.split("\n").filter(c=>c.trim());
if(cmds.length < 2) return showToast("최소 2개 명령 필요","error");
const r = await fetch("/api/skill-miner/analyze",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({commands:cmds})});
const d = await r.json();
document.getElementById("miner-result").innerHTML = `<div style="padding:10px;border:1px solid #e2e8f0;border-radius:8px;font-size:13px">
패턴 ID: ${d.pattern_id} | 발생: ${d.occurrence_count}
${d.auto_skill?"<span style=\"color:#166534;margin-left:8px\">🎉 스킬 자동 생성!</span>":"<span style=\"color:#64748b;margin-left:8px\">3회 시 자동 생성</span>"}
</div>`;
loadMinerQueue();
}
async function loadMinerQueue() {
const t = localStorage.getItem("token")||"";
const q = await fetch("/api/skill-miner/queue",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]);
const el = document.getElementById("miner-queue");
if(!el) return;
el.innerHTML = q.length ? q.map(p=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
[${p.count}] ${p.trigger} ${p.status==="SKILL_PROPOSED"?"✅ 스킬 제안":"⏳ 분석 중"}
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px;text-align:center\"> </p>";
}
function renderFinetune() {
document.getElementById("content").innerHTML = `
<h2>🎓 LoRA 파인튜닝</h2>
<p style="color:#64748b;margin-bottom:16px">운영 데이터로 Ollama 모델 특화 학습.</p>
${_nextCard("피드백 수집","📥",`
<input id="ft-q" class="form-control" placeholder="질문" style="margin-bottom:6px">
<textarea id="ft-approved" class="form-control" rows="2" placeholder="전문가 승인 답변" style="margin-bottom:6px"></textarea>
<select id="ft-domain" class="form-control" style="margin-bottom:8px">
<option value="general">일반</option><option value="incident"></option>
<option value="deploy">배포</option><option value="security"></option>
</select>
<button class="btn btn-primary" onclick="addFeedback()">📥 추가</button>
`)}
${_nextCard("데이터 품질","📊","<div id=\"ft-quality\">로딩 중...</div>")}
${_nextCard("파인튜닝 작업","🎓",`<div id="ft-jobs">로딩 중...</div>
<button class="btn btn-primary" style="margin-top:8px" onclick="startFinetune()">🎓 파인튜닝 시작</button>`)}`;
loadFtData();
}
async function addFeedback() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/finetune/feedback",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({question:document.getElementById("ft-q").value,
ollama_response:"",approved_answer:document.getElementById("ft-approved").value,
domain:document.getElementById("ft-domain").value})});
const d = await r.json();
showToast(d.ok===false ? d.message : `학습 데이터 추가 (총 ${d.total_samples}개)`,"success");
loadFtData();
}
async function startFinetune() {
const t = localStorage.getItem("token")||"";
const r = await fetch("/api/finetune/start",{method:"POST",
headers:{Authorization:`Bearer ${t}`,"Content-Type":"application/json"},
body:JSON.stringify({base_model:"llama3",epochs:3,dataset_min:50})});
const d = await r.json();
if(d.ok===false) return showToast(d.message,"error");
showToast(`파인튜닝 시작 (Job ID: ${d.job_id})`,"success"); loadFtData();
}
async function loadFtData() {
const t = localStorage.getItem("token")||"";
const [q, jobs] = await Promise.all([
fetch("/api/finetune/quality",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>({})),
fetch("/api/finetune/jobs",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const qe = document.getElementById("ft-quality");
if(qe) qe.innerHTML = `${q.total_samples||0}개 | 고품질: ${q.high_quality||0}개 (${q.quality_rate||0}%) | ${q.ready_for_training?"✅ 파인튜닝 가능":"⚠️ 50개 이상 필요"}`;
const je = document.getElementById("ft-jobs");
if(je) je.innerHTML = (jobs||[]).slice(0,5).map(j=>`<div style="padding:6px;border-bottom:1px solid #f1f5f9;font-size:12px">
#${j.id} ${j.base_model} [${j.status}] ${j.dataset_size}
</div>`).join("") || "<p style=\"color:#94a3b8\"> </p>";
}
function renderBrainPlugins() {
document.getElementById("content").innerHTML = `
<h2>🔌 플러그인 관리</h2>
<p style="color:#64748b;margin-bottom:16px">Claude Marketplace AI 역량 강화 플러그인.</p>
${_nextCard("사용 가능","📦","<div id=\"plugins-available\">로딩 중...</div>")}
${_nextCard("설치됨","✅","<div id=\"plugins-installed\">로딩 중...</div>")}`;
loadPlugins();
}
async function loadPlugins() {
const t = localStorage.getItem("token")||"";
const [avail, installed] = await Promise.all([
fetch("/api/brain/plugins/available",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
fetch("/api/brain/plugins/installed",{headers:{Authorization:`Bearer ${t}`}}).then(r=>r.json()).catch(()=>[]),
]);
const ae = document.getElementById("plugins-available");
if(ae) ae.innerHTML = avail.map(p=>`<div style="border:1px solid #e2e8f0;border-radius:8px;padding:12px;margin-bottom:8px">
<div style="font-weight:700;margin-bottom:4px">${p.name} <span style="color:#64748b;font-size:11px">[${p.category}]</span></div>
<div style="font-size:12px;color:#64748b;margin-bottom:6px">${p.description}</div>
<button onclick="installPlugin('${p.name}')" style="padding:4px 10px;background:#003366;color:#fff;border:none;border-radius:4px;font-size:11px;cursor:pointer">설치</button>
</div>`).join("");
const ie = document.getElementById("plugins-installed");
if(ie) ie.innerHTML = installed.length ? installed.map(p=>`<div style="padding:8px;border-bottom:1px solid #f1f5f9;font-size:13px">
${p.name}
</div>`).join("") : "<p style=\"color:#94a3b8;padding:12px\"> </p>";
}
async function installPlugin(name) {
const t = localStorage.getItem("token")||"";
const r = await fetch(`/api/brain/plugins/install?plugin_name=${name}`,{method:"POST",headers:{Authorization:`Bearer ${t}`}});
const d = await r.json();
showToast(d.ok ? `${name} 설치됨` : (d.message||"이미 설치됨"), d.ok?"success":"info");
if(d.ok) loadPlugins();
}

View File

@ -211,6 +211,23 @@
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</div> <div class="nav-sub-item" data-view="white_label">브랜딩 설정</div>
</div> </div>
<!-- ── GUARDiA Brain — AI 지능화 엔진 ──────────── -->
<div class="nav-separator"></div>
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="true">
<span class="nav-icon">🧠</span><span>AI 뇌 엔진</span>
<span class="nav-arrow" aria-hidden="true"></span>
</div>
<div class="nav-group-body" role="group" style="display:block">
<div class="nav-sub-item" data-view="brain_dashboard">AI 엔진 대시보드</div>
<div class="nav-sub-item" data-view="ai_memory">영구 메모리</div>
<div class="nav-sub-item" data-view="knowledge_graph_view">지식 그래프</div>
<div class="nav-sub-item" data-view="skill_registry_view">스킬 레지스트리</div>
<div class="nav-sub-item" data-view="skill_miner_view">자동 스킬 발굴</div>
<div class="nav-sub-item" data-view="finetune_view">LoRA 파인튜닝</div>
<div class="nav-sub-item" data-view="brain_plugins">플러그인 관리</div>
</div>
<!-- ── GUARDiA 차세대 확장 ───────────────────── --> <!-- ── GUARDiA 차세대 확장 ───────────────────── -->
<div class="nav-separator"></div> <div class="nav-separator"></div>