/** * Implicit rating from the user's reaction to the previous reply. * * A model labels the reaction from the end of the reply and the message. Only a * correction, confirmed by a second call, or plain praise becomes a rating; a * go-ahead, a question or news carries no rating at all. */ import type { TurnInput } from "./adaptation-turns"; const MODEL_REACTIONS = [ "corrected", "repeated", "praised", "approved", "new-topic", "follow-up", ] as const; type ModelReaction = (typeof MODEL_REACTIONS)[number]; export interface ReactionLabel { reaction: ModelReaction; issue: string; } const REACTION_SYSTEM_PROMPT = `You classify how a user reacted to an AI assistant's reply. Pick exactly one label: - corrected: the message shows the reply itself was wrong. Something the reply claimed is false, something it said was done is missing, a command or step it gave does not work, a promise it made was not kept, it forgot or missed what the user asked, or the user still does not understand after its explanation. - repeated: the user restates their previous request because it was not done. - praised: the user explicitly praises the reply or the work, such as "well done", "perfect", "exactly", "this works great". - approved: the user accepts the reply or tells the assistant to go ahead, with nothing else to fix. - new-topic: the user moves to something unrelated to the reply. - follow-up: anything else on the same work. These are follow-up, not corrected: - news about something that failed after the reply, when the reply never claimed it would work, such as a failed CI run or pasted errors; - asking "are you sure?" or asking the assistant to double-check, without saying the reply is wrong; - suggesting more, or a new requirement on top of the work; - asking where or how to do a step the reply described; - a complaint about something other than the reply. A go-ahead, "yes", "ok", or "thanks" with a next instruction is approved, not praised. Judge the message against what the reply said. Answer corrected only when the message clearly says or shows the reply was wrong; otherwise answer follow-up. For corrected or repeated, set issue to what the reply got wrong, in at most twelve words. Otherwise set issue to an empty string.`; const REACTION_SCHEMA = { type: "object", properties: { reaction: { enum: [...MODEL_REACTIONS] }, issue: { type: "string" }, }, required: ["reaction", "issue"], additionalProperties: false, } as const; const CORRECTION_CHECK_PROMPT = `An AI assistant sent a reply and the user answered. A first reader labelled the answer a correction of the reply. Check whether that is right. It is a correction only when the user says or shows that the reply itself was wrong: - something the reply claimed is false; - something it said was done is not done; - a command or step it gave does not work; - a promise it made was not kept; - following its advice or steps caused a problem; - it missed what the user asked, or is waiting on or asking for something the user had already done; - the user still does not understand after its explanation, even when they phrase it as a question. It is not a correction when the user: - wants more, or adds a requirement the reply was not asked to meet. "This needs more" or "it should also do X" is a new requirement unless the reply claimed X was already done; - cannot find where or how to do a step the reply described; - doubts the reply or asks for a check, without saying it is wrong; - reports something that failed later, when the reply never claimed it would work; - complains about something other than what the reply claimed; - decides to change or drop a feature.`; const CORRECTION_CHECK_ANSWER = `Answer corrected if it is a correction, otherwise not-corrected. For corrected, set issue to what the reply got wrong, in at most twelve words; otherwise set issue to an empty string.`; const CORRECTION_CHECK_SCHEMA = { type: "object", properties: { reaction: { enum: ["corrected", "not-corrected"] }, issue: { type: "string" }, }, required: ["reaction", "issue"], additionalProperties: false, } as const; const MESSAGE_MAX = 800; function reactionPrompt(replyEnd: string, message: string): string { return `The end of the assistant's reply:\n\n${replyEnd}\n\n\nThe user's next message:\n\n${message.slice(0, MESSAGE_MAX)}\n`; } export function reactionRequest(replyEnd: string, message: string, sessionId?: string) { return { system: REACTION_SYSTEM_PROMPT, user: reactionPrompt(replyEnd, message), maxTokens: 200, timeout: 90_000, jsonSchema: REACTION_SCHEMA, caller: "rating", sessionId, }; } /** * Asked again with the labelling prompt, a model repeats its own mistake; a * narrower question catches the follow-ups it misreads as corrections, and the * corrections it misreads as follow-ups. The answer instruction follows the * message, the order eval/correction-check measures. */ export function correctionCheckRequest( replyEnd: string, message: string, sessionId?: string ) { return { ...reactionRequest(replyEnd, message, sessionId), system: CORRECTION_CHECK_PROMPT, user: `${reactionPrompt(replyEnd, message)}\n\n${CORRECTION_CHECK_ANSWER}`, jsonSchema: CORRECTION_CHECK_SCHEMA, }; } export function parseCorrectionCheck( output: string | null | undefined ): ReactionLabel | null { if (!output) return null; try { const parsed = JSON.parse(output) as { reaction?: unknown; issue?: unknown }; if (parsed.reaction !== "corrected") return { reaction: "follow-up", issue: "" }; const issue = typeof parsed.issue === "string" ? parsed.issue.trim() : ""; return { reaction: "corrected", issue }; } catch { return null; } } export function needsCorrectionCheck(first: ReactionLabel | null): boolean { return isCorrectionLabel(first) || first?.reaction === "follow-up"; } export function settledLabel( first: ReactionLabel | null, check: ReactionLabel | null ): ReactionLabel | null { const caughtFollowUp = first?.reaction === "follow-up" && isCorrectionLabel(check); return caughtFollowUp ? check : first; } function isModelReaction(value: unknown): value is ModelReaction { return MODEL_REACTIONS.includes(value as ModelReaction); } export function parseReactionLabel( output: string | null | undefined ): ReactionLabel | null { if (!output) return null; try { const parsed = JSON.parse(output) as { reaction?: unknown; issue?: unknown }; if (!isModelReaction(parsed.reaction)) return null; return { reaction: parsed.reaction, issue: typeof parsed.issue === "string" ? parsed.issue.trim() : "", }; } catch { return null; } } export function isCorrectionLabel(label: ReactionLabel | null): boolean { return label?.reaction === "corrected" || label?.reaction === "repeated"; } const RATING_BY_REACTION: Partial> = { corrected: 3, repeated: 2, praised: 8, }; /** A correction counts only when a second, independent label agrees. */ export function ratingFromLabels( first: ReactionLabel | null, confirmation: ReactionLabel | null ): number | null { if (!first) return null; if (isCorrectionLabel(first) && !isCorrectionLabel(confirmation)) return null; return RATING_BY_REACTION[first.reaction] ?? null; } interface SeenTurn { session: string; message: string; replyEnd: string; } export function turnFromLabels( seen: SeenTurn, first: ReactionLabel | null, confirmation: ReactionLabel | null ): TurnInput | null { if (!first) return null; const turn = { ...seen, reaction: first.reaction, issue: first.issue }; return isCorrectionLabel(first) ? { ...turn, confirmed: isCorrectionLabel(confirmation) } : turn; } export function ratingContext(label: ReactionLabel, message: string): string { const what = label.reaction === "praised" ? "Praised the reply" : label.issue; return `${what || label.reaction}: ${message.slice(0, 200)}`; }