@steno-ai/engine 0.1.16 → 0.1.17

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,463 @@
1
+ /**
2
+ * Structured data extractor — bypasses LLM entirely.
3
+ *
4
+ * Handles structured_event, structured_task, structured_email, structured_vault
5
+ * input types by directly creating entities, edges, and facts from known fields.
6
+ * Zero LLM cost, deterministic, high confidence.
7
+ */
8
+
9
+ import type { ExtractionResult, ExtractedFact, ExtractedEntity, ExtractedEdge } from './types.js';
10
+ import type { SourceType, EdgeType } from '../config.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Structured input schemas
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface StructuredEvent {
17
+ title: string;
18
+ startTime: string; // ISO 8601
19
+ endTime?: string;
20
+ location?: string;
21
+ description?: string;
22
+ organizers?: string[]; // org/person names
23
+ attendees?: string[];
24
+ url?: string;
25
+ provider?: string; // 'google' | 'microsoft' | 'partiful' etc.
26
+ externalId?: string; // calendar event ID, vault item ID, etc.
27
+ sourceType?: 'calendar' | 'vault';
28
+ }
29
+
30
+ export interface StructuredTask {
31
+ title: string;
32
+ description?: string;
33
+ status?: string;
34
+ priority?: string;
35
+ category?: string;
36
+ dueDate?: string; // ISO 8601
37
+ tags?: string[];
38
+ externalId?: string;
39
+ }
40
+
41
+ export interface StructuredEmail {
42
+ subject: string;
43
+ from: string;
44
+ to?: string[];
45
+ body?: string; // truncated
46
+ date: string; // ISO 8601
47
+ isUnread?: boolean;
48
+ threadId?: string;
49
+ provider?: string; // 'gmail' | 'outlook'
50
+ externalId?: string;
51
+ }
52
+
53
+ export interface StructuredVault {
54
+ title: string;
55
+ contentType: string; // 'event', 'article', 'job', 'recipe', etc.
56
+ url?: string;
57
+ source?: string; // domain
58
+ savedAt: string; // ISO 8601
59
+ content?: string; // truncated page content
60
+ metadata?: Record<string, unknown>;
61
+ externalId?: string;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Helpers
66
+ // ---------------------------------------------------------------------------
67
+
68
+ function canonicalize(name: string): string {
69
+ return name.toLowerCase().replace(/[^a-z0-9\s.-]/g, '').replace(/\s+/g, ' ').trim();
70
+ }
71
+
72
+ function formatDate(iso: string): string {
73
+ try {
74
+ return new Date(iso).toLocaleDateString('en-US', {
75
+ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
76
+ });
77
+ } catch {
78
+ return iso;
79
+ }
80
+ }
81
+
82
+ function formatTime(iso: string): string {
83
+ try {
84
+ return new Date(iso).toLocaleTimeString('en-US', {
85
+ hour: 'numeric', minute: '2-digit', hour12: true,
86
+ });
87
+ } catch {
88
+ return '';
89
+ }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Extractors
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export function extractStructuredEvent(data: StructuredEvent): ExtractionResult {
97
+ const entities: ExtractedEntity[] = [];
98
+ const edges: ExtractedEdge[] = [];
99
+
100
+ // Main event entity
101
+ const eventCanonical = canonicalize(data.title);
102
+ entities.push({
103
+ name: data.title,
104
+ entityType: 'event',
105
+ canonicalName: eventCanonical,
106
+ properties: {
107
+ startTime: data.startTime,
108
+ endTime: data.endTime,
109
+ location: data.location,
110
+ url: data.url,
111
+ provider: data.provider,
112
+ externalId: data.externalId,
113
+ sourceType: data.sourceType,
114
+ },
115
+ });
116
+
117
+ // Location entity
118
+ if (data.location) {
119
+ const locCanonical = canonicalize(data.location);
120
+ entities.push({
121
+ name: data.location,
122
+ entityType: 'location',
123
+ canonicalName: locCanonical,
124
+ properties: {},
125
+ });
126
+ edges.push({
127
+ sourceName: eventCanonical,
128
+ targetName: locCanonical,
129
+ relation: 'located_at',
130
+ edgeType: 'associative',
131
+ confidence: 1.0,
132
+ });
133
+ }
134
+
135
+ // Organizer entities
136
+ for (const org of data.organizers ?? []) {
137
+ const orgCanonical = canonicalize(org);
138
+ entities.push({
139
+ name: org,
140
+ entityType: 'organization',
141
+ canonicalName: orgCanonical,
142
+ properties: {},
143
+ });
144
+ edges.push({
145
+ sourceName: eventCanonical,
146
+ targetName: orgCanonical,
147
+ relation: 'hosted_by',
148
+ edgeType: 'associative',
149
+ confidence: 1.0,
150
+ });
151
+ }
152
+
153
+ // Attendee entities
154
+ for (const attendee of data.attendees ?? []) {
155
+ const attCanonical = canonicalize(attendee);
156
+ entities.push({
157
+ name: attendee,
158
+ entityType: 'person',
159
+ canonicalName: attCanonical,
160
+ properties: {},
161
+ });
162
+ edges.push({
163
+ sourceName: attCanonical,
164
+ targetName: eventCanonical,
165
+ relation: 'attends',
166
+ edgeType: 'associative',
167
+ confidence: 1.0,
168
+ });
169
+ }
170
+
171
+ // Build fact content
172
+ let factContent = `Event: "${data.title}" on ${formatDate(data.startTime)}`;
173
+ if (data.startTime) factContent += ` at ${formatTime(data.startTime)}`;
174
+ if (data.endTime) factContent += ` - ${formatTime(data.endTime)}`;
175
+ if (data.location) factContent += ` at ${data.location}`;
176
+ if (data.organizers?.length) factContent += `. Hosted by ${data.organizers.join(', ')}`;
177
+ if (data.description) factContent += `. ${data.description.slice(0, 300)}`;
178
+
179
+ const fact: ExtractedFact = {
180
+ content: factContent,
181
+ importance: 0.8,
182
+ confidence: 1.0,
183
+ sourceType: (data.sourceType === 'vault' ? 'structured_vault' : 'structured_event') as SourceType,
184
+ modality: 'text',
185
+ tags: ['structured', 'event', ...(data.provider ? [data.provider] : [])],
186
+ originalContent: JSON.stringify(data),
187
+ entityCanonicalNames: [eventCanonical, ...entities.filter(e => e.canonicalName !== eventCanonical).map(e => e.canonicalName)],
188
+ eventDate: new Date(data.startTime),
189
+ documentDate: new Date(),
190
+ };
191
+
192
+ return {
193
+ facts: [fact],
194
+ entities,
195
+ edges,
196
+ tier: 'heuristic',
197
+ confidence: 1.0,
198
+ tokensInput: 0,
199
+ tokensOutput: 0,
200
+ model: null,
201
+ };
202
+ }
203
+
204
+ export function extractStructuredTask(data: StructuredTask): ExtractionResult {
205
+ const entities: ExtractedEntity[] = [];
206
+ const edges: ExtractedEdge[] = [];
207
+
208
+ const taskCanonical = canonicalize(data.title);
209
+ entities.push({
210
+ name: data.title,
211
+ entityType: 'task',
212
+ canonicalName: taskCanonical,
213
+ properties: {
214
+ status: data.status,
215
+ priority: data.priority,
216
+ category: data.category,
217
+ dueDate: data.dueDate,
218
+ externalId: data.externalId,
219
+ },
220
+ });
221
+
222
+ // Category entity
223
+ if (data.category) {
224
+ const catCanonical = canonicalize(data.category);
225
+ entities.push({
226
+ name: data.category,
227
+ entityType: 'topic',
228
+ canonicalName: catCanonical,
229
+ properties: {},
230
+ });
231
+ edges.push({
232
+ sourceName: taskCanonical,
233
+ targetName: catCanonical,
234
+ relation: 'categorized_as',
235
+ edgeType: 'hierarchical',
236
+ confidence: 1.0,
237
+ });
238
+ }
239
+
240
+ let factContent = `Task: "${data.title}"`;
241
+ if (data.status) factContent += ` (${data.status})`;
242
+ if (data.priority) factContent += `, priority: ${data.priority}`;
243
+ if (data.dueDate) factContent += `, due ${formatDate(data.dueDate)}`;
244
+ if (data.description) factContent += `. ${data.description.slice(0, 200)}`;
245
+
246
+ const fact: ExtractedFact = {
247
+ content: factContent,
248
+ importance: data.priority === 'high' || data.priority === 'urgent' ? 0.9 : 0.7,
249
+ confidence: 1.0,
250
+ sourceType: 'structured_task' as SourceType,
251
+ modality: 'text',
252
+ tags: ['structured', 'task', ...(data.tags ?? [])],
253
+ originalContent: JSON.stringify(data),
254
+ entityCanonicalNames: [taskCanonical],
255
+ eventDate: data.dueDate ? new Date(data.dueDate) : undefined,
256
+ documentDate: new Date(),
257
+ };
258
+
259
+ return {
260
+ facts: [fact],
261
+ entities,
262
+ edges,
263
+ tier: 'heuristic',
264
+ confidence: 1.0,
265
+ tokensInput: 0,
266
+ tokensOutput: 0,
267
+ model: null,
268
+ };
269
+ }
270
+
271
+ export function extractStructuredEmail(data: StructuredEmail): ExtractionResult {
272
+ const entities: ExtractedEntity[] = [];
273
+ const edges: ExtractedEdge[] = [];
274
+
275
+ // Sender entity
276
+ const senderCanonical = canonicalize(data.from);
277
+ entities.push({
278
+ name: data.from,
279
+ entityType: 'person',
280
+ canonicalName: senderCanonical,
281
+ properties: { email: data.from },
282
+ });
283
+
284
+ // Subject as topic entity if substantial
285
+ if (data.subject && data.subject.length > 5) {
286
+ const subjectCanonical = canonicalize(data.subject);
287
+ entities.push({
288
+ name: data.subject,
289
+ entityType: 'topic',
290
+ canonicalName: subjectCanonical,
291
+ properties: { threadId: data.threadId, provider: data.provider },
292
+ });
293
+ edges.push({
294
+ sourceName: senderCanonical,
295
+ targetName: subjectCanonical,
296
+ relation: 'authored',
297
+ edgeType: 'associative',
298
+ confidence: 1.0,
299
+ });
300
+ }
301
+
302
+ // Recipients
303
+ for (const to of data.to ?? []) {
304
+ const toCanonical = canonicalize(to);
305
+ entities.push({
306
+ name: to,
307
+ entityType: 'person',
308
+ canonicalName: toCanonical,
309
+ properties: { email: to },
310
+ });
311
+ }
312
+
313
+ let factContent = `Email from ${data.from}: "${data.subject}"`;
314
+ if (data.date) factContent += ` on ${formatDate(data.date)}`;
315
+ if (data.body) factContent += `. ${data.body.slice(0, 300)}`;
316
+
317
+ const fact: ExtractedFact = {
318
+ content: factContent,
319
+ importance: data.isUnread ? 0.8 : 0.5,
320
+ confidence: 1.0,
321
+ sourceType: 'structured_email' as SourceType,
322
+ modality: 'text',
323
+ tags: ['structured', 'email', ...(data.provider ? [data.provider] : []), ...(data.isUnread ? ['unread'] : [])],
324
+ originalContent: JSON.stringify(data),
325
+ entityCanonicalNames: [senderCanonical],
326
+ eventDate: new Date(data.date),
327
+ documentDate: new Date(),
328
+ };
329
+
330
+ return {
331
+ facts: [fact],
332
+ entities,
333
+ edges,
334
+ tier: 'heuristic',
335
+ confidence: 1.0,
336
+ tokensInput: 0,
337
+ tokensOutput: 0,
338
+ model: null,
339
+ };
340
+ }
341
+
342
+ export function extractStructuredVault(data: StructuredVault): ExtractionResult {
343
+ const entities: ExtractedEntity[] = [];
344
+ const edges: ExtractedEdge[] = [];
345
+
346
+ const vaultCanonical = canonicalize(data.title);
347
+ entities.push({
348
+ name: data.title,
349
+ entityType: data.contentType === 'event' ? 'event' : 'topic',
350
+ canonicalName: vaultCanonical,
351
+ properties: {
352
+ contentType: data.contentType,
353
+ url: data.url,
354
+ source: data.source,
355
+ savedAt: data.savedAt,
356
+ externalId: data.externalId,
357
+ ...(data.metadata ?? {}),
358
+ },
359
+ });
360
+
361
+ // Source domain entity
362
+ if (data.source) {
363
+ const sourceCanonical = canonicalize(data.source);
364
+ entities.push({
365
+ name: data.source,
366
+ entityType: 'source',
367
+ canonicalName: sourceCanonical,
368
+ properties: {},
369
+ });
370
+ edges.push({
371
+ sourceName: vaultCanonical,
372
+ targetName: sourceCanonical,
373
+ relation: 'saved_from',
374
+ edgeType: 'associative',
375
+ confidence: 1.0,
376
+ });
377
+ }
378
+
379
+ // If event type, extract organizers from metadata
380
+ const organizers = data.metadata?.organizer || data.metadata?.organizers;
381
+ if (organizers) {
382
+ const orgList = typeof organizers === 'string'
383
+ ? organizers.split(/,\s*|(?:\s+and\s+)/)
384
+ : Array.isArray(organizers) ? organizers : [];
385
+ for (const org of orgList) {
386
+ const trimmed = (org as string).trim();
387
+ if (!trimmed) continue;
388
+ const orgCanonical = canonicalize(trimmed);
389
+ entities.push({
390
+ name: trimmed,
391
+ entityType: 'organization',
392
+ canonicalName: orgCanonical,
393
+ properties: {},
394
+ });
395
+ edges.push({
396
+ sourceName: vaultCanonical,
397
+ targetName: orgCanonical,
398
+ relation: 'hosted_by',
399
+ edgeType: 'associative',
400
+ confidence: 1.0,
401
+ });
402
+ }
403
+ }
404
+
405
+ let factContent = `Saved to vault: "${data.title}" (${data.contentType})`;
406
+ if (data.source) factContent += ` from ${data.source}`;
407
+ if (data.savedAt) factContent += ` on ${formatDate(data.savedAt)}`;
408
+ if (data.content) factContent += `. ${data.content.slice(0, 300)}`;
409
+
410
+ const fact: ExtractedFact = {
411
+ content: factContent,
412
+ importance: 0.7,
413
+ confidence: 1.0,
414
+ sourceType: 'structured_vault' as SourceType,
415
+ modality: 'text',
416
+ tags: ['structured', 'vault', data.contentType],
417
+ originalContent: JSON.stringify(data),
418
+ entityCanonicalNames: [vaultCanonical, ...entities.filter(e => e.canonicalName !== vaultCanonical).map(e => e.canonicalName)],
419
+ eventDate: data.metadata?.date ? new Date(data.metadata.date as string) : undefined,
420
+ documentDate: new Date(data.savedAt),
421
+ };
422
+
423
+ return {
424
+ facts: [fact],
425
+ entities,
426
+ edges,
427
+ tier: 'heuristic',
428
+ confidence: 1.0,
429
+ tokensInput: 0,
430
+ tokensOutput: 0,
431
+ model: null,
432
+ };
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Router — picks the right extractor based on inputType
437
+ // ---------------------------------------------------------------------------
438
+
439
+ const STRUCTURED_INPUT_TYPES = new Set([
440
+ 'structured_event',
441
+ 'structured_task',
442
+ 'structured_email',
443
+ 'structured_vault',
444
+ ]);
445
+
446
+ export function isStructuredInput(inputType: string): boolean {
447
+ return STRUCTURED_INPUT_TYPES.has(inputType);
448
+ }
449
+
450
+ export function extractStructured(inputType: string, data: unknown): ExtractionResult {
451
+ switch (inputType) {
452
+ case 'structured_event':
453
+ return extractStructuredEvent(data as StructuredEvent);
454
+ case 'structured_task':
455
+ return extractStructuredTask(data as StructuredTask);
456
+ case 'structured_email':
457
+ return extractStructuredEmail(data as StructuredEmail);
458
+ case 'structured_vault':
459
+ return extractStructuredVault(data as StructuredVault);
460
+ default:
461
+ throw new Error(`Unknown structured input type: ${inputType}`);
462
+ }
463
+ }
@@ -60,7 +60,7 @@ export interface ExtractionInput {
60
60
  scope: Scope;
61
61
  scopeId: string;
62
62
  sessionId?: string;
63
- inputType: 'conversation' | 'document' | 'url' | 'raw_text' | 'image' | 'audio' | 'code' | 'codebase_scan' | 'file_change' | 'architecture_doc';
63
+ inputType: 'conversation' | 'document' | 'url' | 'raw_text' | 'image' | 'audio' | 'code' | 'codebase_scan' | 'file_change' | 'architecture_doc' | 'structured_event' | 'structured_task' | 'structured_email' | 'structured_vault';
64
64
  data: unknown;
65
65
  existingFacts?: Array<{ id: string; lineageId: string; content: string; embedding?: number[] }>;
66
66
  /** Source provider for provenance tracking — where did this data come from? */