| """FateForge v2 — FastAPI 后端。 |
| |
| 四关卡流转 (Vue 3 SPA): |
| 画面一 缘起: GET / |
| 画面二 窥机: POST /api/cast/seed (前端摇 6 次后,生成卦象) |
| 画面三 破迷: POST /api/read (SSE 流式返回 LLM 解读) |
| 画面四 自渡: 前端展示自渡锦囊 + 拂袖 |
| |
| 免登录, 卦象/解读客户端只放 localStorage。 |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from typing import AsyncIterator |
|
|
| from fastapi import FastAPI, HTTPException |
| from fastapi.responses import FileResponse, StreamingResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel, Field |
|
|
| from .config import STATIC_DIR |
| from .divination import ( |
| all_lines_glyph, |
| cast_six_lines, |
| get_changed_hex, |
| hex_auspicious, |
| hex_auspicious_level, |
| lines_to_hex, |
| moving_indices, |
| ) |
| from .llm import stream_fate_reading |
| from .interpret import build_full_reading, build_full_reading_en |
| from .constants import CATEGORY_UI, HEX_LEVELS, LEVEL_CATEGORY |
|
|
| logging.basicConfig(level=logging.INFO, format="[FF] %(message)s") |
| log = logging.getLogger("fateforge") |
|
|
| app = FastAPI(title="FateForge", version="2.0.0") |
|
|
|
|
| |
| |
| |
| class CastRequest(BaseModel): |
| seed: int | None = Field(default=None, description="可选随机种子,用于复现") |
|
|
|
|
| class ReadRequest(BaseModel): |
| question: str = Field(..., min_length=1, max_length=200) |
| lines: list[int] = Field(..., min_length=6, max_length=6) |
| lang: str = Field(default="zh", description="界面语言: zh | en") |
|
|
|
|
| |
| |
| |
| @app.get("/", include_in_schema=False) |
| async def index() -> FileResponse: |
| return FileResponse(STATIC_DIR / "index.html") |
|
|
|
|
| |
| @app.get("/favicon.ico", include_in_schema=False) |
| async def favicon() -> FileResponse: |
| f = STATIC_DIR / "favicon.svg" |
| return FileResponse(f) if f.exists() else FileResponse(STATIC_DIR / "index.html") |
|
|
|
|
| |
| |
| |
| @app.post("/api/cast/seed") |
| async def cast_seed(req: CastRequest) -> dict: |
| """后端帮前端生成 6 爻(避免前端随机数可预测)。 |
| |
| 返回: |
| lines: [6,7,8,9,7,8] # 自下而上 |
| glyphs: [{value, yang, moving, mark, label}, ...] |
| hex: 主卦完整信息 |
| changed: 变卦 (无动爻时为 null) |
| moving: [3,5] # 动爻位置 (1-indexed) |
| auspicious: '吉' | '平' | '凶' |
| """ |
| lines = cast_six_lines(req.seed) |
| glyphs = all_lines_glyph(lines) |
| hex_info = lines_to_hex(lines) |
| changed = get_changed_hex(lines) |
| moving = moving_indices(lines) |
| level = hex_auspicious_level(hex_info) |
| level_info = HEX_LEVELS.get(hex_info["name"], {}) |
| return { |
| "lines": lines, |
| "glyphs": glyphs, |
| "hex": hex_info, |
| "changed": changed, |
| "moving": moving, |
| "auspicious": hex_auspicious(hex_info), |
| "level": level, |
| "levelInfo": { |
| "tag": level_info.get("tag", "潜龙勿用"), |
| "reason": level_info.get("reason", ""), |
| }, |
| "ui": CATEGORY_UI.get(LEVEL_CATEGORY.get(level, "平"), CATEGORY_UI["平"]), |
| } |
|
|
|
|
| |
| |
| |
| SSE_HEADERS = { |
| "Cache-Control": "no-cache", |
| "X-Accel-Buffering": "no", |
| "Connection": "keep-alive", |
| } |
|
|
|
|
| def _sse_pack(data: dict) -> str: |
| """SSE 协议: data: <json>\n\n""" |
| return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" |
|
|
|
|
| @app.post("/api/read") |
| async def read(req: ReadRequest) -> StreamingResponse: |
| """流式推演缘主困惑 → 卦象 → LLM 解读 (SSE)。 |
| |
| LLM 在线时:直接流式输出 LLM 文本 (含 <听天命> 与 <运在人为> 两段标签)。 |
| LLM 离线 / 超时:降级到 fateforge.interpret 的本地卦象解读,按字流式输出, |
| 保持「水墨渗字」的视觉效果。 |
| """ |
| if not req.lines or len(req.lines) != 6: |
| raise HTTPException(status_code=400, detail="lines 必须是长度为 6 的数组") |
| for v in req.lines: |
| if v not in (6, 7, 8, 9): |
| raise HTTPException(status_code=400, detail="爻值必须为 6/7/8/9") |
|
|
| hex_info = lines_to_hex(req.lines) |
| changed = get_changed_hex(req.lines) |
| moving = moving_indices(req.lines) |
| level = hex_auspicious_level(hex_info) |
| level_info = HEX_LEVELS.get(hex_info["name"], {}) |
| ui = CATEGORY_UI.get(LEVEL_CATEGORY.get(level, "平"), CATEGORY_UI["平"]) |
|
|
| async def gen() -> AsyncIterator[str]: |
| |
| yield _sse_pack({ |
| "type": "meta", |
| "hex": hex_info, |
| "changed": changed, |
| "moving": moving, |
| "auspicious": hex_auspicious(hex_info), |
| "level": level, |
| "levelInfo": { |
| "tag": level_info.get("tag", ui["tag"]), |
| "reason": level_info.get("reason", ""), |
| }, |
| "ui": ui, |
| }) |
| |
| import asyncio |
| llm_chunks: list[str] = [] |
| llm_failed = True |
| try: |
| async with asyncio.timeout(20): |
| async for chunk in stream_fate_reading( |
| req.question, hex_info, changed, moving, |
| level=level, |
| reason=level_info.get("reason", ""), |
| ui=ui, |
| lang=req.lang, |
| ): |
| llm_chunks.append(chunk) |
| llm_failed = False |
| yield _sse_pack({"type": "delta", "text": chunk, "source": "llm"}) |
| except (asyncio.TimeoutError, Exception) as e: |
| if llm_failed: |
| log.warning(f"LLM 不可用,降级本地解读: {type(e).__name__}: {e}") |
| else: |
| |
| log.warning(f"LLM 流中断,补全降级: {e}") |
|
|
| |
| if llm_failed: |
| yield _sse_pack({ |
| "type": "warn", |
| "message": "天机暂不可闻,以下为本座亲推" if req.lang == "zh" else "The way is silent. These notes are mine.", |
| }) |
| |
| full = "".join(llm_chunks) |
| |
| if llm_failed or "<听天命" not in full or "<运在人为" not in full: |
| if req.lang == "en": |
| |
| local_full = build_full_reading_en( |
| req.question, hex_info, moving, changed, |
| level=level, ui=ui, |
| ) |
| if llm_failed: |
| yield _sse_pack({"type": "delta", "text": local_full, "source": "local"}) |
| else: |
| local_full = build_full_reading( |
| req.question, hex_info, moving, changed, |
| level=level, ui=ui, |
| ) |
| if llm_failed: |
| yield _sse_pack({"type": "delta", "text": local_full, "source": "local"}) |
| else: |
| |
| import re |
| if "<听天命" not in full: |
| m1 = re.search(r"<听天命[^>]*>[\s\S]*?</听天命[^>]*>", local_full) |
| if m1: |
| yield _sse_pack({"type": "delta", "text": "\n\n" + m1.group(0), "source": "local"}) |
| if "<运在人为" not in full: |
| m2 = re.search(r"<运在人为[^>]*>[\s\S]*?</运在人为[^>]*>", local_full) |
| if m2: |
| yield _sse_pack({"type": "delta", "text": "\n\n" + m2.group(0), "source": "local"}) |
|
|
| |
| yield _sse_pack({"type": "done", "source": "local" if llm_failed else "llm"}) |
|
|
| return StreamingResponse(gen(), media_type="text/event-stream", headers=SSE_HEADERS) |
|
|
|
|
| |
| |
| |
| if STATIC_DIR.exists(): |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") |
|
|