- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""
|
|
자연어 명령 라우터 — GUARDiA AI 어시스턴트.
|
|
|
|
POST /api/nlcmd → intent 분류 → 엔티티 추출 → ITSM 액션 → 자연어 응답
|
|
외부 LLM 없음. 순수 온프레미스 규칙 기반.
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from core.nlu import parse
|
|
from database import get_db
|
|
from models import (
|
|
AuditLog, EngineerProfile, Institution, KBDocument, SRRequest,
|
|
SRStatus, SRType, Priority, User, UserRole, compute_log_hash,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/nlcmd", tags=["nlcmd"])
|
|
|
|
PRIORITY_LABEL = {"CRITICAL": "긴급", "HIGH": "높음", "MEDIUM": "보통", "LOW": "낮음"}
|
|
STATUS_LABEL = {
|
|
"RECEIVED": "접수", "PARSED": "파싱 완료",
|
|
"PENDING_APPROVAL": "승인 대기", "APPROVED": "승인됨",
|
|
"IN_PROGRESS": "진행 중", "PENDING_PM_VALIDATION": "PM 검증 대기",
|
|
"COMPLETED": "완료", "FAILED_ROLLBACK": "롤백 실패", "REJECTED": "반려",
|
|
}
|
|
TYPE_LABEL = {
|
|
"DEPLOY": "배포", "RESTART": "재기동",
|
|
"LOG": "로그 분석", "INQUIRY": "문의", "OTHER": "기타",
|
|
}
|
|
|
|
# 활성 상태 (워크로드 계산)
|
|
_ACTIVE = [s.value for s in [
|
|
SRStatus.RECEIVED, SRStatus.PARSED, SRStatus.PENDING_APPROVAL,
|
|
SRStatus.APPROVED, SRStatus.IN_PROGRESS, SRStatus.PENDING_PM_VALIDATION,
|
|
]]
|
|
|
|
|
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
|
|
|
class NLCmdRequest(BaseModel):
|
|
text: str
|
|
|
|
|
|
class NLCmdResponse(BaseModel):
|
|
intent: str
|
|
response: str
|
|
action_taken: bool = False
|
|
data: Optional[list] = None
|
|
suggestions: Optional[list[str]] = None
|
|
|
|
|
|
# ── Helper ─────────────────────────────────────────────────────────────────────
|
|
|
|
def _sr_summary(sr: SRRequest) -> str:
|
|
p = PRIORITY_LABEL.get(sr.priority, sr.priority)
|
|
s = STATUS_LABEL.get(sr.status, sr.status)
|
|
t = TYPE_LABEL.get(sr.sr_type, sr.sr_type)
|
|
assigned = sr.assigned_to or "미배정"
|
|
return f"• **{sr.sr_id}** [{p}] {t} — {sr.title[:40]} | 상태: {s} | 담당: {assigned}"
|
|
|
|
|
|
async def _write_audit(db, sr_id, actor, action, detail):
|
|
res = await db.execute(
|
|
select(AuditLog).where(AuditLog.sr_id == sr_id)
|
|
.order_by(AuditLog.id.desc()).limit(1)
|
|
)
|
|
last = res.scalars().first()
|
|
prev = last.log_hash if last else None
|
|
ts = datetime.now().isoformat()
|
|
db.add(AuditLog(
|
|
sr_id=sr_id, actor=actor, action=action, detail=detail,
|
|
prev_hash=prev, log_hash=compute_log_hash(prev, actor, action, detail, ts),
|
|
))
|
|
|
|
|
|
# ── Handler dispatch ───────────────────────────────────────────────────────────
|
|
|
|
async def _handle_query_sr_list(p, db, current_user) -> NLCmdResponse:
|
|
q = select(SRRequest).order_by(SRRequest.created_at.desc())
|
|
|
|
filters = []
|
|
if p.sr_type:
|
|
q = q.where(SRRequest.sr_type == p.sr_type)
|
|
filters.append(TYPE_LABEL.get(p.sr_type, p.sr_type))
|
|
if p.inst_code:
|
|
r2 = await db.execute(select(Institution).where(Institution.inst_code == p.inst_code))
|
|
inst = r2.scalars().first()
|
|
if inst:
|
|
q = q.where(SRRequest.inst_id == inst.id)
|
|
filters.append(p.inst_code)
|
|
if p.priority:
|
|
q = q.where(SRRequest.priority == p.priority)
|
|
filters.append(PRIORITY_LABEL.get(p.priority, p.priority))
|
|
if p.status:
|
|
q = q.where(SRRequest.status == p.status)
|
|
filters.append(STATUS_LABEL.get(p.status, p.status))
|
|
# 상태 키워드 없이 "승인 대기" 의도 감지
|
|
elif "승인 대기" in p.raw_text or "대기" in p.raw_text:
|
|
q = q.where(SRRequest.status == SRStatus.PENDING_APPROVAL)
|
|
filters.append("승인 대기")
|
|
elif "진행" in p.raw_text or "진행 중" in p.raw_text:
|
|
q = q.where(SRRequest.status == SRStatus.IN_PROGRESS)
|
|
filters.append("진행 중")
|
|
elif "긴급" in p.raw_text and not p.priority:
|
|
q = q.where(SRRequest.priority == Priority.CRITICAL)
|
|
filters.append("긴급")
|
|
|
|
q = q.limit(10)
|
|
res = await db.execute(q)
|
|
srs = res.scalars().all()
|
|
|
|
tag = " · ".join(filters) if filters else "전체"
|
|
if not srs:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{tag}** 조건에 해당하는 SR이 없습니다.",
|
|
)
|
|
|
|
lines = [f"**{tag}** SR 목록 ({len(srs)}건):"]
|
|
lines += [_sr_summary(sr) for sr in srs]
|
|
data = [{"sr_id": sr.sr_id, "title": sr.title, "status": sr.status,
|
|
"priority": sr.priority, "assigned_to": sr.assigned_to} for sr in srs]
|
|
return NLCmdResponse(intent=p.intent, response="\n".join(lines), data=data)
|
|
|
|
|
|
async def _handle_query_sr_detail(p, db) -> NLCmdResponse:
|
|
if not p.sr_id:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 상태 알려줘`",
|
|
)
|
|
res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id))
|
|
sr = res.scalars().first()
|
|
if not sr:
|
|
return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.")
|
|
|
|
lines = [
|
|
f"**{sr.sr_id}** 상세 정보",
|
|
f"제목: {sr.title}",
|
|
f"유형: {TYPE_LABEL.get(sr.sr_type, sr.sr_type)}",
|
|
f"상태: {STATUS_LABEL.get(sr.status, sr.status)}",
|
|
f"우선순위: {PRIORITY_LABEL.get(sr.priority, sr.priority)}",
|
|
f"요청자: {sr.requested_by or '—'}",
|
|
f"담당자: {sr.assigned_to or '미배정'}",
|
|
f"대상 서버: {sr.target_server or '—'}",
|
|
f"생성일: {sr.created_at.strftime('%Y-%m-%d %H:%M') if sr.created_at else '—'}",
|
|
]
|
|
if sr.description:
|
|
lines.append(f"설명: {sr.description[:100]}{'...' if len(sr.description or '') > 100 else ''}")
|
|
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="\n".join(lines),
|
|
data=[{"sr_id": sr.sr_id, "status": sr.status}],
|
|
)
|
|
|
|
|
|
async def _handle_assign(p, db, current_user) -> NLCmdResponse:
|
|
if not p.sr_id:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="배정할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 자동 배정해줘`",
|
|
)
|
|
|
|
res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id))
|
|
sr = res.scalars().first()
|
|
if not sr:
|
|
return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.")
|
|
|
|
if p.engineer:
|
|
ue = await db.execute(select(User).where(User.username == p.engineer))
|
|
u = ue.scalars().first()
|
|
if not u or u.role != UserRole.ENGINEER:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.engineer}** 은(는) 등록된 엔지니어가 아닙니다.",
|
|
)
|
|
assigned = p.engineer
|
|
mode = "수동"
|
|
else:
|
|
from routers.assign import auto_assign_engine
|
|
assigned = await auto_assign_engine(db, sr)
|
|
if not assigned:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="현재 배정 가능한 엔지니어가 없습니다. (모두 최대 워크로드)",
|
|
)
|
|
mode = "자동"
|
|
|
|
prev = sr.assigned_to or "미배정"
|
|
sr.assigned_to = assigned
|
|
sr.updated_at = datetime.now()
|
|
actor = current_user.display_name or current_user.username
|
|
await _write_audit(db, p.sr_id, actor, "ENGINEER_ASSIGNED", f"{mode} 배정: {prev} → {assigned}")
|
|
await db.commit()
|
|
|
|
# 표시 이름 조회
|
|
ep = await db.execute(select(EngineerProfile).where(EngineerProfile.username == assigned))
|
|
ep_obj = ep.scalars().first()
|
|
disp = (ep_obj.display_name if ep_obj else None) or assigned
|
|
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.sr_id}** 담당 엔지니어를 **{disp}**({assigned})으로 {mode} 배정했습니다.\n이전: {prev}",
|
|
action_taken=True,
|
|
)
|
|
|
|
|
|
async def _handle_approve_reject(p, db, current_user, result_val) -> NLCmdResponse:
|
|
from models import ApprovalFlow, ApprovalResult
|
|
if not p.sr_id:
|
|
act_kr = "승인" if result_val == "APPROVED" else "반려"
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"{act_kr}할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 승인해줘`",
|
|
)
|
|
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="승인/반려 권한은 PM 또는 관리자만 있습니다.",
|
|
)
|
|
|
|
res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id))
|
|
sr = res.scalars().first()
|
|
if not sr:
|
|
return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.")
|
|
if sr.status != SRStatus.PENDING_APPROVAL:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.sr_id}** 은(는) 현재 '승인 대기' 상태가 아닙니다. (현재: {STATUS_LABEL.get(sr.status, sr.status)})",
|
|
)
|
|
|
|
actor = current_user.display_name or current_user.username
|
|
new_status = SRStatus.APPROVED if result_val == "APPROVED" else SRStatus.REJECTED
|
|
|
|
apv = ApprovalFlow(
|
|
sr_id=p.sr_id, approver=actor,
|
|
result=result_val,
|
|
comment="AI 명령으로 처리",
|
|
decided_at=datetime.now(),
|
|
)
|
|
db.add(apv)
|
|
sr.status = new_status
|
|
sr.updated_at = datetime.now()
|
|
action_str = "SR_APPROVED" if result_val == "APPROVED" else "SR_REJECTED"
|
|
await _write_audit(db, p.sr_id, actor, action_str, f"자연어 명령: {p.raw_text[:80]}")
|
|
await db.commit()
|
|
|
|
act_kr = "승인" if result_val == "APPROVED" else "반려"
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.sr_id}** — {sr.title[:40]} 을(를) **{act_kr}** 처리했습니다.",
|
|
action_taken=True,
|
|
)
|
|
|
|
|
|
async def _handle_simulate(p, db, current_user) -> NLCmdResponse:
|
|
if not p.sr_id:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="시뮬레이션할 SR ID를 명시해 주세요. 예: `SR-20260525-XXXXXX 작업 시뮬레이션 실행해줘`",
|
|
)
|
|
res = await db.execute(select(SRRequest).where(SRRequest.sr_id == p.sr_id))
|
|
sr = res.scalars().first()
|
|
if not sr:
|
|
return NLCmdResponse(intent=p.intent, response=f"**{p.sr_id}** SR을 찾을 수 없습니다.")
|
|
if sr.status in (SRStatus.COMPLETED, SRStatus.FAILED_ROLLBACK, SRStatus.REJECTED):
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.sr_id}** 은(는) 이미 종료된 SR입니다. (상태: {STATUS_LABEL.get(sr.status, sr.status)})",
|
|
)
|
|
|
|
# work simulate 로직 재사용
|
|
from routers.work import _notify_messenger
|
|
import asyncio
|
|
from models import WorkLog, WorkActionType
|
|
_SSH_SIM = {
|
|
"RESTART": [
|
|
{"action": WorkActionType.SSH_CONNECT,
|
|
"content": "SSH 접속 (opsagent@{server}:22)",
|
|
"result": "Connected to {server}"},
|
|
{"action": WorkActionType.SSH_EXEC,
|
|
"content": "systemctl stop {svc} && sleep 2 && systemctl start {svc}",
|
|
"result": "● {svc}.service: active (running)"},
|
|
{"action": WorkActionType.HEALTH_CHECK,
|
|
"content": "curl -sf http://localhost:8080/health",
|
|
"result": "200 OK"},
|
|
],
|
|
"DEPLOY": [
|
|
{"action": WorkActionType.SSH_CONNECT,
|
|
"content": "SFTP 접속 (opsagent@{server}:22)", "result": "Connected"},
|
|
{"action": WorkActionType.SOURCE_MOD,
|
|
"content": "파일 전송 (SFTP)", "result": "전송 완료"},
|
|
{"action": WorkActionType.SSH_EXEC,
|
|
"content": "systemctl reload tomcat9", "result": "Reload OK"},
|
|
{"action": WorkActionType.HEALTH_CHECK,
|
|
"content": "헬스체크", "result": "200 OK"},
|
|
],
|
|
}
|
|
steps = _SSH_SIM.get(sr.sr_type, [
|
|
{"action": WorkActionType.SSH_EXEC, "content": "작업 수행", "result": "완료"},
|
|
])
|
|
server = sr.target_server or "UNKNOWN-SRV"
|
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
actor = current_user.display_name or current_user.username
|
|
|
|
sr.status = SRStatus.IN_PROGRESS
|
|
sr.updated_at = datetime.now()
|
|
await _write_audit(db, p.sr_id, actor, "SR_STARTED", f"자연어 명령: 작업 시작")
|
|
|
|
for step in steps:
|
|
wlog = WorkLog(
|
|
sr_id=p.sr_id, engineer=actor,
|
|
action_type=step["action"],
|
|
content=step["content"].format(server=server, svc="tomcat9", ts=ts),
|
|
result=step["result"].format(server=server, svc="tomcat9", ts=ts),
|
|
is_success=True,
|
|
)
|
|
db.add(wlog)
|
|
|
|
final = f"{sr.title[:50]} 처리 완료"
|
|
db.add(WorkLog(
|
|
sr_id=p.sr_id, engineer=actor,
|
|
action_type=WorkActionType.RESULT,
|
|
content="작업 결과 기록", result=final, is_success=True,
|
|
))
|
|
sr.status = SRStatus.COMPLETED
|
|
sr.updated_at = datetime.now()
|
|
await _write_audit(db, p.sr_id, actor, "SR_COMPLETED", final)
|
|
await db.commit()
|
|
|
|
asyncio.create_task(_notify_messenger(sr, final))
|
|
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**{p.sr_id}** 작업 시뮬레이션을 완료했습니다.\n{final}",
|
|
action_taken=True,
|
|
)
|
|
|
|
|
|
async def _handle_search_kb(p, db) -> NLCmdResponse:
|
|
from routers.kb import _search_docs
|
|
q = p.keyword or p.raw_text
|
|
hits = await _search_docs(db, q, sr_type_hint=p.sr_type, limit=3)
|
|
if not hits:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"**'{q}'** 관련 기술 문서를 찾지 못했습니다.",
|
|
)
|
|
lines = [f"**'{q}'** 관련 기술 문서 {len(hits)}건:"]
|
|
data = []
|
|
for h in hits:
|
|
d = h["doc"]
|
|
lines.append(
|
|
f"• [{d.category}] **{d.doc_id}** {d.title} "
|
|
f"(관련도 {int(h['score']*100)}%)"
|
|
)
|
|
data.append({"doc_id": d.doc_id, "title": d.title, "score": h["score"]})
|
|
lines.append("\n💡 *상세 내용은 KB 뷰에서 확인하세요.*")
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response="\n".join(lines),
|
|
data=data,
|
|
)
|
|
|
|
|
|
async def _handle_workload(p, db) -> NLCmdResponse:
|
|
res = await db.execute(select(EngineerProfile))
|
|
profiles = res.scalars().all()
|
|
|
|
lines = ["**엔지니어 워크로드 현황:**"]
|
|
data = []
|
|
for ep in profiles:
|
|
cnt = (await db.execute(
|
|
select(func.count(SRRequest.id)).where(
|
|
SRRequest.assigned_to == ep.username,
|
|
SRRequest.status.in_(_ACTIVE),
|
|
)
|
|
)).scalar() or 0
|
|
done = (await db.execute(
|
|
select(func.count(SRRequest.id)).where(
|
|
SRRequest.assigned_to == ep.username,
|
|
SRRequest.status == SRStatus.COMPLETED.value,
|
|
)
|
|
)).scalar() or 0
|
|
bar = "█" * cnt + "░" * (ep.max_workload - cnt)
|
|
disp = ep.display_name or ep.username
|
|
lines.append(f"• **{disp}** [{bar}] {cnt}/{ep.max_workload}건 완료: {done}건")
|
|
data.append({"username": ep.username, "active": cnt, "max": ep.max_workload})
|
|
|
|
if not profiles:
|
|
return NLCmdResponse(intent=p.intent, response="등록된 엔지니어 프로필이 없습니다.")
|
|
|
|
return NLCmdResponse(intent=p.intent, response="\n".join(lines), data=data)
|
|
|
|
|
|
async def _handle_stats(p, db) -> NLCmdResponse:
|
|
total = (await db.execute(select(func.count(SRRequest.id)))).scalar() or 0
|
|
by_status: dict[str, int] = {}
|
|
for s in SRStatus:
|
|
cnt = (await db.execute(
|
|
select(func.count(SRRequest.id)).where(SRRequest.status == s)
|
|
)).scalar() or 0
|
|
if cnt:
|
|
by_status[s.value] = cnt
|
|
|
|
lines = [f"**ITSM 현황 요약** (전체 {total}건)"]
|
|
for k, v in sorted(by_status.items(), key=lambda x: -x[1]):
|
|
label = STATUS_LABEL.get(k, k)
|
|
bar = "▪" * min(v, 10)
|
|
lines.append(f"• {label}: **{v}건** {bar}")
|
|
|
|
return NLCmdResponse(intent=p.intent, response="\n".join(lines))
|
|
|
|
|
|
# ── Main endpoint ──────────────────────────────────────────────────────────────
|
|
|
|
@router.post("", response_model=NLCmdResponse)
|
|
async def nl_command(
|
|
req: NLCmdRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""자연어 명령을 받아 ITSM 작업을 수행하고 자연어로 응답합니다."""
|
|
if not req.text.strip():
|
|
return NLCmdResponse(intent="UNKNOWN", response="명령어를 입력해 주세요.")
|
|
|
|
p = parse(req.text.strip())
|
|
|
|
suggestions = [
|
|
"승인 대기 SR 목록 보여줘",
|
|
"엔지니어 워크로드 현황",
|
|
"KB에서 OOM 검색해줘",
|
|
]
|
|
|
|
try:
|
|
if p.intent == "QUERY_SR_LIST":
|
|
return await _handle_query_sr_list(p, db, current_user)
|
|
elif p.intent == "QUERY_SR_DETAIL":
|
|
return await _handle_query_sr_detail(p, db)
|
|
elif p.intent == "ASSIGN_ENGINEER":
|
|
return await _handle_assign(p, db, current_user)
|
|
elif p.intent == "APPROVE_SR":
|
|
return await _handle_approve_reject(p, db, current_user, "APPROVED")
|
|
elif p.intent == "REJECT_SR":
|
|
return await _handle_approve_reject(p, db, current_user, "REJECTED")
|
|
elif p.intent == "SIMULATE":
|
|
return await _handle_simulate(p, db, current_user)
|
|
elif p.intent == "SEARCH_KB":
|
|
return await _handle_search_kb(p, db)
|
|
elif p.intent == "QUERY_WORKLOAD":
|
|
return await _handle_workload(p, db)
|
|
elif p.intent == "QUERY_STATS":
|
|
return await _handle_stats(p, db)
|
|
else:
|
|
return NLCmdResponse(
|
|
intent="UNKNOWN",
|
|
response=(
|
|
"죄송합니다, 명령을 이해하지 못했습니다.\n"
|
|
"다음과 같이 입력해 보세요:"
|
|
),
|
|
suggestions=suggestions,
|
|
)
|
|
except Exception as e:
|
|
return NLCmdResponse(
|
|
intent=p.intent,
|
|
response=f"처리 중 오류가 발생했습니다: {str(e)[:100]}",
|
|
)
|