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:
sinmb79
2026-03-24 22:02:29 +09:00
parent 48f1027f08
commit 5a044a3882
17 changed files with 1350 additions and 8 deletions
+27
View File
@@ -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
+113
View File
@@ -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,
}
+102
View File
@@ -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
View File
@@ -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
+162
View File
@@ -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],
}
+38 -1
View File
@@ -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,