guardia-itsm/routers/op_assistant.py
2026-06-02 18:48:18 +09:00

209 lines
6.5 KiB
Python

"""
대화형 운영 어시스턴트 — Multi-turn 컨텍스트 유지
여러 번의 대화로 복잡한 운영 쿼리를 처리한다.
이전 대화 내용을 기억하고 후속 질문에 맥락 있게 답변.
엔드포인트:
POST /api/assistant/chat — 대화 메시지 전송
GET /api/assistant/sessions — 대화 세션 목록
GET /api/assistant/sessions/{id} — 특정 세션 대화 이력
DELETE /api/assistant/sessions/{id} — 세션 종료
POST /api/assistant/quick — 빠른 질의 (단일 턴)
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, AssistantSession, AssistantMessage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/assistant", tags=["운영 어시스턴트"])
OLLAMA_URL = "http://localhost:11434"
CHAT_MODEL = "llama3"
MAX_HISTORY = 10 # 컨텍스트에 포함할 최근 메시지 수
SYSTEM_PROMPT = """당신은 GUARDiA ITSM 운영 전문 어시스턴트입니다.
역할:
- IT 운영 문제 진단 및 해결 방안 제시
- SR·인시던트·KPI 데이터 분석 및 해석
- 장애 예방을 위한 선제적 권고
- 공공기관 IT 운영 모범 사례 안내
원칙:
- 온프레미스 환경 우선 (외부 클라우드 API 제안 금지)
- 에이전트리스 접근 권장 (SSH/SFTP만 사용)
- 보안 정보(IP, 비밀번호) 응답에 포함 금지
- 한국어로만 답변
- 3~5문장으로 간결하게"""
class ChatRequest(BaseModel):
session_id: Optional[int] = None # None이면 새 세션 생성
message: str = Field(..., min_length=1, max_length=2000)
context_data: Optional[dict] = None # 추가 컨텍스트 (SR ID, 서버명 등)
async def _call_ollama(messages: list[dict]) -> str:
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
"model": CHAT_MODEL,
"messages": messages,
"stream": False,
})
if r.status_code == 200:
return r.json().get("message", {}).get("content", "").strip()
except Exception as e:
logger.warning(f"Ollama 호출 실패: {e}")
return "현재 AI 어시스턴트를 사용할 수 없습니다. 잠시 후 다시 시도하세요."
@router.post("/chat")
async def chat(
req: ChatRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""대화 메시지 전송 — Multi-turn 컨텍스트 유지."""
# 세션 조회 또는 생성
if req.session_id:
sess_row = await db.execute(
select(AssistantSession).where(
AssistantSession.id == req.session_id,
AssistantSession.user_id == user.id,
)
)
session = sess_row.scalar_one_or_none()
if not session:
raise HTTPException(404, "세션 없음")
else:
session = AssistantSession(
user_id=user.id,
title=req.message[:50],
created_at=datetime.utcnow(),
)
db.add(session)
await db.commit()
await db.refresh(session)
# 이전 대화 이력 로드
hist_rows = await db.execute(
select(AssistantMessage).where(AssistantMessage.session_id == session.id)
.order_by(AssistantMessage.created_at).limit(MAX_HISTORY * 2)
)
history = hist_rows.scalars().all()
# Ollama messages 구성
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if req.context_data:
ctx = f"\n현재 컨텍스트: {req.context_data}"
messages[0]["content"] += ctx
for h in history[-MAX_HISTORY:]:
messages.append({"role": h.role, "content": h.content})
messages.append({"role": "user", "content": req.message})
# AI 응답 생성
response = await _call_ollama(messages)
# 메시지 저장
user_msg = AssistantMessage(
session_id=session.id, role="user",
content=req.message, created_at=datetime.utcnow()
)
ai_msg = AssistantMessage(
session_id=session.id, role="assistant",
content=response, created_at=datetime.utcnow()
)
db.add(user_msg)
db.add(ai_msg)
session.updated_at = datetime.utcnow()
await db.commit()
return {
"session_id": session.id,
"response": response,
"turn": len(history) // 2 + 1,
}
@router.post("/quick")
async def quick_query(
message: str,
user: User = Depends(get_current_user),
):
"""단일 턴 빠른 질의 (세션 저장 없음)."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message},
]
response = await _call_ollama(messages)
return {"response": response}
@router.get("/sessions")
async def list_sessions(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AssistantSession).where(AssistantSession.user_id == user.id)
.order_by(desc(AssistantSession.updated_at)).limit(20)
)
sessions = rows.scalars().all()
return [
{"id": s.id, "title": s.title,
"created_at": s.created_at, "updated_at": s.updated_at}
for s in sessions
]
@router.get("/sessions/{session_id}")
async def get_session(
session_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = await db.execute(
select(AssistantMessage).where(AssistantMessage.session_id == session_id)
.order_by(AssistantMessage.created_at)
)
msgs = rows.scalars().all()
return [
{"role": m.role, "content": m.content, "created_at": m.created_at}
for m in msgs
]
@router.delete("/sessions/{session_id}")
async def delete_session(
session_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
sess_row = await db.execute(
select(AssistantSession).where(
AssistantSession.id == session_id,
AssistantSession.user_id == user.id,
)
)
session = sess_row.scalar_one_or_none()
if not session:
raise HTTPException(404)
await db.delete(session)
await db.commit()
return {"ok": True}