補 PPT 報表覆蓋矩陣
All checks were successful
CD Pipeline / deploy (push) Successful in 1m4s

This commit is contained in:
OoO
2026-05-19 01:09:58 +08:00
parent f6d34628f6
commit 4a6f6a2007
6 changed files with 376 additions and 17 deletions

View File

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

View File

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

View File

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

View File

@@ -359,16 +359,56 @@
</div>
</div>
<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>
<small>{{ item.status_hint }}</small>
<div class="ppt-coverage-list-head">
<span class="ppt-label">報表覆蓋矩陣</span>
<small>DB / 預覽 / 視覺 QA / 交付</small>
</div>
<span class="ppt-run-status is-{{ item.status }}">
{{ item.status_label }}
</span>
{% for item in auto_generation_items %}
<div class="ppt-coverage-row is-{{ item.delivery_status }}">
<div class="ppt-coverage-main">
<strong>{{ item.label }}</strong>
<small>{{ item.target_label or '最新資料' }}{% if item.latest_file_mtime %} · {{ item.latest_file_mtime }}{% endif %}</small>
{% if item.latest_file_name %}
<small><code>{{ item.latest_file_name }}</code></small>
{% else %}
<small>{{ item.status_hint }}</small>
{% endif %}
{% if item.audit_summary %}
<small>{{ item.audit_summary[:90] }}</small>
{% endif %}
</div>
<div class="ppt-coverage-signals">
<span class="ppt-run-status is-{{ item.db_status }}">{{ item.db_label }}</span>
<span class="ppt-run-status is-{{ item.preview_status }}">{{ item.preview_label }}</span>
<span class="ppt-run-status is-{{ item.qa_status }}">{{ item.qa_label }}</span>
<span class="ppt-run-status is-{{ item.delivery_status }}">{{ item.delivery_label }}</span>
</div>
<div class="ppt-coverage-actions">
{% if item.can_preview %}
<a class="btn btn-outline-primary btn-sm"
href="{{ url_for('admin_observability.ppt_audit_file', filename=item.latest_file_name) }}"
target="_blank"
rel="noopener"
data-ppt-open-preview
data-ppt-filename="{{ item.latest_file_name }}"
data-ppt-preview-title="{{ item.label }} · {{ item.latest_file_name }}"
data-ppt-preview-pdf="{{ url_for('admin_observability.ppt_audit_file', filename=item.latest_file_name, action='pdf') }}"
data-ppt-preview-page="{{ url_for('admin_observability.ppt_audit_file', filename=item.latest_file_name) }}"
data-ppt-download-url="{{ url_for('admin_observability.ppt_audit_file', filename=item.latest_file_name, action='download') }}">
<i class="fas fa-eye me-1"></i>預覽
</a>
{% endif %}
{% if item.can_prewarm %}
<button class="btn btn-outline-primary btn-sm" type="button" data-ppt-prewarm-preview data-ppt-filename="{{ item.latest_file_name }}">
<i class="fas fa-fire me-1"></i>預熱
</button>
{% endif %}
{% if item.can_regenerate and item.delivery_status in ['missing', 'error', 'partial'] %}
<button class="btn btn-outline-warning btn-sm" type="button" data-ppt-generate-one data-report-type="{{ item.key }}" data-report-label="{{ item.label }}">
<i class="fas fa-rotate me-1"></i>重跑
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>

View File

@@ -287,6 +287,101 @@ def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, t
assert '1</strong> 份 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', '<Types></Types>')
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

View File

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