補 PPT 視覺 QA 背景狀態
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-19 09:06:11 +08:00
parent 595c88fa1e
commit 583e318295
8 changed files with 369 additions and 4 deletions

View File

@@ -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`。

View File

@@ -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 # 用於模板顯示

View File

@@ -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', []),

View File

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

View File

@@ -96,6 +96,33 @@
</div>
</section>
<section class="ppt-vision-status is-{{ vision_audit_status.status }}" data-ppt-vision-status aria-live="polite">
<div class="ppt-vision-status-main">
<span class="ppt-run-status is-{{ 'ready' if vision_audit_status.status == 'completed' else 'planned' if vision_audit_status.status in ['idle', 'queued', 'running'] else 'error' }}">
<i class="fas fa-eye me-1" aria-hidden="true"></i>Vision QA
</span>
<div>
<strong data-ppt-vision-status-title>{{ vision_audit_status.status_label }}</strong>
<small data-ppt-vision-status-meta>{{ vision_audit_status.message }}</small>
</div>
</div>
<div class="ppt-vision-status-list" data-ppt-vision-status-list>
{% if vision_audit_status.last_run %}
<div class="ppt-vision-job">
<span>{{ vision_audit_status.last_run.finished_at or vision_audit_status.last_run.started_at or vision_audit_status.last_run.queued_at }}</span>
<strong>{{ vision_audit_status.last_run.summary.audited_count }} 份 / {{ vision_audit_status.last_run.summary.total_issues }} 問題</strong>
<small>{% if vision_audit_status.last_run.summary.error_count %}錯誤 {{ vision_audit_status.last_run.summary.error_count }}{% else %}無 runtime error{% endif %}</small>
</div>
{% else %}
<div class="ppt-vision-job">
<span>尚無紀錄</span>
<strong>待命</strong>
<small>按下「立即視覺 QA」後會在這裡顯示背景任務狀態。</small>
</div>
{% endif %}
</div>
</section>
<section class="ppt-toolbar">
<div class="d-flex align-items-center gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('admin_observability.ppt_audit_history', month=prev_month_label, report_type=report_type) }}">

View File

@@ -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_foundAiderHeal 應可吃診斷摘要派工。"""
from routes import admin_observability_routes as mod

View File

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

View File

@@ -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 = `
<div class="ppt-vision-job">
<span>尚無紀錄</span>
<strong>待命</strong>
<small>按下「立即視覺 QA」後會在這裡顯示背景任務狀態。</small>
</div>
`;
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 = `
<div class="ppt-vision-job">
<span>${escapeHtml(timestamp)}</span>
<strong>${escapeHtml(issueText)}</strong>
<small>${escapeHtml(errorText)}</small>
</div>
`;
}
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 => {