優化 PPT 產線健康總覽
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-18 19:00:18 +08:00
parent f5b9f1bd74
commit ebbf7bc063
7 changed files with 392 additions and 17 deletions

View File

@@ -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/<filename>` 站內線上預覽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 寫入紀錄;保留自動補齊缺漏與資料庫/檔案覆蓋狀態。

View File

@@ -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,
)

View File

@@ -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 {

View File

@@ -128,6 +128,31 @@
</div>
</section>
{% endif %}
<section class="ppt-health-board" aria-label="PPT 產線健康總覽">
<div class="ppt-health-main is-{{ pipeline_view.status }}">
<div class="ppt-label">Pipeline Health</div>
<h2>{{ pipeline_view.title }}</h2>
<p>{{ pipeline_view.message }}</p>
<div class="ppt-health-facts">
<span><strong>{{ pipeline_view.ready_count }}/{{ pipeline_view.total_count }}</strong> 定義覆蓋</span>
<span><strong>{{ pipeline_view.valid_preview_count }}</strong> 份可預覽</span>
<span><strong>{{ pipeline_view.audit_total }}</strong> 筆視覺 QA</span>
</div>
</div>
<div class="ppt-stage-grid">
{% for stage in pipeline_view.stages %}
<article class="ppt-stage-card is-{{ stage.status }}">
<div class="ppt-stage-icon"><i class="fas fa-{{ stage.icon }}" aria-hidden="true"></i></div>
<div>
<div class="ppt-label">{{ stage.label }}</div>
<strong>{{ stage.value }}</strong>
<small>{{ stage.meta }}</small>
<p>{{ stage.detail }}</p>
</div>
</article>
{% endfor %}
</div>
</section>
<section class="ppt-panel mt-3"
data-ppt-auto-generation
data-auto-start="{{ 'true' if auto_generation.can_auto_start else 'false' }}"
@@ -154,9 +179,15 @@
<span style="width: {{ cadence.progress_pct }}%"></span>
</div>
<div class="ppt-cadence-meta">
<span>{{ cadence.gate }}</span>
{% if cadence.missing_count > 0 %}<span>{{ cadence.missing_count }} 類</span>{% else %}<span>完整</span>{% endif %}
<span>{{ cadence.status_label }}</span>
<span>{{ cadence.coverage_text }}</span>
</div>
<p class="ppt-cadence-gate">{{ cadence.description }}</p>
{% if cadence.missing_count > 0 %}
<small class="text-muted">待補:{{ cadence.missing_report_labels[:3]|join('、') }}{% if cadence.missing_report_labels|length > 3 %} 等 {{ cadence.missing_report_labels|length }} 類{% endif %}</small>
{% else %}
<small class="status-good">{{ cadence.status_hint }}</small>
{% endif %}
</article>
{% endfor %}
</div>
@@ -181,12 +212,13 @@
<div class="ppt-coverage-list" aria-label="定義簡報覆蓋明細">
{% for item in auto_generation_items %}
<div class="ppt-coverage-row">
<div>
<strong>{{ item.label }}</strong>
<small>{{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %}</small>
</div>
<span class="ppt-run-status {% if item.ready %}is-ready{% elif item.has_other_versions %}is-partial{% else %}is-missing_file{% endif %}">
{% if item.ready %}已產生{% elif item.has_other_versions %}其他版本{% else %}待補齊{% endif %}
<div>
<strong>{{ item.label }}</strong>
<small>{{ item.target_label or '最新資料' }}{% if item.latest_generated_at %} · {{ item.latest_generated_at }}{% endif %}</small>
<small>{{ item.status_hint }}</small>
</div>
<span class="ppt-run-status is-{{ item.status }}">
{{ item.status_label }}
</span>
</div>
{% endfor %}
@@ -217,6 +249,11 @@
<span class="ppt-run-target">{{ run.target_label or '最新資料' }}</span>
<span class="ppt-run-status is-{{ run.status }}">{{ run.status_label }}</span>
<small class="text-muted">{{ run.started_at }}{% if run.finished_at %} → {{ run.finished_at }}{% endif %}</small>
{% if run.file_name %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin_observability.ppt_audit_file', filename=run.file_name) }}" target="_blank" rel="noopener">
<i class="fas fa-eye me-1"></i>預覽
</a>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -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):

View File

@@ -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"]

View File

@@ -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;