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

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 (187) hide show
  1. package/README.md +2 -2
  2. package/package.json +17 -17
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/activity/ai-activity-actions.js +10 -2
  6. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  7. package/src/lib/components/ai-assist-provider.component.js +2 -2
  8. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  9. package/src/lib/components/ai-brief-dialog.component.js +3 -3
  10. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  11. package/src/lib/components/ai-experiment-cards.component.js +1 -1
  12. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  13. package/src/lib/components/ai-job-plan.component.d.ts +8 -0
  14. package/src/lib/components/ai-job-plan.component.js +21 -4
  15. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  16. package/src/lib/components/ai-permissions-on-host.component.d.ts +12 -2
  17. package/src/lib/components/ai-permissions-on-host.component.js +12 -3
  18. package/src/lib/components/ai-permissions-on-host.component.js.map +1 -1
  19. package/src/lib/components/ai-product-copy-card.component.d.ts +1 -1
  20. package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
  21. package/src/lib/components/ai-product-import-option.component.d.ts +1 -1
  22. package/src/lib/components/ai-product-import-option.component.js.map +1 -1
  23. package/src/lib/components/ai-product-zones.d.ts +124 -0
  24. package/src/lib/components/ai-product-zones.js +32 -0
  25. package/src/lib/components/ai-product-zones.js.map +1 -0
  26. package/src/lib/components/ai-products-hub-card.component.d.ts +1 -1
  27. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  28. package/src/lib/components/ai-products-proposals.component.d.ts +1 -1
  29. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  30. package/src/lib/components/ai-seo-audit-card.component.js +35 -92
  31. package/src/lib/components/ai-seo-audit-card.component.js.map +1 -1
  32. package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
  33. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  34. package/src/lib/components/assist-jobs-drawer.component.js +21 -15
  35. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  36. package/src/lib/components/assist-panel.component.js +2 -2
  37. package/src/lib/components/assist-panel.component.js.map +1 -1
  38. package/src/lib/components/assist-signals-page.component.js +2 -2
  39. package/src/lib/components/assist-signals-page.component.js.map +1 -1
  40. package/src/lib/jobs/ai-email-bindings.d.ts +28 -21
  41. package/src/lib/jobs/ai-email-bindings.js +75 -42
  42. package/src/lib/jobs/ai-email-bindings.js.map +1 -1
  43. package/src/lib/jobs/ai-job-campaign-step.d.ts +0 -2
  44. package/src/lib/jobs/ai-job-campaign-step.js +5 -10
  45. package/src/lib/jobs/ai-job-campaign-step.js.map +1 -1
  46. package/src/lib/jobs/ai-job-component-step.js +9 -3
  47. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  48. package/src/lib/jobs/ai-job-drafts.d.ts +9 -1
  49. package/src/lib/jobs/ai-job-drafts.js +12 -5
  50. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  51. package/src/lib/jobs/ai-job-email-step.d.ts +0 -2
  52. package/src/lib/jobs/ai-job-email-step.js +1 -3
  53. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-experiment-step.d.ts +1 -1
  55. package/src/lib/jobs/ai-job-experiment-step.js +1 -1
  56. package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-generation.d.ts +1 -1
  58. package/src/lib/jobs/ai-job-generation.js +10 -2
  59. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  60. package/src/lib/jobs/ai-job-layout-step.js +1 -1
  61. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-page-sections.d.ts +4 -2
  63. package/src/lib/jobs/ai-job-page-sections.js +7 -4
  64. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  65. package/src/lib/jobs/ai-job-page-step.js +7 -2
  66. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-plan-conformance.js +33 -0
  68. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  69. package/src/lib/jobs/ai-job-plan-step.d.ts +3 -0
  70. package/src/lib/jobs/ai-job-plan-step.js +29 -17
  71. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  72. package/src/lib/jobs/ai-job-plugin-drafts.d.ts +14 -6
  73. package/src/lib/jobs/ai-job-plugin-drafts.js +12 -4
  74. package/src/lib/jobs/ai-job-plugin-drafts.js.map +1 -1
  75. package/src/lib/jobs/ai-job-products-step.d.ts +7 -0
  76. package/src/lib/jobs/ai-job-products-step.js +54 -32
  77. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  78. package/src/lib/jobs/ai-job-seo-step.d.ts +3 -24
  79. package/src/lib/jobs/ai-job-seo-step.js +35 -108
  80. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  81. package/src/lib/jobs/ai-job-template-step.js +1 -0
  82. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  83. package/src/lib/jobs/ai-job-theme-step.d.ts +1 -1
  84. package/src/lib/jobs/ai-job-theme-step.js +1 -1
  85. package/src/lib/jobs/ai-job-theme-step.js.map +1 -1
  86. package/src/lib/jobs/ai-job-workflow-step.js +17 -9
  87. package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
  88. package/src/lib/jobs/ai-workflow-records.d.ts +19 -11
  89. package/src/lib/jobs/ai-workflow-records.js +60 -27
  90. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  91. package/src/lib/model/ai-allotments.d.ts +11 -3
  92. package/src/lib/model/ai-allotments.js +21 -4
  93. package/src/lib/model/ai-allotments.js.map +1 -1
  94. package/src/lib/model/ai-automation-outline.d.ts +21 -2
  95. package/src/lib/model/ai-automation-outline.js.map +1 -1
  96. package/src/lib/model/ai-build-plan.d.ts +57 -0
  97. package/src/lib/model/ai-build-plan.js +116 -7
  98. package/src/lib/model/ai-build-plan.js.map +1 -1
  99. package/src/lib/model/ai-seo.d.ts +11 -51
  100. package/src/lib/model/ai-seo.js +0 -17
  101. package/src/lib/model/ai-seo.js.map +1 -1
  102. package/src/lib/model/ai-site-inventory.js +1 -1
  103. package/src/lib/model/ai-site-inventory.js.map +1 -1
  104. package/src/lib/model/ai-template-subjects.js +2 -2
  105. package/src/lib/model/ai-template-subjects.js.map +1 -1
  106. package/src/lib/model/ai-theme-proposal.d.ts +1 -1
  107. package/src/lib/model/ai-theme-proposal.js +1 -1
  108. package/src/lib/model/ai-theme-proposal.js.map +1 -1
  109. package/src/lib/model/ai-workflow-job.d.ts +1 -3
  110. package/src/lib/model/ai-workflow-job.js +1 -2
  111. package/src/lib/model/ai-workflow-job.js.map +1 -1
  112. package/src/lib/plan-entitlements.d.ts +149 -0
  113. package/src/lib/plan-entitlements.js +168 -0
  114. package/src/lib/plan-entitlements.js.map +1 -0
  115. package/src/lib/plugin.js +1 -1
  116. package/src/lib/plugin.js.map +1 -1
  117. package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -4
  118. package/src/lib/runtime/ai-doctrine-validators.js +179 -23
  119. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  120. package/src/lib/runtime/ai-doctrine.js +5 -5
  121. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  122. package/src/lib/runtime/ai-eval-live.js +29 -14
  123. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  124. package/src/lib/runtime/ai-eval.d.ts +8 -1
  125. package/src/lib/runtime/ai-eval.js +29 -2
  126. package/src/lib/runtime/ai-eval.js.map +1 -1
  127. package/src/lib/runtime/ai-node-tree.js +3 -3
  128. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  129. package/src/lib/runtime/ai-palette.d.ts +6 -0
  130. package/src/lib/runtime/ai-palette.generated.js +21 -255
  131. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  132. package/src/lib/runtime/ai-palette.js +7 -1
  133. package/src/lib/runtime/ai-palette.js.map +1 -1
  134. package/src/lib/runtime/seo-audit.d.ts +13 -63
  135. package/src/lib/runtime/seo-audit.js +22 -207
  136. package/src/lib/runtime/seo-audit.js.map +1 -1
  137. package/src/lib/runtime/site-inventory.js +1 -1
  138. package/src/lib/runtime/site-inventory.js.map +1 -1
  139. package/src/lib/server/ai-admin-org.js +4 -25
  140. package/src/lib/server/ai-admin-org.js.map +1 -1
  141. package/src/lib/server/ai-admin-user.js +2 -19
  142. package/src/lib/server/ai-admin-user.js.map +1 -1
  143. package/src/lib/server/ai-assist-prompts.js +3 -3
  144. package/src/lib/server/ai-assist-prompts.js.map +1 -1
  145. package/src/lib/server/ai-insight-digest-route.js +8 -1
  146. package/src/lib/server/ai-insight-digest-route.js.map +1 -1
  147. package/src/lib/server/assist-chat.js +11 -10
  148. package/src/lib/server/assist-chat.js.map +1 -1
  149. package/src/lib/server/assist-view-context.js +23 -23
  150. package/src/lib/server/assist-view-context.js.map +1 -1
  151. package/src/lib/server.d.ts +1 -0
  152. package/src/lib/server.js.map +1 -1
  153. package/src/lib/testing/stand-in-automation-index.d.ts +20 -0
  154. package/src/lib/testing/stand-in-automation-index.js +102 -0
  155. package/src/lib/testing/stand-in-automation-index.js.map +1 -0
  156. package/src/lib/testing/stand-in-list-send-time.d.ts +4 -0
  157. package/src/lib/testing/stand-in-list-send-time.js +77 -0
  158. package/src/lib/testing/stand-in-list-send-time.js.map +1 -0
  159. package/src/lib/testing/stand-in-pipeline-index.d.ts +32 -0
  160. package/src/lib/testing/stand-in-pipeline-index.js +59 -0
  161. package/src/lib/testing/stand-in-pipeline-index.js.map +1 -0
  162. package/src/lib/testing/stand-in-product-index.d.ts +20 -0
  163. package/src/lib/testing/stand-in-product-index.js +105 -0
  164. package/src/lib/testing/stand-in-product-index.js.map +1 -0
  165. package/src/lib/tools/ai-component-tool.js +2 -2
  166. package/src/lib/tools/ai-component-tool.js.map +1 -1
  167. package/src/lib/tools/ai-duplicate-resource-tool.js +1 -1
  168. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  169. package/src/lib/tools/ai-seo-tool.d.ts +6 -18
  170. package/src/lib/tools/ai-seo-tool.js +7 -38
  171. package/src/lib/tools/ai-seo-tool.js.map +1 -1
  172. package/src/lib/tools/ai-workflow-tool.d.ts +1 -1
  173. package/src/lib/tools/ai-workflow-tool.js +1 -1
  174. package/src/lib/tools/ai-workflow-tool.js.map +1 -1
  175. package/src/lib/usage/ai-allotment-alerts.d.ts +3 -0
  176. package/src/lib/usage/ai-allotment-alerts.js +20 -8
  177. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  178. package/src/lib/usage/assist-free-taste.js +2 -1
  179. package/src/lib/usage/assist-free-taste.js.map +1 -1
  180. package/src/lib/usage/assist-signal-mining.js +1 -1
  181. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  182. package/src/lib/usage-axes.d.ts +39 -0
  183. package/src/lib/usage-axes.js +70 -0
  184. package/src/lib/usage-axes.js.map +1 -0
  185. package/src/lib/runtime/seo-page-facts.d.ts +0 -61
  186. package/src/lib/runtime/seo-page-facts.js +0 -150
  187. package/src/lib/runtime/seo-page-facts.js.map +0 -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 {\n FREE_AI_TASTE_CREDITS_PER_MONTH,\n resolveEffectivePlan,\n} from '@aglyn/aglyn/app-utils/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","FREE_AI_TASTE_CREDITS_PER_MONTH","resolveEffectivePlan","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 '@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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"}
