@weniatall/dsh-task-models 0.2.2 → 0.3.0

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/lib/core.js CHANGED
@@ -19,6 +19,7 @@ export const DEFAULT_ROUTING = Object.freeze({
19
19
  enabled: false,
20
20
  fallback: 'taskRole',
21
21
  useJev: true,
22
+ decisionTimeoutMs: 5_000,
22
23
  respectExplicitChildModel: true,
23
24
  classifier: Object.freeze({ provider: '', model: '', reasoningEffort: '' }),
24
25
  })
@@ -44,10 +45,13 @@ function readString(value, fallback, label) {
44
45
  return value.trim()
45
46
  }
46
47
 
47
- function readNumber(value, fallback, label, min, max) {
48
+ function readNumber(value, fallback, label, min, max, requireInteger = false) {
48
49
  if (value === undefined || value === null || value === '') return fallback
49
50
  const number = typeof value === 'number' ? value : Number(value)
50
51
  if (!Number.isFinite(number)) throw new Error(`task-models: ${label} must be a finite number`)
52
+ if (requireInteger && !Number.isInteger(number)) {
53
+ throw new Error(`task-models: ${label} must be an integer`)
54
+ }
51
55
  if (number < min || number > max) {
52
56
  throw new Error(`task-models: ${label} must be between ${String(min)} and ${String(max)}`)
53
57
  }
@@ -112,6 +116,14 @@ export function resolveConfig(raw = {}) {
112
116
  enabled: readBoolean(routingRaw.enabled, DEFAULT_ROUTING.enabled, 'routing.enabled'),
113
117
  fallback,
114
118
  useJev: readBoolean(routingRaw.useJev, DEFAULT_ROUTING.useJev, 'routing.useJev'),
119
+ decisionTimeoutMs: readNumber(
120
+ routingRaw.decisionTimeoutMs,
121
+ DEFAULT_ROUTING.decisionTimeoutMs,
122
+ 'routing.decisionTimeoutMs',
123
+ 100,
124
+ 60_000,
125
+ true,
126
+ ),
115
127
  respectExplicitChildModel: readBoolean(
116
128
  routingRaw.respectExplicitChildModel,
117
129
  DEFAULT_ROUTING.respectExplicitChildModel,
@@ -182,40 +194,132 @@ export function resolveConfig(raw = {}) {
182
194
  return Object.freeze({ routing, profiles, jev })
183
195
  }
184
196
 
197
+ const ASCII_IDENTIFIER_PATTERN = /^[a-z0-9_]+$/i
198
+
199
+ function matchKeyword(target, keyword) {
200
+ if (typeof target !== 'string' || typeof keyword !== 'string') return false
201
+ const t = target.toLowerCase()
202
+ const k = keyword.toLowerCase()
203
+ if (t === '' || k === '') return false
204
+ if (ASCII_IDENTIFIER_PATTERN.test(k)) {
205
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${k}(?![\\p{L}\\p{N}_])`, 'u')
206
+ return pattern.test(t)
207
+ }
208
+ return t.includes(k)
209
+ }
210
+
185
211
  /**
186
- * Pick one enabled profile from a label and task text. A tie is deliberately
187
- * no decision; configuration order must never choose the model.
212
+ * Explainable rule analysis over candidate profiles against a subtask label and prompt text.
188
213
  */
189
- export function selectProfile(profiles, label, text) {
214
+ export function analyzeRules(profiles, label, text) {
190
215
  const labelText = typeof label === 'string' ? label.trim().toLowerCase() : ''
191
216
  const taskText = typeof text === 'string' ? text.trim().toLowerCase() : ''
192
217
  const values = profiles instanceof Map ? profiles.values() : profiles ?? []
193
- let best
194
- let bestScore = 0
195
- let tied = false
218
+
219
+ const matches = []
220
+
196
221
  for (const profile of values) {
197
222
  if (!profile?.enabled) continue
223
+ const id = String(profile.id ?? '').trim().toLowerCase()
224
+ if (id === '') continue
225
+
226
+ const explicitLabel = labelText === id
227
+ || labelText.startsWith(`[${id}]`)
228
+ || labelText.startsWith(`${id}:`)
229
+
198
230
  let score = 0
199
- const id = String(profile.id ?? '').toLowerCase()
200
- if (id !== '' && (labelText === id || labelText.startsWith(`[${id}]`) || labelText.startsWith(`${id}:`))) {
201
- score += 5
202
- }
231
+ const matchedKeywords = []
232
+ const seenKeywords = new Set()
233
+
203
234
  for (const rawKeyword of profile.keywords ?? []) {
204
- const keyword = String(rawKeyword).trim().toLowerCase()
205
- if (keyword === '') continue
206
- if (labelText.includes(keyword)) score += 3
207
- else if (taskText.includes(keyword)) score += 1
235
+ const keyword = String(rawKeyword ?? '').trim().toLowerCase()
236
+ if (keyword === '' || seenKeywords.has(keyword)) continue
237
+ seenKeywords.add(keyword)
238
+
239
+ if (matchKeyword(labelText, keyword)) {
240
+ score += 3
241
+ matchedKeywords.push({ keyword, in: 'label' })
242
+ } else if (matchKeyword(taskText, keyword)) {
243
+ score += 1
244
+ matchedKeywords.push({ keyword, in: 'text' })
245
+ }
208
246
  }
209
- if (score === 0) continue
210
- if (score > bestScore) {
211
- best = profile
212
- bestScore = score
213
- tied = false
214
- } else if (score === bestScore) {
215
- tied = true
247
+
248
+ if (explicitLabel || matchedKeywords.length > 0) {
249
+ matches.push({
250
+ id: profile.id,
251
+ score,
252
+ explicitLabel,
253
+ matchedKeywords,
254
+ })
216
255
  }
217
256
  }
218
- return tied ? undefined : best
257
+
258
+ matches.sort((a, b) => {
259
+ if (a.explicitLabel !== b.explicitLabel) return a.explicitLabel ? -1 : 1
260
+ if (b.score !== a.score) return b.score - a.score
261
+ return a.id.localeCompare(b.id)
262
+ })
263
+
264
+ const explicitMatches = matches.filter((m) => m.explicitLabel)
265
+ if (explicitMatches.length === 1) {
266
+ return {
267
+ profileId: explicitMatches[0].id,
268
+ source: 'explicit-label',
269
+ matches,
270
+ tiedIds: [],
271
+ }
272
+ }
273
+ if (explicitMatches.length > 1) {
274
+ return {
275
+ source: 'tie',
276
+ matches,
277
+ tiedIds: explicitMatches.map((m) => m.id).sort(),
278
+ }
279
+ }
280
+
281
+ let maxScore = 0
282
+ for (const match of matches) {
283
+ if (match.score > maxScore) maxScore = match.score
284
+ }
285
+
286
+ if (maxScore === 0) {
287
+ return {
288
+ source: 'none',
289
+ matches,
290
+ tiedIds: [],
291
+ }
292
+ }
293
+
294
+ const topMatches = matches.filter((m) => m.score === maxScore)
295
+ if (topMatches.length === 1) {
296
+ return {
297
+ profileId: topMatches[0].id,
298
+ source: 'keywords',
299
+ matches,
300
+ tiedIds: [],
301
+ }
302
+ }
303
+
304
+ return {
305
+ source: 'tie',
306
+ matches,
307
+ tiedIds: topMatches.map((m) => m.id).sort(),
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Pick one enabled profile from a label and task text. A tie is deliberately
313
+ * no decision; configuration order must never choose the model.
314
+ */
315
+ export function selectProfile(profiles, label, text) {
316
+ const analysis = analyzeRules(profiles, label, text)
317
+ if (!analysis.profileId) return undefined
318
+ if (profiles instanceof Map) {
319
+ return profiles.get(analysis.profileId)
320
+ ?? [...profiles.values()].find((p) => String(p?.id ?? '').toLowerCase() === analysis.profileId.toLowerCase())
321
+ }
322
+ return (profiles ?? []).find((p) => String(p?.id ?? '').toLowerCase() === analysis.profileId.toLowerCase())
219
323
  }
220
324
 
221
325
  /** Accept one exact configured profile id from a classifier reply. */
@@ -303,8 +407,12 @@ export function parseJevChoice(payload, allowedIds, minConfidence = 0, key = 'ta
303
407
  if (answer === null || answer === undefined || typeof answer !== 'object' || Array.isArray(answer)) return undefined
304
408
  const choice = typeof answer.choice === 'string' ? answer.choice.trim().toLowerCase() : ''
305
409
  if (choice === '' || !allowed.has(choice)) return undefined
306
- const confidence = Number(answer.confidence)
307
- if (Number.isFinite(confidence) && confidence < minConfidence) return undefined
410
+ if (typeof answer.confidence !== 'number' || !Number.isFinite(answer.confidence)) {
411
+ return undefined
412
+ }
413
+ if (answer.confidence < 0 || answer.confidence > 1 || answer.confidence < minConfidence) {
414
+ return undefined
415
+ }
308
416
  return choice
309
417
  }
310
418