결함 1 — 업로드 세션이 인증 세션을 가리고 있었다
- finalize_project_upload가 Depends(verify_session)로 받은 session을 같은 이름으로
덮어써, str(session["role"])이 업로드 세션 행에서 role을 찾다 KeyError를 냈다.
KeyError는 LookupError 하위라 404 {"message": "'role'"}로 나가고 로그도 안 남았다.
- 업로드 세션 변수를 upload_session으로 분리(청크 업로드·finalize·상태 조회 3곳).
- B03_FileInput_Router_Errors.lookup_error_response() 신설: 조회 실패만 404,
KeyError·IndexError는 500 + 예외 로그. 두 라우터의 LookupError 처리 13곳에 적용.
batch 단위 엔드포인트에는 batch_id를, 프로젝트 단위에는 project_id를 로그 필드로 준다.
결함 4 — fail_stage가 예외 문자열을 그대로 넣어 UPDATE가 죽었다
- project_workflow_stages.message는 varchar(255)인데 PermissionError 메시지는 300자를
넘겨 DataError로 실패했고, 단계가 FAILED로 못 가 화면이 영영 "분석 중"이었다.
- 200자로 자르고 말줄임표를 붙인다. 원문은 호출부 로그에 남는다.
검증(실서버, 신규 프로젝트 f45243b3에 표본 5개 직접 청크 업로드):
finalize 성공 11건 / "'role'" 오류 0건, 마지막 파일 complete_upload=true도 성공.
이어서 WF1 자동 분석이 시작됨(stage 0 COMPLETE, stage 1 IN_PROGRESS) — 종전에는
예외가 스케줄링 앞에서 터져 자동 분석이 아예 걸리지 않았다.
ruff format·check 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
7.0 KiB
Python
223 lines
7.0 KiB
Python
"""워크플로우 단계별 상태 관리를 위한 공통 유틸리티."""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Any, Dict
|
|
|
|
import aiomysql
|
|
|
|
# `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다.
|
|
_STAGE_MESSAGE_MAX_LENGTH = 200
|
|
|
|
STAGE_KEYS = [
|
|
"FILE_INPUT", # 0 (B03)
|
|
"PREPROCESS", # 1 (B04)
|
|
"PROFILE", # 2 (B05)
|
|
"SECTION", # 3 (B06)
|
|
"QUANTITY", # 4 (B07)
|
|
"DESIGN_DETAIL", # 5 (B08)
|
|
"ESTIMATION", # 6 (B09)
|
|
]
|
|
|
|
|
|
async def initialize_project_stages(cursor: aiomysql.DictCursor, project_id: str) -> None:
|
|
"""프로젝트 생성 시 7개의 단계를 NOT_STARTED 상태로 시드한다."""
|
|
for stage_no, stage_key in enumerate(STAGE_KEYS):
|
|
await cursor.execute(
|
|
"""
|
|
INSERT INTO project_workflow_stages (
|
|
project_id, stage_no, stage_key, state, progress_percent
|
|
)
|
|
VALUES (%s, %s, %s, 'NOT_STARTED', 0)
|
|
ON DUPLICATE KEY UPDATE
|
|
state = 'NOT_STARTED',
|
|
progress_percent = 0,
|
|
params = NULL,
|
|
message = NULL,
|
|
started_at = NULL,
|
|
completed_at = NULL
|
|
""",
|
|
(project_id, stage_no, stage_key),
|
|
)
|
|
|
|
|
|
async def start_stage(
|
|
cursor: aiomysql.DictCursor,
|
|
project_id: str,
|
|
stage_no: int,
|
|
params: Dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""단계를 시작하여 IN_PROGRESS 상태로 만들고, 이후 단계들을 STALE로 전환한다."""
|
|
now = datetime.utcnow()
|
|
params_json = json.dumps(params, ensure_ascii=False) if params is not None else None
|
|
|
|
# 해당 단계 시작
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE project_workflow_stages
|
|
SET state = 'IN_PROGRESS',
|
|
progress_percent = 0,
|
|
params = COALESCE(%s, params),
|
|
message = NULL,
|
|
started_at = %s,
|
|
completed_at = NULL
|
|
WHERE project_id = %s AND stage_no = %s
|
|
""",
|
|
(params_json, now, project_id, stage_no),
|
|
)
|
|
|
|
# 역방향 재작업 무효화 (stale 전파): 이후 단계 중 COMPLETE인 것들을 STALE로 변경
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE project_workflow_stages
|
|
SET state = 'STALE'
|
|
WHERE project_id = %s AND stage_no > %s AND state = 'COMPLETE'
|
|
""",
|
|
(project_id, stage_no),
|
|
)
|
|
|
|
# projects.status 캐시 업데이트 (호환성 유지)
|
|
status_str = f"WF{stage_no}_ANALYZING" if stage_no > 0 else "FILE_INPUT_PROCESSING"
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE projects
|
|
SET status = %s, updated_at = %s
|
|
WHERE id = %s
|
|
""",
|
|
(status_str, now, project_id),
|
|
)
|
|
|
|
|
|
async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None:
|
|
"""단계를 완료 상태로 전환한다."""
|
|
now = datetime.utcnow()
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE project_workflow_stages
|
|
SET state = 'COMPLETE',
|
|
progress_percent = 100,
|
|
completed_at = %s
|
|
WHERE project_id = %s AND stage_no = %s
|
|
""",
|
|
(now, project_id, stage_no),
|
|
)
|
|
|
|
# projects.status 캐시 업데이트 (호환성 유지)
|
|
status_str = f"WF{stage_no}_COMPLETE" if stage_no > 0 else "FILE_UPLOADED"
|
|
if stage_no == 6:
|
|
status_str = "DONE"
|
|
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE projects
|
|
SET status = %s, updated_at = %s
|
|
WHERE id = %s
|
|
""",
|
|
(status_str, now, project_id),
|
|
)
|
|
|
|
|
|
async def update_stage_progress(
|
|
cursor: aiomysql.DictCursor, project_id: str, stage_no: int, progress: int
|
|
) -> None:
|
|
"""단계 진행률을 업데이트한다."""
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE project_workflow_stages
|
|
SET progress_percent = %s
|
|
WHERE project_id = %s AND stage_no = %s
|
|
""",
|
|
(progress, project_id, stage_no),
|
|
)
|
|
|
|
|
|
async def fail_stage(
|
|
cursor: aiomysql.DictCursor, project_id: str, stage_no: int, message: str
|
|
) -> None:
|
|
"""단계를 실패 상태로 전환한다.
|
|
|
|
`message`는 컬럼 길이에 맞춰 잘라 넣는다. 예외 문자열을 그대로 넣었다가 실패 기록
|
|
자체가 `DataError (1406, "Data too long for column 'message'")`로 죽어, 단계가 FAILED로
|
|
가지 못하고 화면이 영영 "분석 중"에 머문 사고가 있었다(2026-08-08 E2E 점검).
|
|
원문 전체는 호출부가 이미 로그에 남긴다.
|
|
"""
|
|
now = datetime.utcnow()
|
|
trimmed = (message or "").strip()
|
|
if len(trimmed) > _STAGE_MESSAGE_MAX_LENGTH:
|
|
trimmed = trimmed[: _STAGE_MESSAGE_MAX_LENGTH - 1] + "…"
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE project_workflow_stages
|
|
SET state = 'FAILED',
|
|
message = %s
|
|
WHERE project_id = %s AND stage_no = %s
|
|
""",
|
|
(trimmed, project_id, stage_no),
|
|
)
|
|
|
|
# projects.status 캐시 업데이트
|
|
status_str = f"WF{stage_no}_FAILED" if stage_no > 0 else "FILE_INPUT_FAILED"
|
|
await cursor.execute(
|
|
"""
|
|
UPDATE projects
|
|
SET status = %s, updated_at = %s
|
|
WHERE id = %s
|
|
""",
|
|
(status_str, now, project_id),
|
|
)
|
|
|
|
|
|
async def get_workflow_state(cursor: aiomysql.DictCursor, project_id: str) -> Dict[str, Any]:
|
|
"""프로젝트의 모든 단계 상태를 조회하여 요약 및 배열로 반환한다."""
|
|
await cursor.execute(
|
|
"""
|
|
SELECT stage_no, stage_key, state, progress_percent, params, message,
|
|
DATE_FORMAT(started_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS started_at,
|
|
DATE_FORMAT(completed_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS completed_at
|
|
FROM project_workflow_stages
|
|
WHERE project_id = %s
|
|
ORDER BY stage_no ASC
|
|
""",
|
|
(project_id,),
|
|
)
|
|
rows = await cursor.fetchall()
|
|
|
|
if not rows:
|
|
return {"project_id": project_id, "current_stage": 0, "stages": []}
|
|
|
|
stages_list = []
|
|
for r in rows:
|
|
params_val = None
|
|
if r.get("params"):
|
|
try:
|
|
params_val = (
|
|
json.loads(r["params"]) if isinstance(r["params"], str) else r["params"]
|
|
)
|
|
except Exception:
|
|
params_val = r["params"]
|
|
|
|
stages_list.append(
|
|
{
|
|
"stage_no": r["stage_no"],
|
|
"stage_key": r["stage_key"],
|
|
"state": r["state"],
|
|
"progress_percent": r["progress_percent"],
|
|
"params": params_val,
|
|
"message": r["message"],
|
|
"started_at": r["started_at"],
|
|
"completed_at": r["completed_at"],
|
|
}
|
|
)
|
|
|
|
current_stage = 0
|
|
for stage in stages_list:
|
|
if stage["stage_no"] == 0:
|
|
continue
|
|
prev_stage = stages_list[stage["stage_no"] - 1]
|
|
if prev_stage["state"] == "COMPLETE":
|
|
current_stage = stage["stage_no"]
|
|
else:
|
|
break
|
|
|
|
return {"project_id": project_id, "current_stage": current_stage, "stages": stages_list}
|