@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.cjs CHANGED
@@ -34,7 +34,11 @@ __export(index_exports, {
34
34
  SelfCorrectionLoop: () => SelfCorrectionLoop,
35
35
  StrategyRouter: () => StrategyRouter,
36
36
  TemplateEngine: () => TemplateEngine,
37
- ValidationPipeline: () => ValidationPipeline
37
+ UnconvertibleClauseError: () => UnconvertibleClauseError,
38
+ UnmappedFieldError: () => UnmappedFieldError,
39
+ ValidationPipeline: () => ValidationPipeline,
40
+ decompose: () => decompose,
41
+ splitClauses: () => splitClauses
38
42
  });
39
43
  module.exports = __toCommonJS(index_exports);
40
44
 
@@ -434,10 +438,55 @@ var ChineseNumberParser = class {
434
438
  };
435
439
 
436
440
  // src/pattern/pattern-matcher.ts
441
+ var UnmappedFieldError = class extends Error {
442
+ /** The field word that could not be resolved. */
443
+ field;
444
+ constructor(field) {
445
+ super(
446
+ `No field mapping for '${field}'. Add a mapping, or use fieldPolicy 'passthrough' to emit it verbatim.`
447
+ );
448
+ this.name = "UnmappedFieldError";
449
+ this.field = field;
450
+ }
451
+ };
452
+ var CN_FIELD_MAP = {
453
+ \u5907\u6CE8: "remark",
454
+ \u8BF4\u660E: "description",
455
+ \u63CF\u8FF0: "description",
456
+ \u91D1\u989D: "amount",
457
+ \u6570\u91CF: "count",
458
+ \u4E2A\u6570: "count",
459
+ \u72B6\u6001: "status",
460
+ \u7C7B\u578B: "type",
461
+ \u540D\u79F0: "name",
462
+ \u6807\u9898: "title",
463
+ \u5730\u5740: "address",
464
+ \u90AE\u7BB1: "email",
465
+ \u624B\u673A: "phone",
466
+ \u7535\u8BDD: "phone",
467
+ \u65E5\u671F: "date",
468
+ \u65F6\u95F4: "time",
469
+ \u5E74\u9F84: "age",
470
+ \u4EF7\u683C: "price",
471
+ \u7528\u6237\u540D: "name",
472
+ \u6743\u9650: "role",
473
+ \u6807\u7B7E: "tags",
474
+ \u5217\u8868: "list",
475
+ \u6570\u7EC4: "items",
476
+ \u6587\u4EF6: "file",
477
+ \u6587\u4EF6\u540D: "name",
478
+ \u8FC7\u671F: "expiryDate",
479
+ \u521B\u5EFA: "createdAt",
480
+ \u6709\u6548: "valid",
481
+ \u6D3B\u8DC3: "active",
482
+ \u6FC0\u6D3B: "active"
483
+ };
437
484
  var PatternMatcher = class {
438
485
  _patterns;
439
- constructor(patterns = []) {
486
+ fieldPolicy;
487
+ constructor(patterns = [], options = {}) {
440
488
  this._patterns = [...patterns];
489
+ this.fieldPolicy = options.fieldPolicy ?? "passthrough";
441
490
  this.sortByPriority();
442
491
  }
443
492
  get patternCount() {
@@ -471,9 +520,22 @@ var PatternMatcher = class {
471
520
  }
472
521
  }
473
522
  }
474
- const spel = this.fillTemplate(pattern, slots, normalized, matchResult);
523
+ const { expression, unmappedFields } = this.fillTemplate(
524
+ pattern,
525
+ slots,
526
+ normalized,
527
+ matchResult
528
+ );
475
529
  const latencyMs2 = Date.now() - startTime;
476
- return { matched: true, pattern, spel, confidence: pattern.confidence, latencyMs: latencyMs2, slots };
530
+ return {
531
+ matched: true,
532
+ pattern,
533
+ spel: expression,
534
+ confidence: pattern.confidence,
535
+ latencyMs: latencyMs2,
536
+ slots,
537
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
538
+ };
477
539
  }
478
540
  const latencyMs = Date.now() - startTime;
479
541
  return { matched: false, confidence: 0, latencyMs };
@@ -494,13 +556,20 @@ var PatternMatcher = class {
494
556
  if (value !== void 0) slots[key] = value;
495
557
  }
496
558
  }
559
+ const { expression, unmappedFields } = this.fillTemplate(
560
+ pattern,
561
+ slots,
562
+ normalized,
563
+ matchResult
564
+ );
497
565
  results.push({
498
566
  matched: true,
499
567
  pattern,
500
- spel: this.fillTemplate(pattern, slots, normalized, matchResult),
568
+ spel: expression,
501
569
  confidence: pattern.confidence,
502
570
  latencyMs: 0,
503
- slots
571
+ slots,
572
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
504
573
  });
505
574
  }
506
575
  return results;
@@ -522,46 +591,37 @@ var PatternMatcher = class {
522
591
  if (/^(?:商品|product|item)/i.test(input)) return "product";
523
592
  return "order";
524
593
  }
594
+ /**
595
+ * The first whitespace- or punctuation-delimited chunk of `input`.
596
+ */
597
+ firstWord(input) {
598
+ const m = input.match(/^[^\s,,、]+/);
599
+ return m ? m[0] : "value";
600
+ }
525
601
  /**
526
602
  * Extract Chinese field names from input and map to SpEL fields
527
603
  */
528
604
  extractChineseField(input) {
529
- const m = input.match(/^[^\s,,、]+/);
530
- if (!m) return "value";
531
- const first = m[0];
532
- const cnMap = {
533
- \u5907\u6CE8: "remark",
534
- \u8BF4\u660E: "description",
535
- \u63CF\u8FF0: "description",
536
- \u91D1\u989D: "amount",
537
- \u6570\u91CF: "count",
538
- \u4E2A\u6570: "count",
539
- \u72B6\u6001: "status",
540
- \u7C7B\u578B: "type",
541
- \u540D\u79F0: "name",
542
- \u6807\u9898: "title",
543
- \u5730\u5740: "address",
544
- \u90AE\u7BB1: "email",
545
- \u624B\u673A: "phone",
546
- \u7535\u8BDD: "phone",
547
- \u65E5\u671F: "date",
548
- \u65F6\u95F4: "time",
549
- \u5E74\u9F84: "age",
550
- \u4EF7\u683C: "price",
551
- \u7528\u6237\u540D: "name",
552
- \u6743\u9650: "role",
553
- \u6807\u7B7E: "tags",
554
- \u5217\u8868: "list",
555
- \u6570\u7EC4: "items",
556
- \u6587\u4EF6: "file",
557
- \u6587\u4EF6\u540D: "name",
558
- \u8FC7\u671F: "expiryDate",
559
- \u521B\u5EFA: "createdAt",
560
- \u6709\u6548: "valid",
561
- \u6D3B\u8DC3: "active",
562
- \u6FC0\u6D3B: "active"
563
- };
564
- return cnMap[first] ?? first;
605
+ const first = this.firstWord(input);
606
+ return CN_FIELD_MAP[first] ?? first;
607
+ }
608
+ /**
609
+ * Resolve a captured field word into the identifier to emit, and report whether
610
+ * the dictionary recognised it.
611
+ *
612
+ * An ASCII word is already an identifier and is emitted unchanged. Anything else
613
+ * is looked up: a word the dictionary knows becomes its English identifier, and a
614
+ * word it does not know is emitted verbatim and reported as unmapped. Emitting it
615
+ * verbatim is legal Spring, but it is a guess about the caller's schema, so the
616
+ * guess is never silent.
617
+ */
618
+ resolveField(captured) {
619
+ if (/^[a-zA-Z_]\w*$/.test(captured)) {
620
+ return { field: captured, mapped: true };
621
+ }
622
+ const first = this.firstWord(captured);
623
+ const mapped = CN_FIELD_MAP[first];
624
+ return { field: mapped ?? first, mapped: mapped !== void 0 };
565
625
  }
566
626
  /**
567
627
  * Template filling and value transformation
@@ -569,7 +629,20 @@ var PatternMatcher = class {
569
629
  fillTemplate(pattern, slots, originalInput, _matchResult) {
570
630
  let result = pattern.spelTemplate;
571
631
  const hasFieldSlot = "field" in slots;
572
- const field = hasFieldSlot ? this.inferFieldFromCapture(slots["field"]) : this.extractChineseField(originalInput);
632
+ const unmappedFields = [];
633
+ let field;
634
+ if (hasFieldSlot) {
635
+ const resolved = this.resolveField(slots["field"]);
636
+ if (!resolved.mapped) {
637
+ if (this.fieldPolicy === "strict") {
638
+ throw new UnmappedFieldError(resolved.field);
639
+ }
640
+ unmappedFields.push(resolved.field);
641
+ }
642
+ field = resolved.field;
643
+ } else {
644
+ field = this.extractChineseField(originalInput);
645
+ }
573
646
  const root = this.inferRoot(originalInput);
574
647
  result = result.replace(/\{field\}/g, field);
575
648
  result = result.replace(/\{root\}/g, root);
@@ -597,14 +670,7 @@ var PatternMatcher = class {
597
670
  result = result.replace(`{${key}}`, transformedValue ?? "");
598
671
  }
599
672
  result = result.replace(/\{[a-zA-Z_]+\}/g, "");
600
- return result.trim();
601
- }
602
- /**
603
- * Infer SpEL field name from capture group
604
- */
605
- inferFieldFromCapture(captured) {
606
- if (/^[a-zA-Z_]\w*$/.test(captured)) return captured;
607
- return this.extractChineseField(captured);
673
+ return { expression: result.trim(), unmappedFields };
608
674
  }
609
675
  };
610
676
 
@@ -670,7 +736,7 @@ var BUILTIN_PATTERNS = [
670
736
  slots: {},
671
737
  priority: 98,
672
738
  tags: ["null", "isNotNull"],
673
- examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#\u5907\u6CE8 != null" }],
739
+ examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#remark != null" }],
674
740
  difficulty: "easy",
675
741
  confidence: 0.98
676
742
  },
@@ -681,7 +747,7 @@ var BUILTIN_PATTERNS = [
681
747
  slots: {},
682
748
  priority: 97,
683
749
  tags: ["null", "isNull"],
684
- examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#\u5907\u6CE8 == null" }],
750
+ examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#remark == null" }],
685
751
  difficulty: "easy",
686
752
  confidence: 0.98
687
753
  },
