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,187 @@
|
|
|
1
|
+
import {generateSqlFilter, sqlFilterSchema} from "./sqlFilter";
|
|
2
|
+
import {z} from "zod";
|
|
3
|
+
import {
|
|
4
|
+
algorithmEnum,
|
|
5
|
+
brandSafetyEnum,
|
|
6
|
+
continentEnum,
|
|
7
|
+
regionEnum,
|
|
8
|
+
type AlgorithmEnum,
|
|
9
|
+
type BrandSafetyEnum,
|
|
10
|
+
type ContinentEnum,
|
|
11
|
+
type RegionEnum,
|
|
12
|
+
} from "../api/schemas";
|
|
13
|
+
import type {NosibleClient} from "../client";
|
|
14
|
+
import {generateExpansions} from "../utils/llm";
|
|
15
|
+
import {aiSearchParamsSchema, type StdSearchParamsType} from "../api/index";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* User Search Params
|
|
19
|
+
* A user can search using additional filters, these are passed as a SQL filter string.
|
|
20
|
+
* */
|
|
21
|
+
// User AI Search Params
|
|
22
|
+
export const userAiSearchParamsSchema = aiSearchParamsSchema;
|
|
23
|
+
export type UserAiSearchParamsType = z.infer<typeof userAiSearchParamsSchema>;
|
|
24
|
+
|
|
25
|
+
// User Fast Search Params
|
|
26
|
+
export const userSearchParamsSchema = sqlFilterSchema.extend({
|
|
27
|
+
// Standard search params for fast and bulk search only
|
|
28
|
+
question: z.string().optional(),
|
|
29
|
+
autoGenerateExpansions: z.boolean().optional(),
|
|
30
|
+
expansions: z.array(z.string()).optional(),
|
|
31
|
+
sqlFilter: z.string().optional(),
|
|
32
|
+
algorithm: algorithmEnum.optional(),
|
|
33
|
+
nResults: z.number().optional(),
|
|
34
|
+
nProbes: z.number().min(1).max(10).optional(),
|
|
35
|
+
nContextify: z.number().min(64).max(1024).optional(),
|
|
36
|
+
minSimilarity: z.number().min(0).max(1).optional(),
|
|
37
|
+
mustInclude: z.array(z.string()).max(100).optional(),
|
|
38
|
+
mustExclude: z.array(z.string()).max(100).optional(),
|
|
39
|
+
brandSafety: brandSafetyEnum.optional(),
|
|
40
|
+
language: z.string().min(2).max(2).optional(),
|
|
41
|
+
continent: continentEnum.optional(),
|
|
42
|
+
region: regionEnum.optional(),
|
|
43
|
+
country: z.string().optional(),
|
|
44
|
+
sector: z.string().optional(),
|
|
45
|
+
industryGroup: z.string().optional(),
|
|
46
|
+
industry: z.string().optional(),
|
|
47
|
+
subIndustry: z.string().optional(),
|
|
48
|
+
iabTier1: z.string().optional(),
|
|
49
|
+
iabTier2: z.string().optional(),
|
|
50
|
+
iabTier3: z.string().optional(),
|
|
51
|
+
iabTier4: z.string().optional(),
|
|
52
|
+
companies: z.array(z.string()).max(30).optional(),
|
|
53
|
+
});
|
|
54
|
+
export type UserSearchParamsType = z.infer<typeof userSearchParamsSchema>;
|
|
55
|
+
|
|
56
|
+
export const clientDefaultSearchParamsSchema = userSearchParamsSchema.omit({
|
|
57
|
+
question: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type ClientDefaultSearchParamsType = z.infer<
|
|
61
|
+
typeof clientDefaultSearchParamsSchema
|
|
62
|
+
>;
|
|
63
|
+
|
|
64
|
+
export type ClientSearchParamsSchema =
|
|
65
|
+
| (UserSearchParamsType & {search?: never})
|
|
66
|
+
| {search: Search};
|
|
67
|
+
|
|
68
|
+
// Similar Search User Params
|
|
69
|
+
const similarUserSearchParams = userSearchParamsSchema.omit({
|
|
70
|
+
question: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type SimilarUserSearchParamsType = z.infer<
|
|
74
|
+
typeof similarUserSearchParams
|
|
75
|
+
>;
|
|
76
|
+
|
|
77
|
+
export class Search {
|
|
78
|
+
question!: string;
|
|
79
|
+
nResults!: number;
|
|
80
|
+
expansions?: string[];
|
|
81
|
+
autoGenerateExpansions?: boolean;
|
|
82
|
+
nProbes?: number;
|
|
83
|
+
nContextify?: number;
|
|
84
|
+
algorithm?: AlgorithmEnum;
|
|
85
|
+
sqlFilter?: string;
|
|
86
|
+
minSimilarity?: number;
|
|
87
|
+
mustInclude?: string[];
|
|
88
|
+
mustExclude?: string[];
|
|
89
|
+
brandSafety?: BrandSafetyEnum;
|
|
90
|
+
language?: string;
|
|
91
|
+
continent?: ContinentEnum;
|
|
92
|
+
region?: RegionEnum;
|
|
93
|
+
country?: string;
|
|
94
|
+
sector?: string;
|
|
95
|
+
industryGroup?: string;
|
|
96
|
+
industry?: string;
|
|
97
|
+
subIndustry?: string;
|
|
98
|
+
iabTier1?: string;
|
|
99
|
+
iabTier2?: string;
|
|
100
|
+
iabTier3?: string;
|
|
101
|
+
iabTier4?: string;
|
|
102
|
+
companies?: string[];
|
|
103
|
+
// SQL Filter
|
|
104
|
+
publishStart?: Date;
|
|
105
|
+
publishEnd?: Date;
|
|
106
|
+
visitedStart?: Date;
|
|
107
|
+
visitedEnd?: Date;
|
|
108
|
+
certain?: boolean;
|
|
109
|
+
includeNetlocs?: string[];
|
|
110
|
+
excludeNetlocs?: string[];
|
|
111
|
+
includeCompanies?: string[];
|
|
112
|
+
excludeCompanies?: string[];
|
|
113
|
+
includeDocs?: string[];
|
|
114
|
+
excludeDocs?: string[];
|
|
115
|
+
|
|
116
|
+
constructor(params: UserSearchParamsType) {
|
|
117
|
+
Object.assign(this, params);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Returns the search body for the API call
|
|
121
|
+
|
|
122
|
+
Generates the expansions and sql filter from the search params if required
|
|
123
|
+
*/
|
|
124
|
+
public searchBody = async ({
|
|
125
|
+
client,
|
|
126
|
+
}: {
|
|
127
|
+
client: NosibleClient;
|
|
128
|
+
}): Promise<StdSearchParamsType> => {
|
|
129
|
+
let expansions: string[] | undefined = this.expansions;
|
|
130
|
+
|
|
131
|
+
if (this.autoGenerateExpansions) {
|
|
132
|
+
expansions = await generateExpansions({
|
|
133
|
+
question: this.question,
|
|
134
|
+
model: client.expansionsModel,
|
|
135
|
+
llmClient: client.llmClient,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let sqlFilter = generateSqlFilter({
|
|
140
|
+
publishStart: this.publishStart || undefined,
|
|
141
|
+
publishEnd: this.publishEnd || undefined,
|
|
142
|
+
visitedStart: this.visitedStart || undefined,
|
|
143
|
+
visitedEnd: this.visitedEnd || undefined,
|
|
144
|
+
certain: this.certain || undefined,
|
|
145
|
+
includeNetlocs: this.includeNetlocs || undefined,
|
|
146
|
+
excludeNetlocs: this.excludeNetlocs || undefined,
|
|
147
|
+
includeCompanies: this.includeCompanies || undefined,
|
|
148
|
+
excludeCompanies: this.excludeCompanies || undefined,
|
|
149
|
+
includeDocs: this.includeDocs || undefined,
|
|
150
|
+
excludeDocs: this.excludeDocs || undefined,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const output: StdSearchParamsType = {
|
|
154
|
+
question: this.question,
|
|
155
|
+
expansions: expansions,
|
|
156
|
+
sql_filter: sqlFilter || undefined,
|
|
157
|
+
algorithm: this.algorithm,
|
|
158
|
+
n_probes: this.nProbes,
|
|
159
|
+
n_results: this.nResults,
|
|
160
|
+
n_contextify: this.nContextify,
|
|
161
|
+
min_similarity: this.minSimilarity,
|
|
162
|
+
must_include: this.mustInclude,
|
|
163
|
+
must_exclude: this.mustExclude,
|
|
164
|
+
brand_safety: this.brandSafety,
|
|
165
|
+
language: this.language,
|
|
166
|
+
continent: this.continent,
|
|
167
|
+
region: this.region,
|
|
168
|
+
country: this.country,
|
|
169
|
+
sector: this.sector,
|
|
170
|
+
industry_group: this.industryGroup,
|
|
171
|
+
industry: this.industry,
|
|
172
|
+
sub_industry: this.subIndustry,
|
|
173
|
+
iab_tier_1: this.iabTier1,
|
|
174
|
+
iab_tier_2: this.iabTier2,
|
|
175
|
+
iab_tier_3: this.iabTier3,
|
|
176
|
+
iab_tier_4: this.iabTier4,
|
|
177
|
+
companies: this.companies,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// remove undefined values
|
|
181
|
+
const outputWithoutUndefined = Object.fromEntries(
|
|
182
|
+
Object.entries(output).filter(([, value]) => value !== undefined)
|
|
183
|
+
) as StdSearchParamsType;
|
|
184
|
+
|
|
185
|
+
return outputWithoutUndefined;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {describe, it, expect, beforeAll} from "bun:test";
|
|
2
|
+
import {SearchSet} from "./searchSet";
|
|
3
|
+
import {Search} from "./search";
|
|
4
|
+
import {importCsv, importJson} from "../utils/file";
|
|
5
|
+
import {randomUUID} from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test configuration
|
|
9
|
+
*/
|
|
10
|
+
const TEST_DIR = `${process.cwd()}/tmp/searchSet/`; // Directory where test files will be created
|
|
11
|
+
const CLEANUP = false; // Set to false to keep test files for inspection
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Helper function to conditionally cleanup test files
|
|
15
|
+
*/
|
|
16
|
+
const cleanupFile = async (filePath: string) => {
|
|
17
|
+
if (CLEANUP) {
|
|
18
|
+
const fs = await import("node:fs/promises");
|
|
19
|
+
await fs.unlink(filePath).catch(() => {});
|
|
20
|
+
} else {
|
|
21
|
+
console.log(`Keeping test file: ${filePath}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* I/O tests for SearchSet with file system operations
|
|
27
|
+
* These tests require file system access for reading/writing
|
|
28
|
+
*/
|
|
29
|
+
describe("SearchSet - I/O Tests", () => {
|
|
30
|
+
let testSearchSet: SearchSet;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
try {
|
|
34
|
+
// Create test directory if it doesn't exist
|
|
35
|
+
const fs = await import("node:fs/promises");
|
|
36
|
+
await fs.mkdir(TEST_DIR, {recursive: true}).catch(() => {});
|
|
37
|
+
|
|
38
|
+
// Create a test SearchSet with sample searches
|
|
39
|
+
const searches = [
|
|
40
|
+
new Search({
|
|
41
|
+
question: "artificial intelligence",
|
|
42
|
+
nResults: 10,
|
|
43
|
+
}),
|
|
44
|
+
new Search({
|
|
45
|
+
question: "machine learning",
|
|
46
|
+
nResults: 20,
|
|
47
|
+
algorithm: "hybrid-1",
|
|
48
|
+
}),
|
|
49
|
+
new Search({
|
|
50
|
+
question: "deep learning",
|
|
51
|
+
nResults: 15,
|
|
52
|
+
language: "en",
|
|
53
|
+
brandSafety: "safe",
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
testSearchSet = new SearchSet(searches);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn("Skipping I/O tests due to setup error");
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("File export methods", () => {
|
|
65
|
+
it("should export SearchSet to JSON file", async () => {
|
|
66
|
+
const uniqueId = randomUUID();
|
|
67
|
+
const fileName = `${TEST_DIR}/test-searches-${uniqueId}.json`;
|
|
68
|
+
await testSearchSet.writeToJson(fileName);
|
|
69
|
+
|
|
70
|
+
console.log(
|
|
71
|
+
`Successfully exported ${testSearchSet.searches.length} searches to JSON`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Verify the file was created
|
|
75
|
+
const fs = await import("node:fs/promises");
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
77
|
+
|
|
78
|
+
const fileExists = await fs
|
|
79
|
+
.access(fileName)
|
|
80
|
+
.then(() => true)
|
|
81
|
+
.catch(() => false);
|
|
82
|
+
expect(fileExists).toBe(true);
|
|
83
|
+
|
|
84
|
+
// Clean up
|
|
85
|
+
await cleanupFile(fileName);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should export SearchSet to NDJSON file", async () => {
|
|
89
|
+
const uniqueId = randomUUID();
|
|
90
|
+
const fileName = `${TEST_DIR}/test-searches-${uniqueId}.ndjson`;
|
|
91
|
+
await testSearchSet.writeToNdjson(fileName);
|
|
92
|
+
|
|
93
|
+
console.log(
|
|
94
|
+
`Successfully exported ${testSearchSet.searches.length} searches to NDJSON`
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Verify the file was created
|
|
98
|
+
const fs = await import("node:fs/promises");
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
100
|
+
|
|
101
|
+
const fileExists = await fs
|
|
102
|
+
.access(fileName)
|
|
103
|
+
.then(() => true)
|
|
104
|
+
.catch(() => false);
|
|
105
|
+
expect(fileExists).toBe(true);
|
|
106
|
+
|
|
107
|
+
// Clean up
|
|
108
|
+
await cleanupFile(fileName);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should export SearchSet to CSV file", async () => {
|
|
112
|
+
const uniqueId = randomUUID();
|
|
113
|
+
const fileName = `${TEST_DIR}/test-searches-${uniqueId}.csv`;
|
|
114
|
+
await testSearchSet.writeToCsv(fileName);
|
|
115
|
+
|
|
116
|
+
console.log(
|
|
117
|
+
`Successfully exported ${testSearchSet.searches.length} searches to CSV`
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Verify the file was created
|
|
121
|
+
const fs = await import("node:fs/promises");
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
123
|
+
|
|
124
|
+
const fileExists = await fs
|
|
125
|
+
.access(fileName)
|
|
126
|
+
.then(() => true)
|
|
127
|
+
.catch(() => false);
|
|
128
|
+
expect(fileExists).toBe(true);
|
|
129
|
+
|
|
130
|
+
// Read file and verify contents
|
|
131
|
+
const json = await importCsv({filePath: fileName});
|
|
132
|
+
expect(json).toBeDefined();
|
|
133
|
+
expect(json.length).toBeGreaterThan(0);
|
|
134
|
+
expect(json[0]?.question).toBeDefined();
|
|
135
|
+
|
|
136
|
+
// Clean up
|
|
137
|
+
await cleanupFile(fileName);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("Data conversion methods", () => {
|
|
142
|
+
it("should convert SearchSet to Polars DataFrame", async () => {
|
|
143
|
+
const df = await testSearchSet.toPolars();
|
|
144
|
+
|
|
145
|
+
expect(df).toBeDefined();
|
|
146
|
+
expect(df.height).toBeGreaterThan(0);
|
|
147
|
+
expect(df.width).toBeGreaterThan(0);
|
|
148
|
+
|
|
149
|
+
console.log(
|
|
150
|
+
`Successfully converted ${testSearchSet.searches.length} searches to Polars DataFrame`
|
|
151
|
+
);
|
|
152
|
+
console.log(`DataFrame shape: ${df.height} rows x ${df.width} columns`);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("Round-trip export/import - JSON", () => {
|
|
157
|
+
it("should verify exported JSON can be reimported", async () => {
|
|
158
|
+
const uniqueId = randomUUID();
|
|
159
|
+
const fileName = `${TEST_DIR}/test-roundtrip-${uniqueId}.json`;
|
|
160
|
+
await testSearchSet.writeToJson(fileName);
|
|
161
|
+
|
|
162
|
+
// Import the JSON file back
|
|
163
|
+
const reimportedSearchSet = await SearchSet.fromFilePath(fileName);
|
|
164
|
+
|
|
165
|
+
expect(reimportedSearchSet).toBeDefined();
|
|
166
|
+
expect(reimportedSearchSet.searches.length).toBe(
|
|
167
|
+
testSearchSet.searches.length
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
console.log(
|
|
171
|
+
`Successfully round-tripped ${reimportedSearchSet.searches.length} searches through JSON`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Clean up
|
|
175
|
+
await cleanupFile(fileName);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should produce identical JSON files after save -> load -> save cycle", async () => {
|
|
179
|
+
const fs = await import("node:fs/promises");
|
|
180
|
+
const uniqueId = randomUUID();
|
|
181
|
+
const file1 = `${TEST_DIR}/test-roundtrip-1-${uniqueId}.json`;
|
|
182
|
+
const file2 = `${TEST_DIR}/test-roundtrip-2-${uniqueId}.json`;
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
// Save original searches
|
|
186
|
+
await testSearchSet.writeToJson(file1);
|
|
187
|
+
|
|
188
|
+
// Load and verify
|
|
189
|
+
const reloadedSearchSet = await SearchSet.fromFilePath(file1);
|
|
190
|
+
expect(reloadedSearchSet.searches.length).toBe(
|
|
191
|
+
testSearchSet.searches.length
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Save again
|
|
195
|
+
await reloadedSearchSet.writeToJson(file2);
|
|
196
|
+
|
|
197
|
+
// Compare the two JSON files
|
|
198
|
+
const json1Content = await fs.readFile(file1, "utf-8");
|
|
199
|
+
const json2Content = await fs.readFile(file2, "utf-8");
|
|
200
|
+
|
|
201
|
+
const json1 = JSON.parse(json1Content);
|
|
202
|
+
const json2 = JSON.parse(json2Content);
|
|
203
|
+
|
|
204
|
+
// Verify they have the same structure and content
|
|
205
|
+
expect(json1.length).toBe(json2.length);
|
|
206
|
+
expect(json1).toEqual(json2);
|
|
207
|
+
|
|
208
|
+
console.log(
|
|
209
|
+
`✓ Both JSON files are identical (${json1.length} searches)`
|
|
210
|
+
);
|
|
211
|
+
} finally {
|
|
212
|
+
// Clean up both files
|
|
213
|
+
await cleanupFile(file1);
|
|
214
|
+
await cleanupFile(file2);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("Round-trip export/import - CSV", () => {
|
|
220
|
+
it("should verify exported CSV can be reimported", async () => {
|
|
221
|
+
const uniqueId = randomUUID();
|
|
222
|
+
const fileName = `${TEST_DIR}/test-roundtrip-${uniqueId}.csv`;
|
|
223
|
+
await testSearchSet.writeToCsv(fileName);
|
|
224
|
+
|
|
225
|
+
// Import the CSV file back
|
|
226
|
+
const reimportedSearchSet = await SearchSet.fromFilePath(fileName);
|
|
227
|
+
|
|
228
|
+
expect(reimportedSearchSet).toBeDefined();
|
|
229
|
+
expect(reimportedSearchSet.searches.length).toBe(
|
|
230
|
+
testSearchSet.searches.length
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
console.log(
|
|
234
|
+
`Successfully round-tripped ${reimportedSearchSet.searches.length} searches through CSV`
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Clean up
|
|
238
|
+
await cleanupFile(fileName);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("should produce identical CSV files after save -> load -> save cycle", async () => {
|
|
242
|
+
const fs = await import("node:fs/promises");
|
|
243
|
+
const uniqueId = randomUUID();
|
|
244
|
+
const file1 = `${TEST_DIR}/test-roundtrip-1-${uniqueId}.csv`;
|
|
245
|
+
const file2 = `${TEST_DIR}/test-roundtrip-2-${uniqueId}.csv`;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Save original searches
|
|
249
|
+
await testSearchSet.writeToCsv(file1);
|
|
250
|
+
|
|
251
|
+
// Load and verify
|
|
252
|
+
const reloadedSearchSet = await SearchSet.fromFilePath(file1);
|
|
253
|
+
expect(reloadedSearchSet.searches.length).toBe(
|
|
254
|
+
testSearchSet.searches.length
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Save again
|
|
258
|
+
await reloadedSearchSet.writeToCsv(file2);
|
|
259
|
+
|
|
260
|
+
// Compare the two CSV files
|
|
261
|
+
const csv1Content = await fs.readFile(file1, "utf-8");
|
|
262
|
+
const csv2Content = await fs.readFile(file2, "utf-8");
|
|
263
|
+
|
|
264
|
+
// Verify they have the same content
|
|
265
|
+
expect(csv1Content).toBe(csv2Content);
|
|
266
|
+
|
|
267
|
+
console.log(`✓ Both CSV files are identical`);
|
|
268
|
+
} finally {
|
|
269
|
+
// Clean up both files
|
|
270
|
+
await cleanupFile(file1);
|
|
271
|
+
await cleanupFile(file2);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("SearchSet with various search parameters", () => {
|
|
277
|
+
it("should handle searches with complex filter parameters", async () => {
|
|
278
|
+
const complexSearches = [
|
|
279
|
+
new Search({
|
|
280
|
+
question: "climate change",
|
|
281
|
+
nResults: 50,
|
|
282
|
+
algorithm: "hybrid-1",
|
|
283
|
+
brandSafety: "safe",
|
|
284
|
+
language: "en",
|
|
285
|
+
country: "US",
|
|
286
|
+
sector: "Technology",
|
|
287
|
+
mustInclude: ["renewable", "energy"],
|
|
288
|
+
mustExclude: ["fossil"],
|
|
289
|
+
}),
|
|
290
|
+
new Search({
|
|
291
|
+
question: "renewable energy",
|
|
292
|
+
nResults: 30,
|
|
293
|
+
expansions: ["solar power", "wind energy", "hydroelectric"],
|
|
294
|
+
minSimilarity: 0.7,
|
|
295
|
+
}),
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
const complexSearchSet = new SearchSet(complexSearches);
|
|
299
|
+
const uniqueId = randomUUID();
|
|
300
|
+
const fileName = `${TEST_DIR}/complex-searches-${uniqueId}.json`;
|
|
301
|
+
|
|
302
|
+
await complexSearchSet.writeToJson(fileName);
|
|
303
|
+
|
|
304
|
+
// Verify round-trip
|
|
305
|
+
const reimported = await SearchSet.fromFilePath(fileName);
|
|
306
|
+
expect(reimported.searches.length).toBe(complexSearches.length);
|
|
307
|
+
expect(reimported.searches[0]?.question).toBe("climate change");
|
|
308
|
+
expect(reimported.searches[0]?.mustInclude).toEqual([
|
|
309
|
+
"renewable",
|
|
310
|
+
"energy",
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
console.log(
|
|
314
|
+
`✓ Successfully handled complex search parameters (${reimported.searches.length} searches)`
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Clean up
|
|
318
|
+
await cleanupFile(fileName);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
exportJson,
|
|
3
|
+
exportCsv,
|
|
4
|
+
exportNdjson,
|
|
5
|
+
importCsv,
|
|
6
|
+
importJson,
|
|
7
|
+
} from "../utils/file";
|
|
8
|
+
import {Search} from "./search";
|
|
9
|
+
import {z} from "zod";
|
|
10
|
+
import {isBrowser} from "../utils";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A set of searches.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const searchSetParams = z.union([
|
|
17
|
+
z.object({
|
|
18
|
+
searches: z.array(z.instanceof(Search)),
|
|
19
|
+
}),
|
|
20
|
+
z.array(z.instanceof(Search)),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export type SearchSetParams = z.infer<typeof searchSetParams>;
|
|
24
|
+
|
|
25
|
+
export class SearchSet {
|
|
26
|
+
public searches: Search[] = [];
|
|
27
|
+
public index: number;
|
|
28
|
+
|
|
29
|
+
constructor(params: SearchSetParams) {
|
|
30
|
+
// Handle both object and array forms
|
|
31
|
+
if (Array.isArray(params)) {
|
|
32
|
+
this.searches = params;
|
|
33
|
+
} else {
|
|
34
|
+
this.searches = params.searches;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.index = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a SearchSet from a JSON file asynchronously
|
|
42
|
+
* @param filePath Path to the JSON file
|
|
43
|
+
* @returns Promise<SearchSet>
|
|
44
|
+
*/
|
|
45
|
+
static async fromFilePath(filePath: string): Promise<SearchSet> {
|
|
46
|
+
if (filePath.endsWith(".json")) {
|
|
47
|
+
const searches = await importJson({filePath: filePath});
|
|
48
|
+
return new SearchSet(searches.map((search: any) => new Search(search)));
|
|
49
|
+
} else if (filePath.endsWith(".csv")) {
|
|
50
|
+
const searches = await importCsv({filePath: filePath});
|
|
51
|
+
return new SearchSet(searches.map((search: any) => new Search(search)));
|
|
52
|
+
} else throw new Error("Unsupported file type");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a SearchSet from a File asynchronously
|
|
57
|
+
* @param file File to read from
|
|
58
|
+
* @returns Promise<SearchSet>
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
static async fromFile(file: File): Promise<SearchSet> {
|
|
62
|
+
if (file.name.endsWith(".json")) {
|
|
63
|
+
const searches = await importJson({file: file});
|
|
64
|
+
return new SearchSet(searches.map((search: any) => new Search(search)));
|
|
65
|
+
} else if (file.name.endsWith(".csv")) {
|
|
66
|
+
const searches = await importCsv({file: file});
|
|
67
|
+
return new SearchSet(searches.map((search: any) => new Search(search)));
|
|
68
|
+
} else throw new Error("Unsupported file type");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns the next search in the set.
|
|
73
|
+
* @returns The next search in the set, or undefined if there are no more searches.
|
|
74
|
+
*/
|
|
75
|
+
public next = (): Search | null => {
|
|
76
|
+
this.index++;
|
|
77
|
+
const search = this.searches[this.index];
|
|
78
|
+
if (!search) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return search;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Adds a search to the set.
|
|
86
|
+
* @param search The search to add.
|
|
87
|
+
*/
|
|
88
|
+
public add = (search: Search) => {
|
|
89
|
+
this.searches.push(search);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Removes a search from the set.
|
|
94
|
+
* @param index The index of the search to remove.
|
|
95
|
+
*/
|
|
96
|
+
public remove = (index: number) => {
|
|
97
|
+
this.searches.splice(index, 1);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
public toPolars = async () => {
|
|
101
|
+
if (isBrowser()) {
|
|
102
|
+
throw new Error("toPolars is not supported in the browser");
|
|
103
|
+
} else {
|
|
104
|
+
const pl = await import("nodejs-polars");
|
|
105
|
+
const df = pl.DataFrame(this.searches);
|
|
106
|
+
return df;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Export to file or download
|
|
111
|
+
public writeToCsv = async (fileName: string) => {
|
|
112
|
+
await exportCsv(this.searches, fileName);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
public writeToJson = async (fileName: string) => {
|
|
116
|
+
await exportJson(this.searches, fileName);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
public writeToNdjson = async (fileName: string) => {
|
|
120
|
+
await exportNdjson(this.searches, fileName);
|
|
121
|
+
};
|
|
122
|
+
}
|