feat: Phase 3 구현 — 완전 자동화, 준공도서, Vision L3, 발주처 포털
EVMS 완전 자동화: - 공기 지연 AI 예측 (SPI 기반 준공일 예측) - 기성청구 가능 금액 자동 산출 - 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler) - 매일 07:00 GONGSA 아침 브리핑 자동 생성 준공도서 패키지: - 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들 - 준공 준비 체크리스트 API - 4종 HTML 템플릿 (WeasyPrint PDF 출력) Vision AI Level 3: - 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision) - 철근 배근, 거푸집 치수 1차 분석 설계도서 파싱: - PDF 이미지/텍스트에서 공종·수량·규격 자동 추출 - Pandoc HWP 출력 지원 발주처 전용 포털: - 토큰 기반 읽기 전용 API - 공사 현황 대시보드, 공정률 추이 차트 에이전트 협업 고도화: - 협업 시나리오 (concrete_pour, excavation, weekly_report) - GONGSA→PUMJIL→ANJEON 순차 처리 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ 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
|
||||
from app.services.agents.collaboration import run_scenario, SCENARIO_AGENTS
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/agents", tags=["AI 에이전트"])
|
||||
|
||||
@@ -267,6 +268,32 @@ async def morning_briefing(
|
||||
return msg
|
||||
|
||||
|
||||
@router.post("/scenario/{scenario_name}")
|
||||
async def run_collaboration_scenario(
|
||||
project_id: uuid.UUID,
|
||||
scenario_name: str,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
에이전트 협업 시나리오 실행 (Phase 3).
|
||||
복수 에이전트가 순차적으로 하나의 현장 상황을 처리합니다.
|
||||
|
||||
사용 가능 시나리오:
|
||||
- concrete_pour: 콘크리트 타설 (GONGSA → PUMJIL → ANJEON)
|
||||
- excavation: 굴착 작업 (GONGSA → ANJEON → PUMJIL)
|
||||
- weekly_report: 주간 보고 (GONGSA → PUMJIL → GUMU)
|
||||
"""
|
||||
if scenario_name not in SCENARIO_AGENTS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"알 수 없는 시나리오. 가능한 값: {list(SCENARIO_AGENTS.keys())}",
|
||||
)
|
||||
await _get_project_or_404(project_id, db)
|
||||
results = await run_scenario(db, project_id, current_user.id, scenario_name)
|
||||
return {"scenario": scenario_name, "steps": results}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""준공도서 패키지 API"""
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
import io
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.deps import CurrentUser, DB
|
||||
from app.models.project import Project
|
||||
from app.services.completion_service import build_completion_package
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/completion", tags=["준공도서"])
|
||||
|
||||
|
||||
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.get("/download")
|
||||
async def download_completion_package(
|
||||
project_id: uuid.UUID,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
준공도서 ZIP 패키지 다운로드
|
||||
포함 문서:
|
||||
- 준공 요약 (전체 실적)
|
||||
- 품질시험 목록 (전체)
|
||||
- 검측 이력 (전체)
|
||||
- 인허가 현황 (전체)
|
||||
"""
|
||||
await _get_project_or_404(project_id, db)
|
||||
zip_bytes, filename = await build_completion_package(db, project_id)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_bytes),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/checklist")
|
||||
async def completion_checklist(
|
||||
project_id: uuid.UUID,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
준공 준비 체크리스트 — 부족한 서류/데이터 현황 반환
|
||||
"""
|
||||
from app.models.daily_report import DailyReport
|
||||
from app.models.quality import QualityTest
|
||||
from app.models.inspection import InspectionRequest, InspectionStatus
|
||||
from app.models.permit import PermitItem, PermitStatus
|
||||
from sqlalchemy import func
|
||||
|
||||
await _get_project_or_404(project_id, db)
|
||||
|
||||
async def count(model, where):
|
||||
r = await db.execute(select(func.count()).where(*where))
|
||||
return r.scalar() or 0
|
||||
|
||||
total_dr = await count(DailyReport, [DailyReport.project_id == project_id])
|
||||
total_qt = await count(QualityTest, [QualityTest.project_id == project_id])
|
||||
total_insp = await count(InspectionRequest, [InspectionRequest.project_id == project_id])
|
||||
done_insp = await count(InspectionRequest, [
|
||||
InspectionRequest.project_id == project_id,
|
||||
InspectionRequest.status == InspectionStatus.COMPLETED,
|
||||
])
|
||||
total_per = await count(PermitItem, [PermitItem.project_id == project_id])
|
||||
approved_per = await count(PermitItem, [
|
||||
PermitItem.project_id == project_id,
|
||||
PermitItem.status == PermitStatus.APPROVED,
|
||||
])
|
||||
|
||||
checks = [
|
||||
{
|
||||
"item": "작업일보",
|
||||
"count": total_dr,
|
||||
"status": "준비완료" if total_dr > 0 else "누락",
|
||||
"ok": total_dr > 0,
|
||||
},
|
||||
{
|
||||
"item": "품질시험 기록",
|
||||
"count": total_qt,
|
||||
"status": "준비완료" if total_qt > 0 else "누락",
|
||||
"ok": total_qt > 0,
|
||||
},
|
||||
{
|
||||
"item": "검측 완료",
|
||||
"count": f"{done_insp}/{total_insp}",
|
||||
"status": "완료" if total_insp > 0 and done_insp == total_insp else f"미완료 {total_insp - done_insp}건",
|
||||
"ok": total_insp > 0 and done_insp == total_insp,
|
||||
},
|
||||
{
|
||||
"item": "인허가 취득",
|
||||
"count": f"{approved_per}/{total_per}",
|
||||
"status": "완료" if total_per > 0 and approved_per == total_per else f"미취득 {total_per - approved_per}건",
|
||||
"ok": total_per > 0 and approved_per == total_per,
|
||||
},
|
||||
]
|
||||
|
||||
all_ok = all(c["ok"] for c in checks)
|
||||
return {
|
||||
"ready": all_ok,
|
||||
"summary": "준공 준비 완료" if all_ok else "준공 서류 미비 항목이 있습니다",
|
||||
"checks": checks,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
설계도서 파싱 + HWP 출력 API
|
||||
"""
|
||||
import uuid
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.deps import CurrentUser, DB
|
||||
from app.models.project import Project
|
||||
from app.services.document_parser import (
|
||||
parse_design_document_text,
|
||||
parse_design_document_image,
|
||||
convert_to_hwp,
|
||||
)
|
||||
from app.services.pdf_service import _render_html, _html_to_pdf
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/documents", tags=["설계도서"])
|
||||
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
MAX_SIZE_MB = 20
|
||||
|
||||
|
||||
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("/parse-image")
|
||||
async def parse_document_image(
|
||||
project_id: uuid.UUID,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(..., description="도면 이미지 (JPG/PNG)"),
|
||||
):
|
||||
"""
|
||||
설계 도면 이미지 → 공종/수량/규격 자동 추출 (Claude Vision)
|
||||
"""
|
||||
await _get_project_or_404(project_id, db)
|
||||
|
||||
content_type = file.content_type or "image/jpeg"
|
||||
if content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="JPG, PNG, WEBP 이미지만 지원합니다")
|
||||
|
||||
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 parse_design_document_image(image_data, content_type)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/parse-text")
|
||||
async def parse_document_text(
|
||||
project_id: uuid.UUID,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
file: UploadFile = File(..., description="설계도서 텍스트 파일 (TXT/MD)"),
|
||||
):
|
||||
"""
|
||||
설계도서 텍스트 파일 → 공종/수량/규격 자동 추출
|
||||
"""
|
||||
await _get_project_or_404(project_id, db)
|
||||
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
result = await parse_design_document_text(text)
|
||||
return result
|
||||
|
||||
|
||||
class HWPRequest(BaseModel):
|
||||
html_content: str
|
||||
filename: str = "document.hwp"
|
||||
|
||||
|
||||
@router.post("/export-hwp")
|
||||
async def export_hwp(
|
||||
project_id: uuid.UUID,
|
||||
data: HWPRequest,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
HTML → HWP 변환 (Pandoc 필요)
|
||||
보고서나 일보를 HWP 형식으로 내보냅니다.
|
||||
"""
|
||||
await _get_project_or_404(project_id, db)
|
||||
|
||||
try:
|
||||
hwp_bytes = convert_to_hwp(data.html_content)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
return Response(
|
||||
content=hwp_bytes,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{data.filename}"},
|
||||
)
|
||||
+59
-1
@@ -9,7 +9,7 @@ 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
|
||||
from app.services.evms_service import compute_evms, predict_delay, compute_progress_claim
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/evms", tags=["EVMS"])
|
||||
|
||||
@@ -117,6 +117,64 @@ async def list_snapshots(
|
||||
return r.scalars().all()
|
||||
|
||||
|
||||
@router.get("/delay-forecast")
|
||||
async def delay_forecast(
|
||||
project_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||
):
|
||||
"""공기 지연 AI 예측 (최근 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 스냅샷이 없습니다")
|
||||
|
||||
project = await _get_project_or_404(project_id, db)
|
||||
planned_end = project.end_date
|
||||
if planned_end and not isinstance(planned_end, date):
|
||||
from datetime import date as ddate
|
||||
planned_end = ddate.fromisoformat(str(planned_end))
|
||||
|
||||
forecast = await predict_delay(
|
||||
db, project_id,
|
||||
spi=snap.spi,
|
||||
planned_end=planned_end,
|
||||
snapshot_date=snap.snapshot_date,
|
||||
)
|
||||
forecast["spi"] = snap.spi
|
||||
forecast["cpi"] = snap.cpi
|
||||
forecast["snapshot_date"] = str(snap.snapshot_date)
|
||||
return forecast
|
||||
|
||||
|
||||
@router.get("/progress-claim")
|
||||
async def progress_claim(
|
||||
project_id: uuid.UUID,
|
||||
already_claimed_pct: float = 0.0,
|
||||
db: DB = None,
|
||||
current_user: CurrentUser = None,
|
||||
):
|
||||
"""기성청구 가능 금액 산출"""
|
||||
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 await compute_progress_claim(
|
||||
total_budget=snap.total_budget or 0,
|
||||
actual_progress=snap.actual_progress or 0,
|
||||
already_claimed_pct=already_claimed_pct,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/latest", response_model=EVMSResponse)
|
||||
async def latest_snapshot(
|
||||
project_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
발주처 전용 포털 API (읽기 전용)
|
||||
토큰 기반 인증으로 발주처가 공사 현황을 실시간 확인합니다.
|
||||
- 로그인 없이 토큰만으로 접근 가능
|
||||
- 쓰기 권한 없음
|
||||
- 민감 정보 제외 (계약 단가 등)
|
||||
"""
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import date, datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.deps import DB
|
||||
from app.models.project import Project
|
||||
from app.models.daily_report import DailyReport, ReportStatus
|
||||
from app.models.quality import QualityTest, QualityResult
|
||||
from app.models.inspection import InspectionRequest
|
||||
from app.models.weather import WeatherAlert
|
||||
from app.models.evms import EVMSSnapshot
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["발주처 포털"])
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
# 간단한 인메모리 토큰 저장소 (운영에서는 DB/Redis로 교체)
|
||||
_portal_tokens: dict[str, dict] = {}
|
||||
|
||||
|
||||
def _verify_portal_token(credentials: HTTPAuthorizationCredentials = Depends(_bearer)) -> dict:
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="포털 토큰이 필요합니다")
|
||||
token = credentials.credentials
|
||||
info = _portal_tokens.get(token)
|
||||
if not info:
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 포털 토큰입니다")
|
||||
if datetime.fromisoformat(info["expires_at"]) < datetime.now():
|
||||
del _portal_tokens[token]
|
||||
raise HTTPException(status_code=401, detail="포털 토큰이 만료되었습니다")
|
||||
return info
|
||||
|
||||
|
||||
PortalAuth = Depends(_verify_portal_token)
|
||||
|
||||
|
||||
class TokenCreateRequest(BaseModel):
|
||||
project_id: uuid.UUID
|
||||
expires_days: int = 30
|
||||
label: str = "발주처"
|
||||
|
||||
|
||||
@router.post("/tokens", summary="포털 접근 토큰 발급 (관리자용)")
|
||||
async def create_portal_token(data: TokenCreateRequest, db: DB):
|
||||
"""
|
||||
발주처에게 공유할 읽기 전용 토큰을 발급합니다.
|
||||
이 엔드포인트는 현장 관리자만 호출해야 합니다 (별도 인증 추가 권장).
|
||||
"""
|
||||
r = await db.execute(select(Project).where(Project.id == data.project_id))
|
||||
project = r.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires_at = (datetime.now() + timedelta(days=data.expires_days)).isoformat()
|
||||
_portal_tokens[token] = {
|
||||
"project_id": str(data.project_id),
|
||||
"project_name": project.name,
|
||||
"label": data.label,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"project_name": project.name,
|
||||
"expires_at": expires_at,
|
||||
"portal_url": f"/portal/dashboard (Authorization: Bearer {token[:8]}...)",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard", summary="발주처 공사 현황 대시보드")
|
||||
async def portal_dashboard(auth: dict = PortalAuth, db: DB = None):
|
||||
"""발주처용 공사 현황 요약 (읽기 전용)"""
|
||||
project_id = uuid.UUID(auth["project_id"])
|
||||
today = date.today()
|
||||
|
||||
# 프로젝트 기본 정보
|
||||
proj_r = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = proj_r.scalar_one_or_none()
|
||||
|
||||
# 최근 작업일보 (3건)
|
||||
dr_r = await db.execute(
|
||||
select(DailyReport)
|
||||
.where(DailyReport.project_id == project_id, DailyReport.status == ReportStatus.CONFIRMED)
|
||||
.order_by(DailyReport.report_date.desc())
|
||||
.limit(3)
|
||||
)
|
||||
recent_reports = [
|
||||
{"date": str(r.report_date), "weather": r.weather_summary, "work": r.work_content[:100] if r.work_content else ""}
|
||||
for r in dr_r.scalars().all()
|
||||
]
|
||||
|
||||
# 품질시험 합격률
|
||||
total_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id))
|
||||
total_qt = total_qt_r.scalar() or 0
|
||||
pass_qt_r = await db.execute(select(func.count()).where(QualityTest.project_id == project_id, QualityTest.result == QualityResult.PASS))
|
||||
pass_qt = pass_qt_r.scalar() or 0
|
||||
|
||||
# EVMS 최신
|
||||
evms_r = await db.execute(
|
||||
select(EVMSSnapshot).where(EVMSSnapshot.project_id == project_id).order_by(EVMSSnapshot.snapshot_date.desc()).limit(1)
|
||||
)
|
||||
evms = evms_r.scalar_one_or_none()
|
||||
|
||||
# 활성 날씨 경보
|
||||
alert_r = await db.execute(
|
||||
select(WeatherAlert).where(WeatherAlert.project_id == project_id, WeatherAlert.alert_date == today, WeatherAlert.acknowledged == False)
|
||||
)
|
||||
active_alerts = [{"type": a.alert_type, "message": a.message} for a in alert_r.scalars().all()]
|
||||
|
||||
return {
|
||||
"project": {
|
||||
"name": project.name if project else "-",
|
||||
"start_date": str(project.start_date) if project and project.start_date else None,
|
||||
"end_date": str(project.end_date) if project and project.end_date else None,
|
||||
"status": project.status.value if project else "-",
|
||||
},
|
||||
"progress": {
|
||||
"planned": evms.planned_progress if evms else None,
|
||||
"actual": evms.actual_progress if evms else None,
|
||||
"spi": evms.spi if evms else None,
|
||||
"snapshot_date": str(evms.snapshot_date) if evms else None,
|
||||
},
|
||||
"quality": {
|
||||
"total_tests": total_qt,
|
||||
"pass_rate": round(pass_qt / total_qt * 100, 1) if total_qt else None,
|
||||
},
|
||||
"recent_reports": recent_reports,
|
||||
"active_alerts": active_alerts,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/progress-chart", summary="공정률 추이 데이터")
|
||||
async def portal_progress_chart(auth: dict = PortalAuth, db: DB = None):
|
||||
"""발주처용 공정률 추이 차트 데이터"""
|
||||
project_id = uuid.UUID(auth["project_id"])
|
||||
r = await db.execute(
|
||||
select(EVMSSnapshot)
|
||||
.where(EVMSSnapshot.project_id == project_id)
|
||||
.order_by(EVMSSnapshot.snapshot_date)
|
||||
.limit(90) # 최근 90일
|
||||
)
|
||||
snapshots = r.scalars().all()
|
||||
return {
|
||||
"labels": [str(s.snapshot_date) for s in snapshots],
|
||||
"planned": [s.planned_progress for s in snapshots],
|
||||
"actual": [s.actual_progress for s in snapshots],
|
||||
"spi": [s.spi for s in snapshots],
|
||||
}
|
||||
@@ -9,10 +9,12 @@ 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
|
||||
from app.services.vision_service import classify_photo, analyze_safety, compare_with_drawing
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/vision", tags=["Vision AI"])
|
||||
|
||||
COMPARISON_TYPES = {"rebar": "철근 배근", "formwork": "거푸집", "general": "일반 비교"}
|
||||
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}
|
||||
MAX_SIZE_MB = 10
|
||||
|
||||
@@ -91,6 +93,41 @@ async def classify_field_photo(
|
||||
return JSONResponse(content=result)
|
||||
|
||||
|
||||
@router.post("/compare-drawing")
|
||||
async def compare_drawing(
|
||||
project_id: uuid.UUID,
|
||||
db: DB,
|
||||
current_user: CurrentUser,
|
||||
field_photo: UploadFile = File(..., description="현장 사진"),
|
||||
drawing: UploadFile = File(..., description="설계 도면 이미지"),
|
||||
comparison_type: str = Form("rebar", description="rebar/formwork/general"),
|
||||
):
|
||||
"""
|
||||
Vision AI Level 3 — 설계 도면 vs 현장 사진 비교 보조 판독
|
||||
철근 배근, 거푸집 치수 등을 도면과 1차 비교합니다.
|
||||
⚠️ 최종 합격/불합격 판정은 현장 책임자가 합니다.
|
||||
"""
|
||||
await _get_project_or_404(project_id, db)
|
||||
|
||||
if comparison_type not in COMPARISON_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"comparison_type은 {list(COMPARISON_TYPES.keys())} 중 하나여야 합니다")
|
||||
|
||||
field_data = await field_photo.read()
|
||||
drawing_data = await drawing.read()
|
||||
|
||||
if len(field_data) > MAX_SIZE_MB * 1024 * 1024 or len(drawing_data) > MAX_SIZE_MB * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail=f"파일 크기가 {MAX_SIZE_MB}MB를 초과합니다")
|
||||
|
||||
result = await compare_with_drawing(
|
||||
field_photo=field_data,
|
||||
drawing_image=drawing_data,
|
||||
comparison_type=comparison_type,
|
||||
field_media_type=field_photo.content_type or "image/jpeg",
|
||||
drawing_media_type=drawing.content_type or "image/jpeg",
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
|
||||
|
||||
@router.post("/safety-check")
|
||||
async def safety_check(
|
||||
project_id: uuid.UUID,
|
||||
|
||||
Reference in New Issue
Block a user