@retrivora-ai/rag-engine 2.0.1 → 2.0.3
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/FREE_TIER_ARCHITECTURE.md +232 -0
- package/README.md +9 -0
- package/dist/{ILLMProvider-DMxLyTdq.d.mts → ILLMProvider-0rRBYbVW.d.mts} +133 -1
- package/dist/{ILLMProvider-DMxLyTdq.d.ts → ILLMProvider-0rRBYbVW.d.ts} +133 -1
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +679 -15
- package/dist/handlers/index.mjs +679 -15
- package/dist/index-B1wGUlSL.d.mts +532 -0
- package/dist/{index-tzwc7UhY.d.ts → index-BIHHp_f6.d.ts} +1 -1
- package/dist/{index-DHsFLJXQ.d.mts → index-BvODr57d.d.mts} +1 -1
- package/dist/index-DcklhThn.d.ts +532 -0
- package/dist/index.css +51 -3
- package/dist/index.d.mts +6 -105
- package/dist/index.d.ts +6 -105
- package/dist/index.js +1918 -8
- package/dist/index.mjs +1913 -8
- package/dist/server.d.mts +51 -7
- package/dist/server.d.ts +51 -7
- package/dist/server.js +721 -15
- package/dist/server.mjs +704 -15
- package/package.json +9 -7
- package/src/app/page.tsx +54 -18
- package/src/components/ChatWindow.tsx +2 -2
- package/src/components/DocumentUpload.tsx +5 -4
- package/src/components/MessageBubble.tsx +2 -4
- package/src/core/Pipeline.ts +23 -5
- package/src/handlers/index.ts +48 -1
- package/src/index.ts +22 -4
- package/src/rendering/IntentClassifier.ts +167 -0
- package/src/rendering/RendererRegistry.ts +164 -0
- package/src/rendering/RuleEngine.ts +67 -0
- package/src/rendering/VisualizationDecisionEngine.ts +164 -0
- package/src/rendering/__tests__/VisualizationDecisionEngine.test.ts +237 -0
- package/src/rendering/index.ts +12 -0
- package/src/rendering/rules/Rule1SpecificInfoRule.ts +28 -0
- package/src/rendering/rules/Rule2ComparisonRule.ts +24 -0
- package/src/rendering/rules/Rule3ProductDiscoveryRule.ts +28 -0
- package/src/rendering/rules/Rule4AnalyticalRule.ts +38 -0
- package/src/rendering/rules/Rule5MixedResponseRule.ts +29 -0
- package/src/rendering/rules/Rule6SmallResultSetRule.ts +24 -0
- package/src/rendering/rules/Rule7LargeTableRule.ts +35 -0
- package/src/rendering/types.ts +96 -0
- package/src/server.ts +3 -2
- package/src/types/index.ts +48 -0
- package/src/utils/DocumentParser.ts +64 -28
- package/src/utils/UITransformer.ts +15 -4
- package/dist/index-CfkqZd2Y.d.ts +0 -197
- package/dist/index-xygonxpW.d.mts +0 -197
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { IRendererStrategy, RenderType } from './types';
|
|
2
|
+
import { UITransformationResponse, VectorMatch } from '../types';
|
|
3
|
+
import { UITransformer } from '../utils/UITransformer';
|
|
4
|
+
|
|
5
|
+
// ── Default Strategy Implementations ───────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export class TextRendererStrategy implements IRendererStrategy {
|
|
8
|
+
readonly type = 'text';
|
|
9
|
+
|
|
10
|
+
render(data: unknown, options?: Record<string, unknown>): UITransformationResponse {
|
|
11
|
+
const title = (options?.title as string) || 'Information';
|
|
12
|
+
if (typeof data === 'string') {
|
|
13
|
+
return { type: 'text', title, data: { content: data } };
|
|
14
|
+
}
|
|
15
|
+
const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
|
|
16
|
+
const text = docs.map(d => d.content).join('\n\n');
|
|
17
|
+
return { type: 'text', title, data: { content: text || 'No detailed content available.' } };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TableRendererStrategy implements IRendererStrategy {
|
|
22
|
+
readonly type = 'table';
|
|
23
|
+
|
|
24
|
+
render(data: unknown, options?: Record<string, unknown>): UITransformationResponse {
|
|
25
|
+
const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
|
|
26
|
+
const query = (options?.query as string) || '';
|
|
27
|
+
return UITransformer.transform(query, docs, undefined, undefined, {
|
|
28
|
+
visualizationHint: 'table',
|
|
29
|
+
recommendedChart: 'table',
|
|
30
|
+
filterInStockOnly: false,
|
|
31
|
+
wantsExplicitTable: true,
|
|
32
|
+
isTemporal: false,
|
|
33
|
+
isComparison: true,
|
|
34
|
+
language: 'en',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class CarouselRendererStrategy implements IRendererStrategy {
|
|
40
|
+
readonly type = 'carousel';
|
|
41
|
+
|
|
42
|
+
render(data: unknown, options?: Record<string, unknown>): UITransformationResponse {
|
|
43
|
+
const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
|
|
44
|
+
const query = (options?.query as string) || '';
|
|
45
|
+
return UITransformer.transform(query, docs, undefined, undefined, {
|
|
46
|
+
visualizationHint: 'product_browse',
|
|
47
|
+
recommendedChart: 'text',
|
|
48
|
+
filterInStockOnly: false,
|
|
49
|
+
wantsExplicitTable: false,
|
|
50
|
+
isTemporal: false,
|
|
51
|
+
isComparison: false,
|
|
52
|
+
language: 'en',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ChartRendererStrategy implements IRendererStrategy {
|
|
58
|
+
readonly type = 'chart';
|
|
59
|
+
|
|
60
|
+
render(data: unknown, options?: Record<string, unknown>): UITransformationResponse {
|
|
61
|
+
const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
|
|
62
|
+
const query = (options?.query as string) || '';
|
|
63
|
+
const chartType = (options?.chartType as string) || 'bar';
|
|
64
|
+
|
|
65
|
+
let hint: any = 'comparison';
|
|
66
|
+
let rec: any = 'bar_chart';
|
|
67
|
+
|
|
68
|
+
if (chartType === 'line') {
|
|
69
|
+
hint = 'trend';
|
|
70
|
+
rec = 'line_chart';
|
|
71
|
+
} else if (chartType === 'pie') {
|
|
72
|
+
hint = 'composition';
|
|
73
|
+
rec = 'pie_chart';
|
|
74
|
+
} else if (chartType === 'scatter') {
|
|
75
|
+
hint = 'correlation';
|
|
76
|
+
rec = 'scatter_plot';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return UITransformer.transform(query, docs, undefined, undefined, {
|
|
80
|
+
visualizationHint: hint,
|
|
81
|
+
recommendedChart: rec,
|
|
82
|
+
filterInStockOnly: false,
|
|
83
|
+
wantsExplicitTable: false,
|
|
84
|
+
isTemporal: chartType === 'line',
|
|
85
|
+
isComparison: chartType === 'bar',
|
|
86
|
+
language: 'en',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class MixedRendererStrategy implements IRendererStrategy {
|
|
92
|
+
readonly type = 'mixed';
|
|
93
|
+
|
|
94
|
+
render(data: unknown, options?: Record<string, unknown>): UITransformationResponse {
|
|
95
|
+
const docs = Array.isArray(data) ? (data as VectorMatch[]) : [];
|
|
96
|
+
const sections = (options?.sections as Array<{ type: RenderType; chartType?: string; title?: string }>) || [];
|
|
97
|
+
|
|
98
|
+
const sectionPayloads = sections.map(s => {
|
|
99
|
+
const strategy = RendererRegistry.getStrategy(s.type);
|
|
100
|
+
return {
|
|
101
|
+
type: s.type,
|
|
102
|
+
title: s.title,
|
|
103
|
+
payload: strategy.render(data, { ...options, chartType: s.chartType, title: s.title }),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
type: 'table', // Fallback container type for compatibility
|
|
109
|
+
title: (options?.title as string) || 'Composite Response',
|
|
110
|
+
description: `Mixed payload containing ${sectionPayloads.length} visual sections`,
|
|
111
|
+
data: { sections: sectionPayloads },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Renderer Registry Class ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export class RendererRegistry {
|
|
119
|
+
private static strategies = new Map<string, IRendererStrategy>();
|
|
120
|
+
|
|
121
|
+
static {
|
|
122
|
+
// Register default strategies
|
|
123
|
+
this.registerStrategy(new TextRendererStrategy());
|
|
124
|
+
this.registerStrategy(new TableRendererStrategy());
|
|
125
|
+
this.registerStrategy(new CarouselRendererStrategy());
|
|
126
|
+
this.registerStrategy(new ChartRendererStrategy());
|
|
127
|
+
this.registerStrategy(new MixedRendererStrategy());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register a new renderer strategy.
|
|
132
|
+
* Enables seamless addition of custom visualizers (Timeline, Maps, Flow Diagrams, etc.).
|
|
133
|
+
*/
|
|
134
|
+
static registerStrategy(strategy: IRendererStrategy): void {
|
|
135
|
+
this.strategies.set(String(strategy.type).toLowerCase(), strategy);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Retrieve a registered renderer strategy by type string.
|
|
140
|
+
*/
|
|
141
|
+
static getStrategy(type: string): IRendererStrategy {
|
|
142
|
+
const key = type.toLowerCase();
|
|
143
|
+
const strategy = this.strategies.get(key);
|
|
144
|
+
if (!strategy) {
|
|
145
|
+
// Fallback to text strategy if unknown type requested
|
|
146
|
+
return this.strategies.get('text')!;
|
|
147
|
+
}
|
|
148
|
+
return strategy;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a renderer strategy is registered.
|
|
153
|
+
*/
|
|
154
|
+
static hasStrategy(type: string): boolean {
|
|
155
|
+
return this.strategies.has(type.toLowerCase());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List all registered strategy types.
|
|
160
|
+
*/
|
|
161
|
+
static getRegisteredTypes(): string[] {
|
|
162
|
+
return Array.from(this.strategies.keys());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DecisionContext, IntentCategory, IRenderRule, RenderDecision } from './types';
|
|
2
|
+
import { Rule1SpecificInfoRule } from './rules/Rule1SpecificInfoRule';
|
|
3
|
+
import { Rule2ComparisonRule } from './rules/Rule2ComparisonRule';
|
|
4
|
+
import { Rule3ProductDiscoveryRule } from './rules/Rule3ProductDiscoveryRule';
|
|
5
|
+
import { Rule4AnalyticalRule } from './rules/Rule4AnalyticalRule';
|
|
6
|
+
import { Rule5MixedResponseRule } from './rules/Rule5MixedResponseRule';
|
|
7
|
+
import { Rule6SmallResultSetRule } from './rules/Rule6SmallResultSetRule';
|
|
8
|
+
import { Rule7LargeTableRule } from './rules/Rule7LargeTableRule';
|
|
9
|
+
|
|
10
|
+
export class RuleEngine {
|
|
11
|
+
private rules: IRenderRule[] = [];
|
|
12
|
+
|
|
13
|
+
constructor() {
|
|
14
|
+
this.registerDefaultRules();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register standard rules in priority order.
|
|
19
|
+
*/
|
|
20
|
+
private registerDefaultRules() {
|
|
21
|
+
this.registerRule(new Rule6SmallResultSetRule()); // Priority 110 (Small data guardrail)
|
|
22
|
+
this.registerRule(new Rule1SpecificInfoRule()); // Priority 100 (Fact/definition)
|
|
23
|
+
this.registerRule(new Rule5MixedResponseRule()); // Priority 95 (Multi-section)
|
|
24
|
+
this.registerRule(new Rule2ComparisonRule()); // Priority 90 (Comparison)
|
|
25
|
+
this.registerRule(new Rule3ProductDiscoveryRule()); // Priority 85 (Product/carousel)
|
|
26
|
+
this.registerRule(new Rule4AnalyticalRule()); // Priority 80 (Charts)
|
|
27
|
+
this.registerRule(new Rule7LargeTableRule()); // Priority 75 (Large datasets > 5)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Add or replace a rule in the engine.
|
|
32
|
+
* Automatically keeps rules sorted by descending priority.
|
|
33
|
+
*/
|
|
34
|
+
registerRule(rule: IRenderRule): void {
|
|
35
|
+
// Remove existing rule with same name if present
|
|
36
|
+
this.rules = this.rules.filter(r => r.name !== rule.name);
|
|
37
|
+
this.rules.push(rule);
|
|
38
|
+
this.rules.sort((a, b) => b.priority - a.priority);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Evaluate context against rules in descending priority order.
|
|
43
|
+
* Returns the first non-null RenderDecision.
|
|
44
|
+
*/
|
|
45
|
+
evaluate(context: DecisionContext, intent: IntentCategory): RenderDecision {
|
|
46
|
+
for (const rule of this.rules) {
|
|
47
|
+
const decision = rule.evaluate(context, intent);
|
|
48
|
+
if (decision) {
|
|
49
|
+
return decision;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Default fallback
|
|
54
|
+
return {
|
|
55
|
+
renderType: 'text',
|
|
56
|
+
confidence: 0.70,
|
|
57
|
+
reason: 'Default fallback: No specific visualization rule matched context.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* List all registered rules.
|
|
63
|
+
*/
|
|
64
|
+
getRegisteredRules(): Array<{ name: string; priority: number }> {
|
|
65
|
+
return this.rules.map(r => ({ name: r.name, priority: r.priority }));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { DecisionContext, RenderDecision } from './types';
|
|
2
|
+
import { IntentClassifier } from './IntentClassifier';
|
|
3
|
+
import { RuleEngine } from './RuleEngine';
|
|
4
|
+
import { RendererRegistry } from './RendererRegistry';
|
|
5
|
+
import { UITransformationResponse, VectorMatch } from '../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LRU Cache for visualization decisions to achieve sub-millisecond repeated execution.
|
|
9
|
+
*/
|
|
10
|
+
class LRUDecisionCache {
|
|
11
|
+
private cache = new Map<string, { decision: RenderDecision; timestamp: number }>();
|
|
12
|
+
private maxSize: number;
|
|
13
|
+
|
|
14
|
+
constructor(maxSize = 200) {
|
|
15
|
+
this.maxSize = maxSize;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private generateKey(context: DecisionContext): string {
|
|
19
|
+
const q = (context.userQuery || '').toLowerCase().trim();
|
|
20
|
+
const docCount = context.retrievedDocuments?.length ?? 0;
|
|
21
|
+
return `${q}::docs:${docCount}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(context: DecisionContext): RenderDecision | undefined {
|
|
25
|
+
const key = this.generateKey(context);
|
|
26
|
+
const item = this.cache.get(key);
|
|
27
|
+
if (item) {
|
|
28
|
+
// Refresh LRU position
|
|
29
|
+
this.cache.delete(key);
|
|
30
|
+
this.cache.set(key, item);
|
|
31
|
+
return item.decision;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set(context: DecisionContext, decision: RenderDecision): void {
|
|
37
|
+
const key = this.generateKey(context);
|
|
38
|
+
if (this.cache.has(key)) {
|
|
39
|
+
this.cache.delete(key);
|
|
40
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
41
|
+
const oldest = this.cache.keys().next().value;
|
|
42
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
43
|
+
}
|
|
44
|
+
this.cache.set(key, { decision, timestamp: Date.now() });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clear(): void {
|
|
48
|
+
this.cache.clear();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get size(): number {
|
|
52
|
+
return this.cache.size;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* High-Performance Visualization Decision Engine.
|
|
58
|
+
* Intelligently determines whether to render text, table, carousel, chart, or mixed sections
|
|
59
|
+
* BEFORE expensive visual generation or LLM visual calls execute.
|
|
60
|
+
*/
|
|
61
|
+
export class VisualizationDecisionEngine {
|
|
62
|
+
private static ruleEngine = new RuleEngine();
|
|
63
|
+
private static cache = new LRUDecisionCache(200);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Evaluate user query, documents, and context to produce a RenderDecision.
|
|
67
|
+
* Execution time is < 1ms for cached or rule-matched queries.
|
|
68
|
+
*/
|
|
69
|
+
static decideVisualization(
|
|
70
|
+
userQuery: string,
|
|
71
|
+
retrievedDocuments?: VectorMatch[],
|
|
72
|
+
llmResponse?: string,
|
|
73
|
+
metadata?: Record<string, unknown>
|
|
74
|
+
): RenderDecision {
|
|
75
|
+
const context: DecisionContext = {
|
|
76
|
+
userQuery,
|
|
77
|
+
retrievedDocuments,
|
|
78
|
+
llmResponse,
|
|
79
|
+
metadata,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Check LRU cache first
|
|
83
|
+
const cached = this.cache.get(context);
|
|
84
|
+
if (cached) {
|
|
85
|
+
return cached;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Step 1: Zero-latency Intent Classification
|
|
89
|
+
const { intent } = IntentClassifier.classify(context);
|
|
90
|
+
|
|
91
|
+
// Step 2: Evaluate priority rules
|
|
92
|
+
const decision = this.ruleEngine.evaluate(context, intent);
|
|
93
|
+
|
|
94
|
+
// Cache result
|
|
95
|
+
this.cache.set(context, decision);
|
|
96
|
+
|
|
97
|
+
return decision;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convenience helper to decide AND render the data into a UITransformationResponse.
|
|
102
|
+
*/
|
|
103
|
+
static render(
|
|
104
|
+
userQuery: string,
|
|
105
|
+
retrievedDocuments?: VectorMatch[],
|
|
106
|
+
llmResponse?: string,
|
|
107
|
+
metadata?: Record<string, unknown>
|
|
108
|
+
): UITransformationResponse {
|
|
109
|
+
const decision = this.decideVisualization(userQuery, retrievedDocuments, llmResponse, metadata);
|
|
110
|
+
const strategy = RendererRegistry.getStrategy(decision.renderType);
|
|
111
|
+
return strategy.render(retrievedDocuments || [], {
|
|
112
|
+
query: userQuery,
|
|
113
|
+
chartType: decision.chartType,
|
|
114
|
+
sections: decision.sections,
|
|
115
|
+
...metadata,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Register a custom rule in the rule engine.
|
|
121
|
+
*/
|
|
122
|
+
static registerRule(rule: import('./types').IRenderRule): void {
|
|
123
|
+
this.ruleEngine.registerRule(rule);
|
|
124
|
+
this.cache.clear(); // Clear cache when rules change
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Register a custom renderer strategy.
|
|
129
|
+
*/
|
|
130
|
+
static registerRendererStrategy(strategy: import('./types').IRendererStrategy): void {
|
|
131
|
+
RendererRegistry.registerStrategy(strategy);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Clear the decision cache.
|
|
136
|
+
*/
|
|
137
|
+
static clearCache(): void {
|
|
138
|
+
this.cache.clear();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get size of current decision cache.
|
|
143
|
+
*/
|
|
144
|
+
static getCacheSize(): number {
|
|
145
|
+
return this.cache.size;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Top-level function export matching prompt specification.
|
|
151
|
+
*/
|
|
152
|
+
export function decideVisualization(
|
|
153
|
+
userQuery: string,
|
|
154
|
+
retrievedDocuments?: VectorMatch[],
|
|
155
|
+
llmResponse?: string,
|
|
156
|
+
metadata?: Record<string, unknown>
|
|
157
|
+
): RenderDecision {
|
|
158
|
+
return VisualizationDecisionEngine.decideVisualization(
|
|
159
|
+
userQuery,
|
|
160
|
+
retrievedDocuments,
|
|
161
|
+
llmResponse,
|
|
162
|
+
metadata
|
|
163
|
+
);
|
|
164
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { VisualizationDecisionEngine, decideVisualization } from '../VisualizationDecisionEngine';
|
|
2
|
+
import { RendererRegistry } from '../RendererRegistry';
|
|
3
|
+
import { IRendererStrategy, RenderDecision } from '../types';
|
|
4
|
+
import { VectorMatch, UITransformationResponse } from '../../types';
|
|
5
|
+
|
|
6
|
+
describe('VisualizationDecisionEngine Specification Tests', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
VisualizationDecisionEngine.clearCache();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Mock retrieved documents helper
|
|
12
|
+
const createMockDocs = (count: number, withNumeric = false): VectorMatch[] => {
|
|
13
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
14
|
+
id: `doc-${i + 1}`,
|
|
15
|
+
score: 0.95 - i * 0.05,
|
|
16
|
+
content: `Item ${i + 1} content details`,
|
|
17
|
+
metadata: {
|
|
18
|
+
title: `Product ${i + 1}`,
|
|
19
|
+
price: withNumeric ? 100 + i * 50 : undefined,
|
|
20
|
+
sales: withNumeric ? 500 - i * 30 : undefined,
|
|
21
|
+
category: `Category ${ (i % 3) + 1 }`,
|
|
22
|
+
date: `2026-0${ (i % 9) + 1 }-01`,
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ── Rule 1: Specific Information Requests ──────────────────────────────────
|
|
28
|
+
describe('Rule 1: Specific Information Requests', () => {
|
|
29
|
+
|
|
30
|
+
test('should render text for "What is Pinecone?"', () => {
|
|
31
|
+
const decision = decideVisualization('What is Pinecone?', createMockDocs(4));
|
|
32
|
+
expect(decision.renderType).toBe('text');
|
|
33
|
+
expect(decision.confidence).toBeGreaterThanOrEqual(0.9);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('should render text for "What is the price of iPhone 16?"', () => {
|
|
37
|
+
const decision = decideVisualization('What is the price of iPhone 16?', createMockDocs(4, true));
|
|
38
|
+
expect(decision.renderType).toBe('text');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('should render text for "Explain RAG."', () => {
|
|
42
|
+
const decision = decideVisualization('Explain RAG.', createMockDocs(3));
|
|
43
|
+
expect(decision.renderType).toBe('text');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('should render text for "Who is the CEO of OpenAI?"', () => {
|
|
47
|
+
const decision = decideVisualization('Who is the CEO of OpenAI?', createMockDocs(4));
|
|
48
|
+
expect(decision.renderType).toBe('text');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('should render text for "What is namespace in Pinecone?"', () => {
|
|
52
|
+
const decision = decideVisualization('What is namespace in Pinecone?', createMockDocs(5));
|
|
53
|
+
expect(decision.renderType).toBe('text');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Rule 2: Comparison Queries ─────────────────────────────────────────────
|
|
58
|
+
describe('Rule 2: Comparison Queries', () => {
|
|
59
|
+
|
|
60
|
+
test('should render table for "Compare GPT-4 vs Claude"', () => {
|
|
61
|
+
const decision = decideVisualization('Compare GPT-4 vs Claude', createMockDocs(4));
|
|
62
|
+
expect(decision.renderType).toBe('table');
|
|
63
|
+
expect(decision.confidence).toBeGreaterThanOrEqual(0.85);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('should render table for "Compare iPhone vs Pixel"', () => {
|
|
67
|
+
const decision = decideVisualization('Compare iPhone vs Pixel', createMockDocs(4));
|
|
68
|
+
expect(decision.renderType).toBe('table');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('should render table for "Difference between React and Vue"', () => {
|
|
72
|
+
const decision = decideVisualization('Difference between React and Vue', createMockDocs(4));
|
|
73
|
+
expect(decision.renderType).toBe('table');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── Rule 3: Product Discovery ──────────────────────────────────────────────
|
|
78
|
+
describe('Rule 3: Product Discovery', () => {
|
|
79
|
+
|
|
80
|
+
test('should render carousel for "Best gaming laptops"', () => {
|
|
81
|
+
const decision = decideVisualization('Best gaming laptops', createMockDocs(4, true));
|
|
82
|
+
expect(decision.renderType).toBe('carousel');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('should render carousel for "Recommend office chairs"', () => {
|
|
86
|
+
const decision = decideVisualization('Recommend office chairs', createMockDocs(5, true));
|
|
87
|
+
expect(decision.renderType).toBe('carousel');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('should render carousel for "Best headphones under $200"', () => {
|
|
91
|
+
const decision = decideVisualization('Best headphones under $200', createMockDocs(4, true));
|
|
92
|
+
expect(decision.renderType).toBe('carousel');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('should render carousel for "Show available Nike shoes"', () => {
|
|
96
|
+
const decision = decideVisualization('Show available Nike shoes', createMockDocs(5, true));
|
|
97
|
+
expect(decision.renderType).toBe('carousel');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── Rule 4: Analytical Queries & Chart Types ───────────────────────────────
|
|
102
|
+
describe('Rule 4: Analytical Queries', () => {
|
|
103
|
+
|
|
104
|
+
test('should render line chart for "Monthly sales"', () => {
|
|
105
|
+
const decision = decideVisualization('Monthly sales', createMockDocs(5, true));
|
|
106
|
+
expect(decision.renderType).toBe('chart');
|
|
107
|
+
expect(decision.chartType).toBe('line');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('should render line chart for "Revenue growth"', () => {
|
|
111
|
+
const decision = decideVisualization('Revenue growth', createMockDocs(5, true));
|
|
112
|
+
expect(decision.renderType).toBe('chart');
|
|
113
|
+
expect(decision.chartType).toBe('line');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('should render bar chart for "Top performing products"', () => {
|
|
117
|
+
const decision = decideVisualization('Top performing products', createMockDocs(4, true));
|
|
118
|
+
expect(decision.renderType).toBe('chart');
|
|
119
|
+
expect(decision.chartType).toBe('bar');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('should render line chart for "Population trends"', () => {
|
|
123
|
+
const decision = decideVisualization('Population trends', createMockDocs(5, true));
|
|
124
|
+
expect(decision.renderType).toBe('chart');
|
|
125
|
+
expect(decision.chartType).toBe('line');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('should render bar chart for "Sales by region"', () => {
|
|
129
|
+
const decision = decideVisualization('Sales by region', createMockDocs(4, true));
|
|
130
|
+
expect(decision.renderType).toBe('chart');
|
|
131
|
+
expect(decision.chartType).toBe('bar');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('should render pie chart for "Percentage breakdown of categories"', () => {
|
|
135
|
+
const decision = decideVisualization('Percentage breakdown of categories', createMockDocs(4, true));
|
|
136
|
+
expect(decision.renderType).toBe('chart');
|
|
137
|
+
expect(decision.chartType).toBe('pie');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('should render scatter chart for "Correlation between price and rating"', () => {
|
|
141
|
+
const decision = decideVisualization('Correlation between price and rating', createMockDocs(4, true));
|
|
142
|
+
expect(decision.renderType).toBe('chart');
|
|
143
|
+
expect(decision.chartType).toBe('scatter');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Rule 5: Mixed Responses ────────────────────────────────────────────────
|
|
148
|
+
describe('Rule 5: Mixed Responses', () => {
|
|
149
|
+
|
|
150
|
+
test('should render mixed sections for "Show top selling products this month"', () => {
|
|
151
|
+
const decision = decideVisualization('Show top selling products this month.', createMockDocs(5, true));
|
|
152
|
+
expect(decision.renderType).toBe('mixed');
|
|
153
|
+
expect(decision.sections).toBeDefined();
|
|
154
|
+
expect(decision.sections?.length).toBe(3);
|
|
155
|
+
|
|
156
|
+
const sectionTypes = decision.sections?.map(s => s.type);
|
|
157
|
+
expect(sectionTypes).toEqual(['text', 'carousel', 'chart']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Rule 6: Small Result Sets (< 3 rows) ───────────────────────────────────
|
|
162
|
+
describe('Rule 6: Small Result Sets', () => {
|
|
163
|
+
|
|
164
|
+
test('should demote chart to text when result count is 2 (< 3 rows)', () => {
|
|
165
|
+
const decision = decideVisualization('Monthly sales', createMockDocs(2, true));
|
|
166
|
+
expect(decision.renderType).toBe('text'); // Demoted from chart to text
|
|
167
|
+
expect(decision.reason).toContain('Rule 6');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('should allow table for explicit comparison even with 2 rows', () => {
|
|
171
|
+
const decision = decideVisualization('Compare GPT-4 vs Claude', createMockDocs(2));
|
|
172
|
+
expect(decision.renderType).toBe('table');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── Rule 7: Large Tables (> 5 rows) ────────────────────────────────────────
|
|
177
|
+
describe('Rule 7: Large Tables', () => {
|
|
178
|
+
|
|
179
|
+
test('should render table for generic non-analytical large dataset (> 5 rows)', () => {
|
|
180
|
+
const decision = decideVisualization('List overall documents in system', createMockDocs(8));
|
|
181
|
+
expect(decision.renderType).toBe('table');
|
|
182
|
+
expect(decision.reason).toContain('Rule 7');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('should render mixed table+chart for large analytical dataset (> 5 rows)', () => {
|
|
186
|
+
const decision = decideVisualization('Regional sales statistics', createMockDocs(8, true));
|
|
187
|
+
expect(decision.renderType).toBe('mixed');
|
|
188
|
+
expect(decision.sections?.some(s => s.type === 'table')).toBe(true);
|
|
189
|
+
expect(decision.sections?.some(s => s.type === 'chart')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Strategy Registry & Extensibility ──────────────────────────────────────
|
|
194
|
+
describe('Extensibility & Strategy Registry', () => {
|
|
195
|
+
|
|
196
|
+
test('should support registering custom timeline renderer strategy', () => {
|
|
197
|
+
class TimelineRendererStrategy implements IRendererStrategy {
|
|
198
|
+
readonly type = 'timeline';
|
|
199
|
+
render(data: unknown): UITransformationResponse {
|
|
200
|
+
return {
|
|
201
|
+
type: 'text',
|
|
202
|
+
title: 'Timeline Events',
|
|
203
|
+
data: { content: 'Rendered timeline' },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
RendererRegistry.registerStrategy(new TimelineRendererStrategy());
|
|
209
|
+
expect(RendererRegistry.hasStrategy('timeline')).toBe(true);
|
|
210
|
+
|
|
211
|
+
const strategy = RendererRegistry.getStrategy('timeline');
|
|
212
|
+
const res = strategy.render(createMockDocs(3));
|
|
213
|
+
expect(res.title).toBe('Timeline Events');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Performance & LRU Decision Cache ──────────────────────────────────────
|
|
218
|
+
describe('Performance & Caching', () => {
|
|
219
|
+
|
|
220
|
+
test('should return cached decisions on repeated queries in < 1ms', () => {
|
|
221
|
+
const query = 'What is namespace in Pinecone?';
|
|
222
|
+
const docs = createMockDocs(4);
|
|
223
|
+
|
|
224
|
+
const t0 = performance.now();
|
|
225
|
+
const firstDecision = decideVisualization(query, docs);
|
|
226
|
+
const t1 = performance.now();
|
|
227
|
+
|
|
228
|
+
const t2 = performance.now();
|
|
229
|
+
const secondDecision = decideVisualization(query, docs);
|
|
230
|
+
const t3 = performance.now();
|
|
231
|
+
|
|
232
|
+
expect(firstDecision.renderType).toBe(secondDecision.renderType);
|
|
233
|
+
expect(t3 - t2).toBeLessThan(1.0); // Sub-millisecond execution
|
|
234
|
+
expect(VisualizationDecisionEngine.getCacheSize()).toBeGreaterThan(0);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export { IntentClassifier } from './IntentClassifier';
|
|
3
|
+
export { RuleEngine } from './RuleEngine';
|
|
4
|
+
export { RendererRegistry, TextRendererStrategy, TableRendererStrategy, CarouselRendererStrategy, ChartRendererStrategy, MixedRendererStrategy } from './RendererRegistry';
|
|
5
|
+
export { VisualizationDecisionEngine, decideVisualization } from './VisualizationDecisionEngine';
|
|
6
|
+
export { Rule1SpecificInfoRule } from './rules/Rule1SpecificInfoRule';
|
|
7
|
+
export { Rule2ComparisonRule } from './rules/Rule2ComparisonRule';
|
|
8
|
+
export { Rule3ProductDiscoveryRule } from './rules/Rule3ProductDiscoveryRule';
|
|
9
|
+
export { Rule4AnalyticalRule } from './rules/Rule4AnalyticalRule';
|
|
10
|
+
export { Rule5MixedResponseRule } from './rules/Rule5MixedResponseRule';
|
|
11
|
+
export { Rule6SmallResultSetRule } from './rules/Rule6SmallResultSetRule';
|
|
12
|
+
export { Rule7LargeTableRule } from './rules/Rule7LargeTableRule';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DecisionContext, IntentCategory, IRenderRule, RenderDecision } from '../types';
|
|
2
|
+
|
|
3
|
+
export class Rule1SpecificInfoRule implements IRenderRule {
|
|
4
|
+
readonly name = 'Rule1SpecificInfo';
|
|
5
|
+
readonly priority = 100;
|
|
6
|
+
|
|
7
|
+
evaluate(context: DecisionContext, intent: IntentCategory): RenderDecision | null {
|
|
8
|
+
const q = (context.userQuery || '').toLowerCase().trim();
|
|
9
|
+
|
|
10
|
+
// Check specific fact/definition/explanation indicators
|
|
11
|
+
const isFactQuery =
|
|
12
|
+
intent === 'information_lookup' ||
|
|
13
|
+
/^(what|who|explain|define|where|when|why|meaning of)\s+/i.test(q) ||
|
|
14
|
+
/^(what is the price of|price of|cost of)\b/i.test(q);
|
|
15
|
+
|
|
16
|
+
const isNonVisual = !/\b(compare|versus|vs\.?|trend|monthly|revenue|best|top 10|chart|table|list|grid)\b/i.test(q);
|
|
17
|
+
|
|
18
|
+
if (isFactQuery && isNonVisual) {
|
|
19
|
+
return {
|
|
20
|
+
renderType: 'text',
|
|
21
|
+
confidence: 0.95,
|
|
22
|
+
reason: 'Rule 1: Specific information request (fact, explanation, or definition) requires plain text rendering.',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|