diff --git a/CHANGELOG.md b/CHANGELOG.md index eff5d117..90ef586d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 明说要校正时,已采用过的人能直接新开一次 + +- 已经采用过出生时间的人,在对话里说要做生时纠正,会直接开始一次新的核对,不再只回「可在历史对话里接着做。」 +- 真没做完的校正仍然接着做。这次如果接不回原来那次,也直接新开。 +- 已采用过的账户,普通对话里的校正提议卡会重新出现。 +- Skill 版本不变(BUG-1423)。 + ## 2026-10-23 — 星盘页本命和月亮的行星表重新占满整行 - 宽屏下,本命和月亮的行星表回到参数卡旁边那一整行,宿主、pada、状态不用横拖就能看见。分盘、Bhava、行运仍是整行。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index af349f4e..1eeb6da9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19286,3 +19286,19 @@ - 复发自:无 - 修复版本:`codex/chart-table-span-20261023` +## BUG-1423 | 明说要做生时纠正只回一句「可在历史对话里接着做」 + +- 状态:resolved +- 首次发现:2026-10-22 +- 最近更新:2026-10-23 +- 影响面:对话里明确要求做生时校正的入口,以及普通对话里的校正提议卡 +- 用户现象:已经采用过出生时间的人,在对话里说要做生时纠正,只收到一句「可在历史对话里接着做。」没有卡片,也进不了校正。真正没做完的校正同样接不回去。 +- 触发条件:本人档案;出生时间显示已采用;在对话里明确要求校正。没做完的校正也会走到同一句,因为查找会话失败被吞掉。 +- 根因:三条。一,找会话时直接读一张服务角色没有权限的表,失败被静默吞掉,所以真正没做完的也续不上。二,把已经采用(实际上的终点)算作还在进行,明说入口和提议卡一起被挡住。三,失败时只给一句话,没有能点的出口。 +- 修复:入口只把还没做完的五种状态当作进行中,已采用不再挡住,明说要校正就新开一次,提议卡也按这个口径恢复。查找会话改走已有的校正读取接口。读不到就记一条不含个人资料的日志,并直接新开。删掉「可在历史对话里接着做。」共享的状态集合和数据库没有改。 +- 验证:入口口径单测;接不回时改为新开并有一条日志;`database-rectification-resume-session-20261023.test.ts` 用服务角色经接口拿到进行中校正的会话,直接读表仍然被拒绝。 +- 防复发:上面的口径单测和这条真库测试。再改回直接读表,或把已采用重新算作进行中,测试会失败。 +- 相关记录:BUG-1306、BUG-1248、BUG-177。issue #103。 +- 复发自:BUG-1306 +- 修复版本:`codex/rectification-explicit-open-20261023` + diff --git a/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md b/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md new file mode 100644 index 00000000..be2fd661 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-explicit-open-20261023.md @@ -0,0 +1,40 @@ +# PROGRESS · 明说要校正能打开(2026-10-23) + +Issue #103。BUG-1423。 + +## 基线 + +- 开工时 `origin/staging` = `d72adb5754eab4d66ac3a47c4edcb12704e29815`(任务书写的 `dd5ae8d03` 已过期)。 +- 本文件最大编号是 BUG-1420,本单用 BUG-1423。 + +## 做了什么 + +- 对话入口不再把「已采用」当成还在进行。明说要校正就新开一次,普通提议卡也按这个口径恢复。 +- 真没做完的五种状态仍接着做。找会话改走已有的校正读取接口,不再直接读那张表。 +- 读不到会话时记一条日志(只有请求号、错误类别、有没有 caseId),然后新开,不再说去历史对话接着做。 +- 删掉过期的「每人最多一条进行中校正」常量。共享状态集合和数据库没改。 + +## 已知边界 + +摘要只看最近一次。如果最近一次已经采用、更早还有一次没做完,这次按没有进行中处理,直接新开。更早那次仍留在历史对话里。数据库允许同一个人有多条校正。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 退出码 0 | +| lint | `frontend` 下 `npm run lint` | 退出码 0。0 error,122 条既有 warning,本轮不改 | +| 定向测试 | 任务书点名的三个测试文件 | 47 条通过,0 失败 | +| 真库 | `database-rectification-resume-session-20261023.test.ts` | 1 条通过。服务角色经接口拿到进行中校正的会话;直接读表被拒绝 | +| 全量真库 | `frontend` 下 `npm run test:db` | 91 条里通过 78、失败 13。失败名单与 `tools/agent-loop/windows-known-failures.txt` 逐条相同,没有多出来的 | +| 构建 | `frontend` 下 `npm run build` | 退出码 0。路由表 `/` 为 `○ Static`。预渲染 `index.html` 引用 33 个 JS、3 个 CSS,gzip-9 合计 646,232 字节(JS 575,154,CSS 71,078)。没有用改动前的代码再构建一次,所以不写增减百分比 | + +未跑前端全量 `npm test`(验收时跑)。真实模型会不会调用工具,留给 `docs/testing/rectification-explicit-open-20261023.md`。 + +## 让步 + +- T3 的自动化测试改写成真机清单第 4 步。入口和真库测试没有让。 + +## 偏离 + +- 无。编号说明:本分支的 BUG-1423 与尚未合并的 #95、#102 分支各自占用了同一个号。若那些先进入 staging,验收时给本条改号。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 31d4a9fd..adced5c3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -37,7 +37,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-rectification-explicit-open-20261023.md` | — | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表 | 待执行(循环) | 分支 `codex/rectification-explicit-open-20261023` | +| `TASK-rectification-explicit-open-20261023.md` | `PROGRESS-rectification-explicit-open-20261023.md` | **明说要校正只回一句话(issue #103)**:找会话直接读一张对服务角色撤权的表、必然失败被吞(真正进行中的也续不上);`candidate_accepted` 被算作进行中挡住入口和提议卡;兜底只给一句话。产品选 C;只改入口口径、经 RPC 找会话、删兜底,不改共享状态集合、不动表。BUG-1423 | 已合入 staging | 分支 `codex/rectification-explicit-open-20261023` | | `TASK-chart-table-span-20261023.md` | `PROGRESS-chart-table-span-20261023.md` | **星盘页本命 / 月亮行星表挤进左栏(issue #102)**:#87 给表包了 `ChartSymbolHelp` 后跨栏规则失配;根因修法是 `VedicLayout` 用固定整行容器承载表格,跨栏只认容器;加结构断言防复发。BUG-1422 | 已合入 staging | 分支 `codex/chart-table-span-20261023` | | `TASK-ui-kit-migration-20261023.md` | — | **界面统一换成组件库**:shadcn base-nova(补 `shadcn/tailwind.css`)· Lucide · Motion · react-resizable-panels · dnd-kit · Zustand;lint 拦手写控件(白名单只减不增);对话旁星盘面板;会话、档案拖动排序(动表);长相不变。产品 2026-10-23 拍板 D1~D8;8 个 issue(K0~K7)串行,取代全站动效 #98 #100 #101 | 待领取(循环) | 原型 https://claude.ai/artifact/PGnWsjHhu38KJseEfEMNWG | | `TASK-onboarding-journey-20261022.md` | `PROGRESS-onboarding-1-data-20261022.md` | **新用户启程**:建档加「先看谁的盘」(自己 / 帮别人看,本人资料可后补)、人物关系字段、首问免费(服务端原子)、起步三问、启程清单(七项从数据推导,收费项标价,老用户可见)、八条一次性提示(含星盘四页签)、启程流程专用动效。产品 2026-10-22 拍板 D1~D6;动表经产品同意进循环,拆四个 issue 串行。BUG-1420 已被文档去重占用,本轮首问免费记 BUG-1421。(一)T1+T2 在 `codex/onboarding-1-data-20261022` | (一)已合入 staging;(二)(三)(四)待领取 | 原型 https://claude.ai/artifact/PcTwMVb9RD611R3qStxv3U | diff --git a/docs/testing/rectification-explicit-open-20261023.md b/docs/testing/rectification-explicit-open-20261023.md new file mode 100644 index 00000000..10afad7f --- /dev/null +++ b/docs/testing/rectification-explicit-open-20261023.md @@ -0,0 +1,8 @@ +# 真机清单 · 明说要校正能打开(2026-10-23) + +用测试账号。不要把真实出生时间写进记录。 + +1. 已经采用过出生时间的账户,在对话里说「帮我做生时纠正」。应直接进入一次新的校正,不再只出现「可在历史对话里接着做。」 +2. 有一次答到一半、还没采用的校正。从对话里说要校正,应接回那一次。 +3. 已采用过的账户,普通对话问到时机一类的问题时,校正提议卡会出现。 +4. 已采用的旧校正还在,再开一次新的并采用另一个时间。打开旧的那次对话:采用后的核对题只读,不弹错误,控制台没有未处理的报错。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 8643a91a..84e7a1cc 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -39,8 +39,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 ## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) - **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 -- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。 -- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」 +- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经采用过时间的,不算还在进行,明说要校正就新开一次。真没做完的,直接接着做那一次。这次如果接不回原来的会话,也直接新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。 +- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。不再说「可在历史对话里接着做。」 - **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。 - **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。 - **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index edd51fec..062f24f9 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -33,7 +33,7 @@ import { consultationDomainSchema } from "@/lib/consultation-domain-registry"; import { parseAgentReply } from "@/lib/agent-reply"; import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat"; import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server"; -import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; +import { loadResumableRectificationSessionId } from "@/lib/rectification-resume-session"; import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata"; import { ConsultationPlanValidationError, @@ -1025,20 +1025,7 @@ export async function POST(request: Request) { alreadyOffered: sessionHasRectificationOffer(chatSession?.messages), revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId), sourceSessionId: sessionId, - loadResumableSessionId: async () => { - const summary = await getRectificationEntrySummary(accounting, userId); - const caseId = summary.latestResumable?.caseId; - if (!caseId) return null; - const { data, error } = await accounting - .from("agentic_rectification_cases") - .select("session_id") - .eq("user_id", userId) - .eq("id", caseId) - .maybeSingle(); - if (error || !data) return null; - const resumableSessionId = (data as { session_id?: unknown }).session_id; - return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null; - }, + loadResumableSessionId: () => loadResumableRectificationSessionId(accounting, userId), }; const hooks = createConsultationRuntimeHooks(state); const usages: Promise[] = []; diff --git a/frontend/src/lib/rectification-agentic/v9/case-status.ts b/frontend/src/lib/rectification-agentic/v9/case-status.ts index 9ef6a7ce..5b888624 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-status.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-status.ts @@ -61,13 +61,6 @@ export function evidenceWritesAllowed( return isResumableStatus(status); } -/** - * The one-resumable-case-per-user invariant, mirrored from the database - * partial unique index. The database is the enforcement point; this is the - * contract the service layer relies on. - */ -export const MAX_RESUMABLE_CASES_PER_USER = 1; - export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification"; // 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人 // 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26) diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index 2414a299..db275422 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -9,6 +9,7 @@ import { appendRectificationReturn, attachOpenedOfferToFreeMessage, bindOfferToCase, + blocksNewRectification, decideRectificationOfferWith, offerForCase, sourceFromSessionRows, @@ -35,7 +36,10 @@ export async function decideRectificationOffer(input: { return decideRectificationOfferWith({ subjectRole: input.subjectRole, productEnabled: () => isProductEnabled("rectification"), - hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase, + hasResumableCase: async () => { + const summary = await getRectificationEntrySummary(input.accounting, input.userId); + return blocksNewRectification(summary.latestResumable?.status ?? ""); + }, creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost, }); } diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts index f912d4c4..b1a08a09 100644 --- a/frontend/src/lib/rectification-in-chat.ts +++ b/frontend/src/lib/rectification-in-chat.ts @@ -86,7 +86,23 @@ export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时 export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。"; export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。"; export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持"; -export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。"; + +/** + * Chat entry only. An accepted case is a finished stop, so it must not block + * a new explicit open or the ordinary offer card. The shared resumable set + * still includes that status for old sessions, skill upgrade and rebaseline. + */ +const ENTRY_BLOCKING_RECTIFICATION_STATUSES = new Set([ + "draft", + "collecting_evidence", + "candidate_ready", + "needs_rebaseline", + "paused", +]); + +export function blocksNewRectification(status: string): boolean { + return ENTRY_BLOCKING_RECTIFICATION_STATUSES.has(status); +} export function rectificationReturnDividerText(record: Pick): string { return record.adopted && record.clock diff --git a/frontend/src/lib/rectification-resume-session.ts b/frontend/src/lib/rectification-resume-session.ts new file mode 100644 index 00000000..1c84951a --- /dev/null +++ b/frontend/src/lib/rectification-resume-session.ts @@ -0,0 +1,50 @@ +import type { DataClient } from "@/lib/db/data-client"; +import { + RectificationCaseServiceError, + getRectificationCase, + getRectificationEntrySummary, +} from "@/lib/rectification-agentic/v9/case-service"; +import { blocksNewRectification } from "@/lib/rectification-in-chat"; + +/** + * Raised when an in-progress case cannot be resumed. The message is the + * category only, so a log of this error cannot carry a profile or a quote. + */ +export class RectificationResumeLookupError extends Error { + readonly errorCategory: string; + readonly hasCaseId: boolean; + + constructor(errorCategory: string, hasCaseId: boolean) { + super(errorCategory); + this.name = "RectificationResumeLookupError"; + this.errorCategory = errorCategory; + this.hasCaseId = hasCaseId; + } +} + +function safeCategory(value: string): string { + return /^[a-z0-9_]+$/.test(value) ? value : "lookup_failed"; +} + +/** + * Session of the latest case that still blocks a new chat entry. + * Accepted cases do not block. The read goes through the case RPC: the + * service role cannot select the cases table. + */ +export async function loadResumableRectificationSessionId( + accounting: DataClient, + userId: string, +): Promise { + const summary = await getRectificationEntrySummary(accounting, userId); + const latest = summary.latestResumable; + if (!latest?.caseId || !blocksNewRectification(latest.status)) return null; + try { + const view = await getRectificationCase(accounting, userId, latest.caseId); + if (!view.sessionId) throw new RectificationResumeLookupError("missing_session", true); + return view.sessionId; + } catch (error) { + if (error instanceof RectificationResumeLookupError) throw error; + const category = error instanceof RectificationCaseServiceError ? error.code : "lookup_failed"; + throw new RectificationResumeLookupError(safeCategory(category), true); + } +} diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index b2ad3868..7bf15248 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -2,7 +2,6 @@ import { RECTIFICATION_DIRECT_NEW_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_RESUME_COPY, - RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_OFFER_TOOL_ID, type RectificationOfferDecision, @@ -1155,7 +1154,7 @@ const evidenceLookupInputSchema = z.object({ const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly["reason"], string>> = { not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`, product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`, - case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。", + case_in_progress: "用户已经有一次没做完的生时校正。这次不出提议卡,正文照常回答这一轮的问题。", pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`, }; @@ -1176,16 +1175,33 @@ function offerRecord( }; } +function resumeFailureLog(error: unknown): { errorCategory: string; hasCaseId: boolean } { + if (error && typeof error === "object" && "errorCategory" in error && "hasCaseId" in error) { + const record = error as { errorCategory?: unknown; hasCaseId?: unknown }; + const errorCategory = typeof record.errorCategory === "string" && /^[a-z0-9_]+$/.test(record.errorCategory) + ? record.errorCategory + : "lookup_failed"; + return { errorCategory, hasCaseId: record.hasCaseId === true }; + } + return { errorCategory: "lookup_failed", hasCaseId: false }; +} + +function logResumeLookupFailed(requestId: string, errorCategory: string, hasCaseId: boolean) { + console.warn("rectification_resume_lookup_failed", { requestId, errorCategory, hasCaseId }); +} + /** * Explicit request (BUG-1306): the server already decided. A pass records an * offered card with autoOpen and tells the stream to open. Status stays * offered until the case is bound. A case already in progress resumes that - * case. Anything else is one sentence and writes no offer. + * case. If that session cannot be read, open a new one instead of a dead end. + * Anything else is one sentence and writes no offer. */ async function finishExplicitRectificationRequest( state: ConsultationRuntimeState, hook: RectificationOfferHook, decision: RectificationOfferDecision, + requestId: string, ) { if (decision.ok) { state.rectificationOffer = offerRecord(hook, decision, "offered", true); @@ -1202,9 +1218,13 @@ async function finishExplicitRectificationRequest( } if (decision.reason === "case_in_progress") { let resumeSessionId: string | null = null; + let lookupFailed = false; try { resumeSessionId = await hook.loadResumableSessionId(); - } catch { + } catch (error) { + lookupFailed = true; + const failure = resumeFailureLog(error); + logResumeLookupFailed(requestId, failure.errorCategory, failure.hasCaseId); resumeSessionId = null; } if (resumeSessionId) { @@ -1221,12 +1241,18 @@ async function finishExplicitRectificationRequest( note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`, }; } + if (!lookupFailed) logResumeLookupFailed(requestId, "missing_session", false); state.rectificationDirectEntry = { - kind: "refused", - sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, + kind: "new", + sentence: RECTIFICATION_DIRECT_NEW_COPY, sourceSessionId: hook.sourceSessionId, }; - return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress }; + return { + offered: false as const, + directEntry: "new" as const, + reason: decision.reason, + note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`, + }; } const sentence = decision.reason === "not_self" ? RECTIFICATION_DIRECT_NOT_SELF_COPY @@ -1244,7 +1270,11 @@ async function finishExplicitRectificationRequest( * whether a card may be shown or this turn opens rectification. Never calculates. * The rule text lives only in RECTIFICATION_OFFER_RULE. */ -function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) { +function createRectificationOfferTool(ctx: Readonly<{ + state: ConsultationRuntimeState; + requestId: string; + rectificationOffer?: RectificationOfferHook; +}>) { return createTool({ id: RECTIFICATION_OFFER_TOOL_ID, description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.", @@ -1274,7 +1304,7 @@ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntime return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" }; } const decision = await hook.decide(); - if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision); + if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision, ctx.requestId); if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] }; ctx.state.rectificationOffer = offerRecord(hook, decision, "offered"); return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" }; diff --git a/frontend/tests/database-rectification-resume-session-20261023.test.ts b/frontend/tests/database-rectification-resume-session-20261023.test.ts new file mode 100644 index 00000000..fd694e58 --- /dev/null +++ b/frontend/tests/database-rectification-resume-session-20261023.test.ts @@ -0,0 +1,77 @@ +// Service role can resume an in-progress case only through the case RPC. +// A direct select of agentic_rectification_cases is revoked. Fictional ids only. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { loadResumableRectificationSessionId } from "../src/lib/rectification-resume-session.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const docker = spawnSync("docker", ["version"], { stdio: "ignore" }).status === 0; +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); + +test("service role loads an in-progress rectification session through the case RPC", { skip: !docker && "docker unavailable" }, async () => { + const fixture = startPostgresFixture(); + const url = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + const userId = randomUUID(); + const sessionId = randomUUID(); + const caseId = randomUUID(); + const snapshot = { + birth_date: "2000-06-15", + reported_birth_time: "00:10", + active_birth_time: null, + birth_time_source: "family_exact", + birth_time_period: null, + uncertainty_before_minutes: 15, + uncertainty_after_minutes: 15, + latitude: 40, + longitude: -74, + timezone_id: "America/New_York", + timezone_offset: -4, + }; + const range = { + start_time: "23:55", + end_time: "00:25", + candidate_intervals: [{ start_at: "2000-06-14T23:55", end_at: "2000-06-15T00:25" }], + }; + try { + const migration = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, `${migration.stdout}\n${migration.stderr}`); + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users(id,name,email,email_verified) + values ('${userId}','Synthetic Resume Fixture','${userId}@example.invalid',true); + `); + fixture.psql(` + update public.profiles set birth_date='2000-06-15',reported_birth_time='00:10',birth_time_source='family_exact', + birth_time_status='reported',uncertainty_before_minutes=15,uncertainty_after_minutes=15, + latitude=40,longitude=-74,timezone_id='America/New_York',timezone_offset=-4 where id='${userId}'; + insert into public.chat_sessions(id,user_id,title,theme,session_type,messages) + values('${sessionId}','${userId}','Synthetic resume','general','birth_time_rectification','[]'); + insert into public.agentic_rectification_cases(id,user_id,session_id,status,skill_name,skill_version, + baseline_profile_fingerprint,baseline_birth_snapshot,candidate_range) + values('${caseId}','${userId}','${sessionId}','collecting_evidence','jyotish-birth-time-rectification','9.0.0', + '${"a".repeat(64)}','${JSON.stringify(snapshot)}','${JSON.stringify(range)}'); + `); + const service = createLocalPostgresDataClient(url, null, "service_role"); + const direct = await service + .from("agentic_rectification_cases") + .select("session_id") + .eq("id", caseId) + .maybeSingle(); + assert.ok(direct.error, "a direct table read stays revoked for service_role"); + assert.match(String(direct.error?.message ?? ""), /permission denied/); + assert.equal(await loadResumableRectificationSessionId(service, userId), sessionId); + } finally { + await closeLocalPostgresDataPool(url); + fixture.stop(); + } +}); diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts index b0a86dd3..34436ca7 100644 --- a/frontend/tests/rectification-explicit-entry-20261012.test.ts +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -11,10 +11,10 @@ import { RectificationOfferCard } from "../src/components/rectification-in-chat- import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts"; import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts"; import { + blocksNewRectification, RECTIFICATION_DIRECT_NEW_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY, RECTIFICATION_DIRECT_RESUME_COPY, - RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_OFFER_OPENED_COPY, RECTIFICATION_OFFER_START_LABEL, @@ -48,7 +48,6 @@ const DIRECT_SENTENCES = [ RECTIFICATION_DIRECT_RESUME_COPY, RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_DIRECT_NOT_SELF_COPY, - RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, ] as const; const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; @@ -123,13 +122,22 @@ function walkSources(dir: string): string[] { } test("direct-entry sentences stay within the free-completion length", () => { - assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 11]); + // 原值:[13, 13, 14, 11, 11],含「可在历史对话里接着做。」 + // 新值:[13, 13, 14, 11] + // 原因:接不回进行中的校正时改为新开,不再留这句没有出口的兜底 + assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11]); for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20); assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。"); assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。"); assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。"); assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持"); - assert.equal(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。"); + assert.equal(blocksNewRectification("candidate_accepted"), false); + assert.equal(blocksNewRectification("collecting_evidence"), true); + assert.equal(blocksNewRectification("draft"), true); + assert.equal(blocksNewRectification("candidate_ready"), true); + assert.equal(blocksNewRectification("needs_rebaseline"), true); + assert.equal(blocksNewRectification("paused"), true); + assert.equal(blocksNewRectification("confirmed"), false); }); test("an opened offer with no case id binds, and a reload can find that case", () => { @@ -240,7 +248,19 @@ test("explicitRequest bypasses an earlier offer, and userAsked alone still only assert.equal(held.decisions(), 1); }); -test("an in-progress case resumes that session, or says to continue in history", async () => { +async function withResumeWarnings(run: () => Promise): Promise { + const warnings: unknown[][] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args); }; + try { + await run(); + } finally { + console.warn = original; + } + return warnings; +} + +test("an in-progress case resumes that session, or opens a new one when the session cannot be read", async () => { const resume = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }), loadResumableSessionId: async () => RESUME_SESSION, @@ -257,29 +277,49 @@ test("an in-progress case resumes that session, or says to continue in history", }); assert.equal(resume.loads(), 1); - const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); - const refused = await missing.tool.execute({ explicitRequest: true }); - assert.equal(refused.directEntry, undefined); - assert.equal(missing.state.rectificationDirectEntry?.kind, "refused"); - assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY); - assert.equal(missing.state.rectificationOffer, undefined); - assert.match(String(refused.note), /历史对话/); - assert.doesNotMatch(String(refused.note), /不要提校正/); - - const thrown = offerTool({ - decide: async () => ({ ok: false, reason: "case_in_progress" }), - loadResumableSessionId: async () => { throw new Error("db down"); }, + // 原值:loader 返回 null 时 kind=refused,句子是「可在历史对话里接着做。」 + // 新值:kind=new,句子是「好,我们开始核对出生时间。」,并记一条不含原文的日志 + // 原因:没有会话号时必须仍能打开一次校正 + const missingWarnings = await withResumeWarnings(async () => { + const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); + const opened = await missing.tool.execute({ explicitRequest: true }); + assert.equal(opened.directEntry, "new"); + assert.equal(missing.state.rectificationDirectEntry?.kind, "new"); + assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY); + assert.equal(missing.state.rectificationOffer, undefined); + assert.match(String(opened.note), /好,我们开始核对出生时间/); + assert.doesNotMatch(String(opened.note), /历史对话/); }); - const fallback = await thrown.tool.execute({ explicitRequest: true }); - assert.equal(thrown.state.rectificationDirectEntry?.kind, "refused"); - assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY); - assert.match(String(fallback.note), /历史对话/); + assert.equal(missingWarnings.length, 1); + assert.equal(missingWarnings[0]?.[0], "rectification_resume_lookup_failed"); + assert.deepEqual(missingWarnings[0]?.[1], { requestId: REQUEST_ID, errorCategory: "missing_session", hasCaseId: false }); + + const thrownWarnings = await withResumeWarnings(async () => { + const thrown = offerTool({ + decide: async () => ({ ok: false, reason: "case_in_progress" }), + loadResumableSessionId: async () => { throw new Error("db down"); }, + }); + const fallback = await thrown.tool.execute({ explicitRequest: true }); + assert.equal(thrown.state.rectificationDirectEntry?.kind, "new"); + assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY); + assert.equal(fallback.directEntry, "new"); + assert.doesNotMatch(JSON.stringify(fallback), /db down/); + }); + assert.equal(thrownWarnings.length, 1); + const thrownPayload = JSON.stringify(thrownWarnings[0]); + assert.match(thrownPayload, /lookup_failed/); + assert.match(thrownPayload, new RegExp(REQUEST_ID)); + assert.doesNotMatch(thrownPayload, /db down/); const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); const ordinary = await quiet.tool.execute({}); assert.equal(quiet.loads(), 0); assert.equal(quiet.state.rectificationDirectEntry, undefined); - assert.match(String(ordinary.note), /历史对话/); + // 原值:普通提议的说明匹配 /历史对话/ + // 新值:说明这次不出卡,不提历史对话 + // 原因:那句兜底已经删掉;没做完时仍不出新卡 + assert.match(String(ordinary.note), /不出提议卡/); + assert.doesNotMatch(String(ordinary.note), /历史对话/); assert.doesNotMatch(String(ordinary.note), /不要提校正/); }); @@ -682,9 +722,14 @@ test("the offer rule is defined once, and direct entry settles with the free com assert.match(windowBlock, /precomputeWindowConsultation/); assert.match(windowBlock, /运行合同不完整/); assert.match(natalBlock, /运行合同不完整/); - assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/); - assert.match(route, /\.from\("agentic_rectification_cases"\)/); - assert.match(route, /\.select\("session_id"\)/); + // 原值:route 直接 getRectificationEntrySummary 后 from("agentic_rectification_cases").select("session_id") + // 新值:route 调用 loadResumableRectificationSessionId,不再读这张表 + // 原因:服务角色对该表没有读权限,直接读必然失败还被吞掉 + assert.match(route, /loadResumableRectificationSessionId\(accounting, userId\)/); + assert.doesNotMatch(route, /\.from\("agentic_rectification_cases"\)/); + assert.doesNotMatch(route, /\.select\("session_id"\)/); + const serverFile = src("../src/lib/rectification-in-chat-server.ts"); + assert.match(serverFile, /blocksNewRectification\(summary\.latestResumable\?\.status \?\? ""\)/); const stream = src("../src/lib/stream-agent-response.ts"); assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/); diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index 9c515310..d7c40429 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.ts @@ -15,6 +15,7 @@ import { import { BIRTH_TIME_CHANGED_HISTORY_NOTE, RECTIFICATION_OFFER_TOOL_ID, + blocksNewRectification, appendRectificationReturn, bindOfferToCase, decideRectificationOfferWith, @@ -111,6 +112,21 @@ test("the server decides whether a card may be shown: self, product on, no case assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "other", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: false, reason: "not_self" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: no, hasResumableCase: no, creditCost: price }), { ok: false, reason: "product_disabled" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: yes, creditCost: price }), { ok: false, reason: "case_in_progress" }); + // 原值:只要摘要说有可续做校正,就判 case_in_progress + // 新值:已采用(candidate_accepted)不再挡住入口;collecting_evidence 仍挡住 + // 原因:已采用是终点。入口改看 blocksNewRectification,不改共享状态集合 + assert.deepEqual(await decideRectificationOfferWith({ + subjectRole: "self", + productEnabled: yes, + hasResumableCase: async () => blocksNewRectification("candidate_accepted"), + creditCost: price, + }), { ok: true, creditCost: 3, questionEstimate: 8 }); + assert.deepEqual(await decideRectificationOfferWith({ + subjectRole: "self", + productEnabled: yes, + hasResumableCase: async () => blocksNewRectification("collecting_evidence"), + creditCost: price, + }), { ok: false, reason: "case_in_progress" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: async () => { throw new Error("feature_pricing_unavailable"); } }), { ok: false, reason: "pricing_unavailable" }); assert.deepEqual(await decideRectificationOfferWith({ subjectRole: "self", productEnabled: yes, hasResumableCase: no, creditCost: price }), { ok: true, creditCost: 3, questionEstimate: 8 }); }); diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts index a0f710e2..2da1c6a3 100644 --- a/frontend/tests/rectification-v9-contracts.test.ts +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { - MAX_RESUMABLE_CASES_PER_USER, RECTIFICATION_CASE_STATUSES, RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION, @@ -91,7 +90,10 @@ test("terminal transitions are one-way and evidence writes stop at terminal", () assert.equal(evidenceWritesAllowed("confirmed"), false); assert.equal(evidenceWritesAllowed("closed"), false); assert.equal(evidenceWritesAllowed("superseded"), false); - assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1); + // 原值:MAX_RESUMABLE_CASES_PER_USER === 1 + // 新值:不再导出这个常量 + // 原因:它声称镜像「每人最多一条进行中校正」的部分唯一索引,该索引已由 BUG-177 删除,没有业务逻辑再读它 + assert.equal(readFileSync(new URL("../src/lib/rectification-agentic/v9/case-status.ts", import.meta.url), "utf8").includes("MAX_RESUMABLE_CASES_PER_USER"), false); }); test("the active rectification skill pins the v10 identity and lives in the right directory", () => {