@larkup/workspace-server 0.0.0-stage → 0.2.1
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/LICENSE +176 -0
- package/README.md +2 -2
- package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
- package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/route.d.ts +3 -0
- package/dist-types/app/api/chat/route.d.ts.map +1 -0
- package/dist-types/app/api/chat/tools.d.ts +67 -0
- package/dist-types/app/api/chat/tools.d.ts.map +1 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
- package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
- package/dist-types/app/api/config/verify/route.d.ts +3 -0
- package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/describe-image/route.d.ts +4 -0
- package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
- package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
- package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
- package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
- package/dist-types/app/api/document/sign/route.d.ts +2 -0
- package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
- package/dist-types/app/api/files/remote/route.d.ts +5 -0
- package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
- package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
- package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
- package/dist-types/app/api/mcp/route.d.ts +4 -0
- package/dist-types/app/api/mcp/route.d.ts.map +1 -0
- package/dist-types/app/api/parse-file/route.d.ts +6 -0
- package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
- package/dist-types/app/api/pdf-page/route.d.ts +4 -0
- package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
- package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
- package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/search/bing/route.d.ts +5 -0
- package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
- package/dist-types/app/api/search/brave/route.d.ts +5 -0
- package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
- package/dist-types/app/api/search/exa/route.d.ts +5 -0
- package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
- package/dist-types/app/api/search/google/route.d.ts +30 -0
- package/dist-types/app/api/search/google/route.d.ts.map +1 -0
- package/dist-types/app/api/search/route.d.ts +7 -0
- package/dist-types/app/api/search/route.d.ts.map +1 -0
- package/dist-types/app/api/search/tavily/route.d.ts +5 -0
- package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
- package/dist-types/app/api/search/verify/route.d.ts +4 -0
- package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
- package/dist-types/app/api/tabular/route.d.ts +9 -0
- package/dist-types/app/api/tabular/route.d.ts.map +1 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
- package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
- package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
- package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
- package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
- package/dist-types/lib/chat/image-description.d.ts +10 -0
- package/dist-types/lib/chat/image-description.d.ts.map +1 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
- package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
- package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/message-input.d.ts +8 -0
- package/dist-types/lib/chat/message-input.d.ts.map +1 -0
- package/dist-types/lib/chat/model-provider.d.ts +23 -0
- package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
- package/dist-types/lib/chat/response-style.d.ts +2 -0
- package/dist-types/lib/chat/response-style.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
- package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
- package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
- package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
- package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
- package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
- package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
- package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
- package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-context.d.ts +72 -0
- package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
- package/dist-types/lib/chat/tool-registry.d.ts +3 -0
- package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
- package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
- package/dist-types/lib/chat/visual-routing.d.ts +53 -0
- package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
- package/dist-types/lib/enterprise-client.d.ts +31 -0
- package/dist-types/lib/enterprise-client.d.ts.map +1 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
- package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
- package/dist-types/lib/media/knowledge.d.ts +47 -0
- package/dist-types/lib/media/knowledge.d.ts.map +1 -0
- package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
- package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
- package/dist-types/lib/media/source-identity.d.ts +12 -0
- package/dist-types/lib/media/source-identity.d.ts.map +1 -0
- package/dist-types/lib/media/source-utils.d.ts +157 -0
- package/dist-types/lib/media/source-utils.d.ts.map +1 -0
- package/dist-types/lib/media/video/quick-look.d.ts +60 -0
- package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
- package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
- package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
- package/dist-types/lib/remote-files.d.ts +12 -0
- package/dist-types/lib/remote-files.d.ts.map +1 -0
- package/dist-types/lib/search/provider-verification.d.ts +5 -0
- package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
- package/dist-types/runtime-host.d.ts +20 -0
- package/dist-types/runtime-host.d.ts.map +1 -0
- package/package.json +338 -3
- package/src/app/api/chat/feedback/route.ts +77 -0
- package/src/app/api/chat/route.ts +2277 -0
- package/src/app/api/chat/tools.ts +3563 -0
- package/src/app/api/config/test-embedding/route.ts +39 -0
- package/src/app/api/config/verify/route.ts +309 -0
- package/src/app/api/describe-image/route.ts +108 -0
- package/src/app/api/doc-editor/edit/route.ts +87 -0
- package/src/app/api/doc-editor/export/route.ts +32 -0
- package/src/app/api/doc-editor/parse/route.ts +74 -0
- package/src/app/api/doc-editor/restore/route.ts +70 -0
- package/src/app/api/document/sign/route.ts +55 -0
- package/src/app/api/files/remote/route.ts +32 -0
- package/src/app/api/mcp/[connectionId]/route.ts +29 -0
- package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
- package/src/app/api/mcp/route.ts +22 -0
- package/src/app/api/parse-file/route.ts +86 -0
- package/src/app/api/pdf-page/route.ts +40 -0
- package/src/app/api/proxy/verify/route.ts +78 -0
- package/src/app/api/search/bing/route.ts +86 -0
- package/src/app/api/search/brave/route.ts +88 -0
- package/src/app/api/search/exa/route.ts +100 -0
- package/src/app/api/search/google/route.ts +147 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/search/tavily/route.ts +93 -0
- package/src/app/api/search/verify/route.ts +27 -0
- package/src/app/api/tabular/route.ts +68 -0
- package/src/lib/chat/assistant-source-scope.ts +32 -0
- package/src/lib/chat/conversation-memory.ts +621 -0
- package/src/lib/chat/gateway-fallbacks.ts +12 -0
- package/src/lib/chat/image-description.ts +21 -0
- package/src/lib/chat/media-retrieval-routing.ts +37 -0
- package/src/lib/chat/media-source-routing.ts +100 -0
- package/src/lib/chat/message-input.ts +48 -0
- package/src/lib/chat/model-provider.ts +157 -0
- package/src/lib/chat/response-style.ts +3 -0
- package/src/lib/chat/retrieval-ranking.ts +126 -0
- package/src/lib/chat/retrieval-routing.ts +221 -0
- package/src/lib/chat/tabular-dataset-routing.ts +93 -0
- package/src/lib/chat/tabular-query-plan.ts +195 -0
- package/src/lib/chat/tabular-routing.ts +122 -0
- package/src/lib/chat/tabular-sandbox-files.ts +78 -0
- package/src/lib/chat/tabular-visualization.ts +165 -0
- package/src/lib/chat/tool-call-repair.ts +84 -0
- package/src/lib/chat/tool-context.ts +1222 -0
- package/src/lib/chat/tool-registry.ts +6 -0
- package/src/lib/chat/video-rag-routing.ts +149 -0
- package/src/lib/chat/visual-routing.ts +225 -0
- package/src/lib/enterprise-client.ts +96 -0
- package/src/lib/marketplace/tool-runtime-config.ts +155 -0
- package/src/lib/media/knowledge.ts +489 -0
- package/src/lib/media/pdf-inspection.ts +280 -0
- package/src/lib/media/source-identity.ts +50 -0
- package/src/lib/media/source-utils.ts +1156 -0
- package/src/lib/media/video/quick-look.ts +368 -0
- package/src/lib/media/video-intelligence-adapter.ts +1946 -0
- package/src/lib/media/video-intelligence-connection.ts +151 -0
- package/src/lib/remote-files.ts +182 -0
- package/src/lib/search/provider-verification.ts +95 -0
- package/src/runtime-host.ts +31 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { generateText } from 'ai';
|
|
6
|
+
import { readConfig } from '@larkup/core/config-store';
|
|
7
|
+
import { getModelsByType } from '@larkup/core/models-cache';
|
|
8
|
+
import { trackUsageEvent } from '@larkup/core/analytics-store';
|
|
9
|
+
import { createChatModel, resolveConfiguredVisionModel } from '../../chat/model-provider';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Re-watches a bounded stretch of an already-indexed video to settle a question
|
|
13
|
+
* the index could not.
|
|
14
|
+
*
|
|
15
|
+
* The dispatched GPU pipeline exists to build an index: it uploads or reseeks
|
|
16
|
+
* the source, cold-starts a worker, and runs decode, detection, OCR, and
|
|
17
|
+
* captioning over a range. That is minutes of wall time, which is the right
|
|
18
|
+
* trade for indexing and the wrong one for a person waiting on a reply -- the
|
|
19
|
+
* turn's budget expires and the answer arrives empty, which reads as the video
|
|
20
|
+
* not showing something it plainly does.
|
|
21
|
+
*
|
|
22
|
+
* Reading frames is not the expensive part. Sampling a bounded range off the
|
|
23
|
+
* local file costs well under a second; the remaining cost is one multimodal
|
|
24
|
+
* request, which is dominated by its own output rather than by how many frames
|
|
25
|
+
* it carries. So a whole range goes out as a single request, and several ranges
|
|
26
|
+
* go out at once.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Frames per request. The index has already located the bounded window, so an
|
|
31
|
+
* interactive verification should sample it rather than upload near-video
|
|
32
|
+
* frame density. Sixteen evenly-spaced frames stay below common multimodal
|
|
33
|
+
* gateway body limits while still covering the beginning, changes, and end.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_FRAMES_PER_LOOK = 16;
|
|
36
|
+
/** A short window is sampled densely, but never past what a viewer could tell apart. */
|
|
37
|
+
const MAX_FPS = 4;
|
|
38
|
+
/** Wide enough to read a small label or readout without inflating the request. */
|
|
39
|
+
const FRAME_WIDTH = 1024;
|
|
40
|
+
const EXTRACT_TIMEOUT_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
export interface QuickLookRange {
|
|
43
|
+
startSecs: number;
|
|
44
|
+
endSecs: number;
|
|
45
|
+
/** What this particular window is expected to settle. */
|
|
46
|
+
lookingFor?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface QuickLookFinding {
|
|
50
|
+
range: { startSecs: number; endSecs: number };
|
|
51
|
+
at: string;
|
|
52
|
+
/** What the re-watch established, in the reader's own words. */
|
|
53
|
+
found: string;
|
|
54
|
+
/** Whether this bounded reading actually answers the question, not merely relates to it. */
|
|
55
|
+
settlesQuestion: boolean;
|
|
56
|
+
/** For requests about every visible subject, whether each one was individually covered. */
|
|
57
|
+
coverageComplete?: boolean;
|
|
58
|
+
/** Exact short strings actually read off the frames. */
|
|
59
|
+
read: string[];
|
|
60
|
+
confidence: 'high' | 'medium' | 'low';
|
|
61
|
+
frameCount: number;
|
|
62
|
+
elapsedMs: number;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function timecode(seconds: number): string {
|
|
67
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
68
|
+
const hours = Math.floor(total / 3_600);
|
|
69
|
+
const minutes = Math.floor((total % 3_600) / 60);
|
|
70
|
+
const rest = total % 60;
|
|
71
|
+
return hours > 0
|
|
72
|
+
? `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
|
73
|
+
: `${minutes}:${String(rest).padStart(2, '0')}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runFfmpeg(args: string[]): Promise<void> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const child = spawn('ffmpeg', args, { shell: false });
|
|
79
|
+
let stderr = '';
|
|
80
|
+
const timeout = setTimeout(() => child.kill('SIGTERM'), EXTRACT_TIMEOUT_MS);
|
|
81
|
+
child.stderr.on('data', (data) => (stderr += String(data)));
|
|
82
|
+
child.on('error', (error) => {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
reject(error);
|
|
85
|
+
});
|
|
86
|
+
child.on('close', (code) => {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
if (code === 0) resolve();
|
|
89
|
+
else reject(new Error(`ffmpeg failed (${code}): ${stderr.trim().slice(0, 300)}`));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Samples one range into JPEG frames. `-ss` before `-i` seeks by keyframe
|
|
96
|
+
* before decoding, so cost tracks the range's length rather than its offset
|
|
97
|
+
* into the source -- a look near the end of a long recording is as cheap as one
|
|
98
|
+
* near the start.
|
|
99
|
+
*/
|
|
100
|
+
async function extractFrames(
|
|
101
|
+
mediaPath: string,
|
|
102
|
+
range: QuickLookRange,
|
|
103
|
+
directory: string,
|
|
104
|
+
): Promise<Array<{ timestampSecs: number; file: string }>> {
|
|
105
|
+
// Spend the frame budget across the whole window, but never sample faster
|
|
106
|
+
// than a viewer could distinguish -- past a few frames a second, consecutive
|
|
107
|
+
// frames are the same moment and cost fidelity elsewhere for nothing.
|
|
108
|
+
const span = Math.max(0.5, range.endSecs - range.startSecs);
|
|
109
|
+
const fps = Math.min(MAX_FPS, MAX_FRAMES_PER_LOOK / span);
|
|
110
|
+
await runFfmpeg([
|
|
111
|
+
'-nostdin',
|
|
112
|
+
'-hide_banner',
|
|
113
|
+
'-loglevel',
|
|
114
|
+
'error',
|
|
115
|
+
'-ss',
|
|
116
|
+
String(range.startSecs),
|
|
117
|
+
'-to',
|
|
118
|
+
String(range.endSecs),
|
|
119
|
+
'-i',
|
|
120
|
+
mediaPath,
|
|
121
|
+
'-vf',
|
|
122
|
+
`fps=${fps.toFixed(3)},scale=${FRAME_WIDTH}:-2`,
|
|
123
|
+
'-q:v',
|
|
124
|
+
'3',
|
|
125
|
+
'-frames:v',
|
|
126
|
+
String(MAX_FRAMES_PER_LOOK),
|
|
127
|
+
path.join(directory, 'f_%04d.jpg'),
|
|
128
|
+
]);
|
|
129
|
+
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.jpg')).sort();
|
|
130
|
+
return names.map((name, index) => ({
|
|
131
|
+
timestampSecs: range.startSecs + index / fps,
|
|
132
|
+
file: path.join(directory, name),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseFinding(raw: string): {
|
|
137
|
+
found?: string;
|
|
138
|
+
read?: unknown;
|
|
139
|
+
confidence?: unknown;
|
|
140
|
+
settlesQuestion?: unknown;
|
|
141
|
+
coverageComplete?: unknown;
|
|
142
|
+
} {
|
|
143
|
+
const text = raw
|
|
144
|
+
.trim()
|
|
145
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
146
|
+
.replace(/```\s*$/, '')
|
|
147
|
+
.trim();
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(text);
|
|
150
|
+
} catch {
|
|
151
|
+
const start = text.indexOf('{');
|
|
152
|
+
if (start >= 0) {
|
|
153
|
+
// A reader may prefix a short acknowledgement before an otherwise valid
|
|
154
|
+
// object. Decoding the first object is cheaper than a second full look.
|
|
155
|
+
for (let end = text.length; end > start; end -= 1) {
|
|
156
|
+
if (text[end - 1] !== '}') continue;
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(text.slice(start, end));
|
|
159
|
+
} catch {
|
|
160
|
+
/* keep shrinking */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Not JSON at all: the prose is still the reader's answer.
|
|
165
|
+
return { found: text.slice(0, 1_200) };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function lookAtRange(input: {
|
|
170
|
+
mediaPath: string;
|
|
171
|
+
range: QuickLookRange;
|
|
172
|
+
question: string;
|
|
173
|
+
transcript?: string;
|
|
174
|
+
model: ReturnType<typeof createChatModel>;
|
|
175
|
+
signal?: AbortSignal;
|
|
176
|
+
}): Promise<QuickLookFinding> {
|
|
177
|
+
const startedAt = Date.now();
|
|
178
|
+
const at = `${timecode(input.range.startSecs)}–${timecode(input.range.endSecs)}`;
|
|
179
|
+
const base = {
|
|
180
|
+
range: { startSecs: input.range.startSecs, endSecs: input.range.endSecs },
|
|
181
|
+
at,
|
|
182
|
+
read: [] as string[],
|
|
183
|
+
confidence: 'low' as const,
|
|
184
|
+
settlesQuestion: false,
|
|
185
|
+
};
|
|
186
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'larkup-quick-look-'));
|
|
187
|
+
try {
|
|
188
|
+
const frames = await extractFrames(input.mediaPath, input.range, directory);
|
|
189
|
+
if (frames.length === 0) {
|
|
190
|
+
return {
|
|
191
|
+
...base,
|
|
192
|
+
found: '',
|
|
193
|
+
frameCount: 0,
|
|
194
|
+
elapsedMs: Date.now() - startedAt,
|
|
195
|
+
error: 'No frames could be read from this range.',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const content: Array<Record<string, unknown>> = [];
|
|
199
|
+
for (const frame of frames) {
|
|
200
|
+
content.push({ type: 'text', text: `@${timecode(frame.timestampSecs)}` });
|
|
201
|
+
content.push({
|
|
202
|
+
type: 'file',
|
|
203
|
+
mediaType: 'image/jpeg',
|
|
204
|
+
data: `data:image/jpeg;base64,${(await fs.readFile(frame.file)).toString('base64')}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
content.push({
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: [
|
|
210
|
+
'These are consecutive frames from one continuous stretch of a video, in order.',
|
|
211
|
+
`Question being answered: ${input.question}`,
|
|
212
|
+
input.range.lookingFor ? `What to establish here: ${input.range.lookingFor}` : '',
|
|
213
|
+
input.transcript
|
|
214
|
+
? `What is said during this stretch: ${input.transcript.slice(0, 4_000)}`
|
|
215
|
+
: '',
|
|
216
|
+
'',
|
|
217
|
+
'Read every informative piece of on-screen text exactly, including anything that appears',
|
|
218
|
+
'only briefly, and say what each is attached to. On-screen text and speech are legitimate',
|
|
219
|
+
'ways to identify people and things -- use them, and say what established an identity.',
|
|
220
|
+
'Track what changes across the frames; a value that differs between two frames is an event.',
|
|
221
|
+
'A displayed value can change and then change back: a correction, a reversal, a review, a',
|
|
222
|
+
'retraction, an undo. When that happens, report the value it SETTLES on last, and say the',
|
|
223
|
+
'earlier value appeared and was reverted -- do not report a value that was taken back as',
|
|
224
|
+
'though it stood. Read the last frames especially carefully for this.',
|
|
225
|
+
'Mark anything you conclude rather than read with "(inferred)". Never invent a name, number,',
|
|
226
|
+
'or value you did not see or hear. Any name or exact value you state must also appear in',
|
|
227
|
+
'your "read" list, quoted as it appeared; if you cannot quote it, you did not read it, so',
|
|
228
|
+
'describe what you saw without it. If these frames genuinely do not settle the question,',
|
|
229
|
+
'say what is missing rather than filling it in, and set "settlesQuestion" to false. Set it',
|
|
230
|
+
'to true only when the information in these frames and aligned speech is sufficient to',
|
|
231
|
+
'answer the exact question. Related evidence alone is not a settled answer.',
|
|
232
|
+
'If the question asks about each, every, both, or all visible subjects, use the clearest',
|
|
233
|
+
'group frame and enumerate every subject separately in stable left-to-right order. Do not',
|
|
234
|
+
'set "coverageComplete" or "settlesQuestion" true if even one requested subject is omitted.',
|
|
235
|
+
'A social handle or nearby label identifies a person only when its placement unmistakably',
|
|
236
|
+
'attaches it to that person; otherwise describe them by position and visible appearance.',
|
|
237
|
+
'For a group-appearance question, answer positionally and do not use social handles as',
|
|
238
|
+
'person identities; a handle is weaker evidence than the visible left-to-right ordering.',
|
|
239
|
+
'',
|
|
240
|
+
'Return JSON only, and keep it short:',
|
|
241
|
+
'{"settlesQuestion":true|false,"coverageComplete":true|false,',
|
|
242
|
+
'"found":"what you established, 1-3 sentences",',
|
|
243
|
+
'"confidence":"high|medium|low",',
|
|
244
|
+
'"read":["up to 5 exact short quotes of text you actually read on screen"]}',
|
|
245
|
+
]
|
|
246
|
+
.filter(Boolean)
|
|
247
|
+
.join('\n'),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const { text, usage } = await generateText({
|
|
251
|
+
model: input.model as Parameters<typeof generateText>[0]['model'],
|
|
252
|
+
abortSignal: input.signal,
|
|
253
|
+
messages: [{ role: 'user', content: content as never }],
|
|
254
|
+
});
|
|
255
|
+
const parsed = parseFinding(text ?? '');
|
|
256
|
+
void trackUsageEvent({
|
|
257
|
+
type: 'media_processing',
|
|
258
|
+
mediaType: 'video',
|
|
259
|
+
mediaOperation: 'inspection',
|
|
260
|
+
frameCount: frames.length,
|
|
261
|
+
promptTokens: usage?.inputTokens ?? 0,
|
|
262
|
+
completionTokens: usage?.outputTokens ?? 0,
|
|
263
|
+
totalTokens: usage?.totalTokens ?? 0,
|
|
264
|
+
latencyMs: Date.now() - startedAt,
|
|
265
|
+
timestamp: new Date().toISOString(),
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
...base,
|
|
269
|
+
found: typeof parsed.found === 'string' ? parsed.found : '',
|
|
270
|
+
settlesQuestion: parsed.settlesQuestion === true,
|
|
271
|
+
coverageComplete:
|
|
272
|
+
typeof parsed.coverageComplete === 'boolean' ? parsed.coverageComplete : undefined,
|
|
273
|
+
read: Array.isArray(parsed.read)
|
|
274
|
+
? parsed.read.filter((item): item is string => typeof item === 'string').slice(0, 5)
|
|
275
|
+
: [],
|
|
276
|
+
confidence:
|
|
277
|
+
parsed.confidence === 'high' || parsed.confidence === 'medium' ? parsed.confidence : 'low',
|
|
278
|
+
frameCount: frames.length,
|
|
279
|
+
elapsedMs: Date.now() - startedAt,
|
|
280
|
+
};
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return {
|
|
283
|
+
...base,
|
|
284
|
+
found: '',
|
|
285
|
+
frameCount: 0,
|
|
286
|
+
elapsedMs: Date.now() - startedAt,
|
|
287
|
+
error: error instanceof Error ? error.message : 'The bounded re-watch failed.',
|
|
288
|
+
};
|
|
289
|
+
} finally {
|
|
290
|
+
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Re-watches each range and returns what it established. Ranges are independent,
|
|
296
|
+
* so they run together: a question needing three separate moments checked costs
|
|
297
|
+
* about the same wall time as one.
|
|
298
|
+
*/
|
|
299
|
+
export async function quickLook(input: {
|
|
300
|
+
mediaPath: string;
|
|
301
|
+
question: string;
|
|
302
|
+
ranges: QuickLookRange[];
|
|
303
|
+
/** Speech aligned to each range, when the caller already holds it. */
|
|
304
|
+
transcriptFor?: (range: QuickLookRange) => string | undefined;
|
|
305
|
+
signal?: AbortSignal;
|
|
306
|
+
}): Promise<QuickLookFinding[]> {
|
|
307
|
+
const ranges = input.ranges
|
|
308
|
+
.filter((range) => Number.isFinite(range.startSecs) && range.endSecs > range.startSecs)
|
|
309
|
+
.slice(0, 4);
|
|
310
|
+
if (ranges.length === 0) return [];
|
|
311
|
+
const config = await readConfig();
|
|
312
|
+
const resolved = resolveConfiguredVisionModel(config, await getModelsByType('language'));
|
|
313
|
+
const model = createChatModel(
|
|
314
|
+
resolved.provider,
|
|
315
|
+
resolved.modelId,
|
|
316
|
+
resolved.apiKey,
|
|
317
|
+
config.customVisionModels,
|
|
318
|
+
);
|
|
319
|
+
return Promise.all(
|
|
320
|
+
ranges.map((range) =>
|
|
321
|
+
lookAtRange({
|
|
322
|
+
mediaPath: input.mediaPath,
|
|
323
|
+
range,
|
|
324
|
+
question: input.question,
|
|
325
|
+
transcript: input.transcriptFor?.(range),
|
|
326
|
+
model,
|
|
327
|
+
signal: input.signal,
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** True when a re-watch produced something an answer can actually rest on. */
|
|
334
|
+
export function isUsableFinding(finding: QuickLookFinding): boolean {
|
|
335
|
+
return (
|
|
336
|
+
!finding.error &&
|
|
337
|
+
finding.settlesQuestion &&
|
|
338
|
+
finding.coverageComplete !== false &&
|
|
339
|
+
finding.found.trim().length > 0
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What ffmpeg should open for this asset.
|
|
345
|
+
*
|
|
346
|
+
* A presigned URL is preferred over downloading: with `-ss` ahead of `-i`,
|
|
347
|
+
* ffmpeg range-requests only the bytes around the window, so re-watching thirty
|
|
348
|
+
* seconds of a long recording never transfers the whole file. Falling back to a
|
|
349
|
+
* full retrieve keeps assets on storage backends without presigning usable.
|
|
350
|
+
*/
|
|
351
|
+
export async function resolveQuickLookSource(
|
|
352
|
+
storageUri: string,
|
|
353
|
+
fileName: string,
|
|
354
|
+
): Promise<{ mediaPath: string; cleanup: () => Promise<void> }> {
|
|
355
|
+
const { createStorageProvider } = await import('@larkup/marketplace/storage');
|
|
356
|
+
const storage = createStorageProvider();
|
|
357
|
+
const local = await storage.resolvePath?.(storageUri).catch(() => undefined);
|
|
358
|
+
if (local) return { mediaPath: local, cleanup: async () => undefined };
|
|
359
|
+
const readUrl = await storage.getReadUrl?.(storageUri, 3_600).catch(() => undefined);
|
|
360
|
+
if (readUrl) return { mediaPath: readUrl, cleanup: async () => undefined };
|
|
361
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'larkup-quick-source-'));
|
|
362
|
+
const mediaPath = path.join(directory, `source.${fileName.split('.').pop() || 'mp4'}`);
|
|
363
|
+
await fs.writeFile(mediaPath, await storage.retrieve(storageUri));
|
|
364
|
+
return {
|
|
365
|
+
mediaPath,
|
|
366
|
+
cleanup: () => fs.rm(directory, { recursive: true, force: true }).catch(() => undefined),
|
|
367
|
+
};
|
|
368
|
+
}
|