fix(rectification): scan chart boundaries in the user's ayanamsa (BUG-1307, BUG-1309)
This commit is contained in:
+1
-1
@@ -66,7 +66,7 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J
|
||||
|
||||
开场一轮只有两段字:正文讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。
|
||||
|
||||
2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。没有新组件、没有新动效。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。
|
||||
2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。本命上升也换时,同一句先说「这里连本命上升都换」,再写其余会变的事;没有其余的事时,这一句就停在「这里连本命上升都换。」,不另起一句。没有新组件、没有新动效。句数上限仍是 3。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。只有本命上升在换时也不说这句。
|
||||
|
||||
> 2026-10-01 修订:校正回复结算后不再显示步骤回执(BUG-1137),本段只适用于写作中的步骤行。
|
||||
|
||||
|
||||
@@ -138,11 +138,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
开场可以在这两句中间再加一句(2026-10-12,BUG-1307)。句数上限仍是 3,没有放宽。这一句由服务端按扫描结果原样放进去,模型不现编:
|
||||
|
||||
- 记录了钟点,而且这段时间里有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 不足 1 分钟写「不到 1 分钟」,满 1 分钟写「差 N 分钟」。两边只用这些词:婚恋、工作、住处、学业、子女、父母、钱、身体。没有对应的事,就不写「两边不一样」。
|
||||
- 本命上升也在这次换段里:同一句先写「这里连本命上升都换」,再写两边不一样的事。例如「你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。」没有别的事时是「你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。」仍是一句,不另起一句。只有本命上升在换时,不说「接下来先问这几件」。
|
||||
- 选题开关默认关。关着的时候这一句到句号为止,不说「接下来先问这几件」。开关打开、离得近、又有这几件事时,句号才换成「,接下来先问这几件。」
|
||||
- 这段时间里都不换:`你填的时间附近这些盘都不换,先从经历开始。`
|
||||
- 只知道大概时段,或扫描没做成:不加这一句,仍是原来两句。
|
||||
|
||||
这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认。
|
||||
这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、D1。
|
||||
|
||||
回答上方的步骤名(完成 / 进行中)一律大白话,同一个名字只出现一次:
|
||||
|
||||
@@ -309,7 +310,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 坏 | 好 | 为什么 |
|
||||
|---|---|---|
|
||||
| Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问(BUG-1307)。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问。本命上升也换时,这一句先写「这里连本命上升都换」,再写其余的事;只有本命上升在换时仍不说接下来先问(BUG-1307)。 |
|
||||
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
|
||||
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
|
||||
| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 |
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Opening scan for the nearest chart change. Failures keep the original
|
||||
* two-sentence opening. Question order stays on its own switch.
|
||||
*/
|
||||
import { resolveAyanamsa } from "../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "./core/candidate-window.ts";
|
||||
import {
|
||||
CHART_LAYERS,
|
||||
@@ -25,18 +26,22 @@ export function openingFactsFromBoundaryPayload(
|
||||
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
const layers = Array.isArray(row.layers)
|
||||
? row.layers.flatMap((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return typeof layer === "string" && LAYERS.has(layer) ? [layer] : [];
|
||||
})
|
||||
: [];
|
||||
const rawLayers = Array.isArray(row.layers) ? row.layers : [];
|
||||
const layers = rawLayers.flatMap((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return typeof layer === "string" && LAYERS.has(layer) ? [layer] : [];
|
||||
});
|
||||
const d1Changed = rawLayers.some((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return layer === "d1";
|
||||
});
|
||||
const topics = topicsForLayers(layers);
|
||||
const at = (payload as { at?: unknown }).at;
|
||||
return {
|
||||
kind: "near",
|
||||
distanceMinutes: distance,
|
||||
topics,
|
||||
d1Changed,
|
||||
...(typeof at === "string" ? { at: at.slice(0, 5) } : {}),
|
||||
askFirst: askFirst && topics.length > 0 && distance <= NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
};
|
||||
@@ -83,6 +88,7 @@ export async function fetchOpeningBoundary(input: {
|
||||
target_charts: ["D1"],
|
||||
boundary_charts: BOUNDARY_CHARTS,
|
||||
recorded_time: recorded,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return { kind: "unavailable" };
|
||||
|
||||
@@ -78,12 +78,15 @@ export function withFirstDatedCollectInvite(stem: string): string {
|
||||
* right below it, so the body does not list them again.
|
||||
*
|
||||
* BUG-1307: when the server already measured a chart change, one extra sentence
|
||||
* sits between those two. The 3-sentence cap is unchanged.
|
||||
* sits between those two. The 3-sentence cap is unchanged. A natal-ascendant
|
||||
* change is named in that same sentence, before the other life topics.
|
||||
*/
|
||||
export type OpeningBoundaryFacts = Readonly<{
|
||||
kind: "period_only" | "unavailable" | "stable" | "near";
|
||||
distanceMinutes?: number | null;
|
||||
topics?: readonly string[];
|
||||
/** Natal ascendant (layer d1) changed. Not a spoken life topic. */
|
||||
d1Changed?: boolean;
|
||||
/** Only when the question-order switch is on and this boundary is inside it. */
|
||||
askFirst?: boolean;
|
||||
}>;
|
||||
@@ -100,9 +103,10 @@ export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | und
|
||||
if ((OPENING_BOUNDARY_TOPICS as readonly string[]).includes(topic) && !topics.includes(topic)) topics.push(topic);
|
||||
}
|
||||
const place = distance < 1 ? "不到 1 分钟" : `差 ${distance} 分钟`;
|
||||
const natal = facts.d1Changed ? ",这里连本命上升都换" : "";
|
||||
const sides = topics.length ? `,两边不一样的是${topics.join("、")}` : "";
|
||||
const tail = facts.askFirst && topics.length ? ",接下来先问这几件。" : "。";
|
||||
return `你填的时间离最近会换的地方${place}${sides}${tail}`;
|
||||
return `你填的时间离最近会换的地方${place}${natal}${sides}${tail}`;
|
||||
}
|
||||
|
||||
export function openingSpokenBody(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAyanamsa } from "../../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts";
|
||||
import type { SegmentMinute, TargetChart } from "../core/segment-summary.ts";
|
||||
import { parseSegmentMinutes } from "./segment-contract.ts";
|
||||
@@ -15,7 +16,8 @@ export async function scanCaseSegments(input: {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({ start_at: interval.start_at, end_at: interval.end_at,
|
||||
latitude: input.snapshot.latitude, longitude: input.snapshot.longitude,
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets }),
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot) }),
|
||||
});
|
||||
if (!response.ok) throw new Error("segment_scan_unavailable");
|
||||
const data = await response.json();
|
||||
@@ -23,7 +25,7 @@ export async function scanCaseSegments(input: {
|
||||
const start = Date.parse(`${interval.start_at}:00Z`);
|
||||
const expectedWidth = (Date.parse(`${interval.end_at}:00Z`) - start) / 60000 + 1;
|
||||
if (!minutes || data.start_at !== interval.start_at || data.end_at !== interval.end_at
|
||||
|| data.ayanamsa !== "raman" || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.ayanamsa !== resolveAyanamsa(input.snapshot) || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.window_minutes !== expectedWidth || minutes.length !== expectedWidth
|
||||
|| minutes.some((minute, index) => minute.offset !== index
|
||||
|| `${minute.date}T${minute.time}` !== new Date(start + index * 60000).toISOString().slice(0, 16)
|
||||
|
||||
@@ -8,7 +8,8 @@ import test from "node:test";
|
||||
import { buildOpeningBrief } from "../src/lib/rectification-agentic/v9/agent-run-messages.ts";
|
||||
import { orderDiscriminatorsForNearestBoundary } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts";
|
||||
import { fetchOpeningBoundary, openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts";
|
||||
import { scanCaseSegments } from "../src/lib/rectification-agentic/v9/segment-scan.ts";
|
||||
import {
|
||||
nearestBoundaryFromTransitions,
|
||||
nearestBoundaryProbesEnabled,
|
||||
@@ -61,6 +62,55 @@ test("near, under a minute, stable, and only the birth chart", () => {
|
||||
assert.equal(isAcceptableOpeningBody(`${near}再补一句。`, RANGE), false);
|
||||
});
|
||||
|
||||
test("d1 change is named in the same sentence, and no d1 change keeps today's sentence", () => {
|
||||
const changed = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d1" }, { layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(changed.d1Changed, true);
|
||||
assert.deepEqual(changed.topics, ["工作", "父母"]);
|
||||
assert.equal(openingBoundarySentence(changed), "你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。");
|
||||
const onlyBirth = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 0,
|
||||
layers: [{ layer: "d1" }],
|
||||
}, true);
|
||||
assert.equal(onlyBirth.d1Changed, true);
|
||||
assert.deepEqual(onlyBirth.topics, []);
|
||||
assert.equal(onlyBirth.askFirst, false);
|
||||
assert.equal(openingBoundarySentence(onlyBirth), "你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: [], d1Changed: true, askFirst: true }),
|
||||
"你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换。",
|
||||
);
|
||||
const asked = openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true });
|
||||
assert.equal(asked, "你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换,两边不一样的是婚恋,接下来先问这几件。");
|
||||
const unchanged = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(unchanged.d1Changed, false);
|
||||
assert.equal(openingBoundarySentence(unchanged), "你填的时间离最近会换的地方差 4 分钟,两边不一样的是工作、父母。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 0, topics: ["婚恋"] }),
|
||||
"你填的时间离最近会换的地方不到 1 分钟,两边不一样的是婚恋。",
|
||||
);
|
||||
assert.equal(openingBoundarySentence({ kind: "stable" }), "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
|
||||
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
|
||||
const askedFacts = { kind: "near" as const, distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true };
|
||||
for (const facts of [changed, onlyBirth, askedFacts, unchanged]) {
|
||||
const text = openingSpokenBody(RANGE, null, facts);
|
||||
assertPlain(text);
|
||||
assert.equal(isAcceptableOpeningBody(text, RANGE, null, facts), true);
|
||||
assert.equal(text.includes("D1"), false);
|
||||
assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 3);
|
||||
}
|
||||
assert.match(openingSpokenBody(RANGE, null, changed), /这里连本命上升都换,两边不一样的是工作、父母。/);
|
||||
});
|
||||
|
||||
test("topics come from the layer allowlist, not the payload string", () => {
|
||||
assert.deepEqual(topicsForLayers(["d12", "d10", "d1", "kp1", "nope"]), ["工作", "父母"]);
|
||||
const facts = openingFactsFromBoundaryPayload({
|
||||
@@ -136,3 +186,51 @@ test("period_only and a missing clock do not scan", async () => {
|
||||
assert.match(withSentence, /原样放在这两句之间/);
|
||||
assert.match(brief, /正文不用大运/);
|
||||
});
|
||||
|
||||
test("opening and segment scans send the snapshot ayanamsa", async (t) => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
t.mock.method(globalThis, "fetch", async (_endpoint: unknown, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
ayanamsa?: string;
|
||||
boundary_charts?: unknown;
|
||||
};
|
||||
bodies.push(body);
|
||||
const start = Date.parse(`${body.start_at}:00Z`);
|
||||
const end = Date.parse(`${body.end_at}:00Z`);
|
||||
const width = (end - start) / 60000 + 1;
|
||||
const minutes = Array.from({ length: width }, (_, offset) => {
|
||||
const stamp = new Date(start + offset * 60000).toISOString().slice(0, 16);
|
||||
return { offset, date: stamp.slice(0, 10), time: stamp.slice(11), signs: { D1: 1, D9: 2, D10: 3 } };
|
||||
});
|
||||
return Response.json({
|
||||
start_at: body.start_at,
|
||||
end_at: body.end_at,
|
||||
ayanamsa: body.ayanamsa,
|
||||
node_mode: "mean",
|
||||
step_minutes: 1,
|
||||
window_minutes: width,
|
||||
minutes,
|
||||
nearest_boundary: { found: false, reason: "no_transition" },
|
||||
});
|
||||
});
|
||||
const base = {
|
||||
birth_date: "2000-06-14",
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
timezone_offset: -4,
|
||||
reported_birth_time: "09:10",
|
||||
};
|
||||
const range = { start_time: "09:00", end_time: "09:06" };
|
||||
await fetchOpeningBoundary({ snapshot: { ...base, ayanamsa: "lahiri" }, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: { ...base, ayanamsa: "lahiri" }, range, targets: ["D1", "D9", "D10"] });
|
||||
await fetchOpeningBoundary({ snapshot: base, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: base, range, targets: ["D1", "D9", "D10"] });
|
||||
assert.equal(bodies.length, 4);
|
||||
assert.deepEqual(bodies.map((body) => body.ayanamsa), ["lahiri", "lahiri", "raman", "raman"]);
|
||||
assert.ok(Array.isArray(bodies[0]?.boundary_charts));
|
||||
assert.equal(bodies[1]?.boundary_charts, undefined);
|
||||
assert.equal(bodies[0]?.node_mode, undefined);
|
||||
assert.equal(bodies[1]?.node_mode, undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user