feat: CONAI Phase 1 MVP 초기 구현

소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼

Backend (FastAPI):
- SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings)
- API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings)
- Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API
- Alembic 마이그레이션 (pgvector 포함)
- pytest 테스트 (CPM, 날씨 경보)

Frontend (Next.js 15):
- 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정)
- TanStack Query + Zustand + Tailwind CSS

인프라:
- Docker Compose (PostgreSQL pgvector + backend + frontend)
- 한국어 README 및 설치 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-24 20:06:36 +09:00
commit 2a4950d8a0
99 changed files with 7447 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from app.deps import CurrentUser, DB
from app.models.report import Report, ReportType
from app.models.daily_report import DailyReport
from app.models.weather import WeatherAlert
from app.models.project import Project
from app.schemas.report import ReportGenerateRequest, ReportResponse
from app.services.report_gen import generate_weekly_report, generate_monthly_report
router = APIRouter(prefix="/projects/{project_id}/reports", tags=["공정보고서"])
# Report schemas (inline for simplicity)
from pydantic import BaseModel
from app.models.report import ReportType, ReportStatus
class ReportGenerateRequest(BaseModel):
report_type: ReportType
period_start: date
period_end: date
class ReportResponse(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
report_type: ReportType
period_start: date
period_end: date
ai_draft_text: str | None
status: ReportStatus
pdf_s3_key: str | None
model_config = {"from_attributes": True}
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
return p
def _compute_overall_progress(tasks) -> float:
if not tasks:
return 0.0
total = sum(t.progress_pct for t in tasks)
return total / len(tasks)
@router.get("", response_model=list[ReportResponse])
async def list_reports(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(
select(Report)
.where(Report.project_id == project_id)
.order_by(Report.period_start.desc())
)
return result.scalars().all()
@router.post("/generate", response_model=ReportResponse, status_code=status.HTTP_201_CREATED)
async def generate_report(project_id: uuid.UUID, data: ReportGenerateRequest, db: DB, current_user: CurrentUser):
"""AI-generate weekly or monthly report draft."""
project = await _get_project_or_404(project_id, db)
# Get daily reports in period
daily_result = await db.execute(
select(DailyReport).where(
DailyReport.project_id == project_id,
DailyReport.report_date >= data.period_start,
DailyReport.report_date <= data.period_end,
).order_by(DailyReport.report_date)
)
daily_reports = daily_result.scalars().all()
# Get tasks for progress
from app.models.task import Task
tasks_result = await db.execute(select(Task).where(Task.project_id == project_id))
tasks = tasks_result.scalars().all()
overall_progress = _compute_overall_progress(tasks)
if data.report_type == ReportType.WEEKLY:
# Get weather alerts in period
alerts_result = await db.execute(
select(WeatherAlert).where(
WeatherAlert.project_id == project_id,
WeatherAlert.alert_date >= data.period_start,
WeatherAlert.alert_date <= data.period_end,
)
)
weather_alerts = alerts_result.scalars().all()
ai_text, content_json = await generate_weekly_report(
project_name=project.name,
period_start=str(data.period_start),
period_end=str(data.period_end),
daily_reports=daily_reports,
overall_progress_pct=overall_progress,
weather_alerts=weather_alerts,
)
else:
ai_text, content_json = await generate_monthly_report(
project_name=project.name,
period_start=str(data.period_start),
period_end=str(data.period_end),
daily_reports=daily_reports,
overall_progress_pct=overall_progress,
)
report = Report(
project_id=project_id,
report_type=data.report_type,
period_start=data.period_start,
period_end=data.period_end,
content_json=content_json,
ai_draft_text=ai_text,
)
db.add(report)
await db.commit()
await db.refresh(report)
return report
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
return report
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
await db.delete(report)
await db.commit()