egovframe-scaffold-mcp 0.8.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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/dist/index.js +283 -35
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -23,7 +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` | **(신규, M1 미리보기)** 공식 [egovframe-ai-rag](https://github.com/eGovFramework/egovframe-ai-rag) 샘플 기반 AI RAG 챗봇 조립 — Spring AI(Redis Stack)·LangChain4j(PGVector) 스택 선택, 파일 복사 계획·pom 의존성 diff·호환성 진단 (설계: [docs/design-ai-components.md](docs/design-ai-components.md)) |
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)) |
27
27
 
28
28
  ### create_egovframe_project 파라미터
29
29
 
@@ -107,11 +107,13 @@ Claude Desktop / Claude Code 설정 예 (`mcpServers`):
107
107
 
108
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))
109
109
  2. 실행환경 보일러플레이트(egovframe-msa 등) 템플릿 추가
110
- 3. AI 컴포넌트 조립(`add_ai_components`) — **M1 완료(v0.8.0)**: AI 카탈로그(egovframe-ai-rag 스캔 자동 생성) + dryRun 미리보기(의존성 diff·호환성 게이트). M2(v0.9.0): 실조립(복사+pom 병합+설정 프로필화) (설계: [docs/design-ai-components.md](docs/design-ai-components.md))
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))
111
111
  4. 검증 후 eGovFramework 조직 공식 저장소 편입 제안
112
112
 
113
113
  ## 변경 이력
114
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
+
115
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`) 추가.
116
118
 
117
119
  - **0.7.0** — `get_egovframe_guide`: 컴포넌트 id로 표준프레임워크 공식 가이드 문서(egovframe-docs)를 조회. 카탈로그에 문서 매핑 자동 생성(`--docs`, 지배적 패키지 참조 기준 45종) 추가. 한글명 큐레이션 12→27종.
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,7 +592,7 @@ export async function getGuide(componentId, docIndex = 0) {
584
592
  return { componentId, docs, selected: sel, content, truncated };
585
593
  }
586
594
  /* ------------------------------------------------------------------ */
587
- /* AI 컴포넌트 조립 (M1: dryRun 미리보기) — 설계: docs/design-ai-components.md */
595
+ /* AI 컴포넌트 조립 (M1 미리보기 + M2 실조립) — 설계: docs/design-ai-components.md */
588
596
  /* 소스: eGovFramework/egovframe-ai-rag (Spring AI·LangChain4j RAG 샘플) */
589
597
  /* ------------------------------------------------------------------ */
590
598
  export const AI_STACKS = ["spring-ai", "langchain4j"];
@@ -608,14 +616,11 @@ export function loadAiCatalog() {
608
616
  throw new Error(`AI 카탈로그 오류: '${c.id}'의 conflictsWith '${x}'가 카탈로그에 없습니다`);
609
617
  return catalog;
610
618
  }
