diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8cb7500d..a1947f1a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1548,3 +1548,18 @@ - 防复发:需要同时访问内部 API 与 staging PostgreSQL 的服务必须在 Compose 合同中显式加入两个现有私有网络。 - 相关记录:BUG-082 - 修复版本:本次修复提交 + +## BUG-085 | 生时校正回退为独立面板和固定领域问卷 + +- 状态:investigating +- 首次发现:2026-07-27 +- 最近更新:2026-07-27 +- 影响面:生时校正 V4 聊天界面、历史恢复、模型选择、下一问规划与 staging 验收 +- 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。 +- 触发条件:V4 页面入口渲染旧式 `RectificationV4Panel` 视觉结构,页面给会话容器添加 `is-rectification`,同时问题规划器按硬编码领域顺序和模板生成下一问。 +- 根因:组件 wrapper 无条件绕过原普通聊天 Surface;普通 session CSS 又显式排除 `is-rectification`;`question-planner.ts` 把教育、迁移、关系、事业、财务、健康压力和家庭写成固定顺序与固定文案,测试还把这些实现细节当成产品合同。 +- 修复:V4 复用普通 session 的消息列表、输入框和模型选择器,并从持久化 turns 恢复完整对话;回答时原子保存所选模型 ID,Worker 将完整 turns、事件台账、日期精度、已追问事件与候选范围交给模型动态生成下一问。确定性 planner 只保留日期修订和开放叙述降级,不再轮询领域或输出固定问卷;候选范围仍不得表述为已确认出生分钟。 +- 验证:聚焦 V4/domain/service/replay/handoff/migration、普通 session UI 合同和 consultation entrypoint 共 59 个测试通过;staging 构建、迁移和登录态 smoke 完成后更新为 resolved 并填写精确提交与部署 SHA。 +- 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。 +- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084 +- 修复版本:待提交(staging 验收中) diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts index 72cac9cd..93cf685a 100644 --- a/frontend/scripts/rectification-v4-worker.mts +++ b/frontend/scripts/rectification-v4-worker.mts @@ -1,6 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts"; import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts"; +import { authorRectificationV4Question } from "../src/lib/rectification-v4/question-author.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; @@ -14,6 +15,7 @@ const worker = createRectificationV4Worker({ engine: createRectificationV4CandidateEngine({ apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", }), + questionAuthor: authorRectificationV4Question, }); do { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4e7bb863..d19ecbc7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -840,20 +840,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; } } -/* Conversational rectification shell */ -.conversation.is-rectification { overflow: hidden; padding-bottom: 0; } -.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); } -.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); } -.rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } -.rectification-composer-wrap { position: static; flex: 0 0 auto; } -.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); } -.rectification-correction-target p { margin: 0; } -.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); } -.conversational-loading { min-height: min(52vh, 460px); display: grid; place-items: center; padding: var(--space-6); } - -@media (max-width: 430px) { - .rectification-correction-target { align-items: stretch; display: grid; } -} +/* Rectification reuses the ordinary session message list and composer. */ +.rectification-chat { display: contents; } /* Starter workbench */ .welcome { @@ -1666,34 +1654,3 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } .birth-time-clock-menu .select-item { justify-content: flex-start; } - -/* Birth-time rectification V4 */ -.rectification-v4-panel { width: min(860px, 100%); margin: 0 auto; padding: clamp(20px, 4vw, 40px); display: grid; gap: 20px; overflow-y: auto; } -.rectification-v4-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } -.rectification-v4-header h2 { margin: 4px 0 8px; font-size: clamp(24px, 4vw, 36px); letter-spacing: -0.035em; } -.rectification-v4-header p, .rectification-v4-context p, .rectification-v4-processing p, .rectification-v4-result p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; } -.rectification-v4-eyebrow { color: var(--color-action) !important; font-size: var(--type-caption); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } -.rectification-v4-context, .rectification-v4-processing, .rectification-v4-result, .rectification-v4-notice { padding: 18px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } -.rectification-v4-context { display: grid; gap: 6px; } -.rectification-v4-processing { display: grid; gap: 8px; } -.rectification-v4-ranges { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 18px; } -.rectification-v4-ranges > div { display: grid; gap: 5px; padding: 16px; border-radius: var(--radius-md); background: var(--color-surface); } -.rectification-v4-ranges span { color: var(--color-ink-secondary); font-size: var(--type-caption); } -.rectification-v4-ranges strong { font-size: 24px; letter-spacing: -.02em; } -.rectification-v4-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } -.rectification-v4-evidence-grid h3 { margin: 0 0 8px; font-size: 15px; } -.rectification-v4-evidence-grid ul { margin: 0; padding-left: 18px; color: var(--color-ink-secondary); line-height: 1.6; } -.rectification-v4-uncertainty { margin-top: 16px !important; padding-top: 14px; border-top: 1px solid var(--color-border); font-size: var(--type-caption); } -.rectification-v4-actions, .rectification-v4-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; } -.rectification-v4-saved { display: inline-flex; align-items: center; gap: 7px; color: var(--color-action); font-weight: 700; } -.rectification-v4-saved svg, .rectification-v4-footer svg { width: 16px; height: 16px; } -.rectification-v4-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; } -.rectification-v4-composer label { grid-column: 1 / -1; font-weight: 650; line-height: 1.55; } -.rectification-v4-composer textarea { min-height: 112px; resize: vertical; } -.rectification-v4-footer button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; } -.rectification-v4-footer button:disabled { cursor: default; opacity: .5; } -@media (max-width: 680px) { - .rectification-v4-panel { padding: 18px 14px 24px; } - .rectification-v4-header { display: grid; } - .rectification-v4-ranges, .rectification-v4-evidence-grid { grid-template-columns: 1fr; } -} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ac244f2f..355ebd77 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -54,13 +54,11 @@ import { type RectificationCardAction, } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; -import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client"; import { createRectificationQuestionHandoffCoordinator, } from "@/lib/rectification-question-handoff"; import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey"; -import type { ConversationalRectificationMessage } from "@/hooks/use-conversational-rectification"; import { requestBirthTimeAssessment, type JourneyClientResponse, @@ -169,13 +167,6 @@ type ChatSession = { rectificationCaseId: string | null; }; -function durableRectificationMessages( - messages: readonly ConversationalRectificationMessage[], -): Message[] { - const durable = messages.map(({ role, text }) => ({ role, text })); - if (durable.length <= 500) return durable; - return [durable[0]!, ...durable.slice(-(500 - 1))]; -} type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; type BirthPlace = { @@ -956,8 +947,6 @@ export default function Home() { ); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); - const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); - const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState(""); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -1015,9 +1004,6 @@ export default function Home() { const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0]; const activeRectificationSession = activeSession?.sessionType === "birth_time_rectification"; - const visibleRectificationTurn = activeSession?.id === rectificationSessionId - ? rectificationInitialTurn - : null; const rectificationSurfaceOpen = activeRectificationSession && activeSession.id === rectificationSessionId; const visibleSessions = sessions @@ -1612,7 +1598,7 @@ export default function Home() { setComposerNotice(""); if (nextSession?.sessionType === "birth_time_rectification") { setRectificationError(""); - if (nextSession.id !== rectificationSessionId || !rectificationInitialTurn) { + if (nextSession.id !== rectificationSessionId) { resumeRectificationSession.current(nextSession); } } @@ -2041,20 +2027,6 @@ export default function Home() { ); } - function synchronizeRectificationQuestion( - turn: ConversationalRectificationTurn, - fallbackSession: ChatSession | undefined = activeSession, - ) { - if (!turn.pendingConsultationQuestion) return; - if (!rectificationQuestionHandoff.current.peek() && fallbackSession) { - rectificationQuestionHandoff.current.synchronizeDurableQuestion( - turn.pendingConsultationQuestion, - { sessionId: fallbackSession.id, theme: fallbackSession.theme }, - ); - } - setRectificationPendingQuestion(turn.pendingConsultationQuestion); - } - async function openBirthTimeRectification( pendingConsultationQuestion: string | null = null, sourceSessionOverride: ChatSession | null = null, @@ -2072,8 +2044,6 @@ export default function Home() { rectificationOpenInFlight.current = true; setRectificationLoading(true); setRectificationError(""); - setRectificationInitialTurn(null); - setRectificationOpeningAssistantText(""); setRectificationPendingQuestion(requestedQuestion); setDraft(""); setDraftTheme(null); @@ -2103,78 +2073,6 @@ export default function Home() { void openBirthTimeRectification(null, session); }; - function handleConversationalRectificationTurn( - turn: ConversationalRectificationTurn, - messages: readonly ConversationalRectificationMessage[], - ) { - const requestIdentity = accountRefreshGuard.current.begin(); - setRectificationInitialTurn(turn); - synchronizeRectificationQuestion(turn); - if (activeSession?.sessionType === "birth_time_rectification") { - const boundSession = { - ...activeSession, - messages: durableRectificationMessages(messages), - rectificationCaseId: turn.caseId, - updatedAt: timestamp(), - }; - updateSession(activeSession.id, () => boundSession); - void rectificationPersistence.current.enqueue( - boundSession.id, - () => persistSession(boundSession), - ).catch(() => { - setComposerNotice("校正进度已经保留,但会话关联暂时未同步到云端。"); - }); - } - setAccount((current) => current ? { - ...current, - hasConfirmedBirthTime: current.hasConfirmedBirthTime - || (turn.status === "completed" && turn.candidate.status === "confirmed"), - rectificationCase: ["active", "paused", "confirming"].includes(turn.status) - ? { - caseId: turn.caseId, - journeyProtocol: "conversational-evidence-v3", - status: turn.status, - turnVersion: turn.turnVersion, - isRevision: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.isRevision - : current.hasConfirmedBirthTime, - preservesActiveTime: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.preservesActiveTime - : current.hasConfirmedBirthTime, - } - : current.rectificationCase?.caseId === turn.caseId - ? null - : current.rectificationCase, - } : current); - if (turn.status === "completed" - && turn.candidate.status === "confirmed" - && turn.candidate.representativeTime) { - setProfile((current) => ({ - ...current, - time: turn.candidate.representativeTime ?? current.time, - birthTimeStatus: "confirmed", - rectificationCaseId: turn.caseId, - })); - setProfileDraft((current) => ({ - ...current, - time: turn.candidate.representativeTime ?? current.time, - birthTimeStatus: "confirmed", - rectificationCaseId: turn.caseId, - })); - } - void fetchAccount() - .then((latest) => { - if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; - setAccount((current) => { - if (turn.status === "completed" || turn.status === "abandoned") return latest; - if (latest.rectificationCase?.caseId !== turn.caseId - || latest.rectificationCase.turnVersion < turn.turnVersion) return current; - return latest; - }); - }) - .catch(() => undefined); - } - async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; if (synastryPendingId) return; @@ -2943,7 +2841,7 @@ export default function Home() { -
+
{!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
{!profileComplete ? ( @@ -3127,28 +3025,17 @@ export default function Home() {
))} - {rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? ( -
-

{rectificationError}

- -
- ) : ( + {rectificationSurfaceOpen && ( void selectSessionModel(modelId)} pendingConsultationQuestion={rectificationPendingQuestion} continuationPending={rectificationContinuationPending} onPendingChange={setRectificationMutationPending} - onTurn={handleConversationalRectificationTurn} onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)} /> - ))} + )}
{!rectificationSurfaceOpen &&
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index f7e37416..610851aa 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,337 +1,21 @@ "use client"; -import { ArrowUp, Check, Copy, RotateCcw, Square, ThumbsDown, ThumbsUp } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { ChatMessageRow } from "./chat-message-row.tsx"; -import { AppLoadingIndicator } from "./app-loading-indicator.tsx"; -import { ModelSelector } from "./model-selector.tsx"; -import { Button } from "./ui/button.tsx"; -import { Textarea } from "./ui/textarea.tsx"; +import type { PublicLanguageModel } from "../lib/public-models.ts"; import { RectificationV4Panel, type RectificationV4Continuation, } from "./rectification-v4-panel.tsx"; -import { - type ConversationalRectificationMessage, - type ConversationalRectificationStoredMessage, - type ConversationalRectificationController, -} from "../hooks/use-conversational-rectification.ts"; -import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts"; -import type { PublicLanguageModel } from "../lib/public-models.ts"; -type SurfaceProps = Readonly<{ - controller: ConversationalRectificationController; - openingAssistantText?: string; +export type ConversationalBirthTimeRectificationProps = Readonly<{ models: readonly PublicLanguageModel[]; selectedModelId: string; onSelectModel: (modelId: string) => void; pendingConsultationQuestion?: string | null; continuationPending?: boolean; - onContinueOriginalQuestion?: (question: string) => void; -}>; - -const ANSWER_UNDO_WINDOW_MS = 2_500; - -function safely(request: Promise) { - void request.catch(() => undefined); -} - -export function ConversationalRectificationSurface({ - controller, - openingAssistantText = "", - models, - selectedModelId, - onSelectModel, - pendingConsultationQuestion, - continuationPending = false, - onContinueOriginalQuestion, -}: SurfaceProps) { - const composer = useRef(null); - const conversationEnd = useRef(null); - const [feedback, setFeedback] = useState>({}); - const [copiedMessageKey, setCopiedMessageKey] = useState(null); - const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); - const undoTimer = useRef | null>(null); - const [submission, setSubmission] = useState | null>(null); - const turn = controller.turn; - const messageCount = controller.messages?.length ?? 0; - const latestMessageText = controller.messages?.[messageCount - 1]?.text ?? turn?.narrative ?? ""; - const latestAssistantKey = [...(controller.messages ?? [])] - .reverse() - .find((message) => message.role === "assistant")?.renderKey - ?? `assistant-${turn?.turnVersion ?? 0}`; - useEffect(() => () => { - if (undoTimer.current) clearTimeout(undoTimer.current); - }, []); - useEffect(() => { - const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - conversationEnd.current?.scrollIntoView({ - behavior: controller.pending || submission !== null || reduceMotion ? "auto" : "smooth", - block: "end", - }); - }, [controller.error, controller.pending, latestMessageText, messageCount, submission, turn?.turnVersion]); - const pendingQuestion = turn?.status === "completed" - ? turn.pendingConsultationQuestion - : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; - - if (!turn) { - return ( -
- {openingAssistantText - ? - :
- -
} - {controller.error &&

{controller.error}

} -
- ); - } - - const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; - const canConfirm = turn.actions.includes("confirm") - && turn.candidate.status === "ready_for_confirmation" - && Boolean(turn.candidate.representativeTime); - const canContinue = turn.actions.includes("continue_original_question") - && Boolean(pendingQuestion) - && Boolean(onContinueOriginalQuestion); - const busy = controller.pending || submission !== null; - const submit = () => { - const text = controller.draft.trim(); - if (!canAnswer || !text || busy) return; - controller.setDraft(""); - setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion }); - undoTimer.current = setTimeout(async () => { - undoTimer.current = null; - setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion }); - try { - await controller.answer(undefined, text); - } catch { - controller.setDraft(text); - } finally { - setSubmission(null); - composer.current?.focus(); - } - }, ANSWER_UNDO_WINDOW_MS); - }; - const copyMessage = async (message: ConversationalRectificationMessage) => { - try { - await navigator.clipboard.writeText(message.text); - setCopiedMessageKey(message.renderKey); - window.setTimeout(() => setCopiedMessageKey((current) => ( - current === message.renderKey ? null : current - )), 1_500); - } catch { - // Clipboard permission failures must not interrupt the conversation. - } - }; - const regenerateMessage = async (messageKey: string) => { - setRegeneratingMessageKey(messageKey); - try { - await controller.regenerate(); - } finally { - setRegeneratingMessageKey((current) => current === messageKey ? null : current); - } - }; - const undoSubmission = () => { - if (submission?.phase !== "undo") return; - if (undoTimer.current) clearTimeout(undoTimer.current); - undoTimer.current = null; - controller.setDraft(submission.text); - setSubmission(null); - requestAnimationFrame(() => composer.current?.focus()); - }; - - return ( -
-
- {submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""} - {(controller.messages ?? [{ - role: "assistant" as const, - text: turn.narrative, - renderKey: `assistant-${turn.turnVersion}`, - }]).map((message) => ( -
- - {message.role === "assistant" && regeneratingMessageKey !== message.renderKey && ( -
- - - - -
- )} -
- ))} - {submission && turn.turnVersion === submission.turnVersion && ( - - )} - {controller.pending && canAnswer && regeneratingMessageKey === null && ( - - )} - {controller.error &&

{controller.error}

} -
-
- - {(canAnswer || canConfirm || canContinue) &&
- {(canConfirm || canContinue) && ( -
- {canConfirm && ( - - )} - {canContinue && ( - - )} -
- )} - {canAnswer && <> - {controller.correctionTarget && ( -
-

正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}

- -
- )} -
{ event.preventDefault(); submit(); }}> - -