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:
@@ -0,0 +1,167 @@
|
||||
"""命主档案(memory/)读写层:frontmatter、文件名主键、小节替换。
|
||||
|
||||
纯标准库(Python ≥ 3.10),无第三方依赖。
|
||||
frontmatter 只认本技能写出的受限 YAML 子集:``key: value`` 标量行、
|
||||
双引号字符串、``[a, b]`` 行内列表——不引入 pyyaml,避免副本依赖膨胀。
|
||||
|
||||
档案规范见 memory/README.md;schema 改动须同步该文件。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ENV_VAR = "ZIPING_MEMORY_ROOT" # 覆盖档案根目录的环境变量(--memory-root 优先)
|
||||
|
||||
# frontmatter 字段次序(写出固定;解析不依赖次序)
|
||||
FIELDS = ("name", "gender", "birth_clock", "birthplace", "true_solar_time",
|
||||
"pillars", "day_master", "created_at", "updated_at")
|
||||
|
||||
# 正文固定小节次序(新建骨架用;盘面每次建档刷新,其余保留)
|
||||
SECTIONS = ("盘面", "主要关注方向", "归纳", "流年大事", "反馈与澄清")
|
||||
|
||||
FEEDBACK_TAGS = ("【已核实】", "【已证伪】", "【未核实】")
|
||||
|
||||
|
||||
def resolve_root(arg: str | None, script_file: str) -> Path:
|
||||
"""档案根目录:--memory-root 参数 > 环境变量 ZIPING_MEMORY_ROOT > 技能根 memory/。"""
|
||||
if arg:
|
||||
return Path(arg).expanduser().resolve()
|
||||
env = os.environ.get(ENV_VAR)
|
||||
if env:
|
||||
return Path(env).expanduser().resolve()
|
||||
return Path(script_file).resolve().parents[1] / "memory"
|
||||
|
||||
|
||||
def key_hash(birth_clock: str, birthplace: str, gender: str) -> str:
|
||||
"""hash6 = sha1(出生钟表时间 + 出生地 + 性别) 前 6 位,区分同盘不同人。"""
|
||||
raw = f"{birth_clock.strip()}{birthplace.strip()}{gender.strip()}"
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:6]
|
||||
|
||||
|
||||
def archive_name(pillars: list[str], gender: str, hash6: str) -> str:
|
||||
return f"{''.join(pillars)}-{gender}-{hash6}.md"
|
||||
|
||||
|
||||
# —— frontmatter 读写(受限 YAML 子集)——
|
||||
|
||||
def _q(s: str) -> str:
|
||||
return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def _unq(s: str) -> str:
|
||||
s = s.strip()
|
||||
if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
|
||||
return s[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
return s
|
||||
|
||||
|
||||
def dump_frontmatter(meta: dict) -> str:
|
||||
lines = ["---"]
|
||||
for k in FIELDS:
|
||||
v = meta.get(k, "")
|
||||
if isinstance(v, (list, tuple)):
|
||||
lines.append(f"{k}: [{', '.join(_q(x) for x in v)}]")
|
||||
else:
|
||||
lines.append(f"{k}: {_q(v)}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
"""返回 (meta, 正文)。无 frontmatter 时 meta 为空 dict、正文为全文。"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return {}, text
|
||||
meta: dict = {}
|
||||
end = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == "---":
|
||||
end = i
|
||||
break
|
||||
m = re.match(r"^(\w+)\s*:\s*(.*)$", lines[i].rstrip("\n"))
|
||||
if not m:
|
||||
continue
|
||||
k, v = m.group(1), m.group(2).strip()
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
inner = v[1:-1].strip()
|
||||
meta[k] = [_unq(x) for x in inner.split(",")] if inner else []
|
||||
else:
|
||||
meta[k] = _unq(v)
|
||||
if end is None:
|
||||
return {}, text
|
||||
return meta, "".join(lines[end + 1:])
|
||||
|
||||
|
||||
# —— 正文骨架与小节替换 ——
|
||||
|
||||
def skeleton(name: str, gender: str, chart_md: str) -> str:
|
||||
return (
|
||||
f"# 档案:{name}({gender})\n\n"
|
||||
f"## 盘面\n\n{chart_md.strip()}\n\n"
|
||||
"## 主要关注方向\n\n(未记录)\n\n"
|
||||
"## 归纳\n\n"
|
||||
"- 性格:未断\n"
|
||||
"- 身体:未断\n"
|
||||
"- 事业:未断\n"
|
||||
"- 感情:未断\n"
|
||||
"- 婚姻:未断\n\n"
|
||||
"## 流年大事\n\n(未记录)\n\n"
|
||||
"## 反馈与澄清\n\n(无)\n"
|
||||
)
|
||||
|
||||
|
||||
def replace_section(body: str, heading: str, content: str) -> str:
|
||||
"""把 `## {heading}` 小节内容替换为 content;小节不存在则插到首个固定小节之前。
|
||||
|
||||
「盘面」内嵌的命盘 Markdown 自带 `## ` 级标题(四柱、大运……),
|
||||
故小节边界只认档案自身的固定小节(SECTIONS),不认任意 `## ` 行。
|
||||
"""
|
||||
lines = body.splitlines()
|
||||
target = f"## {heading}"
|
||||
bounds = {f"## {s}" for s in SECTIONS} - {target}
|
||||
start = next((i for i, ln in enumerate(lines) if ln.strip() == target), None)
|
||||
if start is None:
|
||||
first = next((i for i, ln in enumerate(lines) if ln.strip() in bounds),
|
||||
len(lines))
|
||||
block = [target, "", *content.strip().splitlines(), ""]
|
||||
return "\n".join(lines[:first] + [""] + block + lines[first:]).strip() + "\n"
|
||||
nxt = next((i for i in range(start + 1, len(lines))
|
||||
if lines[i].strip() in bounds), len(lines))
|
||||
block = [lines[start], "", *content.strip().splitlines(), ""]
|
||||
return "\n".join(lines[:start] + block + lines[nxt:]).strip() + "\n"
|
||||
|
||||
|
||||
# —— 检索 ——
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""大小写/空白容错比较口径。"""
|
||||
return "".join(str(s).split()).casefold()
|
||||
|
||||
|
||||
def iter_archives(root: Path):
|
||||
d = root / "archives"
|
||||
if not d.is_dir():
|
||||
return
|
||||
yield from sorted(d.glob("*.md"))
|
||||
|
||||
|
||||
def matches(meta: dict, *, name=None, birth=None, place=None,
|
||||
gender=None, pillars=None) -> bool:
|
||||
if name is not None and _norm(meta.get("name", "")) != _norm(name):
|
||||
return False
|
||||
if birth is not None and _norm(meta.get("birth_clock", "")) != _norm(birth):
|
||||
return False
|
||||
if place is not None and _norm(meta.get("birthplace", "")) != _norm(place):
|
||||
return False
|
||||
if gender is not None and _norm(meta.get("gender", "")) != _norm(gender):
|
||||
return False
|
||||
if pillars is not None:
|
||||
have = meta.get("pillars", [])
|
||||
if isinstance(have, str):
|
||||
have = have.split()
|
||||
if [_norm(p) for p in have] != [_norm(p) for p in pillars.split()]:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""命主档案检索 —— 按任意组合条件查 memory/archives/。
|
||||
|
||||
用法:
|
||||
python memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
|
||||
[--place 四川省成都市] [--gender 男] [--pillars "丁亥 乙巳 庚子 辛巳"]
|
||||
|
||||
条件可任意组合(至少一项),全部命中才算匹配;比较大小写/空白容错。
|
||||
输出:每份命中档案的路径与 frontmatter 摘要;无匹配输出「无档案」。
|
||||
|
||||
只读档案、不排盘,不经过软锁。档案规范见 memory/README.md。
|
||||
|
||||
退出码:0 检索完成(含无匹配);2 输入不可用;3 内部错误。
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _memory_store as store
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="memory_lookup.py",
|
||||
description="命主档案检索:按姓名/出生时刻/出生地/性别/四柱任意组合查 memory/archives/",
|
||||
)
|
||||
ap.add_argument("--name", default=None, help="姓名")
|
||||
ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别")
|
||||
ap.add_argument("--birth", default=None,
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM"')
|
||||
ap.add_argument("--place", default=None, help="出生地")
|
||||
ap.add_argument("--pillars", default=None,
|
||||
help='四柱干支,年月日时顺序,空格分隔,如 "丁亥 乙巳 庚子 辛巳"')
|
||||
ap.add_argument("--memory-root", default=None,
|
||||
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
|
||||
a = ap.parse_args()
|
||||
|
||||
if all(v is None for v in (a.name, a.gender, a.birth, a.place, a.pillars)):
|
||||
print("ERROR: 至少给一个检索条件(--name/--birth/--place/--gender/--pillars)。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if a.pillars is not None and len(a.pillars.split()) != 4:
|
||||
print("ERROR: --pillars 应为年月日时四组干支,如 \"丁亥 乙巳 庚子 辛巳\"。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
root = store.resolve_root(a.memory_root, __file__)
|
||||
hits = []
|
||||
for path in store.iter_archives(root):
|
||||
meta, _ = store.parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if not meta:
|
||||
continue
|
||||
if store.matches(meta, name=a.name, birth=a.birth, place=a.place,
|
||||
gender=a.gender, pillars=a.pillars):
|
||||
hits.append((path, meta))
|
||||
|
||||
if not hits:
|
||||
print("无档案")
|
||||
return 0
|
||||
print(f"命中 {len(hits)} 份档案:\n")
|
||||
for path, meta in hits:
|
||||
print(path)
|
||||
for k in store.FIELDS:
|
||||
v = meta.get(k, "")
|
||||
if isinstance(v, list):
|
||||
v = " ".join(str(x) for x in v)
|
||||
print(f" {k}: {v}")
|
||||
print()
|
||||
return 0
|
||||
except Exception:
|
||||
print("ERROR: 检索内部错误。", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user