learning_ai_notes/backend/src/lib/copilot-transform.ts

117 lines
4.7 KiB
TypeScript

/**
* Copilot text transforms — powered by @bytelyst/llm.
*
* Falls back to local heuristics if LLM is unavailable.
*/
import { llm } from './llm.js';
import { trackEvent } from './telemetry.js';
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('LLM request timed out')), ms)),
]);
}
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar' | 'fix-rewrite' | 'change-tone' | 'continue' | 'explain';
const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
shorten: 'Condense the text to about half its length while preserving key points. Return only the shortened text.',
expand: 'Expand the text with more detail and examples. Return only the expanded text.',
bulletize: 'Convert the text into concise bullet points. Return only the bullet points.',
grammar: 'Fix grammar, spelling, and punctuation. Preserve original meaning and tone. Return only the corrected text.',
'fix-rewrite': 'Completely rewrite the text for better clarity, grammar, and flow while preserving the original meaning. Return only the rewritten text.',
'change-tone': 'Rewrite the text in the requested tone (formal, casual, professional, or friendly). The tone is specified at the end after "Tone:". Return only the rewritten text.',
'continue': 'You are a writing assistant. Continue writing naturally from where the text ends. Write 2-3 paragraphs that flow logically from the context. Return only the continuation, not the original text.',
'explain': 'Explain the given term, concept, or text selection concisely in 2-3 sentences. Return only the explanation.',
};
function fallbackTransform(action: CopilotAction, text: string): string {
const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean);
switch (action) {
case 'bulletize':
return lines.map((l) => (l.startsWith('-') || l.startsWith('•') ? l : `- ${l}`)).join('\n');
case 'shorten': {
const words = text.split(/\s+/);
const target = Math.max(8, Math.floor(words.length * 0.55));
return words.slice(0, target).join(' ') + (words.length > target ? '…' : '');
}
case 'expand':
return `${text}\n\n_Additional detail could be added here to expand on the main points._`;
case 'fix-rewrite':
return text;
case 'change-tone':
return text;
case 'continue':
return `${text}\n\n[Continue writing here...]`;
case 'explain':
return 'Explanation not available without an LLM provider.';
case 'grammar':
default:
return text;
}
}
export async function runCopilotTransform(action: CopilotAction, text: string): Promise<string> {
const provider = llm();
if (!provider.isConfigured()) {
return fallbackTransform(action, text);
}
const maxRetries = 3;
const baseDelayMs = 1000;
const startMs = Date.now();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await withTimeout(provider.chatCompletion({
messages: [
{ role: 'system', content: SYSTEM_PROMPTS[action] },
{ role: 'user', content: text },
],
temperature: 0.3,
maxTokens: 4096,
}), 60_000);
const out = result.content.trim();
if (out.length > 0) {
trackEvent('copilot_transform', 'system', { action, durationMs: String(Date.now() - startMs) });
return out;
}
break; // empty response — fall through to heuristic
} catch (err: unknown) {
const isRateLimit = err instanceof Error && (err.message.includes('429') || err.message.includes('rate'));
if (isRateLimit && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, baseDelayMs * Math.pow(2, attempt)));
continue;
}
break; // non-retriable error — fall through to heuristic
}
}
return fallbackTransform(action, text);
}
export async function suggestTitleFromBody(body: string): Promise<string> {
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const provider = llm();
if (!provider.isConfigured()) {
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
}
try {
const result = await withTimeout(provider.chatCompletion({
messages: [
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
{ role: 'user', content: plain.slice(0, 4000) },
],
temperature: 0.6,
maxTokens: 64,
}), 60_000);
const t = result.content.trim();
if (t.length > 0 && t.length < 500) return t;
} catch {
// timeout or LLM error — fall through to heuristic
}
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
}