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
9 changes: 4 additions & 5 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@
_(nothing active — pick the next batch from below)_

## Deferred / future refinements
- [ ] **P13c-2 — opt-in auto-tag-on-download.** The sibling of P13a-2/P13b-3: an opt-in (default-off) setting
to suggest tags for new downloads in the background. Unlike the passive summary/OCR enrichments, tags are
**user-curated** (they drive facets), so this should likely **suggest + post a "review tags" Activity
Inbox nudge** (or write into a marked "ai" namespace) rather than silently auto-apply LLM tags — decide
the apply-vs-review behaviour when planning it. *(From P13c.)*
- [ ] **Library "hide / filter AI tags" facet.** P13c-2 marks AI-applied tags (`media_tags.source = 'ai'`)
and shows a ✦ on their chips, but the library tag facet (`watchDistinctTags`) treats them like any tag.
Add a "hide AI tags" / "AI-tagged only" filter (and maybe a bulk "remove all AI tags on this item") if
auto-tagging proves noisy. Also: promote an AI tag to 'user' when the user re-adds/keeps it. *(From P13c-2.)*
- [ ] **AI tag casing/normalization.** `addTagToItem` trims but doesn't lowercase, so P13c's AI suggestions
are lowercased while manual/graph tags keep their case (e.g. `Live` vs `live` can coexist). Consider a
single normalization policy (case-fold on store, or a display-case + fold-key) if duplicate-case tags
Expand Down
8 changes: 8 additions & 0 deletions docs/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,14 @@ entries, or verify after P11c lands.)*
- [ ] On a **low-end** device the AI row is **absent** (the graph co-occurrence "Suggested" chips still work);
with generation **not enabled**, the button routes to **AI settings** (on-ramp).

### P13c-2 — Auto-tag on download *(install `app-arm64-v8a-debug.apk`; needs a capable device)*
- [ ] AI & graph settings → with generation enabled, the **Auto-tag new downloads** toggle is visible; enable
it. Finish a download (item with description/transcript) → sensible **AI tags are applied** + an
Activity Inbox entry ("N tags added"), **fully offline**. The tags show a **✦ marker** in the editor +
on item detail, and appear as **search facets**; any AI tag can be **deleted**.
- [ ] A **manually-added** tag has no marker. **Default off:** downloads aren't auto-tagged; with generation
off there's a one-time "finish setting up auto-tagging" nudge; the queue still drains.

