@agentix-e/nl2spel 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -34,54 +34,80 @@ __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
 
41
45
  // src/provider/provider-registry.ts
42
46
  var ProviderRegistry = class {
43
47
  _providers = [];
44
- /** Register a Provider */
45
- register(provider) {
46
- if (this._providers.some((p) => p.name === provider.name)) {
48
+ _nextIndex = 0;
49
+ /**
50
+ * Register a Provider.
51
+ * @param provider LLMProvider instance
52
+ * @param options.priority User-assigned priority (lower = preferred). Defaults to registration order.
53
+ */
54
+ register(provider, options) {
55
+ if (this._providers.some((p) => p.provider.name === provider.name)) {
47
56
  throw new Error(`Provider '${provider.name}' already registered`);
48
57
  }
49
- this._providers.push(provider);
58
+ this._providers.push({
59
+ provider,
60
+ priority: options?.priority ?? this._nextIndex,
61
+ index: this._nextIndex
62
+ });
63
+ this._nextIndex++;
50
64
  }
51
65
  /** Unregister a Provider */
52
66
  unregister(name) {
53
- this._providers = this._providers.filter((p) => p.name !== name);
67
+ this._providers = this._providers.filter((p) => p.provider.name !== name);
54
68
  }
55
69
  /** Get a Provider by name */
56
70
  get(name) {
57
- return this._providers.find((p) => p.name === name);
71
+ return this._providers.find((p) => p.provider.name === name)?.provider;
58
72
  }
59
73
  /**
60
74
  * Get available Providers sorted by priority.
61
- * Sort rule: offline > low cost > low latency > high accuracy
75
+ * Sort rule: offline first user priority (asc) registration order (asc)
62
76
  */
63
77
  async getPrioritized() {
64
78
  const available = [];
65
- for (const p of this._providers) {
66
- if (await p.isAvailable()) {
67
- available.push(p);
79
+ for (const entry of this._providers) {
80
+ if (await entry.provider.isAvailable()) {
81
+ available.push(entry);
68
82
  }
69
83
  }
70
84
  return available.sort((a, b) => {
71
- const aOffline = a.capabilities.offlineAvailable;
72
- const bOffline = b.capabilities.offlineAvailable;
85
+ const aOffline = a.provider.capabilities.offlineAvailable;
86
+ const bOffline = b.provider.capabilities.offlineAvailable;
73
87
  if (aOffline && !bOffline) return -1;
74
88
  if (!aOffline && bOffline) return 1;
75
- const costA = a.capabilities.estimatedCostPerRequest ?? Infinity;
76
- const costB = b.capabilities.estimatedCostPerRequest ?? Infinity;
77
- if (costA < costB) return -1;
78
- if (costB < costA) return 1;
79
- return a.capabilities.estimatedLatencyMs - b.capabilities.estimatedLatencyMs;
80
- });
89
+ if (a.priority !== b.priority) return a.priority - b.priority;
90
+ return a.index - b.index;
91
+ }).map((entry) => entry.provider);
92
+ }
93
+ /**
94
+ * Explicitly reorder providers by name.
95
+ * Providers not listed retain their position after the reordered ones.
96
+ */
97
+ reorder(providerNames) {
98
+ const orderMap = new Map(providerNames.map((name, i) => [name, i]));
99
+ const maxExisting = this._providers.reduce(
100
+ (max, p) => Math.max(max, p.priority),
101
+ providerNames.length - 1
102
+ );
103
+ for (const entry of this._providers) {
104
+ const explicitIndex = orderMap.get(entry.provider.name);
105
+ entry.priority = explicitIndex ?? maxExisting + entry.index + 1;
106
+ }
81
107
  }
82
108
  /** List all registered Providers */
83
109
  list() {
84
- return [...this._providers];
110
+ return this._providers.map((p) => p.provider);
85
111
  }
86
112
  /** Number of registered Providers */
87
113
  get count() {
@@ -412,10 +438,55 @@ var ChineseNumberParser = class {
412
438
  };
413
439
 
414
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
+ };
415
484
  var PatternMatcher = class {
416
485
  _patterns;
417
- constructor(patterns = []) {
486
+ fieldPolicy;
487
+ constructor(patterns = [], options = {}) {
418
488
  this._patterns = [...patterns];
489
+ this.fieldPolicy = options.fieldPolicy ?? "passthrough";
419
490
  this.sortByPriority();
420
491
  }
421
492
  get patternCount() {
@@ -449,9 +520,22 @@ var PatternMatcher = class {
449
520
  }
450
521
  }
451
522
  }
452
- const spel = this.fillTemplate(pattern, slots, normalized, matchResult);
523
+ const { expression, unmappedFields } = this.fillTemplate(
524
+ pattern,
525
+ slots,
526
+ normalized,
527
+ matchResult
528
+ );
453
529
  const latencyMs2 = Date.now() - startTime;
454
- 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
+ };
455
539
  }
456
540
  const latencyMs = Date.now() - startTime;
457
541
  return { matched: false, confidence: 0, latencyMs };
@@ -472,13 +556,20 @@ var PatternMatcher = class {
472
556
  if (value !== void 0) slots[key] = value;
473
557
  }
474
558
  }
559
+ const { expression, unmappedFields } = this.fillTemplate(
560
+ pattern,
561
+ slots,
562
+ normalized,
563
+ matchResult
564
+ );
475
565
  results.push({
476
566
  matched: true,
477
567
  pattern,
478
- spel: this.fillTemplate(pattern, slots, normalized, matchResult),
568
+ spel: expression,
479
569
  confidence: pattern.confidence,
480
570
  latencyMs: 0,
481
- slots
571
+ slots,
572
+ ...unmappedFields.length > 0 ? { unmappedFields } : {}
482
573
  });
483
574
  }
484
575
  return results;
@@ -500,46 +591,37 @@ var PatternMatcher = class {
500
591
  if (/^(?:商品|product|item)/i.test(input)) return "product";
501
592
  return "order";
502
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
+ }
503
601
  /**
504
602
  * Extract Chinese field names from input and map to SpEL fields
505
603
  */
