fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.
1. The assistant re-asked for information the user had already given.
The user answered the representation question in WhatsApp ("Elias
Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
was stored correctly on invoice_inbox_items.channel_context, and then
the in-app assistant said it could see no participant names and asked
for them again. The intent's inbox query selected only document_id and
extracted_data, and nothing under lib/agent/ read channel_context at
all. It is now selected, threaded onto each underlag as chat_answers,
and rendered into the prompt as "uppgivna av användaren" with an
explicit instruction that human answers outrank anything read off the
image and must never be re-asked. Also backfilled by document_id: a
receipt can reach the intent through the document paths without its
inbox row being matched to the transaction.
2. The representation question accepted half an answer in silence.
Naming participants but no purpose stored purpose=null and replied
"Tack!", leaving the deduction undocumented while looking complete.
Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
missing half only, and keeps the question open so the reply routes
back to the same receipt. Anti-loop: the follow-up fires only when no
representation block exists yet, so a second incomplete answer is
taken as-is rather than nagging.
Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
c84f951a5c
commit
576ed4d290
@@ -217,6 +217,60 @@ describe('answer flow (text rows through processInboundMessage)', () => {
|
||||
expect(confirm.inboxItemId).toBe('item-9')
|
||||
})
|
||||
|
||||
// Skatteverket needs participants AND purpose. An answer with only the
|
||||
// names used to be accepted silently, leaving the deduction undocumented
|
||||
// (found in the first live receipt, 2026-08-05).
|
||||
it('participants without a purpose: asks once for the purpose and keeps the question open', async () => {
|
||||
interpretMock.mockResolvedValue({
|
||||
ok: true,
|
||||
data: {
|
||||
is_denial: false,
|
||||
participants: [{ name: 'Elias Karlsson', company: 'Canguro Media' }],
|
||||
purpose: null,
|
||||
event_date: null,
|
||||
note: null,
|
||||
},
|
||||
})
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: makeTextRow('Elias Karlsson från Canguro Media') })
|
||||
enqueue({ data: { id: 'msg-t1' } })
|
||||
enqueue({ data: makeLink() })
|
||||
enqueue({ data: awaitingConversation('representation') })
|
||||
enqueue({ data: openItemContext('representation') })
|
||||
enqueue({ data: null }) // item update
|
||||
enqueue({ data: null }) // conversation update
|
||||
enqueue({ data: { company_id: 'company-1', correlation_id: null } })
|
||||
enqueue({ data: awaitingConversation('representation', { state: 'idle', context: {} }) })
|
||||
enqueue({ data: null }) // markStatus
|
||||
|
||||
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-t1')
|
||||
|
||||
const itemPatch = findCalls('invoice_inbox_items', 'update')[0][0] as {
|
||||
channel_context: {
|
||||
representation: { participants: unknown[]; purpose: string | null }
|
||||
pending_question?: { status?: string }
|
||||
}
|
||||
}
|
||||
// Names are kept, and the question stays OPEN for the missing half.
|
||||
expect(itemPatch.channel_context.representation.participants).toHaveLength(1)
|
||||
expect(itemPatch.channel_context.representation.purpose).toBeNull()
|
||||
expect(itemPatch.channel_context.pending_question?.status).toBe('open')
|
||||
|
||||
// The follow-up asks only for the purpose, never for the names again.
|
||||
const reply = sendTextMock.mock.calls[0][1]
|
||||
expect(reply.template).toBe(TEMPLATE.m8RepNeedPurpose)
|
||||
expect(reply.body).toContain('Elias Karlsson (Canguro Media)')
|
||||
expect(reply.body.toLowerCase()).toContain('syftet')
|
||||
|
||||
// Conversation must NOT go idle, or the next reply draws the fallback.
|
||||
const conversationPatch = findCalls('whatsapp_conversations', 'update')[0][0] as {
|
||||
state: string
|
||||
context: { pending_question?: unknown }
|
||||
}
|
||||
expect(conversationPatch.state).toBe('awaiting_representation')
|
||||
expect(conversationPatch.context.pending_question).toBeDefined()
|
||||
})
|
||||
|
||||
it('garbage interpretation degrades to raw-note storage + M8 partial (no retry, no error)', async () => {
|
||||
interpretMock.mockResolvedValue({ ok: false })
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
|
||||
@@ -40,6 +40,7 @@ export const TEMPLATE = {
|
||||
m6BytPin: 'm6_byt_pin',
|
||||
m7Representation: 'm7_representation',
|
||||
m8RepConfirmed: 'm8_rep_confirmed',
|
||||
m8RepNeedPurpose: 'm8_rep_need_purpose',
|
||||
m8RepPartial: 'm8_rep_partial',
|
||||
m8RepDenied: 'm8_rep_denied',
|
||||
m9Resend: 'm9_resend',
|
||||
@@ -133,6 +134,11 @@ const SV = {
|
||||
m8RepConfirmed: ({ participants, purpose }: { participants: string; purpose?: string | null }) =>
|
||||
`Tack! Noterat: ${participants}${purpose ? ` · Syfte: ${purpose}` : ''}. Följer med när kvittot bokförs.`,
|
||||
|
||||
// Participants captured, purpose missing. Skatteverket wants both, so ask
|
||||
// once for the missing half only, never for the names again.
|
||||
m8RepNeedPurpose: ({ participants }: { participants: string }) =>
|
||||
`Tack! Noterat: ${participants}. En sak till: vad var syftet med mötet? Skriv en kort rad, t.ex. _uppföljning av avtal_.`,
|
||||
|
||||
m8RepPartial: () =>
|
||||
'Tack! Jag har sparat ditt svar som anteckning på kvittot. Kolla att deltagare och syfte kom med när du bokför i appen.',
|
||||
|
||||
@@ -261,6 +267,9 @@ const EN: typeof SV = {
|
||||
m8RepConfirmed: ({ participants, purpose }: { participants: string; purpose?: string | null }) =>
|
||||
`Thanks! Noted: ${participants}${purpose ? ` · Purpose: ${purpose}` : ''}. It follows the receipt when it is booked.`,
|
||||
|
||||
m8RepNeedPurpose: ({ participants }: { participants: string }) =>
|
||||
`Thanks! Noted: ${participants}. One more thing: what was the purpose of the meeting? A short line is enough, e.g. _contract follow-up_.`,
|
||||
|
||||
m8RepPartial: () =>
|
||||
'Thanks! I saved your reply as a note on the receipt. Check that attendees and purpose came through when you book it in the app.',
|
||||
|
||||
|
||||
@@ -1044,6 +1044,9 @@ async function processAnswerMessage(
|
||||
const nowIso = new Date().toISOString()
|
||||
let confirmBody: string
|
||||
let confirmTemplate: TemplateId
|
||||
/** Set when the answer named participants but no purpose: the question
|
||||
* stays open for exactly one targeted follow-up. */
|
||||
let keepOpenForPurpose = false
|
||||
|
||||
if (target.type === 'representation' && text.toLowerCase() === 'nej') {
|
||||
// Exact 'nej' short-circuits WITHOUT an LLM call.
|
||||
@@ -1096,25 +1099,48 @@ async function processAnswerMessage(
|
||||
((interpretation.data.participants?.length ?? 0) > 0 || interpretation.data.purpose)
|
||||
) {
|
||||
const data = interpretation.data
|
||||
await updateItemContext(supabase, target.inboxItemId, (itemContext) => ({
|
||||
...itemContext,
|
||||
representation: {
|
||||
participants: data.participants ?? [],
|
||||
purpose: data.purpose,
|
||||
event_date: data.event_date,
|
||||
raw_answer: text,
|
||||
answered_at: nowIso,
|
||||
},
|
||||
...(data.note ? { user_note: data.note } : {}),
|
||||
pending_question: itemContext.pending_question
|
||||
? { ...itemContext.pending_question, status: 'answered' }
|
||||
: undefined,
|
||||
}))
|
||||
confirmBody = copy.m8RepConfirmed({
|
||||
participants: renderParticipants(data.participants ?? []),
|
||||
purpose: data.purpose,
|
||||
await updateItemContext(supabase, target.inboxItemId, (itemContext) => {
|
||||
// Skatteverket wants participants AND purpose; an answer with
|
||||
// only the names used to be accepted silently, leaving the
|
||||
// deduction undocumented. Ask once more for the missing half,
|
||||
// and only once: if a previous answer already wrote a
|
||||
// representation block, take whatever we have and stop.
|
||||
const isFirstAnswer = itemContext.representation == null
|
||||
keepOpenForPurpose =
|
||||
isFirstAnswer && (data.participants?.length ?? 0) > 0 && !data.purpose
|
||||
return {
|
||||
...itemContext,
|
||||
representation: {
|
||||
participants: data.participants ?? [],
|
||||
// A follow-up must not erase a purpose captured earlier.
|
||||
purpose: data.purpose ?? itemContext.representation?.purpose ?? null,
|
||||
event_date: data.event_date ?? itemContext.representation?.event_date ?? null,
|
||||
raw_answer: itemContext.representation
|
||||
? `${itemContext.representation.raw_answer}\n${text}`
|
||||
: text,
|
||||
answered_at: nowIso,
|
||||
},
|
||||
...(data.note ? { user_note: data.note } : {}),
|
||||
pending_question: itemContext.pending_question
|
||||
? {
|
||||
...itemContext.pending_question,
|
||||
status: keepOpenForPurpose ? 'open' : 'answered',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
confirmTemplate = TEMPLATE.m8RepConfirmed
|
||||
if (keepOpenForPurpose) {
|
||||
confirmBody = copy.m8RepNeedPurpose({
|
||||
participants: renderParticipants(data.participants ?? []),
|
||||
})
|
||||
confirmTemplate = TEMPLATE.m8RepNeedPurpose
|
||||
} else {
|
||||
confirmBody = copy.m8RepConfirmed({
|
||||
participants: renderParticipants(data.participants ?? []),
|
||||
purpose: data.purpose ?? null,
|
||||
})
|
||||
confirmTemplate = TEMPLATE.m8RepConfirmed
|
||||
}
|
||||
} else {
|
||||
await updateItemContext(supabase, target.inboxItemId, (itemContext) => ({
|
||||
...itemContext,
|
||||
@@ -1165,10 +1191,17 @@ async function processAnswerMessage(
|
||||
recent_questions: markRecentQuestion(currentContext, target.inboxItemId, 'answered'),
|
||||
}
|
||||
nextState = current.state
|
||||
if (!target.late && currentContext.pending_question?.inbox_item_id === target.inboxItemId) {
|
||||
if (
|
||||
!target.late &&
|
||||
!keepOpenForPurpose &&
|
||||
currentContext.pending_question?.inbox_item_id === target.inboxItemId
|
||||
) {
|
||||
delete patchContext.pending_question
|
||||
nextState = 'idle'
|
||||
}
|
||||
// keepOpenForPurpose: leave the pending question and the awaiting
|
||||
// state in place so the next free-text reply routes back to this same
|
||||
// receipt instead of drawing the M16 fallback.
|
||||
return { state: nextState, context: patchContext }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user