You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
"""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
|