@tellescope/utilities 1.256.10 → 1.256.12

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
 
@@ -50,6 +56,73 @@ export const fmtResponses = (responses: any) => {
50
56
  .filter(Boolean)
51
57
  .join(' | ')
52
58
  }
59
+
60
+ /* --------- submitted-answer formatting (the answers are the *subject*, not context) --------- */
61
+
62
+ const fmtAnswerScalar = (v: any) => (
63
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? String(v) : ''
64
+ )
65
+
66
+ // Shallow `key: value` rendering for object-valued answers (signatures, files, addresses,
67
+ // insurance blocks). Deliberately one level deep — deeper nesting is metadata, not an answer.
68
+ const fmtAnswerObject = (v: any): string => {
69
+ if (!v || typeof v !== 'object') return ''
70
+ return Object.entries(v)
71
+ .map(([k, val]) => {
72
+ const rendered = (
73
+ fmtAnswerScalar(val) || (
74
+ Array.isArray(val)
75
+ ? val.map((x: any) => fmtAnswerScalar(x?.label ?? x)).filter(Boolean).join(', ')
76
+ : ''
77
+ )
78
+ )
79
+ return rendered ? `${k}: ${rendered}` : ''
80
+ })
81
+ .filter(Boolean)
82
+ .join(', ')
83
+ }
84
+
85
+ const fmtAnswerValue = (v: any): string => {
86
+ if (v === undefined || v === null) return ''
87
+ const scalar = fmtAnswerScalar(v)
88
+ if (scalar) return scalar
89
+ if (Array.isArray(v)) {
90
+ return v
91
+ .map((x: any) => fmtAnswerScalar(x?.label ?? x) || fmtAnswerObject(x))
92
+ .filter(Boolean)
93
+ .join(', ')
94
+ }
95
+ if (typeof v === 'object') return fmtAnswerObject(v)
96
+ return ''
97
+ }
98
+
99
+ // Renders one form response's answers for a summary *of those answers*.
100
+ //
101
+ // fmtResponses (above) intentionally drops object-valued answers — signatures, files, addresses,
102
+ // insurance blocks — because they add noise when a response is only background chart context.
103
+ // That is not acceptable when the answers are the subject of the summary, so those are rendered
104
+ // here as a shallow key/value list instead. One answer per line rather than ' | '-joined, since
105
+ // this text is read closely rather than skimmed alongside other records.
106
+ //
107
+ // fmtResponses is left untouched so the form_responses data-source output stays byte-identical.
108
+ export const formatFormResponseAnswersForSummary = (responses: any): string => {
109
+ if (!Array.isArray(responses)) return ''
110
+ return responses
111
+ .map((r: any) => {
112
+ const value = r?.answer?.value
113
+ if (value === undefined || value === null) return ''
114
+
115
+ const title = r?.fieldTitle || ''
116
+ // an empty answer on a question the patient was shown is itself informative
117
+ const rendered = value === '' ? '(no answer)' : fmtAnswerValue(value)
118
+ if (!rendered) return ''
119
+
120
+ return title ? `${title}: ${rendered}` : rendered
121
+ })
122
+ .filter(Boolean)
123
+ .join('\n')
124
+ }
125
+
53
126
  export const fmtObservation = (o: any) => {
54
127
  const m = o.measurement
55
128
  // numeric value + unit when present; fall back to qualitative result ("positive", etc.)
@@ -83,15 +156,26 @@ export const DATA_SOURCE_LABELS: Record<AISummaryDataSource, string> = {
83
156
  enduser_orders: 'Orders',
84
157
  enduser_medications: 'Medications',
85
158
  purchases: 'Purchases',
159
+ managed_content_records: 'Content (Articles)',
160
+ templates: 'Message Templates',
86
161
  }
87
162
 
88
163
  export type DataSourceMapEntry = {
89
- // collection key equals the source key for all 11 entries; callers map this to their own
164
+ // collection key equals the source key for every entry; callers map this to their own
90
165
  // transport (session.api[collection] / DB[collection]).
91
166
  collection: AISummaryDataSource,
92
167
  sortField: string,
93
- format: (record: any) => string,
168
+ // `enduser` is supplied for sources whose rendering depends on the patient (template merge
169
+ // fields). Enduser-scoped sources ignore it.
170
+ format: (record: any, enduser?: Enduser | null) => string,
94
171
  enduserMatchClause?: (enduserId: string) => object,
172
+ // false = an org-wide reference collection with no enduserId link, so no enduser clause is
173
+ // applied and records are chosen by `ids` instead of a recency window.
174
+ enduserScoped?: boolean,
175
+ // always-applied data-layer constraint, ANDed with the customer's filter
176
+ baseFilter?: object,
177
+ // true when `format` needs the enduser loaded even if the profile block is excluded
178
+ formatNeedsEnduser?: boolean,
95
179
  }
96
180
 
97
181
  export const DATA_SOURCE_MAP: Record<AISummaryDataSource, DataSourceMapEntry> = {
@@ -156,6 +240,40 @@ export const DATA_SOURCE_MAP: Record<AISummaryDataSource, DataSourceMapEntry> =
156
240
  sortField: 'createdAt',
157
241
  format: p => `[Purchase ${fmt(p.createdAt)}] ${p.title ?? ''} ${typeof p.amount === 'number' ? `$${p.amount}` : ''}`.trim(),
158
242
  },
243
+ // Org-wide reference sources below: approved material the AI is grounded in, rather than
244
+ // records describing the patient. Selected by `ids`, so no enduser clause and no lookback.
245
+ managed_content_records: {
246
+ collection: 'managed_content_records',
247
+ sortField: 'createdAt',
248
+ enduserScoped: false,
249
+ // Articles only. PDF/Video bodies live in an attached file that can't be read, so they're
250
+ // excluded here rather than relying on the UI to keep them out.
251
+ baseFilter: { type: 'Article' },
252
+ format: (r: ManagedContentRecord) => {
253
+ const body = plaintext_for_managed_content_record(r)
254
+ if (!body?.trim()) return ''
255
+ const description = r.description ? ` ${r.description}` : ''
256
+ return `[Content "${r.title ?? ''}"]${description}\n${body.trim().slice(0, MAX_AI_SUMMARY_RECORD_CHARS)}`
257
+ },
258
+ },
259
+ templates: {
260
+ collection: 'templates',
261
+ sortField: 'createdAt',
262
+ enduserScoped: false,
263
+ formatNeedsEnduser: true,
264
+ format: (t: MessageTemplate, enduser) => {
265
+ const raw = t.message || stripHtml(t.html || '')
266
+ if (!raw?.trim()) return ''
267
+ // Resolve {{merge}} fields so the model reads concrete text instead of placeholders.
268
+ // Unresolvable ones stay literal; the caller's prompt tells the model to fill or drop them.
269
+ const body = replace_enduser_template_values(raw, enduser)
270
+ const subject = t.subject
271
+ ? ` Subject: ${replace_enduser_template_values(t.subject, enduser)}`
272
+ : ''
273
+ const type = t.type ? ` type:${t.type}` : ''
274
+ return `[Template "${t.title ?? ''}"${type}]${subject}\n${body.trim().slice(0, MAX_AI_SUMMARY_RECORD_CHARS)}`
275
+ },
276
+ },
159
277
  }
