357 lines
13 KiB
Python
357 lines
13 KiB
Python
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
|
|
],
|
|
}
|