dsh-metaphysics 0.16.0 → 0.17.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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  版本按「一组可感知的改动」发,README 与市场条目描述随能力同步。
4
4
 
5
+ ## 0.17.0(未发布)
6
+
7
+ 对应「晚子时开关 / 验前事 / 生时校正」三条用户反馈:
8
+
9
+ - **晚子时开关**:面板与独立页新增「晚子时换日」(八字/紫微/合盘)与奇门「归当日/归次日」;
10
+ 之前引擎已支持 `lateZi` / `lateZiHourMode`,但 UI 没有暴露,用户无法自己调
11
+ - **验前事**:工具参数 `verifyPast`(年数)+ 面板/独立页「验前事」区块——列出过去若干年的
12
+ 流年信号(冲/合/刑、档位、十神),用户自评「准/不准/不确定」,用 `verifyFit` 算贴合度并给建议;
13
+ **只读,不重排、不改盘**;贴合度低时先提示核对出生时间/时区/晚子时
14
+ - **生时校正**:`engine/rectify.js` + 工具参数 `rectify`/`events`——对候选时辰逐一排盘,
15
+ 按固定规则(事件类别 × 当年十神/关系/档位)打分,返回候选排序与并列说明;
16
+ 规则显式可复核,明确「只缩小范围、不替代出生记录」
17
+ - 测试:新增 `test/verify.test.mjs` 25 条;全量 53 个文件 / 1362 条 PASS
18
+
5
19
  ## 0.16.0(已发布)
6
20
 
7
21
  报告化改版,对应与 Seeself 对比后的 R1–R8:
