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

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},
);
});
}