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:
@@ -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/ 排盘脚本测试(时区、夏令时、日界、流年小运、端到端)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 腔(强制)
|
||||||
|
|
||||||
命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止:
|
命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止:
|
||||||
|
|||||||
@@ -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。
|
||||||
|
档案内容属命主个人信息,模型不得把档案原文外发
|
||||||
|
(不粘贴到外部对话、不上传网络、不写入其他文件)。
|
||||||
+1
-1
@@ -323,7 +323,7 @@
|
|||||||
|
|
||||||
### R-运-31 事类映射表:十神、宫位、神煞的现实领域
|
### R-运-31 事类映射表:十神、宫位、神煞的现实领域
|
||||||
- **条件**:需要把某字(天干十神、地支宫位、神煞)翻译成现实事类时。
|
- **条件**:需要把某字(天干十神、地支宫位、神煞)翻译成现实事类时。
|
||||||
- **判定**:按下表取象,再按 R-运-32 合成候选事件。
|
- **判定**:按下表取象,再按 R-运-32 合成候选事件。本表为开放清单、非穷举;表外取象须与十神/宫位/神煞之本义相洽,且只在分析推理段注明,不进直答段。
|
||||||
|
|
||||||
**十神事类**(被透清、被引动之干,或宫位中所藏透出之神):
|
**十神事类**(被透清、被引动之干,或宫位中所藏透出之神):
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -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
@@ -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 童限断运程序五组引动照用以小运代大运位),皆【拓展】条,书证《命理探源》卷二起例推小运法(引《星平会海》)、卷五论小运与《三命通会》卷二论小运;古法「男起丙寅女起壬申」存疑备录不取;
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user