mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Add memory prompt templates and robust JSON summarization
This commit is contained in:
1 parent
415bff738e
commit
3665aa0f4f
7 files changed
+247
-10
No files matched your search
@@ -0,0 +1,12 @@
|
||||
The previous response was malformed JSON.
|
||||
|
||||
Repair it into STRICT JSON only, preserving original meaning and using conservative confidence:
|
||||
{"summary":"...","importance":0.0}
|
||||
|
||||
No markdown. No preamble. No trailing text.
|
||||
|
||||
Parse error:
|
||||
{{PARSE_ERROR}}
|
||||
|
||||
Malformed response:
|
||||
{{RAW_RESPONSE}}
|
||||
@@ -0,0 +1,9 @@
|
||||
Decide whether low-value memory can be pruned.
|
||||
|
||||
Output STRICT JSON only:
|
||||
{"prune":false,"reason":"short factual reason"}
|
||||
|
||||
Rules:
|
||||
- Never prune if uncertain.
|
||||
- Never expose PII; redact if quoted.
|
||||
- Prefer retaining safety-critical, decision, and incident context.
|
||||
@@ -0,0 +1,10 @@
|
||||
Score memory importance from 0.0 to 1.0 conservatively.
|
||||
|
||||
Output STRICT JSON only:
|
||||
{"importance":0.0,"rationale":"short factual reason"}
|
||||
|
||||
Guidelines:
|
||||
- 0.0-0.3: routine/noisy or low impact
|
||||
- 0.4-0.7: useful operational context
|
||||
- 0.8-1.0: high-salience commitments, incidents, or decisions
|
||||
- Avoid over-scoring uncertain data
|
||||
@@ -0,0 +1,17 @@
|
||||
You are a memory summarizer.
|
||||
|
||||
Task:
|
||||
- Summarize the input events factually.
|
||||
- Do not invent entities, actions, or outcomes not present in the events.
|
||||
- Redact PII (emails, phone numbers, SSNs) as [redacted:*].
|
||||
- Keep summary length <= {{MAX_SUMMARY_LENGTH}} characters.
|
||||
|
||||
Return STRICT JSON only (no markdown, no prose):
|
||||
{"summary":"...","importance":0.0}
|
||||
|
||||
Rules:
|
||||
- importance is a number between 0.0 and 1.0.
|
||||
- Use conservative importance when uncertain.
|
||||
|
||||
Events:
|
||||
{{EVENTS}}
|
||||
@@ -182,9 +182,9 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
||||
|
||||
const chunks = chunkEvents(sorted, options);
|
||||
for (const chunk of chunks) {
|
||||
const summary = summarizeEvents(chunk, options);
|
||||
const summarization = await summarizeEvents(chunk, options);
|
||||
const contextText = chunk.map((event) => event.content).join('\n');
|
||||
const embedding = await embedText(`${summary}\n${contextText}`, options);
|
||||
const embedding = await embedText(`${summarization.summary}\n${contextText}`, options);
|
||||
const chunkStart = Math.min(...chunk.map((event) => event.timestamp));
|
||||
const chunkEnd = Math.max(...chunk.map((event) => event.timestamp));
|
||||
|
||||
@@ -193,9 +193,11 @@ export async function ingestMemoryWindow(input = {}, options = {}) {
|
||||
session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown',
|
||||
type: 'batch_window',
|
||||
content: contextText,
|
||||
summary,
|
||||
summary: summarization.summary,
|
||||
tags: [...new Set(chunk.flatMap((event) => event.tags))],
|
||||
importance: Number.isFinite(chunk[0]?.metadata?.importance) ? Number(chunk[0].metadata.importance) : 0.5,
|
||||
importance: Number.isFinite(chunk[0]?.metadata?.importance)
|
||||
? Number(chunk[0].metadata.importance)
|
||||
: summarization.importance,
|
||||
embedding_id: Array.isArray(embedding) && embedding.length > 0 ? randomUUID() : null,
|
||||
created_at: chunkStart,
|
||||
last_seen: chunkEnd,
|
||||
|
||||
@@ -1,11 +1,151 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const DEFAULT_TEMPLATE_DIR = path.resolve(__dirname, '../../prompts/memory');
|
||||
const TEMPLATE_FILES = {
|
||||
summarize: 'summarize_prompt.txt',
|
||||
condense: 'condense_prompt.txt',
|
||||
score: 'score_prompt.txt',
|
||||
prune: 'prune_prompt.txt',
|
||||
};
|
||||
|
||||
const templateCache = new Map();
|
||||
|
||||
function redactObviousPii(text) {
|
||||
if (typeof text !== 'string' || text.length === 0) return '';
|
||||
return text
|
||||
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]')
|
||||
.replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]')
|
||||
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]');
|
||||
}
|
||||
|
||||
function renderTemplate(template, replacements) {
|
||||
return Object.entries(replacements).reduce(
|
||||
(output, [key, value]) => output.replaceAll(`{{${key}}}`, String(value ?? '')),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
export function loadMemoryPromptTemplates(options = {}) {
|
||||
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
|
||||
if (templateCache.has(templateDir)) {
|
||||
return templateCache.get(templateDir);
|
||||
}
|
||||
|
||||
const templates = Object.fromEntries(
|
||||
Object.entries(TEMPLATE_FILES).map(([key, fileName]) => {
|
||||
const filePath = path.join(templateDir, fileName);
|
||||
const content = readFileSync(filePath, 'utf8').trim();
|
||||
return [key, content];
|
||||
})
|
||||
);
|
||||
|
||||
templateCache.set(templateDir, templates);
|
||||
return templates;
|
||||
}
|
||||
|
||||
function parseSummaryPayload(rawResponse) {
|
||||
const parsed = JSON.parse(rawResponse);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new TypeError('Response must be a JSON object');
|
||||
}
|
||||
|
||||
const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : '';
|
||||
if (!summary) {
|
||||
throw new TypeError('Response.summary must be a non-empty string');
|
||||
}
|
||||
|
||||
const rawImportance = Number(parsed.importance);
|
||||
const importance = Number.isFinite(rawImportance)
|
||||
? Math.min(1, Math.max(0, rawImportance))
|
||||
: 0.25;
|
||||
|
||||
return { summary, importance };
|
||||
}
|
||||
|
||||
function deterministicFallback(events, maxSummaryLength) {
|
||||
const joined = events.map((event) => redactObviousPii(event.content)).join(' ').trim();
|
||||
const bounded = joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
|
||||
return { summary: bounded, importance: 0.25 };
|
||||
}
|
||||
|
||||
function isLikelyFactual(summary, sourceText) {
|
||||
const sourceTokens = new Set(
|
||||
sourceText
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((token) => token.length > 3)
|
||||
);
|
||||
const summaryTokens = summary
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((token) => token.length > 3);
|
||||
|
||||
if (!summaryTokens.length) return true;
|
||||
const overlap = summaryTokens.filter((token) => sourceTokens.has(token)).length;
|
||||
return overlap / summaryTokens.length >= 0.35;
|
||||
}
|
||||
|
||||
function sanitizeResult(parsed, sourceText, maxSummaryLength, fallback) {
|
||||
const sanitizedSummary = redactObviousPii(parsed.summary);
|
||||
if (!isLikelyFactual(sanitizedSummary, sourceText)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
summary: sanitizedSummary.length > maxSummaryLength
|
||||
? `${sanitizedSummary.slice(0, maxSummaryLength - 1)}…`
|
||||
: sanitizedSummary,
|
||||
importance: parsed.importance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./schema.js').MemoryEvent[]} events
|
||||
* @param {{ maxSummaryLength?: number }} [options]
|
||||
* @returns {string}
|
||||
* @param {{
|
||||
* maxSummaryLength?: number,
|
||||
* generateSummary?: (prompt: string) => Promise<string> | string,
|
||||
* templateDir?: string,
|
||||
* }} [options]
|
||||
* @returns {Promise<{ summary: string, importance: number }>}
|
||||
*/
|
||||
export function summarizeEvents(events, options = {}) {
|
||||
export async function summarizeEvents(events, options = {}) {
|
||||
const maxSummaryLength = options.maxSummaryLength ?? 280;
|
||||
const joined = events.map((event) => event.content).join(' ').trim();
|
||||
if (!joined) return '';
|
||||
return joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
|
||||
const fallback = deterministicFallback(events, maxSummaryLength);
|
||||
if (!fallback.summary) return fallback;
|
||||
|
||||
const templates = loadMemoryPromptTemplates(options);
|
||||
const sourceText = events.map((event) => redactObviousPii(event.content)).join('\n').trim();
|
||||
const generateSummary = options.generateSummary;
|
||||
|
||||
if (typeof generateSummary !== 'function') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const summaryPrompt = renderTemplate(templates.summarize, {
|
||||
EVENTS: sourceText,
|
||||
MAX_SUMMARY_LENGTH: maxSummaryLength,
|
||||
});
|
||||
|
||||
let firstResponse = '';
|
||||
try {
|
||||
firstResponse = await generateSummary(summaryPrompt);
|
||||
const parsed = parseSummaryPayload(firstResponse);
|
||||
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
|
||||
} catch (firstError) {
|
||||
try {
|
||||
const repairPrompt = renderTemplate(templates.condense, {
|
||||
RAW_RESPONSE: firstResponse,
|
||||
PARSE_ERROR: firstError instanceof Error ? firstError.message : 'unknown_parse_error',
|
||||
});
|
||||
const repairedResponse = await generateSummary(repairPrompt);
|
||||
const parsed = parseSummaryPayload(repairedResponse);
|
||||
return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { loadMemoryPromptTemplates, summarizeEvents } from '../src/services/memory/summarizer.js';
|
||||
|
||||
const SAMPLE_EVENTS = [
|
||||
{ agent_id: 'a', content: 'Deployment completed for service alpha', timestamp: 1, tags: [], metadata: {} },
|
||||
{ agent_id: 'a', content: 'Contact: [email protected]', timestamp: 2, tags: [], metadata: {} },
|
||||
];
|
||||
|
||||
test('loadMemoryPromptTemplates loads all memory prompt templates from disk', () => {
|
||||
const templates = loadMemoryPromptTemplates();
|
||||
assert.ok(templates.summarize.includes('STRICT JSON only'));
|
||||
assert.ok(templates.condense.includes('malformed JSON'));
|
||||
assert.ok(templates.score.includes('importance'));
|
||||
assert.ok(templates.prune.includes('prune'));
|
||||
});
|
||||
|
||||
test('summarizeEvents retries with repair prompt when first response is malformed JSON', async () => {
|
||||
const prompts = [];
|
||||
const result = await summarizeEvents(SAMPLE_EVENTS, {
|
||||
generateSummary(prompt) {
|
||||
prompts.push(prompt);
|
||||
if (prompts.length === 1) {
|
||||
return '{"summary":"partial"';
|
||||
}
|
||||
return '{"summary":"Deployment completed for service alpha [redacted:email]","importance":0.6}';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prompts.length, 2);
|
||||
assert.equal(result.importance, 0.6);
|
||||
assert.equal(result.summary, 'Deployment completed for service alpha [redacted:email]');
|
||||
});
|
||||
|
||||
test('summarizeEvents falls back to deterministic minimal summary with conservative importance when parsing fails twice', async () => {
|
||||
const result = await summarizeEvents(SAMPLE_EVENTS, {
|
||||
maxSummaryLength: 80,
|
||||
async generateSummary() {
|
||||
return 'not json';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.importance, 0.25);
|
||||
assert.match(result.summary, /Deployment completed for service alpha/);
|
||||
assert.match(result.summary, /\[redacted:email\]/);
|
||||
});
|
||||
Reference in new issue
Block a user