Merge remote-tracking branch 'origin/staging' into codex/astrologer-166-rulings
# Conflicts: # docs/BUG_HISTORY.md
This commit is contained in:
@@ -21583,6 +21583,23 @@
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/audit-shadbala-rest-20261104`(未推送)。
|
||||
|
||||
## BUG-1580 | 年纪很大的人问父母,回答仍默认父母健在(服务端直接给一句事实,统一盲评通过后上线)
|
||||
|
||||
- 状态:fixed-pending-verify(staging;真机未看)
|
||||
- 首次发现 / 最近更新:2026-11-04(BUG-1540 回测:80–119 岁的人被劝「趁早沟通」、被写成仍和父母来往)/ 2026-11-04
|
||||
- 影响面:`frontend/src/lib/consultation-evidence-card.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/app/api/consult/route.ts`、`frontend/src/mastra/product-voice.ts`、`frontend/scripts/research/consult-biography-backtest.mts`。
|
||||
- 现象:普通本命咨询里模型看不到出生日期,年纪大的人问父母时,回答照样写和父母现在的来往,或给以父母健在为前提的建议。
|
||||
- 触发条件:本人 70 岁以上,问父母、家庭类问题。
|
||||
- 根因:模型拿不到出生日期,自己无从判断年纪;卡上给了 `age_years`(a957edb9,ac7d25db 撤回)也不用。只给事实时,模型更倾向于遵守口吻里「在不在世盘分不出、不预设」的红线,写「也不说在不在世」。
|
||||
- 修复(产品 2026-11-04:服务端判断,模型不算):本人在请求时(UTC+8 日历日)满 `ELDER_PARENTS_AGE_THRESHOLD = 65` 岁时,卡上 parents / family / general 三个领域段在 label 下加一句 `parents_life_stage`:「本人已 N 岁,父母多半已不在世:不默认父母健在,不写和父母现在的来往,不给以此为前提的建议(如趁早沟通、多陪伴)。」不满 65 岁或没有出生日期时不加。出生日期、时间、地点仍不进模型。VISIBLE VOICE 在传记事实红线后加一句「卡上有 parents_life_stage 时照它写,不当父母还健在」。回测脚本改为读 golden 里的真实出生日期(原写死 1950-01-01)。同批推上 K 按 staging 71d38b0d 重新生成的证据卡、名人与 card-facts 捕获。
|
||||
- 验证:合同测试 6 条(64 / 65 门槛、生日前后、2 月 29 日、没有出生日期、模型视图里没有出生资料、只出现在这三个领域);VISIBLE VOICE 合同测试加一条。统一配对盲评(同一份评分说明,按题配对,基线与 L / M / N 三版各 4 轮 × 53 题 = 212 份,两名评分员重评 84 份,判定一致 93%,bootstrap 10 000 次):本改动(M)忽视年纪 67 → 8(差 −59,95% CI [−93, −29]),卡罗、梦露、泰勒、布什、乔布斯父母题归零;严重 32 → 39(+7,CI [−7, +21],不显著);误报 6 → 9(+3,CI [0, +8],临界);可读性 3.79 → 3.84(不显著)。
|
||||
- 已知副作用(上线后盯):卡罗父亲一侧严重 0 → 4、梦露婚姻题 0 → 4、布什原题误报 +2。先前两轮小样本回测(每版两轮、各自评分员)严重 22–25 对基线 15,按当时规则撤回过一次(788725fb);统一评法下差别不显著,产品 2026-11-04 决定上线。
|
||||
- 未解决(与本改动无关,四版都有):泰勒婚姻题 32 份里 30 份写成「定下来就稳」;梦露、奥巴马缺席父母被写出分量或温度;布什父母题每版 4–7 个误报。
|
||||
- 防复发:服务端给事实时,同时检查口吻里有没有与之冲突的红线;提示词改动用统一配对盲评判,不用不同评分员的小样本对比。
|
||||
- 关联记录:BUG-1540、BUG-1558(a957edb9 / ac7d25db)、BUG-1164、BUG-1165。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/ship-m-golden-20261104`,推 staging。
|
||||
|
||||
## BUG-1581 | 月亮盈亏力(Paksha)亏月取 60 − P:梵文 27.11 把月亮与水、金、木并列取 P,「亏月归凶再取反」只在英译注释里
|
||||
|
||||
- 状态:resolved(引擎 + 回归测试;未部署)
|
||||
|
||||
@@ -25,6 +25,7 @@ Purpose: read this file before substantial project work. It exists to stop repea
|
||||
- 数据核对 J:Sthana / Dig / Drik(worktree `/workspace/wt-j`,`codex/audit-shadbala-rest-20261104`):`pre_work_check.py` exit 0。本仓与 PyJHora 在 19 张盘加乔布斯盘上 Dig 140 / 140「一致」,但两边用的是同一个没折回的函数(PyJHora `_dig_bala`;它自己的 `_dig_bala_another` 是折回的)。这是第二次出现「两家一致 = 两家同错」(BUG-1546 之后)。BUG-1270 已按 BPHS 27.7 和 Raman GBB 例 14–15 修好。同一份经度两次复算,Drik 差了 0.01:求和顺序跟着 PYTHONHASHSEED 变,记为 BUG-1576,已修。两个修复一起重冻为 `audit_j_2026_11_04`。比较前后两次运行时,要固定 PYTHONHASHSEED,或者先确认被比较的函数本身是确定的。教训:对照某个外部引擎时,先查这个引擎有没有同一分量的多种写法,几种都跑一遍,再拿原书的例题核对。Sthana 和 Drik 用 Raman 标准盘例 13、例 55 逐项吻合。jyotishganit 在本机跑需要 Hipparcos 星表,下载被 SSL 拦截,改用 Spica 的 J2000 星表值替代;七星经度与 Lahiri 相差不超过 0.022°。jyotishganit 的上升点比我们晚约 1.08°,Piaf 和 Kahlo 两张盘的上升换了星座,不要把它的 Kendra / Dig 当对照。`jyotish_engine.MOOLATRIKONA` 里月亮写成金牛 0–3°,记为 BUG-1575,未修。全量 Shadbala 相关测试在负载 69 的机器上很慢,基线放在独立的干净 worktree(`/workspace/wt-j-base`)里跑,不占用改动中的 worktree。
|
||||
- 数据核对第二波合并(worktree `/workspace/wt-int2`,`codex/audit-wave2-integration-20261104`,基于 staging 8f15745c):G、H、I、J 依次 `--no-ff` 合并。G / H / J 各自的 `audit_g` / `audit_h` / `audit_j` 冻结记录只对各自分支的代码成立,合并后的代码上哪一份都不是当前记录;合并时合同、FREEZE / REPORT 常量与两份门测试先保持 G 的状态,最后按 ERR-110 只重冻一次为 `audit_wave2_2026_11_04`,合同上一份指回 `moon_paksha_2026_11_04`,三份分支记录逐字节保留为中间记录。与 `moon_paksha_2026_11_04` 逐条逐字段比:密封 20 / 20、报时 900 / 900 全同,`metrics`、`summary` 不变,changed_trial_count 仍为 8 / 581。冲突只在文档(BUG_HISTORY、台账、CHANGELOG 全部保留)、冻结合同与 `run_chara_dasha_compare.py`(G、H 都改了岁差传参,取 H 的写法)。注意:`tests/test_narayana_rath_book_tables.py::test_rath_profile_is_display_only` 在 staging 8f15745c 上就失败(`scripts/annual_service/package.py` 也引用 `narayana_rath`),不是本轮造成。
|
||||
- BUG-1560 D30 + BUG-1570 金星功能凶入算(worktree `/workspace/wt-sc`,`codex/d30-venus-roles-scoring-20261104`,基于 staging 607a48b6;产品 2026-11-04 决定):`pre_work_check.py` exit 0。两项一起升 `rectification-v5-matrix-scoring-13`、记忆化 golden v6(v5 逐字节保留)、跨午夜 scoring-13 golden、dated-transitions scan 重生成,ERR-110 只重冻一次为 `d30_venus_2026_11_04`。对 `audit_wave2_2026_11_04`:密封 3 / 20 条变(头名率 0.50 → 0.45,全部来自 D30),报时 250 / 900 条变(头名命中 46 → 7,全部来自金星;覆盖不变)。归因用 `git archive` 副本各改一项各自冻结回放(冻结记录放副本内、传绝对路径)。教训:①`midnight_date_anchor_regression.py --golden` 先断言 baseline 与当前同日 A/B 不变,分数变化的升版里 baseline 只能传当前树(只证明独立进程可复现),不能传旧树;②记忆化公开请求(1990-01-01 北京)候选分数这次没变,变的是 D30 特征与哈希,「golden 红」不等于「这张盘分数变了」;③两项在同一案例(第 20 例)上有交互,合并结果不是单项变化条数之和(166 + 108 − 15 重叠 ≠ 250),归因要逐条比对。前端捕获 golden(咨询卡、名人回测)仍是改前的金星中性快照,未重生成。
|
||||
- 前端捕获 golden 重生成(worktree `/workspace/wt-k`,`codex/backtest-golden-refresh-20261104`,基于 staging 71d38b0d):`pre_work_check.py` exit 0。`consult-evidence-card-golden.json`、`consult-biography-backtest-golden.json`、`consult-card-facts-20261009.json` 按各自脚本(`JYOTISH_API_CHART_CACHE_TTL_SECONDS=0 PYTHONHASHSEED=0`)重生成。注意:两份捕获 golden 实际是 10-04 / 10-07 的快照,不是「今天改前」;用 `git archive db2fc3fc`(今天引擎修复之前)副本再捕获一次做归因:db2fc3fc → 71d38b0d 的差异全部落在 BUG-1541~1572 / 1270 / 1576 范围(天蝎金星功能凶:泰勒、琵雅芙;卡罗 Vimshottari 第二轮;Narayana 当前运;Chara 双主星 / 小运;瑜伽;Shadbala;D16–D45;婚姻点昼夜公式)。旧 golden → db2fc3fc 的 10 月漂移另有来源、不在今天的修复清单里:BUG-1275 章动进恒星系(岁差 ±0.005°,全部经度、星宿、Vimshottari 起止日差几天、高次分盘个别星座)、aa87c0f1 Argala 平局规则与新增字段、上升星宿字段、`vedastro_cross_check` 由 `official_verified` 变 `blocked`(研究替身现在真正生效;旧值的来源未核实)。前端读这三份 fixture 的 18 个测试文件 118 条中只有 2 条要改(BUG-1570、BUG-1547),没有断言卡在 10 月漂移的值上。另:回测脚本默认不带 `consult_card_facts`(判断表 / participation),产品每次都带;要贴近产品需设 `CONSULT_CARD_FACTS_FIXTURE=<freeze_consult_card_facts_fixture.py --biography-out 的输出>`,设与不设的回测不可直接比较。
|
||||
|
||||
## 2026-11-03 · 输入框单行起步(方案 2,worktree `/workspace/wt-composer`)
|
||||
|
||||
|
||||
@@ -243,7 +243,8 @@ function serverChart(figure: GoldenFigure): ServerChartConsultation {
|
||||
name: "backtest",
|
||||
toolInput,
|
||||
truth: {
|
||||
birthDate: "1950-01-01",
|
||||
// BUG-1580: the card's parents_life_stage reads the age from this date; the model never sees the date itself.
|
||||
birthDate: typeof birth.date === "string" ? birth.date : "1950-01-01",
|
||||
reportedBirthTime: null,
|
||||
activeBirthTime: null,
|
||||
selectedTimeKind: "reported",
|
||||
@@ -308,6 +309,7 @@ function agentContext(job: Job, state: ConsultationRuntimeState, executed: strin
|
||||
theme: job.domain as ConsultationDomain,
|
||||
followUpTurn: Boolean(job.followUp),
|
||||
serverChart: serverChart(job.figure),
|
||||
requestTime: REQUEST_TIME,
|
||||
state,
|
||||
runWorkflow: stubRunWorkflow(job.figure, executed) as never,
|
||||
});
|
||||
|
||||
@@ -1561,6 +1561,7 @@ export async function POST(request: Request) {
|
||||
abortSignal: agentAbortSignal,
|
||||
state,
|
||||
rectificationOffer,
|
||||
requestTime,
|
||||
});
|
||||
const agent = getJyotishAgent(selectedModel, agentContext);
|
||||
const result = await streamWithOverflowRetry(agent, natalStreamOptions);
|
||||
|
||||
@@ -29,7 +29,10 @@ import { recordOrEmpty as record } from "@/lib/guards";
|
||||
* card-authored strings are `EVIDENCE_CARD_LABELS` (and domain ids that point
|
||||
* at another section); selections (which houses, which conclusions) are made
|
||||
* here, values never. One label is read from a value: each domain house row's
|
||||
* `sav_strength` (强 / 中 / 弱 from the engine's raw SAV, savStrength).
|
||||
* `sav_strength` (强 / 中 / 弱 from the engine's raw SAV, savStrength). One
|
||||
* sentence is computed from the profile, not the engine: `parents_life_stage`
|
||||
* (BUG-1580, evidenceCardParentsLifeStage), and only the age in it reaches
|
||||
* the model, never the birth date.
|
||||
*
|
||||
* Pure functions, no React.
|
||||
*/
|
||||
@@ -120,6 +123,8 @@ export type EvidenceCardDomainSpec = Readonly<{
|
||||
vivahSaham: boolean;
|
||||
/** The section carries the current subject's gender (「性别未知」 when not filled). */
|
||||
gender: boolean;
|
||||
/** BUG-1580: the section carries `parents_life_stage` when the native is elderly. */
|
||||
parentsLifeStage: boolean;
|
||||
/** The annual Tajika chart (annual route only). */
|
||||
annualChart: boolean;
|
||||
/** Houses of the running and next AD lords and of the year's Jupiter / Saturn transits. */
|
||||
@@ -164,6 +169,7 @@ const spec = (value: Partial<EvidenceCardDomainSpec> & Pick<EvidenceCardDomainSp
|
||||
dayNight: false,
|
||||
vivahSaham: false,
|
||||
gender: false,
|
||||
parentsLifeStage: false,
|
||||
annualChart: false,
|
||||
annualHouses: false,
|
||||
followVargas: false,
|
||||
@@ -207,9 +213,9 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
|
||||
health: spec({ vargas: ["D6", "D8", "D30"], houses: [1, 6, 8, 12], transits: true, shadbala: true }),
|
||||
education: spec({ vargas: ["D5", "D24"], houses: [4, 5, 9], planets: ["Mercury", "Jupiter"] }),
|
||||
migration: spec({ vargas: ["D4", "D12"], houses: [4, 12] }),
|
||||
family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9] }),
|
||||
family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9], parentsLifeStage: true }),
|
||||
parents: spec({
|
||||
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"], yogas: true, yogaTags: ["parents"],
|
||||
vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"], karakas: ["MK"], yogas: true, yogaTags: ["parents"], parentsLifeStage: true,
|
||||
roles: [
|
||||
{ key: "mother", label: EVIDENCE_CARD_LABELS.roleMother, house: 4, planets: ["Moon"], karakas: ["MK"], optionalKarakas: [], varga: "D12" },
|
||||
{ key: "father", label: EVIDENCE_CARD_LABELS.roleFather, house: 9, planets: ["Sun"], karakas: [], optionalKarakas: ["PiK"], varga: "D12" },
|
||||
@@ -223,7 +229,7 @@ export const EVIDENCE_CARD_SPECS: Readonly<Record<ConsultationDomain, EvidenceCa
|
||||
}),
|
||||
general: spec({
|
||||
vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, yogaTags: ["general"], traditionalYogas: true, transits: true,
|
||||
ashtakavarga: true, shadbala: true,
|
||||
ashtakavarga: true, shadbala: true, parentsLifeStage: true,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -319,6 +325,8 @@ export type EvidenceCardDomainSection = {
|
||||
vargas_follow?: ConsultationDomain;
|
||||
/** Per-person index into this section (parents: mother / father). */
|
||||
roles?: JsonRecord;
|
||||
/** BUG-1580: an elderly native's parents are probably no longer living (evidenceCardParentsLifeStage). */
|
||||
parents_life_stage?: string;
|
||||
/**
|
||||
* Present only when this domain's own base differs from the card's base
|
||||
* (the first domain's). The same chart gives the same base; a difference is
|
||||
@@ -378,8 +386,52 @@ export function evidenceCardGenderLabel(gender: ProfileGender | null | undefined
|
||||
export type EvidenceCardOptions = Readonly<{
|
||||
/** The current subject's optional gender; null / undefined = not filled. */
|
||||
gender?: ProfileGender | null;
|
||||
/**
|
||||
* BUG-1580: the native's age in whole years at the request time
|
||||
* (`evidenceCardAgeYears`). The card never carries it as a field; at
|
||||
* ELDER_PARENTS_AGE_THRESHOLD or above it only picks the
|
||||
* `parents_life_stage` sentence. null / undefined = unknown, nothing added.
|
||||
*/
|
||||
ageYears?: number | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* BUG-1580: from this age on, the parents / family / general sections say the
|
||||
* native's parents are probably no longer living. Parents are typically 25–35
|
||||
* years older than their child, so at 65 they would be about 90–100. Tunable.
|
||||
*/
|
||||
export const ELDER_PARENTS_AGE_THRESHOLD = 65;
|
||||
|
||||
/**
|
||||
* BUG-1580: the native's current age in whole years, from the profile's birth
|
||||
* date (YYYY-MM-DD, local calendar date) and the request's authoritative
|
||||
* server time read as a China Standard Time (UTC+8) calendar date, the same
|
||||
* date the user turn's 「服务端当前时间」 carries. Born on 29 February: the
|
||||
* birthday counts from 1 March in other years. null when the date is missing,
|
||||
* not a real date, or after the request.
|
||||
*/
|
||||
export function evidenceCardAgeYears(birthDate: string | null | undefined, at: Date): number | null {
|
||||
const match = typeof birthDate === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate) : null;
|
||||
if (!match || Number.isNaN(at.getTime())) return null;
|
||||
const [year, month, day] = match.slice(1).map(Number) as [number, number, number];
|
||||
const born = new Date(Date.UTC(year, month - 1, day));
|
||||
if (born.getUTCFullYear() !== year || born.getUTCMonth() !== month - 1 || born.getUTCDate() !== day) return null;
|
||||
const today = new Date(at.getTime() + 8 * 60 * 60 * 1000);
|
||||
const [ty, tm, td] = [today.getUTCFullYear(), today.getUTCMonth() + 1, today.getUTCDate()];
|
||||
const age = ty - year - (tm < month || (tm === month && td < day) ? 1 : 0);
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG-1580: the plain fact an elderly native's sections carry, or null below
|
||||
* the threshold / when the age is unknown. The model is never asked to work
|
||||
* the age out; it reads this sentence.
|
||||
*/
|
||||
export function evidenceCardParentsLifeStage(ageYears: number | null | undefined): string | null {
|
||||
if (typeof ageYears !== "number" || !Number.isFinite(ageYears) || ageYears < ELDER_PARENTS_AGE_THRESHOLD) return null;
|
||||
return `本人已 ${ageYears} 岁,父母多半已不在世:不默认父母健在,不写和父母现在的来往,不给以此为前提的建议(如趁早沟通、多陪伴)。`;
|
||||
}
|
||||
|
||||
/** One domain's inputs: the projected packet and the agent context behind it. */
|
||||
export type EvidenceCardSource = Readonly<{
|
||||
domain: ConsultationDomain;
|
||||
@@ -702,8 +754,11 @@ function domainSection(
|
||||
const localLayers = record(context.local_layers);
|
||||
const chart = record(context.chart);
|
||||
const formal = record(record(natal.varga_spectrum).formal);
|
||||
const lifeStage = rule.parentsLifeStage ? evidenceCardParentsLifeStage(options.ageYears) : null;
|
||||
const section: EvidenceCardDomainSection = {
|
||||
label: consultationDomainDefinition(domain).label,
|
||||
// BUG-1580: right under the label, before any chart data.
|
||||
...(lifeStage ? { parents_life_stage: lifeStage } : {}),
|
||||
vargas: {},
|
||||
houses: {},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import { CONSULTATION_QUEUE_LABEL, retryOnConsultationRateLimit } from "../lib/c
|
||||
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
|
||||
import {
|
||||
buildEvidenceCard,
|
||||
evidenceCardAgeYears,
|
||||
citedEvidenceCardFields,
|
||||
EVIDENCE_LOOKUP_SECTIONS,
|
||||
evidenceCardTokenEstimate,
|
||||
@@ -600,6 +601,8 @@ export type ConsultationAgentContext = Readonly<{
|
||||
// The domain loop budgets itself against this clock, so tests can drive the
|
||||
// deadline without waiting for it.
|
||||
now?: () => number;
|
||||
/** BUG-1580: the request's authoritative server time (the user turn's 服务端当前时间); the native's age is read at it. */
|
||||
requestTime?: Date;
|
||||
}>;
|
||||
|
||||
export type WindowConsultationCalculationCache = {
|
||||
@@ -1505,7 +1508,11 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
domain: execution.domain,
|
||||
packet: execution.modelOutput,
|
||||
context: execution.context,
|
||||
})), { gender: subjectGender });
|
||||
})), {
|
||||
gender: subjectGender,
|
||||
// BUG-1580: only the age reaches the model (in parents_life_stage); the birth date stays on the server.
|
||||
ageYears: evidenceCardAgeYears(ctx.serverChart.truth?.birthDate, ctx.requestTime ?? new Date()),
|
||||
});
|
||||
const modelView = toModelEvidenceView(modelContext, card);
|
||||
ctx.state.evidenceCard = card;
|
||||
ctx.state.evidenceCardChars = JSON.stringify({
|
||||
|
||||
@@ -23,7 +23,7 @@ export const productConversationVoice = `VISIBLE VOICE
|
||||
|
||||
红线(这几条之外靠你的判断):
|
||||
- 只用卡上和补取到的盘面事实,不编位置、不编时间;不允许精确应期时不写月份。分析清单(受冲信号的编号和条数、层名、规则原句)只用来判断,不念给用户;不写「从某宫数起第几位」这类数法,只说结论(「这颗星落在对这个宫不利的位置」)。
|
||||
- 用户自己更清楚的传记事实——有没有伴侣、是否已婚、几段感情、父母在不在身边、在不在世、有没有病、在不在上班、做哪一行、孩子性别——盘分不出,不预设、不替他断定;性别未知时不用「他」「她」称伴侣和孩子。某个人这条线压力重时,盘分不出这是聚少离多还是人在但相处吃力:用自己的话给出这个范围,全篇只说一次(几个人都重时合成一句),不套固定句式;这一侧不写这个人实际怎么对你——照顾、管、在意、有温度、有分量都不写,「善意是存在的」「这条线不是空的」这类安慰也不写;可以写你从这一侧带走的东西。这一侧压力不重时才写他的方式。只有回答确实取决于这类事实时,才问一句,全篇最多一次。
|
||||
- 用户自己更清楚的传记事实——有没有伴侣、是否已婚、几段感情、父母在不在身边、在不在世、有没有病、在不在上班、做哪一行、孩子性别——盘分不出,不预设、不替他断定;卡上有 parents_life_stage 时照它写,不当父母还健在;性别未知时不用「他」「她」称伴侣和孩子。某个人这条线压力重时,盘分不出这是聚少离多还是人在但相处吃力:用自己的话给出这个范围,全篇只说一次(几个人都重时合成一句),不套固定句式;这一侧不写这个人实际怎么对你——照顾、管、在意、有温度、有分量都不写,「善意是存在的」「这条线不是空的」这类安慰也不写;可以写你从这一侧带走的东西。这一侧压力不重时才写他的方式。只有回答确实取决于这类事实时,才问一句,全篇最多一次。
|
||||
- 不断言死亡、离婚、疾病诊断、流产、不孕;不给医疗、法律、投资指令。
|
||||
- 不起「外热内冷」这类四字格局名,不把大运写成推着谁、替谁收尾的角色,空宫不单独下结论(说它的宫主落在哪)。
|
||||
- 不以「根据计算结果」「综合来看」「从星盘来看」开头;不写内部字段、英文状态码、JSON、技法审计表、统一参数。
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// BUG-1580: answers presumed living parents for natives aged 80–119 (BUG-1540
|
||||
// backtests). Asking the model to work out the age failed twice (a voice clause
|
||||
// with no birth date; an age_years field it ignored). The server now decides:
|
||||
// at ELDER_PARENTS_AGE_THRESHOLD or above, the parents / family / general
|
||||
// sections carry one plain sentence with the age; below it, nothing. The birth
|
||||
// date, time and place never reach the model.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildEvidenceCard,
|
||||
EVIDENCE_CARD_SPECS,
|
||||
ELDER_PARENTS_AGE_THRESHOLD,
|
||||
evidenceCardAgeYears,
|
||||
evidenceCardParentsLifeStage,
|
||||
} from "../src/lib/consultation-evidence-card.ts";
|
||||
import type { ConsultationDomain } from "../src/lib/consultation-domain-registry.ts";
|
||||
import { createConsultationRuntimeState, createConsultationTools } from "../src/mastra/consultation-tools.ts";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const golden = JSON.parse(readFileSync(
|
||||
new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as { charts: { id: string; workflow: Json }[] };
|
||||
|
||||
const AT = new Date("2026-09-27T04:00:00.000Z");
|
||||
|
||||
test("threshold is 65 and the sentence carries the age; 64 gets nothing", () => {
|
||||
assert.equal(ELDER_PARENTS_AGE_THRESHOLD, 65);
|
||||
assert.equal(evidenceCardParentsLifeStage(64), null);
|
||||
assert.equal(
|
||||
evidenceCardParentsLifeStage(65),
|
||||
"本人已 65 岁,父母多半已不在世:不默认父母健在,不写和父母现在的来往,不给以此为前提的建议(如趁早沟通、多陪伴)。",
|
||||
);
|
||||
assert.match(evidenceCardParentsLifeStage(110)!, /^本人已 110 岁,/);
|
||||
for (const unknown of [null, undefined, Number.NaN]) assert.equal(evidenceCardParentsLifeStage(unknown), null);
|
||||
});
|
||||
|
||||
test("age is whole years at the request's China calendar date, around the birthday", () => {
|
||||
// 2026-07-05 23:59:59 in UTC+8 is the day before the 65th birthday; 00:00 on 07-06 is the birthday.
|
||||
assert.equal(evidenceCardAgeYears("1961-07-06", new Date("2026-07-05T15:59:59.000Z")), 64);
|
||||
assert.equal(evidenceCardAgeYears("1961-07-06", new Date("2026-07-05T16:00:00.000Z")), 65);
|
||||
// The year turns over in UTC+8 before it does in UTC.
|
||||
assert.equal(evidenceCardAgeYears("2000-01-01", new Date("2025-12-31T16:00:00.000Z")), 26);
|
||||
assert.equal(evidenceCardAgeYears("2000-01-01", new Date("2025-12-31T15:59:59.000Z")), 25);
|
||||
// Born on 29 February: the birthday counts from 1 March in other years.
|
||||
assert.equal(evidenceCardAgeYears("2004-02-29", new Date("2026-02-28T04:00:00.000Z")), 21);
|
||||
assert.equal(evidenceCardAgeYears("2004-02-29", new Date("2026-03-01T04:00:00.000Z")), 22);
|
||||
// The backtest figures at its request time.
|
||||
assert.equal(evidenceCardAgeYears("1946-07-06", AT), 80);
|
||||
assert.equal(evidenceCardAgeYears("1907-07-06", AT), 119);
|
||||
for (const missing of [null, undefined, "", "1946-02-30", "1946/07/06", "2030-01-01"]) {
|
||||
assert.equal(evidenceCardAgeYears(missing, AT), null, String(missing));
|
||||
}
|
||||
});
|
||||
|
||||
const empty = (domain: ConsultationDomain) => ({ domain, packet: { claim_cards: [] }, context: {} });
|
||||
|
||||
test("card: the sentence sits right under the label, only in parents / family / general", () => {
|
||||
const intended = new Set<ConsultationDomain>(["parents", "family", "general"]);
|
||||
for (const domain of Object.keys(EVIDENCE_CARD_SPECS) as ConsultationDomain[]) {
|
||||
const card = buildEvidenceCard([empty(domain)], { ageYears: 80 });
|
||||
const section = card.domain_sections[domain]!;
|
||||
if (intended.has(domain)) {
|
||||
assert.equal(Object.keys(section)[1], "parents_life_stage", domain);
|
||||
assert.match(section.parents_life_stage!, /^本人已 80 岁,父母多半已不在世/);
|
||||
} else {
|
||||
assert.equal("parents_life_stage" in section, false, domain);
|
||||
}
|
||||
}
|
||||
// No other part of the card carries the age or the sentence.
|
||||
const card = buildEvidenceCard([empty("parents"), empty("career")], { ageYears: 80 });
|
||||
assert.equal(JSON.stringify(card.base).includes("80"), false);
|
||||
assert.equal("parents_life_stage" in card.domain_sections.career!, false);
|
||||
assert.equal(card.meta.gaps.some((gap) => gap.includes("age") || gap.includes("life_stage")), false);
|
||||
});
|
||||
|
||||
test("card: under 65, unknown age or no option -> no field and no gap", () => {
|
||||
for (const options of [{ ageYears: 64 }, { ageYears: null }, {}]) {
|
||||
const card = buildEvidenceCard([empty("parents"), empty("general")], options);
|
||||
assert.equal("parents_life_stage" in card.domain_sections.parents!, false, JSON.stringify(options));
|
||||
assert.equal("parents_life_stage" in card.domain_sections.general!, false, JSON.stringify(options));
|
||||
assert.equal(card.meta.gaps.some((gap) => gap.includes("age") || gap.includes("life_stage")), false);
|
||||
}
|
||||
});
|
||||
|
||||
async function modelView(truth: Json, requestTime: Date, domains: ConsultationDomain[] = ["parents"]) {
|
||||
const chart = golden.charts[0]!;
|
||||
const tools = createConsultationTools({
|
||||
userId: "u", sessionId: "s", requestId: "elder", consultationMode: "verified_chart",
|
||||
serverChart: {
|
||||
name: "public",
|
||||
toolInput: { year: 1946, month: 7, day: 6, hour: 7, minute: 26, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" },
|
||||
truth,
|
||||
} as never,
|
||||
state: createConsultationRuntimeState(),
|
||||
runWorkflow: async () => structuredClone(chart.workflow) as never,
|
||||
requestTime,
|
||||
});
|
||||
return await tools["run-jyotish-consultation"].execute!(
|
||||
{ question: "q", domains } as never,
|
||||
{ writer: { custom: async () => {} } } as never,
|
||||
) as Json;
|
||||
}
|
||||
|
||||
function domainSections(view: Json) {
|
||||
return (view.claim_cards as { category: string; evidence: Json }[])
|
||||
.find((card) => card.category === "domain")!.evidence as Record<string, Json>;
|
||||
}
|
||||
|
||||
test("model view: an 80-year-old's parents section says so; no birth date, time or place reaches the model", async () => {
|
||||
const view = await modelView({
|
||||
birthDate: "1946-07-06",
|
||||
reportedBirthTime: "07:26",
|
||||
placeLabel: "New Haven, CT",
|
||||
birthTimeSource: "reported",
|
||||
}, AT);
|
||||
assert.match(String(domainSections(view).parents!.parents_life_stage), /^本人已 80 岁,/);
|
||||
const text = JSON.stringify(view);
|
||||
for (const leak of ["1946-07-06", "1946", "07:26", "New Haven", "birth_date", "birthDate", "age_years"]) {
|
||||
assert.equal(text.includes(leak), false, `model view carries ${leak}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("model view: a day before the 65th birthday nothing; on it the sentence; no birth date -> nothing", async () => {
|
||||
const truth = { birthDate: "1961-09-27", birthTimeSource: "reported" };
|
||||
const before = await modelView(truth, new Date("2026-09-26T15:59:59.000Z"));
|
||||
assert.equal("parents_life_stage" in domainSections(before).parents!, false);
|
||||
const on = await modelView(truth, new Date("2026-09-26T16:00:00.000Z"));
|
||||
assert.match(String(domainSections(on).parents!.parents_life_stage), /^本人已 65 岁,/);
|
||||
const missing = await modelView({ birthTimeSource: "reported" }, AT);
|
||||
assert.equal("parents_life_stage" in domainSections(missing).parents!, false);
|
||||
assert.equal(JSON.stringify(missing).includes("本人已"), false);
|
||||
});
|
||||
@@ -42,6 +42,16 @@ test("the natal layers are projected value for value: D9 summary, Punarphoo, Viv
|
||||
const natal = evidence(chart.workflow, "marriage", "natal_foundation");
|
||||
for (const name of ["d9_summary", "punarphoo", "vivah_saham", "day_night"]) {
|
||||
assert.equal((engine[name] as Json).status === "blocked", false, `${chart.id} ${name} executed`);
|
||||
// 原值:vivah_saham 与引擎逐字段相等(引擎没有 formula_variant)
|
||||
// 新值:引擎多出 formula_variant("day" / "night"),投影的白名单没有它;其余字段仍逐字段相等,formula_variant 不进投影
|
||||
// 原因:BUG-1547。本命婚姻点改用共享的昼夜公式,引擎随值写出所用公式;捕获 golden 2026-11-04 重生成。是否把它给模型另议
|
||||
if (name === "vivah_saham") {
|
||||
const { formula_variant: variant, ...projected } = engine[name] as Json;
|
||||
assert.ok(variant === "day" || variant === "night", `${chart.id} vivah_saham formula_variant`);
|
||||
assert.equal("formula_variant" in (natal[name] as Json), false, `${chart.id} vivah_saham formula_variant not projected`);
|
||||
assert.deepEqual(natal[name], projected, `${chart.id} ${name}`);
|
||||
continue;
|
||||
}
|
||||
assert.deepEqual(natal[name], engine[name], `${chart.id} ${name}`);
|
||||
}
|
||||
const d9 = natal.d9_summary as Json;
|
||||
|
||||
@@ -42,12 +42,15 @@ test("goldens carry functional profile v2 from the real engine (BUG-1158)", () =
|
||||
];
|
||||
assert.equal(layers.length, 12);
|
||||
for (const layer of layers) assert.equal(layer.profile, "bphs_ch34_general_with_sign_exceptions_v2");
|
||||
// Scorpio (Taylor): explicit exceptions Mars mixed, Sun yoga-supporting; Venus (7/12) no longer malefic by the 12th alone.
|
||||
// Scorpio (Taylor): explicit exceptions Mars mixed, Sun yoga-supporting.
|
||||
// 原值:functional_malefics ["Mercury", "Saturn"],functional_neutrals ["Mars", "Venus"](金星 7 / 12 宫主不因 12 宫单独判凶)
|
||||
// 新值:functional_malefics ["Mercury", "Saturn", "Venus"],functional_neutrals ["Mars"]
|
||||
// 原因:BUG-1570。BPHS 34.35 明文:天蝎上升的金星为凶;引擎补上这条例外,捕获 golden 2026-11-04 重生成
|
||||
const taylor = card(golden.charts.find((chart) => chart.id === "elizabeth_taylor")!.workflow, "career");
|
||||
const functional = taylor.base.natal.functional_benefic_malefic as Json;
|
||||
assert.deepEqual(functional.functional_benefics, ["Jupiter", "Moon", "Sun"]);
|
||||
assert.deepEqual(functional.functional_malefics, ["Mercury", "Saturn"]);
|
||||
assert.deepEqual(functional.functional_neutrals, ["Mars", "Venus"]);
|
||||
assert.deepEqual(functional.functional_malefics, ["Mercury", "Saturn", "Venus"]);
|
||||
assert.deepEqual(functional.functional_neutrals, ["Mars"]);
|
||||
assert.deepEqual(functional.yogakarakas, []);
|
||||
// Virgo (Jobs): Jupiter challenging by exception, Sun (sole 12th lord) neutral.
|
||||
const jobs = card(golden.charts.find((chart) => chart.id === "steve_jobs")!.workflow, "parents");
|
||||
|
||||
@@ -73,6 +73,11 @@ test("the red lines the old rules guarded are all still in VISIBLE VOICE (BUG-15
|
||||
assert.match(redLines, /分析清单(受冲信号的编号和条数、层名、规则原句)只用来判断,不念给用户;不写「从某宫数起第几位」这类数法/);
|
||||
// BUG-1164 / BUG-1182: never decide a biographical fact the user knows better, never the opposite reassurance.
|
||||
assert.match(redLines, /有没有伴侣、是否已婚、几段感情、父母在不在身边、在不在世、有没有病、在不在上班、做哪一行、孩子性别——盘分不出,不预设、不替他断定/);
|
||||
// 原值: 传记事实红线只说「父母在不在身边、在不在世……盘分不出,不预设、不替他断定」
|
||||
// 新值: 同一句后接一个分句「卡上有 parents_life_stage 时照它写,不当父母还健在」
|
||||
// 原因: BUG-1580(2026-11-04)卡上给 65 岁以上的人加了服务端写好的一句(本人已 N 岁,父母多半已不在世);
|
||||
// 只加字段的两轮回测里模型照「在不在世盘分不出」写「也不说在不在世」,106 份里只有 3 份顾及年纪,红线与卡上事实打架,只加这一句指过去
|
||||
assert.match(redLines, /不预设、不替他断定;卡上有 parents_life_stage 时照它写,不当父母还健在;性别未知时/);
|
||||
assert.match(redLines, /性别未知时不用「他」「她」称伴侣和孩子/);
|
||||
assert.match(redLines, /某个人这条线压力重时,盘分不出这是聚少离多还是人在但相处吃力:用自己的话给出这个范围,全篇只说一次(几个人都重时合成一句),不套固定句式/);
|
||||
assert.match(redLines, /照顾、管、在意、有温度、有分量都不写,「善意是存在的」「这条线不是空的」这类安慰也不写/);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user