from __future__ import annotations import time from datetime import datetime from typing import Optional, Any, List import httpx from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from models import User, HarnessAgent, HarnessRunHistory, HarnessSkill router = APIRouter(prefix="/api/harness", tags=["Harness Builder"]) def _tenant(user: User) -> str: return user.inst_code or str(user.id) async def _ollama(prompt: str, system: str = "") -> str: try: payload: dict[str, Any] = {"model": "llama3", "prompt": prompt, "stream": False} if system: payload["system"] = system async with httpx.AsyncClient(timeout=30) as c: r = await c.post("http://localhost:11434/api/generate", json=payload) return r.json().get("response", "AI 응답 없음") except Exception: return "AI 연결 불가 (Ollama 오프라인)" _BUILTIN_ORCHESTRATORS = [ {"name": "guardia-orchestrator", "type": "orchestrator", "description": "ITSM E2E 워크플로우 오케스트레이터"}, {"name": "guardia-brain-orchestrator", "type": "orchestrator", "description": "AI 지능화 엔진 오케스트레이터"}, {"name": "guardia-extend5-orchestrator", "type": "orchestrator", "description": "5세대 확장 오케스트레이터"}, {"name": "guardia-parent-orchestrator", "type": "orchestrator", "description": "부모 역할 4가지 오케스트레이터"}, {"name": "guardia-fullstack-orchestrator", "type": "orchestrator", "description": "풀스택 통합 오케스트레이터"}, ] _SKILL_TEMPLATES = { "orchestrator": "# {name} 오케스트레이터 스킬\n\n## 목적\n{role}\n\n## 트리거\n{keywords}\n\n## 에이전트 목록\n- 전문 에이전트 목록을 여기에 정의\n\n## 실행 순서\n1. 요청 분석\n2. 에이전트 선택\n3. 병렬 실행\n4. 결과 통합\n", "specialist": "# {name} 전문 에이전트 스킬\n\n## 역할\n{role}\n\n## 입력\n- 요청 텍스트\n- 컨텍스트 데이터\n\n## 출력\n- 분석 결과\n- 권고 사항\n\n## 보안 원칙\n- JWT 인증 필수\n- 외부 API 호출 금지\n", "reviewer": "# {name} 리뷰어 스킬\n\n## 역할\n{role}\n\n## 체크리스트\n- [ ] 보안 검토\n- [ ] 성능 검토\n- [ ] 코드 품질 검토\n\n## 출력\n- 리뷰 결과 (통과/실패)\n- 개선 권고\n", } # ── Pydantic 스키마 ──────────────────────────────────────────────────────────── class AgentIn(BaseModel): name: str role: Optional[str] = None skills: list[str] = [] trigger_keywords: list[str] = [] system_prompt: Optional[str] = None auto_describe: bool = False class AgentOut(BaseModel): model_config = {"from_attributes": True} id: int name: str role: Optional[str] skills: Optional[Any] trigger_keywords: Optional[Any] is_active: bool run_count: int last_run_at: Optional[datetime] created_at: datetime class AgentUpdate(BaseModel): role: Optional[str] = None skills: Optional[list[str]] = None trigger_keywords: Optional[list[str]] = None system_prompt: Optional[str] = None is_active: Optional[bool] = None class RunIn(BaseModel): prompt: str class SkillIn(BaseModel): name: str skill_type: str = "specialist" content_md: Optional[str] = None auto_generate: bool = False role: Optional[str] = None # ── 엔드포인트 ───────────────────────────────────────────────────────────────── @router.get("/agents", summary="에이전트 목록 조회") async def list_agents( active_only: bool = True, keyword: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) q = select(HarnessAgent).where(HarnessAgent.tenant_id == tid) if active_only: q = q.where(HarnessAgent.is_active == True) rows = (await db.execute(q.order_by(HarnessAgent.created_at.desc()))).scalars().all() if keyword: keyword_lower = keyword.lower() rows = [r for r in rows if keyword_lower in (r.name or "").lower() or keyword_lower in (r.role or "").lower()] return [AgentOut.model_validate(r) for r in rows] @router.post("/agents", status_code=201, summary="에이전트 노코드 생성") async def create_agent( body: AgentIn, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) system_prompt = body.system_prompt if body.auto_describe or not system_prompt: prompt = ( f"AI 에이전트 역할 설명 작성:\n" f"이름: {body.name}\n역할: {body.role or '범용 에이전트'}\n" f"스킬: {', '.join(body.skills) or '없음'}\n" f"위 정보를 바탕으로 이 에이전트의 시스템 프롬프트를 한국어로 3문장 이내로 작성하라." ) system_prompt = await _ollama(prompt) agent = HarnessAgent( tenant_id=tid, name=body.name, role=body.role, skills=body.skills, trigger_keywords=body.trigger_keywords, system_prompt=system_prompt, ) db.add(agent) await db.commit() await db.refresh(agent) return AgentOut.model_validate(agent) @router.put("/agents/{agent_id}", summary="에이전트 수정") async def update_agent( agent_id: int, body: AgentUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) agent = (await db.execute( select(HarnessAgent).where(HarnessAgent.tenant_id == tid, HarnessAgent.id == agent_id) )).scalar_one_or_none() if not agent: raise HTTPException(404, "에이전트를 찾을 수 없습니다") if body.role is not None: agent.role = body.role if body.skills is not None: agent.skills = body.skills if body.trigger_keywords is not None: agent.trigger_keywords = body.trigger_keywords if body.system_prompt is not None: agent.system_prompt = body.system_prompt if body.is_active is not None: agent.is_active = body.is_active await db.commit() await db.refresh(agent) return AgentOut.model_validate(agent) @router.delete("/agents/{agent_id}", summary="에이전트 삭제") async def delete_agent( agent_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) agent = (await db.execute( select(HarnessAgent).where(HarnessAgent.tenant_id == tid, HarnessAgent.id == agent_id) )).scalar_one_or_none() if not agent: raise HTTPException(404, "에이전트를 찾을 수 없습니다") agent.is_active = False await db.commit() return {"id": agent_id, "message": "에이전트가 비활성화되었습니다"} @router.post("/agents/{agent_id}/run", summary="에이전트 즉시 실행") async def run_agent( agent_id: int, body: RunIn, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) agent = (await db.execute( select(HarnessAgent).where(HarnessAgent.tenant_id == tid, HarnessAgent.id == agent_id) )).scalar_one_or_none() if not agent: raise HTTPException(404, "에이전트를 찾을 수 없습니다") if not agent.is_active: raise HTTPException(400, "비활성화된 에이전트입니다") # 스킬 컨텍스트 조회 skill_context = "" if agent.skills: skills = (await db.execute( select(HarnessSkill).where( HarnessSkill.tenant_id == tid, HarnessSkill.name.in_(agent.skills), ) )).scalars().all() if skills: skill_context = "\n".join(f"[스킬: {s.name}]\n{(s.content_md or '')[:300]}" for s in skills) system_parts = [agent.system_prompt or f"당신은 {agent.name} AI 에이전트입니다. 역할: {agent.role}"] if skill_context: system_parts.append(f"\n[활성 스킬]\n{skill_context[:500]}") full_system = "\n".join(system_parts) start = time.monotonic() result = await _ollama(body.prompt, system=full_system) duration_ms = int((time.monotonic() - start) * 1000) run = HarnessRunHistory( tenant_id=tid, agent_id=agent_id, prompt=body.prompt, result=result, status="SUCCESS", duration_ms=duration_ms, run_by=current_user.username, ) db.add(run) agent.run_count = (agent.run_count or 0) + 1 agent.last_run_at = datetime.utcnow() await db.commit() return { "agent_id": agent_id, "agent_name": agent.name, "prompt": body.prompt, "result": result, "duration_ms": duration_ms, "run_id": run.id, } @router.get("/agents/{agent_id}/history", summary="에이전트 실행 이력") async def agent_history( agent_id: int, limit: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) agent = (await db.execute( select(HarnessAgent).where(HarnessAgent.tenant_id == tid, HarnessAgent.id == agent_id) )).scalar_one_or_none() if not agent: raise HTTPException(404, "에이전트를 찾을 수 없습니다") rows = (await db.execute( select(HarnessRunHistory).where( HarnessRunHistory.tenant_id == tid, HarnessRunHistory.agent_id == agent_id, ).order_by(HarnessRunHistory.created_at.desc()).limit(limit) )).scalars().all() return { "agent_id": agent_id, "agent_name": agent.name, "total_runs": agent.run_count, "history": [ {"id": r.id, "prompt": (r.prompt or "")[:100], "status": r.status, "duration_ms": r.duration_ms, "run_by": r.run_by, "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows ], } @router.get("/skills", summary="스킬 템플릿 목록") async def list_skills( skill_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) q = select(HarnessSkill).where(HarnessSkill.tenant_id == tid, HarnessSkill.is_active == True) if skill_type: q = q.where(HarnessSkill.skill_type == skill_type) rows = (await db.execute(q)).scalars().all() return {"total": len(rows), "skills": [ {"id": r.id, "name": r.name, "skill_type": r.skill_type, "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows ]} @router.post("/skills", status_code=201, summary="스킬 생성") async def create_skill( body: SkillIn, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if body.skill_type not in ("orchestrator", "specialist", "reviewer"): raise HTTPException(400, "skill_type은 orchestrator/specialist/reviewer 중 하나여야 합니다") tid = _tenant(current_user) content = body.content_md if body.auto_generate or not content: template = _SKILL_TEMPLATES.get(body.skill_type, "") prompt = ( f"SKILL.md 파일 작성:\n이름: {body.name}\n유형: {body.skill_type}\n역할: {body.role or body.name}\n" f"다음 템플릿을 채워서 완성된 SKILL.md를 한국어로 작성하라:\n{template}" ) content = await _ollama(prompt) skill = HarnessSkill( tenant_id=tid, name=body.name, skill_type=body.skill_type, content_md=content, ) db.add(skill) await db.commit() await db.refresh(skill) return {"id": skill.id, "name": skill.name, "skill_type": skill.skill_type, "content_preview": (content or "")[:200]} @router.get("/orchestrators", summary="오케스트레이터 목록 + 연결된 에이전트") async def list_orchestrators( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tid = _tenant(current_user) db_agents = (await db.execute( select(HarnessAgent).where( HarnessAgent.tenant_id == tid, HarnessAgent.is_active == True, ) )).scalars().all() result = list(_BUILTIN_ORCHESTRATORS) for agent in db_agents: if agent.skill_type if hasattr(agent, "skill_type") else "specialist" == "orchestrator": result.append({"name": agent.name, "type": "orchestrator", "description": agent.role or "", "id": agent.id}) return { "total": len(result), "orchestrators": result, "registered_agents": [ {"id": a.id, "name": a.name, "role": a.role, "run_count": a.run_count} for a in db_agents ], }