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:
@@ -109,6 +109,8 @@ class ProtocolEngine {
|
|||||||
let summary = conversation.summary;
|
let summary = conversation.summary;
|
||||||
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
||||||
summary = await this.summarize(agent, recentMessages, userMessage, response);
|
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')
|
await this.db('sec_conversations')
|
||||||
.where({ id: conversationId })
|
.where({ id: conversationId })
|
||||||
@@ -144,6 +146,10 @@ class ProtocolEngine {
|
|||||||
``,
|
``,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
prompt += protocolHeader;
|
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) {
|
for (const node of nodes) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'persona':
|
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 ─────────────────────────────────────────────────────
|
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||||
async finalizeProtocol(conversationId) {
|
async finalizeProtocol(conversationId) {
|
||||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export class ProtocolEngine {
|
|||||||
let summary = conversation.summary
|
let summary = conversation.summary
|
||||||
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
||||||
summary = await this.summarize(agent, recentMessages, userMessage, response)
|
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')
|
await this.db('sec_conversations')
|
||||||
@@ -182,6 +184,10 @@ export class ProtocolEngine {
|
|||||||
].join('\n')
|
].join('\n')
|
||||||
prompt += protocolHeader
|
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[]) {
|
for (const node of nodes as any[]) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'persona':
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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 ─────────────────────────────────────────────────────
|
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
||||||
|
|||||||
@@ -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_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)');
|
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 ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first();
|
const agentCount = await db('sec_agents').count('id as c').first();
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
@@ -165,6 +165,21 @@ export async function runMigrations(db: Knex): Promise<void> {
|
|||||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_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 ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first()
|
const agentCount = await db('sec_agents').count('id as c').first()
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user