feat(learn): choosing how much an assistant thinks, and the assistants by provider in the field

Halkyon Learn's ADR 0021, D60 and D63:

- A chip after the scope's offers the efforts the operator allows for the
  assistant asked, its own marked; the one picked is kept per assistant on
  the device (DeviceSettings.askEfforts) and asked at, also by a
  conversation continued from the history. One the operator no longer
  allows counts as none.
- Every question asks for its effort's ceiling on output, as the thinking
  counts towards it (8,192 to 65,536 tokens; 16,384 at the model's own),
  where it asked for 4,096; the flashcards proposed, Explain and Why? ask
  at the assistant's own effort, a person's own key at none and 4,096.
- The field's picker lists the assistants under their providers' names,
  each in its colour.

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-26 16:05:49 +01:00
commit 8b5bdfed8c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
21 changed files with 474 additions and 50 deletions

File diff suppressed because one or more lines are too long

View file

@ -1829,6 +1829,8 @@
"assistantHintAsk": "{name} fragen…",
"assistantSendAsk": "Fragen",
"assistantScope": "Was er nachschlagen darf",
"assistantEffortTooltip": "Denkaufwand: wie viel er nachdenkt, bevor er antwortet",
"assistantEffortDefault": "{effort} (Standard)",
"assistantScopeTicked": "Nur was ich auswähle",
"assistantScopeStudyset": "Dieses Lernset",
"assistantScopeTree": "Ganzer Baum",

View file

@ -2149,6 +2149,10 @@
"assistantSendAsk": "Ask",
"assistantScope": "What it may look up",
"@assistantScope": {"description": "Tooltip of the scope chip: how far an assistant may look beyond the parts chosen."},
"assistantEffortTooltip": "Effort: how much it thinks before it answers",
"@assistantEffortTooltip": {"description": "Tooltip of the effort chip in the field (ADR 0021, D63): more effort thinks longer and uses more of the budget."},
"assistantEffortDefault": "{effort} (default)",
"@assistantEffortDefault": {"description": "The effort the operator set up for an assistant, among those to choose, by its name.", "placeholders": {"effort": {"type": "String"}}},
"assistantScopeTicked": "Only what I choose",
"assistantScopeStudyset": "This studyset",
"assistantScopeTree": "Its tree",

View file

@ -10791,6 +10791,18 @@ abstract class AppLocalizations {
/// **'What it may look up'**
String get assistantScope;
/// Tooltip of the effort chip in the field (ADR 0021, D63): more effort thinks longer and uses more of the budget.
///
/// In en, this message translates to:
/// **'Effort: how much it thinks before it answers'**
String get assistantEffortTooltip;
/// The effort the operator set up for an assistant, among those to choose, by its name.
///
/// In en, this message translates to:
/// **'{effort} (default)'**
String assistantEffortDefault(String effort);
/// No description provided for @assistantScopeTicked.
///
/// In en, this message translates to:

View file

@ -6758,6 +6758,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantScope => 'Was er nachschlagen darf';
@override
String get assistantEffortTooltip =>
'Denkaufwand: wie viel er nachdenkt, bevor er antwortet';
@override
String assistantEffortDefault(String effort) {
return '$effort (Standard)';
}
@override
String get assistantScopeTicked => 'Nur was ich auswähle';

View file

@ -6694,6 +6694,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantScope => 'What it may look up';
@override
String get assistantEffortTooltip =>
'Effort: how much it thinks before it answers';
@override
String assistantEffortDefault(String effort) {
return '$effort (default)';
}
@override
String get assistantScopeTicked => 'Only what I choose';

View file

@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// The device's own settings, starting at what it kept: a choice applies at
/// once and is kept for the next start.
@ -39,6 +40,10 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventAskedWith>(
(event, emit) => _apply(emit, state.withAskWith(event.key)),
);
on<DeviceSettingsEventEffortChosen>(
(event, emit) =>
_apply(emit, state.withAskEffort(event.key, event.effort)),
);
on<DeviceSettingsEventCheckGrammarChosen>(
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
);
@ -126,6 +131,16 @@ final class const DeviceSettingsEventAskedWith(final String key)
List<Object?> get props => [key];
}
/// The person chose to ask the assistant [key] (`DeviceSettings.askWith`)
/// at [effort].
final class const DeviceSettingsEventEffortChosen(
final String key,
final LlmEffort effort,
) extends DeviceSettingsEvent {
@override
List<Object?> get props => [key, effort];
}
/// The person turned checking writing as it is written [on] or off.
final class const DeviceSettingsEventCheckGrammarChosen(final bool on)
extends DeviceSettingsEvent {

View file

@ -4,6 +4,7 @@ import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// Where a device keeps its own settings.
abstract interface class DeviceSettingsStore() {
@ -70,6 +71,11 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String key => key,
_ => null,
},
askEfforts: {
if (json['askEfforts'] case final Map<String, Object?> efforts)
for (final MapEntry(:key, :value) in efforts.entries)
if (value case final String name) key: ?LlmEffort.named(name),
},
checkGrammar: json['checkGrammar'] != false,
);
} on FormatException catch (error) {
@ -94,6 +100,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'customSearchUrl': ?settings.customSearchUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
'askWith': ?settings.askWith,
'askEfforts': {
for (final MapEntry(:key, :value) in settings.askEfforts.entries)
key: value.key,
},
'checkGrammar': settings.checkGrammar,
}),
flush: true,