@@ -286,7 +286,7 @@ const number = (value)=>{
286
286
  cacheWrite: totals.cacheWriteTokens
287
287
  });
288
288
  for (const _ref of kinds){
289
- let [kind, _ref1] = _ref, { outputs } = _ref1, bucket = _object_without_properties_loose(_ref1, [
289
+ const [kind, _ref1] = _ref, { outputs } = _ref1, bucket = _object_without_properties_loose(_ref1, [
290
290
  "outputs"
291
291
  ]);
292
292
  totals.byKind[kind] = _extends({}, bucket, {
@@ -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 '@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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"}
@@ -0,0 +1,39 @@
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 { PluginUsageAxesDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-usage-axes';
18
+ /**
19
+ * THE AI PLUGIN'S METER (AGL-3080): provider spend, in the platform's cost
20
+ * model and against the credit band in its utilization table. Compiled into
21
+ * core by the manifest generator (`register.usageAxes`).
22
+ *
23
+ * The rollup records `assistCostUsd` — what the month's tokens cost US, at
24
+ * the provider's rates (AGL-3015) — so the axis enters the cost model at ×1
25
+ * and names no rate. It is the one line on the platform whose unit cost is
26
+ * real money paid to a third party, and the only one that can clear the
27
+ * $2/site floor on its own, which is why a margin surface that could not see
28
+ * it would rate a token-heavy workspace exactly as it rates an idle one.
29
+ *
30
+ * LIVE, because the rollup is a snapshot from when the cron ran and assist
31
+ * keeps spending after it: a reader that fetches the month's
32
+ * `assistUsage/{month}` document takes the provider figure, or the billed
33
+ * one on a month closed before the two were split, over the snapshot.
34
+ *
35
+ * The band is sold in credits, and a credit is `ASSIST_CREDIT_COST_USD` of
36
+ * spend, so what was used is the dollars over that — rounded up, the one
37
+ * conversion the customer's own meter uses.
38
+ */
39
+ export declare function aiUsageAxes(): PluginUsageAxesDeclaration;
@@ -0,0 +1,70 @@
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
+ */ import { ASSIST_CREDIT_COST_USD, ASSIST_PROVIDER_COST_FIELD } from "@aglyn/aglyn/app-utils/assist-credits";
17
+ /**
18
+ * THE AI PLUGIN'S METER (AGL-3080): provider spend, in the platform's cost
19
+ * model and against the credit band in its utilization table. Compiled into
20
+ * core by the manifest generator (`register.usageAxes`).
21
+ *
22
+ * The rollup records `assistCostUsd` — what the month's tokens cost US, at
23
+ * the provider's rates (AGL-3015) — so the axis enters the cost model at ×1
24
+ * and names no rate. It is the one line on the platform whose unit cost is
25
+ * real money paid to a third party, and the only one that can clear the
26
+ * $2/site floor on its own, which is why a margin surface that could not see
27
+ * it would rate a token-heavy workspace exactly as it rates an idle one.
28
+ *
29
+ * LIVE, because the rollup is a snapshot from when the cron ran and assist
30
+ * keeps spending after it: a reader that fetches the month's
31
+ * `assistUsage/{month}` document takes the provider figure, or the billed
32
+ * one on a month closed before the two were split, over the snapshot.
33
+ *
34
+ * The band is sold in credits, and a credit is `ASSIST_CREDIT_COST_USD` of
35
+ * spend, so what was used is the dollars over that — rounded up, the one
36
+ * conversion the customer's own meter uses.
37
+ */ export function aiUsageAxes() {
38
+ return {
39
+ costAxes: [
40
+ {
41
+ id: 'assist',
42
+ order: 80,
43
+ fields: [
44
+ 'assistCostUsd'
45
+ ],
46
+ live: {
47
+ collection: 'assistUsage',
48
+ fields: [
49
+ ASSIST_PROVIDER_COST_FIELD,
50
+ 'estCostUsd'
51
+ ]
52
+ }
53
+ }
54
+ ],
55
+ bands: [
56
+ {
57
+ id: 'assistCredits',
58
+ label: 'Assist credits',
59
+ order: 90,
60
+ fields: [
61
+ 'assistCostUsd'
62
+ ],
63
+ entitlement: 'assistCreditsPerMonth',
64
+ unitCostUsd: ASSIST_CREDIT_COST_USD
65
+ }
66
+ ]
67
+ };
68
+ }
69
+
70
+ //# sourceMappingURL=usage-axes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/plugins/ai/src/lib/usage-axes.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 type { PluginUsageAxesDeclaration } from '@aglyn/aglyn/plugin-manager/plugin-usage-axes'\nimport {\n ASSIST_CREDIT_COST_USD,\n ASSIST_PROVIDER_COST_FIELD,\n} from '@aglyn/aglyn/app-utils/assist-credits'\n\n/**\n * THE AI PLUGIN'S METER (AGL-3080): provider spend, in the platform's cost\n * model and against the credit band in its utilization table. Compiled into\n * core by the manifest generator (`register.usageAxes`).\n *\n * The rollup records `assistCostUsd` — what the month's tokens cost US, at\n * the provider's rates (AGL-3015) — so the axis enters the cost model at ×1\n * and names no rate. It is the one line on the platform whose unit cost is\n * real money paid to a third party, and the only one that can clear the\n * $2/site floor on its own, which is why a margin surface that could not see\n * it would rate a token-heavy workspace exactly as it rates an idle one.\n *\n * LIVE, because the rollup is a snapshot from when the cron ran and assist\n * keeps spending after it: a reader that fetches the month's\n * `assistUsage/{month}` document takes the provider figure, or the billed\n * one on a month closed before the two were split, over the snapshot.\n *\n * The band is sold in credits, and a credit is `ASSIST_CREDIT_COST_USD` of\n * spend, so what was used is the dollars over that — rounded up, the one\n * conversion the customer's own meter uses.\n */\nexport function aiUsageAxes(): PluginUsageAxesDeclaration {\n return {\n costAxes: [\n {\n id: 'assist',\n order: 80,\n fields: ['assistCostUsd'],\n live: {\n collection: 'assistUsage',\n fields: [ASSIST_PROVIDER_COST_FIELD, 'estCostUsd'],\n },\n },\n ],\n bands: [\n {\n id: 'assistCredits',\n label: 'Assist credits',\n order: 90,\n fields: ['assistCostUsd'],\n entitlement: 'assistCreditsPerMonth',\n unitCostUsd: ASSIST_CREDIT_COST_USD,\n },\n ],\n }\n}\n"],"names":["ASSIST_CREDIT_COST_USD","ASSIST_PROVIDER_COST_FIELD","aiUsageAxes","costAxes","id","order","fields","live","collection","bands","label","entitlement","unitCostUsd"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SACEA,sBAAsB,EACtBC,0BAA0B,QACrB,wCAAuC;AAE9C;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASC;IACd,OAAO;QACLC,UAAU;YACR;gBACEC,IAAI;gBACJC,OAAO;gBACPC,QAAQ;oBAAC;iBAAgB;gBACzBC,MAAM;oBACJC,YAAY;oBACZF,QAAQ;wBAACL;wBAA4B;qBAAa;gBACpD;YACF;SACD;QACDQ,OAAO;YACL;gBACEL,IAAI;gBACJM,OAAO;gBACPL,OAAO;gBACPC,QAAQ;oBAAC;iBAAgB;gBACzBK,aAAa;gBACbC,aAAaZ;YACf;SACD;IACH;AACF"}
@@ -1,61 +0,0 @@
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 PageMarkdownNodes } from '@aglyn/aglyn/app-utils/page-markdown';
18
- /**
19
- * What a page SAYS, read off its node tree (AGL-2910): the text a search
20
- * listing is written from, the headings, and the images — each heading and
21
- * image by node id, because a content fix addresses the element it changes.
22
- *
23
- * The text is the page's Markdown representation, the one an agent is
24
- * served, so what the listing summarizes is what a reader of the page gets.
25
- * The walk starts where that serializer starts — the content region — and
26
- * skips what it skips, through the same chrome predicate: a heading in the
27
- * site's navigation is not the page's heading.
28
- */
29
- /** How much of a page's text a prompt carries. A listing summarizes; it does not need the page. */
30
- export declare const AI_SEO_PAGE_TEXT_MAX_CHARS = 2000;
31
- export interface AiSeoHeading {
32
- /** The element, or `null` for a heading inside rich text a fix cannot address. */
33
- nodeId: string | null;
34
- level: number;
35
- text: string;
36
- /** Whether a content fix can change it: a text component with plain text. */
37
- editable: boolean;
38
- }
39
- export interface AiSeoImage {
40
- nodeId: string;
41
- src: string;
42
- alt: string;
43
- /** The nearest heading or text before it, which is what the image illustrates. */
44
- context: string;
45
- }
46
- export interface AiSeoPageFacts {
47
- /** The page's Markdown body, capped. */
48
- text: string;
49
- wordCount: number;
50
- headings: AiSeoHeading[];
51
- h1s: AiSeoHeading[];
52
- images: AiSeoImage[];
53
- imagesMissingAlt: AiSeoImage[];
54
- /** The node the content region starts at; a new heading goes first inside it. */
55
- contentRootId: string | null;
56
- }
57
- /** Read the facts a listing and an audit need from one page's node map. */
58
- export declare function aiSeoPageFacts(nodes: PageMarkdownNodes | null | undefined, options?: {
59
- rootId?: string;
60
- maxChars?: number;
61
- }): AiSeoPageFacts;