@nine-lab/nine-mu 0.1.150 → 0.1.152

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.
@@ -0,0 +1,438 @@
1
+ import ninegrid from "../index.js";
2
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
3
+ import { Ollama } from "@langchain/ollama";
4
+ import { ChatOpenAI } from '@langchain/openai';
5
+ import { HumanMessage, SystemMessage } from '@langchain/core/messages';
6
+ import { OllamaEmbeddings } from "@langchain/ollama";
7
+ import { QdrantClient } from "@qdrant/js-client-rest";
8
+ import { StringOutputParser } from "@langchain/core/output_parsers"
9
+ import { PromptTemplate} from "@langchain/core/prompts"
10
+
11
+ class aiContainer extends HTMLElement
12
+ {
13
+ #target;
14
+ #ing = false;
15
+ #model;
16
+ #qdrantClient;
17
+ //#elChat;
18
+
19
+ settings; /** nx-ai-settings element */
20
+
21
+ constructor() {
22
+ super();
23
+ this.attachShadow({ mode: 'open' });
24
+ }
25
+
26
+ connectedCallback() {
27
+
28
+ const textareaText = "나에게 무엇이든 물어봐...";
29
+
30
+ this.shadowRoot.innerHTML = `
31
+ <style>
32
+ @import "https://cdn.jsdelivr.net/npm/ninegrid2@${ninegrid.version}/dist/css/ai.css";
33
+ ${ninegrid.getCustomPath(this,"ai.css")}
34
+ </style>
35
+
36
+ <div class="wrapper">
37
+ <nx-ai-settings></nx-ai-settings>
38
+
39
+ <div class="container">
40
+ <div class="head">
41
+ <div class="logo"></div>
42
+ </div>
43
+ <nx-ai-chat></nx-ai-chat>
44
+ <div class="foot">
45
+ <textarea name="ask" id="q" name="q" rows="4" placeholder="${textareaText}"></textarea>
46
+ </div>
47
+ </div>
48
+ <div class="menu">
49
+ <div class="collapse-icon"></div>
50
+ <div class="menu-icon menu-filter active"></div>
51
+ <div class="menu-icon menu-general"></div>
52
+ <div class="menu-icon menu-setting"></div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="expand-icon"></div>
57
+ `;
58
+
59
+ requestAnimationFrame(() => {
60
+ this.#init();
61
+ });
62
+ };
63
+
64
+ get target() { return this.#target; };
65
+ set target(v) { this.#target = v; };
66
+
67
+ #createModel = () => {
68
+
69
+ switch (this.settings.server) {
70
+ case "gemini":
71
+ console.log(this.settings.geminiApiKey);
72
+ this.#model = new ChatGoogleGenerativeAI({ model: this.settings.model, apiKey: this.settings.geminiApiKey, temperature: 0,});
73
+ break;
74
+ case "openai":
75
+ console.log(this.settings.openaiApiKey);
76
+ this.#model = new ChatOpenAI({ model: this.settings.model, apiKey: this.settings.openaiApiKey, temperature: 0, });
77
+ break;
78
+ case "ollama":
79
+ this.#model = new Ollama({
80
+ model: this.settings.model,
81
+ host: this.settings.ollamaUrl,
82
+ });
83
+ break;
84
+ }
85
+
86
+ this.#qdrantClient = new QdrantClient({ url: this.settings.qdrantUrl });
87
+ };
88
+
89
+ #getColumnInfo = () => {
90
+ if (this.#target.tagName === "NINE-GRID") {
91
+ return this.#target.columns.info()
92
+ }
93
+ };
94
+
95
+ #getColumnInfoString = () => {
96
+
97
+ const columnInfo = this.#getColumnInfo() || [];
98
+
99
+ let r = "";
100
+
101
+ columnInfo.forEach(info => {
102
+ r += `- "${info.name}": ${info.desc}, ${info.type}\n`;
103
+ });
104
+
105
+ return r;
106
+ };
107
+
108
+ #getUniqueKey = () => {
109
+ if (this.#target.tagName === "NINE-GRID") {
110
+ return this.#target.dataset.unique;
111
+ }
112
+ };
113
+
114
+ #getData = () => {
115
+ if (this.#target.tagName === "NINE-GRID") {
116
+ return this.#target.data.get();
117
+ }
118
+ };
119
+
120
+ #isEmptyObject = (obj) => {
121
+ return Object.keys(obj).length === 0 && obj.constructor === Object;
122
+ }
123
+
124
+ #extractJsonSnippet = (text) => {
125
+ const match = text.match(/```json([\s\S]*?)```/);
126
+ return match ? match[1].trim() : text;
127
+ };
128
+
129
+ #getToday = () => {
130
+ const now = new Date();
131
+ const yyyy = now.getFullYear();
132
+ const mm = String(now.getMonth() + 1).padStart(2, '0'); // 월은 0부터 시작
133
+ const dd = String(now.getDate()).padStart(2, '0');
134
+
135
+ return `${yyyy}-${mm}-${dd}`;
136
+ }
137
+
138
+ #generateQdrantFilter = async () => {
139
+
140
+ const userInput = this.shadowRoot.querySelector("textarea").value.trim();
141
+
142
+ const systemMessage = "You are a helpful assistant.";
143
+ // Qdrant 필터로 변환하기 위한 프롬프트 엔지니어링
144
+ // 중요: 실제 컬렉션의 payload 필드와 그 데이터 타입을 정확히 알려줘야 Gemini가 올바른 필터를 생성합니다.
145
+ const prompt = `
146
+ 자연어 쿼리를 Qdrant 필터 JSON 객체로 변환하는 AI 비서입니다.
147
+
148
+ Qdrant 컬렉션에서 사용 가능한 메타데이터 필드와 유형은 다음과 같습니다.:
149
+ ${this.#getColumnInfoString()}
150
+
151
+ 필터 생성 규칙:
152
+ 1. 위에 제공된 필드만 사용하십시오. 새로운 필드를 만들지 마십시오.
153
+ 2. 조건이 명확하게 지정되지 않았거나 모호한 경우 필터에 포함하지 마십시오.
154
+ 3. AND 조건에는 'must', OR 조건에는 'should', NOT 조건에는 'must_not'을 사용하십시오.
155
+ 4. 문자열 일치에는 'match.text'를 사용하십시오.
156
+ 5. 날짜 조건은 'YYYY-MM-DD' 형식으로 변형해서 사용하십시요. 오늘날짜는 ${this.#getToday()} 입니다.
157
+ 6. 숫자 범위에는 'range.gte', 'range.lte', 'range.gt', 'range.lt'를 사용하십시오.
158
+ 7. 특정 값을 포함하는 배열에는 'contains', 'match.any' 또는 'match.all'을 사용하십시오.
159
+ 8. 출력은 Qdrant 필터를 나타내는 유효한 JSON 객체여야 합니다.
160
+ 9. 적용 가능한 필터가 없는 경우 빈 JSON 객체인 {}를 반환합니다.
161
+
162
+ 예:
163
+ - "500달러 미만의 전자제품 찾기" -> {"must": [{"key": "category", "match": {"text": "electronics"}}, {"key": "price", "range": {"lt": 500}}]}
164
+ - "John Doe 또는 Jane Smith의 책 보기" -> {"should": [{"key": "author", "match": {"text": "John Doe"}}, {"key": "author", "match": {"text": "Jane Smith"}}]}
165
+ - "재고 있는 품목(의류 제외)" -> {"must": [{"key": "in_stock", "match": {"text": true}}], "must_not": [{"key": "category", "match": {"text": "clothing"}}]}
166
+ - "다음이 포함된 제품 'new_arrival' 태그" -> {"must": [{"key": "tags", "match": {"text": "new_arrival"}}]}
167
+ - "2020년 이후 출판된 도서" -> {"must": [{"key": "published_year", "range": {"gt": 2020}}]}
168
+
169
+ 이제 다음 사용자 쿼리를 변환해 보겠습니다.
170
+ 사용자 쿼리: "${userInput}"
171
+
172
+ Qdrant 필터 JSON:
173
+ `;
174
+
175
+
176
+ const response = await this.#model.invoke([
177
+ new SystemMessage(systemMessage),
178
+ new HumanMessage(prompt),
179
+ ]);
180
+
181
+ console.log(response);
182
+
183
+ let filterString;
184
+ switch (this.settings.server) {
185
+ case "openai":
186
+ filterString = this.#extractJsonSnippet(response.content.trim());
187
+ break;
188
+ case "gemini":
189
+ filterString = this.#extractJsonSnippet(response.content.trim());
190
+ break;
191
+ case "ollama":
192
+ filterString = this.#extractJsonSnippet(response);
193
+ break;
194
+ default:
195
+ break;
196
+ }
197
+
198
+ //console.log(filterString);
199
+
200
+ return JSON.parse(filterString);
201
+ }
202
+
203
+ #searchWithQdrantFilter = async (filter) => {
204
+
205
+ const queryText = this.shadowRoot.querySelector("textarea").value.trim();
206
+ const collectionName = this.#target.collectionName;
207
+
208
+ //try {
209
+ // 쿼리 텍스트를 임베딩으로 변환 (Gemini Embedding 모델 사용)
210
+ // LangChain의 Embeddings 클래스 또는 @google/generative-ai SDK 직접 사용 가능
211
+ // 여기서는 예시를 위해 임시로 더미 벡터를 사용합니다.
212
+ // 실제로는 GoogleGenerativeAI.embedContent 등을 사용해야 합니다.
213
+ // const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
214
+ // const embeddingModel = genAI.get GenerativeModel({ model: "embedding-001" });
215
+ // const { embedding } = await embeddingModel.embedContent({
216
+ // content: queryText,
217
+ // taskType: "retrieval_query",
218
+ // });
219
+ //const queryVector = [0.1, 0.2, 0.3, 0.4, ...Array(764).fill(0)]; // 실제 임베딩 벡터로 대체
220
+ const embeddings = new OllamaEmbeddings({ model: "nomic-embed-text" });
221
+ const queryVector = await embeddings.embedQuery(queryText);
222
+
223
+
224
+ console.log(collectionName, filter);
225
+
226
+ const searchParams = {
227
+ vector: queryVector,
228
+ limit: 500, // 검색 결과 수
229
+ filter: filter, // Gemini가 생성한 필터 적용
230
+ with_payload: true, // 페이로드도 함께 가져오도록 설정
231
+ };
232
+
233
+ //console.log("Qdrant Search Params:", JSON.stringify(searchParams, null, 2));
234
+
235
+ return await this.#qdrantClient.search(collectionName, searchParams);
236
+
237
+ //} catch (error) {
238
+ // console.error("Error during Qdrant search:", error);
239
+ return [];
240
+ //}
241
+ };
242
+
243
+
244
+
245
+ #q1 = async () => {
246
+
247
+ /**
248
+ const filter = {
249
+ "should": [
250
+ {
251
+ "key": "reference_month",
252
+ "match": {
253
+ "any": [
254
+ "2025-04",
255
+ "2025-05",
256
+ "2025-06"
257
+ ]
258
+ }
259
+ }
260
+ ]
261
+ }; */
262
+
263
+
264
+
265
+ const filter = await this.#generateQdrantFilter();
266
+ console.log(filter);
267
+
268
+ const searchResults = this.#isEmptyObject(filter) ? [] : ninegrid.filter(this.#getData(), filter);
269
+
270
+ await this.#answer(searchResults);
271
+ };
272
+
273
+ #q2 = async () => {
274
+ const filter = await this.#generateQdrantFilter();
275
+ console.log(filter);
276
+ const searchResults = await this.#searchWithQdrantFilter(filter);
277
+
278
+ await this.#answer(searchResults);
279
+ };
280
+
281
+ #answer = async (searchResults) => {
282
+ const elAiChat = this.shadowRoot.querySelector("nx-ai-chat");
283
+ const columnInfo = this.#getColumnInfo();
284
+ const uniqueKey = this.#getUniqueKey();
285
+
286
+ const isAnalyze = this.settings.shadowRoot.querySelector("#chkAnalyze")?.checked || false;
287
+ const analyzeCnt = Number(this.settings.shadowRoot.querySelector("#txtAnalyze")?.value || 0);
288
+
289
+ console.log(searchResults);
290
+
291
+ if (!searchResults || searchResults.length == 0) {
292
+ elAiChat.add("ai", "관련된 정보가 없습니다.");
293
+ }
294
+ else if (isAnalyze && searchResults.length <= analyzeCnt) {
295
+ //let arr = searchResults.map(item => item.payload);
296
+ let contextText = `당신은 주어진 정보를 참고하여 답변하는 AI 입니다. 주어진 '정보'를 바탕으로 데이타를 분석해줘. 데이타는 "context" 항목에 있어. 제공될 데이터는 CSV 형식이며, 첫 번째 줄은 컬럼명입니다. 이전에 너가 찾아준 데이타를 다시 보내는거니깐 첫 줄에는 "${searchResults.length}건을 찾았습니다." 라고 답변을 달아줘.\n\ncontext: `;
297
+
298
+ let contextText1 = `당신은 주어진 데이터를 분석하고 핵심 인사이트를 도출하는 전문 데이터 분석가입니다.
299
+
300
+ 아래에 제공될 데이터는 CSV 형식이며, 첫 번째 줄은 컬럼명입니다.
301
+
302
+ 이 데이터를 철저히 분석하여 다음 질문에 대한 답변을 제공해주세요:
303
+ - 데이터의 주요 트렌드는 무엇인가요?
304
+ - 특이하거나 주목할 만한 패턴이 있나요?
305
+ - 가장 빈번하게 나타나는 항목(또는 값)은 무엇인가요?
306
+ - 데이터에서 발견할 수 있는 잠재적인 문제점이나 개선점은 무엇인가요?
307
+ - 각 데이터 레코드를 기반으로 한 간략한 요약 또는 분류가 가능할까요?
308
+
309
+ 분석 결과는 다음 형식으로 제공해주세요:
310
+ 1. **주요 트렌드**: 불릿 포인트 형식으로 요약
311
+ 2. **주목할 만한 패턴/이상치**: 구체적인 예시와 함께 설명
312
+ 3. **가장 빈번한 항목**: 항목명과 빈도수를 포함 (테이블 형식도 가능)
313
+ 4. **개선점/문제점**: 텍스트 설명
314
+ 5. **각 레코드에 대한 간략한 요약 또는 분류**: (선택 사항, 필요시)
315
+
316
+ context:\n`;
317
+
318
+ columnInfo.forEach(info => {
319
+ contextText += `${info.desc}\t`;
320
+ });
321
+ contextText += "\n";
322
+
323
+ for (const o of searchResults) {
324
+ columnInfo.forEach(info => {
325
+ contextText += `${o[info.name]}\t`;
326
+ });
327
+
328
+ contextText += "\n";
329
+ //contextText += `문서ID: ${o.doc_id}, 문서명: ${o.doc_nm}, 매출액: ${o.amt}, 수정자: ${o.update_user}, 수정일: ${o.update_dt}\n`;
330
+ }
331
+
332
+ //console.log(contextText);
333
+
334
+ const prompt = PromptTemplate.fromTemplate(
335
+ contextText
336
+ )
337
+
338
+ const chain = prompt
339
+ .pipe(this.#model)
340
+ .pipe(new StringOutputParser())
341
+
342
+ const result = await chain.invoke();
343
+
344
+ elAiChat.add("ai", result, columnInfo, searchResults, uniqueKey);
345
+ }
346
+ else {
347
+ //elAiChat.add("ai", `${searchResults.length}건의 정보를 찾았습니다.`, columnInfo, searchResults.map(item => item.payload), uniqueKey);
348
+ elAiChat.add("ai", `${searchResults.length}건의 정보를 찾았습니다.`, columnInfo, searchResults, uniqueKey);
349
+ }
350
+ };
351
+
352
+ #init = (info) => {
353
+
354
+ this.settings = this.shadowRoot.querySelector("nx-ai-settings");
355
+
356
+ //this.#elChat = this.shadowRoot.querySelector("nx-ai-chat");
357
+
358
+ this.shadowRoot.querySelector("textarea").addEventListener("keydown", this.#keydownHandler);
359
+
360
+ this.shadowRoot.querySelector(".expand-icon").addEventListener("click", this.#toggleCollapseHandler);
361
+ this.shadowRoot.querySelector(".collapse-icon").addEventListener("click", this.#toggleCollapseHandler);
362
+
363
+ this.shadowRoot.querySelectorAll(".menu-icon").forEach(el => el.addEventListener("click", this.#menuClickHandler));
364
+ };
365
+
366
+ #keydownHandler = async (e) => {
367
+ if (e.key !== "Enter") return;
368
+
369
+ if (this.#target.tagName === "NINE-GRID") {
370
+ }
371
+ else {
372
+ throw new Error("지원하지 않는 그리드 입니다.");
373
+ }
374
+
375
+ e.preventDefault();
376
+
377
+ const question = e.target.value.trim();
378
+ if (!question) return;
379
+
380
+ if (this.#ing) return;
381
+ this.#ing = true;
382
+
383
+ /**
384
+ * setTimeout 없으면, 맥에서 한글 잔상이 남음
385
+ * setTimeout 내에서 e.target이 nx-ai-container가 된다.
386
+ */
387
+ setTimeout(() => {
388
+ this.shadowRoot.querySelector("textarea").value = "";
389
+ });
390
+
391
+ const elAiChat = this.shadowRoot.querySelector("nx-ai-chat");
392
+
393
+ elAiChat.add("me", question);
394
+ elAiChat.add("ing", question);
395
+
396
+ try {
397
+ this.#createModel();
398
+
399
+ if (this.shadowRoot.querySelector(".menu-filter").classList.contains("active")) {
400
+ await this.#q1();
401
+ }
402
+ else if (this.shadowRoot.querySelector(".menu-general").classList.contains("active")) {
403
+ await this.#q2();
404
+ }
405
+ else {
406
+ await this.#q2();
407
+ }
408
+ } catch (error) {
409
+ console.error(error);
410
+ elAiChat.add("ai", error);
411
+ }
412
+
413
+
414
+ this.#ing = false;
415
+ }
416
+
417
+ #toggleCollapseHandler = () => {
418
+ this.classList.toggle("collapse");
419
+ };
420
+
421
+ #menuClickHandler = (e) => {
422
+
423
+ // 모든 `.menu-icon`에서 `active` 클래스 제거
424
+ this.shadowRoot.querySelectorAll(".menu-icon").forEach(el => el.classList.remove("active"));
425
+
426
+ // 클릭한 `.menu-icon`에 `active` 클래스 추가
427
+ const clickedIcon = e.target.closest(".menu-icon");
428
+ if (clickedIcon) clickedIcon.classList.add("active");
429
+
430
+ // `.menu-setting`이 클릭되었는지 확인 후 `nx-ai-settings` 토글
431
+ this.settings.classList.toggle("expand", !!e.target.closest(".menu-setting"));
432
+ };
433
+ }
434
+
435
+
436
+ if (!customElements.get('nx-ai-container')) {
437
+ customElements.define("nx-ai-container", aiContainer);
438
+ }