506
604
  extractChineseField(input) {
507
- const m = input.match(/^[^\s,,、]+/);
508
- if (!m) return "value";
509
- const first = m[0];
510
- const cnMap = {
511
- \u5907\u6CE8: "remark",
512
- \u8BF4\u660E: "description",
513
- \u63CF\u8FF0: "description",
514
- \u91D1\u989D: "amount",
515
- \u6570\u91CF: "count",
516
- \u4E2A\u6570: "count",
517
- \u72B6\u6001: "status",
518
- \u7C7B\u578B: "type",
519
- \u540D\u79F0: "name",
520
- \u6807\u9898: "title",
521
- \u5730\u5740: "address",
522
- \u90AE\u7BB1: "email",
523
- \u624B\u673A: "phone",
524
- \u7535\u8BDD: "phone",
525
- \u65E5\u671F: "date",
526
- \u65F6\u95F4: "time",
527
- \u5E74\u9F84: "age",
528
- \u4EF7\u683C: "price",
529
- \u7528\u6237\u540D: "name",
530
- \u6743\u9650: "role",
531
- \u6807\u7B7E: "tags",
532
- \u5217\u8868: "list",
533
- \u6570\u7EC4: "items",
534
- \u6587\u4EF6: "file",
535
- \u6587\u4EF6\u540D: "name",
536
- \u8FC7\u671F: "expiryDate",
537
- \u521B\u5EFA: "createdAt",
538
- \u6709\u6548: "valid",
539
- \u6D3B\u8DC3: "active",
540
- \u6FC0\u6D3B: "active"
541
- };
542
- 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 };
543
625
  }
544
626
  /**
545
627
  * Template filling and value transformation
@@ -547,7 +629,20 @@ var PatternMatcher = class {
547
629
  fillTemplate(pattern, slots, originalInput, _matchResult) {
548
630
  let result = pattern.spelTemplate;
549
631
  const hasFieldSlot = "field" in slots;
550
- 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
+ }
551
646
  const root = this.inferRoot(originalInput);
552
647
  result = result.replace(/\{field\}/g, field);
553
648
  result = result.replace(/\{root\}/g, root);
@@ -575,14 +670,7 @@ var PatternMatcher = class {
575
670
  result = result.replace(`{${key}}`, transformedValue ?? "");
576
671
  }
577
672
  result = result.replace(/\{[a-zA-Z_]+\}/g, "");
578
- return result.trim();
579
- }
580
- /**
581
- * Infer SpEL field name from capture group
582
- */
583
- inferFieldFromCapture(captured) {
584
- if (/^[a-zA-Z_]\w*$/.test(captured)) return captured;
585
- return this.extractChineseField(captured);
673
+ return { expression: result.trim(), unmappedFields };
586
674
  }
587
675
  };
588
676
 
@@ -648,7 +736,7 @@ var BUILTIN_PATTERNS = [
648
736
  slots: {},
649
737
  priority: 98,
650
738
  tags: ["null", "isNotNull"],
651
- examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#\u5907\u6CE8 != null" }],
739
+ examples: [{ nl: "\u5907\u6CE8\u4E0D\u4E3A\u7A7A", spel: "#remark != null" }],
652
740
  difficulty: "easy",
653
741
  confidence: 0.98
654
742
  },
@@ -659,7 +747,7 @@ var BUILTIN_PATTERNS = [
659
747
  slots: {},
660
748
  priority: 97,
661
749
  tags: ["null", "isNull"],
662
- examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#\u5907\u6CE8 == null" }],
750
+ examples: [{ nl: "\u5907\u6CE8\u4E3A\u7A7A", spel: "#remark == null" }],
663
751
  difficulty: "easy",
664
752
  confidence: 0.98
665
753
  },
@@ -690,29 +778,29 @@ var BUILTIN_PATTERNS = [
690
778
  // ================================================================
691
779
  {
692
780
  id: "CN-CMP-GE",
693
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
781
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不小于|不低于|大于等于|>=)\s*(?<value>\d+(?:\.\d+)?)/,
694
782
  spelTemplate: "#{field} >= {value}",
695
783
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
696
784
  priority: 93,
697
785
  tags: ["comparison", "ge", "chinese"],
698
- examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#\u91D1\u989D >= 100" }],
786
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u5C0F\u4E8E100", spel: "#amount >= 100" }],
699
787
  difficulty: "easy",
700
788
  confidence: 0.95
701
789
  },
702
790
  {
703
791
  id: "CN-CMP-LE",
704
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值)?\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
792
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不大于|不超过|小于等于|<=)\s*(?<value>\d+(?:\.\d+)?)/,
705
793
  spelTemplate: "#{field} <= {value}",
706
794
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
707
795
  priority: 93,
708
796
  tags: ["comparison", "le", "chinese"],
709
- examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#\u91D1\u989D <= 500" }],
797
+ examples: [{ nl: "\u91D1\u989D\u4E0D\u8D85\u8FC7500", spel: "#amount <= 500" }],
710
798
  difficulty: "easy",
711
799
  confidence: 0.95
712
800
  },