611
- /**
612
- * AI 컴포넌트 조립 계획 (M1) — dryRun 미리보기 전용.
613
- * 파일 복사 계획·pom 의존성 diff·호환성 게이트 결과를 네트워크 없이 반환한다.
614
- * 실제 조립(파일 복사 + pom 병합 + 설정 프로필화)은 M2(v0.9.0)에서 제공한다.
615
- */
616
- export async function planAiComponents(opts) {
617
- if (opts.dryRun === false)
618
- throw new Error("add_ai_components는 현재 dryRun 미리보기만 지원합니다 — 실제 조립은 M2(v0.9.0)에서 제공됩니다 (설계: docs/design-ai-components.md)");
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) {
619
624
  if (!AI_STACKS.includes(opts.stack))
620
625
  throw new Error(`stack은 ${AI_STACKS.join("|")} 중 하나여야 합니다: ${String(opts.stack)}`);
621
626
  const catalog = loadAiCatalog();
@@ -630,14 +635,15 @@ export async function planAiComponents(opts) {
630
635
  if (manifest.components[x])
631
636
  throw new Error(`상호 배타 컴포넌트 '${x}'가 이미 설치되어 있습니다 — 두 AI 스택은 같은 패키지(com.example.chat)·UI 경로를 사용합니다. 먼저 remove_egovframe_components로 제거하세요`);
632
637
  }
633
- // 호환성 게이트: 부모 POM 좌표 확인 + 의존성 diff
638
+ // 호환성 게이트: 부모 POM 좌표 확인 + 의존성/프로퍼티 diff
634
639
  const warnings = [];
635
640
  const pomPath = path.join(projectDir, "pom.xml");
636
641
  const pomFound = fs.existsSync(pomPath);
637
642
  let parentFound = null;
638
643
  let parentOk = null;
639
- const toAdd = [];
644
+ const toAddDeps = [];
640
645
  const alreadyPresent = [];
646
+ const toAddProps = {};
641
647
  if (pomFound) {
642
648
  const pom = fs.readFileSync(pomPath, "utf-8");
643
649
  const pm = pom.match(/<parent>[\s\S]*?<\/parent>/);
@@ -653,17 +659,19 @@ export async function planAiComponents(opts) {
653
659
  else if (parentFound !== comp.requires.parent)
654
660
  warnings.push(`부모 POM 버전이 다릅니다(요구 ${reqV}, 발견 ${parentFound}) — BOM 관리 버전 차이를 확인하세요`);
655
661
  for (const d of comp.mavenDependencies) {
656
- const coord = `${d.groupId}:${d.artifactId}${d.version ? ":" + d.version : ""}${d.scope ? " (" + d.scope + ")" : ""}`;
657
662
  if (pom.includes(`<artifactId>${d.artifactId}</artifactId>`))
658
- alreadyPresent.push(coord);
663
+ alreadyPresent.push(depCoord(d));
659
664
  else
660
- toAdd.push(coord);
665
+ toAddDeps.push(d);
661
666
  }
667
+ for (const [k, v] of Object.entries(comp.mavenProperties))
668
+ if (!pom.includes(`<${k}>`))
669
+ toAddProps[k] = v;
662
670
  }
663
671
  else {
664
672
  warnings.push("pom.xml이 없습니다 — Boot 백엔드 프로젝트 루트 경로인지 확인하세요");
665
673
  }
666
- // 복사 계획: source·config 필수, ui/infra/tests는 옵션
674
+ // 복사 그룹: source·config 필수, ui/infra/tests는 옵션
667
675
  const groups = ["source", "config"];
668
676
  if (opts.includeUi !== false)
669
677
  groups.push("ui");
@@ -671,30 +679,264 @@ export async function planAiComponents(opts) {
671
679
  groups.push("infra");
672
680
  if (opts.includeTests === true)
673
681
  groups.push("tests");
674
- const copyPlan = groups.map((g) => ({ group: g, files: comp.copyGroups[g].files, paths: comp.copyGroups[g].paths }));
675
- const totalFiles = copyPlan.reduce((n, g) => n + g.files, 0);
676
682
  return {
683
+ comp,
677
684
  projectDir,
678
- component: { id: comp.id, name: comp.name, vectorStore: comp.vectorStore },
679
685
  compatibility: { pomFound, required: comp.requires.parent, parentFound, parentOk, warnings },
680
- dependencyChanges: { toAdd, alreadyPresent },
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 },
681
890
  copyPlan,
682
- totalFiles,
891
+ totalFiles: plan.length,
892
+ copiedFiles: plan.length,
893
+ pomChanged,
894
+ pomBackup: pomChanged ? AI_POM_BACKUP : null,
683
895
  prerequisites: comp.prerequisites,
684
896
  nextSteps: [
685
- "M1은 미리보기 전용입니다 — 실제 조립은 M2(v0.9.0)에서 제공됩니다",
686
897
  "Ollama(>=0.17.1) 설치 및 LLM 모델 준비 (폐쇄망 절차는 egovframe-ai-rag README 참조)",
687
898
  "ONNX 임베딩 모델 익스포트·배치",
688
- "docker compose로 벡터 저장소(Redis Stack 또는 PGVector) 기동",
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 참조)",
689
902
  ],
690
- dryRun: true,
903
+ dryRun: false,
691
904
  };
692
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
+ }
693
935
  /* ------------------------------------------------------------------ */
694
936
  /* MCP 서버 */
695
937
  /* ------------------------------------------------------------------ */
