diff --git a/README.md b/README.md index 83641b1..b4f961e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Symphony는 AI 개발환경(IDE, MCP 기반 LLM Tooling)을 위한 정책 기반 - [`add_category`](#add_category) - [`edit_category`](#edit_category) - [`remove_category`](#remove_category) + - [`import_convention`](#import_convention) - [컨벤션 파일](#컨벤션-파일) - [요구사항](#요구사항) - [지원 플랫폼](#지원-플랫폼) @@ -105,6 +106,13 @@ sym dashboard - 카테고리를 삭제합니다 (배치 지원). - 필수 파라미터: `names` (배열) +### `import_convention` + +- 외부 문서(텍스트, 마크다운, 코드 파일)에서 컨벤션을 추출합니다. +- LLM을 사용하여 코딩 규칙을 자동으로 인식하고 정책에 추가합니다. +- 필수 파라미터: `path` +- 선택 파라미터: `mode` (`append` 또는 `clear`, 기본값: `append`) + --- ## 컨벤션 파일 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 165441c..8f4294d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,7 @@ graph TD subgraph L3["3 Core"] converter["converter"] validator["validator"] + importer["importer"] end subgraph L4["4 Tool Adapters"] @@ -126,6 +127,7 @@ Cobra 프레임워크 기반 CLI 명령어를 구현합니다. | `sym dashboard` | 웹 대시보드 실행 | | `sym mcp` | MCP 서버 실행 | | `sym my-role` | 역할 관리 | +| `sym import` | 외부 문서에서 컨벤션 추출 | | `sym llm status\|test\|setup` | LLM 프로바이더 관리 | ### Layer 2: Gateways @@ -144,6 +146,7 @@ AI 코딩 도구(Claude Code, Cursor 등)와 stdio를 통해 통신합니다. | `add_category` | 카테고리 추가 (배치 지원) | | `edit_category` | 카테고리 편집 (배치 지원) | | `remove_category` | 카테고리 삭제 (배치 지원) | +| `import_convention` | 외부 문서에서 컨벤션 추출 | #### HTTP Server (`internal/server`) @@ -159,6 +162,7 @@ AI 코딩 도구(Claude Code, Cursor 등)와 stdio를 통해 통신합니다. | `POST /api/categories` | 카테고리 추가 | | `PUT /api/categories/{name}` | 카테고리 편집 | | `DELETE /api/categories/{name}` | 카테고리 삭제 | +| `POST /api/import` | 컨벤션 Import 실행 | ### Layer 3: Core @@ -192,6 +196,14 @@ CodePolicy (정형화된 규칙) + 린터 설정 파일 3. **실행 단위 생성**: 린터/LLM 실행 단위 구성 4. **병렬 실행**: 세마포어 기반 동시성 제어 +#### Importer (`internal/importer`) + +외부 문서에서 LLM을 사용하여 코딩 컨벤션을 추출합니다: + +1. **파일 읽기**: 31개 포맷 지원 (txt, md, 코드 파일 등), 50KB 제한 +2. **LLM 추출**: 문서 내용에서 카테고리와 규칙 자동 인식 +3. **정책 병합**: 기존 user-policy.json과 병합 (append/clear 모드) + ### Layer 4: Tool Adapters 외부 도구와의 통합을 담당합니다. diff --git a/docs/COMMAND.md b/docs/COMMAND.md index f151dbb..32b2eb3 100644 --- a/docs/COMMAND.md +++ b/docs/COMMAND.md @@ -21,6 +21,7 @@ Symphony (`sym`)는 코드 컨벤션 관리와 RBAC(역할 기반 접근 제어) - [sym policy validate](#sym-policy-validate) - [sym convert](#sym-convert) - [sym validate](#sym-validate) + - [sym import](#sym-import) - [sym category](#sym-category) - [sym mcp](#sym-mcp) - [sym llm](#sym-llm) @@ -44,6 +45,7 @@ Symphony (`sym`)는 코드 컨벤션 관리와 RBAC(역할 기반 접근 제어) - [add\_category](#add_category) - [edit\_category](#edit_category) - [remove\_category](#remove_category) + - [import\_convention](#import_convention) - [등록 방법](#등록-방법) - [LLM 프로바이더](#llm-프로바이더) - [지원 프로바이더](#지원-프로바이더) @@ -109,6 +111,7 @@ sym │ └── validate # 정책 파일 유효성 검사 ├── convert # 정책 → 린터 설정 변환 ├── validate # Git 변경사항 검증 +├── import # 외부 문서에서 컨벤션 추출 ├── category # 카테고리 목록 조회 ├── mcp # MCP 서버 실행 ├── llm # LLM 프로바이더 관리 @@ -373,6 +376,80 @@ sym validate --timeout 60 --- +### sym import + +**설명**: 외부 문서에서 코딩 컨벤션을 추출하여 user-policy.json에 추가합니다. + +LLM을 사용하여 텍스트, 마크다운, 코드 파일 등에서 코딩 규칙을 자동으로 인식하고 Symphony 정책 형식으로 변환합니다. + +**지원 포맷**: +- 텍스트 문서: `.txt`, `.md`, `.markdown` +- 코드 파일: `.go`, `.js`, `.ts`, `.jsx`, `.tsx`, `.py`, `.java`, `.rs`, `.rb`, `.php`, `.c`, `.cpp`, `.h`, `.hpp`, `.cs`, `.swift`, `.kt`, `.scala` +- 설정/데이터: `.yaml`, `.yml`, `.json`, `.toml`, `.xml` +- 웹 파일: `.html`, `.htm`, `.css`, `.scss`, `.less` +- 기타: `.rst`, `.adoc` + +**파일 크기 제한**: 50KB + +**문법**: +``` +sym import [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--mode` | `-m` | string | `append` | Import 모드: `append` (기존 유지, 새 항목 추가) 또는 `clear` (기존 삭제 후 추가) | + +**Import 모드**: +- `append` (기본값): 기존 카테고리와 규칙을 유지하고 새 항목을 추가합니다. 중복 카테고리는 건너뛰고, 중복 규칙 ID는 접미어를 추가합니다 (예: `SEC-001-2`). +- `clear`: 기존 모든 카테고리와 규칙을 삭제한 후 새 항목을 추가합니다. 사용자 확인이 필요합니다. + +**예시**: +```bash +# 마크다운 문서에서 컨벤션 추출 (append 모드, 기본값) +sym import coding-standards.md + +# 텍스트 파일에서 컨벤션 추출 +sym import team-guidelines.txt + +# 기존 컨벤션 삭제 후 새로 추가 (clear 모드) +sym import new-rules.md --mode clear +``` + +**Import 프로세스**: +1. 파일 읽기 및 형식 검증 +2. LLM을 사용하여 코딩 컨벤션 추출 +3. 카테고리와 규칙에 고유 ID 생성 +4. 기존 user-policy.json과 병합 +5. 정책 파일 저장 + +**출력 예시**: +``` +[Import Conventions] Processing: coding-standards.md +Mode: append + +[OK] Processed: /path/to/coding-standards.md + +[OK] Added 2 categories: + • security: Security rules for safe coding + • performance: Performance optimization guidelines + +[OK] Added 5 rules: + • [SEC-001] Use parameterized queries for database operations (security) + • [SEC-002] Sanitize all user inputs before processing (security) + • [PERF-001] Avoid N+1 queries in database operations (performance) + • [PERF-002] Use pagination for large data sets (performance) + • [PERF-003] Cache frequently accessed data (performance) + +[DONE] Import complete +``` + +**관련 파일**: `internal/cmd/import.go` + +--- + ### sym category **설명**: 컨벤션 카테고리를 관리합니다. @@ -876,6 +953,41 @@ Use query_conventions with a specific category to get rules for that category. } ``` +#### import_convention + +외부 문서에서 코딩 컨벤션을 추출하여 user-policy.json에 추가합니다. + +LLM을 사용하여 텍스트, 마크다운, 코드 파일 등에서 코딩 규칙을 자동으로 인식하고 카테고리와 규칙을 생성합니다. + +**입력 스키마**: + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `path` | string | 예 | Import할 파일 경로 | +| `mode` | string | 아니오 | Import 모드: `append` (기본값, 기존 유지) 또는 `clear` (기존 삭제 후 추가) | + +**예시**: +```json +{ + "path": "/path/to/coding-standards.md", + "mode": "append" +} +``` + +**출력 예시**: +``` +Import completed successfully. + +Categories added (2): +• security: Security rules for safe coding +• performance: Performance optimization guidelines + +Rules added (3): +• [SEC-001] Use parameterized queries for database operations (security) +• [PERF-001] Avoid N+1 queries in database operations (performance) +• [PERF-002] Use pagination for large data sets (performance) +``` + ### 등록 방법 ```bash diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 966095f..9ef24c5 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -126,6 +126,7 @@ sym-cli/ │ │ └── openaiapi/ # OpenAI API 프로바이더 │ ├── mcp/ # AI 도구 통합을 위한 Model Context Protocol 서버 │ ├── server/ # 웹 대시보드 HTTP 서버 +│ ├── importer/ # 외부 문서에서 컨벤션 추출 │ ├── policy/ # 정책 파일 로드, 저장 및 템플릿 관리 │ ├── roles/ # RBAC (역할 기반 접근 제어) 관리 │ └── util/ # 공유 유틸리티 diff --git a/internal/cmd/README.md b/internal/cmd/README.md index 8d94c40..1700af7 100644 --- a/internal/cmd/README.md +++ b/internal/cmd/README.md @@ -2,7 +2,7 @@ Symphony CLI 명령어를 구현합니다. -Cobra 프레임워크 기반으로 init, validate, convert, policy, dashboard, my-role, llm, mcp, version 등의 명령어를 제공합니다. +Cobra 프레임워크 기반으로 init, validate, convert, policy, dashboard, my-role, llm, mcp, import, version 등의 명령어를 제공합니다. ## 패키지 구조 @@ -21,6 +21,7 @@ cmd/ ├── mcp.go # sym mcp 명령어 (MCP 서버) ├── mcp_register.go # MCP 서버 등록 헬퍼 함수 ├── category.go # sym category list|add|edit|remove 명령어 (카테고리 관리) +├── import.go # sym import 명령어 (외부 문서에서 컨벤션 추출) ├── survey_templates.go # 커스텀 survey UI 템플릿 └── README.md ``` @@ -36,21 +37,21 @@ cmd/ ### 패키지 의존성 ``` - ┌───────────┐ - │ cmd │ - └─────┬─────┘ - ┌──────┬──────┬───────┼───────┬──────┬──────┬──────┐ - ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ - converter llm validator policy roles server mcp linter - │ │ │ │ │ │ │ │ - └──────┴──────┴───────┴───────┴──────┴──────┴──────┘ - │ - ┌──────────────┼──────────────┐ - ▼ ▼ ▼ - util/config util/git util/env - │ - ▼ - pkg/schema + ┌───────────┐ + │ cmd │ + └─────┬─────┘ + ┌──────┬──────┬───────┬─────┼─────┬──────┬──────┬──────┐ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ + converter llm validator policy roles server mcp linter importer + │ │ │ │ │ │ │ │ │ + └──────┴──────┴───────┴─────┴─────┴──────┴──────┴──────┘ + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + util/config util/git util/env + │ + ▼ + pkg/schema ``` ## Public / Private API @@ -86,6 +87,7 @@ cmd/ | `llmSetupCmd` | llm.go:47 | llm setup 명령어 | | `mcpCmd` | mcp.go:15 | mcp 명령어 | | `categoryCmd` | category.go:10 | category 명령어 | +| `importCmd` | import.go:18 | import 명령어 | #### 명령어 실행 함수 @@ -106,6 +108,7 @@ cmd/ | `runCategoryAdd(cmd, args)` | category.go:165 | category add 실행 | | `runCategoryEdit(cmd, args)` | category.go:240 | category edit 실행 | | `runCategoryRemove(cmd, args)` | category.go:353 | category remove 실행 | +| `runImport(cmd, args)` | import.go:50 | import 실행 | #### 헬퍼 함수 - 초기화 @@ -150,6 +153,7 @@ cmd/ |------|------|------| | `printValidationResult(result)` | validate.go:131 | 검증 결과 출력 | | `runNewConverter(policy)` | convert.go:74 | 새 컨버터 실행 | +| `printImportResults(result)` | import.go:108 | Import 결과 출력 | #### 터미널 포맷팅 (colors.go) diff --git a/internal/cmd/import.go b/internal/cmd/import.go new file mode 100644 index 0000000..4525fb6 --- /dev/null +++ b/internal/cmd/import.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/DevSymphony/sym-cli/internal/importer" + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/spf13/cobra" +) + +var importMode string + +var importCmd = &cobra.Command{ + Use: "import ", + Short: "Import conventions from external document", + Long: `Import coding conventions from an external document into user-policy.json. + +Supported formats: txt, md, and code files (go, js, ts, py, java, etc.) + +The import process: +1. Reads document content +2. Uses LLM to extract coding conventions +3. Generates categories and rules with unique IDs +4. Merges with existing user-policy.json + +Import modes: + append - Keep existing categories/rules, add new ones (default) + clear - Remove all existing categories/rules, then import`, + Example: ` # Import from a file (append mode, default) + sym import coding-standards.md + + # Clear existing conventions and import fresh + sym import new-rules.md --mode clear`, + Args: cobra.ExactArgs(1), + RunE: runImport, +} + +func init() { + rootCmd.AddCommand(importCmd) + + importCmd.Flags().StringVarP(&importMode, "mode", "m", "append", + "Import mode: 'append' (keep existing, add new) or 'clear' (remove existing, then import)") +} + +func runImport(cmd *cobra.Command, args []string) error { + // Validate mode + mode := importer.ImportModeAppend + if importMode == "clear" { + mode = importer.ImportModeClear + // Confirm clear mode + fmt.Println("WARNING: Clear mode will remove all existing categories and rules.") + fmt.Print("Continue? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Import cancelled.") + return nil + } + } else if importMode != "append" { + return fmt.Errorf("invalid mode '%s': must be 'append' or 'clear'", importMode) + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmCfg.Verbose = verbose + llmProvider, err := llm.New(llmCfg) + if err != nil { + return fmt.Errorf("failed to create LLM provider: %w\nTip: configure provider in .sym/config.json", err) + } + defer func() { _ = llmProvider.Close() }() + + // Create importer + imp := importer.NewImporter(llmProvider, verbose) + + // Setup context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Execute import + input := &importer.ImportInput{ + Path: args[0], + Mode: mode, + } + + printTitle("Import Conventions", fmt.Sprintf("Processing: %s", args[0])) + fmt.Printf("Mode: %s\n\n", importMode) + + result, err := imp.Import(ctx, input) + if err != nil { + // Print partial results if available + if result != nil { + printImportResults(result) + } + return fmt.Errorf("import failed: %w", err) + } + + // Print results + printImportResults(result) + return nil +} + +func printImportResults(result *importer.ImportResult) { + fmt.Println() + + if result.FileProcessed != "" { + printOK(fmt.Sprintf("Processed: %s", result.FileProcessed)) + } + + if result.CategoriesRemoved > 0 || result.RulesRemoved > 0 { + fmt.Println() + printWarn(fmt.Sprintf("Removed %d categories, %d rules (clear mode)", + result.CategoriesRemoved, result.RulesRemoved)) + } + + if len(result.CategoriesAdded) > 0 { + fmt.Println() + printOK(fmt.Sprintf("Added %d categories:", len(result.CategoriesAdded))) + for _, cat := range result.CategoriesAdded { + fmt.Printf(" • %s: %s\n", cat.Name, cat.Description) + } + } + + if len(result.RulesAdded) > 0 { + fmt.Println() + printOK(fmt.Sprintf("Added %d rules:", len(result.RulesAdded))) + for _, rule := range result.RulesAdded { + fmt.Printf(" • [%s] %s (%s)\n", rule.ID, rule.Say, rule.Category) + } + } + + if len(result.Warnings) > 0 { + fmt.Println() + printWarn(fmt.Sprintf("Warnings (%d):", len(result.Warnings))) + for _, w := range result.Warnings { + fmt.Printf(" %s\n", w) + } + } + + fmt.Println() + printDone("Import complete") +} diff --git a/internal/importer/README.md b/internal/importer/README.md new file mode 100644 index 0000000..8a8be46 --- /dev/null +++ b/internal/importer/README.md @@ -0,0 +1,186 @@ +# importer + +외부 문서에서 LLM을 사용하여 코딩 컨벤션을 추출하고 정책 파일에 병합합니다. + +텍스트, 마크다운, 코드 파일 등 다양한 포맷의 문서에서 컨벤션을 자동으로 인식하여 +Symphony 정책 형식으로 변환합니다. + +## 패키지 구조 + +``` +importer/ +├── types.go # 타입 정의 (ImportMode, ImportInput, ImportResult 등) +├── reader.go # 파일 읽기 및 포맷 검증 (31개 포맷 지원, 50KB 제한) +├── extractor.go # LLM 기반 컨벤션 추출 및 JSON 파싱 +├── importer.go # Import 워크플로우 조율 +└── README.md +``` + +## 의존성 + +### 패키지 사용자 + +| 위치 | 용도 | +|------|------| +| `internal/cmd/import.go` | `sym import` CLI 명령어 | +| `internal/mcp/server.go` | `import_convention` MCP 도구 | +| `internal/server/server.go` | `/api/import` HTTP 엔드포인트 | + +### 패키지 의존성 + +``` + ┌──────────┐ + │ importer │ + └────┬─────┘ + ┌───────────┼───────────┐ + ▼ ▼ ▼ +┌─────────┐ ┌────────┐ ┌───────────┐ +│ llm │ │ policy │ │ pkg/schema│ +└─────────┘ └────────┘ └───────────┘ +``` + +## Import 워크플로우 + +`Import()` 메서드는 6단계 워크플로우를 실행합니다: + +``` +1. 파일 읽기 (Reader.ReadFile) + │ + ▼ +2. LLM 추출 (Extractor.Extract) + │ + ▼ +3. 기존 정책 로드 (policy.LoadPolicy) + │ + ▼ +4. Import 모드 적용 + ├─ append: 기존 유지 + └─ clear: 기존 삭제 + │ + ▼ +5. 고유 ID 할당 및 병합 + └─ 중복 카테고리: 건너뛰기 + └─ 중복 규칙 ID: 접미어 추가 (e.g., SEC-001-2) + │ + ▼ +6. 정책 저장 (policy.SavePolicy) +``` + +## Public / Private API + +### Public API + +#### Types + +| 타입 | 파일 | 설명 | +|------|------|------| +| `ImportMode` | types.go:6 | Import 모드 (`append` / `clear`) | +| `ImportInput` | types.go:16 | Import 입력 (경로, 모드) | +| `ImportResult` | types.go:22 | Import 결과 (추가된 항목, 경고) | +| `DocumentContent` | types.go:32 | 파싱된 문서 내용 | +| `ExtractedConventions` | types.go:39 | LLM 추출 결과 | +| `Importer` | importer.go:13 | Import 워크플로우 조율자 | +| `Reader` | reader.go:57 | 파일 읽기 담당 | +| `Extractor` | extractor.go:15 | LLM 추출 담당 | + +#### Constants + +| 상수 | 파일 | 값 | 설명 | +|------|------|-----|------| +| `ImportModeAppend` | types.go:10 | `"append"` | 기존 유지 모드 | +| `ImportModeClear` | types.go:12 | `"clear"` | 기존 삭제 모드 | +| `MaxFileSizeBytes` | reader.go:54 | `50 * 1024` | 최대 파일 크기 (50KB) | + +#### Variables + +| 변수 | 파일 | 설명 | +|------|------|------| +| `SupportedFormats` | reader.go:12 | 지원 파일 확장자 맵 | + +#### Functions + +| 함수 | 파일 | 설명 | +|------|------|------| +| `NewImporter(provider, verbose)` | importer.go:20 | Importer 인스턴스 생성 | +| `NewReader(verbose)` | reader.go:62 | Reader 인스턴스 생성 | +| `NewExtractor(provider, verbose)` | extractor.go:21 | Extractor 인스턴스 생성 | +| `IsSupportedFormat(ext)` | reader.go:122 | 지원 포맷 여부 확인 | +| `GetSupportedExtensions()` | reader.go:127 | 지원 확장자 목록 반환 | + +#### Methods + +| 메서드 | 파일 | 설명 | +|--------|------|------| +| `(*Importer) Import(ctx, input)` | importer.go:29 | Import 워크플로우 실행 | +| `(*Reader) ReadFile(ctx, filePath)` | reader.go:67 | 파일 읽기 및 검증 | +| `(*Extractor) Extract(ctx, doc)` | extractor.go:29 | LLM으로 컨벤션 추출 | + +### Private API + +#### Importer (importer.go) + +| 함수/메서드 | 설명 | +|-------------|------| +| `assignUniqueIDs(existing, extracted, result)` | 고유 ID 생성 및 중복 처리 | +| `generateUniqueID(baseID, existingIDs)` | 고유 규칙 ID 생성 | +| `updateDefaultsLanguages(policy, newRules)` | defaults.languages 업데이트 | + +#### Extractor (extractor.go) + +| 함수/메서드 | 설명 | +|-------------|------| +| `buildExtractionPrompt(content, filename)` | LLM 프롬프트 생성 | +| `parseExtractionResponse(response, source)` | LLM JSON 응답 파싱 | +| `cleanJSONResponse(response)` | JSON 응답 정리 (마크다운 펜싱 제거) | +| `normalizeCategory(category)` | 카테고리명 정규화 | +| `normalizeSeverity(severity)` | 심각도 정규화 | +| `truncateString(s, maxLen)` | 문자열 자르기 | + +## 지원 파일 포맷 + +총 31개 포맷을 지원합니다: + +| 카테고리 | 확장자 | +|----------|--------| +| 텍스트 문서 | `.txt`, `.md`, `.markdown` | +| 코드 파일 | `.go`, `.js`, `.ts`, `.jsx`, `.tsx`, `.py`, `.java`, `.rs`, `.rb`, `.php`, `.c`, `.cpp`, `.h`, `.hpp`, `.cs`, `.swift`, `.kt`, `.scala` | +| 설정/데이터 | `.yaml`, `.yml`, `.json`, `.toml`, `.xml` | +| 웹 파일 | `.html`, `.htm`, `.css`, `.scss`, `.less` | +| 기타 | `.rst`, `.adoc` | + +## 사용 예시 + +```go +import ( + "context" + "github.com/DevSymphony/sym-cli/internal/importer" + "github.com/DevSymphony/sym-cli/internal/llm" +) + +func example() { + // LLM 프로바이더 생성 + provider, _ := llm.NewProvider() + + // Importer 생성 + imp := importer.NewImporter(provider, false) + + // Import 실행 + result, err := imp.Import(context.Background(), &importer.ImportInput{ + Path: "coding-standards.md", + Mode: importer.ImportModeAppend, + }) + + if err != nil { + // 에러 처리 + } + + fmt.Printf("추가된 카테고리: %d\n", len(result.CategoriesAdded)) + fmt.Printf("추가된 규칙: %d\n", len(result.RulesAdded)) +} +``` + +## 참고 문헌 + +- [sym import 명령어](/docs/COMMAND.md#sym-import) - CLI 사용법 +- [import_convention MCP 도구](/docs/COMMAND.md#import_convention) - MCP 도구 스키마 +- [pkg/schema](/pkg/schema/README.md) - UserPolicy, UserRule 타입 정의 diff --git a/internal/importer/extractor.go b/internal/importer/extractor.go new file mode 100644 index 0000000..b55663e --- /dev/null +++ b/internal/importer/extractor.go @@ -0,0 +1,202 @@ +package importer + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Extractor uses LLM to extract conventions from document content +type Extractor struct { + provider llm.Provider + verbose bool +} + +// NewExtractor creates a new Extractor instance +func NewExtractor(provider llm.Provider, verbose bool) *Extractor { + return &Extractor{ + provider: provider, + verbose: verbose, + } +} + +// Extract analyzes document content and extracts coding conventions +func (e *Extractor) Extract(ctx context.Context, doc *DocumentContent) (*ExtractedConventions, error) { + // Build prompt + prompt := e.buildExtractionPrompt(doc.Content, filepath.Base(doc.Path)) + + // Call LLM + response, err := e.provider.Execute(ctx, prompt, llm.JSON) + if err != nil { + return nil, fmt.Errorf("LLM execution failed: %w", err) + } + + // Parse response + conventions, err := e.parseExtractionResponse(response, doc.Path) + if err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) + } + + return conventions, nil +} + +// buildExtractionPrompt builds the LLM prompt for convention extraction +func (e *Extractor) buildExtractionPrompt(content string, filename string) string { + // Truncate content if too long + maxContentLen := 40000 + if len(content) > maxContentLen { + content = content[:maxContentLen] + "\n\n... (content truncated)" + } + + return fmt.Sprintf(`You are a coding standards expert. Analyze the following document and extract coding conventions/rules from it. + +SOURCE DOCUMENT: %s + +DOCUMENT CONTENT: +--- +%s +--- + +TASK: Extract all coding conventions, rules, and guidelines from this document. + +OUTPUT FORMAT: Return ONLY valid JSON (no markdown fencing, no preamble text): +{ + "categories": [ + {"name": "category_name", "description": "1-2 sentence description of the category"} + ], + "rules": [ + { + "id": "CATEGORY-001", + "say": "Natural language description of what the rule enforces", + "category": "category_name", + "languages": ["javascript", "typescript"], + "severity": "error", + "message": "Short message shown when rule is violated", + "example": "Optional example of correct/incorrect code" + } + ] +} + +RULES FOR EXTRACTION: +1. Category names MUST be lowercase with underscores (e.g., "error_handling", "code_style") +2. Use standard categories when applicable: security, style, documentation, error_handling, architecture, performance, testing, naming, formatting +3. Rule IDs MUST be unique and follow pattern: UPPERCASE_CATEGORY-NNN (e.g., SEC-001, STYLE-001, DOC-001) +4. The "say" field MUST be a clear, actionable statement (e.g., "Use async/await instead of Promise callbacks") +5. Languages should be lowercase (e.g., "javascript", "python", "go", "java") +6. Severity MUST be one of: "error", "warning", "info" +7. If the document doesn't contain coding conventions, return: {"categories": [], "rules": []} +8. Extract ONLY coding conventions, not general documentation or explanations +9. Each rule should be specific and enforceable + +EXAMPLES OF GOOD EXTRACTIONS: +- "All functions must have JSDoc comments" -> {"id": "DOC-001", "say": "All functions must have JSDoc comments", "category": "documentation", "severity": "warning"} +- "No console.log in production code" -> {"id": "STYLE-001", "say": "Remove all console.log statements from production code", "category": "style", "severity": "error"} +- "Use parameterized queries" -> {"id": "SEC-001", "say": "Use parameterized queries for all database operations to prevent SQL injection", "category": "security", "severity": "error"} +- "Function names must use camelCase" -> {"id": "NAMING-001", "say": "Function names must use camelCase convention", "category": "naming", "severity": "warning"}`, filename, content) +} + +// parseExtractionResponse parses the LLM JSON response into conventions +func (e *Extractor) parseExtractionResponse(response string, source string) (*ExtractedConventions, error) { + // Clean response - remove potential markdown fencing + response = cleanJSONResponse(response) + + // Parse JSON + var llmResponse LLMExtractionResponse + if err := json.Unmarshal([]byte(response), &llmResponse); err != nil { + return nil, fmt.Errorf("invalid JSON response: %w (response: %s)", err, truncateString(response, 200)) + } + + // Convert to schema types + conventions := &ExtractedConventions{ + Source: source, + Categories: make([]schema.CategoryDef, 0, len(llmResponse.Categories)), + Rules: make([]schema.UserRule, 0, len(llmResponse.Rules)), + } + + // Convert categories + for _, cat := range llmResponse.Categories { + if cat.Name == "" { + continue + } + conventions.Categories = append(conventions.Categories, schema.CategoryDef{ + Name: normalizeCategory(cat.Name), + Description: cat.Description, + }) + } + + // Convert rules + for _, rule := range llmResponse.Rules { + if rule.ID == "" || rule.Say == "" { + continue + } + + userRule := schema.UserRule{ + ID: rule.ID, + Say: rule.Say, + Category: normalizeCategory(rule.Category), + Languages: rule.Languages, + Severity: normalizeSeverity(rule.Severity), + Message: rule.Message, + Example: rule.Example, + } + conventions.Rules = append(conventions.Rules, userRule) + } + + return conventions, nil +} + +// cleanJSONResponse removes markdown fencing and extra whitespace from JSON response +func cleanJSONResponse(response string) string { + response = strings.TrimSpace(response) + + // Remove markdown code fencing + if strings.HasPrefix(response, "```json") { + response = strings.TrimPrefix(response, "```json") + } else if strings.HasPrefix(response, "```") { + response = strings.TrimPrefix(response, "```") + } + + response = strings.TrimSuffix(response, "```") + + return strings.TrimSpace(response) +} + +// normalizeCategory normalizes category name to lowercase with underscores +func normalizeCategory(category string) string { + if category == "" { + return "general" + } + // Convert to lowercase and replace spaces/hyphens with underscores + category = strings.ToLower(category) + category = strings.ReplaceAll(category, " ", "_") + category = strings.ReplaceAll(category, "-", "_") + return category +} + +// normalizeSeverity normalizes severity to valid values +func normalizeSeverity(severity string) string { + severity = strings.ToLower(strings.TrimSpace(severity)) + switch severity { + case "error", "err": + return "error" + case "warning", "warn": + return "warning" + case "info", "information": + return "info" + default: + return "warning" // Default to warning + } +} + +// truncateString truncates a string to maxLen characters +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 0000000..381563f --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,173 @@ +package importer + +import ( + "context" + "fmt" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/internal/policy" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Importer handles the complete import workflow +type Importer struct { + reader *Reader + extractor *Extractor + verbose bool +} + +// NewImporter creates a new Importer instance +func NewImporter(provider llm.Provider, verbose bool) *Importer { + return &Importer{ + reader: NewReader(verbose), + extractor: NewExtractor(provider, verbose), + verbose: verbose, + } +} + +// Import executes the import workflow for a single file +func (i *Importer) Import(ctx context.Context, input *ImportInput) (*ImportResult, error) { + result := &ImportResult{ + CategoriesAdded: []schema.CategoryDef{}, + RulesAdded: []schema.UserRule{}, + Warnings: []string{}, + } + + // Validate input + if input.Path == "" { + return nil, fmt.Errorf("file path is required") + } + + // Step 1: Read the file + doc, err := i.reader.ReadFile(ctx, input.Path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + result.FileProcessed = doc.Path + + // Step 2: Extract conventions using LLM + extracted, err := i.extractor.Extract(ctx, doc) + if err != nil { + return nil, fmt.Errorf("failed to extract conventions: %w", err) + } + + if len(extracted.Categories) == 0 && len(extracted.Rules) == 0 { + result.Warnings = append(result.Warnings, "No conventions found in the document") + return result, nil + } + + // Step 3: Load existing policy + existingPolicy, err := policy.LoadPolicy("") + if err != nil { + return nil, fmt.Errorf("failed to load existing policy: %w", err) + } + + // Step 4: Apply import mode + if input.Mode == ImportModeClear { + result.CategoriesRemoved = len(existingPolicy.Category) + result.RulesRemoved = len(existingPolicy.Rules) + existingPolicy.Category = []schema.CategoryDef{} + existingPolicy.Rules = []schema.UserRule{} + existingPolicy.Defaults.Languages = []string{} + } + + // Step 5: Generate unique IDs and merge + newCategories, newRules := i.assignUniqueIDs(existingPolicy, extracted, result) + + existingPolicy.Category = append(existingPolicy.Category, newCategories...) + existingPolicy.Rules = append(existingPolicy.Rules, newRules...) + + result.CategoriesAdded = newCategories + result.RulesAdded = newRules + + // Step 5.5: Update defaults.languages with new languages from rules + i.updateDefaultsLanguages(existingPolicy, newRules) + + // Step 6: Save updated policy + if err := policy.SavePolicy(existingPolicy, ""); err != nil { + return result, fmt.Errorf("failed to save policy: %w", err) + } + + return result, nil +} + +// assignUniqueIDs generates unique IDs for all extracted items +func (i *Importer) assignUniqueIDs( + existing *schema.UserPolicy, + extracted *ExtractedConventions, + result *ImportResult, +) ([]schema.CategoryDef, []schema.UserRule) { + // Build map of existing category names + existingCategoryNames := make(map[string]bool) + for _, cat := range existing.Category { + existingCategoryNames[cat.Name] = true + } + + // Build map of existing rule IDs + existingRuleIDs := make(map[string]bool) + for _, rule := range existing.Rules { + existingRuleIDs[rule.ID] = true + } + + // Process categories: skip duplicates + var newCategories []schema.CategoryDef + for _, cat := range extracted.Categories { + if existingCategoryNames[cat.Name] { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Category '%s' already exists, skipped", cat.Name)) + continue + } + newCategories = append(newCategories, cat) + existingCategoryNames[cat.Name] = true + } + + // Process rules: generate unique IDs + var newRules []schema.UserRule + for _, rule := range extracted.Rules { + uniqueID := i.generateUniqueID(rule.ID, existingRuleIDs) + if uniqueID != rule.ID { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Rule ID '%s' already exists, renamed to '%s'", rule.ID, uniqueID)) + } + rule.ID = uniqueID + existingRuleIDs[uniqueID] = true + newRules = append(newRules, rule) + } + + return newCategories, newRules +} + +// generateUniqueID generates a unique rule ID +func (i *Importer) generateUniqueID(baseID string, existingIDs map[string]bool) string { + if !existingIDs[baseID] { + return baseID + } + + counter := 1 + for { + newID := fmt.Sprintf("%s-%d", baseID, counter) + if !existingIDs[newID] { + return newID + } + counter++ + } +} + +// updateDefaultsLanguages adds new languages from rules to defaults.languages +func (i *Importer) updateDefaultsLanguages(p *schema.UserPolicy, newRules []schema.UserRule) { + // Build set of existing default languages + existingLangs := make(map[string]bool) + for _, lang := range p.Defaults.Languages { + existingLangs[lang] = true + } + + // Collect new languages from rules + for _, rule := range newRules { + for _, lang := range rule.Languages { + if !existingLangs[lang] { + p.Defaults.Languages = append(p.Defaults.Languages, lang) + existingLangs[lang] = true + } + } + } +} diff --git a/internal/importer/reader.go b/internal/importer/reader.go new file mode 100644 index 0000000..3f2bd7c --- /dev/null +++ b/internal/importer/reader.go @@ -0,0 +1,133 @@ +package importer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SupportedFormats lists all supported file extensions +var SupportedFormats = map[string]bool{ + // Text documents + ".txt": true, + ".md": true, + ".markdown": true, + // Code files + ".go": true, + ".js": true, + ".ts": true, + ".jsx": true, + ".tsx": true, + ".py": true, + ".java": true, + ".rs": true, + ".rb": true, + ".php": true, + ".c": true, + ".cpp": true, + ".h": true, + ".hpp": true, + ".cs": true, + ".swift": true, + ".kt": true, + ".scala": true, + // Config/data files + ".yaml": true, + ".yml": true, + ".json": true, + ".toml": true, + ".xml": true, + // Web files + ".html": true, + ".htm": true, + ".css": true, + ".scss": true, + ".less": true, + // Other + ".rst": true, + ".adoc": true, +} + +// MaxFileSizeBytes is the maximum size for a single file (50KB for LLM context) +const MaxFileSizeBytes = 50 * 1024 + +// Reader handles file reading and format detection +type Reader struct { + verbose bool +} + +// NewReader creates a new Reader instance +func NewReader(verbose bool) *Reader { + return &Reader{verbose: verbose} +} + +// ReadFile reads a single file and extracts text content +func (r *Reader) ReadFile(ctx context.Context, filePath string) (*DocumentContent, error) { + // Check context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Check if path exists + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("file not found: %w", err) + } + + // Ensure it's a file, not a directory + if info.IsDir() { + return nil, fmt.Errorf("path is a directory, not a file: %s", absPath) + } + + // Check file extension + ext := strings.ToLower(filepath.Ext(absPath)) + if !IsSupportedFormat(ext) { + return nil, fmt.Errorf("unsupported format: %s", ext) + } + + // Check file size + if info.Size() == 0 { + return nil, fmt.Errorf("empty file") + } + + if info.Size() > MaxFileSizeBytes { + return nil, fmt.Errorf("file too large (%d bytes, max %d bytes)", info.Size(), MaxFileSizeBytes) + } + + // Read file content + content, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return &DocumentContent{ + Path: absPath, + Content: string(content), + Format: ext, + Size: info.Size(), + }, nil +} + +// IsSupportedFormat checks if a file extension is supported +func IsSupportedFormat(ext string) bool { + return SupportedFormats[strings.ToLower(ext)] +} + +// GetSupportedExtensions returns a list of supported extensions +func GetSupportedExtensions() []string { + var exts []string + for ext := range SupportedFormats { + exts = append(exts, ext) + } + return exts +} diff --git a/internal/importer/types.go b/internal/importer/types.go new file mode 100644 index 0000000..92fda72 --- /dev/null +++ b/internal/importer/types.go @@ -0,0 +1,61 @@ +package importer + +import "github.com/DevSymphony/sym-cli/pkg/schema" + +// ImportMode defines how imported conventions are merged with existing ones +type ImportMode string + +const ( + // ImportModeAppend keeps existing categories/rules and adds new ones + ImportModeAppend ImportMode = "append" + // ImportModeClear removes existing categories/rules, then imports new ones + ImportModeClear ImportMode = "clear" +) + +// ImportInput represents the input for import operation +type ImportInput struct { + Path string // Single file path to import + Mode ImportMode // Import mode (clear or append) +} + +// ImportResult represents the result of import operation +type ImportResult struct { + CategoriesAdded []schema.CategoryDef // New categories added + RulesAdded []schema.UserRule // New rules added + CategoriesRemoved int // Categories removed (clear mode only) + RulesRemoved int // Rules removed (clear mode only) + FileProcessed string // Processed file path + Warnings []string // Non-fatal warnings +} + +// DocumentContent represents parsed document content +type DocumentContent struct { + Path string // File path + Content string // Extracted text content + Format string // Original format (txt, md, go, etc.) + Size int64 // Original file size +} + +// ExtractedConventions represents LLM-extracted conventions from a document +type ExtractedConventions struct { + Categories []schema.CategoryDef + Rules []schema.UserRule + Source string // Source document path +} + +// LLMExtractionResponse represents the expected JSON response from LLM +type LLMExtractionResponse struct { + Categories []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"categories"` + Rules []struct { + ID string `json:"id"` + Say string `json:"say"` + Category string `json:"category"` + Languages []string `json:"languages,omitempty"` + Severity string `json:"severity,omitempty"` + Message string `json:"message,omitempty"` + Example string `json:"example,omitempty"` + } `json:"rules"` +} diff --git a/internal/mcp/README.md b/internal/mcp/README.md index 36d0fa2..8e7b2df 100644 --- a/internal/mcp/README.md +++ b/internal/mcp/README.md @@ -28,11 +28,11 @@ mcp/ ┌───────────┐ │ mcp │ └─────┬─────┘ - ┌───────┬───────┼───────┬───────┐ - ▼ ▼ ▼ ▼ ▼ -┌─────────┐ ┌─────┐ ┌───────┐ ┌─────┐ ┌─────────┐ -│converter│ │ llm │ │policy │ │roles│ │validator│ -└─────────┘ └─────┘ └───────┘ └─────┘ └─────────┘ + ┌───────┬───────┼───────┬───────┬────────┐ + ▼ ▼ ▼ ▼ ▼ ▼ +┌─────────┐ ┌─────┐ ┌───────┐ ┌─────┐ ┌─────────┐ ┌────────┐ +│converter│ │ llm │ │policy │ │roles│ │validator│ │importer│ +└─────────┘ └─────┘ └───────┘ └─────┘ └─────────┘ └────────┘ │ ┌───────┴───────┐ ▼ ▼ @@ -59,6 +59,7 @@ mcp/ | `AddCategoryInput` | server.go:225 | add_category 입력 스키마 | | `EditCategoryInput` | server.go:230 | edit_category 입력 스키마 | | `RemoveCategoryInput` | server.go:235 | remove_category 입력 스키마 | +| `ImportConventionsInput` | server.go:240 | import_convention 입력 스키마 | | `QueryConventionsRequest` | server.go:330 | 컨벤션 조회 요청 | | `ConventionItem` | server.go:250 | 컨벤션 항목 | | `ValidateCodeRequest` | server.go:411 | 검증 요청 | @@ -99,9 +100,10 @@ mcp/ | `handleAddCategory(input)` | 카테고리 추가 핸들러 | | `handleEditCategory(input)` | 카테고리 편집 핸들러 | | `handleRemoveCategory(input)` | 카테고리 삭제 핸들러 | +| `handleImportConventions(ctx, input)` | import_convention 핸들러 | | `saveUserPolicy()` | 정책 파일 저장 | ## 참고 문헌 -- [MCP 도구 스키마](../../docs/COMMAND.md#mcp-도구-스키마) - query_conventions, validate_code, list_category, add_category, edit_category, remove_category 입력/출력 스펙 +- [MCP 도구 스키마](../../docs/COMMAND.md#mcp-도구-스키마) - query_conventions, validate_code, list_category, add_category, edit_category, remove_category, import_convention 입력/출력 스펙 - [MCP 통합 가이드](../../docs/COMMAND.md#mcp-통합) - 지원 도구 및 등록 방법 diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9515e30..4a95169 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -10,6 +10,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/importer" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -236,6 +237,12 @@ type RemoveCategoryInput struct { Names []string `json:"names" jsonschema:"Array of category names to remove"` } +// ImportConventionsInput represents the input schema for the import_convention tool. +type ImportConventionsInput struct { + Path string `json:"path" jsonschema:"File path to import conventions from"` + Mode string `json:"mode,omitempty" jsonschema:"Import mode: 'append' (default) keeps existing, 'clear' removes existing first"` +} + // runStdioWithSDK runs a spec-compliant MCP server over stdio using the official go-sdk. func (s *Server) runStdioWithSDK(ctx context.Context) error { server := sdkmcp.NewServer(&sdkmcp.Implementation{ @@ -323,6 +330,18 @@ func (s *Server) runStdioWithSDK(ctx context.Context) error { return nil, result.(map[string]any), nil }) + // Tool: import_convention + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "import_convention", + Description: "Import coding conventions from external documents (txt, md, code files) into user-policy.json. Uses LLM to extract categories and rules from document content.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input ImportConventionsInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleImportConventions(ctx, input) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + // Run the server over stdio until the client disconnects return server.Run(ctx, &sdkmcp.StdioTransport{}) } @@ -1175,6 +1194,128 @@ func (s *Server) buildBatchResponse(action string, succeeded []string, failed [] } } +// handleImportConventions handles the import_convention tool. +func (s *Server) handleImportConventions(ctx context.Context, input ImportConventionsInput) (interface{}, *RPCError) { + // Validate input + if input.Path == "" { + return nil, &RPCError{Code: -32602, Message: "File path is required"} + } + + // Default mode to append + mode := importer.ImportModeAppend + if input.Mode == "clear" { + mode = importer.ImportModeClear + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmProvider, err := llm.New(llmCfg) + if err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to create LLM provider: %v", err)} + } + defer func() { _ = llmProvider.Close() }() + + // Create importer and execute + imp := importer.NewImporter(llmProvider, false) + importInput := &importer.ImportInput{ + Path: input.Path, + Mode: mode, + } + + result, err := imp.Import(ctx, importInput) + if err != nil { + // Build partial result response if available + if result != nil { + return s.buildImportResponse(result, err), nil + } + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Import failed: %v", err)} + } + + // Reload user policy after import + if s.userPolicy != nil { + userPolicyPath := s.getUserPolicyPath() + if userPolicy, err := s.loader.LoadUserPolicy(userPolicyPath); err == nil { + s.userPolicy = userPolicy + } + } + + return s.buildImportResponse(result, nil), nil +} + +// buildImportResponse builds the MCP response for import operation. +func (s *Server) buildImportResponse(result *importer.ImportResult, importErr error) map[string]interface{} { + var textContent strings.Builder + textContent.WriteString("Convention Import ") + + if importErr != nil { + textContent.WriteString("(completed with errors)\n\n") + } else { + textContent.WriteString("Complete\n\n") + } + + if result.FileProcessed != "" { + textContent.WriteString(fmt.Sprintf("Processed: %s\n\n", result.FileProcessed)) + } + + if result.CategoriesRemoved > 0 || result.RulesRemoved > 0 { + textContent.WriteString(fmt.Sprintf("Removed: %d categories, %d rules (clear mode)\n\n", + result.CategoriesRemoved, result.RulesRemoved)) + } + + if len(result.CategoriesAdded) > 0 { + textContent.WriteString(fmt.Sprintf("Added %d categories:\n", len(result.CategoriesAdded))) + for _, cat := range result.CategoriesAdded { + textContent.WriteString(fmt.Sprintf(" • %s: %s\n", cat.Name, cat.Description)) + } + textContent.WriteString("\n") + } + + if len(result.RulesAdded) > 0 { + textContent.WriteString(fmt.Sprintf("Added %d rules:\n", len(result.RulesAdded))) + for _, rule := range result.RulesAdded { + textContent.WriteString(fmt.Sprintf(" • [%s] %s (%s)\n", rule.ID, rule.Say, rule.Category)) + } + textContent.WriteString("\n") + } + + if len(result.Warnings) > 0 { + textContent.WriteString(fmt.Sprintf("Warnings (%d):\n", len(result.Warnings))) + for _, w := range result.Warnings { + textContent.WriteString(fmt.Sprintf(" • %s\n", w)) + } + textContent.WriteString("\n") + } + + if importErr != nil { + textContent.WriteString(fmt.Sprintf("Import Error: %v\n", importErr)) + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": textContent.String()}, + }, + } +} + +// getUserPolicyPath returns the user policy file path. +func (s *Server) getUserPolicyPath() string { + projectCfg, _ := config.LoadProjectConfig() + userPolicyPath := projectCfg.PolicyPath + if userPolicyPath == "" { + repoRoot, err := git.GetRepoRoot() + if err != nil { + return ".sym/user-policy.json" + } + return filepath.Join(repoRoot, ".sym", "user-policy.json") + } + if !filepath.IsAbs(userPolicyPath) { + if repoRoot, err := git.GetRepoRoot(); err == nil { + userPolicyPath = filepath.Join(repoRoot, userPolicyPath) + } + } + return userPolicyPath +} + // saveUserPolicy saves the user policy to file. func (s *Server) saveUserPolicy() error { return policy.SavePolicy(s.userPolicy, "") diff --git a/internal/server/README.md b/internal/server/README.md index 9ff40e8..769f195 100644 --- a/internal/server/README.md +++ b/internal/server/README.md @@ -36,6 +36,7 @@ internal/server/ | internal/policy | 정책 파일 로드/저장, 템플릿 관리 | | internal/roles | 역할 관리, 현재 역할 조회/설정 | | internal/util/config | 프로젝트 설정(config.json) 로드/저장 | +| internal/importer | 외부 문서에서 컨벤션 추출 (`/api/import` 엔드포인트) | | pkg/schema | UserPolicy 타입 정의 | **외부 의존성:** @@ -92,6 +93,7 @@ type Server struct { | handleAddCategory | POST /api/categories | 카테고리 추가 | | handleEditCategory | PUT /api/categories/{name} | 카테고리 편집 | | handleDeleteCategory | DELETE /api/categories/{name} | 카테고리 삭제 | +| handleImport | POST /api/import | Import 실행 (editPolicy 권한 필요) | ## 참고 문헌 diff --git a/internal/server/server.go b/internal/server/server.go index 864c537..a6d3775 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/importer" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -60,6 +61,9 @@ func (s *Server) Start() error { mux.HandleFunc("/api/categories", s.handleCategories) mux.HandleFunc("/api/categories/", s.handleCategoryByName) + // Import API endpoint + mux.HandleFunc("/api/import", s.handleImport) + // Static files staticFS, err := fs.Sub(staticFiles, "static") if err != nil { @@ -1006,3 +1010,108 @@ func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request, ca "message": fmt.Sprintf("Category '%s' removed successfully", categoryName), }) } + +// ImportRequest represents the HTTP request body for import +type ImportRequest struct { + Path string `json:"path"` // Single file path to import + Mode string `json:"mode"` // "append" or "clear" +} + +// ImportResponse represents the HTTP response for import +type ImportResponse struct { + Status string `json:"status"` + CategoriesAdded []schema.CategoryDef `json:"categoriesAdded"` + RulesAdded []schema.UserRule `json:"rulesAdded"` + CategoriesRemoved int `json:"categoriesRemoved,omitempty"` + RulesRemoved int `json:"rulesRemoved,omitempty"` + FileProcessed string `json:"fileProcessed"` + Warnings []string `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleImport handles POST /api/import +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check permission + currentRole, err := roles.GetCurrentRole() + if err != nil { + http.Error(w, "Failed to get current role", http.StatusInternalServerError) + return + } + + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") + if err != nil || !canEdit { + http.Error(w, "Forbidden: editPolicy permission required", http.StatusForbidden) + return + } + + // Parse request + var req ImportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate + if req.Path == "" { + http.Error(w, "File path is required", http.StatusBadRequest) + return + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmProvider, err := llm.New(llmCfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create LLM provider: %v", err), http.StatusInternalServerError) + return + } + defer func() { _ = llmProvider.Close() }() + + // Create importer and execute + mode := importer.ImportModeAppend + if req.Mode == "clear" { + mode = importer.ImportModeClear + } + + imp := importer.NewImporter(llmProvider, false) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + + input := &importer.ImportInput{ + Path: req.Path, + Mode: mode, + } + + result, err := imp.Import(ctx, input) + + // Build response + response := ImportResponse{ + Status: "success", + } + + if result != nil { + response.CategoriesAdded = result.CategoriesAdded + response.RulesAdded = result.RulesAdded + response.CategoriesRemoved = result.CategoriesRemoved + response.RulesRemoved = result.RulesRemoved + response.FileProcessed = result.FileProcessed + response.Warnings = result.Warnings + } + + if err != nil { + response.Status = "error" + response.Error = err.Error() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} diff --git a/internal/server/static/index.html b/internal/server/static/index.html index e699a99..e74cf8b 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -46,6 +46,9 @@

템플릿 + @@ -211,6 +214,41 @@

+ + +