package/README.md CHANGED
@@ -75,7 +75,10 @@ Chinese metaphysics for [DeepSeek Harness](https://github.com/deepseek-ai/deepse
75
75
  - **追问**:工具参数 `ask`、面板与独立页的追问输入框——围绕**已算好的盘**回答「2027 年事业怎么样」「财运如何」这类问题;只取盘面已有字段(流年/大运/宫位/分节),不重排、不猜测;年份超出已排范围或没识别到方面时,明确说找不到并给出可问范围
76
76
  - **八字 × 紫微 交叉验证**:工具参数 `cross`(或面板勾选)会用同一份出生信息再排另一套盘,对事业/财富/感情/身体/节奏五项并列对照,标注「同向 / 有张力 / 互补」;只作对照,不把两套体系折算成同一个分数
77
77
  - **量化口径**:只量化引擎已有数据(十神权重、大运/流年档位、宫位与四化计数),**不换算金额、不给健康诊断**;报告里有「量化口径」分节写明这条边界
78
- - **方法与验证**:独立页内置同页「方法与验证」(体系、128 条卦例对拍、52 个测试文件/1335 条断言、五道闸、口径与已知边界),离线可开
78
+ - **方法与验证**:独立页内置同页「方法与验证」(体系、128 条卦例对拍、53 个测试文件/1362 条断言、五道闸、口径与已知边界),离线可开
79
+ - **晚子时开关**:面板与独立页都能切「23:00 后算次日/当日」;奇门另可切「归当日/归次日」——这是排盘口径,不是结果重算
80
+ - **验前事**:八字报告下方列出过去 6 年流年信号,用户自评「准/不准/不确定」,算一个贴合度提示;只读、不改盘
81
+ - **生时校正**:输入过去 2–3 件大事(年份 + 类别),对候选时辰逐一排盘并按固定规则打分,返回候选排序——用于缩小时辰范围,不替代出生记录
79
82
  - **导出**:面板与独立页都能「导出 .md」(盘面 + 一页读懂 + 三张表 + 交叉验证 + 追问);独立页另有打印/存 PDF
80
83
  - **推导可复核**:八字/紫微另有「看盘顺序」「时间窗」「交叉校验」「未纳入」四段——先看什么后看什么、未来几步大运与近五年流年、喜忌分量与运年是否同向、以及这套算法**没有**计算什么;六爻/六壬对应给「看卦/看课顺序」与「未纳入」。这些都从盘面数据推出,不新增断语
81
84
  - **卦象**(六爻/梅花/大六壬)额外给:卦名与卦辞大意、爻辞与动爻、体用关系、互卦与变卦、落到生活里
package/client.js CHANGED
@@ -746,7 +746,7 @@ function resolveAddress(input) {
746
746
 
747
747
  const DETAIL = /(区|县|镇|乡|村|小区|街道|路|号|巷|弄|组|队)/;
748
748
 
749
- const USER_AGENT = 'dsh-metaphysics/0.16.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
749
+ const USER_AGENT = 'dsh-metaphysics/0.17.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
750
750
 
751
751
  async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
752
752
  if (typeof fetch !== 'function') return null;
@@ -6843,6 +6843,169 @@ function crossText(cross) {
6843
6843
  return { crossCheck, crossText };
6844
6844
  })();
6845
6845
 
6846
+ __mods.verify = (function () {
6847
+ const KIND_LABEL = {
6848
+ career: '事业变动',
6849
+ wealth: '财运起伏',
6850
+ love: '感情变化',
6851
+ move: '搬迁 / 出行',
6852
+ health: '健康问题',
6853
+ other: '其他大事',
6854
+ };
6855
+
6856
+ const THEME = (l) => {
6857
+ const rel = (l.relations || []).join('');
6858
+ if (/冲/.test(rel)) return '有冲:变动 / 摩擦';
6859
+ if (/刑/.test(rel)) return '有刑:内耗 / 反复';
6860
+ if (/合/.test(rel)) return '有合:合作 / 牵引';
6861
+ const tier = l.luck && l.luck.tier;
6862
+ if (tier === '大吉' || tier === '吉') return '流年偏顺';
6863
+ if (tier === '大忌' || tier === '忌') return '流年偏压';
6864
+ return '信号平缓';
6865
+ };
6866
+
6867
+ function pastSignals(chart, untilYear) {
6868
+ const until = Number(untilYear) || Infinity;
6869
+ return (chart.liunian || [])
6870
+ .filter((l) => Number(l.year) <= until)
6871
+ .map((l) => ({
6872
+ year: Number(l.year),
6873
+ ganzhi: l.ganzhi,
6874
+ tier: (l.luck && l.luck.tier) || '平',
6875
+ tenGod: l.stemTenGod || '',
6876
+ relations: l.relations || [],
6877
+ theme: THEME(l),
6878
+ }));
6879
+ }
6880
+
6881
+ function verifyFit(rows, marks) {
6882
+ const list = Array.isArray(rows) ? rows : [];
6883
+ const m = marks || {};
6884
+ const picked = list.filter((r) => m[r.year] === 'yes' || m[r.year] === 'no');
6885
+ const yes = picked.filter((r) => m[r.year] === 'yes').length;
6886
+ const no = picked.length - yes;
6887
+ const rate = picked.length ? yes / picked.length : null;
6888
+ const level = rate == null ? '未评' : rate >= 0.7 ? '高' : rate >= 0.4 ? '中' : '低';
6889
+ let advice = '';
6890
+ if (rate == null) advice = '先勾几项「准 / 不准 / 不确定」,再看得出的贴合度。';
6891
+ else if (level === '高') advice = '前事与流年信号对得较顺,可以按这张盘继续看;有分歧时以事实为准。';
6892
+ else if (level === '中') advice = '有一部分对得上:先用「追问」核对具体年份,再决定信到什么程度。';
6893
+ else advice = '对得不多:优先核对出生时间、出生地(时区/真太阳时)与晚子时口径,而不是改结论。';
6894
+ return { answered: picked.length, yes, no, rate, level, advice, labels: { ...KIND_LABEL } };
6895
+ }
6896
+
6897
+ return { KIND_LABEL, pastSignals, verifyFit };
6898
+ })();
6899
+
6900
+ __mods.rectify = (function () {
6901
+ const { buildBazi } = __mods.bazi;
6902
+
6903
+ const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
6904
+ const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
6905
+
6906
+ const TEN_GOD = (l) => l.stemTenGod || '';
6907
+ const has = (l, re) => re.test((l.relations || []).join(''));
6908
+ const tier = (l) => (l.luck && l.luck.tier) || '平';
6909
+
6910
+ function matchEvent(l, kind) {
6911
+ if (!l) return false;
6912
+ const tg = TEN_GOD(l);
6913
+ const t = tier(l);
6914
+ switch (kind) {
6915
+ case 'career': return /官|杀|食|伤/.test(tg) || has(l, /冲|合/) || t === '大吉' || t === '大忌';
6916
+ case 'wealth': return /财|食|伤/.test(tg) || t === '大吉' || t === '吉';
6917
+ case 'love': return /财|官|杀/.test(tg) || has(l, /合|冲/);
6918
+ case 'move': return has(l, /冲|合/);
6919
+ case 'health': return has(l, /刑|害|冲/) || t === '大忌' || t === '忌';
6920
+ default: return t === '大吉' || t === '大忌' || has(l, /冲|刑|合/);
6921
+ }
6922
+ }
6923
+
6924
+ function rectifyBirth(opts) {
6925
+ const base = opts && opts.base;
6926
+ const events = (opts && opts.events) || [];
6927
+ const gender = opts && opts.gender;
6928
+ if (!base || !Number.isInteger(base.year) || !Number.isInteger(base.month) || !Number.isInteger(base.day)) {
6929
+ const e = new Error('生时校正需要出生日期 { year, month, day }');
6930
+ e.code = 'E_INPUT';
6931
+ throw e;
6932
+ }
6933
+ if (!gender) {
6934
+ const e = new Error('生时校正需要性别');
6935
+ e.code = 'E_INPUT';
6936
+ throw e;
6937
+ }
6938
+ const clean = events
6939
+ .filter((x) => x && Number.isInteger(Number(x.year)))
6940
+ .map((x) => ({ year: Number(x.year), kind: KINDS.includes(x.kind) ? x.kind : 'other' }));
6941
+ if (!clean.length) {
6942
+ const e = new Error('生时校正需要至少一条「过去发生的事」(年份 + 类别)');
6943
+ e.code = 'E_INPUT';
6944
+ throw e;
6945
+ }
6946
+ const nowYear = Math.min(...clean.map((x) => x.year));
6947
+ const hours = Array.isArray(opts.hours) && opts.hours.length
6948
+ ? opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
6949
+ : HOURS;
6950
+
6951
+ const candidates = hours.map((hour) => {
6952
+ const chart = buildBazi({
6953
+ ...base,
6954
+ hour,
6955
+ minute: 0,
6956
+ gender,
6957
+ lateZi: opts.lateZi !== false,
6958
+ trueSolar: opts.trueSolar !== false,
6959
+ address: typeof opts.address === 'string' ? opts.address : undefined,
6960
+ longitude: typeof opts.longitude === 'number' ? opts.longitude : undefined,
6961
+ geo: opts.geo && typeof opts.geo === 'object' ? opts.geo : undefined,
6962
+ nowYear,
6963
+ });
6964
+ const matched = [];
6965
+ const missed = [];
6966
+ for (const ev of clean) {
6967
+ const l = (chart.liunian || []).find((x) => Number(x.year) === ev.year);
6968
+ (matchEvent(l, ev.kind) ? matched : missed).push(ev);
6969
+ }
6970
+ return {
6971
+ hour,
6972
+ label: `${String(hour).padStart(2, '0')}:00 起`,
6973
+ dayGanzhi: chart.pillars.day.ganzhi,
6974
+ hourGanzhi: chart.pillars.hour.ganzhi,
6975
+ score: clean.length ? matched.length / clean.length : 0,
6976
+ hits: matched.length,
6977
+ total: clean.length,
6978
+ matchedYears: matched.map((x) => x.year),
6979
+ missedYears: missed.map((x) => x.year),
6980
+ };
6981
+ });
6982
+ candidates.sort((a, b) => b.score - a.score || a.hour - b.hour);
6983
+
6984
+ const days = [...new Set(candidates.map((c) => c.dayGanzhi))];
6985
+ const best = candidates[0];
6986
+ const second = candidates[1];
6987
+ const tie = second && second.score === best.score;
6988
+
6989
+ return {
6990
+ system: 'bazi',
6991
+ base,
6992
+ events: clean,
6993
+ hours,
6994
+ candidates,
6995
+ best: best || null,
6996
+ tie,
6997
+ dayChanged: days.length > 1,
6998
+ note: `候选 ${candidates.length} 个:命中数最高 ${best ? best.hits : 0}/${clean.length}${tie ? `(有并列:${candidates.filter((c) => c.score === best.score).map((c) => c.hour).join('、')} 点)` : ''}。` +
6999
+ (days.length > 1 ? `注意:跨 23:00 的候选会改变日柱(${days.join(' / ')}),这类差异比时柱更关键。` : '候选之间只差时柱(日柱未变)。') +
7000
+ ' 这是缩小范围的参考,不替代出生记录;命中数接近时不要硬选。',
7001
+ };
7002
+ }
7003
+
7004
+ const RECTIFY_KINDS = KINDS;
7005
+
7006
+ return { matchEvent, rectifyBirth, RECTIFY_KINDS };
7007
+ })();
7008
+
6846
7009
  __mods.index = (function () {
6847
7010
  const { buildBazi } = __mods.bazi;
6848
7011
  const { buildLiuyao, tossLines } = __mods.liuyao;
@@ -6859,6 +7022,8 @@ const { randomOf, isSeed } = __mods.random;
6859
7022
  const { interpret, DIRECTIONS, DIRECTION_DEFAULT } = __mods.interpret;
6860
7023
  const { followup } = __mods.followup;
6861
7024
  const { crossCheck, crossText } = __mods.crosscheck;
7025
+ const { pastSignals, verifyFit } = __mods.verify;
7026
+ const { rectifyBirth } = __mods.rectify;
6862
7027
  const { fail } = __mods.fail;
6863
7028
  const { assertCivil } = __mods.validate;
6864
7029
 
@@ -7137,6 +7302,39 @@ function compute(args = {}) {
7137
7302
  r.interpret.sections.push({ key: 'crossSystem', label: '八字 × 紫微 交叉验证', text: crossText(r.cross) });
7138
7303
  }
7139
7304
  }
7305
+ if (Number.isInteger(args.verifyPast) && args.verifyPast > 0 && r.system === 'bazi') {
7306
+ const years = Math.min(Math.max(args.verifyPast, 1), 10);
7307
+ const from = Number(r.liunianFrom) - years;
7308
+ const pastChart = buildBazi({
7309
+ ...parseDateTime(args.datetime),
7310
+ gender: requireGender(args.gender),
7311
+ lateZi: args.lateZi !== false,
7312
+ trueSolar: args.trueSolar !== false,
7313
+ longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
7314
+ address: typeof args.address === 'string' ? args.address : undefined,
7315
+ geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
7316
+ nowYear: from,
7317
+ });
7318
+ r.past = {
7319
+ rows: pastSignals(pastChart, Number(r.liunianFrom) - 1),
7320
+ years,
7321
+ until: Number(r.liunianFrom) - 1,
7322
+ };
7323
+ }
7324
+ if (args.rectify === true && r.system === 'bazi') {
7325
+ const base = parseDateTime(args.datetime);
7326
+ r.rectify = rectifyBirth({
7327
+ base: { year: base.year, month: base.month, day: base.day, minute: base.minute },
7328
+ gender: requireGender(args.gender),
7329
+ events: Array.isArray(args.events) ? args.events : [],
7330
+ hours: Array.isArray(args.rectifyHours) ? args.rectifyHours : undefined,
7331
+ lateZi: args.lateZi !== false,
7332
+ trueSolar: args.trueSolar !== false,
7333
+ longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
7334
+ address: typeof args.address === 'string' ? args.address : undefined,
7335
+ geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
7336
+ });
7337
+ }
7140
7338
  if (typeof args.ask === 'string' && args.ask.trim()) r.followup = followup(r, args.ask);
7141
7339
  return r;
7142
7340
  }
@@ -7352,10 +7550,21 @@ function renderText(result) {
7352
7550
  const f = result.followup;
7353
7551
  parts.push(`【追问】${f.question}\n${f.answer}${f.evidence && f.evidence.length ? `\n 依据:${f.evidence.join(';')} 置信度:${f.confidence}` : ''}`);
7354
7552
  }
7553
+ if (result.past && Array.isArray(result.past.rows) && result.past.rows.length) {
7554
+ parts.push(`【验前事】过去 ${result.past.years} 年的流年信号(只读,不改盘)\n` +
7555
+ result.past.rows.map((r) => `· ${r.year} ${r.ganzhi} · ${r.theme}(${r.tier}${r.tenGod ? `,${r.tenGod}` : ''})`).join('\n') +
7556
+ '\n用法:在面板里勾「准 / 不准 / 不确定」后算贴合度;贴合度低时先核对出生时间、时区与晚子时口径。');
7557
+ }
7558
+ if (result.rectify && Array.isArray(result.rectify.candidates)) {
7559
+ const rc = result.rectify;
7560
+ parts.push(`【生时校正】候选时辰 ${rc.candidates.length} 个(固定规则打分,只缩小范围)\n` +
7561
+ rc.candidates.slice(0, 8).map((c) => `· ${c.label} 时柱 ${c.hourGanzhi} / 日柱 ${c.dayGanzhi} · 命中 ${c.hits}/${c.total}`).join('\n') +
7562
+ `\n${rc.note}`);
7563
+ }
7355
7564
  return parts.filter(Boolean).join('\n\n');
7356
7565
  }
