From 433c9b4a41b3db53374e91a98b44fb37fd1d3825 Mon Sep 17 00:00:00 2001 From: moxiaonian Date: Sat, 10 Oct 2026 13:13:41 +0800 Subject: [PATCH] =?UTF-8?q?v3.6.0=20=E5=90=8C=E6=AD=A5=E5=8F=91=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 交互契约改对话式询问(禁点选工具、一次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 项全绿 --- README.md | 6 +- SKILL.md | 47 ++-- VERSION | 2 +- manifest.json | 56 +++-- memory/README.md | 16 +- pyproject.toml | 2 +- rules/90_行运.md | 6 +- rules/README.md | 5 +- scripts/_runtime_guard.py | 32 +++ scripts/astro/day_boundary.py | 10 +- scripts/cast_chart.py | 21 +- scripts/chart/ganzhi.py | 68 ++++++ scripts/chart/shensha.py | 17 +- scripts/chart_shot.py | 96 ++++++++ scripts/memory_lookup.py | 1 + scripts/memory_save.py | 305 ++++++++++++++++++------- scripts/query_shensha.py | 2 + scripts/render/assets/lingyao-logo.png | Bin 0 -> 30247 bytes scripts/render/assets/logo-font.ttf | Bin 0 -> 4392 bytes scripts/render/assets/qun-qrcode.jpg | Bin 0 -> 54767 bytes scripts/render/html.py | 188 ++++++--------- scripts/render/templates/paipan.html | 195 +++++++++++----- scripts/self_update.py | 4 + scripts/share_export.py | 142 ++++++++++-- scripts/share_push.py | 65 ++++-- share/README.md | 8 +- tests/test_chart_shot.py | 113 +++++++++ tests/test_html.py | 117 +++++++++- tests/test_memory.py | 98 ++++++++ tests/test_runtime_guard.py | 74 ++++++ tests/test_share.py | 94 +++++++- tests/test_shensha.py | 67 +++++- 32 files changed, 1468 insertions(+), 389 deletions(-) create mode 100644 scripts/_runtime_guard.py create mode 100644 scripts/chart/ganzhi.py create mode 100644 scripts/chart_shot.py create mode 100644 scripts/render/assets/lingyao-logo.png create mode 100644 scripts/render/assets/logo-font.ttf create mode 100644 scripts/render/assets/qun-qrcode.jpg create mode 100644 tests/test_chart_shot.py create mode 100644 tests/test_runtime_guard.py diff --git a/README.md b/README.md index 08f6a78..673fc8d 100644 --- a/README.md +++ b/README.md @@ -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/ 分层)与岁运神煞查询 diff --git a/SKILL.md b/SKILL.md index 0a67210..9f83800 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 文件路径并说明可直接打开查看。 +- **重排必重生**:出生信息有任何变动(校正时辰、节气边界确认、晚子时分歧对照等)重排时,必须重新生成盘面图与截图,不得沿用旧图。 +- 盘面内容:四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。 - HTML 只是 Markdown 命盘的图形化重渲染,内容以 stdout 命盘为准;解释时引用的字段名仍以 Markdown 章节为准。 ## 文风与反 AI 腔(强制) diff --git a/VERSION b/VERSION index d5c0c99..40c341b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.1 +3.6.0 diff --git a/manifest.json b/manifest.json index e8f1e54..d1f2a7e 100644 --- a/manifest.json +++ b/manifest.json @@ -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" } diff --git a/memory/README.md b/memory/README.md index 010d1b3..819314f 100644 --- a/memory/README.md +++ b/memory/README.md @@ -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/<四柱连写>-<性别>-.md`,其中 hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制, 用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」, -正文其余小节原样保留。 +正文其余小节原样保留。换盘(`--update-chart`)后四柱或 hash6 变动时, +文件改为新主键名(目标已存在则拒绝覆盖,报错退出 2)。 + +## 换盘纪律(工作盘唯一 + 变更留痕) + +经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,必须重排并把 +「## 盘面」段整体替换为新盘:档案任何时候只有一个工作盘,旧盘信息 +(原四柱、换盘原因、确认方式与时间)记入「反馈与澄清」校订条目,不留盘面段。 +凡受变动柱影响的已有推断条目(归纳、流年大事各行)同轮标记【待复核】, +复核前不得继续引用。 ## Schema diff --git a/pyproject.toml b/pyproject.toml index 9df629d..78a889b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性 diff --git a/rules/90_行运.md b/rules/90_行运.md index bb9fd39..526c2a3 100644 --- a/rules/90_行运.md +++ b/rules/90_行运.md @@ -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 步那次提问,不单独多问一轮。 diff --git a/rules/README.md b/rules/README.md index ca960a8..bdefce3 100644 --- a/rules/README.md +++ b/rules/README.md @@ -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 文件不变)。 diff --git a/scripts/_runtime_guard.py b/scripts/_runtime_guard.py new file mode 100644 index 0000000..c510fdc --- /dev/null +++ b/scripts/_runtime_guard.py @@ -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) diff --git a/scripts/astro/day_boundary.py b/scripts/astro/day_boundary.py index 6e751f6..ac4a8db 100644 --- a/scripts/astro/day_boundary.py +++ b/scripts/astro/day_boundary.py @@ -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"), } diff --git a/scripts/cast_chart.py b/scripts/cast_chart.py index b28596c..10fb2ae 100644 --- a/scripts/cast_chart.py +++ b/scripts/cast_chart.py @@ -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) diff --git a/scripts/chart/ganzhi.py b/scripts/chart/ganzhi.py new file mode 100644 index 0000000..c264be6 --- /dev/null +++ b/scripts/chart/ganzhi.py @@ -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)} diff --git a/scripts/chart/shensha.py b/scripts/chart/shensha.py index d1081d9..6f411e3 100644 --- a/scripts/chart/shensha.py +++ b/scripts/chart/shensha.py @@ -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 = [ "天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人", + "三奇贵人", "文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星", "劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿", "天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败", diff --git a/scripts/chart_shot.py b/scripts/chart_shot.py new file mode 100644 index 0000000..6d7e688 --- /dev/null +++ b/scripts/chart_shot.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""HTML 盘面 → PNG 截图。 + +用法: + python chart_shot.py [-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()) diff --git a/scripts/memory_lookup.py b/scripts/memory_lookup.py index 37e7365..fa37de8 100644 --- a/scripts/memory_lookup.py +++ b/scripts/memory_lookup.py @@ -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 diff --git a/scripts/memory_save.py b/scripts/memory_save.py index d08e60c..fdc7940 100644 --- a/scripts/memory_save.py +++ b/scripts/memory_save.py @@ -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(): diff --git a/scripts/query_shensha.py b/scripts/query_shensha.py index 3dcefab..3c8b1ad 100644 --- a/scripts/query_shensha.py +++ b/scripts/query_shensha.py @@ -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( diff --git a/scripts/render/assets/lingyao-logo.png b/scripts/render/assets/lingyao-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fbff1945cb543710306a8db251f983c9ff1f71c6 GIT binary patch literal 30247 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

