feat(learn): an assistant reads PDFs' pages, and sees images the person chooses or approves

ADR 0021, D12: text first. Lookups find a studyset's PDFs and read a page's
text as PDFium extracts it. An assistant that reads images is offered a
card's images as chips, and may ask to see a card's image or a PDF's page
drawn whole; the conversation asks the person each time (Show it, Don't
show) and sends nothing meanwhile. Images go only as png, jpeg, gif or webp
of at most 5 MB, are never kept in a turn, and a follow-up goes without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 21:54:56 +01:00
commit 2b7d04fd70
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 2030 additions and 77 deletions

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -9,14 +10,17 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_looku
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_lookup.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../../support/fake_assistant_files.dart';
import '../../../support/fake_conversation_gateway.dart';
// Asking an assistant in a conversation (ADR 0021, D7, D47, D51, D52): the
@ -33,6 +37,16 @@ const _claude = OfferedAssistant(
model: 'claude-sonnet',
);
/// An assistant that reads images (D12).
const _seeing = OfferedAssistant(
id: 'seeing',
name: 'Seeing',
protocol: 'openai',
host: 'api.openai.com',
model: 'gpt',
images: true,
);
const _usage = LlmUsage(inputTokens: 100, outputTokens: 20);
/// The model answering [text], streamed in two pieces.
@ -99,6 +113,8 @@ void main() {
_card('c-kuru', '来る', 'irregular'),
],
notesOf: (_) async => const [],
pdfsOf: (_) async => const [],
files: FakeAssistantFiles(),
);
setUp(() {
@ -241,7 +257,14 @@ void main() {
final (_, first) = gateway.asked.first;
expect(
[for (final tool in first.tools) tool.name],
['list_studysets', 'find_cards', 'find_notes', 'read_note'],
[
'list_studysets',
'find_cards',
'find_notes',
'read_note',
'find_pdfs',
'read_pdf_page',
],
);
expect(first.system, contains('the studyset “Verbs”'));
final (_, second) = gateway.asked.last;
@ -267,6 +290,8 @@ void main() {
studysets: () async => [_verbs],
cardsOf: (_) async => throw const FormatException('unreadable'),
notesOf: (_) async => const [],
pdfsOf: (_) async => const [],
files: FakeAssistantFiles(),
);
addTearDown(() => lookups = working);
final conversation = cubit(
@ -506,6 +531,8 @@ void main() {
],
cardsOf: (_) async => const [],
notesOf: (_) async => const [],
pdfsOf: (_) async => const [],
files: FakeAssistantFiles(),
);
final conversation = cubit(
task: AssistantTask.check,
@ -523,4 +550,273 @@ void main() {
await conversation.close();
},
);
group('images (D12)', () {
final files = FakeAssistantFiles()
..images['img-1'] = LlmImage(
mediaType: 'image/png',
bytes: Uint8List.fromList([1, 2, 3]),
)
..pages['p-genki'] = {2: 'A table of verbs.'};
final pictured = AssistantLookups(
studysets: () async => [_verbs],
cardsOf: (_) async => const [],
notesOf: (_) async => const [],
pdfsOf: (_) async => [
const PdfSource(
id: 'p-genki',
studysetId: 'verbs',
content: PdfSourceContent(
title: 'Genki I',
fileName: 'genki.pdf',
assetId: 'asset-genki',
pageCount: 12,
),
),
],
files: files,
);
const library = AssistantScope(reach: AssistantReach.library);
const viewPage = LlmToolCall(
id: 'call-page',
name: 'view_pdf_page',
input: {'id': 'p-genki', 'page': 2},
);
const asked = AskedImage(
kind: LookupKind.pageImage,
title: 'Genki I',
number: 2,
pdfId: 'p-genki',
);
const chosenImage = AssistantPart(
kind: AssistantPartKind.image,
text: 'img-1',
number: 1,
);
setUp(() {
final working = lookups;
lookups = pictured;
addTearDown(() => lookups = working);
});
List<LlmImage> imagesIn(List<LlmMessage> messages) => [
for (final message in messages) ...message.parts.whereType<LlmImage>(),
];
/// Asks [conversation] [question], answering the image the model asks
/// to see with [answer] once it is asked; the image asked about.
Future<AskedImage?> askApproving(
ConversationCubit conversation,
String question,
void Function() answer,
) async {
AskedImage? approving;
final listening = conversation.stream.listen((state) {
if (state.asking?.approving case final image? when approving == null) {
approving = image;
answer();
}
});
await conversation.ask(question, const []);
await listening.cancel();
return approving;
}
test('an image chosen goes along to an assistant reading images, and '
'is kept as a mark, so a follow-up goes without it', () async {
final conversation = cubit(choice: const GrantedAssistant(_seeing));
await conversation.open();
gateway.answers
..add(_says('A cat.'))
..add(_says('Grey.'));
await conversation.ask('What is it?', const [chosenImage]);
final question = gateway.asked.single.$2.messages.single;
expect(imagesIn([question]).single.bytes, [1, 2, 3]);
expect(imagesIn(conversation.state.turns.single.messages), isEmpty);
final saved = (await conversations.all()).single;
expect(
imagesIn((await conversations.turnsOf(saved.id)).single.messages),
isEmpty,
);
await conversation.ask('Which colour?', const []);
expect(imagesIn(gateway.asked.last.$2.messages), isEmpty);
expect(
TurnView.of(conversation.state.turns.first.messages).sent,
[
const AssistantPart(
kind: AssistantPartKind.image,
text: '',
number: 1,
),
],
);
await conversation.close();
});
test('an image chosen never goes to an assistant not reading them, nor '
'is it said to', () async {
final conversation = cubit();
await conversation.open();
gateway.answers.add(_says('Hello.'));
await conversation.ask('Hi', const [chosenImage]);
final question = gateway.asked.single.$2.messages.single;
expect(imagesIn([question]), isEmpty);
expect(question.text, isNot(contains('image')));
// Alone, it is no question at all.
await conversation.ask('', const [chosenImage]);
expect(gateway.asked, hasLength(1));
await conversation.close();
});
test('an image chosen that cannot be had is said to be missing', () async {
final conversation = cubit(choice: const GrantedAssistant(_seeing));
await conversation.open();
gateway.answers.add(_says('I see nothing.'));
await conversation.ask('What is it?', const [
AssistantPart(kind: AssistantPartKind.image, text: 'gone', number: 1),
]);
final question = gateway.asked.single.$2.messages.single;
expect(imagesIn([question]), isEmpty);
expect(question.text, contains(imageNotHad));
await conversation.close();
});
test(
'what shows images is offered only to an assistant reading them',
() async {
for (final (choice, offered) in [
(const GrantedAssistant(_seeing), true),
(const GrantedAssistant(_claude), false),
]) {
final conversation = cubit(scope: library, choice: choice);
await conversation.open();
gateway.answers.add(_says('Yes.'));
await conversation.ask('Any figures?', const []);
expect(
[for (final tool in gateway.asked.last.$2.tools) tool.name],
offered
? contains('view_pdf_page')
: isNot(contains('view_pdf_page')),
);
await conversation.close();
}
},
);
test('a page the model asks to see goes once the person approves it, '
'after the results', () async {
final conversation = cubit(
scope: library,
choice: const GrantedAssistant(_seeing),
);
await conversation.open();
gateway.answers
..add(_looksUp([viewPage]))
..add(_says('The table lists godan verbs.'));
final approving = await askApproving(
conversation,
'What does page 2 show?',
() => conversation.approveImage(approved: true),
);
expect(approving, asked);
final shown = gateway.asked.last.$2.messages.last.parts;
expect(shown, hasLength(3));
expect((shown[0] as LlmToolResult).isError, isFalse);
expect((shown[1] as LlmText).text, asked.label);
expect((shown[2] as LlmImage).mediaType, 'image/png');
expect(files.drawn, ['p-genki:2']);
final turn = conversation.state.turns.single;
expect(imagesIn(turn.messages), isEmpty);
expect(
TurnView.of(turn.messages).lookups,
[const AssistantLookup(kind: LookupKind.pageImage, found: 1)],
);
await conversation.close();
});
test('refused, the model is told so and sees nothing', () async {
files.drawn.clear();
final conversation = cubit(
scope: library,
choice: const GrantedAssistant(_seeing),
);
await conversation.open();
gateway.answers
..add(_looksUp([viewPage]))
..add(_says('Without it, I cannot say.'));
await askApproving(
conversation,
'What does page 2 show?',
() => conversation.approveImage(approved: false),
);
final results = gateway.asked.last.$2.messages.last.parts;
final result = results.single as LlmToolResult;
expect(result.isError, isTrue);
expect(result.content, contains('chose not to show it'));
expect(imagesIn(gateway.asked.last.$2.messages), isEmpty);
expect(files.drawn, isEmpty);
await conversation.close();
});
test('stopped while asked, the question goes no further', () async {
final conversation = cubit(
scope: library,
choice: const GrantedAssistant(_seeing),
);
await conversation.open();
gateway.answers
..add(_looksUp([viewPage]))
..add(_says('Never asked.'));
await askApproving(
conversation,
'What does page 2 show?',
() => unawaited(conversation.stop()),
);
expect(gateway.asked, hasLength(1));
expect(conversation.state.asking, isNull);
expect(conversation.state.unanswered?.failure, isNull);
expect(conversation.state.turns, isEmpty);
await conversation.close();
});
test('an image out of reach is never asked about', () async {
final conversation = cubit(
scope: const AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'elsewhere',
),
choice: const GrantedAssistant(_seeing),
);
await conversation.open();
gateway.answers
..add(_looksUp([viewPage]))
..add(_says('Nothing there.'));
final approving = await askApproving(
conversation,
'What does page 2 show?',
() => fail('asked to approve an image out of reach'),
);
expect(approving, isNull);
final result =
gateway.asked.last.$2.messages.last.parts.single as LlmToolResult;
expect(result.isError, isTrue);
await conversation.close();
});
});
}

View file

@ -1,8 +1,10 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
@ -24,10 +26,14 @@ import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_conversation_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
import '../../support/memory_device_settings_store.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
@ -49,6 +55,17 @@ OfferedAssistant _claude({bool agreed = true}) => OfferedAssistant(
agreedAt: agreed ? _agreed : null,
);
/// An assistant that reads images (D12).
final _seeing = OfferedAssistant(
id: 'seeing',
name: 'Seeing',
protocol: 'openai',
host: 'api.openai.com',
model: 'gpt',
images: true,
agreedAt: _agreed,
);
List<LlmEvent> _says(String text) => [
LlmTextDelta(text),
LlmFinished(
@ -99,6 +116,8 @@ void main() {
late FakeLlmClient llm;
late MemoryDeviceSettingsStore store;
late List<Uri> searched;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache assetFiles;
setUp(() {
assistants = FakeAssistantGateway()
@ -120,6 +139,8 @@ void main() {
settings = FakeSettingsGateway();
llm = FakeLlmClient();
searched = [];
assetGateway = FakeAssetGateway();
assetFiles = MemoryAssetFileCache();
});
Future<void> showApp(
@ -140,6 +161,8 @@ void main() {
studysetGateway: studysets,
cardGateway: cards,
settingsGateway: settings,
assetGateway: assetGateway,
assetFiles: assetFiles,
llm: llm,
webSearch: WebSearch(
open: (url) async {
@ -282,7 +305,15 @@ void main() {
final request = assistants.asked.single.$2;
expect(
[for (final tool in request.tools) tool.name],
['list_studysets', 'find_cards', 'find_notes', 'read_note'],
[
'list_studysets',
'find_cards',
'find_notes',
'read_note',
'find_pdfs',
'read_pdf_page',
],
reason: 'nothing showing images to an assistant not reading them',
);
expect(request.system, contains('the studyset “Japanese”'));
});
@ -795,4 +826,172 @@ void main() {
expect(find.byTooltip('Check for mistakes'), findsNothing);
});
});
group('images (D12)', () {
/// Writes a card into Verbs with a picture of a bowl on its front, as
/// another device would have; its id.
Future<String> picturedCard() async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
assetFiles,
FakeAssetSealing(),
);
addTearDown(assets.dispose);
final image = await assets.add(
Uint8List.fromList(utf8.encode('a bowl of rice')),
fileName: 'bowl.png',
mimeType: 'image/png',
studysetId: null,
);
final repository = CardRepository(cards);
addTearDown(repository.dispose);
return await repository.create(
'verbs',
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': '飯'},
],
},
},
{
'type': 'image',
'data': {'url': 'asset:$image'},
},
],
},
}),
back: RichContent.plain('rice, a meal'),
),
);
}
List<LlmImage> imagesIn(LlmRequest request) => [
for (final message in request.messages)
...message.parts.whereType<LlmImage>(),
];
testWidgets("a card's image is offered to an assistant reading images, "
'and goes along once chosen', (tester) async {
assistants.offer = AssistantsOffer(assistants: [_seeing]);
await picturedCard();
await showApp(tester, location: RoutePaths.study('japanese'));
await unfold(tester);
// The chips wrap to four lines on a phone's study screen.
await tester.ensureVisible(find.text('Image 1'));
await tester.pumpAndSettle();
await tester.tap(find.text('Image 1'));
await tester.pump();
assistants.answers.add(_says('A bowl of rice.'));
await ask(tester, 'What is this?');
final request = assistants.asked.single.$2;
expect(imagesIn(request).single.bytes, utf8.encode('a bowl of rice'));
expect(find.text('A bowl of rice.', findRichText: true), findsOneWidget);
});
testWidgets('an assistant not reading images is offered none', (
tester,
) async {
await picturedCard();
await showApp(tester, location: RoutePaths.study('japanese'));
await unfold(tester);
expect(find.text('Image 1'), findsNothing);
expect(find.textContaining('Front'), findsOneWidget);
});
testWidgets('an image the model asks to see is shown to it only once '
'the person approves it in the conversation', (tester) async {
assistants.offer = AssistantsOffer(assistants: [_seeing]);
final card = await picturedCard();
await showApp(tester, location: RoutePaths.studyset('japanese'));
await unfold(tester);
await tester.tap(find.text('Only what I choose'));
await tester.pumpAndSettle();
await tester.tap(find.text('This studyset with its topics'));
await tester.pumpAndSettle();
assistants.answers
..add([
LlmFinished(
message: LlmMessage.assistant([
LlmToolCall(
id: 'call-1',
name: 'view_card_image',
input: {'id': card, 'number': 1},
),
]),
stop: LlmStop.toolCalls,
usage: const LlmUsage(inputTokens: 10, outputTokens: 5),
),
])
..add(_says('Your card shows a bowl of rice.'));
await ask(tester, 'What does my card about 飯 show?');
expect(
find.text('Seeing asks to see image 1 of the card “飯”.'),
findsOneWidget,
);
expect(assistants.asked, hasLength(1), reason: 'nothing sent meanwhile');
await tester.tap(find.text('Show it'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Show it'), findsNothing);
expect(
imagesIn(assistants.asked.last.$2).single.bytes,
utf8.encode('a bowl of rice'),
);
expect(find.text('Was shown an image you approved'), findsOneWidget);
expect(
find.text('Your card shows a bowl of rice.', findRichText: true),
findsOneWidget,
);
});
testWidgets("Don't show tells the model so, and shows it nothing", (
tester,
) async {
assistants.offer = AssistantsOffer(assistants: [_seeing]);
final card = await picturedCard();
await showApp(tester, location: RoutePaths.studyset('japanese'));
await unfold(tester);
await tester.tap(find.text('Only what I choose'));
await tester.pumpAndSettle();
await tester.tap(find.text('This studyset with its topics'));
await tester.pumpAndSettle();
assistants.answers
..add([
LlmFinished(
message: LlmMessage.assistant([
LlmToolCall(
id: 'call-1',
name: 'view_card_image',
input: {'id': card, 'number': 1},
),
]),
stop: LlmStop.toolCalls,
usage: const LlmUsage(inputTokens: 10, outputTokens: 5),
),
])
..add(_says('I cannot see it.'));
await ask(tester, 'What does my card show?');
await tester.tap(find.text("Don't show"));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(imagesIn(assistants.asked.last.$2), isEmpty);
expect(find.text('Was not shown an image'), findsOneWidget);
});
});
}

