Files
conai/backend/app/api/geofence.py
sinmb79 48f1027f08 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>
2026-03-24 21:49:44 +09:00

172 lines
5.4 KiB
Python

"""
Geofence 위험구역 API
- 위험구역 CRUD (익명 — 개인 이동 경로 비수집)
- 진입 이벤트 웹훅 (카카오맵 또는 모바일 앱에서 호출)
- ANJEON 에이전트 연동 경보
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.agent import GeofenceZone
from app.models.project import Project
router = APIRouter(prefix="/projects/{project_id}/geofence", tags=["Geofence 위험구역"])
ZONE_TYPE_LABELS = {
"excavation": "굴착면",
"crane": "크레인 반경",
"confined": "밀폐공간",
"high_voltage": "고압선 인근",
"slope": "비탈면",
"custom": "사용자 정의",
}
class ZoneCreate(BaseModel):
name: str
zone_type: str
coordinates: list[list[float]] # [[lat, lng], ...]
radius_m: float | None = None
description: str | None = None
class ZoneUpdate(BaseModel):
name: str | None = None
is_active: bool | None = None
description: str | None = None
class ZoneResponse(BaseModel):
id: uuid.UUID
name: str
zone_type: str
zone_type_label: str
coordinates: list
radius_m: float | None
is_active: bool
description: str | None
model_config = {"from_attributes": True}
class EntryEvent(BaseModel):
"""위험구역 진입 이벤트 (개인 식별 정보 없음)"""
zone_id: uuid.UUID
device_token: str # 익명 토큰 (개인 특정 불가)
timestamp: datetime | None = None
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
def _to_response(zone: GeofenceZone) -> ZoneResponse:
return ZoneResponse(
id=zone.id,
name=zone.name,
zone_type=zone.zone_type,
zone_type_label=ZONE_TYPE_LABELS.get(zone.zone_type, zone.zone_type),
coordinates=zone.coordinates,
radius_m=zone.radius_m,
is_active=zone.is_active,
description=zone.description,
)
@router.get("", response_model=list[ZoneResponse])
async def list_zones(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
r = await db.execute(
select(GeofenceZone).where(GeofenceZone.project_id == project_id).order_by(GeofenceZone.created_at)
)
return [_to_response(z) for z in r.scalars().all()]
@router.post("", response_model=ZoneResponse, status_code=status.HTTP_201_CREATED)
async def create_zone(
project_id: uuid.UUID, data: ZoneCreate, db: DB, current_user: CurrentUser
):
await _get_project_or_404(project_id, db)
zone = GeofenceZone(**data.model_dump(), project_id=project_id)
db.add(zone)
await db.commit()
await db.refresh(zone)
return _to_response(zone)
@router.put("/{zone_id}", response_model=ZoneResponse)
async def update_zone(
project_id: uuid.UUID, zone_id: uuid.UUID, data: ZoneUpdate, db: DB, current_user: CurrentUser
):
r = await db.execute(
select(GeofenceZone).where(GeofenceZone.id == zone_id, GeofenceZone.project_id == project_id)
)
zone = r.scalar_one_or_none()
if not zone:
raise HTTPException(status_code=404, detail="구역을 찾을 수 없습니다")
for k, v in data.model_dump(exclude_none=True).items():
setattr(zone, k, v)
await db.commit()
await db.refresh(zone)
return _to_response(zone)
@router.delete("/{zone_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_zone(
project_id: uuid.UUID, zone_id: uuid.UUID, db: DB, current_user: CurrentUser
):
r = await db.execute(
select(GeofenceZone).where(GeofenceZone.id == zone_id, GeofenceZone.project_id == project_id)
)
zone = r.scalar_one_or_none()
if not zone:
raise HTTPException(status_code=404, detail="구역을 찾을 수 없습니다")
await db.delete(zone)
await db.commit()
@router.post("/entry-event")
async def zone_entry_event(
project_id: uuid.UUID,
event: EntryEvent,
db: DB,
):
"""
위험구역 진입 감지 웹훅 (모바일 앱 또는 카카오맵에서 호출)
- 개인 이동 경로 비수집
- 진입 이벤트만 기록하고 ANJEON 에이전트가 경보를 생성합니다
"""
r = await db.execute(
select(GeofenceZone).where(
GeofenceZone.id == event.zone_id,
GeofenceZone.project_id == project_id,
GeofenceZone.is_active == True,
)
)
zone = r.scalar_one_or_none()
if not zone:
raise HTTPException(status_code=404, detail="활성 위험구역을 찾을 수 없습니다")
# ANJEON 에이전트로 경보 메시지 생성
from app.services.agents.anjeon import anjeon_agent
alert_message = (
f"⚠️ 위험구역 진입 감지\n"
f"구역: {zone.name} ({ZONE_TYPE_LABELS.get(zone.zone_type, zone.zone_type)})\n"
f"시각: {(event.timestamp or datetime.now()).strftime('%H:%M')}\n"
f"즉시 해당 구역을 확인하고 안전 조치를 취하세요."
)
return {
"zone_id": str(zone.id),
"zone_name": zone.name,
"alert": alert_message,
"timestamp": (event.timestamp or datetime.now()).isoformat(),
}