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
+2
View File
@@ -9,6 +9,7 @@
- **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。 - **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。
- **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。 - **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
- **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。
## 快速开始 ## 快速开始
@@ -53,6 +54,7 @@ rules/ 541 条编号解释规则(20 个文件)
tests/golden/ 原书命例黄金集 tests/golden/ 原书命例黄金集
scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询 scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询
data/ 城市经纬度数据与授权状态 data/ 城市经纬度数据与授权状态
memory/ 命主档案(archives/ 数据仅存本机)
vendor/ 内置依赖:tyme4py 1.5.0 + tzdata(纯 Python) vendor/ 内置依赖:tyme4py 1.5.0 + tzdata(纯 Python)
tests/ 排盘脚本测试(时区、夏令时、日界、流年小运、端到端) tests/ 排盘脚本测试(时区、夏令时、日界、流年小运、端到端)
``` ```
+14 -4
View File
@@ -1,6 +1,6 @@
--- ---
name: ZiPingEightChar name: ZiPingEightChar
description: 子平八字:排盘与《子平真诠》格局法解释。输入姓名、性别、出生日期时刻(当地钟表时间)、出生地,脚本输出结构化命盘 Markdown(四柱、藏干、空亡、纳音、神煞、节令、大运、小运、全量流年、流月及排盘审计),模型依随附的 541 条编号规则(蒸馏自《子平真诠》《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》五书,含女命、神煞细节、性情、事类、调候、旺衰扶抑、童限等参看层)完成取格、成败、用神相神、行运、六亲与流年流月细节的解释。依赖已内置,无需安装。 description: 八字命理:排盘与解读。以子平格局法为骨架,融合《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》等经典参看层。输入姓名、性别、出生日期时刻(当地钟表时间)、出生地,脚本输出结构化命盘 Markdown(四柱、藏干、空亡、纳音、神煞、节令、大运、小运、全量流年、流月及排盘审计),模型依随附的编号规则完成取格、成败、用神相神、行运、六亲与流年流月细节的解释;可回答婚姻感情、事业财运、健康、学业、六亲、大运流年等常见问题,含过往事件回溯与应期推断。当用户要求看八字、算命、排盘、分析命理、问运势时使用。依赖已内置,无需安装。
--- ---
<!-- 副本编号:LY-20261007-AC63 · 授权对象:normal · 测试授权 · 禁止商业用途与个人传播 --> <!-- 副本编号:LY-20261007-AC63 · 授权对象:normal · 测试授权 · 禁止商业用途与个人传播 -->
@@ -95,9 +95,9 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。 - **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。
- **夏令时**:1986–1991 年全国夏令时期间(每年 4 月中旬至 9 月中旬)出生者,钟表时间先回拨 1 小时再作后续修正——「钟表 23 点 ≠ 真太阳时 23 点」,是否晚子时以真太阳时为准(「排盘审计」段有「夏令时:是/否」)。 - **夏令时**: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 前提挑战),不顺其预设铺年份; 2. **直答** ★(问及具体事项或断及流年时必须有;纯取格问答可省)—— 对用户问题的一手回答:**一个主答案**,至多带一个备选,再给**置信度**(能定到年/只能定到窗口/定不了)。白话,三条以内,不展开底盘。用户问题预设某事已发生而盘上无硬信号时,直答「盘上看不出必见此事」(R-运-32 前提挑战),不顺其预设铺年份;
3. **现实对应** ★(问及婚姻、六亲、财运、健康等具体事项,或断及流年时必须有)—— 白话详解,见下「现实对应段」; 3. **现实对应** ★(问及婚姻、六亲、财运、健康等具体事项,或断及流年时必须有)—— 白话详解,见下「现实对应段」;
4. **分析推理(术语段)** —— 按 取格 → 格之成败 → 用神与相神 → 格局高低 → 行运喜忌 的内部次序给出专业判定,须与问题紧密关联。出处用篇名级(如「依《子平真诠》论行运篇」)或直述,**不出现规则编号**(编号收调试层,见「调试与溯源」)。行运喜忌用户问到才展开,按 `rules/90_行运.md` §7 断运操作序列走:先运后年,喜忌系于格局成败,不逐年流水断,关键流年按 §8 落地为现实事件候选(每年 2–3 个、按可能性排序),逐年落笔前经 §9 核对回路重新查表;**全量候选与排序依据收在本段,直答段只取其一**。现实对应段的每一条在本段都必须有出处; 4. **分析推理(术语段)** —— 按 取格 → 格之成败 → 用神与相神 → 格局高低 → 行运喜忌 的内部次序给出专业判定,须与问题紧密关联。出处用篇名级(如「依《子平真诠》论行运篇」)或直述,**不出现规则编号**(编号收调试层,见「调试与溯源」)。行运喜忌用户问到才展开,按 `rules/90_行运.md` §7 断运操作序列走:先运后年,喜忌系于格局成败,不逐年流水断,关键流年按 §8 落地为现实事件候选(每年 2–3 个、按可能性排序),逐年落笔前经 §9 核对回路重新查表;**全量候选与排序依据收在本段,直答段只取其一**。现实对应段的每一条在本段都必须有出处;
5. **存疑与边界声明** —— 命中的存疑规则、规则两可处、无法确认处,如实声明;无则写「无」; 5. **存疑与边界声明** —— 命中的存疑规则、规则两可处、无法确认处,如实声明;无则写「无」;不取某条规则之判时,在此段按 R-禁-22 声明偏离(编号、理由、书证);
6. **结尾标识与免责** ★ —— 凡含解读内容的轮次,末尾固定附上以下引用块,一字不改;纯提问或澄清的短轮可省: 6. **结尾标识与免责** ★ —— 凡含解读内容的轮次,末尾固定附上以下引用块,一字不改;纯提问或澄清的短轮可省:
> 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com > 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com
@@ -173,6 +173,16 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **两种展开情形**:① 用户追问「凭什么这么断」「依据是什么」时,展开该结论对应的规则编号、条件、判定与原文;② 用户或开发者明言调试(如「调试模式」「显示依据」)时,各段末尾恢复编号依据行。 - **两种展开情形**:① 用户追问「凭什么这么断」「依据是什么」时,展开该结论对应的规则编号、条件、判定与原文;② 用户或开发者明言调试(如「调试模式」「显示依据」)时,各段末尾恢复编号依据行。
- 用户面正文出现 `(依 R-亲-12)` 这类夹注即视为违规——编号是溯源用的,不是给读者看的。 - 用户面正文出现 `(依 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 腔(强制) ## 文风与反 AI 腔(强制)
命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止: 命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止:
+61
View File
@@ -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/<四柱连写>-<性别>-<hash6>.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。
档案内容属命主个人信息,模型不得把档案原文外发
(不粘贴到外部对话、不上传网络、不写入其他文件)。
View File
+1 -1
View File
@@ -323,7 +323,7 @@
### R-运-31 事类映射表:十神、宫位、神煞的现实领域 ### R-运-31 事类映射表:十神、宫位、神煞的现实领域
- **条件**:需要把某字(天干十神、地支宫位、神煞)翻译成现实事类时。 - **条件**:需要把某字(天干十神、地支宫位、神煞)翻译成现实事类时。
- **判定**:按下表取象,再按 R-运-32 合成候选事件。 - **判定**:按下表取象,再按 R-运-32 合成候选事件。本表为开放清单、非穷举;表外取象须与十神/宫位/神煞之本义相洽,且只在分析推理段注明,不进直答段。
**十神事类**(被透清、被引动之干,或宫位中所藏透出之神): **十神事类**(被透清、被引动之干,或宫位中所藏透出之神):
+9 -1
View File
@@ -1,7 +1,7 @@
# 子平真诠 · 禁则(不得为之事) # 子平真诠 · 禁则(不得为之事)
<!-- rules_version: 1.0 --> <!-- rules_version: 1.0 -->
<!-- 本篇蒸馏批谬三篇(第二十一篇·论星辰无关格局、第二十九篇·论时说拘泥格局、第三十篇·论时说以讹传讹),为解释全程之约束:取格、成败、救应、高低、行运皆不得违。条目字段用「禁止 / 理由 / 出处 / 原文」,异于分格之「条件 / 判定」。神煞、纳音、空亡之流年流月细节参看另见 94_神煞细节.md(体系拓展层),只叠加于格局定论之后,受本篇 R-禁-01 分层约束。第四节(R-禁-20、R-禁-21)为滴天髓旺衰扶抑层之接口约束,与 89_旺衰扶抑.md 配套。 --> <!-- 本篇蒸馏批谬三篇(第二十一篇·论星辰无关格局、第二十九篇·论时说拘泥格局、第三十篇·论时说以讹传讹),为解释全程之约束:取格、成败、救应、高低、行运皆不得违。条目字段用「禁止 / 理由 / 出处 / 原文」,异于分格之「条件 / 判定」。神煞、纳音、空亡之流年流月细节参看另见 94_神煞细节.md(体系拓展层),只叠加于格局定论之后,受本篇 R-禁-01 分层约束。第四节(R-禁-20、R-禁-21)为滴天髓旺衰扶抑层之接口约束,与 89_旺衰扶抑.md 配套。文末 R-禁-22 为声明式偏离之元规则(与输出契约第 5 段联动)。 -->
## 一、神煞星辰之禁(第二十一篇·论星辰无关格局) ## 一、神煞星辰之禁(第二十一篇·论星辰无关格局)
@@ -150,4 +150,12 @@
- **出处**:(综括,无单一原文) - **出处**:(综括,无单一原文)
- **原文**:(综括,无单一原文) - **原文**:(综括,无单一原文)
---
### R-禁-22 偏离须声明(声明式偏离通道)
- **条件**:模型判定某条规则不适用于当前命局、或不取其判时。
- **判定**:允许偏离,但必须在输出契约第 5 段(存疑与边界)声明——写明不取哪条编号、理由是什么、书证是哪一篇(rules/sources/ 篇目);无声明而偏离者视为违规。本条不豁免本文件其余禁则(R-禁-01 等硬性禁断不因声明而放行)。
- **出处**:综括,无单一原文
- **原文**:(综括,无单一原文)
<!-- 副本 LY-20261007-AC63 · 授权对象 normal --> <!-- 副本 LY-20261007-AC63 · 授权对象 normal -->
+4 -1
View File
@@ -102,7 +102,10 @@ rules/
## 6. 版本与溯源 ## 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: ### 3.4 相对 3.3:
- 童限规则入库(537 → 541 条,20 文件不变)——`90_行运.md` 新增 §10 童限四条(R-运-36 童限以流年为主小运为辅、R-运-37 小运起法从醉醒子时柱法、R-运-38 小运较量之法、R-运-39 童限断运程序五组引动照用以小运代大运位),皆【拓展】条,书证《命理探源》卷二起例推小运法(引《星平会海》)、卷五论小运与《三命通会》卷二论小运;古法「男起丙寅女起壬申」存疑备录不取; - 童限规则入库(537 → 541 条,20 文件不变)——`90_行运.md` 新增 §10 童限四条(R-运-36 童限以流年为主小运为辅、R-运-37 小运起法从醉醒子时柱法、R-运-38 小运较量之法、R-运-39 童限断运程序五组引动照用以小运代大运位),皆【拓展】条,书证《命理探源》卷二起例推小运法(引《星平会海》)、卷五论小运与《三命通会》卷二论小运;古法「男起丙寅女起壬申」存疑备录不取;
+167
View File
@@ -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
+81
View File
@@ -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())
+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())
+119
View File
@@ -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