fix(web): hold reverse-inference cards until acceptance event quality
Conflict probes were jumping after one dated event, so the interview asked another domain before method collection. Spoken replies now follow the stamped choice prompt instead of a topic denylist. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -44,7 +44,12 @@ import {
|
||||
createStepAnswerState,
|
||||
flushStepAnswerOnStreamFinish,
|
||||
} from "./step-answer";
|
||||
import { composeRectificationTurnNarration, publicNarrationDtoFromDossier } from "./turn-narration";
|
||||
import {
|
||||
bindSpokenToOpenQuestion,
|
||||
composeRectificationTurnNarration,
|
||||
openQuestionPromptFromToolResult,
|
||||
publicNarrationDtoFromDossier,
|
||||
} from "./turn-narration";
|
||||
import {
|
||||
defaultMessageOrigin,
|
||||
isRectificationMessageOrigin,
|
||||
@@ -630,11 +635,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
|
||||
const stepAnswer = createStepAnswerState();
|
||||
|
||||
let persistedPrompt: string | null = null;
|
||||
const heldSpoken: string[] = [];
|
||||
|
||||
const publishSpokenStep = async (pieces: readonly string[]) => {
|
||||
// The model's terminal text-delta is the user-visible reply. Do not
|
||||
// regex-split it, and do not replace it with Case narration.
|
||||
const spoken = pieces.join("").trim();
|
||||
if (!spoken || !caseLoaded) return;
|
||||
if (persistedPrompt) {
|
||||
heldSpoken.push(spoken);
|
||||
return;
|
||||
}
|
||||
answerText += spoken;
|
||||
answerDeltas.push(spoken);
|
||||
await emit({ type: "answer.delta", text: spoken });
|
||||
@@ -663,6 +673,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
}
|
||||
|
||||
const stampedPrompt = openQuestionPromptFromToolResult(chunk);
|
||||
if (stampedPrompt) persistedPrompt = stampedPrompt;
|
||||
|
||||
const stepEffect = applyStepAnswerChunk(
|
||||
stepAnswer,
|
||||
chunk,
|
||||
@@ -770,7 +783,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (mapped === "max_steps" || mapped === "provider_error") {
|
||||
return failedAttempt(attemptId, mapped);
|
||||
}
|
||||
if (!answerText.trim()) {
|
||||
if (!persistedPrompt) {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
persistedPrompt = publicNarrationDtoFromDossier(latest).nextQuestion;
|
||||
} catch {
|
||||
// Keep whatever prompt the tool result already stamped.
|
||||
}
|
||||
}
|
||||
if (persistedPrompt) {
|
||||
const bound = bindSpokenToOpenQuestion(heldSpoken.join("") || answerText, persistedPrompt);
|
||||
if (bound.trim()) {
|
||||
answerText = bound;
|
||||
answerDeltas.push(bound);
|
||||
await emit({ type: "answer.delta", text: bound });
|
||||
}
|
||||
} else if (!answerText.trim()) {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
|
||||
|
||||
@@ -204,10 +204,70 @@ export const BACKGROUND_ONLY_KINDS: ReadonlySet<EvidenceKind> = new Set([
|
||||
"horary_query",
|
||||
]);
|
||||
|
||||
/** Notes that may cover a method layer but do not count as primary scoring events. */
|
||||
export const AUXILIARY_EVIDENCE_KINDS: ReadonlySet<EvidenceKind> = new Set([
|
||||
"appearance_note",
|
||||
"birthmark_or_scar",
|
||||
"occupation_note",
|
||||
]);
|
||||
|
||||
export const NON_PRIMARY_SCORING_DOMAINS: ReadonlySet<string> = new Set([
|
||||
"appearance",
|
||||
"marks",
|
||||
"occupation",
|
||||
"horary",
|
||||
"other",
|
||||
]);
|
||||
|
||||
/** Same floors as `scripts/rectification/decision_policy.py`. */
|
||||
export const MIN_ACCEPTANCE_EVENTS = 3;
|
||||
export const MIN_ACCEPTANCE_DOMAINS = 2;
|
||||
|
||||
export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean {
|
||||
return BACKGROUND_ONLY_KINDS.has(kind);
|
||||
}
|
||||
|
||||
export function isPrimaryScoreableEvidence(item: {
|
||||
status: string;
|
||||
domain: string;
|
||||
datePrecision: string;
|
||||
occurredFrom: string | null;
|
||||
occurredTo: string | null;
|
||||
eventKind?: string | null;
|
||||
}): boolean {
|
||||
if (item.status !== "confirmed") return false;
|
||||
if (item.datePrecision === "unknown") return false;
|
||||
if (!item.occurredFrom && !item.occurredTo) return false;
|
||||
if (NON_PRIMARY_SCORING_DOMAINS.has(item.domain)) return false;
|
||||
const kind = item.eventKind;
|
||||
if (
|
||||
kind === "other"
|
||||
|| kind === "horary_query"
|
||||
|| kind === "appearance_note"
|
||||
|| kind === "birthmark_or_scar"
|
||||
|| kind === "occupation_note"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reverse-inference / conflict probes wait until the engine could accept. */
|
||||
export function meetsAcceptanceEventQuality(
|
||||
evidence: readonly Readonly<{
|
||||
status: string;
|
||||
domain: string;
|
||||
datePrecision: string;
|
||||
occurredFrom: string | null;
|
||||
occurredTo: string | null;
|
||||
eventKind?: string | null;
|
||||
}>[],
|
||||
): boolean {
|
||||
const scoreable = evidence.filter(isPrimaryScoreableEvidence);
|
||||
const domains = new Set(scoreable.map((item) => item.domain));
|
||||
return scoreable.length >= MIN_ACCEPTANCE_EVENTS && domains.size >= MIN_ACCEPTANCE_DOMAINS;
|
||||
}
|
||||
|
||||
/** Ledger/engine subject follows the domain. Family events must not stay on the tool default `self`. */
|
||||
export function evidenceSubjectForDomain(
|
||||
domain: string,
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
* Appearance and marks are skipped_by_policy. Horary does not block offering
|
||||
* time cards. Occupation does block cards until a note exists.
|
||||
* Method coverage asks for dated events in natural language.
|
||||
* After the first dated event, remaining dasha conflict probes
|
||||
* (year/activation differences) are asked before more method rotation
|
||||
* and they block offering time cards so the window can be filtered.
|
||||
* Dasha conflict probes wait until acceptance event quality
|
||||
* (3 primary scoreable events in 2 domains), then jump ahead of
|
||||
* remaining method rotation and block offering time cards so the
|
||||
* window can be filtered.
|
||||
* Once blocking methods are covered, move into candidate discrimination.
|
||||
* Coverage complete never means adopt. Horary does not block cards.
|
||||
* A/B/C/D choice frames attach only when candidates already diverge
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
type CandidateDiscriminatorProbe,
|
||||
} from "../core/candidate-contrast-packet.ts";
|
||||
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
|
||||
import { meetsAcceptanceEventQuality } from "./evidence-model";
|
||||
import type {
|
||||
DiscriminatingEventProbe,
|
||||
NakshatraBoundary,
|
||||
@@ -764,7 +766,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
...(input.askedProbeKeys ?? []),
|
||||
...askedKeysFromLedgerEvidence(input.evidence),
|
||||
]);
|
||||
const conflictProbe = dashaCovered
|
||||
const conflictProbe = dashaCovered && meetsAcceptanceEventQuality(input.evidence)
|
||||
? remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys)[0] ?? null
|
||||
: null;
|
||||
if (!dashaCovered) {
|
||||
|
||||
@@ -30,3 +30,47 @@ export function composeRectificationTurnNarration(dto: RectificationNarrationDto
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function promptFromQuestionField(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const prompt = (value as { prompt?: unknown }).prompt;
|
||||
if (typeof prompt !== "string") return null;
|
||||
const text = prompt.trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
export function readOpenQuestionPrompt(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
return promptFromQuestionField(row.open_question)
|
||||
?? promptFromQuestionField(row.current_question);
|
||||
}
|
||||
|
||||
export function openQuestionPromptFromToolResult(chunk: {
|
||||
type?: string;
|
||||
payload?: { result?: unknown; output?: unknown };
|
||||
object?: unknown;
|
||||
}): string | null {
|
||||
if (chunk.type !== "tool-result") return null;
|
||||
return readOpenQuestionPrompt(chunk.payload?.result)
|
||||
?? readOpenQuestionPrompt(chunk.payload?.output)
|
||||
?? readOpenQuestionPrompt(chunk.object)
|
||||
?? readOpenQuestionPrompt(chunk.payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a choice card is already stamped, the persisted prompt is the only
|
||||
* follow-up. Keep non-interrogative acknowledgements; drop any other ask.
|
||||
* This is not a topic denylist — user answers stay free text or taps.
|
||||
*/
|
||||
export function bindSpokenToOpenQuestion(spoken: string, nextQuestion: string | null): string {
|
||||
const question = nextQuestion?.trim() ?? "";
|
||||
if (!question) return spoken.trim();
|
||||
const ack = spoken
|
||||
.split(/\n{2,}/)
|
||||
.flatMap((block) => block.split(/(?<=[。!])\s*/u))
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0 && !/[??]/.test(part) && !part.includes(question))
|
||||
.slice(0, 2);
|
||||
return [...ack, question].join("\n\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user