feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)

- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 01:09:04 +01:00
commit 5641285d31
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -33,6 +33,22 @@ Never _refusedForOperator(OperatorRefusal reason) =>
String providerOf(AssistantSetting assistant) =>
'${assistant.endpoint.protocol.key} ${assistant.endpoint.host}';
/// When a person agreed to each provider ([providerOf]) by their
/// [grants], the first time (D66): an agreement is to what goes where, so
/// it holds for every assistant of that provider, those granted later too.
Map<String, DateTime> _agreementsOf(Iterable<AssistantGrant> grants) {
final agreed = <String, DateTime>{};
for (final grant in grants) {
if ((grant.agreedTo, grant.agreedAt) case (final to?, final at?)) {
if (agreed[to] case final before? when !at.isBefore(before)) {
continue;
}
agreed[to] = at;
}
}
return agreed;
}
/// The UTC day [time] is in, at its midnight (D42).
DateTime utcDayOf(DateTime time) {
final utc = time.toUtc();
@ -50,21 +66,6 @@ DateTime utcMonthOf(DateTime time) {
AssistantSetting? _offered(Serverpod pod, String id) =>
isMember(pod) ? null : assistantsOf(pod).named(id);
/// [person]'s grant of [assistant]; null for none.
Future<AssistantGrant?> _grantOf(
Session session,
UuidValue person,
String assistant, {
Transaction? transaction,
}) =>
// SELECT … FROM "assistant_grant"
// WHERE "authUserId" = $1 AND "assistant" = $2
AssistantGrant.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person) & t.assistant.equals(assistant),
transaction: transaction,
);
/// [person]'s budget; null for none, so no limits.
Future<AssistantBudget?> _budgetOf(
Session session,
@ -163,12 +164,14 @@ Future<AssistantsOffered> assistantsOffered(
))
grant.assistant: grant,
};
final agreements = _agreementsOf(grants.values);
final used =
(await _usedBy(session, {person}, clock.now()))[person] ?? _noneUsed;
return AssistantsOffered(
assistants: [
for (final assistant in assistants.all)
if (grants[assistant.id] case final grant?) _offerOf(assistant, grant),
if (grants.containsKey(assistant.id))
_offerOf(assistant, agreements[providerOf(assistant)]),
],
ownKeys: assistants.ownKeys,
currency: assistants.currency,
@ -178,7 +181,9 @@ Future<AssistantsOffered> assistantsOffered(
);
}
AssistantOffer _offerOf(AssistantSetting assistant, AssistantGrant grant) =>
/// [assistant] as offered to a person who agreed to its provider at
/// [agreedAt], if they have.
AssistantOffer _offerOf(AssistantSetting assistant, DateTime? agreedAt) =>
AssistantOffer(
id: assistant.id,
name: assistant.name,
@ -186,7 +191,7 @@ AssistantOffer _offerOf(AssistantSetting assistant, AssistantGrant grant) =>
host: assistant.endpoint.host,
model: assistant.endpoint.model,
images: assistant.images,
agreedAt: grant.agreedTo == providerOf(assistant) ? grant.agreedAt : null,
agreedAt: agreedAt,
effort: _effortOf(assistant.effort),
efforts: [for (final effort in assistant.efforts) _effortOf(effort)!],
);
@ -196,25 +201,37 @@ AssistantEffort? _effortOf(LlmEffort? effort) =>
effort == null ? null : AssistantEffort.fromJson(effort.key);
/// [person] agrees to what goes where with [assistant] (D40), its provider
/// as it is now. Refused ([AssistantRefusal.notGranted]) without a grant of
/// as it is now, and so with every assistant of that provider they have
/// (D66): each of those grants keeps it, so taking one back leaves it with
/// the others. Refused ([AssistantRefusal.notGranted]) without a grant of
/// it, or where it isn't set.
Future<void> agreeToAssistant(
Session session,
UuidValue person,
String assistant,
) async {
final pod = session.serverpod;
final setting =
_offered(session.serverpod, assistant) ??
_refused(AssistantRefusal.notGranted);
final grant =
await _grantOf(session, person, assistant) ??
_refused(AssistantRefusal.notGranted);
// UPDATE "assistant_grant" SET "agreedAt" = $1, "agreedTo" = $2
// WHERE "id" = $3
await AssistantGrant.db.updateRow(
_offered(pod, assistant) ?? _refused(AssistantRefusal.notGranted);
final provider = providerOf(setting);
// SELECT … FROM "assistant_grant" WHERE "authUserId" = $1
final grants = await AssistantGrant.db.find(
session,
grant.copyWith(agreedAt: clock.now(), agreedTo: providerOf(setting)),
where: (t) => t.authUserId.equals(person),
);
if (!grants.any((grant) => grant.assistant == assistant)) {
_refused(AssistantRefusal.notGranted);
}
final now = clock.now();
// UPDATE "assistant_grant" SET "agreedAt" = $1, "agreedTo" = $2
// WHERE "id" = ANY($3), one statement per grant: those of [person] of
// assistants set with the same provider.
await AssistantGrant.db.update(session, [
for (final grant in grants)
if (_offered(pod, grant.assistant) case final other?
when providerOf(other) == provider)
grant.copyWith(agreedAt: now, agreedTo: provider),
]);
}
/// The assistant [assistant] if [person] may ask it now: granted and set
@ -232,10 +249,16 @@ Future<AssistantSetting> admittedAssistant(
final setting =
_offered(session.serverpod, assistant) ??
_refused(AssistantRefusal.notGranted);
final grant =
await _grantOf(session, person, assistant) ??
_refused(AssistantRefusal.notGranted);
if (grant.agreedAt == null || grant.agreedTo != providerOf(setting)) {
// SELECT … FROM "assistant_grant" WHERE "authUserId" = $1
final grants = await AssistantGrant.db.find(
session,
where: (t) => t.authUserId.equals(person),
);
if (!grants.any((grant) => grant.assistant == assistant)) {
_refused(AssistantRefusal.notGranted);
}
// Agreed to its provider as it is now, by any of their grants (D66).
if (!_agreementsOf(grants).containsKey(providerOf(setting))) {
_refused(AssistantRefusal.notAgreed);
}
final budget = await _budgetOf(session, person);
@ -609,10 +632,12 @@ Future<List<AssistantGrantee>> _granteesOf(
assistant: grant.assistant,
assistantSet: assistants.named(grant.assistant) != null,
grantedAt: grant.grantedAt,
// Its provider's agreement, by any of their grants
// (D66).
agreedAt: switch (assistants.named(grant.assistant)) {
final setting?
when grant.agreedTo == providerOf(setting) =>
grant.agreedAt,
final setting? => _agreementsOf(
grants.where((each) => each.authUserId == person),
)[providerOf(setting)],
_ => null,
},
),