From d4dcc97320317979b92131f8d14f258ebade53ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 05:23:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20"Ask=20your=20library"=20GraphRAG=20cha?= =?UTF-8?q?t=20=E2=80=94=20schema=20+=20screen=20(P13d-2a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First user-facing slice of the P13d flagship: a persisted, multi-turn "Ask your library" chat reached from the Dashboard that drives the P13d-1 retrieval engine through on-device generation. - Schema v13→v14: `chats` + `chat_messages` (FK cascade), forward-included (title/archivedAt/citationsJson) so d-2b needs no further migration. - ChatRepository + providers; pure ask_chat helpers (history pairing, citation codec, citation-span parsing). - AskController: create-on-first-send → append question → re-retrieve with bounded history → stream a grounded answer → persist with citations; no-sources → graceful reply with no LLM call. - AskScreen: streamed transcript, inline `[n]` + Sources citations that deep-link items, generation-gated input (on-ramp via aiSummaryAction). - Dashboard AskEntryTile (auto-hides off the full-generation path) + /ask route. - Tests: v13→v14 migration (+ cascade), repo, pure helpers, controller (full/no-sources/error), entry-tile gating. Docs: P13-PLAN, VERIFICATION, SPEC (schema narrative caught up v11–v14), BACKLOG. https://claude.ai/code/session_013JoYmLCosYt5tQ8qwdbL1T --- docs/BACKLOG.md | 7 + docs/SPEC.md | 12 +- docs/VERIFICATION.md | 13 + docs/design/P13-PLAN.md | 15 +- lib/core/db/database.dart | 40 +- lib/core/routing/app_router.dart | 7 + lib/features/ai/data/chat_repository.dart | 84 +++++ lib/features/ai/presentation/ask_chat.dart | 111 ++++++ .../ai/presentation/ask_controller.dart | 95 +++++ lib/features/ai/presentation/ask_screen.dart | 346 ++++++++++++++++++ .../presentation/dashboard_screen.dart | 2 + .../presentation/widgets/ask_entry_tile.dart | 65 ++++ test/core/db/database_test.dart | 92 ++++- test/features/ai/ask_chat_test.dart | 98 +++++ test/features/ai/ask_controller_test.dart | 158 ++++++++ test/features/ai/chat_repository_test.dart | 66 ++++ .../dashboard/ask_entry_tile_test.dart | 90 +++++ 17 files changed, 1295 insertions(+), 6 deletions(-) create mode 100644 lib/features/ai/data/chat_repository.dart create mode 100644 lib/features/ai/presentation/ask_chat.dart create mode 100644 lib/features/ai/presentation/ask_controller.dart create mode 100644 lib/features/ai/presentation/ask_screen.dart create mode 100644 lib/features/dashboard/presentation/widgets/ask_entry_tile.dart create mode 100644 test/features/ai/ask_chat_test.dart create mode 100644 test/features/ai/ask_controller_test.dart create mode 100644 test/features/ai/chat_repository_test.dart create mode 100644 test/features/dashboard/ask_entry_tile_test.dart diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f8fd346..062a931 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8,6 +8,13 @@ _(nothing active — pick the next batch from below)_ ## Deferred / future refinements +- [ ] **GraphRAG — store only the *cited* sources.** P13d-2a persists **all** retrieved sources as a turn's + citations (and renders every `[n]` the model emits); it doesn't prune to the subset the answer actually + cites. If answers reference few sources, post-parse the `[n]` markers and persist only those (smaller + `citationsJson`, tidier Sources row). *(From P13d-2a.)* +- [ ] **GraphRAG — surface retrieval/generation errors per turn.** An `InferenceException` mid-answer sets a + screen-level error banner but persists no assistant row, so the failed question lingers without a reply + bubble. Consider a retry affordance or an inline "couldn't answer" turn. *(From P13d-2a.)* - [ ] **GraphRAG — retrieval-only answer persistence.** On low / ineligible tiers (`ragAvailability == retrievalOnly`) P13d falls back to an ephemeral "most relevant items" answer that **isn't** saved to the chat history (nothing is generated to revisit). Decide at d-3 whether these should be persisted as a diff --git a/docs/SPEC.md b/docs/SPEC.md index 5eddef7..72a95a9 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -122,6 +122,9 @@ IntColumn orderIndex; // v3 (P9d): queue reor // settings (key/value JSON, single row) // notifications (v9, P11): id, createdAt, category, severity, title, body, // targetRoute?, itemId?, taskId?, readAt?, dedupeKey?, expiresAt? — Activity Inbox +// chats (v14, P13d-2a): id, title, createdAt, updatedAt, archivedAt? — "Ask your library" +// chat_messages (v14, P13d-2a): id(autoinc), chatId→chats(cascade), role, content, +// citationsJson?, createdAt ``` > **Forward seam (v2 Things Engine).** A generic **`things`** table (schema.org Things as JSON-LD + @@ -131,7 +134,7 @@ IntColumn orderIndex; // v3 (P9d): queue reor > the derived index, and `things.id` is kept alignable to `media_items.id` (no FK). See ADR-0001 > (schema-as-data) and ADR-0003 (MediaObject bridge); overview in `docs/things-engine.md`. -Migration strategy: Drift `schemaVersion` (currently **10**); write `MigrationStrategy` +Migration strategy: Drift `schemaVersion` (currently **14**); write `MigrationStrategy` steps; never drop user data without migration. Add a schema test on bump (upgrade tests live in `test/core/db/database_test.dart`). **v3 (P9a)** adds `media_items.{isFavorite,contentHash,lastAccessedAt}` + `download_tasks.orderIndex` and @@ -161,6 +164,13 @@ decode via the `image` package) and best-effort backfilled for legacy items by ` **v10 (P12f)** adds the empty **`things`** table (schema.org Things as JSON-LD + promoted `name`/`url`/`created_at`/`updated_at`; `id` alignable to `media_items.id`, **no FK**) — the v2 Things-Engine forward seam, created empty and **unused in v1** (Drift canonical). +**v11 (P13a)** adds `media_metadata.{aiSummary,aiSummaryModelId}` (cached on-device LLM summary + its +model). **v12 (P13b-1)** adds `media_metadata.ocrText` and extends `media_fts` with an `ocr` column +(the FTS5 table is dropped + rebuilt by the migration since FTS5 can't `ALTER ADD COLUMN`). **v13 (P13c-2)** +adds `media_tags.source` (`'user'` default; `'ai'` for auto-applied tags). **v14 (P13d-2a)** adds the +**`chats`** + **`chat_messages`** tables backing the "Ask your library" GraphRAG chat — `chats` +(id/title/createdAt/updatedAt/`archivedAt?`) and `chat_messages` (autoinc id, `chatId`→`chats` **FK +cascade**, role, content, `citationsJson?`, createdAt); created by `m.createTable` (no data migration). --- diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 6c815d7..39bbfcc 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -990,6 +990,19 @@ entries, or verify after P11c lands.)* native path). It's exercised by unit tests (fake embedder + graph + seeded in-memory metadata). The end-to-end **"Ask your library"** flow is verified at P13d-2 (chat screen + generation). +### P13d-2a — "Ask your library" chat *(install `app-arm64-v8a-debug.apk`; needs a capable device + a downloaded generation model)* +- [ ] On a capable device with a generation model set up, the Dashboard shows an **"Ask"** entry; tapping it + opens the chat. (On a low-end device, or with the graph index unavailable, the entry is absent.) +- [ ] Ask a natural-language question about your library → the answer **streams in**, is **grounded** in your + items, and shows inline **`[n]` citations** plus a **Sources** row — **fully offline** (airplane mode). +- [ ] Tapping a citation (inline `[n]` or a Sources chip) **opens the cited item**. +- [ ] Ask a follow-up → it still answers (fresh retrieval + prior turns as context); the turns **persist** + (the transcript stays on screen for the session). +- [ ] Ask something your library can't answer → a graceful **"couldn't find anything"** reply (no invented + answer). +- [ ] With generation **not** set up (eligible device, no model), sending shows the **on-ramp** snackbar and + routes to AI settings. + ### P13 (later subphases) - [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write results back to the item. diff --git a/docs/design/P13-PLAN.md b/docs/design/P13-PLAN.md index f0b43cd..eb92fa6 100644 --- a/docs/design/P13-PLAN.md +++ b/docs/design/P13-PLAN.md @@ -239,13 +239,24 @@ Incapable / low tiers fall back to an ephemeral **retrieval-only** answer (d-3). well-formed, size-bounded, history-aware prompt; degrades to empty-sources when retrieval is unavailable; covered by unit tests. ✓ -#### `[ ]` P13d-2a — Chat schema + Ask screen (single conversation) *(native; APK)* +#### `[~]` P13d-2a — Chat schema + Ask screen (single conversation) *(native; APK)* - Drift **`chats` + `chat_messages`** schema; a dedicated **"Ask your library"** screen from the Dashboard that runs P13d-1's per-turn fresh retrieval + bounded history through `GenerationEngine.generate()` and **streams** a grounded answer with **tappable citations** deep-linking to the cited items. Generation-gated via `aiSummaryAction` (on-ramp when no model). +- **Status:** implemented (CI-green). **Schema v13→v14** (`Chats`: id/title/createdAt/updatedAt/`archivedAt?`; + `ChatMessages`: autoinc id, `chatId`→`Chats` FK cascade, role, content, `citationsJson?`, createdAt — + forward-included so d-2b needs no further migration). New `lib/features/ai/data/chat_repository.dart` + (`ChatRepository` + `chatRepositoryProvider`/`chatMessagesProvider`); `lib/features/ai/presentation/` + `ask_chat.dart` (pure — `messagesToHistory`, `encode`/`decodeCitations`, `parseCitationSpans`), + `ask_controller.dart` (`AskController`: create-on-first-send → append user → re-retrieve with bounded history + → stream grounded answer → persist with citations; no-sources → graceful reply, no LLM call), + `ask_screen.dart` (transcript + streaming bubble + inline `[n]`/Sources citations + gated input); + `dashboard/.../widgets/ask_entry_tile.dart` (auto-hides off the full-generation path); `/ask` route. + Tests: migration v13→v14 (+ cascade), repo, the pure helpers, the controller (full/no-sources/error), and + the entry-tile gating. **No new deps.** - **Exit / review:** ask a natural-language question on a capable device → a streamed, grounded, cited answer - **offline**; the turn persists; citations navigate. APK spot-check. + **offline**; the turn persists; citations navigate. APK spot-check. ✓ (CI parts) · APK owed #### `[ ]` P13d-2b — Conversation list + manage *(native)* - A conversation **list** with **continue / rename / archive / delete**; resuming a chat re-feeds the bounded diff --git a/lib/core/db/database.dart b/lib/core/db/database.dart index ba95293..c5c53dd 100644 --- a/lib/core/db/database.dart +++ b/lib/core/db/database.dart @@ -200,6 +200,36 @@ class AppSettings extends Table { Set> get primaryKey => {id}; } +/// P13d-2a: a persisted "Ask your library" GraphRAG conversation. Each chat is a +/// multi-turn thread of [ChatMessages]; every turn re-retrieves fresh sources +/// and feeds back a bounded slice of this history. `title` is derived from the +/// first question (renamable in d-2b); `archivedAt` (null = active) backs the +/// d-2b archive action. Forward-compatible so d-2b needs no further migration. +class Chats extends Table { + TextColumn get id => text()(); // 'chat_' + TextColumn get title => text()(); + DateTimeColumn get createdAt => dateTime()(); + DateTimeColumn get updatedAt => dateTime()(); // bumped each turn → list sort + DateTimeColumn get archivedAt => dateTime().nullable()(); // null = active + + @override + Set> get primaryKey => {id}; +} + +/// P13d-2a: one message in a [Chats] thread. `id` autoincrements for natural +/// per-chat ordering; deleting a chat cascades its messages. `citationsJson` +/// (assistant turns only) stores the cited sources as compact JSON so the inline +/// `[n]` citations stay tappable after the chat is reloaded. +class ChatMessages extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get chatId => + text().references(Chats, #id, onDelete: KeyAction.cascade)(); + TextColumn get role => text()(); // user | assistant + TextColumn get content => text()(); + TextColumn get citationsJson => text().nullable()(); + DateTimeColumn get createdAt => dateTime()(); +} + @DriftDatabase( tables: [ MediaItems, @@ -213,6 +243,8 @@ class AppSettings extends Table { AppSettings, Notifications, Things, + Chats, + ChatMessages, ], ) class AppDatabase extends _$AppDatabase { @@ -220,7 +252,7 @@ class AppDatabase extends _$AppDatabase { : super(executor ?? driftDatabase(name: 'grabbit')); @override - int get schemaVersion => 13; + int get schemaVersion => 14; @override MigrationStrategy get migration => MigrationStrategy( @@ -299,6 +331,12 @@ class AppDatabase extends _$AppDatabase { ).get()).isNotEmpty; if (hasMediaTags) await m.addColumn(mediaTags, mediaTags.source); } + if (from < 14) { + // P13d-2a: persisted "Ask your library" chats. New tables only — no + // data migration (mirrors the v8→v9 notifications / v9→v10 things steps). + await m.createTable(chats); + await m.createTable(chatMessages); + } await _createIndices(); await _createFtsObjects(); }, diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8568992..5a6ede1 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -12,6 +12,7 @@ import 'package:grabbit/features/library/presentation/collections_screen.dart'; import 'package:grabbit/features/library/presentation/entity_hub_screen.dart'; import 'package:grabbit/features/library/presentation/home_screen.dart'; import 'package:grabbit/features/library/presentation/item_detail_screen.dart'; +import 'package:grabbit/features/ai/presentation/ask_screen.dart'; import 'package:grabbit/features/ai/presentation/graph_view_screen.dart'; import 'package:grabbit/features/library/presentation/media_studio_screen.dart'; import 'package:grabbit/features/library/presentation/metadata_edit_screen.dart'; @@ -254,6 +255,12 @@ GoRouter appRouter(Ref ref) { parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const InboxScreen(), ), + GoRoute( + path: '/ask', + name: 'ask', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const AskScreen(), + ), ], ); } diff --git a/lib/features/ai/data/chat_repository.dart b/lib/features/ai/data/chat_repository.dart new file mode 100644 index 0000000..dcd1516 --- /dev/null +++ b/lib/features/ai/data/chat_repository.dart @@ -0,0 +1,84 @@ +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/db/database_provider.dart'; + +/// Persistence for the "Ask your library" GraphRAG chat (P13d-2a): a thread of +/// [Chat]s, each a list of ordered [ChatMessage]s. Drift stays canonical; this +/// repo only reads/writes the `chats` + `chat_messages` tables. The conversation +/// list + rename/archive/delete land in d-2b on the same schema. +class ChatRepository { + ChatRepository(this._db); + + final AppDatabase _db; + + /// Creates a new chat with [title] (derived from the first question) and + /// returns its id. `createdAt`/`updatedAt` start equal. + Future createChat(String title) async { + final now = DateTime.now(); + final id = 'chat_${now.microsecondsSinceEpoch}'; + await _db + .into(_db.chats) + .insert( + ChatsCompanion.insert( + id: id, + title: title, + createdAt: now, + updatedAt: now, + ), + ); + return id; + } + + /// Appends a message to [chatId] and bumps the chat's `updatedAt` (so the + /// d-2b list sorts most-recent-first). [citationsJson] is set on assistant + /// turns only. + Future appendMessage( + String chatId, { + required String role, + required String content, + String? citationsJson, + }) async { + await _db.transaction(() async { + await _db + .into(_db.chatMessages) + .insert( + ChatMessagesCompanion.insert( + chatId: chatId, + role: role, + content: content, + citationsJson: Value(citationsJson), + createdAt: DateTime.now(), + ), + ); + await (_db.update(_db.chats)..where((t) => t.id.equals(chatId))).write( + ChatsCompanion(updatedAt: Value(DateTime.now())), + ); + }); + } + + /// Live transcript for [chatId], ordered oldest-first. + Stream> watchMessages(String chatId) => + (_db.select(_db.chatMessages) + ..where((t) => t.chatId.equals(chatId)) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .watch(); + + /// One-shot read of [chatId]'s messages (oldest-first) — used to build the + /// bounded history window for the next turn's prompt. + Future> messagesForChat(String chatId) => + (_db.select(_db.chatMessages) + ..where((t) => t.chatId.equals(chatId)) + ..orderBy([(t) => OrderingTerm.asc(t.id)])) + .get(); +} + +final chatRepositoryProvider = Provider( + (ref) => ChatRepository(ref.watch(appDatabaseProvider)), +); + +/// Live transcript provider. Hand-written (returns the Drift row type +/// [ChatMessage]) per CLAUDE.md §8 — `riverpod_generator` can't return rows. +final chatMessagesProvider = StreamProvider.family, String>( + (ref, chatId) => ref.watch(chatRepositoryProvider).watchMessages(chatId), +); diff --git a/lib/features/ai/presentation/ask_chat.dart b/lib/features/ai/presentation/ask_chat.dart new file mode 100644 index 0000000..a615b5c --- /dev/null +++ b/lib/features/ai/presentation/ask_chat.dart @@ -0,0 +1,111 @@ +/// Pure, engine-free helpers for the "Ask your library" chat (P13d-2a): turning +/// persisted messages into bounded RAG history, the citation persistence codec, +/// and splitting an answer into tappable `[n]` citation spans. Kept out of the +/// controller/UI so they're unit-testable in isolation. +library; + +import 'dart:convert'; + +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/features/ai/data/rag_context.dart'; + +const String kRoleUser = 'user'; +const String kRoleAssistant = 'assistant'; + +/// A decoded citation persisted on an assistant message — enough to render and +/// deep-link the inline `[n]` markers without re-running retrieval. +class Citation { + const Citation({ + required this.index, + required this.itemId, + required this.title, + }); + + final int index; + final String itemId; + final String title; +} + +/// One piece of a rendered answer: literal [text], plus a non-null [citation] +/// when this piece is a tappable `[n]` marker. +class CitationSpan { + const CitationSpan.text(this.text) : citation = null; + const CitationSpan.cite(this.text, Citation this.citation); + + final String text; + final Citation? citation; + + bool get isCitation => citation != null; +} + +/// Pairs consecutive user→assistant messages into RAG history turns. An +/// assistant message with no preceding question is ignored; a trailing, +/// not-yet-answered user message (e.g. the in-flight turn) is dropped. +List messagesToHistory(List msgs) { + final turns = []; + String? pendingQuestion; + for (final m in msgs) { + if (m.role == kRoleUser) { + pendingQuestion = m.content; + } else if (m.role == kRoleAssistant && pendingQuestion != null) { + turns.add(RagChatTurn(question: pendingQuestion, answer: m.content)); + pendingQuestion = null; + } + } + return turns; +} + +/// Serializes the retrieved [sources] to the compact JSON stored on an assistant +/// message's `citationsJson` (only `index`/`itemId`/`title` — the snippet the +/// model saw isn't needed to render citations). +String encodeCitations(List sources) => jsonEncode([ + for (final s in sources) {'i': s.index, 'id': s.itemId, 'title': s.title}, +]); + +/// Inverse of [encodeCitations]; tolerant of null/blank/malformed input +/// (returns an empty list rather than throwing at a UI boundary). +List decodeCitations(String? json) { + if (json == null || json.trim().isEmpty) return const []; + try { + final decoded = jsonDecode(json); + if (decoded is! List) return const []; + return [ + for (final e in decoded) + if (e is Map && + e['i'] is int && + e['id'] is String && + e['title'] is String) + Citation( + index: e['i'] as int, + itemId: e['id'] as String, + title: e['title'] as String, + ), + ]; + } on FormatException { + return const []; + } +} + +final _citationMarker = RegExp(r'\[(\d+)\]'); + +/// Splits [answer] into text + citation spans. A `[n]` whose number matches a +/// known citation becomes a tappable span; an out-of-range `[n]` stays plain +/// text so nothing is dropped. +List parseCitationSpans(String answer, List citations) { + final byIndex = {for (final c in citations) c.index: c}; + final spans = []; + var cursor = 0; + for (final match in _citationMarker.allMatches(answer)) { + final citation = byIndex[int.parse(match.group(1)!)]; + if (citation == null) continue; // leave unknown [n] in the surrounding text + if (match.start > cursor) { + spans.add(CitationSpan.text(answer.substring(cursor, match.start))); + } + spans.add(CitationSpan.cite(match.group(0)!, citation)); + cursor = match.end; + } + if (cursor < answer.length) { + spans.add(CitationSpan.text(answer.substring(cursor))); + } + return spans; +} diff --git a/lib/features/ai/presentation/ask_controller.dart b/lib/features/ai/presentation/ask_controller.dart new file mode 100644 index 0000000..e5193b5 --- /dev/null +++ b/lib/features/ai/presentation/ask_controller.dart @@ -0,0 +1,95 @@ +import 'package:grabbit/core/ai/generation_provider.dart'; +import 'package:grabbit/core/ai/inference_error.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/ai/data/rag_retriever.dart'; +import 'package:grabbit/features/ai/presentation/ask_chat.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'ask_controller.g.dart'; + +/// Transient state for the active "Ask your library" conversation (P13d-2a). The +/// persisted transcript is read from `chatMessagesProvider`; this only tracks the +/// in-flight turn — [chatId] (created on the first send), [busy], the [streaming] +/// answer as it generates, and any [error]. +class AskState { + const AskState({this.chatId, this.busy = false, this.streaming, this.error}); + + final String? chatId; + final bool busy; + final String? streaming; + final String? error; +} + +/// Drives one chat session: each [send] appends the question, re-retrieves fresh +/// RAG sources (reusing d-1) with a bounded slice of prior turns, streams a +/// grounded answer through the local LLM, and persists the turn with its +/// citations. No sources → a graceful "couldn't find" reply, no LLM call. The +/// caller gates on generation readiness (`aiSummaryAction`) before [send]. +@riverpod +class AskController extends _$AskController { + @override + AskState build() => const AskState(); + + Future send(String question) async { + final q = question.trim(); + if (q.isEmpty || state.busy) return; + + final repo = ref.read(chatRepositoryProvider); + final chatId = state.chatId ?? await repo.createChat(_deriveTitle(q)); + state = AskState(chatId: chatId, busy: true, streaming: ''); + await repo.appendMessage(chatId, role: kRoleUser, content: q); + + try { + // The just-appended question is the trailing unanswered message, so + // `messagesToHistory` drops it; only completed prior turns feed back. + final history = messagesToHistory(await repo.messagesForChat(chatId)); + final ctx = await ref + .read(ragRetrieverProvider) + .retrieve(q, history: history); + + if (!ctx.hasSources) { + await repo.appendMessage( + chatId, + role: kRoleAssistant, + content: "I couldn't find anything in your library about that.", + ); + state = AskState(chatId: chatId); + return; + } + + final engine = ref.read(generationEngineProvider); + final buffer = StringBuffer(); + await for (final token in engine.generate( + ctx.prompt, + systemPrompt: ctx.systemPrompt, + )) { + buffer.write(token); + state = AskState( + chatId: chatId, + busy: true, + streaming: buffer.toString(), + ); + } + final answer = buffer.toString().trim(); + if (answer.isNotEmpty) { + await repo.appendMessage( + chatId, + role: kRoleAssistant, + content: answer, + citationsJson: encodeCitations(ctx.sources), + ); + } + state = AskState(chatId: chatId); + } on InferenceException catch (e) { + state = AskState(chatId: chatId, error: "Couldn't answer — ${e.message}"); + } + } + + /// A compact, single-line chat title from the first question. + String _deriveTitle(String q) { + final oneLine = q.replaceAll(RegExp(r'\s+'), ' ').trim(); + return oneLine.length <= 60 + ? oneLine + : '${oneLine.substring(0, 60).trimRight()}…'; + } +} diff --git a/lib/features/ai/presentation/ask_screen.dart b/lib/features/ai/presentation/ask_screen.dart new file mode 100644 index 0000000..56fef12 --- /dev/null +++ b/lib/features/ai/presentation/ask_screen.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:grabbit/core/ai/generation_provider.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/theme/tokens.dart'; +import 'package:grabbit/core/widgets/empty_state.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/ai/presentation/ask_chat.dart'; +import 'package:grabbit/features/ai/presentation/ask_controller.dart'; +import 'package:grabbit/features/library/presentation/ai_summary.dart'; +import 'package:grabbit/features/settings/presentation/settings_controller.dart'; + +/// The "Ask your library" GraphRAG chat (P13d-2a): ask a natural-language +/// question and get a grounded, streamed answer that cites library items. Each +/// turn re-retrieves fresh sources + a bounded slice of history. Reached from the +/// Dashboard; generation-gated (an on-ramp routes to AI settings when no model). +class AskScreen extends ConsumerStatefulWidget { + const AskScreen({super.key}); + + @override + ConsumerState createState() => _AskScreenState(); +} + +class _AskScreenState extends ConsumerState { + final _input = TextEditingController(); + final _scroll = ScrollController(); + + @override + void dispose() { + _input.dispose(); + _scroll.dispose(); + super.dispose(); + } + + Future _onSend() async { + final text = _input.text.trim(); + if (text.isEmpty || ref.read(askControllerProvider).busy) return; + + final messenger = ScaffoldMessenger.of(context); + final router = GoRouter.of(context); + final engine = ref.read(generationEngineProvider); + final enabled = + ref.read(settingsControllerProvider).value?.generationEnabled ?? false; + // Only probe the model when enabled — never load a model the user hasn't + // opted into. `ensureReady` does not download. + final modelReady = enabled && await engine.ensureReady(); + final action = aiSummaryAction( + eligible: ref.read(activeGenerationModelProvider) != null, + enabled: enabled, + modelReady: modelReady, + ); + switch (action) { + case AiSummaryAction.unavailable: + return; + case AiSummaryAction.offerSetup: + case AiSummaryAction.offerDownload: + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Set up on-device text generation to ask'), + ), + ); + await router.push('/settings/ai'); + case AiSummaryAction.summarizeNow: + _input.clear(); + await ref.read(askControllerProvider.notifier).send(text); + _scrollToBottom(); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final tokens = GrabBitTokens.of(context); + final state = ref.watch(askControllerProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Ask your library')), + body: SafeArea( + child: Column( + children: [ + Expanded(child: _transcript(state)), + if (state.error != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: tokens.spaceLg), + child: Text( + state.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + _InputBar(controller: _input, busy: state.busy, onSend: _onSend), + ], + ), + ), + ); + } + + Widget _transcript(AskState state) { + final tokens = GrabBitTokens.of(context); + final chatId = state.chatId; + if (chatId == null) { + return const EmptyState( + icon: Icons.auto_awesome_outlined, + title: 'Ask your library', + message: + 'Ask a question and get an answer grounded in your downloads, with ' + 'links to the items it used.', + ); + } + + final messages = + ref.watch(chatMessagesProvider(chatId)).asData?.value ?? const []; + final streaming = state.streaming; + final hasStreaming = state.busy && streaming != null; + + return ListView( + controller: _scroll, + padding: EdgeInsets.all(tokens.spaceLg), + children: [ + for (final m in messages) _MessageBubble(message: m), + if (hasStreaming) _StreamingBubble(text: streaming), + ], + ); + } +} + +/// A persisted user/assistant message. Assistant bubbles render tappable `[n]` +/// citations + a "Sources" footer that deep-links the cited items. +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + + final ChatMessage message; + + @override + Widget build(BuildContext context) { + final isUser = message.role == kRoleUser; + if (isUser) return _Bubble(isUser: true, child: Text(message.content)); + + final citations = decodeCitations(message.citationsJson); + return _Bubble( + isUser: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _answerBody(context, message.content, citations), + if (citations.isNotEmpty) _SourcesFooter(citations: citations), + ], + ), + ); + } +} + +class _StreamingBubble extends StatelessWidget { + const _StreamingBubble({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final tokens = GrabBitTokens.of(context); + return _Bubble( + isUser: false, + child: text.isEmpty + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(text)), + SizedBox(width: tokens.spaceSm), + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + ); + } +} + +/// Renders an answer as rich text with inline, tappable `[n]` citation badges. +Widget _answerBody( + BuildContext context, + String content, + List citations, +) { + final scheme = Theme.of(context).colorScheme; + final spans = parseCitationSpans(content, citations); + return Text.rich( + TextSpan( + children: [ + for (final s in spans) + if (s.isCitation) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: InkWell( + onTap: () => context.push('/item/${s.citation!.itemId}'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Text( + s.text, + style: TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + else + TextSpan(text: s.text), + ], + ), + ); +} + +/// Cited items as tappable chips beneath an answer. +class _SourcesFooter extends StatelessWidget { + const _SourcesFooter({required this.citations}); + + final List citations; + + @override + Widget build(BuildContext context) { + final tokens = GrabBitTokens.of(context); + return Padding( + padding: EdgeInsets.only(top: tokens.spaceSm), + child: Wrap( + spacing: tokens.spaceSm, + runSpacing: tokens.spaceXs, + children: [ + for (final c in citations) + ActionChip( + avatar: const Icon(Icons.link, size: 16), + label: Text( + '[${c.index}] ${c.title}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onPressed: () => context.push('/item/${c.itemId}'), + ), + ], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.isUser, required this.child}); + + final bool isUser; + final Widget child; + + @override + Widget build(BuildContext context) { + final tokens = GrabBitTokens.of(context); + final scheme = Theme.of(context).colorScheme; + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: EdgeInsets.only(bottom: tokens.spaceMd), + padding: EdgeInsets.symmetric( + horizontal: tokens.spaceMd, + vertical: tokens.spaceSm, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.82, + ), + decoration: BoxDecoration( + color: isUser + ? scheme.primaryContainer + : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(tokens.radiusLg), + ), + child: child, + ), + ); + } +} + +class _InputBar extends StatelessWidget { + const _InputBar({ + required this.controller, + required this.busy, + required this.onSend, + }); + + final TextEditingController controller; + final bool busy; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + final tokens = GrabBitTokens.of(context); + return Padding( + padding: EdgeInsets.fromLTRB( + tokens.spaceLg, + tokens.spaceSm, + tokens.spaceLg, + tokens.spaceMd, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: controller, + minLines: 1, + maxLines: 4, + enabled: !busy, + textInputAction: TextInputAction.send, + onSubmitted: (_) => onSend(), + decoration: const InputDecoration( + hintText: 'Ask about your library…', + ), + ), + ), + SizedBox(width: tokens.spaceSm), + IconButton.filled( + onPressed: busy ? null : onSend, + icon: const Icon(Icons.send), + tooltip: 'Ask', + ), + ], + ), + ); + } +} diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index ca73595..dddcb39 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -13,6 +13,7 @@ import 'package:grabbit/core/widgets/skeleton.dart'; import 'package:grabbit/features/dashboard/domain/dashboard_summary.dart'; import 'package:grabbit/features/dashboard/presentation/dashboard_providers.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/activity_chart_tile.dart'; +import 'package:grabbit/features/dashboard/presentation/widgets/ask_entry_tile.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/duplicates_callout.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/graph_entry_tile.dart'; import 'package:grabbit/features/dashboard/presentation/widgets/recent_activity_tile.dart'; @@ -173,6 +174,7 @@ class _DashboardBody extends ConsumerWidget { const RecentActivityTile(), const SuggestionsTile(), const DuplicatesCallout(), + const AskEntryTile(), const GraphEntryTile(), ], ), diff --git a/lib/features/dashboard/presentation/widgets/ask_entry_tile.dart b/lib/features/dashboard/presentation/widgets/ask_entry_tile.dart new file mode 100644 index 0000000..aa3f041 --- /dev/null +++ b/lib/features/dashboard/presentation/widgets/ask_entry_tile.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:grabbit/core/ai/generation_provider.dart'; +import 'package:grabbit/core/graph/graph_store_provider.dart'; +import 'package:grabbit/core/theme/tokens.dart'; +import 'package:grabbit/core/widgets/section_header.dart'; +import 'package:grabbit/features/library/presentation/library_controller.dart'; + +/// Dashboard entry into the "Ask your library" GraphRAG chat (P13d-2a). Shown +/// only when generation is eligible for this device tier, the graph index is +/// available, and the library is non-empty — i.e. the full generate-and-cite +/// path can run (or be set up). Low/ineligible tiers get the retrieval-only +/// fallback in d-3; until then the entry simply auto-hides there. +class AskEntryTile extends ConsumerWidget { + const AskEntryTile({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(activeGenerationModelProvider) == null) { + return const SizedBox.shrink(); + } + if (!ref.watch(graphStoreProvider).isAvailable) { + return const SizedBox.shrink(); + } + final items = ref.watch(libraryItemsProvider).asData?.value ?? const []; + if (items.isEmpty) return const SizedBox.shrink(); + + final scheme = Theme.of(context).colorScheme; + final tokens = GrabBitTokens.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SectionHeader('Ask', icon: Icons.auto_awesome_outlined), + Padding( + padding: EdgeInsets.fromLTRB( + tokens.spaceLg, + 0, + tokens.spaceLg, + tokens.spaceSm, + ), + child: Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + child: const Icon(Icons.auto_awesome_outlined), + ), + title: const Text('Ask your library'), + subtitle: Text( + 'Ask a question about your ${items.length} ' + '${items.length == 1 ? 'item' : 'items'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/ask'), + ), + ), + ), + ], + ); + } +} diff --git a/test/core/db/database_test.dart b/test/core/db/database_test.dart index d28f643..8860c9b 100644 --- a/test/core/db/database_test.dart +++ b/test/core/db/database_test.dart @@ -9,8 +9,8 @@ void main() { setUp(() => db = AppDatabase(NativeDatabase.memory())); tearDown(() => db.close()); - test('opens at schema version 13 with all tables created', () async { - expect(db.schemaVersion, 13); + test('opens at schema version 14 with all tables created', () async { + expect(db.schemaVersion, 14); // Forces onCreate (createAll) + beforeOpen to run. final tableNames = db.allTables.map((t) => t.actualTableName).toSet(); @@ -28,6 +28,8 @@ void main() { 'app_settings', 'notifications', 'things', + 'chats', + 'chat_messages', ]), ); @@ -1125,6 +1127,92 @@ void main() { }, ); + test( + 'upgrades a v13 database to v14, adding the chat tables (P13d-2a)', + () async { + // Seed a minimal v13 DB (media_tags already has `source`; no chat tables), + // user_version=13. Opening at v14 must create chats + chat_messages. + final upgraded = AppDatabase( + NativeDatabase.memory( + setup: (raw) { + raw.execute(''' + CREATE TABLE media_items ( + id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, + source_url TEXT NOT NULL, site TEXT NOT NULL, + file_path TEXT NOT NULL, type TEXT NOT NULL, duration_sec INTEGER, + size_bytes INTEGER, width INTEGER, height INTEGER, thumb_path TEXT, + created_at INTEGER NOT NULL, storage_state TEXT NOT NULL, notes TEXT, + folder_id INTEGER, is_favorite INTEGER NOT NULL DEFAULT 0, + content_hash TEXT, last_accessed_at INTEGER + )'''); + raw.execute(''' + CREATE TABLE media_metadata ( + item_id TEXT NOT NULL PRIMARY KEY REFERENCES media_items (id), + uploader TEXT, upload_date INTEGER, description TEXT, + original_url TEXT, uploader_id TEXT, channel_id TEXT, source_id TEXT, + playlist_id TEXT, playlist_title TEXT, tags TEXT, transcript TEXT, + transcript_cues TEXT, ai_summary TEXT, ai_summary_model_id TEXT, + ocr_text TEXT + )'''); + // Tables the migration tail (_createIndices) touches. + raw.execute(''' + CREATE TABLE notifications ( + id TEXT NOT NULL PRIMARY KEY, category TEXT NOT NULL, + severity TEXT NOT NULL, title TEXT NOT NULL, body TEXT, + target_route TEXT, item_id TEXT, task_id TEXT, dedupe_key TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + read_at INTEGER, expires_at INTEGER, + coalesce_count INTEGER NOT NULL DEFAULT 1 + )'''); + raw.execute('PRAGMA user_version = 13'); + }, + ), + ); + addTearDown(upgraded.close); + + // The new tables exist and a chat + its messages round-trip. + const chatId = 'chat_1'; + await upgraded + .into(upgraded.chats) + .insert( + ChatsCompanion.insert( + id: chatId, + title: 'What concerts?', + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + ); + await upgraded + .into(upgraded.chatMessages) + .insert( + ChatMessagesCompanion.insert( + chatId: chatId, + role: 'user', + content: 'hi', + createdAt: DateTime.utc(2026), + ), + ); + await upgraded + .into(upgraded.chatMessages) + .insert( + ChatMessagesCompanion.insert( + chatId: chatId, + role: 'assistant', + content: 'hello [1]', + citationsJson: const Value('[{"i":1,"id":"x","title":"X"}]'), + createdAt: DateTime.utc(2026), + ), + ); + expect(await upgraded.select(upgraded.chatMessages).get(), hasLength(2)); + + // Deleting the chat cascades to its messages (foreign_keys = ON). + await (upgraded.delete( + upgraded.chats, + )..where((t) => t.id.equals(chatId))).go(); + expect(await upgraded.select(upgraded.chatMessages).get(), isEmpty); + }, + ); + test( 'addColumnIfMissing is idempotent and adds only absent columns', () async { diff --git a/test/features/ai/ask_chat_test.dart b/test/features/ai/ask_chat_test.dart new file mode 100644 index 0000000..9a5e89c --- /dev/null +++ b/test/features/ai/ask_chat_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/features/ai/data/rag_context.dart'; +import 'package:grabbit/features/ai/presentation/ask_chat.dart'; + +ChatMessage _msg(String role, String content) => ChatMessage( + id: 0, + chatId: 'c', + role: role, + content: content, + createdAt: DateTime.utc(2026), +); + +void main() { + group('messagesToHistory', () { + test('pairs consecutive user→assistant messages into turns', () { + final turns = messagesToHistory([ + _msg(kRoleUser, 'q1'), + _msg(kRoleAssistant, 'a1'), + _msg(kRoleUser, 'q2'), + _msg(kRoleAssistant, 'a2'), + ]); + expect(turns.map((t) => t.question), ['q1', 'q2']); + expect(turns.map((t) => t.answer), ['a1', 'a2']); + }); + + test('drops a trailing unanswered user message', () { + final turns = messagesToHistory([ + _msg(kRoleUser, 'q1'), + _msg(kRoleAssistant, 'a1'), + _msg(kRoleUser, 'q2'), + ]); + expect(turns.length, 1); + expect(turns.single.question, 'q1'); + }); + + test('ignores an assistant message with no preceding question', () { + final turns = messagesToHistory([ + _msg(kRoleAssistant, 'orphan'), + _msg(kRoleUser, 'q1'), + _msg(kRoleAssistant, 'a1'), + ]); + expect(turns.length, 1); + expect(turns.single, isA()); + expect(turns.single.answer, 'a1'); + }); + }); + + group('citation codec', () { + test('encode → decode round-trips index/itemId/title', () { + final json = encodeCitations(const [ + RagSource(index: 1, itemId: 'a', title: 'Live in Tokyo', snippet: 's1'), + RagSource(index: 2, itemId: 'b', title: 'Studio', snippet: 's2'), + ]); + final decoded = decodeCitations(json); + expect(decoded.map((c) => c.index), [1, 2]); + expect(decoded.map((c) => c.itemId), ['a', 'b']); + expect(decoded.map((c) => c.title), ['Live in Tokyo', 'Studio']); + }); + + test('decode tolerates null, blank, and malformed input', () { + expect(decodeCitations(null), isEmpty); + expect(decodeCitations(' '), isEmpty); + expect(decodeCitations('not json'), isEmpty); + expect(decodeCitations('{"i":1}'), isEmpty); // not a list + }); + }); + + group('parseCitationSpans', () { + const citations = [ + Citation(index: 1, itemId: 'a', title: 'A'), + Citation(index: 2, itemId: 'b', title: 'B'), + ]; + + test('splits prose and tappable [n] markers', () { + final spans = parseCitationSpans('See [1] and [2].', citations); + expect(spans.map((s) => s.text), ['See ', '[1]', ' and ', '[2]', '.']); + expect(spans.map((s) => s.isCitation), [false, true, false, true, false]); + expect(spans[1].citation!.itemId, 'a'); + expect(spans[3].citation!.itemId, 'b'); + }); + + test('leaves an out-of-range [n] as plain text', () { + final spans = parseCitationSpans('Yes [3] indeed', const [ + Citation(index: 1, itemId: 'a', title: 'A'), + ]); + expect(spans.length, 1); + expect(spans.single.isCitation, isFalse); + expect(spans.single.text, 'Yes [3] indeed'); + }); + + test('plain answer with no markers is a single text span', () { + final spans = parseCitationSpans('No citations here', citations); + expect(spans.single.text, 'No citations here'); + expect(spans.single.isCitation, isFalse); + }); + }); +} diff --git a/test/features/ai/ask_controller_test.dart b/test/features/ai/ask_controller_test.dart new file mode 100644 index 0000000..fee927d --- /dev/null +++ b/test/features/ai/ask_controller_test.dart @@ -0,0 +1,158 @@ +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/ai/generation_engine.dart'; +import 'package:grabbit/core/ai/generation_model.dart'; +import 'package:grabbit/core/ai/generation_provider.dart'; +import 'package:grabbit/core/ai/inference_error.dart'; +import 'package:grabbit/core/ai/structured_generation.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/db/database_provider.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/ai/data/rag_context.dart'; +import 'package:grabbit/features/ai/data/rag_retriever.dart'; +import 'package:grabbit/features/ai/presentation/ask_chat.dart'; +import 'package:grabbit/features/ai/presentation/ask_controller.dart'; + +/// Generation engine that streams a fixed reply (or throws). +class FakeGenerationEngine implements GenerationEngine { + FakeGenerationEngine({this.output = 'It was great [1].', this.fail = false}); + final String output; + final bool fail; + final List prompts = []; + + @override + GenerationModel get model => qwen3_0_6b; + @override + bool get isAvailable => true; + @override + Future ensureReady() async => true; + @override + Future downloadModel({void Function(double)? onProgress}) async {} + @override + Stream generate(String prompt, {String? systemPrompt}) async* { + prompts.add(prompt); + if (fail) { + throw const InferenceException(InferenceErrorCode.generateFailed, 'boom'); + } + yield output; + } + + @override + Future generateStructured( + List toolDefs, + String prompt, { + String? systemPrompt, + }) => throw UnimplementedError(); + @override + Future close() async {} +} + +/// Retriever returning a canned context (overrides the real embed→search path). +class FakeRagRetriever extends RagRetriever { + FakeRagRetriever(super.ref, this._ctx); + final RagContext _ctx; + + @override + Future retrieve( + String question, { + List history = const [], + int historyCharBudget = 1500, + int maxSources = 6, + int k = 30, + }) async => _ctx; +} + +RagContext _ctxWithSources(String question) => RagContext( + question: question, + sources: const [ + RagSource(index: 1, itemId: 'a', title: 'Live in Tokyo', snippet: 's1'), + RagSource(index: 2, itemId: 'b', title: 'Studio', snippet: 's2'), + ], + systemPrompt: kRagSystemPrompt, + prompt: 'PROMPT for: $question', +); + +RagContext _emptyCtx(String question) => RagContext( + question: question, + sources: const [], + systemPrompt: kRagSystemPrompt, + prompt: '', +); + +void main() { + late AppDatabase db; + setUp(() => db = AppDatabase(NativeDatabase.memory())); + tearDown(() => db.close()); + + ProviderContainer makeContainer({ + required RagContext ctx, + required FakeGenerationEngine engine, + }) { + final c = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(db), + ragRetrieverProvider.overrideWith((ref) => FakeRagRetriever(ref, ctx)), + generationEngineProvider.overrideWithValue(engine), + ], + ); + addTearDown(c.dispose); + return c; + } + + test('send persists the question + streamed, cited answer', () async { + final engine = FakeGenerationEngine(output: 'It was great [1].'); + final c = makeContainer( + ctx: _ctxWithSources('what concerts?'), + engine: engine, + ); + + await c.read(askControllerProvider.notifier).send('what concerts?'); + + final state = c.read(askControllerProvider); + expect(state.chatId, isNotNull); + expect(state.busy, isFalse); + expect(state.streaming, isNull); + + final repo = ChatRepository(db); + final msgs = await repo.messagesForChat(state.chatId!); + expect(msgs.map((m) => m.role), [kRoleUser, kRoleAssistant]); + expect(msgs.first.content, 'what concerts?'); + expect(msgs.last.content, 'It was great [1].'); + expect(decodeCitations(msgs.last.citationsJson).map((x) => x.itemId), [ + 'a', + 'b', + ]); + expect(engine.prompts.single, 'PROMPT for: what concerts?'); + }); + + test('no sources → graceful reply, no generation call', () async { + final engine = FakeGenerationEngine(); + final c = makeContainer(ctx: _emptyCtx('huh?'), engine: engine); + + await c.read(askControllerProvider.notifier).send('huh?'); + + final state = c.read(askControllerProvider); + final repo = ChatRepository(db); + final msgs = await repo.messagesForChat(state.chatId!); + expect(msgs.map((m) => m.role), [kRoleUser, kRoleAssistant]); + expect(msgs.last.content, contains("couldn't find anything")); + expect(msgs.last.citationsJson, isNull); + expect(engine.prompts, isEmpty); // the LLM was never invoked + }); + + test('a generation failure sets an error and persists no answer', () async { + final engine = FakeGenerationEngine(fail: true); + final c = makeContainer(ctx: _ctxWithSources('q'), engine: engine); + + await c.read(askControllerProvider.notifier).send('q'); + + final state = c.read(askControllerProvider); + expect(state.error, contains("Couldn't answer")); + expect(state.busy, isFalse); + + final repo = ChatRepository(db); + final msgs = await repo.messagesForChat(state.chatId!); + expect(msgs.map((m) => m.role), [kRoleUser]); // user only; no assistant row + }); +} diff --git a/test/features/ai/chat_repository_test.dart b/test/features/ai/chat_repository_test.dart new file mode 100644 index 0000000..26a3ebd --- /dev/null +++ b/test/features/ai/chat_repository_test.dart @@ -0,0 +1,66 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/ai/presentation/ask_chat.dart'; + +void main() { + late AppDatabase db; + late ChatRepository repo; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + repo = ChatRepository(db); + }); + tearDown(() => db.close()); + + test( + 'createChat returns an id and stores the title; timestamps equal', + () async { + final id = await repo.createChat('What concerts have I saved?'); + final chat = await (db.select( + db.chats, + )..where((t) => t.id.equals(id))).getSingle(); + expect(chat.title, 'What concerts have I saved?'); + expect(chat.createdAt, chat.updatedAt); + expect(chat.archivedAt, isNull); + }, + ); + + test('appendMessage persists messages in order and bumps updatedAt', () async { + final id = await repo.createChat('q1'); + // Force an old `updatedAt` so the bump is observable (DateTime is stored at + // second resolution, so a wall-clock delay would be unreliable). + await (db.update(db.chats)..where((t) => t.id.equals(id))).write( + ChatsCompanion(updatedAt: Value(DateTime.utc(2000))), + ); + + await repo.appendMessage(id, role: kRoleUser, content: 'q1'); + await repo.appendMessage( + id, + role: kRoleAssistant, + content: 'a1 [1]', + citationsJson: '[{"i":1,"id":"x","title":"X"}]', + ); + + final msgs = await repo.messagesForChat(id); + expect(msgs.map((m) => m.role), [kRoleUser, kRoleAssistant]); + expect(msgs.map((m) => m.content), ['q1', 'a1 [1]']); + expect(msgs.last.citationsJson, isNotNull); + + final after = await (db.select( + db.chats, + )..where((t) => t.id.equals(id))).getSingle(); + expect(after.updatedAt.isAfter(DateTime.utc(2000)), isTrue); + }); + + test('watchMessages emits the ordered transcript', () async { + final id = await repo.createChat('q'); + await repo.appendMessage(id, role: kRoleUser, content: 'q'); + await repo.appendMessage(id, role: kRoleAssistant, content: 'a'); + + final msgs = await repo.watchMessages(id).first; + expect(msgs.map((m) => m.content), ['q', 'a']); + }); +} diff --git a/test/features/dashboard/ask_entry_tile_test.dart b/test/features/dashboard/ask_entry_tile_test.dart new file mode 100644 index 0000000..51ee5d9 --- /dev/null +++ b/test/features/dashboard/ask_entry_tile_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/core/ai/generation_model.dart'; +import 'package:grabbit/core/ai/generation_provider.dart'; +import 'package:grabbit/core/db/database.dart'; +import 'package:grabbit/core/graph/graph_store_provider.dart'; +import 'package:grabbit/features/dashboard/presentation/widgets/ask_entry_tile.dart'; +import 'package:grabbit/features/library/presentation/library_controller.dart'; + +import '../../support/graph_fakes.dart'; + +MediaItem _item(String id) => MediaItem( + id: id, + title: id, + sourceUrl: 'u', + site: 'youtube', + filePath: '/tmp/$id', + type: 'video', + sizeBytes: 1, + createdAt: DateTime.utc(2026), + storageState: 'private', + isFavorite: false, +); + +Future _pump( + WidgetTester tester, { + required GenerationModel? model, + required bool graphAvailable, + required List items, +}) { + return tester.pumpWidget( + ProviderScope( + overrides: [ + activeGenerationModelProvider.overrideWith((ref) => model), + graphStoreProvider.overrideWithValue( + FakeGraphStore(available: graphAvailable), + ), + libraryItemsProvider.overrideWith((ref) => Stream.value(items)), + ], + child: const MaterialApp(home: Scaffold(body: AskEntryTile())), + ), + ); +} + +void main() { + testWidgets( + 'shows when generation is eligible, graph available, items exist', + (tester) async { + await _pump( + tester, + model: qwen3_0_6b, + graphAvailable: true, + items: [_item('a')], + ); + await tester.pump(); + expect(find.text('Ask your library'), findsOneWidget); + }, + ); + + testWidgets('auto-hides when no generation model fits the device', ( + tester, + ) async { + await _pump(tester, model: null, graphAvailable: true, items: [_item('a')]); + await tester.pump(); + expect(find.text('Ask your library'), findsNothing); + }); + + testWidgets('auto-hides when the graph is unavailable', (tester) async { + await _pump( + tester, + model: qwen3_0_6b, + graphAvailable: false, + items: [_item('a')], + ); + await tester.pump(); + expect(find.text('Ask your library'), findsNothing); + }); + + testWidgets('auto-hides when the library is empty', (tester) async { + await _pump( + tester, + model: qwen3_0_6b, + graphAvailable: true, + items: const [], + ); + await tester.pump(); + expect(find.text('Ask your library'), findsNothing); + }); +}