@vellumai/assistant 0.3.4 → 0.3.5

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.
Files changed (122) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +37 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +70 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +21 -17
  12. package/src/__tests__/channel-approvals.test.ts +48 -1
  13. package/src/__tests__/channel-guardian.test.ts +74 -22
  14. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  15. package/src/__tests__/config-schema.test.ts +2 -1
  16. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  17. package/src/__tests__/daemon-lifecycle.test.ts +13 -12
  18. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  19. package/src/__tests__/entity-search.test.ts +615 -0
  20. package/src/__tests__/handlers-twilio-config.test.ts +407 -0
  21. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  22. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  23. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  24. package/src/__tests__/run-orchestrator.test.ts +22 -0
  25. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  26. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  27. package/src/__tests__/twilio-routes.test.ts +39 -3
  28. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  29. package/src/__tests__/web-search.test.ts +1 -1
  30. package/src/__tests__/work-item-output.test.ts +110 -0
  31. package/src/calls/call-domain.ts +8 -5
  32. package/src/calls/call-orchestrator.ts +22 -11
  33. package/src/calls/twilio-config.ts +17 -11
  34. package/src/calls/twilio-rest.ts +276 -0
  35. package/src/calls/twilio-routes.ts +39 -1
  36. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  37. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  38. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  39. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  40. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  41. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  42. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  43. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  44. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  45. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  46. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  47. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  48. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  49. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  50. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  51. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  52. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  53. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  54. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  55. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  56. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  57. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  58. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  59. package/src/config/bundled-skills/messaging/SKILL.md +21 -6
  60. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  61. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  62. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  63. package/src/config/defaults.ts +2 -1
  64. package/src/config/schema.ts +9 -3
  65. package/src/config/system-prompt.ts +24 -0
  66. package/src/config/templates/IDENTITY.md +2 -2
  67. package/src/config/vellum-skills/catalog.json +6 -0
  68. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  69. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  70. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  71. package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
  72. package/src/daemon/handlers/config.ts +783 -9
  73. package/src/daemon/handlers/dictation.ts +182 -0
  74. package/src/daemon/handlers/identity.ts +14 -23
  75. package/src/daemon/handlers/index.ts +2 -0
  76. package/src/daemon/handlers/sessions.ts +2 -0
  77. package/src/daemon/handlers/shared.ts +3 -0
  78. package/src/daemon/handlers/work-items.ts +15 -7
  79. package/src/daemon/ipc-contract-inventory.json +10 -0
  80. package/src/daemon/ipc-contract.ts +108 -4
  81. package/src/daemon/lifecycle.ts +2 -0
  82. package/src/daemon/ride-shotgun-handler.ts +1 -1
  83. package/src/daemon/server.ts +6 -2
  84. package/src/daemon/session-agent-loop.ts +5 -1
  85. package/src/daemon/session-runtime-assembly.ts +55 -0
  86. package/src/daemon/session-tool-setup.ts +2 -0
  87. package/src/daemon/session.ts +11 -1
  88. package/src/inbound/public-ingress-urls.ts +3 -3
  89. package/src/memory/channel-guardian-store.ts +2 -1
  90. package/src/memory/db-init.ts +144 -0
  91. package/src/memory/job-handlers/media-processing.ts +100 -0
  92. package/src/memory/jobs-store.ts +2 -1
  93. package/src/memory/jobs-worker.ts +4 -0
  94. package/src/memory/media-store.ts +759 -0
  95. package/src/memory/retriever.ts +6 -1
  96. package/src/memory/schema.ts +98 -0
  97. package/src/memory/search/entity.ts +208 -25
  98. package/src/memory/search/ranking.ts +6 -1
  99. package/src/memory/search/types.ts +24 -0
  100. package/src/messaging/provider-types.ts +2 -0
  101. package/src/messaging/providers/sms/adapter.ts +204 -0
  102. package/src/messaging/providers/sms/client.ts +93 -0
  103. package/src/messaging/providers/sms/types.ts +7 -0
  104. package/src/permissions/checker.ts +16 -2
  105. package/src/runtime/approval-message-composer.ts +143 -0
  106. package/src/runtime/channel-approvals.ts +12 -4
  107. package/src/runtime/channel-guardian-service.ts +44 -18
  108. package/src/runtime/channel-readiness-service.ts +292 -0
  109. package/src/runtime/channel-readiness-types.ts +29 -0
  110. package/src/runtime/http-server.ts +53 -27
  111. package/src/runtime/http-types.ts +3 -0
  112. package/src/runtime/routes/call-routes.ts +2 -1
  113. package/src/runtime/routes/channel-routes.ts +67 -21
  114. package/src/runtime/run-orchestrator.ts +35 -2
  115. package/src/tools/assets/materialize.ts +2 -2
  116. package/src/tools/calls/call-start.ts +1 -0
  117. package/src/tools/credentials/vault.ts +1 -1
  118. package/src/tools/execution-target.ts +11 -1
  119. package/src/tools/network/web-search.ts +1 -1
  120. package/src/tools/types.ts +2 -0
  121. package/src/twitter/router.ts +1 -1
  122. package/src/util/platform.ts +35 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Generic event detection service.
