@tellescope/utilities 1.254.1 → 1.255.0

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.
@@ -0,0 +1,313 @@
1
+ // Shared, transport-agnostic AI summary code.
2
+ //
3
+ // This module holds the *pure* parts of the Form "AI profile summary" feature so they can be
4
+ // reused by both the webapp SDK loader (data_sources.ts) and the backend DB loader
5
+ // (api/modules/ai_summary_context.ts). It must stay free of any SDK/DB/React imports — only
6
+ // plain record shapes, the types-models Enduser, and constants.
7
+
8
+ import {
9
+ AISummaryConfiguration,
10
+ AISummaryDataSource,
11
+ AISummaryDataSourceConfig,
12
+ Enduser,
13
+ } from "@tellescope/types-models"
14
+ import {
15
+ DEFAULT_AI_SUMMARY_DATA_SOURCE_LIMIT,
16
+ MAX_AI_SUMMARY_INPUT_TOKENS,
17
+ } from "@tellescope/constants"
18
+
19
+ /* ---------------------------------- formatters ---------------------------------- */
20
+
21
+ export const stripHtml = (s: string) => s
22
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
23
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
24
+ .replace(/<[^>]+>/g, ' ')
25
+ .replace(/&nbsp;/gi, ' ')
26
+ .replace(/\s+/g, ' ')
27
+ .trim()
28
+
29
+ export const fmt = (d: any) => {
30
+ if (!d) return ''
31
+ try { return new Date(d).toISOString().slice(0, 16).replace('T', ' ') } catch { return '' }
32
+ }
33
+ export const fmtFromMS = (ms: any) => {
34
+ if (typeof ms !== 'number') return ''
35
+ try { return new Date(ms).toISOString().slice(0, 16).replace('T', ' ') } catch { return '' }
36
+ }
37
+ export const fmtResponses = (responses: any) => {
38
+ if (!Array.isArray(responses)) return ''
39
+ return responses
40
+ .map((r: any) => {
41
+ const ans = r?.answer
42
+ const v = ans?.value
43
+ if (v === undefined || v === null) return ''
44
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
45
+ return `${r.fieldTitle || ''}: ${v}`
46
+ }
47
+ if (Array.isArray(v)) return `${r.fieldTitle || ''}: ${v.map((x: any) => x?.label ?? x).join(', ')}`
48
+ return ''
49
+ })
50
+ .filter(Boolean)
51
+ .join(' | ')
52
+ }
53
+ export const fmtObservation = (o: any) => {
54
+ const m = o.measurement
55
+ // numeric value + unit when present; fall back to qualitative result ("positive", etc.)
56
+ const reading =
57
+ m && typeof m.value === 'number'
58
+ ? `${m.value}${m.unit ? ` ${m.unit}` : ''}`
59
+ : (o.qualitativeResult ?? '')
60
+
61
+ const name = o.type || o.code || o.category || '' // what was measured
62
+ const extras = [
63
+ o.refRange ? `ref ${o.refRange}` : '',
64
+ o.statusIndicator ?? '', // High / Low / Normal
65
+ ].filter(Boolean).join(', ')
66
+
67
+ const label = name ? `${name}: ` : ''
68
+ const tail = extras ? ` (${extras})` : ''
69
+ return `[Observation ${fmt(o.timestamp ?? o.recordedAt ?? o.createdAt)}] ${label}${reading}${tail}`.trim()
70
+ }
71
+
72
+ /* ---------------------------------- data source metadata ---------------------------------- */
73
+
74
+ export const DATA_SOURCE_LABELS: Record<AISummaryDataSource, string> = {
75
+ enduser_observations: 'Observations',
76
+ form_responses: 'Form Responses',
77
+ chats: 'Chat Messages',
78
+ phone_calls: 'Phone Calls',
79
+ calendar_events: 'Calendar Events',
80
+ tickets: 'Tickets',
81
+ sms_messages: 'SMS Messages',
82
+ emails: 'Emails',
83
+ enduser_orders: 'Orders',
84
+ enduser_medications: 'Medications',
85
+ purchases: 'Purchases',
86
+ }
87
+
88
+ export type DataSourceMapEntry = {
89
+ // collection key equals the source key for all 11 entries; callers map this to their own
90
+ // transport (session.api[collection] / DB[collection]).
91
+ collection: AISummaryDataSource,
92
+ sortField: string,
93
+ format: (record: any) => string,
94
+ enduserMatchClause?: (enduserId: string) => object,
95
+ }
96
+
97
+ export const DATA_SOURCE_MAP: Record<AISummaryDataSource, DataSourceMapEntry> = {
98
+ enduser_observations: {
99
+ collection: 'enduser_observations',
100
+ sortField: 'timestamp',
101
+ format: fmtObservation,
102
+ },
103
+ form_responses: {
104
+ collection: 'form_responses',
105
+ sortField: 'submittedAt',
106
+ format: fr => `[Form "${fr.formTitle ?? ''}" ${fmt(fr.submittedAt ?? fr.createdAt)}] ${fmtResponses(fr.responses)}`,
107
+ },
108
+ chats: {
109
+ collection: 'chats',
110
+ sortField: 'timestamp',
111
+ // enduserId is set only on enduser-authored chats (schema initializer); senderId is always set
112
+ // (userId for staff, enduserId for patients) so it can't distinguish the sender.
113
+ format: m => `[Chat ${fmt(m.timestamp ?? m.createdAt)} ${m.enduserId ? 'patient' : 'staff'}] ${m.message ?? ''}`,
114
+ },
115
+ phone_calls: {
116
+ collection: 'phone_calls',
117
+ sortField: 'createdAt',
118
+ format: c => `[Call ${fmt(c.createdAt)} ${c.direction ?? ''} ${c.durationInSeconds ?? 0}s] ${c.aiSummary ?? c.summary ?? ''}`,
119
+ },
120
+ calendar_events: {
121
+ collection: 'calendar_events',
122
+ sortField: 'startTimeInMS',
123
+ format: e => `[Appt ${fmtFromMS(e.startTimeInMS)}] ${e.title ?? ''} (${e.type ?? ''})`,
124
+ enduserMatchClause: enduserId => ({ 'attendees.id': enduserId }),
125
+ },
126
+ tickets: {
127
+ collection: 'tickets',
128
+ sortField: 'createdAt',
129
+ format: t => `[Ticket ${fmt(t.createdAt)} ${t.closedAt ? 'closed' : 'open'}] ${t.title ?? ''}`,
130
+ },
131
+ sms_messages: {
132
+ collection: 'sms_messages',
133
+ sortField: 'timestamp',
134
+ format: s => `[SMS ${fmt(s.timestamp ?? s.createdAt)} ${s.inbound ? 'in' : 'out'}] ${s.message ?? ''}`,
135
+ },
136
+ emails: {
137
+ collection: 'emails',
138
+ sortField: 'timestamp',
139
+ format: e => {
140
+ const body = e.textContent || stripHtml(e.HTMLContent || '')
141
+ return `[Email ${fmt(e.timestamp ?? e.createdAt)}] ${e.subject ?? ''}\n${body.slice(0, 2000)}`
142
+ },
143
+ },
144
+ enduser_orders: {
145
+ collection: 'enduser_orders',
146
+ sortField: 'createdAt',
147
+ format: o => `[Order ${fmt(o.createdAt)}] ${o.status ?? ''} — ${(o.items ?? []).map((i: any) => i.title ?? i.sku ?? '').filter(Boolean).join(', ')}`,
148
+ },
149
+ enduser_medications: {
150
+ collection: 'enduser_medications',
151
+ sortField: 'createdAt',
152
+ format: m => `[Medication ${fmt(m.createdAt)}] ${m.title ?? ''} ${m.dosage ?? ''} ${m.status ?? ''}`.trim(),
153
+ },
154
+ purchases: {
155
+ collection: 'purchases',
156
+ sortField: 'createdAt',
157
+ format: p => `[Purchase ${fmt(p.createdAt)}] ${p.title ?? ''} ${typeof p.amount === 'number' ? `$${p.amount}` : ''}`.trim(),
158
+ },
159
+ }
160
+
161
+ export const enduserProfileToText = (e: Enduser): string => {
162
+ const lines: string[] = []
163
+ lines.push('## enduser')
164
+ if (e.fname || e.lname) lines.push(`Name: ${e.fname ?? ''} ${e.lname ?? ''}`.trim())
165
+ if (e.dateOfBirth) lines.push(`DOB: ${e.dateOfBirth}`)
166
+ if (e.gender) lines.push(`Gender: ${e.gender}`)
167
+ if (e.state) lines.push(`State: ${e.state}`)
168
+ if (e.email) lines.push(`Email: ${e.email}`)
169
+ if (e.phone) lines.push(`Phone: ${e.phone}`)
170
+ if (e.tags?.length) lines.push(`Tags: ${e.tags.join(', ')}`)
171
+ if (e.fields && typeof e.fields === 'object') {
172
+ const entries = Object.entries(e.fields).filter(([_, v]) => v !== '' && v !== null && v !== undefined)
173
+ if (entries.length) {
174
+ lines.push('Custom fields:')
175
+ for (const [k, v] of entries) {
176
+ lines.push(` - ${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
177
+ }
178
+ }
179
+ }
180
+ return lines.join('\n')
181
+ }
182
+
183
+ /* ---------------------------------- filter sanitization ---------------------------------- */
184
+
185
+ export const FORBIDDEN_FILTER_OPERATORS = ['$where', '$function']
186
+ export const aiSummaryError = (code: number, message: string) => Object.assign(new Error(message), { code })
187
+
188
+ export const sanitizeFilter = (filter: any) => {
189
+ if (!filter || typeof filter !== 'object') return {}
190
+ const json = JSON.stringify(filter)
191
+ for (const op of FORBIDDEN_FILTER_OPERATORS) {
192
+ if (json.includes(op)) {
193
+ throw aiSummaryError(400, `AI summary filter cannot contain ${op}`)
194
+ }
195
+ }
196
+ return filter
197
+ }
198
+
199
+ /* ---------------------------------- context assembly ---------------------------------- */
200
+
201
+ export type AISummarySourceSection = {
202
+ type: AISummaryDataSource,
203
+ label: string,
204
+ lookbackMS?: number,
205
+ limit: number,
206
+ records: any[],
207
+ formattedLines: string[],
208
+ }
209
+
210
+ export type AISummaryContext = {
211
+ contextText: string,
212
+ estimatedTokens: number,
213
+ profileBlock: string,
214
+ sources: AISummarySourceSection[],
215
+ }
216
+
217
+ // Build the mongo filter + effective limit for a single data source. Shared by both loaders so
218
+ // the lookback / enduserMatch / sanitized-filter / default-limit logic stays identical.
219
+ export const buildAISummarySourceFilter = ({ ds, enduserId, mapEntry } : {
220
+ ds: AISummaryDataSourceConfig,
221
+ enduserId: string,
222
+ mapEntry: DataSourceMapEntry,
223
+ }): { mdbFilter: object, effectiveLimit: number } => {
224
+ const userFilter = sanitizeFilter(ds.filter)
225
+ const lookbackClause = ds.lookbackMS
226
+ ? { [mapEntry.sortField]: { $gte: new Date(Date.now() - ds.lookbackMS) } }
227
+ : {}
228
+
229
+ const enduserMatch = mapEntry.enduserMatchClause ? mapEntry.enduserMatchClause(enduserId) : { enduserId }
230
+ const mdbFilter = { $and: [userFilter, lookbackClause, enduserMatch] }
231
+ const effectiveLimit = ds.limit ?? DEFAULT_AI_SUMMARY_DATA_SOURCE_LIMIT
232
+
233
+ return { mdbFilter, effectiveLimit }
234
+ }
235
+
236
+ // Join the profile block + per-source blocks, estimate tokens, and enforce the input budget.
237
+ export const assembleAISummaryContext = ({ profileBlock, sources } : {
238
+ profileBlock: string,
239
+ sources: AISummarySourceSection[],
240
+ }): AISummaryContext => {
241
+ const blocks = sources
242
+ .filter(s => s.formattedLines.length > 0)
243
+ .map(s => `## ${s.type}\n${s.formattedLines.join('\n')}`)
244
+
245
+ const contextText = [profileBlock, ...blocks].filter(Boolean).join('\n\n')
246
+ const estimatedTokens = Math.ceil(contextText.length / 4)
247
+ if (estimatedTokens > MAX_AI_SUMMARY_INPUT_TOKENS) {
248
+ throw aiSummaryError(413, 'AI summary input exceeds token budget — reduce limits, shorten lookback, or remove data sources.')
249
+ }
250
+ return { contextText, estimatedTokens, profileBlock, sources }
251
+ }
252
+
253
+ /* ---------------------------------- transport-agnostic loader ---------------------------------- */
254
+
255
+ export type LoadAISummaryRecordsArgs = {
256
+ type: AISummaryDataSource,
257
+ collection: AISummaryDataSource,
258
+ mdbFilter: object,
259
+ limit: number,
260
+ sortField: string,
261
+ }
262
+
263
+ // Transport-agnostic loader. Callers inject loadProfile/loadRecords backed by the SDK (webapp) or
264
+ // the DB (api). Per-source failures degrade to an empty section so one broken collection doesn't
265
+ // sink the whole summary; filter-sanitization (400) and the token-budget (413) errors still throw.
266
+ export const loadAISummaryContext = async ({
267
+ enduserId, configuration, loadProfile, loadRecords, includeProfile = true,
268
+ } : {
269
+ enduserId: string,
270
+ configuration: AISummaryConfiguration,
271
+ loadProfile: (enduserId: string) => Promise<Enduser>,
272
+ loadRecords: (args: LoadAISummaryRecordsArgs) => Promise<any[]>,
273
+ // include the enduser profile block (name/DOB/fields/custom fields) in the context. Defaults to
274
+ // true for the summary use case; the AI Decision step passes false to avoid prompt clutter.
275
+ includeProfile?: boolean,
276
+ }): Promise<AISummaryContext> => {
277
+ const enduser = includeProfile ? await loadProfile(enduserId) : null
278
+ const profileBlock = enduser ? enduserProfileToText(enduser) : ''
279
+
280
+ const sections = await Promise.all((configuration.dataSources ?? []).map(async (ds: AISummaryDataSourceConfig): Promise<AISummarySourceSection | null> => {
281
+ const m = DATA_SOURCE_MAP[ds.type]
282
+ if (!m) return null
283
+
284
+ const { mdbFilter, effectiveLimit } = buildAISummarySourceFilter({ ds, enduserId, mapEntry: m })
285
+
286
+ try {
287
+ const records = await loadRecords({ type: ds.type, collection: m.collection, mdbFilter, limit: effectiveLimit, sortField: m.sortField })
288
+ 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,
297
+ }
298
+ } catch (err) {
299
+ 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
+ }
308
+ }
309
+ }))
310
+
311
+ const sources = sections.filter((s): s is AISummarySourceSection => s !== null)
312
+ return assembleAISummaryContext({ profileBlock, sources })
313
+ }
package/src/utils.ts CHANGED
@@ -6,6 +6,8 @@ import { DateTime } from "luxon"
6
6
  import { ObjectId } from "./ObjectId/objectid";
7
7
  export { ObjectId }
8
8
 
9
+ export * from "./ai_summary"
10
+
9
11
  export type Indexable<T=any> = { [index: string]: T }
10
12
 
11
13
  export const user_is_admin = (u: { id: string, roles?: string[] } & ({ type: 'user' } | { type: 'enduser' })) =>