firebase-dataconnect-bootstrap 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -35
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +125 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +436 -34
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +137 -5
- package/dist/search-client.d.ts +40 -0
- package/dist/search-client.js +56 -0
- package/dist/types.d.ts +39 -0
- package/package.json +16 -3
package/README.md
CHANGED
|
@@ -1,31 +1,28 @@
|
|
|
1
1
|
# firebase-dataconnect-bootstrap
|
|
2
2
|
|
|
3
|
-
Firebase Data Connect
|
|
3
|
+
Firebase Data Connect と Cloud Functions のセットアップを自動化する CLI です。
|
|
4
|
+
初期作成だけでなく、再実行で設定変更(途中更新)もできます。
|
|
4
5
|
|
|
5
6
|
## できること
|
|
6
7
|
|
|
7
|
-
-
|
|
8
|
-
- `dataconnect/`
|
|
9
|
-
- `functions/`
|
|
10
|
-
- Firestore `onDocumentWritten`
|
|
11
|
-
-
|
|
8
|
+
- `.firebaserc` と `firebase.json` の生成/更新
|
|
9
|
+
- `dataconnect/` 初期ファイル生成
|
|
10
|
+
- `functions/` 未初期化時の最小構成作成
|
|
11
|
+
- Firestore `onDocumentWritten` の生成/更新
|
|
12
|
+
- ベクトル検索用 `onRequest` 関数の生成/更新
|
|
13
|
+
- 埋め込み実装差し替え用 `vectorSearchEmbedding` テンプレート生成
|
|
14
|
+
- 前回設定を `.firebase-dataconnect-bootstrap.json` に保存し、次回実行時に再利用
|
|
12
15
|
|
|
13
16
|
## 使い方
|
|
14
17
|
|
|
15
|
-
初回セットアップ:
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
npm install
|
|
19
|
-
npm run build
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
対話モード:
|
|
23
|
-
|
|
24
18
|
```bash
|
|
25
19
|
npx firebase-dataconnect-bootstrap
|
|
26
20
|
```
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
対話モードは日本語で質問が表示されます。
|
|
23
|
+
同じリポジトリで再実行すると、保存済み設定を初期値として設定変更できます。
|
|
24
|
+
|
|
25
|
+
## 非対話モード例
|
|
29
26
|
|
|
30
27
|
```bash
|
|
31
28
|
npx firebase-dataconnect-bootstrap \
|
|
@@ -37,30 +34,50 @@ npx firebase-dataconnect-bootstrap \
|
|
|
37
34
|
--location asia-northeast1 \
|
|
38
35
|
--document 'meetingSummaries/{summaryId}' \
|
|
39
36
|
--function onMeetingSummaryWritten \
|
|
37
|
+
--vector-search \
|
|
38
|
+
--vector-collection 'meetingSummaries' \
|
|
39
|
+
--source-text-field 'summary' \
|
|
40
|
+
--vector-field 'embedding' \
|
|
41
|
+
--search-fields 'title,summary,createdAt' \
|
|
42
|
+
--search-function 'searchByVector' \
|
|
43
|
+
--top-k 5 \
|
|
40
44
|
--install
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
##
|
|
47
|
+
## 主なオプション
|
|
48
|
+
|
|
49
|
+
- `--config <name>`: 設定保存ファイル名(既定: `.firebase-dataconnect-bootstrap.json`)
|
|
50
|
+
- `--vector-search` / `--no-vector-search`: ベクトル検索 scaffold の有効/無効
|
|
51
|
+
- `--vector-collection <path>`: 検索対象コレクション
|
|
52
|
+
- `--source-text-field <name>`: 埋め込み更新対象のテキストフィールド
|
|
53
|
+
- `--vector-field <name>`: ベクトル保存フィールド
|
|
54
|
+
- `--search-fields <csv>`: 検索結果として返すフィールド
|
|
55
|
+
- `--search-function <name>`: 生成する `onRequest` 関数名
|
|
56
|
+
- `--top-k <number>`: 検索時のデフォルト上位件数
|
|
57
|
+
|
|
58
|
+
## 生成される主な関数
|
|
44
59
|
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
- `--function <name>`: 関数の export 名
|
|
52
|
-
- `--install`: `functions` で `npm install` を実行
|
|
53
|
-
- `--no-install`: `npm install` をスキップ
|
|
54
|
-
- `--yes`: 対話なしで実行(`--project` は必須)
|
|
60
|
+
- `firestoreOnDocumentWritten.*`
|
|
61
|
+
- `sourceTextField` の内容を `embedText` に渡し、`vectorField` へ保存
|
|
62
|
+
- `vectorSearchOnRequest.*`
|
|
63
|
+
- HTTP リクエストの `vector`(または `query`)でコサイン類似度検索
|
|
64
|
+
- `vectorSearchEmbedding.*`
|
|
65
|
+
- `embedText` を実装する差し替えポイント(デフォルトは `null` を返す)
|
|
55
66
|
|
|
56
|
-
## npm
|
|
67
|
+
## npm モジュールとして検索クライアントを使う
|
|
57
68
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
3. `npm run build`
|
|
61
|
-
4. `npm publish --access public`
|
|
69
|
+
```ts
|
|
70
|
+
import { createVectorSearchClient } from "firebase-dataconnect-bootstrap/search-client";
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
const client = createVectorSearchClient({
|
|
73
|
+
endpoint: "https://<region>-<project>.cloudfunctions.net/searchByVector"
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await client.search({
|
|
77
|
+
query: "議事録の要点",
|
|
78
|
+
topK: 5
|
|
79
|
+
});
|
|
80
|
+
```
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
82
|
+
`query` を使う場合は、Functions 側 `vectorSearchEmbedding.*` の `embedText` 実装が必要です。
|
|
83
|
+
`vector` を直接渡す場合は `embedText` が未実装でも検索できます。
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCli(argv?: string[]): Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -14,8 +14,17 @@ Options:
|
|
|
14
14
|
--location <location> Data Connect location (default: same as region)
|
|
15
15
|
--document <path> Firestore document path (default: meetingSummaries/{summaryId})
|
|
16
16
|
--function <name> Function export name (default: onMeetingSummaryWritten)
|
|
17
|
+
--config <name> Saved config filename (default: .firebase-dataconnect-bootstrap.json)
|
|
17
18
|
--install Run npm install in functions directory
|
|
18
19
|
--no-install Skip npm install in functions directory
|
|
20
|
+
--vector-search Enable vector search scaffolding
|
|
21
|
+
--no-vector-search Disable vector search scaffolding
|
|
22
|
+
--vector-collection <path> Collection path used for vector search
|
|
23
|
+
--source-text-field <name> Source text field for embedding updates
|
|
24
|
+
--vector-field <name> Vector field name
|
|
25
|
+
--search-fields <csv> Response fields for search results (comma-separated)
|
|
26
|
+
--search-function <name> onRequest function export name for vector search
|
|
27
|
+
--top-k <number> Default topK value for vector search
|
|
19
28
|
--yes Use defaults and skip prompts where possible
|
|
20
29
|
-h, --help Show help
|
|
21
30
|
`;
|
|
@@ -29,6 +38,8 @@ function parseArgs(argv) {
|
|
|
29
38
|
documentPath: undefined,
|
|
30
39
|
functionName: undefined,
|
|
31
40
|
installDependencies: undefined,
|
|
41
|
+
configFileName: undefined,
|
|
42
|
+
vectorSearch: {},
|
|
32
43
|
yes: false,
|
|
33
44
|
help: false
|
|
34
45
|
};
|
|
@@ -50,6 +61,14 @@ function parseArgs(argv) {
|
|
|
50
61
|
args.installDependencies = false;
|
|
51
62
|
continue;
|
|
52
63
|
}
|
|
64
|
+
if (token === "--vector-search") {
|
|
65
|
+
args.vectorSearch = { ...(args.vectorSearch ?? {}), enabled: true };
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (token === "--no-vector-search") {
|
|
69
|
+
args.vectorSearch = { ...(args.vectorSearch ?? {}), enabled: false };
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
53
72
|
if (token.startsWith("--target=")) {
|
|
54
73
|
args.targetDir = token.slice("--target=".length);
|
|
55
74
|
continue;
|
|
@@ -78,12 +97,66 @@ function parseArgs(argv) {
|
|
|
78
97
|
args.functionName = token.slice("--function=".length);
|
|
79
98
|
continue;
|
|
80
99
|
}
|
|
100
|
+
if (token.startsWith("--config=")) {
|
|
101
|
+
args.configFileName = token.slice("--config=".length);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (token.startsWith("--vector-collection=")) {
|
|
105
|
+
args.vectorSearch = {
|
|
106
|
+
...(args.vectorSearch ?? {}),
|
|
107
|
+
collectionPath: token.slice("--vector-collection=".length)
|
|
108
|
+
};
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (token.startsWith("--source-text-field=")) {
|
|
112
|
+
args.vectorSearch = {
|
|
113
|
+
...(args.vectorSearch ?? {}),
|
|
114
|
+
sourceTextField: token.slice("--source-text-field=".length)
|
|
115
|
+
};
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (token.startsWith("--vector-field=")) {
|
|
119
|
+
args.vectorSearch = {
|
|
120
|
+
...(args.vectorSearch ?? {}),
|
|
121
|
+
vectorField: token.slice("--vector-field=".length)
|
|
122
|
+
};
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (token.startsWith("--search-fields=")) {
|
|
126
|
+
args.vectorSearch = {
|
|
127
|
+
...(args.vectorSearch ?? {}),
|
|
128
|
+
returnFields: token
|
|
129
|
+
.slice("--search-fields=".length)
|
|
130
|
+
.split(",")
|
|
131
|
+
.map((field) => field.trim())
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
};
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (token.startsWith("--search-function=")) {
|
|
137
|
+
args.vectorSearch = {
|
|
138
|
+
...(args.vectorSearch ?? {}),
|
|
139
|
+
functionName: token.slice("--search-function=".length)
|
|
140
|
+
};
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (token.startsWith("--top-k=")) {
|
|
144
|
+
const parsedTopK = Number.parseInt(token.slice("--top-k=".length), 10);
|
|
145
|
+
if (Number.isNaN(parsedTopK) || parsedTopK <= 0) {
|
|
146
|
+
throw new Error("--top-k must be a positive integer");
|
|
147
|
+
}
|
|
148
|
+
args.vectorSearch = { ...(args.vectorSearch ?? {}), defaultTopK: parsedTopK };
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
81
151
|
const nextValue = argv[i + 1];
|
|
82
|
-
const
|
|
152
|
+
const requireNextValue = () => {
|
|
83
153
|
if (!nextValue || nextValue.startsWith("-")) {
|
|
84
154
|
throw new Error(`Missing value for ${token}`);
|
|
85
155
|
}
|
|
86
|
-
|
|
156
|
+
return nextValue;
|
|
157
|
+
};
|
|
158
|
+
const useNext = (field) => {
|
|
159
|
+
args[field] = requireNextValue();
|
|
87
160
|
i += 1;
|
|
88
161
|
};
|
|
89
162
|
switch (token) {
|
|
@@ -108,6 +181,56 @@ function parseArgs(argv) {
|
|
|
108
181
|
case "--function":
|
|
109
182
|
useNext("functionName");
|
|
110
183
|
break;
|
|
184
|
+
case "--config":
|
|
185
|
+
useNext("configFileName");
|
|
186
|
+
break;
|
|
187
|
+
case "--vector-collection":
|
|
188
|
+
args.vectorSearch = {
|
|
189
|
+
...(args.vectorSearch ?? {}),
|
|
190
|
+
collectionPath: requireNextValue()
|
|
191
|
+
};
|
|
192
|
+
i += 1;
|
|
193
|
+
break;
|
|
194
|
+
case "--source-text-field":
|
|
195
|
+
args.vectorSearch = {
|
|
196
|
+
...(args.vectorSearch ?? {}),
|
|
197
|
+
sourceTextField: requireNextValue()
|
|
198
|
+
};
|
|
199
|
+
i += 1;
|
|
200
|
+
break;
|
|
201
|
+
case "--vector-field":
|
|
202
|
+
args.vectorSearch = {
|
|
203
|
+
...(args.vectorSearch ?? {}),
|
|
204
|
+
vectorField: requireNextValue()
|
|
205
|
+
};
|
|
206
|
+
i += 1;
|
|
207
|
+
break;
|
|
208
|
+
case "--search-fields":
|
|
209
|
+
args.vectorSearch = {
|
|
210
|
+
...(args.vectorSearch ?? {}),
|
|
211
|
+
returnFields: requireNextValue()
|
|
212
|
+
.split(",")
|
|
213
|
+
.map((field) => field.trim())
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
};
|
|
216
|
+
i += 1;
|
|
217
|
+
break;
|
|
218
|
+
case "--search-function":
|
|
219
|
+
args.vectorSearch = {
|
|
220
|
+
...(args.vectorSearch ?? {}),
|
|
221
|
+
functionName: requireNextValue()
|
|
222
|
+
};
|
|
223
|
+
i += 1;
|
|
224
|
+
break;
|
|
225
|
+
case "--top-k": {
|
|
226
|
+
const parsedTopK = Number.parseInt(requireNextValue(), 10);
|
|
227
|
+
if (Number.isNaN(parsedTopK) || parsedTopK <= 0) {
|
|
228
|
+
throw new Error("--top-k must be a positive integer");
|
|
229
|
+
}
|
|
230
|
+
args.vectorSearch = { ...(args.vectorSearch ?? {}), defaultTopK: parsedTopK };
|
|
231
|
+
i += 1;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
111
234
|
default:
|
|
112
235
|
throw new Error(`Unknown option: ${token}`);
|
|
113
236
|
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { run } from "./main.js";
|
|
2
|
+
export { runCli } from "./cli.js";
|
|
3
|
+
export { collectConfig } from "./prompt.js";
|
|
4
|
+
export { createVectorSearchClient, searchByVector } from "./search-client.js";
|
|
5
|
+
export type { CliArgs, RunResult, SetupConfig, VectorSearchConfig } from "./types.js";
|
|
6
|
+
export type { SearchByVectorInput, VectorSearchClientOptions, VectorSearchHit, VectorSearchResponse } from "./search-client.js";
|
package/dist/index.js
ADDED
package/dist/main.d.ts
ADDED
package/dist/main.js
CHANGED
|
@@ -50,10 +50,7 @@ function appendIfMissing(content, appendLine) {
|
|
|
50
50
|
}
|
|
51
51
|
function detectModuleStyle(entryPath, packageJson) {
|
|
52
52
|
const ext = path.extname(entryPath);
|
|
53
|
-
if (ext === ".ts") {
|
|
54
|
-
return "esm";
|
|
55
|
-
}
|
|
56
|
-
if (ext === ".mjs") {
|
|
53
|
+
if (ext === ".ts" || ext === ".mjs") {
|
|
57
54
|
return "esm";
|
|
58
55
|
}
|
|
59
56
|
if (ext === ".cjs") {
|
|
@@ -64,6 +61,18 @@ function detectModuleStyle(entryPath, packageJson) {
|
|
|
64
61
|
}
|
|
65
62
|
return "cjs";
|
|
66
63
|
}
|
|
64
|
+
function buildGeneratedImportPath(moduleStyle, extension, moduleBaseName) {
|
|
65
|
+
if (moduleStyle === "cjs") {
|
|
66
|
+
if (extension === ".cjs") {
|
|
67
|
+
return `./${moduleBaseName}.cjs`;
|
|
68
|
+
}
|
|
69
|
+
return `./${moduleBaseName}`;
|
|
70
|
+
}
|
|
71
|
+
if (extension === ".ts") {
|
|
72
|
+
return `./${moduleBaseName}.js`;
|
|
73
|
+
}
|
|
74
|
+
return `./${moduleBaseName}${extension}`;
|
|
75
|
+
}
|
|
67
76
|
function buildDataconnectYaml(config) {
|
|
68
77
|
return `specVersion: "v1alpha"
|
|
69
78
|
serviceId: "${config.serviceId}"
|
|
@@ -124,13 +133,36 @@ function buildMutationsGql() {
|
|
|
124
133
|
}
|
|
125
134
|
`;
|
|
126
135
|
}
|
|
127
|
-
function
|
|
136
|
+
function buildEmbeddingModule(style) {
|
|
128
137
|
if (style === "cjs") {
|
|
129
|
-
return
|
|
138
|
+
return `/**
|
|
139
|
+
* Replace this function with your embedding provider implementation.
|
|
140
|
+
* Return a numeric vector (number[]) for the given text.
|
|
141
|
+
*/
|
|
142
|
+
async function embedText(_text) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { embedText };
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
return `/**
|
|
150
|
+
* Replace this function with your embedding provider implementation.
|
|
151
|
+
* Return a numeric vector (number[]) for the given text.
|
|
152
|
+
*/
|
|
153
|
+
export async function embedText(_text) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
function buildFunctionModule({ style, config }) {
|
|
159
|
+
if (style === "cjs") {
|
|
160
|
+
if (!config.vectorSearch.enabled) {
|
|
161
|
+
return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
|
|
130
162
|
const logger = require("firebase-functions/logger");
|
|
131
163
|
|
|
132
|
-
const ${functionName} = onDocumentWritten(
|
|
133
|
-
{ document: "${documentPath}", region: "${region}" },
|
|
164
|
+
const ${config.functionName} = onDocumentWritten(
|
|
165
|
+
{ document: "${config.documentPath}", region: "${config.region}" },
|
|
134
166
|
async (event) => {
|
|
135
167
|
logger.info("Firestore onDocumentWritten fired", {
|
|
136
168
|
params: event.params,
|
|
@@ -142,14 +174,61 @@ const ${functionName} = onDocumentWritten(
|
|
|
142
174
|
}
|
|
143
175
|
);
|
|
144
176
|
|
|
145
|
-
module.exports = { ${functionName} };
|
|
177
|
+
module.exports = { ${config.functionName} };
|
|
146
178
|
`;
|
|
179
|
+
}
|
|
180
|
+
return `const { onDocumentWritten } = require("firebase-functions/v2/firestore");
|
|
181
|
+
const logger = require("firebase-functions/logger");
|
|
182
|
+
const { embedText } = require("./vectorSearchEmbedding");
|
|
183
|
+
|
|
184
|
+
const SOURCE_TEXT_FIELD = ${JSON.stringify(config.vectorSearch.sourceTextField)};
|
|
185
|
+
const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
|
|
186
|
+
|
|
187
|
+
const ${config.functionName} = onDocumentWritten(
|
|
188
|
+
{ document: "${config.documentPath}", region: "${config.region}" },
|
|
189
|
+
async (event) => {
|
|
190
|
+
if (!event.data?.after?.exists) {
|
|
191
|
+
return;
|
|
147
192
|
}
|
|
148
|
-
|
|
193
|
+
|
|
194
|
+
const afterData = event.data.after.data();
|
|
195
|
+
const sourceText = String(afterData?.[SOURCE_TEXT_FIELD] ?? "").trim();
|
|
196
|
+
if (!sourceText) {
|
|
197
|
+
logger.info("Skip embedding update because source text field is empty.", {
|
|
198
|
+
field: SOURCE_TEXT_FIELD,
|
|
199
|
+
document: event.data.after.ref.path
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const vector = await embedText(sourceText);
|
|
205
|
+
const validVector =
|
|
206
|
+
Array.isArray(vector) && vector.length > 0 && vector.every((value) => Number.isFinite(value));
|
|
207
|
+
if (!validVector) {
|
|
208
|
+
logger.warn("Embedding provider returned an invalid vector.", {
|
|
209
|
+
field: SOURCE_TEXT_FIELD,
|
|
210
|
+
document: event.data.after.ref.path
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await event.data.after.ref.set({ [VECTOR_FIELD]: vector }, { merge: true });
|
|
216
|
+
logger.info("Updated vector field from source text.", {
|
|
217
|
+
field: VECTOR_FIELD,
|
|
218
|
+
document: event.data.after.ref.path
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
module.exports = { ${config.functionName} };
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
if (!config.vectorSearch.enabled) {
|
|
227
|
+
return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
|
|
149
228
|
import * as logger from "firebase-functions/logger";
|
|
150
229
|
|
|
151
|
-
export const ${functionName} = onDocumentWritten(
|
|
152
|
-
{ document: "${documentPath}", region: "${region}" },
|
|
230
|
+
export const ${config.functionName} = onDocumentWritten(
|
|
231
|
+
{ document: "${config.documentPath}", region: "${config.region}" },
|
|
153
232
|
async (event) => {
|
|
154
233
|
logger.info("Firestore onDocumentWritten fired", {
|
|
155
234
|
params: event.params,
|
|
@@ -160,6 +239,267 @@ export const ${functionName} = onDocumentWritten(
|
|
|
160
239
|
return;
|
|
161
240
|
}
|
|
162
241
|
);
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
return `import { onDocumentWritten } from "firebase-functions/v2/firestore";
|
|
245
|
+
import * as logger from "firebase-functions/logger";
|
|
246
|
+
import { embedText } from "./vectorSearchEmbedding.js";
|
|
247
|
+
|
|
248
|
+
const SOURCE_TEXT_FIELD = ${JSON.stringify(config.vectorSearch.sourceTextField)};
|
|
249
|
+
const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
|
|
250
|
+
|
|
251
|
+
export const ${config.functionName} = onDocumentWritten(
|
|
252
|
+
{ document: "${config.documentPath}", region: "${config.region}" },
|
|
253
|
+
async (event) => {
|
|
254
|
+
if (!event.data?.after?.exists) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const afterData = event.data.after.data();
|
|
259
|
+
const sourceText = String(afterData?.[SOURCE_TEXT_FIELD] ?? "").trim();
|
|
260
|
+
if (!sourceText) {
|
|
261
|
+
logger.info("Skip embedding update because source text field is empty.", {
|
|
262
|
+
field: SOURCE_TEXT_FIELD,
|
|
263
|
+
document: event.data.after.ref.path
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const vector = await embedText(sourceText);
|
|
269
|
+
const validVector =
|
|
270
|
+
Array.isArray(vector) && vector.length > 0 && vector.every((value) => Number.isFinite(value));
|
|
271
|
+
if (!validVector) {
|
|
272
|
+
logger.warn("Embedding provider returned an invalid vector.", {
|
|
273
|
+
field: SOURCE_TEXT_FIELD,
|
|
274
|
+
document: event.data.after.ref.path
|
|
275
|
+
});
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await event.data.after.ref.set({ [VECTOR_FIELD]: vector }, { merge: true });
|
|
280
|
+
logger.info("Updated vector field from source text.", {
|
|
281
|
+
field: VECTOR_FIELD,
|
|
282
|
+
document: event.data.after.ref.path
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
function buildVectorSearchOnRequestModule(style, config, extension) {
|
|
289
|
+
const returnFields = JSON.stringify(config.vectorSearch.returnFields);
|
|
290
|
+
const embeddingImportPath = buildGeneratedImportPath(style, extension, "vectorSearchEmbedding");
|
|
291
|
+
if (style === "cjs") {
|
|
292
|
+
return `const { onRequest } = require("firebase-functions/v2/https");
|
|
293
|
+
const logger = require("firebase-functions/logger");
|
|
294
|
+
const admin = require("firebase-admin");
|
|
295
|
+
const { embedText } = require("${embeddingImportPath}");
|
|
296
|
+
|
|
297
|
+
if (admin.apps.length === 0) {
|
|
298
|
+
admin.initializeApp();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const db = admin.firestore();
|
|
302
|
+
const COLLECTION_PATH = ${JSON.stringify(config.vectorSearch.collectionPath)};
|
|
303
|
+
const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
|
|
304
|
+
const RETURN_FIELDS = ${returnFields};
|
|
305
|
+
const DEFAULT_TOP_K = ${config.vectorSearch.defaultTopK};
|
|
306
|
+
|
|
307
|
+
function normalizeVector(raw) {
|
|
308
|
+
if (!Array.isArray(raw)) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
if (raw.length === 0) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
const vector = raw.map((value) => Number(value));
|
|
315
|
+
if (vector.some((value) => !Number.isFinite(value))) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
return vector;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function cosineSimilarity(left, right) {
|
|
322
|
+
let dot = 0;
|
|
323
|
+
let leftNorm = 0;
|
|
324
|
+
let rightNorm = 0;
|
|
325
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
326
|
+
dot += left[i] * right[i];
|
|
327
|
+
leftNorm += left[i] * left[i];
|
|
328
|
+
rightNorm += right[i] * right[i];
|
|
329
|
+
}
|
|
330
|
+
const denom = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
|
|
331
|
+
if (denom === 0) {
|
|
332
|
+
return Number.NaN;
|
|
333
|
+
}
|
|
334
|
+
return dot / denom;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function pickFields(data) {
|
|
338
|
+
const picked = {};
|
|
339
|
+
for (const field of RETURN_FIELDS) {
|
|
340
|
+
if (field in data) {
|
|
341
|
+
picked[field] = data[field];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return picked;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const ${config.vectorSearch.functionName} = onRequest(
|
|
348
|
+
{ region: ${JSON.stringify(config.region)}, cors: true },
|
|
349
|
+
async (req, res) => {
|
|
350
|
+
try {
|
|
351
|
+
const payload = req.method === "GET" ? req.query : req.body ?? {};
|
|
352
|
+
let queryVector = normalizeVector(payload.vector);
|
|
353
|
+
const queryText = typeof payload.query === "string" ? payload.query.trim() : "";
|
|
354
|
+
const rawTopK = Number.parseInt(String(payload.topK ?? DEFAULT_TOP_K), 10);
|
|
355
|
+
const topK = Number.isFinite(rawTopK) && rawTopK > 0 ? rawTopK : DEFAULT_TOP_K;
|
|
356
|
+
|
|
357
|
+
if (!queryVector && queryText) {
|
|
358
|
+
queryVector = normalizeVector(await embedText(queryText));
|
|
359
|
+
}
|
|
360
|
+
if (!queryVector) {
|
|
361
|
+
res.status(400).json({
|
|
362
|
+
error: "Provide 'vector' (number[]) or implement embedText and send 'query'."
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const snapshot = await db.collection(COLLECTION_PATH).get();
|
|
368
|
+
const scored = [];
|
|
369
|
+
snapshot.forEach((doc) => {
|
|
370
|
+
const data = doc.data();
|
|
371
|
+
const candidate = normalizeVector(data[VECTOR_FIELD]);
|
|
372
|
+
if (!candidate || candidate.length !== queryVector.length) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const score = cosineSimilarity(queryVector, candidate);
|
|
376
|
+
if (!Number.isFinite(score)) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
scored.push({
|
|
380
|
+
id: doc.id,
|
|
381
|
+
score,
|
|
382
|
+
data: pickFields(data)
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
scored.sort((a, b) => b.score - a.score);
|
|
387
|
+
res.status(200).json({
|
|
388
|
+
total: scored.length,
|
|
389
|
+
results: scored.slice(0, topK)
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
logger.error("Vector search request failed", error);
|
|
393
|
+
res.status(500).json({ error: "Vector search failed." });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
module.exports = { ${config.vectorSearch.functionName} };
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
return `import { onRequest } from "firebase-functions/v2/https";
|
|
402
|
+
import * as logger from "firebase-functions/logger";
|
|
403
|
+
import admin from "firebase-admin";
|
|
404
|
+
import { embedText } from "${embeddingImportPath}";
|
|
405
|
+
|
|
406
|
+
if (admin.apps.length === 0) {
|
|
407
|
+
admin.initializeApp();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const db = admin.firestore();
|
|
411
|
+
const COLLECTION_PATH = ${JSON.stringify(config.vectorSearch.collectionPath)};
|
|
412
|
+
const VECTOR_FIELD = ${JSON.stringify(config.vectorSearch.vectorField)};
|
|
413
|
+
const RETURN_FIELDS = ${returnFields};
|
|
414
|
+
const DEFAULT_TOP_K = ${config.vectorSearch.defaultTopK};
|
|
415
|
+
|
|
416
|
+
function normalizeVector(raw) {
|
|
417
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const vector = raw.map((value) => Number(value));
|
|
421
|
+
if (vector.some((value) => !Number.isFinite(value))) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
return vector;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function cosineSimilarity(left, right) {
|
|
428
|
+
let dot = 0;
|
|
429
|
+
let leftNorm = 0;
|
|
430
|
+
let rightNorm = 0;
|
|
431
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
432
|
+
dot += left[i] * right[i];
|
|
433
|
+
leftNorm += left[i] * left[i];
|
|
434
|
+
rightNorm += right[i] * right[i];
|
|
435
|
+
}
|
|
436
|
+
const denom = Math.sqrt(leftNorm) * Math.sqrt(rightNorm);
|
|
437
|
+
if (denom === 0) {
|
|
438
|
+
return Number.NaN;
|
|
439
|
+
}
|
|
440
|
+
return dot / denom;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function pickFields(data) {
|
|
444
|
+
const picked = {};
|
|
445
|
+
for (const field of RETURN_FIELDS) {
|
|
446
|
+
if (field in data) {
|
|
447
|
+
picked[field] = data[field];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return picked;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export const ${config.vectorSearch.functionName} = onRequest(
|
|
454
|
+
{ region: ${JSON.stringify(config.region)}, cors: true },
|
|
455
|
+
async (req, res) => {
|
|
456
|
+
try {
|
|
457
|
+
const payload = req.method === "GET" ? req.query : req.body ?? {};
|
|
458
|
+
let queryVector = normalizeVector(payload.vector);
|
|
459
|
+
const queryText = typeof payload.query === "string" ? payload.query.trim() : "";
|
|
460
|
+
const rawTopK = Number.parseInt(String(payload.topK ?? DEFAULT_TOP_K), 10);
|
|
461
|
+
const topK = Number.isFinite(rawTopK) && rawTopK > 0 ? rawTopK : DEFAULT_TOP_K;
|
|
462
|
+
|
|
463
|
+
if (!queryVector && queryText) {
|
|
464
|
+
queryVector = normalizeVector(await embedText(queryText));
|
|
465
|
+
}
|
|
466
|
+
if (!queryVector) {
|
|
467
|
+
res.status(400).json({
|
|
468
|
+
error: "Provide 'vector' (number[]) or implement embedText and send 'query'."
|
|
469
|
+
});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const snapshot = await db.collection(COLLECTION_PATH).get();
|
|
474
|
+
const scored = [];
|
|
475
|
+
snapshot.forEach((doc) => {
|
|
476
|
+
const data = doc.data();
|
|
477
|
+
const candidate = normalizeVector(data[VECTOR_FIELD]);
|
|
478
|
+
if (!candidate || candidate.length !== queryVector.length) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const score = cosineSimilarity(queryVector, candidate);
|
|
482
|
+
if (!Number.isFinite(score)) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
scored.push({
|
|
486
|
+
id: doc.id,
|
|
487
|
+
score,
|
|
488
|
+
data: pickFields(data)
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
scored.sort((a, b) => b.score - a.score);
|
|
493
|
+
res.status(200).json({
|
|
494
|
+
total: scored.length,
|
|
495
|
+
results: scored.slice(0, topK)
|
|
496
|
+
});
|
|
497
|
+
} catch (error) {
|
|
498
|
+
logger.error("Vector search request failed", error);
|
|
499
|
+
res.status(500).json({ error: "Vector search failed." });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
);
|
|
163
503
|
`;
|
|
164
504
|
}
|
|
165
505
|
async function ensureFirebaseRc(targetDir, projectId) {
|
|
@@ -266,41 +606,88 @@ admin.initializeApp();
|
|
|
266
606
|
`, "utf8");
|
|
267
607
|
return { entryPath: defaultEntry, created: defaultEntry };
|
|
268
608
|
}
|
|
269
|
-
async function
|
|
609
|
+
async function resolveEntryContext(targetDir, entryRelativePath) {
|
|
270
610
|
const functionsPackage = await readJsonIfExists(path.join(targetDir, "functions", "package.json"));
|
|
271
611
|
const entryFullPath = path.join(targetDir, entryRelativePath);
|
|
272
612
|
const moduleStyle = detectModuleStyle(entryFullPath, functionsPackage);
|
|
273
613
|
const extension = path.extname(entryRelativePath) || ".js";
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
614
|
+
return {
|
|
615
|
+
moduleStyle,
|
|
616
|
+
extension,
|
|
617
|
+
entryRelativePath,
|
|
618
|
+
entryFullPath
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async function ensureFunctionExport(targetDir, config, context) {
|
|
622
|
+
const generatedFileName = `firestoreOnDocumentWritten${context.extension}`;
|
|
623
|
+
const generatedRelativePath = path.join(path.dirname(context.entryRelativePath), generatedFileName);
|
|
624
|
+
const generatedImportPath = buildGeneratedImportPath(context.moduleStyle, context.extension, "firestoreOnDocumentWritten");
|
|
282
625
|
const generatedFullPath = path.join(targetDir, generatedRelativePath);
|
|
283
626
|
const functionModuleText = buildFunctionModule({
|
|
284
|
-
style: moduleStyle,
|
|
285
|
-
|
|
286
|
-
documentPath: config.documentPath,
|
|
287
|
-
region: config.region
|
|
627
|
+
style: context.moduleStyle,
|
|
628
|
+
config
|
|
288
629
|
});
|
|
289
630
|
await writeFile(generatedFullPath, functionModuleText, "utf8");
|
|
290
|
-
const entryText = (await readFile(entryFullPath, "utf8")).trimEnd();
|
|
291
|
-
const exportLine = moduleStyle === "cjs"
|
|
631
|
+
const entryText = (await readFile(context.entryFullPath, "utf8")).trimEnd();
|
|
632
|
+
const exportLine = context.moduleStyle === "cjs"
|
|
292
633
|
? `exports.${config.functionName} = require("${generatedImportPath}").${config.functionName};`
|
|
293
634
|
: `export { ${config.functionName} } from "${generatedImportPath}";`;
|
|
294
635
|
const updated = appendIfMissing(entryText, exportLine);
|
|
295
636
|
if (updated.changed) {
|
|
296
|
-
await writeFile(entryFullPath,
|
|
637
|
+
await writeFile(context.entryFullPath, updated.content, "utf8");
|
|
297
638
|
}
|
|
298
639
|
return {
|
|
299
640
|
generatedRelativePath: toPosixPath(generatedRelativePath),
|
|
300
|
-
entryRelativePath: toPosixPath(entryRelativePath),
|
|
641
|
+
entryRelativePath: toPosixPath(context.entryRelativePath),
|
|
301
642
|
entryUpdated: updated.changed
|
|
302
643
|
};
|
|
303
644
|
}
|
|
645
|
+
async function ensureVectorSearchScaffold(targetDir, config, context) {
|
|
646
|
+
if (!config.vectorSearch.enabled) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const folder = path.dirname(context.entryRelativePath);
|
|
650
|
+
const onRequestFileName = `vectorSearchOnRequest${context.extension}`;
|
|
651
|
+
const embeddingFileName = `vectorSearchEmbedding${context.extension}`;
|
|
652
|
+
const onRequestRelativePath = path.join(folder, onRequestFileName);
|
|
653
|
+
const embeddingRelativePath = path.join(folder, embeddingFileName);
|
|
654
|
+
const onRequestFullPath = path.join(targetDir, onRequestRelativePath);
|
|
655
|
+
const embeddingFullPath = path.join(targetDir, embeddingRelativePath);
|
|
656
|
+
const onRequestImportPath = buildGeneratedImportPath(context.moduleStyle, context.extension, "vectorSearchOnRequest");
|
|
657
|
+
await writeFile(onRequestFullPath, buildVectorSearchOnRequestModule(context.moduleStyle, config, context.extension), "utf8");
|
|
658
|
+
const embeddingCreated = await writeTextIfMissing(embeddingFullPath, buildEmbeddingModule(context.moduleStyle));
|
|
659
|
+
const entryText = (await readFile(context.entryFullPath, "utf8")).trimEnd();
|
|
660
|
+
const exportLine = context.moduleStyle === "cjs"
|
|
661
|
+
? `exports.${config.vectorSearch.functionName} = require("${onRequestImportPath}").${config.vectorSearch.functionName};`
|
|
662
|
+
: `export { ${config.vectorSearch.functionName} } from "${onRequestImportPath}";`;
|
|
663
|
+
const updated = appendIfMissing(entryText, exportLine);
|
|
664
|
+
if (updated.changed) {
|
|
665
|
+
await writeFile(context.entryFullPath, updated.content, "utf8");
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
generatedRelativePath: toPosixPath(onRequestRelativePath),
|
|
669
|
+
embeddingRelativePath: toPosixPath(embeddingRelativePath),
|
|
670
|
+
entryRelativePath: toPosixPath(context.entryRelativePath),
|
|
671
|
+
entryUpdated: updated.changed,
|
|
672
|
+
embeddingCreated
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
async function persistBootstrapConfig(targetDir, config) {
|
|
676
|
+
const filePath = path.join(targetDir, config.configFileName);
|
|
677
|
+
const persisted = {
|
|
678
|
+
projectId: config.projectId,
|
|
679
|
+
region: config.region,
|
|
680
|
+
serviceId: config.serviceId,
|
|
681
|
+
location: config.location,
|
|
682
|
+
documentPath: config.documentPath,
|
|
683
|
+
functionName: config.functionName,
|
|
684
|
+
installDependencies: config.installDependencies,
|
|
685
|
+
configFileName: config.configFileName,
|
|
686
|
+
vectorSearch: config.vectorSearch
|
|
687
|
+
};
|
|
688
|
+
await writeJson(filePath, persisted);
|
|
689
|
+
return toPosixPath(config.configFileName);
|
|
690
|
+
}
|
|
304
691
|
async function runNpmInstall(functionsDir) {
|
|
305
692
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
306
693
|
const child = spawn("npm", ["install"], {
|
|
@@ -350,11 +737,26 @@ export async function run(config) {
|
|
|
350
737
|
else {
|
|
351
738
|
summaryLines.push(`Using existing ${entryPath}`);
|
|
352
739
|
}
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
summaryLines.push(
|
|
356
|
-
|
|
357
|
-
|
|
740
|
+
const context = await resolveEntryContext(targetDir, entryPath);
|
|
741
|
+
const documentWriteResult = await ensureFunctionExport(targetDir, config, context);
|
|
742
|
+
summaryLines.push(`Updated ${documentWriteResult.generatedRelativePath}`);
|
|
743
|
+
summaryLines.push(documentWriteResult.entryUpdated
|
|
744
|
+
? `Appended export in ${documentWriteResult.entryRelativePath}`
|
|
745
|
+
: `Export already present in ${documentWriteResult.entryRelativePath}`);
|
|
746
|
+
const vectorSearchResult = await ensureVectorSearchScaffold(targetDir, config, context);
|
|
747
|
+
if (vectorSearchResult) {
|
|
748
|
+
summaryLines.push(`Updated ${vectorSearchResult.generatedRelativePath}`);
|
|
749
|
+
summaryLines.push(vectorSearchResult.embeddingCreated
|
|
750
|
+
? `Created ${vectorSearchResult.embeddingRelativePath}`
|
|
751
|
+
: `Using existing ${vectorSearchResult.embeddingRelativePath}`);
|
|
752
|
+
summaryLines.push(vectorSearchResult.entryUpdated
|
|
753
|
+
? `Appended export in ${vectorSearchResult.entryRelativePath}`
|
|
754
|
+
: `Vector search export already present in ${vectorSearchResult.entryRelativePath}`);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
summaryLines.push("Skipped vector search scaffolding");
|
|
758
|
+
}
|
|
759
|
+
summaryLines.push(`Updated ${await persistBootstrapConfig(targetDir, config)}`);
|
|
358
760
|
if (config.installDependencies) {
|
|
359
761
|
const functionsDir = path.join(targetDir, "functions");
|
|
360
762
|
await runNpmInstall(functionsDir);
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { createInterface } from "node:readline/promises";
|
|
3
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
const DEFAULT_CONFIG_FILE = ".firebase-dataconnect-bootstrap.json";
|
|
6
|
+
const DEFAULT_RETURN_FIELDS = ["title", "summary", "createdAt"];
|
|
4
7
|
function normalizeFunctionName(name) {
|
|
5
8
|
const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
6
9
|
if (!cleaned) {
|
|
@@ -11,15 +14,64 @@ function normalizeFunctionName(name) {
|
|
|
11
14
|
}
|
|
12
15
|
return `fn_${cleaned}`;
|
|
13
16
|
}
|
|
17
|
+
function toCsv(fields) {
|
|
18
|
+
return fields.join(",");
|
|
19
|
+
}
|
|
20
|
+
function parseCsvFields(value) {
|
|
21
|
+
const fields = value
|
|
22
|
+
.split(",")
|
|
23
|
+
.map((field) => field.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
return fields.length > 0 ? fields : [...DEFAULT_RETURN_FIELDS];
|
|
26
|
+
}
|
|
27
|
+
function deriveCollectionPath(documentPath) {
|
|
28
|
+
const segments = documentPath.split("/").filter(Boolean);
|
|
29
|
+
if (segments.length === 0) {
|
|
30
|
+
return "meetingSummaries";
|
|
31
|
+
}
|
|
32
|
+
while (segments.length > 0 && segments[segments.length - 1].startsWith("{")) {
|
|
33
|
+
segments.pop();
|
|
34
|
+
}
|
|
35
|
+
if (segments.length === 0) {
|
|
36
|
+
return "meetingSummaries";
|
|
37
|
+
}
|
|
38
|
+
return segments.join("/");
|
|
39
|
+
}
|
|
40
|
+
function normalizeVectorSearch(partialVectorSearch, documentPath) {
|
|
41
|
+
const source = partialVectorSearch ?? {};
|
|
42
|
+
const collectionPath = source.collectionPath?.trim() || deriveCollectionPath(documentPath);
|
|
43
|
+
const sourceTextField = source.sourceTextField?.trim() || "summary";
|
|
44
|
+
const vectorField = source.vectorField?.trim() || "embedding";
|
|
45
|
+
const returnFields = source.returnFields?.length ? source.returnFields : DEFAULT_RETURN_FIELDS;
|
|
46
|
+
const functionName = normalizeFunctionName(source.functionName?.trim() || "searchByVector");
|
|
47
|
+
const rawTopK = typeof source.defaultTopK === "number" && Number.isFinite(source.defaultTopK)
|
|
48
|
+
? Math.floor(source.defaultTopK)
|
|
49
|
+
: 5;
|
|
50
|
+
const defaultTopK = rawTopK > 0 ? rawTopK : 5;
|
|
51
|
+
const enabled = typeof source.enabled === "boolean" ? source.enabled : false;
|
|
52
|
+
return {
|
|
53
|
+
enabled,
|
|
54
|
+
collectionPath,
|
|
55
|
+
sourceTextField,
|
|
56
|
+
vectorField,
|
|
57
|
+
returnFields,
|
|
58
|
+
functionName,
|
|
59
|
+
defaultTopK
|
|
60
|
+
};
|
|
61
|
+
}
|
|
14
62
|
function normalizeConfig(partial) {
|
|
15
63
|
const targetDir = resolve(partial.targetDir ?? process.cwd());
|
|
16
|
-
const region = partial.region?.trim() || "us-central1";
|
|
17
64
|
const projectId = partial.projectId?.trim() || "";
|
|
65
|
+
const region = partial.region?.trim() || "us-central1";
|
|
18
66
|
const serviceId = partial.serviceId?.trim() || `${projectId || "my-project"}-service`;
|
|
19
67
|
const location = partial.location?.trim() || region;
|
|
20
68
|
const documentPath = partial.documentPath?.trim() || "meetingSummaries/{summaryId}";
|
|
21
69
|
const functionName = normalizeFunctionName(partial.functionName?.trim() || "onMeetingSummaryWritten");
|
|
22
|
-
const
|
|
70
|
+
const configFileName = partial.configFileName?.trim() || DEFAULT_CONFIG_FILE;
|
|
71
|
+
const installDependencies = typeof partial.installDependencies === "boolean"
|
|
72
|
+
? partial.installDependencies
|
|
73
|
+
: true;
|
|
74
|
+
const vectorSearch = normalizeVectorSearch(partial.vectorSearch, documentPath);
|
|
23
75
|
return {
|
|
24
76
|
targetDir,
|
|
25
77
|
projectId,
|
|
@@ -28,11 +80,50 @@ function normalizeConfig(partial) {
|
|
|
28
80
|
location,
|
|
29
81
|
documentPath,
|
|
30
82
|
functionName,
|
|
31
|
-
installDependencies
|
|
83
|
+
installDependencies,
|
|
84
|
+
configFileName,
|
|
85
|
+
vectorSearch
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function loadSavedConfig(targetDir, configFileName) {
|
|
89
|
+
try {
|
|
90
|
+
const filePath = resolve(targetDir, configFileName);
|
|
91
|
+
const raw = await readFile(filePath, "utf8");
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function mergeConfig(parsed, saved, targetDir) {
|
|
103
|
+
return {
|
|
104
|
+
...saved,
|
|
105
|
+
targetDir,
|
|
106
|
+
projectId: parsed.projectId ?? saved.projectId,
|
|
107
|
+
region: parsed.region ?? saved.region,
|
|
108
|
+
serviceId: parsed.serviceId ?? saved.serviceId,
|
|
109
|
+
location: parsed.location ?? saved.location,
|
|
110
|
+
documentPath: parsed.documentPath ?? saved.documentPath,
|
|
111
|
+
functionName: parsed.functionName ?? saved.functionName,
|
|
112
|
+
installDependencies: typeof parsed.installDependencies === "boolean"
|
|
113
|
+
? parsed.installDependencies
|
|
114
|
+
: saved.installDependencies,
|
|
115
|
+
configFileName: parsed.configFileName ?? saved.configFileName ?? DEFAULT_CONFIG_FILE,
|
|
116
|
+
vectorSearch: {
|
|
117
|
+
...(saved.vectorSearch ?? {}),
|
|
118
|
+
...(parsed.vectorSearch ?? {})
|
|
119
|
+
}
|
|
32
120
|
};
|
|
33
121
|
}
|
|
34
122
|
export async function collectConfig(parsed) {
|
|
35
|
-
const
|
|
123
|
+
const targetDir = resolve(parsed.targetDir ?? process.cwd());
|
|
124
|
+
const configFileName = parsed.configFileName?.trim() || DEFAULT_CONFIG_FILE;
|
|
125
|
+
const saved = await loadSavedConfig(targetDir, configFileName);
|
|
126
|
+
const defaults = normalizeConfig(mergeConfig({ ...parsed, targetDir, configFileName }, { ...saved, targetDir, configFileName: saved.configFileName ?? configFileName }, targetDir));
|
|
36
127
|
if (parsed.yes) {
|
|
37
128
|
if (!defaults.projectId) {
|
|
38
129
|
throw new Error("`--yes` を使う場合は `--project` が必須です。");
|
|
@@ -49,6 +140,17 @@ export async function collectConfig(parsed) {
|
|
|
49
140
|
}
|
|
50
141
|
return value;
|
|
51
142
|
};
|
|
143
|
+
const askBoolean = async (label, fallback) => {
|
|
144
|
+
const base = fallback ? "y" : "n";
|
|
145
|
+
const value = (await ask(label, base, true)).toLowerCase();
|
|
146
|
+
if (["y", "yes", "true", "1", "はい"].includes(value)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (["n", "no", "false", "0", "いいえ"].includes(value)) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`${label} は y か n を入力してください。`);
|
|
153
|
+
};
|
|
52
154
|
console.log("Firebase Data Connect / Cloud Functions セットアップ");
|
|
53
155
|
console.log("");
|
|
54
156
|
const targetDir = resolve(await ask("対象リポジトリのパス", defaults.targetDir, true));
|
|
@@ -61,6 +163,26 @@ export async function collectConfig(parsed) {
|
|
|
61
163
|
const functionName = normalizeFunctionName(rawFunctionName);
|
|
62
164
|
const installRaw = await ask("functions ディレクトリで npm install を実行しますか? (y/n)", defaults.installDependencies ? "y" : "n", true);
|
|
63
165
|
const installDependencies = installRaw.toLowerCase().startsWith("y");
|
|
166
|
+
const vectorEnabled = await askBoolean("ベクトル検索と onRequest 検索関数を有効化しますか? (y/n)", defaults.vectorSearch.enabled);
|
|
167
|
+
let vectorCollectionPath = defaults.vectorSearch.collectionPath;
|
|
168
|
+
let sourceTextField = defaults.vectorSearch.sourceTextField;
|
|
169
|
+
let vectorField = defaults.vectorSearch.vectorField;
|
|
170
|
+
let returnFields = defaults.vectorSearch.returnFields;
|
|
171
|
+
let searchFunctionName = defaults.vectorSearch.functionName;
|
|
172
|
+
let defaultTopK = defaults.vectorSearch.defaultTopK;
|
|
173
|
+
if (vectorEnabled) {
|
|
174
|
+
vectorCollectionPath = await ask("ベクトル検索対象のコレクションパス", defaults.vectorSearch.collectionPath, true);
|
|
175
|
+
sourceTextField = await ask("埋め込み作成対象のテキストフィールド", defaults.vectorSearch.sourceTextField, true);
|
|
176
|
+
vectorField = await ask("ベクトル保存先フィールド名", defaults.vectorSearch.vectorField, true);
|
|
177
|
+
const returnFieldsCsv = await ask("検索結果に返すフィールド(カンマ区切り)", toCsv(defaults.vectorSearch.returnFields), true);
|
|
178
|
+
returnFields = parseCsvFields(returnFieldsCsv);
|
|
179
|
+
searchFunctionName = normalizeFunctionName(await ask("onRequest 検索関数名", defaults.vectorSearch.functionName, true));
|
|
180
|
+
const defaultTopKText = await ask("ベクトル検索のデフォルト topK", String(defaults.vectorSearch.defaultTopK), true);
|
|
181
|
+
defaultTopK = Number.parseInt(defaultTopKText, 10);
|
|
182
|
+
if (Number.isNaN(defaultTopK) || defaultTopK <= 0) {
|
|
183
|
+
throw new Error("topK は 1 以上の整数で入力してください。");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
64
186
|
return {
|
|
65
187
|
targetDir,
|
|
66
188
|
projectId,
|
|
@@ -69,7 +191,17 @@ export async function collectConfig(parsed) {
|
|
|
69
191
|
location,
|
|
70
192
|
documentPath,
|
|
71
193
|
functionName,
|
|
72
|
-
installDependencies
|
|
194
|
+
installDependencies,
|
|
195
|
+
configFileName: defaults.configFileName,
|
|
196
|
+
vectorSearch: {
|
|
197
|
+
enabled: vectorEnabled,
|
|
198
|
+
collectionPath: vectorCollectionPath,
|
|
199
|
+
sourceTextField,
|
|
200
|
+
vectorField,
|
|
201
|
+
returnFields,
|
|
202
|
+
functionName: searchFunctionName,
|
|
203
|
+
defaultTopK
|
|
204
|
+
}
|
|
73
205
|
};
|
|
74
206
|
}
|
|
75
207
|
finally {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface VectorSearchHit {
|
|
2
|
+
id: string;
|
|
3
|
+
score: number;
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface VectorSearchResponse {
|
|
7
|
+
total: number;
|
|
8
|
+
results: VectorSearchHit[];
|
|
9
|
+
}
|
|
10
|
+
export interface SearchByVectorInput {
|
|
11
|
+
endpoint: string;
|
|
12
|
+
vector?: number[];
|
|
13
|
+
query?: string;
|
|
14
|
+
topK?: number;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
interface MinimalResponse {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
status: number;
|
|
21
|
+
statusText: string;
|
|
22
|
+
json(): Promise<unknown>;
|
|
23
|
+
text(): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
type MinimalFetch = (url: string, init: {
|
|
26
|
+
method: string;
|
|
27
|
+
headers: Record<string, string>;
|
|
28
|
+
body: string;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}) => Promise<MinimalResponse>;
|
|
31
|
+
export declare function searchByVector(input: SearchByVectorInput, fetchImpl?: MinimalFetch): Promise<VectorSearchResponse>;
|
|
32
|
+
export interface VectorSearchClientOptions {
|
|
33
|
+
endpoint: string;
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
fetchImpl?: MinimalFetch;
|
|
36
|
+
}
|
|
37
|
+
export declare function createVectorSearchClient(options: VectorSearchClientOptions): {
|
|
38
|
+
search: (input: Omit<SearchByVectorInput, "endpoint">) => Promise<VectorSearchResponse>;
|
|
39
|
+
};
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const defaultFetch = (url, init) => fetch(url, init);
|
|
2
|
+
function validateVector(vector) {
|
|
3
|
+
if (!vector) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
if (vector.length === 0 || vector.some((value) => !Number.isFinite(value))) {
|
|
7
|
+
throw new Error("`vector` must be a non-empty number array.");
|
|
8
|
+
}
|
|
9
|
+
return vector;
|
|
10
|
+
}
|
|
11
|
+
export async function searchByVector(input, fetchImpl = defaultFetch) {
|
|
12
|
+
const endpoint = input.endpoint.trim();
|
|
13
|
+
if (!endpoint) {
|
|
14
|
+
throw new Error("`endpoint` is required.");
|
|
15
|
+
}
|
|
16
|
+
const payload = {
|
|
17
|
+
topK: input.topK
|
|
18
|
+
};
|
|
19
|
+
const vector = validateVector(input.vector);
|
|
20
|
+
if (vector) {
|
|
21
|
+
payload.vector = vector;
|
|
22
|
+
}
|
|
23
|
+
if (input.query) {
|
|
24
|
+
payload.query = input.query;
|
|
25
|
+
}
|
|
26
|
+
const response = await fetchImpl(endpoint, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
...(input.headers ?? {})
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify(payload),
|
|
33
|
+
signal: input.signal
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const message = await response.text();
|
|
37
|
+
throw new Error(`Vector search request failed (${response.status} ${response.statusText}): ${message}`);
|
|
38
|
+
}
|
|
39
|
+
const body = (await response.json());
|
|
40
|
+
return {
|
|
41
|
+
total: typeof body.total === "number" ? body.total : 0,
|
|
42
|
+
results: Array.isArray(body.results) ? body.results : []
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function createVectorSearchClient(options) {
|
|
46
|
+
return {
|
|
47
|
+
search: (input) => searchByVector({
|
|
48
|
+
...input,
|
|
49
|
+
endpoint: options.endpoint,
|
|
50
|
+
headers: {
|
|
51
|
+
...(options.headers ?? {}),
|
|
52
|
+
...(input.headers ?? {})
|
|
53
|
+
}
|
|
54
|
+
}, options.fetchImpl)
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CliArgs {
|
|
2
|
+
targetDir?: string;
|
|
3
|
+
projectId?: string;
|
|
4
|
+
region?: string;
|
|
5
|
+
serviceId?: string;
|
|
6
|
+
location?: string;
|
|
7
|
+
documentPath?: string;
|
|
8
|
+
functionName?: string;
|
|
9
|
+
installDependencies?: boolean;
|
|
10
|
+
configFileName?: string;
|
|
11
|
+
vectorSearch?: Partial<VectorSearchConfig>;
|
|
12
|
+
yes: boolean;
|
|
13
|
+
help: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface VectorSearchConfig {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
collectionPath: string;
|
|
18
|
+
sourceTextField: string;
|
|
19
|
+
vectorField: string;
|
|
20
|
+
returnFields: string[];
|
|
21
|
+
functionName: string;
|
|
22
|
+
defaultTopK: number;
|
|
23
|
+
}
|
|
24
|
+
export interface SetupConfig {
|
|
25
|
+
targetDir: string;
|
|
26
|
+
projectId: string;
|
|
27
|
+
region: string;
|
|
28
|
+
serviceId: string;
|
|
29
|
+
location: string;
|
|
30
|
+
documentPath: string;
|
|
31
|
+
functionName: string;
|
|
32
|
+
installDependencies: boolean;
|
|
33
|
+
configFileName: string;
|
|
34
|
+
vectorSearch: VectorSearchConfig;
|
|
35
|
+
}
|
|
36
|
+
export interface RunResult {
|
|
37
|
+
targetDir: string;
|
|
38
|
+
summaryLines: string[];
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firebase-dataconnect-bootstrap",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Bootstrap Firebase Data Connect and Firestore onDocumentWritten function setup in any repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"firebase-dataconnect-bootstrap": "bin/firebase-dataconnect-bootstrap.js"
|
|
8
8
|
},
|
|
9
|
-
"main": "dist/
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./search-client": {
|
|
17
|
+
"types": "./dist/search-client.d.ts",
|
|
18
|
+
"import": "./dist/search-client.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
10
22
|
"files": [
|
|
11
23
|
"bin",
|
|
12
24
|
"dist",
|
|
@@ -26,7 +38,8 @@
|
|
|
26
38
|
"dataconnect",
|
|
27
39
|
"firestore",
|
|
28
40
|
"cloud-functions",
|
|
29
|
-
"cli"
|
|
41
|
+
"cli",
|
|
42
|
+
"vector-search"
|
|
30
43
|
],
|
|
31
44
|
"author": "",
|
|
32
45
|
"license": "MIT",
|