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
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from app.deps import CurrentUser, DB
|
||||
from app.models.daily_report import DailyReport, InputSource
|
||||
@@ -9,6 +10,7 @@ from app.schemas.daily_report import (
|
||||
DailyReportCreate, DailyReportUpdate, DailyReportGenerateRequest, DailyReportResponse
|
||||
)
|
||||
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=["작업일보"])
|
||||
|
||||
@@ -97,6 +99,20 @@ async def update_report(project_id: uuid.UUID, report_id: uuid.UUID, data: Daily
|
||||
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)
|
||||
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))
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from app.deps import CurrentUser, DB
|
||||
from app.models.inspection import InspectionRequest
|
||||
from app.models.project import Project, WBSItem
|
||||
from app.schemas.inspection import InspectionCreate, InspectionUpdate, InspectionGenerateRequest, InspectionResponse
|
||||
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=["검측요청서"])
|
||||
|
||||
@@ -96,6 +98,20 @@ async def update_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, dat
|
||||
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)
|
||||
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))
|
||||
|
||||
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
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select, func
|
||||
from app.deps import CurrentUser, DB
|
||||
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.schemas.report import ReportGenerateRequest, ReportResponse
|
||||
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=["공정보고서"])
|
||||
|
||||
@@ -134,6 +136,20 @@ async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, curren
|
||||
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)
|
||||
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))
|
||||
|
||||
@@ -2,14 +2,17 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: seed default work types, check DB connection
|
||||
# Startup
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown: cleanup resources
|
||||
# Shutdown
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -41,6 +44,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rag.router, prefix=api_prefix)
|
||||
app.include_router(kakao.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.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