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 0000000..fbff194 Binary files /dev/null and b/scripts/render/assets/lingyao-logo.png differ diff --git a/scripts/render/assets/logo-font.ttf b/scripts/render/assets/logo-font.ttf new file mode 100644 index 0000000..aa56e1b Binary files /dev/null and b/scripts/render/assets/logo-font.ttf differ diff --git a/scripts/render/assets/qun-qrcode.jpg b/scripts/render/assets/qun-qrcode.jpg new file mode 100644 index 0000000..17c3aa1 Binary files /dev/null and b/scripts/render/assets/qun-qrcode.jpg differ diff --git a/scripts/render/html.py b/scripts/render/html.py index ebff5c9..16ce90e 100644 --- a/scripts/render/html.py +++ b/scripts/render/html.py @@ -2,52 +2,24 @@ """ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。 与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰 -均为固定查表,不含任何命理判断(P1)。 +均为固定查表,不含任何命理判断(P1)。岁运干支(大运/流年格)的十神/ +星运/空亡/纳音复用 chart/ganzhi.py,神煞复用 chart/shensha.py 的岁运 +求值器 evaluate_target(与 query_shensha.py 同一口径),不在渲染层 +另造规则。 + +logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文件便携; +神煞条目的查法注释(全角/半角括号)仅在本装配层剔除,Markdown 输出保持原样。 """ +import base64 +import re from datetime import datetime from pathlib import Path -from chart.luck import luck_label_for_year +from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain +from chart.shensha import evaluate_target -GAN = "甲乙丙丁戊己庚辛壬癸" -ZHI = "子丑寅卯辰巳午未申酉戌亥" - -_WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", - "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"} -_WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", - "巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金", - "戌": "土", "亥": "水"} -_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1, - "乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1} -_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} -_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"} - -_HIDDEN = {"子": ["癸"], "丑": ["己", "癸", "辛"], "寅": ["甲", "丙", "戊"], - "卯": ["乙"], "辰": ["戊", "乙", "癸"], "巳": ["丙", "戊", "庚"], - "午": ["丁", "己"], "未": ["己", "丁", "乙"], - "申": ["庚", "壬", "戊"], "酉": ["辛"], "戌": ["戊", "辛", "丁"], - "亥": ["壬", "甲"]} - -_NAYIN = {"甲子": "海中金", "乙丑": "海中金", "丙寅": "炉中火", "丁卯": "炉中火", - "戊辰": "大林木", "己巳": "大林木", "庚午": "路旁土", "辛未": "路旁土", - "壬申": "剑锋金", "癸酉": "剑锋金", "甲戌": "山头火", "乙亥": "山头火", - "丙子": "涧下水", "丁丑": "涧下水", "戊寅": "城头土", "己卯": "城头土", - "庚辰": "白蜡金", "辛巳": "白蜡金", "壬午": "杨柳木", "癸未": "杨柳木", - "甲申": "泉中水", "乙酉": "泉中水", "丙戌": "屋上土", "丁亥": "屋上土", - "戊子": "霹雳火", "己丑": "霹雳火", "庚寅": "松柏木", "辛卯": "松柏木", - "壬辰": "长流水", "癸巳": "长流水", "甲午": "沙中金", "乙未": "沙中金", - "丙申": "山下火", "丁酉": "山下火", "戊戌": "平地木", "己亥": "平地木", - "庚子": "壁上土", "辛丑": "壁上土", "壬寅": "金箔金", "癸卯": "金箔金", - "甲辰": "覆灯火", "乙巳": "覆灯火", "丙午": "天河水", "丁未": "天河水", - "戊申": "大驿土", "己酉": "大驿土", "庚戌": "钗钏金", "辛亥": "钗钏金", - "壬子": "桑柘木", "癸丑": "桑柘木", "甲寅": "大溪水", "乙卯": "大溪水", - "丙辰": "沙中土", "丁巳": "沙中土", "戊午": "天上火", "己未": "天上火", - "庚申": "石榴木", "辛酉": "石榴木", "壬戌": "大海水", "癸亥": "大海水"} - -_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰", - "病", "死", "墓", "绝", "胎", "养"] -_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳", - "壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"} +_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu", + "金": "wx-jin", "水": "wx-shui"} _SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"), "巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"), @@ -57,43 +29,22 @@ _SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", " "丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")} _SEASON_LABEL = ("旺", "相", "休", "囚", "死") +_ASSETS_DIR = Path(__file__).resolve().parent / "assets" +_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]") + + +def _strip_sha_note(entry: str) -> str: + """神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。""" + return _SHA_NOTE_RE.sub("", entry).strip() + + +def _asset_b64(name: str) -> str: + return base64.b64encode((_ASSETS_DIR / name).read_bytes()).decode("ascii") + def _wx(ch: str) -> str: - wx = _WX_GAN.get(ch) or _WX_ZHI.get(ch) - return {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu", - "金": "wx-jin", "水": "wx-shui"}.get(wx, "") - - -def _ten_god(me: str, other: str) -> str: - wx_m, wx_o = _WX_GAN[me], _WX_GAN[other] - same = _YY[me] == _YY[other] - if wx_m == wx_o: - return "比肩" if same else "劫财" - if _SHENG[wx_m] == wx_o: - return "食神" if same else "伤官" - if _KE[wx_m] == wx_o: - return "偏财" if same else "正财" - if _KE[wx_o] == wx_m: - return "七杀" if same else "正官" - return "偏印" if same else "正印" - - -def _terrain(me: str, zhi: str) -> str: - """日干对支的十二长生(阳顺阴逆)。""" - start = _ZHI_IDX(_TERRAIN_START[me]) - zi = _ZHI_IDX(zhi) - off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12 - return _TERRAIN_ORDER[off] - - -def _ZHI_IDX(z: str) -> int: - return ZHI.index(z) - - -def _gz_void(gz: str) -> str: - """一柱干支的旬空两支。""" - g, z = GAN.index(gz[0]), ZHI.index(gz[1]) - return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12] + wx = WX_GAN.get(ch) or WX_ZHI.get(ch) + return _WX_CLASS.get(wx, "") def _z(zhi: str, voids: str) -> str: @@ -111,51 +62,21 @@ def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict: "gan": p.gan, "zhi": p.zhi, "hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)), "terrain": chart.terrain.get(key, ""), - "void": _gz_void(p.gan + p.zhi), + "void": gz_void(p.gan + p.zhi), "nayin": p.na_yin or "", - "shensha": chart.shen_sha.get(key, []), + "shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])], "voids": voids, } -def _extra_col(chart, gz: str, label: str, cur: bool) -> dict: - """流年/大运列(仅干支行星级信息,神煞留空)。""" - me = chart.day_master - voids = chart.void.get("day", "") + chart.void.get("hour", "") - return { - "label": label, "cur": cur, - "god": _ten_god(me, gz[0]), - "gan": gz[0], "zhi": gz[1], - "hidden": [(h, _ten_god(me, h)) for h in _HIDDEN[gz[1]]], - "terrain": _terrain(me, gz[1]), - "void": _gz_void(gz), - "nayin": _NAYIN[gz], - "shensha": [], - "voids": voids, - } - - -def _main_table(chart) -> str: - this_year = datetime.now().year - ly = next(((y, gz) for y, gz, _ in chart.flow_years if y == this_year), None) - cur_luck = None - for lp in chart.luck_pillars: - if lp.start_year <= this_year: - cur_luck = lp - cols = [] - if ly: - cols.append(_extra_col(chart, ly[1], f"流年 {ly[0]}", cur=True)) - if cur_luck: - cols.append(_extra_col(chart, cur_luck.gan + cur_luck.zhi, "大运", cur=True)) - for key, label in (("year", "年柱"), ("month", "月柱"), - ("day", "日柱"), ("hour", "时柱")): - cols.append(_pillar_col(chart, key, label, cur=(key == "day"))) +def _render_table(cols: list) -> str: + """干支列列表 → 排盘表格(主星/天干/地支/藏干/星运/空亡/纳音/神煞)。""" def th(c): tag = '当前' 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'
{line}
' 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()