From 10e12a329f0652a2270d2b988eae42567b792cef Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 3 Mar 2026 15:53:56 +0000 Subject: [PATCH] fix(app): harden brief extraction and default to fastest model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default chatCollapsed to true so PromptIndex shows on load - Default AI model to claude-haiku-4.5 for faster responses - Fix extractMagazineSections to handle numbered/bullet bold lists without markdown headings (e.g. "1. **Title** — content") - Fix "For deeper coverage" stripping for bold-formatted text - Expand hasMagazine detection with more financial/news keywords Co-Authored-By: Claude Opus 4.6 --- .../app/src/composables/contentExtraction.ts | 100 +++++++++++++++++- packages/app/src/composables/useAI.ts | 4 +- .../app/src/composables/useContentPanel.ts | 4 +- packages/app/src/stores/chat.ts | 2 +- 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/packages/app/src/composables/contentExtraction.ts b/packages/app/src/composables/contentExtraction.ts index 63a99857..955b8a62 100644 --- a/packages/app/src/composables/contentExtraction.ts +++ b/packages/app/src/composables/contentExtraction.ts @@ -161,7 +161,7 @@ export function extractMagazineSections(text: string): MagazineSection[] { let cleanedText = text .replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '') - .replace(/\n+For deeper[^:]*:[\s\S]*$/im, '') + .replace(/\n+\*{0,2}For deeper[^:]*:[\s\S]*$/im, '') const headingPositions: { title: string; start: number; contentStart: number }[] = [] const headingLineRe = /^#{2,3}\s+(.+)$/gm @@ -223,6 +223,104 @@ export function extractMagazineSections(text: string): MagazineSection[] { } } + // ── Fallback: numbered/bullet lists with bold titles ────────────── + // Handles responses like: "1. **Strong price recovery** — BTC climbed..." + if (sections.length === 0) { + // Detect bold pseudo-headings on their own line: "**Key developments today:**" + const boldHeadingRe = /(?:^|\n)\s*\*\*([^*]+?)\*\*\s*:?\s*(?:\n|$)/g + const boldHeadings: { title: string; start: number; end: number }[] = [] + while ((m = boldHeadingRe.exec(cleanedText)) !== null) { + const title = m[1].trim() + // Skip if preceded by a list marker (it's a list item, not a heading) + const before = cleanedText.slice(Math.max(0, m.index - 10), m.index) + if (/\d+\.\s*$/.test(before) || /[-•]\s*$/.test(before)) continue + if (title.length > 2 && title.length < 100) { + boldHeadings.push({ title, start: m.index, end: m.index + m[0].length }) + } + } + + // Try numbered items: "1. **Title** — content" + const numberedRe = /(?:^|\n)\s*\d+\.\s*\*\*([^*]+)\*\*\s*[-–—:]+\s*/g + const numberedItems: { title: string; start: number; contentStart: number }[] = [] + while ((m = numberedRe.exec(cleanedText)) !== null) { + const title = m[1].trim() + if (title.length > 1) { + numberedItems.push({ title, start: m.index, contentStart: m.index + m[0].length }) + } + } + + // Find group header for numbered items + let group: string | undefined + if (numberedItems.length > 0 && boldHeadings.length > 0) { + const firstItemStart = numberedItems[0].start + const precedingHeading = boldHeadings.filter(h => h.end <= firstItemStart).pop() + if (precedingHeading) group = precedingHeading.title + } + + for (let i = 0; i < numberedItems.length; i++) { + const item = numberedItems[i] + const nextStart = i + 1 < numberedItems.length ? numberedItems[i + 1].start : cleanedText.length + const rawContent = cleanMagazineContent(cleanedText.slice(item.contentStart, nextStart)) + if (rawContent.length >= 15) { + addSection(sections, item.title, rawContent, seen, group) + } + } + + // Also try bullet items: "- **Title** — content" + if (sections.length === 0) { + const bulletRe = /(?:^|\n)\s*[-•]\s*\*\*([^*]+)\*\*\s*[-–—:]+\s*/g + const bulletItems: { title: string; start: number; contentStart: number }[] = [] + while ((m = bulletRe.exec(cleanedText)) !== null) { + const title = m[1].trim() + if (title.length > 1) { + bulletItems.push({ title, start: m.index, contentStart: m.index + m[0].length }) + } + } + + for (let i = 0; i < bulletItems.length; i++) { + const item = bulletItems[i] + const nextStart = i + 1 < bulletItems.length ? bulletItems[i + 1].start : cleanedText.length + const rawContent = cleanMagazineContent(cleanedText.slice(item.contentStart, nextStart)) + if (rawContent.length >= 15) { + addSection(sections, item.title, rawContent, seen) + } + } + } + + // Also try standalone bold paragraphs: "**Title** — content\n\n**Next** — ..." + if (sections.length === 0) { + const boldParaRe = /(?:^|\n)\s*\*\*([^*]{3,60})\*\*\s*[-–—:]+\s*([\s\S]*?)(?=\n\s*\*\*[^*]{3,60}\*\*\s*[-–—:]|\n{2,}\*\*[^*]+\*\*\s*:|\s*$)/g + while ((m = boldParaRe.exec(cleanedText)) !== null) { + const title = m[1].trim() + const content = cleanMagazineContent(m[2].trim()) + if (title.length > 2 && content.length >= 15) { + addSection(sections, title, content, seen) + } + } + } + + // Extract intro paragraph as Summary + if (sections.length > 0) { + const firstStructured = numberedItems.length > 0 + ? numberedItems[0].start + : cleanedText.length + // Bold pseudo-heading before items marks the end of the intro + const precedingBoldHeading = boldHeadings.length > 0 && boldHeadings[0].start < firstStructured + ? boldHeadings[0].start + : firstStructured + const fallbackIntroEnd = Math.min(firstStructured, precedingBoldHeading) + if (fallbackIntroEnd > 30) { + const intro = cleanedText.slice(0, fallbackIntroEnd) + .replace(/\*\*/g, '') + .replace(/^[#*_\s-]+/gm, '') + .trim() + if (intro.length > 30 && !seen.has('Summary')) { + addSection(sections, 'Summary', intro, seen) + } + } + } + } + const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText) if (closingMatch && !seen.has('Key')) { addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen) diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 8502e5e8..1a28c457 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -62,7 +62,7 @@ ${podcastContext}` const activeProvider = ref('claude') -const activeModel = ref('claude-sonnet-4') +const activeModel = ref('claude-haiku-4.5') const availableProviders = computed(() => { const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ @@ -70,9 +70,9 @@ const availableProviders = computed(() => { id: 'claude', name: 'Claude (Max)', models: [ + { id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' }, { id: 'claude-sonnet-4', name: 'Claude Sonnet 4' }, { id: 'claude-opus-4', name: 'Claude Opus 4' }, - { id: 'claude-haiku-3.5', name: 'Claude 3.5 Haiku' }, ], }, ] diff --git a/packages/app/src/composables/useContentPanel.ts b/packages/app/src/composables/useContentPanel.ts index 20fc481d..4f757a5b 100644 --- a/packages/app/src/composables/useContentPanel.ts +++ b/packages/app/src/composables/useContentPanel.ts @@ -91,7 +91,7 @@ export function useContentPanel() { books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0 const hasMagazine = magazineSections.length >= 1 && ( isNewsQuery(userQuery) || isNewsLikeResponse(text) || - /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text) || + /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) || (!hasAnyOtherContent && !hasWebsites && magazineSections.length >= 2) ) @@ -239,7 +239,7 @@ export function useContentPanel() { books.length > 0 || tvSeries.length > 0 || images.length > 0 || places.length > 0 const hasMagazine = magazineSections.length >= 1 && ( isNewsQuery(userQuery) || isNewsLikeResponse(text) || - /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening/i.test(text) || + /sentiment|bearish|bull case|macro|%|BTC|bitcoin|BIP|protocol|debate|what'?s happening|ETF|inflow|trading at|key developments|price recovery|institutional|analyst watch|market cap/i.test(text) || (!hasAnyOtherInline && !hasWebsites && magazineSections.length >= 2) ) const tabs = filterTabsByContext(userQuery, films.length > 0, songs.length > 0, podcasts.length > 0, books.length > 0, tvSeries.length > 0, images.length > 0, places.length > 0, hasNews, hasWebsites, hasMagazine) diff --git a/packages/app/src/stores/chat.ts b/packages/app/src/stores/chat.ts index 3da7d51d..51f2d1a0 100644 --- a/packages/app/src/stores/chat.ts +++ b/packages/app/src/stores/chat.ts @@ -53,7 +53,7 @@ export const useChatStore = defineStore('chat', () => { const panelSide = ref<'left' | 'right'>(savedSide ?? 'left') const webSearchEnabled = ref(localStorage.getItem('aiui-web-search') !== 'false') - const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') === 'true') + const chatCollapsed = ref(localStorage.getItem('aiui-chat-collapsed') !== 'false') // Load chats from server on startup — _loaded gate prevents the watcher // from overwriting the file with empty data before the load completes