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:
parent
8ba758c020
commit
cca10e855e
1 changed files with 1553 additions and 15 deletions
|
|
@ -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},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue