v3.5:命主档案系统;技能介绍按场景触发;声明式偏离通道

- 新增 memory/ 命主档案:memory_save/memory_lookup 建档检索,
  反馈三态(已核实/已证伪/未核实),档案只存本机不进副本
- SKILL.md description 改写为按问题域与使用场景触发;
  输出契约补 R-禁-22 偏离须声明(编号+理由+书证)联动
- R-运-31 事类映射表改开放清单措辞
- 规则库 541→542 条(新增 R-禁-22),库版本 3.5
- 测试全套 69 项通过
This commit is contained in:
2026-10-09 21:01:55 +08:00
parent 618bf8dcab
commit 5889d7a150
11 changed files with 634 additions and 7 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""命主档案建档/更新 —— 排盘一次并写入 memory/archives/。
用法:
python memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市"
输出:档案文件路径到 stdout。
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
"""
import argparse
import sys
import traceback
from datetime import datetime
from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
sys.path.insert(0, str(_VENDOR))
import _memory_store as store
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=["男", "女"],
help="性别:男 或 女")
ap.add_argument("--birth", required=True,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
ap.add_argument("--place", required=True,
help="出生地,精确到地级市一级,如「四川省成都市」")
ap.add_argument("--memory-root", default=None,
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
a = ap.parse_args()
# 软授权锁:与 cast_chart.py 同位置——建档即一次排盘,先 check,成功后 consume
import _license_gate as _gate
_deny = _gate.check(Path(__file__).resolve().parents[1])
if _deny:
print(f"ERROR: {_deny}", file=sys.stderr)
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
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
try:
loc = resolve_location(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)
path = arch_dir / store.archive_name(
pillars, a.gender, store.key_hash(a.birth, a.place, a.gender))
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,
"created_at": now, "updated_at": now,
}
if path.is_file():
old_meta, body = store.parse_frontmatter(
path.read_text(encoding="utf-8"))
meta["created_at"] = old_meta.get("created_at") or now
body = store.replace_section(body, "盘面", chart_md)
else:
body = store.skeleton(a.name, a.gender, chart_md)
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
_gate.consume(Path(__file__).resolve().parents[1])
print(path)
return 0
except SystemExit:
raise
except Exception:
print("ERROR: 建档内部错误。请把以下信息原样报告给用户,"
"不要自行猜测命盘。", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())