cert-study-app / cert_study_app /services /visual_question_service.py
Kentlo's picture
Sync from GitHub b098d17dbed1a6386c212d28b4bfa6138ad59d75
88c7275 verified
Raw
History Blame Contribute Delete
7.02 kB
import base64
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
import requests
from cert_study_app.models import Question
from cert_study_app.services.question_concept_service import apply_question_concept
VISUAL_MODEL = os.getenv("OLLAMA_VISUAL_MODEL", "qwen3-vl:8b-instruct-q4_K_M")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
def _image_base64(path: str) -> str:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("ascii")
def _json_from_response(text: str) -> dict:
if not text:
return {}
match = re.search(r"\{.*\}", text, re.S)
if match:
text = match.group(0)
try:
return json.loads(text)
except Exception:
return {"raw_response": text}
def analyze_visual_question(question: Question, model: str = VISUAL_MODEL, base_url: str = OLLAMA_BASE_URL) -> dict:
if not question.image_path or not Path(question.image_path).exists():
return {"ok": False, "error": "image_not_found"}
prompt = f"""
You are an exam question image parser.
Look at the image and the OCR text. Return JSON only. No reasoning.
Fields:
- question_type: one of hotspot, ordering, matching, table_choice, yes_no, mcq, unparsed
- stem: clean Korean question text without answer/explanation and without duplicated answer-area row labels
- source_content: visible code, JSON, table, diagram text, configuration, or other source material that the question asks about
- options: array of visible selectable choices, if any
- answer: answer shown in OCR text or image, if visible in source data
- answer_areas: for hotspot/table dropdown questions, array of objects with:
- label: left-side prompt text such as "From the Azure portal"
- selected_answer: selected value for that row if visible
- options: selectable choices for that row if visible
- statements: for Yes/No matrix questions, array of objects with:
- text: statement text
- selected_answer: Yes or No if visible
- confidence: integer 0-100
- notes: short Korean note
Rules:
- If the question refers to a shown code block, JSON, table, role definition, policy, diagram, or configuration, put that visible material in source_content.
- Do not repeat answer_areas labels in stem. Keep the common instruction in stem and put row-specific prompts only in answer_areas.
- If the image has dropdown boxes, preserve each left-side label exactly and put it in answer_areas.
- If the image has a Statements / Yes / No table, preserve every statement row in statements.
- For highlighted or circled answers, include selected_answer.
- Do not infer from Azure knowledge. Parse only what is visible in the image/OCR.
OCR text:
{(question.raw_text or question.stem or '')[:1800]}
"""
payload = {
"model": model,
"prompt": prompt,
"images": [_image_base64(question.image_path)],
"stream": False,
"format": "json",
"think": False,
"options": {
"temperature": 0,
"num_predict": 1400,
},
}
try:
response = requests.post(f"{base_url.rstrip('/')}/api/generate", json=payload, timeout=180)
response.raise_for_status()
except requests.RequestException as exc:
raise RuntimeError(f"Ollama 이미지 분석 API 연결 실패 ({base_url}): {exc}") from exc
body = response.json()
parsed = _json_from_response(body.get("response", ""))
parsed.setdefault("model", model)
parsed.setdefault("ok", True)
return parsed
def apply_visual_analysis(question: Question, analysis: dict) -> None:
if analysis.get("raw_response") and not any(
key in analysis for key in ["stem", "source_content", "options", "answer_areas", "statements"]
):
question.review_issues = json.dumps(["이미지 분석 응답이 구조화 JSON으로 완성되지 않았습니다."], ensure_ascii=False)
return
question.visual_analysis_json = json.dumps(analysis, ensure_ascii=False)
question.visual_reviewed_at = datetime.utcnow()
if not analysis.get("ok", True):
question.review_issues = json.dumps([analysis.get("error", "이미지 분석 실패")], ensure_ascii=False)
question.parse_status = "needs_visual"
return
options = analysis.get("options")
answer = analysis.get("answer")
answer_areas = analysis.get("answer_areas")
statements = analysis.get("statements")
stem = analysis.get("stem")
question_type = analysis.get("question_type")
confidence = int(analysis.get("confidence") or 0)
if isinstance(options, list) and options:
question.set_options(options)
if answer and not question.answer:
question.answer = str(answer).strip()
if stem and len(str(stem).strip()) > 20:
question.stem = str(stem).strip()
if question_type:
question.question_type = str(question_type).strip()
apply_question_concept(question, overwrite=False)
if confidence >= 70:
question.review_score = max(question.review_score or 0, confidence)
question.review_issues = json.dumps([], ensure_ascii=False)
if options and (answer or answer_areas or statements):
question.parse_status = "approved"
question.reviewed_at = datetime.utcnow()
elif question.parse_status != "approved":
question.parse_status = "needs_visual"
def run_visual_analysis(db, source: Optional[str] = None, limit: int = 10, model: str = VISUAL_MODEL) -> dict:
query = db.query(Question).filter(Question.parse_status.in_(["needs_visual", "needs_review"]))
query = query.filter(Question.question_type.in_(["hotspot", "ordering", "matching", "table_choice", "yes_no"]))
if source:
query = query.filter(Question.source == source)
questions = (
query.order_by(
Question.parse_status.asc(),
Question.id.asc(),
)
.limit(limit)
.all()
)
summary = {"checked": 0, "approved": 0, "needs_visual": 0, "failed": 0}
total = len(questions)
print(f"[visual] targets={total} source={source or 'all'} model={model}", flush=True)
for index, question in enumerate(questions, 1):
try:
analysis = analyze_visual_question(question, model=model)
apply_visual_analysis(question, analysis)
except Exception as exc:
apply_visual_analysis(question, {"ok": False, "error": str(exc)[:300], "model": model})
summary["failed"] += 1
summary["checked"] += 1
if question.parse_status == "approved":
summary["approved"] += 1
elif question.parse_status == "needs_visual":
summary["needs_visual"] += 1
db.commit()
print(
f"[visual] {index}/{total} q{question.question_number or question.id} "
f"{question.parse_status} {question.question_type}",
flush=True,
)
return summary