EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
9.6 KiB
Python
304 lines
9.6 KiB
Python
"""
|
|
AI 에이전트 API
|
|
- 대화 생성/조회/삭제
|
|
- 메시지 전송 (에이전트 응답 반환)
|
|
- 에이전트 자동 라우팅
|
|
- 프로액티브 브리핑 (아침 공정 브리핑 등)
|
|
"""
|
|
import uuid
|
|
from datetime import date
|
|
from fastapi import APIRouter, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.deps import CurrentUser, DB
|
|
from app.models.agent import AgentConversation, AgentMessage, AgentType, ConversationStatus
|
|
from app.models.project import Project
|
|
from app.services.agents.router import get_agent, route_by_keyword
|
|
from app.services.agents.collaboration import run_scenario, SCENARIO_AGENTS
|
|
|
|
router = APIRouter(prefix="/projects/{project_id}/agents", tags=["AI 에이전트"])
|
|
|
|
|
|
# ── Schemas ────────────────────────────────────────────────────────────────────
|
|
|
|
class ConversationCreate(BaseModel):
|
|
agent_type: AgentType
|
|
title: str | None = None
|
|
|
|
|
|
class MessageSend(BaseModel):
|
|
content: str
|
|
agent_type: AgentType | None = None # None이면 자동 라우팅
|
|
|
|
|
|
class ConversationResponse(BaseModel):
|
|
id: uuid.UUID
|
|
agent_type: AgentType
|
|
title: str | None
|
|
status: ConversationStatus
|
|
message_count: int = 0
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class MessageResponse(BaseModel):
|
|
id: uuid.UUID
|
|
conversation_id: uuid.UUID
|
|
role: str
|
|
content: str
|
|
is_proactive: bool
|
|
metadata: dict | None
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
|
|
r = await db.execute(select(Project).where(Project.id == project_id))
|
|
p = r.scalar_one_or_none()
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
|
return p
|
|
|
|
|
|
async def _get_conversation_or_404(
|
|
conv_id: uuid.UUID, project_id: uuid.UUID, db: DB
|
|
) -> AgentConversation:
|
|
r = await db.execute(
|
|
select(AgentConversation)
|
|
.where(AgentConversation.id == conv_id, AgentConversation.project_id == project_id)
|
|
.options(selectinload(AgentConversation.messages))
|
|
)
|
|
conv = r.scalar_one_or_none()
|
|
if not conv:
|
|
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다")
|
|
return conv
|
|
|
|
|
|
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("", response_model=list[ConversationResponse])
|
|
async def list_conversations(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
|
r = await db.execute(
|
|
select(AgentConversation)
|
|
.where(AgentConversation.project_id == project_id)
|
|
.options(selectinload(AgentConversation.messages))
|
|
.order_by(AgentConversation.updated_at.desc())
|
|
)
|
|
convs = r.scalars().all()
|
|
result = []
|
|
for c in convs:
|
|
d = ConversationResponse(
|
|
id=c.id,
|
|
agent_type=c.agent_type,
|
|
title=c.title,
|
|
status=c.status,
|
|
message_count=len(c.messages),
|
|
)
|
|
result.append(d)
|
|
return result
|
|
|
|
|
|
@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_conversation(
|
|
project_id: uuid.UUID, data: ConversationCreate, db: DB, current_user: CurrentUser
|
|
):
|
|
await _get_project_or_404(project_id, db)
|
|
conv = AgentConversation(
|
|
project_id=project_id,
|
|
user_id=current_user.id,
|
|
agent_type=data.agent_type,
|
|
title=data.title or f"{data.agent_type.value.upper()} 대화",
|
|
)
|
|
db.add(conv)
|
|
await db.commit()
|
|
await db.refresh(conv)
|
|
return ConversationResponse(
|
|
id=conv.id, agent_type=conv.agent_type, title=conv.title,
|
|
status=conv.status, message_count=0,
|
|
)
|
|
|
|
|
|
@router.get("/{conv_id}/messages", response_model=list[MessageResponse])
|
|
async def list_messages(
|
|
project_id: uuid.UUID, conv_id: uuid.UUID, db: DB, current_user: CurrentUser
|
|
):
|
|
conv = await _get_conversation_or_404(conv_id, project_id, db)
|
|
return conv.messages
|
|
|
|
|
|
@router.post("/{conv_id}/messages", response_model=MessageResponse)
|
|
async def send_message(
|
|
project_id: uuid.UUID,
|
|
conv_id: uuid.UUID,
|
|
data: MessageSend,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
):
|
|
"""메시지 전송 → 에이전트 응답 반환"""
|
|
conv = await _get_conversation_or_404(conv_id, project_id, db)
|
|
|
|
# 사용자 메시지 저장
|
|
user_msg = AgentMessage(
|
|
conversation_id=conv.id,
|
|
role="user",
|
|
content=data.content,
|
|
)
|
|
db.add(user_msg)
|
|
await db.flush()
|
|
|
|
# 대화 히스토리 구성 (최근 20개)
|
|
history = [
|
|
{"role": m.role, "content": m.content}
|
|
for m in conv.messages[-20:]
|
|
if m.role in ("user", "assistant")
|
|
]
|
|
history.append({"role": "user", "content": data.content})
|
|
|
|
# 에이전트 선택 (대화에 지정된 에이전트 사용)
|
|
agent = get_agent(conv.agent_type)
|
|
|
|
# 컨텍스트 조회 및 응답 생성
|
|
context = await agent.build_context(db, str(project_id))
|
|
reply = await agent.chat(messages=history, context=context)
|
|
|
|
# 에이전트 응답 저장
|
|
agent_msg = AgentMessage(
|
|
conversation_id=conv.id,
|
|
role="assistant",
|
|
content=reply,
|
|
)
|
|
db.add(agent_msg)
|
|
await db.commit()
|
|
await db.refresh(agent_msg)
|
|
return agent_msg
|
|
|
|
|
|
@router.post("/chat", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
|
async def quick_chat(
|
|
project_id: uuid.UUID,
|
|
data: MessageSend,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
):
|
|
"""
|
|
새 대화 없이 바로 메시지 전송 (에이전트 자동 라우팅).
|
|
자동으로 대화 세션을 생성하고 첫 응답을 반환합니다.
|
|
"""
|
|
await _get_project_or_404(project_id, db)
|
|
|
|
# 에이전트 자동 라우팅
|
|
agent_type = data.agent_type or route_by_keyword(data.content)
|
|
agent = get_agent(agent_type)
|
|
|
|
# 새 대화 생성
|
|
conv = AgentConversation(
|
|
project_id=project_id,
|
|
user_id=current_user.id,
|
|
agent_type=agent_type,
|
|
title=data.content[:50],
|
|
)
|
|
db.add(conv)
|
|
await db.flush()
|
|
|
|
# 사용자 메시지 저장
|
|
user_msg = AgentMessage(
|
|
conversation_id=conv.id, role="user", content=data.content
|
|
)
|
|
db.add(user_msg)
|
|
await db.flush()
|
|
|
|
# 응답 생성
|
|
context = await agent.build_context(db, str(project_id))
|
|
reply = await agent.chat(
|
|
messages=[{"role": "user", "content": data.content}],
|
|
context=context,
|
|
)
|
|
|
|
agent_msg = AgentMessage(
|
|
conversation_id=conv.id, role="assistant", content=reply
|
|
)
|
|
db.add(agent_msg)
|
|
await db.commit()
|
|
await db.refresh(agent_msg)
|
|
return agent_msg
|
|
|
|
|
|
@router.post("/briefing", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
|
async def morning_briefing(
|
|
project_id: uuid.UUID,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
):
|
|
"""
|
|
GONGSA 아침 공정 브리핑 생성 (오전 7시 자동 호출 또는 수동 호출).
|
|
오늘 날씨 + 예정 공종 + 전일 실적 기반으로 브리핑을 생성합니다.
|
|
"""
|
|
await _get_project_or_404(project_id, db)
|
|
from app.services.agents.gongsa import gongsa_agent
|
|
|
|
context = await gongsa_agent.build_context(db, str(project_id))
|
|
|
|
prompt = (
|
|
f"오늘({context.get('today', date.today())}) 아침 공정 브리핑을 작성해주세요. "
|
|
"날씨, 오늘 예정 공종, 주의사항을 포함해주세요."
|
|
)
|
|
|
|
conv = AgentConversation(
|
|
project_id=project_id,
|
|
user_id=current_user.id,
|
|
agent_type=AgentType.GONGSA,
|
|
title=f"{context.get('today', '')} 아침 브리핑",
|
|
)
|
|
db.add(conv)
|
|
await db.flush()
|
|
|
|
reply = await gongsa_agent.chat(
|
|
messages=[{"role": "user", "content": prompt}],
|
|
context=context,
|
|
)
|
|
|
|
msg = AgentMessage(
|
|
conversation_id=conv.id, role="assistant", content=reply, is_proactive=True
|
|
)
|
|
db.add(msg)
|
|
await db.commit()
|
|
await db.refresh(msg)
|
|
return msg
|
|
|
|
|
|
@router.post("/scenario/{scenario_name}")
|
|
async def run_collaboration_scenario(
|
|
project_id: uuid.UUID,
|
|
scenario_name: str,
|
|
db: DB,
|
|
current_user: CurrentUser,
|
|
):
|
|
"""
|
|
에이전트 협업 시나리오 실행 (Phase 3).
|
|
복수 에이전트가 순차적으로 하나의 현장 상황을 처리합니다.
|
|
|
|
사용 가능 시나리오:
|
|
- concrete_pour: 콘크리트 타설 (GONGSA → PUMJIL → ANJEON)
|
|
- excavation: 굴착 작업 (GONGSA → ANJEON → PUMJIL)
|
|
- weekly_report: 주간 보고 (GONGSA → PUMJIL → GUMU)
|
|
"""
|
|
if scenario_name not in SCENARIO_AGENTS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"알 수 없는 시나리오. 가능한 값: {list(SCENARIO_AGENTS.keys())}",
|
|
)
|
|
await _get_project_or_404(project_id, db)
|
|
results = await run_scenario(db, project_id, current_user.id, scenario_name)
|
|
return {"scenario": scenario_name, "steps": results}
|
|
|
|
|
|
@router.delete("/{conv_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_conversation(
|
|
project_id: uuid.UUID, conv_id: uuid.UUID, db: DB, current_user: CurrentUser
|
|
):
|
|
conv = await _get_conversation_or_404(conv_id, project_id, db)
|
|
await db.delete(conv)
|
|
await db.commit()
|