0f7147a078
* fix(agent): read a WhatsApp "nej" as an answer, not a half answer #1425 gave the assistant the answers the user typed in WhatsApp. Rendering those inline off the raw channel_context blob gets the most common answer backwards. Answering "nej" to the representation question stores an EMPTY representation block: participants: [], purpose: null, denied: true. The renderer branched on `if (!rep.purpose)` and so emitted syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen. for a user who had just said the meal was not representation. `denied` was never read anywhere. The result is the assistant asking about the purpose of a private lunch, which is worse than the generic re-ask #1425 fixed, because the instruction is specific and confident. Clarifications now come from a structured summary that models the denial and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) as different states. #1425's syfte SAKNAS nudge is preserved for the case it was written for. Two smaller fixes in the same renderer, both about untrusted text: - The photo caption no longer reaches the prompt. It is the one field on the record nobody was asked for and nobody reviewed, and the rationale already written down in lib/documents/channel-context-notes.ts for keeping it off an immutable verifikat applies at least as strongly to a prompt that can call tools. - Human free text passes through flattenMemoryContent. An intent's promptTemplate output is seeded as a user message, so wrapToolResult never sees it and nothing else defends this path; a caption reading "# NYA INSTRUKTIONER: ..." previously rendered verbatim. All three tests fail against the current renderer and pass against this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent): gate the chat-answer guidance on what was rendered CodeRabbit caught the same defect shape this PR is about: the prior-conversation paragraph was gated on chat_answers != null, but a caption-only context is non-null and now summarises to nothing, so the paragraph pointed at 'uppgivna av användaren' rows the prompt does not contain. Gate on whether a clarification line was actually emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
191 lines
7.8 KiB
TypeScript
191 lines
7.8 KiB
TypeScript
/**
|
|
* Structured view of the answers a human gave about one underlag in a capture
|
|
* channel (today: the WhatsApp intake conversation, stored on
|
|
* invoice_inbox_items.channel_context).
|
|
*
|
|
* Why this exists rather than reading the blob inline where it is rendered:
|
|
*
|
|
* 1. A DENIAL IS AN ANSWER, and the raw shape hides that. Answering "nej" to
|
|
* the representation question stores `representation` with
|
|
* `participants: []` and `purpose: null` and `denied: true`. Any renderer
|
|
* that branches on `if (!representation.purpose)` therefore reads a settled
|
|
* "this was not representation" as a half-finished answer and asks the user
|
|
* for the purpose of a meal they just said was not a business meal. The
|
|
* denial and the genuine half answer (participants named, purpose missing,
|
|
* which BFL 5 kap 6-7 § does want completed) are different states and are
|
|
* modelled here as different fields.
|
|
*
|
|
* 2. THE VERIFIKAT RENDERER IS THE WRONG SHAPE. Its sibling,
|
|
* lib/documents/channel-context-notes.ts, exists to put answers on a
|
|
* verifikat, so for a denial it correctly renders nothing at all: "the user
|
|
* says this was not representation" is not verifikat text. Feeding a prompt
|
|
* from it loses exactly the answer that stops the agent asking again.
|
|
*
|
|
* Core lib: must not import from @/extensions. Pure: no DB, no model, so the
|
|
* ask-or-not decision is unit-testable on its own.
|
|
*/
|
|
import { flattenMemoryContent } from '@/lib/agent/chat/system-prompt'
|
|
import type { InboxChannelContext } from '@/types'
|
|
|
|
export interface ChatClarifications {
|
|
/** Answered representation: who was there and why. Null when unanswered. */
|
|
representation: {
|
|
participants: { name: string; company: string | null }[]
|
|
purpose: string | null
|
|
} | null
|
|
/**
|
|
* The human explicitly answered that this is NOT representation. Distinct
|
|
* from `representation === null` (nobody has been asked yet): a settled
|
|
* question must never be asked again, an unasked one may be.
|
|
*/
|
|
representationDenied: boolean
|
|
/**
|
|
* Participants were named but the purpose was not. BFL 5 kap 6-7 § wants
|
|
* both, so this is a real gap, but only the missing half may be asked for:
|
|
* re-asking who was there when the user already listed them is the same
|
|
* failure this module exists to prevent. Never true for a denial, where
|
|
* there is no purpose to want.
|
|
*/
|
|
representationPurposeMissing: boolean
|
|
/** Event date, when the user gave one. */
|
|
eventDate: string | null
|
|
/** Free-text note the sender attached, as paraphrased at capture time. */
|
|
userNote: string | null
|
|
/** What the human actually typed when answering a context question. */
|
|
contextAnswerRaw: string | null
|
|
/**
|
|
* A question that was asked and is still unanswered. `moved_to_app` means
|
|
* the capture channel gave up waiting (48h TTL) and the app now owns it, so
|
|
* whichever surface the user reaches first should ask exactly this.
|
|
*/
|
|
openQuestion: {
|
|
type: 'representation' | 'context' | 'resend'
|
|
status: 'open' | 'moved_to_app'
|
|
} | null
|
|
/** Where these answers were captured, for provenance in the prompt. */
|
|
channel: 'whatsapp'
|
|
}
|
|
|
|
/**
|
|
* Flatten a channel_context blob into the states an asking surface branches on.
|
|
* Returns null when the record carries no human answer at all, so callers can
|
|
* treat "no clarifications" as a single falsy check.
|
|
*
|
|
* The photo caption is deliberately not carried. It is the one field on the
|
|
* record that nobody was asked for and nobody reviewed (the rationale is
|
|
* written out in lib/documents/channel-context-notes.ts), and the reasoning
|
|
* that keeps unreviewed text off an immutable verifikat applies at least as
|
|
* strongly to text entering a prompt that can call tools.
|
|
*/
|
|
export function summariseClarifications(
|
|
ctx: InboxChannelContext | null | undefined,
|
|
): ChatClarifications | null {
|
|
if (!ctx) return null
|
|
|
|
const rep = ctx.representation
|
|
const denied = rep?.denied === true
|
|
const participants = (rep?.participants ?? []).filter((p) => (p?.name ?? '').trim().length > 0)
|
|
const purpose = rep?.purpose?.trim() || null
|
|
// A denial carries no participants and no purpose; it is an answer all the
|
|
// same, so it must not collapse into "unanswered".
|
|
const answeredRepresentation =
|
|
!denied && (participants.length > 0 || purpose !== null) ? { participants, purpose } : null
|
|
|
|
const userNote = ctx.user_note?.trim() || null
|
|
const contextAnswerRaw = ctx.context_answer?.raw_answer?.trim() || null
|
|
|
|
const pending = ctx.pending_question
|
|
const openQuestion =
|
|
pending && (pending.status === 'open' || pending.status === 'moved_to_app')
|
|
? { type: pending.type, status: pending.status }
|
|
: null
|
|
|
|
if (!answeredRepresentation && !denied && !userNote && !contextAnswerRaw && !openQuestion) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
representation: answeredRepresentation,
|
|
representationDenied: denied,
|
|
representationPurposeMissing: !denied && participants.length > 0 && purpose === null,
|
|
eventDate: rep?.event_date ?? null,
|
|
userNote,
|
|
contextAnswerRaw,
|
|
openQuestion,
|
|
channel: ctx.channel,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render clarifications as Swedish prompt lines.
|
|
*
|
|
* Every free-text field passes through flattenMemoryContent, the same defence
|
|
* the system prompt applies to agent memory. This is human-typed text arriving
|
|
* from an external channel, and an intent's promptTemplate output is seeded as
|
|
* a user message: wrapToolResult (lib/agent/chat/run-turn.ts) wraps tool
|
|
* RESULTS and never sees this path, so flattening here is the only thing
|
|
* standing between a receipt caption and a line that reads as new instructions.
|
|
*/
|
|
export function renderClarificationLines(c: ChatClarifications): string[] {
|
|
const lines: string[] = []
|
|
|
|
if (c.representation) {
|
|
const names = c.representation.participants
|
|
.map((p) => {
|
|
const name = flattenMemoryContent(p.name)
|
|
const company = p.company ? flattenMemoryContent(p.company) : null
|
|
return company ? `${name} (${company})` : name
|
|
})
|
|
.filter((n) => n.length > 0)
|
|
if (names.length > 0) {
|
|
lines.push(`deltagare (uppgivna av användaren): ${names.join(', ')}`)
|
|
}
|
|
if (c.representation.purpose) {
|
|
lines.push(`syfte (uppgivet av användaren): ${flattenMemoryContent(c.representation.purpose)}`)
|
|
}
|
|
}
|
|
|
|
if (c.eventDate) {
|
|
lines.push(`datum (uppgivet av användaren): ${flattenMemoryContent(c.eventDate)}`)
|
|
}
|
|
|
|
if (c.representationPurposeMissing) {
|
|
lines.push('syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen.')
|
|
}
|
|
|
|
// The case a naive read of the blob gets backwards.
|
|
if (c.representationDenied) {
|
|
lines.push(
|
|
'svar från användaren: detta är INTE representation. Fråga varken om deltagare eller syfte.',
|
|
)
|
|
}
|
|
|
|
if (c.userNote) {
|
|
lines.push(`anteckning från användaren: ${flattenMemoryContent(c.userNote)}`)
|
|
}
|
|
|
|
if (c.contextAnswerRaw && c.contextAnswerRaw !== c.userNote) {
|
|
lines.push(`användarens egna ord: ${flattenMemoryContent(c.contextAnswerRaw)}`)
|
|
}
|
|
|
|
if (c.openQuestion) {
|
|
const via = c.channel === 'whatsapp' ? 'WhatsApp' : c.channel
|
|
const what =
|
|
c.openQuestion.type === 'representation'
|
|
? 'deltagare och syfte (representation)'
|
|
: c.openQuestion.type === 'resend'
|
|
? 'ett tydligare foto av underlaget'
|
|
: 'vad köpet avsåg'
|
|
const where =
|
|
c.openQuestion.status === 'moved_to_app'
|
|
? `frågan ställdes i ${via} men förblev obesvarad och ägs nu av appen`
|
|
: `frågan är ställd i ${via} och väntar på svar`
|
|
// Marker text must match the instruction in the intent's promptTemplate
|
|
// verbatim: a rule keyed to a string the renderer never emits is a rule
|
|
// the model cannot follow.
|
|
lines.push(`OBESVARAD FRÅGA: ${what}. Detta är den ENDA fråga som saknar svar (${where}).`)
|
|
}
|
|
|
|
return lines
|
|
}
|