Skip to content

Commit ea05e21

Browse files
ChiShengChenclaude
andcommitted
feat: 算命 Divination Suite — 11 traditional systems as a standalone fortune-telling service
Extracted the deterministic divination engines (T25–T35) from the quant monorepo, stripped all trading/backtest coupling, and rebuilt them around their real purpose: telling people's fortunes from a 生辰 (birth date + 時辰 + birthplace). - 11 engine cores synced verbatim from the monorepo via scripts/sync_from_main.sh (single source of truth), renamed taskNN_* → real names, cross-engine imports rewritten - Native product shell: BirthInput, uniform Chart/Reading schema, 11 casting adapters, registry, LLM 解讀 layer (anthropic + mock fallback), FastAPI app - Static 算命 web page (no node build) hitting the API - 12 passing tests; all 11 systems cast a non-empty 命盤 from one birth Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit ea05e21

66 files changed

Lines changed: 3085 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# 算命 service config — copy to .env and fill in as needed.
2+
3+
# 解讀 backend. "mock" (default) needs NO API key: every 命盤 still casts
4+
# deterministically, only the prose interpretation is a stubbed facts digest.
5+
LLM_BACKEND=mock
6+
# LLM_BACKEND=anthropic
7+
ANTHROPIC_API_KEY=
8+
ANTHROPIC_MODEL=claude-sonnet-4-6
9+
INTERPRETATION_MAX_TOKENS=900
10+
11+
LOG_LEVEL=INFO
12+
LOG_FORMAT=text
13+
14+
API_HOST=0.0.0.0
15+
API_PORT=8000
16+
CORS_ORIGINS=http://localhost:3000,http://localhost:5500

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
__pycache__/
2+
*.py[cod]
3+
.venv/
4+
venv/
5+
.env
6+
.env.*
7+
!.env.example
8+
*.egg-info/
9+
dist/
10+
build/
11+
.pytest_cache/
12+
.DS_Store
13+
node_modules/
14+
web/.next/
15+
web/out/

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# 算命 · Divination Suite
2+
3+
十一套傳統命理系統,寫成**確定性 Python 排盤引擎**,前面接一層 LLM 解讀,後面只吃一個輸入:**生辰**(出生日期+時辰+出生地)。
4+
5+
> 起源:本專案的排盤引擎源自一個更大的量化研究專案,原本是當作「對照組/安慰劑」(把命理當成 date-keyed 訊號,跑無未來函數回測證明它們是雜訊)。這裡把**排盤數學**單獨抽出來,去掉所有交易/回測,還原成它本來的用途——**幫人算命**。排盤數學以 `scripts/sync_from_main.sh` 從母專案同步(單一真相源),算命產品殼(生辰輸入、API、解讀、前端)是本 repo 原生。
6+
7+
## 十一系
8+
9+
| key | 系統 | 引擎核心 | 時辰敏感 | 出生地敏感 |
10+
|---|---|---|:--:|:--:|
11+
| `astrology` | 西洋占星 | `ephem` 黃道經度 | 月相 | 規劃中(上升/宮位)|
12+
| `bazi` | 八字(四柱)| JDN 校準干支 | ✅ 時柱 ||
13+
| `ziwei` | 紫微斗數 | 純 Python 排盤 | 規劃中(命宮)||
14+
| `iching` | 梅花易數 | 時間起卦 |||
15+
| `suimei` | 四柱推命(日)| 十二運星+天中殺 |||
16+
| `qizheng` | 七政四餘 | 真實天文經度 || 規劃中 |
17+
| `tieban` | 鐵板神數 | 起命數 |||
18+
| `qimen` | 奇門遁甲 | 八門九宮起局 |||
19+
| `liuren` | 大六壬 | 四課三傳 |||
20+
| `taiyi` | 太乙神數 | 太乙九宮 |||
21+
| `jyotish` | Jyotiṣa 吠陀占星 | 恆星黃道+Vimśottarī 大運 || 規劃中 |
22+
23+
> **誠實標註**:八字/四柱推命已用 `時辰` 排時柱;占星/紫微/七政等的「時辰/出生地」欄位已在輸入層備好(`BirthInput`),但對應引擎的上升星座、命宮、宮位計算是**規劃中的引擎升級**——升級寫在母專案、再 sync 過來。
24+
25+
## 快速開始
26+
27+
```bash
28+
python -m venv .venv && source .venv/bin/activate
29+
pip install -e ".[dev,llm]" # llm 可省略;不裝就用 mock 解讀
30+
cp .env.example .env
31+
32+
# 後端 API
33+
uvicorn fortune.api.main:app --reload
34+
35+
# 靜態算命頁(免 node build):用任意靜態伺服器開 web/index.html
36+
python -m http.server 5500 --directory web
37+
# 開 http://localhost:5500/ 填生辰 → 選命理系統 → 看命盤+解讀
38+
```
39+
40+
不接 LLM 也能跑:每個命盤都會**確定性排出**,只有解讀文字是 mock 的事實摘要。設 `LLM_BACKEND=anthropic` + `ANTHROPIC_API_KEY` 即換成真正的 AI 解讀。
41+
42+
## API
43+
44+
| method | path | 說明 |
45+
|---|---|---|
46+
| `GET` | `/systems` | 11 系清單+哪些可用 |
47+
| `POST` | `/cast/{system}` | 純命盤(不呼叫 LLM)→ `Chart` |
48+
| `POST` | `/reading/{system}` | 命盤+解讀 → `Reading` |
49+
50+
```bash
51+
curl -s localhost:8000/cast/bazi -H 'content-type: application/json' -d '{
52+
"name":"小美","birth_date":"1990-06-15","birth_time":"14:30",
53+
"gender":"女","place":"台北","latitude":25.04,"longitude":121.56
54+
}' | jq .summary
55+
# "日主 辛金・身弱(喜生扶)・喜用 土、金"
56+
```
57+
58+
## 架構
59+
60+
```
61+
fortune/
62+
birth.py 生辰輸入(唯一輸入)
63+
schemas.py Chart / Reading 統一封裝
64+
shared/ 原生:config / logging / llm(mock+anthropic)
65+
engines/<system>/ ← sync 自母專案的純排盤數學(勿手改,會被覆蓋)
66+
casting/<system>.py 每系 adapter:生辰 → 引擎函式 → Chart
67+
casting/__init__.py 11 系註冊表(惰性 import)
68+
interpret.py 命盤事實 + 門派 prompt → LLM 解讀
69+
api/main.py FastAPI
70+
prompts/<system>/ ← sync 自母專案的門派解讀 prompt
71+
web/ 靜態算命頁 + sync 來的命盤渲染元件
72+
scripts/sync_from_main.sh 重新同步排盤數學
73+
```
74+
75+
### 重新同步排盤數學
76+
77+
母專案改了某系的排盤邏輯後:
78+
79+
```bash
80+
scripts/sync_from_main.sh # 預設母專案 ~/Desktop/威鯨面試_LLMEng
81+
scripts/sync_from_main.sh /path/to/monorepo
82+
```
83+
84+
只會覆蓋 `fortune/engines/*``prompts/*``web/app/_charts/*`(排盤數學與命盤視覺);
85+
生辰輸入、API、解讀、前端殼是原生的,**不會被動到**
86+
87+
## 測試
88+
89+
```bash
90+
pytest -q # 11 系各自從同一個生辰排出非空命盤 + mock 解讀
91+
```

