File size: 15,821 Bytes
b8071ca
6ae590b
3852e32
 
 
8274fc8
decd7c4
7404654
decd7c4
b8071ca
decd7c4
6ae590b
b8071ca
 
 
 
eafe525
b8071ca
 
 
539b936
 
b8071ca
 
 
 
044b73d
decd7c4
 
 
 
 
 
b8071ca
decd7c4
 
 
 
85ed214
6ae590b
dbe6f6d
160ba8b
 
6ae590b
 
 
 
 
 
decd7c4
 
12e9a73
 
 
 
278aca6
85ed214
decd7c4
 
278aca6
85ed214
278aca6
 
7404654
 
 
 
 
 
 
 
 
 
 
dbe6f6d
 
 
85ed214
dbe6f6d
 
9435060
dbe6f6d
 
278aca6
decd7c4
3852e32
decd7c4
dbe6f6d
decd7c4
 
 
 
 
278aca6
 
3852e32
278aca6
dbe6f6d
 
278aca6
dbe6f6d
278aca6
 
158821c
dbe6f6d
158821c
 
 
278aca6
 
 
 
 
12e9a73
 
 
 
 
 
 
 
0145275
 
 
 
 
 
 
 
 
 
 
 
12e9a73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0145275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decd7c4
6ae590b
278aca6
7404654
decd7c4
 
 
6ae590b
decd7c4
dbe6f6d
decd7c4
8274fc8
7404654
decd7c4
 
278aca6
 
158821c
278aca6
 
 
7404654
 
 
 
278aca6
 
 
 
 
 
dbe6f6d
 
278aca6
dbe6f6d
278aca6
 
158821c
dbe6f6d
158821c
 
278aca6
 
 
 
 
7404654
 
 
 
278aca6
 
 
160ba8b
 
85ed214
160ba8b
 
 
 
 
 
 
 
 
 
12e9a73
 
 
 
 
 
 
 
160ba8b
c860f4d
 
160ba8b
 
 
 
 
 
c860f4d
 
160ba8b
 
 
 
 
7096516
 
b3b8b28
 
 
 
 
 
 
 
 
 
85ed214
b3b8b28
 
 
85ed214
cba6cfc
85ed214
cba6cfc
85ed214
 
 
 
 
 
 
 
b8071ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599f6b0
 
 
756365a
 
 
 
 
 
 
 
eafe525
 
 
 
 
 
b8071ca
 
 
 
 
 
 
599f6b0
756365a
b8071ca
 
 
 
 
 
 
539b936
b8071ca
 
 
 
539b936
 
 
 
 
 
37653dd
 
539b936
 
 
 
 
 
37653dd
 
044b73d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
decd7c4
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
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)


@app.get("/rag")
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")


@app.get("/rag/{date_str}")
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}")


@app.get("/youtube")
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")


@app.get("/youtube/{date_str}")
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}")


@app.get("/predictions")
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)}


@app.get("/predictions/{date_str}")
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)}


@app.get("/backtest")
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),
    }


@app.get("/backtest/{date_str}")
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


@app.get("/trigger/run-rag")
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}


@app.get("/trigger/update-price")
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}


@app.get("/trigger/margin-lgbm")
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}


@app.get("/trigger/rfc-macd-6xx")
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}


@app.get("/trigger/predict-all")
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",
}


@app.get("/models")
def list_models():
    return {"models": list(_MODELS.keys())}


# ─── 議題聊天室 REST API ──────────────────────────────────────────────────────


@app.get("/topics")
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}


@app.get("/topics/{topic_id}")
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


@app.post("/topics/{topic_id}/ask")
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 = "匿名"


@app.post("/topics")
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"]


@app.get("/chat/models")
def list_models_available():
    """前端顯示用:可選的 LLM 清單 + 目前使用哪個。"""
    return {"providers": _AVAILABLE_PROVIDERS, "current": get_provider()}


@app.get("/chat/roles")
def list_roles_available():
    """前端顯示用:聊天室裡有哪些 AI 角色。"""
    return {"roles": list_chat_roles()}


@app.get("/chat/model")
def get_current_model():
    """查看目前使用的 LLM。"""
    return {"provider": get_provider()}


@app.post("/chat/model")
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")


@app.post("/trigger/roundtable/rfc-macd-ib")
@app.get("/trigger/roundtable/rfc-macd-ib")
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 議題已在背景啟動"}


@app.post("/trigger/roundtable/radar-top3")
@app.get("/trigger/roundtable/radar-top3")
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] = []


@app.post("/support/ask")
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}


@app.get("/health")
def health():
    return {"status": "ok"}