feat(learn): an assistant proposes flashcards from a text, a note or a PDF page

The cards come as drafts (ADR 0021, D10, D55): one question with one
tool, propose_cards, the cards in a sheet, every one chosen at first,
each changed in a small sheet of its own or left out, and only those
chosen added, marked as an assistant's (origin "assistant"). They are
proposed from a text pasted in Add material, from a note's title and
text, and from a PDF's page or the text selected on it, tied to that page
(D56). A note or a page of a private studyset goes only once agreed to
(D9), the text pasted counting as the person's own; nothing is offered
without an assistant. PDFs give their pages' text (PdfDocumentView.pageText).

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:30:59 +01:00
commit cca10e855e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1553 additions and 15 deletions

View file

@ -0,0 +1,179 @@
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/feature/assistant/bloc/card_proposals_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/card_proposals.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../../support/fake_card_gateway.dart';
// Proposing cards from a text (ADR 0021, D55): one question with the one
// tool, the cards it proposes all chosen at first, changed or left out by
// the person, and only those chosen added, tied to the page they came from.
const _claude = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet',
);
const _usage = LlmUsage(inputTokens: 100, outputTokens: 20);
/// The model calling propose_cards with [cards], front and back.
List<LlmEvent> _proposes(List<(String, String)> cards) => [
LlmFinished(
message: LlmMessage.assistant([
LlmToolCall(
id: 'call-1',
name: 'propose_cards',
input: {
'cards': [
for (final (front, back) in cards) {'front': front, 'back': back},
],
},
),
]),
stop: LlmStop.toolCalls,
usage: _usage,
),
];
void main() {
late FakeAssistantGateway gateway;
late FakeCardGateway cardGateway;
late CardRepository cards;
setUp(() {
gateway = FakeAssistantGateway();
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
});
tearDown(() => cards.dispose());
CardProposalsCubit cubit({String text = 'Verbs: 食べる is to eat.'}) =>
CardProposalsCubit(
assistants: AssistantRepository(gateway),
cards: cards,
choice: const GrantedAssistant(_claude),
text: text,
studysetId: 'verbs',
source: (pdfId: 'pdf-1', page: 3),
);
test('asks once with the text and the one tool, then holds the cards '
'proposed, every one chosen', () async {
gateway.answers.add(
_proposes([('食べる', 'to eat'), ('飲む', 'to drink')]),
);
final proposals = cubit();
addTearDown(proposals.close);
expect(proposals.state.stage, ProposalStage.writing);
await proposals.propose();
final (assistant, request) = gateway.asked.single;
expect(assistant, 'claude');
expect(request.tools, [proposeCardsTool]);
expect(request.system, proposalPrompt());
expect(
(request.messages.single.parts.single as LlmText).text,
'Verbs: 食べる is to eat.',
);
expect(proposals.state.stage, ProposalStage.proposed);
expect(proposals.state.cards, const [
ProposedCard(front: '食べる', back: 'to eat'),
ProposedCard(front: '飲む', back: 'to drink'),
]);
expect(proposals.state.chosen, {0, 1});
});
test('a long text goes cut to $maxProposalSource characters', () async {
gateway.answers.add(_proposes([('Q', 'A')]));
final proposals = cubit(text: 'x' * (maxProposalSource + 100));
addTearDown(proposals.close);
await proposals.propose();
final (_, request) = gateway.asked.single;
expect(
(request.messages.single.parts.single as LlmText).text,
hasLength(maxProposalSource),
);
});
test(
'an answer without the call proposes none, and can be asked again',
() async {
gateway.answers
..add([
LlmFinished(
message: LlmMessage.assistant(const [LlmText('Q: A')]),
stop: LlmStop.end,
usage: _usage,
),
])
..add(_proposes([('Q', 'A')]));
final proposals = cubit();
addTearDown(proposals.close);
await proposals.propose();
expect(proposals.state.stage, ProposalStage.none);
await proposals.propose();
expect(proposals.state.stage, ProposalStage.proposed);
expect(gateway.asked, hasLength(2));
},
);
test('a refusal says why, nothing proposed', () async {
gateway.failing = const AssistantFailed(AssistantProblem.limitReached);
final proposals = cubit();
addTearDown(proposals.close);
await proposals.propose();
expect(proposals.state.stage, ProposalStage.failed);
expect(proposals.state.failure?.problem, AssistantProblem.limitReached);
expect(proposals.state.cards, isEmpty);
});
test('adds only the cards chosen, as changed, tied to the page, marked as '
"an assistant's", () async {
gateway.answers.add(
_proposes([('食べる', 'to eat'), ('飲む', 'to drink'), ('見る', 'to see')]),
);
final proposals = cubit();
addTearDown(proposals.close);
await proposals.propose();
proposals
..toggle(1)
..edit(2, const ProposedCard(front: '見る', back: 'to see, to look'));
expect(proposals.state.chosen, {0, 2});
expect(await proposals.add(), 2);
final added = await cards.cardsOf('verbs');
expect(
[for (final card in added) card.content.back.plainText.trim()],
['to eat', 'to see, to look'],
);
expect(
{for (final card in added) card.content.source},
{
(pdfId: 'pdf-1', page: 3),
},
);
expect(
{
for (final revision in cardGateway.written)
cardOriginOf(revision.payload),
},
{proposedCardOrigin},
);
});
}

