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:
sinmb79
2026-03-24 21:49:44 +09:00
parent 0156d8ca4f
commit 48f1027f08
19 changed files with 1639 additions and 1 deletions
+44
View File
@@ -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()
+138
View File
@@ -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,
}
+46
View File
@@ -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()
+42
View File
@@ -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()
+38
View File
@@ -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()
+46
View File
@@ -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]