Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cli

import (
"os"
"strings"
"testing"
"time"
)

func TestSetVersion(t *testing.T) {
Expand Down Expand Up @@ -238,3 +240,50 @@ func TestDetectProviderFromModel(t *testing.T) {
})
}
}

func TestParseLLMTimeout(t *testing.T) {
tests := []struct {
name string
flag string
env string
want time.Duration
wantErr bool
wantErrContain string // optional substring expected in the error message
}{
{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 {
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)
}
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 {
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)
}
})
}
}
1 change: 1 addition & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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"))},
Expand Down
49 changes: 45 additions & 4 deletions internal/cli/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/roboco-io/hwp2md/internal/config"
"github.com/roboco-io/hwp2md/internal/ir"
Expand All @@ -31,6 +32,7 @@ var (
convertParser string
convertExtractImgs bool
convertImagesDir string
convertTimeout string
convertVerbose bool
convertQuiet bool
)
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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, "조용한 모드")

Expand Down Expand Up @@ -146,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)
}
Expand Down Expand Up @@ -243,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 == "" {
Expand All @@ -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 == "" {
Expand All @@ -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)
Expand All @@ -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): `5m`, `300s`, `10m30s` 같은 시간 형식이어야 합니다: %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 != "" {
Expand Down
Loading