v3.6.0 同步发版

- 交互契约改对话式询问(禁点选工具、一次1~2问、先铺垫再问),规则库 3.6
- 盘面图默认生成+chart_shot 截图;品牌区、大运/流年小卡信息补全、交流群二维码卡
- day_boundary 子正换日;memory_save --update-chart 换盘留痕;share_export 事件简介+城市级地域
- share_push 失败诊断+代理引导;_runtime_guard;修 STAR_ORDER 三奇贵人 KeyError
- README 规则计数 541→542;manifest 重建(1275 文件);测试 142 项全绿
This commit is contained in:
2026-10-10 13:13:41 +08:00
parent f725df8ff6
commit 433c9b4a41
32 changed files with 1468 additions and 389 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
# ZiPingEightChar · 子平八字(公众授权版)
> 子平八字排盘 + 《子平真诠》格局法解释的 AI 技能(Skill)。
> 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 541 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。
> 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 542 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。
## 这是什么
这是一个面向 AI Agent 的技能包(Skill),由两部分组成:
- **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。
- **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
- **规则库(`rules/`)**:542 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
- **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。
## 快速开始
@@ -49,7 +49,7 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
```
SKILL.md 技能入口:用法、输出契约、解释规则、文风要求
rules/ 541 条编号解释规则(20 个文件)
rules/ 542 条编号解释规则(20 个文件)
sources/ 五书原文归档(回查用)
tests/golden/ 原书命例黄金集
scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询
+30 -17
View File
@@ -25,6 +25,8 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市" --html
```
加 `--shot` 则在生成 HTML 的同时截图 PNG(playwright 优先、Chrome 无头兜底,见「盘面图」节),HTML 与 PNG 路径打在 stdout 末尾;截图工具不可用时只出 HTML 并附降级提示,不影响命盘输出。
另配岁运神煞查询工具(本命神煞已列于命盘 `## 神煞`,此工具查某流年/流月/大运/流日干支相对本命所逢之神煞):
```bash
@@ -34,7 +36,7 @@ python scripts/query_shensha.py --gender 女 \
## 运行环境
- **只需 Python ≥ 3.10,无需安装任何依赖。** tyme4py 1.5.0 与 tzdata 已内置在 `vendor/`(纯 Python / 纯数据,跨平台跨架构),脚本启动时自动优先加载,优先于环境中已安装的同名包。原理与升级方法见 `vendor/VENDOR.md`。
- **只需 Python ≥ 3.10,无需安装任何依赖。** tyme4py 1.5.0 与 tzdata 已内置在 `vendor/`(纯 Python / 纯数据,跨平台跨架构),脚本启动时自动优先加载,优先于环境中已安装的同名包。原理与升级方法见 `vendor/VENDOR.md`。解释器低于 3.10 时脚本拒绝运行:stderr 打印中文提示(说明所需版本与当前解释器路径)并以退出码 3 结束——macOS 系统自带 python3 为 3.9,不可用,请改用更新版本的 Python 运行。
- 开发模式回退:删除 `vendor/` 即改用环境安装的包(`requirements.txt` 锁定不变),两种方式输出一致。
## 输入参数(四个,全部必需)
@@ -48,14 +50,16 @@ python scripts/query_shensha.py --gender 女 \
**时刻是必需的。** 时辰决定时柱,差一个时辰就是另一张盘。用户只给了日期(「1990 年 5 月 14 日」)时,**必须追问具体时刻**,不要自行假设 12:00 或任何时刻。
## Intake 追问话术
## Intake 对话式询问(强制)
输入不全或不可用时,按退出码与下列口径追问,语气温和、一次只问必要的项;多项缺失时合成一次提问,不逐条单独打断:
命理询问是分支式的——前一个回答决定下一个问题。**所有询问一律以普通对话消息进行,禁止调用结构化问卷/点选工具(AskUserQuestion 之类),也禁止在正文罗列编号问题清单让用户勾选。** 口吻是命理老师傅聊天:自然、简短、口语化。
- **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,所以出生时间需要精确到小时。您大概几点出生?(如『早上 8 点半』『下午 3 点左右』均可)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,并说明哪些结论会随时柱变化、哪些不受影响。」
- **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。请告诉我您出生的城市或县(如『四川成都』『浙江义乌』)。」
- **缺性别**:「性别决定大运顺排还是逆排,请补充一下性别。」
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。与缺项追问合成一次提问,不单独多占一轮。
- **节奏**:一次最多问 1~2 个最关键的问题,得到回答再决定下一个问题。缺多项时按 时刻 → 地点 → 性别 的优先级逐个补,不把整串问题一次甩给用户。
- **先铺垫再问**:盘已排出时,提问前先用一两句不依赖提问即可断出的盘面信息做铺垫(日主、月令、显著特征),再自然带出问题。例如:「这盘甲木生腊月,底子偏寒——先问一句:十来岁那年是不是住过院?」出生信息还不全、盘未排出时没有铺垫可用,直接问缺项即可。
- **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,出生时间得精确到小时。大概几点出生?(『早上 8 点半』『下午 3 点左右』都行)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,哪些结论会随时柱变化、哪些不受影响,分开说明。」
- **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。生在哪个城市或县?(如『四川成都』『浙江义乌』)」
- **缺性别**:「性别决定大运顺排还是逆排,补一下性别。」
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。
- **时间格式错误**(退出码 2):照 stderr 提示转述,不自行猜测改写。
- **库内部错误**(退出码 3):把 stderr 原样报告给用户,**不要猜盘**,不要凭印象编造四柱。
- **授权不可用**(退出码 4):把 stderr 原样转述给用户(次数用尽 / 已到期 / 授权文件被改动)。**不要尝试绕过或猜测授权状态**;需要续期请用户联系授权方(见 LICENSE.md)。
@@ -66,7 +70,7 @@ python scripts/query_shensha.py --gender 女 \
|---|---|---|
| 0 | 成功 | 读 stdout 的 Markdown,进入解释 |
| 2 | 输入不可用(出生地无法解析 / 时间格式错误或缺时刻) | 读 stderr 的提示,**向用户追问**后重试 |
| 3 | 库内部错误 | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 |
| 3 | 库内部错误 / 运行环境不符(Python < 3.10) | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 |
| 4 | 授权不可用(软锁:次数用尽 / 已到期 / 授权文件被改动) | 把 stderr 原样转述给用户,**不要绕过** |
## 授权与分发
@@ -103,13 +107,13 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。
- **夏令时**:1986–1991 年全国夏令时期间(每年 4 月中旬至 9 月中旬)出生者,钟表时间先回拨 1 小时再作后续修正——「钟表 23 点 ≠ 真太阳时 23 点」,是否晚子时以真太阳时为准(「排盘审计」段有「夏令时:是/否」)。
## 解释规则(rules/ 目录,库版本 3.5)
## 解释规则(rules/ 目录,库版本 3.6)
取格、用神、断运等解释规则在 `rules/` 下,共 20 个文件、542 条编号规则,以《子平真诠》格局法为骨架,并蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》四书为格局定论后的参看层(性情/事类/六亲女命增补、调候、神煞书证、旺衰扶抑、童限;拓展条均逐条标注【拓展】);`rules/sources/` 按书归档五书原书全文可供回查,书目登记见 `rules/sources/README.md`。格式规范与锚点约定见 `rules/README.md`。
**执行顺序**:
0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见 Intake 场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线;
0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见「Intake 对话式询问」场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线;
1. 读命盘 Markdown,加载 `rules/00_总纲_取格.md`,按 §1 六步决策流程定格;
2. 命中后加载对应分格文件(`rules/11_正官格.md` … `rules/19_杂格.md`),判成/败/救/应、用神相神、格局高低;
3. 断运时加载 `rules/90_行运.md`(§7 断运操作序列定喜忌应期,§8 事类落地把关键流年译成现实事件候选,§9 断年核对回路,§10 童限)与分格文件的「取运」节;覆盖多个流年的批量断运按 R-运-33 **核对前置**——先以场景判定分流,可核对者先锚点核对、不可核对者直推,输出只出现一次。**推断任一具体流年/流月前,必须按 R-运-34 五步核对重新打开规则文件查表,不得凭会话记忆推断**;编号记录只列本轮实际查阅的条目(R-运-35),收调试层(见「调试与溯源」);
@@ -134,6 +138,8 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
> 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com
**共建提示(首次讲解后)**:本次会话的第一次完整讲解(第 1–6 段全部输出完毕)之轮,在免责引用块**之前**顺带一句共建提示——第 6 段引用块保持一字不改、仍在全轮末尾;**同一会话只提示一次**,后续问答轮不再重复。口吻延续老师傅人设,自然一句带过,不写成广告,参考:「这盘先看到这。若愿意为这门手艺出份力——会话结束后直接发我『推送』二字,会把脱敏后的盘面存入共建档案库,用来校准后续版本;推与不推,都不影响看盘。」用户回应「推送」(或「推送黄金集」等明确意图)后,进入「档案共建(share/)」节既有流程:导出过目 → 授权问答 → 推送授权话术 → 推送计励。
**★边界声明的次数上限**:单轮回答中「这一层我给不出 / 这不是干这个的 / 硬报数字就是忽悠」型的方法边界说明**至多出现一次**,且必须并入第 5 段,不得另起一段、不得打断解读节奏。其余场合一律改用模糊语自然带过(见「文风」节)。声明本身不得写成辩解或自嘲。
**阶段纪律**:落笔之前遇下列情形,**必须先向用户声明分歧再继续**——
@@ -148,10 +154,10 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
## 断运交互(核对前置)
用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次:
用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次。核对同样走对话式询问(见「Intake 对话式询问」):不用结构化工具、不列编号选项,一次最多问一两个锚点,答完再定下一个;R-运-33 的核对程序(锚点选取、候选排序、证伪回查)照旧执行。
1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往),与缺项追问合成一次提问;
2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一),每个锚点年一个问题,选项为按可能性排序的 2–3 个候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次结构化提问,不逐条单独打断。断语涉及学业年份时,学制锚点并入同一次提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运。无询问工具的环境,在正文列出编号选项请用户回复编号;
1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往);
2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一)。每问先给铺垫——该年盘上的显著信号一两句——再自然带出按可能性排序的 2–3 个候选事件,外加「都对不上」(证伪入口);用户答完再挑下一个锚点。断语涉及学业年份时,学制锚点顺嘴并入当轮提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运;
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断——直答给主答案与置信度,全量候选收分析推理段。
锚点全部落空时先回头查组数判定、阶段假设、时辰边界与节气边界,不硬铺(R-运-33 第 4 步)。
@@ -189,15 +195,19 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
`python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男`
(亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。
- **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。
- **换盘纪律(工作盘唯一 + 变更留痕)**:经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,**必须当轮重排并把档案 `## 盘面` 段整体替换为新盘**——档案任何时候只有一个工作盘,旧盘不得留在盘面段,防止续推时误用。操作:
`python scripts/memory_save.py --update-chart <档案路径> [--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后地点"]`
(重排一次,授权副本照常扣次;frontmatter 四柱/日主同步为新盘,四柱变动时文件自动改为新主键名;目标档案已存在则拒绝覆盖)。留痕:旧盘四柱、换盘原因、确认方式与时间记入 `## 反馈与澄清` 校订条目;凡受变动柱(如日柱/时柱)影响的已有推断条目(`## 归纳`、`## 流年大事` 各行),同轮标记【待复核】,复核前不得继续引用。
- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、档案原文不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。回传授权方的只有按下节规格生成的结构化脱敏包,且必须经用户授权。
## 档案共建(share/)
用户可自愿把**脱敏后**的命盘数据回传授权方,用于技能优化与规则蒸馏(脱敏规格、隐私协议与撤回方式见 `share/README.md`)。每成功推送一盘,本副本授权次数 **+10**;同一盘(按盘 hash)只计一次,不重复加次。
- **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2)。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件(带核实状态标签)可入包,自由文本(反馈原文、盘面细节、归纳文字)一律不入。
- **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2);事件简介缺失(档案未写事件内容)不挡导出,包内如实反映。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件与流年大事(带核实状态标签)连同事件简介(note)可入包。脱敏纪律:姓名一律删除;出生信息只留年月日与时辰;地域保留到城市级(省+地级市,区县及以下删除);事件简介中**城市级地名保留**(方位信息供规则蒸馏定位),真实姓名、详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除——脚本自动脱敏,书写事件简介时也只写到城市级;归纳文字、盘面细节等其余自由文本一律不入。**换盘清理纪律**:换盘/出生信息重大更正后重新导出前,先清理 `share/pending/` 中同一命主的旧 hash 包——盘 hash 随四柱/出生地变化,旧包即成孤儿,可能被误推;share_export.py 导出时检测到疑似残留(同出生日同性别、盘 hash 不同)会打 WARN 提示,确认后手动删除。
- **授权询问(强制)**:推送前必须先把脱敏包**全文展示**给用户,并询问是否授权推送——用户同意才执行推送;不同意则取消,脱敏包保留在 `share/pending/`(用户可随时删除),不影响技能正常使用。
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub 与 Gitea **两条线都推**,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
- **推送授权话术(宿主权限层)**:调用推送脚本时宿主会请求执行权限,请用户确认——**「点一下允许」是唯一主路径**。① **推送前先预告**:得到用户口头授权后、调用脚本前,先发一句话预告,如「接下来运行推送脚本,你的环境可能弹出执行权限确认,点『允许』即可」;② **被拦即停、零重试**:脚本被权限层拦截后不得重试(权限状态不会因重试改变),直接向用户说明并问「是否看到权限确认?点允许我就能继续」;③ 终端手动执行 `python scripts/share_push.py` 仅作为宿主静默拦截(不弹确认、直接拒绝)时的兜底,向用户提及时一句话带过,**不列为推荐步骤**;④ **话术宿主中立**:不写死任何具体宿主的权限界面名称,统一表述为「宿主会请求执行权限,请用户确认」。
## 版本与自更新
@@ -205,11 +215,14 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(24 小时)则后台线程拉一次远端 VERSION 刷新缓存(超时 3 秒、失败静默,主流程不等);缓存显示有新版本时,stderr 提示一行「技能有新版本 X.Y.Z,运行 `python scripts/self_update.py --apply` 可更新」。
- **应用更新(须用户同意)**:用户说「更新技能」时再运行 `python scripts/self_update.py --apply`——从 GitHub 拉取(失败转 Gitea),**先按 manifest.json 逐文件校验 SHA256、任一不符即中止不动本地**,通过后覆盖更新;`data/.license.json`、`memory/archives/`、`share/pending/`、`share/pushed/`、`.git` 保留不动。不得替用户自动执行 --apply。
## 盘面图(HTML)
## 盘面图(HTML,默认生成)
排盘后同步生成 HTML 盘面图(`cast_chart.py --html`):四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。
拿到出生信息排盘后,**默认立即生成盘面图、截图并发给用户**,然后才进入对话式核对与分析。流程次序:排盘(`cast_chart.py --shot`)→ 生成盘面图并发送 → 对话式核对/补缺 → 分析 → 结论。
- **给用户看图**:agent 环境具备浏览器/发图能力时,排盘后生成 HTML 并直接打开,或截图发送给用户;纯文本环境则告知文件路径。
- **生成**:`--shot` 一步完成——stdout 出命盘 Markdown,同目录出 HTML 盘面图与 PNG 截图(路径打在 stdout 末尾;`--html 路径` 可另指定 HTML 位置)。截图分级回退:环境装有 playwright 用 playwright(file:// 全页、2 倍分辨率),否则用本机 Chrome 无头截图;两者都不可用只出 HTML 并附降级提示,不影响排盘。也可单独调 `scripts/chart_shot.py <html路径>` 补截图。
- **发给用户**:以宿主支持的方式把盘面图发给用户(图片引用或文件链接);截图不可用时给出 HTML 文件路径并说明可直接打开查看。
- **重排必重生**:出生信息有任何变动(校正时辰、节气边界确认、晚子时分歧对照等)重排时,必须重新生成盘面图与截图,不得沿用旧图。
- 盘面内容:四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。
- HTML 只是 Markdown 命盘的图形化重渲染,内容以 stdout 命盘为准;解释时引用的字段名仍以 Markdown 章节为准。
## 文风与反 AI 腔(强制)
+1 -1
View File
@@ -1 +1 @@
3.5.1
3.6.0
+32 -24
View File
@@ -2,13 +2,13 @@
"files": {
".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5",
"LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315",
"README.md": "1e11e5d19856fbd9bb15068c74e184617a10b2e0b18c7e42d9b617cd0d8f534a",
"SKILL.md": "eaff2bc3cc1221417caaf40cf86741c7fe2e43f7538fd9efc0f436c13c030a0b",
"VERSION": "eb41f86c3d67299b46490cde10158a521148fdc03641aa14a93fa2797becd78c",
"README.md": "adb4481f20360c66e67fd89076c1535c577cdf85f34122d2dac1ff727dd81c38",
"SKILL.md": "720aa2de6e7e6f65c44f63421f0aece401146921e54a6c99f24db1af14241813",
"VERSION": "138203e3f604a08fad0c4d0a297efe2c486219820dcdd053e72b7d843504c7a1",
"data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee",
"data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854",
"memory/README.md": "b3cf2c58a8418a8c1a54243c2e09fbdb42f31dcd48ba8c841b74f1234c7fe063",
"pyproject.toml": "58e415e53d2418721fa07bd81b829e4bc5bbfb2155a446bce6222d3f7afeda12",
"memory/README.md": "a979f985dd87e87bfca49676ebf10a537d299b1eeebac121c506c320c88a3afd",
"pyproject.toml": "d71541e952e8b1650a157bf17d2bed9946052df9f6647032218ca272973d93b4",
"requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6",
"rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8",
"rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6",
@@ -21,7 +21,7 @@
"rules/18_建禄月劫格.md": "f0b400292d22cb3b38ab5011bda8080d511a065c661305f7a9c0485f1c57705c",
"rules/19_杂格.md": "78939bc9d61621a427fd41a31156086b3ae6675eccc77c899a3aa7443408ef26",
"rules/89_旺衰扶抑.md": "758180d5ee0aa46f4b22e8817baa831974c9c3b239078ba51e0a7b9016f7f47b",
"rules/90_行运.md": "510c243e84f3cffacf0a85887b8cc8650bb009083112390c50d5f8ab8c969587",
"rules/90_行运.md": "bacb9d99d2c1b117b0a9c297712068f3d41c73aa210ee01d2d2754d017bdf438",
"rules/91_何知事类.md": "3775448e1830929dbcbc90cacf1065a4ab0592c9c4ee984c09ec187911bcf2c1",
"rules/92_调候.md": "8d37d2cb722ae4b581532b7b73218fd648f1e75d8e16c6bedbabe0f93a2e1946",
"rules/93_性情.md": "c9d3003cf4e0869e5075623e34bad41faa294623df1b2481e0f30b80fc61cec6",
@@ -30,7 +30,7 @@
"rules/96_六亲.md": "96cf362bd180558352d0181401ac8e297ac89c09d91d1098972a9d1c40468198",
"rules/97_女命.md": "52962aa666faa3fa2a5175d352380df14f54fefb8f57cfe0761779ad51e18696",
"rules/99_附录_干支通例.md": "da906502391833eb1744ff0145a03c7d1e10781ac7156034632dc5994da0d2f4",
"rules/README.md": "80a77a744fcac31673dd7eb9f84cb80b83c9d61c4d0aaabcb39e86f98f661c2f",
"rules/README.md": "27118f8338549ae4f92d1c9ed66e6bea82e028f2a5f68769bc43e08770857b51",
"rules/sources/README.md": "183ba554b1d89c16b19fb65ac8a4d0614069c865676fb4c1bd20d95b5581c209",
"rules/sources/三命通会/00_卷一·原造化之始.md": "6329058dd08aed5f41d1111da99027cac38761aeea7120a84eff02683426294d",
"rules/sources/三命通会/01_卷一·论五行生成.md": "c9db87683a6d3f9b7eea665e3814e383e11a69b608114d9317beb2a2c56825f8",
@@ -565,44 +565,52 @@
"rules/tests/golden/97_女命.yaml": "dc229eae558be6592f392be4f71e72ec6650d64590c9bac3361febed9ead9b89",
"scripts/_license_gate.py": "4f3a24685c86abc6c66a158b441b89e2738bd14f5d63e8bdd41489654a29e837",
"scripts/_memory_store.py": "690e088a208ee5aeb9f66e6d4b5d4342f44bfc852bad51870cdfe397fbdf7660",
"scripts/_runtime_guard.py": "c4f9d00acbb5b2ba49680b23ed4f846c60211e67b9d58950079b3e8909dcfe81",
"scripts/astro/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/astro/day_boundary.py": "5dbd942f08a009400c2fbc60faa77c9d9a0c315d01e0cc6edf3d7b2efc09d2c3",
"scripts/astro/day_boundary.py": "b5a24b3937fbcd9c278331b5b19f8cf708acc987f6f6f97f346524786735af9a",
"scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e",
"scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153",
"scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba",
"scripts/cast_chart.py": "16e5e9f0df1bcb0c0eef1febd1ae2395a84c1676e0c629058821056861daa05e",
"scripts/cast_chart.py": "9b9f33cfae9af797859554c630529d8fc5c27c5688a0414fbc5038ae2e5c90d8",
"scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/chart/ganzhi.py": "94d53d2a8cd7674312223e976ce6e1379a6fbb53873253b34963b3e3d645508e",
"scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299",
"scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6",
"scripts/chart/shensha.py": "1a9a59fa35ad5f2e2700d6b1a0155979dd7bf6f9fea4637356bfd41c5eae7e05",
"scripts/chart/shensha.py": "9db4a0e4cfaaa667efa9bbdcf7b8fd3d0c461cafbdf125e037cb467d75803939",
"scripts/chart/tyme_adapter.py": "06a46f16efc3d745edb5f35bbdcfee1c78552a44235fc85de0af3eb6c163eaca",
"scripts/chart_shot.py": "0096a01e64128a2628ee0e2d3bc211d93fbb0e2b17a49a34f4f2f9a37da4960c",
"scripts/geo/__init__.py": "1211b885ec767b399472df572f970b07d6d028a5889af49fe50f6fc1a2a0cf7d",
"scripts/geo/cities.py": "9d393608c03a2ad5c7352939e70ea717dae5dd4bc833dfdd0e2f663940c61fb5",
"scripts/memory_lookup.py": "853c815a97b457ca20d5b4c03aff234dfb218a58a5511f6c1a55dc635de31c90",
"scripts/memory_save.py": "42638841cceb586783b8764eacff91a8518d8df7dfe99b3919b3caa5a432e0ec",
"scripts/query_shensha.py": "1d77205c8781676f3536bbeb2f36d397b825ff3065a4b968344fb95e5b8859dd",
"scripts/memory_lookup.py": "23f02b04636924a1f6872d338e85ee2145ed1cde742b3e2e5b6ee4592ff92d50",
"scripts/memory_save.py": "cfdf6dcd652475fb9bf04521655581dad10f36d7d7f69d079bf07833b99ba4e6",
"scripts/query_shensha.py": "e4c852c8e56fb8b32bde0fb376d985647bcfd93a74fe30fd34c5884d2ab77dc3",
"scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/render/html.py": "9896ffc7c1f29cdce0d2ce9c21ce78ec6035b77851249466776945de918de49d",
"scripts/render/assets/lingyao-logo.png": "3d63f71ff9af61a443708f2665d1331737bb567a4e731d049676dfeb4710e323",
"scripts/render/assets/logo-font.ttf": "0b3a28b91d30276a68501a837ce7c1874274276c21793957b83f1efbb4a9264a",
"scripts/render/assets/qun-qrcode.jpg": "437854ff4ea6d2b3452b16371afbba1b9bb97e1f2c7fdbc77580ae2cceb3cbaf",
"scripts/render/html.py": "3196b808d83b876ff1d4b7ea13bbb97f27154bfcd27b491cd10b2e4c0c254874",
"scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977",
"scripts/render/templates/paipan.html": "9cdeed44834ef0de7adf0d775486b8ece7c81cf228107295e51bc31847a81688",
"scripts/self_update.py": "b1c9ad0ef90e20ac75ac6b8e857a4a5bd31181c67a58c7c1d46b45f468fd9f26",
"scripts/share_export.py": "21396143ad5cc2d244c7cc060a3ece25702594465689a9f12438dfe92de3296f",
"scripts/share_push.py": "42683c27a93f12208c20b0a5cd2feb7cd81f9be3efb76b46d4789ad4d2aab4ca",
"share/README.md": "42ebed92285752af93c7c7af5eb4b6e84ed836408d0cedb57e1b32c796746b26",
"scripts/render/templates/paipan.html": "aecc6a7ec5d6dcb567e56a986dc803678838f12551828ff58ae313ae7a6499a5",
"scripts/self_update.py": "ca8cf55dffd805a3643e71167912e6468f33e1b7fe0a270ec263f6d9bf949bb8",
"scripts/share_export.py": "eab38048e3737de1eb8731e6f4860f4061d29dedb9e9f3a1c9d9ac44ec200c20",
"scripts/share_push.py": "307e83747b8d244f20632a198b7bfba51352a6b6bf0a8f98b73c904e719a6b89",
"share/README.md": "adccd0f7691bedd64441b11cdd8ce66e20adce55fe4d8de1333f531b689f82b8",
"share/config.json": "b0747cb29b01e65c79e612062e1d572f6658c3046ae8bb5d97020ab2d7c6f04e",
"tests/golden/history_tz.yaml": "3fb74e90c4f53abebaac0a5de8825ac3794fc0b5b41dc84121201e70cecade47",
"tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2",
"tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37",
"tests/test_chart_shot.py": "7923d2868ded898331a65152b237cf014fa49b180b08706584ff7319f88b7a33",
"tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04",
"tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa",
"tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790",
"tests/test_html.py": "0972e938f37e7f2f70569cc3abbd9bf313a2610cd227537dcde6add010d46737",
"tests/test_html.py": "29fbab116aaa48ef9b97f26b6eec6c22157cd8c6af9da61943e20dd93157e49d",
"tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e",
"tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea",
"tests/test_memory.py": "d3decfdc8d7c5bbfc4af5e3aed93bb3c53ab09071b682a0f4808614ac2cd3c6f",
"tests/test_memory.py": "068241927df08301806451ff6e66206ceb9d439d95197b3506fe519d892c28ed",
"tests/test_runtime_guard.py": "7fd7e2f334433432452ea68fde30df1698def2126a1920f8da6d6aab236c1caf",
"tests/test_self_update.py": "183c2fb82d2d20285941b6c75a1890c4417d400c3c7e7d5f8a9680b4fa7bf76c",
"tests/test_share.py": "399f20da22689d00e71eb78c76d60507060c29a7d68865e6b0e9f591d19be871",
"tests/test_shensha.py": "b8a20d264e18c79f1fe546281381b0d929543af44ec68dc890ef8b64efc625b0",
"tests/test_share.py": "7938344415cbba95f683b2a09ecec9af28d00658f475ff0a56563dd13beff630",
"tests/test_shensha.py": "2aaa181279739356828f05a2175027b0ae711b322b4bd0e60d5c5e62538bcac8",
"tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4",
"tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d",
"tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83",
@@ -1268,5 +1276,5 @@
"vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f",
"vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881"
},
"version": "3.5.1"
"version": "3.6.0"
}
+15 -1
View File
@@ -7,6 +7,11 @@
python scripts/memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市"
# 换盘(经用户确认换盘后):重排并整体替换「## 盘面」段,frontmatter 同步;
# 姓名/性别/出生信息读档案,--birth/--place 仅在校正时给覆盖值
python scripts/memory_save.py --update-chart archives/<档案>.md \
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后地点"]
# 检索(条件任意组合、至少一项;大小写/空白容错)
python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
[--place 四川省成都市] [--gender 男] [--pillars "庚午 辛巳 己卯 戊辰"]
@@ -21,7 +26,16 @@ python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
`archives/<四柱连写>-<性别>-<hash6>.md`,其中
hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制,
用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」,
正文其余小节原样保留。
正文其余小节原样保留。换盘(`--update-chart`)后四柱或 hash6 变动时,
文件改为新主键名(目标已存在则拒绝覆盖,报错退出 2)。
## 换盘纪律(工作盘唯一 + 变更留痕)
经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,必须重排并把
「## 盘面」段整体替换为新盘:档案任何时候只有一个工作盘,旧盘信息
(原四柱、换盘原因、确认方式与时间)记入「反馈与澄清」校订条目,不留盘面段。
凡受变动柱影响的已有推断条目(归纳、流年大事各行)同轮标记【待复核】,
复核前不得继续引用。
## Schema
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ZiPingEightChar"
version = "0.1.0"
version = "3.6.0"
requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10
dependencies = [
"tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性
+3 -3
View File
@@ -363,10 +363,10 @@
- **原文**:(综括,无单一原文)
### R-运-33 核对前置:先定场景,再定断运路径
- **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。agent 环境具备询问工具时**必须用询问工具**,无询问工具时以正文编号选项代替。
- **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。核对提问一律走对话式询问(见 SKILL.md「Intake 对话式询问」):以普通对话消息进行,禁用结构化问卷/点选工具,也不在正文罗列编号问题清单让用户勾选;一次最多问 1~2 个,答完再定下一个。
- **判定**:
1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 追问话术」),不单独多占一轮。
2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对用结构化选项**:每个锚点年一个问题,选项为按可能性排序的候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次提问,不逐条单独打断用户。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。
1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 对话式询问」),不单独多占一轮。
2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对走对话式询问**:一次最多问一两个锚点年,每问先以该年盘上一两句显著信号作铺垫,再自然带出按可能性排序的候选事件,并留「都对不上」的证伪入口;用户答完再挑下一个锚点,不逐条单独打断、也不合成清单一次抛出。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断;直答按 R-运-32 落笔纪律给主答案与置信度,全量候选收在分析推理段。
4. **证伪回查**:锚点全部落空时不得硬铺——先回头检查:组数判定是否偏重、学制等阶段假设是否错位、出生时刻是否跨时辰或跨日界(晚子时)、是否临近节气边界(命盘带 ⚠️ 节气边界提示者,重排另一月令之盘对照);修正后重新给锚点。
5. **学制锚点**:断语涉及学业年份时,把一个学制锚点(哪年上大学/哪年毕业、有无复读休学)并入第 2 步那次提问,不单独多问一轮。
+4 -1
View File
@@ -102,7 +102,10 @@ rules/
## 6. 版本与溯源
- 规则库版本:
- **3.5**(2026-10-09)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。
- **3.6**(2026-10-10)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。
### 3.6 相对 3.5:
- 询问契约改对话式(规则条数不变,542 条、20 文件)——SKILL.md Intake 节改「对话式询问(强制)」(禁用结构化问卷/点选工具与正文编号选项清单,一次最多 1~2 问、先铺垫再问),断运交互节同步;`90_行运.md` R-运-33 措辞随之修订——原「具备询问工具时必须用询问工具,否则以正文编号选项代替」「核对用结构化选项、多锚点年合成一次提问」改为对话式口径(普通对话消息、一次最多一两个锚点、先铺垫再问、答完再定下一个);核对前置的内部程序(场景分流、锚点选取、候选排序、证伪回查、收敛后一次性输出)不变。
### 3.5 相对 3.4:
- description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。
+32
View File
@@ -0,0 +1,32 @@
"""运行环境守卫:本技能脚本需要 Python ≥ 3.10。
各入口脚本在 sys.path 引导后立即 import 本模块(import 即检查,无其他调用):
解释器低于 3.10 时在 stderr 打印中文提示并以退出码 3 结束,
避免用户看到库深处难以理解的报错(运行期 `X | None` 注解在 3.9 及以下
求值即 TypeError,报错位置离真正原因很远)。
本模块自身只用 Python 3.6 兼容语法,保证在旧解释器上也能正常执行检查。
"""
from __future__ import annotations
import sys
MIN_PYTHON = (3, 10)
def version_error(version_info=None) -> str | None:
"""版本达标返回 None;不足时返回中文报错文案。可注入 version_info 便于测试。"""
vi = sys.version_info if version_info is None else version_info
if tuple(vi[:2]) >= MIN_PYTHON:
return None
return (f"运行环境不可用:本技能脚本需要 Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} "
f"或更高版本,当前解释器为 Python {vi[0]}.{vi[1]}.{vi[2]}"
f"({sys.executable})。请改用更新版本的 Python 运行"
"(macOS 系统自带 python3 为 3.9,不可用;可用 python3.11/3.12/3.13 "
"或项目自带虚拟环境)。")
_msg = version_error()
if _msg is not None:
print(f"ERROR: {_msg}", file=sys.stderr)
raise SystemExit(3)
+6 -4
View File
@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
ZI_HOUR_START = 23 # 真太阳时 23:00 换日
def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
def resolve_calendar_day(tst: datetime, rule: str = "子初") -> tuple[datetime, dict]:
"""
子初换日:真太阳时 23:00–24:00 归入次日。
日界归算:rule="子初"(默认)真太阳时 23:00–24:00 归入次日;
rule="子正" 以 0:00 为日界,晚子时不换日。
返回
(日历日 00:00 的 datetime, flag)
@@ -13,11 +14,12 @@ def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
is_late_zi = tst.hour == ZI_HOUR_START
day = tst.replace(hour=0, minute=0, second=0, microsecond=0)
if is_late_zi:
if is_late_zi and rule != "子正":
day += timedelta(days=1)
return day, {
"rule": "子初换日@真太阳时23:00",
"rule": ("子正换日@真太阳时00:00" if rule == "子正"
else "子初换日@真太阳时23:00"),
"is_late_zi": is_late_zi,
"tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"),
}
+18 -3
View File
@@ -18,6 +18,8 @@ from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
# vendor 目录不存在时回退到环境中已安装的包(开发模式)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
@@ -39,6 +41,9 @@ def main() -> int:
help="出生地,精确到地级市一级,如「四川省成都市」")
ap.add_argument("--html", nargs="?", const="", default=None,
help="可选:同时生成 HTML 盘面图(给路径参数,或省略则写 ./命盘_<姓名>.html)")
ap.add_argument("--shot", action="store_true",
help="可选:生成 HTML 盘面图并截图 PNG,路径打在 stdout 末尾;"
"截图工具不可用时只出 HTML")
a = ap.parse_args()
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
@@ -132,14 +137,24 @@ def main() -> int:
print(render(chart))
_gate.consume(Path(__file__).resolve().parents[1])
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约)
if a.html is not None:
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约);--shot 再截图 PNG
if a.html is not None or a.shot:
try:
from render.html import build_html
html_path = (Path(a.html) if a.html
else Path(f"命盘_{a.name}.html"))
html_path.write_text(build_html(chart), encoding="utf-8")
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
if a.shot:
import chart_shot
png = chart_shot.shoot(html_path)
print(f"\nHTML 盘面:{html_path.resolve()}")
if png:
print(f"盘面截图:{png}")
else:
print("盘面截图:不可用(未检测到 playwright 或 Chrome,"
"可直接打开上面的 HTML 查看盘面)")
else:
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
except Exception:
print("WARN: HTML 盘面生成失败(命盘不受影响)", file=sys.stderr)
+68
View File
@@ -0,0 +1,68 @@
"""干支派生纯查表:五行、十神、十二长生(星运)、旬空、六十甲子纳音。
本命四柱的同名信息由 chart/tyme_adapter.py 经 tyme4py 求得;本模块为同口径的
纯 Python 实现,供岁运干支(大运/流年/流月)与渲染层复用,保证 HTML 盘面
与排盘正文口径一致。纯查表,零命理判断(P1)。
"""
GAN = "甲乙丙丁戊己庚辛壬癸"
ZHI = "子丑寅卯辰巳午未申酉戌亥"
WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
"戌": "土", "亥": "水"}
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
def ten_god(me: str, other: str) -> str:
"""other 天干相对日干 me 的十神(命名与 tyme4py get_ten_star 一致)。"""
wx_m, wx_o = WX_GAN[me], WX_GAN[other]
same = _YY[me] == _YY[other]
if wx_m == wx_o:
return "比肩" if same else "劫财"
if _SHENG[wx_m] == wx_o:
return "食神" if same else "伤官"
if _KE[wx_m] == wx_o:
return "偏财" if same else "正财"
if _KE[wx_o] == wx_m:
return "七杀" if same else "正官"
return "偏印" if same else "正印"
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
"病", "死", "墓", "绝", "胎", "养"]
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
def terrain(me: str, zhi: str) -> str:
"""日干 me 对地支 zhi 的十二长生(阳顺阴逆,命名与 tyme4py get_terrain 一致)。"""
start = ZHI.index(_TERRAIN_START[me])
zi = ZHI.index(zhi)
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
return _TERRAIN_ORDER[off]
def gz_void(gz: str) -> str:
"""一柱干支所在旬的空亡两支(与 tyme4py get_extra_earth_branches 同果)。"""
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
# 六十甲子纳音( pairwise ),末字即五行
_NAYIN_PAIRS = [
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
]
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
+3 -14
View File
@@ -10,21 +10,9 @@
"""
from __future__ import annotations
GAN = "甲乙丙丁戊己庚辛壬癸"
ZHI = "子丑寅卯辰巳午未申酉戌亥"
YANG_GAN = set("甲丙戊庚壬")
from .ganzhi import GAN, NAYIN, ZHI
# 六十甲子纳音( pairwise ),末字即五行
_NAYIN_PAIRS = [
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
]
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
YANG_GAN = set("甲丙戊庚壬")
# —— 日干查地支 ——
TIANYI = {"甲": "丑未", "戊": "丑未", "庚": "丑未",
@@ -102,6 +90,7 @@ SANQI = ("乙丙丁", "甲戊庚", "辛壬癸")
# 输出排序(每柱内部按此序)
STAR_ORDER = [
"天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人",
"三奇贵人",
"文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星",
"劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿",
"天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败",
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""HTML 盘面 → PNG 截图。
用法:
python chart_shot.py <html路径> [-o 输出png路径]
截图分级回退:环境装有 playwright 则优先(file:// 全页截图,2 倍分辨率);
否则用 macOS Chrome 无头截图(固定窗口,高度按 HTML 内容估算避免截断)。
两者都不可用:退出码 2,stderr 提示直接打开 HTML。截图失败不影响排盘主流程。
"""
import argparse
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
def _shot_playwright(html: Path, out: Path) -> bool:
try:
from playwright.sync_api import sync_playwright
except ImportError:
return False
try:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(device_scale_factor=2)
page.goto(html.resolve().as_uri())
page.screenshot(path=str(out), full_page=True)
browser.close()
except Exception:
return False
return out.is_file() and out.stat().st_size > 0
def _content_height(html: Path) -> int:
"""按 HTML 结构估算整页高度(px):头卡/主表/页脚取固定高,条带按格数折行。"""
text = html.read_text(encoding="utf-8", errors="ignore")
height = (900 + 40 * text.count('class="warn"')
+ 150 * text.count('class="qrcard"'))
for strip in text.split('class="strip"')[1:]:
cells = strip.count('class="cell"')
height += 36 + 132 * -(-cells // 9)
return max(1300, min(height + 120, 4200))
def _shot_chrome(html: Path, out: Path) -> bool:
if not Path(CHROME).is_file():
return False
try:
r = subprocess.run(
[CHROME, "--headless=new", f"--screenshot={out.resolve()}",
f"--window-size=1100,{_content_height(html)}",
"--force-device-scale-factor=2", "--hide-scrollbars",
html.resolve().as_uri()],
capture_output=True, timeout=60)
except Exception:
return False
return r.returncode == 0 and out.is_file() and out.stat().st_size > 0
def shoot(html: Path, out: Path | None = None) -> Path | None:
"""对 HTML 盘面截图,成功返回 PNG 绝对路径,无可用工具或失败返回 None。"""
out = out or html.with_suffix(".png")
if _shot_playwright(html, out) or _shot_chrome(html, out):
return out.resolve()
return None
def main() -> int:
ap = argparse.ArgumentParser(
prog="chart_shot.py",
description="HTML 盘面 → PNG 截图(playwright 优先,Chrome 无头兜底)")
ap.add_argument("html", help="HTML 盘面文件路径")
ap.add_argument("-o", "--out", default=None,
help="输出 PNG 路径(默认与 HTML 同目录同名 .png)")
a = ap.parse_args()
html = Path(a.html)
if not html.is_file():
print(f"ERROR: 找不到 HTML 文件 {html}", file=sys.stderr)
return 2
png = shoot(html, Path(a.out) if a.out else None)
if png:
print(png)
return 0
print("无可用截图工具(未检测到 playwright 或 Chrome),"
"请直接打开 HTML 文件查看盘面。", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -19,6 +19,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _memory_store as store
+222 -83
View File
@@ -4,12 +4,23 @@
用法:
python memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市"
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
输出:档案文件路径到 stdout。
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
换盘模式(--update-chart):姓名/性别/出生信息默认读档案 frontmatter,
--birth/--place 用于时辰/地点校正,--day-boundary 子正 切换日界规则(默认子初)。
换盘后「## 盘面」只保留新盘(工作盘唯一),旧盘信息由使用方记入「## 反馈与澄清」;
frontmatter 的 pillars/day_master/true_solar_time 同步为新盘,文件改为新主键名
(目标已存在且非同一文件时报错退出 2,不覆盖)。四柱有变动时 stderr 列出变动柱,
提示使用方把受影响推断条目(归纳、流年大事)标记【待复核】。
退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
"""
import argparse
@@ -21,6 +32,8 @@ from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
@@ -28,24 +41,215 @@ if _VENDOR.is_dir():
import _memory_store as store
_PILLAR_LABELS = ("年柱", "月柱", "日柱", "时柱")
# render 层日界文案写定(scripts/render/ 不在此处改动),换盘子正时改写这两行
_AUDIT_ZICHU = "- 日界规则:子初换日(真太阳时 23:00)"
_AUDIT_ZIZHENG = "- 日界规则:子正换日(真太阳时 00:00)"
_WARN_ZICHU = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
"已按「子初换日」归入次日。另存「子正换日」一派,"
"日柱与时柱将不同,结论需谨慎。")
_WARN_ZIZHENG = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
"已按「子正换日」定盘(日界 0:00,不换日)。"
"另存「子初换日」一派,日柱与时柱将不同,结论需谨慎。")
def _cast_chart(name: str, gender: str, birth: str, place: str,
day_boundary: str = "子初"):
"""排盘管线(geo → astro → chart → render),与 cast_chart.py 同一调用序列。
返回 (命盘 Markdown, 四柱 list, 日主, 真太阳时 str);地点/时刻不可用抛
LocationError/ValueError(由调用方映射退出码 2)。
"""
from geo.cities import resolve_location
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)
loc = resolve_location(place)
# L0-b 出生时刻解析
clock = datetime.strptime(birth.strip(), "%Y-%m-%d %H:%M")
# 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, rule=day_boundary)
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
# L1-a 排盘(tyme4py 唯一接触面)
raw = cast(cast_dt, gender)
# L1-b 起运、大运、流年、流月、小运
forward = is_forward(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=name, gender=gender, place=place, clock=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)
if day_boundary == "子正":
# render 层日界文案固定为子初口径,换盘子正时改写警示条与审计行
chart_md = chart_md.replace(_WARN_ZICHU, _WARN_ZIZHENG)
chart_md = chart_md.replace(_AUDIT_ZICHU, _AUDIT_ZIZHENG)
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}"]
return chart_md, pillars, chart.day_master, chart.true_solar_time
def _update_chart(a) -> int:
"""换盘模式:重排并替换既有档案的「## 盘面」段,frontmatter 与主键名同步。"""
path = Path(a.update_chart).expanduser().resolve()
if not path.is_file():
print(f"ERROR: 档案不存在:{path}", file=sys.stderr)
return 2
meta, body = store.parse_frontmatter(path.read_text(encoding="utf-8"))
if not meta:
print("ERROR: 档案无 frontmatter,无法换盘;请检查文件或用完整建档。",
file=sys.stderr)
return 2
name = str(meta.get("name", "")).strip()
gender = str(meta.get("gender", "")).strip()
birth = (a.birth or str(meta.get("birth_clock", ""))).strip()
place = (a.place or str(meta.get("birthplace", ""))).strip()
if not name or gender not in ("男", "女") or not birth or not place:
print("ERROR: 档案 frontmatter 缺 name/gender/birth_clock/birthplace,"
"无法重排;请补全档案或改用完整建档。", file=sys.stderr)
return 2
from geo.cities import LocationError
try:
chart_md, pillars, day_master, tst_str = _cast_chart(
name, gender, birth, place, a.day_boundary)
except LocationError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
except ValueError:
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
return 2
now = datetime.now().strftime("%Y-%m-%d %H:%M")
old_pillars = meta.get("pillars", [])
if isinstance(old_pillars, str):
old_pillars = old_pillars.split()
meta.update({
"name": name, "gender": gender, "birth_clock": birth, "birthplace": place,
"true_solar_time": tst_str, "pillars": pillars, "day_master": day_master,
"created_at": meta.get("created_at") or now, "updated_at": now,
})
body = store.replace_section(body, "盘面", chart_md)
# 四柱/hash6 变动时改为新主键名;目标已存在则拒绝覆盖
new_path = path.with_name(store.archive_name(
pillars, gender, store.key_hash(birth, place, gender)))
if new_path != path:
if new_path.exists():
print(f"ERROR: 新盘主键档案已存在:{new_path};"
f"为避免覆盖,本次未写入。请先核对两份档案。", file=sys.stderr)
return 2
new_path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
path.unlink()
else:
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
changed = [f"{_PILLAR_LABELS[i]} {old_pillars[i]}→{pillars[i]}"
for i in range(min(len(old_pillars), 4))
if old_pillars[i] != pillars[i]]
if changed:
print(f"注意:{'、'.join(changed)} 已变。受变动柱影响的已有推断条目"
f"(归纳、流年大事)须复核并标【待复核】,复核前不得继续引用;"
f"旧盘四柱与换盘原因请记入「## 反馈与澄清」。", file=sys.stderr)
print(new_path)
return 0
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=["男", "女"],
ap.add_argument("--name", default=None, help="姓名")
ap.add_argument("--gender", default=None, choices=["男", "女"],
help="性别:男 或 女")
ap.add_argument("--birth", required=True,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
ap.add_argument("--place", required=True,
help="出生地,精确到地级市一级,如「四川省成都市」")
ap.add_argument("--birth", default=None,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
"换盘模式下为时辰校正覆盖值")
ap.add_argument("--place", default=None,
help="出生地,精确到地级市一级,如「四川省成都市」;"
"换盘模式下为地点校正覆盖值")
ap.add_argument("--update-chart", default=None, metavar="档案路径",
help="换盘模式:按档案重排并替换其「## 盘面」段,"
"frontmatter 与文件名同步为新盘")
ap.add_argument("--day-boundary", default="子初", choices=["子初", "子正"],
help="日界规则:子初(默认,真太阳时 23:00 换日)或 子正(0:00 换日);"
"仅换盘模式可用")
ap.add_argument("--memory-root", default=None,
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
a = ap.parse_args()
# 软授权锁:与 cast_chart.py 同位置——建档即一次排盘,先 check,成功后 consume
if a.update_chart is None:
missing = [k for k in ("name", "gender", "birth", "place")
if getattr(a, k) is None]
if missing:
print(f"ERROR: 建档缺参数:{'、'.join('--' + k for k in missing)}。"
f"(换盘请用 --update-chart <档案路径>)", file=sys.stderr)
return 2
if a.day_boundary != "子初":
print("ERROR: --day-boundary 仅换盘模式(--update-chart)可用;"
"建档固定子初换日。", file=sys.stderr)
return 2
elif a.name is not None or a.gender is not None:
print("ERROR: 换盘模式不支持 --name/--gender(姓名、性别以档案为准)。",
file=sys.stderr)
return 2
# 软授权锁:与 cast_chart.py 同位置——建档/换盘即一次排盘,先 check,成功后 consume
import _license_gate as _gate
_deny = _gate.check(Path(__file__).resolve().parents[1])
if _deny:
@@ -53,91 +257,26 @@ def main() -> int:
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
if a.update_chart is not None:
rc = _update_chart(a)
if rc == 0:
_gate.consume(Path(__file__).resolve().parents[1])
return rc
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
# L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
from geo.cities import LocationError
try:
loc = resolve_location(a.place)
chart_md, pillars, day_master, tst_str = _cast_chart(
a.name, a.gender, a.birth, 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)
@@ -147,8 +286,8 @@ def main() -> int:
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,
"birthplace": a.place.strip(), "true_solar_time": tst_str,
"pillars": pillars, "day_master": day_master,
"created_at": now, "updated_at": now,
}
if path.is_file():
+2
View File
@@ -19,6 +19,8 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
def main() -> int:
ap = argparse.ArgumentParser(
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+71 -117
View File
@@ -2,52 +2,24 @@
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
均为固定查表,不含任何命理判断(P1)。
均为固定查表,不含任何命理判断(P1)。岁运干支(大运/流年格)的十神/
星运/空亡/纳音复用 chart/ganzhi.py,神煞复用 chart/shensha.py 的岁运
求值器 evaluate_target(与 query_shensha.py 同一口径),不在渲染层
另造规则。
logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文件便携;
神煞条目的查法注释(全角/半角括号)仅在本装配层剔除,Markdown 输出保持原样。
"""
import base64
import re
from datetime import datetime
from pathlib import Path
from chart.luck import luck_label_for_year
from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
from chart.shensha import evaluate_target
GAN = "甲乙丙丁戊己庚辛壬癸"
ZHI = "子丑寅卯辰巳午未申酉戌亥"
_WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
_WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
"戌": "土", "亥": "水"}
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
_HIDDEN = {"子": ["癸"], "丑": ["己", "癸", "辛"], "寅": ["甲", "丙", "戊"],
"卯": ["乙"], "辰": ["戊", "乙", "癸"], "巳": ["丙", "戊", "庚"],
"午": ["丁", "己"], "未": ["己", "丁", "乙"],
"申": ["庚", "壬", "戊"], "酉": ["辛"], "戌": ["戊", "辛", "丁"],
"亥": ["壬", "甲"]}
_NAYIN = {"甲子": "海中金", "乙丑": "海中金", "丙寅": "炉中火", "丁卯": "炉中火",
"戊辰": "大林木", "己巳": "大林木", "庚午": "路旁土", "辛未": "路旁土",
"壬申": "剑锋金", "癸酉": "剑锋金", "甲戌": "山头火", "乙亥": "山头火",
"丙子": "涧下水", "丁丑": "涧下水", "戊寅": "城头土", "己卯": "城头土",
"庚辰": "白蜡金", "辛巳": "白蜡金", "壬午": "杨柳木", "癸未": "杨柳木",
"甲申": "泉中水", "乙酉": "泉中水", "丙戌": "屋上土", "丁亥": "屋上土",
"戊子": "霹雳火", "己丑": "霹雳火", "庚寅": "松柏木", "辛卯": "松柏木",
"壬辰": "长流水", "癸巳": "长流水", "甲午": "沙中金", "乙未": "沙中金",
"丙申": "山下火", "丁酉": "山下火", "戊戌": "平地木", "己亥": "平地木",
"庚子": "壁上土", "辛丑": "壁上土", "壬寅": "金箔金", "癸卯": "金箔金",
"甲辰": "覆灯火", "乙巳": "覆灯火", "丙午": "天河水", "丁未": "天河水",
"戊申": "大驿土", "己酉": "大驿土", "庚戌": "钗钏金", "辛亥": "钗钏金",
"壬子": "桑柘木", "癸丑": "桑柘木", "甲寅": "大溪水", "乙卯": "大溪水",
"丙辰": "沙中土", "丁巳": "沙中土", "戊午": "天上火", "己未": "天上火",
"庚申": "石榴木", "辛酉": "石榴木", "壬戌": "大海水", "癸亥": "大海水"}
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
"病", "死", "墓", "绝", "胎", "养"]
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
"金": "wx-jin", "水": "wx-shui"}
_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"),
"巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"),
@@ -57,43 +29,22 @@ _SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "
"丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")}
_SEASON_LABEL = ("旺", "相", "休", "囚", "死")
_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]")
def _strip_sha_note(entry: str) -> str:
"""神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。"""
return _SHA_NOTE_RE.sub("", entry).strip()
def _asset_b64(name: str) -> str:
return base64.b64encode((_ASSETS_DIR / name).read_bytes()).decode("ascii")
def _wx(ch: str) -> str:
wx = _WX_GAN.get(ch) or _WX_ZHI.get(ch)
return {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
"金": "wx-jin", "水": "wx-shui"}.get(wx, "")
def _ten_god(me: str, other: str) -> str:
wx_m, wx_o = _WX_GAN[me], _WX_GAN[other]
same = _YY[me] == _YY[other]
if wx_m == wx_o:
return "比肩" if same else "劫财"
if _SHENG[wx_m] == wx_o:
return "食神" if same else "伤官"
if _KE[wx_m] == wx_o:
return "偏财" if same else "正财"
if _KE[wx_o] == wx_m:
return "七杀" if same else "正官"
return "偏印" if same else "正印"
def _terrain(me: str, zhi: str) -> str:
"""日干对支的十二长生(阳顺阴逆)。"""
start = _ZHI_IDX(_TERRAIN_START[me])
zi = _ZHI_IDX(zhi)
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
return _TERRAIN_ORDER[off]
def _ZHI_IDX(z: str) -> int:
return ZHI.index(z)
def _gz_void(gz: str) -> str:
"""一柱干支的旬空两支。"""
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
wx = WX_GAN.get(ch) or WX_ZHI.get(ch)
return _WX_CLASS.get(wx, "")
def _z(zhi: str, voids: str) -> str:
@@ -111,51 +62,21 @@ def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
"gan": p.gan, "zhi": p.zhi,
"hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)),
"terrain": chart.terrain.get(key, ""),
"void": _gz_void(p.gan + p.zhi),
"void": gz_void(p.gan + p.zhi),
"nayin": p.na_yin or "",
"shensha": chart.shen_sha.get(key, []),
"shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])],
"voids": voids,
}
def _extra_col(chart, gz: str, label: str, cur: bool) -> dict:
"""流年/大运列(仅干支行星级信息,神煞留空)。"""
me = chart.day_master
voids = chart.void.get("day", "") + chart.void.get("hour", "")
return {
"label": label, "cur": cur,
"god": _ten_god(me, gz[0]),
"gan": gz[0], "zhi": gz[1],
"hidden": [(h, _ten_god(me, h)) for h in _HIDDEN[gz[1]]],
"terrain": _terrain(me, gz[1]),
"void": _gz_void(gz),
"nayin": _NAYIN[gz],
"shensha": [],
"voids": voids,
}
def _main_table(chart) -> str:
this_year = datetime.now().year
ly = next(((y, gz) for y, gz, _ in chart.flow_years if y == this_year), None)
cur_luck = None
for lp in chart.luck_pillars:
if lp.start_year <= this_year:
cur_luck = lp
cols = []
if ly:
cols.append(_extra_col(chart, ly[1], f"流年 {ly[0]}", cur=True))
if cur_luck:
cols.append(_extra_col(chart, cur_luck.gan + cur_luck.zhi, "大运", cur=True))
for key, label in (("year", "年柱"), ("month", "月柱"),
("day", "日柱"), ("hour", "时柱")):
cols.append(_pillar_col(chart, key, label, cur=(key == "day")))
def _render_table(cols: list) -> str:
"""干支列列表 → 排盘表格(主星/天干/地支/藏干/星运/空亡/纳音/神煞)。"""
def th(c):
tag = '<span class="curtag">当前</span>' if c["cur"] else ""
return f'<th class="{"cur" if c["cur"] else ""}">{c["label"]}{tag}</th>'
def row(label, fn, cls=""):
def row(label, fn):
tds = "".join(
f'<td class="{"cur" if c["cur"] else ""}">{fn(c)}</td>' for c in cols)
return f'<tr><td class="rowlabel">{label}</td>{tds}</tr>'
@@ -178,6 +99,34 @@ def _main_table(chart) -> str:
return '<table class="pan">' + "".join(rows) + "</table>"
def _main_table(chart) -> str:
"""排盘卡:仅年月日时四柱。"""
cols = [_pillar_col(chart, key, label, cur=(key == "day"))
for key, label in (("year", "年柱"), ("month", "月柱"),
("day", "日柱"), ("hour", "时柱"))]
return _render_table(cols)
def _target_shensha(chart, gz: str) -> list[str]:
"""岁运干支相对本命锚点所逢的神煞(与 query_shensha.py 同一求值器)。"""
pillars = {k: getattr(chart, k).gan + getattr(chart, k).zhi
for k in ("year", "month", "day", "hour")}
return [star for star, _ in evaluate_target(pillars, chart.gender, gz)["anchored"]]
def _gz_sub(chart, gz: str) -> str:
"""岁运小卡的干支附属行:十神·星运 / 空亡 / 纳音 / 神煞(无标签,值本身说话)。"""
lines = [
f"{ten_god(chart.day_master, gz[0])} · {terrain(chart.day_master, gz[1])}",
gz_void(gz),
NAYIN[gz],
]
sha = _target_shensha(chart, gz)
if sha:
lines.append(" · ".join(sha))
return "".join(f'<div class="cs">{line}</div>' for line in lines)
def _luck_strip(chart) -> str:
this_year = datetime.now().year
cells = []
@@ -189,8 +138,7 @@ def _luck_strip(chart) -> str:
f'<div class="cy">{lp.start_year} · {lp.start_age:.0f} 岁起</div>'
f'<div class="cg"><span class="{_wx(lp.gan)}">{lp.gan}</span>'
f'<span class="{_wx(lp.zhi)}">{lp.zhi}</span></div>'
f'<div class="cy">{_ten_god(chart.day_master, lp.gan)}'
f' · {_terrain(chart.day_master, lp.zhi)}</div></div>')
f'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
@@ -218,7 +166,8 @@ def _flow_years(chart) -> str:
cls += " jiao"
cells.append(f'<div class="{cls}"><div class="cy">{year}</div>'
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div>'
f'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
@@ -258,14 +207,18 @@ def build_html(chart) -> str:
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
f"日界 子初换日(23:00)· tyme4py {chart.library_version} · "
f"schema {chart.schema_version}")
f"日界 子初换日(23:00)")
audit_full = (f"{audit}· tyme4py {chart.library_version} · "
f"schema {chart.schema_version}")
mapping = {
"TITLE": f"命盘:{chart.name}",
"LOGO_B64": _asset_b64("lingyao-logo.png"),
"FONT_B64": _asset_b64("logo-font.ttf"),
"QRCODE_B64": _asset_b64("qun-qrcode.jpg"),
"NAME": chart.name,
"GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造",
"DAY_MASTER": f"{chart.day_master}{_WX_GAN[chart.day_master]}",
"DAY_MASTER": f"{chart.day_master}{WX_GAN[chart.day_master]}",
"BIRTHPLACE": chart.birthplace,
"BIRTH_CLOCK": chart.birth_clock,
"TRUE_SOLAR": chart.true_solar_time,
@@ -282,6 +235,7 @@ def build_html(chart) -> str:
"FLOW_MONTHS": _flow_months(chart),
"SEASON_BAR": _season_bar(chart),
"AUDIT": audit,
"AUDIT_FULL": audit_full,
}
out = tpl
for k, v in mapping.items():
+139 -56
View File
@@ -5,24 +5,69 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>
@font-face {
font-family: 'LogoFont';
src: url("data:font/ttf;base64,{{FONT_B64}}") format("truetype");
font-weight: normal; font-style: normal; font-display: swap;
}
:root {
--ink: #2b2b2b; --muted: #8a8577; --line: #e6dfd2; --bg: #f7f4ee;
--card: #fffdf8; --accent: #b08d2f;
/* 灵爻妙解网站浅色主题(root.css :root.light + paipan_base.css) */
--ink: #222831;
--muted: rgba(27, 27, 27, .52);
--soft: rgba(27, 27, 27, .68);
--line: rgba(193, 188, 235, .55);
--card-line: rgba(193, 188, 235, .45);
--accent: #dc7b28; /* 网站浅色 primary-color-1 */
--accent-2: #f97316; /* 网站浅色 primary-color-3 */
--wx-mu: #2e7d32; --wx-huo: #c62828; --wx-tu: #8d6e63;
--wx-jin: #af8c2c; --wx-shui: #1565c0;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; background: var(--bg); color: var(--ink);
margin: 0; padding: 28px 24px 32px; color: var(--ink);
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(ellipse 72% 58% at 78% 20%, rgba(150, 125, 245, .18), transparent 68%),
radial-gradient(ellipse 66% 48% at 20% 18%, rgba(112, 174, 255, .18), transparent 70%),
radial-gradient(ellipse 82% 52% at 52% 92%, rgba(84, 210, 200, .11), transparent 72%),
linear-gradient(135deg, #fbfcff 0%, #f5f8ff 42%, #f2edff 100%);
background-attachment: fixed;
}
.page { max-width: 980px; margin: 0 auto; }
.page { max-width: 1100px; margin: 0 auto; }
/* 顶部品牌区:base.html 抽屉顶部 .drawer-brand 的居中放大版 */
.brand-head {
display: flex; align-items: center; justify-content: center;
gap: 14px; margin: 2px 0 20px;
}
.brand-logo { width: 52px; height: 52px; display: block; }
.brand-name {
font-family: 'LogoFont', 'Kaiti SC', 'STKaiti', 'KaiTi', serif;
font-weight: 500; font-size: 30px; line-height: 1.25;
color: var(--ink); letter-spacing: 1px;
}
.brand-slogan {
margin-top: 3px; font-size: 11px; letter-spacing: 3.5px;
color: var(--muted);
}
/* 大卡片:网站左 panel(.panel)亮色风格 */
.panel {
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 1), rgba(250, 252, 255, 1));
box-shadow: 0 16px 38px rgba(48, 56, 96, .12);
padding: 16px;
}
/* 面板内子卡 */
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: 12px; padding: 18px 20px; margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(60, 50, 30, .06);
background: #fff; border: 1px solid var(--card-line);
border-radius: 8px; padding: 16px 18px; margin-bottom: 14px;
box-shadow: 0 2px 8px rgba(48, 56, 96, .05);
}
/* 头部 */
.card:last-child { margin-bottom: 0; }
/* 用户信息 */
.head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.name { font-size: 28px; font-weight: 700; font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
.chip {
@@ -31,90 +76,128 @@
}
.meta { color: var(--muted); font-size: 13px; line-height: 1.9; }
.meta b { color: var(--ink); font-weight: 600; }
.meta.tech {
margin-top: 8px; padding-top: 8px; font-size: 12.5px;
border-top: 1px dashed var(--card-line);
}
.warn {
margin-top: 6px; padding: 6px 10px; border-radius: 8px;
background: #fdf3e3; border: 1px solid #ecd9ae; color: #8a6d1d; font-size: 12.5px;
}
/* 主排盘表 */
table.pan { width: 100%; border-collapse: collapse; table-layout: fixed; }
.pan th, .pan td {
border: 1px solid var(--line); text-align: center;
border: 1px solid var(--card-line); text-align: center;
padding: 6px 4px; vertical-align: middle; font-size: 14px;
}
.pan th { background: #f3eee2; color: #6b6455; font-weight: 600; }
.pan td.rowlabel { background: #f3eee2; color: #6b6455; font-size: 13px; width: 64px; }
.pan th { background: rgba(193, 188, 235, .18); color: var(--soft); font-weight: 600; }
.pan td.rowlabel { background: rgba(193, 188, 235, .14); color: var(--soft); font-size: 13px; width: 64px; }
.gz { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 34px; line-height: 1.25; }
.god { font-size: 12.5px; color: #7a7264; }
.god { font-size: 12.5px; color: var(--muted); }
.god.day { color: var(--accent); font-weight: 600; }
.hidden { font-size: 12.5px; line-height: 1.7; color: #57503f; }
.hidden { font-size: 12.5px; line-height: 1.7; color: var(--soft); }
.hidden .hg { font-weight: 600; }
.small { font-size: 12.5px; color: #57503f; line-height: 1.7; }
.small { font-size: 12.5px; color: var(--soft); line-height: 1.7; }
.voidz { border-bottom: 2px dashed #c0392b; padding-bottom: 1px; }
.cur { background: #fdf6e0; }
.curtag { display:block; font-size: 11px; color: var(--accent); }
.cur { background: rgba(220, 123, 40, .07); }
.curtag { display: block; font-size: 11px; color: var(--accent); }
.wx-mu { color: var(--wx-mu); } .wx-huo { color: var(--wx-huo); }
.wx-tu { color: var(--wx-tu); } .wx-jin { color: var(--wx-jin); }
.wx-shui { color: var(--wx-shui); }
/* 条带 */
.sect { font-size: 15px; font-weight: 700; margin: 2px 0 10px;
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
.sect .note { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; }
.strip { display: flex; flex-wrap: wrap; gap: 6px; }
.cell {
border: 1px solid var(--line); border-radius: 8px; background: #fff;
padding: 6px 8px; text-align: center; min-width: 64px;
border: 1px solid var(--card-line); border-radius: 8px; background: #fff;
padding: 6px 8px; text-align: center; min-width: 64px; max-width: 96px;
}
.cell .cy { font-size: 11px; color: var(--muted); }
.cell .cg { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 20px; }
.cell.cur { border-color: var(--accent); background: #fdf6e0; box-shadow: 0 0 0 1px var(--accent) inset; }
.cell .cs { font-size: 11px; color: var(--muted); line-height: 1.55; margin-top: 1px; }
.cell.cur { border-color: var(--accent); background: rgba(220, 123, 40, .07); box-shadow: 0 0 0 1px var(--accent) inset; }
.cell.jiao .cy { color: #c0392b; }
/* 尾部交流群卡:左图右文 */
.qrcard { display: flex; align-items: center; gap: 16px; }
.qrimg { width: 108px; height: 108px; border-radius: 6px; display: block; }
.qrtitle { font-size: 15px; font-weight: 700;
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
.qrdesc { margin-top: 4px; font-size: 12.5px; color: var(--soft); line-height: 1.8; }
/* 页脚 */
.foot { color: var(--muted); font-size: 12px; line-height: 1.9; }
.brand { margin-top: 6px; color: #a89e86; }
.foot {
text-align: center; color: var(--muted); font-size: 12px;
line-height: 1.9; margin-top: 16px;
}
</style>
</head>
<body>
<div class="page">
<div class="card">
<div class="head">
<span class="name">{{NAME}}</span>
<span class="chip">{{GENDER_LABEL}}</span>
<span class="chip">日主 {{DAY_MASTER}}</span>
<div class="brand-head">
<img class="brand-logo" src="data:image/png;base64,{{LOGO_B64}}" alt="灵爻妙解">
<div class="brand-text">
<div class="brand-name">灵爻妙解</div>
<div class="brand-slogan">传承易学智慧</div>
</div>
<div class="meta" style="margin-top:8px">
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
</div>
<div class="panel">
<div class="card">
<div class="head">
<span class="name">{{NAME}}</span>
<span class="chip">{{GENDER_LABEL}}</span>
<span class="chip">日主 {{DAY_MASTER}}</span>
</div>
<div class="meta" style="margin-top:8px">
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
</div>
{{WARNINGS}}
<div class="meta tech">
{{SEASON_BAR}}<br>
{{AUDIT}}
</div>
<!-- 排盘审计原文(开发溯源,含版本信息):{{AUDIT_FULL}} -->
</div>
{{WARNINGS}}
<div class="card">
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞</span></div>
{{MAIN_TABLE}}
</div>
<div class="card">
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
{{LUCK_STRIP}}
{{CHILDHOOD_BLOCK}}
</div>
<div class="card">
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
{{FLOW_YEARS}}
</div>
<div class="card">
<div class="sect">流月<span class="note">本年节气月</span></div>
{{FLOW_MONTHS}}
</div>
<div class="card qrcard">
<img class="qrimg" src="data:image/jpeg;base64,{{QRCODE_B64}}" alt="微信群二维码">
<div class="qrtext">
<div class="qrtitle">灵爻妙解 · 传统文化交流群</div>
<div class="qrdesc">扫码加入微信群:反馈解读偏差、交流文化心得、参与档案共建,版本更新动态第一时间同步。</div>
</div>
</div>
</div>
<div class="card">
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞(当前大运与流年并列对照)</span></div>
{{MAIN_TABLE}}
</div>
<div class="card">
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
{{LUCK_STRIP}}
{{CHILDHOOD_BLOCK}}
</div>
<div class="card">
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
{{FLOW_YEARS}}
</div>
<div class="card">
<div class="sect">流月<span class="note">本年节气月</span></div>
{{FLOW_MONTHS}}
</div>
<div class="card foot">
{{SEASON_BAR}}<br>
{{AUDIT}}
<div class="brand">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
</div>
<div class="foot">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
</div>
</body>
+4
View File
@@ -31,6 +31,10 @@ import urllib.request
import zipfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
# ---- 远端配置(GitHub 优先,Gitea 兜底)----
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
+125 -17
View File
@@ -6,11 +6,18 @@
python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男
脱敏规格(见 share/README.md):不含姓名;出生信息只保留年月日与时辰(不留分钟
与钟表时间);地域只保留到省级;事件一律类目化;正文自由文本(归纳、盘面细节、
反馈原文)不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
与钟表时间);地域保留到城市级(省+地级市,区县及以下删除);事件带类目与简介
(note),简介中城市级地名保留(规则蒸馏要用方位信息),姓名、详细住址
(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;归纳、盘面细节等
正文自由文本不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态);
不满足拒绝导出,退出码 2 并说明缺什么。退出码:0 成功;2 不可用/不完整;3 内部错误。
不满足拒绝导出,退出码 2 并说明缺什么。事件简介缺失(档案未写事件内容)
不挡导出,JSON 中如实反映(events 为空或 note 为 null)。
换盘残留提示:导出时若 pending/ 中已有同出生日、同性别但盘 hash 不同的
旧包(多为换盘/信息更正后的孤儿包),stderr 打 WARN 列出路径,提醒人工清理
(只提示,不自动删)。
退出码:0 成功;2 不可用/不完整;3 内部错误。
"""
from __future__ import annotations
@@ -25,6 +32,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _memory_store as store
SCHEMA = "share/v1"
@@ -37,7 +45,7 @@ CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书")),
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书", "大学")),
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
("流年运势", ("流年", "运势", "大运", "运程")),
]
@@ -60,6 +68,9 @@ _PROVINCE_SHORT = {
_STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"}
_GZ_RE = re.compile(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]")
_YEAR_RE = re.compile(r"(?:19|20)\d{2}")
def _categorize_all(text: str) -> list[str]:
return [cat for cat, keywords in CATEGORIES if any(k in text for k in keywords)]
@@ -71,7 +82,7 @@ def _categorize(text: str) -> str:
def _province_of(birthplace: str) -> str | None:
"""地域脱敏:只保留到省级;识别不出省级则舍弃(宁缺毋滥)。"""
"""地域脱敏:省级;识别不出省级则舍弃(宁缺毋滥)。"""
for p in _PROVINCES:
if p in birthplace:
return p
@@ -82,6 +93,42 @@ def _province_of(birthplace: str) -> str | None:
return m.group(1) if m else None
def _city_of(birthplace: str) -> str | None:
"""地域脱敏:城市级(地级市/州/盟;直辖市即省级本身)。识别不出则舍弃。"""
province = _province_of(birthplace)
if province in ("北京市", "天津市", "上海市", "重庆市",
"香港特别行政区", "澳门特别行政区"):
return province
rest = birthplace
if province and rest.startswith(province):
rest = rest[len(province):]
m = re.match(r"^(.{2,10}?(?:市|州|盟))", rest)
return m.group(1) if m else None
def _sanitize_note(text: str, name: str) -> str:
"""事件简介脱敏:姓名、身份证/电话、详细住址(小区/门牌/街道号)一律删除;
城市级地名保留(规则蒸馏要用方位信息)。"""
t = text
if name and name not in PLACEHOLDERS:
t = t.replace(name, "命主")
t = re.sub(r"\d{17}[\dXx]", "(已删)", t) # 身份证
t = re.sub(r"1[3-9]\d{9}", "(已删)", t) # 手机号
t = re.sub(r"\d{3,4}-\d{7,8}", "(已删)", t) # 座机号
t = re.sub(r"[^\s,。;:、()]*(?:小区|公寓|花园|山庄|新村|苑|大院)"
r"[^\s,。;:、()]*", "(已删)", t) # 小区/楼盘名
t = re.sub(r"\d+\s*(?:号楼|栋|单元|室|号院|层|户)", "(已删)", t) # 楼栋门牌
t = re.sub(r"(?:街|路|巷|大道|道)\s*\d+\s*号(?:\s*\d+\s*室)?",
"(已删)", t) # 街道号
return t.strip()
def _strip_tags(text: str) -> str:
for tag in _STATUS_TAGS:
text = text.replace(tag, "")
return text.strip()
def _section_lines(body: str, heading: str) -> list[str]:
"""取档案正文某固定小节(SECTIONS 内)的行,到下一个固定小节或文末。"""
lines = body.splitlines()
@@ -129,22 +176,59 @@ def _parse_focus(body: str) -> list[str]:
return cats
def _parse_feedback(body: str) -> list[dict]:
"""反馈事件:只取年份、类目、核实状态——反馈原文不入包。"""
def _status_of(t: str) -> str:
for tag, name in _STATUS_TAGS.items():
if tag in t:
return name
return "未核实"
def _parse_feedback(body: str, name: str) -> list[dict]:
"""反馈事件:年份、类目、核实状态 + 事件简介(note,脱敏后;无实质内容则为 None)。"""
events = []
for ln in _section_lines(body, "反馈与澄清"):
t = ln.strip().lstrip("-•· ").strip()
if not t or t in PLACEHOLDERS:
continue
status = "未核实"
for tag, name in _STATUS_TAGS.items():
if tag in t:
status = name
break
m = re.search(r"(?:19|20)\d{2}", t)
m = _YEAR_RE.search(t)
note = _sanitize_note(_strip_tags(t), name)
events.append({"year": int(m.group(0)) if m else None,
"category": _categorize(t),
"status": status})
"status": _status_of(t),
"note": note or None})
return events
def _parse_events(body: str, name: str) -> list[dict]:
"""流年大事:年份(区间取首年)、干支、标签、核实状态 + 事件简介(脱敏后)。
行形如「- 2016(丙申·交运年,入丁酉运):【已核实】离乡,自四川乐山赴厦门读大学。」;
无括号标签或缺事件内容时对应字段为 None(如实反映,不挡导出)。
"""
events = []
for ln in _section_lines(body, "流年大事"):
t = ln.strip().lstrip("-•· ").strip()
if not t or t in PLACEHOLDERS:
continue
m = re.match(r"^((?:19|20)\d{2})(?:[–—\-]\d{2,4})?"
r"(?:(([^)]*)))?\s*[::]\s*(.+)$", t)
if m:
year = int(m.group(1))
label = m.group(2) or None
note = m.group(3)
else:
ym = _YEAR_RE.search(t)
year = int(ym.group(0)) if ym else None
label = None
note = t
ganzhi = None
if label:
gz = _GZ_RE.search(label)
ganzhi = gz.group(0) if gz else None
note = _sanitize_note(_strip_tags(note), name)
events.append({"year": year, "ganzhi": ganzhi, "label": label,
"note": note or None, "status": _status_of(t),
"category": _categorize(t)})
return events
@@ -154,13 +238,18 @@ def chart_hash(pillars: list[str], birthplace: str) -> str:
def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
"""组装脱敏包;返回 (package, 缺失项列表)。package 保证不含姓名/分钟/省以下地域。"""
"""组装脱敏包;返回 (package, 缺失项列表)。
保证不含姓名、出生分钟/钟表时间、区县及以下地域;事件简介中城市级地名保留,
直接定位信息(详细住址、证件、电话)已删除。
"""
pillars = meta.get("pillars", [])
if isinstance(pillars, str):
pillars = pillars.split()
pillars = [str(p) for p in pillars]
birthplace = str(meta.get("birthplace", ""))
gender = str(meta.get("gender", ""))
name = str(meta.get("name", ""))
pkg: dict = {"schema": SCHEMA,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
@@ -169,7 +258,8 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
"pillars": pillars,
"luck_pillars": _parse_luck(body),
"focus_categories": _parse_focus(body),
"feedback": _parse_feedback(body)}
"events": _parse_events(body, name),
"feedback": _parse_feedback(body, name)}
# 出生信息脱敏:只保留年月日与时辰(时支),不留分钟与钟表时间
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", str(meta.get("birth_clock", "")).strip())
@@ -180,6 +270,9 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
province = _province_of(birthplace)
if province:
pkg["region_province"] = province
city = _city_of(birthplace)
if city:
pkg["region_city"] = city
missing = []
if not gender:
@@ -210,7 +303,7 @@ def _find_archive(args) -> Path | None:
def main() -> int:
ap = argparse.ArgumentParser(
prog="share_export.py",
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、省以下地域")
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、区县及以下地域")
ap.add_argument("--archive", default=None, help="档案文件路径(直接指定)")
ap.add_argument("--name", default=None, help="姓名(检索条件)")
ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别(检索条件)")
@@ -249,6 +342,21 @@ def main() -> int:
pending = share_root / "pending"
pending.mkdir(parents=True, exist_ok=True)
out = pending / f"{pkg['chart_hash']}.json"
# 换盘孤儿包提示:pending 中同出生日、同性别但盘 hash 不同的旧包,
# 多为换盘/信息更正后的残留,提醒人工清理(只提示,不自动删)
for old in sorted(pending.glob("*.json")):
if old.name == out.name:
continue
try:
opkg = json.loads(old.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if (opkg.get("birth_date") == pkg.get("birth_date")
and opkg.get("gender") == pkg.get("gender")
and opkg.get("chart_hash") != pkg["chart_hash"]):
print(f"WARN: pending 中已有同出生日同性别的旧包(盘 hash 不同),"
f"疑似换盘残留:{old};确认后请手动删除,避免误推。",
file=sys.stderr)
out.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(out)
+45 -20
View File
@@ -9,7 +9,8 @@ ZIPING_SHARE_GITEA_REMOTE 覆盖)。两个远端逐个尝试,至少一个成
推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次),
并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。
无 git 或无网络安静失败(不打栈)。
无 git 或无网络安静失败(不打栈);远端推送失败时在 stderr 带该远端
git 报错的末几行摘要,GitHub 线失败另附 HTTPS_PROXY 代理提示。
退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。
"""
@@ -26,6 +27,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _license_gate as gate
GRANT_USES = 10 # 每盘推送成功的计励次数
@@ -39,24 +41,38 @@ def _git(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
capture_output=True, text=True, timeout=60)
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> bool:
"""克隆远端 → 放入脱敏包 → commit+push。任一步失败返回 False(安静)。"""
def _err_tail(cp: subprocess.CompletedProcess, n: int = 2) -> str:
"""取 git 命令报错的末几行(stderr 优先,退而 stdout)。"""
for text in (cp.stderr, cp.stdout):
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
if lines:
return " / ".join(lines[-n:])
return f"退出码 {cp.returncode}"
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> tuple[bool, str]:
"""克隆远端 → 放入脱敏包 → commit+push。返回 (成功与否, 失败摘要)。"""
try:
if _git("clone", "--depth", "50", remote, workdir).returncode != 0:
return False
r = _git("clone", "--depth", "50", remote, workdir)
if r.returncode != 0:
return False, f"clone 失败:{_err_tail(r)}"
dst = Path(workdir) / pkg.name
shutil.copy2(pkg, dst)
if _git("add", pkg.name, cwd=workdir).returncode != 0:
return False
commit = _git("-c", "user.name=ziping-share",
"-c", "user.email=ziping-share@localhost",
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
cwd=workdir)
if commit.returncode != 0:
return False
return _git("push", "origin", "HEAD:main", cwd=workdir).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
r = _git("add", pkg.name, cwd=workdir)
if r.returncode != 0:
return False, f"add 失败:{_err_tail(r)}"
r = _git("-c", "user.name=ziping-share",
"-c", "user.email=ziping-share@localhost",
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
cwd=workdir)
if r.returncode != 0:
return False, f"commit 失败:{_err_tail(r)}"
r = _git("push", "origin", "HEAD:main", cwd=workdir)
if r.returncode != 0:
return False, f"push 失败:{_err_tail(r)}"
return True, ""
except (OSError, subprocess.SubprocessError) as e:
return False, f"{type(e).__name__}: {e}"
def _remotes(root: Path) -> list[tuple[str, str]]:
@@ -106,13 +122,22 @@ def main() -> int:
for pkg in packages:
chart_hash = pkg.stem
ok_remotes = []
failures = []
for name, remote in remotes:
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
if _push_to_remote(remote, pkg, td):
ok_remotes.append(name)
ok, why = _push_to_remote(remote, pkg, td)
if ok:
ok_remotes.append(name)
else:
if name == "github_remote":
why += (";若本机直连 GitHub 不通,可通过 HTTPS_PROXY "
"环境变量配置代理")
failures.append(f"{name}:{why}")
for f in failures:
print(f"WARN {pkg.name} {f}", file=sys.stderr)
if not ok_remotes:
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达),"
f"pending 保留。", file=sys.stderr)
print(f"FAIL {pkg.name}:所有远端推送失败,pending 保留。",
file=sys.stderr)
continue
granted = gate.grant_uses(root, GRANT_USES, chart_hash)
shutil.move(str(pkg), str(pushed / pkg.name))
+4 -4
View File
@@ -15,11 +15,11 @@
| 项目 | 处理 |
|---|---|
| 姓名 | 一律删除,不入包 |
| 姓名 | 一律删除,不入包(事件简介中出现的姓名替换为「命主」) |
| 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 |
| 地域 | 只保留到省级,省以下(市、县、区县)一律删除 |
| 事件 | 一律类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他),不写细节 |
| 正文自由文本 | 不入包(归纳、盘面细节、反馈原文均不导出) |
| 地域 | 保留到城市级(省+地级市),区县及以下一律删除 |
| 事件 | 类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他)+ 事件简介(note);简介中**城市级地名保留**(规则蒸馏要用方位信息),详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;书写事件简介时只写到城市级 |
| 正文自由文本 | 归纳、盘面细节不入包;入包的事件简介按上行规格脱敏 |
| 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 |
## 用途与许可
+113
View File
@@ -0,0 +1,113 @@
"""chart_shot.py 与 cast_chart --shot:HTML → PNG 截图的分级回退与优雅失败。"""
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SKILL_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
import chart_shot # noqa: E402
CHROME_OK = Path(chart_shot.CHROME).is_file()
try:
import playwright # noqa: F401
PLAYWRIGHT_OK = True
except ImportError:
PLAYWRIGHT_OK = False
SHOT_OK = CHROME_OK or PLAYWRIGHT_OK
HTML = "<!DOCTYPE html><html><body><h1>命盘</h1></body></html>"
class ShootUnitTest(unittest.TestCase):
def test_missing_html_exit_2(self):
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
"/nonexistent/x.html"], capture_output=True, text=True, timeout=30)
self.assertEqual(r.returncode, 2)
self.assertIn("ERROR", r.stderr)
def test_no_tools_fails_gracefully(self):
"""playwright 与 Chrome 都不可用时:shoot 返回 None,CLI 退出码 2 并给提示。"""
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
with mock.patch.object(chart_shot, "_shot_playwright",
return_value=False), \
mock.patch.object(chart_shot, "_shot_chrome",
return_value=False):
self.assertIsNone(chart_shot.shoot(html))
self.assertFalse(html.with_suffix(".png").exists())
with mock.patch.object(sys, "argv",
["chart_shot.py", str(html)]):
with mock.patch("sys.stderr") as err:
self.assertEqual(chart_shot.main(), 2)
err.write.assert_called()
def test_content_height_bounds(self):
"""高度估算不越界:空页夹到下限,超长页夹到上限。"""
with tempfile.TemporaryDirectory() as td:
empty = Path(td) / "e.html"
empty.write_text(HTML, encoding="utf-8")
self.assertEqual(chart_shot._content_height(empty), 1300)
big = Path(td) / "b.html"
big.write_text(
'<div class="strip">' + '<div class="cell"></div>' * 5000
+ "</div>", encoding="utf-8")
self.assertEqual(chart_shot._content_height(big), 4200)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过真实截图")
class ShootRealTest(unittest.TestCase):
def test_real_png(self):
"""真实截图:PNG 非空、宽 2200(1100 窗口 × 2 倍分辨率)。"""
import struct
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
png = chart_shot.shoot(html)
self.assertIsNotNone(png)
data = png.read_bytes()
self.assertGreater(len(data), 1000)
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
w, _h = struct.unpack(">II", data[16:24])
self.assertEqual(w, 2200)
def test_cli_success(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
out = Path(td) / "o.png"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
str(html), "-o", str(out)],
capture_output=True, text=True, timeout=120)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn(str(out), r.stdout)
self.assertGreater(out.stat().st_size, 0)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过 --shot 端到端")
class CastShotE2ETest(unittest.TestCase):
def test_shot_flag_outputs_paths(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
"--place", "四川省成都市", "--html", str(html), "--shot"],
capture_output=True, text=True, timeout=120,
cwd=str(SKILL_ROOT / "scripts"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
self.assertIn(f"HTML 盘面:{html.resolve()}", r.stdout)
png = html.with_suffix(".png")
self.assertIn(f"盘面截图:{png.resolve()}", r.stdout)
self.assertGreater(png.stat().st_size, 0)
if __name__ == "__main__":
unittest.main()
+107 -10
View File
@@ -60,19 +60,116 @@ class HtmlRenderTest(unittest.TestCase):
for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"):
self.assertIn(sect, html)
def test_brand_head_embedded(self):
"""顶部品牌区:logo 与 LogoFont 均 base64 内嵌,口号在。"""
from render.html import build_html
html = build_html(_chart())
self.assertIn("灵爻妙解", html)
self.assertIn("传承易学智慧", html)
self.assertIn('class="brand-head"', html)
self.assertIn("data:image/png;base64,", html)
self.assertIn("@font-face", html)
self.assertIn("data:font/ttf;base64,", html)
def test_qrcode_card(self):
"""尾部交流群卡:二维码 jpeg base64 内嵌,标题与正文文案各出现一次。"""
from render.html import build_html
html = build_html(_chart())
self.assertIn('class="card qrcard"', html)
self.assertIn("data:image/jpeg;base64,", html)
self.assertEqual(html.count("灵爻妙解 · 传统文化交流群"), 1)
self.assertEqual(
html.count("扫码加入微信群:反馈解读偏差、交流文化心得、"
"参与档案共建,版本更新动态第一时间同步。"), 1)
def test_audit_versions_only_in_comment(self):
"""技术信息并入用户信息卡:展示行删版本段,完整原文留在 HTML 注释。"""
from render.html import build_html
html = build_html(_chart())
self.assertEqual(html.count("tyme4py"), 1)
self.assertEqual(html.count("schema"), 1)
m = re.search(r"<!--[^>]*tyme4py[^>]*-->", html)
self.assertIsNotNone(m, "注释中应保留含 tyme4py/schema 的完整审计原文")
self.assertIn("schema", m.group(0))
self.assertIn("时区", m.group(0))
self.assertIn("月令", html)
self.assertIn("日界 子初换日(23:00)", html)
def test_shensha_notes_stripped(self):
"""神煞括号注释在装配层剔除(全角/半角),Markdown 侧不受影响。"""
from render.html import _strip_sha_note, build_html
self.assertEqual(_strip_sha_note("将星(以年支子查,见于月支子)"), "将星")
self.assertEqual(_strip_sha_note("天乙贵人(以日干查)"), "天乙贵人")
self.assertEqual(_strip_sha_note("驿马"), "驿马")
html = build_html(_chart())
self.assertNotIn("(以", html)
def test_luck_flow_card_removed(self):
"""「大运 · 流年」对照卡已整张删除;排盘卡仍只含四柱。"""
from render.html import build_html
html = build_html(_chart())
self.assertNotIn("大运 · 流年", html)
self.assertNotIn("当前一步大运与本年流年对照", html)
self.assertNotIn("LUCK_FLOW_TABLE", html)
pan_seg = html[html.find("排盘<span"):html.find('class="sect">大运')]
self.assertIn("年柱", pan_seg)
self.assertNotIn("大运", pan_seg)
self.assertNotIn("流年", pan_seg)
def test_luck_strip_enriched(self):
"""大运格:起运年+干支之外带十神·星运/空亡/纳音/神煞(2018 甲申运对拍)。"""
from render.html import build_html
html = build_html(_chart())
seg = html[html.find('class="sect">大运'):html.find('class="sect">流年')]
self.assertIn("2018 · 28 岁起", seg)
self.assertIn("正官 · 沐浴", seg) # 甲对日干己 / 己对申
self.assertIn("午未", seg) # 甲申旬空
self.assertIn("泉中水", seg) # 甲申纳音
self.assertIn("天乙贵人 · 金舆 · 驿马 · 劫煞 · 孤辰", seg) # 甲申岁运神煞
self.assertIn('class="cell cur"', seg) # 当前一步高亮
def test_flow_year_strip_enriched(self):
"""流年格:年份+干支之外带主星/星运/空亡/纳音/神煞(2026 丙午对拍)。"""
from render.html import build_html
html = build_html(_chart())
seg = html[html.find('class="sect">流年'):html.find('class="sect">流月')]
self.assertIn("正印 · 临官", seg) # 丙对日干己 / 己对午
self.assertIn("寅卯", seg) # 丙午旬空(甲辰旬)
self.assertIn("天河水", seg) # 丙午纳音
self.assertIn("天德合 · 禄神 · 将星", seg) # 丙午岁运神煞
self.assertIn('class="cell cur"', seg) # 本年高亮
def test_strips_have_no_field_labels(self):
"""条带小卡只排值:不出现「主星/星运/空亡/纳音/神煞」字段标签。"""
from render.html import build_html
html = build_html(_chart())
segs = [html[html.find('class="sect">大运'):html.find('class="sect">流年')],
html[html.find('class="sect">流年'):html.find('class="sect">流月')]]
for seg in segs:
for label in ("主星", "星运", "空亡", "纳音", "神煞"):
self.assertNotIn(label, seg)
def test_ten_god_table(self):
from render.html import _ten_god
self.assertEqual(_ten_god("庚", "丁"), "正官")
self.assertEqual(_ten_god("庚", "丙"), "七杀")
self.assertEqual(_ten_god("庚", "辛"), "劫财")
self.assertEqual(_ten_god("庚", "壬"), "食神")
from chart.ganzhi import ten_god
self.assertEqual(ten_god("庚", "丁"), "正官")
self.assertEqual(ten_god("庚", "丙"), "七杀")
self.assertEqual(ten_god("庚", "辛"), "劫财")
self.assertEqual(ten_god("庚", "壬"), "食神")
def test_void_and_terrain(self):
from render.html import _gz_void, _terrain
self.assertEqual(_gz_void("庚子"), "辰巳")
self.assertEqual(_gz_void("甲子"), "戌亥")
self.assertEqual(_terrain("庚", "巳"), "长生")
self.assertEqual(_terrain("庚", "子"), "死")
from chart.ganzhi import gz_void, terrain
self.assertEqual(gz_void("庚子"), "辰巳")
self.assertEqual(gz_void("甲子"), "戌亥")
self.assertEqual(terrain("庚", "巳"), "长生")
self.assertEqual(terrain("庚", "子"), "死")
def test_nayin_shared_with_shensha_module(self):
"""纳音表集中于 chart/ganzhi.py,chart/shensha.py 复用同一份。"""
from chart.ganzhi import NAYIN
from chart.shensha import NAYIN as SHA_NAYIN
self.assertIs(NAYIN, SHA_NAYIN)
self.assertEqual(NAYIN["甲辰"], "覆灯火")
self.assertEqual(NAYIN["丙午"], "天河水")
class CastHtmlE2ETest(unittest.TestCase):
+98
View File
@@ -16,6 +16,10 @@ LOOKUP = SCRIPTS_DIR / "memory_lookup.py"
ZHANGSAN = ["--name", "张三", "--gender", "男",
"--birth", "1990-05-14 08:30", "--place", "四川省成都市"]
# 晚子时案例:真太阳时落于 23:00–24:00,子初/子正换日四柱不同
WANZI = ["--name", "李四", "--gender", "女",
"--birth", "1996-12-08 00:30", "--place", "四川省乐山市"]
def run(script, mem_root, *args, use_env=False):
env = dict(os.environ)
@@ -117,3 +121,97 @@ class TestLookupNoMatch:
def test_no_condition_exit_2(self, tmp_path):
r = run(LOOKUP, tmp_path)
assert r.returncode == 2
class TestUpdateChart:
"""换盘(--update-chart):工作盘唯一——盘面段整体替换为新盘,frontmatter
与文件名同步,其余小节保留;旧盘信息由使用方记入反馈与澄清。"""
def test_zizheng_replaces_chart_and_renames(self, tmp_path):
r1 = run(SAVE, tmp_path, *WANZI)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
assert "丙子庚子庚辰丙子" in path.name # 默认子初换日盘
# 模拟核对后的回写 + 固定 created_at
text = path.read_text(encoding="utf-8")
text = text.replace("(无)", "- 【已核实】2016 年离乡赴厦门读大学。")
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")
# 用户确认子正换日:重排替换盘面段,日柱/时柱变 → 改新主键名
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
"--day-boundary", "子正")
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert "日柱 庚辰→己卯" in r2.stderr and "时柱 丙子→甲子" in r2.stderr
assert "待复核" in r2.stderr # 变动柱影响条目提示复核
new_path = Path(r2.stdout.strip())
assert new_path != path and not path.exists()
assert "丙子庚子己卯甲子" in new_path.name
assert list((tmp_path / "archives").glob("*.md")) == [new_path]
text = new_path.read_text(encoding="utf-8")
meta = read_meta(text)
assert meta["pillars"] == '["丙子", "庚子", "己卯", "甲子"]'
assert meta["day_master"] == "己"
assert meta["created_at"] == "2000-01-01 00:00" # 保留
# 盘面段只剩新盘:日界审计与晚子时警示均为子正口径,无旧盘残留
assert "- 日界规则:子正换日(真太阳时 00:00)" in text
assert "已按「子正换日」定盘" in text
assert text.count("# 命盘") == 1
chart_section = text.split("## 盘面")[1].split("## 主要关注方向")[0]
assert "日柱:己卯" in chart_section and "日柱:庚辰" not in chart_section
# 其余小节保留
assert "- 【已核实】2016 年离乡赴厦门读大学。" in text
def test_birth_correction_updates_meta(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
"--birth", "1990-05-14 10:30")
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert "时柱" in r2.stderr and "待复核" in r2.stderr
new_path = Path(r2.stdout.strip())
assert new_path != path and not path.exists() # hash6 随 birth 变
meta = read_meta(new_path.read_text(encoding="utf-8"))
assert meta["birth_clock"] == "1990-05-14 10:30"
def test_same_chart_keeps_filename(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
r2 = run(SAVE, tmp_path, "--update-chart", str(path))
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert Path(r2.stdout.strip()) == path # 四柱未变,文件名不动
assert "待复核" not in r2.stderr
def test_conflict_refuses_overwrite(self, tmp_path):
run(SAVE, tmp_path, *WANZI)
olds = list((tmp_path / "archives").glob("*.md"))
assert len(olds) == 1
old = olds[0]
r = run(SAVE, tmp_path, "--update-chart", str(old),
"--day-boundary", "子正")
assert r.returncode == 0, f"stderr:\n{r.stderr}"
# 重新建子初盘档案,再换子正 → 目标已存在,拒绝覆盖
run(SAVE, tmp_path, *WANZI)
r = run(SAVE, tmp_path, "--update-chart", str(old),
"--day-boundary", "子正")
assert r.returncode == 2
assert "已存在" in r.stderr
assert len(list((tmp_path / "archives").glob("*.md"))) == 2
def test_missing_archive_exit_2(self, tmp_path):
r = run(SAVE, tmp_path, "--update-chart",
str(tmp_path / "archives" / "nope.md"))
assert r.returncode == 2
def test_rejects_name_gender_overrides(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = r1.stdout.strip()
r = run(SAVE, tmp_path, "--update-chart", path, "--name", "王五")
assert r.returncode == 2
+74
View File
@@ -0,0 +1,74 @@
"""运行环境守卫(scripts/_runtime_guard.py):版本判定文案、import 时拦截、入口脚本接线。
import 时拦截用子进程模拟:伪造 sys.version_info = (3, 9, 6) 后再 import,
守卫应打印中文报错并以退出码 3 结束进程。
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
import _runtime_guard as guard
ENTRY_SCRIPTS = ("cast_chart.py", "chart_shot.py", "memory_lookup.py",
"memory_save.py", "query_shensha.py", "self_update.py",
"share_export.py", "share_push.py")
def run_with_fake_py39(code: str) -> subprocess.CompletedProcess:
"""在子进程中把 sys.version_info 伪造成 3.9.6 后执行 code。"""
prefix = "import sys, runpy; sys.version_info = (3, 9, 6); "
return subprocess.run([sys.executable, "-c", prefix + code],
capture_output=True, text=True, timeout=60)
class TestVersionError:
def test_current_interpreter_passes(self):
assert guard.version_error() is None
def test_py39_denied_with_chinese_message(self):
msg = guard.version_error((3, 9, 6))
assert msg is not None
assert "Python 3.10" in msg
assert "3.9.6" in msg
assert sys.executable in msg
def test_boundary_versions(self):
assert guard.version_error((3, 10, 0)) is None
assert guard.version_error((4, 0, 0)) is None
assert guard.version_error((3, 8, 10)) is not None
assert guard.version_error((2, 7, 18)) is not None
class TestImportTimeGuard:
def test_import_denied_exits_3(self):
cp = run_with_fake_py39(
f"sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard")
assert cp.returncode == 3
assert "Python 3.10" in cp.stderr
def test_import_allowed_is_silent(self):
cp = subprocess.run(
[sys.executable, "-c",
f"import sys; sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard"],
capture_output=True, text=True, timeout=60)
assert cp.returncode == 0
assert cp.stderr == ""
def test_entry_script_intercepts_before_deep_error(self):
"""chart_shot.py 在 3.9 下原本 def 期即 TypeError;接线后守卫先拦截,报错清晰。"""
cp = run_with_fake_py39(
f"sys.argv = ['chart_shot.py', 'x.html']; "
f"runpy.run_path({str(SCRIPTS / 'chart_shot.py')!r}, run_name='__main__')")
assert cp.returncode == 3
assert "Python 3.10" in cp.stderr
assert "TypeError" not in cp.stderr
class TestEntryScriptsWired:
def test_every_entry_script_imports_guard(self):
for name in ENTRY_SCRIPTS:
src = (SCRIPTS / name).read_text(encoding="utf-8")
assert "import _runtime_guard" in src, f"{name} 未接入 _runtime_guard"
+85 -9
View File
@@ -19,7 +19,7 @@ import _memory_store as store
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
BIRTH = "1990-05-14 08:30"
PLACE = "四川省成都市"
PLACE = "四川省成都市武侯区"
git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git")
@@ -31,11 +31,14 @@ def run(script, *args):
capture_output=True, text=True, timeout=120, env=env)
def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
"""造一份手工档案:四柱/大运/关注方向齐全,反馈小节可定制。"""
def make_archive(mem_root: Path, feedback: str | None = None,
events: str | None = None,
pillars: list[str] | None = None) -> Path:
"""造一份手工档案:四柱/大运/关注方向齐全,流年大事与反馈小节可定制。"""
pillars = pillars or PILLARS
meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH,
"birthplace": PLACE, "true_solar_time": "1990-05-14 08:23",
"pillars": PILLARS, "day_master": "己",
"pillars": pillars, "day_master": "己",
"created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"}
body = (
"# 档案:张三(男)\n\n"
@@ -49,15 +52,19 @@ def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
"## 小运\n\n(无)\n\n"
"## 主要关注方向\n\n- 婚姻与事业走向\n\n"
"## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n"
"## 流年大事\n\n(未记录)\n\n"
"## 反馈与澄清\n\n"
"## 流年大事\n\n"
+ (events if events is not None else
"- 2018(戊戌):【已核实】升职,自成都赴北京工作。\n"
"- 2021(辛丑):【已核实】张三搬家,新居在幸福小区3栋2单元501室,"
"电话 13812345678。\n")
+ "\n## 反馈与澄清\n\n"
+ (feedback if feedback is not None else
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
)
arch_dir = mem_root / "archives"
arch_dir.mkdir(parents=True, exist_ok=True)
path = arch_dir / store.archive_name(
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男"))
pillars, "男", store.key_hash(BIRTH, PLACE, "男"))
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
return path
@@ -77,11 +84,13 @@ class TestExport:
text = out.read_text("utf-8")
pkg = json.loads(text)
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域
# 脱敏:不含姓名、出生分钟/钟表时间、区县及以下地域、直接定位信息
assert "name" not in pkg
assert "张三" not in text
assert "08:30" not in text and "08:23" not in text
assert "成都市" not in text
assert "武侯区" not in text
assert "幸福小区" not in text and "501" not in text
assert "13812345678" not in text
assert "birth_clock" not in pkg and "birthplace" not in pkg
# 结构字段
@@ -90,17 +99,50 @@ class TestExport:
assert pkg["pillars"] == PILLARS
assert pkg["birth_date"] == "1990-05-14"
assert pkg["birth_shichen"] == "辰"
# 地域放宽到城市级:省+地级市保留
assert pkg["region_province"] == "四川省"
assert pkg["region_city"] == "成都市"
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
assert pkg["luck_pillars"][0]["start_year"] == 1998
assert pkg["focus_categories"] == ["婚姻", "事业"]
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
assert all(f["category"] for f in pkg["feedback"])
# 反馈带事件简介(状态标签已剥离)
fb = {f["year"]: f for f in pkg["feedback"]}
assert fb[2018]["note"] == "2018 年升职,跳槽到外企"
assert fb[2020]["note"] == "2020 年感情波动"
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
assert out.stem == pkg["chart_hash"]
assert len(pkg["chart_hash"]) == 8
def test_events_carry_notes_with_city_kept(self, tmp_path, share_root):
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
ev = pkg["events"]
assert len(ev) == 2
# 目标格式:2018(戊戌):升职,自成都赴北京工作。——城市级地名保留
assert ev[0]["year"] == 2018 and ev[0]["ganzhi"] == "戊戌"
assert ev[0]["label"] == "戊戌"
assert ev[0]["note"] == "升职,自成都赴北京工作。"
assert ev[0]["status"] == "已核实" and ev[0]["category"] == "事业"
# 姓名替换为「命主」,详细住址与电话删除,事件内容保留
assert ev[1]["year"] == 2021 and ev[1]["ganzhi"] == "辛丑"
assert "搬家" in ev[1]["note"] and "命主" in ev[1]["note"]
assert "(已删)" in ev[1]["note"]
def test_events_missing_still_exports(self, tmp_path, share_root):
# 流年大事为空不挡导出,events 如实为空
arch = make_archive(tmp_path / "memory", events="(未记录)\n")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
assert pkg["events"] == []
assert len(pkg["feedback"]) == 2
def test_lookup_by_birth_place_gender(self, tmp_path, share_root):
make_archive(tmp_path / "memory")
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
@@ -121,6 +163,20 @@ class TestExport:
"--share-root", str(share_root))
assert r.returncode == 2
def test_export_warns_orphan_pending_after_recast(self, tmp_path, share_root):
# 先导出原盘,再导出同出生日同性别但时柱不同的换盘 → WARN 提示残留,不自动删
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
arch2 = make_archive(tmp_path / "memory2",
pillars=["庚午", "辛巳", "己卯", "己巳"])
r2 = run(EXPORT, "--archive", str(arch2), "--share-root", str(share_root))
assert r2.returncode == 0, r2.stderr
assert r.stdout.strip() != r2.stdout.strip() # hash 确实变了
assert "WARN" in r2.stderr and "残留" in r2.stderr
assert r.stdout.strip().split("/")[-1] in r2.stderr # 列出了旧包文件名
assert len(list((share_root / "pending").glob("*.json"))) == 2 # 不自动删
@git
class TestPush:
@@ -190,6 +246,26 @@ class TestPush:
assert pkg.is_file() # 失败保留 pending
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 5 # 未计励
# 失败诊断:带该远端 git 报错末行;GitHub 线附 HTTPS_PROXY 提示
assert "FAIL" in r.stderr and "no-such-repo" in r.stderr
assert "HTTPS_PROXY" in r.stderr
def test_push_partial_failure_warns_but_succeeds(self, skill, tmp_path):
# github 坏、gitea 好:整体成功,但 stderr 带 github 线的失败摘要
(skill / "share" / "config.json").write_text(json.dumps(
{"github_remote": str(skill / "no-such-repo.git"),
"gitea_remote": str(tmp_path / "gitea.git")}, ensure_ascii=False),
"utf-8")
pkg = skill / "share" / "pending" / "baddcafe.json"
pkg.write_text(json.dumps({"schema": "share/v1",
"chart_hash": "baddcafe"}), "utf-8")
r = run(PUSH, "--root", str(skill))
assert r.returncode == 0, r.stderr
assert (skill / "share" / "pushed" / "baddcafe.json").is_file()
assert "WARN" in r.stderr and "github_remote" in r.stderr
assert "no-such-repo" in r.stderr and "HTTPS_PROXY" in r.stderr
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15 # gitea 成功,正常计励
def test_no_remote_configured(self, tmp_path):
root = tmp_path / "skill"
+66 -1
View File
@@ -9,7 +9,16 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from chart.shensha import evaluate_natal, evaluate_target # noqa: E402
from chart.ganzhi import GAN, ZHI # noqa: E402
from chart.shensha import STAR_ORDER, evaluate_natal, evaluate_target # noqa: E402
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
sys.path.insert(0, str(_VENDOR))
try: # 排盘适配层(含排序逻辑);无 tyme4py 的环境跳过其回归测试
from chart.tyme_adapter import _shen_sha
except ImportError: # pragma: no cover
_shen_sha = None
def natal(year, month, day, hour, gender="男"):
@@ -267,5 +276,61 @@ class TargetQueryTest(unittest.TestCase):
self.assertEqual(own, {"十恶大败"})
def _jiazi60() -> list[str]:
return [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
# 甲子…乙亥:12 个干支覆盖全部 10 天干与 12 地支(月、时柱扫面用)
_SPREAD12 = _jiazi60()[:12]
class StarOrderExhaustiveTest(unittest.TestCase):
"""根治校验:求值器一切可能产出的神煞名都必须落在 STAR_ORDER 键集内。
3.6.0 事故:三奇贵人漏录排序表,排盘排序 KeyError。此处穷举求值器
全部输入维度(年柱 60 × 月柱 12 全干支行 × 日柱 60 × 时柱 12 全干支行
× 男女),收集实际产出的星名集合,断言与 STAR_ORDER 完全一致——
新增神煞忘补排序表、或排序表残留死项,都会被本测试当场抓住。
"""
def test_star_order_no_duplicates(self):
self.assertEqual(len(STAR_ORDER), len(set(STAR_ORDER)))
def test_natal_outputs_exactly_star_order(self):
produced: set[str] = set()
for year in _jiazi60():
for month in _SPREAD12:
for day in _jiazi60():
for hour in _SPREAD12:
for gender in ("男", "女"):
r = evaluate_natal(
{"year": year, "month": month,
"day": day, "hour": hour}, gender)
for entries in r.values():
produced.update(s for s, _ in entries)
self.assertEqual(produced, set(STAR_ORDER))
def test_target_outputs_subset_of_star_order(self):
"""岁运查询同理:年 60 × 月 4(四季各一)× 日 60 × 目标干支 60。"""
produced: set[str] = set()
for year in _jiazi60():
for month in ("甲寅", "乙巳", "丙申", "丁亥"):
for day in _jiazi60():
p = {"year": year, "month": month, "day": day, "hour": "甲子"}
for target in _jiazi60():
r = evaluate_target(p, "男", target)
produced.update(s for s, _ in r["anchored"])
produced.update(s for s, _ in r["own"])
missing = produced - set(STAR_ORDER)
self.assertEqual(missing, set())
@unittest.skipIf(_shen_sha is None, "tyme4py 不可用")
def test_sanqi_combo_survives_adapter_sort(self):
"""回归:combo 组三奇贵人经 tyme_adapter._shen_sha 排序不再 KeyError。"""
out = _shen_sha({"year": "甲辰", "month": "戊辰",
"day": "庚子", "hour": "辛巳"}, "男")
self.assertTrue(any(s.startswith("三奇贵人") for s in out["combo"]))
if __name__ == "__main__":
unittest.main()