kibi-mcp 0.15.3 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/diagnostics.js +1 -1
- package/dist/server/docs.js +9 -7
- package/dist/server/tools.js +17 -0
- package/dist/tools/autopilot-discovery.js +1 -1
- package/dist/tools/autopilot-generate.js +39 -17
- package/dist/tools/model-requirement.js +10 -0
- package/dist/tools/sparql.js +96 -0
- package/dist/tools/suggest-predicates.js +557 -0
- package/dist/tools/upsert.js +210 -75
- package/dist/tools/validate-upsert.js +37 -0
- package/dist/tools-config.js +121 -11
- package/package.json +3 -3
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { parseEntityFromList, parseListOfLists } from "kibi-cli/prolog/codec";
|
|
3
|
+
const DEFAULT_MIN_SCORE = 0.35;
|
|
4
|
+
const DEFAULT_MAX_CANDIDATES = 5;
|
|
5
|
+
const BUILT_IN_PREDICATE_SCHEMAS = [
|
|
6
|
+
{
|
|
7
|
+
id: "FACT-SCHEMA-STATE",
|
|
8
|
+
predicate_name: "state",
|
|
9
|
+
title: "State assertion",
|
|
10
|
+
description: "A subject has or enters a named state.",
|
|
11
|
+
argument_names: ["subject", "state"],
|
|
12
|
+
argument_types: ["entity", "state"],
|
|
13
|
+
keywords: ["state", "mode", "idle", "active", "draft", "edit"],
|
|
14
|
+
examples: ["state(editor.annotation, idle)"],
|
|
15
|
+
tags: ["state", "workflow"],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "FACT-SCHEMA-TRANSITION",
|
|
19
|
+
predicate_name: "transition",
|
|
20
|
+
title: "State transition",
|
|
21
|
+
description: "A subject transitions between states because of a trigger.",
|
|
22
|
+
argument_names: ["subject", "from_state", "to_state", "trigger"],
|
|
23
|
+
argument_types: ["entity", "state", "state", "trigger"],
|
|
24
|
+
keywords: [
|
|
25
|
+
"transition",
|
|
26
|
+
"enter",
|
|
27
|
+
"leave",
|
|
28
|
+
"idle",
|
|
29
|
+
"navigate",
|
|
30
|
+
"cancel",
|
|
31
|
+
"escape",
|
|
32
|
+
],
|
|
33
|
+
examples: ["transition(editor.annotation, draft, idle, navigation)"],
|
|
34
|
+
tags: ["state", "workflow"],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "FACT-SCHEMA-GUARD",
|
|
38
|
+
predicate_name: "guard",
|
|
39
|
+
title: "Behavior guard",
|
|
40
|
+
description: "A condition gates or forbids behavior for a subject.",
|
|
41
|
+
argument_names: ["subject", "condition", "expected"],
|
|
42
|
+
argument_types: ["entity", "condition", "boolean"],
|
|
43
|
+
keywords: ["guard", "unless", "readonly", "scrubbing"],
|
|
44
|
+
examples: ["guard(editor.annotation, isReadOnly, false)"],
|
|
45
|
+
tags: ["guard", "workflow"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "FACT-SCHEMA-HAS-UNSAVED-CHANGES",
|
|
49
|
+
predicate_name: "has_unsaved_changes",
|
|
50
|
+
title: "Unsaved change state",
|
|
51
|
+
description: "A subject has unsaved or dirty local edits.",
|
|
52
|
+
argument_names: ["subject", "expected"],
|
|
53
|
+
argument_types: ["entity", "boolean"],
|
|
54
|
+
keywords: ["unsaved", "dirty", "draft", "edits", "changes"],
|
|
55
|
+
examples: ["has_unsaved_changes(editor.annotation, true)"],
|
|
56
|
+
tags: ["state", "persistence"],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "FACT-SCHEMA-COMMIT-ACTION",
|
|
60
|
+
predicate_name: "commit_action",
|
|
61
|
+
title: "Commit or save action",
|
|
62
|
+
description: "A trigger commits, saves, or persists a subject within a scope.",
|
|
63
|
+
argument_names: ["subject", "trigger", "scope"],
|
|
64
|
+
argument_types: ["entity", "trigger", "scope"],
|
|
65
|
+
keywords: [
|
|
66
|
+
"save",
|
|
67
|
+
"saves",
|
|
68
|
+
"saved",
|
|
69
|
+
"auto-save",
|
|
70
|
+
"autosave",
|
|
71
|
+
"commit",
|
|
72
|
+
"persist",
|
|
73
|
+
"navigation",
|
|
74
|
+
"navigates",
|
|
75
|
+
"draft",
|
|
76
|
+
],
|
|
77
|
+
examples: ["commit_action(editor.annotation, navigation, draft)"],
|
|
78
|
+
tags: ["persistence", "workflow"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "FACT-SCHEMA-DISCARD-ACTION",
|
|
82
|
+
predicate_name: "discard_action",
|
|
83
|
+
title: "Discard action",
|
|
84
|
+
description: "A trigger discards or cancels changes for a subject within a scope.",
|
|
85
|
+
argument_names: ["subject", "trigger", "scope"],
|
|
86
|
+
argument_types: ["entity", "trigger", "scope"],
|
|
87
|
+
keywords: ["discard", "cancel", "escape", "revert", "without save"],
|
|
88
|
+
examples: ["discard_action(editor.annotation, escape, active_annotation)"],
|
|
89
|
+
tags: ["persistence", "workflow"],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "FACT-SCHEMA-ACCESSIBILITY",
|
|
93
|
+
predicate_name: "accessibility_requirement",
|
|
94
|
+
title: "Accessibility requirement",
|
|
95
|
+
description: "A subject must satisfy an accessibility standard or severity target.",
|
|
96
|
+
argument_names: ["subject", "standard", "severity"],
|
|
97
|
+
argument_types: ["entity", "standard", "severity"],
|
|
98
|
+
keywords: ["accessibility", "a11y", "wcag", "keyboard", "screen reader"],
|
|
99
|
+
examples: ["accessibility_requirement(game.flow, WCAG, high)"],
|
|
100
|
+
tags: ["accessibility", "quality"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "FACT-SCHEMA-RETENTION-POLICY",
|
|
104
|
+
predicate_name: "retention_policy",
|
|
105
|
+
title: "Retention policy",
|
|
106
|
+
description: "A subject is retained for a bounded duration.",
|
|
107
|
+
argument_names: ["subject", "duration", "unit"],
|
|
108
|
+
argument_types: ["entity", "number", "unit"],
|
|
109
|
+
keywords: ["retain", "retained", "retention", "days", "months", "years"],
|
|
110
|
+
examples: ["retention_policy(customer.data, 7, years)"],
|
|
111
|
+
tags: ["data", "policy"],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "FACT-SCHEMA-RESOURCE-CONSTRAINT",
|
|
115
|
+
predicate_name: "resource_constraint",
|
|
116
|
+
title: "Resource constraint",
|
|
117
|
+
description: "A subject constrains a resource by operator, threshold, and unit.",
|
|
118
|
+
argument_names: ["subject", "resource", "operator", "threshold", "unit"],
|
|
119
|
+
argument_types: ["entity", "resource", "operator", "number", "unit"],
|
|
120
|
+
keywords: ["limit", "maximum", "minimum", "latency", "timeout", "size"],
|
|
121
|
+
examples: ["resource_constraint(api.search, latency, lte, 200, ms)"],
|
|
122
|
+
tags: ["performance", "constraint"],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: "FACT-SCHEMA-FEATURE-GATE",
|
|
126
|
+
predicate_name: "feature_gate",
|
|
127
|
+
title: "Feature gate",
|
|
128
|
+
description: "A subject is controlled by a runtime or configuration gate.",
|
|
129
|
+
argument_names: ["subject", "gate", "expected"],
|
|
130
|
+
argument_types: ["entity", "flag", "boolean"],
|
|
131
|
+
keywords: ["flag", "feature gate", "enabled", "disabled", "kill switch"],
|
|
132
|
+
examples: ["feature_gate(checkout.v2, checkoutV2Enabled, true)"],
|
|
133
|
+
tags: ["flag", "runtime"],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: "FACT-SCHEMA-EVENT-PUBLISH",
|
|
137
|
+
predicate_name: "publishes_event",
|
|
138
|
+
title: "Event publication",
|
|
139
|
+
description: "A subject publishes a domain or system event.",
|
|
140
|
+
argument_names: ["subject", "event"],
|
|
141
|
+
argument_types: ["entity", "event"],
|
|
142
|
+
keywords: ["publish", "publishes", "emit", "emits", "event"],
|
|
143
|
+
examples: ["publishes_event(order.checkout, OrderSubmitted)"],
|
|
144
|
+
tags: ["event", "architecture"],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "FACT-SCHEMA-ACCEPTANCE-RULE",
|
|
148
|
+
predicate_name: "acceptance_rule",
|
|
149
|
+
title: "Acceptance rule",
|
|
150
|
+
description: "A subject has an observable acceptance outcome.",
|
|
151
|
+
argument_names: ["subject", "outcome"],
|
|
152
|
+
argument_types: ["entity", "outcome"],
|
|
153
|
+
keywords: [
|
|
154
|
+
"acceptance",
|
|
155
|
+
"observable",
|
|
156
|
+
"outcome",
|
|
157
|
+
"must show",
|
|
158
|
+
"must display",
|
|
159
|
+
],
|
|
160
|
+
examples: ["acceptance_rule(search.results, shows_empty_state)"],
|
|
161
|
+
tags: ["acceptance", "quality"],
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
function normalizeText(text) {
|
|
165
|
+
const normalized = String(text ?? "").trim();
|
|
166
|
+
if (!normalized) {
|
|
167
|
+
throw new Error("Predicate suggestion failed: text must be a non-empty string");
|
|
168
|
+
}
|
|
169
|
+
return normalized;
|
|
170
|
+
}
|
|
171
|
+
function normalizeOptionalString(value) {
|
|
172
|
+
const normalized = String(value ?? "").trim();
|
|
173
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
174
|
+
}
|
|
175
|
+
function clampInteger(value, fallback, min, max) {
|
|
176
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
177
|
+
return Math.min(max, Math.max(min, Math.trunc(numeric)));
|
|
178
|
+
}
|
|
179
|
+
function clampScore(value) {
|
|
180
|
+
const numeric = typeof value === "number" && Number.isFinite(value)
|
|
181
|
+
? value
|
|
182
|
+
: DEFAULT_MIN_SCORE;
|
|
183
|
+
return Math.min(1, Math.max(0, numeric));
|
|
184
|
+
}
|
|
185
|
+
function slug(value) {
|
|
186
|
+
return value
|
|
187
|
+
.trim()
|
|
188
|
+
.toLowerCase()
|
|
189
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
190
|
+
.replace(/^_+|_+$/g, "");
|
|
191
|
+
}
|
|
192
|
+
function hashId(prefix, parts) {
|
|
193
|
+
const digest = createHash("sha256")
|
|
194
|
+
.update(parts.join("\u0000"))
|
|
195
|
+
.digest("hex")
|
|
196
|
+
.slice(0, 12)
|
|
197
|
+
.toUpperCase();
|
|
198
|
+
return `${prefix}-${digest}`;
|
|
199
|
+
}
|
|
200
|
+
function inferSubject(text, subjectHint) {
|
|
201
|
+
const explicit = normalizeOptionalString(subjectHint);
|
|
202
|
+
if (explicit)
|
|
203
|
+
return explicit;
|
|
204
|
+
const lower = text.toLowerCase();
|
|
205
|
+
if (lower.includes("annotation"))
|
|
206
|
+
return "editor.annotation";
|
|
207
|
+
if (lower.includes("editor"))
|
|
208
|
+
return "editor";
|
|
209
|
+
if (lower.includes("session"))
|
|
210
|
+
return "session";
|
|
211
|
+
if (lower.includes("customer data"))
|
|
212
|
+
return "customer.data";
|
|
213
|
+
if (lower.includes("user"))
|
|
214
|
+
return "user";
|
|
215
|
+
return "requirement.subject";
|
|
216
|
+
}
|
|
217
|
+
function inferTrigger(text) {
|
|
218
|
+
const lower = text.toLowerCase();
|
|
219
|
+
if (lower.includes("navigate"))
|
|
220
|
+
return "navigation";
|
|
221
|
+
if (lower.includes("escape"))
|
|
222
|
+
return "escape";
|
|
223
|
+
if (lower.includes("cancel"))
|
|
224
|
+
return "cancel";
|
|
225
|
+
if (lower.includes("submit"))
|
|
226
|
+
return "submit";
|
|
227
|
+
return "unspecified_trigger";
|
|
228
|
+
}
|
|
229
|
+
function inferScope(text) {
|
|
230
|
+
const lower = text.toLowerCase();
|
|
231
|
+
if (lower.includes("draft"))
|
|
232
|
+
return "draft";
|
|
233
|
+
if (lower.includes("annotation"))
|
|
234
|
+
return "active_annotation";
|
|
235
|
+
if (lower.includes("session"))
|
|
236
|
+
return "session";
|
|
237
|
+
return "subject";
|
|
238
|
+
}
|
|
239
|
+
function inferArgs(schema, text, subject) {
|
|
240
|
+
const lower = text.toLowerCase();
|
|
241
|
+
switch (schema.predicate_name) {
|
|
242
|
+
case "state":
|
|
243
|
+
return [subject, lower.includes("idle") ? "idle" : "active"];
|
|
244
|
+
case "transition":
|
|
245
|
+
return [
|
|
246
|
+
subject,
|
|
247
|
+
lower.includes("edit") ? "edit" : "draft",
|
|
248
|
+
lower.includes("idle") ? "idle" : "active",
|
|
249
|
+
inferTrigger(text),
|
|
250
|
+
];
|
|
251
|
+
case "guard":
|
|
252
|
+
return [
|
|
253
|
+
subject,
|
|
254
|
+
lower.includes("readonly") ? "isReadOnly" : "condition",
|
|
255
|
+
"true",
|
|
256
|
+
];
|
|
257
|
+
case "has_unsaved_changes":
|
|
258
|
+
return [subject, lower.includes("no unsaved") ? "false" : "true"];
|
|
259
|
+
case "commit_action":
|
|
260
|
+
case "discard_action":
|
|
261
|
+
return [subject, inferTrigger(text), inferScope(text)];
|
|
262
|
+
case "accessibility_requirement":
|
|
263
|
+
return [
|
|
264
|
+
subject,
|
|
265
|
+
lower.includes("wcag") ? "WCAG" : "accessibility",
|
|
266
|
+
"required",
|
|
267
|
+
];
|
|
268
|
+
case "retention_policy":
|
|
269
|
+
return [subject, inferDuration(text), inferDurationUnit(text)];
|
|
270
|
+
case "resource_constraint":
|
|
271
|
+
return [
|
|
272
|
+
subject,
|
|
273
|
+
inferResource(text),
|
|
274
|
+
inferOperator(text),
|
|
275
|
+
inferNumber(text),
|
|
276
|
+
inferUnit(text),
|
|
277
|
+
];
|
|
278
|
+
case "feature_gate":
|
|
279
|
+
return [
|
|
280
|
+
subject,
|
|
281
|
+
inferGate(text),
|
|
282
|
+
lower.includes("disabled") ? "false" : "true",
|
|
283
|
+
];
|
|
284
|
+
case "publishes_event":
|
|
285
|
+
return [subject, inferEvent(text)];
|
|
286
|
+
case "acceptance_rule":
|
|
287
|
+
return [subject, slug(text).slice(0, 64) || "observable_outcome"];
|
|
288
|
+
default:
|
|
289
|
+
return schema.argument_names.map((name) => name === "subject" ? subject : "unknown");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function inferDuration(text) {
|
|
293
|
+
return text.match(/\b\d+\b/)?.[0] ?? "1";
|
|
294
|
+
}
|
|
295
|
+
function inferDurationUnit(text) {
|
|
296
|
+
const lower = text.toLowerCase();
|
|
297
|
+
if (lower.includes("year"))
|
|
298
|
+
return "years";
|
|
299
|
+
if (lower.includes("month"))
|
|
300
|
+
return "months";
|
|
301
|
+
if (lower.includes("day"))
|
|
302
|
+
return "days";
|
|
303
|
+
return "unit";
|
|
304
|
+
}
|
|
305
|
+
function inferResource(text) {
|
|
306
|
+
const lower = text.toLowerCase();
|
|
307
|
+
if (lower.includes("latency"))
|
|
308
|
+
return "latency";
|
|
309
|
+
if (lower.includes("timeout"))
|
|
310
|
+
return "timeout";
|
|
311
|
+
if (lower.includes("size"))
|
|
312
|
+
return "size";
|
|
313
|
+
return "resource";
|
|
314
|
+
}
|
|
315
|
+
function inferOperator(text) {
|
|
316
|
+
const lower = text.toLowerCase();
|
|
317
|
+
if (lower.includes("minimum") || lower.includes("at least"))
|
|
318
|
+
return "gte";
|
|
319
|
+
if (lower.includes("not exceed") ||
|
|
320
|
+
lower.includes("not be more than") ||
|
|
321
|
+
lower.includes("no more than") ||
|
|
322
|
+
lower.includes("at most") ||
|
|
323
|
+
lower.includes("maximum")) {
|
|
324
|
+
return "lte";
|
|
325
|
+
}
|
|
326
|
+
if (lower.includes("not"))
|
|
327
|
+
return "neq";
|
|
328
|
+
return "lte";
|
|
329
|
+
}
|
|
330
|
+
function inferNumber(text) {
|
|
331
|
+
return text.match(/\b\d+(?:\.\d+)?\b/)?.[0] ?? "0";
|
|
332
|
+
}
|
|
333
|
+
function inferUnit(text) {
|
|
334
|
+
const lower = text.toLowerCase();
|
|
335
|
+
if (lower.includes("ms"))
|
|
336
|
+
return "ms";
|
|
337
|
+
if (lower.includes("seconds"))
|
|
338
|
+
return "seconds";
|
|
339
|
+
if (lower.includes("mb"))
|
|
340
|
+
return "mb";
|
|
341
|
+
return "unit";
|
|
342
|
+
}
|
|
343
|
+
function inferGate(text) {
|
|
344
|
+
const quoted = text.match(/[`'"](?<gate>[A-Za-z0-9_.:-]+)[`'"]/)?.groups
|
|
345
|
+
?.gate;
|
|
346
|
+
return quoted ?? "feature_gate";
|
|
347
|
+
}
|
|
348
|
+
function inferEvent(text) {
|
|
349
|
+
const eventName = text.match(/\b[A-Z][A-Za-z0-9]+Event\b/)?.[0];
|
|
350
|
+
return eventName ?? "domain_event";
|
|
351
|
+
}
|
|
352
|
+
function scoreSchema(schema, text) {
|
|
353
|
+
const lower = text.toLowerCase();
|
|
354
|
+
const keywordHits = schema.keywords.filter((keyword) => lower.includes(keyword.toLowerCase())).length;
|
|
355
|
+
if (keywordHits === 0)
|
|
356
|
+
return 0;
|
|
357
|
+
const normalized = keywordHits / Math.max(3, schema.keywords.length / 2);
|
|
358
|
+
const score = Math.min(0.98, 0.24 + normalized * 0.5 + keywordHits * 0.06);
|
|
359
|
+
return Math.round(score * 100) / 100;
|
|
360
|
+
}
|
|
361
|
+
function schemaForCandidate(schema) {
|
|
362
|
+
return {
|
|
363
|
+
id: schema.id,
|
|
364
|
+
predicate_name: schema.predicate_name,
|
|
365
|
+
title: schema.title,
|
|
366
|
+
description: schema.description,
|
|
367
|
+
argument_names: schema.argument_names,
|
|
368
|
+
argument_types: schema.argument_types,
|
|
369
|
+
examples: schema.examples,
|
|
370
|
+
tags: schema.tags,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function loadExistingPredicateSchemas(prolog, includeExistingSchemas, warnings) {
|
|
374
|
+
if (!includeExistingSchemas || prolog === null) {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const queryResult = await prolog.query("findall([Id,'fact',Props], (kb_entity(Id, 'fact', Props), member(fact_kind=predicate_schema, Props)), Results)");
|
|
379
|
+
if (!queryResult.success) {
|
|
380
|
+
throw new Error(queryResult.error || "Query failed with unknown error");
|
|
381
|
+
}
|
|
382
|
+
const facts = queryResult.bindings.Results
|
|
383
|
+
? parseListOfLists(queryResult.bindings.Results).map(parseEntityFromList)
|
|
384
|
+
: [];
|
|
385
|
+
return facts.flatMap((fact) => predicateSchemaFromEntity(fact));
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
389
|
+
warnings.push(`Existing predicate_schema facts could not be loaded: ${message}`);
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function predicateSchemaFromEntity(entity) {
|
|
394
|
+
if (entity.fact_kind !== "predicate_schema")
|
|
395
|
+
return [];
|
|
396
|
+
const predicateName = normalizeOptionalString(typeof entity.predicate_name === "string"
|
|
397
|
+
? entity.predicate_name
|
|
398
|
+
: undefined);
|
|
399
|
+
if (!predicateName)
|
|
400
|
+
return [];
|
|
401
|
+
return [
|
|
402
|
+
{
|
|
403
|
+
id: String(entity.id ?? hashId("FACT-SCHEMA", [predicateName])),
|
|
404
|
+
predicate_name: predicateName,
|
|
405
|
+
title: String(entity.title ?? predicateName),
|
|
406
|
+
description: String(entity.description ??
|
|
407
|
+
`Project-local ${predicateName} predicate schema.`),
|
|
408
|
+
argument_names: stringArray(entity.argument_names),
|
|
409
|
+
argument_types: stringArray(entity.argument_types),
|
|
410
|
+
keywords: [
|
|
411
|
+
predicateName,
|
|
412
|
+
...stringArray(entity.aliases),
|
|
413
|
+
...stringArray(entity.tags),
|
|
414
|
+
],
|
|
415
|
+
examples: stringArray(entity.examples),
|
|
416
|
+
tags: stringArray(entity.tags),
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
function stringArray(value) {
|
|
421
|
+
if (!Array.isArray(value))
|
|
422
|
+
return [];
|
|
423
|
+
return value.flatMap((item) => {
|
|
424
|
+
const normalized = normalizeOptionalString(typeof item === "string" ? item : undefined);
|
|
425
|
+
return normalized ? [normalized] : [];
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function buildSuggestion(schema, text, subject, score) {
|
|
429
|
+
const predicateArgs = inferArgs(schema, text, subject);
|
|
430
|
+
const canonicalKey = `${schema.predicate_name}(${predicateArgs.join(",")})`;
|
|
431
|
+
return {
|
|
432
|
+
id: hashId("SUGGEST", [schema.id, canonicalKey, text]),
|
|
433
|
+
predicate_name: schema.predicate_name,
|
|
434
|
+
predicate_args: predicateArgs,
|
|
435
|
+
canonical_key: canonicalKey,
|
|
436
|
+
polarity: "assert",
|
|
437
|
+
score,
|
|
438
|
+
rationale: `Matched ${schema.predicate_name} because the prose overlaps with ${schema.tags.join(", ")} cues.`,
|
|
439
|
+
schema: schemaForCandidate(schema),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function buildPredicateApplyPlan(suggestion, args) {
|
|
443
|
+
const factId = hashId("FACT-PRED", [
|
|
444
|
+
args.requirementId ?? "",
|
|
445
|
+
args.source ?? "",
|
|
446
|
+
suggestion.canonical_key,
|
|
447
|
+
]);
|
|
448
|
+
return [
|
|
449
|
+
{
|
|
450
|
+
type: "fact",
|
|
451
|
+
id: factId,
|
|
452
|
+
properties: {
|
|
453
|
+
title: `Predicate: ${suggestion.canonical_key}`,
|
|
454
|
+
status: "active",
|
|
455
|
+
source: args.source ?? "mcp://kibi/suggest-predicates",
|
|
456
|
+
text_ref: args.source,
|
|
457
|
+
tags: [
|
|
458
|
+
"lane:ontology",
|
|
459
|
+
"predicate-suggestion",
|
|
460
|
+
...suggestion.schema.tags.map((tag) => `predicate:${tag}`),
|
|
461
|
+
],
|
|
462
|
+
fact_kind: "predicate",
|
|
463
|
+
predicate_name: suggestion.predicate_name,
|
|
464
|
+
predicate_args: suggestion.predicate_args,
|
|
465
|
+
canonical_key: suggestion.canonical_key,
|
|
466
|
+
polarity: suggestion.polarity,
|
|
467
|
+
},
|
|
468
|
+
relationships: [],
|
|
469
|
+
},
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
function buildRelationshipPlan(factId, requirementId) {
|
|
473
|
+
if (!factId || !requirementId)
|
|
474
|
+
return null;
|
|
475
|
+
return {
|
|
476
|
+
applyAfter: factId,
|
|
477
|
+
requiresExistingReq: requirementId,
|
|
478
|
+
relationship: {
|
|
479
|
+
type: "requires_predicate",
|
|
480
|
+
from: requirementId,
|
|
481
|
+
to: factId,
|
|
482
|
+
},
|
|
483
|
+
instructions: "Apply the predicate fact first, then attach this relationship from the existing requirement without overwriting requirement metadata.",
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function buildGapApplyPlan(text, args) {
|
|
487
|
+
const factId = hashId("FACT-ONTOLOGY-GAP", [
|
|
488
|
+
args.requirementId ?? "",
|
|
489
|
+
args.source ?? "",
|
|
490
|
+
text,
|
|
491
|
+
]);
|
|
492
|
+
return [
|
|
493
|
+
{
|
|
494
|
+
type: "fact",
|
|
495
|
+
id: factId,
|
|
496
|
+
properties: {
|
|
497
|
+
title: "Ontology gap: predicate schema needed",
|
|
498
|
+
status: "active",
|
|
499
|
+
source: args.source ?? "mcp://kibi/suggest-predicates",
|
|
500
|
+
text_ref: args.source,
|
|
501
|
+
tags: ["review:ontology-gap", "needs_schema_extension"],
|
|
502
|
+
fact_kind: "observation",
|
|
503
|
+
value_string: text,
|
|
504
|
+
},
|
|
505
|
+
relationships: [],
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
// implements REQ-mcp-suggest-predicates
|
|
510
|
+
export async function handleKbSuggestPredicates(prolog, args) {
|
|
511
|
+
const text = normalizeText(args.text);
|
|
512
|
+
const maxCandidates = clampInteger(args.maxCandidates, DEFAULT_MAX_CANDIDATES, 1, 20);
|
|
513
|
+
const minScore = clampScore(args.minScore);
|
|
514
|
+
const warnings = [];
|
|
515
|
+
const subject = inferSubject(text, args.subjectHint);
|
|
516
|
+
const existingSchemas = await loadExistingPredicateSchemas(prolog, args.includeExistingSchemas ?? true, warnings);
|
|
517
|
+
const schemas = [...existingSchemas, ...BUILT_IN_PREDICATE_SCHEMAS];
|
|
518
|
+
const candidates = schemas
|
|
519
|
+
.map((schema) => ({ schema, score: scoreSchema(schema, text) }))
|
|
520
|
+
.filter((scored) => scored.score >= minScore)
|
|
521
|
+
.sort((left, right) => {
|
|
522
|
+
if (right.score !== left.score)
|
|
523
|
+
return right.score - left.score;
|
|
524
|
+
return left.schema.predicate_name.localeCompare(right.schema.predicate_name);
|
|
525
|
+
})
|
|
526
|
+
.slice(0, maxCandidates)
|
|
527
|
+
.map((scored) => buildSuggestion(scored.schema, text, subject, scored.score));
|
|
528
|
+
if (candidates.length === 0) {
|
|
529
|
+
warnings.push("No predicate candidate met minScore. If this is recurring domain language, create a fact_kind=predicate_schema fact; otherwise keep the generated review:ontology-gap observation. Do not invent unsupported predicate names without a predicate_schema.");
|
|
530
|
+
}
|
|
531
|
+
const recommendedAction = candidates.length > 0 ? "apply_requires_predicate" : "record_ontology_gap";
|
|
532
|
+
const firstCandidate = candidates[0];
|
|
533
|
+
const applyPlan = firstCandidate
|
|
534
|
+
? buildPredicateApplyPlan(firstCandidate, args)
|
|
535
|
+
: buildGapApplyPlan(text, args);
|
|
536
|
+
const relationshipPlan = firstCandidate
|
|
537
|
+
? buildRelationshipPlan(String(applyPlan[0]?.id ?? ""), args.requirementId)
|
|
538
|
+
: null;
|
|
539
|
+
const textSummary = candidates.length > 0
|
|
540
|
+
? `Suggested ${candidates.length} predicate candidate(s). Top match: ${candidates[0]?.predicate_name}. Apply structured predicate facts before falling back to prose.`
|
|
541
|
+
: "No predicate candidate met the confidence threshold; record an ontology gap instead of silently writing prose.";
|
|
542
|
+
return {
|
|
543
|
+
content: [{ type: "text", text: textSummary }],
|
|
544
|
+
structuredContent: {
|
|
545
|
+
text,
|
|
546
|
+
source: args.source ?? null,
|
|
547
|
+
requirementId: args.requirementId ?? null,
|
|
548
|
+
subject,
|
|
549
|
+
candidates,
|
|
550
|
+
recommendedAction,
|
|
551
|
+
applyPlan,
|
|
552
|
+
relationshipPlan,
|
|
553
|
+
warnings,
|
|
554
|
+
},
|
|
555
|
+
applyPlan,
|
|
556
|
+
};
|
|
557
|
+
}
|