feat: Phase 2 구현 — AI 에이전트 4인방, EVMS, Vision AI, Geofence
AI 에이전트 (Layer 2): - GONGSA: 공사 담당 (공정 브리핑, 공기 지연 감지, 날씨 연동 작업 조정) - PUMJIL: 품질 담당 (시공 전 체크리스트, Vision 보조 판독, 시험 기한 추적) - ANJEON: 안전 담당 (위험 공정 경보, TBM 생성, 중대재해처벌법 Q&A) - GUMU: 공무 담당 (인허가 능동 추적, 기성청구 제안, 보고서 초안) - 에이전트 라우터 (키워드 기반 자동 분배), 아침 브리핑 엔드포인트 EVMS 기본: - PV·EV·AC·SPI·CPI 산출 (WBS/Task 기반) - EAC·ETC 예측, 스냅샷 이력 저장 Vision AI: - Level 1: 현장 사진 분류 (Claude Vision), 작업일보 자동 첨부 - Level 2: 안전장비(안전모/조끼) 착용 감지 Geofence 위험구역: - 구역 CRUD (굴착면, 크레인 반경, 밀폐공간 등) - 진입 이벤트 웹훅 (익명 — 개인 이동 경로 비수집) 인허가 자동도출: - 공종 입력 → AI가 필요 인허가 목록 자동 도출 + 체크리스트 생성 DB 마이그레이션 (002): - agent_conversations, agent_messages, evms_snapshots, geofence_zones Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""ANJEON — 안전 담당 에이전트"""
|
||||
from .base import BaseAgent
|
||||
|
||||
|
||||
class AnjeonAgent(BaseAgent):
|
||||
agent_type = "anjeon"
|
||||
name_ko = "안전 에이전트 (ANJEON)"
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
return """당신은 ANJEON입니다. 소형 건설현장의 안전 담당 AI 에이전트입니다.
|
||||
|
||||
## 역할과 책임
|
||||
- 위험 공정 시작 **전** 사전 경보 및 점검 항목 발송
|
||||
- TBM(Tool Box Meeting) 자료 자동 생성
|
||||
- 위험구역 Geofence 진입 감지 경보 (익명, 개인 이동 추적 없음)
|
||||
- 중대재해처벌법 관련 Q&A (RAG 기반)
|
||||
- 안전교육 실시 여부 확인 및 미실시 알림
|
||||
|
||||
## 행동 원칙
|
||||
1. **안전은 타협하지 않습니다** — 기준 미달 시 단호하게 작업 중단을 권고합니다
|
||||
2. 법령/기준을 반드시 인용합니다 (산업안전보건법, 중대재해처벌법)
|
||||
3. 위험구역 감지 시 개인 식별 정보 없이 "위험구역 진입 감지" 알림만 발송합니다
|
||||
4. TBM은 당일 작업 공종에 맞게 맞춤 생성합니다
|
||||
|
||||
## 주요 위험 공종별 기준
|
||||
- 굴착 5m 이상: 흙막이 설치, 낙하 방지망, 출입 통제
|
||||
- 고소 작업 2m 이상: 안전난간 또는 안전네트, 안전대 착용
|
||||
- 크레인 작업: 신호수 배치, 인양 반경 출입 통제
|
||||
- 콘크리트 타설: 바이브레이터 감전 주의, 거푸집 점검
|
||||
- 밀폐공간: 산소/유해가스 측정, 감시자 배치
|
||||
|
||||
## TBM 형식
|
||||
1. 오늘의 위험 작업
|
||||
2. 핵심 안전 수칙 3가지
|
||||
3. 비상 연락처
|
||||
4. "안전 확인합니다" 서명란
|
||||
|
||||
## 중요 면책 고지
|
||||
"이 답변은 참고용이며 법률 자문이 아닙니다. 중대한 안전 결정은 전문가에게 문의하세요."
|
||||
"""
|
||||
|
||||
|
||||
anjeon_agent = AnjeonAgent()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
에이전트 베이스 클래스
|
||||
각 에이전트는 독립된 페르소나 + Claude 인스턴스로 구동됩니다.
|
||||
- 엔진 DB를 공유하여 프로젝트 데이터 접근
|
||||
- 에이전트는 제안하고, 사람이 결정합니다
|
||||
- 모든 대화는 DB에 기록됩니다
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.ai_engine import complete
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""모든 에이전트의 공통 베이스"""
|
||||
|
||||
agent_type: str
|
||||
name_ko: str
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def system_prompt(self) -> str:
|
||||
"""에이전트 고유 시스템 프롬프트"""
|
||||
...
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict],
|
||||
context: dict | None = None,
|
||||
temperature: float = 0.5,
|
||||
) -> str:
|
||||
"""
|
||||
대화 수행
|
||||
messages: [{"role": "user"|"assistant", "content": "..."}]
|
||||
context: 프로젝트/날씨/태스크 등 컨텍스트 (시스템 프롬프트에 주입)
|
||||
"""
|
||||
system = self.system_prompt
|
||||
if context:
|
||||
system += "\n\n## 현재 컨텍스트\n" + self._format_context(context)
|
||||
|
||||
return await complete(
|
||||
messages=messages,
|
||||
system=system,
|
||||
temperature=temperature,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
def _format_context(self, context: dict) -> str:
|
||||
lines = []
|
||||
if context.get("project_name"):
|
||||
lines.append(f"- 프로젝트: {context['project_name']}")
|
||||
if context.get("today"):
|
||||
lines.append(f"- 오늘 날짜: {context['today']}")
|
||||
if context.get("weather"):
|
||||
lines.append(f"- 오늘 날씨: {context['weather']}")
|
||||
if context.get("active_tasks"):
|
||||
lines.append(f"- 진행 중 공종: {', '.join(context['active_tasks'])}")
|
||||
if context.get("pending_inspections"):
|
||||
lines.append(f"- 미완료 검측: {context['pending_inspections']}건")
|
||||
if context.get("overdue_tests"):
|
||||
lines.append(f"- 기한 초과 품질시험: {context['overdue_tests']}건")
|
||||
if context.get("overdue_permits"):
|
||||
lines.append(f"- 지연 인허가: {context['overdue_permits']}건")
|
||||
if context.get("schedule_delay_days"):
|
||||
lines.append(f"- 공정 지연: {context['schedule_delay_days']}일")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def build_context(self, db: AsyncSession, project_id: str) -> dict:
|
||||
"""프로젝트 컨텍스트를 DB에서 조회하여 반환"""
|
||||
from datetime import date
|
||||
from sqlalchemy import select, func
|
||||
from app.models.project import Project
|
||||
from app.models.task import Task
|
||||
from app.models.weather import WeatherData
|
||||
from app.models.inspection import InspectionRequest, InspectionStatus
|
||||
from app.models.permit import PermitItem
|
||||
|
||||
import uuid
|
||||
pid = uuid.UUID(str(project_id))
|
||||
|
||||
# 프로젝트 정보
|
||||
proj_r = await db.execute(select(Project).where(Project.id == pid))
|
||||
project = proj_r.scalar_one_or_none()
|
||||
if not project:
|
||||
return {}
|
||||
|
||||
# 오늘 날씨
|
||||
today = date.today()
|
||||
weather_r = await db.execute(
|
||||
select(WeatherData).where(WeatherData.project_id == pid, WeatherData.forecast_date == today)
|
||||
)
|
||||
weather = weather_r.scalar_one_or_none()
|
||||
weather_summary = None
|
||||
if weather:
|
||||
parts = []
|
||||
if weather.sky_condition:
|
||||
parts.append(weather.sky_condition)
|
||||
if weather.temperature_max is not None:
|
||||
parts.append(f"최고 {weather.temperature_max:.0f}°C")
|
||||
if weather.precipitation_mm and weather.precipitation_mm > 0:
|
||||
parts.append(f"강수 {weather.precipitation_mm:.1f}mm")
|
||||
weather_summary = " / ".join(parts) if parts else None
|
||||
|
||||
# 진행 중 태스크
|
||||
tasks_r = await db.execute(
|
||||
select(Task).where(Task.project_id == pid, Task.status.in_(["in_progress", "not_started"]))
|
||||
)
|
||||
tasks = tasks_r.scalars().all()
|
||||
active_tasks = list({t.name for t in tasks if t.status == "in_progress"})[:5]
|
||||
|
||||
# 미완료 검측
|
||||
insp_r = await db.execute(
|
||||
select(func.count()).where(
|
||||
InspectionRequest.project_id == pid,
|
||||
InspectionRequest.status == InspectionStatus.DRAFT,
|
||||
)
|
||||
)
|
||||
pending_inspections = insp_r.scalar() or 0
|
||||
|
||||
# 지연 인허가
|
||||
permit_r = await db.execute(
|
||||
select(func.count()).where(
|
||||
PermitItem.project_id == pid,
|
||||
PermitItem.status.in_(["pending", "in_progress"]),
|
||||
PermitItem.due_date < today,
|
||||
)
|
||||
)
|
||||
overdue_permits = permit_r.scalar() or 0
|
||||
|
||||
return {
|
||||
"project_name": project.name,
|
||||
"today": str(today),
|
||||
"weather": weather_summary,
|
||||
"active_tasks": active_tasks,
|
||||
"pending_inspections": pending_inspections,
|
||||
"overdue_permits": overdue_permits,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""GONGSA — 공사 담당 에이전트"""
|
||||
from .base import BaseAgent
|
||||
|
||||
|
||||
class GongsaAgent(BaseAgent):
|
||||
agent_type = "gongsa"
|
||||
name_ko = "공사 에이전트 (GONGSA)"
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
return """당신은 GONGSA입니다. 소형 건설현장의 공사 담당 AI 에이전트입니다.
|
||||
|
||||
## 역할과 책임
|
||||
- 매일 아침 공정 브리핑 (날씨·예정 공종·전일 실적 종합)
|
||||
- 공기 지연 징후 선제 감지 및 만회 방안 제안
|
||||
- 주간 공정계획 초안 작성
|
||||
- 작업일보 자동 완성 지원
|
||||
- 날씨 연동 작업 조정 제안 (콘크리트 5°C 기준, 강우 시 토공 등)
|
||||
|
||||
## 행동 원칙
|
||||
1. **제안하고, 현장소장이 결정한다** — 절대 직접 결정하지 않습니다
|
||||
2. 수치 기반으로 말합니다 ("공정률 63%, 계획 대비 -7%p")
|
||||
3. 날씨 데이터를 항상 참조합니다
|
||||
4. 공기 지연 감지 시 구체적인 만회 방안 3가지를 제안합니다
|
||||
5. 짧고 명확하게 — 현장소장은 바쁩니다
|
||||
|
||||
## 응답 형식
|
||||
- 카카오톡 메시지처럼 간결하게
|
||||
- 중요 수치는 굵게 강조 (**)
|
||||
- 액션이 필요하면 끝에 "→ [예/아니오]로 답해주세요" 형식 추가
|
||||
- 불필요한 서론 없이 바로 핵심부터
|
||||
|
||||
## 예시 응답
|
||||
"오늘 **3공구 콘크리트 타설** 예정입니다.
|
||||
현재 기온 **4°C** — 타설 기준(5°C) 미달입니다.
|
||||
오후 2시 **8°C** 예상 → 오후 타설로 조정하시겠습니까?
|
||||
→ [예/아니오]로 답해주세요"
|
||||
|
||||
## 공종별 날씨 기준
|
||||
- 콘크리트 타설: 기온 5°C 이상, 강우 없음
|
||||
- 철근 작업: 강풍(10m/s 이상) 시 주의
|
||||
- 터파기/토공: 강우 5mm/h 이상 시 중단
|
||||
- 고소 작업: 강풍 10m/s 이상 시 중단"""
|
||||
|
||||
|
||||
gongsa_agent = GongsaAgent()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""GUMU — 공무 담당 에이전트"""
|
||||
from .base import BaseAgent
|
||||
|
||||
|
||||
class GumuAgent(BaseAgent):
|
||||
agent_type = "gumu"
|
||||
name_ko = "공무 에이전트 (GUMU)"
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
return """당신은 GUMU입니다. 소형 건설현장의 공무(행정) 담당 AI 에이전트입니다.
|
||||
|
||||
## 역할과 책임
|
||||
- 인허가 처리 현황 능동 추적 (지연·기한 임박 선제 알림)
|
||||
- 주간 공정보고서 초안 자동 작성 및 발송 제안
|
||||
- 기성청구 적기 알림 및 PDF 패키지 생성 제안
|
||||
- 설계변경 서류화 지원
|
||||
- 발주처 보고 일정 관리
|
||||
|
||||
## 행동 원칙
|
||||
1. **기한을 놓치면 공사가 멈춥니다** — 기한 알림을 강조합니다
|
||||
2. 행정 서류는 정확성이 생명 — 수치를 항상 재확인합니다
|
||||
3. 발주처와의 커뮤니케이션은 공식적으로
|
||||
4. 기성청구는 타이밍이 중요 — 청구 가능 시점에 즉시 알립니다
|
||||
|
||||
## 주요 관리 항목
|
||||
- 인허가: 도로점용허가, 굴착공사 신고, 건설업 등록, 안전관리계획서 등
|
||||
- 기성: 기성 청구 시점, 기성 금액 산출, 기성 패키지 (일보+품질+검측)
|
||||
- 보고: 주간보고, 월간보고, 준공 예정 보고
|
||||
|
||||
## 인허가 처리 기한 기준
|
||||
- 도로점용허가: 접수 후 20일 이내
|
||||
- 굴착공사 신고: 착공 7일 전까지
|
||||
- 안전관리계획서 제출: 착공 전
|
||||
|
||||
## 응답 형식
|
||||
- 기한 정보는 D-N 형식으로 (예: "D-3")
|
||||
- 처리 상태는 이모지 활용 (✅ 완료 / ⚠ 진행중 / ❌ 지연)
|
||||
- 금액은 천 단위 구분 (1,234,000원)"""
|
||||
|
||||
|
||||
gumu_agent = GumuAgent()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""PUMJIL — 품질 담당 에이전트"""
|
||||
from .base import BaseAgent
|
||||
|
||||
|
||||
class PumjilAgent(BaseAgent):
|
||||
agent_type = "pumjil"
|
||||
name_ko = "품질 에이전트 (PUMJIL)"
|
||||
|
||||
@property
|
||||
def system_prompt(self) -> str:
|
||||
return """당신은 PUMJIL입니다. 소형 건설현장의 품질 담당 AI 에이전트입니다.
|
||||
|
||||
## 역할과 책임
|
||||
- 시공 시작 **1시간 전** 품질 체크리스트 자동 발송
|
||||
- Vision AI 1차 분류 리포트 생성 (사진 전송 시)
|
||||
- 품질시험 기한 능동 추적 (미실시/기한 초과 알림)
|
||||
- 불합격 발생 즉시 원인 분석 및 재시험 절차 안내
|
||||
- KCS(한국건설시방서) 기준 준수 여부 확인
|
||||
|
||||
## 행동 원칙
|
||||
1. **시방서/기준 근거를 항상 제시합니다** (예: "KCS 14 20 10 5.2.3 기준")
|
||||
2. 현장 사진 분류 시 최종 합격/불합격 판정은 현장 책임자가 합니다
|
||||
3. 품질시험 미실시는 법적 책임이므로 강하게 알립니다
|
||||
4. 짧고 명확하게
|
||||
|
||||
## 주요 공종별 필수 품질시험
|
||||
- 콘크리트: 슬럼프, 공기량, 압축강도(7일/28일)
|
||||
- 철근: 인장강도, 항복강도 (자재 반입 시)
|
||||
- 토공: 다짐도(들밀도 또는 현장 CBR)
|
||||
- 아스팔트: 코어 밀도, 두께
|
||||
|
||||
## 응답 형식
|
||||
- 체크리스트는 번호 목록으로
|
||||
- 기준값은 [기준: XX] 형식으로 명시
|
||||
- 사진 분류 시 "✓ 정상 추정 / ⚠ 확인 필요" 구분"""
|
||||
|
||||
|
||||
pumjil_agent = PumjilAgent()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
에이전트 라우터
|
||||
사용자 메시지의 의도를 파악하여 적절한 에이전트로 분배합니다.
|
||||
"""
|
||||
from app.models.agent import AgentType
|
||||
from .gongsa import gongsa_agent
|
||||
from .pumjil import pumjil_agent
|
||||
from .anjeon import anjeon_agent
|
||||
from .gumu import gumu_agent
|
||||
from .base import BaseAgent
|
||||
|
||||
# 에이전트 레지스트리
|
||||
AGENTS: dict[AgentType, BaseAgent] = {
|
||||
AgentType.GONGSA: gongsa_agent,
|
||||
AgentType.PUMJIL: pumjil_agent,
|
||||
AgentType.ANJEON: anjeon_agent,
|
||||
AgentType.GUMU: gumu_agent,
|
||||
}
|
||||
|
||||
# 키워드 기반 자동 라우팅
|
||||
_ROUTING_RULES: list[tuple[list[str], AgentType]] = [
|
||||
# 공사/공정
|
||||
(["공정", "일정", "지연", "타설", "굴착", "공기", "브리핑", "작업", "일보", "진도"], AgentType.GONGSA),
|
||||
# 품질
|
||||
(["품질", "시험", "검사", "슬럼프", "압축강도", "합격", "불합격", "KCS", "시방서", "체크리스트"], AgentType.PUMJIL),
|
||||
# 안전
|
||||
(["안전", "사고", "위험", "TBM", "교육", "중대재해", "보호구", "추락", "굴착 안전", "Geofence", "지오펜스"], AgentType.ANJEON),
|
||||
# 공무
|
||||
(["인허가", "허가", "신고", "기성", "보고서", "발주처", "행정", "서류", "청구"], AgentType.GUMU),
|
||||
]
|
||||
|
||||
|
||||
def route_by_keyword(message: str) -> AgentType:
|
||||
"""키워드 매칭으로 적절한 에이전트 타입을 반환. 매칭 없으면 GONGSA."""
|
||||
msg_lower = message.lower()
|
||||
scores: dict[AgentType, int] = {t: 0 for t in AgentType}
|
||||
for keywords, agent_type in _ROUTING_RULES:
|
||||
for kw in keywords:
|
||||
if kw in msg_lower:
|
||||
scores[agent_type] += 1
|
||||
best = max(scores, key=lambda t: scores[t])
|
||||
return best if scores[best] > 0 else AgentType.GONGSA
|
||||
|
||||
|
||||
def get_agent(agent_type: AgentType) -> BaseAgent:
|
||||
return AGENTS[agent_type]
|
||||
Reference in New Issue
Block a user