7357
7566
 
7358
- return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, geocodeAddress, resolveAddress, buildBazi, buildLiuyao, tossLines, followup, crossCheck, crossText, parseDateTime, compute, interpretText, renderText };
7567
+ return { interpret, DIRECTIONS, DIRECTION_DEFAULT, BRIGHTNESS, flowOf, geocodeAddress, resolveAddress, buildBazi, buildLiuyao, tossLines, followup, pastSignals, verifyFit, rectifyBirth, crossCheck, crossText, parseDateTime, compute, interpretText, renderText };
7359
7568
  })();
7360
7569
 
7361
7570
  const Engine = __mods.index;
@@ -8908,6 +9117,19 @@ const CSS = `
8908
9117
  .mp-askbox{display:flex;gap:8px;align-items:center;margin-top:12px}
8909
9118
  .mp-askbox .mp-input{flex:1;min-width:0}
8910
9119
  .mp-fx{margin-top:10px}
9120
+ .mp-verify,.mp-rect{margin-top:12px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2)}
9121
+ .mp-verify-h{font-size:12px;font-weight:700;letter-spacing:.08em;color:var(--dsw-alias-label-secondary);margin-bottom:8px}
9122
+ .mp-verify-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:12.5px;padding:3px 0}
9123
+ .mp-verify-btns{display:flex;gap:4px}
9124
+ .mp-chip{font-size:11.5px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
9125
+ .mp-chip.on{background:var(--dsw-alias-brand-primary);color:#fff;border-color:transparent}
9126
+ .mp-verify-foot{display:flex;align-items:flex-start;gap:8px;margin-top:8px;flex-wrap:wrap}
9127
+ .mp-verify-fit{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.7;flex:1;min-width:0}
9128
+ .mp-rect-row{display:flex;gap:6px;margin-bottom:6px}
9129
+ .mp-rect-row .mp-input{flex:1;min-width:0}
9130
+ .mp-rect-cand{font-size:12px;padding:5px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);margin-top:4px;color:var(--dsw-alias-label-secondary)}
9131
+ .mp-rect-cand.best{border-left:3px solid var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary)}
9132
+ .mp-rect-err{color:var(--dsw-alias-label-secondary);font-size:12px}
8911
9133
  .mp-checks{flex-direction:row;align-items:center;gap:7px;font-size:12.5px}
8912
9134
  .mp-checks input{width:15px;height:15px;accent-color:var(--dsw-alias-brand-primary)}
8913
9135
  .mp-auto{margin:-8px 0 14px;padding-left:2px;font-size:12.5px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
@@ -8995,9 +9217,15 @@ function App() {
8995
9217
  const [hpChart, setHpChart] = useState('bazi');
8996
9218
  const [qimenRule, setQimenRule] = useState('chaibu');
8997
9219
  const [trueSolar, setTrueSolar] = useState(true);
9220
+ const [lateZi, setLateZi] = useState(true);
9221
+ const [qLateZi, setQLateZi] = useState('same-day');
8998
9222
  const [crossOn, setCrossOn] = useState(false);
8999
9223
  const [askText, setAskText] = useState('');
9000
9224
  const [fx, setFx] = useState(null);
9225
+ const [pastMarks, setPastMarks] = useState({});
9226
+ const [fit, setFit] = useState(null);
9227
+ const [rectEvents, setRectEvents] = useState([{ year: '2019', kind: 'career' }, { year: '2022', kind: 'love' }]);
9228
+ const [rectRes, setRectRes] = useState(null);
9001
9229
  const [onlineGeo, setOnlineGeo] = useState(false);
9002
9230
  const [lines, setLines] = useState('');
9003
9231
  const [result, setResult] = useState(null);
@@ -9046,13 +9274,14 @@ function App() {
9046
9274
  }
9047
9275
  }
9048
9276
  if (k !== 'hepan') args.cross = crossOn;
9277
+ args.lateZi = lateZi;
9049
9278
  }
9050
9279
  if (k === 'liuyao') {
9051
9280
  const parsed = Core.parseLines(lines);
9052
9281
  if (parsed.lines) args.lines = parsed.lines;
9053
9282
  else if (parsed.warning) setNotice(parsed.warning);
9054
9283
  }
9055
- if (k === 'qimen') args.juMethod = qimenRule;
9284
+ if (k === 'qimen') { args.juMethod = qimenRule; args.lateZiHourMode = qLateZi; }
9056
9285
  if (k === 'tarot') args.count = Number(count);
9057
9286
  if (k === 'hepan') {
9058
9287
  args.chart = hpChart;
@@ -9101,7 +9330,7 @@ function App() {
9101
9330
  }
9102
9331
  const timed = kindOf(k) === 'cast' && (k !== 'meihua' || mh === 'time');
9103
9332
  setResult({ kind: k, r, it, at: dt, timed });
9104
- setFx(null);
9333
+ setFx(null); setFit(null); setPastMarks({}); setRectRes(null);
9105
9334
  setError(''); setCopied('');
9106
9335
  if (k !== 'tarot') {
9107
9336
  const entry = {
@@ -9259,6 +9488,34 @@ function App() {
9259
9488
  }
9260
9489
  };
9261
9490
 
9491
+ const btnStyle = { fontSize: 12, padding: '5px 12px' };
9492
+
9493
+ const calcFit = () => {
9494
+ if (!result || !result.r || !result.r.past) return;
9495
+ setFit(Engine.verifyFit ? Engine.verifyFit(result.r.past.rows, pastMarks) : null);
9496
+ };
9497
+
9498
+ const runRectify = () => {
9499
+ if (!result || result.kind !== 'bazi') return;
9500
+ try {
9501
+ const [y, m, d] = String(date).split('-').map(Number);
9502
+ const [, mm] = String(time).split(':').map(Number);
9503
+ const events = rectEvents
9504
+ .filter((e) => Number(e.year))
9505
+ .map((e) => ({ year: Number(e.year), kind: e.kind }));
9506
+ setRectRes(Engine.rectifyBirth ? Engine.rectifyBirth({
9507
+ base: { year: y, month: m, day: d, minute: Number.isFinite(mm) ? mm : 0 },
9508
+ gender,
9509
+ events,
9510
+ lateZi,
9511
+ trueSolar,
9512
+ address: address.trim() || undefined,
9513
+ }) : { error: '当前客户端未加载生时校正模块' });
9514
+ } catch (e) {
9515
+ setRectRes({ error: e && e.message ? e.message : String(e) });
9516
+ }
9517
+ };
9518
+
9262
9519
  const field = (label, node) => h('label', { className: 'mp-field' }, h('span', { className: 'mp-lbl' }, label), node);
9263
9520
 
9264
9521
  const replay = (x) => {
@@ -9345,6 +9602,10 @@ function App() {
9345
9602
  onChange: () => setQimenRule(value),
9346
9603
  }),
9347
9604
  h('span', null, label)))),
9605
+ h('div', { className: 'mp-field' }, h('span', { className: 'mp-lbl' }, '晚子时'),
9606
+ h('select', { className: 'mp-input', value: qLateZi, onChange: (e) => setQLateZi(e.target.value) },
9607
+ h('option', { value: 'same-day' }, '23:00 后归当日'),
9608
+ h('option', { value: 'next-day' }, '23:00 后归次日'))),
9348
9609
  h('details', { className: 'mp-qmhelp' },
9349
9610
  h('summary', null, '三种规则有什么区别?'),
9350
9611
  h('p', null, '决定符头与节气如何对齐、是否插入闰局;同一个时刻在不同规则下可能得到不同局数。默认「拆补」与工具层一致。'))) : null,
@@ -9378,6 +9639,9 @@ function App() {
9378
9639
  isBirth ? h('div', { className: 'mp-checks' },
9379
9640
  h('label', { title: '按出生地经度把钟表时间换算成真太阳时' },
9380
9641
  h('input', { type: 'checkbox', checked: trueSolar, onChange: (e) => setTrueSolar(e.target.checked) }), '真太阳时'),
9642
+ isBirth ? h('label', { className: 'mp-field mp-checks' },
9643
+ h('input', { type: 'checkbox', checked: lateZi, onChange: (e) => setLateZi(e.target.checked) }),
9644
+ h('span', { className: 'mp-lbl' }, '晚子时换日')) : null,
9381
9645
  h('label', { title: '开启后会请求 OpenStreetMap,把区县/村落到更精确的坐标' },
9382
9646
  h('input', { type: 'checkbox', checked: onlineGeo, onChange: (e) => setOnlineGeo(e.target.checked) }), '联网精确到区县')) : null,
9383
9647
  needAsk(tab) ? h('div', { className: 'mp-askblock' },
@@ -9471,6 +9735,40 @@ function App() {
9471
9735
 
9472
9736
  fx ? h('div', { key: 'fx', className: 'mp-fx', dangerouslySetInnerHTML: { __html: PP.followupHTML(fx) } }) : null,
9473
9737
 
9738
+ (result && result.kind === 'bazi' && result.r.past) ? h('div', { key: 'verify', className: 'mp-verify' },
9739
+ h('div', { className: 'mp-verify-h' }, '验前事 · 过去几年的信号(只读,不改盘)'),
9740
+ result.r.past.rows.map((row) => h('div', { key: row.year, className: 'mp-verify-row' },
9741
+ h('span', { className: 'mp-verify-txt' }, `${row.year} ${row.ganzhi} · ${row.theme}`),
9742
+ h('span', { className: 'mp-verify-btns' }, ['yes', 'no', 'unsure'].map((m) => h('button', {
9743
+ key: m,
9744
+ className: 'mp-chip' + (pastMarks[row.year] === m ? ' on' : ''),
9745
+ onClick: () => setPastMarks(Object.assign({}, pastMarks, { [row.year]: m })),
9746
+ }, m === 'yes' ? '准' : m === 'no' ? '不准' : '不确定'))))),
9747
+ h('div', { className: 'mp-verify-foot' },
9748
+ h('button', { className: 'mp-btn ghost', style: btnStyle, onClick: calcFit }, '算贴合度'),
9749
+ fit ? h('span', { className: 'mp-verify-fit' }, `命中 ${fit.yes}/${fit.answered}(${fit.level}):${fit.advice}`) : null)) : null,
9750
+
9751
+ (result && result.kind === 'bazi') ? h('div', { key: 'rect', className: 'mp-rect' },
9752
+ h('div', { className: 'mp-verify-h' }, '生时校正 · 用过去的事反推时辰'),
9753
+ rectEvents.map((ev, i) => h('div', { key: i, className: 'mp-rect-row' },
9754
+ h('input', {
9755
+ className: 'mp-input', type: 'number', min: 1900, max: 2100, value: ev.year,
9756
+ onChange: (e) => setRectEvents(rectEvents.map((x, j) => (j === i ? Object.assign({}, x, { year: e.target.value }) : x))),
9757
+ }),
9758
+ h('select', {
9759
+ className: 'mp-input', value: ev.kind,
9760
+ onChange: (e) => setRectEvents(rectEvents.map((x, j) => (j === i ? Object.assign({}, x, { kind: e.target.value }) : x))),
9761
+ }, [['career', '事业变动'], ['wealth', '财运'], ['love', '感情'], ['move', '搬迁/出行'], ['health', '健康'], ['other', '其他']]
9762
+ .map(([v, l]) => h('option', { key: v, value: v }, l))))),
9763
+ h('button', { className: 'mp-btn ghost', style: btnStyle, onClick: runRectify }, '计算候选时辰'),
9764
+ rectRes ? (rectRes.error
9765
+ ? h('div', { className: 'mp-rect-err' }, rectRes.error)
9766
+ : h('div', { className: 'mp-rect-out' },
9767
+ h('div', { className: 'mp-verify-fit' }, rectRes.note),
9768
+ rectRes.candidates.slice(0, 6).map((c) => h('div', {
9769
+ key: c.hour, className: 'mp-rect-cand' + (c === rectRes.best ? ' best' : ''),
9770
+ }, `${c.label} · 时柱 ${c.hourGanzhi} · 日柱 ${c.dayGanzhi} · 命中 ${c.hits}/${c.total}`)))) : null) : null,
9771
+
9474
9772
  (!result && !error) ? h('div', { key: 'ph', className: 'mp-dim', style: { marginTop: 24, fontSize: 12 } },
9475
9773
  '选择体系后点「排盘」。所有计算在本机完成,不上传任何数据。') : null,
9476
9774
  ];
package/engine/geo.js CHANGED
@@ -143,7 +143,7 @@ const DETAIL = /(区|县|镇|乡|村|小区|街道|路|号|巷|弄|组|队)/;
143
143
 
144
144
  /** OpenStreetMap Nominatim:免密钥、支持中文。失败返回 null。
145
145
  * UA 按 OSM 使用政策要求写明应用与联系方式(改版本时同步这里)。 */
146
- const USER_AGENT = 'dsh-metaphysics/0.16.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
146
+ const USER_AGENT = 'dsh-metaphysics/0.17.0 (+https://github.com/xuanfu-web/dsh-metaphysics)';
147
147
 
148
148
  async function onlineLookup(text, { timeoutMs = 2500 } = {}) {
149
149
  if (typeof fetch !== 'function') return null;
package/engine/index.js CHANGED
@@ -14,6 +14,8 @@ import { randomOf, isSeed } from './random.js';
14
14
  import { interpret, DIRECTIONS, DIRECTION_DEFAULT } from './interpret.js';
15
15
  import { followup } from './followup.js';
16
16
  import { crossCheck, crossText } from './crosscheck.js';
17
+ import { pastSignals, verifyFit } from './verify.js';
18
+ import { rectifyBirth } from './rectify.js';
17
19
  import { fail } from './fail.js';
18
20
  import { assertCivil } from './validate.js';
19
21
 
@@ -313,6 +315,41 @@ export function compute(args = {}) {
313
315
  r.interpret.sections.push({ key: 'crossSystem', label: '八字 × 紫微 交叉验证', text: crossText(r.cross) });
314
316
  }
315
317
  }
318
+ // 验前事:把过去若干年的流年信号摊开(只读,不改盘)
319
+ if (Number.isInteger(args.verifyPast) && args.verifyPast > 0 && r.system === 'bazi') {
320
+ const years = Math.min(Math.max(args.verifyPast, 1), 10);
321
+ const from = Number(r.liunianFrom) - years;
322
+ const pastChart = buildBazi({
323
+ ...parseDateTime(args.datetime),
324
+ gender: requireGender(args.gender),
325
+ lateZi: args.lateZi !== false,
326
+ trueSolar: args.trueSolar !== false,
327
+ longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
328
+ address: typeof args.address === 'string' ? args.address : undefined,
329
+ geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
330
+ nowYear: from,
331
+ });
332
+ r.past = {
333
+ rows: pastSignals(pastChart, Number(r.liunianFrom) - 1),
334
+ years,
335
+ until: Number(r.liunianFrom) - 1,
336
+ };
337
+ }
338
+ // 生时校正:候选时辰 × 过去事件,按固定规则打分(仅八字)
339
+ if (args.rectify === true && r.system === 'bazi') {
340
+ const base = parseDateTime(args.datetime);
341
+ r.rectify = rectifyBirth({
342
+ base: { year: base.year, month: base.month, day: base.day, minute: base.minute },
343
+ gender: requireGender(args.gender),
344
+ events: Array.isArray(args.events) ? args.events : [],
345
+ hours: Array.isArray(args.rectifyHours) ? args.rectifyHours : undefined,
346
+ lateZi: args.lateZi !== false,
347
+ trueSolar: args.trueSolar !== false,
348
+ longitude: typeof args.longitude === 'number' ? args.longitude : undefined,
349
+ address: typeof args.address === 'string' ? args.address : undefined,
350
+ geo: args.geo && typeof args.geo === 'object' ? args.geo : undefined,
351
+ });
352
+ }
316
353
  // 追问:围绕已算好的盘回答「某一年 / 某方面」的具体问题
317
354
  if (typeof args.ask === 'string' && args.ask.trim()) r.followup = followup(r, args.ask);
318
355
  return r;
@@ -546,6 +583,19 @@ export function renderText(result) {
546
583
  const f = result.followup;
547
584
  parts.push(`【追问】${f.question}\n${f.answer}${f.evidence && f.evidence.length ? `\n 依据:${f.evidence.join(';')} 置信度:${f.confidence}` : ''}`);
548
585
  }
586
+ // 验前事:过去几年的信号(只读);贴合度由用户在面板里勾选后算,这里只列事实
587
+ if (result.past && Array.isArray(result.past.rows) && result.past.rows.length) {
588
+ parts.push(`【验前事】过去 ${result.past.years} 年的流年信号(只读,不改盘)\n` +
589
+ result.past.rows.map((r) => `· ${r.year} ${r.ganzhi} · ${r.theme}(${r.tier}${r.tenGod ? `,${r.tenGod}` : ''})`).join('\n') +
590
+ '\n用法:在面板里勾「准 / 不准 / 不确定」后算贴合度;贴合度低时先核对出生时间、时区与晚子时口径。');
591
+ }
592
+ // 生时校正:候选时辰的排序与命中数(固定规则,只缩小范围)
593
+ if (result.rectify && Array.isArray(result.rectify.candidates)) {
594
+ const rc = result.rectify;
595
+ parts.push(`【生时校正】候选时辰 ${rc.candidates.length} 个(固定规则打分,只缩小范围)\n` +
596
+ rc.candidates.slice(0, 8).map((c) => `· ${c.label} 时柱 ${c.hourGanzhi} / 日柱 ${c.dayGanzhi} · 命中 ${c.hits}/${c.total}`).join('\n') +
597
+ `\n${rc.note}`);
598
+ }
549
599
  return parts.filter(Boolean).join('\n\n');
550
600
  }
551
601
 
@@ -554,6 +604,7 @@ export { geocodeAddress, resolveAddress };
554
604
 
555
605
  export { buildBazi, buildLiuyao, tossLines };
556
606
  export { followup };
607
+ export { pastSignals, verifyFit, rectifyBirth };
557
608
  export { crossCheck, crossText };
558
609
 
559
610
 
@@ -0,0 +1,130 @@
1
+ // 生时校正(仅八字):给一组「过去发生过的事」,把候选时辰逐一排盘、按同一套信号规则打分,
2
+ // 输出候选排序,帮助缩小出生时辰的范围。
3
+ //
4
+ // 必须说清的三件事:
5
+ // 1. 这是**缩小范围**的参考,不替代出生记录;命中数相同或接近时,不要硬选一个;
6
+ // 2. 只有时柱(以及跨 23:00 时的日柱)会变,年/月柱通常不变,所以候选之间的差异集中在晚运、子女、事业后半段;
7
+ // 3. 打分规则是显式、固定的(见 matchEvent),不是拟合;换一套规则排序可能变。
8
+ import { buildBazi } from './bazi.js';
9
+
10
+ const KINDS = ['career', 'wealth', 'love', 'move', 'health', 'other'];
11
+ const HOURS = [0, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 23];
12
+
13
+ const TEN_GOD = (l) => l.stemTenGod || '';
14
+ const has = (l, re) => re.test((l.relations || []).join(''));
15
+ const tier = (l) => (l.luck && l.luck.tier) || '平';
16
+
17
+ /**
18
+ * 单条「过去发生的事」在某个候选盘里的信号是否吻合(规则固定、可复核)。
19
+ * @param {object} l 某年的流年条目
20
+ * @param {string} kind career | wealth | love | move | health | other
21
+ */
22
+ export function matchEvent(l, kind) {
23
+ if (!l) return false;
24
+ const tg = TEN_GOD(l);
25
+ const t = tier(l);
26
+ switch (kind) {
27
+ case 'career': return /官|杀|食|伤/.test(tg) || has(l, /冲|合/) || t === '大吉' || t === '大忌';
28
+ case 'wealth': return /财|食|伤/.test(tg) || t === '大吉' || t === '吉';
29
+ case 'love': return /财|官|杀/.test(tg) || has(l, /合|冲/);
30
+ case 'move': return has(l, /冲|合/);
31
+ case 'health': return has(l, /刑|害|冲/) || t === '大忌' || t === '忌';
32
+ default: return t === '大吉' || t === '大忌' || has(l, /冲|刑|合/);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {object} opts
38
+ * @param {{year:number,month:number,day:number,minute?:number}} opts.base 出生日期(时辰待定)
39
+ * @param {'male'|'female'} opts.gender
40
+ * @param {Array<{year:number, kind:string}>} opts.events 过去发生的事(年份 + 类别)
41
+ * @param {number[]} [opts.hours] 候选小时(默认 0/1/2/4/…/22/23)
42
+ * @param {boolean} [opts.lateZi] 晚子时是否换日
43
+ * @param {boolean} [opts.trueSolar]
44
+ * @param {string} [opts.address]
45
+ * @param {number} [opts.longitude]
46
+ * @param {object} [opts.geo]
47
+ */
48
+ export function rectifyBirth(opts) {
49
+ const base = opts && opts.base;
50
+ const events = (opts && opts.events) || [];
51
+ const gender = opts && opts.gender;
52
+ if (!base || !Number.isInteger(base.year) || !Number.isInteger(base.month) || !Number.isInteger(base.day)) {
53
+ const e = new Error('生时校正需要出生日期 { year, month, day }');
54
+ e.code = 'E_INPUT';
55
+ throw e;
56
+ }
57
+ if (!gender) {
58
+ const e = new Error('生时校正需要性别');
59
+ e.code = 'E_INPUT';
60
+ throw e;
61
+ }
62
+ const clean = events
63
+ .filter((x) => x && Number.isInteger(Number(x.year)))
64
+ .map((x) => ({ year: Number(x.year), kind: KINDS.includes(x.kind) ? x.kind : 'other' }));
65
+ if (!clean.length) {
66
+ const e = new Error('生时校正需要至少一条「过去发生的事」(年份 + 类别)');
67
+ e.code = 'E_INPUT';
68
+ throw e;
69
+ }
70
+ const nowYear = Math.min(...clean.map((x) => x.year));
71
+ const hours = Array.isArray(opts.hours) && opts.hours.length
72
+ ? opts.hours.filter((h) => Number.isInteger(h) && h >= 0 && h <= 23)
73
+ : HOURS;
74
+
75
+ const candidates = hours.map((hour) => {
76
+ const chart = buildBazi({
77
+ ...base,
78
+ hour,
79
+ minute: 0,
80
+ gender,
81
+ lateZi: opts.lateZi !== false,
82
+ trueSolar: opts.trueSolar !== false,
83
+ address: typeof opts.address === 'string' ? opts.address : undefined,
84
+ longitude: typeof opts.longitude === 'number' ? opts.longitude : undefined,
85
+ geo: opts.geo && typeof opts.geo === 'object' ? opts.geo : undefined,
86
+ nowYear,
87
+ });
88
+ const matched = [];
89
+ const missed = [];
90
+ for (const ev of clean) {
91
+ const l = (chart.liunian || []).find((x) => Number(x.year) === ev.year);
92
+ (matchEvent(l, ev.kind) ? matched : missed).push(ev);
93
+ }
94
+ return {
95
+ hour,
96
+ label: `${String(hour).padStart(2, '0')}:00 起`,
97
+ dayGanzhi: chart.pillars.day.ganzhi,
98
+ hourGanzhi: chart.pillars.hour.ganzhi,
99
+ score: clean.length ? matched.length / clean.length : 0,
100
+ hits: matched.length,
101
+ total: clean.length,
102
+ matchedYears: matched.map((x) => x.year),
103
+ missedYears: missed.map((x) => x.year),
104
+ };
105
+ });
106
+ candidates.sort((a, b) => b.score - a.score || a.hour - b.hour);
107
+
108
+ const days = [...new Set(candidates.map((c) => c.dayGanzhi))];
109
+ const best = candidates[0];
110
+ const second = candidates[1];
111
+ const tie = second && second.score === best.score;
112
+
113
+ return {
114
+ system: 'bazi',
115
+ base,
116
+ events: clean,
117
+ hours,
118
+ candidates,
119
+ best: best || null,
120
+ tie,
121
+ dayChanged: days.length > 1,
122
+ note: `候选 ${candidates.length} 个:命中数最高 ${best ? best.hits : 0}/${clean.length}${tie ? `(有并列:${candidates.filter((c) => c.score === best.score).map((c) => c.hour).join('、')} 点)` : ''}。` +
123
+ (days.length > 1 ? `注意:跨 23:00 的候选会改变日柱(${days.join(' / ')}),这类差异比时柱更关键。` : '候选之间只差时柱(日柱未变)。') +
124
+ ' 这是缩小范围的参考,不替代出生记录;命中数接近时不要硬选。',
125
+ };
126
+ }
127
+
128
+ /** 事件类别(与工具 schema 的 kind 枚举一致);导出时不起别名,打包器只支持直接导出 */
129
+ export const RECTIFY_KINDS = KINDS;
130
+ export default rectifyBirth;
@@ -0,0 +1,64 @@
1
+ // 验前事:把过去几年的流年信号摊开,让用户自己标「准 / 不准 / 不确定」,
2
+ // 只算一个贴合度提示,**不改盘、不重排、不自动调整结论**。
3
+ //
4
+ // 口径说明:这里说的「准」是用户对自己经历的主观确认,不是事实核验;
5
+ // 贴合度低时优先怀疑输入(出生时间、时区、晚子时口径),而不是推翻整张盘。
6
+ const KIND_LABEL = {
7
+ career: '事业变动',
8
+ wealth: '财运起伏',
9
+ love: '感情变化',
10
+ move: '搬迁 / 出行',
11
+ health: '健康问题',
12
+ other: '其他大事',
13
+ };
14
+
15
+ const THEME = (l) => {
16
+ const rel = (l.relations || []).join('');
17
+ if (/冲/.test(rel)) return '有冲:变动 / 摩擦';
18
+ if (/刑/.test(rel)) return '有刑:内耗 / 反复';
19
+ if (/合/.test(rel)) return '有合:合作 / 牵引';
20
+ const tier = l.luck && l.luck.tier;
21
+ if (tier === '大吉' || tier === '吉') return '流年偏顺';
22
+ if (tier === '大忌' || tier === '忌') return '流年偏压';
23
+ return '信号平缓';
24
+ };
25
+
26
+ /** 把某张盘(需已含过去年份的 liunian)摊成可勾选的信号行 */
27
+ export function pastSignals(chart, untilYear) {
28
+ const until = Number(untilYear) || Infinity;
29
+ return (chart.liunian || [])
30
+ .filter((l) => Number(l.year) <= until)
31
+ .map((l) => ({
32
+ year: Number(l.year),
33
+ ganzhi: l.ganzhi,
34
+ tier: (l.luck && l.luck.tier) || '平',
35
+ tenGod: l.stemTenGod || '',
36
+ relations: l.relations || [],
37
+ theme: THEME(l),
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * 按用户的勾选算贴合度。
43
+ * @param {Array<{year:number}>} rows pastSignals 的结果
44
+ * @param {Record<string,'yes'|'no'|'unsure'>} marks 年份 → 勾选
45
+ */
46
+ export function verifyFit(rows, marks) {
47
+ const list = Array.isArray(rows) ? rows : [];
48
+ const m = marks || {};
49
+ const picked = list.filter((r) => m[r.year] === 'yes' || m[r.year] === 'no');
50
+ const yes = picked.filter((r) => m[r.year] === 'yes').length;
51
+ const no = picked.length - yes;
52
+ const rate = picked.length ? yes / picked.length : null;
53
+ const level = rate == null ? '未评' : rate >= 0.7 ? '高' : rate >= 0.4 ? '中' : '低';
54
+ let advice = '';
55
+ if (rate == null) advice = '先勾几项「准 / 不准 / 不确定」,再看得出的贴合度。';
56
+ else if (level === '高') advice = '前事与流年信号对得较顺,可以按这张盘继续看;有分歧时以事实为准。';
57
+ else if (level === '中') advice = '有一部分对得上:先用「追问」核对具体年份,再决定信到什么程度。';
58
+ else advice = '对得不多:优先核对出生时间、出生地(时区/真太阳时)与晚子时口径,而不是改结论。';
59
+ return { answered: picked.length, yes, no, rate, level, advice, labels: { ...KIND_LABEL } };
60
+ }
61
+
62
+ export { KIND_LABEL };
63
+
64
+ export default pastSignals;
package/index.js CHANGED
@@ -146,6 +146,26 @@ export function apply(ctx) {
146
146
  type: 'boolean',
147
147
  description: '八字 / 紫微专用:为同一份出生信息再排另一套盘,返回「八字 × 紫微 交叉验证」(同向 / 有张力 / 互补),不合并数值。',
148
148
  },
149
+ verifyPast: {
150
+ type: 'number',
151
+ description: '验前事(仅八字):传年数(如 6),返回过去 N 年的流年信号行,供用户勾选「准/不准」并算贴合度;只读,不改盘。',
152
+ },
153
+ rectify: {
154
+ type: 'boolean',
155
+ description: '生时校正(仅八字):配合 events 使用,把候选时辰逐一排盘并按固定规则对过去事件打分,返回候选排序;用于缩小时辰范围,不替代出生记录。',
156
+ },
157
+ events: {
158
+ type: 'array',
159
+ description: '生时校正用的过去事件:年份 + 类别(career/wealth/love/move/health/other)。',
160
+ items: {
161
+ type: 'object',
162
+ properties: {
163
+ year: { type: 'integer', description: '事件发生的公历年份,如 2019' },
164
+ kind: { type: 'string', enum: ['career', 'wealth', 'love', 'move', 'health', 'other'], description: '事件类别' },
165
+ },
166
+ required: ['year'],
167
+ },
168
+ },
149
169
  ask: {
150
170
  type: 'string',
151
171
  description: '追问:围绕已算好的盘回答「某一年 / 某方面」的问题,如「2027 年事业怎么样」「感情如何」。只取盘面已有字段,不重排、不猜测。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-metaphysics",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "命理排盘 for DeepSeek Harness: eight systems — BaZi four pillars (42 shensha, pattern / useful-god / luck scoring), Zi Wei Dou Shu (34 patterns, 大限, 流年/流月/流日/流时四化), LiuYao with real line rendering, MeiHua YiShu, Da Liu Ren, Qi Men Dun Jia (chaibu / chaoshen / zhirun), compatibility charts (BaZi + Zi Wei) and Tarot; Ask(所问之事 + 询问方向)driven interpretation, plus a plain-language 解析与详解 module. Reproducible via seed/asOf. All computed locally, networking off by default — as an agent tool plus a Web panel.",
6
6
  "keywords": [