viruagent 1.0.1 → 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.
@@ -0,0 +1,484 @@
1
+ # 하이브리드 DB 에이전트 설계 가이드
2
+
3
+ 자연어로 데이터베이스를 조회하는 AI 에이전트의 스키마 관리 전략.
4
+
5
+ **캐싱(부팅 시 스키마 로드)** + **도구(상세 조회)**를 조합하여 정확도와 성능을 모두 확보한다.
6
+
7
+ ---
8
+
9
+ ## 1. 전체 아키텍처
10
+
11
+ ```
12
+ [서버 부팅]
13
+ └── DB에서 스키마 캐싱 (테이블, 컬럼, FK 관계)
14
+
15
+ [사용자 요청] "이번 달 매출 상위 고객 보여줘"
16
+
17
+ [시스템 프롬프트]
18
+ ├── 캐싱된 스키마 요약 (항상 포함)
19
+ └── "상세 정보가 필요하면 get_schema 도구를 사용하라"
20
+
21
+ [에이전트 루프]
22
+ ├── AI가 캐싱된 스키마로 충분하면 → 바로 query_database 호출
23
+ └── 컬럼 타입, 제약조건 등 상세 필요하면 → get_schema 먼저 호출
24
+
25
+ [결과 반환] "매출 상위 고객은 ..."
26
+ ```
27
+
28
+ ---
29
+
30
+ ## 2. 스키마 캐싱 (서버 부팅 시)
31
+
32
+ 서버가 시작될 때 한 번 DB에서 스키마 정보를 읽어 메모리에 저장한다.
33
+
34
+ ### 2-1. 캐싱 쿼리
35
+
36
+ ```js
37
+ const loadSchema = async (client) => {
38
+ // 테이블 + 컬럼 정보
39
+ const columns = await client.query(`
40
+ SELECT table_name, column_name, data_type, is_nullable,
41
+ column_default
42
+ FROM information_schema.columns
43
+ WHERE table_schema = 'public'
44
+ ORDER BY table_name, ordinal_position
45
+ `);
46
+
47
+ // FK 관계
48
+ const fks = await client.query(`
49
+ SELECT
50
+ tc.table_name AS from_table,
51
+ kcu.column_name AS from_column,
52
+ ccu.table_name AS to_table,
53
+ ccu.column_name AS to_column
54
+ FROM information_schema.table_constraints tc
55
+ JOIN information_schema.key_column_usage kcu
56
+ ON tc.constraint_name = kcu.constraint_name
57
+ JOIN information_schema.constraint_column_usage ccu
58
+ ON tc.constraint_name = ccu.constraint_name
59
+ WHERE tc.constraint_type = 'FOREIGN KEY'
60
+ AND tc.table_schema = 'public'
61
+ `);
62
+
63
+ // 테이블별 그룹핑
64
+ const grouped = {};
65
+ for (const row of columns.rows) {
66
+ if (!grouped[row.table_name]) grouped[row.table_name] = [];
67
+ grouped[row.table_name].push(`${row.column_name} (${row.data_type})`);
68
+ }
69
+
70
+ // 텍스트로 변환
71
+ let schema = '## 테이블 스키마\n';
72
+ schema += Object.entries(grouped)
73
+ .map(([table, cols]) => `- ${table}: ${cols.join(', ')}`)
74
+ .join('\n');
75
+
76
+ if (fks.rows.length > 0) {
77
+ schema += '\n\n## 관계 (FK)\n';
78
+ schema += fks.rows
79
+ .map(r => `- ${r.from_table}.${r.from_column} → ${r.to_table}.${r.to_column}`)
80
+ .join('\n');
81
+ }
82
+
83
+ return schema;
84
+ };
85
+ ```
86
+
87
+ ### 2-2. 캐싱 결과 예시
88
+
89
+ ```
90
+ ## 테이블 스키마
91
+ - users: id (integer), email (varchar), name (varchar), role (varchar), created_at (timestamp)
92
+ - orders: id (integer), user_id (integer), total (numeric), status (varchar), created_at (timestamp)
93
+ - products: id (integer), name (varchar), price (numeric), category (varchar), stock (integer)
94
+ - order_items: id (integer), order_id (integer), product_id (integer), quantity (integer), price (numeric)
95
+
96
+ ## 관계 (FK)
97
+ - orders.user_id → users.id
98
+ - order_items.order_id → orders.id
99
+ - order_items.product_id → products.id
100
+ ```
101
+
102
+ AI는 이것만 보고도 대부분의 JOIN 쿼리를 정확하게 생성할 수 있다.
103
+
104
+ ### 2-3. 캐시 갱신
105
+
106
+ ```js
107
+ // 서버 시작 시
108
+ let schemaCache = await loadSchema(client);
109
+
110
+ // 주기적 갱신 (선택)
111
+ setInterval(async () => {
112
+ schemaCache = await loadSchema(client);
113
+ }, 1000 * 60 * 30); // 30분마다
114
+
115
+ // 수동 갱신 API (어드민용)
116
+ app.post('/api/admin/refresh-schema', async (req, res) => {
117
+ schemaCache = await loadSchema(client);
118
+ res.json({ success: true });
119
+ });
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 3. 도구 정의
125
+
126
+ ### 3-1. tools 배열
127
+
128
+ ```js
129
+ const tools = [
130
+ {
131
+ type: 'function',
132
+ function: {
133
+ name: 'query_database',
134
+ description: 'PostgreSQL에 SELECT 쿼리를 실행합니다. READ ONLY. 결과는 최대 100행.',
135
+ parameters: {
136
+ type: 'object',
137
+ properties: {
138
+ sql: { type: 'string', description: 'SELECT 쿼리문' },
139
+ },
140
+ required: ['sql'],
141
+ },
142
+ },
143
+ },
144
+ {
145
+ type: 'function',
146
+ function: {
147
+ name: 'get_schema',
148
+ description: '특정 테이블의 상세 스키마를 조회합니다. 컬럼 타입, 기본값, NOT NULL, 인덱스, 코멘트 등 캐싱된 요약보다 상세한 정보가 필요할 때 사용하세요.',
149
+ parameters: {
150
+ type: 'object',
151
+ properties: {
152
+ table_name: { type: 'string', description: '조회할 테이블명' },
153
+ },
154
+ required: ['table_name'],
155
+ },
156
+ },
157
+ },
158
+ ];
159
+ ```
160
+
161
+ ### 3-2. AI의 판단 기준
162
+
163
+ | 상황 | AI 행동 |
164
+ |------|---------|
165
+ | "주문 많은 고객 보여줘" | 캐시에 users, orders, FK 있음 → 바로 `query_database` |
166
+ | "users 테이블에 soft delete 있어?" | 캐시만으론 모름 → `get_schema("users")` 먼저 |
167
+ | "인덱스 걸린 컬럼이 뭐야?" | 캐시에 없음 → `get_schema` 호출 |
168
+
169
+ ---
170
+
171
+ ## 4. Tool Executor
172
+
173
+ ### 4-1. query_database
174
+
175
+ ```js
176
+ const executeQuery = async (sql, client) => {
177
+ // 1차: SQL 파싱 검증
178
+ const normalized = sql.trim().toUpperCase();
179
+ if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) {
180
+ return { error: 'SELECT / WITH 쿼리만 허용됩니다.' };
181
+ }
182
+
183
+ const blocked = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'GRANT', 'REVOKE'];
184
+ for (const kw of blocked) {
185
+ // SELECT 뒤에 나오는 서브쿼리 내 키워드도 체크
186
+ if (normalized.includes(kw + ' ')) {
187
+ return { error: `${kw} 키워드가 포함된 쿼리는 실행할 수 없습니다.` };
188
+ }
189
+ }
190
+
191
+ // 2차: read-only 트랜잭션
192
+ try {
193
+ await client.query('BEGIN READ ONLY');
194
+ const result = await client.query(sql);
195
+ await client.query('COMMIT');
196
+
197
+ return {
198
+ columns: result.fields.map(f => f.name),
199
+ rows: result.rows.slice(0, 100),
200
+ totalRows: result.rowCount,
201
+ truncated: result.rowCount > 100,
202
+ };
203
+ } catch (e) {
204
+ await client.query('ROLLBACK');
205
+ return { error: `쿼리 실행 오류: ${e.message}` };
206
+ }
207
+ };
208
+ ```
209
+
210
+ ### 4-2. get_schema (상세 조회)
211
+
212
+ ```js
213
+ const getDetailedSchema = async (tableName, client) => {
214
+ // 컬럼 상세
215
+ const columns = await client.query(`
216
+ SELECT
217
+ c.column_name,
218
+ c.data_type,
219
+ c.character_maximum_length,
220
+ c.is_nullable,
221
+ c.column_default,
222
+ pgd.description AS comment
223
+ FROM information_schema.columns c
224
+ LEFT JOIN pg_catalog.pg_description pgd
225
+ ON pgd.objsubid = c.ordinal_position
226
+ AND pgd.objoid = (SELECT oid FROM pg_class WHERE relname = $1)
227
+ WHERE c.table_name = $1 AND c.table_schema = 'public'
228
+ ORDER BY c.ordinal_position
229
+ `, [tableName]);
230
+
231
+ // 인덱스
232
+ const indexes = await client.query(`
233
+ SELECT indexname, indexdef
234
+ FROM pg_indexes
235
+ WHERE tablename = $1 AND schemaname = 'public'
236
+ `, [tableName]);
237
+
238
+ // FK (이 테이블에서 나가는)
239
+ const fksOut = await client.query(`
240
+ SELECT kcu.column_name, ccu.table_name AS ref_table, ccu.column_name AS ref_column
241
+ FROM information_schema.table_constraints tc
242
+ JOIN information_schema.key_column_usage kcu
243
+ ON tc.constraint_name = kcu.constraint_name
244
+ JOIN information_schema.constraint_column_usage ccu
245
+ ON tc.constraint_name = ccu.constraint_name
246
+ WHERE tc.constraint_type = 'FOREIGN KEY'
247
+ AND tc.table_name = $1
248
+ `, [tableName]);
249
+
250
+ // FK (이 테이블로 들어오는)
251
+ const fksIn = await client.query(`
252
+ SELECT tc.table_name AS from_table, kcu.column_name AS from_column
253
+ FROM information_schema.table_constraints tc
254
+ JOIN information_schema.key_column_usage kcu
255
+ ON tc.constraint_name = kcu.constraint_name
256
+ JOIN information_schema.constraint_column_usage ccu
257
+ ON tc.constraint_name = ccu.constraint_name
258
+ WHERE tc.constraint_type = 'FOREIGN KEY'
259
+ AND ccu.table_name = $1
260
+ `, [tableName]);
261
+
262
+ return {
263
+ table: tableName,
264
+ columns: columns.rows,
265
+ indexes: indexes.rows,
266
+ foreignKeysOut: fksOut.rows,
267
+ foreignKeysIn: fksIn.rows,
268
+ };
269
+ };
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 5. 시스템 프롬프트
275
+
276
+ ```js
277
+ const buildSystemPrompt = (schemaCache) => `
278
+ 당신은 데이터베이스 조회 어시스턴트입니다.
279
+ 사용자의 자연어 질문을 SQL SELECT 쿼리로 변환하여 실행합니다.
280
+
281
+ ## 규칙
282
+ - SELECT 쿼리만 생성하세요. 데이터 변경은 불가합니다.
283
+ - 결과가 많을 수 있으니 LIMIT을 적절히 사용하세요.
284
+ - 날짜 필터가 모호하면 사용자에게 확인하세요.
285
+ - 쿼리 결과를 사용자가 이해하기 쉽게 요약해서 설명하세요.
286
+ - 상세 스키마 정보(인덱스, 코멘트 등)가 필요하면 get_schema 도구를 사용하세요.
287
+
288
+ ${schemaCache}
289
+ `;
290
+ ```
291
+
292
+ ---
293
+
294
+ ## 6. 에이전트 루프
295
+
296
+ ViruAgent의 `runAgent`와 동일한 구조:
297
+
298
+ ```js
299
+ const runDbAgent = async (userMessage, { schemaCache, client, chatHistory }) => {
300
+ chatHistory.push({ role: 'user', content: userMessage });
301
+
302
+ const messages = [
303
+ { role: 'system', content: buildSystemPrompt(schemaCache) },
304
+ ...chatHistory,
305
+ ];
306
+
307
+ const MAX_LOOPS = 10;
308
+
309
+ for (let i = 0; i < MAX_LOOPS; i++) {
310
+ const res = await openai.chat.completions.create({
311
+ model: 'gpt-4o-mini',
312
+ messages,
313
+ tools,
314
+ temperature: 0, // SQL 생성은 정확도 우선
315
+ });
316
+
317
+ const msg = res.choices[0].message;
318
+ messages.push(msg);
319
+
320
+ if (!msg.tool_calls?.length) {
321
+ chatHistory.push({ role: 'assistant', content: msg.content });
322
+ return msg.content;
323
+ }
324
+
325
+ for (const tc of msg.tool_calls) {
326
+ const args = JSON.parse(tc.function.arguments);
327
+ let result;
328
+
329
+ switch (tc.function.name) {
330
+ case 'query_database':
331
+ result = await executeQuery(args.sql, client);
332
+ break;
333
+ case 'get_schema':
334
+ result = await getDetailedSchema(args.table_name, client);
335
+ break;
336
+ default:
337
+ result = { error: `알 수 없는 도구: ${tc.function.name}` };
338
+ }
339
+
340
+ messages.push({
341
+ role: 'tool',
342
+ tool_call_id: tc.id,
343
+ content: JSON.stringify(result),
344
+ });
345
+ }
346
+ }
347
+
348
+ return '쿼리가 너무 복잡합니다. 질문을 나눠서 요청해주세요.';
349
+ };
350
+ ```
351
+
352
+ ---
353
+
354
+ ## 7. 실전 흐름 추적
355
+
356
+ ### 시나리오 1: 단순 조회 (캐시만으로 해결)
357
+
358
+ ```
359
+ 사용자: "이번 달 주문 건수 알려줘"
360
+
361
+ [루프 1]
362
+ 시스템 프롬프트에 캐시된 스키마 포함
363
+ → AI: orders 테이블에 created_at 있네 → 바로 SQL 생성
364
+ → tool_calls: query_database
365
+ sql: "SELECT COUNT(*) as cnt FROM orders WHERE created_at >= '2026-02-01'"
366
+ → 결과: [{ cnt: 1847 }]
367
+
368
+ [루프 2]
369
+ → AI: "이번 달 주문은 총 1,847건입니다."
370
+ → 루프 종료
371
+
372
+ 총 루프: 2회, get_schema 호출: 0회
373
+ ```
374
+
375
+ ### 시나리오 2: 복잡한 조회 (상세 스키마 필요)
376
+
377
+ ```
378
+ 사용자: "soft delete된 사용자 중에 주문 있는 사람 보여줘"
379
+
380
+ [루프 1]
381
+ → AI: 캐시에 users 테이블은 있지만 soft delete 컬럼이 뭔지 모르겠다
382
+ → tool_calls: get_schema("users")
383
+ → 결과: { columns: [..., { column_name: "deleted_at", data_type: "timestamp", is_nullable: "YES" }] }
384
+
385
+ [루프 2]
386
+ → AI: deleted_at이 NOT NULL이면 soft delete구나
387
+ → tool_calls: query_database
388
+ sql: "SELECT u.name, u.email, COUNT(o.id) as order_count
389
+ FROM users u
390
+ JOIN orders o ON o.user_id = u.id
391
+ WHERE u.deleted_at IS NOT NULL
392
+ GROUP BY u.id, u.name, u.email"
393
+ → 결과: [{ name: "김철수", email: "...", order_count: 5 }, ...]
394
+
395
+ [루프 3]
396
+ → AI: "soft delete된 사용자 중 주문이 있는 분은 3명입니다: ..."
397
+ → 루프 종료
398
+
399
+ 총 루프: 3회, get_schema 호출: 1회
400
+ ```
401
+
402
+ ---
403
+
404
+ ## 8. 안전장치 요약
405
+
406
+ ```
407
+ ┌─────────────────────────────────────────────┐
408
+ │ Layer 1: 시스템 프롬프트 │
409
+ │ → "SELECT만 생성하라" │
410
+ ├─────────────────────────────────────────────┤
411
+ │ Layer 2: Tool Executor (SQL 파싱) │
412
+ │ → SELECT/WITH 외 차단 │
413
+ │ → INSERT/UPDATE/DELETE/DROP 키워드 감지 │
414
+ ├─────────────────────────────────────────────┤
415
+ │ Layer 3: DB 연결 │
416
+ │ → BEGIN READ ONLY 트랜잭션 │
417
+ │ → 또는 read replica 연결 │
418
+ ├─────────────────────────────────────────────┤
419
+ │ Layer 4: 결과 제한 │
420
+ │ → 최대 100행 반환 │
421
+ │ → 타임아웃 설정 (statement_timeout) │
422
+ └─────────────────────────────────────────────┘
423
+ ```
424
+
425
+ ### DB 레벨 추가 보호 (권장)
426
+
427
+ ```sql
428
+ -- 전용 read-only 유저 생성
429
+ CREATE USER db_agent_readonly WITH PASSWORD '...';
430
+ GRANT CONNECT ON DATABASE mydb TO db_agent_readonly;
431
+ GRANT USAGE ON SCHEMA public TO db_agent_readonly;
432
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO db_agent_readonly;
433
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
434
+ GRANT SELECT ON TABLES TO db_agent_readonly;
435
+
436
+ -- 쿼리 타임아웃 (느린 쿼리 방지)
437
+ ALTER USER db_agent_readonly SET statement_timeout = '10s';
438
+ ```
439
+
440
+ ---
441
+
442
+ ## 9. 토큰 비용
443
+
444
+ | 항목 | 토큰 수 |
445
+ |------|---------|
446
+ | 시스템 프롬프트 (규칙) | ~200 |
447
+ | 캐싱된 스키마 (10개 테이블) | ~400 |
448
+ | 캐싱된 스키마 (50개 테이블) | ~2,000 |
449
+ | tools 정의 (2개) | ~300 |
450
+ | get_schema 결과 (1회) | ~500 |
451
+ | query_database 결과 (50행) | ~1,000 |
452
+
453
+ ### 테이블이 많을 때 최적화
454
+
455
+ ```js
456
+ // 50개 이상이면 테이블명만 캐싱, 컬럼은 get_schema로
457
+ const loadLightSchema = async (client) => {
458
+ const tables = await client.query(`
459
+ SELECT table_name,
460
+ obj_description(('"' || table_name || '"')::regclass) AS comment
461
+ FROM information_schema.tables
462
+ WHERE table_schema = 'public'
463
+ `);
464
+
465
+ return '## 테이블 목록\n' +
466
+ tables.rows.map(t =>
467
+ `- ${t.table_name}${t.comment ? ` (${t.comment})` : ''}`
468
+ ).join('\n') +
469
+ '\n\n컬럼 정보가 필요하면 get_schema 도구를 사용하세요.';
470
+ };
471
+ ```
472
+
473
+ ---
474
+
475
+ ## 10. 하이브리드 방식을 선택한 이유
476
+
477
+ | 방식 | 장점 | 단점 |
478
+ |------|------|------|
479
+ | 하드코딩 | 빠름, 정확 | 스키마 변경 시 코드 수정 |
480
+ | 도구만 (MCP 방식) | 항상 최신 | 매번 1~2루프 낭비 |
481
+ | 캐싱만 | 빠름, 자동 | 상세 정보 부족 |
482
+ | **하이브리드** | **빠름 + 자동 + 상세** | 구현 약간 복잡 |
483
+
484
+ 하이브리드는 **90%의 쿼리는 캐시로 즉시 처리**하고, **10%의 복잡한 케이스만 도구로 상세 조회**한다. 토큰 절약과 정확도를 동시에 달성하는 최적의 전략이다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viruagent",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "AI 기반 티스토리 블로그 자동 발행 CLI 도구",
5
5
  "main": "src/agent.js",
6
6
  "bin": {
@@ -30,7 +30,6 @@
30
30
  "type": "commonjs",
31
31
  "dependencies": {
32
32
  "chalk": "^4.1.2",
33
- "dotenv": "^16.4.7",
34
33
  "oh-my-logo": "^0.4.0",
35
34
  "openai": "^4.77.0",
36
35
  "playwright": "^1.58.2"