@agentix-e/nl2spel 1.3.0 → 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
@@ -394,10 +394,55 @@ var ChineseNumberParser = class {
394
394
  };
395
395
 
396
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
+ };
397
440
  var PatternMatcher = class {
398
441
  _patterns;
399
- constructor(patterns = []) {
442
+ fieldPolicy;
443
+ constructor(patterns = [], options = {}) {
400
444
  this._patterns = [...patterns];
445
+ this.fieldPolicy = options.fieldPolicy ?? "passthrough";
401
446
  this.sortByPriority();
402
447
  }
403
448
  get patternCount() {
@@ -431,9 +476,22 @@ var PatternMatcher = class {
431
476
  }
432
477
  }
433
478
  }
434
- const spel = this.fillTemplate(pattern, slots, normalized, matchResult);
479
+ const { expression, unmappedFields } = this.fillTemplate(
480
+ pattern,
481
+ slots,
482
+ normalized,
483
+ matchResult
484
+ );
435
485
  const latencyMs2 = Date.now() - startTime;
436
- 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
+ };
437
495
  }
438
496
  const latencyMs = Date.now() - startTime;
439
497
  return { matched: false, confidence: 0, latencyMs };
@@ -454,13 +512,20 @@ var PatternMatcher = class {
454
512
  if (value !== void 0) slots[key] = value;
455
513
  }
456
514
  }
515
+ const { expression, unmappedFields } = this.fillTemplate(
516
+ pattern,
517
+ slots,
518
+ normalized,
519
+ matchResult
520
+ );
457
521
  results.push({
458
522
  matched: true,
459
523
  pattern,
460
- spel: this.fillTemplate(pattern, slots, normalized, matchResult),
524
+ spel: expression,
461
525
  confidence: pattern.confidence,
462
526
  latencyMs: 0,
463
- slots
527
+ slots,
528
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
464
529
  });
465
530
  }
466
531
  return results;
@@ -482,46 +547,37 @@ var PatternMatcher = class {
482
547
  if (/^(?:商品|product|item)/i.test(input)) return "product";
483
548
  return "order";
484
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
+ }
485
557
  /**
486
558
  * Extract Chinese field names from input and map to SpEL fields
487
559
  */
488
560
  extractChineseField(input) {
489
- const m = input.match(/^[^\s,,、]+/);
490
- if (!m) return "value";
491
- const first = m[0];
492
- const cnMap = {
493
- \u5907\u6CE8: "remark",
494
- \u8BF4\u660E: "description",
495
- \u63CF\u8FF0: "description",
496
- \u91D1\u989D: "amount",
497
- \u6570\u91CF: "count",
498
- \u4E2A\u6570: "count",
499
- \u72B6\u6001: "status",
500
- \u7C7B\u578B: "type",
501
- \u540D\u79F0: "name",
502
- \u6807\u9898: "title",
503
- \u5730\u5740: "address",
504
- \u90AE\u7BB1: "email",
505
- \u624B\u673A: "phone",
506
- \u7535\u8BDD: "phone",
507
- \u65E5\u671F: "date",
508
- \u65F6\u95F4: "time",
509
- \u5E74\u9F84: "age",
510
- \u4EF7\u683C: "price",
511
- \u7528\u6237\u540D: "name",
512
- \u6743\u9650: "role",
513
- \u6807\u7B7E: "tags",
514
- \u5217\u8868: "list",
515
- \u6570\u7EC4: "items",
516
- \u6587\u4EF6: "file",
517
- \u6587\u4EF6\u540D: "name",
518
- \u8FC7\u671F: "expiryDate",
519
- \u521B\u5EFA: "createdAt",
520
- \u6709\u6548: "valid",
521
- \u6D3B\u8DC3: "active",
522
- \u6FC0\u6D3B: "active"
523
- };
524
- 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 };
525
581
  }
526
582
  /**
527
583
  * Template filling and value transformation
@@ -529,7 +585,20 @@ var PatternMatcher = class {
529
585
  fillTemplate(pattern, slots, originalInput, _matchResult) {
530
586
  let result = pattern.spelTemplate;
531
587
  const hasFieldSlot = "field" in slots;
532
- 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
+ }
533
602
  const root = this.inferRoot(originalInput);
534
603
  result = result.replace(/\{field\}/g, field);
535
604
  result = result.replace(/\{root\}/g, root);
@@ -557,14 +626,7 @@ var PatternMatcher = class {
557
626
  result = result.replace(`{${key}}`, transformedValue ?? "");
558
627
  }
559
628
  result = result.replace(/\{[a-zA-Z_]+\}/g, "");
560
- return result.trim();
561
- }
562
- /**
563
- * Infer SpEL field name from capture group
564
- */
565
- inferFieldFromCapture(captured) {
566
- if (/^[a-zA-Z_]\w*$/.test(captured)) return captured;
567
- return this.extractChineseField(captured);
629
+ return { expression: result.trim(), unmappedFields };
568
630
  }
569
631
  };
570
632
 
@@ -630,7 +692,7 @@ var BUILTIN_PATTERNS = [
630
692
  slots: {},
631
693
  priority: 98,
632
694
  tags: ["null", "isNotNull"],
633
- examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#\u5907\u6CE8 != null" }],
695
+ examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#remark != null" }],
634
696
  difficulty: "easy",
635
697
  confidence: 0.98
636
698
  },
