diff --git a/aiui/packages/app/src/composables/__tests__/contentFiltering.test.ts b/aiui/packages/app/src/composables/__tests__/contentFiltering.test.ts new file mode 100644 index 00000000..24cb732b --- /dev/null +++ b/aiui/packages/app/src/composables/__tests__/contentFiltering.test.ts @@ -0,0 +1,34 @@ +// Regression: operator phrasing must not be mistaken for content queries. +// +// Reported on-device 2026-08-06 — the content panel showed "Podcast +// recommendations" for a query that had nothing to do with podcasts. Both +// classifiers (this one and ChatPage's loader label) matched a bare `show`, +// which is how an operator phrases most requests to a node assistant: +// "show me my files", "show the logs", "show installed apps". +import { describe, it, expect } from 'vitest' +import { preferredFirstTab } from '../contentFiltering' + +describe('preferredFirstTab — operator phrasing is not a content query', () => { + it('does not classify "show me ..." as a podcast query', () => { + expect(preferredFirstTab('show me my files')).not.toBe('podcast') + expect(preferredFirstTab('show the filebrowser logs')).not.toBe('podcast') + expect(preferredFirstTab('show installed apps')).not.toBe('podcast') + }) + + it('still classifies genuine podcast queries', () => { + expect(preferredFirstTab('recommend me a podcast')).toBe('podcast') + expect(preferredFirstTab('any good episode about bitcoin?')).toBe('podcast') + // NB: a bare "listen" belongs to the song rule, which is checked + // first — only the explicit "listen to a podcast" phrasing lands here. + expect(preferredFirstTab('listen to a podcast about bitcoin')).toBe('podcast') + }) + + it('keeps tv-show detection, which needs the two-word form', () => { + expect(preferredFirstTab('best tv show to binge')).toBe('tvshow') + }) + + it('still classifies films and music', () => { + expect(preferredFirstTab('recommend me 10 scifi films')).toBe('film') + expect(preferredFirstTab('good album for working')).toBe('song') + }) +}) diff --git a/aiui/packages/app/src/composables/contentFiltering.ts b/aiui/packages/app/src/composables/contentFiltering.ts index 62dae6a1..83441111 100644 --- a/aiui/packages/app/src/composables/contentFiltering.ts +++ b/aiui/packages/app/src/composables/contentFiltering.ts @@ -167,9 +167,17 @@ export function extractQueryContext(q: string): string { export function preferredFirstTab(userQuery: string): ContentTab | null { const q = userQuery.toLowerCase().trim() - if (/\b(film|movie|movies)\b/.test(q)) return 'film' + // `films` (plural) was missing: "recommend me 10 scifi films" — the + // operator's own words — matched nothing and opened no content tab. + if (/\b(film|films|movie|movies)\b/.test(q)) return 'film' + // Podcast is checked BEFORE song because its words are the specific + // ones: "listen to a podcast" must not be swallowed by the song rule's + // bare `listen`. Conversely `show` is NOT a podcast word — it is how + // operators phrase almost every request ("show me my files"), and it + // made unrelated queries render "Podcast recommendations". `tv show` + // keeps its own two-word form in the tvshow rule below. + if (/\b(podcast|episode)\b/.test(q)) return 'podcast' if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song' - if (/\b(podcast|episode|show|listen to)\b/.test(q)) return 'podcast' if (/\b(book|books|read|reading|novel|author|nonfiction|non-fiction)\b/.test(q)) return 'book' if (/\b(tv\b|tv show|tv series|series|television|streaming|binge|watch)\b/.test(q)) return 'tvshow' if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image' diff --git a/aiui/packages/app/src/pages/ChatPage.vue b/aiui/packages/app/src/pages/ChatPage.vue index 026c7d39..83099016 100644 --- a/aiui/packages/app/src/pages/ChatPage.vue +++ b/aiui/packages/app/src/pages/ChatPage.vue @@ -641,15 +641,16 @@ const loaderContextType = computed(() => { if (panelOpen.value) return contentType.value const lastUser = [...chatStore.messages].reverse().find((m) => m.role === 'user') const q = (lastUser?.content ?? '').toLowerCase() + // Podcast before song: its words are the specific ones, so "listen to a + // podcast" must not be swallowed by the song rule's bare `listen`. + // Mirrors contentFiltering.ts's preferredFirstTab ordering — these two + // classifiers label the same panel and must not disagree. + if (/\b(podcast|episode)\b/.test(q)) return 'podcast' if (/\b(song|music|track|album|band|artist|listen)\b/.test(q)) return 'song' if (/\b(book|novel|read|author|fiction|nonfiction|memoir)\b/.test(q)) return 'book' if (/\b(tv show|tv series|series|television|binge|season)\b/.test(q)) return 'tvshow' if (/\b(image|images|photo|photos|picture|pictures|screenshot|gallery|artwork|illustration)\b/.test(q)) return 'image' if (/\b(restaurant|restaurants|place|places|food|eat|dining|cafe|bar|pub|brunch|lunch|dinner)\b/.test(q)) return 'film' - // NOT a bare `show`: "show me my files" / "show the logs" is the most - // common phrasing in an operator chat, and it was labelling every such - // query "Podcast recommendations" (reported on-device 2026-08-06). - if (/\b(podcast|episode|listen to)\b/.test(q)) return 'podcast' if (/\b(news|latest|recent|current|what'?s happening|what are people saying)\b/.test(q)) return 'news' if (/\b(bip|protocol|debate|sentiment|bearish|bull case|macro)\b/.test(q)) return 'magazine' if (/\b(website|websites|where to check|best places|check online|resources?|sources?)\b/.test(q)) return 'websites'