View file

@ -0,0 +1,305 @@
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/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/card_proposals_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:material_ui/material_ui.dart' hide Card;
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_note_gateway.dart';
import '../../support/fake_pdf_engine.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
// Flashcards an assistant proposes (ADR 0021, D55, D56): from a text
// pasted in Add material, a note or a PDF's page, in a sheet where they are
// chosen, changed and added; a private studyset's content only once agreed
// to go, and nothing offered without an assistant.
OfferedAssistant _claude() => OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-sonnet',
agreedAt: DateTime.utc(2026, 10, 25),
);
/// The model proposing [cards], front and back.
List<LlmEvent> _proposes(List<(String, String)> cards) => [
LlmFinished(
message: LlmMessage.assistant([
LlmToolCall(
id: 'call-1',
name: 'propose_cards',
input: {
'cards': [
for (final (front, back) in cards) {'front': front, 'back': back},
],
},
),
]),
stop: LlmStop.toolCalls,
usage: const LlmUsage(inputTokens: 10, outputTokens: 5),
),
];
/// The text of the question [request] asked.
String _sent(LlmRequest request) =>
(request.messages.single.parts.single as LlmText).text;
void main() {
late FakeAssistantGateway assistants;
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache files;
late FakePdfEngine engine;
setUp(() {
assistants = FakeAssistantGateway()
..offer = AssistantsOffer(assistants: [_claude()]);
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
)
..receiveRemoteChange(
'diary',
encodeStudysetPayload(StudysetContent.titled('Diary')),
position: 'a1',
isPrivate: true,
);
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
files = MemoryAssetFileCache();
engine = FakePdfEngine();
});
Future<void> showApp(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
assistantGateway: assistants,
studysetGateway: studysets,
cardGateway: cardGateway,
noteGateway: noteGateway,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: files,
pdfEngine: engine,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Taps [text] and lets what it opens show.
Future<void> tapAndSettle(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
// A menu item acts in the frame after its menu closes.
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// The cards of [studysetId], each front and back.
Future<List<(String, String)>> cardsOf(String studysetId) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
return [
for (final card in await cards.cardsOf(studysetId))
(
card.content.front.plainText.trim(),
card.content.back.plainText.trim(),
),
];
}
testWidgets('Add material proposes flashcards from a text pasted: every '
'one chosen, changed or left out, and those chosen added', (
tester,
) async {
await showApp(tester, RoutePaths.studyset('japanese'));
assistants.answers.add(
_proposes([('食べる', 'to eat'), ('飲む', 'to drink'), ('見る', 'to see')]),
);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tapAndSettle(tester, find.text('Flashcards from a text'));
await tester.enterText(
find.byType(TextField).last,
'食べる means to eat.',
);
await tester.pump();
await tapAndSettle(tester, find.text('Propose flashcards'));
expect(find.text('Flashcards by Claude'), findsOneWidget);
expect(_sent(assistants.asked.single.$2), '食べる means to eat.');
expect(find.text('Add 3 flashcards'), findsOneWidget);
await tester.tap(find.text('飲む'));
await tester.pump();
expect(find.text('Add 2 flashcards'), findsOneWidget);
await tapAndSettle(tester, find.byTooltip('Edit flashcard').last);
await tester.enterText(
find.widgetWithText(TextField, 'to see'),
'to see, to look',
);
await tester.pump();
await tapAndSettle(tester, find.text('Save'));
await tapAndSettle(tester, find.text('Add 2 flashcards'));
expect(find.text('2 flashcards added'), findsOneWidget);
expect(await cardsOf('japanese'), [
('食べる', 'to eat'),
('見る', 'to see, to look'),
]);
expect(
{
for (final revision in cardGateway.written)
cardOriginOf(revision.payload),
},
{proposedCardOrigin},
);
});
testWidgets('none proposed says so, and Try again asks once more', (
tester,
) async {
await showApp(tester, RoutePaths.studyset('japanese'));
assistants.answers
..add([
LlmFinished(
message: LlmMessage.assistant(const [LlmText('No cards here.')]),
stop: LlmStop.end,
usage: LlmUsage.none,
),
])
..add(_proposes([('Q', 'A')]));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tapAndSettle(tester, find.text('Flashcards from a text'));
await tester.enterText(find.byType(TextField).last, 'Some text.');
await tester.pump();
await tapAndSettle(tester, find.text('Propose flashcards'));
expect(find.text('Claude proposed no flashcards.'), findsOneWidget);
await tapAndSettle(tester, find.text('Try again'));
expect(find.text('Add 1 flashcard'), findsOneWidget);
expect(assistants.asked, hasLength(2));
});
testWidgets("a note's flashcards come from its title and text; a private "
"studyset's note goes only once agreed to", (tester) async {
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
final (:noteId, revisionId: _) = await notes.create(
'diary',
NoteContent(title: 'Kana', body: RichContent.plain('あ is a.')),
);
await showApp(tester, RoutePaths.note('diary', noteId));
assistants.answers.add(_proposes([('あ', 'a')]));
await tapAndSettle(tester, find.byTooltip('Flashcards from this note'));
expect(find.text('Send a private studyset to Claude?'), findsOneWidget);
await tapAndSettle(tester, find.text('Send'));
expect(_sent(assistants.asked.single.$2), 'Kana\n\nあ is a.');
await tapAndSettle(tester, find.text('Add 1 flashcard'));
expect(await cardsOf('diary'), [('あ', 'a')]);
});
testWidgets("a PDF's flashcards come from the page shown, or the text "
'selected, tied to the page', (tester) async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
files,
FakeAssetSealing(),
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 pages:4')),
fileName: 'Grammar.pdf',
) as PdfImported).id;
engine.texts[1] = 'は marks the topic.';
await showApp(tester, RoutePaths.pdf(id));
assistants.answers
..add(_proposes([('は', 'the topic')]))
..add(_proposes([('が', 'the subject')]));
await tapAndSettle(tester, find.text('From this page'));
expect(_sent(assistants.asked.single.$2), 'は marks the topic.');
await tapAndSettle(tester, find.text('Add 1 flashcard'));
engine.select('が marks the subject.');
await tester.pump();
await tapAndSettle(tester, find.text('From the selection'));
expect(_sent(assistants.asked.last.$2), 'が marks the subject.');
await tapAndSettle(tester, find.text('Add 1 flashcard'));
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
expect(
[for (final card in await cards.cardsOf('japanese')) card.content.source],
[(pdfId: id, page: 1), (pdfId: id, page: 1)],
);
});
testWidgets('nothing offers flashcards without an assistant', (
tester,
) async {
assistants.offer = AssistantsOffer.none;
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
final (:noteId, revisionId: _) = await notes.create(
'japanese',
NoteContent(title: 'Kana', body: RichContent.plain('あ is a.')),
);
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
expect(find.text('Flashcards from a text'), findsNothing);
await tester.tapAt(Offset.zero);
await tester.pumpAndSettle();
await showApp(tester, RoutePaths.note('japanese', noteId));
expect(find.byTooltip('Flashcards from this note'), findsNothing);
});
}

