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:
parent
cca10e855e
commit
2b7d04fd70
6 changed files with 2030 additions and 77 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in a new issue