This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user