"""Ollama/Qwen summarizer for job postings.""" from __future__ import annotations from typing import TYPE_CHECKING from loguru import logger from tenacity import retry, stop_after_attempt, wait_exponential if TYPE_CHECKING: from gimme_job.models.db import JobPosting class OllamaSummarizer: def __init__( self, base_url: str = "http://127.0.0.1:11434", model: str = "qwen3.5:9b", temperature: float = 0.1, ): self.base_url = base_url.rstrip("/") self.model = model self.temperature = temperature def summarize(self, postings: list["JobPosting"]) -> str: """Generate a Korean-language summary of job postings.""" if not postings: return "오늘 신규 채용 공고가 없습니다." prompt = self._build_prompt(postings) try: return self._call_ollama(prompt) except Exception as e: logger.error(f"Ollama summarization failed: {e}") return self._fallback_summary(postings) @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10)) def _call_ollama(self, prompt: str) -> str: import httpx response = httpx.post( f"{self.base_url}/api/generate", json={ "model": self.model, "prompt": prompt, "temperature": self.temperature, "stream": False, "options": {"num_predict": 2048}, }, timeout=120.0, ) response.raise_for_status() data = response.json() return data.get("response", "").strip() def _build_prompt(self, postings: list["JobPosting"]) -> str: from jinja2 import Environment, FileSystemLoader from gimme_job.utils.paths import project_root template_path = project_root() / "gimme_job" / "templates" / "digest.md.j2" if template_path.exists(): env = Environment(loader=FileSystemLoader(str(template_path.parent))) template = env.get_template("digest.md.j2") return template.render(postings=postings, count=len(postings)) # Inline fallback lines = [] for p in postings[:100]: parts = [f"[{p.site_id}]", p.title] if p.company: parts.append(p.company) if p.location: parts.append(p.location) if p.posted_text: parts.append(p.posted_text) if p.job_url: parts.append(p.job_url) lines.append(" / ".join(parts)) postings_text = "\n".join(f"- {l}" for l in lines) return ( "다음은 오늘 수집된 신규 채용 공고 목록입니다. " "한국어로 bullet point 요약을 작성하고, 추천 우선순위 3개를 제시하세요. " "원본 데이터를 절대 변조하지 마세요.\n\n" f"{postings_text}\n\n" "형식:\n" "# 오늘의 신규 채용 공고\n" "- [사이트] 직무 / 회사 / 위치 / 게시일 / URL\n\n" "## 추천 우선순위\n" "1. ...\n" "2. ...\n" "3. ..." ) def _fallback_summary(self, postings: list["JobPosting"]) -> str: """Plain-text fallback when Ollama is unavailable.""" lines = ["# 오늘의 신규 채용 공고\n"] for p in postings: parts = [f"[{p.site_id}]", p.title] if p.company: parts.append(p.company) if p.location: parts.append(p.location) if p.posted_text: parts.append(p.posted_text) if p.job_url: parts.append(p.job_url) lines.append("- " + " / ".join(parts)) return "\n".join(lines) def is_available(self) -> bool: try: import httpx r = httpx.get(f"{self.base_url}/api/tags", timeout=3.0) return r.status_code == 200 except Exception: return False