View file

@ -0,0 +1,107 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' show Rect;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_sealing.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_engine.dart';
import '../../../support/memory_asset_file_cache.dart';
// What a model is sent of the person's files (ADR 0021, D12): images every
// provider reads, none larger than the smallest limit, and a PDF's pages
// as text and drawn whole.
void main() {
late AssetRepository assets;
late FakePdfEngine engine;
late DeviceAssistantFiles files;
setUp(() {
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
FakeAssetSealing(),
);
engine = FakePdfEngine();
files = DeviceAssistantFiles(assets, engine);
});
Future<String> add(List<int> bytes, String mimeType) => assets.add(
Uint8List.fromList(bytes),
fileName: 'file',
mimeType: mimeType,
studysetId: null,
);
Future<PdfSource> pdf({int pages = 3}) async => PdfSource(
id: 'p',
studysetId: 's',
content: PdfSourceContent(
title: 'Genki I',
fileName: 'genki.pdf',
assetId: await add(utf8.encode('%PDF- pages:$pages'), 'application/pdf'),
),
);
test('gives an image a model reads', () async {
final types = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
for (final (index, type) in types.indexed) {
// Bytes of its own, or it would be the same file.
final id = await add([1, 2, 3, index], type);
final image = await files.image(id);
expect(image?.mediaType, type);
expect(image?.bytes, [1, 2, 3, index]);
}
});
test('gives no other file, nor one larger than a provider takes', () async {
expect(await files.image(await add([1], 'image/svg+xml')), isNull);
expect(await files.image(await add([2], 'audio/mpeg')), isNull);
expect(
await files.image(
await add(
List.filled(DeviceAssistantFiles.maxImageBytes + 1, 7),
'image/png',
),
),
isNull,
);
expect(await files.image('no-such-asset'), isNull);
});
test("reads a page's text, and none of a page it hasn't", () async {
engine.texts[2] = 'Verbs ending in -ru.';
final genki = await pdf();
expect(await files.pageText(genki, 2), 'Verbs ending in -ru.');
expect(await files.pageText(genki, 4), isNull);
expect(await files.pageText(genki, 0), isNull);
});
test('draws a page whole, as a PNG', () async {
final genki = await pdf();
final image = await files.pageImage(genki, 3);
expect(image?.mediaType, 'image/png');
expect(image?.bytes, engine.image);
expect(engine.rendered.single.page, 3);
expect(engine.rendered.single.region, const Rect.fromLTWH(0, 0, 1, 1));
});
test('gives nothing of a PDF it cannot open, nor a page too large', () async {
final genki = await pdf();
engine.failNext = true;
expect(await files.pageText(genki, 1), isNull);
engine.image = Uint8List(DeviceAssistantFiles.maxImageBytes + 1);
expect(await files.pageImage(genki, 1), isNull);
});
}

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -9,10 +10,13 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../../support/fake_assistant_files.dart';
// What a model may look up, and only within the scope the person chose
// (ADR 0021, D7, D8, D11): read-only, on the device, never progress.
@ -50,6 +54,24 @@ Card _card(
heads: const {},
);
PdfSource _pdf(String id, String studysetId, String title, {int? pages}) =>
PdfSource(
id: id,
studysetId: studysetId,
content: PdfSourceContent(
title: title,
fileName: '$title.pdf',
assetId: 'asset-$id',
pageCount: pages,
),
);
/// A block showing the person's image [assetId].
Map<String, Object?> _image(String assetId) => {
'type': 'image',
'data': {'url': 'asset:$assetId'},
};
Note _note(String id, String title, String body) => Note(
id: id,
position: 'a0',
@ -86,6 +108,46 @@ final _cards = {
'locked': [_card('c-locked', 'secret', 'irregular')],
};
// A card of Verbs with two images, one on each side.
final _pictured = Card(
id: 'c-pictured',
position: 'a1',
content: CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': '食べる'},
],
},
},
_image('img-eat'),
],
},
}),
back: RichContent({
'document': {
'type': 'page',
'children': [_image('img-bowl')],
},
}),
),
heads: const {},
);
final _pdfs = {
'verbs': [
_pdf('p-genki', 'verbs', 'Genki I', pages: 12),
_pdf('p-drill', 'verbs', 'Verb drills'),
],
'kanji': [_pdf('p-kanji', 'kanji', 'Kanji in context', pages: 3)],
'es': [_pdf('p-es', 'es', 'Gramática', pages: 5)],
};
final _notes = {
'verbs': [
_note(
@ -97,10 +159,12 @@ final _notes = {
'es': [_note('n-es', 'Irregular verbs', 'ser, ir, hacer')],
};
AssistantLookups _lookups() => AssistantLookups(
AssistantLookups _lookups({FakeAssistantFiles? files}) => AssistantLookups(
studysets: () async => _studysets,
cardsOf: (id) async => _cards[id] ?? const [],
notesOf: (id) async => _notes[id] ?? const [],
pdfsOf: (id) async => _pdfs[id] ?? const [],
files: files ?? FakeAssistantFiles(),
);
Future<Object?> _answer(
@ -141,11 +205,36 @@ void main() {
))
tool.name,
],
['list_studysets', 'find_cards', 'find_notes', 'read_note'],
[
'list_studysets',
'find_cards',
'find_notes',
'read_note',
'find_pdfs',
'read_pdf_page',
],
);
}
});
test('offers what shows images only to an assistant reading them, and '
'only with its lookups (D12)', () {
const scope = AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ja',
);
List<String> names(List<LlmTool> tools) => [
for (final tool in tools) tool.name,
];
expect(names(lookups.toolsFor(scope)), isNot(contains('view_pdf_page')));
expect(
names(lookups.toolsFor(scope, images: true)),
containsAllInOrder(['read_pdf_page', 'view_card_image', 'view_pdf_page']),
);
expect(lookups.toolsFor(const AssistantScope(), images: true), isEmpty);
});
group('reaches', () {
test('a studyset with everything inside it, but neither archived nor '
'private ones (D8)', () async {
@ -213,6 +302,8 @@ void main() {
],
cardsOf: (id) async => const [],
notesOf: (id) async => const [],
pdfsOf: (id) async => const [],
files: FakeAssistantFiles(),
);
Future<List<String>> sent(AssistantScope scope) async => [
for (final studyset in await withKorean.privateSent(scope)) studyset.id,
@ -383,6 +474,8 @@ void main() {
for (var i = 0; i < 40; i++) _card('c-$i', 'word $i', 'meaning'),
],
notesOf: (_) async => const [],
pdfsOf: (_) async => const [],
files: FakeAssistantFiles(),
);
const scope = AssistantScope(reach: AssistantReach.library);
@ -420,6 +513,233 @@ void main() {
});
});
group("reads a PDF's pages as text (D12)", () {
final files = FakeAssistantFiles()
..pages['p-genki'] = {3: ' Verbs ending in -ru. ', 4: 'x' * 7000}
..pages['p-kanji'] = {1: 'secret'};
final reader = _lookups(files: files);
const scope = AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ja',
);
test('finding those within reach by their titles, or all', () async {
expect(await _answer(reader, 'find_pdfs', {}, scope), [
{
'id': 'p-genki',
'studyset': 'Verbs',
'title': 'Genki I',
'pages': 12,
},
{'id': 'p-drill', 'studyset': 'Verbs', 'title': 'Verb drills'},
]);
expect(
_ids(await _answer(reader, 'find_pdfs', {'query': 'GENKI'}, scope)),
['p-genki'],
);
expect(
_ids(
await _answer(
reader,
'find_pdfs',
{},
const AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ja',
includePrivate: true,
),
),
),
['p-genki', 'p-drill', 'p-kanji'],
);
});
test('a page, its text cut as a note is', () async {
expect(
await _answer(reader, 'read_pdf_page', {
'id': 'p-genki',
'page': 3,
}, scope),
{
'id': 'p-genki',
'studyset': 'Verbs',
'title': 'Genki I',
'page': 3,
'pages': 12,
'text': 'Verbs ending in -ru.',
},
);
final long =
(await _answer(reader, 'read_pdf_page', {
'id': 'p-genki',
'page': 4,
}, scope))!
as Map<String, Object?>;
expect(
(long['text']! as String).length,
AssistantLookups.maxPageLength,
);
});
test('never a PDF outside the scope, nor a page it cannot read', () async {
Future<LlmToolResult> read(String id, Object? page) => reader.answer(
LlmToolCall(
id: 'c',
name: 'read_pdf_page',
input: {
'id': id,
'page': page,
},
),
scope,
);
for (final result in [
await read('p-es', 1),
// Private, and not switched on.
await read('p-kanji', 1),
]) {
expect(result.isError, isTrue);
expect(result.content, contains('No PDF'));
}
expect((await read('p-genki', 9)).isError, isTrue);
expect((await read('p-genki', '3')).isError, isTrue);
expect(files.drawn, isEmpty);
});
});
group('images (D12)', () {
final files = FakeAssistantFiles()
..images['img-eat'] = LlmImage(
mediaType: 'image/png',
bytes: Uint8List.fromList([1, 2, 3]),
)
..pages['p-genki'] = {2: 'A table of verbs.'};
final pictured = AssistantLookups(
studysets: () async => _studysets,
cardsOf: (id) async => [...?_cards[id], if (id == 'verbs') _pictured],
notesOf: (id) async => _notes[id] ?? const [],
pdfsOf: (id) async => _pdfs[id] ?? const [],
files: files,
);
const scope = AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ja',
);
LlmToolCall call(String tool, Map<String, Object?> input) =>
LlmToolCall(id: 'c', name: tool, input: input);
test('a card found says how many it has, only to an assistant reading '
'them', () async {
Future<Object?> found({required bool images}) async => jsonDecode(
(await pictured.answer(
call('find_cards', {'query': '食べる'}),
scope,
images: images,
)).content,
);
expect(
((await found(images: true))! as List).single,
containsPair(
'images',
2,
),
);
expect(
((await found(images: false))! as List).single,
isNot(contains('images')),
);
});
test('are asked for within reach, for the person to approve', () async {
expect(
await pictured.askedImage(
call('view_card_image', {'id': 'c-pictured', 'number': 2}),
scope,
),
const AskedImage(
kind: LookupKind.cardImage,
title: '食べる',
number: 2,
assetId: 'img-bowl',
),
);
expect(
await pictured.askedImage(
call('view_pdf_page', {'id': 'p-genki', 'page': 2}),
scope,
),
const AskedImage(
kind: LookupKind.pageImage,
title: 'Genki I',
number: 2,
pdfId: 'p-genki',
),
);
});
test('never outside reach, nor one there is not', () async {
for (final asked in [
call('view_card_image', {'id': 'c-pictured', 'number': 3}),
call('view_card_image', {'id': 'c-pictured', 'number': 0}),
call('view_card_image', {'id': 'c-es', 'number': 1}),
call('view_pdf_page', {'id': 'p-genki', 'page': 13}),
call('view_pdf_page', {'id': 'p-kanji', 'page': 1}),
call('view_pdf_page', {'id': 'p-es', 'page': 1}),
call('view_pdf_page', {'id': 'p-genki'}),
call('find_cards', {'id': 'c-pictured'}),
]) {
expect(
await pictured.askedImage(asked, scope),
isNull,
reason: '$asked',
);
}
});
test(
'are never answered as a lookup is, before the person approves',
() async {
final result = await pictured.answer(
call('view_pdf_page', {'id': 'p-genki', 'page': 2}),
scope,
images: true,
);
expect(result.isError, isTrue);
expect(result.content, contains('approves'));
expect(files.drawn, isEmpty);
},
);
test('approved, are had from the files, a page drawn whole', () async {
final card = (await pictured.askedImage(
call('view_card_image', {'id': 'c-pictured', 'number': 1}),
scope,
))!;
expect((await pictured.imageOf(card, scope))?.bytes, [1, 2, 3]);
final page = (await pictured.askedImage(
call('view_pdf_page', {'id': 'p-genki', 'page': 2}),
scope,
))!;
expect((await pictured.imageOf(page, scope))?.mediaType, 'image/png');
expect(files.drawn, ['p-genki:2']);
// Out of reach by now: the scope no longer takes Verbs in.
expect(
await pictured.imageOf(
page,
const AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'es',
),
),
isNull,
);
});
});
group('refuses, as an error the model reads,', () {
const scope = AssistantScope(
reach: AssistantReach.studyset,

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
// A conversation's and its turns' payloads (ADR 0021, D51): what a follow-up

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
@ -67,6 +69,59 @@ void main() {
});
});
group('an image chosen (D12)', () {
final bowl = LlmImage(
mediaType: 'image/png',
bytes: Uint8List.fromList([1, 2, 3]),
);
const image = AssistantPart(
kind: AssistantPartKind.image,
text: 'asset-bowl',
number: 2,
);
test('goes labelled, the image after its label', () {
final message = questionMessage(
'What is it?',
const [
image,
],
images: {'asset-bowl': bowl},
);
expect(message.parts, hasLength(3));
expect((message.parts[1] as LlmText).text, "\n\nThe card's image 2:\n");
expect(message.parts[2], same(bowl));
});
test('that cannot be had is said to be missing', () {
final message = questionMessage('What is it?', const [image]);
expect(_texts(message).last, "\n\nThe card's image 2:\n$imageNotHad");
});
test('is left as a mark once answered, read back as the part it was', () {
final question = questionMessage(
'What is it?',
const [
image,
],
images: {'asset-bowl': bowl},
);
final answer = LlmMessage.assistant(const [LlmText('A bowl.')]);
final kept = withoutImages([question, answer]);
expect(kept.last, same(answer));
expect(_texts(kept.first).last, imageNotKept);
final asked = askedIn(kept.first);
expect(asked.question, 'What is it?');
expect(asked.parts, [
const AssistantPart(kind: AssistantPartKind.image, text: '', number: 2),
]);
});
});
group('the system prompt', () {
test('offers nothing to look up where only what is ticked goes', () {
final prompt = systemPrompt(const AssistantScope());