From ebbf7bc063cd786820e70a60340342188f1821af Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 19:00:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=84=AA=E5=8C=96=20PPT=20=E7=94=A2=E7=B7=9A?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E7=B8=BD=E8=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + routes/admin_observability_routes.py | 145 ++++++++++++++++++++ services/ppt_auto_generation_service.py | 45 +++++-- templates/admin/ppt_audit_history.html | 53 ++++++-- tests/test_admin_observability_routes.py | 4 + tests/test_ppt_auto_generation_service.py | 7 + web/static/css/page-ppt-audit-history.css | 154 ++++++++++++++++++++++ 7 files changed, 392 insertions(+), 17 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 14ed8fe..df5197f 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.194 重整 `/observability/ppt_audit_history` 產線資訊階層:新增 Pipeline Health、五段式流程階段、排程/覆蓋/DB/預覽/視覺 QA 狀態摘要,讓「已產生」改為可判斷的目標產生、其他版本、待排程補齊等狀態。 - V10.192 補 `/observability/ppt_audit_history` 最近可預覽簡報 workbench:最新 4 份 PPT 直接在控制台下方提供線上預覽與下載,降低使用者找檔案的操作成本;完整檔案清單仍保留在下方表格。 - V10.190 補 `/observability/ppt_audit_file/` 站內線上預覽:PPTX 由 LibreOffice 轉 PDF 快取後以 iframe 預覽,保留原始 PPTX 下載;Dockerfile 加 `libreoffice-impress`,compose 預設啟用 `PPT_VISION_ENABLED=true`,PPT 產線頁新增視覺 QA 停用原因與更精簡的控制台式排版。 - V10.188 補強 `/observability/ppt_audit_history` PPT 視覺 QA 產線:頁面明確呈現每日、每週、每月、每季、每半年、每年定期產出節奏,並顯示 `ppt_generation_runs` DB 寫入紀錄;保留自動補齊缺漏與資料庫/檔案覆蓋狀態。 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 407b82a..fa95b9f 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -2229,6 +2229,142 @@ def budget_update(budget_id: int): # /observability/ppt_audit_history — Phase 29 PPT 視覺審核歷史 # ───────────────────────────────────────────────────────────────────────────── +def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_runs, vision_status): + """Compose page-level PPT pipeline health so the template stays declarative.""" + files = files or [] + auto_generation = auto_generation or {} + audit_stats = audit_stats or {} + generation_runs = generation_runs or [] + vision_status = vision_status or {} + + def _as_int(value): + try: + return int(value or 0) + except Exception: + return 0 + + def _as_float(value): + try: + return float(value or 0) + except Exception: + return 0.0 + + ready_count = _as_int(auto_generation.get('ready_count')) + total_count = _as_int(auto_generation.get('total')) + missing_count = _as_int(auto_generation.get('missing_count')) + coverage_pct = round((ready_count / total_count * 100), 1) if total_count else 0 + valid_preview_count = sum(1 for item in files if item.get('file_exists') and item.get('is_valid_ppt')) + broken_file_count = sum(1 for item in files if item.get('file_exists') and not item.get('is_valid_ppt')) + db_backed_count = sum(1 for item in files if item.get('source') in ('database', 'both')) + run_error_count = sum(1 for item in generation_runs if item.get('status') == 'error') + run_ready_count = sum(1 for item in generation_runs if item.get('status') == 'ready') + audit_total = _as_int(audit_stats.get('total')) + audit_issues = _as_int(audit_stats.get('total_issues')) + pass_rate = round(_as_float(audit_stats.get('pass_rate')), 1) + latest_run = generation_runs[0] if generation_runs else {} + latest_file = files[0] if files else {} + + if not vision_status.get('ready'): + health_status = 'partial' + health_title = '視覺審核環境待確認' + health_message = 'PPT 可產出與預覽,但 minicpm-v / LibreOffice 的 runtime 狀態仍需維持就緒。' + elif run_error_count or broken_file_count: + health_status = 'error' + health_title = '產線有異常待處理' + health_message = f'目前有 {run_error_count} 筆產出失敗、{broken_file_count} 份檔案不可預覽,應先處理最近錯誤。' + elif missing_count > 0: + health_status = 'partial' + health_title = '定義簡報尚未全數補齊' + health_message = f'本月已完成 {ready_count}/{total_count} 類,仍缺 {missing_count} 類,可等排程或手動補齊。' + elif audit_total and pass_rate < 80: + health_status = 'partial' + health_title = '審核通過率偏低' + health_message = f'本月視覺 QA 通過率 {pass_rate:.1f}%,需優先檢查失敗熱點與 RAG 修法建議。' + elif total_count: + health_status = 'ready' + health_title = '產線覆蓋完整' + health_message = '定義簡報、DB 紀錄、線上預覽與視覺 QA 都已具備可追蹤入口。' + else: + health_status = 'planned' + health_title = '產線等待資料' + health_message = '目前尚未讀到定義簡報覆蓋資料,頁面會保留安全空狀態。' + + if audit_total: + qa_value = f'{pass_rate:.0f}%' + qa_meta = f'{audit_total} 筆審核 / {audit_issues} 個問題' + qa_status = 'ready' if pass_rate >= 80 and audit_issues == 0 else 'partial' + else: + qa_value = '待審核' + qa_meta = '每日報表才進入 minicpm-v 視覺 QA' + qa_status = 'planned' + + stages = [ + { + 'key': 'schedule', + 'icon': 'calendar-check', + 'label': '排程節奏', + 'value': '6 條', + 'meta': '每日 / 每週 / 每月 / 每季 / 半年 / 年度', + 'detail': auto_generation.get('cadence_summary') or '等待排程設定', + 'status': 'ready' if auto_generation.get('enabled') else 'partial', + }, + { + 'key': 'coverage', + 'icon': 'diagram-project', + 'label': '定義覆蓋', + 'value': f'{ready_count}/{total_count}' if total_count else '—', + 'meta': f'{coverage_pct:.1f}% 完成', + 'detail': f'缺漏 {missing_count} 類' if missing_count else '當期目標完整', + 'status': 'ready' if total_count and missing_count == 0 else 'partial', + }, + { + 'key': 'database', + 'icon': 'database', + 'label': 'DB 寫入', + 'value': f'{len(generation_runs)} 筆', + 'meta': f'{run_ready_count} 成功 / {run_error_count} 失敗', + 'detail': latest_run.get('started_at') or '尚無本月寫入紀錄', + 'status': 'error' if run_error_count else ('ready' if generation_runs else 'planned'), + }, + { + 'key': 'preview', + 'icon': 'desktop', + 'label': '線上預覽', + 'value': f'{valid_preview_count} 份', + 'meta': f'{db_backed_count} 份含 DB 紀錄', + 'detail': latest_file.get('name') or '尚無可預覽檔案', + 'status': 'error' if broken_file_count else ('ready' if valid_preview_count else 'planned'), + }, + { + 'key': 'qa', + 'icon': 'eye', + 'label': '視覺 QA', + 'value': qa_value, + 'meta': qa_meta, + 'detail': 'minicpm-v + RAG 修法 + AiderHeal', + 'status': qa_status, + }, + ] + + return { + 'status': health_status, + 'title': health_title, + 'message': health_message, + 'ready_count': ready_count, + 'total_count': total_count, + 'missing_count': missing_count, + 'coverage_pct': coverage_pct, + 'valid_preview_count': valid_preview_count, + 'broken_file_count': broken_file_count, + 'db_backed_count': db_backed_count, + 'run_error_count': run_error_count, + 'pass_rate': pass_rate, + 'audit_total': audit_total, + 'latest_run': latest_run, + 'latest_file': latest_file, + 'stages': stages, + } + @admin_observability_bp.route('/ppt_audit_history') @login_required def ppt_audit_history(): @@ -2586,6 +2722,14 @@ def ppt_audit_history(): except Exception: logger.debug("PPT auto-generation coverage unavailable", exc_info=True) + pipeline_view = _build_ppt_pipeline_view( + files=files, + auto_generation=auto_generation, + audit_stats=audit_30d_stats, + generation_runs=generation_runs, + vision_status=vision_status, + ) + return render_template( 'admin/ppt_audit_history.html', active_page='obs_ppt_audit', @@ -2607,6 +2751,7 @@ def ppt_audit_history(): auto_generation_items=auto_generation.get('items', []), auto_generation_missing_report_types=auto_generation.get('missing_report_types', []), generation_runs=generation_runs, + pipeline_view=pipeline_view, error=error, ) diff --git a/services/ppt_auto_generation_service.py b/services/ppt_auto_generation_service.py index f95beb7..b149ccc 100644 --- a/services/ppt_auto_generation_service.py +++ b/services/ppt_auto_generation_service.py @@ -289,10 +289,16 @@ def get_schedule_cadence_status(coverage_items: Sequence[dict] | None = None) -> total = len(report_types) if total and not missing_types: status = "ready" + status_label = "當期完整" + status_hint = "排程定義內的簡報都已找到目標版本。" elif ready_count > 0: status = "partial" + status_label = f"已完成 {ready_count}/{total}" + status_hint = "仍有部分簡報尚未補齊,需等排程或手動回補。" else: status = "missing" + status_label = "待產出" + status_hint = "當期尚未看到符合定義的簡報。" cadences.append({ "key": key, "label": meta["label"], @@ -305,9 +311,13 @@ def get_schedule_cadence_status(coverage_items: Sequence[dict] | None = None) -> "ready_count": ready_count, "missing_count": len(missing_types), "missing_report_types": missing_types, + "missing_report_labels": [REPORT_TYPE_LABELS.get(report_type, report_type) for report_type in missing_types], "total": total, "progress_pct": round((ready_count / total * 100), 1) if total else 0, "status": status, + "status_label": status_label, + "status_hint": status_hint, + "coverage_text": f"{ready_count}/{total}", }) return cadences @@ -581,25 +591,42 @@ def get_defined_report_coverage( latest_generated_at[report_type] = datetime.fromtimestamp(mtime) latest_file_path[report_type] = str(path) - items = [ - { + items = [] + for job in jobs: + count = counts[job.report_type] + exact_count = exact_counts[job.report_type] + if exact_count > 0: + status = "ready" + status_label = "目標已產生" + status_hint = "檔案參數與本期定義相符。" + elif count > 0: + status = "partial" + status_label = "有其他版本" + status_hint = "找到同類簡報,但參數或目標期別不完全相符。" + else: + status = "missing" + status_label = "待排程補齊" + status_hint = "尚未找到符合定義的檔案或 DB 紀錄。" + items.append({ "key": job.report_type, "label": job.label, "target_label": job.target_label, - "count": counts[job.report_type], - "exact_count": exact_counts[job.report_type], - "ready": exact_counts[job.report_type] > 0, - "has_other_versions": counts[job.report_type] > 0 and exact_counts[job.report_type] == 0, + "count": count, + "exact_count": exact_count, + "ready": status == "ready", + "has_other_versions": status == "partial", + "status": status, + "status_label": status_label, + "status_hint": status_hint, "sources": sorted(sources[job.report_type]), "latest_generated_at": ( latest_generated_at[job.report_type].strftime("%Y-%m-%d %H:%M") if latest_generated_at[job.report_type] else None ), "latest_file_path": latest_file_path[job.report_type], + "latest_file_name": os.path.basename(latest_file_path[job.report_type]) if latest_file_path[job.report_type] else "", "expected_params": job.expected_params, - } - for job in jobs - ] + }) missing = [item for item in items if not item["ready"]] cadences = get_schedule_cadence_status(items) return { diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index ce6826b..188519b 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -128,6 +128,31 @@ {% endif %} +
+
+
Pipeline Health
+

