// src/formatters.ts // ============================================================================ // OUTPUT FORMATTERS // ============================================================================ // // Format search results and case content for Claude. // Designed to be readable and include proper citations. // ============================================================================ import type { SearchResult, CaseContent, CaseParagraph } from './types.js'; // ============================================================================ // PARAGRAPH RANGE UTILITIES // ============================================================================ export function parseParaRange(range: string): { start: number; end: number } | null { const match = range.match(/^(\d+)\s*-\s*(\d+)$/); if (!match || !match[1] || !match[2]) return null; return { start: parseInt(match[1], 10), end: parseInt(match[2], 10), }; } // Maximum characters to return (roughly 8000 tokens) const MAX_OUTPUT_CHARS = 32000; export function applyParaRange( paragraphs: CaseParagraph[], range: string | undefined ): { paragraphs: CaseParagraph[]; truncated: boolean; remaining: number } { if (!range) { // No range specified - apply token limit return truncateToLimit(paragraphs); } const parsed = parseParaRange(range); if (!parsed) { return truncateToLimit(paragraphs); } const filtered = paragraphs.filter( p => p.number >= parsed.start && p.number <= parsed.end ); return truncateToLimit(filtered); } function truncateToLimit(paragraphs: CaseParagraph[]): { paragraphs: CaseParagraph[]; truncated: boolean; remaining: number; } { let totalChars = 0; const output: CaseParagraph[] = []; for (const para of paragraphs) { if (totalChars + para.text.length > MAX_OUTPUT_CHARS) { break; } output.push(para); totalChars += para.text.length; } return { paragraphs: output, truncated: output.length < paragraphs.length, remaining: paragraphs.length - output.length, }; } // ============================================================================ // SEARCH RESULTS FORMATTER // ============================================================================ export function formatSearchResults(results: SearchResult[]): string { if (results.length === 0) { return 'No cases found matching your search query.'; } let output = `Found ${results.length} case${results.length === 1 ? '' : 's'}:\n\n`; for (const result of results) { // Citation or URI as header const citation = result.neutralCitation || result.documentUri; output += `**${citation}**\n`; // Title output += `${result.title}\n`; // Court and date const parts: string[] = []; if (result.court) parts.push(result.court); if (result.date) parts.push(result.date); if (parts.length > 0) { output += `${parts.join(' | ')}\n`; } // Snippet if available if (result.snippet) { const cleanSnippet = result.snippet .replace(/\s+/g, ' ') .trim() .substring(0, 200); output += `> ${cleanSnippet}${cleanSnippet.length >= 200 ? '...' : ''}\n`; } // Add document links output += `[View on TNA](${result.urls.web}) | [PDF](${result.urls.pdf})\n`; output += '\n'; } return output.trim(); } // ============================================================================ // CASE CONTENT FORMATTER // ============================================================================ export interface FormatOptions { includeMetadata?: boolean; paragraphRange?: string; } export function formatCaseContent( caseData: CaseContent, options: FormatOptions = {} ): string { const { includeMetadata = true, paragraphRange } = options; let output = ''; // Metadata header if (includeMetadata) { output += `# ${caseData.metadata.title}\n\n`; if (caseData.metadata.neutralCitation) { output += `**Citation:** ${caseData.metadata.neutralCitation}\n`; } output += `**Court:** ${caseData.metadata.courtName || caseData.metadata.court}\n`; if (caseData.metadata.date) { output += `**Date:** ${caseData.metadata.date}\n`; } if (caseData.judges && caseData.judges.length > 0) { output += `**Judges:** ${caseData.judges.join(', ')}\n`; } if (caseData.parties) { if (caseData.parties.claimants.length > 0) { output += `**Claimant(s):** ${caseData.parties.claimants.join(', ')}\n`; } if (caseData.parties.defendants.length > 0) { output += `**Defendant(s):** ${caseData.parties.defendants.join(', ')}\n`; } } // Add document links output += `\n[View on TNA](${caseData.metadata.urls.web}) | [PDF](${caseData.metadata.urls.pdf})\n`; output += '\n---\n\n'; } // Apply paragraph range and truncation const { paragraphs, truncated, remaining } = applyParaRange( caseData.paragraphs, paragraphRange ); // Format paragraphs with numbers for citation for (const para of paragraphs) { output += `[${para.number}] ${para.text}\n\n`; } // Truncation notice if (truncated) { output += `---\n\n`; output += `*Output truncated. ${remaining} paragraph${remaining === 1 ? '' : 's'} remaining. `; const lastPara = paragraphs[paragraphs.length - 1]; if (lastPara) { output += `Use the paragraphs parameter (e.g., "${lastPara.number + 1}-${lastPara.number + 50}") to retrieve more.*\n`; } } return output.trim(); } // ============================================================================ // CITATIONS FORMATTER // ============================================================================ export function formatCitations( citations: { citing: any[]; cited: any[] }, direction: 'citing' | 'cited' | 'both' ): string { let output = ''; if (direction === 'citing' || direction === 'both') { output += `## Cases Citing This Decision\n\n`; if (citations.citing.length === 0) { output += `No citing cases found.\n\n`; } else { for (const c of citations.citing) { output += `- **${c.citation || c.title}** (${c.court}, ${c.date})\n`; } output += '\n'; } } if (direction === 'cited' || direction === 'both') { output += `## Cases Cited By This Decision\n\n`; if (citations.cited.length === 0) { output += `No cited cases found.\n\n`; } else { for (const c of citations.cited) { output += `- **${c.citation || c.title}** (${c.court}, ${c.date})\n`; } output += '\n'; } } return output.trim(); }