Spaces:
Paused
Paused
| // Pure, framework-free presentation helpers — unit-testable in node. | |
| // The backend cites sources by their stored path (e.g. an upload path like | |
| // "/app/data/storage/uploads/2024/report.pdf"). Those internal paths must never | |
| // be shown to users; we surface a clean, human filename instead and keep the | |
| // raw value only behind a debug disclosure. | |
| /** True for values that look like an internal storage / upload path. */ | |
| export function isInternalPath(source: string): boolean { | |
| if (!source) return false; | |
| return ( | |
| source.includes("/app/data/") || | |
| source.includes("/storage/") || | |
| source.includes("/uploads/") || | |
| /^[a-z]+:\/\//i.test(source) === false && source.includes("/") && source.startsWith("/") | |
| ); | |
| } | |
| /** | |
| * Clean a citation source into a readable, user-facing label. | |
| * - strips directory paths → keeps the filename | |
| * - URL-decodes percent-encoding | |
| * - drops a leading content-hash/uuid prefix like "a1b2c3d4_name.pdf" | |
| * - falls back to the original (trimmed) string when nothing to clean | |
| */ | |
| export function displaySource(source: string | null | undefined): string { | |
| if (!source) return "Untitled source"; | |
| let s = String(source).trim(); | |
| if (!s) return "Untitled source"; | |
| // Keep real http(s) URLs but show just host + last path segment. | |
| const urlMatch = /^(https?:\/\/)([^/]+)(\/.*)?$/i.exec(s); | |
| if (urlMatch) { | |
| const host = urlMatch[2].replace(/^www\./, ""); | |
| const tail = (urlMatch[3] || "").split("/").filter(Boolean).pop(); | |
| return tail ? `${host}/${decodeURIComponent(tail)}` : host; | |
| } | |
| // Otherwise treat as a filesystem path: take the basename. | |
| s = s.replace(/\\/g, "/"); | |
| const base = s.split("/").filter(Boolean).pop() || s; | |
| let name = base; | |
| try { | |
| name = decodeURIComponent(base); | |
| } catch { | |
| /* leave as-is on malformed encoding */ | |
| } | |
| // Drop a leading hex/uuid hash prefix used for dedupe (e.g. 8+ hex then _ or -). | |
| name = name.replace(/^[0-9a-f]{8,}[._-]/i, ""); | |
| return name || "Untitled source"; | |
| } | |
| /** Short relative-time label from an ISO timestamp. */ | |
| export function timeAgo(iso: string | null | undefined, now: number = Date.now()): string { | |
| if (!iso) return "never"; | |
| const t = new Date(iso).getTime(); | |
| if (Number.isNaN(t)) return "unknown"; | |
| const mins = Math.round((now - t) / 60000); | |
| if (mins < 1) return "just now"; | |
| if (mins < 60) return `${mins}m ago`; | |
| const hrs = Math.round(mins / 60); | |
| if (hrs < 24) return `${hrs}h ago`; | |
| return `${Math.round(hrs / 24)}d ago`; | |
| } | |
| /** Map a 0..1 evidence-coverage score to a strength tier + label. */ | |
| export type EvidenceTier = "strong" | "weak" | "none"; | |
| export function coverageTier(coverage: number): EvidenceTier { | |
| const pct = Math.max(0, Math.min(1, coverage)); | |
| if (pct >= 0.6) return "strong"; | |
| if (pct >= 0.35) return "weak"; | |
| return "none"; | |
| } | |
| /** | |
| * Heuristic: does this question ask about the *collection itself* (what's | |
| * indexed, languages, file types, counts) rather than the documents' content? | |
| * Such questions should render a corpus-inventory answer, not an evidence | |
| * failure (e.g. "Is there any document in Arabic in my collection?"). | |
| */ | |
| export function isInventoryQuestion(q: string): boolean { | |
| if (!q) return false; | |
| const s = q.toLowerCase(); | |
| const corpusWord = /\b(corpus|collection|knowledge base|index(?:ed)?|library|uploaded|ingest(?:ed)?|documents?|files?|docs?)\b/.test( | |
| s, | |
| ); | |
| const inventoryIntent = | |
| /\b(how many|what|which|list|are there|is there|any|do you have|contain|languages?|file types?|formats?|last (?:indexed|updated)|what'?s in)\b/.test( | |
| s, | |
| ); | |
| // Strong single-phrase triggers. | |
| if (/\b(in my (?:collection|corpus|library)|in the (?:collection|corpus))\b/.test(s)) return true; | |
| if (/\bwhat (?:documents?|files?|docs?) /.test(s)) return true; | |
| if (/\b(languages?|file types?|formats?)\b/.test(s) && corpusWord) return true; | |
| // "Last document added/uploaded/ingested" and similar recency queries. | |
| if (/\b(last|latest|most recent|recently)\b.*\b(document|file|doc|upload|ingest|added)\b/.test(s)) return true; | |
| if (/\bwhat.*\b(last|latest|most recent)\b.*\b(document|file|upload)\b/.test(s)) return true; | |
| if (/\b(last|latest)\b.*\b(document|file)\b.*(add|upload|ingest)/.test(s)) return true; | |
| return corpusWord && inventoryIntent && s.length < 120; | |
| } | |
| /** Human label for a relevance/score value in [0,1]. */ | |
| export function relevanceLabel(score: number): string { | |
| if (score >= 0.66) return "high"; | |
| if (score >= 0.4) return "medium"; | |
| return "low"; | |
| } | |
| /** | |
| * Heuristic: does this question request a corpus management action (delete, | |
| * clear, reset) rather than asking about corpus content? | |
| */ | |
| export function isCorpusManagementQuestion(q: string): boolean { | |
| if (!q) return false; | |
| const s = q.toLowerCase(); | |
| // Explicit destructive intent | |
| if (/\b(delete|remove|wipe|clear|purge|erase|drop|reset)\b/.test(s) && | |
| /\b(document|file|corpus|collection|index|vector|upload|pdf|docx|everything|all|data|last)\b/.test(s)) return true; | |
| if (/\b(how|can i|how do i|can you)\b.*\b(delete|remove|clear|wipe)\b/.test(s)) return true; | |
| if (/\bremove.*permanently\b/.test(s) || /\bdelete.*permanently\b/.test(s)) return true; | |
| if (/\b(clear|wipe|reset)\b.*\b(corpus|collection|vectors?|index|everything|all)\b/.test(s)) return true; | |
| if (/\b(delete|remove)\b.*\b(last|latest|most recent)\b.*\b(document|file|upload)\b/.test(s)) return true; | |
| return false; | |
| } | |