fortune/__init__.py

Whitespace-only changes.

fortune/api/__init__.py

Whitespace-only changes.

fortune/api/main.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""算命 service — FastAPI. One input (生辰), eleven 命理 systems, deterministic 命盤
2+
plus optional LLM 解讀.
3+
4+
GET /systems → the 11 systems + which cast cleanly
5+
POST /cast/{system} → deterministic 命盤 (no LLM) → Chart
6+
POST /reading/{system} → 命盤 + 解讀 (LLM, mock by default) → Reading
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from fastapi import FastAPI, HTTPException
12+
from fastapi.middleware.cors import CORSMiddleware
13+
from pydantic import BaseModel
14+
15+
from fortune import casting
16+
from fortune.birth import BirthInput
17+
from fortune.interpret import interpret
18+
from fortune.schemas import Chart, Reading
19+
from fortune.shared.config import get_settings
20+
from fortune.shared.logging import configure_logging, get_logger
21+
22+
configure_logging()
23+
log = get_logger("api")
24+
settings = get_settings()
25+
26+
app = FastAPI(title="算命 · Divination Suite", version="0.1.0")
27+
app.add_middleware(
28+
CORSMiddleware,
29+
allow_origins=settings.cors_origin_list,
30+
allow_methods=["*"],
31+
allow_headers=["*"],
32+
)
33+
34+
35+
class ReadingRequest(BaseModel):
36+
birth: BirthInput
37+
focus: str | None = None # 命主想問的方向(事業/感情/健康…),可選
38+
39+
40+
@app.get("/health")
41+
def health() -> dict[str, str]:
42+
return {"status": "ok"}
43+
44+
45+
@app.get("/systems")
46+
def list_systems() -> list[dict[str, object]]:
47+
return casting.systems()
48+
49+
50+
@app.post("/cast/{system}", response_model=Chart)
51+
def cast(system: str, birth: BirthInput) -> Chart:
52+
try:
53+
return casting.cast(system, birth)
54+
except KeyError as e:
55+
raise HTTPException(404, str(e)) from e
56+
except Exception as e: # noqa: BLE001
57+
log.exception("cast_failed", system=system)
58+
raise HTTPException(500, f"{system} 排盤失敗:{e}") from e
59+
60+
61+
@app.post("/reading/{system}", response_model=Reading)
62+
def reading(system: str, req: ReadingRequest) -> Reading:
63+
try:
64+
chart = casting.cast(system, req.birth)
65+
except KeyError as e:
66+
raise HTTPException(404, str(e)) from e
67+
except Exception as e: # noqa: BLE001
68+
log.exception("cast_failed", system=system)
69+
raise HTTPException(500, f"{system} 排盤失敗:{e}") from e
70+
return interpret(chart, focus=req.focus)

