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,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()
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user