修正 PPT 視覺 QA 多 worker 狀態
All checks were successful
CD Pipeline / deploy (push) Successful in 1m2s

This commit is contained in:
OoO
2026-05-19 09:15:31 +08:00
parent 583e318295
commit 21cdbdb75c
4 changed files with 164 additions and 21 deletions

View File

@@ -20,6 +20,7 @@ from __future__ import annotations
import os
import time
import base64
import json
import logging
import shutil
import threading
@@ -39,6 +40,7 @@ PPT_VISION_IMAGE_MAX_EDGE = int(os.getenv('PPT_VISION_IMAGE_MAX_EDGE', '1280'))
PPT_VISION_IMAGE_QUALITY = int(os.getenv('PPT_VISION_IMAGE_QUALITY', '82'))
_AUDIT_LOCK = threading.Lock()
_LAST_AUDIT_RUN: Dict[str, Any] | None = None
_ACTIVE_AUDIT_TTL_SECONDS = int(os.getenv('PPT_VISION_ACTIVE_TTL_SECONDS', '7200'))
def is_ppt_vision_enabled() -> bool:
@@ -106,6 +108,80 @@ def get_ppt_vision_runtime_status() -> Dict[str, Any]:
}
def _audit_state_path() -> str:
return os.getenv(
'PPT_VISION_STATE_PATH',
os.path.join(os.getenv('DATA_DIR', os.path.join(os.getcwd(), 'data')), 'ppt_vision_audit_status.json'),
)
def _now_label() -> str:
return time.strftime('%Y-%m-%d %H:%M:%S')
def _read_persisted_audit_run() -> Dict[str, Any] | None:
path = _audit_state_path()
try:
if not os.path.isfile(path):
return None
with open(path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
return payload if isinstance(payload, dict) else None
except Exception:
logger.debug("[PPTVision] read audit state failed", exc_info=True)
return None
def _write_persisted_audit_run(run: Dict[str, Any]) -> None:
path = _audit_state_path()
directory = os.path.dirname(path)
try:
os.makedirs(directory, exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, 'w', encoding='utf-8') as handle:
json.dump(run, handle, ensure_ascii=False)
os.replace(tmp_path, path)
except Exception:
logger.debug("[PPTVision] write audit state failed", exc_info=True)
def _record_audit_run(run: Dict[str, Any]) -> Dict[str, Any]:
global _LAST_AUDIT_RUN
payload = dict(run)
payload['updated_at'] = payload.get('updated_at') or _now_label()
_LAST_AUDIT_RUN = payload
_write_persisted_audit_run(payload)
return payload
def _load_last_audit_run() -> Dict[str, Any] | None:
persisted = _read_persisted_audit_run()
if not _LAST_AUDIT_RUN:
return persisted
if not persisted:
return _LAST_AUDIT_RUN
if str(persisted.get('updated_at') or '') >= str(_LAST_AUDIT_RUN.get('updated_at') or ''):
return persisted
return _LAST_AUDIT_RUN
def _timestamp_age_seconds(value: str | None) -> float | None:
if not value:
return None
try:
parsed = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
return max(0.0, (datetime.now() - parsed).total_seconds())
except Exception:
return None
def _is_recent_active_audit_run(run: Dict[str, Any] | None) -> bool:
if not run or run.get('status') not in {'queued', 'running'}:
return False
age = _timestamp_age_seconds(run.get('updated_at') or run.get('started_at') or run.get('queued_at'))
return age is None or age < _ACTIVE_AUDIT_TTL_SECONDS
def _public_audit_run_payload(run: Dict[str, Any] | None) -> Dict[str, Any] | None:
if not run:
return None
@@ -126,6 +202,7 @@ def _public_audit_run_payload(run: Dict[str, Any] | None) -> Dict[str, Any] | No
'queued_at': run.get('queued_at') or '',
'started_at': run.get('started_at') or '',
'finished_at': run.get('finished_at') or '',
'updated_at': run.get('updated_at') or '',
'filenames': [
os.path.basename(str(name))
for name in (run.get('filenames') or [])
@@ -146,8 +223,9 @@ def _public_audit_run_payload(run: Dict[str, Any] | None) -> Dict[str, Any] | No
def get_ppt_vision_audit_status() -> Dict[str, Any]:
"""Return the current/last background visual QA run without touching DB."""
running = _AUDIT_LOCK.locked()
last_run = _public_audit_run_payload(_LAST_AUDIT_RUN)
raw_run = _load_last_audit_run()
running = _AUDIT_LOCK.locked() or _is_recent_active_audit_run(raw_run)
last_run = _public_audit_run_payload(raw_run)
if running:
status = 'running'
status_label = '執行中'
@@ -619,14 +697,13 @@ def start_ppt_vision_audit_background(
filenames: Sequence[str] | None = None,
) -> Dict[str, Any]:
"""Queue a non-blocking PPT vision audit run for the admin UI."""
global _LAST_AUDIT_RUN
if _AUDIT_LOCK.locked():
current_run = _load_last_audit_run()
if _AUDIT_LOCK.locked() or _is_recent_active_audit_run(current_run):
return {
'ok': True,
'status': 'already_running',
'message': 'PPT vision audit is already running.',
'last_run': _public_audit_run_payload(_LAST_AUDIT_RUN),
'last_run': _public_audit_run_payload(current_run),
}
clean_filenames = [
@@ -634,27 +711,26 @@ def start_ppt_vision_audit_background(
for name in (filenames or [])
if str(name).lower().endswith('.pptx')
]
queued_at = time.strftime('%Y-%m-%d %H:%M:%S')
_LAST_AUDIT_RUN = {
queued_at = _now_label()
_record_audit_run({
'ok': True,
'status': 'queued',
'queued_at': queued_at,
'filenames': clean_filenames,
'max_files': max_files,
}
})
def _run():
global _LAST_AUDIT_RUN
with _AUDIT_LOCK:
started_at = time.strftime('%Y-%m-%d %H:%M:%S')
_LAST_AUDIT_RUN = {
started_at = _now_label()
_record_audit_run({
'ok': True,
'status': 'running',
'queued_at': queued_at,
'started_at': started_at,
'filenames': clean_filenames,
'max_files': max_files,
}
})
try:
summary = audit_recent_ppts(
reports_dir=reports_dir,
@@ -662,27 +738,27 @@ def start_ppt_vision_audit_background(
max_files=max_files,
filenames=clean_filenames or None,
)
_LAST_AUDIT_RUN = {
_record_audit_run({
'ok': True,
'status': 'completed',
'queued_at': queued_at,
'started_at': started_at,
'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'finished_at': _now_label(),
'filenames': clean_filenames,
'max_files': max_files,
'summary': summary,
}
})
except Exception as exc:
_LAST_AUDIT_RUN = {
_record_audit_run({
'ok': False,
'status': 'error',
'queued_at': queued_at,
'started_at': started_at,
'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'finished_at': _now_label(),
'filenames': clean_filenames,
'max_files': max_files,
'error': f'{type(exc).__name__}: {str(exc)[:200]}',
}
})
logger.error("[PPTVision] background audit failed: %s", exc, exc_info=True)
thread = threading.Thread(target=_run, name='ppt-vision-audit', daemon=True)