713
801
  {
714
802
  id: "CN-CMP-GT",
715
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
803
+ match: /^(?<field>[^\s,,、]+?)\s*(?:大于|超过|高于)\s*(?<value>\d+(?:\.\d+)?)/,
716
804
  spelTemplate: "#{field} > {value}",
717
805
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
718
806
  priority: 92,
@@ -723,7 +811,7 @@ var BUILTIN_PATTERNS = [
723
811
  },
724
812
  {
725
813
  id: "CN-CMP-LT",
726
- match: /^(?<field>[^\s,,、]+?)\s*(?:金额|值|数量|价格)?\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
814
+ match: /^(?<field>[^\s,,、]+?)\s*(?:小于|低于|不到)\s*(?<value>\d+(?:\.\d+)?)/,
727
815
  spelTemplate: "#{field} < {value}",
728
816
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
729
817
  priority: 92,
@@ -737,7 +825,7 @@ var BUILTIN_PATTERNS = [
737
825
  // ================================================================
738
826
  {
739
827
  id: "CN-EQ-STATUS",
740
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
828
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|是|为)\s*(?<value>[^\s,,、]+)$/,
741
829
  spelTemplate: "#{field} == '{value}'",
742
830
  slots: { value: { key: "value", type: "string" } },
743
831
  priority: 85,
@@ -760,7 +848,7 @@ var BUILTIN_PATTERNS = [
760
848
  // CN: "order status is not cancelled" — must be before NOT pattern
761
849
  {
762
850
  id: "CN-NE-STATUS",
763
- match: /^(?<field>[^\s,,、]+?)\s*(?:状态|类型)?\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
851
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不等于|!=|不是)\s*(?<value>[^\s,,、]+)$/,
764
852
  spelTemplate: "#{field} != '{value}'",
765
853
  slots: { value: { key: "value", type: "string" } },
766
854
  priority: 91,
@@ -783,12 +871,12 @@ var BUILTIN_PATTERNS = [
783
871
  // CN/EN: count equality
784
872
  {
785
873
  id: "CN-EQ-COUNT",
786
- match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|计数)?\s*(?:等于|==)\s*(?<value>\d+)/,
874
+ match: /^(?<field>[^\s,,、]+?)\s*(?:等于|==)\s*(?<value>\d+)/,
787
875
  spelTemplate: "#{field} == {value}",
788
876
  slots: { value: { key: "value", type: "number", transform: "toNumber" } },
789
877
  priority: 91,
790
878
  tags: ["comparison", "eq", "number"],
791
- examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#\u6570\u91CF == 5" }],
879
+ examples: [{ nl: "\u6570\u91CF\u7B49\u4E8E5", spel: "#count == 5" }],
792
880
  difficulty: "easy",
793
881
  confidence: 0.95
794
882
  },
@@ -830,7 +918,9 @@ var BUILTIN_PATTERNS = [
830
918
  },
831
919
  {
832
920
  id: "CN-PERM-PERM",
833
- 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>.+)$/,
834
924
  spelTemplate: "hasPermission('{permission}')",
835
925
  slots: { permission: { key: "permission", type: "string" } },
836
926
  priority: 88,
@@ -855,7 +945,7 @@ var BUILTIN_PATTERNS = [
855
945
  // ================================================================
856
946
  {
857
947
  id: "CN-COLL-EMPTY",
858
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
948
+ match: /^(?<field>[^\s,,、]+?)\s*(?:为空|是空的|没有|无)\s*(?:元素|数据|项)?$/,
859
949
  spelTemplate: "#{field}.isEmpty()",
860
950
  slots: {},
861
951
  priority: 89,
@@ -877,7 +967,7 @@ var BUILTIN_PATTERNS = [
877
967
  },
878
968
  {
879
969
  id: "CN-COLL-NOTEMPTY",
880
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
970
+ match: /^(?<field>[^\s,,、]+?)\s*(?:不为空|有)\s*(?:元素|数据|项)?$/,
881
971
  spelTemplate: "!#{field}.isEmpty()",
882
972
  slots: {},
883
973
  priority: 89,
@@ -899,7 +989,7 @@ var BUILTIN_PATTERNS = [
899
989
  },
900
990
  {
901
991
  id: "CN-COLL-CONTAINS",
902
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
992
+ match: /^(?<field>[^\s,,、]+?)\s*(?:中\s*)?(?:包含|含有|有)\s*(?<element>[^\s,,、]+)/,
903
993
  spelTemplate: "#{field}.contains('{element}')",
904
994
  slots: { element: { key: "element", type: "string" } },
905
995
  priority: 87,
@@ -921,7 +1011,7 @@ var BUILTIN_PATTERNS = [
921
1011
  },
922
1012
  {
923
1013
  id: "CN-COLL-SIZE",
924
- match: /^(?<field>[^\s,,、]+?)\s*(?:列表|数组|集合)?\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
1014
+ match: /^(?<field>[^\s,,、]+?)\s*(?:数量|个数|大小|长度)\s*(?<op>大于|>|超过|小于|<|等于|==)\s*(?<value>\d+)/,
925
1015
  spelTemplate: "#{field}.size() {op} {value}",
926
1016
  slots: {
927
1017
  value: { key: "value", type: "number", transform: "toNumber" },
@@ -952,7 +1042,7 @@ var BUILTIN_PATTERNS = [
952
1042
  // ================================================================
953
1043
  {
954
1044
  id: "CN-STR-CONTAINS",
955
- match: /^(?<field>[^\s,,、]+?)(?:备注|名称|描述|标签|标题)?\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
1045
+ match: /^(?<field>[^\s,,、]+?)\s*(?:包含|含有|包括)\s*(?<substr>[^\s,,、]+)/,
956
1046
  spelTemplate: "#{field}.contains('{substr}')",
957
1047
  slots: { substr: { key: "substr", type: "string" } },
958
1048
  priority: 85,
@@ -1001,7 +1091,7 @@ var BUILTIN_PATTERNS = [
1001
1091
  slots: { suffix: { key: "suffix", type: "string" } },
1002
1092
  priority: 85,
1003
1093
  tags: ["string", "endsWith"],
1004
- 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')" }],
1005
1095
  difficulty: "easy",
1006
1096
  confidence: 0.95
1007
1097
  },
@@ -1044,21 +1134,21 @@ var BUILTIN_PATTERNS = [
1044
1134
  {
1045
1135
  id: "CN-RANGE-BETWEEN",
1046
1136
  match: /^(?<field>[^\s,,、]+?)\s*(?:在|介于)\s*(?<min>\d+)\s*(?:和|到|~)\s*(?<max>\d+)\s*(?:之间|范围)?/,
1047
- spelTemplate: "#{field} between {{{min}, {max}}}",
1137
+ spelTemplate: "#{field} between {{min}, {max}}",
1048
1138
  slots: {
1049
1139
  min: { key: "min", type: "number", transform: "toNumber" },
1050
1140
  max: { key: "max", type: "number", transform: "toNumber" }
1051
1141
  },
1052
1142
  priority: 82,
1053
1143
  tags: ["range", "between"],
1054
- 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}" }],
1055
1145
  difficulty: "easy",
1056
1146
  confidence: 0.95
1057
1147
  },
1058
1148
  {
1059
1149
  id: "EN-RANGE-BETWEEN",
1060
1150
  match: /\b(?<field>\w+)\s+between\s+(?<min>\d+)\s+and\s+(?<max>\d+)/i,
1061
- spelTemplate: "#{field} between {{{min}, {max}}}",
1151
+ spelTemplate: "#{field} between {{min}, {max}}",
1062
1152
  slots: {
1063
1153
  min: { key: "min", type: "number", transform: "toNumber" },
1064
1154
  max: { key: "max", type: "number", transform: "toNumber" }
@@ -1079,7 +1169,7 @@ var BUILTIN_PATTERNS = [
1079
1169
  slots: { default: { key: "default", type: "string" } },
1080
1170
  priority: 78,
1081
1171
  tags: ["elvis"],
1082
- 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'" }],
1083
1173
  difficulty: "medium",
1084
1174
  confidence: 0.85
1085
1175
  },
@@ -1185,7 +1275,11 @@ var BUILTIN_PATTERNS = [
1185
1275
  // ================================================================
1186
1276
  {
1187
1277
  id: "CN-BOOL-TRUE",
1188
- 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*)$/,
1189
1283
  spelTemplate: "#{field} == true",
1190
1284
  slots: {},
1191
1285
  priority: 74,
@@ -1196,7 +1290,10 @@ var BUILTIN_PATTERNS = [
1196
1290
  },
1197
1291
  {
1198
1292
  id: "CN-BOOL-FALSE",
1199
- 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*)$/,
1200
1297
  spelTemplate: "#{field} == false",
1201
1298
  slots: {},
1202
1299
  priority: 73,
@@ -1304,7 +1401,7 @@ var BUILTIN_PATTERNS = [
1304
1401
  // ================================================================
1305
1402
  {
1306
1403
  id: "CN-LOGIC-NOT",
1307
- match: /^不是\s+(?<expr>.+)/,
1404
+ match: /^不是\s*(?<expr>.+)/,
1308
1405
  spelTemplate: "!({expr})",
1309
1406
  slots: { expr: { key: "expr", type: "variable" } },
1310
1407
  priority: 62,
@@ -1329,8 +1426,8 @@ var BUILTIN_PATTERNS = [
1329
1426
  // ================================================================
1330
1427
  {
1331
1428
  id: "CN-SELECT-FIRST",
1332
- match: /^(?<root>[^\s,,、]+?)\s*中\s*第一[个位]\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==)\s*(?<value>[^\s,,、]+)/,
1333
- 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}]",
1334
1431
  slots: {
1335
1432
  root: { key: "root", type: "variable" },
1336
1433
  field: { key: "field", type: "variable" },
@@ -1338,14 +1435,14 @@ var BUILTIN_PATTERNS = [
1338
1435
  },
1339
1436
  priority: 60,
1340
1437
  tags: ["selection", "first"],
1341
- 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]" }],
1342
1439
  difficulty: "medium",
1343
1440
  confidence: 0.8
1344
1441
  },
1345
1442
  {
1346
1443
  id: "CN-SELECT-ALL",
1347
- match: /^(?<root>[^\s,,、]+?)\s*中\s*所有\s*(?<field>[^\s,,、]+?)\s*(?:大于|>|超过|<|小于|等于|==|包含|满足)\s*(?<value>[^\s,,、]+)/,
1348
- 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}]",
1349
1446
  slots: {
1350
1447
  root: { key: "root", type: "variable" },
1351
1448
  field: { key: "field", type: "variable" },
@@ -1353,28 +1450,28 @@ var BUILTIN_PATTERNS = [
1353
1450
  },
1354
1451
  priority: 60,
1355
1452
  tags: ["selection", "all"],
1356
- 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]" }],
1357
1454
  difficulty: "medium",
1358
1455
  confidence: 0.8
1359
1456
  },
1360
1457
  {
1361
1458
  id: "CN-PROJ",
1362
- match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:值|金额|名称|价格|name|amount|price)?/,
1363
- spelTemplate: "#{root}.items.![#{this}.{field}]",
1459
+ match: /^(?<root>[^\s,,、]+?)\s*中\s*每[个一]\s*(?:的)?(?<field>[^\s,,、]+?)\s*(?:的)?(?:值|金额|名称|价格|name|amount|price)?$/,
1460
+ spelTemplate: "#{root}.items.![#this.{field}]",
1364
1461
  slots: {
1365
1462
  root: { key: "root", type: "variable" },
1366
1463
  field: { key: "field", type: "variable" }
1367
1464
  },
1368
1465
  priority: 55,
1369
1466
  tags: ["projection"],
1370
- 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]" }],
1371
1468
  difficulty: "medium",
1372
1469
  confidence: 0.75
1373
1470
  },
1374
1471
  {
1375
1472
  id: "EN-SELECT-ALL",
1376
1473
  match: /all\s+(?<root>\w+)\s+with\s+(?<field>\w+)\s*>\s*(?<value>\d+)/i,
1377
- spelTemplate: "#{root}.items.?[#{this}.{field} > {value}]",
1474
+ spelTemplate: "#{root}.items.?[#this.{field} > {value}]",
1378
1475
  slots: {
1379
1476
  root: { key: "root", type: "variable" },
1380
1477
  field: { key: "field", type: "variable" },
@@ -1382,13 +1479,149 @@ var BUILTIN_PATTERNS = [
1382
1479
  },
1383
1480
  priority: 50,
1384
1481
  tags: ["selection", "all", "english"],
1385
- 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]" }],
1386
1483
  difficulty: "medium",
1387
1484
  confidence: 0.8
1388
1485
  }
1389
1486
  ];
1390
1487
  BUILTIN_PATTERNS.sort((a, b) => b.priority - a.priority);
1391
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
+
1392
1625
  // src/template/nl-intent.ts
1393
1626
  var NLIntent = /* @__PURE__ */ ((NLIntent2) => {
1394
1627
  NLIntent2["COMPARISON"] = "COMPARISON";
@@ -1427,7 +1660,7 @@ var INTENT_KEYWORDS = {
1427
1660
  en: ["greater", "less", "equal", "above", "below", "exceed", ">", "<", "==", "!=", ">=", "<="]
1428
1661
  },
1429
1662
  ["NULL_CHECK" /* NULL_CHECK */]: {
1430
- 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"],
1431
1664
  en: ["null", "empty", "is null", "is not null", "is empty", "is not empty"]
1432
1665
  },
1433
1666
  ["PERMISSION_CHECK" /* PERMISSION_CHECK */]: {
@@ -1464,7 +1697,10 @@ var INTENT_KEYWORDS = {
1464
1697
  },
1465
1698
  ["BOOLEAN" /* BOOLEAN */]: {
1466
1699
  zh: ["\u662F\u5426", "\u771F\u5047", "true", "false", "\u662F", "\u5426"],
1467
- 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"]
1468
1704
  },
1469
1705
  ["DATE" /* DATE */]: {
1470
1706
  zh: ["\u65E5\u671F", "\u65F6\u95F4", "\u4E4B\u540E", "\u4E4B\u524D", "\u65E9\u4E8E", "\u665A\u4E8E"],
@@ -1483,6 +1719,38 @@ var INTENT_KEYWORDS = {
1483
1719
  en: ["plus", "minus", "multiply", "divide", "mod", "sum", "average"]
1484
1720
  }
1485
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
+ }
1486
1754
  var IntentClassifier = class {
1487
1755
  /**
1488
1756
  * Main method for classifying natural language input
@@ -1516,6 +1784,9 @@ var IntentClassifier = class {
1516
1784
  if (/isempty|\.isEmpty|列表|数组|集合/.test(normalized)) {
1517
1785
  intentScores.set("COLLECTION" /* COLLECTION */, (intentScores.get("COLLECTION" /* COLLECTION */) ?? 0) + 1);
1518
1786
  }
1787
+ if (detectNullPredicate(normalized)) {
1788
+ intentScores.set("NULL_CHECK" /* NULL_CHECK */, (intentScores.get("NULL_CHECK" /* NULL_CHECK */) ?? 0) + 2);
1789
+ }
1519
1790
  const entities = this.extractEntities(normalized);
1520
1791
  const operators = this.extractOperators(normalized);
1521
1792
  const logicalConnectors = this.extractLogicalConnectors(normalized);
@@ -1628,6 +1899,68 @@ var IntentClassifier = class {
1628
1899
  };
1629
1900
 
1630
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
+ ]);
1631
1964
  var TEMPLATE_LIBRARY = {
1632
1965
  ["COMPARISON" /* COMPARISON */]: [
1633
1966
  {
@@ -1872,8 +2205,9 @@ var TemplateEngine = class {
1872
2205
  selectBestTemplate(templates, intentResult, input) {
1873
2206
  let bestScore = -1;
1874
2207
  let bestTemplate = null;
1875
- const hasEmptyKeyword = /为空|empty|null/i.test(input);
1876
- const hasNotEmptyKeyword = /不为空|not empty|not null/i.test(input);
2208
+ const polarity = detectNullPredicate(input);
2209
+ const isAffirmative = polarity === "affirmative";
2210
+ const isNegated = polarity === "negated";
1877
2211
  for (const template of templates) {
1878
2212
  const conditions = template.conditions;
1879
2213
  let score = 0;
@@ -1882,8 +2216,10 @@ var TemplateEngine = class {
1882
2216
  if (conditions.hasCollection) score += 0.5;
1883
2217
  if (conditions.hasNull) score += 0.5;
1884
2218
  if (conditions.hasString) score += 1;
1885
- if (template.name.includes("IS_EMPTY") && hasEmptyKeyword) score += 2;
1886
- 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;
1887
2223
  if (conditions.entityCount) {
1888
2224
  if (conditions.entityCount.min && intentResult.entities.length < conditions.entityCount.min) {
1889
2225
  continue;
@@ -1899,37 +2235,8 @@ var TemplateEngine = class {
1899
2235
  fillTemplate(template, input, intentResult) {
1900
2236
  let expression = template;
1901
2237
  const unfilledSlots = [];
1902
- let rootName = "order";
1903
- let fieldName = "field";
1904
- if (this.contextSchema?.root) {
1905
- rootName = this.contextSchema.root.name;
1906
- const fields = Object.keys(this.contextSchema.root.fields ?? {});
1907
- for (const f of fields) {
1908
- if (input.includes(f)) {
1909
- fieldName = f;
1910
- break;
1911
- }
1912
- }
1913
- } else {
1914
- const rootMap = {
1915
- \u8BA2\u5355: "order",
1916
- order: "order",
1917
- \u7528\u6237: "user",
1918
- user: "user",
1919
- \u6587\u4EF6: "file",
1920
- file: "file",
1921
- \u8D26\u53F7: "account",
1922
- account: "account",
1923
- \u5546\u54C1: "item",
1924
- product: "item"
1925
- };
1926
- for (const [key, val] of Object.entries(rootMap)) {
1927
- if (input.includes(key)) {
1928
- rootName = val;
1929
- break;
1930
- }
1931
- }
1932
- }
2238
+ const rootName = this.resolveRootName(input);
2239
+ const fieldName = this.resolveFieldName(input);
1933
2240
  expression = expression.replace(/\{root\}/g, rootName);
1934
2241
  expression = expression.replace(/\{field\}/g, fieldName);
1935
2242
  const fieldEntities = intentResult.entities.filter((e) => e.type === "field");
@@ -1998,6 +2305,58 @@ var TemplateEngine = class {
1998
2305
  }
1999
2306
  return { expression, unfilledSlots };
2000
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
+ }
2001
2360
  };
2002
2361
 
2003
2362
  // src/template/prompts/prompt-builder.ts
@@ -2235,6 +2594,172 @@ ${userInput}
2235
2594
  }
2236
2595
  };
2237
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
+
2238
2763
  // src/validation/validation-pipeline.ts
2239
2764
  var ValidationPipeline = class {
2240
2765
  evaluator;
@@ -2254,25 +2779,29 @@ var ValidationPipeline = class {
2254
2779
  const typeStage = this.validateTypes(expression, contextSchema);
2255
2780
  const semanticStage = this.validateSemantic(expression);
2256
2781
  const contextStage = this.validateContext(expression, contextSchema);
2782
+ const finalStage = this.validateFinal(expression);
2257
2783
  errors.push(
2258
2784
  ...parseStage.errors,
2259
2785
  ...typeStage.errors,
2260
2786
  ...semanticStage.errors,
2261
- ...contextStage.errors
2787
+ ...contextStage.errors,
2788
+ ...finalStage.errors
2262
2789
  );
2263
2790
  warnings.push(
2264
2791
  ...parseStage.warnings,
2265
2792
  ...typeStage.warnings,
2266
2793
  ...semanticStage.warnings,
2267
- ...contextStage.warnings
2794
+ ...contextStage.warnings,
2795
+ ...finalStage.warnings
2268
2796
  );
2269
2797
  return {
2270
- valid: parseStage.passed && typeStage.passed && semanticStage.passed && contextStage.passed,
2798
+ valid: errors.length === 0,
2271
2799
  stages: {
2272
2800
  parse: parseStage,
2273
2801
  type: typeStage,
2274
2802
  semantic: semanticStage,
2275
- context: contextStage
2803
+ context: contextStage,
2804
+ final: finalStage
2276
2805
  },
2277
2806
  errors,
2278
2807
  warnings
@@ -2280,47 +2809,57 @@ var ValidationPipeline = class {
2280
2809
  }
2281
2810
  /**
2282
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.
2283
2816
  */
2284
2817
  async validateParse(expression) {
2285
2818
  const errors = [];
2286
2819
  const warnings = [];
2820
+ const structural = maskStringLiterals(expression);
2287
2821
  if (!expression || expression.trim().length === 0) {
2288
2822
  errors.push({
2289
2823
  code: "PARSE-EMPTY",
2290
2824
  message: "Expression is empty",
2825
+ severity: "error",
2291
2826
  stage: "parse",
2292
2827
  requiresLLM: true
2293
2828
  });
2294
2829
  return { passed: false, errors, warnings };
2295
2830
  }
2296
- if (!this.hasBalancedParentheses(expression)) {
2831
+ if (!this.hasBalancedParentheses(structural)) {
2297
2832
  errors.push({
2298
2833
  code: "PARSE-UNBALANCED_PARENS",
2299
2834
  message: "Unbalanced parentheses in expression",
2835
+ severity: "error",
2300
2836
  stage: "parse",
2301
2837
  requiresLLM: true
2302
2838
  });
2303
2839
  }
2304
- if (expression.includes("===") || expression.includes("!==")) {
2840
+ if (structural.includes("===") || structural.includes("!==")) {
2305
2841
  errors.push({
2306
2842
  code: "PARSE-JS_OPERATOR",
2307
2843
  message: "JavaScript operators detected (=== or !==), use == or != in SpEL",
2844
+ severity: "error",
2308
2845
  stage: "parse",
2309
2846
  requiresLLM: true
2310
2847
  });
2311
2848
  }
2312
- if (expression.includes("&&")) {
2849
+ if (structural.includes("&&")) {
2313
2850
  errors.push({
2314
2851
  code: "PARSE-JS_LOGIC",
2315
2852
  message: 'JavaScript && detected, use "and" in SpEL',
2853
+ severity: "error",
2316
2854
  stage: "parse",
2317
2855
  requiresLLM: true
2318
2856
  });
2319
2857
  }
2320
- if (expression.includes("||")) {
2858
+ if (structural.includes("||")) {
2321
2859
  errors.push({
2322
2860
  code: "PARSE-JS_LOGIC",
2323
2861
  message: 'JavaScript || detected, use "or" in SpEL',
2862
+ severity: "error",
2324
2863
  stage: "parse",
2325
2864
  requiresLLM: true
2326
2865
  });
@@ -2333,6 +2872,7 @@ var ValidationPipeline = class {
2333
2872
  errors.push({
2334
2873
  code: `PARSE-${pe.code ?? "SYNTAX"}`,
2335
2874
  message: pe.message,
2875
+ severity: "error",
2336
2876
  position: pe.position,
2337
2877
  stage: "parse",
2338
2878
  requiresLLM: true
@@ -2343,6 +2883,7 @@ var ValidationPipeline = class {
2343
2883
  errors.push({
2344
2884
  code: "PARSE-EXCEPTION",
2345
2885
  message: `Parse threw exception: ${err.message}`,
2886
+ severity: "error",
2346
2887
  stage: "parse",
2347
2888
  requiresLLM: true
2348
2889
  });
@@ -2355,27 +2896,43 @@ var ValidationPipeline = class {
2355
2896
  };
2356
2897
  }
2357
2898
  /**
2358
- * Stage 2: Type Check — type validation
2899
+ * Stage 2: Type Check — advisory type validation
2359
2900
  */
2360
2901
  validateTypes(expression, contextSchema) {
2361
2902
  const errors = [];
2362
2903
  const warnings = [];
2363
- const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+/;
2904
+ const structural = maskStringLiterals(expression);
2905
+ const strNumMismatch = /'(?:\\.|[^'\\])*'\s*(?:>|<|>=|<=)\s*\d+|\d+\s*(?:>|<|>=|<=)\s*'(?:\\.|[^'\\])*'/;
2364
2906
  if (strNumMismatch.test(expression)) {
2365
2907
  warnings.push({
2366
2908
  code: "TYPE-STR_NUM_CMP",
2367
2909
  message: "String literal compared with number using arithmetic operator",
2910
+ severity: "warning",
2368
2911
  stage: "type"
2369
2912
  });
2370
2913
  }
2371
2914
  if (contextSchema?.root) {
2915
+ const rootRef = escapeRegExp(contextSchema.root.name);
2372
2916
  for (const [fieldName, field] of Object.entries(contextSchema.root.fields)) {
2917
+ const fieldRef = `#(?:${rootRef}\\.)?${escapeRegExp(fieldName)}`;
2373
2918
  if (field.type === "boolean") {
2374
- const boolNumPattern = new RegExp(`#\\w+\\.${fieldName}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2375
- if (boolNumPattern.test(expression)) {
2919
+ const boolNumPattern = new RegExp(`${fieldRef}\\s*(?:>|<|>=|<=)\\s*\\d+`);
2920
+ if (boolNumPattern.test(structural)) {
2376
2921
  warnings.push({
2377
2922
  code: "TYPE-BOOL_NUM_CMP",
2378
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",
2379
2936
  stage: "type"
2380
2937
  });
2381
2938
  }
@@ -2389,23 +2946,26 @@ var ValidationPipeline = class {
2389
2946
  };
2390
2947
  }
2391
2948
  /**
2392
- * Stage 3: Semantic Check — semantic reasonableness validation
2949
+ * Stage 3: Semantic Check — advisory semantic validation
2393
2950
  */
2394
2951
  validateSemantic(expression) {
2395
2952
  const errors = [];
2396
2953
  const warnings = [];
2954
+ const structural = maskStringLiterals(expression);
2397
2955
  const selfCompare = /(#\w+(?:\.\w+)*)\s*==\s*\1/;
2398
- if (selfCompare.test(expression)) {
2956
+ if (selfCompare.test(structural)) {
2399
2957
  warnings.push({
2400
2958
  code: "SEM-SELF_COMPARE",
2401
2959
  message: "Self-comparison detected: expression is always true",
2960
+ severity: "warning",
2402
2961
  stage: "semantic"
2403
2962
  });
2404
2963
  }
2405
- if (expression.includes("!!")) {
2964
+ if (structural.includes("!!")) {
2406
2965
  warnings.push({
2407
2966
  code: "SEM-DOUBLE_NEGATION",
2408
2967
  message: "Double negation detected, consider simplifying",
2968
+ severity: "warning",
2409
2969
  stage: "semantic"
2410
2970
  });
2411
2971
  }
@@ -2417,59 +2977,72 @@ var ValidationPipeline = class {
2417
2977
  }
2418
2978
  /**
2419
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.
2420
2984
  */
2421
2985
  validateContext(expression, contextSchema) {
2422
2986
  const errors = [];
2423
2987
  const warnings = [];
2988
+ const structural = maskStringLiterals(expression);
2424
2989
  if (!contextSchema) {
2425
2990
  warnings.push({
2426
2991
  code: "CTX-NO_SCHEMA",
2427
2992
  message: "No ContextSchema provided, skipping context validation",
2993
+ severity: "warning",
2428
2994
  stage: "context"
2429
2995
  });
2430
2996
  return { passed: true, errors, warnings };
2431
2997
  }
2432
- const refs = this.extractReferences(expression);
2433
- if (contextSchema.root) {
2434
- 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) {
2435
3005
  for (const ref of refs) {
2436
- if (ref.startsWith(`#${rootName}`)) {
2437
- const parts = ref.split(".");
2438
- const field = parts[1];
2439
- if (field && !(field in contextSchema.root.fields)) {
2440
- warnings.push({
2441
- code: "CTX-UNKNOWN_FIELD",
2442
- message: `Field '${field}' not found in root '${rootName}'`,
2443
- stage: "context"
2444
- });
2445
- }
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
+ });
2446
3016
  }
2447
3017
  }
2448
3018
  }
2449
3019
  for (const ref of refs) {
2450
- if (ref.startsWith("#") && !ref.includes(".")) {
2451
- const varName = ref.slice(1);
2452
- const isRoot = contextSchema.root?.name === varName;
2453
- const isVariable = varName in contextSchema.variables;
2454
- const isFunction = varName in contextSchema.functions;
2455
- if (!isRoot && !isVariable && !isFunction) {
2456
- warnings.push({
2457
- code: "CTX-UNKNOWN_REF",
2458
- message: `Unknown reference '${ref}'`,
2459
- stage: "context"
2460
- });
2461
- }
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
+ });
2462
3033
  }
2463
3034
  }
2464
- const beanMatch = expression.match(/@(\w+)/g);
2465
- if (beanMatch && contextSchema.beans) {
3035
+ const beanMatch = structural.match(/@(\w+)/g);
3036
+ if (beanMatch) {
2466
3037
  for (const b of beanMatch) {
2467
3038
  const beanName = b.slice(1);
2468
- if (!(beanName in contextSchema.beans)) {
2469
- warnings.push({
3039
+ if (!(beanName in beans)) {
3040
+ errors.push({
2470
3041
  code: "CTX-UNKNOWN_BEAN",
2471
3042
  message: `Bean '${beanName}' not found in ContextSchema`,
2472
- stage: "context"
3043
+ severity: "error",
3044
+ stage: "context",
3045
+ requiresLLM: true
2473
3046
  });
2474
3047
  }
2475
3048
  }
@@ -2481,7 +3054,62 @@ var ValidationPipeline = class {
2481
3054
  };
2482
3055
  }
2483
3056
  /**
2484
- * 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.
2485
3113
  */
2486
3114
  hasBalancedParentheses(expression) {
2487
3115
  const stack = [];
@@ -2497,17 +3125,29 @@ var ValidationPipeline = class {
2497
3125
  return stack.length === 0;
2498
3126
  }
2499
3127
  /**
2500
- * 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.
2501
3133
  */
2502
3134
  extractReferences(expression) {
2503
3135
  const refs = [];
3136
+ const dottedHeads = /* @__PURE__ */ new Set();
2504
3137
  const varMatch = expression.matchAll(/#(\w+(?:\.\w+(?:\.\w+)?)?)/g);
2505
3138
  for (const m of varMatch) {
2506
- 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
+ }
2507
3146
  }
2508
3147
  const simpleMatch = expression.matchAll(/#(\w+)(?!\w*\()/g);
2509
3148
  for (const m of simpleMatch) {
2510
3149
  const ref = `#${m[1]}`;
3150
+ if (dottedHeads.has(ref)) continue;
2511
3151
  if (!refs.includes(ref)) {
2512
3152
  refs.push(ref);
2513
3153
  }
@@ -2515,98 +3155,68 @@ var ValidationPipeline = class {
2515
3155
  return refs;
2516
3156
  }
2517
3157
  };
2518
-
2519
- // src/validation/auto-fixer.ts
2520
- var AutoFixer = class {
2521
- fix(expression) {
2522
- let fixed = expression;
2523
- const changes = [];
2524
- if (fixed.includes("=== undefined")) {
2525
- fixed = fixed.replace(/=== undefined/g, "== null");
2526
- changes.push("Replaced === undefined with == null");
2527
- }
2528
- if (fixed.includes("!== undefined")) {
2529
- fixed = fixed.replace(/!== undefined/g, "!= null");
2530
- changes.push("Replaced !== undefined with != null");
2531
- }
2532
- if (fixed.includes("!==")) {
2533
- const count = fixed.match(/!==/g).length;
2534
- fixed = fixed.replace(/!==/g, "!=");
2535
- changes.push(`Replaced ${count}x !== with !=`);
2536
- }
2537
- if (fixed.includes("===")) {
2538
- const count = fixed.match(/===/g).length;
2539
- fixed = fixed.replace(/===/g, "==");
2540
- changes.push(`Replaced ${count}x === with ==`);
2541
- }
2542
- if (fixed.includes("&&")) {
2543
- const count = fixed.match(/&&/g).length;
2544
- fixed = fixed.replace(/&&/g, "and");
2545
- changes.push(`Replaced ${count}x && with and`);
2546
- }
2547
- if (fixed.includes("||")) {
2548
- const count = fixed.match(/\|\|/g).length;
2549
- fixed = fixed.replace(/\|\|/g, "or");
2550
- changes.push(`Replaced ${count}x || with or`);
2551
- }
2552
- const singleQuoteCount = (fixed.match(/'/g) ?? []).length;
2553
- if (singleQuoteCount % 2 !== 0) {
2554
- if (!fixed.endsWith("'")) {
2555
- fixed = fixed + "'";
2556
- changes.push("Added missing closing single quote");
2557
- }
2558
- }
2559
- const doubleQuoteCount = (fixed.match(/"/g) ?? []).length;
2560
- if (doubleQuoteCount % 2 !== 0) {
2561
- if (!fixed.endsWith('"')) {
2562
- fixed = fixed + '"';
2563
- changes.push("Added missing closing double quote");
2564
- }
2565
- }
2566
- const parenFix = this.fixAllBrackets(fixed);
2567
- if (parenFix !== fixed) {
2568
- changes.push("Fixed unbalanced brackets");
2569
- fixed = parenFix;
2570
- }
2571
- if (fixed.includes("> ==")) {
2572
- fixed = fixed.replace(/> ==/g, ">=");
2573
- changes.push("Replaced > == with >=");
2574
- }
2575
- if (fixed.includes("< ==")) {
2576
- fixed = fixed.replace(/< ==/g, "<=");
2577
- changes.push("Replaced < == with <=");
2578
- }
2579
- if (fixed.includes("? :")) {
2580
- fixed = fixed.replace(/\?\s*:\s*/g, " ?: ");
2581
- changes.push("Fixed Elvis operator spacing");
2582
- }
2583
- const wasFixed = changes.length > 0;
2584
- return {
2585
- wasFixed,
2586
- expression: wasFixed ? fixed : expression,
2587
- changes
2588
- };
2589
- }
2590
- fixAllBrackets(expr) {
2591
- let result = expr;
2592
- const openParen = (result.match(/\(/g) ?? []).length;
2593
- const closeParen = (result.match(/\)/g) ?? []).length;
2594
- if (openParen > closeParen) {
2595
- result += ")".repeat(openParen - closeParen);
2596
- }
2597
- const openBracket = (result.match(/\[/g) ?? []).length;
2598
- const closeBracket = (result.match(/\]/g) ?? []).length;
2599
- if (openBracket > closeBracket) {
2600
- result += "]".repeat(openBracket - closeBracket);
2601
- }
2602
- const openBrace = (result.match(/\{/g) ?? []).length;
2603
- const closeBrace = (result.match(/\}/g) ?? []).length;
2604
- if (openBrace > closeBrace) {
2605
- result += "}".repeat(openBrace - closeBrace);
2606
- }
2607
- return result;
2608
- }
2609
- };
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
+ }
2610
3220
 
2611
3221
  // src/validation/self-correction-loop.ts
2612
3222
  var SelfCorrectionLoop = class {
@@ -2764,20 +3374,23 @@ var StrategyRouter = class {
2764
3374
  if (contextSchema) {
2765
3375
  this.templateEngine.setContext(contextSchema);
2766
3376
  }
2767
- const patternResult = this.patternMatcher.match(nl);
2768
- if (patternResult.matched && patternResult.confidence >= this.config.patternMinConfidence) {
2769
- 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);
2770
3383
  if (validation.valid) {
2771
3384
  return {
2772
- expression: patternResult.spel,
3385
+ expression: wholeMatch.spel,
2773
3386
  strategy: "pattern",
2774
- confidence: patternResult.confidence,
2775
- metadata: { patternId: patternResult.pattern?.id },
3387
+ confidence: wholeMatch.confidence,
3388
+ metadata: { patternId: wholeMatch.pattern?.id },
2776
3389
  latencyMs: Date.now() - startTime
2777
3390
  };
2778
3391
  }
2779
3392
  try {
2780
- const afResult = this.autoFixer.fix(patternResult.spel);
3393
+ const afResult = this.autoFixer.fix(wholeMatch.spel);
2781
3394
  if (afResult.wasFixed) {
2782
3395
  const afValidation = await this.validationPipeline.validate(
2783
3396
  afResult.expression,
@@ -2787,8 +3400,8 @@ var StrategyRouter = class {
2787
3400
  return {
2788
3401
  expression: afResult.expression,
2789
3402
  strategy: "pattern",
2790
- confidence: patternResult.confidence * 0.95,
2791
- metadata: { patternId: patternResult.pattern?.id },
3403
+ confidence: wholeMatch.confidence * 0.95,
3404
+ metadata: { patternId: wholeMatch.pattern?.id },
2792
3405
  latencyMs: Date.now() - startTime
2793
3406
  };
2794
3407
  }
@@ -2796,6 +3409,30 @@ var StrategyRouter = class {
2796
3409
  } catch {
2797
3410
  }
2798
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
+ }
2799
3436
  const intentResult = this.intentClassifier.classify(nl);
2800
3437
  let templateResult = null;
2801
3438
  try {
@@ -2829,7 +3466,7 @@ var StrategyRouter = class {
2829
3466
  }
2830
3467
  }
2831
3468
  if (providers.length === 0) {
2832
- throw new Error("No LLM providers available");
3469
+ throw clauseFailure ?? new Error("No LLM providers available");
2833
3470
  }
2834
3471
  let lastError = null;
2835
3472
  for (const provider of providers) {
@@ -2875,6 +3512,33 @@ var StrategyRouter = class {
2875
3512
  /**
2876
3513
  * Get PatternMatcher (for external testing/debugging)
2877
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
+ }
2878
3542
  getPatternMatcher() {
2879
3543
  return this.patternMatcher;
2880
3544
  }
@@ -2958,6 +3622,19 @@ var NL2SpelEngine = class {
2958
3622
  if (options.offlineOnly) {
2959
3623
  const patternMatcher = this.router.getPatternMatcher();
2960
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
+ }
2961
3638
  if (patternResult.matched) {
2962
3639
  return {
2963
3640
  expression: patternResult.spel,
@@ -3036,5 +3713,9 @@ var NL2SpelEngine = class {
3036
3713
  SelfCorrectionLoop,
3037
3714
  StrategyRouter,
3038
3715
  TemplateEngine,
3039
- ValidationPipeline
3716
+ UnconvertibleClauseError,
3717
+ UnmappedFieldError,
3718
+ ValidationPipeline,
3719
+ decompose,
3720
+ splitClauses
3040
3721
  });