@@ -712,29 +778,29 @@ var BUILTIN_PATTERNS = [
712
778
  // ================================================================
713
779
  {
714
780
  id: "CN-CMP-GE",
715
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
781
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
716
782
  spelTemplate: "#{field} >= {value}",
717
783
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
718
784
  priority: 93,
719
785
  tags: ["comparison", "ge", "chinese"],
720
- examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#\u91D1\u989D >= 100" }],
786
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#amount >= 100" }],
721
787
  difficulty: "easy",
722
788
  confidence: 0.95
723
789
  },
724
790
  {
725
791
  id: "CN-CMP-LE",
726
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
792
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
727
793
  spelTemplate: "#{field} <= {value}",
728
794
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
729
795
  priority: 93,
730
796
  tags: ["comparison", "le", "chinese"],
731
- examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#\u91D1\u989D <= 500" }],
797
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#amount <= 500" }],
732
798
  difficulty: "easy",
733
799
  confidence: 0.95
734
800
  },
735
801
  {
736
802
  id: "CN-CMP-GT",
737
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
803
+ match: /^(?<field>[^\s,,、]+?)\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
738
804
  spelTemplate: "#{field} > {value}",
739
805
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
740
806
  priority: 92,
@@ -745,7 +811,7 @@ var BUILTIN_PATTERNS = [
745
811
  },
746
812
  {
747
813
  id: "CN-CMP-LT",
748
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
814
+ match: /^(?<field>[^\s,,、]+?)\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
749
815
  spelTemplate: "#{field} < {value}",
750
816
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
751
817
  priority: 92,
@@ -759,7 +825,7 @@ var BUILTIN_PATTERNS = [
759
825
  // ================================================================
760
826
  {
761
827
  id: "CN-EQ-STATUS",
762
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
828
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
763
829
  spelTemplate: "#{field} == '{value}'",
764
830
  slots: { value: { key: "value", type: "string" } },
765
831
  priority: 85,
@@ -782,7 +848,7 @@ var BUILTIN_PATTERNS = [
782
848
  // CN: "order status is not cancelled" — must be before NOT pattern
783
849
  {
784
850
  id: "CN-NE-STATUS",
785
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
851
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
786
852
  spelTemplate: "#{field} != '{value}'",
787
853
  slots: { value: { key: "value", type: "string" } },
788
854
  priority: 91,
@@ -805,12 +871,12 @@ var BUILTIN_PATTERNS = [
805
871
  // CN/EN: count equality
806
872
  {
807
873
  id: "CN-EQ-COUNT",
808
- match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|计数)?\s*(?:等于|==)\s*(?<value>\d+)/,
874
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|==)\s*(?<value>\d+)/,
809
875
  spelTemplate: "#{field} == {value}",
810
876
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
811
877
  priority: 91,
812
878
  tags: ["comparison", "eq", "number"],
813
- examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#\u6570\u91CF == 5" }],
879
+ examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#count == 5" }],
814
880
  difficulty: "easy",
815
881
  confidence: 0.95
816
882
  },
@@ -852,7 +918,9 @@ var BUILTIN_PATTERNS = [
852
918
  },
853
919
  {
854
920
  id: "CN-PERM-PERM",
855
- match: /^(?<field>[^\s,,、]+)\s*(?:可以|能够|允许|may|can)\s+(?<permission>.+)$/i,
921
+ // Chinese-only operator: keeping the English "can"/"may" here would let the
922
+ // pattern fire on the "can" inside "not cancelled".
923
+ match: /^(?<field>[^\s,,、]+)\s*(?:可以|能够|允许)\s*(?<permission>.+)$/,
856
924
  spelTemplate: "hasPermission('{permission}')",
857
925
  slots: { permission: { key: "permission", type: "string" } },
858
926
  priority: 88,
@@ -877,7 +945,7 @@ var BUILTIN_PATTERNS = [
877
945
  // ================================================================
878
946
  {
879
947
  id: "CN-COLL-EMPTY",
880
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
948
+ match: /^(?<field>[^\s,,、]+?)\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
881
949
  spelTemplate: "#{field}.isEmpty()",
882
950
  slots: {},
883
951
  priority: 89,
@@ -899,7 +967,7 @@ var BUILTIN_PATTERNS = [
899
967
  },
900
968
  {
901
969
  id: "CN-COLL-NOTEMPTY",
902
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
970
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
903
971
  spelTemplate: "!#{field}.isEmpty()",
904
972
  slots: {},
905
973
  priority: 89,
@@ -921,7 +989,7 @@ var BUILTIN_PATTERNS = [
921
989
  },
922
990
  {
923
991
  id: "CN-COLL-CONTAINS",
924
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
992
+ match: /^(?<field>[^\s,,、]+?)\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
925
993
  spelTemplate: "#{field}.contains('{element}')",
926
994
  slots: { element: { key: "element", type: "string" } },
927
995
  priority: 87,
@@ -943,7 +1011,7 @@ var BUILTIN_PATTERNS = [
943
1011
  },
944
1012
  {
945
1013
  id: "CN-COLL-SIZE",
946
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
1014
+ match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
947
1015
  spelTemplate: "#{field}.size() {op} {value}",
948
1016
  slots: {
949
1017
  value: { key: "value", type: "number", transform: "toNumber" },
@@ -974,7 +1042,7 @@ var BUILTIN_PATTERNS = [
974
1042
  // ================================================================
975
1043
  {
976
1044
  id: "CN-STR-CONTAINS",
977
- match: /^(?<field>[^\s,,、]+?)(?:备注|名称|描述|标签|标题)?\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
1045
+ match: /^(?<field>[^\s,,、]+?)\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
978
1046
  spelTemplate: "#{field}.contains('{substr}')",
979
1047
  slots: { substr: { key: "substr", type: "string" } },
980
1048
  priority: 85,
@@ -1023,7 +1091,7 @@ var BUILTIN_PATTERNS = [
1023
1091
  slots: { suffix: { key: "suffix", type: "string" } },
1024
1092
  priority: 85,
1025
1093
  tags: ["string", "endsWith"],
1026
- examples: [{ nl: "\u6587\u4EF6\u540D\u4EE5.pdf\u7ED3\u5C3E", spel: "#\u6587\u4EF6\u540D.endsWith('.pdf')" }],
1094
+ examples: [{ nl: "\u6587\u4EF6\u540D\u4EE5.pdf\u7ED3\u5C3E", spel: "#name.endsWith('.pdf')" }],
1027
1095
  difficulty: "easy",
1028
1096
  confidence: 0.95
1029
1097
  },
@@ -1066,21 +1134,21 @@ var BUILTIN_PATTERNS = [
1066
1134
  {
1067
1135
  id: "CN-RANGE-BETWEEN",
1068
1136
  match: /^(?<field>[^\s,,、]+?)\s*(?:在|介于)\s*(?<min>\d+)\s*(?:和|到|~)\s*(?<max>\d+)\s*(?:之间|范围)?/,
1069
- spelTemplate: "#{field} between {{{min}, {max}}}",
1137
+ spelTemplate: "#{field} between {{min}, {max}}",
1070
1138
  slots: {
1071
1139
  min: { key: "min", type: "number", transform: "toNumber" },
1072
1140
  max: { key: "max", type: "number", transform: "toNumber" }
1073
1141
  },
1074
1142
  priority: 82,
1075
1143
  tags: ["range", "between"],
1076
- examples: [{ nl: "\u5E74\u9F84\u572818\u523060\u4E4B\u95F4", spel: "#\u5E74\u9F84 between {18, 60}" }],
1144
+ examples: [{ nl: "\u5E74\u9F84\u572818\u523060\u4E4B\u95F4", spel: "#age between {18, 60}" }],
1077
1145
  difficulty: "easy",
1078
1146
  confidence: 0.95
1079
1147
  },
1080
1148
  {
1081
1149
  id: "EN-RANGE-BETWEEN",
1082
1150
  match: /\b(?<field>\w+)\s+between\s+(?<min>\d+)\s+and\s+(?<max>\d+)/i,
1083
- spelTemplate: "#{field} between {{{min}, {max}}}",
1151
+ spelTemplate: "#{field} between {{min}, {max}}",
1084
1152
  slots: {
1085
1153
  min: { key: "min", type: "number", transform: "toNumber" },
1086
1154
  max: { key: "max", type: "number", transform: "toNumber" }
@@ -1101,7 +1169,7 @@ var BUILTIN_PATTERNS = [
1101
1169
  slots: { default: { key: "default", type: "string" } },
1102
1170
  priority: 78,
1103
1171
  tags: ["elvis"],
1104
- examples: [{ nl: "\u7528\u6237\u540D\u6216\u8005\u533F\u540D\u7528\u6237", spel: "#\u7528\u6237\u540D ?: '\u533F\u540D\u7528\u6237'" }],
1172
+ examples: [{ nl: "\u7528\u6237\u540D\u6216\u8005\u533F\u540D\u7528\u6237", spel: "#name ?: '\u533F\u540D\u7528\u6237'" }],
1105
1173
  difficulty: "medium",
1106
1174
  confidence: 0.85
1107
1175
  },
@@ -1207,7 +1275,11 @@ var BUILTIN_PATTERNS = [
1207
1275
  // ================================================================
1208
1276
  {
1209
1277
  id: "CN-BOOL-TRUE",
1210
- match: /^(?<field>[^\s,,、]+?)\s*(?:是|为|等于|==)\s*(?:true|真|是|yes)$/i,
1278
+ // "用户是VIP" asserts the subject through a Latin marker, so accept either a
1279
+ // truth word or a Latin word. A fully open marker would fire on function
1280
+ // words ("一个非常…"), and the lookbehind keeps a negator out of the field
1281
+ // ("用户不" is not the subject).
1282
+ match: /^(?<field>[^\s,,、]+?)(?<![不非])\s*(?:是|为|等于|==)\s*(?:true|真|是|yes|[A-Za-z]\w*)$/,
1211
1283
  spelTemplate: "#{field} == true",
1212
1284
  slots: {},
1213
1285
  priority: 74,
@@ -1218,7 +1290,10 @@ var BUILTIN_PATTERNS = [
1218
1290
  },
1219
1291
  {
1220
1292
  id: "CN-BOOL-FALSE",
1221
- match: /^(?<field>[^\s,,、]+?)\s*(?:不是|非|为|是|等于|==)\s*(?:false|假|否|no)$/i,
1293
+ // Mirror of CN-BOOL-TRUE. The marker stays narrow so that function words
1294
+ // ("非常…") do not match, and the lookbehind keeps "不是有效" with
1295
+ // CN-LOGIC-NOT instead of capturing "不" as the field.
1296
+ match: /^(?<field>[^\s,,、]+?)(?<![不非])\s*(?:不是|非|为|是|等于|==)\s*(?:false|假|否|no|[A-Za-z]\w*)$/,
1222
1297
  spelTemplate: "#{field} == false",
1223
1298
  slots: {},
1224
1299
  priority: 73,
@@ -1326,7 +1401,7 @@ var BUILTIN_PATTERNS = [
1326
1401
  // ================================================================
1327
1402
  {
1328
1403
  id: "CN-LOGIC-NOT",
1329
- match: /^不是\s+(?<expr>.+)/,
1404
+ match: /^不是\s*(?<expr>.+)/,
1330
1405
  spelTemplate: "!({expr})",
1331
1406
  slots: { expr: { key: "expr", type: "variable" } },
1332
1407
  priority: 62,
@@ -1351,8 +1426,8 @@ var BUILTIN_PATTERNS = [
1351
1426
  // ================================================================
1352
1427
  {
1353
1428
  id: "CN-SELECT-FIRST",
1354
- match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、]+)/,
1355
- spelTemplate: "#{root}.items.^[#{this}.{field} > {value}]",
1429
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、的]+)/,
1430
+ spelTemplate: "#{root}.items.^[#this.{field} > {value}]",
1356
1431
  slots: {
1357
1432
  root: { key: "root", type: "variable" },
1358
1433
  field: { key: "field", type: "variable" },
@@ -1360,14 +1435,14 @@ var BUILTIN_PATTERNS = [
1360
1435
  },
1361
1436
  priority: 60,
1362
1437
  tags: ["selection", "first"],
1363
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u7B2C\u4E00\u4E2A\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#\u8BA2\u5355.items.^[#this.\u91D1\u989D > 1000]" }],
1438
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u7B2C\u4E00\u4E2A\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#order.items.^[#this.amount > 1000]" }],
1364
1439
  difficulty: "medium",
1365
1440
  confidence: 0.8
1366
1441
  },
1367
1442
  {
1368
1443
  id: "CN-SELECT-ALL",
1369
- match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、]+)/,
1370
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1444
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、的]+)/,
1445
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1371
1446
  slots: {
1372
1447
  root: { key: "root", type: "variable" },
1373
1448
  field: { key: "field", type: "variable" },
@@ -1375,28 +1450,28 @@ var BUILTIN_PATTERNS = [
1375
1450
  },
1376
1451
  priority: 60,
1377
1452
  tags: ["selection", "all"],
1378
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u6240\u6709\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#\u8BA2\u5355.items.?[#this.\u91D1\u989D > 1000]" }],
1453
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u6240\u6709\u91D1\u989D\u5927\u4E8E1000\u7684", spel: "#order.items.?[#this.amount > 1000]" }],
1379
1454
  difficulty: "medium",
1380
1455
  confidence: 0.8
1381
1456
  },
1382
1457
  {
1383
1458
  id: "CN-PROJ",
1384
- match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:值|金额|名称|价格|name|amount|price)?/,
1385
- spelTemplate: "#{root}.items.![#{this}.{field}]",
1459
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:的)?(?:值|金额|名称|价格|name|amount|price)?$/,
1460
+ spelTemplate: "#{root}.items.![#this.{field}]",
1386
1461
  slots: {
1387
1462
  root: { key: "root", type: "variable" },
1388
1463
  field: { key: "field", type: "variable" }
1389
1464
  },
1390
1465
  priority: 55,
1391
1466
  tags: ["projection"],
1392
- examples: [{ nl: "\u8BA2\u5355\u4E2D\u6BCF\u4E2A\u5546\u54C1\u7684\u4EF7\u683C", spel: "#\u8BA2\u5355.items.![#this.\u5546\u54C1]" }],
1467
+ examples: [{ nl: "\u8BA2\u5355\u4E2D\u6BCF\u4E2A\u5546\u54C1\u7684\u4EF7\u683C", spel: "#order.items.![#this.\u5546\u54C1]" }],
1393
1468
  difficulty: "medium",
1394
1469
  confidence: 0.75
1395
1470
  },
1396
1471
  {
1397
1472
  id: "EN-SELECT-ALL",
1398
1473
  match: /all\s+(?<root>\w+)\s+with\s+(?<field>\w+)\s*>\s*(?<value>\d+)/i,
1399
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1474
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1400
1475
  slots: {
1401
1476
  root: { key: "root", type: "variable" },
1402
1477
  field: { key: "field", type: "variable" },
@@ -1404,13 +1479,149 @@ var BUILTIN_PATTERNS = [
1404
1479
  },
1405
1480
  priority: 50,
1406
1481
  tags: ["selection", "all", "english"],
1407
- examples: [{ nl: "all items with price > 100", spel: "#items.items.?[#this.price > 100]" }],
1482
+ examples: [{ nl: "all items with price > 100", spel: "#order.items.?[#this.price > 100]" }],
1408
1483
  difficulty: "medium",
1409
1484
  confidence: 0.8
1410
1485
  }
1411
1486
  ];
1412
1487
  BUILTIN_PATTERNS.sort((a, b) => b.priority - a.priority);
1413
1488
 
1489
+ // src/pattern/clause-splitter.ts
1490
+ var CONNECTORS = [
1491
+ // Chinese conjunctions. `和` is deliberately absent: it is a range separator in
1492
+ // `价格在10和20之间`, not a conjunction.
1493
+ { pattern: /(?:并且|且|同时|而且|、)/y, operator: "and" },
1494
+ { pattern: /(?:或者|或|要么)/y, operator: "or" },
1495
+ // English conjunctions, matched as whole words.
1496
+ { pattern: /\band\b/iy, operator: "and" },
1497
+ { pattern: /\bor\b/iy, operator: "or" }
1498
+ ];
1499
+ var UnconvertibleClauseError = class extends Error {
1500
+ /** The clause texts that could not be converted. */
1501
+ unconvertible;
1502
+ /** The full input that was being decomposed. */
1503
+ input;
1504
+ constructor(input, unconvertible) {
1505
+ const detail = unconvertible.map((clause) => `'${clause}'`).join(", ");
1506
+ super(
1507
+ `Cannot decompose '${input}': no conversion for ${detail}. Refusing to emit a partial rule.`
1508
+ );
1509
+ this.name = "UnconvertibleClauseError";
1510
+ this.input = input;
1511
+ this.unconvertible = unconvertible;
1512
+ }
1513
+ };
1514
+ var QUOTES = /* @__PURE__ */ new Set(["'", '"']);
1515
+ var OPENERS = { "(": ")", "[": "]", "{": "}" };
1516
+ var CLOSERS = /* @__PURE__ */ new Set([")", "]", "}"]);
1517
+ var isDigit = (ch) => ch !== void 0 && ch >= "0" && ch <= "9";
1518
+ function nextConnector(input, from) {
1519
+ let depth = 0;
1520
+ let quote = null;
1521
+ for (let i = from; i < input.length; i += 1) {
1522
+ const ch = input[i];
1523
+ if (quote !== null) {
1524
+ if (ch === quote) {
1525
+ if (input[i + 1] === quote) {
1526
+ i += 1;
1527
+ continue;
1528
+ }
1529
+ quote = null;
1530
+ }
1531
+ continue;
1532
+ }
1533
+ if (QUOTES.has(ch)) {
1534
+ quote = ch;
1535
+ continue;
1536
+ }
1537
+ if (ch in OPENERS) {
1538
+ depth += 1;
1539
+ continue;
1540
+ }
1541
+ if (CLOSERS.has(ch)) {
1542
+ depth = Math.max(0, depth - 1);
1543
+ continue;
1544
+ }
1545
+ if (depth > 0) continue;
1546
+ for (const { pattern, operator } of CONNECTORS) {
1547
+ pattern.lastIndex = i;
1548
+ const match = pattern.exec(input);
1549
+ if (!match) continue;
1550
+ if (operator === "and") {
1551
+ let before = i - 1;
1552
+ while (before >= 0 && input[before] === " ") before -= 1;
1553
+ let after = i + match[0].length;
1554
+ while (after < input.length && input[after] === " ") after += 1;
1555
+ if (isDigit(input[before]) && isDigit(input[after])) continue;
1556
+ }
1557
+ return { index: i, length: match[0].length, operator };
1558
+ }
1559
+ }
1560
+ return null;
1561
+ }
1562
+ function splitClauses(input) {
1563
+ const clauses = [];
1564
+ let start = 0;
1565
+ let connector = "and";
1566
+ let cursor = 0;
1567
+ for (; ; ) {
1568
+ const found = nextConnector(input, cursor);
1569
+ if (found === null) break;
1570
+ clauses.push({ text: input.slice(start, found.index).trim(), connector });
1571
+ connector = found.operator;
1572
+ start = found.index + found.length;
1573
+ cursor = start;
1574
+ }
1575
+ const tail = input.slice(start).trim();
1576
+ if (clauses.length > 0 || tail.length > 0) {
1577
+ clauses.push({ text: tail, connector });
1578
+ }
1579
+ if (clauses.length === 0) {
1580
+ clauses.push({ text: "", connector: "and" });
1581
+ }
1582
+ return clauses;
1583
+ }
1584
+ function groupByPrecedence(clauses) {
1585
+ const groups = [];
1586
+ let current = [];
1587
+ for (const clause of clauses) {
1588
+ if (clause.connector === "or" && current.length > 0) {
1589
+ groups.push(current);
1590
+ current = [];
1591
+ }
1592
+ current.push(clause);
1593
+ }
1594
+ if (current.length > 0) groups.push(current);
1595
+ return groups;
1596
+ }
1597
+ function decompose(input, convert) {
1598
+ const clauses = splitClauses(input);
1599
+ if (clauses.length <= 1) return null;
1600
+ const unconvertible = [];
1601
+ const resolved = clauses.map((clause) => {
1602
+ const expression = clause.text.length === 0 ? null : convert(clause.text);
1603
+ if (expression === null) {
1604
+ unconvertible.push(clause.text);
1605
+ return { ...clause, expression: "" };
1606
+ }
1607
+ return { ...clause, expression };
1608
+ });
1609
+ if (unconvertible.length > 0) {
1610
+ throw new UnconvertibleClauseError(input, unconvertible);
1611
+ }
1612
+ const groups = groupByPrecedence(resolved);
1613
+ const mixed = groups.length > 1;
1614
+ const rendered = groups.map((group) => {
1615
+ const parts = group.map((clause) => `(${clause.expression})`);
1616
+ const joined = parts.join(" and ");
1617
+ return mixed && group.length > 1 ? `(${joined})` : joined;
1618
+ });
1619
+ return {
1620
+ expression: rendered.join(" or "),
1621
+ clauses: clauses.map((clause) => clause.text)
1622
+ };
1623
+ }
1624
+
1414
1625
  // src/template/nl-intent.ts
1415
1626
  var NLIntent = /* @__PURE__ */ ((NLIntent2) => {
1416
1627
  NLIntent2["COMPARISON"] = "COMPARISON";
@@ -1449,7 +1660,7 @@ var INTENT_KEYWORDS = {
1449
1660
  en: ["greater", "less", "equal", "above", "below", "exceed", ">", "<", "==", "!=", ">=", "<="]
1450
1661
  },
1451
1662
  ["NULL_CHECK" /* NULL_CHECK */]: {
1452
- zh: ["\u4E3A\u7A7A", "\u4E0D\u4E3A\u7A7A", "\u662F\u7A7A", "\u5B58\u5728", "\u4E0D\u5B58\u5728", "null", "\u6CA1\u6709\u503C"],
1663
+ zh: ["\u4E3A\u7A7A", "\u4E0D\u4E3A\u7A7A", "\u662F\u7A7A", "\u975E\u7A7A", "\u5B58\u5728", "\u4E0D\u5B58\u5728", "\u6709\u503C", "\u65E0\u503C", "null", "\u6CA1\u6709\u503C"],
1453
1664
  en: ["null", "empty", "is null", "is not null", "is empty", "is not empty"]
1454
1665
  },
1455
1666
  ["PERMISSION_CHECK" /* PERMISSION_CHECK */]: {
@@ -1486,7 +1697,10 @@ var INTENT_KEYWORDS = {
1486
1697
  },
1487
1698
  ["BOOLEAN" /* BOOLEAN */]: {
1488
1699
  zh: ["\u662F\u5426", "\u771F\u5047", "true", "false", "\u662F", "\u5426"],
1489
- en: ["true", "false", "yes", "no", "is", "is not"]
1700
+ // "no" is deliberately absent: it is a substring of "not", so it fired on
1701
+ // every negated null/emptiness phrase and made "remark is not empty" a
1702
+ // boolean check. "false"/"否" already cover the negative boolean spelling.
1703
+ en: ["true", "false", "yes", "is", "is not"]
1490
1704
  },
1491
1705
  ["DATE" /* DATE */]: {
1492
1706
  zh: ["\u65E5\u671F", "\u65F6\u95F4", "\u4E4B\u540E", "\u4E4B\u524D", "\u65E9\u4E8E", "\u665A\u4E8E"],
@@ -1505,6 +1719,38 @@ var INTENT_KEYWORDS = {
1505
1719
  en: ["plus", "minus", "multiply", "divide", "mod", "sum", "average"]
1506
1720
  }
1507
1721
  };
1722
+ var NULL_NEGATED_SPECIFIC = [
1723
+ "\u4E0D\u4E3A\u7A7A",
1724
+ "\u4E0D\u4E3Anull",
1725
+ "\u4E0D\u662F\u7A7A",
1726
+ "\u975E\u7A7A",
1727
+ "is not null",
1728
+ "is not empty"
1729
+ ];
1730
+ var NULL_AFFIRMATIVE = [
1731
+ "\u4E0D\u5B58\u5728",
1732
+ "\u65E0\u503C",
1733
+ "\u6CA1\u6709\u503C",
1734
+ "\u4E3A\u7A7A",
1735
+ "\u4E3Anull",
1736
+ "\u662F\u7A7A",
1737
+ "is null",
1738
+ "is empty"
1739
+ ];
1740
+ var NULL_NEGATED_GENERIC = ["\u6709\u503C", "\u5B58\u5728"];
1741
+ function detectNullPredicate(input) {
1742
+ const text = input.trim().toLowerCase().replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 65248)).replace(/\s+/g, " ");
1743
+ for (const token of NULL_NEGATED_SPECIFIC) {
1744
+ if (text.includes(token)) return "negated";
1745
+ }
1746
+ for (const token of NULL_AFFIRMATIVE) {
1747
+ if (text.includes(token)) return "affirmative";
1748
+ }
1749
+ for (const token of NULL_NEGATED_GENERIC) {
1750
+ if (text.includes(token)) return "negated";
1751
+ }
1752
+ return null;
1753
+ }
1508
1754
  var IntentClassifier = class {
1509
1755
  /**
1510
1756
  * Main method for classifying natural language input
@@ -1538,6 +1784,9 @@ var IntentClassifier = class {
1538
1784
  if (/isempty|\.isEmpty|列表|数组|集合/.test(normalized)) {
1539
1785
  intentScores.set("COLLECTION" /* COLLECTION */, (intentScores.get("COLLECTION" /* COLLECTION */) ?? 0) + 1);
1540
1786
  }
1787
+ if (detectNullPredicate(normalized)) {
1788
+ intentScores.set("NULL_CHECK" /* NULL_CHECK */, (intentScores.get("NULL_CHECK" /* NULL_CHECK */) ?? 0) + 2);
1789
+ }
1541
1790
  const entities = this.extractEntities(normalized);
1542
1791
  const operators = this.extractOperators(normalized);
1543
1792
  const logicalConnectors = this.extractLogicalConnectors(normalized);
@@ -1650,6 +1899,68 @@ var IntentClassifier = class {
1650
1899
  };
1651
1900
 
1652
1901
  // src/template/template-engine.ts
1902
+ var CHINESE_FIELD_MAP = {
1903
+ \u5907\u6CE8: "remark",
1904
+ \u8BF4\u660E: "description",
1905
+ \u63CF\u8FF0: "description",
1906
+ \u91D1\u989D: "amount",
1907
+ \u6570\u91CF: "count",
1908
+ \u4E2A\u6570: "count",
1909
+ \u72B6\u6001: "status",
1910
+ \u7C7B\u578B: "type",
1911
+ \u540D\u79F0: "name",
1912
+ \u6807\u9898: "title",
1913
+ \u5730\u5740: "address",
1914
+ \u90AE\u7BB1: "email",
1915
+ \u624B\u673A: "phone",
1916
+ \u7535\u8BDD: "phone",
1917
+ \u65E5\u671F: "date",
1918
+ \u65F6\u95F4: "time",
1919
+ \u5E74\u9F84: "age",
1920
+ \u4EF7\u683C: "price",
1921
+ \u7528\u6237\u540D: "name",
1922
+ \u6743\u9650: "role",
1923
+ \u6807\u7B7E: "tags",
1924
+ \u5217\u8868: "list",
1925
+ \u6570\u7EC4: "items",
1926
+ \u6587\u4EF6: "file",
1927
+ \u6587\u4EF6\u540D: "name",
1928
+ \u8FC7\u671F: "expiryDate",
1929
+ \u521B\u5EFA: "createdAt",
1930
+ \u6709\u6548: "valid",
1931
+ \u6D3B\u8DC3: "active",
1932
+ \u6FC0\u6D3B: "active"
1933
+ };
1934
+ var CHINESE_FIELDS_BY_LENGTH = Object.entries(CHINESE_FIELD_MAP).sort(
1935
+ (a, b) => b[0].length - a[0].length
1936
+ );
1937
+ var FIELD_STOPWORDS = /* @__PURE__ */ new Set([
1938
+ "a",
1939
+ "an",
1940
+ "account",
1941
+ "and",
1942
+ "are",
1943
+ "be",
1944
+ "between",
1945
+ "empty",
1946
+ "false",
1947
+ "file",
1948
+ "has",
1949
+ "have",
1950
+ "is",
1951
+ "no",
1952
+ "not",
1953
+ "null",
1954
+ "or",
1955
+ "order",
1956
+ "product",
1957
+ "than",
1958
+ "the",
1959
+ "true",
1960
+ "user",
1961
+ "value",
1962
+ "yes"
1963
+ ]);
1653
1964
  var TEMPLATE_LIBRARY = {
1654
1965
  ["COMPARISON" /* COMPARISON */]: [
1655
1966
  {
@@ -1894,8 +2205,9 @@ var TemplateEngine = class {
1894
2205
  selectBestTemplate(templates, intentResult, input) {
1895
2206
  let bestScore = -1;
1896
2207
  let bestTemplate = null;
1897
- const hasEmptyKeyword = /为空|empty|null/i.test(input);
1898
- const hasNotEmptyKeyword = /不为空|not empty|not null/i.test(input);
2208
+ const polarity = detectNullPredicate(input);
2209
+ const isAffirmative = polarity === "affirmative";
2210
+ const isNegated = polarity === "negated";
1899
2211
  for (const template of templates) {
1900
2212
  const conditions = template.conditions;
1901
2213
  let score = 0;
@@ -1904,8 +2216,10 @@ var TemplateEngine = class {
1904
2216
  if (conditions.hasCollection) score += 0.5;
1905
2217
  if (conditions.hasNull) score += 0.5;
1906
2218
  if (conditions.hasString) score += 1;
1907
- if (template.name.includes("IS_EMPTY") && hasEmptyKeyword) score += 2;
1908
- if (template.name.includes("IS_NOT_EMPTY") && hasNotEmptyKeyword) score += 2;
2219
+ if (template.name.includes("IS_EMPTY") && isAffirmative) score += 2;
2220
+ if (template.name.includes("IS_NOT_EMPTY") && isNegated) score += 2;
2221
+ if (template.name === "NULL-IS_NULL" && isAffirmative) score += 2;
2222
+ if (template.name === "NULL-IS_NOT_NULL" && isNegated) score += 2;
1909
2223
  if (conditions.entityCount) {
1910
2224
  if (conditions.entityCount.min && intentResult.entities.length < conditions.entityCount.min) {
1911
2225
  continue;
@@ -1921,37 +2235,8 @@ var TemplateEngine = class {
1921
2235
  fillTemplate(template, input, intentResult) {
1922
2236
  let expression = template;
1923
2237
  const unfilledSlots = [];
1924
- let rootName = "order";
1925
- let fieldName = "field";
1926
- if (this.contextSchema?.root) {
1927
- rootName = this.contextSchema.root.name;
1928
- const fields = Object.keys(this.contextSchema.root.fields ?? {});
1929
- for (const f of fields) {
1930
- if (input.includes(f)) {
1931
- fieldName = f;
1932
- break;
1933
- }
1934
- }
1935
- } else {
1936
- const rootMap = {
1937
- \u8BA2\u5355: "order",
1938
- order: "order",
1939
- \u7528\u6237: "user",
1940
- user: "user",
1941
- \u6587\u4EF6: "file",
1942
- file: "file",
1943
- \u8D26\u53F7: "account",
1944
- account: "account",
1945
- \u5546\u54C1: "item",
1946
- product: "item"
1947
- };
1948
- for (const [key, val] of Object.entries(rootMap)) {
1949
- if (input.includes(key)) {
1950
- rootName = val;
1951
- break;
1952
- }
1953
- }
1954
- }
2238
+ const rootName = this.resolveRootName(input);
2239
+ const fieldName = this.resolveFieldName(input);
1955
2240
  expression = expression.replace(/\{root\}/g, rootName);
1956
2241
  expression = expression.replace(/\{field\}/g, fieldName);
1957
2242
  const fieldEntities = intentResult.entities.filter((e) => e.type === "field");
@@ -2020,6 +2305,58 @@ var TemplateEngine = class {
2020
2305
  }
2021
2306
  return { expression, unfilledSlots };
2022
2307
  }
2308
+ /**
2309
+ * Resolve the SpEL root name for `input`: the configured schema root when one
2310
+ * exists, otherwise a keyword heuristic over the well-known roots.
2311
+ */
2312
+ resolveRootName(input) {
2313
+ if (this.contextSchema?.root) {
2314
+ return this.contextSchema.root.name;
2315
+ }
2316
+ const rootMap = {
2317
+ \u8BA2\u5355: "order",
2318
+ order: "order",
2319
+ \u7528\u6237: "user",
2320
+ user: "user",
2321
+ \u6587\u4EF6: "file",
2322
+ file: "file",
2323
+ \u8D26\u53F7: "account",
2324
+ account: "account",
2325
+ \u5546\u54C1: "item",
2326
+ product: "item"
2327
+ };
2328
+ for (const [key, val] of Object.entries(rootMap)) {
2329
+ if (input.includes(key)) return val;
2330
+ }
2331
+ return "order";
2332
+ }
2333
+ /**
2334
+ * Resolve the field name for `input`.
2335
+ *
2336
+ * The previous implementation only compared schema *keys* and otherwise left
2337
+ * the literal placeholder default in place, which is how "#order.field == null"
2338
+ * reached callers. The lookup now falls back in order: schema key, schema
2339
+ * field description (Chinese inputs name the field by its description),
2340
+ * Chinese field word, first English identifier, and finally "value" — the
2341
+ * neutral default the pattern layer already uses for an unknown field.
2342
+ */
2343
+ resolveFieldName(input) {
2344
+ const fields = this.contextSchema?.root?.fields;
2345
+ if (fields) {
2346
+ for (const [key, schema] of Object.entries(fields)) {
2347
+ if (input.includes(key)) return key;
2348
+ if (schema.description && input.includes(schema.description)) return key;
2349
+ }
2350
+ }
2351
+ for (const [word, field] of CHINESE_FIELDS_BY_LENGTH) {
2352
+ if (input.includes(word)) return field;
2353
+ }
2354
+ const tokens = input.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
2355
+ for (const token of tokens) {
2356
+ if (!FIELD_STOPWORDS.has(token.toLowerCase())) return token;
2357
+ }
2358
+ return "value";
2359
+ }
2023
2360
  };
2024
2361
 
2025
2362
  // src/template/prompts/prompt-builder.ts
@@ -2257,6 +2594,172 @@ ${userInput}
2257
2594
  }
2258
2595
  };
2259
2596
 
2597
+ // src/validation/validation-pipeline.ts
2598
+ var import_spel_ts = require("@agentix-e/spel-ts");
2599
+
2600
+ // src/validation/auto-fixer.ts
2601
+ function scanStringLiterals(expression) {
2602
+ const spans = [];
2603
+ let unterminatedQuote = null;
2604
+ let i = 0;
2605
+ while (i < expression.length) {
2606
+ const quote = expression[i];
2607
+ if (quote !== "'" && quote !== '"') {
2608
+ i += 1;
2609
+ continue;
2610
+ }
2611
+ const start = i;
2612
+ i += 1;
2613
+ let closed = false;
2614
+ while (i < expression.length) {
2615
+ if (expression[i] === quote) {
2616
+ if (expression[i + 1] === quote) {
2617
+ i += 2;
2618
+ continue;
2619
+ }
2620
+ i += 1;
2621
+ closed = true;
2622
+ break;
2623
+ }
2624
+ i += 1;
2625
+ }
2626
+ spans.push({ start, end: i });
2627
+ if (!closed) {
2628
+ unterminatedQuote = quote;
2629
+ break;
2630
+ }
2631
+ }
2632
+ return { spans, unterminatedQuote };
2633
+ }
2634
+ function hasUnterminatedStringLiteral(expression) {
2635
+ return scanStringLiterals(expression).unterminatedQuote !== null;
2636
+ }
2637
+ function maskStringLiterals(expression) {
2638
+ const { spans } = scanStringLiterals(expression);
2639
+ if (spans.length === 0) return expression;
2640
+ const chars = expression.split("");
2641
+ for (const span of spans) {
2642
+ for (let i = span.start; i < span.end && i < chars.length; i++) {
2643
+ chars[i] = " ";
2644
+ }
2645
+ }
2646
+ return chars.join("");
2647
+ }
2648
+ var AutoFixer = class {
2649
+ fix(expression) {
2650
+ const { chunks, unterminatedQuote } = this.split(expression);
2651
+ const changes = [];
2652
+ this.applyRule(
2653
+ chunks,
2654
+ /=== undefined/g,
2655
+ "== null",
2656
+ () => "Replaced === undefined with == null",
2657
+ changes
2658
+ );
2659
+ this.applyRule(
2660
+ chunks,
2661
+ /!== undefined/g,
2662
+ "!= null",
2663
+ () => "Replaced !== undefined with != null",
2664
+ changes
2665
+ );
2666
+ this.applyRule(chunks, /!==/g, "!=", (n) => `Replaced ${n}x !== with !=`, changes);
2667
+ this.applyRule(chunks, /===/g, "==", (n) => `Replaced ${n}x === with ==`, changes);
2668
+ this.applyRule(chunks, /&&/g, "and", (n) => `Replaced ${n}x && with and`, changes);
2669
+ this.applyRule(chunks, /\|\|/g, "or", (n) => `Replaced ${n}x || with or`, changes);
2670
+ this.applyRule(chunks, /> ==/g, ">=", () => "Replaced > == with >=", changes);
2671
+ this.applyRule(chunks, /< ==/g, "<=", () => "Replaced < == with <=", changes);
2672
+ this.applyElvisRule(chunks, changes);
2673
+ let fixed = chunks.map((chunk) => chunk.text).join("");
2674
+ if (unterminatedQuote !== null && !fixed.endsWith(unterminatedQuote)) {
2675
+ fixed += unterminatedQuote;
2676
+ changes.push(
2677
+ unterminatedQuote === "'" ? "Added missing closing single quote" : "Added missing closing double quote"
2678
+ );
2679
+ }
2680
+ const wasFixed = changes.length > 0;
2681
+ return {
2682
+ wasFixed,
2683
+ expression: wasFixed ? fixed : expression,
2684
+ changes
2685
+ };
2686
+ }
2687
+ /**
2688
+ * Apply one global replacement rule to every unprotected chunk.
2689
+ *
2690
+ * The replacement is only reported when it actually matched, and the count
2691
+ * is the number of matches across all chunks, so the human-readable change
2692
+ * log is unchanged from the previous implementation.
2693
+ */
2694
+ applyRule(chunks, pattern, replacement, describe, changes) {
2695
+ let count = 0;
2696
+ for (const chunk of chunks) {
2697
+ if (chunk.protected) continue;
2698
+ const matches = chunk.text.match(pattern);
2699
+ if (matches === null) continue;
2700
+ count += matches.length;
2701
+ chunk.text = chunk.text.replace(pattern, replacement);
2702
+ }
2703
+ if (count > 0) {
2704
+ changes.push(describe(count));
2705
+ }
2706
+ }
2707
+ /**
2708
+ * Normalise the Elvis operator only when it was written with whitespace
2709
+ * between `?` and `:`. A correctly written `?:` must be left untouched so
2710
+ * that `fix()` is a no-op on already-valid input.
2711
+ */
2712
+ applyElvisRule(chunks, changes) {
2713
+ let touched = false;
2714
+ for (const chunk of chunks) {
2715
+ if (chunk.protected || !chunk.text.includes("? :")) continue;
2716
+ const next = chunk.text.replace(/\s*\?\s*:\s*/g, " ?: ");
2717
+ if (next !== chunk.text) {
2718
+ chunk.text = next;
2719
+ touched = true;
2720
+ }
2721
+ }
2722
+ if (touched) {
2723
+ changes.push("Fixed Elvis operator spacing");
2724
+ }
2725
+ }
2726
+ /**
2727
+ * Split the expression into alternating protected (string literal) and
2728
+ * unprotected chunks. Ranges come from spel-ts's tokenizer so they agree
2729
+ * exactly with what the parser considers opaque.
2730
+ */
2731
+ split(expression) {
2732
+ const { spans, unterminatedQuote } = scanStringLiterals(expression);
2733
+ return { chunks: this.toChunks(expression, spans), unterminatedQuote };
2734
+ }
2735
+ /**
2736
+ * Turn protected spans into a chunk list covering the whole expression.
2737
+ */
2738
+ toChunks(expression, spans) {
2739
+ const ordered = [...spans].sort((a, b) => a.start - b.start);
2740
+ const chunks = [];
2741
+ let cursor = 0;
2742
+ for (const span of ordered) {
2743
+ const start = Math.max(span.start, cursor);
2744
+ const end = Math.max(span.end, start);
2745
+ if (start > cursor) {
2746
+ chunks.push({ protected: false, text: expression.slice(cursor, start) });
2747
+ }
2748
+ if (end > start) {
2749
+ chunks.push({ protected: true, text: expression.slice(start, end) });
2750
+ }
2751
+ cursor = end;
2752
+ }
2753
+ if (cursor < expression.length) {
2754
+ chunks.push({ protected: false, text: expression.slice(cursor) });
2755
+ }
2756
+ if (chunks.length === 0) {
2757
+ chunks.push({ protected: false, text: "" });
2758
+ }
2759
+ return chunks;
2760
+ }
2761
+ };
2762
+
2260
2763
  // src/validation/validation-pipeline.ts
2261
2764
  var ValidationPipeline = class {
2262
2765
  evaluator;
@@ -2276,25 +2779,29 @@ var ValidationPipeline = class {
2276
2779
  const typeStage = this.validateTypes(expression, contextSchema);
2277
2780
  const semanticStage = this.validateSemantic(expression);
2278
2781
  const contextStage = this.validateContext(expression, contextSchema);
2782
+ const finalStage = this.validateFinal(expression);
2279
2783
  errors.push(
2280
2784
  ...parseStage.errors,
2281
2785
  ...typeStage.errors,
2282
2786
  ...semanticStage.errors,
2283
- ...contextStage.errors
2787
+ ...contextStage.errors,
2788
+ ...finalStage.errors
2284
2789
  );
2285
2790
  warnings.push(
2286
2791
  ...parseStage.warnings,
2287
2792
  ...typeStage.warnings,
2288
2793
  ...semanticStage.warnings,
2289
- ...contextStage.warnings
2794
+ ...contextStage.warnings,
2795
+ ...finalStage.warnings
2290
2796
  );
2291
2797
  return {
2292
- valid: parseStage.passed && typeStage.passed && semanticStage.passed && contextStage.passed,
2798
+ valid: errors.length === 0,
2293
2799
  stages: {
2294
2800
  parse: parseStage,
2295
2801
  type: typeStage,
2296
2802
  semantic: semanticStage,
2297
- context: contextStage
2803
+ context: contextStage,
2804
+ final: finalStage
2298
2805
  },
2299
2806
  errors,
2300
2807
  warnings
@@ -2302,47 +2809,57 @@ var ValidationPipeline = class {
2302
2809
  }
2303
2810
  /**
2304
2811
  * Stage 1: Parse Check — syntax validation
2812
+ *
2813
+ * Delimiter balance and JavaScript operators are checked against a copy of
2814
+ * the expression with string literals blanked out, so literal text can never
2815
+ * be mistaken for syntax.
2305
2816
  */
2306
2817
  async validateParse(expression) {
2307
2818
  const errors = [];
2308
2819
  const warnings = [];
2820
+ const structural = maskStringLiterals(expression);
2309
2821
  if (!expression || expression.trim().length === 0) {
2310
2822
  errors.push({
2311
2823
  code: "PARSE-EMPTY",
2312
2824
  message: "Expression is empty",
2825
+ severity: "error",
2313
2826
  stage: "parse",
2314
2827
  requiresLLM: true
2315
2828
  });
2316
2829
  return { passed: false, errors, warnings };
2317
2830
  }
2318
- if (!this.hasBalancedParentheses(expression)) {
2831
+ if (!this.hasBalancedParentheses(structural)) {
2319
2832
  errors.push({
2320
2833
  code: "PARSE-UNBALANCED_PARENS",
2321
2834
  message: "Unbalanced parentheses in expression",
2835
+ severity: "error",
2322
2836
  stage: "parse",
2323
2837
  requiresLLM: true
2324
2838
  });
2325
2839
  }
2326
- if (expression.includes("===") || expression.includes("!==")) {
2840
+ if (structural.includes("===") || structural.includes("!==")) {
2327
2841
  errors.push({
2328
2842
  code: "PARSE-JS_OPERATOR",
2329
2843
  message: "JavaScript operators detected (=== or !==), use == or != in SpEL",
2844
+ severity: "error",
2330
2845
  stage: "parse",
2331
2846
  requiresLLM: true
2332
2847
  });
2333
2848
  }
2334
- if (expression.includes("&&")) {
2849
+ if (structural.includes("&&")) {
2335
2850
  errors.push({
2336
2851
  code: "PARSE-JS_LOGIC",
2337
2852
  message: 'JavaScript && detected, use "and" in SpEL',
2853
+ severity: "error",
2338
2854
  stage: "parse",
2339
2855
  requiresLLM: true
2340
2856
  });
2341
2857
  }
2342
- if (expression.includes("||")) {
2858
+ if (structural.includes("||")) {
2343
2859
  errors.push({
2344
2860
  code: "PARSE-JS_LOGIC",
2345
2861
  message: 'JavaScript || detected, use "or" in SpEL',
2862
+ severity: "error",
2346
2863
  stage: "parse",
2347
2864
  requiresLLM: true
2348
2865
  });
@@ -2355,6 +2872,7 @@ var ValidationPipeline = class {
2355
2872
  errors.push({
2356
2873
  code: `PARSE-${pe.code ?? "SYNTAX"}`,
2357
2874
  message: pe.message,
2875
+ severity: "error",
2358
2876
  position: pe.position,
2359
2877
  stage: "parse",
2360
2878
  requiresLLM: true
@@ -2365,6 +2883,7 @@ var ValidationPipeline = class {
2365
2883
  errors.push({
2366
2884
  code: "PARSE-EXCEPTION",
2367
2885
  message: `Parse threw exception: ${err.message}`,
2886
+ severity: "error",
2368
2887
  stage: "parse",
2369
2888
  requiresLLM: true
2370
2889
  });
@@ -2377,27 +2896,43 @@ var ValidationPipeline = class {
2377
2896
  };
2378
2897
  }
2379
2898
  /**
2380
- * Stage 2: Type Check — type validation
2899
+ * Stage 2: Type Check — advisory type validation
2381
2900
  */
2382
2901
  validateTypes(expression, contextSchema) {
2383
2902
  const errors = [];
2384
2903
  const warnings = [];
2385
- const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+/;
2904
+ const structural = maskStringLiterals(expression);
2905
+ const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+|\d+\s*(?:>|<|>=|<=)\s*'(?:\\.|[^'\\])*'/;
2386
2906
  if (strNumMismatch.test(expression)) {
2387
2907
  warnings.push({
2388
2908
  code: "TYPE-STR_NUM_CMP",
2389
2909
  message: "String literal compared with number using arithmetic operator",
2910
+ severity: "warning",
2390
2911
  stage: "type"
2391
2912
  });
2392
2913
  }
2393
2914
  if (contextSchema?.root) {
2915
+ const rootRef = escapeRegExp(contextSchema.root.name);
2394
2916
  for (const [fieldName, field] of Object.entries(contextSchema.root.fields)) {
2917
+ const fieldRef = `#(?:${rootRef}\\.)?${escapeRegExp(fieldName)}`;
2395
2918
  if (field.type === "boolean") {
2396
- const boolNumPattern = new RegExp(`#\\w+\\.${fieldName}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2397
- if (boolNumPattern.test(expression)) {
2919
+ const boolNumPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2920
+ if (boolNumPattern.test(structural)) {
2398
2921
  warnings.push({
2399
2922
  code: "TYPE-BOOL_NUM_CMP",
2400
2923
  message: `Boolean field '${fieldName}' compared with number`,
2924
+ severity: "warning",
2925
+ stage: "type"
2926
+ });
2927
+ }
2928
+ }
2929
+ if (field.type === "number") {
2930
+ const numStrPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*'(?:\\\\.|[^'\\\\])*'`);
2931
+ if (numStrPattern.test(expression)) {
2932
+ warnings.push({
2933
+ code: "TYPE-STR_NUM_CMP",
2934
+ message: `Numeric field '${fieldName}' compared with a string literal`,
2935
+ severity: "warning",
2401
2936
  stage: "type"
2402
2937
  });
2403
2938
  }
@@ -2411,23 +2946,26 @@ var ValidationPipeline = class {
2411
2946
  };
2412
2947
  }
2413
2948
  /**
2414
- * Stage 3: Semantic Check — semantic reasonableness validation
2949
+ * Stage 3: Semantic Check — advisory semantic validation
2415
2950
  */
2416
2951
  validateSemantic(expression) {
2417
2952
  const errors = [];
2418
2953
  const warnings = [];
2954
+ const structural = maskStringLiterals(expression);
2419
2955
  const selfCompare = /(#\w+(?:\.\w+)*)\s*==\s*\1/;
2420
- if (selfCompare.test(expression)) {
2956
+ if (selfCompare.test(structural)) {
2421
2957
  warnings.push({
2422
2958
  code: "SEM-SELF_COMPARE",
2423
2959
  message: "Self-comparison detected: expression is always true",
2960
+ severity: "warning",
2424
2961
  stage: "semantic"
2425
2962
  });
2426
2963
  }
2427
- if (expression.includes("!!")) {
2964
+ if (structural.includes("!!")) {
2428
2965
  warnings.push({
2429
2966
  code: "SEM-DOUBLE_NEGATION",
2430
2967
  message: "Double negation detected, consider simplifying",
2968
+ severity: "warning",
2431
2969
  stage: "semantic"
2432
2970
  });
2433
2971
  }
@@ -2439,59 +2977,72 @@ var ValidationPipeline = class {
2439
2977
  }
2440
2978
  /**
2441
2979
  * Stage 4: Context Check — context reference validation
2980
+ *
2981
+ * A supplied schema turns this stage into a real gate: an undeclared bean,
2982
+ * a missing root field or an undeclared variable is an error. Without a
2983
+ * schema nothing can be judged, so the stage stays advisory.
2442
2984
  */
2443
2985
  validateContext(expression, contextSchema) {
2444
2986
  const errors = [];
2445
2987
  const warnings = [];
2988
+ const structural = maskStringLiterals(expression);
2446
2989
  if (!contextSchema) {
2447
2990
  warnings.push({
2448
2991
  code: "CTX-NO_SCHEMA",
2449
2992
  message: "No ContextSchema provided, skipping context validation",
2993
+ severity: "warning",
2450
2994
  stage: "context"
2451
2995
  });
2452
2996
  return { passed: true, errors, warnings };
2453
2997
  }
2454
- const refs = this.extractReferences(expression);
2455
- if (contextSchema.root) {
2456
- const rootName = contextSchema.root.name;
2998
+ const variables = contextSchema.variables ?? {};
2999
+ const functions = contextSchema.functions ?? {};
3000
+ const beans = contextSchema.beans ?? {};
3001
+ const root = contextSchema.root;
3002
+ const rootFields = root?.fields ?? {};
3003
+ const refs = this.extractReferences(structural);
3004
+ if (root) {
2457
3005
  for (const ref of refs) {
2458
- if (ref.startsWith(`#${rootName}`)) {
2459
- const parts = ref.split(".");
2460
- const field = parts[1];
2461
- if (field && !(field in contextSchema.root.fields)) {
2462
- warnings.push({
2463
- code: "CTX-UNKNOWN_FIELD",
2464
- message: `Field '${field}' not found in root '${rootName}'`,
2465
- stage: "context"
2466
- });
2467
- }
3006
+ if (!ref.startsWith(`#${root.name}.`)) continue;
3007
+ const field = ref.split(".")[1];
3008
+ if (field && !(field in rootFields)) {
3009
+ errors.push({
3010
+ code: "CTX-UNKNOWN_FIELD",
3011
+ message: `Field '${field}' not found in root '${root.name}'`,
3012
+ severity: "error",
3013
+ stage: "context",
3014
+ requiresLLM: true
3015
+ });
2468
3016
  }
2469
3017
  }
2470
3018
  }
2471
3019
  for (const ref of refs) {
2472
- if (ref.startsWith("#") && !ref.includes(".")) {
2473
- const varName = ref.slice(1);
2474
- const isRoot = contextSchema.root?.name === varName;
2475
- const isVariable = varName in contextSchema.variables;
2476
- const isFunction = varName in contextSchema.functions;
2477
- if (!isRoot && !isVariable && !isFunction) {
2478
- warnings.push({
2479
- code: "CTX-UNKNOWN_REF",
2480
- message: `Unknown reference '${ref}'`,
2481
- stage: "context"
2482
- });
2483
- }
3020
+ if (!ref.startsWith("#") || ref.includes(".")) continue;
3021
+ const varName = ref.slice(1);
3022
+ 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
3023
+ // as shorthand for `#root.name`.
3024
+ varName in rootFields;
3025
+ if (!known) {
3026
+ errors.push({
3027
+ code: "CTX-UNKNOWN_REF",
3028
+ message: `Unknown reference '${ref}'`,
3029
+ severity: "error",
3030
+ stage: "context",
3031
+ requiresLLM: true
3032
+ });
2484
3033
  }
2485
3034
  }
2486
- const beanMatch = expression.match(/@(\w+)/g);
2487
- if (beanMatch && contextSchema.beans) {
3035
+ const beanMatch = structural.match(/@(\w+)/g);
3036
+ if (beanMatch) {
2488
3037
  for (const b of beanMatch) {
2489
3038
  const beanName = b.slice(1);
2490
- if (!(beanName in contextSchema.beans)) {
2491
- warnings.push({
3039
+ if (!(beanName in beans)) {
3040
+ errors.push({
2492
3041
  code: "CTX-UNKNOWN_BEAN",
2493
3042
  message: `Bean '${beanName}' not found in ContextSchema`,
2494
- stage: "context"
3043
+ severity: "error",
3044
+ stage: "context",
3045
+ requiresLLM: true
2495
3046
  });
2496
3047
  }
2497
3048
  }
@@ -2503,7 +3054,62 @@ var ValidationPipeline = class {
2503
3054
  };
2504
3055
  }
2505
3056
  /**
2506
- * Check if parentheses are balanced
3057
+ * Stage 5: Final Check the generated expression must parse.
3058
+ *
3059
+ * When an evaluator is configured the parse stage already performed a real
3060
+ * parse; this mandatory final gate adds the structural check that a truncated
3061
+ * tail (an expression ending on an operator) is never accepted, even when no
3062
+ * engine is wired in. It is deliberately conservative: only a trailing
3063
+ * operator or an unterminated literal fails, so no well-formed expression is
3064
+ * rejected.
3065
+ */
3066
+ validateFinal(expression) {
3067
+ const errors = [];
3068
+ const warnings = [];
3069
+ if (this.isManifestlyIncomplete(expression)) {
3070
+ errors.push({
3071
+ code: "FINAL-TRUNCATED",
3072
+ message: "Expression is incomplete: it ends with an operator or is not terminated",
3073
+ severity: "error",
3074
+ stage: "final",
3075
+ requiresLLM: true
3076
+ });
3077
+ }
3078
+ return { passed: errors.length === 0, errors, warnings };
3079
+ }
3080
+ /**
3081
+ * Whether the expression is obviously incomplete.
3082
+ *
3083
+ * The last token is obtained from spel-ts's tokenizer, so a trailing
3084
+ * operator keyword or an unterminated string literal is detected exactly,
3085
+ * and text inside a literal is never read as a trailing operator.
3086
+ */
3087
+ isManifestlyIncomplete(expression) {
3088
+ if (expression.trim().length === 0) return true;
3089
+ const tokenizer = new import_spel_ts.Tokenizer(expression);
3090
+ let lastKind = null;
3091
+ let lastLiteral;
3092
+ let previousKind = null;
3093
+ try {
3094
+ for (; ; ) {
3095
+ const token = tokenizer.nextToken();
3096
+ if (token.kind === import_spel_ts.TokenKind.EOF) break;
3097
+ previousKind = lastKind;
3098
+ lastKind = token.kind;
3099
+ lastLiteral = token.literal;
3100
+ }
3101
+ } catch {
3102
+ return hasUnterminatedStringLiteral(expression);
3103
+ }
3104
+ if (lastKind === null) return true;
3105
+ if (INCOMPLETE_TRAILING_TOKENS.has(lastKind)) return true;
3106
+ if (lastLiteral !== void 0 && /^[A-Za-z]+$/.test(lastLiteral) && lastKind !== import_spel_ts.TokenKind.LITERAL_STRING && previousKind !== import_spel_ts.TokenKind.DOT && previousKind !== import_spel_ts.TokenKind.SAFE_NAV && WORD_OPERATORS.has(lastLiteral.toLowerCase())) {
3107
+ return true;
3108
+ }
3109
+ return false;
3110
+ }
3111
+ /**
3112
+ * Check if parentheses are balanced. Expects a literal-masked expression.
2507
3113
  */
2508
3114
  hasBalancedParentheses(expression) {
2509
3115
  const stack = [];
@@ -2519,17 +3125,29 @@ var ValidationPipeline = class {
2519
3125
  return stack.length === 0;
2520
3126
  }
2521
3127
  /**
2522
- * Extract all identifier references from expression
3128
+ * Extract all identifier references from expression.
3129
+ *
3130
+ * A bare `#x` that is only the head of a dotted reference (`#x.y`) is not
3131
+ * emitted on its own: a dotted reference names a property of some object,
3132
+ * not a variable of that name.
2523
3133
  */
2524
3134
  extractReferences(expression) {
2525
3135
  const refs = [];
3136
+ const dottedHeads = /* @__PURE__ */ new Set();
2526
3137
  const varMatch = expression.matchAll(/#(\w+(?:\.\w+(?:\.\w+)?)?)/g);
2527
3138
  for (const m of varMatch) {
2528
- refs.push(`#${m[1]}`);
3139
+ const ref = `#${m[1]}`;
3140
+ if (!refs.includes(ref)) {
3141
+ refs.push(ref);
3142
+ }
3143
+ if (ref.includes(".")) {
3144
+ dottedHeads.add(ref.split(".")[0]);
3145
+ }
2529
3146
  }
2530
3147
  const simpleMatch = expression.matchAll(/#(\w+)(?!\w*\()/g);
2531
3148
  for (const m of simpleMatch) {
2532
3149
  const ref = `#${m[1]}`;
3150
+ if (dottedHeads.has(ref)) continue;
2533
3151
  if (!refs.includes(ref)) {
2534
3152
  refs.push(ref);
2535
3153
  }
@@ -2537,98 +3155,68 @@ var ValidationPipeline = class {
2537
3155
  return refs;
2538
3156
  }
2539
3157
  };
2540
-
2541
- // src/validation/auto-fixer.ts
2542
- var AutoFixer = class {
2543
- fix(expression) {
2544
- let fixed = expression;
2545
- const changes = [];
2546
- if (fixed.includes("=== undefined")) {
2547
- fixed = fixed.replace(/=== undefined/g, "== null");
2548
- changes.push("Replaced === undefined with == null");
2549
- }
2550
- if (fixed.includes("!== undefined")) {
2551
- fixed = fixed.replace(/!== undefined/g, "!= null");
2552
- changes.push("Replaced !== undefined with != null");
2553
- }
2554
- if (fixed.includes("!==")) {
2555
- const count = fixed.match(/!==/g).length;
2556
- fixed = fixed.replace(/!==/g, "!=");
2557
- changes.push(`Replaced ${count}x !== with !=`);
2558
- }
2559
- if (fixed.includes("===")) {
2560
- const count = fixed.match(/===/g).length;
2561
- fixed = fixed.replace(/===/g, "==");
2562
- changes.push(`Replaced ${count}x === with ==`);
2563
- }
2564
- if (fixed.includes("&&")) {
2565
- const count = fixed.match(/&&/g).length;
2566
- fixed = fixed.replace(/&&/g, "and");
2567
- changes.push(`Replaced ${count}x && with and`);
2568
- }
2569
- if (fixed.includes("||")) {
2570
- const count = fixed.match(/\|\|/g).length;
2571
- fixed = fixed.replace(/\|\|/g, "or");
2572
- changes.push(`Replaced ${count}x || with or`);
2573
- }
2574
- const singleQuoteCount = (fixed.match(/'/g) ?? []).length;
2575
- if (singleQuoteCount % 2 !== 0) {
2576
- if (!fixed.endsWith("'")) {
2577
- fixed = fixed + "'";
2578
- changes.push("Added missing closing single quote");
2579
- }
2580
- }
2581
- const doubleQuoteCount = (fixed.match(/"/g) ?? []).length;
2582
- if (doubleQuoteCount % 2 !== 0) {
2583
- if (!fixed.endsWith('"')) {
2584
- fixed = fixed + '"';
2585
- changes.push("Added missing closing double quote");
2586
- }
2587
- }
2588
- const parenFix = this.fixAllBrackets(fixed);
2589
- if (parenFix !== fixed) {
2590
- changes.push("Fixed unbalanced brackets");
2591
- fixed = parenFix;
2592
- }
2593
- if (fixed.includes("> ==")) {
2594
- fixed = fixed.replace(/> ==/g, ">=");
2595
- changes.push("Replaced > == with >=");
2596
- }
2597
- if (fixed.includes("< ==")) {
2598
- fixed = fixed.replace(/< ==/g, "<=");
2599
- changes.push("Replaced < == with <=");
2600
- }
2601
- if (fixed.includes("? :")) {
2602
- fixed = fixed.replace(/\?\s*:\s*/g, " ?: ");
2603
- changes.push("Fixed Elvis operator spacing");
2604
- }
2605
- const wasFixed = changes.length > 0;
2606
- return {
2607
- wasFixed,
2608
- expression: wasFixed ? fixed : expression,
2609
- changes
2610
- };
2611
- }
2612
- fixAllBrackets(expr) {
2613
- let result = expr;
2614
- const openParen = (result.match(/\(/g) ?? []).length;
2615
- const closeParen = (result.match(/\)/g) ?? []).length;
2616
- if (openParen > closeParen) {
2617
- result += ")".repeat(openParen - closeParen);
2618
- }
2619
- const openBracket = (result.match(/\[/g) ?? []).length;
2620
- const closeBracket = (result.match(/\]/g) ?? []).length;
2621
- if (openBracket > closeBracket) {
2622
- result += "]".repeat(openBracket - closeBracket);
2623
- }
2624
- const openBrace = (result.match(/\{/g) ?? []).length;
2625
- const closeBrace = (result.match(/\}/g) ?? []).length;
2626
- if (openBrace > closeBrace) {
2627
- result += "}".repeat(openBrace - closeBrace);
2628
- }
2629
- return result;
2630
- }
2631
- };
3158
+ var INCOMPLETE_TRAILING_TOKENS = /* @__PURE__ */ new Set([
3159
+ import_spel_ts.TokenKind.PLUS,
3160
+ import_spel_ts.TokenKind.MINUS,
3161
+ import_spel_ts.TokenKind.STAR,
3162
+ import_spel_ts.TokenKind.SLASH,
3163
+ import_spel_ts.TokenKind.PERCENT,
3164
+ import_spel_ts.TokenKind.MOD,
3165
+ import_spel_ts.TokenKind.POWER,
3166
+ import_spel_ts.TokenKind.INC,
3167
+ import_spel_ts.TokenKind.DEC,
3168
+ import_spel_ts.TokenKind.EQ,
3169
+ import_spel_ts.TokenKind.NE,
3170
+ import_spel_ts.TokenKind.LT,
3171
+ import_spel_ts.TokenKind.LE,
3172
+ import_spel_ts.TokenKind.GT,
3173
+ import_spel_ts.TokenKind.GE,
3174
+ import_spel_ts.TokenKind.AND,
3175
+ import_spel_ts.TokenKind.OR,
3176
+ import_spel_ts.TokenKind.NOT,
3177
+ import_spel_ts.TokenKind.ASSIGN,
3178
+ import_spel_ts.TokenKind.MATCHES,
3179
+ import_spel_ts.TokenKind.BETWEEN,
3180
+ import_spel_ts.TokenKind.INSTANCEOF,
3181
+ import_spel_ts.TokenKind.LPAREN,
3182
+ import_spel_ts.TokenKind.LBRACKET,
3183
+ import_spel_ts.TokenKind.LBRACE,
3184
+ import_spel_ts.TokenKind.COMMA,
3185
+ import_spel_ts.TokenKind.COLON,
3186
+ import_spel_ts.TokenKind.DOT,
3187
+ import_spel_ts.TokenKind.SAFE_NAV,
3188
+ import_spel_ts.TokenKind.QMARK,
3189
+ import_spel_ts.TokenKind.ELVIS,
3190
+ import_spel_ts.TokenKind.HASH,
3191
+ import_spel_ts.TokenKind.AT,
3192
+ import_spel_ts.TokenKind.AMP_AT,
3193
+ import_spel_ts.TokenKind.PROJECTION,
3194
+ import_spel_ts.TokenKind.SELECTION,
3195
+ import_spel_ts.TokenKind.SELECT_FIRST,
3196
+ import_spel_ts.TokenKind.SELECT_LAST,
3197
+ import_spel_ts.TokenKind.TYPE_START,
3198
+ import_spel_ts.TokenKind.NEW,
3199
+ import_spel_ts.TokenKind.DOTDOT
3200
+ ]);
3201
+ var WORD_OPERATORS = /* @__PURE__ */ new Set([
3202
+ "and",
3203
+ "or",
3204
+ "not",
3205
+ "matches",
3206
+ "between",
3207
+ "instanceof",
3208
+ // The textual operators in Spring's ALTERNATIVE_OPERATOR_NAMES (`eq`, `ne`,
3209
+ // `div`, …) tokenize as their own kinds where the engine defines one, and as
3210
+ // plain identifiers where it does not. `div` is the one that has no kind on
3211
+ // the engine build this package compiles against — `1 div 2` tokenizes as
3212
+ // IDENTIFIER so it is listed here to keep `#a div` from passing as complete.
3213
+ // A property named `div` is still safe: the preceding-dot guard below exempts
3214
+ // `#order.div`.
3215
+ "div"
3216
+ ]);
3217
+ function escapeRegExp(value) {
3218
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3219
+ }
2632
3220
 
2633
3221
  // src/validation/self-correction-loop.ts
2634
3222
  var SelfCorrectionLoop = class {
@@ -2786,20 +3374,23 @@ var StrategyRouter = class {
2786
3374
  if (contextSchema) {
2787
3375
  this.templateEngine.setContext(contextSchema);
2788
3376
  }
2789
- const patternResult = this.patternMatcher.match(nl);
2790
- if (patternResult.matched && patternResult.confidence >= this.config.patternMinConfidence) {
2791
- const validation = await this.validationPipeline.validate(patternResult.spel, contextSchema);
3377
+ const isCompound = splitClauses(nl).length > 1;
3378
+ let clauseFailure = null;
3379
+ const wholeMatch = this.patternMatcher.match(nl);
3380
+ const wholeIsFaithful = wholeMatch.matched && wholeMatch.confidence >= this.config.patternMinConfidence && (!isCompound || (wholeMatch.pattern?.tags.includes("logic") ?? false));
3381
+ if (wholeIsFaithful) {
3382
+ const validation = await this.validationPipeline.validate(wholeMatch.spel, contextSchema);
2792
3383
  if (validation.valid) {
2793
3384
  return {
2794
- expression: patternResult.spel,
3385
+ expression: wholeMatch.spel,
2795
3386
  strategy: "pattern",
2796
- confidence: patternResult.confidence,
2797
- metadata: { patternId: patternResult.pattern?.id },
3387
+ confidence: wholeMatch.confidence,
3388
+ metadata: { patternId: wholeMatch.pattern?.id },
2798
3389
  latencyMs: Date.now() - startTime
2799
3390
  };
2800
3391
  }
2801
3392
  try {
2802
- const afResult = this.autoFixer.fix(patternResult.spel);
3393
+ const afResult = this.autoFixer.fix(wholeMatch.spel);
2803
3394
  if (afResult.wasFixed) {
2804
3395
  const afValidation = await this.validationPipeline.validate(
2805
3396
  afResult.expression,
@@ -2809,8 +3400,8 @@ var StrategyRouter = class {
2809
3400
  return {
2810
3401
  expression: afResult.expression,
2811
3402
  strategy: "pattern",
2812
- confidence: patternResult.confidence * 0.95,
2813
- metadata: { patternId: patternResult.pattern?.id },
3403
+ confidence: wholeMatch.confidence * 0.95,
3404
+ metadata: { patternId: wholeMatch.pattern?.id },
2814
3405
  latencyMs: Date.now() - startTime
2815
3406
  };
2816
3407
  }
@@ -2818,6 +3409,30 @@ var StrategyRouter = class {
2818
3409
  } catch {
2819
3410
  }
2820
3411
  }
3412
+ if (isCompound && !wholeIsFaithful) {
3413
+ let decomposition = null;
3414
+ try {
3415
+ decomposition = this.decomposeClauses(nl);
3416
+ } catch (error) {
3417
+ if (!(error instanceof UnconvertibleClauseError)) throw error;
3418
+ clauseFailure = error;
3419
+ }
3420
+ if (decomposition) {
3421
+ const validation = await this.validationPipeline.validate(
3422
+ decomposition.expression,
3423
+ contextSchema
3424
+ );
3425
+ if (validation.valid) {
3426
+ return {
3427
+ expression: decomposition.expression,
3428
+ strategy: "pattern",
3429
+ confidence: decomposition.confidence,
3430
+ metadata: { clauses: decomposition.clauses },
3431
+ latencyMs: Date.now() - startTime
3432
+ };
3433
+ }
3434
+ }
3435
+ }
2821
3436
  const intentResult = this.intentClassifier.classify(nl);
2822
3437
  let templateResult = null;
2823
3438
  try {
@@ -2851,7 +3466,7 @@ var StrategyRouter = class {
2851
3466
  }
2852
3467
  }
2853
3468
  if (providers.length === 0) {
2854
- throw new Error("No LLM providers available");
3469
+ throw clauseFailure ?? new Error("No LLM providers available");
2855
3470
  }
2856
3471
  let lastError = null;
2857
3472
  for (const provider of providers) {
@@ -2897,6 +3512,33 @@ var StrategyRouter = class {
2897
3512
  /**
2898
3513
  * Get PatternMatcher (for external testing/debugging)
2899
3514
  */
3515
+ /**
3516
+ * Convert a sentence that joins its clauses with a logical connector.
3517
+ *
3518
+ * Returns `null` when `nl` has no top-level connector, in which case the caller
3519
+ * should use its ordinary single-pass conversion. Throws
3520
+ * {@link UnconvertibleClauseError} when a clause cannot be converted, so the
3521
+ * caller can refuse instead of emitting a partial rule.
3522
+ */
3523
+ decomposeClauses(nl) {
3524
+ if (splitClauses(nl).length <= 1) return null;
3525
+ const confidences = [];
3526
+ const convert = (clause) => {
3527
+ const result = this.patternMatcher.match(clause);
3528
+ if (!result.matched || result.confidence < this.config.patternMinConfidence) {
3529
+ return null;
3530
+ }
3531
+ confidences.push(result.confidence);
3532
+ return result.spel;
3533
+ };
3534
+ const decomposition = decompose(nl, convert);
3535
+ if (decomposition === null) return null;
3536
+ return {
3537
+ expression: decomposition.expression,
3538
+ clauses: decomposition.clauses,
3539
+ confidence: confidences.length > 0 ? Math.min(...confidences) : 0
3540
+ };
3541
+ }
2900
3542
  getPatternMatcher() {
2901
3543
  return this.patternMatcher;
2902
3544
  }
@@ -2980,6 +3622,19 @@ var NL2SpelEngine = class {
2980
3622
  if (options.offlineOnly) {
2981
3623
  const patternMatcher = this.router.getPatternMatcher();
2982
3624
  const patternResult = patternMatcher.match(nl);
3625
+ const isCompound = splitClauses(nl).length > 1;
3626
+ const wholeIsFaithful = patternResult.matched && (patternResult.pattern?.tags.includes("logic") ?? false);
3627
+ if (isCompound && !wholeIsFaithful) {
3628
+ const decomposition = this.router.decomposeClauses(nl);
3629
+ if (decomposition) {
3630
+ return {
3631
+ expression: decomposition.expression,
3632
+ strategy: "pattern",
3633
+ confidence: decomposition.confidence,
3634
+ latencyMs: Date.now() - startTime
3635
+ };
3636
+ }
3637
+ }
2983
3638
  if (patternResult.matched) {
2984
3639
  return {
2985
3640
  expression: patternResult.spel,
@@ -3058,5 +3713,9 @@ var NL2SpelEngine = class {
3058
3713
  SelfCorrectionLoop,
3059
3714
  StrategyRouter,
3060
3715
  TemplateEngine,
3061
- ValidationPipeline
3716
+ UnconvertibleClauseError,
3717
+ UnmappedFieldError,
3718
+ ValidationPipeline,
3719
+ decompose,
3720
+ splitClauses
3062
3721
  });