feat(secretaria): memória de longo prazo por contato (a partir das conversas)

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 08:05:42 +02:00
parent 31bf45faf5
commit 42a04efe14
4 changed files with 244 additions and 0 deletions
@@ -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();