egovframe-scaffold-mcp 0.7.0 → 0.9.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 CHANGED
@@ -23,6 +23,7 @@ Claude, VS Code(Copilot), Cursor 등 MCP를 지원하는 AI 도구에서 **대
23
23
  | `remove_egovframe_components` | 설치 매니페스트 기반 컴포넌트 제거 — 의존 컴포넌트 보호, `dryRun` 미리보기 |
24
24
  | `validate_egovframe_project` | 조립 프로젝트 무결성 진단 — 파일 존재·DbType↔DB 스크립트 일치 |
25
25
  | `get_egovframe_guide` | 컴포넌트의 공식 가이드 문서 조회 (egovframe-docs, 45종 매핑) |
26
+ | `add_ai_components` | 공식 [egovframe-ai-rag](https://github.com/eGovFramework/egovframe-ai-rag) 샘플 기반 AI RAG 챗봇 조립 — Spring AI(Redis Stack)·LangChain4j(PGVector) 스택 선택(상호 배타), 소스·설정(`application-ai.yml` 프로필)·UI·인프라 복사, **pom 누락 의존성만 마커 구간 삽입**(백업 생성, 제거 시 원복), 충돌 시 전체 거부, `dryRun` 미리보기 (설계: [docs/design-ai-components.md](docs/design-ai-components.md)) |
26
27
 
27
28
  ### create_egovframe_project 파라미터
28
29
 
@@ -106,11 +107,15 @@ Claude Desktop / Claude Code 설정 예 (`mcpServers`):
106
107
 
107
108
  1. 공통컴포넌트 선택 설치 — **M3 완료(v0.4.0)**: 저장소 스캔 기반 카탈로그 자동 생성(`npm run generate:catalog`, 68종) + 조립 파이프라인. 남은 항목: 컴포넌트별 테이블 선별 DDL, 이름·의존성 큐레이션 확대(`catalog/overrides.json` 기여 환영) (설계: [docs/design-components-parameter.md](docs/design-components-parameter.md))
108
109
  2. 실행환경 보일러플레이트(egovframe-msa 등) 템플릿 추가
109
- 3. 검증 도구(`validate_egovframe_config`) 등 후속 도구
110
+ 3. AI 컴포넌트 조립(`add_ai_components`) — **M2 완료(v0.9.0)**: 실조립(복사+pom 마커 병합+설정 프로필화+remove/validate 연동). M3: langchain4j 실검증 확대, 진단 확장(ONNX 모델 경로·docker 서비스 감지), 패키지 재배치 옵션 검토 (설계: [docs/design-ai-components.md](docs/design-ai-components.md))
110
111
  4. 검증 후 eGovFramework 조직 공식 저장소 편입 제안
111
112
 
112
113
  ## 변경 이력
113
114
 
115
+ - **0.9.0** — AI 컴포넌트 조립 M2(실조립): `add_ai_components`가 실제로 조립합니다 — 소스(`com.example.chat`)·설정(`application-ai.yml` 프로필, 기존 설정 불변)·UI·인프라(`docker-compose.ai.yml`·`Dockerfile.ai`·`k8s/ai/`) 복사(전체 사전 충돌 검사·원자적 거부), pom에 누락 좌표만 마커 주석 구간으로 삽입(exclusions 보존, `pom.xml.bak-ai` 백업), 매니페스트 기록으로 `remove_egovframe_components`가 파일·pom 삽입분을 함께 원복(바이트 단위 복원 검증), `validate_egovframe_project`에 pom 마커 진단 추가, 통합 테스트(`npm run test:ai-assembly`) 추가.
116
+
117
+ - **0.8.0** — AI 컴포넌트 조립 M1: `add_ai_components` dryRun 미리보기(파일 복사 계획·pom 의존성 diff·부모 POM 호환성 게이트·스택 상호 배타 검사), AI 카탈로그(`catalog/ai-components.json`, egovframe-ai-rag 모듈 스캔 자동 생성 `npm run generate:ai-catalog`), `list_egovframe_components`에 AI 컴포넌트 노출, 오프라인 테스트(`npm run test:ai`) 추가.
118
+
114
119
  - **0.7.0** — `get_egovframe_guide`: 컴포넌트 id로 표준프레임워크 공식 가이드 문서(egovframe-docs)를 조회. 카탈로그에 문서 매핑 자동 생성(`--docs`, 지배적 패키지 참조 기준 45종) 추가. 한글명 큐레이션 12→27종.
115
120
 
116
121
  - **0.6.0** — 컴포넌트별 테이블 선별 DDL(M4): 카탈로그에 매퍼 기반 참조 테이블 자동 추출(48/68종), `database` 지정 시 통합 스크립트에서 해당 컴포넌트 구문만 추출해 `ddl|dml/<컴포넌트id>.sql` 생성(테이블 미상 컴포넌트는 통합본 폴백), 매니페스트에 컴포넌트별 스크립트 귀속(제거 시 함께 정리).
@@ -0,0 +1,369 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "source": {
4
+ "repo": "eGovFramework/egovframe-ai-rag",
5
+ "branch": "main",
6
+ "surveyedAt": "2026-07-12"
7
+ },
8
+ "note": "add_ai_components용 카탈로그 — 조립 대상은 egovframe-boot-starter-parent 기반 Boot 프로젝트. 두 스택은 같은 패키지(com.example.chat)·UI 경로를 사용하므로 상호 배타(conflictsWith).",
9
+ "components": [
10
+ {
11
+ "id": "ai-rag-spring-ai",
12
+ "stack": "spring-ai",
13
+ "kind": "ai",
14
+ "name": "AI RAG 챗봇 (Spring AI + Redis Stack)",
15
+ "description": "Spring AI 기반 RAG 질의응답 — 문서 업로드(PDF·DOCX·HWP·HWPX·MD)→임베딩→Redis Stack 하이브리드 검색→Ollama LLM 응답, 채팅 UI 포함",
16
+ "modulePath": "spring-ai-rag-redis-stack",
17
+ "vectorStore": "Redis Stack",
18
+ "conflictsWith": [
19
+ "ai-rag-langchain4j"
20
+ ],
21
+ "requires": {
22
+ "java": "17",
23
+ "parent": "egovframe-boot-starter-parent:5.0.0"
24
+ },
25
+ "copyGroups": {
26
+ "source": {
27
+ "paths": [
28
+ "src/main/java/com/example/chat/"
29
+ ],
30
+ "files": 45
31
+ },
32
+ "config": {
33
+ "paths": [
34
+ "src/main/resources/application.yml -> src/main/resources/application-ai.yml",
35
+ "src/main/resources/log4j2-spring.xml -> src/main/resources/log4j2-ai.xml"
36
+ ],
37
+ "files": 2
38
+ },
39
+ "ui": {
40
+ "paths": [
41
+ "src/main/resources/templates/",
42
+ "src/main/resources/static/"
43
+ ],
44
+ "files": 3
45
+ },
46
+ "infra": {
47
+ "paths": [
48
+ "docker-compose.yml -> docker-compose.ai.yml",
49
+ "Dockerfile -> Dockerfile.ai",
50
+ "k8s/ -> k8s/ai/"
51
+ ],
52
+ "files": 6
53
+ },
54
+ "tests": {
55
+ "paths": [
56
+ "src/test/"
57
+ ],
58
+ "files": 15
59
+ }
60
+ },
61
+ "approxFiles": 56,
62
+ "mavenDependencies": [
63
+ {
64
+ "groupId": "org.springframework.boot",
65
+ "artifactId": "spring-boot-starter-actuator",
66
+ "exclusions": [
67
+ {
68
+ "groupId": "org.springframework.boot",
69
+ "artifactId": "spring-boot-starter-logging"
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ "groupId": "org.springframework.ai",
75
+ "artifactId": "spring-ai-client-chat"
76
+ },
77
+ {
78
+ "groupId": "org.springframework.ai",
79
+ "artifactId": "spring-ai-rag"
80
+ },
81
+ {
82
+ "groupId": "org.springframework.ai",
83
+ "artifactId": "spring-ai-starter-vector-store-redis"
84
+ },
85
+ {
86
+ "groupId": "org.springframework.ai",
87
+ "artifactId": "spring-ai-starter-model-ollama"
88
+ },
89
+ {
90
+ "groupId": "org.springframework.ai",
91
+ "artifactId": "spring-ai-advisors-vector-store"
92
+ },
93
+ {
94
+ "groupId": "org.springframework.ai",
95
+ "artifactId": "spring-ai-starter-model-transformers"
96
+ },
97
+ {
98
+ "groupId": "org.springframework.ai",
99
+ "artifactId": "spring-ai-pdf-document-reader"
100
+ },
101
+ {
102
+ "groupId": "org.springframework.ai",
103
+ "artifactId": "spring-ai-markdown-document-reader"
104
+ },
105
+ {
106
+ "groupId": "org.egovframe.rte",
107
+ "artifactId": "egovframe-rte-fdl-excel"
108
+ },
109
+ {
110
+ "groupId": "org.springframework.boot",
111
+ "artifactId": "spring-boot-starter-thymeleaf",
112
+ "exclusions": [
113
+ {
114
+ "groupId": "org.springframework.boot",
115
+ "artifactId": "spring-boot-starter-logging"
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "groupId": "org.springframework.boot",
121
+ "artifactId": "spring-boot-starter-web",
122
+ "exclusions": [
123
+ {
124
+ "groupId": "org.springframework.boot",
125
+ "artifactId": "spring-boot-starter-logging"
126
+ }
127
+ ]
128
+ },
129
+ {
130
+ "groupId": "org.egovframe.rte",
131
+ "artifactId": "egovframe-rte-ptl-mvc",
132
+ "exclusions": [
133
+ {
134
+ "groupId": "org.apache.commons",
135
+ "artifactId": "commons-lang3"
136
+ }
137
+ ]
138
+ },
139
+ {
140
+ "groupId": "kr.dogfoot",
141
+ "artifactId": "hwplib",
142
+ "version": "1.1.9"
143
+ },
144
+ {
145
+ "groupId": "org.projectlombok",
146
+ "artifactId": "lombok",
147
+ "optional": true
148
+ },
149
+ {
150
+ "groupId": "kr.dogfoot",
151
+ "artifactId": "hwpxlib",
152
+ "version": "1.0.8"
153
+ },
154
+ {
155
+ "groupId": "org.springframework.boot",
156
+ "artifactId": "spring-boot-starter-test",
157
+ "scope": "test"
158
+ }
159
+ ],
160
+ "mavenProperties": {},
161
+ "prerequisites": [
162
+ "ollama>=0.17.1",
163
+ "onnx-embedding-model",
164
+ "docker"
165
+ ]
166
+ },
167
+ {
168
+ "id": "ai-rag-langchain4j",
169
+ "stack": "langchain4j",
170
+ "kind": "ai",
171
+ "name": "AI RAG 챗봇 (LangChain4j + PGVector)",
172
+ "description": "LangChain4j 기반 RAG 질의응답 — 문서 업로드(PDF·DOCX·HWP·HWPX·MD)→임베딩→PostgreSQL(PGVector) 하이브리드 검색→Ollama LLM 응답, 채팅 UI 포함",
173
+ "modulePath": "langchain4j-ai-rag-postgre",
174
+ "vectorStore": "PostgreSQL (PGVector)",
175
+ "conflictsWith": [
176
+ "ai-rag-spring-ai"
177
+ ],
178
+ "requires": {
179
+ "java": "17",
180
+ "parent": "egovframe-boot-starter-parent:5.0.0"
181
+ },
182
+ "copyGroups": {
183
+ "source": {
184
+ "paths": [
185
+ "src/main/java/com/example/chat/"
186
+ ],
187
+ "files": 49
188
+ },
189
+ "config": {
190
+ "paths": [
191
+ "src/main/resources/application.yml -> src/main/resources/application-ai.yml",
192
+ "src/main/resources/log4j2-spring.xml -> src/main/resources/log4j2-ai.xml"
193
+ ],
194
+ "files": 2
195
+ },
196
+ "ui": {
197
+ "paths": [
198
+ "src/main/resources/templates/",
199
+ "src/main/resources/static/"
200
+ ],
201
+ "files": 3
202
+ },
203
+ "infra": {
204
+ "paths": [
205
+ "docker-compose.yml -> docker-compose.ai.yml",
206
+ "Dockerfile -> Dockerfile.ai",
207
+ "k8s/ -> k8s/ai/",
208
+ "init-scripts/ -> init-scripts/ai/"
209
+ ],
210
+ "files": 9
211
+ },
212
+ "tests": {
213
+ "paths": [
214
+ "src/test/"
215
+ ],
216
+ "files": 14
217
+ }
218
+ },
219
+ "approxFiles": 63,
220
+ "mavenDependencies": [
221
+ {
222
+ "groupId": "org.springframework.boot",
223
+ "artifactId": "spring-boot-starter-actuator",
224
+ "exclusions": [
225
+ {
226
+ "groupId": "org.springframework.boot",
227
+ "artifactId": "spring-boot-starter-logging"
228
+ }
229
+ ]
230
+ },
231
+ {
232
+ "groupId": "dev.langchain4j",
233
+ "artifactId": "langchain4j"
234
+ },
235
+ {
236
+ "groupId": "dev.langchain4j",
237
+ "artifactId": "langchain4j-ollama"
238
+ },
239
+ {
240
+ "groupId": "dev.langchain4j",
241
+ "artifactId": "langchain4j-pgvector"
242
+ },
243
+ {
244
+ "groupId": "dev.langchain4j",
245
+ "artifactId": "langchain4j-embeddings"
246
+ },
247
+ {
248
+ "groupId": "dev.langchain4j",
249
+ "artifactId": "langchain4j-document-parser-apache-pdfbox"
250
+ },
251
+ {
252
+ "groupId": "org.egovframe.rte",
253
+ "artifactId": "egovframe-rte-fdl-excel",
254
+ "exclusions": [
255
+ {
256
+ "groupId": "org.apache.commons",
257
+ "artifactId": "commons-lang3"
258
+ }
259
+ ]
260
+ },
261
+ {
262
+ "groupId": "dev.langchain4j",
263
+ "artifactId": "langchain4j-reactor"
264
+ },
265
+ {
266
+ "groupId": "com.h2database",
267
+ "artifactId": "h2",
268
+ "version": "2.2.224",
269
+ "scope": "test"
270
+ },
271
+ {
272
+ "groupId": "org.postgresql",
273
+ "artifactId": "postgresql",
274
+ "scope": "runtime"
275
+ },
276
+ {
277
+ "groupId": "org.testcontainers",
278
+ "artifactId": "postgresql",
279
+ "scope": "test"
280
+ },
281
+ {
282
+ "groupId": "org.testcontainers",
283
+ "artifactId": "junit-jupiter",
284
+ "scope": "test"
285
+ },
286
+ {
287
+ "groupId": "org.springframework.boot",
288
+ "artifactId": "spring-boot-starter-thymeleaf",
289
+ "exclusions": [
290
+ {
291
+ "groupId": "org.springframework.boot",
292
+ "artifactId": "spring-boot-starter-logging"
293
+ }
294
+ ]
295
+ },
296
+ {
297
+ "groupId": "org.springframework.boot",
298
+ "artifactId": "spring-boot-starter-web",
299
+ "exclusions": [
300
+ {
301
+ "groupId": "org.springframework.boot",
302
+ "artifactId": "spring-boot-starter-logging"
303
+ }
304
+ ]
305
+ },
306
+ {
307
+ "groupId": "org.springframework.boot",
308
+ "artifactId": "spring-boot-starter-webflux",
309
+ "exclusions": [
310
+ {
311
+ "groupId": "org.springframework.boot",
312
+ "artifactId": "spring-boot-starter-logging"
313
+ }
314
+ ]
315
+ },
316
+ {
317
+ "groupId": "org.springframework.boot",
318
+ "artifactId": "spring-boot-starter-data-jpa",
319
+ "exclusions": [
320
+ {
321
+ "groupId": "org.springframework.boot",
322
+ "artifactId": "spring-boot-starter-logging"
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ "groupId": "org.egovframe.rte",
328
+ "artifactId": "egovframe-rte-ptl-mvc",
329
+ "exclusions": [
330
+ {
331
+ "groupId": "org.apache.commons",
332
+ "artifactId": "commons-lang3"
333
+ }
334
+ ]
335
+ },
336
+ {
337
+ "groupId": "commons-codec",
338
+ "artifactId": "commons-codec"
339
+ },
340
+ {
341
+ "groupId": "kr.dogfoot",
342
+ "artifactId": "hwplib",
343
+ "version": "1.1.9"
344
+ },
345
+ {
346
+ "groupId": "kr.dogfoot",
347
+ "artifactId": "hwpxlib",
348
+ "version": "1.0.5"
349
+ },
350
+ {
351
+ "groupId": "org.projectlombok",
352
+ "artifactId": "lombok",
353
+ "optional": true
354
+ },
355
+ {
356
+ "groupId": "org.springframework.boot",
357
+ "artifactId": "spring-boot-starter-test",
358
+ "scope": "test"
359
+ }
360
+ ],
361
+ "mavenProperties": {},
362
+ "prerequisites": [
363
+ "ollama>=0.17.1",
364
+ "onnx-embedding-model",
365
+ "docker"
366
+ ]
367
+ }
368
+ ]
369
+ }
package/dist/index.js CHANGED
@@ -506,6 +506,8 @@ export async function removeComponents(opts) {
506
506
  fs.unlinkSync(target);
507
507
  pruneEmptyDirs(path.dirname(target), projectDir);
508
508
  }
509
+ if (entry.pom)
510
+ stripAiPomAdditions(projectDir, id);
509
511
  delete manifest.components[id];
510
512
  }
511
513
  removed.push({ id, files: entry.files.length, sqlScripts: entry.sqlScripts.length });
@@ -533,6 +535,12 @@ export async function validateProject(opts) {
533
535
  if (missing.length > 0)
534
536
  warnings.push(`컴포넌트 '${id}'의 파일 ${missing.length}개가 없습니다 (수동 삭제 또는 이동 가능성)`);
535
537
  components.push({ id, files: entry.files.length, missing: missing.length, missingSamples: missing.slice(0, 5) });
538
+ if (entry.pom) {
539
+ const pomPath = path.join(projectDir, "pom.xml");
540
+ const pomText = fs.existsSync(pomPath) ? fs.readFileSync(pomPath, "utf-8") : "";
541
+ if (!pomText.includes(`egovframe-scaffold-mcp:ai:${id}:deps:start`) && entry.pom.addedDeps.length > 0)
542
+ warnings.push(`컴포넌트 '${id}'의 pom 삽입 마커가 없습니다 (수동 편집 가능성) — 의존성 ${entry.pom.addedDeps.length}건 확인 필요`);
543
+ }
536
544
  }
537
545
  }
538
546
  else {
@@ -584,10 +592,351 @@ export async function getGuide(componentId, docIndex = 0) {
584
592
  return { componentId, docs, selected: sel, content, truncated };
585
593
  }
586
594
  /* ------------------------------------------------------------------ */
595
+ /* AI 컴포넌트 조립 (M1 미리보기 + M2 실조립) — 설계: docs/design-ai-components.md */
596
+ /* 소스: eGovFramework/egovframe-ai-rag (Spring AI·LangChain4j RAG 샘플) */
597
+ /* ------------------------------------------------------------------ */
598
+ export const AI_STACKS = ["spring-ai", "langchain4j"];
599
+ const AI_CATALOG_URL = new URL("../catalog/ai-components.json", import.meta.url);
600
+ /** AI 카탈로그 로드 + 무결성 검증 (id 중복, conflictsWith 대상 존재, stack 유일) */
601
+ export function loadAiCatalog() {
602
+ const catalog = JSON.parse(fs.readFileSync(AI_CATALOG_URL, "utf-8"));
603
+ const ids = new Set();
604
+ const stacks = new Set();
605
+ for (const c of catalog.components) {
606
+ if (ids.has(c.id))
607
+ throw new Error(`AI 카탈로그 오류: 중복 id '${c.id}'`);
608
+ ids.add(c.id);
609
+ if (stacks.has(c.stack))
610
+ throw new Error(`AI 카탈로그 오류: 중복 stack '${c.stack}'`);
611
+ stacks.add(c.stack);
612
+ }
613
+ for (const c of catalog.components)
614
+ for (const x of c.conflictsWith)
615
+ if (!ids.has(x))
616
+ throw new Error(`AI 카탈로그 오류: '${c.id}'의 conflictsWith '${x}'가 카탈로그에 없습니다`);
617
+ return catalog;
618
+ }
619
+ /** pom 마커 주석 — 제거 시 이 구간만 걷어내 원복한다 */
620
+ const AI_POM_MARKER = (id, kind, pos) => `<!-- egovframe-scaffold-mcp:ai:${id}:${kind}:${pos} -->`;
621
+ const depCoord = (d) => `${d.groupId}:${d.artifactId}${d.version ? ":" + d.version : ""}${d.scope ? " (" + d.scope + ")" : ""}`;
622
+ /** 공통 게이트·의존성 diff·복사 그룹 계산 (dryRun/실조립 공용) */
623
+ function computeAiPlan(opts) {
624
+ if (!AI_STACKS.includes(opts.stack))
625
+ throw new Error(`stack은 ${AI_STACKS.join("|")} 중 하나여야 합니다: ${String(opts.stack)}`);
626
+ const catalog = loadAiCatalog();
627
+ const comp = catalog.components.find((c) => c.stack === opts.stack);
628
+ const projectDir = path.resolve(opts.projectDir);
629
+ // 매니페스트 게이트: 동일/배타 스택 설치 여부
630
+ const manifest = readManifest(projectDir);
631
+ if (manifest) {
632
+ if (manifest.components[comp.id])
633
+ throw new Error(`'${comp.id}'가 이미 설치되어 있습니다 (매니페스트 기준)`);
634
+ for (const x of comp.conflictsWith)
635
+ if (manifest.components[x])
636
+ throw new Error(`상호 배타 컴포넌트 '${x}'가 이미 설치되어 있습니다 — 두 AI 스택은 같은 패키지(com.example.chat)·UI 경로를 사용합니다. 먼저 remove_egovframe_components로 제거하세요`);
637
+ }
638
+ // 호환성 게이트: 부모 POM 좌표 확인 + 의존성/프로퍼티 diff
639
+ const warnings = [];
640
+ const pomPath = path.join(projectDir, "pom.xml");
641
+ const pomFound = fs.existsSync(pomPath);
642
+ let parentFound = null;
643
+ let parentOk = null;
644
+ const toAddDeps = [];
645
+ const alreadyPresent = [];
646
+ const toAddProps = {};
647
+ if (pomFound) {
648
+ const pom = fs.readFileSync(pomPath, "utf-8");
649
+ const pm = pom.match(/<parent>[\s\S]*?<\/parent>/);
650
+ if (pm) {
651
+ const a = pm[0].match(/<artifactId>([^<]*)<\/artifactId>/)?.[1]?.trim();
652
+ const v = pm[0].match(/<version>([^<]*)<\/version>/)?.[1]?.trim();
653
+ parentFound = a ? `${a}:${v ?? "?"}` : null;
654
+ }
655
+ const [reqA, reqV] = comp.requires.parent.split(":");
656
+ parentOk = parentFound !== null && parentFound.startsWith(`${reqA}:`);
657
+ if (!parentOk)
658
+ warnings.push(`부모 POM이 '${comp.requires.parent}'가 아닙니다(발견: ${parentFound ?? "없음"}) — Boot 기반 템플릿(simple-backend)에서 지원합니다`);
659
+ else if (parentFound !== comp.requires.parent)
660
+ warnings.push(`부모 POM 버전이 다릅니다(요구 ${reqV}, 발견 ${parentFound}) — BOM 관리 버전 차이를 확인하세요`);
661
+ for (const d of comp.mavenDependencies) {
662
+ if (pom.includes(`<artifactId>${d.artifactId}</artifactId>`))
663
+ alreadyPresent.push(depCoord(d));
664
+ else
665
+ toAddDeps.push(d);
666
+ }
667
+ for (const [k, v] of Object.entries(comp.mavenProperties))
668
+ if (!pom.includes(`<${k}>`))
669
+ toAddProps[k] = v;
670
+ }
671
+ else {
672
+ warnings.push("pom.xml이 없습니다 — Boot 백엔드 프로젝트 루트 경로인지 확인하세요");
673
+ }
674
+ // 복사 그룹: source·config 필수, ui/infra/tests는 옵션
675
+ const groups = ["source", "config"];
676
+ if (opts.includeUi !== false)
677
+ groups.push("ui");
678
+ if (opts.includeInfra !== false)
679
+ groups.push("infra");
680
+ if (opts.includeTests === true)
681
+ groups.push("tests");
682
+ return {
683
+ comp,
684
+ projectDir,
685
+ compatibility: { pomFound, required: comp.requires.parent, parentFound, parentOk, warnings },
686
+ toAddDeps,
687
+ alreadyPresent,
688
+ toAddProps,
689
+ groups,
690
+ };
691
+ }
692
+ /** AI 컴포넌트 조립 계획 — dryRun 미리보기 (네트워크 불필요) */
693
+ export async function planAiComponents(opts) {
694
+ const p = computeAiPlan(opts);
695
+ const copyPlan = p.groups.map((g) => ({
696
+ group: g,
697
+ files: p.comp.copyGroups[g].files,
698
+ paths: p.comp.copyGroups[g].paths,
699
+ }));
700
+ return {
701
+ projectDir: p.projectDir,
702
+ component: { id: p.comp.id, name: p.comp.name, vectorStore: p.comp.vectorStore },
703
+ compatibility: p.compatibility,
704
+ dependencyChanges: { toAdd: p.toAddDeps.map(depCoord), alreadyPresent: p.alreadyPresent },
705
+ copyPlan,
706
+ totalFiles: copyPlan.reduce((n, g) => n + g.files, 0),
707
+ prerequisites: p.comp.prerequisites,
708
+ nextSteps: ["미리보기 모드입니다. 실제 조립하려면 dryRun 없이 다시 호출하세요."],
709
+ dryRun: true,
710
+ };
711
+ }
712
+ /** 프로세스 수명 동안 AI 샘플 zip을 1회만 내려받기 위한 캐시 */
713
+ let aiZipCache = null;
714
+ async function downloadAiZip(repo, branch) {
715
+ const key = `${repo}@${branch}`;
716
+ if (aiZipCache && aiZipCache.key === key)
717
+ return aiZipCache.zip;
718
+ const zipUrl = `https://codeload.github.com/${repo}/zip/${branch}`;
719
+ const res = await fetchWithTimeout(zipUrl, COMPONENTS_DOWNLOAD_TIMEOUT_MS);
720
+ if (!res.ok)
721
+ throw new Error(`AI 샘플 저장소 다운로드 실패 (${res.status}): ${zipUrl}`);
722
+ const zip = new AdmZip(Buffer.from(await res.arrayBuffer()));
723
+ aiZipCache = { key, zip };
724
+ return zip;
725
+ }
726
+ /** copyGroups 경로("src" 또는 "src -> dest")를 zip 상대경로 → 대상 상대경로 매핑으로 해석 */
727
+ function mapAiEntry(relInModule, groupPaths) {
728
+ for (const spec of groupPaths) {
729
+ const [src, dest] = spec.split("->").map((s) => s.trim());
730
+ const target = dest ?? src;
731
+ if (src.endsWith("/")) {
732
+ if (relInModule.startsWith(src))
733
+ return target + relInModule.slice(src.length);
734
+ }
735
+ else if (relInModule === src) {
736
+ return target;
737
+ }
738
+ }
739
+ return null;
740
+ }
741
+ /** XML 의존성 블록 직렬화 (exclusions·scope·optional 보존) */
742
+ function depToXml(d, indent = " ") {
743
+ const i2 = indent + " ";
744
+ const lines = [`${indent}<dependency>`, `${i2}<groupId>${d.groupId}</groupId>`, `${i2}<artifactId>${d.artifactId}</artifactId>`];
745
+ if (d.version)
746
+ lines.push(`${i2}<version>${d.version}</version>`);
747
+ if (d.scope)
748
+ lines.push(`${i2}<scope>${d.scope}</scope>`);
749
+ if (d.optional)
750
+ lines.push(`${i2}<optional>true</optional>`);
751
+ if (d.exclusions?.length) {
752
+ lines.push(`${i2}<exclusions>`);
753
+ for (const e of d.exclusions)
754
+ lines.push(`${i2} <exclusion>`, `${i2} <groupId>${e.groupId}</groupId>`, `${i2} <artifactId>${e.artifactId}</artifactId>`, `${i2} </exclusion>`);
755
+ lines.push(`${i2}</exclusions>`);
756
+ }
757
+ lines.push(`${indent}</dependency>`);
758
+ return lines.join("\n");
759
+ }
760
+ /** dependencyManagement 밖의 프로젝트 직속 </dependencies> 위치를 찾는다 */
761
+ export function findProjectDependenciesClose(pom) {
762
+ const dmStart = pom.indexOf("<dependencyManagement>");
763
+ const dmEnd = pom.indexOf("</dependencyManagement>");
764
+ let idx = -1;
765
+ const re = /<\/dependencies>/g;
766
+ let m;
767
+ while ((m = re.exec(pom)) !== null) {
768
+ if (dmStart >= 0 && m.index > dmStart && m.index < dmEnd + 24)
769
+ continue; // dependencyManagement 내부
770
+ idx = m.index;
771
+ break;
772
+ }
773
+ return idx;
774
+ }
775
+ export const AI_POM_BACKUP = "pom.xml.bak-ai";
776
+ /**
777
+ * AI 컴포넌트 실조립 (M2).
778
+ * - 파일 복사: 전체 사전 충돌 검사 후 하나라도 충돌하면 아무것도 쓰지 않고 거부 (원자적)
779
+ * - pom 병합: 누락 좌표만 마커 주석 구간으로 삽입(기존 항목 불변), 병합 전 pom.xml.bak-ai 백업
780
+ * - 설정 프로필화: application.yml → application-ai.yml 복사 (기존 설정 파일은 수정하지 않음)
781
+ * - 매니페스트 기록: remove_egovframe_components가 파일과 pom 삽입분을 함께 정리
782
+ */
783
+ export async function addAiComponents(opts) {
784
+ if (opts.dryRun === true) {
785
+ const plan = await planAiComponents(opts);
786
+ return { ...plan, copiedFiles: 0, pomChanged: false, pomBackup: null };
787
+ }
788
+ const p = computeAiPlan(opts);
789
+ const { comp, projectDir } = p;
790
+ if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory())
791
+ throw new Error(`대상 프로젝트 디렉터리가 없습니다: ${projectDir} — 먼저 create_egovframe_project로 생성하세요`);
792
+ if (!p.compatibility.pomFound)
793
+ throw new Error(`pom.xml이 없습니다: ${projectDir} — Boot 백엔드 프로젝트 루트에서 실행하세요`);
794
+ if (p.compatibility.parentOk === false)
795
+ throw new Error(`부모 POM 불일치: 요구 '${comp.requires.parent}', 발견 '${p.compatibility.parentFound ?? "없음"}' — egovframe-boot-starter-parent 기반 Boot 프로젝트만 지원합니다 (dryRun으로 진단 가능)`);
796
+ // ---- 다운로드 & 파일 계획 ----
797
+ const catalog = loadAiCatalog();
798
+ const zip = await downloadAiZip(catalog.source.repo, opts.ref ?? catalog.source.branch);
799
+ const entries = zip.getEntries().filter((e) => !e.isDirectory);
800
+ const rootPrefix = entries[0].entryName.split("/")[0] + "/";
801
+ const modPrefix = rootPrefix + comp.modulePath + "/";
802
+ const plan = [];
803
+ for (const e of entries) {
804
+ if (!e.entryName.startsWith(modPrefix))
805
+ continue;
806
+ const relInModule = e.entryName.slice(modPrefix.length);
807
+ for (const g of p.groups) {
808
+ const destRel = mapAiEntry(relInModule, comp.copyGroups[g].paths);
809
+ if (destRel !== null) {
810
+ plan.push({ entry: e, destRel, group: g });
811
+ break;
812
+ }
813
+ }
814
+ }
815
+ if (plan.length === 0)
816
+ throw new Error(`조립할 파일을 찾지 못했습니다 — 카탈로그(${catalog.source.surveyedAt})와 저장소 구조가 달라졌을 수 있습니다. npm run generate:ai-catalog로 재생성하세요`);
817
+ // ---- 전체 사전 충돌 검사 (원자적 거부) ----
818
+ const conflicts = plan.filter((f) => fs.existsSync(path.join(projectDir, f.destRel))).map((f) => f.destRel);
819
+ if (conflicts.length > 0)
820
+ throw new Error(`기존 파일과 충돌하여 조립을 거부합니다 (${conflicts.length}개): ${conflicts.slice(0, 10).join(", ")}${conflicts.length > 10 ? " 외" : ""} — 아무 파일도 쓰지 않았습니다`);
821
+ const pomPath = path.join(projectDir, "pom.xml");
822
+ const backupPath = path.join(projectDir, AI_POM_BACKUP);
823
+ if ((p.toAddDeps.length > 0 || Object.keys(p.toAddProps).length > 0) && fs.existsSync(backupPath))
824
+ throw new Error(`pom 백업(${AI_POM_BACKUP})이 이미 있습니다 — 이전 조립 잔여물을 정리한 뒤 다시 시도하세요`);
825
+ // ---- 파일 복사 ----
826
+ for (const f of plan) {
827
+ const target = path.join(projectDir, f.destRel);
828
+ if (!target.startsWith(projectDir + path.sep))
829
+ throw new Error(`경로 이탈 감지: ${f.destRel}`);
830
+ fs.mkdirSync(path.dirname(target), { recursive: true });
831
+ fs.writeFileSync(target, f.entry.getData());
832
+ }
833
+ // ---- pom 병합 (마커 구간 삽입) ----
834
+ let pomChanged = false;
835
+ if (p.toAddDeps.length > 0 || Object.keys(p.toAddProps).length > 0) {
836
+ let pom = fs.readFileSync(pomPath, "utf-8");
837
+ fs.writeFileSync(backupPath, pom);
838
+ if (p.toAddDeps.length > 0) {
839
+ const close = findProjectDependenciesClose(pom);
840
+ if (close < 0)
841
+ throw new Error("pom.xml에서 <dependencies> 블록을 찾지 못했습니다");
842
+ const block = [
843
+ ` ${AI_POM_MARKER(comp.id, "deps", "start")}`,
844
+ ...p.toAddDeps.map((d) => depToXml(d)),
845
+ ` ${AI_POM_MARKER(comp.id, "deps", "end")}`,
846
+ " ",
847
+ ].join("\n");
848
+ pom = pom.slice(0, close) + block + pom.slice(close);
849
+ }
850
+ if (Object.keys(p.toAddProps).length > 0) {
851
+ const pClose = pom.indexOf("</properties>");
852
+ if (pClose < 0)
853
+ throw new Error("pom.xml에서 <properties> 블록을 찾지 못했습니다");
854
+ const block = [
855
+ ` ${AI_POM_MARKER(comp.id, "props", "start")}`,
856
+ ...Object.entries(p.toAddProps).map(([k, v]) => ` <${k}>${v}</${k}>`),
857
+ ` ${AI_POM_MARKER(comp.id, "props", "end")}`,
858
+ " ",
859
+ ].join("\n");
860
+ pom = pom.slice(0, pClose) + block + pom.slice(pClose);
861
+ }
862
+ fs.writeFileSync(pomPath, pom);
863
+ pomChanged = true;
864
+ }
865
+ // ---- 매니페스트 기록 ----
866
+ const manifest = readManifest(projectDir) ?? {
867
+ schemaVersion: 1,
868
+ source: { repo: catalog.source.repo, branch: catalog.source.branch },
869
+ components: {},
870
+ };
871
+ manifest.components[comp.id] = {
872
+ installedAt: new Date().toISOString(),
873
+ files: plan.map((f) => f.destRel),
874
+ sqlScripts: [],
875
+ pom: pomChanged
876
+ ? { backup: AI_POM_BACKUP, addedDeps: p.toAddDeps.map((d) => d.artifactId), addedProps: Object.keys(p.toAddProps) }
877
+ : undefined,
878
+ };
879
+ writeManifest(projectDir, manifest);
880
+ const copyPlan = p.groups.map((g) => ({
881
+ group: g,
882
+ files: plan.filter((f) => f.group === g).length,
883
+ paths: comp.copyGroups[g].paths,
884
+ }));
885
+ return {
886
+ projectDir,
887
+ component: { id: comp.id, name: comp.name, vectorStore: comp.vectorStore },
888
+ compatibility: p.compatibility,
889
+ dependencyChanges: { toAdd: p.toAddDeps.map(depCoord), alreadyPresent: p.alreadyPresent },
890
+ copyPlan,
891
+ totalFiles: plan.length,
892
+ copiedFiles: plan.length,
893
+ pomChanged,
894
+ pomBackup: pomChanged ? AI_POM_BACKUP : null,
895
+ prerequisites: comp.prerequisites,
896
+ nextSteps: [
897
+ "Ollama(>=0.17.1) 설치 및 LLM 모델 준비 (폐쇄망 절차는 egovframe-ai-rag README 참조)",
898
+ "ONNX 임베딩 모델 익스포트·배치",
899
+ `docker compose -f docker-compose.ai.yml up -d 로 벡터 저장소(${comp.vectorStore}) 기동`,
900
+ "spring.profiles.active=ai 로 애플리케이션 실행 (application-ai.yml 사용)",
901
+ "브라우저에서 채팅 UI 접속 (경로·포트는 application-ai.yml 참조)",
902
+ ],
903
+ dryRun: false,
904
+ };
905
+ }
906
+ /** AI 컴포넌트의 pom 삽입분(마커 구간)을 걷어낸다 — remove 시 호출 */
907
+ export function stripAiPomAdditions(projectDir, componentId) {
908
+ const pomPath = path.join(projectDir, "pom.xml");
909
+ if (!fs.existsSync(pomPath))
910
+ return false;
911
+ let pom = fs.readFileSync(pomPath, "utf-8");
912
+ let changed = false;
913
+ for (const kind of ["deps", "props"]) {
914
+ const start = pom.indexOf(AI_POM_MARKER(componentId, kind, "start"));
915
+ const endMark = AI_POM_MARKER(componentId, kind, "end");
916
+ const end = pom.indexOf(endMark);
917
+ if (start >= 0 && end > start) {
918
+ // 마커 라인 앞 들여쓰기부터 end 마커 라인 끝(개행 포함)까지 제거
919
+ const lineStart = pom.lastIndexOf("\n", start) + 1;
920
+ let lineEnd = end + endMark.length;
921
+ while (lineEnd < pom.length && pom[lineEnd] !== "\n")
922
+ lineEnd++;
923
+ lineEnd++; // 개행 포함
924
+ pom = pom.slice(0, lineStart) + pom.slice(lineEnd);
925
+ changed = true;
926
+ }
927
+ }
928
+ if (changed)
929
+ fs.writeFileSync(pomPath, pom);
930
+ const backup = path.join(projectDir, AI_POM_BACKUP);
931
+ if (fs.existsSync(backup))
932
+ fs.unlinkSync(backup);
933
+ return changed;
934
+ }
935
+ /* ------------------------------------------------------------------ */
587
936
  /* MCP 서버 */
588
937
  /* ------------------------------------------------------------------ */
589
938
  export function buildServer() {
590
- const server = new McpServer({ name: "egovframe-scaffold-mcp", version: "0.7.0" });
939
+ const server = new McpServer({ name: "egovframe-scaffold-mcp", version: "0.9.0" });
591
940
  server.tool("list_egovframe_templates", "사용 가능한 전자정부 표준프레임워크 프로젝트 템플릿 목록을 반환합니다.", {}, async () => ({
592
941
  content: [
593
942
  {
@@ -625,6 +974,17 @@ export function buildServer() {
625
974
  });
626
975
  server.tool("list_egovframe_components", "선택 설치를 지원하는 공통컴포넌트 카탈로그를 반환합니다 (저장소 스캔으로 자동 생성, scripts/generate-catalog.mjs).", {}, async () => {
627
976
  const catalog = loadCatalog();
977
+ let ai;
978
+ try {
979
+ const aiCat = loadAiCatalog();
980
+ ai = {
981
+ source: aiCat.source,
982
+ components: aiCat.components.map((c) => ({
983
+ id: c.id, stack: c.stack, name: c.name, description: c.description, approxFiles: c.approxFiles,
984
+ })),
985
+ };
986
+ }
987
+ catch { /* AI 카탈로그가 없어도 기본 목록은 동작 */ }
628
988
  return {
629
989
  content: [
630
990
  {
@@ -632,6 +992,7 @@ export function buildServer() {
632
992
  text: JSON.stringify({
633
993
  source: catalog.source,
634
994
  sqlNote: catalog.sqlNote,
995
+ aiComponents: ai,
635
996
  components: catalog.components.map((c) => ({
636
997
  id: c.id, name: c.name, category: c.category,
637
998
  description: c.description, dependsOn: c.dependsOn, approxFiles: c.approxFiles,
@@ -726,6 +1087,42 @@ export function buildServer() {
726
1087
  ].filter((l, i) => l !== "" || i >= 4).join("\n");
727
1088
  return { content: [{ type: "text", text: head + r.content }] };
728
1089
  });
1090
+ server.tool("add_ai_components", "공식 egovframe-ai-rag 샘플 기반 AI RAG 챗봇(문서 업로드→임베딩→하이브리드 검색→LLM 응답)을 기존 Boot 프로젝트에 조립합니다. " +
1091
+ "소스·설정(application-ai.yml 프로필)·UI·인프라를 복사하고 pom에 누락 의존성만 마커 구간으로 삽입합니다(백업 생성, 제거 시 원복). " +
1092
+ "기존 파일과 충돌하면 아무것도 쓰지 않고 거부합니다. dryRun=true로 먼저 미리볼 수 있습니다.", {
1093
+ projectDir: z.string().describe("대상 프로젝트 디렉터리(절대경로 권장). egovframe-boot-starter-parent 기반 Boot 프로젝트"),
1094
+ stack: z.enum(AI_STACKS).describe("AI 스택: spring-ai(Redis Stack) | langchain4j(PGVector). 상호 배타"),
1095
+ includeInfra: z.boolean().default(true).describe("docker-compose.ai.yml·Dockerfile.ai·k8s/ai 복사"),
1096
+ includeUi: z.boolean().default(true).describe("채팅 UI(chat.html·static) 복사"),
1097
+ includeTests: z.boolean().default(false).describe("샘플 테스트 복사"),
1098
+ ref: z.string().optional().describe("egovframe-ai-rag 브랜치/태그 (기본: 카탈로그 기준 브랜치)"),
1099
+ dryRun: z.boolean().default(false).describe("true면 복사·병합 없이 계획만 미리보기(네트워크 불필요)"),
1100
+ }, async (args) => {
1101
+ const r = await addAiComponents(args);
1102
+ const c = r.compatibility;
1103
+ const head = r.dryRun
1104
+ ? `🔍 AI 컴포넌트 조립 미리보기(dryRun): ${r.projectDir}`
1105
+ : `✅ AI 컴포넌트 조립 완료: ${r.projectDir}`;
1106
+ const text = [
1107
+ head,
1108
+ `- 컴포넌트: ${r.component.id} — ${r.component.name}`,
1109
+ `- 호환성: 부모 POM ${c.parentOk === true ? "일치" : c.parentOk === false ? "불일치" : "미확인"}` +
1110
+ ` (요구 ${c.required}${c.parentFound ? ", 발견 " + c.parentFound : ""})`,
1111
+ ...(c.warnings.length ? c.warnings.map((w) => ` ! ${w}`) : []),
1112
+ `- pom 의존성: ${r.dryRun ? "추가 예정" : "추가됨"} ${r.dependencyChanges.toAdd.length}건` +
1113
+ (r.dependencyChanges.alreadyPresent.length ? `, 이미 존재 ${r.dependencyChanges.alreadyPresent.length}건` : "") +
1114
+ (r.pomBackup ? ` (백업: ${r.pomBackup})` : ""),
1115
+ ...r.dependencyChanges.toAdd.slice(0, 8).map((d) => ` + ${d}`),
1116
+ ...(r.dependencyChanges.toAdd.length > 8 ? [` + … 외 ${r.dependencyChanges.toAdd.length - 8}건`] : []),
1117
+ `- ${r.dryRun ? "복사 계획" : "복사 완료"} (총 ${r.totalFiles}개 파일):`,
1118
+ ...r.copyPlan.map((g) => ` · ${g.group}: ${g.files}개`),
1119
+ `- 실행 전제: ${r.prerequisites.join(", ")}`,
1120
+ ``,
1121
+ `다음 단계:`,
1122
+ ...r.nextSteps.map((s, i) => ` ${i + 1}. ${s}`),
1123
+ ].join("\n");
1124
+ return { content: [{ type: "text", text }] };
1125
+ });
729
1126
  return server;
730
1127
  }
731
1128
  const isMain = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egovframe-scaffold-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "전자정부 표준프레임워크(eGovFrame) 프로젝트 스캐폴딩·공통컴포넌트 조립 MCP 서버 — create/search/add/validate/remove",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,7 +15,10 @@
15
15
  "test:catalog": "node test/catalog.mjs",
16
16
  "test:components": "node test/components.mjs",
17
17
  "generate:catalog": "node scripts/generate-catalog.mjs",
18
- "prepublishOnly": "npm run build && npm run test:catalog"
18
+ "prepublishOnly": "npm run build && npm run test:catalog && npm run test:ai",
19
+ "test:ai": "node test/ai.mjs",
20
+ "generate:ai-catalog": "node scripts/generate-ai-catalog.mjs",
21
+ "test:ai-assembly": "node test/ai-assembly.mjs"
19
22
  },
20
23
  "engines": {
21
24
  "node": ">=18"