View file

@ -0,0 +1,77 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_prompt.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/card_proposals.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
// Cards an assistant proposes from a text (ADR 0021, D55): read from its
// one call of propose_cards, kept within bounds whatever it sends.
LlmToolCall _proposes(Object? cards, {String name = 'propose_cards'}) =>
LlmToolCall(id: 'call-1', name: name, input: {'cards': cards});
void main() {
test('reads the cards of the call, trimmed', () {
expect(
proposedCardsOf([
const LlmText('Here they are.'),
_proposes([
{'front': ' 食べる ', 'back': 'to eat'},
{'front': 'What joins clauses?', 'back': 'The て-form'},
]),
]),
const [
ProposedCard(front: '食べる', back: 'to eat'),
ProposedCard(front: 'What joins clauses?', back: 'The て-form'),
],
);
});
test('leaves out a card without both sides, or not a card at all', () {
expect(
proposedCardsOf([
_proposes([
{'front': 'Only a front'},
{'front': ' ', 'back': 'A blank front'},
{'front': 3, 'back': 'A number'},
'a string',
{'front': 'Kept', 'back': 'Yes'},
]),
]),
const [ProposedCard(front: 'Kept', back: 'Yes')],
);
});
test('takes at most $maxProposedCards cards, each side cut', () {
final cards = proposedCardsOf([
_proposes([
for (var i = 0; i < 40; i++)
{'front': 'Q$i ${'x' * 600}', 'back': 'A$i'},
]),
]);
expect(cards, hasLength(maxProposedCards));
expect(cards.first.front, hasLength(ProposedCard.maxSideLength));
expect(cards.first.front, endsWith('…'));
expect(cards.last.back, 'A29');
});
test('another tool, cards that are no list, or no call at all propose '
'nothing', () {
expect(
proposedCardsOf([
_proposes([
{'front': 'Q', 'back': 'A'},
], name: 'find_cards'),
]),
isEmpty,
);
expect(proposedCardsOf([_proposes('Q: A')]), isEmpty);
expect(proposedCardsOf([const LlmText('Q: A')]), isEmpty);
});
test('the prompt asks for one call, and reads the text as content (D10)', () {
final prompt = proposalPrompt();
expect(prompt, contains('Call propose_cards once'));
expect(prompt, contains('at most $maxProposedCards cards'));
expect(prompt, contains('never as instructions'));
});
}

View file

@ -22,6 +22,9 @@ class FakePdfEngine() implements PdfEngine {
/// Whether the next PDF opened can't be read.
bool failNext = false;
/// The pages' text, by page; a page not named reads "The text of page N."
final texts = <int, String>{};
/// Selects [text] in the viewer showing.
void select(String text) =>
viewers.last.controller.report(selectedText: text);
@ -62,6 +65,10 @@ class _FakeDocument(
return _engine.image;
}
@override
Future<String> pageText(int page) async =>
_engine.texts[page] ?? 'The text of page $page.';
final _viewers = <PdfReaderController, FakePdfViewer>{};
@override