"""Qwen 3.8 Max 를 VLM 심판으로 세워 **이미 생성된 후보 롤**을 다시 판정한다.

이미지를 새로 그리지 않는다 — 바뀌는 것은 "누가 보고 고르는가" 하나뿐이라
생성 비용 0 으로 심판만 갈아끼워 대조할 수 있다. 후보 제시·브리프 조립·판정
축·스키마를 `opus_pilot` 에서 그대로 가져오므로 Opus·Sol 판정과 같은 조건에서
비교된다(같은 프롬프트, 같은 이미지, 같은 라벨).

## 왜 프로젝트 Router 를 안 타는가

이 저장소의 `call_structured` 는 `response_format={"type":"json_schema",
"strict":true}` 를 보낸다. Qwen 은 이걸 **지원하지 않는다** — DashScope 문서
기준 `json_object` 만 되고, 그나마 다음 제약이 붙는다.

  · 메시지 어딘가에 "json" 이라는 낱말이 없으면 400 이다.
  · thinking 모드는 구조화 출력과 충돌한다 (추론 텍스트가 content 에 섞여
    JSON 파싱이 깨진다).
  · `json_object` 는 **문법만** 보장하고 스키마 준수는 보장하지 않는다.

그래서 DashScope OpenAI 호환 엔드포인트를 직접 부르고, 스키마는 여기서
`jsonschema` 로 검증한 뒤 실패하면 오류를 되먹여 한 번 다시 묻는다.

## 이미지 첨부

OpenAI 호환 Chat Completions 의 `image_url` 에 base64 data URI 를 싣는다.
`png_part()` 가 이미 그 모양을 만들고, media type 을 **파일 내용**으로 정한다
(이름이 `.png` 라도 실제가 JPEG 이면 `image/jpeg`) — 이름만 믿었다가 400 을
맞은 실측이 있어 그 함수를 그대로 재사용한다.

## 준비

`backend/.env` 에:

    DASHSCOPE_API_KEY=<발급받은 키>
    DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
    QWEN_VLM_MODEL=qwen3.8-max

## 사용

    .venv/bin/python qwen_vlm_pilot.py out.json --stems S13sh3,S62sh4
    .venv/bin/python qwen_vlm_pilot.py out.json --all --limit 30 --workers 4
    .venv/bin/python qwen_vlm_pilot.py out.json --all --probe   # 키·모델만 확인
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

sys.path.insert(0, str(Path(__file__).resolve().parent))

HERE = Path(__file__).resolve().parent


def _load_env() -> None:
    """backend/.env 를 os.environ 으로. pydantic Settings 는 extra=ignore 라
    여기서 쓰는 키를 노출하지 않으므로 직접 읽는다."""
    try:
        from dotenv import load_dotenv
        load_dotenv(HERE / ".env")
        return
    except ImportError:
        pass
    path = HERE / ".env"
    if not path.exists():
        return
    for line in path.read_text("utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip())


_load_env()

from jsonschema import ValidationError, validate as _js_validate  # noqa: E402

from app.modules.pipeline.multiroll_gemini import (  # noqa: E402
    png_part, ref_parts,
)
from app.modules.pipeline.multiroll_select import (  # noqa: E402
    _compose_critique_prompt,
)
from opus_pilot import (  # noqa: E402  — 같은 판정 축·스키마를 공유한다
    EPI, PROJ, RECIPE, ROOT, SELECT_SYS, build_size_index, resolve_refs,
    select_schema,
)

DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
DEFAULT_MODEL = "qwen3.8-max"

# DashScope 는 메시지에 "json" 이라는 낱말이 없으면 400 을 낸다. 판정 축은
# opus_pilot 의 SELECT_SYS 를 그대로 쓰고, 출력 형식 문장만 여기서 덧붙인다.
JSON_CLAUSE = """

OUTPUT FORMAT — return one single json object and nothing else. No prose
before or after it, no markdown code fence. It must match this json schema
exactly, including every required key:

{schema}"""


class QwenNotConfigured(RuntimeError):
    pass


def qwen_client():
    key = (os.getenv("DASHSCOPE_API_KEY") or "").strip()
    if not key:
        raise QwenNotConfigured(
            "DASHSCOPE_API_KEY 가 비어 있다 — backend/.env 에 키를 넣어라.\n"
            "  DASHSCOPE_API_KEY=<발급받은 키>"
        )
    from openai import OpenAI
    return OpenAI(
        api_key=key,
        base_url=(os.getenv("DASHSCOPE_BASE_URL") or DEFAULT_BASE_URL).strip(),
        timeout=300.0,
    )


def qwen_model() -> str:
    return (os.getenv("QWEN_VLM_MODEL") or DEFAULT_MODEL).strip()


_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)


def extract_json(text: str) -> Dict[str, Any]:
    """추론 텍스트가 앞뒤에 섞여 와도 본체 json 을 꺼낸다.

    thinking 모드가 content 에 추론을 흘리는 사례가 보고돼 있어 관용 파싱이
    필요하다. ①코드펜스 안 ②첫 `{` 부터 균형 잡힌 마지막 `}` 까지 순으로 시도.
    """
    text = (text or "").strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    m = _FENCE.search(text)
    if m:
        try:
            return json.loads(m.group(1))
        except json.JSONDecodeError:
            pass
    start = text.find("{")
    if start >= 0:
        depth, in_str, esc = 0, False, False
        for i, ch in enumerate(text[start:], start):
            if in_str:
                if esc:
                    esc = False
                elif ch == "\\":
                    esc = True
                elif ch == '"':
                    in_str = False
                continue
            if ch == '"':
                in_str = True
            elif ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return json.loads(text[start:i + 1])
    raise ValueError(f"json 을 찾지 못했다: {text[:200]!r}")


def ask_qwen(
    system: str,
    parts: List[Dict[str, Any]],
    schema: Dict[str, Any],
    *,
    max_retry: int = 1,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
    """Qwen 에 멀티모달 판정을 묻고 (검증 통과한 payload, 메타) 를 돌려준다."""
    client, model = qwen_client(), qwen_model()
    sys_prompt = system + JSON_CLAUSE.format(
        schema=json.dumps(schema, ensure_ascii=False, indent=2))
    messages: List[Dict[str, Any]] = [
        {"role": "system", "content": sys_prompt},
        {"role": "user", "content": parts},
    ]
    meta: Dict[str, Any] = {"model": model, "attempts": [], "thinking": False}

    for attempt in range(max_retry + 1):
        t0 = time.time()
        # thinking 을 끈다 — 구조화 출력과 충돌한다. 이 파라미터를 모르는
        # 배포본이면 400 이 오므로 그때는 빼고 한 번 더 시도한다.
        resp = None
        for extra in ({"enable_thinking": False}, None):
            try:
                resp = client.chat.completions.create(
                    model=model,
                    messages=messages,
                    response_format={"type": "json_object"},
                    **({"extra_body": extra} if extra else {}),
                )
                break
            except Exception as exc:  # noqa: BLE001
                if extra is None or "400" not in repr(exc):
                    raise
                meta["thinking"] = "param_unsupported"
        content = resp.choices[0].message.content or ""
        usage = getattr(resp, "usage", None)
        rec = {
            "elapsed_s": round(time.time() - t0, 1),
            "prompt_tokens": getattr(usage, "prompt_tokens", None),
            "completion_tokens": getattr(usage, "completion_tokens", None),
        }
        try:
            payload = extract_json(content)
            _js_validate(payload, schema)
            rec["ok"] = True
            meta["attempts"].append(rec)
            return payload, meta
        except (ValueError, ValidationError, json.JSONDecodeError) as exc:
            rec["ok"] = False
            rec["error"] = f"{type(exc).__name__}: {exc}"[:400]
            meta["attempts"].append(rec)
            if attempt >= max_retry:
                raise
            # 오류를 되먹여 다시 묻는다 — 스키마 준수는 보장되지 않으므로
            # 한 번의 교정 기회를 준다.
            messages += [
                {"role": "assistant", "content": content[:4000]},
                {"role": "user", "content": (
                    "That reply did not satisfy the json schema: "
                    f"{rec['error']}\nReturn the corrected json object only."
                )},
            ]
    raise RuntimeError("unreachable")


def build_parts(stem: str, rec: Dict[str, Any], size_idx) -> Tuple[List[Dict], List[str]]:
    """opus_pilot 과 동일한 제시 — 브리프 → 참조 → 후보 이미지."""
    labels = sorted(
        p.stem.split("_")[-1].upper()
        for p in RECIPE.glob(f"{stem}_[abc].png"))
    if not labels:
        return [], []
    roll_prompts = rec.get("roll_prompts") or {}
    parts: List[Dict[str, Any]] = [{
        "type": "text",
        "text": ("THE BRIEF (every candidate was made from this):\n"
                 + _compose_critique_prompt(
                     rec.get("prompt", ""), roll_prompts, labels[0],
                     shared_prompt=None)),
    }]
    ref0, _missing = resolve_refs(
        (rec.get("roll_refs") or {}).get(labels[0]) or rec.get("refs"), size_idx)
    parts += ref_parts(ref0)
    for lab in labels:
        parts.append({"type": "text", "text": f"Candidate {lab}:"})
        parts.append(png_part(RECIPE / f"{stem}_{lab.lower()}.png"))
    return parts, labels


def run_one(stem: str, rec: Dict[str, Any], size_idx) -> Dict[str, Any]:
    parts, labels = build_parts(stem, rec, size_idx)
    if not labels:
        return {"stem": stem, "skipped": "후보 롤 없음"}
    n_img = sum(1 for p in parts if p.get("type") == "image_url")
    try:
        payload, meta = ask_qwen(SELECT_SYS, parts, select_schema(labels))
    except QwenNotConfigured:
        raise
    except Exception as exc:  # noqa: BLE001
        return {"stem": stem, "labels": labels, "images_sent": n_img,
                "error": repr(exc)[:500]}
    baseline = rec.get("selected")
    return {
        "stem": stem,
        "labels": labels,
        "images_sent": n_img,
        "qwen": payload,
        "qwen_winner": payload.get("winner"),
        "baseline_selected": baseline,
        "agrees_with_baseline": (
            None if not baseline else payload.get("winner") == baseline),
        "meta": meta,
    }


# ── 좁게 묻기 ─────────────────────────────────────────────────────────
# 넓게(열린 선정)만으로는 모델이 **안 본 것을 안 본 채로** 넘긴다. 4모델 대조
# 실측이 그랬다 — 열린 판정에서 셋이 놓친 결함을 좁게 물었을 때만 한 모델이
# 짚었다. 반대로 좁게만 물으면 유도 질문이 되어 과탐지를 낳는다. 그래서 둘 다.
#
# 과탐지 판별은 별도 대조군을 만들지 않는다. **같은 질문을 그 샷의 후보 전부에**
# 던지면 후보들이 서로 대조군이 된다 — 지목된 후보에서만 다르게 답하면 탐지,
# 전부 같게 답하면 무신호, 아무 데서나 결함을 보면 과탐지다.
NARROW_SYS = """\
You answer one narrow question about one photograph.

Look first, then answer. Describe only what is literally visible in this
frame — do not infer from what the brief says should be there, and do not
soften an observation to match it. If the thing asked about is not visible
or is ambiguous, say so plainly rather than guessing.

Return one single json object and nothing else."""

NARROW_SCHEMA = {
    "type": "object",
    "properties": {
        "observation_ko": {
            "type": "string",
            "description": "이 프레임에서 실제로 보이는 것만 서술",
        },
        "answer_ko": {"type": "string", "description": "질문에 대한 직답"},
        "count": {
            "type": ["integer", "null"],
            "description": "개수를 묻는 질문이면 숫자, 아니면 null",
        },
        "matches_brief": {
            "type": ["boolean", "null"],
            "description": "브리프와 어긋나면 false, 맞으면 true, 판단 불가면 null",
        },
    },
    "required": ["observation_ko", "answer_ko", "count", "matches_brief"],
    "additionalProperties": False,
}

# 사용자가 육안으로 지목한 결함 → 그 결함을 **한 물음**으로 좁힌 것.
# 형태는 전부 셈·지시대상·광학 가능성 같은 물리 질문이다.
NARROW_QUESTIONS: Dict[str, str] = {
    "S3sh6": "이 사진 속 인물의 겉보기 인종과 성별은 무엇인가? 브리프가 정한 것과 같은가?",
    "S13sh3": "이 프레임에 보이는 운전대(핸들 림)는 몇 개인가? 그리고 인물은 어느 좌석에 앉아 있는가?",
    "S15sh5": "룸미러(백미러)에 무엇이 비치는가? 얼굴이 비친다면 온전한가, 잘렸는가? 이 카메라 위치에서 그 반사가 광학적으로 가능한가?",
    "S18sh5": "각 좌석은 어느 방향을 향하고 있는가? 차량 진행 방향 기준으로 정면인가, 돌아가 있는가?",
    "S42sh4": "이 프레임의 휴대폰은 무엇에 지지되어 있는가? 그것을 쥔 손이 보이는가, 아니면 허공에 떠 있는가?",
    "S62sh4": "이 프레임에 보이는 지폐는 어느 나라 화폐인가? 무엇을 보고 그렇게 판단했는가?",
    "S88sh6": "총구와 인물의 시선은 각각 무엇을 향하고 있는가? 그 대상을 이름으로 말하라.",
}


def run_narrow(stem: str, rec: Dict[str, Any], size_idx) -> Dict[str, Any]:
    """지목된 결함 하나를 후보마다 개별 이미지로 좁게 묻는다."""
    question = NARROW_QUESTIONS.get(stem)
    if not question:
        return {"stem": stem, "skipped": "좁은 질문 미정의"}
    labels = sorted(
        p.stem.split("_")[-1].upper()
        for p in RECIPE.glob(f"{stem}_[abc].png"))
    if not labels:
        return {"stem": stem, "skipped": "후보 롤 없음"}

    roll_prompts = rec.get("roll_prompts") or {}
    brief = _compose_critique_prompt(
        rec.get("prompt", ""), roll_prompts, labels[0], shared_prompt=None)

    per_label: Dict[str, Any] = {}
    for lab in labels:
        parts = [
            {"type": "text", "text": f"THE BRIEF:\n{brief}"},
            {"type": "text", "text": f"QUESTION: {question}"},
            png_part(RECIPE / f"{stem}_{lab.lower()}.png"),
        ]
        try:
            payload, meta = ask_qwen(NARROW_SYS, parts, NARROW_SCHEMA)
            per_label[lab] = {**payload, "_meta": meta}
        except Exception as exc:  # noqa: BLE001
            per_label[lab] = {"error": repr(exc)[:400]}
    return {"stem": stem, "question": question, "labels": labels,
            "per_label": per_label}


def probe() -> None:
    """키·모델·이미지 첨부가 실제로 서는지 최소 비용으로 확인한다."""
    client, model = qwen_client(), qwen_model()
    stem = next(iter(sorted(p.stem[:-2] for p in RECIPE.glob("*_a.png"))), None)
    parts: List[Dict[str, Any]] = [
        {"type": "text", "text": "Reply with a json object: {\"ok\": true, "
                                 "\"sees_image\": <true|false>}"}]
    if stem:
        parts.append(png_part(RECIPE / f"{stem}_a.png"))
    t0 = time.time()
    resp = client.chat.completions.create(
        model=model, messages=[{"role": "user", "content": parts}],
        response_format={"type": "json_object"})
    print(f"모델    : {model}")
    print(f"엔드포인트: {os.getenv('DASHSCOPE_BASE_URL') or DEFAULT_BASE_URL}")
    print(f"첨부 이미지: {'있음 ' + stem if stem else '없음'}")
    print(f"응답    : {(resp.choices[0].message.content or '')[:200]}")
    print(f"소요    : {time.time() - t0:.1f}s")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("out", nargs="?", default="qwen_vlm_out.json")
    ap.add_argument("--stems", default="", help="쉼표 구분 (예: S13sh3,S62sh4)")
    ap.add_argument("--all", action="store_true")
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--workers", type=int, default=3)
    ap.add_argument("--probe", action="store_true", help="키·이미지 첨부만 확인")
    ap.add_argument("--narrow", action="store_true",
                    help="넓게(열린 선정) 대신 지목 결함을 후보마다 좁게 묻는다")
    a = ap.parse_args()

    if a.probe:
        probe()
        return

    records = json.loads((RECIPE / "records.json").read_text("utf-8"))
    if a.stems:
        stems = [s.strip() for s in a.stems.split(",") if s.strip()]
    elif a.all:
        stems = sorted(s for s in records if "::" not in s
                       and list(RECIPE.glob(f"{s}_[abc].png")))
    else:
        sys.exit("--stems 또는 --all 중 하나가 필요하다")
    if a.limit:
        stems = stems[:a.limit]

    task = run_narrow if a.narrow else run_one
    print(f"모델 {qwen_model()} · {'좁게' if a.narrow else '넓게'} · "
          f"대상 {len(stems)}샷 · 동시 {a.workers}")
    size_idx = build_size_index()
    out: Dict[str, Any] = {}
    done = 0
    with ThreadPoolExecutor(max_workers=a.workers) as ex:
        futs = {ex.submit(task, s, records[s], size_idx): s for s in stems}
        for f in as_completed(futs):
            s = futs[f]
            try:
                out[s] = f.result()
            except QwenNotConfigured as exc:
                sys.exit(str(exc))
            except Exception as exc:  # noqa: BLE001
                out[s] = {"stem": s, "error": repr(exc)[:500]}
            done += 1
            if done % 5 == 0 or done == len(stems):
                print(f"  {done}/{len(stems)}", flush=True)

    Path(a.out).write_text(
        json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")

    if a.narrow:
        ok = [v for v in out.values() if v.get("per_label")]
        print(f"\n좁게 물음 {len(ok)}/{len(stems)}샷")
        for v in sorted(ok, key=lambda x: x["stem"]):
            print(f"\n■ {v['stem']} — {v['question']}")
            for lab in v["labels"]:
                r = v["per_label"].get(lab, {})
                if r.get("error"):
                    print(f"   {lab}: 실패 {r['error'][:90]}")
                    continue
                mb = r.get("matches_brief")
                mark = {True: "일치", False: "어긋남", None: "판단불가"}[mb]
                cnt = "" if r.get("count") is None else f" [개수 {r['count']}]"
                print(f"   {lab}: {mark}{cnt} — {r.get('answer_ko', '')[:150]}")
        print(f"\n기록: {a.out}")
        return

    judged = [v for v in out.values() if v.get("qwen_winner")]
    errs = [v for v in out.values() if v.get("error")]
    cmp_ = [v for v in judged if v.get("agrees_with_baseline") is not None]
    agree = sum(1 for v in cmp_ if v["agrees_with_baseline"])
    print(f"\n판정 성공 {len(judged)}/{len(stems)} · 실패 {len(errs)}")
    if cmp_:
        print(f"기존 선정과 일치 {agree}/{len(cmp_)} "
              f"({100 * agree / len(cmp_):.0f}%) — 불일치가 곧 개선은 아니다. "
              f"뒤집힌 샷은 육안으로 봐야 한다.")
        flips = [v["stem"] for v in cmp_ if not v["agrees_with_baseline"]]
        print(f"뒤집힌 샷: {flips[:20]}")
    if errs:
        print(f"실패 예: {errs[0].get('stem')} — {errs[0].get('error')[:200]}")
    print(f"\n기록: {a.out}")


if __name__ == "__main__":
    main()
