@open-mercato/search 0.4.2-canary-c02407ff85
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/AGENTS.md +678 -0
- package/build.mjs +92 -0
- package/dist/di.js +157 -0
- package/dist/di.js.map +7 -0
- package/dist/fulltext/drivers/index.js +21 -0
- package/dist/fulltext/drivers/index.js.map +7 -0
- package/dist/fulltext/drivers/meilisearch/index.js +320 -0
- package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
- package/dist/fulltext/index.js +7 -0
- package/dist/fulltext/index.js.map +7 -0
- package/dist/fulltext/types.js +1 -0
- package/dist/fulltext/types.js.map +7 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +7 -0
- package/dist/indexer/index.js +8 -0
- package/dist/indexer/index.js.map +7 -0
- package/dist/indexer/search-indexer.js +848 -0
- package/dist/indexer/search-indexer.js.map +7 -0
- package/dist/indexer/subscribers/delete.js +41 -0
- package/dist/indexer/subscribers/delete.js.map +7 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/debug.js.map +7 -0
- package/dist/lib/fallback-presenter.js +107 -0
- package/dist/lib/fallback-presenter.js.map +7 -0
- package/dist/lib/field-policy.js +75 -0
- package/dist/lib/field-policy.js.map +7 -0
- package/dist/lib/index.js +19 -0
- package/dist/lib/index.js.map +7 -0
- package/dist/lib/merger.js +93 -0
- package/dist/lib/merger.js.map +7 -0
- package/dist/lib/presenter-enricher.js +192 -0
- package/dist/lib/presenter-enricher.js.map +7 -0
- package/dist/modules/search/acl.js +14 -0
- package/dist/modules/search/acl.js.map +7 -0
- package/dist/modules/search/ai-tools.js +284 -0
- package/dist/modules/search/ai-tools.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
- package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/route.js +246 -0
- package/dist/modules/search/api/embeddings/route.js.map +7 -0
- package/dist/modules/search/api/index/route.js +245 -0
- package/dist/modules/search/api/index/route.js.map +7 -0
- package/dist/modules/search/api/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/reindex/route.js +332 -0
- package/dist/modules/search/api/reindex/route.js.map +7 -0
- package/dist/modules/search/api/search/global/route.js +100 -0
- package/dist/modules/search/api/search/global/route.js.map +7 -0
- package/dist/modules/search/api/search/route.js +101 -0
- package/dist/modules/search/api/search/route.js.map +7 -0
- package/dist/modules/search/api/settings/fulltext/route.js +55 -0
- package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
- package/dist/modules/search/api/settings/global-search/route.js +80 -0
- package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
- package/dist/modules/search/api/settings/route.js +118 -0
- package/dist/modules/search/api/settings/route.js.map +7 -0
- package/dist/modules/search/api/settings/vector-store/route.js +77 -0
- package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.js +10 -0
- package/dist/modules/search/backend/config/search/page.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.meta.js +24 -0
- package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
- package/dist/modules/search/cli.js +698 -0
- package/dist/modules/search/cli.js.map +7 -0
- package/dist/modules/search/di.js +32 -0
- package/dist/modules/search/di.js.map +7 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/index.js +9 -0
- package/dist/modules/search/frontend/index.js.map +7 -0
- package/dist/modules/search/frontend/utils.js +41 -0
- package/dist/modules/search/frontend/utils.js.map +7 -0
- package/dist/modules/search/i18n/de.json +61 -0
- package/dist/modules/search/i18n/en.json +72 -0
- package/dist/modules/search/i18n/es.json +61 -0
- package/dist/modules/search/i18n/pl.json +61 -0
- package/dist/modules/search/index.js +11 -0
- package/dist/modules/search/index.js.map +7 -0
- package/dist/modules/search/lib/auto-indexing.js +29 -0
- package/dist/modules/search/lib/auto-indexing.js.map +7 -0
- package/dist/modules/search/lib/embedding-config.js +131 -0
- package/dist/modules/search/lib/embedding-config.js.map +7 -0
- package/dist/modules/search/lib/global-search-config.js +45 -0
- package/dist/modules/search/lib/global-search-config.js.map +7 -0
- package/dist/modules/search/lib/reindex-lock.js +99 -0
- package/dist/modules/search/lib/reindex-lock.js.map +7 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
- package/dist/modules/search/subscribers/vector_delete.js +58 -0
- package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
- package/dist/modules/search/subscribers/vector_purge.js +142 -0
- package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
- package/dist/modules/search/subscribers/vector_upsert.js +58 -0
- package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
- package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
- package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
- package/dist/modules/search/workers/vector-index.worker.js +234 -0
- package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
- package/dist/queue/fulltext-indexing.js +15 -0
- package/dist/queue/fulltext-indexing.js.map +7 -0
- package/dist/queue/index.js +3 -0
- package/dist/queue/index.js.map +7 -0
- package/dist/queue/vector-indexing.js +15 -0
- package/dist/queue/vector-indexing.js.map +7 -0
- package/dist/service.js +286 -0
- package/dist/service.js.map +7 -0
- package/dist/strategies/fulltext.strategy.js +116 -0
- package/dist/strategies/fulltext.strategy.js.map +7 -0
- package/dist/strategies/index.js +12 -0
- package/dist/strategies/index.js.map +7 -0
- package/dist/strategies/token.strategy.js +80 -0
- package/dist/strategies/token.strategy.js.map +7 -0
- package/dist/strategies/vector.strategy.js +137 -0
- package/dist/strategies/vector.strategy.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/dist/vector/drivers/chromadb/index.js +44 -0
- package/dist/vector/drivers/chromadb/index.js.map +7 -0
- package/dist/vector/drivers/index.js +9 -0
- package/dist/vector/drivers/index.js.map +7 -0
- package/dist/vector/drivers/pgvector/index.js +509 -0
- package/dist/vector/drivers/pgvector/index.js.map +7 -0
- package/dist/vector/drivers/qdrant/index.js +44 -0
- package/dist/vector/drivers/qdrant/index.js.map +7 -0
- package/dist/vector/index.js +4 -0
- package/dist/vector/index.js.map +7 -0
- package/dist/vector/lib/vector-logs.js +33 -0
- package/dist/vector/lib/vector-logs.js.map +7 -0
- package/dist/vector/services/checksum.js +20 -0
- package/dist/vector/services/checksum.js.map +7 -0
- package/dist/vector/services/embedding.js +222 -0
- package/dist/vector/services/embedding.js.map +7 -0
- package/dist/vector/services/index.js +4 -0
- package/dist/vector/services/index.js.map +7 -0
- package/dist/vector/services/vector-index.service.js +960 -0
- package/dist/vector/services/vector-index.service.js.map +7 -0
- package/dist/vector/types/pg.d.js +1 -0
- package/dist/vector/types/pg.d.js.map +7 -0
- package/dist/vector/types.js +75 -0
- package/dist/vector/types.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +142 -0
- package/src/__tests__/queue.test.ts +148 -0
- package/src/__tests__/service.test.ts +345 -0
- package/src/__tests__/workers.test.ts +319 -0
- package/src/di.ts +291 -0
- package/src/fulltext/drivers/index.ts +41 -0
- package/src/fulltext/drivers/meilisearch/index.ts +410 -0
- package/src/fulltext/index.ts +13 -0
- package/src/fulltext/types.ts +115 -0
- package/src/index.ts +36 -0
- package/src/indexer/index.ts +13 -0
- package/src/indexer/search-indexer.ts +1141 -0
- package/src/indexer/subscribers/delete.ts +49 -0
- package/src/lib/debug.ts +46 -0
- package/src/lib/fallback-presenter.ts +106 -0
- package/src/lib/field-policy.ts +169 -0
- package/src/lib/index.ts +13 -0
- package/src/lib/merger.ts +159 -0
- package/src/lib/presenter-enricher.ts +323 -0
- package/src/modules/search/README.md +694 -0
- package/src/modules/search/acl.ts +10 -0
- package/src/modules/search/ai-tools.ts +467 -0
- package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
- package/src/modules/search/api/embeddings/route.ts +304 -0
- package/src/modules/search/api/index/route.ts +297 -0
- package/src/modules/search/api/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/reindex/route.ts +419 -0
- package/src/modules/search/api/search/global/route.ts +120 -0
- package/src/modules/search/api/search/route.ts +121 -0
- package/src/modules/search/api/settings/fulltext/route.ts +82 -0
- package/src/modules/search/api/settings/global-search/route.ts +91 -0
- package/src/modules/search/api/settings/route.ts +187 -0
- package/src/modules/search/api/settings/vector-store/route.ts +105 -0
- package/src/modules/search/backend/config/search/page.meta.ts +22 -0
- package/src/modules/search/backend/config/search/page.tsx +12 -0
- package/src/modules/search/cli.ts +818 -0
- package/src/modules/search/di.ts +50 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
- package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
- package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
- package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
- package/src/modules/search/frontend/index.ts +3 -0
- package/src/modules/search/frontend/utils.ts +82 -0
- package/src/modules/search/i18n/de.json +61 -0
- package/src/modules/search/i18n/en.json +72 -0
- package/src/modules/search/i18n/es.json +61 -0
- package/src/modules/search/i18n/pl.json +61 -0
- package/src/modules/search/index.ts +9 -0
- package/src/modules/search/lib/auto-indexing.ts +35 -0
- package/src/modules/search/lib/embedding-config.ts +161 -0
- package/src/modules/search/lib/global-search-config.ts +69 -0
- package/src/modules/search/lib/reindex-lock.ts +201 -0
- package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
- package/src/modules/search/subscribers/vector_delete.ts +75 -0
- package/src/modules/search/subscribers/vector_purge.ts +161 -0
- package/src/modules/search/subscribers/vector_upsert.ts +75 -0
- package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
- package/src/modules/search/workers/vector-index.worker.ts +292 -0
- package/src/queue/fulltext-indexing.ts +87 -0
- package/src/queue/index.ts +2 -0
- package/src/queue/vector-indexing.ts +66 -0
- package/src/service.ts +397 -0
- package/src/strategies/fulltext.strategy.ts +155 -0
- package/src/strategies/index.ts +17 -0
- package/src/strategies/token.strategy.ts +153 -0
- package/src/strategies/vector.strategy.ts +234 -0
- package/src/types.ts +38 -0
- package/src/vector/drivers/chromadb/index.ts +49 -0
- package/src/vector/drivers/index.ts +4 -0
- package/src/vector/drivers/pgvector/index.ts +627 -0
- package/src/vector/drivers/qdrant/index.ts +49 -0
- package/src/vector/index.ts +3 -0
- package/src/vector/lib/vector-logs.ts +46 -0
- package/src/vector/services/checksum.ts +18 -0
- package/src/vector/services/embedding.ts +275 -0
- package/src/vector/services/index.ts +3 -0
- package/src/vector/services/vector-index.service.ts +1234 -0
- package/src/vector/types/pg.d.ts +1 -0
- package/src/vector/types.ts +220 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { decryptIndexDocForSearch } from "@open-mercato/shared/lib/encryption/indexDoc";
|
|
2
|
+
import { extractFallbackPresenter } from "./fallback-presenter.js";
|
|
3
|
+
const BATCH_SIZE = 500;
|
|
4
|
+
const logWarning = (message, context) => {
|
|
5
|
+
if (process.env.NODE_ENV === "development" || process.env.DEBUG_SEARCH_ENRICHER) {
|
|
6
|
+
console.warn(`[search:presenter-enricher] ${message}`, context ?? "");
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function looksEncrypted(value) {
|
|
10
|
+
if (typeof value !== "string") return false;
|
|
11
|
+
if (!value.includes(":")) return false;
|
|
12
|
+
const parts = value.split(":");
|
|
13
|
+
return parts.length >= 3 && parts[parts.length - 1] === "v1";
|
|
14
|
+
}
|
|
15
|
+
function needsEnrichment(result) {
|
|
16
|
+
if (!result.presenter?.title) return true;
|
|
17
|
+
if (looksEncrypted(result.presenter.title)) return true;
|
|
18
|
+
if (looksEncrypted(result.presenter.subtitle)) return true;
|
|
19
|
+
if (!result.url && (!result.links || result.links.length === 0)) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
function chunk(array, size) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for (let i = 0; i < array.length; i += size) {
|
|
25
|
+
chunks.push(array.slice(i, i + size));
|
|
26
|
+
}
|
|
27
|
+
return chunks;
|
|
28
|
+
}
|
|
29
|
+
async function fetchDocsBatch(knex, byEntityType, tenantId, organizationId) {
|
|
30
|
+
const allDocs = [];
|
|
31
|
+
const allPairs = [];
|
|
32
|
+
for (const [entityType, results] of byEntityType) {
|
|
33
|
+
for (const result of results) {
|
|
34
|
+
allPairs.push({ entityType, recordId: result.recordId });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (allPairs.length === 0) return allDocs;
|
|
38
|
+
const chunks = chunk(allPairs, BATCH_SIZE);
|
|
39
|
+
for (const pairChunk of chunks) {
|
|
40
|
+
const chunkByType = /* @__PURE__ */ new Map();
|
|
41
|
+
for (const { entityType, recordId } of pairChunk) {
|
|
42
|
+
const ids = chunkByType.get(entityType) ?? [];
|
|
43
|
+
ids.push(recordId);
|
|
44
|
+
chunkByType.set(entityType, ids);
|
|
45
|
+
}
|
|
46
|
+
const query = knex("entity_indexes").select("entity_type", "entity_id", "doc").where("tenant_id", tenantId).whereNull("deleted_at").where((builder) => {
|
|
47
|
+
for (const [entityType, recordIds] of chunkByType) {
|
|
48
|
+
builder.orWhere((sub) => {
|
|
49
|
+
sub.where("entity_type", entityType).whereIn("entity_id", recordIds);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (organizationId) {
|
|
54
|
+
query.where((builder) => {
|
|
55
|
+
builder.where("organization_id", organizationId).orWhereNull("organization_id");
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const rows = await query;
|
|
59
|
+
allDocs.push(...rows);
|
|
60
|
+
}
|
|
61
|
+
return allDocs;
|
|
62
|
+
}
|
|
63
|
+
async function computePresenterAndLinks(doc, entityId, recordId, config, tenantId, organizationId, queryEngine) {
|
|
64
|
+
let presenter = null;
|
|
65
|
+
let url;
|
|
66
|
+
let links;
|
|
67
|
+
const customFields = {};
|
|
68
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
69
|
+
if (key.startsWith("cf:") || key.startsWith("cf_")) {
|
|
70
|
+
customFields[key.slice(3)] = value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const buildContext = {
|
|
74
|
+
record: doc,
|
|
75
|
+
customFields,
|
|
76
|
+
organizationId,
|
|
77
|
+
tenantId,
|
|
78
|
+
queryEngine
|
|
79
|
+
};
|
|
80
|
+
if (config?.formatResult || config?.buildSource) {
|
|
81
|
+
if (config.buildSource) {
|
|
82
|
+
try {
|
|
83
|
+
const source = await config.buildSource(buildContext);
|
|
84
|
+
if (source?.presenter) presenter = source.presenter;
|
|
85
|
+
if (source?.links) links = source.links;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!presenter && config.formatResult) {
|
|
91
|
+
try {
|
|
92
|
+
presenter = await config.formatResult(buildContext) ?? null;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!presenter) {
|
|
99
|
+
presenter = extractFallbackPresenter(doc, entityId, recordId);
|
|
100
|
+
}
|
|
101
|
+
if (config?.resolveUrl) {
|
|
102
|
+
try {
|
|
103
|
+
url = await config.resolveUrl(buildContext) ?? void 0;
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!links && config?.resolveLinks) {
|
|
108
|
+
try {
|
|
109
|
+
links = await config.resolveLinks(buildContext) ?? void 0;
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { presenter, url, links };
|
|
114
|
+
}
|
|
115
|
+
function createPresenterEnricher(knex, entityConfigMap, queryEngine, encryptionService) {
|
|
116
|
+
return async (results, tenantId, organizationId) => {
|
|
117
|
+
const missingResults = results.filter(needsEnrichment);
|
|
118
|
+
if (missingResults.length === 0) return results;
|
|
119
|
+
const byEntityType = /* @__PURE__ */ new Map();
|
|
120
|
+
for (const result of missingResults) {
|
|
121
|
+
const group = byEntityType.get(result.entityId) ?? [];
|
|
122
|
+
group.push(result);
|
|
123
|
+
byEntityType.set(result.entityId, group);
|
|
124
|
+
}
|
|
125
|
+
const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId);
|
|
126
|
+
const dekCache = /* @__PURE__ */ new Map();
|
|
127
|
+
const decryptedDocs = await Promise.all(
|
|
128
|
+
rawDocs.map(async (row) => {
|
|
129
|
+
try {
|
|
130
|
+
const docData = row.doc;
|
|
131
|
+
const docOrgId = docData.organization_id ?? organizationId;
|
|
132
|
+
const scope = { tenantId, organizationId: docOrgId };
|
|
133
|
+
const decryptedDoc = await decryptIndexDocForSearch(
|
|
134
|
+
row.entity_type,
|
|
135
|
+
row.doc,
|
|
136
|
+
scope,
|
|
137
|
+
encryptionService ?? null,
|
|
138
|
+
dekCache
|
|
139
|
+
);
|
|
140
|
+
return { ...row, doc: decryptedDoc };
|
|
141
|
+
} catch (err) {
|
|
142
|
+
logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) });
|
|
143
|
+
return row;
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
const docMap = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const row of decryptedDocs) {
|
|
149
|
+
docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc);
|
|
150
|
+
}
|
|
151
|
+
const enrichmentPromises = missingResults.map(async (result) => {
|
|
152
|
+
const key = `${result.entityId}:${result.recordId}`;
|
|
153
|
+
const doc = docMap.get(key);
|
|
154
|
+
if (!doc) {
|
|
155
|
+
logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId });
|
|
156
|
+
return { key, presenter: null, url: void 0, links: void 0 };
|
|
157
|
+
}
|
|
158
|
+
const config = entityConfigMap.get(result.entityId);
|
|
159
|
+
const enrichment = await computePresenterAndLinks(
|
|
160
|
+
doc,
|
|
161
|
+
result.entityId,
|
|
162
|
+
result.recordId,
|
|
163
|
+
config,
|
|
164
|
+
tenantId,
|
|
165
|
+
organizationId,
|
|
166
|
+
queryEngine
|
|
167
|
+
);
|
|
168
|
+
return { key, ...enrichment };
|
|
169
|
+
});
|
|
170
|
+
const computed = await Promise.all(enrichmentPromises);
|
|
171
|
+
const enrichmentMap = /* @__PURE__ */ new Map();
|
|
172
|
+
for (const { key, presenter, url, links } of computed) {
|
|
173
|
+
enrichmentMap.set(key, { presenter, url, links });
|
|
174
|
+
}
|
|
175
|
+
return results.map((result) => {
|
|
176
|
+
if (!needsEnrichment(result)) return result;
|
|
177
|
+
const key = `${result.entityId}:${result.recordId}`;
|
|
178
|
+
const enriched = enrichmentMap.get(key);
|
|
179
|
+
if (!enriched) return result;
|
|
180
|
+
return {
|
|
181
|
+
...result,
|
|
182
|
+
presenter: enriched.presenter ?? result.presenter,
|
|
183
|
+
url: result.url ?? enriched.url,
|
|
184
|
+
links: result.links ?? enriched.links
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export {
|
|
190
|
+
createPresenterEnricher
|
|
191
|
+
};
|
|
192
|
+
//# sourceMappingURL=presenter-enricher.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/presenter-enricher.ts"],
|
|
4
|
+
"sourcesContent": ["import type { Knex } from 'knex'\nimport type {\n SearchBuildContext,\n SearchResult,\n SearchResultPresenter,\n SearchResultLink,\n SearchEntityConfig,\n PresenterEnricherFn,\n} from '../types'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { EntityId } from '@open-mercato/shared/modules/entities'\nimport type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'\nimport { decryptIndexDocForSearch } from '@open-mercato/shared/lib/encryption/indexDoc'\nimport { extractFallbackPresenter } from './fallback-presenter'\n\n/** Maximum number of record IDs per batch query to avoid hitting DB parameter limits */\nconst BATCH_SIZE = 500\n\n/** Logger for debugging - uses console.warn to surface issues without breaking flow */\nconst logWarning = (message: string, context?: Record<string, unknown>) => {\n if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SEARCH_ENRICHER) {\n console.warn(`[search:presenter-enricher] ${message}`, context ?? '')\n }\n}\n\n/**\n * Check if a string looks like an encrypted value.\n * Encrypted format: iv:ciphertext:authTag:v1\n */\nfunction looksEncrypted(value: unknown): boolean {\n if (typeof value !== 'string') return false\n if (!value.includes(':')) return false\n const parts = value.split(':')\n // Encrypted strings end with :v1 and have at least 3 colon-separated parts\n return parts.length >= 3 && parts[parts.length - 1] === 'v1'\n}\n\n/**\n * Check if a result needs enrichment (missing presenter, encrypted values, or missing URL/links)\n */\nfunction needsEnrichment(result: SearchResult): boolean {\n if (!result.presenter?.title) return true\n // Also re-enrich if presenter looks encrypted\n if (looksEncrypted(result.presenter.title)) return true\n if (looksEncrypted(result.presenter.subtitle)) return true\n // Also enrich if missing URL/links (needed for token search results)\n if (!result.url && (!result.links || result.links.length === 0)) return true\n return false\n}\n\n/**\n * Split an array into chunks of specified size.\n */\nfunction chunk<T>(array: T[], size: number): T[][] {\n const chunks: T[][] = []\n for (let i = 0; i < array.length; i += size) {\n chunks.push(array.slice(i, i + size))\n }\n return chunks\n}\n\n/**\n * Build a single batch query for multiple entity types and their record IDs.\n * Uses OR conditions to fetch all needed docs in one round trip.\n */\nasync function fetchDocsBatch(\n knex: Knex,\n byEntityType: Map<string, SearchResult[]>,\n tenantId: string,\n organizationId?: string | null,\n): Promise<Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }>> {\n const allDocs: Array<{ entity_type: string; entity_id: string; doc: Record<string, unknown> }> = []\n\n // Collect all entity type + record ID pairs\n const allPairs: Array<{ entityType: string; recordId: string }> = []\n for (const [entityType, results] of byEntityType) {\n for (const result of results) {\n allPairs.push({ entityType, recordId: result.recordId })\n }\n }\n\n if (allPairs.length === 0) return allDocs\n\n // Process in chunks to avoid hitting DB parameter limits\n const chunks = chunk(allPairs, BATCH_SIZE)\n\n for (const pairChunk of chunks) {\n // Group by entity type within this chunk for efficient OR query\n const chunkByType = new Map<string, string[]>()\n for (const { entityType, recordId } of pairChunk) {\n const ids = chunkByType.get(entityType) ?? []\n ids.push(recordId)\n chunkByType.set(entityType, ids)\n }\n\n // Build query with OR conditions per entity type\n const query = knex('entity_indexes')\n .select('entity_type', 'entity_id', 'doc')\n .where('tenant_id', tenantId)\n .whereNull('deleted_at')\n .where((builder) => {\n for (const [entityType, recordIds] of chunkByType) {\n builder.orWhere((sub) => {\n sub.where('entity_type', entityType).whereIn('entity_id', recordIds)\n })\n }\n })\n\n // Add organization filter if provided\n if (organizationId) {\n query.where((builder) => {\n builder.where('organization_id', organizationId).orWhereNull('organization_id')\n })\n }\n\n const rows = await query\n allDocs.push(...(rows as typeof allDocs))\n }\n\n return allDocs\n}\n\n/** Result type for presenter and links computation */\ntype EnrichmentResult = {\n presenter: SearchResultPresenter | null\n url?: string\n links?: SearchResultLink[]\n}\n\n/**\n * Compute presenter, URL, and links for a single doc using config or fallback.\n * Returns presenter (null if cannot be computed), and optionally URL/links from config.\n */\nasync function computePresenterAndLinks(\n doc: Record<string, unknown>,\n entityId: string,\n recordId: string,\n config: SearchEntityConfig | undefined,\n tenantId: string,\n organizationId: string | null | undefined,\n queryEngine: QueryEngine | undefined,\n): Promise<EnrichmentResult> {\n let presenter: SearchResultPresenter | null = null\n let url: string | undefined\n let links: SearchResultLink[] | undefined\n\n // Build context for config functions\n const customFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(doc)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.slice(3)] = value\n }\n }\n\n const buildContext: SearchBuildContext = {\n record: doc,\n customFields,\n organizationId,\n tenantId,\n queryEngine,\n }\n\n // If search.ts config exists, use formatResult/buildSource for presenter\n if (config?.formatResult || config?.buildSource) {\n if (config.buildSource) {\n try {\n const source = await config.buildSource(buildContext)\n if (source?.presenter) presenter = source.presenter\n if (source?.links) links = source.links\n } catch (err) {\n logWarning(`buildSource failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n\n if (!presenter && config.formatResult) {\n try {\n presenter = (await config.formatResult(buildContext)) ?? null\n } catch (err) {\n logWarning(`formatResult failed for ${entityId}:${recordId}`, { error: String(err) })\n }\n }\n }\n\n // Fallback presenter: extract from doc fields directly\n if (!presenter) {\n presenter = extractFallbackPresenter(doc, entityId, recordId)\n }\n\n // Resolve URL from config\n if (config?.resolveUrl) {\n try {\n url = (await config.resolveUrl(buildContext)) ?? undefined\n } catch {\n // Skip URL resolution errors\n }\n }\n\n // Resolve links from config (if not already set from buildSource)\n if (!links && config?.resolveLinks) {\n try {\n links = (await config.resolveLinks(buildContext)) ?? undefined\n } catch {\n // Skip link resolution errors\n }\n }\n\n return { presenter, url, links }\n}\n\n/**\n * Create a presenter enricher that loads data from entity_indexes and computes presenter.\n * Uses formatResult from search.ts configs when available, otherwise falls back to extracting\n * common fields like display_name, name, title from the doc.\n *\n * Optimizations:\n * - Single batch DB query for all entity types (instead of one per type)\n * - Parallel Promise.all for formatResult/buildSource calls\n * - Tenant/organization scoping for security\n * - Chunked queries to avoid DB parameter limits\n * - Automatic decryption of encrypted fields when encryption service is provided\n */\nexport function createPresenterEnricher(\n knex: Knex,\n entityConfigMap: Map<EntityId, SearchEntityConfig>,\n queryEngine?: QueryEngine,\n encryptionService?: TenantDataEncryptionService | null,\n): PresenterEnricherFn {\n return async (results, tenantId, organizationId) => {\n // Find results missing presenter OR with encrypted presenter\n const missingResults = results.filter(needsEnrichment)\n if (missingResults.length === 0) return results\n\n // Group by entity type for config lookup\n const byEntityType = new Map<string, SearchResult[]>()\n for (const result of missingResults) {\n const group = byEntityType.get(result.entityId) ?? []\n group.push(result)\n byEntityType.set(result.entityId, group)\n }\n\n // Single batch query for all docs across all entity types\n const rawDocs = await fetchDocsBatch(knex, byEntityType, tenantId, organizationId)\n\n // Decrypt docs in parallel using DEK cache for efficiency\n const dekCache = new Map<string | null, string | null>()\n\n const decryptedDocs = await Promise.all(\n rawDocs.map(async (row) => {\n try {\n // Use organization_id from the doc itself for proper encryption map lookup\n // This is critical for global search where organizationId param is null\n const docData = row.doc as Record<string, unknown>\n const docOrgId = (docData.organization_id as string | null | undefined) ?? organizationId\n const scope = { tenantId, organizationId: docOrgId }\n\n const decryptedDoc = await decryptIndexDocForSearch(\n row.entity_type,\n row.doc,\n scope,\n encryptionService ?? null,\n dekCache,\n )\n return { ...row, doc: decryptedDoc }\n } catch (err) {\n logWarning(`Failed to decrypt doc for ${row.entity_type}:${row.entity_id}`, { error: String(err) })\n return row // Return original doc if decryption fails\n }\n }),\n )\n\n // Build doc lookup map for fast access\n const docMap = new Map<string, Record<string, unknown>>()\n for (const row of decryptedDocs) {\n docMap.set(`${row.entity_type}:${row.entity_id}`, row.doc)\n }\n\n // Compute presenters and links in parallel\n const enrichmentPromises = missingResults.map(async (result) => {\n const key = `${result.entityId}:${result.recordId}`\n const doc = docMap.get(key)\n\n if (!doc) {\n logWarning(`Doc not found in entity_indexes`, { entityId: result.entityId, recordId: result.recordId })\n return { key, presenter: null, url: undefined, links: undefined }\n }\n\n const config = entityConfigMap.get(result.entityId as EntityId)\n const enrichment = await computePresenterAndLinks(\n doc,\n result.entityId,\n result.recordId,\n config,\n tenantId,\n organizationId,\n queryEngine,\n )\n\n return { key, ...enrichment }\n })\n\n const computed = await Promise.all(enrichmentPromises)\n\n // Build enrichment map from parallel results\n const enrichmentMap = new Map<string, EnrichmentResult>()\n for (const { key, presenter, url, links } of computed) {\n enrichmentMap.set(key, { presenter, url, links })\n }\n\n // Enrich results with computed presenter, URL, and links\n return results.map((result) => {\n if (!needsEnrichment(result)) return result\n const key = `${result.entityId}:${result.recordId}`\n const enriched = enrichmentMap.get(key)\n if (!enriched) return result\n return {\n ...result,\n presenter: enriched.presenter ?? result.presenter,\n url: result.url ?? enriched.url,\n links: result.links ?? enriched.links,\n }\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const features = [
|
|
2
|
+
{ id: "search.view", title: "View search settings", module: "search" },
|
|
3
|
+
{ id: "search.manage", title: "Manage search settings", module: "search" },
|
|
4
|
+
{ id: "search.reindex", title: "Reindex search data", module: "search" },
|
|
5
|
+
{ id: "search.embeddings.view", title: "View embedding settings", module: "search" },
|
|
6
|
+
{ id: "search.embeddings.manage", title: "Manage embedding settings", module: "search" },
|
|
7
|
+
{ id: "search.global", title: "Use global search (Cmd+K)", module: "search" }
|
|
8
|
+
];
|
|
9
|
+
var acl_default = features;
|
|
10
|
+
export {
|
|
11
|
+
acl_default as default,
|
|
12
|
+
features
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=acl.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/search/acl.ts"],
|
|
4
|
+
"sourcesContent": ["export const features = [\n { id: 'search.view', title: 'View search settings', module: 'search' },\n { id: 'search.manage', title: 'Manage search settings', module: 'search' },\n { id: 'search.reindex', title: 'Reindex search data', module: 'search' },\n { id: 'search.embeddings.view', title: 'View embedding settings', module: 'search' },\n { id: 'search.embeddings.manage', title: 'Manage embedding settings', module: 'search' },\n { id: 'search.global', title: 'Use global search (Cmd+K)', module: 'search' },\n]\n\nexport default features\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,eAAe,OAAO,wBAAwB,QAAQ,SAAS;AAAA,EACrE,EAAE,IAAI,iBAAiB,OAAO,0BAA0B,QAAQ,SAAS;AAAA,EACzE,EAAE,IAAI,kBAAkB,OAAO,uBAAuB,QAAQ,SAAS;AAAA,EACvE,EAAE,IAAI,0BAA0B,OAAO,2BAA2B,QAAQ,SAAS;AAAA,EACnF,EAAE,IAAI,4BAA4B,OAAO,6BAA6B,QAAQ,SAAS;AAAA,EACvF,EAAE,IAAI,iBAAiB,OAAO,6BAA6B,QAAQ,SAAS;AAC9E;AAEA,IAAO,cAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const searchQueryTool = {
|
|
3
|
+
name: "search_query",
|
|
4
|
+
description: "Search across all data in Open Mercato. Searches customers, products, orders, and other entities using hybrid search (full-text, semantic, and keyword matching).",
|
|
5
|
+
inputSchema: z.object({
|
|
6
|
+
query: z.string().min(1).describe("The search query text"),
|
|
7
|
+
limit: z.number().int().min(1).max(100).optional().default(20).describe("Maximum number of results to return (default: 20)"),
|
|
8
|
+
entityTypes: z.array(z.string()).optional().describe(
|
|
9
|
+
'Filter to specific entity types (e.g., ["customers:customer_person_profile", "catalog:product"])'
|
|
10
|
+
),
|
|
11
|
+
strategies: z.array(z.enum(["fulltext", "vector", "tokens"])).optional().describe("Specific search strategies to use (default: all available)")
|
|
12
|
+
}),
|
|
13
|
+
requiredFeatures: ["search.global"],
|
|
14
|
+
handler: async (input, ctx) => {
|
|
15
|
+
if (!ctx.tenantId) {
|
|
16
|
+
throw new Error("Tenant context is required for search");
|
|
17
|
+
}
|
|
18
|
+
const searchService = ctx.container.resolve("searchService");
|
|
19
|
+
const results = await searchService.search(input.query, {
|
|
20
|
+
tenantId: ctx.tenantId,
|
|
21
|
+
organizationId: ctx.organizationId,
|
|
22
|
+
entityTypes: input.entityTypes,
|
|
23
|
+
strategies: input.strategies,
|
|
24
|
+
limit: input.limit
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
query: input.query,
|
|
28
|
+
totalResults: results.length,
|
|
29
|
+
results: results.map((result) => ({
|
|
30
|
+
entityType: result.entityId,
|
|
31
|
+
recordId: result.recordId,
|
|
32
|
+
score: Math.round(result.score * 100) / 100,
|
|
33
|
+
source: result.source,
|
|
34
|
+
title: result.presenter?.title ?? result.recordId,
|
|
35
|
+
subtitle: result.presenter?.subtitle,
|
|
36
|
+
url: result.url
|
|
37
|
+
}))
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const searchStatusTool = {
|
|
42
|
+
name: "search_status",
|
|
43
|
+
description: "Get the current status of the search module, including available search strategies and their availability.",
|
|
44
|
+
inputSchema: z.object({}),
|
|
45
|
+
requiredFeatures: ["search.view"],
|
|
46
|
+
handler: async (_input, ctx) => {
|
|
47
|
+
const searchService = ctx.container.resolve("searchService");
|
|
48
|
+
const strategies = searchService.getStrategies();
|
|
49
|
+
const defaultStrategies = searchService.getDefaultStrategies();
|
|
50
|
+
const strategyStatus = await Promise.all(
|
|
51
|
+
strategies.map(async (strategy) => ({
|
|
52
|
+
id: strategy.id,
|
|
53
|
+
name: strategy.name,
|
|
54
|
+
priority: strategy.priority,
|
|
55
|
+
isAvailable: await strategy.isAvailable(),
|
|
56
|
+
isDefault: defaultStrategies.includes(strategy.id)
|
|
57
|
+
}))
|
|
58
|
+
);
|
|
59
|
+
return {
|
|
60
|
+
strategiesRegistered: strategies.length,
|
|
61
|
+
defaultStrategies,
|
|
62
|
+
strategies: strategyStatus
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const searchGetTool = {
|
|
67
|
+
name: "search_get",
|
|
68
|
+
description: "Retrieve full record details by entity type and record ID. Use this after search_query to get complete data for a specific record.",
|
|
69
|
+
inputSchema: z.object({
|
|
70
|
+
entityType: z.string().describe('The entity type (e.g., "customers:customer_company_profile", "customers:customer_deal")'),
|
|
71
|
+
recordId: z.string().describe("The record ID (UUID)")
|
|
72
|
+
}),
|
|
73
|
+
requiredFeatures: ["search.view"],
|
|
74
|
+
handler: async (input, ctx) => {
|
|
75
|
+
if (!ctx.tenantId) {
|
|
76
|
+
throw new Error("Tenant context is required");
|
|
77
|
+
}
|
|
78
|
+
const queryEngine = ctx.container.resolve("queryEngine");
|
|
79
|
+
const result = await queryEngine.query(input.entityType, {
|
|
80
|
+
tenantId: ctx.tenantId,
|
|
81
|
+
organizationId: ctx.organizationId,
|
|
82
|
+
filters: { id: input.recordId },
|
|
83
|
+
includeCustomFields: true,
|
|
84
|
+
page: { page: 1, pageSize: 1 }
|
|
85
|
+
});
|
|
86
|
+
const record = result.items[0];
|
|
87
|
+
if (!record) {
|
|
88
|
+
return {
|
|
89
|
+
found: false,
|
|
90
|
+
entityType: input.entityType,
|
|
91
|
+
recordId: input.recordId,
|
|
92
|
+
error: "Record not found"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const customFields = {};
|
|
96
|
+
const standardFields = {};
|
|
97
|
+
for (const [key, value] of Object.entries(record)) {
|
|
98
|
+
if (key.startsWith("cf:") || key.startsWith("cf_")) {
|
|
99
|
+
customFields[key.replace(/^cf[:_]/, "")] = value;
|
|
100
|
+
} else {
|
|
101
|
+
standardFields[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let url = null;
|
|
105
|
+
const id = record.id ?? record.entity_id ?? input.recordId;
|
|
106
|
+
if (input.entityType.includes("person")) {
|
|
107
|
+
url = `/backend/customers/people/${id}`;
|
|
108
|
+
} else if (input.entityType.includes("company")) {
|
|
109
|
+
url = `/backend/customers/companies/${id}`;
|
|
110
|
+
} else if (input.entityType.includes("deal")) {
|
|
111
|
+
url = `/backend/customers/deals/${id}`;
|
|
112
|
+
} else if (input.entityType.includes("activity")) {
|
|
113
|
+
const entityId = record.entity_id ?? record.entityId;
|
|
114
|
+
url = entityId ? `/backend/customers/companies/${entityId}#activity-${id}` : null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
found: true,
|
|
118
|
+
entityType: input.entityType,
|
|
119
|
+
recordId: input.recordId,
|
|
120
|
+
record: standardFields,
|
|
121
|
+
customFields: Object.keys(customFields).length > 0 ? customFields : void 0,
|
|
122
|
+
url
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const searchSchemaTool = {
|
|
127
|
+
name: "search_schema",
|
|
128
|
+
description: "Discover searchable entities and their fields. Use this to learn what data can be searched and what fields are available for filtering.",
|
|
129
|
+
inputSchema: z.object({
|
|
130
|
+
entityType: z.string().optional().describe("Optional: Get schema for a specific entity type only")
|
|
131
|
+
}),
|
|
132
|
+
requiredFeatures: ["search.view"],
|
|
133
|
+
handler: async (input, ctx) => {
|
|
134
|
+
const searchIndexer = ctx.container.resolve("searchIndexer");
|
|
135
|
+
const allConfigs = searchIndexer.getAllEntityConfigs();
|
|
136
|
+
const entities = [];
|
|
137
|
+
for (const entityConfig of allConfigs) {
|
|
138
|
+
if (input.entityType && entityConfig.entityId !== input.entityType) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
entities.push({
|
|
142
|
+
entityId: entityConfig.entityId,
|
|
143
|
+
enabled: entityConfig.enabled !== false,
|
|
144
|
+
priority: entityConfig.priority ?? 5,
|
|
145
|
+
strategies: entityConfig.strategies,
|
|
146
|
+
searchableFields: entityConfig.fieldPolicy?.searchable,
|
|
147
|
+
hashOnlyFields: entityConfig.fieldPolicy?.hashOnly,
|
|
148
|
+
excludedFields: entityConfig.fieldPolicy?.excluded
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (input.entityType && entities.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
found: false,
|
|
154
|
+
entityType: input.entityType,
|
|
155
|
+
error: "Entity type not configured for search"
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
totalEntities: entities.length,
|
|
160
|
+
entities: entities.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const searchAggregateTool = {
|
|
165
|
+
name: "search_aggregate",
|
|
166
|
+
description: 'Get record counts grouped by a field value. Useful for analytics like "how many deals by stage?" or "customers by status".',
|
|
167
|
+
inputSchema: z.object({
|
|
168
|
+
entityType: z.string().describe('The entity type to aggregate (e.g., "customers:customer_deal")'),
|
|
169
|
+
groupBy: z.string().describe('The field to group by (e.g., "status", "industry", "pipeline_stage")'),
|
|
170
|
+
limit: z.number().int().min(1).max(100).optional().default(20).describe("Maximum number of buckets to return (default: 20)")
|
|
171
|
+
}),
|
|
172
|
+
requiredFeatures: ["search.view"],
|
|
173
|
+
handler: async (input, ctx) => {
|
|
174
|
+
if (!ctx.tenantId) {
|
|
175
|
+
throw new Error("Tenant context is required");
|
|
176
|
+
}
|
|
177
|
+
const queryEngine = ctx.container.resolve("queryEngine");
|
|
178
|
+
const result = await queryEngine.query(input.entityType, {
|
|
179
|
+
tenantId: ctx.tenantId,
|
|
180
|
+
organizationId: ctx.organizationId,
|
|
181
|
+
page: { page: 1, pageSize: 1e3 }
|
|
182
|
+
// Fetch up to 1000 for aggregation
|
|
183
|
+
});
|
|
184
|
+
const counts = /* @__PURE__ */ new Map();
|
|
185
|
+
for (const item of result.items) {
|
|
186
|
+
const value = item[input.groupBy];
|
|
187
|
+
const key = value === null || value === void 0 ? null : String(value);
|
|
188
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
const total = result.items.length;
|
|
191
|
+
const buckets = Array.from(counts.entries()).map(([value, count]) => ({
|
|
192
|
+
value,
|
|
193
|
+
count,
|
|
194
|
+
percentage: Math.round(count / total * 100 * 100) / 100
|
|
195
|
+
})).sort((a, b) => b.count - a.count).slice(0, input.limit);
|
|
196
|
+
return {
|
|
197
|
+
entityType: input.entityType,
|
|
198
|
+
groupBy: input.groupBy,
|
|
199
|
+
total,
|
|
200
|
+
buckets
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
const searchReindexTool = {
|
|
205
|
+
name: "search_reindex",
|
|
206
|
+
description: "Trigger a reindex operation for search data. This rebuilds the search index for the specified entity type or all entities.",
|
|
207
|
+
inputSchema: z.object({
|
|
208
|
+
entityType: z.string().optional().describe(
|
|
209
|
+
'Specific entity type to reindex (e.g., "customers:customer_person_profile"). If not provided, reindexes all entities.'
|
|
210
|
+
),
|
|
211
|
+
strategy: z.enum(["fulltext", "vector"]).optional().default("fulltext").describe("Which search strategy to reindex (default: fulltext)"),
|
|
212
|
+
recreateIndex: z.boolean().optional().default(false).describe("Whether to recreate the index from scratch (default: false)")
|
|
213
|
+
}),
|
|
214
|
+
requiredFeatures: ["search.reindex"],
|
|
215
|
+
handler: async (input, ctx) => {
|
|
216
|
+
if (!ctx.tenantId) {
|
|
217
|
+
throw new Error("Tenant context is required for reindex");
|
|
218
|
+
}
|
|
219
|
+
const searchIndexer = ctx.container.resolve("searchIndexer");
|
|
220
|
+
const baseParams = {
|
|
221
|
+
tenantId: ctx.tenantId,
|
|
222
|
+
organizationId: ctx.organizationId,
|
|
223
|
+
recreateIndex: input.recreateIndex,
|
|
224
|
+
useQueue: true
|
|
225
|
+
};
|
|
226
|
+
if (input.strategy === "vector") {
|
|
227
|
+
if (input.entityType) {
|
|
228
|
+
await searchIndexer.reindexEntityToVector({
|
|
229
|
+
...baseParams,
|
|
230
|
+
entityId: input.entityType
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
status: "started",
|
|
234
|
+
strategy: "vector",
|
|
235
|
+
entityType: input.entityType,
|
|
236
|
+
message: `Vector reindex started for ${input.entityType}`
|
|
237
|
+
};
|
|
238
|
+
} else {
|
|
239
|
+
await searchIndexer.reindexAllToVector(baseParams);
|
|
240
|
+
return {
|
|
241
|
+
status: "started",
|
|
242
|
+
strategy: "vector",
|
|
243
|
+
entityType: "all",
|
|
244
|
+
message: "Vector reindex started for all entities"
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
if (input.entityType) {
|
|
249
|
+
const result = await searchIndexer.reindexEntityToFulltext({
|
|
250
|
+
...baseParams,
|
|
251
|
+
entityId: input.entityType
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
status: "completed",
|
|
255
|
+
strategy: "fulltext",
|
|
256
|
+
entityType: input.entityType,
|
|
257
|
+
...result
|
|
258
|
+
};
|
|
259
|
+
} else {
|
|
260
|
+
const result = await searchIndexer.reindexAllToFulltext(baseParams);
|
|
261
|
+
return {
|
|
262
|
+
status: "completed",
|
|
263
|
+
strategy: "fulltext",
|
|
264
|
+
entityType: "all",
|
|
265
|
+
...result
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const aiTools = [
|
|
272
|
+
searchQueryTool,
|
|
273
|
+
searchStatusTool,
|
|
274
|
+
searchGetTool,
|
|
275
|
+
searchSchemaTool,
|
|
276
|
+
searchAggregateTool,
|
|
277
|
+
searchReindexTool
|
|
278
|
+
];
|
|
279
|
+
var ai_tools_default = aiTools;
|
|
280
|
+
export {
|
|
281
|
+
aiTools,
|
|
282
|
+
ai_tools_default as default
|
|
283
|
+
};
|
|
284
|
+
//# sourceMappingURL=ai-tools.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/modules/search/ai-tools.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { SearchResult, SearchStrategyId } from '@open-mercato/shared/modules/search'\n\n/**\n * AI Tools definitions for the Search module.\n *\n * These tool definitions are discovered by the ai-assistant module's generator\n * and registered as MCP tools. The search module does not depend on ai-assistant.\n *\n * Tool Definition Format:\n * - name: Unique tool identifier (module_action format, no dots allowed)\n * - description: Human-readable description for AI clients\n * - inputSchema: Zod schema for input validation\n * - requiredFeatures: ACL features required to execute\n * - handler: Async function that executes the tool\n */\n\n/**\n * Tool context provided by the MCP server at execution time.\n */\ntype ToolContext = {\n tenantId: string | null\n organizationId: string | null\n userId: string | null\n container: {\n resolve: <T = unknown>(name: string) => T\n }\n userFeatures: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Tool definition structure.\n */\ntype AiToolDefinition = {\n name: string\n description: string\n inputSchema: z.ZodType<any>\n requiredFeatures?: string[]\n handler: (input: any, ctx: ToolContext) => Promise<unknown>\n}\n\n// =============================================================================\n// Tool Definitions\n// =============================================================================\n\nconst searchQueryTool: AiToolDefinition = {\n name: 'search_query',\n description:\n 'Search across all data in Open Mercato. Searches customers, products, orders, and other entities using hybrid search (full-text, semantic, and keyword matching).',\n inputSchema: z.object({\n query: z.string().min(1).describe('The search query text'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of results to return (default: 20)'),\n entityTypes: z\n .array(z.string())\n .optional()\n .describe(\n 'Filter to specific entity types (e.g., [\"customers:customer_person_profile\", \"catalog:product\"])'\n ),\n strategies: z\n .array(z.enum(['fulltext', 'vector', 'tokens']))\n .optional()\n .describe('Specific search strategies to use (default: all available)'),\n }),\n requiredFeatures: ['search.global'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for search')\n }\n\n const searchService = ctx.container.resolve<{\n search: (query: string, options: any) => Promise<SearchResult[]>\n }>('searchService')\n\n const results = await searchService.search(input.query, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n entityTypes: input.entityTypes,\n strategies: input.strategies as SearchStrategyId[],\n limit: input.limit,\n })\n\n return {\n query: input.query,\n totalResults: results.length,\n results: results.map((result) => ({\n entityType: result.entityId,\n recordId: result.recordId,\n score: Math.round(result.score * 100) / 100,\n source: result.source,\n title: result.presenter?.title ?? result.recordId,\n subtitle: result.presenter?.subtitle,\n url: result.url,\n })),\n }\n },\n}\n\nconst searchStatusTool: AiToolDefinition = {\n name: 'search_status',\n description:\n 'Get the current status of the search module, including available search strategies and their availability.',\n inputSchema: z.object({}),\n requiredFeatures: ['search.view'],\n handler: async (_input, ctx) => {\n const searchService = ctx.container.resolve<{\n getStrategies: () => Array<{\n id: string\n name: string\n priority: number\n isAvailable: () => Promise<boolean>\n }>\n getDefaultStrategies: () => string[]\n }>('searchService')\n\n const strategies = searchService.getStrategies()\n const defaultStrategies = searchService.getDefaultStrategies()\n\n const strategyStatus = await Promise.all(\n strategies.map(async (strategy) => ({\n id: strategy.id,\n name: strategy.name,\n priority: strategy.priority,\n isAvailable: await strategy.isAvailable(),\n isDefault: defaultStrategies.includes(strategy.id),\n }))\n )\n\n return {\n strategiesRegistered: strategies.length,\n defaultStrategies,\n strategies: strategyStatus,\n }\n },\n}\n\n// =============================================================================\n// search.get - Retrieve full record details by entity type and ID\n// =============================================================================\n\nconst searchGetTool: AiToolDefinition = {\n name: 'search_get',\n description:\n 'Retrieve full record details by entity type and record ID. Use this after search_query to get complete data for a specific record.',\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type (e.g., \"customers:customer_company_profile\", \"customers:customer_deal\")'),\n recordId: z.string().describe('The record ID (UUID)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n filters: { id: input.recordId },\n includeCustomFields: true,\n page: { page: 1, pageSize: 1 },\n })\n\n const record = result.items[0] as Record<string, unknown> | undefined\n if (!record) {\n return {\n found: false,\n entityType: input.entityType,\n recordId: input.recordId,\n error: 'Record not found',\n }\n }\n\n // Extract custom fields\n const customFields: Record<string, unknown> = {}\n const standardFields: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(record)) {\n if (key.startsWith('cf:') || key.startsWith('cf_')) {\n customFields[key.replace(/^cf[:_]/, '')] = value\n } else {\n standardFields[key] = value\n }\n }\n\n // Build URL based on entity type\n let url: string | null = null\n const id = record.id ?? record.entity_id ?? input.recordId\n if (input.entityType.includes('person')) {\n url = `/backend/customers/people/${id}`\n } else if (input.entityType.includes('company')) {\n url = `/backend/customers/companies/${id}`\n } else if (input.entityType.includes('deal')) {\n url = `/backend/customers/deals/${id}`\n } else if (input.entityType.includes('activity')) {\n const entityId = record.entity_id ?? record.entityId\n url = entityId ? `/backend/customers/companies/${entityId}#activity-${id}` : null\n }\n\n return {\n found: true,\n entityType: input.entityType,\n recordId: input.recordId,\n record: standardFields,\n customFields: Object.keys(customFields).length > 0 ? customFields : undefined,\n url,\n }\n },\n}\n\n// =============================================================================\n// search.schema - Discover searchable entities and their fields\n// =============================================================================\n\nconst searchSchemaTool: AiToolDefinition = {\n name: 'search_schema',\n description:\n 'Discover searchable entities and their fields. Use this to learn what data can be searched and what fields are available for filtering.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe('Optional: Get schema for a specific entity type only'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n const searchIndexer = ctx.container.resolve<{\n getAllEntityConfigs: () => Array<{\n entityId: string\n enabled?: boolean\n priority?: number\n strategies?: string[]\n fieldPolicy?: {\n searchable?: string[]\n hashOnly?: string[]\n excluded?: string[]\n }\n }>\n }>('searchIndexer')\n\n const allConfigs = searchIndexer.getAllEntityConfigs()\n const entities: Array<{\n entityId: string\n enabled: boolean\n priority: number\n strategies?: string[]\n searchableFields?: string[]\n hashOnlyFields?: string[]\n excludedFields?: string[]\n }> = []\n\n for (const entityConfig of allConfigs) {\n if (input.entityType && entityConfig.entityId !== input.entityType) {\n continue\n }\n\n entities.push({\n entityId: entityConfig.entityId,\n enabled: entityConfig.enabled !== false,\n priority: entityConfig.priority ?? 5,\n strategies: entityConfig.strategies,\n searchableFields: entityConfig.fieldPolicy?.searchable,\n hashOnlyFields: entityConfig.fieldPolicy?.hashOnly,\n excludedFields: entityConfig.fieldPolicy?.excluded,\n })\n }\n\n if (input.entityType && entities.length === 0) {\n return {\n found: false,\n entityType: input.entityType,\n error: 'Entity type not configured for search',\n }\n }\n\n return {\n totalEntities: entities.length,\n entities: entities.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)),\n }\n },\n}\n\n// =============================================================================\n// search.aggregate - Get counts grouped by field values\n// =============================================================================\n\nconst searchAggregateTool: AiToolDefinition = {\n name: 'search_aggregate',\n description:\n 'Get record counts grouped by a field value. Useful for analytics like \"how many deals by stage?\" or \"customers by status\".',\n inputSchema: z.object({\n entityType: z\n .string()\n .describe('The entity type to aggregate (e.g., \"customers:customer_deal\")'),\n groupBy: z\n .string()\n .describe('The field to group by (e.g., \"status\", \"industry\", \"pipeline_stage\")'),\n limit: z\n .number()\n .int()\n .min(1)\n .max(100)\n .optional()\n .default(20)\n .describe('Maximum number of buckets to return (default: 20)'),\n }),\n requiredFeatures: ['search.view'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required')\n }\n\n const queryEngine = ctx.container.resolve<{\n query: (entityId: string, options: any) => Promise<{ items: unknown[]; total: number }>\n }>('queryEngine')\n\n // Fetch records and aggregate in memory\n // Note: For large datasets, this should use database GROUP BY\n const result = await queryEngine.query(input.entityType, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n page: { page: 1, pageSize: 1000 }, // Fetch up to 1000 for aggregation\n })\n\n const counts = new Map<string | null, number>()\n for (const item of result.items as Record<string, unknown>[]) {\n const value = item[input.groupBy]\n const key = value === null || value === undefined ? null : String(value)\n counts.set(key, (counts.get(key) ?? 0) + 1)\n }\n\n const total = result.items.length\n const buckets = Array.from(counts.entries())\n .map(([value, count]) => ({\n value,\n count,\n percentage: Math.round((count / total) * 100 * 100) / 100,\n }))\n .sort((a, b) => b.count - a.count)\n .slice(0, input.limit)\n\n return {\n entityType: input.entityType,\n groupBy: input.groupBy,\n total,\n buckets,\n }\n },\n}\n\nconst searchReindexTool: AiToolDefinition = {\n name: 'search_reindex',\n description:\n 'Trigger a reindex operation for search data. This rebuilds the search index for the specified entity type or all entities.',\n inputSchema: z.object({\n entityType: z\n .string()\n .optional()\n .describe(\n 'Specific entity type to reindex (e.g., \"customers:customer_person_profile\"). If not provided, reindexes all entities.'\n ),\n strategy: z\n .enum(['fulltext', 'vector'])\n .optional()\n .default('fulltext')\n .describe('Which search strategy to reindex (default: fulltext)'),\n recreateIndex: z\n .boolean()\n .optional()\n .default(false)\n .describe('Whether to recreate the index from scratch (default: false)'),\n }),\n requiredFeatures: ['search.reindex'],\n handler: async (input, ctx) => {\n if (!ctx.tenantId) {\n throw new Error('Tenant context is required for reindex')\n }\n\n const searchIndexer = ctx.container.resolve<{\n reindexEntityToFulltext: (params: any) => Promise<any>\n reindexAllToFulltext: (params: any) => Promise<any>\n reindexEntityToVector: (params: any) => Promise<void>\n reindexAllToVector: (params: any) => Promise<void>\n }>('searchIndexer')\n\n const baseParams = {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n recreateIndex: input.recreateIndex,\n useQueue: true,\n }\n\n if (input.strategy === 'vector') {\n if (input.entityType) {\n await searchIndexer.reindexEntityToVector({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'started',\n strategy: 'vector',\n entityType: input.entityType,\n message: `Vector reindex started for ${input.entityType}`,\n }\n } else {\n await searchIndexer.reindexAllToVector(baseParams)\n return {\n status: 'started',\n strategy: 'vector',\n entityType: 'all',\n message: 'Vector reindex started for all entities',\n }\n }\n } else {\n if (input.entityType) {\n const result = await searchIndexer.reindexEntityToFulltext({\n ...baseParams,\n entityId: input.entityType,\n })\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: input.entityType,\n ...result,\n }\n } else {\n const result = await searchIndexer.reindexAllToFulltext(baseParams)\n return {\n status: 'completed',\n strategy: 'fulltext',\n entityType: 'all',\n ...result,\n }\n }\n }\n },\n}\n\n// =============================================================================\n// Export\n// =============================================================================\n\n/**\n * All AI tools exported by the search module.\n * Discovered by ai-assistant module's generator.\n */\nexport const aiTools = [\n searchQueryTool,\n searchStatusTool,\n searchGetTool,\n searchSchemaTool,\n searchAggregateTool,\n searchReindexTool,\n]\n\nexport default aiTools\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
3
|
+
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
|
+
import { clearReindexLock } from "../../../../lib/reindex-lock.js";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
|
+
import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
|
|
7
|
+
const metadata = {
|
|
8
|
+
POST: { requireAuth: true, requireFeatures: ["search.embeddings.manage"] }
|
|
9
|
+
};
|
|
10
|
+
async function POST(req) {
|
|
11
|
+
const { t } = await resolveTranslations();
|
|
12
|
+
const auth = await getAuthFromRequest(req);
|
|
13
|
+
if (!auth?.tenantId) {
|
|
14
|
+
return NextResponse.json({ error: t("api.errors.unauthorized", "Unauthorized") }, { status: 401 });
|
|
15
|
+
}
|
|
16
|
+
const container = await createRequestContainer();
|
|
17
|
+
const em = container.resolve("em");
|
|
18
|
+
const knex = em.getConnection().getKnex();
|
|
19
|
+
let queue;
|
|
20
|
+
try {
|
|
21
|
+
queue = container.resolve("vectorIndexQueue");
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
let jobsRemoved = 0;
|
|
25
|
+
if (queue) {
|
|
26
|
+
try {
|
|
27
|
+
const countsBefore = await queue.getJobCounts();
|
|
28
|
+
jobsRemoved = countsBefore.waiting + countsBefore.active;
|
|
29
|
+
await queue.clear();
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
await clearReindexLock(knex, auth.tenantId, "vector", auth.orgId ?? null);
|
|
34
|
+
try {
|
|
35
|
+
const em2 = container.resolve("em");
|
|
36
|
+
await recordIndexerLog(
|
|
37
|
+
{ em: em2 },
|
|
38
|
+
{
|
|
39
|
+
source: "vector",
|
|
40
|
+
handler: "api:search.embeddings.reindex.cancel",
|
|
41
|
+
message: `Cancelled vector reindex operation (${jobsRemoved} jobs removed)`,
|
|
42
|
+
tenantId: auth.tenantId,
|
|
43
|
+
organizationId: auth.orgId ?? null,
|
|
44
|
+
details: { jobsRemoved }
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const disposable = container;
|
|
51
|
+
if (typeof disposable.dispose === "function") {
|
|
52
|
+
await disposable.dispose();
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return NextResponse.json({
|
|
57
|
+
ok: true,
|
|
58
|
+
jobsRemoved
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
POST,
|
|
63
|
+
metadata
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=route.js.map
|