fix(rectification): keep hour-window tail clusters and lock the search window (BUG-623, BUG-624, BUG-625)
Independent Staging Quality Gate / validate (push) Successful in 17m15s
Independent Staging Quality Gate / publish (push) Successful in 2m15s

Hour windows no longer drop later signature clusters. Credible range uses cluster coverage, and a mid-session spoken birth window gets a fixed reply without calling the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-09 20:44:00 +08:00
parent a31a5e2426
commit 6008c07c81
41 changed files with 1164 additions and 44 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-09 — 一小时窗不再丢掉后段簇;中途说出时段固定回复不改窗口
生时校正在一小时搜索窗里不再按时间只留前 12 个签名簇。61 分钟网格上的全部簇都会进入比较;超过 64 个时合并相邻低分簇,不会把窗口尾巴整段扔掉。可信区间按簇实际覆盖的分钟来读,不再缩成代表那一分钟。
校正做到一半如果又说出一个钟点范围,助手不再口头答应「以你说的为准」。服务端直接回复:搜索范围开始时按资料定、过程中不改;要换时间段请先改资料再新开一次校正。当前那道点选题还在。Skill 10.0.20。
## 2026-09-09 — 历史生时校正按它自己那一版打开
从历史对话点开生时校正时,不再要求和当前注册的 Skill 版本完全相同。Case 仍钉住创建时的版本;只要那一版快照还在注册表里,就按那一版打开。首页「开始校正」仍用当前版本。打开失败时,错误出现在被点的那一行下面,不会再看起来像没点到。Skill 版本不变。
+48
View File
@@ -9667,3 +9667,51 @@
- 相关记录:BUG-614、BUG-615、BUG-620
- 复发自:无
- 修复版本:待发布
## BUG-623 | 一小时窗口按时间取前 12 簇,后段真实出生时间从未进入候选
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`scripts/rectification/candidate_contrast.py` `select_signature_representatives``MAX_PUBLIC_CLUSTERS`、公开候选集
- 用户现象:intake 填一小时窗后,第一次比较给出的范围停在窗口前段(例如 14:00–15:00 只收到约 14:04–14:43)。用户知道的更晚时段从头到尾不在候选里。
- 触发条件:分钟网格上签名簇超过 12 个。半小时窗(±15)通常 ≤12,所以以前测不到;「前后半小时」的 60 分钟窗必现。
- 根因:`cluster_contexts_by_signature` 按代表分钟时间排序后,循环里 `len(representatives) >= MAX_PUBLIC_CLUSTERS` 直接 `break`。多出的整簇被丢掉,而且永远是窗口尾部。这发生在证据评分之前。
- 修复:不再按时间截断。安全上限改为 64;超过 64 时合并相邻低分簇,不丢尾部。公开候选仍是每簇一个代表分钟。
- 验证:`tests/test_rectification_v5_services.py` 的 17 簇夹具(含 14:46–14:51)与 65 簇相邻合并保尾。
- 防复发:禁止再对时间排序后的簇做 `break` 截断;超上限只能按分数合并相邻簇。
- 相关记录:BUG-560(以前「分钟级≈随机」的校准是在 ±15 窗口上得出,一小时窗还叠加了本截断)
- 复发自:无
- 修复版本:待发布
## BUG-624 | 可信区间按代表分钟跨度算,簇内其余分钟被视觉淘汰
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`build_candidate_decisions`、推断层 `cluster_range``credible-range.ts`
- 用户现象:即便某簇还活着,区间右端只写到代表分钟。例如 14:40–14:45 这一簇的代表是 14:40 时,读数停在 14:40。
- 触发条件:签名簇覆盖连续多分钟,代表分钟不是簇的末端。
- 根因:引擎候选只带 `time`。推断层用换升时刻在代表分钟之间重新聚类,多数变成单分钟;`unionStillValidRange` 再取这些点的跨度。
- 修复:每个公开候选带 `cluster_times` / `cluster_start` / `cluster_end`。推断层优先用引擎簇覆盖;换升聚类只作缺字段时的兜底。可信区间是仍有效簇覆盖的并集。时间轴实心/空心点仍画代表分钟。
- 验证:Python 簇 14:4014:45、代表 14:40 → `cluster_end=14:45``frontend/tests/rectification-window-cluster-cap-20260909.test.ts` 区间右端 14:45。
- 防复发:可信区间不得只用代表分钟;缺 `cluster_times` 才允许换升兜底。
- 相关记录:BUG-623
- 复发自:无
- 修复版本:待发布
## BUG-625 | 中途说出出生时段时助手口头答应改窗口,但什么都没改
- 状态:resolved
- 首次发现:2026-09-09
- 最近更新:2026-09-09
- 影响面:`frontend/src/app/api/rectification/agent/route.ts` 自由文本、`declared-window-utterance.ts`、Skill 10.0.20、`agent-run` 正文守卫
- 用户现象:校正做到一半,用户说「我的出生时间是 14 点 45 到 14 点 50」。助手回答「明白了,以你说的为准」,搜索窗口和候选集不变。下一句「继续吧」被当成没听懂的点选题回答。
- 触发条件:点选或采集焦点下,用户用自由文本申报钟点范围或「HH:MM 左右」。
- 根因:15 个工具里没有改搜索窗口的入口;opening brief 还禁止改窗口。自由文本进意图分类器,枚举没有「申报时间段」,落 `unclear`。模型在采集焦点下会自己答应。产品否决了中途改窗口。
- 修复:自由文本在分类器和选模型之前做确定性解析。命中则落库固定回复,不调模型,不改 `candidate_range`,当前焦点保持。正文守卫删除「以你说的…为准」。Skill 写明不得口头改窗口。intake 自定义更小范围等产品答复,本单不做。
- 验证:`frontend/tests/rectification-window-cluster-cap-20260909.test.ts`(解析、路由在选模型前拦截、簇覆盖区间、禁用口头承认)。
- 防复发:中途申报时段不得进模型、不得改窗口;助手不得写「以你说的为准」。
- 相关记录:BUG-623、BUG-572
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,42 @@
# PROGRESS · 窗口簇上限(2026-09-09
工作树:`.worktrees/rectification-window-cluster-cap-20260909`
分支:`codex/rectification-window-cluster-cap-20260909`
任务书:`docs/tasks/TASK-rectification-window-cluster-cap-20260909.md`
基线:开工时 `15cede97`;推 staging 前接到 `origin/staging` @ `a31a5e24`BUG-621
## 开工回执
- 目标:BUG-623 去掉按时间截断;BUG-624 簇覆盖进可信区间;BUG-625 中途说出时段固定回复、不改窗口、不进模型。
- 顺序:4.1 → 4.2 → 4.3 → 4.4 → 4.6。4.5 intake 自定义范围等产品,本单不做。
- 最大风险:一小时窗 17 簇依赖签名而不是分数;前端若不把 `cluster_times` 写进快照,区间仍会缩成代表分钟。
## 实测簇数(虚构盘 1997-08-08`minute_step=1`,与任务书 §1 同盘)
| 窗口 | 分钟数 | 签名簇 | 公开候选 | 覆盖 |
| --- | --- | --- | --- | --- |
| 14:0015:00 | 61 | **17** | 17 | 14:00 … 15:00 |
| 14:3015:00 | 31 | 9 | 9 | 14:30 … 15:00 |
| 12:0016:0014:00 ±120 | 241 | 64 | 64 | 12:00 … 16:00 |
| 12:4716:4714:47 ±120 | 241 | **66** | **64**(相邻低分合并) | 12:47 … 16:47 |
`MAX_PUBLIC_CLUSTERS = 64` 对 ±120 够用:66 簇会合并相邻低分,窗口两端仍在。没有按时间 `break`
## 已完成
- **4.1 / BUG-623**`select_signature_representatives` 不再按时间截断;超 64 走 `cap_clusters_by_adjacent_merge`。夹具:17 簇含 14:46–14:51;65 簇合并后仍覆盖 `01:04`
- **4.2 / BUG-624**:每个公开候选带 `cluster_times` / `cluster_start` / `cluster_end`,经引擎 → 快照 JSONB → 推断层;`engineClusterRange` 优先于换升聚类。簇 14:40–14:45、代表 14:40 → 区间右端 14:45。无新迁移。
- **4.3 / BUG-625**`parseDeclaredBirthWindow` 在选模型之前拦截自由文本。命中则 `persistV9DeterministicTurn` 固定回复,不调模型、不改窗口、不换焦点。不含钟点的句子不命中。
- **4.4**Skill **10.0.20**live + `versions/10.0.20`sha256 `9c09867591cc9e8726f3577346230339b5a6dd4b4f3d634a9cda3e66170d6ac6`)。§4 禁止「以你说的为准」。词表删除该短语;只剩标点时回落到固定回复。
- **4.5**:不做。
- **4.6**BUG-623 / 624 / 625 已写入 `docs/BUG_HISTORY.md``CHANGELOG.md`;走查 `docs/testing/rectification-window-cluster-cap-20260909.md`(固定回复,不是确认卡)。
固定回复原文:「搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。」
## 验收命令
- `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -q` → 全过
- `frontend``tsc --noEmit` 过;`npm run lint` 0 error(既有 warning 未动)
- `tsx --test` 任务书所列 `rectification-*.test.ts` + `birth-time-intake*.test.ts`(排除 database)→ **1073 pass / 0 fail**
本单状态:**待验收**。推 `origin HEAD:staging`
+2 -2
View File
@@ -88,8 +88,8 @@
| `TASK-rectification-conversation-economy-20260909.md` | `PROGRESS-rectification-conversation-economy-20260909.md` | 对照竞品后产品拍板三条:开场三句讲做法 + 一次收多件(推翻 opening brief「不要一次说完/不举例」与 SKILL L52);采集题「没有 / 记不清」按钮(不做示例骨架条);每轮只留一句(方法句进活动记录、点选旁白去领先落后、证据轮正文一句复述 + 服务端裁剪);Skill 10.0.19 | 已验收通过(2 条 P3 备注) | `aa7ccb30` + `1453fb16`BUG-604606Skill 10.0.19 |
| `TASK-consultation-daily-empty-answer-20260909.md` | `PROGRESS-consultation-daily-empty-answer-20260909.md` | 首页「深入看今日」计算完成却 `empty_answer`:入口定的 `timing` 被模型改成 `general``canonicalDomainPlan` 以模型为准),分段写作标题与今日格式错位,分段 `maxSteps=1` 且工具仍可调 → 模型在分段里再调工具、零正文;思考流按 chunk 过滤漏出缺词英文 | 待验收 | `codex/consultation-daily-empty-answer-20260909`BUG-612613);走查 `docs/testing/consultation-daily-empty-answer-20260909.md` |
| `TASK-rectification-compare-card-polish-20260909.md` | `PROGRESS-rectification-compare-card-polish-20260909.md` | 三列卡两列经历对照 0、三列无未来窗:引擎按引擎前三算 by_time,卡片按推断前三查表,键不匹配(BUG-614);交付旁白被 BUG-606 裁成一句(BUG-615);设计减法:相同性格句只写一次、经历对照说人话、删小标题、右栏本命宫位改北印星盘、换升时刻默认折叠 | 待验收 | `codex/rectification-compare-card-polish-20260909`BUG-614615 |
| `TASK-rectification-history-open-20260909.md` | `PROGRESS-rectification-history-open-20260909.md` | 历史生时校正打不开:`open_agentic_rectification_case_v2` 把 Case 绑定 Skill 身份与当前注册版本做相等比较,两天内 10.0.15→10.0.19 五次 bump 让所有旧 Case 被拒;错误码未映射成 500;错误只画在首页卡下 → 表现为点了没反应。决策:session 打开用绑定身份(v1 取 Case → 读身份 → 注册表核验 → v2),不改迁移 | 待验收 | `codex/rectification-history-open-20260909`BUG-621 |
| `TASK-rectification-window-cluster-cap-20260909.md` | `PROGRESS-rectification-window-cluster-cap-20260909.md` | **P0** 真实用户:一小时窗口分 17 个签名簇,`select_signature_representatives` 按时间取前 12 个,14:40 之后整簇丢弃(本机复现表);区间按代表分钟跨度而非簇覆盖;中途说出真实时段时助手口头答应却未改 → 改为固定回复"范围开始时按资料定、中途不改"(产品否决口头改范围);intake 自定义范围等产品答复 | 待执行 | `codex/rectification-window-cluster-cap-20260909`BUG-623625Skill 10.0.20 |
| `TASK-rectification-history-open-20260909.md` | `PROGRESS-rectification-history-open-20260909.md` | 历史生时校正打不开:`open_agentic_rectification_case_v2` 把 Case 绑定 Skill 身份与当前注册版本做相等比较,两天内 10.0.15→10.0.19 五次 bump 让所有旧 Case 被拒;错误码未映射成 500;错误只画在首页卡下 → 表现为点了没反应。决策:session 打开用绑定身份(v1 取 Case → 读身份 → 注册表核验 → v2),不改迁移 | 待验收 | `a31a5e24`BUG-621 |
| `TASK-rectification-window-cluster-cap-20260909.md` | `PROGRESS-rectification-window-cluster-cap-20260909.md` | **P0** 真实用户:一小时窗口分 17 个签名簇,`select_signature_representatives` 按时间取前 12 个,14:40 之后整簇丢弃(本机复现表);区间按代表分钟跨度而非簇覆盖;中途说出真实时段时助手口头答应却未改 → 改为固定回复"范围开始时按资料定、中途不改"(产品否决口头改范围);intake 自定义范围等产品答复 | 待验收 | `codex/rectification-window-cluster-cap-20260909`BUG-623625Skill 10.0.20;走查 `docs/testing/rectification-window-cluster-cap-20260909.md` |
### 聊天主链路与首页
@@ -0,0 +1,29 @@
# 生时校正窗口簇上限 · 走查清单(2026-09-09
分支 `codex/rectification-window-cluster-cap-20260909`,任务书 `TASK-rectification-window-cluster-cap-20260909.md`。不要把真实案例里的姓名、地点或精确出生资料写进记录。
自动化已锁:`tests/test_rectification_v5_services.py`(17 簇夹具、簇覆盖、超 64 合并保尾)、`frontend/tests/rectification-window-cluster-cap-20260909.test.ts`(解析、路由不进模型、区间右端、禁用口头承认)。
## 1. ±30 窗口候选必须覆盖整窗
用虚构盘,家人档选「差不多准」(±15,半小时窗)和新开一次「前后半小时」(一小时窗)。
- [ ] 半小时窗:比较后的候选范围左端不晚于窗口左端、右端不早于窗口右端(允许因证据收到中间,但不得在第一次比较前就丢掉尾巴)。
- [ ] 一小时窗:第一次比较不得只收到窗口前 2/3。若窗口是 14:00–15:00,14:46 之后必须仍可能成为候选。
## 2. 中途说出时段必须固定回复,不得改窗口
点选题还挂着时输入「我的出生时间是 14 点 45 到 14 点 50」。采集题挂着时再试一次「14:47 左右」。
- [ ] 立刻出现固定回复:搜索范围开始时按资料定、过程中不改;要换时间段先改资料再新建校正。
- [ ] 顶部搜索窗口不变。
- [ ] 原来的点选题或采集题还在,没有被换成别的题。
- [ ] 这一句没有调用模型(网络面板不应出现对话补全;助手气泡应瞬间出现)。
- [ ] 不含钟点的普通句子(「继续吧」「没有」)不走这句固定回复。
## 3. 助手不得口头答应改时间
若模型仍被问到出生时段(回归:关掉拦截再试,或看历史事故会话)。
- [ ] 不得出现「明白了,以你说的为准」或改写搜索窗口。
- [ ] Skill 10.0.20 写明不得口头承认。
@@ -46,6 +46,7 @@ import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocus
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { parseDeclaredBirthWindow } from "@/lib/rectification-agentic/v9/declared-window-utterance";
import {
awaitTurnExitBeforeResponse,
finalizeSuccessfulTurnExit,
@@ -268,6 +269,30 @@ export async function POST(request: Request) {
);
}
if (action === "message" && parseDeclaredBirthWindow(parsed.data.message ?? "")) {
try {
const narration = RECTIFICATION_USER_COPY.declaredWindowLockedReply;
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
assistantMessage: narration,
});
return completedMessageResponse(narration, requestId, caseId, turn.turnId);
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
return NextResponse.json(
{ error: mapped.message, message: mapped.message, code: mapped.code },
{ status: mapped.status },
);
}
return NextResponse.json(
{ error: "校正回复保存失败", message: "请稍后重试。" },
{ status: 500 },
);
}
}
const selectedModel = isStructuredChoice
? null
: await resolveSessionLanguageModel(
@@ -2,7 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { unionStillValidRange } from "./credible-range.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
import { holdoutDomainYears, holdoutEventIds, stickyHoldoutEvents } from "./split-holdout.ts";
@@ -22,8 +22,16 @@ export type EngineCandidateInput = Readonly<{
id: string;
time: string;
relative_support: number;
cluster_times?: readonly string[];
cluster_start?: string;
cluster_end?: string;
}>;
function engineClusterRange(item: EngineCandidateInput): readonly [string, string] | null {
if (item.cluster_start && item.cluster_end) return [item.cluster_start, item.cluster_end];
return item.cluster_times?.length ? rangeFromTimes(item.cluster_times) : null;
}
export type EngineEventInput = Readonly<{
id: string;
domain: string;
@@ -126,7 +134,7 @@ export function buildInferenceState(input: {
return {
id: item.id,
time: item.time,
cluster_range: clusterRangeFor(clusters, item.id, item.time),
cluster_range: engineClusterRange(item) ?? clusterRangeFor(clusters, item.id, item.time),
prior_score: trainingPrior[item.id] ?? 0,
posterior_score: scores[item.id] ?? 0,
probability: eliminated.has(item.id) ? 0 : probabilities[item.id] ?? 0,
@@ -254,6 +262,8 @@ export function replayInferenceState(
id: item.id,
time: item.time,
relative_support: item.prior_score,
cluster_start: item.cluster_range[0],
cluster_end: item.cluster_range[1],
})),
events: state.events,
probes: state.probes,
@@ -348,6 +358,8 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe
id: item.id,
time: item.time,
relative_support: item.prior_score,
cluster_start: item.cluster_range[0],
cluster_end: item.cluster_range[1],
})),
events: state.events,
probes: state.probes,
@@ -111,6 +111,7 @@ export const RECTIFICATION_USER_COPY = {
collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY,
choicePrompt: "直接点下面的选项就行,打字回答也一样算数。",
unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。",
declaredWindowLockedReply: "搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
questionUpdated: "这一问刚换成新的,刷新后再答就行。",
adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。",
hostNarrationFallback: "我按现有材料继续往下收。",
@@ -461,6 +462,7 @@ export function listUserVisibleCopy(): string[] {
GENERIC_COLLECT_QUESTION,
RECTIFICATION_USER_COPY.choicePrompt,
RECTIFICATION_USER_COPY.unclearFocusReply,
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
RECTIFICATION_USER_COPY.questionUpdated,
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
@@ -43,7 +43,7 @@ import {
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
} from "../user-copy";
import { stripQuestionSentences, trimSpokenTurnForInterview } from "./collect-prompt";
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
@@ -1031,6 +1031,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
rangeBeforeCompare,
rangeAfterEvidence,
);
answerText = stripVerbalWindowChange(answerText)
|| RECTIFICATION_USER_COPY.declaredWindowLockedReply;
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
@@ -24,4 +24,5 @@ export const MACHINE_VOICE_LEXICON = [
"强相关",
"有关联",
"弱关联",
"以你说的为准",
] as const;
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.19";
export const RECTIFICATION_SKILL_VERSION = "10.0.20";
@@ -51,6 +51,17 @@ function isQuestionSentence(text: string, stem: string): boolean {
return /[?]$/.test(text);
}
export function stripVerbalWindowChange(body: string): string {
const stripped = body
.replace(/以你说的.{0,40}为准/g, "")
.replace(/[,、]{2,}/g, "")
.replace(/[ \t]+/g, " ")
.replace(/[,、]+\s*(?=[。..!?!?;]|$)/g, "")
.trim();
if (!stripped || /^[,、。..!?!?;:\s]+$/.test(stripped)) return "";
return stripped;
}
export function stripQuestionSentences(body: string, stem: string): string {
const prompt = stem.trim();
const spoken = body.trim();
@@ -0,0 +1,40 @@
/**
* Mid-session spoken birth-time windows are not a search-window change.
* Detect them so the route can answer with a fixed reply and skip the model.
*/
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/;
const RANGE_SEP = /\s*(?:到|至|[-–—~])\s*/;
const AROUND = /\s*(?:左右|前后)/;
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
export type DeclaredBirthWindow =
| { kind: "range"; start: string; end: string }
| { kind: "around"; time: string };
export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow | null {
const text = message.trim();
if (!text) return null;
const range = RANGE_PATTERN.exec(text);
if (range) {
const start = normalizeClock(range[1] ?? "");
const end = normalizeClock(range[2] ?? "");
if (start && end && start !== end) return { kind: "range", start, end };
}
const around = AROUND_PATTERN.exec(text);
if (around) {
const time = normalizeClock(around[1] ?? "");
if (time) return { kind: "around", time };
}
return null;
}
function normalizeClock(raw: string): string | null {
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
@@ -58,6 +58,9 @@ export type V9EngineCandidate = Readonly<{
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>;
export type V9DecisionReceipt = Readonly<Record<string, unknown>>;
@@ -282,6 +285,31 @@ export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringId
}
}
function readClusterCoverage(row: Record<string, unknown> | null): {
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
} {
if (!row) return {};
const times = Array.isArray(row.cluster_times)
? row.cluster_times.flatMap((item) => (
typeof item === "string" && timePattern.test(item) ? [item] : []
))
: [];
const start = typeof row.cluster_start === "string" && timePattern.test(row.cluster_start)
? row.cluster_start
: times[0];
const end = typeof row.cluster_end === "string" && timePattern.test(row.cluster_end)
? row.cluster_end
: times[times.length - 1];
if (!start || !end) return {};
return {
...(times.length > 0 ? { clusterTimes: times } : {}),
clusterStart: start,
clusterEnd: end,
};
}
function readCandidates(
value: unknown,
range: { start_time: string; end_time: string },
@@ -311,7 +339,15 @@ function readCandidates(
}
seenIds.add(candidateId);
seenTimes.add(time);
candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount });
const cluster = readClusterCoverage(row);
candidates.push({
candidateId,
time,
rank,
relativeSupport,
tiedMinuteCount,
...cluster,
});
}
return candidates;
}
@@ -387,7 +387,14 @@ export function compactInferenceProjection(state: InferenceState | null | undefi
export function buildCaseInferenceState(input: {
range: { start_time: string; end_time: string };
candidates: readonly Readonly<{ candidateId: string; time: string; relativeSupport: number }>[];
candidates: readonly Readonly<{
candidateId: string;
time: string;
relativeSupport: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>[];
evidence: readonly Readonly<{
id: string;
domain: string;
@@ -421,6 +428,9 @@ export function buildCaseInferenceState(input: {
id: item.time,
time: item.time,
relative_support: item.relativeSupport,
...(item.clusterTimes ? { cluster_times: item.clusterTimes } : {}),
...(item.clusterStart ? { cluster_start: item.clusterStart } : {}),
...(item.clusterEnd ? { cluster_end: item.clusterEnd } : {}),
})),
events,
probes,
@@ -280,6 +280,9 @@ export type V9Candidate = Readonly<{
rank: number;
relativeSupport: number;
tiedMinuteCount: number;
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
}>;
export type V9CandidateSnapshot = Readonly<{
@@ -303,6 +306,28 @@ export type V9CandidateSnapshot = Readonly<{
invalidatedAt: string | null;
}>;
function clusterCoverageFromRow(row: Record<string, unknown> | null): {
clusterTimes?: readonly string[];
clusterStart?: string;
clusterEnd?: string;
} {
if (!row) return {};
const times = Array.isArray(row.cluster_times)
? row.cluster_times.flatMap((item) => {
const time = timeValue(item);
return time ? [time] : [];
})
: [];
const start = timeValue(row.cluster_start) ?? times[0];
const end = timeValue(row.cluster_end) ?? times[times.length - 1];
if (!start || !end) return {};
return {
...(times.length > 0 ? { clusterTimes: times } : {}),
clusterStart: start,
clusterEnd: end,
};
}
function timeValue(value: unknown): string | null {
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
@@ -489,7 +514,14 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot |
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
) return null;
seenIds.add(candidateId);
candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount });
candidates.push({
candidateId,
time: candidateTime,
rank,
relativeSupport,
tiedMinuteCount,
...clusterCoverageFromRow(candidate),
});
}
const decisionReceipt = rowObject(row.decision_receipt);
const executionLedger = Array.isArray(row.execution_ledger)
@@ -1469,6 +1501,9 @@ export async function persistV9Candidate(
rank: candidate.rank,
relative_support: candidate.relativeSupport,
tied_minute_count: candidate.tiedMinuteCount,
...(candidate.clusterTimes ? { cluster_times: candidate.clusterTimes } : {}),
...(candidate.clusterStart ? { cluster_start: candidate.clusterStart } : {}),
...(candidate.clusterEnd ? { cluster_end: candidate.clusterEnd } : {}),
})),
p_decision_receipt: input.decisionReceipt,
p_execution_ledger: input.executionLedger,
@@ -463,8 +463,8 @@ function rpcDossier(decision: DecisionDossier, activeFocus?: Record<string, unkn
});
}
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
@@ -359,7 +359,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
assert.match(agentSource, /不得宣称唯一出生分钟/);
assert.doesNotMatch(agentSource, /±2 分钟/);
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
const accounting = fakeAccounting({
...receiptHandlers,
@@ -199,12 +199,12 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf
assert.match(report.markdown, /04:53 \| .*巨蟹座/);
});
test("skill 10.0.19 forbids computing varga signs from transition times", () => {
test("skill 10.0.20 forbids computing varga signs from transition times", () => {
const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url));
const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8");
const comparison = readFileSync(`${skillDir}/references/candidate-comparison.md`, "utf8");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
assert.match(skill, /^version: 10\.0\.20$/m);
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
@@ -1454,9 +1454,9 @@ test("rescore failure does not fail the evidence write", async () => {
assert.ok(result.rescore.error_code);
});
test("public tool surface stays at 14 and new cases bind 10.0.19", () => {
test("public tool surface stays at 14 and new cases bind 10.0.20", () => {
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 14);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -576,8 +576,8 @@ function warnLines(run: () => Promise<unknown> | unknown) {
}).then((result) => ({ result, lines }));
}
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
@@ -213,9 +213,9 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d
assert.equal("p_occurred_from" in confirmCall.args, false);
});
test("new-case skill identity is 10.0.19 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.match(skill, /^version: 10\.0\.19$/m);
test("new-case skill identity is 10.0.20 and the prompt prefers batch ingest", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
assert.match(skill, /^version: 10\.0\.20$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -184,8 +184,8 @@ const CANDIDATE_IDS = [
"88888888-8888-4888-8888-888888888882",
] as const;
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
@@ -426,8 +426,8 @@ function rpcDossier(decision: DecisionDossier) {
});
}
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
@@ -94,8 +94,8 @@ function collectPersistResult(overrides: {
};
}
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
test("skill version is 10.0.20 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
@@ -96,11 +96,11 @@ test("system prompt carries only high-priority boundaries, never the method copy
test("agent pins the dedicated rectification skill and its fixed version", () => {
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.19"));
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.20"));
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -95,9 +95,9 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
assert.match(skill, /^version: 10\.0\.19$/m);
assert.match(skill, /^version: 10\.0\.20$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
@@ -209,7 +209,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
skill_version: "10.0.19",
skill_version: "10.0.20",
};
}
return null;
@@ -247,11 +247,11 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
assert.equal(response.skillVersion, "10.0.19");
assert.equal(response.skillVersion, "10.0.20");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
assert.equal(openCall.args.p_skill_version, "10.0.19");
assert.equal(openCall.args.p_skill_version, "10.0.20");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import { stripVerbalWindowChange } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { parseDeclaredBirthWindow } from "../src/lib/rectification-agentic/v9/declared-window-utterance.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
const ROUTE = readFileSync(
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
"utf8",
);
const SKILL = readFileSync(
new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url),
"utf8",
);
test("declared birth-window utterances parse ranges and around-times", () => {
assert.deepEqual(
parseDeclaredBirthWindow("我的出生时间是 14 点 45 到 14 点 50"),
{ kind: "range", start: "14:45", end: "14:50" },
);
assert.deepEqual(
parseDeclaredBirthWindow("14:4514:50"),
{ kind: "range", start: "14:45", end: "14:50" },
);
assert.deepEqual(
parseDeclaredBirthWindow("14:47 左右"),
{ kind: "around", time: "14:47" },
);
assert.equal(parseDeclaredBirthWindow("没有"), null);
assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null);
assert.equal(parseDeclaredBirthWindow("继续吧"), null);
});
test("choice-focus declared window is answered from the route before the model", () => {
const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow');
const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice");
const classifyCall = ROUTE.indexOf("await classifyRectificationTurnIntent");
assert.ok(intercept > 0);
assert.ok(selectedModel > intercept);
assert.ok(classifyCall > intercept);
assert.match(ROUTE, /declaredWindowLockedReply/);
assert.match(ROUTE, /persistV9DeterministicTurn/);
assert.equal(
RECTIFICATION_USER_COPY.declaredWindowLockedReply,
"搜索范围是开始时按你的资料定的,校正过程中不改。想按别的时间段重来,请先到资料里改出生时间,再新建一次校正。",
);
});
test("engine cluster coverage becomes the credible-range right edge", () => {
const state = buildInferenceState({
range_start: "14:00",
range_end: "15:00",
candidates: [{
id: "14:40",
time: "14:40",
relative_support: 40,
cluster_times: ["14:40", "14:41", "14:42", "14:43", "14:44", "14:45"],
cluster_start: "14:40",
cluster_end: "14:45",
}],
events: [],
probes: [],
});
assert.deepEqual(state.candidates[0]?.cluster_range, ["14:40", "14:45"]);
assert.deepEqual(unionStillValidRange(state.candidates), ["14:40", "14:45"]);
});
test("agent body cannot verbally accept a spoken birth window", () => {
assert.ok(MACHINE_VOICE_LEXICON.includes("以你说的为准"));
assert.equal(
stripVerbalWindowChange("明白了,出生时间以你说的 14:4514:50 为准。"),
"明白了,出生时间。",
);
assert.equal(stripVerbalWindowChange("以你说的为准。"), "");
assert.equal(stripVerbalWindowChange("明白了,以你说的为准。"), "明白了。");
assert.match(SKILL, /不得回答『以你说的为准』或改写搜索窗口/);
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.20");
});
+2 -2
View File
@@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on
[
{
name: "jyotish-birth-time-rectification",
version: "10.0.19",
sha256: "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
version: "10.0.20",
sha256: "9c09867591cc9e8726f3577346230339b5a6dd4b4f3d634a9cda3e66170d6ac6",
},
{
name: "jyotish-personal-report",
+56 -4
View File
@@ -21,7 +21,7 @@ PROBE_PHASE_HOLDOUT_VALIDATION = "holdout_validation"
MIN_DISCRIMINATOR_EVENTS = 3
MIN_DISCRIMINATOR_DOMAINS = 2
MAX_PUBLIC_CLUSTERS = 12
MAX_PUBLIC_CLUSTERS = 64
SIGNATURE_LAYERS = ("d1", "d9", "d10", "d24", "d4", "d12", "md")
NAKSHATRA_SPAN = 360.0 / 27.0
LAYER_VARGA = {
@@ -235,6 +235,55 @@ def expand_times_through_clusters(
return expanded
def _cluster_peak_score(cluster: dict[str, Any], by_time: dict[str, dict[str, Any]]) -> float:
scores = [
float(by_time[time].get("score") or 0)
for time in cluster.get("times") or []
if time in by_time
]
return max(scores) if scores else 0.0
def cap_clusters_by_adjacent_merge(
clusters: Sequence[dict[str, Any]],
by_time: dict[str, dict[str, Any]],
max_clusters: int = MAX_PUBLIC_CLUSTERS,
) -> list[dict[str, Any]]:
"""Keep the whole window. If there are too many clusters, merge adjacent weak ones."""
work = [
{
**cluster,
"times": list(cluster.get("times") or []),
"contexts": list(cluster.get("contexts") or []),
}
for cluster in clusters
if cluster.get("times")
]
while len(work) > max_clusters:
best_index = 0
best_key: tuple[float, float, int] | None = None
for index in range(len(work) - 1):
left = _cluster_peak_score(work[index], by_time)
right = _cluster_peak_score(work[index + 1], by_time)
key = (min(left, right), left + right, index)
if best_key is None or key < best_key:
best_key = key
best_index = index
left = work[best_index]
right = work[best_index + 1]
merged_times = sorted(set(left["times"] + right["times"]), key=_clock)
work[best_index] = {
"signature": left.get("signature"),
"signature_key": f"{left.get('signature_key')}+{right.get('signature_key')}",
"contexts": list(left.get("contexts") or []) + list(right.get("contexts") or []),
"times": merged_times,
"representative_time": merged_times[len(merged_times) // 2],
"representative": left.get("representative") or right.get("representative"),
}
del work[best_index + 1]
return work
def select_signature_representatives(
rows: Sequence[dict[str, Any]],
static_contexts: Sequence[dict[str, Any]] | None = None,
@@ -251,14 +300,17 @@ def select_signature_representatives(
clusters = cluster_contexts_by_signature(contexts)
else:
clusters = _adjacent_score_clusters(list(by_time.values()))
clusters = cap_clusters_by_adjacent_merge(clusters, by_time)
representatives: list[dict[str, Any]] = []
for cluster in clusters:
members = [by_time[time] for time in cluster["times"] if time in by_time]
if not members:
continue
representatives.append(max(members, key=lambda row: (float(row.get("score") or 0), str(row.get("time")))))
if len(representatives) >= MAX_PUBLIC_CLUSTERS:
break
best = max(members, key=lambda row: (float(row.get("score") or 0), str(row.get("time"))))
representatives.append({
**best,
"cluster_times": [time for time in cluster["times"] if time in by_time],
})
representatives.sort(key=lambda row: (-float(row.get("score") or 0), str(row.get("time"))))
return representatives or list(rows)[:1]
+24
View File
@@ -402,16 +402,40 @@ def build_candidate_decisions(
abs(score - other) <= TIE_ABSOLUTE_TOLERANCE
for other in all_scores
)
cluster_times, cluster_start, cluster_end = _cluster_span(row)
decisions.append({
"candidate_id": str(uuid5(NAMESPACE_URL, f"{POLICY_VERSION}:{result_id}:{row['time']}")),
"rank": index + 1,
"time": row["time"],
"relative_support": supports[index],
"tied_minute_count": tied_minute_count,
"cluster_times": cluster_times,
"cluster_start": cluster_start,
"cluster_end": cluster_end,
})
return decisions
def _cluster_span(row: dict[str, Any]) -> tuple[list[str], str, str]:
raw = row.get("cluster_times")
times: list[str] = []
seen: set[str] = set()
values = raw if isinstance(raw, list) and raw else [row.get("time")]
for item in values:
text = str(item or "")[:5]
if len(text) != 5 or text[2] != ":" or text in seen:
continue
seen.add(text)
times.append(text)
if not times:
clock = str(row.get("time") or "")[:5]
times = [clock] if len(clock) == 5 and clock[2] == ":" else []
times.sort(key=lambda value: int(value[:2]) * 60 + int(value[3:5]))
start = times[0] if times else str(row.get("time") or "")[:5]
end = times[-1] if times else start
return times, start, end
def _gate(passed: bool, **details: Any) -> dict[str, Any]:
return {"passed": passed, **details}
@@ -1,6 +1,6 @@
---
name: jyotish-birth-time-rectification
version: 10.0.19
version: 10.0.20
description: "生时校正专用 SkillV10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。"
---
@@ -62,6 +62,7 @@ description: "生时校正专用 SkillV10)。以服务器权威 Case、Conv
- 首题保持采集题身份(`collect:other:*`),题干写成「先说你最容易想起的一两件,年月大概就行」。
- 至多一个主问题且唯一来源:每轮当前问题只能由服务端建立 `ConversationFocus` 并通过界面问题槽呈现。Agent 回复正文只做承接与解释,不得自行提出、复述、改写或预告问题;正文内容不参与问题槽判定。
- 不机械复述 opening brief,不泄露服务器字段、内部状态对象或出生资料明文。
- 用户说出出生时间或时段时,不得回答『以你说的为准』或改写搜索窗口;服务端会固定回复范围在开始时已定、过程中不改。
## 5. ConversationFocus 与意图承接
@@ -0,0 +1,145 @@
---
name: jyotish-birth-time-rectification
version: 10.0.20
description: "生时校正专用 SkillV10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。"
---
# Jyotish 生时校正(V10
## 1. 触发条件与方法学归属
本 Skill 只服务 `agentic_rectification_cases` 绑定的生时校正会话:
- 服务端 Case 存在且 `skill_name = 'jyotish-birth-time-rectification'`
- 用户话题是出生时间 / 出生分钟 / 事件发生时间能否定位到某几分钟,而不是普通解盘或推运。
- 普通咨询、推运、合盘、补救问题交给 `jyotish-vedic-astrology`,不要在这里处理。
生时校正的方法学、访谈策略、证据边界与候选表达规则只定义在本 Skill 及其 references。system prompt 只保留安全、权限、隐私、工具和运行边界,不得复制、压缩或另写一套校时方法学,也不得用 system prompt 覆盖本版本政策。
## 2. 必须先读与服务器权威
进入任何一轮实质工作前读取(服务器会随 Dossier 提供投影,缺文件时以服务器 Dossier 为准):
1. `references/evidence-model.md`:证据种类、日期精度、原文引用、修订链、服务器持有 ID。
2. `references/conversation-strategy.md`OpeningPolicy、ConversationFocus、长会话记忆、批量证据与追问策略。
3. `references/candidate-comparison.md`candidate / accepted / confirmed 三层语义与表达边界。
4. `references/technique-routing.md`:技法按主题调用,D9/D10 核心,不一次性调用所有分盘。
5. `references/truth-consent-boundaries.md`:真实性、同意与选择政策。
服务器是下列信息的唯一权威:Skill 绑定版本、Case/Session 身份与状态、`ConversationFocus``CaseConversationSummary`、evidence/focus ID、事件状态与修订链、候选范围与评分、采用/确认权限、工具执行、持久化和计费。Agent 只能解释服务器投影并选择自然表达,不得从对话文本、上一条 assistant 消息或 recent turns 重建权威状态。
每次 attempt 必须先完成真实 Skill 绑定和 Case 加载,之后才能执行 action。失败或重试 attempt 的部分文本、工具结果与推断不得当作已提交事实;只依据服务器提交成功的 attempt 与 receipt。
## 3. Case 状态与只读边界
服务器 Dossier 会给出当前 `status`。按表行动:
| status | 允许动作 |
|---|---|
| `draft` / `collecting_evidence` | 继续收集/修订带日期事件;可读取诊断。`next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间,**不得**同时追问;仍有挡住出牌的 `next_followup` 时继续收集,**不得**提供候选。`selection_allowed` 不够作为出示卡片的理由;提出门看 `propose_allowed` 且访谈已停或用户喊停 |
| `candidate_ready` | 可比较候选、说明当前边界;仍可继续补证据 |
| `candidate_accepted` | 已采用代表性时间。采用后先按该分钟核最多两件前事,对不上可改选其他候选;核对结束再用这个时间看盘。`unique_minute_path=closed_at_representative` 时本会话以此收口,**不得**进入唯一分钟确认 |
| `needs_rebaseline` | 出生资料基线已变化,候选失效;只允许重新收集/修订事件,禁止引用旧候选 |
| `paused` | 可继续访谈;不要声称结束 |
| `confirmed` / `closed` / `abandoned` / `superseded` | terminal Case,只读历史;不得追加/修订/确认证据,不得采用/确认候选,不得关闭第二次 |
- terminal Case 的只读限制由服务器强制;Agent 不得用换工具、换措辞、重试或旧 focus 绕过。用户要继续校正时,说明需要走显式新建 Case 的入口。
- 同一用户可以保留多个可恢复 Case;首页显式新建与历史 Session 精确恢复是两条不同入口,不得因存在旧 Case 强制回到旧 Session。
- 历史 Session 必须恢复对应的精确 Case/Session;不得把另一个 resumable Case 的上下文混入当前会话。
## 4. OpeningPolicy
服务端首次提供 opening brief:Case 状态、当前搜索窗口(`candidate_range`)与来源(intake 声明的不确定档)、做法三句要点、六类领域清单(升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤)。Agent 按下列三句模板自然开场,不得要求先准备一套材料,也不得写具体年份:
1. 一句当前搜索窗口与核对做法。
2. 一句「最后给区间和代表分钟,不给精确到秒」。
3. 一句「想到几件说几件,有大概年月就行」并点出上述六类。
开场必须满足:
- 一条消息可以报多件;想到几件说几件,有大概年月即可。只报一件也继续既有逐领域采集,不得追问「还有吗 / 还能想起别的吗」,也不得重复开场邀请。第一道逐领域题干末尾由服务端带一次「想到别的也可以一起说」。
- 允许模糊日期:可以先说大概年份、阶段或范围;如确有信息增益,后续再澄清,不诱导猜测月份或日期。
- 首题保持采集题身份(`collect:other:*`),题干写成「先说你最容易想起的一两件,年月大概就行」。
- 至多一个主问题且唯一来源:每轮当前问题只能由服务端建立 `ConversationFocus` 并通过界面问题槽呈现。Agent 回复正文只做承接与解释,不得自行提出、复述、改写或预告问题;正文内容不参与问题槽判定。
- 不机械复述 opening brief,不泄露服务器字段、内部状态对象或出生资料明文。
- 用户说出出生时间或时段时,不得回答『以你说的为准』或改写搜索窗口;服务端会固定回复范围在开始时已定、过程中不改。
## 5. ConversationFocus 与意图承接
`ConversationFocus` 是服务器持久化的当前对话目标,至少包含 `id`(即 `focusId`)、`questionId``intent``targetEvidenceId`、目标领域/类型、预期回答结构、状态与时间。Agent 可做意图分类,但服务器必须验证目标仍为 `active`
- “是的 / 不是 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”等承接、拒答、确认和修订,必须依赖服务器给出的 active focus。
- 拒绝、跳过、解决或修订既有目标时,工具调用必须引用服务器提供的 `focusId`;涉及既有证据时还必须引用对应 `evidenceId`。用户对已有 pending 说“对/是”时,`rectification-confirm-evidence` 可以省略 `focusId`,尤其当 active focus 是无 `target_evidence_id` 的 opening focus 时,不得用它烧掉后续事件确认。
- 不得从 assistant 上一句倒推拒答目标,不得仅靠 pending revision 或中文正则构造 active focus,也不得把脱离上下文的承接词保存成新事件。
- 没有 active focus、focus 已 resolved/declined/skipped/superseded、或当前表达可能指向多个目标时,只做一句简短澄清;不得猜测或写 evidence。
- 当前轮用户主动、明确、无歧义地提出全新事件时,可按新事件处理;若需要后续问题,由服务器建立新的 focus。
- 用户已拒绝或跳过的目标不得换词重问;只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。
## 6. CaseConversationSummary 与长会话记忆
`CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。
- 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary``method_followup_plan`
- 不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,仍可计分。下一问只跟 `method_followup_plan.next_followup`。先走完方法覆盖(感情 → 事业 → 家人 → 职业 → 占问),再对已覆盖领域做精度追问。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup``choice_frame`(冲突探针、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative``next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。
- recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。
- summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。
- 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。
## 7. 批量证据与日期真实性
一次用户消息可包含多件事件。优先使用服务器提供的批量 proposal/confirmation 服务,并遵守逐项原子语义:
- 每件事件独立保留用户原话 `quote``kind``domain` 和真实 `date precision`;不得合并、拆错主体或要求用户逐条重发。
- 服务器逐项返回 `accepted` / `needs_clarification` / `rejected`;Agent 按每项结果分别处理,不得让一条模糊或拒绝项阻塞同批清晰项。
- 清晰且 quote grounding 通过的新事件必须走批量服务写入;不要对同一句用户消息里的多件事件逐条 propose+confirm。`rectification-confirm-evidence` 只用于用户对已有 pending 明确说“对/是”。
- 证据有效写入后,服务器会按当前账本重算候选。不要等用户说“没有更多了”才 compare;同一证据指纹不要再 compare。不要调用新的扫描工具。
- 证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」例如「记下了:2016 年 9 月入学、2020 年 6 月毕业。」不得加评价句,不得写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接到正文后面。
- 批量结果中的 evidence item `accepted` 只是该项被服务接纳处理,不等于候选 `accepted`;清晰项在批量路径上可由服务器直接 `confirmed`
- 复述任何事件日期必须使用服务器 `display_date_label`。日级不得说成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`
- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;`rejected` 不得伪装成已记录。
- 修订必须生成 superseding revision,引用 active `focusId` 与目标 `evidenceId`,不得覆盖历史;pending revision 不自动确认。
- 日期精度真实保留:`year` / `month` / `quarter` / `day` / `range` / `unknown` 按用户原话保存,范围不得取中点,只有服务器目标已明确年份时才可把用户补充的月份/季度并入修订。
- 批量服务与单项工具都必须依赖服务器幂等键;重试不得重复创建或确认 evidence。Agent 不自行生成 evidence/focus ID。
## 8. 可调用工具与输入边界
只调用服务器提供的 `rectification-*` 工具,包括 read-case、set/resolve-focus、批量 evidence、单项 proposal/confirmation/revision、candidate comparison/offer/accept/confirm 与 close-case。工具 input 只含服务端合同要求的最小引用(如 caseId、focusId、evidenceId、quote、proposedKind),**绝不**传:
- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。
工具结果只读取;事实、ID、评分、范围、状态、持久化、幂等与权限一律以服务器为准。工具执行对用户保持静默:不得叙述读取 Skill、Case 已加载、调用工具、建立草稿、读取诊断或呈现快照,也不得自行生成“本轮做了什么”“执行步骤”“使用技法”或 Activity 状态文案;运行状态和实际方法 receipt 只由服务器公开凭证展示。
## 9. candidate / accepted / confirmed 语言边界
- `candidate`:引擎对当前证据的归一化比较结果,称“当前候选 / 相对支持度”,**不得**称概率、置信度或确定性。
- `accepted`:用户明确选择的当前排盘时间,称“校正采用时间”,**不得**称“已确认唯一出生时间”。
- `confirmed`:通过服务器确认门且用户明确同意,称“已确认校正时间”。
- `session_outcome=adopt_representative` / `next_user_action.id=adopt_representative`:本轮**有结果**,结果是采用代表性时间作当前排盘。正文应自然说明代表性候选可用于当前排盘,但它不是已确认的唯一出生分钟;不要使用固定收口句式。不要调用 confirm。只有这时才调用 `rectification-offer-candidates`。服务器会拒绝访谈未停且用户未喊停的 offer。`collecting_evidence` 且仍有挡住出牌的 `next_followup` 时不得 offer/accept。`propose_allowed` 需要可评分事件≥4、领域≥3、诊断稳定,或事件吻合率≥80%;唯一领先和宽度≤5只挡确认门,不挡出示代表性时间卡。精度阶段追问在方法覆盖完成后才问,且不挡出牌。KP 观察不计分、不挡提出门。
- 确认门以 `latest_result.confirmation_gate` 为准。`unique_minute_path=closed_at_representative` 或任一 blocker 未通过时,不得把唯一分钟确认当下一步;用户仍可 accepted 代表性候选。
- `vedastro_minute_sensitive``not_evaluated` 表示尚未跑通,不等于 fail,但缺它不能写 confirmed。
- 若 `vedastro_minute_sensitive``passed``public_aa_holdout``not_ready`,可以说官方分钟层已区分相邻分钟,仍必须说公开密封集尚未达标,不能确认唯一分钟。
- `public_aa_holdout``not_ready``unique_minute_path` 必须是 `closed_at_representative`:不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。
- 未达到唯一分钟确认门时,任何“就用 HH:MM”都只能进入 accepted;只有 `confirmation_allowed=true` 且用户同意才可写 confirmed。
- 若不可分 blocker 为 `blocked`、宽度大于 5、top `tied_minute_count` > 1,或 `confirmation_allowed=false`,正文必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。
- 分钟窗口扫描只在服务端。即使高吻合、宽度 ≤5、`can_apply`/`propose_allowed`,仍写 `candidate_range_not_birth_time_truth`
- 出牌/采用轮正文只写三句:这次给出的范围与排盘用代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report``width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。
- 80%/60% 只描述**事件吻合率**(高度/中度/低度拟合),**不得**写成“已确认唯一出生分钟”。
- 不得在同一回复中一边要求继续补证据、一边提供采用候选。
- 不得伪造出生分钟、分数、权重、事件 ID、分盘事实或确认门结果。
## 10. 输出与停止条件
- 简体中文。访谈按 skill 路径 C:先用自然语言收集带大概年份的经历;只有候选已经分不开时才生成可点选的 A/B/C/D 主题问卷。允许模糊日期、允许分多轮。**不得**一进场就出点选卡,也不得先逼 10–15 条事件长表。
- 每轮最多一个主要问题;完整回复可以零问题,不为了延续对话强行追问,不生成三条推荐问题。
- 用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,基于服务器状态直接回答,不把问题当作事件。
- 用户说“不知道 / 记不清 / 不想回答 / 换个方向”时,按 active focus 关闭或跳过该目标;用户说“目前没有 / 没有更多事件”时,不再轮换证据领域,也不要求结束、暂停或保存进度。
- 不得询问外貌、体质、胎记或疤痕。D9/D10 类型表是校时方法,写「该分钟下 D9/D10 升 X,与用户所述特质的对应/冲突」,不是咨询命运承诺。职业对照本命第 10 宫和 D10,允许类型表。占问只问一次;有问起时间则观察,没有也不挡出牌。`internal_observations` 可用于选题,类型对照写入验证报告。若用户消息以「盘外核对(不计分)」开头,不得写入可评分证据。
- 精度阶段按本命上升 → D9 → D10 → D4 居所 → D5/D24 成就收窄;家人走 D12/D7/D3 方法覆盖。财务走 D2/D11、健康走 D30,仅在用户主动说时计分,均不得混进 D4。Pada / Hora / Ghati / Bhava / Pranapada / KP 子主只展示换升,不确认唯一分钟。
- 采用后按采用分钟核最多两件服务器探针前事;对得上写入并重算,对不上可改选其他候选。不得声称唯一分钟,也不自动进入咨询 Agent。
- 采用候选后自然说明 accepted 与 confirmed 边界;`verify_adopted_time` 时必须核一件前事,核对结束或用户先这样才请看盘。不主动关闭 Case,Session 会保留并可日后继续。
- 不再有固定 10–15 个事件长表、外貌/体型/疤痕主评分、或“稳定确定到精确分钟”的承诺。A/B/C/D 主题问卷只在候选已经分不开或采用后核对前事时使用。80%/60% 只描述事件吻合率。
- 无法验证时如实降级并说明受限,不得把内部一致性伪装成全球顶级精度。
## 11. 上游同步边界
方法源只在本 Skill 与 references。不得把本 Skill 内容反向写回 `yinduzhanxing` 上游快照,也不得在同步时自动覆盖商业 Skill。
@@ -0,0 +1,59 @@
# Candidate ComparisonV9
候选比较是服务器计算产物,Agent 只负责解释与引导,不负责产生候选、分数或范围。
## 1. 三层语义
| 层 | 含义 | 表达 |
|---|---|---|
| `candidate` | 引擎对当前证据的归一化比较结果 | “当前候选”“相对支持度” |
| `accepted` | 用户明确选择的当前排盘时间 | “校正采用时间” |
| `confirmed` | 通过服务器确认门且用户明确同意 | “已确认校正时间” |
- `candidate_accepted` 不是“唯一出生分钟已确认”,默认仍可继续补充证据。
- accepted 后用户仍可在同一批有效候选中改选(幂等 RPC 支持)。
- confirmed 只能由服务器确认门 + 用户明确同意触发,同时写 `completed_at`
## 2. 何时提供候选
- 只有本轮完成 `rectification-offer-candidates` 且返回 `selection_allowed=true` 时,界面才展示候选卡。
- `selection_allowed` 只表示可以采用代表性时间,**不是**本轮必须出示卡片。提出门看 `latest_result.propose_allowed`,并且没有挡住出牌的 `method_followup_plan.next_followup`(占问和精度阶段追问不挡;职业挡出牌)。唯一领先和宽度≤5只挡确认门。
- `next_user_action.id=adopt_representative`,或用户停止且 `on_user_stop` 为 adopt 时,本轮才 offer/accept。服务器会拒绝访谈未停的 offer。这是采用代表性时间,不是 confirmed。
- 继续收集证据时不得边追问边提供采用。
- 候选卡内容来自持久化 Candidate Snapshot`agentic_rectification_results`),不是 Agent 文本解析。
- 候选卡按一行至多三列并排:每列一个候选分钟,写相对可能性、性格处事、经历对照、往后 12 个月事件窗;「更像这个」即采用。不预标「排盘用」。Agent 正文在出牌轮**不得**复述八法表格或 Technique Audit。
## 3. 表达边界
- 相对支持度是候选间归一化比较,**不是**概率、统计置信度或确定性。卡片上的「相对可能性」是答题后的后验百分比,同样不是引擎置信度。80%/60% 只描述事件吻合率。
- 出牌轮正文不写事件–DashaGochara 表、D9/D10 类型对照和技法审计;那些只出现在折叠的验证报告里。不暴露隐藏分钟证据或把分数说成唯一分钟概率。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。
- 候选范围必须说明“待核对边界”,不得表述为已确认出生分钟。
- 外部验证状态按服务器字面读取:`not_evaluated` 表示未调用(入口门未就绪),不是“调用了但失败”。
## 4. 证据变化与重算
- 证据有效变化时由服务器重算候选;Agent 不必等用户说“没有更多了”才 compare。
- 相同 evidence 指纹 + 引擎版本复用缓存;不要对同一指纹再 compare。
- 分钟窗口扫描只在服务端,结果进入候选卡 / 不可分平台语言。不得把若干事件说成已确定到 ±5 分钟。
- 普通澄清轮若不改变账本指纹,不重复播报。
- 出生资料基线变化 → `needs_rebaseline`,旧候选失效;不得静默继续用旧结果。
- `needs_rebaseline` 下不引用旧候选、不提供采用。
## 5. 不可分平台与确认门(必须说出来)
服务器 `latest_result``confirmation_gate``engine_indistinguishable_width_minutes``confirmation_allowed``selection_allowed``margin_percent`(若有)。`confirmation_gate` 是确认门权威,不是让 Agent 另算一分钟。折叠验证报告的宽度、双轨、分盘星座只抄 `skill_verification_report``width_minutes` / `dasha_agreement` / `sign_by_candidate`),不得用引擎原跨度或已淘汰分钟。Agent 正文不得再写这些表。
- 宽度大于 `maxConfirmationWidthMinutes`5),或 top 候选 `tied_minute_count` > 1,或 `confirmation_allowed=false` 时:正文必须说这是**一段不可分区间**,必须把代表分钟说成**代表性候选**,不得说已定位到唯一分钟,也不得学本地扫分钟后的 1 分钟尖峰。
- `vedastro_minute_sensitive``not_evaluated` 表示官方分钟敏感校验尚未跑通,不是 fail;缺它不能写 confirmed。
- 若官方分钟层已 `passed``public_aa_holdout``not_ready`:可以说已区分相邻分钟,仍不得确认唯一分钟或发布准确率。
- `public_aa_holdout``not_ready` 时不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。
- 用户仍可 accepted 代表性候选;accepted ≠ confirmed。`session_outcome=adopt_representative` 时自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。`unique_minute_path=closed_at_representative` 时不得把确认当下一步。
- `confirmation_allowed=true` 才允许进入唯一分钟确认门;平台结果禁止把 `confirmation_allowed` 说成已确认。
- 候选卡仍可展示代表性时间;Agent 不得把该时间写成“已校正到 HH:MM”。
## 6. 保存边界
- accepted 写入 `active_birth_time`,保留 `reported_birth_time` 原填报,不写兼容 `birth_time`
- 采用后界面按采用分钟重算本命宫位表,并折叠展示本轮技法审计。这不是唯一分钟确认,也不自动进入咨询 Agent。
- confirmed 同样保留原填报;不自动写入,需要用户明确同意。
- 失败、空流、Skill 未加载或未完成必要工具链时不保存、不扣费。
@@ -0,0 +1,107 @@
# Conversation StrategyV10
生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。
## 1. 每轮上下文优先级
每轮先按以下优先级理解会话:
1. 当前 Case 的服务器状态与读写权限。
2. `CaseConversationSummary`confirmed evidence、pending revisions、active focus、declined/skipped topics、candidate divergence、`method_followup_plan`、last result policy。不要把 `missing_evidence_categories` 当下一问。
3. 当前用户消息。
4. recent turns:只作为有界原文引用窗口,辅助 quote grounding 和局部措辞理解。
recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什么”的倒推、正则匹配或被截断的聊天记录重建 Case 状态。summary 与局部文本不一致时,以服务器状态为准;若用户意图仍不唯一,只澄清一个关键点。
## 2. OpeningPolicy
首次开场只使用服务器 opening brief 中的 Case 状态、当前搜索窗口(intake 不确定档)、做法三句要点与六类领域清单,并自然满足:
- 三句模板:当前窗口与核对做法;「最后给区间和代表分钟,不给精确到秒」;「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。
- 一条消息可以报多件。不索要 10–15 条事件长表,不要一进场就出 A/B/C/D。只报一件也走既有逐领域采集;不得追问「还有吗 / 还能想起别的吗」,也不得重复开场邀请。第一道逐领域题干末尾由服务端带一次「想到别的也可以一起说」。
- 接受“大概某年 / 那几年 / 某个阶段”等模糊日期,不诱导猜月份、日期或精确时点。
- 不得写具体年份,不得要求先准备材料。
- 首题 `collect:other:*` 题干写成「先说你最容易想起的一两件,年月大概就行」。
- 至多一个主问题;开场可以零问题。
- 不固定复述身份、opening brief 原文或服务器字段。
区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?”
## 3. 一轮的基本形态
1. 先判断用户意图:新事件、批量事件、补日期、修正旧事实、回答上一问、确认/否认、询问进度或原因、拒答/换方向、查看或采用候选。
2. 先读取服务器 Case、summary 与 active focus;静默完成必要的工具调用后再输出答案。正文不叙述内部执行步骤,也不生成 Activity/技法凭证文案。
3. 自然回应本轮内容。证据轮正文只写一句复述:「记下了:年 月 事件短语(、…)。」不评价价值,不写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接在后面。
4. 清晰项先处理;若仍需追问,只保留一个最有信息增益的主问题。完整回复可以没有问题。
5. 不允许在同一回复中既要求补证据、又提供采用候选;不生成三条推荐问题。
6. `next_user_action.id=adopt_representative` 时本轮只解释结果并邀请采用,零追问(除非有 active focus)。`id=verify_adopted_time` 时本轮只核一件前事,不要 offer,不要看盘。仍有挡住出牌的 `next_followup` 时不得出示采用卡。提出门看 `propose_allowed`。精度阶段追问和占问不挡出牌;职业仍挡。不得询问外貌、体质、胎记或疤痕。宽度大于 5 仍可出示代表性时间卡,不得为把不可分区间问到 5 分钟以内而继续 A/B/C/D。`unique_minute_path=closed_at_representative` 时不得把唯一分钟确认当下一步。
## 4. ConversationFocus
active `ConversationFocus` 是承接型意图的唯一目标来源。它由服务器持久化并提供 `focusId`、目标 `evidenceId`(如有)、intent、预期回答结构和状态。
- “是的 / 不是 / 对 / 不对 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”只有在存在唯一 active focus 时才能解释为回答、拒答、确认或修订。
- 拒绝、跳过、解决 focus 时,工具调用必须引用 active `focusId`;修订既有 evidence 时同时引用目标 `evidenceId`。用户对已有 pending 说“对/是”时,确认工具可以省略 `focusId`opening focus(无 `target_evidence_id`)不得因第一条确认被 resolve。
- 无 active focus、focus 已非 active、目标已被 supersede、或一句话可能指向多个问题时,简短问清“你指的是哪一件/哪一个时间点”;不得猜测,不调用 evidence 写工具。
- 脱离 active focus 的“是的 / 不是”不是新事件。不得从 assistant 上一句倒推目标,不得只用 pending revision 构造 `active_followup`
- 当前消息若主动、明确陈述全新事件,可独立进入 evidence 流程;需要追问时由服务器建立新 focus。
- 服务器验证 focus 已失效时,停止该动作并基于最新 summary 重新回应,不沿用旧目标。
## 5. 自然叙述与批量 evidence
用户一段话中可以包含多件事件。应优先走服务器批量服务:
- 每件事件分别保留原话 `quote``kind``domain`、主体和日期精度,不合并,不要求逐条重发。
- 服务器对每项独立返回 `accepted``needs_clarification``rejected`。一项失败不改变其他项结果。
- 新事件优先走批量服务;一句里两件及以上事件时只允许批量。清晰项在批量路径上可由服务器直接 `confirmed`,不要再逐条 propose+confirm。不要让模糊项阻塞清晰项。
- 多个模糊项同时存在时,只选择信息增益最高的一项追问一个关键点,其余维持待澄清,不连续抛出问题清单。
- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。
- `rejected` 如需解释,只说明用户可理解的边界,不伪装成已记录。
- 批量 evidence item 的 `accepted` 是服务处理结果,不是候选采用状态;清晰项的最终 `status` 以服务器返回为准,批量路径上可以为 `confirmed`
- 询问进度/原因、拒答、查看结果、采用候选,以及无唯一 active focus 的承接词,都不是新事件。
## 6. 确认、修订、拒答与换方向
- 确认既有事实:必须有对应 `evidenceId`;确认词本身不创建新 evidence。无匹配 pending-target 的 focus 时可省略 `focusId`
- 修订既有事实:必须有 active `focusId` 和目标 `evidenceId`,生成 superseding revision,不覆盖历史;pending revision 不自动确认。
- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped,本会话不再问该领域采集;采用后核对仍可碰。回执「记下了,这题先放着。」
- 用户明确“没有 / 不想回答 / 换个方向”:decline/skip active focus;不得换词重开同一目标。采集题「没有」走 declined,回执「记下了,这方面先跳过。」
- 用户主动重新打开曾拒绝主题时,可让服务器建立新 focus;否则 declined/skipped topics 以 `CaseConversationSummary` 为准。
- 用户说“目前没有 / 没有更多事件”时,停止轮换证据领域;不要求结束、暂停或保存进度。
- 若没有其他具备信息增益的问题,可以直接说明当前边界或自然结束本轮。
## 7. 追问策略
追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级:
1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。
2. `method_followup_plan.next_followup` 指定的下一方法层。方法覆盖优先于对已覆盖领域的精度追问:有日期事件 → 感情 → 事业 → 家人(D12/D7/D3)→ 职业(挡出牌,独立于带日期事业事件)→ 占问(只问一次,不挡出牌)→ 再按精度阶段问关系盘/事业盘/居所(D4)/学业成就(D5,D24 换升并入同一问)。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务与健康只有用户主动说才问,仍可计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针或采用后核对前事时,`choice_frame` 才提供冲突节点;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative``next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。
3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。
4. pending revision 的一个关键歧义。
5. 已有证据的必要稳定性补强。
不要按 `missing_evidence_categories` 轮询迁居。财务与健康只有用户主动说才问,不是 SQL 类别轮询。`stop_domain_rotation=true` 时停止领域清单。一轮最多一个主要问题。用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,直接说明目的、当前状态和边界,不绕开问题继续索取证据。
## 8. 日期精度
- `year`:只说年份;复述用 `display_date_label`(如 `2024年`)。
- `month`:明确到月份;复述如 `2024-05`
- `quarter`:明确到季度。
- `day`:明确到日期;复述必须是 `YYYY-MM-DD`,禁止说成“年份已确定为 YYYY”。
- `range`:只有范围,不得擅自取中点当事实;复述用 `fromto`
- `unknown`:日期不明;可保留背景,但不得当作高权重校正证据。
- 用户确认“是 / 对”不得改 `date_precision`
- 用户只补月份/季度时,只有 active focus 与目标 evidence 已由服务器明确年份,才可合并为 revision;不得猜年份。
- “大概 3 月”仍按用户真实表达保存,不升级成某一天。
## 9. 候选输出与终态
- 候选卡负责呈现时间、排名、相对支持度、采用动作与选中状态。
- 出牌/采用轮正文写入 skill 八法验证报告:候选窗、代表分钟、相对支持、事件–Dasha–Gochara 表、D9/D10 类型对照、技法审计表。卡片仍作 adopt 控件。
- `relative_support` 不是概率,不能写“准确率 70%”。80%/60% 只描述事件吻合率。
- candidate、accepted、confirmed 严格分离;accepted 不是 confirmed。
- `next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间;正文自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。仍有 `next_followup` 时不得出示采用卡。
- 确认门以 `confirmation_gate` 为准。`not_evaluated` 不是 failholdout `not_ready``unique_minute_path=closed_at_representative`,不得声称精确分钟或发布准确率,也不得把唯一分钟确认当下一步。官方分钟层 `passed` 仍不能单独打开确认门。
- 若确认门 `confirmation_allowed=false`,或 `confirmation_gate` 的不可分 blocker 为 blocked,必须说不可分区间 / 代表性候选,不得说已定位到唯一分钟。交付轮宽度只抄 `skill_verification_report.width_minutes`。accepted ≠ confirmed。
- accepted 后按采用分钟核最多两件前事;对得上写入并重算,对不上可改选。不强制看盘,不要求用户结束、暂停或保存进度。核对结束或用户先这样才 `start_consultation`
- terminal Caseconfirmed / closed / abandoned / superseded)只读:不得新增/修订/确认 evidence,不得采用/确认候选;若用户要继续,指向显式新建 Case。
@@ -0,0 +1,122 @@
# Evidence ModelV9
证据是生时校正的唯一事实账本。本文件定义证据如何进入、校验、修订与关闭。服务器是证据账本的唯一写入者;Agent 只能提出 proposal。
## 1. 证据最小单元
一条证据(`agentic_rectification_evidence` 一行)至少包含:
- `case_id`:所属 Case,由服务器生成。
- `source_turn_id`:用户消息所在轮次;`source_message_id` 可选。
- `user_quote`:用户原话的规范化子串。
- `subject`:主体(`self` 或亲属关系;家庭事件必须显式 `related_person`)。
- `event_kind`:语义种类(见 §2),不再只保留粗领域。
- `domain`:评分/路由领域。
- `occurred_from` / `occurred_to`:真实日期边界,可空。
- `date_precision``year | month | quarter | day | range | unknown`
- `summary`:服务器从已验证引用中生成的安全摘要。
- `status``draft | pending_confirmation | confirmed | superseded | rejected`
- `supersedes_evidence_id`:修订链指针。
## 2. 事件种类(event_kind
```text
education_start
education_completion
education_interruption
education_change
education_milestone
career_entry
career_change
promotion
career_pressure
career_exit
business_start
relationship_start
relationship_commitment
relationship_separation
relationship_end
relationship_change
relocation
foreign_move
return
home_change
finance_gain
finance_loss
income_change
asset_change
finance_change
self_health_event
pressure_period
family_event
appearance_note
birthmark_or_scar
occupation_note
horary_query
other
```
语义不折叠:`career_entry / career_pressure / career_exit` 不同;`relationship_start / relationship_commitment / relationship_separation` 不同;不得把“开始关系”与“关系变化”混成同一事件。`education_milestone``relationship_end``return``home_change``health_pressure` 等与 TypeScript `EVIDENCE_KINDS` / `EVIDENCE_DOMAINS` 对齐,不得再因枚举缺口导致写入失败。
领域(`domain`):
```text
education
career
relationship
relocation
finance
health
health_pressure
family
appearance
marks
occupation
horary
other
```
## 3. 日期精度
- 用户只给年份 → `date_precision = 'year'``occurred_from = YYYY-01-01`(边界),不得诱导编造月份。
- 用户给年月 → `month`;给季度 → `quarter`;给年月日 → `day`;给区间 → `range`
- 相对表达(“刚毕业那年”)必须由服务器结合权威当前时间解析,Agent 不得自行假设年份。
- 跨午夜、未知时间不伪造具体分钟;`unknown` 精度允许保留。
- 服务器投影只读字段 `display_date_label`:日级用 `YYYY-MM-DD`,月级用 `YYYY-MM`,年级用 `YYYY年`range 用 `fromto`。复述必须用该标签;禁止把日级格式化成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。更粗的修订若 quote 并没有更粗的日期表达,服务器拒绝 `precision_downgrade`
## 4. 原文引用(quote grounding
- `user_quote` 必须能在对应 `source_turn.user_message` 中找到规范化匹配(去空白、去标点后子串命中)。
- 服务器确认路径必须校验:引用来自本轮用户消息、kind 属于枚举、日期与原文一致。
- 模型不得凭空补充月份、日期、原因、主动/被动、人物关系。
## 5. 修订链(append-only
- 事实变化 = 新增 superseding row,旧行标记 `superseded`,永不覆盖/删除。
- 合法修订:日期更正、日期补全(如“2016 年 + 9 月”合并为 `2016-09`)、事件重分类(同身份)。
- 非法修订:跨事件覆盖既有 ID(如把“大学入学”改成“搬家”);服务器拒绝并降级为新的 pending proposal。
- 证据 ID 只能由服务器生成;模型不得提供或覆盖。
## 6. 状态迁移
```text
draft -> confirmed (当前轮明确事件:proposal 通过原文绑定后,同轮走服务器确认路径)
draft -> pending_confirmation (事实模糊、冲突或需要用户补充)
pending_confirmation -> confirmed (用户明确确认 + 服务器确认路径)
pending_confirmation -> superseded(用户更正,产生修订)
confirmed -> superseded (后续修订使旧事实失效)
draft / pending_confirmation -> rejected (用户否认,保留只读历史)
```
- Agent 只能先产生 `draft``confirmed` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。
- 终态 Caseconfirmed/closed/abandoned/superseded)禁止新增或修订证据。
- 同一请求重放不得重复写证据(幂等键 = case + source_turn + quote + kind + summary)。
## 7. 评分输入边界
- 只有 `confirmed` 证据进入评分账本;`draft``pending_confirmation` 都不参与评分。
- `family_event` 进入评分(D12 + D7 + D3 + 六亲宫位)。`other` 只作背景,不推进评分覆盖计数。
- `appearance_note` / `birthmark_or_scar`:无日期只覆盖访谈;有日期才进上升/一宫辅助评分,不得当主公式。
- `occupation_note`:与带日期事业事件独立。无日期只覆盖访谈;有日期按 D10 + 本命 10 宫辅助评分,允许事业类型表作校时方法。
- `horary_query` 只作背景观察,不推进评分覆盖计数,也不计入 4 事件 / 3 领域。
- 证据变化才触发重算;相同证据指纹复用缓存,不重复评分。
@@ -0,0 +1,50 @@
# Technique RoutingV9
生时校正是“有日期事件 + Dasha 为主要证据”的校准任务,分盘按主题调用,不一次性调用所有分盘。所有计算只能通过服务端工具;本文件只决定读哪些技法证据,不复制任何引擎实现。
## 1. 主证据
- 有明确日期(年月级或更精确)的人生事件 + 对应 Dasha 边界是主要证据。
- 事件原文是用户原话;日期精度按用户真实提供保留。
- 不把“支持某技法”误当作已完成独立验证;内部一致性不得伪装成全球顶级精度。
## 2. 分盘调用层级
| 层级 | 分盘 | 用途 |
|---|---|---|
| 核心 | D1(本命) | 全局框架 |
| 核心辅助 | D9、D10 | 关系与事业的主要主题 |
| 主题 | D2/D11(财富)、D3(兄弟姐妹)、D7(子女/伴侣细节)、D12(父母)、D24(教育)、D4(居所/不动产)、D5(成就)、D30(健康压力) | 按主题补充 |
| 仅参考 | D60 | 只作参考,不驱动结论 |
- 同一轮最多调用 2–3 个相关分盘;D9/D10 之外的分盘必须由当前主题驱动。
- 未执行、不可用或仅供参考的技法不得显示为已执行。
## 3. 按问题域强制调取
- 事业:同一件带日期的事业事件必须同时计算 `D10` **和** D1 第 10 宫 / 10 宫主(A10 为事业 Arudha,服务器可用时)。职业说明与带日期事业事件独立,同样对照 D10 与本命 10 宫,**允许**事业类型表作校时方法;无日期只覆盖访谈。
- 财富:用户主动提供带日期的收入、资产或财务变化时计分 `D2 / D11`。不要主动追问。窗口扫描记录 D2/D11 换升,但不新增精度阶段。
- 婚恋:`D9 + UL`UL 为 Upapada Lagna,服务器可用时)。
- 六亲/家人:`D12``D7`(子女/伴侣细节)加 `D3`(兄弟姐妹)加 D1 三/四/五/九宫。家人事件进入评分,不只作背景。D3 不另开精度阶段。
- 外貌/体质/胎记疤痕:本轮访谈不追问。若用户主动提到带日期的外貌或受伤变化,只对照 D1 上升/一宫作辅助降权,不得当主评分。
- 健康:用户主动提供带日期的健康、事故或压力变化时计分 D1 + D30。不要主动追问。不是医学判断。窗口扫描记录 D30 换升,但不新增精度阶段。
- 迁居:精度阶段 `d4_refine` 问带日期的搬家/住处变化;这不是领域轮询。计分 D4 + D1 四/十二宫。
- 教育/成就:精度阶段 `d5_refine` 在 D5 **或 D24** 换升时问带日期的学业、考试或被委以责任的变化。计分 D24 + D5 + D1 四/五/九宫。D24 窗口扫描并入 `d5_refine`,不新增阶段 id。
- 占问:只问一次第一次认真问起这件事的时间。有日期则按该时点重算观察盘(出生地经纬,除非另给地点),可附 1/4/7/10 KP 子主。失败写成 blocked 观察,不计分,不挡提出门或确认门。没有时间或拒绝则 `skipped_by_policy`
- 精度阶段顺序:有日期事件 → 本命上升 → 方法覆盖(感情 → 事业 → 家人 → 职业 → 占问)→ 再对已覆盖领域做 D9 / D10 / D4 / D5(含 D24 换升)。家人不得混进 D4,也不另开 `d11_refine` / `d30_refine`。未走完挡住出牌的方法覆盖(含职业)时,不得因为关系盘仍会换升就提前出示时间卡。
- Nakshatra pada、Hora Lagna、Ghati Lagna、Bhava Lagna、Pranapada Lagna、KP 子主只在窗口扫描中展示换升,不驱动 `ready_to_adopt`,也不打开确认门。日出不可用时省略 Hora/Ghati/Pranapada,不得用 06:00 假日出。Bhava 只用本命日月,不依赖日出。
- D9/D10 类型表写入出牌轮验证报告,作为校时方法,不得写成命运承诺。`internal_observations.ask_theme` 决定下一问主题。
## 4. 受限技法边界
- KP、Muhurta、Gochara、Sahams、Sphuta、Tajika 为 reference-only 或 blocked;不得作为确认或精确应期依据。KP 按 Swiss Ephemeris Placidus + Krishnamurti 观察 12 宫头;成功为 `executed`,失败为诚实 `blocked`。不计分,不参与提出门或确认门。不得把政策跳过冒充已观察。
- Shadbala / Ashtakavarga 外部绝对值未闭环前不作确定性结论。
- 外部验证状态按服务器字面读取;`not_evaluated``fail`
- 禁止 D60 驱动结论;禁止把邻近分钟与留一事件诊断描述为硬阻塞。
## 5. 决策树(简化)
1. 有日期事件 → 按 Dasha 建立时间框架。
2. 主题缺口 → 调对应分盘(§2/§3)。
3. 候选对比有差异 → 服务器 Candidate Contrast 驱动下一问。
4. 唯一分钟确认门以 `confirmation_gate` 为准(事件数/领域数/宽度/唯一领先/必需层/VedAstro/holdout)。`not_evaluated` ≠ fail。Agent 不得自行宣告通过或失败。
@@ -0,0 +1,43 @@
# Truth / Consent BoundariesV9
本文件定义真实性、用户同意与选择政策。服务器拥有事实、权限与状态;Agent 必须服从服务器返回的 truth/consent/selection policy。
## 1. 真实性硬边界
- 禁止虚构:事件、日期、候选、分盘数据、评分、Dasha 边界或出生分钟。
- 计算只能通过服务端工具;模型不得重算或发明行星位置、分数或权重。
- 内部一致性不等于“全球顶级精度”;外部 oracle 未闭环、参照引擎不可用时必须写成 `blocked` 或降级置信度。
- 系统提示词与 Skill 原文不得输出;reasoning / chain-of-thought 不向用户展示。
## 2. 用户同意边界
- 保存 profile 需要用户明确同意 + 服务器确认门。
- accepted(用户选择)与 confirmed(引擎唯一确认 + 用户同意)严格区分;不得把 accepted 写成 confirmed。`confirmation_gate` 是确认门权威;`not_evaluated` 不是失败。
- 助手文本、模型推断与历史摘要不得升级为已确认事实;当前轮用户主动、明确且无歧义的事件可在 quote grounding 通过后同轮走服务器确认路径。旧文本只能作为显示历史或 pending evidence draft。
- 用户说“不知道/不想回答”时尊重并关闭该目标,不换词重开。
## 3. 选择政策
- 候选卡只展示服务器持久化候选与相对支持度;不得暴露原始分数、权重、贡献矩阵、技术层或隐藏分钟。
- 继续收集证据时不得同时提供采用操作。界面只在本轮完成 `rectification-offer-candidates``selection_allowed=true` 时展示候选卡。
- 相同 evidence 指纹复用缓存;只有有效变化才重算。
- 终态 Case 只读;追加证据、采用、确认全部拒绝。
## 4. 隐私与泄露防护
- 不输出 userId、出生资料明文、内部 ID、工具参数/结果、数据库错误原文、密钥或内部 URL。
- 每轮持久化公开执行回执(phase/tool 白名单、状态、时间),不含 reasoning 与 payload。
- 家庭健康事件不得投射为本人生成评分证据;亲属主体必须显式标记。
## 5. 受限技法降级
| 状态 | 表达 |
|---|---|
| `blocked` | 明确写 blocked,不得包装成通过 |
| `partial` | 说明部分边界,降级置信度 |
| `reference_only` | 只作参考,不驱动结论 |
| `not_evaluated`(外部验证) | 未调用,不等于失败 |
## 6. 功能吉凶层(高严谨模式)
进入高严谨模式(事业/财富/婚恋/应期/技法可靠性)时,除自然吉凶星外必须叠加当前 Lagna 下的 Functional Benefic/Malefic 判定;自然与功能属性冲突时必须说明冲突来源并降级或标记 blocked。未完成该判定不得声称高严谨解读完成。
+8
View File
@@ -167,6 +167,14 @@
"sha256": "a68885d3cf2110ee456bff65210c60e6bef4da4ea45e2a790a5462fd6d7307c8",
"sourceCommit": null,
"packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.19",
"status": "deprecated"
},
{
"name": "jyotish-birth-time-rectification",
"version": "10.0.20",
"sha256": "9c09867591cc9e8726f3577346230339b5a6dd4b4f3d634a9cda3e66170d6ac6",
"sourceCommit": null,
"packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.20",
"status": "active"
},
{
+127 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import unittest
from datetime import date
from datetime import date, datetime
from unittest.mock import patch
from uuid import UUID
@@ -13,7 +13,12 @@ from scripts.jyotish_api_server import (
JyotishAPIHandler,
)
from scripts.rectification.api_service import diagnostics, score_candidates
from scripts.rectification.candidate_contrast import (
MAX_PUBLIC_CLUSTERS,
select_signature_representatives,
)
from scripts.rectification.contracts import normalize_rectification_request
from scripts.rectification.decision_policy import build_candidate_decisions
from scripts.rectification.scoring_service import (
build_event_contribution_matrix,
calculation_spec,
@@ -472,7 +477,16 @@ class RectificationV5ServicesTest(unittest.TestCase):
self.assertEqual(first["candidate_decisions"], second["candidate_decisions"])
self.assertEqual(sum(item["relative_support"] for item in first["candidate_decisions"]), 100)
for rank, candidate in enumerate(first["candidate_decisions"], start=1):
self.assertEqual(set(candidate), {"candidate_id", "rank", "time", "relative_support", "tied_minute_count"})
self.assertEqual(set(candidate), {
"candidate_id",
"rank",
"time",
"relative_support",
"tied_minute_count",
"cluster_times",
"cluster_start",
"cluster_end",
})
self.assertEqual(candidate["rank"], rank)
UUID(candidate["candidate_id"])
@@ -1102,6 +1116,117 @@ class RectificationV5ServicesTest(unittest.TestCase):
self.assertEqual(set(windows), set(times))
self.assertLess(packet["column_compare_ms"], 1000)
def test_hour_window_keeps_all_seventeen_signature_clusters_including_late_tail(self):
rows, contexts = _hour_window_signature_fixture()
public = select_signature_representatives(rows, contexts)
times = {row["time"] for row in public}
self.assertEqual(MAX_PUBLIC_CLUSTERS, 64)
self.assertEqual(len(public), 17)
self.assertTrue(times & {"14:46", "14:47", "14:48", "14:49", "14:50", "14:51"})
self.assertIn("15:00", {time for row in rows for time in [row["time"]]})
decisions = build_candidate_decisions(
rows,
result_id="00000000-0000-4000-8000-000000000623",
static_contexts=contexts,
)
self.assertEqual(len(decisions), 17)
late = next(item for item in decisions if item["time"] in {"14:46", "14:47", "14:48", "14:49", "14:50", "14:51"})
self.assertEqual(late["cluster_start"], "14:46")
self.assertEqual(late["cluster_end"], "14:51")
self.assertEqual(late["cluster_times"][0], "14:46")
self.assertEqual(late["cluster_times"][-1], "14:51")
def test_cluster_coverage_not_representative_minute_sets_credible_span(self):
times = _clock_span("14:40", "14:45")
rows = [
{"time": time, "score": 20 if time == "14:40" else 11, "evidence": [], "missing_layers": []}
for time in times
]
contexts = [_signature_context(time, 3) for time in times]
decisions = build_candidate_decisions(
rows,
result_id="00000000-0000-4000-8000-000000000624",
static_contexts=contexts,
)
self.assertEqual(len(decisions), 1)
self.assertEqual(decisions[0]["time"], "14:40")
self.assertEqual(decisions[0]["cluster_start"], "14:40")
self.assertEqual(decisions[0]["cluster_end"], "14:45")
self.assertEqual(decisions[0]["cluster_times"], times)
def test_adjacent_low_score_merge_keeps_the_window_tail(self):
rows = []
contexts = []
for index in range(65):
time = f"{index // 60:02d}:{index % 60:02d}"
score = 1 if index >= 63 else 20
rows.append({"time": time, "score": score, "evidence": [], "missing_layers": []})
contexts.append(_signature_context(time, index))
public = select_signature_representatives(rows, contexts)
self.assertEqual(len(public), 64)
covered = {time for row in public for time in row.get("cluster_times", [row["time"]])}
self.assertIn("01:04", covered)
def _clock_span(start: str, end: str) -> list[str]:
start_minutes = int(start[:2]) * 60 + int(start[3:5])
end_minutes = int(end[:2]) * 60 + int(end[3:5])
return [f"{minute // 60:02d}:{minute % 60:02d}" for minute in range(start_minutes, end_minutes + 1)]
def _signature_context(time: str, signature_index: int) -> dict:
hour, minute = (int(part) for part in time.split(":"))
return {
"candidate_at": datetime(1997, 8, 8, hour, minute),
"ascendant_index": signature_index % 12,
"planet_longitudes": {"Moon": 13.0 * (signature_index % 27)},
"feature": {
"time": time,
"ascendant_sign_index": signature_index % 12,
"moon_degree": 13.0 * (signature_index % 27),
"varga_ascendants": {
"D4": (signature_index // 12) % 12,
"D9": (signature_index // 3) % 12,
"D10": (signature_index // 5) % 12,
"D24": (signature_index // 7) % 12,
"D12": (signature_index // 11) % 12,
},
},
}
def _hour_window_signature_fixture() -> tuple[list[dict], list[dict]]:
ranges = [
("14:00", "14:03"),
("14:04", "14:06"),
("14:07", "14:09"),
("14:10", "14:12"),
("14:13", "14:15"),
("14:16", "14:18"),
("14:19", "14:21"),
("14:22", "14:24"),
("14:25", "14:28"),
("14:29", "14:32"),
("14:33", "14:36"),
("14:37", "14:39"),
("14:40", "14:45"),
("14:46", "14:51"),
("14:52", "14:53"),
("14:54", "14:57"),
("14:58", "15:00"),
]
rows: list[dict] = []
contexts: list[dict] = []
for index, (start, end) in enumerate(ranges):
times = _clock_span(start, end)
for offset, time in enumerate(times):
score = 30 + index if offset == 0 else 10 + index
if start == "14:46":
score = 90 if offset == 0 else 80
rows.append({"time": time, "score": score, "evidence": [], "missing_layers": []})
contexts.append(_signature_context(time, index))
return rows, contexts
if __name__ == "__main__":
unittest.main()