@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/lib/cjs/ai_summary.d.ts +13 -1
- package/lib/cjs/ai_summary.d.ts.map +1 -1
- package/lib/cjs/ai_summary.js +167 -38
- package/lib/cjs/ai_summary.js.map +1 -1
- package/lib/cjs/utils.d.ts +1 -1
- package/lib/cjs/utils.d.ts.map +1 -1
- package/lib/cjs/utils.js +10 -4
- package/lib/cjs/utils.js.map +1 -1
- package/lib/esm/ai_summary.d.ts +13 -1
- package/lib/esm/ai_summary.d.ts.map +1 -1
- package/lib/esm/ai_summary.js +166 -38
- package/lib/esm/ai_summary.js.map +1 -1
- package/lib/esm/utils.d.ts +1 -1
- package/lib/esm/utils.d.ts.map +1 -1
- package/lib/esm/utils.js +10 -4
- package/lib/esm/utils.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/ai_summary.ts +168 -28
- package/src/utils.ts +14 -5
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
|
|
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
|
-
|
|
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 =
|
|
230
|
-
|
|
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.
|
|
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
|
|
278
|
-
|
|
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(
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|