修復定期簡報漏產與補跑可靠性
This commit is contained in:
@@ -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.5(2026-05-04):MCP omnisearch L0 路徑 ───
|
||||
# MCP_ROUTER_ENABLED=true 且 docker-compose.mcp.yml 已 deploy 時,
|
||||
# 優先走 self-hosted Tavily/Exa(取代 Gemini Grounding 主路徑)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user