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
+276
View File
@@ -0,0 +1,276 @@
"""
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
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.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()
+134
View File
@@ -0,0 +1,134 @@
"""EVMS API — PV·EV·AC·SPI·CPI"""
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.deps import CurrentUser, DB
from app.models.evms import EVMSSnapshot
from app.models.project import Project
from app.services.evms_service import compute_evms
router = APIRouter(prefix="/projects/{project_id}/evms", tags=["EVMS"])
class EVMSRequest(BaseModel):
snapshot_date: date = date.today()
actual_cost: float | None = None # 실제 투입 비용 (없으면 추정)
save: bool = True # DB 저장 여부
class EVMSResponse(BaseModel):
id: uuid.UUID | None = None
project_id: uuid.UUID
snapshot_date: date
total_budget: float | None
planned_progress: float | None
actual_progress: float | None
pv: float | None
ev: float | None
ac: float | None
spi: float | None
cpi: float | None
eac: float | None
etc: float | None
detail_json: dict | None = None
model_config = {"from_attributes": True}
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
@router.post("/compute", response_model=EVMSResponse)
async def compute_evms_endpoint(
project_id: uuid.UUID,
data: EVMSRequest,
db: DB,
current_user: CurrentUser,
):
"""EVMS 계산 (옵션: DB 저장)"""
await _get_project_or_404(project_id, db)
result = await compute_evms(db, project_id, data.snapshot_date, data.actual_cost)
if data.save:
snapshot = EVMSSnapshot(
project_id=project_id,
snapshot_date=data.snapshot_date,
total_budget=result["total_budget"],
planned_progress=result["planned_progress"],
actual_progress=result["actual_progress"],
pv=result["pv"],
ev=result["ev"],
ac=result["ac"],
spi=result["spi"],
cpi=result["cpi"],
eac=result["eac"],
etc=result["etc"],
detail_json={"tasks": result["detail"]},
)
db.add(snapshot)
await db.commit()
await db.refresh(snapshot)
return EVMSResponse(
id=snapshot.id,
project_id=project_id,
snapshot_date=snapshot.snapshot_date,
total_budget=snapshot.total_budget,
planned_progress=snapshot.planned_progress,
actual_progress=snapshot.actual_progress,
pv=snapshot.pv, ev=snapshot.ev, ac=snapshot.ac,
spi=snapshot.spi, cpi=snapshot.cpi,
eac=snapshot.eac, etc=snapshot.etc,
detail_json=snapshot.detail_json,
)
return EVMSResponse(
project_id=project_id,
snapshot_date=data.snapshot_date,
total_budget=result["total_budget"],
planned_progress=result["planned_progress"],
actual_progress=result["actual_progress"],
pv=result["pv"], ev=result["ev"], ac=result["ac"],
spi=result["spi"], cpi=result["cpi"],
eac=result["eac"], etc=result["etc"],
detail_json={"tasks": result["detail"]},
)
@router.get("", response_model=list[EVMSResponse])
async def list_snapshots(
project_id: uuid.UUID, db: DB, current_user: CurrentUser
):
"""EVMS 스냅샷 이력 조회"""
r = await db.execute(
select(EVMSSnapshot)
.where(EVMSSnapshot.project_id == project_id)
.order_by(EVMSSnapshot.snapshot_date.desc())
)
return r.scalars().all()
@router.get("/latest", response_model=EVMSResponse)
async def latest_snapshot(
project_id: uuid.UUID, db: DB, current_user: CurrentUser
):
"""최근 EVMS 스냅샷 조회"""
r = await db.execute(
select(EVMSSnapshot)
.where(EVMSSnapshot.project_id == project_id)
.order_by(EVMSSnapshot.snapshot_date.desc())
.limit(1)
)
snap = r.scalar_one_or_none()
if not snap:
raise HTTPException(status_code=404, detail="EVMS 스냅샷이 없습니다. /compute 를 먼저 실행하세요.")
return snap
+171
View File
@@ -0,0 +1,171 @@
"""
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(),
}
+64
View File
@@ -6,6 +6,7 @@ from app.models.permit import PermitItem, PermitStatus
from app.models.project import Project
from pydantic import BaseModel
from datetime import date, datetime
from app.services.ai_engine import complete
class PermitCreate(BaseModel):
@@ -92,3 +93,66 @@ async def delete_permit(project_id: uuid.UUID, permit_id: uuid.UUID, db: DB, cur
raise HTTPException(status_code=404, detail="인허가 항목을 찾을 수 없습니다")
await db.delete(permit)
await db.commit()
class PermitDerivationRequest(BaseModel):
work_types: list[str]
construction_type: str
start_date: date | None = None
auto_create: bool = True
@router.post("/derive", response_model=list[PermitResponse], status_code=status.HTTP_201_CREATED)
async def derive_permits(
project_id: uuid.UUID,
data: PermitDerivationRequest,
db: DB,
current_user: CurrentUser,
):
"""공종 입력 → 필요 인허가 항목 AI 자동 도출"""
await _get_project_or_404(project_id, db)
system = """당신은 건설공사 인허가 전문가입니다.
공사 정보를 보고 필요한 인허가 항목을 JSON 배열로만 반환하세요.
각 항목: {"permit_type": "도로점용허가", "authority": "관할 시·군·구청", "notes": "착공 30일 전 신청"}
법령 근거를 notes에 간략히 포함하세요."""
user_msg = (
f"공사 유형: {data.construction_type}\n"
f"주요 공종: {', '.join(data.work_types)}\n"
f"착공 예정: {data.start_date or '미정'}\n\n"
"이 공사에 필요한 인허가 항목을 모두 도출해주세요. JSON 배열로만 반환하세요."
)
raw = await complete(
messages=[{"role": "user", "content": user_msg}],
system=system,
temperature=0.2,
)
import json, re
json_match = re.search(r"\[.*\]", raw, re.DOTALL)
if not json_match:
raise HTTPException(status_code=500, detail="AI 응답 파싱 실패")
items_data = json.loads(json_match.group())
created = []
if data.auto_create:
for idx, item in enumerate(items_data):
permit = PermitItem(
project_id=project_id,
permit_type=item.get("permit_type", "미정"),
authority=item.get("authority"),
required=True,
notes=item.get("notes"),
sort_order=idx,
deadline=data.start_date,
)
db.add(permit)
created.append(permit)
await db.commit()
for p in created:
await db.refresh(p)
return created
+118
View File
@@ -0,0 +1,118 @@
"""
Vision AI API — Level 1 (사진 분류) + Level 2 (안전장비 감지)
"""
import uuid
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
from fastapi.responses import JSONResponse
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.project import Project
from app.models.daily_report import DailyReport, DailyReportPhoto
from app.services.vision_service import classify_photo, analyze_safety
router = APIRouter(prefix="/projects/{project_id}/vision", tags=["Vision AI"])
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
MAX_SIZE_MB = 10
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
@router.post("/classify")
async def classify_field_photo(
project_id: uuid.UUID,
db: DB,
current_user: CurrentUser,
file: UploadFile = File(...),
location_hint: str | None = Form(None),
daily_report_id: uuid.UUID | None = Form(None),
):
"""
현장 사진 분류 (Vision AI Level 1)
- 공종 자동 분류
- 안전장비 착용 여부 1차 확인
- 작업일보 캡션 자동 생성
- daily_report_id 제공 시 해당 일보에 사진 자동 첨부
"""
await _get_project_or_404(project_id, db)
# 파일 검증
content_type = file.content_type or "image/jpeg"
if content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail=f"지원하지 않는 파일 형식입니다. 허용: {', '.join(ALLOWED_TYPES)}"
)
image_data = await file.read()
if len(image_data) > MAX_SIZE_MB * 1024 * 1024:
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
# Vision AI 분류
result = await classify_photo(
image_data=image_data,
media_type=content_type,
location_hint=location_hint,
)
# 작업일보에 사진 첨부 (daily_report_id 제공 시)
if daily_report_id:
dr = await db.execute(
select(DailyReport).where(
DailyReport.id == daily_report_id,
DailyReport.project_id == project_id,
)
)
report = dr.scalar_one_or_none()
if report:
# 사진 수 카운트
photos_q = await db.execute(
select(DailyReportPhoto).where(DailyReportPhoto.daily_report_id == daily_report_id)
)
existing = photos_q.scalars().all()
photo = DailyReportPhoto(
daily_report_id=daily_report_id,
s3_key=f"vision/{project_id}/{daily_report_id}/{file.filename or 'photo.jpg'}",
caption=result.get("caption", ""),
sort_order=len(existing),
)
db.add(photo)
await db.commit()
result["attached_to_report"] = str(daily_report_id)
return JSONResponse(content=result)
@router.post("/safety-check")
async def safety_check(
project_id: uuid.UUID,
db: DB,
current_user: CurrentUser,
file: UploadFile = File(...),
):
"""
안전장비 착용 감지 (Vision AI Level 2)
안전모/안전조끼 착용 여부를 분석하고 위반 사항을 반환합니다.
최종 판정은 현장 책임자가 합니다.
"""
await _get_project_or_404(project_id, db)
content_type = file.content_type or "image/jpeg"
if content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다")
image_data = await file.read()
if len(image_data) > MAX_SIZE_MB * 1024 * 1024:
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
result = await analyze_safety(image_data=image_data, media_type=content_type)
result["disclaimer"] = "이 결과는 AI 1차 분석이며, 최종 판정은 현장 책임자가 합니다."
return JSONResponse(content=result)