/** * Lead AI console ("KI-Konsole") — free-form chat with Claude about ONE lead, grounded in * the same dossier the KI-Analyse uses (ERP record, activities, offers / contract, signing * links, attachments, the mailpiler e-mail history with bodies, call transcripts) plus the * last KI-Analyse result when one exists. * * Two parts, both persisted in data/lead-chats/.json (git-ignored): * - context: the dossier build (same steps / background model as the assessment — * iDempiere reads inside the request, archive + PBX in the background, polled via GET). * The dossier text itself stays server-side; the client only sees steps / counts. * - messages: the conversation (user / assistant turns) so the console reopens with its * history. Answers stream to the browser as SSE while they are generated. * * Claude: one request per question with the full history; the dossier sits in a cached * system block (1 h TTL) so follow-up questions only pay for the new turn. Long answers * (mail drafts, scripts) get up to 32k output tokens per request and are auto-continued * twice when they still hit the limit. */ import { existsSync } from 'node:fs' import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises' import { join } from 'node:path' import { type AssessmentJob, type AssessmentStep, newSteps, collectErpContext, collectMails, collectCalls, buildDossier, readAssessmentJob, cfg, val, anthropicClient, anthropicErrorMessage, LANG_NAMES } from './leadAssessment' const CONTEXT_STALE_MS = 20 * 60000 // a pending context build older than this is dead const MAX_TURNS = 40 // history turns sent to the model (newest kept) const MAX_QUESTION_CHARS = 6000 const DEFAULT_ANSWER_TOKENS = 32000 // per request; ANTHROPIC_CHAT_MAX_TOKENS overrides (1000 … 128000) const MAX_CONTINUATIONS = 2 // extra requests when an answer still hits max_tokens const CONTINUE_PROMPT = 'Deine Antwort wurde am Token-Limit abgeschnitten. Fahre exakt an der Stelle fort, an der du aufgehört hast – ohne Wiederholung des bisherigen Textes und ohne Vorbemerkung.' const answerTokens = () => Math.min(128000, Math.max(1000, cfg().chatMaxTokens || DEFAULT_ANSWER_TOKENS)) export interface ChatMessage { id: string role: 'user' | 'assistant' text: string at: string userId?: number model?: string usage?: { input: number; output: number; cacheRead: number; cacheWrite: number } ms?: number error?: string truncated?: boolean } export interface ChatContext extends AssessmentJob { dossier?: string dossierChars?: number assessmentAt?: string // finishedAt of the KI-Analyse that was folded into the context } export interface ChatFile { leadId: number context: ChatContext | null messages: ChatMessage[] } // ---- persistence ------------------------------------------------------------------ const dir = () => join(process.cwd(), 'data', 'lead-chats') const filePath = (leadId: number) => join(dir(), `${leadId}.json`) export const readChat = async (leadId: number): Promise => { try { const p = filePath(leadId) if (!existsSync(p)) return { leadId, context: null, messages: [] } const f: ChatFile = JSON.parse(await readFile(p, 'utf8')) f.messages = Array.isArray(f.messages) ? f.messages : [] const c = f.context if (c?.state === 'pending' && Date.now() - new Date(c.startedAt).getTime() > CONTEXT_STALE_MS) { c.state = 'failed'; c.error = 'Kontextaufbau abgebrochen (Zeitüberschreitung)'; c.finishedAt = new Date().toISOString() } return f } catch { return { leadId, context: null, messages: [] } } } const saveChat = async (f: ChatFile) => { try { if (!existsSync(dir())) await mkdir(dir(), { recursive: true }) await writeFile(filePath(f.leadId), JSON.stringify(f), 'utf8') } catch (err: any) { console.warn('[lead-chat] persist failed:', err?.message || err) } } /** What the browser gets: the context without the (large) dossier text. */ export const publicContext = (c: ChatContext | null) => { if (!c) return null const { dossier, ...rest } = c return rest } // ---- context build ------------------------------------------------------------------ const running = new Map>() /** * Builds (or rebuilds with `force`) the dossier for the console. Returns immediately after * the iDempiere reads; mails / calls continue in the background. A build that is still * running, or a finished one without `force`, is returned as-is. */ export const startChatContext = async (event: any, token: string, leadId: number, userId: number, force: boolean): Promise => { const file = await readChat(leadId) if (file.context?.state === 'pending') return file if (file.context?.state === 'done' && file.context.dossier && !force) return file const ctx: ChatContext = { leadId, state: 'pending', startedAt: new Date().toISOString(), startedBy: userId, language: 'de', transcribeMissing: false, steps: newSteps(['ai']), warnings: [] } // The assessment helpers call their own saveJob(); route it to this file instead. Object.defineProperty(ctx, '__persist', { value: () => saveChat(file), enumerable: false, configurable: true }) file.context = ctx await saveChat(file) let erp try { erp = await collectErpContext(event, token, leadId, ctx) } catch (err: any) { ctx.state = 'failed'; ctx.error = err?.message || String(err); ctx.finishedAt = new Date().toISOString() const s = ctx.steps.find((x: AssessmentStep) => x.key === 'lead'); if (s) { s.state = 'error'; s.message = ctx.error } await saveChat(file) return file } const email = String(val(erp.lead, 'EMail')).trim().toLowerCase() const numbers = [val(erp.lead, 'Phone'), val(erp.lead, 'Phone2')].map(String) ctx.lead = { name: String(val(erp.lead, 'Name')), email } await saveChat(file) const run = (async () => { try { const [mails, calls] = await Promise.all([collectMails(email, ctx), collectCalls(numbers, ctx)]) ctx.dossier = buildDossier(erp, mails, calls) ctx.dossierChars = ctx.dossier.length ctx.sources = { activities: erp.activities.length, conditions: Object.keys(erp.conditions || {}).length, signing: erp.signing.length, attachments: erp.attachments.length, mails: mails.length, mailBodies: mails.filter((m) => m.body !== undefined).length, calls: calls.length, transcripts: calls.filter((c) => c.transcript).length } ctx.state = 'done' } catch (err: any) { const m = err?.message || String(err) console.warn('[lead-chat] context build failed:', leadId, m) ctx.state = 'failed'; ctx.error = m } finally { ctx.finishedAt = new Date().toISOString() await saveChat(file) running.delete(leadId) } })() running.set(leadId, run) return file } export const clearChatMessages = async (leadId: number): Promise => { const file = await readChat(leadId) file.messages = [] if (file.context) await saveChat(file) else { try { if (existsSync(filePath(leadId))) await unlink(filePath(leadId)) } catch { /* ignore */ } } return file } // ---- asking --------------------------------------------------------------------------- const SYSTEM_PROMPT = `Du bist der KI-Assistent im ERP von LogYou (LogShip), einem deutschen Fulfillment-/3PL-Dienstleister (Lagerung, Kommissionierung, Versand für Online-Händler; Angebote mit Lagergeld, Pick & Pack, Versandkonditionen, Wareneingang, Retouren; Rahmenvertrag mit AGB/ADSp/AVV, digitale Signatur, SEPA-Mandat, Fragebogen Erstgespräch, individuelle Prognose). Ein Vertriebsmitarbeiter stellt dir Fragen zu GENAU EINEM Lead. Im zweiten System-Block steht das vollständige Dossier dieses Leads: ERP-Datensatz, Aktivitäten, gespeicherte Angebots-/Vertragskonditionen, Signatur-Links, Anhänge, der komplette E-Mail-Verlauf (gesendet und empfangen) mit Inhalten, Transkripte der Telefonate und – falls vorhanden – die letzte KI-Analyse. Regeln: - Beantworte die Fragen NUR aus dem Dossier. Erfinde nichts. Wenn das Dossier etwas nicht hergibt, sage das klar. - Nenne, wo es hilft, Datum und Quelle (Mail vom …, Anruf am …, Angebot vom …), damit der Nutzer es nachschlagen kann. - Sei konkret: Daten, Beträge, Namen, Zitate. Keine Allgemeinplätze, keine Wiederholung der Frage. - Antworte so knapp wie möglich und so ausführlich wie nötig; bei Listen von Fakten gern als Aufzählung. Einfaches Markdown (Fett, Aufzählungen, kurze Überschriften) ist erlaubt, keine Tabellen. - Transkripte sind automatisch erstellt (Whisper) und können Fehler enthalten; Sprecher sind nicht markiert – leite den Sprecher aus dem Kontext ab und sage, wenn du unsicher bist. - Wenn du einen E-Mail-Text oder Gesprächsleitfaden formulieren sollst: Anrede bis Grußformel, OHNE Signaturblock (wird beim Versand automatisch angehängt). - Du beziehst dich auf frühere Fragen und Antworten dieses Gesprächs, wenn der Nutzer das tut. - Antworte in der Sprache, die unten angegeben ist, auch wenn das Dossier deutsch ist.` const contextBlock = async (ctx: ChatContext): Promise => { const parts = [`DOSSIER DES LEADS (Stand ${String(ctx.finishedAt || ctx.startedAt).slice(0, 16).replace('T', ' ')}):\n\n${ctx.dossier || ''}`] const job = await readAssessmentJob(ctx.leadId) if (job?.state === 'done' && job.result) { ctx.assessmentAt = job.finishedAt const { draftMessage, ...rest } = job.result parts.push(`\n# LETZTE KI-ANALYSE (${String(job.finishedAt || '').slice(0, 16).replace('T', ' ')}, Modell ${job.model || ''})\n${JSON.stringify(rest, null, 1)}`) } return parts.join('\n') } export interface AskHandlers { onDelta: (text: string) => void } /** * Answers one question. Streams text deltas through `handlers.onDelta`, persists the user * turn and the (possibly partial) assistant turn, and resolves with the stored assistant * message. Throws with a readable message when the context is missing or the API fails * before any text arrived. */ export const askChat = async (leadId: number, userId: number, question: string, language: string, handlers: AskHandlers): Promise => { const file = await readChat(leadId) const ctx = file.context if (!ctx || ctx.state !== 'done' || !ctx.dossier) throw new Error(ctx?.state === 'pending' ? 'Der Kontext wird noch aufgebaut' : 'Kein Kontext – bitte zuerst den Kontext aufbauen') const text = String(question || '').trim().slice(0, MAX_QUESTION_CHARS) if (!text) throw new Error('Leere Frage') const now = new Date().toISOString() const userMsg: ChatMessage = { id: `${Date.now().toString(36)}u`, role: 'user', text, at: now, userId } // History for the model: finished turns only (a failed answer without text is skipped). const history = file.messages.filter((m) => m.text && m.text.trim()).slice(-MAX_TURNS) const messages: any[] = history.map((m) => ({ role: m.role, content: m.text })) // Consecutive same-role turns are merged by the API; the first turn must be a user one. while (messages.length && messages[0].role !== 'user') messages.shift() messages.push({ role: 'user', content: `Sprache der Antwort: ${LANG_NAMES[language] || 'Deutsch'}. Heutiges Datum: ${now.slice(0, 10)}.\n\n${text}` }) file.messages.push(userMsg) await saveChat(file) const { model } = cfg() const client = anthropicClient(10 * 60 * 1000) const system = [ { type: 'text', text: SYSTEM_PROMPT }, { type: 'text', text: await contextBlock(ctx), cache_control: { type: 'ephemeral', ttl: '1h' } } ] const t0 = Date.now() const reply: ChatMessage = { id: `${Date.now().toString(36)}a`, role: 'assistant', text: '', at: new Date().toISOString() } const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } let finalMsg: any = null // One streamed request; when the answer hits max_tokens the partial text is handed back as an // assistant turn and the model is asked to continue (at most MAX_CONTINUATIONS times). The // dossier block stays cached, so a continuation only pays for the new turn. let round = 0 let partial = '' try { while (true) { const turnMessages = partial ? [...messages, { role: 'assistant', content: partial }, { role: 'user', content: CONTINUE_PROMPT }] : messages const stream = client.beta.messages.stream({ model, max_tokens: answerTokens(), betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default', output_config: { effort: 'medium' }, system, messages: turnMessages } as any) let roundText = '' for await (const ev of stream as any) { if (ev?.type === 'content_block_delta' && ev.delta?.type === 'text_delta' && ev.delta.text) { roundText += ev.delta.text reply.text += ev.delta.text handlers.onDelta(ev.delta.text) } } finalMsg = await stream.finalMessage() usage.input += Number(finalMsg.usage?.input_tokens || 0); usage.output += Number(finalMsg.usage?.output_tokens || 0) usage.cacheRead += Number(finalMsg.usage?.cache_read_input_tokens || 0); usage.cacheWrite += Number(finalMsg.usage?.cache_creation_input_tokens || 0) if (finalMsg.stop_reason !== 'max_tokens' || !roundText.trim() || round >= MAX_CONTINUATIONS) break round++ partial = reply.text } } catch (err: any) { reply.error = anthropicErrorMessage(err) if (!reply.text) { // Nothing arrived: keep the question, drop the empty answer, surface the error. await saveChat(file) throw new Error(reply.error) } } if (finalMsg) { reply.model = String(finalMsg.model || model) reply.usage = usage if (finalMsg.stop_reason === 'max_tokens') reply.truncated = true if (finalMsg.stop_reason === 'refusal') reply.error = 'Das Modell hat die Antwort abgelehnt' + (finalMsg.stop_details?.explanation ? ': ' + finalMsg.stop_details.explanation : '') } reply.ms = Date.now() - t0 file.messages.push(reply) await saveChat(file) return reply }