feat(rectification): 删掉年月录入卡,年月阶段打字回答且不被追问覆盖
This commit is contained in:
@@ -2972,6 +2972,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
margin-block-start: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
.rectification-message-question.is-standalone {
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
}
|
||||
.rectification-message-question.is-standalone .rectification-choice-card.is-embedded {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.rectification-message-question__prompt {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
@@ -3086,49 +3092,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-choice-card.is-embedded .birth-time-choice-question legend {
|
||||
display: unset;
|
||||
}
|
||||
.rectification-event-entry {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0 var(--space-4);
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-event-entry__label {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-event-entry__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-event-entry__chip {
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.rectification-event-entry__chip[data-selected="true"] {
|
||||
border-color: var(--color-action);
|
||||
background: var(--color-action-soft);
|
||||
color: var(--color-action);
|
||||
}
|
||||
.rectification-event-date {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rectification-event-date__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
}
|
||||
.rectification-choice-why {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--color-ink-secondary);
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { EventDatePicker, type EventDateValue } from "@/components/event-date-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
COLLECT_KIND_ORDER,
|
||||
GUIDED_ANY_DOMAIN,
|
||||
type CollectKind,
|
||||
type GuidedWindowDomain,
|
||||
} from "@/lib/rectification-agentic/v9/collection-question-pool";
|
||||
import { RANGE_DELIVERY_DOMAIN_LABEL } from "@/lib/rectification-agentic/user-copy";
|
||||
|
||||
export type EventDateEntrySubmit = Readonly<{
|
||||
domain: CollectKind;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* BUG-750: the card already knows the date and the domain the user picked, so
|
||||
* it states the one fact. The old text pasted the question's option list
|
||||
* ("开始认真关系、分手或结婚"), which the ledger then stored as the summary.
|
||||
*/
|
||||
export function formatEventDateEntryMessage(input: EventDateEntrySubmit): string {
|
||||
const when = input.day
|
||||
? `${input.year} 年 ${input.month} 月 ${input.day} 日`
|
||||
: `${input.year} 年 ${input.month} 月`;
|
||||
const label = RANGE_DELIVERY_DOMAIN_LABEL[input.domain] ?? input.domain;
|
||||
return `${when},${label}方面有一件事`;
|
||||
}
|
||||
|
||||
export function eventDateYearRange(
|
||||
birthYear: number | null | undefined,
|
||||
nowYear = new Date().getFullYear(),
|
||||
): { minYear: number; maxYear: number } {
|
||||
const maxYear = nowYear;
|
||||
if (typeof birthYear === "number" && birthYear >= 1900 && birthYear <= maxYear) {
|
||||
return { minYear: birthYear, maxYear };
|
||||
}
|
||||
return { minYear: Math.max(1900, maxYear - 80), maxYear };
|
||||
}
|
||||
|
||||
export function resolveEntryDomain(
|
||||
domain: GuidedWindowDomain,
|
||||
openDomains: readonly CollectKind[] = COLLECT_KIND_ORDER,
|
||||
): CollectKind {
|
||||
if (domain !== GUIDED_ANY_DOMAIN) return domain;
|
||||
return openDomains[0] ?? COLLECT_KIND_ORDER[0];
|
||||
}
|
||||
|
||||
export function EventDateEntryCard({
|
||||
defaultDomain,
|
||||
openDomains,
|
||||
defaultYear,
|
||||
defaultMonth,
|
||||
minYear,
|
||||
maxYear,
|
||||
disabled = false,
|
||||
onSubmit,
|
||||
}: {
|
||||
readonly defaultDomain: GuidedWindowDomain;
|
||||
readonly openDomains?: readonly CollectKind[];
|
||||
readonly defaultYear?: number;
|
||||
readonly defaultMonth?: number;
|
||||
readonly minYear: number;
|
||||
readonly maxYear: number;
|
||||
readonly disabled?: boolean;
|
||||
readonly onSubmit: (value: EventDateEntrySubmit) => void;
|
||||
}) {
|
||||
const [domain, setDomain] = useState<CollectKind>(
|
||||
resolveEntryDomain(defaultDomain, openDomains),
|
||||
);
|
||||
const initial = useMemo<EventDateValue | null>(() => {
|
||||
if (!defaultYear || defaultYear < minYear || defaultYear > maxYear) return null;
|
||||
const month = defaultMonth && defaultMonth >= 1 && defaultMonth <= 12 ? defaultMonth : 1;
|
||||
return { year: defaultYear, month, day: null };
|
||||
}, [defaultYear, defaultMonth, minYear, maxYear]);
|
||||
const [date, setDate] = useState<EventDateValue | null>(initial);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="rectification-event-entry"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!date || disabled) return;
|
||||
onSubmit({ domain, year: date.year, month: date.month, day: date.day });
|
||||
}}
|
||||
>
|
||||
<p className="rectification-event-entry__label">哪一类事</p>
|
||||
<div className="rectification-event-entry__chips" role="group" aria-label="经历类型">
|
||||
{COLLECT_KIND_ORDER.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className="rectification-event-entry__chip"
|
||||
data-selected={kind === domain}
|
||||
disabled={disabled}
|
||||
onClick={() => setDomain(kind)}
|
||||
>
|
||||
{RANGE_DELIVERY_DOMAIN_LABEL[kind] ?? kind}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<EventDatePicker
|
||||
value={date}
|
||||
minYear={minYear}
|
||||
maxYear={maxYear}
|
||||
disabled={disabled}
|
||||
onChange={setDate}
|
||||
/>
|
||||
<Button type="submit" disabled={disabled || !date}>
|
||||
记下这件
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
export type EventDateValue = Readonly<{
|
||||
year: number;
|
||||
month: number;
|
||||
day: number | null;
|
||||
}>;
|
||||
|
||||
type EventDatePickerProps = {
|
||||
readonly value: EventDateValue | null;
|
||||
readonly minYear: number;
|
||||
readonly maxYear: number;
|
||||
readonly disabled?: boolean;
|
||||
readonly onChange: (value: EventDateValue) => void;
|
||||
};
|
||||
|
||||
function clampYear(year: number, minYear: number, maxYear: number): number {
|
||||
return Math.min(maxYear, Math.max(minYear, year));
|
||||
}
|
||||
|
||||
function formatValue(value: EventDateValue | null): string {
|
||||
if (!value) return "选择年月";
|
||||
if (value.day) return `${value.year} 年 ${value.month} 月 ${value.day} 日`;
|
||||
return `${value.year} 年 ${value.month} 月`;
|
||||
}
|
||||
|
||||
export function EventDatePicker({
|
||||
value,
|
||||
minYear,
|
||||
maxYear,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: EventDatePickerProps) {
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = value
|
||||
? new Date(value.year, value.month - 1, value.day ?? 1)
|
||||
: undefined;
|
||||
const startMonth = new Date(minYear, 0);
|
||||
const endMonth = new Date(maxYear, 11);
|
||||
|
||||
return (
|
||||
<div className="rectification-event-date">
|
||||
<span id={labelId}>发生年月</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
data-empty={!value}
|
||||
className="w-full justify-start px-3 text-left font-normal data-[empty=true]:text-muted-foreground"
|
||||
/>}
|
||||
>
|
||||
<span id={valueId}>{formatValue(value)}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
key={value ? `${value.year}-${value.month}-${value.day ?? 0}` : "empty"}
|
||||
mode="single"
|
||||
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
|
||||
selected={selected}
|
||||
defaultMonth={selected ?? new Date(clampYear(maxYear - 10, minYear, maxYear), 0)}
|
||||
captionLayout="dropdown"
|
||||
navLayout="after"
|
||||
startMonth={startMonth}
|
||||
endMonth={endMonth}
|
||||
reverseYears
|
||||
disabled={{ before: new Date(minYear, 0, 1), after: new Date(maxYear, 11, 31) }}
|
||||
onSelect={(nextDate) => {
|
||||
if (nextDate === undefined) return;
|
||||
onChange({
|
||||
year: nextDate.getFullYear(),
|
||||
month: nextDate.getMonth() + 1,
|
||||
day: nextDate.getDate(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="rectification-event-date__footer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={disabled || !value}
|
||||
onClick={() => {
|
||||
if (!value) return;
|
||||
onChange({ year: value.year, month: value.month, day: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
只要年月
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,13 +113,6 @@ import {
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
|
||||
import { RectificationChoiceCard } from "./rectification-choice-card";
|
||||
import {
|
||||
EventDateEntryCard,
|
||||
eventDateYearRange,
|
||||
formatEventDateEntryMessage,
|
||||
} from "./event-date-entry-card";
|
||||
import { parseYearEntryQuestionId } from "@/lib/rectification-agentic/v9/collection-question-pool";
|
||||
import { birthYearFromDate } from "@/lib/rectification-agentic/v9/adult-floor";
|
||||
import {
|
||||
applyLiveCandidateOffer,
|
||||
copyTextForMessage,
|
||||
@@ -1424,8 +1417,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
|
||||
datedEventCount: candidateResult?.rangeDelivery?.event_count ?? null,
|
||||
});
|
||||
const yearEntry = parseYearEntryQuestionId(currentQuestion?.question_id);
|
||||
const yearRange = eventDateYearRange(birthYearFromDate(birthDate));
|
||||
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
|
||||
const liveSelectionCardKey = persistedOfferKey
|
||||
?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined);
|
||||
@@ -1739,7 +1730,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
})}
|
||||
{questionGap === "persisted_question" && currentQuestion?.prompt && (
|
||||
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
|
||||
<div className="rectification-message-question">
|
||||
<div className="rectification-message-question is-standalone">
|
||||
{persistedQuestionSurface({
|
||||
questionKind: currentQuestion.kind,
|
||||
hasChoiceCard: Boolean(choiceCard),
|
||||
@@ -1758,21 +1749,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onStop={submitStop}
|
||||
/>
|
||||
</>
|
||||
) : yearEntry ? (
|
||||
<>
|
||||
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
|
||||
<EventDateEntryCard
|
||||
defaultDomain={yearEntry.domain}
|
||||
defaultYear={yearEntry.year ?? undefined}
|
||||
defaultMonth={yearEntry.month ?? undefined}
|
||||
minYear={yearRange.minYear}
|
||||
maxYear={yearRange.maxYear}
|
||||
disabled={busy || readonly}
|
||||
onSubmit={(value) => {
|
||||
void send("message", formatEventDateEntryMessage(value));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
loadV9CaseSkillIdentity,
|
||||
loadV9CaseCompute,
|
||||
RectificationToolServiceError,
|
||||
persistV9DeterministicTurn,
|
||||
resolveV10ConversationFocus,
|
||||
setV9EvidenceDateReliability,
|
||||
type RectificationRpcClient,
|
||||
@@ -31,6 +32,7 @@ import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
|
||||
import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts";
|
||||
import { toAgentModelFinishReason } from "../../agent-observability.ts";
|
||||
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
|
||||
import { shouldHostReaskYearEntry } from "./year-entry-host.ts";
|
||||
import { decideFromDossier } from "./decision-from-dossier";
|
||||
import { ensureNonTerminalTurnExit, persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
|
||||
import { alreadyDelivered } from "./delivery-turn-guard";
|
||||
@@ -336,6 +338,28 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
}
|
||||
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
|
||||
// Date reliability (above) runs first. Year-stage typed collect with a
|
||||
// yearless short reply does not enter the model (BUG-910).
|
||||
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
|
||||
if (yearReask && message) {
|
||||
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
|
||||
requestId: options.requestId,
|
||||
userMessage: message,
|
||||
assistantMessage: yearReask,
|
||||
});
|
||||
await emit({ type: "answer.delta", text: yearReask, replace: true });
|
||||
return {
|
||||
ok: true,
|
||||
turnId: persisted.turnId,
|
||||
turnStatus: "completed",
|
||||
skillLoaded: true,
|
||||
answerText: yearReask,
|
||||
phases: ["answer.host_year_entry"],
|
||||
toolsUsed: [],
|
||||
errorCode: null,
|
||||
previousFocusId,
|
||||
};
|
||||
}
|
||||
if (dossier.case.sessionId !== sessionId) {
|
||||
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
|
||||
}
|
||||
|
||||
@@ -89,5 +89,5 @@ export function evidenceWritesAllowed(
|
||||
export const MAX_RESUMABLE_CASES_PER_USER = 1;
|
||||
|
||||
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
|
||||
// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.28";
|
||||
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.29";
|
||||
|
||||
@@ -27,15 +27,15 @@ export const CHOICE_STOP_MESSAGE = "先这样";
|
||||
export const CHOICE_SKIP_QUESTION_LABEL = "这题跳过";
|
||||
export const CHOICE_SKIP_QUESTION_MESSAGE = "这题跳过";
|
||||
export const HOLDOUT_MESSAGE_PREFIX = "盘外核对(不计分)";
|
||||
export const TARGETED_COLLECT_OPTION_A = "有,我来填时间";
|
||||
export const TARGETED_COLLECT_OPTION_A = "有,我来说";
|
||||
export const TARGETED_COLLECT_OPTION_B = "这类事都没有过";
|
||||
export const TARGETED_COLLECT_OPTION_C = "记不太清楚";
|
||||
export const TARGETED_COLLECT_OPTION_D = "这条先跳过";
|
||||
export const GUIDED_WINDOW_OPTION_A = "有,我来填时间";
|
||||
export const GUIDED_WINDOW_OPTION_A = "有,我来说";
|
||||
export const GUIDED_WINDOW_OPTION_B = "这段没有";
|
||||
export const GUIDED_WINDOW_OPTION_C = "记不清";
|
||||
export const GUIDED_WINDOW_OPTION_D = "这条先跳过";
|
||||
export const TARGETED_COLLECT_WHY_USER = "答有的话再用选择器填年月,没有或记不清就问下一条。";
|
||||
export const TARGETED_COLLECT_WHY_USER = "答有的话直接打字说大概年月,没有或记不清就问下一条。";
|
||||
export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。";
|
||||
export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/;
|
||||
export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -680,18 +680,10 @@ export function guidedWindowQuestionId(
|
||||
return stage === "year" ? `${base}:year` : base;
|
||||
}
|
||||
|
||||
export function parseYearEntryQuestionId(
|
||||
questionId: string | null | undefined,
|
||||
): Readonly<{ domain: GuidedWindowDomain; year: number | null; month: number | null }> | null {
|
||||
const guided = parseGuidedWindowQuestionId(questionId);
|
||||
if (guided?.stage === "year") {
|
||||
return { domain: guided.domain, year: guided.year, month: guided.monthLo };
|
||||
}
|
||||
const targeted = parseTargetedCollectQuestionId(questionId);
|
||||
if (targeted?.stage === "year") {
|
||||
return { domain: targeted.domain, year: null, month: null };
|
||||
}
|
||||
return null;
|
||||
/** Year-stage collect questions: typed answers, no entry card (T0 / BUG-908). */
|
||||
export function isYearStageQuestionId(questionId: string | null | undefined): boolean {
|
||||
return parseTargetedCollectQuestionId(questionId)?.stage === "year"
|
||||
|| parseGuidedWindowQuestionId(questionId)?.stage === "year";
|
||||
}
|
||||
|
||||
/** Open-window examples: the first three still-open domains, table driven. */
|
||||
@@ -897,8 +889,9 @@ export function pendingTargetedYearDomain(
|
||||
existenceResolved.add(targeted.domain);
|
||||
}
|
||||
}
|
||||
for (const domain of covered) yearClosed.add(domain);
|
||||
for (const domain of existenceResolved) {
|
||||
if (covered.has(domain) || yearClosed.has(domain)) continue;
|
||||
if (yearClosed.has(domain)) continue;
|
||||
return domain;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1528,7 +1528,7 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup
|
||||
ask_theme: theme,
|
||||
domain,
|
||||
kind_hint: `targeted:${domain}:year`,
|
||||
user_prompt_hint: TARGETED_YEAR_PROMPT,
|
||||
user_prompt_hint: `${TARGETED_YEAR_PROMPT} 用户给了年月就走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。`,
|
||||
must_not_label: false,
|
||||
choice_frame: null,
|
||||
source: "method_coverage",
|
||||
@@ -2490,7 +2490,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
kind_hint: collectKindFromFocus(focus),
|
||||
user_prompt_hint: keepChoice
|
||||
? "先承接当前焦点。用 rectification-set-focus 的 spokenPrompt 写出题干;题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。年份和事件家族以已持久化的 period / 探针为准,不得发明年份,不得改问其他领域。正文不要提问、不要复述选项。"
|
||||
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入;否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
|
||||
: "先承接当前服务器焦点。若用户已说带年份的经历,走 batch 写入并结束本题;不追问主体、原因、细节;定向健康题主体默认本人。否则用 rectification-set-focus 的 spokenPrompt 继续问一件带大概年份的事。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。",
|
||||
source: "active_focus",
|
||||
...(liveProbe && focus.intent === "distinguish_candidates"
|
||||
? {
|
||||
|
||||
@@ -23,11 +23,13 @@ import { refinementFromDecisionReceipt } from "./refinement-packet";
|
||||
import {
|
||||
setV10ConversationFocus,
|
||||
resolveV10ConversationFocus,
|
||||
loadV9CaseDossier,
|
||||
RectificationToolServiceError,
|
||||
safeToolErrorCode,
|
||||
type AccountingClient,
|
||||
type ConversationFocus,
|
||||
} from "./tool-service";
|
||||
import { isYearStageQuestionId } from "./collection-question-pool";
|
||||
|
||||
export { parsePersistedFollowupQuestionId, parseCollectFocusQuestionId } from "./method-followup";
|
||||
|
||||
@@ -342,14 +344,6 @@ export function collectFocusSchema(followup: MethodFollowup): Record<string, unk
|
||||
schema.date_reliability = true;
|
||||
schema.target_evidence_id = followup.date_reliability_evidence_id;
|
||||
}
|
||||
const yearEntry = (followup.kind_hint ?? "").endsWith(":year")
|
||||
|| (followup.collection_key ?? "").endsWith(":year");
|
||||
if (yearEntry) {
|
||||
schema.event_date_entry = true;
|
||||
if (followup.domain) schema.default_domain = followup.domain;
|
||||
if (typeof followup.probe_year === "number") schema.default_year = followup.probe_year;
|
||||
if (typeof followup.probe_month === "number") schema.default_month = followup.probe_month;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -381,6 +375,31 @@ export function isCollectFocusSchema(schema: Readonly<Record<string, unknown>> |
|
||||
return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string";
|
||||
}
|
||||
|
||||
export function shouldResolveCollectFocusAfterEvidence(
|
||||
focus: Pick<ConversationFocus, "intent" | "questionId" | "expectedAnswerSchema"> | null | undefined,
|
||||
): boolean {
|
||||
if (!focus || focus.intent !== "collect_method_evidence") return false;
|
||||
if (isCollectFocusSchema(focus.expectedAnswerSchema)) return true;
|
||||
return isYearStageQuestionId(focus.questionId);
|
||||
}
|
||||
|
||||
export async function resolveActiveCollectFocusAfterEvidence(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
evidenceId: string | null;
|
||||
}): Promise<void> {
|
||||
if (!input.evidenceId) return;
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
const active = dossier.conversationSummary.activeFocus;
|
||||
if (!active || !shouldResolveCollectFocusAfterEvidence(active)) return;
|
||||
await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, {
|
||||
focusId: active.id,
|
||||
status: "resolved",
|
||||
evidenceId: input.evidenceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function persistCollectFocus(input: {
|
||||
accounting: AccountingClient;
|
||||
userId: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { parseAgentChoiceCopy } from "./choice-card";
|
||||
import { persistableFocusDomain, stableFollowupQuestionId } from "./server-focus";
|
||||
import { MACHINE_VOICE_LEXICON } from "./agent-voice-lexicon";
|
||||
import { COLLECT_DOMAIN_KEYWORDS } from "../user-copy";
|
||||
import { isYearStageQuestionId } from "./collection-question-pool";
|
||||
import type { MethodFollowup } from "./method-followup";
|
||||
|
||||
export const SPOKEN_PROMPT_MIN = 8;
|
||||
@@ -99,8 +100,9 @@ export function validateSpokenPrompt(input: {
|
||||
export function withSpokenPrompt(
|
||||
schema: Readonly<Record<string, unknown>>,
|
||||
spokenPrompt: string,
|
||||
questionId?: string | null,
|
||||
): Record<string, unknown> {
|
||||
if (schema.targeted_collect === true) {
|
||||
if (schema.targeted_collect === true || isYearStageQuestionId(questionId)) {
|
||||
return { ...schema, spoken_prompt: spokenPrompt };
|
||||
}
|
||||
const next: Record<string, unknown> = { ...schema, prompt: spokenPrompt };
|
||||
|
||||
@@ -14,7 +14,7 @@ import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract";
|
||||
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
|
||||
import { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card";
|
||||
import { isCollectFocusSchema } from "./server-focus";
|
||||
import { isTargetedCollectExistenceFocus } from "./collection-question-pool";
|
||||
import { isTargetedCollectExistenceFocus, isYearStageQuestionId } from "./collection-question-pool";
|
||||
import { rebuildTargetedCollectExistenceFrame } from "./method-followup";
|
||||
|
||||
export const TURN_DECISION_MAX_BYTES = 6 * 1024;
|
||||
@@ -83,6 +83,17 @@ export function projectCurrentQuestion(
|
||||
if (!focus) return null;
|
||||
const schema = focus.expectedAnswerSchema;
|
||||
const spoken = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
|
||||
if (isYearStageQuestionId(focus.questionId) && spoken) {
|
||||
return {
|
||||
question_id: focus.questionId ?? null,
|
||||
focus_id: focus.id ?? null,
|
||||
probe_id: typeof schema?.probe_id === "string" ? schema.probe_id : null,
|
||||
prompt: spoken,
|
||||
kind: "collect_spoken",
|
||||
intent: focus.intent,
|
||||
domain: focus.targetDomain ?? null,
|
||||
};
|
||||
}
|
||||
const copy = parseAgentChoiceCopy(schema);
|
||||
const probeId = typeof schema?.probe_id === "string" ? schema.probe_id : null;
|
||||
if (copy) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Year-stage collect is a typed question. A yearless short reply must not
|
||||
* enter the model (BUG-910). Date-reliability classification runs first.
|
||||
*/
|
||||
import { TARGETED_YEAR_PROMPT, isYearStageQuestionId } from "./collection-question-pool";
|
||||
import type { ConversationFocus } from "./tool-service";
|
||||
|
||||
export function userMessageHasYear(message: string): boolean {
|
||||
return /(?:19|20)\d{2}|\d{1,4}\s*年/.test(message);
|
||||
}
|
||||
|
||||
export function yearEntryServerPrompt(
|
||||
schema: Readonly<Record<string, unknown>> | null | undefined,
|
||||
): string {
|
||||
const prompt = typeof schema?.prompt === "string" ? schema.prompt.trim() : "";
|
||||
return prompt || TARGETED_YEAR_PROMPT;
|
||||
}
|
||||
|
||||
export function shouldHostReaskYearEntry(
|
||||
focus: Pick<ConversationFocus, "questionId" | "expectedAnswerSchema"> | null | undefined,
|
||||
message: string | null | undefined,
|
||||
): string | null {
|
||||
if (!message?.trim()) return null;
|
||||
if (!isYearStageQuestionId(focus?.questionId ?? null)) return null;
|
||||
if (userMessageHasYear(message)) return null;
|
||||
return yearEntryServerPrompt(focus?.expectedAnswerSchema);
|
||||
}
|
||||
@@ -94,10 +94,10 @@ import {
|
||||
import {
|
||||
COLLECT_FOCUS_SCHEMA_KEY,
|
||||
collectFocusSchema,
|
||||
isCollectFocusSchema,
|
||||
openQuestionFromPersistedFocus,
|
||||
persistableFocusDomain,
|
||||
persistServerOwnedFocus,
|
||||
resolveActiveCollectFocusAfterEvidence,
|
||||
serverOwnedExpectedAnswerSchema,
|
||||
stableFollowupQuestionId,
|
||||
} from "@/lib/rectification-agentic/v9/server-focus";
|
||||
@@ -1239,6 +1239,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
expectedAnswerSchema: withSpokenPrompt(
|
||||
{ ...activeFocus.expectedAnswerSchema },
|
||||
spoken.prompt,
|
||||
activeFocus.questionId,
|
||||
),
|
||||
askedTurnId: turnId,
|
||||
});
|
||||
@@ -1346,7 +1347,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
&& isTargetedCollectExistenceFollowup(persistFollowup)
|
||||
&& expectedAnswerSchema.choice
|
||||
? { ...expectedAnswerSchema, spoken_prompt: spokenText }
|
||||
: withSpokenPrompt(expectedAnswerSchema, spokenText);
|
||||
: withSpokenPrompt(
|
||||
expectedAnswerSchema,
|
||||
spokenText,
|
||||
stableFollowupQuestionId(persistFollowup),
|
||||
);
|
||||
await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint });
|
||||
const result = await setV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
questionId: stableFollowupQuestionId(persistFollowup),
|
||||
@@ -1626,23 +1631,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
focusId: recorded.focusId,
|
||||
}));
|
||||
if (result.acceptedCount > 0) {
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
const acceptedEvidenceId = result.items.find(
|
||||
(item) => item.outcome === "accepted" && item.evidenceId,
|
||||
)?.evidenceId ?? null;
|
||||
const activeFocus = dossier.conversationSummary.activeFocus;
|
||||
if (
|
||||
acceptedEvidenceId
|
||||
&& activeFocus
|
||||
&& activeFocus.intent === "collect_method_evidence"
|
||||
&& isCollectFocusSchema(activeFocus.expectedAnswerSchema)
|
||||
) {
|
||||
await resolveV10ConversationFocus(accounting, userId, input.caseId, {
|
||||
focusId: activeFocus.id,
|
||||
status: "resolved",
|
||||
evidenceId: acceptedEvidenceId,
|
||||
});
|
||||
}
|
||||
await resolveActiveCollectFocusAfterEvidence({
|
||||
accounting,
|
||||
userId,
|
||||
caseId: input.caseId,
|
||||
evidenceId: acceptedEvidenceId,
|
||||
});
|
||||
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
|
||||
if (isResumableStatus(dossier.case.status as RectificationCaseStatus)) {
|
||||
await transitionV9CaseStatus(accounting, userId, input.caseId, "collecting_evidence");
|
||||
}
|
||||
@@ -1777,6 +1775,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
|
||||
errorCode: "invalid_item",
|
||||
idempotent: false,
|
||||
};
|
||||
if (result.outcome === "accepted" && result.evidenceId) {
|
||||
await resolveActiveCollectFocusAfterEvidence({
|
||||
accounting,
|
||||
userId,
|
||||
caseId: input.caseId,
|
||||
evidenceId: result.evidenceId,
|
||||
});
|
||||
}
|
||||
await receipt("rectification-propose-evidence", "evidence.proposed", "completed", {
|
||||
inputFingerprint,
|
||||
resultFingerprint: hashResult(result),
|
||||
|
||||
Reference in New Issue
Block a user