@tellescope/utilities 1.256.9 → 1.256.11

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.
package/src/ai_summary.ts CHANGED
@@ -10,11 +10,17 @@ import {
10
10
  AISummaryDataSource,
11
11
  AISummaryDataSourceConfig,
12
12
  Enduser,
13
+ ManagedContentRecord,
14
+ MessageTemplate,
13
15
  } from "@tellescope/types-models"
14
16
  import {
15
17
  DEFAULT_AI_SUMMARY_DATA_SOURCE_LIMIT,
16
18
  MAX_AI_SUMMARY_INPUT_TOKENS,
19
+ MAX_AI_SUMMARY_RECORD_CHARS,
17
20
  } from "@tellescope/constants"
21
+ // Same-package import. utils.ts re-exports this module, so this is a cycle — it resolves because
22
+ // both helpers are only ever called from inside formatter closures, long after module init.
23
+ import { plaintext_for_managed_content_record, replace_enduser_template_values } from "./utils"
18
24
 
19
25
  /* ---------------------------------- formatters ---------------------------------- */
20
26
 
@@ -83,15 +89,26 @@ export const DATA_SOURCE_LABELS: Record<AISummaryDataSource, string> = {
83
89
  enduser_orders: 'Orders',
84
90
  enduser_medications: 'Medications',
85
91
  purchases: 'Purchases',
92
+ managed_content_records: 'Content (Articles)',
93
+ templates: 'Message Templates',
86
94
  }
87
95
 
88
96
  export type DataSourceMapEntry = {
89
- // collection key equals the source key for all 11 entries; callers map this to their own
97
+ // collection key equals the source key for every entry; callers map this to their own
90
98
  // transport (session.api[collection] / DB[collection]).
91
99
  collection: AISummaryDataSource,
92
100
  sortField: string,
93
- format: (record: any) => string,
101
+ // `enduser` is supplied for sources whose rendering depends on the patient (template merge
102
+ // fields). Enduser-scoped sources ignore it.
103
+ format: (record: any, enduser?: Enduser | null) => string,
94
104
  enduserMatchClause?: (enduserId: string) => object,
105
+ // false = an org-wide reference collection with no enduserId link, so no enduser clause is
106
+ // applied and records are chosen by `ids` instead of a recency window.
107
+ enduserScoped?: boolean,
108
+ // always-applied data-layer constraint, ANDed with the customer's filter
109
+ baseFilter?: object,
110
+ // true when `format` needs the enduser loaded even if the profile block is excluded
111
+ formatNeedsEnduser?: boolean,
95
112
  }
96
113
 
97
114
  export const DATA_SOURCE_MAP: Record<AISummaryDataSource, DataSourceMapEntry> = {
@@ -156,6 +173,40 @@ export const DATA_SOURCE_MAP: Record<AISummaryDataSource, DataSourceMapEntry> =
156
173
  sortField: 'createdAt',
157
174
  format: p => `[Purchase ${fmt(p.createdAt)}] ${p.title ?? ''} ${typeof p.amount === 'number' ? `$${p.amount}` : ''}`.trim(),
158
175
  },
176
+ // Org-wide reference sources below: approved material the AI is grounded in, rather than
177
+ // records describing the patient. Selected by `ids`, so no enduser clause and no lookback.
178
+ managed_content_records: {
179
+ collection: 'managed_content_records',
180
+ sortField: 'createdAt',
181
+ enduserScoped: false,
182
+ // Articles only. PDF/Video bodies live in an attached file that can't be read, so they're
183
+ // excluded here rather than relying on the UI to keep them out.
184
+ baseFilter: { type: 'Article' },
185
+ format: (r: ManagedContentRecord) => {
186
+ const body = plaintext_for_managed_content_record(r)
187
+ if (!body?.trim()) return ''
188
+ const description = r.description ? ` ${r.description}` : ''
189
+ return `[Content "${r.title ?? ''}"]${description}\n${body.trim().slice(0, MAX_AI_SUMMARY_RECORD_CHARS)}`
190
+ },
191
+ },
192
+ templates: {
193
+ collection: 'templates',
194
+ sortField: 'createdAt',
195
+ enduserScoped: false,
196
+ formatNeedsEnduser: true,
197
+ format: (t: MessageTemplate, enduser) => {
198
+ const raw = t.message || stripHtml(t.html || '')
199
+ if (!raw?.trim()) return ''
200
+ // Resolve {{merge}} fields so the model reads concrete text instead of placeholders.
201
+ // Unresolvable ones stay literal; the caller's prompt tells the model to fill or drop them.
202
+ const body = replace_enduser_template_values(raw, enduser)
203
+ const subject = t.subject
204
+ ? ` Subject: ${replace_enduser_template_values(t.subject, enduser)}`
205
+ : ''
206
+ const type = t.type ? ` type:${t.type}` : ''
207
+ return `[Template "${t.title ?? ''}"${type}]${subject}\n${body.trim().slice(0, MAX_AI_SUMMARY_RECORD_CHARS)}`
208
+ },
209
+ },
159
210
  }
160
211
 
