nosible 0.1.5
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/README.md +438 -0
- package/dist/index.cjs +1841 -0
- package/dist/index.cjs.map +29 -0
- package/dist/index.js +1815 -0
- package/dist/index.js.map +29 -0
- package/package.json +63 -0
- package/src/api/api.test.ts +366 -0
- package/src/api/index.ts +179 -0
- package/src/api/schemas.ts +152 -0
- package/src/client.test.ts +685 -0
- package/src/client.ts +762 -0
- package/src/index.ts +4 -0
- package/src/scrape/types.ts +119 -0
- package/src/scrape/webPageData.test.ts +302 -0
- package/src/scrape/webPageData.ts +103 -0
- package/src/search/analyze.test.ts +396 -0
- package/src/search/analyze.ts +151 -0
- package/src/search/bulkSearch.ts +62 -0
- package/src/search/result.test.ts +423 -0
- package/src/search/result.ts +391 -0
- package/src/search/result.types.ts +32 -0
- package/src/search/resultFactory.ts +21 -0
- package/src/search/resultSet.io.test.ts +320 -0
- package/src/search/resultSet.test.ts +368 -0
- package/src/search/resultSet.ts +387 -0
- package/src/search/resultSet.types.ts +3 -0
- package/src/search/search.test.ts +299 -0
- package/src/search/search.ts +187 -0
- package/src/search/searchSet.io.test.ts +321 -0
- package/src/search/searchSet.ts +122 -0
- package/src/search/sqlFilter.test.ts +129 -0
- package/src/search/sqlFilter.ts +147 -0
- package/src/test-utils/mocks.ts +159 -0
- package/src/topicTrend/topicTrend.ts +53 -0
- package/src/utils/browser.test.ts +209 -0
- package/src/utils/browser.ts +21 -0
- package/src/utils/fernet.ts +47 -0
- package/src/utils/file.test.ts +81 -0
- package/src/utils/file.ts +195 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/llm.test.ts +279 -0
- package/src/utils/llm.ts +244 -0
- package/src/utils/userPlan.test.ts +332 -0
- package/src/utils/userPlan.ts +211 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Downloads a blob in the browser by creating a temporary link and triggering a download
|
|
3
|
+
* @param data - The data string to download
|
|
4
|
+
* @param mimeType - The MIME type of the file (e.g., 'application/json', 'text/csv')
|
|
5
|
+
* @param fileName - The name of the file to download
|
|
6
|
+
*/
|
|
7
|
+
export const downloadBlob = (
|
|
8
|
+
data: string,
|
|
9
|
+
mimeType: string,
|
|
10
|
+
fileName: string
|
|
11
|
+
): void => {
|
|
12
|
+
const blob = new Blob([data], {type: mimeType});
|
|
13
|
+
const url = URL.createObjectURL(blob);
|
|
14
|
+
const a = document.createElement("a");
|
|
15
|
+
a.href = url;
|
|
16
|
+
a.download = fileName;
|
|
17
|
+
document.body.appendChild(a);
|
|
18
|
+
a.click();
|
|
19
|
+
document.body.removeChild(a);
|
|
20
|
+
URL.revokeObjectURL(url);
|
|
21
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {createDecipheriv, createHmac, timingSafeEqual} from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decrypt data using Fernet-compatible decryption
|
|
5
|
+
* Fernet uses: version (1 byte) | timestamp (8 bytes) | iv (16 bytes) | ciphertext | hmac (32 bytes)
|
|
6
|
+
*/
|
|
7
|
+
export const fernetDecrypt = (token: Buffer, key: string): Buffer => {
|
|
8
|
+
try {
|
|
9
|
+
// Decode the base64 key and derive encryption and signing keys
|
|
10
|
+
const keyBuffer = Buffer.from(key, "base64");
|
|
11
|
+
const signingKey = keyBuffer.subarray(0, 16);
|
|
12
|
+
const encryptionKey = keyBuffer.subarray(16, 32);
|
|
13
|
+
|
|
14
|
+
// Verify HMAC (last 32 bytes)
|
|
15
|
+
const hmac = token.subarray(token.length - 32);
|
|
16
|
+
const payload = token.subarray(0, token.length - 32);
|
|
17
|
+
|
|
18
|
+
const expectedHmac = createHmac("sha256", signingKey)
|
|
19
|
+
.update(payload)
|
|
20
|
+
.digest();
|
|
21
|
+
|
|
22
|
+
if (!timingSafeEqual(hmac, expectedHmac)) {
|
|
23
|
+
throw new Error("HMAC verification failed");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract components
|
|
27
|
+
const version = payload[0];
|
|
28
|
+
if (version !== 0x80) {
|
|
29
|
+
throw new Error(`Unsupported Fernet version: ${version}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const iv = payload.subarray(9, 25); // Skip version (1) + timestamp (8)
|
|
33
|
+
const ciphertext = payload.subarray(25);
|
|
34
|
+
|
|
35
|
+
// Decrypt using AES-128-CBC
|
|
36
|
+
const decipher = createDecipheriv("aes-128-cbc", encryptionKey, iv);
|
|
37
|
+
const decrypted = Buffer.concat([
|
|
38
|
+
decipher.update(ciphertext),
|
|
39
|
+
decipher.final(),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return decrypted;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error("Error decrypting data:", error);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {describe, test, expect, afterEach} from "bun:test";
|
|
2
|
+
import {importCsv, exportCsv} from "./file";
|
|
3
|
+
import {writeFile, unlink} from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
describe("importCsv", () => {
|
|
6
|
+
const testFiles: string[] = [];
|
|
7
|
+
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
// Clean up test files
|
|
10
|
+
for (const file of testFiles) {
|
|
11
|
+
try {
|
|
12
|
+
await unlink(file);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
// Ignore errors if file doesn't exist
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
testFiles.length = 0; // Clear the array
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("should import CSV data from file path", async () => {
|
|
21
|
+
const csvContent = `name,age,city
|
|
22
|
+
John Doe,30,New York
|
|
23
|
+
Jane Smith,25,Los Angeles
|
|
24
|
+
Bob Johnson,35,Chicago`;
|
|
25
|
+
|
|
26
|
+
const testFilePath = "/tmp/test-import.csv";
|
|
27
|
+
testFiles.push(testFilePath);
|
|
28
|
+
|
|
29
|
+
await writeFile(testFilePath, csvContent);
|
|
30
|
+
|
|
31
|
+
const result = await importCsv({filePath: testFilePath});
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
{name: "John Doe", age: 30, city: "New York"},
|
|
35
|
+
{name: "Jane Smith", age: 25, city: "Los Angeles"},
|
|
36
|
+
{name: "Bob Johnson", age: 35, city: "Chicago"},
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("should handle empty CSV file", async () => {
|
|
41
|
+
const testFilePath = "/tmp/test-empty.csv";
|
|
42
|
+
testFiles.push(testFilePath);
|
|
43
|
+
|
|
44
|
+
await writeFile(testFilePath, "");
|
|
45
|
+
|
|
46
|
+
const result = await importCsv({filePath: testFilePath});
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("should handle CSV with only headers", async () => {
|
|
52
|
+
const csvContent = "name,age,city";
|
|
53
|
+
const testFilePath = "/tmp/test-headers-only.csv";
|
|
54
|
+
testFiles.push(testFilePath);
|
|
55
|
+
|
|
56
|
+
await writeFile(testFilePath, csvContent);
|
|
57
|
+
|
|
58
|
+
const result = await importCsv({filePath: testFilePath});
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("should round trip CSV data", async () => {
|
|
64
|
+
const testData = [
|
|
65
|
+
{name: "Alice", age: 28, city: "Boston"},
|
|
66
|
+
{name: "Charlie", age: 32, city: "Seattle"},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const exportPath = "/tmp/test-roundtrip.csv";
|
|
70
|
+
testFiles.push(exportPath);
|
|
71
|
+
|
|
72
|
+
// Export to CSV
|
|
73
|
+
await exportCsv(testData, exportPath);
|
|
74
|
+
|
|
75
|
+
// Import from CSV
|
|
76
|
+
const importedData = await importCsv({filePath: exportPath});
|
|
77
|
+
|
|
78
|
+
// Data should match (note: papaparse converts numbers to numbers)
|
|
79
|
+
expect(importedData).toEqual(testData);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {isBrowser} from ".";
|
|
2
|
+
import {downloadBlob} from "./browser";
|
|
3
|
+
import Papa from "papaparse";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Exports data to a JSON file
|
|
7
|
+
* @param data - The data to export
|
|
8
|
+
* @param outputPath - Path to write the JSON file (or filename for browser)
|
|
9
|
+
*/
|
|
10
|
+
export const exportJson = async (
|
|
11
|
+
data: any,
|
|
12
|
+
outputPath: string
|
|
13
|
+
): Promise<void> => {
|
|
14
|
+
try {
|
|
15
|
+
if (isBrowser()) {
|
|
16
|
+
// Extract filename from outputPath or use default
|
|
17
|
+
const filename = outputPath.split("/").pop() || "export.json";
|
|
18
|
+
const jsonString = JSON.stringify(data);
|
|
19
|
+
const finalFilename = filename.endsWith(".json")
|
|
20
|
+
? filename
|
|
21
|
+
: `${filename}.json`;
|
|
22
|
+
|
|
23
|
+
downloadBlob(jsonString, "application/json", finalFilename);
|
|
24
|
+
} else {
|
|
25
|
+
const {writeFile} = await import("node:fs/promises");
|
|
26
|
+
await writeFile(outputPath, JSON.stringify(data));
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
30
|
+
throw new Error(`Failed to write JSON: ${errorMessage}`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Imports JSON data from a file
|
|
36
|
+
* @param options - Either {filePath: string} for server-side or {file: File} for browser-side
|
|
37
|
+
* @returns Parsed JSON data
|
|
38
|
+
*/
|
|
39
|
+
export const importJson = async (
|
|
40
|
+
options: {filePath: string} | {file: File}
|
|
41
|
+
): Promise<any> => {
|
|
42
|
+
try {
|
|
43
|
+
if ("file" in options) {
|
|
44
|
+
// Browser-side: read from File object
|
|
45
|
+
const text = await options.file.text();
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} else {
|
|
48
|
+
// Server-side: read from file path
|
|
49
|
+
if (isBrowser()) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"File path import is not supported in browser. Use {file: File} instead."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const {readFile} = await import("node:fs/promises");
|
|
55
|
+
const data = await readFile(options.filePath, "utf-8");
|
|
56
|
+
return JSON.parse(data);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(`Failed to read JSON: ${errorMessage}`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Imports CSV data from a file
|
|
66
|
+
* @param options - Either {filePath: string} for server-side or {file: File} for browser-side
|
|
67
|
+
* @returns Parsed CSV data as an array of objects
|
|
68
|
+
*/
|
|
69
|
+
export const importCsv = async (
|
|
70
|
+
options: {filePath: string} | {file: File}
|
|
71
|
+
): Promise<any[]> => {
|
|
72
|
+
try {
|
|
73
|
+
let csvText: string;
|
|
74
|
+
|
|
75
|
+
if ("file" in options) {
|
|
76
|
+
// Browser-side: read from File object
|
|
77
|
+
csvText = await options.file.text();
|
|
78
|
+
} else {
|
|
79
|
+
// Server-side: read from file path
|
|
80
|
+
if (isBrowser()) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"File path import is not supported in browser. Use {file: File} instead."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const {readFile} = await import("node:fs/promises");
|
|
86
|
+
csvText = await readFile(options.filePath, "utf-8");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle empty CSV
|
|
90
|
+
if (!csvText.trim()) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Parse CSV using papaparse
|
|
95
|
+
const result = Papa.parse(csvText, {
|
|
96
|
+
header: true,
|
|
97
|
+
skipEmptyLines: true,
|
|
98
|
+
dynamicTyping: true,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Filter out delimiter detection errors for empty/whitespace-only content
|
|
102
|
+
const significantErrors = result.errors.filter(
|
|
103
|
+
(error) =>
|
|
104
|
+
!error.message.includes("Unable to auto-detect delimiting character")
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (significantErrors.length > 0) {
|
|
108
|
+
const errorMessages = significantErrors
|
|
109
|
+
.map((err) => err.message)
|
|
110
|
+
.join(", ");
|
|
111
|
+
throw new Error(`CSV parsing errors: ${errorMessages}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result.data as any[];
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
117
|
+
throw new Error(`Failed to read CSV: ${errorMessage}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Exports data to an NDJSON file (newline-delimited JSON)
|
|
123
|
+
* @param data - Array of objects to export
|
|
124
|
+
* @param outputPath - Path to write the NDJSON file (or filename for browser)
|
|
125
|
+
*/
|
|
126
|
+
export const exportNdjson = async (
|
|
127
|
+
data: any[],
|
|
128
|
+
outputPath: string
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
try {
|
|
131
|
+
if (isBrowser()) {
|
|
132
|
+
// Extract filename from outputPath or use default
|
|
133
|
+
const filename = outputPath.split("/").pop() || "export.ndjson";
|
|
134
|
+
|
|
135
|
+
// Create NDJSON string (one JSON object per line)
|
|
136
|
+
const ndjsonString = data.map((item) => JSON.stringify(item)).join("\n");
|
|
137
|
+
|
|
138
|
+
const finalFilename = filename.endsWith(".ndjson")
|
|
139
|
+
? filename
|
|
140
|
+
: `${filename}.ndjson`;
|
|
141
|
+
|
|
142
|
+
downloadBlob(ndjsonString, "application/x-ndjson", finalFilename);
|
|
143
|
+
} else {
|
|
144
|
+
const pl = await import("nodejs-polars");
|
|
145
|
+
const df = pl.DataFrame(data);
|
|
146
|
+
await df.writeJSON(outputPath, {format: "lines"});
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
150
|
+
throw new Error(`Failed to write NDJSON: ${errorMessage}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Exports data to a CSV file
|
|
156
|
+
* @param data - Array of objects to export
|
|
157
|
+
* @param outputPath - Path to write the CSV file (or filename for browser)
|
|
158
|
+
*/
|
|
159
|
+
export const exportCsv = async (
|
|
160
|
+
data: any[],
|
|
161
|
+
outputPath: string
|
|
162
|
+
): Promise<void> => {
|
|
163
|
+
try {
|
|
164
|
+
// Validate input
|
|
165
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
166
|
+
throw new Error("Data must be a non-empty array");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!outputPath || typeof outputPath !== "string") {
|
|
170
|
+
throw new Error("Output path must be a non-empty string");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Use papaparse to convert data to CSV
|
|
174
|
+
const csvString = Papa.unparse(data, {
|
|
175
|
+
header: true,
|
|
176
|
+
newline: "\n",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (isBrowser()) {
|
|
180
|
+
// Extract filename from outputPath or use default
|
|
181
|
+
const filename = outputPath.split("/").pop() || "export.csv";
|
|
182
|
+
const finalFilename = filename.endsWith(".csv")
|
|
183
|
+
? filename
|
|
184
|
+
: `${filename}.csv`;
|
|
185
|
+
|
|
186
|
+
downloadBlob(csvString, "text/csv", finalFilename);
|
|
187
|
+
} else {
|
|
188
|
+
const {writeFile} = await import("node:fs/promises");
|
|
189
|
+
await writeFile(outputPath, csvString);
|
|
190
|
+
}
|
|
191
|
+
} catch (error) {
|
|
192
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
193
|
+
throw new Error(`Failed to write CSV: ${errorMessage}`);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import {describe, test, expect, beforeAll, mock} from "bun:test";
|
|
2
|
+
import {getSentiment, generateExpansions} from "./llm";
|
|
3
|
+
import OpenAI from "openai";
|
|
4
|
+
|
|
5
|
+
// Helper to create a mock LLM client
|
|
6
|
+
const createMockLLMClient = (mockResponse: string): OpenAI => {
|
|
7
|
+
return {
|
|
8
|
+
chat: {
|
|
9
|
+
completions: {
|
|
10
|
+
create: mock(async () => ({
|
|
11
|
+
choices: [
|
|
12
|
+
{
|
|
13
|
+
message: {
|
|
14
|
+
content: mockResponse,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
})),
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
} as unknown as OpenAI;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("getSentiment", () => {
|
|
25
|
+
let realLlmClient: OpenAI;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
const llmApiKeyToUse = process.env.LLM_API_KEY;
|
|
29
|
+
if (!llmApiKeyToUse) {
|
|
30
|
+
throw new Error("LLM_API_KEY environment variable is required for tests");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
realLlmClient = new OpenAI({
|
|
34
|
+
apiKey: llmApiKeyToUse,
|
|
35
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Integration test - uses real API
|
|
40
|
+
test("should return positive sentiment for positive text (integration)", async () => {
|
|
41
|
+
const positiveText =
|
|
42
|
+
"I absolutely love this product! It's amazing and works perfectly. Best purchase ever!";
|
|
43
|
+
|
|
44
|
+
const score = await getSentiment({
|
|
45
|
+
text: positiveText,
|
|
46
|
+
llmClient: realLlmClient,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(score).toBeGreaterThanOrEqual(0.5);
|
|
50
|
+
expect(score).toBeLessThanOrEqual(1.0);
|
|
51
|
+
}, 30000);
|
|
52
|
+
|
|
53
|
+
// Mocked tests
|
|
54
|
+
test("should return negative sentiment for negative text", async () => {
|
|
55
|
+
const mockClient = createMockLLMClient("-0.85");
|
|
56
|
+
const negativeText =
|
|
57
|
+
"This is terrible. I hate it and it completely failed. Worst experience ever.";
|
|
58
|
+
|
|
59
|
+
const score = await getSentiment({
|
|
60
|
+
text: negativeText,
|
|
61
|
+
llmClient: mockClient,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(score).toBe(-0.85);
|
|
65
|
+
expect(score).toBeGreaterThanOrEqual(-1.0);
|
|
66
|
+
expect(score).toBeLessThanOrEqual(-0.5);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("should return neutral sentiment for neutral text", async () => {
|
|
70
|
+
const mockClient = createMockLLMClient("0.1");
|
|
71
|
+
const neutralText =
|
|
72
|
+
"The product is a widget that performs basic functions. It has standard features.";
|
|
73
|
+
|
|
74
|
+
const score = await getSentiment({
|
|
75
|
+
text: neutralText,
|
|
76
|
+
llmClient: mockClient,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(score).toBe(0.1);
|
|
80
|
+
expect(score).toBeGreaterThanOrEqual(-0.5);
|
|
81
|
+
expect(score).toBeLessThanOrEqual(0.5);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("should throw error when no client is provided", async () => {
|
|
85
|
+
const text = "Some test text";
|
|
86
|
+
|
|
87
|
+
await expect(
|
|
88
|
+
getSentiment({
|
|
89
|
+
text,
|
|
90
|
+
llmClient: undefined,
|
|
91
|
+
})
|
|
92
|
+
).rejects.toThrow(
|
|
93
|
+
"A OpenAI client instance must be provided as 'llmClient'."
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("should handle empty string", async () => {
|
|
98
|
+
const mockClient = createMockLLMClient("0.5");
|
|
99
|
+
const text = "";
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
getSentiment({
|
|
103
|
+
text,
|
|
104
|
+
llmClient: mockClient,
|
|
105
|
+
})
|
|
106
|
+
).rejects.toThrow("Text must not be empty or whitespace.");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("should parse valid float responses", async () => {
|
|
110
|
+
const mockClient = createMockLLMClient("0.75");
|
|
111
|
+
const text = "This is reasonably good";
|
|
112
|
+
|
|
113
|
+
const score = await getSentiment({
|
|
114
|
+
text,
|
|
115
|
+
llmClient: mockClient,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(typeof score).toBe("number");
|
|
119
|
+
expect(Number.isFinite(score)).toBe(true);
|
|
120
|
+
expect(score).toBe(0.75);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("should throw error for invalid sentiment response", async () => {
|
|
124
|
+
const mockClient = createMockLLMClient("not a number");
|
|
125
|
+
const text = "Some text";
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
getSentiment({
|
|
129
|
+
text,
|
|
130
|
+
llmClient: mockClient,
|
|
131
|
+
})
|
|
132
|
+
).rejects.toThrow("Sentiment response is not a float");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("should throw error for out of range sentiment", async () => {
|
|
136
|
+
const mockClient = createMockLLMClient("1.5");
|
|
137
|
+
const text = "Some text";
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
getSentiment({
|
|
141
|
+
text,
|
|
142
|
+
llmClient: mockClient,
|
|
143
|
+
})
|
|
144
|
+
).rejects.toThrow("Sentiment 1.5 outside valid range [-1.0, 1.0]");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("generateExpansions", () => {
|
|
149
|
+
let realLlmClient: OpenAI;
|
|
150
|
+
|
|
151
|
+
beforeAll(() => {
|
|
152
|
+
const llmApiKeyToUse = process.env.LLM_API_KEY;
|
|
153
|
+
if (!llmApiKeyToUse) {
|
|
154
|
+
throw new Error("LLM_API_KEY environment variable is required for tests");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
realLlmClient = new OpenAI({
|
|
158
|
+
apiKey: llmApiKeyToUse,
|
|
159
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Integration test - uses real API
|
|
164
|
+
test("should return 10 expansion strings for a typical question (integration)", async () => {
|
|
165
|
+
const question = "What are effective treatments for type 2 diabetes?";
|
|
166
|
+
|
|
167
|
+
const expansions = await generateExpansions({
|
|
168
|
+
question,
|
|
169
|
+
llmClient: realLlmClient,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(Array.isArray(expansions)).toBe(true);
|
|
173
|
+
expect(expansions.length).toBe(10);
|
|
174
|
+
expect(expansions.every((q) => typeof q === "string")).toBe(true);
|
|
175
|
+
}, 30000);
|
|
176
|
+
|
|
177
|
+
// Mocked tests
|
|
178
|
+
test("should parse valid JSON array response", async () => {
|
|
179
|
+
const mockExpansions = [
|
|
180
|
+
"What are the best treatments for managing type 2 diabetes?",
|
|
181
|
+
"How can type 2 diabetes be effectively treated?",
|
|
182
|
+
"What medications are used to treat type 2 diabetes?",
|
|
183
|
+
"What are the recommended therapies for type 2 diabetes?",
|
|
184
|
+
"How to control type 2 diabetes with treatment?",
|
|
185
|
+
"What treatment options exist for type 2 diabetes patients?",
|
|
186
|
+
"How is type 2 diabetes managed medically?",
|
|
187
|
+
"What are effective interventions for type 2 diabetes?",
|
|
188
|
+
"Which treatments work best for type 2 diabetes?",
|
|
189
|
+
"What are proven methods to treat type 2 diabetes?",
|
|
190
|
+
];
|
|
191
|
+
const mockClient = createMockLLMClient(JSON.stringify(mockExpansions));
|
|
192
|
+
const question = "What are effective treatments for type 2 diabetes?";
|
|
193
|
+
|
|
194
|
+
const expansions = await generateExpansions({
|
|
195
|
+
question,
|
|
196
|
+
llmClient: mockClient,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(Array.isArray(expansions)).toBe(true);
|
|
200
|
+
expect(expansions.length).toBe(10);
|
|
201
|
+
expect(expansions).toEqual(mockExpansions);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("should handle JSON wrapped in markdown code blocks", async () => {
|
|
205
|
+
const mockExpansions = [
|
|
206
|
+
"Question 1",
|
|
207
|
+
"Question 2",
|
|
208
|
+
"Question 3",
|
|
209
|
+
"Question 4",
|
|
210
|
+
"Question 5",
|
|
211
|
+
"Question 6",
|
|
212
|
+
"Question 7",
|
|
213
|
+
"Question 8",
|
|
214
|
+
"Question 9",
|
|
215
|
+
"Question 10",
|
|
216
|
+
];
|
|
217
|
+
const mockClient = createMockLLMClient(
|
|
218
|
+
`\`\`\`json\n${JSON.stringify(mockExpansions)}\n\`\`\``
|
|
219
|
+
);
|
|
220
|
+
const question = "Test question";
|
|
221
|
+
|
|
222
|
+
const expansions = await generateExpansions({
|
|
223
|
+
question,
|
|
224
|
+
llmClient: mockClient,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(expansions).toEqual(mockExpansions);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("should throw error when no client is provided", async () => {
|
|
231
|
+
const question = "What are the symptoms of seasonal allergies?";
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
generateExpansions({
|
|
235
|
+
question,
|
|
236
|
+
llmClient: undefined,
|
|
237
|
+
})
|
|
238
|
+
).rejects.toThrow(
|
|
239
|
+
"A OpenAI client instance must be provided as 'llmClient'."
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("should throw on empty question", async () => {
|
|
244
|
+
const mockClient = createMockLLMClient("[]");
|
|
245
|
+
|
|
246
|
+
await expect(
|
|
247
|
+
generateExpansions({
|
|
248
|
+
question: "",
|
|
249
|
+
llmClient: mockClient,
|
|
250
|
+
})
|
|
251
|
+
).rejects.toThrow("Question must not be empty or whitespace.");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("should throw error for invalid JSON response", async () => {
|
|
255
|
+
const mockClient = createMockLLMClient("not valid json");
|
|
256
|
+
const question = "Test question";
|
|
257
|
+
|
|
258
|
+
await expect(
|
|
259
|
+
generateExpansions({
|
|
260
|
+
question,
|
|
261
|
+
llmClient: mockClient,
|
|
262
|
+
})
|
|
263
|
+
).rejects.toThrow("OpenRouter response was not valid JSON");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("should throw error for invalid array length", async () => {
|
|
267
|
+
const mockClient = createMockLLMClient(
|
|
268
|
+
JSON.stringify(["Question 1", "Question 2"])
|
|
269
|
+
);
|
|
270
|
+
const question = "Test question";
|
|
271
|
+
|
|
272
|
+
await expect(
|
|
273
|
+
generateExpansions({
|
|
274
|
+
question,
|
|
275
|
+
llmClient: mockClient,
|
|
276
|
+
})
|
|
277
|
+
).rejects.toThrow("Invalid response");
|
|
278
|
+
});
|
|
279
|
+
});
|