@compilr-dev/factory 0.1.13 → 0.1.14
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.js +1 -0
- package/dist/research-model/defaults.d.ts +16 -0
- package/dist/research-model/defaults.js +76 -0
- package/dist/research-model/index.d.ts +12 -0
- package/dist/research-model/index.js +13 -0
- package/dist/research-model/operations-claim.d.ts +43 -0
- package/dist/research-model/operations-claim.js +99 -0
- package/dist/research-model/operations-metadata.d.ts +43 -0
- package/dist/research-model/operations-metadata.js +34 -0
- package/dist/research-model/operations-question.d.ts +32 -0
- package/dist/research-model/operations-question.js +59 -0
- package/dist/research-model/operations-section.d.ts +37 -0
- package/dist/research-model/operations-section.js +94 -0
- package/dist/research-model/operations-source.d.ts +27 -0
- package/dist/research-model/operations-source.js +78 -0
- package/dist/research-model/operations.d.ts +13 -0
- package/dist/research-model/operations.js +80 -0
- package/dist/research-model/persistence.d.ts +19 -0
- package/dist/research-model/persistence.js +40 -0
- package/dist/research-model/schema.d.ts +23 -0
- package/dist/research-model/schema.js +293 -0
- package/dist/research-model/tools.d.ts +13 -0
- package/dist/research-model/tools.js +586 -0
- package/dist/research-model/types.d.ts +113 -0
- package/dist/research-model/types.js +8 -0
- package/package.json +5 -6
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Model Tools
|
|
3
|
+
*
|
|
4
|
+
* research_model_get — scoped reads of the Research Model
|
|
5
|
+
* research_model_update — semantic operations on the model
|
|
6
|
+
* research_model_validate — check model health
|
|
7
|
+
*/
|
|
8
|
+
import { defineTool, createSuccessResult, createErrorResult } from '@compilr-dev/agents';
|
|
9
|
+
import { ResearchModelStore } from './persistence.js';
|
|
10
|
+
import { applyResearchOperation } from './operations.js';
|
|
11
|
+
import { validateResearchModel } from './schema.js';
|
|
12
|
+
import { createDefaultResearchModel } from './defaults.js';
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Helpers
|
|
15
|
+
// =============================================================================
|
|
16
|
+
function getProjectId(config, inputProjectId) {
|
|
17
|
+
const projectId = inputProjectId ?? config.context.currentProjectId;
|
|
18
|
+
if (!projectId) {
|
|
19
|
+
throw new Error('No active project. Use project_create or project_get first.');
|
|
20
|
+
}
|
|
21
|
+
return projectId;
|
|
22
|
+
}
|
|
23
|
+
function createStore(config, projectId) {
|
|
24
|
+
return new ResearchModelStore({
|
|
25
|
+
documents: config.context.documents,
|
|
26
|
+
projectId,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function createSummary(model) {
|
|
30
|
+
return {
|
|
31
|
+
title: model.title,
|
|
32
|
+
citationStyle: model.citationStyle,
|
|
33
|
+
sectionCount: model.sections.length,
|
|
34
|
+
sourceCount: model.sources.length,
|
|
35
|
+
questionCount: model.researchQuestions.length,
|
|
36
|
+
sections: model.sections.map((s) => ({
|
|
37
|
+
id: s.id,
|
|
38
|
+
title: s.title,
|
|
39
|
+
status: s.status,
|
|
40
|
+
claimCount: s.claims.length,
|
|
41
|
+
parentId: s.parentId,
|
|
42
|
+
})),
|
|
43
|
+
sources: model.sources.map((s) => ({
|
|
44
|
+
id: s.id,
|
|
45
|
+
citeKey: s.citeKey,
|
|
46
|
+
title: s.citation.title,
|
|
47
|
+
analyzed: s.analyzed,
|
|
48
|
+
})),
|
|
49
|
+
questions: model.researchQuestions.map((q) => ({
|
|
50
|
+
id: q.id,
|
|
51
|
+
question: q.question,
|
|
52
|
+
status: q.status,
|
|
53
|
+
sectionCount: q.sectionRefs.length,
|
|
54
|
+
})),
|
|
55
|
+
revision: model.meta.revision,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function createResearchModelGetTool(config) {
|
|
59
|
+
return defineTool({
|
|
60
|
+
name: 'research_model_get',
|
|
61
|
+
description: 'Read the Research Model. Supports scoped reads by section or overview.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
scope: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
enum: ['overview', 'sections', 'sources', 'claims', 'questions', 'full'],
|
|
68
|
+
description: 'Which part to return. "overview" for summary, "sections" for all sections, "sources" for source registry, "claims" for all claims across sections, "questions" for research questions, omit or "full" for everything.',
|
|
69
|
+
},
|
|
70
|
+
sectionId: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: 'Return only this section (by ID). Overrides scope.',
|
|
73
|
+
},
|
|
74
|
+
project_id: {
|
|
75
|
+
type: 'number',
|
|
76
|
+
description: 'Project ID. Uses active project if omitted.',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: [],
|
|
80
|
+
},
|
|
81
|
+
execute: async (input) => {
|
|
82
|
+
try {
|
|
83
|
+
const projectId = getProjectId(config, input.project_id);
|
|
84
|
+
const store = createStore(config, projectId);
|
|
85
|
+
const model = await store.get();
|
|
86
|
+
if (!model) {
|
|
87
|
+
return createSuccessResult({
|
|
88
|
+
exists: false,
|
|
89
|
+
message: 'No Research Model found. Use research_model_update with an operation to start building one (e.g., set_title, section_add, question_add).',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Section-scoped read
|
|
93
|
+
if (input.sectionId) {
|
|
94
|
+
const section = model.sections.find((s) => s.id === input.sectionId);
|
|
95
|
+
if (!section) {
|
|
96
|
+
return createErrorResult(`Section "${input.sectionId}" not found`);
|
|
97
|
+
}
|
|
98
|
+
return createSuccessResult({ section, meta: model.meta });
|
|
99
|
+
}
|
|
100
|
+
// Scope-based read
|
|
101
|
+
switch (input.scope) {
|
|
102
|
+
case 'overview':
|
|
103
|
+
return createSuccessResult(createSummary(model));
|
|
104
|
+
case 'sections':
|
|
105
|
+
return createSuccessResult({ sections: model.sections, meta: model.meta });
|
|
106
|
+
case 'sources':
|
|
107
|
+
return createSuccessResult({ sources: model.sources, meta: model.meta });
|
|
108
|
+
case 'claims': {
|
|
109
|
+
const allClaims = model.sections.flatMap((s) => s.claims.map((c) => ({ ...c, sectionId: s.id, sectionTitle: s.title })));
|
|
110
|
+
return createSuccessResult({ claims: allClaims, meta: model.meta });
|
|
111
|
+
}
|
|
112
|
+
case 'questions':
|
|
113
|
+
return createSuccessResult({
|
|
114
|
+
researchQuestions: model.researchQuestions,
|
|
115
|
+
meta: model.meta,
|
|
116
|
+
});
|
|
117
|
+
default:
|
|
118
|
+
return createSuccessResult(model);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return createErrorResult(error instanceof Error ? error.message : String(error));
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
readonly: true,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const ALL_OPS = [
|
|
129
|
+
// Section
|
|
130
|
+
'section_add',
|
|
131
|
+
'section_update',
|
|
132
|
+
'section_remove',
|
|
133
|
+
'section_reorder',
|
|
134
|
+
'section_move',
|
|
135
|
+
// Claim
|
|
136
|
+
'claim_add',
|
|
137
|
+
'claim_update',
|
|
138
|
+
'claim_remove',
|
|
139
|
+
'claim_link_source',
|
|
140
|
+
'claim_unlink_source',
|
|
141
|
+
// Source
|
|
142
|
+
'source_add',
|
|
143
|
+
'source_update',
|
|
144
|
+
'source_remove',
|
|
145
|
+
// Question
|
|
146
|
+
'question_add',
|
|
147
|
+
'question_update',
|
|
148
|
+
'question_remove',
|
|
149
|
+
'question_link_section',
|
|
150
|
+
// Metadata
|
|
151
|
+
'set_title',
|
|
152
|
+
'set_abstract',
|
|
153
|
+
'set_citation_style',
|
|
154
|
+
'set_keywords',
|
|
155
|
+
'set_methodology',
|
|
156
|
+
'set_authors',
|
|
157
|
+
'set_target_journal',
|
|
158
|
+
];
|
|
159
|
+
function createResearchModelUpdateTool(config) {
|
|
160
|
+
return defineTool({
|
|
161
|
+
name: 'research_model_update',
|
|
162
|
+
description: 'Apply a semantic operation to the Research Model. Auto-creates if none exists.',
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
op: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
enum: [...ALL_OPS],
|
|
169
|
+
description: 'The operation to perform.',
|
|
170
|
+
},
|
|
171
|
+
revision: {
|
|
172
|
+
type: 'number',
|
|
173
|
+
description: 'Expected current revision for optimistic locking. Optional.',
|
|
174
|
+
},
|
|
175
|
+
sectionId: {
|
|
176
|
+
type: 'string',
|
|
177
|
+
description: 'Section ID (for section_update, section_remove, section_move, claim ops).',
|
|
178
|
+
},
|
|
179
|
+
section: {
|
|
180
|
+
oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }],
|
|
181
|
+
description: 'Section object for section_add. Must include "title". Optional: purpose, order, parentId, status, targetWordCount.',
|
|
182
|
+
},
|
|
183
|
+
newParentId: {
|
|
184
|
+
type: 'string',
|
|
185
|
+
description: 'New parent section ID for section_move. Omit for top-level.',
|
|
186
|
+
},
|
|
187
|
+
order: {
|
|
188
|
+
type: 'array',
|
|
189
|
+
items: { type: 'string' },
|
|
190
|
+
description: 'Section ID order for section_reorder.',
|
|
191
|
+
},
|
|
192
|
+
force: {
|
|
193
|
+
type: 'boolean',
|
|
194
|
+
description: 'Force removal with cascading cleanup.',
|
|
195
|
+
},
|
|
196
|
+
claimId: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
description: 'Claim ID (for claim_update, claim_remove, claim_link/unlink_source).',
|
|
199
|
+
},
|
|
200
|
+
claim: {
|
|
201
|
+
oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }],
|
|
202
|
+
description: 'Claim object for claim_add. Must include "statement". Optional: evidenceStrength, type.',
|
|
203
|
+
},
|
|
204
|
+
sourceRef: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
additionalProperties: true,
|
|
207
|
+
description: 'Source reference for claim_link_source. Must include "sourceId". Optional: location, relevance.',
|
|
208
|
+
},
|
|
209
|
+
relation: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
enum: ['supporting', 'contradicting'],
|
|
212
|
+
description: 'Relation type for claim_link_source.',
|
|
213
|
+
},
|
|
214
|
+
sourceId: {
|
|
215
|
+
type: 'string',
|
|
216
|
+
description: 'Source ID (for source_update, source_remove, claim_unlink_source).',
|
|
217
|
+
},
|
|
218
|
+
source: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
additionalProperties: true,
|
|
221
|
+
description: 'Source object for source_add. Must include "citeKey" and "citation" (with title, authors, year, type).',
|
|
222
|
+
},
|
|
223
|
+
questionId: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
description: 'Question ID (for question_update, question_remove, question_link_section).',
|
|
226
|
+
},
|
|
227
|
+
question: {
|
|
228
|
+
oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }],
|
|
229
|
+
description: 'Question object for question_add. Must include "question" text.',
|
|
230
|
+
},
|
|
231
|
+
unlink: {
|
|
232
|
+
type: 'boolean',
|
|
233
|
+
description: 'If true, question_link_section removes the link instead of adding it.',
|
|
234
|
+
},
|
|
235
|
+
updates: {
|
|
236
|
+
oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }],
|
|
237
|
+
description: 'Partial updates for section_update, claim_update, source_update, question_update.',
|
|
238
|
+
},
|
|
239
|
+
// Metadata shortcuts
|
|
240
|
+
title: { type: 'string', description: 'Paper title for set_title.' },
|
|
241
|
+
abstract: { type: 'string', description: 'Abstract text for set_abstract.' },
|
|
242
|
+
citationStyle: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
enum: ['apa', 'mla', 'chicago', 'ieee', 'harvard'],
|
|
245
|
+
description: 'Citation style for set_citation_style.',
|
|
246
|
+
},
|
|
247
|
+
keywords: {
|
|
248
|
+
type: 'array',
|
|
249
|
+
items: { type: 'string' },
|
|
250
|
+
description: 'Keywords for set_keywords.',
|
|
251
|
+
},
|
|
252
|
+
methodology: { type: 'string', description: 'Methodology for set_methodology.' },
|
|
253
|
+
authors: {
|
|
254
|
+
type: 'array',
|
|
255
|
+
items: { type: 'string' },
|
|
256
|
+
description: 'Authors for set_authors.',
|
|
257
|
+
},
|
|
258
|
+
targetJournal: { type: 'string', description: 'Target journal for set_target_journal.' },
|
|
259
|
+
project_id: {
|
|
260
|
+
type: 'number',
|
|
261
|
+
description: 'Project ID. Uses active project if omitted.',
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
required: ['op'],
|
|
265
|
+
},
|
|
266
|
+
execute: async (input) => {
|
|
267
|
+
try {
|
|
268
|
+
const projectId = getProjectId(config, input.project_id);
|
|
269
|
+
const store = createStore(config, projectId);
|
|
270
|
+
let model = await store.get();
|
|
271
|
+
if (!model) {
|
|
272
|
+
model = createDefaultResearchModel();
|
|
273
|
+
}
|
|
274
|
+
// Optimistic locking
|
|
275
|
+
if (input.revision !== undefined && input.revision !== model.meta.revision) {
|
|
276
|
+
return createErrorResult(`Revision conflict: expected ${String(input.revision)}, current is ${String(model.meta.revision)}. Re-read the model and retry.`);
|
|
277
|
+
}
|
|
278
|
+
const operation = buildOperation(input);
|
|
279
|
+
const updated = applyResearchOperation(model, operation);
|
|
280
|
+
// Validate (only block on errors, not warnings)
|
|
281
|
+
const validation = validateResearchModel(updated);
|
|
282
|
+
if (!validation.valid) {
|
|
283
|
+
const msgs = validation.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
284
|
+
return createErrorResult(`Operation would produce invalid model: ${msgs}`);
|
|
285
|
+
}
|
|
286
|
+
await store.save(updated);
|
|
287
|
+
return createSuccessResult({
|
|
288
|
+
op: input.op,
|
|
289
|
+
revision: updated.meta.revision,
|
|
290
|
+
message: `Operation "${input.op}" applied successfully.`,
|
|
291
|
+
warnings: validation.warnings.length > 0
|
|
292
|
+
? validation.warnings.map((w) => `${w.path}: ${w.message}`)
|
|
293
|
+
: undefined,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return createErrorResult(error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
// =============================================================================
|
|
303
|
+
// Build Operation from flat input
|
|
304
|
+
// =============================================================================
|
|
305
|
+
function coerceToObject(value) {
|
|
306
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
if (typeof value === 'string') {
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(value);
|
|
312
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
313
|
+
return parsed;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Not valid JSON
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
function buildOperation(input) {
|
|
323
|
+
switch (input.op) {
|
|
324
|
+
// ── Section ops ────────────────────────────────────────────────────────
|
|
325
|
+
case 'section_add': {
|
|
326
|
+
const section = coerceToObject(input.section);
|
|
327
|
+
if (!section) {
|
|
328
|
+
throw new Error('section_add requires "section" object with at least "title".');
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
op: 'section_add',
|
|
332
|
+
section: section,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
case 'section_update': {
|
|
336
|
+
if (!input.sectionId)
|
|
337
|
+
throw new Error('section_update requires "sectionId".');
|
|
338
|
+
const updates = coerceToObject(input.updates);
|
|
339
|
+
if (!updates)
|
|
340
|
+
throw new Error('section_update requires "updates" object.');
|
|
341
|
+
return {
|
|
342
|
+
op: 'section_update',
|
|
343
|
+
sectionId: input.sectionId,
|
|
344
|
+
updates,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
case 'section_remove':
|
|
348
|
+
if (!input.sectionId)
|
|
349
|
+
throw new Error('section_remove requires "sectionId".');
|
|
350
|
+
return {
|
|
351
|
+
op: 'section_remove',
|
|
352
|
+
sectionId: input.sectionId,
|
|
353
|
+
force: input.force,
|
|
354
|
+
};
|
|
355
|
+
case 'section_reorder':
|
|
356
|
+
if (!input.order)
|
|
357
|
+
throw new Error('section_reorder requires "order" array.');
|
|
358
|
+
return { op: 'section_reorder', order: input.order };
|
|
359
|
+
case 'section_move':
|
|
360
|
+
if (!input.sectionId)
|
|
361
|
+
throw new Error('section_move requires "sectionId".');
|
|
362
|
+
return {
|
|
363
|
+
op: 'section_move',
|
|
364
|
+
sectionId: input.sectionId,
|
|
365
|
+
newParentId: input.newParentId,
|
|
366
|
+
};
|
|
367
|
+
// ── Claim ops ──────────────────────────────────────────────────────────
|
|
368
|
+
case 'claim_add': {
|
|
369
|
+
if (!input.sectionId)
|
|
370
|
+
throw new Error('claim_add requires "sectionId".');
|
|
371
|
+
const claim = coerceToObject(input.claim);
|
|
372
|
+
if (!claim)
|
|
373
|
+
throw new Error('claim_add requires "claim" object with at least "statement".');
|
|
374
|
+
return { op: 'claim_add', sectionId: input.sectionId, claim };
|
|
375
|
+
}
|
|
376
|
+
case 'claim_update': {
|
|
377
|
+
if (!input.sectionId)
|
|
378
|
+
throw new Error('claim_update requires "sectionId".');
|
|
379
|
+
if (!input.claimId)
|
|
380
|
+
throw new Error('claim_update requires "claimId".');
|
|
381
|
+
const updates = coerceToObject(input.updates);
|
|
382
|
+
if (!updates)
|
|
383
|
+
throw new Error('claim_update requires "updates" object.');
|
|
384
|
+
return {
|
|
385
|
+
op: 'claim_update',
|
|
386
|
+
sectionId: input.sectionId,
|
|
387
|
+
claimId: input.claimId,
|
|
388
|
+
updates,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
case 'claim_remove':
|
|
392
|
+
if (!input.sectionId)
|
|
393
|
+
throw new Error('claim_remove requires "sectionId".');
|
|
394
|
+
if (!input.claimId)
|
|
395
|
+
throw new Error('claim_remove requires "claimId".');
|
|
396
|
+
return {
|
|
397
|
+
op: 'claim_remove',
|
|
398
|
+
sectionId: input.sectionId,
|
|
399
|
+
claimId: input.claimId,
|
|
400
|
+
};
|
|
401
|
+
case 'claim_link_source': {
|
|
402
|
+
if (!input.sectionId)
|
|
403
|
+
throw new Error('claim_link_source requires "sectionId".');
|
|
404
|
+
if (!input.claimId)
|
|
405
|
+
throw new Error('claim_link_source requires "claimId".');
|
|
406
|
+
const sourceRef = coerceToObject(input.sourceRef);
|
|
407
|
+
if (!sourceRef)
|
|
408
|
+
throw new Error('claim_link_source requires "sourceRef" object with "sourceId".');
|
|
409
|
+
if (!sourceRef.sourceId)
|
|
410
|
+
throw new Error('claim_link_source sourceRef must include "sourceId".');
|
|
411
|
+
if (!input.relation)
|
|
412
|
+
throw new Error('claim_link_source requires "relation" ("supporting" or "contradicting").');
|
|
413
|
+
return {
|
|
414
|
+
op: 'claim_link_source',
|
|
415
|
+
sectionId: input.sectionId,
|
|
416
|
+
claimId: input.claimId,
|
|
417
|
+
sourceRef: sourceRef,
|
|
418
|
+
relation: input.relation,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
case 'claim_unlink_source':
|
|
422
|
+
if (!input.sectionId)
|
|
423
|
+
throw new Error('claim_unlink_source requires "sectionId".');
|
|
424
|
+
if (!input.claimId)
|
|
425
|
+
throw new Error('claim_unlink_source requires "claimId".');
|
|
426
|
+
if (!input.sourceId)
|
|
427
|
+
throw new Error('claim_unlink_source requires "sourceId".');
|
|
428
|
+
return {
|
|
429
|
+
op: 'claim_unlink_source',
|
|
430
|
+
sectionId: input.sectionId,
|
|
431
|
+
claimId: input.claimId,
|
|
432
|
+
sourceId: input.sourceId,
|
|
433
|
+
};
|
|
434
|
+
// ── Source ops ─────────────────────────────────────────────────────────
|
|
435
|
+
case 'source_add': {
|
|
436
|
+
const source = coerceToObject(input.source);
|
|
437
|
+
if (!source) {
|
|
438
|
+
throw new Error('source_add requires "source" object with "citeKey" and "citation".');
|
|
439
|
+
}
|
|
440
|
+
return { op: 'source_add', source };
|
|
441
|
+
}
|
|
442
|
+
case 'source_update': {
|
|
443
|
+
if (!input.sourceId)
|
|
444
|
+
throw new Error('source_update requires "sourceId".');
|
|
445
|
+
const updates = coerceToObject(input.updates);
|
|
446
|
+
if (!updates)
|
|
447
|
+
throw new Error('source_update requires "updates" object.');
|
|
448
|
+
return { op: 'source_update', sourceId: input.sourceId, updates };
|
|
449
|
+
}
|
|
450
|
+
case 'source_remove':
|
|
451
|
+
if (!input.sourceId)
|
|
452
|
+
throw new Error('source_remove requires "sourceId".');
|
|
453
|
+
return {
|
|
454
|
+
op: 'source_remove',
|
|
455
|
+
sourceId: input.sourceId,
|
|
456
|
+
force: input.force,
|
|
457
|
+
};
|
|
458
|
+
// ── Question ops ───────────────────────────────────────────────────────
|
|
459
|
+
case 'question_add': {
|
|
460
|
+
const question = coerceToObject(input.question);
|
|
461
|
+
if (!question) {
|
|
462
|
+
// Allow string shorthand: { op: "question_add", question: "What is...?" }
|
|
463
|
+
if (typeof input.question === 'string') {
|
|
464
|
+
return {
|
|
465
|
+
op: 'question_add',
|
|
466
|
+
question: { question: input.question },
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
throw new Error('question_add requires "question" object with "question" text.');
|
|
470
|
+
}
|
|
471
|
+
return { op: 'question_add', question };
|
|
472
|
+
}
|
|
473
|
+
case 'question_update': {
|
|
474
|
+
if (!input.questionId)
|
|
475
|
+
throw new Error('question_update requires "questionId".');
|
|
476
|
+
const updates = coerceToObject(input.updates);
|
|
477
|
+
if (!updates)
|
|
478
|
+
throw new Error('question_update requires "updates" object.');
|
|
479
|
+
return {
|
|
480
|
+
op: 'question_update',
|
|
481
|
+
questionId: input.questionId,
|
|
482
|
+
updates,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
case 'question_remove':
|
|
486
|
+
if (!input.questionId)
|
|
487
|
+
throw new Error('question_remove requires "questionId".');
|
|
488
|
+
return { op: 'question_remove', questionId: input.questionId };
|
|
489
|
+
case 'question_link_section':
|
|
490
|
+
if (!input.questionId)
|
|
491
|
+
throw new Error('question_link_section requires "questionId".');
|
|
492
|
+
if (!input.sectionId)
|
|
493
|
+
throw new Error('question_link_section requires "sectionId".');
|
|
494
|
+
return {
|
|
495
|
+
op: 'question_link_section',
|
|
496
|
+
questionId: input.questionId,
|
|
497
|
+
sectionId: input.sectionId,
|
|
498
|
+
unlink: input.unlink,
|
|
499
|
+
};
|
|
500
|
+
// ── Metadata ops ───────────────────────────────────────────────────────
|
|
501
|
+
case 'set_title':
|
|
502
|
+
if (!input.title)
|
|
503
|
+
throw new Error('set_title requires "title".');
|
|
504
|
+
return { op: 'set_title', title: input.title };
|
|
505
|
+
case 'set_abstract':
|
|
506
|
+
return { op: 'set_abstract', abstract: input.abstract ?? '' };
|
|
507
|
+
case 'set_citation_style':
|
|
508
|
+
if (!input.citationStyle)
|
|
509
|
+
throw new Error('set_citation_style requires "citationStyle".');
|
|
510
|
+
return {
|
|
511
|
+
op: 'set_citation_style',
|
|
512
|
+
citationStyle: input.citationStyle,
|
|
513
|
+
};
|
|
514
|
+
case 'set_keywords':
|
|
515
|
+
if (!input.keywords)
|
|
516
|
+
throw new Error('set_keywords requires "keywords" array.');
|
|
517
|
+
return { op: 'set_keywords', keywords: input.keywords };
|
|
518
|
+
case 'set_methodology':
|
|
519
|
+
return {
|
|
520
|
+
op: 'set_methodology',
|
|
521
|
+
methodology: input.methodology ?? '',
|
|
522
|
+
};
|
|
523
|
+
case 'set_authors':
|
|
524
|
+
if (!input.authors)
|
|
525
|
+
throw new Error('set_authors requires "authors" array.');
|
|
526
|
+
return { op: 'set_authors', authors: input.authors };
|
|
527
|
+
case 'set_target_journal':
|
|
528
|
+
return {
|
|
529
|
+
op: 'set_target_journal',
|
|
530
|
+
targetJournal: input.targetJournal ?? '',
|
|
531
|
+
};
|
|
532
|
+
default:
|
|
533
|
+
throw new Error(`Unknown operation: ${input.op}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function createResearchModelValidateTool(config) {
|
|
537
|
+
return defineTool({
|
|
538
|
+
name: 'research_model_validate',
|
|
539
|
+
description: 'Validate the Research Model. Returns structural errors and research-quality warnings (unsupported claims, orphan sources, etc.).',
|
|
540
|
+
inputSchema: {
|
|
541
|
+
type: 'object',
|
|
542
|
+
properties: {
|
|
543
|
+
project_id: {
|
|
544
|
+
type: 'number',
|
|
545
|
+
description: 'Project ID. Uses active project if omitted.',
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
required: [],
|
|
549
|
+
},
|
|
550
|
+
execute: async (input) => {
|
|
551
|
+
try {
|
|
552
|
+
const projectId = getProjectId(config, input.project_id);
|
|
553
|
+
const store = createStore(config, projectId);
|
|
554
|
+
const model = await store.get();
|
|
555
|
+
if (!model) {
|
|
556
|
+
return createSuccessResult({
|
|
557
|
+
valid: false,
|
|
558
|
+
errors: [{ path: '', message: 'No Research Model found' }],
|
|
559
|
+
warnings: [],
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const result = validateResearchModel(model);
|
|
563
|
+
return createSuccessResult({
|
|
564
|
+
valid: result.valid,
|
|
565
|
+
errors: result.errors,
|
|
566
|
+
warnings: result.warnings,
|
|
567
|
+
revision: model.meta.revision,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
return createErrorResult(error instanceof Error ? error.message : String(error));
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
readonly: true,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// =============================================================================
|
|
578
|
+
// Public API
|
|
579
|
+
// =============================================================================
|
|
580
|
+
export function createResearchModelTools(config) {
|
|
581
|
+
return [
|
|
582
|
+
createResearchModelGetTool(config),
|
|
583
|
+
createResearchModelUpdateTool(config),
|
|
584
|
+
createResearchModelValidateTool(config),
|
|
585
|
+
];
|
|
586
|
+
}
|