progmune-runtime 2.1.2 → 2.1.4
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/emitter.js +107 -40
- package/dist/planner.js +169 -19
- package/dist/semantic-topology.js +160 -0
- package/dist/strategy-planner.js +168 -0
- package/package.json +1 -1
package/dist/emitter.js
CHANGED
|
@@ -143,33 +143,73 @@ function emitCode(actions, meta) {
|
|
|
143
143
|
// Pre-scan: find input variables (referenced but never assigned via call/assign)
|
|
144
144
|
const declared = new Set();
|
|
145
145
|
const referenced = new Set();
|
|
146
|
+
// Track param name → type for conflict detection
|
|
147
|
+
const paramTypes = new Map();
|
|
146
148
|
let counter = 0;
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
+
// Recursive scan: collect variables from actions including nested if/for
|
|
150
|
+
const scanAction = (action) => {
|
|
149
151
|
if (action.kind === "call" && action.assignTo) {
|
|
150
152
|
declared.add(action.assignTo);
|
|
151
153
|
}
|
|
152
154
|
else if (action.kind === "assign" && action.target) {
|
|
153
155
|
declared.add(action.target);
|
|
154
156
|
}
|
|
155
|
-
// Collect arg value references AND empty-default params
|
|
156
157
|
if (action.kind === "call" && action.args) {
|
|
157
158
|
for (const arg of action.args) {
|
|
158
159
|
const v = typeof arg === "object" ? arg?.value : arg;
|
|
160
|
+
const n = typeof arg === "object" ? arg?.name : undefined;
|
|
161
|
+
const t = typeof arg === "object" ? arg?.type : "string";
|
|
159
162
|
if (typeof v === "string" && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v) && v !== "") {
|
|
160
163
|
referenced.add(v);
|
|
161
164
|
}
|
|
162
|
-
|
|
163
|
-
const isDefault = v
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
const isEmptyV = (val) => val === "" || val === 0 || val === false || val === null || (Array.isArray(val) && val.length === 0);
|
|
166
|
+
const isDefault = isEmptyV(v);
|
|
167
|
+
if (isDefault && n) {
|
|
168
|
+
// Track type for conflict detection
|
|
169
|
+
if (!paramTypes.has(n))
|
|
170
|
+
paramTypes.set(n, []);
|
|
171
|
+
if (!paramTypes.get(n).includes(t))
|
|
172
|
+
paramTypes.get(n).push(t);
|
|
173
|
+
referenced.add(n);
|
|
167
174
|
}
|
|
168
175
|
}
|
|
169
176
|
}
|
|
170
|
-
|
|
177
|
+
// Recursive: scan nested actions in if/for
|
|
178
|
+
if (action.kind === "if") {
|
|
179
|
+
(action.thenActions || []).forEach(scanAction);
|
|
180
|
+
(action.elseActions || []).forEach(scanAction);
|
|
181
|
+
}
|
|
182
|
+
else if (action.kind === "for") {
|
|
183
|
+
if (action.variable)
|
|
184
|
+
declared.add(action.variable);
|
|
185
|
+
(action.bodyActions || []).forEach(scanAction);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (const action of actions)
|
|
189
|
+
scanAction(action);
|
|
171
190
|
// Inputs = referenced but not declared — add as typed parameters
|
|
172
|
-
const
|
|
191
|
+
const rawInputs = [...referenced].filter(v => !declared.has(v) && v !== "");
|
|
192
|
+
// Resolve type conflicts: rename duplicate params with different types
|
|
193
|
+
const conflictNames = [...paramTypes.entries()]
|
|
194
|
+
.filter(([_, types]) => new Set(types).size > 1)
|
|
195
|
+
.map(([name]) => name);
|
|
196
|
+
const _resolved = [];
|
|
197
|
+
const renamed = new Map(); // oldName → newName
|
|
198
|
+
for (const name of rawInputs) {
|
|
199
|
+
if (conflictNames.includes(name) && paramTypes.get(name).length > 1) {
|
|
200
|
+
const types = [...new Set(paramTypes.get(name))]; // unique types
|
|
201
|
+
for (const t of types) {
|
|
202
|
+
const suffix = t.replace(/\[\]$/, "").toLowerCase();
|
|
203
|
+
const newName = `${name}_${suffix}`;
|
|
204
|
+
_resolved.push(newName);
|
|
205
|
+
renamed.set(name + ":" + t, newName);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
_resolved.push(name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const uniqueInputs = [...new Set(_resolved)];
|
|
173
213
|
const inputTypes = new Map();
|
|
174
214
|
for (const action of actions) {
|
|
175
215
|
if (action.kind === "call" && action.args) {
|
|
@@ -177,42 +217,59 @@ function emitCode(actions, meta) {
|
|
|
177
217
|
const v = typeof arg === "object" ? arg?.value : arg;
|
|
178
218
|
const n = typeof arg === "object" ? arg?.name : undefined;
|
|
179
219
|
const t = typeof arg === "object" ? arg?.type : "string";
|
|
180
|
-
//
|
|
181
|
-
|
|
220
|
+
// Check for renamed param (type conflict resolution)
|
|
221
|
+
const renameKey = n + ":" + t;
|
|
222
|
+
const resolvedName = renamed.get(renameKey) || n || "";
|
|
223
|
+
// Variable reference
|
|
224
|
+
if (typeof v === "string" && uniqueInputs.includes(v) && t && t !== "any") {
|
|
182
225
|
inputTypes.set(v, t);
|
|
183
226
|
}
|
|
184
|
-
// Empty default:
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
if (isDefault && n
|
|
227
|
+
// Empty default: use resolved name for type-conflicted params
|
|
228
|
+
const isEmptyV = (val) => val === "" || val === 0 || val === false || val === null || (Array.isArray(val) && val.length === 0);
|
|
229
|
+
const isDefault = isEmptyV(v);
|
|
230
|
+
if (isDefault && n) {
|
|
188
231
|
const cleanType = (t || "string").replace(/\[\]$/, "");
|
|
189
|
-
|
|
232
|
+
const targetName = (resolvedName !== n) ? resolvedName : n;
|
|
233
|
+
if (uniqueInputs.includes(targetName) && !inputTypes.has(targetName)) {
|
|
234
|
+
inputTypes.set(targetName, cleanType);
|
|
235
|
+
}
|
|
236
|
+
else if (uniqueInputs.includes(n) && !inputTypes.has(n)) {
|
|
190
237
|
inputTypes.set(n, cleanType);
|
|
238
|
+
}
|
|
191
239
|
}
|
|
192
240
|
}
|
|
193
241
|
}
|
|
194
242
|
}
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
// Parameter bloat threshold: refuse if too many params
|
|
244
|
+
const MAX_PARAMS = 10;
|
|
245
|
+
if (uniqueInputs.length > MAX_PARAMS) {
|
|
246
|
+
return `// REFINEMENT_NEEDED: ${uniqueInputs.length} params detected (max ${MAX_PARAMS}). Split into smaller functions or use an options object.`;
|
|
247
|
+
}
|
|
248
|
+
// Refinement: collect empty defaults for Planner feedback
|
|
249
|
+
const emptyDefaults = [];
|
|
250
|
+
for (let i = 0; i < actions.length; i++) {
|
|
251
|
+
const action = actions[i];
|
|
252
|
+
if (action.kind === "call" && action.args) {
|
|
253
|
+
for (const arg of action.args) {
|
|
254
|
+
const v = typeof arg === "object" ? arg?.value : arg;
|
|
255
|
+
const isEmptyV = (val) => val === "" || val === 0 || val === false || val === null || (Array.isArray(val) && val.length === 0);
|
|
256
|
+
const isDefault = isEmptyV(v);
|
|
257
|
+
if (isDefault && typeof arg === "object" && arg.name) {
|
|
258
|
+
emptyDefaults.push({
|
|
259
|
+
actionIndex: i,
|
|
260
|
+
function: action.function || "unknown",
|
|
261
|
+
param: arg.name,
|
|
262
|
+
type: (typeof arg === "object" ? arg.type : "string") || "string",
|
|
263
|
+
});
|
|
211
264
|
}
|
|
212
265
|
}
|
|
213
266
|
}
|
|
214
267
|
}
|
|
215
|
-
|
|
268
|
+
// Store for Planner feedback (accessible via global)
|
|
269
|
+
if (emptyDefaults.length > 0) {
|
|
270
|
+
globalThis.__progmune_empty_defaults = emptyDefaults;
|
|
271
|
+
}
|
|
272
|
+
const paramList = uniqueInputs.map(v => `${v}: ${inputTypes.get(v) || "string"}`).join(", ");
|
|
216
273
|
code += `\nexport function main(${paramList}) {\n`;
|
|
217
274
|
const convert = (action, indent = " ") => {
|
|
218
275
|
if (!action || !action.kind)
|
|
@@ -233,11 +290,21 @@ function emitCode(actions, meta) {
|
|
|
233
290
|
return val;
|
|
234
291
|
}
|
|
235
292
|
// Empty default value → use function's parameter name (input parameter)
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
if (isDefault && a?.name
|
|
239
|
-
|
|
240
|
-
|
|
293
|
+
const isEmptyVal = (v) => v === "" || v === 0 || v === false || v === null || (Array.isArray(v) && v.length === 0);
|
|
294
|
+
const isDefault = isEmptyVal(val);
|
|
295
|
+
if (isDefault && a?.name) {
|
|
296
|
+
// Check for renamed param (type conflict resolution)
|
|
297
|
+
const paramType = meta?.params?.[i]?.type || "string";
|
|
298
|
+
const renameKey = a.name + ":" + paramType;
|
|
299
|
+
const resolvedName = renamed.get(renameKey) || a.name;
|
|
300
|
+
if (uniqueInputs.includes(resolvedName)) {
|
|
301
|
+
declared.add(resolvedName);
|
|
302
|
+
return resolvedName;
|
|
303
|
+
}
|
|
304
|
+
else if (uniqueInputs.includes(a.name)) {
|
|
305
|
+
declared.add(a.name);
|
|
306
|
+
return a.name;
|
|
307
|
+
}
|
|
241
308
|
}
|
|
242
309
|
const paramType = meta?.params?.[i]?.type || "any";
|
|
243
310
|
if (BASIC_TYPES.has(paramType)) {
|
|
@@ -328,7 +395,7 @@ function emitCode(actions, meta) {
|
|
|
328
395
|
}
|
|
329
396
|
code += "}\n";
|
|
330
397
|
// Call main with params if it has inputs, otherwise no-arg call
|
|
331
|
-
if (
|
|
398
|
+
if (uniqueInputs.length > 0) {
|
|
332
399
|
// Don't call — the user provides the arguments
|
|
333
400
|
// Just export the function for external use
|
|
334
401
|
}
|
package/dist/planner.js
CHANGED
|
@@ -39,12 +39,15 @@ const runtime_types_1 = require("./runtime-types");
|
|
|
39
39
|
const action_runtime_1 = require("./action-runtime");
|
|
40
40
|
const validator_1 = require("./validator");
|
|
41
41
|
const semantic_validator_1 = require("./semantic-validator");
|
|
42
|
+
const feedback_1 = require("./feedback");
|
|
42
43
|
const utils_1 = require("./utils");
|
|
43
44
|
const failure_corpus_1 = require("./failure-corpus");
|
|
44
45
|
const memory_layer_1 = require("./memory-layer");
|
|
45
46
|
const ssg_validator_1 = require("./ssg-validator");
|
|
46
47
|
const protocol_registry_1 = require("./protocol-registry");
|
|
47
48
|
const semantic_snapshot_1 = require("./semantic-snapshot");
|
|
49
|
+
const strategy_planner_1 = require("./strategy-planner");
|
|
50
|
+
const semantic_topology_1 = require("./semantic-topology");
|
|
48
51
|
const fs = __importStar(require("fs"));
|
|
49
52
|
function enrichActions(actions, ir) {
|
|
50
53
|
return actions.map(a => {
|
|
@@ -104,10 +107,12 @@ function buildCompactFuncList(funcs, allFuncs) {
|
|
|
104
107
|
return def ? `${p.name}: ${def}` : `${p.name}: ${p.type}`;
|
|
105
108
|
}).join(",");
|
|
106
109
|
let line = `${f.name}(${params})->${f.returnType || "any"}`;
|
|
107
|
-
// Add capability metadata
|
|
110
|
+
// Add capability metadata + score
|
|
108
111
|
const meta = [];
|
|
112
|
+
if (f.score && f.score > 0)
|
|
113
|
+
meta.push(`★${f.score.toFixed(1)}`);
|
|
109
114
|
if (f.purpose)
|
|
110
|
-
meta.push(f.purpose.slice(0,
|
|
115
|
+
meta.push(f.purpose.slice(0, 50));
|
|
111
116
|
if (f.produces && f.produces.length > 0)
|
|
112
117
|
meta.push(`→${f.produces.join(",")}`);
|
|
113
118
|
if (meta.length > 0)
|
|
@@ -115,17 +120,40 @@ function buildCompactFuncList(funcs, allFuncs) {
|
|
|
115
120
|
return line;
|
|
116
121
|
}).join("\n");
|
|
117
122
|
}
|
|
123
|
+
/** Semantic matching: check if two capability labels are related.
|
|
124
|
+
* Uses SemanticTopology (structural graph) instead of string matching. */
|
|
125
|
+
function semanticMatch(a, b) {
|
|
126
|
+
try {
|
|
127
|
+
const topo = (0, semantic_topology_1.getTopology)();
|
|
128
|
+
if (topo.size > 0)
|
|
129
|
+
return topo.capabilityMatch(a, b);
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
// Fallback: exact + substring
|
|
133
|
+
if (a === b)
|
|
134
|
+
return true;
|
|
135
|
+
if (a.includes(b) || b.includes(a))
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
118
139
|
/** Build capability chain hints from IR: producer→consumer relationships.
|
|
119
|
-
*
|
|
140
|
+
* Uses semantic matching for fuzzy capability linking. */
|
|
120
141
|
function buildChainHints(funcs) {
|
|
121
142
|
const chains = [];
|
|
143
|
+
const seen = new Set();
|
|
122
144
|
for (const f of funcs) {
|
|
123
145
|
if (!f.produces)
|
|
124
146
|
continue;
|
|
125
147
|
for (const p of f.produces) {
|
|
126
|
-
const consumers = funcs.filter((x) => x.
|
|
148
|
+
const consumers = funcs.filter((x) => x.name !== f.name &&
|
|
149
|
+
(x.requires || []).some((r) => semanticMatch(p, r)));
|
|
127
150
|
for (const c of consumers) {
|
|
128
|
-
|
|
151
|
+
const key = `${f.name}→${c.name}`;
|
|
152
|
+
if (seen.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
const matchedReq = (c.requires || []).find((r) => semanticMatch(p, r));
|
|
156
|
+
chains.push(`${f.name}()→${c.name}() // ${p} ≈ ${matchedReq || "?"}`);
|
|
129
157
|
}
|
|
130
158
|
}
|
|
131
159
|
}
|
|
@@ -528,6 +556,11 @@ async function plan(userIntent) {
|
|
|
528
556
|
const irRaw = JSON.parse(fs.readFileSync("ir.json", "utf-8"));
|
|
529
557
|
// Support both old (array) and new ({typeMap, functions}) formats
|
|
530
558
|
const ir = Array.isArray(irRaw) ? irRaw : (irRaw.functions || []);
|
|
559
|
+
// P1: Build Semantic Topology (once per plan call, cached)
|
|
560
|
+
try {
|
|
561
|
+
(0, semantic_topology_1.rebuildTopology)(ir);
|
|
562
|
+
}
|
|
563
|
+
catch { }
|
|
531
564
|
// Helper: wrap actions into PlanResult
|
|
532
565
|
let repairMetrics = { applied: false, count: 0, branchIds: [] };
|
|
533
566
|
const wrapResult = (actions, repair) => ({
|
|
@@ -688,17 +721,33 @@ async function plan(userIntent) {
|
|
|
688
721
|
score += 0.3;
|
|
689
722
|
}
|
|
690
723
|
}
|
|
691
|
-
// Capability Graph: requires/produces
|
|
724
|
+
// Capability Graph: semantic requires/produces matching
|
|
692
725
|
if (f.produces) {
|
|
693
726
|
for (const p of f.produces) {
|
|
694
|
-
|
|
727
|
+
const pText = p.toLowerCase().replace(/_/g, " ");
|
|
728
|
+
// Exact match
|
|
729
|
+
if (intentLower.includes(pText)) {
|
|
695
730
|
score += 1.5;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
// Semantic: word overlap
|
|
734
|
+
const pWords = pText.split(/\s+/);
|
|
735
|
+
const matchCount = pWords.filter((w) => intentLower.includes(w)).length;
|
|
736
|
+
if (matchCount > 0)
|
|
737
|
+
score += matchCount * 0.5;
|
|
696
738
|
}
|
|
697
739
|
}
|
|
698
740
|
if (f.requires) {
|
|
699
741
|
for (const r of f.requires) {
|
|
700
|
-
|
|
742
|
+
const rText = r.toLowerCase().replace(/_/g, " ");
|
|
743
|
+
if (intentLower.includes(rText)) {
|
|
701
744
|
score += 0.5;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const rWords = rText.split(/\s+/);
|
|
748
|
+
const matchCount = rWords.filter((w) => intentLower.includes(w)).length;
|
|
749
|
+
if (matchCount > 0)
|
|
750
|
+
score += matchCount * 0.2;
|
|
702
751
|
}
|
|
703
752
|
}
|
|
704
753
|
// Capability Graph: tag match
|
|
@@ -708,11 +757,32 @@ async function plan(userIntent) {
|
|
|
708
757
|
score += 0.8;
|
|
709
758
|
}
|
|
710
759
|
}
|
|
760
|
+
// Dynamic Credit: multiply by actual success rate (0.1-1.0)
|
|
761
|
+
const successRate = (0, feedback_1.getFunctionSuccessRate)(f.name);
|
|
762
|
+
const creditFactor = 0.3 + successRate * 0.7; // range: 0.3 (always fail) to 1.0 (always succeed)
|
|
763
|
+
if (f.exported && !f.external)
|
|
764
|
+
score *= creditFactor;
|
|
711
765
|
return { ...f, score };
|
|
712
766
|
});
|
|
713
767
|
scored.sort((a, b) => b.score - a.score);
|
|
714
768
|
const topFuncs = scored.slice(0, 15);
|
|
715
|
-
|
|
769
|
+
// Strategy Layer: select capability chain (local, 0 LLM calls)
|
|
770
|
+
const chains = (0, strategy_planner_1.selectCapabilityChains)(userIntent, ir, 3);
|
|
771
|
+
const strategyHint = (0, strategy_planner_1.formatChainHint)(chains);
|
|
772
|
+
// Action Layer: filter functions to those in selected chains
|
|
773
|
+
let chainFuncs = topFuncs;
|
|
774
|
+
if (chains.length > 0) {
|
|
775
|
+
const chainNames = new Set();
|
|
776
|
+
for (const c of chains.slice(0, 2)) {
|
|
777
|
+
for (const n of c.nodes)
|
|
778
|
+
chainNames.add(n.name);
|
|
779
|
+
}
|
|
780
|
+
// Prioritize chain functions: keep them first, add others as fallback
|
|
781
|
+
const inChain = topFuncs.filter((f) => chainNames.has(f.name));
|
|
782
|
+
const outChain = topFuncs.filter((f) => !chainNames.has(f.name));
|
|
783
|
+
chainFuncs = [...inChain, ...outChain].slice(0, 15);
|
|
784
|
+
}
|
|
785
|
+
const compactFuncList = buildCompactFuncList(chainFuncs, ir);
|
|
716
786
|
const chainHints = buildChainHints(topFuncs);
|
|
717
787
|
// Known string-enum types: tell LLM these are strings, not objects
|
|
718
788
|
const STRING_ENUMS = {
|
|
@@ -734,7 +804,7 @@ async function plan(userIntent) {
|
|
|
734
804
|
}
|
|
735
805
|
const protocolChainHint = buildProtocolChainHint(protocols);
|
|
736
806
|
const userPrompt = `可用函数:
|
|
737
|
-
${compactFuncList}${protocolChainHint}${chainHints}${typeHints}
|
|
807
|
+
${compactFuncList}${protocolChainHint}${chainHints}${typeHints}${strategyHint}
|
|
738
808
|
|
|
739
809
|
需求:${userIntent}${antibodyHint}
|
|
740
810
|
|
|
@@ -874,20 +944,55 @@ ${RETRY_HINT}
|
|
|
874
944
|
const enriched = enrichActions(rawActions, ir);
|
|
875
945
|
const filtered = enriched.filter(a => !forbiddenFuncs.includes(a.kind === "call" ? a.function : ''));
|
|
876
946
|
let ssgTransitions = [];
|
|
947
|
+
// 0) Hard Constraint Pre-check: local scan before full validation
|
|
948
|
+
// Saves LLM tokens by detecting obvious violations first
|
|
949
|
+
const preCheckRules = new Map();
|
|
950
|
+
for (const p of protocols)
|
|
951
|
+
preCheckRules.set(p.function, p.protocol);
|
|
952
|
+
const preCheckErrors = [];
|
|
953
|
+
for (let ai = 0; ai < filtered.length; ai++) {
|
|
954
|
+
const a = filtered[ai];
|
|
955
|
+
if (a.kind !== "call" || !a.function)
|
|
956
|
+
continue;
|
|
957
|
+
const def = ir.find((f) => f.name === a.function);
|
|
958
|
+
if (!def) {
|
|
959
|
+
preCheckErrors.push(`${a.function}: 函数不存在于 IR`);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (def.params && a.args && a.args.length !== def.params.length) {
|
|
963
|
+
preCheckErrors.push(`${a.function}: 参数数量错误 (期望${def.params.length}, 实际${a.args.length})`);
|
|
964
|
+
}
|
|
965
|
+
// Protocol pre-check: verify function is callable in current namespace state
|
|
966
|
+
if (def.protocol && preCheckRules.size > 0) {
|
|
967
|
+
const ctx = { ledger: [], currentState: (0, ssg_validator_1.rebuildState)([], namespaceInitialStates) };
|
|
968
|
+
const { valid, rejection } = (0, ssg_validator_1.validateTransition)(ctx, a.function, ai, preCheckRules, namespaceInitialStates);
|
|
969
|
+
if (!valid && rejection) {
|
|
970
|
+
preCheckErrors.push(`${a.function}: 协议违规 — 需要先调用 ${rejection.fixPath?.join(" → ") || "?"}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// P0: Strategy Enforcement — LLM must follow recommended chain
|
|
975
|
+
if (chains.length > 0 && chains[0].nodes.length >= 2) {
|
|
976
|
+
const topChain = chains[0];
|
|
977
|
+
const requiredFuncs = topChain.nodes.map(n => n.name);
|
|
978
|
+
const chosenFuncs = filtered.filter(a => a.kind === "call").map(a => a.function);
|
|
979
|
+
const missing = requiredFuncs.filter(fn => !chosenFuncs.includes(fn));
|
|
980
|
+
if (missing.length >= requiredFuncs.length * 0.5) {
|
|
981
|
+
// More than 50% of the chain is missing — LLM ignored the strategy
|
|
982
|
+
preCheckErrors.push(`策略违规: 推荐链 ${topChain.explanation},但缺少 ${missing.join(", ")}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
877
985
|
// 1) 基础序列校验
|
|
878
986
|
const seqResult = (0, validator_1.validateActionSequence)(filtered);
|
|
879
|
-
if (!seqResult.valid) {
|
|
880
|
-
const errorsFlat = seqResult.errors.flat();
|
|
987
|
+
if (!seqResult.valid || preCheckErrors.length > 0) {
|
|
988
|
+
const errorsFlat = [...preCheckErrors, ...seqResult.errors.flat()];
|
|
881
989
|
console.error("⚠️ 序列校验失败:", errorsFlat.join(", "));
|
|
882
990
|
// Use structured violations directly from validator
|
|
883
991
|
const violations = seqResult.violations.length > 0
|
|
884
992
|
? seqResult.violations
|
|
885
|
-
:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
actionIndex: 0,
|
|
889
|
-
description: errorsFlat.join("; "),
|
|
890
|
-
}];
|
|
993
|
+
: preCheckErrors.length > 0
|
|
994
|
+
? [{ svl: 1, violatedConstraint: "symbol_existence", actionIndex: 0, description: preCheckErrors.join("; ") }]
|
|
995
|
+
: [{ svl: 1, violatedConstraint: "symbol_existence", actionIndex: 0, description: errorsFlat.join("; ") }];
|
|
891
996
|
const primarySvl = `SVL-${violations[0].svl}`;
|
|
892
997
|
const attempt = {
|
|
893
998
|
id: (0, runtime_types_1.generateAttemptId)(),
|
|
@@ -918,7 +1023,11 @@ ${RETRY_HINT}
|
|
|
918
1023
|
plannerRetryTotal: maxRetries,
|
|
919
1024
|
});
|
|
920
1025
|
(0, memory_layer_1.recordEpisode)({ intent: userIntent, actions: filtered, success: false, svlViolated: primarySvl });
|
|
921
|
-
|
|
1026
|
+
// Build targeted retry prompt based on pre-check results
|
|
1027
|
+
const specificErrors = preCheckErrors.length > 0
|
|
1028
|
+
? `精确错误:\n${preCheckErrors.map(e => ` - ${e}`).join("\n")}`
|
|
1029
|
+
: `错误:${errorsFlat.join(";")}`;
|
|
1030
|
+
currentPrompt = `可用函数:\n${compactFuncList}${protocolChainHint}\n\n需求:${userIntent}\n\n${specificErrors}\n请修正上述问题。\n${RETRY_HINT}\n只输出 JSON。`;
|
|
922
1031
|
useSystem = false;
|
|
923
1032
|
(0, failure_corpus_1.saveCheckpoint)(userIntent, { attemptIndex: r + 1, sessionAttempts: session.attempts, currentPrompt, useSystem });
|
|
924
1033
|
continue;
|
|
@@ -1066,6 +1175,47 @@ ${RETRY_HINT}
|
|
|
1066
1175
|
(0, failure_corpus_1.saveCheckpoint)(userIntent, { attemptIndex: r + 1, sessionAttempts: session.attempts, currentPrompt, useSystem });
|
|
1067
1176
|
continue;
|
|
1068
1177
|
}
|
|
1178
|
+
// Phase 8: Refinement — detect empty args, ask LLM to fill meaningful values
|
|
1179
|
+
const emptyArgs = [];
|
|
1180
|
+
for (let ai = 0; ai < filtered.length; ai++) {
|
|
1181
|
+
const a = filtered[ai];
|
|
1182
|
+
if (a.kind === "call" && a.args) {
|
|
1183
|
+
for (const arg of a.args) {
|
|
1184
|
+
const v = typeof arg === "object" ? arg.value : arg;
|
|
1185
|
+
const isEmpty = (val) => val === "" || val === 0 || val === false || val === null || (Array.isArray(val) && val.length === 0);
|
|
1186
|
+
if (isEmpty(v)) {
|
|
1187
|
+
emptyArgs.push({ idx: ai, fn: a.function || "?", param: arg.name || "?", type: arg.type || "?" });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (emptyArgs.length > 0 && r < maxRetries - 1) {
|
|
1193
|
+
const argDetails = emptyArgs.map(e => ` ${e.fn}() 参数 "${e.param}" (${e.type}) 是空值`).join("\n");
|
|
1194
|
+
console.error(`🔍 检测到 ${emptyArgs.length} 个空参数,启动精炼...`);
|
|
1195
|
+
currentPrompt = `可用函数:\n${compactFuncList}${protocolChainHint}\n\n需求:${userIntent}\n\n上一次生成的 JSON 中以下参数为空值:\n${argDetails}\n\n请为这些参数填入有意义的示例值(字符串用描述性值,数字用合理数值,对象用 {} as Type)。\n${RETRY_HINT}\n只输出 JSON。`;
|
|
1196
|
+
useSystem = false;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
// 4D Scoring Self-Verification: compare LLM choices against scores
|
|
1200
|
+
const chosenFuncs = filtered.filter(a => a.kind === "call").map(a => a.function);
|
|
1201
|
+
const scoredFuncs = new Map(topFuncs.map((f) => [f.name, f.score || 0]));
|
|
1202
|
+
let scoreViolations = 0;
|
|
1203
|
+
for (const fn of chosenFuncs) {
|
|
1204
|
+
const actualScore = scoredFuncs.get(fn) || 0;
|
|
1205
|
+
const allScores = [...scoredFuncs.values()].filter((s) => typeof s === "number" && s > 0);
|
|
1206
|
+
const maxScore = allScores.length > 0 ? Math.max(...allScores) : 0;
|
|
1207
|
+
if (maxScore > 0 && actualScore < maxScore * 0.3 && maxScore > 1) {
|
|
1208
|
+
scoreViolations++;
|
|
1209
|
+
const better = [...scoredFuncs.entries()]
|
|
1210
|
+
.filter(([_, s]) => typeof s === "number" && s > actualScore * 2)
|
|
1211
|
+
.sort((a, b) => b[1] - a[1])
|
|
1212
|
+
.slice(0, 3).map(([n, s]) => `${n}(${s.toFixed(1)})`).join(", ");
|
|
1213
|
+
console.error(`⚠️ 评分偏低: ${fn}(${actualScore.toFixed(1)}) 被选中, 但更高分函数可用: ${better}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (scoreViolations > 0) {
|
|
1217
|
+
console.error(`📊 评分自省: ${scoreViolations}/${chosenFuncs.length} 个函数评分偏低 (阈值: 最高分30%)`);
|
|
1218
|
+
}
|
|
1069
1219
|
// 校验通过:构建成功 Attempt
|
|
1070
1220
|
const successAntibodyHit = antibodyHint
|
|
1071
1221
|
? {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 8: Semantic Topology (P1)
|
|
4
|
+
*
|
|
5
|
+
* Builds a similarity graph from IR structural data:
|
|
6
|
+
* - File co-occurrence (functions in same file are related)
|
|
7
|
+
* - Tag overlap (shared domain tags)
|
|
8
|
+
* - Purpose word overlap (Jaccard on purpose text)
|
|
9
|
+
* - Chain adjacency (producer→consumer links)
|
|
10
|
+
*
|
|
11
|
+
* Replaces simple string matching in capability search.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.SemanticTopology = void 0;
|
|
15
|
+
exports.getTopology = getTopology;
|
|
16
|
+
exports.rebuildTopology = rebuildTopology;
|
|
17
|
+
class SemanticTopology {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.nodes = new Map();
|
|
20
|
+
this.edges = new Map();
|
|
21
|
+
this.similarityCache = new Map();
|
|
22
|
+
}
|
|
23
|
+
/** Build topology from IR data */
|
|
24
|
+
build(ir) {
|
|
25
|
+
this.nodes.clear();
|
|
26
|
+
this.edges.clear();
|
|
27
|
+
this.similarityCache.clear();
|
|
28
|
+
// 1. Create nodes
|
|
29
|
+
for (const f of ir) {
|
|
30
|
+
if (!f.exported && !f.external)
|
|
31
|
+
continue;
|
|
32
|
+
this.nodes.set(f.name, {
|
|
33
|
+
name: f.name,
|
|
34
|
+
file: f.file || "",
|
|
35
|
+
tags: new Set((f.tags || []).map((t) => t.toLowerCase())),
|
|
36
|
+
purposeWords: new Set((f.purpose || "").toLowerCase().split(/[\s,,]+/).filter((w) => w.length > 2)),
|
|
37
|
+
produces: new Set(f.produces || []),
|
|
38
|
+
requires: new Set(f.requires || []),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// 2. Build edges: file co-occurrence
|
|
42
|
+
const byFile = new Map();
|
|
43
|
+
for (const [name, node] of this.nodes) {
|
|
44
|
+
if (!byFile.has(node.file))
|
|
45
|
+
byFile.set(node.file, []);
|
|
46
|
+
byFile.get(node.file).push(name);
|
|
47
|
+
}
|
|
48
|
+
for (const names of byFile.values()) {
|
|
49
|
+
for (let i = 0; i < names.length; i++) {
|
|
50
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
51
|
+
this.addEdge(names[i], names[j], 0.3, "co-file");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 3. Build edges: tag overlap
|
|
56
|
+
for (const [nameA, nodeA] of this.nodes) {
|
|
57
|
+
for (const [nameB, nodeB] of this.nodes) {
|
|
58
|
+
if (nameA >= nameB)
|
|
59
|
+
continue;
|
|
60
|
+
const tagOverlap = [...nodeA.tags].filter(t => nodeB.tags.has(t)).length;
|
|
61
|
+
if (tagOverlap > 0) {
|
|
62
|
+
const maxTags = Math.max(nodeA.tags.size, nodeB.tags.size) || 1;
|
|
63
|
+
this.addEdge(nameA, nameB, 0.4 * (tagOverlap / maxTags), "tag");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// 4. Build edges: purpose word overlap
|
|
68
|
+
for (const [nameA, nodeA] of this.nodes) {
|
|
69
|
+
for (const [nameB, nodeB] of this.nodes) {
|
|
70
|
+
if (nameA >= nameB)
|
|
71
|
+
continue;
|
|
72
|
+
const shared = [...nodeA.purposeWords].filter(w => nodeB.purposeWords.has(w)).length;
|
|
73
|
+
const total = [...new Set([...nodeA.purposeWords, ...nodeB.purposeWords])].length || 1;
|
|
74
|
+
const jaccard = shared / total;
|
|
75
|
+
if (jaccard > 0.15) {
|
|
76
|
+
this.addEdge(nameA, nameB, 0.5 * jaccard, "purpose");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// 5. Build edges: chain adjacency (producer→consumer)
|
|
81
|
+
for (const [nameA, nodeA] of this.nodes) {
|
|
82
|
+
for (const p of nodeA.produces) {
|
|
83
|
+
for (const [nameB, nodeB] of this.nodes) {
|
|
84
|
+
if (nameA === nameB)
|
|
85
|
+
continue;
|
|
86
|
+
if (nodeB.requires.has(p)) {
|
|
87
|
+
this.addEdge(nameA, nameB, 0.7, `chain:${p}`);
|
|
88
|
+
}
|
|
89
|
+
// Fuzzy chain: substring match
|
|
90
|
+
for (const r of nodeB.requires) {
|
|
91
|
+
if (p.includes(r) || r.includes(p)) {
|
|
92
|
+
this.addEdge(nameA, nameB, 0.4, `fuzzy:${p}≈${r}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
addEdge(a, b, weight, reason) {
|
|
100
|
+
const key = a < b ? `${a}::${b}` : `${b}::${a}`;
|
|
101
|
+
if (!this.edges.has(a))
|
|
102
|
+
this.edges.set(a, []);
|
|
103
|
+
if (!this.edges.has(b))
|
|
104
|
+
this.edges.set(b, []);
|
|
105
|
+
this.edges.get(a).push({ source: a, target: b, weight, reason });
|
|
106
|
+
this.edges.get(b).push({ source: b, target: a, weight, reason });
|
|
107
|
+
this.similarityCache.set(key, Math.max(this.similarityCache.get(key) || 0, weight));
|
|
108
|
+
}
|
|
109
|
+
/** Get similarity between two functions (0-1). */
|
|
110
|
+
similarity(funcA, funcB) {
|
|
111
|
+
if (funcA === funcB)
|
|
112
|
+
return 1.0;
|
|
113
|
+
const key = funcA < funcB ? `${funcA}::${funcB}` : `${funcB}::${funcA}`;
|
|
114
|
+
return this.similarityCache.get(key) || 0;
|
|
115
|
+
}
|
|
116
|
+
/** Find top N most similar functions to a given function. */
|
|
117
|
+
findSimilar(funcName, topN = 5) {
|
|
118
|
+
const edges = this.edges.get(funcName) || [];
|
|
119
|
+
return edges
|
|
120
|
+
.sort((a, b) => b.weight - a.weight)
|
|
121
|
+
.slice(0, topN)
|
|
122
|
+
.map(e => ({ name: e.target, similarity: e.weight }));
|
|
123
|
+
}
|
|
124
|
+
/** Semantic match: two capability labels are related via topology. */
|
|
125
|
+
capabilityMatch(produce, require) {
|
|
126
|
+
// Direct match
|
|
127
|
+
if (produce === require)
|
|
128
|
+
return true;
|
|
129
|
+
if (produce.includes(require) || require.includes(produce))
|
|
130
|
+
return true;
|
|
131
|
+
// Topology check: are there functions producing 'produce' that are connected
|
|
132
|
+
// to functions requiring 'require'?
|
|
133
|
+
const producers = [...this.nodes.values()].filter(n => n.produces.has(produce));
|
|
134
|
+
const consumers = [...this.nodes.values()].filter(n => n.requires.has(require));
|
|
135
|
+
for (const p of producers) {
|
|
136
|
+
for (const c of consumers) {
|
|
137
|
+
if (this.similarity(p.name, c.name) > 0.2)
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/** Get node count */
|
|
144
|
+
get size() { return this.nodes.size; }
|
|
145
|
+
}
|
|
146
|
+
exports.SemanticTopology = SemanticTopology;
|
|
147
|
+
// Singleton
|
|
148
|
+
let _topology = null;
|
|
149
|
+
function getTopology(ir) {
|
|
150
|
+
if (!_topology && ir) {
|
|
151
|
+
_topology = new SemanticTopology();
|
|
152
|
+
_topology.build(ir);
|
|
153
|
+
}
|
|
154
|
+
return _topology || new SemanticTopology();
|
|
155
|
+
}
|
|
156
|
+
function rebuildTopology(ir) {
|
|
157
|
+
_topology = new SemanticTopology();
|
|
158
|
+
_topology.build(ir);
|
|
159
|
+
return _topology;
|
|
160
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 8: Multi-Level Planning — Strategy Layer
|
|
4
|
+
*
|
|
5
|
+
* Selects capability chains from the IR graph WITHOUT calling LLM.
|
|
6
|
+
* Pure graph search: intent → capability matching → ordered chain.
|
|
7
|
+
*
|
|
8
|
+
* The Action Layer (planner.ts) then uses this chain to constrain
|
|
9
|
+
* the LLM's function selection space.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.selectCapabilityChains = selectCapabilityChains;
|
|
13
|
+
exports.formatChainHint = formatChainHint;
|
|
14
|
+
const utils_1 = require("./utils");
|
|
15
|
+
/** Build a capability graph from IR functions. */
|
|
16
|
+
function buildCapabilityGraph(ir) {
|
|
17
|
+
const graph = new Map();
|
|
18
|
+
for (const f of ir) {
|
|
19
|
+
if (!f.exported)
|
|
20
|
+
continue;
|
|
21
|
+
graph.set(f.name, {
|
|
22
|
+
name: f.name,
|
|
23
|
+
purpose: f.purpose || "",
|
|
24
|
+
tags: f.tags || [],
|
|
25
|
+
requires: f.requires || [],
|
|
26
|
+
produces: f.produces || [],
|
|
27
|
+
score: 0,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return graph;
|
|
31
|
+
}
|
|
32
|
+
/** Score a capability node against an intent. */
|
|
33
|
+
function scoreNode(node, intentLower, keywords) {
|
|
34
|
+
let score = 0;
|
|
35
|
+
// Name match
|
|
36
|
+
for (const kw of keywords) {
|
|
37
|
+
if (node.name.toLowerCase().includes(kw))
|
|
38
|
+
score += 1;
|
|
39
|
+
score += (0, utils_1.jaccardSimilarity)(node.name.toLowerCase(), kw);
|
|
40
|
+
}
|
|
41
|
+
// Purpose match
|
|
42
|
+
const purposeLower = node.purpose.toLowerCase();
|
|
43
|
+
for (const kw of keywords) {
|
|
44
|
+
if (purposeLower.includes(kw))
|
|
45
|
+
score += 2;
|
|
46
|
+
}
|
|
47
|
+
// Tag match
|
|
48
|
+
for (const tag of node.tags) {
|
|
49
|
+
if (intentLower.includes(tag.toLowerCase()))
|
|
50
|
+
score += 1.5;
|
|
51
|
+
}
|
|
52
|
+
// Semantic word overlap in purpose
|
|
53
|
+
const intentWords = intentLower.split(/[\s,,]+/);
|
|
54
|
+
for (const w of intentWords) {
|
|
55
|
+
if (w.length > 2 && purposeLower.includes(w))
|
|
56
|
+
score += 0.5;
|
|
57
|
+
}
|
|
58
|
+
return score;
|
|
59
|
+
}
|
|
60
|
+
/** Find all capability nodes that produce a given capability label. */
|
|
61
|
+
function findProducers(graph, capability) {
|
|
62
|
+
const producers = [];
|
|
63
|
+
for (const node of graph.values()) {
|
|
64
|
+
if (node.produces.some(p => p === capability || capability.includes(p) || p.includes(capability))) {
|
|
65
|
+
producers.push(node);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return producers;
|
|
69
|
+
}
|
|
70
|
+
/** Find all capability nodes that require a given capability label. */
|
|
71
|
+
function findConsumers(graph, capability) {
|
|
72
|
+
const consumers = [];
|
|
73
|
+
for (const node of graph.values()) {
|
|
74
|
+
if (node.requires.some(r => r === capability || capability.includes(r) || r.includes(capability))) {
|
|
75
|
+
consumers.push(node);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return consumers;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Select the best capability chain for an intent.
|
|
82
|
+
*
|
|
83
|
+
* Algorithm:
|
|
84
|
+
* 1. Score all nodes against intent
|
|
85
|
+
* 2. Find top producers (nodes whose produces matches intent keywords)
|
|
86
|
+
* 3. For each producer, trace forward: producer → consumer → consumer...
|
|
87
|
+
* 4. Score each chain and return top N
|
|
88
|
+
*/
|
|
89
|
+
function selectCapabilityChains(intent, ir, maxChains = 5) {
|
|
90
|
+
const intentLower = intent.toLowerCase();
|
|
91
|
+
const keywords = (0, utils_1.extractKeywords)(intent);
|
|
92
|
+
const graph = buildCapabilityGraph(ir);
|
|
93
|
+
// Score all nodes
|
|
94
|
+
for (const node of graph.values()) {
|
|
95
|
+
node.score = scoreNode(node, intentLower, keywords);
|
|
96
|
+
}
|
|
97
|
+
// Find seed nodes: highest-scoring nodes that produce something
|
|
98
|
+
const seeds = [...graph.values()]
|
|
99
|
+
.filter(n => n.produces.length > 0 && n.score > 0)
|
|
100
|
+
.sort((a, b) => b.score - a.score)
|
|
101
|
+
.slice(0, 10);
|
|
102
|
+
const chains = [];
|
|
103
|
+
for (const seed of seeds) {
|
|
104
|
+
// Build chain: seed → consumer → consumer...
|
|
105
|
+
const chain = [seed];
|
|
106
|
+
const visited = new Set([seed.name]);
|
|
107
|
+
let totalScore = seed.score;
|
|
108
|
+
// Forward trace: for each produce of the last node, find consumers
|
|
109
|
+
let current = seed;
|
|
110
|
+
let extended = true;
|
|
111
|
+
while (extended && chain.length < 8) {
|
|
112
|
+
extended = false;
|
|
113
|
+
for (const p of current.produces) {
|
|
114
|
+
const consumers = findConsumers(graph, p).filter(c => !visited.has(c.name));
|
|
115
|
+
if (consumers.length > 0) {
|
|
116
|
+
// Pick best-scoring consumer
|
|
117
|
+
const bestConsumer = consumers.sort((a, b) => b.score - a.score)[0];
|
|
118
|
+
chain.push(bestConsumer);
|
|
119
|
+
visited.add(bestConsumer.name);
|
|
120
|
+
totalScore += bestConsumer.score;
|
|
121
|
+
current = bestConsumer;
|
|
122
|
+
extended = true;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Backward trace: does seed need something? Find producers.
|
|
128
|
+
if (seed.requires.length > 0) {
|
|
129
|
+
const producers = findProducers(graph, seed.requires[0])
|
|
130
|
+
.filter(p => !visited.has(p.name));
|
|
131
|
+
if (producers.length > 0) {
|
|
132
|
+
const bestProducer = producers.sort((a, b) => b.score - a.score)[0];
|
|
133
|
+
chain.unshift(bestProducer);
|
|
134
|
+
totalScore += bestProducer.score;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (chain.length >= 1) {
|
|
138
|
+
chains.push({
|
|
139
|
+
nodes: chain,
|
|
140
|
+
score: totalScore / chain.length, // average score
|
|
141
|
+
explanation: chain.map(n => n.name).join(" → "),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Sort by score, deduplicate, return top N
|
|
146
|
+
chains.sort((a, b) => b.score - a.score);
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const unique = [];
|
|
149
|
+
for (const c of chains) {
|
|
150
|
+
const key = c.explanation;
|
|
151
|
+
if (!seen.has(key)) {
|
|
152
|
+
seen.add(key);
|
|
153
|
+
unique.push(c);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return unique.slice(0, maxChains);
|
|
157
|
+
}
|
|
158
|
+
/** Format chains as a hint for the LLM prompt. */
|
|
159
|
+
function formatChainHint(chains) {
|
|
160
|
+
if (chains.length === 0)
|
|
161
|
+
return "";
|
|
162
|
+
const lines = ["\n推荐能力链 (Strategy Layer):"];
|
|
163
|
+
for (let i = 0; i < Math.min(chains.length, 3); i++) {
|
|
164
|
+
const c = chains[i];
|
|
165
|
+
lines.push(` ${i + 1}. ${c.explanation} (★${c.score.toFixed(1)})`);
|
|
166
|
+
}
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|