mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The frugal sync gap is the larger of a day and the operator's sync gap, so an instance gap above 24 hours no longer leaves frugal ahead of standard.
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
/**
|
|
* v1.43 (#1217) — AI usage in three levels.
|
|
*
|
|
* One setting per account instead of many knobs. The operator sets the highest
|
|
* level an account may choose (`maxLevel`) and the level an account without a
|
|
* choice runs at (`defaultLevel`), since the operator pays for the shared key;
|
|
* each person picks within that. A level decides how much background model
|
|
* work the account asks for. It never decides what data is stored or shown,
|
|
* and the interactive Coach is the same at every level (its own budget caps
|
|
* apply as before).
|
|
*
|
|
* This file is the vocabulary: the levels, what each one does, the resolver
|
|
* and the estimate. Pure, no I/O, so the account payload, the jobs, the
|
|
* routes, the admin readout and the tests all read the same table. The reads
|
|
* live in `./capabilities/usage.ts`.
|
|
*
|
|
* Wire spelling is lower case (`frugal` / `standard` / `full`); the database
|
|
* enum (`AiUsageLevel`) is upper case. {@link AI_USAGE_LEVELS} is the ceiling
|
|
* order: a level is allowed when it is at or below `maxLevel`.
|
|
*/
|
|
import {
|
|
AI_USAGE_LEVELS,
|
|
type AiUsageLevelWire,
|
|
} from "@/lib/validations/ai-usage";
|
|
|
|
export { AI_USAGE_LEVELS };
|
|
export type AiUsageLevel = AiUsageLevelWire;
|
|
|
|
/**
|
|
* Where the effective level comes from:
|
|
*
|
|
* - `user`: the account's own stored choice;
|
|
* - `default`: no stored choice, so the operator's default;
|
|
* - `admin_cap`: a stored choice above the operator's ceiling, capped at
|
|
* read time. The choice itself is kept, so raising the ceiling again
|
|
* restores it.
|
|
*/
|
|
export const AI_USAGE_SOURCES = ["user", "default", "admin_cap"] as const;
|
|
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
|
|
|
|
/** What one level does. Every effect a level has is a row here. */
|
|
export interface AiUsageLevelEffect {
|
|
/**
|
|
* Minimum hours between two status-card regenerations of one scope after a
|
|
* sync (a watch, an integration). `"instance"` reads the operator's sync
|
|
* gap (`AppSettings.aiSyncRegenGapHours`, else `AI_SYNC_REGEN_GAP_HOURS`,
|
|
* else six). `{ instanceAtLeast }` reads the same gap but never goes
|
|
* below the floor, so a level meant to regenerate less often than
|
|
* `standard` cannot end up regenerating more often on an instance whose
|
|
* operator set a gap above the floor.
|
|
*/
|
|
syncRegenGapHours: number | "instance" | { instanceAtLeast: number };
|
|
/** The same gap after a hand entry. */
|
|
manualRegenGapHours: number;
|
|
/**
|
|
* The status snapshot's char cap, measured on the compact JSON the prompt
|
|
* ships (`degradeStatusSnapshotToBudget`). Fourteen thousand holds every
|
|
* snapshot that fitted the old 24 000 pretty-printed characters.
|
|
*/
|
|
statusSnapshotChars: number;
|
|
/**
|
|
* Whether the background jobs that may reason (the briefing, the period
|
|
* narratives, the Coach memory) ask for it. When true the job's own level
|
|
* applies (`BACKGROUND_REASONING_LEVEL`), still capped by the operator.
|
|
*/
|
|
backgroundReasoning: boolean;
|
|
/**
|
|
* The daily briefing's form. `short`: the lead plus today, at most one
|
|
* recommendation; every field of the contract stays present, so a client
|
|
* decodes it unchanged.
|
|
*/
|
|
briefing: "full" | "short";
|
|
/**
|
|
* Whether the account's own "read documents automatically" setting may
|
|
* act. When false, documents are read on request only.
|
|
*/
|
|
documentsAutoRead: boolean;
|
|
/**
|
|
* Relative token use, `full` = 1. Scales the measured use of the effective
|
|
* level into an estimate for another level. Calibrated with the generation
|
|
* simulation of a watch-synced account
|
|
* (`src/lib/insights/__tests__/usage-level-simulation.test.ts`): status-card
|
|
* generations a day, the nightly warm included, are 141 on `full`, 44 on
|
|
* `standard` (0.31) and 21 on `frugal` (0.15, before its shorter briefing
|
|
* and switched-off reasoning). The Coach is part of the measured use and
|
|
* does not scale, so the result is published and shown as an estimate.
|
|
*/
|
|
tokenFactor: number;
|
|
}
|
|
|
|
export const AI_USAGE_LEVEL_EFFECTS: Readonly<
|
|
Record<AiUsageLevel, Readonly<AiUsageLevelEffect>>
|
|
> = Object.freeze({
|
|
frugal: {
|
|
syncRegenGapHours: { instanceAtLeast: 24 },
|
|
manualRegenGapHours: 6,
|
|
statusSnapshotChars: 10_000,
|
|
backgroundReasoning: false,
|
|
briefing: "short",
|
|
documentsAutoRead: false,
|
|
tokenFactor: 0.15,
|
|
},
|
|
standard: {
|
|
syncRegenGapHours: "instance",
|
|
manualRegenGapHours: 1,
|
|
statusSnapshotChars: 14_000,
|
|
backgroundReasoning: true,
|
|
briefing: "full",
|
|
documentsAutoRead: true,
|
|
tokenFactor: 0.3,
|
|
},
|
|
full: {
|
|
syncRegenGapHours: 1,
|
|
manualRegenGapHours: 1,
|
|
statusSnapshotChars: 14_000,
|
|
backgroundReasoning: true,
|
|
briefing: "full",
|
|
documentsAutoRead: true,
|
|
tokenFactor: 1,
|
|
},
|
|
});
|
|
|
|
/** The instance's own defaults, before an operator changes anything. */
|
|
export const DEFAULT_AI_USAGE_MAX_LEVEL: AiUsageLevel = "standard";
|
|
export const DEFAULT_AI_USAGE_DEFAULT_LEVEL: AiUsageLevel = "standard";
|
|
|
|
// ── Order ───────────────────────────────────────────────────────────────────
|
|
|
|
export function usageLevelRank(level: AiUsageLevel): number {
|
|
return AI_USAGE_LEVELS.indexOf(level);
|
|
}
|
|
|
|
/** Whether `level` is at or above `min`. */
|
|
export function usageLevelAtLeast(
|
|
level: AiUsageLevel,
|
|
min: AiUsageLevel,
|
|
): boolean {
|
|
return usageLevelRank(level) >= usageLevelRank(min);
|
|
}
|
|
|
|
/** `level`, lowered to `max` when it lies above it. */
|
|
export function capUsageLevel(
|
|
level: AiUsageLevel,
|
|
max: AiUsageLevel,
|
|
): AiUsageLevel {
|
|
return usageLevelRank(level) > usageLevelRank(max) ? max : level;
|
|
}
|
|
|
|
export function isAiUsageLevel(value: unknown): value is AiUsageLevel {
|
|
return (
|
|
typeof value === "string" &&
|
|
(AI_USAGE_LEVELS as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
// ── Database spelling ───────────────────────────────────────────────────────
|
|
|
|
export type AiUsageLevelDb = "FRUGAL" | "STANDARD" | "FULL";
|
|
|
|
export function usageLevelFromDb(
|
|
value: string | null | undefined,
|
|
): AiUsageLevel | null {
|
|
if (value == null) return null;
|
|
const lower = value.toLowerCase();
|
|
return isAiUsageLevel(lower) ? lower : null;
|
|
}
|
|
|
|
export function usageLevelToDb(level: AiUsageLevel): AiUsageLevelDb {
|
|
return level.toUpperCase() as AiUsageLevelDb;
|
|
}
|
|
|
|
// ── Resolution ──────────────────────────────────────────────────────────────
|
|
|
|
export interface AiUsageBounds {
|
|
/** The account's stored choice; `null` follows the default. */
|
|
preference: AiUsageLevel | null;
|
|
defaultLevel: AiUsageLevel;
|
|
maxLevel: AiUsageLevel;
|
|
}
|
|
|
|
export interface AiUsageResolution {
|
|
level: AiUsageLevel;
|
|
source: AiUsageSource;
|
|
}
|
|
|
|
/**
|
|
* The level in effect. A stored choice at or below the ceiling wins; above it
|
|
* the ceiling applies and the choice is kept (`admin_cap`). Without a choice
|
|
* the default applies, held to the ceiling too, so a default stored above
|
|
* the ceiling (the admin route refuses one, an old row might hold one) can
|
|
* never lift an account over it.
|
|
*/
|
|
export function resolveAiUsageLevel(bounds: AiUsageBounds): AiUsageResolution {
|
|
const { preference, defaultLevel, maxLevel } = bounds;
|
|
if (preference === null) {
|
|
return { level: capUsageLevel(defaultLevel, maxLevel), source: "default" };
|
|
}
|
|
if (usageLevelRank(preference) > usageLevelRank(maxLevel)) {
|
|
return { level: maxLevel, source: "admin_cap" };
|
|
}
|
|
return { level: preference, source: "user" };
|
|
}
|
|
|
|
// ── Effects ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Where a batch of fresh measurements came from (`status-invalidation.ts`). */
|
|
export type IngestSource = "manual" | "sync";
|
|
|
|
/** Default sync gap when neither the operator nor the environment sets one. */
|
|
export const DEFAULT_SYNC_REGEN_GAP_HOURS = 6;
|
|
export const SYNC_REGEN_GAP_HOURS_MIN = 1;
|
|
export const SYNC_REGEN_GAP_HOURS_MAX = 48;
|
|
|
|
/** A sync gap in whole hours, or `null` when the value is not a usable one. */
|
|
export function parseSyncRegenGapHours(value: unknown): number | null {
|
|
const n =
|
|
typeof value === "number"
|
|
? value
|
|
: typeof value === "string" && value.trim() !== ""
|
|
? Number(value)
|
|
: NaN;
|
|
if (!Number.isInteger(n)) return null;
|
|
if (n < SYNC_REGEN_GAP_HOURS_MIN || n > SYNC_REGEN_GAP_HOURS_MAX) return null;
|
|
return n;
|
|
}
|
|
|
|
/**
|
|
* Hours a scope waits after its last regeneration before an ingest of this
|
|
* source may regenerate it again.
|
|
*
|
|
* @param instanceSyncGapHours The operator's resolved sync gap, read by
|
|
* `standard`.
|
|
*/
|
|
export function statusRegenGapHours(
|
|
level: AiUsageLevel,
|
|
source: IngestSource,
|
|
instanceSyncGapHours: number,
|
|
): number {
|
|
const effect = AI_USAGE_LEVEL_EFFECTS[level];
|
|
if (source === "manual") return effect.manualRegenGapHours;
|
|
const gap = effect.syncRegenGapHours;
|
|
if (gap === "instance") return instanceSyncGapHours;
|
|
if (typeof gap === "number") return gap;
|
|
return Math.max(gap.instanceAtLeast, instanceSyncGapHours);
|
|
}
|
|
|
|
// ── Estimate ────────────────────────────────────────────────────────────────
|
|
|
|
/** Full days behind the measured mean, at most. */
|
|
export const AI_USAGE_MEASURE_DAYS = 7;
|
|
/** Days of history an estimate needs. */
|
|
export const AI_USAGE_ESTIMATE_MIN_DAYS = 3;
|
|
|
|
/**
|
|
* Tokens per day at `target`, scaled from what the account used at `from`.
|
|
* `null` without enough history.
|
|
*/
|
|
export function estimateTokensPerDay(args: {
|
|
measuredTokensPerDay: number | null;
|
|
measuredDays: number;
|
|
from: AiUsageLevel;
|
|
target: AiUsageLevel;
|
|
}): number | null {
|
|
if (args.measuredTokensPerDay === null) return null;
|
|
if (args.measuredDays < AI_USAGE_ESTIMATE_MIN_DAYS) return null;
|
|
const ratio =
|
|
AI_USAGE_LEVEL_EFFECTS[args.target].tokenFactor /
|
|
AI_USAGE_LEVEL_EFFECTS[args.from].tokenFactor;
|
|
return Math.round(args.measuredTokensPerDay * ratio);
|
|
}
|
|
|
|
/** `ai.usage` on `GET /api/auth/me` (OpenAPI `AiUsageState`). */
|
|
export interface AiUsageState {
|
|
level: AiUsageLevel;
|
|
preference: AiUsageLevel | null;
|
|
defaultLevel: AiUsageLevel;
|
|
maxLevel: AiUsageLevel;
|
|
source: AiUsageSource;
|
|
options: Array<{
|
|
level: AiUsageLevel;
|
|
allowed: boolean;
|
|
estimatedTokensPerDay: number | null;
|
|
}>;
|
|
measuredTokensPerDay: number | null;
|
|
measuredDays: number;
|
|
}
|
|
|
|
/** The whole published state, from the bounds and the measured use. */
|
|
export function buildAiUsageState(
|
|
args: AiUsageBounds & {
|
|
measuredTokensPerDay: number | null;
|
|
measuredDays: number;
|
|
},
|
|
): AiUsageState {
|
|
const { level, source } = resolveAiUsageLevel(args);
|
|
return {
|
|
level,
|
|
preference: args.preference,
|
|
defaultLevel: args.defaultLevel,
|
|
maxLevel: args.maxLevel,
|
|
source,
|
|
options: AI_USAGE_LEVELS.map((option) => ({
|
|
level: option,
|
|
allowed: usageLevelRank(option) <= usageLevelRank(args.maxLevel),
|
|
estimatedTokensPerDay: estimateTokensPerDay({
|
|
measuredTokensPerDay: args.measuredTokensPerDay,
|
|
measuredDays: args.measuredDays,
|
|
from: level,
|
|
target: option,
|
|
}),
|
|
})),
|
|
measuredTokensPerDay: args.measuredTokensPerDay,
|
|
measuredDays: args.measuredDays,
|
|
};
|
|
}
|