Files
accounted/lib/agent-context/chat-clarifications.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

198 lines
8.0 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.
*
* Only WhatsApp asks a human anything, so only WhatsApp produces
* clarifications. The mail hunt writes the same column with its own shape
* and never carries answers, which is why this stays narrow while
* InboxChannelContext.channel spans both intakes.
*/
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: 'whatsapp',
}
}
/**
* 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
}