v3.6.0 同步发版

- 交互契约改对话式询问(禁点选工具、一次1~2问、先铺垫再问),规则库 3.6
- 盘面图默认生成+chart_shot 截图;品牌区、大运/流年小卡信息补全、交流群二维码卡
- day_boundary 子正换日;memory_save --update-chart 换盘留痕;share_export 事件简介+城市级地域
- share_push 失败诊断+代理引导;_runtime_guard;修 STAR_ORDER 三奇贵人 KeyError
- README 规则计数 541→542;manifest 重建(1275 文件);测试 142 项全绿
This commit is contained in:
2026-10-10 13:13:41 +08:00
parent f725df8ff6
commit 433c9b4a41
32 changed files with 1468 additions and 389 deletions
+222 -83
View File
@@ -4,12 +4,23 @@
用法:
python memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市"
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
输出:档案文件路径到 stdout。
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
换盘模式(--update-chart):姓名/性别/出生信息默认读档案 frontmatter,
--birth/--place 用于时辰/地点校正,--day-boundary 子正 切换日界规则(默认子初)。
换盘后「## 盘面」只保留新盘(工作盘唯一),旧盘信息由使用方记入「## 反馈与澄清」;
frontmatter 的 pillars/day_master/true_solar_time 同步为新盘,文件改为新主键名
(目标已存在且非同一文件时报错退出 2,不覆盖)。四柱有变动时 stderr 列出变动柱,
提示使用方把受影响推断条目(归纳、流年大事)标记【待复核】。
退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
"""
import argparse
@@ -21,6 +32,8 @@ from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
@@ -28,24 +41,215 @@ if _VENDOR.is_dir():
import _memory_store as store
_PILLAR_LABELS = ("年柱", "月柱", "日柱", "时柱")
# render 层日界文案写定(scripts/render/ 不在此处改动),换盘子正时改写这两行
_AUDIT_ZICHU = "- 日界规则:子初换日(真太阳时 23:00)"
_AUDIT_ZIZHENG = "- 日界规则:子正换日(真太阳时 00:00)"
_WARN_ZICHU = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
"已按「子初换日」归入次日。另存「子正换日」一派,"
"日柱与时柱将不同,结论需谨慎。")
_WARN_ZIZHENG = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
"已按「子正换日」定盘(日界 0:00,不换日)。"
"另存「子初换日」一派,日柱与时柱将不同,结论需谨慎。")
def _cast_chart(name: str, gender: str, birth: str, place: str,
day_boundary: str = "子初"):
"""排盘管线(geo → astro → chart → render),与 cast_chart.py 同一调用序列。
返回 (命盘 Markdown, 四柱 list, 日主, 真太阳时 str);地点/时刻不可用抛
LocationError/ValueError(由调用方映射退出码 2)。
"""
from geo.cities import resolve_location
from astro.timezone_resolve import to_utc
from astro.solar_time import to_true_solar_time
from astro.day_boundary import resolve_calendar_day
from chart.tyme_adapter import cast
from chart.luck import (
childhood_luck, compute_luck, decade_pillars, flow_months,
flow_years, is_forward, luck_label_for_year,
)
from chart.model import build_chart_data
from render.markdown import render
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
loc = resolve_location(place)
# L0-b 出生时刻解析
clock = datetime.strptime(birth.strip(), "%Y-%m-%d %H:%M")
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
utc, tz_audit = to_utc(clock, loc)
# L0-d UTC → 真太阳时(经度修正 + 均时差)
tst, st_audit = to_true_solar_time(utc, loc.lon)
# L0-e 日界归算:默认子初换日(真太阳时 23:00–24:00 归入次日)
day, bd_audit = resolve_calendar_day(tst, rule=day_boundary)
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
# L1-a 排盘(tyme4py 唯一接触面)
raw = cast(cast_dt, gender)
# L1-b 起运、大运、流年、流月、小运
forward = is_forward(gender, raw["year"]["gan"])
luck = compute_luck(
birth_tst=tst,
prev_jie_time=raw["prev_jie"]["time"],
next_jie_time=raw["next_jie"]["time"],
forward=forward,
)
this_year = datetime.now().year
n_pillars = max(
8,
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
)
decade = decade_pillars(
raw["month"]["gan"], raw["month"]["zhi"], forward,
luck["start_age_years"], cast_dt.year, n=n_pillars,
)
years = [
(y, gz, luck_label_for_year(y, decade))
for y, gz in flow_years(cast_dt.year, this_year, 10)
]
months = flow_months(this_year)
childhood = childhood_luck(
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
luck["start_age_years"], cast_dt.year,
)
# L1-c 组装 ChartData
chart = build_chart_data(
name=name, gender=gender, place=place, clock=birth,
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
raw=raw, luck=luck, luck_pillars=decade,
flow_years=years, flow_months=months, childhood_luck=childhood,
)
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
chart_md = render(chart)
if day_boundary == "子正":
# render 层日界文案固定为子初口径,换盘子正时改写警示条与审计行
chart_md = chart_md.replace(_WARN_ZICHU, _WARN_ZIZHENG)
chart_md = chart_md.replace(_AUDIT_ZICHU, _AUDIT_ZIZHENG)
pillars = [f"{chart.year.gan}{chart.year.zhi}",
f"{chart.month.gan}{chart.month.zhi}",
f"{chart.day.gan}{chart.day.zhi}",
f"{chart.hour.gan}{chart.hour.zhi}"]
return chart_md, pillars, chart.day_master, chart.true_solar_time
def _update_chart(a) -> int:
"""换盘模式:重排并替换既有档案的「## 盘面」段,frontmatter 与主键名同步。"""
path = Path(a.update_chart).expanduser().resolve()
if not path.is_file():
print(f"ERROR: 档案不存在:{path}", file=sys.stderr)
return 2
meta, body = store.parse_frontmatter(path.read_text(encoding="utf-8"))
if not meta:
print("ERROR: 档案无 frontmatter,无法换盘;请检查文件或用完整建档。",
file=sys.stderr)
return 2
name = str(meta.get("name", "")).strip()
gender = str(meta.get("gender", "")).strip()
birth = (a.birth or str(meta.get("birth_clock", ""))).strip()
place = (a.place or str(meta.get("birthplace", ""))).strip()
if not name or gender not in ("男", "女") or not birth or not place:
print("ERROR: 档案 frontmatter 缺 name/gender/birth_clock/birthplace,"
"无法重排;请补全档案或改用完整建档。", file=sys.stderr)
return 2
from geo.cities import LocationError
try:
chart_md, pillars, day_master, tst_str = _cast_chart(
name, gender, birth, place, a.day_boundary)
except LocationError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
except ValueError:
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
return 2
now = datetime.now().strftime("%Y-%m-%d %H:%M")
old_pillars = meta.get("pillars", [])
if isinstance(old_pillars, str):
old_pillars = old_pillars.split()
meta.update({
"name": name, "gender": gender, "birth_clock": birth, "birthplace": place,
"true_solar_time": tst_str, "pillars": pillars, "day_master": day_master,
"created_at": meta.get("created_at") or now, "updated_at": now,
})
body = store.replace_section(body, "盘面", chart_md)
# 四柱/hash6 变动时改为新主键名;目标已存在则拒绝覆盖
new_path = path.with_name(store.archive_name(
pillars, gender, store.key_hash(birth, place, gender)))
if new_path != path:
if new_path.exists():
print(f"ERROR: 新盘主键档案已存在:{new_path};"
f"为避免覆盖,本次未写入。请先核对两份档案。", file=sys.stderr)
return 2
new_path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
path.unlink()
else:
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
changed = [f"{_PILLAR_LABELS[i]} {old_pillars[i]}→{pillars[i]}"
for i in range(min(len(old_pillars), 4))
if old_pillars[i] != pillars[i]]
if changed:
print(f"注意:{'、'.join(changed)} 已变。受变动柱影响的已有推断条目"
f"(归纳、流年大事)须复核并标【待复核】,复核前不得继续引用;"
f"旧盘四柱与换盘原因请记入「## 反馈与澄清」。", file=sys.stderr)
print(new_path)
return 0
def main() -> int:
ap = argparse.ArgumentParser(
prog="memory_save.py",
description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)",
)
ap.add_argument("--name", required=True, help="姓名")
ap.add_argument("--gender", required=True, choices=["男", "女"],
ap.add_argument("--name", default=None, help="姓名")
ap.add_argument("--gender", default=None, choices=["男", "女"],
help="性别:男 或 女")
ap.add_argument("--birth", required=True,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
ap.add_argument("--place", required=True,
help="出生地,精确到地级市一级,如「四川省成都市」")
ap.add_argument("--birth", default=None,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
"换盘模式下为时辰校正覆盖值")
ap.add_argument("--place", default=None,
help="出生地,精确到地级市一级,如「四川省成都市」;"
"换盘模式下为地点校正覆盖值")
ap.add_argument("--update-chart", default=None, metavar="档案路径",
help="换盘模式:按档案重排并替换其「## 盘面」段,"
"frontmatter 与文件名同步为新盘")
ap.add_argument("--day-boundary", default="子初", choices=["子初", "子正"],
help="日界规则:子初(默认,真太阳时 23:00 换日)或 子正(0:00 换日);"
"仅换盘模式可用")
ap.add_argument("--memory-root", default=None,
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
a = ap.parse_args()
# 软授权锁:与 cast_chart.py 同位置——建档即一次排盘,先 check,成功后 consume
if a.update_chart is None:
missing = [k for k in ("name", "gender", "birth", "place")
if getattr(a, k) is None]
if missing:
print(f"ERROR: 建档缺参数:{'、'.join('--' + k for k in missing)}。"
f"(换盘请用 --update-chart <档案路径>)", file=sys.stderr)
return 2
if a.day_boundary != "子初":
print("ERROR: --day-boundary 仅换盘模式(--update-chart)可用;"
"建档固定子初换日。", file=sys.stderr)
return 2
elif a.name is not None or a.gender is not None:
print("ERROR: 换盘模式不支持 --name/--gender(姓名、性别以档案为准)。",
file=sys.stderr)
return 2
# 软授权锁:与 cast_chart.py 同位置——建档/换盘即一次排盘,先 check,成功后 consume
import _license_gate as _gate
_deny = _gate.check(Path(__file__).resolve().parents[1])
if _deny:
@@ -53,91 +257,26 @@ def main() -> int:
return 4
try:
# 排盘管线与 cast_chart.py 同一调用序列(geo → astro → chart → render)
from geo.cities import resolve_location, LocationError
from astro.timezone_resolve import to_utc
from astro.solar_time import to_true_solar_time
from astro.day_boundary import resolve_calendar_day
from chart.tyme_adapter import cast
from chart.luck import (
childhood_luck, compute_luck, decade_pillars, flow_months,
flow_years, is_forward, luck_label_for_year,
)
from chart.model import build_chart_data
from render.markdown import render
if a.update_chart is not None:
rc = _update_chart(a)
if rc == 0:
_gate.consume(Path(__file__).resolve().parents[1])
return rc
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
# L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
from geo.cities import LocationError
try:
loc = resolve_location(a.place)
chart_md, pillars, day_master, tst_str = _cast_chart(
a.name, a.gender, a.birth, a.place)
except LocationError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
# L0-b 出生时刻解析
try:
clock = datetime.strptime(a.birth.strip(), "%Y-%m-%d %H:%M")
except ValueError:
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
return 2
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
utc, tz_audit = to_utc(clock, loc)
# L0-d UTC → 真太阳时(经度修正 + 均时差)
tst, st_audit = to_true_solar_time(utc, loc.lon)
# L0-e 子初换日:真太阳时 23:00–24:00 归入次日
day, bd_audit = resolve_calendar_day(tst)
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
# L1-a 排盘(tyme4py 唯一接触面)
raw = cast(cast_dt, a.gender)
# L1-b 起运、大运、流年、流月、小运
forward = is_forward(a.gender, raw["year"]["gan"])
luck = compute_luck(
birth_tst=tst,
prev_jie_time=raw["prev_jie"]["time"],
next_jie_time=raw["next_jie"]["time"],
forward=forward,
)
this_year = datetime.now().year
n_pillars = max(
8,
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
)
decade = decade_pillars(
raw["month"]["gan"], raw["month"]["zhi"], forward,
luck["start_age_years"], cast_dt.year, n=n_pillars,
)
years = [
(y, gz, luck_label_for_year(y, decade))
for y, gz in flow_years(cast_dt.year, this_year, 10)
]
months = flow_months(this_year)
childhood = childhood_luck(
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
luck["start_age_years"], cast_dt.year,
)
# L1-c 组装 ChartData
chart = build_chart_data(
name=a.name, gender=a.gender, place=a.place, clock=a.birth,
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
raw=raw, luck=luck, luck_pillars=decade,
flow_years=years, flow_months=months, childhood_luck=childhood,
)
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
chart_md = render(chart)
# —— 建档/更新 ——
pillars = [f"{chart.year.gan}{chart.year.zhi}",
f"{chart.month.gan}{chart.month.zhi}",
f"{chart.day.gan}{chart.day.zhi}",
f"{chart.hour.gan}{chart.hour.zhi}"]
root = store.resolve_root(a.memory_root, __file__)
arch_dir = root / "archives"
arch_dir.mkdir(parents=True, exist_ok=True)
@@ -147,8 +286,8 @@ def main() -> int:
now = datetime.now().strftime("%Y-%m-%d %H:%M")
meta = {
"name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(),
"birthplace": a.place.strip(), "true_solar_time": chart.true_solar_time,
"pillars": pillars, "day_master": chart.day_master,
"birthplace": a.place.strip(), "true_solar_time": tst_str,
"pillars": pillars, "day_master": day_master,
"created_at": now, "updated_at": now,
}
if path.is_file():