fortune/birth.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""生辰輸入 — the one input the whole 算命 service takes.
2+
3+
Replaces the monorepo's "stock listing date": a person's birth moment (date +
4+
time-of-day + optional birthplace). Time-of-day drives the 時柱 / Moon position;
5+
birthplace (lat/lon) is what house-/ascendant-based systems (占星, Jyotiṣa) need —
6+
carried here so engines can use it as they grow into it.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import date, datetime, time
12+
13+
from pydantic import BaseModel, Field, field_validator
14+
15+
16+
class BirthInput(BaseModel):
17+
name: str | None = Field(default=None, description="稱呼(可選,只用於解讀文案)")
18+
birth_date: date = Field(..., description="出生日期")
19+
birth_time: time | None = Field(
20+
default=None, description="出生時刻(時辰)。未知時 時柱以正午估算"
21+
)
22+
gender: str | None = Field(default=None, description="性別(部分命理用神不同)")
23+
24+
# birthplace — optional, needed for ascendant/house systems
25+
place: str | None = Field(default=None, description="出生地名(顯示用)")
26+
latitude: float | None = Field(default=None, ge=-90, le=90)
27+
longitude: float | None = Field(default=None, ge=-180, le=180)
28+
tz_offset_hours: float = Field(default=8.0, description="出生地時區(東八區=+8)")
29+
30+
@field_validator("birth_date")
31+
@classmethod
32+
def _not_absurd(cls, v: date) -> date:
33+
if v.year < 1 or v.year > 9999:
34+
raise ValueError("birth_date out of range")
35+
return v
36+
37+
@property
38+
def as_date(self) -> date:
39+
return self.birth_date
40+
41+
@property
42+
def hour(self) -> int:
43+
"""Clock hour 0–23; defaults to noon (12) when time-of-day is unknown."""
44+
return self.birth_time.hour if self.birth_time else 12
45+
46+
@property
47+
def dt(self) -> datetime:
48+
t = self.birth_time or time(12, 0)
49+
return datetime.combine(self.birth_date, t)
50+
51+
def label(self) -> str:
52+
who = self.name or "命主"
53+
when = self.birth_date.isoformat() + (
54+
f" {self.birth_time.strftime('%H:%M')}" if self.birth_time else " (時辰未知)"
55+
)
56+
where = f" · {self.place}" if self.place else ""
57+
return f"{who} · {when}{where}"

fortune/casting/__init__.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Registry of the 11 命理 systems → their cast(birth) adapter.
2+
3+
Each value is (中文名, "module:function"). Imports are lazy so a half-wired engine
4+
never breaks the whole service — `systems()` reports which casters import cleanly.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import importlib
10+
11+
from fortune.birth import BirthInput
12+
from fortune.schemas import Chart
13+
14+
# key : (中文名, dotted "module:func")
15+
REGISTRY: dict[str, tuple[str, str]] = {
16+
"astrology": ("西洋占星", "fortune.casting.astrology:cast"),
17+
"bazi": ("八字(四柱)", "fortune.casting.bazi:cast"),
18+
"ziwei": ("紫微斗數", "fortune.casting.ziwei:cast"),
19+
"iching": ("梅花易數", "fortune.casting.iching:cast"),
20+
"suimei": ("四柱推命(日)", "fortune.casting.suimei:cast"),
21+
"qizheng": ("七政四餘", "fortune.casting.qizheng:cast"),
22+
"tieban": ("鐵板神數", "fortune.casting.tieban:cast"),
23+
"qimen": ("奇門遁甲", "fortune.casting.qimen:cast"),
24+
"liuren": ("大六壬", "fortune.casting.liuren:cast"),
25+
"taiyi": ("太乙神數", "fortune.casting.taiyi:cast"),
26+
"jyotish": ("Jyotiṣa(吠陀占星)", "fortune.casting.jyotish:cast"),
27+
}
28+
29+
30+
def _resolve(spec: str):
31+
mod, fn = spec.split(":")
32+
return getattr(importlib.import_module(mod), fn)
33+
34+
35+
def cast(system: str, birth: BirthInput) -> Chart:
36+
if system not in REGISTRY:
37+
raise KeyError(f"unknown system: {system!r}. known: {', '.join(REGISTRY)}")
38+
return _resolve(REGISTRY[system][1])(birth)
39+
40+
41+
def systems() -> list[dict[str, object]]:
42+
"""[{key, zh, available}] for the UI menu."""
43+
out: list[dict[str, object]] = []
44+
for key, (zh, spec) in REGISTRY.items():
45+
try:
46+
_resolve(spec)
47+
ok = True
48+
except Exception: # noqa: BLE001
49+
ok = False
50+
out.append({"key": key, "zh": zh, "available": ok})
51+
return out

