zioinfo-mail/workspace/guardia-itsm/routers/nlcmd.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +09:00

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]}",
)