160
278
 
161
279
  export const enduserProfileToText = (e: Enduser): string => {
@@ -205,6 +323,11 @@ export type AISummarySourceSection = {
205
323
  limit: number,
206
324
  records: any[],
207
325
  formattedLines: string[],
326
+ // records that loaded but produced no usable text (e.g. a PDF selected before the Articles-only
327
+ // filter, or an empty article). Surfaced in the sources drawer so exclusions are visible.
328
+ skipped: { id: string, title?: string, reason: 'no-readable-text' }[],
329
+ // heading used for this section's block in the context text
330
+ heading: string,
208
331
  }
209
332
 
210
333
  export type AISummaryContext = {
@@ -216,21 +339,27 @@ export type AISummaryContext = {
216
339
 
217
340
  // Build the mongo filter + effective limit for a single data source. Shared by both loaders so
218
341
  // the lookback / enduserMatch / sanitized-filter / default-limit logic stays identical.
342
+ // `ids` is returned rather than folded into mdbFilter: _id needs ObjectId conversion on the DB
343
+ // side, and the readMany endpoint takes `ids` natively, so each transport applies it.
219
344
  export const buildAISummarySourceFilter = ({ ds, enduserId, mapEntry } : {
220
345
  ds: AISummaryDataSourceConfig,
221
346
  enduserId: string,
222
347
  mapEntry: DataSourceMapEntry,
223
- }): { mdbFilter: object, effectiveLimit: number } => {
348
+ }): { mdbFilter: object, effectiveLimit: number, ids?: string[] } => {
224
349
  const userFilter = sanitizeFilter(ds.filter)
225
350
  const lookbackClause = ds.lookbackMS
226
351
  ? { [mapEntry.sortField]: { $gte: new Date(Date.now() - ds.lookbackMS) } }
227
352
  : {}
228
353
 
229
- const enduserMatch = mapEntry.enduserMatchClause ? mapEntry.enduserMatchClause(enduserId) : { enduserId }
230
- const mdbFilter = { $and: [userFilter, lookbackClause, enduserMatch] }
354
+ const enduserMatch = (
355
+ mapEntry.enduserScoped === false
356
+ ? {}
357
+ : mapEntry.enduserMatchClause ? mapEntry.enduserMatchClause(enduserId) : { enduserId }
358
+ )
359
+ const mdbFilter = { $and: [userFilter, lookbackClause, enduserMatch, mapEntry.baseFilter ?? {}] }
231
360
  const effectiveLimit = ds.limit ?? DEFAULT_AI_SUMMARY_DATA_SOURCE_LIMIT
232
361
 
233
- return { mdbFilter, effectiveLimit }
362
+ return { mdbFilter, effectiveLimit, ids: ds.ids?.length ? ds.ids : undefined }
234
363
  }
235
364
 
236
365
  // Join the profile block + per-source blocks, estimate tokens, and enforce the input budget.
@@ -240,7 +369,7 @@ export const assembleAISummaryContext = ({ profileBlock, sources } : {
240
369
  }): AISummaryContext => {
241
370
  const blocks = sources
242
371
  .filter(s => s.formattedLines.length > 0)
243
- .map(s => `## ${s.type}\n${s.formattedLines.join('\n')}`)
372
+ .map(s => `## ${s.heading}\n${s.formattedLines.join('\n')}`)
244
373
 
245
374
  const contextText = [profileBlock, ...blocks].filter(Boolean).join('\n\n')
246
375
  const estimatedTokens = Math.ceil(contextText.length / 4)
@@ -258,6 +387,8 @@ export type LoadAISummaryRecordsArgs = {
258
387
  mdbFilter: object,
259
388
  limit: number,
260
389
  sortField: string,
390
+ // when set, restrict to these record ids (see buildAISummarySourceFilter)
391
+ ids?: string[],
261
392
  }
262
393
 
263
394
  // Transport-agnostic loader. Callers inject loadProfile/loadRecords backed by the SDK (webapp) or
@@ -274,37 +405,46 @@ export const loadAISummaryContext = async ({
274
405
  // true for the summary use case; the AI Decision step passes false to avoid prompt clutter.
275
406
  includeProfile?: boolean,
276
407
  }): Promise<AISummaryContext> => {
277
- const enduser = includeProfile ? await loadProfile(enduserId) : null
278
- const profileBlock = enduser ? enduserProfileToText(enduser) : ''
408
+ const dataSources = configuration.dataSources ?? []
409
+ // some formatters (message templates) need the patient to resolve merge fields, so load the
410
+ // enduser for those even when the profile block itself is excluded from the context
411
+ const needsEnduserForFormat = dataSources.some(ds => DATA_SOURCE_MAP[ds.type]?.formatNeedsEnduser)
412
+ const enduser = (includeProfile || needsEnduserForFormat) ? await loadProfile(enduserId) : null
413
+ const profileBlock = (includeProfile && enduser) ? enduserProfileToText(enduser) : ''
279
414
 
280
- const sections = await Promise.all((configuration.dataSources ?? []).map(async (ds: AISummaryDataSourceConfig): Promise<AISummarySourceSection | null> => {
415
+ const sections = await Promise.all(dataSources.map(async (ds: AISummaryDataSourceConfig): Promise<AISummarySourceSection | null> => {
281
416
  const m = DATA_SOURCE_MAP[ds.type]
282
417
  if (!m) return null
283
418
 
284
- const { mdbFilter, effectiveLimit } = buildAISummarySourceFilter({ ds, enduserId, mapEntry: m })
419
+ const { mdbFilter, effectiveLimit, ids } = buildAISummarySourceFilter({ ds, enduserId, mapEntry: m })
420
+ const base = {
421
+ type: ds.type,
422
+ label: DATA_SOURCE_LABELS[ds.type] ?? ds.type,
423
+ heading: ds.label || ds.type,
424
+ lookbackMS: ds.lookbackMS,
425
+ limit: effectiveLimit,
426
+ }
427
+
428
+ // Reference collections are selected exclusively by ids — an empty selection means "load
429
+ // nothing", never "no id restriction" (which would pull the newest org-wide records into the prompt)
430
+ if (m.enduserScoped === false && !ids) return { ...base, records: [], formattedLines: [], skipped: [] }
285
431
 
286
432
  try {
287
- const records = await loadRecords({ type: ds.type, collection: m.collection, mdbFilter, limit: effectiveLimit, sortField: m.sortField })
433
+ const records = await loadRecords({ type: ds.type, collection: m.collection, mdbFilter, limit: effectiveLimit, sortField: m.sortField, ids })
288
434
  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,
435
+
436
+ const formattedLines: string[] = []
437
+ const skipped: AISummarySourceSection['skipped'] = []
438
+ for (const record of list) {
439
+ const line = m.format(record, enduser)
440
+ if (line) formattedLines.push(line)
441
+ else skipped.push({ id: record?.id ?? record?._id?.toString?.() ?? '', title: record?.title, reason: 'no-readable-text' })
297
442
  }
443
+
444
+ return { ...base, records: list, formattedLines, skipped }
298
445
  } catch (err) {
299
446
  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
- }
447
+ return { ...base, records: [], formattedLines: [], skipped: [] }
308
448
  }
309
449
  }))
310
450
 
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