@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/lib/cjs/ai_summary.d.ts +12 -1
- package/lib/cjs/ai_summary.d.ts.map +1 -1
- package/lib/cjs/ai_summary.js +103 -37
- 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 +12 -1
- package/lib/esm/ai_summary.d.ts.map +1 -1
- package/lib/esm/ai_summary.js +104 -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 +101 -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
|
|
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
230
|
-
|
|
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.
|
|
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
|
|
278
|
-
|
|
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(
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|