@aglyn/plugins-ai 1.0.0-beta.219 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/package.json +18 -18
  2. package/src/lib/billing/ai-month-meter.d.ts +51 -0
  3. package/src/lib/billing/ai-month-meter.js +89 -0
  4. package/src/lib/billing/ai-month-meter.js.map +1 -0
  5. package/src/lib/billing/ai-overage-close.d.ts +11 -33
  6. package/src/lib/billing/ai-overage-close.js +4 -3
  7. package/src/lib/billing/ai-overage-close.js.map +1 -1
  8. package/src/lib/billing/ai-overage-cutover.d.ts +28 -12
  9. package/src/lib/billing/ai-overage-cutover.js +1 -28
  10. package/src/lib/billing/ai-overage-cutover.js.map +1 -1
  11. package/src/lib/billing/ai-overage-ledger.js +1 -1
  12. package/src/lib/billing/ai-overage-ledger.js.map +1 -1
  13. package/src/lib/billing/ai-overage-standing.js +1 -1
  14. package/src/lib/billing/ai-overage-standing.js.map +1 -1
  15. package/src/lib/components/ai-allotment-editor.component.js +1 -1
  16. package/src/lib/components/ai-allotment-editor.component.js.map +1 -1
  17. package/src/lib/components/ai-assist-provider.component.js +3 -2
  18. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  19. package/src/lib/components/ai-automation-zones.d.ts +63 -0
  20. package/src/lib/components/ai-automation-zones.js +31 -0
  21. package/src/lib/components/ai-automation-zones.js.map +1 -0
  22. package/src/lib/components/ai-describe-automation.component.d.ts +1 -1
  23. package/src/lib/components/ai-describe-automation.component.js.map +1 -1
  24. package/src/lib/components/ai-describe-button.component.d.ts +6 -3
  25. package/src/lib/components/ai-describe-button.component.js +4 -1
  26. package/src/lib/components/ai-describe-button.component.js.map +1 -1
  27. package/src/lib/components/ai-explain-automation.component.d.ts +1 -1
  28. package/src/lib/components/ai-explain-automation.component.js.map +1 -1
  29. package/src/lib/components/billing-ai-allotments.component.js +1 -1
  30. package/src/lib/components/billing-ai-allotments.component.js.map +1 -1
  31. package/src/lib/components/billing-assist-overage-card.component.js +1 -1
  32. package/src/lib/components/billing-assist-overage-card.component.js.map +1 -1
  33. package/src/lib/constants.d.ts +5 -0
  34. package/src/lib/constants.js +4 -0
  35. package/src/lib/constants.js.map +1 -1
  36. package/src/lib/declarations.js +2 -0
  37. package/src/lib/declarations.js.map +1 -1
  38. package/src/lib/declarations.server.js +24 -10
  39. package/src/lib/declarations.server.js.map +1 -1
  40. package/src/lib/insights/ai-dataset-catalog.d.ts +49 -0
  41. package/src/lib/insights/ai-dataset-catalog.js +57 -0
  42. package/src/lib/insights/ai-dataset-catalog.js.map +1 -0
  43. package/src/lib/insights/ai-figure-readers.d.ts +6 -39
  44. package/src/lib/insights/ai-figure-readers.js +2 -391
  45. package/src/lib/insights/ai-figure-readers.js.map +1 -1
  46. package/src/lib/insights/ai-insight-digest.d.ts +10 -1
  47. package/src/lib/insights/ai-insight-digest.js +16 -4
  48. package/src/lib/insights/ai-insight-digest.js.map +1 -1
  49. package/src/lib/insights/ai-insight-readers.d.ts +1 -1
  50. package/src/lib/insights/ai-insight-readers.js.map +1 -1
  51. package/src/lib/jobs/ai-job-component-step.d.ts +6 -1
  52. package/src/lib/jobs/ai-job-component-step.js +23 -6
  53. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-draft-ids.js +11 -0
  55. package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
  56. package/src/lib/jobs/ai-job-email-step.d.ts +5 -1
  57. package/src/lib/jobs/ai-job-email-step.js +10 -1
  58. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  59. package/src/lib/jobs/ai-job-form-step.d.ts +17 -1
  60. package/src/lib/jobs/ai-job-form-step.js +60 -8
  61. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-generation.d.ts +21 -0
  63. package/src/lib/jobs/ai-job-generation.js +53 -1
  64. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  65. package/src/lib/jobs/ai-job-insight-step.js +1 -1
  66. package/src/lib/jobs/ai-job-insight-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-layout-step.d.ts +15 -4
  68. package/src/lib/jobs/ai-job-layout-step.js +45 -12
  69. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  70. package/src/lib/jobs/ai-job-plan-conformance.d.ts +5 -0
  71. package/src/lib/jobs/ai-job-plan-conformance.js +7 -1
  72. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  73. package/src/lib/jobs/ai-job-plan-creations.d.ts +80 -0
  74. package/src/lib/jobs/ai-job-plan-creations.js +199 -0
  75. package/src/lib/jobs/ai-job-plan-creations.js.map +1 -0
  76. package/src/lib/jobs/ai-job-plan-step.d.ts +8 -2
  77. package/src/lib/jobs/ai-job-plan-step.js +20 -6
  78. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  79. package/src/lib/jobs/ai-job-site-step.d.ts +2 -0
  80. package/src/lib/jobs/ai-job-site-step.js +6 -6
  81. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  82. package/src/lib/jobs/ai-job-template-step.d.ts +9 -6
  83. package/src/lib/jobs/ai-job-template-step.js +72 -15
  84. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  85. package/src/lib/jobs/ai-jobs.js +1 -1
  86. package/src/lib/jobs/ai-jobs.js.map +1 -1
  87. package/src/lib/jobs/ai-workflow-records.d.ts +5 -4
  88. package/src/lib/jobs/ai-workflow-records.js +26 -22
  89. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  90. package/src/lib/model/ai-allotments.d.ts +16 -3
  91. package/src/lib/model/ai-allotments.js +34 -11
  92. package/src/lib/model/ai-allotments.js.map +1 -1
  93. package/src/lib/model/ai-job-creations.d.ts +55 -0
  94. package/src/lib/model/ai-job-creations.js +85 -0
  95. package/src/lib/model/ai-job-creations.js.map +1 -0
  96. package/src/lib/model/ai-page-job.d.ts +3 -2
  97. package/src/lib/model/ai-page-job.js +4 -3
  98. package/src/lib/model/ai-page-job.js.map +1 -1
  99. package/src/lib/model/ai-site-job.d.ts +4 -2
  100. package/src/lib/model/ai-site-job.js +8 -4
  101. package/src/lib/model/ai-site-job.js.map +1 -1
  102. package/src/lib/model/ai-template-subjects.d.ts +10 -0
  103. package/src/lib/model/ai-template-subjects.js +14 -0
  104. package/src/lib/model/ai-template-subjects.js.map +1 -1
  105. package/src/lib/model/ai-tokens.js +1 -1
  106. package/src/lib/model/ai-tokens.js.map +1 -1
  107. package/src/lib/model/ai-usage-by-user.js +1 -1
  108. package/src/lib/model/ai-usage-by-user.js.map +1 -1
  109. package/src/lib/providers/catalog.js +1 -1
  110. package/src/lib/providers/catalog.js.map +1 -1
  111. package/src/lib/providers/model-choice.js +1 -1
  112. package/src/lib/providers/model-choice.js.map +1 -1
  113. package/src/lib/runtime/ai-device-audit.d.ts +1 -1
  114. package/src/lib/runtime/ai-device-audit.js +1 -1
  115. package/src/lib/runtime/ai-device-audit.js.map +1 -1
  116. package/src/lib/runtime/ai-eval-live.js +1 -1
  117. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  118. package/src/lib/runtime/ai-eval.d.ts +1 -1
  119. package/src/lib/runtime/ai-eval.js.map +1 -1
  120. package/src/lib/runtime/ai-gate.js +1 -1
  121. package/src/lib/runtime/ai-gate.js.map +1 -1
  122. package/src/lib/runtime/ai-palette.d.ts +1 -1
  123. package/src/lib/runtime/ai-palette.generated.d.ts +1 -1
  124. package/src/lib/runtime/ai-palette.generated.js +1 -1
  125. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  126. package/src/lib/runtime/ai-palette.js.map +1 -1
  127. package/src/lib/runtime/site-inventory.js +28 -22
  128. package/src/lib/runtime/site-inventory.js.map +1 -1
  129. package/src/lib/server/ai-admin-org.js +1 -1
  130. package/src/lib/server/ai-admin-org.js.map +1 -1
  131. package/src/lib/server/ai-admin-orgs-spend.js +1 -1
  132. package/src/lib/server/ai-admin-orgs-spend.js.map +1 -1
  133. package/src/lib/server/ai-admin-signals.js +1 -1
  134. package/src/lib/server/ai-admin-signals.js.map +1 -1
  135. package/src/lib/server/ai-allotments.js +1 -1
  136. package/src/lib/server/ai-allotments.js.map +1 -1
  137. package/src/lib/server/ai-assist.js +2 -2
  138. package/src/lib/server/ai-assist.js.map +1 -1
  139. package/src/lib/server/assist-chat.d.ts +1 -1
  140. package/src/lib/server/assist-chat.js +2 -2
  141. package/src/lib/server/assist-chat.js.map +1 -1
  142. package/src/lib/server/billing-credits.d.ts +5 -0
  143. package/src/lib/server/billing-credits.js +8 -2
  144. package/src/lib/server/billing-credits.js.map +1 -1
  145. package/src/lib/server/billing-overage.js +2 -2
  146. package/src/lib/server/billing-overage.js.map +1 -1
  147. package/src/lib/testing/stand-in-dataset-index.d.ts +20 -0
  148. package/src/lib/testing/stand-in-dataset-index.js +80 -0
  149. package/src/lib/testing/stand-in-dataset-index.js.map +1 -0
  150. package/src/lib/usage/ai-allotment-alerts.d.ts +10 -7
  151. package/src/lib/usage/ai-allotment-alerts.js +31 -14
  152. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  153. package/src/lib/usage/ai-quota-checks.d.ts +37 -0
  154. package/src/lib/usage/ai-quota-checks.js +69 -0
  155. package/src/lib/usage/ai-quota-checks.js.map +1 -0
  156. package/src/lib/usage/ai-usage-alerts.js +22 -10
  157. package/src/lib/usage/ai-usage-alerts.js.map +1 -1
  158. package/src/lib/usage/ai-usage-by-user.js +1 -1
  159. package/src/lib/usage/ai-usage-by-user.js.map +1 -1
  160. package/src/lib/usage/assist-ceiling.d.ts +2 -2
  161. package/src/lib/usage/assist-ceiling.js +4 -4
  162. package/src/lib/usage/assist-ceiling.js.map +1 -1
  163. package/src/lib/usage/assist-credits.d.ts +494 -0
  164. package/src/lib/usage/assist-credits.js +524 -0
  165. package/src/lib/usage/assist-credits.js.map +1 -0
  166. package/src/lib/usage/assist-free-taste.d.ts +1 -1
  167. package/src/lib/usage/assist-free-taste.js +1 -1
  168. package/src/lib/usage/assist-free-taste.js.map +1 -1
  169. package/src/lib/usage/assist-signal-mining.js +1 -1
  170. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  171. package/src/lib/usage/assist-spend-guards.d.ts +153 -0
  172. package/src/lib/usage/assist-spend-guards.js +182 -0
  173. package/src/lib/usage/assist-spend-guards.js.map +1 -0
  174. package/src/lib/usage/assist-usage.d.ts +1 -1
  175. package/src/lib/usage/assist-usage.js +1 -1
  176. package/src/lib/usage/assist-usage.js.map +1 -1
  177. package/src/lib/usage/staff-org-ai.js +1 -1
  178. package/src/lib/usage/staff-org-ai.js.map +1 -1
  179. package/src/lib/usage-axes.d.ts +10 -0
  180. package/src/lib/usage-axes.js +40 -2
  181. package/src/lib/usage-axes.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n accountCostUsd: Number(account?.get('estCostUsd') ?? 0),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","FREE_AI_TASTE_CREDITS_PER_MONTH","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-free-taste.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n assistUsdFromCredits,\n type AssistRefusedBy,\n} from './assist-credits'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { FREE_AI_TASTE_CREDITS_PER_MONTH } from '../plan-entitlements'\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation/definitions/org-billing.types'\nimport { sendStaffAlertEmail } from '@aglyn/tenant-data-admin/server/staff-alert-email'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/**\n * The Free AI taste's precautions (AGL-2925) — the half of the meter that\n * exists only because the Free band has no invoice behind it.\n *\n * `FREE_AI_TASTE_CREDITS_PER_MONTH` gives every Free workspace 300 credits a\n * month as a wall. A wall per WORKSPACE is not a bound per PERSON: one\n * account may hold three free workspaces (AGL-2265), a script can mint\n * accounts faster than a person can, and a refused brief costs the same\n * tokens as an answered one. So the taste is metered a second time, per\n * account, and bounded three more ways. Everything below is read and\n * decided INSIDE `reserveAssistMessage`'s transaction, so a burst of\n * concurrent requests cannot each read \"under\" and all proceed — the same\n * property every other rung of that reservation has.\n *\n * ## Documents (both ABSENT from firebase-firestore.rules on purpose)\n *\n * users/{uid}/aiUsage/{YYYY-MM} the ACCOUNT's month:\n * { month, estCostUsd, requests, days: { [YYYY-MM-DD]: { requests,\n * refusals } }, updatedAt }\n * platformAiFreeSpend/{YYYY-MM-DD} the PLATFORM's day of free spend:\n * { day, estCostUsd, requests, refusals, alertedAt?, pausedAt?,\n * updatedAt }\n *\n * The account document sits under `users/{uid}`, whose owner may read and\n * write the user document itself. It may not touch this subcollection: the\n * `users` block matches its subcollections BY NAME and carries no wildcard,\n * so an unmatched name is default-deny for every client, staff included —\n * the same property `assistUsage` relies on one level down. An owner who\n * could write here could reset their own allowance, which is the whole\n * meter. The platform document is a top-level collection alongside\n * `platformCronBeats`, unmatched and therefore closed, and every reader is\n * an Admin-SDK reader behind the staff gate.\n *\n * ## Whose account\n *\n * The WORKSPACE OWNER's. A member invited to someone else's free workspace\n * draws from that workspace's band and from its owner's allowance, never\n * from their own account: the owner is the person the free workspace is\n * attributed to for the ceiling on how many they may hold, and attributing\n * the spend the same way is what keeps the two bounds describing one\n * person. `ownerUid` first, `createdByUid` for an org that predates\n * ownership stamping — the same union the free-workspace count reads.\n */\n\n/** Where an account's month of free AI spend lives, under `users/{uid}`. */\nexport const FREE_AI_ACCOUNT_USAGE_COLLECTION = 'aiUsage'\n\n/** The top-level collection holding one document per UTC day of free spend. */\nexport const PLATFORM_AI_FREE_SPEND_COLLECTION = 'platformAiFreeSpend'\n\n/**\n * Refusals a day before an account's free generation pauses. Three, not\n * one: a single declined brief is a normal outcome of asking an assistant\n * for things, and pausing on it would punish an ordinary afternoon. Three\n * in a day is somebody probing what the model will write.\n */\nexport const AI_FREE_REFUSALS_PER_DAY = 3\n\n/** The share of the platform ceiling at which staff are told, once a day. */\nexport const AI_FREE_PLATFORM_ALERT_SHARE = 0.8\n\n/** The account allowance, in USD of provider spend — the taste, per person. */\nexport const FREE_AI_ACCOUNT_BUDGET_USD = assistUsdFromCredits(\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n)\n\n/**\n * Free requests one account may make a UTC day, across its workspaces\n * (`AI_FREE_DAILY_REQUESTS`, default 30).\n *\n * A request, not a message: this counts every reservation the account's\n * free workspaces take, at either door. Thirty is three times the\n * per-workspace message cap, so one workspace never hits it before its own\n * cap and three workspaces hit it exactly when their caps would have\n * summed — it bounds the multiplier, not the ordinary day. Zero is honored\n * as \"no free requests\", which is a decision an operator may write down;\n * junk and an empty value take the default.\n */\nexport function aiFreeDailyRequests(): number {\n const raw = process.env.AI_FREE_DAILY_REQUESTS\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 30\n}\n\n/**\n * The platform-wide ceiling on ONE UTC day of free-tier provider spend\n * (`AI_FREE_DAILY_PLATFORM_CEILING_USD`, default $25).\n *\n * Every other bound here is per something — per workspace, per account,\n * per address — and a wave of new accounts clears all of them one at a\n * time. This is the bound on the sum. At 80% of it staff are mailed; at\n * 100% every free reservation refuses until the day rolls, paid workspaces\n * untouched, and the manual `ai-generate` lockdown key remains the switch\n * for anything the ceiling did not catch.\n *\n * Fails to the DEFAULT, never to \"no ceiling\", for the reason the operator\n * COGS limit does: an empty, negative, zero or unparseable value is not a\n * decision to run unbounded. There is deliberately no `off` word — a\n * deployment that wants no ceiling sets a figure it is content to spend.\n */\nexport function aiFreeDailyPlatformCeilingUsd(): number {\n const raw = String(process.env.AI_FREE_DAILY_PLATFORM_CEILING_USD ?? '').trim()\n const parsed = raw ? Number(raw) : Number.NaN\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 25\n}\n\n/** The account a free workspace's spend is attributed to. */\nexport interface FreeAssistAccount {\n /**\n * The workspace owner's uid, or `null` for an org document that names no\n * owner and no creator — every org created since ownership was stamped\n * names one, so `null` is the shape of a fixture or a very old record.\n * The account rungs are skipped for it; the org's own band still binds.\n */\n accountUid: string | null\n}\n\n/** The org fields the attribution reads, beside the billing ones. */\nexport type AssistMeteredOrg = Partial<AglynOrgBilling> & {\n ownerUid?: string | null\n createdByUid?: string | null\n}\n\n/**\n * The account to meter a workspace's free spend against, or `null` when the\n * workspace is not on the Free plan and none of this applies.\n *\n * `resolveEffectivePlan`, not `org.plan`: a workspace whose subscription\n * died is Free again, and its spend is the taste's spend again.\n */\nexport function freeAssistAccount(\n org: AssistMeteredOrg | null | undefined,\n): FreeAssistAccount | null {\n if (resolveEffectivePlan(org) !== 'free') return null\n const owner = String(org?.ownerUid ?? '').trim()\n const creator = String(org?.createdByUid ?? '').trim()\n return { accountUid: owner || creator || null }\n}\n\nexport function freeAccountUsageRef(\n firestore: FirebaseFirestore.Firestore,\n accountUid: string,\n month: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('users')\n .doc(accountUid)\n .collection(FREE_AI_ACCOUNT_USAGE_COLLECTION)\n .doc(month)\n}\n\nexport function platformFreeSpendRef(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore.collection(PLATFORM_AI_FREE_SPEND_COLLECTION).doc(day)\n}\n\n/** What the reservation read about the account and the platform. */\nexport interface FreeTasteReads {\n accountCostUsd: number\n accountRequestsToday: number\n accountRefusalsToday: number\n platformCostUsd: number\n platformPaused: boolean\n}\n\n/** The two snapshots, as the transaction handed them over, into figures. */\nexport function freeTasteReadsFrom(\n account: FirebaseFirestore.DocumentSnapshot | null,\n platform: FirebaseFirestore.DocumentSnapshot | null,\n day: string,\n): FreeTasteReads {\n const days = (account?.get('days') ?? {}) as Record<\n string,\n { requests?: unknown; refusals?: unknown } | undefined\n >\n const today = days[day] ?? {}\n return {\n accountCostUsd: Number(account?.get('estCostUsd') ?? 0),\n accountRequestsToday: Number(today.requests ?? 0),\n accountRefusalsToday: Number(today.refusals ?? 0),\n platformCostUsd: Number(platform?.get('estCostUsd') ?? 0),\n platformPaused: Boolean(platform?.get('pausedAt')),\n }\n}\n\n/**\n * Which of the taste's own precautions refuses, or `null` when none does —\n * pure, so the spec can force every rung red without a Firestore.\n *\n * Checked in the order the reads are cheapest to explain: the daily\n * request cap and the refusal pause (both reset on a clock the customer\n * can be told about), then the account's month (an upgrade is the way\n * out), then the platform ceiling (nobody's doing; try again tomorrow).\n * The org's own rungs — its daily message cap, its band — run before this\n * in the reservation, so a workspace at its own band hears about its own\n * band rather than about its owner's other workspaces.\n *\n * Every comparison is `>=` against a count the reservation is about to\n * increment, so a cap of N admits exactly N. A paused platform refuses\n * regardless of the running figure: the pause is a decision recorded on\n * the document, and a ceiling raised by an operator mid-day does not undo\n * it — the day rolls, or staff clear the document.\n */\nexport function freeTasteRefusal(\n reads: FreeTasteReads,\n limits: {\n dailyRequests?: number\n refusalsPerDay?: number\n accountBudgetUsd?: number\n platformCeilingUsd?: number\n } = {},\n): Extract<AssistRefusedBy, 'requests' | 'refusals' | 'account' | 'platform'> | null {\n const dailyRequests = limits.dailyRequests ?? aiFreeDailyRequests()\n const refusalsPerDay = limits.refusalsPerDay ?? AI_FREE_REFUSALS_PER_DAY\n const accountBudgetUsd = limits.accountBudgetUsd ?? FREE_AI_ACCOUNT_BUDGET_USD\n const platformCeilingUsd =\n limits.platformCeilingUsd ?? aiFreeDailyPlatformCeilingUsd()\n if (reads.accountRequestsToday >= dailyRequests) return 'requests'\n if (reads.accountRefusalsToday >= refusalsPerDay) return 'refusals'\n if (reads.accountCostUsd >= accountBudgetUsd) return 'account'\n if (reads.platformPaused || reads.platformCostUsd >= platformCeilingUsd) {\n return 'platform'\n }\n return null\n}\n\n/**\n * The account-side write one admitted free reservation owes: a request\n * counted for the month and for the day. `set(…, { merge: true })` with\n * nested increments, so the first request of a month conjures the document\n * and a day's map entry appears the first time that day is counted.\n */\nexport function freeAccountReservationWrite(\n month: string,\n day: string,\n): Record<string, unknown> {\n const increment = FieldValue.increment\n return {\n month,\n requests: increment(1),\n days: { [day]: { requests: increment(1) } },\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/**\n * The two writes one metered free turn owes, beside the org's own rollup.\n *\n * A `refusal` stop is the one case where the meters disagree on purpose. The\n * tokens were spent, so the PLATFORM document — which bounds our money —\n * takes the cost and counts the refusal. The ACCOUNT's credits do not move:\n * a refusal never draws credits, because a customer whose brief was\n * declined has not been given anything, and because a run of declined\n * briefs is answered by the refusal pause rather than by an emptied band.\n * The account's day counts the refusal, which is what the pause reads.\n */\nexport function freeTasteMeterWrites(input: {\n estCostUsd: number\n refused: boolean\n day: string\n month: string\n}): { account: Record<string, unknown>; platform: Record<string, unknown> } {\n const increment = FieldValue.increment\n const serverTimestamp = FieldValue.serverTimestamp\n const { estCostUsd, refused, day, month } = input\n return {\n account: refused\n ? {\n month,\n days: { [day]: { refusals: increment(1) } },\n updatedAt: serverTimestamp(),\n }\n : { month, estCostUsd: increment(estCostUsd), updatedAt: serverTimestamp() },\n platform: {\n day,\n estCostUsd: increment(estCostUsd),\n requests: increment(1),\n refusals: increment(refused ? 1 : 0),\n updatedAt: serverTimestamp(),\n },\n }\n}\n\n/**\n * After a free turn is metered: tell staff at 80% of the day's ceiling,\n * and record the pause at 100% — each ONCE per UTC day, deduplicated on\n * the day document itself inside a transaction, so two turns landing\n * together cannot both cross a threshold and both announce it.\n *\n * The refusal at the ceiling is NOT decided here; `freeTasteRefusal` reads\n * the figure inside every reservation and refuses on it directly. This\n * only says so out loud: the email, and an `adminAudit` row under\n * `platform.aiFreeSpend.paused` so the staff audit trail shows the day the\n * taste switched itself off. Both are best-effort and neither can fail the\n * turn that triggered them — the turn is already paid for.\n *\n * Returns what it announced, for the spec and for the log.\n */\nexport async function announcePlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<{ alerted: boolean; paused: boolean; estCostUsd: number }> {\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const ref = platformFreeSpendRef(firestore, day)\n const crossed = await firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n const alert =\n !snapshot.get('alertedAt') &&\n estCostUsd >= ceilingUsd * AI_FREE_PLATFORM_ALERT_SHARE\n const pause = !snapshot.get('pausedAt') && estCostUsd >= ceilingUsd\n if (alert || pause) {\n tx.set(\n ref,\n {\n ...(alert ? { alertedAt: FieldValue.serverTimestamp() } : {}),\n ...(pause ? { pausedAt: FieldValue.serverTimestamp() } : {}),\n },\n { merge: true },\n )\n }\n return { alerted: alert, paused: pause, estCostUsd }\n })\n if (!crossed.alerted && !crossed.paused) return crossed\n\n const figure = `$${crossed.estCostUsd.toFixed(2)} of the $${ceilingUsd.toFixed(2)} ceiling`\n if (crossed.paused) {\n await addAdminAudit(firestore, {\n actorUid: 'system:ai-free-spend',\n action: 'platform.aiFreeSpend.paused',\n target: `${PLATFORM_AI_FREE_SPEND_COLLECTION}/${day}`,\n after: { day, estCostUsd: crossed.estCostUsd, ceilingUsd },\n at: FieldValue.serverTimestamp(),\n })\n .catch((error) =>\n console.error('[ai-free-spend] audit row failed', error),\n )\n }\n await sendStaffAlertEmail({\n subject: crossed.paused\n ? `Free AI generation paused for ${day}: ${figure}`\n : `Free AI spend at 80% of today's ceiling (${day}): ${figure}`,\n text: crossed.paused\n ? `Free-tier AI spend for ${day} (UTC) reached ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). Every Free workspace is ' +\n 'refused AI generation until the UTC day rolls; paid workspaces are ' +\n 'unaffected. If this is an abuse wave rather than organic use, the ' +\n '`ai-generate` feature lock on Staff → Lockdown stops generation ' +\n 'for everyone, and the assist signals page shows which workspaces ' +\n 'spent it.'\n : `Free-tier AI spend for ${day} (UTC) is at ${figure} ` +\n '(AI_FREE_DAILY_PLATFORM_CEILING_USD). At 100% every Free ' +\n 'workspace is refused AI generation until the day rolls. The ' +\n 'assist signals page shows the day so far; the `ai-generate` ' +\n 'feature lock on Staff → Lockdown is the manual stop.',\n context: 'ai-free-spend',\n })\n return crossed\n}\n\n/** The staff readout of today's free spend, for the assist signals page. */\nexport interface PlatformFreeSpendReadout {\n day: string\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once the 80% mail went out today. */\n alerted: boolean\n /** True while free generation is refused for the day. */\n paused: boolean\n}\n\nexport async function readPlatformFreeSpend(\n firestore: FirebaseFirestore.Firestore,\n day: string,\n): Promise<PlatformFreeSpendReadout> {\n const snapshot = await platformFreeSpendRef(firestore, day).get()\n const ceilingUsd = aiFreeDailyPlatformCeilingUsd()\n const estCostUsd = Number(snapshot.get('estCostUsd') ?? 0)\n return {\n day,\n estCostUsd,\n requests: Number(snapshot.get('requests') ?? 0),\n refusals: Number(snapshot.get('refusals') ?? 0),\n ceilingUsd,\n alerted: Boolean(snapshot.get('alertedAt')),\n // The pause is the recorded decision OR the live figure: a day that\n // crossed the ceiling before anything announced it is paused all the\n // same, because the reservation refuses on the figure.\n paused: Boolean(snapshot.get('pausedAt')) || estCostUsd >= ceilingUsd,\n }\n}\n"],"names":["FieldValue","assistUsdFromCredits","resolveEffectivePlan","FREE_AI_TASTE_CREDITS_PER_MONTH","sendStaffAlertEmail","addAdminAudit","FREE_AI_ACCOUNT_USAGE_COLLECTION","PLATFORM_AI_FREE_SPEND_COLLECTION","AI_FREE_REFUSALS_PER_DAY","AI_FREE_PLATFORM_ALERT_SHARE","FREE_AI_ACCOUNT_BUDGET_USD","aiFreeDailyRequests","raw","process","env","AI_FREE_DAILY_REQUESTS","parsed","Number","NaN","isFinite","Math","floor","aiFreeDailyPlatformCeilingUsd","String","AI_FREE_DAILY_PLATFORM_CEILING_USD","trim","freeAssistAccount","org","owner","ownerUid","creator","createdByUid","accountUid","freeAccountUsageRef","firestore","month","collection","doc","platformFreeSpendRef","day","freeTasteReadsFrom","account","platform","days","today","get","accountCostUsd","accountRequestsToday","requests","accountRefusalsToday","refusals","platformCostUsd","platformPaused","Boolean","freeTasteRefusal","reads","limits","dailyRequests","refusalsPerDay","accountBudgetUsd","platformCeilingUsd","freeAccountReservationWrite","increment","updatedAt","serverTimestamp","freeTasteMeterWrites","input","estCostUsd","refused","announcePlatformFreeSpend","ceilingUsd","ref","crossed","runTransaction","tx","snapshot","alert","pause","set","alertedAt","pausedAt","merge","alerted","paused","figure","toFixed","actorUid","action","target","after","at","catch","error","console","subject","text","context","readPlatformFreeSpend"],"mappings":"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"}
@@ -15,7 +15,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
15
15
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
16
  * See the License for the specific language governing permissions and
17
17
  * limitations under the License.
18
- */ import { assistProviderCostUsd, ASSIST_PROVIDER_COST_FIELD } from "@aglyn/aglyn/app-utils/assist-credits";
18
+ */ import { assistProviderCostUsd, ASSIST_PROVIDER_COST_FIELD } from "./assist-credits.js";
19
19
  import { aiCacheHitRate } from "../model/ai-tokens.js";
20
20
  import { aiUsageKindFromRoute } from "../model/ai-usage-by-user.js";
21
21
  /** The kinds as the board lists them, dearest first. */ export function kindTokenRows(split) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-signal-mining.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assistProviderCostUsd,\n ASSIST_PROVIDER_COST_FIELD,\n} from '@aglyn/aglyn/app-utils/assist-credits'\nimport { aiCacheHitRate } from '../model/ai-tokens'\nimport { aiUsageKindFromRoute } from '../model/ai-usage-by-user'\n\n/**\n * The read side of the Aglyn Assist data loop (AGL-1860, AGL-2252).\n *\n * `orgs/{id}/assistSignals/{id}` is written on every assist turn and, until\n * this module, was read by nothing. That is a specific kind of failure: the\n * signal document exists BECAUSE AGL-1972 split the prose from the analytics\n * so the corpus could outlive the 180-day expiry on what people typed. A\n * corpus preserved for a reader that does not exist is just retention.\n *\n * Three questions, answered from one pass:\n *\n * 1. **Where are the docs thin?** Rank the cited docs paths by how often a\n * question landed on them and how often the answer was rated down. A\n * page cited constantly and rated down is a page that is being FOUND and\n * is not answering — the single most actionable row in the set, and the\n * one AGL-1860 wants turned into a docs issue.\n * 2. **What did retrieval miss entirely?** A turn with no `docsPaths` is a\n * question the corpus could not match at all, so the model answered\n * ungrounded. That is a sharper gap signal than a low rating, and it is\n * invisible in any ranking keyed on paths — a missing page cites nothing,\n * so it appears nowhere. It is counted separately, by ROUTE, so the gap\n * comes with the screen the person was looking at when they hit it.\n * 3. **What is this costing, and to whom?** Per-org and fleet-wide token and\n * dollar rollups, plus the cache-read rate. That last one settles a\n * question the chat route could only pose: the cached system prefix\n * measures 1,030–1,190 tokens against Sonnet 5's 1,024-token minimum, so\n * whether it caches at all is an empirical question and `cacheReadTokens`\n * is the only evidence. A prefix under the minimum caches silently not at\n * all, and the bill is the only place it shows.\n *\n * Pure and free of Firestore, so it can be tested on fixtures rather than on\n * a mocked SDK — the route does the reading, this does the arithmetic.\n */\n\n/** One `assistSignals` document, as the route projects it. */\nexport interface AssistSignalRow {\n orgId: string\n /**\n * The signal's document id, which IS the exchange's id (AGL-2314).\n *\n * `recordAssistExchange` mints one id and writes both halves under it —\n * `assistExchanges/{id}` for the prose that expires and `assistSignals/{id}`\n * for the analytics that do not — deliberately, \"so an exchange and its\n * signal are joinable for as long as both exist, without storing a second\n * pointer that could rot\". Nothing ever made the join. This is the field\n * that lets the corpus be read.\n */\n exchangeId: string\n route: string\n /**\n * What the turn was for, as the rollups name it (AGL-2937): `assist`, a\n * besigner mode, or a job kind. A signal written before the field is read\n * by its route, where a generation job's step reads as `job`.\n */\n kind?: string\n model: string\n tier: 'free' | 'entitled' | string\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n /**\n * What this turn cost us, at the serving model's provider rates. Every\n * dollar mined out of this module is that figure: the signals page asks\n * one money question and it is \"what are we spending\" (AGL-3015).\n */\n providerCostUsd: number\n docsPaths: string[]\n stopReason: string | null\n feedback: 'up' | 'down' | null\n /** Served with no model call — docs retrieval or a cache hit (AGL-2486). */\n deflected: boolean\n}\n\nexport interface DocsGapRow {\n path: string\n /** Turns that cited this path. */\n questions: number\n up: number\n down: number\n /** Distinct orgs that landed here — a gap one org has is not a gap. */\n orgs: number\n providerCostUsd: number\n /** `down / (up + down)`, or null when nobody rated. */\n downRate: number | null\n}\n\nexport interface UngroundedRoute {\n route: string\n questions: number\n down: number\n}\n\n/**\n * A turn whose PROSE is worth reading (AGL-2314).\n *\n * `assistExchanges` holds the verbatim half — the question a customer typed\n * and the answer we gave — for 180 days, and the module header calls it \"the\n * data loop's corpus\". No corpus consumer existed: nothing in the repo read\n * the collection, so we were retaining customers' words, and publicly\n * promising to, for zero product value.\n *\n * The shortlist is deliberately NOT every turn. Prose is fetched only for\n * turns that FAILED — rated thumbs-down, or grounded in no documentation at\n * all — because those are the ones where the counts cannot say what went\n * wrong and the words can. Reading the successful ones would be surveillance\n * with a dashboard on it.\n */\nexport interface ProseCandidate {\n orgId: string\n /**\n * That workspace by name, filled in by the route.\n *\n * Optional because this module is pure and Firestore-free: it can count a\n * workspace's turns and it cannot know what the workspace is called. The\n * route resolves the labels and the reader falls back to the id, so a\n * workspace outside the lookup is still a lead somebody can search for.\n */\n orgLabel?: string | null\n exchangeId: string\n /** The console screen the question was asked from. */\n route: string\n feedback: 'up' | 'down' | null\n /** False when retrieval cited no documentation at all. */\n grounded: boolean\n}\n\nexport interface OrgCostRow {\n orgId: string\n /** That workspace by name — see {@link ProseCandidate.orgLabel}. */\n orgLabel?: string | null\n messages: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n providerCostUsd: number\n down: number\n}\n\n/** One row of a cost breakdown — see `totals.byTier` / `totals.byModel`. */\nexport interface CostSplit {\n messages: number\n providerCostUsd: number\n}\n\n/** One kind of model turn, summed (AGL-2937) — see `totals.byKind`. */\nexport interface KindTokenSplit {\n messages: number\n providerCostUsd: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n /**\n * The output 95 of every 100 turns of this kind stayed within, by nearest\n * rank — the figure a kind's `max_tokens` is sized against.\n */\n outputP95: number\n /** The share of this kind's prompt tokens the cache served — `aiCacheHitRate`. */\n cacheHitRate: number | null\n}\n\n/** The kinds as the board lists them, dearest first. */\nexport function kindTokenRows(\n split: Record<string, KindTokenSplit>,\n): Array<KindTokenSplit & { kind: string }> {\n return Object.entries(split)\n .map(([kind, value]) => ({ kind, ...value }))\n .sort(\n (a, b) =>\n b.providerCostUsd - a.providerCostUsd || b.messages - a.messages || a.kind.localeCompare(b.kind),\n )\n}\n\n/** A sample's value at a percentile by nearest rank — always one that was seen; 0 for none. */\nexport function nearestRankPercentile(values: readonly number[], percentile: number): number {\n if (!values.length) return 0\n const sorted = [...values].sort((a, b) => a - b)\n const rank = Math.min(sorted.length, Math.max(1, Math.ceil(percentile * sorted.length)))\n return sorted[rank - 1]\n}\n\n/**\n * A cost breakdown as a sorted, labelled list — dearest first (AGL-2340).\n *\n * Exported because the ordering is the readable half. A breakdown in\n * whatever order the keys happened to be inserted is a table you have to\n * scan; the point of the panel is that the expensive line is the top line.\n */\nexport function costSplitRows(\n split: Record<string, CostSplit>,\n): { key: string; messages: number; providerCostUsd: number }[] {\n return Object.entries(split)\n .map(([key, value]) => ({\n key,\n messages: value.messages,\n providerCostUsd: value.providerCostUsd,\n }))\n .sort((a, b) => b.providerCostUsd - a.providerCostUsd || a.key.localeCompare(b.key))\n}\n\n/**\n * One workspace on the month's spend leaderboard (AGL-2930).\n *\n * Read off `assistUsage/{month}` rather than off the signals: the signals\n * carry every turn ever recorded and no month, so ranking them answers\n * \"who has spent the most since the beginning\", and the board's question is\n * \"who is spending the most NOW\". The month document is also where the\n * refusal counter lives, so the row can say how often the org was refused\n * beside how much it spent — the two numbers a staff reader compares.\n */\nexport interface AssistSpendRow {\n orgId: string\n orgLabel?: string | null\n plan: string\n aiAddon: boolean\n /** Credits drawn this month, from the measured spend. */\n credits: number\n providerCostUsd: number\n refusals: {\n band: number\n cap: number\n messages: number\n budget: number\n /** A hard AI allotment a manager set (AGL-2942). */\n allotment: number\n /** The Free taste's rungs (AGL-2925); zero on every paid workspace. */\n account: number\n requests: number\n refusals: number\n platform: number\n total: number\n }\n}\n\n/** The Free taste's refusals as one figure: the board names the wall, not each rung. */\nexport function freeTasteRefusals(refusals: AssistSpendRow['refusals']): number {\n return refusals.account + refusals.requests + refusals.refusals + refusals.platform\n}\n\n/**\n * The leaderboard's ordering: dearest first, then most credits, then by id\n * so two equal rows render in one stable order rather than the order the\n * scan happened to return them in. Returns the top `limit` and how many\n * were ranked, so the page can say \"top 25 of 140\" rather than imply the\n * cut is the whole fleet.\n */\nexport function rankAssistSpend(\n rows: readonly AssistSpendRow[],\n limit: number,\n): { rows: AssistSpendRow[]; ranked: number } {\n const sorted = [...rows].sort(\n (a, b) =>\n b.providerCostUsd - a.providerCostUsd ||\n b.credits - a.credits ||\n a.orgId.localeCompare(b.orgId),\n )\n const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : sorted.length\n return { rows: sorted.slice(0, cap), ranked: sorted.length }\n}\n\n/**\n * Today's platform-wide free-tier AI spend against its daily ceiling\n * (AGL-2925), as the route reads it beside the mined signals. Declared here,\n * where the page's report type lives, so the client page needs no import\n * from the admin library to name it.\n */\nexport interface AssistFreeSpendReadout {\n /** The UTC day the figures describe. */\n day: string\n /**\n * The day's free spend as the CEILING measures it — the billed figure,\n * because the ceiling this is compared against was sized against it and\n * the reservation refuses on the same reading (AGL-3015). The one dollar\n * on this page that is not our provider bill, and it is a wall rather than\n * a cost.\n */\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once staff were mailed at 80% today. */\n alerted: boolean\n /** True while every Free workspace is refused generation for the day. */\n paused: boolean\n}\n\nexport interface AssistMiningReport {\n scanned: number\n /** True when the read hit its ceiling — see `mineAssistSignals`. */\n truncated: boolean\n totals: {\n messages: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n providerCostUsd: number\n /**\n * Turns answered with NO model call — docs retrieval or an answer-cache\n * hit (AGL-2486). Counted separately from `messages`, which is every\n * turn, so `deflected / messages` is the deflection rate.\n */\n deflected: number\n /**\n * That rate, or `null` when nothing was scanned.\n *\n * Nullable rather than 0 for the reason `costUsd` on a reservation is:\n * \"no turns were scanned\" and \"every turn cost a model call\" are opposite\n * findings, and 0 says the second one when the truth is the first. This\n * is the headline number for whether Assist is affordable to leave on,\n * and it must not be able to report a false alarm on an idle month.\n */\n deflectionRate: number | null\n /**\n * Share of prompt tokens served from cache — reads over everything the\n * prompts were billed as, cache writes included (`aiCacheHitRate`) — or\n * null at zero.\n */\n cacheReadRate: number | null\n /**\n * Cost split by entitlement tier, then by model (AGL-2340).\n *\n * Both carried as `{ messages, providerCostUsd }` rather than a bare turn\n * count, because the questions these answer are money questions and a\n * count cannot answer either of them. `byTier` settles \"is the free tier\n * eating the margin, or are paying orgs?\" — the free tier can be a\n * minority of turns and a majority of spend, and a count says the\n * opposite of the truth in exactly that case. `byModel` settles \"would a\n * cheaper model on the common path fix this?\", where the whole point is\n * that turns and dollars do not move together.\n */\n byTier: Record<string, CostSplit>\n byModel: Record<string, CostSplit>\n /**\n * Model turns split by what they were for (AGL-2937): cost, the four\n * token counts, the p95 output and the cache hit rate of each kind. A\n * turn answered with no model call has no tokens to split and is left\n * out.\n */\n byKind: Record<string, KindTokenSplit>\n stopReasons: Record<string, number>\n feedback: { up: number; down: number; none: number }\n }\n docsGaps: DocsGapRow[]\n /**\n * How tall each ranking was BEFORE it was cut to `limit`.\n *\n * `truncated` reports only that the SCAN hit its ceiling. It says nothing\n * about the rankings built from what was scanned, and those are cut too —\n * every list below is sliced to `limit`. A table showing twenty-five of a\n * hundred and thirty-seven cited pages looks exactly like one showing all\n * twenty-five there are, which is the AGL-2220 shape the scan ceiling was\n * given a banner to avoid, reproduced one layer down.\n *\n * Counted rather than inferred from `length === limit`: a ranking that is\n * exactly `limit` rows tall is the case where the guess is wrong in both\n * directions.\n */\n ranked: {\n docsGaps: number\n ungroundedRoutes: number\n orgs: number\n }\n /**\n * Today's free-tier spend against the platform ceiling (AGL-2925).\n * Optional because the miner is pure and does not produce it — the route\n * reads it from Firestore and attaches it beside the mined report.\n */\n freeSpend?: AssistFreeSpendReadout\n /** Turns whose words are worth reading — see {@link ProseCandidate}. */\n proseCandidates: ProseCandidate[]\n ungrounded: {\n questions: number\n down: number\n routes: UngroundedRoute[]\n }\n orgs: OrgCostRow[]\n}\n\nconst number = (value: unknown) => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/** A signal's kind: the field once it is written, else read off its route (AGL-2937). */\nfunction signalKind(data: Record<string, unknown>, route: string): string {\n const kind = data['kind']\n if (typeof kind === 'string' && kind) return kind\n // A job step was metered under this route before its signal named the kind.\n return route === 'ai/jobs' ? 'job' : aiUsageKindFromRoute(route)\n}\n\n/**\n * Normalize one raw signal document. Every field is defaulted, because a\n * signal written by an older build is still evidence and dropping it would\n * bias the ranking toward whatever shipped most recently.\n */\nexport function assistSignalRow(\n orgId: string,\n exchangeId: string,\n data: Record<string, unknown>,\n): AssistSignalRow {\n const rawPaths = Array.isArray(data['docsPaths']) ? data['docsPaths'] : []\n const feedback = data['feedback']\n const route = String(data['route'] ?? '')\n return {\n orgId,\n exchangeId,\n route,\n kind: signalKind(data, route),\n model: String(data['model'] ?? 'unknown'),\n tier: String(data['tier'] ?? 'unknown'),\n inputTokens: number(data['inputTokens']),\n outputTokens: number(data['outputTokens']),\n cacheReadTokens: number(data['cacheReadTokens']),\n cacheWriteTokens: number(data['cacheWriteTokens']),\n // What the turn COST US (AGL-3015). The stored `estCostUsd` beside it\n // is the same turn at BILLED rates — the figure the customer's credits\n // came out of — and every dollar on the signals page is asking the other\n // question. A signal written before the split carries only the billed\n // figure, which over-reads our bill rather than under-reads it.\n providerCostUsd: assistProviderCostUsd(\n data['estCostUsd'],\n data[ASSIST_PROVIDER_COST_FIELD],\n ),\n docsPaths: rawPaths.map((path) => String(path)).filter(Boolean),\n stopReason: data['stopReason'] == null ? null : String(data['stopReason']),\n feedback: feedback === 'up' || feedback === 'down' ? feedback : null,\n // Strict `=== true`, so a signal written before the field existed reads\n // as \"served by a model\" rather than as deflected. Defaulting the other\n // way would credit historic turns with a saving that never happened and\n // make the rate look best on the day it shipped.\n deflected: data['deflected'] === true,\n }\n}\n\n/** Accumulate one turn's spend into a `byTier`/`byModel` bucket. */\nfunction addToSplit(\n split: Record<string, CostSplit>,\n key: string,\n providerCostUsd: number,\n) {\n const bucket = split[key] ?? { messages: 0, providerCostUsd: 0 }\n bucket.messages += 1\n bucket.providerCostUsd += providerCostUsd\n split[key] = bucket\n}\n\n/**\n * Rank the docs gaps and roll the money up.\n *\n * `truncated` is carried through rather than hidden. A sweep that quietly\n * stops at a ceiling and presents the remainder as the whole is the AGL-2220\n * defect, and it is worse here than there: a partial sample of a RANKING\n * looks exactly like a complete one, and the thing being ranked is where to\n * spend documentation effort.\n */\nexport function mineAssistSignals(\n rows: AssistSignalRow[],\n options: { truncated?: boolean; limit?: number } = {},\n): AssistMiningReport {\n const limit = options.limit ?? 25\n const totals = {\n messages: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n providerCostUsd: 0,\n deflected: 0,\n deflectionRate: null as number | null,\n cacheReadRate: null as number | null,\n byTier: {} as Record<string, CostSplit>,\n byModel: {} as Record<string, CostSplit>,\n byKind: {} as Record<string, KindTokenSplit>,\n stopReasons: {} as Record<string, number>,\n feedback: { up: 0, down: 0, none: 0 },\n }\n\n const gaps = new Map<\n string,\n { row: Omit<DocsGapRow, 'orgs' | 'downRate'>; orgs: Set<string> }\n >()\n const ungroundedRoutes = new Map<string, UngroundedRoute>()\n const orgs = new Map<string, OrgCostRow>()\n const proseCandidates: ProseCandidate[] = []\n const kinds = new Map<\n string,\n Omit<KindTokenSplit, 'outputP95' | 'cacheHitRate'> & { outputs: number[] }\n >()\n let ungroundedQuestions = 0\n let ungroundedDown = 0\n\n for (const row of rows) {\n totals.messages += 1\n totals.inputTokens += row.inputTokens\n totals.outputTokens += row.outputTokens\n totals.cacheReadTokens += row.cacheReadTokens\n totals.cacheWriteTokens += row.cacheWriteTokens\n totals.providerCostUsd += row.providerCostUsd\n if (row.deflected) totals.deflected += 1\n addToSplit(totals.byTier, row.tier, row.providerCostUsd)\n addToSplit(totals.byModel, row.model, row.providerCostUsd)\n if (!row.deflected) {\n const kind = row.kind || 'unknown'\n const bucket = kinds.get(kind) ?? {\n messages: 0,\n providerCostUsd: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n outputs: [],\n }\n bucket.messages += 1\n bucket.providerCostUsd += row.providerCostUsd\n bucket.inputTokens += row.inputTokens\n bucket.outputTokens += row.outputTokens\n bucket.cacheReadTokens += row.cacheReadTokens\n bucket.cacheWriteTokens += row.cacheWriteTokens\n bucket.outputs.push(row.outputTokens)\n kinds.set(kind, bucket)\n }\n const stop = row.stopReason ?? 'none'\n totals.stopReasons[stop] = (totals.stopReasons[stop] ?? 0) + 1\n if (row.feedback === 'up') totals.feedback.up += 1\n else if (row.feedback === 'down') totals.feedback.down += 1\n else totals.feedback.none += 1\n\n const org = orgs.get(row.orgId) ?? {\n orgId: row.orgId,\n messages: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n providerCostUsd: 0,\n down: 0,\n }\n org.messages += 1\n org.inputTokens += row.inputTokens\n org.outputTokens += row.outputTokens\n org.cacheReadTokens += row.cacheReadTokens\n org.cacheWriteTokens += row.cacheWriteTokens\n org.providerCostUsd += row.providerCostUsd\n if (row.feedback === 'down') org.down += 1\n orgs.set(row.orgId, org)\n\n /**\n * A FAILING turn. Thumbs-down, or nothing retrieved at all (AGL-2314).\n *\n * Collected before the `continue` below, so an ungrounded turn — the case\n * that leaves the loop early — is not silently excluded from the very\n * shortlist it most belongs on.\n */\n if (row.feedback === 'down' || !row.docsPaths.length) {\n proseCandidates.push({\n orgId: row.orgId,\n exchangeId: row.exchangeId,\n route: row.route || '(unknown)',\n feedback: row.feedback,\n grounded: row.docsPaths.length > 0,\n })\n }\n\n if (!row.docsPaths.length) {\n // Retrieval matched nothing. Counted here and NOT in `docsGaps` — a\n // question with no citation has no path to rank under, which is\n // precisely why a path-keyed ranking cannot see a missing page.\n ungroundedQuestions += 1\n if (row.feedback === 'down') ungroundedDown += 1\n const route = row.route || '(unknown)'\n const entry = ungroundedRoutes.get(route) ?? {\n route,\n questions: 0,\n down: 0,\n }\n entry.questions += 1\n if (row.feedback === 'down') entry.down += 1\n ungroundedRoutes.set(route, entry)\n continue\n }\n\n // Cost is attributed to EVERY path the turn cited, so the column is\n // \"what did questions touching this page cost\", not a partition of the\n // total. Splitting a turn's cost across its citations would understate\n // an expensive page that is always cited alongside others, and the\n // column exists to find pages worth rewriting, not to balance a ledger.\n for (const path of new Set(row.docsPaths)) {\n const entry = gaps.get(path) ?? {\n row: { path, questions: 0, up: 0, down: 0, providerCostUsd: 0 },\n orgs: new Set<string>(),\n }\n entry.row.questions += 1\n entry.row.providerCostUsd += row.providerCostUsd\n if (row.feedback === 'up') entry.row.up += 1\n if (row.feedback === 'down') entry.row.down += 1\n entry.orgs.add(row.orgId)\n gaps.set(path, entry)\n }\n }\n\n /**\n * Thumbs-down first, then the ungrounded ones.\n *\n * A rated failure is somebody telling us the answer was wrong; an\n * ungrounded turn is us telling ourselves we had nothing to answer with.\n * Both are worth reading, and the first is worth reading first.\n */\n proseCandidates.sort((a, b) => {\n const rank = (candidate: ProseCandidate) =>\n candidate.feedback === 'down' ? 0 : 1\n return rank(a) - rank(b)\n })\n\n totals.cacheReadRate = aiCacheHitRate({\n input: totals.inputTokens,\n cached: totals.cacheReadTokens,\n cacheWrite: totals.cacheWriteTokens,\n })\n for (const [kind, { outputs, ...bucket }] of kinds) {\n totals.byKind[kind] = {\n ...bucket,\n outputP95: nearestRankPercentile(outputs, 0.95),\n cacheHitRate: aiCacheHitRate({\n input: bucket.inputTokens,\n cached: bucket.cacheReadTokens,\n cacheWrite: bucket.cacheWriteTokens,\n }),\n }\n }\n totals.deflectionRate = totals.messages\n ? totals.deflected / totals.messages\n : null\n\n const docsGaps = [...gaps.values()]\n .map(({ row, orgs: orgSet }) => {\n const rated = row.up + row.down\n return {\n ...row,\n orgs: orgSet.size,\n downRate: rated ? row.down / rated : null,\n }\n })\n // Thumbs-down first, then volume. Ranking on volume alone surfaces the\n // pages people read most, which is a popularity list and not a gap list;\n // ranking on rate alone puts a single grumpy rating above a page that\n // failed forty people. Down count leads, volume breaks the tie.\n .sort((a, b) => b.down - a.down || b.questions - a.questions)\n .slice(0, limit)\n\n const ungroundedRouteRows = [...ungroundedRoutes.values()].sort(\n (a, b) => b.questions - a.questions || b.down - a.down,\n )\n const orgRows = [...orgs.values()].sort(\n (a, b) => b.providerCostUsd - a.providerCostUsd || b.messages - a.messages,\n )\n\n return {\n scanned: rows.length,\n truncated: Boolean(options.truncated),\n totals,\n docsGaps,\n ranked: {\n // `gaps` rather than `docsGaps`: the latter is already sliced, so\n // measuring it would report the cap as the total and the disclosure\n // would agree with itself forever.\n docsGaps: gaps.size,\n ungroundedRoutes: ungroundedRoutes.size,\n orgs: orgs.size,\n },\n proseCandidates: proseCandidates.slice(0, limit),\n ungrounded: {\n questions: ungroundedQuestions,\n down: ungroundedDown,\n routes: ungroundedRouteRows.slice(0, limit),\n },\n orgs: orgRows.slice(0, limit),\n }\n}\n"],"names":["assistProviderCostUsd","ASSIST_PROVIDER_COST_FIELD","aiCacheHitRate","aiUsageKindFromRoute","kindTokenRows","split","Object","entries","map","kind","value","sort","a","b","providerCostUsd","messages","localeCompare","nearestRankPercentile","values","percentile","length","sorted","rank","Math","min","max","ceil","costSplitRows","key","freeTasteRefusals","refusals","account","requests","platform","rankAssistSpend","rows","limit","credits","orgId","cap","Number","isFinite","floor","slice","ranked","number","parsed","signalKind","data","route","assistSignalRow","exchangeId","rawPaths","Array","isArray","feedback","String","model","tier","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","docsPaths","path","filter","Boolean","stopReason","deflected","addToSplit","bucket","mineAssistSignals","options","totals","deflectionRate","cacheReadRate","byTier","byModel","byKind","stopReasons","up","down","none","gaps","Map","ungroundedRoutes","orgs","proseCandidates","kinds","ungroundedQuestions","ungroundedDown","row","get","outputs","push","set","stop","org","grounded","entry","questions","Set","add","candidate","input","cached","cacheWrite","outputP95","cacheHitRate","docsGaps","orgSet","rated","size","downRate","ungroundedRouteRows","orgRows","scanned","truncated","ungrounded","routes"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/usage/assist-signal-mining.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assistProviderCostUsd,\n ASSIST_PROVIDER_COST_FIELD,\n} from './assist-credits'\nimport { aiCacheHitRate } from '../model/ai-tokens'\nimport { aiUsageKindFromRoute } from '../model/ai-usage-by-user'\n\n/**\n * The read side of the Aglyn Assist data loop (AGL-1860, AGL-2252).\n *\n * `orgs/{id}/assistSignals/{id}` is written on every assist turn and, until\n * this module, was read by nothing. That is a specific kind of failure: the\n * signal document exists BECAUSE AGL-1972 split the prose from the analytics\n * so the corpus could outlive the 180-day expiry on what people typed. A\n * corpus preserved for a reader that does not exist is just retention.\n *\n * Three questions, answered from one pass:\n *\n * 1. **Where are the docs thin?** Rank the cited docs paths by how often a\n * question landed on them and how often the answer was rated down. A\n * page cited constantly and rated down is a page that is being FOUND and\n * is not answering — the single most actionable row in the set, and the\n * one AGL-1860 wants turned into a docs issue.\n * 2. **What did retrieval miss entirely?** A turn with no `docsPaths` is a\n * question the corpus could not match at all, so the model answered\n * ungrounded. That is a sharper gap signal than a low rating, and it is\n * invisible in any ranking keyed on paths — a missing page cites nothing,\n * so it appears nowhere. It is counted separately, by ROUTE, so the gap\n * comes with the screen the person was looking at when they hit it.\n * 3. **What is this costing, and to whom?** Per-org and fleet-wide token and\n * dollar rollups, plus the cache-read rate. That last one settles a\n * question the chat route could only pose: the cached system prefix\n * measures 1,030–1,190 tokens against Sonnet 5's 1,024-token minimum, so\n * whether it caches at all is an empirical question and `cacheReadTokens`\n * is the only evidence. A prefix under the minimum caches silently not at\n * all, and the bill is the only place it shows.\n *\n * Pure and free of Firestore, so it can be tested on fixtures rather than on\n * a mocked SDK — the route does the reading, this does the arithmetic.\n */\n\n/** One `assistSignals` document, as the route projects it. */\nexport interface AssistSignalRow {\n orgId: string\n /**\n * The signal's document id, which IS the exchange's id (AGL-2314).\n *\n * `recordAssistExchange` mints one id and writes both halves under it —\n * `assistExchanges/{id}` for the prose that expires and `assistSignals/{id}`\n * for the analytics that do not — deliberately, \"so an exchange and its\n * signal are joinable for as long as both exist, without storing a second\n * pointer that could rot\". Nothing ever made the join. This is the field\n * that lets the corpus be read.\n */\n exchangeId: string\n route: string\n /**\n * What the turn was for, as the rollups name it (AGL-2937): `assist`, a\n * besigner mode, or a job kind. A signal written before the field is read\n * by its route, where a generation job's step reads as `job`.\n */\n kind?: string\n model: string\n tier: 'free' | 'entitled' | string\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n /**\n * What this turn cost us, at the serving model's provider rates. Every\n * dollar mined out of this module is that figure: the signals page asks\n * one money question and it is \"what are we spending\" (AGL-3015).\n */\n providerCostUsd: number\n docsPaths: string[]\n stopReason: string | null\n feedback: 'up' | 'down' | null\n /** Served with no model call — docs retrieval or a cache hit (AGL-2486). */\n deflected: boolean\n}\n\nexport interface DocsGapRow {\n path: string\n /** Turns that cited this path. */\n questions: number\n up: number\n down: number\n /** Distinct orgs that landed here — a gap one org has is not a gap. */\n orgs: number\n providerCostUsd: number\n /** `down / (up + down)`, or null when nobody rated. */\n downRate: number | null\n}\n\nexport interface UngroundedRoute {\n route: string\n questions: number\n down: number\n}\n\n/**\n * A turn whose PROSE is worth reading (AGL-2314).\n *\n * `assistExchanges` holds the verbatim half — the question a customer typed\n * and the answer we gave — for 180 days, and the module header calls it \"the\n * data loop's corpus\". No corpus consumer existed: nothing in the repo read\n * the collection, so we were retaining customers' words, and publicly\n * promising to, for zero product value.\n *\n * The shortlist is deliberately NOT every turn. Prose is fetched only for\n * turns that FAILED — rated thumbs-down, or grounded in no documentation at\n * all — because those are the ones where the counts cannot say what went\n * wrong and the words can. Reading the successful ones would be surveillance\n * with a dashboard on it.\n */\nexport interface ProseCandidate {\n orgId: string\n /**\n * That workspace by name, filled in by the route.\n *\n * Optional because this module is pure and Firestore-free: it can count a\n * workspace's turns and it cannot know what the workspace is called. The\n * route resolves the labels and the reader falls back to the id, so a\n * workspace outside the lookup is still a lead somebody can search for.\n */\n orgLabel?: string | null\n exchangeId: string\n /** The console screen the question was asked from. */\n route: string\n feedback: 'up' | 'down' | null\n /** False when retrieval cited no documentation at all. */\n grounded: boolean\n}\n\nexport interface OrgCostRow {\n orgId: string\n /** That workspace by name — see {@link ProseCandidate.orgLabel}. */\n orgLabel?: string | null\n messages: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n providerCostUsd: number\n down: number\n}\n\n/** One row of a cost breakdown — see `totals.byTier` / `totals.byModel`. */\nexport interface CostSplit {\n messages: number\n providerCostUsd: number\n}\n\n/** One kind of model turn, summed (AGL-2937) — see `totals.byKind`. */\nexport interface KindTokenSplit {\n messages: number\n providerCostUsd: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n /**\n * The output 95 of every 100 turns of this kind stayed within, by nearest\n * rank — the figure a kind's `max_tokens` is sized against.\n */\n outputP95: number\n /** The share of this kind's prompt tokens the cache served — `aiCacheHitRate`. */\n cacheHitRate: number | null\n}\n\n/** The kinds as the board lists them, dearest first. */\nexport function kindTokenRows(\n split: Record<string, KindTokenSplit>,\n): Array<KindTokenSplit & { kind: string }> {\n return Object.entries(split)\n .map(([kind, value]) => ({ kind, ...value }))\n .sort(\n (a, b) =>\n b.providerCostUsd - a.providerCostUsd || b.messages - a.messages || a.kind.localeCompare(b.kind),\n )\n}\n\n/** A sample's value at a percentile by nearest rank — always one that was seen; 0 for none. */\nexport function nearestRankPercentile(values: readonly number[], percentile: number): number {\n if (!values.length) return 0\n const sorted = [...values].sort((a, b) => a - b)\n const rank = Math.min(sorted.length, Math.max(1, Math.ceil(percentile * sorted.length)))\n return sorted[rank - 1]\n}\n\n/**\n * A cost breakdown as a sorted, labelled list — dearest first (AGL-2340).\n *\n * Exported because the ordering is the readable half. A breakdown in\n * whatever order the keys happened to be inserted is a table you have to\n * scan; the point of the panel is that the expensive line is the top line.\n */\nexport function costSplitRows(\n split: Record<string, CostSplit>,\n): { key: string; messages: number; providerCostUsd: number }[] {\n return Object.entries(split)\n .map(([key, value]) => ({\n key,\n messages: value.messages,\n providerCostUsd: value.providerCostUsd,\n }))\n .sort((a, b) => b.providerCostUsd - a.providerCostUsd || a.key.localeCompare(b.key))\n}\n\n/**\n * One workspace on the month's spend leaderboard (AGL-2930).\n *\n * Read off `assistUsage/{month}` rather than off the signals: the signals\n * carry every turn ever recorded and no month, so ranking them answers\n * \"who has spent the most since the beginning\", and the board's question is\n * \"who is spending the most NOW\". The month document is also where the\n * refusal counter lives, so the row can say how often the org was refused\n * beside how much it spent — the two numbers a staff reader compares.\n */\nexport interface AssistSpendRow {\n orgId: string\n orgLabel?: string | null\n plan: string\n aiAddon: boolean\n /** Credits drawn this month, from the measured spend. */\n credits: number\n providerCostUsd: number\n refusals: {\n band: number\n cap: number\n messages: number\n budget: number\n /** A hard AI allotment a manager set (AGL-2942). */\n allotment: number\n /** The Free taste's rungs (AGL-2925); zero on every paid workspace. */\n account: number\n requests: number\n refusals: number\n platform: number\n total: number\n }\n}\n\n/** The Free taste's refusals as one figure: the board names the wall, not each rung. */\nexport function freeTasteRefusals(refusals: AssistSpendRow['refusals']): number {\n return refusals.account + refusals.requests + refusals.refusals + refusals.platform\n}\n\n/**\n * The leaderboard's ordering: dearest first, then most credits, then by id\n * so two equal rows render in one stable order rather than the order the\n * scan happened to return them in. Returns the top `limit` and how many\n * were ranked, so the page can say \"top 25 of 140\" rather than imply the\n * cut is the whole fleet.\n */\nexport function rankAssistSpend(\n rows: readonly AssistSpendRow[],\n limit: number,\n): { rows: AssistSpendRow[]; ranked: number } {\n const sorted = [...rows].sort(\n (a, b) =>\n b.providerCostUsd - a.providerCostUsd ||\n b.credits - a.credits ||\n a.orgId.localeCompare(b.orgId),\n )\n const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : sorted.length\n return { rows: sorted.slice(0, cap), ranked: sorted.length }\n}\n\n/**\n * Today's platform-wide free-tier AI spend against its daily ceiling\n * (AGL-2925), as the route reads it beside the mined signals. Declared here,\n * where the page's report type lives, so the client page needs no import\n * from the admin library to name it.\n */\nexport interface AssistFreeSpendReadout {\n /** The UTC day the figures describe. */\n day: string\n /**\n * The day's free spend as the CEILING measures it — the billed figure,\n * because the ceiling this is compared against was sized against it and\n * the reservation refuses on the same reading (AGL-3015). The one dollar\n * on this page that is not our provider bill, and it is a wall rather than\n * a cost.\n */\n estCostUsd: number\n requests: number\n refusals: number\n ceilingUsd: number\n /** True once staff were mailed at 80% today. */\n alerted: boolean\n /** True while every Free workspace is refused generation for the day. */\n paused: boolean\n}\n\nexport interface AssistMiningReport {\n scanned: number\n /** True when the read hit its ceiling — see `mineAssistSignals`. */\n truncated: boolean\n totals: {\n messages: number\n inputTokens: number\n outputTokens: number\n cacheReadTokens: number\n cacheWriteTokens: number\n providerCostUsd: number\n /**\n * Turns answered with NO model call — docs retrieval or an answer-cache\n * hit (AGL-2486). Counted separately from `messages`, which is every\n * turn, so `deflected / messages` is the deflection rate.\n */\n deflected: number\n /**\n * That rate, or `null` when nothing was scanned.\n *\n * Nullable rather than 0 for the reason `costUsd` on a reservation is:\n * \"no turns were scanned\" and \"every turn cost a model call\" are opposite\n * findings, and 0 says the second one when the truth is the first. This\n * is the headline number for whether Assist is affordable to leave on,\n * and it must not be able to report a false alarm on an idle month.\n */\n deflectionRate: number | null\n /**\n * Share of prompt tokens served from cache — reads over everything the\n * prompts were billed as, cache writes included (`aiCacheHitRate`) — or\n * null at zero.\n */\n cacheReadRate: number | null\n /**\n * Cost split by entitlement tier, then by model (AGL-2340).\n *\n * Both carried as `{ messages, providerCostUsd }` rather than a bare turn\n * count, because the questions these answer are money questions and a\n * count cannot answer either of them. `byTier` settles \"is the free tier\n * eating the margin, or are paying orgs?\" — the free tier can be a\n * minority of turns and a majority of spend, and a count says the\n * opposite of the truth in exactly that case. `byModel` settles \"would a\n * cheaper model on the common path fix this?\", where the whole point is\n * that turns and dollars do not move together.\n */\n byTier: Record<string, CostSplit>\n byModel: Record<string, CostSplit>\n /**\n * Model turns split by what they were for (AGL-2937): cost, the four\n * token counts, the p95 output and the cache hit rate of each kind. A\n * turn answered with no model call has no tokens to split and is left\n * out.\n */\n byKind: Record<string, KindTokenSplit>\n stopReasons: Record<string, number>\n feedback: { up: number; down: number; none: number }\n }\n docsGaps: DocsGapRow[]\n /**\n * How tall each ranking was BEFORE it was cut to `limit`.\n *\n * `truncated` reports only that the SCAN hit its ceiling. It says nothing\n * about the rankings built from what was scanned, and those are cut too —\n * every list below is sliced to `limit`. A table showing twenty-five of a\n * hundred and thirty-seven cited pages looks exactly like one showing all\n * twenty-five there are, which is the AGL-2220 shape the scan ceiling was\n * given a banner to avoid, reproduced one layer down.\n *\n * Counted rather than inferred from `length === limit`: a ranking that is\n * exactly `limit` rows tall is the case where the guess is wrong in both\n * directions.\n */\n ranked: {\n docsGaps: number\n ungroundedRoutes: number\n orgs: number\n }\n /**\n * Today's free-tier spend against the platform ceiling (AGL-2925).\n * Optional because the miner is pure and does not produce it — the route\n * reads it from Firestore and attaches it beside the mined report.\n */\n freeSpend?: AssistFreeSpendReadout\n /** Turns whose words are worth reading — see {@link ProseCandidate}. */\n proseCandidates: ProseCandidate[]\n ungrounded: {\n questions: number\n down: number\n routes: UngroundedRoute[]\n }\n orgs: OrgCostRow[]\n}\n\nconst number = (value: unknown) => {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/** A signal's kind: the field once it is written, else read off its route (AGL-2937). */\nfunction signalKind(data: Record<string, unknown>, route: string): string {\n const kind = data['kind']\n if (typeof kind === 'string' && kind) return kind\n // A job step was metered under this route before its signal named the kind.\n return route === 'ai/jobs' ? 'job' : aiUsageKindFromRoute(route)\n}\n\n/**\n * Normalize one raw signal document. Every field is defaulted, because a\n * signal written by an older build is still evidence and dropping it would\n * bias the ranking toward whatever shipped most recently.\n */\nexport function assistSignalRow(\n orgId: string,\n exchangeId: string,\n data: Record<string, unknown>,\n): AssistSignalRow {\n const rawPaths = Array.isArray(data['docsPaths']) ? data['docsPaths'] : []\n const feedback = data['feedback']\n const route = String(data['route'] ?? '')\n return {\n orgId,\n exchangeId,\n route,\n kind: signalKind(data, route),\n model: String(data['model'] ?? 'unknown'),\n tier: String(data['tier'] ?? 'unknown'),\n inputTokens: number(data['inputTokens']),\n outputTokens: number(data['outputTokens']),\n cacheReadTokens: number(data['cacheReadTokens']),\n cacheWriteTokens: number(data['cacheWriteTokens']),\n // What the turn COST US (AGL-3015). The stored `estCostUsd` beside it\n // is the same turn at BILLED rates — the figure the customer's credits\n // came out of — and every dollar on the signals page is asking the other\n // question. A signal written before the split carries only the billed\n // figure, which over-reads our bill rather than under-reads it.\n providerCostUsd: assistProviderCostUsd(\n data['estCostUsd'],\n data[ASSIST_PROVIDER_COST_FIELD],\n ),\n docsPaths: rawPaths.map((path) => String(path)).filter(Boolean),\n stopReason: data['stopReason'] == null ? null : String(data['stopReason']),\n feedback: feedback === 'up' || feedback === 'down' ? feedback : null,\n // Strict `=== true`, so a signal written before the field existed reads\n // as \"served by a model\" rather than as deflected. Defaulting the other\n // way would credit historic turns with a saving that never happened and\n // make the rate look best on the day it shipped.\n deflected: data['deflected'] === true,\n }\n}\n\n/** Accumulate one turn's spend into a `byTier`/`byModel` bucket. */\nfunction addToSplit(\n split: Record<string, CostSplit>,\n key: string,\n providerCostUsd: number,\n) {\n const bucket = split[key] ?? { messages: 0, providerCostUsd: 0 }\n bucket.messages += 1\n bucket.providerCostUsd += providerCostUsd\n split[key] = bucket\n}\n\n/**\n * Rank the docs gaps and roll the money up.\n *\n * `truncated` is carried through rather than hidden. A sweep that quietly\n * stops at a ceiling and presents the remainder as the whole is the AGL-2220\n * defect, and it is worse here than there: a partial sample of a RANKING\n * looks exactly like a complete one, and the thing being ranked is where to\n * spend documentation effort.\n */\nexport function mineAssistSignals(\n rows: AssistSignalRow[],\n options: { truncated?: boolean; limit?: number } = {},\n): AssistMiningReport {\n const limit = options.limit ?? 25\n const totals = {\n messages: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n providerCostUsd: 0,\n deflected: 0,\n deflectionRate: null as number | null,\n cacheReadRate: null as number | null,\n byTier: {} as Record<string, CostSplit>,\n byModel: {} as Record<string, CostSplit>,\n byKind: {} as Record<string, KindTokenSplit>,\n stopReasons: {} as Record<string, number>,\n feedback: { up: 0, down: 0, none: 0 },\n }\n\n const gaps = new Map<\n string,\n { row: Omit<DocsGapRow, 'orgs' | 'downRate'>; orgs: Set<string> }\n >()\n const ungroundedRoutes = new Map<string, UngroundedRoute>()\n const orgs = new Map<string, OrgCostRow>()\n const proseCandidates: ProseCandidate[] = []\n const kinds = new Map<\n string,\n Omit<KindTokenSplit, 'outputP95' | 'cacheHitRate'> & { outputs: number[] }\n >()\n let ungroundedQuestions = 0\n let ungroundedDown = 0\n\n for (const row of rows) {\n totals.messages += 1\n totals.inputTokens += row.inputTokens\n totals.outputTokens += row.outputTokens\n totals.cacheReadTokens += row.cacheReadTokens\n totals.cacheWriteTokens += row.cacheWriteTokens\n totals.providerCostUsd += row.providerCostUsd\n if (row.deflected) totals.deflected += 1\n addToSplit(totals.byTier, row.tier, row.providerCostUsd)\n addToSplit(totals.byModel, row.model, row.providerCostUsd)\n if (!row.deflected) {\n const kind = row.kind || 'unknown'\n const bucket = kinds.get(kind) ?? {\n messages: 0,\n providerCostUsd: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n outputs: [],\n }\n bucket.messages += 1\n bucket.providerCostUsd += row.providerCostUsd\n bucket.inputTokens += row.inputTokens\n bucket.outputTokens += row.outputTokens\n bucket.cacheReadTokens += row.cacheReadTokens\n bucket.cacheWriteTokens += row.cacheWriteTokens\n bucket.outputs.push(row.outputTokens)\n kinds.set(kind, bucket)\n }\n const stop = row.stopReason ?? 'none'\n totals.stopReasons[stop] = (totals.stopReasons[stop] ?? 0) + 1\n if (row.feedback === 'up') totals.feedback.up += 1\n else if (row.feedback === 'down') totals.feedback.down += 1\n else totals.feedback.none += 1\n\n const org = orgs.get(row.orgId) ?? {\n orgId: row.orgId,\n messages: 0,\n inputTokens: 0,\n outputTokens: 0,\n cacheReadTokens: 0,\n cacheWriteTokens: 0,\n providerCostUsd: 0,\n down: 0,\n }\n org.messages += 1\n org.inputTokens += row.inputTokens\n org.outputTokens += row.outputTokens\n org.cacheReadTokens += row.cacheReadTokens\n org.cacheWriteTokens += row.cacheWriteTokens\n org.providerCostUsd += row.providerCostUsd\n if (row.feedback === 'down') org.down += 1\n orgs.set(row.orgId, org)\n\n /**\n * A FAILING turn. Thumbs-down, or nothing retrieved at all (AGL-2314).\n *\n * Collected before the `continue` below, so an ungrounded turn — the case\n * that leaves the loop early — is not silently excluded from the very\n * shortlist it most belongs on.\n */\n if (row.feedback === 'down' || !row.docsPaths.length) {\n proseCandidates.push({\n orgId: row.orgId,\n exchangeId: row.exchangeId,\n route: row.route || '(unknown)',\n feedback: row.feedback,\n grounded: row.docsPaths.length > 0,\n })\n }\n\n if (!row.docsPaths.length) {\n // Retrieval matched nothing. Counted here and NOT in `docsGaps` — a\n // question with no citation has no path to rank under, which is\n // precisely why a path-keyed ranking cannot see a missing page.\n ungroundedQuestions += 1\n if (row.feedback === 'down') ungroundedDown += 1\n const route = row.route || '(unknown)'\n const entry = ungroundedRoutes.get(route) ?? {\n route,\n questions: 0,\n down: 0,\n }\n entry.questions += 1\n if (row.feedback === 'down') entry.down += 1\n ungroundedRoutes.set(route, entry)\n continue\n }\n\n // Cost is attributed to EVERY path the turn cited, so the column is\n // \"what did questions touching this page cost\", not a partition of the\n // total. Splitting a turn's cost across its citations would understate\n // an expensive page that is always cited alongside others, and the\n // column exists to find pages worth rewriting, not to balance a ledger.\n for (const path of new Set(row.docsPaths)) {\n const entry = gaps.get(path) ?? {\n row: { path, questions: 0, up: 0, down: 0, providerCostUsd: 0 },\n orgs: new Set<string>(),\n }\n entry.row.questions += 1\n entry.row.providerCostUsd += row.providerCostUsd\n if (row.feedback === 'up') entry.row.up += 1\n if (row.feedback === 'down') entry.row.down += 1\n entry.orgs.add(row.orgId)\n gaps.set(path, entry)\n }\n }\n\n /**\n * Thumbs-down first, then the ungrounded ones.\n *\n * A rated failure is somebody telling us the answer was wrong; an\n * ungrounded turn is us telling ourselves we had nothing to answer with.\n * Both are worth reading, and the first is worth reading first.\n */\n proseCandidates.sort((a, b) => {\n const rank = (candidate: ProseCandidate) =>\n candidate.feedback === 'down' ? 0 : 1\n return rank(a) - rank(b)\n })\n\n totals.cacheReadRate = aiCacheHitRate({\n input: totals.inputTokens,\n cached: totals.cacheReadTokens,\n cacheWrite: totals.cacheWriteTokens,\n })\n for (const [kind, { outputs, ...bucket }] of kinds) {\n totals.byKind[kind] = {\n ...bucket,\n outputP95: nearestRankPercentile(outputs, 0.95),\n cacheHitRate: aiCacheHitRate({\n input: bucket.inputTokens,\n cached: bucket.cacheReadTokens,\n cacheWrite: bucket.cacheWriteTokens,\n }),\n }\n }\n totals.deflectionRate = totals.messages\n ? totals.deflected / totals.messages\n : null\n\n const docsGaps = [...gaps.values()]\n .map(({ row, orgs: orgSet }) => {\n const rated = row.up + row.down\n return {\n ...row,\n orgs: orgSet.size,\n downRate: rated ? row.down / rated : null,\n }\n })\n // Thumbs-down first, then volume. Ranking on volume alone surfaces the\n // pages people read most, which is a popularity list and not a gap list;\n // ranking on rate alone puts a single grumpy rating above a page that\n // failed forty people. Down count leads, volume breaks the tie.\n .sort((a, b) => b.down - a.down || b.questions - a.questions)\n .slice(0, limit)\n\n const ungroundedRouteRows = [...ungroundedRoutes.values()].sort(\n (a, b) => b.questions - a.questions || b.down - a.down,\n )\n const orgRows = [...orgs.values()].sort(\n (a, b) => b.providerCostUsd - a.providerCostUsd || b.messages - a.messages,\n )\n\n return {\n scanned: rows.length,\n truncated: Boolean(options.truncated),\n totals,\n docsGaps,\n ranked: {\n // `gaps` rather than `docsGaps`: the latter is already sliced, so\n // measuring it would report the cap as the total and the disclosure\n // would agree with itself forever.\n docsGaps: gaps.size,\n ungroundedRoutes: ungroundedRoutes.size,\n orgs: orgs.size,\n },\n proseCandidates: proseCandidates.slice(0, limit),\n ungrounded: {\n questions: ungroundedQuestions,\n down: ungroundedDown,\n routes: ungroundedRouteRows.slice(0, limit),\n },\n orgs: orgRows.slice(0, limit),\n }\n}\n"],"names":["assistProviderCostUsd","ASSIST_PROVIDER_COST_FIELD","aiCacheHitRate","aiUsageKindFromRoute","kindTokenRows","split","Object","entries","map","kind","value","sort","a","b","providerCostUsd","messages","localeCompare","nearestRankPercentile","values","percentile","length","sorted","rank","Math","min","max","ceil","costSplitRows","key","freeTasteRefusals","refusals","account","requests","platform","rankAssistSpend","rows","limit","credits","orgId","cap","Number","isFinite","floor","slice","ranked","number","parsed","signalKind","data","route","assistSignalRow","exchangeId","rawPaths","Array","isArray","feedback","String","model","tier","inputTokens","outputTokens","cacheReadTokens","cacheWriteTokens","docsPaths","path","filter","Boolean","stopReason","deflected","addToSplit","bucket","mineAssistSignals","options","totals","deflectionRate","cacheReadRate","byTier","byModel","byKind","stopReasons","up","down","none","gaps","Map","ungroundedRoutes","orgs","proseCandidates","kinds","ungroundedQuestions","ungroundedDown","row","get","outputs","push","set","stop","org","grounded","entry","questions","Set","add","candidate","input","cached","cacheWrite","outputP95","cacheHitRate","docsGaps","orgSet","rated","size","downRate","ungroundedRouteRows","orgRows","scanned","truncated","ungrounded","routes"],"mappings":"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"}
@@ -0,0 +1,153 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { UsageAlertGuard } from '@aglyn/aglyn/app-utils/usage-budget';
18
+ /**
19
+ * The MARGIN GUARD.
20
+ *
21
+ * A customer's usage budget (`usage-budget.ts`) protects the customer. This
22
+ * protects the platform, from the one meter whose unit cost is real money
23
+ * paid to a third party and whose ceiling is a message count rather than a
24
+ * dollar figure: `assistEntitledMonthlyLimit` caps an entitled org at 1,000
25
+ * MESSAGES a month, and a thousand long, cache-cold Opus-class exchanges is
26
+ * a three-figure bill against a subscription that did not move.
27
+ *
28
+ * So one org's Assist COGS crossing this threshold notifies STAFF, not the
29
+ * customer — the customer is not being charged and has done nothing wrong.
30
+ * Returns the threshold crossed, or 0.
31
+ *
32
+ * `ASSIST_ORG_MONTHLY_COGS_ALERT_USD` overrides the default and FAILS TO THE
33
+ * DEFAULT: a blank or malformed value that disabled the guard would be
34
+ * another alert that cannot fire.
35
+ */
36
+ export declare const ASSIST_ORG_MONTHLY_COGS_ALERT_USD_DEFAULT = 25;
37
+ export declare function assistCogsAlertThresholdUsd(configured?: string | null): number;
38
+ /**
39
+ * The repo default spend ceiling: **$40 per org per month** (AGL-2264).
40
+ *
41
+ * The number is arithmetic rather than pricing. After AGL-2441 the
42
+ * 1,000-message entitled guard bounds roughly $28/org/month of worst-case
43
+ * spend at Sonnet, and the staff margin alert already fires at $25. $40
44
+ * therefore sits ABOVE anything the message cap can produce at the current
45
+ * model, so it changes no charged amount and no plan's behaviour — it is a
46
+ * ceiling on OUR cost, not a customer price, which is why it is outside the
47
+ * Sept-1 pricing lock. It binds only when an assumption behind that
48
+ * arithmetic has moved: an `ASSIST_MODEL` swap to an Opus-class tier, a
49
+ * longer prompt, or a caller finding another way to inflate input. That is
50
+ * the failure it exists for.
51
+ *
52
+ * It ships as a DEFAULT rather than as an environment variable someone must
53
+ * remember, because an unset ceiling is the fail-open this issue was opened
54
+ * about: a fresh deployment and a self-hoster both inherit a sane bound
55
+ * without knowing the variable exists.
56
+ *
57
+ * The free tier gets no separate figure and needs none — its 10 messages a
58
+ * UTC day bound it at roughly $0.28/day, so this is a backstop it cannot
59
+ * reach rather than a cap it runs into.
60
+ *
61
+ * ⚠️ It applies only to an org whose plan sells NO assist band. Agency and
62
+ * Enterprise include more assist than $40 of spend, so the meter's
63
+ * `assistMonthlyCeilingUsd` keeps this default off every plan that sells a
64
+ * band — see there for which figure binds when.
65
+ */
66
+ export declare const ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD = 40;
67
+ /**
68
+ * The per-org monthly PROVIDER-SPEND ceiling in USD — the dollar half of
69
+ * the cap (AGL-2264), from `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD` as the caller
70
+ * read it. Defaults to {@link ASSIST_ORG_MONTHLY_COGS_LIMIT_DEFAULT_USD};
71
+ * `off` removes it.
72
+ *
73
+ * Pure over the configured value, like {@link assistCogsAlertThresholdUsd}:
74
+ * the meter that refuses at the ceiling and the cron that announces the
75
+ * refusal read the one variable and must agree on what it means.
76
+ *
77
+ * The meter's message caps bound spend only through an assumed cost per
78
+ * message, and that assumption is exactly what drifts: `ASSIST_MODEL` is an
79
+ * env override, prompts grow, and a client controls how much history it
80
+ * posts. The ceiling is measured against the real figure instead: the
81
+ * running `estCostUsd` on the month's assist usage document, which the meter
82
+ * increments at the SERVING model's rates. So it bounds the actual bill
83
+ * rather than a forecast of it.
84
+ *
85
+ * The refusal is deliberately the same shape as the message refusal (a
86
+ * reservation that did not move the counter), so an org at the ceiling
87
+ * spends nothing at all rather than spending less. It refuses — it does
88
+ * NOT quietly swap to a cheaper model. A silent quality drop is worse than
89
+ * an honest stop, because nobody can tell it happened.
90
+ *
91
+ * ⚠️ **Fails to the DEFAULT, never to “no ceiling”.** An empty, negative or
92
+ * unparseable value reads as unconfigured and takes the repo default, so a
93
+ * typo cannot reopen the fail-open this exists to close. It cannot become a
94
+ * ceiling of `$0` either — a value of zero is not “refuse everyone”, it is
95
+ * “not a number I will honour” — because an outage across every workspace
96
+ * would be worse than the overspend being guarded.
97
+ *
98
+ * Removing the ceiling therefore takes a WORD rather than a number:
99
+ * `ASSIST_ORG_MONTHLY_COGS_LIMIT_USD=off`. A deployment paying its own
100
+ * provider bill may genuinely want none, and that is a decision someone
101
+ * should have to write down rather than reach by mistyping a digit.
102
+ */
103
+ export declare function assistOrgMonthlyCostLimitUsd(configured?: string | null): number | null;
104
+ /**
105
+ * The operator's ceiling exactly as CONFIGURED — three states, not two:
106
+ * a number, `null` for the word `off`, and `undefined` for unset or
107
+ * unparseable.
108
+ *
109
+ * `assistOrgMonthlyCostLimitUsd` collapses `undefined` onto the repo default
110
+ * and is the reading every existing caller wants. The composition with a
111
+ * plan's own band needs the third state, because "the operator wrote a
112
+ * number" and "nobody configured anything" have to bind differently against a
113
+ * band that was sold: an operator's figure is a decision, and the repo
114
+ * default is a backstop for orgs that have no band of their own.
115
+ */
116
+ export declare function assistOperatorCeilingUsd(configured?: string | null): number | null | undefined;
117
+ /**
118
+ * Whether this org's Assist spend has newly crossed the margin threshold.
119
+ *
120
+ * Dedupes through the same guard shape and the same month semantics as the
121
+ * customer budget, under its own key, so a staff alert and a customer alert
122
+ * can never suppress one another.
123
+ */
124
+ export declare function assistMarginBreach(input: {
125
+ assistUsd: number;
126
+ thresholdUsd: number;
127
+ guard: UsageAlertGuard | null | undefined;
128
+ month: string;
129
+ }): boolean;
130
+ /**
131
+ * Whether this org has newly crossed the HARD spend ceiling — the one that
132
+ * refuses (AGL-2264), as distinct from {@link assistMarginBreach}'s review
133
+ * threshold, which only warns.
134
+ *
135
+ * It needs its own guard and its own announcement because the margin alert
136
+ * cannot carry this news. That alert speaks in WHOLE MULTIPLES of its $25
137
+ * threshold, so an org that climbs from $25 to the $40 ceiling is still at
138
+ * 1x and stays silent — staff would learn that the assistant had stopped
139
+ * answering for a customer only when someone complained. A ceiling that
140
+ * refuses quietly is the same defect as a ceiling that does not refuse.
141
+ *
142
+ * Once per org per month: crossing is a state, not an escalating figure, and
143
+ * the org is refused from here to the month boundary regardless of how far
144
+ * past it the recorded spend sits.
145
+ */
146
+ export declare function assistCeilingBreach(input: {
147
+ assistUsd: number;
148
+ ceilingUsd: number | null;
149
+ guard: UsageAlertGuard | null | undefined;
150
+ month: string;
151
+ }): boolean;
152
+ /** The multiple {@link assistMarginBreach} would record. */
153
+ export declare function assistMarginMultiple(assistUsd: number, thresholdUsd: number): number;