696
938
  export function buildServer() {
697
- const server = new McpServer({ name: "egovframe-scaffold-mcp", version: "0.8.0" });
939
+ const server = new McpServer({ name: "egovframe-scaffold-mcp", version: "0.9.0" });
698
940
  server.tool("list_egovframe_templates", "사용 가능한 전자정부 표준프레임워크 프로젝트 템플릿 목록을 반환합니다.", {}, async () => ({
699
941
  content: [
700
942
  {
@@ -846,28 +1088,34 @@ export function buildServer() {
846
1088
  return { content: [{ type: "text", text: head + r.content }] };
847
1089
  });
848
1090
  server.tool("add_ai_components", "공식 egovframe-ai-rag 샘플 기반 AI RAG 챗봇(문서 업로드→임베딩→하이브리드 검색→LLM 응답)을 기존 Boot 프로젝트에 조립합니다. " +
849
- "현재 M1은 dryRun 미리보기 전용 — 파일 복사 계획·pom 의존성 diff·호환성 진단을 반환합니다 (설계: docs/design-ai-components.md).", {
1091
+ "소스·설정(application-ai.yml 프로필)·UI·인프라를 복사하고 pom에 누락 의존성만 마커 구간으로 삽입합니다(백업 생성, 제거 시 원복). " +
1092
+ "기존 파일과 충돌하면 아무것도 쓰지 않고 거부합니다. dryRun=true로 먼저 미리볼 수 있습니다.", {
850
1093
  projectDir: z.string().describe("대상 프로젝트 디렉터리(절대경로 권장). egovframe-boot-starter-parent 기반 Boot 프로젝트"),
851
1094
  stack: z.enum(AI_STACKS).describe("AI 스택: spring-ai(Redis Stack) | langchain4j(PGVector). 상호 배타"),
852
- includeInfra: z.boolean().default(true).describe("docker-compose·Dockerfile·k8s 복사 계획 포함"),
853
- includeUi: z.boolean().default(true).describe("채팅 UI(chat.html·static) 복사 계획 포함"),
854
- includeTests: z.boolean().default(false).describe("샘플 테스트 복사 계획 포함"),
855
- dryRun: z.boolean().default(true).describe("M1에서는 true만 지원 (실조립은 M2/v0.9.0)"),
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면 복사·병합 없이 계획만 미리보기(네트워크 불필요)"),
856
1100
  }, async (args) => {
857
- const r = await planAiComponents(args);
1101
+ const r = await addAiComponents(args);
858
1102
  const c = r.compatibility;
1103
+ const head = r.dryRun
1104
+ ? `🔍 AI 컴포넌트 조립 미리보기(dryRun): ${r.projectDir}`
1105
+ : `✅ AI 컴포넌트 조립 완료: ${r.projectDir}`;
859
1106
  const text = [
860
- `🔍 AI 컴포넌트 조립 미리보기(dryRun): ${r.projectDir}`,
1107
+ head,
861
1108
  `- 컴포넌트: ${r.component.id} — ${r.component.name}`,
862
1109
  `- 호환성: 부모 POM ${c.parentOk === true ? "일치" : c.parentOk === false ? "불일치" : "미확인"}` +
863
1110
  ` (요구 ${c.required}${c.parentFound ? ", 발견 " + c.parentFound : ""})`,
864
1111
  ...(c.warnings.length ? c.warnings.map((w) => ` ! ${w}`) : []),
865
- `- pom 의존성: 추가 예정 ${r.dependencyChanges.toAdd.length}건` +
866
- (r.dependencyChanges.alreadyPresent.length ? `, 이미 존재 ${r.dependencyChanges.alreadyPresent.length}건` : ""),
1112
+ `- pom 의존성: ${r.dryRun ? "추가 예정" : "추가됨"} ${r.dependencyChanges.toAdd.length}건` +
1113
+ (r.dependencyChanges.alreadyPresent.length ? `, 이미 존재 ${r.dependencyChanges.alreadyPresent.length}건` : "") +
1114
+ (r.pomBackup ? ` (백업: ${r.pomBackup})` : ""),
867
1115
  ...r.dependencyChanges.toAdd.slice(0, 8).map((d) => ` + ${d}`),
868
1116
  ...(r.dependencyChanges.toAdd.length > 8 ? [` + … 외 ${r.dependencyChanges.toAdd.length - 8}건`] : []),
869
- `- 복사 계획 (총 ${r.totalFiles}개 파일):`,
870
- ...r.copyPlan.map((g) => ` · ${g.group}: ${g.files}개 — ${g.paths.join(", ")}`),
1117
+ `- ${r.dryRun ? "복사 계획" : "복사 완료"} (총 ${r.totalFiles}개 파일):`,
1118
+ ...r.copyPlan.map((g) => ` · ${g.group}: ${g.files}개`),
871
1119
  `- 실행 전제: ${r.prerequisites.join(", ")}`,
872
1120
  ``,
873
1121
  `다음 단계:`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egovframe-scaffold-mcp",
3
- "version": "0.8.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",
@@ -17,7 +17,8 @@
17
17
  "generate:catalog": "node scripts/generate-catalog.mjs",
18
18
  "prepublishOnly": "npm run build && npm run test:catalog && npm run test:ai",
19
19
  "test:ai": "node test/ai.mjs",
20
- "generate:ai-catalog": "node scripts/generate-ai-catalog.mjs"
20
+ "generate:ai-catalog": "node scripts/generate-ai-catalog.mjs",
21
+ "test:ai-assembly": "node test/ai-assembly.mjs"
21
22
  },
22
23
  "engines": {
23
24
  "node": ">=18"