fortune/casting/astrology.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""西洋占星 — cast a natal-style chart from a birth moment.
2+
3+
Adapter over the pure synced engine (fortune/engines/astrology/astro.py). The
4+
engine computes ecliptic longitudes from the calendar; birth time-of-day refines
5+
the Moon. (Ascendant/house cusps need lat/lon and are a planned engine upgrade —
6+
the birthplace is already carried on BirthInput for when it lands.)
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from fortune.birth import BirthInput
12+
from fortune.engines.astrology import astro
13+
from fortune.schemas import Chart
14+
15+
KEY, ZH, ORB = "astrology", "西洋占星", 6.0
16+
17+
18+
def cast(birth: BirthInput) -> Chart:
19+
d = birth.as_date
20+
chart_rows = [
21+
{"body": b, "ecliptic_lon": lon, "sign": sign, "sign_zh": astro.sign_zh(lon), "retrograde": retro}
22+
for (b, lon, sign, retro) in astro.chart_for(d)
23+
]
24+
readings = astro.astro_readings(d, ORB)
25+
sun = next((r for r in chart_rows if r["body"] == "Sun"), None)
26+
moon = readings.get("moon_phase", "")
27+
summary = (
28+
f"太陽 {sun['sign_zh'] if sun else ''}座"
29+
f"・月相 {moon}"
30+
f"・水星{'逆行' if readings.get('mercury_retrograde') == 'yes' else '順行'}"
31+
)
32+
return Chart(
33+
system=KEY, system_zh=ZH, subject=birth.label(), cast_at=birth.dt,
34+
chart={"planets": chart_rows, "aspects": astro.aspects_for(d, ORB)},
35+
reasoning_chain=astro.reasoning_chain(d, ORB),
36+
readings=readings,
37+
summary=summary,
38+
)

fortune/casting/bazi.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""八字(四柱)— cast the natal 命盤 from birth date + 時辰.
2+
3+
Adapter over fortune/engines/bazi/bazi.py. Birth hour drives the 時柱 (defaults to
4+
noon → 午時 when unknown); the engine pins 日柱 to the verified 甲子 anchor.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from fortune.birth import BirthInput
10+
from fortune.engines.bazi import bazi
11+
from fortune.schemas import Chart
12+
13+
KEY, ZH = "bazi", "八字(四柱)"
14+
_ORDER = ("year", "month", "day", "hour")
15+
_ZH = {"year": "年柱", "month": "月柱", "day": "日柱", "hour": "時柱"}
16+
17+
18+
def cast(birth: BirthInput) -> Chart:
19+
d = birth.as_date
20+
pillars = bazi.four_pillars(d, birth.hour)
21+
fav = bazi.strength_and_favourable(pillars)
22+
23+
rows = [{"pillar": _ZH[k], **pillars[k]} for k in _ORDER]
24+
chain = [
25+
f"{_ZH[k]}{pillars[k]['gz']}{pillars[k]['stem_elem']}{pillars[k]['branch_elem']}{pillars[k]['zodiac']})"
26+
for k in _ORDER
27+
]
28+
chain.append(
29+
f"日主 {fav['day_master']}{fav['dm_elem']})→ {fav['label']},喜用神:{'、'.join(fav['favourable'])}"
30+
)
31+
summary = (
32+
f"日主 {fav['day_master']}{fav['dm_elem']}{fav['label']}・"
33+
f"喜用 {'、'.join(fav['favourable'])}"
34+
)
35+
return Chart(
36+
system=KEY, system_zh=ZH, subject=birth.label(), cast_at=birth.dt,
37+
chart={"pillars": rows},
38+
reasoning_chain=chain,
39+
readings={
40+
"day_master": fav["day_master"], "dm_elem": fav["dm_elem"],
41+
"strength": fav["label"], "favourable": fav["favourable"],
42+
"current_liunian_elem": bazi.liunian_elem(d),
43+
},
44+
summary=summary,
45+
)

0 commit comments

Comments
 (0)