mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-05 04:11:53 +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);
|
const chunks = chunkEvents(sorted, options);
|
||||||
for (const chunk of chunks) {
|
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 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 chunkStart = Math.min(...chunk.map((event) => event.timestamp));
|
||||||
const chunkEnd = Math.max(...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',
|
session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown',
|
||||||
type: 'batch_window',
|
type: 'batch_window',
|
||||||
content: contextText,
|
content: contextText,
|
||||||
summary,
|
summary: summarization.summary,
|
||||||
tags: [...new Set(chunk.flatMap((event) => event.tags))],
|
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,
|
embedding_id: Array.isArray(embedding) && embedding.length > 0 ? randomUUID() : null,
|
||||||
created_at: chunkStart,
|
created_at: chunkStart,
|
||||||
last_seen: chunkEnd,
|
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 {import('./schema.js').MemoryEvent[]} events
|
||||||
* @param {{ maxSummaryLength?: number }} [options]
|
* @param {{
|
||||||
* @returns {string}
|
* 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 maxSummaryLength = options.maxSummaryLength ?? 280;
|
||||||
const joined = events.map((event) => event.content).join(' ').trim();
|
const fallback = deterministicFallback(events, maxSummaryLength);
|
||||||
if (!joined) return '';
|
if (!fallback.summary) return fallback;
|
||||||
return joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined;
|
|
||||||
|
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