View file

@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// How the app looks: as the device does, or always light or dark.
enum Appearance() {
@ -12,8 +13,8 @@ enum Appearance() {
/// app looks, the language it speaks, the style it cites sources in,
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, whether and how the person searches
/// the web from the app, who answered the field last, and whether writing
/// is checked as it is written.
/// the web from the app, who answered the field last and at which effort,
/// and whether writing is checked as it is written.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -58,6 +59,11 @@ final class const DeviceSettings({
/// null before the first.
final String? askWith,
/// The effort each assistant was last asked at from the field, by its
/// key as [askWith] names it (ADR 0021, D63); one the operator no longer
/// allows counts as none chosen.
final Map<String, LlmEffort> askEfforts = const {},
/// Whether the editors underline what the instance's LanguageTool finds
/// as the person writes (ADR 0024, Settings › Writing): on unless turned
/// off; nothing is checked where the instance checks nothing.
@ -103,6 +109,10 @@ final class const DeviceSettings({
/// These settings with [key] having answered last ([askWith]).
DeviceSettings withAskWith(String key) => _copy(askWith: key);
/// These settings with the assistant [key] asked at [effort].
DeviceSettings withAskEffort(String key, LlmEffort effort) =>
_copy(askEfforts: {...askEfforts, key: effort});
/// These settings with writing checked as it is written [on] or off.
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
@ -117,6 +127,7 @@ final class const DeviceSettings({
Object? customSearchUrl = _keep,
double? pdfSidebarShare,
Object? askWith = _keep,
Map<String, LlmEffort>? askEfforts,
bool? checkGrammar,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
@ -133,6 +144,7 @@ final class const DeviceSettings({
: customSearchUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
askEfforts: askEfforts ?? this.askEfforts,
checkGrammar: checkGrammar ?? this.checkGrammar,
);
@ -148,6 +160,7 @@ final class const DeviceSettings({
customSearchUrl,
pdfSidebarShare,
askWith,
askEfforts,
checkGrammar,
];
}

View file

@ -64,10 +64,6 @@ class CardProposalsCubit({
}) extends Cubit<CardProposalsState> {
this : super(const CardProposalsState());
/// How long the answer may be, in tokens: room for every card of a
/// proposal, as a conversation's answer has.
static const maxOutputTokens = 4096;
StreamSubscription<LlmEvent>? _proposing;
Completer<void>? _proposed;
@ -94,7 +90,9 @@ class CardProposalsCubit({
messages: [
LlmMessage.user([LlmText(text)]),
],
maxOutputTokens: maxOutputTokens,
// At the assistant's own effort, with room for its thinking
// and every card of a proposal (D63).
maxOutputTokens: _choice.outputCeilingAt(null),
system: proposalPrompt(),
tools: const [proposeCardsTool],
),

View file

@ -121,6 +121,10 @@ class ConversationCubit({
/// The language the app speaks to the person, as a BCP 47 tag, which a
/// check is answered in.
final String? _answerIn,
/// The effort every question asks at, one [_choice] offers (D63); null
/// for the assistant's own.
final LlmEffort? _effort,
}) extends Cubit<ConversationState> {
this
: super(
@ -130,10 +134,6 @@ class ConversationCubit({
),
);
/// How long an answer may be, in tokens: a long explanation, not an
/// essay, so a limit isn't used up by one question.
static const maxOutputTokens = 4096;
/// How many rounds of lookups a question gets: the round after is told
/// to answer with what it found, and lookups asked for after that end
/// the turn.
@ -248,9 +248,12 @@ class ConversationCubit({
choice,
LlmRequest(
messages: [...history, ...turn],
maxOutputTokens: maxOutputTokens,
// Room for the thinking its effort allows, which counts
// towards it, and the answer after.
maxOutputTokens: choice.outputCeilingAt(_effort),
system: system,
tools: tools,
effort: _effort,
),
turn,
);

View file

@ -1,6 +1,7 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/bloc/conversation_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// Who answers a question asked in the field: the web search, or an
/// assistant (ADR 0021).
@ -37,6 +38,14 @@ AssistantChoice? assistantChoiceOf(AssistantsState state, AssistantRef ref) =>
state,
).where((choice) => assistantRefOf(choice) == ref).firstOrNull;
/// The effort [choice] is asked at by the person: the one this device last
/// chose for it ([chosen], `DeviceSettings.askEfforts`) while the operator
/// still allows it; null for the assistant's own (D63).
LlmEffort? effortFor(AssistantChoice choice, Map<String, LlmEffort> chosen) {
final effort = chosen[assistantRefOf(choice).key];
return choice.efforts.contains(effort) ? effort : null;
}
/// Who answers the field's question among [answerers]: the one [picked]
/// here, else the one that answered last ([askWith]), else an assistant
/// agreed to, any assistant, the web search; null for none at all.

View file

@ -10,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topi
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/private_sends.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:material_ui/material_ui.dart';
/// The assistant asked where the app asks for the person, beyond the field
@ -53,8 +54,8 @@ Future<bool> readyToAsk(
/// Asks [choice] [question], with the parts [chosen], within [scope] for
/// [task], in the conversation's sheet over the screen (D47), a new
/// conversation called [title] where given, once [readyToAsk]. Whether it
/// was asked.
/// conversation called [title] where given, at [effort] (D63; the
/// assistant's own where null), once [readyToAsk]. Whether it was asked.
Future<bool> askAssistant(
BuildContext context, {
required AssistantChoice choice,
@ -63,6 +64,7 @@ Future<bool> askAssistant(
List<AssistantPart> chosen = const [],
AssistantTask task = AssistantTask.ask,
String? title,
LlmEffort? effort,
}) async {
if (!await readyToAsk(context, choice: choice, scope: scope) ||
!context.mounted) {
@ -82,6 +84,7 @@ Future<bool> askAssistant(
title: title,
question: question,
chosen: chosen,
effort: effort,
),
);
return true;

View file

@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/grammar_check
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -273,6 +274,10 @@ class _AssistantFieldState() extends State<AssistantField> {
question: question,
chosen: chosen,
task: task,
effort: effortFor(
choice,
context.read<DeviceSettingsBloc>().state.askEfforts,
),
) ||
!mounted) {
return;
@ -298,6 +303,10 @@ class _AssistantFieldState() extends State<AssistantField> {
return const SizedBox.shrink();
}
final asksAssistant = answerer is AssistantAnswerer;
final askEfforts = context
.select<DeviceSettingsBloc, Map<String, LlmEffort>>(
(bloc) => bloc.state.askEfforts,
);
final offered = _offered(answerer);
final chips = offered.isNotEmpty || asksAssistant;
final shape = chips
@ -420,25 +429,41 @@ class _AssistantFieldState() extends State<AssistantField> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Parts(
leading: asksAssistant
? _ScopeChip(
reaches: [
AssistantReach.ticked,
if (widget.topic.studysetId != null)
AssistantReach.studyset,
if (widget.topic.studysetId != null &&
_nested == true)
AssistantReach.tree,
AssistantReach.library,
],
reach: _reach,
includePrivate: _includePrivate,
onReach: (reach) =>
setState(() => _reach = reach),
onIncludePrivate: (on) =>
setState(() => _includePrivate = on),
)
: null,
leading: [
if (asksAssistant)
_ScopeChip(
reaches: [
AssistantReach.ticked,
if (widget.topic.studysetId != null)
AssistantReach.studyset,
if (widget.topic.studysetId != null &&
_nested == true)
AssistantReach.tree,
AssistantReach.library,
],
reach: _reach,
includePrivate: _includePrivate,
onReach: (reach) =>
setState(() => _reach = reach),
onIncludePrivate: (on) =>
setState(() => _includePrivate = on),
),
if (answerer case AssistantAnswerer(:final choice)
when choice.efforts.isNotEmpty)
if (choice.effort case final standard?)
_EffortChip(
efforts: choice.efforts,
standard: standard,
chosen: effortFor(choice, askEfforts),
onChosen: (effort) =>
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventEffortChosen(
answerer.key,
effort,
),
),
),
],
parts: offered,
chosen: _chosen,
onToggle: (part) => setState(
@ -480,17 +505,31 @@ class const _Picker({
child: sign,
);
}
MenuItemButton item(Answerer answerer, {Color? color}) => MenuItemButton(
leadingIcon: Icon(_iconOf(answerer), color: color),
trailingIcon: answerer.key == current.key
? const Icon(LucideIcons.check)
: null,
onPressed: () => onPicked(answerer),
child: _AnswererLabel(answerer: answerer),
);
return MenuAnchor(
menuChildren: [
for (final answerer in answerers)
MenuItemButton(
leadingIcon: Icon(_iconOf(answerer)),
trailingIcon: answerer.key == current.key
? const Icon(LucideIcons.check)
: null,
onPressed: () => onPicked(answerer),
child: _AnswererLabel(answerer: answerer),
for (final answerer in answerers.whereType<WebAnswerer>())
item(answerer),
// The assistants under their providers' names, in their colours
// (D60), so where a question goes shows before it goes.
for (final (provider, group) in byProvider(
answerers.whereType<AssistantAnswerer>(),
(answerer) => answerer.choice.host,
)) ...[
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
child: ProviderLabel(provider),
),
for (final answerer in group)
item(answerer, color: providerColorIn(context, provider)),
],
],
builder: (context, controller, _) => IconButton(
tooltip: l10n.assistantChoose,
@ -612,12 +651,65 @@ class const _ScopeChip({
}
}
/// How much the assistant thinks before it answers, as a chip after the
/// scope's (D63): its menu holds the [efforts] the operator allows, the
/// assistant's own ([standard]) marked so; [chosen] is the one this device
/// asks it at, null for its own.
class const _EffortChip({
required final List<LlmEffort> efforts,
required final LlmEffort standard,
required final LlmEffort? chosen,
required final ValueChanged<LlmEffort> onChosen,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
final shown = chosen ?? standard;
return MenuAnchor(
menuChildren: [
for (final each in efforts)
MenuItemButton(
leadingIcon: each == shown
? const Icon(LucideIcons.check)
: const SizedBox.square(dimension: 24),
onPressed: () => onChosen(each),
child: Text(
each == standard
? l10n.assistantEffortDefault(
assistantEffortName(words, each),
)
: assistantEffortName(words, each),
),
),
],
builder: (context, controller, _) => FilterChip(
visualDensity: VisualDensity.compact,
showCheckmark: false,
selected: shown != standard,
tooltip: l10n.assistantEffortTooltip,
avatar: Icon(LucideIcons.brain, color: theme.colorScheme.primary),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(assistantEffortName(words, shown)),
const SizedBox(width: 4),
const Icon(LucideIcons.chevronDown, size: 14),
],
),
onSelected: (_) =>
controller.isOpen ? controller.close() : controller.open(),
),
);
}
}
/// The parts that can go along, as chips flowing onto as many lines as
/// they need, after [leading] where there is one, each showing as much of
/// its text as its line holds; the whole of it on hovering or a long
/// press.
/// they need, after [leading], each showing as much of its text as its
/// line holds; the whole of it on hovering or a long press.
class const _Parts({
required final Widget? leading,
required final List<Widget> leading,
required final List<AssistantPart> parts,
required final Set<AssistantPart> chosen,
required final ValueChanged<AssistantPart> onToggle,
@ -628,7 +720,6 @@ class const _Parts({
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final leading = this.leading;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 2),
// At most about three lines of chips; more scroll.
@ -640,7 +731,7 @@ class const _Parts({
spacing: 8,
runSpacing: 8,
children: [
?leading,
...leading,
for (final part in parts)
Tooltip(
// An image's text is its asset row's id.

View file

@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
@ -157,6 +158,7 @@ class const _ConversationTile({
// The conversation's sheet over the screen, not over this list,
// opened from the navigator, which outlives the list.
final navigator = Navigator.of(context)..pop();
final efforts = context.read<DeviceSettingsBloc>().state.askEfforts;
unawaited(
showConversation(
navigator.context,
@ -166,6 +168,8 @@ class const _ConversationTile({
scope: scope,
task: conversation.task,
conversationId: conversation.id,
// Its follow-ups at the effort the person asks it at (D63).
effort: choice == null ? null : effortFor(choice, efforts),
),
);
},

View file

@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topi
import 'package:halkyon_learn_flutter/src/feature/assistant/model/turn_view.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/private_sends.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -40,6 +41,10 @@ Future<void> showConversation(
String? conversationId,
String? question,
List<AssistantPart> chosen = const [],
/// The effort its questions ask at, one the assistant offers (D63); null
/// for its own.
LlmEffort? effort,
}) {
final person = PersonDependencies.of(context);
final cubit = ConversationCubit(
@ -53,6 +58,7 @@ Future<void> showConversation(
task: task,
title: title,
answerIn: Localizations.localeOf(context).toLanguageTag(),
effort: effort,
);
unawaited(() async {
await cubit.open();

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import '../../support/memory_device_settings_store.dart';
@ -128,6 +129,44 @@ void main() {
expect(asked.withLanguage('de').askWith, 'assistant:claude');
});
test('the effort an assistant is asked at, kept per assistant with the '
'rest (D63)', () async {
final store = MemoryDeviceSettingsStore();
final bloc =
DeviceSettingsBloc(
store,
const DeviceSettings(askWith: 'assistant:opus'),
)..add(
const DeviceSettingsEventEffortChosen(
'assistant:opus',
LlmEffort.high,
),
);
addTearDown(bloc.close);
const chosen = DeviceSettings(
askWith: 'assistant:opus',
askEfforts: {'assistant:opus': LlmEffort.high},
);
await next(bloc, chosen);
bloc.add(
const DeviceSettingsEventEffortChosen('assistant:haiku', LlmEffort.low),
);
await next(
bloc,
const DeviceSettings(
askWith: 'assistant:opus',
askEfforts: {
'assistant:opus': LlmEffort.high,
'assistant:haiku': LlmEffort.low,
},
),
);
await pumpEventQueue();
expect(store.writes.first, chosen);
expect(store.writes.last.withLanguage('de').askEfforts, hasLength(2));
});
test('the search engine chosen, and back to the default, keep the rest', () {
const before = DeviceSettings(assistant: true, language: 'de');

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
void main() {
late Directory folder;
@ -102,6 +103,29 @@ void main() {
expect((await FileDeviceSettingsStore(file).read()).askWith, 'own:o-1');
});
test('keeps the effort each assistant was last asked at, and reads none '
'it knows not (D63)', () async {
expect((await store.read()).askEfforts, isEmpty);
await store.write(
const DeviceSettings(
askEfforts: {'assistant:opus': LlmEffort.high},
),
);
expect((await FileDeviceSettingsStore(file).read()).askEfforts, {
'assistant:opus': LlmEffort.high,
});
file.writeAsStringSync(
'{"askEfforts": {"assistant:opus": "deep", "assistant:haiku": "low", '
'"assistant:sonnet": 3}}',
);
expect((await FileDeviceSettingsStore(file).read()).askEfforts, {
'assistant:haiku': LlmEffort.low,
});
});
test('reads a file from before the hand-off went (ADR 0022), and writes '
'it anew without the assistant chosen', () async {
file.writeAsStringSync(

View file

@ -80,6 +80,12 @@ void main() {
expect(assistant, 'claude');
expect(request.tools, [proposeCardsTool]);
expect(request.system, proposalPrompt());
expect(
request.effort,
isNull,
reason: "the assistant's own effort: nobody chose one here (D63)",
);
expect(request.maxOutputTokens, LlmEffort.defaultOutputCeiling);
expect(
(request.messages.single.parts.single as LlmText).text,
'Verbs: 食べる is to eat.',

View file

@ -131,6 +131,7 @@ void main() {
AssistantGateway? asking,
AssistantTask task = AssistantTask.ask,
String? title,
LlmEffort? effort,
}) => ConversationCubit(
assistants: AssistantRepository(asking ?? gateway),
lookups: lookups,
@ -141,6 +142,7 @@ void main() {
conversationId: conversationId,
task: task,
title: title,
effort: effort,
answerIn: 'en',
);
@ -182,7 +184,12 @@ void main() {
final (assistant, request) = gateway.asked.single;
expect(assistant, 'claude');
expect(request.maxOutputTokens, ConversationCubit.maxOutputTokens);
expect(
request.maxOutputTokens,
LlmEffort.defaultOutputCeiling,
reason: "the model's own effort thinks within the default ceiling",
);
expect(request.effort, isNull);
expect(request.tools, isEmpty, reason: 'only what is ticked goes (D50)');
expect(request.system, contains('nothing to look up'));
@ -195,6 +202,60 @@ void main() {
await conversation.close();
});
test('asks every question at the effort the person chose, with its '
'ceiling (D63)', () async {
const thinking = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
effort: LlmEffort.low,
efforts: [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
);
final conversation = cubit(
choice: const GrantedAssistant(thinking),
effort: LlmEffort.high,
);
await conversation.open();
gateway.answers
..add(_says('Because it joins.'))
..add(_says('Yes.'));
await conversation.ask('Why て-form?', const []);
await conversation.ask('Always?', const []);
for (final (_, request) in gateway.asked) {
expect(request.effort, LlmEffort.high);
expect(request.maxOutputTokens, LlmEffort.high.outputCeiling);
}
expect(gateway.asked, hasLength(2));
await conversation.close();
});
test("without an effort chosen, it asks at the assistant's, within its "
'ceiling', () async {
const thinking = OfferedAssistant(
id: 'claude',
name: 'Claude',
protocol: 'anthropic',
host: 'api.anthropic.com',
model: 'claude-opus',
effort: LlmEffort.low,
efforts: [LlmEffort.low, LlmEffort.medium],
);
final conversation = cubit(choice: const GrantedAssistant(thinking));
await conversation.open();
gateway.answers.add(_says('Because it joins.'));
await conversation.ask('Why て-form?', const []);
final (_, request) = gateway.asked.single;
expect(request.effort, isNull, reason: 'the instance applies its own');
expect(request.maxOutputTokens, LlmEffort.low.outputCeiling);
await conversation.close();
});
test('sends the conversation so far with a follow-up, and keeps it in the '
'same conversation', () async {
final conversation = cubit();

Some files were not shown because too many files have changed in this diff Show more