From 42a04efe146e7b6d7fd1bc970b1b6b756ceaa82f Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 28 Jun 2026 08:05:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(secretaria):=20mem=C3=B3ria=20de=20longo?= =?UTF-8?q?=20prazo=20por=20contato=20(a=20partir=20das=20conversas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A secretária passa a "lembrar" do cliente entre conversas: - tabela sec_contact_memory (fato + embedding JSON), chave = ext_chat_id/contact_name. - na sumarização (a cada ~10 trocas) extrai fatos duradouros do cliente via LLM e salva com embedding + dedup por similaridade (>0.92). - buildSystemPrompt injeta "MEMÓRIA DO CLIENTE" com os fatos mais relevantes à pergunta (cosseno); sem chave de embedding usa os mais recentes; tudo com fallback silencioso (não regride). Co-Authored-By: Claude Opus 4.8 --- .../plugins/secretaria/brain.js | 118 ++++++++++++++++++ .../plugins/secretaria/brain.ts | 97 ++++++++++++++ .../plugins/secretaria/migrate.js | 14 +++ .../plugins/secretaria/migrate.ts | 15 +++ 4 files changed, 244 insertions(+) diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js index ead0601..b9bc741 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js @@ -109,6 +109,8 @@ class ProtocolEngine { let summary = conversation.summary; if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) { summary = await this.summarize(agent, recentMessages, userMessage, response); + // Extrai memória duradoura do contato a partir da conversa (não bloqueia a resposta). + this.extractContactMemory(agent, conversation, recentMessages, userMessage, response).catch(() => { }); } await this.db('sec_conversations') .where({ id: conversationId }) @@ -144,6 +146,10 @@ class ProtocolEngine { ``, ].join('\n'); prompt += protocolHeader; + // Memória de longo prazo do contato (fatos de conversas anteriores) + const contactMem = await this.contactMemoryContext(conversation, userMessage); + if (contactMem) + prompt += `=== MEMÓRIA DO CLIENTE (de conversas anteriores) ===\n${contactMem}\n\n`; for (const node of nodes) { switch (node.type) { case 'persona': @@ -256,6 +262,118 @@ class ProtocolEngine { }); } } + // ── Memória de longo prazo por contato ───────────────────────────────────── + /** Recupera os fatos do contato relevantes à pergunta (conversas anteriores). */ + async contactMemoryContext(conversation, userMessage) { + const contactKey = conversation.ext_chat_id || conversation.contact_name; + if (!contactKey) + return ''; + let cfg; + try { + cfg = await this.config.get('secretaria'); + } + catch { + return ''; + } + try { + const mems = await this.db('sec_contact_memory') + .where({ agent_id: conversation.agent_id, contact_key: contactKey }); + if (!mems.length) + return ''; + const qVec = (userMessage?.trim() && (0, embeddings_1.hasEmbeddingKey)(cfg)) ? await (0, embeddings_1.embed)(userMessage, cfg) : null; + if (!qVec) { + // Sem embedding da pergunta: usa os fatos mais recentes. + return mems.slice(-6).map((m) => `- ${m.content}`).join('\n'); + } + const ranked = mems + .map((m) => { + let v = []; + try { + v = JSON.parse(m.embedding); + } + catch { + v = []; + } + return { content: m.content, score: (0, embeddings_1.cosineSimilarity)(qVec, v) }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, 6) + .filter((r) => r.score > 0); + return ranked.length ? ranked.map((r) => `- ${r.content}`).join('\n') : ''; + } + catch { + return ''; + } + } + /** Extrai fatos duradouros do cliente da conversa e salva (com embedding + dedup). */ + async extractContactMemory(agent, conversation, recentMessages, userMessage, response) { + const contactKey = conversation.ext_chat_id || conversation.contact_name; + if (!contactKey) + return; + let cfg; + try { + cfg = await this.config.get('secretaria'); + } + catch { + return; + } + if (!(0, embeddings_1.hasEmbeddingKey)(cfg)) + return; // sem embedding não há como armazenar/buscar + const transcript = [ + ...recentMessages.map((m) => ({ role: m.role, content: m.content })), + { role: 'user', content: userMessage }, + { role: 'assistant', content: response }, + ].map((m) => `${m.role === 'user' ? 'Cliente' : 'Atendente'}: ${m.content}`).join('\n'); + const sys = [ + 'Extraia FATOS DURADOUROS sobre o CLIENTE desta conversa, úteis em atendimentos futuros', + '(preferências, dados pessoais, decisões, contexto recorrente).', + '- Uma frase curta por fato, em 3ª pessoa ("O cliente ...").', + '- Ignore saudações, agradecimentos e o que é efêmero.', + '- Se não houver nada digno de memória, responda exatamente: NADA', + 'Responda só a lista, uma por linha, sem numerar.', + ].join('\n'); + let factsText = ''; + try { + const r = await this.callAI(agent, sys, [{ role: 'user', content: transcript }]); + factsText = r.text ?? ''; + } + catch { + return; + } + if (!factsText.trim() || /^\s*NADA\s*$/i.test(factsText.trim())) + return; + const facts = factsText.split('\n') + .map((s) => s.replace(/^[-*\d.\s]+/, '').trim()) + .filter((f) => f.length > 3) + .slice(0, 8); + if (!facts.length) + return; + const existing = await this.db('sec_contact_memory') + .where({ agent_id: agent.id, contact_key: contactKey }); + const vecs = existing.map((e) => { try { + return JSON.parse(e.embedding); + } + catch { + return []; + } }); + for (const fact of facts) { + const vec = await (0, embeddings_1.embed)(fact, cfg); + if (!vec) + continue; + if (vecs.some((ev) => ev.length && (0, embeddings_1.cosineSimilarity)(vec, ev) > 0.92)) + continue; // dedup + await this.db('sec_contact_memory').insert({ + id: this.uuid(), + agent_id: agent.id, + contact_key: contactKey, + content: fact, + embedding: JSON.stringify(vec), + created_at: new Date(), + updated_at: new Date(), + }); + vecs.push(vec); + } + } // ── Finalize Protocol ───────────────────────────────────────────────────── async finalizeProtocol(conversationId) { const conversation = await this.db('sec_conversations').where({ id: conversationId }).first(); diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts index 74b25f8..86af27a 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts @@ -132,6 +132,8 @@ export class ProtocolEngine { let summary = conversation.summary if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) { summary = await this.summarize(agent, recentMessages, userMessage, response) + // Extrai memória duradoura do contato a partir da conversa (não bloqueia a resposta). + this.extractContactMemory(agent, conversation, recentMessages, userMessage, response).catch(() => {}) } await this.db('sec_conversations') @@ -182,6 +184,10 @@ export class ProtocolEngine { ].join('\n') prompt += protocolHeader + // Memória de longo prazo do contato (fatos de conversas anteriores) + const contactMem = await this.contactMemoryContext(conversation, userMessage) + if (contactMem) prompt += `=== MEMÓRIA DO CLIENTE (de conversas anteriores) ===\n${contactMem}\n\n` + for (const node of nodes as any[]) { switch (node.type) { case 'persona': @@ -285,6 +291,97 @@ export class ProtocolEngine { } } + // ── Memória de longo prazo por contato ───────────────────────────────────── + + /** Recupera os fatos do contato relevantes à pergunta (conversas anteriores). */ + private async contactMemoryContext(conversation: any, userMessage?: string): Promise { + const contactKey = conversation.ext_chat_id || conversation.contact_name + if (!contactKey) return '' + let cfg: any + try { cfg = await this.config.get('secretaria') } catch { return '' } + try { + const mems = await this.db('sec_contact_memory') + .where({ agent_id: conversation.agent_id, contact_key: contactKey }) + if (!mems.length) return '' + const qVec = (userMessage?.trim() && hasEmbeddingKey(cfg)) ? await embed(userMessage, cfg) : null + if (!qVec) { + // Sem embedding da pergunta: usa os fatos mais recentes. + return mems.slice(-6).map((m: any) => `- ${m.content}`).join('\n') + } + const ranked = mems + .map((m: any) => { + let v: number[] = [] + try { v = JSON.parse(m.embedding) } catch { v = [] } + return { content: m.content, score: cosineSimilarity(qVec, v) } + }) + .sort((a: any, b: any) => b.score - a.score) + .slice(0, 6) + .filter((r: any) => r.score > 0) + return ranked.length ? ranked.map((r: any) => `- ${r.content}`).join('\n') : '' + } catch { + return '' + } + } + + /** Extrai fatos duradouros do cliente da conversa e salva (com embedding + dedup). */ + private async extractContactMemory( + agent: any, conversation: any, recentMessages: any[], userMessage: string, response: string, + ): Promise { + const contactKey = conversation.ext_chat_id || conversation.contact_name + if (!contactKey) return + let cfg: any + try { cfg = await this.config.get('secretaria') } catch { return } + if (!hasEmbeddingKey(cfg)) return // sem embedding não há como armazenar/buscar + + const transcript = [ + ...recentMessages.map((m: any) => ({ role: m.role, content: m.content })), + { role: 'user', content: userMessage }, + { role: 'assistant', content: response }, + ].map((m) => `${m.role === 'user' ? 'Cliente' : 'Atendente'}: ${m.content}`).join('\n') + + const sys = [ + 'Extraia FATOS DURADOUROS sobre o CLIENTE desta conversa, úteis em atendimentos futuros', + '(preferências, dados pessoais, decisões, contexto recorrente).', + '- Uma frase curta por fato, em 3ª pessoa ("O cliente ...").', + '- Ignore saudações, agradecimentos e o que é efêmero.', + '- Se não houver nada digno de memória, responda exatamente: NADA', + 'Responda só a lista, uma por linha, sem numerar.', + ].join('\n') + + let factsText = '' + try { + const r = await this.callAI(agent, sys, [{ role: 'user', content: transcript }]) + factsText = r.text ?? '' + } catch { return } + if (!factsText.trim() || /^\s*NADA\s*$/i.test(factsText.trim())) return + + const facts = factsText.split('\n') + .map((s) => s.replace(/^[-*\d.\s]+/, '').trim()) + .filter((f) => f.length > 3) + .slice(0, 8) + if (!facts.length) return + + const existing = await this.db('sec_contact_memory') + .where({ agent_id: agent.id, contact_key: contactKey }) + const vecs: number[][] = existing.map((e: any) => { try { return JSON.parse(e.embedding) } catch { return [] } }) + + for (const fact of facts) { + const vec = await embed(fact, cfg) + if (!vec) continue + if (vecs.some((ev) => ev.length && cosineSimilarity(vec, ev) > 0.92)) continue // dedup + await this.db('sec_contact_memory').insert({ + id: this.uuid(), + agent_id: agent.id, + contact_key: contactKey, + content: fact, + embedding: JSON.stringify(vec), + created_at: new Date(), + updated_at: new Date(), + }) + vecs.push(vec) + } + } + // ── Finalize Protocol ───────────────────────────────────────────────────── async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> { diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js index 26a07f6..384be1a 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js @@ -154,6 +154,20 @@ async function runMigrations(db) { await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_agent ON sec_knowledge_chunks (agent_id)'); await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_id)'); } + // ── sec_contact_memory (memória de longo prazo por contato) ─────────────── + // Fatos duradouros do cliente extraídos das conversas, com embedding (JSON). + // Permite a secretária "lembrar" do contato entre conversas/protocolos. + if (!(await db.schema.hasTable('sec_contact_memory'))) { + await db.schema.createTable('sec_contact_memory', (t) => { + t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')); + t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE'); + t.string('contact_key', 300).notNullable(); // ext_chat_id ou contact_name + t.text('content').notNullable(); + t.text('embedding').notNullable(); // vetor serializado em JSON + t.timestamps(true, true); + }); + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)'); + } // ── Seeds: agente padrão + nós + calendário ─────────────────────────────── const agentCount = await db('sec_agents').count('id as c').first(); if (Number(agentCount?.c ?? 0) === 0) { diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts index d39de38..0b3ee88 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts @@ -165,6 +165,21 @@ export async function runMigrations(db: Knex): Promise { await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_id)') } + // ── sec_contact_memory (memória de longo prazo por contato) ─────────────── + // Fatos duradouros do cliente extraídos das conversas, com embedding (JSON). + // Permite a secretária "lembrar" do contato entre conversas/protocolos. + if (!(await db.schema.hasTable('sec_contact_memory'))) { + await db.schema.createTable('sec_contact_memory', (t) => { + t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')) + t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE') + t.string('contact_key', 300).notNullable() // ext_chat_id ou contact_name + t.text('content').notNullable() + t.text('embedding').notNullable() // vetor serializado em JSON + t.timestamps(true, true) + }) + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)') + } + // ── Seeds: agente padrão + nós + calendário ─────────────────────────────── const agentCount = await db('sec_agents').count('id as c').first() if (Number(agentCount?.c ?? 0) === 0) {