From 6e525517f7c936e721cde711c3dcbf8de43f89c6 Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Sun, 26 Apr 2026 20:08:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20LLM=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=EC=9D=84=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EC=97=90=EC=84=9C=20=EC=84=A4=EC=A0=95=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원격 호스트의 Ollama 서버 등 응답이 느린 환경에서 고정 타임아웃 때문에 요청이 실패하던 문제를 해결한다. `--timeout` 플래그와 `HWP2MD_TIMEOUT` 환경변수로 모든 LLM 프로바이더(openai, anthropic, gemini, upstage, ollama)에 일관되게 적용되며, 미지정 시 기존 프로바이더 기본값을 그대로 사용한다. 값은 Go 기간 형식(예: `5m`, `300s`, `10m30s`)으로 받고, 양수가 아니거나 파싱 불가능한 입력은 명시적 에러로 거절한다. Closes #24 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + internal/cli/cli_test.go | 40 +++++++++++++++++++++++++++++++++++ internal/cli/config.go | 1 + internal/cli/convert.go | 45 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b0648d9..c0ff468 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown | `HWP2MD_LLM` | Enable Stage 2: `true` | | `HWP2MD_MODEL` | Model name (auto-detects provider) | | `HWP2MD_BASE_URL` | Private API endpoint (Bedrock, Azure, local) | +| `HWP2MD_TIMEOUT` | LLM request timeout (Go duration: `5m`, `300s`, `10m30s`). Empty → provider default | | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `UPSTAGE_API_KEY` | Provider API keys | ## Conventions diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9389553..340ba2f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,6 +3,7 @@ package cli import ( "os" "testing" + "time" ) func TestSetVersion(t *testing.T) { @@ -238,3 +239,42 @@ func TestDetectProviderFromModel(t *testing.T) { }) } } + +func TestParseLLMTimeout(t *testing.T) { + tests := []struct { + name string + flag string + env string + want time.Duration + wantErr bool + }{ + {"both empty falls back to provider default", "", "", 0, false}, + {"flag value parsed", "5m", "", 5 * time.Minute, false}, + {"env value parsed when flag empty", "", "300s", 300 * time.Second, false}, + {"flag overrides env", "10m", "30s", 10 * time.Minute, false}, + {"compound duration", "10m30s", "", 10*time.Minute + 30*time.Second, false}, + {"whitespace trimmed", " 2m ", "", 2 * time.Minute, false}, + {"invalid flag returns error", "abc", "", 0, true}, + {"invalid env returns error", "", "5", 0, true}, + {"zero rejected", "0s", "", 0, true}, + {"negative rejected", "-1s", "", 0, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parseLLMTimeout(tc.flag, tc.env) + if tc.wantErr { + if err == nil { + t.Fatalf("parseLLMTimeout(%q, %q) expected error, got nil", tc.flag, tc.env) + } + return + } + if err != nil { + t.Fatalf("parseLLMTimeout(%q, %q) unexpected error: %v", tc.flag, tc.env, err) + } + if got != tc.want { + t.Errorf("parseLLMTimeout(%q, %q) = %v, want %v", tc.flag, tc.env, got, tc.want) + } + }) + } +} diff --git a/internal/cli/config.go b/internal/cli/config.go index 57b89de..c2dff0e 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -125,6 +125,7 @@ func runConfigShow(cmd *cobra.Command, args []string) error { {"HWP2MD_LLM", "LLM 활성화", os.Getenv("HWP2MD_LLM")}, {"HWP2MD_MODEL", "모델 (프로바이더 자동 감지)", os.Getenv("HWP2MD_MODEL")}, {"HWP2MD_BASE_URL", "프라이빗 API 엔드포인트", os.Getenv("HWP2MD_BASE_URL")}, + {"HWP2MD_TIMEOUT", "LLM 요청 타임아웃 (예: 5m)", os.Getenv("HWP2MD_TIMEOUT")}, {"ANTHROPIC_API_KEY", "Anthropic API 키", maskAPIKey(os.Getenv("ANTHROPIC_API_KEY"))}, {"OPENAI_API_KEY", "OpenAI API 키", maskAPIKey(os.Getenv("OPENAI_API_KEY"))}, {"GOOGLE_API_KEY", "Google API 키", maskAPIKey(os.Getenv("GOOGLE_API_KEY"))}, diff --git a/internal/cli/convert.go b/internal/cli/convert.go index c82b4dc..9576d7d 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/roboco-io/hwp2md/internal/config" "github.com/roboco-io/hwp2md/internal/ir" @@ -31,6 +32,7 @@ var ( convertParser string convertExtractImgs bool convertImagesDir string + convertTimeout string convertVerbose bool convertQuiet bool ) @@ -49,6 +51,7 @@ var convertCmd = &cobra.Command{ HWP2MD_LLM=true Stage 2 활성화 HWP2MD_MODEL=xxx 모델 이름 (프로바이더 자동 감지) HWP2MD_BASE_URL=xxx 프라이빗 API 엔드포인트 (Bedrock, 로컬 서버 등) + HWP2MD_TIMEOUT=xxx LLM 요청 타임아웃 (예: 5m, 300s, 10m30s) 모델 이름 예시: claude-* → Anthropic @@ -74,6 +77,7 @@ var convertCmd = &cobra.Command{ hwp2md convert document.hwpx --llm --model gpt-4o hwp2md convert document.hwpx --llm --model solar-pro hwp2md convert document.hwpx --llm --base-url http://localhost:8080 + hwp2md convert document.hwpx --llm --timeout 10m hwp2md convert document.hwpx --extract-images ./images`, Args: cobra.ExactArgs(1), RunE: runConvert, @@ -88,6 +92,7 @@ func init() { convertCmd.Flags().StringVar(&convertParser, "parser", "", "파서 선택 (native, upstage)") convertCmd.Flags().BoolVar(&convertExtractImgs, "extract-images", false, "이미지 추출 활성화") convertCmd.Flags().StringVar(&convertImagesDir, "images-dir", "./images", "추출된 이미지 저장 디렉토리") + convertCmd.Flags().StringVar(&convertTimeout, "timeout", "", "LLM 요청 타임아웃 (예: 5m, 300s; 미지정 시 프로바이더 기본값)") convertCmd.Flags().BoolVarP(&convertVerbose, "verbose", "v", false, "상세 출력") convertCmd.Flags().BoolVarP(&convertQuiet, "quiet", "q", false, "조용한 모드") @@ -256,6 +261,12 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes baseURL = os.Getenv("HWP2MD_BASE_URL") } + // Determine LLM request timeout (flag > env). Zero means provider default. + timeout, err := parseLLMTimeout(convertTimeout, os.Getenv("HWP2MD_TIMEOUT")) + if err != nil { + return "", nil, err + } + // Auto-detect provider from model name, or use explicit flag providerName := convertProvider if providerName == "" { @@ -264,33 +275,37 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes // Create provider var provider llm.Provider - var err error switch providerName { case "openai": provider, err = openai.New(openai.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "anthropic": provider, err = anthropic.New(anthropic.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "gemini": // Gemini does not support custom base URL (uses Google API only) provider, err = gemini.New(gemini.Config{ - Model: model, + Model: model, + Timeout: timeout, }) case "upstage": provider, err = llmupstage.New(llmupstage.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) case "ollama": provider, err = ollama.New(ollama.Config{ Model: model, BaseURL: baseURL, + Timeout: timeout, }) default: return "", nil, fmt.Errorf("지원하지 않는 프로바이더: %s (지원: openai, anthropic, gemini, upstage, ollama)", providerName) @@ -313,6 +328,32 @@ func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatRes return result.Markdown, result, nil } +// parseLLMTimeout resolves the LLM request timeout from the --timeout flag +// or the HWP2MD_TIMEOUT environment variable. The flag takes precedence; +// when both are empty it returns 0 so each provider applies its own default. +// The value must be a Go duration string (e.g. "5m", "300s", "10m30s") and +// must be positive. +func parseLLMTimeout(flagVal, envVal string) (time.Duration, error) { + raw := strings.TrimSpace(flagVal) + source := "--timeout" + if raw == "" { + raw = strings.TrimSpace(envVal) + source = "HWP2MD_TIMEOUT" + } + if raw == "" { + return 0, nil + } + + d, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s 값 파싱 실패 (%q): Go 기간 형식이어야 합니다 (예: 5m, 300s): %w", source, raw, err) + } + if d <= 0 { + return 0, fmt.Errorf("%s 값은 양수여야 합니다 (입력: %q)", source, raw) + } + return d, nil +} + func convertToBasicMarkdown(doc *ir.Document) string { // If RawMarkdown is available (e.g., from Upstage parser), use it directly if doc.RawMarkdown != "" { From da11602ca5a4f598f1c712f5b0a9035d1e771432 Mon Sep 17 00:00:00 2001 From: Jung Do Hyun Date: Mon, 27 Apr 2026 11:08:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20PR=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20formatWithLLM=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80/=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 셀프 리뷰 지적 사항을 반영한다. - `formatWithLLM`의 unused `cmd *cobra.Command` 파라미터를 제거하고 호출자도 `formatWithLLM(doc)`로 갱신해 dead API 표면을 정리한다. - `parseLLMTimeout` 에러 메시지를 "Go 기간 형식이어야 합니다"에서 "`5m`, `300s`, `10m30s` 같은 시간 형식이어야 합니다"로 자연스럽게 다듬는다. - `config show`의 `HWP2MD_TIMEOUT` 설명을 `convert --help`/CLAUDE.md와 같은 디테일(예시 + provider 기본값 폴백 안내)로 통일한다. - `TestParseLLMTimeout`에 source-specific 메시지와 공백 fallback 케이스 3종을 추가해 우선순위/메시지 품질을 함께 검증한다 (총 13 케이스). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/cli_test.go | 39 ++++++++++++++++++++++++--------------- internal/cli/config.go | 2 +- internal/cli/convert.go | 6 +++--- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 340ba2f..a92da7d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "os" + "strings" "testing" "time" ) @@ -242,22 +243,26 @@ func TestDetectProviderFromModel(t *testing.T) { func TestParseLLMTimeout(t *testing.T) { tests := []struct { - name string - flag string - env string - want time.Duration - wantErr bool + name string + flag string + env string + want time.Duration + wantErr bool + wantErrContain string // optional substring expected in the error message }{ - {"both empty falls back to provider default", "", "", 0, false}, - {"flag value parsed", "5m", "", 5 * time.Minute, false}, - {"env value parsed when flag empty", "", "300s", 300 * time.Second, false}, - {"flag overrides env", "10m", "30s", 10 * time.Minute, false}, - {"compound duration", "10m30s", "", 10*time.Minute + 30*time.Second, false}, - {"whitespace trimmed", " 2m ", "", 2 * time.Minute, false}, - {"invalid flag returns error", "abc", "", 0, true}, - {"invalid env returns error", "", "5", 0, true}, - {"zero rejected", "0s", "", 0, true}, - {"negative rejected", "-1s", "", 0, true}, + {name: "both empty falls back to provider default", flag: "", env: "", want: 0}, + {name: "flag value parsed", flag: "5m", want: 5 * time.Minute}, + {name: "env value parsed when flag empty", env: "300s", want: 300 * time.Second}, + {name: "flag overrides env", flag: "10m", env: "30s", want: 10 * time.Minute}, + {name: "compound duration", flag: "10m30s", want: 10*time.Minute + 30*time.Second}, + {name: "whitespace trimmed", flag: " 2m ", want: 2 * time.Minute}, + {name: "whitespace flag falls back to env", flag: " ", env: "5m", want: 5 * time.Minute}, + {name: "whitespace env returns provider default", env: "\t", want: 0}, + {name: "invalid flag is reported as flag", flag: "abc", env: "5m", wantErr: true, wantErrContain: "--timeout"}, + {name: "invalid env is reported as env", env: "abc", wantErr: true, wantErrContain: "HWP2MD_TIMEOUT"}, + {name: "invalid env returns error", env: "5", wantErr: true}, + {name: "zero rejected", flag: "0s", wantErr: true, wantErrContain: "양수"}, + {name: "negative rejected", flag: "-1s", wantErr: true, wantErrContain: "양수"}, } for _, tc := range tests { @@ -267,6 +272,10 @@ func TestParseLLMTimeout(t *testing.T) { if err == nil { t.Fatalf("parseLLMTimeout(%q, %q) expected error, got nil", tc.flag, tc.env) } + if tc.wantErrContain != "" && !strings.Contains(err.Error(), tc.wantErrContain) { + t.Errorf("parseLLMTimeout(%q, %q) error %q does not contain %q", + tc.flag, tc.env, err.Error(), tc.wantErrContain) + } return } if err != nil { diff --git a/internal/cli/config.go b/internal/cli/config.go index c2dff0e..baa1384 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -125,7 +125,7 @@ func runConfigShow(cmd *cobra.Command, args []string) error { {"HWP2MD_LLM", "LLM 활성화", os.Getenv("HWP2MD_LLM")}, {"HWP2MD_MODEL", "모델 (프로바이더 자동 감지)", os.Getenv("HWP2MD_MODEL")}, {"HWP2MD_BASE_URL", "프라이빗 API 엔드포인트", os.Getenv("HWP2MD_BASE_URL")}, - {"HWP2MD_TIMEOUT", "LLM 요청 타임아웃 (예: 5m)", os.Getenv("HWP2MD_TIMEOUT")}, + {"HWP2MD_TIMEOUT", "LLM 요청 타임아웃 (예: 5m, 300s; 미설정 시 프로바이더 기본값)", os.Getenv("HWP2MD_TIMEOUT")}, {"ANTHROPIC_API_KEY", "Anthropic API 키", maskAPIKey(os.Getenv("ANTHROPIC_API_KEY"))}, {"OPENAI_API_KEY", "OpenAI API 키", maskAPIKey(os.Getenv("OPENAI_API_KEY"))}, {"GOOGLE_API_KEY", "Google API 키", maskAPIKey(os.Getenv("GOOGLE_API_KEY"))}, diff --git a/internal/cli/convert.go b/internal/cli/convert.go index 9576d7d..a7b5b94 100644 --- a/internal/cli/convert.go +++ b/internal/cli/convert.go @@ -151,7 +151,7 @@ func runConvert(cmd *cobra.Command, args []string) error { } // Stage 2: LLM formatting var result *llm.FormatResult - markdown, result, err = formatWithLLM(cmd, doc) + markdown, result, err = formatWithLLM(doc) if err != nil { return fmt.Errorf("LLM 포맷팅 실패: %w", err) } @@ -248,7 +248,7 @@ func detectProviderFromModel(model string) string { } } -func formatWithLLM(cmd *cobra.Command, doc *ir.Document) (string, *llm.FormatResult, error) { +func formatWithLLM(doc *ir.Document) (string, *llm.FormatResult, error) { // Determine model (from flag or env) model := convertModel if model == "" { @@ -346,7 +346,7 @@ func parseLLMTimeout(flagVal, envVal string) (time.Duration, error) { d, err := time.ParseDuration(raw) if err != nil { - return 0, fmt.Errorf("%s 값 파싱 실패 (%q): Go 기간 형식이어야 합니다 (예: 5m, 300s): %w", source, raw, err) + return 0, fmt.Errorf("%s 값 파싱 실패 (%q): `5m`, `300s`, `10m30s` 같은 시간 형식이어야 합니다: %w", source, raw, err) } if d <= 0 { return 0, fmt.Errorf("%s 값은 양수여야 합니다 (입력: %q)", source, raw)