Spaces:
Running
Running
| import json | |
| import os | |
| from datetime import datetime, timedelta, timezone | |
| _TW = timezone(timedelta(hours=8)) | |
| from typing import Optional | |
| import numpy as np | |
| import pandas as pd | |
| from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Query, Security | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.security.api_key import APIKeyHeader | |
| from pydantic import BaseModel | |
| from ai_chat import ( | |
| get_provider, | |
| list_chat_roles, | |
| list_topics, | |
| load_topic, | |
| run_qa, | |
| run_radar_top3_roundtable, | |
| run_rfc_macd_ib_roundtable, | |
| run_roundtable_for_topic, | |
| save_topic, | |
| set_provider, | |
| ) | |
| from ai_support import ask_support | |
| app = FastAPI() | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| REPO_ID = "raywu918python/j1s-data" | |
| DEFAULT_MODEL = "margin_lgbm" | |
| _API_KEY = os.environ.get("API_KEY", "") | |
| _HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| _GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") | |
| _GITHUB_REPO = "raywu918python/j1stools_source" | |
| _key_header = APIKeyHeader(name="X-API-Key") | |
| def _verify_key(key: str = Security(_key_header)): | |
| if not _API_KEY or key != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid API key") | |
| def _rag_url(date_str: str) -> str: | |
| return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/db/rag/daily_picks_{date_str}.json" | |
| def _pred_url(date_str: str, model: str) -> str: | |
| return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main" f"/db/predictions/{model}/pred_{date_str}.parquet" | |
| def _backtest_url(date_str: str, model: str, kind: str) -> str: | |
| return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main" f"/db/backtest/{model}/{kind}_{date_str}.parquet" | |
| def _to_records(df: pd.DataFrame) -> list: | |
| df = df.copy() | |
| for col in df.columns: | |
| if pd.api.types.is_datetime64_any_dtype(df[col]): | |
| df[col] = df[col].dt.strftime("%Y-%m-%d") | |
| return [ | |
| {k: (None if isinstance(v, float) and np.isnan(v) else v) for k, v in row.items()} | |
| for row in df.to_dict(orient="records") | |
| ] | |
| def _read_parquet(url: str) -> pd.DataFrame: | |
| headers = {"Authorization": f"Bearer {_HF_TOKEN}"} if _HF_TOKEN else {} | |
| import io, httpx | |
| r = httpx.get(url, headers=headers, follow_redirects=True, timeout=30) | |
| r.raise_for_status() | |
| return pd.read_parquet(io.BytesIO(r.content)) | |
| def _load_latest_pred(model: str): | |
| for delta in range(7): | |
| d = (datetime.now(_TW).date() - timedelta(days=delta)).strftime("%Y-%m-%d") | |
| try: | |
| return _read_parquet(_pred_url(d, model)), d | |
| except Exception: | |
| continue | |
| raise HTTPException(status_code=404, detail="No prediction found in last 7 days") | |
| def _load_latest_backtest(model: str): | |
| for delta in range(7): | |
| d = (datetime.now(_TW).date() - timedelta(days=delta)).strftime("%Y-%m-%d") | |
| try: | |
| equity = _read_parquet(_backtest_url(d, model, "equity")) | |
| trades = _read_parquet(_backtest_url(d, model, "trades")) | |
| try: | |
| open_pos = _read_parquet(_backtest_url(d, model, "open")) | |
| except Exception: | |
| open_pos = pd.DataFrame() | |
| try: | |
| market = _read_parquet(_backtest_url(d, model, "market")) | |
| except Exception: | |
| market = pd.DataFrame() | |
| return equity, trades, open_pos, market, d | |
| except Exception: | |
| continue | |
| raise HTTPException(status_code=404, detail="No backtest found in last 7 days") | |
| def _read_rag(date_str: str) -> dict: | |
| import json as _json, httpx | |
| headers = {"Authorization": f"Bearer {_HF_TOKEN}"} if _HF_TOKEN else {} | |
| r = httpx.get(_rag_url(date_str), headers=headers, follow_redirects=True, timeout=30) | |
| r.raise_for_status() | |
| return _json.loads(r.content) | |
| def _youtube_url(date_str: str) -> str: | |
| return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/db/youtube/{date_str}/index.json" | |
| def _read_youtube(date_str: str) -> list: | |
| import json as _json, httpx | |
| headers = {"Authorization": f"Bearer {_HF_TOKEN}"} if _HF_TOKEN else {} | |
| r = httpx.get(_youtube_url(date_str), headers=headers, follow_redirects=True, timeout=30) | |
| r.raise_for_status() | |
| return _json.loads(r.content) | |
| def get_rag_picks(_=Depends(_verify_key)): | |
| """最新一天的 RAG 選股結果(往前找最近 7 天)。""" | |
| for delta in range(7): | |
| d = (datetime.now(_TW).date() - timedelta(days=delta)).strftime("%Y-%m-%d") | |
| try: | |
| return _read_rag(d) | |
| except Exception: | |
| continue | |
| raise HTTPException(status_code=404, detail="No RAG picks found in last 7 days") | |
| def get_rag_picks_by_date(date_str: str, _=Depends(_verify_key)): | |
| """指定日期的 RAG 選股結果。""" | |
| try: | |
| return _read_rag(date_str) | |
| except Exception: | |
| raise HTTPException(status_code=404, detail=f"No RAG picks for {date_str}") | |
| def get_youtube_videos(_=Depends(_verify_key)): | |
| """最新一天的 AI 主播短影音清單(往前找最近 7 天)。""" | |
| for delta in range(7): | |
| d = (datetime.now(_TW).date() - timedelta(days=delta)).strftime("%Y-%m-%d") | |
| try: | |
| return {"date": d, "videos": _read_youtube(d)} | |
| except Exception: | |
| continue | |
| raise HTTPException(status_code=404, detail="No youtube videos found in last 7 days") | |
| def get_youtube_videos_by_date(date_str: str, _=Depends(_verify_key)): | |
| """指定日期的 AI 主播短影音清單。""" | |
| try: | |
| return {"date": date_str, "videos": _read_youtube(date_str)} | |
| except Exception: | |
| raise HTTPException(status_code=404, detail=f"No youtube videos for {date_str}") | |
| def get_predictions(model: Optional[str] = Query(default=DEFAULT_MODEL), _=Depends(_verify_key)): | |
| df, d = _load_latest_pred(model) | |
| return {"date": d, "model": model, "data": _to_records(df)} | |
| def get_predictions_by_date(date_str: str, model: Optional[str] = Query(default=DEFAULT_MODEL), _=Depends(_verify_key)): | |
| try: | |
| df = _read_parquet(_pred_url(date_str, model)) | |
| except Exception: | |
| raise HTTPException(status_code=404, detail=f"No prediction for {date_str} model={model}") | |
| return {"date": date_str, "model": model, "data": _to_records(df)} | |
| def get_backtest(model: Optional[str] = Query(default=DEFAULT_MODEL), _=Depends(_verify_key)): | |
| equity, trades, open_pos, market, d = _load_latest_backtest(model) | |
| return { | |
| "date": d, | |
| "model": model, | |
| "equity": _to_records(equity), | |
| "trades": _to_records(trades), | |
| "open": _to_records(open_pos), | |
| "market": _to_records(market), | |
| } | |
| def get_backtest_by_date(date_str: str, model: Optional[str] = Query(default=DEFAULT_MODEL), _=Depends(_verify_key)): | |
| try: | |
| equity = _read_parquet(_backtest_url(date_str, model, "equity")) | |
| trades = _read_parquet(_backtest_url(date_str, model, "trades")) | |
| try: | |
| open_pos = _read_parquet(_backtest_url(date_str, model, "open")) | |
| except Exception: | |
| open_pos = pd.DataFrame() | |
| try: | |
| market = _read_parquet(_backtest_url(date_str, model, "market")) | |
| except Exception: | |
| market = pd.DataFrame() | |
| except Exception: | |
| raise HTTPException(status_code=404, detail=f"No backtest for {date_str} model={model}") | |
| return { | |
| "date": date_str, | |
| "model": model, | |
| "equity": _to_records(equity), | |
| "trades": _to_records(trades), | |
| "open": _to_records(open_pos), | |
| "market": _to_records(market), | |
| } | |
| def _trigger_workflow(workflow_file: str) -> bool: | |
| import httpx | |
| url = f"https://api.github.com/repos/{_GITHUB_REPO}/actions/workflows/{workflow_file}/dispatches" | |
| r = httpx.post( | |
| url, | |
| headers={"Authorization": f"Bearer {_GITHUB_TOKEN}", "Accept": "application/vnd.github+json"}, | |
| json={"ref": "main"}, | |
| timeout=10, | |
| ) | |
| return r.status_code == 204 | |
| def trigger_run_rag(token: str = Query(default="")): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| ok = _trigger_workflow("run_rag.yml") | |
| return {"ok": ok} | |
| def trigger_update_price(token: str = Query(default="")): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| ok = _trigger_workflow("update_price.yml") | |
| return {"ok": ok} | |
| def trigger_margin_lgbm(token: str = Query(default="")): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| ok = _trigger_workflow("margin_lgbm.yml") | |
| return {"ok": ok} | |
| def trigger_rfc_macd_6xx(token: str = Query(default="")): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| ok = _trigger_workflow("rfc_macd.yml") | |
| return {"ok": ok} | |
| def trigger_predict_all(token: str = Query(default="")): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| results = {name: _trigger_workflow(yml) for name, yml in _MODELS.items()} | |
| return {"ok": all(results.values()), "results": results} | |
| _MODELS = { | |
| # "rfc_macd_6xx": "rfc_macd.yml", | |
| "rfc_macd_ib_6xx": "rfc_ib_macd.yml", | |
| # "margin_lgbm": "margin_lgbm.yml", | |
| } | |
| def list_models(): | |
| return {"models": list(_MODELS.keys())} | |
| # ─── 議題聊天室 REST API ────────────────────────────────────────────────────── | |
| async def get_topics(date: str = Query(default="")): | |
| """列出指定日期所有議題摘要(不帶 date 則回傳今日)。 | |
| 回傳範例: | |
| { | |
| "date": "2026-06-18", | |
| "topics": [ | |
| {"topic_id": "rfc_macd_ib", "title": "RFC MACD IB 持倉分析", "summary": "...", | |
| "created_at": "2026-06-18 09:00", "source": "auto", "qa_count": 3}, | |
| ... | |
| ] | |
| } | |
| """ | |
| import asyncio as _aio | |
| date_str = date or datetime.now(_TW).strftime("%Y-%m-%d") | |
| topics = await _aio.to_thread(list_topics, date_str) | |
| return {"date": date_str, "topics": topics} | |
| async def get_topic(topic_id: str, date: str = Query(default="")): | |
| """取得指定議題的完整資料(discussion + qa)。""" | |
| import asyncio as _aio | |
| date_str = date or datetime.now(_TW).strftime("%Y-%m-%d") | |
| topic = await _aio.to_thread(load_topic, topic_id, date_str) | |
| if topic is None: | |
| raise HTTPException(status_code=404, detail=f"議題 {topic_id} 不存在") | |
| return topic | |
| class AskBody(BaseModel): | |
| nickname: str = "匿名" | |
| message: str | |
| async def ask_topic(topic_id: str, body: AskBody, background_tasks: BackgroundTasks, date: str = Query(default="")): | |
| """對現有議題追問。LLM 答完立即回傳,HF 存檔在背景執行。 | |
| 回傳:{"question": {...}, "answer": {...}} | |
| """ | |
| text = body.message.strip() | |
| if not text: | |
| raise HTTPException(status_code=400, detail="message 不可為空") | |
| date_str = date or datetime.now(_TW).strftime("%Y-%m-%d") | |
| try: | |
| qa_entry, updated_topic = await run_qa(topic_id, text, date_str, body.nickname) | |
| except ValueError as e: | |
| raise HTTPException(status_code=404, detail=str(e)) | |
| background_tasks.add_task(save_topic, updated_topic, date_str) | |
| return qa_entry | |
| class NewTopicBody(BaseModel): | |
| title: str | |
| question: str | |
| nickname: str = "匿名" | |
| async def create_topic(body: NewTopicBody, background_tasks: BackgroundTasks): | |
| """用戶自開新議題,觸發完整圓桌(背景執行)。 | |
| 立即回傳 topic_id,前端輪詢 GET /topics/{topic_id} 查看結果。 | |
| """ | |
| title = body.title.strip() | |
| question = body.question.strip() | |
| if not title or not question: | |
| raise HTTPException(status_code=400, detail="title 和 question 不可為空") | |
| import uuid as _uuid | |
| topic_id = f"user_{_uuid.uuid4().hex[:8]}" | |
| date_str = datetime.now(_TW).strftime("%Y-%m-%d") | |
| background_tasks.add_task( | |
| run_roundtable_for_topic, | |
| topic_id=topic_id, | |
| title=title, | |
| question=question, | |
| date_str=date_str, | |
| source="user", | |
| ) | |
| return { | |
| "topic_id": topic_id, | |
| "title": title, | |
| "status": "processing", | |
| "message": "圓桌會議已啟動,請稍後用 GET /topics/{topic_id} 查看結果", | |
| } | |
| # openrouter / qwen 留在 ai_chat.py 給內部測試用(工具呼叫不穩定,見 ai_chat.py 註解), | |
| # 不對前端開放,避免有人切換後讓所有使用者的圓桌都跟著壞掉。 | |
| _AVAILABLE_PROVIDERS = ["groq"] | |
| def list_models_available(): | |
| """前端顯示用:可選的 LLM 清單 + 目前使用哪個。""" | |
| return {"providers": _AVAILABLE_PROVIDERS, "current": get_provider()} | |
| def list_roles_available(): | |
| """前端顯示用:聊天室裡有哪些 AI 角色。""" | |
| return {"roles": list_chat_roles()} | |
| def get_current_model(): | |
| """查看目前使用的 LLM。""" | |
| return {"provider": get_provider()} | |
| def switch_model(provider: str = Query(..., description="groq")): | |
| """切換 LLM。前端直接呼叫,不需 token。重啟後還原預設值。""" | |
| try: | |
| set_provider(provider) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| return {"provider": provider, "message": f"已切換到 {provider}"} | |
| def _check_token(token: str): | |
| if token != _API_KEY: | |
| raise HTTPException(status_code=403, detail="Invalid key") | |
| async def trigger_rfc_macd_ib(background_tasks: BackgroundTasks, token: str = Query(default="")): | |
| _check_token(token) | |
| background_tasks.add_task(run_rfc_macd_ib_roundtable) | |
| return {"ok": True, "message": "RFC MACD IB 議題已在背景啟動"} | |
| async def trigger_radar_top3(background_tasks: BackgroundTasks, token: str = Query(default="")): | |
| _check_token(token) | |
| background_tasks.add_task(run_radar_top3_roundtable) | |
| return {"ok": True, "message": "AI 雷達 Top3 議題已在背景啟動"} | |
| # ─── 客服 AI(RAG 問答)───────────────────────────────────────────────────── | |
| class SupportAskBody(BaseModel): | |
| message: str | |
| history: list[dict] = [] | |
| async def support_ask(body: SupportAskBody): | |
| """客服問答。history 為前端帶上來的對話紀錄(可選,無狀態設計,後端不存檔)。 | |
| body: {"message": "...", "history": [{"role": "user"|"assistant", "content": "..."}]} | |
| 回傳: {"answer": "..."} | |
| """ | |
| import asyncio as _aio | |
| text = body.message.strip() | |
| if not text: | |
| raise HTTPException(status_code=400, detail="message 不可為空") | |
| answer = await _aio.to_thread(ask_support, text, body.history) | |
| return {"answer": answer} | |
| def health(): | |
| return {"status": "ok"} | |