161
212
  export const enduserProfileToText = (e: Enduser): string => {
@@ -205,6 +256,11 @@ export type AISummarySourceSection = {
205
256
  limit: number,
206
257
  records: any[],
207
258
  formattedLines: string[],
259
+ // records that loaded but produced no usable text (e.g. a PDF selected before the Articles-only
260
+ // filter, or an empty article). Surfaced in the sources drawer so exclusions are visible.
261
+ skipped: { id: string, title?: string, reason: 'no-readable-text' }[],
262
+ // heading used for this section's block in the context text
263
+ heading: string,
208
264
  }
209
265
 
210
266
  export type AISummaryContext = {
@@ -216,21 +272,27 @@ export type AISummaryContext = {
216
272
 
217
273
  // Build the mongo filter + effective limit for a single data source. Shared by both loaders so
218
274
  // the lookback / enduserMatch / sanitized-filter / default-limit logic stays identical.
275
+ // `ids` is returned rather than folded into mdbFilter: _id needs ObjectId conversion on the DB
276
+ // side, and the readMany endpoint takes `ids` natively, so each transport applies it.
219
277
  export const buildAISummarySourceFilter = ({ ds, enduserId, mapEntry } : {
220
278
  ds: AISummaryDataSourceConfig,
221
279
  enduserId: string,
222
280
  mapEntry: DataSourceMapEntry,
223
- }): { mdbFilter: object, effectiveLimit: number } => {
281
+ }): { mdbFilter: object, effectiveLimit: number, ids?: string[] } => {
224
282
  const userFilter = sanitizeFilter(ds.filter)
225
283
  const lookbackClause = ds.lookbackMS
226
284
  ? { [mapEntry.sortField]: { $gte: new Date(Date.now() - ds.lookbackMS) } }
227
285
  : {}
228
286
 
229
- const enduserMatch = mapEntry.enduserMatchClause ? mapEntry.enduserMatchClause(enduserId) : { enduserId }
230
- const mdbFilter = { $and: [userFilter, lookbackClause, enduserMatch] }
287
+ const enduserMatch = (
288
+ mapEntry.enduserScoped === false
289
+ ? {}
290
+ : mapEntry.enduserMatchClause ? mapEntry.enduserMatchClause(enduserId) : { enduserId }
291
+ )
292
+ const mdbFilter = { $and: [userFilter, lookbackClause, enduserMatch, mapEntry.baseFilter ?? {}] }
231
293
  const effectiveLimit = ds.limit ?? DEFAULT_AI_SUMMARY_DATA_SOURCE_LIMIT
232
294
 
233
- return { mdbFilter, effectiveLimit }
295
+ return { mdbFilter, effectiveLimit, ids: ds.ids?.length ? ds.ids : undefined }
234
296
  }
235
297
 
236
298
  // Join the profile block + per-source blocks, estimate tokens, and enforce the input budget.
@@ -240,7 +302,7 @@ export const assembleAISummaryContext = ({ profileBlock, sources } : {
240
302
  }): AISummaryContext => {
241
303
  const blocks = sources
242
304
  .filter(s => s.formattedLines.length > 0)
243
- .map(s => `## ${s.type}\n${s.formattedLines.join('\n')}`)
305
+ .map(s => `## ${s.heading}\n${s.formattedLines.join('\n')}`)
244
306
 
245
307
  const contextText = [profileBlock, ...blocks].filter(Boolean).join('\n\n')
246
308
  const estimatedTokens = Math.ceil(contextText.length / 4)
@@ -258,6 +320,8 @@ export type LoadAISummaryRecordsArgs = {
258
320
  mdbFilter: object,
259
321
  limit: number,
260
322
  sortField: string,
323
+ // when set, restrict to these record ids (see buildAISummarySourceFilter)
324
+ ids?: string[],
261
325
  }
262
326
 
263
327
  // Transport-agnostic loader. Callers inject loadProfile/loadRecords backed by the SDK (webapp) or
@@ -274,37 +338,46 @@ export const loadAISummaryContext = async ({
274
338
  // true for the summary use case; the AI Decision step passes false to avoid prompt clutter.
275
339
  includeProfile?: boolean,
276
340
  }): Promise<AISummaryContext> => {
277
- const enduser = includeProfile ? await loadProfile(enduserId) : null
278
- const profileBlock = enduser ? enduserProfileToText(enduser) : ''
341
+ const dataSources = configuration.dataSources ?? []
342
+ // some formatters (message templates) need the patient to resolve merge fields, so load the
343
+ // enduser for those even when the profile block itself is excluded from the context
344
+ const needsEnduserForFormat = dataSources.some(ds => DATA_SOURCE_MAP[ds.type]?.formatNeedsEnduser)
345
+ const enduser = (includeProfile || needsEnduserForFormat) ? await loadProfile(enduserId) : null
346
+ const profileBlock = (includeProfile && enduser) ? enduserProfileToText(enduser) : ''
279
347
 
280
- const sections = await Promise.all((configuration.dataSources ?? []).map(async (ds: AISummaryDataSourceConfig): Promise<AISummarySourceSection | null> => {
348
+ const sections = await Promise.all(dataSources.map(async (ds: AISummaryDataSourceConfig): Promise<AISummarySourceSection | null> => {
281
349
  const m = DATA_SOURCE_MAP[ds.type]
282
350
  if (!m) return null
283
351
 
284
- const { mdbFilter, effectiveLimit } = buildAISummarySourceFilter({ ds, enduserId, mapEntry: m })
352
+ const { mdbFilter, effectiveLimit, ids } = buildAISummarySourceFilter({ ds, enduserId, mapEntry: m })
353
+ const base = {
354
+ type: ds.type,
355
+ label: DATA_SOURCE_LABELS[ds.type] ?? ds.type,
356
+ heading: ds.label || ds.type,
357
+ lookbackMS: ds.lookbackMS,
358
+ limit: effectiveLimit,
359
+ }
360
+
361
+ // Reference collections are selected exclusively by ids — an empty selection means "load
362
+ // nothing", never "no id restriction" (which would pull the newest org-wide records into the prompt)
363
+ if (m.enduserScoped === false && !ids) return { ...base, records: [], formattedLines: [], skipped: [] }
285
364
 
286
365
  try {
287
- const records = await loadRecords({ type: ds.type, collection: m.collection, mdbFilter, limit: effectiveLimit, sortField: m.sortField })
366
+ const records = await loadRecords({ type: ds.type, collection: m.collection, mdbFilter, limit: effectiveLimit, sortField: m.sortField, ids })
288
367
  const list: any[] = Array.isArray(records) ? records : []
289
- const formattedLines = list.map(m.format).filter(Boolean)
290
- return {
291
- type: ds.type,
292
- label: DATA_SOURCE_LABELS[ds.type] ?? ds.type,
293
- lookbackMS: ds.lookbackMS,
294
- limit: effectiveLimit,
295
- records: list,
296
- formattedLines,
368
+
369
+ const formattedLines: string[] = []
370
+ const skipped: AISummarySourceSection['skipped'] = []
371
+ for (const record of list) {
372
+ const line = m.format(record, enduser)
373
+ if (line) formattedLines.push(line)
374
+ else skipped.push({ id: record?.id ?? record?._id?.toString?.() ?? '', title: record?.title, reason: 'no-readable-text' })
297
375
  }
376
+
377
+ return { ...base, records: list, formattedLines, skipped }
298
378
  } catch (err) {
299
379
  console.error(`Failed to load ${ds.type} for AI summary`, err)
300
- return {
301
- type: ds.type,
302
- label: DATA_SOURCE_LABELS[ds.type] ?? ds.type,
303
- lookbackMS: ds.lookbackMS,
304
- limit: effectiveLimit,
305
- records: [],
306
- formattedLines: [],
307
- }
380
+ return { ...base, records: [], formattedLines: [], skipped: [] }
308
381
  }
309
382
  }))
310
383
 
package/src/utils.ts CHANGED
@@ -1218,20 +1218,29 @@ export const timezone_for_enduser = (e: Pick<Enduser, 'state' | 'timezone'>) =>
1218
1218
 
1219
1219
  export const sanitize_html = (t: string) => sanitizeHtml(t, { allowedTags: [], allowedAttributes: {} })
1220
1220
 
1221
- export const plaintext_for_managed_content_record = (record: Pick<ManagedContentRecord, 'type' | 'blocks'>) => {
1221
+ // Plain-text body of a content record, or null when there's no readable text.
1222
+ // PDF and Video carry their content in an attached file, which can't be read here.
1223
+ export const plaintext_for_managed_content_record = (
1224
+ record: Pick<ManagedContentRecord, 'type' | 'blocks' | 'htmlContent' | 'textContent'>
1225
+ ) => {
1222
1226
  if (record.type === 'PDF') return null
1223
1227
  if (record.type === 'Video') return null
1224
1228
 
1225
- if (!record.blocks?.length) return null
1229
+ // legacy records predate blocks and carry their body in htmlContent / textContent
1230
+ if (!record.blocks?.length) {
1231
+ const legacy = record.textContent || (record.htmlContent ? sanitize_html(record.htmlContent) : '')
1232
+ return legacy || null
1233
+ }
1226
1234
 
1227
1235
  return (
1228
1236
  record
1229
1237
  .blocks
1230
1238
  .filter(
1231
1239
  b => (
1232
- b.type === 'h1'
1240
+ b.type === 'h1'
1233
1241
  || b.type === 'h2'
1234
1242
  || b.type === 'html'
1243
+ || b.type === 'raw-html'
1235
1244
  )
1236
1245
  )
1237
1246
  .map(
@@ -1240,14 +1249,14 @@ export const plaintext_for_managed_content_record = (record: Pick<ManagedContent
1240
1249
  ? b.info.text
1241
1250
  : b.type === 'h2'
1242
1251
  ? b.info.text
1243
- : b.type === 'html'
1252
+ : (b.type === 'html' || b.type === 'raw-html')
1244
1253
  ? sanitize_html(b.info.html)
1245
1254
  : ''
1246
1255
 
1247
1256
  )
1248
1257
  )
1249
1258
  .join('\n')
1250
- )
1259
+ )
1251
1260
  }
1252
1261
 
1253
1262
  // https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array