@duckcodeailabs/dql-project 1.6.18 → 1.6.19
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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-notebook-research-storage.d.ts +365 -0
- package/dist/local-notebook-research-storage.d.ts.map +1 -0
- package/dist/local-notebook-research-storage.js +1521 -0
- package/dist/local-notebook-research-storage.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
let databaseCtor = null;
|
|
6
|
+
const HAS_SQL_SQL = "(COALESCE(NULLIF(TRIM(reviewed_sql), ''), NULLIF(TRIM(generated_sql), '')) IS NOT NULL)";
|
|
7
|
+
const HAS_REVIEWED_SQL = "(NULLIF(TRIM(reviewed_sql), '') IS NOT NULL)";
|
|
8
|
+
const HAS_GENERATED_SQL = "(NULLIF(TRIM(generated_sql), '') IS NOT NULL)";
|
|
9
|
+
const HAS_PREVIEW_SQL = "(result_preview IS NOT NULL AND TRIM(result_preview) <> '')";
|
|
10
|
+
const HAS_DRAFT_SQL = "(draft_block_path IS NOT NULL AND TRIM(draft_block_path) <> '')";
|
|
11
|
+
const HAS_EVIDENCE_SQL = "((evidence IS NOT NULL AND TRIM(evidence) <> '' AND TRIM(evidence) <> 'null') OR (context_pack_id IS NOT NULL AND TRIM(context_pack_id) <> ''))";
|
|
12
|
+
const DRAFT_READY_SQL = `(TRIM(question) <> '' AND ${HAS_REVIEWED_SQL} AND ${HAS_EVIDENCE_SQL} AND status <> 'error')`;
|
|
13
|
+
const CERTIFICATION_READY_SQL = `(${DRAFT_READY_SQL} AND ${HAS_PREVIEW_SQL} AND ${HAS_DRAFT_SQL} AND COALESCE(dql_promotion_action, '') NOT IN ('reuse_existing', 'review_required'))`;
|
|
14
|
+
const BLOCKED_SQL = `(status = 'error' OR TRIM(question) = '' OR NOT ${HAS_SQL_SQL})`;
|
|
15
|
+
const NEXT_ACTION_SQL = `CASE
|
|
16
|
+
WHEN review_status IN ('completed', 'rejected', 'certified') THEN 'continue_review'
|
|
17
|
+
WHEN ${BLOCKED_SQL} THEN 'fix_blockers'
|
|
18
|
+
WHEN NOT ${HAS_REVIEWED_SQL} AND ${HAS_GENERATED_SQL} THEN 'review_sql'
|
|
19
|
+
WHEN NOT ${HAS_EVIDENCE_SQL} THEN 'review_context'
|
|
20
|
+
WHEN NOT ${HAS_PREVIEW_SQL} THEN 'run_preview'
|
|
21
|
+
WHEN dql_promotion_action = 'reuse_existing' THEN 'reuse_existing'
|
|
22
|
+
WHEN NOT ${HAS_DRAFT_SQL} AND ${DRAFT_READY_SQL} THEN 'create_dql_draft'
|
|
23
|
+
WHEN ${CERTIFICATION_READY_SQL} THEN 'open_certification'
|
|
24
|
+
WHEN ${HAS_DRAFT_SQL} THEN 'complete_review'
|
|
25
|
+
ELSE 'continue_review'
|
|
26
|
+
END`;
|
|
27
|
+
const NEXT_ACTION_PRIORITY_SQL = `CASE (${NEXT_ACTION_SQL})
|
|
28
|
+
WHEN 'fix_blockers' THEN 0
|
|
29
|
+
WHEN 'review_sql' THEN 1
|
|
30
|
+
WHEN 'review_context' THEN 2
|
|
31
|
+
WHEN 'run_preview' THEN 3
|
|
32
|
+
WHEN 'reuse_existing' THEN 4
|
|
33
|
+
WHEN 'create_dql_draft' THEN 5
|
|
34
|
+
WHEN 'open_certification' THEN 6
|
|
35
|
+
WHEN 'complete_review' THEN 7
|
|
36
|
+
WHEN 'continue_review' THEN 8
|
|
37
|
+
ELSE 9
|
|
38
|
+
END`;
|
|
39
|
+
const SEARCH_INDEX_VERSION = '3';
|
|
40
|
+
const RESEARCH_DOMAIN_GROUP_LIMIT = 100;
|
|
41
|
+
const RESEARCH_INTENT_GROUP_LIMIT = 50;
|
|
42
|
+
const RESEARCH_NOTEBOOK_GROUP_LIMIT = 100;
|
|
43
|
+
const RESEARCH_OWNER_GROUP_LIMIT = 100;
|
|
44
|
+
function loadDatabase() {
|
|
45
|
+
databaseCtor ??= require('better-sqlite3');
|
|
46
|
+
return databaseCtor;
|
|
47
|
+
}
|
|
48
|
+
const NOTEBOOK_NEXT_ACTION_PRIORITY = [
|
|
49
|
+
'fix_blockers',
|
|
50
|
+
'review_sql',
|
|
51
|
+
'review_context',
|
|
52
|
+
'run_preview',
|
|
53
|
+
'reuse_existing',
|
|
54
|
+
'create_dql_draft',
|
|
55
|
+
'open_certification',
|
|
56
|
+
'complete_review',
|
|
57
|
+
'continue_review',
|
|
58
|
+
];
|
|
59
|
+
function notebookSummaryNextAction(counts) {
|
|
60
|
+
for (const action of NOTEBOOK_NEXT_ACTION_PRIORITY) {
|
|
61
|
+
const count = counts[action];
|
|
62
|
+
if (count > 0)
|
|
63
|
+
return { action, count };
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
function actionCountsFromRow(row) {
|
|
68
|
+
return {
|
|
69
|
+
fix_blockers: row.nextFixBlockers ?? 0,
|
|
70
|
+
review_sql: row.nextReviewSql ?? 0,
|
|
71
|
+
review_context: row.nextReviewContext ?? 0,
|
|
72
|
+
run_preview: row.nextRunPreview ?? 0,
|
|
73
|
+
reuse_existing: row.nextReuseExisting ?? 0,
|
|
74
|
+
create_dql_draft: row.nextCreateDqlDraft ?? 0,
|
|
75
|
+
open_certification: row.nextOpenCertification ?? 0,
|
|
76
|
+
complete_review: row.nextCompleteReview ?? 0,
|
|
77
|
+
continue_review: row.nextContinueReview ?? 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function defaultNotebookResearchDbPath(projectRoot) {
|
|
81
|
+
return `${projectRoot}/.dql/local/notebook-research.sqlite`;
|
|
82
|
+
}
|
|
83
|
+
export class LocalNotebookResearchStorage {
|
|
84
|
+
db;
|
|
85
|
+
searchIndexAvailable = false;
|
|
86
|
+
constructor(dbPath) {
|
|
87
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
88
|
+
const Database = loadDatabase();
|
|
89
|
+
this.db = new Database(dbPath);
|
|
90
|
+
this.db.pragma('journal_mode = WAL');
|
|
91
|
+
this.initSchema();
|
|
92
|
+
}
|
|
93
|
+
close() {
|
|
94
|
+
this.db.close();
|
|
95
|
+
}
|
|
96
|
+
createRun(input) {
|
|
97
|
+
const now = new Date().toISOString();
|
|
98
|
+
const question = cleanOptionalString(input.question) ?? 'Notebook research';
|
|
99
|
+
const sourceCell = normalizeSourceCellInput(input.sourceCell);
|
|
100
|
+
const sourceSql = sourceCell?.sql ?? sourceCell?.content ?? sourceCell?.source;
|
|
101
|
+
const run = {
|
|
102
|
+
id: input.id ?? `nbr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
103
|
+
notebookPath: input.notebookPath,
|
|
104
|
+
domain: cleanOptionalString(input.domain) ?? inferResearchDomain({
|
|
105
|
+
context: input.context,
|
|
106
|
+
notebookPath: input.notebookPath,
|
|
107
|
+
}),
|
|
108
|
+
owner: cleanOptionalString(input.owner),
|
|
109
|
+
sourceCellId: cleanOptionalString(input.sourceCellId) ?? sourceCell?.id,
|
|
110
|
+
sourceCellName: cleanOptionalString(input.sourceCellName) ?? sourceCell?.name,
|
|
111
|
+
sourceCellFingerprint: cleanOptionalString(input.sourceCellFingerprint)
|
|
112
|
+
?? sourceCell?.fingerprint
|
|
113
|
+
?? fingerprintSql(cleanOptionalString(input.reviewedSql) ?? cleanOptionalString(input.generatedSql) ?? sourceSql),
|
|
114
|
+
title: cleanOptionalString(input.title) ?? titleFromQuestion(question),
|
|
115
|
+
question,
|
|
116
|
+
intent: input.intent ?? inferIntent(question),
|
|
117
|
+
context: input.context,
|
|
118
|
+
status: 'draft',
|
|
119
|
+
generatedSql: cleanOptionalString(input.generatedSql),
|
|
120
|
+
reviewedSql: cleanOptionalString(input.reviewedSql),
|
|
121
|
+
warnings: [],
|
|
122
|
+
reviewStatus: 'needs_review',
|
|
123
|
+
dqlCandidateIds: [],
|
|
124
|
+
createdAt: now,
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
};
|
|
127
|
+
this.db.prepare(`
|
|
128
|
+
INSERT INTO notebook_research_runs (
|
|
129
|
+
id, notebook_path, domain, owner, source_cell_id, source_cell_name, source_cell_fingerprint, title, question,
|
|
130
|
+
intent, context, status, summary, recommendation, result_preview,
|
|
131
|
+
evidence, research_plan, generated_sql, reviewed_sql, display, context_pack_id,
|
|
132
|
+
route_decision, warnings, review_status, error, draft_block_path,
|
|
133
|
+
dql_import_id, dql_candidate_ids, dql_promotion_action, dql_promotion, created_at, updated_at, last_run_at
|
|
134
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
135
|
+
`).run(run.id, run.notebookPath, run.domain ?? null, run.owner ?? null, run.sourceCellId ?? null, run.sourceCellName ?? null, run.sourceCellFingerprint ?? null, run.title, run.question, run.intent, json(run.context), run.status, null, null, null, null, null, run.generatedSql ?? null, run.reviewedSql ?? null, null, null, null, json(run.warnings), run.reviewStatus, null, null, null, json(run.dqlCandidateIds), null, null, run.createdAt, run.updatedAt, null);
|
|
136
|
+
this.upsertSearchIndex(run);
|
|
137
|
+
return run;
|
|
138
|
+
}
|
|
139
|
+
listRuns(query) {
|
|
140
|
+
return this.listRunsPage(query).runs;
|
|
141
|
+
}
|
|
142
|
+
listRunsPage(query) {
|
|
143
|
+
const normalized = normalizeListQuery(query);
|
|
144
|
+
const { whereSql, params } = researchListWhere(normalized, { useSearchIndex: this.searchIndexAvailable });
|
|
145
|
+
const countScope = { ...normalized, status: undefined, reviewStatus: undefined, promotionAction: undefined, readiness: undefined, age: undefined, nextAction: undefined };
|
|
146
|
+
const { whereSql: countWhereSql, params: countParams } = researchListWhere(countScope, { useSearchIndex: this.searchIndexAvailable });
|
|
147
|
+
const domainScope = { ...countScope, domain: undefined };
|
|
148
|
+
const { whereSql: domainWhereSql, params: domainParams } = researchListWhere(domainScope, { useSearchIndex: this.searchIndexAvailable });
|
|
149
|
+
const ownerScope = { ...countScope, owner: undefined };
|
|
150
|
+
const { whereSql: ownerWhereSql, params: ownerParams } = researchListWhere(ownerScope, { useSearchIndex: this.searchIndexAvailable });
|
|
151
|
+
const intentScope = { ...countScope, intent: undefined };
|
|
152
|
+
const { whereSql: intentWhereSql, params: intentParams } = researchListWhere(intentScope, { useSearchIndex: this.searchIndexAvailable });
|
|
153
|
+
const notebookScope = { ...countScope, notebookPath: undefined };
|
|
154
|
+
const { whereSql: notebookWhereSql, params: notebookParams } = researchListWhere(notebookScope, { useSearchIndex: this.searchIndexAvailable });
|
|
155
|
+
const groupCounts = this.db.prepare(`
|
|
156
|
+
SELECT
|
|
157
|
+
(SELECT COUNT(*) FROM (
|
|
158
|
+
SELECT COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized') AS domain
|
|
159
|
+
FROM notebook_research_runs
|
|
160
|
+
${domainWhereSql}
|
|
161
|
+
GROUP BY COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')
|
|
162
|
+
)) AS domains,
|
|
163
|
+
(SELECT COUNT(*) FROM (
|
|
164
|
+
SELECT intent
|
|
165
|
+
FROM notebook_research_runs
|
|
166
|
+
${intentWhereSql}
|
|
167
|
+
GROUP BY intent
|
|
168
|
+
)) AS intents,
|
|
169
|
+
(SELECT COUNT(*) FROM (
|
|
170
|
+
SELECT COALESCE(NULLIF(TRIM(owner), ''), 'unassigned') AS owner
|
|
171
|
+
FROM notebook_research_runs
|
|
172
|
+
${ownerWhereSql}
|
|
173
|
+
GROUP BY COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')
|
|
174
|
+
)) AS owners,
|
|
175
|
+
(SELECT COUNT(*) FROM (
|
|
176
|
+
SELECT notebook_path
|
|
177
|
+
FROM notebook_research_runs
|
|
178
|
+
${notebookWhereSql}
|
|
179
|
+
GROUP BY notebook_path
|
|
180
|
+
)) AS notebooks
|
|
181
|
+
`).get(...domainParams, ...intentParams, ...ownerParams, ...notebookParams);
|
|
182
|
+
const total = this.db.prepare(`
|
|
183
|
+
SELECT COUNT(*) AS count
|
|
184
|
+
FROM notebook_research_runs
|
|
185
|
+
${whereSql}
|
|
186
|
+
`).get(...params)?.count ?? 0;
|
|
187
|
+
const staleCutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
188
|
+
const expiredCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
189
|
+
const counts = this.db.prepare(`
|
|
190
|
+
SELECT
|
|
191
|
+
COUNT(*) AS total,
|
|
192
|
+
SUM(CASE WHEN status = 'ready' THEN 1 ELSE 0 END) AS ready,
|
|
193
|
+
SUM(CASE WHEN review_status = 'needs_review' THEN 1 ELSE 0 END) AS needsReview,
|
|
194
|
+
SUM(CASE WHEN review_status = 'draft_created' THEN 1 ELSE 0 END) AS dqlDrafts,
|
|
195
|
+
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors,
|
|
196
|
+
SUM(CASE WHEN dql_promotion_action = 'reuse_existing' THEN 1 ELSE 0 END) AS reuseExisting,
|
|
197
|
+
SUM(CASE WHEN dql_promotion_action = 'extend_existing' THEN 1 ELSE 0 END) AS extendExisting,
|
|
198
|
+
SUM(CASE WHEN dql_promotion_action = 'create_replacement' THEN 1 ELSE 0 END) AS replacements,
|
|
199
|
+
SUM(CASE WHEN dql_promotion_action = 'create_new' THEN 1 ELSE 0 END) AS createNew,
|
|
200
|
+
SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
|
|
201
|
+
SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
|
|
202
|
+
SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
|
|
203
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
|
|
204
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
|
|
205
|
+
COUNT(DISTINCT CASE WHEN source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> '' THEN source_cell_id END) AS sourceLinked,
|
|
206
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
|
|
207
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
|
|
208
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
|
|
209
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
|
|
210
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
|
|
211
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
|
|
212
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
|
|
213
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
|
|
214
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview
|
|
215
|
+
FROM notebook_research_runs
|
|
216
|
+
${countWhereSql}
|
|
217
|
+
`).get(staleCutoff, expiredCutoff, ...countParams);
|
|
218
|
+
const domains = this.db.prepare(`
|
|
219
|
+
SELECT
|
|
220
|
+
COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized') AS domain,
|
|
221
|
+
COUNT(*) AS total,
|
|
222
|
+
SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
|
|
223
|
+
SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
|
|
224
|
+
SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
|
|
225
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
|
|
226
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
|
|
227
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
|
|
228
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
|
|
229
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
|
|
230
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
|
|
231
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
|
|
232
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
|
|
233
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
|
|
234
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
|
|
235
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
|
|
236
|
+
MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
|
|
237
|
+
FROM notebook_research_runs
|
|
238
|
+
${domainWhereSql}
|
|
239
|
+
GROUP BY COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')
|
|
240
|
+
ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, domain ASC
|
|
241
|
+
LIMIT ${RESEARCH_DOMAIN_GROUP_LIMIT}
|
|
242
|
+
`).all(staleCutoff, expiredCutoff, ...domainParams);
|
|
243
|
+
const owners = this.db.prepare(`
|
|
244
|
+
SELECT
|
|
245
|
+
COALESCE(NULLIF(TRIM(owner), ''), 'unassigned') AS owner,
|
|
246
|
+
COUNT(*) AS total,
|
|
247
|
+
SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
|
|
248
|
+
SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
|
|
249
|
+
SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
|
|
250
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
|
|
251
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
|
|
252
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
|
|
253
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
|
|
254
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
|
|
255
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
|
|
256
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
|
|
257
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
|
|
258
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
|
|
259
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
|
|
260
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
|
|
261
|
+
MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
|
|
262
|
+
FROM notebook_research_runs
|
|
263
|
+
${ownerWhereSql}
|
|
264
|
+
GROUP BY COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')
|
|
265
|
+
ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, owner ASC
|
|
266
|
+
LIMIT ${RESEARCH_OWNER_GROUP_LIMIT}
|
|
267
|
+
`).all(staleCutoff, expiredCutoff, ...ownerParams);
|
|
268
|
+
const intents = this.db.prepare(`
|
|
269
|
+
SELECT
|
|
270
|
+
intent,
|
|
271
|
+
COUNT(*) AS total,
|
|
272
|
+
SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
|
|
273
|
+
SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
|
|
274
|
+
SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
|
|
275
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
|
|
276
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
|
|
277
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
|
|
278
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
|
|
279
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
|
|
280
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
|
|
281
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
|
|
282
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
|
|
283
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
|
|
284
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
|
|
285
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
|
|
286
|
+
MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
|
|
287
|
+
FROM notebook_research_runs
|
|
288
|
+
${intentWhereSql}
|
|
289
|
+
GROUP BY intent
|
|
290
|
+
ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, intent ASC
|
|
291
|
+
LIMIT ${RESEARCH_INTENT_GROUP_LIMIT}
|
|
292
|
+
`).all(staleCutoff, expiredCutoff, ...intentParams);
|
|
293
|
+
const notebooks = this.db.prepare(`
|
|
294
|
+
SELECT
|
|
295
|
+
notebook_path AS path,
|
|
296
|
+
COUNT(*) AS total,
|
|
297
|
+
SUM(CASE WHEN ${DRAFT_READY_SQL} THEN 1 ELSE 0 END) AS draftReady,
|
|
298
|
+
SUM(CASE WHEN ${CERTIFICATION_READY_SQL} THEN 1 ELSE 0 END) AS certificationReady,
|
|
299
|
+
SUM(CASE WHEN ${BLOCKED_SQL} THEN 1 ELSE 0 END) AS blocked,
|
|
300
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpen,
|
|
301
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpen,
|
|
302
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'fix_blockers' THEN 1 ELSE 0 END) AS nextFixBlockers,
|
|
303
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_sql' THEN 1 ELSE 0 END) AS nextReviewSql,
|
|
304
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'review_context' THEN 1 ELSE 0 END) AS nextReviewContext,
|
|
305
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'run_preview' THEN 1 ELSE 0 END) AS nextRunPreview,
|
|
306
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'reuse_existing' THEN 1 ELSE 0 END) AS nextReuseExisting,
|
|
307
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'create_dql_draft' THEN 1 ELSE 0 END) AS nextCreateDqlDraft,
|
|
308
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'open_certification' THEN 1 ELSE 0 END) AS nextOpenCertification,
|
|
309
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'complete_review' THEN 1 ELSE 0 END) AS nextCompleteReview,
|
|
310
|
+
SUM(CASE WHEN (${NEXT_ACTION_SQL}) = 'continue_review' THEN 1 ELSE 0 END) AS nextContinueReview,
|
|
311
|
+
MIN(${NEXT_ACTION_PRIORITY_SQL}) AS nextActionPriority
|
|
312
|
+
FROM notebook_research_runs
|
|
313
|
+
${notebookWhereSql}
|
|
314
|
+
GROUP BY notebook_path
|
|
315
|
+
ORDER BY nextActionPriority ASC, blocked DESC, certificationReady DESC, draftReady DESC, total DESC, path ASC
|
|
316
|
+
LIMIT ${RESEARCH_NOTEBOOK_GROUP_LIMIT}
|
|
317
|
+
`).all(staleCutoff, expiredCutoff, ...notebookParams);
|
|
318
|
+
const orderSql = normalized.sort === 'priority'
|
|
319
|
+
? `ORDER BY
|
|
320
|
+
${NEXT_ACTION_PRIORITY_SQL} ASC,
|
|
321
|
+
updated_at DESC`
|
|
322
|
+
: 'ORDER BY updated_at DESC';
|
|
323
|
+
const pageSql = normalized.limit === undefined ? '' : 'LIMIT ? OFFSET ?';
|
|
324
|
+
const pageParams = normalized.limit === undefined ? [] : [normalized.limit, normalized.offset];
|
|
325
|
+
const rows = this.db.prepare(`
|
|
326
|
+
SELECT * FROM notebook_research_runs
|
|
327
|
+
${whereSql}
|
|
328
|
+
${orderSql}
|
|
329
|
+
${pageSql}
|
|
330
|
+
`).all(...params, ...pageParams);
|
|
331
|
+
return {
|
|
332
|
+
runs: rows.map(rowToRun),
|
|
333
|
+
total,
|
|
334
|
+
domains: domains.map((row) => {
|
|
335
|
+
const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
|
|
336
|
+
return {
|
|
337
|
+
domain: row.domain ?? 'uncategorized',
|
|
338
|
+
total: row.total ?? 0,
|
|
339
|
+
draftReady: row.draftReady ?? 0,
|
|
340
|
+
certificationReady: row.certificationReady ?? 0,
|
|
341
|
+
blocked: row.blocked ?? 0,
|
|
342
|
+
staleOpen: row.staleOpen ?? 0,
|
|
343
|
+
expiredOpen: row.expiredOpen ?? 0,
|
|
344
|
+
...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
|
|
345
|
+
};
|
|
346
|
+
}),
|
|
347
|
+
owners: owners.map((row) => {
|
|
348
|
+
const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
|
|
349
|
+
return {
|
|
350
|
+
owner: row.owner ?? 'unassigned',
|
|
351
|
+
total: row.total ?? 0,
|
|
352
|
+
draftReady: row.draftReady ?? 0,
|
|
353
|
+
certificationReady: row.certificationReady ?? 0,
|
|
354
|
+
blocked: row.blocked ?? 0,
|
|
355
|
+
staleOpen: row.staleOpen ?? 0,
|
|
356
|
+
expiredOpen: row.expiredOpen ?? 0,
|
|
357
|
+
...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
|
|
358
|
+
};
|
|
359
|
+
}),
|
|
360
|
+
intents: intents.map((row) => {
|
|
361
|
+
const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
|
|
362
|
+
return {
|
|
363
|
+
intent: parseIntent(row.intent),
|
|
364
|
+
total: row.total ?? 0,
|
|
365
|
+
draftReady: row.draftReady ?? 0,
|
|
366
|
+
certificationReady: row.certificationReady ?? 0,
|
|
367
|
+
blocked: row.blocked ?? 0,
|
|
368
|
+
staleOpen: row.staleOpen ?? 0,
|
|
369
|
+
expiredOpen: row.expiredOpen ?? 0,
|
|
370
|
+
...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
|
|
371
|
+
};
|
|
372
|
+
}),
|
|
373
|
+
notebooks: notebooks.map((row) => {
|
|
374
|
+
const path = row.path ?? 'notebooks/untitled.dqlnb';
|
|
375
|
+
const nextAction = notebookSummaryNextAction(actionCountsFromRow(row));
|
|
376
|
+
return {
|
|
377
|
+
path,
|
|
378
|
+
title: notebookTitleFromPath(path),
|
|
379
|
+
total: row.total ?? 0,
|
|
380
|
+
draftReady: row.draftReady ?? 0,
|
|
381
|
+
certificationReady: row.certificationReady ?? 0,
|
|
382
|
+
blocked: row.blocked ?? 0,
|
|
383
|
+
staleOpen: row.staleOpen ?? 0,
|
|
384
|
+
expiredOpen: row.expiredOpen ?? 0,
|
|
385
|
+
...(nextAction ? { nextAction: nextAction.action, nextActionCount: nextAction.count } : {}),
|
|
386
|
+
};
|
|
387
|
+
}),
|
|
388
|
+
counts: {
|
|
389
|
+
total: counts?.total ?? 0,
|
|
390
|
+
ready: counts?.ready ?? 0,
|
|
391
|
+
needsReview: counts?.needsReview ?? 0,
|
|
392
|
+
dqlDrafts: counts?.dqlDrafts ?? 0,
|
|
393
|
+
errors: counts?.errors ?? 0,
|
|
394
|
+
reuseExisting: counts?.reuseExisting ?? 0,
|
|
395
|
+
extendExisting: counts?.extendExisting ?? 0,
|
|
396
|
+
replacements: counts?.replacements ?? 0,
|
|
397
|
+
createNew: counts?.createNew ?? 0,
|
|
398
|
+
draftReady: counts?.draftReady ?? 0,
|
|
399
|
+
certificationReady: counts?.certificationReady ?? 0,
|
|
400
|
+
blocked: counts?.blocked ?? 0,
|
|
401
|
+
staleOpen: counts?.staleOpen ?? 0,
|
|
402
|
+
expiredOpen: counts?.expiredOpen ?? 0,
|
|
403
|
+
sourceLinked: counts?.sourceLinked ?? 0,
|
|
404
|
+
nextActions: {
|
|
405
|
+
fix_blockers: counts?.nextFixBlockers ?? 0,
|
|
406
|
+
review_sql: counts?.nextReviewSql ?? 0,
|
|
407
|
+
review_context: counts?.nextReviewContext ?? 0,
|
|
408
|
+
run_preview: counts?.nextRunPreview ?? 0,
|
|
409
|
+
reuse_existing: counts?.nextReuseExisting ?? 0,
|
|
410
|
+
create_dql_draft: counts?.nextCreateDqlDraft ?? 0,
|
|
411
|
+
open_certification: counts?.nextOpenCertification ?? 0,
|
|
412
|
+
complete_review: counts?.nextCompleteReview ?? 0,
|
|
413
|
+
continue_review: counts?.nextContinueReview ?? 0,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
groupCounts: {
|
|
417
|
+
domains: groupCounts?.domains ?? domains.length,
|
|
418
|
+
owners: groupCounts?.owners ?? owners.length,
|
|
419
|
+
intents: groupCounts?.intents ?? intents.length,
|
|
420
|
+
notebooks: groupCounts?.notebooks ?? notebooks.length,
|
|
421
|
+
},
|
|
422
|
+
limit: normalized.limit,
|
|
423
|
+
offset: normalized.offset,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
listLatestRunsBySourceCell(query) {
|
|
427
|
+
const notebookPath = cleanOptionalString(query.notebookPath);
|
|
428
|
+
if (!notebookPath)
|
|
429
|
+
return [];
|
|
430
|
+
const limit = typeof query.limit === 'number' && Number.isFinite(query.limit)
|
|
431
|
+
? Math.max(1, Math.min(10_000, Math.floor(query.limit)))
|
|
432
|
+
: 10_000;
|
|
433
|
+
const sourceCells = normalizeSourceCoverageCells(query, limit);
|
|
434
|
+
const sourceCellIds = sourceCells.map((cell) => cell.id);
|
|
435
|
+
if (sourceCellIds.length === 0)
|
|
436
|
+
return [];
|
|
437
|
+
const bySourceCell = new Map();
|
|
438
|
+
const chunkSize = 400;
|
|
439
|
+
for (let index = 0; index < sourceCellIds.length; index += chunkSize) {
|
|
440
|
+
const chunk = sourceCellIds.slice(index, index + chunkSize);
|
|
441
|
+
const placeholders = chunk.map(() => '?').join(', ');
|
|
442
|
+
const rows = this.db.prepare(`
|
|
443
|
+
SELECT *
|
|
444
|
+
FROM notebook_research_runs
|
|
445
|
+
WHERE notebook_path = ?
|
|
446
|
+
AND source_cell_id IN (${placeholders})
|
|
447
|
+
ORDER BY source_cell_id ASC, updated_at DESC, rowid DESC
|
|
448
|
+
`).all(notebookPath, ...chunk);
|
|
449
|
+
for (const row of rows) {
|
|
450
|
+
const sourceCellId = optionalString(row.source_cell_id);
|
|
451
|
+
if (!sourceCellId || bySourceCell.has(sourceCellId))
|
|
452
|
+
continue;
|
|
453
|
+
bySourceCell.set(sourceCellId, rowToRun(row));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const unmatchedByFingerprint = new Map();
|
|
457
|
+
for (const sourceCell of sourceCells) {
|
|
458
|
+
if (bySourceCell.has(sourceCell.id) || !sourceCell.fingerprint)
|
|
459
|
+
continue;
|
|
460
|
+
const list = unmatchedByFingerprint.get(sourceCell.fingerprint) ?? [];
|
|
461
|
+
list.push(sourceCell);
|
|
462
|
+
unmatchedByFingerprint.set(sourceCell.fingerprint, list);
|
|
463
|
+
}
|
|
464
|
+
const fingerprints = Array.from(unmatchedByFingerprint.keys());
|
|
465
|
+
for (let index = 0; index < fingerprints.length; index += chunkSize) {
|
|
466
|
+
const chunk = fingerprints.slice(index, index + chunkSize);
|
|
467
|
+
const placeholders = chunk.map(() => '?').join(', ');
|
|
468
|
+
const rows = this.db.prepare(`
|
|
469
|
+
SELECT *
|
|
470
|
+
FROM notebook_research_runs
|
|
471
|
+
WHERE notebook_path = ?
|
|
472
|
+
AND source_cell_fingerprint IN (${placeholders})
|
|
473
|
+
AND (source_cell_id IS NULL OR TRIM(source_cell_id) = '')
|
|
474
|
+
ORDER BY source_cell_fingerprint ASC, updated_at DESC, rowid DESC
|
|
475
|
+
`).all(notebookPath, ...chunk);
|
|
476
|
+
const byFingerprint = new Set();
|
|
477
|
+
for (const row of rows) {
|
|
478
|
+
const fingerprint = optionalString(row.source_cell_fingerprint);
|
|
479
|
+
if (!fingerprint || byFingerprint.has(fingerprint))
|
|
480
|
+
continue;
|
|
481
|
+
byFingerprint.add(fingerprint);
|
|
482
|
+
const sourceCellMatches = unmatchedByFingerprint.get(fingerprint) ?? [];
|
|
483
|
+
const run = rowToRun(row);
|
|
484
|
+
for (const sourceCell of sourceCellMatches) {
|
|
485
|
+
if (bySourceCell.has(sourceCell.id))
|
|
486
|
+
continue;
|
|
487
|
+
bySourceCell.set(sourceCell.id, {
|
|
488
|
+
...run,
|
|
489
|
+
sourceCellId: sourceCell.id,
|
|
490
|
+
sourceCellName: run.sourceCellName ?? sourceCell.name,
|
|
491
|
+
sourceCellFingerprint: run.sourceCellFingerprint ?? sourceCell.fingerprint,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return sourceCellIds
|
|
497
|
+
.map((sourceCellId) => bySourceCell.get(sourceCellId))
|
|
498
|
+
.filter((run) => Boolean(run));
|
|
499
|
+
}
|
|
500
|
+
listLatestRunsForMissingSourceCells(query) {
|
|
501
|
+
const notebookPath = cleanOptionalString(query.notebookPath);
|
|
502
|
+
if (!notebookPath)
|
|
503
|
+
return [];
|
|
504
|
+
const limit = typeof query.limit === 'number' && Number.isFinite(query.limit)
|
|
505
|
+
? Math.max(1, Math.min(10_000, Math.floor(query.limit)))
|
|
506
|
+
: 10_000;
|
|
507
|
+
const currentSourceIds = new Set(normalizeSourceCoverageCells(query, Number.POSITIVE_INFINITY).map((cell) => cell.id));
|
|
508
|
+
const rows = this.db.prepare(`
|
|
509
|
+
SELECT *
|
|
510
|
+
FROM notebook_research_runs
|
|
511
|
+
WHERE notebook_path = ?
|
|
512
|
+
AND source_cell_id IS NOT NULL
|
|
513
|
+
AND TRIM(source_cell_id) <> ''
|
|
514
|
+
ORDER BY source_cell_id ASC, updated_at DESC, rowid DESC
|
|
515
|
+
`).all(notebookPath);
|
|
516
|
+
const byMissingSource = new Map();
|
|
517
|
+
for (const row of rows) {
|
|
518
|
+
const sourceCellId = optionalString(row.source_cell_id);
|
|
519
|
+
if (!sourceCellId || currentSourceIds.has(sourceCellId) || byMissingSource.has(sourceCellId))
|
|
520
|
+
continue;
|
|
521
|
+
byMissingSource.set(sourceCellId, rowToRun(row));
|
|
522
|
+
if (byMissingSource.size >= limit)
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
return Array.from(byMissingSource.values());
|
|
526
|
+
}
|
|
527
|
+
getRun(id) {
|
|
528
|
+
const row = this.db.prepare('SELECT * FROM notebook_research_runs WHERE id = ?').get(id);
|
|
529
|
+
return row ? rowToRun(row) : null;
|
|
530
|
+
}
|
|
531
|
+
updateRun(id, input) {
|
|
532
|
+
const current = this.getRun(id);
|
|
533
|
+
if (!current)
|
|
534
|
+
return null;
|
|
535
|
+
const now = new Date().toISOString();
|
|
536
|
+
const nextPromotion = input.dqlPromotion === undefined ? current.dqlPromotion : input.dqlPromotion;
|
|
537
|
+
const nextContext = input.context === undefined ? current.context : input.context;
|
|
538
|
+
const nextDraftBlockPath = input.draftBlockPath === undefined ? current.draftBlockPath : cleanOptionalString(input.draftBlockPath);
|
|
539
|
+
const nextDomain = cleanOptionalString(input.domain)
|
|
540
|
+
?? inferResearchDomain({
|
|
541
|
+
context: nextContext,
|
|
542
|
+
dqlPromotion: nextPromotion,
|
|
543
|
+
draftBlockPath: nextDraftBlockPath,
|
|
544
|
+
notebookPath: current.notebookPath,
|
|
545
|
+
})
|
|
546
|
+
?? current.domain;
|
|
547
|
+
const nextPromotionAction = input.dqlPromotionAction
|
|
548
|
+
?? normalizePromotionAction(nextPromotion?.recommendedAction)
|
|
549
|
+
?? current.dqlPromotionAction
|
|
550
|
+
?? normalizePromotionAction(current.dqlPromotion?.recommendedAction);
|
|
551
|
+
this.db.prepare(`
|
|
552
|
+
UPDATE notebook_research_runs
|
|
553
|
+
SET domain = ?, owner = ?, source_cell_id = ?, source_cell_name = ?, source_cell_fingerprint = ?, title = ?, question = ?,
|
|
554
|
+
intent = ?, context = ?, status = ?, summary = ?, recommendation = ?,
|
|
555
|
+
result_preview = ?, evidence = ?, research_plan = ?, generated_sql = ?, reviewed_sql = ?,
|
|
556
|
+
display = ?, context_pack_id = ?, route_decision = ?, warnings = ?,
|
|
557
|
+
review_status = ?, error = ?, draft_block_path = ?, dql_import_id = ?,
|
|
558
|
+
dql_candidate_ids = ?, dql_promotion_action = ?, dql_promotion = ?, updated_at = ?, last_run_at = ?
|
|
559
|
+
WHERE id = ?
|
|
560
|
+
`).run(nextDomain ?? null, input.owner === undefined ? (current.owner ?? null) : (cleanOptionalString(input.owner) ?? null), input.sourceCellId === undefined ? (current.sourceCellId ?? null) : (cleanOptionalString(input.sourceCellId) ?? null), input.sourceCellName === undefined ? (current.sourceCellName ?? null) : (cleanOptionalString(input.sourceCellName) ?? null), input.sourceCellFingerprint === undefined ? (current.sourceCellFingerprint ?? null) : (cleanOptionalString(input.sourceCellFingerprint) ?? null), cleanOptionalString(input.title) ?? current.title, cleanOptionalString(input.question) ?? current.question, input.intent ?? current.intent, json(nextContext), input.status ?? current.status, input.summary === undefined ? (current.summary ?? null) : (cleanOptionalString(input.summary) ?? null), input.recommendation === undefined ? (current.recommendation ?? null) : (cleanOptionalString(input.recommendation) ?? null), input.resultPreview === undefined ? json(current.resultPreview) : json(input.resultPreview), input.evidence === undefined ? json(current.evidence) : json(input.evidence), input.researchPlan === undefined ? json(current.researchPlan) : json(input.researchPlan), input.generatedSql === undefined ? (current.generatedSql ?? null) : (cleanOptionalString(input.generatedSql) ?? null), input.reviewedSql === undefined ? (current.reviewedSql ?? null) : (cleanOptionalString(input.reviewedSql) ?? null), input.display === undefined ? json(current.display) : json(input.display), input.contextPackId === undefined ? (current.contextPackId ?? null) : (cleanOptionalString(input.contextPackId) ?? null), input.routeDecision === undefined ? json(current.routeDecision) : json(input.routeDecision), input.warnings === undefined ? json(current.warnings) : json(input.warnings), input.reviewStatus ?? current.reviewStatus, input.error === undefined ? (current.error ?? null) : (cleanOptionalString(input.error) ?? null), nextDraftBlockPath ?? null, input.dqlImportId === undefined ? (current.dqlImportId ?? null) : (cleanOptionalString(input.dqlImportId) ?? null), input.dqlCandidateIds === undefined ? json(current.dqlCandidateIds) : json(input.dqlCandidateIds), nextPromotionAction ?? null, json(nextPromotion), now, input.lastRunAt === undefined ? (current.lastRunAt ?? null) : (cleanOptionalString(input.lastRunAt) ?? null), id);
|
|
561
|
+
const updated = this.getRun(id);
|
|
562
|
+
if (updated)
|
|
563
|
+
this.upsertSearchIndex(updated);
|
|
564
|
+
return updated;
|
|
565
|
+
}
|
|
566
|
+
markPromoted(id, input) {
|
|
567
|
+
return this.updateRun(id, {
|
|
568
|
+
reviewStatus: 'draft_created',
|
|
569
|
+
draftBlockPath: input.draftBlockPath,
|
|
570
|
+
dqlImportId: input.dqlImportId,
|
|
571
|
+
dqlCandidateIds: input.dqlCandidateIds,
|
|
572
|
+
dqlPromotionAction: normalizePromotionAction(input.dqlPromotion?.recommendedAction),
|
|
573
|
+
dqlPromotion: input.dqlPromotion,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
seedRunsFromCells(input) {
|
|
577
|
+
const notebookPath = cleanOptionalString(input.notebookPath);
|
|
578
|
+
if (!notebookPath)
|
|
579
|
+
return { created: [], createdCount: 0, skippedCount: input.cells.length, limitApplied: false };
|
|
580
|
+
const limit = typeof input.limit === 'number' && Number.isFinite(input.limit)
|
|
581
|
+
? Math.max(1, Math.min(1000, Math.floor(input.limit)))
|
|
582
|
+
: 1000;
|
|
583
|
+
const cells = Array.isArray(input.cells) ? input.cells : [];
|
|
584
|
+
const existingSourceIds = new Set(this.db.prepare(`
|
|
585
|
+
SELECT source_cell_id AS sourceCellId
|
|
586
|
+
FROM notebook_research_runs
|
|
587
|
+
WHERE notebook_path = ? AND source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> ''
|
|
588
|
+
`).all(notebookPath)
|
|
589
|
+
.map((row) => cleanOptionalString(row.sourceCellId))
|
|
590
|
+
.filter((id) => Boolean(id)));
|
|
591
|
+
const existingSourceFingerprints = new Set(this.db.prepare(`
|
|
592
|
+
SELECT source_cell_fingerprint AS sourceCellFingerprint
|
|
593
|
+
FROM notebook_research_runs
|
|
594
|
+
WHERE notebook_path = ? AND source_cell_fingerprint IS NOT NULL AND TRIM(source_cell_fingerprint) <> ''
|
|
595
|
+
`).all(notebookPath)
|
|
596
|
+
.map((row) => cleanOptionalString(row.sourceCellFingerprint))
|
|
597
|
+
.filter((fingerprint) => Boolean(fingerprint)));
|
|
598
|
+
const created = [];
|
|
599
|
+
let skippedCount = Math.max(0, cells.length - limit);
|
|
600
|
+
for (const cell of cells.slice(0, limit)) {
|
|
601
|
+
const sourceCell = normalizeSourceCellInput(cell.sourceCell);
|
|
602
|
+
const sourceCellId = cleanOptionalString(cell.id) ?? cleanOptionalString(cell.sourceCellId) ?? sourceCell?.id;
|
|
603
|
+
const sql = cleanOptionalString(cell.sql) ?? cleanOptionalString(cell.content) ?? cleanOptionalString(cell.source) ?? sourceCell?.sql ?? sourceCell?.content ?? sourceCell?.source;
|
|
604
|
+
const sourceCellFingerprint = cleanOptionalString(cell.sourceCellFingerprint) ?? sourceCell?.fingerprint ?? fingerprintSql(sql);
|
|
605
|
+
if (!sourceCellId
|
|
606
|
+
|| !sql
|
|
607
|
+
|| existingSourceIds.has(sourceCellId)
|
|
608
|
+
|| Boolean(sourceCellFingerprint && existingSourceFingerprints.has(sourceCellFingerprint))) {
|
|
609
|
+
skippedCount += 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const sourceCellName = cleanOptionalString(cell.name) ?? cleanOptionalString(cell.sourceCellName) ?? cleanOptionalString(cell.title) ?? sourceCell?.name ?? sourceCellId;
|
|
613
|
+
const question = cleanOptionalString(cell.question) ?? seedQuestionForCell(sourceCellName);
|
|
614
|
+
const run = this.createRun({
|
|
615
|
+
notebookPath,
|
|
616
|
+
domain: cleanOptionalString(input.domain),
|
|
617
|
+
owner: cleanOptionalString(input.owner),
|
|
618
|
+
sourceCellId,
|
|
619
|
+
sourceCellName,
|
|
620
|
+
title: cleanOptionalString(cell.title) ?? sourceCellName,
|
|
621
|
+
question,
|
|
622
|
+
intent: parseOptionalIntent(cell.intent),
|
|
623
|
+
context: {
|
|
624
|
+
notebookTitle: cleanOptionalString(input.notebookTitle),
|
|
625
|
+
sourceCell: {
|
|
626
|
+
id: sourceCellId,
|
|
627
|
+
name: sourceCellName,
|
|
628
|
+
type: cleanOptionalString(cell.type) ?? sourceCell?.type,
|
|
629
|
+
},
|
|
630
|
+
selectedDomain: cleanOptionalString(input.domain),
|
|
631
|
+
selectedOwner: cleanOptionalString(input.owner),
|
|
632
|
+
seededFromNotebook: true,
|
|
633
|
+
},
|
|
634
|
+
sourceCellFingerprint,
|
|
635
|
+
generatedSql: sql,
|
|
636
|
+
reviewedSql: sql,
|
|
637
|
+
});
|
|
638
|
+
existingSourceIds.add(sourceCellId);
|
|
639
|
+
if (sourceCellFingerprint)
|
|
640
|
+
existingSourceFingerprints.add(sourceCellFingerprint);
|
|
641
|
+
created.push(run);
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
created,
|
|
645
|
+
createdCount: created.length,
|
|
646
|
+
skippedCount,
|
|
647
|
+
limitApplied: cells.length > limit,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
getDiagnostics() {
|
|
651
|
+
const staleThresholdDays = 7;
|
|
652
|
+
const expiredThresholdDays = 30;
|
|
653
|
+
const staleCutoff = new Date(Date.now() - staleThresholdDays * 24 * 60 * 60 * 1000).toISOString();
|
|
654
|
+
const expiredCutoff = new Date(Date.now() - expiredThresholdDays * 24 * 60 * 60 * 1000).toISOString();
|
|
655
|
+
const counts = this.db.prepare(`
|
|
656
|
+
SELECT
|
|
657
|
+
COUNT(*) AS totalRuns,
|
|
658
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN 1 ELSE 0 END) AS activeRuns,
|
|
659
|
+
SUM(CASE WHEN review_status IN ('completed', 'certified', 'rejected') THEN 1 ELSE 0 END) AS closedRuns,
|
|
660
|
+
COUNT(DISTINCT notebook_path) AS notebooks,
|
|
661
|
+
COUNT(DISTINCT COALESCE(NULLIF(TRIM(domain), ''), 'uncategorized')) AS domains,
|
|
662
|
+
COUNT(DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'unassigned')) AS owners,
|
|
663
|
+
COUNT(CASE WHEN source_cell_id IS NOT NULL AND TRIM(source_cell_id) <> '' THEN 1 END) AS sourceLinkedRuns,
|
|
664
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS staleOpenRuns,
|
|
665
|
+
SUM(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') AND updated_at <= ? THEN 1 ELSE 0 END) AS expiredOpenRuns,
|
|
666
|
+
MIN(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN updated_at END) AS oldestOpenUpdatedAt,
|
|
667
|
+
MAX(CASE WHEN review_status NOT IN ('completed', 'certified', 'rejected') THEN updated_at END) AS newestOpenUpdatedAt,
|
|
668
|
+
MIN(updated_at) AS oldestUpdatedAt,
|
|
669
|
+
MAX(updated_at) AS newestUpdatedAt
|
|
670
|
+
FROM notebook_research_runs
|
|
671
|
+
`).get(staleCutoff, expiredCutoff);
|
|
672
|
+
const totalRuns = counts?.totalRuns ?? 0;
|
|
673
|
+
const indexRows = this.searchIndexAvailable
|
|
674
|
+
? this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs_fts')
|
|
675
|
+
: 0;
|
|
676
|
+
const indexVersion = this.searchIndexAvailable
|
|
677
|
+
? this.getMeta('notebook_research_search_index_version')
|
|
678
|
+
: undefined;
|
|
679
|
+
const stale = this.searchIndexAvailable
|
|
680
|
+
? indexRows !== totalRuns || indexVersion !== SEARCH_INDEX_VERSION
|
|
681
|
+
: false;
|
|
682
|
+
const warnings = [];
|
|
683
|
+
if (!this.searchIndexAvailable) {
|
|
684
|
+
warnings.push('Full-text search index is unavailable; research search falls back to local text scanning.');
|
|
685
|
+
}
|
|
686
|
+
else if (stale) {
|
|
687
|
+
warnings.push('Research search index is stale and will rebuild on the next storage initialization.');
|
|
688
|
+
}
|
|
689
|
+
if (totalRuns > 10_000) {
|
|
690
|
+
warnings.push('Research backlog is above 10,000 runs; use project, domain, next-action, and search filters before reviewing.');
|
|
691
|
+
}
|
|
692
|
+
if ((counts?.activeRuns ?? 0) > 500) {
|
|
693
|
+
warnings.push('Open research backlog is large; use Open next, next-action filters, and register snapshots for review handoff.');
|
|
694
|
+
}
|
|
695
|
+
if ((counts?.expiredOpenRuns ?? 0) > 0) {
|
|
696
|
+
warnings.push(`${counts?.expiredOpenRuns ?? 0} open research run(s) have not changed in ${expiredThresholdDays}+ days; revalidate or close stale investigations.`);
|
|
697
|
+
}
|
|
698
|
+
else if ((counts?.staleOpenRuns ?? 0) > 0) {
|
|
699
|
+
warnings.push(`${counts?.staleOpenRuns ?? 0} open research run(s) have not changed in ${staleThresholdDays}+ days; confirm they are still relevant.`);
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
counts: {
|
|
703
|
+
totalRuns,
|
|
704
|
+
activeRuns: counts?.activeRuns ?? 0,
|
|
705
|
+
closedRuns: counts?.closedRuns ?? 0,
|
|
706
|
+
notebooks: counts?.notebooks ?? 0,
|
|
707
|
+
domains: counts?.domains ?? 0,
|
|
708
|
+
owners: counts?.owners ?? 0,
|
|
709
|
+
sourceLinkedRuns: counts?.sourceLinkedRuns ?? 0,
|
|
710
|
+
},
|
|
711
|
+
health: {
|
|
712
|
+
staleOpenRuns: counts?.staleOpenRuns ?? 0,
|
|
713
|
+
expiredOpenRuns: counts?.expiredOpenRuns ?? 0,
|
|
714
|
+
staleThresholdDays,
|
|
715
|
+
expiredThresholdDays,
|
|
716
|
+
oldestOpenUpdatedAt: optionalString(counts?.oldestOpenUpdatedAt),
|
|
717
|
+
newestOpenUpdatedAt: optionalString(counts?.newestOpenUpdatedAt),
|
|
718
|
+
},
|
|
719
|
+
search: {
|
|
720
|
+
indexed: this.searchIndexAvailable,
|
|
721
|
+
indexRows,
|
|
722
|
+
indexVersion,
|
|
723
|
+
stale,
|
|
724
|
+
},
|
|
725
|
+
updatedAt: {
|
|
726
|
+
oldest: optionalString(counts?.oldestUpdatedAt),
|
|
727
|
+
newest: optionalString(counts?.newestUpdatedAt),
|
|
728
|
+
},
|
|
729
|
+
limits: {
|
|
730
|
+
pageSize: 25,
|
|
731
|
+
maxPageSize: 500,
|
|
732
|
+
sourceCoverageLimit: 10_000,
|
|
733
|
+
seedCellLimit: 1_000,
|
|
734
|
+
},
|
|
735
|
+
warnings,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
initSchema() {
|
|
739
|
+
this.db.exec(`
|
|
740
|
+
CREATE TABLE IF NOT EXISTS notebook_research_runs (
|
|
741
|
+
id TEXT PRIMARY KEY,
|
|
742
|
+
notebook_path TEXT NOT NULL,
|
|
743
|
+
domain TEXT,
|
|
744
|
+
owner TEXT,
|
|
745
|
+
source_cell_id TEXT,
|
|
746
|
+
source_cell_name TEXT,
|
|
747
|
+
source_cell_fingerprint TEXT,
|
|
748
|
+
title TEXT NOT NULL,
|
|
749
|
+
question TEXT NOT NULL,
|
|
750
|
+
intent TEXT NOT NULL,
|
|
751
|
+
context TEXT,
|
|
752
|
+
status TEXT NOT NULL,
|
|
753
|
+
summary TEXT,
|
|
754
|
+
recommendation TEXT,
|
|
755
|
+
result_preview TEXT,
|
|
756
|
+
evidence TEXT,
|
|
757
|
+
research_plan TEXT,
|
|
758
|
+
generated_sql TEXT,
|
|
759
|
+
reviewed_sql TEXT,
|
|
760
|
+
display TEXT,
|
|
761
|
+
context_pack_id TEXT,
|
|
762
|
+
route_decision TEXT,
|
|
763
|
+
warnings TEXT,
|
|
764
|
+
review_status TEXT NOT NULL,
|
|
765
|
+
error TEXT,
|
|
766
|
+
draft_block_path TEXT,
|
|
767
|
+
dql_import_id TEXT,
|
|
768
|
+
dql_candidate_ids TEXT,
|
|
769
|
+
dql_promotion_action TEXT,
|
|
770
|
+
dql_promotion TEXT,
|
|
771
|
+
created_at TEXT NOT NULL,
|
|
772
|
+
updated_at TEXT NOT NULL,
|
|
773
|
+
last_run_at TEXT
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_path ON notebook_research_runs(notebook_path, updated_at);
|
|
777
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_review ON notebook_research_runs(review_status);
|
|
778
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_status ON notebook_research_runs(status);
|
|
779
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_intent ON notebook_research_runs(intent, updated_at);
|
|
780
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_source_cell ON notebook_research_runs(notebook_path, source_cell_id, updated_at);
|
|
781
|
+
CREATE INDEX IF NOT EXISTS idx_notebook_research_source_fingerprint ON notebook_research_runs(notebook_path, source_cell_fingerprint, updated_at);
|
|
782
|
+
|
|
783
|
+
CREATE TABLE IF NOT EXISTS notebook_research_meta (
|
|
784
|
+
key TEXT PRIMARY KEY,
|
|
785
|
+
value TEXT NOT NULL
|
|
786
|
+
);
|
|
787
|
+
`);
|
|
788
|
+
this.ensureColumn('notebook_research_runs', 'dql_promotion_action', 'TEXT');
|
|
789
|
+
this.ensureColumn('notebook_research_runs', 'dql_promotion', 'TEXT');
|
|
790
|
+
this.ensureColumn('notebook_research_runs', 'domain', 'TEXT');
|
|
791
|
+
this.ensureColumn('notebook_research_runs', 'owner', 'TEXT');
|
|
792
|
+
this.ensureColumn('notebook_research_runs', 'source_cell_fingerprint', 'TEXT');
|
|
793
|
+
this.ensureColumn('notebook_research_runs', 'research_plan', 'TEXT');
|
|
794
|
+
this.backfillDomains();
|
|
795
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_promotion_action ON notebook_research_runs(dql_promotion_action)');
|
|
796
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_domain ON notebook_research_runs(domain, updated_at)');
|
|
797
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_notebook_research_source_fingerprint ON notebook_research_runs(notebook_path, source_cell_fingerprint, updated_at)');
|
|
798
|
+
this.initSearchIndex();
|
|
799
|
+
}
|
|
800
|
+
ensureColumn(table, column, definition) {
|
|
801
|
+
const rows = this.db.prepare(`PRAGMA table_info(${table})`).all();
|
|
802
|
+
if (rows.some((row) => row.name === column))
|
|
803
|
+
return;
|
|
804
|
+
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
805
|
+
}
|
|
806
|
+
backfillDomains() {
|
|
807
|
+
const rows = this.db.prepare(`
|
|
808
|
+
SELECT id, notebook_path, context, draft_block_path, dql_promotion
|
|
809
|
+
FROM notebook_research_runs
|
|
810
|
+
WHERE domain IS NULL OR TRIM(domain) = ''
|
|
811
|
+
`).all();
|
|
812
|
+
const update = this.db.prepare('UPDATE notebook_research_runs SET domain = ? WHERE id = ?');
|
|
813
|
+
for (const row of rows) {
|
|
814
|
+
const domain = inferResearchDomain({
|
|
815
|
+
context: parseJson(row.context),
|
|
816
|
+
dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
|
|
817
|
+
draftBlockPath: optionalString(row.draft_block_path),
|
|
818
|
+
notebookPath: optionalString(row.notebook_path),
|
|
819
|
+
});
|
|
820
|
+
if (domain)
|
|
821
|
+
update.run(domain, row.id);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
initSearchIndex() {
|
|
825
|
+
try {
|
|
826
|
+
this.dropSearchIndexIfMissingColumn('owner');
|
|
827
|
+
this.db.exec(`
|
|
828
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notebook_research_runs_fts USING fts5(
|
|
829
|
+
id UNINDEXED,
|
|
830
|
+
notebook_path,
|
|
831
|
+
domain,
|
|
832
|
+
owner,
|
|
833
|
+
source_cell_name,
|
|
834
|
+
title,
|
|
835
|
+
question,
|
|
836
|
+
intent,
|
|
837
|
+
summary,
|
|
838
|
+
recommendation,
|
|
839
|
+
warnings,
|
|
840
|
+
draft_block_path,
|
|
841
|
+
dql_promotion_action,
|
|
842
|
+
generated_sql,
|
|
843
|
+
reviewed_sql,
|
|
844
|
+
context_text,
|
|
845
|
+
evidence_text,
|
|
846
|
+
research_plan_text,
|
|
847
|
+
promotion_text,
|
|
848
|
+
tokenize = 'unicode61'
|
|
849
|
+
);
|
|
850
|
+
`);
|
|
851
|
+
this.searchIndexAvailable = true;
|
|
852
|
+
this.ensureSearchIndexFresh();
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
this.searchIndexAvailable = false;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
ensureSearchIndexFresh() {
|
|
859
|
+
if (!this.searchIndexAvailable)
|
|
860
|
+
return;
|
|
861
|
+
const version = this.getMeta('notebook_research_search_index_version');
|
|
862
|
+
const runCount = this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs');
|
|
863
|
+
const indexCount = this.scalarCount('SELECT COUNT(*) AS count FROM notebook_research_runs_fts');
|
|
864
|
+
if (version === SEARCH_INDEX_VERSION && runCount === indexCount)
|
|
865
|
+
return;
|
|
866
|
+
this.rebuildSearchIndex();
|
|
867
|
+
}
|
|
868
|
+
rebuildSearchIndex() {
|
|
869
|
+
if (!this.searchIndexAvailable)
|
|
870
|
+
return;
|
|
871
|
+
this.db.prepare('DELETE FROM notebook_research_runs_fts').run();
|
|
872
|
+
const rows = this.db.prepare('SELECT * FROM notebook_research_runs ORDER BY rowid ASC').all();
|
|
873
|
+
const insert = this.searchIndexInsertStatement();
|
|
874
|
+
const transaction = this.db.transaction((items) => {
|
|
875
|
+
for (const row of items) {
|
|
876
|
+
insert.run(...searchIndexValues(rowToRun(row)));
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
transaction(rows);
|
|
880
|
+
this.setMeta('notebook_research_search_index_version', SEARCH_INDEX_VERSION);
|
|
881
|
+
}
|
|
882
|
+
upsertSearchIndex(run) {
|
|
883
|
+
if (!this.searchIndexAvailable)
|
|
884
|
+
return;
|
|
885
|
+
try {
|
|
886
|
+
this.db.prepare('DELETE FROM notebook_research_runs_fts WHERE id = ?').run(run.id);
|
|
887
|
+
this.searchIndexInsertStatement().run(...searchIndexValues(run));
|
|
888
|
+
this.setMeta('notebook_research_search_index_version', SEARCH_INDEX_VERSION);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
this.searchIndexAvailable = false;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
searchIndexInsertStatement() {
|
|
895
|
+
return this.db.prepare(`
|
|
896
|
+
INSERT INTO notebook_research_runs_fts (
|
|
897
|
+
id, notebook_path, domain, owner, source_cell_name, title, question, intent, summary, recommendation,
|
|
898
|
+
warnings, draft_block_path, dql_promotion_action, generated_sql, reviewed_sql,
|
|
899
|
+
context_text, evidence_text, research_plan_text, promotion_text
|
|
900
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
901
|
+
`);
|
|
902
|
+
}
|
|
903
|
+
dropSearchIndexIfMissingColumn(column) {
|
|
904
|
+
const exists = this.db.prepare(`
|
|
905
|
+
SELECT name
|
|
906
|
+
FROM sqlite_master
|
|
907
|
+
WHERE type = 'table' AND name = 'notebook_research_runs_fts'
|
|
908
|
+
`).get();
|
|
909
|
+
if (!exists)
|
|
910
|
+
return;
|
|
911
|
+
const rows = this.db.prepare('PRAGMA table_info(notebook_research_runs_fts)').all();
|
|
912
|
+
if (rows.some((row) => row.name === column))
|
|
913
|
+
return;
|
|
914
|
+
this.db.prepare('DROP TABLE notebook_research_runs_fts').run();
|
|
915
|
+
}
|
|
916
|
+
scalarCount(sql) {
|
|
917
|
+
return this.db.prepare(sql).get()?.count ?? 0;
|
|
918
|
+
}
|
|
919
|
+
getMeta(key) {
|
|
920
|
+
return optionalString(this.db.prepare('SELECT value FROM notebook_research_meta WHERE key = ?').get(key)?.value);
|
|
921
|
+
}
|
|
922
|
+
setMeta(key, value) {
|
|
923
|
+
this.db.prepare(`
|
|
924
|
+
INSERT INTO notebook_research_meta (key, value)
|
|
925
|
+
VALUES (?, ?)
|
|
926
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
927
|
+
`).run(key, value);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function rowToRun(row) {
|
|
931
|
+
return {
|
|
932
|
+
id: String(row.id),
|
|
933
|
+
notebookPath: String(row.notebook_path),
|
|
934
|
+
domain: optionalString(row.domain) ?? inferResearchDomain({
|
|
935
|
+
context: parseJson(row.context),
|
|
936
|
+
dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
|
|
937
|
+
draftBlockPath: optionalString(row.draft_block_path),
|
|
938
|
+
notebookPath: optionalString(row.notebook_path),
|
|
939
|
+
}),
|
|
940
|
+
owner: optionalString(row.owner),
|
|
941
|
+
sourceCellId: optionalString(row.source_cell_id),
|
|
942
|
+
sourceCellName: optionalString(row.source_cell_name),
|
|
943
|
+
sourceCellFingerprint: optionalString(row.source_cell_fingerprint),
|
|
944
|
+
title: String(row.title),
|
|
945
|
+
question: String(row.question),
|
|
946
|
+
intent: parseIntent(row.intent),
|
|
947
|
+
context: parseJson(row.context),
|
|
948
|
+
status: parseStatus(row.status),
|
|
949
|
+
summary: optionalString(row.summary),
|
|
950
|
+
recommendation: optionalString(row.recommendation),
|
|
951
|
+
resultPreview: parseJson(row.result_preview),
|
|
952
|
+
evidence: parseJson(row.evidence),
|
|
953
|
+
researchPlan: parseResearchPlan(parseJson(row.research_plan)),
|
|
954
|
+
generatedSql: optionalString(row.generated_sql),
|
|
955
|
+
reviewedSql: optionalString(row.reviewed_sql),
|
|
956
|
+
display: parseJson(row.display),
|
|
957
|
+
contextPackId: optionalString(row.context_pack_id),
|
|
958
|
+
routeDecision: parseJson(row.route_decision),
|
|
959
|
+
warnings: stringArray(parseJson(row.warnings)),
|
|
960
|
+
reviewStatus: parseReviewStatus(row.review_status),
|
|
961
|
+
error: optionalString(row.error),
|
|
962
|
+
draftBlockPath: optionalString(row.draft_block_path),
|
|
963
|
+
dqlImportId: optionalString(row.dql_import_id),
|
|
964
|
+
dqlCandidateIds: stringArray(parseJson(row.dql_candidate_ids)),
|
|
965
|
+
dqlPromotionAction: normalizePromotionAction(row.dql_promotion_action),
|
|
966
|
+
dqlPromotion: parseDqlPromotion(parseJson(row.dql_promotion)),
|
|
967
|
+
createdAt: String(row.created_at),
|
|
968
|
+
updatedAt: String(row.updated_at),
|
|
969
|
+
lastRunAt: optionalString(row.last_run_at),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function inferIntent(question) {
|
|
973
|
+
const lower = question.toLowerCase();
|
|
974
|
+
if (/\b(trust|rely|certif|lineage|owner|caveat|gap)\b/.test(lower))
|
|
975
|
+
return 'trust_gap_review';
|
|
976
|
+
if (/\b(anomal|exception|outlier|spike|dip)\b/.test(lower))
|
|
977
|
+
return 'anomaly_investigation';
|
|
978
|
+
if (/\b(compare|versus| vs |segment|cohort)\b/.test(lower))
|
|
979
|
+
return 'segment_compare';
|
|
980
|
+
if (/\b(why|changed|change|drop|decline|increase|decrease|month|week|quarter)\b/.test(lower))
|
|
981
|
+
return 'diagnose_change';
|
|
982
|
+
if (/\b(driver|drove|break down|breakdown|contribute|top mover|movers)\b/.test(lower))
|
|
983
|
+
return 'driver_breakdown';
|
|
984
|
+
if (/\b(profile|detail|drill|customer|account|user|client|merchant|product|player|team)\b/.test(lower))
|
|
985
|
+
return 'entity_drilldown';
|
|
986
|
+
return 'ad_hoc_analysis';
|
|
987
|
+
}
|
|
988
|
+
function parseIntent(value) {
|
|
989
|
+
if (value === 'ad_hoc_analysis'
|
|
990
|
+
|| value === 'diagnose_change'
|
|
991
|
+
|| value === 'driver_breakdown'
|
|
992
|
+
|| value === 'segment_compare'
|
|
993
|
+
|| value === 'entity_drilldown'
|
|
994
|
+
|| value === 'anomaly_investigation'
|
|
995
|
+
|| value === 'trust_gap_review')
|
|
996
|
+
return value;
|
|
997
|
+
return 'ad_hoc_analysis';
|
|
998
|
+
}
|
|
999
|
+
function notebookTitleFromPath(path) {
|
|
1000
|
+
const file = path.split(/[\\/]/).pop() ?? path;
|
|
1001
|
+
return file
|
|
1002
|
+
.replace(/\.dqlnb$/i, '')
|
|
1003
|
+
.replace(/[-_]+/g, ' ')
|
|
1004
|
+
.replace(/\s+/g, ' ')
|
|
1005
|
+
.trim()
|
|
1006
|
+
|| 'Untitled notebook';
|
|
1007
|
+
}
|
|
1008
|
+
function parseOptionalIntent(value) {
|
|
1009
|
+
if (value === 'ad_hoc_analysis'
|
|
1010
|
+
|| value === 'diagnose_change'
|
|
1011
|
+
|| value === 'driver_breakdown'
|
|
1012
|
+
|| value === 'segment_compare'
|
|
1013
|
+
|| value === 'entity_drilldown'
|
|
1014
|
+
|| value === 'anomaly_investigation'
|
|
1015
|
+
|| value === 'trust_gap_review')
|
|
1016
|
+
return value;
|
|
1017
|
+
return undefined;
|
|
1018
|
+
}
|
|
1019
|
+
function parseStatus(value) {
|
|
1020
|
+
return value === 'running' || value === 'ready' || value === 'error' ? value : 'draft';
|
|
1021
|
+
}
|
|
1022
|
+
function parseReviewStatus(value) {
|
|
1023
|
+
return value === 'draft_created' || value === 'completed' || value === 'certified' || value === 'rejected' ? value : 'needs_review';
|
|
1024
|
+
}
|
|
1025
|
+
function normalizePromotionAction(value) {
|
|
1026
|
+
return value === 'reuse_existing'
|
|
1027
|
+
|| value === 'extend_existing'
|
|
1028
|
+
|| value === 'create_replacement'
|
|
1029
|
+
|| value === 'create_new'
|
|
1030
|
+
|| value === 'review_required'
|
|
1031
|
+
? value
|
|
1032
|
+
: undefined;
|
|
1033
|
+
}
|
|
1034
|
+
function normalizeReadinessFilter(value) {
|
|
1035
|
+
return value === 'draft_ready' || value === 'certification_ready' || value === 'blocked' ? value : undefined;
|
|
1036
|
+
}
|
|
1037
|
+
function normalizeAgeFilter(value) {
|
|
1038
|
+
return value === 'stale_open' || value === 'expired_open' ? value : undefined;
|
|
1039
|
+
}
|
|
1040
|
+
function normalizeNextActionFilter(value) {
|
|
1041
|
+
return value === 'fix_blockers'
|
|
1042
|
+
|| value === 'review_sql'
|
|
1043
|
+
|| value === 'review_context'
|
|
1044
|
+
|| value === 'run_preview'
|
|
1045
|
+
|| value === 'reuse_existing'
|
|
1046
|
+
|| value === 'create_dql_draft'
|
|
1047
|
+
|| value === 'open_certification'
|
|
1048
|
+
|| value === 'complete_review'
|
|
1049
|
+
|| value === 'continue_review'
|
|
1050
|
+
? value
|
|
1051
|
+
: undefined;
|
|
1052
|
+
}
|
|
1053
|
+
function normalizeSort(value) {
|
|
1054
|
+
return value === 'updated_desc' ? 'updated_desc' : 'priority';
|
|
1055
|
+
}
|
|
1056
|
+
function titleFromQuestion(question) {
|
|
1057
|
+
const clean = question.replace(/\s+/g, ' ').trim();
|
|
1058
|
+
return clean.length > 80 ? `${clean.slice(0, 77)}...` : clean || 'Notebook research';
|
|
1059
|
+
}
|
|
1060
|
+
function seedQuestionForCell(sourceCellName) {
|
|
1061
|
+
const cleanName = sourceCellName.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1062
|
+
return `What reusable business logic does ${cleanName || 'this query'} represent, and should it become a DQL block?`;
|
|
1063
|
+
}
|
|
1064
|
+
function normalizeSourceCellInput(value) {
|
|
1065
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1066
|
+
return null;
|
|
1067
|
+
const source = value;
|
|
1068
|
+
const id = cleanOptionalString(source.id)
|
|
1069
|
+
?? cleanOptionalString(source.sourceCellId)
|
|
1070
|
+
?? cleanOptionalString(source.cellId);
|
|
1071
|
+
const name = cleanOptionalString(source.name)
|
|
1072
|
+
?? cleanOptionalString(source.sourceCellName)
|
|
1073
|
+
?? cleanOptionalString(source.title);
|
|
1074
|
+
const fingerprint = cleanOptionalString(source.fingerprint)
|
|
1075
|
+
?? cleanOptionalString(source.sourceCellFingerprint)
|
|
1076
|
+
?? cleanOptionalString(source.sqlFingerprint);
|
|
1077
|
+
const type = cleanOptionalString(source.type);
|
|
1078
|
+
const sql = cleanOptionalString(source.sql);
|
|
1079
|
+
const content = cleanOptionalString(source.content);
|
|
1080
|
+
const sourceText = cleanOptionalString(source.source);
|
|
1081
|
+
if (!id && !name && !fingerprint && !type && !sql && !content && !sourceText)
|
|
1082
|
+
return null;
|
|
1083
|
+
return { id, name, fingerprint, type, sql, content, source: sourceText };
|
|
1084
|
+
}
|
|
1085
|
+
function normalizeSourceCoverageCells(query, limit) {
|
|
1086
|
+
const byId = new Map();
|
|
1087
|
+
for (const id of Array.isArray(query.sourceCellIds) ? query.sourceCellIds : []) {
|
|
1088
|
+
const cleanId = cleanOptionalString(id);
|
|
1089
|
+
if (cleanId && !byId.has(cleanId))
|
|
1090
|
+
byId.set(cleanId, { id: cleanId });
|
|
1091
|
+
}
|
|
1092
|
+
for (const rawCell of Array.isArray(query.sourceCells) ? query.sourceCells : []) {
|
|
1093
|
+
const sourceCell = normalizeSourceCellInput(rawCell);
|
|
1094
|
+
const id = sourceCell?.id;
|
|
1095
|
+
if (!id)
|
|
1096
|
+
continue;
|
|
1097
|
+
const current = byId.get(id) ?? { id };
|
|
1098
|
+
byId.set(id, {
|
|
1099
|
+
id,
|
|
1100
|
+
name: current.name ?? sourceCell.name,
|
|
1101
|
+
fingerprint: current.fingerprint ?? sourceCell.fingerprint,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
return Array.from(byId.values()).slice(0, limit);
|
|
1105
|
+
}
|
|
1106
|
+
function fingerprintSql(sql) {
|
|
1107
|
+
const normalized = sql?.replace(/\s+/g, ' ').trim();
|
|
1108
|
+
if (!normalized)
|
|
1109
|
+
return undefined;
|
|
1110
|
+
let hash = 2166136261;
|
|
1111
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
1112
|
+
hash ^= normalized.charCodeAt(index);
|
|
1113
|
+
hash = Math.imul(hash, 16777619);
|
|
1114
|
+
}
|
|
1115
|
+
return `fnv1a:${(hash >>> 0).toString(16).padStart(8, '0')}`;
|
|
1116
|
+
}
|
|
1117
|
+
function normalizeListQuery(query) {
|
|
1118
|
+
if (typeof query === 'string') {
|
|
1119
|
+
return { notebookPath: cleanOptionalString(query), offset: 0 };
|
|
1120
|
+
}
|
|
1121
|
+
const limit = typeof query?.limit === 'number' && Number.isFinite(query.limit)
|
|
1122
|
+
? Math.max(1, Math.min(500, Math.floor(query.limit)))
|
|
1123
|
+
: undefined;
|
|
1124
|
+
const offset = typeof query?.offset === 'number' && Number.isFinite(query.offset)
|
|
1125
|
+
? Math.max(0, Math.floor(query.offset))
|
|
1126
|
+
: 0;
|
|
1127
|
+
return {
|
|
1128
|
+
notebookPath: cleanOptionalString(query?.notebookPath),
|
|
1129
|
+
sourceCellId: cleanOptionalString(query?.sourceCellId),
|
|
1130
|
+
domain: cleanOptionalString(query?.domain),
|
|
1131
|
+
owner: cleanOptionalString(query?.owner),
|
|
1132
|
+
intent: parseOptionalIntent(query?.intent),
|
|
1133
|
+
search: cleanOptionalString(query?.search),
|
|
1134
|
+
status: query?.status,
|
|
1135
|
+
reviewStatus: query?.reviewStatus,
|
|
1136
|
+
promotionAction: normalizePromotionAction(query?.promotionAction),
|
|
1137
|
+
readiness: normalizeReadinessFilter(query?.readiness),
|
|
1138
|
+
age: normalizeAgeFilter(query?.age),
|
|
1139
|
+
nextAction: normalizeNextActionFilter(query?.nextAction),
|
|
1140
|
+
activeOnly: query?.activeOnly === true,
|
|
1141
|
+
sort: normalizeSort(query?.sort),
|
|
1142
|
+
limit,
|
|
1143
|
+
offset,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
function researchListWhere(query, options = {}) {
|
|
1147
|
+
const clauses = [];
|
|
1148
|
+
const params = [];
|
|
1149
|
+
if (query.notebookPath) {
|
|
1150
|
+
clauses.push('notebook_path = ?');
|
|
1151
|
+
params.push(query.notebookPath);
|
|
1152
|
+
}
|
|
1153
|
+
if (query.sourceCellId) {
|
|
1154
|
+
clauses.push('source_cell_id = ?');
|
|
1155
|
+
params.push(query.sourceCellId);
|
|
1156
|
+
}
|
|
1157
|
+
if (query.domain) {
|
|
1158
|
+
if (query.domain.toLowerCase() === 'uncategorized') {
|
|
1159
|
+
clauses.push("(domain IS NULL OR TRIM(domain) = '')");
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
clauses.push('LOWER(domain) = ?');
|
|
1163
|
+
params.push(query.domain.toLowerCase());
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (query.owner) {
|
|
1167
|
+
clauses.push('LOWER(owner) = ?');
|
|
1168
|
+
params.push(query.owner.toLowerCase());
|
|
1169
|
+
}
|
|
1170
|
+
if (query.intent) {
|
|
1171
|
+
clauses.push('intent = ?');
|
|
1172
|
+
params.push(query.intent);
|
|
1173
|
+
}
|
|
1174
|
+
if (query.status) {
|
|
1175
|
+
clauses.push('status = ?');
|
|
1176
|
+
params.push(query.status);
|
|
1177
|
+
}
|
|
1178
|
+
if (query.reviewStatus) {
|
|
1179
|
+
clauses.push('review_status = ?');
|
|
1180
|
+
params.push(query.reviewStatus);
|
|
1181
|
+
}
|
|
1182
|
+
if (query.activeOnly) {
|
|
1183
|
+
clauses.push("review_status NOT IN ('completed', 'certified', 'rejected')");
|
|
1184
|
+
}
|
|
1185
|
+
if (query.promotionAction) {
|
|
1186
|
+
clauses.push('dql_promotion_action = ?');
|
|
1187
|
+
params.push(query.promotionAction);
|
|
1188
|
+
}
|
|
1189
|
+
if (query.readiness === 'draft_ready') {
|
|
1190
|
+
clauses.push(`(${DRAFT_READY_SQL})`);
|
|
1191
|
+
}
|
|
1192
|
+
else if (query.readiness === 'certification_ready') {
|
|
1193
|
+
clauses.push(`(${CERTIFICATION_READY_SQL})`);
|
|
1194
|
+
}
|
|
1195
|
+
else if (query.readiness === 'blocked') {
|
|
1196
|
+
clauses.push(`(${BLOCKED_SQL})`);
|
|
1197
|
+
}
|
|
1198
|
+
if (query.age === 'stale_open' || query.age === 'expired_open') {
|
|
1199
|
+
const thresholdDays = query.age === 'expired_open' ? 30 : 7;
|
|
1200
|
+
const cutoff = new Date(Date.now() - thresholdDays * 24 * 60 * 60 * 1000).toISOString();
|
|
1201
|
+
clauses.push("review_status NOT IN ('completed', 'certified', 'rejected')");
|
|
1202
|
+
clauses.push('updated_at <= ?');
|
|
1203
|
+
params.push(cutoff);
|
|
1204
|
+
}
|
|
1205
|
+
if (query.nextAction) {
|
|
1206
|
+
clauses.push(`(${NEXT_ACTION_SQL}) = ?`);
|
|
1207
|
+
params.push(query.nextAction);
|
|
1208
|
+
}
|
|
1209
|
+
if (query.search) {
|
|
1210
|
+
const ftsQuery = options.useSearchIndex ? searchIndexQuery(query.search) : undefined;
|
|
1211
|
+
if (ftsQuery) {
|
|
1212
|
+
clauses.push(`id IN (
|
|
1213
|
+
SELECT id
|
|
1214
|
+
FROM notebook_research_runs_fts
|
|
1215
|
+
WHERE notebook_research_runs_fts MATCH ?
|
|
1216
|
+
)`);
|
|
1217
|
+
params.push(ftsQuery);
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
const fallback = researchSearchFallback(query.search);
|
|
1221
|
+
clauses.push(fallback.sql);
|
|
1222
|
+
params.push(...fallback.params);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
whereSql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
|
|
1227
|
+
params,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function searchIndexQuery(search) {
|
|
1231
|
+
const tokens = researchSearchTokens(search);
|
|
1232
|
+
if (!tokens?.length)
|
|
1233
|
+
return undefined;
|
|
1234
|
+
return tokens.map((token) => `${token}*`).join(' AND ');
|
|
1235
|
+
}
|
|
1236
|
+
function researchSearchTokens(search) {
|
|
1237
|
+
const tokens = search
|
|
1238
|
+
.toLowerCase()
|
|
1239
|
+
.match(/[a-z0-9]+/g)
|
|
1240
|
+
?.filter((token) => token.length >= 2)
|
|
1241
|
+
.slice(0, 12) ?? [];
|
|
1242
|
+
return Array.from(new Set(tokens));
|
|
1243
|
+
}
|
|
1244
|
+
const RESEARCH_SEARCH_FALLBACK_FIELDS = [
|
|
1245
|
+
'LOWER(title)',
|
|
1246
|
+
'LOWER(question)',
|
|
1247
|
+
"LOWER(COALESCE(domain, ''))",
|
|
1248
|
+
"LOWER(COALESCE(owner, ''))",
|
|
1249
|
+
'LOWER(intent)',
|
|
1250
|
+
"LOWER(COALESCE(source_cell_id, ''))",
|
|
1251
|
+
"LOWER(COALESCE(source_cell_name, ''))",
|
|
1252
|
+
"LOWER(COALESCE(source_cell_fingerprint, ''))",
|
|
1253
|
+
'LOWER(notebook_path)',
|
|
1254
|
+
"LOWER(COALESCE(summary, ''))",
|
|
1255
|
+
"LOWER(COALESCE(recommendation, ''))",
|
|
1256
|
+
"LOWER(COALESCE(warnings, ''))",
|
|
1257
|
+
"LOWER(COALESCE(draft_block_path, ''))",
|
|
1258
|
+
"LOWER(COALESCE(dql_promotion_action, ''))",
|
|
1259
|
+
"LOWER(COALESCE(generated_sql, ''))",
|
|
1260
|
+
"LOWER(COALESCE(reviewed_sql, ''))",
|
|
1261
|
+
"LOWER(COALESCE(context, ''))",
|
|
1262
|
+
"LOWER(COALESCE(result_preview, ''))",
|
|
1263
|
+
"LOWER(COALESCE(evidence, ''))",
|
|
1264
|
+
"LOWER(COALESCE(research_plan, ''))",
|
|
1265
|
+
"LOWER(COALESCE(context_pack_id, ''))",
|
|
1266
|
+
"LOWER(COALESCE(route_decision, ''))",
|
|
1267
|
+
"LOWER(COALESCE(display, ''))",
|
|
1268
|
+
"LOWER(COALESCE(dql_import_id, ''))",
|
|
1269
|
+
"LOWER(COALESCE(dql_candidate_ids, ''))",
|
|
1270
|
+
"LOWER(COALESCE(dql_promotion, ''))",
|
|
1271
|
+
"LOWER(COALESCE(error, ''))",
|
|
1272
|
+
];
|
|
1273
|
+
function researchSearchFallback(search) {
|
|
1274
|
+
const terms = researchSearchTokens(search);
|
|
1275
|
+
const needles = terms.length ? terms : [search.trim().toLowerCase()].filter(Boolean);
|
|
1276
|
+
const clauses = needles.map(() => `(${RESEARCH_SEARCH_FALLBACK_FIELDS.map((field) => `${field} LIKE ? ESCAPE '\\'`).join(' OR ')})`);
|
|
1277
|
+
return {
|
|
1278
|
+
sql: `(${clauses.join(' AND ')})`,
|
|
1279
|
+
params: needles.flatMap((term) => RESEARCH_SEARCH_FALLBACK_FIELDS.map(() => `%${escapeLike(term)}%`)),
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
function escapeLike(value) {
|
|
1283
|
+
return value.replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
1284
|
+
}
|
|
1285
|
+
function searchIndexValues(run) {
|
|
1286
|
+
return [
|
|
1287
|
+
run.id,
|
|
1288
|
+
run.notebookPath,
|
|
1289
|
+
run.domain ?? '',
|
|
1290
|
+
run.owner ?? '',
|
|
1291
|
+
run.sourceCellName ?? '',
|
|
1292
|
+
run.title,
|
|
1293
|
+
run.question,
|
|
1294
|
+
run.intent,
|
|
1295
|
+
run.summary ?? '',
|
|
1296
|
+
run.recommendation ?? '',
|
|
1297
|
+
run.warnings.join(' '),
|
|
1298
|
+
run.draftBlockPath ?? '',
|
|
1299
|
+
run.dqlPromotionAction ?? '',
|
|
1300
|
+
run.generatedSql ?? '',
|
|
1301
|
+
run.reviewedSql ?? '',
|
|
1302
|
+
searchTextFromValue({
|
|
1303
|
+
sourceCellId: run.sourceCellId,
|
|
1304
|
+
sourceCellFingerprint: run.sourceCellFingerprint,
|
|
1305
|
+
context: run.context,
|
|
1306
|
+
resultPreview: run.resultPreview,
|
|
1307
|
+
display: run.display,
|
|
1308
|
+
}),
|
|
1309
|
+
searchTextFromValue({
|
|
1310
|
+
evidence: run.evidence,
|
|
1311
|
+
contextPackId: run.contextPackId,
|
|
1312
|
+
routeDecision: run.routeDecision,
|
|
1313
|
+
error: run.error,
|
|
1314
|
+
}),
|
|
1315
|
+
searchTextFromValue(run.researchPlan),
|
|
1316
|
+
searchTextFromValue(run.dqlPromotion),
|
|
1317
|
+
];
|
|
1318
|
+
}
|
|
1319
|
+
function searchTextFromValue(value) {
|
|
1320
|
+
if (value === undefined || value === null)
|
|
1321
|
+
return '';
|
|
1322
|
+
if (typeof value === 'string')
|
|
1323
|
+
return value.slice(0, 20_000);
|
|
1324
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
1325
|
+
return String(value);
|
|
1326
|
+
try {
|
|
1327
|
+
return JSON.stringify(value).slice(0, 20_000);
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
return '';
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
function cleanOptionalString(value) {
|
|
1334
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
1335
|
+
}
|
|
1336
|
+
function inferResearchDomain(input) {
|
|
1337
|
+
const contextDomain = domainFromContext(input.context);
|
|
1338
|
+
if (contextDomain)
|
|
1339
|
+
return contextDomain;
|
|
1340
|
+
const promotedDomain = input.dqlPromotion?.candidates.find((candidate) => candidate.domain)?.domain;
|
|
1341
|
+
if (promotedDomain)
|
|
1342
|
+
return cleanOptionalString(promotedDomain);
|
|
1343
|
+
const draftDomain = domainFromPath(input.draftBlockPath);
|
|
1344
|
+
if (draftDomain)
|
|
1345
|
+
return draftDomain;
|
|
1346
|
+
return domainFromPath(input.notebookPath);
|
|
1347
|
+
}
|
|
1348
|
+
function domainFromContext(value) {
|
|
1349
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1350
|
+
return undefined;
|
|
1351
|
+
const record = value;
|
|
1352
|
+
return cleanOptionalString(record.selectedDomain)
|
|
1353
|
+
?? cleanOptionalString(record.domain)
|
|
1354
|
+
?? domainFromContext(record.block)
|
|
1355
|
+
?? domainFromContext(record.notebook)
|
|
1356
|
+
?? domainFromContext(record.metadata);
|
|
1357
|
+
}
|
|
1358
|
+
function domainFromPath(path) {
|
|
1359
|
+
if (!path)
|
|
1360
|
+
return undefined;
|
|
1361
|
+
const domainLayout = path.match(/(?:^|\/)domains\/([^/]+)/);
|
|
1362
|
+
if (domainLayout?.[1])
|
|
1363
|
+
return cleanOptionalString(domainLayout[1]);
|
|
1364
|
+
const draftLayout = path.match(/(?:^|\/)_drafts\/([^/]+)/);
|
|
1365
|
+
if (draftLayout?.[1])
|
|
1366
|
+
return cleanOptionalString(draftLayout[1]);
|
|
1367
|
+
return undefined;
|
|
1368
|
+
}
|
|
1369
|
+
function optionalString(value) {
|
|
1370
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
1371
|
+
}
|
|
1372
|
+
function json(value) {
|
|
1373
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
1374
|
+
}
|
|
1375
|
+
function parseJson(value) {
|
|
1376
|
+
if (typeof value !== 'string' || !value.trim())
|
|
1377
|
+
return undefined;
|
|
1378
|
+
try {
|
|
1379
|
+
return JSON.parse(value);
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return undefined;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function stringArray(value) {
|
|
1386
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
1387
|
+
}
|
|
1388
|
+
function parseDqlPromotion(value) {
|
|
1389
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1390
|
+
return undefined;
|
|
1391
|
+
const record = value;
|
|
1392
|
+
const importId = optionalString(record.importId);
|
|
1393
|
+
if (!importId)
|
|
1394
|
+
return undefined;
|
|
1395
|
+
return {
|
|
1396
|
+
importId,
|
|
1397
|
+
candidateIds: stringArray(record.candidateIds),
|
|
1398
|
+
draftBlockPath: optionalString(record.draftBlockPath),
|
|
1399
|
+
recommendedAction: optionalString(record.recommendedAction),
|
|
1400
|
+
similarityMatches: parsePromotionMatches(record.similarityMatches),
|
|
1401
|
+
candidates: parsePromotionCandidates(record.candidates),
|
|
1402
|
+
createdAt: optionalString(record.createdAt) ?? new Date(0).toISOString(),
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function parsePromotionCandidates(value) {
|
|
1406
|
+
if (!Array.isArray(value))
|
|
1407
|
+
return [];
|
|
1408
|
+
return value.flatMap((item) => {
|
|
1409
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
1410
|
+
return [];
|
|
1411
|
+
const record = item;
|
|
1412
|
+
const id = optionalString(record.id);
|
|
1413
|
+
const name = optionalString(record.name);
|
|
1414
|
+
if (!id || !name)
|
|
1415
|
+
return [];
|
|
1416
|
+
return [{
|
|
1417
|
+
id,
|
|
1418
|
+
name,
|
|
1419
|
+
domain: optionalString(record.domain),
|
|
1420
|
+
draftPath: optionalString(record.draftPath),
|
|
1421
|
+
savedPath: optionalString(record.savedPath),
|
|
1422
|
+
reviewStatus: optionalString(record.reviewStatus),
|
|
1423
|
+
recommendedAction: optionalString(record.recommendedAction),
|
|
1424
|
+
similarityMatches: parsePromotionMatches(record.similarityMatches),
|
|
1425
|
+
parameterPolicy: parseParameterPolicy(record.parameterPolicy),
|
|
1426
|
+
allowedFilters: stringArray(record.allowedFilters),
|
|
1427
|
+
warnings: stringArray(record.warnings),
|
|
1428
|
+
}];
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
function parsePromotionMatches(value) {
|
|
1432
|
+
if (!Array.isArray(value))
|
|
1433
|
+
return [];
|
|
1434
|
+
return value.flatMap((item) => {
|
|
1435
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
1436
|
+
return [];
|
|
1437
|
+
const record = item;
|
|
1438
|
+
const name = optionalString(record.name);
|
|
1439
|
+
const reason = optionalString(record.reason);
|
|
1440
|
+
if (!name || !reason)
|
|
1441
|
+
return [];
|
|
1442
|
+
const score = typeof record.score === 'number' && Number.isFinite(record.score) ? record.score : 0;
|
|
1443
|
+
return [{
|
|
1444
|
+
kind: optionalString(record.kind) ?? 'near_variant',
|
|
1445
|
+
objectKey: optionalString(record.objectKey),
|
|
1446
|
+
name,
|
|
1447
|
+
status: optionalString(record.status),
|
|
1448
|
+
source: optionalString(record.source),
|
|
1449
|
+
score,
|
|
1450
|
+
reason,
|
|
1451
|
+
recommendedAction: optionalString(record.recommendedAction) ?? 'review_required',
|
|
1452
|
+
}];
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
function parseParameterPolicy(value) {
|
|
1456
|
+
if (!Array.isArray(value))
|
|
1457
|
+
return [];
|
|
1458
|
+
return value.flatMap((item) => {
|
|
1459
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
1460
|
+
return [];
|
|
1461
|
+
const record = item;
|
|
1462
|
+
const name = optionalString(record.name);
|
|
1463
|
+
const policy = optionalString(record.policy);
|
|
1464
|
+
return name && policy ? [{ name, policy }] : [];
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
function parseResearchPlan(value) {
|
|
1468
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
1469
|
+
return undefined;
|
|
1470
|
+
const record = value;
|
|
1471
|
+
const evidence = record.evidence && typeof record.evidence === 'object' && !Array.isArray(record.evidence)
|
|
1472
|
+
? record.evidence
|
|
1473
|
+
: {};
|
|
1474
|
+
const preview = record.preview && typeof record.preview === 'object' && !Array.isArray(record.preview)
|
|
1475
|
+
? record.preview
|
|
1476
|
+
: {};
|
|
1477
|
+
const promotion = record.promotion && typeof record.promotion === 'object' && !Array.isArray(record.promotion)
|
|
1478
|
+
? record.promotion
|
|
1479
|
+
: {};
|
|
1480
|
+
const sqlState = record.sqlState === 'generated' || record.sqlState === 'reviewed' ? record.sqlState : 'missing';
|
|
1481
|
+
const previewStatus = preview.status === 'ready' || preview.status === 'error' ? preview.status : 'not_run';
|
|
1482
|
+
const promotionPath = parseResearchPlanPromotionPath(promotion.path);
|
|
1483
|
+
const rowCount = typeof preview.rowCount === 'number' && Number.isFinite(preview.rowCount) ? preview.rowCount : undefined;
|
|
1484
|
+
return {
|
|
1485
|
+
sqlState,
|
|
1486
|
+
grain: optionalString(record.grain),
|
|
1487
|
+
parameterPolicy: parseParameterPolicy(record.parameterPolicy),
|
|
1488
|
+
allowedFilters: stringArray(record.allowedFilters),
|
|
1489
|
+
evidence: {
|
|
1490
|
+
trustLabel: optionalString(evidence.trustLabel),
|
|
1491
|
+
contextPackId: optionalString(evidence.contextPackId),
|
|
1492
|
+
evidenceCount: finiteNumber(evidence.evidenceCount),
|
|
1493
|
+
relationCount: finiteNumber(evidence.relationCount),
|
|
1494
|
+
missingContextCount: finiteNumber(evidence.missingContextCount),
|
|
1495
|
+
},
|
|
1496
|
+
preview: {
|
|
1497
|
+
status: previewStatus,
|
|
1498
|
+
...(rowCount === undefined ? {} : { rowCount }),
|
|
1499
|
+
},
|
|
1500
|
+
promotion: {
|
|
1501
|
+
path: promotionPath,
|
|
1502
|
+
duplicateDecision: optionalString(promotion.duplicateDecision),
|
|
1503
|
+
},
|
|
1504
|
+
reviewFocus: stringArray(record.reviewFocus),
|
|
1505
|
+
generatedAt: optionalString(record.generatedAt) ?? new Date(0).toISOString(),
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
function parseResearchPlanPromotionPath(value) {
|
|
1509
|
+
return value === 'review_context'
|
|
1510
|
+
|| value === 'run_preview'
|
|
1511
|
+
|| value === 'reuse_existing'
|
|
1512
|
+
|| value === 'create_dql_draft'
|
|
1513
|
+
|| value === 'open_certification'
|
|
1514
|
+
|| value === 'complete_review'
|
|
1515
|
+
? value
|
|
1516
|
+
: 'needs_sql';
|
|
1517
|
+
}
|
|
1518
|
+
function finiteNumber(value) {
|
|
1519
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
1520
|
+
}
|
|
1521
|
+
//# sourceMappingURL=local-notebook-research-storage.js.map
|