diff --git a/cmd/issuetracker/main.go b/cmd/issuetracker/main.go index fa34e4b3..e323c127 100644 --- a/cmd/issuetracker/main.go +++ b/cmd/issuetracker/main.go @@ -41,7 +41,7 @@ import ( const ( validateWorkerCount = 8 // parserWorkerCount: TopicFetched consumer group (issuetracker-parsers) 의 worker 수. - // fetcher worker 와 독립 — chromedp/LLM 등으로 parser 가 무거워질 때 별도 스케일 (이슈 #134). + // fetcher worker 와 독립 — chromedp/LLM 등으로 parser 가 무거워질 때 별도 스케일. parserWorkerCount = 6 ) @@ -73,7 +73,7 @@ func main() { ctx = log.ToContext(ctx) - // ── Metrics endpoint (이슈 #165) ────────────────────────────────────────── + // ── Metrics endpoint ────────────────────────────────────────── // METRICS_ADDR 빈 값이면 endpoint 비활성화. default ":9090". metricsCfg, err := config.LoadMetrics() if err != nil { @@ -120,14 +120,14 @@ func main() { jobPublisher := publisher.New(crawlerProducer, resolver, log) - // rule.Parser: parsing_rules 테이블 기반 단일 파서 엔진 (이슈 #100 / #139). + // rule.Parser: parsing_rules 테이블 기반 단일 파서 엔진. // 사이트별 NaverParser/CNNParser/... 를 대체 — 모든 사이트가 본 단일 인스턴스를 공유. parsingRuleRepo := pgstore.NewParsingRuleRepository(pool, log) ruleResolver, err := rule.NewResolver(parsingRuleRepo) if err != nil { log.WithError(err).Fatal("failed to construct rule resolver") } - // 이슈 #288: parsing_rules mutation → cache invalidate 자동 결합 (decorator 패턴). + // parsing_rules mutation → cache invalidate 자동 결합 (decorator 패턴). // 호출처가 명시적 Invalidate 를 까먹어도 stale cache 발생 X — single source of truth. parsingRuleRepo = rule.WrapWithInvalidator(parsingRuleRepo, ruleResolver) ruleParser, err := rule.NewParser(ruleResolver) @@ -135,7 +135,7 @@ func main() { log.WithError(err).Fatal("failed to construct rule parser") } - // 이슈 #295: page-parse 블랙리스트 — 카테고리 → article job 발행 단계에서 매칭 URL 차단. + // page-parse 블랙리스트 — 카테고리 → article job 발행 단계에서 매칭 URL 차단. // Enabled=false 시 Matcher 미주입 → parser_worker 가 모든 링크 그대로 발행 (기능 OFF). blacklistCfg, err := config.LoadBlacklist() if err != nil { @@ -148,8 +148,8 @@ func main() { if bmErr != nil { log.WithError(bmErr).Fatal("failed to construct blacklist matcher") } - // invalidatingBlacklistRepo decorator 는 본 PR scope 에서 wiring 하지 않음 (PR #296 gemini). - // 본 PR 은 read-only 경로 (Matcher.Filter) 만 사용 — application 측 mutation 경로 부재. + // invalidatingBlacklistRepo decorator 는 현재 wiring 하지 않음. + // read-only 경로 (Matcher.Filter) 만 사용 — application 측 mutation 경로 부재. // 운영 CLI / 자동 색출 후속 이슈에서 decorator 도입 + 변수 재할당으로 invalidate 결합. // (decorator 코드 자체는 blacklist_matcher.go 에 보존, unit test 로 검증.) blacklistMatcher = bm @@ -165,14 +165,14 @@ func main() { log.WithError(err).Fatal("parsing_rules seed missing — apply migration 007 before deploy") } - // raw_contents 서비스 — fetcher 측 Claim Check 저장 + parser 측 로드/삭제 (이슈 #134). + // raw_contents 서비스 — fetcher 측 Claim Check 저장 + parser 측 로드/삭제. rawRepo := pgstore.NewRawContentRepository(pool, log) rawSvc := service.NewRawContentService(rawRepo, log) contentRepo := pgstore.NewContentRepository(pool, log) contentSvc := service.NewContentService(contentRepo, log) - // host 단위 fetcher 룰 (이슈 #175 단계 1) — fetcher_rules 테이블 + Resolver wiring. + // host 단위 fetcher 룰 — fetcher_rules 테이블 + Resolver wiring. // 룰 부재 host 는 default chain (현재 동작 100% 보존). fetcherRuleRepo, err := pgstore.NewFetcherRuleRepository(pool, log) if err != nil { @@ -183,13 +183,13 @@ func main() { log.WithError(err).Fatal("failed to construct fetcher rule resolver") } - // 이슈 #221: process-local secret token — Upgrader 의 force_fetcher 부착과 ChainHandler 의 + // process-local secret token — Upgrader 의 force_fetcher 부착과 ChainHandler 의 // 검증이 같은 token 공유. 외부 source 의 임의 force 차단. if err := fetcherRule.InitForceFetcherToken(); err != nil { log.WithError(err).Fatal("failed to init force_fetcher token") } - // 이슈 #230: chromedp pool config 를 사이트 등록 전에 로드 — 사이트별 chromedp chain 이 + // chromedp pool config 를 사이트 등록 전에 로드 — 사이트별 chromedp chain 이 // worker_id 별 RemoteURL 로 N 개 build 되어야 ChainHandler 가 worker:Chrome 1:1 매핑 활성화. chromedpPoolCfg, err := config.LoadFetcherChromedpPool() if err != nil { @@ -202,7 +202,7 @@ func main() { } // chromedp pool 이 활성화된 경우 remoteURLs 수 == WorkerCount 를 사전 검증합니다 (CodeRabbit Major 반영). - // RegisterAll 이 URL 당 1 개 chain 을 생성하므로, 불일치 시 worker:chain 매핑이 어긋납니다 (이슈 #230). + // RegisterAll 이 URL 당 1 개 chain 을 생성하므로, 불일치 시 worker:chain 매핑이 어긋납니다. if chromedpPoolCfg.Enabled && len(chromedpRemoteURLs) != chromedpPoolCfg.WorkerCount { log.WithFields(map[string]interface{}{ "worker_count": chromedpPoolCfg.WorkerCount, @@ -210,13 +210,13 @@ func main() { }).Fatal("chromedp pool config mismatch: remote_urls count must equal worker_count") } - // fetcher 측 등록 (이슈 #246): fetcher_rules DB 에서 모든 source 를 읽어 일괄 등록. + // fetcher 측 등록: fetcher_rules DB 에서 모든 source 를 읽어 일괄 등록. if err := sources.RegisterAll(ctx, registry, fetcherRuleRepo, core.DefaultConfig(), rawSvc, crawlerProducer, fetcherResolver, chromedpRemoteURLs, log); err != nil { log.WithError(err).Fatal("failed to register crawlers from db") } // Redis 기반 ProcessingLock: 동일 URL 이 여러 worker/인스턴스에서 단계별 (fetcher/parser/validator) - // 중복 처리되는 것을 방지합니다 (이슈 #178). 단일 인스턴스를 fetcher / parser / validator 가 공유 — + // 중복 처리되는 것을 방지합니다. 단일 인스턴스를 fetcher / parser / validator 가 공유 — // 단계 구분은 ProcessingKey(stage, url) 의 stage prefix 로 처리. // worker/manager 가 nil 을 NoopProcessingLock 로 fallback 처리하는 설계와 일관되게, // Redis 초기화 실패 시에도 크롤링이 중단되지 않도록 graceful degrade 합니다. @@ -224,7 +224,7 @@ func main() { var ingestionLock locks.IngestionLock var retryScheduler crawlerWorker.RetryScheduler var retrySchedulerStop func() - var redisClientShared *redis.Client // 이슈 #220: failure counter wiring 에서 재사용 + var redisClientShared *redis.Client // failure counter wiring 에서 재사용 redisCfg, err := config.LoadRedis() if err != nil { log.WithError(err).Warn("failed to load redis config, falling back to noop processing lock and ingestion lock") @@ -242,7 +242,7 @@ func main() { procLock = locks.NewRedisProcessingLock(redisClient, locks.DefaultProcessingLockTTL) ingestionLock = locks.NewRedisIngestionLock(redisClient, redisCfg.IngestionLockTTL) - // Delayed retry queue (이슈 #82): retry 를 Redis ZSET 에 보관하고 별도 + // Delayed retry queue: retry 를 Redis ZSET 에 보관하고 별도 // goroutine 이 ScheduledAt 도달 시 Kafka 에 발행 — worker 슬롯 점유 회피. // Redis 부재 시 worker 가 lazy 로 KafkaImmediateRetryScheduler 를 사용 (기존 동작). redisRetry := crawlerWorker.NewRedisDelayedRetryScheduler( @@ -264,7 +264,7 @@ func main() { defer retrySchedulerStop() } - // URL dedup — Ingestion Lock (이슈 #178) → Pipeline Guard (이슈 #285) 통합: + // URL dedup — Ingestion Lock → Pipeline Guard 통합: // Publisher / Scheduler / ParserWorker 가 동일 guard 를 공유하여 target type 별 정책 적용: // - Article: 24h TTL (기존 IngestionLock 정책 유지) // - Category: 단명 TTL (default 60s) — cycle 종료 시 명시적 release + TTL fallback @@ -287,8 +287,8 @@ func main() { RetryScheduler: retryScheduler, } - // 이슈 #218: chromedp 전용 worker pool — semaphore 로 Chrome 동시 호출 제한. - // chromedpPoolCfg 는 사이트 등록 단계에서 미리 로드됨 (이슈 #230 — RemoteURLs 가 사이트 + // chromedp 전용 worker pool — semaphore 로 Chrome 동시 호출 제한. + // chromedpPoolCfg 는 사이트 등록 단계에서 미리 로드됨 (RemoteURLs 가 사이트 // chromedp chain build 에 필요). if chromedpPoolCfg.Enabled { chromedpKafkaCfg := queue.DefaultConfig() @@ -296,7 +296,7 @@ func main() { chromedpConsumer := queue.NewConsumer(chromedpKafkaCfg, queue.TopicCrawlChromedp) defer chromedpConsumer.Close() - // 이슈 #229: per-worker Semaphore 모델 — worker_id 별 1 개씩, 길이 = WorkerCount. + // per-worker Semaphore 모델 — worker_id 별 1 개씩, 길이 = WorkerCount. // 본 Semaphore 는 worker 자원 격리 guard 역할 — KafkaConsumerPool 의 worker 는 메시지를 // 순차 처리하므로 capacity > 1 은 현 모델에서 추가 동시성 이득 없음 (gemini 피드백). // 실질 전체 동시 navigate 수 = WorkerCount. 다음 sub-issue (#230) 에서 worker_id 별 @@ -323,7 +323,7 @@ func main() { "effective_concurrency": chromedpPoolCfg.WorkerCount, }).Info("chromedp pool wiring enabled (per-worker semaphores; effective concurrency = worker_count)") } else { - // 이슈 #218 (CodeRabbit 피드백): goquery worker 의 ChainHandler 가 lazy detect / chromedp + // goquery worker 의 ChainHandler 가 lazy detect / chromedp // 룰 / force_fetcher 분기에서 항상 TopicCrawlChromedp 로 republish 함. consumer 가 없으면 // 메시지가 영구 누적되어 운영 장애로 이어짐 — fail-fast 로 운영자가 명시적 의사결정 강제. log.Fatal("chromedp pool disabled (FETCHER_CHROMEDP_POOL_ENABLED=false) but goquery republish path is unconditional — enable pool or fork republish behavior in chain_handler") @@ -337,21 +337,21 @@ func main() { "low_workers": managerCfg.Low.WorkerCount, }).Info("fetcher pool manager constructed") - // ── LLM rule generator (이슈 #149) ────────────────────────────────────────── + // ── LLM rule generator ────────────────────────────────────────── // rule.ErrNoRule (host 매칭 활성 규칙 없음) fallback 으로 LLM 이 selector 를 자동 생성합니다. // LLM_ENABLED=false 또는 API key 누락 시 nil — parser worker 는 ErrNoRule 시 raw 만 잔존. // - // **본 PR scope**: FixedOrder("gemini") 정책으로 Gemini 단일 provider 사용 (1000회/일 무료 한도 내 검증). + // **현재 정책**: FixedOrder("gemini") 정책으로 Gemini 단일 provider 사용 (1000회/일 무료 한도 내 검증). // 후속 PR (이슈 TBD) 에서 chain (gemini → openai → anthropic) 으로 정책 확장. // - // 이슈 #173 단계 4-2: 동일 provider 를 refiner 와 공유 — 환경변수 1세트로 동시 제어. + // 동일 provider 를 refiner 와 공유 — 환경변수 1세트로 동시 제어. llmProvider := llmwiring.BuildProvider(log) llmGen, err := llmgenwiring.Build(llmProvider, parsingRuleRepo, ruleResolver, redisClientShared, log) if err != nil { log.WithError(err).Fatal("failed to build llmgen generator") } - // ── Fetcher 실패 카운터 (이슈 #220) ──────────────────────────────────────── + // ── Fetcher 실패 카운터 ──────────────────────────────────────── // host 단위 fetcher 실패를 sliding window 로 누적 — 단계 3 (#221) 의 chromedp 자동 전환 // 트리거 입력. ENABLED=false 또는 Redis 미연결 시 Noop (성능 저하 0). fetcherUpgradeCfg, err := config.LoadFetcherAutoUpgrade() @@ -384,7 +384,7 @@ func main() { log.Warn("redis unavailable, fetcher failure counter falls back to noop") } - // 이슈 #221: host 단위 실패 raw_id 추적기 — 단계 3 의 chromedp 자동 전환 trigger 가 republish 대상 수집에 사용. + // host 단위 실패 raw_id 추적기 — 단계 3 의 chromedp 자동 전환 trigger 가 republish 대상 수집에 사용. // 카운터와 같은 lifecycle (window TTL 동기화) — Redis 미연결 시 Noop. var rawIDTracker fetcherRule.RawIDTracker = fetcherRule.NewNoopRawIDTracker() if fetcherUpgradeCfg.Enabled && redisClientShared != nil { @@ -402,7 +402,7 @@ func main() { } } - // 이슈 #221: 임계값 도달 시 chromedp 자동 전환 + 실패 raw republish trigger. + // 임계값 도달 시 chromedp 자동 전환 + 실패 raw republish trigger. // ENABLED=false 또는 의존성 부재 시 nil — parser_worker 가 thresholdReached 신호만 받고 실제 전환 발생 안 함. var fetcherUpgrader *fetcherRule.Upgrader if fetcherUpgradeCfg.Enabled { @@ -427,14 +427,14 @@ func main() { } } - // ── Parser worker (이슈 #134) ────────────────────────────────────────────── + // ── Parser worker ────────────────────────────────────────────── // fetcher 와 분리된 별도 consumer group (issuetracker-parsers) 으로 동작 — 인스턴스 수 독립 스케일. // TopicFetched 의 RawContentRef 를 consume 하여 raw 로드 + 파싱 + content 저장 + raw 삭제. - // 파싱 실패 (rule.Error) 시 raw 잔존 → LLM 재처리 윈도우 (이슈 #149). + // 파싱 실패 (rule.Error) 시 raw 잔존 → LLM 재처리 윈도우. parserKafkaCfg := queue.DefaultConfig() parserKafkaCfg.GroupID = queue.GroupParsers parserConsumer := queue.NewConsumer(parserKafkaCfg, queue.TopicFetched) - // 이슈 #173 단계 4-1: sample URL 누적 — parser_worker 가 정상 파싱 후 누적, 단계 4-2 의 정밀화 트리거 입력. + // sample URL 누적 — parser_worker 가 정상 파싱 후 누적, 단계 4-2 의 정밀화 트리거 입력. sampleRepo := pgstore.NewSampleURLRepository(pool, log) pw := parserWorker.NewParserWorker( @@ -444,33 +444,33 @@ func main() { contentSvc, jobPublisher, ruleParser, - ruleResolver, // 이슈 #173 단계 4-1: sample 누적 시 매칭 rule lookup - sampleRepo, // 이슈 #173 단계 4-1 - procLock, // 이슈 #178: fetcher / parser / validator 가 동일 ProcessingLock 인스턴스 공유 + ruleResolver, // sample 누적 시 매칭 rule lookup + sampleRepo, + procLock, // fetcher / parser / validator 가 동일 ProcessingLock 인스턴스 공유 llmGen, - failureCounter, // 이슈 #220: host 단위 fetcher 실패 카운터 - rawIDTracker, // 이슈 #221: host 별 실패 raw_id 추적기 - fetcherUpgrader, // 이슈 #221: 임계값 도달 시 chromedp 자동 전환 + republish + failureCounter, // host 단위 fetcher 실패 카운터 + rawIDTracker, // host 별 실패 raw_id 추적기 + fetcherUpgrader, // 임계값 도달 시 chromedp 자동 전환 + republish fetcherUpgradeCfg.EmptyBodyTitleMin, fetcherUpgradeCfg.EmptyBodyContentMin, parserWorkerCount, log, ) - // ── Pipeline Guard release (이슈 #285) ───────────────────────────────────── + // ── Pipeline Guard release ───────────────────────────────────── // Category cycle 종료 시 marker release — scheduler 다음 주기에 즉시 진입 가능. if pipelineGuard != nil { pw.SetPipelineGuard(pipelineGuard) } - // ── Page-parse 블랙리스트 (이슈 #295) ─────────────────────────────────────── + // ── Page-parse 블랙리스트 ─────────────────────────────────────── // 카테고리에서 추출된 article URL 중 blacklist 매칭은 publisher.Publish 직전 drop. // Matcher 가 nil (BLACKLIST_ENABLED=false) 이면 setter noop — 모든 링크 그대로 발행. if blacklistMatcher != nil { pw.SetBlacklist(blacklistMatcher) } - // ── Stale rule 재학습 카운터 (이슈 #282) ─────────────────────────────────── + // ── Stale rule 재학습 카운터 ─────────────────────────────────── // host 단위 stale parse failure 누적 — 임계 도달 시 Generator.EnqueueStale 트리거. // FetcherAutoUpgrade 와 별개 keyspace + 더 긴 윈도우 / 더 높은 임계값 — chromedp 전환이 // 먼저 시도되고, 그래도 실패 지속 시 LLM 재학습 (InsertNextVersion 으로 v+1 추가). @@ -503,14 +503,14 @@ func main() { log.Info("llmgen disabled — stale rule relearn skipped (no enqueue target)") } - // ── LLM validate 실패 재큐 (이슈 #237) ─────────────────────────────────── + // ── LLM validate 실패 재큐 ─────────────────────────────────── // selector 검증 실패 시 raw 를 issuetracker.fetched 에 재발행 — 룰 생성 성공 후 재파싱 기회 부여. // llmGen 이 nil(LLM 비활성) 이면 wiring 불필요. if llmGen != nil { llmGen.SetValidateFailureHandler(pw.RequeueForLLMRetry) } - // ── Pending URL 큐 (이슈 #262) ──────────────────────────────────────────── + // ── Pending URL 큐 ──────────────────────────────────────────── // in-flight 중 동일 도메인으로 유입된 URL 을 Redis LIST 에 보존. // 룰 생성 완료 시 대기 URL 을 issuetracker.fetched 에 재발행 — 새 룰로 재파싱. // Redis 미설정 시 graceful degrade (pending URL 보존 없이 기존 skip 동작 유지). @@ -522,7 +522,7 @@ func main() { log.Info("llmgen: Redis 기반 pending URL 큐 활성화") } - // ── 의미 검증 ValidatorPool (이슈 #257) ────────────────────────────────── + // ── 의미 검증 ValidatorPool ────────────────────────────────── // DOM 매칭 검증 통과 후 추출 내용이 실제 뉴스 제목/본문인지 LLM 으로 의미 검증. // llmProvider 가 nil(LLM 비활성) 이면 의미 검증 건너뜀 — DOM 검증만 수행. if llmGen != nil && llmProvider != nil { @@ -533,7 +533,7 @@ func main() { log.Info("llmgen: 의미 검증 ValidatorPool 활성화") } - // ── Claude Code 추출기 (이슈 #267) ────────────────────────────────────── + // ── Claude Code 추출기 ────────────────────────────────────── // LLM_EXTRACTOR=claude-code 일 때 활성화 — Claude 구독 환경의 sonnet 으로 셀렉터 추출. // 미지정 / gemini (기본) 일 때는 buildLLMGenerator 가 설정한 기본 LLM provider 추출. // Start 실패 시 Gemini 경로로 graceful fallback (fatal 아님). @@ -544,7 +544,7 @@ func main() { case llmExtractor != "claude-code": // 기본 경로 — 분기 미발생 (Gemini 등 buildLLMGenerator 의 provider 사용). case llmGen == nil: - // LLM_ENABLED=false / API key 부재 등으로 llmGen 비활성 — silent skip 회피 (PR #271 리뷰). + // LLM_ENABLED=false / API key 부재 등으로 llmGen 비활성 — silent skip 회피. log.Warn("LLM_EXTRACTOR=claude-code requested but LLM generator is disabled (check LLM_ENABLED / API key); claudegen extractor not registered") default: worker, werr := claudegen.NewFromEnv(log) @@ -559,10 +559,10 @@ func main() { } } - // ── Refiner (이슈 #173 단계 4-2) ────────────────────────────────────────── + // ── Refiner ────────────────────────────────────────── // catch-all + llm-auto rule 의 누적 sample URL 로부터 path_pattern 정밀화. // REFINEMENT_ENABLED=false 또는 config 실패 시 nil — 기존 catch-all rule 그대로 동작. - // metricsRegistry 는 nil 허용 — Record* 호출이 noop (PR #191 피드백). + // metricsRegistry 는 nil 허용 — Record* 호출이 noop. pathRefiner, err := refinerwiring.Build(llmProvider, parsingRuleRepo, sampleRepo, ruleResolver, metricsRegistry, log) if err != nil { log.WithError(err).Fatal("failed to build refiner") @@ -581,14 +581,14 @@ func main() { emitter := scheduler.NewJobEmitter(crawlerProducer, log) if pipelineGuard != nil { emitter.SetGuard(pipelineGuard) - // publisher 와 동일 normalizer 공유 — marker 키 일관성 (PR #286 gemini 리뷰). + // publisher 와 동일 normalizer 공유 — marker 키 일관성. emitter.SetNormalizer(links.NewNormalizer()) - log.Info("scheduler emitter pipeline guard enabled (이슈 #285)") + log.Info("scheduler emitter pipeline guard enabled") } entries := scheduler.DefaultEntries(schedulerCfg) sched := scheduler.New(entries, emitter, log, schedulerCfg.MaxRetries) - // Backlog throttle (이슈 #124): SCHEDULER_MAX_BACKLOG > 0 일 때만 활성. + // Backlog throttle: SCHEDULER_MAX_BACKLOG > 0 일 때만 활성. // crawl 토픽의 consumer-group lag 가 임계값 초과 시 publish 차단. if schedulerCfg.MaxBacklog > 0 { backlogChecker := queue.NewBacklogChecker(crawlerKafkaCfg.Brokers, schedulerCfg.BacklogCheckTimeout) @@ -638,7 +638,7 @@ func main() { }).Info("validate worker constructed") // ══════════════════════════════════════════════════════════════════════════ - // Stage 통합 — processor.Stage 인터페이스로 모든 단계 균일 관리 (이슈 #206 / #208) + // Stage 통합 — processor.Stage 인터페이스로 모든 단계 균일 관리 // ══════════════════════════════════════════════════════════════════════════ fetcherStage, err := fetcher.NewStage(manager) @@ -654,7 +654,7 @@ func main() { log.WithError(err).Fatal("failed to construct validate stage") } - // 이슈 #224: chromedp 자동 upgrade 의 자가 회복 안전장치 — interval 마다 reason='auto_upgrade_validation' + // chromedp 자동 upgrade 의 자가 회복 안전장치 — interval 마다 reason='auto_upgrade_validation' // row 를 goquery 로 reset. ENABLED=false 시 stage 미등록 (단계 3 의 upgrade-only 동작 유지). stages := []processor.Stage{fetcherStage, parserStg, validateStage} downgradeCfg, err := config.LoadFetcherAutoDowngrade() @@ -684,7 +684,7 @@ func main() { signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan - // 셧다운 시작 시점부터 logger 에 shutting_down=true 를 부여합니다 (이슈 #72 TODO #4). + // 셧다운 시작 시점부터 logger 에 shutting_down=true 를 부여합니다. // // 적용 범위 (중요): // - 본 변수 'log' 와 shutdownCtx 를 통해 전달되는 모든 로그에만 부착됩니다. @@ -701,7 +701,7 @@ func main() { // shutdownCtx 에 logger 를 주입하여 Stop 내부에서 logger.FromContext 가 // shutting_down 필드를 자동으로 상속받도록 합니다. // context.WithoutCancel(ctx) 사용 — parent ctx 의 cancellation (방금 호출한 cancel()) - // 은 분리하되 ctx values (logger / 향후 trace ID 등) 는 상속 보존 (PR #273 리뷰). + // 은 분리하되 ctx values (logger / 향후 trace ID 등) 는 상속 보존. shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownCfg.Timeout) defer shutdownCancel() shutdownCtx = log.ToContext(shutdownCtx) @@ -720,12 +720,12 @@ func main() { } } - // Claude Code 컨테이너 종료 (이슈 #267) — stages.Stop 으로 llmGen 의 모든 in-flight Extract 가 + // Claude Code 컨테이너 종료 — stages.Stop 으로 llmGen 의 모든 in-flight Extract 가 // 완료된 이후 호출. Worker.Stop 이 실행 중인 docker exec 세션 완료 대기 + 컨테이너 정리. // // shutdownCtx 가 stages.Stop 에서 timeout 으로 cancel 되더라도 docker rm -f 자체는 반드시 시도되어야 - // 컨테이너 누수가 발생하지 않으므로, 별도의 cleanupCtx 를 사용 (PR #271 리뷰). - // WithoutCancel(ctx) 로 ctx values 보존 (PR #273 리뷰). + // 컨테이너 누수가 발생하지 않으므로, 별도의 cleanupCtx 를 사용. + // WithoutCancel(ctx) 로 ctx values 보존. if claudegenWorker != nil { cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownCfg.ClaudegenTimeout) cleanupCtx = log.ToContext(cleanupCtx) diff --git a/cmd/processor/main.go b/cmd/processor/main.go index 73751ad1..6b0d0dc6 100644 --- a/cmd/processor/main.go +++ b/cmd/processor/main.go @@ -45,7 +45,7 @@ func main() { ctx = log.ToContext(ctx) - // ── Metrics endpoint (이슈 #165) ────────────────────────────────────────── + // ── Metrics endpoint ────────────────────────────────────────── metricsCfg, err := config.LoadMetrics() if err != nil { log.WithError(err).Fatal("failed to load metrics config") @@ -95,7 +95,7 @@ func main() { defer producer.Close() // ── 5. Validate Worker 시작 ─────────────────────────────────────────────── - // validator 결과 (passed/rejected) 는 contentSvc.UpdateValidationStatus 로 contents 에 기록 (이슈 #135 / #161). + // validator 결과 (passed/rejected) 는 contentSvc.UpdateValidationStatus 로 contents 에 기록. // processor 단독 실행은 dev/test 시나리오 — Redis wiring 없이 NoopProcessingLock 사용. // 다중 인스턴스 운영은 cmd/issuetracker 통합 바이너리에서 Redis 기반 ProcessingLock 공유. worker := validate.NewWorker(consumer, producer, contentSvc, locks.NoopProcessingLock{}, validateWorkerCount, validateCfg) @@ -112,7 +112,7 @@ func main() { signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) <-sigChan - // 셧다운 시작 시점부터 logger 에 shutting_down=true 를 부여합니다 (이슈 #72 TODO #4). + // 셧다운 시작 시점부터 logger 에 shutting_down=true 를 부여합니다. // // 적용 범위 (중요): // - 본 변수 'log' 와 shutdownCtx 를 통해 전달되는 로그에만 부착됩니다 (Stop 경로). @@ -124,7 +124,7 @@ func main() { log.Warn("shutdown signal received, draining workers...") cancel() - // WithoutCancel(ctx) 로 parent cancellation 은 분리하되 ctx values (logger 등) 보존 (PR #273 리뷰). + // WithoutCancel(ctx) 로 parent cancellation 은 분리하되 ctx values (logger 등) 보존. shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownCfg.Timeout) defer shutdownCancel() shutdownCtx = log.ToContext(shutdownCtx) diff --git a/cmd/rule-validator/main.go b/cmd/rule-validator/main.go index 0994af9c..ec3af863 100644 --- a/cmd/rule-validator/main.go +++ b/cmd/rule-validator/main.go @@ -1,4 +1,4 @@ -// rule-validator 는 parsing_rules 의 특정 row 를 의미 검증하는 수동 운영 CLI 입니다 (이슈 #257). +// rule-validator 는 parsing_rules 의 특정 row 를 의미 검증하는 수동 운영 CLI 입니다. // // 사용법: // @@ -82,7 +82,7 @@ func main() { fmt.Printf("검증 결과: valid=%v\nreason: %s\n", res.Valid, res.Reason) - // CLI 는 수동 운영 도구 — API 오류(verr)도 실패로 처리하여 운영자가 인지 가능하도록 (이슈 #265 리뷰). + // CLI 는 수동 운영 도구 — API 오류(verr)도 실패로 처리하여 운영자가 인지 가능하도록. // best-effort 통과는 자동 파이프라인(validator.Pool) 에서만 적용. if verr != nil { fmt.Printf("검증 API 오류: %v\n", verr) diff --git a/internal/locks/ingestion_lock.go b/internal/locks/ingestion_lock.go index c5eeb1be..0870343c 100644 --- a/internal/locks/ingestion_lock.go +++ b/internal/locks/ingestion_lock.go @@ -20,7 +20,7 @@ const ( DefaultIngestionLockTTL = 24 * time.Hour ) -// IngestionLock 은 URL 이 파이프라인에 진입한 상태를 marker 로 표시합니다 (이슈 #178). +// IngestionLock 은 URL 이 파이프라인에 진입한 상태를 marker 로 표시합니다. // // 의도: // @@ -39,7 +39,7 @@ type IngestionLock interface { // 두 URL 이 다른 marker 를 갖지 않도록. Acquire(ctx context.Context, url string) (acquired bool, err error) - // AcquireWithTTL 은 Acquire 와 동일하되 호출별 TTL 을 지정합니다 (이슈 #285). + // AcquireWithTTL 은 Acquire 와 동일하되 호출별 TTL 을 지정합니다. // // 용도: PipelineGuard 가 target type 에 따라 Article (24h) / Category (단명) 로 다른 // TTL 적용. ttl<=0 이면 default TTL fallback. @@ -88,7 +88,7 @@ func (l *RedisIngestionLock) Acquire(ctx context.Context, url string) (bool, err return l.locker.AcquireLock(ctx, ingestionKey(url), l.ttl) } -// AcquireWithTTL 은 호출별 TTL 로 marker 를 set 시도합니다 (이슈 #285). +// AcquireWithTTL 은 호출별 TTL 로 marker 를 set 시도합니다. // // ttl<=0 이면 RedisIngestionLock 의 default TTL 사용. nil receiver / nil locker 보호는 Acquire 와 동일. func (l *RedisIngestionLock) AcquireWithTTL(ctx context.Context, url string, ttl time.Duration) (bool, error) { diff --git a/internal/locks/pipeline_guard.go b/internal/locks/pipeline_guard.go index 1b8ae54a..41270580 100644 --- a/internal/locks/pipeline_guard.go +++ b/internal/locks/pipeline_guard.go @@ -8,7 +8,7 @@ import ( "issuetracker/internal/processor/fetcher/core" ) -// DefaultCategoryTTL 은 Category target 의 pipeline guard TTL default 입니다 (이슈 #285). +// DefaultCategoryTTL 은 Category target 의 pipeline guard TTL default 입니다. // // Category 는 정기 갱신이 본질이므로 Article (24h) 보다 훨씬 짧은 TTL. // fetch + ParseLinks 한 사이클이 완료될 때까지만 marker 가 유지되도록 — 운영 환경의 @@ -17,7 +17,7 @@ import ( const DefaultCategoryTTL = 60 * time.Second // PipelineGuard 는 publish 진입점에서 \"이 URL 이 현재 파이프라인에 있는가\" 를 체크하는 통합 -// 게이트입니다 (이슈 #285). +// 게이트입니다. // // 의도: // @@ -55,7 +55,7 @@ func NewPipelineGuard(lock IngestionLock, categoryTTL time.Duration) *PipelineGu // - acquired=false : 이미 pipeline 안 (다른 publish 가 marker 점유) — 호출자가 skip // - err != nil : Redis 일시 장애 등 — 호출자 정책 (보통 fail-open) 으로 처리 // -// targetType 별 TTL (PR #286 gemini 리뷰 — 명시적 switch 로 안전성 강화): +// targetType 별 TTL: // - core.TargetTypeCategory : categoryTTL (단명, default 60s) // - core.TargetTypeArticle : IngestionLock 의 default TTL (24h) // - 그 외 (Sitemap 등 미래 target type) : IngestionLock default TTL fallback diff --git a/internal/locks/processing_lock.go b/internal/locks/processing_lock.go index c3e2254c..10dfc083 100644 --- a/internal/locks/processing_lock.go +++ b/internal/locks/processing_lock.go @@ -9,7 +9,7 @@ import ( ) const ( - // processingLockKeyPrefix 는 단계별 URL 단위 처리 lock 의 키 접두사입니다 (이슈 #178). + // processingLockKeyPrefix 는 단계별 URL 단위 처리 lock 의 키 접두사입니다. // 형식: "processing::url:" — Redis 운영자가 grep / SCAN 으로 // 다른 namespace (ingestion:url:..., lock:job:... 등) 와 구분 가능. processingLockKeyPrefix = "processing:" @@ -20,7 +20,7 @@ const ( DefaultProcessingLockTTL = 10 * time.Minute ) -// ProcessingLock 은 파이프라인 단계별 URL 중복 처리를 방지하는 분산 락 인터페이스입니다 (이슈 #178). +// ProcessingLock 은 파이프라인 단계별 URL 중복 처리를 방지하는 분산 락 인터페이스입니다. // // \"각 프로세스마다 작업 중인 URL 을 다른 동일 프로세스 단계의 워커가 건들지 못하도록 막는 dedup.\" // @@ -49,7 +49,7 @@ const ( StageValidator = "validator" ) -// ProcessingKey 는 (stage, normalized_url) 페어로 ProcessingLock 의 Redis 키를 생성합니다 (이슈 #178). +// ProcessingKey 는 (stage, normalized_url) 페어로 ProcessingLock 의 Redis 키를 생성합니다. // // 호출자 책임: url 은 pkg/links.Normalizer 로 정규화된 상태로 전달 — 동일 컨텐츠를 가리키는 // 두 URL 이 다른 키를 갖지 않도록. diff --git a/internal/processor/fetcher/core/http2_metrics.go b/internal/processor/fetcher/core/http2_metrics.go index 620c0508..ec817599 100644 --- a/internal/processor/fetcher/core/http2_metrics.go +++ b/internal/processor/fetcher/core/http2_metrics.go @@ -17,7 +17,7 @@ import ( // log.Printf("%s: %d", errType, count) // } // -// 본 카운터는 이슈 #71 의 'received DATA after END_STREAM' 같은 protocol error 의 +// 본 카운터는 'received DATA after END_STREAM' 같은 protocol error 의 // 재발 빈도 추적을 목표로 합니다. 빈도 추이가 충분히 누적되면 후속 단계 // (ForceHTTP1 옵션화, 풀 제거 hook) 도입 여부를 결정합니다. type HTTP2ErrorCounter struct { diff --git a/internal/processor/fetcher/core/http_client.go b/internal/processor/fetcher/core/http_client.go index 53d09a22..0ff6e491 100644 --- a/internal/processor/fetcher/core/http_client.go +++ b/internal/processor/fetcher/core/http_client.go @@ -21,7 +21,7 @@ const ( // defaultHTTPClient는 기본 HTTP 클라이언트를 생성합니다. // Connection pooling과 timeout을 적용합니다. // -// HTTP/2 stream/connection error 모니터링 (이슈 #71/#117): +// HTTP/2 stream/connection error 모니터링: // - http2.ConfigureTransports 로 base transport 에 http2 transport 를 명시 구성 // - http2.Transport.CountError hook 으로 에러 유형별 카운트 (DefaultHTTP2ErrorCounter 누적) // - 에러 발생 시 로그로 가시화 — 운영 모니터링/알림에서 빈도 추적 가능 diff --git a/internal/processor/fetcher/core/http_status.go b/internal/processor/fetcher/core/http_status.go index b7da7d2f..01993294 100644 --- a/internal/processor/fetcher/core/http_status.go +++ b/internal/processor/fetcher/core/http_status.go @@ -5,7 +5,7 @@ import "net/http" // CheckHTTPStatus 는 HTTP 응답 상태 코드를 검사하여 4xx/5xx 에러를 적절한 // CrawlerError 로 변환합니다. 정상(2xx/3xx) 인 경우 nil 을 반환합니다. // -// 분기 정책 (이슈 #75 — fetcher 공통 추출): +// 분기 정책: // - 404 (Not Found) → NewNotFoundError (retry 불가) // - 429 (Too Many Requests) → NewRateLimitError (retry 가능, code "HTTP_429") // - 5xx (Server Error) → NewHTTPServerError (retry 가능) diff --git a/internal/processor/fetcher/core/models.go b/internal/processor/fetcher/core/models.go index 0b650142..9bbec68e 100644 --- a/internal/processor/fetcher/core/models.go +++ b/internal/processor/fetcher/core/models.go @@ -54,7 +54,7 @@ type RawContent struct { } // NewRawContent 는 fetcher 들이 공통으로 사용하는 RawContent 조립 패턴을 -// 일원화한 생성자입니다 (이슈 #75 — fetcher 공통 추출). +// 일원화한 생성자입니다. // // 인자: // - name : crawler 이름 (ID prefix 로 사용) @@ -120,7 +120,7 @@ type RawContentRef struct { URL string `json:"url"` FetchedAt time.Time `json:"fetched_at"` SourceInfo SourceInfo `json:"source_info"` - LLMRetryCount int `json:"llm_retry_count,omitempty"` // 이슈 #237: LLM validate 실패 재큐 횟수 + LLMRetryCount int `json:"llm_retry_count,omitempty"` // LLM validate 실패 재큐 횟수 } // ContentRef는 contents 테이블에 저장된 Content의 경량 참조입니다. diff --git a/internal/processor/fetcher/core/worker_id.go b/internal/processor/fetcher/core/worker_id.go index da1e472e..7ed093d2 100644 --- a/internal/processor/fetcher/core/worker_id.go +++ b/internal/processor/fetcher/core/worker_id.go @@ -2,8 +2,8 @@ package core import "context" -// 이슈 #229 — KafkaConsumerPool 의 worker goroutine 별 인덱스를 ctx 로 전달하기 위한 helper. -// ChromedpJobHandler 가 per-worker Semaphore 슬롯을 lookup 하는 데 사용. 이슈 #230 에서 +// KafkaConsumerPool 의 worker goroutine 별 인덱스를 ctx 로 전달하기 위한 helper. +// ChromedpJobHandler 가 per-worker Semaphore 슬롯을 lookup 하는 데 사용. // general.ChainHandler 의 chromedpChains slice lookup 에도 동일 키 재사용 — 이를 위해 // worker 패키지 외부 (사이클 없는 core) 에 위치. // diff --git a/internal/processor/fetcher/domain/general/chain_handler.go b/internal/processor/fetcher/domain/general/chain_handler.go index f4a64b6e..3d51ad00 100644 --- a/internal/processor/fetcher/domain/general/chain_handler.go +++ b/internal/processor/fetcher/domain/general/chain_handler.go @@ -15,7 +15,7 @@ import ( "issuetracker/pkg/queue" ) -// ChainHandler 는 fetcher worker 의 책임을 담당하는 handler.Handler 어댑터입니다 (이슈 #134). +// ChainHandler 는 fetcher worker 의 책임을 담당하는 handler.Handler 어댑터입니다. // // 분리 후 책임 (Claim Check 패턴): // 1. Chain (GoQuery / Browser) 으로 raw HTML fetch @@ -26,13 +26,13 @@ import ( // consumer group) 의 책임으로 이전됨. 본 핸들러는 nil Content slice 를 반환하여 worker pool 의 // publishNormalized 단계를 스킵하도록 함. // -// host 단위 fetcher 정책 (이슈 #175 단계 1): +// host 단위 fetcher 정책: // - Resolver 가 nil 이거나 룰 미등록 host: DefaultChain 사용 (gq → browser fallback) // - 룰 = 'chromedp': ChromedpChains 사용 (browser only) — goquery 시도 skip // - 룰 = 'goquery': DefaultChain 사용 (gq → browser fallback) — 현재로선 default 와 동일. // 단계 3 의 자동 downgrade 정책이 도입되면 GoQueryOnly chain 으로 분기 가능. // -// 이슈 #230 — ChromedpChains 를 worker_id 별 slice 로 분리하여 worker:Chrome 1:1 매핑 활성화: +// ChromedpChains 를 worker_id 별 slice 로 분리하여 worker:Chrome 1:1 매핑 활성화: // - 각 chain 인스턴스는 자기 전용 RemoteURL 의 ChromedpCrawler 를 보유 // - HandleChromedpOnly 가 ctx 의 worker_id 로 자기 chain 선택 → worker:Chrome 격리 // - chain 길이는 chromedp pool 의 WorkerCount 와 일치 (main.go wiring 시 보장) @@ -41,11 +41,11 @@ import ( // - parser 가 무거워져도 (chromedp 가 큰 HTML 처리, LLM 호출 등) fetcher worker 슬롯 점유 X // - parser worker 인스턴스 수를 fetcher 와 독립으로 스케일 가능 // - raw HTML 이 DB 에 보존되어 worker crash 시에도 복구 가능 -// - 파싱 실패한 raw 가 잔존 → LLM 으로 새 rule 생성 (이슈 #149) 후 재처리 가능 +// - 파싱 실패한 raw 가 잔존 → LLM 으로 새 rule 생성 후 재처리 가능 type ChainHandler struct { Crawler SourceCrawler DefaultChain Handler // gq → browser (현재 동작 — 룰 미등록 host 의 기본) - ChromedpChains []Handler // worker_id 별 browser only chain (이슈 #230) + ChromedpChains []Handler // worker_id 별 browser only chain Resolver rule.Resolver // optional. nil 이면 항상 DefaultChain 사용 RawSvc service.RawContentService Producer queue.Producer @@ -57,7 +57,7 @@ type ChainHandler struct { // ChromedpChains 가 nil/empty 이면 룰 = 'chromedp' 매칭이어도 DefaultChain fallback (warn 로그). // Resolver 가 nil 이면 룰 조회 없이 항상 DefaultChain — 기존 동작 100% 보존. // -// 이슈 #230 — chromedpChains 길이는 chromedp pool 의 WorkerCount 와 일치해야 함 (호출자 책임). +// chromedpChains 길이는 chromedp pool 의 WorkerCount 와 일치해야 함 (호출자 책임). // 단일 Chrome 운영 호환 모드 (sub-issue #229 머지 직후 시점) 에서는 길이 1 의 slice 로 호출. func NewChainHandler( crawler SourceCrawler, @@ -120,7 +120,7 @@ func (h *ChainHandler) resolveChromedpChain(ctx context.Context) Handler { // - isChromedp=true: 호출자 (Handle) 가 republish 분기로 진입 (chain 자체는 사용 안 함) // - isChromedp=false: chain 직접 호출 // -// 이슈 #230 — ChromedpChains 가 worker_id 별 slice 가 되면서 selectChain 단계에서는 어느 슬롯을 +// ChromedpChains 가 worker_id 별 slice 가 되면서 selectChain 단계에서는 어느 슬롯을 // 쓸지 결정하지 않음 (republish 후 ChromedpJobHandler 가 slot 선택). Handle 의 분기 식별을 // chain 포인터 비교 대신 isChromedp 플래그로 안전하게 처리. // @@ -205,11 +205,11 @@ func extractHost(rawURL string) string { // // 항상 nil Content slice 를 반환 — 파싱은 parser worker 가 TopicFetched 를 consume 하여 처리. // -// 이슈 #218: chromedp 처리는 별도 worker pool 로 격리. selectChain 결과가 chromedp 매칭이거나 +// chromedp 처리는 별도 worker pool 로 격리. selectChain 결과가 chromedp 매칭이거나 // DefaultChain 의 GoQuery 가 lazy detect 시 sentinel 반환하면 직접 호출 대신 TopicCrawlChromedp // 로 republish — Chrome 자원 동시 호출량을 chromedp pool 의 semaphore 로 제어. // -// 이슈 #230 — selectChain 이 (chain, isChromedp) tuple 반환. ChromedpChains slice 모델로 +// selectChain 이 (chain, isChromedp) tuple 반환. ChromedpChains slice 모델로 // 변경되어 chain 포인터 비교가 부적합 → isChromedp 플래그로 분기. func (h *ChainHandler) Handle(ctx context.Context, job *core.CrawlJob) ([]*core.Content, error) { if h.DefaultChain == nil || h.Log == nil || h.RawSvc == nil || h.Producer == nil { @@ -218,7 +218,7 @@ func (h *ChainHandler) Handle(ctx context.Context, job *core.CrawlJob) ([]*core. chain, isChromedp := h.selectChain(ctx, job) - // 이슈 #218: chromedp 매칭 (force_fetcher 또는 Resolver 룰) → 직접 호출 안 하고 republish. + // chromedp 매칭 (force_fetcher 또는 Resolver 룰) → 직접 호출 안 하고 republish. if isChromedp { if err := h.republishToChromedpQueue(ctx, job); err != nil { return nil, fmt.Errorf("republish to chromedp queue for %s: %w", job.Target.URL, err) @@ -228,7 +228,7 @@ func (h *ChainHandler) Handle(ctx context.Context, job *core.CrawlJob) ([]*core. raw, err := chain.Handle(ctx, job) if err != nil { - // 이슈 #218: GoQuery 의 lazy detect sentinel — chromedp pool 로 republish 후 정상 종료. + // GoQuery 의 lazy detect sentinel — chromedp pool 로 republish 후 정상 종료. if errors.Is(err, ErrLazyContentNeedsBrowser) { if pubErr := h.republishToChromedpQueue(ctx, job); pubErr != nil { return nil, fmt.Errorf("republish to chromedp queue (lazy detect) for %s: %w", job.Target.URL, pubErr) @@ -279,7 +279,7 @@ func (h *ChainHandler) processFetchedRaw(ctx context.Context, job *core.CrawlJob return nil } -// HandleChromedpOnly 는 ChromedpChains 중 worker_id 슬롯을 호출하여 chromedp 단독 fetch 를 수행합니다 (이슈 #218, #230). +// HandleChromedpOnly 는 ChromedpChains 중 worker_id 슬롯을 호출하여 chromedp 단독 fetch 를 수행합니다. // // chromedp pool 의 ChromedpJobHandler 가 같은 ChainHandler 인스턴스를 Registry 에서 lookup 하여 // 본 메소드 호출 — Resolver / force_fetcher / republish 분기 skip 하고 ctx 의 worker_id 로 @@ -313,7 +313,7 @@ func (h *ChainHandler) HandleChromedpOnly(ctx context.Context, job *core.CrawlJo return nil, h.processFetchedRaw(ctx, job, raw, "chromedp") } -// republishToChromedpQueue 는 본 job 을 TopicCrawlChromedp 로 다시 발행합니다 (이슈 #218). +// republishToChromedpQueue 는 본 job 을 TopicCrawlChromedp 로 다시 발행합니다. // // chromedp 처리 책임을 본 worker (goquery pool) 에서 분리 — 별도 chromedp worker pool 이 receive // 후 semaphore 로 Chrome 자원 보호. force_fetcher metadata + token 은 보존하여 chromedp pool 의 diff --git a/internal/processor/fetcher/domain/general/handler.go b/internal/processor/fetcher/domain/general/handler.go index 9ae7fe49..4ad1bee1 100644 --- a/internal/processor/fetcher/domain/general/handler.go +++ b/internal/processor/fetcher/domain/general/handler.go @@ -19,7 +19,7 @@ type Handler interface { SetNext(h Handler) } -// ErrLazyContentNeedsBrowser 는 GoQueryFetchHandler 가 lazy-load 감지 시 반환하는 sentinel 입니다 (이슈 #218). +// ErrLazyContentNeedsBrowser 는 GoQueryFetchHandler 가 lazy-load 감지 시 반환하는 sentinel 입니다. // // 기존 동작 — chain next 위임으로 같은 worker 에서 chromedp 호출 — 은 단일 Chrome 자원 풀 // 고갈을 초래. 본 sentinel 을 받은 ChainHandler 는 next 위임 대신 TopicCrawlChromedp 로 @@ -75,7 +75,7 @@ func (h *GoQueryFetchHandler) Handle(ctx context.Context, job *core.CrawlJob) (* } if h.hasLazyContent(raw.HTML) { - // 이슈 #218: chain next 위임 대신 sentinel 반환. ChainHandler 가 받아 TopicCrawlChromedp + // chain next 위임 대신 sentinel 반환. ChainHandler 가 받아 TopicCrawlChromedp // 로 republish — chromedp 호출이 별도 worker pool 에서 격리되어 Chrome 자원 안정. h.log.WithFields(map[string]interface{}{ "handler": "goquery", diff --git a/internal/processor/fetcher/domain/general/sources/registry.go b/internal/processor/fetcher/domain/general/sources/registry.go index 5d96f6f2..e473f06a 100644 --- a/internal/processor/fetcher/domain/general/sources/registry.go +++ b/internal/processor/fetcher/domain/general/sources/registry.go @@ -1,4 +1,4 @@ -// Package sources 는 fetcher_rules DB 에서 모든 사이트 크롤러를 읽어 Registry 에 등록합니다 (이슈 #246). +// Package sources 는 fetcher_rules DB 에서 모든 사이트 크롤러를 읽어 Registry 에 등록합니다. // // 기존 사이트별 kr.Register / us.Register 를 RegisterAll 하나로 통합. // SourceInfo·RequestsPerHour 는 fetcher_rules 테이블 (migration 014) 에서 조회. @@ -25,11 +25,11 @@ import ( // defaultLazyKeywords 는 대부분 뉴스 사이트가 사용하는 lazy-load 감지 attr 목록입니다. // chromedp 가 활성화된 경우에만 BuildChain 에 전달합니다 — pool 이 꺼진 환경에서 // lazy sentinel 이 발생하면 TopicCrawlChromedp 로 republish 되지만 consumer 가 없어 -// 메시지가 유실됩니다 (이슈 #218, Copilot 피드백 반영). +// 메시지가 유실됩니다 . var defaultLazyKeywords = []string{"data-lazy-src", "lazyload", "data-lazy"} // RegisterAll 은 fetcher_rules 테이블에서 source_name 이 채워진 모든 source 를 읽어 -// Registry 에 등록합니다 (이슈 #246). +// Registry 에 등록합니다. // // 각 source_name 별로 goquery + (optional) chromedp ChainHandler 를 생성. // chromedpRemoteURLs 가 empty 이면 chromedp chain 없이 goquery-only 로 등록. diff --git a/internal/processor/fetcher/domain/general/types.go b/internal/processor/fetcher/domain/general/types.go index 7b2c8f30..779d7316 100644 --- a/internal/processor/fetcher/domain/general/types.go +++ b/internal/processor/fetcher/domain/general/types.go @@ -1,5 +1,5 @@ // Package general 은 임의 웹페이지 (뉴스/블로그/제품/일반 문서) 크롤링·파싱을 위한 -// 도메인 중립 추상화를 제공합니다 (이슈 #100 / #139 통합). +// 도메인 중립 추상화를 제공합니다. // // Package general provides domain-neutral abstractions for crawling arbitrary web pages // (news, blogs, product pages, generic documents). 기존 news 도메인을 흡수합니다 — diff --git a/internal/processor/fetcher/handler/handler.go b/internal/processor/fetcher/handler/handler.go index 82ab5114..15fa1915 100644 --- a/internal/processor/fetcher/handler/handler.go +++ b/internal/processor/fetcher/handler/handler.go @@ -19,7 +19,7 @@ import ( // // Handler processes a single CrawlJob, fetches and parses the content, // and returns normalized Content(s) ready for the processing pipeline. -// 시그니처상 다수 Content 반환을 허용하나, 현 fetcher/parser 분리 (이슈 #134) 이후 +// 시그니처상 다수 Content 반환을 허용하나, 현 fetcher/parser 분리 이후 // fetcher 측 ChainHandler 는 항상 nil 을 반환하고 실제 Content 발행은 parser worker 가 담당합니다. // Implementations must be safe for concurrent use by multiple goroutines. type Handler interface { @@ -61,7 +61,7 @@ func (r *Registry) Register(name string, h Handler) { // Lookup returns the registered Handler for crawler name. ok=false 면 미등록 — 호출자는 // fallback (예: 별도 에러 처리) 결정. Handle 과 달리 noop fallback 자동 적용 안 함. // -// 이슈 #218: ChromedpJobHandler 가 Registry 에서 *ChainHandler 를 직접 가져와 +// ChromedpJobHandler 가 Registry 에서 *ChainHandler 를 직접 가져와 // HandleChromedpOnly 호출하기 위해 사용. func (r *Registry) Lookup(name string) (Handler, bool) { h, ok := r.handlers[name] diff --git a/internal/processor/fetcher/implementation/chromedp/fetch.go b/internal/processor/fetcher/implementation/chromedp/fetch.go index d174331c..e7839054 100644 --- a/internal/processor/fetcher/implementation/chromedp/fetch.go +++ b/internal/processor/fetcher/implementation/chromedp/fetch.go @@ -113,7 +113,7 @@ func (c *ChromedpCrawler) Fetch(ctx context.Context, target core.Target) (*core. "timeout_ms": c.config.Timeout.Milliseconds(), }).Warn("page render timed out, attempting graceful capture") - // 이슈 #146: timeout = 정상 종료 시그널로 취급. 캡처 실패/검증 실패해도 + // timeout = 정상 종료 시그널로 취급. 캡처 실패/검증 실패해도 // 에러로 끌어올리지 않고 partial_load=true 의 (가능하면 빈 HTML) 응답으로 // downgrade. 호출자(parser)는 빈 HTML 을 받으면 자연스럽게 링크 0건 처리하므로 // 시스템이 안정적으로 흘러간다. Navigate 자체가 한 번도 응답을 못 받은 케이스 @@ -134,7 +134,7 @@ func (c *ChromedpCrawler) Fetch(ctx context.Context, target core.Target) (*core. // Navigate 자체 실패 가드 (CodeRabbit 피드백): main-document 응답이 한 번도 // 도착하지 않았고 (statusCode == 0) 캡처도 빈 결과면 페이지가 사실상 미응답. - // 이슈 #146 acceptance criteria "Navigate 자체 실패 (네트워크 오류) 는 기존대로 + // acceptance criteria: "Navigate 자체 실패 (네트워크 오류) 는 기존대로 // CDP_002 에러" 를 충족하기 위해 명시적으로 CDP_002 에러로 분류한다. statusMu.Lock() preCheckStatus := statusCode @@ -179,12 +179,12 @@ func (c *ChromedpCrawler) Fetch(ctx context.Context, target core.Target) (*core. capturedStatus = 200 } - // HTTP 상태코드 검사 (이슈 #75: core 공통 분기) + // HTTP 상태코드 검사 (core 공통 분기) if err := core.CheckHTTPStatus(target.URL, capturedStatus); err != nil { return nil, err } - // RawContent 조립 (이슈 #75: core 공통 생성자) + // RawContent 조립 (core 공통 생성자) // chromedp 는 raw HTTP header 에 접근하지 않으므로 nil 전달 → 빈 map 으로 보정됨. rawContent := core.NewRawContent(c.name, c.sourceInfo, target, html, capturedStatus, nil) diff --git a/internal/processor/fetcher/implementation/chromedp/graceful_timeout.go b/internal/processor/fetcher/implementation/chromedp/graceful_timeout.go index 4e0bf4d1..f362650c 100644 --- a/internal/processor/fetcher/implementation/chromedp/graceful_timeout.go +++ b/internal/processor/fetcher/implementation/chromedp/graceful_timeout.go @@ -46,7 +46,7 @@ func IsValidPartialDOM(html string) bool { return strings.Contains(html, " 1 은 // 현 모델에서 추가 동시성 이득 없음 (default 1 권장). @@ -40,12 +40,12 @@ type ChromedpJobHandler struct { log *logger.Logger } -// NewChromedpJobHandler 는 새 ChromedpJobHandler 를 생성합니다 (이슈 #229). +// NewChromedpJobHandler 는 새 ChromedpJobHandler 를 생성합니다. // // sems 는 worker_id 인덱스의 Semaphore slice — 길이는 chromedp pool 의 worker 수와 동일해야 // 합니다. 호출자(main.go) 가 cfg.WorkerCount 만큼 NewSemaphore 를 만들어 전달합니다. // -// registry / sems 는 nil/empty 허용 안 함 (이슈 #208 정책). +// registry / sems 는 nil/empty 허용 안 함. func NewChromedpJobHandler(registry *handler.Registry, sems []Semaphore, log *logger.Logger) (*ChromedpJobHandler, error) { if registry == nil { return nil, errors.New("worker: NewChromedpJobHandler requires non-nil handler Registry") diff --git a/internal/processor/fetcher/worker/circuit_breaker.go b/internal/processor/fetcher/worker/circuit_breaker.go index 8cd17c06..3c27a12f 100644 --- a/internal/processor/fetcher/worker/circuit_breaker.go +++ b/internal/processor/fetcher/worker/circuit_breaker.go @@ -63,7 +63,7 @@ var DefaultCircuitBreakerConfig = CircuitBreakerConfig{ type CircuitBreaker struct { config CircuitBreakerConfig source string - log *logger.Logger // 이슈 #137 — state 전이 가시성용 (nil 허용 — 미주입 시 로그 skip) + log *logger.Logger // state 전이 가시성용 (nil 허용 — 미주입 시 로그 skip) mu sync.Mutex state cbState @@ -159,7 +159,7 @@ func (cb *CircuitBreaker) RecordFailure() { } } -// logTransition 은 state 전이를 구조화 로그로 출력합니다 (이슈 #137). +// logTransition 은 state 전이를 구조화 로그로 출력합니다. // // asWarn=true 면 WARN, false 면 INFO 레벨로 출력합니다 — open 으로 들어가는 전이는 // 운영 알림 가치가 있어 WARN, 그 외 (probing/recovered) 는 INFO. logger 미주입 시 no-op. @@ -203,13 +203,13 @@ func (cb *CircuitBreaker) Failures() int { // goroutine-safe합니다. type CircuitBreakerRegistry struct { config CircuitBreakerConfig - log *logger.Logger // 이슈 #137 — 신규 CB 생성 시 주입 (nil 허용) + log *logger.Logger // 신규 CB 생성 시 주입 (nil 허용) mu sync.RWMutex cbs map[string]*CircuitBreaker } // NewCircuitBreakerRegistry는 CircuitBreakerRegistry를 생성합니다. -// log 가 nil 이 아니면 각 CB 의 state 전이 로그가 출력됩니다 (이슈 #137). +// log 가 nil 이 아니면 각 CB 의 state 전이 로그가 출력됩니다. func NewCircuitBreakerRegistry(config CircuitBreakerConfig, log *logger.Logger) *CircuitBreakerRegistry { return &CircuitBreakerRegistry{ config: config, diff --git a/internal/processor/fetcher/worker/manager.go b/internal/processor/fetcher/worker/manager.go index e6928fd7..94344471 100644 --- a/internal/processor/fetcher/worker/manager.go +++ b/internal/processor/fetcher/worker/manager.go @@ -29,12 +29,12 @@ type PoolConfig struct { // ManagerConfig aggregates the three per-priority pool configs. // ProcessingLock 이 nil 이면 NoopProcessingLock 이 사용되어 중복 처리 방지가 비활성화됩니다. // RetryScheduler가 nil이면 각 Pool 이 lazy 로 KafkaImmediateRetryScheduler 를 생성하여 -// 기존 동작 (즉시 Kafka publish + worker sleep) 을 유지합니다 (이슈 #82). +// 기존 동작 (즉시 Kafka publish + worker sleep) 을 유지합니다. // -// URL dedup 은 이슈 #178 의 Ingestion Lock (Publisher 단) 으로 단일화 — worker 측 별도 cache 없음. +// URL dedup 은 Ingestion Lock (Publisher 단) 으로 단일화 — worker 측 별도 cache 없음. // 단계별 worker 간 동시처리 차단은 ProcessingLock 으로 일원화 (구 JobLocker 의 명칭 변경). // -// 이슈 #218: Chromedp 필드는 chromedp 전용 worker pool 의 PoolConfig + ChromedpHandler. +// Chromedp 필드는 chromedp 전용 worker pool 의 PoolConfig + ChromedpHandler. // nil 이면 chromedp pool 미기동 (fetcher pool split 비활성 — 기존 동작 유지). Consumer 와 // Handler 둘 다 non-nil 이어야 wiring 됨. type ManagerConfig struct { @@ -72,7 +72,7 @@ type PoolManager struct { resolver PriorityResolver log *logger.Logger - // chromedpPool 은 chromedp 전용 worker pool (이슈 #218). nil 이면 비활성. + // chromedpPool 은 chromedp 전용 worker pool. nil 이면 비활성. chromedpPool *KafkaConsumerPool } @@ -94,7 +94,7 @@ func NewPoolManager( // 세 개 Pool이 동일한 CircuitBreakerRegistry를 공유하여 // 소스별 실패 카운팅이 우선순위 경계 없이 누적됩니다. - // log 주입 (이슈 #137) — CB state 전이마다 INFO/WARN 로그. + // log 주입 — CB state 전이마다 INFO/WARN 로그. cbRegistry := NewCircuitBreakerRegistry(DefaultCircuitBreakerConfig, log) newPool := func(pc PoolConfig, priorityName string) *KafkaConsumerPool { @@ -102,9 +102,9 @@ func NewPoolManager( pc.Consumer, producer, handler, contentSvc, pc.WorkerCount, cbRegistry, procLock, ) - // 이슈 #137 — heartbeat 식별자 주입 (DEBUG 레벨에서 worker pool status 출력 활성) + // heartbeat 식별자 주입 (DEBUG 레벨에서 worker pool status 출력 활성) pool.SetPriority(priorityName) - // 단일 RetryScheduler 인스턴스를 세 우선순위 Pool 이 공유합니다 (이슈 #82) — + // 단일 RetryScheduler 인스턴스를 세 우선순위 Pool 이 공유합니다 — // Redis 기반 구현에서 ZSET/연결 풀이 통일되도록. if cfg.RetryScheduler != nil { pool.SetRetryScheduler(cfg.RetryScheduler) @@ -123,7 +123,7 @@ func NewPoolManager( log: log, } - // 이슈 #218: chromedp 전용 pool wiring (Consumer + Handler 둘 다 있을 때만 활성). + // chromedp 전용 pool wiring (Consumer + Handler 둘 다 있을 때만 활성). if cfg.Chromedp.Consumer != nil && cfg.ChromedpHandler != nil { chromedpPool := NewKafkaConsumerPoolWithOptions( cfg.Chromedp.Consumer, producer, cfg.ChromedpHandler, contentSvc, cfg.Chromedp.WorkerCount, @@ -176,7 +176,7 @@ func (m *PoolManager) Publish(ctx context.Context, job *core.CrawlJob) error { } // Start는 high/normal/low 모든 Pool의 goroutine을 시작합니다. -// chromedp pool 이 wiring 되어 있으면 함께 시작 (이슈 #218). +// chromedp pool 이 wiring 되어 있으면 함께 시작. func (m *PoolManager) Start(ctx context.Context) { // 우선순위 순서대로 시작 (로그 가독성) for _, p := range []core.Priority{core.PriorityHigh, core.PriorityNormal, core.PriorityLow} { @@ -199,7 +199,7 @@ func (m *PoolManager) Start(ctx context.Context) { // // Stop stops all pools concurrently so they drain in parallel within the // shared context timeout. 첫 번째로 발생한 에러를 반환하며 나머지 Pool 종료도 계속 시도합니다. -// chromedp pool 이 wiring 되어 있으면 함께 종료 (이슈 #218). +// chromedp pool 이 wiring 되어 있으면 함께 종료. func (m *PoolManager) Stop(ctx context.Context) error { var ( mu sync.Mutex diff --git a/internal/processor/fetcher/worker/pool.go b/internal/processor/fetcher/worker/pool.go index f52cbb43..1606895f 100644 --- a/internal/processor/fetcher/worker/pool.go +++ b/internal/processor/fetcher/worker/pool.go @@ -33,7 +33,7 @@ const drainTimeout = 5 * time.Second // JobHandler는 CrawlJob을 처리하는 인터페이스입니다. // 구현체는 여러 goroutine에서 동시에 호출되므로 goroutine-safe해야 합니다. // Handle은 크롤링 및 파싱 결과를 []*core.Content로 반환합니다. -// 처리할 내용이 없으면 nil, nil을 반환합니다 — 이슈 #134 분리 후 fetcher 측 +// 처리할 내용이 없으면 nil, nil을 반환합니다 — fetcher 측 // ChainHandler 는 raw_contents 저장 + RawContentRef 발행 만 수행하므로 항상 nil 반환. type JobHandler interface { Handle(ctx context.Context, job *core.CrawlJob) ([]*core.Content, error) @@ -75,12 +75,12 @@ type KafkaConsumerPool struct { // atomic.Pointer 를 사용하여 polling/worker goroutine 의 동시 Load 와 // SetNormalizer 의 Store 사이에 race 가 발생하지 않도록 보장합니다. normalizer atomic.Pointer[links.Normalizer] - // gate 는 URL 가드 (이슈 #119) 입니다. + // gate 는 URL 가드 입니다. // 미설정(nil) 이면 가드 비활성 — 모든 job 이 처리됩니다. // processJob 진입 직후 검사하여 차단된 URL 의 처리를 skip 하고 message 만 commit. // atomic.Pointer 로 race-safe 한 lock-free 설정/조회. gate atomic.Pointer[urlguard.Gate] - // retryScheduler 는 재시도 발행 시점을 관리하는 RetryScheduler 입니다 (이슈 #82). + // retryScheduler 는 재시도 발행 시점을 관리하는 RetryScheduler 입니다. // 미설정(nil) 이면 lazy 로 KafkaImmediateRetryScheduler 가 사용되어 기존 동작 유지 — // 즉시 priority 토픽에 publish + worker 가 ScheduledAt 까지 sleep. // atomic.Pointer 로 race-safe 설정 — Start 이후 SetRetryScheduler 호출에도 안전. @@ -89,7 +89,7 @@ type KafkaConsumerPool struct { // close(p.jobs) 전에 반드시 이 채널이 닫혔음을 확인해야 합니다. pollDone chan struct{} - // 이슈 #137 — heartbeat 가시성. processJob 진입 시 inc, defer 시 dec. + // heartbeat 가시성. processJob 진입 시 inc, defer 시 dec. // 운영자가 "silent vs hang" 을 즉답하기 위한 단일 source of truth. busyCount atomic.Int32 // priority 는 heartbeat 로그에 포함될 pool 식별자 ("high"/"normal"/"low"). @@ -171,7 +171,7 @@ func NewKafkaConsumerPoolWithOptions( } } -// SetRetryScheduler 는 requeueWithRetry 시 사용할 RetryScheduler 를 설정합니다 (이슈 #82). +// SetRetryScheduler 는 requeueWithRetry 시 사용할 RetryScheduler 를 설정합니다. // nil 전달 시 fallback 인 KafkaImmediateRetryScheduler (lazy 생성) 가 사용됩니다 — // 기존 동작 보존. atomic 교체로 Start 이후에도 race-safe. func (p *KafkaConsumerPool) SetRetryScheduler(rs RetryScheduler) { @@ -182,7 +182,7 @@ func (p *KafkaConsumerPool) SetRetryScheduler(rs RetryScheduler) { p.retryScheduler.Store(&retrySchedulerHolder{s: rs}) } -// SetGate 는 processJob 진입 시 URL 검사에 사용할 urlguard.Gate 를 설정합니다 (이슈 #119). +// SetGate 는 processJob 진입 시 URL 검사에 사용할 urlguard.Gate 를 설정합니다. // 미설정(nil) 시 가드 비활성 — 모든 job 이 정상 처리됩니다. // // 차단 시 동작: handler.Handle 호출 없이 메시지만 commit (큐에서 제거). @@ -222,7 +222,7 @@ func (p *KafkaConsumerPool) normalizeURL(rawURL string) string { return normalized } -// SetPriority 는 heartbeat 로그에 사용할 pool 식별자를 설정합니다 (이슈 #137). +// SetPriority 는 heartbeat 로그에 사용할 pool 식별자를 설정합니다. // 빈 문자열이면 heartbeat goroutine 이 시작되지 않아 기존 동작이 유지됩니다. // Start 호출 전에 설정해야 효과가 있습니다. func (p *KafkaConsumerPool) SetPriority(name string) { @@ -233,7 +233,7 @@ func (p *KafkaConsumerPool) SetPriority(name string) { // context가 cancel되면 polling이 중단되고 진행 중인 작업이 완료됩니다. // // 각 worker goroutine 은 0..workerCount-1 의 worker_id 를 부여받아 ctx 에 실어 전달합니다 -// (이슈 #229). 다운스트림 JobHandler — 특히 ChromedpJobHandler — 는 worker_id 로 per-worker +// 다운스트림 JobHandler — 특히 ChromedpJobHandler — 는 worker_id 로 per-worker // 자원 (Semaphore, 추후 RemoteURL) 을 lookup 합니다. priority pool (high/normal/low) 의 worker_id // 는 의미가 없지만 (handler 가 무시) 모든 pool 이 동일 wiring 을 갖도록 일관성 보장. func (p *KafkaConsumerPool) Start(ctx context.Context) { @@ -250,7 +250,7 @@ func (p *KafkaConsumerPool) Start(ctx context.Context) { p.pollMessages(ctx) }() - // 이슈 #137 — heartbeat goroutine. priority 가 설정된 경우만 시작 (테스트 호환). + // heartbeat goroutine. priority 가 설정된 경우만 시작 (테스트 호환). // silent vs hang 즉답을 위해 30초마다 worker pool status 를 DEBUG 로 출력. // ctx cancel 시 자체 종료 — wg 등록 안 함 (관찰용 goroutine). if p.priority != "" { @@ -259,7 +259,7 @@ func (p *KafkaConsumerPool) Start(ctx context.Context) { } // heartbeatLoop 는 30초마다 worker pool 의 현재 상태 (busy/total/buffered) 를 DEBUG 로 -// 출력합니다 (이슈 #137). 운영자가 LOG_LEVEL=debug 로 토글 시 silent 구간이 정상 idle +// 출력합니다. 운영자가 LOG_LEVEL=debug 로 토글 시 silent 구간이 정상 idle // 인지 (busy=0, buffer=0) 또는 long-running 처리 중인지 (busy>0) 즉답할 수 있습니다. // // ctx cancel 시 자체 종료 — wg 미등록 (관찰용이라 셧다운을 차단할 책임 없음). @@ -373,9 +373,9 @@ func (p *KafkaConsumerPool) pollMessages(ctx context.Context) { func (p *KafkaConsumerPool) worker(ctx context.Context, workerID int) { defer p.wg.Done() - // 이슈 #229 — worker_id 를 ctx 에 첨부. ChromedpJobHandler 가 per-worker Semaphore + // worker_id 를 ctx 에 첨부. ChromedpJobHandler 가 per-worker Semaphore // 슬롯을 선택하는 데 사용. priority pool 에서는 handler 가 ID 를 무시. - // 이슈 #230 — general.ChainHandler 의 chromedpChains slice lookup 에도 동일 키 재사용. + // general.ChainHandler 의 chromedpChains slice lookup 에도 동일 키 재사용. ctx = core.WithWorkerID(ctx, workerID) log := logger.FromContext(ctx) @@ -396,7 +396,7 @@ func (p *KafkaConsumerPool) worker(ctx context.Context, workerID int) { func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err error) { log := logger.FromContext(ctx) - // 이슈 #137 — processJob 의 모든 return path 를 defer 로 wrap 하여 duration 측정 + + // processJob 의 모든 return path 를 defer 로 wrap 하여 duration 측정 + // busyCount 증감 (heartbeat 가시성). outcome 은 err 여부로 분기. p.busyCount.Add(1) start := time.Now() @@ -415,7 +415,7 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e log.WithFields(fields).Debug("job processed") }() - // URL 가드 (이슈 #119): processJob 진입 직후 차단 URL 을 즉시 거르고 commit + // URL 가드: processJob 진입 직후 차단 URL 을 즉시 거르고 commit // → handler 호출·lock 획득·backoff 대기 등 모든 비용 회피 // → stale URL 메시지가 큐에서 즉시 제거되어 retry 사이클 차단 // @@ -452,7 +452,7 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e } } - // 이슈 #178: 동일 URL 이 여러 worker 에서 동시 처리되는 것을 ProcessingLock (stage=fetcher) 로 차단. + // 동일 URL 이 여러 worker 에서 동시 처리되는 것을 ProcessingLock (stage=fetcher) 로 차단. // Kafka rebalance/재시작으로 동일 메시지가 중복 소비될 때 + 같은 URL 의 다른 jobID 가 동시 처리될 때 // 모두 흡수. backoff 대기 이후에 Acquire 하여 락 점유 시간을 실제 처리 구간으로 최소화합니다. procKey := locks.ProcessingKey(locks.StageFetcher, item.job.Target.URL) @@ -475,9 +475,9 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e // 처리 담당 워커의 commit 에 의존하여, 해당 워커 장애 시 재처리가 보장되도록 합니다. return nil } else { - // 이슈 #137 — 운영자가 lock 획득 흐름을 추적할 수 있도록 DEBUG 로 success 기록. + // 운영자가 lock 획득 흐름을 추적할 수 있도록 DEBUG 로 success 기록. // ttl_ms 는 ProcessingLock 인스턴스가 custom TTL 로 생성될 수 있어 인터페이스로 노출하지 않으면 - // 정확치 않으므로 로그에서 제외 (PR #180 gemini 피드백). + // 정확치 않으므로 로그에서 제외. log.WithFields(map[string]interface{}{ "job_id": item.job.ID, "crawler": item.job.CrawlerName, @@ -497,7 +497,7 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e }).WithError(releaseErr).Warn("failed to release processing lock") return } - // 이슈 #137 — release 성공도 DEBUG 로 짝을 맞춰 lifecycle 완성. + // release 성공도 DEBUG 로 짝을 맞춰 lifecycle 완성. log.WithFields(map[string]interface{}{ "job_id": item.job.ID, "crawler": item.job.CrawlerName, @@ -506,7 +506,7 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e }() } - // 이슈 #178: URL 단위 dedup 은 위 ProcessingLock + Publisher 단의 Ingestion Lock 두 층으로 보장. + // URL 단위 dedup 은 위 ProcessingLock + Publisher 단의 Ingestion Lock 두 층으로 보장. log.WithFields(map[string]interface{}{ "job_id": item.job.ID, @@ -570,7 +570,7 @@ func (p *KafkaConsumerPool) processJob(ctx context.Context, item jobItem) (err e } if len(contents) == 0 { - // 이슈 #178: URL dedup 은 Publisher 의 Ingestion Lock (TTL 24h) 으로 보장 — fetch 성공 후 + // URL dedup 은 Publisher 의 Ingestion Lock (TTL 24h) 으로 보장 — fetch 성공 후 // 별도 캐시 등록 불필요. lock TTL 만료 시점에 자연스럽게 재크롤 가능. cb.RecordSuccess() return p.commitMessage(ctx, item.msg) @@ -758,7 +758,7 @@ func (p *KafkaConsumerPool) requeueWithRetry(ctx context.Context, job *core.Craw job.RetryCount++ // RetryCount 기반 exponential backoff 계산 후 ScheduledAt에 저장합니다. - // 실제 지연 적용은 RetryScheduler 구현체가 책임집니다 (이슈 #82): + // 실제 지연 적용은 RetryScheduler 구현체가 책임집니다: // - KafkaImmediateRetryScheduler (fallback): 즉시 publish — worker 가 sleep // - RedisDelayedRetryScheduler: Redis ZSET 보관 — worker 슬롯 점유 없음 backoffDelay := core.CalculateBackoff(kafkaRequeuePolicy, job.RetryCount) diff --git a/internal/processor/fetcher/worker/retry_scheduler.go b/internal/processor/fetcher/worker/retry_scheduler.go index fd9b1d8c..b5500712 100644 --- a/internal/processor/fetcher/worker/retry_scheduler.go +++ b/internal/processor/fetcher/worker/retry_scheduler.go @@ -21,7 +21,6 @@ type retrySchedulerHolder struct { } // RetryScheduler 는 처리 실패한 CrawlJob 의 재시도 발행 시점을 관리하는 인터페이스입니다 -// (이슈 #82). // // 두 가지 구현 전략을 추상화합니다: // - KafkaImmediateRetryScheduler: 즉시 Kafka 에 재발행하고 worker 가 ScheduledAt 까지 @@ -43,7 +42,7 @@ type RetryScheduler interface { // 2. priority 토픽으로 즉시 publish (ScheduledAt 은 미래 시각으로 셋팅된 상태) // 3. worker 가 메시지 fetch 후 processJob 진입 시 ScheduledAt 까지 sleep — 워커 슬롯 점유 // -// 본 구현은 이슈 #82 가 지적한 처리량 급감을 그대로 갖지만, Redis 미설정 환경 (단일 인스턴스 +// 본 구현은 지적한 처리량 급감을 그대로 갖지만, Redis 미설정 환경 (단일 인스턴스 // 개발/테스트, 통합 redis 장애) 에서 retry 자체는 동작하도록 보존합니다. type KafkaImmediateRetryScheduler struct { producer queue.Producer @@ -86,13 +85,13 @@ func retryHeaders(job *core.CrawlJob, lastErr error) map[string]string { } // ───────────────────────────────────────────────────────────────────────────── -// Redis-backed delayed retry scheduler (이슈 #82 본 PR 의 핵심) +// Redis-backed delayed retry scheduler // ───────────────────────────────────────────────────────────────────────────── // retryQueueClient 는 RedisDelayedRetryScheduler 가 사용하는 Redis 연산을 추상화합니다. // pkg/redis.Client 가 구조적으로 만족하며, 테스트는 mock 으로 교체합니다. // -// peek-publish-ack 패턴 (이슈 #82, PR #128 피드백): +// peek-publish-ack 패턴: // - PeekDueRetries 는 due 항목을 조회만 하고 ZSET 에서 제거하지 않음 // - publish 성공 후 AckRetry 로 명시적 제거 → at-least-once 보장 // - publish 실패 시 backoff 적용한 재 EnqueueRetry (또는 무처리 후 다음 polling) @@ -125,7 +124,7 @@ func DefaultRedisRetrySchedulerConfig() RedisRetrySchedulerConfig { } // RedisDelayedRetryScheduler 는 Redis ZSET 에 retry 를 보관하고 별도 goroutine 이 -// ScheduledAt 도달 항목을 Kafka 에 발행하는 구현체입니다 (이슈 #82). +// ScheduledAt 도달 항목을 Kafka 에 발행하는 구현체입니다. // // 핵심 효과: requeue 는 Redis 에만 저장되므로 worker 가 메시지를 소비한 뒤 sleep 하지 // 않고 즉시 다음 정상 job 처리로 넘어갑니다. 워커 슬롯 점유 문제 해소. @@ -235,7 +234,7 @@ func (s *RedisDelayedRetryScheduler) Stop() { // pollOnce 는 due 항목을 한 batch 만큼 peek 하여 Kafka 에 발행합니다. // peek 단계에서는 ZSET 에서 제거하지 않으며, publish 성공 후에만 AckRetry 로 제거 — -// at-least-once 보장 (peek-publish-ack 패턴, PR #128 피드백). +// at-least-once 보장 (peek-publish-ack 패턴). func (s *RedisDelayedRetryScheduler) pollOnce(ctx context.Context) { peekStart := time.Now() due, err := s.client.PeekDueRetries(ctx, time.Now(), s.cfg.BatchSize) @@ -249,7 +248,7 @@ func (s *RedisDelayedRetryScheduler) pollOnce(ctx context.Context) { return } - // 이슈 #137 — peek 결과를 항상 DEBUG 로 노출 (count=0 이어도 한 줄). + // peek 결과를 항상 DEBUG 로 노출 (count=0 이어도 한 줄). // 운영자가 retry pipeline 이 살아있고 polling 중인지 즉답 가능. if len(due) > 0 { s.log.WithFields(map[string]interface{}{ diff --git a/internal/processor/fetcher/worker/semaphore.go b/internal/processor/fetcher/worker/semaphore.go index 4aa30857..47f3a6ca 100644 --- a/internal/processor/fetcher/worker/semaphore.go +++ b/internal/processor/fetcher/worker/semaphore.go @@ -5,7 +5,7 @@ import ( "errors" ) -// Semaphore 는 N 개의 동시 진입을 허용하는 counting semaphore 입니다 (이슈 #218). +// Semaphore 는 N 개의 동시 진입을 허용하는 counting semaphore 입니다. // // chromedp worker pool 이 Chrome 인스턴스의 동시 navigation 수를 제한하기 위해 사용 — 같은 // Chrome 의 URLLoader / ResourceScheduler 가 ERR_INSUFFICIENT_RESOURCES 로 거부되는 빈도를 @@ -19,7 +19,7 @@ type Semaphore interface { // Release 는 슬롯 1개를 반납합니다. Acquire 와 1:1 매핑. // 매칭 Acquire 없이 호출 시 ErrReleaseWithoutAcquire — 호출자가 contract 위반을 log/audit. - // (production panic 금지 정책 — 이슈 #208 / 04-error-handling.md.) + // (production panic 금지 정책 — 04-error-handling.md.) Release() error // Capacity 는 동시 보유 가능한 최대 슬롯 수를 반환합니다. @@ -28,7 +28,7 @@ type Semaphore interface { // ErrReleaseWithoutAcquire 는 Release() 가 매칭 Acquire 없이 호출됐을 때 반환됩니다. // -// production panic 금지 (이슈 #208) 에 따라 panic 대신 sentinel error — 호출자가 sentry / log 로 +// production panic 금지 에 따라 panic 대신 sentinel error — 호출자가 sentry / log 로 // contract 위반 알림. 정상 흐름에서는 발생 안 함 (defer Release 패턴 사용). var ErrReleaseWithoutAcquire = errors.New("worker: release called without matching acquire") @@ -40,7 +40,7 @@ type chanSemaphore struct { // NewSemaphore 는 capacity 만큼의 동시 진입을 허용하는 Semaphore 를 생성합니다. // -// capacity <= 0 이면 error (이슈 #208 정책). +// capacity <= 0 이면 error. func NewSemaphore(capacity int) (Semaphore, error) { if capacity <= 0 { return nil, errors.New("worker: semaphore requires positive capacity") @@ -70,7 +70,7 @@ func (s *chanSemaphore) Acquire(ctx context.Context) error { // Release 는 슬롯 1개를 반납합니다 (Acquire 후 1:1 매핑). // // 매칭 Acquire 없이 호출되면 ErrReleaseWithoutAcquire 반환 — 호출자가 log 로 contract 위반 -// 알림. production panic 금지 (이슈 #208) 에 따라 panic 대신 error. +// 알림. production panic 금지 에 따라 panic 대신 error. func (s *chanSemaphore) Release() error { select { case <-s.slots: diff --git a/internal/processor/parser/parser.go b/internal/processor/parser/parser.go index 8f685e9f..9cdc5df9 100644 --- a/internal/processor/parser/parser.go +++ b/internal/processor/parser/parser.go @@ -1,5 +1,5 @@ // Package parser 은 모든 웹페이지 (뉴스 / 블로그 / 일반 문서) 의 핵심 내용을 -// 추출하기 위한 도메인 중립 인터페이스와 모델을 제공합니다 (이슈 #100). +// 추출하기 위한 도메인 중립 인터페이스와 모델을 제공합니다. // // Package parser defines domain-agnostic interfaces and models for extracting // the main content of any web page. 사이트별 hardcode 파서를 대체하여, DB 기반 rule diff --git a/internal/processor/parser/rule/blacklist_matcher.go b/internal/processor/parser/rule/blacklist_matcher.go index 3bd56073..3a35f7c0 100644 --- a/internal/processor/parser/rule/blacklist_matcher.go +++ b/internal/processor/parser/rule/blacklist_matcher.go @@ -11,7 +11,7 @@ import ( "issuetracker/internal/storage" ) -// DefaultBlacklistCacheTTL 은 BlacklistMatcher 의 기본 양성 캐시 TTL 입니다 (이슈 #295). +// DefaultBlacklistCacheTTL 은 BlacklistMatcher 의 기본 양성 캐시 TTL 입니다. // // Resolver (5m) 와 동일 — 운영자가 manual 등록 / disable 토글 후 약간의 지연을 허용하되, // 핫패스 lookup 의 DB 부담은 거의 0 으로. @@ -27,12 +27,12 @@ const DefaultBlacklistMaxCacheEntries = 10_000 // DefaultBlacklistMaxRegexEntries 는 path_pattern regex 컴파일 결과 cache 의 최대 entry 수입니다. // -// PR #296 gemini 피드백: sync.Map 은 키 공간이 작고 bounded 일 때만 권장 — 향후 auto source +// sync.Map 은 키 공간이 작고 bounded 일 때만 권장 — 향후 auto source // 등장 시 unique pattern 수가 증가할 수 있어 메모리 unbounded growth 가능. cap + simple evict // 로 보호. const DefaultBlacklistMaxRegexEntries = 10_000 -// BlacklistMatcher 는 (host, path) 매칭으로 blacklist 차단 여부를 판단합니다 (이슈 #295). +// BlacklistMatcher 는 (host, path) 매칭으로 blacklist 차단 여부를 판단합니다. // // Resolver 와 동일 패턴 — host 단위 후보 슬라이스를 cache + DB lookup, application 측에서 // path regex 매칭. catch-all (path_pattern="") 은 LENGTH DESC 정렬상 가장 마지막에 평가. @@ -95,7 +95,7 @@ func WithBlacklistMaxCacheEntries(n int) BlacklistMatcherOption { } } -// WithBlacklistMaxRegexEntries 는 path_pattern regex 컴파일 cache 의 최대 entry 수를 override 합니다 (PR #296 gemini). +// WithBlacklistMaxRegexEntries 는 path_pattern regex 컴파일 cache 의 최대 entry 수를 override 합니다. func WithBlacklistMaxRegexEntries(n int) BlacklistMatcherOption { return func(m *BlacklistMatcher) { if n > 0 { @@ -159,7 +159,7 @@ func (m *BlacklistMatcher) IsBlocked(ctx context.Context, rawURL string) (bool, // 동일 호스트가 반복되는 카테고리 페이지 링크 시나리오에서 host cache 가 재사용되어 비용 // 거의 없음. 개별 IsBlocked 호출 에러는 best-effort — 해당 URL 만 통과 (차단 안 함). // -// Deprecated: 이슈 #297 에서 mode 컬럼 도입 후 Classify 사용 권장 — Filter 는 Allowed 만 반환하여 +// Deprecated: mode 컬럼 도입 후 Classify 사용 권장 — Filter 는 Allowed 만 반환하여 // 'extract_links_only' 모드의 URL 도 함께 drop 됨. 호환성을 위해 메소드는 유지. func (m *BlacklistMatcher) Filter(ctx context.Context, urls []string) []string { if len(urls) == 0 { @@ -180,7 +180,7 @@ func (m *BlacklistMatcher) Filter(ctx context.Context, urls []string) []string { return out } -// BlacklistDecision 은 Classify 의 결과 — mode 별로 URL 슬라이스를 분리합니다 (이슈 #297). +// BlacklistDecision 은 Classify 의 결과 — mode 별로 URL 슬라이스를 분리합니다. // // 호출자 (parser_worker) 는 슬라이스별로 다른 publish 분기를 적용: // - Allowed : blacklist 매칭 X 또는 lookup 에러 (best-effort 통과) — 정상 article 발행 @@ -191,7 +191,7 @@ type BlacklistDecision struct { ExtractLinksOnly []string } -// Classify 는 입력 URL 슬라이스를 mode 별로 분류합니다 (이슈 #297). +// Classify 는 입력 URL 슬라이스를 mode 별로 분류합니다. // // 매칭 정책: // 1. blacklist row 매칭 X → Allowed @@ -314,7 +314,7 @@ func (m *BlacklistMatcher) pathMatches(pattern, path string) bool { return cp.re.MatchString(path) } -// compileRegex 는 pattern 의 컴파일 결과를 cache + reuse 합니다 (PR #296 gemini 피드백 반영). +// compileRegex 는 pattern 의 컴파일 결과를 cache + reuse 합니다. // // sync.Map 대신 mutex-protected map 으로 변경 — maxRegexEntries cap 적용 + simple evict (1 random). // auto source 등 unique pattern 수 증가 시 unbounded growth 회피. @@ -347,7 +347,7 @@ func (m *BlacklistMatcher) compileRegex(pattern string) *blacklistCompiledPatter } // invalidatingBlacklistRepo 는 BlacklistRepository 를 wrap 하여 mutation 후 자동으로 Matcher 의 -// host cache 를 invalidate 하는 decorator 입니다 (이슈 #295, parsing_rules 의 invalidatingRepo +// host cache 를 invalidate 하는 decorator 입니다 (parsing_rules 의 invalidatingRepo // 와 동일 패턴). // // 적용 정책: @@ -391,7 +391,7 @@ func (r *invalidatingBlacklistRepo) Insert(ctx context.Context, rec *storage.Bla } func (r *invalidatingBlacklistRepo) Update(ctx context.Context, rec *storage.BlacklistRecord) error { - // PR #296 CodeRabbit 피드백: Update 는 host 변경 안 하므로 호출자가 rec.HostPattern 을 비워 + // Update 는 host 변경 안 하므로 호출자가 rec.HostPattern 을 비워 // 보내도 정당. 사전 GetByID 로 authoritative host 를 얻어 invalidate — Delete 와 동일 패턴. // pre-fetch 실패 시 fallback 으로 rec.HostPattern (비어있지 않을 때만) 사용. before, lookupErr := r.inner.GetByID(ctx, rec.ID) diff --git a/internal/processor/parser/rule/claudegen/container.go b/internal/processor/parser/rule/claudegen/container.go index e7c070d5..67b4a6f2 100644 --- a/internal/processor/parser/rule/claudegen/container.go +++ b/internal/processor/parser/rule/claudegen/container.go @@ -15,7 +15,7 @@ type execContainerRunner struct{} // StartContainer 는 workspace + auth 상태를 마운트한 장기 실행 컨테이너를 기동합니다. // -// 마운트 정책 (이슈 #266): +// 마운트 정책: // - workDir → /workspace (read-write): 세션별 페이지 + 출력 임시 저장 // - authDir → containerAuthPath (read-write): 호스트의 .claude/ 디렉토리. // Claude CLI 가 세션 history / 일시 상태를 본 디렉토리에 기록하므로 :ro 마운트 불가. @@ -26,7 +26,7 @@ type execContainerRunner struct{} // // 컨테이너는 `tail -f /dev/null` 로 대기 — docker exec 세션이 올 때까지 유지. func (r *execContainerRunner) StartContainer(ctx context.Context, image, workDir, authDir, containerAuthPath string) (string, error) { - // trailing slash 등 정규화 — sibling .claude.json 도출이 정확하도록 (PR #268 리뷰). + // trailing slash 등 정규화 — sibling .claude.json 도출이 정확하도록. cleanAuthDir := filepath.Clean(authDir) cleanContainerAuthPath := filepath.Clean(containerAuthPath) @@ -35,7 +35,7 @@ func (r *execContainerRunner) StartContainer(ctx context.Context, image, workDir "-v", cleanAuthDir + ":" + cleanContainerAuthPath, } - // .claude.json 파일도 함께 마운트 (이슈 #266) — Claude CLI 가 main config 를 sibling 위치에서 찾음. + // .claude.json 파일도 함께 마운트 — Claude CLI 가 main config 를 sibling 위치에서 찾음. hostJSON := filepath.Join(filepath.Dir(cleanAuthDir), ".claude.json") if _, err := os.Stat(hostJSON); err == nil { containerJSON := filepath.Join(filepath.Dir(cleanContainerAuthPath), ".claude.json") @@ -62,7 +62,7 @@ func (r *execContainerRunner) StartContainer(ctx context.Context, image, workDir } // ExecSession 은 실행 중인 컨테이너에서 명령을 실행합니다. -// 인증은 컨테이너에 마운트된 auth_token 디렉토리로 처리됨 (이슈 #266) — env 전달 불필요. +// 인증은 컨테이너에 마운트된 auth_token 디렉토리로 처리됨 — env 전달 불필요. func (r *execContainerRunner) ExecSession(ctx context.Context, containerID string, args []string) (string, string, error) { fullArgs := append([]string{"exec", containerID}, args...) var stdout, stderr bytes.Buffer diff --git a/internal/processor/parser/rule/claudegen/worker.go b/internal/processor/parser/rule/claudegen/worker.go index 19235491..5511f3a4 100644 --- a/internal/processor/parser/rule/claudegen/worker.go +++ b/internal/processor/parser/rule/claudegen/worker.go @@ -1,11 +1,11 @@ // Package claudegen 은 상시 기동된 Claude Code Docker 컨테이너에 세션을 생성하여 -// HTML 에서 CSS 셀렉터를 추출하는 컴포넌트입니다 (이슈 #256, #266). +// HTML 에서 CSS 셀렉터를 추출하는 컴포넌트입니다. // // 기존 콜드스타트 방식(docker run --rm)과 달리, 컨테이너를 서비스 기동 시 한 번만 띄우고 // (Start) 요청마다 docker exec 으로 새 세션을 생성합니다. 컨테이너 초기화 비용을 최초 1회로 // 상각하여 이후 요청의 레이턴시를 줄입니다. // -// 인증 방식 (이슈 #266): +// 인증 방식: // // 호스트에서 `claude` CLI 로 사전 로그인하여 발급된 OAuth auth_token 을 사용합니다. // Claude 의 인증 상태는 두 위치에 분산되어 있어 둘 다 컨테이너에 마운트합니다: @@ -39,12 +39,12 @@ import ( ) const ( - // defaultImage 는 deployments/docker/claudegen/Dockerfile 로 빌드한 자체 이미지 (이슈 #269). + // defaultImage 는 deployments/docker/claudegen/Dockerfile 로 빌드한 자체 이미지. // Anthropic 공식 ghcr.io/anthropics/claude-code 는 비공개 상태이므로 `make claudegen-build` 로 사전 빌드 필요. defaultImage = "issuetracker-claudegen:local" defaultModel = "claude-sonnet-4-6" defaultSessionTimeout = 120 * time.Second - defaultContainerAuthPath = "/root/.claude" // 컨테이너 내 인증 마운트 경로 (이슈 #266) + defaultContainerAuthPath = "/root/.claude" // 컨테이너 내 인증 마운트 경로 truncateStderrLen = 512 // exec 실패 시 stderr 미리보기 최대 길이 truncateStdoutLen = 256 // 파싱 실패 시 stdout 미리보기 최대 길이 @@ -77,8 +77,8 @@ type ContainerRunner interface { type ClaudeWorker struct { image string model string - authDir string // 호스트 인증 디렉토리 (이슈 #266) - containerAuthPath string // 컨테이너 내 마운트 경로 (이슈 #266) + authDir string // 호스트 인증 디렉토리 + containerAuthPath string // 컨테이너 내 마운트 경로 sessionTimeout time.Duration runner ContainerRunner log *logger.Logger @@ -90,13 +90,13 @@ type ClaudeWorker struct { } // ModelName 은 이 Worker 가 사용하는 모델 ID 를 반환합니다. -// llmgen.Generator 가 DB description 에 기록할 때 사용합니다 (이슈 #256). +// llmgen.Generator 가 DB description 에 기록할 때 사용합니다. func (w *ClaudeWorker) ModelName() string { return w.model } // NewFromEnv 는 환경변수 기반 ClaudeWorker 를 생성합니다. // Start() 를 호출하기 전까지는 컨테이너가 기동되지 않습니다. // -// CLAUDE_CODE_AUTH_DIR 미지정 시 $HOME/.claude 를 사용합니다 (이슈 #266). +// CLAUDE_CODE_AUTH_DIR 미지정 시 $HOME/.claude 를 사용합니다. // 인증 디렉토리가 없거나 접근 불가하면 fail-fast — 호스트 `claude` CLI 사전 로그인 필요. func NewFromEnv(log *logger.Logger) (*ClaudeWorker, error) { if log == nil { @@ -132,9 +132,9 @@ func NewFromEnv(log *logger.Logger) (*ClaudeWorker, error) { // New 는 명시적 파라미터로 ClaudeWorker 를 생성합니다 (DI 용). // -// authDir 은 호스트의 Claude 인증 디렉토리, containerAuthPath 는 컨테이너 내 마운트 대상 경로 (이슈 #266). +// authDir 은 호스트의 Claude 인증 디렉토리, containerAuthPath 는 컨테이너 내 마운트 대상 경로. // containerAuthPath 가 빈 문자열이면 defaultContainerAuthPath 사용. -// authDir 은 validateAuthDir 로 절대 경로 정규화 + 존재/디렉토리/읽기 권한 검증 (PR #268 리뷰). +// authDir 은 validateAuthDir 로 절대 경로 정규화 + 존재/디렉토리/읽기 권한 검증. func New(image, model, authDir, containerAuthPath string, timeout time.Duration, log *logger.Logger) (*ClaudeWorker, error) { if log == nil { return nil, errors.New("claudegen: New requires non-nil logger") @@ -154,7 +154,7 @@ func New(image, model, authDir, containerAuthPath string, timeout time.Duration, } // NewWithRunner 는 ContainerRunner 를 주입하는 생성자입니다 (테스트/DI 용). -// authDir 은 validateAuthDir 로 절대 경로 정규화 + 존재/디렉토리/읽기 권한 검증 (PR #268 리뷰). +// authDir 은 validateAuthDir 로 절대 경로 정규화 + 존재/디렉토리/읽기 권한 검증. func NewWithRunner(image, model, authDir, containerAuthPath string, timeout time.Duration, runner ContainerRunner, log *logger.Logger) (*ClaudeWorker, error) { if log == nil { return nil, errors.New("claudegen: NewWithRunner requires non-nil logger") @@ -190,7 +190,7 @@ func resolveAuthDir(envValue string) (string, error) { return validateAuthDir(authDir) } -// validateAuthDir 은 인증 디렉토리의 절대 경로를 산출하고 접근성을 검증합니다 (PR #268 리뷰). +// validateAuthDir 은 인증 디렉토리의 절대 경로를 산출하고 접근성을 검증합니다. // // - 빈 문자열 거부 (호출자가 빈 값 처리 후 호출) // - filepath.Abs 로 절대 경로 변환 — Docker 마운트 시 상대 경로 모호성 제거 @@ -240,7 +240,7 @@ func (w *ClaudeWorker) Start(ctx context.Context) error { w.containerID = containerID w.workDir = workDir - // INFO 로그는 운영자가 외부 수집 시스템에서 보는 항목 — 호스트 사용자명/홈 구조 노출 회피 (PR #268 리뷰). + // INFO 로그는 운영자가 외부 수집 시스템에서 보는 항목 — 호스트 사용자명/홈 구조 노출 회피. // auth_dir 같은 절대 경로는 DEBUG 레벨로 격리. w.log.WithFields(map[string]interface{}{ "container_id": containerID, @@ -340,7 +340,7 @@ func (w *ClaudeWorker) Extract(ctx context.Context, host string, targetType stor "claude", "--model", w.model, // --dangerously-skip-permissions 는 root user 환경에서 동작하지 않고, OAuth 인증 + -p 모드는 - // 본 플래그 없이 정상 동작 — 라이브 검증 시 발견 (이슈 #266). + // 본 플래그 없이 정상 동작 — 라이브 검증 시 발견. "-p", buildPrompt(host, targetType, sessionContainerPath), } diff --git a/internal/processor/parser/rule/discovery.go b/internal/processor/parser/rule/discovery.go index effaa626..741ba692 100644 --- a/internal/processor/parser/rule/discovery.go +++ b/internal/processor/parser/rule/discovery.go @@ -13,7 +13,7 @@ import ( ) // PageLinkDiscovery 는 페이지 전체 를 스캔한 뒤 LinkDiscoveryConfig 의 -// ArticleURLPattern (RE2 regex) 로 article URL 만 통과시키는 generic discovery 입니다 (이슈 #139). +// ArticleURLPattern (RE2 regex) 로 article URL 만 통과시키는 generic discovery 입니다. // // PageLinkDiscovery scans every on a page, then filters by the rule's // ArticleURLPattern regex. Replaces site-specific ItemContainer extraction when @@ -25,7 +25,7 @@ import ( // // stateless / goroutine-safe — 호출 시마다 regex 컴파일을 회피하기 위해 // Resolver 가 cache 한 ParsingRuleRecord 를 재사용하는 호출자 측에서 컴파일 결과를 -// 메모이즈하는 것이 이상적이나, 본 PR 범위에서는 호출 시 한 번만 컴파일. +// 메모이즈하는 것이 이상적이나, 현재 구현에서는 호출 시 한 번만 컴파일. type PageLinkDiscovery struct{} // NewPageLinkDiscovery 는 stateless discovery 컴포넌트를 생성합니다. @@ -34,13 +34,13 @@ func NewPageLinkDiscovery() *PageLinkDiscovery { return &PageLinkDiscovery{} } // Discover 는 raw 의 HTML 에서 cfg 정책에 부합하는 링크들을 LinkItem 으로 반환합니다. // // 흐름: -// 1. cfg.ArticleURLPattern 이 비어있지 않으면 compile (빈 문자열이면 all-pass 모드 — 이슈 #148) +// 1. cfg.ArticleURLPattern 이 비어있지 않으면 compile (빈 문자열이면 all-pass 모드) // 2. pkg/links.Extractor 로 raw.HTML 의 모든 추출 // (SameOriginOnly / PathPrefixes / ExcludePatterns / MaxLinksPerPage 적용) // 3. pattern 이 있으면 추가 regex 필터링, 없으면 extractor 결과 그대로 // -// All-pass 모드 (이슈 #148): -// - 본 시스템의 타겟은 \"뉴스 기사\" 만이 아닌 페이지 내 모든 의미 있는 글 (이슈 #100 도메인 일반화) +// All-pass 모드: +// - 본 시스템의 타겟은 \"뉴스 기사\" 만이 아닌 페이지 내 모든 의미 있는 글 // - ArticleURLPattern 을 강제하면 사이트별 article URL regex 외 모든 컨텐츠가 누락됨 // - 빈 pattern 은 \"ExcludePatterns + SameOriginOnly + MaxLinksPerPage 만으로 필터\" 의도 // @@ -60,7 +60,7 @@ func (d *PageLinkDiscovery) Discover(raw *core.RawContent, cfg *storage.LinkDisc } } - // pattern 이 비어있으면 all-pass — 이슈 #148. Extractor 의 다른 옵션만으로 필터링. + // pattern 이 비어있으면 all-pass. Extractor 의 다른 옵션만으로 필터링. var pattern *regexp.Regexp if cfg.ArticleURLPattern != "" { compiled, err := regexp.Compile(cfg.ArticleURLPattern) @@ -102,7 +102,7 @@ func (d *PageLinkDiscovery) Discover(raw *core.RawContent, cfg *storage.LinkDisc } } - // MaxLinksPerPage 우선순위 정책 (이슈 #148 후속): + // MaxLinksPerPage 우선순위 정책: // 1. same-origin (raw.URL 의 host 와 동일) 링크는 maxOut 무시하고 모두 통과 // 2. cross-origin 링크는 잔여 슬롯 (maxOut - len(same)) 만큼 무작위 sample // 3. maxOut == 0 (무제한) 이면 cross 도 모두 통과 diff --git a/internal/processor/parser/rule/invalidating_repo.go b/internal/processor/parser/rule/invalidating_repo.go index ba736990..f3f36b43 100644 --- a/internal/processor/parser/rule/invalidating_repo.go +++ b/internal/processor/parser/rule/invalidating_repo.go @@ -7,7 +7,7 @@ import ( "issuetracker/internal/storage" ) -// CacheInvalidator 는 (host, target_type) 튜플의 cache entry 를 무효화하는 최소 인터페이스입니다 (이슈 #288). +// CacheInvalidator 는 (host, target_type) 튜플의 cache entry 를 무효화하는 최소 인터페이스입니다. // // Resolver 가 본 인터페이스를 만족 — invalidatingRepo 가 internal/storage 를 거꾸로 import 하지 않도록 // 별도 정의. 향후 다른 cache 보유 컴포넌트도 본 인터페이스만 구현하면 decorator 와 결합 가능. @@ -16,7 +16,7 @@ type CacheInvalidator interface { } // invalidatingRepo 는 ParsingRuleRepository 를 wrap 하여 mutation 메소드 호출 후 자동으로 -// CacheInvalidator.Invalidate 를 호출하는 decorator 입니다 (이슈 #288). +// CacheInvalidator.Invalidate 를 호출하는 decorator 입니다. // // 의도: // @@ -27,7 +27,7 @@ type CacheInvalidator interface { // - Insert ErrDuplicate → Invalidate (row 가 DB 에 이미 존재 — cache 불일치 가능성) // - Update 성공 → Invalidate (host, target_type) // - UpdatePathPattern 성공 → 사전 GetByID 로 host/type lookup 후 Invalidate -// - Delete 성공 → 사전 GetByID 로 host/type lookup 후 Invalidate (PR #292 gemini 리뷰 — 일관성) +// - Delete 성공 → 사전 GetByID 로 host/type lookup 후 Invalidate // // UpdatePathPattern / Delete 의 호스트 정보 부재: // @@ -40,7 +40,7 @@ type invalidatingRepo struct { inv CacheInvalidator } -// WrapWithInvalidator 는 ParsingRuleRepository 를 invalidatingRepo 로 wrap 합니다 (이슈 #288). +// WrapWithInvalidator 는 ParsingRuleRepository 를 invalidatingRepo 로 wrap 합니다. // // 사용 예 (cmd/issuetracker/main.go): // @@ -62,7 +62,7 @@ func (r *invalidatingRepo) invalidate(host string, t storage.TargetType) { } } -// InsertNextVersion wraps inner.InsertNextVersion + invalidates on success or ErrDuplicate (이슈 #282). +// InsertNextVersion wraps inner.InsertNextVersion + invalidates on success or ErrDuplicate. // // 같은 (host, target_type) 에 대한 재학습 시 cache 가 stale 일 수 있으므로 invalidate 보장. func (r *invalidatingRepo) InsertNextVersion(ctx context.Context, rec *storage.ParsingRuleRecord) error { @@ -76,7 +76,7 @@ func (r *invalidatingRepo) InsertNextVersion(ctx context.Context, rec *storage.P // Insert wraps inner.Insert + invalidates on success or ErrDuplicate. // // ErrDuplicate 시에도 invalidate — INSERT 실패했지만 동일 자연키 row 가 이미 DB 에 존재하므로 -// cache 가 stale 일 가능성 (다른 인스턴스가 INSERT 했거나 운영자 manual). 이슈 #274 의 사후 +// cache 가 stale 일 가능성 (다른 인스턴스가 INSERT 했거나 운영자 manual). 사후 // invalidate 로직을 본 decorator 로 통합. func (r *invalidatingRepo) Insert(ctx context.Context, rec *storage.ParsingRuleRecord) error { err := r.inner.Insert(ctx, rec) @@ -113,7 +113,7 @@ func (r *invalidatingRepo) UpdatePathPattern(ctx context.Context, id int64, patt return nil } -// Delete wraps inner.Delete + 사전 GetByID 로 host/type 조회 후 invalidate (PR #292 gemini 리뷰). +// Delete wraps inner.Delete + 사전 GetByID 로 host/type 조회 후 invalidate. // // UpdatePathPattern 과 동일 패턴 — decorator 의 일관성 보장 (mutation→invalidate 결합 누락 0). // pre-fetch 실패 시 invalidate skip — TTL fallback 으로 자연 회수. diff --git a/internal/processor/parser/rule/llmgen/confidence.go b/internal/processor/parser/rule/llmgen/confidence.go index 90c8f272..4bc43d54 100644 --- a/internal/processor/parser/rule/llmgen/confidence.go +++ b/internal/processor/parser/rule/llmgen/confidence.go @@ -10,7 +10,7 @@ import ( "issuetracker/internal/storage" ) -// confidenceThreshold 는 selector 의 hit_rate 가 본 임계값 미만이면 drop (nil) 처리됩니다 (이슈 #283). +// confidenceThreshold 는 selector 의 hit_rate 가 본 임계값 미만이면 drop (nil) 처리됩니다. // // 단일 sample 환경에서 hit_rate 는 0.0 또는 1.0 (binary) — threshold 0.5 면 \"매칭 못함\" 만 drop. // 향후 multi-sample 도입 시 (sample_count > 1) 임계값이 의미 있는 분기점이 됨. @@ -40,7 +40,7 @@ var publishedAtTimeLayouts = []string{ "Jan 2, 2006", } -// ComputeFieldConfidence 는 SelectorMap 의 각 필드별 hit_rate 를 계산합니다 (이슈 #283). +// ComputeFieldConfidence 는 SelectorMap 의 각 필드별 hit_rate 를 계산합니다. // // 단일 sample 환경 — 각 selector 를 html 에 적용 후 매칭 1건 이상이면 hit_rate=1.0, 아니면 0.0. // SampleCount 는 항상 1 (multi-sample 도입 시 분모 증가). @@ -81,7 +81,7 @@ func ComputeFieldConfidence(sm storage.SelectorMap, html string) map[string]stor return out } -// ApplyConfidenceFilter 는 confidence 가 임계값 미만인 필드의 selector 를 nil 로 drop 합니다 (이슈 #283). +// ApplyConfidenceFilter 는 confidence 가 임계값 미만인 필드의 selector 를 nil 로 drop 합니다. // // 신뢰할 수 없는 selector 가 INSERT 되면 하류 parser 가 빈 값을 추출 — host 별 \"본 필드 부재\" 학습 // 으로 이어짐. validator 는 confidence=0 인 필드를 \"부재가 정상\" 으로 판단할 수 있음 (sub-issue). @@ -111,7 +111,7 @@ func ApplyConfidenceFilter(sm storage.SelectorMap, confidence map[string]storage // isFieldHit 는 doc 에 selector 를 적용해 hit 여부를 반환합니다. // -// hit 정의 (PR #293 CodeRabbit Major): +// hit 정의: // - DOM 매칭 1건 이상 // - **추출 결과 (text 또는 attribute) 가 비어있지 않음** — attribute selector 가 attribute // 를 못 찾거나 빈 element 매칭하면 hit 아님 (low-confidence drop 게이트 강화) diff --git a/internal/processor/parser/rule/llmgen/dedup.go b/internal/processor/parser/rule/llmgen/dedup.go index e8d3eec2..4b0e8e19 100644 --- a/internal/processor/parser/rule/llmgen/dedup.go +++ b/internal/processor/parser/rule/llmgen/dedup.go @@ -25,7 +25,7 @@ type inflightKey struct { targetType storage.TargetType } -// InflightLocker 는 (host, targetType) 단위 중복 실행 방지 인터페이스입니다 (이슈 #261). +// InflightLocker 는 (host, targetType) 단위 중복 실행 방지 인터페이스입니다. // // 구현체: // - memInflightLocker: in-process map 기반 (기본값, 단일 인스턴스 환경) @@ -72,7 +72,7 @@ func (m *memInflightLocker) Release(_ context.Context, host string, targetType s } // ───────────────────────────────────────────────────────────────────────────── -// RedisInflightLocker — 분산 Lock 구현 (이슈 #261) +// RedisInflightLocker — 분산 Lock 구현 // ───────────────────────────────────────────────────────────────────────────── // luaRelease 는 소유권 확인 후 삭제하는 Lua 스크립트입니다. diff --git a/internal/processor/parser/rule/llmgen/generator.go b/internal/processor/parser/rule/llmgen/generator.go index 2ec96eb5..661b6312 100644 --- a/internal/processor/parser/rule/llmgen/generator.go +++ b/internal/processor/parser/rule/llmgen/generator.go @@ -1,4 +1,4 @@ -// Package llmgen 은 LLM 으로 parsing rule 을 자동 생성하는 컴포넌트입니다 (이슈 #149). +// Package llmgen 은 LLM 으로 parsing rule 을 자동 생성하는 컴포넌트입니다. // // Package llmgen automatically generates parsing_rules entries via LLM when a host // has no rule registered (rule.ErrNoRule fallback path). @@ -11,11 +11,11 @@ // 5. ParsingRuleRepository.Insert (enabled=true — CSS selector 검증 통과가 품질 게이트) // 6. Resolver.Invalidate 로 negative cache flush — 다음 fetch 부터 새 rule 사용 가능 // -// 본 PR scope (이슈 #149 1차): +// 현재 구현 범위: // - 단일 LLM 호출 + 기본 validation + DB INSERT + cache invalidate // - Best-effort in-process dedup (단일 instance 보호) // -// 후속 PR scope (이슈 TBD 2차): +// 후속 확장 후보: // - 비용 cap (일일 / 시간당 호출 수 제한) // - audit log + Prometheus metric // - 분산 dedup (Redis lock) @@ -41,16 +41,16 @@ import ( ) // LLMAutoSourceName 은 LLM 으로 자동 생성된 rule row 의 source_name 값입니다. -// 운영 대시보드 / 운영자 review 시 hand-tuned rule 과 구분 가능 (이슈 #149). +// 운영 대시보드 / 운영자 review 시 hand-tuned rule 과 구분 가능. // -// 외부 패키지 (예: parser_worker 의 sample 누적 — 이슈 #173 단계 4-1) 도 본 상수를 사용 — -// 동기화 누락 방지 (PR #189 gemini 피드백). +// 외부 패키지 (예: parser_worker 의 sample 누적) 도 본 상수를 사용 — +// 동기화 누락 방지. const LLMAutoSourceName = "llm-auto" // llmAutoDescription 은 자동 생성 row 의 description prefix 입니다. const llmAutoDescription = "auto-generated by llmgen — review before enabling" -// SelectorValidator 는 생성된 셀렉터의 의미 검증 인터페이스입니다 (이슈 #257). +// SelectorValidator 는 생성된 셀렉터의 의미 검증 인터페이스입니다. // DOM 매칭 검증 이후 추가 단계로 실행 — 추출 내용이 실제 뉴스 제목/본문인지 LLM 으로 확인. // nil 이면 의미 검증 건너뜀 (DOM 검증만 수행). type SelectorValidator interface { @@ -63,7 +63,7 @@ type SelectorValidatorResult struct { Reason string } -// SelectorExtractor 는 HTML 에서 CSS 셀렉터를 추출하는 인터페이스입니다 (이슈 #256). +// SelectorExtractor 는 HTML 에서 CSS 셀렉터를 추출하는 인터페이스입니다. // // 기본 구현: pkg/llm.Provider 기반 LLM 호출 (Gemini Flash 등). // 대체 구현: claudegen.ClaudeWorker (Claude Code 웜 컨테이너). @@ -72,7 +72,7 @@ type SelectorExtractor interface { Extract(ctx context.Context, host string, targetType storage.TargetType, html string) (storage.SelectorMap, error) } -// modelNamer 는 추출기가 사용하는 모델 ID 를 반환하는 선택적 인터페이스입니다 (이슈 #256). +// modelNamer 는 추출기가 사용하는 모델 ID 를 반환하는 선택적 인터페이스입니다. // SelectorExtractor 구현체가 이 인터페이스도 구현하면 실제 모델 ID 를 DB description 에 기록합니다. // 구현하지 않으면 fallback "claude-code" 를 사용합니다. type modelNamer interface { @@ -81,7 +81,7 @@ type modelNamer interface { // selectorValidationError 는 validateSelectors 실패를 나타내는 sentinel 타입입니다. // Enqueue goroutine 에서 다른 에러 (LLM API 실패, JSON 파싱 실패 등) 와 구분하여 -// validate 실패일 때만 validateFailureHandler 를 호출하기 위해 사용합니다 (이슈 #237). +// validate 실패일 때만 validateFailureHandler 를 호출하기 위해 사용합니다. type selectorValidationError struct{ cause error } func (e *selectorValidationError) Error() string { @@ -100,12 +100,12 @@ func (e *selectorValidationError) Unwrap() error { return e.cause } // - Stop 후 Enqueue 는 noop (race 안전) type Generator struct { provider llm.Provider - extractor SelectorExtractor // nil 이면 provider 로 fallback (이슈 #256) + extractor SelectorExtractor // nil 이면 provider 로 fallback repo storage.ParsingRuleRepository resolver *rule.Resolver log *logger.Logger - // validateFailureHandler 는 selector 검증 실패 시 호출되는 콜백입니다 (이슈 #237). + // validateFailureHandler 는 selector 검증 실패 시 호출되는 콜백입니다. // 인자: (ctx, ref, llmRetryCount, targetType, crawlerName). // - ref: raw *core.RawContent 의 경량 snapshot — goroutine 내 포인터 공유 없이 값 전달 (Copilot 피드백). // - targetType, crawlerName: Kafka 메시지 헤더 복원에 필요 (gemini/Copilot/CodeRabbit 피드백). @@ -113,21 +113,21 @@ type Generator struct { // nil 이면 호출 안 함. validateFailureHandler func(context.Context, core.RawContentRef, int, storage.TargetType, string) - locker InflightLocker // 기본: memInflightLocker, Redis 활성 시: RedisInflightLocker (이슈 #261) - pendingQueue PendingQueue // nil 이면 pending URL 보존 비활성 (이슈 #262) - requeueFn RequeueFunc // nil 이면 재투입 비활성 (이슈 #262) - semValidator SelectorValidator // nil 이면 의미 검증 건너뜀 (이슈 #257) + locker InflightLocker // 기본: memInflightLocker, Redis 활성 시: RedisInflightLocker + pendingQueue PendingQueue // nil 이면 pending URL 보존 비활성 + requeueFn RequeueFunc // nil 이면 재투입 비활성 + semValidator SelectorValidator // nil 이면 의미 검증 건너뜀 wg sync.WaitGroup stopped atomic.Bool } -// SetValidateFailureHandler 는 selector 검증 실패 시 호출할 콜백을 등록합니다 (이슈 #237). +// SetValidateFailureHandler 는 selector 검증 실패 시 호출할 콜백을 등록합니다. // Stop 전에 설정해야 하며, goroutine-safe 하지 않으므로 초기화 시 1회만 호출합니다. func (g *Generator) SetValidateFailureHandler(fn func(context.Context, core.RawContentRef, int, storage.TargetType, string)) { g.validateFailureHandler = fn } -// SetPendingQueue 는 대기 URL 큐와 재투입 콜백을 등록합니다 (이슈 #262). +// SetPendingQueue 는 대기 URL 큐와 재투입 콜백을 등록합니다. // 둘 다 비-nil 이어야 활성화됩니다. Stop 전에 설정해야 하며, goroutine-safe 하지 않으므로 // 초기화 시 1회만 호출합니다. func (g *Generator) SetPendingQueue(pq PendingQueue, fn RequeueFunc) { @@ -135,7 +135,7 @@ func (g *Generator) SetPendingQueue(pq PendingQueue, fn RequeueFunc) { g.requeueFn = fn } -// SetLocker 는 분산 Lock 구현체를 교체합니다 (이슈 #261). +// SetLocker 는 분산 Lock 구현체를 교체합니다. // nil 이면 기본 in-process memInflightLocker 로 fallback. // Stop 전에 설정해야 하며, goroutine-safe 하지 않으므로 초기화 시 1회만 호출합니다. func (g *Generator) SetLocker(l InflightLocker) { @@ -146,14 +146,14 @@ func (g *Generator) SetLocker(l InflightLocker) { g.locker = l } -// SetSelectorValidator 는 의미 검증기를 등록합니다 (이슈 #257). +// SetSelectorValidator 는 의미 검증기를 등록합니다. // nil 이면 의미 검증 건너뜀. Stop 전에 설정해야 하며, goroutine-safe 하지 않으므로 // 초기화 시 1회만 호출합니다. func (g *Generator) SetSelectorValidator(v SelectorValidator) { g.semValidator = v } -// SetExtractor 는 셀렉터 추출 단계를 교체합니다 (이슈 #256). +// SetExtractor 는 셀렉터 추출 단계를 교체합니다. // nil 이면 기존 provider (Gemini Flash 등) 로 fallback. // Stop 전에 설정해야 하며, goroutine-safe 하지 않으므로 초기화 시 1회만 호출합니다. func (g *Generator) SetExtractor(e SelectorExtractor) { @@ -163,7 +163,7 @@ func (g *Generator) SetExtractor(e SelectorExtractor) { // New 는 Generator 를 생성합니다. // // provider / repo / resolver / log 모두 비-nil 필수. 하나라도 nil 이면 error — -// 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). +// 호출자 (cmd/main) 가 boot fatal 처리. func New(provider llm.Provider, repo storage.ParsingRuleRepository, resolver *rule.Resolver, log *logger.Logger) (*Generator, error) { if provider == nil { return nil, errors.New("llmgen: New requires non-nil provider") @@ -193,9 +193,9 @@ func New(provider llm.Provider, repo storage.ParsingRuleRepository, resolver *ru // // llmRetryCount 는 호출자 (parser_worker) 가 RawContentRef 에서 전달하는 재큐 횟수입니다. // crawlerName 은 Kafka 메시지 헤더 복원용 — validateSelectors 실패 시 재큐 메시지에 포함됩니다. -// jobTimeout 은 원본 crawl job 의 timeout — pending 재투입 시 카테고리 chained job timeout 보존 (이슈 #262 리뷰). +// jobTimeout 은 원본 crawl job 의 timeout — pending 재투입 시 카테고리 chained job timeout 보존. // validateSelectors 실패 시 validateFailureHandler 가 등록되어 있으면 -// (ctx, ref, llmRetryCount, targetType, crawlerName) 으로 호출됩니다 (이슈 #237). +// (ctx, ref, llmRetryCount, targetType, crawlerName) 으로 호출됩니다. // // 동일 (host, type) 에 대한 in-flight 호출이 이미 있으면 즉시 skip. // Stop 호출 후의 Enqueue 는 즉시 noop. @@ -203,7 +203,7 @@ func (g *Generator) Enqueue(ctx context.Context, host string, targetType storage g.enqueueImpl(ctx, host, targetType, raw, llmRetryCount, crawlerName, jobTimeout, false) } -// EnqueueStale 은 stale rule 재학습 모드로 LLM 호출을 비동기 시작합니다 (이슈 #282). +// EnqueueStale 은 stale rule 재학습 모드로 LLM 호출을 비동기 시작합니다. // // 차이점 (vs Enqueue): // - pre-check 의 enabled=true 룰 적중 시 skip 안 함 — v2 학습 진행 @@ -215,7 +215,7 @@ func (g *Generator) EnqueueStale(ctx context.Context, host string, targetType st g.enqueueImpl(ctx, host, targetType, raw, llmRetryCount, crawlerName, jobTimeout, true) } -// enqueueImpl 은 Enqueue / EnqueueStale 의 공통 구현입니다 (이슈 #282). +// enqueueImpl 은 Enqueue / EnqueueStale 의 공통 구현입니다. // stale=true 면 InsertNextVersion 경로 (v2 추가), false 면 기존 Insert 경로 (v1). func (g *Generator) enqueueImpl(ctx context.Context, host string, targetType storage.TargetType, raw *core.RawContent, llmRetryCount int, crawlerName string, jobTimeout time.Duration, stale bool) { if g.stopped.Load() { @@ -257,7 +257,7 @@ func (g *Generator) enqueueImpl(ctx context.Context, host string, targetType sto return } if !acquired { - // in-flight 중 — pending 큐에 적재하여 룰 생성 완료 후 재파싱 (이슈 #262). + // in-flight 중 — pending 큐에 적재하여 룰 생성 완료 후 재파싱. if g.pendingQueue != nil { item := PendingItem{ RawRef: rawRef, @@ -310,7 +310,7 @@ func (g *Generator) enqueueImpl(ctx context.Context, host string, targetType sto "url": urlSnapshot, }).WithError(err).Warn("llmgen rule generation failed") - // selector 검증 실패인 경우에만 재큐 콜백 호출 (이슈 #237). + // selector 검증 실패인 경우에만 재큐 콜백 호출. // rawRef (값 복사) + targetType + crawlerName 전달 — 헤더 복원에 사용. var sve *selectorValidationError if errors.As(err, &sve) && g.validateFailureHandler != nil { @@ -319,10 +319,10 @@ func (g *Generator) enqueueImpl(ctx context.Context, host string, targetType sto return } - // 룰 생성 성공 — pending 대기 URL 을 파서 워커에 재투입 (이슈 #262). + // 룰 생성 성공 — pending 대기 URL 을 파서 워커에 재투입. // flush 는 defer(Release) 실행 전에 수행됨 — flush 중 새로 들어온 URL 은 pending 에 적재되고 // 락 해제 후 새 runOnce 가 다음 flush 에서 처리. - // Kafka publish 실패 항목은 requeueFn 이 반환 → pending 에 재적재 (이슈 #262 리뷰). + // Kafka publish 실패 항목은 requeueFn 이 반환 → pending 에 재적재. if g.pendingQueue != nil && g.requeueFn != nil { items, ferr := g.pendingQueue.Flush(bgCtx, host, targetType) if ferr != nil { @@ -384,11 +384,11 @@ func (g *Generator) Stop(ctx context.Context) { // runOnce 는 단일 추출 + validation + INSERT + cache invalidate 의 동기 실행입니다. // 호출자 (Enqueue 의 goroutine) 가 in-flight 슬롯 release 책임. func (g *Generator) runOnce(ctx context.Context, host string, targetType storage.TargetType, sampleURL, html string, stale bool) error { - // 사전 lookup (이슈 #274) — 동일 자연키 룰이 이미 DB 에 존재하면 LLM 호출 회피. - // PR #275 리뷰 반영: + // 사전 lookup — 동일 자연키 룰이 이미 DB 에 존재하면 LLM 호출 회피. + // // - enabled=true 룰 적중 시에만 skip (resolver 가 enabled=TRUE 만 조회하므로 disabled 는 사실상 부재) // - ErrNotFound 만 normal miss, 그 외 에러는 warn 로그 — DB 장애 silent fallthrough 회피 - // - stale=true (이슈 #282): v2 학습 진행 — pre-check enabled hit 분기에서 skip 안 함 + // - stale=true: v2 학습 진행 — pre-check enabled hit 분기에서 skip 안 함 existing, err := g.repo.FindByNaturalKey(ctx, LLMAutoSourceName, host, "", targetType, 1) switch { case err == nil && existing != nil && existing.Enabled: @@ -432,7 +432,7 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage ) if g.extractor != nil { - // Claude Code 웜 컨테이너 추출 경로 (이슈 #256) + // Claude Code 웜 컨테이너 추출 경로 sm, err := g.extractor.Extract(ctx, host, targetType, html) if err != nil { return fmt.Errorf("claude code extractor: %w", err) @@ -472,7 +472,7 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage return &selectorValidationError{cause: err} } - // 의미 검증 — DOM 매칭 후 추출 내용이 실제로 뉴스 제목/본문인지 LLM 으로 확인 (이슈 #257). + // 의미 검증 — DOM 매칭 후 추출 내용이 실제로 뉴스 제목/본문인지 LLM 으로 확인. if g.semValidator != nil { res, verr := g.semValidator.Validate(ctx, html, selectors, targetType) if verr != nil { @@ -486,7 +486,7 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage } } - // 프로그래매틱 검증 (이슈 #283) — 각 selector 를 sample HTML 에 적용 후 hit_rate 계산. + // 프로그래매틱 검증 — 각 selector 를 sample HTML 에 적용 후 hit_rate 계산. // hit_rate < confidenceThreshold 인 selector 는 nil 로 drop — 신뢰할 수 없는 selector 가 // DB 에 들어가 하류 parser 가 빈 값 / 잘못된 값을 추출하지 않도록. // published_at 은 form 검증 (time.Parse) 통과 시에만 hit 로 카운트. @@ -497,7 +497,7 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage SourceName: LLMAutoSourceName, HostPattern: host, TargetType: targetType, - Version: 1, // stale 모드 시 InsertNextVersion 가 자동으로 max+1 로 덮어씀 (이슈 #282) + Version: 1, // stale 모드 시 InsertNextVersion 가 자동으로 max+1 로 덮어씀 Enabled: true, // CSS selector 검증 통과 = 품질 게이트 — 즉시 활성화하여 pending 재투입이 유효하도록 Selectors: selectors, Confidence: confidence, @@ -505,22 +505,22 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage } var insertErr error if stale { - // stale 재학습: 기존 v1 보존 + v2 추가 (이슈 #282). + // stale 재학습: 기존 v1 보존 + v2 추가. insertErr = g.repo.InsertNextVersion(ctx, record) } else { insertErr = g.repo.Insert(ctx, record) } if err := insertErr; err != nil { // 동일 자연키 (source_name, host_pattern, path_pattern, target_type, version) 룰이 이미 - // DB 에 존재 — 정상 경로로 흡수 (이슈 #274). + // DB 에 존재 — 정상 경로로 흡수. // 발생 시나리오: 이전 실행이 INSERT 한 룰이 잔존하나 resolver lookup 이 stale 캐시 등으로 // 룰을 찾지 못해 generate() 진입한 케이스. 또는 사전 lookup 후 race window 에서 다른 // 인스턴스가 INSERT 완료한 케이스. // - // PR #275 리뷰 반영: enabled 여부 / rule_id 를 로그에 포함하여 운영 가시성 ↑. + // enabled 여부 / rule_id 를 로그에 포함하여 운영 가시성 ↑. // disabled 룰 잔존이면 warn — 운영자가 수동 재활성 결정을 해야 함을 명시. if errors.Is(err, storage.ErrDuplicate) { - // 이슈 #288: cache invalidate 는 invalidatingRepo decorator 가 ErrDuplicate + // cache invalidate 는 invalidatingRepo decorator 가 ErrDuplicate // 시에도 자동 호출 — 본 함수는 순수 로깅 / 분기 책임만. fields := map[string]interface{}{ "host": host, @@ -540,7 +540,7 @@ func (g *Generator) runOnce(ctx context.Context, host string, targetType storage return fmt.Errorf("insert parsing rule: %w", err) } - // 이슈 #288: cache invalidate 는 invalidatingRepo decorator 가 자동 호출. + // cache invalidate 는 invalidatingRepo decorator 가 자동 호출. g.log.WithFields(map[string]interface{}{ "host": host, @@ -571,7 +571,7 @@ func parseSelectorMap(content string) (storage.SelectorMap, error) { // target_type 별 핵심 필드가 0건 매칭이면 selector 는 hallucination 으로 간주, ErrInvalid 반환. // // - TargetTypePage: Title + MainContent 둘 다 1건 이상 매칭 -// - TargetTypeList: ItemContainer + ItemLink 둘 다 1건 이상 매칭 (LinkDiscovery 모드는 본 PR scope 외) +// - TargetTypeList: ItemContainer + ItemLink 둘 다 1건 이상 매칭 (LinkDiscovery 모드는 미적용) func validateSelectors(sm storage.SelectorMap, targetType storage.TargetType, html string) error { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { diff --git a/internal/processor/parser/rule/llmgen/pending.go b/internal/processor/parser/rule/llmgen/pending.go index b7d7156b..4a11ce15 100644 --- a/internal/processor/parser/rule/llmgen/pending.go +++ b/internal/processor/parser/rule/llmgen/pending.go @@ -14,28 +14,28 @@ import ( const pendingKeyPrefix = "llmgen:pending:" // defaultPendingTTL 은 pending LIST 키의 기본 TTL 입니다. -// rule 생성이 계속 실패해도 Redis 메모리가 무기한 증가하지 않도록 제한합니다 (이슈 #262 리뷰). +// rule 생성이 계속 실패해도 Redis 메모리가 무기한 증가하지 않도록 제한합니다. const defaultPendingTTL = 24 * time.Hour // defaultPendingMaxLen 은 (host, targetType) 당 pending LIST 의 최대 항목 수입니다. -// noisy host 하나가 Redis 메모리를 독점하지 않도록 상한을 둡니다 (이슈 #262 리뷰). +// noisy host 하나가 Redis 메모리를 독점하지 않도록 상한을 둡니다. const defaultPendingMaxLen = 1000 -// PendingItem 은 in-flight 중 대기 중인 URL 의 재투입에 필요한 정보입니다 (이슈 #262). +// PendingItem 은 in-flight 중 대기 중인 URL 의 재투입에 필요한 정보입니다. type PendingItem struct { RawRef core.RawContentRef `json:"raw_ref"` CrawlerName string `json:"crawler_name"` LLMRetryCount int `json:"llm_retry_count"` TargetType storage.TargetType `json:"target_type"` - // TimeoutMs 는 원본 crawl job 의 timeout — 카테고리 재투입 시 chained job timeout 보존 (이슈 #262 리뷰). + // TimeoutMs 는 원본 crawl job 의 timeout — 카테고리 재투입 시 chained job timeout 보존. TimeoutMs int64 `json:"timeout_ms"` } -// RequeueFunc 는 pending 대기 URL 목록을 파서 워커에 재투입하는 콜백 타입입니다 (이슈 #262). +// RequeueFunc 는 pending 대기 URL 목록을 파서 워커에 재투입하는 콜백 타입입니다. // Kafka 발행에 실패한 항목을 반환하면 Generator 가 pending queue 에 재적재합니다. type RequeueFunc func(ctx context.Context, items []PendingItem) (failed []PendingItem) -// PendingQueue 는 (host, targetType) 단위 대기 URL 목록을 저장/조회하는 인터페이스입니다 (이슈 #262). +// PendingQueue 는 (host, targetType) 단위 대기 URL 목록을 저장/조회하는 인터페이스입니다. type PendingQueue interface { // Push 는 대기 항목을 큐에 적재합니다. Push(ctx context.Context, host string, targetType storage.TargetType, item PendingItem) error @@ -44,7 +44,7 @@ type PendingQueue interface { } // ───────────────────────────────────────────────────────────────────────────── -// RedisPendingQueue — Redis LIST 기반 구현 (이슈 #262) +// RedisPendingQueue — Redis LIST 기반 구현 // ───────────────────────────────────────────────────────────────────────────── // luaFlush 는 LRANGE + DEL 을 원자적으로 수행하는 Lua 스크립트입니다. @@ -73,7 +73,7 @@ func NewRedisPendingQueue(rdb *goredis.Client) *RedisPendingQueue { func (r *RedisPendingQueue) Push(ctx context.Context, host string, targetType storage.TargetType, item PendingItem) error { key := r.key(host, targetType) - // 길이 상한 초과 시 skip — noisy host 가 Redis 메모리를 독점하지 않도록 (이슈 #262 리뷰). + // 길이 상한 초과 시 skip — noisy host 가 Redis 메모리를 독점하지 않도록. llen, err := r.rdb.LLen(ctx, key).Result() if err != nil && err != goredis.Nil { return fmt.Errorf("redis pending llen %s: %w", key, err) @@ -87,7 +87,7 @@ func (r *RedisPendingQueue) Push(ctx context.Context, host string, targetType st return fmt.Errorf("marshal pending item: %w", err) } - // RPUSH + EXPIRE 를 pipeline 으로 묶어 TTL 갱신 보장 (이슈 #262 리뷰). + // RPUSH + EXPIRE 를 pipeline 으로 묶어 TTL 갱신 보장. pipe := r.rdb.Pipeline() pipe.RPush(ctx, key, data) pipe.Expire(ctx, key, r.ttl) diff --git a/internal/processor/parser/rule/llmgen/prompt.go b/internal/processor/parser/rule/llmgen/prompt.go index 4afeb40f..d5296c7c 100644 --- a/internal/processor/parser/rule/llmgen/prompt.go +++ b/internal/processor/parser/rule/llmgen/prompt.go @@ -12,7 +12,7 @@ import ( // LLM context window 와 비용을 절약하기 위해 큰 페이지는 앞부분만 첨부합니다. // HTML 의 핵심 구조 (,
,
, top-level container) 는 보통 앞쪽에 // 위치하므로 selector 추출에는 충분. 너무 짧으면 동적 로드되는 본문 부분을 놓치므로 -// 32KB 가 합리적 baseline (이슈 #149). +// 32KB 가 합리적 baseline. const promptMaxHTMLBytes = 32 * 1024 // BuildPrompt 는 host + target_type + 샘플 HTML 로 LLM 시스템/사용자 프롬프트를 생성합니다. diff --git a/internal/processor/parser/rule/llmgen/stale_counter.go b/internal/processor/parser/rule/llmgen/stale_counter.go index 6771ccd9..dd933182 100644 --- a/internal/processor/parser/rule/llmgen/stale_counter.go +++ b/internal/processor/parser/rule/llmgen/stale_counter.go @@ -15,7 +15,7 @@ import ( "issuetracker/pkg/logger" ) -// StaleCounter 는 stale rule 발생을 (host, target_type) 단위 sliding window 로 카운팅합니다 (이슈 #282). +// StaleCounter 는 stale rule 발생을 (host, target_type) 단위 sliding window 로 카운팅합니다. // // 기존 fetcher 의 FailureCounter (chromedp 업그레이드용) 와 별개의 keyspace / 임계값 보유: // - 임계값: STALE_RELEARN_THRESHOLD (default 10) — chromedp 업그레이드보다 높은 임계 @@ -96,7 +96,7 @@ func (r *redisStaleCounter) Record(ctx context.Context, host string, t storage.T // member 는 unique 보장용 — ns + 8 bytes random hex. // rand.Read 실패 시 nonce 가 빈 문자열이 되면 동일 ns 의 동시 record 가 같은 member 가 되어 - // ZADD 한 쪽이 무시됨 → 카운트 누락. 명시적으로 error 전파 (PR #294 gemini 피드백). + // ZADD 한 쪽이 무시됨 → 카운트 누락. 명시적으로 error 전파. nonce, err := randStaleNonce() if err != nil { return 0, false, fmt.Errorf("stale counter nonce for (%s, %s): %w", host, t, err) @@ -137,7 +137,7 @@ func (r *redisStaleCounter) Record(ctx context.Context, host string, t storage.T // randStaleNonce 는 ZADD member unique 변별자용 random hex 를 생성합니다. // rand.Read 실패 시 error 반환 — 호출자가 카운팅 자체를 포기 (빈 nonce fallback 은 동시 record -// 시 member 충돌을 유발하므로 회피, PR #294 gemini 피드백). +// 시 member 충돌을 유발하므로 회피). func randStaleNonce() (string, error) { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { diff --git a/internal/processor/parser/rule/llmgen/wiring/wiring.go b/internal/processor/parser/rule/llmgen/wiring/wiring.go index f60b8eff..8f3ed1b8 100644 --- a/internal/processor/parser/rule/llmgen/wiring/wiring.go +++ b/internal/processor/parser/rule/llmgen/wiring/wiring.go @@ -1,5 +1,5 @@ // Package wiring 은 cmd/* 바이너리가 환경 의존성 (config / Redis 등) 을 주입하여 llmgen.Generator 를 -// 구성하는 헬퍼를 제공합니다 (이슈 #276 / PR #277 리뷰). +// 구성하는 헬퍼를 제공합니다. // // llmgen 도메인 패키지는 인프라 설정 (config / Redis raw client 등) 에 의존하지 않으며, 본 wiring // 서브패키지가 그 결합을 흡수 — 도메인 로직과 인프라 wiring 의 분리. @@ -16,12 +16,12 @@ import ( "issuetracker/pkg/redis" ) -// Build 는 LLM provider + repository + resolver 로 llmgen.Generator 를 구성합니다 (이슈 #149). +// Build 는 LLM provider + repository + resolver 로 llmgen.Generator 를 구성합니다. // // provider 는 호출자가 직접 주입 — 일반적으로 pkg/llm/wiring.BuildProvider 결과를 그대로 전달. // provider 가 nil (LLM 비활성) 이면 (nil, nil) 반환 — parser worker 는 ErrNoRule 시 raw 만 잔존. -// llmgen.New 자체 실패는 wiring 버그 — 호출자 (main) 에서 Fatal 결정 (PR #277 리뷰). -// redisClient 가 nil 이면 in-process memInflightLocker 로 graceful degrade (이슈 #261). +// llmgen.New 자체 실패는 wiring 버그 — 호출자 (main) 에서 Fatal 결정. +// redisClient 가 nil 이면 in-process memInflightLocker 로 graceful degrade. func Build(provider llm.Provider, repo storage.ParsingRuleRepository, resolver *rule.Resolver, redisClient *redis.Client, log *logger.Logger) (*llmgen.Generator, error) { if provider == nil { return nil, nil diff --git a/internal/processor/parser/rule/parser.go b/internal/processor/parser/rule/parser.go index 6282cf23..78ec27a5 100644 --- a/internal/processor/parser/rule/parser.go +++ b/internal/processor/parser/rule/parser.go @@ -21,7 +21,7 @@ import ( // ctx 에 이미 더 짧은 deadline 이 있으면 그것이 우선 (context.WithTimeout 이 합성). const resolveTimeout = 5 * time.Second -// Parser 는 DB 기반 파싱 규칙으로 동작하는 단일 page parser engine 입니다 (이슈 #100). +// Parser 는 DB 기반 파싱 규칙으로 동작하는 단일 page parser engine 입니다. // // Parser implements both parser.ContentParser and parser.LinkListParser, driven by // storage.ParsingRuleRecord resolved per request via Resolver. 사이트별 hardcode 파서 @@ -33,12 +33,12 @@ const resolveTimeout = 5 * time.Second // stateless / goroutine-safe — 모든 worker 가 단일 인스턴스 공유 가능. type Parser struct { resolver *Resolver - discovery *PageLinkDiscovery // 이슈 #139 — full-page link discovery + discovery *PageLinkDiscovery // full-page link discovery dateLayouts []string // PublishedAt try-list (앞쪽 우선) } // NewParser 는 Resolver 를 사용하는 Parser 를 생성합니다. -// resolver 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). +// resolver 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리. func NewParser(resolver *Resolver) (*Parser, error) { if resolver == nil { return nil, errors.New("rule: NewParser requires non-nil resolver") @@ -78,7 +78,7 @@ func (p *Parser) ParsePage(ctx context.Context, raw *core.RawContent) (*parser.P return nil, err } - // 호출자 ctx 의 cancel/trace metadata 를 보존하면서 추가 timeout 안전망 적용 (Gemini #3). + // 호출자 ctx 의 cancel/trace metadata 를 보존하면서 추가 timeout 안전망 적용. resolveCtx, cancel := context.WithTimeout(ctx, resolveTimeout) defer cancel() rule, err := p.resolver.ResolveByURL(resolveCtx, raw.URL, storage.TargetTypePage) @@ -114,7 +114,7 @@ func (p *Parser) ParsePage(ctx context.Context, raw *core.RawContent) (*parser.P PublishedAt: p.extractDate(doc, rule.Selectors.PublishedAt), } - // Title 도 MainContent 와 동등한 필수 — selector 는 있지만 추출 결과 빈 경우도 stale 진단 (Gemini #5). + // Title 도 MainContent 와 동등한 필수 — selector 는 있지만 추출 결과 빈 경우도 stale 진단. if page.Title == "" || page.MainContent == "" { return nil, &Error{ Code: ErrParseFailure, @@ -129,7 +129,7 @@ func (p *Parser) ParsePage(ctx context.Context, raw *core.RawContent) (*parser.P // ParseLinks 는 RawContent 의 링크-허브 페이지를 LinkItem 슬라이스로 파싱합니다 // (parser.LinkListParser 구현). // -// 모드 분기 (이슈 #139): +// 모드 분기: // - rule.Selectors.LinkDiscovery.ArticleURLPattern 이 설정 → full-page discovery // (페이지 전체 + URL pattern 필터). 사이드바 / 추천 / 관련 기사 포함. // - 그렇지 않으면 → 기존 ItemContainer 경로 (정확한 컨테이너 기반 추출). @@ -154,7 +154,7 @@ func (p *Parser) ParseLinks(ctx context.Context, raw *core.RawContent) ([]parser return nil, err } - // LinkDiscovery 모드 (이슈 #139, #148) — opt-in. + // LinkDiscovery 모드 — opt-in. // LinkDiscovery 객체 자체가 채워져 있으면 discovery 경로 (ArticleURLPattern 빈 문자열도 허용 — all-pass). // LinkDiscovery 가 nil 일 때만 ItemContainer fallback. if cfg := rule.Selectors.LinkDiscovery; cfg != nil { diff --git a/internal/processor/parser/rule/pathinfer/llm.go b/internal/processor/parser/rule/pathinfer/llm.go index 113331ac..78fc769c 100644 --- a/internal/processor/parser/rule/pathinfer/llm.go +++ b/internal/processor/parser/rule/pathinfer/llm.go @@ -8,7 +8,7 @@ import ( "strings" ) -// LLMClient 는 InferLLM 이 사용하는 최소 LLM 호출 인터페이스입니다 (이슈 #173 단계 3). +// LLMClient 는 InferLLM 이 사용하는 최소 LLM 호출 인터페이스입니다. // // pathinfer 가 pkg/llm 을 직접 import 하지 않도록 작은 abstraction — // 호출자 (단계 4 의 hybrid 흐름) 가 pkg/llm.Provider 위에 adapter 를 만들어 주입합니다. @@ -34,7 +34,7 @@ type LLMSamples struct { // llmSystemPrompt 는 모든 InferLLM 호출에 공통으로 사용되는 system 프롬프트입니다. // // 응답 형식 강제 — markdown 펜스 / prose 없이 단일 RE2 패턴 라인만 반환하도록 지시. -// 본 PR scope: pathinfer 패키지 안 inline 상수. 이슈 #171 (프롬프트 외부 파일) 머지 후 그곳으로 이전 가능. +// pathinfer 패키지 안 inline 상수. 그곳으로 이전 가능. const llmSystemPrompt = `You are an expert at writing URL path regular expressions. Strict rules: @@ -56,7 +56,7 @@ Non-article URLs (negative — must NOT match): Respond with ONLY the regex pattern.` -// InferLLM 은 LLMClient 를 사용해 path_pattern regex 를 추론합니다 (이슈 #173 단계 3). +// InferLLM 은 LLMClient 를 사용해 path_pattern regex 를 추론합니다. // // 흐름: // 1. samples.Articles 가 cfg.minSamples 미만이면 ("", false, nil) — pathinfer.InferHeuristic 과 동일 정책 @@ -123,11 +123,11 @@ func joinOrNone(items []string) string { return b.String() } -// extractPattern 은 LLM 응답에서 첫 번째 RE2 패턴 라인을 추출합니다 (이슈 #173 단계 3). +// extractPattern 은 LLM 응답에서 첫 번째 RE2 패턴 라인을 추출합니다. // // 처리 우선순위: // 1. markdown 코드 펜스 (```...```) 가 응답 어느 위치에든 있으면 → 펜스 내부 첫 비어있지 않은 라인 우선 사용 -// (LLM 이 prose 먼저 출력 후 펜스로 regex 를 감싸는 케이스 cover — PR #187 CodeRabbit 피드백) +// (LLM 이 prose 먼저 출력 후 펜스로 regex 를 감싸는 케이스 cover) // 2. 펜스 없으면 응답 첫 비어있지 않은 라인 사용 // 3. 라인 trim (whitespace 제거) // 4. 빈 결과 → "" @@ -144,7 +144,7 @@ func extractPattern(resp string) string { line := strings.TrimSpace(raw) if strings.HasPrefix(line, "```") { // single-line fence — ```pattern``` 형식 — 안의 패턴을 즉시 추출 - // (PR #187 gemini 피드백, ``` 가 한 라인에 양쪽으로 있는 경우). + // (``` 가 한 라인에 양쪽으로 있는 경우). if strings.HasSuffix(line, "```") && len(line) > 6 { if inner := strings.TrimSpace(line[3 : len(line)-3]); inner != "" { return inner @@ -169,7 +169,7 @@ func extractPattern(resp string) string { return "" } -// validateLLMResult 는 LLM 응답 regex 가 모든 검증 단계를 통과하는지 확인합니다 (이슈 #173 단계 3). +// validateLLMResult 는 LLM 응답 regex 가 모든 검증 단계를 통과하는지 확인합니다. // // 검증 단계: // 1. RE2 컴파일 가능 diff --git a/internal/processor/parser/rule/pathinfer/pathinfer.go b/internal/processor/parser/rule/pathinfer/pathinfer.go index 34b25180..9a9e4da0 100644 --- a/internal/processor/parser/rule/pathinfer/pathinfer.go +++ b/internal/processor/parser/rule/pathinfer/pathinfer.go @@ -1,5 +1,5 @@ // Package pathinfer 는 같은 호스트의 sample URL 들로부터 path_pattern regex 를 -// 알고리즘 기반으로 추론합니다 (이슈 #173 단계 2). +// 알고리즘 기반으로 추론합니다. // // 본 패키지는 **LLM 의존 없는 결정적 휴리스틱** 만 다룹니다 — 70-80% 의 단순 ID // 패턴 (numeric/UUID/slug/연도-월) 케이스를 cover. 모호한 케이스는 ok=false 로 @@ -66,7 +66,7 @@ type PathSamples struct { Articles []string } -// InferHeuristic 은 PathSamples 로부터 path_pattern regex 를 추론합니다 (이슈 #173 단계 2). +// InferHeuristic 은 PathSamples 로부터 path_pattern regex 를 추론합니다. // // opts 로 동작 override 가능 — WithMinSamples 등 (운영자 환경변수 주입용). // 미지정 시 default config (DefaultMinSamples = 3) 적용. @@ -132,7 +132,6 @@ func InferHeuristic(samples PathSamples, opts ...Option) (string, bool) { // 검증: 입력 samples 전체가 결과 regex 에 매칭되는지 확인 — hallucination 방어. // splitSegments 가 leading/trailing slash 를 trim 후 분리했으므로, 검증도 정규화된 // 형태 ("/" + trimmed) 로 매칭 — 그렇지 않으면 trailing slash 있는 path 는 자체 거부됨 - // (PR #183 gemini 피드백). re, err := regexp.Compile(result) if err != nil { return "", false @@ -181,7 +180,7 @@ func inferVariablePattern(values []string) (string, bool) { case allMatch(values, regexUUID): return `([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, true case allMatch(values, regexYear): - // 외곽 capturing group 으로 4자리 연도 전체를 capture (PR #183 gemini 피드백) + // 외곽 capturing group 으로 4자리 연도 전체를 capture // — 다른 패턴 (numeric/UUID/slug/month) 과 capture 일관성 보장. return `((19|20)\d{2})`, true case allMatch(values, regexMonth): diff --git a/internal/processor/parser/rule/refiner/llm_adapter.go b/internal/processor/parser/rule/refiner/llm_adapter.go index aae87cdf..aa6c3755 100644 --- a/internal/processor/parser/rule/refiner/llm_adapter.go +++ b/internal/processor/parser/rule/refiner/llm_adapter.go @@ -8,7 +8,7 @@ import ( "issuetracker/pkg/llm" ) -// providerAdapter 는 pkg/llm.Provider 를 pathinfer.LLMClient 로 wrapping 하는 어댑터입니다 (이슈 #173 단계 4-2). +// providerAdapter 는 pkg/llm.Provider 를 pathinfer.LLMClient 로 wrapping 하는 어댑터입니다. // // pathinfer 패키지의 LLMClient 인터페이스는 system + user 두 string 만 받음 — 호출자가 pkg/llm 의 // Request / Response 타입을 직접 다루지 않도록 분리됨. 본 adapter 가 두 string 을 llm.Request 로 @@ -19,7 +19,7 @@ type providerAdapter struct { // NewLLMAdapter 는 pkg/llm.Provider 를 pathinfer.LLMClient 로 변환하는 어댑터를 반환합니다. // -// provider 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). +// provider 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리. func NewLLMAdapter(provider llm.Provider) (pathinfer.LLMClient, error) { if provider == nil { return nil, errors.New("refiner: NewLLMAdapter requires non-nil provider") diff --git a/internal/processor/parser/rule/refiner/metrics.go b/internal/processor/parser/rule/refiner/metrics.go index 99b6f486..7cf138e2 100644 --- a/internal/processor/parser/rule/refiner/metrics.go +++ b/internal/processor/parser/rule/refiner/metrics.go @@ -6,7 +6,7 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -// Metrics 는 refiner 의 Prometheus collector 모음입니다 (PR #191 피드백, 이슈 #190 후속). +// Metrics 는 refiner 의 Prometheus collector 모음입니다. // // nil-Metrics 또는 nil 내부 collector 는 모든 Record* 메소드가 noop — // 호출자는 nil 검사 없이 항상 호출 가능. NewMetrics(nil) 또는 NewMetrics(registry) 둘 다 허용. diff --git a/internal/processor/parser/rule/refiner/refiner.go b/internal/processor/parser/rule/refiner/refiner.go index 9d0fad66..9f3f4caa 100644 --- a/internal/processor/parser/rule/refiner/refiner.go +++ b/internal/processor/parser/rule/refiner/refiner.go @@ -1,5 +1,5 @@ // Package refiner 는 catch-all + llm-auto rule 의 누적 sample URL 로부터 path_pattern 을 -// 추론하여 자동 갱신하는 점진적 정밀화 워크플로를 구현합니다 (이슈 #173 단계 4-2). +// 추론하여 자동 갱신하는 점진적 정밀화 워크플로를 구현합니다. // // 흐름 (Run goroutine 1회 cycle): // 1. ParsingRuleRepository.List(SourceName='llm-auto', OnlyEnabled=true) 로 후보 rule 조회. @@ -11,7 +11,7 @@ // d. 실패 + LLMClient != nil 이면 pathinfer.InferLLM(samples) 시도 — 성공하면 llm 방식 채택. // e. 결과 regex 가 비어있으면 skip (다음 polling 에서 재시도 — sample 누적 추가 후 재평가). // f. ParsingRuleRepository.InsertNextVersion — 기존 catch-all (v1) 보존 + 정밀 path_pattern 으로 -// 새 version (v2) INSERT (이슈 #282 Phase 2). v2 가 매칭 안 되는 path 는 v1 (catch-all) +// 새 version (v2) INSERT. v2 가 매칭 안 되는 path 는 v1 (catch-all) // 로 fallback — 정밀화 적용 범위가 좁아도 silent miss 없음. // g. resolver.Invalidate(host, type) — cache flush (다음 lookup 부터 갱신된 rule 적용). // h. SampleURLRepository.Purge(rule.ID) — 기존 catch-all (v1) 의 sample 정리 (다음 cycle 에서 재누적). @@ -50,7 +50,7 @@ const DefaultMinSamples = 5 // LLMClient 는 nil 허용 — nil 이면 InferLLM 단계 skip (algorithm-only). // 운영자가 LLM 비활성 환경 (REFINEMENT 활성 + LLM_ENABLE=false) 도 동작. // -// Lifecycle (PR #191 피드백): +// Lifecycle: // - Start(ctx) 가 background goroutine 으로 polling 시작 — sync.WaitGroup 으로 추적 // - Stop(ctx) 호출 시 in-flight cycle 의 완료 대기 (graceful shutdown). ctx cancel 시 // 대기 timeout — in-flight 호출은 background ctx 가 아니라 Start 의 ctx 를 사용하므로 @@ -100,14 +100,14 @@ func WithLLMClient(c pathinfer.LLMClient) Option { return func(r *Refiner) { r.llm = c } } -// WithMetrics 는 Prometheus collector 를 주입합니다 (PR #191 피드백). +// WithMetrics 는 Prometheus collector 를 주입합니다. // nil 또는 미지정 시 모든 Record* 호출이 noop — REFINEMENT 활성 + METRICS 비활성 환경 cover. func WithMetrics(m *Metrics) Option { return func(r *Refiner) { r.metrics = m } } // New 는 Refiner 를 생성합니다. rules / samples / resolver / log 가 nil 이면 error — -// 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). LLMClient 만 nil 허용 (algorithm-only 동작). +// 호출자 (cmd/main) 가 boot fatal 처리. LLMClient 만 nil 허용 (algorithm-only 동작). func New( rules storage.ParsingRuleRepository, samples storage.SampleURLRepository, @@ -141,7 +141,7 @@ func New( return r, nil } -// Start 는 background goroutine 으로 polling 을 시작하고 즉시 반환합니다 (PR #191 피드백). +// Start 는 background goroutine 으로 polling 을 시작하고 즉시 반환합니다. // // 첫 호출만 goroutine 을 spawn — 이후 호출은 noop (atomic CAS). Stop 후 호출도 noop. // 호출자는 Stop(ctx) 으로 in-flight cycle 의 완료를 대기 가능. @@ -159,7 +159,7 @@ func (r *Refiner) Start(ctx context.Context) { }() } -// Stop 은 in-flight polling cycle 의 완료를 대기합니다 (graceful shutdown, PR #191 피드백). +// Stop 은 in-flight polling cycle 의 완료를 대기합니다 (graceful shutdown). // // Start 의 ctx 가 이미 cancel 된 상태라면 in-flight RunOnce 가 ctx 전파로 빠르게 종료되어 // Stop 도 즉시 반환. ctx (Stop 인자) 가 cancel 되면 대기 timeout — in-flight 가 길게 걸리는 @@ -291,7 +291,7 @@ func (r *Refiner) refineOne(ctx context.Context, rec *storage.ParsingRuleRecord) return } - // 1) algorithm 우선 시도 → 실패 시 LLM fallback. LLM 호출 에러는 별도 분기 (PR #191 gemini 피드백). + // 1) algorithm 우선 시도 → 실패 시 LLM fallback. LLM 호출 에러는 별도 분기. pattern, method, inferErr := inferPattern(ctx, paths, r.llm, r.minSamples, r.metrics) if inferErr != nil { rlog.WithFields(map[string]interface{}{ @@ -317,7 +317,7 @@ func (r *Refiner) refineOne(ctx context.Context, rec *storage.ParsingRuleRecord) desc := buildDescription(rec.Description, method, len(paths)) - // 이슈 #282 Phase 2: catch-all (v1) 을 보존하고 정밀 path_pattern 으로 새 version (v2) INSERT. + // catch-all (v1) 을 보존하고 정밀 path_pattern 으로 새 version (v2) INSERT. // 기존 UpdatePathPattern 은 v1 의 path_pattern 을 직접 mutation 하여 catch-all 을 잃었음 — // 정밀화 적용 범위 (예: /news/123) 외 path 는 매칭되는 룰이 사라져 silent miss 발생. // InsertNextVersion 은 v1 그대로 두고 v2 추가 — Resolver 가 LENGTH(path_pattern) DESC + version DESC @@ -353,7 +353,7 @@ func (r *Refiner) refineOne(ctx context.Context, rec *storage.ParsingRuleRecord) return } - // 2) cache flush 는 invalidatingRepo decorator 가 InsertNextVersion 성공 후 자동 호출 (이슈 #288). + // 2) cache flush 는 invalidatingRepo decorator 가 InsertNextVersion 성공 후 자동 호출. // refiner 의 명시적 Invalidate 책임 제거 — single source of truth. // 3) sample purge — 기존 catch-all (v1) 의 누적 sample 정리. 다음 cycle 에서 v2 미매칭 path 가 @@ -379,10 +379,10 @@ func (r *Refiner) refineOne(ctx context.Context, rec *storage.ParsingRuleRecord) // - 추론 거부 : ("", "", nil) — algorithm 휴리스틱 실패 + LLM 검증 거부 (호출자가 Debug 로그) // - LLM 호출 에러 : ("", "", err) — network / API 에러 (호출자가 Warn 로그, 다음 cycle 재시도) // -// PR #191 gemini 피드백: LLM 에러를 삼키지 않고 호출자에게 전달 — 운영자가 LLM 장애 / 할당량 +// LLM 에러를 삼키지 않고 호출자에게 전달 — 운영자가 LLM 장애 / 할당량 // 초과 등을 로그로 즉시 인지 가능. // -// PR #191 후속 (metrics): LLM Generate 호출 1건이 발생할 때 metrics.RecordLLMCall 호출 — +// LLM Generate 호출 1건이 발생할 때 metrics.RecordLLMCall 호출 — // success / error 라벨로 운영자가 호출 빈도 + 실패율 추적 가능. metrics nil 허용. func inferPattern(ctx context.Context, paths []string, llm pathinfer.LLMClient, minSamples int, metrics *Metrics) (string, string, error) { opt := pathinfer.WithMinSamples(minSamples) diff --git a/internal/processor/parser/rule/refiner/wiring/wiring.go b/internal/processor/parser/rule/refiner/wiring/wiring.go index deb9b149..6b928ef8 100644 --- a/internal/processor/parser/rule/refiner/wiring/wiring.go +++ b/internal/processor/parser/rule/refiner/wiring/wiring.go @@ -1,5 +1,5 @@ // Package wiring 은 cmd/* 바이너리가 환경 의존성 (config 등) 을 주입하여 refiner.Refiner 를 -// 구성하는 헬퍼를 제공합니다 (이슈 #276 / PR #277 리뷰). +// 구성하는 헬퍼를 제공합니다. // // refiner 도메인 패키지는 pkg/config 에 의존하지 않으며, 본 wiring 서브패키지가 그 결합을 흡수 — // 도메인 로직과 인프라 설정의 분리. @@ -18,14 +18,14 @@ import ( "issuetracker/pkg/logger" ) -// Build 는 RefinementConfig + LLM provider 로 refiner.Refiner 를 구성합니다 (이슈 #173 단계 4-2). +// Build 는 RefinementConfig + LLM provider 로 refiner.Refiner 를 구성합니다. // // 반환값 (nil, nil) 은 정밀화 비활성을 의미 — REFINEMENT_ENABLED=false 인 경우에 한정. -// config load 실패는 (nil, error) — malformed env 가 silent disable 되지 않도록 명시적 에러 (PR #277 CodeRabbit). +// config load 실패는 (nil, error) — malformed env 가 silent disable 되지 않도록 명시적 에러. // LLM provider 는 nil 허용 — algorithm-only 모드로 동작. -// metricsRegistry 는 nil 허용 — METRICS_ADDR 빈 값으로 endpoint 비활성인 환경에서 noop (PR #191 피드백). +// metricsRegistry 는 nil 허용 — METRICS_ADDR 빈 값으로 endpoint 비활성인 환경에서 noop. // -// 에러 반환 정책 (PR #277 리뷰): 도메인 wiring 실패는 main 이 Fatal 결정 — 본 함수는 wrap 후 return. +// 에러 반환 정책: 도메인 wiring 실패는 main 이 Fatal 결정 — 본 함수는 wrap 후 return. func Build( provider llm.Provider, rules storage.ParsingRuleRepository, @@ -36,7 +36,7 @@ func Build( ) (*refiner.Refiner, error) { cfg, err := config.LoadRefinement() if err != nil { - // malformed env 가 silent 로 refiner 를 끄지 않도록 명시적 에러 반환 (PR #277 CodeRabbit). + // malformed env 가 silent 로 refiner 를 끄지 않도록 명시적 에러 반환. // (nil, nil) 은 explicit !cfg.Enabled 경로에 한정 — 호출자가 Fatal 결정. return nil, fmt.Errorf("load refinement config: %w", err) } diff --git a/internal/processor/parser/rule/resolver.go b/internal/processor/parser/rule/resolver.go index e5f24c61..3395c511 100644 --- a/internal/processor/parser/rule/resolver.go +++ b/internal/processor/parser/rule/resolver.go @@ -1,4 +1,4 @@ -// Package rule 은 DB 기반 파싱 규칙 (이슈 #100) 의 resolver 와 단일 parser engine 을 제공합니다. +// Package rule 은 DB 기반 파싱 규칙 의 resolver 와 단일 parser engine 을 제공합니다. // // Package rule provides the URL → Rule resolver and a single rule-driven parser engine // that implements both NewsArticleParser and NewsListParser. 사이트별 hardcode 파서를 @@ -26,20 +26,20 @@ const DefaultCacheTTL = 5 * time.Minute // 양성보다 짧게 — 새 rule 등록 시 빠르게 반영되도록. const DefaultNegativeCacheTTL = 30 * time.Second -// DefaultMaxCacheEntries 는 cache 의 최대 entry 수입니다 (이슈 #100, Gemini #6). +// DefaultMaxCacheEntries 는 cache 의 최대 entry 수입니다. // // 무제한 map 은 호스트 수 폭증 시 OOM 위험. 단순 정책 — 가득 차면 가장 오래된 entry // (만료 임박 순) 를 evict. LRU 가 아니라 expiry-order eviction 이지만 본 패키지의 // 부하 패턴 (소수의 동일 host 반복 lookup) 에는 충분. const DefaultMaxCacheEntries = 10_000 -// Resolver 는 URL 에서 host 를 추출해 storage.ParsingRuleRecord 를 조회합니다 (이슈 #100). +// Resolver 는 URL 에서 host 를 추출해 storage.ParsingRuleRecord 를 조회합니다. // // Resolver maps a URL to its active ParsingRule via host_pattern + target_type. // In-memory cache (TTL based) 로 DB roundtrip 을 줄입니다 — 운영자는 새 rule enabled 후 // 최대 DefaultCacheTTL 만큼 지연을 감수합니다. // -// 이슈 #173 단계 1: cache value 가 단일 rule → rule 슬라이스 (host 매칭 후보들) 로 변경. +// cache value 가 단일 rule → rule 슬라이스 (host 매칭 후보들) 로 변경. // ResolveByURL 이 슬라이스를 받아 application 측에서 URL path 와 path_pattern regex 매칭. // path_pattern=” 인 row 는 catch-all 로 마지막에 위치 (LENGTH DESC 정렬). // @@ -54,17 +54,17 @@ type Resolver struct { cache map[cacheKey]cacheEntry now func() time.Time // 테스트 주입 (실시각 → fake clock) - // hasAnyCache 는 HasAnyRule 결과 (exists, hasEnabled) 를 보관합니다 (이슈 #287). + // hasAnyCache 는 HasAnyRule 결과 (exists, hasEnabled) 를 보관합니다. // negativeCacheTTL 과 동일 짧은 TTL — disabled 룰 토글 / 신규 룰 학습이 빠르게 반영되도록. hasAnyCache map[cacheKey]hasAnyEntry - // regexCache 는 path_pattern 별 compile 결과를 보관합니다 (이슈 #173). + // regexCache 는 path_pattern 별 compile 결과를 보관합니다. // 같은 패턴의 재컴파일을 회피 — 운영 중 동일 host 의 후보 슬라이스가 cache 만료 시마다 // 다시 fetch 되어도 regex 객체는 재사용. sync.Map 으로 lock-free read. regexCache sync.Map // map[string]*compiledPattern } -// hasAnyEntry 는 HasAnyRule 결과 캐시 entry 입니다 (이슈 #287). +// hasAnyEntry 는 HasAnyRule 결과 캐시 entry 입니다. type hasAnyEntry struct { exists bool hasEnabled bool @@ -86,7 +86,7 @@ type cacheKey struct { // cacheEntry 는 lookup 결과를 캐싱합니다. // -// 이슈 #173: 후보 슬라이스를 통째로 보관 — application 측 path 매칭은 매 호출마다 실행 +// 후보 슬라이스를 통째로 보관 — application 측 path 매칭은 매 호출마다 실행 // (cache hit path 안에서 수행). 매칭 비용은 ms 미만 (regex 컴파일은 regexCache 로 분리). type cacheEntry struct { candidates []*storage.ParsingRuleRecord // 빈 슬라이스 = negative cache @@ -117,7 +117,7 @@ func WithMaxCacheEntries(n int) Option { } // NewResolver 는 ParsingRuleRepository 를 사용하는 Resolver 를 생성합니다. -// repo 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). +// repo 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리. func NewResolver(repo storage.ParsingRuleRepository, opts ...Option) (*Resolver, error) { if repo == nil { return nil, errors.New("rule: NewResolver requires non-nil repo") @@ -139,7 +139,7 @@ func NewResolver(repo storage.ParsingRuleRepository, opts ...Option) (*Resolver, // ResolveByURL 은 URL 에서 host + path 를 추출해 매칭 활성 규칙을 반환합니다. // -// 흐름 (이슈 #173): +// 흐름: // 1. URL parse 실패 → ErrInvalidURL // 2. cache hit → 후보 슬라이스에서 path 매칭, 첫 매칭 rule 반환 // 3. cache miss → repo.FindActiveCandidates → cache 후 path 매칭 @@ -159,7 +159,7 @@ func (r *Resolver) ResolveByURL(ctx context.Context, rawURL string, targetType s return r.Resolve(ctx, host, path, targetType) } -// Resolve 는 host + path 를 직접 받아 매칭 활성 규칙을 반환합니다 (이슈 #173). +// Resolve 는 host + path 를 직접 받아 매칭 활성 규칙을 반환합니다. // // host 는 정규화 (lowercase, 포트 제외) 된 상태로 전달 권장. path 는 URL.Path (rawpath 아님) — // 정규화는 호출자 책임. @@ -194,7 +194,7 @@ func (r *Resolver) Resolve(ctx context.Context, host, path string, targetType st return nil, &Error{Code: ErrNoRule, Message: "no rule matched url path", Host: host, URL: path, TargetType: string(targetType)} } -// pathMatches 는 path_pattern 이 path 와 매칭되는지 확인합니다 (이슈 #173). +// pathMatches 는 path_pattern 이 path 와 매칭되는지 확인합니다. // // - pattern=” → 모든 path 매칭 (catch-all) // - pattern 컴파일 결과를 regexCache 에 보관 — 같은 패턴 재컴파일 회피 @@ -210,13 +210,13 @@ func (r *Resolver) pathMatches(pattern, path string) bool { return cp.re.MatchString(path) } -// compileRegex 는 path_pattern 을 컴파일하고 결과를 sync.Map 에 캐시합니다 (이슈 #173). +// compileRegex 는 path_pattern 을 컴파일하고 결과를 sync.Map 에 캐시합니다. // 같은 패턴이 여러 host 또는 cache 만료 후 재fetch 되어도 컴파일은 1회만 발생. func (r *Resolver) compileRegex(pattern string) *compiledPattern { if v, ok := r.regexCache.Load(pattern); ok { return v.(*compiledPattern) } - // regexp.Compile 은 에러 시 re=nil 반환 — 별도 nil 처리 불필요 (PR #181 gemini 피드백). + // regexp.Compile 은 에러 시 re=nil 반환 — 별도 nil 처리 불필요. re, err := regexp.Compile(pattern) cp := &compiledPattern{re: re, err: err} // LoadOrStore 로 race 시 첫 winner 의 결과를 보존 — 같은 패턴 두 goroutine 동시 컴파일도 안전. @@ -226,7 +226,7 @@ func (r *Resolver) compileRegex(pattern string) *compiledPattern { // Invalidate 는 (host, type) 의 cache entry 를 즉시 제거합니다 — 운영자가 rule 변경 직후 호출. // -// 이슈 #287: hasAnyCache 도 함께 invalidate — 룰 INSERT/UPDATE/DELETE 시 enabled 상태 변동 가능. +// hasAnyCache 도 함께 invalidate — 룰 INSERT/UPDATE/DELETE 시 enabled 상태 변동 가능. func (r *Resolver) Invalidate(host string, targetType storage.TargetType) { host = strings.ToLower(host) key := cacheKey{host: host, targetType: targetType} @@ -244,7 +244,7 @@ func (r *Resolver) InvalidateAll() { r.mu.Unlock() } -// HasAnyRule 은 (host, target_type) 룰의 존재 여부 + enabled 여부를 short-TTL 캐시 + DB lookup 으로 반환합니다 (이슈 #287). +// HasAnyRule 은 (host, target_type) 룰의 존재 여부 + enabled 여부를 short-TTL 캐시 + DB lookup 으로 반환합니다. // // 반환: // - exists : enabled / disabled 무관 row 존재 여부 @@ -285,7 +285,7 @@ func (r *Resolver) HasAnyRule(ctx context.Context, host string, targetType stora return exists, hasEnabled, nil } -// lookupCache 는 캐시 조회 결과를 반환합니다 (이슈 #173). +// lookupCache 는 캐시 조회 결과를 반환합니다. // 반환값: (candidates, hit) — hit=true 이면 캐시 적용 (negative cache 는 빈 슬라이스). func (r *Resolver) lookupCache(key cacheKey) ([]*storage.ParsingRuleRecord, bool) { r.mu.RLock() @@ -321,7 +321,7 @@ func (r *Resolver) evictExpiringSoon() { } } -// evictHasAnyExpiringSoon 은 hasAnyCache 가 maxEntries 초과 시 만료 임박 entry 를 제거합니다 (이슈 #287). +// evictHasAnyExpiringSoon 은 hasAnyCache 가 maxEntries 초과 시 만료 임박 entry 를 제거합니다. // evictExpiringSoon 과 동일 정책 — host 폭증 시 OOM 방어. 호출자는 r.mu hold. func (r *Resolver) evictHasAnyExpiringSoon() { if len(r.hasAnyCache) < r.maxEntries { @@ -342,7 +342,7 @@ func (r *Resolver) evictHasAnyExpiringSoon() { } } -// storeCache 는 후보 슬라이스를 저장합니다 (이슈 #173 — 빈 슬라이스 = negative cache). +// storeCache 는 후보 슬라이스를 저장합니다. // maxEntries 초과 시 evictExpiringSoon 으로 가장 만료 임박 entry 제거 후 저장. func (r *Resolver) storeCache(key cacheKey, candidates []*storage.ParsingRuleRecord, ttl time.Duration) { r.mu.Lock() @@ -351,7 +351,7 @@ func (r *Resolver) storeCache(key cacheKey, candidates []*storage.ParsingRuleRec r.mu.Unlock() } -// extractHostPath 는 URL 문자열에서 host (소문자) + path 를 추출합니다 (이슈 #173). +// extractHostPath 는 URL 문자열에서 host (소문자) + path 를 추출합니다. // // host: 포트 제거 + 소문자. // path: URL.Path (raw path 아님 — percent-decoded 표현). 빈 path 는 "/" 로 정규화. diff --git a/internal/processor/parser/rule/seeded.go b/internal/processor/parser/rule/seeded.go index 842eede5..a2db4398 100644 --- a/internal/processor/parser/rule/seeded.go +++ b/internal/processor/parser/rule/seeded.go @@ -26,13 +26,13 @@ var seededHostTargets = []struct { } // VerifySeeded 는 seededHostTargets 의 모든 (host, target_type) 페어가 parsing_rules 테이블에 -// 활성 row 로 존재하는지 확인합니다 (이슈 #276 — main.go 에서 이동). +// 활성 row 로 존재하는지 확인합니다. // // 부재 시 ErrNoRule 등 진단 에러를 그대로 반환 — 호출자가 Fatal 로 부팅 차단. // migration 007 이 적용되어야 통과 (또는 운영자가 동등한 row 를 직접 입력). func VerifySeeded(ctx context.Context, resolver *Resolver) error { for _, r := range seededHostTargets { - // "/" path 로 catch-all (path_pattern='') 매칭 검증 — seed 된 host-only rule 확인 (이슈 #173). + // "/" path 로 catch-all (path_pattern='') 매칭 검증 — seed 된 host-only rule 확인. if _, err := resolver.Resolve(ctx, r.Host, "/", r.TargetType); err != nil { return fmt.Errorf("missing rule for (%s, %s): %w", r.Host, r.TargetType, err) } diff --git a/internal/processor/parser/rule/validator/adapter.go b/internal/processor/parser/rule/validator/adapter.go index 98cf1b76..65a1e5fd 100644 --- a/internal/processor/parser/rule/validator/adapter.go +++ b/internal/processor/parser/rule/validator/adapter.go @@ -7,7 +7,7 @@ import ( "issuetracker/internal/storage" ) -// LLMGenAdapter 는 validator.Pool 을 llmgen.SelectorValidator 인터페이스로 감쌉니다 (이슈 #257). +// LLMGenAdapter 는 validator.Pool 을 llmgen.SelectorValidator 인터페이스로 감쌉니다. // 두 패키지 간 import cycle 없이 연결합니다. type LLMGenAdapter struct { pool *Pool diff --git a/internal/processor/parser/rule/validator/llm_validator.go b/internal/processor/parser/rule/validator/llm_validator.go index d9c265ca..98575936 100644 --- a/internal/processor/parser/rule/validator/llm_validator.go +++ b/internal/processor/parser/rule/validator/llm_validator.go @@ -16,7 +16,7 @@ type llmValidationResponse struct { Reason string `json:"reason"` } -// LLMValidator 는 pkg/llm.Provider 기반 의미 검증 구현체입니다 (이슈 #257). +// LLMValidator 는 pkg/llm.Provider 기반 의미 검증 구현체입니다. type LLMValidator struct { provider llm.Provider } diff --git a/internal/processor/parser/rule/validator/pool.go b/internal/processor/parser/rule/validator/pool.go index b290f739..2f754df6 100644 --- a/internal/processor/parser/rule/validator/pool.go +++ b/internal/processor/parser/rule/validator/pool.go @@ -9,12 +9,12 @@ import ( "issuetracker/pkg/logger" ) -// Pool 은 여러 Validator 를 순서대로 시도하는 복합 검증기입니다 (이슈 #257). +// Pool 은 여러 Validator 를 순서대로 시도하는 복합 검증기입니다. // // 정책: 첫 번째로 응답한 validator 의 결과(valid/invalid)를 반환합니다. // API 오류가 발생한 validator 는 건너뛰고 다음을 시도합니다. // 모든 validator 가 API 오류를 반환하면 best-effort 통과 (룰 INSERT 차단 안 함) — 검증 인프라 장애가 -// rule 생성 자체를 막지 않도록 설계 (이슈 #257 요구사항: 운영 안정성 우선). +// rule 생성 자체를 막지 않도록 설계. type Pool struct { validators []Validator log *logger.Logger diff --git a/internal/processor/parser/rule/validator/validator.go b/internal/processor/parser/rule/validator/validator.go index 60e53552..2fc146d0 100644 --- a/internal/processor/parser/rule/validator/validator.go +++ b/internal/processor/parser/rule/validator/validator.go @@ -1,4 +1,4 @@ -// Package validator 는 LLM 으로 생성된 CSS 셀렉터의 의미 검증을 담당합니다 (이슈 #257). +// Package validator 는 LLM 으로 생성된 CSS 셀렉터의 의미 검증을 담당합니다. // // 역할: DOM 매칭 검증 (validateSelectors) 이 통과한 뒤, 실제 추출 내용이 뉴스 기사의 // 제목·본문·날짜로서 의미상 유효한지 LLM 으로 확인합니다. @@ -59,7 +59,7 @@ func extractContent(html string, sm storage.SelectorMap) (extractedContent, erro ec.Title = strings.TrimSpace(doc.Find(sm.Title.CSS).First().Text()) } if sm.MainContent != nil && sm.MainContent.CSS != "" { - // Multi=false(기본) 이면 첫 번째 매칭만 사용 — 실제 parser 동작과 일치해야 의미 검증이 유효 (이슈 #265 리뷰). + // Multi=false(기본) 이면 첫 번째 매칭만 사용 — 실제 parser 동작과 일치해야 의미 검증이 유효. var body string if sm.MainContent.Multi { var parts []string @@ -95,7 +95,7 @@ func extractContent(html string, sm storage.SelectorMap) (extractedContent, erro ec.ItemContainer = string(runes[:200]) + "…" } // ItemLink 는 ItemContainer 내부에서 수집 — 전체 문서에서 수집하면 네비게이션/푸터 링크가 - // 섞여 잘못된 list rule 이 의미 검증을 통과할 수 있음 (이슈 #265 리뷰). + // 섞여 잘못된 list rule 이 의미 검증을 통과할 수 있음. if sm.ItemLink != nil && sm.ItemLink.CSS != "" { container.Find(sm.ItemLink.CSS).Each(func(i int, s *goquery.Selection) { if i >= 3 { diff --git a/internal/processor/parser/stage/stage.go b/internal/processor/parser/stage/stage.go index f1900d45..bf42529a 100644 --- a/internal/processor/parser/stage/stage.go +++ b/internal/processor/parser/stage/stage.go @@ -1,4 +1,4 @@ -// Package stage 는 parser 단계의 processor.Stage 래퍼를 제공합니다 (이슈 #206). +// Package stage 는 parser 단계의 processor.Stage 래퍼를 제공합니다. // // 본 wrapper 는 별도 sub-package 에 위치 — internal/processor/parser/parser.go 의 Page 타입을 // rule/* 가 import 하므로, parser 부모 패키지가 rule/* 를 import 하면 import cycle 발생. @@ -45,7 +45,7 @@ type Stage struct { } // NewStage 는 component 들을 받아 parser.Stage 를 반환합니다. -// worker / cleaner / log 는 필수 (nil 이면 error), llmGen / refiner 는 nil 허용 (이슈 #208). +// worker / cleaner / log 는 필수 (nil 이면 error), llmGen / refiner 는 nil 허용. func NewStage( pw *worker.ParserWorker, cleaner *worker.RawContentCleaner, @@ -102,7 +102,7 @@ func (s *Stage) Stop(ctx context.Context) error { var firstErr error // 1. ParserWorker — Enqueue source 차단. 에러는 호출자 (main) 에서 stage 별로 일괄 로깅하므로 - // 본 위치에서는 중복 로그 회피 — 단순 first-error 보존만 (PR #207 gemini 피드백). + // 본 위치에서는 중복 로그 회피 — 단순 first-error 보존만. if err := s.worker.Stop(ctx); err != nil { firstErr = err } @@ -118,7 +118,7 @@ func (s *Stage) Stop(ctx context.Context) error { } // 4. RawContentCleaner — 시그니처가 ctx 를 받지 않아 별도 goroutine + select 로 caller 의 - // timeout 을 honor (PR #207 CodeRabbit 피드백). janitor 라 ctx cancel 시 firstErr 만 기록하고 + // timeout 을 honor. janitor 라 ctx cancel 시 firstErr 만 기록하고 // 강제 반환 — 본 stop 은 best-effort. cleanerStopped := make(chan struct{}) go func() { diff --git a/internal/processor/parser/worker/cleanup.go b/internal/processor/parser/worker/cleanup.go index eca488e7..ce7747fe 100644 --- a/internal/processor/parser/worker/cleanup.go +++ b/internal/processor/parser/worker/cleanup.go @@ -15,10 +15,10 @@ const ( // DefaultStaleRawTTL: parser worker 가 처리하지 못한 채 남은 raw_contents row 의 보존 기간. // - // 정책 의도 (이슈 #134): + // 정책 의도: // - 정상 흐름에서는 parser worker 가 즉시 Delete 하므로 raw 가 누적되지 않음 // - 잔존하는 row 는 (1) parser crash, (2) rule.Error 로 잔존, (3) LLM 재처리 대기 윈도우 - // - LLM 자동 rule 생성 (이슈 #149) 후 cleanup 이전에 reprocess 가능해야 의미 있음 + // - LLM 자동 rule 생성 후 cleanup 이전에 reprocess 가능해야 의미 있음 // - 기본 1시간 — LLM rule 생성 + 재처리에 충분, 폭주 시 디스크 보호 DefaultStaleRawTTL = 1 * time.Hour ) @@ -32,12 +32,12 @@ type CleanupConfig struct { } // RawContentCleaner 는 parser worker 가 처리하지 못한 채 잔존한 raw_contents row 를 -// 주기적으로 정리하는 안전망 goroutine 입니다 (이슈 #134). +// 주기적으로 정리하는 안전망 goroutine 입니다. // // 정상 흐름에서는 parser worker 가 처리 직후 raw 를 삭제하므로 본 cleaner 의 동작은 거의 없음. // row 가 남는 케이스: // - parser worker crash (Delete 호출 전) -// - rule.Error 로 raw 잔존 (LLM 재처리 윈도우 — 이슈 #149) +// - rule.Error 로 raw 잔존 (LLM 재처리 윈도우) // - 일시적 DB 에러로 Delete 실패 // // StaleTTL 보다 오래된 row 는 LLM 재처리에 더 이상 의미 없다고 판단하여 정리. diff --git a/internal/processor/parser/worker/parser_worker.go b/internal/processor/parser/worker/parser_worker.go index 532f8fce..d8f9900d 100644 --- a/internal/processor/parser/worker/parser_worker.go +++ b/internal/processor/parser/worker/parser_worker.go @@ -1,4 +1,4 @@ -// Package worker 는 fetcher 와 분리된 parser worker 를 제공합니다 (이슈 #134). +// Package worker 는 fetcher 와 분리된 parser worker 를 제공합니다. // // Package worker provides a parser worker decoupled from fetcher workers. // @@ -12,7 +12,7 @@ // // 실패 정책: // - rule.Error (parse_failure / empty_selector / no_rule): raw 잔존 + Kafka commit (재시도 X) -// → LLM 으로 새 rule 생성 (이슈 #149) 후 cleanup cron 이전에 재처리 가능 +// → LLM 으로 새 rule 생성 후 cleanup cron 이전에 재처리 가능 // - 기타 transient 에러: commit 안 함 → Kafka 재배달 → 재시도 package worker @@ -57,42 +57,42 @@ type ParserWorker struct { contentSvc service.ContentService publisher general.JobPublisher parser *rule.Parser - resolver *rule.Resolver // 이슈 #173 단계 4-1 — sample 누적 시 매칭된 rule lookup + resolver *rule.Resolver // sample 누적 시 매칭된 rule lookup sampleSvc storage.SampleURLRepository // nil 허용 — nil 이면 sample 누적 skip (단계 4-1) procLock locks.ProcessingLock // nil 이면 NoopProcessingLock 사용 (단일 인스턴스 fallback) llmGen *llmgen.Generator // nil 허용 — nil 이면 ErrNoRule 시 raw 잔존만 (LLM auto-rule 비활성) - // failureCounter: host 단위 fetcher 실패 카운터 (이슈 #220). + // failureCounter: host 단위 fetcher 실패 카운터. // nil 시 noopFailureCounter 로 fallback — 카운팅 자체 비활성. // rule.ErrParseFailure / rule.ErrEmptySelector 또는 빈본문 발생 시 Record 호출. failureCounter fetcherRule.FailureCounter - // rawIDTracker: 같은 실패 시점에 host 별 raw_id 추적 (이슈 #221). + // rawIDTracker: 같은 실패 시점에 host 별 raw_id 추적. // nil 시 noopRawIDTracker — republish 비활성 (단계 3 trigger 가 빈 raw_id 받음). rawIDTracker fetcherRule.RawIDTracker - // upgrader: 임계값 도달 시 chromedp 자동 전환 + 실패 raw republish 트리거 (이슈 #221). + // upgrader: 임계값 도달 시 chromedp 자동 전환 + 실패 raw republish 트리거. // nil 허용 — nil 이면 thresholdReached 신호 발신만 (자동 전환 비활성). upgrader *fetcherRule.Upgrader emptyBodyTitleMin int emptyBodyContentMin int - // guard: PipelineGuard — Category cycle 종료 시 Release 호출용 (이슈 #285). + // guard: PipelineGuard — Category cycle 종료 시 Release 호출용. // nil 허용 — nil 이면 release skip (TTL fallback 으로 자동 회수). guard PipelineGuard - // blacklist: page-parse 블랙리스트 Matcher (이슈 #295). + // blacklist: page-parse 블랙리스트 Matcher. // nil 허용 — nil 이면 차단 비활성 (모든 카테고리 링크가 그대로 발행). // processCategoryPage 의 publisher.Publish 직전에 Filter 호출. blacklist *rule.BlacklistMatcher - // staleCounter: stale rule 재학습 트리거 카운터 (이슈 #282). + // staleCounter: stale rule 재학습 트리거 카운터. // nil 허용 — nil 이면 stale 재학습 비활성 (chromedp 자동 전환만 동작). // ErrParseFailure / ErrEmptySelector 발생 시 Record 호출, threshold 도달 시 // llmGen.EnqueueStale 트리거. staleCounter llmgen.StaleCounter - // staleThreshold: window 내 stale failure 임계값 (이슈 #282, PR #294 CodeRabbit 피드백). + // staleThreshold: window 내 stale failure 임계값. // 임계 도달 첫 회 (count == staleThreshold) 만 enqueue — count > threshold 인 후속 호출은 // 카운트만 누적하고 enqueue 회피 (window 내 동일 host 의 version churn / 비용 spike 방지). staleThreshold int @@ -103,7 +103,7 @@ type ParserWorker struct { wg sync.WaitGroup } -// PipelineGuard 는 Category cycle 종료 시 marker 를 release 하기 위한 최소 인터페이스입니다 (이슈 #285). +// PipelineGuard 는 Category cycle 종료 시 marker 를 release 하기 위한 최소 인터페이스입니다. // // parser_worker 는 release 만 필요하므로 locks.PipelineGuard 의 전체 surface 가 아닌 Release 만 // 노출 — interface segregation 원칙. locks.PipelineGuard 는 구조적 타이핑으로 본 인터페이스 만족. @@ -115,15 +115,15 @@ type PipelineGuard interface { // // - publisher 는 nil 허용 — nil 이면 카테고리 chained jobs 발행 건너뜀 (이런 모드는 보통 운영 금지) // - procLock 은 nil 허용 — nil 이면 NoopProcessingLock 으로 fallback (단일 인스턴스 환경에서 dedup 비활성) -// - llmGen 은 nil 허용 — nil 이면 ErrNoRule 시 raw 잔존만 (LLM auto-rule 비활성, 이슈 #149) -// - resolver / sampleSvc 는 nil 허용 — nil 이면 sample 누적 skip (이슈 #173 단계 4-1, 정밀화 워크플로 비활성) -// - failureCounter 는 nil 허용 — nil 이면 NoopFailureCounter 로 fallback (이슈 #220 카운팅 비활성) -// - rawIDTracker 는 nil 허용 — nil 이면 NoopRawIDTracker 로 fallback (이슈 #221 republish 비활성) +// - llmGen 은 nil 허용 — nil 이면 ErrNoRule 시 raw 잔존만 (LLM auto-rule 비활성) +// - resolver / sampleSvc 는 nil 허용 — nil 이면 sample 누적 skip +// - failureCounter 는 nil 허용 — nil 이면 NoopFailureCounter 로 fallback +// - rawIDTracker 는 nil 허용 — nil 이면 NoopRawIDTracker 로 fallback // -// 이슈 #161 (도메인 중립화) 이후 news_articles 도메인 특화 보존은 제거됐습니다 — 모든 article +// 도메인 중립화 이후 news_articles 도메인 특화 보존은 제거됐습니다 — 모든 article // 결과는 contentSvc.Store 로 contents 단일 테이블에 저장됩니다. // -// 이슈 #178: ProcessingLock 으로 fetcher / parser / validator 가 동일 인터페이스로 단계별 dedup. +// ProcessingLock 으로 fetcher / parser / validator 가 동일 인터페이스로 단계별 dedup. // parser 단계는 raw.URL 단위로 acquire — Kafka rebalance 시 같은 raw 가 두 worker 에 도달해도 1회만 파싱. func NewParserWorker( consumer *queue.KafkaConsumer, @@ -177,13 +177,13 @@ func NewParserWorker( } } -// SetPipelineGuard 는 Category cycle 완료 시 marker release 용 PipelineGuard 를 주입합니다 (이슈 #285). +// SetPipelineGuard 는 Category cycle 완료 시 marker release 용 PipelineGuard 를 주입합니다. // nil 주입 시 release 비활성 (TTL fallback). Start 호출 전 wiring 단계에서 1회 설정. func (w *ParserWorker) SetPipelineGuard(g PipelineGuard) { w.guard = g } -// SetBlacklist 는 page-parse 블랙리스트 Matcher 를 주입합니다 (이슈 #295). +// SetBlacklist 는 page-parse 블랙리스트 Matcher 를 주입합니다. // // nil 주입 시 차단 비활성 (모든 카테고리 링크가 그대로 article job 으로 발행). // Start 호출 전 wiring 단계에서 1회 설정. @@ -191,7 +191,7 @@ func (w *ParserWorker) SetBlacklist(b *rule.BlacklistMatcher) { w.blacklist = b } -// SetStaleCounter 는 stale rule 재학습 트리거 카운터 + 임계값을 주입합니다 (이슈 #282). +// SetStaleCounter 는 stale rule 재학습 트리거 카운터 + 임계값을 주입합니다. // // nil counter 주입 시 stale 재학습 비활성 (기존 chromedp 자동 전환만 동작). // threshold <= 0 이면 threshold-crossing gate 비활성 — counter.Record 의 thresholdReached 만으로 트리거 @@ -287,7 +287,7 @@ func (w *ParserWorker) processMessage(ctx context.Context, msg *queue.Message) e "url": ref.URL, }) - // 이슈 #178: parser 단계 ProcessingLock — 같은 URL 의 동시 파싱을 차단. + // parser 단계 ProcessingLock — 같은 URL 의 동시 파싱을 차단. // Kafka rebalance / 재배달 시 같은 raw 가 두 parser worker 에 도달해도 1회만 처리. // ref.URL 은 fetcher 가 정규화한 URL — Ingestion Lock 키와 같은 정규형 사용. procKey := locks.ProcessingKey(locks.StageParser, ref.URL) @@ -300,7 +300,7 @@ func (w *ParserWorker) processMessage(ctx context.Context, msg *queue.Message) e return nil } else { defer func() { - // 셧다운 시 ctx cancel 되어도 락 해제 보장 + trace ID 등 메타데이터 보존 (PR #180 gemini 피드백). + // 셧다운 시 ctx cancel 되어도 락 해제 보장 + trace ID 등 메타데이터 보존. releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() if releaseErr := w.procLock.Release(releaseCtx, procKey); releaseErr != nil { @@ -337,7 +337,7 @@ func (w *ParserWorker) processMessage(ctx context.Context, msg *queue.Message) e } func (w *ParserWorker) processCategoryPage(ctx context.Context, raw *core.RawContent, rawID, crawlerName string, jobTimeout time.Duration, llmRetryCount int, mlog *logger.Logger) error { - // Category cycle 종료 시 PipelineGuard marker release (이슈 #285) — defer 로 어떤 경로 + // Category cycle 종료 시 PipelineGuard marker release — defer 로 어떤 경로 // (성공 / handleRuleError / 0 links / publish 실패) 든 release 보장. Release 실패는 non-fatal // (TTL fallback 으로 자동 회수). defer w.releaseCategoryMarker(ctx, raw.URL, mlog) @@ -360,7 +360,7 @@ func (w *ParserWorker) processCategoryPage(ctx context.Context, raw *core.RawCon urls := uniqueURLs(items, maxChainedURLs) - // 이슈 #295/#297: page-parse 블랙리스트 적용 — mode 별 분기: + // page-parse 블랙리스트 적용 — mode 별 분기: // - 'drop' 매칭 : 어느 슬라이스에도 미포함 (완전 drop, fetch / parse 안 함) // - 'extract_links_only' 매칭 : list 로 강제 발행 → fetch + ParseLinks 만 진행 (ParsePage skip) // - 매칭 X : 정상 article 로 발행 @@ -407,7 +407,7 @@ func (w *ParserWorker) processCategoryPage(ctx context.Context, raw *core.RawCon return nil } -// releaseCategoryMarker 는 Category cycle 종료 시 PipelineGuard marker 를 release 합니다 (이슈 #285). +// releaseCategoryMarker 는 Category cycle 종료 시 PipelineGuard marker 를 release 합니다. // // guard 미설정 시 noop. Release 실패는 non-fatal — TTL 만료 fallback 으로 자동 회수. // ctx.WithoutCancel 로 parent ctx 취소 신호 분리 — shutdown 중에도 release 시도 보장. @@ -436,7 +436,7 @@ func (w *ParserWorker) processArticlePage(ctx context.Context, raw *core.RawCont return w.handleRuleError(ctx, raw, rawID, "parse_page", storage.TargetTypePage, err, llmRetryCount, crawlerName, 0, mlog) } - // 이슈 #220: parse 자체는 성공했지만 Title / MainContent 텍스트 길이가 임계값 미달이면 + // parse 자체는 성공했지만 Title / MainContent 텍스트 길이가 임계값 미달이면 // 빈본문 신호로 host 카운터에 누적. 정상 흐름은 차단하지 않음 (downstream validator 가 // 별도 정책으로 처리). w.recordEmptyBodyIfApplicable(ctx, raw, rawID, page, mlog) @@ -446,7 +446,7 @@ func (w *ParserWorker) processArticlePage(ctx context.Context, raw *core.RawCont return fmt.Errorf("publish article content: %w", err) } - // 이슈 #173 단계 4-1: 정상 파싱 성공 시 sample URL 누적 — 단계 4-2 의 정밀화 트리거 입력. + // 정상 파싱 성공 시 sample URL 누적 — 단계 4-2 의 정밀화 트리거 입력. // 누적 실패는 정상 흐름 차단 안 함 (warn 로그). w.accumulateSample(ctx, raw, mlog) @@ -456,13 +456,13 @@ func (w *ParserWorker) processArticlePage(ctx context.Context, raw *core.RawCont // handleRuleError 는 rule.Error (parse 실패) 와 그 외 에러를 구분합니다. // -// - rule.ErrNoRule + llmGen 활성화 → LLM rule generator 비동기 enqueue (이슈 #149) + raw 잔존 + commit +// - rule.ErrNoRule + llmGen 활성화 → LLM rule generator 비동기 enqueue + raw 잔존 + commit // - 기타 rule.Error → raw 잔존 + commit (warn 로그) — 운영자 review 윈도우 // - 기타 → 호출자에게 error 전파 → commit 안 함 → 재시도 // -// 이슈 #220 (page 단계 한정): rule.ErrParseFailure / rule.ErrEmptySelector 는 host 단위 카운터로 +// rule.ErrParseFailure / rule.ErrEmptySelector 는 host 단위 카운터로 // 누적 — 임계값 도달 시 단계 3 (#221) 의 chromedp 자동 전환 트리거 입력. ErrNoRule 은 LLM 자동 -// rule 생성 (이슈 #149) 의 책임 영역이라 카운팅 제외 (다른 정책으로 처리됨). +// rule 생성 의 책임 영역이라 카운팅 제외 (다른 정책으로 처리됨). func (w *ParserWorker) handleRuleError(ctx context.Context, raw *core.RawContent, rawID, stage string, targetType storage.TargetType, err error, llmRetryCount int, crawlerName string, jobTimeout time.Duration, mlog *logger.Logger) error { var rerr *rule.Error if errors.As(err, &rerr) { @@ -472,11 +472,11 @@ func (w *ParserWorker) handleRuleError(ctx context.Context, raw *core.RawContent "target_type": string(targetType), }).WithError(err).Warn("rule-based parse failed, raw retained for LLM retry") - // ErrNoRule + llmGen 활성화 → LLM 자동 rule 생성 비동기 트리거 (이슈 #149) - // crawlerName 은 validate 실패 시 재큐 메시지 헤더 복원용 (이슈 #237 피드백). - // jobTimeout 은 pending 재투입 시 카테고리 chained job timeout 보존 (이슈 #262 리뷰). + // ErrNoRule + llmGen 활성화 → LLM 자동 rule 생성 비동기 트리거 + // crawlerName 은 validate 실패 시 재큐 메시지 헤더 복원용. + // jobTimeout 은 pending 재투입 시 카테고리 chained job timeout 보존. // - // 이슈 #287: Resolver miss (ErrNoRule) 가 운영자가 의도적으로 disable 한 룰 잔존 + // Resolver miss (ErrNoRule) 가 운영자가 의도적으로 disable 한 룰 잔존 // 인 경우 LLM 재학습 트리거 회피 — 매 fetch 마다 LLM 호출 → ErrDuplicate 흐름이 // 운영자의 의도된 disable 을 무력화하지 않도록. // HasAnyRule lookup 실패는 best-effort (fail-open — 기존 동작 유지하여 LLM enqueue 진행). @@ -494,7 +494,7 @@ func (w *ParserWorker) handleRuleError(ctx context.Context, raw *core.RawContent }).Warn("rule exists but all disabled — skipping LLM regen (manual re-enable required)") case herr != nil && ctx.Err() == nil: // lookup 실패 — fail-open (warn 로그 + 기존 LLM enqueue 경로 진행). - // ctx.Err() 체크: shutdown 중 cancellation 을 lookup 장애로 오인 회피 (PR #291 gemini). + // ctx.Err() 체크: shutdown 중 cancellation 을 lookup 장애로 오인 회피. mlog.WithFields(map[string]interface{}{ "host": rerr.Host, "target_type": string(targetType), @@ -506,10 +506,10 @@ func (w *ParserWorker) handleRuleError(ctx context.Context, raw *core.RawContent } } - // 이슈 #220: page 단계의 ParseFailure / EmptySelector 만 host 카운터에 누적. + // page 단계의 ParseFailure / EmptySelector 만 host 카운터에 누적. // list (category) 는 본질적으로 다른 selector 셋이라 chromedp 전환 신호로 부적절. - // 이슈 #221: 같은 시점에 raw_id 를 host 별 추적 — 단계 3 의 republish 대상 수집. - // 이슈 #282: 동일 시점에 stale counter 누적 — 임계 도달 시 LLM 재학습 트리거. + // 같은 시점에 raw_id 를 host 별 추적 — 단계 3 의 republish 대상 수집. + // 동일 시점에 stale counter 누적 — 임계 도달 시 LLM 재학습 트리거. if targetType == storage.TargetTypePage && (rerr.Code == rule.ErrParseFailure || rerr.Code == rule.ErrEmptySelector) { w.recordHostFailure(ctx, rerr.Host, rawID, fetcherRule.FailureReasonRuleParseFailure, mlog) @@ -525,12 +525,12 @@ func (w *ParserWorker) handleRuleError(ctx context.Context, raw *core.RawContent } // recordHostFailure 는 host 단위 fetcher 실패 카운터에 1건 누적하고 raw_id 를 host Set 에 -// 추적합니다 (이슈 #220 + #221). +// 추적합니다. // // 카운팅 / 트래킹 자체 실패는 non-fatal — warn 로그만 남기고 정상 흐름 유지 (Redis 장애가 // parse 실패 처리 흐름을 막지 않도록). // -// 이슈 #221: 카운터가 thresholdReached=true 반환하면 Upgrader.Trigger 를 별도 goroutine 으로 +// 카운터가 thresholdReached=true 반환하면 Upgrader.Trigger 를 별도 goroutine 으로 // 비동기 호출 — parser 본 흐름의 latency 차단 회피. Upgrader 가 nil 이면 신호만 발신. // // rawID 는 단계 3 의 chromedp 자동 전환 trigger 가 republish 대상으로 사용. 빈 문자열이면 @@ -554,7 +554,7 @@ func (w *ParserWorker) recordHostFailure(ctx context.Context, host, rawID string }).WithError(err).Warn("raw id tracker track failed (non-fatal)") } } - // 이슈 #221: 임계값 도달 시 자동 chromedp 전환 + 실패 raw republish trigger. + // 임계값 도달 시 자동 chromedp 전환 + 실패 raw republish trigger. // 비동기 — parser 흐름의 latency 차단 회피. Upgrader 자체가 in-flight dedup / 이미 chromedp // skip 등 안전망 보유. // @@ -572,13 +572,13 @@ func (w *ParserWorker) recordHostFailure(ctx context.Context, host, rawID string } // recordStaleAndMaybeRelearn 은 stale rule 발생 1건을 (host, target_type) 카운터에 누적하고 -// 임계 도달 시 LLM 재학습을 트리거합니다 (이슈 #282). +// 임계 도달 시 LLM 재학습을 트리거합니다. // // 동작 요건 (모두 충족 시만 enqueue): // 1. staleCounter / llmGen 둘 다 wiring 됨 (둘 중 하나 nil 이면 noop) // 2. host != "" — 빈 host 는 카운팅 skip // 3. counter.Record 가 thresholdReached=true 반환 -// 4. resolver 의 HasAnyRule 결과 — 운영자가 모든 rule 을 disable 한 host 는 skip (이슈 #287 동일 정책) +// 4. resolver 의 HasAnyRule 결과 — 운영자가 모든 rule 을 disable 한 host 는 skip // // 카운팅 / lookup 실패는 non-fatal — warn 로그만 남기고 정상 흐름 유지. 임계 도달 후 EnqueueStale // 은 비동기 (Generator 내부 goroutine) 라 본 함수 latency 영향 없음. @@ -600,7 +600,7 @@ func (w *ParserWorker) recordStaleAndMaybeRelearn(ctx context.Context, host stri if !thresholdReached { return } - // 이슈 #282 PR #294 CodeRabbit 피드백: thresholdReached 는 window 내 count >= threshold 일 때 + // thresholdReached 는 window 내 count >= threshold 일 때 // 매번 true 이므로, 첫 crossing (count == threshold) 만 enqueue 하여 동일 window 안의 중복 // version 생성 / 비용 spike 회피. staleThreshold == 0 이면 호환 모드 (gate 비활성). if w.staleThreshold > 0 && count != w.staleThreshold { @@ -613,7 +613,7 @@ func (w *ParserWorker) recordStaleAndMaybeRelearn(ctx context.Context, host stri return } - // 운영자 disable 잔존 검증 — 같은 fail-open 정책 (이슈 #287). + // 운영자 disable 잔존 검증 — 같은 fail-open 정책. shouldEnqueue := true if w.resolver != nil { exists, hasEnabled, herr := w.resolver.HasAnyRule(ctx, host, targetType) @@ -654,12 +654,12 @@ func hostOf(rawURL string) string { } // recordEmptyBodyIfApplicable 는 page 의 Title / MainContent 길이가 임계값 미달이면 -// host 카운터에 empty_body 신호로 누적합니다 (이슈 #220). +// host 카운터에 empty_body 신호로 누적합니다. // // 임계값 어느 한쪽 미달이어도 신호 발신 — chromedp 전환 후 둘 다 정상 추출되는 케이스 가정. // 임계값이 0 이면 해당 필드 검증 비활성 (운영 옵션). // -// 이슈 #221: rawID 도 host 별 추적 — 단계 3 의 republish 대상 수집. +// rawID 도 host 별 추적 — 단계 3 의 republish 대상 수집. func (w *ParserWorker) recordEmptyBodyIfApplicable(ctx context.Context, raw *core.RawContent, rawID string, page *parser.Page, mlog *logger.Logger) { if w.emptyBodyTitleMin <= 0 && w.emptyBodyContentMin <= 0 { return @@ -748,13 +748,13 @@ func (w *ParserWorker) publishContents(ctx context.Context, contents []*core.Con } const ( - // maxLLMRetries 는 LLM selector 검증 실패 시 raw content 를 재큐잉할 최대 횟수입니다 (이슈 #237). + // maxLLMRetries 는 LLM selector 검증 실패 시 raw content 를 재큐잉할 최대 횟수입니다. // 이 값을 초과하면 재큐잉 없이 raw 를 TTL cleanup 에 맡깁니다. maxLLMRetries = 3 ) // RequeueForLLMRetry 는 LLM selector 검증 실패 시 raw content 를 issuetracker.fetched 에 -// 재발행합니다 (이슈 #237). llmgen.Generator 의 validateFailureHandler 로 등록됩니다. +// 재발행합니다. llmgen.Generator 의 validateFailureHandler 로 등록됩니다. // // llmRetryCount >= maxLLMRetries 이면 재큐잉을 중단하고 Warn 로그만 남깁니다 — 무한루프 방지. // targetType, crawlerName 은 Kafka 메시지 헤더로 설정 — 재큐 후 processMessage 가 올바른 @@ -796,7 +796,7 @@ func (w *ParserWorker) RequeueForLLMRetry(ctx context.Context, ref core.RawConte Value: payload, Headers: map[string]string{ // storageToFetcherTargetType: storage "list"/"page" → fetcher "category"/"article" - // 파서 워커가 target_type 을 core.TargetType 으로 읽으므로 변환 필수 (이슈 #262 리뷰). + // 파서 워커가 target_type 을 core.TargetType 으로 읽으므로 변환 필수. "target_type": storageToFetcherTargetType(targetType), "crawler": crawlerName, }, @@ -819,17 +819,16 @@ func (w *ParserWorker) RequeueForLLMRetry(ctx context.Context, ref core.RawConte }).Info("raw content requeued for llm retry after selector validation failure") } -// RequeueParsing 은 pending 대기 URL 목록을 issuetracker.fetched 에 재발행합니다 (이슈 #262). +// RequeueParsing 은 pending 대기 URL 목록을 issuetracker.fetched 에 재발행합니다. // llmgen.Generator 의 RequeueFunc 로 등록됩니다. // // 룰 생성 완료 후 호출 — 대기 중이던 URL 이 새 룰로 재파싱될 수 있도록 TopicFetched 에 투입. -// Kafka 발행에 실패한 항목을 반환 — Generator 가 pending queue 에 재적재 (이슈 #262 리뷰). +// Kafka 발행에 실패한 항목을 반환 — Generator 가 pending queue 에 재적재. func (w *ParserWorker) RequeueParsing(ctx context.Context, items []llmgen.PendingItem) (failed []llmgen.PendingItem) { // WithoutCancel 은 루프 외부에서 1회 — 루프마다 새 컨텍스트 파생 불필요 (Gemini 피드백). baseCtx := context.WithoutCancel(ctx) for _, item := range items { // LLMRetryCount 를 ref 에 반영하여 직렬화 — item.RawRef 는 원본(0)이므로 별도 세팅 필요 - // (이슈 #262 리뷰: PendingItem.LLMRetryCount 가 payload 에서 유실되는 버그). ref := item.RawRef ref.LLMRetryCount = item.LLMRetryCount payload, err := json.Marshal(ref) @@ -846,10 +845,10 @@ func (w *ParserWorker) RequeueParsing(ctx context.Context, items []llmgen.Pendin Topic: queue.TopicFetched, Value: payload, Headers: map[string]string{ - // storage "list"/"page" → fetcher "category"/"article" 변환 (이슈 #262 리뷰). + // storage "list"/"page" → fetcher "category"/"article" 변환. "target_type": storageToFetcherTargetType(item.TargetType), "crawler": item.CrawlerName, - // timeout_ms 보존 — 카테고리 재투입 시 chained job timeout 유지 (이슈 #262 리뷰). + // timeout_ms 보존 — 카테고리 재투입 시 chained job timeout 유지. "timeout_ms": strconv.FormatInt(item.TimeoutMs, 10), }, } @@ -873,7 +872,7 @@ func (w *ParserWorker) deleteRaw(ctx context.Context, rawID string, mlog *logger } } -// accumulateSample 은 정상 파싱 성공한 article 의 URL 을 sample 에 누적합니다 (이슈 #173 단계 4-1). +// accumulateSample 은 정상 파싱 성공한 article 의 URL 을 sample 에 누적합니다. // // 누적 조건 (모두 충족 시만): // 1. resolver / sampleSvc 둘 다 wiring 됨 @@ -883,7 +882,7 @@ func (w *ParserWorker) deleteRaw(ctx context.Context, rawID string, mlog *logger // // 모든 실패 (lookup 실패 / Insert 에러 / cap 도달) 는 정상 흐름 차단 X — DEBUG/WARN 로그만. // -// 변수명 matchedRule: import 된 rule 패키지와의 shadowing 회피 (PR #189 gemini 피드백). +// 변수명 matchedRule: import 된 rule 패키지와의 shadowing 회피. func (w *ParserWorker) accumulateSample(ctx context.Context, raw *core.RawContent, mlog *logger.Logger) { if w.resolver == nil || w.sampleSvc == nil { return @@ -934,7 +933,7 @@ func uniqueURLs(items []parser.LinkItem, limit int) []string { return urls } -// ShouldEnqueueLLMOnNoRule 은 ErrNoRule 상황에서 HasAnyRule 결과로 LLM Enqueue 여부를 결정합니다 (이슈 #287). +// ShouldEnqueueLLMOnNoRule 은 ErrNoRule 상황에서 HasAnyRule 결과로 LLM Enqueue 여부를 결정합니다. // // 정책: // - lookup 실패 (err != nil) : fail-open — true (기존 동작 유지) @@ -957,7 +956,7 @@ func ShouldEnqueueLLMOnNoRule(exists, hasEnabled bool, lookupErr error) bool { // TopicFetched 헤더에서 사용하는 core.TargetType 문자열 ("category"/"article") 로 변환합니다. // // 초기 fetcher 는 core.TargetType 값을 target_type 헤더로 발행하므로, -// RequeueParsing / RequeueForLLMRetry 에서 재발행 시 동일 변환이 필요합니다 (이슈 #262 리뷰). +// RequeueParsing / RequeueForLLMRetry 에서 재발행 시 동일 변환이 필요합니다. func storageToFetcherTargetType(t storage.TargetType) string { if t == storage.TargetTypeList { return string(core.TargetTypeCategory) diff --git a/internal/processor/processor.go b/internal/processor/processor.go index abf561bb..ae252f95 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -6,7 +6,7 @@ // (and optional auxiliary background goroutines) and is managed uniformly via Stage. // // 검증 인터페이스 (Validator / ValidationResult / ValidationError) 는 -// `internal/processor/validate/types/` 로 분리됨 (이슈 #206 후속) — sub-validator +// `internal/processor/validate/types/` 로 분리됨 — sub-validator // (news / community) 가 import 하므로 leaf-only sub-package 에 두어 cycle 회피. package processor @@ -14,7 +14,7 @@ import ( "context" ) -// Stage 는 파이프라인 단계의 공통 lifecycle 인터페이스입니다 (이슈 #206). +// Stage 는 파이프라인 단계의 공통 lifecycle 인터페이스입니다. // // 각 stage 는 Kafka consumer + worker pool 을 보유하며 background goroutine 으로 동작. // `cmd/issuetracker/main.go` 는 `[]Stage` 로 모든 단계를 균일하게 Start/Stop 관리합니다. diff --git a/internal/processor/validate/community/validator.go b/internal/processor/validate/community/validator.go index 2b7d9c52..d079513f 100644 --- a/internal/processor/validate/community/validator.go +++ b/internal/processor/validate/community/validator.go @@ -39,7 +39,7 @@ func (v *Validator) Validate(_ context.Context, content *core.Content) types.Val score := v.qualityScore(content) threshold := float32(v.cfg.CommunityQualityThreshold) - // 이슈 #135 — 품질 점수 임계 미달이고 다른 reject 사유 없는 경우, quality_low 에러를 + // 품질 점수 임계 미달이고 다른 reject 사유 없는 경우, quality_low 에러를 // breakdown 과 함께 명시적으로 추가하여 reject 사유의 사후 추적을 가능하게 한다. if score < threshold && len(errs) == 0 { bodyScore := v.scoreBody(content.Body) diff --git a/internal/processor/validate/news/validator.go b/internal/processor/validate/news/validator.go index 145a27a0..4a52f175 100644 --- a/internal/processor/validate/news/validator.go +++ b/internal/processor/validate/news/validator.go @@ -42,7 +42,7 @@ func (v *Validator) Validate(_ context.Context, content *core.Content) types.Val score := v.qualityScore(content) threshold := float32(v.cfg.NewsQualityThreshold) - // 이슈 #135 — 품질 점수 임계 미달이고 다른 reject 사유 없는 경우, quality_low 에러를 + // 품질 점수 임계 미달이고 다른 reject 사유 없는 경우, quality_low 에러를 // breakdown 과 함께 명시적으로 추가. 이전에는 errs=[] 인 채로 reject 되어 사후 추적 불가. if score < threshold && len(errs) == 0 { wordScore := v.scoreWordCount(content.WordCount) diff --git a/internal/processor/validate/stage.go b/internal/processor/validate/stage.go index 84d40b45..bc2d9dca 100644 --- a/internal/processor/validate/stage.go +++ b/internal/processor/validate/stage.go @@ -1,4 +1,4 @@ -// 본 파일은 validate 단계의 processor.Stage 래퍼를 제공합니다 (이슈 #206). +// 본 파일은 validate 단계의 processor.Stage 래퍼를 제공합니다. // // validate 는 단일 worker 만 갖는 단순 stage — Worker.Start/Stop 을 그대로 위임. @@ -20,7 +20,7 @@ type Stage struct { } // NewStage 는 wired Worker 를 받아 validate.Stage 를 반환합니다. -// worker 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리 (이슈 #208). +// worker 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리. func NewStage(worker *Worker) (*Stage, error) { if worker == nil { return nil, errors.New("validate: NewStage requires non-nil Worker") diff --git a/internal/processor/validate/types/types.go b/internal/processor/validate/types/types.go index 972a875f..8f289293 100644 --- a/internal/processor/validate/types/types.go +++ b/internal/processor/validate/types/types.go @@ -1,6 +1,6 @@ // Package types 는 validate 단계의 검증 인터페이스와 결과 타입을 정의합니다. // -// Sub-package 인 이유 (이슈 #206 후속): +// Sub-package 인 이유: // // validate/news + validate/community sub-validator 가 본 타입을 import 하고, // validate (parent) 도 dispatch 함수 NewValidator 에서 sub-validator 를 import. diff --git a/internal/processor/validate/validator.go b/internal/processor/validate/validator.go index 3c433cab..130d9537 100644 --- a/internal/processor/validate/validator.go +++ b/internal/processor/validate/validator.go @@ -2,7 +2,7 @@ // // Package validate implements the content validation stage of the processing pipeline. // It dispatches to source-type-specific validators (news, community) via NewValidator. -// Worker 가 Validator 결과를 직접 사용 — 별도 ContentProcessor 어댑터 없음 (이슈 #206). +// Worker 가 Validator 결과를 직접 사용 — 별도 ContentProcessor 어댑터 없음. package validate import ( @@ -31,7 +31,7 @@ func NewValidator(sourceType core.SourceType, cfg config.ValidateConfig) types.V } // RunValidation 은 content 를 검증하고 결과에 따라 Reliability 설정 + 에러 변환을 수행합니다. -// 이전의 ContentProcessor 어댑터를 대체 (이슈 #206) — 단순 함수로 충분. +// 이전의 ContentProcessor 어댑터를 대체 — 단순 함수로 충분. // // 검증 통과 시: content.Reliability = QualityScore 설정, (content, nil) 반환. // 검증 실패 시: core.CrawlerError(Validation 카테고리) 반환 — 첫 번째 룰 기준 코드 부여. @@ -50,7 +50,7 @@ func RunValidation(ctx context.Context, v types.Validator, content *core.Content // codeForValidationResult 는 ValidationResult.Errors 에서 첫 번째 룰을 참조하여 // 가장 적절한 에러 코드를 결정합니다. errors 가 비어있으면 임계 미달로 간주합니다 (legacy 안전망 — -// 이슈 #135 이후 validator 들은 임계 미달 시 명시적으로 quality_low 룰을 errors 에 추가합니다). +// validator 들은 임계 미달 시 명시적으로 quality_low 룰을 errors 에 추가합니다). func codeForValidationResult(result types.ValidationResult) string { if len(result.Errors) == 0 { return core.CodeValQualityLow diff --git a/internal/processor/validate/worker.go b/internal/processor/validate/worker.go index 5b4f03e4..975f8bcd 100644 --- a/internal/processor/validate/worker.go +++ b/internal/processor/validate/worker.go @@ -34,7 +34,7 @@ type Worker struct { consumer queue.Consumer producer queue.Producer contentSvc service.ContentService - procLock locks.ProcessingLock // nil 허용 → NoopProcessingLock 으로 fallback (이슈 #178) + procLock locks.ProcessingLock // nil 허용 → NoopProcessingLock 으로 fallback cfg config.ValidateConfig workerCount int jobs chan *queue.Message @@ -48,9 +48,9 @@ type Worker struct { // procLock 은 nil 허용 — nil 이면 NoopProcessingLock 으로 fallback (단일 인스턴스 환경에서 dedup 비활성). // // validator 결과 (passed/rejected) 는 contentSvc.UpdateValidationStatus 로 contents 테이블에 -// 기록됩니다 (이슈 #135 / #161 — news_articles 제거 후 contents 로 일원화). +// 기록됩니다. // -// 이슈 #178: ProcessingLock 으로 fetcher / parser / validator 가 동일 인터페이스로 단계별 dedup. +// ProcessingLock 으로 fetcher / parser / validator 가 동일 인터페이스로 단계별 dedup. // validator 단계는 ContentRef.URL 단위로 acquire — Kafka rebalance 시 같은 ref 가 두 worker 에 도달해도 1회만 검증. func NewWorker( consumer queue.Consumer, @@ -183,7 +183,7 @@ func (w *Worker) process(ctx context.Context, msg *queue.Message) error { return w.commit(ctx, msg) } - // 이슈 #178: validator 단계 ProcessingLock — 같은 ref.URL 의 동시 검증을 차단. + // validator 단계 ProcessingLock — 같은 ref.URL 의 동시 검증을 차단. // Kafka rebalance / 재배달 시 같은 ref 가 두 validator 에 도달해도 1회만 처리. procKey := locks.ProcessingKey(locks.StageValidator, ref.URL) acquired, lockErr := w.procLock.Acquire(ctx, procKey) @@ -201,7 +201,7 @@ func (w *Worker) process(ctx context.Context, msg *queue.Message) error { return nil } else { defer func() { - // 셧다운 시 ctx cancel 되어도 락 해제 보장 + trace ID 등 메타데이터 보존 (PR #180 gemini 피드백). + // 셧다운 시 ctx cancel 되어도 락 해제 보장 + trace ID 등 메타데이터 보존. releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() if releaseErr := w.procLock.Release(releaseCtx, procKey); releaseErr != nil { @@ -246,7 +246,7 @@ func (w *Worker) process(ctx context.Context, msg *queue.Message) error { "country": content.Country, }).WithError(err).Info("content validation failed, deleting content and sending to dlq") - // 이슈 #135 / #161 — contents.Delete 직전에 reject 사유를 contents 컬럼에 기록. + // contents.Delete 직전에 reject 사유를 contents 컬럼에 기록. // 순서가 중요: Delete 후엔 사후 추적 단일 source 가 깨진다. w.recordValidationRejected(ctx, ref.ID, err) @@ -273,7 +273,7 @@ func (w *Worker) process(ctx context.Context, msg *queue.Message) error { return w.commit(ctx, msg) } - // 이슈 #135 / #161 — 검증 통과: contents 의 passed 기록 (publish 전에 호출하여 publish 실패 시 + // 검증 통과: contents 의 passed 기록 (publish 전에 호출하여 publish 실패 시 // 재처리되더라도 status 는 이미 정확. UpdateValidationStatus 는 idempotent 라 재호출 안전). w.recordValidationPassed(ctx, ref.ID) @@ -334,7 +334,7 @@ func (w *Worker) publishValidatedRef(ctx context.Context, ref *core.ContentRef, } // recordValidationRejected 는 validator 영구 실패 시 news_articles 에 reject 메타데이터를 -// 기록합니다 (이슈 #135 / #161). 호출은 contentSvc.Delete 직전에 이루어져야 합니다. +// 기록합니다. 호출은 contentSvc.Delete 직전에 이루어져야 합니다. // // 본 메소드는 모든 실패를 best-effort 로 처리합니다 — id 미존재(ErrNotFound), DB 일시 장애 등 // 어떤 실패도 메인 처리 흐름을 차단하지 않습니다. 추적이 끊겨도 contents.Delete 와 DLQ 라우팅은 @@ -342,7 +342,7 @@ func (w *Worker) publishValidatedRef(ctx context.Context, ref *core.ContentRef, // // reject_code 는 errors.As 로 *core.CrawlerError 를 추출하여 .Code (VAL_xxx) 를 사용합니다. // reject_detail 은 err.Error() 의 message 부분 — VAL_005 의 quality breakdown 보강은 -// 별도 단계 (이슈 #135 P0-4) 에서 진행됩니다. +// 별도 단계 에서 진행됩니다. func (w *Worker) recordValidationRejected(ctx context.Context, id string, reason error) { if id == "" { return @@ -372,7 +372,7 @@ func (w *Worker) recordValidationRejected(ctx context.Context, id string, reason } // recordValidationPassed 는 validator 통과 시 contents.validation_status 를 -// 'passed' 로 갱신합니다 (이슈 #135 / #161). best-effort — 실패가 메인 흐름을 차단하지 않습니다. +// 'passed' 로 갱신합니다. best-effort — 실패가 메인 흐름을 차단하지 않습니다. func (w *Worker) recordValidationPassed(ctx context.Context, id string) { if id == "" { return diff --git a/internal/publisher/publisher.go b/internal/publisher/publisher.go index 4c3b8de8..be779598 100644 --- a/internal/publisher/publisher.go +++ b/internal/publisher/publisher.go @@ -28,7 +28,7 @@ type PriorityResolver interface { } // IngestionLock 은 publish 직전에 URL 의 파이프라인 진입 marker 를 atomic 으로 set -// 하는 최소 인터페이스입니다 (이슈 #178). +// 하는 최소 인터페이스입니다. // // 의도적으로 작은 인터페이스 — Publisher 는 단지 "이 URL 의 진입 슬롯을 잡을 수 있는가?" // 만 알고 싶어합니다. worker 패키지의 IngestionLock 와 method signature 가 동일하지만 @@ -40,7 +40,7 @@ type IngestionLock interface { Acquire(ctx context.Context, url string) (bool, error) } -// PipelineGuard 는 publish 진입 시 URL 의 pipeline membership 을 target type 별 정책으로 체크합니다 (이슈 #285). +// PipelineGuard 는 publish 진입 시 URL 의 pipeline membership 을 target type 별 정책으로 체크합니다. // // publisher 가 internal/locks 를 직접 import 하지 않도록 별도 정의 — 구조적 타이핑으로 // locks.PipelineGuard 가 그대로 만족. @@ -51,13 +51,13 @@ type PipelineGuard interface { // Publisher는 크롤된 페이지에서 발견된 URL을 새 CrawlJob으로 변환하여 // 우선순위에 맞는 Kafka crawl 토픽에 발행합니다. // -// URL 가드 (이슈 #119): +// URL 가드: // - SetGate 로 urlguard.Gate 를 설정하면 PublishBatch 직전에 urls 슬라이스를 필터링 // - 차단된 URL 은 발행에서 제외 (Gate 가 자체 WARN 로그) // - 미설정 시 가드 비활성 (기존 동작 유지) // - atomic.Pointer 로 race-safe 한 lock-free 설정/조회 — 워커 동시 실행 중 변경에도 race 없음 // -// URL dedup — Pipeline Guard (이슈 #285) / Ingestion Lock (이슈 #178): +// URL dedup — Pipeline Guard / Ingestion Lock: // - SetNormalizer 로 pkg/links.Normalizer 를 주입하면 publish 직전 모든 URL 정규화 // (정규화된 URL 이 marker 키 / Kafka payload / 다운스트림 dedup 모두에 일관) // - SetPipelineGuard 우선 — 모든 target type 에 적용: @@ -85,7 +85,7 @@ type ingestionLockRef struct { l IngestionLock } -// guardRef 는 PipelineGuard 의 atomic 교체용 wrapper 입니다 (이슈 #285). +// guardRef 는 PipelineGuard 의 atomic 교체용 wrapper 입니다. type guardRef struct { g PipelineGuard } @@ -111,17 +111,17 @@ func (p *Publisher) SetGate(g *urlguard.Gate) { // nil 전달 시 정규화 비활성 (URL 원본 그대로 사용). atomic 으로 race-safe 한 swap 보장. // // 정규화는 Ingestion Lock 키 / Kafka payload / 다운스트림 dedup 모두에 동일하게 적용되도록 -// Publisher 단에서 단일 책임으로 수행 (이슈 #178). +// Publisher 단에서 단일 책임으로 수행. func (p *Publisher) SetNormalizer(n *links.Normalizer) { p.normalizer.Store(n) } // SetIngestionLock 은 Publish 시 atomic SETNX 로 진입 marker 를 잡을 IngestionLock 을 -// 설정합니다 (이슈 #178). nil 전달 시 dedup 비활성 (기존 동작 유지). +// 설정합니다. nil 전달 시 dedup 비활성 (기존 동작 유지). // // atomic 으로 race-safe 한 swap 보장. // -// Deprecated (이슈 #285): SetPipelineGuard 사용 권장 — target type 별 TTL 정책 적용. +// Deprecated: SetPipelineGuard 사용 권장 — target type 별 TTL 정책 적용. // 본 메소드는 backward compat 로 유지 — guard 미설정 시 fallback 으로 사용됨. func (p *Publisher) SetIngestionLock(l IngestionLock) { if l == nil { @@ -132,7 +132,7 @@ func (p *Publisher) SetIngestionLock(l IngestionLock) { } // SetPipelineGuard 는 publish 진입 시 target type 별 정책으로 marker 를 잡을 PipelineGuard 를 -// 설정합니다 (이슈 #285). +// 설정합니다. // // guard 가 설정되어 있으면 IngestionLock 보다 우선 — 모든 target type 에 적용 (Article 24h, // Category 단명 TTL). nil 전달 시 가드 비활성 — IngestionLock fallback (있으면). @@ -157,7 +157,7 @@ func (p *Publisher) Publish( return nil } - // URL 정규화 (이슈 #178): publish 직전 단일 책임으로 정규화 + // URL 정규화: publish 직전 단일 책임으로 정규화 // - Ingestion Lock 키 / Kafka payload / 다운스트림 dedup 모두 동일 정규형 사용 // - 정규화 실패한 URL 은 통과 (fail-open) — 정규화 실패가 fetch 가능성을 차단하지 않도록 // - Normalizer 미설정 시 원본 그대로 @@ -168,7 +168,7 @@ func (p *Publisher) Publish( } } - // URL 가드 (이슈 #119): 차단된 URL 을 사전 필터링 + // URL 가드: 차단된 URL 을 사전 필터링 // Gate 가 자체 WARN 로그 + url/reason/crawler/stage 필드 자동 부착 if g := p.gate.Load(); g != nil { urls = g.Filter(urls, map[string]interface{}{ @@ -180,7 +180,7 @@ func (p *Publisher) Publish( } } - // Pipeline Guard (이슈 #285) / Ingestion Lock (이슈 #178): + // Pipeline Guard / Ingestion Lock: // - PipelineGuard 우선 — target type 별 TTL 정책 (Article 24h / Category 단명) // - IngestionLock fallback — Article 만 적용 (Category 우회) — backward compat // - 둘 다 미설정 시 dedup 비활성 @@ -234,7 +234,7 @@ func (p *Publisher) Publish( return nil } -// normalizeURLs 는 입력 URL 슬라이스를 정규화하여 새 슬라이스로 반환합니다 (이슈 #178). +// normalizeURLs 는 입력 URL 슬라이스를 정규화하여 새 슬라이스로 반환합니다. // // 정규화 실패한 URL 은 원본을 그대로 통과 (fail-open) + WARN 로그 — 정규화 자체가 // fetch 가능성을 차단하지 않도록. 정규화 결과가 빈 문자열이면 결과에서 제외. @@ -262,7 +262,7 @@ func (p *Publisher) normalizeURLs(urls []string, n *links.Normalizer, crawlerNam return out } -// acquireViaGuard 는 PipelineGuard 로 target type 별 TTL 정책을 적용하여 진입 marker 를 잡습니다 (이슈 #285). +// acquireViaGuard 는 PipelineGuard 로 target type 별 TTL 정책을 적용하여 진입 marker 를 잡습니다. // // 동작 정책은 acquireIngestion 과 동일 — fail-open / ctx 취소 / 정규화 가정 등. // 차이점: lock.Acquire 대신 guard.CheckAndAcquire(targetType) 호출 — Category 는 단명 TTL 적용. @@ -296,7 +296,7 @@ func (p *Publisher) acquireViaGuard(ctx context.Context, urls []string, crawlerN } // acquireIngestion 은 IngestionLock 으로 atomic SETNX 시도 후 marker 를 잡은 URL 만 -// 반환합니다 (이슈 #178). +// 반환합니다. // // - acquired=true : 신규 진입 marker 획득 — 결과 슬라이스에 포함 // - acquired=false : 이미 다른 publisher 또는 재배달이 marker 점유 — DEBUG 로그 후 제외 @@ -309,7 +309,7 @@ func (p *Publisher) acquireViaGuard(ctx context.Context, urls []string, crawlerN // // 성능: crawler/stage sub-logger 를 루프 외부에서 1회 생성하여 재사용. // -// Deprecated (이슈 #285): SetPipelineGuard 사용 시 acquireViaGuard 가 우선 — 본 메소드는 +// Deprecated: SetPipelineGuard 사용 시 acquireViaGuard 가 우선 — 본 메소드는 // guard 미주입 환경의 backward compat fallback. func (p *Publisher) acquireIngestion(ctx context.Context, urls []string, crawlerName string, lock IngestionLock) []string { out := make([]string, 0, len(urls)) diff --git a/internal/scheduler/emitter.go b/internal/scheduler/emitter.go index a8ff44e8..7bf50465 100644 --- a/internal/scheduler/emitter.go +++ b/internal/scheduler/emitter.go @@ -11,19 +11,19 @@ import ( "issuetracker/pkg/queue" ) -// ErrEmitSkipped 는 PipelineGuard 가 \"이미 in-pipeline\" 으로 판단해 emit 을 건너뛴 경우 반환됩니다 (이슈 #285). +// ErrEmitSkipped 는 PipelineGuard 가 \"이미 in-pipeline\" 으로 판단해 emit 을 건너뛴 경우 반환됩니다. // // 호출자는 errors.Is(err, ErrEmitSkipped) 로 분기하여 \"failed to publish\" / \"scheduled\" 로그를 -// 모두 생략 — 실제로 발행되지 않은 job 이 발행된 것처럼 보이는 misleading 로그 회피 (PR #286 리뷰). +// 모두 생략 — 실제로 발행되지 않은 job 이 발행된 것처럼 보이는 misleading 로그 회피. var ErrEmitSkipped = errors.New("emit skipped — url already in pipeline") -// PipelineGuard 는 publish 진입 시 URL 의 pipeline membership 을 체크하는 인터페이스입니다 (이슈 #285). +// PipelineGuard 는 publish 진입 시 URL 의 pipeline membership 을 체크하는 인터페이스입니다. // // emitter 가 internal/locks 의 전체 surface 가 아닌 필요한 메소드만 노출 — interface segregation. // locks.PipelineGuard 는 구조적 타이핑으로 본 인터페이스 만족. // // Release: CheckAndAcquire 가 marker 를 잡았으나 후속 producer.Publish 가 실패한 경우 marker 를 -// 즉시 해제 — 다음 retry 가 silent skip 으로 잃어버리지 않도록 (PR #286 CodeRabbit 리뷰). +// 즉시 해제 — 다음 retry 가 silent skip 으로 잃어버리지 않도록. type PipelineGuard interface { CheckAndAcquire(ctx context.Context, url string, targetType core.TargetType) (bool, error) Release(ctx context.Context, url string) error @@ -33,7 +33,7 @@ type PipelineGuard interface { // ScheduleEntry에 이미 결정된 Priority를 그대로 사용하여 Kafka crawl 토픽에 직접 발행합니다. // 우선순위 재결정이 필요한 체이닝 발행은 internal/publisher 패키지를 사용하세요. // -// PipelineGuard (이슈 #285): SetGuard 로 주입 시 Emit 직전에 CheckAndAcquire 호출 — 같은 URL 의 +// PipelineGuard: SetGuard 로 주입 시 Emit 직전에 CheckAndAcquire 호출 — 같은 URL 의 // cycle 이 진행 중이면 silent skip. Scheduler 의 정기 갱신 의도는 보존 (다음 주기 자연 진입). // 미주입 (nil) 이면 가드 비활성 (기존 동작 유지). type JobEmitter struct { @@ -48,11 +48,11 @@ func NewJobEmitter(producer queue.Producer, log *logger.Logger) *JobEmitter { return &JobEmitter{producer: producer, log: log} } -// SetGuard 는 PipelineGuard 를 주입합니다 (이슈 #285). +// SetGuard 는 PipelineGuard 를 주입합니다. // nil 주입 시 가드 비활성. Emit 호출 도중 변경하지 말 것 (race) — wiring 단계에서 1회. func (e *JobEmitter) SetGuard(g PipelineGuard) { e.guard = g } -// SetNormalizer 는 guard 호출 전 URL 정규화에 사용할 Normalizer 를 주입합니다 (PR #286 gemini 리뷰). +// SetNormalizer 는 guard 호출 전 URL 정규화에 사용할 Normalizer 를 주입합니다. // // publisher 가 SetNormalizer 로 사용하는 것과 동일 normalizer 를 공유해야 marker 키가 일치 — // 같은 logical URL 이 다른 입구 (scheduler / publisher) 에서 다른 Redis 키를 갖지 않도록. @@ -64,7 +64,7 @@ func (e *JobEmitter) SetNormalizer(n *links.Normalizer) { e.normalizer = n } // PipelineGuard 가 주입되어 있고 같은 URL 의 cycle 이 진행 중이면 (acquired=false) silent skip // (debug 로그 + nil 반환). guard 조회 실패는 fail-open (warn 로그 + publish 진행). func (e *JobEmitter) Emit(ctx context.Context, job *core.CrawlJob) error { - // guard 키 일관성 (PR #286 gemini): publisher 도 동일 normalizer 적용 후 CheckAndAcquire 함. + // guard 키 일관성: publisher 도 동일 normalizer 적용 후 CheckAndAcquire 함. // scheduler 에서도 같은 정규형으로 marker 잡아야 동일 URL 이 두 입구에서 같은 키 사용. // 정규화 실패는 fail-open — 원본으로 fallback (정규화 자체 장애가 emit 차단 회피). guardURL := job.Target.URL @@ -73,7 +73,7 @@ func (e *JobEmitter) Emit(ctx context.Context, job *core.CrawlJob) error { guardURL = normalized } } - guardAcquired := false // publish 실패 시 release 호출 여부 추적 (PR #286 CodeRabbit 리뷰) + guardAcquired := false // publish 실패 시 release 호출 여부 추적 if e.guard != nil { acquired, gerr := e.guard.CheckAndAcquire(ctx, guardURL, job.Target.Type) @@ -116,7 +116,6 @@ func (e *JobEmitter) Emit(ctx context.Context, job *core.CrawlJob) error { if err := e.producer.Publish(ctx, msg); err != nil { // publish 실패 시 marker 즉시 해제 — 다음 retry 가 false acquired 로 silent skip 되지 않도록 - // (PR #286 CodeRabbit 리뷰). e.releaseGuardOnFailure(ctx, guardURL, guardAcquired, job) return fmt.Errorf("emit job %s to %s: %w", job.ID, topic, err) } @@ -133,7 +132,7 @@ func (e *JobEmitter) Emit(ctx context.Context, job *core.CrawlJob) error { } // releaseGuardOnFailure 는 CheckAndAcquire 가 marker 를 잡았으나 후속 marshal/publish 가 실패한 경우 -// marker 를 즉시 해제합니다 (PR #286 CodeRabbit 리뷰). +// marker 를 즉시 해제합니다. // // guardAcquired=false 이거나 guard=nil 이면 noop. Release 실패는 non-fatal — TTL fallback 으로 // 자연 해제 (Category 60s). diff --git a/internal/scheduler/entries.go b/internal/scheduler/entries.go index 94f04e57..a8d666ed 100644 --- a/internal/scheduler/entries.go +++ b/internal/scheduler/entries.go @@ -7,7 +7,7 @@ import ( "issuetracker/pkg/config" ) -// sourceCategoryURLs 는 각 소스의 카테고리 URL 목록입니다 (이슈 #247). +// sourceCategoryURLs 는 각 소스의 카테고리 URL 목록입니다. // // 기존 sources/{kr,us}/*/config.go 에 하드코딩된 CategoryURLs 를 이 맵으로 통합. // DefaultEntries 가 이 목록에서 ScheduleEntry 를 생성합니다. diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index ccf27ecb..1d50bece 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -19,13 +19,13 @@ import ( // Emitter를 통해 Kafka crawl 토픽에 발행합니다. // 체이닝 Job(크롤된 페이지에서 발견된 URL)은 internal/publisher 패키지가 담당합니다. // -// URL 가드 (이슈 #119): +// URL 가드: // - SetGate 로 urlguard.Gate 를 설정하면 publish 직전에 entry.URL 검사 // - 차단된 URL 은 Emit 호출 없이 silent drop + WARN 로그 // - 미설정 시 가드 비활성 (기존 동작 유지) // - atomic.Pointer 로 race-safe 한 lock-free 설정/조회 — Start 이후 변경에도 race 없음 // -// Backlog throttle (이슈 #124): +// Backlog throttle: // - SetThrottler 로 Throttler 를 설정하면 emit 직전에 ShouldThrottle 검사 // - throttle 결정 시 emit 호출 없이 silent drop (구현체가 WARN 로그 책임) // - 미설정 시 throttle 비활성 (기존 동작 유지) @@ -147,7 +147,7 @@ func (s *Scheduler) publish(ctx context.Context, entry ScheduleEntry) { return } - // URL 가드 (이슈 #119): job 생성 직전에 entry.URL 검사 + // URL 가드: job 생성 직전에 entry.URL 검사 // 차단 시 emit 호출 없이 silent drop (가드가 WARN 로그 자동 생성) if g := s.gate.Load(); g != nil { if !g.Allow(entry.URL, map[string]interface{}{ @@ -171,14 +171,14 @@ func (s *Scheduler) publish(ctx context.Context, entry ScheduleEntry) { MaxRetries: s.maxRetries, } - // Backlog throttle (이슈 #124): job.Priority 에 대응하는 crawl 토픽의 + // Backlog throttle: job.Priority 에 대응하는 crawl 토픽의 // consumer-group lag 가 임계값 초과 시 silent drop. 구현체가 WARN 로그 책임. if r := s.throttler.Load(); r != nil && r.t.ShouldThrottle(ctx, job) { return } if err := s.emitter.Emit(ctx, job); err != nil { - // ErrEmitSkipped (이슈 #285): PipelineGuard 가 cycle 진행 중 판단으로 skip — non-fatal. + // ErrEmitSkipped: PipelineGuard 가 cycle 진행 중 판단으로 skip — non-fatal. // "scheduled" / "failed" 양쪽 로그 모두 생략 (실제 발행 안 된 job 이 발행된 것처럼 보이지 않게). if errors.Is(err, ErrEmitSkipped) { return diff --git a/internal/scheduler/source.go b/internal/scheduler/source.go index 924bbdd3..756ab125 100644 --- a/internal/scheduler/source.go +++ b/internal/scheduler/source.go @@ -16,7 +16,7 @@ import ( // // ScheduleEntry describes a single URL to be crawled on a fixed interval. type ScheduleEntry struct { - // CrawlerName은 이 Job을 처리할 크롤러 이름입니다 (registry 키 — host 기반, 이슈 #248). + // CrawlerName은 이 Job을 처리할 크롤러 이름입니다 (registry 키 — host 기반). CrawlerName string // URL은 크롤링 대상 URL입니다. diff --git a/internal/storage/blacklist.go b/internal/storage/blacklist.go index 71262f42..7082a834 100644 --- a/internal/storage/blacklist.go +++ b/internal/storage/blacklist.go @@ -5,10 +5,10 @@ import ( "time" ) -// BlacklistSource 는 블랙리스트 row 의 등록 출처입니다 (이슈 #295). +// BlacklistSource 는 블랙리스트 row 의 등록 출처입니다. // // - BlacklistSourceManual : 운영자가 명시적으로 등록 (광고 / sponsored / redirect 등 도메인 지식 기반) -// - BlacklistSourceAuto : 시스템이 시그널 누적으로 자동 등록 (후속 이슈 — 본 PR scope 외) +// - BlacklistSourceAuto : 시스템이 시그널 누적으로 자동 등록 (후속 이슈에서 도입) // // CHECK 제약으로 DB 레벨에서 두 값만 허용. 운영 가시성 + 자동 vs 수동 분리 정책 (예: auto 만 // 일괄 disable, manual 은 보존) 을 위해 컬럼화. @@ -19,7 +19,7 @@ const ( BlacklistSourceAuto BlacklistSource = "auto" ) -// BlacklistMode 는 매칭된 URL 에 적용할 차단 정책입니다 (이슈 #297). +// BlacklistMode 는 매칭된 URL 에 적용할 차단 정책입니다. // // - BlacklistModeDrop : URL 자체 drop — fetch / parse / 링크추출 모두 안 함 (default). // 광고 / sponsored / redirect 처럼 그 안의 링크도 가치 없는 케이스. @@ -35,7 +35,7 @@ const ( BlacklistModeExtractLinksOnly BlacklistMode = "extract_links_only" ) -// BlacklistRecord 는 parsing_blacklist 테이블의 단일 행입니다 (이슈 #295). +// BlacklistRecord 는 parsing_blacklist 테이블의 단일 행입니다. // // page-parse 차단 정책 — 매칭 URL 은 article job 발행 단계에서 drop: // - HostPattern : URL host 매칭 (정확 일치, 호출자가 normalize) @@ -50,7 +50,7 @@ type BlacklistRecord struct { PathPattern string Reason string Source BlacklistSource - Mode BlacklistMode // 'drop' (default) | 'extract_links_only' (이슈 #297) + Mode BlacklistMode // 'drop' (default) | 'extract_links_only' Enabled bool CreatedAt time.Time UpdatedAt time.Time @@ -65,7 +65,7 @@ type BlacklistFilter struct { Offset int } -// BlacklistRepository 는 parsing_blacklist 테이블에 대한 데이터 접근 인터페이스입니다 (이슈 #295). +// BlacklistRepository 는 parsing_blacklist 테이블에 대한 데이터 접근 인터페이스입니다. // // goroutine-safe: 모든 구현은 동시 호출 안전해야 함. type BlacklistRepository interface { diff --git a/internal/storage/content.go b/internal/storage/content.go index 64888c4c..247be287 100644 --- a/internal/storage/content.go +++ b/internal/storage/content.go @@ -58,11 +58,11 @@ type ContentRepository interface { // Returns ErrNotFound if the id does not exist. // Also refreshes updated_at to NOW() for audit trail consistency. // - // id 기준으로 validator 결과 메타데이터를 갱신합니다 (이슈 #135 / #161). + // id 기준으로 validator 결과 메타데이터를 갱신합니다. // status 가 ValidationStatusRejected 가 아니면 code/detail 은 NULL 로 저장됩니다. // 호출은 validator worker 가 contentSvc.Delete 직전에 수행합니다. // - // id 사용 이유 (PR #163 gemini 피드백): url unique 인덱스보다 primary key 가 효율적이고, + // id 사용 이유: url unique 인덱스보다 primary key 가 효율적이고, // URL 정규화 정책 (whitelist 기반 query 파라미터 strip) 과 무관하게 매칭이 보장됩니다. UpdateValidationStatus(ctx context.Context, id, status, code, detail string) error } diff --git a/internal/storage/errors.go b/internal/storage/errors.go index 8d9ae50f..fa746cac 100644 --- a/internal/storage/errors.go +++ b/internal/storage/errors.go @@ -10,6 +10,6 @@ var ErrNotFound = errors.New("record not found") // ErrDuplicate는 유일성 제약 위반(예: URL 중복) 시 반환됩니다. var ErrDuplicate = errors.New("duplicate record") -// ErrInvalid 는 record 의 형식이 유효하지 않을 때 반환됩니다 (이슈 #173). +// ErrInvalid 는 record 의 형식이 유효하지 않을 때 반환됩니다. // 예: parsing_rules.path_pattern 이 RE2 컴파일 실패 — Repository 가 DB write 전 거부. var ErrInvalid = errors.New("invalid record") diff --git a/internal/storage/fetcher_rule.go b/internal/storage/fetcher_rule.go index 5c005e4d..a63ab012 100644 --- a/internal/storage/fetcher_rule.go +++ b/internal/storage/fetcher_rule.go @@ -5,7 +5,7 @@ import ( "time" ) -// FetcherKind 는 fetch 단계에서 사용할 도구 식별자입니다 (이슈 #175 단계 1, sub-issue #219). +// FetcherKind 는 fetch 단계에서 사용할 도구 식별자입니다. // // FetcherKind discriminates between the static-HTML fetcher (goquery) and the headless // browser fetcher (chromedp). host 단위로 어느 fetcher 가 우선인지 결정하는 단일 키. @@ -32,7 +32,7 @@ type FetcherRuleRecord struct { Fetcher FetcherKind // "goquery" | "chromedp" Reason string // "manual" | "auto_upgrade_validation" | ... - // SourceInfo 필드 — migration 014 추가 (이슈 #245). + // SourceInfo 필드 — migration 014 추가. // source_name~base_url 은 DB 상 NULL 허용 (레거시 row 호환); 빈 문자열로 반환됨. // requests_per_hour 는 DB 상 NOT NULL DEFAULT 0; 0 = 제한 없음. SourceName string // 소스 식별자 (예: "naver", "cnn") @@ -66,7 +66,7 @@ type FetcherRuleRepository interface { Delete(ctx context.Context, host string) error // BulkDowngradeAutoUpgraded 는 자동 upgrade (reason='auto_upgrade_validation') 로 chromedp 가 - // 된 모든 host 를 goquery 로 일괄 다운그레이드합니다 (이슈 #224, sub of #175). + // 된 모든 host 를 goquery 로 일괄 다운그레이드합니다. // // 정책: // - WHERE 조건: reason='auto_upgrade_validation' AND fetcher='chromedp' diff --git a/internal/storage/parsing_rule.go b/internal/storage/parsing_rule.go index 1df1ce17..548d1a9d 100644 --- a/internal/storage/parsing_rule.go +++ b/internal/storage/parsing_rule.go @@ -5,7 +5,7 @@ import ( "time" ) -// TargetType 은 파싱 규칙이 적용될 페이지 종류입니다 (이슈 #100). +// TargetType 은 파싱 규칙이 적용될 페이지 종류입니다. // // TargetType discriminates rules between article pages and list/category pages. type TargetType string @@ -46,7 +46,7 @@ type FieldSelector struct { // page (단일 컨텐츠 페이지) 용 필드와 list (링크-허브 페이지) 용 필드가 한 struct 에 // 함께 정의되지만, target_type 에 따라 사용되는 부분만 채워집니다 (JSONB nil 친화). // -// 뉴스 / 블로그 / 제품 페이지 등 임의 웹페이지의 핵심 내용 추출에 일반화 (이슈 #100): +// 뉴스 / 블로그 / 제품 페이지 등 임의 웹페이지의 핵심 내용 추출에 일반화: // - Title : 페이지 제목 (h1 등) // - MainContent : 핵심 본문 (article body / blog post / product description ...) // - Summary : meta description 또는 별도 요약 영역 @@ -72,19 +72,19 @@ type SelectorMap struct { ItemTitle *FieldSelector `json:"item_title,omitempty"` ItemSnippet *FieldSelector `json:"item_snippet,omitempty"` // 짧은 요약/설명 (있을 때) - // LinkDiscovery (이슈 #139, #148): 페이지 전체 스캔 + 정책 기반 필터 모드. + // LinkDiscovery: 페이지 전체 스캔 + 정책 기반 필터 모드. // // 설정 시 ParseLinks 가 ItemContainer 경로 대신 full-page discovery 로 동작합니다 — // 사이드바 / 추천 / 관련 기사 / 카테고리 메뉴 등 ItemContainer 가 놓치는 영역까지 // 같은 fetch 비용으로 발견. **nil 일 때만** 기존 ItemContainer 경로로 fallback — // 객체가 채워져 있으면 ArticleURLPattern 이 빈 문자열이어도 discovery 모드 (all-pass). // - // 권장 활용: 사이트 전체에서 모든 의미 있는 글을 발견하고자 할 때 (이슈 #100 도메인 일반화). + // 권장 활용: 사이트 전체에서 모든 의미 있는 글을 발견하고자 할 때. // noise 는 ExcludePatterns + MaxLinksPerPage + 다운스트림 URL dedup / rate limiter 가 흡수. LinkDiscovery *LinkDiscoveryConfig `json:"link_discovery,omitempty"` } -// LinkDiscoveryConfig 는 full-page link discovery 모드의 설정입니다 (이슈 #139, #148). +// LinkDiscoveryConfig 는 full-page link discovery 모드의 설정입니다. // // LinkDiscoveryConfig drives the rule-based full-page discovery, replacing // site-specific ItemContainer extraction with a generic policy-based filter. @@ -111,7 +111,7 @@ type LinkDiscoveryConfig struct { // MaxLinksPerPage: 한 페이지에서 발행할 최대 링크 수 (0 = 무제한). // - // 우선순위 정책 (이슈 #148): + // 우선순위 정책: // 1. same-origin (raw.URL host 와 동일) 링크는 cap 무시하고 모두 통과 // 2. cross-origin 은 잔여 슬롯 (cap - len(same)) 만큼 무작위 sample // 3. 0 (무제한) 이면 same + cross 모두 통과 @@ -131,13 +131,13 @@ type ParsingRuleRecord struct { ID int64 SourceName string // "naver" / "cnn" HostPattern string // URL host 매칭 (예: "n.news.naver.com") - PathPattern string // URL path regex (RE2). "" 면 모든 path 매칭 (이슈 #173 단계 1). + PathPattern string // URL path regex (RE2). "" 면 모든 path 매칭. TargetType TargetType // "page" | "list" Version int // 활성 row 안에서 같은 (source, host, path, type) 의 최신 버전 Enabled bool Selectors SelectorMap // JSONB — application 측 struct 로 직렬화 - // Confidence 는 필드별 selector 추출 신뢰도 metadata 입니다 (이슈 #283). + // Confidence 는 필드별 selector 추출 신뢰도 metadata 입니다. // // LLM 자동 생성 룰이 INSERT 시점에 각 selector 를 sample HTML 에 적용하여 hit_rate 계산. // 하류 validator 는 본 metadata 로 host 별 차별화된 정책 적용 가능 (예: published_at hit_rate=0 @@ -151,7 +151,7 @@ type ParsingRuleRecord struct { UpdatedAt time.Time } -// FieldConfidence 는 단일 필드의 추출 신뢰도 (이슈 #283). +// FieldConfidence 는 단일 필드의 추출 신뢰도. // // - HitRate : 0.0~1.0 — sample 중 selector 가 매칭 + 유효 결과를 산출한 비율 // - SampleCount : 분모 — 신뢰도 계산에 사용된 sample 수 (단일 sample 환경은 1) @@ -185,11 +185,11 @@ type ParsingRuleRepository interface { // 갱신 가능 필드: Selectors, Enabled, Description (자연키는 변경 불가). Update(ctx context.Context, r *ParsingRuleRecord) error - // UpdatePathPattern 은 정밀화 워크플로 (이슈 #173 단계 4-2) 에서 호출 — path_pattern + description 갱신. + // UpdatePathPattern 은 정밀화 워크플로 에서 호출 — path_pattern + description 갱신. // // pattern 이 비어있지 않으면 RE2 컴파일 검증 (Insert 와 동일 정책) — 실패 시 ErrInvalid. // - // optimistic guard (PR #191 CodeRabbit 피드백): 대상 rule 이 여전히 + // optimistic guard: 대상 rule 이 여전히 // (source_name='llm-auto' AND enabled=TRUE AND path_pattern='') 상태일 때만 적용. // 가드 실패 또는 rule 미존재 시 ErrNotFound — 호출자가 lost-update 회피용으로 분기. // @@ -203,13 +203,13 @@ type ParsingRuleRepository interface { // 같은 (host, type) 에 여러 활성 row 가 있다면 version DESC 순으로 첫 항목 반환. // 매칭 없으면 ErrNotFound. // - // Deprecated (이슈 #173): path_pattern 도입 후 후보 슬라이스를 한꺼번에 받아 application 측에서 + // Deprecated: path_pattern 도입 후 후보 슬라이스를 한꺼번에 받아 application 측에서 // 매칭하는 FindActiveCandidates 사용 권장. 본 메소드는 후방 호환을 위해 유지 — 내부적으로 // FindActiveCandidates 의 첫 항목 (length DESC 정렬, '' 포함) 을 반환합니다. FindActive(ctx context.Context, host string, targetType TargetType) (*ParsingRuleRecord, error) // InsertNextVersion 은 (source_name, host_pattern, path_pattern, target_type) 자연키의 다음 - // version 으로 rec 을 INSERT 합니다 (이슈 #282). + // version 으로 rec 을 INSERT 합니다. // // 사용처: // - Stale rule 재학습: 기존 v1 (catch-all enabled=true) 잔존 + 신규 v2 (정밀 / 갱신 selector) @@ -225,7 +225,7 @@ type ParsingRuleRepository interface { // rec.Version 은 입력 무관 (자동 계산). 성공 시 rec.ID / Version / CreatedAt / UpdatedAt 채워짐. InsertNextVersion(ctx context.Context, r *ParsingRuleRecord) error - // HasAnyRule 은 (host_pattern, target_type) 에 대한 룰 존재 여부를 반환합니다 (이슈 #287). + // HasAnyRule 은 (host_pattern, target_type) 에 대한 룰 존재 여부를 반환합니다. // // FindActiveCandidates 와 달리 enabled 필터 없음 — disabled 룰도 \"존재함\" 으로 카운트. // @@ -239,7 +239,7 @@ type ParsingRuleRepository interface { HasAnyRule(ctx context.Context, hostPattern string, targetType TargetType) (exists, hasEnabled bool, err error) // FindByNaturalKey 는 자연키 (source_name, host_pattern, path_pattern, target_type, version) - // 로 단일 rule 을 조회합니다 (이슈 #274). enabled 필터 없음 — disabled 룰도 반환. + // 로 단일 rule 을 조회합니다. enabled 필터 없음 — disabled 룰도 반환. // // 용도: llmgen.Generator 가 Insert 전에 동일 자연키 룰의 존재 여부를 확인하여 LLM 호출을 // 회피하는 사전 lookup. Insert 시 ErrDuplicate 위반과 동일한 자연키를 검사합니다. @@ -248,7 +248,7 @@ type ParsingRuleRepository interface { FindByNaturalKey(ctx context.Context, sourceName, hostPattern, pathPattern string, targetType TargetType, version int) (*ParsingRuleRecord, error) // FindActiveCandidates 는 host + target_type 매칭 활성 rule 들을 LENGTH(path_pattern) DESC, - // version DESC 정렬로 반환합니다 (이슈 #173 단계 1). + // version DESC 정렬로 반환합니다. // // Resolver 가 반환된 슬라이스를 application 측에서 URL path 와 regex 매칭 — 첫 매칭 rule 채택. // path_pattern='' 인 row 는 길이 0 으로 가장 마지막에 위치 (catch-all). diff --git a/internal/storage/postgres/blacklist.go b/internal/storage/postgres/blacklist.go index 0e8ffb7f..e6bd4074 100644 --- a/internal/storage/postgres/blacklist.go +++ b/internal/storage/postgres/blacklist.go @@ -16,7 +16,7 @@ import ( "issuetracker/pkg/logger" ) -// pgBlacklistRepository 는 pgx/v5 기반 BlacklistRepository 구현체입니다 (이슈 #295). +// pgBlacklistRepository 는 pgx/v5 기반 BlacklistRepository 구현체입니다. type pgBlacklistRepository struct { pool *pgxpool.Pool } @@ -39,7 +39,7 @@ RETURNING id, created_at, updated_at // path_pattern 이 비어있지 않으면 RE2 컴파일 검증 — parsing_rules.Insert 와 동일 정책 (DB write // 전 잘못된 regex 거부, 운영 가시성 ↑ + Matcher 가 매 호출마다 negative cache 로 흡수하지 않도록). // -// PR #296 gemini 피드백: HostPattern 을 lowercase 로 정규화 — BlacklistMatcher 가 host 를 +// HostPattern 을 lowercase 로 정규화 — BlacklistMatcher 가 host 를 // lowercase 로 lookup 하므로 저장 시점에도 동일 정규화 필수 (대소문자 섞인 등록 시 미스매치 회피). func (r *pgBlacklistRepository) Insert(ctx context.Context, rec *storage.BlacklistRecord) error { if rec.PathPattern != "" { @@ -134,7 +134,7 @@ ORDER BY LENGTH(path_pattern) DESC, id DESC // 이 가장 마지막. tie-break 로 id DESC (최근 등록 우선) 사용. // // host 는 lowercase 로 정규화된 상태 가정 (Matcher 가 lowercase 로 호출). DB 에 저장된 host_pattern -// 도 Insert 시 lowercase 정규화 (PR #296 gemini 피드백) — 양쪽 일치 보장. +// 도 Insert 시 lowercase 정규화 — 양쪽 일치 보장. func (r *pgBlacklistRepository) FindEnabledByHost(ctx context.Context, host string) ([]*storage.BlacklistRecord, error) { rows, err := r.pool.Query(ctx, sqlFindEnabledBlacklistByHost, strings.ToLower(host)) if err != nil { @@ -171,7 +171,7 @@ WHERE ($1 = '' OR host_pattern = $1) ORDER BY id DESC LIMIT $4 OFFSET $5 ` - // HostPattern 을 lowercase 로 정규화 — Insert 와 동일 정책 (PR #296 gemini 피드백). + // HostPattern 을 lowercase 로 정규화 — Insert 와 동일 정책. // 저장은 lowercase 이므로 검색 조건도 lowercase 여야 일치. rows, err := r.pool.Query(ctx, q, strings.ToLower(f.HostPattern), string(f.Source), f.OnlyEnabled, limit, f.Offset, diff --git a/internal/storage/postgres/content.go b/internal/storage/postgres/content.go index cd4df4ce..984397d9 100644 --- a/internal/storage/postgres/content.go +++ b/internal/storage/postgres/content.go @@ -184,7 +184,7 @@ WHERE id = $1 // UpdateValidationStatus updates validator result metadata for the content with the given id. // status==Rejected 가 아닐 때 code/detail 은 NULL 로 저장됩니다. 미존재 시 storage.ErrNotFound. // -// id 사용 이유 (PR #163 피드백): primary key 가 url unique 인덱스 lookup 보다 효율적이고, +// id 사용 이유: primary key 가 url unique 인덱스 lookup 보다 효율적이고, // URL 정규화 정책 변동에 영향받지 않습니다. updated_at 도 함께 갱신하여 audit trail 보장. func (r *pgContentRepository) UpdateValidationStatus(ctx context.Context, id, status, code, detail string) error { var ( diff --git a/internal/storage/postgres/fetcher_rule.go b/internal/storage/postgres/fetcher_rule.go index 8b020995..2545c174 100644 --- a/internal/storage/postgres/fetcher_rule.go +++ b/internal/storage/postgres/fetcher_rule.go @@ -20,7 +20,7 @@ func canonicalizeHost(host string) string { return strings.ToLower(strings.TrimSpace(host)) } -// pgFetcherRuleRepository 는 pgx/v5 기반 FetcherRuleRepository 구현체입니다 (이슈 #175 단계 1). +// pgFetcherRuleRepository 는 pgx/v5 기반 FetcherRuleRepository 구현체입니다. type pgFetcherRuleRepository struct { pool *pgxpool.Pool } @@ -30,7 +30,7 @@ type pgFetcherRuleRepository struct { // log 인자는 향후 query latency / error log 등 운영 가시성 추가를 대비해 시그니처에 유지하되, // 현재 구현에서는 사용하지 않습니다 — 다른 Repository 들과 일관된 시그니처. // -// pool 이 nil 이면 error 반환 (이슈 #208 의 panic-on-nil → error 마이그레이션 정책). +// pool 이 nil 이면 error 반환. func NewFetcherRuleRepository(pool *pgxpool.Pool, log *logger.Logger) (storage.FetcherRuleRepository, error) { if pool == nil { return nil, errors.New("postgres: NewFetcherRuleRepository requires non-nil pool") @@ -152,7 +152,7 @@ func (r *pgFetcherRuleRepository) Delete(ctx context.Context, host string) error return nil } -// sqlBulkDowngradeAutoUpgraded 는 자동 upgrade 로 chromedp 가 된 모든 host 를 goquery 로 다시 내립니다 (이슈 #224). +// sqlBulkDowngradeAutoUpgraded 는 자동 upgrade 로 chromedp 가 된 모든 host 를 goquery 로 다시 내립니다. // // 매칭 조건은 reason='auto_upgrade_validation' AND fetcher='chromedp' — manual rule 보호 + 미래 자동 reason 영향 차단. // DELETE 대신 UPDATE — created_at 등 audit trail 보존. diff --git a/internal/storage/postgres/parsing_rule.go b/internal/storage/postgres/parsing_rule.go index cb62c15f..3b379837 100644 --- a/internal/storage/postgres/parsing_rule.go +++ b/internal/storage/postgres/parsing_rule.go @@ -17,7 +17,7 @@ import ( "issuetracker/pkg/logger" ) -// pgParsingRuleRepository 는 pgx/v5 기반 ParsingRuleRepository 구현체입니다 (이슈 #100). +// pgParsingRuleRepository 는 pgx/v5 기반 ParsingRuleRepository 구현체입니다. type pgParsingRuleRepository struct { pool *pgxpool.Pool } @@ -44,7 +44,7 @@ RETURNING id, created_at, updated_at // Insert 는 새 규칙을 저장합니다. 자연키 (source_name, host_pattern, path_pattern, target_type, version) // 충돌 시 storage.ErrDuplicate 를 반환합니다. // -// path_pattern 이 비어있지 않으면 RE2 컴파일 검증 — 실패 시 storage.ErrInvalid (이슈 #173). +// path_pattern 이 비어있지 않으면 RE2 컴파일 검증 — 실패 시 storage.ErrInvalid. // DB write 전 마지막 방어선 — Resolver 가 매 호출마다 컴파일 실패한 패턴을 스킵하도록 두기보다, // INSERT 시점에 거부하는 것이 운영 가시성 ↑ + DB 에 잘못된 row 진입 차단. // @@ -90,7 +90,7 @@ RETURNING updated_at // Update 는 ID 로 규칙을 갱신합니다. 자연키 (source/host/path/type/version) 는 변경 불가 — // 규칙 진화는 새 row 를 INSERT 후 enabled flip 으로 표현 권장. // -// PR #293 CodeRabbit Major: confidence 도 함께 갱신 — selectors 만 바뀌고 confidence 가 stale +// confidence 도 함께 갱신 — selectors 만 바뀌고 confidence 가 stale // 로 남으면 하류 validator 가 잘못된 신뢰도로 판단. 호출자는 selectors 변경 시 confidence 도 // 같이 채워서 전달 (또는 nil/빈 map 으로 reset). func (r *pgParsingRuleRepository) Update(ctx context.Context, rec *storage.ParsingRuleRecord) error { @@ -114,7 +114,7 @@ func (r *pgParsingRuleRepository) Update(ctx context.Context, rec *storage.Parsi // sqlUpdatePathPattern 은 catch-all + llm-auto + enabled 상태 가드를 포함한 optimistic update 입니다. // -// PR #191 CodeRabbit 피드백 — 단순 `WHERE id=$1` 은 lost-update 윈도우 발생: +// 단순 `WHERE id=$1` 은 lost-update 윈도우 발생: // - 다중 issuetracker 인스턴스에서 동시 정밀화 시 마지막 writer 만 적용 // - 운영자가 List 직후 rule 의 source_name / enabled / path_pattern 을 수동 변경했을 때 덮어씀 // @@ -130,7 +130,7 @@ WHERE id = $1 RETURNING updated_at ` -// UpdatePathPattern 은 정밀화 워크플로 (이슈 #173 단계 4-2) 에서 호출 — path_pattern + description 갱신. +// UpdatePathPattern 은 정밀화 워크플로 에서 호출 — path_pattern + description 갱신. // // pattern != "" 이면 RE2 컴파일 검증 (Insert 와 동일 정책) — 실패 시 storage.ErrInvalid. // rule 이 미존재하거나 catch-all + llm-auto + enabled 상태가 아니면 storage.ErrNotFound. @@ -179,7 +179,7 @@ WHERE source_name = $1 AND version = $5 ` -// FindByNaturalKey 는 자연키로 단일 rule 을 조회합니다 (이슈 #274). +// FindByNaturalKey 는 자연키로 단일 rule 을 조회합니다. // // Insert 의 unique constraint 와 동일한 키 — enabled 필터 없음. // 매칭 없으면 storage.ErrNotFound. @@ -209,7 +209,7 @@ WHERE source_name = $1 AND target_type = $4 ` -// InsertNextVersion 은 자연키의 max(version)+1 로 rec 을 INSERT 합니다 (이슈 #282). +// InsertNextVersion 은 자연키의 max(version)+1 로 rec 을 INSERT 합니다. // // race window: MAX 조회와 INSERT 사이에 다른 인스턴스가 같은 version 을 INSERT 할 수 있음. // 자연키 unique 제약이 ErrDuplicate 로 반응 — 호출자가 retry 또는 흡수 책임. @@ -230,10 +230,10 @@ func (r *pgParsingRuleRepository) InsertNextVersion(ctx context.Context, rec *st } // sqlHasAnyRule 은 (host_pattern, target_type) 에 대한 enabled / disabled 무관 존재 여부를 -// 1 row 로 반환합니다 (이슈 #287). +// 1 row 로 반환합니다. // // 단일 aggregate query — host_pattern + target_type 인덱스 스캔 1회로 exists / has_enabled 동시 산출. -// 매칭 row 없으면 COUNT(*)=0 / bool_or NULL → COALESCE 로 (FALSE, FALSE) 보장 (PR #291 Copilot 리뷰). +// 매칭 row 없으면 COUNT(*)=0 / bool_or NULL → COALESCE 로 (FALSE, FALSE) 보장. const sqlHasAnyRule = ` SELECT COUNT(*) > 0 AS exists_any, @@ -242,7 +242,7 @@ FROM parsing_rules WHERE host_pattern=$1 AND target_type=$2 ` -// HasAnyRule 은 (host_pattern, target_type) 룰 존재 여부 + enabled 여부를 1 round-trip 으로 반환합니다 (이슈 #287). +// HasAnyRule 은 (host_pattern, target_type) 룰 존재 여부 + enabled 여부를 1 round-trip 으로 반환합니다. // // FindActiveCandidates 와 달리 enabled 필터 없음 — disabled 룰도 \"존재함\" 으로 카운트. // 결과는 (exists, hasEnabled). @@ -255,7 +255,7 @@ func (r *pgParsingRuleRepository) HasAnyRule(ctx context.Context, hostPattern st return exists, hasEnabled, nil } -// sqlFindActiveCandidates 는 host + target_type 매칭 활성 rule 들을 후보 슬라이스로 반환합니다 (이슈 #173). +// sqlFindActiveCandidates 는 host + target_type 매칭 활성 rule 들을 후보 슬라이스로 반환합니다. // // 정렬: // - LENGTH(path_pattern) DESC : 더 구체적인 (긴) regex 패턴 우선 (path_pattern=” 은 길이 0 으로 마지막) @@ -271,7 +271,7 @@ ORDER BY LENGTH(path_pattern) DESC, version DESC // FindActive 는 host + target_type 매칭 활성 규칙 1건을 반환합니다 (후방 호환). // -// Deprecated (이슈 #173): path_pattern 도입 후 후보 슬라이스를 받아 application 측에서 path 매칭하는 +// Deprecated: path_pattern 도입 후 후보 슬라이스를 받아 application 측에서 path 매칭하는 // FindActiveCandidates 사용 권장. 본 메소드는 호출자 호환을 위해 유지 — 내부적으로 후보 슬라이스의 // 첫 항목 (가장 구체적 또는 최신) 을 반환. ErrNotFound 시 storage.ErrNotFound. func (r *pgParsingRuleRepository) FindActive(ctx context.Context, host string, targetType storage.TargetType) (*storage.ParsingRuleRecord, error) { @@ -286,7 +286,7 @@ func (r *pgParsingRuleRepository) FindActive(ctx context.Context, host string, t } // FindActiveCandidates 는 host + target_type 매칭 활성 rule 들을 LENGTH(path_pattern) DESC, -// version DESC 정렬로 반환합니다 (이슈 #173). +// version DESC 정렬로 반환합니다. // // 매칭 없으면 빈 슬라이스 + nil 에러 (호출자가 빈 슬라이스로 분기). func (r *pgParsingRuleRepository) FindActiveCandidates(ctx context.Context, host string, targetType storage.TargetType) ([]*storage.ParsingRuleRecord, error) { @@ -377,7 +377,7 @@ func (r *pgParsingRuleRepository) Delete(ctx context.Context, id int64) error { } // scanParsingRule 은 Row/Rows 에서 ParsingRuleRecord 를 스캔합니다. -// selectors / confidence 는 raw JSONB → application struct 로 unmarshal (이슈 #283). +// selectors / confidence 는 raw JSONB → application struct 로 unmarshal. func scanParsingRule(s scanner) (*storage.ParsingRuleRecord, error) { rec := &storage.ParsingRuleRecord{} var selectorsRaw, confidenceRaw []byte @@ -402,7 +402,7 @@ func scanParsingRule(s scanner) (*storage.ParsingRuleRecord, error) { return rec, nil } -// marshalConfidence 는 Confidence map 을 JSONB byte 로 직렬화합니다 (이슈 #283). +// marshalConfidence 는 Confidence map 을 JSONB byte 로 직렬화합니다. // // nil 또는 빈 map 은 "{}" 로 직렬화 — JSONB NOT NULL 제약 충족 + scan 시 빈 map 으로 복원. func marshalConfidence(c map[string]storage.FieldConfidence) ([]byte, error) { diff --git a/internal/storage/postgres/sample_url.go b/internal/storage/postgres/sample_url.go index 075b7493..e0591aaf 100644 --- a/internal/storage/postgres/sample_url.go +++ b/internal/storage/postgres/sample_url.go @@ -13,7 +13,7 @@ import ( "issuetracker/pkg/logger" ) -// pgSampleURLRepository 는 pgx/v5 기반 SampleURLRepository 구현체입니다 (이슈 #173 단계 4-1). +// pgSampleURLRepository 는 pgx/v5 기반 SampleURLRepository 구현체입니다. type pgSampleURLRepository struct { pool *pgxpool.Pool } @@ -38,7 +38,7 @@ INSERT INTO parsing_rule_sample_urls (rule_id, url) VALUES ($1, $2) ` -// Insert 는 sample URL 을 누적합니다 (이슈 #173 단계 4-1). +// Insert 는 sample URL 을 누적합니다. // // 정책: // - 같은 (rule_id, url) 이미 있으면 storage.ErrDuplicate 반환 — 호출자가 무시 가능 (이미 누적됨) diff --git a/internal/storage/sample_url.go b/internal/storage/sample_url.go index 3245f770..7bd06d81 100644 --- a/internal/storage/sample_url.go +++ b/internal/storage/sample_url.go @@ -5,7 +5,7 @@ import ( "time" ) -// SampleURL 은 parsing_rule_sample_urls 테이블의 단일 행입니다 (이슈 #173 단계 4-1). +// SampleURL 은 parsing_rule_sample_urls 테이블의 단일 행입니다. // // SampleURL represents a single accumulated sample URL for a parsing rule — // used by the progressive refinement workflow (path_pattern 추론). @@ -22,7 +22,7 @@ type SampleURL struct { // trigger 미동작 / LLM_ENABLED=false 등으로 정밀화가 발생하지 않을 때 DB 폭증 방어. const SampleCapPerRule = 100 -// SampleURLRepository 는 parsing_rule_sample_urls 테이블에 대한 데이터 접근 인터페이스입니다 (이슈 #173 단계 4-1). +// SampleURLRepository 는 parsing_rule_sample_urls 테이블에 대한 데이터 접근 인터페이스입니다. // // 모든 구현체는 goroutine-safe 해야 합니다. type SampleURLRepository interface { diff --git a/internal/storage/service/content.go b/internal/storage/service/content.go index 81123145..0bbcae0c 100644 --- a/internal/storage/service/content.go +++ b/internal/storage/service/content.go @@ -48,7 +48,7 @@ type ContentService interface { // 존재하지 않아도 에러를 반환하지 않습니다. Delete(ctx context.Context, id string) error - // UpdateValidationStatus updates validator result metadata for the given content id (이슈 #135 / #161). + // UpdateValidationStatus updates validator result metadata for the given content id. // 자세한 내용은 storage.ContentRepository.UpdateValidationStatus 참조. UpdateValidationStatus(ctx context.Context, id, status, code, detail string) error } diff --git a/internal/storage/service/raw_content.go b/internal/storage/service/raw_content.go index 9e21fe5a..1d384b12 100644 --- a/internal/storage/service/raw_content.go +++ b/internal/storage/service/raw_content.go @@ -23,7 +23,7 @@ type RawContentService interface { GetByID(ctx context.Context, id string) (*core.RawContent, error) // Delete는 ID로 RawContent를 삭제합니다 (idempotent — 미존재여도 nil). - // parser worker (이슈 #134) 가 파싱 완료된 raw_contents row 를 즉시 정리할 때 사용 (Claim Check 패턴). + // parser worker 가 파싱 완료된 raw_contents row 를 즉시 정리할 때 사용 (Claim Check 패턴). Delete(ctx context.Context, id string) error // List는 필터 조건에 맞는 RawContent 목록을 반환합니다. diff --git a/internal/storage/validation_status.go b/internal/storage/validation_status.go index 384e72cf..e7d45b86 100644 --- a/internal/storage/validation_status.go +++ b/internal/storage/validation_status.go @@ -1,4 +1,4 @@ -// Package storage — validation status lifecycle constants (이슈 #135 / 이슈 #161). +// Package storage — validation status lifecycle constants. package storage // ValidationStatus values represent the lifecycle state of a validator's content review. @@ -13,7 +13,7 @@ package storage // - Passed : validator 통과 (issuetracker.validated 발행 직후) // - Rejected : validator maxRetries 영구 실패 (contentSvc.Delete 직전) // -// 본 상수는 contents 테이블의 validation_status 컬럼 값과 1:1 매핑됩니다 (이슈 #161 도메인 +// 본 상수는 contents 테이블의 validation_status 컬럼 값과 1:1 매핑됩니다 (도메인 // 중립화로 news_articles 에서 contents 로 이전). const ( ValidationStatusPending = "pending" diff --git a/pkg/config/config.go b/pkg/config/config.go index da2b9007..52832f9a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,7 +13,7 @@ import ( "github.com/joho/godotenv" ) -// MetricsConfig는 Prometheus /metrics endpoint 노출 설정을 나타냅니다 (이슈 #165). +// MetricsConfig는 Prometheus /metrics endpoint 노출 설정을 나타냅니다. // // MetricsConfig holds settings for the Prometheus /metrics HTTP endpoint. type MetricsConfig struct { @@ -45,7 +45,7 @@ func LoadMetrics(envFiles ...string) (MetricsConfig, error) { return cfg, nil } -// ShutdownConfig 는 graceful shutdown 시 대기 시간 설정입니다 (이슈 #272). +// ShutdownConfig 는 graceful shutdown 시 대기 시간 설정입니다. // // 배경: claudegen LLM 추출기 활성 시 in-flight Extract 호출 latency 가 p95 110s 까지 늘어나, // 기존 hardcode 30s shutdownCtx 가 정기적으로 deadline exceeded 를 트리거. 운영자가 @@ -292,7 +292,7 @@ func LoadClassifier(envFiles ...string) (ClassifierConfig, error) { return cfg, nil } -// PathInferConfig 는 pathinfer 휴리스틱의 설정입니다 (이슈 #173 단계 2). +// PathInferConfig 는 pathinfer 휴리스틱의 설정입니다. // // pathinfer 패키지의 InferHeuristic 동작을 운영자가 환경변수로 조정 가능하도록. type PathInferConfig struct { @@ -335,14 +335,14 @@ func LoadPathInfer(envFiles ...string) (PathInferConfig, error) { return cfg, nil } -// FetcherChromedpPoolConfig 는 chromedp 전용 worker pool 의 wiring 설정입니다 (이슈 #218). +// FetcherChromedpPoolConfig 는 chromedp 전용 worker pool 의 wiring 설정입니다. // // goquery worker pool 과 분리된 별도 Kafka consumer group 을 운영하며, worker 의 chromedp 호출 // 직전에 Semaphore.Acquire 로 Chrome 인스턴스의 동시 navigation 수를 제한해 ResourceScheduler // 큐 고갈 (ERR_INSUFFICIENT_RESOURCES) 을 차단. // -// 이슈 #229 — Semaphore 의미 변경 + 실효 동시성 정정 (gemini 피드백): -// PR #227 의 글로벌 Semaphore 1 개 (전체 worker 공유) 모델에서, worker_id 별 Semaphore 1 개로 +// Semaphore 의미 변경 + 실효 동시성 정정 (gemini 피드백): +// 글로벌 Semaphore 1 개 (전체 worker 공유) 모델에서, worker_id 별 Semaphore 1 개로 // 분리. **단, KafkaConsumerPool 의 worker goroutine 은 jobs 채널에서 메시지를 1 개씩 꺼내 // 순차 처리** 하므로 (pool.go 의 worker 루프 참조), 같은 worker 가 동시 2 개 이상의 Handle 을 // 호출할 수 없음 → per-worker SemaphoreCapacity > 1 은 현 모델에서 추가 동시성 이득 없음. @@ -368,7 +368,7 @@ type FetcherChromedpPoolConfig struct { // 환경변수 FETCHER_CHROMEDP_WORKER_COUNT (default 2). WorkerCount int - // SemaphoreCapacity: per-worker Semaphore 슬롯 수 (이슈 #229). + // SemaphoreCapacity: per-worker Semaphore 슬롯 수. // **현 KafkaConsumerPool 모델에서는 1 이상 의미 없음** — worker 가 메시지를 순차 처리하므로 // 같은 worker 의 동시 Acquire 가 발생하지 않음. 옵션은 미래 worker-내 동시 분기 시나리오 // (예: 한 worker 가 메시지 1건을 여러 sub-tab 으로 분할) 대비 보존. @@ -376,7 +376,7 @@ type FetcherChromedpPoolConfig struct { // 환경변수 FETCHER_CHROMEDP_SEMAPHORE_CAPACITY (default 1). SemaphoreCapacity int - // RemoteURLs: worker_id 별 Chrome 인스턴스의 CDP WebSocket URL 매핑 (이슈 #230). + // RemoteURLs: worker_id 별 Chrome 인스턴스의 CDP WebSocket URL 매핑. // 길이는 WorkerCount 와 일치해야 하며 (LoadFetcherChromedpPool 가 검증), 사이트별 ChromedpCrawler // 가 worker_id 인덱스로 자기 전용 RemoteURL 을 사용 → worker:Chrome 1:1 매핑. // @@ -389,13 +389,13 @@ type FetcherChromedpPoolConfig struct { // DefaultFetcherChromedpPoolConfig 는 기본 FetcherChromedpPoolConfig 를 반환합니다. // -// 이슈 #229 — default 재조정 (gemini 피드백 반영): +// default 재조정 (gemini 피드백 반영): // SemaphoreCapacity 가 현 KafkaConsumerPool 순차 처리 모델에서 1 이상 의미 없으므로 default 1. // 기존 운영자가 환경변수로 4 를 명시했다면 — 그 값은 무시되지 않고 그대로 적용되지만 (slot 수 // 4 의 sem 이 worker 별로 만들어짐) 실효 동시성은 변하지 않음 (worker 1 + slot 4 = 동시 1건). // 처리량 변경은 WorkerCount 환경변수로만 조정 가능. // -// 이슈 #230 — RemoteURLs default: +// RemoteURLs default: // FETCHER_CHROMEDP_REMOTE_URLS 미지정 시 LoadFetcherChromedpPool 가 ws://localhost:9222 을 // WorkerCount 만큼 복제하여 채움 — 기존 단일 Chrome 운영 호환 (이전 동작 100% 보존). // Default 함수 자체는 빈 slice 반환 — Load 가 WorkerCount 결정 후 채움. @@ -414,7 +414,7 @@ func DefaultFetcherChromedpPoolConfig() FetcherChromedpPoolConfig { // - FETCHER_CHROMEDP_POOL_ENABLED: true | false (default true) // - FETCHER_CHROMEDP_WORKER_COUNT: 양의 정수 (default 2) — 실질 전체 동시 navigate 수 // - FETCHER_CHROMEDP_SEMAPHORE_CAPACITY: 양의 정수, per-worker (default 1, 1 이상 의미 없음) -// - FETCHER_CHROMEDP_REMOTE_URLS: 콤마 구분 CDP WS URL 리스트 (이슈 #230). 명시 시 가장 우선. +// - FETCHER_CHROMEDP_REMOTE_URLS: 콤마 구분 CDP WS URL 리스트. 명시 시 가장 우선. // 길이가 WorkerCount 와 일치해야 함 — 미일치 시 fail-fast. // - FETCHER_CHROMEDP_REMOTE_URL_PATTERN: {n} placeholder 를 1..WorkerCount 로 치환하여 RemoteURLs // 자동 생성 (예: "ws://chrome-{n}:9222"). REMOTE_URLS 미지정 시 적용. {n} 누락 시 fail-fast @@ -458,7 +458,7 @@ func LoadFetcherChromedpPool(envFiles ...string) (FetcherChromedpPoolConfig, err cfg.SemaphoreCapacity = n } - // 이슈 #230: RemoteURLs 처리 — 우선순위: + // RemoteURLs 처리 — 우선순위: // 1. FETCHER_CHROMEDP_REMOTE_URLS (콤마 구분 list, 명시적 매핑) // 2. FETCHER_CHROMEDP_REMOTE_URL_PATTERN ({n} placeholder, 1..WorkerCount 치환) // 3. default (ws://localhost:9222 × WorkerCount — 단일 Chrome 호환) @@ -504,7 +504,7 @@ func LoadFetcherChromedpPool(envFiles ...string) (FetcherChromedpPoolConfig, err return cfg, nil } -// FetcherAutoDowngradeConfig 는 자동 upgrade 된 host 를 주기적으로 goquery 로 되돌리는 안전장치 설정입니다 (이슈 #175 후속, sub-issue #224). +// FetcherAutoDowngradeConfig 는 자동 upgrade 된 host 를 주기적으로 goquery 로 되돌리는 안전장치 설정입니다. // // upgrade-only 비대칭으로 인해 일시적 트래픽 에러로 잘못 upgrade 된 host 가 영원히 chromedp 로 // 처리되어 시간 누적 시 모든 host 가 chromedp 로 수렴 → Chrome 자원 압박. 본 cron 이 주기적 @@ -563,7 +563,7 @@ func LoadFetcherAutoDowngrade(envFiles ...string) (FetcherAutoDowngradeConfig, e return cfg, nil } -// FetcherAutoUpgradeConfig 는 host 단위 fetcher 실패 누적 → chromedp 자동 전환 정책 설정입니다 (이슈 #175 단계 2, sub-issue #220). +// FetcherAutoUpgradeConfig 는 host 단위 fetcher 실패 누적 → chromedp 자동 전환 정책 설정입니다. // // 본 단계는 카운팅 + 임계값 도달 신호 발신까지만 — 실제 fetcher_rules UPSERT / 실패 raw republish 는 단계 3 (#221) 의 책임. // @@ -581,7 +581,7 @@ type FetcherAutoUpgradeConfig struct { // Window: sliding window 길이. 환경변수 FETCHER_AUTO_UPGRADE_WINDOW (Go duration, default 1h). Window time.Duration - // EmptyBodyTitleMin / EmptyBodyContentMin: 빈본문 판정 임계값 (이슈 #220 단계 2 확장 신호). + // EmptyBodyTitleMin / EmptyBodyContentMin: 빈본문 판정 임계값. // parse 자체는 성공했지만 결과 텍스트가 너무 짧은 경우도 실패 신호로 카운팅. // 환경변수 FETCHER_EMPTY_BODY_TITLE_MIN (default 5), FETCHER_EMPTY_BODY_CONTENT_MIN (default 100). EmptyBodyTitleMin int @@ -668,7 +668,7 @@ func LoadFetcherAutoUpgrade(envFiles ...string) (FetcherAutoUpgradeConfig, error return cfg, nil } -// StaleRelearnConfig 는 stale rule (parse_failure / empty_selector) 누적 → LLM 자동 재학습 정책 설정입니다 (이슈 #282). +// StaleRelearnConfig 는 stale rule (parse_failure / empty_selector) 누적 → LLM 자동 재학습 정책 설정입니다. // // FetcherAutoUpgrade 와 별개의 keyspace + 더 긴 윈도우 / 더 높은 임계값 — chromedp 자동 전환을 // 먼저 시도한 후, 그래도 실패가 지속되면 LLM 재학습 트리거. 임계 도달 시 Generator.EnqueueStale @@ -743,7 +743,7 @@ func LoadStaleRelearn(envFiles ...string) (StaleRelearnConfig, error) { return cfg, nil } -// BlacklistConfig 는 page-parse 블랙리스트 wiring 설정입니다 (이슈 #295). +// BlacklistConfig 는 page-parse 블랙리스트 wiring 설정입니다. // // Enabled=false 면 BlacklistMatcher 미주입 — parser_worker.processCategoryPage 가 모든 카테고리 // 링크를 그대로 article job 으로 발행 (기능 OFF). 운영 toggle 용도. @@ -780,12 +780,12 @@ func LoadBlacklist(envFiles ...string) (BlacklistConfig, error) { return cfg, nil } -// LLMConfig 는 LLM rule generator (이슈 #149) wiring 설정입니다. +// LLMConfig 는 LLM rule generator wiring 설정입니다. // // LLMConfig drives the LLM provider used for auto-generating parsing rules when // a host has no rule registered (rule.ErrNoRule fallback). // -// 본 PR scope: Gemini 만 사용 (1000회/일 무료 한도) + FixedOrder("gemini") 정책. +// Gemini 만 사용 (1000회/일 무료 한도) + FixedOrder("gemini") 정책. // 후속 PR (이슈 TBD) 에서 chain (gemini → openai → anthropic) 으로 확장. type LLMConfig struct { // Enabled: false 면 rule generator wiring 자체 skip (ErrNoRule 잔존 동작 유지). @@ -881,7 +881,7 @@ func lookupLLMAPIKey(provider string) string { return os.Getenv("LLM_API_KEY") } -// RefinementConfig 는 점진적 정밀화 워크플로의 설정입니다 (이슈 #173 단계 4-2). +// RefinementConfig 는 점진적 정밀화 워크플로의 설정입니다. // // catch-all + llm-auto rule 의 누적 sample URL 로부터 path_pattern 을 추론하여 자동 갱신. // @@ -995,12 +995,12 @@ type RedisConfig struct { ReadTimeout time.Duration // REDIS_READ_TIMEOUT (default: 3s) WriteTimeout time.Duration // REDIS_WRITE_TIMEOUT (default: 3s) PoolSize int // REDIS_POOL_SIZE (default: 10) - // IngestionLockTTL: 파이프라인 진입 marker 의 TTL (이슈 #178). + // IngestionLockTTL: 파이프라인 진입 marker 의 TTL. // publisher 가 atomic SETNX 로 marker 를 잡고, 본 TTL 만료 시 자연스럽게 재크롤 가능. // 환경변수: REDIS_INGESTION_LOCK_TTL (default 24h). IngestionLockTTL time.Duration - // PipelineGuardCategoryTTL: PipelineGuard 의 Category target 전용 단명 TTL (이슈 #285). + // PipelineGuardCategoryTTL: PipelineGuard 의 Category target 전용 단명 TTL. // fetch + ParseLinks 한 cycle 진행 중에만 marker 유지 — 정상 흐름은 명시적 Release, // 본 TTL 은 fallback (worker 가 release 호출 못 하고 죽은 경우 자동 회수). // 환경변수: PIPELINE_GUARD_CATEGORY_TTL (default 60s). @@ -1193,7 +1193,7 @@ type SchedulerConfig struct { JobTimeout time.Duration // 개별 Job 최대 실행 시간 — SCHEDULER_JOB_TIMEOUT (default: 30s) MaxRetries int // Job 최대 재시도 횟수 — SCHEDULER_MAX_RETRIES (default: 3) - // Backlog throttle (이슈 #124): publish 직전 Kafka crawl 토픽의 + // Backlog throttle: publish 직전 Kafka crawl 토픽의 // consumer-group lag 가 임계값 초과 시 발행 차단. // MaxBacklog <= 0 → throttle 비활성 (기본). MaxBacklog int64 // SCHEDULER_MAX_BACKLOG (default: 0 — disabled) diff --git a/pkg/llm/capabilities.go b/pkg/llm/capabilities.go index a399d78d..5a4fb0d1 100644 --- a/pkg/llm/capabilities.go +++ b/pkg/llm/capabilities.go @@ -2,7 +2,7 @@ package llm // Capabilities describes a provider/model's quantitative properties used by routing policies. // -// Capabilities 는 routing policy 가 provider 선택에 사용하는 정량적 속성입니다 (이슈 #144). +// Capabilities 는 routing policy 가 provider 선택에 사용하는 정량적 속성입니다. // 비용 / context window / 평균 latency 등을 표준 단위로 표현합니다. // // 모든 비용은 USD per 1M tokens, latency 는 milliseconds 입니다. @@ -24,9 +24,9 @@ type Capabilities struct { // CapabilitiesProvider returns the Capabilities for a given (provider, model) pair. // // 구현체: -// - StaticCapabilitiesProvider: 컴파일 시점 hardcode (초기 구현, 본 PR) +// - StaticCapabilitiesProvider: 컴파일 시점 hardcode (초기 구현) // - 향후 RefreshableCapabilitiesProvider: 주기 background goroutine 으로 외부 source -// (config 파일 / DB / pricing API) 에서 fetch 후 cache 갱신 (이슈 #144 후속) +// (config 파일 / DB / pricing API) 에서 fetch 후 cache 갱신 // // Get 은 lookup 결과가 없으면 (Capabilities{}, false) 반환합니다. type CapabilitiesProvider interface { @@ -35,7 +35,7 @@ type CapabilitiesProvider interface { // StaticCapabilitiesProvider returns Capabilities from a compile-time hardcoded table. // -// 본 구현은 본 PR 에서만 동기 lookup. RefreshableCapabilitiesProvider (후속) 는 동일 인터페이스를 +// 초기 구현은 동기 lookup 만. RefreshableCapabilitiesProvider (후속) 는 동일 인터페이스를 // 구현하면서 background refresh 로직만 추가하므로 호출자 코드 변경 없이 교체 가능합니다. type StaticCapabilitiesProvider struct { table map[capKey]Capabilities @@ -47,7 +47,7 @@ type capKey struct { } // NewStaticCapabilitiesProvider returns a StaticCapabilitiesProvider pre-populated with -// the well-known model pricing as of 2026-04 (이슈 #144 본문 참고). 운영 단가 변동 시 hardcode +// the well-known model pricing as of 2026-04. 운영 단가 변동 시 hardcode // 갱신 또는 RefreshableCapabilitiesProvider 로 교체. func NewStaticCapabilitiesProvider() *StaticCapabilitiesProvider { return &StaticCapabilitiesProvider{ @@ -79,7 +79,7 @@ func (s *StaticCapabilitiesProvider) Get(provider, model string) (Capabilities, } // defaultCapabilitiesTable returns the hardcoded pricing baseline (USD per 1M tokens). -// 단가 출처는 이슈 #144 본문 — 작업 시점 (2026-04) 기준이며 운영자가 주기 검증 필요. +// 단가 출처는 본문 — 작업 시점 (2026-04) 기준이며 운영자가 주기 검증 필요. func defaultCapabilitiesTable() map[capKey]Capabilities { return map[capKey]Capabilities{ // OpenAI diff --git a/pkg/llm/chain/chain.go b/pkg/llm/chain/chain.go index d90e69fb..9286a289 100644 --- a/pkg/llm/chain/chain.go +++ b/pkg/llm/chain/chain.go @@ -1,4 +1,4 @@ -// Package chain 은 여러 llm.Provider 를 Chain-of-Responsibility 패턴으로 합성합니다 (이슈 #142). +// Package chain 은 여러 llm.Provider 를 Chain-of-Responsibility 패턴으로 합성합니다. // // Package chain composes multiple llm.Provider instances into a single fallback chain // where the next handler is invoked when the current one fails with a delegatable error. diff --git a/pkg/llm/chain/policy.go b/pkg/llm/chain/policy.go index 8e3ffe44..0bdb9103 100644 --- a/pkg/llm/chain/policy.go +++ b/pkg/llm/chain/policy.go @@ -10,7 +10,7 @@ import ( // PolicyProvider composes a Policy with a chain — order is decided per-request by Policy.Select. // -// PolicyProvider 는 매 호출마다 policy.Select 가 결정한 순서로 chain 동작을 수행합니다 (이슈 #144 Phase 3). +// PolicyProvider 는 매 호출마다 policy.Select 가 결정한 순서로 chain 동작을 수행합니다. // 정적 chain.Provider 와 달리 호출별 dynamic ordering 이 가능하여 비용 / latency / 작업 특성에 따라 // 다른 provider 가 우선 시도됩니다. // diff --git a/pkg/llm/llm.go b/pkg/llm/llm.go index 953b941e..ff005494 100644 --- a/pkg/llm/llm.go +++ b/pkg/llm/llm.go @@ -1,5 +1,5 @@ // Package llm 은 외부 LLM API (Gemini / OpenAI ChatGPT / Anthropic Claude) 를 -// 동일한 추상 인터페이스로 호출할 수 있게 하는 generic client 패키지입니다 (이슈 #140). +// 동일한 추상 인터페이스로 호출할 수 있게 하는 generic client 패키지입니다. // // Package llm provides a unified interface for invoking external LLM APIs // (Google Gemini, OpenAI ChatGPT, Anthropic Claude). Provider-specific request / @@ -67,7 +67,7 @@ type Request struct { // MaxTokens 는 응답 생성 시 최대 토큰 수. 0 이면 provider default. MaxTokens int - // TaskHint 는 routing policy 가 작업 특성별로 적합한 provider 를 선택할 때 사용하는 hint 입니다 (이슈 #144). + // TaskHint 는 routing policy 가 작업 특성별로 적합한 provider 를 선택할 때 사용하는 hint 입니다. // 빈 문자열이면 policy 기본 동작. 표준 값은 TaskHint* 상수 참고. // // TaskHint helps routing policies pick the most suitable provider per task type. diff --git a/pkg/llm/measured.go b/pkg/llm/measured.go index d0945e39..c489c512 100644 --- a/pkg/llm/measured.go +++ b/pkg/llm/measured.go @@ -11,10 +11,10 @@ import ( // MeasuredProvider wraps a Provider, recording per-call latency and success/failure metrics. // -// MeasuredProvider 는 다른 Provider 를 wrap 하여 호출 시 latency / 성공·실패 metric 을 기록합니다 (이슈 #144). +// MeasuredProvider 는 다른 Provider 를 wrap 하여 호출 시 latency / 성공·실패 metric 을 기록합니다. // // - in-memory EMA (LatencyMs) — routing policy 가 dynamic 가중치로 활용 -// - Prometheus metric (히스토그램 / counter) — /metrics endpoint 로 export (이슈 #165 의존) +// - Prometheus metric (히스토그램 / counter) — /metrics endpoint 로 export // // Prometheus registry 는 호출자가 주입 — nil 이면 metric 등록 skip (in-memory EMA 만 동작). type MeasuredProvider struct { @@ -30,7 +30,7 @@ type MeasuredProvider struct { // Stats holds in-memory rolling metrics for a wrapped provider. // // Stats 는 wrap 된 provider 의 in-memory rolling metric 입니다. -// 모든 필드는 단일 mu 보호 — calls/failures/latency 의 일관된 snapshot 보장 (PR #167 gemini 피드백). +// 모든 필드는 단일 mu 보호 — calls/failures/latency 의 일관된 snapshot 보장. type Stats struct { mu sync.RWMutex calls uint64 @@ -92,7 +92,7 @@ func (s *Stats) record(latencyMs float64, failed bool) { // MeasuredFactory creates MeasuredProvider instances that share a single set of Prometheus collectors. // // MeasuredFactory 는 동일 registry / labelPrefix 에 대해 collector 를 1회만 생성·등록하고, -// 여러 provider 를 wrap 할 수 있는 factory 입니다 (PR #167 gemini 피드백 — collector 중복 등록 panic 해결). +// 여러 provider 를 wrap 할 수 있는 factory 입니다 (collector 중복 등록 panic 해결). // // collector 는 (provider, status) label 로 구분하므로 단일 collector 인스턴스가 모든 wrapped // provider 의 metric 을 처리할 수 있습니다. diff --git a/pkg/llm/policy/fixed.go b/pkg/llm/policy/fixed.go index 00976250..09b0e6e1 100644 --- a/pkg/llm/policy/fixed.go +++ b/pkg/llm/policy/fixed.go @@ -8,7 +8,7 @@ import ( // FixedOrder pins routing to an explicit list of provider names, ignoring others. // -// FixedOrder 는 호출자가 지정한 provider 이름 슬라이스를 그대로 우선순위로 사용합니다 (이슈 #144). +// FixedOrder 는 호출자가 지정한 provider 이름 슬라이스를 그대로 우선순위로 사용합니다. // candidates 중 names 에 매칭되는 provider 만 지정 순서로 반환하며, 매칭되지 않는 candidate 는 // 결과에서 제외됩니다 — "이 provider 만 쓴다" 는 명시적 정책 (단일 provider 운영, 무료 한도 내 // 제한, A/B 비교용 강제 핀 등에 사용). diff --git a/pkg/llm/policy/hybrid.go b/pkg/llm/policy/hybrid.go index da1706de..a8fefe91 100644 --- a/pkg/llm/policy/hybrid.go +++ b/pkg/llm/policy/hybrid.go @@ -9,7 +9,7 @@ import ( // HybridWeights controls the relative importance of cost / latency / failure rate signals. // -// HybridWeights 는 비용 / latency / 실패율 시그널의 상대 가중치입니다 (이슈 #144 Phase 2.C). +// HybridWeights 는 비용 / latency / 실패율 시그널의 상대 가중치입니다. // 합이 1.0 일 필요는 없음 — 각 시그널을 normalize 후 가중 합산하므로 절대값보다 비율이 의미. // // 모두 0 이면 입력 순서가 보존됩니다 (panic 없이 graceful no-op). diff --git a/pkg/llm/policy/latency.go b/pkg/llm/policy/latency.go index f4df7775..762599fa 100644 --- a/pkg/llm/policy/latency.go +++ b/pkg/llm/policy/latency.go @@ -11,7 +11,7 @@ import ( // LatencyWeighted orders candidates by ascending observed latency (lower is better). // -// LatencyWeighted 는 동적 EMA latency 가 낮은 후보를 우선합니다 (이슈 #144 Phase 2.B). +// LatencyWeighted 는 동적 EMA latency 가 낮은 후보를 우선합니다. // // Latency source 우선순위: // 1. MeasuredProvider.Stats().LatencyMs() (실측 EMA) — 호출 이력이 있으면 본 값을 사용 diff --git a/pkg/llm/policy/policy.go b/pkg/llm/policy/policy.go index 4a5c2001..2f64fb71 100644 --- a/pkg/llm/policy/policy.go +++ b/pkg/llm/policy/policy.go @@ -1,7 +1,7 @@ // Package policy implements LLM routing policies that order Providers per-request. // // Package policy 는 매 호출마다 비용 / 성능 / 작업 특성을 입력으로 적절한 provider 를 동적으로 -// 선택하는 routing policy 들을 제공합니다 (이슈 #144). +// 선택하는 routing policy 들을 제공합니다. // // Policy 는 후보 provider 슬라이스를 입력받아 우선순위 순서로 정렬해 반환합니다 — 단일 선택이 아닌 // 정렬된 슬라이스를 반환하여 chain 합성 (chain.NewWithPolicy) 과 자연스럽게 어울립니다. @@ -42,7 +42,7 @@ type Policy interface { // - 비어있으면 provider 의 default model — 현재 Provider 인터페이스에 노출 X 라 // 향후 provider 가 자기 default 를 노출하는 메소드를 추가할 때 본 헬퍼가 그 메소드를 호출 // -// 본 PR scope: req.Model 만 사용. req.Model 이 비어있고 caps lookup 실패하면 zero Capabilities 사용. +// req.Model 만 사용. req.Model 이 비어있고 caps lookup 실패하면 zero Capabilities 사용. func capabilityFor(caps llm.CapabilitiesProvider, p llm.Provider, req llm.Request) llm.Capabilities { if caps == nil { return llm.Capabilities{} diff --git a/pkg/llm/prompt/prompt.go b/pkg/llm/prompt/prompt.go index 9212bab0..657fb33a 100644 --- a/pkg/llm/prompt/prompt.go +++ b/pkg/llm/prompt/prompt.go @@ -1,6 +1,6 @@ // Package prompt loads LLM prompt templates from the filesystem. // -// Package prompt 는 LLM 호출용 프롬프트를 외부 파일에서 로드합니다 (이슈 #144 Phase 4). +// Package prompt 는 LLM 호출용 프롬프트를 외부 파일에서 로드합니다. // // **위치 정책**: 프롬프트는 binary 와 분리하여 \`scripts/prompts/.txt\` (또는 .md) 로 관리합니다. // 이는 다음을 가능하게 합니다: diff --git a/pkg/llm/providers/anthropic/anthropic.go b/pkg/llm/providers/anthropic/anthropic.go index cb772e12..1cedf62b 100644 --- a/pkg/llm/providers/anthropic/anthropic.go +++ b/pkg/llm/providers/anthropic/anthropic.go @@ -1,4 +1,4 @@ -// Package anthropic 는 Anthropic Claude API 의 llm.Provider 구현입니다 (이슈 #140). +// Package anthropic 는 Anthropic Claude API 의 llm.Provider 구현입니다. // // Package anthropic implements the llm.Provider interface against Anthropic's // Messages REST API. @@ -22,7 +22,7 @@ const ( apiVersion = "2023-06-01" ) -// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다 (이슈 #140). +// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다. // // "anthropic" 외 별칭 "claude" 도 함께 등록 — 사용자가 모델 이름으로 자연스럽게 호출 가능. func init() { diff --git a/pkg/llm/providers/gemini/gemini.go b/pkg/llm/providers/gemini/gemini.go index 7b2a7aa2..31d4b104 100644 --- a/pkg/llm/providers/gemini/gemini.go +++ b/pkg/llm/providers/gemini/gemini.go @@ -1,4 +1,4 @@ -// Package gemini 는 Google Gemini API 의 llm.Provider 구현입니다 (이슈 #140). +// Package gemini 는 Google Gemini API 의 llm.Provider 구현입니다. // // Package gemini implements the llm.Provider interface against Google's // Generative Language REST API. @@ -27,7 +27,7 @@ const ( defaultModel = "gemini-2.5-flash" ) -// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다 (이슈 #140). +// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다. // // 사용자는 llm.Config{Provider: "gemini", APIKey: "...", Model: "..."} 으로 생성 가능. func init() { diff --git a/pkg/llm/providers/openai/openai.go b/pkg/llm/providers/openai/openai.go index 9e6a868c..0e9a8dfb 100644 --- a/pkg/llm/providers/openai/openai.go +++ b/pkg/llm/providers/openai/openai.go @@ -1,4 +1,4 @@ -// Package openai 는 OpenAI ChatGPT API 의 llm.Provider 구현입니다 (이슈 #140). +// Package openai 는 OpenAI ChatGPT API 의 llm.Provider 구현입니다. // // Package openai implements the llm.Provider interface against OpenAI's // chat completions REST API. @@ -20,7 +20,7 @@ const ( defaultModel = "gpt-4o-mini" ) -// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다 (이슈 #140). +// init 은 factory (llm.New) 에서 본 provider 를 사용할 수 있게 등록합니다. func init() { llm.RegisterProvider(providerName, func(cfg llm.Config) (llm.Provider, error) { opts := []Option{} diff --git a/pkg/llm/providers/providers.go b/pkg/llm/providers/providers.go index 6f3ebf4b..b653f6a5 100644 --- a/pkg/llm/providers/providers.go +++ b/pkg/llm/providers/providers.go @@ -1,4 +1,4 @@ -// Package providers 는 모든 내장 provider 를 사이드 이펙트로 등록하는 편의 패키지입니다 (이슈 #140). +// Package providers 는 모든 내장 provider 를 사이드 이펙트로 등록하는 편의 패키지입니다. // // 사용자는 호출처에서 다음 한 줄만 import 하면 모든 provider (gemini / openai / anthropic / claude) 가 // llm.New 에 자동 등록됩니다: diff --git a/pkg/llm/wiring/wiring.go b/pkg/llm/wiring/wiring.go index 6f866bb0..93f83d97 100644 --- a/pkg/llm/wiring/wiring.go +++ b/pkg/llm/wiring/wiring.go @@ -1,4 +1,4 @@ -// Package wiring 은 환경변수 / config 기반 LLM provider 조립 헬퍼를 제공합니다 (이슈 #276). +// Package wiring 은 환경변수 / config 기반 LLM provider 조립 헬퍼를 제공합니다. // // cmd/* 바이너리가 환경변수만으로 chain provider 를 구성할 수 있도록, config 로딩 ↔ provider 생성 ↔ // 정책 wrapping 을 한 곳에 모아 재사용. llmgen / refiner 등 LLM 소비자가 동일 provider 를 공유합니다. @@ -15,7 +15,7 @@ import ( "issuetracker/pkg/logger" ) -// lookupProviderAPIKey 는 provider 별 표준 환경변수의 *값* 을 반환합니다 (이슈 #216). +// lookupProviderAPIKey 는 provider 별 표준 환경변수의 *값* 을 반환합니다. // // config 의 lookupLLMAPIKey 는 미발견 시 LLM_API_KEY 로 cascade 하여 모든 provider 에 동일 key 가 // 적용되는 부작용이 있으므로, chain 구성 시에는 본 helper 로 provider 별 key 만 직접 조회합니다. @@ -35,7 +35,7 @@ func lookupProviderAPIKey(name string) string { // normalizePrimary 는 LLM_PROVIDER 의 alias 를 fallbackOrder 의 정식 이름으로 정규화합니다. // // 예: cfg.Provider="claude" → "anthropic" 으로 매핑하여 chain 의 anthropic 항목에 LLM_API_KEY -// fallback / LLM_MODEL override 가 정확히 적용되도록 보장 (PR #280 gemini 리뷰). +// fallback / LLM_MODEL override 가 정확히 적용되도록 보장. func normalizePrimary(name string) string { if name == "claude" { return "anthropic" @@ -43,14 +43,14 @@ func normalizePrimary(name string) string { return name } -// fallbackOrder 는 fixed-order fallback chain 의 시도 순서입니다 (이슈 #216). +// fallbackOrder 는 fixed-order fallback chain 의 시도 순서입니다. // // 현재는 gemini → openai → anthropic 으로 hardcoded — 비용 / 한도 / 가용성 우선순위 기준. // 향후 capability 기반 metric 정책 (cost / latency / 성공률) 도입 시 본 슬라이스 대신 // pkg/llm/policy 의 dynamic policy (CheapestFirst / Latency / Hybrid 등) 로 NewFixedOrder 만 교체. var fallbackOrder = []string{"gemini", "openai", "anthropic"} -// BuildProvider 는 LLMConfig (환경변수) 에 따라 fixed-order fallback chain 을 구성합니다 (이슈 #216). +// BuildProvider 는 LLMConfig (환경변수) 에 따라 fixed-order fallback chain 을 구성합니다. // // fallback 순서: gemini → openai → anthropic (hardcoded — 향후 metric 기반 정책으로 대체). // 각 provider 는 자신의 API key 가 환경변수에 설정된 경우에만 chain 후보에 포함: @@ -64,7 +64,7 @@ var fallbackOrder = []string{"gemini", "openai", "anthropic"} // - LLM_ENABLED=false → nil // - 모든 provider 가 API key 부재 / 생성 실패 → nil + warn // -// 정책 적용 (이슈 #144 PolicyProvider): +// 정책 적용: // - policy.NewFixedOrder(fallbackOrder...) 로 후보를 정해진 순서로 정렬 // - 향후 metric 기반 정책 도입 시 본 함수의 policy 객체만 교체하면 됨 // @@ -85,7 +85,7 @@ func BuildProvider(log *logger.Logger) llm.Provider { } // LLM_PROVIDER alias (예: "claude" → "anthropic") 정규화 — fallbackOrder 의 정식 이름과 매칭하여 - // LLM_API_KEY fallback / LLM_MODEL override 가 올바른 chain 항목에 적용되도록 보장 (PR #280 리뷰). + // LLM_API_KEY fallback / LLM_MODEL override 가 올바른 chain 항목에 적용되도록 보장. primaryName := normalizePrimary(cfg.Provider) candidates := make([]llm.Provider, 0, len(fallbackOrder)) @@ -129,7 +129,7 @@ func BuildProvider(log *logger.Logger) llm.Provider { pol := policy.NewFixedOrder(fallbackOrder...) composed := chain.NewWithPolicy(pol, candidates, chain.WithPolicyLogger(log)) - // 로그 필드 의미 (PR #280 Copilot 리뷰): + // 로그 필드 의미: // - chain : 실제 활성화되어 시도되는 provider 시퀀스 (정책 적용 전 후보 목록) // - first_in_chain : chain[0] — 매 호출의 첫 시도 대상 (디버깅용) // - configured_primary : LLM_PROVIDER 가 가리키는 사용자 의도 (alias 정규화 후) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 7e7c81c0..89a1590d 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -1,6 +1,6 @@ // Package metrics provides a Prometheus registry and HTTP handler for /metrics export. // -// metrics 패키지는 Prometheus 기반 운영 metric 수집·노출의 진입점입니다 (이슈 #165). +// metrics 패키지는 Prometheus 기반 운영 metric 수집·노출의 진입점입니다. // 모든 모듈은 본 패키지의 NewRegistry() 가 반환한 *prometheus.Registry 를 공유하여 // 자신의 metric (counter / histogram / gauge) 을 등록합니다. // @@ -42,7 +42,7 @@ func NewRegistry() *prometheus.Registry { // addr 가 빈 문자열이면 endpoint 비활성화 — (noop stop, nil) 반환 후 종료. 운영 환경별 metric // 노출 토글에 사용. // -// **fail-fast 정책 (PR #166 CodeRabbit 피드백)**: bind/listen 실패는 호출 시점에 동기 검출되어 +// **fail-fast 정책**: bind/listen 실패는 호출 시점에 동기 검출되어 // error 로 반환됩니다 — 포트 충돌 등으로 metric 이 silent 누락되지 않도록 caller 가 Fatal 처리해야 // 합니다. listen 성공 후 발생하는 Serve 에러만 goroutine 안에서 로깅됩니다. // diff --git a/pkg/queue/config.go b/pkg/queue/config.go index 613ccf81..4521812f 100644 --- a/pkg/queue/config.go +++ b/pkg/queue/config.go @@ -9,7 +9,7 @@ const ( TopicCrawlNormal = "issuetracker.crawl.normal" TopicCrawlLow = "issuetracker.crawl.low" - // TopicCrawlChromedp: chromedp 전용 fetch 큐 (이슈 #218). + // TopicCrawlChromedp: chromedp 전용 fetch 큐. // goquery worker 가 lazy-load 감지 / fetch 실패 시 재발행하거나, 단계 3 의 자동 upgrade // trigger / force_fetcher metadata path 가 직접 enqueue. 별도 consumer group // (GroupChromedpFetchers) 의 chromedp worker 가 consume 하여 worker 단위 semaphore 로 @@ -20,7 +20,7 @@ const ( TopicRawUS = "issuetracker.raw.us" TopicRawKR = "issuetracker.raw.kr" - // TopicFetched: fetcher worker 가 RawContent 저장 후 RawContentRef 발행하는 토픽 (이슈 #134). + // TopicFetched: fetcher worker 가 RawContent 저장 후 RawContentRef 발행하는 토픽. // payload 는 raw_id + url + source_info 만 포함 (HTML 본문 미포함, < 1KB). // parser worker (GroupParsers) 가 consume 하여 raw_contents 에서 본문 로드 + 파싱. TopicFetched = "issuetracker.fetched" @@ -40,10 +40,10 @@ const ( const ( GroupCrawlerWorkers = "issuetracker-crawler-workers" // GroupChromedpFetchers: TopicCrawlChromedp 를 consume 하여 chromedp 만 사용하는 fetcher worker - // pool 의 consumer group (이슈 #218). 일반 crawler worker (GroupCrawlerWorkers) 와 분리되어 + // pool 의 consumer group. 일반 crawler worker (GroupCrawlerWorkers) 와 분리되어 // Chrome 자원과 1:1 매핑된 worker 수 + semaphore 로 동시 호출량 제한. GroupChromedpFetchers = "issuetracker-chromedp-fetchers" - // GroupParsers: TopicFetched 를 consume 하여 raw 로드 + 파싱 + content 저장 + raw 삭제 (이슈 #134). + // GroupParsers: TopicFetched 를 consume 하여 raw 로드 + 파싱 + content 저장 + raw 삭제. GroupParsers = "issuetracker-parsers" GroupNormalizers = "issuetracker-normalizers" GroupValidators = "issuetracker-validators" diff --git a/pkg/urlguard/gate.go b/pkg/urlguard/gate.go index 8a67b54d..654fd45f 100644 --- a/pkg/urlguard/gate.go +++ b/pkg/urlguard/gate.go @@ -29,7 +29,7 @@ type Gate struct { // NewGate 는 주어진 guard 와 log 로 새 Gate 를 생성합니다. // guard 가 nil 이면 error — 호출자 (cmd/main) 가 boot fatal 처리. 비활성화는 AllowAllGuard{} -// 명시 주입으로 표현 (이슈 #208). +// 명시 주입으로 표현. // log 가 nil 이면 logger.FromContext(context.Background()) 의 기본 logger 를 사용. func NewGate(guard Guard, log *logger.Logger) (*Gate, error) { if guard == nil { diff --git a/pkg/urlguard/pattern.go b/pkg/urlguard/pattern.go index 3bccc4d2..a868aaab 100644 --- a/pkg/urlguard/pattern.go +++ b/pkg/urlguard/pattern.go @@ -53,7 +53,7 @@ func (g *PatternGuard) Allow(url string) (bool, string) { // defaultPatterns 는 도메인 디폴트 차단 패턴입니다 (unexported — 외부 변경 차단). // // 정책 근거: -// - "/rss" : RSS 피드 URL 일괄 차단 (이슈 #119 의 CNN RSS 잔존 사고 대응). +// - "/rss" : RSS 피드 URL 일괄 차단. // pkg/links 의 defaultExcludePatterns 와 동일 패턴. // - "mailto:" / "tel:" : 비-HTTP 스킴은 외부 fetch 대상이 아님. // - "javascript:" : 클라이언트 사이드 코드 — fetch 의미 없음.