feat: Phase 1 잔여 기능 구현 완료
- 품질시험 API: schemas/quality.py + api/quality.py (CRUD, 합격률 요약, 자동 합불 판정) - PDF 생성: WeasyPrint + Jinja2 (작업일보/검측요청서/보고서 템플릿 + /pdf 다운로드 엔드포인트) - RAG 시드 스크립트: scripts/seed_rag.py (PDF/TXT 청킹, 배치 임베딩, CLI) - APScheduler: 날씨 3시간 주기 자동 수집 + 경보 평가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.deps import CurrentUser, DB
|
from app.deps import CurrentUser, DB
|
||||||
from app.models.daily_report import DailyReport, InputSource
|
from app.models.daily_report import DailyReport, InputSource
|
||||||
@@ -9,6 +10,7 @@ from app.schemas.daily_report import (
|
|||||||
DailyReportCreate, DailyReportUpdate, DailyReportGenerateRequest, DailyReportResponse
|
DailyReportCreate, DailyReportUpdate, DailyReportGenerateRequest, DailyReportResponse
|
||||||
)
|
)
|
||||||
from app.services.daily_report_gen import generate_work_content
|
from app.services.daily_report_gen import generate_work_content
|
||||||
|
from app.services.pdf_service import generate_daily_report_pdf
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/daily-reports", tags=["작업일보"])
|
router = APIRouter(prefix="/projects/{project_id}/daily-reports", tags=["작업일보"])
|
||||||
|
|
||||||
@@ -97,6 +99,20 @@ async def update_report(project_id: uuid.UUID, report_id: uuid.UUID, data: Daily
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{report_id}/pdf")
|
||||||
|
async def download_report_pdf(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
|
"""작업일보 PDF 다운로드"""
|
||||||
|
r = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
|
||||||
|
report = r.scalar_one_or_none()
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=404, detail="일보를 찾을 수 없습니다")
|
||||||
|
project = await _get_project_or_404(project_id, db)
|
||||||
|
pdf_bytes = generate_daily_report_pdf(report, project)
|
||||||
|
filename = f"daily_report_{report.report_date}.pdf"
|
||||||
|
return Response(content=pdf_bytes, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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):
|
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
|
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.deps import CurrentUser, DB
|
from app.deps import CurrentUser, DB
|
||||||
from app.models.inspection import InspectionRequest
|
from app.models.inspection import InspectionRequest
|
||||||
from app.models.project import Project, WBSItem
|
from app.models.project import Project, WBSItem
|
||||||
from app.schemas.inspection import InspectionCreate, InspectionUpdate, InspectionGenerateRequest, InspectionResponse
|
from app.schemas.inspection import InspectionCreate, InspectionUpdate, InspectionGenerateRequest, InspectionResponse
|
||||||
from app.services.inspection_gen import generate_checklist
|
from app.services.inspection_gen import generate_checklist
|
||||||
|
from app.services.pdf_service import generate_inspection_pdf
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/inspections", tags=["검측요청서"])
|
router = APIRouter(prefix="/projects/{project_id}/inspections", tags=["검측요청서"])
|
||||||
|
|
||||||
@@ -96,6 +98,20 @@ async def update_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, dat
|
|||||||
return insp
|
return insp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{inspection_id}/pdf")
|
||||||
|
async def download_inspection_pdf(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
|
"""검측요청서 PDF 다운로드"""
|
||||||
|
r = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
|
||||||
|
insp = r.scalar_one_or_none()
|
||||||
|
if not insp:
|
||||||
|
raise HTTPException(status_code=404, detail="검측요청서를 찾을 수 없습니다")
|
||||||
|
project = await _get_project_or_404(project_id, db)
|
||||||
|
pdf_bytes = generate_inspection_pdf(insp, project)
|
||||||
|
filename = f"inspection_{insp.requested_date}_{insp.inspection_type}.pdf"
|
||||||
|
return Response(content=pdf_bytes, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{inspection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{inspection_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
async def delete_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
|
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
|
||||||
|
|||||||
137
backend/app/api/quality.py
Normal file
137
backend/app/api/quality.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.deps import CurrentUser, DB
|
||||||
|
from app.models.quality import QualityTest, QualityResult
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.schemas.quality import QualityTestCreate, QualityTestUpdate, QualityTestResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/quality", tags=["품질시험"])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[QualityTestResponse])
|
||||||
|
async def list_quality_tests(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
test_type: str | None = None,
|
||||||
|
result: QualityResult | None = None,
|
||||||
|
):
|
||||||
|
query = select(QualityTest).where(QualityTest.project_id == project_id)
|
||||||
|
if test_type:
|
||||||
|
query = query.where(QualityTest.test_type == test_type)
|
||||||
|
if result:
|
||||||
|
query = query.where(QualityTest.result == result)
|
||||||
|
query = query.order_by(QualityTest.test_date.desc())
|
||||||
|
rows = await db.execute(query)
|
||||||
|
return rows.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=QualityTestResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_quality_test(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
data: QualityTestCreate,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
await _get_project_or_404(project_id, db)
|
||||||
|
test = QualityTest(**data.model_dump(), project_id=project_id)
|
||||||
|
db.add(test)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def quality_summary(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
|
"""프로젝트 품질시험 합격률 요약"""
|
||||||
|
total_q = await db.execute(
|
||||||
|
select(func.count()).where(QualityTest.project_id == project_id)
|
||||||
|
)
|
||||||
|
total = total_q.scalar() or 0
|
||||||
|
|
||||||
|
pass_q = await db.execute(
|
||||||
|
select(func.count()).where(
|
||||||
|
QualityTest.project_id == project_id,
|
||||||
|
QualityTest.result == QualityResult.PASS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
passed = pass_q.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"passed": passed,
|
||||||
|
"failed": total - passed,
|
||||||
|
"pass_rate": round(passed / total * 100, 1) if total > 0 else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{test_id}", response_model=QualityTestResponse)
|
||||||
|
async def get_quality_test(
|
||||||
|
project_id: uuid.UUID, test_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualityTest).where(
|
||||||
|
QualityTest.id == test_id, QualityTest.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{test_id}", response_model=QualityTestResponse)
|
||||||
|
async def update_quality_test(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
data: QualityTestUpdate,
|
||||||
|
db: DB,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualityTest).where(
|
||||||
|
QualityTest.id == test_id, QualityTest.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# 측정값/기준값 변경 시 합격 여부 재계산
|
||||||
|
new_measured = update_data.get("measured_value", test.measured_value)
|
||||||
|
new_design = update_data.get("design_value", test.design_value)
|
||||||
|
if "result" not in update_data and new_design is not None:
|
||||||
|
update_data["result"] = QualityResult.PASS if new_measured >= new_design else QualityResult.FAIL
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(test, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(test)
|
||||||
|
return test
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_quality_test(
|
||||||
|
project_id: uuid.UUID, test_id: uuid.UUID, db: DB, current_user: CurrentUser
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(QualityTest).where(
|
||||||
|
QualityTest.id == test_id, QualityTest.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
test = result.scalar_one_or_none()
|
||||||
|
if not test:
|
||||||
|
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
|
||||||
|
await db.delete(test)
|
||||||
|
await db.commit()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from app.deps import CurrentUser, DB
|
from app.deps import CurrentUser, DB
|
||||||
from app.models.report import Report, ReportType
|
from app.models.report import Report, ReportType
|
||||||
@@ -9,6 +10,7 @@ from app.models.weather import WeatherAlert
|
|||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.schemas.report import ReportGenerateRequest, ReportResponse
|
from app.schemas.report import ReportGenerateRequest, ReportResponse
|
||||||
from app.services.report_gen import generate_weekly_report, generate_monthly_report
|
from app.services.report_gen import generate_weekly_report, generate_monthly_report
|
||||||
|
from app.services.pdf_service import generate_report_pdf
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/reports", tags=["공정보고서"])
|
router = APIRouter(prefix="/projects/{project_id}/reports", tags=["공정보고서"])
|
||||||
|
|
||||||
@@ -134,6 +136,20 @@ async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, curren
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{report_id}/pdf")
|
||||||
|
async def download_report_pdf(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
|
||||||
|
"""공정보고서 PDF 다운로드"""
|
||||||
|
r = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
|
||||||
|
report = r.scalar_one_or_none()
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
|
||||||
|
project = await _get_project_or_404(project_id, db)
|
||||||
|
pdf_bytes = generate_report_pdf(report, project)
|
||||||
|
filename = f"report_{report.report_type.value}_{report.period_start}.pdf"
|
||||||
|
return Response(content=pdf_bytes, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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):
|
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))
|
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api import auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings as settings_router
|
from app.api import auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, quality, settings as settings_router
|
||||||
|
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup: seed default work types, check DB connection
|
# Startup
|
||||||
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
# Shutdown: cleanup resources
|
# Shutdown
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -41,6 +44,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(rag.router, prefix=api_prefix)
|
app.include_router(rag.router, prefix=api_prefix)
|
||||||
app.include_router(kakao.router, prefix=api_prefix)
|
app.include_router(kakao.router, prefix=api_prefix)
|
||||||
app.include_router(permits.router, prefix=api_prefix)
|
app.include_router(permits.router, prefix=api_prefix)
|
||||||
|
app.include_router(quality.router, prefix=api_prefix)
|
||||||
app.include_router(settings_router.router, prefix=api_prefix)
|
app.include_router(settings_router.router, prefix=api_prefix)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
57
backend/app/schemas/quality.py
Normal file
57
backend/app/schemas/quality.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
from app.models.quality import QualityResult
|
||||||
|
|
||||||
|
|
||||||
|
class QualityTestCreate(BaseModel):
|
||||||
|
wbs_item_id: uuid.UUID | None = None
|
||||||
|
test_type: str # compression_strength, slump, compaction, etc.
|
||||||
|
test_date: date
|
||||||
|
location_detail: str | None = None
|
||||||
|
design_value: float | None = None
|
||||||
|
measured_value: float
|
||||||
|
unit: str
|
||||||
|
result: QualityResult | None = None # auto-calculated if design_value provided
|
||||||
|
lab_name: str | None = None
|
||||||
|
report_number: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def auto_result(self) -> "QualityTestCreate":
|
||||||
|
if self.result is None:
|
||||||
|
if self.design_value is not None:
|
||||||
|
self.result = QualityResult.PASS if self.measured_value >= self.design_value else QualityResult.FAIL
|
||||||
|
else:
|
||||||
|
self.result = QualityResult.PASS
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class QualityTestUpdate(BaseModel):
|
||||||
|
test_date: date | None = None
|
||||||
|
location_detail: str | None = None
|
||||||
|
design_value: float | None = None
|
||||||
|
measured_value: float | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
result: QualityResult | None = None
|
||||||
|
lab_name: str | None = None
|
||||||
|
report_number: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class QualityTestResponse(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
wbs_item_id: uuid.UUID | None
|
||||||
|
test_type: str
|
||||||
|
test_date: date
|
||||||
|
location_detail: str | None
|
||||||
|
design_value: float | None
|
||||||
|
measured_value: float
|
||||||
|
unit: str
|
||||||
|
result: QualityResult
|
||||||
|
lab_name: str | None
|
||||||
|
report_number: str | None
|
||||||
|
notes: str | None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
78
backend/app/services/pdf_service.py
Normal file
78
backend/app/services/pdf_service.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
PDF 생성 서비스 — WeasyPrint + Jinja2
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||||
|
_jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True)
|
||||||
|
|
||||||
|
INSPECTION_TYPE_LABELS = {
|
||||||
|
"rebar": "철근 배근 검측",
|
||||||
|
"formwork": "거푸집 검측",
|
||||||
|
"concrete": "콘크리트 타설 검측",
|
||||||
|
"pipe_burial": "관 매설 검측",
|
||||||
|
"compaction": "다짐 검측",
|
||||||
|
"waterproofing": "방수 검측",
|
||||||
|
"finishing": "마감 검측",
|
||||||
|
}
|
||||||
|
|
||||||
|
REPORT_TYPE_LABELS = {
|
||||||
|
"weekly": "주간",
|
||||||
|
"monthly": "월간",
|
||||||
|
}
|
||||||
|
|
||||||
|
REPORT_STATUS_LABELS = {
|
||||||
|
"draft": "초안",
|
||||||
|
"reviewed": "검토완료",
|
||||||
|
"submitted": "제출완료",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(template_name: str, **context) -> str:
|
||||||
|
template = _jinja_env.get_template(template_name)
|
||||||
|
return template.render(now=datetime.now().strftime("%Y-%m-%d %H:%M"), **context)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_to_pdf(html: str) -> bytes:
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WeasyPrint가 설치되지 않았습니다. `pip install weasyprint` 실행 후 재시도하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_daily_report_pdf(report, project) -> bytes:
|
||||||
|
html = _render_html("daily_report.html", report=report, project=project)
|
||||||
|
return _html_to_pdf(html)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_inspection_pdf(inspection, project) -> bytes:
|
||||||
|
type_label = INSPECTION_TYPE_LABELS.get(inspection.inspection_type, inspection.inspection_type)
|
||||||
|
html = _render_html(
|
||||||
|
"inspection.html",
|
||||||
|
inspection=inspection,
|
||||||
|
project=project,
|
||||||
|
inspection_type_label=type_label,
|
||||||
|
)
|
||||||
|
return _html_to_pdf(html)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_report_pdf(report, project) -> bytes:
|
||||||
|
type_label = REPORT_TYPE_LABELS.get(report.report_type.value, report.report_type.value)
|
||||||
|
status_label = REPORT_STATUS_LABELS.get(report.status.value, report.status.value)
|
||||||
|
period_label = f"{report.period_start} ~ {report.period_end} ({type_label})"
|
||||||
|
html = _render_html(
|
||||||
|
"report.html",
|
||||||
|
report=report,
|
||||||
|
project=project,
|
||||||
|
report_type_label=type_label,
|
||||||
|
status_label=status_label,
|
||||||
|
period_label=period_label,
|
||||||
|
content_json=report.content_json,
|
||||||
|
ai_draft_text=report.ai_draft_text,
|
||||||
|
)
|
||||||
|
return _html_to_pdf(html)
|
||||||
143
backend/app/services/scheduler.py
Normal file
143
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
APScheduler 날씨 자동 수집 배치
|
||||||
|
- 3시간마다 활성 프로젝트의 날씨 데이터를 수집
|
||||||
|
- 수집 후 날씨 경보 평가
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.weather import WeatherData, WeatherAlert
|
||||||
|
from app.services.weather_service import fetch_short_term_forecast, evaluate_alerts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler: AsyncIOScheduler | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_weather_for_all_projects():
|
||||||
|
"""활성 프로젝트 전체의 날씨를 수집하고 경보를 평가합니다."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project).where(Project.status == "active")
|
||||||
|
)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
if not projects:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"날씨 수집 시작: {len(projects)}개 프로젝트")
|
||||||
|
|
||||||
|
for project in projects:
|
||||||
|
# 프로젝트에 KMA 격자 좌표가 없으면 스킵
|
||||||
|
if not project.kma_nx or not project.kma_ny:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
forecasts = await fetch_short_term_forecast(project.kma_nx, project.kma_ny)
|
||||||
|
|
||||||
|
today = date.today().isoformat()
|
||||||
|
today_forecasts = [f for f in forecasts if f.get("date") == today]
|
||||||
|
|
||||||
|
if today_forecasts:
|
||||||
|
# 오늘 날씨 데이터 upsert (중복 저장 방지)
|
||||||
|
existing = await db.execute(
|
||||||
|
select(WeatherData).where(
|
||||||
|
WeatherData.project_id == project.id,
|
||||||
|
WeatherData.forecast_date == date.today(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
weather_row = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not weather_row:
|
||||||
|
# 최고/최저 기온 계산
|
||||||
|
temps = [f.get("temperature") for f in today_forecasts if f.get("temperature") is not None]
|
||||||
|
precips = [f.get("precipitation_mm", 0) or 0 for f in today_forecasts]
|
||||||
|
wind_speeds = [f.get("wind_speed", 0) or 0 for f in today_forecasts]
|
||||||
|
|
||||||
|
weather_row = WeatherData(
|
||||||
|
project_id=project.id,
|
||||||
|
forecast_date=date.today(),
|
||||||
|
temperature_max=max(temps) if temps else None,
|
||||||
|
temperature_min=min(temps) if temps else None,
|
||||||
|
precipitation_mm=sum(precips),
|
||||||
|
wind_speed_max=max(wind_speeds) if wind_speeds else None,
|
||||||
|
sky_condition=today_forecasts[0].get("sky_condition"),
|
||||||
|
raw_forecast=today_forecasts,
|
||||||
|
)
|
||||||
|
db.add(weather_row)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 날씨 경보 평가 (활성 태스크 기반)
|
||||||
|
from app.models.task import Task
|
||||||
|
tasks_result = await db.execute(
|
||||||
|
select(Task).where(
|
||||||
|
Task.project_id == project.id,
|
||||||
|
Task.status.in_(["not_started", "in_progress"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tasks = tasks_result.scalars().all()
|
||||||
|
|
||||||
|
# 오늘 이미 생성된 경보 확인
|
||||||
|
existing_alerts = await db.execute(
|
||||||
|
select(WeatherAlert).where(
|
||||||
|
WeatherAlert.project_id == project.id,
|
||||||
|
WeatherAlert.alert_date == date.today(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
already_alerted = existing_alerts.scalars().all()
|
||||||
|
alerted_types = {a.alert_type for a in already_alerted}
|
||||||
|
|
||||||
|
new_alerts = evaluate_alerts(
|
||||||
|
forecasts=today_forecasts,
|
||||||
|
tasks=tasks,
|
||||||
|
existing_alert_types=alerted_types,
|
||||||
|
)
|
||||||
|
|
||||||
|
for alert_data in new_alerts:
|
||||||
|
alert = WeatherAlert(
|
||||||
|
project_id=project.id,
|
||||||
|
alert_date=date.today(),
|
||||||
|
**alert_data,
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"프로젝트 {project.id} 날씨 수집 실패: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info("날씨 수집 완료")
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""FastAPI 시작 시 스케줄러를 초기화하고 시작합니다."""
|
||||||
|
global _scheduler
|
||||||
|
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||||
|
|
||||||
|
# 3시간마다 날씨 수집
|
||||||
|
_scheduler.add_job(
|
||||||
|
_collect_weather_for_all_projects,
|
||||||
|
trigger=IntervalTrigger(hours=3),
|
||||||
|
id="weather_collect",
|
||||||
|
name="날씨 데이터 자동 수집",
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=300, # 5분 내 누락 허용
|
||||||
|
)
|
||||||
|
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info("APScheduler 시작: 날씨 수집 3시간 주기")
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""FastAPI 종료 시 스케줄러를 중지합니다."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler and _scheduler.running:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
logger.info("APScheduler 종료")
|
||||||
98
backend/app/templates/daily_report.html
Normal file
98
backend/app/templates/daily_report.html
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||||
|
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 4mm; }
|
||||||
|
.subtitle { text-align: center; font-size: 11pt; margin-bottom: 8mm; color: #444; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||||
|
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: top; }
|
||||||
|
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 28%; }
|
||||||
|
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||||
|
.workers-table th { width: auto; }
|
||||||
|
.footer { margin-top: 10mm; text-align: right; font-size: 10pt; color: #555; }
|
||||||
|
.badge { display: inline-block; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; font-weight: 700; }
|
||||||
|
.badge-draft { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-confirmed { background: #d1fae5; color: #065f46; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>작 업 일 보</h1>
|
||||||
|
<div class="subtitle">{{ project.name }}</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>일자</th><td>{{ report.report_date }}</td>
|
||||||
|
<th>날씨</th><td>{{ report.weather_summary or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>기온 (최고/최저)</th>
|
||||||
|
<td>{{ report.temperature_high }}°C / {{ report.temperature_low }}°C</td>
|
||||||
|
<th>상태</th>
|
||||||
|
<td>
|
||||||
|
{% if report.status.value == 'confirmed' %}
|
||||||
|
<span class="badge badge-confirmed">확인완료</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-draft">초안</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section-title">▶ 투입 인원</div>
|
||||||
|
{% if report.workers_count %}
|
||||||
|
<table class="workers-table">
|
||||||
|
<tr>
|
||||||
|
{% for key in report.workers_count %}<th>{{ key }}</th>{% endfor %}
|
||||||
|
<th>합계</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{% set total = namespace(n=0) %}
|
||||||
|
{% for key, val in report.workers_count.items() %}
|
||||||
|
<td style="text-align:center">{{ val }}명</td>
|
||||||
|
{% set total.n = total.n + val %}
|
||||||
|
{% endfor %}
|
||||||
|
<td style="text-align:center;font-weight:700">{{ total.n }}명</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<table><tr><td style="color:#888">투입 인원 정보 없음</td></tr></table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if report.equipment_list %}
|
||||||
|
<div class="section-title">▶ 투입 장비</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>장비명</th><th>규격</th><th>수량</th><th>비고</th></tr>
|
||||||
|
{% for eq in report.equipment_list %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ eq.get('type', '-') }}</td>
|
||||||
|
<td>{{ eq.get('spec', '-') }}</td>
|
||||||
|
<td style="text-align:center">{{ eq.get('count', 1) }}대</td>
|
||||||
|
<td>{{ eq.get('notes', '') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section-title">▶ 작업 내용</div>
|
||||||
|
<table>
|
||||||
|
<tr><td style="min-height:30mm; white-space:pre-wrap">{{ report.work_content or '-' }}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if report.issues %}
|
||||||
|
<div class="section-title">▶ 특이사항 / 문제점</div>
|
||||||
|
<table>
|
||||||
|
<tr><td style="white-space:pre-wrap">{{ report.issues }}</td></tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
작성일시: {{ now }}
|
||||||
|
{% if report.ai_generated %}AI 보조 작성{% endif %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
91
backend/app/templates/inspection.html
Normal file
91
backend/app/templates/inspection.html
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||||
|
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 8mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||||
|
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: middle; }
|
||||||
|
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 28%; }
|
||||||
|
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||||
|
.checklist-item { display: flex; align-items: flex-start; padding: 2mm 0; border-bottom: 1px solid #ddd; }
|
||||||
|
.check-box { width: 6mm; height: 6mm; border: 1px solid #666; margin-right: 3mm; flex-shrink: 0; margin-top: 1mm; }
|
||||||
|
.check-num { color: #888; margin-right: 2mm; min-width: 8mm; }
|
||||||
|
.badge { display: inline-block; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; font-weight: 700; }
|
||||||
|
.badge-pass { background: #d1fae5; color: #065f46; }
|
||||||
|
.badge-fail { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-conditional { background: #fef3c7; color: #92400e; }
|
||||||
|
.sign-area { display: flex; justify-content: flex-end; gap: 10mm; margin-top: 10mm; }
|
||||||
|
.sign-box { border: 1px solid #888; width: 35mm; text-align: center; }
|
||||||
|
.sign-box .title { background: #f0f0f0; padding: 2mm; border-bottom: 1px solid #888; font-size: 9pt; }
|
||||||
|
.sign-box .space { height: 15mm; }
|
||||||
|
.footer { margin-top: 8mm; text-align: right; font-size: 10pt; color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>검 측 요 청 서</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>검측 항목</th>
|
||||||
|
<td>{{ inspection_type_label }}</td>
|
||||||
|
<th>요청일</th>
|
||||||
|
<td>{{ inspection.requested_date }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>위치 / 부위</th>
|
||||||
|
<td>{{ inspection.location_detail or '-' }}</td>
|
||||||
|
<th>결과</th>
|
||||||
|
<td>
|
||||||
|
{% if inspection.result %}
|
||||||
|
{% if inspection.result.value == 'pass' %}<span class="badge badge-pass">합격</span>
|
||||||
|
{% elif inspection.result.value == 'fail' %}<span class="badge badge-fail">불합격</span>
|
||||||
|
{% else %}<span class="badge badge-conditional">조건부합격</span>{% endif %}
|
||||||
|
{% else %}-{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if inspection.inspector_name %}
|
||||||
|
<tr><th>검측자</th><td colspan="3">{{ inspection.inspector_name }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if inspection.checklist_items %}
|
||||||
|
<div class="section-title">▶ 검측 체크리스트</div>
|
||||||
|
<table>
|
||||||
|
<tr><th style="width:8%">No.</th><th style="width:50%">검측 항목</th><th style="width:22%">기준값</th><th style="width:20%">확인</th></tr>
|
||||||
|
{% for item in inspection.checklist_items %}
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center">{{ loop.index }}</td>
|
||||||
|
<td>{{ item.get('item', item) if item is mapping else item }}</td>
|
||||||
|
<td>{{ item.get('standard', '') if item is mapping else '' }}</td>
|
||||||
|
<td style="text-align:center">□</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if inspection.notes %}
|
||||||
|
<div class="section-title">▶ 특이사항</div>
|
||||||
|
<table><tr><td style="white-space:pre-wrap">{{ inspection.notes }}</td></tr></table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sign-area">
|
||||||
|
<div class="sign-box">
|
||||||
|
<div class="title">현장대리인</div>
|
||||||
|
<div class="space"></div>
|
||||||
|
<div style="padding:2mm;font-size:9pt">(인)</div>
|
||||||
|
</div>
|
||||||
|
<div class="sign-box">
|
||||||
|
<div class="title">감독관</div>
|
||||||
|
<div class="space"></div>
|
||||||
|
<div style="padding:2mm;font-size:9pt">(인)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">출력일시: {{ now }}{% if inspection.ai_generated %} AI 보조 작성{% endif %}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
backend/app/templates/report.html
Normal file
58
backend/app/templates/report.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||||
|
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 4mm; }
|
||||||
|
.subtitle { text-align: center; font-size: 11pt; color: #444; margin-bottom: 8mm; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||||
|
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: top; }
|
||||||
|
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 25%; }
|
||||||
|
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||||
|
.content-block { border: 1px solid #ccc; padding: 4mm; min-height: 20mm; white-space: pre-wrap; line-height: 1.8; }
|
||||||
|
.footer { margin-top: 10mm; text-align: right; font-size: 10pt; color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{ report_type_label }} 공정보고서</h1>
|
||||||
|
<div class="subtitle">{{ project.name }} | {{ period_label }}</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<th>보고 기간</th><td>{{ period_label }}</td>
|
||||||
|
<th>상태</th><td>{{ status_label }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if content_json %}
|
||||||
|
{% if content_json.get('work_summary') %}
|
||||||
|
<div class="section-title">▶ 주요 작업 내용</div>
|
||||||
|
<div class="content-block">{{ content_json.work_summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if content_json.get('overall_progress') is not none %}
|
||||||
|
<div class="section-title">▶ 공정률</div>
|
||||||
|
<table><tr><th>종합 공정률</th><td>{{ content_json.overall_progress }}%</td></tr></table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if content_json.get('issues') %}
|
||||||
|
<div class="section-title">▶ 문제점 및 조치사항</div>
|
||||||
|
<div class="content-block">{{ content_json.issues }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if content_json.get('next_plan') %}
|
||||||
|
<div class="section-title">▶ 다음 기간 예정 작업</div>
|
||||||
|
<div class="content-block">{{ content_json.next_plan }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif ai_draft_text %}
|
||||||
|
<div class="section-title">▶ AI 작성 보고서 초안</div>
|
||||||
|
<div class="content-block">{{ ai_draft_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer">출력일시: {{ now }}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
backend/scripts/__init__.py
Normal file
0
backend/scripts/__init__.py
Normal file
273
backend/scripts/seed_rag.py
Normal file
273
backend/scripts/seed_rag.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
RAG 시드 스크립트
|
||||||
|
법규/시방서 PDF 또는 텍스트 파일을 pgvector에 색인합니다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python scripts/seed_rag.py --file "경로/파일명.pdf" --title "KCS 14 20 10" --type kcs
|
||||||
|
python scripts/seed_rag.py --file "경로/파일명.txt" --title "건설안전관리법" --type law
|
||||||
|
python scripts/seed_rag.py --list # 색인된 소스 목록 출력
|
||||||
|
python scripts/seed_rag.py --delete <source_id> # 소스 및 청크 삭제
|
||||||
|
|
||||||
|
지원 파일 형식: PDF, TXT, MD
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 프로젝트 루트를 sys.path에 추가
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select, text, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.rag import RagSource, RagChunk, RagSourceType
|
||||||
|
|
||||||
|
# ─── 텍스트 추출 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def extract_text_from_pdf(filepath: str) -> str:
|
||||||
|
try:
|
||||||
|
import pdfplumber
|
||||||
|
text_parts = []
|
||||||
|
with pdfplumber.open(filepath) as pdf:
|
||||||
|
for page in pdf.pages:
|
||||||
|
t = page.extract_text()
|
||||||
|
if t:
|
||||||
|
text_parts.append(t)
|
||||||
|
return "\n".join(text_parts)
|
||||||
|
except ImportError:
|
||||||
|
# fallback: pypdf
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
reader = PdfReader(filepath)
|
||||||
|
return "\n".join(page.extract_text() or "" for page in reader.pages)
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PDF 읽기 라이브러리가 없습니다.\n"
|
||||||
|
"설치: pip install pdfplumber 또는 pip install pypdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text(filepath: str) -> str:
|
||||||
|
ext = Path(filepath).suffix.lower()
|
||||||
|
if ext == ".pdf":
|
||||||
|
return extract_text_from_pdf(filepath)
|
||||||
|
elif ext in (".txt", ".md"):
|
||||||
|
with open(filepath, encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"지원하지 않는 파일 형식: {ext} (pdf, txt, md만 가능)")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 텍스트 청킹 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def split_chunks(text: str, chunk_size: int = 800, overlap: int = 100) -> list[str]:
|
||||||
|
"""
|
||||||
|
단락 단위로 먼저 분리하고, chunk_size 초과 시 슬라이딩 윈도우로 분할.
|
||||||
|
overlap: 앞 청크 마지막 n 글자를 다음 청크 앞에 붙임 (문맥 유지).
|
||||||
|
"""
|
||||||
|
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = ""
|
||||||
|
|
||||||
|
for para in paragraphs:
|
||||||
|
if len(current) + len(para) < chunk_size:
|
||||||
|
current = (current + "\n\n" + para).strip()
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
# 긴 단락은 슬라이딩 윈도우
|
||||||
|
if len(para) > chunk_size:
|
||||||
|
for start in range(0, len(para), chunk_size - overlap):
|
||||||
|
chunks.append(para[start : start + chunk_size])
|
||||||
|
else:
|
||||||
|
current = para
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
|
||||||
|
return [c for c in chunks if len(c) > 50] # 너무 짧은 청크 제거
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 임베딩 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def embed_batch(texts: list[str]) -> list[list[float]]:
|
||||||
|
"""배치 임베딩 (Voyage AI 또는 OpenAI)"""
|
||||||
|
if settings.VOYAGE_API_KEY:
|
||||||
|
return await _embed_voyage_batch(texts)
|
||||||
|
elif settings.OPENAI_API_KEY:
|
||||||
|
return await _embed_openai_batch(texts)
|
||||||
|
else:
|
||||||
|
raise ValueError("VOYAGE_API_KEY 또는 OPENAI_API_KEY를 .env에 설정하세요.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed_voyage_batch(texts: list[str]) -> list[list[float]]:
|
||||||
|
# Voyage AI는 배치 최대 128개
|
||||||
|
BATCH = 128
|
||||||
|
results = []
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
for i in range(0, len(texts), BATCH):
|
||||||
|
batch = texts[i : i + BATCH]
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api.voyageai.com/v1/embeddings",
|
||||||
|
headers={"Authorization": f"Bearer {settings.VOYAGE_API_KEY}"},
|
||||||
|
json={"model": settings.EMBEDDING_MODEL, "input": batch},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()["data"]
|
||||||
|
results.extend(item["embedding"] for item in sorted(data, key=lambda x: x["index"]))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _embed_openai_batch(texts: list[str]) -> list[list[float]]:
|
||||||
|
BATCH = 100
|
||||||
|
results = []
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
for i in range(0, len(texts), BATCH):
|
||||||
|
batch = texts[i : i + BATCH]
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api.openai.com/v1/embeddings",
|
||||||
|
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
|
||||||
|
json={"model": "text-embedding-3-small", "input": batch},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()["data"]
|
||||||
|
results.extend(item["embedding"] for item in sorted(data, key=lambda x: x["index"]))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ─── DB 작업 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_session() -> AsyncSession:
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
return factory()
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(filepath: str, title: str, source_type: str, chunk_size: int, overlap: int):
|
||||||
|
print(f"\n[1/4] 파일 읽기: {filepath}")
|
||||||
|
raw_text = extract_text(filepath)
|
||||||
|
print(f" 추출된 텍스트: {len(raw_text):,}자")
|
||||||
|
|
||||||
|
print(f"[2/4] 청크 분할 (크기={chunk_size}, 겹침={overlap})")
|
||||||
|
chunks = split_chunks(raw_text, chunk_size, overlap)
|
||||||
|
print(f" 청크 수: {len(chunks)}개")
|
||||||
|
|
||||||
|
print(f"[3/4] 임베딩 생성 중...")
|
||||||
|
embeddings = await embed_batch([c for c in chunks])
|
||||||
|
print(f" 임베딩 완료: {len(embeddings)}개")
|
||||||
|
|
||||||
|
print(f"[4/4] DB 저장 중...")
|
||||||
|
async with await get_session() as session:
|
||||||
|
# RagSource 생성
|
||||||
|
source = RagSource(
|
||||||
|
title=title,
|
||||||
|
source_type=RagSourceType(source_type),
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush() # source.id 확보
|
||||||
|
|
||||||
|
# RagChunk 배치 저장
|
||||||
|
dim = settings.EMBEDDING_DIMENSIONS
|
||||||
|
for idx, (content, emb) in enumerate(zip(chunks, embeddings)):
|
||||||
|
chunk = RagChunk(
|
||||||
|
source_id=source.id,
|
||||||
|
chunk_index=idx,
|
||||||
|
content=content,
|
||||||
|
metadata_={"chunk_index": idx, "source_title": title},
|
||||||
|
)
|
||||||
|
session.add(chunk)
|
||||||
|
await session.flush() # chunk.id 확보
|
||||||
|
|
||||||
|
# pgvector 직접 업데이트 (SQLAlchemy ORM이 VECTOR 타입을 직접 지원 안 함)
|
||||||
|
emb_str = "[" + ",".join(str(x) for x in emb) + "]"
|
||||||
|
await session.execute(
|
||||||
|
text("UPDATE rag_chunks SET embedding = :emb WHERE id = :id"),
|
||||||
|
{"emb": emb_str, "id": chunk.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
print(f"\n완료! source_id={source.id}")
|
||||||
|
print(f" 제목: {title}")
|
||||||
|
print(f" 타입: {source_type}")
|
||||||
|
print(f" 청크: {len(chunks)}개 저장됨")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sources():
|
||||||
|
async with await get_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(RagSource).order_by(RagSource.created_at.desc())
|
||||||
|
)
|
||||||
|
sources = result.scalars().all()
|
||||||
|
if not sources:
|
||||||
|
print("색인된 소스가 없습니다.")
|
||||||
|
return
|
||||||
|
print(f"\n{'ID':<38} {'타입':<12} {'제목'}")
|
||||||
|
print("-" * 80)
|
||||||
|
for s in sources:
|
||||||
|
chunks_q = await session.execute(
|
||||||
|
text("SELECT COUNT(*) FROM rag_chunks WHERE source_id = :id"),
|
||||||
|
{"id": s.id},
|
||||||
|
)
|
||||||
|
count = chunks_q.scalar()
|
||||||
|
print(f"{str(s.id):<38} {s.source_type.value:<12} {s.title} ({count}청크)")
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_source(source_id: str):
|
||||||
|
async with await get_session() as session:
|
||||||
|
sid = uuid.UUID(source_id)
|
||||||
|
result = await session.execute(select(RagSource).where(RagSource.id == sid))
|
||||||
|
source = result.scalar_one_or_none()
|
||||||
|
if not source:
|
||||||
|
print(f"소스를 찾을 수 없습니다: {source_id}")
|
||||||
|
return
|
||||||
|
await session.execute(delete(RagChunk).where(RagChunk.source_id == sid))
|
||||||
|
await session.delete(source)
|
||||||
|
await session.commit()
|
||||||
|
print(f"삭제 완료: {source.title} ({source_id})")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="CONAI RAG 시드 스크립트 — 법규/시방서를 pgvector에 색인",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument("--file", help="임베딩할 파일 경로 (pdf/txt/md)")
|
||||||
|
parser.add_argument("--title", help="문서 제목 (예: KCS 14 20 10 콘크리트 시방서)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--type",
|
||||||
|
choices=["kcs", "law", "regulation", "guideline"],
|
||||||
|
default="kcs",
|
||||||
|
help="소스 타입: kcs(시방서), law(법령), regulation(규정), guideline(지침)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--chunk-size", type=int, default=800, help="청크 최대 글자 수 (기본: 800)")
|
||||||
|
parser.add_argument("--overlap", type=int, default=100, help="청크 겹침 글자 수 (기본: 100)")
|
||||||
|
parser.add_argument("--list", action="store_true", help="색인된 소스 목록 출력")
|
||||||
|
parser.add_argument("--delete", metavar="SOURCE_ID", help="소스 ID로 삭제")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
asyncio.run(list_sources())
|
||||||
|
elif args.delete:
|
||||||
|
asyncio.run(delete_source(args.delete))
|
||||||
|
elif args.file:
|
||||||
|
if not args.title:
|
||||||
|
parser.error("--title 이 필요합니다")
|
||||||
|
if not os.path.exists(args.file):
|
||||||
|
print(f"파일을 찾을 수 없습니다: {args.file}")
|
||||||
|
sys.exit(1)
|
||||||
|
asyncio.run(seed(args.file, args.title, args.type, args.chunk_size, args.overlap))
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user