sync: update from workspace (latest ITSM/CICD/DR changes)
This commit is contained in:
parent
5f3a0247b3
commit
7d092126eb
14
main.py
14
main.py
@ -410,6 +410,20 @@ app.include_router(greenops.router) # 탄소 배출 추적
|
||||
app.include_router(edge_monitor.router) # Edge/IoT 모니터링
|
||||
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")
|
||||
|
||||
180
models.py
180
models.py
@ -5899,3 +5899,183 @@ class EdgeAlert(Base):
|
||||
message = Column(Text, nullable=True)
|
||||
severity = Column(String(20), default="WARNING")
|
||||
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
195
routers/agent_memory.py
Normal 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
240
routers/ai_dashboard.py
Normal 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()]
|
||||
@ -125,7 +125,7 @@ async def scan_qr(
|
||||
|
||||
return {
|
||||
"server_id": server.id,
|
||||
"hostname": server.hostname or "미설정",
|
||||
"hostname": server.server_name or "미설정",
|
||||
"ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹
|
||||
"os_type": server.os_type or "미상",
|
||||
"cpu_cores": server.cpu_cores,
|
||||
@ -185,7 +185,7 @@ async def print_label(
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>QR 라벨 — {server.hostname}</title>
|
||||
<title>QR 라벨 — {server.server_name}</title>
|
||||
<style>
|
||||
@page {{ size: 50mm 30mm; margin: 1mm }}
|
||||
body {{ font-family: sans-serif; margin: 0; padding: 2mm }}
|
||||
@ -202,7 +202,7 @@ async def print_label(
|
||||
<div class="label">
|
||||
<div class="qr">{qr_img}</div>
|
||||
<div class="info">
|
||||
<div class="host">{server.hostname or '미설정'}</div>
|
||||
<div class="host">{server.server_name or '미설정'}</div>
|
||||
<div>ID: {server.id}</div>
|
||||
<div>{server.os_type or ''}</div>
|
||||
<div>GUARDiA ITSM</div>
|
||||
@ -236,7 +236,7 @@ async def batch_print(
|
||||
<div class="label">
|
||||
<div class="qr">{qr_img}</div>
|
||||
<div class="info">
|
||||
<div class="host">{server.hostname or '미설정'}</div>
|
||||
<div class="host">{server.server_name or '미설정'}</div>
|
||||
<div>ID: {server.id}</div>
|
||||
<div>{server.os_type or ''}</div>
|
||||
</div>
|
||||
@ -268,14 +268,14 @@ async def list_qr_tokens(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
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
|
||||
).order_by(desc(AssetQRToken.created_at)).limit(100)
|
||||
)
|
||||
return [
|
||||
{
|
||||
"server_id": r.AssetQRToken.server_id,
|
||||
"hostname": r.hostname or "미설정",
|
||||
"hostname": r.server_name or "미설정",
|
||||
"ip": r.ip_addr,
|
||||
"scan_count": r.AssetQRToken.scan_count,
|
||||
"last_scan": r.AssetQRToken.last_scan_at,
|
||||
|
||||
199
routers/finetune_pipeline.py
Normal file
199
routers/finetune_pipeline.py
Normal 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}
|
||||
@ -88,11 +88,18 @@ async def emission_trend(months: int = 6, db: AsyncSession = Depends(get_db),
|
||||
@router.post("/baseline")
|
||||
async def set_baseline(body: BaselineSet, db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user)):
|
||||
cfg = GreenOpsConfig(
|
||||
server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue,
|
||||
note=body.note, set_by=user.id, created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(cfg); await db.commit(); await db.refresh(cfg)
|
||||
# 기존 베이스라인 있으면 업데이트 (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(
|
||||
server_id=body.server_id, watt_baseline=body.watt_avg, pue=body.pue,
|
||||
note=body.note, set_by=user.id, created_at=datetime.utcnow()
|
||||
)
|
||||
db.add(cfg)
|
||||
await db.commit(); await db.refresh(cfg)
|
||||
baseline_carbon = _calc_carbon(body.watt_avg, 24 * 30, body.pue)
|
||||
return {"config_id": cfg.id, "monthly_carbon_kg": baseline_carbon,
|
||||
"annual_carbon_ton": round(baseline_carbon * 12 / 1000, 3)}
|
||||
|
||||
@ -122,3 +122,8 @@ async def search(q: str = "", db: AsyncSession = Depends(get_db),
|
||||
rows = await db.execute(stmt)
|
||||
return [{"id":c.id,"name":c.name,"type":c.component_type,"language":c.language}
|
||||
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
170
routers/knowledge_graph.py
Normal 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}
|
||||
@ -30,6 +30,7 @@ class OtlpIngest(BaseModel):
|
||||
async def ingest_spans(body: OtlpIngest, db: AsyncSession = Depends(get_db)):
|
||||
"""OTLP HTTP 수집 엔드포인트 (인증 불필요 — 내부망 전용)."""
|
||||
new_traces: set = set()
|
||||
# 1단계: 트레이스 먼저 커밋 (FK 충족)
|
||||
for sp in body.spans:
|
||||
if sp.trace_id not in new_traces:
|
||||
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),
|
||||
created_at=datetime.utcnow()))
|
||||
new_traces.add(sp.trace_id)
|
||||
await db.commit()
|
||||
# 2단계: 스팬 저장
|
||||
for sp in body.spans:
|
||||
db.add(OtelSpan(
|
||||
trace_id=sp.trace_id, span_id=sp.span_id, parent_span_id=sp.parent_span_id,
|
||||
service=sp.service, operation=sp.operation,
|
||||
|
||||
@ -132,10 +132,8 @@ async def scan_vulnerabilities(sbom_id: int, db: AsyncSession = Depends(get_db),
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def sbom_dashboard(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
total = (await db.execute(
|
||||
__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id)
|
||||
if False else select(__import__('sqlalchemy', fromlist=['func']).func.count(SBOMRecord.id))
|
||||
)).scalar() or 0
|
||||
from sqlalchemy import func as sa_func
|
||||
total = (await db.execute(select(sa_func.count(SBOMRecord.id)))).scalar() or 0
|
||||
return {"total_sboms": total, "format_breakdown": {"CycloneDX": total}}
|
||||
|
||||
|
||||
|
||||
169
routers/skill_miner.py
Normal file
169
routers/skill_miner.py
Normal 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
227
routers/skill_registry.py
Normal 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}
|
||||
@ -115,7 +115,7 @@ async def list_rules(
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
rows = await db.execute(
|
||||
select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
select(SmartNotifyRule)
|
||||
.order_by(SmartNotifyRule.name)
|
||||
)
|
||||
rules = rows.scalars().all()
|
||||
@ -139,7 +139,7 @@ async def create_rule(
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
rule = SmartNotifyRule(
|
||||
tenant_id=user.tenant_id,
|
||||
|
||||
name=req.name,
|
||||
trigger_type=req.trigger_type,
|
||||
conditions=req.conditions,
|
||||
@ -165,7 +165,7 @@ async def update_rule(
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
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()
|
||||
if not rule:
|
||||
@ -186,7 +186,7 @@ async def delete_rule(
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
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()
|
||||
if not rule: raise HTTPException(404)
|
||||
@ -203,7 +203,7 @@ async def test_rule(
|
||||
):
|
||||
"""테스트 알림 발송."""
|
||||
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()
|
||||
if not rule: raise HTTPException(404)
|
||||
@ -231,7 +231,7 @@ async def notify_logs(
|
||||
rows = await db.execute(
|
||||
select(NotifyLog, SmartNotifyRule.name.label("rule_name")).join(
|
||||
SmartNotifyRule, NotifyLog.rule_id == SmartNotifyRule.id, isouter=True
|
||||
).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
)
|
||||
.order_by(desc(NotifyLog.sent_at)).limit(limit)
|
||||
)
|
||||
return [
|
||||
@ -249,7 +249,7 @@ async def set_silence(
|
||||
user: User = Depends(require_admin_role),
|
||||
):
|
||||
"""무음 시간대 설정."""
|
||||
q = select(SmartNotifyRule).where(SmartNotifyRule.tenant_id == user.tenant_id)
|
||||
q = select(SmartNotifyRule)
|
||||
if req.rule_id:
|
||||
q = q.where(SmartNotifyRule.id == req.rule_id)
|
||||
rows = await db.execute(q)
|
||||
|
||||
@ -62,7 +62,10 @@ async def list_policies(db: AsyncSession = Depends(get_db), user: User = Depends
|
||||
@router.post("/policies", status_code=201)
|
||||
async def create_policy(body: PolicyCreate, db: AsyncSession = Depends(get_db),
|
||||
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)
|
||||
return {"id": p.id}
|
||||
|
||||
@ -107,12 +110,15 @@ async def verify_access(body: VerifyRequest, db: AsyncSession = Depends(get_db),
|
||||
allowed = len(reasons) == 0
|
||||
|
||||
if not allowed:
|
||||
db.add(ZTNAViolation(
|
||||
user_id=body.user_id, resource=body.resource,
|
||||
reason=", ".join(reasons), source_ip=body.source_ip,
|
||||
trust_score=trust_score, created_at=datetime.utcnow()
|
||||
))
|
||||
await db.commit()
|
||||
try:
|
||||
db.add(ZTNAViolation(
|
||||
user_id=body.user_id, resource=body.resource,
|
||||
reason=", ".join(reasons), source_ip=body.source_ip or "",
|
||||
trust_score=trust_score, created_at=datetime.utcnow()
|
||||
))
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
|
||||
return {"allowed": allowed, "trust_score": trust_score,
|
||||
"reasons": reasons, "policy": policy.name}
|
||||
|
||||
307
static/app.js
307
static/app.js
@ -367,6 +367,14 @@ function renderCurrentView() {
|
||||
else if (currentView === "batch_ssh") renderBatchSsh();
|
||||
else if (currentView === "asset_qr") renderAssetQr();
|
||||
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 차세대 확장 뷰 ──
|
||||
else if (currentView === "agentic_aiops") renderAgenticAiops();
|
||||
else if (currentView === "auto_remediation_v2") renderAutoRemediation();
|
||||
@ -4454,3 +4462,302 @@ async function scheduleJob() {
|
||||
const d=await r.json();
|
||||
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="반복 명령 (줄바꿈 구분) df -h / 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();
|
||||
}
|
||||
|
||||
@ -211,6 +211,23 @@
|
||||
<div class="nav-sub-item" data-view="white_label">브랜딩 설정</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 차세대 확장 ───────────────────── -->
|
||||
<div class="nav-separator"></div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user