dsh-metaphysics 0.59.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/handle.js CHANGED
@@ -23,7 +23,7 @@ const PREFIX = 'mc1.';
23
23
  * 反过来,seed / asOf / direction / 各流派开关都必须留下——少了它们就复现不出同一张盘。
24
24
  */
25
25
  const SKIP = new Set([
26
- 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'cross',
26
+ 'handle', 'trace', 'shadow', 'shadowVersion', 'diff', 'withInterpret', 'detail', 'cross',
27
27
  'verifyPast', 'rectify', 'events', 'ask', 'followup', 'question', 'geocode', 'caliber',
28
28
  // 一句话改参是把原话翻成补丁:补丁已经落在其余参数里,原话本身不进句柄,
29
29
  // 否则同一张盘换个说法就换一个句柄,回放时还会再套一遍补丁。
@@ -171,6 +171,9 @@ export function decodeHandle(handle) {
171
171
  return payload;
172
172
  }
173
173
 
174
+ /** 不能作为增量键的名字:`out['__proto__'] = v` 走的是 setter,会把 out 的原型换掉 */
175
+ const KEY_BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
176
+
174
177
  /**
175
178
  * 把 handle 与增量参数并起来。没有 handle 时原样返回(深一层的浅拷贝,避免改调用方对象)。
176
179
  * 增量优先:{ handle, asOf: 2030 } 只覆盖 asOf,其余沿用原参数。
@@ -183,7 +186,8 @@ export function resolveHandle(args = {}) {
183
186
  // 显式的 undefined 当作「没传」:否则 { handle, datetime: undefined } 会把句柄里的日期抹掉
184
187
  const out = { ...base };
185
188
  for (const [k, v] of Object.entries(rest)) {
186
- if (v !== undefined) out[k] = v;
189
+ if (v === undefined || KEY_BLOCKED.has(k)) continue;
190
+ out[k] = v;
187
191
  }
188
192
  return out;
189
193
  }
package/engine/index.js CHANGED
@@ -76,13 +76,17 @@ function ziweiSchoolArgs(args) {
76
76
  return out;
77
77
  }
78
78
 
79
- /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD' */
79
+ /** 解析 'YYYY-MM-DDTHH:mm' / 'YYYY-MM-DD HH:mm' / 'YYYY-MM-DD'
80
+ * 可以带秒(用户常直接粘 ISO 串),但引擎的民用时间口径到分钟:秒只校验、不参与换算。 */
80
81
  export function parseDateTime(text) {
81
82
  if (typeof text !== 'string' || !text.trim()) {
82
83
  throw fail('E_INPUT', '缺少 datetime(公历日期时间),例如 1990-05-12T14:30');
83
84
  }
84
85
  const m = text.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
85
86
  if (!m) throw fail('E_INPUT', `无法解析 datetime:「${text}」,请用 1990-05-12T14:30 这种格式`);
87
+ // 秒位以前只捕获不校验:\d{1,2} 放过 0–99,于是「T03:04:99」被静默折成 03:04。
88
+ // 与 1990-02-30 同一类问题——非法输入悄悄变成一个合法时刻,盘面照样排出,没人知道。
89
+ if (m[6] !== undefined && Number(m[6]) > 59) throw fail('E_INPUT', `秒超出范围:${m[6]}`);
86
90
  const out = {
87
91
  year: Number(m[1]), month: Number(m[2]), day: Number(m[3]),
88
92
  hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0),
@@ -114,8 +118,11 @@ function baziText(r) {
114
118
  + ` 地势 ${p.dishi} 自坐 ${p.zizuo} 空亡 ${p.kongwang.join('')}`
115
119
  + (p.shensha && p.shensha.length ? ` 神煞 ${p.shensha.join('、')}` : '');
116
120
  const wuxing = Object.entries(r.wuxing).map(([k, v]) => `${k}${v}`).join(' ');
121
+ // 命宫/身宫由「生月 + 生时支」定:时柱未知时它们是 null,不能拼字符串,也不能拿占位值顶上
117
122
  const palaces = r.palaces
118
- ? `【胎元】${r.palaces.taiyuan.ganzhi} 胎息 ${r.palaces.taixi ? r.palaces.taixi.ganzhi : '—'} 命宫 ${r.palaces.minggong.ganzhi} 身宫 ${r.palaces.shengong.ganzhi}`
123
+ ? `【胎元】${r.palaces.taiyuan.ganzhi} 胎息 ${r.palaces.taixi ? r.palaces.taixi.ganzhi : '—'}`
124
+ + (r.palaces.minggong ? ` 命宫 ${r.palaces.minggong.ganzhi}` : ' 命宫 时柱暂缺,不排')
125
+ + (r.palaces.shengong ? ` 身宫 ${r.palaces.shengong.ganzhi}` : ' 身宫 时柱暂缺,不排')
119
126
  : '';
120
127
  const dayun = r.dayun.list.slice(0, 8)
121
128
  .map((d) => `${d.startAge}岁 ${d.ganzhi}(${d.stemTenGod})${d.luck ? `·${d.luck.tier}` : ''}${d.relations && d.relations.length ? `[${d.relations.join('')}]` : ''}`).join(' ');
@@ -155,10 +162,12 @@ function baziText(r) {
155
162
  (r.frame.yongshen.qt ? ` 穷通:${r.frame.yongshen.qt.month}${r.frame.yongshen.qt.stem}取${r.frame.yongshen.qt.yong.join('、')}(首取${r.frame.yongshen.qt.primary})` : ''),
156
163
  `十神权重:${Object.entries(r.frame.godStats.groups).map(([k, v]) => `${k}${v}`).join(' ')}`,
157
164
  ] : [];
165
+ const hourMissing = r.hourUnknown === true || !P.hour;
158
166
  return [
159
- `【四柱】${P.year.ganzhi} ${P.month.ganzhi} ${P.day.ganzhi} ${P.hour.ganzhi}`,
167
+ `【四柱】${P.year.ganzhi} ${P.month.ganzhi} ${P.day.ganzhi}${P.hour ? ` ${P.hour.ganzhi}` : '(时柱暂缺)'}`,
160
168
  `日主:${r.dayMaster.stem}${r.dayMaster.element}(${r.dayMaster.yinYang}) 空亡:${r.kongwang.join('')}`,
161
- line('年柱', P.year), line('月柱', P.month), line('日柱', P.day), line('时柱', P.hour),
169
+ line('年柱', P.year), line('月柱', P.month), line('日柱', P.day),
170
+ ...(hourMissing ? [] : [line('时柱', P.hour)]),
162
171
  `五行:${wuxing}`,
163
172
  ...(palaces ? [palaces] : []),
164
173
  `日主强弱:${r.strength.level}(${r.strength.score}) ${r.strength.detail}`,
@@ -512,7 +521,7 @@ function jsonSafe(value) {
512
521
  // ── 影子盘:同一组归一化参数 + 同一版本 → 同一个短 ID ──────────────
513
522
  // 只收影响盘面的参数:地址换成有效经度后不再把原文算进去,问事/方向按体系保留。
514
523
  const SHADOW_IGNORE = new Set([
515
- 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'cross', 'verifyPast', 'rectify', 'events',
524
+ 'shadow', 'shadowVersion', 'diff', 'trace', 'traceError', 'withInterpret', 'detail', 'cross', 'verifyPast', 'rectify', 'events',
516
525
  'geocode', 'ask', 'followup', 'caliber', 'seed', 'asOf', 'handle', 'revise',
517
526
  ]);
518
527
 
@@ -751,7 +760,23 @@ export function compute(input = {}) {
751
760
  args = { ...args };
752
761
  delete args.partner2;
753
762
  }
763
+ // 经度整形 + 守卫(非有限 / 超范围 → E_RANGE;数字串 → 数字)
764
+ args = normalizeLongitudes(args);
754
765
  const r = jsonSafe(computeRaw(args));
766
+ // 「所问之事」以前只有六爻和塔罗会落到结果里,梅花/六壬/奇门/小六壬都丢掉了:
767
+ // 于是第一屏想说「你问的是……」却拿不到那句话。这里统一补齐,已有的值不动。
768
+ if (r && !r.question && typeof args.question === 'string' && args.question.trim()
769
+ && ['liuyao', 'meihua', 'daliuren', 'qimen', 'tarot', 'xiaoliuren'].includes(r.system)) {
770
+ r.question = args.question.trim();
771
+ }
772
+ // 顶部那段「人话总结」:命盘两类 + 问事五类 + 合盘 + 走势,都由 plainSummary 决定给不给。
773
+ // 数据不够时它返回 null,不动 r.brief;生成失败只记 briefError,绝不因此拖垮排盘。
774
+ if (r) {
775
+ try {
776
+ const brief = plainSummary(r);
777
+ if (brief) r.brief = brief;
778
+ } catch (e) { r.briefError = String((e && e.message) || e); }
779
+ }
755
780
  if (revision) r.revision = revision;
756
781
  if (r && r.system === 'bazi') {
757
782
  r.classics = classicsFor(r);
@@ -774,15 +799,32 @@ export function compute(input = {}) {
774
799
  if (r && (r.system === 'bazi' || r.system === 'ziwei') && r.input && r.input.longitude == null && r.summary) {
775
800
  r.summary = `⚠ 未提供出生地或经度:按东八区平太阳时排盘,时柱可能与本命盘相差一个时辰;海外出生请先把当地时间换算成东八区标准时。\n${r.summary}`;
776
801
  }
802
+ // 时辰未知:固定提示必须在第一行。放在所有警告之后 prepend,才不会被地警告/精度提示挤到第二行
803
+ if (r && r.system === 'bazi' && r.hourUnknown && r.summary) {
804
+ r.summary = `时柱按正午暂算,涉及时柱的结论仅供参考
805
+ ${r.summary}`;
806
+ }
807
+ // 趋势是降级而不是阻断:起运按日估算,也要在首行说清
808
+ if (r && r.system === 'trend' && r.hourUnknown && r.summary) {
809
+ r.summary = `时柱暂缺:起运与大运按日估算,仅供参考
810
+ ${r.summary}`;
811
+ }
777
812
  // 显式要求时才附加解析模块,避免默认输出过长;「所问之事」与「询问方向」一并传进去
813
+ const tier = detailOf(args.detail);
778
814
  if (args.withInterpret) {
779
815
  r.interpret = interpret(r, {
780
816
  question: typeof args.question === 'string' ? args.question : '',
781
817
  direction: typeof args.direction === 'string' ? args.direction : 'general',
782
818
  });
819
+ // 档位只改「回给模型的文本」,不改解析对象:面板拿到的仍是完整的 interpret。
820
+ // 键名不用 detail / tier:合盘结果里 r.detail 是详批对象、流年行里 r.tier 是档位,都会撞。
821
+ if (tier === 'lean') r.interpretTier = 'lean';
783
822
  }
784
823
  // 八字 × 紫微交叉验证:同一份出生信息再排另一套盘,并列两边对同一件事的说法
785
- if (args.cross === true && (r.system === 'bazi' || r.system === 'ziwei')) {
824
+ // 交叉验证要再排另一套盘:时辰未知时紫微排不出来,这条支路直接跳过,
825
+ // 不能拿正午排一张紫微盘来「交叉」,那是两张都不可信的盘互证。
826
+ if (args.cross === true && args.hourUnknown === true) r.crossSkipped = 'HOUR_UNKNOWN';
827
+ if (args.cross === true && args.hourUnknown !== true && (r.system === 'bazi' || r.system === 'ziwei')) {
786
828
  const cdt = parseDateTime(args.datetime);
787
829
  const common = {
788
830
  ...cdt,
@@ -872,9 +914,13 @@ export function compute(input = {}) {
872
914
  return r;
873
915
  }
874
916
 
875
- /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON) */
876
- export function interpretText(i) {
917
+ /** 解析与详解的紧凑文本(给模型看,不塞整块 JSON)
918
+ * detail:'full'(默认,与历史输出逐字一致)或 'lean'(只给结论、你问的那条与三张表,
919
+ * 长论述与「规则·出处」折成一行指针)。省的是回给模型的文本,不改盘面、不改解析对象本身。 */
920
+ export function interpretText(i, detail) {
877
921
  if (!i) return '';
922
+ const lean = detail === 'lean';
923
+ const LONG_SECTION = 300;
878
924
  const out = [`【${i.title || '解析与详解'}】${i.ask && i.ask.echo ? i.ask.echo : ''}`];
879
925
  // 直答放在最前:先回答「你问的事」,再说依据,避免读者读到最后才知道结论;
880
926
  // 没填问题时就不该喊「答所问」——改为「先看结论」,否则是一句假话
@@ -887,16 +933,27 @@ export function interpretText(i) {
887
933
  }
888
934
  // 解析生成失败时不能只返回一个空壳:盘面数据仍然可用,但要让调用方知道"这段没生成出来"
889
935
  if (i.error) out.push(`⚠ 解析生成失败:${i.error}(上方盘面数据本身可用)`);
890
- for (const d of i.dimensions || []) {
936
+ const allDims = i.dimensions || [];
937
+ // 问了方向就只给那一条:其余四条在 full 档里,模型要展开随时可以再排一次
938
+ const dims = lean && allDims.some((d) => d && d.focused) ? allDims.filter((d) => d && d.focused) : allDims;
939
+ for (const d of dims) {
891
940
  const evidence = Array.isArray(d.evidence) && d.evidence.length ? `\n 依据:${d.evidence.join(';')}${d.confidence ? ` 置信度:${d.confidence}` : ''}` : '';
892
941
  const plain = d.plain ? `(${d.plain})` : '';
893
942
  // 知识条目:「为什么这样断」+出处+可信度标注,是结论与依据之外的第三层
894
- const know = (d.knowledge || [])
895
- .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
896
- .join('');
943
+ const know = lean
944
+ ? ((d.knowledge || []).length ? `\n 规则 ${d.knowledge.length} 条未展开(detail:"full" 给出原文与出处)` : '')
945
+ : (d.knowledge || [])
946
+ .map((k) => `\n 规则·${k.gist}〔${k.statusLabel}〕${k.detail} 出处:${k.source}${k.note ? ` 注:${k.note}` : ''}`)
947
+ .join('');
897
948
  out.push(`${d.label}:${plain}${d.text}${d.advice ? ` 建议:${d.advice}` : ''}${evidence}${know}`);
898
949
  }
899
- for (const s of i.sections || []) out.push(`${s.label}:${s.text}`);
950
+ const secs = i.sections || [];
951
+ const longSecs = lean ? secs.filter((s) => String(s.text || '').length > LONG_SECTION) : [];
952
+ for (const s of secs) {
953
+ if (lean && String(s.text || '').length > LONG_SECTION) continue;
954
+ out.push(`${s.label}:${s.text}`);
955
+ }
956
+ if (longSecs.length) out.push(`(另有 ${longSecs.length} 节长论述未展开:${longSecs.map((s) => s.label).join('、')}。需要原文与出处时传 detail:"full")`);
900
957
  // 三张表:大运 / 流年 / 触发事件(纯文本对齐,工具输出也能直接读)
901
958
  for (const t of i.tables || []) {
902
959
  if (!Array.isArray(t.rows) || !t.rows.length) continue;
@@ -920,7 +977,7 @@ export function interpretText(i) {
920
977
 
921
978
  /** 对照时不参与计算的参数:它们只影响解析/附加模块,不影响盘面 */
922
979
  // shadow / trace 是结果的派生展示,不是盘面口径;带进来会让影子盘回显把每组开关都算成「有变化」
923
- const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
980
+ const CALIBER_IGNORE_ARGS = ['cross', 'verifyPast', 'withInterpret', 'detail', 'question', 'direction', 'ask', 'geocode', 'rectify', 'events', 'shadow', 'trace'];
924
981
 
925
982
  /**
926
983
  * 口径对照:同一组输入,只把某一个口径开关换成它的另一档,逐字段列出变化。
@@ -988,15 +1045,62 @@ const JU_METHODS = ['chaibu', 'chaoshen', 'zhirun'];
988
1045
  const TAROT_COUNTS = [1, 3, 5];
989
1046
  const LATE_ZI_MODES = ['same-day', 'next-day'];
990
1047
 
1048
+ /**
1049
+ * 经度守卫:入口只判 `typeof === 'number'`,于是 JSON 里的 1e999(= Infinity)
1050
+ * 会一路走到真太阳时里炸成裸 TypeError,181 这类超范围值则静默排出另一张盘。
1051
+ * 两者都比报错坏:一个把堆栈式文案交给模型,一个把错结果当成对结果说出去。
1052
+ */
1053
+ function checkLongitude(value, who = '') {
1054
+ if (value === undefined || value === null || value === '') return undefined;
1055
+ const n = Number(value);
1056
+ const tag = who ? `${who}的` : '';
1057
+ if (!Number.isFinite(n)) throw fail('E_RANGE', `${tag}经度不是有限数值:收到「${value}」。经度写十进制数字即可,例如北京 116.4`);
1058
+ if (n < -180 || n > 180) throw fail('E_RANGE', `${tag}经度要在 −180 到 180 之间(东经为正),收到 ${n}`);
1059
+ return n;
1060
+ }
1061
+
1062
+ /**
1063
+ * 把主方 / 对方 / 第三方的经度统一成数字。
1064
+ * 下游按 `typeof === 'number'` 取值,所以 "86.0"(模型常给带引号的数)会被静默丢掉,
1065
+ * 少一次真太阳时修正而结果里毫无痕迹——这里先整形,再交给校验过的值。
1066
+ */
1067
+ function normalizeLongitudes(args) {
1068
+ const self = checkLongitude(args.longitude, '');
1069
+ const p = args.partner && typeof args.partner === 'object' ? checkLongitude(args.partner.longitude, '对方') : undefined;
1070
+ const p2 = args.partner2 && typeof args.partner2 === 'object' ? checkLongitude(args.partner2.longitude, '第三方') : undefined;
1071
+ const dirty = (self !== undefined && args.longitude !== self)
1072
+ || (p !== undefined && args.partner.longitude !== p)
1073
+ || (p2 !== undefined && args.partner2.longitude !== p2);
1074
+ if (!dirty) return args;
1075
+ const out = { ...args };
1076
+ if (self !== undefined) out.longitude = self;
1077
+ if (p !== undefined) out.partner = { ...out.partner, longitude: p };
1078
+ if (p2 !== undefined) out.partner2 = { ...out.partner2, longitude: p2 };
1079
+ return out;
1080
+ }
1081
+
1082
+ /** 输出档位:缺省与 'full' 都是历史全文;'lean' 只回结论与你问的那一条,省模型上下文。
1083
+ * 其它值以前会被当成「缺省」静默放过,写了 detail:'brief' 的人以为自己省了钱,其实没有。 */
1084
+ function detailOf(value) {
1085
+ if (value === undefined || value === null || value === '' || value === 'full') return 'full';
1086
+ if (value === 'lean') return 'lean';
1087
+ throw fail('E_INPUT', `detail 只能是 'full' 或 'lean':收到「${String(value).slice(0, 24)}」`);
1088
+ }
1089
+
991
1090
  /** asOf 只接受能解析成真实日期的字符串;非法值以前会静默回退到「此刻」 */
992
1091
  function checkAsOf(asOf) {
993
1092
  if (asOf === undefined || asOf === null || asOf === '') return undefined;
994
1093
  const parts = asOfParts(asOf);
995
1094
  if (!parts) throw fail('E_ASOF', `无法解析 asOf:「${asOf}」,请用 2026-10-22T16:00 这种格式`);
996
- const { year, month, day } = parts;
1095
+ const { year, month, day, hour, minute } = parts;
997
1096
  if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= daysInMonth(year, month))) {
998
1097
  throw fail('E_ASOF', `asOf 指向的日期不存在:${asOf}`);
999
1098
  }
1099
+ // 时分以前不校验:asOf: '2026-06-01T25:99' 会被接受并原样回显,
1100
+ // 而小时又按 mod 24 折回当日子时——流日/流时静默错位,比报错更坏。
1101
+ // 与 parseDateTime 同一把尺:给了就必须在范围内,缺省才由下游补默认值。
1102
+ if (hour !== undefined && !(hour >= 0 && hour <= 23)) throw fail('E_ASOF', `asOf 的小时要在 0–23 之间:${asOf}`);
1103
+ if (minute !== undefined && !(minute >= 0 && minute <= 59)) throw fail('E_ASOF', `asOf 的分钟要在 0–59 之间:${asOf}`);
1000
1104
  return parts;
1001
1105
  }
1002
1106
 
@@ -1016,10 +1120,16 @@ function computeRaw(args = {}) {
1016
1120
  dst: args.dst !== false,
1017
1121
  // 流年表默认从「今年」起排;要复现历史结论就把基准年钉死
1018
1122
  nowYear: asOf ? asOf.year : undefined,
1123
+ // 时辰未知:三柱模式(时柱不排、精度降到日、摘要首行固定提示)
1124
+ hourUnknown: args.hourUnknown === true,
1019
1125
  });
1020
1126
  return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: baziText(result) };
1021
1127
  }
1022
1128
  if (system === 'ziwei') {
1129
+ // 命宫由「生月 + 生时支」定:时支未知则整盘无处落脚,没有「大概的紫微盘」这种东西
1130
+ if (args.hourUnknown === true) {
1131
+ throw fail('HOUR_UNKNOWN', '紫微必须有出生时刻:命宫由生月与生时支定,时支未知则整盘不成立。请补上出生时刻,或改用八字三柱模式只看年/月/日。');
1132
+ }
1023
1133
  const dt = parseDateTime(args.datetime);
1024
1134
  const asOf = checkAsOf(args.asOf);
1025
1135
  const result = buildZiwei({
@@ -1036,7 +1146,7 @@ function computeRaw(args = {}) {
1036
1146
  // 流月/流日/流时的基准时刻:默认本机此刻,可用 asOf 指定以便复现
1037
1147
  asOf,
1038
1148
  });
1039
- return { ...result, precision: dt.precision, summary: ziweiText(result) };
1149
+ return { ...result, ...(asOf ? { asOf } : {}), precision: dt.precision, summary: ziweiText(result) };
1040
1150
  }
1041
1151
  if (system === 'tarot') {
1042
1152
  const count = args.count === undefined || args.count === null || args.count === '' ? 3 : Number(args.count);
@@ -1076,6 +1186,10 @@ function computeRaw(args = {}) {
1076
1186
  return { ...result, summary: qimenText(result) };
1077
1187
  }
1078
1188
  if (system === 'hepan') {
1189
+ // 双方夫妻宫、大运都依赖时支:任一方时辰未知就不出盘,免得「差不多」的合盘误导决定
1190
+ if (args.hourUnknown === true || (args.partner && args.partner.hourUnknown === true)) {
1191
+ throw fail('HOUR_UNKNOWN', '合盘需要双方都有出生时刻:夫妻宫与大运都依赖时支。请补齐时刻后再看,或用八字三柱模式各自看年/月/日。');
1192
+ }
1079
1193
  const partner = args.partner;
1080
1194
  if (!partner || typeof partner !== 'object') {
1081
1195
  throw fail('E_PARTNER', '合盘需要 partner:{ datetime, gender, address? },其中 partner.datetime 是对方的公历出生时间');
@@ -1288,8 +1402,12 @@ function computeRaw(args = {}) {
1288
1402
  }
1289
1403
  if (system === 'trend') {
1290
1404
  const dt = parseDateTime(args.datetime);
1405
+ // 「现在在第几步」要有个当前年份;引擎不读挂钟,只认 asOf。
1406
+ // 不给 asOf 就不标当前位置(summary 会少一句,但结果可复现)。
1407
+ const nowParts = args.asOf ? checkAsOf(args.asOf) : undefined;
1291
1408
  const result = buildTrend({
1292
1409
  ...dt,
1410
+ nowYear: nowParts ? nowParts.year : undefined,
1293
1411
  gender: requireGender(args.gender),
1294
1412
  toAge: args.toAge,
1295
1413
  lateZi: args.lateZi !== false,
@@ -1298,8 +1416,15 @@ function computeRaw(args = {}) {
1298
1416
  longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
1299
1417
  address: typeof args.address === 'string' ? args.address : undefined,
1300
1418
  geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
1419
+ hourUnknown: args.hourUnknown === true,
1301
1420
  });
1302
- return { ...result, precision: dt.precision, summary: trendText(result) };
1421
+ // 趋势没有独立的 dayun 字段(大运在 buildTrend 内部用),所以精度标记挂在结果顶层
1422
+ return {
1423
+ ...result,
1424
+ ...(args.hourUnknown === true ? { hourUnknown: true, hourPrecision: 'day' } : {}),
1425
+ precision: dt.precision,
1426
+ summary: trendText(result),
1427
+ };
1303
1428
  }
1304
1429
  if (system === 'fengshui') {
1305
1430
  const action = args.action === 'advise' ? 'advise' : args.action === 'xuankong' ? 'xuankong' : 'natal';
@@ -1647,7 +1772,16 @@ function nameText(r) {
1647
1772
  /** 给模型的紧凑文本(去掉大字段,保留摘要;带解析时附上解析) */
1648
1773
  export function renderText(result) {
1649
1774
  const base = result.summary ?? JSON.stringify(result, null, 2);
1650
- const parts = [base];
1775
+ // 先给一段人话,再给盘面细节:模型照这段复述,就不至于一开口就是术语表
1776
+ const NL = String.fromCharCode(10);
1777
+ const brief = result.brief
1778
+ ? ['【先说结论】' + result.brief.lead]
1779
+ .concat((result.brief.sections || []).map((x) => [' ', x.h, ':', x.t].join('')))
1780
+ .concat((result.brief.advice || []).length ? [' 可以试着做的:' + result.brief.advice.map((a) => String(a).replace(/。$/, '')).join(';') + '。'] : [])
1781
+ .concat(result.brief.caveat ? [' ※ ' + result.brief.caveat] : [])
1782
+ .join(NL) + NL
1783
+ : '';
1784
+ const parts = [brief + base];
1651
1785
  if (result.shadow && result.shadow.id) {
1652
1786
  const s = result.shadow;
1653
1787
  parts.push(`【影子盘】${s.id}(同参数同 ID;版本 ${s.version})`
@@ -1671,7 +1805,7 @@ export function renderText(result) {
1671
1805
  + result.trace.map((t, i) => `${i + 1}. ${t.field}|${t.result}\n 规则:${t.rule} 出处:${t.source}`).join('\n'));
1672
1806
  }
1673
1807
  if (result.interpret) {
1674
- const extra = interpretText(result.interpret);
1808
+ const extra = interpretText(result.interpret, result.interpretTier);
1675
1809
  if (extra) parts.push(extra);
1676
1810
  }
1677
1811
  if (result.cross) parts.push(`【八字 × 紫微 交叉验证】\n${crossText(result.cross)}`);
@@ -1724,6 +1858,7 @@ export { askLink, parseAskLink, formOf, restNote, askPackages };
1724
1858
  // 问事解读提示:把一张盘打包成「先答他所问、不堆术语、回到现实核对」的提示,供面板一键复制
1725
1859
  // (本仓 esbuild 不接受 `export {} from`,先静态 import 再 export)
1726
1860
  import { buildEventPrompt, ASK_KINDS } from './event-prompt.js';
1861
+ import { plainSummary } from './plain-summary.js';
1727
1862
  export { buildEventPrompt, ASK_KINDS };
1728
1863
 
1729
1864
 
@@ -494,7 +494,10 @@ function baziInterpret(r) {
494
494
  key: 'palaces', label: '胎命身',
495
495
  text: `胎元 ${r.palaces.taiyuan.ganzhi}(月干进一、月支进三);`
496
496
  + `${r.palaces.taixi ? `胎息 ${r.palaces.taixi.ganzhi}(日干五合、日支六合);` : ''}`
497
- + `命宫 ${r.palaces.minggong.ganzhi}、身宫 ${r.palaces.shengong.ganzhi}(生月与生时两支合数定宫,天干随六十甲子)。`
497
+ + (r.palaces.minggong && r.palaces.shengong
498
+ ? `命宫 ${r.palaces.minggong.ganzhi}、身宫 ${r.palaces.shengong.ganzhi}(生月与生时两支合数定宫,天干随六十甲子)。`
499
+ // 时辰未知:这两宫由生时支定,没有时支就没有宫位——宁可空着,不能拿正午顶上
500
+ : '命宫与身宫由生月与生时支合数定宫:时柱暂缺,这两宫不排。')
498
501
  + '四宫是读盘的辅助坐标,不单独折吉凶。',
499
502
  }] : []),
500
503
  {
package/engine/limits.js CHANGED
@@ -54,6 +54,11 @@ export const SYSTEM_LIMITS = {
54
54
  },
55
55
  ],
56
56
  bazi: [
57
+ {
58
+ id: 'bazi-hour-unknown',
59
+ title: '时辰未知时,只给三柱,不给紫微与合盘',
60
+ text: '不知道出生时辰时,本插件不排时柱,起运按日估算,并把「时柱按正午暂算」写在结果第一行。紫微(命宫由生月与生时支定)与合盘(夫妻宫、大运都依赖时支)一律不出盘——不是保守,是这两套离了时支就不成立,硬排出来只是看着像盘。',
61
+ },
57
62
  {
58
63
  id: 'bazi-no-event',
59
64
  title: '不预测具体年份的事件',
package/engine/lunar.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 农历(定朔):朔望月计算 + 冬至所在月为十一月 + 无中气置闰
2
2
  import {
3
- toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor, OFFSET_MODERN,
3
+ toJDN, jdToParts, termInstantJd, deltaTSeconds, localOffsetFor,
4
4
  } from './calendar.js';
5
5
 
6
6
  const SYNODIC = 29.530588861;
@@ -63,12 +63,11 @@ export function dayNumberOf(jd) {
63
63
  return Math.floor(jd + 0.5);
64
64
  }
65
65
 
66
- // 1929 年前后的时区切换常量与换算统一放在 calendar.js(节气与农历共用)
67
- /** 把 calendar 给出的「东八区轴」时刻换成当时的地方轴 */
68
- function toHistoricalLocal(jdBeijing) {
69
- const jdUt = jdBeijing - OFFSET_MODERN;
70
- return jdUt + localOffsetFor(jdUt);
71
- }
66
+ // 时区切换常量与换算统一在 calendar.js:termInstantJd 与 newMoonJd 都已经落在
67
+ // 「当时的本地时间轴」上(1929 前北京地方平时,之后东八区),两边直接比日序就行。
68
+ // 以前这里再套一次 localOffset 把节气拉回本地轴,等于对同一个量移了两次时:
69
+ // 净偏移约 −12 分,节气时刻一旦落在午夜前后十几分钟内就少算一天,
70
+ // 中气归属错一个月,置闰跟着整月错位(1917 报闰三、真历表闰二;1922 报闰六、真历表闰五)。
72
71
 
73
72
  function kNear(jdDay) {
74
73
  return Math.round((jdDay + 0.5 - NEW_MOON_EPOCH) / SYNODIC);
@@ -78,7 +77,7 @@ function kNear(jdDay) {
78
77
  function hasZhongQi(year, startDay, endDay) {
79
78
  for (let y = year - 1; y <= year + 1; y++) {
80
79
  for (let i = 1; i < 24; i += 2) {
81
- const jdLocal = toHistoricalLocal(termInstantJd(y, i));
80
+ const jdLocal = termInstantJd(y, i);
82
81
  const d = dayNumberOf(jdLocal);
83
82
  if (d >= startDay && d < endDay) return { index: i, jd: jdLocal };
84
83
  }
@@ -93,9 +92,9 @@ function hasZhongQi(year, startDay, endDay) {
93
92
  export function solarToLunar(year, month, day) {
94
93
  const targetDay = toJDN(year, month, day);
95
94
 
96
- const wsDayThisYear = dayNumberOf(toHistoricalLocal(termInstantJd(year, 23)));
95
+ const wsDayThisYear = dayNumberOf(termInstantJd(year, 23));
97
96
  const wsYear = targetDay < wsDayThisYear ? year - 1 : year;
98
- const wsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear, 23)));
97
+ const wsDay = dayNumberOf(termInstantJd(wsYear, 23));
99
98
 
100
99
  // 十一月朔:日序不晚于冬至的最后一个朔日
101
100
  let k = kNear(wsDay);
@@ -105,7 +104,7 @@ export function solarToLunar(year, month, day) {
105
104
  // ── 置闰前提(国标口径)──
106
105
  // 只有「两个冬至月之间有 13 个朔望月」才需要置闰,且取其中第一个无中气的月。
107
106
  // 若只有 12 个月,即使某月无中气也不闰(1985 正月、2053 正月就是这种反例)。
108
- const nextWsDay = dayNumberOf(toHistoricalLocal(termInstantJd(wsYear + 1, 23)));
107
+ const nextWsDay = dayNumberOf(termInstantJd(wsYear + 1, 23));
109
108
  let kNext = k;
110
109
  while (dayNumberOf(newMoonJd(kNext + 1)) <= nextWsDay) kNext++;
111
110
  const cycleMonths = kNext - k;