Oh$CBh(u=QL!^9l%`_QmSVJI3McKy8+ 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�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 xVdF%UR2(a@?gA`v2y^?%tK|p-CMOgwgW$gw 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 ot}-*A7zIqK05IqP!zX1n-G_kIbr7Hm3B0)B!0 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 = '当前' if c["cur"] else "" return f'{c["label"]}{tag}' - def row(label, fn, cls=""): + def row(label, fn): tds = "".join( f'{fn(c)}' for c in cols) return f'{label}{tds}' @@ -178,6 +99,34 @@ def _main_table(chart) -> str: return '' + "".join(rows) + "
" +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'

' 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'
{lp.start_year} · {lp.start_age:.0f} 岁起
' f'
{lp.gan}' f'{lp.zhi}
' - f'
{_ten_god(chart.day_master, lp.gan)}' - f' · {_terrain(chart.day_master, lp.zhi)}
') + f'{_gz_sub(chart, gz)}') return '
' + "".join(cells) + "
" @@ -218,7 +166,8 @@ def _flow_years(chart) -> str: cls += " jiao" cells.append(f'
{year}
' f'
{gz[0]}' - f'{gz[1]}
') + f'{gz[1]}' + f'{_gz_sub(chart, gz)}') return '
' + "".join(cells) + "
" @@ -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(): diff --git a/scripts/render/templates/paipan.html b/scripts/render/templates/paipan.html index 1bacc43..545d882 100644 --- a/scripts/render/templates/paipan.html +++ b/scripts/render/templates/paipan.html @@ -5,24 +5,69 @@ {{TITLE}}
-
-
- {{NAME}} - {{GENDER_LABEL}} - 日主 {{DAY_MASTER}} +
+ +
+
灵爻妙解
+
传承易学智慧
-
- 出生地:{{BIRTHPLACE}} 钟表时间:{{BIRTH_CLOCK}} 真太阳时:{{TRUE_SOLAR}}
- 月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}} +
+ +
+ +
+
+ {{NAME}} + {{GENDER_LABEL}} + 日主 {{DAY_MASTER}} +
+
+ 出生地:{{BIRTHPLACE}} 钟表时间:{{BIRTH_CLOCK}} 真太阳时:{{TRUE_SOLAR}}
+ 月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}} +
+ {{WARNINGS}} +
+ {{SEASON_BAR}}
+ {{AUDIT}} +
+
- {{WARNINGS}} + +
+
排盘四柱 · 藏干 · 空亡 · 纳音 · 神煞
+ {{MAIN_TABLE}} +
+ +
+
大运{{LUCK_NOTE}}
+ {{LUCK_STRIP}} + {{CHILDHOOD_BLOCK}} +
+ +
+
流年出生年至今后十年,今年高亮,红字年份为交运年
+ {{FLOW_YEARS}} +
+ +
+
流月本年节气月
+ {{FLOW_MONTHS}} +
+ +
+ 微信群二维码 +
+
灵爻妙解 · 传统文化交流群
+
扫码加入微信群:反馈解读偏差、交流文化心得、参与档案共建,版本更新动态第一时间同步。
+
+
+
-
-
排盘四柱 · 藏干 · 空亡 · 纳音 · 神煞(当前大运与流年并列对照)
- {{MAIN_TABLE}} -
- -
-
大运{{LUCK_NOTE}}
- {{LUCK_STRIP}} - {{CHILDHOOD_BLOCK}} -
- -
-
流年出生年至今后十年,今年高亮,红字年份为交运年
- {{FLOW_YEARS}} -
- -
-
流月本年节气月
- {{FLOW_MONTHS}} -
- -
- {{SEASON_BAR}}
- {{AUDIT}} -
灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考
-
+
灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考
diff --git a/scripts/self_update.py b/scripts/self_update.py index 8e66ea5..b71c02a 100644 --- a/scripts/self_update.py +++ b/scripts/self_update.py @@ -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" diff --git a/scripts/share_export.py b/scripts/share_export.py index a82b40e..a709cc0 100644 --- a/scripts/share_export.py +++ b/scripts/share_export.py @@ -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) diff --git a/scripts/share_push.py b/scripts/share_push.py index 3587ffb..fb1ca06 100644 --- a/scripts/share_push.py +++ b/scripts/share_push.py @@ -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)) diff --git a/share/README.md b/share/README.md index 2ccd199..d5d82ee 100644 --- a/share/README.md +++ b/share/README.md @@ -15,11 +15,11 @@ | 项目 | 处理 | |---|---| -| 姓名 | 一律删除,不入包 | +| 姓名 | 一律删除,不入包(事件简介中出现的姓名替换为「命主」) | | 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 | -| 地域 | 只保留到省级,省以下(市、县、区县)一律删除 | -| 事件 | 一律类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他),不写细节 | -| 正文自由文本 | 不入包(归纳、盘面细节、反馈原文均不导出) | +| 地域 | 保留到城市级(省+地级市),区县及以下一律删除 | +| 事件 | 类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他)+ 事件简介(note);简介中**城市级地名保留**(规则蒸馏要用方位信息),详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;书写事件简介时只写到城市级 | +| 正文自由文本 | 归纳、盘面细节不入包;入包的事件简介按上行规格脱敏 | | 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 | ## 用途与许可 diff --git a/tests/test_chart_shot.py b/tests/test_chart_shot.py new file mode 100644 index 0000000..098c4c9 --- /dev/null +++ b/tests/test_chart_shot.py @@ -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 = "

命盘

" + + +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( + '
' + '
' * 5000 + + "
", 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() diff --git a/tests/test_html.py b/tests/test_html.py index 41ffef8..8e98764 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -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"", 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("排盘大运')] + 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): diff --git a/tests/test_memory.py b/tests/test_memory.py index 9070918..dd7b125 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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 diff --git a/tests/test_runtime_guard.py b/tests/test_runtime_guard.py new file mode 100644 index 0000000..c020930 --- /dev/null +++ b/tests/test_runtime_guard.py @@ -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" diff --git a/tests/test_share.py b/tests/test_share.py index 3b824e0..a08c633 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -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" diff --git a/tests/test_shensha.py b/tests/test_shensha.py index a3ff4a0..1a3d99b 100644 --- a/tests/test_shensha.py +++ b/tests/test_shensha.py @@ -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()