From 8f9e0a75f915ab93bfdcb9d8a0c846e23da5d2e8 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:18:00 +0100 Subject: [PATCH] Add bounded question context and targeting builders --- .../corrections-contributions.test.ts | 112 ++++++ .../corrections-excerpts.test.ts | 108 ++++++ .../corrections.test-fixtures.ts | 128 +++++++ .../src/conversation-plan/corrections.test.ts | 161 +++++++++ .../question-builder.test-fixtures.ts | 75 ++++ .../src/conversation-plan/question-context.ts | 160 +++++++++ .../conversation-plan/question-research.ts | 123 +++++++ .../src/conversation-plan/question-shared.ts | 31 ++ .../conversation-plan/question-targeting.ts | 322 ++++++++++++++++++ .../src/conversation-plan/question-triage.ts | 210 ++++++++++++ .../questions-duplicates.test.ts | 163 +++++++++ .../questions-targeting.test.ts | 67 ++++ .../src/conversation-plan/questions.test.ts | 63 ++++ .../server/src/conversation-plan/questions.ts | 24 ++ 14 files changed, 1747 insertions(+) create mode 100644 apps/server/src/conversation-plan/corrections-contributions.test.ts create mode 100644 apps/server/src/conversation-plan/corrections-excerpts.test.ts create mode 100644 apps/server/src/conversation-plan/corrections.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/corrections.test.ts create mode 100644 apps/server/src/conversation-plan/question-builder.test-fixtures.ts create mode 100644 apps/server/src/conversation-plan/question-context.ts create mode 100644 apps/server/src/conversation-plan/question-research.ts create mode 100644 apps/server/src/conversation-plan/question-shared.ts create mode 100644 apps/server/src/conversation-plan/question-targeting.ts create mode 100644 apps/server/src/conversation-plan/question-triage.ts create mode 100644 apps/server/src/conversation-plan/questions-duplicates.test.ts create mode 100644 apps/server/src/conversation-plan/questions-targeting.test.ts create mode 100644 apps/server/src/conversation-plan/questions.test.ts create mode 100644 apps/server/src/conversation-plan/questions.ts diff --git a/apps/server/src/conversation-plan/corrections-contributions.test.ts b/apps/server/src/conversation-plan/corrections-contributions.test.ts new file mode 100644 index 00000000..eda808e5 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections-contributions.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState, replay, restoreState } from "./domain"; +import { buildTargetingRequest, QUESTION_SET_VERSION } from "./questions"; +import { extractQuotes } from "./quotes"; +import { add, correct, message, open, stance } from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card contribution corrections", () => { + test("retargets reasons and constraints with exact wording and source attribution", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = add(state, "reason", "reason", "a"); + state = add(state, "constraint", "constraint", "t1"); + let original = structuredClone(state.threads[0].contributions.slice(-2)); + state = correct(state, "reason-to-thread", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "t1", + }); + state = correct(state, "constraint-to-b", { + kind: "retarget-contribution", + contributionId: "constraint", + targetId: "b", + }); + let changed = state.threads[0].contributions.slice(-2); + for (let index = 0; index < 2; index++) { + expect(changed[index]).toEqual({ + ...original[index], + targetId: index === 0 ? "t1" : "b", + targetEditedBy: "bob", + }); + } + expect(replay(state.events).threads).toEqual(state.threads); + expect(restoreState(JSON.parse(JSON.stringify(state))).threads).toEqual( + JSON.parse(JSON.stringify(state.threads)), + ); + let forged = JSON.parse(JSON.stringify(state)); + forged.threads[0].contributions.find((item: { id: string }) => item.id === "reason") + .targetEditedBy = "mallory"; + expect(() => restoreState(forged)).toThrow(/snapshot/i); + let invalid = structuredClone(state); + (invalid.events.at(-1) as any).change.extra = "unknown"; + expect(() => restoreState(invalid)).toThrow(/unknown/i); + }); + + test("rejects wrong contribution kinds, foreign targets, overlong IDs and stale versions", () => { + let state = add(open(initialState()), "option", "a"); + state = add(state, "reason", "reason", "a"); + state = open(state, "t2"); + state = add(state, "option", "foreign", undefined, "t2"); + let before = structuredClone(state); + for ( + let change of [ + { kind: "retarget-contribution", contributionId: "a", targetId: "t1" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "t2" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "foreign" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "a" }, + { kind: "retarget-contribution", contributionId: "reason", targetId: "x".repeat(201) }, + ] as ConversationPlan.CorrectionChange[] + ) { + expect(() => correct(state, "invalid-target", change)).toThrow(); + } + expect(() => + correct(state, "stale-target", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "t1", + }, state.threads[0].version - 1) + ).toThrow(/stale/i); + expect(state).toEqual(before); + }); + + test("later Jev context uses bounded corrected targets and current stances", () => { + let state = add(add(open(initialState()), "option", "a"), "reason", "reason", "t1"); + for (let index = 0; index < 15; index++) { + state = add(state, "reason", `other-${index}`, "t1"); + } + state = stance(state, "chat020-stance", "a"); + state = correct(state, "retarget-chat020", { + kind: "retarget-stance", + stanceId: "chat020-stance", + }); + state = correct(state, "retarget-reason", { + kind: "retarget-contribution", + contributionId: "reason", + targetId: "a", + }); + let next = message("next", "What follows from that?"); + let request = buildTargetingRequest(next, [], state.threads, extractQuotes(next.text)); + let context = (request.state as { + threads: Array<{ + stances: Array<{ participant: string; optionId?: string; position: string }>; + contributions: Array<{ id: string; targetId: string; relation: string }>; + }>; + }).threads[0]; + expect(QUESTION_SET_VERSION).toBe("conversation-plan-8"); + expect(context.stances).toEqual([{ + participant: "alice", + optionId: undefined, + position: "oppose", + }]); + expect(context.contributions).toHaveLength(4); + expect(context.contributions[0]).toMatchObject({ + id: "reason", + targetId: "a", + relation: "qualifies", + }); + expect(state.events.some((event) => event.id === "chat020-stance")).toBe(true); + }); +}); diff --git a/apps/server/src/conversation-plan/corrections-excerpts.test.ts b/apps/server/src/conversation-plan/corrections-excerpts.test.ts new file mode 100644 index 00000000..23dd0479 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections-excerpts.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { initialState } from "./domain"; +import { + add, + bob, + correctExcerpt, + message, + open, + reviewedExcerpt, +} from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card excerpt corrections", () => { + test.each( + [ + { + kind: "option", + text: "Postmark handles email delivery.", + targetOptionId: undefined, + targetId: "t1", + }, + { kind: "reason", text: "SQS is operationally simple.", targetOptionId: "a", targetId: "a" }, + { + kind: "constraint", + text: "Must stay inside our VPC.", + targetOptionId: undefined, + targetId: "t1", + }, + ] as const, + )("adds a held $kind excerpt with original source and human correction actor", ({ + kind, + text, + targetOptionId, + targetId, + }) => { + let state = add(open(initialState()), "option", "a"); + let original = message("held-chat", `I think ${text} Please review.`); + let start = original.text.indexOf(text); + let end = start + text.length; + state = reviewedExcerpt(state, original, 0, original.text.length, "ignored"); + let action = { + actionId: `correct-${kind}`, + threadId: "t1", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: original.id, + start, + end, + contributionKind: kind, + targetOptionId, + }, + } as unknown as ConversationPlan.CorrectionAction; + let changed = correctExcerpt(state, action, original); + let added = changed.threads[0]!.contributions.at(-1)!; + let excerpt = text; + let sourceRef = { + messageId: original.id, + author: original.author, + quote: excerpt, + start, + end, + role: kind, + }; + expect(added).toMatchObject({ + kind, + text: excerpt, + targetId, + authoring: "quoted", + actor: bob, + sources: [sourceRef], + }); + expect(changed.events.at(-1)).toMatchObject({ + type: `${kind}.added`, + origin: "human", + actor: bob, + source: sourceRef, + contribution: { text: excerpt, targetId }, + }); + expect(changed.analysis.at(-1)?.outcomes?.[0]?.status).toBe("ignored"); + expect(changed.revision).toBe(state.revision + 1); + }); + + test("replaying an excerpt correction action ID is idempotent", () => { + let state = add(open(initialState()), "option", "a"); + let original = message("held-retry", "SQS is operationally simple."); + state = reviewedExcerpt(state, original, 0, original.text.length); + let action = { + actionId: "correct-retry", + threadId: "t1", + expectedVersion: state.threads[0]!.version, + change: { + kind: "add-excerpt", + messageId: original.id, + start: 0, + end: original.text.length, + contributionKind: "reason", + targetOptionId: "a", + }, + } as unknown as ConversationPlan.CorrectionAction; + let changed = correctExcerpt(state, action, original); + expect(correctExcerpt(changed, action, original)).toBe(changed); + expect(changed.events.filter(event => event.origin === "human")).toHaveLength(1); + }); +}); diff --git a/apps/server/src/conversation-plan/corrections.test-fixtures.ts b/apps/server/src/conversation-plan/corrections.test-fixtures.ts new file mode 100644 index 00000000..db2c26a1 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections.test-fixtures.ts @@ -0,0 +1,128 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, applyInference } from "./domain"; +import { QUESTION_SET_VERSION } from "./questions"; + +// Exact actor/helper declarations: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. +export let alice = { kind: "member", handle: "alice" } as const; +export let bob = { kind: "member", handle: "bob" } as const; + +export function message(id: string, text: string): Chat.Entry { + return { id, text, author: alice, ts: 1 }; +} + +export function source( + entry: Chat.Entry, + role: ConversationPlan.SourceRole, +): ConversationPlan.SourceRef { + return { + messageId: entry.id, + author: entry.author as ConversationPlan.SourceAuthor, + quote: entry.text, + start: 0, + end: entry.text.length, + role, + }; +} + +export function open(state: ConversationPlan.State, threadId = "t1"): ConversationPlan.State { + let entry = message(`open-${threadId}`, `Which option for ${threadId}?`); + return applyInference(state, { + id: `open-event-${threadId}`, + type: "thread.opened", + threadId, + observedThreadVersion: 0, + origin: "classifier", + actor: { kind: "classifier" }, + at: 1, + source: source(entry, "question"), + question: entry.text, + }, entry); +} + +export function add( + state: ConversationPlan.State, + kind: "option" | "reason" | "constraint", + id: string, + targetId?: string, + threadId = "t1", +): ConversationPlan.State { + let entry = message(`message-${id}`, `Words for ${id}`); + return applyInference(state, { + id: `event-${id}`, + type: `${kind}.added`, + threadId, + observedThreadVersion: state.threads.find((thread) => thread.id === threadId)!.version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 2, + source: source(entry, kind), + contribution: { id, text: entry.text, authoring: "quoted", targetId, relation: "qualifies" }, + }, entry); +} + +export function stance( + state: ConversationPlan.State, + id: string, + optionId?: string, +): ConversationPlan.State { + let entry = message(`message-${id}`, `I object to ${optionId ?? "the thread"}.`); + return applyInference(state, { + id, + type: "stance.changed", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "classifier", + actor: { kind: "classifier" }, + at: 3, + source: source(entry, "objection"), + optionId, + position: "oppose", + }, entry); +} + +export function correct( + state: ConversationPlan.State, + actionId: string, + change: ConversationPlan.CorrectionChange, + expectedVersion = state.threads[0].version, +): ConversationPlan.State { + return applyCorrection(state, { actionId, threadId: "t1", expectedVersion, change }, bob, 10); +} + +export function reviewedExcerpt( + state: ConversationPlan.State, + entry: Chat.Entry, + start: number, + end: number, + status: "review" | "ignored" = "review", +): ConversationPlan.State { + return { + ...state, + revision: state.revision + 1, + analysis: [...state.analysis, { + messageId: entry.id, + questionSetVersion: QUESTION_SET_VERSION, + modelVersion: "jev-test", + status: "unlinked", + passes: [], + eventIds: [], + outcomes: [{ start, end, status, gate: "unclassified excerpt", eventIds: [] }], + }], + }; +} + +export function correctExcerpt( + state: ConversationPlan.State, + action: ConversationPlan.CorrectionAction, + entry: Chat.Entry, +): ConversationPlan.State { + let apply = applyCorrection as unknown as ( + state: ConversationPlan.State, + action: ConversationPlan.CorrectionAction, + actor: typeof bob, + at: number, + messages: readonly Chat.Entry[], + ) => ConversationPlan.State; + return apply(state, action, bob, 10, [entry]); +} diff --git a/apps/server/src/conversation-plan/corrections.test.ts b/apps/server/src/conversation-plan/corrections.test.ts new file mode 100644 index 00000000..7544aa20 --- /dev/null +++ b/apps/server/src/conversation-plan/corrections.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { ConversationPlan } from "@chopin/protocol"; +import { applyCorrection, initialState, replay, restoreState } from "./domain"; +import { applyEvent } from "./events"; +import { add, bob, correct, open, stance } from "./corrections.test-fixtures"; + +// Original callbacks/data: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/corrections.test.ts. + +describe("conversation card target corrections", () => { + test("retargets a sourced objection while keeping its speaker, quote and history", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = stance(state, "chat020-stance", "a"); + let original = structuredClone(state.threads[0].stances[0]); + let version = state.threads[0].version; + let changed = correct(state, "retarget-chat020", { + kind: "retarget-stance", + stanceId: original.id, + optionId: "b", + }); + let thread = changed.threads[0]; + expect(thread.version).toBe(version + 1); + expect(changed.revision).toBe(state.revision + 1); + expect(thread.status).toBe("exploring"); + expect(thread.stances).toEqual([{ + ...original, + id: "human:bob:retarget-chat020", + optionId: "b", + corrects: original.id, + correctedBy: "bob", + }]); + expect(thread.stanceHistory).toEqual([original, thread.stances[0]]); + expect(thread.stances[0].participant).toBe("alice"); + expect(thread.stances[0].sources).toEqual(original.sources); + expect(changed.events.at(-1)).toMatchObject({ + type: "card.corrected", + actor: bob, + change: { kind: "retarget-stance", stanceId: original.id, optionId: "b" }, + }); + expect("source" in changed.events.at(-1)!).toBe(false); + let saved = JSON.parse(JSON.stringify(changed)); + let restored = restoreState(saved); + expect(restored.threads).toEqual(saved.threads); + expect(replay(restored.events).threads).toEqual(restored.threads); + let forged = structuredClone(saved); + forged.threads[0].stances[0].correctedBy = "mallory"; + expect(() => restoreState(forged)).toThrow(/snapshot/i); + let retry = { + actionId: "retarget-chat020", + threadId: "t1", + expectedVersion: version, + change: { kind: "retarget-stance", stanceId: original.id, optionId: "b" } as const, + }; + expect(applyCorrection(restored, retry, bob, 11)).toBe(restored); + expect(() => + applyCorrection( + restored, + { + ...retry, + change: { ...retry.change, optionId: "a" }, + }, + bob, + 11, + ) + ).toThrow(/collision/i); + }); + + test("dismisses a mistaken stance without inventing a neutral vote or reopening", () => { + let state = stance(add(open(initialState()), "option", "a"), "wrong-objection", "a"); + let original = structuredClone(state.threads[0].stances[0]); + state = correct(state, "dismiss-wrong", { kind: "dismiss-stance", stanceId: original.id }); + expect(state.threads[0].stances).toEqual([]); + expect(state.threads[0].stanceHistory).toEqual([original]); + expect(state.threads[0].status).toBe("exploring"); + expect(restoreState(JSON.parse(JSON.stringify(state))).events.at(-1)).toMatchObject({ + type: "card.corrected", + actor: bob, + }); + state = applyEvent(state, { + id: "decision", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 12, + text: "Use a", + optionId: "a", + explicit: true, + }); + expect(state.threads[0].status).toBe("decided"); + expect(replay(state.events).threads).toEqual(state.threads); + }); + + test("correction of an objection leaves an existing decision in place", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = applyEvent(state, { + id: "card-decision", + type: "decision.recorded", + threadId: "t1", + observedThreadVersion: state.threads[0].version, + origin: "human", + actor: bob, + at: 10, + text: "Choose a", + optionId: "a", + explicit: true, + }); + state = stance(state, "late-objection", "a"); + let decision = structuredClone(state.threads[0].decision); + state = correct(state, "move-objection", { + kind: "retarget-stance", + stanceId: "late-objection", + optionId: "b", + }); + expect(state.threads[0].status).toBe("decided"); + expect(state.threads[0].decision).toEqual(decision); + }); + + test("rejects stale stance identities, absent options, same targets and target collisions", () => { + let state = add(add(open(initialState()), "option", "a"), "option", "b"); + state = stance(state, "old", "a"); + state = stance(state, "current", "a"); + state = stance(state, "on-b", "b"); + let before = structuredClone(state); + for ( + let change of [ + { kind: "retarget-stance", stanceId: "old", optionId: "b" }, + { kind: "dismiss-stance", stanceId: "old" }, + { kind: "retarget-stance", stanceId: "current", optionId: "missing" }, + { kind: "retarget-stance", stanceId: "current", optionId: "a" }, + { kind: "retarget-stance", stanceId: "current", optionId: "b" }, + ] as ConversationPlan.CorrectionChange[] + ) { + expect(() => + correct( + state, + `reject-${change.kind}-${"optionId" in change ? change.optionId : "old"}`, + change, + ) + ) + .toThrow(); + } + expect(() => + correct( + state, + "stale", + { kind: "dismiss-stance", stanceId: "current" }, + state.threads[0].version - 1, + ) + ).toThrow(/stale/i); + expect(state).toEqual(before); + expect(() => + correct(state, "unknown-field", { + kind: "dismiss-stance", + stanceId: "current", + extra: "forged", + } as unknown as ConversationPlan.CorrectionChange) + ).toThrow(/unknown/i); + }); +}); diff --git a/apps/server/src/conversation-plan/question-builder.test-fixtures.ts b/apps/server/src/conversation-plan/question-builder.test-fixtures.ts new file mode 100644 index 00000000..2b7efaa9 --- /dev/null +++ b/apps/server/src/conversation-plan/question-builder.test-fixtures.ts @@ -0,0 +1,75 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { extractQuotes } from "./quotes"; + +// Exact archive data/helpers: 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, +// apps/server/src/conversation-plan/questions.test.ts. +export let text = + "For human sign-in, the options are GitHub OAuth or email magic links. Separately, for the hosted agent’s repository credentials, we could pass each user’s GitHub token or use a GitHub App installation token. Those are two different calls."; +export let message: Chat.Entry = { + id: "D19-m2", + text, + author: { kind: "member", handle: "Omar" }, + ts: 1_000, +}; +export let quotes = extractQuotes(text); + +export let fourCandidates = () => { + let text = [ + "Use GitHub OAuth", + "Use email magic links", + "Use the GitHub App if it supports SSO", + "Keep repository tokens separate", + ].join("; "); + let candidates = [ + "Use GitHub OAuth", + "Use email magic links", + "Use the GitHub App if it supports SSO", + "Keep repository tokens separate", + ].map(quote => { + let start = text.indexOf(quote); + return { quote, start, end: start + quote.length }; + }); + return { + current: { ...message, id: "four-source-spans", text }, + candidates, + }; +}; + +export let threadWithStatus = ( + status: ConversationPlan.Thread["status"], +): ConversationPlan.Thread => ({ + id: "auth-thread", + question: "How should people sign in?", + questionSources: [], + questionAuthoring: "quoted", + status, + contributions: [], + stances: [], + stanceHistory: [], + decision: status === "decided" + ? { + id: "decision", + text: "Use GitHub OAuth", + sources: [], + actor: { kind: "member", handle: "Mina" }, + at: 1_000, + } + : undefined, + decisionHistory: [], + candidates: [], + version: 1, +}); + +export let cacheText = "🧪 Use Redis for API caching; use Valkey for background jobs."; +export let cacheMessage: Chat.Entry = { + id: "cache-options", + text: cacheText, + author: { kind: "member", handle: "Ari" }, + ts: 2_001, +}; +export let cacheQuotes = extractQuotes(cacheText); +export let emptyCacheThread = { + ...threadWithStatus("exploring"), + id: "api-cache", + question: "How should we cache API responses?", +}; diff --git a/apps/server/src/conversation-plan/question-context.ts b/apps/server/src/conversation-plan/question-context.ts new file mode 100644 index 00000000..242780ee --- /dev/null +++ b/apps/server/src/conversation-plan/question-context.ts @@ -0,0 +1,160 @@ +import type { ConversationPlan } from "@chopin/protocol"; +import type { LinkedCardOptions } from "./question-shared"; +import { effectivePending } from "./preference"; + +export type VisibleThread = { + thread: ConversationPlan.Thread; + options: ConversationPlan.Contribution[]; +}; + +export function visibleThreads( + threads: readonly ConversationPlan.Thread[], + events: readonly ConversationPlan.Event[] = [], +): VisibleThread[] { + let ordered = [ + ...threads.filter((thread) => thread.status !== "discarded"), + ...threads.filter((thread) => thread.status === "discarded"), + ]; + if (ordered.length > 12) { + let touched = new Map(); + events.forEach((event, index) => { + touched.set(event.threadId, index); + if (event.type === "card.corrected" && event.change.kind === "move") { + touched.set(event.change.targetThreadId, index); + } + }); + let selected = new Set( + ordered.map((thread, index) => ({ thread, index })) + .sort((a, b) => + Number(a.thread.status === "discarded") - Number(b.thread.status === "discarded") + || (touched.get(b.thread.id) ?? -1) - (touched.get(a.thread.id) ?? -1) + || b.index - a.index + ).slice(0, 12).map(({ thread }) => thread.id), + ); + ordered = ordered.filter(thread => selected.has(thread.id)); + } + let available = ordered.map((thread) => + thread.contributions.filter((item) => item.kind === "option").slice(-8).reverse() + ); + let visible = ordered.map((thread) => ({ + thread, + options: [] as ConversationPlan.Contribution[], + })); + let count = 0; + for (let depth = 0; depth < 8 && count < 32; depth++) { + for (let index = 0; index < ordered.length && count < 32; index++) { + let option = available[index][depth]; + if (!option) continue; + visible[index].options.push(option); + count++; + } + } + return visible; +} + +export function cardChoices( + selected: readonly VisibleThread[], + linkedCards: LinkedCardOptions, +): Record { + let choices: Record = {}; + for (let { thread } of selected) { + let card = linkedCards.get(thread.id); + if (!card || card.cardId !== thread.questionnaireId) continue; + for (let option of card.options) { + if (["new", "none"].includes(option.id)) throw new Error("reserved card option ID"); + choices[option.id] = `${option.label.slice(0, 100)} (in ${thread.question.slice(0, 100)})`; + } + } + return choices; +} + +export function compactThreads( + selected: readonly VisibleThread[], + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +) { + return selected.map(({ thread, options }) => { + let pending = effectivePending(thread, events); + let pendingOption = pending + && thread.contributions.find((item) => item.id === pending.optionId); + let card = linkedCards.get(thread.id); + let visibleOptions = options.map((item) => ({ + id: item.id, + text: (item.displayLabel ?? item.text).slice(0, 100), + })); + if (card && card.cardId === thread.questionnaireId) { + for (let option of card.options) { + if (!visibleOptions.some(item => item.id === option.id)) { + visibleOptions.push({ id: option.id, text: option.label.slice(0, 100) }); + } + } + } + return { + id: thread.id, + question: thread.question.slice(0, 160), + status: thread.status, + options: visibleOptions, + contributions: thread.contributions.filter((item) => item.kind !== "option") + .sort((a, b) => Number(Boolean(b.targetEditedBy)) - Number(Boolean(a.targetEditedBy))) + .slice(0, 4) + .map((item) => ({ + id: item.id, + text: item.text.slice(0, 100), + targetId: item.targetId, + relation: item.relation, + })), + stances: thread.stances.slice(-6).map((item) => ({ + participant: item.participant, + optionId: item.optionId, + position: item.position, + })), + decision: thread.decision?.text.slice(0, 160), + pendingSettle: pending && { + optionId: pending.optionId, + option: pendingOption + && (pendingOption.displayLabel ?? pendingOption.text).slice(0, 100), + proposer: pending.proposer, + }, + }; + }); +} + +export function fitState< + T extends { + threads: ReturnType; + recent: Array<{ text: string }>; + }, +>(state: T): T { + let withinBudget = () => JSON.stringify(state).length <= 23_500; + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.contributions = thread.contributions.slice(0, 1); + thread.stances = thread.stances.slice(-2); + thread.decision = thread.decision?.slice(0, 80); + } + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.contributions = []; + thread.stances = []; + thread.decision = undefined; + } + if (withinBudget()) return state; + for (let thread of state.threads) { + thread.question = thread.question.slice(0, 80); + for (let option of thread.options) option.text = option.text.slice(0, 40); + } + while (!withinBudget() && state.recent.length) state.recent.shift(); + if (!withinBudget()) throw new Error("conversation context exceeds Jev state budget"); + return state; +} + +export function threadChoices(selected: readonly VisibleThread[]): Record { + let choices: Record = {}; + for (let { thread } of selected) { + if (["new", "none"].includes(thread.id)) throw new Error("reserved conversation thread ID"); + choices[thread.id] = `${thread.question.slice(0, 160)} (${thread.status})`; + } + choices.new = "A distinct planning question not represented by a current thread."; + choices.none = "No planning thread is clearly referred to; do not guess from proximity alone."; + return choices; +} diff --git a/apps/server/src/conversation-plan/question-research.ts b/apps/server/src/conversation-plan/question-research.ts new file mode 100644 index 00000000..d287b978 --- /dev/null +++ b/apps/server/src/conversation-plan/question-research.ts @@ -0,0 +1,123 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; +import { compactThreads, fitState, visibleThreads } from "./question-context"; +import { noul } from "./question-shared"; + +export function buildResearchOfferRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + events: readonly ConversationPlan.Event[] = [], +): JevRequest { + assertQuoteBudget(candidates); + let selected = visibleThreads(threads, events).filter(({ thread, options }) => + ["exploring", "leaning", "reopened"].includes(thread.status) + && [2, 3, 4].includes(thread.contributions.filter(item => item.kind === "option").length) + && options.length === thread.contributions.filter(item => item.kind === "option").length + ); + if (!candidates.length || !selected.length) throw new Error("no grounded research candidates"); + let quoteCriteria: Record = {}; + let ownershipQuestions: Record = {}; + for (let index = 0; index < candidates.length; index++) { + quoteCriteria[`q${index}`] = candidates[index]!.quote.slice(0, 200); + ownershipQuestions[`research_q${index}_owned`] = noul( + `Is candidates[${index}].quote the current speaker's own sincere, unconditional, still-current external cost concern? Judge this exact span in all of current.text. Exclude quoted or reported words, sarcasm, hypotheticals, and a concern withdrawn later.`, + "Direct speaker-owned concern.", + "Quoted, conditional, reported, withdrawn, or unclear concern.", + ); + ownershipQuestions[`research_q${index}_answered`] = noul( + `Has the current external cost concern in candidates[${index}].quote already been answered anywhere in current.text, recent, or threads, so fresh outside research is unnecessary? A local volume estimate alone is not a current provider price answer.`, + "The current costs have already been answered.", + "The current costs remain unknown.", + ); + ownershipQuestions[`research_q${index}_scope`] = { + type: "choice", + instructions: + `For candidates[${index}].quote alone, which existing option does its current external cost concern uniquely name? Choose all-current only for a generic provider-price unknown naming no option. Choose none for an unclear, foreign, or multi-option concern. Do not infer a pair.`, + criteria: { + "all-current": "Generic current provider-price unknown without a named option.", + ...Object.fromEntries(selected.flatMap(({ options }) => + options.map(option => [ + option.id, + (option.displayLabel ?? option.text).slice(0, 100), + ]) + )), + none: "No unique current option or grounded generic concern.", + }, + }; + } + quoteCriteria.none = "No exact speaker-owned quote states the current external cost concern."; + let threadCriteria: Record = {}; + let optionCriteria: Record = {}; + for (let { thread, options } of selected) { + if (thread.id === "none" || thread.id === "new") continue; + threadCriteria[thread.id] = thread.question.slice(0, 160); + for (let option of options) { + if (option.id === "none" || option.id === "new" || option.id in optionCriteria) continue; + optionCriteria[option.id] = `${(option.displayLabel ?? option.text).slice(0, 100)} (in ${ + thread.question.slice(0, 100) + })`; + } + } + if (!Object.keys(threadCriteria).length || Object.keys(optionCriteria).length < 2) { + throw new Error("no grounded research options"); + } + threadCriteria.none = "No single active thread clearly owns this comparison."; + optionCriteria.none = "No single existing option is clearly relevant."; + let optionQuestion = (side: string): JevQuestion => ({ + type: "choice", + instructions: + `Select ${side} of exactly two distinct existing options whose current external costs need comparison. Choose none if no clear option. Do not create an option or infer a new provider.`, + criteria: optionCriteria, + }); + return { + state: fitState({ + current: { id: message.id, author: message.author, text: message.text.slice(0, 4000) }, + candidates: [...candidates], + recent: recent.filter(entry => entry.id !== message.id).slice(-12).map(entry => ({ + id: entry.id, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events).map((item, index) => ({ + ...item, + options: item.options.map(option => ({ + ...option, + text: (selected[index]?.options.find(value => value.id === option.id)?.displayLabel + ?? option.text).slice(0, 100), + })), + })), + }), + questions: { + ...ownershipQuestions, + research_quote: { + type: "choice", + instructions: + "Which exact candidates quote is the speaker's direct, still-current external cost concern? Choose none for quotation, report, hypothetical, ambiguity, or no suitable span.", + criteria: quoteCriteria, + }, + research_thread: { + type: "choice", + instructions: + "Which one active planning thread owns the current external cost concern? Choose none if ambiguous or already settled.", + criteria: threadCriteria, + }, + research_option_a: optionQuestion("the first"), + research_option_b: optionQuestion("the second"), + research_kind: { + type: "choice", + instructions: + "Does current.text itself raise a need for fresh outside information about current costs in one active thread? Choose comparison only for two existing options; choose concern for a generic price unknown or one-option cost concern among three or four current options. Exclude local volume estimates, general tradeoffs, and already available facts. Do not rely on answers to other questions.", + criteria: { + "current-cost-comparison": + "Fresh outside pricing or cost information for both options is needed.", + "current-cost-concern": + "Fresh outside cost information for a three- or four-option context is needed, with at most one named focus.", + none: "No current external cost comparison is warranted.", + }, + }, + }, + }; +} diff --git a/apps/server/src/conversation-plan/question-shared.ts b/apps/server/src/conversation-plan/question-shared.ts new file mode 100644 index 00000000..29810b54 --- /dev/null +++ b/apps/server/src/conversation-plan/question-shared.ts @@ -0,0 +1,31 @@ +import type { JevQuestion } from "./jev"; + +export const QUESTION_SET_VERSION = "conversation-plan-8"; + +export const FOUR_CANDIDATE_QUESTIONS = new Set([ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "support", + "objection", + "duplicate", + "explicit_resolution", +]); + +/** Schedule a dedicated judgment only for wording that can directly retract a stance. */ +export function hasWithdrawalCue(quote: string): boolean { + return /\b(?:withdraw|retract|take\s+back|no\s+longer|chang(?:e|ed)\s+my\s+mind|not\s+going\s+with|scratch\s+that)\b/i + .test(quote); +} + +export type LinkedCardOptions = ReadonlyMap; +}>; + +export function noul(instructions: string, yes: string, no: string): JevQuestion { + return { type: "noul", instructions, criteria: { true: yes, false: no } }; +} diff --git a/apps/server/src/conversation-plan/question-targeting.ts b/apps/server/src/conversation-plan/question-targeting.ts new file mode 100644 index 00000000..73db2a04 --- /dev/null +++ b/apps/server/src/conversation-plan/question-targeting.ts @@ -0,0 +1,322 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import { isExplicitCompoundDecision, type QuoteCandidate } from "./quotes"; +import { assertQuoteBudget, MAX_QUOTE_CANDIDATES } from "./quote-budget"; +import { effectivePending } from "./preference"; +import { + cardChoices, + compactThreads, + fitState, + threadChoices, + visibleThreads, +} from "./question-context"; +import { FOUR_CANDIDATE_QUESTIONS, hasWithdrawalCue, noul } from "./question-shared"; +import type { LinkedCardOptions } from "./question-shared"; + +export function buildTargetingRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + focusIndex?: number, + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +): JevRequest { + assertQuoteBudget(candidates); + let focus = focusIndex === undefined ? undefined : candidates[focusIndex]; + let compoundDecision = !!focus && isExplicitCompoundDecision(message.text, candidates); + if ( + focusIndex !== undefined && (!focus || focusIndex < 0 || focusIndex >= MAX_QUOTE_CANDIDATES + || message.text.slice(focus.start, focus.end) !== focus.quote) + ) { + throw new Error("invalid targeting candidate"); + } + let questions: Record = {}; + let selected = visibleThreads(threads, events); + // The service supplies recent as the chat prefix before this message. Source-free + // contributions cannot be ordered safely when an older message is retried. + let earlierMessageIds = new Set( + recent.filter(entry => + entry.id !== message.id && entry.ts <= message.ts + && entry.author.kind !== "system" && !entry.streaming + ).map(entry => entry.id), + ); + let priorContributions = selected.flatMap(({ thread }) => + thread.contributions.filter(contribution => + contribution.text.trim() && contribution.sources.length > 0 + && contribution.sources.every(source => + source.messageId !== message.id && earlierMessageIds.has(source.messageId) + ) + ).map(contribution => ({ + id: contribution.id, + text: contribution.text.slice(0, 300), + })) + ).slice(-8); + let threadCriteria = threadChoices(selected); + let pending = selected.flatMap(({ thread }) => { + let proposal = effectivePending(thread, events); + return proposal ? [{ thread, proposal }] : []; + }); + for (let { thread, proposal } of pending) { + let option = thread.contributions.find(item => item.id === proposal.optionId); + if (!option) continue; + threadCriteria[thread.id] += ` Pending proposal to settle on "${ + option.text.slice(0, 100) + }" by ${proposal.proposer.slice(0, 80)}.`; + } + let solePending = pending.length === 1 ? pending[0] : undefined; + let soleOption = solePending?.thread.contributions.find(item => + item.id === solePending.proposal.optionId + ); + let optionCriteria: Record = {}; + for (let { thread, options } of selected) { + for (let option of options) { + if (["new", "none"].includes(option.id)) throw new Error("reserved conversation option ID"); + optionCriteria[option.id] = `${option.text.slice(0, 100)} (in ${ + thread.question.slice(0, 100) + })`; + } + } + Object.assign(optionCriteria, cardChoices(selected, linkedCards)); + optionCriteria.new = "A distinct option that does not yet exist."; + optionCriteria.none = "No specific option can be identified."; + for (let index = 0; index < candidates.length; index++) { + let path = `candidates[${index}].quote`; + let prefix = `c${index}_`; + questions[`${prefix}role`] = { + type: "choice", + instructions: + `What single planning role does ${path} play as this speaker's own assertion? Do not mistake a quotation, reported agreement, sarcasm, or assent for a proposal to settle.` + + (compoundDecision + ? " Use current.fullMessage only to assess the speaker's intent and ownership; classify this exact span." + : ""), + criteria: { + question: "New unresolved planning question.", + option: "Distinct proposed course of action.", + reason: "Reason or evidence for a plan.", + constraint: "Requirement or limitation.", + support: "Speaker's support for an existing option.", + objection: "Speaker's objection to an option or decision.", + resolution: "Speaker proposes settling on one definite option now.", + reopening: "Speaker explicitly requests reopening a decision.", + none: "No useful atomic planning contribution, or unclear/quoted/sarcastic content.", + }, + }; + questions[`${prefix}thread`] = { + type: "choice", + instructions: + `Which planning thread does ${path} address? Judge this exact quote; later clauses of current.text may address a different thread. A short assent or direct withdrawal may target a pending proposal to settle if the quote and preceding context clearly refer to it. Use new for a distinct question; none if unclear.`, + criteria: threadCriteria, + }; + questions[`${prefix}option`] = { + type: "choice", + instructions: + `Which existing option does ${path} discuss, support, oppose, or withdraw a proposal for? A direct withdrawal may target the specific pending option through preceding context. A resolution may address a rejected option; this question does not identify the chosen option. Use new for a distinct option or none if unclear.`, + criteria: optionCriteria, + }; + questions[`${prefix}chosen_option`] = { + type: "choice", + instructions: + `If ${path} proposes settling a team choice, which option does the speaker positively choose? Do not select an option the speaker rejects, negates, or merely mentions. Select new only for a clearly proposed plan absent from the listed options; select none for no clear chosen option.`, + criteria: { + ...Object.fromEntries( + Object.entries(optionCriteria).filter(([key]) => key !== "new" && key !== "none"), + ), + new: "A clearly adopted plan not represented by an existing option.", + none: "No explicit chosen option; only a rejected/quoted option or unclear resolution.", + }, + }; + questions[`${prefix}relation`] = { + type: "choice", + instructions: `How does ${path} relate to its target?`, + criteria: { + supports: "Supports it.", + challenges: "Challenges it.", + qualifies: "Adds a condition.", + replaces: "Explicitly supersedes it.", + unrelated: "No clear relation.", + }, + }; + questions[`${prefix}new_option`] = noul( + `Does ${path} itself propose a distinct plan option? Judge this exact quote only, not other clauses in current.text.`, + "A concrete distinct course of action in this quote.", + "No new option in this quote, or merely chatter/assent.", + ); + questions[`${prefix}planning_substance`] = noul( + `Does ${path} itself contain a substantive planning reason, requirement, or concern? Judge only this quote; vague deictic chatter without a clear plan point is false.`, + "A specific reason, requirement, or concern in this quote.", + "No specific planning substance in this quote.", + ); + questions[`${prefix}support`] = noul( + `Does the current speaker personally support an option in ${path}? A quote, reported stance, or sarcasm is false.`, + "This quote expresses the speaker's own support.", + "No personal support in this quote.", + ); + questions[`${prefix}objection`] = noul( + `Does the current speaker personally object to a plan in ${path}? A quote, report, or sarcasm is false.`, + "This quote expresses the speaker's own plan objection.", + "No personal plan objection in this quote.", + ); + if (priorContributions.length) { + questions[`${prefix}duplicate`] = noul( + `Compare only ${path} with state.priorContributions. Is this exact focused quote merely a duplicate of one of those earlier contributions, with no new substantive detail? Do not compare other spans of the current message or preceding context.`, + "Duplicate of a named prior contribution.", + "New detail or distinct contribution.", + ); + } + questions[`${prefix}explicit_resolution`] = noul( + `Does ${path} itself propose that the team settle on one definite option now, such as 'let's just go with X' or 'we decided X'? Judge only this speaker's own assertion. A question, reported or quoted decision, hedged preference, or sarcasm is false.`, + "Proposal to settle now.", + "No proposal to settle.", + ); + if (pending.length) { + let referent = solePending && soleOption + ? `the pending proposal by ${solePending.proposal.proposer.slice(0, 80)} to settle on "${ + soleOption.text.slice(0, 100) + }" in the "${solePending.thread.question.slice(0, 160)}" thread` + : "a clearly identified pending proposal in threads[].pendingSettle"; + questions[`${prefix}agrees_with_settle`] = noul( + `Does ${path} itself agree with ${referent}? Judge this exact quote and preceding context, not later clauses of current.text. Plain assent counts when it clearly follows that proposal; quoted or reported assent, sarcasm, and agreement with a different target do not. If several proposals are pending, require a clear referent.`, + "The speaker agrees with the pending proposal to settle.", + "No agreement with a pending proposal.", + ); + if (focusIndex !== undefined) { + questions[`${prefix}qualifies_pending_settle`] = noul( + `Does ${path} itself state a specific requirement or condition on a clearly identified pending proposal to settle? Use threads[].pendingSettle for the proposed option, including when this quote names a different option as a fallback. Require a clear link to the proposed option from this quote and preceding context. A condition on an unrelated option, vague concern, quoted or withdrawn condition, or unclear referent is false. If several proposals are pending, require a clear referent.`, + "The speaker adds a specific condition to the identified pending proposal.", + "No specific condition on an identified pending proposal.", + ); + if (hasWithdrawalCue(candidates[index]!.quote)) { + questions[`${prefix}withdraws_pending_settle`] = noul( + `Does ${path} directly and unconditionally take back this speaker's own currently pending proposal to settle on a specific option? Use threads[].pendingSettle and preceding context to identify both the proposer and option. This is distinct from correcting a plan detail or reopening an already decided choice. Quoted, reported, hypothetical, conditional, and other-speaker withdrawals are false; if the proposal or option is unclear, answer false.`, + "The proposer personally withdraws their identified pending option now.", + "No direct withdrawal of that speaker's identified pending option.", + ); + } + } + } + if (threads.some((thread) => thread.status === "discarded")) { + questions[`${prefix}raises_again`] = noul( + `Does ${path} deliberately bring back a question the team already discarded, saying it should be considered again?`, + "The speaker explicitly raises a discarded question again.", + "The quote merely resembles a discarded question.", + ); + } + questions[`${prefix}reopening`] = noul( + `Does ${path} itself directly request reopening a decision?`, + "Direct reopening request.", + "No direct reopening request.", + ); + if ( + !(focusIndex !== undefined && pending.length && hasWithdrawalCue(candidates[index]!.quote)) + ) { + questions[`${prefix}material_objection`] = noul( + `Does ${path} raise a substantive new concern that calls a current decision into question?`, + "Material concern against a current decision.", + "No material challenge to a current decision.", + ); + } + } + let fourCandidateQuestions: Set | undefined; + // Four isolated requests share one durable targeting pass capped at 45 answers. + if (candidates.length === MAX_QUOTE_CANDIDATES) { + fourCandidateQuestions = new Set(); + let include = (index: number, suffix: string) => { + let key = `c${index}_${suffix}`; + if (questions[key]) fourCandidateQuestions!.add(key); + }; + let discarded = selected.some(({ thread }) => thread.status === "discarded"); + let decided = selected.some(({ thread }) => thread.status === "decided"); + let active = selected.some(({ thread }) => + ["exploring", "leaning", "reopened"].includes(thread.status) + ); + if ((discarded || decided) && active) { + throw new Error("four-candidate mixed thread states exceed targeting budget"); + } + let core = discarded + ? [ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "duplicate", + "relation", + ] + : decided + ? [ + "role", + "thread", + "option", + "chosen_option", + "new_option", + "planning_substance", + "support", + "objection", + "duplicate", + ] + : [...FOUR_CANDIDATE_QUESTIONS, "relation"]; + for (let index = 0; index < candidates.length; index++) { + for (let suffix of core) include(index, suffix); + if (discarded) { + for (let suffix of ["raises_again", "reopening", "material_objection"]) { + include(index, suffix); + } + } else if (decided) { + include(index, "reopening"); + include(index, "material_objection"); + } + } + if (pending.length) { + for (let index = 0; index < candidates.length; index++) { + let quote = candidates[index]!.quote; + let extra = hasWithdrawalCue(quote) + ? "withdraws_pending_settle" + : /\b(?:if|unless|provided)\b/i.test(quote) + ? "qualifies_pending_settle" + : "agrees_with_settle"; + let key = `c${index}_${extra}`; + if (!questions[key]) continue; + if (fourCandidateQuestions.size === 45) { + fourCandidateQuestions.delete(`c${index}_explicit_resolution`); + } + fourCandidateQuestions.add(key); + } + } + if (fourCandidateQuestions.size > 45) throw new Error("too many targeting questions"); + } + return { + state: fitState({ + current: { + id: message.id, + author: message.author, + text: focus ? focus.quote : message.text.slice(0, 4000), + ...(compoundDecision + ? { fullMessage: message.text } + : {}), + }, + ...(focus ? { preceding: message.text.slice(0, focus.start).slice(-300) } : {}), + recent: recent.filter((entry) => entry.id !== message.id).slice(-12).map((entry) => ({ + id: entry.id, + author: entry.author, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events, linkedCards), + priorContributions, + candidates: candidates.slice(0, focusIndex === undefined ? candidates.length : focusIndex + 1) + .map((candidate) => ({ + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + })), + }), + questions: Object.fromEntries( + Object.entries(questions).filter(([key]) => { + if (focusIndex !== undefined && !key.startsWith(`c${focusIndex}_`)) return false; + if (fourCandidateQuestions && !fourCandidateQuestions.has(key)) return false; + return true; + }), + ), + }; +} diff --git a/apps/server/src/conversation-plan/question-triage.ts b/apps/server/src/conversation-plan/question-triage.ts new file mode 100644 index 00000000..a56c4874 --- /dev/null +++ b/apps/server/src/conversation-plan/question-triage.ts @@ -0,0 +1,210 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevQuestion, JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import { assertQuoteBudget } from "./quote-budget"; +import { compactThreads, fitState, threadChoices, visibleThreads } from "./question-context"; +import { noul } from "./question-shared"; + +export function triageQuestions(): Record { + return { + new_question: noul( + "Does current.text itself put forward a distinct unresolved planning decision or question for the group to answer? Require a concrete choice, tradeoff, or specific unknown that could be answered, even when phrased as a statement. A message merely announcing intent to figure out a topic or start planning is purpose, not a new question, even if no solution exists yet. Do not invent a decision from the broad topic.", + "A concrete open decision or answerable question is raised, including a declarative choice between approaches.", + "Only intent or a topic for planning is stated; no distinct question to resolve is posed.", + ), + enough_purpose: noul( + "Does current.text, with recent messages, name a concrete planning topic or problem and an intent to plan or choose an approach? A topic-level statement like 'we need to figure out what we are doing for auth' is enough for a broad title and one-sentence goal. Do not require a chosen solution, candidate options, or implementation details. Generic 'we need a plan' without a topic is false.", + "A topic and intent to plan or choose it are clear, even if details are unresolved.", + "There is no identifiable topic or no intent to plan it.", + ), + new_option: noul( + "Does current.text propose or personally name concrete alternatives for an open planning question? A first-person present explanation like 'By X I mean A or B' names two options even without 'should'. Exclude reported, quoted, negated, or withdrawn alternatives.", + "A distinct course of action is proposed or personally named as an alternative.", + "No distinct option; assent alone is not new.", + ), + reason: noul( + "Does current.text give a reason for or against a plan option?", + "A causal or evaluative reason is stated.", + "No reason is stated.", + ), + constraint: noul( + "Does current.text impose a requirement or limitation on a plan?", + "A requirement or limitation is expressed.", + "No plan constraint.", + ), + evidence: noul( + "Does current.text offer evidence relevant to a planning choice?", + "A relevant observation or fact is offered.", + "No evidence claim.", + ), + assumption: noul( + "Does current.text name an assumption behind a planning choice?", + "A relevant assumption is stated.", + "No assumption.", + ), + support: noul( + "Does the current speaker personally support an existing option in current.text?", + "The speaker expresses support, including qualified support or assent.", + "Support is quoted, sarcastic, or absent.", + ), + objection: noul( + "Does the current speaker personally object to a plan in current.text?", + "The speaker expresses a substantive objection.", + "No current objection.", + ), + correction: noul( + "Does current.text correct earlier planning information?", + "The speaker corrects a previously stated plan detail.", + "No correction.", + ), + withdrawal: noul( + "Does current.text directly retract the current speaker's own earlier proposal or preference for a planning option? Require an explicit withdrawal in the speaker's own words; a correction of a detail, quotation, reported speech, hypothetical condition, or another person's withdrawal is false.", + "The speaker explicitly takes back their own earlier proposal or preference.", + "No direct personal withdrawal is stated.", + ), + explicit_resolution: noul( + "Does current.text itself propose that the team settle on one definite option now, such as 'let's just go with X' or 'we decided X'? Judge only this speaker's own assertion. A question, reported or quoted decision, hedged preference, or sarcasm is false.", + "Proposal to settle now.", + "No proposal to settle.", + ), + reopening: noul( + "Does current.text explicitly request reopening an already decided planning choice?", + "A direct request to reopen a settled choice.", + "Concern without explicit reopening, or no reopening.", + ), + planner_request: noul( + "Does current.text directly ask the Planner to act?", + "A direct request to the Planner.", + "No direct Planner request.", + ), + research_need: noul( + "Does the current speaker raise an unresolved need for current external cost or price information about two to four existing planning options? This may be a generic current-provider price unknown or a cost concern focused on one option. Require a useful unknown needing fresh outside research. A quoted, conditional, already answered, or merely local calculation is false. Judge this independently of whether the message adds a planning contribution.", + "Fresh external cost information about existing options is needed.", + "No grounded current external cost information is needed.", + ), + act: { + type: "choice", + instructions: + "What is the current speaker's primary discourse act in current.text? Judge the speaker's own words, not quotations or reported consensus.", + criteria: { + question: + "Raises an unresolved planning question, including a declarative 'we need to choose how ...' or 'we need to decide how ...' without a question mark.", + proposal: + "Suggests a concrete answer, plan, or option; not merely that we need to choose how or that a choice is needed.", + evaluation: "Evaluates, supports, or objects to a plan.", + commitment: "Explicitly settles the team's choice now.", + correction: "Corrects an earlier plan detail.", + other: "Chatter, quotation, report, sarcasm, or another act.", + }, + }, + thread_target: { + type: "choice", + instructions: + "Which one current planning thread does current.text mainly address? If there are no current threads and current.text clearly raises a distinct unanswered planning question, choose new. Use none only when no question or clear target is raised. Do not guess a target from a topic-only purpose statement; multi-claim quotes are targeted separately later.", + criteria: { + new: "An unanswered planning question is raised and no current thread represents it.", + none: "No distinct planning question or clear current-thread target is raised.", + }, + }, + relation: { + type: "choice", + instructions: "How does current.text relate to the planning idea it mentions?", + criteria: { + supports: "Supports it.", + challenges: "Challenges it.", + qualifies: "Adds a condition or nuance.", + replaces: "Supersedes a prior statement.", + unrelated: "No clear relation.", + }, + }, + significance: { + type: "score", + instructions: "Rate the planning significance of current.text, excluding quoted claims.", + criteria: [ + "Chatter or logistical aside", + "Minor related remark", + "Useful planning point", + "Changes proposed work", + ], + }, + commitment: { + type: "score", + instructions: + "How much team commitment does the current speaker personally express in current.text? Do not credit quotations, reported commitments, consensus guesses, or sarcasm.", + criteria: [ + "No team commitment", + "Personal preference or proposal", + "Tentative team agreement", + "Explicit present team resolution", + ], + }, + ambiguity: { + type: "score", + instructions: "How ambiguous is the intended planning meaning of current.text?", + criteria: [ + "Clear", + "Mostly clear", + "Several plausible readings", + "Needs human interpretation", + ], + }, + novelty: { + type: "score", + instructions: + "How novel is the substantive planning point in current.text relative to recent and threads?", + criteria: [ + "Repeated or no point", + "Restates with small nuance", + "Substantive new detail", + "New planning direction", + ], + }, + }; +} + +/** A second judgment selects only existing IDs and one exact saved quote span. */ +export function buildTriageRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[] = [], + events: readonly ConversationPlan.Event[] = [], +): JevRequest { + assertQuoteBudget(candidates); + let questions = triageQuestions(); + for (let index = 0; index < candidates.length; index++) { + questions[`c${index}_owned_unretracted`] = noul( + `Read all of current.text in order. Is candidates[${index}].quote the current speaker's own sincere, still-current contribution? It may be a question, option, reason, concern, support, or resolution; do not require its planning target to be clear. A later independent topic does not cancel it. But a later sentence can reveal that an earlier phrase was another person's words, or can withdraw or contradict the speaker's earlier stance. In those cases the earlier candidate is false even though the speaker typed it. Reported words, quotation, and sarcasm are false. Judge this exact source span rather than the message's overall stance.`, + "The speaker owns this sincere contribution and did not later withdraw it.", + "Attribution, quotation, sarcasm, or an earlier contribution withdrawn later.", + ); + } + let selected = visibleThreads(threads, events); + questions.thread_target = { + type: "choice", + instructions: + "Which one current planning thread does current.text mainly address? If there are no current threads and current.text clearly raises a distinct unanswered planning question, choose new. Use none only when no question or clear target is raised. Do not guess a target from a topic-only purpose statement; multi-claim quotes are targeted separately later.", + criteria: { + ...threadChoices(selected), + new: "An unanswered planning question is raised and no current thread represents it.", + none: "No distinct planning question or clear current-thread target is raised.", + }, + }; + return { + state: fitState({ + current: { id: message.id, author: message.author, text: message.text.slice(0, 4000) }, + candidates: candidates.map(candidate => ({ + quote: candidate.quote, + start: candidate.start, + end: candidate.end, + })), + recent: recent.filter((entry) => entry.id !== message.id).slice(-12).map((entry) => ({ + id: entry.id, + author: entry.author, + text: entry.text.slice(0, 300), + })), + threads: compactThreads(selected, events), + }), + questions, + }; +} diff --git a/apps/server/src/conversation-plan/questions-duplicates.test.ts b/apps/server/src/conversation-plan/questions-duplicates.test.ts new file mode 100644 index 00000000..abe05e6f --- /dev/null +++ b/apps/server/src/conversation-plan/questions-duplicates.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test"; +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { + cacheMessage, + cacheQuotes, + cacheText, + emptyCacheThread, +} from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("a sibling span and preceding text are not prior duplicate evidence", () => { + expect(cacheQuotes).toEqual([ + { quote: "🧪 Use Redis for API caching;", start: 0, end: 29 }, + { quote: "use Valkey for background jobs.", start: 30, end: 61 }, + ]); + let request = buildCandidateTargetingRequest( + cacheMessage, + [cacheMessage], + [emptyCacheThread], + cacheQuotes, + 1, + ); + let state = request.state as { + current: { text: string }; + preceding: string; + candidates: typeof cacheQuotes; + priorContributions: unknown[]; + }; + expect(request.questions.c1_duplicate).toBeUndefined(); + expect(state.priorContributions).toEqual([]); + expect(state.current.text).toBe(cacheQuotes[1]!.quote); + expect(state.preceding).toBe(cacheText.slice(0, cacheQuotes[1]!.start)); + expect(state.candidates).toEqual(cacheQuotes); + expect(request.questions.c1_role?.type).toBe("choice"); + expect(request.questions.c1_role?.instructions).toContain("reported agreement"); + expect(request.questions.c1_thread?.type).toBe("choice"); + expect(request.questions.c1_explicit_resolution?.type).toBe("noul"); + expect( + buildTriageRequest(cacheMessage, [], [emptyCacheThread], cacheQuotes).questions + .c1_owned_unretracted?.type, + ).toBe("noul"); +}); + +test("an accepted earlier message supplies a named prior-only duplicate comparator", () => { + let earlier: Chat.Entry = { + id: "cache-earlier-proposal", + text: cacheQuotes[0]!.quote, + author: { kind: "member", handle: "Mina" }, + ts: 2_000, + }; + let accepted = { + ...emptyCacheThread, + contributions: [{ + id: "cache-earlier-contribution", + kind: "option" as const, + text: earlier.text, + authoring: "quoted" as const, + sources: [{ + messageId: earlier.id, + author: { kind: "member" as const, handle: "Mina" }, + quote: earlier.text, + start: 0, + end: earlier.text.length, + role: "option" as const, + }], + actor: { kind: "classifier" as const }, + }], + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [earlier, cacheMessage], + [accepted], + cacheQuotes, + 0, + ); + let prior = (request.state as { + priorContributions: Array<{ id: string; text: string }>; + }).priorContributions; + expect(request.questions.c0_duplicate?.type).toBe("noul"); + expect(prior).toEqual([expect.objectContaining({ + id: "cache-earlier-contribution", + text: earlier.text, + })]); + expect(prior.every(item => item.text.length <= 300)).toBe(true); + expect(prior.some(item => item.id === cacheMessage.id)).toBe(false); +}); + +test("retrying an older message excludes later accepted contributions", () => { + let later: Chat.Entry = { + ...cacheMessage, + id: "later-cache-proposal", + text: cacheQuotes[0]!.quote, + ts: cacheMessage.ts + 1, + }; + let thread = { + ...emptyCacheThread, + contributions: [{ + id: "later-cache-contribution", + kind: "option" as const, + text: later.text, + authoring: "quoted" as const, + sources: [{ + messageId: later.id, + author: { kind: "member" as const, handle: "Ari" }, + quote: later.text, + start: 0, + end: later.text.length, + role: "option" as const, + }], + actor: { kind: "classifier" as const }, + }], + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [later], + [thread], + cacheQuotes, + 0, + ); + expect((request.state as { priorContributions: unknown[] }).priorContributions).toEqual([]); + expect(request.questions.c0_duplicate).toBeUndefined(); +}); + +test("reported earlier context without an accepted contribution is not duplicate evidence", () => { + let reported: Chat.Entry = { + ...cacheMessage, + id: "reported-cache-proposal", + text: `Mina said “${cacheQuotes[0]!.quote}” yesterday, but I disagree.`, + ts: cacheMessage.ts - 1, + }; + let request = buildCandidateTargetingRequest( + cacheMessage, + [reported], + [emptyCacheThread], + cacheQuotes, + 0, + ); + expect((request.state as { priorContributions: unknown[] }).priorContributions).toEqual([]); + expect(request.questions.c0_duplicate).toBeUndefined(); +}); + +test("a source-free persisted contribution cannot prove it predates a retry", () => { + let thread: ConversationPlan.Thread = { + ...emptyCacheThread, + contributions: [{ + id: "cache-redis-option", + kind: "option", + text: cacheQuotes[0]!.quote, + authoring: "quoted", + sources: [], + actor: { kind: "classifier" }, + }], + }; + let request = buildCandidateTargetingRequest(cacheMessage, [], [thread], cacheQuotes, 0); + let prior = (request.state as { + priorContributions: Array<{ id: string; text: string }>; + }).priorContributions; + expect(request.questions.c0_duplicate).toBeUndefined(); + expect(prior).toEqual([]); +}); diff --git a/apps/server/src/conversation-plan/questions-targeting.test.ts b/apps/server/src/conversation-plan/questions-targeting.test.ts new file mode 100644 index 00000000..5d3daec7 --- /dev/null +++ b/apps/server/src/conversation-plan/questions-targeting.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test"; +import { buildCandidateTargetingRequest } from "./questions"; +import { fourCandidates, threadWithStatus } from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("four-span targeting keeps prompts for reopening a discarded thread", () => { + let { current, candidates } = fourCandidates(); + let request = buildCandidateTargetingRequest( + current, + [], + [threadWithStatus("discarded")], + candidates, + 3, + ); + + expect(request.questions.c3_raises_again?.type).toBe("noul"); + expect(request.questions.c3_reopening?.type).toBe("noul"); + expect(request.questions.c3_material_objection?.type).toBe("noul"); +}); + +test("four-span targeting keeps prompts for reopening a decided thread", () => { + let { current, candidates } = fourCandidates(); + let request = buildCandidateTargetingRequest( + current, + [], + [threadWithStatus("decided")], + candidates, + 3, + ); + + expect(request.questions.c3_reopening?.type).toBe("noul"); + expect(request.questions.c3_material_objection?.type).toBe("noul"); +}); + +test("four-span pending-settle targeting includes relation and its qualifier within 45 answers", () => { + let { current, candidates } = fourCandidates(); + let pendingThread = { + ...threadWithStatus("exploring"), + contributions: [{ + id: "oauth", + kind: "option" as const, + text: "GitHub OAuth", + authoring: "quoted" as const, + sources: [], + actor: { kind: "classifier" as const }, + }], + pendingSettle: { optionId: "oauth", proposer: "Mina", messageId: "proposal" }, + }; + let requests = candidates.map((_, index) => + buildCandidateTargetingRequest(current, [], [pendingThread], candidates, index) + ); + let persistedAnswerCount = requests.reduce( + (count, request) => count + Object.keys(request.questions).length, + 0, + ); + + expect(requests[2]!.questions.c2_relation?.type).toBe("choice"); + expect(requests[2]!.questions.c2_qualifies_pending_settle?.type).toBe("noul"); + expect( + requests.flatMap(request => Object.keys(request.questions)).filter(key => + key.endsWith("_duplicate") + ), + ).toEqual([]); + expect(persistedAnswerCount).toBe(44); +}); diff --git a/apps/server/src/conversation-plan/questions.test.ts b/apps/server/src/conversation-plan/questions.test.ts new file mode 100644 index 00000000..720dd819 --- /dev/null +++ b/apps/server/src/conversation-plan/questions.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { extractQuotes } from "./quotes"; +import { buildCandidateTargetingRequest, buildTriageRequest } from "./questions"; +import { message, quotes, text } from "./question-builder.test-fixtures"; + +// Original callbacks: archive 446a9779a937fa5be7cd3eb52fd7f3023d691ed2, questions.test.ts. +// Final parameterized interpreter callback (two cases) awaits the interpreter/policy slice. + +test("act criteria separate an unresolved choice invitation from a proposed answer", () => { + let invitation = { + ...message, + id: "D19-m1", + text: + "Before the repository pilot, we need to choose how people sign in and how the hosted agent gets repository credentials.", + }; + let request = buildTriageRequest(invitation, [], [], extractQuotes(invitation.text)); + let act = request.questions.act; + if (act?.type !== "choice") throw new Error("missing act choice"); + expect((request.state as { candidates: unknown[] }).candidates).toHaveLength(2); + expect(act.criteria.question).toMatch(/need to (?:choose|decide) how/i); + expect(act.criteria.question).toMatch(/declarative|without a question mark/i); + expect(act.criteria.proposal).toMatch(/(?:concrete|specific).*(?:answer|plan|option)/i); + expect(act.criteria.proposal).toMatch( + /(?:not|without).*(?:need to choose|choice is needed|unresolved choice)/i, + ); +}); + +test("D19 m2 extracts four exact option quotes in order", () => { + expect(quotes.map(({ quote, start, end }) => ({ quote, start, end }))).toEqual([ + { quote: "GitHub OAuth", start: 35, end: 47 }, + { quote: "email magic links", start: 51, end: 68 }, + { quote: "each user’s GitHub token", start: 143, end: 167 }, + { quote: "GitHub App installation token", start: 177, end: 206 }, + ]); +}); + +test("triage includes all four D19 m2 options in its bounded candidate context", () => { + let triage = buildTriageRequest(message, [], [], quotes); + expect((triage.state as { candidates: unknown[] }).candidates).toHaveLength(4); + expect(triage.questions.c3_owned_unretracted?.type).toBe("noul"); +}); + +test("targeting accepts the fourth D19 m2 option candidate", () => { + let fourth = buildCandidateTargetingRequest(message, [], [], quotes, 3); + expect(fourth.questions.c3_role?.type).toBe("choice"); + expect((fourth.state as { current: { text: string } }).current.text).toBe(quotes[3]!.quote); + expect((fourth.state as { candidates: unknown[] }).candidates).toHaveLength(4); +}); + +test("candidate builders reject input beyond the four-quote budget instead of truncating", () => { + let remainderStart = quotes[3]!.end; + let overBudget = [ + ...quotes, + { + quote: text.slice(remainderStart), + start: remainderStart, + end: text.length, + }, + ]; + + expect(() => buildTriageRequest(message, [], [], overBudget)).toThrow(); + expect(() => buildCandidateTargetingRequest(message, [], [], overBudget, 4)).toThrow(); +}); diff --git a/apps/server/src/conversation-plan/questions.ts b/apps/server/src/conversation-plan/questions.ts new file mode 100644 index 00000000..bc37569d --- /dev/null +++ b/apps/server/src/conversation-plan/questions.ts @@ -0,0 +1,24 @@ +import type { Chat, ConversationPlan } from "@chopin/protocol"; +import type { JevRequest } from "./jev"; +import type { QuoteCandidate } from "./quotes"; +import type { LinkedCardOptions } from "./question-shared"; +import { buildTargetingRequest } from "./question-targeting"; + +export { buildResearchOfferRequest } from "./question-research"; +export { hasWithdrawalCue, QUESTION_SET_VERSION } from "./question-shared"; +export type { LinkedCardOptions } from "./question-shared"; +export { buildTargetingRequest } from "./question-targeting"; +export { buildTriageRequest, triageQuestions } from "./question-triage"; + +/** Keep one candidate's target questions free of later clauses in the same message. */ +export function buildCandidateTargetingRequest( + message: Chat.Entry, + recent: readonly Chat.Entry[], + threads: readonly ConversationPlan.Thread[], + candidates: readonly QuoteCandidate[], + index: number, + events: readonly ConversationPlan.Event[] = [], + linkedCards: LinkedCardOptions = new Map(), +): JevRequest { + return buildTargetingRequest(message, recent, threads, candidates, index, events, linkedCards); +}