Spaces:
Running
Running
| from __future__ import annotations | |
| import json | |
| import re | |
| from typing import Optional | |
| import requests | |
| from sqlalchemy import or_ | |
| from cert_study_app.config import DEFAULT_USER | |
| from cert_study_app.models import ConceptNote, Question | |
| CONCEPT_PROMPT = """ | |
| 너는 자격증 시험 학습 노트 큐레이터다. | |
| 아래 문제에서 저장할 만한 개념 후보를 1~3개만 제안하라. | |
| 너무 세부적인 문제 상황이 아니라 재사용 가능한 시험 개념으로 추상화하라. | |
| JSON만 반환하라. | |
| 형식: | |
| { | |
| "concepts": [ | |
| { | |
| "concept_name": "개념명", | |
| "summary": "핵심 요약 1~2문장", | |
| "exam_point": "시험장에서 기억할 포인트", | |
| "trap_point": "헷갈릴 포인트", | |
| "keywords": ["keyword1", "keyword2"] | |
| } | |
| ] | |
| } | |
| 문제: | |
| {stem} | |
| 보기: | |
| {options} | |
| 정답: | |
| {answer} | |
| 해설: | |
| {explanation} | |
| """ | |
| def _json_from_response(text: str) -> dict: | |
| match = re.search(r"\{.*\}", text or "", re.S) | |
| if match: | |
| text = match.group(0) | |
| try: | |
| return json.loads(text) | |
| except Exception: | |
| return {"concepts": []} | |
| class ConceptNoteService: | |
| def __init__(self, db): | |
| self.db = db | |
| def generate_candidates( | |
| self, | |
| question_id: int, | |
| model: str = "qwen2.5:14b", | |
| base_url: str = "http://localhost:11434", | |
| ) -> list[dict]: | |
| question = self.db.query(Question).filter(Question.id == question_id).first() | |
| if not question: | |
| return [] | |
| prompt = CONCEPT_PROMPT.format( | |
| stem=(question.stem or "")[:1600], | |
| options="\n".join(str(option) for option in question.get_options())[:1200], | |
| answer=question.answer or "", | |
| explanation=(question.explanation or "")[:1600], | |
| ) | |
| payload = { | |
| "model": model, | |
| "prompt": prompt, | |
| "stream": False, | |
| "format": "json", | |
| "think": False, | |
| "options": {"temperature": 0, "num_predict": 700}, | |
| } | |
| try: | |
| response = requests.post(f"{base_url.rstrip('/')}/api/generate", json=payload, timeout=120) | |
| response.raise_for_status() | |
| except requests.RequestException as exc: | |
| raise RuntimeError(f"Ollama API 연결 실패 ({base_url}): {exc}") from exc | |
| parsed = _json_from_response(response.json().get("response", "")) | |
| concepts = parsed.get("concepts") if isinstance(parsed, dict) else [] | |
| return [self._normalize_candidate(item) for item in concepts if isinstance(item, dict)][:3] | |
| def save_candidate( | |
| self, | |
| candidate: dict, | |
| question_id: int, | |
| user_id: str = DEFAULT_USER, | |
| ) -> ConceptNote: | |
| question = self.db.query(Question).filter(Question.id == question_id).first() | |
| note = ConceptNote( | |
| concept_name=str(candidate.get("concept_name") or "").strip()[:255], | |
| summary=str(candidate.get("summary") or "").strip(), | |
| exam_point=str(candidate.get("exam_point") or "").strip(), | |
| trap_point=str(candidate.get("trap_point") or "").strip(), | |
| source=question.source if question else None, | |
| source_question_id=question_id, | |
| user_id=user_id, | |
| ) | |
| note.set_keywords(candidate.get("keywords") or []) | |
| self.db.add(note) | |
| self.db.commit() | |
| self.db.refresh(note) | |
| return note | |
| def list_notes(self, source: Optional[str] = None, query: str = "", limit: int = 100) -> list[ConceptNote]: | |
| rows = self.db.query(ConceptNote) | |
| if source: | |
| rows = rows.filter(ConceptNote.source == source) | |
| if query: | |
| like = f"%{query.strip()}%" | |
| rows = rows.filter( | |
| or_( | |
| ConceptNote.concept_name.ilike(like), | |
| ConceptNote.summary.ilike(like), | |
| ConceptNote.exam_point.ilike(like), | |
| ConceptNote.trap_point.ilike(like), | |
| ConceptNote.keywords.ilike(like), | |
| ) | |
| ) | |
| return rows.order_by(ConceptNote.updated_at.desc(), ConceptNote.id.desc()).limit(limit).all() | |
| def get_note(self, note_id: int) -> ConceptNote | None: | |
| return self.db.query(ConceptNote).filter(ConceptNote.id == note_id).first() | |
| def related_questions(self, note: ConceptNote, limit: int = 20) -> list[Question]: | |
| keywords = [note.concept_name, *note.keyword_list()] | |
| keywords = [keyword for keyword in keywords if str(keyword or "").strip()] | |
| if not keywords: | |
| return [] | |
| filters = [] | |
| for keyword in keywords[:8]: | |
| like = f"%{keyword}%" | |
| filters.append(Question.stem.ilike(like)) | |
| filters.append(Question.explanation.ilike(like)) | |
| filters.append(Question.raw_text.ilike(like)) | |
| query = self.db.query(Question).filter(Question.parse_status == "approved") | |
| if note.source: | |
| query = query.filter(Question.source == note.source) | |
| return query.filter(or_(*filters)).order_by(Question.id.asc()).limit(limit).all() | |
| def _normalize_candidate(self, item: dict) -> dict: | |
| keywords = item.get("keywords") or [] | |
| if isinstance(keywords, str): | |
| keywords = [keyword.strip() for keyword in re.split(r"[,#]", keywords) if keyword.strip()] | |
| return { | |
| "concept_name": str(item.get("concept_name") or "").strip(), | |
| "summary": str(item.get("summary") or "").strip(), | |
| "exam_point": str(item.get("exam_point") or "").strip(), | |
| "trap_point": str(item.get("trap_point") or "").strip(), | |
| "keywords": [str(keyword).strip() for keyword in keywords if str(keyword).strip()][:8], | |
| } | |