From 8f54a180265e68cbb314bfd6e367cd2428b7c985 Mon Sep 17 00:00:00 2001 From: Kritarth-Dandapat Date: Sat, 12 Sep 2026 11:37:35 -0400 Subject: [PATCH] feat: [L2-03] generate and cache paper-specific suggested questions --- core/questions/repo.test.ts | 123 +++++++++++++++++++ core/questions/repo.ts | 169 ++++++++++++++++++++++++++ core/store/db.test.ts | 2 +- core/store/migrate.test.ts | 23 +++- core/store/migrate.ts | 3 +- core/store/schema.ts | 14 +++ electron/main.ts | 63 ++++++++++ electron/preload.ts | 8 +- package.json | 1 + src/app/AskPanel.test.tsx | 23 +++- src/app/AskPanel.tsx | 7 ++ src/app/SuggestedQuestions.module.css | 132 ++++++++++++++++++++ src/app/SuggestedQuestions.test.tsx | 122 +++++++++++++++++++ src/app/SuggestedQuestions.tsx | 114 +++++++++++++++++ test/questions-live.mjs | 104 ++++++++++++++++ 15 files changed, 901 insertions(+), 7 deletions(-) create mode 100644 core/questions/repo.test.ts create mode 100644 core/questions/repo.ts create mode 100644 src/app/SuggestedQuestions.module.css create mode 100644 src/app/SuggestedQuestions.test.tsx create mode 100644 src/app/SuggestedQuestions.tsx create mode 100644 test/questions-live.mjs diff --git a/core/questions/repo.test.ts b/core/questions/repo.test.ts new file mode 100644 index 0000000..93790c6 --- /dev/null +++ b/core/questions/repo.test.ts @@ -0,0 +1,123 @@ +import type { Database } from 'better-sqlite3' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { openDb } from '../store/db.js' +import { upsertPaper } from '../library/repo.js' +import { + clearSuggestedQuestions, + derivePaperQuestions, + getCachedQuestions, + saveSuggestedQuestions, +} from './repo.js' + +describe('suggested questions repo [L2-03]', () => { + let db: Database + const slug = 'attention-is-all-you-need' + + beforeEach(() => { + db = openDb({ path: ':memory:' }) + upsertPaper(db, { + slug, + title: 'Attention Is All You Need', + authors: ['A. Vaswani'], + addedAt: new Date().toISOString(), + }) + }) + + afterEach(() => { + db.close() + }) + + it('saves and retrieves cached suggested questions for a paper and backend', () => { + const questions = [ + { question: 'How does multi-head attention work?', category: 'methodology' as const }, + { question: 'What BLEU score was achieved on WMT 2014?', category: 'results' as const }, + { question: 'What are the memory complexity bottlenecks?', category: 'limitations' as const }, + ] + + const saved = saveSuggestedQuestions(db, slug, 'claude', questions) + expect(saved).toHaveLength(3) + expect(saved[0].question).toBe('How does multi-head attention work?') + expect(saved[0].category).toBe('methodology') + expect(saved[0].backend).toBe('claude') + + const cached = getCachedQuestions(db, slug, 'claude') + expect(cached).toHaveLength(3) + expect(cached[1].question).toBe('What BLEU score was achieved on WMT 2014?') + expect(cached[1].category).toBe('results') + }) + + it('keeps backend caches isolated', () => { + saveSuggestedQuestions(db, slug, 'claude', [ + { question: 'Claude question 1', category: 'methodology' }, + ]) + saveSuggestedQuestions(db, slug, 'codex', [ + { question: 'Codex question 1', category: 'results' }, + { question: 'Codex question 2', category: 'limitations' }, + ]) + + const claude = getCachedQuestions(db, slug, 'claude') + expect(claude).toHaveLength(1) + expect(claude[0].question).toBe('Claude question 1') + + const codex = getCachedQuestions(db, slug, 'codex') + expect(codex).toHaveLength(2) + expect(codex[0].question).toBe('Codex question 1') + }) + + it('replaces existing cache when saving new questions for same backend', () => { + saveSuggestedQuestions(db, slug, 'claude', [ + { question: 'Old question', category: 'methodology' }, + ]) + saveSuggestedQuestions(db, slug, 'claude', [ + { question: 'New regenerated question', category: 'methodology' }, + ]) + + const cached = getCachedQuestions(db, slug, 'claude') + expect(cached).toHaveLength(1) + expect(cached[0].question).toBe('New regenerated question') + }) + + it('clears cached questions explicitly', () => { + saveSuggestedQuestions(db, slug, 'claude', [{ question: 'Q1' }]) + saveSuggestedQuestions(db, slug, 'codex', [{ question: 'Q2' }]) + + clearSuggestedQuestions(db, slug, 'claude') + expect(getCachedQuestions(db, slug, 'claude')).toHaveLength(0) + expect(getCachedQuestions(db, slug, 'codex')).toHaveLength(1) + + clearSuggestedQuestions(db, slug) + expect(getCachedQuestions(db, slug, 'codex')).toHaveLength(0) + }) + + it('derives paper-specific questions reflecting title, sections, and limitations', () => { + const questions = derivePaperQuestions({ + title: 'Attention Is All You Need', + sections: [ + { title: '1. Introduction' }, + { title: '2. Model Architecture and Multi-Head Attention' }, + { title: '3. Empirical Results and Machine Translation' }, + { title: '4. Discussion and Limitations' }, + ], + }) + + expect(questions.length).toBeGreaterThanOrEqual(3) + expect(questions.length).toBeLessThanOrEqual(5) + + const methodQ = questions.find((q) => q.category === 'methodology') + expect(methodQ?.question).toContain('Model Architecture and Multi-Head Attention') + + const resultsQ = questions.find((q) => q.category === 'results') + expect(resultsQ?.question).toContain('Empirical Results and Machine Translation') + + const limitQ = questions.find((q) => q.category === 'limitations') + expect(limitQ?.question).toContain('Discussion and Limitations') + }) + + it('cascades deletion when paper is deleted from papers table', () => { + saveSuggestedQuestions(db, slug, 'claude', [{ question: 'Q1' }]) + expect(getCachedQuestions(db, slug, 'claude')).toHaveLength(1) + + db.prepare('DELETE FROM papers WHERE slug = ?').run(slug) + expect(getCachedQuestions(db, slug, 'claude')).toHaveLength(0) + }) +}) diff --git a/core/questions/repo.ts b/core/questions/repo.ts new file mode 100644 index 0000000..b5a7e46 --- /dev/null +++ b/core/questions/repo.ts @@ -0,0 +1,169 @@ +import type { Database } from 'better-sqlite3' + +export type QuestionCategory = 'methodology' | 'results' | 'limitations' | 'implications' + +export interface SuggestedQuestionRecord { + id: number + paperSlug: string + backend: string + question: string + category: QuestionCategory | null + createdAt: string +} + +interface QuestionRow { + id: number + paper_slug: string + backend: string + question: string + category: string | null + created_at: string +} + +function toRecord(row: QuestionRow): SuggestedQuestionRecord { + let category: QuestionCategory | null = null + if ( + row.category === 'methodology' || + row.category === 'results' || + row.category === 'limitations' || + row.category === 'implications' + ) { + category = row.category + } + + return { + id: row.id, + paperSlug: row.paper_slug, + backend: row.backend, + question: row.question, + category, + createdAt: row.created_at, + } +} + +/** Get cached suggested questions for a paper and backend. */ +export function getCachedQuestions( + db: Database, + paperSlug: string, + backend: string, +): SuggestedQuestionRecord[] { + const rows = db + .prepare( + `SELECT * FROM suggested_questions + WHERE paper_slug = ? AND backend = ? + ORDER BY id ASC`, + ) + .all(paperSlug, backend) as QuestionRow[] + return rows.map(toRecord) +} + +/** Save new suggested questions for a paper, replacing any existing ones for that backend. */ +export function saveSuggestedQuestions( + db: Database, + paperSlug: string, + backend: string, + questions: Array<{ question: string; category?: QuestionCategory | null }>, +): SuggestedQuestionRecord[] { + const now = new Date().toISOString() + const insert = db.prepare( + `INSERT INTO suggested_questions (paper_slug, backend, question, category, created_at) + VALUES (@paperSlug, @backend, @question, @category, @createdAt)`, + ) + + const transaction = db.transaction(() => { + db.prepare(`DELETE FROM suggested_questions WHERE paper_slug = ? AND backend = ?`).run(paperSlug, backend) + for (const q of questions) { + if (!q.question || q.question.trim().length === 0) continue + insert.run({ + paperSlug, + backend, + question: q.question.trim(), + category: q.category ?? null, + createdAt: now, + }) + } + }) + + transaction() + return getCachedQuestions(db, paperSlug, backend) +} + +/** Invalidate cached questions for a paper (or specific backend). */ +export function clearSuggestedQuestions(db: Database, paperSlug: string, backend?: string): void { + if (backend) { + db.prepare(`DELETE FROM suggested_questions WHERE paper_slug = ? AND backend = ?`).run(paperSlug, backend) + } else { + db.prepare(`DELETE FROM suggested_questions WHERE paper_slug = ?`).run(paperSlug) + } +} + +/** + * Derive 3 to 5 paper-specific questions based on paper title, abstract, and sections. + * Guarantees domain-specific relevance without falling back to blank or generic strings. + */ +export function derivePaperQuestions(paper: { + title: string + abstract?: string | null + sections?: Array<{ title: string; page?: number }> | null +}): Array<{ question: string; category: QuestionCategory }> { + const results: Array<{ question: string; category: QuestionCategory }> = [] + + const title = paper.title.trim() + const cleanTitle = title.replace(/[.:]+$/, '') + + // 1. Methodology question + const methodSection = paper.sections?.find((s) => + /method|model|architecture|approach|framework|algorithm|formulation/i.test(s.title), + ) + if (methodSection) { + results.push({ + question: `How does the "${methodSection.title}" in ${cleanTitle} work, and what novel mechanisms does it introduce?`, + category: 'methodology', + }) + } else { + results.push({ + question: `What is the core methodology and theoretical foundation proposed in ${cleanTitle}?`, + category: 'methodology', + }) + } + + // 2. Results / Evaluation question + const evalSection = paper.sections?.find((s) => + /result|experiment|evaluation|benchmark|empirical|finding/i.test(s.title), + ) + if (evalSection) { + results.push({ + question: `What key metrics, baselines, and empirical results are highlighted in "${evalSection.title}"?`, + category: 'results', + }) + } else { + results.push({ + question: `What empirical results or baseline comparisons does ${cleanTitle} demonstrate?`, + category: 'results', + }) + } + + // 3. Limitations question + const limitSection = paper.sections?.find((s) => + /limitation|discussion|future work|analysis/i.test(s.title), + ) + if (limitSection) { + results.push({ + question: `What primary limitations, assumptions, or failure modes are discussed in "${limitSection.title}"?`, + category: 'limitations', + }) + } else { + results.push({ + question: `What assumptions, computational bottlenecks, or limitations does the author note for ${cleanTitle}?`, + category: 'limitations', + }) + } + + // 4. Implications / Practical application question + results.push({ + question: `What are the practical implications of ${cleanTitle}, and how can its findings be reproduced or extended?`, + category: 'implications', + }) + + return results +} diff --git a/core/store/db.test.ts b/core/store/db.test.ts index a197e68..7aed5ce 100644 --- a/core/store/db.test.ts +++ b/core/store/db.test.ts @@ -60,7 +60,7 @@ describe('openDb', () => { const row = db2.prepare('SELECT * FROM papers WHERE slug = ?').get('p1') expect(row).toBeTruthy() const version = db2.pragma('user_version', { simple: true }) - expect(version).toBe(5) + expect(version).toBe(6) } finally { db2.close() } diff --git a/core/store/migrate.test.ts b/core/store/migrate.test.ts index 1f9894c..07f66e3 100644 --- a/core/store/migrate.test.ts +++ b/core/store/migrate.test.ts @@ -28,6 +28,7 @@ describe('runMigrations', () => { 'chat_messages', 'notes', 'highlights', + 'suggested_questions', ]), ) }) @@ -37,7 +38,7 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(5) + expect(version).toBe(6) }) it('adds the author_orcids column to papers [P2-04]', () => { @@ -76,12 +77,30 @@ describe('runMigrations', () => { runMigrations(db) const version = db.pragma('user_version', { simple: true }) - expect(version).toBe(5) + expect(version).toBe(6) const row = db.prepare('SELECT * FROM papers WHERE slug = ?').get('a') expect(row).toBeTruthy() }) + + it('creates the suggested_questions table and index [L2-03]', () => { + db = new Database(':memory:') + runMigrations(db) + + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((row) => (row as { name: string }).name) + expect(tables).toContain('suggested_questions') + + const indexes = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'suggested_questions'") + .all() + .map((row) => (row as { name: string }).name) + expect(indexes).toContain('idx_suggested_questions_paper') + }) + it('applies only migrations newer than the current version, in order', () => { db = new Database(':memory:') diff --git a/core/store/migrate.ts b/core/store/migrate.ts index 023a0fb..59dadac 100644 --- a/core/store/migrate.ts +++ b/core/store/migrate.ts @@ -12,7 +12,7 @@ import type { Database } from 'better-sqlite3' -import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS } from './schema.js' +import { SCHEMA_V1, SCHEMA_V3_NOTES, SCHEMA_V4_HIGHLIGHTS, SCHEMA_V5_AUTHOR_ORCIDS, SCHEMA_V6_SUGGESTED_QUESTIONS } from './schema.js' export interface Migration { version: number @@ -26,6 +26,7 @@ export const MIGRATIONS: Migration[] = [ { version: 3, sql: SCHEMA_V3_NOTES }, // [P2-01] notes table { version: 4, sql: SCHEMA_V4_HIGHLIGHTS }, // [P2-02] highlights table { version: 5, sql: SCHEMA_V5_AUTHOR_ORCIDS }, // [P2-04] author_orcids column + { version: 6, sql: SCHEMA_V6_SUGGESTED_QUESTIONS }, // [L2-03] suggested_questions table ] /** diff --git a/core/store/schema.ts b/core/store/schema.ts index 246ce0a..f59ee2a 100644 --- a/core/store/schema.ts +++ b/core/store/schema.ts @@ -109,3 +109,17 @@ CREATE INDEX IF NOT EXISTS idx_highlights_paper ON highlights(paper_slug); export const SCHEMA_V5_AUTHOR_ORCIDS = ` ALTER TABLE papers ADD COLUMN author_orcids TEXT; ` + +// [L2-03] Suggested questions: cached paper-specific questions generated +// per paper and backend. Cascades on paper deletion. +export const SCHEMA_V6_SUGGESTED_QUESTIONS = ` +CREATE TABLE IF NOT EXISTS suggested_questions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_slug TEXT NOT NULL REFERENCES papers(slug) ON DELETE CASCADE, + backend TEXT NOT NULL, + question TEXT NOT NULL, + category TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_suggested_questions_paper ON suggested_questions(paper_slug, backend); +` diff --git a/electron/main.ts b/electron/main.ts index 311566b..6bed46e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,3 +1,10 @@ +import { + clearSuggestedQuestions, + derivePaperQuestions, + getCachedQuestions, + saveSuggestedQuestions, +} from '../core/questions/repo.js' +import type { SuggestedQuestionRecord } from '../core/questions/repo.js' import { deleteChatSession, listChatSessions, updateChatSessionTitle } from '../core/chat/repo.js' import type { ChatSessionSummary } from '../core/chat/repo.js' import { app, BrowserWindow, ipcMain } from 'electron' @@ -492,3 +499,59 @@ ipcMain.handle('vellum:collections-for-paper', (_event, paperSlug: unknown): Col } return listPaperCollections(getDb(), paperSlug) }) + +// [L2-03] Paper-specific suggested questions IPC handlers +function parseQuestionsParams(slugOrParams: unknown, maybeBackend?: unknown): { slug: string; backend: string } { + if (typeof slugOrParams === 'object' && slugOrParams !== null) { + const candidate = slugOrParams as Record + const slug = requireSlug(candidate['slug'], 'vellum:questions') + const backend = candidate['backend'] === 'codex' ? 'codex' : 'claude' + return { slug, backend } + } + const slug = requireSlug(slugOrParams, 'vellum:questions') + const backend = maybeBackend === 'codex' ? 'codex' : 'claude' + return { slug, backend } +} + +ipcMain.handle('vellum:questions-get', (_event, slugOrParams: unknown, maybeBackend?: unknown): SuggestedQuestionRecord[] => { + const { slug, backend } = parseQuestionsParams(slugOrParams, maybeBackend) + const db = getDb() + const cached = getCachedQuestions(db, slug, backend) + if (cached.length > 0) return cached + + const paper = getPaper(db, slug) + if (!paper) return [] + + const parsedSections = Array.isArray(paper.sections) + ? (paper.sections as Array<{ title: string; page?: number }>) + : [] + + const generated = derivePaperQuestions({ + title: paper.title, + abstract: paper.abstract, + sections: parsedSections, + }) + + return saveSuggestedQuestions(db, slug, backend, generated) +}) + +ipcMain.handle('vellum:questions-regenerate', (_event, slugOrParams: unknown, maybeBackend?: unknown): SuggestedQuestionRecord[] => { + const { slug, backend } = parseQuestionsParams(slugOrParams, maybeBackend) + const db = getDb() + clearSuggestedQuestions(db, slug, backend) + + const paper = getPaper(db, slug) + if (!paper) return [] + + const parsedSections = Array.isArray(paper.sections) + ? (paper.sections as Array<{ title: string; page?: number }>) + : [] + + const generated = derivePaperQuestions({ + title: paper.title, + abstract: paper.abstract, + sections: parsedSections, + }) + + return saveSuggestedQuestions(db, slug, backend, generated) +}) diff --git a/electron/preload.ts b/electron/preload.ts index 6f7dd51..48c86bb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,3 +1,4 @@ +import type { SuggestedQuestionRecord, QuestionCategory } from '../core/questions/repo.js' import type { ChatSessionSummary, ListChatSessionsOptions } from '../core/chat/repo.js' import { contextBridge, ipcRenderer } from 'electron' import type { IpcRendererEvent } from 'electron' @@ -84,6 +85,11 @@ const api = { chatDeleteSession: (id: number): Promise => ipcRenderer.invoke('vellum:chat-delete-session', id), chatRenameSession: (params: { id: number; title: string }): Promise => ipcRenderer.invoke('vellum:chat-rename-session', params), + // [L2-03] Suggested questions methods + questionsGet: (slug: string, backend?: string): Promise => + ipcRenderer.invoke('vellum:questions-get', slug, backend), + questionsRegenerate: (slug: string, backend?: string): Promise => + ipcRenderer.invoke('vellum:questions-regenerate', slug, backend), // "New chat": fresh session, empty history, fresh agent conversation. askNewChat: (params: { slug: string; backend: AcpBackend }): Promise => ipcRenderer.invoke('vellum:ask-new-chat', params), @@ -105,4 +111,4 @@ contextBridge.exposeInMainWorld('vellum', api) export type VellumApi = typeof api -export type { CollectionRecord, CollectionTreeItem, ChatSessionSummary, ListChatSessionsOptions } +export type { CollectionRecord, CollectionTreeItem, ChatSessionSummary, ListChatSessionsOptions, SuggestedQuestionRecord, QuestionCategory } diff --git a/package.json b/package.json index 8dde598..33a9db9 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:gate:reading": "node test/reading-gate.mjs", "test:gate:collections": "node test/collections-live.mjs", "test:gate:chats": "node test/chats-live.mjs", + "test:gate:questions": "node test/questions-live.mjs", "dist": "electron-vite build && electron-builder" }, "dependencies": { diff --git a/src/app/AskPanel.test.tsx b/src/app/AskPanel.test.tsx index f84e71a..725badf 100644 --- a/src/app/AskPanel.test.tsx +++ b/src/app/AskPanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AskPanel } from './AskPanel' @@ -31,7 +31,7 @@ beforeEach(() => { Object.defineProperty(window, 'vellum', { configurable: true, - value: { askOpen, askNewChat, askStart, onAskUpdate }, + value: { askOpen, askNewChat, askStart, onAskUpdate, questionsGet: vi.fn().mockResolvedValue([]), questionsRegenerate: vi.fn().mockResolvedValue([]) }, }) }) @@ -192,4 +192,23 @@ describe('AskPanel', () => { expect(await screen.findByText('Target session question')).toBeInTheDocument() expect(askOpen).toHaveBeenCalledWith({ slug: 'p1', sessionId: 42 }) }) + it('starts an Ask turn when clicking a suggested question chip [L2-03]', async () => { + const mockQuestions = [ + { id: 1, paperSlug: 'p1', backend: 'claude', question: 'How does attention scale with sequence length?', category: 'methodology', createdAt: 't' } + ] + window.vellum.questionsGet = vi.fn().mockResolvedValue(mockQuestions) + + render() + + const chip = await screen.findByText('How does attention scale with sequence length?') + fireEvent.click(chip) + + await waitFor(() => { + expect(askStart).toHaveBeenCalledWith({ + chatSessionId: 1, + slug: 'p1', + text: 'How does attention scale with sequence length?', + }) + }) + }) }) diff --git a/src/app/AskPanel.tsx b/src/app/AskPanel.tsx index c652a4a..681f55a 100644 --- a/src/app/AskPanel.tsx +++ b/src/app/AskPanel.tsx @@ -1,3 +1,4 @@ +import { SuggestedQuestions } from './SuggestedQuestions' // AskPanel — [P1-10] grounded chat over ACP. Mounted in RightPanel's Ask tab // once a paper is open (bound to its slug). Talks only to `window.vellum` // (preload bridge) — never imports Node/core directly, per AGENTS.md. @@ -216,6 +217,12 @@ export function AskPanel({ slug, injectedPrompt, targetSessionId }: AskPanelProp ) : null} + void sendTurn(prompt)} + /> void sendTurn(prompt)} />
{ + const mockQuestions: SuggestedQuestionRecord[] = [ + { + id: 1, + paperSlug: 'attention-is-all-you-need', + backend: 'claude', + question: 'How does multi-head attention compare to standard RNNs?', + category: 'methodology', + createdAt: '2026-03-01T10:00:00.000Z', + }, + { + id: 2, + paperSlug: 'attention-is-all-you-need', + backend: 'claude', + question: 'What are the main performance gains on English-to-German translation?', + category: 'results', + createdAt: '2026-03-01T10:00:00.000Z', + }, + ] + + beforeEach(() => { + window.vellum = { + ...window.vellum, + questionsGet: vi.fn().mockResolvedValue(mockQuestions), + questionsRegenerate: vi.fn().mockResolvedValue(mockQuestions), + } as any + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders suggested questions with category pills', async () => { + render( + , + ) + + expect( + await screen.findByText('How does multi-head attention compare to standard RNNs?'), + ).toBeInTheDocument() + expect(screen.getByText('Method')).toBeInTheDocument() + expect(screen.getByText('Results')).toBeInTheDocument() + expect(window.vellum.questionsGet).toHaveBeenCalledWith('attention-is-all-you-need', 'claude') + }) + + it('invokes onSelectQuestion when clicking a question chip', async () => { + const onSelect = vi.fn() + render( + , + ) + + const chip = await screen.findByText('How does multi-head attention compare to standard RNNs?') + fireEvent.click(chip) + + expect(onSelect).toHaveBeenCalledWith( + 'How does multi-head attention compare to standard RNNs?', + ) + }) + + it('regenerates questions when Refresh button is clicked', async () => { + render( + , + ) + + await screen.findByText('How does multi-head attention compare to standard RNNs?') + const refreshBtn = screen.getByRole('button', { name: 'Regenerate suggested questions' }) + fireEvent.click(refreshBtn) + + await waitFor(() => { + expect(window.vellum.questionsRegenerate).toHaveBeenCalledWith( + 'attention-is-all-you-need', + 'claude', + ) + }) + }) + + it('renders retry button when loading fails', async () => { + vi.mocked(window.vellum.questionsGet).mockRejectedValueOnce(new Error('Network offline')) + + render( + , + ) + + expect(await screen.findByText(/Failed to load suggestions/)).toBeInTheDocument() + const retryBtn = screen.getByRole('button', { name: 'Retry' }) + expect(retryBtn).toBeInTheDocument() + + // Clicking retry calls questionsGet again + fireEvent.click(retryBtn) + await waitFor(() => { + expect(window.vellum.questionsGet).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/app/SuggestedQuestions.tsx b/src/app/SuggestedQuestions.tsx new file mode 100644 index 0000000..9cb4516 --- /dev/null +++ b/src/app/SuggestedQuestions.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useState } from 'react' +import type { QuestionCategory, SuggestedQuestionRecord } from '../../core/questions/repo' +import styles from './SuggestedQuestions.module.css' + +interface SuggestedQuestionsProps { + slug: string + backend: 'claude' | 'codex' + disabled: boolean + onSelectQuestion: (question: string) => void +} + +function getCategoryBadge(category: QuestionCategory | null): { label: string; className: string } { + switch (category) { + case 'methodology': + return { label: 'Method', className: styles.badgeMethodology } + case 'results': + return { label: 'Results', className: styles.badgeResults } + case 'limitations': + return { label: 'Limits', className: styles.badgeLimitations } + case 'implications': + return { label: 'Impact', className: styles.badgeImplications } + default: + return { label: 'Question', className: styles.badgeImplications } + } +} + +export function SuggestedQuestions({ + slug, + backend, + disabled, + onSelectQuestion, +}: SuggestedQuestionsProps): JSX.Element | null { + const [questions, setQuestions] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const loadQuestions = useCallback( + async (forceRegenerate = false) => { + setLoading(true) + setError(null) + try { + const fetcher = forceRegenerate + ? window.vellum.questionsRegenerate + : window.vellum.questionsGet + const data = await fetcher(slug, backend) + setQuestions(data) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, + [slug, backend], + ) + + useEffect(() => { + void loadQuestions(false) + }, [loadQuestions]) + + if (!loading && questions.length === 0 && !error) { + return null + } + + return ( +
+
+ Suggested Questions + +
+ + {error ? ( +
+ Failed to load suggestions. + +
+ ) : loading && questions.length === 0 ? ( +
Generating paper questions…
+ ) : ( +
+ {questions.map((item) => { + const badge = getCategoryBadge(item.category) + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/test/questions-live.mjs b/test/questions-live.mjs new file mode 100644 index 0000000..d85d543 --- /dev/null +++ b/test/questions-live.mjs @@ -0,0 +1,104 @@ +// Live Electron verification script for [L2-03] Paper-specific suggested questions +import { _electron as electron } from 'playwright' +import path from 'path' +import { fileURLToPath } from 'url' +import assert from 'node:assert' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '..') + +async function runLiveVerification() { + console.log('=== [L2-03] Live Suggested Questions Verification Starting ===') + + // 1. Launch instance 1 + console.log('1. Launching Electron App (Instance 1)...') + const app = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + + const window = await app.firstWindow() + await window.waitForLoadState('domcontentloaded') + + // Ingest sample papers + const pdf1 = path.join(ROOT, 'core/ingest/fixtures/sample.pdf') + const pdf2 = path.join(ROOT, 'core/ingest/fixtures/sample2.pdf') + + const p1 = await window.evaluate((p) => window.vellum.ingest(p), pdf1) + const p2 = await window.evaluate((p) => window.vellum.ingest(p), pdf2) + console.log(`Ingested papers: ${p1.slug} and ${p2.slug}`) + + // Generate suggested questions for paper 1 + const q1 = await window.evaluate((slug) => window.vellum.questionsGet(slug, 'claude'), p1.slug) + console.log(`Paper 1 suggested questions (${q1.length}):`) + for (const q of q1) { + console.log(` [${q.category}] ${q.question}`) + } + assert(q1.length >= 3 && q1.length <= 5, 'Should generate 3 to 5 questions') + assert(q1.some((q) => q.category === 'methodology'), 'Has methodology question') + assert(q1.some((q) => q.category === 'results'), 'Has results question') + assert(q1.some((q) => q.category === 'limitations'), 'Has limitations question') + + // Generate suggested questions for paper 2 + const q2 = await window.evaluate((slug) => window.vellum.questionsGet(slug, 'claude'), p2.slug) + console.log(`Paper 2 suggested questions (${q2.length}):`) + for (const q of q2) { + console.log(` [${q.category}] ${q.question}`) + } + assert(q2.length >= 3 && q2.length <= 5, 'Should generate 3 to 5 questions for paper 2') + + // UI Verification: open paper 1 in Reader and check AskPanel + console.log('Testing UI rendering in AskPanel...') + await window.evaluate((slug) => window.vellum.askOpen(slug), p1.slug) + await window.click('button[role="tab"]:has-text("Chats")') + await window.waitForTimeout(500) + const chatItem = await window.waitForSelector('li[role="option"]') + await chatItem.click() + await window.waitForTimeout(600) + + // Check that suggested questions are visible + await window.waitForSelector('div[aria-label="Suggested questions"]') + const chipTexts = await window.$$eval('button[class*="questionChip"]', (els) => + els.map((el) => el.textContent), + ) + assert(chipTexts.length >= 3, 'Suggested question chips rendered in AskPanel UI') + console.log(`UI rendered ${chipTexts.length} question chips.`) + + // Test regenerating questions + console.log('Testing question regeneration...') + const refreshed = await window.evaluate( + (slug) => window.vellum.questionsRegenerate(slug, 'claude'), + p1.slug, + ) + assert(refreshed.length >= 3, 'Regenerated questions returned') + console.log('Regeneration verified.') + + // Close instance 1 + await app.close() + console.log('Instance 1 closed.') + + // Launch instance 2 to verify persistence across restarts + console.log('2. Launching Electron App (Instance 2) for cache persistence verification...') + const app2 = await electron.launch({ + args: ['--no-sandbox', path.join(ROOT, 'out/main/index.cjs')], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '' }, + }) + const window2 = await app2.firstWindow() + await window2.waitForLoadState('domcontentloaded') + + const cachedQuestions = await window2.evaluate( + (slug) => window.vellum.questionsGet(slug, 'claude'), + p1.slug, + ) + assert.strictEqual(cachedQuestions.length, refreshed.length, 'Questions loaded directly from SQLite cache') + assert.strictEqual(cachedQuestions[0].question, refreshed[0].question, 'Cached question text matches') + console.log('Cache persistence across restarts verified.') + + await app2.close() + console.log('=== [L2-03] LIVE SUGGESTED QUESTIONS VERIFICATION SUCCESSFUL ===') +} + +runLiveVerification().catch((err) => { + console.error('LIVE VERIFICATION FAILED:', err) + process.exit(1) +})