Files
openchamber/packages/web/server/lib/text/summarization.js
T

287 lines
9.0 KiB
JavaScript

/**
* Shared text summarization service.
*
* Modes:
* - tts: concise speakable text
* - notification: concise notification text
* - note: distilled project note
*/
function buildSummarizationPrompt(maxLength, mode = 'tts') {
if (mode === 'note') {
return `You are distilling selected assistant text into a single short project note.
Goal:
- Produce one concise note the user may want to keep in project notes.
Rules:
1. Output ONLY the final note text.
2. Keep the result under ${maxLength} characters.
3. Prefer one sentence or a short sentence fragment.
4. Keep the most useful insight, decision, constraint, or recommendation.
5. Be concrete and specific.
6. Do not use markdown, bullets, code fences, headings, or quotes.
7. Do not mention the assistant, the text, or that this is a summary.
8. Do not include filler like In summary or Heres a note.
9. If the text contains multiple ideas, keep only the most important one.
10. Rewrite and compress the input into a distilled note. Do not copy the source text verbatim unless it is already an extremely short note.
11. Prefer a shorter phrasing than the input whenever possible.
12. Write the result as a plain sentence or sentence fragment, not as a bullet point.`;
}
if (mode === 'notification') {
return `Summarize the following text in approximately ${maxLength} characters. Be concise and capture the key point.
Rules:
1. Output plain text only.
2. Do not use markdown, bullets, headings, code fences, backticks, or quotes.
3. Output only the summary text.
4. Prefer a short notification-friendly sentence.`;
}
return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters.
CRITICAL INSTRUCTIONS:
1. Output ONLY the final summary - no thinking, no reasoning, no explanations
2. Do not show your work or thought process
3. Do not use any special characters, markdown, code, URLs, file paths, or formatting
4. Do not include phrases like "Here's a summary" or "In summary"
5. Just provide clean, speakable text that can be read aloud
6. Stay within the ${maxLength} character limit
Your response should be ready to speak immediately.`;
}
const SUMMARIZE_TIMEOUT_MS = 30_000;
export function sanitizeForTTS(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/[*_~`#]/g, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]*`/g, '')
.replace(/^\s*[$#>]\s*/gm, '')
.replace(/[|&;<>]/g, ' ')
.replace(/\\/g, '')
.replace(/[\[\]{}()]/g, '')
.replace(/["']/g, '')
.replace(/https?:\/\/[^\s]+/g, ' a link ')
.replace(/\/[\w\-./]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export function sanitizeForNotification(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/^[\t ]*[-*+]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/\s*\n\s*/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function sanitizeForNote(text) {
if (!text || typeof text !== 'string') return '';
return text
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`]*)`/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/__(.*?)__/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/https?:\/\/[^\s]+/g, '')
.replace(/["']/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeByMode(text, mode) {
if (mode === 'note') return sanitizeForNote(text);
if (mode === 'notification') return sanitizeForNotification(text);
return sanitizeForTTS(text);
}
function extractZenOutputText(data) {
if (!data || typeof data !== 'object') return null;
const output = data.output;
if (!Array.isArray(output)) return null;
const messageItem = output.find((item) => item && typeof item === 'object' && item.type === 'message');
if (!messageItem) return null;
const content = messageItem.content;
if (!Array.isArray(content)) return null;
const textItem = content.find((item) => item && typeof item === 'object' && item.type === 'output_text');
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
return text || null;
}
function extractZenChatCompletionText(data) {
if (!data || typeof data !== 'object') return null;
const choices = data.choices;
if (!Array.isArray(choices)) return null;
const choice = choices.find((item) => item && typeof item === 'object');
const content = choice?.message?.content;
if (typeof content === 'string') {
const text = content.trim();
return text || null;
}
if (!Array.isArray(content)) return null;
const text = content
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object' && typeof item.text === 'string') return item.text;
return '';
})
.join('')
.trim();
return text || null;
}
function getZenCompletionEndpoint(model) {
if (typeof model !== 'string') return 'responses';
if (
model.startsWith('gpt-')
|| model.startsWith('claude-')
|| model.startsWith('gemini-')
) {
return 'responses';
}
return 'chat/completions';
}
function distillNoteFallback(text, maxLength) {
const sanitized = sanitizeForNote(text);
if (!sanitized) return '';
const normalized = sanitized
.replace(/^In summary[:,]?\s*/i, '')
.replace(/^Here(?:s| is) (?:a )?note[:,]?\s*/i, '')
.trim();
const sentences = normalized
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
const best = (sentences[0] || normalized)
.split(/[;:()-]\s+/)[0]
.split(/,\s+/)[0]
.trim();
const idealLimit = Math.min(maxLength, Math.max(32, Math.floor(normalized.length * 0.65)));
if (best.length <= idealLimit) return best;
const clipped = best.slice(0, Math.max(0, idealLimit - 1)).trim();
return clipped ? `${clipped}…` : best.slice(0, idealLimit).trim();
}
function fallbackByMode(text, maxLength, mode) {
if (mode === 'note') return distillNoteFallback(text, maxLength);
return sanitizeByMode(text, mode);
}
export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) {
if (!text || text.length <= threshold) {
return {
summary: fallbackByMode(text || '', maxLength, mode),
summarized: false,
reason: text ? 'Text under threshold' : 'No text provided',
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS);
try {
const prompt = buildSummarizationPrompt(maxLength, mode);
const model = zenModel || 'gpt-5-nano';
const endpoint = getZenCompletionEndpoint(model);
const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(endpoint === 'responses'
? {
model,
input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
stream: false,
reasoning: { effort: 'low' },
}
: {
model,
messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }],
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error('[Summarize] zen API error:', response.status, errorBody);
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: `zen API returned ${response.status}`,
};
}
const data = await response.json();
const summary = endpoint === 'responses'
? extractZenOutputText(data)
: extractZenChatCompletionText(data);
if (summary) {
const sanitized = sanitizeByMode(summary, mode);
const finalSummary = mode === 'note'
? (sanitized && sanitized !== sanitizeForNote(text) ? sanitized : distillNoteFallback(text, maxLength))
: sanitized;
return {
summary: finalSummary,
summarized: true,
originalLength: text.length,
summaryLength: finalSummary.length,
};
}
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: 'No response from model',
};
} catch (error) {
if (error.name === 'AbortError') {
console.error('[Summarize] Request timed out');
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: 'Request timed out',
};
}
console.error('[Summarize] Error:', error);
return {
summary: fallbackByMode(text, maxLength, mode),
summarized: false,
reason: error.message,
};
} finally {
clearTimeout(timer);
}
}