fix(chat): keep rectification sessions findable and keep the delivery card
Rectification answers now advance chat_sessions.updated_at (BUG-704). A ?c= id missing from the loaded page is fetched before anyone may call it deleted (BUG-705). The range card no longer has a post-card tie-break button; live questions leave the card visible with adopt locked (BUG-706/708). Spoken copy bans 相对支持度 (BUG-709). Task docs assigned 700-704; qizheng already took 700-703.
This commit is contained in:
+3
-3
@@ -293,7 +293,7 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
|
||||
- **Structure:** title, optional subtitle for another person's chart, current-state marker.
|
||||
- **States:** default, hover, current, focus, disabled.
|
||||
- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity. Locked by `frontend/tests/session-open-preserves-identity.test.ts`.
|
||||
- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`.
|
||||
- **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles.
|
||||
- **Surface:** translucent warm-gray sidebar; current uses a white glass surface and deep-brown marker.
|
||||
|
||||
@@ -478,7 +478,7 @@ or user IDs.
|
||||
|
||||
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
|
||||
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出卡。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
|
||||
校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。
|
||||
|
||||
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
@@ -546,7 +546,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
每列 = 时间 + 相对可能性 + 至多三行 + 「更像这个」。列内不再有 `<h3>`。经历对照由服务端拼成一行:「8 件经历里 7 件对得上,最不合的是 2024 年 5 月那段感情」。未来窗一行:「下一个值得留意的时段:2027 年 3 月前后(事业)」;算过但没有窗写「未来一年没有明显的时段」;`by_time` 缺键写灰字「这一分钟还没对照」,不得回退成 `0 · 0 · 0` 或把「没算」说成「没有窗」。用户可见文案不得出现「强相关 / 有关联 / 弱关联」。
|
||||
|
||||
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。收尾句跟卡片同一列。
|
||||
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。
|
||||
|
||||
引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
|
||||
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 |
|
||||
| 相对支持度 16 · 采用此时间 | 一行至多三列。相同性格句只写一次。每列:相对可能性、有差别的性格句、人话经历对照、下一个时段,点「更像这个」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是区间里并排的候选分钟,不是四张卡上的支持度数字。 |
|
||||
| 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。卡上三列仍写「相对可能性 %」。 | 旁白不得念内部计分词。卡上的「相对可能性」是 09-08 定稿,不要动。 |
|
||||
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 |
|
||||
| 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 |
|
||||
| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 |
|
||||
|
||||
@@ -2,7 +2,7 @@ import pg from "pg";
|
||||
|
||||
const { Client } = pg;
|
||||
|
||||
const MATCH_SQL = `
|
||||
const TITLE_MATCH_SQL = `
|
||||
session_type = 'birth_time_rectification'
|
||||
and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$'
|
||||
and (
|
||||
@@ -14,9 +14,9 @@ and (
|
||||
)
|
||||
`;
|
||||
|
||||
const COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${MATCH_SQL}`;
|
||||
const TITLE_COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${TITLE_MATCH_SQL}`;
|
||||
|
||||
const APPLY_SQL = `
|
||||
const TITLE_APPLY_SQL = `
|
||||
update public.chat_sessions
|
||||
set title = case
|
||||
when created_at is null then '生时校正'
|
||||
@@ -25,7 +25,37 @@ set title = case
|
||||
|| to_char(created_at at time zone 'Asia/Shanghai', 'FMDD')
|
||||
|| '日 · 生时校正'
|
||||
end
|
||||
where ${MATCH_SQL}
|
||||
where ${TITLE_MATCH_SQL}
|
||||
`;
|
||||
|
||||
const ACTIVITY_COUNT_SQL = `
|
||||
select count(*)::int as n
|
||||
from public.chat_sessions as session
|
||||
join public.agentic_rectification_cases as case_row
|
||||
on case_row.session_id = session.id
|
||||
and case_row.user_id = session.user_id
|
||||
left join lateral (
|
||||
select max(turn.created_at) as last_turn_at
|
||||
from public.agentic_rectification_turns as turn
|
||||
where turn.case_id = case_row.id
|
||||
) as turns on true
|
||||
where session.session_type = 'birth_time_rectification'
|
||||
and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at)
|
||||
`;
|
||||
|
||||
const ACTIVITY_APPLY_SQL = `
|
||||
update public.chat_sessions as session
|
||||
set updated_at = coalesce(turns.last_turn_at, case_row.last_activity_at)
|
||||
from public.agentic_rectification_cases as case_row
|
||||
left join lateral (
|
||||
select max(turn.created_at) as last_turn_at
|
||||
from public.agentic_rectification_turns as turn
|
||||
where turn.case_id = case_row.id
|
||||
) as turns on true
|
||||
where case_row.session_id = session.id
|
||||
and case_row.user_id = session.user_id
|
||||
and session.session_type = 'birth_time_rectification'
|
||||
and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at)
|
||||
`;
|
||||
|
||||
function connectionString() {
|
||||
@@ -44,14 +74,19 @@ async function main() {
|
||||
const client = new Client({ connectionString: connectionString() });
|
||||
await client.connect();
|
||||
try {
|
||||
const before = await client.query(COUNT_SQL);
|
||||
const n = before.rows[0]?.n ?? 0;
|
||||
console.log(`mismatched_dated_rectification_titles=${n}`);
|
||||
const titlesBefore = await client.query(TITLE_COUNT_SQL);
|
||||
const activityBefore = await client.query(ACTIVITY_COUNT_SQL);
|
||||
console.log(`mismatched_dated_rectification_titles=${titlesBefore.rows[0]?.n ?? 0}`);
|
||||
console.log(`stale_rectification_updated_at=${activityBefore.rows[0]?.n ?? 0}`);
|
||||
if (!apply) return;
|
||||
const updated = await client.query(APPLY_SQL);
|
||||
const after = await client.query(COUNT_SQL);
|
||||
console.log(`updated_rows=${updated.rowCount ?? 0}`);
|
||||
console.log(`mismatched_after=${after.rows[0]?.n ?? 0}`);
|
||||
const titlesUpdated = await client.query(TITLE_APPLY_SQL);
|
||||
const activityUpdated = await client.query(ACTIVITY_APPLY_SQL);
|
||||
const titlesAfter = await client.query(TITLE_COUNT_SQL);
|
||||
const activityAfter = await client.query(ACTIVITY_COUNT_SQL);
|
||||
console.log(`updated_title_rows=${titlesUpdated.rowCount ?? 0}`);
|
||||
console.log(`updated_activity_rows=${activityUpdated.rowCount ?? 0}`);
|
||||
console.log(`mismatched_after=${titlesAfter.rows[0]?.n ?? 0}`);
|
||||
console.log(`stale_updated_at_after=${activityAfter.rows[0]?.n ?? 0}`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
@@ -3326,24 +3326,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-range-delivery__accept:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.rectification-range-delivery__tie-break {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-ink-secondary);
|
||||
background: var(--color-canvas);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rectification-range-delivery__tie-break:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.rectification-range-delivery__tie-break:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.rectification-range-delivery__invite {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
|
||||
@@ -210,6 +210,7 @@ import {
|
||||
LoginRedirectError,
|
||||
mergeHydratedSession,
|
||||
patchSessionModel,
|
||||
resolveLookupBootstrap,
|
||||
payloadCode,
|
||||
payloadMessage,
|
||||
readSessions,
|
||||
@@ -993,12 +994,22 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
const bootstrapSelection = resolveBootstrapSessionSelection({
|
||||
const listedSelection = resolveBootstrapSessionSelection({
|
||||
listedIds: nextSessions.map((session) => session.id),
|
||||
defaultSessionId: nextSessions[0].id,
|
||||
search: window.location.search,
|
||||
storedReturnId: readLoginSessionReturn(),
|
||||
});
|
||||
const lookedUp = await resolveLookupBootstrap({
|
||||
selection: listedSelection,
|
||||
sessions: nextSessions,
|
||||
defaultSessionId: nextSessions[0].id,
|
||||
catalog: nextModelCatalog,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
nextSessions = lookedUp.sessions;
|
||||
const bootstrapSelection = lookedUp.selection;
|
||||
let landingSessionId = resolveStarterHomeLandingSessionId(
|
||||
nextSessions,
|
||||
bootstrapSelection.sessionId,
|
||||
@@ -1061,6 +1072,8 @@ export default function Home() {
|
||||
setComposerNotice("模型服务暂时不可用,当前无法发送问题。");
|
||||
} else if (parsedSessions.fallbackSessionIds.length > 0) {
|
||||
setComposerNotice("此前选择的模型已下线,已切换为默认模型。");
|
||||
} else if (lookedUp.notice) {
|
||||
setComposerNotice(lookedUp.notice);
|
||||
} else if (bootstrapSelection.missing) {
|
||||
setComposerNotice(SESSION_MISSING_NOTICE);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
|
||||
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
|
||||
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
|
||||
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
@@ -1343,45 +1343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]);
|
||||
|
||||
const requestTieBreak = useCallback(async () => {
|
||||
if (!candidateResult || acceptingCandidateId || busy || readonly) return;
|
||||
if (!candidateResult.rangeDelivery?.tie_break_available) return;
|
||||
setError("");
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/rectification/cases/${encodeURIComponent(caseId)}/tie-break`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
requestId: globalThis.crypto.randomUUID(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (payload?.code === "tie_break_unavailable") {
|
||||
await loadCaseSnapshot();
|
||||
return;
|
||||
}
|
||||
if (!response.ok || payload?.ok !== true) {
|
||||
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
|
||||
}
|
||||
await loadCaseSnapshot((turns) => {
|
||||
setMessages((current) => applySnapshotTurnsToMessages(
|
||||
current,
|
||||
turns,
|
||||
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
|
||||
));
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, readonly, sessionId, setPending]);
|
||||
|
||||
async function copyMessage(message: RenderMessage) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question));
|
||||
@@ -1509,15 +1470,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const keepSelectionCardsWhileBusy = Boolean(
|
||||
acceptingCandidateId || candidateResult?.selectedTime,
|
||||
);
|
||||
const liveInterviewBlockingAdopt = Boolean(
|
||||
candidateResult
|
||||
&& !candidateResult.selectedTime
|
||||
&& messages.some((message) => (
|
||||
interviewQuestionBlocksAdoptOffer(message.question, false)
|
||||
)),
|
||||
);
|
||||
const showSelectionCards = Boolean(
|
||||
candidateResult
|
||||
&& caseSnapshotLoaded
|
||||
&& (!busy || keepSelectionCardsWhileBusy)
|
||||
&& selectionCardMessageKey
|
||||
&& (canOfferCards || Boolean(candidateResult.selectedTime))
|
||||
&& !messages.some((message) => (
|
||||
interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime))
|
||||
)),
|
||||
);
|
||||
const showReadonlyRange = Boolean(
|
||||
canShowRectificationReadonlyRange(candidateResult)
|
||||
@@ -1843,7 +1808,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
acceptingCandidateId={acceptingCandidateId}
|
||||
readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)}
|
||||
onAccept={(candidateId) => void acceptCandidate(candidateId)}
|
||||
onTieBreak={() => void requestTieBreak()}
|
||||
adoptLocked={liveInterviewBlockingAdopt}
|
||||
/>
|
||||
{verifiedIdleCopy && (
|
||||
<p className="rectification-pending-note" role="status">
|
||||
|
||||
@@ -21,19 +21,19 @@ export function RectificationRangeDelivery({
|
||||
acceptingCandidateId,
|
||||
readonly,
|
||||
onAccept,
|
||||
onTieBreak,
|
||||
adoptLocked,
|
||||
}: Readonly<{
|
||||
result: RectificationCandidateResult;
|
||||
acceptingCandidateId: string | null;
|
||||
readonly: boolean;
|
||||
onAccept: (candidateId: string) => void;
|
||||
onTieBreak?: () => void;
|
||||
adoptLocked?: boolean;
|
||||
}>) {
|
||||
const delivery: RangeDeliveryProjection | null = result.rangeDelivery;
|
||||
const range = delivery?.range ?? result.credibleRange;
|
||||
const eventCount = delivery?.event_count ?? 0;
|
||||
const selected = result.selectedTime;
|
||||
const busy = Boolean(acceptingCandidateId) || readonly;
|
||||
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
|
||||
const columns = delivery?.columns ?? [];
|
||||
const title = range
|
||||
? eventCount > 0
|
||||
@@ -68,15 +68,10 @@ export function RectificationRangeDelivery({
|
||||
{delivery?.provenance_line ? (
|
||||
<p className="rectification-range-delivery__provenance">{delivery.provenance_line}</p>
|
||||
) : null}
|
||||
{delivery?.tie_break_available && onTieBreak && !readonly ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rectification-range-delivery__tie-break"
|
||||
disabled={busy}
|
||||
onClick={onTieBreak}
|
||||
>
|
||||
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
|
||||
</button>
|
||||
{adoptLocked ? (
|
||||
<p className="rectification-range-delivery__narrow">
|
||||
{RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked}
|
||||
</p>
|
||||
) : null}
|
||||
{delivery?.tie_break_note ? (
|
||||
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRef } from "react";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import {
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
SESSION_MISSING_NOTICE,
|
||||
parseSessionUrlQuery,
|
||||
writeSessionUrl,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
fetchSessionDetail,
|
||||
fetchSessions,
|
||||
LoginRedirectError,
|
||||
lookupSessionById,
|
||||
mergeHydratedSession,
|
||||
patchSessionModel,
|
||||
readSessions,
|
||||
@@ -405,7 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
const query = parseSessionUrlQuery(search);
|
||||
const fallbackId = listed[0]?.id ?? "";
|
||||
const requestedId = query.sessionId;
|
||||
if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) {
|
||||
if (query.present && !requestedId) {
|
||||
writeSessionUrl(null, "replace");
|
||||
sessionSelectionSource.current = "history";
|
||||
if (fallbackId) selectSession(fallbackId);
|
||||
@@ -413,6 +415,29 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
setComposerNotice(SESSION_MISSING_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) {
|
||||
void lookupSessionById(requestedId, modelCatalog).then((looked) => {
|
||||
if (looked.status === "found") {
|
||||
setSessions((current) => mergeHydratedSession(current, looked.session));
|
||||
sessionSelectionSource.current = "history";
|
||||
selectSession(looked.session.id);
|
||||
return;
|
||||
}
|
||||
if (looked.status === "missing") {
|
||||
writeSessionUrl(null, "replace");
|
||||
sessionSelectionSource.current = "history";
|
||||
if (fallbackId) selectSession(fallbackId);
|
||||
else setActiveSessionId("");
|
||||
setComposerNotice(SESSION_MISSING_NOTICE);
|
||||
return;
|
||||
}
|
||||
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
|
||||
}).catch((caught) => {
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!requestedId) {
|
||||
if (fallbackId) {
|
||||
sessionSelectionSource.current = "history";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SESSION_URL_QUERY_KEY = "c";
|
||||
export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return";
|
||||
export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除";
|
||||
export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。";
|
||||
export const SESSION_URL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export type SessionUrlQuery = {
|
||||
@@ -10,7 +11,7 @@ export type SessionUrlQuery = {
|
||||
|
||||
export type BootstrapSessionSelection = {
|
||||
readonly sessionId: string;
|
||||
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none";
|
||||
readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
|
||||
readonly missing: boolean;
|
||||
readonly clearStoredReturn: boolean;
|
||||
};
|
||||
@@ -88,6 +89,14 @@ export function resolveBootstrapSessionSelection(input: {
|
||||
clearStoredReturn: true,
|
||||
};
|
||||
}
|
||||
if (query.sessionId) {
|
||||
return {
|
||||
sessionId: query.sessionId,
|
||||
urlAction: "lookup",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionId: input.defaultSessionId,
|
||||
urlAction: "replace-clear",
|
||||
@@ -105,10 +114,10 @@ export function resolveBootstrapSessionSelection(input: {
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionId: input.defaultSessionId,
|
||||
urlAction: "replace-clear",
|
||||
missing: true,
|
||||
clearStoredReturn: true,
|
||||
sessionId: input.storedReturnId,
|
||||
urlAction: "lookup",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -118,3 +127,32 @@ export function resolveBootstrapSessionSelection(input: {
|
||||
clearStoredReturn: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function bootstrapSelectionFromLookup(
|
||||
status: "found" | "missing" | "unavailable",
|
||||
requestedId: string,
|
||||
defaultSessionId: string,
|
||||
): BootstrapSessionSelection {
|
||||
if (status === "found") {
|
||||
return {
|
||||
sessionId: requestedId,
|
||||
urlAction: "keep",
|
||||
missing: false,
|
||||
clearStoredReturn: true,
|
||||
};
|
||||
}
|
||||
if (status === "missing") {
|
||||
return {
|
||||
sessionId: defaultSessionId,
|
||||
urlAction: "replace-clear",
|
||||
missing: true,
|
||||
clearStoredReturn: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionId: defaultSessionId,
|
||||
urlAction: "none",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type LandingSession = Readonly<{
|
||||
messages: readonly unknown[];
|
||||
}>;
|
||||
|
||||
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none";
|
||||
export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup";
|
||||
|
||||
/**
|
||||
* A rectification Case is opened only when the address bar names that session
|
||||
@@ -43,7 +43,9 @@ export function resolveStarterHomeLandingSessionId(
|
||||
selectedId: string,
|
||||
urlAction: LandingUrlAction,
|
||||
): string {
|
||||
if (urlAction === "keep" || urlAction === "replace-selected") return selectedId;
|
||||
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") {
|
||||
return selectedId;
|
||||
}
|
||||
const selected = sessions.find((session) => session.id === selectedId);
|
||||
if (selected?.sessionType !== "birth_time_rectification") return selectedId;
|
||||
const emptyConsultation = sessions.find(
|
||||
@@ -59,7 +61,7 @@ export function starterHomeLandingNeedsConsultation(
|
||||
landingId: string,
|
||||
urlAction: LandingUrlAction,
|
||||
): boolean {
|
||||
if (urlAction === "keep" || urlAction === "replace-selected") return false;
|
||||
if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false;
|
||||
const landing = sessions.find((session) => session.id === landingId);
|
||||
return !landing || landing.sessionType === "birth_time_rectification";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import { persistLoginSessionReturn } from "@/lib/chat-session-url";
|
||||
import {
|
||||
bootstrapSelectionFromLookup,
|
||||
persistLoginSessionReturn,
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
type BootstrapSessionSelection,
|
||||
} from "@/lib/chat-session-url";
|
||||
import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan";
|
||||
import {
|
||||
parsePublicModelCatalog,
|
||||
@@ -465,6 +470,68 @@ export async function fetchSessionDetail(
|
||||
return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null;
|
||||
}
|
||||
|
||||
export type SessionLookupResult =
|
||||
| { status: "found"; session: ChatSession }
|
||||
| { status: "missing" }
|
||||
| { status: "unavailable" };
|
||||
|
||||
export async function lookupSessionById(
|
||||
sessionId: string,
|
||||
catalog: PublicLanguageModelCatalog | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionLookupResult> {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) redirectToLogin();
|
||||
if (response.status === 404) return { status: "missing" };
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) return { status: "unavailable" };
|
||||
const sessionValue = payload && typeof payload === "object"
|
||||
? (payload as { session?: unknown }).session
|
||||
: null;
|
||||
const session = readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0];
|
||||
if (!session) return { status: "missing" };
|
||||
return { status: "found", session };
|
||||
}
|
||||
|
||||
export async function resolveLookupBootstrap(input: {
|
||||
selection: BootstrapSessionSelection;
|
||||
sessions: ChatSession[];
|
||||
defaultSessionId: string;
|
||||
catalog: PublicLanguageModelCatalog | null;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
selection: BootstrapSessionSelection;
|
||||
sessions: ChatSession[];
|
||||
notice: string | null;
|
||||
}> {
|
||||
if (input.selection.urlAction !== "lookup") {
|
||||
return { selection: input.selection, sessions: input.sessions, notice: null };
|
||||
}
|
||||
const looked = await lookupSessionById(input.selection.sessionId, input.catalog, input.signal);
|
||||
if (looked.status === "found") {
|
||||
return {
|
||||
selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId),
|
||||
sessions: mergeHydratedSession(input.sessions, looked.session),
|
||||
notice: null,
|
||||
};
|
||||
}
|
||||
if (looked.status === "missing") {
|
||||
return {
|
||||
selection: bootstrapSelectionFromLookup("missing", input.selection.sessionId, input.defaultSessionId),
|
||||
sessions: input.sessions,
|
||||
notice: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
selection: bootstrapSelectionFromLookup("unavailable", input.selection.sessionId, input.defaultSessionId),
|
||||
sessions: input.sessions,
|
||||
notice: SESSION_LOOKUP_FAILED_NOTICE,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
|
||||
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
|
||||
const status = payload as Partial<ConsultationStatus>;
|
||||
|
||||
@@ -201,6 +201,7 @@ export const RECTIFICATION_USER_COPY = {
|
||||
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
|
||||
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
|
||||
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
|
||||
rangeDeliveryAdoptLocked: "先答完上面这道,再选时间。",
|
||||
verificationReportSummary: "查看验证报告",
|
||||
} as const;
|
||||
|
||||
@@ -586,6 +587,7 @@ export function listUserVisibleCopy(): string[] {
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
|
||||
RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked,
|
||||
rangeDeliveryCollectClosed(2),
|
||||
rangeDeliveryCollectClosed(4),
|
||||
rangeDeliveryCollectClosed(),
|
||||
|
||||
@@ -22,7 +22,9 @@ export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
|
||||
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
|
||||
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。
|
||||
若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。
|
||||
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
|
||||
只能使用输入事实里出现的时间和年份;输入里没有的数字一律不要写。
|
||||
不要写「相对支持度」「概率」「置信度」。两个时间分不开时说「这两个时间按现有信息分不开」,不要念分数。
|
||||
只有 stop_facts 里真有「不再问」时才可以那么说。
|
||||
不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`;
|
||||
|
||||
export type AdoptNarrationOutcome =
|
||||
|
||||
@@ -126,7 +126,7 @@ function stopFactsFromDropped(
|
||||
if (yearless > 0) {
|
||||
facts.push({
|
||||
kind: "yearless_varga",
|
||||
label: "没有年份的分盘题不再问",
|
||||
label: "没有年份的分盘题按现有信息分不开",
|
||||
count: yearless,
|
||||
});
|
||||
}
|
||||
@@ -277,6 +277,7 @@ export function validateAdoptNarration(
|
||||
return { ok: false, reason: "question" };
|
||||
}
|
||||
if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" };
|
||||
if (/相对支持度|概率|置信度/.test(trimmed)) return { ok: false, reason: "machine_voice" };
|
||||
if (
|
||||
facts.post_adopt_verification.length === 0
|
||||
&& /会拿[\s\S]{0,40}核对/.test(trimmed)
|
||||
|
||||
@@ -26,4 +26,5 @@ export const MACHINE_VOICE_LEXICON = [
|
||||
"有关联",
|
||||
"弱关联",
|
||||
"以你说的为准",
|
||||
"相对支持度",
|
||||
] as const;
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
type V9CaseDossier,
|
||||
} from "./tool-service";
|
||||
import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card";
|
||||
import { vargaStyleFollowupRenderable } from "./probe-question-contract";
|
||||
import { clusterScoreDeltas } from "./probe-explain.ts";
|
||||
import {
|
||||
adoptDeliveryFacts,
|
||||
@@ -1326,6 +1327,21 @@ async function persistFocusAfterChoice(input: {
|
||||
followup: ReturnType<typeof buildMethodFollowupPlan>["next_followup"];
|
||||
askedTurnId?: string | null;
|
||||
}) {
|
||||
const followupKind = input.followup?.choice_kind
|
||||
?? input.followup?.choice_frame?.choice_kind
|
||||
?? null;
|
||||
const followupStyles = input.followup?.style_options ?? [];
|
||||
if (followupKind === "varga_style" && followupStyles.length > 0 && !vargaStyleFollowupRenderable({
|
||||
choiceKind: followupKind,
|
||||
styleOptions: followupStyles,
|
||||
})) {
|
||||
return {
|
||||
status: "skipped" as const,
|
||||
focus: null,
|
||||
questionId: null,
|
||||
prompt: null,
|
||||
};
|
||||
}
|
||||
let persisted;
|
||||
try {
|
||||
persisted = await persistServerOwnedFocus({
|
||||
|
||||
@@ -143,6 +143,7 @@ import {
|
||||
isEventQualityProbe,
|
||||
sameYearProbeAsked,
|
||||
SEMANTIC_YEAR_KEY,
|
||||
vargaStyleFollowupRenderable,
|
||||
type DroppedProbe,
|
||||
type ProbeStyleOption,
|
||||
} from "./probe-question-contract.ts";
|
||||
@@ -3070,6 +3071,14 @@ export function tieBreakPersonalityFollowup(
|
||||
if (kind !== "varga_style") return null;
|
||||
const key = followup.semantic_key ?? "";
|
||||
if (!key.startsWith("varga.d9") && !key.startsWith("varga.d10")) return null;
|
||||
const styleOptions = followup.style_options ?? [];
|
||||
if (styleOptions.length > 0 && !vargaStyleFollowupRenderable({
|
||||
choiceKind: kind,
|
||||
styleOptions,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
if (styleOptions.length === 0 && !followup.choice_frame) return null;
|
||||
return { ...followup, tie_break_round: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,22 @@ export function canRenderYearlessChoice(input: {
|
||||
return !optionCopyPresupposesPeriod(input.styleOptions);
|
||||
}
|
||||
|
||||
/** Same gate as buildChoiceCard: followup exists is not enough. */
|
||||
export function vargaStyleFollowupRenderable(input: {
|
||||
choiceKind?: string | null;
|
||||
styleOptions?: readonly unknown[] | null;
|
||||
}): boolean {
|
||||
const styles = completeStyleOptions({
|
||||
choiceKind: input.choiceKind,
|
||||
styleOptions: input.styleOptions,
|
||||
});
|
||||
if (!styles.ok) return false;
|
||||
return canRenderYearlessChoice({
|
||||
choiceKind: input.choiceKind,
|
||||
styleOptions: styles.options,
|
||||
});
|
||||
}
|
||||
|
||||
export function isRenderableProbe(input: {
|
||||
informationGain?: number | null;
|
||||
candidateIds?: readonly string[] | null;
|
||||
|
||||
@@ -167,19 +167,12 @@ export function applyLiveCandidateOffer<T extends CandidateOfferAnchor>(
|
||||
));
|
||||
}
|
||||
if (!input.canOffer) return [...messages];
|
||||
const liveInterview = messages.some((message) => (
|
||||
interviewQuestionBlocksAdoptOffer(message.question, false)
|
||||
));
|
||||
if (liveInterview) {
|
||||
return messages.map((message) => (
|
||||
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
|
||||
));
|
||||
}
|
||||
const owner = [...messages].reverse().find((message) => (
|
||||
isSettledAssistant(message)
|
||||
&& !interviewQuestionBlocksAdoptOffer(message.question, false)
|
||||
));
|
||||
if (!owner) {
|
||||
if (messages.some((message) => message.candidateOffer)) return [...messages];
|
||||
return messages.map((message) => (
|
||||
message.candidateOffer ? { ...message, candidateOffer: undefined } : message
|
||||
));
|
||||
|
||||
@@ -382,9 +382,8 @@ export function interviewChoiceCardUnavailable(input: Readonly<{
|
||||
// A choice question without a GET card is a dead tap target, not a
|
||||
// collect-wait. The repair-exit path must take over.
|
||||
if (input.questionKind === "choice" && !input.hasChoiceCard) return true;
|
||||
// Spoken targeted existence is the same dead end: the stem is a card
|
||||
// question stored as collect_spoken. Year-stage targeted questions stay spoken.
|
||||
const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, "");
|
||||
if (!input.hasChoiceCard && /^varga\./.test(questionId)) return true;
|
||||
if (
|
||||
input.questionKind === "collect_spoken"
|
||||
&& !input.hasChoiceCard
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- BUG-704: rectification activity must advance chat_sessions.updated_at the
|
||||
-- same way append_consultation_question does. Open / GET / metadata PATCH
|
||||
-- still must not bump it (BUG-553).
|
||||
|
||||
begin;
|
||||
|
||||
do $migration$
|
||||
begin
|
||||
if current_user <> 'schema_owner' then
|
||||
raise exception 'rectification_touch_chat_session_requires_schema_owner'
|
||||
using errcode = '42501';
|
||||
end if;
|
||||
end
|
||||
$migration$;
|
||||
|
||||
create or replace function public.touch_chat_session_from_rectification_case()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'UPDATE'
|
||||
and new.last_activity_at is not distinct from old.last_activity_at then
|
||||
return new;
|
||||
end if;
|
||||
update public.chat_sessions
|
||||
set updated_at = new.last_activity_at
|
||||
where id = new.session_id
|
||||
and user_id = new.user_id
|
||||
and session_type = 'birth_time_rectification'
|
||||
and updated_at is distinct from new.last_activity_at
|
||||
and updated_at < new.last_activity_at;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists agentic_rectification_cases_touch_chat_session
|
||||
on public.agentic_rectification_cases;
|
||||
|
||||
create trigger agentic_rectification_cases_touch_chat_session
|
||||
after update of last_activity_at on public.agentic_rectification_cases
|
||||
for each row
|
||||
execute function public.touch_chat_session_from_rectification_case();
|
||||
|
||||
revoke all on function public.touch_chat_session_from_rectification_case()
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.touch_chat_session_from_rectification_case()
|
||||
to schema_owner;
|
||||
|
||||
commit;
|
||||
@@ -344,8 +344,13 @@ test("range delivery copy bans 这次给出 / 最终 and titles 目前范围", (
|
||||
assert.match(card, /rangeDeliveryTitle/);
|
||||
assert.doesNotMatch(card, /这次给出|最终/);
|
||||
assert.match(card, /narrow_hint/);
|
||||
assert.match(card, /rangeDeliveryTieBreakEntry/);
|
||||
// 原值: 卡源码引用 rangeDeliveryTieBreakEntry
|
||||
// 新值: 卡上不再引用该入口;文案仍留给出卡前的服务端 turn
|
||||
// 原因: BUG-706
|
||||
assert.doesNotMatch(card, /rangeDeliveryTieBreakEntry/);
|
||||
assert.doesNotMatch(card, /onTieBreak/);
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry));
|
||||
assert.ok(MACHINE_VOICE_LEXICON.includes("相对支持度"));
|
||||
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck));
|
||||
assert.doesNotMatch(listUserVisibleCopy().join("\n"), SPLIT_ENDPOINT_PHRASE);
|
||||
const pool = readFileSync(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
SESSION_MISSING_NOTICE,
|
||||
SESSION_URL_QUERY_KEY,
|
||||
SESSION_URL_RETURN_STORAGE_KEY,
|
||||
@@ -83,6 +84,9 @@ test("bootstrap clears an illegal or unknown session query", () => {
|
||||
clearStoredReturn: true,
|
||||
},
|
||||
);
|
||||
// 原值: 未出现在已加载页的合法 UUID 直接 missing + replace-clear
|
||||
// 新值: urlAction lookup,先问服务端
|
||||
// 原因: BUG-705,分页里找不到不等于已删除
|
||||
assert.deepEqual(
|
||||
resolveBootstrapSessionSelection({
|
||||
listedIds: [sessionA],
|
||||
@@ -91,12 +95,13 @@ test("bootstrap clears an illegal or unknown session query", () => {
|
||||
storedReturnId: null,
|
||||
}),
|
||||
{
|
||||
sessionId: sessionA,
|
||||
urlAction: "replace-clear",
|
||||
missing: true,
|
||||
clearStoredReturn: true,
|
||||
sessionId: sessionB,
|
||||
urlAction: "lookup",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
},
|
||||
);
|
||||
assert.equal(SESSION_LOOKUP_FAILED_NOTICE.includes("暂时读不到"), true);
|
||||
});
|
||||
|
||||
test("default bootstrap selection does not write a session URL", () => {
|
||||
@@ -122,6 +127,7 @@ test("default bootstrap selection does not write a session URL", () => {
|
||||
assert.match(bootstrap, /urlAction === "replace-selected"/);
|
||||
assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/);
|
||||
assert.match(bootstrap, /setComposerNotice\(SESSION_MISSING_NOTICE\)/);
|
||||
assert.match(bootstrap, /resolveLookupBootstrap/);
|
||||
});
|
||||
|
||||
test("session href keeps sibling query keys and can drop a dead c", () => {
|
||||
|
||||
@@ -93,6 +93,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260915010000_rectification_touch_chat_session\.sql/);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))),
|
||||
false,
|
||||
|
||||
@@ -75,18 +75,28 @@ test("live offer moves off a collect message once the adopt narration exists", (
|
||||
assert.deepEqual(next[2]?.candidateOffer, { resultId: "result-1" });
|
||||
});
|
||||
|
||||
test("live offer stays hidden while an unanswered collect question is still on screen", () => {
|
||||
test("live offer stays on the delivery message while an unanswered question is on screen", () => {
|
||||
const next = applyLiveCandidateOffer([
|
||||
{
|
||||
renderKey: "delivery",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "我按你说的经历认真分析过了,下面是这次的结果。",
|
||||
candidateOffer: { resultId: "result-1" },
|
||||
},
|
||||
{
|
||||
renderKey: "collect",
|
||||
role: "assistant",
|
||||
state: "settled",
|
||||
text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?",
|
||||
text: "亲密关系里,你更接近哪一种相处方式?",
|
||||
question: collect("active"),
|
||||
candidateOffer: { resultId: "result-1" },
|
||||
},
|
||||
], { resultId: "result-1", canOffer: true, adopted: false });
|
||||
assert.equal(next[0]?.candidateOffer, undefined);
|
||||
// 原值: liveInterview 时剥掉每一条 candidateOffer,交付卡消失
|
||||
// 新值: 交付消息上的 offer 留下,只抑制采用动作
|
||||
// 原因: BUG-706,有活题时卡必须还在
|
||||
assert.deepEqual(next[0]?.candidateOffer, { resultId: "result-1" });
|
||||
assert.equal(next[1]?.candidateOffer, undefined);
|
||||
});
|
||||
|
||||
test("after adopt the offer stays on the original message", () => {
|
||||
|
||||
@@ -261,7 +261,7 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", ()
|
||||
assert.match(source, /aria-pressed=\{adopted\}/);
|
||||
});
|
||||
|
||||
test("range-delivery tie-break entry is absent until available and wired", () => {
|
||||
test("range-delivery never shows a post-card tie-break button", () => {
|
||||
const delivery = buildRangeDelivery({
|
||||
inference: inference({ probes: [d9Probe()] }),
|
||||
publicCandidates,
|
||||
@@ -269,30 +269,26 @@ test("range-delivery tie-break entry is absent until available and wired", () =>
|
||||
representativeTime: "04:50",
|
||||
tieBreakAvailable: true,
|
||||
});
|
||||
const hidden = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: deliveryResult(delivery, "04:50"),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: () => undefined,
|
||||
}));
|
||||
assert.doesNotMatch(hidden, /再答两道参考题微调排序/);
|
||||
const shown = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: deliveryResult({ ...delivery, tie_break_available: true }, "04:50"),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: () => undefined,
|
||||
onTieBreak: () => undefined,
|
||||
}));
|
||||
assert.match(shown, /再答两道参考题微调排序/);
|
||||
assert.equal([...shown.matchAll(/<button\b/g)].length, delivery.columns.length + 1);
|
||||
const skipped = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: deliveryResult({ ...delivery, tie_break_available: false }, "04:50"),
|
||||
// 原值: tie_break_available + onTieBreak 时出现「再答两道参考题微调排序」
|
||||
// 新值: 卡上不再渲染该入口,参考题只在出卡前问
|
||||
// 原因: BUG-706,产品决定删掉卡上第二条路
|
||||
assert.doesNotMatch(shown, /再答两道参考题微调排序/);
|
||||
assert.doesNotMatch(shown, /onTieBreak/);
|
||||
assert.equal([...shown.matchAll(/<button\b/g)].length, delivery.columns.length);
|
||||
const locked = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: deliveryResult({ ...delivery, tie_break_available: true }, "04:50"),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: () => undefined,
|
||||
onTieBreak: () => undefined,
|
||||
adoptLocked: true,
|
||||
}));
|
||||
assert.doesNotMatch(skipped, /再答两道参考题微调排序/);
|
||||
assert.match(locked, /先答完上面这道,再选时间/);
|
||||
});
|
||||
|
||||
test("the range-delivery component does not render relative support or the old two actions", () => {
|
||||
|
||||
@@ -169,13 +169,16 @@ test("click path does not surface 409 copy; round continue is opt-in only", () =
|
||||
const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
|
||||
const panel = readFileSync(new URL("../src/lib/rectification-agentic/v9/divergence-panel.ts", import.meta.url), "utf8");
|
||||
const pool = readFileSync(new URL("../src/lib/rectification-agentic/v9/collection-question-pool.ts", import.meta.url), "utf8");
|
||||
assert.match(chat, /payload\?\.code === "tie_break_unavailable"/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);\s*\n\s*return;/);
|
||||
const unavailableBranch = chat.slice(
|
||||
chat.indexOf('payload?.code === "tie_break_unavailable"'),
|
||||
chat.indexOf("暂时无法开始参考题"),
|
||||
const route = readFileSync(
|
||||
new URL("../src/app/api/rectification/cases/[caseId]/tie-break/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(unavailableBranch, /setError/);
|
||||
// 原值: 客户端 requestTieBreak 把 tie_break_unavailable 当静默刷新,不 setError
|
||||
// 新值: 卡上入口已删,客户端不再有这条 click path;409 口径留在 API
|
||||
// 原因: BUG-706
|
||||
assert.doesNotMatch(chat, /requestTieBreak/);
|
||||
assert.doesNotMatch(chat, /tie_break_unavailable/);
|
||||
assert.match(route, /tie_break_unavailable/);
|
||||
assert.match(answer, /continueTieBreakRound\?: boolean/);
|
||||
assert.match(answer, /continueTieBreakRound === true/);
|
||||
assert.match(answer, /isTieBreakRoundSchema/);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { vargaStyleFollowupRenderable } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
|
||||
import { interviewChoiceCardUnavailable } from "../src/lib/rectification-surface-state.ts";
|
||||
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
|
||||
import { validateAdoptNarration, type AdoptDeliveryFacts } from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
|
||||
const chat = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const card = readFileSync(
|
||||
new URL("../src/components/rectification-range-delivery.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const followup = readFileSync(
|
||||
new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const facts: AdoptDeliveryFacts = {
|
||||
precision_stage: "ready_to_adopt",
|
||||
already_accepted: false,
|
||||
credible_range: ["04:48", "05:07"],
|
||||
representative_minute: "04:53",
|
||||
runner_up_minute: "05:00",
|
||||
opening_window: ["04:45", "05:15"],
|
||||
active_candidates: [
|
||||
{ time: "04:53", relative_support: 16 },
|
||||
{ time: "05:00", relative_support: 16 },
|
||||
],
|
||||
answered_rounds: 6,
|
||||
stop_facts: [{ kind: "indistinguishable", label: "再问下去也分不开 04:53 和 05:00", count: 1 }],
|
||||
post_adopt_verification: [],
|
||||
minutes: ["04:48", "05:07", "04:53", "05:00", "04:45", "05:15"],
|
||||
years: [],
|
||||
};
|
||||
|
||||
test("the delivery card no longer renders a post-card tie-break entry", () => {
|
||||
assert.doesNotMatch(card, /onTieBreak/);
|
||||
assert.doesNotMatch(card, /rangeDeliveryTieBreakEntry/);
|
||||
assert.doesNotMatch(chat, /onTieBreak/);
|
||||
assert.doesNotMatch(chat, /requestTieBreak/);
|
||||
assert.doesNotMatch(css, /rectification-range-delivery__tie-break/);
|
||||
assert.match(chat, /adoptLocked=\{liveInterviewBlockingAdopt\}/);
|
||||
assert.match(chat, /liveInterviewBlockingAdopt/);
|
||||
assert.doesNotMatch(
|
||||
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const showReadonlyRange")),
|
||||
/interviewQuestionBlocksAdoptOffer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("tie-break availability reuses the choice-card renderability gate", () => {
|
||||
assert.match(followup, /vargaStyleFollowupRenderable/);
|
||||
assert.equal(
|
||||
vargaStyleFollowupRenderable({
|
||||
choiceKind: "varga_style",
|
||||
styleOptions: [
|
||||
{ label: "相处里更在意照顾对方的感受", answer_class: "yes", sign: "巨蟹座" },
|
||||
{ label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes", sign: "狮子座" },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
vargaStyleFollowupRenderable({
|
||||
choiceKind: "varga_style",
|
||||
styleOptions: [
|
||||
{ label: "相处里更在意照顾对方的感受", answer_class: "yes" },
|
||||
{ label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" },
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a varga_style focus without a choice card is a dead question, not a spoken stem", () => {
|
||||
assert.equal(
|
||||
interviewChoiceCardUnavailable({
|
||||
questionKind: "choice",
|
||||
hasChoiceCard: false,
|
||||
questionId: "varga.d9.cancer/leo",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
interviewChoiceCardUnavailable({
|
||||
questionKind: "collect_spoken",
|
||||
hasChoiceCard: false,
|
||||
questionId: "varga.d9.cancer/leo",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
interviewChoiceCardUnavailable({
|
||||
questionKind: "choice",
|
||||
hasChoiceCard: true,
|
||||
questionId: "varga.d9.cancer/leo",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("adopt narration may not speak 相对支持度 even when the number is in facts", () => {
|
||||
assert.ok(MACHINE_VOICE_LEXICON.includes("相对支持度"));
|
||||
assert.equal(validateAdoptNarration("相对支持度都是 16。", facts).ok, false);
|
||||
assert.equal(validateAdoptNarration("这两个时间按现有信息分不开。", facts).ok, true);
|
||||
assert.equal(RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked, "先答完上面这道,再选时间。");
|
||||
});
|
||||
@@ -1547,3 +1547,22 @@ test("host-fallback phase migration is forward-only and does not rewrite the fro
|
||||
/answer\.host_fallback/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rectification activity touches chat_sessions.updated_at without a metadata PATCH", () => {
|
||||
const filename = "20260915010000_rectification_touch_chat_session.sql";
|
||||
const touch = readFileSync(
|
||||
new URL(`../supabase/migrations/${filename}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(touch, /^begin;[\s\S]*^commit;$/m);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
assert.match(touch, /rectification_touch_chat_session_requires_schema_owner/);
|
||||
assert.match(touch, /touch_chat_session_from_rectification_case/);
|
||||
assert.match(touch, /after update of last_activity_at/);
|
||||
assert.match(touch, /session_type = 'birth_time_rectification'/);
|
||||
assert.doesNotMatch(touch, /persistSession/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_LOOKUP_FAILED_NOTICE,
|
||||
SESSION_MISSING_NOTICE,
|
||||
SESSION_URL_QUERY_KEY,
|
||||
bootstrapSelectionFromLookup,
|
||||
resolveBootstrapSessionSelection,
|
||||
} from "../src/lib/chat-session-url.ts";
|
||||
import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation } from "../src/lib/home-bootstrap.ts";
|
||||
|
||||
const sessionA = "11111111-1111-4111-8111-111111111111";
|
||||
const sessionB = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
test("an unlisted ?c= UUID must look up the server instead of announcing deletion", () => {
|
||||
const selection = resolveBootstrapSessionSelection({
|
||||
listedIds: [sessionA],
|
||||
defaultSessionId: sessionA,
|
||||
search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`,
|
||||
storedReturnId: null,
|
||||
});
|
||||
assert.equal(selection.urlAction, "lookup");
|
||||
assert.equal(selection.missing, false);
|
||||
assert.equal(selection.sessionId, sessionB);
|
||||
assert.equal(
|
||||
resolveStarterHomeLandingSessionId(
|
||||
[{ id: sessionA, sessionType: "consultation", messages: [] }],
|
||||
sessionB,
|
||||
"lookup",
|
||||
),
|
||||
sessionB,
|
||||
);
|
||||
assert.equal(
|
||||
starterHomeLandingNeedsConsultation(
|
||||
[{ id: sessionA, sessionType: "consultation", messages: [] }],
|
||||
sessionB,
|
||||
"lookup",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("lookup results: found keeps URL, 404 deletes, 5xx keeps URL", () => {
|
||||
assert.deepEqual(bootstrapSelectionFromLookup("found", sessionB, sessionA), {
|
||||
sessionId: sessionB,
|
||||
urlAction: "keep",
|
||||
missing: false,
|
||||
clearStoredReturn: true,
|
||||
});
|
||||
assert.deepEqual(bootstrapSelectionFromLookup("missing", sessionB, sessionA), {
|
||||
sessionId: sessionA,
|
||||
urlAction: "replace-clear",
|
||||
missing: true,
|
||||
clearStoredReturn: true,
|
||||
});
|
||||
assert.deepEqual(bootstrapSelectionFromLookup("unavailable", sessionB, sessionA), {
|
||||
sessionId: sessionA,
|
||||
urlAction: "none",
|
||||
missing: false,
|
||||
clearStoredReturn: false,
|
||||
});
|
||||
assert.notEqual(SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE);
|
||||
});
|
||||
|
||||
test("cloud lookup and session management call the server before clearing ?c=", () => {
|
||||
const cloud = readFileSync(new URL("../src/lib/home-cloud-sync.ts", import.meta.url), "utf8");
|
||||
assert.match(cloud, /export async function lookupSessionById/);
|
||||
assert.match(cloud, /status === 404/);
|
||||
assert.match(cloud, /bootstrapSelectionFromLookup\("found"/);
|
||||
assert.match(cloud, /bootstrapSelectionFromLookup\("missing"/);
|
||||
assert.match(cloud, /bootstrapSelectionFromLookup\("unavailable"/);
|
||||
const management = readFileSync(
|
||||
new URL("../src/hooks/use-session-management.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const pop = management.slice(
|
||||
management.indexOf("applySessionPopStateRef.current ="),
|
||||
management.indexOf("async function selectSessionModel"),
|
||||
);
|
||||
assert.match(pop, /lookupSessionById/);
|
||||
assert.match(pop, /SESSION_LOOKUP_FAILED_NOTICE/);
|
||||
assert.match(pop, /status === "missing"/);
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /resolveLookupBootstrap/);
|
||||
});
|
||||
Reference in New Issue
Block a user