{{ pipeline_view.title }}

+

{{ pipeline_view.message }}

+
+ {{ pipeline_view.ready_count }}/{{ pipeline_view.total_count }} 定義覆蓋 + {{ pipeline_view.valid_preview_count }} 份可預覽 + {{ pipeline_view.audit_total }} 筆視覺 QA +
+
+
+ {% for stage in pipeline_view.stages %} +
+
+
+
{{ stage.label }}
+ {{ stage.value }} + {{ stage.meta }} +

{{ stage.detail }}

+
+
+ {% endfor %} +
+
- {{ cadence.gate }} - {% if cadence.missing_count > 0 %}缺 {{ cadence.missing_count }} 類{% else %}完整{% endif %} + {{ cadence.status_label }} + {{ cadence.coverage_text }}
+

{{ cadence.description }}

+ {% if cadence.missing_count > 0 %} + 待補:{{ cadence.missing_report_labels[:3]|join('、') }}{% if cadence.missing_report_labels|length > 3 %} 等 {{ cadence.missing_report_labels|length }} 類{% endif %} + {% else %} + {{ cadence.status_hint }} + {% endif %} {% endfor %} @@ -181,12 +212,13 @@
{% for item in auto_generation_items %}
-
- {{ item.label }} - {{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %} -
- - {% if item.ready %}已產生{% elif item.has_other_versions %}其他版本{% else %}待補齊{% endif %} +
+ {{ item.label }} + {{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %} + {{ item.status_hint }} +
+ + {{ item.status_label }}
{% endfor %} @@ -217,6 +249,11 @@ {{ run.target_label or '最新資料' }} {{ run.status_label }} {{ run.started_at }}{% if run.finished_at %} → {{ run.finished_at }}{% endif %} + {% if run.file_name %} + + 預覽 + + {% endif %}
{% endfor %} diff --git a/tests/test_admin_observability_routes.py b/tests/test_admin_observability_routes.py index 7caa317..685407b 100644 --- a/tests/test_admin_observability_routes.py +++ b/tests/test_admin_observability_routes.py @@ -215,6 +215,10 @@ def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch): assert text in html assert 'ppt_generation_runs' in html assert '每日日報' in html + assert 'Pipeline Health' in html + assert '排程節奏' in html + assert 'DB 寫入' in html + assert '線上預覽' in html def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, tmp_path): diff --git a/tests/test_ppt_auto_generation_service.py b/tests/test_ppt_auto_generation_service.py index 4672ac0..96640d6 100644 --- a/tests/test_ppt_auto_generation_service.py +++ b/tests/test_ppt_auto_generation_service.py @@ -100,8 +100,12 @@ def test_coverage_marks_ready_from_database(monkeypatch): by_key = {item["key"]: item for item in result["items"]} assert by_key["daily"]["ready"] is True + assert by_key["daily"]["status"] == "ready" + assert by_key["daily"]["status_label"] == "目標已產生" assert by_key["monthly"]["ready"] is True assert by_key["weekly"]["ready"] is False + assert by_key["weekly"]["status"] == "missing" + assert by_key["weekly"]["status_label"] == "待排程補齊" assert result["missing_count"] == 1 @@ -145,6 +149,9 @@ def test_schedule_cadence_status_exposes_all_periodic_contracts(): assert by_key["weekly"]["report_types"] == ["weekly", "market_intel"] assert by_key["weekly"]["ready_count"] == 1 assert by_key["weekly"]["missing_report_types"] == ["weekly"] + assert by_key["weekly"]["missing_report_labels"] == ["週報"] + assert by_key["weekly"]["status_label"] == "已完成 1/2" + assert by_key["weekly"]["coverage_text"] == "1/2" assert "TTM 滾動 12 月" in by_key["monthly"]["report_labels"] diff --git a/web/static/css/page-ppt-audit-history.css b/web/static/css/page-ppt-audit-history.css index f6178ba..45f1e64 100644 --- a/web/static/css/page-ppt-audit-history.css +++ b/web/static/css/page-ppt-audit-history.css @@ -182,6 +182,142 @@ overflow-wrap: anywhere; } +.ppt-health-board { + display: grid; + grid-template-columns: minmax(280px, 0.36fr) minmax(0, 0.64fr); + gap: var(--momo-space-4, 16px); + margin-top: var(--momo-space-4, 16px); +} + +.ppt-health-main, +.ppt-stage-card { + border: 1px solid var(--obs-line); + border-radius: var(--momo-radius-lg, 8px); + background: + radial-gradient(circle, rgba(45, 40, 32, 0.08) 1px, transparent 1.2px), + rgba(255, 255, 255, 0.58); + background-size: 10px 10px, auto; + box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08)); +} + +.ppt-health-main { + display: grid; + align-content: space-between; + min-height: 228px; + padding: var(--momo-space-4, 16px); + border-left: 5px solid var(--obs-blue); +} + +.ppt-health-main.is-ready { + border-left-color: var(--obs-green); +} + +.ppt-health-main.is-partial, +.ppt-health-main.is-planned { + border-left-color: var(--obs-amber); +} + +.ppt-health-main.is-error { + border-left-color: var(--obs-red); +} + +.ppt-health-main h2 { + margin: var(--momo-space-2, 8px) 0; + color: var(--obs-ink); + font-size: var(--momo-text-title, 18px); + font-weight: var(--momo-font-weight-black, 800); + letter-spacing: 0; +} + +.ppt-health-main p { + margin: 0; + color: var(--obs-muted); + line-height: 1.6; +} + +.ppt-health-facts { + display: grid; + gap: var(--momo-space-2, 8px); + margin-top: var(--momo-space-4, 16px); +} + +.ppt-health-facts span { + display: flex; + justify-content: space-between; + gap: var(--momo-space-3, 12px); + padding-top: var(--momo-space-2, 8px); + border-top: 1px solid var(--obs-line); + color: var(--obs-muted); + font-size: var(--momo-text-caption, 12px); +} + +.ppt-health-facts strong { + color: var(--obs-ink); + font-size: var(--momo-text-body, 14px); +} + +.ppt-stage-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--momo-space-3, 12px); +} + +.ppt-stage-card { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: var(--momo-space-2, 8px); + min-height: 228px; + padding: var(--momo-space-3, 12px); + border-top: 4px solid var(--obs-blue); +} + +.ppt-stage-card.is-ready { + border-top-color: var(--obs-green); +} + +.ppt-stage-card.is-partial, +.ppt-stage-card.is-planned { + border-top-color: var(--obs-amber); +} + +.ppt-stage-card.is-error { + border-top-color: var(--obs-red); +} + +.ppt-stage-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid rgba(201, 100, 66, 0.24); + border-radius: var(--momo-radius-md, 6px); + color: var(--obs-accent); + background: rgba(255, 248, 239, 0.78); +} + +.ppt-stage-card strong { + display: block; + margin: var(--momo-space-1, 4px) 0; + color: var(--obs-ink); + font-size: var(--momo-text-headline, 22px); + line-height: 1.1; +} + +.ppt-stage-card small, +.ppt-stage-card p { + display: block; + margin: 0; + color: var(--obs-muted); + font-size: var(--momo-text-caption, 12px); + line-height: 1.5; +} + +.ppt-stage-card p { + margin-top: var(--momo-space-2, 8px); + overflow-wrap: anywhere; +} + .ppt-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(330px, 0.8fr); @@ -316,6 +452,12 @@ font-size: var(--momo-text-caption, 12px); } +.ppt-cadence-tile > small { + display: block; + margin-top: var(--momo-space-2, 8px); + line-height: 1.5; +} + .ppt-coverage-score { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -415,6 +557,12 @@ border-color: rgba(72, 108, 149, 0.35); } +.ppt-run-status.is-missing, +.ppt-run-status.is-planned { + color: var(--obs-amber); + border-color: rgba(184, 121, 47, 0.35); +} + .ppt-run-status.is-error, .ppt-run-status.is-missing_file { color: var(--obs-red); @@ -494,10 +642,15 @@ } .ppt-diagnostic-strip, + .ppt-health-board, .ppt-pipeline-layout { grid-template-columns: 1fr; } + .ppt-stage-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .ppt-deck-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -516,6 +669,7 @@ .ppt-auto-grid, .ppt-mini-grid, .ppt-deck-rail, + .ppt-stage-grid, .ppt-coverage-score, .ppt-coverage-list { grid-template-columns: 1fr;