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
+4
View File
@@ -9,6 +9,8 @@ from .weather import WeatherData, WeatherAlert
from .permit import PermitItem
from .rag import RagSource, RagChunk
from .settings import ClientProfile, AlertRule, WorkTypeLibrary
from .agent import AgentConversation, AgentMessage, GeofenceZone, AgentType
from .evms import EVMSSnapshot
__all__ = [
"User",
@@ -22,4 +24,6 @@ __all__ = [
"PermitItem",
"RagSource", "RagChunk",
"ClientProfile", "AlertRule", "WorkTypeLibrary",
"AgentConversation", "AgentMessage", "GeofenceZone", "AgentType",
"EVMSSnapshot",
]
+74
View File
@@ -0,0 +1,74 @@
import uuid
import enum
from sqlalchemy import String, Text, ForeignKey, Enum as SAEnum, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class AgentType(str, enum.Enum):
GONGSA = "gongsa" # 공사 담당
PUMJIL = "pumjil" # 품질 담당
ANJEON = "anjeon" # 안전 담당
GUMU = "gumu" # 공무 담당
class ConversationStatus(str, enum.Enum):
ACTIVE = "active"
CLOSED = "closed"
class AgentConversation(Base, UUIDMixin, TimestampMixin):
"""에이전트와의 대화 세션"""
__tablename__ = "agent_conversations"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
agent_type: Mapped[AgentType] = mapped_column(SAEnum(AgentType, name="agent_type"), nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
status: Mapped[ConversationStatus] = mapped_column(
SAEnum(ConversationStatus, name="conversation_status"),
default=ConversationStatus.ACTIVE,
nullable=False,
)
# relationships
project: Mapped["Project"] = relationship("Project")
user: Mapped["User"] = relationship("User")
messages: Mapped[list["AgentMessage"]] = relationship(
"AgentMessage", back_populates="conversation", cascade="all, delete-orphan",
order_by="AgentMessage.created_at",
)
class AgentMessage(Base, UUIDMixin, TimestampMixin):
"""에이전트 대화 메시지"""
__tablename__ = "agent_messages"
conversation_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("agent_conversations.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(20), nullable=False) # user | assistant | system
content: Mapped[str] = mapped_column(Text, nullable=False)
metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # action proposals, references, etc.
is_proactive: Mapped[bool] = mapped_column(Boolean, default=False) # 에이전트가 먼저 보낸 메시지
# relationships
conversation: Mapped["AgentConversation"] = relationship("AgentConversation", back_populates="messages")
class GeofenceZone(Base, UUIDMixin, TimestampMixin):
"""익명 위험구역 Geofence"""
__tablename__ = "geofence_zones"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False) # "3공구 굴착면", "크레인 반경"
zone_type: Mapped[str] = mapped_column(String(50), nullable=False) # excavation, crane, confined_space
coordinates: Mapped[list] = mapped_column(JSONB, nullable=False) # [[lat,lng], ...]
radius_m: Mapped[float | None] = mapped_column(nullable=True) # 원형 구역용 반경(m)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# relationships
project: Mapped["Project"] = relationship("Project")
+49
View File
@@ -0,0 +1,49 @@
import uuid
from datetime import date
from sqlalchemy import Date, Float, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class EVMSSnapshot(Base, UUIDMixin, TimestampMixin):
"""
EVMS 공정 성과 스냅샷 (기준일 기준 PV/EV/AC 저장)
PV (Planned Value) = 계획 공정률 기준 예산 투입액
EV (Earned Value) = 실제 완료 공정률 기준 예산 투입액
AC (Actual Cost) = 실제 투입 비용
SPI = EV / PV (1 이상: 공정 앞서감, 미만: 지연)
CPI = EV / AC (1 이상: 비용 효율적, 미만: 비용 초과)
"""
__tablename__ = "evms_snapshots"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
# 예산 (원)
total_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
# 공정률 (%)
planned_progress: Mapped[float | None] = mapped_column(Float, nullable=True) # 계획 공정률
actual_progress: Mapped[float | None] = mapped_column(Float, nullable=True) # 실제 공정률
# EVM 핵심 지표 (원)
pv: Mapped[float | None] = mapped_column(Float, nullable=True) # Planned Value
ev: Mapped[float | None] = mapped_column(Float, nullable=True) # Earned Value
ac: Mapped[float | None] = mapped_column(Float, nullable=True) # Actual Cost
# 파생 지수
spi: Mapped[float | None] = mapped_column(Float, nullable=True) # Schedule Performance Index
cpi: Mapped[float | None] = mapped_column(Float, nullable=True) # Cost Performance Index
# 예측
eac: Mapped[float | None] = mapped_column(Float, nullable=True) # Estimate at Completion
etc: Mapped[float | None] = mapped_column(Float, nullable=True) # Estimate to Complete
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
detail_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # WBS별 세부 내역
# relationships
project: Mapped["Project"] = relationship("Project")