@tellescope/utilities 1.254.0 → 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.
- package/lib/cjs/ai_summary.d.ts +61 -0
- package/lib/cjs/ai_summary.d.ts.map +1 -0
- package/lib/cjs/ai_summary.js +354 -0
- package/lib/cjs/ai_summary.js.map +1 -0
- package/lib/cjs/utils.d.ts +1 -0
- package/lib/cjs/utils.d.ts.map +1 -1
- package/lib/cjs/utils.js +15 -0
- package/lib/cjs/utils.js.map +1 -1
- package/lib/esm/ai_summary.d.ts +61 -0
- package/lib/esm/ai_summary.d.ts.map +1 -0
- package/lib/esm/ai_summary.js +340 -0
- package/lib/esm/ai_summary.js.map +1 -0
- package/lib/esm/utils.d.ts +1 -0
- package/lib/esm/utils.d.ts.map +1 -1
- package/lib/esm/utils.js +1 -0
- package/lib/esm/utils.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/ai_summary.ts +313 -0
- package/src/utils.ts +2 -0
|
@@ -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(/ /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' })) =>
|