強化 PPT 視覺問題追蹤
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-19 00:16:21 +08:00
parent 774f1b4b45
commit dddafc579b
6 changed files with 210 additions and 9 deletions

View File

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.215 強化 `/observability/ppt_audit_history` 視覺問題追蹤:將 `issues_found` 拆成投影片、問題類型、問題文字與回放入口,新增「視覺問題追蹤」面板,讓問題簡報能直接定位與預覽。
- V10.213 補 `/observability/ppt_audit_history` 視覺 QA 診斷摘要:審核歷史與 Action Queue 直接顯示 `ppt_audit_results.issues_found` 的投影片問題,讓「有問題」可追查,不再只剩問題數或空白錯誤欄。
- V10.212 修正 PPT 視覺 QA 的 Ollama 三主機 fallback當 Primary/Secondary request timeout 超過 unhealthy TTL 時,第三輪仍強制打 111 final fallbackPPT 截圖送模型前轉輕量 JPEG、縮小輸出 token降低單檔審核耗時。
- V10.211 補 `/observability/ppt_audit_history` 全類型視覺 QA審核歷史不再限 daily頁面新增「立即視覺 QA」非阻塞補跑結果寫入 `ppt_audit_results`;模型失敗時也保留 slide error避免產線狀態只剩空白。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.214"
SYSTEM_VERSION = "V10.215"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2818,6 +2818,48 @@ def ppt_audit_history():
return ''.join(snippets)
return ''.join(snippets)
def _load_ppt_issues(raw_issues):
if not raw_issues:
return []
try:
import json as _json
issues_payload = _json.loads(raw_issues) if isinstance(raw_issues, str) else raw_issues
except Exception:
return []
return issues_payload if isinstance(issues_payload, list) else []
def _classify_ppt_issue(issue_text: str):
text = issue_text or ''
if any(k in text for k in ['圖表', '切掉', '截斷', '超出', '溢出']):
return '版面越界', 'error'
if any(k in text for k in ['空白', '未填', '缺少', '無資料']):
return '內容缺漏', 'warn'
if any(k in text for k in ['低對比', '顏色', '字體', '字型', '閱讀']):
return '可讀性', 'warn'
return '視覺問題', 'warn'
def _extract_ppt_issue_items(raw_issues, *, pptx_filename: str, audited_at: str):
issue_items = []
for slide_item in _load_ppt_issues(raw_issues):
if not isinstance(slide_item, dict):
continue
slide = slide_item.get('slide')
slide_label = f"S{slide}" if slide else 'S?'
for raw_issue in slide_item.get('issues') or []:
issue_text = str(raw_issue).strip()
if not issue_text:
continue
category, status = _classify_ppt_issue(issue_text)
issue_items.append({
'pptx_filename': pptx_filename,
'audited_at': audited_at,
'slide_label': slide_label,
'category': category,
'status': status,
'text': issue_text,
})
return issue_items
# Phase 38+:讀指定月份 / 指定簡報類型 audit 歷史
try:
session = get_session()
@@ -2836,24 +2878,45 @@ def ppt_audit_history():
"""),
audit_params,
).fetchall()
audit_records = [
{
'audited_at': r[0].strftime('%Y-%m-%d %H:%M'),
'pptx_filename': r[1],
audit_records = []
for r in audit_rows:
audited_at = r[0].strftime('%Y-%m-%d %H:%M')
pptx_filename = r[1]
raw_issues = r[7]
audit_records.append({
'audited_at': audited_at,
'pptx_filename': pptx_filename,
'audit_status': r[2],
'issues_count': int(r[3] or 0),
'confidence': float(r[4] or 0),
'duration_ms': int(r[5] or 0),
'error_msg': r[6],
'issue_summary': _summarize_ppt_issues(r[7]),
}
for r in audit_rows
]
'issue_summary': _summarize_ppt_issues(raw_issues),
'issue_items': _extract_ppt_issue_items(
raw_issues,
pptx_filename=pptx_filename,
audited_at=audited_at,
),
})
finally:
session.close()
except Exception:
logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True)
issue_items = [
issue
for record in audit_records
for issue in record.get('issue_items', [])
]
issue_files = {issue.get('pptx_filename') for issue in issue_items if issue.get('pptx_filename')}
issue_digest = {
'total': len(issue_items),
'files': len(issue_files),
'error_count': sum(1 for issue in issue_items if issue.get('status') == 'error'),
'warn_count': sum(1 for issue in issue_items if issue.get('status') == 'warn'),
'latest_audit': issue_items[0].get('audited_at') if issue_items else '',
}
# PPT vision 啟用狀態
vision_status = {'enabled': False, 'ready': False, 'blockers': ['視覺狀態讀取失敗']}
try:
@@ -3040,6 +3103,8 @@ def ppt_audit_history():
generation_runs=generation_runs,
pipeline_view=pipeline_view,
vision_audit_filenames=vision_audit_filenames,
issue_items=issue_items,
issue_digest=issue_digest,
error=error,
)

View File

@@ -235,6 +235,49 @@
{% endfor %}
</div>
</section>
{% if issue_items %}
<section class="ppt-issue-board" aria-label="視覺問題追蹤">
<div class="ppt-workbench-head">
<div>
<div class="ppt-label">Vision Findings</div>
<h2 class="ppt-panel-title">視覺問題追蹤</h2>
</div>
<div class="ppt-issue-metrics">
<span><strong>{{ issue_digest.total }}</strong> 問題</span>
<span><strong>{{ issue_digest.files }}</strong> 檔案</span>
<span><strong>{{ issue_digest.error_count }}</strong> 高優先</span>
<span><strong>{{ issue_digest.latest_audit or '—' }}</strong> 最近審核</span>
</div>
</div>
<div class="ppt-issue-grid">
{% for issue in issue_items[:8] %}
<article class="ppt-issue-card is-{{ issue.status }}">
<div class="ppt-issue-top">
<span class="ppt-run-status is-{{ 'error' if issue.status == 'error' else 'planned' }}">{{ issue.slide_label }}</span>
<span class="ppt-label">{{ issue.category }}</span>
</div>
<strong><code>{{ issue.pptx_filename }}</code></strong>
<p>{{ issue.text }}</p>
<div class="ppt-action-foot">
<small class="text-muted">{{ issue.audited_at }}</small>
<a class="btn btn-outline-primary btn-sm"
href="{{ url_for('admin_observability.ppt_audit_file', filename=issue.pptx_filename) }}"
target="_blank"
rel="noopener"
data-ppt-open-preview
data-ppt-filename="{{ issue.pptx_filename }}"
data-ppt-preview-title="問題回放 · {{ issue.pptx_filename }}"
data-ppt-preview-pdf="{{ url_for('admin_observability.ppt_audit_file', filename=issue.pptx_filename, action='pdf') }}"
data-ppt-preview-page="{{ url_for('admin_observability.ppt_audit_file', filename=issue.pptx_filename) }}"
data-ppt-download-url="{{ url_for('admin_observability.ppt_audit_file', filename=issue.pptx_filename, action='download') }}">
<i class="fas fa-eye me-1"></i>回放
</a>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
<section class="ppt-panel mt-3"
data-ppt-auto-generation
data-auto-start="{{ 'true' if auto_generation.can_auto_start else 'false' }}"

View File

@@ -380,6 +380,9 @@ def test_ppt_audit_history_audit_rows_include_inline_replay(client, monkeypatch,
assert 'data-ppt-open-preview' in html
assert 'ocbot_daily_20260517.pptx?action=pdf' in html
assert '圖表被切掉' in html
assert '視覺問題追蹤' in html
assert '版面越界' in html
assert '問題回放 · ocbot_daily_20260517.pptx' in html
assert '回放' in html

View File

@@ -451,6 +451,89 @@ body.ppt-preview-open {
box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08));
}
.ppt-issue-board {
margin-top: var(--momo-space-4, 16px);
padding: var(--momo-space-4, 16px);
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, 250, 242, 0.58);
background-size: 10px 10px, auto;
box-shadow: var(--momo-shadow-md, 0 16px 38px rgba(70, 46, 28, 0.08));
}
.ppt-issue-metrics {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--momo-space-2, 8px);
flex-wrap: wrap;
}
.ppt-issue-metrics span {
display: inline-flex;
align-items: center;
gap: var(--momo-space-1, 4px);
min-height: 30px;
padding: 0 var(--momo-space-2, 8px);
border: 1px solid rgba(86, 64, 48, 0.14);
border-radius: 999px;
color: var(--obs-muted);
background: rgba(255, 255, 255, 0.58);
font-size: var(--momo-text-caption, 12px);
}
.ppt-issue-metrics strong {
color: var(--obs-ink);
font-family: var(--momo-font-mono);
}
.ppt-issue-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--momo-space-3, 12px);
}
.ppt-issue-card {
display: grid;
align-content: space-between;
min-height: 196px;
padding: var(--momo-space-3, 12px);
border: 1px solid var(--obs-line);
border-top: 4px solid var(--obs-amber);
border-radius: var(--momo-radius-lg, 8px);
background: rgba(255, 255, 255, 0.62);
}
.ppt-issue-card.is-error {
border-top-color: var(--obs-red);
}
.ppt-issue-card strong {
display: block;
margin-top: var(--momo-space-2, 8px);
color: var(--obs-ink);
font-size: var(--momo-text-body, 14px);
line-height: 1.35;
overflow-wrap: anywhere;
}
.ppt-issue-card p {
margin: var(--momo-space-2, 8px) 0 0;
color: var(--obs-muted);
font-size: var(--momo-text-body, 14px);
line-height: 1.55;
overflow-wrap: anywhere;
}
.ppt-issue-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--momo-space-2, 8px);
}
.ppt-action-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -882,6 +965,10 @@ body.ppt-preview-open {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ppt-issue-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ppt-deck-rail {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -902,6 +989,7 @@ body.ppt-preview-open {
.ppt-deck-rail,
.ppt-stage-grid,
.ppt-action-grid,
.ppt-issue-grid,
.ppt-coverage-score,
.ppt-coverage-list {
grid-template-columns: 1fr;
@@ -912,6 +1000,7 @@ body.ppt-preview-open {
.ppt-table-title,
.ppt-workbench-head,
.ppt-workbench-actions,
.ppt-issue-metrics,
.ppt-preview-head,
.ppt-run-log-head,
.ppt-run-row,