diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 8e74297..6c08d9f 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -114,6 +114,7 @@ - Phase 53 manual sample candidate queue approval:新增 `/api/market_intel/manual_sample_review/candidate_queue_approval` POST 與 UI 送審 gate 按鈕,將 queue draft row preview 對齊既有 `market_alert_review_queue` 契約,檢查必填欄位、寫入 flags、備份與人工批准 gate;不建立 approval record、不寫 review queue、不開 DB transaction、不掛 scheduler;版本同步至 V10.225。 - V10.226 補 PPT 視覺 QA runtime checklist:`/observability/ppt_audit_history` 在視覺模型未就緒時顯示 Feature Flag、LibreOffice、Vision Model 三段檢查與下一步操作,避免只看到「停用」而不知道卡在哪。 - Phase 54 manual sample candidate queue transaction:新增 `/api/market_intel/manual_sample_review/candidate_queue_transaction` POST 與 UI transaction preview 按鈕,將 queue row preview 轉成 `market_alert_review_queue` idempotent insert statement、payload hash 與 rollback plan;不開 DB connection、不開 transaction、不 commit、不建立 approval record;版本同步至 V10.227。 + - V10.228 補 PPT 視覺 QA 背景狀態卡:新增 `/observability/ppt_audit/vision_status` 與頁面 Vision QA 狀態卡,讓立即視覺 QA 排入後可看 queued/running/completed/error 與最近審核摘要,不必刷新猜測。 - Schema smoke:`tests/test_market_intel_skeleton.py` 檢查 `Base.metadata` 內含 ADR-035 八張 `market_*` tables。 - Desktop UI QA:本機只註冊 `market_intel_bp` 的 Flask harness 載入 `/market_intel`,確認 Phase 15、候選預覽、writer preview、安全 flags、點陣暖紙視覺正常,console error 0。 - API QA:`/api/market_intel/schema_smoke` 通過 7 張表與 `market_platforms` 必要欄位檢查;`/api/market_intel/platform_seed_writer_plan` 回傳 4 筆 dry-run upsert preview,`writes_executed=false`,四平台皆 `blocked_dry_run_only`。 diff --git a/config.py b/config.py index 6d5b90a..65764d9 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.227" +SYSTEM_VERSION = "V10.228" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index f7fb2b1..4310ce1 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -1973,6 +1973,18 @@ def ppt_audit_run_vision(): return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 +@admin_observability_bp.route('/ppt_audit/vision_status') +@login_required +def ppt_audit_vision_status(): + """Expose current/last background PPT vision audit status for the admin UI.""" + try: + from services.ppt_vision_service import get_ppt_vision_audit_status + + return jsonify(get_ppt_vision_audit_status()) + except Exception as e: + return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 + + def _resolve_ppt_report_path(filename: str): """在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。""" import os @@ -3137,11 +3149,20 @@ def ppt_audit_history(): 'next_actions': ['確認 ppt_vision_service import 與 runtime 設定後重新整理此頁。'], } try: - from services.ppt_vision_service import get_ppt_vision_runtime_status + from services.ppt_vision_service import get_ppt_vision_audit_status, get_ppt_vision_runtime_status vision_status = get_ppt_vision_runtime_status() vision_enabled = bool(vision_status.get('enabled')) + vision_audit_status = get_ppt_vision_audit_status() except Exception: vision_enabled = False + vision_audit_status = { + 'ok': False, + 'running': False, + 'status': 'unknown', + 'status_label': '讀取失敗', + 'message': '最近視覺 QA 狀態讀取失敗。', + 'last_run': None, + } # Phase 47 K-6: 月報表統計 + top failure files audit_30d_stats = {} @@ -3321,6 +3342,7 @@ def ppt_audit_history(): top_failure_files=top_failure_files, vision_enabled=vision_enabled, vision_status=vision_status, + vision_audit_status=vision_audit_status, auto_generation=auto_generation, auto_generation_items=auto_generation_items, auto_generation_missing_report_types=auto_generation.get('missing_report_types', []), diff --git a/services/ppt_vision_service.py b/services/ppt_vision_service.py index 84ab201..4d364ea 100644 --- a/services/ppt_vision_service.py +++ b/services/ppt_vision_service.py @@ -106,6 +106,75 @@ def get_ppt_vision_runtime_status() -> Dict[str, Any]: } +def _public_audit_run_payload(run: Dict[str, Any] | None) -> Dict[str, Any] | None: + if not run: + return None + summary = run.get('summary') or {} + audited_files = [] + for item in summary.get('audited_files') or []: + path = item.get('path') or '' + audited_files.append({ + 'filename': os.path.basename(path) if path else '', + 'slides_checked': int(item.get('slides_checked') or 0), + 'issues': int(item.get('issues') or 0), + 'error': item.get('error') or '', + }) + errors = [str(error)[:160] for error in (summary.get('errors') or [])[:3]] + payload = { + 'ok': bool(run.get('ok')), + 'status': run.get('status') or 'unknown', + 'queued_at': run.get('queued_at') or '', + 'started_at': run.get('started_at') or '', + 'finished_at': run.get('finished_at') or '', + 'filenames': [ + os.path.basename(str(name)) + for name in (run.get('filenames') or []) + if str(name).lower().endswith('.pptx') + ], + 'max_files': run.get('max_files'), + 'error': run.get('error') or '', + 'summary': { + 'audited_count': len(audited_files), + 'total_issues': int(summary.get('total_issues') or 0), + 'error_count': len(summary.get('errors') or []), + 'errors': errors, + 'files': audited_files[:5], + }, + } + return payload + + +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) + if running: + status = 'running' + status_label = '執行中' + message = '視覺 QA 正在背景審核簡報。' + elif last_run: + status = last_run.get('status') or 'unknown' + status_label = { + 'queued': '已排入', + 'running': '執行中', + 'completed': '已完成', + 'error': '錯誤', + }.get(status, status) + message = '最近一次視覺 QA 已完成。' if status == 'completed' else '最近一次視覺 QA 狀態可查。' + else: + status = 'idle' + status_label = '待命' + message = '尚未有背景視覺 QA 執行紀錄。' + return { + 'ok': True, + 'running': running, + 'status': status, + 'status_label': status_label, + 'message': message, + 'last_run': last_run, + } + + # ───────────────────────────────────────────────────────────────────────────── # 結果容器 # ───────────────────────────────────────────────────────────────────────────── @@ -557,7 +626,7 @@ def start_ppt_vision_audit_background( 'ok': True, 'status': 'already_running', 'message': 'PPT vision audit is already running.', - 'last_run': _LAST_AUDIT_RUN, + 'last_run': _public_audit_run_payload(_LAST_AUDIT_RUN), } clean_filenames = [ @@ -565,11 +634,27 @@ 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 = { + '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 = { + '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, @@ -580,16 +665,22 @@ def start_ppt_vision_audit_background( _LAST_AUDIT_RUN = { 'ok': True, 'status': 'completed', + 'queued_at': queued_at, 'started_at': started_at, 'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'), + 'filenames': clean_filenames, + 'max_files': max_files, 'summary': summary, } except Exception as exc: _LAST_AUDIT_RUN = { 'ok': False, 'status': 'error', + 'queued_at': queued_at, 'started_at': started_at, 'finished_at': time.strftime('%Y-%m-%d %H:%M:%S'), + '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) @@ -641,6 +732,7 @@ __all__ = [ 'ppt_vision_service', 'is_ppt_vision_enabled', 'get_ppt_vision_runtime_status', + 'get_ppt_vision_audit_status', 'PPT_VISION_SYSTEM_PROMPT', 'audit_recent_ppts', 'start_ppt_vision_audit_background', diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index 8e608a1..1ed1950 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -96,6 +96,33 @@ +
+
+ + Vision QA + +
+ {{ vision_audit_status.status_label }} + {{ vision_audit_status.message }} +
+
+
+ {% if vision_audit_status.last_run %} +
+ {{ vision_audit_status.last_run.finished_at or vision_audit_status.last_run.started_at or vision_audit_status.last_run.queued_at }} + {{ vision_audit_status.last_run.summary.audited_count }} 份 / {{ vision_audit_status.last_run.summary.total_issues }} 問題 + {% if vision_audit_status.last_run.summary.error_count %}錯誤 {{ vision_audit_status.last_run.summary.error_count }}{% else %}無 runtime error{% endif %} +
+ {% else %} +
+ 尚無紀錄 + 待命 + 按下「立即視覺 QA」後會在這裡顯示背景任務狀態。 +
+ {% endif %} +
+
+
diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index 88a62c9..673daad 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -696,6 +696,93 @@ def test_ppt_audit_run_vision_queues_background_audit(client, monkeypatch): assert captured['max_files'] == 1 +def test_ppt_vision_audit_status_sanitizes_last_run(monkeypatch): + """背景視覺 QA 狀態只回檔名與摘要,不把 reports_dir 絕對路徑曝露到頁面。""" + from services import ppt_vision_service as svc + + monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', { + 'ok': True, + 'status': 'completed', + 'queued_at': '2026-05-19 12:00:00', + 'started_at': '2026-05-19 12:00:01', + 'finished_at': '2026-05-19 12:00:05', + 'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'], + 'max_files': 1, + 'summary': { + 'audited_files': [{ + 'path': '/app/data/reports/ocbot_daily_20260518.pptx', + 'slides_checked': 1, + 'issues': 0, + 'error': None, + }], + 'total_issues': 0, + 'errors': [], + }, + }) + + status = svc.get_ppt_vision_audit_status() + + assert status['ok'] is True + assert status['status'] == 'completed' + assert status['status_label'] == '已完成' + assert status['last_run']['filenames'] == ['ocbot_daily_20260518.pptx'] + assert status['last_run']['summary']['audited_count'] == 1 + assert status['last_run']['summary']['files'][0]['filename'] == 'ocbot_daily_20260518.pptx' + assert '/app/data/reports' not in str(status) + + +def test_ppt_audit_vision_status_route_returns_json(client, monkeypatch): + """頁面輪詢用 status endpoint 要能回最近一次背景視覺 QA 狀態。""" + from services import ppt_vision_service as svc + + monkeypatch.setattr(svc, 'get_ppt_vision_audit_status', lambda: { + 'ok': True, + 'running': False, + 'status': 'completed', + 'status_label': '已完成', + 'message': '最近一次視覺 QA 已完成。', + 'last_run': { + 'summary': {'audited_count': 2, 'total_issues': 1, 'error_count': 0, 'errors': [], 'files': []}, + }, + }) + + r = client.get('/observability/ppt_audit/vision_status') + data = r.get_json() + + assert r.status_code == 200 + assert data['ok'] is True + assert data['status'] == 'completed' + assert data['last_run']['summary']['audited_count'] == 2 + + +def test_ppt_audit_history_renders_last_vision_status(client, monkeypatch): + """PPT 產線頁要在按下立即 QA 前後都看得到背景狀態卡。""" + from services import ppt_vision_service as svc + + monkeypatch.setattr(svc, 'get_ppt_vision_audit_status', lambda: { + 'ok': True, + 'running': False, + 'status': 'completed', + 'status_label': '已完成', + 'message': '最近一次視覺 QA 已完成。', + 'last_run': { + 'queued_at': '2026-05-19 12:00:00', + 'started_at': '2026-05-19 12:00:01', + 'finished_at': '2026-05-19 12:00:05', + 'summary': {'audited_count': 2, 'total_issues': 1, 'error_count': 0, 'errors': [], 'files': []}, + }, + }) + + r = client.get('/observability/ppt_audit_history') + html = r.get_data(as_text=True) + + assert r.status_code == 200 + assert 'data-ppt-vision-status' in html + assert 'data-ppt-vision-status-title' in html + assert '最近一次視覺 QA 已完成。' in html + assert '2 份 / 1 問題' in html + + def test_ppt_audit_trigger_aider_heal_accepts_issue_summary(client, monkeypatch): """視覺 QA failed 常只有 issues_found;AiderHeal 應可吃診斷摘要派工。""" from routes import admin_observability_routes as mod diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css index ef3422f..4edff65 100644 --- a/web/static/css/page-ppt-audit-history.css +++ b/web/static/css/page-ppt-audit-history.css @@ -205,6 +205,83 @@ font-size: var(--momo-text-caption, 12px); } +.ppt-vision-status { + display: grid; + grid-template-columns: minmax(260px, 0.36fr) minmax(0, 0.64fr); + gap: var(--momo-space-3, 12px); + align-items: stretch; + margin-top: var(--momo-space-3, 12px); + padding: var(--momo-space-3, 12px); + border: 1px solid rgba(72, 108, 149, 0.24); + border-radius: var(--momo-radius-lg, 8px); + background: + radial-gradient(circle, rgba(72, 108, 149, 0.08) 1px, transparent 1.2px), + rgba(255, 255, 255, 0.58); + background-size: 10px 10px, auto; +} + +.ppt-vision-status.is-running, +.ppt-vision-status.is-queued { + border-color: rgba(184, 121, 47, 0.32); +} + +.ppt-vision-status.is-error { + border-color: rgba(196, 84, 75, 0.32); +} + +.ppt-vision-status-main { + display: flex; + align-items: center; + gap: var(--momo-space-3, 12px); +} + +.ppt-vision-status-main strong, +.ppt-vision-status-main small { + display: block; +} + +.ppt-vision-status-main strong { + color: var(--obs-ink); + font-size: var(--momo-text-body, 14px); + font-weight: var(--momo-font-weight-black, 800); +} + +.ppt-vision-status-main small, +.ppt-vision-job span, +.ppt-vision-job small { + color: var(--obs-muted); + font-size: var(--momo-text-caption, 12px); +} + +.ppt-vision-status-list { + display: grid; + gap: var(--momo-space-2, 8px); +} + +.ppt-vision-job { + display: grid; + grid-template-columns: minmax(130px, 0.28fr) minmax(150px, 0.28fr) minmax(0, 0.44fr); + gap: var(--momo-space-2, 8px); + align-items: center; + min-height: 42px; + padding: var(--momo-space-2, 8px); + border: 1px solid rgba(86, 64, 48, 0.12); + border-radius: var(--momo-radius-md, 6px); + background: rgba(255, 255, 255, 0.54); +} + +.ppt-vision-job strong, +.ppt-vision-job span, +.ppt-vision-job small { + min-width: 0; + overflow-wrap: anywhere; +} + +.ppt-vision-job strong { + color: var(--obs-ink); + font-size: var(--momo-text-body, 14px); +} + .ppt-command { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -1151,6 +1228,7 @@ body.ppt-preview-open { .ppt-diagnostic-strip, .ppt-aider-status, + .ppt-vision-status, .ppt-health-board, .ppt-pipeline-layout { grid-template-columns: 1fr; @@ -1262,7 +1340,8 @@ body.ppt-preview-open { min-height: calc(100vh - 168px); } - .ppt-aider-job { + .ppt-aider-job, + .ppt-vision-job { grid-template-columns: 1fr; } } diff --git a/web/static/js/observability-charts.js b/web/static/js/observability-charts.js index e54dba2..a45f5cc 100644 --- a/web/static/js/observability-charts.js +++ b/web/static/js/observability-charts.js @@ -659,6 +659,57 @@ } }; + function renderPptVisionStatus(payload) { + const panel = document.querySelector('[data-ppt-vision-status]'); + if (!panel) return; + const status = (payload && payload.status) || 'unknown'; + panel.className = panel.className.replace(/\bis-[a-z_]+\b/g, '').trim(); + panel.classList.add(`is-${status}`); + const title = panel.querySelector('[data-ppt-vision-status-title]'); + const meta = panel.querySelector('[data-ppt-vision-status-meta]'); + const list = panel.querySelector('[data-ppt-vision-status-list]'); + if (title) title.textContent = (payload && payload.status_label) || '狀態未知'; + if (meta) meta.textContent = (payload && payload.message) || '最近視覺 QA 狀態無法讀取。'; + if (!list) return; + const lastRun = payload && payload.last_run; + if (!lastRun) { + list.innerHTML = ` +
+ 尚無紀錄 + 待命 + 按下「立即視覺 QA」後會在這裡顯示背景任務狀態。 +
+ `; + return; + } + const summary = lastRun.summary || {}; + const timestamp = lastRun.finished_at || lastRun.started_at || lastRun.queued_at || ''; + const issueText = `${Number(summary.audited_count || 0)} 份 / ${Number(summary.total_issues || 0)} 問題`; + const errorText = Number(summary.error_count || 0) > 0 + ? `錯誤 ${Number(summary.error_count || 0)}` + : '無 runtime error'; + list.innerHTML = ` +
+ ${escapeHtml(timestamp)} + ${escapeHtml(issueText)} + ${escapeHtml(errorText)} +
+ `; + } + + window.refreshPptVisionStatus = async function refreshPptVisionStatus() { + try { + const response = await fetch('/observability/ppt_audit/vision_status', { + headers: { 'Accept': 'application/json' } + }); + const data = await response.json(); + if (!response.ok || !data.ok) return; + renderPptVisionStatus(data); + } catch (error) { + console.warn('ppt_vision_status_failed', error); + } + }; + function initPptAutoGeneration() { const panel = document.querySelector('[data-ppt-auto-generation]'); const pageStatus = document.querySelector('[data-ppt-auto-status]'); @@ -671,6 +722,9 @@ const previewLoading = previewModal ? previewModal.querySelector('[data-ppt-preview-loading]') : null; const previewFrameWrap = previewModal ? previewModal.querySelector('.ppt-preview-frame-wrap') : null; const visionAuditFilenames = readJson('obs-ppt-audit-filenames', []); + if (document.querySelector('[data-ppt-vision-status]') && window.refreshPptVisionStatus) { + window.refreshPptVisionStatus(); + } function closePreviewModal() { if (!previewModal) return; @@ -772,6 +826,9 @@ ? '視覺 QA 已在執行中,請稍後重新整理查看資料庫結果。' : `視覺 QA 已排入 ${filenames.length} 份簡報;審核結果會寫入 ppt_audit_results。`; } + if (window.refreshPptVisionStatus) { + window.refreshPptVisionStatus(); + } } catch (error) { console.warn('ppt_vision_audit_queue_failed', error); buttons.forEach(item => {