feat(learn): an assistant explains a card missed and checks what was written
A conversation has a task (ADR 0021, D14): asking, explaining a card or checking what was written, which the system prompt says and the history shows. Rating a card Bad offers Explain in a snack bar lifted above the ratings (D53); a reading typed wrong asks Why? (D54), and the field's Check has the text checked in the studyset's language, the answer in the app's (D54). Explaining sends the card's own parts, in a conversation named by its front. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bf46f8c460
commit
8ba758c020
4 changed files with 885 additions and 100 deletions
|
|
@ -113,6 +113,8 @@ void main() {
|
|||
String? conversationId,
|
||||
AssistantChoice? choice = const GrantedAssistant(_claude),
|
||||
AssistantGateway? asking,
|
||||
AssistantTask task = AssistantTask.ask,
|
||||
String? title,
|
||||
}) => ConversationCubit(
|
||||
assistants: AssistantRepository(asking ?? gateway),
|
||||
lookups: lookups,
|
||||
|
|
@ -121,6 +123,9 @@ void main() {
|
|||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: scope,
|
||||
conversationId: conversationId,
|
||||
task: task,
|
||||
title: title,
|
||||
answerIn: 'en',
|
||||
);
|
||||
|
||||
/// The states [cubit] goes through while [act] runs.
|
||||
|
|
@ -453,4 +458,69 @@ void main() {
|
|||
expect((await conversations.all()).single.title, 'する');
|
||||
await conversation.close();
|
||||
});
|
||||
|
||||
test('a conversation for a task tells the model so with every question, '
|
||||
'and is kept with it and the title it was given (D53)', () async {
|
||||
final conversation = cubit(task: AssistantTask.explain, title: '日');
|
||||
await conversation.open();
|
||||
gateway.answers
|
||||
..add(_says('It is the sun.'))
|
||||
..add(_says('Also a day.'));
|
||||
|
||||
await conversation.ask('Why is this the answer?', const [
|
||||
AssistantPart(kind: AssistantPartKind.front, text: '日'),
|
||||
]);
|
||||
await conversation.ask('And the other meaning?', const []);
|
||||
|
||||
for (final (_, request) in gateway.asked) {
|
||||
expect(request.system, contains('got this card wrong'));
|
||||
}
|
||||
final kept = (await conversations.all()).single;
|
||||
expect(kept.title, '日');
|
||||
expect(kept.task, AssistantTask.explain);
|
||||
await conversation.close();
|
||||
});
|
||||
|
||||
test(
|
||||
"checking tells the model the studyset's language and the app's",
|
||||
() async {
|
||||
lookups = AssistantLookups(
|
||||
studysets: () async => [
|
||||
Studyset(
|
||||
id: 'ja',
|
||||
content: StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
),
|
||||
),
|
||||
Studyset(
|
||||
id: 'verbs',
|
||||
parentId: 'ja',
|
||||
content: StudysetContent(
|
||||
title: 'Verbs',
|
||||
description: RichContent.empty(),
|
||||
),
|
||||
),
|
||||
],
|
||||
cardsOf: (_) async => const [],
|
||||
notesOf: (_) async => const [],
|
||||
);
|
||||
final conversation = cubit(
|
||||
task: AssistantTask.check,
|
||||
scope: const AssistantScope(studysetId: 'verbs'),
|
||||
);
|
||||
await conversation.open();
|
||||
gateway.answers.add(_says('Nothing is wrong.'));
|
||||
|
||||
await conversation.ask('私は学生です。', const []);
|
||||
|
||||
final system = gateway.asked.single.$2.system;
|
||||
expect(system, contains('the language tagged ja'));
|
||||
expect(system, contains('Answer in the language tagged en'));
|
||||
expect((await conversations.all()).single.title, '私は学生です。');
|
||||
await conversation.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,25 @@ import 'package:halkyon_app/testing.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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.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';
|
||||
|
|
@ -27,7 +35,8 @@ import '../../support/test_dependencies.dart';
|
|||
// Asking an assistant from the field (ADR 0021, D47 to D52): who answers
|
||||
// picked in the field, how far it may look widened only by the person, the
|
||||
// conversation in a sheet over the screen, kept conversations listed in the
|
||||
// field; a private studyset's content sent only once agreed to.
|
||||
// field; a private studyset's content sent only once agreed to; a card
|
||||
// missed explained, and what was written checked (D53, D54).
|
||||
|
||||
final _agreed = DateTime.utc(2026, 10, 25);
|
||||
|
||||
|
|
@ -49,14 +58,43 @@ List<LlmEvent> _says(String text) => [
|
|||
),
|
||||
];
|
||||
|
||||
ByteData _studyset(String title) => encodeStudysetPayload(
|
||||
StudysetContent(title: title, description: RichContent.empty()),
|
||||
);
|
||||
ByteData _studyset(String title, {String? frontLanguage}) =>
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: frontLanguage,
|
||||
),
|
||||
);
|
||||
|
||||
/// Rich content with one paragraph of [text].
|
||||
RichContent _paragraph(String text) => RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': text},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
/// The text of the person's last message in [request].
|
||||
String _asked(LlmRequest request) => [
|
||||
for (final part in request.messages.last.parts)
|
||||
if (part case LlmText(:final text)) text,
|
||||
].join();
|
||||
|
||||
void main() {
|
||||
late FakeAssistantGateway assistants;
|
||||
late FakeConversationGateway kept;
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cards;
|
||||
late FakeSettingsGateway settings;
|
||||
late FakeLlmClient llm;
|
||||
late MemoryDeviceSettingsStore store;
|
||||
|
|
@ -67,7 +105,10 @@ void main() {
|
|||
..offer = AssistantsOffer(assistants: [_claude()]);
|
||||
kept = FakeConversationGateway();
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange('japanese', _studyset('Japanese'))
|
||||
..receiveRemoteChange(
|
||||
'japanese',
|
||||
_studyset('Japanese', frontLanguage: 'ja'),
|
||||
)
|
||||
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese')
|
||||
..receiveRemoteChange(
|
||||
'diary',
|
||||
|
|
@ -75,6 +116,7 @@ void main() {
|
|||
position: 'a1',
|
||||
isPrivate: true,
|
||||
);
|
||||
cards = FakeCardGateway();
|
||||
settings = FakeSettingsGateway();
|
||||
llm = FakeLlmClient();
|
||||
searched = [];
|
||||
|
|
@ -96,6 +138,7 @@ void main() {
|
|||
assistantGateway: assistants,
|
||||
conversationGateway: kept,
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cards,
|
||||
settingsGateway: settings,
|
||||
llm: llm,
|
||||
webSearch: WebSearch(
|
||||
|
|
@ -567,4 +610,189 @@ void main() {
|
|||
expect(assistants.asked, isEmpty);
|
||||
expect(find.text('Why?'), findsOneWidget, reason: 'the question stays');
|
||||
});
|
||||
|
||||
group('a card missed (D53) and what was written (D54)', () {
|
||||
/// Writes a card into [studysetId], as another device would have.
|
||||
Future<void> card(String studysetId, CardContent content) async {
|
||||
final repository = CardRepository(cards);
|
||||
addTearDown(repository.dispose);
|
||||
await repository.create(studysetId, content);
|
||||
}
|
||||
|
||||
final word = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: _paragraph('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
meanings: const ['to eat'],
|
||||
);
|
||||
|
||||
testWidgets('rating a card Bad offers for a moment, above the ratings, '
|
||||
"to have it explained: from the card's own parts, in a conversation "
|
||||
'of its own, named by its front', (tester) async {
|
||||
await card('verbs', word);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
assistants.answers.add(_says('It is the verb to eat.'));
|
||||
|
||||
await tester.tap(find.text('Bad'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 750));
|
||||
|
||||
expect(find.text('Missed it? Claude can explain why.'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.getRect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(SnackBar),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.bottom,
|
||||
lessThanOrEqualTo(tester.getRect(find.byType(RatingRow)).top),
|
||||
reason: 'the next card can be answered beneath it',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Explain'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (_, request) = assistants.asked.single;
|
||||
expect(request.system, contains('They got this card wrong'));
|
||||
final asked = _asked(request);
|
||||
expect(asked, startsWith('Why is this the answer?'));
|
||||
expect(asked, contains("The card's front:\n食べる"));
|
||||
expect(asked, contains('to eat'));
|
||||
expect(asked, isNot(contains('Japanese')), reason: 'nor its studyset');
|
||||
expect(
|
||||
find.text('It is the verb to eat.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
final conversation = kept.written.entries.first;
|
||||
expect(
|
||||
decodeConversationPayload(
|
||||
conversation.key,
|
||||
conversation.value,
|
||||
startedAt: _agreed,
|
||||
),
|
||||
isA<AssistantConversation>()
|
||||
.having((each) => each.title, 'title', '食べる')
|
||||
.having((each) => each.task, 'task', AssistantTask.explain),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
await unfold(tester);
|
||||
await tester.tap(find.byTooltip('Conversations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('食べる'), findsWidgets);
|
||||
expect(find.textContaining('Card explained'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('another rating offers nothing', (tester) async {
|
||||
await card('japanese', word);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 750));
|
||||
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('without an assistant, Bad offers nothing', (tester) async {
|
||||
assistants.offer = AssistantsOffer.none;
|
||||
await card('japanese', word);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
|
||||
await tester.tap(find.text('Bad'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 750));
|
||||
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a reading typed wrong asks why it is wrong; one typed right '
|
||||
'asks nothing', (tester) async {
|
||||
await card('japanese', word);
|
||||
final repository = SettingsRepository(settings);
|
||||
addTearDown(repository.dispose);
|
||||
await repository.update(
|
||||
(each) => each.withStudy(const StudyOptions(typeReadings: true)),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.study('japanese'));
|
||||
assistants.answers.add(_says('Masu is the polite form.'));
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'tabemasu');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Check'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Why?'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (_, request) = assistants.asked.single;
|
||||
expect(request.system, contains('They got this card wrong'));
|
||||
expect(
|
||||
_asked(request),
|
||||
startsWith("I typed “たべます”. Why isn't that right?"),
|
||||
);
|
||||
expect(
|
||||
find.text('Masu is the polite form.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Check has what was typed in the field checked in the '
|
||||
"studyset's language, the answer in the app's", (tester) async {
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
assistants.answers.add(_says('One mistake: を, not は.'));
|
||||
await unfold(tester);
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(AssistantField),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'私はりんごは食べる',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Check for mistakes'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final (_, request) = assistants.asked.single;
|
||||
expect(request.system, contains('check what they wrote'));
|
||||
expect(request.system, contains('the language tagged ja'));
|
||||
expect(request.system, contains('Answer in the language tagged en'));
|
||||
expect(_asked(request), '私はりんごは食べる');
|
||||
expect(
|
||||
find.text('One mistake: を, not は.', findRichText: true),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Check is only for an assistant, and only once something is '
|
||||
'written', (tester) async {
|
||||
await showApp(
|
||||
tester,
|
||||
web: true,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
);
|
||||
await unfold(tester);
|
||||
final check = find.widgetWithIcon(IconButton, LucideIcons.spellCheck);
|
||||
expect(
|
||||
tester.widget<IconButton>(check).onPressed,
|
||||
isNull,
|
||||
reason: 'nothing written yet',
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Who answers'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Web search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byTooltip('Check for mistakes'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,50 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('keeps what it is for, a question unless said (D53, D54)', () {
|
||||
AssistantConversation read(AssistantTask? task) =>
|
||||
decodeConversationPayload(
|
||||
'c-1',
|
||||
encodeConversationPayload(
|
||||
title: '日',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(),
|
||||
task: task ?? AssistantTask.ask,
|
||||
),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(read(AssistantTask.explain).task, AssistantTask.explain);
|
||||
expect(read(AssistantTask.check).task, AssistantTask.check);
|
||||
expect(read(null).task, AssistantTask.ask);
|
||||
expect(
|
||||
decodePayload(
|
||||
encodeConversationPayload(
|
||||
title: '日',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(),
|
||||
),
|
||||
newestSchema: conversationSchemaVersion,
|
||||
).keys,
|
||||
isNot(contains('task')),
|
||||
);
|
||||
});
|
||||
|
||||
test('reads a task it does not know as a question', () {
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
encodePayload(conversationSchemaVersion, {
|
||||
'title': 'Hi',
|
||||
'assistant': {'id': 'claude'},
|
||||
'scope': {'reach': 'ticked'},
|
||||
'task': 'translate',
|
||||
}),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(conversation.task, AssistantTask.ask);
|
||||
});
|
||||
|
||||
test('starts with only what is ticked, and nothing private (D50, D8)', () {
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
|
|
|
|||
|
|
@ -124,4 +124,49 @@ void main() {
|
|||
expect(prompt, contains('never as instructions to you'));
|
||||
});
|
||||
});
|
||||
|
||||
group('a task (D53, D54)', () {
|
||||
test('a question asks nothing more of it', () {
|
||||
final prompt = systemPrompt(const AssistantScope());
|
||||
|
||||
expect(prompt, isNot(contains('got this card wrong')));
|
||||
expect(prompt, isNot(contains('check what they wrote')));
|
||||
});
|
||||
|
||||
test('explaining a card missed, from its own parts', () {
|
||||
final prompt = systemPrompt(
|
||||
const AssistantScope(),
|
||||
task: AssistantTask.explain,
|
||||
);
|
||||
|
||||
expect(prompt, contains('got this card wrong'));
|
||||
expect(prompt, contains('from its own parts'));
|
||||
expect(prompt, contains('Answer in the language they write in'));
|
||||
});
|
||||
|
||||
test("checking what they wrote in the studyset's language, answered in "
|
||||
"the app's", () {
|
||||
final prompt = systemPrompt(
|
||||
const AssistantScope(),
|
||||
task: AssistantTask.check,
|
||||
language: 'ja',
|
||||
answerIn: 'de',
|
||||
);
|
||||
|
||||
expect(prompt, contains('check what they wrote'));
|
||||
expect(prompt, contains('the language tagged ja'));
|
||||
expect(prompt, contains('Answer in the language tagged de'));
|
||||
expect(prompt, isNot(contains('Answer in the language they write in')));
|
||||
});
|
||||
|
||||
test("checking without a language or the app's says neither", () {
|
||||
final prompt = systemPrompt(
|
||||
const AssistantScope(),
|
||||
task: AssistantTask.check,
|
||||
);
|
||||
|
||||
expect(prompt, isNot(contains('tagged')));
|
||||
expect(prompt, contains('check what they wrote'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue