Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a410f5b65c | |||
| 7031439364 | |||
| 468804494d |
@@ -0,0 +1,90 @@
|
||||
"""처리 머신 보드 API — GET /api/queue/overview (plan ds-processing-ui-6an).
|
||||
|
||||
홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계 로직은
|
||||
services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와 계약 고정.
|
||||
응답에 raw 모델명 노출 금지 — 머신 label 만.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
from services.queue_overview import build_overview
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CurrentItem(BaseModel):
|
||||
"""머신이 지금 처리 중인 문서 (최대 2건)."""
|
||||
document_id: int
|
||||
title: str
|
||||
stage: str
|
||||
|
||||
|
||||
class MachineCard(BaseModel):
|
||||
"""머신 카드 — stage 귀속 합산 + 완료 실적(summarize 는 풀 분리) + state."""
|
||||
key: Literal["gpu", "macmini", "macbook"]
|
||||
label: str
|
||||
state: Literal["active", "deferred", "idle"]
|
||||
stages: list[str]
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
done_1h: int
|
||||
done_today: int
|
||||
deferred_pending: int
|
||||
current: list[CurrentItem]
|
||||
|
||||
|
||||
class SummarizeEta(BaseModel):
|
||||
"""summarize 풀 ETA — done > inflow 일 때만 eta_minutes 산출."""
|
||||
pending: int
|
||||
done_rate_1h: int
|
||||
inflow_rate_1h: int
|
||||
eta_minutes: int | None
|
||||
|
||||
|
||||
class TrendBucket(BaseModel):
|
||||
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
|
||||
hour: str
|
||||
inflow: int
|
||||
done: int
|
||||
|
||||
|
||||
class Totals(BaseModel):
|
||||
"""전 stage 합계."""
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
|
||||
|
||||
class StageRow(BaseModel):
|
||||
"""단계별 현황 행 — '단계 상세' 패널용 (완료 가시화)."""
|
||||
stage: str
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
done_today: int
|
||||
oldest_pending_age_sec: int | None
|
||||
|
||||
|
||||
class QueueOverviewResponse(BaseModel):
|
||||
machines: list[MachineCard]
|
||||
stages: list[StageRow]
|
||||
summarize_eta: SummarizeEta
|
||||
trend_24h: list[TrendBucket]
|
||||
totals: Totals
|
||||
|
||||
|
||||
@router.get("/overview", response_model=QueueOverviewResponse)
|
||||
async def get_queue_overview(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""머신 관점 처리 보드 + summarize ETA 집계 (라이브 계산, 신규 테이블 0)"""
|
||||
return QueueOverviewResponse.model_validate(await build_overview(session))
|
||||
@@ -22,6 +22,7 @@ from api.events import router as events_router
|
||||
from api.library import router as library_router
|
||||
from api.memos import router as memos_router
|
||||
from api.news import router as news_router
|
||||
from api.queue_overview import router as queue_overview_router
|
||||
from api.search import router as search_router
|
||||
from api.setup import router as setup_router
|
||||
from api.study_question_progress import router as study_question_progress_router
|
||||
@@ -183,6 +184,8 @@ app.include_router(events_router, prefix="/api/events", tags=["events"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
app.include_router(library_router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
# 처리 머신 보드 (plan ds-processing-ui-6an) — GET /api/queue/overview
|
||||
app.include_router(queue_overview_router, prefix="/api/queue", tags=["queue"])
|
||||
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
|
||||
app.include_router(briefing_router, prefix="/api/briefing", tags=["briefing"])
|
||||
app.include_router(audio_router, prefix="/api/audio", tags=["audio"])
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""처리 머신 보드 + ETA 집계 (plan ds-processing-ui-6an, 안2+안5/6).
|
||||
|
||||
GET /api/queue/overview 의 집계 로직. 모든 수치는 기존 processing_queue /
|
||||
documents 컬럼에서 라이브 계산 — 신규 테이블/마이그레이션 0 (HARD 제약).
|
||||
|
||||
구조: SQL 수집부(build_overview 내부 5쿼리)와 판정부(순수 함수)를 분리.
|
||||
판정부(rows_to_* / build_machines / build_summarize_eta / build_trend /
|
||||
build_totals / compute_eta_minutes)는 DB 없이 단위테스트 가능.
|
||||
|
||||
귀속 규칙 (단일 진실):
|
||||
- stage→machine 정적 맵: gpu = extract/embed/chunk/markdown/preview/thumbnail/
|
||||
fulltext/stt · macmini = classify/summarize · macbook = deep_summary
|
||||
(단, settings.ai.deep 부재 시 deep_summary 도 macmini 귀속).
|
||||
- summarize 는 풀(pool): pending/processing/failed 는 macmini 귀속이되, 완료
|
||||
실적(done_*)은 documents.ai_model_version 조인으로 분리 — 'qwen-macbook'
|
||||
이면 macbook 실적, 아니면 macmini 실적.
|
||||
- deferred_pending(payload.deferred_until 미래)은 macbook 카드 귀속
|
||||
(보류 = 맥북 불가 신호).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from posixpath import basename
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
# 내부 판별용 alias — 응답에 raw 모델명 노출 금지, 머신 label 만 노출.
|
||||
_MACBOOK_MODEL_ALIAS = "qwen-macbook"
|
||||
|
||||
# stage→machine 정적 맵 재료 (선언 순서 = 카드 stages 표시 순서)
|
||||
_GPU_STAGES = (
|
||||
"extract", "embed", "chunk", "markdown",
|
||||
"preview", "thumbnail", "fulltext", "stt",
|
||||
)
|
||||
_MACMINI_STAGES = ("classify", "summarize")
|
||||
_MACBOOK_STAGES = ("deep_summary",)
|
||||
_STAGE_ORDER = _GPU_STAGES + _MACMINI_STAGES + _MACBOOK_STAGES
|
||||
|
||||
_MACHINE_KEYS = ("gpu", "macmini", "macbook")
|
||||
_MACHINE_LABELS = {
|
||||
"gpu": "GPU 서버",
|
||||
"macmini": "맥미니",
|
||||
"macbook": "맥북 M5 Max",
|
||||
}
|
||||
|
||||
# 머신 카드당 current 표시 상한
|
||||
_CURRENT_LIMIT = 2
|
||||
|
||||
|
||||
def stage_machine_map(deep_enabled: bool) -> dict[str, str]:
|
||||
"""stage → machine key 맵. deep 슬롯 부재 시 deep_summary 는 macmini 귀속."""
|
||||
mapping: dict[str, str] = {}
|
||||
for s in _GPU_STAGES:
|
||||
mapping[s] = "gpu"
|
||||
for s in _MACMINI_STAGES:
|
||||
mapping[s] = "macmini"
|
||||
for s in _MACBOOK_STAGES:
|
||||
mapping[s] = "macbook" if deep_enabled else "macmini"
|
||||
return mapping
|
||||
|
||||
|
||||
def _zero_stage() -> dict:
|
||||
return {
|
||||
"pending": 0, "processing": 0, "failed": 0,
|
||||
"done_1h": 0, "done_today": 0, "done_15m": 0,
|
||||
"deferred_pending": 0, "created_1h": 0, "oldest_pending_at": None,
|
||||
}
|
||||
|
||||
|
||||
def rows_to_stage_stats(rows) -> dict[str, dict]:
|
||||
"""stage×status 집계 쿼리 행 → {stage: {pending, ..., created_1h}} 변환."""
|
||||
stats: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
stats[row[0]] = {
|
||||
"pending": int(row[1] or 0),
|
||||
"processing": int(row[2] or 0),
|
||||
"failed": int(row[3] or 0),
|
||||
"done_1h": int(row[4] or 0),
|
||||
"done_today": int(row[5] or 0),
|
||||
"done_15m": int(row[6] or 0),
|
||||
"deferred_pending": int(row[7] or 0),
|
||||
"created_1h": int(row[8] or 0),
|
||||
"oldest_pending_at": row[9] if len(row) > 9 else None,
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
def rows_to_summarize_split(rows) -> dict[str, dict]:
|
||||
"""summarize 완료 실적 분리 쿼리 행 → {"macbook"|"macmini": {done_*}}.
|
||||
|
||||
is_macbook = documents.ai_model_version 이 'qwen-macbook' 인지 (내부 판별 전용).
|
||||
"""
|
||||
split = {
|
||||
"macbook": {"done_1h": 0, "done_today": 0, "done_15m": 0},
|
||||
"macmini": {"done_1h": 0, "done_today": 0, "done_15m": 0},
|
||||
}
|
||||
for row in rows:
|
||||
key = "macbook" if row[0] else "macmini"
|
||||
split[key]["done_1h"] += int(row[1] or 0)
|
||||
split[key]["done_today"] += int(row[2] or 0)
|
||||
split[key]["done_15m"] += int(row[3] or 0)
|
||||
return split
|
||||
|
||||
|
||||
def display_title(row: dict) -> str:
|
||||
"""표시용 제목 — title > original_filename > file_path basename > 문서 id."""
|
||||
if row.get("title"):
|
||||
return row["title"]
|
||||
if row.get("original_filename"):
|
||||
return row["original_filename"]
|
||||
if row.get("file_path"):
|
||||
return basename(row["file_path"].rstrip("/"))
|
||||
return f"문서 #{row['document_id']}"
|
||||
|
||||
|
||||
def build_machines(
|
||||
stage_stats: dict[str, dict],
|
||||
summarize_split: dict[str, dict],
|
||||
current_rows: list[dict],
|
||||
*,
|
||||
deep_enabled: bool,
|
||||
) -> list[dict]:
|
||||
"""머신 카드 3장 (gpu / macmini / macbook) 구성 — 귀속 규칙의 판정부."""
|
||||
smap = stage_machine_map(deep_enabled)
|
||||
|
||||
def g(stage: str, field: str) -> int:
|
||||
return stage_stats.get(stage, {}).get(field, 0)
|
||||
|
||||
# current 귀속: processing 행을 머신별 최대 2건 (summarize processing → macmini)
|
||||
current_by_machine: dict[str, list[dict]] = {k: [] for k in _MACHINE_KEYS}
|
||||
for row in current_rows:
|
||||
machine = smap.get(row["stage"])
|
||||
if machine and len(current_by_machine[machine]) < _CURRENT_LIMIT:
|
||||
current_by_machine[machine].append({
|
||||
"document_id": row["document_id"],
|
||||
"title": display_title(row),
|
||||
"stage": row["stage"],
|
||||
})
|
||||
|
||||
machines = []
|
||||
for key in _MACHINE_KEYS:
|
||||
stages = [s for s in _STAGE_ORDER if smap[s] == key]
|
||||
|
||||
pending = sum(g(s, "pending") for s in stages)
|
||||
processing = sum(g(s, "processing") for s in stages)
|
||||
failed = sum(g(s, "failed") for s in stages)
|
||||
|
||||
# 완료 실적: summarize 는 풀이라 stage 합산에서 제외하고 split 로 귀속
|
||||
done_1h = sum(g(s, "done_1h") for s in stages if s != "summarize")
|
||||
done_today = sum(g(s, "done_today") for s in stages if s != "summarize")
|
||||
done_15m = sum(g(s, "done_15m") for s in stages if s != "summarize")
|
||||
if key in summarize_split:
|
||||
done_1h += summarize_split[key]["done_1h"]
|
||||
done_today += summarize_split[key]["done_today"]
|
||||
done_15m += summarize_split[key]["done_15m"]
|
||||
|
||||
# 보류 백오프 = 맥북 불가 신호 → macbook 카드 귀속 (deep 슬롯 유무 무관)
|
||||
deferred_pending = (
|
||||
g("summarize", "deferred_pending") + g("deep_summary", "deferred_pending")
|
||||
if key == "macbook" else 0
|
||||
)
|
||||
|
||||
# state 판정 — 우선순위: 가동 > 보류 > 대기 (사용자 피드백 2026-06-11).
|
||||
# 일하고 있으면(처리 중 또는 최근 15분 완료) 백오프 잔여가 있어도 "가동" —
|
||||
# 보류 건수는 카드의 deferred_pending 라인이 따로 보여준다. "보류" 칩은
|
||||
# 실제로 일이 멈춰 있고 백오프만 쌓인 상태(sleep/불가 지속)에서만.
|
||||
if processing > 0 or done_15m > 0:
|
||||
state = "active"
|
||||
elif key == "macbook" and deferred_pending > 0:
|
||||
state = "deferred"
|
||||
else:
|
||||
state = "idle"
|
||||
|
||||
machines.append({
|
||||
"key": key,
|
||||
"label": _MACHINE_LABELS[key],
|
||||
"state": state,
|
||||
"stages": stages,
|
||||
"pending": pending,
|
||||
"processing": processing,
|
||||
"failed": failed,
|
||||
"done_1h": done_1h,
|
||||
"done_today": done_today,
|
||||
"deferred_pending": deferred_pending,
|
||||
"current": current_by_machine[key],
|
||||
})
|
||||
return machines
|
||||
|
||||
|
||||
def compute_eta_minutes(pending: int, done_1h: int, inflow_1h: int) -> int | None:
|
||||
"""ETA(분) = 순소화율 기반. done > inflow 일 때만 산출, 아니면 None (소화 불가)."""
|
||||
if done_1h > inflow_1h:
|
||||
return round(pending / (done_1h - inflow_1h) * 60)
|
||||
return None
|
||||
|
||||
|
||||
def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
"""summarize 풀 ETA — pending 은 보류(deferred) 포함 총수."""
|
||||
s = stage_stats.get("summarize", _zero_stage())
|
||||
pending = s["pending"]
|
||||
done_rate = s["done_1h"]
|
||||
inflow_rate = s["created_1h"]
|
||||
return {
|
||||
"pending": pending,
|
||||
"done_rate_1h": done_rate,
|
||||
"inflow_rate_1h": inflow_rate,
|
||||
"eta_minutes": compute_eta_minutes(pending, done_rate, inflow_rate),
|
||||
}
|
||||
|
||||
|
||||
def build_trend(
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
now_kst: datetime,
|
||||
) -> list[dict]:
|
||||
"""summarize 24h 추이 — KST 시간 버킷 24개 (오래된 것부터, 빈 버킷 0).
|
||||
|
||||
버킷 key = "YYYY-MM-DD HH:00" (KST). SQL to_char 출력과 동일 포맷.
|
||||
"""
|
||||
base = now_kst.replace(minute=0, second=0, microsecond=0)
|
||||
trend = []
|
||||
for i in range(23, -1, -1):
|
||||
bucket = base - timedelta(hours=i)
|
||||
key = bucket.strftime("%Y-%m-%d %H:00")
|
||||
trend.append({
|
||||
"hour": bucket.strftime("%H:00"),
|
||||
"inflow": inflow_buckets.get(key, 0),
|
||||
"done": done_buckets.get(key, 0),
|
||||
})
|
||||
return trend
|
||||
|
||||
|
||||
def build_stages(stage_stats: dict[str, dict], now=None) -> list[dict]:
|
||||
"""단계별 현황 행 — '단계 상세' 패널용 (2026-06-11 사용자 피드백: 완료가 보여야 한다).
|
||||
|
||||
파이프라인 순서 유지, 미지 stage 는 뒤에. 숨김/강조 판단은 FE 몫 — 여기선 사실만.
|
||||
oldest_pending_age_sec = 가장 오래된 pending 의 경과 초 (pending 없으면 None).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
now = now or datetime.now(timezone.utc)
|
||||
extra = [s for s in stage_stats if s not in _STAGE_ORDER]
|
||||
rows = []
|
||||
for stage in [*_STAGE_ORDER, *extra]:
|
||||
st = stage_stats.get(stage) or _zero_stage()
|
||||
oldest = st.get("oldest_pending_at")
|
||||
age = None
|
||||
if oldest is not None:
|
||||
if oldest.tzinfo is None:
|
||||
oldest = oldest.replace(tzinfo=timezone.utc)
|
||||
age = max(0, int((now - oldest).total_seconds()))
|
||||
rows.append({
|
||||
"stage": stage,
|
||||
"pending": st["pending"],
|
||||
"processing": st["processing"],
|
||||
"failed": st["failed"],
|
||||
"done_today": st["done_today"],
|
||||
"oldest_pending_age_sec": age,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def build_totals(stage_stats: dict[str, dict]) -> dict:
|
||||
"""전 stage 합계."""
|
||||
return {
|
||||
"pending": sum(s["pending"] for s in stage_stats.values()),
|
||||
"processing": sum(s["processing"] for s in stage_stats.values()),
|
||||
"failed": sum(s["failed"] for s in stage_stats.values()),
|
||||
}
|
||||
|
||||
|
||||
def compose_overview(
|
||||
stage_stats: dict[str, dict],
|
||||
summarize_split: dict[str, dict],
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
current_rows: list[dict],
|
||||
*,
|
||||
deep_enabled: bool,
|
||||
now_kst: datetime,
|
||||
) -> dict:
|
||||
"""수집된 통계 → 응답 dict (계약 shape). 순수 함수 — DB 불요."""
|
||||
return {
|
||||
"machines": build_machines(
|
||||
stage_stats, summarize_split, current_rows, deep_enabled=deep_enabled
|
||||
),
|
||||
"stages": build_stages(stage_stats),
|
||||
"summarize_eta": build_summarize_eta(stage_stats),
|
||||
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
|
||||
"totals": build_totals(stage_stats),
|
||||
}
|
||||
|
||||
|
||||
# ─── SQL 수집부 (총 5쿼리) ────────────────────────────────────────────────────
|
||||
|
||||
# 1) stage×status 집계 + 시간창 완료/유입 + 보류 (1방)
|
||||
_STAGE_STATS_SQL = """
|
||||
SELECT
|
||||
stage,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
|
||||
COUNT(*) FILTER (WHERE status = 'processing') AS processing,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '1 hour') AS done_1h,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > :kst_midnight) AS done_today,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '15 minutes') AS done_15m,
|
||||
COUNT(*) FILTER (WHERE status = 'pending'
|
||||
AND payload ->> 'deferred_until' IS NOT NULL
|
||||
AND (payload ->> 'deferred_until')::timestamptz > NOW())
|
||||
AS deferred_pending,
|
||||
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') AS created_1h,
|
||||
MIN(created_at) FILTER (WHERE status = 'pending') AS oldest_pending_at
|
||||
FROM processing_queue
|
||||
GROUP BY stage
|
||||
"""
|
||||
|
||||
# 2) summarize 풀 완료 실적 분리 (documents.ai_model_version 조인, 1방)
|
||||
# 스캔 하한 = 오늘 0시(KST)와 1h 전 중 더 이른 시각 (자정 직후 1h 창 보전).
|
||||
_SUMMARIZE_SPLIT_SQL = """
|
||||
SELECT
|
||||
COALESCE(d.ai_model_version = :macbook_alias, false) AS is_macbook,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > NOW() - INTERVAL '1 hour') AS done_1h,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > :kst_midnight) AS done_today,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > NOW() - INTERVAL '15 minutes') AS done_15m
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.stage = 'summarize'
|
||||
AND q.status = 'completed'
|
||||
AND q.completed_at > LEAST(:kst_midnight, NOW() - INTERVAL '1 hour')
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
# 3/4) summarize 24h 추이 — KST 시간 버킷 (inflow/done 각 1방)
|
||||
_TREND_INFLOW_SQL = """
|
||||
SELECT to_char(date_trunc('hour', created_at AT TIME ZONE 'Asia/Seoul'),
|
||||
'YYYY-MM-DD HH24:00') AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM processing_queue
|
||||
WHERE stage = 'summarize'
|
||||
AND created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
_TREND_DONE_SQL = """
|
||||
SELECT to_char(date_trunc('hour', completed_at AT TIME ZONE 'Asia/Seoul'),
|
||||
'YYYY-MM-DD HH24:00') AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM processing_queue
|
||||
WHERE stage = 'summarize'
|
||||
AND status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
# 5) processing 행 + 표시용 제목 재료 (1방 — 머신별 2건 슬라이스는 판정부에서)
|
||||
_CURRENT_SQL = """
|
||||
SELECT q.stage, q.document_id, d.title, d.original_filename, d.file_path
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.status = 'processing'
|
||||
ORDER BY q.started_at DESC NULLS LAST
|
||||
LIMIT 50
|
||||
"""
|
||||
|
||||
|
||||
async def build_overview(session: AsyncSession) -> dict:
|
||||
"""5쿼리 수집 → compose_overview 판정 → 응답 dict."""
|
||||
now_kst = datetime.now(KST)
|
||||
kst_midnight = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
deep_enabled = settings.ai is not None and settings.ai.deep is not None
|
||||
|
||||
stage_rows = (
|
||||
await session.execute(text(_STAGE_STATS_SQL), {"kst_midnight": kst_midnight})
|
||||
).all()
|
||||
split_rows = (
|
||||
await session.execute(
|
||||
text(_SUMMARIZE_SPLIT_SQL),
|
||||
{"kst_midnight": kst_midnight, "macbook_alias": _MACBOOK_MODEL_ALIAS},
|
||||
)
|
||||
).all()
|
||||
inflow_rows = (await session.execute(text(_TREND_INFLOW_SQL))).all()
|
||||
done_rows = (await session.execute(text(_TREND_DONE_SQL))).all()
|
||||
current_result = (await session.execute(text(_CURRENT_SQL))).all()
|
||||
|
||||
current_rows = [
|
||||
{
|
||||
"stage": row[0],
|
||||
"document_id": row[1],
|
||||
"title": row[2],
|
||||
"original_filename": row[3],
|
||||
"file_path": row[4],
|
||||
}
|
||||
for row in current_result
|
||||
]
|
||||
|
||||
return compose_overview(
|
||||
rows_to_stage_stats(stage_rows),
|
||||
rows_to_summarize_split(split_rows),
|
||||
{row[0]: int(row[1]) for row in inflow_rows},
|
||||
{row[0]: int(row[1]) for row in done_rows},
|
||||
current_rows,
|
||||
deep_enabled=deep_enabled,
|
||||
now_kst=now_kst,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
// 처리 현황 드로어 (안6 라이트) — 전 페이지 상태 스트립 클릭 시 우측에서 열림.
|
||||
// 머신 미니카드 3 + ETA 한 줄 + 실패 합계 + 홈 링크 축약본. 상세는 홈 보드가 담당.
|
||||
// 데이터 = queueOverview store 공유 (60s 폴링, 실패 시 null → 안내문으로 degrade).
|
||||
// 열림 상태는 uiState 단일 drawer slot('queue') — 사이드바 드로어와 동시 오픈 차단.
|
||||
import { X } from 'lucide-svelte';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import { queueOverview } from '$lib/stores/queueOverview';
|
||||
import {
|
||||
MACHINE_STATE_LABEL, machineChipClass, machineDotClass, formatRate, etaPhrase,
|
||||
} from '$lib/utils/queueDisplay';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
|
||||
let open = $derived(ui.isDrawerOpen('queue'));
|
||||
let data = $derived($queueOverview);
|
||||
|
||||
function close() {
|
||||
ui.closeDrawer();
|
||||
}
|
||||
|
||||
// ESC 닫기 — 레이아웃 전역 핸들러(ui.handleEscape)와 중복돼도 무해(멱등).
|
||||
// modal stack 이 열려 있으면 modal 우선 (전역 우선순위와 동일).
|
||||
function onWindowKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open && ui.modalStack.length === 0) close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-drawer">
|
||||
<!-- 스크림 — 클릭 시 닫기 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={close}
|
||||
class="absolute inset-0 bg-scrim transition-opacity"
|
||||
aria-label="드로어 닫기"
|
||||
></button>
|
||||
|
||||
<!-- 패널 — div + role="dialog" (aside 는 interactive role 불가, a11y 경고) -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="처리 현황"
|
||||
class="absolute right-0 top-0 bottom-0 w-rail max-w-full bg-sidebar shadow-xl overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 h-12 border-b border-default">
|
||||
<span class="text-sm font-bold text-text">처리 현황</span>
|
||||
<IconButton icon={X} size="sm" aria-label="닫기" onclick={close} />
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
{#if data}
|
||||
<!-- 머신 미니카드 3 -->
|
||||
{#each data.machines as m (m.key)}
|
||||
<div class="bg-surface border border-default rounded-lg px-3.5 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="flex items-center gap-2 text-[13px] font-semibold text-text min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {machineDotClass(m.state)}"></span>
|
||||
<span class="truncate">{m.label}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(m.state)}">
|
||||
{MACHINE_STATE_LABEL[m.state]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[11px] text-dim mt-1 tabular-nums">
|
||||
대기 <strong class="text-text">{m.pending.toLocaleString()}</strong>
|
||||
· 오늘 <strong class="text-text">{m.done_today.toLocaleString()}</strong>건 처리
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- ETA 한 줄 (안5 라이트 — 추정치) -->
|
||||
<div
|
||||
class="text-[11px] text-dim leading-relaxed tabular-nums"
|
||||
title="현재 페이스 기반 추정치 — 유입 변동 시 달라질 수 있습니다"
|
||||
>
|
||||
요약 대기 <strong class="text-text">{data.summarize_eta.pending.toLocaleString()}건</strong>
|
||||
— 소화 {formatRate(data.summarize_eta.done_rate_1h)}/h
|
||||
· 유입 {formatRate(data.summarize_eta.inflow_rate_1h)}/h
|
||||
{#if data.summarize_eta.eta_minutes != null}
|
||||
· <span class="text-accent font-semibold">{etaPhrase(data.summarize_eta.eta_minutes)}</span>
|
||||
{:else}
|
||||
· 유입 우세(백필 중)
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 실패 합계 -->
|
||||
{#if data.totals.failed > 0}
|
||||
<div class="text-[11px] font-semibold text-error bg-error/10 rounded-md px-2.5 py-1.5 tabular-nums">
|
||||
실패 {data.totals.failed.toLocaleString()}건 — 확인 필요
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-xs text-dim">처리 현황을 불러오지 못했습니다.</p>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/"
|
||||
onclick={close}
|
||||
class="block text-xs text-accent font-semibold hover:underline pt-1"
|
||||
>홈에서 자세히 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 처리 큐 overview store — GET /api/queue/overview 를 60초 주기로 폴링.
|
||||
// system.ts 의 dashboardSummary 와 같은 구독 기반 패턴 (첫 subscribe 시 시작).
|
||||
//
|
||||
// 의도적으로 api() 헬퍼를 쓰지 않는다 — 폴링 경로의 401 이 refresh 실패 →
|
||||
// window.location='/login' 강제 logout 부수효과를 일으키면 안 됨 (eid 리뷰
|
||||
// finding 재발 방지). 백엔드 미배포(404)/401/네트워크 실패 전부 silent 하게
|
||||
// null 로 수렴하고, 소비자(스트립/보드/드로어)는 null 이면 스스로 숨는다.
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { getAccessToken } from '$lib/api';
|
||||
import type { QueueOverview } from '$lib/types/queue';
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscriberCount = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
const internal = writable<QueueOverview | null>(null, (_set) => {
|
||||
subscriberCount += 1;
|
||||
if (subscriberCount === 1 && browser) {
|
||||
void refreshQueueOverview();
|
||||
pollHandle = setInterval(() => void refreshQueueOverview(), POLL_INTERVAL_MS);
|
||||
}
|
||||
return () => {
|
||||
subscriberCount -= 1;
|
||||
if (subscriberCount === 0 && pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const queueOverview = { subscribe: internal.subscribe };
|
||||
|
||||
/** 경량 fetch — 실패는 전부 null (silent 비차단, 강제 logout 경로 없음) */
|
||||
async function fetchOverview(): Promise<QueueOverview | null> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = getAccessToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/queue/overview', { headers, credentials: 'include' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as QueueOverview;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 수동/추가 폴링용 — 홈은 자체 30s interval 로 이 함수를 호출 (동시 fetch 합치기) */
|
||||
export async function refreshQueueOverview(): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
internal.set(await fetchOverview());
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
// (toast는 별도 store. drawer가 persistent inline panel(예: xl+ meta rail)일 때는
|
||||
// 여기 시스템 밖이다 — 그저 레이아웃의 일부.)
|
||||
|
||||
type Drawer = { id: 'sidebar' | 'meta' } | null;
|
||||
// 'queue' = 처리 현황 드로어 (상태 스트립 클릭 시 우측) — 단일 slot 규칙 동일
|
||||
export type DrawerId = 'sidebar' | 'meta' | 'queue';
|
||||
type Drawer = { id: DrawerId } | null;
|
||||
type Modal = { id: string };
|
||||
|
||||
class UIState {
|
||||
@@ -11,14 +13,14 @@ class UIState {
|
||||
modalStack = $state<Modal[]>([]);
|
||||
|
||||
// ── Drawer (단일 slot) ──────────────────────────────
|
||||
openDrawer(id: 'sidebar' | 'meta') {
|
||||
openDrawer(id: DrawerId) {
|
||||
// 새 drawer 열면 이전 drawer는 자동으로 사라진다 (단일 slot)
|
||||
this.drawer = { id };
|
||||
}
|
||||
closeDrawer() {
|
||||
this.drawer = null;
|
||||
}
|
||||
isDrawerOpen(id: 'sidebar' | 'meta') {
|
||||
isDrawerOpen(id: DrawerId) {
|
||||
return this.drawer?.id === id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* GET /api/queue/overview 응답 타입.
|
||||
*
|
||||
* Backend 는 병렬 트랙에서 구현 중 — 계약 고정 (feat/ds-processing-board).
|
||||
* 필드 변경 시 양쪽 동시 수정 필수.
|
||||
*/
|
||||
|
||||
export type MachineKey = 'gpu' | 'macmini' | 'macbook';
|
||||
|
||||
/** 머신 상태 — active(가동) / deferred(보류) / idle(대기) */
|
||||
export type MachineState = 'active' | 'deferred' | 'idle';
|
||||
|
||||
/** 머신이 지금 처리 중인 문서 1건 */
|
||||
export interface MachineCurrentItem {
|
||||
document_id: number;
|
||||
title: string;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
export interface MachineOverview {
|
||||
key: MachineKey;
|
||||
label: string;
|
||||
state: MachineState;
|
||||
/** 담당 단계 키 목록 (extract/classify/... — 홈 STAGE_LABEL 로 한글화) */
|
||||
stages: string[];
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
/** 최근 1시간 완료 건수 (처리율 N/h 표기) */
|
||||
done_1h: number;
|
||||
done_today: number;
|
||||
/** 보류 건수 — 맥북 sleep 등으로 자동 재개 대기 중 */
|
||||
deferred_pending: number;
|
||||
current: MachineCurrentItem[];
|
||||
}
|
||||
|
||||
/** 요약 백로그 ETA (안5 라이트) — 추정치, 유입 변동 시 오차 */
|
||||
export interface SummarizeEta {
|
||||
pending: number;
|
||||
done_rate_1h: number;
|
||||
inflow_rate_1h: number;
|
||||
/** null = 유입이 소화를 앞섬 (백필 중) — 소진 예상 불가 */
|
||||
eta_minutes: number | null;
|
||||
}
|
||||
|
||||
/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
inflow: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface QueueTotals {
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface QueueStageRow {
|
||||
stage: string;
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
done_today: number;
|
||||
oldest_pending_age_sec: number | null;
|
||||
}
|
||||
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
trend_24h: TrendPoint[];
|
||||
stages: QueueStageRow[];
|
||||
totals: QueueTotals;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 처리 머신 보드 / 상태 스트립 / 드로어 공용 표시 헬퍼.
|
||||
// 상태 표현은 dot + 칩 (이모지 금지 원칙) — 토큰 클래스만 사용.
|
||||
|
||||
import type { MachineState } from '$lib/types/queue';
|
||||
|
||||
/** 머신 상태 한글 라벨 */
|
||||
export const MACHINE_STATE_LABEL: Record<MachineState, string> = {
|
||||
active: '가동',
|
||||
deferred: '보류',
|
||||
idle: '대기',
|
||||
};
|
||||
|
||||
/** 상태 dot 색 — 가동=success / 보류=warning / 대기=faint */
|
||||
export function machineDotClass(state: MachineState): string {
|
||||
if (state === 'active') return 'bg-success';
|
||||
if (state === 'deferred') return 'bg-warning';
|
||||
return 'bg-faint';
|
||||
}
|
||||
|
||||
/** 상태 칩 톤 — 가동=accent / 보류=warn / 대기=dim */
|
||||
export function machineChipClass(state: MachineState): string {
|
||||
if (state === 'active') return 'bg-accent/10 text-accent';
|
||||
if (state === 'deferred') return 'bg-warning/10 text-warning';
|
||||
return 'bg-surface-hover text-faint';
|
||||
}
|
||||
|
||||
/** 처리율 표기 — 정수는 그대로, 소수는 한 자리 */
|
||||
export function formatRate(n: number): string {
|
||||
return Number.isInteger(n) ? n.toLocaleString() : n.toFixed(1);
|
||||
}
|
||||
|
||||
/** ETA 분 → "약 N분/N시간 후 소진 예상" (추정치 — title 로 명시는 호출부 책임) */
|
||||
export function etaPhrase(minutes: number): string {
|
||||
if (minutes < 60) return `약 ${Math.max(1, Math.round(minutes))}분 후 소진 예상`;
|
||||
const hours = minutes / 60;
|
||||
const text = hours >= 10 ? String(Math.round(hours)) : String(Math.round(hours * 10) / 10);
|
||||
return `약 ${text}시간 후 소진 예상`;
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { refresh as refreshPublicConfig } from '$lib/stores/config';
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import { queueOverview } from '$lib/stores/queueOverview';
|
||||
import { MACHINE_STATE_LABEL, machineChipClass } from '$lib/utils/queueDisplay';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||
import QuickMemoButton from '$lib/components/QuickMemoButton.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
@@ -65,6 +68,15 @@
|
||||
let showChrome = $derived($isAuthenticated && !NO_CHROME_PATHS.some(p => $page.url.pathname.startsWith(p)));
|
||||
let showSidebar = $derived(showChrome && !NO_SIDEBAR_PATHS.some(p => $page.url.pathname.startsWith(p)));
|
||||
|
||||
// 처리 현황 스트립 (안6 라이트) — 60s 폴링 store 공유. fetch 실패/401 시
|
||||
// store 가 null → 스트립 자체를 숨김 (silent 비차단, 로그인 페이지 동일).
|
||||
let queue = $derived($queueOverview);
|
||||
let queueMacbook = $derived(queue?.machines?.find((m) => m.key === 'macbook') ?? null);
|
||||
function toggleQueueDrawer() {
|
||||
if (ui.isDrawerOpen('queue')) ui.closeDrawer();
|
||||
else ui.openDrawer('queue');
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
|
||||
e.preventDefault();
|
||||
@@ -162,6 +174,28 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 전 페이지 상태 스트립 (안6 라이트) — 클릭 시 우측 처리 현황 드로어 토글 -->
|
||||
{#if queue}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleQueueDrawer}
|
||||
aria-expanded={ui.isDrawerOpen('queue')}
|
||||
aria-label="처리 현황 자세히 보기"
|
||||
class="flex items-center gap-3 px-4 py-1.5 border-b border-default bg-surface text-[11px] text-dim shrink-0 text-left hover:bg-surface-hover transition-colors overflow-x-auto"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="w-2 h-2 rounded-full {queue.totals.processing > 0 ? 'bg-success' : 'bg-faint'}"></span>
|
||||
<strong class="text-text font-semibold tabular-nums">처리 중 {queue.totals.processing.toLocaleString()}</strong>
|
||||
</span>
|
||||
<span class="tabular-nums shrink-0">대기 <strong class="text-text">{queue.totals.pending.toLocaleString()}</strong></span>
|
||||
<span class="tabular-nums shrink-0 {queue.totals.failed > 0 ? 'text-error font-semibold' : ''}">실패 <strong class={queue.totals.failed > 0 ? '' : 'text-text'}>{queue.totals.failed.toLocaleString()}</strong></span>
|
||||
{#if queueMacbook}
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(queueMacbook.state)}">맥북 {MACHINE_STATE_LABEL[queueMacbook.state]}</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex items-center gap-0.5 text-faint shrink-0">자세히 <ChevronDown size={11} /></span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- 메인: 데스크탑 상시 사이드바 + 콘텐츠 -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if showSidebar}
|
||||
@@ -191,6 +225,9 @@
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<!-- 처리 현황 드로어 (안6 라이트, 스트립 클릭 시 우측) -->
|
||||
<QueueDrawer />
|
||||
|
||||
<!-- 빠른 메모 FAB -->
|
||||
<QuickMemoButton />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { queueOverview, refreshQueueOverview } from '$lib/stores/queueOverview';
|
||||
import {
|
||||
MACHINE_STATE_LABEL, machineChipClass, machineDotClass, formatRate, etaPhrase,
|
||||
} from '$lib/utils/queueDisplay';
|
||||
import type { QueueOverview } from '$lib/types/queue';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import {
|
||||
@@ -125,6 +130,28 @@
|
||||
preview: '미리보기', thumbnail: '썸네일',
|
||||
};
|
||||
|
||||
// ─── 처리 머신 보드 (안2) + ETA (안5 라이트) — GET /api/queue/overview ───
|
||||
// 홈은 30s 폴링 (store 기본 60s 위에 추가 — inFlight 합치기로 중복 호출 0).
|
||||
// 백엔드 미배포/실패 시 store=null → 보드 자체가 조용히 생략 (silent 비차단).
|
||||
let queue = $derived<QueueOverview | null>($queueOverview);
|
||||
|
||||
// 머신 담당 단계 라벨 — STAGE_LABEL 재사용 + overview 전용 단계 보강
|
||||
// (backend services/queue_overview.py _STAGE_ORDER 와 동기), 미지 키는 raw
|
||||
const QUEUE_STAGE_LABEL: Record<string, string> = {
|
||||
...STAGE_LABEL,
|
||||
summarize: '요약', chunk: '청크', markdown: '마크다운',
|
||||
fulltext: '전문', deep_summary: '심층분석',
|
||||
};
|
||||
function queueStageLabel(stage: string): string {
|
||||
return QUEUE_STAGE_LABEL[stage] ?? stage;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void refreshQueueOverview();
|
||||
const handle = setInterval(() => void refreshQueueOverview(), 30_000);
|
||||
return () => clearInterval(handle);
|
||||
});
|
||||
|
||||
interface PipelineRow {
|
||||
stage: string; label: string;
|
||||
pending: number; processing: number; failed: number; total: number;
|
||||
@@ -172,7 +199,20 @@
|
||||
let totalProcessing = $derived(pipelineRows.reduce((s, r) => s + r.processing, 0));
|
||||
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
|
||||
let pipelineOpen = $derived(
|
||||
pipelineManualClosed ? false : (queue?.totals.failed ?? totalFailed) > 0
|
||||
);
|
||||
|
||||
// 단계별 현황 (2026-06-11 피드백 재설계: 완료가 보여야 한다 — overview.stages 단일 소스)
|
||||
// active = 오늘 움직임이 있는 단계만, idle = 전부 0 인 단계는 한 줄로 숨김.
|
||||
let stageRows = $derived(queue?.stages ?? []);
|
||||
let activeStageRows = $derived(
|
||||
stageRows.filter((r) => r.pending + r.processing + r.failed + r.done_today > 0)
|
||||
);
|
||||
let idleStageRows = $derived(
|
||||
stageRows.filter((r) => r.pending + r.processing + r.failed + r.done_today === 0)
|
||||
);
|
||||
let stageDoneToday = $derived(stageRows.reduce((s, r) => s + r.done_today, 0));
|
||||
|
||||
function formatAge(sec: number | null): string {
|
||||
if (sec == null || sec <= 0) return '';
|
||||
@@ -420,7 +460,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 파이프라인 상세 (실패 있을 때 자동 펼침) ═══ -->
|
||||
<!-- ═══ 처리 머신 보드 (안2) + ETA 라인 (안5 라이트) ═══ -->
|
||||
{#if queue}
|
||||
<div class="mt-5">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-3">처리 머신</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{#each queue.machines as m (m.key)}
|
||||
<div class="bg-surface border border-default rounded-card p-4">
|
||||
<!-- 헤더: 상태 dot + 라벨 + state 칩 -->
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<span class="flex items-center gap-2 text-[13px] font-bold text-text min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {machineDotClass(m.state)}"></span>
|
||||
<span class="truncate">{m.label}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(m.state)}">
|
||||
{MACHINE_STATE_LABEL[m.state]}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 담당 단계 칩 -->
|
||||
{#if m.stages.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-2.5">
|
||||
{#each m.stages as s (s)}
|
||||
<span class="text-[10px] font-semibold rounded-full px-2 py-0.5 bg-surface-hover text-dim">{queueStageLabel(s)}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 대기 · 처리율 · 오늘 -->
|
||||
<div class="text-xs text-dim tabular-nums">
|
||||
대기 <strong class="text-text">{m.pending.toLocaleString()}</strong>
|
||||
· 처리율 <strong class="text-text">{formatRate(m.done_1h)}/h</strong>
|
||||
· 오늘 <strong class="text-text">{m.done_today.toLocaleString()}</strong>건
|
||||
</div>
|
||||
<!-- 맥북 보류 (sleep 등 자동 재개 대기) -->
|
||||
{#if m.key === 'macbook' && m.deferred_pending > 0}
|
||||
<div class="text-[11px] font-semibold text-warning mt-1.5 tabular-nums">보류 {m.deferred_pending.toLocaleString()}건 — 자동 재개 대기</div>
|
||||
{/if}
|
||||
<!-- 지금 처리 중인 문서 -->
|
||||
{#if m.current.length > 0}
|
||||
<div class="text-[11px] text-dim border-t border-dashed border-default mt-2.5 pt-2 truncate"
|
||||
title={m.current.map((c) => `${c.title} (${queueStageLabel(c.stage)})`).join(' · ')}>
|
||||
지금: {m.current[0].title} ({queueStageLabel(m.current[0].stage)}){m.current.length > 1 ? ` 외 ${m.current.length - 1}건` : ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ETA 한 줄 (안5 라이트 — 추정치) -->
|
||||
<div class="text-xs text-dim mt-2.5 px-1 tabular-nums"
|
||||
title="현재 페이스 기반 추정치 — 유입 변동 시 달라질 수 있습니다">
|
||||
요약 대기 <strong class="text-text">{queue.summarize_eta.pending.toLocaleString()}건</strong>
|
||||
— 소화 {formatRate(queue.summarize_eta.done_rate_1h)}/h
|
||||
· 유입 {formatRate(queue.summarize_eta.inflow_rate_1h)}/h
|
||||
{#if queue.summarize_eta.eta_minutes != null}
|
||||
· <span class="text-accent font-semibold">{etaPhrase(queue.summarize_eta.eta_minutes)}</span>
|
||||
{:else}
|
||||
· 유입 우세(백필 중)
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 단계 상세 (기존 stage 테이블 — 접힘 강등, 실패 있을 때 자동 펼침) ═══ -->
|
||||
<details
|
||||
class="mt-5"
|
||||
open={pipelineOpen}
|
||||
@@ -429,44 +530,67 @@
|
||||
<summary class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-card cursor-pointer hover:bg-surface-hover transition-colors select-none list-none">
|
||||
<span class="text-sm font-semibold text-text flex items-center gap-2">
|
||||
<ChevronRight size={14} class="transition-transform details-chevron" />
|
||||
파이프라인 상세
|
||||
단계별 현황
|
||||
</span>
|
||||
<span class="text-xs text-dim flex items-center gap-2.5">
|
||||
{#if totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
|
||||
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
|
||||
{#if totalFailed === 0 && totalPending === 0}<span>처리 완료</span>{/if}
|
||||
{#if queue}
|
||||
{#if stageDoneToday > 0}<span class="text-success">오늘 {stageDoneToday.toLocaleString()} 완료</span>{/if}
|
||||
{#if queue.totals.failed > 0}<span class="text-error font-medium">실패 {queue.totals.failed}</span>{/if}
|
||||
{#if queue.totals.pending > 0}<span>대기 {queue.totals.pending.toLocaleString()}</span>{/if}
|
||||
{#if stageDoneToday === 0 && queue.totals.failed === 0 && queue.totals.pending === 0}<span>모든 단계 한가함</span>{/if}
|
||||
{:else}
|
||||
{#if totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
|
||||
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-card">
|
||||
<p class="text-xs text-dim mb-3">최근 24시간</p>
|
||||
{#if pipelineRows.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each pipelineRows as row (row.stage)}
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1.5">
|
||||
<span class="text-dim">
|
||||
{row.label}
|
||||
{#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600}
|
||||
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">({formatAge(row.oldestPendingAgeSec)})</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-dim tabular-nums">
|
||||
대기 <span class="text-text">{row.pending}</span> ·
|
||||
처리 <span class="text-text">{row.processing}</span> ·
|
||||
실패 <span class={row.failed > 0 ? 'text-error font-medium' : ''}>{row.failed}</span>
|
||||
</span>
|
||||
{#if queue}
|
||||
{#if activeStageRows.length > 0}
|
||||
<div class="space-y-3.5">
|
||||
{#each activeStageRows as row (row.stage)}
|
||||
{@const total = row.done_today + row.pending + row.processing}
|
||||
{@const donePct = total > 0 ? (row.done_today / total) * 100 : 0}
|
||||
{@const procPct = total > 0 ? (row.processing / total) * 100 : 0}
|
||||
<div>
|
||||
<div class="flex items-baseline justify-between text-xs mb-1.5 gap-2">
|
||||
<span class="font-medium text-text flex items-center gap-1.5 whitespace-nowrap">
|
||||
{queueStageLabel(row.stage)}
|
||||
{#if row.processing > 0}
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>
|
||||
<span class="text-accent font-normal">처리 중 {row.processing}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-dim tabular-nums flex items-center gap-2.5 whitespace-nowrap">
|
||||
{#if row.done_today > 0}<span class="text-success">오늘 {row.done_today.toLocaleString()} 완료</span>{/if}
|
||||
{#if row.pending > 0}<span>대기 {row.pending.toLocaleString()}</span>{/if}
|
||||
{#if row.failed > 0}<span class="text-error font-medium">실패 {row.failed}</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 게이지 = 이 단계의 오늘 진척 (완료 / 완료+대기) — 가득 찬 초록 = 다 끝남 -->
|
||||
<div class="flex h-1.5 w-full overflow-hidden rounded-sm bg-bg" title="오늘 완료 {row.done_today.toLocaleString()} / 잔여 {row.pending.toLocaleString()}">
|
||||
{#if donePct > 0}<div class="bg-success/70 h-full" style="width: {donePct}%"></div>{/if}
|
||||
{#if procPct > 0}<div class="bg-accent h-full" style="width: {Math.max(procPct, 1)}%"></div>{/if}
|
||||
</div>
|
||||
{#if row.pending > 0 && row.oldest_pending_age_sec && row.oldest_pending_age_sec > 600}
|
||||
<p class="text-[10px] mt-1 tabular-nums {row.oldest_pending_age_sec > 21600 ? 'text-warning' : 'text-faint'}">
|
||||
가장 오래 기다린 항목 {formatAge(row.oldest_pending_age_sec)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex h-1.5 w-full overflow-hidden rounded-sm bg-bg">
|
||||
{#if row.pending > 0}<div class="bg-warning h-full" style="width: {(row.pending / pipelineMax) * 100}%"></div>{/if}
|
||||
{#if row.processing > 0}<div class="bg-accent h-full" style="width: {(row.processing / pipelineMax) * 100}%"></div>{/if}
|
||||
{#if row.failed > 0}<div class="bg-error h-full" style="width: {(row.failed / pipelineMax) * 100}%"></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-dim text-center py-3">대기·처리·실패 없음 — 모든 단계가 한가합니다</p>
|
||||
{/if}
|
||||
{#if idleStageRows.length > 0}
|
||||
<p class="text-[11px] text-faint mt-4 pt-3 border-t border-default">
|
||||
비어 있음: {idleStageRows.map((r) => queueStageLabel(r.stage)).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-xs text-dim text-center py-3">처리 작업 없음</p>
|
||||
<p class="text-xs text-dim text-center py-3">현황을 불러오지 못했습니다</p>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""GET /api/queue/overview 판정부 단위테스트 — DB 불요 (plan ds-processing-ui-6an).
|
||||
|
||||
services/queue_overview 의 SQL 수집부와 분리된 순수 판정 함수
|
||||
(stage_machine_map / build_machines / build_summarize_eta / build_trend /
|
||||
build_totals / compute_eta_minutes / rows_to_* / display_title) 를
|
||||
mock 행으로 검증한다. 통합(실 SQL)은 배포 후 라이브 smoke 로 확인.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from services.queue_overview import (
|
||||
build_machines,
|
||||
build_summarize_eta,
|
||||
build_totals,
|
||||
build_trend,
|
||||
compose_overview,
|
||||
compute_eta_minutes,
|
||||
display_title,
|
||||
rows_to_stage_stats,
|
||||
rows_to_summarize_split,
|
||||
stage_machine_map,
|
||||
)
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def _stage(**kw) -> dict:
|
||||
"""stage 통계 1건 — 미지정 필드 0."""
|
||||
base = {
|
||||
"pending": 0, "processing": 0, "failed": 0,
|
||||
"done_1h": 0, "done_today": 0, "done_15m": 0,
|
||||
"deferred_pending": 0, "created_1h": 0,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def _split(macbook: dict | None = None, macmini: dict | None = None) -> dict:
|
||||
"""summarize 풀 완료 실적 split — 미지정 0."""
|
||||
zero = {"done_1h": 0, "done_today": 0, "done_15m": 0}
|
||||
return {
|
||||
"macbook": {**zero, **(macbook or {})},
|
||||
"macmini": {**zero, **(macmini or {})},
|
||||
}
|
||||
|
||||
|
||||
def _machine(machines: list[dict], key: str) -> dict:
|
||||
return next(m for m in machines if m["key"] == key)
|
||||
|
||||
|
||||
# ─── stage→machine 귀속 맵 ────────────────────────────────────────────────────
|
||||
|
||||
def test_stage_machine_map_deep_enabled():
|
||||
smap = stage_machine_map(deep_enabled=True)
|
||||
for s in ("extract", "embed", "chunk", "markdown", "preview", "thumbnail", "fulltext", "stt"):
|
||||
assert smap[s] == "gpu"
|
||||
assert smap["classify"] == "macmini"
|
||||
assert smap["summarize"] == "macmini"
|
||||
assert smap["deep_summary"] == "macbook"
|
||||
|
||||
|
||||
def test_stage_machine_map_deep_disabled():
|
||||
"""deep 슬롯 부재 시 deep_summary 도 macmini 귀속."""
|
||||
smap = stage_machine_map(deep_enabled=False)
|
||||
assert smap["deep_summary"] == "macmini"
|
||||
|
||||
|
||||
# ─── 머신 카드 귀속 합산 ──────────────────────────────────────────────────────
|
||||
|
||||
def test_gpu_stage_counts_attribution():
|
||||
stats = {
|
||||
"extract": _stage(pending=3, processing=1, done_1h=5, done_today=9, done_15m=1),
|
||||
"stt": _stage(failed=2, done_1h=1, done_today=2),
|
||||
}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
gpu = _machine(machines, "gpu")
|
||||
assert (gpu["pending"], gpu["processing"], gpu["failed"]) == (3, 1, 2)
|
||||
assert (gpu["done_1h"], gpu["done_today"]) == (6, 11)
|
||||
# gpu 의 stages 는 정적 8종 전부 (집계 0 이어도 표시)
|
||||
assert gpu["stages"] == [
|
||||
"extract", "embed", "chunk", "markdown",
|
||||
"preview", "thumbnail", "fulltext", "stt",
|
||||
]
|
||||
|
||||
|
||||
def test_summarize_pool_split_attribution():
|
||||
"""summarize pending/failed = macmini 귀속, 완료 실적은 split 로 분리 —
|
||||
stage-level summarize done 수치는 카드에 이중 합산되지 않는다."""
|
||||
stats = {
|
||||
"classify": _stage(done_1h=2, done_today=3),
|
||||
"summarize": _stage(pending=7, failed=1, done_1h=10, done_today=20),
|
||||
}
|
||||
split = _split(macbook={"done_1h": 4, "done_today": 8}, macmini={"done_1h": 6, "done_today": 12})
|
||||
machines = build_machines(stats, split, [], deep_enabled=True)
|
||||
macmini = _machine(machines, "macmini")
|
||||
macbook = _machine(machines, "macbook")
|
||||
|
||||
assert macmini["pending"] == 7 and macmini["failed"] == 1
|
||||
assert macmini["done_1h"] == 2 + 6 # classify + macmini 몫 (10 아님)
|
||||
assert macmini["done_today"] == 3 + 12
|
||||
assert macbook["done_1h"] == 4 and macbook["done_today"] == 8
|
||||
assert macbook["pending"] == 0 # 풀 pending 은 macmini 만
|
||||
|
||||
|
||||
def test_deep_disabled_deep_summary_counts_to_macmini():
|
||||
stats = {"deep_summary": _stage(pending=2, processing=1, done_1h=3, done_today=4)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=False)
|
||||
macmini = _machine(machines, "macmini")
|
||||
macbook = _machine(machines, "macbook")
|
||||
assert macmini["pending"] == 2 and macmini["processing"] == 1
|
||||
assert macmini["done_1h"] == 3 and macmini["done_today"] == 4
|
||||
assert macbook["stages"] == [] and macbook["pending"] == 0
|
||||
assert _machine(machines, "macmini")["stages"] == ["classify", "summarize", "deep_summary"]
|
||||
|
||||
|
||||
def test_deferred_pending_always_on_macbook_card():
|
||||
"""보류(deferred_until 미래)는 summarize+deep_summary 합산으로 macbook 카드 귀속.
|
||||
deep 슬롯 유무와 무관 (보류 = 맥북 불가 신호)."""
|
||||
stats = {
|
||||
"summarize": _stage(pending=5, deferred_pending=2),
|
||||
"deep_summary": _stage(pending=1, deferred_pending=1),
|
||||
}
|
||||
for deep_enabled in (True, False):
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=deep_enabled)
|
||||
assert _machine(machines, "macbook")["deferred_pending"] == 3
|
||||
assert _machine(machines, "gpu")["deferred_pending"] == 0
|
||||
assert _machine(machines, "macmini")["deferred_pending"] == 0
|
||||
|
||||
|
||||
# ─── state 판정 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_macbook_state_active_wins_over_deferred_while_working():
|
||||
"""가동 > 보류 (사용자 피드백 2026-06-11): 일하고 있으면 백오프 잔여가 있어도 '가동'.
|
||||
|
||||
보류 건수는 deferred_pending 필드가 별도로 전달 — 카드 라인이 표시.
|
||||
"""
|
||||
stats = {"summarize": _stage(pending=1, deferred_pending=1)}
|
||||
split = _split(macbook={"done_15m": 3})
|
||||
machines = build_machines(stats, split, [], deep_enabled=True)
|
||||
mb = _machine(machines, "macbook")
|
||||
assert mb["state"] == "active"
|
||||
assert mb["deferred_pending"] == 1
|
||||
|
||||
|
||||
def test_macbook_state_deferred_only_when_not_working():
|
||||
"""일이 멈춰 있고(처리 0·최근 완료 0) 백오프만 쌓인 상태에서만 '보류'."""
|
||||
stats = {"summarize": _stage(pending=1, deferred_pending=1)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "macbook")["state"] == "deferred"
|
||||
|
||||
|
||||
def test_macbook_state_active_on_recent_qwen_done():
|
||||
split = _split(macbook={"done_15m": 1})
|
||||
machines = build_machines({}, split, [], deep_enabled=True)
|
||||
assert _machine(machines, "macbook")["state"] == "active"
|
||||
|
||||
|
||||
def test_macbook_state_idle():
|
||||
machines = build_machines({}, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "macbook")["state"] == "idle"
|
||||
|
||||
|
||||
def test_gpu_state_active_on_processing():
|
||||
stats = {"extract": _stage(processing=1)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "gpu")["state"] == "active"
|
||||
|
||||
|
||||
def test_gpu_state_active_on_recent_done():
|
||||
stats = {"embed": _stage(done_15m=2)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "gpu")["state"] == "active"
|
||||
|
||||
|
||||
def test_gpu_state_idle_when_old_done_only():
|
||||
stats = {"embed": _stage(done_1h=5, done_today=9)} # 15분 내 완료 없음
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "gpu")["state"] == "idle"
|
||||
|
||||
|
||||
def test_macmini_state_not_active_on_macbook_pool_done():
|
||||
"""summarize 풀 완료가 전부 macbook 몫이면 macmini 는 active 아님 (귀속 기준)."""
|
||||
stats = {"summarize": _stage(done_15m=1)}
|
||||
split = _split(macbook={"done_15m": 1})
|
||||
machines = build_machines(stats, split, [], deep_enabled=True)
|
||||
assert _machine(machines, "macmini")["state"] == "idle"
|
||||
|
||||
|
||||
def test_macmini_state_active_on_summarize_processing():
|
||||
stats = {"summarize": _stage(processing=1)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=True)
|
||||
assert _machine(machines, "macmini")["state"] == "active"
|
||||
|
||||
|
||||
# ─── current 귀속 ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_current_summarize_to_macmini_max_two():
|
||||
rows = [
|
||||
{"stage": "summarize", "document_id": 1, "title": "문서A", "original_filename": None, "file_path": None},
|
||||
{"stage": "summarize", "document_id": 2, "title": "문서B", "original_filename": None, "file_path": None},
|
||||
{"stage": "summarize", "document_id": 3, "title": "문서C", "original_filename": None, "file_path": None},
|
||||
{"stage": "extract", "document_id": 4, "title": "문서D", "original_filename": None, "file_path": None},
|
||||
]
|
||||
machines = build_machines({}, _split(), rows, deep_enabled=True)
|
||||
macmini = _machine(machines, "macmini")
|
||||
gpu = _machine(machines, "gpu")
|
||||
assert [c["document_id"] for c in macmini["current"]] == [1, 2] # 최대 2건
|
||||
assert macmini["current"][0] == {"document_id": 1, "title": "문서A", "stage": "summarize"}
|
||||
assert [c["document_id"] for c in gpu["current"]] == [4]
|
||||
assert _machine(machines, "macbook")["current"] == []
|
||||
|
||||
|
||||
def test_current_deep_summary_follows_deep_slot():
|
||||
rows = [{"stage": "deep_summary", "document_id": 9, "title": "심층", "original_filename": None, "file_path": None}]
|
||||
enabled = build_machines({}, _split(), rows, deep_enabled=True)
|
||||
disabled = build_machines({}, _split(), rows, deep_enabled=False)
|
||||
assert _machine(enabled, "macbook")["current"][0]["document_id"] == 9
|
||||
assert _machine(disabled, "macmini")["current"][0]["document_id"] == 9
|
||||
|
||||
|
||||
def test_display_title_fallback_chain():
|
||||
assert display_title({"document_id": 1, "title": "제목"}) == "제목"
|
||||
assert display_title({"document_id": 1, "title": None, "original_filename": "a.pdf"}) == "a.pdf"
|
||||
assert display_title(
|
||||
{"document_id": 1, "title": None, "original_filename": None, "file_path": "/documents/PKM/Inbox/b.hwp"}
|
||||
) == "b.hwp"
|
||||
assert display_title(
|
||||
{"document_id": 7, "title": None, "original_filename": None, "file_path": None}
|
||||
) == "문서 #7"
|
||||
|
||||
|
||||
# ─── summarize ETA ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_eta_minutes_positive_drain():
|
||||
# 순소화 6건/h, 잔량 30건 → 300분
|
||||
assert compute_eta_minutes(30, 10, 4) == 300
|
||||
|
||||
|
||||
def test_eta_minutes_null_when_not_draining():
|
||||
assert compute_eta_minutes(30, 4, 10) is None # 유입 > 소화
|
||||
assert compute_eta_minutes(30, 5, 5) is None # 동률도 null
|
||||
assert compute_eta_minutes(30, 0, 0) is None
|
||||
|
||||
|
||||
def test_eta_minutes_zero_pending():
|
||||
assert compute_eta_minutes(0, 10, 4) == 0
|
||||
|
||||
|
||||
def test_build_summarize_eta_pending_includes_deferred():
|
||||
stats = {"summarize": _stage(pending=12, deferred_pending=5, done_1h=8, created_1h=2)}
|
||||
eta = build_summarize_eta(stats)
|
||||
assert eta == {
|
||||
"pending": 12, # 보류 포함 총수 (pending 자체에 deferred 포함)
|
||||
"done_rate_1h": 8,
|
||||
"inflow_rate_1h": 2,
|
||||
"eta_minutes": round(12 / 6 * 60),
|
||||
}
|
||||
|
||||
|
||||
def test_build_summarize_eta_empty_stats():
|
||||
eta = build_summarize_eta({})
|
||||
assert eta == {"pending": 0, "done_rate_1h": 0, "inflow_rate_1h": 0, "eta_minutes": None}
|
||||
|
||||
|
||||
# ─── trend 24h ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_trend_24_buckets_oldest_first_with_gaps():
|
||||
now_kst = datetime(2026, 6, 11, 14, 30, tzinfo=KST)
|
||||
inflow = {"2026-06-11 13:00": 3, "2026-06-10 15:00": 1} # 15:00 어제 = 최고령 버킷
|
||||
done = {"2026-06-11 14:00": 2}
|
||||
trend = build_trend(inflow, done, now_kst)
|
||||
|
||||
assert len(trend) == 24
|
||||
assert trend[0] == {"hour": "15:00", "inflow": 1, "done": 0} # 오래된 것부터
|
||||
assert trend[-1] == {"hour": "14:00", "inflow": 0, "done": 2} # 현재 시각 버킷
|
||||
assert trend[-2] == {"hour": "13:00", "inflow": 3, "done": 0}
|
||||
# 빈 버킷은 0
|
||||
assert sum(b["inflow"] for b in trend) == 4
|
||||
assert sum(b["done"] for b in trend) == 2
|
||||
|
||||
|
||||
def test_trend_ignores_out_of_window_bucket():
|
||||
"""창 밖(24버킷 미포함) key 는 무시 — cutoff 경계 행이 섞여도 안전."""
|
||||
now_kst = datetime(2026, 6, 11, 14, 30, tzinfo=KST)
|
||||
inflow = {"2026-06-10 14:00": 99} # 14:00 어제 — 창의 최고령(15:00 어제) 이전
|
||||
trend = build_trend(inflow, {}, now_kst)
|
||||
assert sum(b["inflow"] for b in trend) == 0
|
||||
|
||||
|
||||
def test_trend_kst_midnight_crossing_labels():
|
||||
now_kst = datetime(2026, 6, 11, 2, 5, tzinfo=KST)
|
||||
trend = build_trend({}, {}, now_kst)
|
||||
assert trend[-1]["hour"] == "02:00"
|
||||
assert trend[0]["hour"] == "03:00" # 전날 03:00 (라벨은 HH:00 만)
|
||||
assert [b["hour"] for b in trend[-3:]] == ["00:00", "01:00", "02:00"]
|
||||
|
||||
|
||||
# ─── totals / row 변환 / 전체 조립 ───────────────────────────────────────────
|
||||
|
||||
def test_totals_sum_all_stages():
|
||||
stats = {
|
||||
"extract": _stage(pending=1, processing=2, failed=3),
|
||||
"summarize": _stage(pending=4, failed=1),
|
||||
"deep_summary": _stage(pending=2),
|
||||
}
|
||||
assert build_totals(stats) == {"pending": 7, "processing": 2, "failed": 4}
|
||||
|
||||
|
||||
def test_rows_to_stage_stats_conversion():
|
||||
rows = [
|
||||
("extract", 3, 1, 0, 5, 9, 1, 0, 2),
|
||||
("summarize", 7, None, 1, 10, 20, 0, 2, 4), # None 방어
|
||||
]
|
||||
stats = rows_to_stage_stats(rows)
|
||||
assert stats["extract"]["pending"] == 3 and stats["extract"]["created_1h"] == 2
|
||||
assert stats["summarize"]["processing"] == 0
|
||||
assert stats["summarize"]["deferred_pending"] == 2
|
||||
|
||||
|
||||
def test_rows_to_summarize_split_conversion():
|
||||
rows = [
|
||||
(True, 4, 8, 1), # is_macbook
|
||||
(False, 6, 12, 0),
|
||||
]
|
||||
split = rows_to_summarize_split(rows)
|
||||
assert split["macbook"] == {"done_1h": 4, "done_today": 8, "done_15m": 1}
|
||||
assert split["macmini"] == {"done_1h": 6, "done_today": 12, "done_15m": 0}
|
||||
|
||||
|
||||
def test_rows_to_summarize_split_empty():
|
||||
split = rows_to_summarize_split([])
|
||||
assert split["macbook"]["done_1h"] == 0 and split["macmini"]["done_today"] == 0
|
||||
|
||||
|
||||
def test_compose_overview_contract_shape():
|
||||
"""응답 dict 의 키가 FE 계약 shape 과 정확히 일치하는지 고정."""
|
||||
out = compose_overview(
|
||||
{"summarize": _stage(pending=1)},
|
||||
_split(),
|
||||
{}, {}, [],
|
||||
deep_enabled=True,
|
||||
now_kst=datetime(2026, 6, 11, 14, 30, tzinfo=KST),
|
||||
)
|
||||
assert set(out.keys()) == {"machines", "stages", "summarize_eta", "trend_24h", "totals"}
|
||||
assert [m["key"] for m in out["machines"]] == ["gpu", "macmini", "macbook"]
|
||||
for m in out["machines"]:
|
||||
assert set(m.keys()) == {
|
||||
"key", "label", "state", "stages", "pending", "processing", "failed",
|
||||
"done_1h", "done_today", "deferred_pending", "current",
|
||||
}
|
||||
assert m["state"] in ("active", "deferred", "idle")
|
||||
assert set(out["summarize_eta"].keys()) == {"pending", "done_rate_1h", "inflow_rate_1h", "eta_minutes"}
|
||||
assert len(out["trend_24h"]) == 24
|
||||
assert set(out["trend_24h"][0].keys()) == {"hour", "inflow", "done"}
|
||||
assert set(out["totals"].keys()) == {"pending", "processing", "failed"}
|
||||
# 머신 label 고정 (raw 모델명 노출 금지 — label 만)
|
||||
assert [m["label"] for m in out["machines"]] == ["GPU 서버", "맥미니", "맥북 M5 Max"]
|
||||
|
||||
|
||||
# ─── build_stages (단계별 현황 — 2026-06-11 사용자 피드백: 완료 가시화) ──────
|
||||
|
||||
def test_build_stages_order_fields_and_age():
|
||||
from datetime import timedelta, timezone
|
||||
from services.queue_overview import build_stages
|
||||
now = datetime(2026, 6, 11, 14, 0, tzinfo=timezone.utc)
|
||||
stats = {
|
||||
"summarize": {**_stage(pending=5, done_today=12),
|
||||
"oldest_pending_at": now - timedelta(hours=4)},
|
||||
"extract": _stage(failed=2),
|
||||
}
|
||||
rows = build_stages(stats, now=now)
|
||||
by = {r["stage"]: r for r in rows}
|
||||
# 파이프라인 순서: extract 가 summarize 보다 앞
|
||||
assert rows[0]["stage"] == "extract"
|
||||
assert by["summarize"]["pending"] == 5
|
||||
assert by["summarize"]["done_today"] == 12
|
||||
assert by["summarize"]["oldest_pending_age_sec"] == 4 * 3600
|
||||
assert by["extract"]["failed"] == 2
|
||||
assert by["extract"]["oldest_pending_age_sec"] is None
|
||||
# 전 stage 행 존재 (빈 단계 숨김은 FE 몫)
|
||||
assert {"stage", "pending", "processing", "failed", "done_today",
|
||||
"oldest_pending_age_sec"} == set(rows[0].keys())
|
||||
Reference in New Issue
Block a user