diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index e1e0c2b..f48f8ed 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -68,6 +68,29 @@ jobs: python -m compileall -q platform/backend/app fi + - name: Validate Backend Runtime Entrypoints + env: + PYTHONPATH: platform/backend + DATABASE_URL: postgresql+asyncpg://fifa_user:ci-placeholder-db-password@127.0.0.1:5432/fifa2026 + REDIS_URL: redis://:ci-placeholder-redis-password@127.0.0.1:6379/0 + run: | + python - <<'PY' + import importlib + + modules = [ + 'app.main', + 'app.analytics.worldcup_seed', + 'app.analytics.crawler', + 'app.analytics.news_worker', + 'app.analytics.agent_review_worker', + 'app.analytics.daily_card_calendar_worker', + 'app.analytics.fixtures_worker', + ] + for module in modules: + importlib.import_module(module) + print(f'OK import {module}') + PY + - name: Setup Node.js Environment uses: actions/setup-node@v4 with: diff --git a/platform/backend/app/analytics/agent_review_worker.py b/platform/backend/app/analytics/agent_review_worker.py new file mode 100644 index 0000000..2f700ea --- /dev/null +++ b/platform/backend/app/analytics/agent_review_worker.py @@ -0,0 +1,55 @@ +"""AI review worker placeholder with guarded cost-aware status.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from redis.asyncio import Redis + +LOGGER = logging.getLogger("fifa2026-agent-review-worker") +logging.basicConfig(level=logging.INFO) +REDIS_URL = os.getenv("REDIS_URL", "redis://fifa2026-redis:6379/0") +INTERVAL_SECONDS = max(300, int(os.getenv("AGENT_REVIEW_POLL_INTERVAL_SECONDS", "1800"))) +STATUS_KEY = "ingestion:agent-review:last_run" + + +async def publish_status(payload: dict[str, Any]) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set(STATUS_KEY, json.dumps(payload, ensure_ascii=False), ex=max(INTERVAL_SECONDS * 3, 900)) + finally: + await redis.aclose() + + +async def run_once() -> dict[str, Any]: + payload = { + "status": "standby", + "worker": "agent_review_worker", + "run_at": datetime.now(timezone.utc).isoformat(), + "message": "AI 驗證器等待 Gemini/NemoTron 安全金鑰與成本閘門設定;目前不呼叫外部模型。", + } + await publish_status(payload) + LOGGER.info("%s", payload) + return payload + + +async def run_forever() -> None: + LOGGER.info("啟動 agent_review_worker,interval=%ss", INTERVAL_SECONDS) + while True: + try: + await run_once() + except Exception as exc: # pragma: no cover - worker loop must survive transient Redis errors + LOGGER.exception("agent_review_worker 本輪狀態寫入失敗:%s", exc) + await asyncio.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + if os.getenv("WORKER_ONCE") == "true": + print(asyncio.run(run_once())) + else: + asyncio.run(run_forever()) diff --git a/platform/backend/app/analytics/daily_card_calendar_worker.py b/platform/backend/app/analytics/daily_card_calendar_worker.py new file mode 100644 index 0000000..f661531 --- /dev/null +++ b/platform/backend/app/analytics/daily_card_calendar_worker.py @@ -0,0 +1,55 @@ +"""Daily card calendar cache worker placeholder.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from redis.asyncio import Redis + +LOGGER = logging.getLogger("fifa2026-calendar-cache-worker") +logging.basicConfig(level=logging.INFO) +REDIS_URL = os.getenv("REDIS_URL", "redis://fifa2026-redis:6379/0") +INTERVAL_SECONDS = max(60, int(os.getenv("DAILY_CARD_CALENDAR_POLL_INTERVAL_SECONDS", "300"))) +STATUS_KEY = "ingestion:daily-card-calendar:last_run" + + +async def publish_status(payload: dict[str, Any]) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set(STATUS_KEY, json.dumps(payload, ensure_ascii=False), ex=max(INTERVAL_SECONDS * 3, 900)) + finally: + await redis.aclose() + + +async def run_once() -> dict[str, Any]: + payload = { + "status": "standby", + "worker": "daily_card_calendar_worker", + "run_at": datetime.now(timezone.utc).isoformat(), + "message": "每日作戰室日期快取等待正式賽程來源回補;目前只回報排程健康狀態。", + } + await publish_status(payload) + LOGGER.info("%s", payload) + return payload + + +async def run_forever() -> None: + LOGGER.info("啟動 daily_card_calendar_worker,interval=%ss", INTERVAL_SECONDS) + while True: + try: + await run_once() + except Exception as exc: # pragma: no cover - worker loop must survive transient Redis errors + LOGGER.exception("daily_card_calendar_worker 本輪狀態寫入失敗:%s", exc) + await asyncio.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + if os.getenv("WORKER_ONCE") == "true": + print(asyncio.run(run_once())) + else: + asyncio.run(run_forever()) diff --git a/platform/backend/app/analytics/fixtures_worker.py b/platform/backend/app/analytics/fixtures_worker.py new file mode 100644 index 0000000..2d7512c --- /dev/null +++ b/platform/backend/app/analytics/fixtures_worker.py @@ -0,0 +1,55 @@ +"""Fixtures ingestion worker placeholder.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from redis.asyncio import Redis + +LOGGER = logging.getLogger("fifa2026-fixtures-worker") +logging.basicConfig(level=logging.INFO) +REDIS_URL = os.getenv("REDIS_URL", "redis://fifa2026-redis:6379/0") +INTERVAL_SECONDS = max(300, int(os.getenv("FIXTURES_POLL_INTERVAL_SECONDS", "21600"))) +STATUS_KEY = "ingestion:fixtures:last_run" + + +async def publish_status(payload: dict[str, Any]) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set(STATUS_KEY, json.dumps(payload, ensure_ascii=False), ex=max(INTERVAL_SECONDS * 3, 900)) + finally: + await redis.aclose() + + +async def run_once() -> dict[str, Any]: + payload = { + "status": "standby", + "worker": "fixtures_worker", + "run_at": datetime.now(timezone.utc).isoformat(), + "message": "賽程同步器已啟動;正式來源未驗證前不覆寫既有賽程。", + } + await publish_status(payload) + LOGGER.info("%s", payload) + return payload + + +async def run_forever() -> None: + LOGGER.info("啟動 fixtures_worker,interval=%ss", INTERVAL_SECONDS) + while True: + try: + await run_once() + except Exception as exc: # pragma: no cover - worker loop must survive transient Redis errors + LOGGER.exception("fixtures_worker 本輪狀態寫入失敗:%s", exc) + await asyncio.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + if os.getenv("WORKER_ONCE") == "true": + print(asyncio.run(run_once())) + else: + asyncio.run(run_forever()) diff --git a/platform/backend/app/analytics/localization.py b/platform/backend/app/analytics/localization.py new file mode 100644 index 0000000..88d4216 --- /dev/null +++ b/platform/backend/app/analytics/localization.py @@ -0,0 +1,180 @@ +"""Traditional Chinese localization helpers for public API payloads.""" + +from __future__ import annotations + +from typing import Any + +TEAM_NAMES = { + 'Argentina': '阿根廷', + 'Australia': '澳洲', + 'Austria': '奧地利', + 'Belgium': '比利時', + 'Brazil': '巴西', + 'Canada': '加拿大', + 'Cape Verde': '維德角', + 'Colombia': '哥倫比亞', + 'Croatia': '克羅埃西亞', + 'Curaçao': '古拉索', + 'Czech Republic': '捷克', + 'DR Congo': '剛果民主共和國', + 'Ecuador': '厄瓜多', + 'Egypt': '埃及', + 'England': '英格蘭', + 'France': '法國', + 'Germany': '德國', + 'Ghana': '迦納', + 'Iran': '伊朗', + 'Iraq': '伊拉克', + 'Japan': '日本', + 'Jordan': '約旦', + 'Mexico': '墨西哥', + 'Netherlands': '荷蘭', + 'New Zealand': '紐西蘭', + 'Norway': '挪威', + 'Panama': '巴拿馬', + 'Portugal': '葡萄牙', + 'Qatar': '卡達', + 'Saudi Arabia': '沙烏地阿拉伯', + 'Senegal': '塞內加爾', + 'South Africa': '南非', + 'South Korea': '南韓', + 'Spain': '西班牙', + 'Sweden': '瑞典', + 'Tunisia': '突尼西亞', + 'Türkiye': '土耳其', + 'Turkey': '土耳其', + 'United States': '美國', + 'USA': '美國', + 'Uruguay': '烏拉圭', + 'Uzbekistan': '烏茲別克', +} + +COUNTRIES = { + 'United States': '美國', + 'USA': '美國', + 'Canada': '加拿大', + 'Mexico': '墨西哥', +} + +CITIES = { + 'New York': '紐約', + 'New Jersey': '紐澤西', + 'Los Angeles': '洛杉磯', + 'Dallas': '達拉斯', + 'Houston': '休士頓', + 'Kansas City': '堪薩斯城', + 'Miami': '邁阿密', + 'Philadelphia': '費城', + 'Seattle': '西雅圖', + 'Atlanta': '亞特蘭大', + 'Boston': '波士頓', + 'San Francisco': '舊金山', + 'Toronto': '多倫多', + 'Vancouver': '溫哥華', + 'Mexico City': '墨西哥城', + 'Guadalajara': '瓜達拉哈拉', + 'Monterrey': '蒙特雷', +} + +VENUES = { + 'MetLife Stadium': '大都會人壽體育場', + 'SoFi Stadium': 'SoFi 體育場', + 'AT&T Stadium': 'AT&T 體育場', + 'NRG Stadium': 'NRG 體育場', + 'Hard Rock Stadium': '硬石體育場', + 'Lumen Field': '流明球場', + 'Mercedes-Benz Stadium': '梅賽德斯-賓士體育場', + 'Lincoln Financial Field': '林肯金融球場', + 'Gillette Stadium': '吉列體育場', + 'Levi's Stadium': '李維斯體育場', + 'BMO Field': 'BMO 球場', + 'BC Place': '卑詩體育館', + 'Estadio Azteca': '阿茲特克體育場', +} + +MARKETS = { + '1x2': '勝平負', + 'h2h': '勝平負', + 'h2h_3_way': '勝平負', + 'asian_handicap': '亞洲讓球', + 'spreads': '讓球盤', + 'alternate_spreads': '讓球變體盤', + 'ou': '大小球', + 'totals': '大小球', + 'alternate_totals': '大小球變體盤', + 'btts': '雙方進球', + 'draw_no_bet': '平手退回', + 'team_total': '球隊大小球', + 'team_totals': '球隊大小球', + 'correct_score': '正確比分', +} + +SELECTIONS = { + 'home': '主隊', + 'away': '客隊', + 'draw': '平手', + 'tie': '平手', + 'over': '大分', + 'under': '小分', + 'yes': '是', + 'no': '否', +} + +STATUSES = { + 'pre-match': '未開賽', + 'scheduled': '未開賽', + 'upcoming': '未開賽', + 'in-play': '進行中', + 'live': '進行中', + 'finished': '已完賽', + 'final': '已完賽', + 'postponed': '延期', + 'cancelled': '取消', + 'canceled': '取消', +} + + +def _clean(value: Any) -> str: + return str(value or '').strip() + + +def _lookup(value: Any, mapping: dict[str, str]) -> str: + text = _clean(value) + if not text: + return '待確認' + return mapping.get(text, mapping.get(text.lower(), text)) + + +def localize_team_name(value: Any) -> str: + return _lookup(value, TEAM_NAMES) + + +def localize_country(value: Any) -> str: + return _lookup(value, COUNTRIES) + + +def localize_city(value: Any) -> str: + return _lookup(value, CITIES) + + +def localize_venue_name(value: Any) -> str: + return _lookup(value, VENUES) + + +def localize_market_type(value: Any) -> str: + return _lookup(value, MARKETS) + + +def localize_selection(value: Any) -> str: + text = _clean(value) + if not text: + return '待確認' + lowered = text.lower() + return SELECTIONS.get(lowered, TEAM_NAMES.get(text, text)) + + +def localize_status(value: Any) -> str: + text = _clean(value) + if not text: + return '待確認' + return STATUSES.get(text.lower().replace('_', '-'), text) diff --git a/platform/backend/app/analytics/news_worker.py b/platform/backend/app/analytics/news_worker.py new file mode 100644 index 0000000..f750903 --- /dev/null +++ b/platform/backend/app/analytics/news_worker.py @@ -0,0 +1,55 @@ +"""News ingestion worker placeholder with explicit freshness status.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone +from typing import Any + +from redis.asyncio import Redis + +LOGGER = logging.getLogger("fifa2026-news-worker") +logging.basicConfig(level=logging.INFO) +REDIS_URL = os.getenv("REDIS_URL", "redis://fifa2026-redis:6379/0") +INTERVAL_SECONDS = max(60, int(os.getenv("NEWS_POLL_INTERVAL_SECONDS", "900"))) +STATUS_KEY = "ingestion:news:last_run" + + +async def publish_status(payload: dict[str, Any]) -> None: + redis = Redis.from_url(REDIS_URL, decode_responses=True) + try: + await redis.set(STATUS_KEY, json.dumps(payload, ensure_ascii=False), ex=max(INTERVAL_SECONDS * 3, 900)) + finally: + await redis.aclose() + + +async def run_once() -> dict[str, Any]: + payload = { + "status": "standby", + "worker": "news_worker", + "run_at": datetime.now(timezone.utc).isoformat(), + "message": "新聞來源尚未設定為正式授權來源;目前只回報新鮮度,不產生假新聞。", + } + await publish_status(payload) + LOGGER.info("%s", payload) + return payload + + +async def run_forever() -> None: + LOGGER.info("啟動 news_worker,interval=%ss", INTERVAL_SECONDS) + while True: + try: + await run_once() + except Exception as exc: # pragma: no cover - worker loop must survive transient Redis errors + LOGGER.exception("news_worker 本輪狀態寫入失敗:%s", exc) + await asyncio.sleep(INTERVAL_SECONDS) + + +if __name__ == "__main__": + if os.getenv("WORKER_ONCE") == "true": + print(asyncio.run(run_once())) + else: + asyncio.run(run_forever())