3
+ *
4
+ * Evaluates configurable detection rules against timeline segments and produces
5
+ * scored event candidates. The rule system is fully pluggable — callers supply
6
+ * a DetectionConfig that specifies which rules to evaluate and how to weight them.
7
+ *
8
+ * Example configurations (not hardcoded — these are passed in by the caller):
9
+ *
10
+ * Basketball turnovers:
11
+ * eventType: 'turnover'
12
+ * rules: [
13
+ * { ruleType: 'segment_transition', params: { field: 'subjects' }, weight: 0.5 },
14
+ * { ruleType: 'short_segment', params: { maxDurationSeconds: 5 }, weight: 0.3 },
15
+ * { ruleType: 'attribute_match', params: { field: 'actions', pattern: 'steal|turnover' }, weight: 0.2 },
16
+ * ]
17
+ *
18
+ * Scene changes:
19
+ * eventType: 'scene_change'
20
+ * rules: [
21
+ * { ruleType: 'segment_transition', params: { field: 'segmentType' }, weight: 1.0 },
22
+ * ]
23
+ */
24
+
25
+ import {
26
+ getMediaAssetById,
27
+ getTimelineForAsset,
28
+ insertEventsBatch,
29
+ deleteEventsForAssetByType,
30
+ type MediaTimeline,
31
+ type MediaEvent,
32
+ } from '../../../../memory/media-store.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface DetectionRule {
39
+ ruleType: string;
40
+ params: Record<string, unknown>;
41
+ weight: number;
42
+ }
43
+
44
+ export interface DetectionConfig {
45
+ eventType: string;
46
+ rules: DetectionRule[];
47
+ }
48
+
49
+ export interface EventCandidate {
50
+ startTime: number;
51
+ endTime: number;
52
+ confidence: number;
53
+ reasons: string[];
54
+ metadata: Record<string, unknown>;
55
+ }
56
+
57
+ export interface DetectionResult {
58
+ assetId: string;
59
+ eventType: string;
60
+ candidateCount: number;
61
+ events: MediaEvent[];
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Rule evaluation
66
+ // ---------------------------------------------------------------------------
67
+
68
+ interface RuleMatch {
69
+ matched: boolean;
70
+ reason: string;
71
+ metadata?: Record<string, unknown>;
72
+ }
73
+
74
+ type RuleEvaluator = (
75
+ segment: MediaTimeline,
76
+ prevSegment: MediaTimeline | null,
77
+ nextSegment: MediaTimeline | null,
78
+ params: Record<string, unknown>,
79
+ ) => RuleMatch;
80
+
81
+ const RULE_EVALUATORS: Record<string, RuleEvaluator> = {
82
+ /**
83
+ * Fires when a specified field changes between adjacent segments.
84
+ * params.field: which attribute to compare ('subjects', 'segmentType', etc.)
85
+ */
86
+ segment_transition: (segment, prevSegment, _next, params) => {
87
+ if (!prevSegment) return { matched: false, reason: '' };
88
+ const field = (params.field as string) ?? 'segmentType';
89
+
90
+ if (field === 'segmentType') {
91
+ const changed = segment.segmentType !== prevSegment.segmentType;
92
+ return {
93
+ matched: changed,
94
+ reason: changed
95
+ ? `Segment type changed from "${prevSegment.segmentType}" to "${segment.segmentType}"`
96
+ : '',
97
+ };
98
+ }
99
+
100
+ // Compare attribute arrays (e.g., subjects)
101
+ const prevAttrs = prevSegment.attributes ?? {};
102
+ const currAttrs = segment.attributes ?? {};
103
+ const prevValues = new Set(Array.isArray(prevAttrs[field]) ? (prevAttrs[field] as string[]) : []);
104
+ const currValues = Array.isArray(currAttrs[field]) ? (currAttrs[field] as string[]) : [];
105
+
106
+ if (prevValues.size === 0 && currValues.length === 0) {
107
+ return { matched: false, reason: '' };
108
+ }
109
+
110
+ const overlap = currValues.filter((v) => prevValues.has(v)).length;
111
+ const unionSize = new Set([...prevValues, ...currValues]).size;
112
+ const similarity = unionSize > 0 ? overlap / unionSize : 0;
113
+
114
+ // A transition is detected when similarity drops below 50%
115
+ const changed = similarity < 0.5;
116
+ return {
117
+ matched: changed,
118
+ reason: changed
119
+ ? `${field} changed between segments (similarity: ${(similarity * 100).toFixed(0)}%)`
120
+ : '',
121
+ metadata: changed ? { similarity, prevValues: [...prevValues], currValues } : undefined,
122
+ };
123
+ },
124
+
125
+ /**
126
+ * Fires when a segment's duration is below a threshold.
127
+ * params.maxDurationSeconds: maximum segment duration to trigger (default: 5)
128
+ */
129
+ short_segment: (segment, _prev, _next, params) => {
130
+ const maxDuration = (params.maxDurationSeconds as number) ?? 5;
131
+ const duration = segment.endTime - segment.startTime;
132
+ const matched = duration > 0 && duration <= maxDuration;
133
+ return {
134
+ matched,
135
+ reason: matched
136
+ ? `Short segment (${duration.toFixed(1)}s <= ${maxDuration}s threshold)`
137
+ : '',
138
+ metadata: matched ? { duration } : undefined,
139
+ };
140
+ },
141
+
142
+ /**
143
+ * Fires when a segment's attribute values match a regex pattern.
144
+ * params.field: which attribute array to search (default: 'actions')
145
+ * params.pattern: regex pattern to match against values
146
+ */
147
+ attribute_match: (segment, _prev, _next, params) => {
148
+ const field = (params.field as string) ?? 'actions';
149
+ const pattern = params.pattern as string;
150
+ if (!pattern) return { matched: false, reason: 'No pattern specified' };
151
+
152
+ const attrs = segment.attributes ?? {};
153
+ const values = Array.isArray(attrs[field]) ? (attrs[field] as string[]) : [];
154
+ const regex = new RegExp(pattern, 'i');
155
+ const matchedValues = values.filter((v) => regex.test(v));
156
+
157
+ return {
158
+ matched: matchedValues.length > 0,
159
+ reason: matchedValues.length > 0
160
+ ? `${field} matched pattern /${pattern}/: [${matchedValues.join(', ')}]`
161
+ : '',
162
+ metadata: matchedValues.length > 0 ? { matchedValues } : undefined,
163
+ };
164
+ },
165
+ };
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Main detection logic
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Detect events in a media asset's timeline using the provided configuration.
173
+ *
174
+ * For each timeline segment, evaluates all rules and computes a weighted
175
+ * confidence score. Segments where at least one rule matches are emitted
176
+ * as event candidates. Results are stored in the media_events table (previous
177
+ * events of the same type for this asset are replaced).
178
+ */
179
+ export function detectEvents(
180
+ assetId: string,
181
+ config: DetectionConfig,
182
+ options?: { onProgress?: (message: string) => void },
183
+ ): DetectionResult {
184
+ const onProgress = options?.onProgress;
185
+
186
+ const asset = getMediaAssetById(assetId);
187
+ if (!asset) {
188
+ throw new Error(`Media asset not found: ${assetId}`);
189
+ }
190
+
191
+ const segments = getTimelineForAsset(assetId);
192
+ if (segments.length === 0) {
193
+ throw new Error('No timeline segments found. Run timeline generation first.');
194
+ }
195
+
196
+ // Sort segments by start time
197
+ const sorted = [...segments].sort((a, b) => a.startTime - b.startTime);
198
+
199
+ onProgress?.(`Evaluating ${config.rules.length} detection rules against ${sorted.length} segments...`);
200
+
201
+ // Normalize weights so they sum to 1
202
+ const totalWeight = config.rules.reduce((sum, r) => sum + r.weight, 0);
203
+ const normalizedRules = totalWeight > 0
204
+ ? config.rules.map((r) => ({ ...r, weight: r.weight / totalWeight }))
205
+ : config.rules;
206
+
207
+ const candidates: EventCandidate[] = [];
208
+
209
+ for (let i = 0; i < sorted.length; i++) {
210
+ const segment = sorted[i];
211
+ const prev = i > 0 ? sorted[i - 1] : null;
212
+ const next = i < sorted.length - 1 ? sorted[i + 1] : null;
213
+
214
+ let weightedScore = 0;
215
+ const reasons: string[] = [];
216
+ const candidateMetadata: Record<string, unknown> = {};
217
+ let anyMatched = false;
218
+
219
+ for (const rule of normalizedRules) {
220
+ const evaluator = RULE_EVALUATORS[rule.ruleType];
221
+ if (!evaluator) {
222
+ reasons.push(`Unknown rule type: ${rule.ruleType}`);
223
+ continue;
224
+ }
225
+
226
+ const result = evaluator(segment, prev, next, rule.params);
227
+ if (result.matched) {
228
+ anyMatched = true;
229
+ weightedScore += rule.weight;
230
+ reasons.push(result.reason);
231
+ if (result.metadata) {
232
+ candidateMetadata[rule.ruleType] = result.metadata;
233
+ }
234
+ }
235
+ }
236
+
237
+ if (anyMatched) {
238
+ candidates.push({
239
+ startTime: segment.startTime,
240
+ endTime: segment.endTime,
241
+ confidence: Math.min(weightedScore, 1),
242
+ reasons,
243
+ metadata: {
244
+ segmentId: segment.id,
245
+ segmentType: segment.segmentType,
246
+ ...candidateMetadata,
247
+ },
248
+ });
249
+ }
250
+ }
251
+
252
+ // Sort by confidence descending
253
+ candidates.sort((a, b) => b.confidence - a.confidence);
254
+
255
+ onProgress?.(`Found ${candidates.length} event candidates. Storing results...`);
256
+
257
+ // Replace existing events of this type for the asset (scoped to eventType)
258
+ deleteEventsForAssetByType(assetId, config.eventType);
259
+
260
+ const eventRows = candidates.map((c) => ({
261
+ assetId,
262
+ eventType: config.eventType,
263
+ startTime: c.startTime,
264
+ endTime: c.endTime,
265
+ confidence: c.confidence,
266
+ reasons: c.reasons,
267
+ metadata: c.metadata,
268
+ }));
269
+
270
+ const events = eventRows.length > 0 ? insertEventsBatch(eventRows) : [];
271
+
272
+ onProgress?.(`Stored ${events.length} events.`);
273
+
274
+ return {
275
+ assetId,
276
+ eventType: config.eventType,
277
+ candidateCount: candidates.length,
278
+ events,
279
+ };
280
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Feedback aggregation service for media events.
3
+ *
4
+ * Computes precision/recall estimates per event type based on user feedback,
5
+ * and provides structured JSON export for offline analysis.
6
+ *
7
+ * All interfaces are generic — works for any event type.
8
+ */
9
+
10
+ import { getFeedbackForAsset, type EventFeedback } from './feedback-store.js';
11
+ import { getEventsForAsset } from '../../../../memory/media-store.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface EventTypeStats {
18
+ eventType: string;
19
+ totalEvents: number;
20
+ correct: number;
21
+ incorrect: number;
22
+ boundaryEdit: number;
23
+ missed: number;
24
+ totalFeedback: number;
25
+ precision: number | null;
26
+ recall: number | null;
27
+ }
28
+
29
+ export interface AggregationResult {
30
+ assetId: string;
31
+ totalFeedbackEntries: number;
32
+ statsByEventType: EventTypeStats[];
33
+ }
34
+
35
+ export interface FeedbackExport {
36
+ assetId: string;
37
+ exportedAt: string;
38
+ totalEntries: number;
39
+ feedback: EventFeedback[];
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Aggregation
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Compute precision/recall estimates per event type for a given asset.
48
+ *
49
+ * precision = correct / (correct + incorrect)
50
+ * recall = (correct + boundary_edit) / (correct + boundary_edit + missed)
51
+ *
52
+ * Returns null for precision/recall when the denominator is zero.
53
+ */
54
+ export function aggregateFeedback(assetId: string): AggregationResult {
55
+ const allFeedback = getFeedbackForAsset(assetId);
56
+ const allEvents = getEventsForAsset(assetId);
57
+
58
+ // Group feedback by event type — we need event type from the events table
59
+ const eventTypeById = new Map<string, string>();
60
+ for (const event of allEvents) {
61
+ eventTypeById.set(event.id, event.eventType);
62
+ }
63
+
64
+ // Collect counts per event type
65
+ const countsMap = new Map<string, {
66
+ correct: number;
67
+ incorrect: number;
68
+ boundaryEdit: number;
69
+ missed: number;
70
+ totalEvents: number;
71
+ }>();
72
+
73
+ // Initialize with known event types from existing events
74
+ for (const event of allEvents) {
75
+ if (!countsMap.has(event.eventType)) {
76
+ countsMap.set(event.eventType, { correct: 0, incorrect: 0, boundaryEdit: 0, missed: 0, totalEvents: 0 });
77
+ }
78
+ countsMap.get(event.eventType)!.totalEvents++;
79
+ }
80
+
81
+ // Tally feedback
82
+ for (const fb of allFeedback) {
83
+ const eventType = eventTypeById.get(fb.eventId);
84
+ if (!eventType) continue;
85
+
86
+ if (!countsMap.has(eventType)) {
87
+ countsMap.set(eventType, { correct: 0, incorrect: 0, boundaryEdit: 0, missed: 0, totalEvents: 0 });
88
+ }
89
+
90
+ const counts = countsMap.get(eventType)!;
91
+ switch (fb.feedbackType) {
92
+ case 'correct':
93
+ counts.correct++;
94
+ break;
95
+ case 'incorrect':
96
+ counts.incorrect++;
97
+ break;
98
+ case 'boundary_edit':
99
+ counts.boundaryEdit++;
100
+ break;
101
+ case 'missed':
102
+ counts.missed++;
103
+ break;
104
+ }
105
+ }
106
+
107
+ const statsByEventType: EventTypeStats[] = [];
108
+ for (const [eventType, counts] of countsMap) {
109
+ const precisionDenom = counts.correct + counts.incorrect;
110
+ const recallDenom = counts.correct + counts.boundaryEdit + counts.missed;
111
+
112
+ statsByEventType.push({
113
+ eventType,
114
+ totalEvents: counts.totalEvents,
115
+ correct: counts.correct,
116
+ incorrect: counts.incorrect,
117
+ boundaryEdit: counts.boundaryEdit,
118
+ missed: counts.missed,
119
+ totalFeedback: counts.correct + counts.incorrect + counts.boundaryEdit + counts.missed,
120
+ precision: precisionDenom > 0 ? counts.correct / precisionDenom : null,
121
+ recall: recallDenom > 0 ? (counts.correct + counts.boundaryEdit) / recallDenom : null,
122
+ });
123
+ }
124
+
125
+ return {
126
+ assetId,
127
+ totalFeedbackEntries: allFeedback.length,
128
+ statsByEventType,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Export all feedback for an asset as structured JSON for offline analysis.
134
+ */
135
+ export function exportFeedback(assetId: string): FeedbackExport {
136
+ const allFeedback = getFeedbackForAsset(assetId);
137
+
138
+ return {
139
+ assetId,
140
+ exportedAt: new Date().toISOString(),
141
+ totalEntries: allFeedback.length,
142
+ feedback: allFeedback,
143
+ };
144
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Feedback store service for media event corrections.
3
+ *
4
+ * Provides CRUD operations for the media_event_feedback table.
5
+ * All interfaces are generic — works for any event type, not just turnovers.
6
+ */
7
+
8
+ import { and, eq } from 'drizzle-orm';
9
+ import { v4 as uuid } from 'uuid';
10
+ import { getDb } from '../../../../memory/db.js';
11
+ import { mediaEventFeedback } from '../../../../memory/schema.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type FeedbackType = 'correct' | 'incorrect' | 'boundary_edit' | 'missed';
18
+
19
+ export interface EventFeedback {
20
+ id: string;
21
+ assetId: string;
22
+ eventId: string;
23
+ feedbackType: FeedbackType;
24
+ originalStartTime: number | null;
25
+ originalEndTime: number | null;
26
+ correctedStartTime: number | null;
27
+ correctedEndTime: number | null;
28
+ notes: string | null;
29
+ createdAt: number;
30
+ }
31
+
32
+ export interface SubmitFeedbackParams {
33
+ assetId: string;
34
+ eventId: string;
35
+ feedbackType: FeedbackType;
36
+ originalStartTime?: number;
37
+ originalEndTime?: number;
38
+ correctedStartTime?: number;
39
+ correctedEndTime?: number;
40
+ notes?: string;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Validation
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const VALID_FEEDBACK_TYPES: FeedbackType[] = ['correct', 'incorrect', 'boundary_edit', 'missed'];
48
+
49
+ function isValidFeedbackType(type: string): type is FeedbackType {
50
+ return VALID_FEEDBACK_TYPES.includes(type as FeedbackType);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // CRUD
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export function submitFeedback(params: SubmitFeedbackParams): EventFeedback {
58
+ if (!isValidFeedbackType(params.feedbackType)) {
59
+ throw new Error(`Invalid feedback type "${params.feedbackType}". Must be one of: ${VALID_FEEDBACK_TYPES.join(', ')}`);
60
+ }
61
+
62
+ const db = getDb();
63
+ const now = Date.now();
64
+ const record = {
65
+ id: uuid(),
66
+ assetId: params.assetId,
67
+ eventId: params.eventId,
68
+ feedbackType: params.feedbackType,
69
+ originalStartTime: params.originalStartTime ?? null,
70
+ originalEndTime: params.originalEndTime ?? null,
71
+ correctedStartTime: params.correctedStartTime ?? null,
72
+ correctedEndTime: params.correctedEndTime ?? null,
73
+ notes: params.notes ?? null,
74
+ createdAt: now,
75
+ };
76
+
77
+ db.insert(mediaEventFeedback).values(record).run();
78
+
79
+ return record;
80
+ }
81
+
82
+ export function getFeedbackForAsset(assetId: string): EventFeedback[] {
83
+ const db = getDb();
84
+ const rows = db
85
+ .select()
86
+ .from(mediaEventFeedback)
87
+ .where(eq(mediaEventFeedback.assetId, assetId))
88
+ .all();
89
+ return rows.map(parseRow);
90
+ }
91
+
92
+ export function getFeedbackForEvent(eventId: string): EventFeedback[] {
93
+ const db = getDb();
94
+ const rows = db
95
+ .select()
96
+ .from(mediaEventFeedback)
97
+ .where(eq(mediaEventFeedback.eventId, eventId))
98
+ .all();
99
+ return rows.map(parseRow);
100
+ }
101
+
102
+ export function getFeedbackByType(assetId: string, feedbackType: FeedbackType): EventFeedback[] {
103
+ if (!isValidFeedbackType(feedbackType)) {
104
+ throw new Error(`Invalid feedback type "${feedbackType}". Must be one of: ${VALID_FEEDBACK_TYPES.join(', ')}`);
105
+ }
106
+
107
+ const db = getDb();
108
+ const rows = db
109
+ .select()
110
+ .from(mediaEventFeedback)
111
+ .where(and(
112
+ eq(mediaEventFeedback.assetId, assetId),
113
+ eq(mediaEventFeedback.feedbackType, feedbackType),
114
+ ))
115
+ .all();
116
+ return rows.map(parseRow);
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Row parsing
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function parseRow(row: typeof mediaEventFeedback.$inferSelect): EventFeedback {
124
+ return {
125
+ id: row.id,
126
+ assetId: row.assetId,
127
+ eventId: row.eventId,
128
+ feedbackType: row.feedbackType as FeedbackType,
129
+ originalStartTime: row.originalStartTime,
130
+ originalEndTime: row.originalEndTime,
131
+ correctedStartTime: row.correctedStartTime,
132
+ correctedEndTime: row.correctedEndTime,
133
+ notes: row.notes,
134
+ createdAt: row.createdAt,
135
+ };
136
+ }