修復定期簡報漏產與補跑可靠性

This commit is contained in:
OoO
2026-06-06 15:25:20 +08:00
parent d6d8777e41
commit 8fa95b94a9
8 changed files with 475 additions and 5 deletions

View File

@@ -47,6 +47,11 @@ try:
except ImportError:
_OLLAMA_AVAILABLE = False
def _fast_static_fallback_enabled() -> bool:
return os.getenv("MCP_FAST_STATIC_FALLBACK", "").strip().lower() in {"1", "true", "yes", "on"}
# ── 查詢主題定義 ────────────────────────────────────────────────────────────
_SEARCH_TOPICS = {
"market_trends": (
@@ -164,6 +169,9 @@ class MCPCollectorService:
if cached:
return cached
if _fast_static_fallback_enabled():
return self._fallback_topic_content(topic, "定期簡報快速補跑:外部模型暫停,使用穩定行銷情報。")
# ─── Phase 10.52026-05-04MCP omnisearch L0 路徑 ───
# MCP_ROUTER_ENABLED=true 且 docker-compose.mcp.yml 已 deploy 時,
# 優先走 self-hosted Tavily/Exa取代 Gemini Grounding 主路徑)。

View File

@@ -13,7 +13,7 @@ import json
import os
import threading
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Iterable, Sequence
@@ -757,11 +757,14 @@ def get_generation_run_history(
return items
def _generate_job(job: PPTAutoJob, *, force: bool = False) -> tuple[str | None, int]:
def _generate_job(job: PPTAutoJob, *, force: bool = False, schedule_kind: str = "manual") -> 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
scheduled_fast_fallback = schedule_kind != "manual"
previous_fast_fallback = os.environ.get("PPT_SCHEDULED_FAST_FALLBACK")
previous_mcp_fast_fallback = os.environ.get("MCP_FAST_STATIC_FALLBACK")
def _noop_send_message(*_args, **_kwargs):
return None
@@ -769,6 +772,9 @@ def _generate_job(job: PPTAutoJob, *, force: bool = False) -> tuple[str | None,
if original_send_message is not None:
bot_routes.send_message = _noop_send_message
try:
if scheduled_fast_fallback:
os.environ["PPT_SCHEDULED_FAST_FALLBACK"] = "true"
os.environ["MCP_FAST_STATIC_FALLBACK"] = "true"
path = bot_routes._generate_ppt_cmd(
job.sub_type,
job.sub_arg,
@@ -778,6 +784,15 @@ def _generate_job(job: PPTAutoJob, *, force: bool = False) -> tuple[str | None,
)
return path, invalidated_count
finally:
if scheduled_fast_fallback:
if previous_fast_fallback is None:
os.environ.pop("PPT_SCHEDULED_FAST_FALLBACK", None)
else:
os.environ["PPT_SCHEDULED_FAST_FALLBACK"] = previous_fast_fallback
if previous_mcp_fast_fallback is None:
os.environ.pop("MCP_FAST_STATIC_FALLBACK", None)
else:
os.environ["MCP_FAST_STATIC_FALLBACK"] = previous_mcp_fast_fallback
if original_send_message is not None:
bot_routes.send_message = original_send_message
@@ -829,7 +844,7 @@ def generate_defined_ppt_reports(
item = asdict(job)
job_started_at = datetime.now(TAIPEI_TZ)
try:
path, invalidated_count = _generate_job(job, force=force)
path, invalidated_count = _generate_job(job, force=force, schedule_kind=schedule_kind)
item["path"] = path
item["cache_invalidated"] = invalidated_count
item["exists"] = bool(path and os.path.exists(path))
@@ -926,6 +941,217 @@ def get_due_schedule_kinds(now: datetime | None = None) -> list[str]:
return kinds
def _parse_cadence_time(kind: str) -> tuple[int, int]:
raw = (SCHEDULE_CADENCES.get(kind) or {}).get("time", "00:00")
hour, minute = raw.split(":", 1)
return int(hour), int(minute)
def _combine_local(run_date: date, kind: str) -> datetime:
hour, minute = _parse_cadence_time(kind)
return datetime(run_date.year, run_date.month, run_date.day, hour, minute)
def _previous_month(year: int, month: int) -> tuple[int, int]:
if month == 1:
return year - 1, 12
return year, month - 1
def _latest_month_occurrence(current: datetime, kind: str, allowed_months: set[int] | None = None) -> datetime:
year, month = current.year, current.month
for _ in range(24):
if allowed_months is None or month in allowed_months:
candidate = _combine_local(date(year, month, 1), kind)
if current >= candidate:
return candidate
year, month = _previous_month(year, month)
return _combine_local(date(current.year, current.month, 1), kind)
def get_latest_schedule_occurrence(kind: str, now: datetime | None = None) -> datetime | None:
"""Return the most recent planned occurrence for a PPT schedule kind.
The Python `schedule` package intentionally does not replay missed jobs.
This helper lets the PPT pipeline detect a missed daily/weekly/monthly slot
after long crawler or AI jobs release the scheduler loop.
"""
if kind not in SCHEDULE_PROFILES:
return None
current = now or datetime.now(TAIPEI_TZ)
if current.tzinfo is not None:
current = current.astimezone(TAIPEI_TZ).replace(tzinfo=None)
if kind == "daily":
candidate = _combine_local(current.date(), kind)
return candidate if current >= candidate else candidate - timedelta(days=1)
if kind == "weekly":
monday = current.date() - timedelta(days=current.weekday())
candidate = _combine_local(monday, kind)
return candidate if current >= candidate else candidate - timedelta(days=7)
if kind == "monthly":
return _latest_month_occurrence(current, kind)
if kind == "quarterly":
return _latest_month_occurrence(current, kind, {1, 4, 7, 10})
if kind == "half_yearly":
return _latest_month_occurrence(current, kind, {1, 7})
if kind == "annual":
return _latest_month_occurrence(current, kind, {1})
return None
def _ready_report_types_since(jobs: Sequence[PPTAutoJob], since: datetime) -> set[str]:
"""Return report types that already have matching DB rows and files."""
if not jobs:
return set()
expected_params = {job.report_type: job.expected_params for job in jobs}
ready: set[str] = set()
try:
session = get_session()
try:
rows = session.execute(
sa_text(
"""
SELECT report_type, parameters, file_path
FROM ppt_reports
WHERE generated_at >= :since
"""
),
{"since": since},
).fetchall()
finally:
session.close()
except Exception:
return ready
for report_type, parameters, file_path in rows:
report_type = str(report_type or "")
if report_type not in expected_params or report_type in ready:
continue
if file_path and not os.path.exists(str(file_path)):
continue
if _params_match(_parse_cache_params(parameters), expected_params[report_type]):
ready.add(report_type)
return ready
def get_scheduled_ppt_catchup_plan(
*,
now: datetime | None = None,
schedule_kinds: Iterable[str] | None = None,
) -> list[dict]:
"""Build a catch-up plan for missed periodic PPT generations."""
current = now or datetime.now(TAIPEI_TZ)
kinds = list(schedule_kinds or SCHEDULE_PROFILES.keys())
plan: list[dict] = []
for kind in kinds:
report_types = SCHEDULE_PROFILES.get(kind)
scheduled_at = get_latest_schedule_occurrence(kind, current)
if not report_types or scheduled_at is None:
continue
jobs = build_defined_ppt_jobs(report_types=report_types)
ready_types = _ready_report_types_since(jobs, scheduled_at)
missing_jobs = [job for job in jobs if job.report_type not in ready_types]
plan.append({
"schedule_kind": kind,
"schedule_label": SCHEDULE_CADENCES.get(kind, {}).get("label", kind),
"scheduled_at": scheduled_at.strftime("%Y-%m-%d %H:%M"),
"ready_report_types": sorted(ready_types),
"missing_report_types": [job.report_type for job in missing_jobs],
"missing_report_labels": [REPORT_TYPE_LABELS.get(job.report_type, job.report_type) for job in missing_jobs],
"ready": not missing_jobs,
})
return plan
def catch_up_scheduled_ppt_reports(
*,
now: datetime | None = None,
force: bool = False,
schedule_kinds: Iterable[str] | None = None,
) -> dict:
"""Generate missing scheduled PPT decks that were skipped by a blocked loop."""
global _LAST_RUN
if not force and not is_ppt_auto_generation_enabled():
result = {
"ok": False,
"status": "disabled",
"message": "PPT_AUTO_GENERATION_ENABLED=false",
"runs": [],
}
_LAST_RUN = result
return result
plan = get_scheduled_ppt_catchup_plan(now=now, schedule_kinds=schedule_kinds)
runs = []
for item in plan:
missing = item.get("missing_report_types") or []
if not missing:
continue
run = generate_defined_ppt_reports(
report_types=missing,
schedule_kind=item["schedule_kind"],
force=force,
)
run["schedule_kind"] = item["schedule_kind"]
run["catchup_scheduled_at"] = item.get("scheduled_at")
run["catchup_missing_report_types"] = missing
runs.append(run)
result = {
"ok": all(run.get("ok", False) for run in runs) if runs else True,
"status": "completed" if runs else "skipped",
"plan": plan,
"runs": runs,
"generated_kinds": [
run.get("schedule_kind")
for run in runs
if run.get("schedule_kind")
],
"ready": sum(int(run.get("ready") or 0) for run in runs),
"errors": sum(int(run.get("errors") or 0) for run in runs),
}
_LAST_RUN = result
return result
def start_scheduled_ppt_catchup_background(
*,
now: datetime | None = None,
force: bool = False,
schedule_kinds: Iterable[str] | None = None,
) -> dict:
"""Queue scheduled catch-up without blocking the scheduler main loop."""
if _RUN_LOCK.locked():
return {
"ok": True,
"status": "already_running",
"message": "PPT auto-generation is already running.",
"last_run": _LAST_RUN,
}
planned_kinds = list(schedule_kinds or SCHEDULE_PROFILES.keys())
def _run():
catch_up_scheduled_ppt_reports(
now=now,
force=force,
schedule_kinds=planned_kinds,
)
thread = threading.Thread(target=_run, name="ppt-auto-generation-catchup", daemon=True)
thread.start()
return {
"ok": True,
"status": "queued",
"message": "PPT scheduled catch-up queued.",
"schedule_kinds": planned_kinds,
}
def generate_scheduled_ppt_reports(
*,
schedule_kind: str | None = None,