@@ -641,7 +703,7 @@ var BUILTIN_PATTERNS = [
641
703
  slots: {},
642
704
  priority: 97,
643
705
  tags: ["null", "isNull"],
644
- examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#\u5907\u6CE8 == null" }],
706
+ examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#remark == null" }],
645
707
  difficulty: "easy",
646
708
  confidence: 0.98
647
709
  },
@@ -672,29 +734,29 @@ var BUILTIN_PATTERNS = [
672
734
  // ================================================================
673
735
  {
674
736
  id: "CN-CMP-GE",
675
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
737
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
676
738
  spelTemplate: "#{field} >= {value}",
677
739
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
678
740
  priority: 93,
679
741
  tags: ["comparison", "ge", "chinese"],
680
- examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#\u91D1\u989D >= 100" }],
742
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#amount >= 100" }],
681
743
  difficulty: "easy",
682
744
  confidence: 0.95
683
745
  },
684
746
  {
685
747
  id: "CN-CMP-LE",
686
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
748
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
687
749
  spelTemplate: "#{field} <= {value}",
688
750
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
689
751
  priority: 93,
690
752
  tags: ["comparison", "le", "chinese"],
691
- examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#\u91D1\u989D <= 500" }],
753
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#amount <= 500" }],
692
754
  difficulty: "easy",
693
755
  confidence: 0.95
694
756
  },
695
757
  {
696
758
  id: "CN-CMP-GT",
697
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
759
+ match: /^(?<field>[^\s,,、]+?)\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
698
760
  spelTemplate: "#{field} > {value}",
699
761
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
700
762
  priority: 92,
@@ -705,7 +767,7 @@ var BUILTIN_PATTERNS = [
705
767
  },
706
768
  {
707
769
  id: "CN-CMP-LT",
708
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
770
+ match: /^(?<field>[^\s,,、]+?)\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
709
771
  spelTemplate: "#{field} < {value}",
710
772
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
711
773
  priority: 92,
@@ -719,7 +781,7 @@ var BUILTIN_PATTERNS = [
719
781
  // ================================================================
720
782
  {
721
783
  id: "CN-EQ-STATUS",
722
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
784
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
723
785
  spelTemplate: "#{field} == '{value}'",
724
786
  slots: { value: { key: "value", type: "string" } },
725
787
  priority: 85,
@@ -742,7 +804,7 @@ var BUILTIN_PATTERNS = [
742
804
  // CN: "order status is not cancelled" — must be before NOT pattern
743
805
  {
744
806
  id: "CN-NE-STATUS",
745
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
807
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
746
808
  spelTemplate: "#{field} != '{value}'",
747
809
  slots: { value: { key: "value", type: "string" } },
748
810
  priority: 91,
@@ -765,12 +827,12 @@ var BUILTIN_PATTERNS = [
765
827
  // CN/EN: count equality
766
828
  {
767
829
  id: "CN-EQ-COUNT",
768
- match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|计数)?\s*(?:等于|==)\s*(?<value>\d+)/,
830
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|==)\s*(?<value>\d+)/,
769
831
  spelTemplate: "#{field} == {value}",
770
832
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
771
833
  priority: 91,
772
834
  tags: ["comparison", "eq", "number"],
773
- examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#\u6570\u91CF == 5" }],
835
+ examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#count == 5" }],
774
836
  difficulty: "easy",
775
837
  confidence: 0.95
776
838
  },
@@ -812,7 +874,9 @@ var BUILTIN_PATTERNS = [
812
874
  },
813
875
  {
814
876
  id: "CN-PERM-PERM",
815
- 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>.+)$/,
816
880
  spelTemplate: "hasPermission('{permission}')",
817
881
  slots: { permission: { key: "permission", type: "string" } },
818
882
  priority: 88,
@@ -837,7 +901,7 @@ var BUILTIN_PATTERNS = [
837
901
  // ================================================================
838
902
  {
839
903
  id: "CN-COLL-EMPTY",
840
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
904
+ match: /^(?<field>[^\s,,、]+?)\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
841
905
  spelTemplate: "#{field}.isEmpty()",
842
906
  slots: {},
843
907
  priority: 89,
@@ -859,7 +923,7 @@ var BUILTIN_PATTERNS = [
859
923
  },
860
924
  {
861
925
  id: "CN-COLL-NOTEMPTY",
862
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
926
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
863
927
  spelTemplate: "!#{field}.isEmpty()",
864
928
  slots: {},
865
929
  priority: 89,
@@ -881,7 +945,7 @@ var BUILTIN_PATTERNS = [
881
945
  },
882
946
  {
883
947
  id: "CN-COLL-CONTAINS",
884
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
948
+ match: /^(?<field>[^\s,,、]+?)\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
885
949
  spelTemplate: "#{field}.contains('{element}')",
886
950
  slots: { element: { key: "element", type: "string" } },
887
951
  priority: 87,
@@ -903,7 +967,7 @@ var BUILTIN_PATTERNS = [
903
967
  },
904
968
  {
905
969
  id: "CN-COLL-SIZE",
906
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
970
+ match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
907
971
  spelTemplate: "#{field}.size() {op} {value}",
908
972
  slots: {
909
973
  value: { key: "value", type: "number", transform: "toNumber" },
@@ -934,7 +998,7 @@ var BUILTIN_PATTERNS = [
934
998
  // ================================================================
935
999
  {
936
1000
  id: "CN-STR-CONTAINS",
937
- match: /^(?<field>[^\s,,、]+?)(?:备注|名称|描述|标签|标题)?\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
1001
+ match: /^(?<field>[^\s,,、]+?)\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
938
1002
  spelTemplate: "#{field}.contains('{substr}')",
939
1003
  slots: { substr: { key: "substr", type: "string" } },
940
1004
  priority: 85,
@@ -983,7 +1047,7 @@ var BUILTIN_PATTERNS = [
983
1047
  slots: { suffix: { key: "suffix", type: "string" } },
984
1048
  priority: 85,
985
1049
  tags: ["string", "endsWith"],
986
- 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')" }],
987
1051
  difficulty: "easy",
988
1052
  confidence: 0.95
989
1053
  },
@@ -1026,21 +1090,21 @@ var BUILTIN_PATTERNS = [
1026
1090
  {
1027
1091
  id: "CN-RANGE-BETWEEN",
1028
1092
  match: /^(?<field>[^\s,,、]+?)\s*(?:在|介于)\s*(?<min>\d+)\s*(?:和|到|~)\s*(?<max>\d+)\s*(?:之间|范围)?/,
1029
- spelTemplate: "#{field} between {{{min}, {max}}}",
1093
+ spelTemplate: "#{field} between {{min}, {max}}",
1030
1094
  slots: {
1031
1095
  min: { key: "min", type: "number", transform: "toNumber" },
1032
1096
  max: { key: "max", type: "number", transform: "toNumber" }
1033
1097
  },
1034
1098
  priority: 82,
1035
1099
  tags: ["range", "between"],
1036
- 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}" }],
1037
1101
  difficulty: "easy",
1038
1102
  confidence: 0.95
1039
1103
  },
1040
1104
  {
1041
1105
  id: "EN-RANGE-BETWEEN",
1042
1106
  match: /\b(?<field>\w+)\s+between\s+(?<min>\d+)\s+and\s+(?<max>\d+)/i,
1043
- spelTemplate: "#{field} between {{{min}, {max}}}",
1107
+ spelTemplate: "#{field} between {{min}, {max}}",
1044
1108
  slots: {
1045
1109
  min: { key: "min", type: "number", transform: "toNumber" },
1046
1110
  max: { key: "max", type: "number", transform: "toNumber" }
@@ -1061,7 +1125,7 @@ var BUILTIN_PATTERNS = [
1061
1125
  slots: { default: { key: "default", type: "string" } },
1062
1126
  priority: 78,
1063
1127
  tags: ["elvis"],
1064
- 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'" }],
1065
1129
  difficulty: "medium",
1066
1130
  confidence: 0.85
1067
1131
  },
@@ -1167,7 +1231,11 @@ var BUILTIN_PATTERNS = [
1167
1231
  // ================================================================
1168
1232
  {
1169
1233
  id: "CN-BOOL-TRUE",
1170
- 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*)$/,
1171
1239
  spelTemplate: "#{field} == true",
1172
1240
  slots: {},
1173
1241
  priority: 74,
@@ -1178,7 +1246,10 @@ var BUILTIN_PATTERNS = [
1178
1246
  },
1179
1247
  {
1180
1248
  id: "CN-BOOL-FALSE",
1181
- 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*)$/,
1182
1253
  spelTemplate: "#{field} == false",
1183
1254
  slots: {},
1184
1255
  priority: 73,
@@ -1286,7 +1357,7 @@ var BUILTIN_PATTERNS = [
1286
1357
  // ================================================================
1287
1358
  {
1288
1359
  id: "CN-LOGIC-NOT",
1289
- match: /^不是\s+(?<expr>.+)/,
1360
+ match: /^不是\s*(?<expr>.+)/,
1290
1361
  spelTemplate: "!({expr})",
1291
1362
  slots: { expr: { key: "expr", type: "variable" } },
1292
1363
  priority: 62,
@@ -1311,8 +1382,8 @@ var BUILTIN_PATTERNS = [
1311
1382
  // ================================================================
1312
1383
  {
1313
1384
  id: "CN-SELECT-FIRST",
1314
- match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、]+)/,
1315
- 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}]",
1316
1387
  slots: {
1317
1388
  root: { key: "root", type: "variable" },
1318
1389
  field: { key: "field", type: "variable" },
@@ -1320,14 +1391,14 @@ var BUILTIN_PATTERNS = [
1320
1391
  },
1321
1392
  priority: 60,
1322
1393
  tags: ["selection", "first"],
1323
- 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]" }],
1324
1395
  difficulty: "medium",
1325
1396
  confidence: 0.8
1326
1397
  },
1327
1398
  {
1328
1399
  id: "CN-SELECT-ALL",
1329
- match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、]+)/,
1330
- 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}]",
1331
1402
  slots: {
1332
1403
  root: { key: "root", type: "variable" },
1333
1404
  field: { key: "field", type: "variable" },
@@ -1335,28 +1406,28 @@ var BUILTIN_PATTERNS = [
1335
1406
  },
1336
1407
  priority: 60,
1337
1408
  tags: ["selection", "all"],
1338
- 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]" }],
1339
1410
  difficulty: "medium",
1340
1411
  confidence: 0.8
1341
1412
  },
1342
1413
  {
1343
1414
  id: "CN-PROJ",
1344
- match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:值|金额|名称|价格|name|amount|price)?/,
1345
- spelTemplate: "#{root}.items.![#{this}.{field}]",
1415
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:的)?(?:值|金额|名称|价格|name|amount|price)?$/,
1416
+ spelTemplate: "#{root}.items.![#this.{field}]",
1346
1417
  slots: {
1347
1418
  root: { key: "root", type: "variable" },
1348
1419
  field: { key: "field", type: "variable" }
1349
1420
  },
1350
1421
  priority: 55,
1351
1422
  tags: ["projection"],
1352
- 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]" }],
1353
1424
  difficulty: "medium",
1354
1425
  confidence: 0.75
1355
1426
  },
1356
1427
  {
1357
1428
  id: "EN-SELECT-ALL",
1358
1429
  match: /all\s+(?<root>\w+)\s+with\s+(?<field>\w+)\s*>\s*(?<value>\d+)/i,
1359
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1430
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1360
1431
  slots: {
1361
1432
  root: { key: "root", type: "variable" },
1362
1433
  field: { key: "field", type: "variable" },
@@ -1364,13 +1435,149 @@ var BUILTIN_PATTERNS = [
1364
1435
  },
1365
1436
  priority: 50,
1366
1437
  tags: ["selection", "all", "english"],
1367
- 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]" }],
1368
1439
  difficulty: "medium",
1369
1440
  confidence: 0.8
1370
1441
  }
1371
1442
  ];
1372
1443
  BUILTIN_PATTERNS.sort((a, b) => b.priority - a.priority);
1373
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
+
1374
1581
  // src/template/nl-intent.ts
1375
1582
  var NLIntent = /* @__PURE__ */ ((NLIntent2) => {
1376
1583
  NLIntent2["COMPARISON"] = "COMPARISON";
@@ -1409,7 +1616,7 @@ var INTENT_KEYWORDS = {
1409
1616
  en: ["greater", "less", "equal", "above", "below", "exceed", ">", "<", "==", "!=", ">=", "<="]
1410
1617
  },
1411
1618
  ["NULL_CHECK" /* NULL_CHECK */]: {
1412
- 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"],
1413
1620
  en: ["null", "empty", "is null", "is not null", "is empty", "is not empty"]
1414
1621
  },
1415
1622
  ["PERMISSION_CHECK" /* PERMISSION_CHECK */]: {
@@ -1446,7 +1653,10 @@ var INTENT_KEYWORDS = {
1446
1653
  },
1447
1654
  ["BOOLEAN" /* BOOLEAN */]: {
1448
1655
  zh: ["\u662F\u5426", "\u771F\u5047", "true", "false", "\u662F", "\u5426"],
1449
- 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"]
1450
1660
  },
1451
1661
  ["DATE" /* DATE */]: {
1452
1662
  zh: ["\u65E5\u671F", "\u65F6\u95F4", "\u4E4B\u540E", "\u4E4B\u524D", "\u65E9\u4E8E", "\u665A\u4E8E"],
@@ -1465,6 +1675,38 @@ var INTENT_KEYWORDS = {
1465
1675
  en: ["plus", "minus", "multiply", "divide", "mod", "sum", "average"]
1466
1676
  }
1467
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
+ }
1468
1710
  var IntentClassifier = class {
1469
1711
  /**
1470
1712
  * Main method for classifying natural language input
@@ -1498,6 +1740,9 @@ var IntentClassifier = class {
1498
1740
  if (/isempty|\.isEmpty|列表|数组|集合/.test(normalized)) {
1499
1741
  intentScores.set("COLLECTION" /* COLLECTION */, (intentScores.get("COLLECTION" /* COLLECTION */) ?? 0) + 1);
1500
1742
  }
1743
+ if (detectNullPredicate(normalized)) {
1744
+ intentScores.set("NULL_CHECK" /* NULL_CHECK */, (intentScores.get("NULL_CHECK" /* NULL_CHECK */) ?? 0) + 2);
1745
+ }
1501
1746
  const entities = this.extractEntities(normalized);
1502
1747
  const operators = this.extractOperators(normalized);
1503
1748
  const logicalConnectors = this.extractLogicalConnectors(normalized);
@@ -1610,6 +1855,68 @@ var IntentClassifier = class {
1610
1855
  };
1611
1856
 
1612
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
+ ]);
1613
1920
  var TEMPLATE_LIBRARY = {
1614
1921
  ["COMPARISON" /* COMPARISON */]: [
1615
1922
  {
@@ -1854,8 +2161,9 @@ var TemplateEngine = class {
1854
2161
  selectBestTemplate(templates, intentResult, input) {
1855
2162
  let bestScore = -1;
1856
2163
  let bestTemplate = null;
1857
- const hasEmptyKeyword = /为空|empty|null/i.test(input);
1858
- const hasNotEmptyKeyword = /不为空|not empty|not null/i.test(input);
2164
+ const polarity = detectNullPredicate(input);
2165
+ const isAffirmative = polarity === "affirmative";
2166
+ const isNegated = polarity === "negated";
1859
2167
  for (const template of templates) {
1860
2168
  const conditions = template.conditions;
1861
2169
  let score = 0;
@@ -1864,8 +2172,10 @@ var TemplateEngine = class {
1864
2172
  if (conditions.hasCollection) score += 0.5;
1865
2173
  if (conditions.hasNull) score += 0.5;
1866
2174
  if (conditions.hasString) score += 1;
1867
- if (template.name.includes("IS_EMPTY") && hasEmptyKeyword) score += 2;
1868
- 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;
1869
2179
  if (conditions.entityCount) {
1870
2180
  if (conditions.entityCount.min && intentResult.entities.length < conditions.entityCount.min) {
1871
2181
  continue;
@@ -1881,37 +2191,8 @@ var TemplateEngine = class {
1881
2191
  fillTemplate(template, input, intentResult) {
1882
2192
  let expression = template;
1883
2193
  const unfilledSlots = [];
1884
- let rootName = "order";
1885
- let fieldName = "field";
1886
- if (this.contextSchema?.root) {
1887
- rootName = this.contextSchema.root.name;
1888
- const fields = Object.keys(this.contextSchema.root.fields ?? {});
1889
- for (const f of fields) {
1890
- if (input.includes(f)) {
1891
- fieldName = f;
1892
- break;
1893
- }
1894
- }
1895
- } else {
1896
- const rootMap = {
1897
- \u8BA2\u5355: "order",
1898
- order: "order",
1899
- \u7528\u6237: "user",
1900
- user: "user",
1901
- \u6587\u4EF6: "file",
1902
- file: "file",
1903
- \u8D26\u53F7: "account",
1904
- account: "account",
1905
- \u5546\u54C1: "item",
1906
- product: "item"
1907
- };
1908
- for (const [key, val] of Object.entries(rootMap)) {
1909
- if (input.includes(key)) {
1910
- rootName = val;
1911
- break;
1912
- }
1913
- }
1914
- }
2194
+ const rootName = this.resolveRootName(input);
2195
+ const fieldName = this.resolveFieldName(input);
1915
2196
  expression = expression.replace(/\{root\}/g, rootName);
1916
2197
  expression = expression.replace(/\{field\}/g, fieldName);
1917
2198
  const fieldEntities = intentResult.entities.filter((e) => e.type === "field");
@@ -1980,6 +2261,58 @@ var TemplateEngine = class {
1980
2261
  }
1981
2262
  return { expression, unfilledSlots };
1982
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
+ }
1983
2316
  };
1984
2317
 
1985
2318
  // src/template/prompts/prompt-builder.ts
@@ -2217,6 +2550,172 @@ ${userInput}
2217
2550
  }
2218
2551
  };
2219
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
+
2220
2719
  // src/validation/validation-pipeline.ts
2221
2720
  var ValidationPipeline = class {
2222
2721
  evaluator;
@@ -2236,25 +2735,29 @@ var ValidationPipeline = class {
2236
2735
  const typeStage = this.validateTypes(expression, contextSchema);
2237
2736
  const semanticStage = this.validateSemantic(expression);
2238
2737
  const contextStage = this.validateContext(expression, contextSchema);
2738
+ const finalStage = this.validateFinal(expression);
2239
2739
  errors.push(
2240
2740
  ...parseStage.errors,
2241
2741
  ...typeStage.errors,
2242
2742
  ...semanticStage.errors,
2243
- ...contextStage.errors
2743
+ ...contextStage.errors,
2744
+ ...finalStage.errors
2244
2745
  );
2245
2746
  warnings.push(
2246
2747
  ...parseStage.warnings,
2247
2748
  ...typeStage.warnings,
2248
2749
  ...semanticStage.warnings,
2249
- ...contextStage.warnings
2750
+ ...contextStage.warnings,
2751
+ ...finalStage.warnings
2250
2752
  );
2251
2753
  return {
2252
- valid: parseStage.passed && typeStage.passed && semanticStage.passed && contextStage.passed,
2754
+ valid: errors.length === 0,
2253
2755
  stages: {
2254
2756
  parse: parseStage,
2255
2757
  type: typeStage,
2256
2758
  semantic: semanticStage,
2257
- context: contextStage
2759
+ context: contextStage,
2760
+ final: finalStage
2258
2761
  },
2259
2762
  errors,
2260
2763
  warnings
@@ -2262,47 +2765,57 @@ var ValidationPipeline = class {
2262
2765
  }
2263
2766
  /**
2264
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.
2265
2772
  */
2266
2773
  async validateParse(expression) {
2267
2774
  const errors = [];
2268
2775
  const warnings = [];
2776
+ const structural = maskStringLiterals(expression);
2269
2777
  if (!expression || expression.trim().length === 0) {
2270
2778
  errors.push({
2271
2779
  code: "PARSE-EMPTY",
2272
2780
  message: "Expression is empty",
2781
+ severity: "error",
2273
2782
  stage: "parse",
2274
2783
  requiresLLM: true
2275
2784
  });
2276
2785
  return { passed: false, errors, warnings };
2277
2786
  }
2278
- if (!this.hasBalancedParentheses(expression)) {
2787
+ if (!this.hasBalancedParentheses(structural)) {
2279
2788
  errors.push({
2280
2789
  code: "PARSE-UNBALANCED_PARENS",
2281
2790
  message: "Unbalanced parentheses in expression",
2791
+ severity: "error",
2282
2792
  stage: "parse",
2283
2793
  requiresLLM: true
2284
2794
  });
2285
2795
  }
2286
- if (expression.includes("===") || expression.includes("!==")) {
2796
+ if (structural.includes("===") || structural.includes("!==")) {
2287
2797
  errors.push({
2288
2798
  code: "PARSE-JS_OPERATOR",
2289
2799
  message: "JavaScript operators detected (=== or !==), use == or != in SpEL",
2800
+ severity: "error",
2290
2801
  stage: "parse",
2291
2802
  requiresLLM: true
2292
2803
  });
2293
2804
  }
2294
- if (expression.includes("&&")) {
2805
+ if (structural.includes("&&")) {
2295
2806
  errors.push({
2296
2807
  code: "PARSE-JS_LOGIC",
2297
2808
  message: 'JavaScript && detected, use "and" in SpEL',
2809
+ severity: "error",
2298
2810
  stage: "parse",
2299
2811
  requiresLLM: true
2300
2812
  });
2301
2813
  }
2302
- if (expression.includes("||")) {
2814
+ if (structural.includes("||")) {
2303
2815
  errors.push({
2304
2816
  code: "PARSE-JS_LOGIC",
2305
2817
  message: 'JavaScript || detected, use "or" in SpEL',
2818
+ severity: "error",
2306
2819
  stage: "parse",
2307
2820
  requiresLLM: true
2308
2821
  });
@@ -2315,6 +2828,7 @@ var ValidationPipeline = class {
2315
2828
  errors.push({
2316
2829
  code: `PARSE-${pe.code ?? "SYNTAX"}`,
2317
2830
  message: pe.message,
2831
+ severity: "error",
2318
2832
  position: pe.position,
2319
2833
  stage: "parse",
2320
2834
  requiresLLM: true
@@ -2325,6 +2839,7 @@ var ValidationPipeline = class {
2325
2839
  errors.push({
2326
2840
  code: "PARSE-EXCEPTION",
2327
2841
  message: `Parse threw exception: ${err.message}`,
2842
+ severity: "error",
2328
2843
  stage: "parse",
2329
2844
  requiresLLM: true
2330
2845
  });
@@ -2337,27 +2852,43 @@ var ValidationPipeline = class {
2337
2852
  };
2338
2853
  }
2339
2854
  /**
2340
- * Stage 2: Type Check — type validation
2855
+ * Stage 2: Type Check — advisory type validation
2341
2856
  */
2342
2857
  validateTypes(expression, contextSchema) {
2343
2858
  const errors = [];
2344
2859
  const warnings = [];
2345
- const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+/;
2860
+ const structural = maskStringLiterals(expression);
2861
+ const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+|\d+\s*(?:>|<|>=|<=)\s*'(?:\\.|[^'\\])*'/;
2346
2862
  if (strNumMismatch.test(expression)) {
2347
2863
  warnings.push({
2348
2864
  code: "TYPE-STR_NUM_CMP",
2349
2865
  message: "String literal compared with number using arithmetic operator",
2866
+ severity: "warning",
2350
2867
  stage: "type"
2351
2868
  });
2352
2869
  }
2353
2870
  if (contextSchema?.root) {
2871
+ const rootRef = escapeRegExp(contextSchema.root.name);
2354
2872
  for (const [fieldName, field] of Object.entries(contextSchema.root.fields)) {
2873
+ const fieldRef = `#(?:${rootRef}\\.)?${escapeRegExp(fieldName)}`;
2355
2874
  if (field.type === "boolean") {
2356
- const boolNumPattern = new RegExp(`#\\w+\\.${fieldName}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2357
- if (boolNumPattern.test(expression)) {
2875
+ const boolNumPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2876
+ if (boolNumPattern.test(structural)) {
2358
2877
  warnings.push({
2359
2878
  code: "TYPE-BOOL_NUM_CMP",
2360
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",
2361
2892
  stage: "type"
2362
2893
  });
2363
2894
  }
@@ -2371,23 +2902,26 @@ var ValidationPipeline = class {
2371
2902
  };
2372
2903
  }
2373
2904
  /**
2374
- * Stage 3: Semantic Check — semantic reasonableness validation
2905
+ * Stage 3: Semantic Check — advisory semantic validation
2375
2906
  */
2376
2907
  validateSemantic(expression) {
2377
2908
  const errors = [];
2378
2909
  const warnings = [];
2910
+ const structural = maskStringLiterals(expression);
2379
2911
  const selfCompare = /(#\w+(?:\.\w+)*)\s*==\s*\1/;
2380
- if (selfCompare.test(expression)) {
2912
+ if (selfCompare.test(structural)) {
2381
2913
  warnings.push({
2382
2914
  code: "SEM-SELF_COMPARE",
2383
2915
  message: "Self-comparison detected: expression is always true",
2916
+ severity: "warning",
2384
2917
  stage: "semantic"
2385
2918
  });
2386
2919
  }
2387
- if (expression.includes("!!")) {
2920
+ if (structural.includes("!!")) {
2388
2921
  warnings.push({
2389
2922
  code: "SEM-DOUBLE_NEGATION",
2390
2923
  message: "Double negation detected, consider simplifying",
2924
+ severity: "warning",
2391
2925
  stage: "semantic"
2392
2926
  });
2393
2927
  }
@@ -2399,59 +2933,72 @@ var ValidationPipeline = class {
2399
2933
  }
2400
2934
  /**
2401
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.
2402
2940
  */
2403
2941
  validateContext(expression, contextSchema) {
2404
2942
  const errors = [];
2405
2943
  const warnings = [];
2944
+ const structural = maskStringLiterals(expression);
2406
2945
  if (!contextSchema) {
2407
2946
  warnings.push({
2408
2947
  code: "CTX-NO_SCHEMA",
2409
2948
  message: "No ContextSchema provided, skipping context validation",
2949
+ severity: "warning",
2410
2950
  stage: "context"
2411
2951
  });
2412
2952
  return { passed: true, errors, warnings };
2413
2953
  }
2414
- const refs = this.extractReferences(expression);
2415
- if (contextSchema.root) {
2416
- 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) {
2417
2961
  for (const ref of refs) {
2418
- if (ref.startsWith(`#${rootName}`)) {
2419
- const parts = ref.split(".");
2420
- const field = parts[1];
2421
- if (field && !(field in contextSchema.root.fields)) {
2422
- warnings.push({
2423
- code: "CTX-UNKNOWN_FIELD",
2424
- message: `Field '${field}' not found in root '${rootName}'`,
2425
- stage: "context"
2426
- });
2427
- }
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
+ });
2428
2972
  }
2429
2973
  }
2430
2974
  }
2431
2975
  for (const ref of refs) {
2432
- if (ref.startsWith("#") && !ref.includes(".")) {
2433
- const varName = ref.slice(1);
2434
- const isRoot = contextSchema.root?.name === varName;
2435
- const isVariable = varName in contextSchema.variables;
2436
- const isFunction = varName in contextSchema.functions;
2437
- if (!isRoot && !isVariable && !isFunction) {
2438
- warnings.push({
2439
- code: "CTX-UNKNOWN_REF",
2440
- message: `Unknown reference '${ref}'`,
2441
- stage: "context"
2442
- });
2443
- }
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
+ });
2444
2989
  }
2445
2990
  }
2446
- const beanMatch = expression.match(/@(\w+)/g);
2447
- if (beanMatch && contextSchema.beans) {
2991
+ const beanMatch = structural.match(/@(\w+)/g);
2992
+ if (beanMatch) {
2448
2993
  for (const b of beanMatch) {
2449
2994
  const beanName = b.slice(1);
2450
- if (!(beanName in contextSchema.beans)) {
2451
- warnings.push({
2995
+ if (!(beanName in beans)) {
2996
+ errors.push({
2452
2997
  code: "CTX-UNKNOWN_BEAN",
2453
2998
  message: `Bean '${beanName}' not found in ContextSchema`,
2454
- stage: "context"
2999
+ severity: "error",
3000
+ stage: "context",
3001
+ requiresLLM: true
2455
3002
  });
2456
3003
  }
2457
3004
  }
@@ -2463,7 +3010,62 @@ var ValidationPipeline = class {
2463
3010
  };
2464
3011
  }
2465
3012
  /**
2466
- * 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.
2467
3069
  */
2468
3070
  hasBalancedParentheses(expression) {
2469
3071
  const stack = [];
@@ -2479,17 +3081,29 @@ var ValidationPipeline = class {
2479
3081
  return stack.length === 0;
2480
3082
  }
2481
3083
  /**
2482
- * 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.
2483
3089
  */
2484
3090
  extractReferences(expression) {
2485
3091
  const refs = [];
3092
+ const dottedHeads = /* @__PURE__ */ new Set();
2486
3093
  const varMatch = expression.matchAll(/#(\w+(?:\.\w+(?:\.\w+)?)?)/g);
2487
3094
  for (const m of varMatch) {
2488
- 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
+ }
2489
3102
  }
2490
3103
  const simpleMatch = expression.matchAll(/#(\w+)(?!\w*\()/g);
2491
3104
  for (const m of simpleMatch) {
2492
3105
  const ref = `#${m[1]}`;
3106
+ if (dottedHeads.has(ref)) continue;
2493
3107
  if (!refs.includes(ref)) {
2494
3108
  refs.push(ref);
2495
3109
  }
@@ -2497,98 +3111,68 @@ var ValidationPipeline = class {
2497
3111
  return refs;
2498
3112
  }
2499
3113
  };
2500
-
2501
- // src/validation/auto-fixer.ts
2502
- var AutoFixer = class {
2503
- fix(expression) {
2504
- let fixed = expression;
2505
- const changes = [];
2506
- if (fixed.includes("=== undefined")) {
2507
- fixed = fixed.replace(/=== undefined/g, "== null");
2508
- changes.push("Replaced === undefined with == null");
2509
- }
2510
- if (fixed.includes("!== undefined")) {
2511
- fixed = fixed.replace(/!== undefined/g, "!= null");
2512
- changes.push("Replaced !== undefined with != null");
2513
- }
2514
- if (fixed.includes("!==")) {
2515
- const count = fixed.match(/!==/g).length;
2516
- fixed = fixed.replace(/!==/g, "!=");
2517
- changes.push(`Replaced ${count}x !== with !=`);
2518
- }
2519
- if (fixed.includes("===")) {
2520
- const count = fixed.match(/===/g).length;
2521
- fixed = fixed.replace(/===/g, "==");
2522
- changes.push(`Replaced ${count}x === with ==`);
2523
- }
2524
- if (fixed.includes("&&")) {
2525
- const count = fixed.match(/&&/g).length;
2526
- fixed = fixed.replace(/&&/g, "and");
2527
- changes.push(`Replaced ${count}x && with and`);
2528
- }
2529
- if (fixed.includes("||")) {
2530
- const count = fixed.match(/\|\|/g).length;
2531
- fixed = fixed.replace(/\|\|/g, "or");
2532
- changes.push(`Replaced ${count}x || with or`);
2533
- }
2534
- const singleQuoteCount = (fixed.match(/'/g) ?? []).length;
2535
- if (singleQuoteCount % 2 !== 0) {
2536
- if (!fixed.endsWith("'")) {
2537
- fixed = fixed + "'";
2538
- changes.push("Added missing closing single quote");
2539
- }
2540
- }
2541
- const doubleQuoteCount = (fixed.match(/"/g) ?? []).length;
2542
- if (doubleQuoteCount % 2 !== 0) {
2543
- if (!fixed.endsWith('"')) {
2544
- fixed = fixed + '"';
2545
- changes.push("Added missing closing double quote");
2546
- }
2547
- }
2548
- const parenFix = this.fixAllBrackets(fixed);
2549
- if (parenFix !== fixed) {
2550
- changes.push("Fixed unbalanced brackets");
2551
- fixed = parenFix;
2552
- }
2553
- if (fixed.includes("> ==")) {
2554
- fixed = fixed.replace(/> ==/g, ">=");
2555
- changes.push("Replaced > == with >=");
2556
- }
2557
- if (fixed.includes("< ==")) {
2558
- fixed = fixed.replace(/< ==/g, "<=");
2559
- changes.push("Replaced < == with <=");
2560
- }
2561
- if (fixed.includes("? :")) {
2562
- fixed = fixed.replace(/\?\s*:\s*/g, " ?: ");
2563
- changes.push("Fixed Elvis operator spacing");
2564
- }
2565
- const wasFixed = changes.length > 0;
2566
- return {
2567
- wasFixed,
2568
- expression: wasFixed ? fixed : expression,
2569
- changes
2570
- };
2571
- }
2572
- fixAllBrackets(expr) {
2573
- let result = expr;
2574
- const openParen = (result.match(/\(/g) ?? []).length;
2575
- const closeParen = (result.match(/\)/g) ?? []).length;
2576
- if (openParen > closeParen) {
2577
- result += ")".repeat(openParen - closeParen);
2578
- }
2579
- const openBracket = (result.match(/\[/g) ?? []).length;
2580
- const closeBracket = (result.match(/\]/g) ?? []).length;
2581
- if (openBracket > closeBracket) {
2582
- result += "]".repeat(openBracket - closeBracket);
2583
- }
2584
- const openBrace = (result.match(/\{/g) ?? []).length;
2585
- const closeBrace = (result.match(/\}/g) ?? []).length;
2586
- if (openBrace > closeBrace) {
2587
- result += "}".repeat(openBrace - closeBrace);
2588
- }
2589
- return result;
2590
- }
2591
- };
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
+ }
2592
3176
 
2593
3177
  // src/validation/self-correction-loop.ts
2594
3178
  var SelfCorrectionLoop = class {
@@ -2746,20 +3330,23 @@ var StrategyRouter = class {
2746
3330
  if (contextSchema) {
2747
3331
  this.templateEngine.setContext(contextSchema);
2748
3332
  }
2749
- const patternResult = this.patternMatcher.match(nl);
2750
- if (patternResult.matched && patternResult.confidence >= this.config.patternMinConfidence) {
2751
- 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);
2752
3339
  if (validation.valid) {
2753
3340
  return {
2754
- expression: patternResult.spel,
3341
+ expression: wholeMatch.spel,
2755
3342
  strategy: "pattern",
2756
- confidence: patternResult.confidence,
2757
- metadata: { patternId: patternResult.pattern?.id },
3343
+ confidence: wholeMatch.confidence,
3344
+ metadata: { patternId: wholeMatch.pattern?.id },
2758
3345
  latencyMs: Date.now() - startTime
2759
3346
  };
2760
3347
  }
2761
3348
  try {
2762
- const afResult = this.autoFixer.fix(patternResult.spel);
3349
+ const afResult = this.autoFixer.fix(wholeMatch.spel);
2763
3350
  if (afResult.wasFixed) {
2764
3351
  const afValidation = await this.validationPipeline.validate(
2765
3352
  afResult.expression,
@@ -2769,8 +3356,8 @@ var StrategyRouter = class {
2769
3356
  return {
2770
3357
  expression: afResult.expression,
2771
3358
  strategy: "pattern",
2772
- confidence: patternResult.confidence * 0.95,
2773
- metadata: { patternId: patternResult.pattern?.id },
3359
+ confidence: wholeMatch.confidence * 0.95,
3360
+ metadata: { patternId: wholeMatch.pattern?.id },
2774
3361
  latencyMs: Date.now() - startTime
2775
3362
  };
2776
3363
  }
@@ -2778,6 +3365,30 @@ var StrategyRouter = class {
2778
3365
  } catch {
2779
3366
  }
2780
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
+ }
2781
3392
  const intentResult = this.intentClassifier.classify(nl);
2782
3393
  let templateResult = null;
2783
3394
  try {
@@ -2811,7 +3422,7 @@ var StrategyRouter = class {
2811
3422
  }
2812
3423
  }
2813
3424
  if (providers.length === 0) {
2814
- throw new Error("No LLM providers available");
3425
+ throw clauseFailure ?? new Error("No LLM providers available");
2815
3426
  }
2816
3427
  let lastError = null;
2817
3428
  for (const provider of providers) {
@@ -2857,6 +3468,33 @@ var StrategyRouter = class {
2857
3468
  /**
2858
3469
  * Get PatternMatcher (for external testing/debugging)
2859
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
+ }
2860
3498
  getPatternMatcher() {
2861
3499
  return this.patternMatcher;
2862
3500
  }
@@ -2940,6 +3578,19 @@ var NL2SpelEngine = class {
2940
3578
  if (options.offlineOnly) {
2941
3579
  const patternMatcher = this.router.getPatternMatcher();
2942
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
+ }
2943
3594
  if (patternResult.matched) {
2944
3595
  return {
2945
3596
  expression: patternResult.spel,
@@ -3017,5 +3668,9 @@ export {
3017
3668
  SelfCorrectionLoop,
3018
3669
  StrategyRouter,
3019
3670
  TemplateEngine,
3020
- ValidationPipeline
3671
+ UnconvertibleClauseError,
3672
+ UnmappedFieldError,
3673
+ ValidationPipeline,
3674
+ decompose,
3675
+ splitClauses
3021
3676
  };