Files
conai/backend/app/services/scheduler.py
sinmb79 5a044a3882 feat: Phase 3 구현 — 완전 자동화, 준공도서, Vision L3, 발주처 포털
EVMS 완전 자동화:
- 공기 지연 AI 예측 (SPI 기반 준공일 예측)
- 기성청구 가능 금액 자동 산출
- 매일 자정 EVMS 스냅샷 자동 생성 (APScheduler)
- 매일 07:00 GONGSA 아침 브리핑 자동 생성

준공도서 패키지:
- 준공 요약 + 품질시험 목록 + 검측 이력 + 인허가 현황 → ZIP 번들
- 준공 준비 체크리스트 API
- 4종 HTML 템플릿 (WeasyPrint PDF 출력)

Vision AI Level 3:
- 설계 도면 vs 현장 사진 비교 보조 판독 (Claude Vision)
- 철근 배근, 거푸집 치수 1차 분석

설계도서 파싱:
- PDF 이미지/텍스트에서 공종·수량·규격 자동 추출
- Pandoc HWP 출력 지원

발주처 전용 포털:
- 토큰 기반 읽기 전용 API
- 공사 현황 대시보드, 공정률 추이 차트

에이전트 협업 고도화:
- 협업 시나리오 (concrete_pour, excavation, weekly_report)
- GONGSA→PUMJIL→ANJEON 순차 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:02:29 +09:00

248 lines
9.4 KiB
Python

"""
APScheduler 날씨 자동 수집 배치
- 3시간마다 활성 프로젝트의 날씨 데이터를 수집
- 수집 후 날씨 경보 평가
"""
import logging
from datetime import date, datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
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("날씨 수집 완료")
async def _daily_evms_snapshot():
"""매일 자정 활성 프로젝트 EVMS 스냅샷 자동 저장"""
from app.services.evms_service import compute_evms
from app.models.evms import EVMSSnapshot
today = date.today()
async with AsyncSessionLocal() as db:
result = await db.execute(select(Project).where(Project.status == "active"))
projects = result.scalars().all()
for project in projects:
try:
data = await compute_evms(db, project.id, today)
snap = EVMSSnapshot(
project_id=project.id,
snapshot_date=today,
total_budget=data["total_budget"],
planned_progress=data["planned_progress"],
actual_progress=data["actual_progress"],
pv=data["pv"], ev=data["ev"], ac=data["ac"],
spi=data["spi"], cpi=data["cpi"],
eac=data["eac"], etc=data["etc"],
detail_json={"tasks": data["detail"]},
)
db.add(snap)
except Exception as e:
logger.error(f"EVMS 스냅샷 실패 [{project.id}]: {e}")
await db.commit()
logger.info(f"EVMS 일일 스냅샷 완료: {len(projects)}개 프로젝트")
async def _morning_agent_briefings():
"""매일 오전 7시 GONGSA 아침 브리핑 자동 생성"""
from app.models.agent import AgentConversation, AgentMessage, AgentType
from app.models.user import User
from app.services.agents.gongsa import gongsa_agent
async with AsyncSessionLocal() as db:
result = await db.execute(select(Project).where(Project.status == "active"))
projects = result.scalars().all()
for project in projects:
try:
# 프로젝트 관리자 조회 (첫 번째 admin)
user_r = await db.execute(
select(User).where(User.role == "site_manager").limit(1)
)
user = user_r.scalar_one_or_none()
if not user:
continue
context = await gongsa_agent.build_context(db, str(project.id))
prompt = (
f"오늘({context.get('today', str(date.today()))}) 아침 공정 브리핑을 작성해주세요. "
"날씨, 오늘 예정 공종, 주의사항을 포함해주세요."
)
reply = await gongsa_agent.chat(
messages=[{"role": "user", "content": prompt}],
context=context,
)
conv = AgentConversation(
project_id=project.id,
user_id=user.id,
agent_type=AgentType.GONGSA,
title=f"{date.today()} 아침 브리핑 (자동)",
)
db.add(conv)
await db.flush()
msg = AgentMessage(
conversation_id=conv.id,
role="assistant",
content=reply,
is_proactive=True,
)
db.add(msg)
except Exception as e:
logger.error(f"아침 브리핑 실패 [{project.id}]: {e}")
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,
)
# 매일 자정 EVMS 스냅샷
_scheduler.add_job(
_daily_evms_snapshot,
trigger=CronTrigger(hour=0, minute=5),
id="evms_daily",
name="EVMS 일일 스냅샷",
replace_existing=True,
misfire_grace_time=600,
)
# 매일 오전 7시 아침 브리핑
_scheduler.add_job(
_morning_agent_briefings,
trigger=CronTrigger(hour=7, minute=0),
id="morning_briefing",
name="GONGSA 아침 브리핑 자동 생성",
replace_existing=True,
misfire_grace_time=600,
)
_scheduler.start()
logger.info("APScheduler 시작: 날씨(3h), EVMS 스냅샷(00:05), 아침 브리핑(07:00)")
return _scheduler
def stop_scheduler():
"""FastAPI 종료 시 스케줄러를 중지합니다."""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("APScheduler 종료")