### P13 (later subphases)
- [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write
results back to the item.
Expand Down
19 changes: 19 additions & 0 deletions docs/design/P13-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,25 @@ LLM-suggested tags feeding the **existing** tag system — builds directly on th
(low hides / high shows). **Pending APK spot-check** (generate + apply on a real item offline). The
generate→chips flow is APK-verified. Background **auto-tag-on-download is a deliberate follow-up (P13c-2)**.

#### `[~]` P13c-2 — Opt-in auto-tag on download (marked AI) *(generation; APK)*
Follow-up: the LLM **applies** tags to new downloads in the background, opt-in (default off). Because tags are
user-curated (they drive facets), AI tags are **marked** (provenance) rather than silently mixed in.
- **Schema v12→v13:** `MediaTags.source` (`withDefault('user')`); `addTagToItem(…, {source})` (insertOrIgnore
keeps an existing link's source → user tags never demoted); `watchAiTagNamesForItem` + provider.
- **Settings** `autoTagOnDownload` (default off) + setter; `_AutoTagTile` in the generation card.
- **Pure** `autoTagDecision` (skip/needsModel/tag). **Queue** (`_persistCompleted`, after auto-OCR): build
source from title + description/transcript/`ocrText`, `buildTagPrompt` → `generate` → `parseTagSuggestions`
(exclude applied) → `addTagToItem(source: 'ai')`; `tagCount`/`tagNeedsModel` in `_PersistResult`; an `ai`
inbox entry ("N tags added") or a "finish setup" nudge.
- **Marking:** the editor + item-detail tag chips show `auto_awesome_outlined` on AI-sourced tags (via
`aiTagNamesForItemProvider`); AI tags appear as normal search facets.
- **Status:** implemented (CI-green) — schema v13 (+ a defensive table-guard in the migration, mirroring the
v8 guard-add spirit, so partial older test DBs don't break); repo provenance + provider; settings + tile;
`autoTagDecision`; queue block + two inbox posts; chip marking in both surfaces. Tests: `autoTagDecision`,
repo provenance (only-ai set; user not demoted), v12→v13 migration, settings round-trip, queue (applied as
'ai' + entry; default-off no-op). **No deps.** **Pending APK spot-check** (real download → AI-marked tags +
facets, offline). A library "hide/filter AI tags" facet is deferred (BACKLOG).

### `[ ]` P13d — Local GraphRAG "Ask your library" *(flagship; split into 3 PRs)*
The headline differentiator — natural-language Q&A grounded in the private library, fully on-device
(AI-SPEC §6, GRAPH-SPEC §7). Sequenced **mid-phase** so the generation patterns (P13a/c) are proven first.
Expand Down
15 changes: 14 additions & 1 deletion lib/core/db/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ class MediaTags extends Table {
text().references(MediaItems, #id, onDelete: KeyAction.cascade)();
IntColumn get tagId =>
integer().references(Tags, #id, onDelete: KeyAction.cascade)();
// P13c-2: provenance of this tag link — 'user' (manual/graph) or 'ai'
// (auto-applied on download). Lets AI tags be marked + managed distinctly.
TextColumn get source => text().withDefault(const Constant('user'))();

@override
Set<Column<Object>> get primaryKey => {itemId, tagId};
Expand Down Expand Up @@ -217,7 +220,7 @@ class AppDatabase extends _$AppDatabase {
: super(executor ?? driftDatabase(name: 'grabbit'));

@override
int get schemaVersion => 12;
int get schemaVersion => 13;

@override
MigrationStrategy get migration => MigrationStrategy(
Expand Down Expand Up @@ -286,6 +289,16 @@ class AppDatabase extends _$AppDatabase {
}
await customStatement('DROP TABLE IF EXISTS media_fts');
}
if (from < 13) {
// P13c-2: tag provenance ('user' default; 'ai' for auto-applied tags).
// Defensive table guard (mirrors the v8 guard-add spirit): a no-op if
// media_tags somehow isn't present yet.
final hasMediaTags = (await customSelect(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='media_tags'",
).get()).isNotEmpty;
if (hasMediaTags) await m.addColumn(mediaTags, mediaTags.source);
}
await _createIndices();
await _createFtsObjects();
},
Expand Down
38 changes: 36 additions & 2 deletions lib/features/library/data/metadata_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,15 @@ class MetadataRepository {
return [for (final r in rows) r.readTable(_db.tags).name];
}

Future<void> addTagToItem(String itemId, String name) async {
/// Applies tag [name] to [itemId]. [source] records provenance — `'user'`
/// (manual/graph) or `'ai'` (auto-applied, P13c-2). `insertOrIgnore` means an
/// existing link keeps its original source, so a user-applied tag is never
/// demoted to `'ai'`.
Future<void> addTagToItem(
String itemId,
String name, {
String source = 'user',
}) async {
final clean = name.trim();
if (clean.isEmpty) return;
await _db
Expand All @@ -733,11 +741,31 @@ class MetadataRepository {
await _db
.into(_db.mediaTags)
.insert(
MediaTagsCompanion.insert(itemId: itemId, tagId: tag.id),
MediaTagsCompanion.insert(
itemId: itemId,
tagId: tag.id,
source: Value(source),
),
mode: InsertMode.insertOrIgnore,
);
}

/// The names of an item's **AI-applied** tags (P13c-2) — for marking them
/// distinctly in the UI. Reactive.
Stream<Set<String>> watchAiTagNamesForItem(String itemId) {
final query =
_db.select(_db.tags).join([
innerJoin(_db.mediaTags, _db.mediaTags.tagId.equalsExp(_db.tags.id)),
])..where(
_db.mediaTags.itemId.equals(itemId) &
_db.mediaTags.source.equals('ai'),
);
return query
.map((row) => row.readTable(_db.tags).name)
.watch()
.map((names) => names.toSet());
}

Future<void> removeTagFromItem(String itemId, int tagId) async {
await (_db.delete(
_db.mediaTags,
Expand Down Expand Up @@ -822,6 +850,12 @@ final tagsForItemProvider = StreamProvider.family<List<Tag>, String>(
ref.watch(metadataRepositoryProvider).watchTagsForItem(itemId),
);

/// Names of an item's AI-applied tags (P13c-2), for marking them in the UI.
final aiTagNamesForItemProvider = StreamProvider.family<Set<String>, String>(
(ref, itemId) =>
ref.watch(metadataRepositoryProvider).watchAiTagNamesForItem(itemId),
);

final collectionsProvider = StreamProvider<List<Collection>>(
(ref) => ref.watch(metadataRepositoryProvider).watchCollections(),
);
Expand Down
6 changes: 6 additions & 0 deletions lib/features/library/presentation/item_detail_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,9 @@ class _TagsRow extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tokens = GrabBitTokens.of(context);
final tags = ref.watch(tagsForItemProvider(itemId));
final aiTags =
ref.watch(aiTagNamesForItemProvider(itemId)).asData?.value ??
const <String>{};
return tags.maybeWhen(
data: (list) => list.isEmpty
? const SizedBox.shrink()
Expand All @@ -1657,6 +1660,9 @@ class _TagsRow extends ConsumerWidget {
children: [
for (final t in list)
ActionChip(
avatar: aiTags.contains(t.name)
? const Icon(Icons.auto_awesome_outlined, size: 16)
: null,
label: Text(t.name),
onPressed: () => _openHub(context, 'tag', t.name, t.name),
),
Expand Down
9 changes: 9 additions & 0 deletions lib/features/library/presentation/metadata_edit_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class _TagsEditor extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final tokens = GrabBitTokens.of(context);
final tags = ref.watch(tagsForItemProvider(itemId));
final aiTags =
ref.watch(aiTagNamesForItemProvider(itemId)).asData?.value ??
const <String>{};
final repo = ref.read(metadataRepositoryProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand All @@ -148,6 +151,12 @@ class _TagsEditor extends ConsumerWidget {
children: [
for (final tag in list)
Chip(
avatar: aiTags.contains(tag.name)
? const Icon(
Icons.auto_awesome_outlined,
size: 16,
)
: null,
label: Text(tag.name),
onDeleted: () =>
repo.removeTagFromItem(itemId, tag.id),
Expand Down
25 changes: 25 additions & 0 deletions lib/features/library/presentation/tag_suggestions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,28 @@ List<String> parseTagSuggestions(
}
return out;
}

/// What auto-tag-on-download (P13c-2) should do for a freshly downloaded item,
/// assuming the feature + generation are opted in. A pure decision so the queue
/// path is testable (mirrors `autoSummaryDecision`).
enum AutoTagDecision {
/// Nothing to tag (no source text).
skip,

/// Would tag, but the generation model isn't downloaded — nudge once.
needsModel,

/// Generate + apply tags now (model ready).
tag,
}

/// [hasText] is whether the item has title/description/transcript/OCR to tag;
/// [modelReady] is whether the generation model is downloaded (`ensureReady` —
/// no fetch).
AutoTagDecision autoTagDecision({
required bool hasText,
required bool modelReady,
}) {
if (!hasText) return AutoTagDecision.skip;
return modelReady ? AutoTagDecision.tag : AutoTagDecision.needsModel;
}
88 changes: 88 additions & 0 deletions lib/features/queue/presentation/queue_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'package:grabbit/features/library/data/metadata_repository.dart';
import 'package:grabbit/features/library/data/transcript_service.dart';
import 'package:grabbit/features/library/presentation/ai_summary.dart';
import 'package:grabbit/features/library/presentation/ocr.dart';
import 'package:grabbit/features/library/presentation/tag_suggestions.dart';
import 'package:grabbit/features/notifications/data/notification_enums.dart';
import 'package:grabbit/features/notifications/data/notifications_repository.dart';
import 'package:grabbit/features/notifications/data/system_notification_service.dart';
Expand Down Expand Up @@ -55,6 +56,10 @@ typedef _PersistResult = ({
bool summaryNeedsModel,
// P13b-3: count of image items auto-scanned for text (OCR).
int ocrCount,
// P13c-2: count of items auto-tagged, and whether auto-tag is opted in but
// the generation model isn't downloaded → prompt to finish setup.
int tagCount,
bool tagNeedsModel,
});

class QueueConfig {
Expand Down Expand Up @@ -436,6 +441,31 @@ class QueueController extends _$QueueController {
dedupeKey: 'ocr_$id',
);
}
// P13c-2: auto-tag applied AI tags to the new download.
if (result.tagCount > 0) {
await center.post(
category: NotificationCategory.ai,
severity: NotificationSeverity.success,
title: queued.title,
body: result.tagCount > 1
? '${result.tagCount} tags added'
: 'Tag added',
targetRoute: route,
itemId: single ? result.primaryId : null,
dedupeKey: 'tags_$id',
);
}
// P13c-2: opted into auto-tagging but the generation model isn't downloaded.
if (result.tagNeedsModel) {
await center.post(
category: NotificationCategory.ai,
severity: NotificationSeverity.info,
title: 'Finish setting up auto-tagging',
body: 'Download a text-generation model to auto-tag new downloads.',
targetRoute: '/settings/ai',
dedupeKey: 'tags_needs_model',
);
}
await _maybeNotifyOs(
taskId: id,
title: queued.title,
Expand Down Expand Up @@ -597,6 +627,8 @@ class QueueController extends _$QueueController {
summaryCount: 0,
summaryNeedsModel: false,
ocrCount: 0,
tagCount: 0,
tagNeedsModel: false,
);
// Files land in a per-task subfolder (see YtDlpHost `-o`): the task id names
// the folder, the user's template names the file inside it.
Expand Down Expand Up @@ -833,6 +865,60 @@ class QueueController extends _$QueueController {
}
}

// P13c-2: auto-apply LLM tags (marked 'ai') to freshly downloaded items
// when opted in + a generation model is already downloaded (no surprise
// fetch). Mirrors auto-summarize; per-item failures never fail the download.
var tagCount = 0;
var tagNeedsModel = false;
if (settings.autoTagOnDownload && settings.generationEnabled) {
final generation = ref.read(generationEngineProvider);
final genReady = await generation.ensureReady();
final metadata = ref.read(metadataRepositoryProvider);
for (final (i, _) in outputs.media.indexed) {
final itemId = single ? id : '${id}__$i';
final item = await metadata.mediaItemById(itemId);
final meta = await metadata.metadataForItem(itemId);
final src = [
if (item != null) item.title,
?meta?.description,
?meta?.transcript,
?meta?.ocrText,
].where((s) => s.trim().isNotEmpty).join('\n');
switch (autoTagDecision(
hasText: src.trim().isNotEmpty,
modelReady: genReady,
)) {
case AutoTagDecision.skip:
continue;
case AutoTagDecision.needsModel:
tagNeedsModel = true;
continue;
case AutoTagDecision.tag:
try {
final existing = await metadata.tagNamesForItem(itemId);
final p = buildTagPrompt(src);
final buffer = StringBuffer();
await for (final token in generation.generate(
p.prompt,
systemPrompt: p.systemPrompt,
)) {
buffer.write(token);
}
final tags = parseTagSuggestions(
buffer.toString(),
exclude: existing.toSet(),
);
for (final t in tags) {
await metadata.addTagToItem(itemId, t, source: 'ai');
}
tagCount += tags.length;
} catch (_) {
// A per-item tagging failure must not fail the download.
}
}
}
}

return (
primaryId: single ? id : '${id}__0',
itemCount: outputs.media.length,
Expand All @@ -841,6 +927,8 @@ class QueueController extends _$QueueController {
summaryCount: summaryCount,
summaryNeedsModel: summaryNeedsModel,
ocrCount: ocrCount,
tagCount: tagCount,
tagNeedsModel: tagNeedsModel,
);
}

Expand Down
4 changes: 4 additions & 0 deletions lib/features/settings/data/settings_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ abstract class SettingsModel with _$SettingsModel {
// images, on-device + offline (bundled ML Kit, no download). The on-demand
// "Scan text" on item detail (P13b-1) works regardless.
@Default(false) bool autoOcrOnDownload,
// P13c-2: auto-apply LLM-suggested tags to a newly downloaded item in the
// background (marked as 'ai', deletable). Opt-in (defaults off); runs only
// when text generation is enabled and its model is already downloaded.
@Default(false) bool autoTagOnDownload,
// On-device speech transcription (P12e). Opt-in (defaults off); the whisper
// model is downloaded only when the user enables it + picks a model.
// `selectedTranscriptionModelId` empty = the device-tier recommendation;
Expand Down
Loading
Loading