diff --git a/config.py b/config.py index 6415ce9..3d9b513 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.437" +SYSTEM_VERSION = "V10.438" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index a57babb..173c0ad 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.437 +> **適用版本**: V10.438 --- @@ -141,7 +141,7 @@ SQL漏斗(~300筆) - `momo-scheduler` 每 6 小時固定執行 `run_action_plan_hygiene_task()`,讓過期 advisory action_plans 的關閉不再依賴 `resource_optimization` 告警觸發;排程失敗會經 EventRouter 發送 `action_plan_hygiene_failure`。 - `action_plans` 產生端必須防重:Code Review 同一檔案已有 active `code_review_fix` 時不重建;OpenClaw recommendation 會寫入文字 fingerprint 並跳過同一建議;AIOrchestrator 不再把 NemoTron `direct_response/reply_simple` 聊天回覆存成 action plan,真正需工具、審核或執行的 NemoTron action 才能進 queue。 - OpenClaw/Hermes embedding 優先呼叫 Ollama `/api/embed`,只在舊節點不支援時 fallback `/api/embeddings`;timeout 由 `EMBEDDING_TIMEOUT` / `OLLAMA_EMBED_TIMEOUT` 控制,並受 `OLLAMA_EMBED_MAX_TIMEOUT` 封頂。背景 worker / RAG 查詢不得落 111,除非 caller 顯式允許 `allow_111_fallback=True`。 -- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs`,`/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。PPT vision 需 `PPT_VISION_ENABLED=true` 與容器內 LibreOffice;`/observability/ppt_audit_file/` 會把 PPTX 轉成 PDF 快取供站內線上預覽,原始 PPTX 仍保留下載。 +- PPT 自動產線由 `momo-scheduler` 依節奏執行 `run_ppt_auto_generation_task(schedule_kind)`:每日 20:30 產日報、週一 20:40 產週報/市場情報、每月 1 日 20:50 產月報與管理型簡報、季初 21:00 產季報、半年初 21:10 產半年報、年初 21:20 產年報,再交給 22:00 `ppt_vision_audit` 做視覺審核;每次嘗試會寫入 `ppt_generation_runs`,`/observability/ppt_audit_history` 以精準參數檢查目標版本是否已產生,並可用 `/observability/ppt_audit/generate_missing` 手動補齊缺漏,總開關為 `PPT_AUTO_GENERATION_ENABLED`。PPT vision 需 `PPT_VISION_ENABLED=true` 與容器內 LibreOffice;`/observability/ppt_audit_file/` 會把 PPTX 轉成 PDF 快取供站內線上預覽,原始 PPTX 仍保留下載。QA 失敗項目的「重跑」必須從檔名推回原 report_type,並只失效相同 `report_type + parameters` 的 active `ppt_reports` cache,避免拿到舊 PPT 或誤重跑 daily。 --- diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 7691646..9fa60fb 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.438 PPT QA 失敗重跑精準化**: `/observability/ppt_audit_history` 的 QA 失敗與 issue triage 卡片會從 PPT 檔名前綴推回原始 `report_type`,不再把所有視覺 QA 失敗硬編成 daily 重跑;單筆「重跑」會以 `force=true` 呼叫補齊 API,並在產生前只失效同一 `report_type + parameters` 的 active `ppt_reports` cache,避免重新產出仍命中舊簡報。頁面也把 audit lane 的預覽按鈕補上,讓失敗檔案可直接站內回放 PDF/PPTX 預覽。 - **V10.437 業績圖表載入韌性與 QA 升級**: `analysis-chart-theme.js` 的 Chart.js loader 加入 4.5 秒 timeout 與 jsDelivr → unpkg → cdnjs 三來源 fallback;若外部 CDN 卡住或失敗,`/daily_sales`、`/growth_analysis` 會切到既有 HTML snapshot / fallback 圖表,不再留下空白圖表框。`check_sales_charts_runtime.js` 也從「canvas 有墨點」升級為檢查非零 dataset、可見元素、彩色資料筆跡與 canvas ink,避免只有座標軸的假通過。 - **V10.436 daily_sales snapshot_date 型別修復**: `/daily_sales` 日期窗口查詢改為依 DB dialect 明確 cast:PostgreSQL 使用 `"snapshot_date"::date` 並把參數 `CAST(:start_date AS date)` / `CAST(:end_date AS date)`,SQLite 使用 `date("snapshot_date")`;metadata / fingerprint 查詢同步引用 cast 後日期,避免正式庫 `snapshot_date` 為 text 時出現 `text = date` / `text >= date` 類型錯誤。後台 `chart_generator_service.monthly_overview_chart()` 的月業績 SQL 也改為 `snapshot_date::date`,防止報表圖表因 text 欄位而空白。 - **V10.435 商品列 PChome 狀態診斷翻譯**: Dashboard 商品列的 `_build_pchome_match_status()` 補上 `makeup_finish_conflict`、`nail_tool_function_conflict`、`schick_razor_line_conflict`、`variant_selection_review` 等具體狀態文案;`_load_pchome_match_attempt_map()` 同步解析 `match_diagnostic_json` 產生 `diagnostic_reasons` / `diagnostic_reason_text`,讓 overview、覆核隊列、商品列表與 Excel 的診斷語意一致。 diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py index 8c52ccd..62b1ef6 100644 --- a/routes/admin_observability_routes.py +++ b/routes/admin_observability_routes.py @@ -2453,6 +2453,47 @@ def budget_update(budget_id: int): # /observability/ppt_audit_history — Phase 29 PPT 視覺審核歷史 # ───────────────────────────────────────────────────────────────────────────── +def _guess_ppt_report_type_from_filename(filename: str) -> str: + """從產出的 PPT 檔名推回報表類型,供 QA 失敗重跑與 triage 使用。""" + name = str(filename or "") + if not name: + return "" + try: + from services.ppt_auto_generation_service import REPORT_PREFIXES + + for report_type, prefix in REPORT_PREFIXES.items(): + if prefix and name.startswith(prefix): + return report_type + except Exception: + pass + + fallback_prefixes = { + "ocbot_daily_": "daily", + "ocbot_weekly_": "weekly", + "ocbot_monthly_": "monthly", + "ocbot_quarterly_": "quarterly", + "ocbot_half_yearly_": "half_yearly", + "ocbot_annual_": "annual", + "ocbot_ttm_": "ttm", + "ocbot_strategy_": "strategy", + "ocbot_competitor_v4_": "competitor_v4", + "ocbot_competitor_": "competitor", + "ocbot_promo_compare_": "promo_compare", + "ocbot_promo_": "promo", + "ocbot_forecast_pre_event_": "forecast_pre_event", + "ocbot_vendor_": "vendor", + "ocbot_category_": "category", + "ocbot_customer_": "customer", + "ocbot_new_product_": "new_product", + "ocbot_market_intel_": "market_intel", + "ocbot_price_elasticity_": "price_elasticity", + } + for prefix, report_type in fallback_prefixes.items(): + if name.startswith(prefix): + return report_type + return "" + + def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_runs, vision_status, audit_records=None): """Compose page-level PPT pipeline health so the template stays declarative.""" files = files or [] @@ -2617,14 +2658,16 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run 'can_regenerate': bool(item.get('report_type')), }) for item in audit_attention[:3]: + filename = item.get('pptx_filename') or '' + inferred_report_type = item.get('report_type') or _guess_ppt_report_type_from_filename(filename) triage_entries.append({ - 'title': item.get('pptx_filename') or '未命名檔案', + 'title': filename or '未命名檔案', 'meta': item.get('audited_at') or '時間未知', 'detail': item.get('issue_summary') or item.get('error_msg') or f"問題 {item.get('issues_count', 0)} 個", 'status_label': '視覺 QA', - 'filename': item.get('pptx_filename') or '', - 'report_type': 'daily', - 'can_regenerate': True, + 'filename': filename, + 'report_type': inferred_report_type, + 'can_regenerate': bool(inferred_report_type), }) action_lanes = [ @@ -2688,6 +2731,8 @@ def _build_ppt_pipeline_view(files, auto_generation, audit_stats, generation_run 'detail': item.get('issue_summary') or item.get('error_msg') or f"問題 {item.get('issues_count', 0)} 個,信心 {item.get('confidence', 0):.2f}", 'status_label': '需修復' if item.get('audit_status') == 'failed' else '需排查', 'filename': item.get('pptx_filename'), + 'report_type': item.get('report_type') or _guess_ppt_report_type_from_filename(item.get('pptx_filename') or ''), + 'can_regenerate': bool(item.get('report_type') or _guess_ppt_report_type_from_filename(item.get('pptx_filename') or '')), } for item in audit_attention[:4] ], @@ -3218,6 +3263,7 @@ def ppt_audit_history(): def _extract_ppt_issue_items(raw_issues, *, pptx_filename: str, audited_at: str): issue_items = [] + report_type_for_file = _guess_report_type(pptx_filename) for slide_item in _load_ppt_issues(raw_issues): if not isinstance(slide_item, dict): continue @@ -3230,6 +3276,7 @@ def ppt_audit_history(): category, status = _classify_ppt_issue(issue_text) issue_items.append({ 'pptx_filename': pptx_filename, + 'report_type': report_type_for_file, 'audited_at': audited_at, 'slide_label': slide_label, 'category': category, @@ -3264,6 +3311,7 @@ def ppt_audit_history(): audit_records.append({ 'audited_at': audited_at, 'pptx_filename': pptx_filename, + 'report_type': _guess_report_type(pptx_filename), 'audit_status': r[2], 'issues_count': int(r[3] or 0), 'confidence': float(r[4] or 0), diff --git a/services/ppt_auto_generation_service.py b/services/ppt_auto_generation_service.py index d6c61b7..948dbaf 100644 --- a/services/ppt_auto_generation_service.py +++ b/services/ppt_auto_generation_service.py @@ -523,6 +523,49 @@ def _log_generation_run( pass +def _normalize_ppt_cache_parameters(parameters: dict) -> str: + """與 OpenClaw PPT cache key 使用同一套 tpl_ver 參數正規化。""" + try: + from routes.openclaw_bot_routes import _normalize_ppt_parameters + + return _normalize_ppt_parameters(parameters) + except Exception: + return json.dumps(parameters or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _expire_matching_ppt_cache(job: PPTAutoJob) -> int: + """只失效這次重跑目標的 active cache,避免按「重跑」仍拿到舊 PPT。""" + params = _normalize_ppt_cache_parameters(job.expected_params) + now = datetime.now(TAIPEI_TZ).replace(tzinfo=None) + expired_at = now - timedelta(minutes=1) + try: + session = get_session() + try: + result = session.execute( + sa_text( + """ + UPDATE ppt_reports + SET expires_at = :expired_at + WHERE report_type = :report_type + AND parameters = :parameters + AND (expires_at IS NULL OR expires_at > :now) + """ + ), + { + "expired_at": expired_at, + "report_type": job.report_type, + "parameters": params, + "now": now, + }, + ) + session.commit() + return int(getattr(result, "rowcount", 0) or 0) + finally: + session.close() + except Exception: + return 0 + + def get_defined_report_coverage( *, month_start: datetime, @@ -714,10 +757,11 @@ def get_generation_run_history( return items -def _generate_job(job: PPTAutoJob) -> str | None: +def _generate_job(job: PPTAutoJob, *, force: bool = False) -> tuple[str | None, int]: from routes import openclaw_bot_routes as bot_routes original_send_message = getattr(bot_routes, "send_message", None) + invalidated_count = _expire_matching_ppt_cache(job) if force else 0 def _noop_send_message(*_args, **_kwargs): return None @@ -725,13 +769,14 @@ def _generate_job(job: PPTAutoJob) -> str | None: if original_send_message is not None: bot_routes.send_message = _noop_send_message try: - return bot_routes._generate_ppt_cmd( + path = bot_routes._generate_ppt_cmd( job.sub_type, job.sub_arg, 0, job.target_date, _reply_to=None, ) + return path, invalidated_count finally: if original_send_message is not None: bot_routes.send_message = original_send_message @@ -784,8 +829,9 @@ def generate_defined_ppt_reports( item = asdict(job) job_started_at = datetime.now(TAIPEI_TZ) try: - path = _generate_job(job) + path, invalidated_count = _generate_job(job, force=force) item["path"] = path + item["cache_invalidated"] = invalidated_count item["exists"] = bool(path and os.path.exists(path)) item["status"] = "ready" if item["exists"] else "missing_file" _log_generation_run( diff --git a/templates/admin/ppt_audit_history.html b/templates/admin/ppt_audit_history.html index 482d70c..001c4e1 100644 --- a/templates/admin/ppt_audit_history.html +++ b/templates/admin/ppt_audit_history.html @@ -282,7 +282,7 @@
{{ item.status_label }} - {% if item.filename and lane.key in ['preview', 'database', 'triage'] %} + {% if item.filename and lane.key in ['preview', 'database', 'triage', 'audit'] %} 回放
+ {% if issue.report_type %} +
+ +
+ {% endif %} {% endfor %} @@ -567,6 +574,15 @@ {% endif %} {% if r.audit_status in ('failed','error') %} + {% if r.report_type %} + + {% endif %}