@@ -0,0 +1,575 @@
import assert from "node:assert/strict" ;
import test from "node:test" ;
import { applyRectificationChoice , persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts" ;
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts" ;
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts" ;
import {
expectedAnswerSchemaFor ,
persistServerOwnedFocus ,
stableFollowupQuestionId ,
} from "../src/lib/rectification-agentic/v9/server-focus.ts" ;
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts" ;
import { persistedQuestionSurface } from "../src/lib/rectification-surface-state.ts" ;
import {
inspectRepresentativeTime ,
warnRepresentativeTimeInconsistency ,
} from "../src/lib/rectification-agentic/core/representative-time-guard.ts" ;
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts" ;
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts" ;
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts" ;
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts" ;
import {
CASE_ID ,
FOCUS_ID ,
SESSION_ID ,
TURN_ID ,
USER_ID ,
activeFocusFixture ,
candidateSnapshotFixture ,
computeFixture ,
conversationSummaryFixture ,
dossierFixture ,
fakeAccounting ,
receiptHandlers ,
} from "./rectification-v9-test-support.ts" ;
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" ;
const STEM = "2023 年 5 月前后,有没有开始一段认真关系、分手或结婚?" ;
const OPTIONS = [
{ label : "明确发生且时间吻合" , answer_class : "yes" as const } ,
{ label : "发生过但程度较弱" , answer_class : "weak_yes" as const } ,
{ label : "明确没有发生" , answer_class : "no" as const } ,
{ label : "这段记不清楚" , answer_class : "unsure" as const } ,
] ;
function emptyInference() {
return {
algorithm_version : INFERENCE_ALGORITHM_VERSION ,
candidate_set_id : "04:48-05:07" ,
revision : 6 ,
phase : "discrimination" ,
result_status : "discriminating" ,
range_start : "04:48" ,
range_end : "05:07" ,
candidates : [
{
id : "04:53" ,
time : "04:53" ,
cluster_range : [ "04:48" , "05:07" ] as const ,
prior_score : 16 ,
posterior_score : 16 ,
probability : 0.4 ,
status : "active" as const ,
rank : 1 ,
strong_conflict_count : 0 ,
} ,
{
id : "05:06" ,
time : "05:06" ,
cluster_range : [ "04:48" , "05:07" ] as const ,
prior_score : 15 ,
posterior_score : 15 ,
probability : 0.35 ,
status : "active" as const ,
rank : 2 ,
strong_conflict_count : 0 ,
} ,
] ,
events : [ ] ,
probes : [ ] ,
answered_probes : [ ] ,
rounds : [ ] ,
entropy : 1 ,
representative_time : "04:53" ,
credible_range : [ "04:48" , "05:07" ] as const ,
} ;
}
const WINDOW_SCAN = {
scanned : true ,
d9_lagna_count : 2 ,
d10_lagna_count : 2 ,
d4_lagna_count : 2 ,
d9_candidates_differ : true ,
d10_candidates_differ : true ,
d4_candidates_differ : true ,
} ;
function distinguishFollowup ( ) : MethodFollowup {
const frame = buildChoiceFrame ( {
method_id : "d9_relationship" ,
ask_theme : "dated_event" ,
domain : "relationship" ,
user_prompt_hint : "ask" ,
} , {
probes : [ {
year : 2023 ,
year_label : "2023 年 5 月前后" ,
domain : "relationship" ,
event_family : "开始一段认真关系、分手或结婚" ,
source : "dasha_boundary" ,
tracks : [ "vimshottari" , "narayana" ] ,
tracks_agree : true ,
unique_minute_claim : false ,
user_meaning : STEM ,
role : "distinguish" ,
information_gain : 0.4 ,
semantic_key : "relationship.2023.05.dasha_boundary" ,
style_options : OPTIONS ,
} ] ,
} ) ;
assert . ok ( frame ) ;
return {
method_id : "d9_relationship" ,
intent : "distinguish_candidates" ,
ask_theme : "dated_event" ,
domain : "relationship" ,
kind_hint : "relationship_change" ,
user_prompt_hint : "ask" ,
must_not_label : false ,
choice_frame : frame ,
source : "precision_stage" ,
information_gain : 0.4 ,
semantic_key : "relationship.2023.05.dasha_boundary" ,
probe_year : 2023 ,
year_label : "2023 年 5 月前后" ,
candidate_ids : [ "04:48" , "05:07" ] ,
expected_outcomes : [
{ answer_class : "yes" , supports : [ "04:48" ] , conflicts : [ "05:07" ] } ,
{ answer_class : "no" , supports : [ "05:07" ] , conflicts : [ "04:48" ] } ,
] ,
} ;
}
const DATED_EVIDENCE = [
{
id : "e-edu" ,
status : "confirmed" as const ,
domain : "education" ,
datePrecision : "month" as const ,
occurredFrom : "2016-09-01" ,
occurredTo : "2016-09-30" ,
eventKind : "education_start" ,
summary : "2016年9月上大学" ,
} ,
{
id : "e-career" ,
status : "confirmed" as const ,
domain : "career" ,
datePrecision : "month" as const ,
occurredFrom : "2020-04-01" ,
occurredTo : null ,
eventKind : "career_entry" ,
summary : "2020年4月入职" ,
} ,
{
id : "e-rel" ,
status : "confirmed" as const ,
domain : "relationship" ,
datePrecision : "year" as const ,
occurredFrom : "2018-01-01" ,
occurredTo : "2018-12-31" ,
eventKind : "relationship_start" ,
summary : "2018年恋爱" ,
} ,
{
id : "e-move" ,
status : "confirmed" as const ,
domain : "relocation" ,
datePrecision : "year" as const ,
occurredFrom : "2015-01-01" ,
occurredTo : "2015-12-31" ,
eventKind : "relocation" ,
summary : "2015年搬家" ,
} ,
] ;
const PERSIST_EVIDENCE = DATED_EVIDENCE . filter ( ( row ) = > row . domain !== "relationship" ) ;
function focusAccounting() {
return fakeAccounting ( {
. . . receiptHandlers ,
get_agentic_rectification_case_compute : ( ) = > computeFixture ( ) ,
get_agentic_rectification_case_dossier : ( ) = > snakeDossier ( ) ,
set_agentic_rectification_conversation_focus : ( _fn , args ) = > ( {
focus : {
id : FOCUS_ID ,
case_id : CASE_ID ,
question_id : args.p_question_id ,
intent : args.p_intent ,
target_evidence_id : args.p_target_evidence_id ? ? null ,
target_domain : args.p_target_domain ? ? null ,
target_kind : args.p_target_kind ? ? null ,
expected_answer_schema : args.p_expected_answer_schema ,
status : "active" ,
asked_at : "2026-09-14T00:00:00.000Z" ,
resolved_at : null ,
asked_turn_id : args.p_asked_turn_id ? ? null ,
} ,
idempotent : false ,
} ) ,
resolve_agentic_rectification_conversation_focus : ( ) = > ( {
focus : { id : FOCUS_ID , status : "resolved" } ,
idempotent : false ,
} ) ,
append_agentic_rectification_turn : ( ) = > ( { turn_id : TURN_ID , idempotent : false } ) ,
apply_agentic_rectification_choice_action : ( _fn , args ) = > ( {
action_id : args.p_action_id ,
status : "applied" ,
idempotent : false ,
question_id : args.p_question_id ,
option_id : args.p_option_id ,
probe_id : "p-cd" ,
revision : Number ( args . p_expected_revision ? ? 6 ) + 1 ,
source_quote : args.p_source_quote ,
derived_context : args.p_derived_context ,
narration : args.p_narration ,
focus_status : args.p_focus_status ,
} ) ,
} ) ;
}
function snakeEvidence ( rows : typeof DATED_EVIDENCE = DATED_EVIDENCE ) {
return rows . map ( ( row ) = > ( {
id : row.id ,
status : row.status ,
domain : row.domain ,
date_precision : row.datePrecision ,
occurred_from : row.occurredFrom ,
occurred_to : row.occurredTo ,
event_kind : row.eventKind ,
summary : row.summary ,
} ) ) ;
}
function snakeDossier ( overrides : Parameters < typeof dossierFixture > [ 0 ] = { } ) {
return dossierFixture ( {
evidence : snakeEvidence ( PERSIST_EVIDENCE ) ,
evidenceCount : PERSIST_EVIDENCE.length ,
stage : "minute" ,
candidateRange : { start_time : "04:48" , end_time : "05:07" } ,
latestResult : candidateSnapshotFixture ( {
selectionAllowed : true ,
representativeTime : "04:53" ,
candidates : [
{ candidate_id : "04:53" , rank : 1 , time : "04:53" , relative_support : 16 } ,
{ candidate_id : "05:06" , rank : 2 , time : "05:06" , relative_support : 15 } ,
] ,
decisionReceipt : {
inference_state : emptyInference ( ) ,
prospective_probes : [ ] ,
precision_stage : { current : "d9_refine" } ,
discriminating_event_probes : [ ] ,
window_scan : WINDOW_SCAN ,
} ,
} ) ,
conversationSummary : conversationSummaryFixture ( {
activeFocus : activeFocusFixture ( {
expectedAnswerSchema : {
prompt : "2016 年前后,有没有明显高考或重要考试发挥失常?" ,
probe_id : "p-cd" ,
semantic_key : "career.2015" ,
scoring : true ,
choice : {
prompt : "2016 年前后,有没有明显高考或重要考试发挥失常?" ,
option_a : "是,大概就在那段时间" ,
option_b : "有类似,但年份不对或不够重大" ,
option_c : "没有明显发生" ,
option_d : "不记得 / 不确定" ,
options : [
{ key : "A" , label : "是,大概就在那段时间" , answer_class : "yes" } ,
{ key : "B" , label : "有类似,但年份不对或不够重大" , answer_class : "weak_yes" } ,
{ key : "C" , label : "没有明显发生" , answer_class : "no" } ,
{ key : "D" , label : "不记得 / 不确定" , answer_class : "unsure" } ,
] ,
} ,
} ,
} ) ,
} ) ,
turns : [
{
id : TURN_ID ,
role : "assistant" ,
text : "已记录,范围收到 04:48– 05:07。" ,
status : "completed" ,
} ,
] ,
. . . overrides ,
} ) ;
}
function camelDossier() {
return {
evidence : PERSIST_EVIDENCE ,
conversationSummary : {
activeFocus : null ,
declinedSkippedTopics : [ ] ,
} ,
latestResult : {
resultId : "55555555-5555-4555-8555-555555555555" ,
decisionReceipt : {
inference_state : emptyInference ( ) ,
prospective_probes : [ ] ,
precision_stage : { current : "d9_refine" } ,
discriminating_event_probes : [ ] ,
window_scan : WINDOW_SCAN ,
} ,
selectionAllowed : true ,
candidates : [
{ time : "04:53" , rank : 1 , relativeSupport : 16 } ,
{ time : "05:06" , rank : 2 , relativeSupport : 15 } ,
] ,
} ,
case : {
acceptedTime : null ,
status : "collecting_evidence" ,
candidateRange : { start_time : "04:48" , end_time : "05:07" } ,
stage : "minute" as const ,
} ,
turns : [
{ role : "assistant" as const , text : "已记录,范围收到 04:48– 05:07。" } ,
] ,
} ;
}
test ( "empty probe pool cannot stamp a scoring distinguish schema" , ( ) = > {
const followup = distinguishFollowup ( ) ;
const schema = expectedAnswerSchemaFor (
followup . choice_frame ! ,
stableFollowupQuestionId ( followup ) ,
{ inference_state : emptyInference ( ) } ,
followup ,
) ;
assert . equal ( schema , null ) ;
} ) ;
test ( "unstampable distinguish persist is invalid_choice_schema" , async ( ) = > {
const accounting = focusAccounting ( ) ;
const result = await persistServerOwnedFocus ( {
accounting : accounting.client ,
userId : USER_ID ,
caseId : CASE_ID ,
activeFocus : null ,
decisionReceipt : { inference_state : emptyInference ( ) } ,
followup : distinguishFollowup ( ) ,
} ) ;
assert . equal ( result . status , "invalid_choice_schema" ) ;
} ) ;
test ( "persistNextInterviewAfterChoice does not speak an unstampable distinguish stem" , async ( ) = > {
const followup = distinguishFollowup ( ) ;
const stem = followup . choice_frame ? . prompt ? ? STEM ;
const accounting = focusAccounting ( ) ;
const dossier = camelDossier ( ) ;
const decision = decideFromDossier ( dossier , { birthDate : "1997-08-08" } ) ;
const first = await persistNextInterviewAfterChoice ( {
accounting : accounting.client ,
userId : USER_ID ,
caseId : CASE_ID ,
dossier ,
decisionState : emptyInference ( ) as never ,
nextAction : publicNextAction ( decision ) ,
decision ,
birthDate : "1997-08-08" ,
skipRefresh : true ,
followup ,
} ) ;
assert . equal ( first . hostNarration . includes ( stem ) , false , first . hostNarration ) ;
assert . notEqual ( first . followup ? . intent , "distinguish_candidates" ) ;
assert . ok (
first . choiceReady === true || first . followup ? . intent === "collect_method_evidence" ,
JSON . stringify ( { choiceReady : first.choiceReady , intent : first.followup?.intent , host : first.hostNarration } ) ,
) ;
const second = await persistNextInterviewAfterChoice ( {
accounting : accounting.client ,
userId : USER_ID ,
caseId : CASE_ID ,
dossier : {
. . . dossier ,
turns : [ { role : "assistant" as const , text : stem } ] ,
} ,
decisionState : emptyInference ( ) as never ,
nextAction : publicNextAction ( decision ) ,
decision ,
birthDate : "1997-08-08" ,
skipRefresh : true ,
followup ,
} ) ;
assert . equal ( second . hostNarration . includes ( stem ) , false , second . hostNarration ) ;
} ) ;
test ( "collect stem already in the last assistant turn is not spoken again" , async ( ) = > {
const stem = "请再补充一件带年月的搬家或长期住到外地的经历。" ;
const followup : MethodFollowup = {
method_id : "dasha_events" ,
intent : "collect_method_evidence" ,
ask_theme : "dated_event" ,
domain : "relocation" ,
kind_hint : "home_change" ,
user_prompt_hint : "ask" ,
must_not_label : false ,
choice_frame : null ,
spoken_prompt : stem ,
source : "method_coverage" ,
} ;
const accounting = focusAccounting ( ) ;
const dossier = {
. . . camelDossier ( ) ,
turns : [ { role : "assistant" as const , text : ` 记下了。 \ n \ n ${ stem } ` } ] ,
} ;
const decision = decideFromDossier ( dossier , { birthDate : "1997-08-08" } ) ;
const result = await persistNextInterviewAfterChoice ( {
accounting : accounting.client ,
userId : USER_ID ,
caseId : CASE_ID ,
dossier ,
decisionState : emptyInference ( ) as never ,
nextAction : publicNextAction ( decision ) ,
decision ,
birthDate : "1997-08-08" ,
skipRefresh : true ,
followup ,
} ) ;
assert . equal ( result . hostNarration . includes ( stem ) , false , result . hostNarration ) ;
} ) ;
test ( "applyRectificationChoice narration does not re-ask an unstampable distinguish stem" , async ( ) = > {
const followup = distinguishFollowup ( ) ;
const stem = followup . choice_frame ? . prompt ? ? STEM ;
const accounting = focusAccounting ( ) ;
const applied = await applyRectificationChoice ( accounting . client , {
userId : USER_ID ,
caseId : CASE_ID ,
sessionId : SESSION_ID ,
actionId : ACTION_ID ,
action : CHOICE_ACTION ,
focusId : FOCUS_ID ,
questionId : "question-1" ,
probeId : "p-cd" ,
optionId : "C" ,
expectedRevision : 6 ,
} ) ;
assert . equal ( applied . applied , true ) ;
assert . equal ( applied . narration . includes ( stem ) , false , applied . narration ) ;
const nextMeaning = JSON . stringify ( applied . nextUserAction ? ? { } ) ;
assert . equal ( nextMeaning . includes ( stem ) , false , nextMeaning ) ;
} ) ;
test ( "active choice focus without asked_turn_id hangs on the last assistant turn" , ( ) = > {
const prompt = "结过婚或订过婚吗?" ;
const attached = attachQuestionsToTurns (
[
{ id : TURN_ID , role : "assistant" as const , text : "已记录,范围收到 04:48– 05:07。" } ,
] ,
[ {
id : FOCUS_ID ,
caseId : CASE_ID ,
questionId : "collect:targeted:relationship" ,
intent : "collect_method_evidence" ,
targetEvidenceId : null ,
targetDomain : "relationship" ,
targetKind : "targeted:relationship" ,
expectedAnswerSchema : {
targeted_collect : true ,
prompt ,
choice : {
prompt ,
option_a : "有过这件事" ,
option_b : "没有发生过" ,
option_c : "记不太清楚" ,
option_d : "这条先跳过" ,
options : [
{ key : "A" , label : "有过这件事" , answer_class : "yes" } ,
{ key : "B" , label : "没有发生过" , answer_class : "no" } ,
{ key : "C" , label : "记不太清楚" , answer_class : "unsure" } ,
{ key : "D" , label : "这条先跳过" , answer_class : "weak_yes" } ,
] ,
} ,
} ,
status : "active" ,
askedAt : "2026-09-14T00:00:00.000Z" ,
resolvedAt : null ,
askedTurnId : null ,
answerOption : null ,
} ] ,
) ;
assert . equal ( attached [ 0 ] ? . question ? . kind , "choice" ) ;
assert . equal ( attached [ 0 ] ? . question ? . focus_id , FOCUS_ID ) ;
assert . equal ( attached [ 0 ] ? . question ? . options ? . length , 4 ) ;
assert . equal ( attached [ 0 ] ? . question ? . prompt , prompt ) ;
} ) ;
test ( "persisted question with a live choice card is not the spoken-only surface" , ( ) = > {
assert . equal (
persistedQuestionSurface ( {
questionKind : "choice" ,
hasChoiceCard : true ,
questionPersisted : true ,
} ) ,
"choice_card" ,
) ;
assert . equal (
persistedQuestionSurface ( {
questionKind : "collect_spoken" ,
hasChoiceCard : false ,
questionPersisted : true ,
} ) ,
"spoken" ,
) ;
assert . equal (
persistedQuestionSurface ( {
questionKind : "choice" ,
hasChoiceCard : true ,
questionPersisted : false ,
} ) ,
"none" ,
) ;
} ) ;
test ( "representative time in eliminated ids or outside the range warns once each" , ( ) = > {
const lines : string [ ] = [ ] ;
const original = console . warn ;
console . warn = ( value : unknown ) = > {
lines . push ( String ( value ) ) ;
} ;
try {
const eliminated = warnRepresentativeTimeInconsistency ( {
representativeTime : "05:14" ,
eliminatedIds : [ "05:14" , "05:08" ] ,
credibleRange : [ "04:48" , "05:07" ] ,
winnerId : "04:53" ,
scoresAfter : { "04:53" : 16 , "05:14" : 7 } ,
} ) ;
assert . equal ( eliminated . ok , false ) ;
assert . equal ( eliminated . inEliminated , true ) ;
const outside = warnRepresentativeTimeInconsistency ( {
representativeTime : "05:14" ,
eliminatedIds : [ ] ,
credibleRange : [ "04:48" , "05:07" ] ,
winnerId : "04:53" ,
scoresAfter : { "04:53" : 16 , "05:14" : 7 } ,
} ) ;
assert . equal ( outside . ok , false ) ;
assert . equal ( outside . outsideRange , true ) ;
const healthy = warnRepresentativeTimeInconsistency ( {
representativeTime : "04:53" ,
eliminatedIds : [ "05:14" ] ,
credibleRange : [ "04:48" , "05:07" ] ,
winnerId : "04:53" ,
scoresAfter : { "04:53" : 16 , "05:14" : 7 } ,
} ) ;
assert . equal ( healthy . ok , true ) ;
assert . equal ( inspectRepresentativeTime ( {
representativeTime : "04:53" ,
eliminatedIds : [ "05:14" ] ,
credibleRange : [ "04:48" , "05:07" ] ,
} ) . ok , true ) ;
} finally {
console . warn = original ;
}
assert . equal ( lines . filter ( ( line ) = > line . includes ( "rectification_representative_time_inconsistent" ) ) . length , 2 ) ;
assert . match ( lines [ 0 ] ? ? "" , /"representative_time":"05:14"/ ) ;
assert . doesNotMatch ( lines . join ( "\n" ) , /birth|email|jwt/i ) ;
} ) ;