@agentix-e/nl2spel 1.2.2 → 1.4.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/dist/index.js CHANGED
@@ -1,47 +1,69 @@
1
1
  // src/provider/provider-registry.ts
2
2
  var ProviderRegistry = class {
3
3
  _providers = [];
4
- /** Register a Provider */
5
- register(provider) {
6
- if (this._providers.some((p) => p.name === provider.name)) {
4
+ _nextIndex = 0;
5
+ /**
6
+ * Register a Provider.
7
+ * @param provider LLMProvider instance
8
+ * @param options.priority User-assigned priority (lower = preferred). Defaults to registration order.
9
+ */
10
+ register(provider, options) {
11
+ if (this._providers.some((p) => p.provider.name === provider.name)) {
7
12
  throw new Error(`Provider '${provider.name}' already registered`);
8
13
  }
9
- this._providers.push(provider);
14
+ this._providers.push({
15
+ provider,
16
+ priority: options?.priority ?? this._nextIndex,
17
+ index: this._nextIndex
18
+ });
19
+ this._nextIndex++;
10
20
  }
11
21
  /** Unregister a Provider */
12
22
  unregister(name) {
13
- this._providers = this._providers.filter((p) => p.name !== name);
23
+ this._providers = this._providers.filter((p) => p.provider.name !== name);
14
24
  }
15
25
  /** Get a Provider by name */
16
26
  get(name) {
17
- return this._providers.find((p) => p.name === name);
27
+ return this._providers.find((p) => p.provider.name === name)?.provider;
18
28
  }
19
29
  /**
20
30
  * Get available Providers sorted by priority.
21
- * Sort rule: offline > low cost > low latency > high accuracy
31
+ * Sort rule: offline first user priority (asc) registration order (asc)
22
32
  */
23
33
  async getPrioritized() {
24
34
  const available = [];
25
- for (const p of this._providers) {
26
- if (await p.isAvailable()) {
27
- available.push(p);
35
+ for (const entry of this._providers) {
36
+ if (await entry.provider.isAvailable()) {
37
+ available.push(entry);
28
38
  }
29
39
  }
30
40
  return available.sort((a, b) => {
31
- const aOffline = a.capabilities.offlineAvailable;
32
- const bOffline = b.capabilities.offlineAvailable;
41
+ const aOffline = a.provider.capabilities.offlineAvailable;
42
+ const bOffline = b.provider.capabilities.offlineAvailable;
33
43
  if (aOffline && !bOffline) return -1;
34
44
  if (!aOffline && bOffline) return 1;
35
- const costA = a.capabilities.estimatedCostPerRequest ?? Infinity;
36
- const costB = b.capabilities.estimatedCostPerRequest ?? Infinity;
37
- if (costA < costB) return -1;
38
- if (costB < costA) return 1;
39
- return a.capabilities.estimatedLatencyMs - b.capabilities.estimatedLatencyMs;
40
- });
45
+ if (a.priority !== b.priority) return a.priority - b.priority;
46
+ return a.index - b.index;
47
+ }).map((entry) => entry.provider);
48
+ }
49
+ /**
50
+ * Explicitly reorder providers by name.
51
+ * Providers not listed retain their position after the reordered ones.
52
+ */
53
+ reorder(providerNames) {
54
+ const orderMap = new Map(providerNames.map((name, i) => [name, i]));
55
+ const maxExisting = this._providers.reduce(
56
+ (max, p) => Math.max(max, p.priority),
57
+ providerNames.length - 1
58
+ );
59
+ for (const entry of this._providers) {
60
+ const explicitIndex = orderMap.get(entry.provider.name);
61
+ entry.priority = explicitIndex ?? maxExisting + entry.index + 1;
62
+ }
41
63
  }
42
64
  /** List all registered Providers */
43
65
  list() {
44
- return [...this._providers];
66
+ return this._providers.map((p) => p.provider);
45
67
  }
46
68
  /** Number of registered Providers */
47
69
  get count() {
@@ -372,10 +394,55 @@ var ChineseNumberParser = class {
372
394
  };
373
395
 
374
396
  // src/pattern/pattern-matcher.ts
397
+ var UnmappedFieldError = class extends Error {
398
+ /** The field word that could not be resolved. */
399
+ field;
400
+ constructor(field) {
401
+ super(
402
+ `No field mapping for '${field}'. Add a mapping, or use fieldPolicy 'passthrough' to emit it verbatim.`
403
+ );
404
+ this.name = "UnmappedFieldError";
405
+ this.field = field;
406
+ }
407
+ };
408
+ var CN_FIELD_MAP = {
409
+ \u5907\u6CE8: "remark",
410
+ \u8BF4\u660E: "description",
411
+ \u63CF\u8FF0: "description",
412
+ \u91D1\u989D: "amount",
413
+ \u6570\u91CF: "count",
414
+ \u4E2A\u6570: "count",
415
+ \u72B6\u6001: "status",
416
+ \u7C7B\u578B: "type",
417
+ \u540D\u79F0: "name",
418
+ \u6807\u9898: "title",
419
+ \u5730\u5740: "address",
420
+ \u90AE\u7BB1: "email",
421
+ \u624B\u673A: "phone",
422
+ \u7535\u8BDD: "phone",
423
+ \u65E5\u671F: "date",
424
+ \u65F6\u95F4: "time",
425
+ \u5E74\u9F84: "age",
426
+ \u4EF7\u683C: "price",
427
+ \u7528\u6237\u540D: "name",
428
+ \u6743\u9650: "role",
429
+ \u6807\u7B7E: "tags",
430
+ \u5217\u8868: "list",
431
+ \u6570\u7EC4: "items",
432
+ \u6587\u4EF6: "file",
433
+ \u6587\u4EF6\u540D: "name",
434
+ \u8FC7\u671F: "expiryDate",
435
+ \u521B\u5EFA: "createdAt",
436
+ \u6709\u6548: "valid",
437
+ \u6D3B\u8DC3: "active",
438
+ \u6FC0\u6D3B: "active"
439
+ };
375
440
  var PatternMatcher = class {
376
441
  _patterns;
377
- constructor(patterns = []) {
442
+ fieldPolicy;
443
+ constructor(patterns = [], options = {}) {
378
444
  this._patterns = [...patterns];
445
+ this.fieldPolicy = options.fieldPolicy ?? "passthrough";
379
446
  this.sortByPriority();
380
447
  }
381
448
  get patternCount() {
@@ -409,9 +476,22 @@ var PatternMatcher = class {
409
476
  }
410
477
  }
411
478
  }
412
- const spel = this.fillTemplate(pattern, slots, normalized, matchResult);
479
+ const { expression, unmappedFields } = this.fillTemplate(
480
+ pattern,
481
+ slots,
482
+ normalized,
483
+ matchResult
484
+ );
413
485
  const latencyMs2 = Date.now() - startTime;
414
- return { matched: true, pattern, spel, confidence: pattern.confidence, latencyMs: latencyMs2, slots };
486
+ return {
487
+ matched: true,
488
+ pattern,
489
+ spel: expression,
490
+ confidence: pattern.confidence,
491
+ latencyMs: latencyMs2,
492
+ slots,
493
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
494
+ };
415
495
  }
416
496
  const latencyMs = Date.now() - startTime;
417
497
  return { matched: false, confidence: 0, latencyMs };
@@ -432,13 +512,20 @@ var PatternMatcher = class {
432
512
  if (value !== void 0) slots[key] = value;
433
513
  }
434
514
  }
515
+ const { expression, unmappedFields } = this.fillTemplate(
516
+ pattern,
517
+ slots,
518
+ normalized,
519
+ matchResult
520
+ );
435
521
  results.push({
436
522
  matched: true,
437
523
  pattern,
438
- spel: this.fillTemplate(pattern, slots, normalized, matchResult),
524
+ spel: expression,
439
525
  confidence: pattern.confidence,
440
526
  latencyMs: 0,
441
- slots
527
+ slots,
528
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
442
529
  });
443
530
  }
444
531
  return results;
@@ -460,46 +547,37 @@ var PatternMatcher = class {
460
547
  if (/^(?:商品|product|item)/i.test(input)) return "product";
461
548
  return "order";
462
549
  }
550
+ /**
551
+ * The first whitespace- or punctuation-delimited chunk of `input`.
552
+ */
553
+ firstWord(input) {
554
+ const m = input.match(/^[^\s,,、]+/);
555
+ return m ? m[0] : "value";
556
+ }
463
557
  /**
464
558
  * Extract Chinese field names from input and map to SpEL fields
465
559
  */
466
560
  extractChineseField(input) {
467
- const m = input.match(/^[^\s,,、]+/);
468
- if (!m) return "value";
469
- const first = m[0];
470
- const cnMap = {
471
- \u5907\u6CE8: "remark",
472
- \u8BF4\u660E: "description",
473
- \u63CF\u8FF0: "description",
474
- \u91D1\u989D: "amount",
475
- \u6570\u91CF: "count",
476
- \u4E2A\u6570: "count",
477
- \u72B6\u6001: "status",
478
- \u7C7B\u578B: "type",
479
- \u540D\u79F0: "name",
480
- \u6807\u9898: "title",
481
- \u5730\u5740: "address",
482
- \u90AE\u7BB1: "email",
483
- \u624B\u673A: "phone",
484
- \u7535\u8BDD: "phone",
485
- \u65E5\u671F: "date",
486
- \u65F6\u95F4: "time",
487
- \u5E74\u9F84: "age",
488
- \u4EF7\u683C: "price",
489
- \u7528\u6237\u540D: "name",
490
- \u6743\u9650: "role",
491
- \u6807\u7B7E: "tags",
492
- \u5217\u8868: "list",
493
- \u6570\u7EC4: "items",
494
- \u6587\u4EF6: "file",
495
- \u6587\u4EF6\u540D: "name",
496
- \u8FC7\u671F: "expiryDate",
497
- \u521B\u5EFA: "createdAt",
498
- \u6709\u6548: "valid",
499
- \u6D3B\u8DC3: "active",
500
- \u6FC0\u6D3B: "active"
501
- };
502
- return cnMap[first] ?? first;
561
+ const first = this.firstWord(input);
562
+ return CN_FIELD_MAP[first] ?? first;
563
+ }
564
+ /**
565
+ * Resolve a captured field word into the identifier to emit, and report whether
566
+ * the dictionary recognised it.
567
+ *
568
+ * An ASCII word is already an identifier and is emitted unchanged. Anything else
569
+ * is looked up: a word the dictionary knows becomes its English identifier, and a
570
+ * word it does not know is emitted verbatim and reported as unmapped. Emitting it
571
+ * verbatim is legal Spring, but it is a guess about the caller's schema, so the
572
+ * guess is never silent.
573
+ */
574
+ resolveField(captured) {
575
+ if (/^[a-zA-Z_]\w*$/.test(captured)) {
576
+ return { field: captured, mapped: true };
577
+ }
578
+ const first = this.firstWord(captured);
579
+ const mapped = CN_FIELD_MAP[first];
580
+ return { field: mapped ?? first, mapped: mapped !== void 0 };
503
581
  }
504
582
  /**
505
583
  * Template filling and value transformation
@@ -507,7 +585,20 @@ var PatternMatcher = class {
507
585
  fillTemplate(pattern, slots, originalInput, _matchResult) {
508
586
  let result = pattern.spelTemplate;
509
587
  const hasFieldSlot = "field" in slots;
510
- const field = hasFieldSlot ? this.inferFieldFromCapture(slots["field"]) : this.extractChineseField(originalInput);
588
+ const unmappedFields = [];
589
+ let field;
590
+ if (hasFieldSlot) {
591
+ const resolved = this.resolveField(slots["field"]);
592
+ if (!resolved.mapped) {
593
+ if (this.fieldPolicy === "strict") {
594
+ throw new UnmappedFieldError(resolved.field);
595
+ }
596
+ unmappedFields.push(resolved.field);
597
+ }
598
+ field = resolved.field;
599
+ } else {
600
+ field = this.extractChineseField(originalInput);
601
+ }
511
602
  const root = this.inferRoot(originalInput);
512
603
  result = result.replace(/\{field\}/g, field);
513
604
  result = result.replace(/\{root\}/g, root);
@@ -535,14 +626,7 @@ var PatternMatcher = class {
535
626
  result = result.replace(`{${key}}`, transformedValue ?? "");
536
627
  }
537
628
  result = result.replace(/\{[a-zA-Z_]+\}/g, "");
538
- return result.trim();
539
- }
540
- /**
541
- * Infer SpEL field name from capture group
542
- */
543
- inferFieldFromCapture(captured) {
544
- if (/^[a-zA-Z_]\w*$/.test(captured)) return captured;
545
- return this.extractChineseField(captured);
629
+ return { expression: result.trim(), unmappedFields };
546
630
  }
547
631
  };
548
632
 
@@ -608,7 +692,7 @@ var BUILTIN_PATTERNS = [
608
692
  slots: {},
609
693
  priority: 98,
610
694
  tags: ["null", "isNotNull"],
611
- examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#\u5907\u6CE8 != null" }],
695
+ examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#remark != null" }],
612
696
  difficulty: "easy",
613
697
  confidence: 0.98
614
698
  },
@@ -619,7 +703,7 @@ var BUILTIN_PATTERNS = [
619
703
  slots: {},
620
704
  priority: 97,
621
705
  tags: ["null", "isNull"],
622
- examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#\u5907\u6CE8 == null" }],
706
+ examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#remark == null" }],
623
707
  difficulty: "easy",
624
708
  confidence: 0.98
625
709
  },
@@ -650,29 +734,29 @@ var BUILTIN_PATTERNS = [
650
734
  // ================================================================
651
735
  {
652
736
  id: "CN-CMP-GE",
653
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
737
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
654
738
  spelTemplate: "#{field} >= {value}",
655
739
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
656
740
  priority: 93,
657
741
  tags: ["comparison", "ge", "chinese"],
658
- examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#\u91D1\u989D >= 100" }],
742
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#amount >= 100" }],
659
743
  difficulty: "easy",
660
744
  confidence: 0.95
661
745
  },
662
746
  {
663
747
  id: "CN-CMP-LE",
664
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
748
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
665
749
  spelTemplate: "#{field} <= {value}",
666
750
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
667
751
  priority: 93,
668
752
  tags: ["comparison", "le", "chinese"],
669
- examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#\u91D1\u989D <= 500" }],
753
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#amount <= 500" }],
670
754
  difficulty: "easy",
671
755
  confidence: 0.95
672
756
  },
673
757
  {
674
758
  id: "CN-CMP-GT",
675
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
759
+ match: /^(?<field>[^\s,,、]+?)\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
676
760
  spelTemplate: "#{field} > {value}",
677
761
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
678
762
  priority: 92,
@@ -683,7 +767,7 @@ var BUILTIN_PATTERNS = [
683
767
  },
684
768
  {
685
769
  id: "CN-CMP-LT",
686
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
770
+ match: /^(?<field>[^\s,,、]+?)\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
687
771
  spelTemplate: "#{field} < {value}",
688
772
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
689
773
  priority: 92,
@@ -697,7 +781,7 @@ var BUILTIN_PATTERNS = [
697
781
  // ================================================================
698
782
  {
699
783
  id: "CN-EQ-STATUS",
700
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
784
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
701
785
  spelTemplate: "#{field} == '{value}'",
702
786
  slots: { value: { key: "value", type: "string" } },
703
787
  priority: 85,
@@ -720,7 +804,7 @@ var BUILTIN_PATTERNS = [
720
804
  // CN: "order status is not cancelled" — must be before NOT pattern
721
805
  {
722
806
  id: "CN-NE-STATUS",
723
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
807
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
724
808
  spelTemplate: "#{field} != '{value}'",
725
809
  slots: { value: { key: "value", type: "string" } },
726
810
  priority: 91,
@@ -743,12 +827,12 @@ var BUILTIN_PATTERNS = [
743
827
  // CN/EN: count equality
744
828
  {
745
829
  id: "CN-EQ-COUNT",
746
- match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|计数)?\s*(?:等于|==)\s*(?<value>\d+)/,
830
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|==)\s*(?<value>\d+)/,
747
831
  spelTemplate: "#{field} == {value}",
748
832
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
749
833
  priority: 91,
750
834
  tags: ["comparison", "eq", "number"],
751
- examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#\u6570\u91CF == 5" }],
835
+ examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#count == 5" }],
752
836
  difficulty: "easy",
753
837
  confidence: 0.95
754
838
  },
@@ -790,7 +874,9 @@ var BUILTIN_PATTERNS = [
790
874
  },
791
875
  {
792
876
  id: "CN-PERM-PERM",
793
- match: /^(?<field>[^\s,,、]+)\s*(?:可以|能够|允许|may|can)\s+(?<permission>.+)$/i,
877
+ // Chinese-only operator: keeping the English "can"/"may" here would let the
878
+ // pattern fire on the "can" inside "not cancelled".
879
+ match: /^(?<field>[^\s,,、]+)\s*(?:可以|能够|允许)\s*(?<permission>.+)$/,
794
880
  spelTemplate: "hasPermission('{permission}')",
795
881
  slots: { permission: { key: "permission", type: "string" } },
796
882
  priority: 88,
@@ -815,7 +901,7 @@ var BUILTIN_PATTERNS = [
815
901
  // ================================================================
816
902
  {
817
903
  id: "CN-COLL-EMPTY",
818
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
904
+ match: /^(?<field>[^\s,,、]+?)\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
819
905
  spelTemplate: "#{field}.isEmpty()",
820
906
  slots: {},
821
907
  priority: 89,
@@ -837,7 +923,7 @@ var BUILTIN_PATTERNS = [
837
923
  },
838
924
  {
839
925
  id: "CN-COLL-NOTEMPTY",
840
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
926
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
841
927
  spelTemplate: "!#{field}.isEmpty()",
842
928
  slots: {},
843
929
  priority: 89,
@@ -859,7 +945,7 @@ var BUILTIN_PATTERNS = [
859
945
  },
860
946
  {
861
947
  id: "CN-COLL-CONTAINS",
862
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
948
+ match: /^(?<field>[^\s,,、]+?)\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
863
949
  spelTemplate: "#{field}.contains('{element}')",
864
950
  slots: { element: { key: "element", type: "string" } },
865
951
  priority: 87,
@@ -881,7 +967,7 @@ var BUILTIN_PATTERNS = [
881
967
  },
882
968
  {
883
969
  id: "CN-COLL-SIZE",
884
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
970
+ match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
885
971
  spelTemplate: "#{field}.size() {op} {value}",
886
972
  slots: {
887
973
  value: { key: "value", type: "number", transform: "toNumber" },
@@ -912,7 +998,7 @@ var BUILTIN_PATTERNS = [
912
998
  // ================================================================
913
999
  {
914
1000
  id: "CN-STR-CONTAINS",
915
- match: /^(?<field>[^\s,,、]+?)(?:备注|名称|描述|标签|标题)?\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
1001
+ match: /^(?<field>[^\s,,、]+?)\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
916
1002
  spelTemplate: "#{field}.contains('{substr}')",
917
1003
  slots: { substr: { key: "substr", type: "string" } },
918
1004
  priority: 85,
@@ -961,7 +1047,7 @@ var BUILTIN_PATTERNS = [
961
1047
  slots: { suffix: { key: "suffix", type: "string" } },
962
1048
  priority: 85,
963
1049
  tags: ["string", "endsWith"],
964
- examples: [{ nl: "\u6587\u4EF6\u540D\u4EE5.pdf\u7ED3\u5C3E", spel: "#\u6587\u4EF6\u540D.endsWith('.pdf')" }],
1050
+ examples: [{ nl: "\u6587\u4EF6\u540D\u4EE5.pdf\u7ED3\u5C3E", spel: "#name.endsWith('.pdf')" }],
965
1051
  difficulty: "easy",
966
1052
  confidence: 0.95
967
1053
  },
@@ -1004,21 +1090,21 @@ var BUILTIN_PATTERNS = [
1004
1090
  {
1005
1091
  id: "CN-RANGE-BETWEEN",
1006
1092
  match: /^(?<field>[^\s,,、]+?)\s*(?:在|介于)\s*(?<min>\d+)\s*(?:和|到|~)\s*(?<max>\d+)\s*(?:之间|范围)?/,
1007
- spelTemplate: "#{field} between {{{min}, {max}}}",
1093
+ spelTemplate: "#{field} between {{min}, {max}}",
1008
1094
  slots: {
1009
1095
  min: { key: "min", type: "number", transform: "toNumber" },
1010
1096
  max: { key: "max", type: "number", transform: "toNumber" }
1011
1097
  },
1012
1098
  priority: 82,
1013
1099
  tags: ["range", "between"],
1014
- examples: [{ nl: "\u5E74\u9F84\u572818\u523060\u4E4B\u95F4", spel: "#\u5E74\u9F84 between {18, 60}" }],
1100
+ examples: [{ nl: "\u5E74\u9F84\u572818\u523060\u4E4B\u95F4", spel: "#age between {18, 60}" }],
1015
1101
  difficulty: "easy",
1016
1102
  confidence: 0.95
1017
1103
  },
1018
1104
  {
1019
1105
  id: "EN-RANGE-BETWEEN",
1020
1106
  match: /\b(?<field>\w+)\s+between\s+(?<min>\d+)\s+and\s+(?<max>\d+)/i,
1021
- spelTemplate: "#{field} between {{{min}, {max}}}",
1107
+ spelTemplate: "#{field} between {{min}, {max}}",
1022
1108
  slots: {
1023
1109
  min: { key: "min", type: "number", transform: "toNumber" },
1024
1110
  max: { key: "max", type: "number", transform: "toNumber" }
@@ -1039,7 +1125,7 @@ var BUILTIN_PATTERNS = [
1039
1125
  slots: { default: { key: "default", type: "string" } },
1040
1126
  priority: 78,
1041
1127
  tags: ["elvis"],
1042
- examples: [{ nl: "\u7528\u6237\u540D\u6216\u8005\u533F\u540D\u7528\u6237", spel: "#\u7528\u6237\u540D ?: '\u533F\u540D\u7528\u6237'" }],
1128
+ examples: [{ nl: "\u7528\u6237\u540D\u6216\u8005\u533F\u540D\u7528\u6237", spel: "#name ?: '\u533F\u540D\u7528\u6237'" }],
1043
1129
  difficulty: "medium",
1044
1130
  confidence: 0.85
1045
1131
  },
@@ -1145,7 +1231,11 @@ var BUILTIN_PATTERNS = [
1145
1231
  // ================================================================
1146
1232
  {
1147
1233
  id: "CN-BOOL-TRUE",
1148
- match: /^(?<field>[^\s,,、]+?)\s*(?:是|为|等于|==)\s*(?:true|真|是|yes)$/i,
1234
+ // "用户是VIP" asserts the subject through a Latin marker, so accept either a
1235
+ // truth word or a Latin word. A fully open marker would fire on function
1236
+ // words ("一个非常…"), and the lookbehind keeps a negator out of the field
1237
+ // ("用户不" is not the subject).
1238
+ match: /^(?<field>[^\s,,、]+?)(?<![不非])\s*(?:是|为|等于|==)\s*(?:true|真|是|yes|[A-Za-z]\w*)$/,
1149
1239
  spelTemplate: "#{field} == true",
1150
1240
  slots: {},
1151
1241
  priority: 74,
@@ -1156,7 +1246,10 @@ var BUILTIN_PATTERNS = [
1156
1246
  },
1157
1247
  {
1158
1248
  id: "CN-BOOL-FALSE",
1159
- match: /^(?<field>[^\s,,、]+?)\s*(?:不是|非|为|是|等于|==)\s*(?:false|假|否|no)$/i,
1249
+ // Mirror of CN-BOOL-TRUE. The marker stays narrow so that function words
1250
+ // ("非常…") do not match, and the lookbehind keeps "不是有效" with
1251
+ // CN-LOGIC-NOT instead of capturing "不" as the field.
1252
+ match: /^(?<field>[^\s,,、]+?)(?<![不非])\s*(?:不是|非|为|是|等于|==)\s*(?:false|假|否|no|[A-Za-z]\w*)$/,
1160
1253
  spelTemplate: "#{field} == false",
1161
1254
  slots: {},
1162
1255
  priority: 73,
@@ -1264,7 +1357,7 @@ var BUILTIN_PATTERNS = [
1264
1357
  // ================================================================
1265
1358
  {
1266
1359
  id: "CN-LOGIC-NOT",
1267
- match: /^不是\s+(?<expr>.+)/,
1360
+ match: /^不是\s*(?<expr>.+)/,
1268
1361
  spelTemplate: "!({expr})",
1269
1362
  slots: { expr: { key: "expr", type: "variable" } },
1270
1363
  priority: 62,
@@ -1289,8 +1382,8 @@ var BUILTIN_PATTERNS = [
1289
1382
  // ================================================================
1290
1383
  {
1291
1384
  id: "CN-SELECT-FIRST",
1292
- match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、]+)/,
1293
- spelTemplate: "#{root}.items.^[#{this}.{field} > {value}]",
1385
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、的]+)/,
1386
+ spelTemplate: "#{root}.items.^[#this.{field} > {value}]",
1294
1387
  slots: {
1295
1388
  root: { key: "root", type: "variable" },
1296
1389
  field: { key: "field", type: "variable" },
@@ -1298,14 +1391,14 @@ var BUILTIN_PATTERNS = [
1298
1391
  },
1299
1392
  priority: 60,
1300
1393
  tags: ["selection", "first"],
1301
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u7B2C\u4E00\u4E2A\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#\u8BA2\u5355.items.^[#this.\u91D1\u989D > 1000]" }],
1394
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u7B2C\u4E00\u4E2A\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#order.items.^[#this.amount > 1000]" }],
1302
1395
  difficulty: "medium",
1303
1396
  confidence: 0.8
1304
1397
  },
1305
1398
  {
1306
1399
  id: "CN-SELECT-ALL",
1307
- match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、]+)/,
1308
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1400
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、的]+)/,
1401
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1309
1402
  slots: {
1310
1403
  root: { key: "root", type: "variable" },
1311
1404
  field: { key: "field", type: "variable" },
@@ -1313,28 +1406,28 @@ var BUILTIN_PATTERNS = [
1313
1406
  },
1314
1407
  priority: 60,
1315
1408
  tags: ["selection", "all"],
1316
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u6240\u6709\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#\u8BA2\u5355.items.?[#this.\u91D1\u989D > 1000]" }],
1409
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u6240\u6709\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#order.items.?[#this.amount > 1000]" }],
1317
1410
  difficulty: "medium",
1318
1411
  confidence: 0.8
1319
1412
  },
1320
1413
  {
1321
1414
  id: "CN-PROJ",
1322
- match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:值|金额|名称|价格|name|amount|price)?/,
1323
- spelTemplate: "#{root}.items.![#{this}.{field}]",
1415
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:的)?(?:值|金额|名称|价格|name|amount|price)?$/,
1416
+ spelTemplate: "#{root}.items.![#this.{field}]",
1324
1417
  slots: {
1325
1418
  root: { key: "root", type: "variable" },
1326
1419
  field: { key: "field", type: "variable" }
1327
1420
  },
1328
1421
  priority: 55,
1329
1422
  tags: ["projection"],
1330
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u6BCF\u4E2A\u5546\u54C1\u7684\u4EF7\u683C", spel: "#\u8BA2\u5355.items.![#this.\u5546\u54C1]" }],
1423
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u6BCF\u4E2A\u5546\u54C1\u7684\u4EF7\u683C", spel: "#order.items.![#this.\u5546\u54C1]" }],
1331
1424
  difficulty: "medium",
1332
1425
  confidence: 0.75
1333
1426
  },
1334
1427
  {
1335
1428
  id: "EN-SELECT-ALL",
1336
1429
  match: /all\s+(?<root>\w+)\s+with\s+(?<field>\w+)\s*>\s*(?<value>\d+)/i,
1337
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1430
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1338
1431
  slots: {
1339
1432
  root: { key: "root", type: "variable" },
1340
1433
  field: { key: "field", type: "variable" },
@@ -1342,13 +1435,149 @@ var BUILTIN_PATTERNS = [
1342
1435
  },
1343
1436
  priority: 50,
1344
1437
  tags: ["selection", "all", "english"],
1345
- examples: [{ nl: "all items with price > 100", spel: "#items.items.?[#this.price > 100]" }],
1438
+ examples: [{ nl: "all items with price > 100", spel: "#order.items.?[#this.price > 100]" }],
1346
1439
  difficulty: "medium",
1347
1440
  confidence: 0.8
1348
1441
  }
1349
1442
  ];
1350
1443
  BUILTIN_PATTERNS.sort((a, b) => b.priority - a.priority);
1351
1444
 
1445
+ // src/pattern/clause-splitter.ts
1446
+ var CONNECTORS = [
1447
+ // Chinese conjunctions. `和` is deliberately absent: it is a range separator in
1448
+ // `价格在10和20之间`, not a conjunction.
1449
+ { pattern: /(?:并且|且|同时|而且|、)/y, operator: "and" },
1450
+ { pattern: /(?:或者|或|要么)/y, operator: "or" },
1451
+ // English conjunctions, matched as whole words.
1452
+ { pattern: /\band\b/iy, operator: "and" },
1453
+ { pattern: /\bor\b/iy, operator: "or" }
1454
+ ];
1455
+ var UnconvertibleClauseError = class extends Error {
1456
+ /** The clause texts that could not be converted. */
1457
+ unconvertible;
1458
+ /** The full input that was being decomposed. */
1459
+ input;
1460
+ constructor(input, unconvertible) {
1461
+ const detail = unconvertible.map((clause) => `'${clause}'`).join(", ");
1462
+ super(
1463
+ `Cannot decompose '${input}': no conversion for ${detail}. Refusing to emit a partial rule.`
1464
+ );
1465
+ this.name = "UnconvertibleClauseError";
1466
+ this.input = input;
1467
+ this.unconvertible = unconvertible;
1468
+ }
1469
+ };
1470
+ var QUOTES = /* @__PURE__ */ new Set(["'", '"']);
1471
+ var OPENERS = { "(": ")", "[": "]", "{": "}" };
1472
+ var CLOSERS = /* @__PURE__ */ new Set([")", "]", "}"]);
1473
+ var isDigit = (ch) => ch !== void 0 && ch >= "0" && ch <= "9";
1474
+ function nextConnector(input, from) {
1475
+ let depth = 0;
1476
+ let quote = null;
1477
+ for (let i = from; i < input.length; i += 1) {
1478
+ const ch = input[i];
1479
+ if (quote !== null) {
1480
+ if (ch === quote) {
1481
+ if (input[i + 1] === quote) {
1482
+ i += 1;
1483
+ continue;
1484
+ }
1485
+ quote = null;
1486
+ }
1487
+ continue;
1488
+ }
1489
+ if (QUOTES.has(ch)) {
1490
+ quote = ch;
1491
+ continue;
1492
+ }
1493
+ if (ch in OPENERS) {
1494
+ depth += 1;
1495
+ continue;
1496
+ }
1497
+ if (CLOSERS.has(ch)) {
1498
+ depth = Math.max(0, depth - 1);
1499
+ continue;
1500
+ }
1501
+ if (depth > 0) continue;
1502
+ for (const { pattern, operator } of CONNECTORS) {
1503
+ pattern.lastIndex = i;
1504
+ const match = pattern.exec(input);
1505
+ if (!match) continue;
1506
+ if (operator === "and") {
1507
+ let before = i - 1;
1508
+ while (before >= 0 && input[before] === " ") before -= 1;
1509
+ let after = i + match[0].length;
1510
+ while (after < input.length && input[after] === " ") after += 1;
1511
+ if (isDigit(input[before]) && isDigit(input[after])) continue;
1512
+ }
1513
+ return { index: i, length: match[0].length, operator };
1514
+ }
1515
+ }
1516
+ return null;
1517
+ }
1518
+ function splitClauses(input) {
1519
+ const clauses = [];
1520
+ let start = 0;
1521
+ let connector = "and";
1522
+ let cursor = 0;
1523
+ for (; ; ) {
1524
+ const found = nextConnector(input, cursor);
1525
+ if (found === null) break;
1526
+ clauses.push({ text: input.slice(start, found.index).trim(), connector });
1527
+ connector = found.operator;
1528
+ start = found.index + found.length;
1529
+ cursor = start;
1530
+ }
1531
+ const tail = input.slice(start).trim();
1532
+ if (clauses.length > 0 || tail.length > 0) {
1533
+ clauses.push({ text: tail, connector });
1534
+ }
1535
+ if (clauses.length === 0) {
1536
+ clauses.push({ text: "", connector: "and" });
1537
+ }
1538
+ return clauses;
1539
+ }
1540
+ function groupByPrecedence(clauses) {
1541
+ const groups = [];
1542
+ let current = [];
1543
+ for (const clause of clauses) {
1544
+ if (clause.connector === "or" && current.length > 0) {
1545
+ groups.push(current);
1546
+ current = [];
1547
+ }
1548
+ current.push(clause);
1549
+ }
1550
+ if (current.length > 0) groups.push(current);
1551
+ return groups;
1552
+ }
1553
+ function decompose(input, convert) {
1554
+ const clauses = splitClauses(input);
1555
+ if (clauses.length <= 1) return null;
1556
+ const unconvertible = [];
1557
+ const resolved = clauses.map((clause) => {
1558
+ const expression = clause.text.length === 0 ? null : convert(clause.text);
1559
+ if (expression === null) {
1560
+ unconvertible.push(clause.text);
1561
+ return { ...clause, expression: "" };
1562
+ }
1563
+ return { ...clause, expression };
1564
+ });
1565
+ if (unconvertible.length > 0) {
1566
+ throw new UnconvertibleClauseError(input, unconvertible);
1567
+ }
1568
+ const groups = groupByPrecedence(resolved);
1569
+ const mixed = groups.length > 1;
1570
+ const rendered = groups.map((group) => {
1571
+ const parts = group.map((clause) => `(${clause.expression})`);
1572
+ const joined = parts.join(" and ");
1573
+ return mixed && group.length > 1 ? `(${joined})` : joined;
1574
+ });
1575
+ return {
1576
+ expression: rendered.join(" or "),
1577
+ clauses: clauses.map((clause) => clause.text)
1578
+ };
1579
+ }
1580
+
1352
1581
  // src/template/nl-intent.ts
1353
1582
  var NLIntent = /* @__PURE__ */ ((NLIntent2) => {
1354
1583
  NLIntent2["COMPARISON"] = "COMPARISON";
@@ -1387,7 +1616,7 @@ var INTENT_KEYWORDS = {
1387
1616
  en: ["greater", "less", "equal", "above", "below", "exceed", ">", "<", "==", "!=", ">=", "<="]
1388
1617
  },
1389
1618
  ["NULL_CHECK" /* NULL_CHECK */]: {
1390
- zh: ["\u4E3A\u7A7A", "\u4E0D\u4E3A\u7A7A", "\u662F\u7A7A", "\u5B58\u5728", "\u4E0D\u5B58\u5728", "null", "\u6CA1\u6709\u503C"],
1619
+ zh: ["\u4E3A\u7A7A", "\u4E0D\u4E3A\u7A7A", "\u662F\u7A7A", "\u975E\u7A7A", "\u5B58\u5728", "\u4E0D\u5B58\u5728", "\u6709\u503C", "\u65E0\u503C", "null", "\u6CA1\u6709\u503C"],
1391
1620
  en: ["null", "empty", "is null", "is not null", "is empty", "is not empty"]
1392
1621
  },
1393
1622
  ["PERMISSION_CHECK" /* PERMISSION_CHECK */]: {
@@ -1424,7 +1653,10 @@ var INTENT_KEYWORDS = {
1424
1653
  },
1425
1654
  ["BOOLEAN" /* BOOLEAN */]: {
1426
1655
  zh: ["\u662F\u5426", "\u771F\u5047", "true", "false", "\u662F", "\u5426"],
1427
- en: ["true", "false", "yes", "no", "is", "is not"]
1656
+ // "no" is deliberately absent: it is a substring of "not", so it fired on
1657
+ // every negated null/emptiness phrase and made "remark is not empty" a
1658
+ // boolean check. "false"/"否" already cover the negative boolean spelling.
1659
+ en: ["true", "false", "yes", "is", "is not"]
1428
1660
  },
1429
1661
  ["DATE" /* DATE */]: {
1430
1662
  zh: ["\u65E5\u671F", "\u65F6\u95F4", "\u4E4B\u540E", "\u4E4B\u524D", "\u65E9\u4E8E", "\u665A\u4E8E"],
@@ -1443,6 +1675,38 @@ var INTENT_KEYWORDS = {
1443
1675
  en: ["plus", "minus", "multiply", "divide", "mod", "sum", "average"]
1444
1676
  }
1445
1677
  };
1678
+ var NULL_NEGATED_SPECIFIC = [
1679
+ "\u4E0D\u4E3A\u7A7A",
1680
+ "\u4E0D\u4E3Anull",
1681
+ "\u4E0D\u662F\u7A7A",
1682
+ "\u975E\u7A7A",
1683
+ "is not null",
1684
+ "is not empty"
1685
+ ];
1686
+ var NULL_AFFIRMATIVE = [
1687
+ "\u4E0D\u5B58\u5728",
1688
+ "\u65E0\u503C",
1689
+ "\u6CA1\u6709\u503C",
1690
+ "\u4E3A\u7A7A",
1691
+ "\u4E3Anull",
1692
+ "\u662F\u7A7A",
1693
+ "is null",
1694
+ "is empty"
1695
+ ];
1696
+ var NULL_NEGATED_GENERIC = ["\u6709\u503C", "\u5B58\u5728"];
1697
+ function detectNullPredicate(input) {
1698
+ const text = input.trim().toLowerCase().replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 65248)).replace(/\s+/g, " ");
1699
+ for (const token of NULL_NEGATED_SPECIFIC) {
1700
+ if (text.includes(token)) return "negated";
1701
+ }
1702
+ for (const token of NULL_AFFIRMATIVE) {
1703
+ if (text.includes(token)) return "affirmative";
1704
+ }
1705
+ for (const token of NULL_NEGATED_GENERIC) {
1706
+ if (text.includes(token)) return "negated";
1707
+ }
1708
+ return null;
1709
+ }
1446
1710
  var IntentClassifier = class {
1447
1711
  /**
1448
1712
  * Main method for classifying natural language input
@@ -1476,6 +1740,9 @@ var IntentClassifier = class {
1476
1740
  if (/isempty|\.isEmpty|列表|数组|集合/.test(normalized)) {
1477
1741
  intentScores.set("COLLECTION" /* COLLECTION */, (intentScores.get("COLLECTION" /* COLLECTION */) ?? 0) + 1);
1478
1742
  }
1743
+ if (detectNullPredicate(normalized)) {
1744
+ intentScores.set("NULL_CHECK" /* NULL_CHECK */, (intentScores.get("NULL_CHECK" /* NULL_CHECK */) ?? 0) + 2);
1745
+ }
1479
1746
  const entities = this.extractEntities(normalized);
1480
1747
  const operators = this.extractOperators(normalized);
1481
1748
  const logicalConnectors = this.extractLogicalConnectors(normalized);
@@ -1588,6 +1855,68 @@ var IntentClassifier = class {
1588
1855
  };
1589
1856
 
1590
1857
  // src/template/template-engine.ts
1858
+ var CHINESE_FIELD_MAP = {
1859
+ \u5907\u6CE8: "remark",
1860
+ \u8BF4\u660E: "description",
1861
+ \u63CF\u8FF0: "description",
1862
+ \u91D1\u989D: "amount",
1863
+ \u6570\u91CF: "count",
1864
+ \u4E2A\u6570: "count",
1865
+ \u72B6\u6001: "status",
1866
+ \u7C7B\u578B: "type",
1867
+ \u540D\u79F0: "name",
1868
+ \u6807\u9898: "title",
1869
+ \u5730\u5740: "address",
1870
+ \u90AE\u7BB1: "email",
1871
+ \u624B\u673A: "phone",
1872
+ \u7535\u8BDD: "phone",
1873
+ \u65E5\u671F: "date",
1874
+ \u65F6\u95F4: "time",
1875
+ \u5E74\u9F84: "age",
1876
+ \u4EF7\u683C: "price",
1877
+ \u7528\u6237\u540D: "name",
1878
+ \u6743\u9650: "role",
1879
+ \u6807\u7B7E: "tags",
1880
+ \u5217\u8868: "list",
1881
+ \u6570\u7EC4: "items",
1882
+ \u6587\u4EF6: "file",
1883
+ \u6587\u4EF6\u540D: "name",
1884
+ \u8FC7\u671F: "expiryDate",
1885
+ \u521B\u5EFA: "createdAt",
1886
+ \u6709\u6548: "valid",
1887
+ \u6D3B\u8DC3: "active",
1888
+ \u6FC0\u6D3B: "active"
1889
+ };
1890
+ var CHINESE_FIELDS_BY_LENGTH = Object.entries(CHINESE_FIELD_MAP).sort(
1891
+ (a, b) => b[0].length - a[0].length
1892
+ );
1893
+ var FIELD_STOPWORDS = /* @__PURE__ */ new Set([
1894
+ "a",
1895
+ "an",
1896
+ "account",
1897
+ "and",
1898
+ "are",
1899
+ "be",
1900
+ "between",
1901
+ "empty",
1902
+ "false",
1903
+ "file",
1904
+ "has",
1905
+ "have",
1906
+ "is",
1907
+ "no",
1908
+ "not",
1909
+ "null",
1910
+ "or",
1911
+ "order",
1912
+ "product",
1913
+ "than",
1914
+ "the",
1915
+ "true",
1916
+ "user",
1917
+ "value",
1918
+ "yes"
1919
+ ]);
1591
1920
  var TEMPLATE_LIBRARY = {
1592
1921
  ["COMPARISON" /* COMPARISON */]: [
1593
1922
  {
@@ -1832,8 +2161,9 @@ var TemplateEngine = class {
1832
2161
  selectBestTemplate(templates, intentResult, input) {
1833
2162
  let bestScore = -1;
1834
2163
  let bestTemplate = null;
1835
- const hasEmptyKeyword = /为空|empty|null/i.test(input);
1836
- const hasNotEmptyKeyword = /不为空|not empty|not null/i.test(input);
2164
+ const polarity = detectNullPredicate(input);
2165
+ const isAffirmative = polarity === "affirmative";
2166
+ const isNegated = polarity === "negated";
1837
2167
  for (const template of templates) {
1838
2168
  const conditions = template.conditions;
1839
2169
  let score = 0;
@@ -1842,8 +2172,10 @@ var TemplateEngine = class {
1842
2172
  if (conditions.hasCollection) score += 0.5;
1843
2173
  if (conditions.hasNull) score += 0.5;
1844
2174
  if (conditions.hasString) score += 1;
1845
- if (template.name.includes("IS_EMPTY") && hasEmptyKeyword) score += 2;
1846
- if (template.name.includes("IS_NOT_EMPTY") && hasNotEmptyKeyword) score += 2;
2175
+ if (template.name.includes("IS_EMPTY") && isAffirmative) score += 2;
2176
+ if (template.name.includes("IS_NOT_EMPTY") && isNegated) score += 2;
2177
+ if (template.name === "NULL-IS_NULL" && isAffirmative) score += 2;
2178
+ if (template.name === "NULL-IS_NOT_NULL" && isNegated) score += 2;
1847
2179
  if (conditions.entityCount) {
1848
2180
  if (conditions.entityCount.min && intentResult.entities.length < conditions.entityCount.min) {
1849
2181
  continue;
@@ -1859,37 +2191,8 @@ var TemplateEngine = class {
1859
2191
  fillTemplate(template, input, intentResult) {
1860
2192
  let expression = template;
1861
2193
  const unfilledSlots = [];
1862
- let rootName = "order";
1863
- let fieldName = "field";
1864
- if (this.contextSchema?.root) {
1865
- rootName = this.contextSchema.root.name;
1866
- const fields = Object.keys(this.contextSchema.root.fields ?? {});
1867
- for (const f of fields) {
1868
- if (input.includes(f)) {
1869
- fieldName = f;
1870
- break;
1871
- }
1872
- }
1873
- } else {
1874
- const rootMap = {
1875
- \u8BA2\u5355: "order",
1876
- order: "order",
1877
- \u7528\u6237: "user",
1878
- user: "user",
1879
- \u6587\u4EF6: "file",
1880
- file: "file",
1881
- \u8D26\u53F7: "account",
1882
- account: "account",
1883
- \u5546\u54C1: "item",
1884
- product: "item"
1885
- };
1886
- for (const [key, val] of Object.entries(rootMap)) {
1887
- if (input.includes(key)) {
1888
- rootName = val;
1889
- break;
1890
- }
1891
- }
1892
- }
2194
+ const rootName = this.resolveRootName(input);
2195
+ const fieldName = this.resolveFieldName(input);
1893
2196
  expression = expression.replace(/\{root\}/g, rootName);
1894
2197
  expression = expression.replace(/\{field\}/g, fieldName);
1895
2198
  const fieldEntities = intentResult.entities.filter((e) => e.type === "field");
@@ -1958,6 +2261,58 @@ var TemplateEngine = class {
1958
2261
  }
1959
2262
  return { expression, unfilledSlots };
1960
2263
  }
2264
+ /**
2265
+ * Resolve the SpEL root name for `input`: the configured schema root when one
2266
+ * exists, otherwise a keyword heuristic over the well-known roots.
2267
+ */
2268
+ resolveRootName(input) {
2269
+ if (this.contextSchema?.root) {
2270
+ return this.contextSchema.root.name;
2271
+ }
2272
+ const rootMap = {
2273
+ \u8BA2\u5355: "order",
2274
+ order: "order",
2275
+ \u7528\u6237: "user",
2276
+ user: "user",
2277
+ \u6587\u4EF6: "file",
2278
+ file: "file",
2279
+ \u8D26\u53F7: "account",
2280
+ account: "account",
2281
+ \u5546\u54C1: "item",
2282
+ product: "item"
2283
+ };
2284
+ for (const [key, val] of Object.entries(rootMap)) {
2285
+ if (input.includes(key)) return val;
2286
+ }
2287
+ return "order";
2288
+ }
2289
+ /**
2290
+ * Resolve the field name for `input`.
2291
+ *
2292
+ * The previous implementation only compared schema *keys* and otherwise left
2293
+ * the literal placeholder default in place, which is how "#order.field == null"
2294
+ * reached callers. The lookup now falls back in order: schema key, schema
2295
+ * field description (Chinese inputs name the field by its description),
2296
+ * Chinese field word, first English identifier, and finally "value" — the
2297
+ * neutral default the pattern layer already uses for an unknown field.
2298
+ */
2299
+ resolveFieldName(input) {
2300
+ const fields = this.contextSchema?.root?.fields;
2301
+ if (fields) {
2302
+ for (const [key, schema] of Object.entries(fields)) {
2303
+ if (input.includes(key)) return key;
2304
+ if (schema.description && input.includes(schema.description)) return key;
2305
+ }
2306
+ }
2307
+ for (const [word, field] of CHINESE_FIELDS_BY_LENGTH) {
2308
+ if (input.includes(word)) return field;
2309
+ }
2310
+ const tokens = input.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
2311
+ for (const token of tokens) {
2312
+ if (!FIELD_STOPWORDS.has(token.toLowerCase())) return token;
2313
+ }
2314
+ return "value";
2315
+ }
1961
2316
  };
1962
2317
 
1963
2318
  // src/template/prompts/prompt-builder.ts
@@ -2195,6 +2550,172 @@ ${userInput}
2195
2550
  }
2196
2551
  };
2197
2552
 
2553
+ // src/validation/validation-pipeline.ts
2554
+ import { TokenKind, Tokenizer } from "@agentix-e/spel-ts";
2555
+
2556
+ // src/validation/auto-fixer.ts
2557
+ function scanStringLiterals(expression) {
2558
+ const spans = [];
2559
+ let unterminatedQuote = null;
2560
+ let i = 0;
2561
+ while (i < expression.length) {
2562
+ const quote = expression[i];
2563
+ if (quote !== "'" && quote !== '"') {
2564
+ i += 1;
2565
+ continue;
2566
+ }
2567
+ const start = i;
2568
+ i += 1;
2569
+ let closed = false;
2570
+ while (i < expression.length) {
2571
+ if (expression[i] === quote) {
2572
+ if (expression[i + 1] === quote) {
2573
+ i += 2;
2574
+ continue;
2575
+ }
2576
+ i += 1;
2577
+ closed = true;
2578
+ break;
2579
+ }
2580
+ i += 1;
2581
+ }
2582
+ spans.push({ start, end: i });
2583
+ if (!closed) {
2584
+ unterminatedQuote = quote;
2585
+ break;
2586
+ }
2587
+ }
2588
+ return { spans, unterminatedQuote };
2589
+ }
2590
+ function hasUnterminatedStringLiteral(expression) {
2591
+ return scanStringLiterals(expression).unterminatedQuote !== null;
2592
+ }
2593
+ function maskStringLiterals(expression) {
2594
+ const { spans } = scanStringLiterals(expression);
2595
+ if (spans.length === 0) return expression;
2596
+ const chars = expression.split("");
2597
+ for (const span of spans) {
2598
+ for (let i = span.start; i < span.end && i < chars.length; i++) {
2599
+ chars[i] = " ";
2600
+ }
2601
+ }
2602
+ return chars.join("");
2603
+ }
2604
+ var AutoFixer = class {
2605
+ fix(expression) {
2606
+ const { chunks, unterminatedQuote } = this.split(expression);
2607
+ const changes = [];
2608
+ this.applyRule(
2609
+ chunks,
2610
+ /=== undefined/g,
2611
+ "== null",
2612
+ () => "Replaced === undefined with == null",
2613
+ changes
2614
+ );
2615
+ this.applyRule(
2616
+ chunks,
2617
+ /!== undefined/g,
2618
+ "!= null",
2619
+ () => "Replaced !== undefined with != null",
2620
+ changes
2621
+ );
2622
+ this.applyRule(chunks, /!==/g, "!=", (n) => `Replaced ${n}x !== with !=`, changes);
2623
+ this.applyRule(chunks, /===/g, "==", (n) => `Replaced ${n}x === with ==`, changes);
2624
+ this.applyRule(chunks, /&&/g, "and", (n) => `Replaced ${n}x && with and`, changes);
2625
+ this.applyRule(chunks, /\|\|/g, "or", (n) => `Replaced ${n}x || with or`, changes);
2626
+ this.applyRule(chunks, /> ==/g, ">=", () => "Replaced > == with >=", changes);
2627
+ this.applyRule(chunks, /< ==/g, "<=", () => "Replaced < == with <=", changes);
2628
+ this.applyElvisRule(chunks, changes);
2629
+ let fixed = chunks.map((chunk) => chunk.text).join("");
2630
+ if (unterminatedQuote !== null && !fixed.endsWith(unterminatedQuote)) {
2631
+ fixed += unterminatedQuote;
2632
+ changes.push(
2633
+ unterminatedQuote === "'" ? "Added missing closing single quote" : "Added missing closing double quote"
2634
+ );
2635
+ }
2636
+ const wasFixed = changes.length > 0;
2637
+ return {
2638
+ wasFixed,
2639
+ expression: wasFixed ? fixed : expression,
2640
+ changes
2641
+ };
2642
+ }
2643
+ /**
2644
+ * Apply one global replacement rule to every unprotected chunk.
2645
+ *
2646
+ * The replacement is only reported when it actually matched, and the count
2647
+ * is the number of matches across all chunks, so the human-readable change
2648
+ * log is unchanged from the previous implementation.
2649
+ */
2650
+ applyRule(chunks, pattern, replacement, describe, changes) {
2651
+ let count = 0;
2652
+ for (const chunk of chunks) {
2653
+ if (chunk.protected) continue;
2654
+ const matches = chunk.text.match(pattern);
2655
+ if (matches === null) continue;
2656
+ count += matches.length;
2657
+ chunk.text = chunk.text.replace(pattern, replacement);
2658
+ }
2659
+ if (count > 0) {
2660
+ changes.push(describe(count));
2661
+ }
2662
+ }
2663
+ /**
2664
+ * Normalise the Elvis operator only when it was written with whitespace
2665
+ * between `?` and `:`. A correctly written `?:` must be left untouched so
2666
+ * that `fix()` is a no-op on already-valid input.
2667
+ */
2668
+ applyElvisRule(chunks, changes) {
2669
+ let touched = false;
2670
+ for (const chunk of chunks) {
2671
+ if (chunk.protected || !chunk.text.includes("? :")) continue;
2672
+ const next = chunk.text.replace(/\s*\?\s*:\s*/g, " ?: ");
2673
+ if (next !== chunk.text) {
2674
+ chunk.text = next;
2675
+ touched = true;
2676
+ }
2677
+ }
2678
+ if (touched) {
2679
+ changes.push("Fixed Elvis operator spacing");
2680
+ }
2681
+ }
2682
+ /**
2683
+ * Split the expression into alternating protected (string literal) and
2684
+ * unprotected chunks. Ranges come from spel-ts's tokenizer so they agree
2685
+ * exactly with what the parser considers opaque.
2686
+ */
2687
+ split(expression) {
2688
+ const { spans, unterminatedQuote } = scanStringLiterals(expression);
2689
+ return { chunks: this.toChunks(expression, spans), unterminatedQuote };
2690
+ }
2691
+ /**
2692
+ * Turn protected spans into a chunk list covering the whole expression.
2693
+ */
2694
+ toChunks(expression, spans) {
2695
+ const ordered = [...spans].sort((a, b) => a.start - b.start);
2696
+ const chunks = [];
2697
+ let cursor = 0;
2698
+ for (const span of ordered) {
2699
+ const start = Math.max(span.start, cursor);
2700
+ const end = Math.max(span.end, start);
2701
+ if (start > cursor) {
2702
+ chunks.push({ protected: false, text: expression.slice(cursor, start) });
2703
+ }
2704
+ if (end > start) {
2705
+ chunks.push({ protected: true, text: expression.slice(start, end) });
2706
+ }
2707
+ cursor = end;
2708
+ }
2709
+ if (cursor < expression.length) {
2710
+ chunks.push({ protected: false, text: expression.slice(cursor) });
2711
+ }
2712
+ if (chunks.length === 0) {
2713
+ chunks.push({ protected: false, text: "" });
2714
+ }
2715
+ return chunks;
2716
+ }
2717
+ };
2718
+
2198
2719
  // src/validation/validation-pipeline.ts
2199
2720
  var ValidationPipeline = class {
2200
2721
  evaluator;
@@ -2214,25 +2735,29 @@ var ValidationPipeline = class {
2214
2735
  const typeStage = this.validateTypes(expression, contextSchema);
2215
2736
  const semanticStage = this.validateSemantic(expression);
2216
2737
  const contextStage = this.validateContext(expression, contextSchema);
2738
+ const finalStage = this.validateFinal(expression);
2217
2739
  errors.push(
2218
2740
  ...parseStage.errors,
2219
2741
  ...typeStage.errors,
2220
2742
  ...semanticStage.errors,
2221
- ...contextStage.errors
2743
+ ...contextStage.errors,
2744
+ ...finalStage.errors
2222
2745
  );
2223
2746
  warnings.push(
2224
2747
  ...parseStage.warnings,
2225
2748
  ...typeStage.warnings,
2226
2749
  ...semanticStage.warnings,
2227
- ...contextStage.warnings
2750
+ ...contextStage.warnings,
2751
+ ...finalStage.warnings
2228
2752
  );
2229
2753
  return {
2230
- valid: parseStage.passed && typeStage.passed && semanticStage.passed && contextStage.passed,
2754
+ valid: errors.length === 0,
2231
2755
  stages: {
2232
2756
  parse: parseStage,
2233
2757
  type: typeStage,
2234
2758
  semantic: semanticStage,
2235
- context: contextStage
2759
+ context: contextStage,
2760
+ final: finalStage
2236
2761
  },
2237
2762
  errors,
2238
2763
  warnings
@@ -2240,47 +2765,57 @@ var ValidationPipeline = class {
2240
2765
  }
2241
2766
  /**
2242
2767
  * Stage 1: Parse Check — syntax validation
2768
+ *
2769
+ * Delimiter balance and JavaScript operators are checked against a copy of
2770
+ * the expression with string literals blanked out, so literal text can never
2771
+ * be mistaken for syntax.
2243
2772
  */
2244
2773
  async validateParse(expression) {
2245
2774
  const errors = [];
2246
2775
  const warnings = [];
2776
+ const structural = maskStringLiterals(expression);
2247
2777
  if (!expression || expression.trim().length === 0) {
2248
2778
  errors.push({
2249
2779
  code: "PARSE-EMPTY",
2250
2780
  message: "Expression is empty",
2781
+ severity: "error",
2251
2782
  stage: "parse",
2252
2783
  requiresLLM: true
2253
2784
  });
2254
2785
  return { passed: false, errors, warnings };
2255
2786
  }
2256
- if (!this.hasBalancedParentheses(expression)) {
2787
+ if (!this.hasBalancedParentheses(structural)) {
2257
2788
  errors.push({
2258
2789
  code: "PARSE-UNBALANCED_PARENS",
2259
2790
  message: "Unbalanced parentheses in expression",
2791
+ severity: "error",
2260
2792
  stage: "parse",
2261
2793
  requiresLLM: true
2262
2794
  });
2263
2795
  }
2264
- if (expression.includes("===") || expression.includes("!==")) {
2796
+ if (structural.includes("===") || structural.includes("!==")) {
2265
2797
  errors.push({
2266
2798
  code: "PARSE-JS_OPERATOR",
2267
2799
  message: "JavaScript operators detected (=== or !==), use == or != in SpEL",
2800
+ severity: "error",
2268
2801
  stage: "parse",
2269
2802
  requiresLLM: true
2270
2803
  });
2271
2804
  }
2272
- if (expression.includes("&&")) {
2805
+ if (structural.includes("&&")) {
2273
2806
  errors.push({
2274
2807
  code: "PARSE-JS_LOGIC",
2275
2808
  message: 'JavaScript && detected, use "and" in SpEL',
2809
+ severity: "error",
2276
2810
  stage: "parse",
2277
2811
  requiresLLM: true
2278
2812
  });
2279
2813
  }
2280
- if (expression.includes("||")) {
2814
+ if (structural.includes("||")) {
2281
2815
  errors.push({
2282
2816
  code: "PARSE-JS_LOGIC",
2283
2817
  message: 'JavaScript || detected, use "or" in SpEL',
2818
+ severity: "error",
2284
2819
  stage: "parse",
2285
2820
  requiresLLM: true
2286
2821
  });
@@ -2293,6 +2828,7 @@ var ValidationPipeline = class {
2293
2828
  errors.push({
2294
2829
  code: `PARSE-${pe.code ?? "SYNTAX"}`,
2295
2830
  message: pe.message,
2831
+ severity: "error",
2296
2832
  position: pe.position,
2297
2833
  stage: "parse",
2298
2834
  requiresLLM: true
@@ -2303,6 +2839,7 @@ var ValidationPipeline = class {
2303
2839
  errors.push({
2304
2840
  code: "PARSE-EXCEPTION",
2305
2841
  message: `Parse threw exception: ${err.message}`,
2842
+ severity: "error",
2306
2843
  stage: "parse",
2307
2844
  requiresLLM: true
2308
2845
  });
@@ -2315,27 +2852,43 @@ var ValidationPipeline = class {
2315
2852
  };
2316
2853
  }
2317
2854
  /**
2318
- * Stage 2: Type Check — type validation
2855
+ * Stage 2: Type Check — advisory type validation
2319
2856
  */
2320
2857
  validateTypes(expression, contextSchema) {
2321
2858
  const errors = [];
2322
2859
  const warnings = [];
2323
- const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+/;
2860
+ const structural = maskStringLiterals(expression);
2861
+ const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+|\d+\s*(?:>|<|>=|<=)\s*'(?:\\.|[^'\\])*'/;
2324
2862
  if (strNumMismatch.test(expression)) {
2325
2863
  warnings.push({
2326
2864
  code: "TYPE-STR_NUM_CMP",
2327
2865
  message: "String literal compared with number using arithmetic operator",
2866
+ severity: "warning",
2328
2867
  stage: "type"
2329
2868
  });
2330
2869
  }
2331
2870
  if (contextSchema?.root) {
2871
+ const rootRef = escapeRegExp(contextSchema.root.name);
2332
2872
  for (const [fieldName, field] of Object.entries(contextSchema.root.fields)) {
2873
+ const fieldRef = `#(?:${rootRef}\\.)?${escapeRegExp(fieldName)}`;
2333
2874
  if (field.type === "boolean") {
2334
- const boolNumPattern = new RegExp(`#\\w+\\.${fieldName}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2335
- if (boolNumPattern.test(expression)) {
2875
+ const boolNumPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2876
+ if (boolNumPattern.test(structural)) {
2336
2877
  warnings.push({
2337
2878
  code: "TYPE-BOOL_NUM_CMP",
2338
2879
  message: `Boolean field '${fieldName}' compared with number`,
2880
+ severity: "warning",
2881
+ stage: "type"
2882
+ });
2883
+ }
2884
+ }
2885
+ if (field.type === "number") {
2886
+ const numStrPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*'(?:\\\\.|[^'\\\\])*'`);
2887
+ if (numStrPattern.test(expression)) {
2888
+ warnings.push({
2889
+ code: "TYPE-STR_NUM_CMP",
2890
+ message: `Numeric field '${fieldName}' compared with a string literal`,
2891
+ severity: "warning",
2339
2892
  stage: "type"
2340
2893
  });
2341
2894
  }
@@ -2349,23 +2902,26 @@ var ValidationPipeline = class {
2349
2902
  };
2350
2903
  }
2351
2904
  /**
2352
- * Stage 3: Semantic Check — semantic reasonableness validation
2905
+ * Stage 3: Semantic Check — advisory semantic validation
2353
2906
  */
2354
2907
  validateSemantic(expression) {
2355
2908
  const errors = [];
2356
2909
  const warnings = [];
2910
+ const structural = maskStringLiterals(expression);
2357
2911
  const selfCompare = /(#\w+(?:\.\w+)*)\s*==\s*\1/;
2358
- if (selfCompare.test(expression)) {
2912
+ if (selfCompare.test(structural)) {
2359
2913
  warnings.push({
2360
2914
  code: "SEM-SELF_COMPARE",
2361
2915
  message: "Self-comparison detected: expression is always true",
2916
+ severity: "warning",
2362
2917
  stage: "semantic"
2363
2918
  });
2364
2919
  }
2365
- if (expression.includes("!!")) {
2920
+ if (structural.includes("!!")) {
2366
2921
  warnings.push({
2367
2922
  code: "SEM-DOUBLE_NEGATION",
2368
2923
  message: "Double negation detected, consider simplifying",
2924
+ severity: "warning",
2369
2925
  stage: "semantic"
2370
2926
  });
2371
2927
  }
@@ -2377,59 +2933,72 @@ var ValidationPipeline = class {
2377
2933
  }
2378
2934
  /**
2379
2935
  * Stage 4: Context Check — context reference validation
2936
+ *
2937
+ * A supplied schema turns this stage into a real gate: an undeclared bean,
2938
+ * a missing root field or an undeclared variable is an error. Without a
2939
+ * schema nothing can be judged, so the stage stays advisory.
2380
2940
  */
2381
2941
  validateContext(expression, contextSchema) {
2382
2942
  const errors = [];
2383
2943
  const warnings = [];
2944
+ const structural = maskStringLiterals(expression);
2384
2945
  if (!contextSchema) {
2385
2946
  warnings.push({
2386
2947
  code: "CTX-NO_SCHEMA",
2387
2948
  message: "No ContextSchema provided, skipping context validation",
2949
+ severity: "warning",
2388
2950
  stage: "context"
2389
2951
  });
2390
2952
  return { passed: true, errors, warnings };
2391
2953
  }
2392
- const refs = this.extractReferences(expression);
2393
- if (contextSchema.root) {
2394
- const rootName = contextSchema.root.name;
2954
+ const variables = contextSchema.variables ?? {};
2955
+ const functions = contextSchema.functions ?? {};
2956
+ const beans = contextSchema.beans ?? {};
2957
+ const root = contextSchema.root;
2958
+ const rootFields = root?.fields ?? {};
2959
+ const refs = this.extractReferences(structural);
2960
+ if (root) {
2395
2961
  for (const ref of refs) {
2396
- if (ref.startsWith(`#${rootName}`)) {
2397
- const parts = ref.split(".");
2398
- const field = parts[1];
2399
- if (field && !(field in contextSchema.root.fields)) {
2400
- warnings.push({
2401
- code: "CTX-UNKNOWN_FIELD",
2402
- message: `Field '${field}' not found in root '${rootName}'`,
2403
- stage: "context"
2404
- });
2405
- }
2962
+ if (!ref.startsWith(`#${root.name}.`)) continue;
2963
+ const field = ref.split(".")[1];
2964
+ if (field && !(field in rootFields)) {
2965
+ errors.push({
2966
+ code: "CTX-UNKNOWN_FIELD",
2967
+ message: `Field '${field}' not found in root '${root.name}'`,
2968
+ severity: "error",
2969
+ stage: "context",
2970
+ requiresLLM: true
2971
+ });
2406
2972
  }
2407
2973
  }
2408
2974
  }
2409
2975
  for (const ref of refs) {
2410
- if (ref.startsWith("#") && !ref.includes(".")) {
2411
- const varName = ref.slice(1);
2412
- const isRoot = contextSchema.root?.name === varName;
2413
- const isVariable = varName in contextSchema.variables;
2414
- const isFunction = varName in contextSchema.functions;
2415
- if (!isRoot && !isVariable && !isFunction) {
2416
- warnings.push({
2417
- code: "CTX-UNKNOWN_REF",
2418
- message: `Unknown reference '${ref}'`,
2419
- stage: "context"
2420
- });
2421
- }
2976
+ if (!ref.startsWith("#") || ref.includes(".")) continue;
2977
+ const varName = ref.slice(1);
2978
+ const known = varName === "this" || varName === "root" || root?.name === varName || varName in variables || varName in functions || // A bare `#name` may also name a root field, which the generator emits
2979
+ // as shorthand for `#root.name`.
2980
+ varName in rootFields;
2981
+ if (!known) {
2982
+ errors.push({
2983
+ code: "CTX-UNKNOWN_REF",
2984
+ message: `Unknown reference '${ref}'`,
2985
+ severity: "error",
2986
+ stage: "context",
2987
+ requiresLLM: true
2988
+ });
2422
2989
  }
2423
2990
  }
2424
- const beanMatch = expression.match(/@(\w+)/g);
2425
- if (beanMatch && contextSchema.beans) {
2991
+ const beanMatch = structural.match(/@(\w+)/g);
2992
+ if (beanMatch) {
2426
2993
  for (const b of beanMatch) {
2427
2994
  const beanName = b.slice(1);
2428
- if (!(beanName in contextSchema.beans)) {
2429
- warnings.push({
2995
+ if (!(beanName in beans)) {
2996
+ errors.push({
2430
2997
  code: "CTX-UNKNOWN_BEAN",
2431
2998
  message: `Bean '${beanName}' not found in ContextSchema`,
2432
- stage: "context"
2999
+ severity: "error",
3000
+ stage: "context",
3001
+ requiresLLM: true
2433
3002
  });
2434
3003
  }
2435
3004
  }
@@ -2441,7 +3010,62 @@ var ValidationPipeline = class {
2441
3010
  };
2442
3011
  }
2443
3012
  /**
2444
- * Check if parentheses are balanced
3013
+ * Stage 5: Final Check the generated expression must parse.
3014
+ *
3015
+ * When an evaluator is configured the parse stage already performed a real
3016
+ * parse; this mandatory final gate adds the structural check that a truncated
3017
+ * tail (an expression ending on an operator) is never accepted, even when no
3018
+ * engine is wired in. It is deliberately conservative: only a trailing
3019
+ * operator or an unterminated literal fails, so no well-formed expression is
3020
+ * rejected.
3021
+ */
3022
+ validateFinal(expression) {
3023
+ const errors = [];
3024
+ const warnings = [];
3025
+ if (this.isManifestlyIncomplete(expression)) {
3026
+ errors.push({
3027
+ code: "FINAL-TRUNCATED",
3028
+ message: "Expression is incomplete: it ends with an operator or is not terminated",
3029
+ severity: "error",
3030
+ stage: "final",
3031
+ requiresLLM: true
3032
+ });
3033
+ }
3034
+ return { passed: errors.length === 0, errors, warnings };
3035
+ }
3036
+ /**
3037
+ * Whether the expression is obviously incomplete.
3038
+ *
3039
+ * The last token is obtained from spel-ts's tokenizer, so a trailing
3040
+ * operator keyword or an unterminated string literal is detected exactly,
3041
+ * and text inside a literal is never read as a trailing operator.
3042
+ */
3043
+ isManifestlyIncomplete(expression) {
3044
+ if (expression.trim().length === 0) return true;
3045
+ const tokenizer = new Tokenizer(expression);
3046
+ let lastKind = null;
3047
+ let lastLiteral;
3048
+ let previousKind = null;
3049
+ try {
3050
+ for (; ; ) {
3051
+ const token = tokenizer.nextToken();
3052
+ if (token.kind === TokenKind.EOF) break;
3053
+ previousKind = lastKind;
3054
+ lastKind = token.kind;
3055
+ lastLiteral = token.literal;
3056
+ }
3057
+ } catch {
3058
+ return hasUnterminatedStringLiteral(expression);
3059
+ }
3060
+ if (lastKind === null) return true;
3061
+ if (INCOMPLETE_TRAILING_TOKENS.has(lastKind)) return true;
3062
+ if (lastLiteral !== void 0 && /^[A-Za-z]+$/.test(lastLiteral) && lastKind !== TokenKind.LITERAL_STRING && previousKind !== TokenKind.DOT && previousKind !== TokenKind.SAFE_NAV && WORD_OPERATORS.has(lastLiteral.toLowerCase())) {
3063
+ return true;
3064
+ }
3065
+ return false;
3066
+ }
3067
+ /**
3068
+ * Check if parentheses are balanced. Expects a literal-masked expression.
2445
3069
  */
2446
3070
  hasBalancedParentheses(expression) {
2447
3071
  const stack = [];
@@ -2457,17 +3081,29 @@ var ValidationPipeline = class {
2457
3081
  return stack.length === 0;
2458
3082
  }
2459
3083
  /**
2460
- * Extract all identifier references from expression
3084
+ * Extract all identifier references from expression.
3085
+ *
3086
+ * A bare `#x` that is only the head of a dotted reference (`#x.y`) is not
3087
+ * emitted on its own: a dotted reference names a property of some object,
3088
+ * not a variable of that name.
2461
3089
  */
2462
3090
  extractReferences(expression) {
2463
3091
  const refs = [];
3092
+ const dottedHeads = /* @__PURE__ */ new Set();
2464
3093
  const varMatch = expression.matchAll(/#(\w+(?:\.\w+(?:\.\w+)?)?)/g);
2465
3094
  for (const m of varMatch) {
2466
- refs.push(`#${m[1]}`);
3095
+ const ref = `#${m[1]}`;
3096
+ if (!refs.includes(ref)) {
3097
+ refs.push(ref);
3098
+ }
3099
+ if (ref.includes(".")) {
3100
+ dottedHeads.add(ref.split(".")[0]);
3101
+ }
2467
3102
  }
2468
3103
  const simpleMatch = expression.matchAll(/#(\w+)(?!\w*\()/g);
2469
3104
  for (const m of simpleMatch) {
2470
3105
  const ref = `#${m[1]}`;
3106
+ if (dottedHeads.has(ref)) continue;
2471
3107
  if (!refs.includes(ref)) {
2472
3108
  refs.push(ref);
2473
3109
  }
@@ -2475,98 +3111,68 @@ var ValidationPipeline = class {
2475
3111
  return refs;
2476
3112
  }
2477
3113
  };
2478
-
2479
- // src/validation/auto-fixer.ts
2480
- var AutoFixer = class {
2481
- fix(expression) {
2482
- let fixed = expression;
2483
- const changes = [];
2484
- if (fixed.includes("=== undefined")) {
2485
- fixed = fixed.replace(/=== undefined/g, "== null");
2486
- changes.push("Replaced === undefined with == null");
2487
- }
2488
- if (fixed.includes("!== undefined")) {
2489
- fixed = fixed.replace(/!== undefined/g, "!= null");
2490
- changes.push("Replaced !== undefined with != null");
2491
- }
2492
- if (fixed.includes("!==")) {
2493
- const count = fixed.match(/!==/g).length;
2494
- fixed = fixed.replace(/!==/g, "!=");
2495
- changes.push(`Replaced ${count}x !== with !=`);
2496
- }
2497
- if (fixed.includes("===")) {
2498
- const count = fixed.match(/===/g).length;
2499
- fixed = fixed.replace(/===/g, "==");
2500
- changes.push(`Replaced ${count}x === with ==`);
2501
- }
2502
- if (fixed.includes("&&")) {
2503
- const count = fixed.match(/&&/g).length;
2504
- fixed = fixed.replace(/&&/g, "and");
2505
- changes.push(`Replaced ${count}x && with and`);
2506
- }
2507
- if (fixed.includes("||")) {
2508
- const count = fixed.match(/\|\|/g).length;
2509
- fixed = fixed.replace(/\|\|/g, "or");
2510
- changes.push(`Replaced ${count}x || with or`);
2511
- }
2512
- const singleQuoteCount = (fixed.match(/'/g) ?? []).length;
2513
- if (singleQuoteCount % 2 !== 0) {
2514
- if (!fixed.endsWith("'")) {
2515
- fixed = fixed + "'";
2516
- changes.push("Added missing closing single quote");
2517
- }
2518
- }
2519
- const doubleQuoteCount = (fixed.match(/"/g) ?? []).length;
2520
- if (doubleQuoteCount % 2 !== 0) {
2521
- if (!fixed.endsWith('"')) {
2522
- fixed = fixed + '"';
2523
- changes.push("Added missing closing double quote");
2524
- }
2525
- }
2526
- const parenFix = this.fixAllBrackets(fixed);
2527
- if (parenFix !== fixed) {
2528
- changes.push("Fixed unbalanced brackets");
2529
- fixed = parenFix;
2530
- }
2531
- if (fixed.includes("> ==")) {
2532
- fixed = fixed.replace(/> ==/g, ">=");
2533
- changes.push("Replaced > == with >=");
2534
- }
2535
- if (fixed.includes("< ==")) {
2536
- fixed = fixed.replace(/< ==/g, "<=");
2537
- changes.push("Replaced < == with <=");
2538
- }
2539
- if (fixed.includes("? :")) {
2540
- fixed = fixed.replace(/\?\s*:\s*/g, " ?: ");
2541
- changes.push("Fixed Elvis operator spacing");
2542
- }
2543
- const wasFixed = changes.length > 0;
2544
- return {
2545
- wasFixed,
2546
- expression: wasFixed ? fixed : expression,
2547
- changes
2548
- };
2549
- }
2550
- fixAllBrackets(expr) {
2551
- let result = expr;
2552
- const openParen = (result.match(/\(/g) ?? []).length;
2553
- const closeParen = (result.match(/\)/g) ?? []).length;
2554
- if (openParen > closeParen) {
2555
- result += ")".repeat(openParen - closeParen);
2556
- }
2557
- const openBracket = (result.match(/\[/g) ?? []).length;
2558
- const closeBracket = (result.match(/\]/g) ?? []).length;
2559
- if (openBracket > closeBracket) {
2560
- result += "]".repeat(openBracket - closeBracket);
2561
- }
2562
- const openBrace = (result.match(/\{/g) ?? []).length;
2563
- const closeBrace = (result.match(/\}/g) ?? []).length;
2564
- if (openBrace > closeBrace) {
2565
- result += "}".repeat(openBrace - closeBrace);
2566
- }
2567
- return result;
2568
- }
2569
- };
3114
+ var INCOMPLETE_TRAILING_TOKENS = /* @__PURE__ */ new Set([
3115
+ TokenKind.PLUS,
3116
+ TokenKind.MINUS,
3117
+ TokenKind.STAR,
3118
+ TokenKind.SLASH,
3119
+ TokenKind.PERCENT,
3120
+ TokenKind.MOD,
3121
+ TokenKind.POWER,
3122
+ TokenKind.INC,
3123
+ TokenKind.DEC,
3124
+ TokenKind.EQ,
3125
+ TokenKind.NE,
3126
+ TokenKind.LT,
3127
+ TokenKind.LE,
3128
+ TokenKind.GT,
3129
+ TokenKind.GE,
3130
+ TokenKind.AND,
3131
+ TokenKind.OR,
3132
+ TokenKind.NOT,
3133
+ TokenKind.ASSIGN,
3134
+ TokenKind.MATCHES,
3135
+ TokenKind.BETWEEN,
3136
+ TokenKind.INSTANCEOF,
3137
+ TokenKind.LPAREN,
3138
+ TokenKind.LBRACKET,
3139
+ TokenKind.LBRACE,
3140
+ TokenKind.COMMA,
3141
+ TokenKind.COLON,
3142
+ TokenKind.DOT,
3143
+ TokenKind.SAFE_NAV,
3144
+ TokenKind.QMARK,
3145
+ TokenKind.ELVIS,
3146
+ TokenKind.HASH,
3147
+ TokenKind.AT,
3148
+ TokenKind.AMP_AT,
3149
+ TokenKind.PROJECTION,
3150
+ TokenKind.SELECTION,
3151
+ TokenKind.SELECT_FIRST,
3152
+ TokenKind.SELECT_LAST,
3153
+ TokenKind.TYPE_START,
3154
+ TokenKind.NEW,
3155
+ TokenKind.DOTDOT
3156
+ ]);
3157
+ var WORD_OPERATORS = /* @__PURE__ */ new Set([
3158
+ "and",
3159
+ "or",
3160
+ "not",
3161
+ "matches",
3162
+ "between",
3163
+ "instanceof",
3164
+ // The textual operators in Spring's ALTERNATIVE_OPERATOR_NAMES (`eq`, `ne`,
3165
+ // `div`, …) tokenize as their own kinds where the engine defines one, and as
3166
+ // plain identifiers where it does not. `div` is the one that has no kind on
3167
+ // the engine build this package compiles against — `1 div 2` tokenizes as
3168
+ // IDENTIFIER so it is listed here to keep `#a div` from passing as complete.
3169
+ // A property named `div` is still safe: the preceding-dot guard below exempts
3170
+ // `#order.div`.
3171
+ "div"
3172
+ ]);
3173
+ function escapeRegExp(value) {
3174
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3175
+ }
2570
3176
 
2571
3177
  // src/validation/self-correction-loop.ts
2572
3178
  var SelfCorrectionLoop = class {
@@ -2724,20 +3330,23 @@ var StrategyRouter = class {
2724
3330
  if (contextSchema) {
2725
3331
  this.templateEngine.setContext(contextSchema);
2726
3332
  }
2727
- const patternResult = this.patternMatcher.match(nl);
2728
- if (patternResult.matched && patternResult.confidence >= this.config.patternMinConfidence) {
2729
- const validation = await this.validationPipeline.validate(patternResult.spel, contextSchema);
3333
+ const isCompound = splitClauses(nl).length > 1;
3334
+ let clauseFailure = null;
3335
+ const wholeMatch = this.patternMatcher.match(nl);
3336
+ const wholeIsFaithful = wholeMatch.matched && wholeMatch.confidence >= this.config.patternMinConfidence && (!isCompound || (wholeMatch.pattern?.tags.includes("logic") ?? false));
3337
+ if (wholeIsFaithful) {
3338
+ const validation = await this.validationPipeline.validate(wholeMatch.spel, contextSchema);
2730
3339
  if (validation.valid) {
2731
3340
  return {
2732
- expression: patternResult.spel,
3341
+ expression: wholeMatch.spel,
2733
3342
  strategy: "pattern",
2734
- confidence: patternResult.confidence,
2735
- metadata: { patternId: patternResult.pattern?.id },
3343
+ confidence: wholeMatch.confidence,
3344
+ metadata: { patternId: wholeMatch.pattern?.id },
2736
3345
  latencyMs: Date.now() - startTime
2737
3346
  };
2738
3347
  }
2739
3348
  try {
2740
- const afResult = this.autoFixer.fix(patternResult.spel);
3349
+ const afResult = this.autoFixer.fix(wholeMatch.spel);
2741
3350
  if (afResult.wasFixed) {
2742
3351
  const afValidation = await this.validationPipeline.validate(
2743
3352
  afResult.expression,
@@ -2747,8 +3356,8 @@ var StrategyRouter = class {
2747
3356
  return {
2748
3357
  expression: afResult.expression,
2749
3358
  strategy: "pattern",
2750
- confidence: patternResult.confidence * 0.95,
2751
- metadata: { patternId: patternResult.pattern?.id },
3359
+ confidence: wholeMatch.confidence * 0.95,
3360
+ metadata: { patternId: wholeMatch.pattern?.id },
2752
3361
  latencyMs: Date.now() - startTime
2753
3362
  };
2754
3363
  }
@@ -2756,6 +3365,30 @@ var StrategyRouter = class {
2756
3365
  } catch {
2757
3366
  }
2758
3367
  }
3368
+ if (isCompound && !wholeIsFaithful) {
3369
+ let decomposition = null;
3370
+ try {
3371
+ decomposition = this.decomposeClauses(nl);
3372
+ } catch (error) {
3373
+ if (!(error instanceof UnconvertibleClauseError)) throw error;
3374
+ clauseFailure = error;
3375
+ }
3376
+ if (decomposition) {
3377
+ const validation = await this.validationPipeline.validate(
3378
+ decomposition.expression,
3379
+ contextSchema
3380
+ );
3381
+ if (validation.valid) {
3382
+ return {
3383
+ expression: decomposition.expression,
3384
+ strategy: "pattern",
3385
+ confidence: decomposition.confidence,
3386
+ metadata: { clauses: decomposition.clauses },
3387
+ latencyMs: Date.now() - startTime
3388
+ };
3389
+ }
3390
+ }
3391
+ }
2759
3392
  const intentResult = this.intentClassifier.classify(nl);
2760
3393
  let templateResult = null;
2761
3394
  try {
@@ -2789,7 +3422,7 @@ var StrategyRouter = class {
2789
3422
  }
2790
3423
  }
2791
3424
  if (providers.length === 0) {
2792
- throw new Error("No LLM providers available");
3425
+ throw clauseFailure ?? new Error("No LLM providers available");
2793
3426
  }
2794
3427
  let lastError = null;
2795
3428
  for (const provider of providers) {
@@ -2835,6 +3468,33 @@ var StrategyRouter = class {
2835
3468
  /**
2836
3469
  * Get PatternMatcher (for external testing/debugging)
2837
3470
  */
3471
+ /**
3472
+ * Convert a sentence that joins its clauses with a logical connector.
3473
+ *
3474
+ * Returns `null` when `nl` has no top-level connector, in which case the caller
3475
+ * should use its ordinary single-pass conversion. Throws
3476
+ * {@link UnconvertibleClauseError} when a clause cannot be converted, so the
3477
+ * caller can refuse instead of emitting a partial rule.
3478
+ */
3479
+ decomposeClauses(nl) {
3480
+ if (splitClauses(nl).length <= 1) return null;
3481
+ const confidences = [];
3482
+ const convert = (clause) => {
3483
+ const result = this.patternMatcher.match(clause);
3484
+ if (!result.matched || result.confidence < this.config.patternMinConfidence) {
3485
+ return null;
3486
+ }
3487
+ confidences.push(result.confidence);
3488
+ return result.spel;
3489
+ };
3490
+ const decomposition = decompose(nl, convert);
3491
+ if (decomposition === null) return null;
3492
+ return {
3493
+ expression: decomposition.expression,
3494
+ clauses: decomposition.clauses,
3495
+ confidence: confidences.length > 0 ? Math.min(...confidences) : 0
3496
+ };
3497
+ }
2838
3498
  getPatternMatcher() {
2839
3499
  return this.patternMatcher;
2840
3500
  }
@@ -2918,6 +3578,19 @@ var NL2SpelEngine = class {
2918
3578
  if (options.offlineOnly) {
2919
3579
  const patternMatcher = this.router.getPatternMatcher();
2920
3580
  const patternResult = patternMatcher.match(nl);
3581
+ const isCompound = splitClauses(nl).length > 1;
3582
+ const wholeIsFaithful = patternResult.matched && (patternResult.pattern?.tags.includes("logic") ?? false);
3583
+ if (isCompound && !wholeIsFaithful) {
3584
+ const decomposition = this.router.decomposeClauses(nl);
3585
+ if (decomposition) {
3586
+ return {
3587
+ expression: decomposition.expression,
3588
+ strategy: "pattern",
3589
+ confidence: decomposition.confidence,
3590
+ latencyMs: Date.now() - startTime
3591
+ };
3592
+ }
3593
+ }
2921
3594
  if (patternResult.matched) {
2922
3595
  return {
2923
3596
  expression: patternResult.spel,
@@ -2995,5 +3668,9 @@ export {
2995
3668
  SelfCorrectionLoop,
2996
3669
  StrategyRouter,
2997
3670
  TemplateEngine,
2998
- ValidationPipeline
3671
+ UnconvertibleClauseError,
3672
+ UnmappedFieldError,
3673
+ ValidationPipeline,
3674
+ decompose,
3675
+ splitClauses
2999
3676
  };