Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions core/questions/repo.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
169 changes: 169 additions & 0 deletions core/questions/repo.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion core/store/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
23 changes: 21 additions & 2 deletions core/store/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('runMigrations', () => {
'chat_messages',
'notes',
'highlights',
'suggested_questions',
]),
)
})
Expand All @@ -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]', () => {
Expand Down Expand Up @@ -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:')

Expand Down
3 changes: 2 additions & 1 deletion core/store/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
]

/**
Expand Down
14 changes: 14 additions & 0 deletions core/store/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
`
Loading
Loading