From 5889d7a150c3892d49a765eb35acf02a78653833 Mon Sep 17 00:00:00 2001 From: moxiaonian Date: Fri, 9 Oct 2026 21:01:55 +0800 Subject: [PATCH] =?UTF-8?q?v3.5=EF=BC=9A=E5=91=BD=E4=B8=BB=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=E7=B3=BB=E7=BB=9F=EF=BC=9B=E6=8A=80=E8=83=BD=E4=BB=8B?= =?UTF-8?q?=E7=BB=8D=E6=8C=89=E5=9C=BA=E6=99=AF=E8=A7=A6=E5=8F=91=EF=BC=9B?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=BC=8F=E5=81=8F=E7=A6=BB=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 memory/ 命主档案:memory_save/memory_lookup 建档检索, 反馈三态(已核实/已证伪/未核实),档案只存本机不进副本 - SKILL.md description 改写为按问题域与使用场景触发; 输出契约补 R-禁-22 偏离须声明(编号+理由+书证)联动 - R-运-31 事类映射表改开放清单措辞 - 规则库 541→542 条(新增 R-禁-22),库版本 3.5 - 测试全套 69 项通过 --- README.md | 2 + SKILL.md | 18 +++- memory/README.md | 61 ++++++++++++++ memory/archives/.gitkeep | 0 rules/90_行运.md | 2 +- rules/95_禁则.md | 10 ++- rules/README.md | 5 +- scripts/_memory_store.py | 167 +++++++++++++++++++++++++++++++++++++ scripts/memory_lookup.py | 81 ++++++++++++++++++ scripts/memory_save.py | 176 +++++++++++++++++++++++++++++++++++++++ tests/test_memory.py | 119 ++++++++++++++++++++++++++ 11 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 memory/README.md create mode 100644 memory/archives/.gitkeep create mode 100644 scripts/_memory_store.py create mode 100644 scripts/memory_lookup.py create mode 100644 scripts/memory_save.py create mode 100644 tests/test_memory.py diff --git a/README.md b/README.md index 2c26237..68b9f7d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。 - **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。 +- **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。 ## 快速开始 @@ -53,6 +54,7 @@ rules/ 541 条编号解释规则(20 个文件) tests/golden/ 原书命例黄金集 scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询 data/ 城市经纬度数据与授权状态 +memory/ 命主档案(archives/ 数据仅存本机) vendor/ 内置依赖:tyme4py 1.5.0 + tzdata(纯 Python) tests/ 排盘脚本测试(时区、夏令时、日界、流年小运、端到端) ``` diff --git a/SKILL.md b/SKILL.md index 898626c..40261e4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: ZiPingEightChar -description: 子平八字:排盘与《子平真诠》格局法解释。输入姓名、性别、出生日期时刻(当地钟表时间)、出生地,脚本输出结构化命盘 Markdown(四柱、藏干、空亡、纳音、神煞、节令、大运、小运、全量流年、流月及排盘审计),模型依随附的 541 条编号规则(蒸馏自《子平真诠》《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》五书,含女命、神煞细节、性情、事类、调候、旺衰扶抑、童限等参看层)完成取格、成败、用神相神、行运、六亲与流年流月细节的解释。依赖已内置,无需安装。 +description: 八字命理:排盘与解读。以子平格局法为骨架,融合《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》等经典参看层。输入姓名、性别、出生日期时刻(当地钟表时间)、出生地,脚本输出结构化命盘 Markdown(四柱、藏干、空亡、纳音、神煞、节令、大运、小运、全量流年、流月及排盘审计),模型依随附的编号规则完成取格、成败、用神相神、行运、六亲与流年流月细节的解释;可回答婚姻感情、事业财运、健康、学业、六亲、大运流年等常见问题,含过往事件回溯与应期推断。当用户要求看八字、算命、排盘、分析命理、问运势时使用。依赖已内置,无需安装。 --- @@ -95,9 +95,9 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚 - **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。 - **夏令时**:1986–1991 年全国夏令时期间(每年 4 月中旬至 9 月中旬)出生者,钟表时间先回拨 1 小时再作后续修正——「钟表 23 点 ≠ 真太阳时 23 点」,是否晚子时以真太阳时为准(「排盘审计」段有「夏令时:是/否」)。 -## 解释规则(rules/ 目录,库版本 3.4) +## 解释规则(rules/ 目录,库版本 3.5) -取格、用神、断运等解释规则在 `rules/` 下,共 20 个文件、541 条编号规则,以《子平真诠》格局法为骨架,并蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》四书为格局定论后的参看层(性情/事类/六亲女命增补、调候、神煞书证、旺衰扶抑、童限;拓展条均逐条标注【拓展】);`rules/sources/` 按书归档五书原书全文可供回查,书目登记见 `rules/sources/README.md`。格式规范与锚点约定见 `rules/README.md`。 +取格、用神、断运等解释规则在 `rules/` 下,共 20 个文件、542 条编号规则,以《子平真诠》格局法为骨架,并蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》四书为格局定论后的参看层(性情/事类/六亲女命增补、调候、神煞书证、旺衰扶抑、童限;拓展条均逐条标注【拓展】);`rules/sources/` 按书归档五书原书全文可供回查,书目登记见 `rules/sources/README.md`。格式规范与锚点约定见 `rules/README.md`。 **执行顺序**: @@ -121,7 +121,7 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚 2. **直答** ★(问及具体事项或断及流年时必须有;纯取格问答可省)—— 对用户问题的一手回答:**一个主答案**,至多带一个备选,再给**置信度**(能定到年/只能定到窗口/定不了)。白话,三条以内,不展开底盘。用户问题预设某事已发生而盘上无硬信号时,直答「盘上看不出必见此事」(R-运-32 前提挑战),不顺其预设铺年份; 3. **现实对应** ★(问及婚姻、六亲、财运、健康等具体事项,或断及流年时必须有)—— 白话详解,见下「现实对应段」; 4. **分析推理(术语段)** —— 按 取格 → 格之成败 → 用神与相神 → 格局高低 → 行运喜忌 的内部次序给出专业判定,须与问题紧密关联。出处用篇名级(如「依《子平真诠》论行运篇」)或直述,**不出现规则编号**(编号收调试层,见「调试与溯源」)。行运喜忌用户问到才展开,按 `rules/90_行运.md` §7 断运操作序列走:先运后年,喜忌系于格局成败,不逐年流水断,关键流年按 §8 落地为现实事件候选(每年 2–3 个、按可能性排序),逐年落笔前经 §9 核对回路重新查表;**全量候选与排序依据收在本段,直答段只取其一**。现实对应段的每一条在本段都必须有出处; -5. **存疑与边界声明** —— 命中的存疑规则、规则两可处、无法确认处,如实声明;无则写「无」; +5. **存疑与边界声明** —— 命中的存疑规则、规则两可处、无法确认处,如实声明;无则写「无」;不取某条规则之判时,在此段按 R-禁-22 声明偏离(编号、理由、书证); 6. **结尾标识与免责** ★ —— 凡含解读内容的轮次,末尾固定附上以下引用块,一字不改;纯提问或澄清的短轮可省: > 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com @@ -173,6 +173,16 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚 - **两种展开情形**:① 用户追问「凭什么这么断」「依据是什么」时,展开该结论对应的规则编号、条件、判定与原文;② 用户或开发者明言调试(如「调试模式」「显示依据」)时,各段末尾恢复编号依据行。 - 用户面正文出现 `(依 R-亲-12)` 这类夹注即视为违规——编号是溯源用的,不是给读者看的。 +## 档案(memory/) + +本机命主档案:每位命主一个 Markdown 文件,存于 `memory/archives/`(schema、回写纪律与隐私边界详见 `memory/README.md`)。档案只存于使用者本机,不随授权副本分发。 + +- **排盘前查档**:拿到出生信息后先跑 + `python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男` + (亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。 +- **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。 +- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。 + ## 文风与反 AI 腔(强制) 命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止: diff --git a/memory/README.md b/memory/README.md new file mode 100644 index 0000000..010d1b3 --- /dev/null +++ b/memory/README.md @@ -0,0 +1,61 @@ +# 命主档案(memory/) + +每位命主一个 Markdown 档案,存于 `archives/`。建档与检索只经两个脚本: + +```bash +# 建档/更新(即一次排盘:授权副本照常过软锁、扣减一次) +python scripts/memory_save.py --name 张三 --gender 男 \ + --birth "1990-05-14 08:30" --place "四川省成都市" + +# 检索(条件任意组合、至少一项;大小写/空白容错) +python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \ + [--place 四川省成都市] [--gender 男] [--pillars "庚午 辛巳 己卯 戊辰"] +``` + +两脚本均可用 `--memory-root` 或环境变量 `ZIPING_MEMORY_ROOT` 覆盖档案根目录 +(默认为技能根 `memory/`),便于测试与多副本隔离。本文件随授权副本分发; +`archives/` 内的档案数据不分发(`tools/` 已排除,骨架 `.gitkeep` 保留)。 + +## 文件名主键 + +`archives/<四柱连写>-<性别>-.md`,其中 +hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制, +用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」, +正文其余小节原样保留。 + +## Schema + +YAML frontmatter(受限子集,脚本自解析,不依赖 pyyaml): + +| 字段 | 含义 | +|---|---| +| `name` | 姓名 | +| `gender` | 男 / 女 | +| `birth_clock` | 出生钟表时间(原始输入) | +| `birthplace` | 出生地(原始输入) | +| `true_solar_time` | 真太阳时(排盘归算结果) | +| `pillars` | 四柱四词,年月日时顺序 | +| `day_master` | 日干 | +| `created_at` | 建档时间 | +| `updated_at` | 最近建档/刷新时间 | + +正文固定小节(次序不变): + +- `## 盘面` —— 命盘 Markdown 全文,每次建档刷新; +- `## 主要关注方向` —— 命主历次提出的主线问题; +- `## 归纳` —— 性格 / 身体 / 事业 / 感情 / 婚姻各一行,未断写「未断」; +- `## 流年大事` —— 已断或已核对的流年事件; +- `## 反馈与澄清` —— 每条以状态标签开头:【已核实】【已证伪】【未核实】。 + +## 回写纪律 + +- 推测性归纳标【未核实】;命主确认改【已核实】;被否改【已证伪】并补一句实际情况。 +- 用户核对/澄清任何断语后,当轮把结果写回该档案「反馈与澄清」小节; + 结构性结论同步更新「归纳」各行。 +- 分析推理时直接参用已核实/已证伪条目;已证伪的断法不得再断。 + +## 隐私边界 + +档案只存于使用者本机:不随授权副本分发、不进 share。 +档案内容属命主个人信息,模型不得把档案原文外发 +(不粘贴到外部对话、不上传网络、不写入其他文件)。 diff --git a/memory/archives/.gitkeep b/memory/archives/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rules/90_行运.md b/rules/90_行运.md index 7bdef79..bb9fd39 100644 --- a/rules/90_行运.md +++ b/rules/90_行运.md @@ -323,7 +323,7 @@ ### R-运-31 事类映射表:十神、宫位、神煞的现实领域 - **条件**:需要把某字(天干十神、地支宫位、神煞)翻译成现实事类时。 -- **判定**:按下表取象,再按 R-运-32 合成候选事件。 +- **判定**:按下表取象,再按 R-运-32 合成候选事件。本表为开放清单、非穷举;表外取象须与十神/宫位/神煞之本义相洽,且只在分析推理段注明,不进直答段。 **十神事类**(被透清、被引动之干,或宫位中所藏透出之神): diff --git a/rules/95_禁则.md b/rules/95_禁则.md index ac694c4..7e4c5a2 100644 --- a/rules/95_禁则.md +++ b/rules/95_禁则.md @@ -1,7 +1,7 @@ # 子平真诠 · 禁则(不得为之事) - + ## 一、神煞星辰之禁(第二十一篇·论星辰无关格局) @@ -150,4 +150,12 @@ - **出处**:(综括,无单一原文) - **原文**:(综括,无单一原文) +--- + +### R-禁-22 偏离须声明(声明式偏离通道) +- **条件**:模型判定某条规则不适用于当前命局、或不取其判时。 +- **判定**:允许偏离,但必须在输出契约第 5 段(存疑与边界)声明——写明不取哪条编号、理由是什么、书证是哪一篇(rules/sources/ 篇目);无声明而偏离者视为违规。本条不豁免本文件其余禁则(R-禁-01 等硬性禁断不因声明而放行)。 +- **出处**:综括,无单一原文 +- **原文**:(综括,无单一原文) + diff --git a/rules/README.md b/rules/README.md index a365d5b..ca960a8 100644 --- a/rules/README.md +++ b/rules/README.md @@ -102,7 +102,10 @@ rules/ ## 6. 版本与溯源 - 规则库版本: -- **3.4**(2026-10-08)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。 +- **3.5**(2026-10-09)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。 + +### 3.5 相对 3.4: +- description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。 ### 3.4 相对 3.3: - 童限规则入库(537 → 541 条,20 文件不变)——`90_行运.md` 新增 §10 童限四条(R-运-36 童限以流年为主小运为辅、R-运-37 小运起法从醉醒子时柱法、R-运-38 小运较量之法、R-运-39 童限断运程序五组引动照用以小运代大运位),皆【拓展】条,书证《命理探源》卷二起例推小运法(引《星平会海》)、卷五论小运与《三命通会》卷二论小运;古法「男起丙寅女起壬申」存疑备录不取; diff --git a/scripts/_memory_store.py b/scripts/_memory_store.py new file mode 100644 index 0000000..6329b36 --- /dev/null +++ b/scripts/_memory_store.py @@ -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 diff --git a/scripts/memory_lookup.py b/scripts/memory_lookup.py new file mode 100644 index 0000000..37e7365 --- /dev/null +++ b/scripts/memory_lookup.py @@ -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()) diff --git a/scripts/memory_save.py b/scripts/memory_save.py new file mode 100644 index 0000000..d08e60c --- /dev/null +++ b/scripts/memory_save.py @@ -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()) diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..9070918 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,119 @@ +"""档案(memory/):save → lookup 往返、同主键更新保留正文小节、无匹配输出。 + +脚本以 --memory-root / ZIPING_MEMORY_ROOT 覆盖档案根目录,测试用 tmp_path 隔离。 +""" +import os +import re +import subprocess +import sys +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = SKILL_ROOT / "scripts" +SAVE = SCRIPTS_DIR / "memory_save.py" +LOOKUP = SCRIPTS_DIR / "memory_lookup.py" + +ZHANGSAN = ["--name", "张三", "--gender", "男", + "--birth", "1990-05-14 08:30", "--place", "四川省成都市"] + + +def run(script, mem_root, *args, use_env=False): + env = dict(os.environ) + env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "") + cmd = [sys.executable, str(script), *args] + if use_env: + env["ZIPING_MEMORY_ROOT"] = str(mem_root) + else: + cmd += ["--memory-root", str(mem_root)] + return subprocess.run(cmd, cwd=str(SCRIPTS_DIR), env=env, + capture_output=True, text=True, timeout=120) + + +def read_meta(text: str) -> dict: + """受限 frontmatter 解析:仅取测试断言所需的标量字段。""" + lines = text.splitlines() + assert lines[0] == "---" + meta = {} + for ln in lines[1:]: + if ln == "---": + break + k, _, v = ln.partition(":") + meta[k.strip()] = v.strip().strip('"') + return meta + + +class TestSaveLookupRoundtrip: + def test_save_then_lookup_by_name_and_pillars(self, tmp_path): + r = run(SAVE, tmp_path, *ZHANGSAN) + assert r.returncode == 0, f"stderr:\n{r.stderr}" + path = Path(r.stdout.strip()) + assert path.is_file() + assert path.parent == tmp_path / "archives" + text = path.read_text(encoding="utf-8") + # schema:frontmatter + 固定小节 + 嵌入命盘 + meta = read_meta(text) + assert meta["name"] == "张三" and meta["gender"] == "男" + assert meta["birth_clock"] == "1990-05-14 08:30" + assert meta["birthplace"] == "四川省成都市" + assert meta["created_at"] and meta["updated_at"] + for section in ("## 盘面", "## 主要关注方向", "## 归纳", + "## 流年大事", "## 反馈与澄清"): + assert section in text + assert "## 四柱" in text # 盘面嵌入命盘全文 + assert "- 性格:未断" in text + + # 按姓名检索命中(参数覆盖 memory 根目录) + r = run(LOOKUP, tmp_path, "--name", "张三") + assert r.returncode == 0, f"stderr:\n{r.stderr}" + assert str(path) in r.stdout and "张三" in r.stdout + + # 按四柱检索命中(环境变量覆盖 memory 根目录) + pillars = meta["pillars"].strip("[]").replace('"', "").replace(",", "") + r = run(LOOKUP, tmp_path, "--pillars", pillars, use_env=True) + assert r.returncode == 0, f"stderr:\n{r.stderr}" + assert str(path) in r.stdout + + # 出生信息组合检索,空白容错 + r = run(LOOKUP, tmp_path, "--birth", "1990-05-14 08:30", + "--place", " 四川省成都市 ", "--gender", "男") + assert r.returncode == 0 and str(path) in r.stdout + + +class TestSameKeyUpdate: + def test_update_preserves_body_sections(self, tmp_path): + r1 = run(SAVE, tmp_path, *ZHANGSAN) + assert r1.returncode == 0, f"stderr:\n{r1.stderr}" + path = Path(r1.stdout.strip()) + + # 模拟解释完成后的回写:改归纳、加反馈、固定 created_at 以便断言 + text = path.read_text(encoding="utf-8") + text = text.replace("- 性格:未断", "- 性格:沉稳内敛【已核实】") + text = text.replace("(无)", "- 【已证伪】断 2024 年升职;实际为调岗未升。") + text = re.sub(r'^created_at:.*$', 'created_at: "2000-01-01 00:00"', + text, count=1, flags=re.M) + path.write_text(text, encoding="utf-8") + + # 同主键再次建档:同一文件、盘面刷新、其余小节保留、created_at 不动 + r2 = run(SAVE, tmp_path, *ZHANGSAN) + assert r2.returncode == 0, f"stderr:\n{r2.stderr}" + assert Path(r2.stdout.strip()) == path + assert list((tmp_path / "archives").glob("*.md")) == [path] + text = path.read_text(encoding="utf-8") + assert "- 性格:沉稳内敛【已核实】" in text + assert "【已证伪】断 2024 年升职;实际为调岗未升。" in text + assert "## 四柱" in text # 盘面仍为完整命盘 + assert text.count("# 命盘") == 1 # 旧盘面整体替换,无残留重复 + meta = read_meta(text) + assert meta["created_at"] == "2000-01-01 00:00" + assert meta["updated_at"] != meta["created_at"] + + +class TestLookupNoMatch: + def test_no_match_exit_0(self, tmp_path): + r = run(LOOKUP, tmp_path, "--name", "不存在的人") + assert r.returncode == 0, f"stderr:\n{r.stderr}" + assert "无档案" in r.stdout + + def test_no_condition_exit_2(self, tmp_path): + r = run(LOOKUP, tmp_path) + assert r.returncode == 2