feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)

The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.

- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
  _PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
  _KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
  ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
  server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
  Serverpod stream (D35): the text as the model writes it, then the
  whole turn, sent once the request is counted; figures, the caller's
  own. Checked in order: at most 2 questions on their way per person
  (tooManyAtOnce), the grant, the agreement, limits in the pricing's
  unit now (limitOutdated), the month's and the day's limits (with when
  they reset, UTC), the question (malformed, imagesNotRead, tooLarge
  past 400,000 characters, an image over 3,932,160 bytes or more than
  20 images); output capped at 16,384 tokens. Each request carries the
  person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
  sent, the provider's reply is read to its end even if the app
  cancels, and counted on a session of its own; a provider's failure is
  logged by kind and status, told as providerKey, providerBusy (with
  its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
  register): setUp, grants, grant (assistantNotSet, invalidLimit,
  notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
  and UTC day), in the module's, Learn's and Todo's migrations. The
  daily pruning keeps this month and the two before
  (forgetOldAssistantFigures, D41); deletePerson deletes both.
  A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
  before the profile pages' route): made from the instance's settings,
  English or German, no JavaScript: who may sign up, where the data and
  backups are, what the module keeps (the account, sessions, IP
  addresses, the server's log), the host's privacyPagePoints (Learn's:
  studysets synced, private ones encrypted on the devices with what
  stays readable, what is published, dictionary lookups, the learner
  count), each assistant with its model and host, what goes and what is
  counted, and whether personal keys are allowed. Never a key.

Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.

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 18:07:01 +01:00
commit dbae802915
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 56851 additions and 33 deletions

View file

@ -223,8 +223,9 @@ class _PruningFutureCallDispatcher {
}
/// Prunes one batch of anonymous accounts and logs how many people went,
/// forgets the resets that lapsed (ADR 0017), and deletes the new accounts
/// left unapproved for 14 days, with the waiting passes past their time
/// forgets the resets that lapsed (ADR 0017) and the assistants' figures
/// past their 3 months (Learn's ADR 0021, D41), and deletes the new accounts left
/// unapproved for 14 days, with the waiting passes past their time
/// (Learn's ADR 0021, D25).
class PruningPruneAccountsFutureCall extends _is.FutureCall
implements _is.InvokableFutureCall {

View file

@ -11,17 +11,20 @@ import 'package:serverpod/serverpod.dart';
/// whose account the register no longer knows, daily.
class PruningFutureCall extends FutureCall {
/// Prunes one batch of anonymous accounts and logs how many people went,
/// forgets the resets that lapsed (ADR 0017), and deletes the new accounts
/// left unapproved for 14 days, with the waiting passes past their time
/// forgets the resets that lapsed (ADR 0017) and the assistants' figures
/// past their 3 months (Learn's ADR 0021, D41), and deletes the new accounts left
/// unapproved for 14 days, with the waiting passes past their time
/// (Learn's ADR 0021, D25).
Future<void> pruneAccounts(Session session) async {
final now = clock.now();
final pruned = await pruneAnonymousPeople(session);
final resets = await forgetLapsedResets(session, now: now);
final unapproved = await forgetUnapprovedSignUps(session, now: now);
final figures = await forgetOldAssistantFigures(session, now: now);
session.log(
'Pruned $pruned anonymous people, $resets lapsed resets and '
'$unapproved accounts never approved.',
'Pruned $pruned anonymous people, $resets lapsed resets, '
'$unapproved accounts never approved and $figures days of assistant '
'figures.',
);
}

View file

@ -0,0 +1,34 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_todo",
"tables": [],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261025165102522-assistants"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_oidc",
"version": "20261023180644988"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,236 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "assistant_figures",
"dartName": "AssistantFigures",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "assistant",
"fieldName": "assistant",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "day",
"fieldName": "day",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "requests",
"fieldName": "requests",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "inputTokens",
"fieldName": "inputTokens",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "outputTokens",
"fieldName": "outputTokens",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "costMicros",
"fieldName": "costMicros",
"columnType": 6,
"isNullable": false,
"dartType": "int"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "assistant_figures_day_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "assistant"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "day"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "assistant_figures_age_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "day"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "assistant_grant",
"dartName": "AssistantGrant",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "assistant",
"fieldName": "assistant",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "grantedAt",
"fieldName": "grantedAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "limitUnit",
"fieldName": "limitUnit",
"columnType": 0,
"isNullable": false,
"dartType": "protocol:AssistantLimitUnit"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "dailyLimit",
"fieldName": "dailyLimit",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "monthlyLimit",
"fieldName": "monthlyLimit",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "agreedAt",
"fieldName": "agreedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "agreedTo",
"fieldName": "agreedTo",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "assistant_grant_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "assistant"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,89 @@
BEGIN;
--
-- ACTION CREATE TABLE
--
CREATE TABLE "assistant_figures" (
"id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL,
"assistant" text NOT NULL,
"day" timestamp without time zone NOT NULL,
"requests" bigint NOT NULL,
"inputTokens" bigint NOT NULL,
"outputTokens" bigint NOT NULL,
"costMicros" bigint NOT NULL
);
-- Indexes
CREATE UNIQUE INDEX "assistant_figures_day_idx" ON "assistant_figures" USING btree ("authUserId", "assistant", "day");
CREATE INDEX "assistant_figures_age_idx" ON "assistant_figures" USING btree ("day");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "assistant_grant" (
"id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL,
"assistant" text NOT NULL,
"grantedAt" timestamp without time zone NOT NULL,
"limitUnit" text NOT NULL,
"dailyLimit" bigint,
"monthlyLimit" bigint,
"agreedAt" timestamp without time zone,
"agreedTo" text
);
-- Indexes
CREATE UNIQUE INDEX "assistant_grant_person_idx" ON "assistant_grant" USING btree ("authUserId", "assistant");
--
-- MIGRATION VERSION FOR halkyon_todo
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_todo', '20261025165103193-assistants', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261025165103193-assistants', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261025165102522-assistants', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261025165102522-assistants', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_oidc
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_oidc', '20261023180644988', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261023180644988', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -19,3 +19,4 @@
20261023181810436-oidc-module
20261023192913500-simplelogin-keys
20261025135744931-waiting-accounts
20261025165103193-assistants