tianwen / app.py
liuyd-dev's picture
enable self-hosted CPU llama.cpp inference
7cedf8e verified
Raw
History Blame Contribute Delete
47.8 kB
"""天问 (Tianwen) — gradio.Server 后端 + React (shadcn) 前端。
启动: python app.py -> http://127.0.0.1:7860/
前端: frontend/ (Vite + React), 唯一前端; 构建产物 frontend/dist 由后端托管。
静态数据 (生肖图 / 城市库) 随前端置于 frontend/public/data, 构建后经 /data 提供。
架构 (参照 docs/Any Custom Frontend with Gradio's Backend.md):
- @app.api() 端点走 Gradio 队列, 前端 fetch 两步调用:
POST /gradio_api/call/<name> {"data": [...]} -> {event_id}
GET /gradio_api/call/<name>/<event_id> -> SSE "event: complete"
- 流式解读走 FastAPI 原生 /stream/* (SSE), 见文末路由。
- 排盘 / 解读 / 安全护栏全部在本地 Python 完成, 无外部请求。
"""
from __future__ import annotations
import base64
import datetime as _dt
import json
import os
import random
import socket
import sys
import tempfile
from pathlib import Path
from typing import Any
# 本机代理(如 Clash) 会拦截 Gradio 对 localhost 的启动自检,必须绕过
os.environ.setdefault("NO_PROXY", "127.0.0.1,localhost")
os.environ.setdefault("no_proxy", "127.0.0.1,localhost")
# HF Space: 强制 Gradio 绑 0.0.0.0:7860 并关闭 SSR 代理层(即使平台以 import 方式拉起也生效)
if os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST"):
os.environ.setdefault("GRADIO_SERVER_NAME", "0.0.0.0")
os.environ.setdefault("GRADIO_SERVER_PORT", "7860")
os.environ.setdefault("GRADIO_SSR_MODE", "False")
# Windows shim: 阻止 spaces 拉起 GPU
if sys.platform == "win32":
import types
_mock = types.ModuleType("spaces")
_mock.GPU = lambda *a, **kw: (a[0] if (a and callable(a[0])) else (lambda f: f))
sys.modules["spaces"] = _mock
# gradio 6 在 Windows 上 import 时会出现 gradio_client 子模块循环依赖,
# 表现为 import gradio 静默挂起。预先 import gradio_client 解决。
try:
from gradio_client import utils as _gc_utils # noqa: F401
from gradio_client.client import Client as _GCClient # noqa: F401
except Exception:
pass
from gradio import Server
from gradio.data_classes import FileData
from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from lunar_python import Solar
from bazi.engine import compute_five_dim
from bazi.five_elements import SHENGXIAO_ORDER, describe_relation
from bazi.shensha_cards import SHEN_SHA_CARDS, JI_SHEN as _JI_SHEN
from divination.coin import Cast, Line, COIN_SUM_TO_LINE, LINE_VALUE_TO_NAME
from divination.hexagram import hex_from_six_lines, get_hexagram
from divination.interpret import build_reading
from prompts.templates import (
daily_narrative, cast_narrative, micro_action,
daily_narrative_stream, cast_narrative_stream,
natal_narrative_stream, year_narrative_stream, month_narrative_stream,
)
from prompts.llm_client import (
set_runtime_config as _llm_set_config,
get_runtime_config as _llm_get_config,
is_reachable as _llm_reachable,
)
from lunar_python.util import LunarUtil
from safety.intercept import check as safety_check, HELPLINES
from storage import profiles as prof_store
from storage import readings as read_store
from storage import divination_log as div_store
from storage import mood_log as mood_store
from storage import backup as bk
from storage.fs import EXPORTS_DIR
from ui.summary_image import render_daily_image
APP_TITLE = "天问"
APP_VERSION = "V3.0"
SAFETY_VERSION = "v3.0.0"
LLM_VERSION = "MiniCPM5-1B (fine-tuned, llama.cpp) + 规则模板兜底"
DIST_DIR = Path(__file__).resolve().parent / "frontend" / "dist" # React (shadcn) 构建产物
ONBOARDING_QUOTES = [
"天行健,君子以自强不息。",
"地势坤,君子以厚德载物。",
"穷则变,变则通,通则久。",
"君子以反身修德。",
"观乎天文,以察时变。",
"云在青天,水在瓶。",
]
# 大运十神关系 -> 积极心理学叙事
_DAYUN_NARRATIVE = {
"同类": "同气相求的十年。自我力量得到同伴支援,适合结盟协作、巩固自己的立场,也请留意人际边界,把精力收回自己身上。",
"生我": "滋养与吸收的十年。资源与贵人之气涌入,是学习、休整、向内积累的好时机,允许自己慢下来,被照顾也是一种能力。",
"我生": "表达与创造的十年。才华向外流淌,适合输出作品、教学相长;留意精力管理,创造之余记得给自己蓄水。",
"我克": "开拓与收获的十年。你对资源有更强的掌控力,适合主动布局事业与财务;收获时也练习分享,让流动持续。",
"克我": "磨砺与塑形的十年。外部压力像刻刀,雕出更清晰的自我边界。压力大时,把它视作修炼心理韧性的道场,蓄力以待。",
}
# 干支 / 神煞 长按解释卡片(规则字典,防幻觉:不由生成式输出)
_TERM_CARDS = {
"甲": "甲木,参天大树之象。代表正直向上的生长力,适合开创。",
"乙": "乙木,花草藤蔓之象。柔韧而善于借势,适合协作与渗透。",
"丙": "丙火,太阳之火。热情坦荡,照亮他人,留意过度燃烧。",
"丁": "丁火,灯烛之火。细腻持久的温暖,适合深耕与陪伴。",
"戊": "戊土,高山厚土。稳重可靠,是他人依靠,也要给自己松土。",
"己": "己土,田园之土。包容滋养,善于成全,记得也滋养自己。",
"庚": "庚金,刀剑之金。果决锐利,长于裁断,用在创作上是利器。",
"辛": "辛金,珠玉之金。精致敏锐,重视品质,留意完美主义。",
"壬": "壬水,江河之水。奔流不息的行动力与适应力,宜疏不宜堵。",
"癸": "癸水,雨露之水。安静的洞察与共情,是滋润万物的智慧。",
"子": "子水,夜半之时。万物萌动之始,代表潜藏的智慧与直觉。",
"丑": "丑土,凌晨之时。寒土孕金,代表沉默的积累与韧性。",
"寅": "寅木,黎明之时。生机破土,代表勇气与新的开始。",
"卯": "卯木,日出之时。草木舒展,代表生长、社交与柔韧。",
"辰": "辰土,上午之时。水库之土,包容变化,代表蓄势与格局。",
"巳": "巳火,近午之时。文明之火,代表表达、礼仪与洞见。",
"午": "午火,正午之时。光明炽盛,代表行动力与影响力的顶点。",
"未": "未土,午后之时。燥土养木,代表温和的坚持与照料。",
"申": "申金,傍晚之时。代表变通、技艺与解决问题的巧思。",
"酉": "酉金,日落之时。代表精纯、审美与对秩序的追求。",
"戌": "戌土,黄昏之时。代表忠诚、守护与对信念的坚持。",
"亥": "亥水,入夜之时。代表包容、想象力与回归本真。",
"天乙贵人": "众神之首的吉星,象征贵人扶助。心理学视角:你善于建立支持系统,困境中总有人愿意伸手。",
"文昌": "主聪明好学之星。代表表达与学习的天赋,适合以文字与思考立身。",
"驿马": "主变动远行之星。心有四方,变化即机会,拥抱流动的人生。",
"桃花": "主人缘魅力之星。亲和力是天赋,善用它建立滋养型关系。",
"华盖": "主艺术孤高之星。独处时能量最盛,适合艺术、哲思与修行。",
"羊刃": "锐利而专注的能量,用在创作与攻坚上是利器,留意刚极易折,配合柔性节奏。",
"七杀": "外部压力较大的信号,适合借此修炼心理边界,把压力转译为目标。",
"劫煞": "提醒守护自己资源与精力的信号,练习温和而坚定地说不。",
"灾煞": "波动期的提示,放慢节奏、降低杠杆,把注意力放回可控之事。",
"孤辰": "独处倾向的信号。孤独不是缺陷,是与自己深度相处的邀请。",
"寡宿": "内省倾向的信号。安静的时光里,完成与自我的和解。",
}
# ---- 英文对照 (默认英语用户需要看懂; 五行 / 关系 / 大运 / 干支神煞) ----
_ELEMENT_EN = {"金": "Metal", "木": "Wood", "水": "Water", "火": "Fire", "土": "Earth"}
_RELATION_EN = {"同类": "Companion", "生我": "Resource", "我生": "Output", "我克": "Wealth", "克我": "Influence"}
_DAYUN_NARRATIVE_EN = {
"同类": "A decade of kindred energy. Your sense of self is backed by peers — good for alliances and consolidating your ground; mind your boundaries and keep some energy for yourself.",
"生我": "A decade of nourishment. Resources and mentors flow in — a fine time to learn, rest, and build inwardly. Let yourself slow down; being cared for is a skill too.",
"我生": "A decade of expression. Talent flows outward — good for creating, teaching, and sharing. Watch your energy and refill the well between bursts.",
"我克": "A decade of initiative. You hold firmer command of resources — good for shaping career and finances; practice sharing your gains so the flow continues.",
"克我": "A decade of tempering. Outside pressure is like a chisel, carving a clearer self. When it weighs on you, treat it as a dojo for resilience and gather strength.",
}
_TERM_CARDS_EN = {
"甲": "Jiǎ wood — the towering tree. Upright, upward-growing strength; suited to pioneering.",
"乙": "Yǐ wood — vines and grasses. Supple, good at leveraging circumstance; suited to cooperation.",
"丙": "Bǐng fire — the sun. Warm and open, lighting others; mind burning out.",
"丁": "Dīng fire — lamp and candle. A gentle, lasting warmth; suited to deep, steady work and companionship.",
"戊": "Wù earth — the high mountain. Steady and dependable, a rock for others; remember to loosen your own soil.",
"己": "Jǐ earth — garden soil. Nurturing and accommodating, good at enabling others; nourish yourself too.",
"庚": "Gēng metal — blade and sword. Decisive and sharp; a fine tool when turned to creation.",
"辛": "Xīn metal — jewel and gem. Refined and keen, valuing quality; watch perfectionism.",
"壬": "Rén water — the river. Ceaseless drive and adaptability; better guided than dammed.",
"癸": "Guǐ water — dew and rain. Quiet insight and empathy; the wisdom that nourishes all things.",
"子": "Zǐ (midnight). The stirring start of all things — latent wisdom and intuition.",
"丑": "Chǒu (before dawn). Cold earth holding metal — silent accumulation and resilience.",
"寅": "Yín (daybreak). Life breaking through soil — courage and new beginnings.",
"卯": "Mǎo (sunrise). Plants unfurling — growth, sociability, suppleness.",
"辰": "Chén (morning). Reservoir earth holding change — gathering momentum and scope.",
"巳": "Sì (late morning). The fire of culture — expression, ritual, insight.",
"午": "Wǔ (noon). Blazing light — the peak of drive and influence.",
"未": "Wèi (afternoon). Dry earth feeding wood — gentle persistence and care.",
"申": "Shēn (dusk). Adaptability, craft, and clever problem-solving.",
"酉": "Yǒu (sunset). Refinement, aesthetics, and a love of order.",
"戌": "Xū (twilight). Loyalty, guardianship, devotion to one's convictions.",
"亥": "Hài (nightfall). Openness, imagination, and a return to one's true self.",
"天乙贵人": "Noble Mentor star — a sign of helpful patrons. In plain terms: you build support systems well; in hard times, someone tends to lend a hand.",
"文昌": "Scholar star — a gift for learning and expression; suited to a life built on words and thought.",
"驿马": "Travelling Horse star — movement and journeys; change is opportunity, embrace a life in motion.",
"桃花": "Peach Blossom star — charm and rapport; your warmth is a gift for building nourishing bonds.",
"华盖": "Canopy star — artistry and solitude; your energy peaks alone, suited to art, philosophy, and practice.",
"羊刃": "Sharp, focused energy — a fine tool for creating and breaking through; the rigid snaps, so pair it with a softer rhythm.",
"七杀": "A sign of strong outside pressure — good for tempering your boundaries and turning pressure into goals.",
"劫煞": "A reminder to guard your resources and energy — practice saying no, gently and firmly.",
"灾煞": "A note of turbulence — slow down, reduce leverage, return attention to what you can control.",
"孤辰": "A sign of solitude — being alone isn't a flaw; it's an invitation to deep time with yourself.",
"寡宿": "A sign of inwardness — in the quiet, make peace with yourself.",
}
def _today() -> _dt.date:
return _dt.date.today()
def _today_day_gan() -> str:
try:
lunar = Solar.fromYmd(_today().year, _today().month, _today().day).getLunar()
return lunar.getDayGan()
except Exception:
return "甲"
_SHENGXIAO_BY_YEAR = list(SHENGXIAO_ORDER)
def _profile_brief(p: dict[str, Any]) -> dict[str, Any]:
zodiac = p.get("zodiac", "")
if not zodiac:
y = (p.get("birth") or {}).get("year")
zodiac = _SHENGXIAO_BY_YEAR[(int(y) - 4) % 12] if y else ""
# 四柱八字(名片展示用): 创建时已存;旧档案懒算并回填缓存
bazi = p.get("bazi")
if not bazi and (p.get("birth") or {}).get("year"):
try:
rd = _compute_reading(p)
bazi = [pl["gan"] + pl["zhi"] for pl in rd["ming"]["pillars"]]
p["bazi"] = bazi
prof_store.save(p)
except Exception:
bazi = []
return {"id": p["id"], "name": p.get("name", "未命名"), "birth": p.get("birth", {}),
"zodiac": zodiac, "bazi": bazi or []}
def _list_profiles(device_id: str = "") -> list[dict[str, Any]]:
"""仅返回归属于该设备的档案(隐私:档案与创建它的设备/浏览器绑定)。"""
did = (device_id or "").strip()
return [
_profile_brief(p) for p in prof_store.list_profiles()
if p.get("owner") == did and did
]
def _load_owned(profile_id: str, device_id: str) -> dict[str, Any] | None:
"""加载档案并校验设备归属,不属于该设备视同不存在。"""
prof = prof_store.load(profile_id)
if not prof or prof.get("owner") != (device_id or "").strip():
return None
return prof
def _compute_reading(profile: dict[str, Any]) -> dict[str, Any]:
b = profile["birth"]
rd = compute_five_dim(
b["year"], b["month"], b["day"], b["hour"], b.get("minute", 0), b.get("gender", 1),
tz_offset=float(b.get("tz_offset", 8.0)),
longitude=float(b.get("longitude", 120.0)),
southern=bool(b.get("southern", False)),
).to_dict()
rd["profile"] = {"name": profile.get("name", "您"), "id": profile["id"]}
return rd
def _dayun_brief_text(reading: dict[str, Any], index: int, lang: str = "zh") -> str:
en = lang == "en"
yun = reading.get("yun", {}) or {}
stages = yun.get("dayun_list", []) or []
if not stages or index < 0 or index >= len(stages):
return "This phase is still in the mist." if en else "此运尚在云雾之中。"
st = stages[index]
if st.get("ganzhi") == "童限":
if en:
return (
f"The Early Years (ages 0-{st['end_age']}) are the nurturing time before the luck phases begin. "
f"Their tone is written by your year pillar and your parents' shelter — like a seed gathering its first breath "
f"in the soil. Looking back at them is a clue to your original temperament."
)
return (
f"童限 (0-{st['end_age']} 岁), 是大运起步前的蒙养之年。这段时光的底色由年柱与父母的庇护写就,"
f"如同种子在土壤中积蓄第一口气。回望它,是理解自己最初性情的线索。"
)
ming = reading.get("ming", {})
pillars = ming.get("pillars") or []
day_el = pillars[2]["gan_element"] if len(pillars) > 2 else "土"
rel = describe_relation(day_el, st.get("gan_element", day_el))
cur = yun.get("current_index", -1)
if en:
body = _DAYUN_NARRATIVE_EN.get(rel, "Ups and downs are the norm — cultivate in fair seasons, gather strength in lean ones.")
when = "current" if index == cur else ("past" if index < cur else "coming")
return (
f"The {st['ganzhi']} luck phase (ages {st['start_age']}-{st['end_age']}, {st['start_year']}-{st['end_year']}) "
f"is your {when} stretch. Its stem {_ELEMENT_EN.get(st['gan_element'], st['gan_element'])} energy forms a "
f"“{_RELATION_EN.get(rel, rel)}” dynamic with your day master's {_ELEMENT_EN.get(day_el, day_el)}. {body} "
f"Ups and downs are natural — each phase carries its own lesson and gift."
)
body = _DAYUN_NARRATIVE.get(rel, "起伏本是常态,顺境耕作,低谷蓄力。")
when = "当前所处的" if index == cur else ("已经走过的" if index < cur else "未来将至的")
return (
f"{st['ganzhi']} 大运 ({st['start_age']}-{st['end_age']} 岁,"
f"{st['start_year']}-{st['end_year']} 年), 是您{when}一程。"
f"天干{st['gan_element']}气与您的日主{day_el}气构成「{rel}」之势。{body}"
f" 起伏本是常态,每一运都有它的功课与礼物。"
)
app = Server()
# ============================ API: 启动与档案 ============================
@app.api()
def bootstrap(device_id: str) -> dict:
"""前端启动握手:本设备的档案列表 + 今日信息 + 版本与热线。"""
did = (device_id or "").strip()
if did:
# 一次性迁移:早期未标记归属的档案,归首个访问的设备
for p in prof_store.list_profiles():
if not p.get("owner"):
p["owner"] = did
prof_store.save(p)
return {
"profiles": _list_profiles(did),
"quote": random.choice(ONBOARDING_QUOTES),
"today": _today().isoformat(),
"zodiacs": list(SHENGXIAO_ORDER),
"version": {"app": APP_VERSION, "safety": SAFETY_VERSION, "llm": LLM_VERSION},
"helplines": [{"name": n, "tel": t} for n, t in HELPLINES],
}
@app.api()
def create_profile(
name: str, year: int, month: int, day: int, hour: int, minute: int,
gender: int, tz_offset: float, longitude: float, southern: bool,
device_id: str, tz_name: str,
) -> dict:
did = (device_id or "").strip()
if not (name or "").strip():
return {"ok": False, "error": "请填写姓名。", "profiles": _list_profiles(did)}
if not did:
return {"ok": False, "error": "设备标识缺失,请刷新页面。", "profiles": []}
try:
offset = float(tz_offset if tz_offset is not None else 8.0)
tzn = (tz_name or "").strip()
if tzn:
# IANA 时区(来自 GeoNames 城市库): 按出生日期算偏移,自动含夏令时
try:
from zoneinfo import ZoneInfo
birth_dt = _dt.datetime(int(year), int(month), int(day), int(hour), int(minute or 0),
tzinfo=ZoneInfo(tzn))
offset = birth_dt.utcoffset().total_seconds() / 3600.0
except Exception:
tzn = ""
prof = prof_store.make_profile(
name.strip(), int(year), int(month), int(day), int(hour), int(minute or 0), int(gender)
)
prof["birth"].update({
"tz_offset": offset,
"tz_name": tzn,
"longitude": float(longitude if longitude is not None else 120.0),
"southern": bool(southern),
})
prof["owner"] = did
reading = _compute_reading(prof) # 先验证生辰可排盘,再落盘保存
prof["zodiac"] = reading["ming"]["zodiac"] # 生肖以排盘(农历年界) 为准
prof["bazi"] = [pl["gan"] + pl["zhi"] for pl in reading["ming"]["pillars"]]
prof_store.save(prof)
read_store.save(_today(), prof["id"], reading)
return {
"ok": True,
"profile_id": prof["id"],
"profiles": _list_profiles(did),
"zodiac": reading["ming"]["zodiac"],
"pillars": reading["ming"]["pillars"],
"solar_corrected": reading["birth"].get("solar_corrected", ""),
}
except Exception as exc:
return {"ok": False, "error": f"暂不支持此时间范围:{exc}", "profiles": _list_profiles(did)}
@app.api()
def delete_profile(profile_id: str, device_id: str) -> dict:
if _load_owned(profile_id, device_id):
prof_store.delete(profile_id)
return {"ok": True, "profiles": _list_profiles(device_id)}
# ============================ API: 五维解读 ============================
def _daily_texts(profile_id: str, reading: dict[str, Any], lang: str = "zh") -> tuple[str, list[str]]:
"""微行动(规则引擎,快) + 缓存的日运叙事。
日运叙事不在此处阻塞生成:当日缓存命中则返回全文,否则返回空串,
由前端调流式端点 stream_daily 逐字生成 (PRD 10.2 流式输出)。
"""
cached = read_store.load(_today(), profile_id) or {}
narrative = cached.get("_narrative") or ""
cache_key = "_actions" if lang == "zh" else f"_actions_{lang}"
actions = cached.get(cache_key) or micro_action(reading, lang=lang)
reading["_narrative"] = narrative
reading["_actions"] = actions
return narrative, actions
@app.api()
def get_reading(profile_id: str, device_id: str, lang: str = "zh") -> dict:
"""完整五维数据 + 日运叙事 + 微行动 + 今日心境。"""
prof = _load_owned(profile_id, device_id)
if not prof:
return {"ok": False, "error": "档案不存在"}
reading = _compute_reading(prof)
narrative, actions = _daily_texts(profile_id, reading, lang)
mood = mood_store.load(_today(), profile_id)
return {
"ok": True,
"reading": reading,
"narrative": narrative,
"actions": actions,
"mood": mood,
"current_dayun_brief": _dayun_brief_text(reading, reading.get("yun", {}).get("current_index", 0), lang),
}
@app.api()
def dayun_brief(profile_id: str, index: int, device_id: str, lang: str = "zh") -> dict:
prof = _load_owned(profile_id, device_id)
if not prof:
return {"ok": False, "error": "档案不存在"}
reading = _compute_reading(prof)
return {"ok": True, "text": _dayun_brief_text(reading, int(index), lang)}
@app.api()
def explain_term(term: str, lang: str = "zh") -> dict:
key = (term or "").strip()
if lang == "en":
text = _TERM_CARDS_EN.get(key)
if not text and key in SHEN_SHA_CARDS:
if key in _JI_SHEN:
text = "An auspicious almanac spirit for this day — read it as a gentle tailwind: a good day to lean into what it favors, not a guarantee."
else:
text = "A cautionary almanac spirit — not an omen of doom, just a nudge to slow down, double-check, and protect your energy today."
return {"ok": True, "term": term,
"text": text or "A faint light in your chart. Every symbol here is a small window for looking at yourself — meet it with curiosity, not a rush to judge fortune."}
text = _TERM_CARDS.get(key) or SHEN_SHA_CARDS.get(key)
return {"ok": True, "term": term,
"text": text or "这是命盘中的一处微光。每个符号都是观照自己的一扇小窗——带着好奇去看它,而非急着论断吉凶。"}
# ── LLM API 接口配置 (可在设置中切换不同的 OpenAI-compatible 服务) ──────────────
_LLM_CFG_FILE = Path(__file__).resolve().parent / "data" / "llm_config.json"
def _load_llm_cfg() -> None:
"""启动时载入已保存的 LLM 配置 (endpoint/key/model)。"""
try:
if _LLM_CFG_FILE.exists():
cfg = json.loads(_LLM_CFG_FILE.read_text(encoding="utf-8-sig"))
_llm_set_config(cfg.get("base"), cfg.get("key"), cfg.get("model"))
except Exception:
pass
@app.api()
def set_llm_config(endpoint: str = "", api_key: str = "", model: str = "") -> dict:
"""配置 LLM API: endpoint(base url) + secret(api key) + 可选 model。
空串表示清除该项, 回落到默认。配置持久化到 data/llm_config.json。"""
_llm_set_config(endpoint, api_key, model)
try:
_LLM_CFG_FILE.parent.mkdir(parents=True, exist_ok=True)
# key 也落盘 (本机单用户场景); 如需更安全可改为仅存内存
_LLM_CFG_FILE.write_text(json.dumps(
{"base": endpoint.strip(), "key": api_key.strip(), "model": model.strip()},
ensure_ascii=False,
), encoding="utf-8")
except Exception as e:
return {"ok": False, "error": str(e)}
reachable = _llm_reachable()
cfg = _llm_get_config()
return {"ok": True, "reachable": reachable, "base": cfg["base"], "model": cfg["model"]}
@app.api()
def get_llm_config() -> dict:
"""返回当前生效的 LLM 配置 (不回传 key 明文),并探活。"""
cfg = _llm_get_config()
return {"ok": True, "base": cfg["base"], "model": cfg["model"],
"has_key": cfg["has_key"], "reachable": _llm_reachable()}
_load_llm_cfg()
# ============================ API: 摇卦 ============================
@app.api()
def cast_begin(question: str) -> dict:
"""书愿:危机词命中则熔断,直接返回安慰文案与热线。"""
q = (question or "").strip()
if not q:
return {"ok": False, "error": "请先落笔心中所念之事。"}
hit, payload = safety_check(q)
if hit:
return {
"ok": False, "blocked": True, "message": payload,
"helplines": [{"name": n, "tel": t} for n, t in HELPLINES],
}
return {"ok": True, "question": q}
@app.api()
def toss() -> dict:
"""掷三枚铜钱,成一爻。"""
coins = [random.randint(0, 1) for _ in range(3)] # 1=正(阳面) 0=反
s = sum(3 if c == 0 else 2 for c in coins)
value = COIN_SUM_TO_LINE[s]
return {
"coins": coins,
"sum": s,
"kind": LINE_VALUE_TO_NAME[value],
"is_yang": value in (7, 9),
"is_changing": value in (6, 9),
}
@app.api()
def cast_complete(question: str, lines: list, lang: str = "zh") -> dict:
"""六爻已成:排出本卦/变卦,生成六亲六神与解卦叙事。"""
if not lines or len(lines) != 6:
return {"ok": False, "error": "六爻未成,请继续摇卦。"}
line_objs = [
Line(index=i, coins=ld["coins"], sum=ld["sum"], kind=ld["kind"],
is_yang=ld["is_yang"], is_changing=ld["is_changing"])
for i, ld in enumerate(lines, start=1)
]
ben_bits = [1 if ld["is_yang"] else 0 for ld in lines]
ben_code = hex_from_six_lines(ben_bits)
bian_bits = [(1 - b) if ld["is_changing"] else b for b, ld in zip(ben_bits, lines)]
bian_code = hex_from_six_lines(bian_bits)
cast = Cast(
lines=line_objs, ben_code=ben_code, bian_code=bian_code,
ben_hex=get_hexagram(ben_code), bian_hex=get_hexagram(bian_code),
changing_indices=[i for i, ld in enumerate(lines, start=1) if ld["is_changing"]],
question=(question or "").strip(),
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
)
cr = build_reading(cast, day_gan=_today_day_gan()).to_dict()
cr["question"] = (question or "").strip()
cr["created_at"] = cast.created_at
# 解卦叙事由前端调 stream_cast 流式生成(不在此阻塞); 微行动为规则引擎,即时
actions = micro_action(cr, lang=lang)
return {
"ok": True,
"reading": cr,
"ben": cr["ben"],
"bian": cr["bian"],
"ben_bits": ben_bits,
"bian_bits": bian_bits,
"changing": cast.changing_indices,
"narrative": "",
"actions": actions,
}
# ---- 流式解读: FastAPI 原生 SSE (gradio generator 不能序列化中间 yield, 故走原生路由) ----
# 每个事件为 data: {"text": <当前累积全文>}\n\n; 前端直接替换显示。
def _sse(text: str) -> str:
return "data: " + json.dumps({"text": text}, ensure_ascii=False) + "\n\n"
def _liunian_info(reading: dict[str, Any], birth: dict[str, Any]) -> dict[str, Any]:
"""今年流年:干支 / 五行 / 虚岁 / 与日主关系(流年按年中取,避开立春边界歧义)。"""
year = _today().year
gz = Solar.fromYmd(year, 6, 1).getLunar().getYearInGanZhi()
gan_el = LunarUtil.WU_XING_GAN.get(gz[0], "—") if gz else "—"
zhi_el = LunarUtil.WU_XING_ZHI.get(gz[1], "—") if len(gz) > 1 else "—"
pillars = reading.get("ming", {}).get("pillars") or []
day_el = pillars[2]["gan_element"] if len(pillars) > 2 else "土"
return {
"year": year,
"ganzhi": gz,
"gan_element": gan_el,
"zhi_element": zhi_el,
"age": year - int(birth.get("year", year)) + 1, # 虚岁
"relation": describe_relation(day_el, gan_el),
}
_TIANGAN = "甲乙丙丁戊己庚辛壬癸"
_MONTH_ZHI = ["寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥", "子", "丑"]
def _liuyue_list(reading: dict[str, Any], ln: dict[str, Any]) -> list[dict[str, Any]]:
"""十二流月干支(五虎遁年起月) + 与日主关系。"""
year_gan = (ln.get("ganzhi") or "甲")[0]
yi = _TIANGAN.find(year_gan)
base = ((yi % 5) * 2 + 2) % 10 if yi >= 0 else 2 # 正月天干索引(甲己丙作首…)
pillars = reading.get("ming", {}).get("pillars") or []
day_el = pillars[2]["gan_element"] if len(pillars) > 2 else "土"
out = []
for m in range(12):
gan = _TIANGAN[(base + m) % 10]
gan_el = LunarUtil.WU_XING_GAN.get(gan, "—")
out.append({
"month": m + 1,
"ganzhi": gan + _MONTH_ZHI[m],
"gan_element": gan_el,
"relation": describe_relation(day_el, gan_el),
})
return out
# 正念话固定话语集 (按语言 × 进度), 零延迟, 无需 LLM
_MINDFUL = {
"zh": {
"none": "新的一年如十二段节气,尚未一一展开。不必心急,一月一月地看,自有分晓。",
"all": "今年十二个月您都已照见。心里有数,便是最大的安稳——其余的,交给时间。",
"some": "您已照见今年的一部分光景。看得见的,便不再只是焦虑;慢慢来,其余的会在合适的时候清晰起来。",
},
"en": {
"none": "The year is twelve seasons, not yet unfolded. No need to rush — take it one month at a time, and clarity will come.",
"all": "You have seen all twelve months of this year. To know is the steadiest comfort; leave the rest to time.",
"some": "You have glimpsed part of this year. What you can see is no longer mere worry — go slowly, the rest will clear in its own time.",
},
}
def _year_mindful(done_count: int, year_done: bool, lang: str = "zh") -> str:
"""据已推演进度生成一两句正念话 (固定话语集, 按语言切换, 零延迟)。"""
table = _MINDFUL.get(lang, _MINDFUL["zh"])
if done_count <= 0 and not year_done:
return table["none"]
if done_count >= 12:
return table["all"]
return table["some"]
def _year_cache(prof: dict[str, Any], year: int) -> dict[str, Any]:
return (prof.get("year_cache") or {}).get(str(year), {})
@app.api()
def year_overview(profile_id: str, device_id: str, lang: str = "zh") -> dict:
"""年运 Tab 的结构化数据:流年 + 十二流月(含已缓存的解读) + 正念话。"""
prof = _load_owned(profile_id, device_id)
if not prof:
return {"ok": False, "error": "档案不存在"}
reading = _compute_reading(prof)
yun = reading.get("yun", {})
cur = (yun.get("dayun_list") or [{}])[yun.get("current_index", 0)] if yun.get("dayun_list") else {}
ln = _liunian_info(reading, prof["birth"])
year = ln["year"]
yc = _year_cache(prof, year)
months = _liuyue_list(reading, ln)
for m in months:
m["text"] = yc.get(f"m{m['month']}", "")
m["done"] = bool(m["text"])
done_count = sum(1 for m in months if m["done"])
year_text = yc.get("year", "")
return {
"ok": True,
"liunian": ln,
"current_dayun": cur,
"months": months,
"year_text": year_text,
"year_done": bool(year_text),
"mindful": _year_mindful(done_count, bool(year_text), lang),
}
class _StreamNatalReq(BaseModel):
profile_id: str
device_id: str
lang: str = "zh"
@app.post("/stream/natal")
def stream_natal_route(req: _StreamNatalReq):
def gen():
prof = _load_owned(req.profile_id, req.device_id)
if not prof:
yield _sse("档案不存在"); return
# 仅缓存中文版;英文每次实时生成,避免旧的中文缓存被当英文返回
if req.lang == "zh":
cached = (read_store.load(_today(), req.profile_id) or {}).get("_natal")
if cached:
yield _sse(cached); return
reading = _compute_reading(prof)
acc = ""
for piece in natal_narrative_stream(reading, req.lang):
acc += piece
yield _sse(acc)
hit, payload = safety_check(acc)
if hit:
acc = payload
yield _sse(payload)
if req.lang == "zh":
rec = read_store.load(_today(), req.profile_id) or {}
rec["_natal"] = acc
try:
read_store.save(_today(), req.profile_id, {**reading, **rec})
except Exception:
pass
return StreamingResponse(gen(), media_type="text/event-stream")
def _save_year_cache(prof: dict[str, Any], year: int, key: str, text: str) -> None:
"""把本年/某月解读写入档案的 year_cache (中文版,推完即缓存,再进直接展示)。"""
fresh = prof_store.load(prof["id"]) or prof
yc = fresh.setdefault("year_cache", {})
yc.setdefault(str(year), {})[key] = text
try:
prof_store.save(fresh)
except Exception:
pass
@app.post("/stream/year")
def stream_year_route(req: _StreamNatalReq):
def gen():
prof = _load_owned(req.profile_id, req.device_id)
if not prof:
yield _sse("档案不存在"); return
reading = _compute_reading(prof)
ln = _liunian_info(reading, prof["birth"])
reading["liunian"] = ln
if req.lang == "zh":
cached = _year_cache(prof, ln["year"]).get("year")
if cached:
yield _sse(cached); return
acc = ""
for piece in year_narrative_stream(reading, req.lang):
acc += piece
yield _sse(acc)
hit, payload = safety_check(acc)
if hit:
acc = payload
yield _sse(payload)
if req.lang == "zh":
_save_year_cache(prof, ln["year"], "year", acc)
return StreamingResponse(gen(), media_type="text/event-stream")
class _StreamMonthReq(BaseModel):
profile_id: str
device_id: str
month: int
lang: str = "zh"
@app.post("/stream/month")
def stream_month_route(req: _StreamMonthReq):
def gen():
prof = _load_owned(req.profile_id, req.device_id)
if not prof:
yield _sse("档案不存在"); return
m = int(req.month)
if not 1 <= m <= 12:
yield _sse("月份无效"); return
reading = _compute_reading(prof)
ln = _liunian_info(reading, prof["birth"])
if req.lang == "zh":
cached = _year_cache(prof, ln["year"]).get(f"m{m}")
if cached:
yield _sse(cached); return
reading["liunian"] = ln
reading["liuyue"] = _liuyue_list(reading, ln)[m - 1]
acc = ""
for piece in month_narrative_stream(reading, req.lang):
acc += piece
yield _sse(acc)
hit, payload = safety_check(acc)
if hit:
acc = payload
yield _sse(payload)
if req.lang == "zh":
_save_year_cache(prof, ln["year"], f"m{m}", acc)
return StreamingResponse(gen(), media_type="text/event-stream")
class _StreamDailyReq(BaseModel):
profile_id: str
device_id: str
lang: str = "zh"
class _StreamCastReq(BaseModel):
cast_reading: dict
device_id: str
lang: str = "zh"
@app.post("/stream/daily")
def stream_daily_route(req: _StreamDailyReq):
def gen():
prof = _load_owned(req.profile_id, req.device_id)
if not prof:
yield _sse("档案不存在"); return
cached = (read_store.load(_today(), req.profile_id) or {}) if req.lang == "zh" else {}
if cached.get("_narrative"):
yield _sse(cached["_narrative"]); return
reading = _compute_reading(prof)
acc = ""
for piece in daily_narrative_stream(reading, req.lang):
acc += piece
yield _sse(acc)
hit, payload = safety_check(acc)
if hit:
acc = payload
yield _sse(payload)
if req.lang == "zh": # 仅缓存中文版,英文每次实时生成
reading["_narrative"] = acc
reading["_actions"] = micro_action(reading)
try:
read_store.save(_today(), req.profile_id, reading)
except Exception:
pass
return StreamingResponse(gen(), media_type="text/event-stream")
@app.post("/stream/cast")
def stream_cast_route(req: _StreamCastReq):
def gen():
cr = req.cast_reading
if not cr or not cr.get("ben"):
yield _sse("卦象待生成,让我们一起静心。"); return
acc = ""
for piece in cast_narrative_stream(cr, req.lang):
acc += piece
yield _sse(acc)
hit, payload = safety_check(acc)
if hit:
yield _sse(payload)
return StreamingResponse(gen(), media_type="text/event-stream")
@app.api()
def save_cast(cast_reading: dict, device_id: str) -> dict:
if not cast_reading or not cast_reading.get("ben"):
return {"ok": False, "error": "无可保存的卦例。"}
rec = dict(cast_reading)
rec["owner"] = (device_id or "").strip()
rec_id = div_store.save(rec)
return {"ok": True, "id": rec_id, "casts": list_casts(device_id)["casts"]}
@app.api()
def list_casts(device_id: str) -> dict:
did = (device_id or "").strip()
out = []
records = [d for d in div_store.list_all() if d.get("owner") == did and did]
for d in records[:50]:
ben = d.get("ben") if isinstance(d.get("ben"), dict) else {}
bian = d.get("bian") if isinstance(d.get("bian"), dict) else {}
out.append({
"id": d.get("id", ""),
"question": d.get("question", "") or "心中挂碍",
"ben_name": ben.get("name", "—"),
"ben_nature": ben.get("nature", ""),
"bian_name": bian.get("name", ""),
"ben_code": ben.get("code", 0),
"bian_code": bian.get("code", 0),
"changing": d.get("changing_indices", []),
"created_at": d.get("created_at", ""),
"outcome": d.get("outcome", ""),
"summary": d.get("summary", ""),
})
return {"ok": True, "casts": out}
@app.api()
def set_outcome(div_id: str, outcome: str, device_id: str) -> dict:
rec = div_store.load(div_id)
if not rec or rec.get("owner") != (device_id or "").strip():
return {"ok": False}
rec = div_store.update_outcome(div_id, (outcome or "").strip())
return {"ok": bool(rec)}
# ============================ API: 心境 ============================
@app.api()
def save_mood(profile_id: str, mood: str, note: str, device_id: str) -> dict:
if not _load_owned(profile_id, device_id):
return {"ok": False, "error": "请先选择档案。"}
hit, payload = safety_check(note or "")
if hit:
return {"ok": False, "blocked": True, "message": payload}
rec = mood_store.save(_today(), profile_id, mood or "cloud", (note or "").strip())
return {"ok": True, "mood": rec}
@app.api()
def mood_series(profile_id: str, days: int, device_id: str) -> dict:
if not _load_owned(profile_id, device_id):
return {"ok": True, "series": []}
return {"ok": True, "series": mood_store.series_for_profile(profile_id, int(days or 30))}
# ============================ API: 日签 ============================
@app.api()
def summary_image(profile_id: str, style: str, device_id: str, lang: str = "zh") -> FileData:
"""生成今日日签 PNG, 返回可下载的文件。lang 控制文字语言。"""
prof = _load_owned(profile_id, device_id)
if not prof:
raise ValueError("档案不存在")
reading = _compute_reading(prof)
_daily_texts(profile_id, reading, lang)
mood_rec = mood_store.load(_today(), profile_id)
mood = (mood_rec or {}).get("mood", "")
el_map = {"rain": "水", "sun": "火", "wood": "木", "metal": "金", "ink": "墨"}
if style in el_map:
reading["riyun"] = {**reading["riyun"], "day_element": el_map[style]}
divs = div_store.list_all()
img = render_daily_image(reading, mood=mood, divination=divs[0] if divs else None, lang=lang)
fname = f"tianwen-daily-{_today().isoformat()}" if lang == "en" else f"天问日签_{_today().isoformat()}"
out = Path(tempfile.gettempdir()) / f"celestial_{_today().isoformat()}_{style}_{lang}.png"
img.save(out)
return FileData(path=str(out), orig_name=f"{fname}.png")
# ============================ API: 备份与清除 ============================
@app.api()
def export_backup(password: str) -> FileData:
if not password or len(password) < 4:
raise ValueError("密码至少 4 个字符")
out = bk.export(password)
return FileData(path=str(out), orig_name=out.name)
@app.api()
def restore_backup(content_b64: str, password: str, device_id: str) -> dict:
if not content_b64 or not password:
return {"ok": False, "error": "请选择备份文件并输入密码。"}
try:
raw = base64.b64decode(content_b64)
EXPORTS_DIR.mkdir(parents=True, exist_ok=True)
tmp = EXPORTS_DIR / "_restore_upload.celestial"
tmp.write_bytes(raw)
n = bk.restore(tmp, password)
tmp.unlink(missing_ok=True)
# 跨设备迁移:还原的档案与卦例归属导入它们的设备
did = (device_id or "").strip()
if did:
for p in prof_store.list_profiles():
p["owner"] = did
prof_store.save(p)
for d in div_store.list_all():
d["owner"] = did
div_store.save(d)
return {"ok": True, "restored": n, "profiles": _list_profiles(did)}
except Exception as exc:
return {"ok": False, "error": f"还原失败(密码错误或文件损坏): {exc}"}
@app.api()
def clear_all(confirm: str) -> dict:
if (confirm or "").strip() != "DELETE":
return {"ok": False, "error": "请输入 DELETE 以确认。"}
import shutil
data_dir = Path(__file__).resolve().parent / "data"
if data_dir.exists():
shutil.rmtree(data_dir)
return {"ok": True, "profiles": []}
# ============================ 静态前端 (React, frontend/dist) ============================
# 单一前端: frontend/ (Vite + React)。静态数据 (生肖图 / 城市库) 随前端置于 public/data,
# 构建后落在 dist/data, 经 /data 提供。后端只用 gradio.Server 托管这份构建产物。
USE_DIST = DIST_DIR.exists() and (DIST_DIR / "index.html").exists()
if not USE_DIST:
print("\n[警告] 未找到 frontend/dist —— 请先构建前端: cd frontend && pnpm install && pnpm build\n")
@app.get("/", response_class=HTMLResponse)
async def homepage():
if not USE_DIST:
return HTMLResponse("<h1>天问</h1><p>前端尚未构建。请运行: <code>cd frontend && pnpm install && pnpm build</code></p>", status_code=503)
return (DIST_DIR / "index.html").read_text(encoding="utf-8")
if USE_DIST:
# Vite 构建: JS/CSS/字体 -> dist/assets; 静态数据 (生肖图/城市库) -> dist/data
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
if (DIST_DIR / "data").exists():
app.mount("/data", StaticFiles(directory=str(DIST_DIR / "data")), name="data")
# PWA 与根静态文件 (sw.js / manifest.webmanifest / workbox-*.js / icon-*.png)
def _dist_file_route(path: Path):
async def _route():
return FileResponse(str(path))
return _route
for _f in DIST_DIR.iterdir():
if _f.is_file() and _f.name != "index.html":
app.add_api_route("/" + _f.name, _dist_file_route(_f), methods=["GET"], include_in_schema=False)
# Service Worker 预缓存清单含 /index.html, 单独补一条(上面循环刻意跳过它)
@app.get("/index.html", response_class=HTMLResponse, include_in_schema=False)
async def _index_html():
return (DIST_DIR / "index.html").read_text(encoding="utf-8")
def _find_port(start: int = 7860, max_tries: int = 10) -> int:
for p in range(start, start + max_tries):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", p))
return p
except OSError:
continue
return start
def _maybe_start_local_llm() -> None:
"""进程内起一个 CPU llama.cpp OpenAI 兼容服务于 127.0.0.1:8888,
供 llm_client 调用(用微调后的 Q4 GGUF)。仅当 TIANWEN_SELF_LLM=1 时启用,
后台线程下载模型并拉起,不阻塞 app 启动;模型就绪前解读自动走规则兜底。"""
if os.environ.get("TIANWEN_SELF_LLM") != "1":
return
import threading
def _run():
try:
import subprocess
from huggingface_hub import hf_hub_download
repo = os.environ.get("MODEL_REPO", "build-small-hackathon/tianwen-minicpm5-1b")
fname = os.environ.get("MODEL_FILE", "tianwen-minicpm1b-q4_k_m.gguf")
gguf = hf_hub_download(repo_id=repo, filename=fname)
print(f">>> 进程内 llama.cpp (CPU) 加载: {gguf}", flush=True)
subprocess.Popen([
sys.executable, "-m", "llama_cpp.server",
"--model", gguf, "--host", "127.0.0.1", "--port", "8888", "--n_ctx", "4096",
])
except Exception as e:
print(f"自托管 LLM 启动失败(继续用规则兜底): {e}", flush=True)
threading.Thread(target=_run, daemon=True).start()
if __name__ == "__main__":
_maybe_start_local_llm()
# HF Space (有 SPACE_ID) 必须绑 0.0.0.0:7860, 否则平台反代无法探活, 一直 "starting"。
# 本地默认 127.0.0.1 + 自动找空闲端口。
on_space = bool(os.environ.get("SPACE_ID") or os.environ.get("SPACE_HOST"))
host = "0.0.0.0" if on_space else "127.0.0.1"
port = 7860 if on_space else _find_port(7860, 10)
print(f"\n>>> {APP_TITLE} {APP_VERSION} 已启动 -> http://{host}:{port}/\n")
# ssr_mode=False: 关掉 Gradio 6 的 Node SSR 代理层 —— 我们自带 React 前端, 不需要它,
# 且该代理层在 Space 上容易绑错地址导致探活失败。
app.launch(server_name=host, server_port=port, show_error=True, ssr_mode=False)