@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,3563 @@
|
|
|
1
|
+
import { workspaceServerUrl, workspaceServerFetch as fetch } from '../../../runtime-host';
|
|
2
|
+
import { workspaceHost } from '@larkup/core/workspace-host';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { jsonSchema, tool } from 'ai';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type {
|
|
7
|
+
AgentToolDefinition,
|
|
8
|
+
AgentToolExecutionContext,
|
|
9
|
+
AgentToolHandler,
|
|
10
|
+
} from '@larkup/marketplace/extension';
|
|
11
|
+
import { readConfig } from '@larkup/core/config-store';
|
|
12
|
+
import { createAdapter } from '@larkup/vector-stores/factory';
|
|
13
|
+
import { embedQuery } from '@larkup/core/indexing/embedder';
|
|
14
|
+
import { runWithProject } from '@larkup/core/project-store';
|
|
15
|
+
import {
|
|
16
|
+
getTabularDataset,
|
|
17
|
+
listTabularDatasets,
|
|
18
|
+
queryTabular,
|
|
19
|
+
resolveTabularDatasetGroups,
|
|
20
|
+
} from '@larkup/core/tabular-store';
|
|
21
|
+
import {
|
|
22
|
+
getCorpusDocuments,
|
|
23
|
+
exportCorpusAsCSV,
|
|
24
|
+
exportCorpusAsJSONL,
|
|
25
|
+
type CorpusFilter,
|
|
26
|
+
} from '@larkup/core/corpus-retriever';
|
|
27
|
+
import { workspaceSandbox } from '../../../runtime-host';
|
|
28
|
+
import type { SandboxBackend, SandboxFile } from '@larkup/sandbox/types';
|
|
29
|
+
import { applyFieldEdits, applyContentEdits } from '@larkup/tool-doc-editor';
|
|
30
|
+
import { loadTool } from '@larkup/marketplace/loader';
|
|
31
|
+
import { getInstalledTools } from '@larkup/marketplace/installer';
|
|
32
|
+
import { readDocuments } from '@larkup/core/documents-store';
|
|
33
|
+
import { readGroups } from '@larkup/core/groups-store';
|
|
34
|
+
import { readMediaAssets } from '@larkup/core/media-store';
|
|
35
|
+
import {
|
|
36
|
+
filterDocumentsAvailableToAssistant,
|
|
37
|
+
filterGroupOwnedDataAvailableToAssistant,
|
|
38
|
+
filterMediaAssetsAvailableToAssistant,
|
|
39
|
+
} from '@larkup/core/assistant-data-scope';
|
|
40
|
+
import {
|
|
41
|
+
searchVideoKnowledge,
|
|
42
|
+
videoKnowledgeRetrievalCapabilities,
|
|
43
|
+
} from '@larkup/core/video-knowledge/retrieval';
|
|
44
|
+
import {
|
|
45
|
+
aggregateVideoKnowledge,
|
|
46
|
+
scanVideoKnowledge,
|
|
47
|
+
} from '@larkup/core/video-knowledge/query-engine';
|
|
48
|
+
import { planVideoInvestigation as buildVideoInvestigationPlan } from '@larkup/core/video-knowledge/investigation';
|
|
49
|
+
import { verifyMediaEvidence } from '@larkup/core/video-knowledge/verification';
|
|
50
|
+
import {
|
|
51
|
+
planVideoQuestion,
|
|
52
|
+
type VideoInvestigationDirective,
|
|
53
|
+
} from '@larkup/core/video-knowledge/query-planner';
|
|
54
|
+
import { decideInspection, LIMITS } from '@larkup/core/video-knowledge/inspection-policy';
|
|
55
|
+
import { chunkTimeRange } from '@larkup/core/video-knowledge/inspection-chunking';
|
|
56
|
+
import { expandInvestigationRange } from '@larkup/core/video-knowledge/range-expansion';
|
|
57
|
+
import { queryVideoEmbeddings } from '@larkup/core/video-knowledge/video-embedding-index';
|
|
58
|
+
import {
|
|
59
|
+
searchSemanticEvidence,
|
|
60
|
+
semanticScoresByEvidenceId,
|
|
61
|
+
} from '@larkup/core/video-knowledge/evidence-semantic-index';
|
|
62
|
+
import {
|
|
63
|
+
formatTimecode,
|
|
64
|
+
fuseFocusRanges,
|
|
65
|
+
type FocusSignal,
|
|
66
|
+
} from '@larkup/core/video-knowledge/focus-ranges';
|
|
67
|
+
import { trackUsageEvent } from '@larkup/core/analytics-store';
|
|
68
|
+
import { cacheImageAnalysis, getCachedImageAnalysis } from '@larkup/core/image-analysis-cache';
|
|
69
|
+
import {
|
|
70
|
+
normalizeMediaCitationRange,
|
|
71
|
+
queryAwareExcerpt,
|
|
72
|
+
timestampMediaUrl,
|
|
73
|
+
} from '../../../lib/media/knowledge';
|
|
74
|
+
import { hasVideoIntelligenceCapacity } from '../../../lib/media/video-intelligence-adapter';
|
|
75
|
+
import {
|
|
76
|
+
isUsableFinding,
|
|
77
|
+
quickLook,
|
|
78
|
+
resolveQuickLookSource,
|
|
79
|
+
type QuickLookFinding,
|
|
80
|
+
} from '../../../lib/media/video/quick-look';
|
|
81
|
+
import {
|
|
82
|
+
buildQueryTermMatchers,
|
|
83
|
+
countMatchesIn,
|
|
84
|
+
rankKnowledgeHits,
|
|
85
|
+
} from '../../../lib/chat/retrieval-ranking';
|
|
86
|
+
import {
|
|
87
|
+
activeMediaFollowUpResult,
|
|
88
|
+
clearlyTitleMatchedMediaAsset,
|
|
89
|
+
clearlyUnavailableTitleMatchedMediaAsset,
|
|
90
|
+
explicitlySelectedMediaAsset,
|
|
91
|
+
shouldKeepActiveMediaSource,
|
|
92
|
+
} from '../../../lib/chat/media-source-routing';
|
|
93
|
+
import { createTabularVisualization } from '../../../lib/chat/tabular-visualization';
|
|
94
|
+
import { normalizeChartConfigWithEvidence } from '@larkup/core/chart-config';
|
|
95
|
+
import { createDataExport, selectDataExportSource } from '@larkup/core/data-export';
|
|
96
|
+
import { assistantSourceScopeFingerprint } from '../../../lib/chat/assistant-source-scope';
|
|
97
|
+
import { inferTabularPlan } from '../../../lib/chat/tabular-query-plan';
|
|
98
|
+
import {
|
|
99
|
+
resolveTabularDatasetForQuestion,
|
|
100
|
+
selectTabularDatasetForQuestion,
|
|
101
|
+
} from '../../../lib/chat/tabular-dataset-routing';
|
|
102
|
+
import {
|
|
103
|
+
createTabularSandboxFiles,
|
|
104
|
+
remapLegacyMultiDatasetReads,
|
|
105
|
+
} from '../../../lib/chat/tabular-sandbox-files';
|
|
106
|
+
import { leadingMediaAssetId } from '../../../lib/chat/media-retrieval-routing';
|
|
107
|
+
import { findIndexedImageSource } from '../../../lib/chat/visual-routing';
|
|
108
|
+
import { indexedVideoEvidenceIsSufficient } from '../../../lib/chat/video-rag-routing';
|
|
109
|
+
import {
|
|
110
|
+
inspectStoredPdf,
|
|
111
|
+
readStoredPdfBytes,
|
|
112
|
+
renderStoredPdfPage,
|
|
113
|
+
} from '../../../lib/media/pdf-inspection';
|
|
114
|
+
import {
|
|
115
|
+
canonicalMediaSourceUrl,
|
|
116
|
+
newestEquivalentMediaAsset,
|
|
117
|
+
} from '../../../lib/media/source-identity';
|
|
118
|
+
import {
|
|
119
|
+
isDocumentAvailableInVideoRuntime,
|
|
120
|
+
videoRuntimeScopeFromConfig,
|
|
121
|
+
type VideoRuntimeScope,
|
|
122
|
+
} from '@larkup/core/video-runtime-scope';
|
|
123
|
+
import {
|
|
124
|
+
executeEnterpriseTool,
|
|
125
|
+
getEnterpriseTools,
|
|
126
|
+
trackEnterpriseToolUsage,
|
|
127
|
+
} from '../../../lib/enterprise-client';
|
|
128
|
+
|
|
129
|
+
const sandboxAvailabilityCache = new Map<string, { expiresAt: number; ready: boolean }>();
|
|
130
|
+
const hostedSandboxAvailabilityCaches = new WeakMap<object, typeof sandboxAvailabilityCache>();
|
|
131
|
+
|
|
132
|
+
/** A compact, typed value captured from the source (for example a displayed
|
|
133
|
+
* state, total, or counter). Unlike free-form OCR, it can be ranked and
|
|
134
|
+
* cited without mistaking a clock or identifier for the answer. */
|
|
135
|
+
/**
|
|
136
|
+
* Ceiling on all automatic source re-inspection for one chat turn. Past this,
|
|
137
|
+
* the answer is composed from the evidence already gathered rather than
|
|
138
|
+
* holding the turn open for another cold GPU worker.
|
|
139
|
+
*/
|
|
140
|
+
const CHAT_INSPECTION_BUDGET_MS = 150_000;
|
|
141
|
+
/**
|
|
142
|
+
* Ceiling on the bounded re-watch. Its windows run together, so this bounds the
|
|
143
|
+
* whole step rather than each window, and it is deliberately far below
|
|
144
|
+
* `CHAT_INSPECTION_BUDGET_MS`: this path exists to answer while someone waits.
|
|
145
|
+
*/
|
|
146
|
+
const QUICK_LOOK_BUDGET_MS = 60_000;
|
|
147
|
+
/** Semantic ranking enriches RAG but must never hold an interactive lookup hostage. */
|
|
148
|
+
const CHAT_SEMANTIC_LOOKUP_BUDGET_MS = 2_500;
|
|
149
|
+
|
|
150
|
+
function structuredStateFromEvidence(
|
|
151
|
+
evidence: any,
|
|
152
|
+
): { subject: string; property: string; value: string | number } | undefined {
|
|
153
|
+
const payload = evidence?.payload;
|
|
154
|
+
const text = payload && typeof payload === 'object' ? payload.text : undefined;
|
|
155
|
+
if (!text || typeof text !== 'object' || Array.isArray(text)) return undefined;
|
|
156
|
+
if (
|
|
157
|
+
typeof text.subject !== 'string' ||
|
|
158
|
+
typeof text.property !== 'string' ||
|
|
159
|
+
(typeof text.value !== 'string' && typeof text.value !== 'number')
|
|
160
|
+
) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return { subject: text.subject, property: text.property, value: text.value };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function evidenceText(payload: unknown): string {
|
|
167
|
+
if (payload && typeof payload === 'object' && 'text' in payload) {
|
|
168
|
+
const text = (payload as { text?: unknown }).text;
|
|
169
|
+
return typeof text === 'string' ? text : JSON.stringify(text);
|
|
170
|
+
}
|
|
171
|
+
return JSON.stringify(payload) ?? '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Do not offer an unavailable sandbox to the model: a factual answer should
|
|
176
|
+
* still use retrieval or tabular data when the local runtime is unavailable.
|
|
177
|
+
*/
|
|
178
|
+
async function isSandboxReady(config: any): Promise<boolean> {
|
|
179
|
+
const host = workspaceHost();
|
|
180
|
+
let availabilityCache = sandboxAvailabilityCache;
|
|
181
|
+
if (host) {
|
|
182
|
+
// Managed hosts can resolve different credentials behind identical public config.
|
|
183
|
+
availabilityCache = hostedSandboxAvailabilityCaches.get(host) ?? new Map();
|
|
184
|
+
hostedSandboxAvailabilityCaches.set(host, availabilityCache);
|
|
185
|
+
}
|
|
186
|
+
const { backend, credentials } = resolveSandboxConfig(config);
|
|
187
|
+
const cacheKey =
|
|
188
|
+
backend +
|
|
189
|
+
':' +
|
|
190
|
+
createHash('sha256')
|
|
191
|
+
.update(JSON.stringify(credentials ?? {}))
|
|
192
|
+
.digest('hex');
|
|
193
|
+
const cached = availabilityCache.get(cacheKey);
|
|
194
|
+
if (cached && cached.expiresAt > Date.now()) return cached.ready;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const health = await workspaceSandbox({ backend, credentials }).healthCheck();
|
|
198
|
+
const ready = health.status === 'ready';
|
|
199
|
+
availabilityCache.set(cacheKey, { ready, expiresAt: Date.now() + 30_000 });
|
|
200
|
+
return ready;
|
|
201
|
+
} catch {
|
|
202
|
+
availabilityCache.set(cacheKey, { ready: false, expiresAt: Date.now() + 30_000 });
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function documentEditModelOutput({ output }: { output: any }) {
|
|
208
|
+
return {
|
|
209
|
+
type: 'json' as const,
|
|
210
|
+
value: output?.success
|
|
211
|
+
? {
|
|
212
|
+
success: true,
|
|
213
|
+
action: output.action,
|
|
214
|
+
sessionId: output.sessionId,
|
|
215
|
+
updatedFields: output.updatedFields ?? [],
|
|
216
|
+
fileName: output.fileName,
|
|
217
|
+
totalPages: output.totalPages,
|
|
218
|
+
message:
|
|
219
|
+
'The document was updated successfully. Do not repeat the edit unless requested.',
|
|
220
|
+
}
|
|
221
|
+
: {
|
|
222
|
+
success: false,
|
|
223
|
+
error: output?.error ?? 'Document update failed',
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Re-watches candidate windows of an indexed source to settle the current
|
|
230
|
+
* question, and reports only what that reading actually established.
|
|
231
|
+
*
|
|
232
|
+
* Speech already recorded for each window travels with it: a reader that can
|
|
233
|
+
* both see the frames and read what was said over them resolves an identity or
|
|
234
|
+
* a change that neither modality settles alone.
|
|
235
|
+
*/
|
|
236
|
+
async function reWatchSource(input: {
|
|
237
|
+
asset: { id: string; storageUri: string; fileName: string; durationSecs?: number };
|
|
238
|
+
question: string;
|
|
239
|
+
ranges: Array<{ startSecs: number; endSecs: number; label?: string }>;
|
|
240
|
+
subjectName?: string;
|
|
241
|
+
knownEntities?: string[];
|
|
242
|
+
mediaAssetId: string;
|
|
243
|
+
maxWaitMs?: number;
|
|
244
|
+
}): Promise<{ findings: QuickLookFinding[]; elapsedMs: number } | undefined> {
|
|
245
|
+
const startedAt = Date.now();
|
|
246
|
+
let release: (() => Promise<void>) | undefined;
|
|
247
|
+
try {
|
|
248
|
+
const looks = input.ranges.map((range) => ({
|
|
249
|
+
startSecs: Math.max(0, range.startSecs),
|
|
250
|
+
endSecs: Math.min(
|
|
251
|
+
input.asset.durationSecs ?? range.endSecs,
|
|
252
|
+
Math.max(range.startSecs + 5, Math.min(range.endSecs, range.startSecs + 45)),
|
|
253
|
+
),
|
|
254
|
+
lookingFor: [
|
|
255
|
+
input.subjectName
|
|
256
|
+
? `Which visible person is "${input.subjectName}", and what the question asks about them.`
|
|
257
|
+
: range.label,
|
|
258
|
+
input.knownEntities?.length
|
|
259
|
+
? `Known source identities to ground from visible labels or aligned speech, never assume: ${input.knownEntities.join(', ')}.`
|
|
260
|
+
: '',
|
|
261
|
+
]
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.join(' '),
|
|
264
|
+
}));
|
|
265
|
+
const speech = await Promise.all(
|
|
266
|
+
looks.map((range) =>
|
|
267
|
+
searchVideoKnowledge(input.mediaAssetId, '', 12, {
|
|
268
|
+
modalities: ['transcript'],
|
|
269
|
+
minimumRangeDistanceSecs: 0,
|
|
270
|
+
videoDurationSecs: input.asset.durationSecs,
|
|
271
|
+
timeRange: range,
|
|
272
|
+
}).catch(() => []),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
const transcriptByStart = new Map(
|
|
276
|
+
looks.map((range, index) => [
|
|
277
|
+
range.startSecs,
|
|
278
|
+
speech[index]
|
|
279
|
+
.sort((a, b) => a.evidence.timeRange.startSecs - b.evidence.timeRange.startSecs)
|
|
280
|
+
.map((hit) => evidenceText(hit.evidence.payload))
|
|
281
|
+
.filter(Boolean)
|
|
282
|
+
.join(' ')
|
|
283
|
+
.slice(0, 4_000),
|
|
284
|
+
]),
|
|
285
|
+
);
|
|
286
|
+
const source = await resolveQuickLookSource(input.asset.storageUri, input.asset.fileName);
|
|
287
|
+
release = source.cleanup;
|
|
288
|
+
const findings = await quickLook({
|
|
289
|
+
mediaPath: source.mediaPath,
|
|
290
|
+
question: input.question,
|
|
291
|
+
ranges: looks,
|
|
292
|
+
transcriptFor: (range) => transcriptByStart.get(range.startSecs) || undefined,
|
|
293
|
+
signal: AbortSignal.timeout(
|
|
294
|
+
Math.max(1_000, Math.min(QUICK_LOOK_BUDGET_MS, input.maxWaitMs ?? QUICK_LOOK_BUDGET_MS)),
|
|
295
|
+
),
|
|
296
|
+
});
|
|
297
|
+
const usable = findings.filter(isUsableFinding);
|
|
298
|
+
return usable.length > 0 ? { findings: usable, elapsedMs: Date.now() - startedAt } : undefined;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.warn('[chat] bounded re-watch of the source failed:', error);
|
|
301
|
+
return undefined;
|
|
302
|
+
} finally {
|
|
303
|
+
await release?.();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Retrieves documents from the knowledge base
|
|
309
|
+
*/
|
|
310
|
+
function isMediaAssetAvailableInRuntime(
|
|
311
|
+
asset: { videoRuntimeScope?: unknown },
|
|
312
|
+
activeScope: VideoRuntimeScope,
|
|
313
|
+
) {
|
|
314
|
+
return asset.videoRuntimeScope !== 'local' && asset.videoRuntimeScope !== 'cloud'
|
|
315
|
+
? true
|
|
316
|
+
: asset.videoRuntimeScope === activeScope;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function uniqueCompletedMediaFallback(
|
|
320
|
+
query: string,
|
|
321
|
+
activeScope: VideoRuntimeScope,
|
|
322
|
+
mediaAssets: Awaited<ReturnType<typeof readMediaAssets>>,
|
|
323
|
+
groups: Awaited<ReturnType<typeof readGroups>>,
|
|
324
|
+
) {
|
|
325
|
+
const assets = filterMediaAssetsAvailableToAssistant(mediaAssets, groups).filter(
|
|
326
|
+
(asset) =>
|
|
327
|
+
asset.processingStatus === 'completed' &&
|
|
328
|
+
Boolean(asset.activeVideoKnowledgeRevisionId) &&
|
|
329
|
+
isMediaAssetAvailableInRuntime(asset, activeScope) &&
|
|
330
|
+
(asset.type === 'video' || asset.type === 'audio'),
|
|
331
|
+
);
|
|
332
|
+
const allCompletedAssets = mediaAssets.filter(
|
|
333
|
+
(asset) =>
|
|
334
|
+
asset.processingStatus === 'completed' &&
|
|
335
|
+
Boolean(asset.activeVideoKnowledgeRevisionId) &&
|
|
336
|
+
isMediaAssetAvailableInRuntime(asset, activeScope) &&
|
|
337
|
+
(asset.type === 'video' || asset.type === 'audio'),
|
|
338
|
+
);
|
|
339
|
+
const unavailableTitleMatch = clearlyUnavailableTitleMatchedMediaAsset(
|
|
340
|
+
query,
|
|
341
|
+
assets,
|
|
342
|
+
allCompletedAssets,
|
|
343
|
+
);
|
|
344
|
+
if (unavailableTitleMatch) {
|
|
345
|
+
return {
|
|
346
|
+
query,
|
|
347
|
+
hits: [],
|
|
348
|
+
error: `The named media source "${unavailableTitleMatch.fileName}" is not available to Assistant chat. Enable its data group under Data → Groups, then ask again.`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// A retrieval miss must not strand a single, unambiguous active media
|
|
352
|
+
// source. With several sources the host deliberately stays neutral and
|
|
353
|
+
// lets ordinary retrieval disambiguate rather than guessing the asset.
|
|
354
|
+
if (assets.length === 1) {
|
|
355
|
+
return {
|
|
356
|
+
query,
|
|
357
|
+
hits: [],
|
|
358
|
+
videoEvidence: {
|
|
359
|
+
mediaAssetId: assets[0].id,
|
|
360
|
+
retrievalFallback: 'single-active-media-source',
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// A vector index can be temporarily unavailable while a completed video
|
|
365
|
+
// remains perfectly usable through its evidence store. Recover an asset
|
|
366
|
+
// only when its user-visible title has a clear lexical lead; otherwise
|
|
367
|
+
// preserve the neutral no-result behavior for genuinely ambiguous media.
|
|
368
|
+
const titleMatch = clearlyTitleMatchedMediaAsset(query, assets);
|
|
369
|
+
if (!titleMatch) return { query, hits: [] };
|
|
370
|
+
return {
|
|
371
|
+
query,
|
|
372
|
+
hits: [],
|
|
373
|
+
videoEvidence: {
|
|
374
|
+
mediaAssetId: titleMatch.id,
|
|
375
|
+
retrievalFallback: 'title-matched-media-source',
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function queryKnowledgeBase(query: string, topK: number, projectId: string | null) {
|
|
381
|
+
const doRetrieve = async () => {
|
|
382
|
+
const config = await readConfig();
|
|
383
|
+
const activeVideoRuntimeScope = videoRuntimeScopeFromConfig(config);
|
|
384
|
+
// Retrieve a small diverse pool. Sending a very large candidate set into
|
|
385
|
+
// the answer model noticeably delays the first streamed token without
|
|
386
|
+
// helping the user-facing source list.
|
|
387
|
+
// Keep a wider candidate pool internal, then return only the compact,
|
|
388
|
+
// diverse evidence set to the model. This substantially improves recall
|
|
389
|
+
// for exact labels in visual PDFs without inflating prompt size.
|
|
390
|
+
const candidateCount = Math.min(Math.max(topK * 16, 64), 100);
|
|
391
|
+
// Document status is more precise than the last index-run summary: a later
|
|
392
|
+
// partial run can be marked failed even though earlier uploaded and scraped
|
|
393
|
+
// documents were successfully indexed and remain queryable.
|
|
394
|
+
const [allDocuments, groups, mediaAssets] = await Promise.all([
|
|
395
|
+
readDocuments(),
|
|
396
|
+
readGroups(),
|
|
397
|
+
readMediaAssets(),
|
|
398
|
+
]);
|
|
399
|
+
const documents = filterDocumentsAvailableToAssistant(allDocuments, groups).filter((document) =>
|
|
400
|
+
isDocumentAvailableInVideoRuntime(document, activeVideoRuntimeScope),
|
|
401
|
+
);
|
|
402
|
+
const availableMediaAssets = filterMediaAssetsAvailableToAssistant(mediaAssets, groups);
|
|
403
|
+
const activeMediaAssets = availableMediaAssets.filter(
|
|
404
|
+
(asset) =>
|
|
405
|
+
asset.processingStatus === 'completed' &&
|
|
406
|
+
Boolean(asset.activeVideoKnowledgeRevisionId) &&
|
|
407
|
+
isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope) &&
|
|
408
|
+
(asset.type === 'video' || asset.type === 'audio'),
|
|
409
|
+
);
|
|
410
|
+
const directTitleMatchedAsset = explicitlySelectedMediaAsset(query, activeMediaAssets);
|
|
411
|
+
const selectedMediaAsset = directTitleMatchedAsset
|
|
412
|
+
? newestEquivalentMediaAsset(directTitleMatchedAsset, availableMediaAssets)
|
|
413
|
+
: undefined;
|
|
414
|
+
if (!documents.some((document) => document.status === 'indexed'))
|
|
415
|
+
return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const host = workspaceHost();
|
|
419
|
+
const vector = host ? [] : await embedQuery(config, query);
|
|
420
|
+
const adapter = host
|
|
421
|
+
? {
|
|
422
|
+
query: (_vector: number[], count: number, text: string) =>
|
|
423
|
+
host.queryKnowledge(text, count),
|
|
424
|
+
}
|
|
425
|
+
: await createAdapter(config);
|
|
426
|
+
const indexedDocumentIds = new Set(documents.map((document) => document.id));
|
|
427
|
+
const hits = (await adapter.query(vector, candidateCount, query)).filter((hit: any) =>
|
|
428
|
+
indexedDocumentIds.has(hit.documentId),
|
|
429
|
+
);
|
|
430
|
+
// Pure vector similarity can leave out a document whose title is an
|
|
431
|
+
// obvious literal match for the query -- a meta-referential question
|
|
432
|
+
// ("what happened in my test video?") has little semantic overlap
|
|
433
|
+
// with the video's actual transcript content, so a document titled
|
|
434
|
+
// test-video.mp4 can miss the top-100 candidate pool entirely even
|
|
435
|
+
// though "test video" names it almost verbatim. Reranking can only
|
|
436
|
+
// reorder what's already in the pool, so supplement it here instead.
|
|
437
|
+
const queryMatchers = buildQueryTermMatchers(query);
|
|
438
|
+
const requiredTitleMatches = Math.min(2, queryMatchers.length);
|
|
439
|
+
if (requiredTitleMatches > 0) {
|
|
440
|
+
const presentDocumentIds = new Set(hits.map((hit: any) => hit.documentId));
|
|
441
|
+
const titleMatched = documents
|
|
442
|
+
.filter(
|
|
443
|
+
(document) => document.status === 'indexed' && !presentDocumentIds.has(document.id),
|
|
444
|
+
)
|
|
445
|
+
.map((document) => ({ document, matches: countMatchesIn(queryMatchers, document.title) }))
|
|
446
|
+
.filter(({ matches }) => matches >= requiredTitleMatches)
|
|
447
|
+
.sort((left, right) => right.matches - left.matches)
|
|
448
|
+
.slice(0, 8)
|
|
449
|
+
.map(({ document }) => ({
|
|
450
|
+
id: document.id,
|
|
451
|
+
documentId: document.id,
|
|
452
|
+
score: 0,
|
|
453
|
+
title: document.title,
|
|
454
|
+
text: document.content.slice(0, 1_200),
|
|
455
|
+
url: document.url,
|
|
456
|
+
metadata: document.metadata,
|
|
457
|
+
}));
|
|
458
|
+
if (titleMatched.length > 0) hits.push(...titleMatched);
|
|
459
|
+
}
|
|
460
|
+
const formatted = await formatKnowledgeHits(
|
|
461
|
+
query,
|
|
462
|
+
hits,
|
|
463
|
+
topK,
|
|
464
|
+
documents,
|
|
465
|
+
availableMediaAssets,
|
|
466
|
+
);
|
|
467
|
+
if (formatted.hits.length === 0)
|
|
468
|
+
return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
|
|
469
|
+
// A vector row created before the evidence-first media revision may
|
|
470
|
+
// carry only a document id. Resolve it against the active media source
|
|
471
|
+
// here, so the generic chat router can force whichever installed
|
|
472
|
+
// evidence-query action owns that source before a model answers.
|
|
473
|
+
const resolvedMediaAssetId =
|
|
474
|
+
selectedMediaAsset?.id ??
|
|
475
|
+
(await findEvidenceFirstVideoAssetId(formatted, activeVideoRuntimeScope));
|
|
476
|
+
const resolvedMediaAsset = resolvedMediaAssetId
|
|
477
|
+
? availableMediaAssets.find((asset) => asset.id === resolvedMediaAssetId)
|
|
478
|
+
: undefined;
|
|
479
|
+
const mediaAssetId = resolvedMediaAsset
|
|
480
|
+
? newestEquivalentMediaAsset(resolvedMediaAsset, availableMediaAssets).id
|
|
481
|
+
: resolvedMediaAssetId;
|
|
482
|
+
// Once retrieval has selected an active evidence-first media source,
|
|
483
|
+
// its tool is the authority for this turn. Keeping secondary clips in
|
|
484
|
+
// the answer context can both confuse source attribution and spend the
|
|
485
|
+
// answer model's budget on unrelated recordings.
|
|
486
|
+
const authoritativeMediaAsset = selectedMediaAsset ?? resolvedMediaAsset;
|
|
487
|
+
const scopedHits = authoritativeMediaAsset
|
|
488
|
+
? formatted.hits.filter(
|
|
489
|
+
(hit: any) =>
|
|
490
|
+
hit.metadata?.mediaAssetId === authoritativeMediaAsset.id ||
|
|
491
|
+
authoritativeMediaAsset.documentIds.includes(String(hit.documentId ?? '')),
|
|
492
|
+
)
|
|
493
|
+
: formatted.hits;
|
|
494
|
+
const authoritativeHits = mediaAssetId
|
|
495
|
+
? scopedHits.map((hit: any) =>
|
|
496
|
+
hit.metadata?.mediaAssetId
|
|
497
|
+
? { ...hit, metadata: { ...hit.metadata, mediaAssetId } }
|
|
498
|
+
: hit,
|
|
499
|
+
)
|
|
500
|
+
: scopedHits;
|
|
501
|
+
return mediaAssetId
|
|
502
|
+
? {
|
|
503
|
+
...formatted,
|
|
504
|
+
hits: authoritativeHits,
|
|
505
|
+
videoEvidence: { mediaAssetId, retrievalFallback: 'active-media-source' },
|
|
506
|
+
}
|
|
507
|
+
: formatted;
|
|
508
|
+
} catch (error) {
|
|
509
|
+
// A retrieval outage must not become a model/tool failure that exposes
|
|
510
|
+
// implementation details. The chat policy handles an empty result with
|
|
511
|
+
// its normal, user-facing uncertainty response.
|
|
512
|
+
console.error('[chat] knowledge-base retrieval failed:', error);
|
|
513
|
+
return uniqueCompletedMediaFallback(query, activeVideoRuntimeScope, mediaAssets, groups);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
return projectId ? runWithProject(projectId, doRetrieve) : doRetrieve();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function formatKnowledgeHits(
|
|
521
|
+
query: string,
|
|
522
|
+
rawHits: any[],
|
|
523
|
+
topK: number,
|
|
524
|
+
indexedDocuments?: Awaited<ReturnType<typeof readDocuments>>,
|
|
525
|
+
indexedMediaAssets?: Awaited<ReturnType<typeof readMediaAssets>>,
|
|
526
|
+
) {
|
|
527
|
+
const [storedDocuments, storedMediaAssets] = await Promise.all([
|
|
528
|
+
readDocuments(),
|
|
529
|
+
readMediaAssets(),
|
|
530
|
+
]);
|
|
531
|
+
const documents = indexedDocuments ?? storedDocuments;
|
|
532
|
+
const mediaAssets = indexedMediaAssets ?? storedMediaAssets;
|
|
533
|
+
const documentsById = new Map(documents.map((document) => [document.id, document]));
|
|
534
|
+
const activeMediaDocumentIds = new Set(mediaAssets.flatMap((asset) => asset.documentIds));
|
|
535
|
+
const hydrated = rankKnowledgeHits(
|
|
536
|
+
query,
|
|
537
|
+
rawHits.map((hit) => {
|
|
538
|
+
const document = documentsById.get(hit.documentId);
|
|
539
|
+
return {
|
|
540
|
+
...hit,
|
|
541
|
+
title: hit.title || document?.title || 'Untitled',
|
|
542
|
+
url: hit.url || document?.url || '',
|
|
543
|
+
text: hit.text || document?.content || '',
|
|
544
|
+
metadata: { ...document?.metadata, ...hit.metadata },
|
|
545
|
+
};
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const selected: any[] = [];
|
|
550
|
+
const hitsPerDocument = new Map<string, number>();
|
|
551
|
+
const selectedEvidenceSources = new Set<string>();
|
|
552
|
+
for (const hit of hydrated) {
|
|
553
|
+
// Never resurrect a vector whose document was deleted or which is no
|
|
554
|
+
// longer indexed. This matters after a mixed scrape/upload index retry.
|
|
555
|
+
if (
|
|
556
|
+
!documentsById.has(hit.documentId) ||
|
|
557
|
+
documentsById.get(hit.documentId)?.status !== 'indexed'
|
|
558
|
+
) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (
|
|
562
|
+
hit.metadata?.mediaAssetId &&
|
|
563
|
+
(!documentsById.has(hit.documentId) || !activeMediaDocumentIds.has(hit.documentId))
|
|
564
|
+
) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
// A re-upload or re-index can leave several assets for the same source.
|
|
568
|
+
// Return one active evidence source per canonical upload so a generic
|
|
569
|
+
// evidence action is never dispatched in parallel for duplicate copies.
|
|
570
|
+
const mediaAsset = hit.metadata?.mediaAssetId
|
|
571
|
+
? mediaAssets.find((asset) => asset.id === hit.metadata.mediaAssetId)
|
|
572
|
+
: undefined;
|
|
573
|
+
const evidenceSourceKey = mediaAsset?.activeVideoKnowledgeRevisionId
|
|
574
|
+
? mediaAsset.originalUrl || mediaAsset.storageUri || mediaAsset.id
|
|
575
|
+
: undefined;
|
|
576
|
+
if (evidenceSourceKey && selectedEvidenceSources.has(evidenceSourceKey)) continue;
|
|
577
|
+
const seen = hitsPerDocument.get(hit.documentId) ?? 0;
|
|
578
|
+
const isTimestampedMedia =
|
|
579
|
+
hit.metadata?.isMediaSummary === true ||
|
|
580
|
+
hit.metadata?.contentKind === 'multimodal-segment' ||
|
|
581
|
+
hit.metadata?.contentKind === 'audio-transcript-segment';
|
|
582
|
+
// One strong hit per timestamped evidence document leaves room for other
|
|
583
|
+
// moments instead of returning eight chunks from the same summary.
|
|
584
|
+
if (seen >= (isTimestampedMedia ? 1 : 3)) continue;
|
|
585
|
+
selected.push(hit);
|
|
586
|
+
if (evidenceSourceKey) selectedEvidenceSources.add(evidenceSourceKey);
|
|
587
|
+
hitsPerDocument.set(hit.documentId, seen + 1);
|
|
588
|
+
if (selected.length >= topK) break;
|
|
589
|
+
}
|
|
590
|
+
const primaryMediaAssetId = selected.find((hit) => hit.metadata?.mediaAssetId)?.metadata
|
|
591
|
+
.mediaAssetId;
|
|
592
|
+
const mediaDocuments = primaryMediaAssetId
|
|
593
|
+
? documents.filter(
|
|
594
|
+
(document) =>
|
|
595
|
+
document.status === 'indexed' &&
|
|
596
|
+
document.metadata?.mediaAssetId === primaryMediaAssetId &&
|
|
597
|
+
activeMediaDocumentIds.has(document.id),
|
|
598
|
+
)
|
|
599
|
+
: [];
|
|
600
|
+
const timestampedDocuments = mediaDocuments
|
|
601
|
+
.filter((document) => Number.isFinite(Number(document.metadata?.endSecs)))
|
|
602
|
+
.sort(
|
|
603
|
+
(left, right) => Number(left.metadata?.endSecs ?? 0) - Number(right.metadata?.endSecs ?? 0),
|
|
604
|
+
);
|
|
605
|
+
const mediaDuration = Math.max(
|
|
606
|
+
0,
|
|
607
|
+
...timestampedDocuments.map((document) =>
|
|
608
|
+
Number(document.metadata?.durationSecs ?? document.metadata?.endSecs ?? 0),
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
const trailingHorizonSecs = Math.max(10 * 60, Math.min(30 * 60, mediaDuration * 0.2));
|
|
612
|
+
const trailingDocuments = timestampedDocuments.filter(
|
|
613
|
+
(document) => Number(document.metadata?.endSecs ?? 0) >= mediaDuration - trailingHorizonSecs,
|
|
614
|
+
);
|
|
615
|
+
const timestampedEnding = [
|
|
616
|
+
...new Map(trailingDocuments.slice(-4).map((document) => [document.id, document])).values(),
|
|
617
|
+
].sort(
|
|
618
|
+
(left, right) => Number(left.metadata?.endSecs ?? 0) - Number(right.metadata?.endSecs ?? 0),
|
|
619
|
+
);
|
|
620
|
+
const mediaSummary = mediaDocuments.find(
|
|
621
|
+
(document) => document.metadata?.isMediaSummary === true,
|
|
622
|
+
);
|
|
623
|
+
const legacyWholeTimeline =
|
|
624
|
+
timestampedEnding.length === 0
|
|
625
|
+
? mediaDocuments.find((document) => document.metadata?.contentKind === 'multimodal-timeline')
|
|
626
|
+
: undefined;
|
|
627
|
+
// A compact ending context is returned for every primary media match. This
|
|
628
|
+
// avoids depending on a brittle language-specific keyword gate for questions
|
|
629
|
+
// whose answer is only established near the end of a source.
|
|
630
|
+
const endingContext = primaryMediaAssetId
|
|
631
|
+
? [
|
|
632
|
+
...(mediaSummary
|
|
633
|
+
? [
|
|
634
|
+
{
|
|
635
|
+
role: 'summary',
|
|
636
|
+
title: mediaSummary.title,
|
|
637
|
+
url: mediaSummary.url,
|
|
638
|
+
text: queryAwareExcerpt(mediaSummary.content, query, 6_000),
|
|
639
|
+
startSecs: undefined,
|
|
640
|
+
endSecs: undefined,
|
|
641
|
+
},
|
|
642
|
+
]
|
|
643
|
+
: []),
|
|
644
|
+
...timestampedEnding.map((document) => ({
|
|
645
|
+
role: 'ending',
|
|
646
|
+
title: document.title,
|
|
647
|
+
url: document.url,
|
|
648
|
+
text: queryAwareExcerpt(document.content, query, 3_200, true),
|
|
649
|
+
startSecs: document.metadata?.startSecs,
|
|
650
|
+
endSecs: document.metadata?.endSecs,
|
|
651
|
+
})),
|
|
652
|
+
...(legacyWholeTimeline
|
|
653
|
+
? [
|
|
654
|
+
{
|
|
655
|
+
role: 'ending',
|
|
656
|
+
title: legacyWholeTimeline.title,
|
|
657
|
+
url: legacyWholeTimeline.url,
|
|
658
|
+
// Legacy media was one giant document. Its ending is the tail,
|
|
659
|
+
// never the beginning.
|
|
660
|
+
text: queryAwareExcerpt(legacyWholeTimeline.content, query, 4_000, true),
|
|
661
|
+
startSecs: undefined,
|
|
662
|
+
endSecs: legacyWholeTimeline.metadata?.durationSecs,
|
|
663
|
+
},
|
|
664
|
+
]
|
|
665
|
+
: []),
|
|
666
|
+
]
|
|
667
|
+
: undefined;
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
query,
|
|
671
|
+
hits: selected.map((hit, hitIndex) => {
|
|
672
|
+
// Text chunks inherit their source-document ID but not necessarily the
|
|
673
|
+
// document-level PDF image list. Recover it here so a question that
|
|
674
|
+
// retrieves a routine name in text can still inspect or preview the
|
|
675
|
+
// corresponding schema image.
|
|
676
|
+
const sourceDocument = documents.find((document) => document.id === hit.documentId);
|
|
677
|
+
const relatedDocumentImages = sourceDocument
|
|
678
|
+
? documents
|
|
679
|
+
.filter(
|
|
680
|
+
(document) =>
|
|
681
|
+
document.status === 'indexed' &&
|
|
682
|
+
document.metadata?.isImage === true &&
|
|
683
|
+
document.metadata?.originalFile === sourceDocument.title,
|
|
684
|
+
)
|
|
685
|
+
.map((document) => ({
|
|
686
|
+
imageUrl: document.metadata?.imageUrl,
|
|
687
|
+
pageNumber: document.metadata?.pageNumber,
|
|
688
|
+
index: document.metadata?.index,
|
|
689
|
+
description: document.metadata?.description,
|
|
690
|
+
}))
|
|
691
|
+
.filter((image) => typeof image.imageUrl === 'string' && image.imageUrl.length > 0)
|
|
692
|
+
: [];
|
|
693
|
+
const sequence = Number(hit.metadata?.sequence);
|
|
694
|
+
const startSecs = Number(hit.metadata?.startSecs);
|
|
695
|
+
const activeVideoAsset = hit.metadata?.mediaAssetId
|
|
696
|
+
? mediaAssets.find((asset) => asset.id === hit.metadata.mediaAssetId)
|
|
697
|
+
: undefined;
|
|
698
|
+
const usesEvidenceFirstVideoKnowledge = Boolean(
|
|
699
|
+
activeVideoAsset?.activeVideoKnowledgeRevisionId &&
|
|
700
|
+
(activeVideoAsset.type === 'video' || activeVideoAsset.type === 'audio'),
|
|
701
|
+
);
|
|
702
|
+
const timelineContext =
|
|
703
|
+
hit.metadata?.mediaAssetId && !usesEvidenceFirstVideoKnowledge
|
|
704
|
+
? documents
|
|
705
|
+
.filter((document) => {
|
|
706
|
+
const candidateSequence = Number(document.metadata?.sequence);
|
|
707
|
+
const candidateStart = Number(document.metadata?.startSecs);
|
|
708
|
+
return (
|
|
709
|
+
document.status === 'indexed' &&
|
|
710
|
+
activeMediaDocumentIds.has(document.id) &&
|
|
711
|
+
document.metadata?.mediaAssetId === hit.metadata.mediaAssetId &&
|
|
712
|
+
Number.isFinite(sequence) &&
|
|
713
|
+
Number.isFinite(candidateSequence) &&
|
|
714
|
+
Math.abs(candidateSequence - sequence) <= 1 &&
|
|
715
|
+
// Sequence is the authoritative adjacency marker. Fixed
|
|
716
|
+
// 90-second distance broke wider adaptive windows used for
|
|
717
|
+
// multi-hour camera and screen recordings.
|
|
718
|
+
(!Number.isFinite(startSecs) ||
|
|
719
|
+
!Number.isFinite(candidateStart) ||
|
|
720
|
+
Math.abs(candidateStart - startSecs) <=
|
|
721
|
+
Math.max(
|
|
722
|
+
90,
|
|
723
|
+
Number(hit.metadata?.endSecs ?? startSecs) - startSecs,
|
|
724
|
+
Number(document.metadata?.endSecs ?? candidateStart) - candidateStart,
|
|
725
|
+
))
|
|
726
|
+
);
|
|
727
|
+
})
|
|
728
|
+
.sort(
|
|
729
|
+
(left, right) =>
|
|
730
|
+
Number(left.metadata?.startSecs ?? 0) - Number(right.metadata?.startSecs ?? 0),
|
|
731
|
+
)
|
|
732
|
+
.map((document) => ({
|
|
733
|
+
role:
|
|
734
|
+
Number(document.metadata?.sequence) < sequence
|
|
735
|
+
? 'before'
|
|
736
|
+
: Number(document.metadata?.sequence) > sequence
|
|
737
|
+
? 'after'
|
|
738
|
+
: 'matched',
|
|
739
|
+
title: document.title,
|
|
740
|
+
url: document.url,
|
|
741
|
+
text: queryAwareExcerpt(document.content, query, 3_200),
|
|
742
|
+
startSecs: document.metadata?.startSecs,
|
|
743
|
+
endSecs: document.metadata?.endSecs,
|
|
744
|
+
}))
|
|
745
|
+
: undefined;
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
documentId: hit.documentId,
|
|
749
|
+
title: hit.title ?? 'Untitled',
|
|
750
|
+
url: hit.url ?? '',
|
|
751
|
+
score: Number((hit.score ?? 0).toFixed(3)),
|
|
752
|
+
// An active Video Knowledge revision replaces this disposable vector
|
|
753
|
+
// projection as the factual source. Do not leak the old timeline
|
|
754
|
+
// excerpt to the model: it can otherwise answer from a broad/dummy
|
|
755
|
+
// window without running the evidence verification tool.
|
|
756
|
+
text: usesEvidenceFirstVideoKnowledge
|
|
757
|
+
? 'Verified media evidence is available. Use the installed evidence-query action with this mediaAssetId before answering factual questions about this media.'
|
|
758
|
+
: queryAwareExcerpt(hit.text ?? '', query, 1_600),
|
|
759
|
+
// UI-only preview of the exact indexed chunk that matched. The model's
|
|
760
|
+
// compact tool context intentionally ignores this field and answers
|
|
761
|
+
// video questions from active evidence instead, while the user can
|
|
762
|
+
// still inspect what the search actually found.
|
|
763
|
+
context: queryAwareExcerpt(hit.text ?? sourceDocument?.content ?? '', query, 900),
|
|
764
|
+
// PDF extraction stores its verified upload URLs on the source
|
|
765
|
+
// document, rather than creating media-library assets. Expose those
|
|
766
|
+
// URLs explicitly so the presentation tool can validate and render
|
|
767
|
+
// them in chat.
|
|
768
|
+
// A visual chunk is a precise match. Keep it ahead of the parent
|
|
769
|
+
// document's full image catalog so a request to preview a retrieved
|
|
770
|
+
// diagram cannot silently fall back to image zero (often a cover).
|
|
771
|
+
images:
|
|
772
|
+
(hit.metadata?.isImage && hit.metadata?.imageUrl
|
|
773
|
+
? [
|
|
774
|
+
{
|
|
775
|
+
imageUrl: hit.metadata.imageUrl,
|
|
776
|
+
pageNumber: hit.metadata.pageNumber,
|
|
777
|
+
index: hit.metadata.index,
|
|
778
|
+
description: hit.metadata.description,
|
|
779
|
+
},
|
|
780
|
+
]
|
|
781
|
+
: undefined) ??
|
|
782
|
+
hit.metadata?.images ??
|
|
783
|
+
sourceDocument?.metadata?.images ??
|
|
784
|
+
(relatedDocumentImages.length > 0 ? relatedDocumentImages : undefined),
|
|
785
|
+
metadata: {
|
|
786
|
+
...hit.metadata,
|
|
787
|
+
usesEvidenceFirstVideoKnowledge,
|
|
788
|
+
},
|
|
789
|
+
timelineContext,
|
|
790
|
+
endingContext:
|
|
791
|
+
hitIndex === 0 && !usesEvidenceFirstVideoKnowledge ? endingContext : undefined,
|
|
792
|
+
};
|
|
793
|
+
}),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Vector rows created before Video Knowledge can be missing the derived
|
|
799
|
+
* `usesEvidenceFirstVideoKnowledge` flag. Resolve against the source of
|
|
800
|
+
* truth instead of allowing that stale projection to downgrade a video
|
|
801
|
+
* question into ordinary RAG.
|
|
802
|
+
*/
|
|
803
|
+
async function findEvidenceFirstVideoAssetId(
|
|
804
|
+
retrieval: { hits?: any[] },
|
|
805
|
+
activeScope: VideoRuntimeScope,
|
|
806
|
+
projectId?: string,
|
|
807
|
+
) {
|
|
808
|
+
const readAssetsAndGroups = async () => Promise.all([readMediaAssets(), readGroups()] as const);
|
|
809
|
+
const [assets, groups] = projectId
|
|
810
|
+
? await runWithProject(projectId, readAssetsAndGroups)
|
|
811
|
+
: await readAssetsAndGroups();
|
|
812
|
+
const activeVideoAssets = filterMediaAssetsAvailableToAssistant(assets, groups).filter(
|
|
813
|
+
(asset) =>
|
|
814
|
+
asset.processingStatus === 'completed' &&
|
|
815
|
+
Boolean(asset.activeVideoKnowledgeRevisionId) &&
|
|
816
|
+
isMediaAssetAvailableInRuntime(asset, activeScope) &&
|
|
817
|
+
(asset.type === 'video' || asset.type === 'audio'),
|
|
818
|
+
);
|
|
819
|
+
const assetsById = new Map(activeVideoAssets.map((asset) => [asset.id, asset]));
|
|
820
|
+
const assetIdByDocumentId = new Map(
|
|
821
|
+
activeVideoAssets.flatMap((asset) =>
|
|
822
|
+
asset.documentIds.map((documentId) => [documentId, asset.id] as const),
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
// Re-indexing/import retries can leave more than one completed asset for a
|
|
826
|
+
// source. The most recently updated completed asset is authoritative: it is
|
|
827
|
+
// the one the user most recently indexed, and choosing it is independent of
|
|
828
|
+
// what kind of question happens to be asked. Seek/tracking URL variants are
|
|
829
|
+
// grouped by canonicalMediaSourceUrl above.
|
|
830
|
+
const preferredAssetByOriginalUrl = new Map<string, Promise<string>>();
|
|
831
|
+
const preferEvidenceQuality = async (assetId: string) => {
|
|
832
|
+
const originalUrl = canonicalMediaSourceUrl(assetsById.get(assetId)?.originalUrl ?? '');
|
|
833
|
+
if (!originalUrl) return assetId;
|
|
834
|
+
const existing = preferredAssetByOriginalUrl.get(originalUrl);
|
|
835
|
+
if (existing) return existing;
|
|
836
|
+
const candidates = activeVideoAssets.filter(
|
|
837
|
+
(candidate) => canonicalMediaSourceUrl(candidate.originalUrl ?? '') === originalUrl,
|
|
838
|
+
);
|
|
839
|
+
const selection = Promise.resolve(
|
|
840
|
+
candidates.sort(
|
|
841
|
+
(left, right) =>
|
|
842
|
+
Date.parse(right.updatedAt ?? right.createdAt ?? '') -
|
|
843
|
+
Date.parse(left.updatedAt ?? left.createdAt ?? ''),
|
|
844
|
+
)[0]?.id ?? assetId,
|
|
845
|
+
);
|
|
846
|
+
preferredAssetByOriginalUrl.set(originalUrl, selection);
|
|
847
|
+
return selection;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const assetId = leadingMediaAssetId(retrieval, new Set(assetsById.keys()), assetIdByDocumentId);
|
|
851
|
+
return assetId ? preferEvidenceQuality(assetId) : undefined;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Builds the SandboxManager config from the workspace's configured default sandbox provider. */
|
|
855
|
+
function resolveSandboxConfig(config: any): {
|
|
856
|
+
backend: SandboxBackend;
|
|
857
|
+
credentials?: Record<string, string>;
|
|
858
|
+
} {
|
|
859
|
+
const backend = (config?.defaultSandboxProvider as SandboxBackend) || 'local';
|
|
860
|
+
return { backend, credentials: config?.sandboxProviderConfigs?.[backend] };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export async function getChatTools(context: {
|
|
864
|
+
projectId?: string;
|
|
865
|
+
docSessionId?: string;
|
|
866
|
+
config?: any;
|
|
867
|
+
requestText?: string;
|
|
868
|
+
/** Compact source context used only to disambiguate the user's immediate follow-up retrieval. */
|
|
869
|
+
contextualKnowledgeQuery?: string;
|
|
870
|
+
/** Public origin of the incoming chat request, used for local media URLs. */
|
|
871
|
+
origin?: string;
|
|
872
|
+
/** Media source from the immediately preceding evidence-backed turn. */
|
|
873
|
+
preferredMediaAssetId?: string;
|
|
874
|
+
/** Exact bounded result from the immediately preceding answer, for direct export follow-ups. */
|
|
875
|
+
recentExportTable?: {
|
|
876
|
+
columns: string[];
|
|
877
|
+
rows: Array<Record<string, unknown>>;
|
|
878
|
+
};
|
|
879
|
+
/** Verified rows from the preceding visible table, for a direct chart follow-up. */
|
|
880
|
+
recentVisualizationTable?: {
|
|
881
|
+
columns: string[];
|
|
882
|
+
rows: Array<Record<string, unknown>>;
|
|
883
|
+
};
|
|
884
|
+
/** Conversational answer fallback when no structured table preceded the export request. */
|
|
885
|
+
recentAnswerText?: string;
|
|
886
|
+
}) {
|
|
887
|
+
const {
|
|
888
|
+
projectId,
|
|
889
|
+
docSessionId,
|
|
890
|
+
config,
|
|
891
|
+
origin,
|
|
892
|
+
requestText,
|
|
893
|
+
preferredMediaAssetId,
|
|
894
|
+
contextualKnowledgeQuery,
|
|
895
|
+
recentExportTable,
|
|
896
|
+
recentVisualizationTable,
|
|
897
|
+
recentAnswerText,
|
|
898
|
+
} = context;
|
|
899
|
+
// Installed tools are shared, while their connection/runtime selection is
|
|
900
|
+
// project-scoped. Resolve that configuration once before constructing any
|
|
901
|
+
// dynamic client so Local and Cloud tools always receive the active
|
|
902
|
+
// project's settings rather than the install-time defaults.
|
|
903
|
+
const activeProjectConfig = projectId
|
|
904
|
+
? await runWithProject(projectId, () => readConfig())
|
|
905
|
+
: config;
|
|
906
|
+
const activeVideoRuntimeScope = videoRuntimeScopeFromConfig(activeProjectConfig);
|
|
907
|
+
let currentTurnExportTable:
|
|
908
|
+
{ columns: string[]; rows: Array<Record<string, unknown>> } | undefined;
|
|
909
|
+
const inActiveProject = <T>(operation: () => Promise<T>): Promise<T> =>
|
|
910
|
+
projectId ? runWithProject(projectId, operation) : operation();
|
|
911
|
+
// Read this scope at tool-execution time instead of capturing it when the
|
|
912
|
+
// chat request starts. A group can be moved or disabled while a request is
|
|
913
|
+
// in flight, and residual vectors/evidence must not bridge that change.
|
|
914
|
+
const readAssistantSourceScope = () =>
|
|
915
|
+
inActiveProject(async () => {
|
|
916
|
+
const [allDocuments, groups, allMediaAssets] = await Promise.all([
|
|
917
|
+
readDocuments(),
|
|
918
|
+
readGroups(),
|
|
919
|
+
readMediaAssets(),
|
|
920
|
+
]);
|
|
921
|
+
return {
|
|
922
|
+
groups,
|
|
923
|
+
documents: filterDocumentsAvailableToAssistant(allDocuments, groups),
|
|
924
|
+
mediaAssets: filterMediaAssetsAvailableToAssistant(allMediaAssets, groups),
|
|
925
|
+
};
|
|
926
|
+
});
|
|
927
|
+
const readAssistantTabularDatasets = () =>
|
|
928
|
+
inActiveProject(async () => {
|
|
929
|
+
const [datasets, groups, documents] = await Promise.all([
|
|
930
|
+
listTabularDatasets(),
|
|
931
|
+
readGroups(),
|
|
932
|
+
readDocuments(),
|
|
933
|
+
]);
|
|
934
|
+
return filterGroupOwnedDataAvailableToAssistant(
|
|
935
|
+
resolveTabularDatasetGroups(datasets, documents),
|
|
936
|
+
groups,
|
|
937
|
+
);
|
|
938
|
+
});
|
|
939
|
+
const readAssistantSourceFingerprint = async () => {
|
|
940
|
+
const [{ documents }, currentConfig] = await Promise.all([
|
|
941
|
+
readAssistantSourceScope(),
|
|
942
|
+
inActiveProject(() => readConfig()),
|
|
943
|
+
]);
|
|
944
|
+
return assistantSourceScopeFingerprint(documents, {
|
|
945
|
+
configUpdatedAt: currentConfig.updatedAt,
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
const scopedAsset = async (mediaAssetId: string) => {
|
|
949
|
+
const { mediaAssets, documents } = await readAssistantSourceScope();
|
|
950
|
+
const asset = mediaAssets.find((candidate) => candidate.id === mediaAssetId);
|
|
951
|
+
if (!asset || !isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope)) {
|
|
952
|
+
return undefined;
|
|
953
|
+
}
|
|
954
|
+
const sourceLanguage = documents
|
|
955
|
+
.filter((document) => document.metadata?.mediaAssetId === mediaAssetId)
|
|
956
|
+
.map((document) => document.metadata?.transcriptLanguage)
|
|
957
|
+
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
|
958
|
+
return sourceLanguage ? { ...asset, sourceLanguage: sourceLanguage.trim() } : asset;
|
|
959
|
+
};
|
|
960
|
+
const authoritativeMediaAssetId = async (mediaAssetId: string) => {
|
|
961
|
+
const { mediaAssets } = await readAssistantSourceScope();
|
|
962
|
+
const requested = mediaAssets.find((asset) => asset.id === mediaAssetId);
|
|
963
|
+
return requested ? newestEquivalentMediaAsset(requested, mediaAssets).id : mediaAssetId;
|
|
964
|
+
};
|
|
965
|
+
const indexedMediaContext = async (mediaAssetId: string) => {
|
|
966
|
+
if (!(await scopedAsset(mediaAssetId))) return '';
|
|
967
|
+
const read = async () => {
|
|
968
|
+
const observations = await searchVideoKnowledge(mediaAssetId, '', 2_000, {
|
|
969
|
+
modalities: ['computed', 'visual'],
|
|
970
|
+
minimumRangeDistanceSecs: 0,
|
|
971
|
+
}).catch(() => []);
|
|
972
|
+
const preferred =
|
|
973
|
+
observations
|
|
974
|
+
.filter((hit) => {
|
|
975
|
+
const text = evidenceText(hit.evidence.payload).trim();
|
|
976
|
+
return (
|
|
977
|
+
/^(?:Reconciled|Indexed) overview:/i.test(text) &&
|
|
978
|
+
!/bounded (?:interactive )?inspection/i.test(text)
|
|
979
|
+
);
|
|
980
|
+
})
|
|
981
|
+
.sort(
|
|
982
|
+
(left, right) =>
|
|
983
|
+
evidenceText(right.evidence.payload).length -
|
|
984
|
+
evidenceText(left.evidence.payload).length,
|
|
985
|
+
)[0] ??
|
|
986
|
+
observations.find((hit) => {
|
|
987
|
+
const text = evidenceText(hit.evidence.payload).trim();
|
|
988
|
+
return (
|
|
989
|
+
hit.evidence.modality === 'visual' &&
|
|
990
|
+
text.length >= 80 &&
|
|
991
|
+
!text.startsWith('{') &&
|
|
992
|
+
!/^Detected objects:/i.test(text)
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
return evidenceText(preferred?.evidence.payload)
|
|
996
|
+
.split(/\nClaim question:/i, 1)[0]
|
|
997
|
+
.trim()
|
|
998
|
+
.slice(0, 900);
|
|
999
|
+
};
|
|
1000
|
+
return projectId ? runWithProject(projectId, read) : read();
|
|
1001
|
+
};
|
|
1002
|
+
const scopedDocument = async (documentId: string) => {
|
|
1003
|
+
const { documents } = await readAssistantSourceScope();
|
|
1004
|
+
return documents.find(
|
|
1005
|
+
(document) => document.id === documentId && document.status === 'indexed',
|
|
1006
|
+
);
|
|
1007
|
+
};
|
|
1008
|
+
const pdfPagePreviewUrl = (documentId: string, pageNumber: number) => {
|
|
1009
|
+
const query = new URLSearchParams({ documentId, page: String(pageNumber) });
|
|
1010
|
+
if (projectId) query.set('projectId', projectId);
|
|
1011
|
+
return workspaceServerUrl(`/api/pdf-page?${query.toString()}`);
|
|
1012
|
+
};
|
|
1013
|
+
// Embeddings and vector providers are the host's concern, so the host
|
|
1014
|
+
// supplies the semantic ranking signal and the installed tool stays
|
|
1015
|
+
// responsible for retrieval strategy. Without it, a question asked in one
|
|
1016
|
+
// language scores zero against a source recorded in another and the ranked
|
|
1017
|
+
// lookup returns whatever happened to share a character with the question.
|
|
1018
|
+
// Concurrent modality searches share one scoring pass. The entry is removed
|
|
1019
|
+
// as soon as that pass completes: an inspection can append evidence and
|
|
1020
|
+
// activate a new manifest, so a later wave must score the new index.
|
|
1021
|
+
const semanticScorePasses = new Map<string, Promise<Map<string, number> | undefined>>();
|
|
1022
|
+
const semanticScoresFor = async (mediaAssetId: string, query: string) => {
|
|
1023
|
+
const key = `${mediaAssetId}\u0000${query}`;
|
|
1024
|
+
const active = semanticScorePasses.get(key);
|
|
1025
|
+
if (active) return active;
|
|
1026
|
+
const pass = searchSemanticEvidence(mediaAssetId, query, {
|
|
1027
|
+
topK: 60,
|
|
1028
|
+
abortSignal: AbortSignal.timeout(CHAT_SEMANTIC_LOOKUP_BUDGET_MS),
|
|
1029
|
+
})
|
|
1030
|
+
.then((hits) => (hits.length > 0 ? semanticScoresByEvidenceId(hits) : undefined))
|
|
1031
|
+
.catch(() => undefined);
|
|
1032
|
+
semanticScorePasses.set(key, pass);
|
|
1033
|
+
try {
|
|
1034
|
+
return await pass;
|
|
1035
|
+
} finally {
|
|
1036
|
+
if (semanticScorePasses.get(key) === pass) semanticScorePasses.delete(key);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// The host only routes a source handle to an installed capability. The
|
|
1041
|
+
// tool remains responsible for retrieval, inspection, and grounding.
|
|
1042
|
+
const mediaEvidence = {
|
|
1043
|
+
getAsset: scopedAsset,
|
|
1044
|
+
planQuestion: (question: string, directive: VideoInvestigationDirective) =>
|
|
1045
|
+
planVideoQuestion(question, directive),
|
|
1046
|
+
planInvestigation: async (
|
|
1047
|
+
mediaAssetId: string,
|
|
1048
|
+
question: string,
|
|
1049
|
+
directive: VideoInvestigationDirective,
|
|
1050
|
+
) => {
|
|
1051
|
+
if (!(await scopedAsset(mediaAssetId))) return undefined;
|
|
1052
|
+
const plan = () => buildVideoInvestigationPlan(mediaAssetId, question, directive);
|
|
1053
|
+
return projectId ? runWithProject(projectId, plan) : plan();
|
|
1054
|
+
},
|
|
1055
|
+
search: async (
|
|
1056
|
+
mediaAssetId: string,
|
|
1057
|
+
query: string,
|
|
1058
|
+
limit: number,
|
|
1059
|
+
options: Parameters<typeof searchVideoKnowledge>[3] = {},
|
|
1060
|
+
) => {
|
|
1061
|
+
if (!(await scopedAsset(mediaAssetId))) return [];
|
|
1062
|
+
const search = async () =>
|
|
1063
|
+
searchVideoKnowledge(mediaAssetId, query, limit, {
|
|
1064
|
+
...options,
|
|
1065
|
+
semanticScores: options.semanticScores ?? (await semanticScoresFor(mediaAssetId, query)),
|
|
1066
|
+
});
|
|
1067
|
+
return projectId ? runWithProject(projectId, search) : search();
|
|
1068
|
+
},
|
|
1069
|
+
// Complete-source questions must not use semantic rank as their coverage
|
|
1070
|
+
// mechanism. The Core engine pages immutable active evidence in time order
|
|
1071
|
+
// and stores aggregate results outside the model context.
|
|
1072
|
+
scan: async (
|
|
1073
|
+
mediaAssetId: string,
|
|
1074
|
+
input: {
|
|
1075
|
+
kind: 'source-inventory' | 'all-evidence';
|
|
1076
|
+
cursor?: number;
|
|
1077
|
+
limit?: number;
|
|
1078
|
+
modalities?: Array<'transcript' | 'ocr' | 'visual' | 'computed'>;
|
|
1079
|
+
},
|
|
1080
|
+
) => {
|
|
1081
|
+
if (!(await scopedAsset(mediaAssetId))) return undefined;
|
|
1082
|
+
const scan = () => scanVideoKnowledge(mediaAssetId, input);
|
|
1083
|
+
return projectId ? runWithProject(projectId, scan) : scan();
|
|
1084
|
+
},
|
|
1085
|
+
aggregate: async (mediaAssetId: string) => {
|
|
1086
|
+
if (!(await scopedAsset(mediaAssetId))) return undefined;
|
|
1087
|
+
const aggregate = () => aggregateVideoKnowledge(mediaAssetId);
|
|
1088
|
+
return projectId ? runWithProject(projectId, aggregate) : aggregate();
|
|
1089
|
+
},
|
|
1090
|
+
// Cloud projects have an explicit normal inspection API backed by their
|
|
1091
|
+
// managed runtime. Do not silently fall back to a local multimodal call
|
|
1092
|
+
// with the chat model, which may not even accept image inputs.
|
|
1093
|
+
...(activeVideoRuntimeScope === 'local'
|
|
1094
|
+
? {
|
|
1095
|
+
reWatch: async (
|
|
1096
|
+
mediaAssetId: string,
|
|
1097
|
+
question: string,
|
|
1098
|
+
ranges: Array<{ startSecs: number; endSecs: number; lookingFor?: string }>,
|
|
1099
|
+
options?: { maxWaitMs?: number; knownEntities?: string[] },
|
|
1100
|
+
) => {
|
|
1101
|
+
const asset = await scopedAsset(mediaAssetId);
|
|
1102
|
+
if (!asset || asset.type !== 'video' || asset.processingStatus !== 'completed')
|
|
1103
|
+
return [];
|
|
1104
|
+
const run = async () => {
|
|
1105
|
+
const outcome = await reWatchSource({
|
|
1106
|
+
asset,
|
|
1107
|
+
question,
|
|
1108
|
+
mediaAssetId,
|
|
1109
|
+
ranges: ranges.map((range) => ({ ...range, label: range.lookingFor })),
|
|
1110
|
+
maxWaitMs: options?.maxWaitMs,
|
|
1111
|
+
knownEntities: options?.knownEntities,
|
|
1112
|
+
});
|
|
1113
|
+
return (outcome?.findings ?? []).map((finding) => ({
|
|
1114
|
+
range: finding.range,
|
|
1115
|
+
at: finding.at,
|
|
1116
|
+
found: finding.found,
|
|
1117
|
+
read: finding.read,
|
|
1118
|
+
confidence: finding.confidence,
|
|
1119
|
+
settlesQuestion: finding.settlesQuestion,
|
|
1120
|
+
}));
|
|
1121
|
+
};
|
|
1122
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
1123
|
+
},
|
|
1124
|
+
}
|
|
1125
|
+
: {}),
|
|
1126
|
+
/**
|
|
1127
|
+
* Ranked windows worth looking at, fused from every timestamped signal the
|
|
1128
|
+
* host holds. An installed tool decides what to do with them; this only
|
|
1129
|
+
* says where the agreement is.
|
|
1130
|
+
*/
|
|
1131
|
+
locate: async (
|
|
1132
|
+
mediaAssetId: string,
|
|
1133
|
+
query: string,
|
|
1134
|
+
options: { maxRanges?: number; maxWindowSecs?: number } = {},
|
|
1135
|
+
) => {
|
|
1136
|
+
if (!(await scopedAsset(mediaAssetId))) return [];
|
|
1137
|
+
const run = async () => {
|
|
1138
|
+
const [semanticEvidence, clipMatches] = await Promise.all([
|
|
1139
|
+
searchSemanticEvidence(mediaAssetId, query, {
|
|
1140
|
+
topK: 12,
|
|
1141
|
+
abortSignal: AbortSignal.timeout(CHAT_SEMANTIC_LOOKUP_BUDGET_MS),
|
|
1142
|
+
}).catch(() => []),
|
|
1143
|
+
queryVideoEmbeddings(mediaAssetId, query, 6).catch(() => []),
|
|
1144
|
+
]);
|
|
1145
|
+
return fuseFocusRanges(
|
|
1146
|
+
[
|
|
1147
|
+
...semanticEvidence.map((hit) => ({
|
|
1148
|
+
kind: 'semantic' as const,
|
|
1149
|
+
startSecs: hit.startSecs,
|
|
1150
|
+
endSecs: hit.endSecs,
|
|
1151
|
+
score: hit.score,
|
|
1152
|
+
})),
|
|
1153
|
+
...clipMatches.map((match) => ({
|
|
1154
|
+
kind: 'clip-embedding' as const,
|
|
1155
|
+
startSecs: match.startSecs,
|
|
1156
|
+
endSecs: match.endSecs,
|
|
1157
|
+
score: match.score,
|
|
1158
|
+
})),
|
|
1159
|
+
],
|
|
1160
|
+
options,
|
|
1161
|
+
);
|
|
1162
|
+
};
|
|
1163
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
const sandboxReady = await isSandboxReady(config);
|
|
1167
|
+
const builtInTools: Record<string, any> = {
|
|
1168
|
+
searchKnowledgeBase: tool({
|
|
1169
|
+
description:
|
|
1170
|
+
"HIGHEST PRIORITY TOOL — Search the user's private RAG knowledge base. ALWAYS use this FIRST before webSearch or any other tool when the user asks a question. Search once with a focused query. If a result has a mediaAssetId and the question concerns media, call an installed evidence-query action with that exact id before answering. DO NOT call presentMedia automatically; only call it when the user explicitly asks to preview a moment.",
|
|
1171
|
+
inputSchema: z.object({
|
|
1172
|
+
query: z.string().describe('The search query for the knowledge base.'),
|
|
1173
|
+
}),
|
|
1174
|
+
execute: async ({ query }) => {
|
|
1175
|
+
const continuingAsset = preferredMediaAssetId
|
|
1176
|
+
? await mediaEvidence.getAsset(preferredMediaAssetId)
|
|
1177
|
+
: undefined;
|
|
1178
|
+
if (continuingAsset?.processingStatus === 'completed') {
|
|
1179
|
+
const completedMedia = (await readAssistantSourceScope()).mediaAssets.filter(
|
|
1180
|
+
(asset) =>
|
|
1181
|
+
asset.processingStatus === 'completed' &&
|
|
1182
|
+
Boolean(asset.activeVideoKnowledgeRevisionId) &&
|
|
1183
|
+
isMediaAssetAvailableInRuntime(asset, activeVideoRuntimeScope) &&
|
|
1184
|
+
(asset.type === 'video' || asset.type === 'audio'),
|
|
1185
|
+
);
|
|
1186
|
+
// Continuing on an unambiguous source keeps ordinary follow-ups
|
|
1187
|
+
// fast. With several indexed videos, however, search again unless
|
|
1188
|
+
// the user clearly names the current one; otherwise the first clip
|
|
1189
|
+
// mentioned in a chat silently monopolizes every later question.
|
|
1190
|
+
if (shouldKeepActiveMediaSource(query, continuingAsset, completedMedia)) {
|
|
1191
|
+
return activeMediaFollowUpResult(
|
|
1192
|
+
query,
|
|
1193
|
+
continuingAsset,
|
|
1194
|
+
await indexedMediaContext(continuingAsset.id),
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// RAG is a locator, never an answer cache. The chat route turns a
|
|
1199
|
+
// matched mediaAssetId into a separate, visible evidence-query tool
|
|
1200
|
+
// call. That action can then fall back to bounded live analysis.
|
|
1201
|
+
// The deterministic preflight calls this action with the user's exact
|
|
1202
|
+
// wording. Preserve that wording in the UI, but use the compact prior
|
|
1203
|
+
// topic to resolve a reference such as "what will it be about?" before
|
|
1204
|
+
// vector retrieval. Model-authored, already-specific search queries
|
|
1205
|
+
// are left untouched.
|
|
1206
|
+
const retrievalQuery =
|
|
1207
|
+
contextualKnowledgeQuery && query.trim() === requestText?.trim()
|
|
1208
|
+
? contextualKnowledgeQuery
|
|
1209
|
+
: query;
|
|
1210
|
+
const fingerprintBeforeSearch = await readAssistantSourceFingerprint();
|
|
1211
|
+
const retrieval = await queryKnowledgeBase(retrievalQuery, 4, projectId ?? null);
|
|
1212
|
+
const fingerprintAfterSearch = await readAssistantSourceFingerprint();
|
|
1213
|
+
return {
|
|
1214
|
+
...retrieval,
|
|
1215
|
+
...(retrievalQuery === query ? {} : { query }),
|
|
1216
|
+
...(fingerprintBeforeSearch === fingerprintAfterSearch
|
|
1217
|
+
? { sourceScopeFingerprint: fingerprintAfterSearch }
|
|
1218
|
+
: {}),
|
|
1219
|
+
};
|
|
1220
|
+
},
|
|
1221
|
+
}),
|
|
1222
|
+
|
|
1223
|
+
inspectPdfPages: tool({
|
|
1224
|
+
description:
|
|
1225
|
+
'Inspect pages from the original indexed PDF locally. Use the exact documentId returned by searchKnowledgeBase when the answer may depend on a PDF page that was not visual-indexed. This ranks nearby pages from local page text, extracts bounded page text and tables, and returns validated page-preview URLs. It never sends the PDF outside this workspace.',
|
|
1226
|
+
inputSchema: z.object({
|
|
1227
|
+
documentId: z.string().describe('Exact documentId returned by searchKnowledgeBase.'),
|
|
1228
|
+
question: z.string().describe('The focused user question used to select nearby PDF pages.'),
|
|
1229
|
+
pageNumbers: z
|
|
1230
|
+
.array(z.number().int().positive())
|
|
1231
|
+
.max(3)
|
|
1232
|
+
.optional()
|
|
1233
|
+
.describe('Optional exact PDF page numbers when the evidence already identifies them.'),
|
|
1234
|
+
}),
|
|
1235
|
+
execute: async ({ documentId, question, pageNumbers }) => {
|
|
1236
|
+
try {
|
|
1237
|
+
const document = await scopedDocument(documentId);
|
|
1238
|
+
if (!document) return { success: false, error: 'That PDF document is not available.' };
|
|
1239
|
+
const result = await inspectStoredPdf(document, question, pageNumbers, (pageNumber) =>
|
|
1240
|
+
pdfPagePreviewUrl(document.id, pageNumber),
|
|
1241
|
+
);
|
|
1242
|
+
return {
|
|
1243
|
+
success: true,
|
|
1244
|
+
documentId: document.id,
|
|
1245
|
+
title: document.title,
|
|
1246
|
+
...result,
|
|
1247
|
+
ui: {
|
|
1248
|
+
kind: 'card',
|
|
1249
|
+
title: `Inspected ${document.title}`,
|
|
1250
|
+
facts: [
|
|
1251
|
+
{
|
|
1252
|
+
label: 'Pages selected',
|
|
1253
|
+
value: result.pages.map((page) => String(page.pageNumber)).join(', '),
|
|
1254
|
+
},
|
|
1255
|
+
{ label: 'Total pages', value: String(result.totalPages) },
|
|
1256
|
+
],
|
|
1257
|
+
},
|
|
1258
|
+
};
|
|
1259
|
+
} catch (error: any) {
|
|
1260
|
+
return { success: false, error: error.message ?? 'Could not inspect the PDF locally.' };
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
}),
|
|
1264
|
+
|
|
1265
|
+
analyzePdfPages: tool({
|
|
1266
|
+
description:
|
|
1267
|
+
'Analyze locally rendered pages from the original indexed PDF. Use after inspectPdfPages when the answer depends on a diagram, chart, image, equation layout, or other visual content. Pass the exact documentId and the selected page numbers. The source PDF stays local; only the bounded rendered pages are sent to the configured vision capability when one is enabled.',
|
|
1268
|
+
inputSchema: z.object({
|
|
1269
|
+
documentId: z.string().describe('Exact documentId returned by inspectPdfPages.'),
|
|
1270
|
+
pageNumbers: z
|
|
1271
|
+
.array(z.number().int().positive())
|
|
1272
|
+
.max(3)
|
|
1273
|
+
.optional()
|
|
1274
|
+
.describe(
|
|
1275
|
+
'Optional one to three page numbers returned by inspectPdfPages. Omit to reuse the local question-based selection.',
|
|
1276
|
+
),
|
|
1277
|
+
prompt: z.string().describe('Precisely what to read or verify in the rendered PDF pages.'),
|
|
1278
|
+
}),
|
|
1279
|
+
execute: async ({ documentId, pageNumbers, prompt }) => {
|
|
1280
|
+
try {
|
|
1281
|
+
const document = await scopedDocument(documentId);
|
|
1282
|
+
if (!document) return { success: false, error: 'That PDF document is not available.' };
|
|
1283
|
+
const inspection = await inspectStoredPdf(document, prompt, pageNumbers, (pageNumber) =>
|
|
1284
|
+
pdfPagePreviewUrl(document.id, pageNumber),
|
|
1285
|
+
);
|
|
1286
|
+
const pages = await Promise.all(
|
|
1287
|
+
inspection.pages.map(async (page) => {
|
|
1288
|
+
const rendered = await renderStoredPdfPage(document, page.pageNumber);
|
|
1289
|
+
const response = await fetch(
|
|
1290
|
+
new URL(
|
|
1291
|
+
workspaceServerUrl(
|
|
1292
|
+
`/api/describe-image${
|
|
1293
|
+
projectId ? `?serverId=${encodeURIComponent(projectId)}` : ''
|
|
1294
|
+
}`,
|
|
1295
|
+
),
|
|
1296
|
+
origin ?? 'http://127.0.0.1:4567',
|
|
1297
|
+
),
|
|
1298
|
+
{
|
|
1299
|
+
method: 'POST',
|
|
1300
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1301
|
+
body: JSON.stringify({ base64: rendered.data.toString('base64'), prompt }),
|
|
1302
|
+
},
|
|
1303
|
+
);
|
|
1304
|
+
const data = await response.json().catch(() => ({}));
|
|
1305
|
+
return {
|
|
1306
|
+
...page,
|
|
1307
|
+
analysis: response.ok ? data.description : undefined,
|
|
1308
|
+
error: response.ok
|
|
1309
|
+
? undefined
|
|
1310
|
+
: (data.error ?? 'Visual analysis failed for this page.'),
|
|
1311
|
+
};
|
|
1312
|
+
}),
|
|
1313
|
+
);
|
|
1314
|
+
return {
|
|
1315
|
+
success: pages.some((page) => Boolean(page.analysis)),
|
|
1316
|
+
documentId: document.id,
|
|
1317
|
+
title: document.title,
|
|
1318
|
+
totalPages: inspection.totalPages,
|
|
1319
|
+
pages,
|
|
1320
|
+
ui: {
|
|
1321
|
+
kind: 'card',
|
|
1322
|
+
title: `Analyzed pages from ${document.title}`,
|
|
1323
|
+
facts: [
|
|
1324
|
+
{ label: 'Pages', value: pages.map((page) => String(page.pageNumber)).join(', ') },
|
|
1325
|
+
],
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
} catch (error: any) {
|
|
1329
|
+
return { success: false, error: error.message ?? 'Could not analyze PDF pages.' };
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
}),
|
|
1333
|
+
|
|
1334
|
+
queryVideoKnowledge: tool({
|
|
1335
|
+
description:
|
|
1336
|
+
'Use this for every factual question about an indexed video or audio asset after searchKnowledgeBase identifies its mediaAssetId. It performs a fresh hierarchical investigation (chapters → scenes → events/states → active source evidence) on every call, returns seekable ranges, and highlights unresolved conflicts. Treat only returned active evidence as source truth. Use it for simple, temporal, comparative, exact-text, counting, and outcome questions before answering; do not infer a claim beyond these records. When verification says evidence is insufficient or inspection is required, inspect the bounded candidate range and query again before saying the answer is unknown.',
|
|
1337
|
+
inputSchema: z.object({
|
|
1338
|
+
mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
|
|
1339
|
+
query: z.string().describe('The user’s focused question or sub-question about this media.'),
|
|
1340
|
+
investigation: z
|
|
1341
|
+
.object({
|
|
1342
|
+
scope: z.enum(['focused', 'temporal', 'source']),
|
|
1343
|
+
goal: z.enum(['answer', 'compare', 'trace', 'enumerate', 'synthesize']),
|
|
1344
|
+
evidence: z
|
|
1345
|
+
.array(z.enum(['speech', 'visible-text', 'visual', 'computed']))
|
|
1346
|
+
.max(4)
|
|
1347
|
+
.optional(),
|
|
1348
|
+
recordSet: z
|
|
1349
|
+
.enum(['all', 'source-authored', 'source-questions', 'observed'])
|
|
1350
|
+
.optional(),
|
|
1351
|
+
timeRange: z
|
|
1352
|
+
.object({ startSecs: z.number().min(0), endSecs: z.number().min(0) })
|
|
1353
|
+
.optional(),
|
|
1354
|
+
})
|
|
1355
|
+
.describe(
|
|
1356
|
+
'Required language-model interpretation of the evidence scope and reasoning operation.',
|
|
1357
|
+
),
|
|
1358
|
+
limit: z.number().int().min(1).max(12).optional(),
|
|
1359
|
+
}),
|
|
1360
|
+
execute: async ({ mediaAssetId, query, investigation, limit }, { toolCallId }) => {
|
|
1361
|
+
mediaAssetId = await authoritativeMediaAssetId(mediaAssetId);
|
|
1362
|
+
const run = async (allowAutomaticInspection = true): Promise<any> => {
|
|
1363
|
+
const asset = await scopedAsset(mediaAssetId);
|
|
1364
|
+
if (!asset || asset.processingStatus !== 'completed') {
|
|
1365
|
+
return { success: false, error: 'That indexed media asset is no longer available.' };
|
|
1366
|
+
}
|
|
1367
|
+
const plan = planVideoQuestion(query, investigation);
|
|
1368
|
+
const sourceWideVisualEvidenceRequested =
|
|
1369
|
+
plan.investigation?.scope === 'source' &&
|
|
1370
|
+
(!plan.investigation.evidence ||
|
|
1371
|
+
plan.investigation.evidence.some(
|
|
1372
|
+
(modality) => modality === 'visual' || modality === 'computed',
|
|
1373
|
+
));
|
|
1374
|
+
// These four locators are independent and each costs a network round
|
|
1375
|
+
// trip or a full state scan. Running them concurrently is most of the
|
|
1376
|
+
// difference between a chat answer that feels immediate and one that
|
|
1377
|
+
// reads as hung before any evidence has even been ranked.
|
|
1378
|
+
const [
|
|
1379
|
+
investigationPlan,
|
|
1380
|
+
semantic,
|
|
1381
|
+
visualMatches,
|
|
1382
|
+
semanticEvidence,
|
|
1383
|
+
sourceWideVisualOverviewEvidence,
|
|
1384
|
+
] = await Promise.all([
|
|
1385
|
+
buildVideoInvestigationPlan(mediaAssetId, query, investigation),
|
|
1386
|
+
// The regular RAG retriever owns embeddings/vector providers. Join
|
|
1387
|
+
// its media projection hits back to active Core evidence so video
|
|
1388
|
+
// answers get hybrid semantic + lexical retrieval without treating
|
|
1389
|
+
// vector documents as source truth.
|
|
1390
|
+
queryKnowledgeBase(query, 24, null),
|
|
1391
|
+
// Video-clip embeddings catch visual actions no caption/OCR/
|
|
1392
|
+
// transcript ever put into words. There is no existing evidence
|
|
1393
|
+
// record to join these to (the embedding IS the signal), so they
|
|
1394
|
+
// surface as candidate ranges for the agent to inspect via
|
|
1395
|
+
// watch_original/read_evidence, not as evidence themselves.
|
|
1396
|
+
queryVideoEmbeddings(mediaAssetId, query, 6),
|
|
1397
|
+
// Evidence-granular semantic retrieval. The corpus above matches
|
|
1398
|
+
// chapter-sized documents, which locates a fifteen-minute span
|
|
1399
|
+
// rather than a moment, and lexical scoring finds nothing at all
|
|
1400
|
+
// when the question and the source are in different languages.
|
|
1401
|
+
// This ranks the individual readings at their own timestamps.
|
|
1402
|
+
searchSemanticEvidence(mediaAssetId, query, { topK: 60 }),
|
|
1403
|
+
sourceWideVisualEvidenceRequested
|
|
1404
|
+
? searchVideoKnowledge(mediaAssetId, '', 2_000, {
|
|
1405
|
+
modalities: ['visual', 'computed'],
|
|
1406
|
+
minimumRangeDistanceSecs: 0,
|
|
1407
|
+
videoDurationSecs: asset.durationSecs,
|
|
1408
|
+
}).then((candidates) => {
|
|
1409
|
+
const sourceWideMoments = candidates.filter((candidate) => {
|
|
1410
|
+
const range = candidate.evidence.timeRange;
|
|
1411
|
+
const span = range.endSecs - range.startSecs;
|
|
1412
|
+
return span >= 15 && span <= 90;
|
|
1413
|
+
});
|
|
1414
|
+
// This source-wide directive is a structural retrieval
|
|
1415
|
+
// decision, independent of language, genre, subject, or
|
|
1416
|
+
// claim-specific vocabulary.
|
|
1417
|
+
const stable = sourceWideMoments.filter(
|
|
1418
|
+
(candidate) => candidate.evidence.timeRange.startSecs >= 60,
|
|
1419
|
+
);
|
|
1420
|
+
const pool = stable.length > 0 ? stable : sourceWideMoments;
|
|
1421
|
+
const selected: typeof pool = [];
|
|
1422
|
+
for (const candidate of pool.sort(
|
|
1423
|
+
(left, right) =>
|
|
1424
|
+
left.evidence.timeRange.startSecs - right.evidence.timeRange.startSecs,
|
|
1425
|
+
)) {
|
|
1426
|
+
if (
|
|
1427
|
+
selected.some(
|
|
1428
|
+
(existing) =>
|
|
1429
|
+
Math.abs(
|
|
1430
|
+
existing.evidence.timeRange.startSecs -
|
|
1431
|
+
candidate.evidence.timeRange.startSecs,
|
|
1432
|
+
) < 20,
|
|
1433
|
+
)
|
|
1434
|
+
)
|
|
1435
|
+
continue;
|
|
1436
|
+
selected.push(candidate);
|
|
1437
|
+
if (selected.length >= 4) break;
|
|
1438
|
+
}
|
|
1439
|
+
return selected;
|
|
1440
|
+
})
|
|
1441
|
+
: Promise.resolve([]),
|
|
1442
|
+
]);
|
|
1443
|
+
const semanticDocumentIds = semantic.hits
|
|
1444
|
+
.filter((hit: any) => hit.metadata?.mediaAssetId === mediaAssetId)
|
|
1445
|
+
.map((hit: any) => String(hit.documentId));
|
|
1446
|
+
const semanticScores = semanticEvidence.length
|
|
1447
|
+
? semanticScoresByEvidenceId(semanticEvidence)
|
|
1448
|
+
: undefined;
|
|
1449
|
+
let hits = await searchVideoKnowledge(mediaAssetId, query, limit ?? 8, {
|
|
1450
|
+
modalities: plan.modalities,
|
|
1451
|
+
minimumRangeDistanceSecs: 2,
|
|
1452
|
+
semanticDocumentIds,
|
|
1453
|
+
semanticScores,
|
|
1454
|
+
queryPlan: plan,
|
|
1455
|
+
videoDurationSecs: asset.durationSecs,
|
|
1456
|
+
});
|
|
1457
|
+
// A question about a named person cannot be answered by describing
|
|
1458
|
+
// the scene generically. Ground the name to a transcript mention
|
|
1459
|
+
// before falling back to whole-scene evidence.
|
|
1460
|
+
const subjectMentions = plan.subjectName
|
|
1461
|
+
? await searchVideoKnowledge(mediaAssetId, plan.subjectName, 3, {
|
|
1462
|
+
modalities: ['transcript'],
|
|
1463
|
+
minimumRangeDistanceSecs: 4,
|
|
1464
|
+
videoDurationSecs: asset.durationSecs,
|
|
1465
|
+
})
|
|
1466
|
+
: [];
|
|
1467
|
+
const subjectRange = subjectMentions[0]
|
|
1468
|
+
? {
|
|
1469
|
+
// Identity is usually established over an exchange rather
|
|
1470
|
+
// than a single spoken token. Keep enough context on both
|
|
1471
|
+
// sides for a vision model to correlate who is addressed,
|
|
1472
|
+
// speaking, or camera-focused, while retaining a bounded
|
|
1473
|
+
// single-pass inspection.
|
|
1474
|
+
startSecs: Math.max(0, subjectMentions[0].evidence.timeRange.startSecs - 15),
|
|
1475
|
+
endSecs: subjectMentions[0].evidence.timeRange.endSecs + 15,
|
|
1476
|
+
}
|
|
1477
|
+
: undefined;
|
|
1478
|
+
const overlapsSubjectRange = (hit: (typeof hits)[number]) =>
|
|
1479
|
+
Boolean(
|
|
1480
|
+
subjectRange &&
|
|
1481
|
+
hit.evidence.timeRange.startSecs < subjectRange.endSecs &&
|
|
1482
|
+
hit.evidence.timeRange.endSecs > subjectRange.startSecs,
|
|
1483
|
+
);
|
|
1484
|
+
// Every video question receives a compact tail view. It lets the
|
|
1485
|
+
// agent reason about an ending/state transition when relevant,
|
|
1486
|
+
// without encoding a sports, meeting, surveillance, or language
|
|
1487
|
+
// specific workflow in the runtime.
|
|
1488
|
+
const tailRange = asset.durationSecs
|
|
1489
|
+
? {
|
|
1490
|
+
// Keep the terminal view large enough to contain a complete
|
|
1491
|
+
// closing exchange/result. It matches the one-minute
|
|
1492
|
+
// interactive inspection cap, so a likely outcome can be
|
|
1493
|
+
// verified in one sparse semantic worker pass.
|
|
1494
|
+
startSecs: Math.max(0, asset.durationSecs - LIMITS.durationSecs),
|
|
1495
|
+
endSecs: asset.durationSecs,
|
|
1496
|
+
}
|
|
1497
|
+
: undefined;
|
|
1498
|
+
// How a source concludes is rarely confined to its final minute -- a
|
|
1499
|
+
// result is announced, shown, and discussed, and then the recording
|
|
1500
|
+
// runs on through reactions and sign-off. Reading the closing *phase*
|
|
1501
|
+
// rather than the closing minute is what makes a concluding state
|
|
1502
|
+
// findable at all; `tailRange` stays narrow because it also sizes a
|
|
1503
|
+
// bounded re-decode, which this does not.
|
|
1504
|
+
const closingRange = asset.durationSecs
|
|
1505
|
+
? {
|
|
1506
|
+
startSecs: Math.max(
|
|
1507
|
+
0,
|
|
1508
|
+
asset.durationSecs -
|
|
1509
|
+
Math.min(900, Math.max(LIMITS.durationSecs, asset.durationSecs * 0.2)),
|
|
1510
|
+
),
|
|
1511
|
+
endSecs: asset.durationSecs,
|
|
1512
|
+
}
|
|
1513
|
+
: undefined;
|
|
1514
|
+
// Two views of the closing phase, because they fail in opposite ways.
|
|
1515
|
+
// Descriptive readings state a concluding value plainly but are
|
|
1516
|
+
// sparse; speech is dense but, on conversational audio, is mostly
|
|
1517
|
+
// fragments. Ranking them together lets whichever is noisier for this
|
|
1518
|
+
// source crowd the other out, so each is retrieved to its own quota.
|
|
1519
|
+
const [closingDescriptive, closingAny] = closingRange
|
|
1520
|
+
? await Promise.all([
|
|
1521
|
+
searchVideoKnowledge(mediaAssetId, query, 8, {
|
|
1522
|
+
modalities: ['visual', 'computed'],
|
|
1523
|
+
minimumRangeDistanceSecs: 0,
|
|
1524
|
+
semanticScores,
|
|
1525
|
+
queryPlan: plan,
|
|
1526
|
+
videoDurationSecs: asset.durationSecs,
|
|
1527
|
+
timeRange: closingRange,
|
|
1528
|
+
}),
|
|
1529
|
+
searchVideoKnowledge(mediaAssetId, query, 6, {
|
|
1530
|
+
modalities: plan.modalities,
|
|
1531
|
+
minimumRangeDistanceSecs: 0,
|
|
1532
|
+
semanticScores,
|
|
1533
|
+
queryPlan: plan,
|
|
1534
|
+
videoDurationSecs: asset.durationSecs,
|
|
1535
|
+
timeRange: closingRange,
|
|
1536
|
+
}),
|
|
1537
|
+
])
|
|
1538
|
+
: [[], []];
|
|
1539
|
+
const closingEvidence = [
|
|
1540
|
+
...closingDescriptive,
|
|
1541
|
+
...closingAny.filter(
|
|
1542
|
+
(candidate) =>
|
|
1543
|
+
!closingDescriptive.some(
|
|
1544
|
+
(existing) => existing.evidence.id === candidate.evidence.id,
|
|
1545
|
+
),
|
|
1546
|
+
),
|
|
1547
|
+
];
|
|
1548
|
+
// Lexical OCR often outranks a VLM observation on an empty tail
|
|
1549
|
+
// lookup. Outcome evidence must still include the bounded visual
|
|
1550
|
+
// interpretation of that terminal range, otherwise chat silently
|
|
1551
|
+
// falls back to text fragments despite a completed GPU analysis.
|
|
1552
|
+
const terminalSemanticEvidence =
|
|
1553
|
+
closingRange && plan.kinds.includes('outcome')
|
|
1554
|
+
? await searchVideoKnowledge(mediaAssetId, query, 4, {
|
|
1555
|
+
modalities: ['visual', 'computed'],
|
|
1556
|
+
minimumRangeDistanceSecs: 0,
|
|
1557
|
+
semanticScores,
|
|
1558
|
+
queryPlan: plan,
|
|
1559
|
+
videoDurationSecs: asset.durationSecs,
|
|
1560
|
+
timeRange: closingRange,
|
|
1561
|
+
})
|
|
1562
|
+
: [];
|
|
1563
|
+
if (terminalSemanticEvidence.length > 0) {
|
|
1564
|
+
const terminalEvidenceIds = new Set(
|
|
1565
|
+
terminalSemanticEvidence.map((hit) => hit.evidence.id),
|
|
1566
|
+
);
|
|
1567
|
+
hits = [
|
|
1568
|
+
...terminalSemanticEvidence,
|
|
1569
|
+
...hits.filter((hit) => !terminalEvidenceIds.has(hit.evidence.id)),
|
|
1570
|
+
].slice(0, limit ?? 8);
|
|
1571
|
+
}
|
|
1572
|
+
const closingEvidenceWithSemantics = [
|
|
1573
|
+
...closingEvidence,
|
|
1574
|
+
...terminalSemanticEvidence.filter(
|
|
1575
|
+
(candidate) =>
|
|
1576
|
+
!closingEvidence.some((existing) => existing.evidence.id === candidate.evidence.id),
|
|
1577
|
+
),
|
|
1578
|
+
];
|
|
1579
|
+
// Typed state evidence records short text that recurred on screen,
|
|
1580
|
+
// kept distinct from free-form OCR. It locates where a display was
|
|
1581
|
+
// present and when it changed, for any question about how a source
|
|
1582
|
+
// ends up; it never establishes what that text refers to.
|
|
1583
|
+
const structuredOutcomeHit = plan.kinds.includes('outcome')
|
|
1584
|
+
? hits.find((hit) => structuredStateFromEvidence(hit.evidence))
|
|
1585
|
+
: undefined;
|
|
1586
|
+
const structuredOutcomeState = structuredOutcomeHit
|
|
1587
|
+
? structuredStateFromEvidence(structuredOutcomeHit.evidence)
|
|
1588
|
+
: undefined;
|
|
1589
|
+
// A named-person appearance answer must be anchored to a visual
|
|
1590
|
+
// observation from the range where that identity was grounded.
|
|
1591
|
+
// Otherwise a generic scene caption can silently attach another
|
|
1592
|
+
// person's clothing or action to the requested name.
|
|
1593
|
+
if (plan.subjectName && subjectRange) {
|
|
1594
|
+
const groundedVisualHits = hits.filter(
|
|
1595
|
+
(hit) => hit.evidence.modality === 'visual' && overlapsSubjectRange(hit),
|
|
1596
|
+
);
|
|
1597
|
+
hits = groundedVisualHits;
|
|
1598
|
+
}
|
|
1599
|
+
const verification = await verifyMediaEvidence({
|
|
1600
|
+
mediaAssetId,
|
|
1601
|
+
evidenceIds: hits.map((hit) => hit.evidence.id),
|
|
1602
|
+
requiresFramePrecision: /\b(exact|precisely|frame|at\s+\d{1,2}:\d{2})\b/i.test(query),
|
|
1603
|
+
});
|
|
1604
|
+
const requiresCorroboration = plan.kinds.some((kind) =>
|
|
1605
|
+
[
|
|
1606
|
+
'outcome',
|
|
1607
|
+
'state-change',
|
|
1608
|
+
'comparison',
|
|
1609
|
+
'counting',
|
|
1610
|
+
'computation',
|
|
1611
|
+
'person-attribute',
|
|
1612
|
+
].includes(kind),
|
|
1613
|
+
);
|
|
1614
|
+
// Where to look is decided by fusing every timestamped signal
|
|
1615
|
+
// retrieval produced -- semantic evidence, cross-modal clip vectors,
|
|
1616
|
+
// lexical hits, the indexed hierarchy, and the source's own ending --
|
|
1617
|
+
// rather than by a fixed rule per claim kind. A conclusion is often
|
|
1618
|
+
// stated before the last minute of a recording, and the moment that
|
|
1619
|
+
// answers a question is frequently nowhere near the top-ranked
|
|
1620
|
+
// chapter, so agreement between independent signals locates it far
|
|
1621
|
+
// more reliably than any single one of them.
|
|
1622
|
+
const focusSignals: FocusSignal[] = [
|
|
1623
|
+
...sourceWideVisualOverviewEvidence.slice(0, 3).map((hit) => ({
|
|
1624
|
+
kind: 'lexical' as const,
|
|
1625
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
1626
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
1627
|
+
score: 2,
|
|
1628
|
+
label: 'source-wide visual reading',
|
|
1629
|
+
})),
|
|
1630
|
+
...semanticEvidence
|
|
1631
|
+
.filter((hit) => hit.endSecs - hit.startSecs <= 180)
|
|
1632
|
+
.slice(0, 12)
|
|
1633
|
+
.map((hit) => ({
|
|
1634
|
+
kind: 'semantic' as const,
|
|
1635
|
+
startSecs: hit.startSecs,
|
|
1636
|
+
endSecs: hit.endSecs,
|
|
1637
|
+
score: hit.score,
|
|
1638
|
+
})),
|
|
1639
|
+
...visualMatches.map((match) => ({
|
|
1640
|
+
kind: 'clip-embedding' as const,
|
|
1641
|
+
startSecs: match.startSecs,
|
|
1642
|
+
endSecs: match.endSecs,
|
|
1643
|
+
score: match.score,
|
|
1644
|
+
})),
|
|
1645
|
+
...hits
|
|
1646
|
+
.filter(
|
|
1647
|
+
(hit) => hit.evidence.timeRange.endSecs - hit.evidence.timeRange.startSecs <= 180,
|
|
1648
|
+
)
|
|
1649
|
+
.slice(0, 8)
|
|
1650
|
+
.map((hit) => ({
|
|
1651
|
+
kind: 'lexical' as const,
|
|
1652
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
1653
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
1654
|
+
score: hit.score,
|
|
1655
|
+
})),
|
|
1656
|
+
...(investigationPlan?.candidateRanges ?? []).slice(0, 6).map((range, position) => ({
|
|
1657
|
+
kind: 'hierarchy' as const,
|
|
1658
|
+
startSecs: range.startSecs,
|
|
1659
|
+
endSecs: range.endSecs,
|
|
1660
|
+
score: 1 - position * 0.15,
|
|
1661
|
+
label: range.reason,
|
|
1662
|
+
})),
|
|
1663
|
+
// How a source concludes is a prior, never an instruction: it only
|
|
1664
|
+
// ever competes with the other signals for a question whose answer
|
|
1665
|
+
// is a resolution or a final state. Where in the closing phase to
|
|
1666
|
+
// look is itself decided by relevance -- the best semantic match
|
|
1667
|
+
// inside that phase -- rather than by pointing blindly at the last
|
|
1668
|
+
// minute, which is usually sign-off rather than substance.
|
|
1669
|
+
...(closingRange &&
|
|
1670
|
+
(plan.kinds.includes('outcome') || plan.kinds.includes('state-change'))
|
|
1671
|
+
? (() => {
|
|
1672
|
+
const closing = semanticEvidence
|
|
1673
|
+
.filter(
|
|
1674
|
+
(hit) =>
|
|
1675
|
+
hit.startSecs <= closingRange.endSecs &&
|
|
1676
|
+
hit.endSecs >= closingRange.startSecs,
|
|
1677
|
+
)
|
|
1678
|
+
.slice(0, 3);
|
|
1679
|
+
return closing.length > 0
|
|
1680
|
+
? closing.map((hit) => ({
|
|
1681
|
+
kind: 'ending' as const,
|
|
1682
|
+
startSecs: hit.startSecs,
|
|
1683
|
+
endSecs: hit.endSecs,
|
|
1684
|
+
score: hit.score,
|
|
1685
|
+
}))
|
|
1686
|
+
: [
|
|
1687
|
+
{
|
|
1688
|
+
kind: 'ending' as const,
|
|
1689
|
+
startSecs: tailRange?.startSecs ?? closingRange.startSecs,
|
|
1690
|
+
endSecs: closingRange.endSecs,
|
|
1691
|
+
score: 1,
|
|
1692
|
+
},
|
|
1693
|
+
];
|
|
1694
|
+
})()
|
|
1695
|
+
: []),
|
|
1696
|
+
];
|
|
1697
|
+
const allFocusRanges = fuseFocusRanges(focusSignals, {
|
|
1698
|
+
maxRanges: 8,
|
|
1699
|
+
maxWindowSecs: LIMITS.durationSecs,
|
|
1700
|
+
});
|
|
1701
|
+
// A recurring on-screen state looks identical to a semantic ranker
|
|
1702
|
+
// wherever it occurs, so a mid-source reading of it can outrank the
|
|
1703
|
+
// concluding one on similarity alone. When the question is explicitly
|
|
1704
|
+
// about how the source ended, time is the only thing that separates
|
|
1705
|
+
// them: keep the closing phase in front, and fall back to the full
|
|
1706
|
+
// list only when nothing there matched at all.
|
|
1707
|
+
const closingFocusRanges =
|
|
1708
|
+
closingRange && plan.kinds.includes('outcome')
|
|
1709
|
+
? allFocusRanges.filter(
|
|
1710
|
+
(range) =>
|
|
1711
|
+
range.startSecs <= closingRange.endSecs &&
|
|
1712
|
+
range.endSecs >= closingRange.startSecs,
|
|
1713
|
+
)
|
|
1714
|
+
: [];
|
|
1715
|
+
const focusRanges = (
|
|
1716
|
+
closingFocusRanges.length > 0
|
|
1717
|
+
? [
|
|
1718
|
+
...closingFocusRanges,
|
|
1719
|
+
...allFocusRanges.filter((range) => !closingFocusRanges.includes(range)),
|
|
1720
|
+
]
|
|
1721
|
+
: allFocusRanges
|
|
1722
|
+
).slice(0, 5);
|
|
1723
|
+
// A named-person claim is still verified from where that person is
|
|
1724
|
+
// named or addressed -- identity grounding outranks topical signal.
|
|
1725
|
+
const requestedRange = subjectRange ?? focusRanges[0] ?? tailRange;
|
|
1726
|
+
const recommendedInspection = requestedRange
|
|
1727
|
+
? {
|
|
1728
|
+
startSecs: requestedRange.startSecs,
|
|
1729
|
+
endSecs: Math.min(
|
|
1730
|
+
requestedRange.endSecs,
|
|
1731
|
+
requestedRange.startSecs +
|
|
1732
|
+
(plan.kinds.includes('outcome') ? LIMITS.durationSecs : 30),
|
|
1733
|
+
),
|
|
1734
|
+
purpose: plan.kinds.includes('counting')
|
|
1735
|
+
? 'count'
|
|
1736
|
+
: plan.kinds.includes('exact-ocr')
|
|
1737
|
+
? 'high-res-ocr'
|
|
1738
|
+
: plan.kinds.includes('comparison') || plan.kinds.includes('state-change')
|
|
1739
|
+
? 'compare'
|
|
1740
|
+
: 'verify-visual',
|
|
1741
|
+
...(plan.subjectName
|
|
1742
|
+
? {
|
|
1743
|
+
focusHint: `Multiple people may be visible in this range. Identify which visible person is "${plan.subjectName}" (named or addressed near this moment) and describe that specific person, not the scene in general.`,
|
|
1744
|
+
}
|
|
1745
|
+
: {}),
|
|
1746
|
+
}
|
|
1747
|
+
: undefined;
|
|
1748
|
+
// Outcome claims need a short semantic read of the ending, not just
|
|
1749
|
+
// an object/OCR anchor that happens to overlap it. A whole-video
|
|
1750
|
+
// summary is navigation context only: it cannot establish what
|
|
1751
|
+
// happened at the end. Counts, computed answers, comparisons, and
|
|
1752
|
+
// state changes always need deliberate bounded coverage too.
|
|
1753
|
+
const hasTerminalSemanticEvidence = closingEvidenceWithSemantics.some(
|
|
1754
|
+
(hit) =>
|
|
1755
|
+
hit.evidence.modality === 'visual' &&
|
|
1756
|
+
hit.evidence.confidence.score >= 0.5 &&
|
|
1757
|
+
hit.evidence.timeRange.endSecs - hit.evidence.timeRange.startSecs <= 180 &&
|
|
1758
|
+
hit.evidence.confidence.uncertaintyReasons.some((reason) =>
|
|
1759
|
+
reason.includes('Semantic VLM interpretation'),
|
|
1760
|
+
),
|
|
1761
|
+
);
|
|
1762
|
+
const hasTerminalDirectVerdict = closingEvidenceWithSemantics.some(
|
|
1763
|
+
(hit) =>
|
|
1764
|
+
hit.evidence.modality === 'visual' &&
|
|
1765
|
+
/Claim verdict:\s*direct/i.test(evidenceText(hit.evidence.payload)),
|
|
1766
|
+
);
|
|
1767
|
+
// Re-decoding the source is the single slowest thing this tool can
|
|
1768
|
+
// do. The shared gate evaluates indexed provenance, confidence,
|
|
1769
|
+
// temporal coverage, and locator agreement before any live read.
|
|
1770
|
+
const topFocusRange = focusRanges[0];
|
|
1771
|
+
const sufficiencyCandidates = [
|
|
1772
|
+
...hits,
|
|
1773
|
+
...closingEvidenceWithSemantics.filter(
|
|
1774
|
+
(candidate) =>
|
|
1775
|
+
!hits.some((existing) => existing.evidence.id === candidate.evidence.id),
|
|
1776
|
+
),
|
|
1777
|
+
];
|
|
1778
|
+
const indexAlreadyAnswers = indexedVideoEvidenceIsSufficient({
|
|
1779
|
+
verificationStatus: verification.status,
|
|
1780
|
+
questionKinds: plan.kinds,
|
|
1781
|
+
evidence: sufficiencyCandidates.map((hit) => ({
|
|
1782
|
+
modality: hit.evidence.modality,
|
|
1783
|
+
text: evidenceText(hit.evidence.payload),
|
|
1784
|
+
confidenceScore: hit.evidence.confidence.score,
|
|
1785
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
1786
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
1787
|
+
conflict: hit.conflict,
|
|
1788
|
+
})),
|
|
1789
|
+
focusSources: topFocusRange?.sources,
|
|
1790
|
+
durationSecs: asset.durationSecs,
|
|
1791
|
+
hierarchyRanges: investigationPlan?.coverage?.representedRanges,
|
|
1792
|
+
});
|
|
1793
|
+
const requiresFreshInspection =
|
|
1794
|
+
!indexAlreadyAnswers &&
|
|
1795
|
+
(verification.status !== 'supported' ||
|
|
1796
|
+
plan.kinds.some((kind) =>
|
|
1797
|
+
[
|
|
1798
|
+
'state-change',
|
|
1799
|
+
'comparison',
|
|
1800
|
+
'counting',
|
|
1801
|
+
'computation',
|
|
1802
|
+
'person-attribute',
|
|
1803
|
+
].includes(kind),
|
|
1804
|
+
) ||
|
|
1805
|
+
(plan.kinds.includes('outcome') &&
|
|
1806
|
+
(!hasTerminalSemanticEvidence || !hasTerminalDirectVerdict)));
|
|
1807
|
+
// Device entitlements are evaluated by the managed cloud control
|
|
1808
|
+
// plane. An unlimited plan may inspect the bounded range directly;
|
|
1809
|
+
// every other installation keeps the conservative browser budget.
|
|
1810
|
+
const videoRuntimeConfig = activeProjectConfig?.toolConfigs?.['video-intelligence'];
|
|
1811
|
+
const usesManagedCloudRuntime =
|
|
1812
|
+
(videoRuntimeConfig?.runtimeMode ?? 'managed-cloud') === 'managed-cloud';
|
|
1813
|
+
// A RAG-complete answer must stay entirely local to the index. Even
|
|
1814
|
+
// checking remote inspection capacity adds avoidable chat latency
|
|
1815
|
+
// and makes the fast path look like analysis was involved.
|
|
1816
|
+
const hasManagedCloudCapacity =
|
|
1817
|
+
usesManagedCloudRuntime && requiresFreshInspection && recommendedInspection
|
|
1818
|
+
? await hasVideoIntelligenceCapacity().catch(() => false)
|
|
1819
|
+
: false;
|
|
1820
|
+
const inspectionDecision = hasManagedCloudCapacity
|
|
1821
|
+
? {
|
|
1822
|
+
decision:
|
|
1823
|
+
requiresCorroboration && requiresFreshInspection
|
|
1824
|
+
? ('required' as const)
|
|
1825
|
+
: ('optional' as const),
|
|
1826
|
+
reason: 'managed-cloud-entitlement',
|
|
1827
|
+
committed: {
|
|
1828
|
+
durationSecs: recommendedInspection
|
|
1829
|
+
? recommendedInspection.endSecs - recommendedInspection.startSecs
|
|
1830
|
+
: 30,
|
|
1831
|
+
bytes: 64 * 1024 * 1024,
|
|
1832
|
+
sandboxSeconds: 30,
|
|
1833
|
+
spendUsd: 0,
|
|
1834
|
+
},
|
|
1835
|
+
}
|
|
1836
|
+
: decideInspection({
|
|
1837
|
+
required:
|
|
1838
|
+
(requiresCorroboration && requiresFreshInspection) ||
|
|
1839
|
+
(plan.requiresInspectionWhenInsufficient &&
|
|
1840
|
+
['insufficient', 'needs_inspection'].includes(verification.status)) ||
|
|
1841
|
+
false,
|
|
1842
|
+
plausibleRange: Boolean(recommendedInspection),
|
|
1843
|
+
estimate: {
|
|
1844
|
+
// A wide recommendedInspection (up to 180s for an outcome
|
|
1845
|
+
// claim) is never dispatched as one request -- it's
|
|
1846
|
+
// chunked into <=LIMITS.durationSecs pieces below. Budget
|
|
1847
|
+
// against one chunk's real cost, not the full range, or
|
|
1848
|
+
// this always trips exceedsPerBundle and gets downgraded
|
|
1849
|
+
// to background-refinement before a single chunk ever runs.
|
|
1850
|
+
durationSecs: recommendedInspection
|
|
1851
|
+
? Math.min(
|
|
1852
|
+
recommendedInspection.endSecs - recommendedInspection.startSecs,
|
|
1853
|
+
LIMITS.durationSecs,
|
|
1854
|
+
)
|
|
1855
|
+
: 30,
|
|
1856
|
+
bytes: 64 * 1024 * 1024,
|
|
1857
|
+
sandboxSeconds: 30,
|
|
1858
|
+
spendUsd: 0,
|
|
1859
|
+
lowerResolutionProbability: hits.length > 0 ? 0.65 : 0,
|
|
1860
|
+
},
|
|
1861
|
+
budget: {
|
|
1862
|
+
remainingDurationSecs: 180,
|
|
1863
|
+
remainingBytes: 1024 * 1024 * 1024,
|
|
1864
|
+
remainingSandboxSeconds: 600,
|
|
1865
|
+
remainingSpendUsd: 0.5,
|
|
1866
|
+
usedBundleRuns: 0,
|
|
1867
|
+
},
|
|
1868
|
+
});
|
|
1869
|
+
let autoInspection:
|
|
1870
|
+
| {
|
|
1871
|
+
status: 'completed' | 'failed';
|
|
1872
|
+
range: { startSecs: number; endSecs: number };
|
|
1873
|
+
purpose: string;
|
|
1874
|
+
evidenceCount?: number;
|
|
1875
|
+
chunksInspected?: number;
|
|
1876
|
+
error?: string;
|
|
1877
|
+
rule?: string;
|
|
1878
|
+
}
|
|
1879
|
+
| undefined;
|
|
1880
|
+
// Before paying for a dispatched re-index, re-watch the candidate
|
|
1881
|
+
// windows directly. Sampling frames off the source and reading them
|
|
1882
|
+
// in one request settles most questions in seconds, where the
|
|
1883
|
+
// dispatched path spends minutes on a cold worker and routinely
|
|
1884
|
+
// outlives the turn -- which is what makes an answerable question
|
|
1885
|
+
// come back as "the video does not show this".
|
|
1886
|
+
const rewatch =
|
|
1887
|
+
allowAutomaticInspection && requiresFreshInspection && recommendedInspection
|
|
1888
|
+
? await reWatchSource({
|
|
1889
|
+
asset,
|
|
1890
|
+
question: query,
|
|
1891
|
+
ranges:
|
|
1892
|
+
sourceWideVisualOverviewEvidence.length > 0
|
|
1893
|
+
? sourceWideVisualOverviewEvidence.slice(0, 1).map((hit) => ({
|
|
1894
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
1895
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
1896
|
+
label: 'source-wide visual reading',
|
|
1897
|
+
}))
|
|
1898
|
+
: (focusRanges.length > 0 ? focusRanges : [recommendedInspection]).slice(
|
|
1899
|
+
0,
|
|
1900
|
+
3,
|
|
1901
|
+
),
|
|
1902
|
+
subjectName: plan.subjectName,
|
|
1903
|
+
mediaAssetId,
|
|
1904
|
+
})
|
|
1905
|
+
: undefined;
|
|
1906
|
+
// A re-watch reads the source; it does not add to the index. So there
|
|
1907
|
+
// is nothing to re-retrieve, and the reading simply travels with the
|
|
1908
|
+
// evidence already gathered below.
|
|
1909
|
+
const settledByReWatch = Boolean(rewatch?.findings.length);
|
|
1910
|
+
if (
|
|
1911
|
+
!settledByReWatch &&
|
|
1912
|
+
allowAutomaticInspection &&
|
|
1913
|
+
inspectionDecision.decision === 'required' &&
|
|
1914
|
+
recommendedInspection &&
|
|
1915
|
+
origin
|
|
1916
|
+
) {
|
|
1917
|
+
const inspectionUrl = new URL(workspaceServerUrl('/api/media/inspect'), origin);
|
|
1918
|
+
if (projectId) inspectionUrl.searchParams.set('projectId', projectId);
|
|
1919
|
+
// A single request wider than LIMITS.durationSecs always gets
|
|
1920
|
+
// downgraded to background-refinement by decideInspection (see
|
|
1921
|
+
// inspection-policy.ts) instead of running now -- an outcome
|
|
1922
|
+
// question's recommended range can be up to 180s, so it must be
|
|
1923
|
+
// dispatched as sequential <=LIMITS.durationSecs chunks, not one
|
|
1924
|
+
// call, or the automatic inspection silently never happens.
|
|
1925
|
+
// Ordered by whatever signal already exists (a visual-embedding
|
|
1926
|
+
// match scores higher relevance to the actual question than a
|
|
1927
|
+
// blind guess), tail-first as the fallback tiebreak since a
|
|
1928
|
+
// concluding state is often near the end: stop as soon as
|
|
1929
|
+
// a chunk actually resolves the claim instead of always
|
|
1930
|
+
// spending the full budget. This is claim/signal-driven, not
|
|
1931
|
+
// tied to any particular language, domain, or video.
|
|
1932
|
+
const chunkSignalScore = (chunk: { startSecs: number; endSecs: number }) =>
|
|
1933
|
+
visualMatches.reduce(
|
|
1934
|
+
(sum, match) =>
|
|
1935
|
+
sum +
|
|
1936
|
+
(match.startSecs < chunk.endSecs && match.endSecs > chunk.startSecs
|
|
1937
|
+
? match.score
|
|
1938
|
+
: 0),
|
|
1939
|
+
0,
|
|
1940
|
+
);
|
|
1941
|
+
const chunks = chunkTimeRange(
|
|
1942
|
+
recommendedInspection.startSecs,
|
|
1943
|
+
recommendedInspection.endSecs,
|
|
1944
|
+
LIMITS.durationSecs,
|
|
1945
|
+
).sort(
|
|
1946
|
+
(a, b) => chunkSignalScore(b) - chunkSignalScore(a) || b.startSecs - a.startSecs,
|
|
1947
|
+
);
|
|
1948
|
+
let evidenceCount = 0;
|
|
1949
|
+
let chunksCompleted = 0;
|
|
1950
|
+
let refined: any;
|
|
1951
|
+
let chunkError: string | undefined;
|
|
1952
|
+
let workerTimedOut = false;
|
|
1953
|
+
// Chunks are signal-ordered, not necessarily chronological, so
|
|
1954
|
+
// the covered span has to be tracked explicitly rather than
|
|
1955
|
+
// read off however many were visited from one end.
|
|
1956
|
+
let coveredStartSecs = recommendedInspection.endSecs;
|
|
1957
|
+
let coveredEndSecs = recommendedInspection.startSecs;
|
|
1958
|
+
// A person is watching this answer arrive. Each chunk may wait out
|
|
1959
|
+
// a cold worker, so without a ceiling across the whole loop a
|
|
1960
|
+
// multi-chunk range can hold one chat turn open for many minutes.
|
|
1961
|
+
// Spend the budget on the best-ranked chunks and then answer from
|
|
1962
|
+
// what was gathered, rather than making the wait unbounded.
|
|
1963
|
+
const inspectionDeadline = Date.now() + CHAT_INSPECTION_BUDGET_MS;
|
|
1964
|
+
let budgetExhausted = false;
|
|
1965
|
+
for (const chunk of chunks) {
|
|
1966
|
+
if (Date.now() >= inspectionDeadline) {
|
|
1967
|
+
budgetExhausted = true;
|
|
1968
|
+
break;
|
|
1969
|
+
}
|
|
1970
|
+
try {
|
|
1971
|
+
const response = await fetch(inspectionUrl, {
|
|
1972
|
+
method: 'POST',
|
|
1973
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1974
|
+
body: JSON.stringify({
|
|
1975
|
+
mediaAssetId,
|
|
1976
|
+
startSecs: chunk.startSecs,
|
|
1977
|
+
endSecs: chunk.endSecs,
|
|
1978
|
+
purpose: recommendedInspection.purpose,
|
|
1979
|
+
queryId: `chat-verification:${mediaAssetId}:${query}:${chunk.startSecs}`.slice(
|
|
1980
|
+
0,
|
|
1981
|
+
128,
|
|
1982
|
+
),
|
|
1983
|
+
question: recommendedInspection.focusHint
|
|
1984
|
+
? `${query}\n\n${recommendedInspection.focusHint}`
|
|
1985
|
+
: query,
|
|
1986
|
+
...(plan.subjectName ? { knownEntities: [plan.subjectName] } : {}),
|
|
1987
|
+
maxFrames: 24,
|
|
1988
|
+
toolCallId,
|
|
1989
|
+
// Never let one chunk consume the whole interactive budget.
|
|
1990
|
+
maxWaitMs: Math.max(30_000, inspectionDeadline - Date.now()),
|
|
1991
|
+
}),
|
|
1992
|
+
});
|
|
1993
|
+
const inspectionResult = await response.json().catch(() => ({}));
|
|
1994
|
+
if (!response.ok) {
|
|
1995
|
+
chunkError =
|
|
1996
|
+
typeof inspectionResult.error === 'string'
|
|
1997
|
+
? inspectionResult.error
|
|
1998
|
+
: `Inspection returned HTTP ${response.status}.`;
|
|
1999
|
+
// The analysis service itself didn't respond -- trying more
|
|
2000
|
+
// chunks just means more multi-minute waits for the same
|
|
2001
|
+
// outcome, so stop immediately rather than after every
|
|
2002
|
+
// remaining chunk times out too.
|
|
2003
|
+
if (response.status === 504 || inspectionResult.workerTimeout) {
|
|
2004
|
+
workerTimedOut = true;
|
|
2005
|
+
}
|
|
2006
|
+
break;
|
|
2007
|
+
}
|
|
2008
|
+
chunksCompleted += 1;
|
|
2009
|
+
coveredStartSecs = Math.min(coveredStartSecs, chunk.startSecs);
|
|
2010
|
+
coveredEndSecs = Math.max(coveredEndSecs, chunk.endSecs);
|
|
2011
|
+
evidenceCount += Array.isArray(inspectionResult.evidence)
|
|
2012
|
+
? inspectionResult.evidence.length
|
|
2013
|
+
: 0;
|
|
2014
|
+
refined = await run(false);
|
|
2015
|
+
if (!refined?.claimVerification?.requiresCorroboration) break;
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
chunkError = error instanceof Error ? error.message : 'Cloud inspection failed.';
|
|
2018
|
+
break;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (chunksCompleted > 0) {
|
|
2022
|
+
// Every available chunk was actually inspected (not deferred,
|
|
2023
|
+
// not timed out) and the claim still isn't fully corroborated
|
|
2024
|
+
// -- more bounded inspection of this same range won't change
|
|
2025
|
+
// that. Rather than leave requiresCorroboration true forever
|
|
2026
|
+
// (which reads to the model as "keep trying" indefinitely,
|
|
2027
|
+
// producing either an infinite tool-call loop or a refusal to
|
|
2028
|
+
// answer), tell it plainly that the search budget for this
|
|
2029
|
+
// claim is exhausted and a best-effort, confidence-qualified
|
|
2030
|
+
// answer from the gathered evidence is expected now.
|
|
2031
|
+
// Exhausted either by running out of candidate chunks, or by a
|
|
2032
|
+
// chunk failing partway through (chunkError) -- either way, at
|
|
2033
|
+
// least one real inspection already ran and no further
|
|
2034
|
+
// automatic one will follow, so the same instruction applies.
|
|
2035
|
+
const exhausted =
|
|
2036
|
+
(chunksCompleted >= chunks.length || Boolean(chunkError) || budgetExhausted) &&
|
|
2037
|
+
refined?.claimVerification?.requiresCorroboration;
|
|
2038
|
+
return {
|
|
2039
|
+
...refined,
|
|
2040
|
+
...(exhausted
|
|
2041
|
+
? {
|
|
2042
|
+
claimVerification: {
|
|
2043
|
+
...refined.claimVerification,
|
|
2044
|
+
requiresCorroboration: false,
|
|
2045
|
+
rule: 'Every available bounded range for this claim has now been inspected; no further inspection of this range is possible. Answer from the gathered evidence, stating your confidence level and citing the specific evidence (or its absence) rather than declining to answer or claiming the video does not show it.',
|
|
2046
|
+
},
|
|
2047
|
+
}
|
|
2048
|
+
: {}),
|
|
2049
|
+
autoInspection: {
|
|
2050
|
+
status: 'completed' as const,
|
|
2051
|
+
range: { startSecs: coveredStartSecs, endSecs: coveredEndSecs },
|
|
2052
|
+
purpose: recommendedInspection.purpose,
|
|
2053
|
+
evidenceCount,
|
|
2054
|
+
chunksInspected: chunksCompleted,
|
|
2055
|
+
},
|
|
2056
|
+
};
|
|
2057
|
+
}
|
|
2058
|
+
autoInspection = {
|
|
2059
|
+
status: 'failed',
|
|
2060
|
+
range: {
|
|
2061
|
+
startSecs: recommendedInspection.startSecs,
|
|
2062
|
+
endSecs: recommendedInspection.endSecs,
|
|
2063
|
+
},
|
|
2064
|
+
purpose: recommendedInspection.purpose,
|
|
2065
|
+
...(workerTimedOut
|
|
2066
|
+
? {
|
|
2067
|
+
rule: "The video analysis service didn't respond -- this range was never actually checked. Tell the user the check couldn't run right now; never phrase this as the video not showing the answer.",
|
|
2068
|
+
}
|
|
2069
|
+
: {}),
|
|
2070
|
+
error: chunkError ?? 'Cloud inspection failed.',
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
void trackUsageEvent({
|
|
2074
|
+
type: 'media_processing',
|
|
2075
|
+
mediaOperation: 'investigation',
|
|
2076
|
+
mediaAssetId,
|
|
2077
|
+
queryKind: plan.kinds.join(','),
|
|
2078
|
+
evidenceCount: hits.length,
|
|
2079
|
+
timestamp: new Date().toISOString(),
|
|
2080
|
+
});
|
|
2081
|
+
const sourceUrl = workspaceServerUrl(
|
|
2082
|
+
`/api/media/${asset.id}${
|
|
2083
|
+
projectId ? `?projectId=${encodeURIComponent(projectId)}` : ''
|
|
2084
|
+
}`,
|
|
2085
|
+
);
|
|
2086
|
+
const buildSeekUrl = (startSecs: number): string | undefined => {
|
|
2087
|
+
if (asset.originalUrl) {
|
|
2088
|
+
try {
|
|
2089
|
+
const url = new URL(asset.originalUrl);
|
|
2090
|
+
if (url.hostname.includes('youtube.com') || url.hostname.includes('youtu.be')) {
|
|
2091
|
+
url.searchParams.set('t', String(Math.floor(startSecs)));
|
|
2092
|
+
return url.toString();
|
|
2093
|
+
}
|
|
2094
|
+
} catch {}
|
|
2095
|
+
}
|
|
2096
|
+
return `${sourceUrl}${sourceUrl.includes('?') ? '&' : '?'}t=${startSecs}`;
|
|
2097
|
+
};
|
|
2098
|
+
const modalityLabel = (modality: string) =>
|
|
2099
|
+
modality === 'transcript'
|
|
2100
|
+
? 'Speech'
|
|
2101
|
+
: modality === 'ocr'
|
|
2102
|
+
? 'On-screen text'
|
|
2103
|
+
: modality === 'visual'
|
|
2104
|
+
? 'Visual'
|
|
2105
|
+
: modality === 'audio-event'
|
|
2106
|
+
? 'Audio'
|
|
2107
|
+
: modality === 'computed'
|
|
2108
|
+
? 'Timeline'
|
|
2109
|
+
: 'Evidence';
|
|
2110
|
+
// Renders through the host's generic output.ui.kind contract
|
|
2111
|
+
// (message-item.tsx / chat-citations.tsx) -- the same mechanism
|
|
2112
|
+
// any marketplace tool's result can use, nothing video-specific
|
|
2113
|
+
// hardcoded on the chat-UI side. A clip is only offered when
|
|
2114
|
+
// verification actually supports the claim being cited.
|
|
2115
|
+
const primarySupportingHit =
|
|
2116
|
+
plan.subjectName && subjectRange
|
|
2117
|
+
? hits.find(
|
|
2118
|
+
(hit) =>
|
|
2119
|
+
hit.evidence.modality === 'visual' &&
|
|
2120
|
+
overlapsSubjectRange(hit) &&
|
|
2121
|
+
!hit.conflict &&
|
|
2122
|
+
Number.isFinite(hit.evidence.timeRange.startSecs),
|
|
2123
|
+
)
|
|
2124
|
+
: hits.find(
|
|
2125
|
+
(hit) => !hit.conflict && Number.isFinite(hit.evidence.timeRange.startSecs),
|
|
2126
|
+
);
|
|
2127
|
+
const ui = {
|
|
2128
|
+
kind: 'citations' as const,
|
|
2129
|
+
title: asset.fileName,
|
|
2130
|
+
fileName: asset.fileName,
|
|
2131
|
+
mediaType: 'video' as const,
|
|
2132
|
+
...(verification.status === 'supported' && primarySupportingHit
|
|
2133
|
+
? {
|
|
2134
|
+
mediaUrl: sourceUrl,
|
|
2135
|
+
sourceUrl: asset.originalUrl || sourceUrl,
|
|
2136
|
+
primaryTimestampSecs: primarySupportingHit.evidence.timeRange.startSecs,
|
|
2137
|
+
primaryEndSecs: primarySupportingHit.evidence.timeRange.endSecs,
|
|
2138
|
+
}
|
|
2139
|
+
: {}),
|
|
2140
|
+
items: hits.map((hit) => ({
|
|
2141
|
+
label: modalityLabel(hit.evidence.modality),
|
|
2142
|
+
detail: evidenceText(hit.evidence.payload),
|
|
2143
|
+
timestampSecs: hit.evidence.timeRange.startSecs,
|
|
2144
|
+
seekUrl: buildSeekUrl(hit.evidence.timeRange.startSecs),
|
|
2145
|
+
conflicted: hit.conflict,
|
|
2146
|
+
})),
|
|
2147
|
+
};
|
|
2148
|
+
const result = {
|
|
2149
|
+
success: true,
|
|
2150
|
+
mediaAssetId: asset.id,
|
|
2151
|
+
fileName: asset.fileName,
|
|
2152
|
+
sourceUrl,
|
|
2153
|
+
originalUrl: asset.originalUrl,
|
|
2154
|
+
ui,
|
|
2155
|
+
verification,
|
|
2156
|
+
plan,
|
|
2157
|
+
// The full hierarchy is chapter and scene summaries running to
|
|
2158
|
+
// tens of thousands of characters. Streaming that into every
|
|
2159
|
+
// answer crowds out the evidence itself and slows the turn down
|
|
2160
|
+
// for no gain, so what ships here is a navigable map: timecodes
|
|
2161
|
+
// and titles only. `planVideoInvestigation` still returns the
|
|
2162
|
+
// detail when the model deliberately asks for it.
|
|
2163
|
+
...(investigationPlan
|
|
2164
|
+
? {
|
|
2165
|
+
timeline: {
|
|
2166
|
+
strategy: investigationPlan.strategy,
|
|
2167
|
+
coverage: investigationPlan.coverage,
|
|
2168
|
+
chapters: investigationPlan.timeline.map((chapter) => ({
|
|
2169
|
+
at: `${formatTimecode(chapter.startSecs)}–${formatTimecode(chapter.endSecs)}`,
|
|
2170
|
+
startSecs: chapter.startSecs,
|
|
2171
|
+
endSecs: chapter.endSecs,
|
|
2172
|
+
title: chapter.title,
|
|
2173
|
+
})),
|
|
2174
|
+
rule: 'A map of the source for navigation only. It never establishes a fact; call read_evidence on a range to read what is actually recorded there.',
|
|
2175
|
+
},
|
|
2176
|
+
}
|
|
2177
|
+
: {}),
|
|
2178
|
+
// The ranked answer to "where should I look?", fused from every
|
|
2179
|
+
// timestamped signal. This is what the model should navigate by.
|
|
2180
|
+
...(focusRanges.length > 0
|
|
2181
|
+
? {
|
|
2182
|
+
focusRanges: {
|
|
2183
|
+
ranges: focusRanges.map((range) => ({
|
|
2184
|
+
at: `${formatTimecode(range.startSecs)}–${formatTimecode(range.endSecs)}`,
|
|
2185
|
+
startSecs: range.startSecs,
|
|
2186
|
+
endSecs: range.endSecs,
|
|
2187
|
+
score: range.score,
|
|
2188
|
+
agreedBy: range.sources,
|
|
2189
|
+
...(range.label ? { label: range.label } : {}),
|
|
2190
|
+
})),
|
|
2191
|
+
rule: 'Ranked by how strongly independent retrieval signals agree that the answer is in the window, not by evidence content. When the evidence below is not enough, call read_evidence on the top range first, and watch_original only if reading it still leaves the question open.',
|
|
2192
|
+
},
|
|
2193
|
+
}
|
|
2194
|
+
: {}),
|
|
2195
|
+
...(plan.subjectName
|
|
2196
|
+
? {
|
|
2197
|
+
entityGrounding: subjectRange
|
|
2198
|
+
? {
|
|
2199
|
+
resolved: true,
|
|
2200
|
+
subjectName: plan.subjectName,
|
|
2201
|
+
range: subjectRange,
|
|
2202
|
+
rule: "Describe this named person only from visual evidence overlapping the grounded range. Do not assign another visible person's clothing, action, or appearance based on a generic scene caption.",
|
|
2203
|
+
}
|
|
2204
|
+
: {
|
|
2205
|
+
resolved: false,
|
|
2206
|
+
subjectName: plan.subjectName,
|
|
2207
|
+
rule: `No transcript mention of "${plan.subjectName}" was found, so no visible person could be linked to that name. Say plainly that this person couldn't be identified in the video rather than describing the scene generically as if it answered the question.`,
|
|
2208
|
+
},
|
|
2209
|
+
}
|
|
2210
|
+
: {}),
|
|
2211
|
+
...(closingRange
|
|
2212
|
+
? {
|
|
2213
|
+
claimVerification: {
|
|
2214
|
+
// The window `closingEvidence` was actually drawn from.
|
|
2215
|
+
// Reporting the narrower inspection-sized tail here made
|
|
2216
|
+
// the model treat evidence from the closing phase as
|
|
2217
|
+
// out-of-range and discard it.
|
|
2218
|
+
closingRange: {
|
|
2219
|
+
...closingRange,
|
|
2220
|
+
at: `${formatTimecode(closingRange.startSecs)}–${formatTimecode(
|
|
2221
|
+
closingRange.endSecs,
|
|
2222
|
+
)}`,
|
|
2223
|
+
},
|
|
2224
|
+
claimKinds: plan.kinds,
|
|
2225
|
+
// The source itself was just read for this question, so
|
|
2226
|
+
// asking for corroboration would only send the turn back
|
|
2227
|
+
// for evidence it already has.
|
|
2228
|
+
requiresCorroboration:
|
|
2229
|
+
requiresCorroboration && requiresFreshInspection && !settledByReWatch,
|
|
2230
|
+
...(recommendedInspection && !settledByReWatch
|
|
2231
|
+
? { recommendedInspection }
|
|
2232
|
+
: {}),
|
|
2233
|
+
rule: settledByReWatch
|
|
2234
|
+
? 'The source was re-watched for this question; answer from what the readings in directObservation established, and cite their timestamps.'
|
|
2235
|
+
: structuredOutcomeState
|
|
2236
|
+
? 'A structured source state establishes only its displayed value. Do not assign the value to an identity, owner, or category unless evidence from the same source range explicitly identifies that mapping. Match the evidence range to the claim being made; never promote a local observation into a broader conclusion.'
|
|
2237
|
+
: 'Match the evidence range to the claim being made. When requiresCorroboration is true, run the recommended bounded inspection before answering, then query this tool again. Never promote a local observation into a broader conclusion.',
|
|
2238
|
+
closingEvidence: closingEvidenceWithSemantics.map((hit) => ({
|
|
2239
|
+
evidenceId: hit.evidence.id,
|
|
2240
|
+
modality: hit.evidence.modality,
|
|
2241
|
+
at: formatTimecode(hit.evidence.timeRange.startSecs),
|
|
2242
|
+
text: evidenceText(hit.evidence.payload),
|
|
2243
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
2244
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
2245
|
+
confidence: hit.evidence.confidence,
|
|
2246
|
+
})),
|
|
2247
|
+
},
|
|
2248
|
+
}
|
|
2249
|
+
: {}),
|
|
2250
|
+
...(structuredOutcomeHit && structuredOutcomeState
|
|
2251
|
+
? {
|
|
2252
|
+
structuredOutcomeState: {
|
|
2253
|
+
...structuredOutcomeState,
|
|
2254
|
+
startSecs: structuredOutcomeHit.evidence.timeRange.startSecs,
|
|
2255
|
+
endSecs: structuredOutcomeHit.evidence.timeRange.endSecs,
|
|
2256
|
+
confidence: structuredOutcomeHit.evidence.confidence,
|
|
2257
|
+
rule: 'This is a compact value read from the source. It establishes the displayed value, but not the identity or meaning of either side unless that mapping is also present in the cited evidence.',
|
|
2258
|
+
},
|
|
2259
|
+
}
|
|
2260
|
+
: {}),
|
|
2261
|
+
retrievalCapabilities: videoKnowledgeRetrievalCapabilities(),
|
|
2262
|
+
inspectionDecision,
|
|
2263
|
+
...(rewatch
|
|
2264
|
+
? {
|
|
2265
|
+
directObservation: {
|
|
2266
|
+
findings: rewatch.findings,
|
|
2267
|
+
elapsedMs: rewatch.elapsedMs,
|
|
2268
|
+
rule:
|
|
2269
|
+
'These are direct readings taken by re-watching the source just now, for ' +
|
|
2270
|
+
'this question. Answer from them and cite the timestamps they came from, ' +
|
|
2271
|
+
'and do not report that the source fails to show something a reading here ' +
|
|
2272
|
+
'establishes. Weigh them against each other and against the indexed ' +
|
|
2273
|
+
'evidence rather than taking each at face value: a reading is strongest ' +
|
|
2274
|
+
'where several readings and the index agree. A lone reading that ' +
|
|
2275
|
+
'contradicts both the other readings and the index -- naming someone or ' +
|
|
2276
|
+
'something that appears nowhere else -- is a misreading, whatever ' +
|
|
2277
|
+
'confidence it carries; leave it out rather than reporting it.',
|
|
2278
|
+
},
|
|
2279
|
+
}
|
|
2280
|
+
: {}),
|
|
2281
|
+
...(autoInspection ? { autoInspection } : {}),
|
|
2282
|
+
...(visualMatches.length > 0
|
|
2283
|
+
? {
|
|
2284
|
+
visualMatches: {
|
|
2285
|
+
matches: visualMatches,
|
|
2286
|
+
rule: 'Ranked by visual similarity only, not evidence. If the transcript/OCR/caption evidence above does not answer the question, call watch_original (inspectVideoKnowledge) or read_evidence on the highest-scoring range here before answering.',
|
|
2287
|
+
},
|
|
2288
|
+
}
|
|
2289
|
+
: {}),
|
|
2290
|
+
evidence: hits.map((hit) => {
|
|
2291
|
+
const rawText = evidenceText(hit.evidence.payload);
|
|
2292
|
+
return {
|
|
2293
|
+
evidenceId: hit.evidence.id,
|
|
2294
|
+
modality: hit.evidence.modality,
|
|
2295
|
+
text: rawText,
|
|
2296
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
2297
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
2298
|
+
precision: hit.evidence.timeRange.precision,
|
|
2299
|
+
confidence: hit.evidence.confidence,
|
|
2300
|
+
conflicted: hit.conflict,
|
|
2301
|
+
};
|
|
2302
|
+
}),
|
|
2303
|
+
};
|
|
2304
|
+
// Video questions deliberately do not reuse answer or miss memory.
|
|
2305
|
+
// A later turn always starts from current indexed evidence and may
|
|
2306
|
+
// request a fresh bounded analysis when that evidence is incomplete.
|
|
2307
|
+
return result;
|
|
2308
|
+
};
|
|
2309
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
2310
|
+
},
|
|
2311
|
+
}),
|
|
2312
|
+
|
|
2313
|
+
planVideoInvestigation: tool({
|
|
2314
|
+
description:
|
|
2315
|
+
'Plan a complex video investigation before source inspection. It searches the active time hierarchy (chapters, scenes, events, states) and returns the best candidate ranges. Use this when queryVideoKnowledge has weak or no direct evidence, or when the question requires before/after reasoning, tracking, counting, or a chain of events. Then inspect only the returned range(s), never the entire video.',
|
|
2316
|
+
inputSchema: z.object({
|
|
2317
|
+
mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
|
|
2318
|
+
question: z.string().describe('The full user question, including any temporal conditions.'),
|
|
2319
|
+
investigation: z.object({
|
|
2320
|
+
scope: z.enum(['focused', 'temporal', 'source']),
|
|
2321
|
+
goal: z.enum(['answer', 'compare', 'trace', 'enumerate', 'synthesize']),
|
|
2322
|
+
evidence: z
|
|
2323
|
+
.array(z.enum(['speech', 'visible-text', 'visual', 'computed']))
|
|
2324
|
+
.max(4)
|
|
2325
|
+
.optional(),
|
|
2326
|
+
recordSet: z.enum(['all', 'source-authored', 'source-questions', 'observed']).optional(),
|
|
2327
|
+
timeRange: z
|
|
2328
|
+
.object({ startSecs: z.number().min(0), endSecs: z.number().min(0) })
|
|
2329
|
+
.optional(),
|
|
2330
|
+
}),
|
|
2331
|
+
}),
|
|
2332
|
+
execute: async ({ mediaAssetId, question, investigation }) => {
|
|
2333
|
+
const run = async () => {
|
|
2334
|
+
const asset = await scopedAsset(mediaAssetId);
|
|
2335
|
+
if (!asset || asset.processingStatus !== 'completed') {
|
|
2336
|
+
return { success: false, error: 'That indexed media asset is no longer available.' };
|
|
2337
|
+
}
|
|
2338
|
+
const plan = await buildVideoInvestigationPlan(mediaAssetId, question, investigation);
|
|
2339
|
+
if (!plan)
|
|
2340
|
+
return { success: false, error: 'No active video knowledge revision is available.' };
|
|
2341
|
+
void trackUsageEvent({
|
|
2342
|
+
type: 'media_processing',
|
|
2343
|
+
mediaOperation: 'investigation',
|
|
2344
|
+
mediaAssetId,
|
|
2345
|
+
queryKind: 'planner',
|
|
2346
|
+
timestamp: new Date().toISOString(),
|
|
2347
|
+
});
|
|
2348
|
+
return { success: true, ...plan };
|
|
2349
|
+
};
|
|
2350
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
2351
|
+
},
|
|
2352
|
+
}),
|
|
2353
|
+
|
|
2354
|
+
read_evidence: tool({
|
|
2355
|
+
description:
|
|
2356
|
+
'Read every recorded piece of evidence (transcript, OCR, visual, computed) whose timestamp falls inside a specific window, in chronological order — no relevance ranking or query terms. Use this once queryVideoKnowledge or planVideoInvestigation has narrowed to a candidate range and you need the full, literal record of what is known there, not a search over it.',
|
|
2357
|
+
inputSchema: z.object({
|
|
2358
|
+
mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
|
|
2359
|
+
startSecs: z.number().nonnegative(),
|
|
2360
|
+
endSecs: z.number().nonnegative(),
|
|
2361
|
+
limit: z.number().int().min(1).max(50).optional(),
|
|
2362
|
+
}),
|
|
2363
|
+
execute: async ({ mediaAssetId, startSecs, endSecs, limit }) => {
|
|
2364
|
+
const run = async () => {
|
|
2365
|
+
const asset = await scopedAsset(mediaAssetId);
|
|
2366
|
+
if (!asset || asset.processingStatus !== 'completed') {
|
|
2367
|
+
return { success: false, error: 'That indexed media asset is no longer available.' };
|
|
2368
|
+
}
|
|
2369
|
+
const hits = await searchVideoKnowledge(mediaAssetId, '', limit ?? 30, {
|
|
2370
|
+
minimumRangeDistanceSecs: 0,
|
|
2371
|
+
videoDurationSecs: asset.durationSecs,
|
|
2372
|
+
timeRange: { startSecs, endSecs },
|
|
2373
|
+
});
|
|
2374
|
+
const chronological = [...hits].sort(
|
|
2375
|
+
(a, b) => a.evidence.timeRange.startSecs - b.evidence.timeRange.startSecs,
|
|
2376
|
+
);
|
|
2377
|
+
return {
|
|
2378
|
+
success: true,
|
|
2379
|
+
mediaAssetId: asset.id,
|
|
2380
|
+
range: { startSecs, endSecs },
|
|
2381
|
+
evidence: chronological.map((hit) => {
|
|
2382
|
+
const rawText =
|
|
2383
|
+
typeof hit.evidence.payload === 'object' &&
|
|
2384
|
+
hit.evidence.payload &&
|
|
2385
|
+
'text' in hit.evidence.payload
|
|
2386
|
+
? String((hit.evidence.payload as { text?: unknown }).text ?? '')
|
|
2387
|
+
: JSON.stringify(hit.evidence.payload);
|
|
2388
|
+
return {
|
|
2389
|
+
evidenceId: hit.evidence.id,
|
|
2390
|
+
modality: hit.evidence.modality,
|
|
2391
|
+
text: rawText,
|
|
2392
|
+
startSecs: hit.evidence.timeRange.startSecs,
|
|
2393
|
+
endSecs: hit.evidence.timeRange.endSecs,
|
|
2394
|
+
precision: hit.evidence.timeRange.precision,
|
|
2395
|
+
confidence: hit.evidence.confidence,
|
|
2396
|
+
conflicted: hit.conflict,
|
|
2397
|
+
};
|
|
2398
|
+
}),
|
|
2399
|
+
};
|
|
2400
|
+
};
|
|
2401
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
2402
|
+
},
|
|
2403
|
+
}),
|
|
2404
|
+
|
|
2405
|
+
expand_range: tool({
|
|
2406
|
+
description:
|
|
2407
|
+
'Widen a candidate time range for further investigation. Prefers the smallest indexed scene or chapter that fully encloses the given range (a real structural boundary in the video) over an arbitrary pad, falling back to a fixed pad only when nothing indexed encloses it yet. Use this when read_evidence or queryVideoKnowledge found too little inside a range to answer, before calling watch_original (inspectVideoKnowledge) on the widened range.',
|
|
2408
|
+
inputSchema: z.object({
|
|
2409
|
+
mediaAssetId: z.string().describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
|
|
2410
|
+
startSecs: z.number().nonnegative(),
|
|
2411
|
+
endSecs: z.number().nonnegative(),
|
|
2412
|
+
}),
|
|
2413
|
+
execute: async ({ mediaAssetId, startSecs, endSecs }) => {
|
|
2414
|
+
const run = async () => {
|
|
2415
|
+
const asset = await scopedAsset(mediaAssetId);
|
|
2416
|
+
if (!asset || asset.processingStatus !== 'completed') {
|
|
2417
|
+
return { success: false, error: 'That indexed media asset is no longer available.' };
|
|
2418
|
+
}
|
|
2419
|
+
const expanded = await expandInvestigationRange(
|
|
2420
|
+
mediaAssetId,
|
|
2421
|
+
{ startSecs, endSecs },
|
|
2422
|
+
asset.durationSecs,
|
|
2423
|
+
);
|
|
2424
|
+
if (!expanded) {
|
|
2425
|
+
return { success: false, error: 'No active video knowledge revision is available.' };
|
|
2426
|
+
}
|
|
2427
|
+
return { success: true, ...expanded };
|
|
2428
|
+
};
|
|
2429
|
+
return projectId ? runWithProject(projectId, run) : run();
|
|
2430
|
+
},
|
|
2431
|
+
}),
|
|
2432
|
+
|
|
2433
|
+
presentMedia: tool({
|
|
2434
|
+
description:
|
|
2435
|
+
'Embed one indexed image, video segment, or audio segment as a compact citation in the chat. Use the exact mediaAssetId for Media-library assets, or the exact images[].imageUrl returned by searchKnowledgeBase for an image extracted from a PDF. Call this when the user asks to see, watch, play, or hear the media. Set extractFrame=true with a specific startSecs to show "what was on screen at X:XX" as a single frame preview instead of a video player. Prefer one best citation and do not call this for every search hit.',
|
|
2436
|
+
inputSchema: z.object({
|
|
2437
|
+
assetId: z
|
|
2438
|
+
.string()
|
|
2439
|
+
.optional()
|
|
2440
|
+
.describe('Exact mediaAssetId returned by searchKnowledgeBase.'),
|
|
2441
|
+
imageUrl: z
|
|
2442
|
+
.string()
|
|
2443
|
+
.optional()
|
|
2444
|
+
.describe(
|
|
2445
|
+
'Exact images[].imageUrl returned by searchKnowledgeBase for an extracted PDF image.',
|
|
2446
|
+
),
|
|
2447
|
+
documentId: z
|
|
2448
|
+
.string()
|
|
2449
|
+
.optional()
|
|
2450
|
+
.describe(
|
|
2451
|
+
'Exact PDF documentId returned by searchKnowledgeBase for an on-demand page preview.',
|
|
2452
|
+
),
|
|
2453
|
+
pageNumber: z
|
|
2454
|
+
.number()
|
|
2455
|
+
.int()
|
|
2456
|
+
.positive()
|
|
2457
|
+
.optional()
|
|
2458
|
+
.describe('Preferred PDF page number to render when documentId is provided.'),
|
|
2459
|
+
question: z
|
|
2460
|
+
.string()
|
|
2461
|
+
.optional()
|
|
2462
|
+
.describe(
|
|
2463
|
+
'Optional user intent used to choose the nearest PDF page when pageNumber is omitted.',
|
|
2464
|
+
),
|
|
2465
|
+
startSecs: z
|
|
2466
|
+
.number()
|
|
2467
|
+
.nonnegative()
|
|
2468
|
+
.optional()
|
|
2469
|
+
.describe('Exact segment start time returned by searchKnowledgeBase.'),
|
|
2470
|
+
endSecs: z
|
|
2471
|
+
.number()
|
|
2472
|
+
.nonnegative()
|
|
2473
|
+
.optional()
|
|
2474
|
+
.describe('Exact segment end time returned by searchKnowledgeBase.'),
|
|
2475
|
+
extractFrame: z
|
|
2476
|
+
.boolean()
|
|
2477
|
+
.optional()
|
|
2478
|
+
.describe(
|
|
2479
|
+
'When true, extract and show a single frame at startSecs instead of embedding the video player. Use this to answer "show me what was on screen at X:XX".',
|
|
2480
|
+
),
|
|
2481
|
+
}),
|
|
2482
|
+
execute: async ({
|
|
2483
|
+
assetId,
|
|
2484
|
+
imageUrl,
|
|
2485
|
+
documentId,
|
|
2486
|
+
pageNumber,
|
|
2487
|
+
question,
|
|
2488
|
+
startSecs,
|
|
2489
|
+
endSecs,
|
|
2490
|
+
extractFrame,
|
|
2491
|
+
}) => {
|
|
2492
|
+
const resolvePresentation = async () => {
|
|
2493
|
+
if (documentId) {
|
|
2494
|
+
const document = await scopedDocument(documentId);
|
|
2495
|
+
if (!document) return { success: false, error: 'That PDF document is not available.' };
|
|
2496
|
+
const selectedPage =
|
|
2497
|
+
pageNumber ??
|
|
2498
|
+
(await inspectStoredPdf(document, question ?? '', undefined)).pages[0]?.pageNumber;
|
|
2499
|
+
if (!selectedPage)
|
|
2500
|
+
return { success: false, error: 'No renderable PDF page was found.' };
|
|
2501
|
+
await renderStoredPdfPage(document, selectedPage, 960);
|
|
2502
|
+
const mediaUrl = pdfPagePreviewUrl(document.id, selectedPage);
|
|
2503
|
+
return {
|
|
2504
|
+
success: true,
|
|
2505
|
+
assetId: `pdf-page-${document.id}-${selectedPage}`,
|
|
2506
|
+
mediaType: 'image' as const,
|
|
2507
|
+
fileName: `${document.title} — Page ${selectedPage}`,
|
|
2508
|
+
mediaUrl,
|
|
2509
|
+
sourceUrl: document.url || mediaUrl,
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
if (imageUrl) {
|
|
2513
|
+
const { documents } = await readAssistantSourceScope();
|
|
2514
|
+
const indexedImage = findIndexedImageSource(documents, imageUrl);
|
|
2515
|
+
if (!indexedImage) {
|
|
2516
|
+
return {
|
|
2517
|
+
success: false,
|
|
2518
|
+
error: 'That extracted document image is no longer available.',
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
const { source, image } = indexedImage;
|
|
2522
|
+
return {
|
|
2523
|
+
success: true,
|
|
2524
|
+
// The preview uses mediaUrl when supplied; this stable sentinel
|
|
2525
|
+
// only satisfies its shared media citation shape.
|
|
2526
|
+
assetId: `document-image-${image.index ?? 0}`,
|
|
2527
|
+
mediaType: 'image' as const,
|
|
2528
|
+
fileName: `${source?.title ?? 'Document'} — Image ${(image.index ?? 0) + 1}`,
|
|
2529
|
+
mediaUrl: imageUrl,
|
|
2530
|
+
sourceUrl: source?.url || imageUrl,
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
if (!assetId) {
|
|
2535
|
+
return { success: false, error: 'Choose a media asset or extracted document image.' };
|
|
2536
|
+
}
|
|
2537
|
+
const asset = await scopedAsset(assetId);
|
|
2538
|
+
if (!asset || asset.processingStatus !== 'completed') {
|
|
2539
|
+
return {
|
|
2540
|
+
success: false,
|
|
2541
|
+
error: 'That indexed media asset is not available in the selected runtime.',
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const range = normalizeMediaCitationRange(
|
|
2546
|
+
asset.type,
|
|
2547
|
+
asset.durationSecs,
|
|
2548
|
+
startSecs,
|
|
2549
|
+
endSecs,
|
|
2550
|
+
);
|
|
2551
|
+
const serverQuery = projectId ? `?projectId=${encodeURIComponent(projectId)}` : '';
|
|
2552
|
+
const mediaUrl = workspaceServerUrl(`/api/media/${asset.id}${serverQuery}`);
|
|
2553
|
+
const sourceUrl =
|
|
2554
|
+
range.startSecs !== undefined
|
|
2555
|
+
? timestampMediaUrl(asset.originalUrl || mediaUrl, range.startSecs)
|
|
2556
|
+
: asset.originalUrl || mediaUrl;
|
|
2557
|
+
|
|
2558
|
+
// Frame preview: extract a single frame at the timestamp instead
|
|
2559
|
+
// of embedding the full video player. This answers "show me what
|
|
2560
|
+
// was on screen at X:XX" with a precise image.
|
|
2561
|
+
let framePreviewUrl: string | undefined;
|
|
2562
|
+
if (extractFrame && asset.type === 'video' && range.startSecs !== undefined) {
|
|
2563
|
+
const frameQuery = new URLSearchParams();
|
|
2564
|
+
frameQuery.set('t', String(range.startSecs));
|
|
2565
|
+
if (projectId) frameQuery.set('projectId', projectId);
|
|
2566
|
+
framePreviewUrl = workspaceServerUrl(
|
|
2567
|
+
`/api/media/${asset.id}/frame?${frameQuery.toString()}`,
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
return {
|
|
2572
|
+
success: true,
|
|
2573
|
+
assetId: asset.id,
|
|
2574
|
+
mediaType: framePreviewUrl ? ('frame-preview' as const) : asset.type,
|
|
2575
|
+
fileName: asset.fileName,
|
|
2576
|
+
mediaUrl: framePreviewUrl || mediaUrl,
|
|
2577
|
+
sourceUrl,
|
|
2578
|
+
startSecs: range.startSecs,
|
|
2579
|
+
endSecs: range.endSecs,
|
|
2580
|
+
framePreviewUrl,
|
|
2581
|
+
};
|
|
2582
|
+
};
|
|
2583
|
+
|
|
2584
|
+
return projectId ? runWithProject(projectId, resolvePresentation) : resolvePresentation();
|
|
2585
|
+
},
|
|
2586
|
+
}),
|
|
2587
|
+
|
|
2588
|
+
queryTabularData: tool({
|
|
2589
|
+
description:
|
|
2590
|
+
'Query a tabular dataset (CSV/Excel/JSON) for exact values, filtering, grouping, and aggregations. Use this when the user asks about specific data values, comparisons, averages, sums, counts, highest/lowest, or any numerical question about uploaded data.',
|
|
2591
|
+
inputSchema: z.object({
|
|
2592
|
+
datasetId: z
|
|
2593
|
+
.string()
|
|
2594
|
+
.describe(
|
|
2595
|
+
'The ID of the dataset to query. Get this from the available datasets list in the system prompt.',
|
|
2596
|
+
),
|
|
2597
|
+
filters: z
|
|
2598
|
+
.array(
|
|
2599
|
+
z.object({
|
|
2600
|
+
column: z.string(),
|
|
2601
|
+
op: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']),
|
|
2602
|
+
value: z.any(),
|
|
2603
|
+
}),
|
|
2604
|
+
)
|
|
2605
|
+
.optional()
|
|
2606
|
+
.describe(
|
|
2607
|
+
'Filters to apply to the data. This array must contain only filter objects; limit and columns are separate top-level arguments.',
|
|
2608
|
+
),
|
|
2609
|
+
groupBy: z.array(z.string()).optional().describe('Columns to group by for aggregations.'),
|
|
2610
|
+
aggregations: z
|
|
2611
|
+
.array(
|
|
2612
|
+
z.object({
|
|
2613
|
+
column: z.string(),
|
|
2614
|
+
op: z.enum(['sum', 'avg', 'count', 'countDistinct', 'min', 'max', 'median']),
|
|
2615
|
+
}),
|
|
2616
|
+
)
|
|
2617
|
+
.optional()
|
|
2618
|
+
.describe(
|
|
2619
|
+
'Aggregation operations to perform. Use countDistinct when the user asks for unique or distinct values.',
|
|
2620
|
+
),
|
|
2621
|
+
sortBy: z.string().optional().describe('Column to sort results by.'),
|
|
2622
|
+
sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort direction.'),
|
|
2623
|
+
limit: z.number().optional().describe('Max number of rows to return.'),
|
|
2624
|
+
columns: z.array(z.string()).optional().describe('Specific columns to include in results.'),
|
|
2625
|
+
timeBucket: z
|
|
2626
|
+
.object({
|
|
2627
|
+
column: z.string(),
|
|
2628
|
+
grain: z.enum(['month', 'quarter', 'year']),
|
|
2629
|
+
})
|
|
2630
|
+
.optional()
|
|
2631
|
+
.describe('Optional calendar grouping for a date column.'),
|
|
2632
|
+
}),
|
|
2633
|
+
execute: async (params) => {
|
|
2634
|
+
try {
|
|
2635
|
+
const availableDatasets = await readAssistantTabularDatasets();
|
|
2636
|
+
const resolvedDatasetId = resolveTabularDatasetForQuestion(
|
|
2637
|
+
requestText,
|
|
2638
|
+
availableDatasets,
|
|
2639
|
+
params.datasetId,
|
|
2640
|
+
);
|
|
2641
|
+
if (!availableDatasets.some((dataset) => dataset.id === resolvedDatasetId)) {
|
|
2642
|
+
throw new Error('Dataset not found');
|
|
2643
|
+
}
|
|
2644
|
+
const dataset = await inActiveProject(() => getTabularDataset(resolvedDatasetId));
|
|
2645
|
+
if (!dataset) throw new Error('Dataset not found');
|
|
2646
|
+
const plan = inferTabularPlan(requestText, dataset, {
|
|
2647
|
+
...params,
|
|
2648
|
+
datasetId: resolvedDatasetId,
|
|
2649
|
+
});
|
|
2650
|
+
const result = await queryTabular(plan.request);
|
|
2651
|
+
if (result.columns.length && result.rows.length) {
|
|
2652
|
+
currentTurnExportTable = { columns: result.columns, rows: result.rows };
|
|
2653
|
+
}
|
|
2654
|
+
const visualization = createTabularVisualization(requestText, result);
|
|
2655
|
+
if (visualization && plan.chartTitle) visualization.title = plan.chartTitle;
|
|
2656
|
+
return visualization ? { ...result, visualization } : result;
|
|
2657
|
+
} catch (err: any) {
|
|
2658
|
+
return {
|
|
2659
|
+
columns: [],
|
|
2660
|
+
rows: [],
|
|
2661
|
+
totalRows: 0,
|
|
2662
|
+
error: err.message ?? 'Query failed',
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
},
|
|
2666
|
+
}),
|
|
2667
|
+
|
|
2668
|
+
createDataExport: tool({
|
|
2669
|
+
description:
|
|
2670
|
+
'Create a real downloadable Excel, CSV, or PDF file from answer data already established in this conversation. Use this whenever the user asks to export, save, download, or convert an answer/result to one of these formats. Never write a Markdown download link. Omit columns and rows whenever a data query ran in this turn or the request is a direct follow-up: the host reuses the complete authoritative result. Supply rows only when no structured data result exists.',
|
|
2671
|
+
inputSchema: z.object({
|
|
2672
|
+
format: z.enum(['xlsx', 'csv', 'pdf']).describe('The requested file format.'),
|
|
2673
|
+
title: z.string().max(100).optional().describe('A short human-readable file title.'),
|
|
2674
|
+
fileName: z
|
|
2675
|
+
.string()
|
|
2676
|
+
.max(100)
|
|
2677
|
+
.optional()
|
|
2678
|
+
.describe('Optional file name without relying on a Markdown link.'),
|
|
2679
|
+
columns: z
|
|
2680
|
+
.array(z.string())
|
|
2681
|
+
.max(50)
|
|
2682
|
+
.optional()
|
|
2683
|
+
.describe('Columns from a result obtained in this same turn.'),
|
|
2684
|
+
rows: z
|
|
2685
|
+
.array(z.looseObject({}))
|
|
2686
|
+
.max(500)
|
|
2687
|
+
.optional()
|
|
2688
|
+
.describe('Rows from a result obtained in this same turn.'),
|
|
2689
|
+
}),
|
|
2690
|
+
execute: async ({ format, title, fileName, columns, rows }) => {
|
|
2691
|
+
try {
|
|
2692
|
+
const source = selectDataExportSource({
|
|
2693
|
+
currentTurnTable: currentTurnExportTable,
|
|
2694
|
+
explicitTable: columns?.length && rows?.length ? { columns, rows } : undefined,
|
|
2695
|
+
recentTable: recentExportTable,
|
|
2696
|
+
recentAnswerText,
|
|
2697
|
+
});
|
|
2698
|
+
if (!source) throw new Error('There is no completed answer available to export yet.');
|
|
2699
|
+
return await createDataExport({
|
|
2700
|
+
format,
|
|
2701
|
+
title: title || 'Larkup answer',
|
|
2702
|
+
fileName,
|
|
2703
|
+
columns: source.columns,
|
|
2704
|
+
rows: source.rows,
|
|
2705
|
+
});
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
return {
|
|
2708
|
+
success: false,
|
|
2709
|
+
error: error instanceof Error ? error.message : 'The export could not be created.',
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
},
|
|
2713
|
+
}),
|
|
2714
|
+
|
|
2715
|
+
generateVisualization: tool({
|
|
2716
|
+
description:
|
|
2717
|
+
'Generate an interactive chart visualization only from verified tabular rows already returned by a data tool in this turn or shown in the immediately preceding table. Copy those exact rows to data; charts without matching table evidence are rejected. xAxisKey and every series.dataKey must exactly match fields in those rows; never use placeholder names such as EMPTY, null, or undefined. Supply clear human-readable xAxisLabel and yAxisLabel values, including the unit when known. The UI renders the result automatically, so after calling it only summarize the finding in prose.',
|
|
2718
|
+
inputSchema: z.object({
|
|
2719
|
+
chartType: z
|
|
2720
|
+
.enum(['bar', 'area', 'line', 'pie', 'scatter', 'radar'])
|
|
2721
|
+
.describe('The type of chart to create.'),
|
|
2722
|
+
title: z.string().describe('Chart title.'),
|
|
2723
|
+
subtitle: z.string().optional().describe('Chart subtitle for additional context.'),
|
|
2724
|
+
data: z
|
|
2725
|
+
.array(z.looseObject({}))
|
|
2726
|
+
.describe(
|
|
2727
|
+
"Array of data points to plot. You MUST provide the data here. Example: [{'name': 'North', 'value': 3206}, {'name': 'East', 'value': 2492}]",
|
|
2728
|
+
),
|
|
2729
|
+
xAxisKey: z
|
|
2730
|
+
.string()
|
|
2731
|
+
.describe("The key in data objects to use for the X axis (usually 'name')."),
|
|
2732
|
+
series: z
|
|
2733
|
+
.array(
|
|
2734
|
+
z.object({
|
|
2735
|
+
dataKey: z.string().describe('The key in data objects for this series values.'),
|
|
2736
|
+
label: z.string().optional().describe('Display label for this series in the legend.'),
|
|
2737
|
+
color: z
|
|
2738
|
+
.string()
|
|
2739
|
+
.optional()
|
|
2740
|
+
.describe('Color for this series (hex). If omitted, theme colors are used.'),
|
|
2741
|
+
}),
|
|
2742
|
+
)
|
|
2743
|
+
.describe('Data series to plot.'),
|
|
2744
|
+
colors: z
|
|
2745
|
+
.array(z.string())
|
|
2746
|
+
.optional()
|
|
2747
|
+
.describe(
|
|
2748
|
+
'Array of hex colors to use for the chart palette. The AI should decide and pick elegant, classy colors that contrast beautifully against a soft, muted background. Make it dynamic and appropriate for the data.',
|
|
2749
|
+
),
|
|
2750
|
+
stacked: z.boolean().optional().describe('Whether to stack bar/area charts.'),
|
|
2751
|
+
showLegend: z.boolean().optional().default(true).describe('Whether to show the legend.'),
|
|
2752
|
+
xAxisLabel: z
|
|
2753
|
+
.string()
|
|
2754
|
+
.optional()
|
|
2755
|
+
.describe('Human-readable X-axis name, including its unit when relevant.'),
|
|
2756
|
+
yAxisLabel: z
|
|
2757
|
+
.string()
|
|
2758
|
+
.optional()
|
|
2759
|
+
.describe('Human-readable Y-axis name, including its unit when relevant.'),
|
|
2760
|
+
}),
|
|
2761
|
+
execute: async (config) => {
|
|
2762
|
+
// Chart arguments are model-authored. Require every plotted value to
|
|
2763
|
+
// match authoritative table rows so unrelated image/text retrieval can
|
|
2764
|
+
// never be turned into a plausible-looking invented chart.
|
|
2765
|
+
return normalizeChartConfigWithEvidence(
|
|
2766
|
+
config,
|
|
2767
|
+
currentTurnExportTable?.rows ?? recentVisualizationTable?.rows,
|
|
2768
|
+
);
|
|
2769
|
+
},
|
|
2770
|
+
}),
|
|
2771
|
+
|
|
2772
|
+
executeAnalysis: tool({
|
|
2773
|
+
description:
|
|
2774
|
+
'Execute Python code in a secure sandbox for deep data analysis. Use this for complex statistical computations (correlations, regressions, clustering), data transformations, or when creating custom matplotlib visualizations. The code has access to pandas, numpy, matplotlib, scipy, scikit-learn, and seaborn. Always print results to stdout and use plt.show() for charts. The sandbox already configures headless plotting and its cache directories: do not set MPLBACKEND, MPLCONFIGDIR, or XDG_CACHE_HOME in analysis code.',
|
|
2775
|
+
inputSchema: z.object({
|
|
2776
|
+
code: z
|
|
2777
|
+
.string()
|
|
2778
|
+
.describe(
|
|
2779
|
+
'Python code to execute. Has pandas (2.0+), numpy, matplotlib, scipy, sklearn, seaborn available. ALWAYS use "ME" instead of "M" for resample frequency. Print results and use plt.show() for charts. Plot configuration is already prepared by the sandbox; do not set MPLBACKEND, MPLCONFIGDIR, or XDG_CACHE_HOME.',
|
|
2780
|
+
),
|
|
2781
|
+
datasetId: z
|
|
2782
|
+
.string()
|
|
2783
|
+
.optional()
|
|
2784
|
+
.describe(
|
|
2785
|
+
"For one dataset, its CSV is available as 'data.csv' in the working directory.",
|
|
2786
|
+
),
|
|
2787
|
+
datasetIds: z
|
|
2788
|
+
.array(z.string())
|
|
2789
|
+
.min(1)
|
|
2790
|
+
.optional()
|
|
2791
|
+
.describe(
|
|
2792
|
+
"For a correlation, comparison, or join across related files or worksheets, provide every required dataset ID. Their CSV filenames and schemas are listed in 'datasets.json' in the working directory.",
|
|
2793
|
+
),
|
|
2794
|
+
pdfDocumentId: z
|
|
2795
|
+
.string()
|
|
2796
|
+
.optional()
|
|
2797
|
+
.describe(
|
|
2798
|
+
"Optional exact PDF documentId. Its original local file will be mounted as 'source.pdf' for pypdf/Pillow analysis.",
|
|
2799
|
+
),
|
|
2800
|
+
}),
|
|
2801
|
+
execute: async ({ code, datasetId, datasetIds, pdfDocumentId }) => {
|
|
2802
|
+
try {
|
|
2803
|
+
const sandboxManager = workspaceSandbox(resolveSandboxConfig(config));
|
|
2804
|
+
const files: SandboxFile[] = [];
|
|
2805
|
+
|
|
2806
|
+
const requestedDatasetIds = [
|
|
2807
|
+
...new Set([...(datasetIds ?? []), ...(datasetId ? [datasetId] : [])]),
|
|
2808
|
+
];
|
|
2809
|
+
const availableDatasets = await readAssistantTabularDatasets();
|
|
2810
|
+
// Some providers emit valid analysis code but omit an optional
|
|
2811
|
+
// datasetId. When the request maps unambiguously to one indexed
|
|
2812
|
+
// schema, stage it automatically so `data.csv` remains a reliable
|
|
2813
|
+
// single-sheet contract. Ambiguous workbooks still require the
|
|
2814
|
+
// model to make an explicit selection.
|
|
2815
|
+
if (requestedDatasetIds.length === 0) {
|
|
2816
|
+
const automaticDatasetId = selectTabularDatasetForQuestion(
|
|
2817
|
+
requestText,
|
|
2818
|
+
availableDatasets,
|
|
2819
|
+
);
|
|
2820
|
+
if (automaticDatasetId) requestedDatasetIds.push(automaticDatasetId);
|
|
2821
|
+
}
|
|
2822
|
+
if (requestedDatasetIds.length > 0) {
|
|
2823
|
+
const availableDatasetIds = new Set(availableDatasets.map((dataset) => dataset.id));
|
|
2824
|
+
const unavailableDatasetId = requestedDatasetIds.find(
|
|
2825
|
+
(requestedId) => !availableDatasetIds.has(requestedId),
|
|
2826
|
+
);
|
|
2827
|
+
if (unavailableDatasetId) {
|
|
2828
|
+
throw new Error(`Tabular dataset is not available: ${unavailableDatasetId}`);
|
|
2829
|
+
}
|
|
2830
|
+
const datasets = await inActiveProject(() =>
|
|
2831
|
+
Promise.all(requestedDatasetIds.map(getTabularDataset)),
|
|
2832
|
+
);
|
|
2833
|
+
const missingDatasetId = requestedDatasetIds.find((_, index) => !datasets[index]);
|
|
2834
|
+
if (missingDatasetId) {
|
|
2835
|
+
throw new Error(`Tabular dataset is not available: ${missingDatasetId}`);
|
|
2836
|
+
}
|
|
2837
|
+
files.push(
|
|
2838
|
+
...createTabularSandboxFiles(datasets as NonNullable<(typeof datasets)[number]>[])
|
|
2839
|
+
.files,
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
if (pdfDocumentId) {
|
|
2844
|
+
const document = await scopedDocument(pdfDocumentId);
|
|
2845
|
+
if (!document) throw new Error('That PDF document is not available.');
|
|
2846
|
+
const bytes = await readStoredPdfBytes(document);
|
|
2847
|
+
files.push({ name: 'source.pdf', content: bytes.toString('base64'), isBase64: true });
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
let finalCode = code;
|
|
2851
|
+
// Clean up any markdown blocks LLMs might mistakenly wrap the code in
|
|
2852
|
+
finalCode = finalCode.replace(/^```python\s*\n?/m, '');
|
|
2853
|
+
finalCode = finalCode.replace(/^```\s*\n?/m, '');
|
|
2854
|
+
finalCode = finalCode.replace(/```\s*$/m, '');
|
|
2855
|
+
// Generated notebook snippets occasionally use `os` only for
|
|
2856
|
+
// plotting-cache setup. The execution wrapper already configures
|
|
2857
|
+
// those paths, but importing os here makes such harmless snippets
|
|
2858
|
+
// robust without changing the requested analysis.
|
|
2859
|
+
if (
|
|
2860
|
+
/\bos\.(?:environ|makedirs|path)\b/.test(finalCode) &&
|
|
2861
|
+
!/^\s*(?:import|from)\s+os\b/m.test(finalCode)
|
|
2862
|
+
) {
|
|
2863
|
+
finalCode = `import os\n${finalCode}`;
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
let result = await sandboxManager.execute({
|
|
2867
|
+
code: finalCode,
|
|
2868
|
+
language: 'python',
|
|
2869
|
+
files,
|
|
2870
|
+
timeout: 30_000,
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2873
|
+
// Auto-retry once if code crashes
|
|
2874
|
+
if (result.exitCode !== 0) {
|
|
2875
|
+
if (
|
|
2876
|
+
requestedDatasetIds.length > 1 &&
|
|
2877
|
+
/(?:no such file|data\.csv)/i.test(result.stderr)
|
|
2878
|
+
) {
|
|
2879
|
+
finalCode = remapLegacyMultiDatasetReads(finalCode, requestedDatasetIds.length);
|
|
2880
|
+
}
|
|
2881
|
+
// Apply regex patches for common LLM mistakes
|
|
2882
|
+
finalCode = finalCode.replace(/resample\((['"])M(['"])\)/g, 'resample($1ME$2)');
|
|
2883
|
+
finalCode = finalCode.replace(/resample\((['"])Q(['"])\)/g, 'resample($1QE$2)');
|
|
2884
|
+
finalCode = finalCode.replace(/resample\((['"])Y(['"])\)/g, 'resample($1YE$2)');
|
|
2885
|
+
finalCode = finalCode.replace(/freq=(['"])M(['"])/g, 'freq=$1ME$2');
|
|
2886
|
+
|
|
2887
|
+
result = await sandboxManager.execute({
|
|
2888
|
+
code: finalCode,
|
|
2889
|
+
language: 'python',
|
|
2890
|
+
files,
|
|
2891
|
+
timeout: 30_000,
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
return {
|
|
2896
|
+
stdout: result.stdout.slice(0, 5000),
|
|
2897
|
+
stderr: result.stderr.slice(0, 2000),
|
|
2898
|
+
exitCode: result.exitCode,
|
|
2899
|
+
artifacts: result.artifacts.map((a) => ({
|
|
2900
|
+
name: a.name,
|
|
2901
|
+
mimeType: a.mimeType,
|
|
2902
|
+
data: a.data.slice(0, 500_000), // cap at around 375KB base64
|
|
2903
|
+
})),
|
|
2904
|
+
executionTimeMs: result.executionTimeMs,
|
|
2905
|
+
};
|
|
2906
|
+
} catch (err: any) {
|
|
2907
|
+
return {
|
|
2908
|
+
stdout: '',
|
|
2909
|
+
stderr: err.message ?? 'Sandbox execution failed',
|
|
2910
|
+
exitCode: 1,
|
|
2911
|
+
artifacts: [],
|
|
2912
|
+
executionTimeMs: 0,
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
},
|
|
2916
|
+
}),
|
|
2917
|
+
|
|
2918
|
+
analyzeImageDeeply: tool({
|
|
2919
|
+
description:
|
|
2920
|
+
'Use this tool when you need to answer a detailed question about an image (like a diagram, chart, or schema) found in the knowledge base. Pass the exact imageUrl found in the document metadata, along with specific instructions on what you need to extract from it.',
|
|
2921
|
+
inputSchema: z.object({
|
|
2922
|
+
imageUrl: z
|
|
2923
|
+
.string()
|
|
2924
|
+
.describe('The URL of the image to analyze (from metadata.images.imageUrl)'),
|
|
2925
|
+
prompt: z
|
|
2926
|
+
.string()
|
|
2927
|
+
.describe(
|
|
2928
|
+
'Specific instructions on what to extract or analyze from this image. Be as detailed as possible to get the best result.',
|
|
2929
|
+
),
|
|
2930
|
+
}),
|
|
2931
|
+
execute: async ({ imageUrl, prompt }) => {
|
|
2932
|
+
const analyzeIndexedImage = async () => {
|
|
2933
|
+
try {
|
|
2934
|
+
// A model may only inspect an image that is actually part of the
|
|
2935
|
+
// active corpus. This prevents guessed URLs from becoming a chat
|
|
2936
|
+
// preview or an image-analysis request.
|
|
2937
|
+
const { documents } = await readAssistantSourceScope();
|
|
2938
|
+
const isIndexedImage = documents.some(
|
|
2939
|
+
(document) =>
|
|
2940
|
+
document.status === 'indexed' &&
|
|
2941
|
+
(document.metadata?.imageUrl === imageUrl ||
|
|
2942
|
+
(Array.isArray(document.metadata?.images) &&
|
|
2943
|
+
document.metadata.images.some((image: any) => image?.imageUrl === imageUrl))),
|
|
2944
|
+
);
|
|
2945
|
+
if (!isIndexedImage) {
|
|
2946
|
+
return { error: 'That image is not available in the indexed material.' };
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
const cached = await getCachedImageAnalysis(imageUrl, prompt);
|
|
2950
|
+
if (cached) return { analysis: cached, cached: true };
|
|
2951
|
+
|
|
2952
|
+
const fetchUrl = imageUrl.startsWith('/')
|
|
2953
|
+
? new URL(
|
|
2954
|
+
imageUrl,
|
|
2955
|
+
context.origin ?? `http://127.0.0.1:${process.env.PORT || 3000}`,
|
|
2956
|
+
).toString()
|
|
2957
|
+
: imageUrl;
|
|
2958
|
+
const res = await fetch(fetchUrl);
|
|
2959
|
+
if (!res.ok) throw new Error(`Failed to fetch image: ${res.statusText}`);
|
|
2960
|
+
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
2961
|
+
|
|
2962
|
+
const descRes = await fetch(
|
|
2963
|
+
new URL(
|
|
2964
|
+
workspaceServerUrl('/api/describe-image'),
|
|
2965
|
+
context.origin ?? `http://127.0.0.1:${process.env.PORT || 3000}`,
|
|
2966
|
+
).toString(),
|
|
2967
|
+
{
|
|
2968
|
+
method: 'POST',
|
|
2969
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2970
|
+
body: JSON.stringify({ base64, prompt }),
|
|
2971
|
+
},
|
|
2972
|
+
);
|
|
2973
|
+
|
|
2974
|
+
if (!descRes.ok) throw new Error(`Vision API error: ${descRes.statusText}`);
|
|
2975
|
+
const data = await descRes.json();
|
|
2976
|
+
await cacheImageAnalysis(imageUrl, prompt, data.description);
|
|
2977
|
+
return { analysis: data.description, cached: false };
|
|
2978
|
+
} catch (err: any) {
|
|
2979
|
+
return { error: `Failed to analyze image: ${err.message}` };
|
|
2980
|
+
}
|
|
2981
|
+
};
|
|
2982
|
+
|
|
2983
|
+
return projectId ? runWithProject(projectId, analyzeIndexedImage) : analyzeIndexedImage();
|
|
2984
|
+
},
|
|
2985
|
+
}),
|
|
2986
|
+
|
|
2987
|
+
getIndexedData: tool({
|
|
2988
|
+
description:
|
|
2989
|
+
'Get structured access to ALL indexed source documents. Use this for counting, listing, filtering, and overview questions about the knowledge base. Returns document metadata (title, source type, status, custom metadata fields, dates). Use this when the user asks "how many documents?", "list all X", "show progress", "what sources?", or any structural question about the corpus.',
|
|
2990
|
+
inputSchema: z.object({
|
|
2991
|
+
filter: z
|
|
2992
|
+
.object({
|
|
2993
|
+
source: z
|
|
2994
|
+
.enum(['text', 'files', 'website', 'media', 'integrations'])
|
|
2995
|
+
.optional()
|
|
2996
|
+
.describe('Filter by document source type.'),
|
|
2997
|
+
status: z
|
|
2998
|
+
.enum(['indexed', 'unindexed'])
|
|
2999
|
+
.optional()
|
|
3000
|
+
.describe('Filter by indexing status.'),
|
|
3001
|
+
metadataKey: z.string().optional().describe('Filter by metadata key existence.'),
|
|
3002
|
+
metadataValue: z
|
|
3003
|
+
.string()
|
|
3004
|
+
.optional()
|
|
3005
|
+
.describe('Filter by metadata key=value (requires metadataKey).'),
|
|
3006
|
+
titleContains: z.string().optional().describe('Case-insensitive text search in title.'),
|
|
3007
|
+
createdAfter: z
|
|
3008
|
+
.string()
|
|
3009
|
+
.optional()
|
|
3010
|
+
.describe('Only docs created on or after this ISO date.'),
|
|
3011
|
+
createdBefore: z
|
|
3012
|
+
.string()
|
|
3013
|
+
.optional()
|
|
3014
|
+
.describe('Only docs created on or before this ISO date.'),
|
|
3015
|
+
})
|
|
3016
|
+
.optional()
|
|
3017
|
+
.describe('Optional filters to narrow down documents.'),
|
|
3018
|
+
limit: z
|
|
3019
|
+
.number()
|
|
3020
|
+
.optional()
|
|
3021
|
+
.default(200)
|
|
3022
|
+
.describe('Max number of document summaries to return (default 200).'),
|
|
3023
|
+
offset: z.number().optional().default(0).describe('Pagination offset.'),
|
|
3024
|
+
includeContent: z
|
|
3025
|
+
.boolean()
|
|
3026
|
+
.optional()
|
|
3027
|
+
.default(false)
|
|
3028
|
+
.describe(
|
|
3029
|
+
'Whether to include truncated document content. Default false for performance. Set true only when you need the actual text.',
|
|
3030
|
+
),
|
|
3031
|
+
}),
|
|
3032
|
+
execute: async ({ filter, limit, offset, includeContent }) => {
|
|
3033
|
+
try {
|
|
3034
|
+
const maxLimit = includeContent ? 20 : 100;
|
|
3035
|
+
const actualLimit = Math.min(limit ?? (includeContent ? 20 : 100), maxLimit);
|
|
3036
|
+
const { documents } = await readAssistantSourceScope();
|
|
3037
|
+
|
|
3038
|
+
return await getCorpusDocuments(
|
|
3039
|
+
{
|
|
3040
|
+
...(filter as CorpusFilter | undefined),
|
|
3041
|
+
documentIds: documents.map((document) => document.id),
|
|
3042
|
+
},
|
|
3043
|
+
actualLimit,
|
|
3044
|
+
offset ?? 0,
|
|
3045
|
+
includeContent ?? false,
|
|
3046
|
+
);
|
|
3047
|
+
} catch (err: any) {
|
|
3048
|
+
return {
|
|
3049
|
+
documents: [],
|
|
3050
|
+
total: 0,
|
|
3051
|
+
summary: {
|
|
3052
|
+
totalDocuments: 0,
|
|
3053
|
+
bySource: {},
|
|
3054
|
+
byStatus: {},
|
|
3055
|
+
topMetadataKeys: [],
|
|
3056
|
+
totalCharacters: 0,
|
|
3057
|
+
dateRange: null,
|
|
3058
|
+
},
|
|
3059
|
+
error: err.message ?? 'Failed to retrieve corpus data',
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
},
|
|
3063
|
+
}),
|
|
3064
|
+
|
|
3065
|
+
analyzeCorpusWithCode: tool({
|
|
3066
|
+
description:
|
|
3067
|
+
'Run Python code in a sandbox with the FULL indexed corpus available as a file. Use this for complex analysis of documents: parsing fields from content, computing progress percentages, grouping by patterns, detecting duplicates, generating charts from corpus data, or any question that requires programmatic processing of hundreds/thousands of documents. The corpus is available as "corpus.csv" (or "corpus.jsonl" if format is jsonl). CSV columns: id, title, source, url, charCount, status, createdAt, content, plus any metadata_* columns.',
|
|
3068
|
+
inputSchema: z.object({
|
|
3069
|
+
code: z
|
|
3070
|
+
.string()
|
|
3071
|
+
.describe(
|
|
3072
|
+
'Python code to execute. The corpus file is in the working directory. Use pandas to load it: df = pd.read_csv("corpus.csv") or for JSONL: df = pd.read_json("corpus.jsonl", lines=True). Has pandas (2.0+), numpy, matplotlib, scipy, sklearn, seaborn available. ALWAYS use "ME" instead of "M" for resample frequency. Print results and use plt.show() for charts.',
|
|
3073
|
+
),
|
|
3074
|
+
format: z
|
|
3075
|
+
.enum(['csv', 'jsonl'])
|
|
3076
|
+
.optional()
|
|
3077
|
+
.default('csv')
|
|
3078
|
+
.describe('Format of the corpus file. Default is CSV.'),
|
|
3079
|
+
}),
|
|
3080
|
+
execute: async ({ code, format }) => {
|
|
3081
|
+
try {
|
|
3082
|
+
const sandboxManager = workspaceSandbox(resolveSandboxConfig(config));
|
|
3083
|
+
const { documents } = await readAssistantSourceScope();
|
|
3084
|
+
const assistantCorpusFilter: CorpusFilter = {
|
|
3085
|
+
documentIds: documents.map((document) => document.id),
|
|
3086
|
+
};
|
|
3087
|
+
|
|
3088
|
+
// Export corpus in the requested format
|
|
3089
|
+
const corpusData =
|
|
3090
|
+
format === 'jsonl'
|
|
3091
|
+
? await exportCorpusAsJSONL(assistantCorpusFilter)
|
|
3092
|
+
: await exportCorpusAsCSV(assistantCorpusFilter);
|
|
3093
|
+
|
|
3094
|
+
if (!corpusData) {
|
|
3095
|
+
return {
|
|
3096
|
+
stdout: 'No documents found in the corpus.',
|
|
3097
|
+
stderr: '',
|
|
3098
|
+
exitCode: 0,
|
|
3099
|
+
artifacts: [],
|
|
3100
|
+
executionTimeMs: 0,
|
|
3101
|
+
};
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
const fileName = format === 'jsonl' ? 'corpus.jsonl' : 'corpus.csv';
|
|
3105
|
+
const files = [{ name: fileName, content: corpusData }];
|
|
3106
|
+
|
|
3107
|
+
let finalCode = code;
|
|
3108
|
+
let result = await sandboxManager.execute({
|
|
3109
|
+
code: finalCode,
|
|
3110
|
+
language: 'python',
|
|
3111
|
+
files,
|
|
3112
|
+
timeout: 30_000,
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
// Auto-retry once if code crashes
|
|
3116
|
+
if (result.exitCode !== 0) {
|
|
3117
|
+
// Apply regex patches for common LLM mistakes
|
|
3118
|
+
finalCode = finalCode.replace(/resample\((['"])M(['"])\)/g, 'resample($1ME$2)');
|
|
3119
|
+
finalCode = finalCode.replace(/resample\((['"])Q(['"])\)/g, 'resample($1QE$2)');
|
|
3120
|
+
finalCode = finalCode.replace(/resample\((['"])Y(['"])\)/g, 'resample($1YE$2)');
|
|
3121
|
+
finalCode = finalCode.replace(/freq=(['"])M(['"])/g, 'freq=$1ME$2');
|
|
3122
|
+
|
|
3123
|
+
result = await sandboxManager.execute({
|
|
3124
|
+
code: finalCode,
|
|
3125
|
+
language: 'python',
|
|
3126
|
+
files,
|
|
3127
|
+
timeout: 30_000,
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
return {
|
|
3132
|
+
stdout: result.stdout.slice(0, 5000),
|
|
3133
|
+
stderr: result.stderr.slice(0, 2000),
|
|
3134
|
+
exitCode: result.exitCode,
|
|
3135
|
+
artifacts: result.artifacts.map((a) => ({
|
|
3136
|
+
name: a.name,
|
|
3137
|
+
mimeType: a.mimeType,
|
|
3138
|
+
data: a.data.slice(0, 500_000),
|
|
3139
|
+
})),
|
|
3140
|
+
executionTimeMs: result.executionTimeMs,
|
|
3141
|
+
};
|
|
3142
|
+
} catch (err: any) {
|
|
3143
|
+
return {
|
|
3144
|
+
stdout: '',
|
|
3145
|
+
stderr: err.message ?? 'Corpus analysis failed',
|
|
3146
|
+
exitCode: 1,
|
|
3147
|
+
artifacts: [],
|
|
3148
|
+
executionTimeMs: 0,
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
},
|
|
3152
|
+
}),
|
|
3153
|
+
|
|
3154
|
+
fillDocumentForm: tool({
|
|
3155
|
+
description:
|
|
3156
|
+
'Fill form fields, text inputs, or checkboxes in the currently active document. Use this when the user asks to "fill the form", "add data to fields", or "fill with dummy data". You should use the searchKnowledgeBase tool to find answers to form questions if you do not know them. The available fields are provided in your system prompt.',
|
|
3157
|
+
inputSchema: z.object({
|
|
3158
|
+
edits: z
|
|
3159
|
+
.array(
|
|
3160
|
+
z.object({
|
|
3161
|
+
fieldId: z
|
|
3162
|
+
.string()
|
|
3163
|
+
.describe('The ID of the field to fill (from the active document context)'),
|
|
3164
|
+
value: z.string().describe('The value to set for the field'),
|
|
3165
|
+
}),
|
|
3166
|
+
)
|
|
3167
|
+
.describe('List of fields to fill and their new values'),
|
|
3168
|
+
}),
|
|
3169
|
+
execute: async ({ edits }) => {
|
|
3170
|
+
if (!docSessionId)
|
|
3171
|
+
return {
|
|
3172
|
+
success: false,
|
|
3173
|
+
error:
|
|
3174
|
+
'Document not loaded yet. Please wait a moment or ask the user to click the document to open it in the canvas before filling it.',
|
|
3175
|
+
};
|
|
3176
|
+
|
|
3177
|
+
try {
|
|
3178
|
+
const fieldEdits = edits.map((edit) => ({
|
|
3179
|
+
...edit,
|
|
3180
|
+
type: 'fill' as const,
|
|
3181
|
+
}));
|
|
3182
|
+
const result = await applyFieldEdits(docSessionId, fieldEdits);
|
|
3183
|
+
if (!result.success) {
|
|
3184
|
+
return { success: false, error: result.error };
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
return {
|
|
3188
|
+
success: true,
|
|
3189
|
+
action: 'fill_document',
|
|
3190
|
+
sessionId: docSessionId,
|
|
3191
|
+
edits,
|
|
3192
|
+
updatedFields: result.updatedFields,
|
|
3193
|
+
fileName: result.parsed.fileName,
|
|
3194
|
+
mimeType: result.parsed.mimeType,
|
|
3195
|
+
fields: result.parsed.fields,
|
|
3196
|
+
pages: result.parsed.pages.map((p) => ({
|
|
3197
|
+
index: p.index,
|
|
3198
|
+
text: p.text.slice(0, 2000),
|
|
3199
|
+
html: p.html,
|
|
3200
|
+
fields: p.fields,
|
|
3201
|
+
dimensions: p.dimensions,
|
|
3202
|
+
})),
|
|
3203
|
+
totalPages: result.parsed.totalPages,
|
|
3204
|
+
fileBase64: result.fileBase64,
|
|
3205
|
+
};
|
|
3206
|
+
} catch (err: any) {
|
|
3207
|
+
return { success: false, error: err.message };
|
|
3208
|
+
}
|
|
3209
|
+
},
|
|
3210
|
+
toModelOutput: documentEditModelOutput,
|
|
3211
|
+
}),
|
|
3212
|
+
|
|
3213
|
+
editDocument: tool({
|
|
3214
|
+
description:
|
|
3215
|
+
'Edit the content (text) of the currently active document. Use this when the user asks to modify paragraphs, rewrite text, or change slides (not form fields).',
|
|
3216
|
+
inputSchema: z.object({
|
|
3217
|
+
edits: z
|
|
3218
|
+
.array(
|
|
3219
|
+
z.object({
|
|
3220
|
+
pageIndex: z.number().default(0).describe('Page or slide index (0-based)'),
|
|
3221
|
+
type: z.enum(['replace_text', 'insert_text']).describe('Type of edit'),
|
|
3222
|
+
search: z.string().optional().describe('Text to search for (if replacing)'),
|
|
3223
|
+
text: z.string().describe('Replacement text or text to insert'),
|
|
3224
|
+
}),
|
|
3225
|
+
)
|
|
3226
|
+
.describe('List of content edits to apply'),
|
|
3227
|
+
}),
|
|
3228
|
+
execute: async ({ edits }) => {
|
|
3229
|
+
if (!docSessionId)
|
|
3230
|
+
return {
|
|
3231
|
+
success: false,
|
|
3232
|
+
error:
|
|
3233
|
+
'Document not loaded yet. Please wait a moment or ask the user to click the document to open it in the canvas before editing it.',
|
|
3234
|
+
};
|
|
3235
|
+
|
|
3236
|
+
try {
|
|
3237
|
+
const result = await applyContentEdits(docSessionId, edits);
|
|
3238
|
+
if (!result.success) {
|
|
3239
|
+
return { success: false, error: result.error };
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
return {
|
|
3243
|
+
success: true,
|
|
3244
|
+
action: 'edit_document',
|
|
3245
|
+
sessionId: docSessionId,
|
|
3246
|
+
edits,
|
|
3247
|
+
updatedFields: result.updatedFields,
|
|
3248
|
+
fileName: result.parsed.fileName,
|
|
3249
|
+
mimeType: result.parsed.mimeType,
|
|
3250
|
+
pages: result.parsed.pages.map((p) => ({
|
|
3251
|
+
index: p.index,
|
|
3252
|
+
text: p.text.slice(0, 2000),
|
|
3253
|
+
html: p.html,
|
|
3254
|
+
fields: p.fields,
|
|
3255
|
+
dimensions: p.dimensions,
|
|
3256
|
+
})),
|
|
3257
|
+
totalPages: result.parsed.totalPages,
|
|
3258
|
+
fileBase64: result.fileBase64,
|
|
3259
|
+
};
|
|
3260
|
+
} catch (err: any) {
|
|
3261
|
+
return { success: false, error: err.message };
|
|
3262
|
+
}
|
|
3263
|
+
},
|
|
3264
|
+
toModelOutput: documentEditModelOutput,
|
|
3265
|
+
}),
|
|
3266
|
+
|
|
3267
|
+
requestDocumentSignature: tool({
|
|
3268
|
+
description:
|
|
3269
|
+
'STRICTLY ONLY use this when the user explicitly asks to "sign" the currently active document or asks for a "signature". CRITICAL: Do NOT use this tool for filling forms, answering questions, or adding dummy data. CRITICAL: NEVER call this tool more than once per user request. Yields a UI widget for the user to confirm the placement and provide their signature.',
|
|
3270
|
+
inputSchema: z.object({
|
|
3271
|
+
detectedLocations: z
|
|
3272
|
+
.array(
|
|
3273
|
+
z.object({
|
|
3274
|
+
pageIndex: z.number(),
|
|
3275
|
+
context: z.string(),
|
|
3276
|
+
}),
|
|
3277
|
+
)
|
|
3278
|
+
.optional()
|
|
3279
|
+
.describe('Optional. The detected signature locations in the document.'),
|
|
3280
|
+
}),
|
|
3281
|
+
}),
|
|
3282
|
+
|
|
3283
|
+
webSearch: tool({
|
|
3284
|
+
description:
|
|
3285
|
+
'Search the public internet for current information, news, or facts. Use it first for clearly current/public requests, or once after an irrelevant local search. If it fails or returns no useful results, searchKnowledgeBase is available once as a fallback. Never repeat either search tool in the same turn.',
|
|
3286
|
+
inputSchema: z.object({
|
|
3287
|
+
query: z.string().describe('The search query.'),
|
|
3288
|
+
}),
|
|
3289
|
+
execute: async ({ query }) => {
|
|
3290
|
+
const knowledgeBaseFallback = async (error: string) => {
|
|
3291
|
+
try {
|
|
3292
|
+
const knowledge = await queryKnowledgeBase(query, 5, projectId ?? null);
|
|
3293
|
+
return {
|
|
3294
|
+
error,
|
|
3295
|
+
results: [],
|
|
3296
|
+
fallback: {
|
|
3297
|
+
source: 'knowledge-base',
|
|
3298
|
+
attemptedBecause: 'Public web search failed or returned no results.',
|
|
3299
|
+
...knowledge,
|
|
3300
|
+
},
|
|
3301
|
+
};
|
|
3302
|
+
} catch (fallbackError: any) {
|
|
3303
|
+
return {
|
|
3304
|
+
error,
|
|
3305
|
+
results: [],
|
|
3306
|
+
fallback: {
|
|
3307
|
+
source: 'knowledge-base',
|
|
3308
|
+
error: fallbackError?.message || 'Knowledge-base fallback failed.',
|
|
3309
|
+
hits: [],
|
|
3310
|
+
},
|
|
3311
|
+
};
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
const provider = config?.webSearchProvider || 'tavily';
|
|
3315
|
+
const req = new Request('http://localhost/api/search', {
|
|
3316
|
+
method: 'POST',
|
|
3317
|
+
body: JSON.stringify({ query }),
|
|
3318
|
+
});
|
|
3319
|
+
|
|
3320
|
+
let res: any;
|
|
3321
|
+
try {
|
|
3322
|
+
switch (provider) {
|
|
3323
|
+
case 'google':
|
|
3324
|
+
case 'serper':
|
|
3325
|
+
const { POST: searchGoogle } = await import('../search/google/route');
|
|
3326
|
+
res = await searchGoogle(req);
|
|
3327
|
+
break;
|
|
3328
|
+
case 'tavily':
|
|
3329
|
+
const { POST: searchTavily } = await import('../search/tavily/route');
|
|
3330
|
+
res = await searchTavily(req);
|
|
3331
|
+
break;
|
|
3332
|
+
case 'brave':
|
|
3333
|
+
const { POST: searchBrave } = await import('../search/brave/route');
|
|
3334
|
+
res = await searchBrave(req);
|
|
3335
|
+
break;
|
|
3336
|
+
case 'bing':
|
|
3337
|
+
const { POST: searchBing } = await import('../search/bing/route');
|
|
3338
|
+
res = await searchBing(req);
|
|
3339
|
+
break;
|
|
3340
|
+
case 'exa':
|
|
3341
|
+
const { POST: searchExa } = await import('../search/exa/route');
|
|
3342
|
+
res = await searchExa(req);
|
|
3343
|
+
break;
|
|
3344
|
+
case 'local':
|
|
3345
|
+
default:
|
|
3346
|
+
const { POST: searchLocal } = await import('../search/route');
|
|
3347
|
+
res = await searchLocal(req);
|
|
3348
|
+
break;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
if (res.status >= 400) {
|
|
3352
|
+
const err = await res.json();
|
|
3353
|
+
return knowledgeBaseFallback(err.error || 'Search failed');
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
const data = await res.json();
|
|
3357
|
+
const items = data.items || data.results || [];
|
|
3358
|
+
if (items.length === 0) {
|
|
3359
|
+
return knowledgeBaseFallback('Public search returned no results.');
|
|
3360
|
+
}
|
|
3361
|
+
return {
|
|
3362
|
+
results: items.slice(0, 10).map((i: any) => ({
|
|
3363
|
+
title: i.title,
|
|
3364
|
+
url: i.url,
|
|
3365
|
+
snippet: i.description || i.snippet || '',
|
|
3366
|
+
})),
|
|
3367
|
+
};
|
|
3368
|
+
} catch (e: any) {
|
|
3369
|
+
return knowledgeBaseFallback(e.message || 'Search failed');
|
|
3370
|
+
}
|
|
3371
|
+
},
|
|
3372
|
+
}),
|
|
3373
|
+
};
|
|
3374
|
+
|
|
3375
|
+
const enabledTools = config?.enabledTools || [];
|
|
3376
|
+
const finalTools: Record<string, any> = {};
|
|
3377
|
+
|
|
3378
|
+
// Core chat capabilities below remain available by their generic category.
|
|
3379
|
+
// Optional first-party actions must be explicitly enabled; installed
|
|
3380
|
+
// Marketplace tools retain the historical "all when unset" behavior.
|
|
3381
|
+
const isBuiltInEnabled = (id: string) => enabledTools.includes(id);
|
|
3382
|
+
const isMarketplaceEnabled = (id: string) =>
|
|
3383
|
+
enabledTools.length === 0 || enabledTools.includes(id);
|
|
3384
|
+
|
|
3385
|
+
// 1. Add enabled built-in tools
|
|
3386
|
+
for (const [id, toolDef] of Object.entries(builtInTools)) {
|
|
3387
|
+
if (id === 'webSearch') continue; // Handled separately
|
|
3388
|
+
if ((id === 'executeAnalysis' || id === 'analyzeCorpusWithCode') && !sandboxReady) continue;
|
|
3389
|
+
|
|
3390
|
+
if (
|
|
3391
|
+
isBuiltInEnabled(id) ||
|
|
3392
|
+
[
|
|
3393
|
+
// Retrieval is core chat behavior, not an optional marketplace action.
|
|
3394
|
+
// Keeping it available prevents an enabled web-search configuration
|
|
3395
|
+
// from silently removing the user's local knowledge base.
|
|
3396
|
+
'searchKnowledgeBase',
|
|
3397
|
+
// Data analysis and visualizations are part of the first-party chat
|
|
3398
|
+
// experience. Keep them available even when a legacy enabled-tools
|
|
3399
|
+
// configuration only lists marketplace tools.
|
|
3400
|
+
'queryTabularData',
|
|
3401
|
+
'createDataExport',
|
|
3402
|
+
'generateVisualization',
|
|
3403
|
+
'executeAnalysis',
|
|
3404
|
+
// PDF/image hits can require one visual pass after retrieval. This is
|
|
3405
|
+
// available only to a follow-up tool step, never auto-run on chat.
|
|
3406
|
+
'inspectPdfPages',
|
|
3407
|
+
'analyzePdfPages',
|
|
3408
|
+
'analyzeImageDeeply',
|
|
3409
|
+
'presentMedia',
|
|
3410
|
+
'fillDocumentForm',
|
|
3411
|
+
'editDocument',
|
|
3412
|
+
'requestDocumentSignature',
|
|
3413
|
+
].includes(id)
|
|
3414
|
+
) {
|
|
3415
|
+
finalTools[id] = toolDef;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
// Add webSearch tool if explicitly enabled globally
|
|
3420
|
+
if (config?.webSearchEnabled === true) {
|
|
3421
|
+
finalTools['webSearch'] = builtInTools['webSearch'];
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
// 2. Add enabled marketplace tools, generically — for ANY tool implementing
|
|
3425
|
+
// the ToolExtension v1 + AGENT_TOOL(S) contract (packages/marketplace's
|
|
3426
|
+
// `defineAgentTool`), a new tool becomes chat-callable purely by publishing
|
|
3427
|
+
// a manifest and an AGENT_TOOL export. Nothing here may name a specific
|
|
3428
|
+
// tool's id, schema, or behavior.
|
|
3429
|
+
const dynamicToolNames: string[] = [];
|
|
3430
|
+
const dynamicToolWorkflows: Record<string, NonNullable<AgentToolDefinition['workflow']>> = {};
|
|
3431
|
+
const dynamicToolEvidenceInputs: Record<
|
|
3432
|
+
string,
|
|
3433
|
+
NonNullable<AgentToolDefinition['evidenceInput']>
|
|
3434
|
+
> = {};
|
|
3435
|
+
const promptFragments: string[] = [];
|
|
3436
|
+
|
|
3437
|
+
try {
|
|
3438
|
+
const hostedTools = workspaceHost()?.tools;
|
|
3439
|
+
const installed = hostedTools ?? (workspaceHost() ? [] : await getInstalledTools());
|
|
3440
|
+
for (const t of installed) {
|
|
3441
|
+
if (!isMarketplaceEnabled(t.id)) continue;
|
|
3442
|
+
const hosted = hostedTools?.find((candidate) => candidate.id === t.id);
|
|
3443
|
+
const mod = hosted?.module ?? (await loadTool<Record<string, unknown>>(t.id));
|
|
3444
|
+
if (!mod) continue;
|
|
3445
|
+
|
|
3446
|
+
const extension = (mod.TOOL_EXTENSION ?? mod.default) as
|
|
3447
|
+
{ id?: string; apiVersion?: string; createClient?: (ctx: unknown) => unknown } | undefined;
|
|
3448
|
+
const agentDefs: AgentToolDefinition[] = Array.isArray(mod.AGENT_TOOLS)
|
|
3449
|
+
? (mod.AGENT_TOOLS as AgentToolDefinition[])
|
|
3450
|
+
: mod.AGENT_TOOL
|
|
3451
|
+
? [mod.AGENT_TOOL as AgentToolDefinition]
|
|
3452
|
+
: [];
|
|
3453
|
+
|
|
3454
|
+
if (
|
|
3455
|
+
extension?.apiVersion === '1' &&
|
|
3456
|
+
typeof extension.createClient === 'function' &&
|
|
3457
|
+
agentDefs.length > 0
|
|
3458
|
+
) {
|
|
3459
|
+
const toolContext = {
|
|
3460
|
+
config: {
|
|
3461
|
+
...(t.config ?? {}),
|
|
3462
|
+
...(activeProjectConfig?.toolConfigs?.[t.id] ?? {}),
|
|
3463
|
+
},
|
|
3464
|
+
fetch: hosted
|
|
3465
|
+
? (input: RequestInfo | URL, init?: RequestInit) => {
|
|
3466
|
+
const raw =
|
|
3467
|
+
typeof input === 'string'
|
|
3468
|
+
? input
|
|
3469
|
+
: input instanceof URL
|
|
3470
|
+
? input.toString()
|
|
3471
|
+
: input.url;
|
|
3472
|
+
const source = new URL(raw, origin ?? 'http://workspace.internal');
|
|
3473
|
+
const routed = workspaceServerUrl(`${source.pathname}${source.search}`);
|
|
3474
|
+
return fetch(new URL(routed, source.origin), init);
|
|
3475
|
+
}
|
|
3476
|
+
: globalThis.fetch,
|
|
3477
|
+
trackUsage: (event: { meter: string; quantity: number; unit: string }) =>
|
|
3478
|
+
trackEnterpriseToolUsage(config, t.id, event),
|
|
3479
|
+
};
|
|
3480
|
+
const client = extension.createClient(toolContext) as Record<string, AgentToolHandler>;
|
|
3481
|
+
for (const def of agentDefs) {
|
|
3482
|
+
if (!isValidAgentToolDefinition(def) || typeof client[def.method] !== 'function') {
|
|
3483
|
+
console.warn(`[marketplace] Skipping invalid agent action from "${t.id}".`);
|
|
3484
|
+
continue;
|
|
3485
|
+
}
|
|
3486
|
+
// Marketplace packages cannot replace first-party tools or each
|
|
3487
|
+
// other. A collision is a package error, not an override mechanism.
|
|
3488
|
+
if (finalTools[def.name]) {
|
|
3489
|
+
console.warn(
|
|
3490
|
+
`[marketplace] Skipping colliding agent action "${def.name}" from "${t.id}".`,
|
|
3491
|
+
);
|
|
3492
|
+
continue;
|
|
3493
|
+
}
|
|
3494
|
+
finalTools[def.name] = tool({
|
|
3495
|
+
description: def.description,
|
|
3496
|
+
inputSchema: jsonSchema(def.parameters as Parameters<typeof jsonSchema>[0]),
|
|
3497
|
+
execute: (input: unknown, options: { toolCallId: string }) =>
|
|
3498
|
+
client[def.method](input, {
|
|
3499
|
+
origin,
|
|
3500
|
+
projectId,
|
|
3501
|
+
toolCallId: options.toolCallId,
|
|
3502
|
+
mediaEvidence,
|
|
3503
|
+
} satisfies AgentToolExecutionContext),
|
|
3504
|
+
});
|
|
3505
|
+
dynamicToolNames.push(def.name);
|
|
3506
|
+
if (def.workflow) dynamicToolWorkflows[def.name] = def.workflow;
|
|
3507
|
+
if (def.evidenceInput) dynamicToolEvidenceInputs[def.name] = def.evidenceInput;
|
|
3508
|
+
if (def.systemPromptFragment) promptFragments.push(def.systemPromptFragment);
|
|
3509
|
+
}
|
|
3510
|
+
continue;
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// Legacy shapes some marketplace tools still export directly.
|
|
3514
|
+
if (typeof mod.default === 'function') {
|
|
3515
|
+
finalTools[t.id] = (mod.default as (ctx: unknown) => unknown)(context);
|
|
3516
|
+
dynamicToolNames.push(t.id);
|
|
3517
|
+
} else if (typeof mod.tool === 'function') {
|
|
3518
|
+
finalTools[t.id] = (mod.tool as (ctx: unknown) => unknown)(context);
|
|
3519
|
+
dynamicToolNames.push(t.id);
|
|
3520
|
+
}
|
|
3521
|
+
}
|
|
3522
|
+
} catch (err) {
|
|
3523
|
+
console.error('[marketplace] Failed to load marketplace tools for chat:', err);
|
|
3524
|
+
}
|
|
3525
|
+
|
|
3526
|
+
// Enterprise tools never touch the public Marketplace catalog. Their
|
|
3527
|
+
// descriptors and handlers stay in the private EE dashboard, and only an
|
|
3528
|
+
// enrolled client with an explicitly installed tool receives them here.
|
|
3529
|
+
// (Generic already: `enterpriseTool.id`/`.description` always come from
|
|
3530
|
+
// whatever the EE server currently returns, never a hardcoded tool.)
|
|
3531
|
+
for (const enterpriseTool of await getEnterpriseTools(config)) {
|
|
3532
|
+
finalTools[enterpriseTool.id] = tool({
|
|
3533
|
+
description: `${enterpriseTool.description} Use this only when it directly helps the Enterprise user's request.`,
|
|
3534
|
+
inputSchema: z.object({
|
|
3535
|
+
query: z.string().describe('The focused request for this private tool.'),
|
|
3536
|
+
}),
|
|
3537
|
+
execute: async ({ query }) => executeEnterpriseTool(config, enterpriseTool.id, { query }),
|
|
3538
|
+
});
|
|
3539
|
+
dynamicToolNames.push(enterpriseTool.id);
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
return {
|
|
3543
|
+
tools: finalTools,
|
|
3544
|
+
promptFragments,
|
|
3545
|
+
dynamicToolNames,
|
|
3546
|
+
dynamicToolWorkflows,
|
|
3547
|
+
dynamicToolEvidenceInputs,
|
|
3548
|
+
};
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
function isValidAgentToolDefinition(definition: AgentToolDefinition): boolean {
|
|
3552
|
+
return (
|
|
3553
|
+
typeof definition.name === 'string' &&
|
|
3554
|
+
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(definition.name) &&
|
|
3555
|
+
typeof definition.description === 'string' &&
|
|
3556
|
+
definition.description.length > 0 &&
|
|
3557
|
+
typeof definition.method === 'string' &&
|
|
3558
|
+
/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(definition.method) &&
|
|
3559
|
+
typeof definition.parameters === 'object' &&
|
|
3560
|
+
definition.parameters !== null &&
|
|
3561
|
+
!Array.isArray(definition.parameters)
|
|
3562
|
+
);
|
|
3563
|
+
}
|