diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index ae06e92..48dd9d1 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -110,6 +110,7 @@ - V10.220 補 Phase 50 UI POST CSRF header:`manual_sample_review/evaluate` 保持 CSRF 保護,頁面 fetch 送出 `X-CSRFToken`,不豁免安全檢查。 - Phase 51 manual sample candidate handoff:新增 `/api/market_intel/manual_sample_review/candidate_handoff` POST 與 UI handoff 按鈕,將已通過審核的 sample result 轉成只讀候選活動 preview payload;不保存 handoff、不建立 review queue、不寫 market_*、不允許候選導入、不掛 scheduler;版本同步至 V10.222。 - Phase 52 manual sample candidate queue draft:新增 `services/market_intel/manual_sample_candidate_queue.py`、`/api/market_intel/manual_sample_review/candidate_queue_draft` POST 與 UI queue 草案按鈕,將 handoff 候選轉成只讀人工審核 queue draft;不建立正式 queue、不保存草案、不寫 market_*、不自動核准候選、不掛 scheduler;版本同步至 V10.223。 + - V10.224 補 PPT 報表覆蓋矩陣:`/observability/ppt_audit_history` 將每個定義簡報同列串起 DB 寫入、線上預覽、視覺 QA 與交付狀態,並提供預覽、預熱、重跑操作,避免只顯示「目標已產生」。 - 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 a797151..6d065b4 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.223" +SYSTEM_VERSION = "V10.224" 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 5e8e5ed..3b2f98a 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -2670,6 +2670,130 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run 'action_lanes': action_lanes, } + +def _enrich_ppt_coverage_items(auto_generation_items, files, generation_runs, audit_records): + """Join coverage rows with file, DB run, preview and QA state for the UI matrix.""" + files = files or [] + generation_runs = generation_runs or [] + audit_records = audit_records or [] + + file_by_name = {item.get('name'): item for item in files if item.get('name')} + latest_file_by_type = {} + for item in files: + report_type = item.get('report_type') + if report_type and report_type not in latest_file_by_type: + latest_file_by_type[report_type] = item + + latest_run_by_type = {} + for item in generation_runs: + report_type = item.get('report_type') + if report_type and report_type not in latest_run_by_type: + latest_run_by_type[report_type] = item + + latest_audit_by_file = {} + for item in audit_records: + filename = item.get('pptx_filename') + if filename and filename not in latest_audit_by_file: + latest_audit_by_file[filename] = item + + enriched = [] + for raw_item in auto_generation_items or []: + item = dict(raw_item) + report_type = item.get('key') or '' + latest_run = latest_run_by_type.get(report_type, {}) + candidate_file = latest_file_by_type.get(report_type, {}) + file_name = ( + item.get('latest_file_name') + or latest_run.get('file_name') + or candidate_file.get('name') + or '' + ) + file_item = file_by_name.get(file_name) or candidate_file or {} + audit = latest_audit_by_file.get(file_name, {}) + sources = set(item.get('sources') or []) + if file_item.get('source'): + sources.add(file_item.get('source')) + + file_exists = bool(file_item.get('file_exists') or ('filesystem' in sources and file_name)) + valid_ppt = bool(file_exists and (file_item.get('is_valid_ppt') is not False)) + db_backed = bool( + latest_run + or 'database' in sources + or file_item.get('source') in ('database', 'both') + ) + preview_cached = bool(valid_ppt and file_item.get('preview_cache_ready')) + audit_status = audit.get('audit_status') or '' + run_status = latest_run.get('status') or '' + + if latest_run and run_status == 'error': + db_status, db_label = 'error', 'DB 失敗' + elif db_backed: + db_status, db_label = 'ready', 'DB 已寫入' + else: + db_status, db_label = 'planned', '待 DB' + + if valid_ppt and preview_cached: + preview_status, preview_label = 'ready', 'PDF 快取' + elif valid_ppt: + preview_status, preview_label = 'partial', '可預覽' + elif file_name: + preview_status, preview_label = 'error', '不可預覽' + else: + preview_status, preview_label = 'planned', '待產檔' + + if audit_status == 'passed': + qa_status, qa_label = 'ready', 'QA 通過' + elif audit_status == 'failed': + qa_status, qa_label = 'error', 'QA 有問題' + elif audit_status == 'error': + qa_status, qa_label = 'error', 'QA 錯誤' + elif audit_status == 'skipped': + qa_status, qa_label = 'partial', 'QA 跳過' + elif valid_ppt: + qa_status, qa_label = 'planned', '待 QA' + else: + qa_status, qa_label = 'planned', '待產檔' + + if not item.get('ready'): + delivery_status, delivery_label = 'missing', '待產出' + elif latest_run and run_status == 'error': + delivery_status, delivery_label = 'error', '產出失敗' + elif file_name and not valid_ppt: + delivery_status, delivery_label = 'error', '檔案異常' + elif qa_status == 'error': + delivery_status, delivery_label = 'error', '需修復' + elif valid_ppt and db_backed and audit_status == 'passed': + delivery_status, delivery_label = 'ready', '可交付' + elif valid_ppt: + delivery_status, delivery_label = 'partial', '待驗收' + else: + delivery_status, delivery_label = item.get('status') or 'planned', item.get('status_label') or '待確認' + + item.update({ + 'latest_file_name': file_name, + 'latest_file_mtime': file_item.get('mtime') or item.get('latest_generated_at') or '', + 'latest_file_size_kb': file_item.get('size_kb'), + 'file_exists': file_exists, + 'is_valid_ppt': valid_ppt, + 'preview_cache_ready': preview_cached, + 'db_status': db_status, + 'db_label': db_label, + 'preview_status': preview_status, + 'preview_label': preview_label, + 'qa_status': qa_status, + 'qa_label': qa_label, + 'delivery_status': delivery_status, + 'delivery_label': delivery_label, + 'audit_summary': audit.get('issue_summary') or audit.get('error_msg') or '', + 'can_preview': valid_ppt and bool(file_name), + 'can_prewarm': valid_ppt and bool(file_name) and not preview_cached, + 'can_regenerate': bool(report_type), + }) + enriched.append(item) + + return enriched + + @admin_observability_bp.route('/ppt_audit_history') @login_required def ppt_audit_history(): @@ -3164,6 +3288,12 @@ def ppt_audit_history(): if item.get('file_exists') and item.get('is_valid_ppt') and item.get('name') ][:10] aider_heal_active_jobs = _list_ppt_aider_heal_active_jobs() + auto_generation_items = _enrich_ppt_coverage_items( + auto_generation.get('items', []), + files, + generation_runs, + audit_records, + ) return render_template( 'admin/ppt_audit_history.html', @@ -3183,7 +3313,7 @@ def ppt_audit_history(): vision_enabled=vision_enabled, vision_status=vision_status, auto_generation=auto_generation, - auto_generation_items=auto_generation.get('items', []), + auto_generation_items=auto_generation_items, auto_generation_missing_report_types=auto_generation.get('missing_report_types', []), generation_runs=generation_runs, pipeline_view=pipeline_view, diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index a125e5a..94f4d3d 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -359,16 +359,56 @@
- {% for item in auto_generation_items %} -
-
- {{ item.label }} - {{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %} - {{ item.status_hint }} +
+ 報表覆蓋矩陣 + DB / 預覽 / 視覺 QA / 交付
- - {{ item.status_label }} - + {% for item in auto_generation_items %} +
+
+ {{ item.label }} + {{ item.target_label or '最新資料' }}{% if item.latest_file_mtime %} · {{ item.latest_file_mtime }}{% endif %} + {% if item.latest_file_name %} + {{ item.latest_file_name }} + {% else %} + {{ item.status_hint }} + {% endif %} + {% if item.audit_summary %} + {{ item.audit_summary[:90] }} + {% endif %} +
+
+ {{ item.db_label }} + {{ item.preview_label }} + {{ item.qa_label }} + {{ item.delivery_label }} +
+
+ {% if item.can_preview %} + + 預覽 + + {% endif %} + {% if item.can_prewarm %} + + {% endif %} + {% if item.can_regenerate and item.delivery_status in ['missing', 'error', 'partial'] %} + + {% endif %} +
{% endfor %}
diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index 16c417d..492e8a2 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -287,6 +287,101 @@ def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, t assert '1 份 PDF 快取' in html +def test_ppt_audit_history_coverage_matrix_joins_db_preview_qa(client, monkeypatch, tmp_path): + """定義簡報覆蓋區要同列呈現 DB、預覽、QA 與交付狀態。""" + import zipfile + from datetime import datetime + from unittest.mock import MagicMock + from routes import admin_observability_routes as mod + from services import ppt_auto_generation_service as svc + + reports_dir = tmp_path / 'reports' + reports_dir.mkdir() + pptx = reports_dir / 'ocbot_daily_20260517.pptx' + with zipfile.ZipFile(pptx, 'w') as zf: + zf.writestr('[Content_Types].xml', '') + + monkeypatch.setenv('REPORTS_DIR', str(reports_dir)) + monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache')) + + coverage_items = [{ + 'key': 'daily', + 'label': '每日日報', + 'target_label': '2026/05/17', + 'ready': True, + 'status': 'ready', + 'status_label': '目標已產生', + 'status_hint': '檔案參數與本期定義相符。', + 'sources': ['database', 'filesystem'], + 'latest_generated_at': '2026-05-17 20:31', + 'latest_file_path': str(pptx), + 'latest_file_name': pptx.name, + }] + monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: { + 'enabled': True, + 'items': coverage_items, + 'missing_report_types': [], + 'missing_count': 0, + 'ready_count': 1, + 'total': 1, + 'last_run': None, + 'can_auto_start': False, + 'cadences': svc.get_schedule_cadence_status(coverage_items), + 'cadence_summary': '每日 20:30', + }) + monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [{ + 'schedule_kind': 'daily', + 'schedule_label': '每日', + 'report_type': 'daily', + 'report_label': '每日日報', + 'target_label': '2026/05/17', + 'status': 'ready', + 'status_label': '已產生', + 'file_name': pptx.name, + 'file_size_kb': 1024, + 'error_msg': '', + 'started_at': '2026-05-17 20:30', + 'finished_at': '2026-05-17 20:31', + }]) + + class FakeSession: + def execute(self, statement, _params=None): + sql = str(statement) + result = MagicMock() + if 'FROM ppt_reports' in sql: + result.fetchall.return_value = [] + elif 'SELECT audited_at, pptx_filename' in sql: + result.fetchall.return_value = [ + (datetime(2026, 5, 17, 22, 5), pptx.name, 'passed', 0, 0.95, 900, '', []) + ] + elif 'COALESCE(AVG(confidence)' in sql: + result.fetchone.return_value = (1, 1, 0, 0, 0, 0.95, 0) + elif 'GROUP BY pptx_filename' in sql: + result.fetchall.return_value = [] + else: + result.fetchall.return_value = [] + result.fetchone.return_value = (0,) + return result + + def close(self): + return None + + monkeypatch.setattr(mod, 'get_session', lambda: FakeSession()) + + r = client.get('/observability/ppt_audit_history?month=2026-05') + html = r.get_data(as_text=True) + + assert r.status_code == 200 + assert '報表覆蓋矩陣' in html + assert 'DB / 預覽 / 視覺 QA / 交付' in html + assert 'DB 已寫入' in html + assert '可預覽' in html + assert 'QA 通過' in html + assert '可交付' in html + assert 'data-ppt-open-preview' in html + assert 'ocbot_daily_20260517.pptx' in html + + def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path): """PPTX view 入口應回站內預覽頁,而不是把 PPTX 直接丟給瀏覽器。""" import zipfile diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css index af553d0..e490d27 100644 --- a/web/static/css/page-ppt-audit-history.css +++ b/web/static/css/page-ppt-audit-history.css @@ -870,17 +870,50 @@ body.ppt-preview-open { .ppt-coverage-list { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: 1fr; gap: var(--momo-space-2, 8px); } -.ppt-coverage-row { +.ppt-coverage-list-head { display: flex; align-items: center; justify-content: space-between; gap: var(--momo-space-3, 12px); - min-height: 58px; - padding: var(--momo-space-2, 8px) var(--momo-space-3, 12px); + padding: var(--momo-space-1, 4px) var(--momo-space-1, 4px) 0; +} + +.ppt-coverage-list-head small { + color: var(--obs-muted); + font-size: var(--momo-text-caption, 12px); +} + +.ppt-coverage-row { + display: grid; + grid-template-columns: minmax(180px, 0.9fr) minmax(300px, 1.1fr) auto; + align-items: center; + gap: var(--momo-space-3, 12px); + min-height: 72px; + padding: var(--momo-space-3, 12px); +} + +.ppt-coverage-row.is-ready { + border-left: 3px solid rgba(76, 137, 91, 0.72); +} + +.ppt-coverage-row.is-partial, +.ppt-coverage-row.is-planned, +.ppt-coverage-row.is-missing { + border-left: 3px solid rgba(184, 121, 47, 0.72); +} + +.ppt-coverage-row.is-error, +.ppt-coverage-row.is-missing_file { + border-left: 3px solid rgba(196, 84, 75, 0.72); +} + +.ppt-coverage-main, +.ppt-coverage-actions { + min-width: 0; } .ppt-coverage-row strong { @@ -893,6 +926,39 @@ body.ppt-preview-open { display: block; color: var(--obs-muted); font-size: var(--momo-text-caption, 12px); + line-height: 1.45; +} + +.ppt-coverage-row code { + color: var(--obs-ink); + font-family: var(--momo-font-mono, "IBM Plex Mono", monospace); + font-size: var(--momo-text-caption, 12px); + overflow-wrap: anywhere; +} + +.ppt-coverage-signals { + display: grid; + grid-template-columns: repeat(4, minmax(72px, 1fr)); + gap: var(--momo-space-1, 4px); + min-width: 0; +} + +.ppt-coverage-signals .ppt-run-status { + justify-content: center; + width: 100%; +} + +.ppt-coverage-actions { + display: flex; + justify-content: flex-end; + gap: var(--momo-space-2, 8px); + flex-wrap: wrap; +} + +.ppt-coverage-actions .btn { + display: inline-flex; + align-items: center; + min-height: 32px; } .ppt-run-log { @@ -1056,6 +1122,15 @@ body.ppt-preview-open { .ppt-grid { grid-template-columns: 1fr; } + + .ppt-coverage-row { + grid-template-columns: minmax(180px, 0.8fr) minmax(280px, 1fr); + } + + .ppt-coverage-actions { + grid-column: 1 / -1; + justify-content: flex-start; + } } @media (max-width: 760px) { @@ -1079,12 +1154,30 @@ body.ppt-preview-open { .ppt-issue-metrics, .ppt-preview-head, .ppt-run-log-head, - .ppt-run-row, - .ppt-coverage-row { + .ppt-run-row { flex-direction: column; align-items: flex-start; } + .ppt-coverage-list-head, + .ppt-coverage-row { + align-items: stretch; + } + + .ppt-coverage-list-head { + flex-direction: column; + gap: 0; + } + + .ppt-coverage-row { + grid-template-columns: 1fr; + min-height: 0; + } + + .ppt-coverage-signals { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .ppt-preview-modal { padding: 0; }