# 선정·수정 판정 체계 개편 — Gemini+Qwen 동시 판정 (2026-08-10)

## 0. 배경과 목표

사용자 지시(2026-08-10):

1. **선정**: Gemini 와 Qwen 이 동시 판정. 점수 편차가 심하지 않으면 Gemini 우선.
   기존 Opus/GPT 선정 판정은 제거.
2. **수정**: Qwen 에게 수정할 부분을 물어 → Gemini 가 취합(수정 프롬프트 작성)
   → gemini nb2 가 수정 실행.

실측 근거(`artifact/20260809_final_eval_gallery/results.json`, 255샷 — 두 심판이
같은 계약·같은 후보로 재선정):

- winner 일치 175건(68.6%), 불일치 80건.
- 불일치 샷에서 "Qwen 눈의 격차"(Qwen 정규화 점수에서 자기 승자 − Gemini 승자):
  중앙값 0.33 / ≥0.2 가 58건(전체 22.7%) / <0.2 가 22건.
- "후보 전부 실패" 선언: Gemini 61 vs Qwen 20 — Gemini 가 훨씬 엄격.
- Qwen 특성: 관찰·셈 좋음, `matches_brief` 못 믿음 → 관찰자 역할 적합.

## 1. 합의된 결정 (2026-08-10 사용자 확정)

| # | 결정점 | 확정 내용 |
|---|---|---|
| 1 | "편차 심하지 않음" 기준 | **winner 일치, 또는 불일치라도 Qwen 정규화 점수에서 Gemini 승자가 Qwen 승자 대비 0.2 미만으로 뒤짐** → Gemini 판정 채택 (실측상 77.3% 구간) |
| 2 | 편차 심할 때 | **합산 합의** — 검증된 `combine_select_verdicts`(모델별 정규화 점수 합 + 하드 위반 합집합 페널티 0.25) 재사용, 추가 호출 0 |
| 3 | 수정 흐름 관계 | **Qwen 단독 관찰** — 기존 Gemini critique 의 관찰 겸임·GPT 구도 critique 는 새 체계에서 배선하지 않음. Gemini 는 취합자(이미지+Qwen 관찰 → 타당성 필터+수정 프롬프트 작성)로 역할 이동 |
| 4 | 적용 범위 | **신규 실행부터** — env 플래그 기본 OFF = 기존 경로·지문 byte-identical. 금월도 완주 256샷은 동결. 소급은 새 체계 소량 실측 뒤 별도 결정 |

## 2. 설계

### 2.1 플래그 — `multiroll_gq_judge_enabled` (기본 False)

`app/core/config.py` Settings 신규. 이 플래그 하나가 선정·fix-rejudge·수정
흐름 전부를 가른다.

- **OFF**: 현행 경로 그대로 — `resolve_select_judge_models()` 는 기존 로직
  (ANTHROPIC 키 유무 → Opus+Sol 이중 / Opus 단독 / gemini-pro 단독),
  critique 는 `make_gemini_critique_fn`(+GPT 구도), 지문·config_hash 불변.
- **ON**: 선정 = `[gemini-pro, qwen]` 동시 판정. critique = Qwen 관찰→Gemini
  취합 2단. GPT 구도 critique·Opus/GPT 는 경로에서 완전히 빠진다.
- **ON + `DASHSCOPE_API_KEY` 없음 = fail-closed** — `resolve_select_judge_models()`
  에서 AppError. 조용히 Gemini 단독으로 강등하지 않는다(강등이면 "이중으로
  판정했다"는 기록이 거짓이 된다).
- Opus/GPT 경로 코드의 물리적 삭제는 **범위 밖** — 금월도 동결이 풀린 뒤 별도
  커밋(OFF 경로가 그 코드를 아직 쓴다).

Settings 에 DashScope 필드 3개 추가(`.env` 에 값 이미 있음):
`dashscope_api_key: str = ""` · `dashscope_base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"` · `qwen_vlm_model: str = "qwen3.8-max"`.

### 2.2 선정 — 동시 판정 + 합의 규칙

`multiroll_gemini.py` 에 상수·함수 추가:

```
QWEN_JUDGE_MODEL = "qwen-vlm"          # Router 미등록 센티널 — 디스패치 키
GQ_DISAGREE_MARGIN = 0.20              # 결정 1 의 문턱
GQ_SELECT_POLICY_VERSION = "gq_select_v1_margin020"   # 지문 기여
```

- `resolve_select_judge_models()`: ON 이면 `[JUDGE_MODEL, QWEN_JUDGE_MODEL]`
  (Gemini 첫째 = 우선 심판). `resolve_select_judge_model()`: ON 이면
  `JUDGE_MODEL`. `resolve_select_judge_model_physical()`: physical 맵에
  `QWEN_JUDGE_MODEL → settings.qwen_vlm_model` 추가 — ON 이면
  `"<gemini_text_model>+<qwen_vlm_model>"`.
- `make_gemini_judge_fn` 의 `_one` 에 모델 디스패치: `QWEN_JUDGE_MODEL` 이면
  Qwen 클라이언트(§2.4) 경로, 그 외는 기존 `call_structured`. 판정 sys·schema 는
  두 심판 **공용**(judge_still 팩 v7 + `build_judge_schema(with_physics=True)`).
- `_judge_gq(models, one, parts, labels)` 신설 — ON 일 때 `_judge_dual` 대신:

```
g = one(gemini); q = one(qwen)          # 순차 호출, margin-skip 없음(동시 판정)
한쪽 실패 → 남은 쪽 채택 + route="single_<모델>" 경고 기록
둘 다 실패 → raise (원인 체이닝 — 현행 _judge_dual 관례)
g.winner == q.winner                    → g 채택, route="agree"
else: gap = q_norm[q.winner] − q_norm[g.winner]   # Qwen verdicts 를 최고점으로 정규화
  gap < GQ_DISAGREE_MARGIN              → g 채택, route="gemini_priority"
  gap ≥ GQ_DISAGREE_MARGIN              → combine_select_verdicts({g,q}), route="combined"
반환에 "gq": {route, gap, per_model_winner, models} 병기
```

- 반환 shape 는 단독 판정 호환(winner/ranking/verdicts) — 호출측·record 계약
  무변경. `all_candidates_fail` 은 combined 경로는 기존 함수 규칙(전원 합의),
  Gemini 채택 경로는 Gemini 선언을 따른다.
- **fix-rejudge 도 자동으로 새 체계**: `make_gemini_judge_fn` 경유이므로 [원본
  vs 수정본] flip 재판정도 G+Q 로 돈다. 수정 발생 샷당 판정 호출 flip 2 × 2모델
  = 4회(현행 Opus+Sol margin-skip 평균 ~3회와 비슷한 수준).

### 2.3 수정 흐름 — CritiqueFn 껍데기 유지, 속만 2단

`make_gq_critique_fn(critique_schema, project_config, step_tag, …)` 신설.
기존 CritiqueFn 시그니처 `(tag, prompt, labeled_refs, image_path)` 를 그대로
지키므로 **하류(`_critique_and_fix` 의 issue partition·`build_fix_prompt`·
fix-rejudge·nb2 실행)는 무변경**이다.

내부 흐름:

1. **Qwen 관찰**: 브리프+참조+선정 이미지 → 관찰 목록.
   스키마 `{observations: [{issue_ko, severity(critical/major/minor)}]}` —
   수정문(fix_en)은 쓰지 않는다. 관찰자는 "무엇이 잘못됐나"만.
2. **관찰 0건 → 즉시 `{"issues": []}` 반환** — Gemini 호출 생략(수정 스킵,
   유료 1콜 절약). 이것이 "Qwen 단독 관찰" 합의의 의미다.
3. **Gemini 취합**: 브리프+참조+이미지+Qwen 관찰 목록 →
   `build_critique_schema()` 그대로(issues: issue_ko/fix_en/unfixable/
   needs_regeneration). 취합 계약: 관찰을 이미지와 대조해 **기각할 수 있고**
   (기각 = issues 에서 제외), 채택한 결함만 수정문으로 옮긴다. severity 는
   취합 참고 입력일 뿐 최종 스키마에 넣지 않는다(하류 소비 없음 — 계약
   불변 원칙).
4. 반환 dict 에 `qwen_observations`(관찰 **원본**) 병기 —
   `record["critique"]` 에 접힌다. 채택·기각은 관찰 원본과 최종 issues 를
   나란히 놓고 읽는다(갤러리 육안 대조용). ★기각 사유의 **구조화 기록은
   두지 않는다** — 기계 소비자가 없고, LLM 출력 칸을 늘릴수록 판정이
   나빠진다는 실측(ref_pick "칸을 늘릴수록 판정이 나빠진다")이 근거다.
   구조화가 필요해지는 소비자가 생기면 그때 별도 wrapper 스키마로.

프롬프트 스템 2개 신설: `gq_observe_sys`(Qwen 관찰 계약) ·
`gq_compose_sys`(Gemini 취합 계약). **새 selector `"8"` 추가-스템 전용 팩**
(`JUDGE_PACK_VERSION_MAP["8"]`, `GQ_CRITIQUE_PACK_VERSION = "8"`) —
fix_rejudge_header v3 단일 스템 팩과 같은 기존 패턴. **v7 디렉토리 불변** →
OFF 지문(`judge_pack_content`)이 안 움직인다.

Qwen 관찰 계약의 관찰 축은 선정 판정 v7 네 축(방향·복잡 구조물 내부 공간·
엔티티 정체·물리 지지)과 정합하게 쓰되, 시나리오·작품 고유 어휘는 넣지
않는다(범용 규칙).

### 2.4 Qwen 프로덕션 클라이언트 — `app/modules/llm/qwen_vlm_client.py`

`backend/qwen_vlm_pilot.py` 의 검증된 부품을 이관(실험 파일은 그대로 둔다):

- DashScope OpenAI 호환 직접 호출. `json_schema` 미지원 → 스키마 전문을
  시스템에 동봉(JSON_CLAUSE) + `jsonschema` 로컬 검증 + 실패 시 오류 되먹여
  교정 1회. thinking off(미지원 배포본이면 빼고 재시도). 관용 JSON 파싱
  (`extract_json` — 코드펜스·앞뒤 추론 텍스트 허용).
- 설정은 `settings`(§2.1 필드)에서 읽는다 — pilot 의 `os.environ` 직접 읽기는
  이관하지 않는다.
- **기록 의무**: 모든 호출을 `record_provider_call`(image_tracer) 로
  llm_call_log+Opik 양쪽에 남긴다 — litellm 우회 직접 호출 규약. `provider`
  명시, 메타(project/episode/still_id)는 ambient scope 규약을 따른다.
  우회 호출 AST 스캐너 시험이 이 배선을 검증 대상으로 잡는다.
- 재시도 정책은 최소로 시작: 스키마 교정 1회만. 네트워크/429 재시도는 첫
  소량 실측에서 필요가 확인되면 그때 더한다(선제 확장 금지).

### 2.5 지문·config_hash — "OFF = byte-identical" 이 계약

**ON 일 때만** 키가 생긴다. OFF 상태에서 이 변경을 배포·재기동해도 완료
256샷의 지문·config_hash 는 1비트도 안 움직여야 한다(유닛으로 고정).

- `still_recipe_service.extra_fingerprint`:
  - 기존 5개 키(`judge_model`·`select_judge_model_physical`·`bgfirst_…`·
    `fix_rejudge_…`·`still_variants_…`)는 전부 `resolve_select_judge_model[
    _physical]()` 경유 — ON 이면 반환값이 바뀌며 **자동으로** 갈린다.
    지문 조립부 자체는 수정 없음(§2.2 의 resolve 함수 수정이 전파될 뿐).
  - ON 일 때 추가: `gq_select_policy`(=`GQ_SELECT_POLICY_VERSION`) ·
    `gq_critique_pack`(selector 해석값) · `gq_critique_pack_content`
    (`judge_pack_content_hash("8")` — 팩 bytes 해시, #77 관례).
- `image_steps._config_hash` payload: ON 일 때만
  `multiroll_gq_judge_enabled: True` · `gq_select_policy` ·
  `gq_critique_pack(+_content)` 추가. **`:713 "judge_model": "gemini-pro"`
  하드코딩은 건드리지 않는다** — 고치면 config base 가 움직여 RERUN_SELF
  동등성이 깨진다(이미 "재생성 완료 뒤 별도 커밋" 목록의 결함).
- ON 전환 후 완료 스텝 재진입은 #77 경로 그대로: config drift →
  `step_config_drift_ack` 승인 없으면 BLOCK / 승인 시 비파괴 resume +
  샷별 JIT 지문 검증 + 재생성 상한 래치(64). **운영 규칙: 금월도 에피소드는
  ON 상태에서 resume 하지 않는다** — 실수해도 위 가드가 조용한 전량 재생성을
  막지만, 가드에 기대는 것이 계획이어서는 안 된다.

### 2.6 소비처 배선 (`still_recipe_service.py`)

- `critique_fn`: ON 이면 `make_gq_critique_fn`, OFF 면 기존
  `make_gemini_critique_fn`.
- `composition_critique_fn`: ON 이면 배선하지 않음(None — kwargs 생략 관례로
  byte-identical 분기 유지). OFF 면 기존 flag 로직 그대로.
- `judge_fn`·`fix_rejudge_fn`: 코드 무변경 — `make_gemini_judge_fn` 내부
  분기(§2.2)가 흡수.
- `canary_production.py`: 서비스와 같은 조립을 쓰도록 critique_fn 분기만 동기
  — ON 상태 카나리아가 새 경로 전부(동시 판정·합의·관찰·취합·기록)를 태운다.

## 3. 리스크

1. **Qwen × 프로덕션 판정 스키마 미검증** — 실측 갤러리는 단순한 실험 스키마
   (readings+winner)였다. 프로덕션 스키마는 verdicts/ranking/physics 필수가
   추가된 형태. 교정 1회로 흡수되는지 카나리아에서 먼저 확인한다. 반복 실패
   시 스키마 단순화가 아니라 **원인(누락 필드 패턴)을 보고 결정**한다.
2. **DashScope 쿼터·지연** — 판정이 스틸 파이프의 직렬 구간이라 Qwen 지연이
   샷당 벽시계에 더해진다. 소량 실측에서 재고 병렬화는 그 뒤 판단.
3. **ON 전환 뒤 실수 resume** — §2.5 가드 3중이지만 운영 규칙이 1차 방어다.
4. **판정 호출 증가** — margin-skip(현행 48.6% 생략)이 사라져 선정 판정이
   항상 2회. 지시("동시 판정")에 내재된 비용이며 Qwen 단가는 낮다.

## 4. 검증 계획 (합의된 개발 사이클)

1. **유닛**: `_judge_gq` 갈래(agree/gemini_priority/combined/single/전건 실패)
   · gap 계산(정규화·0점 방어) · critique 2단(관찰 0건 조기 반환, 취합 기각,
   스키마 검증) · **OFF byte-identical 고정**(플래그 OFF 에서 지문 dict·config
   payload 가 개편 전과 동일함을 스냅샷으로) · fail-closed(ON+키 없음).
2. **Codex 리뷰** — 코드만.
3. **재기동 → 소량 실측**: `canary_production.py` ON 상태 몇 샷(기존 후보 롤
   재사용 — 생성비 0, 판정·관찰·취합·기록만 유료 소량). 확인: Qwen 스키마
   준수율(리스크 1)·합의 route 분포·record/Opik 기록·OFF 복귀 시 불변.
4. 적용(신규 실행부터). 소급 여부는 실측 결과를 보고 별도 결정.

## 5. 범위 밖 (명시)

- 금월도 256샷 소급 재선정·재생성 (결정 4 — 별도 결정).
- Opus/GPT 선정 경로 코드의 물리적 삭제 (OFF 경로가 사용 중).
- `image_steps.py:713` `judge_model` 하드코딩 수정 (별도 커밋 예정 목록).
- 판정 계약(judge_still v7) 자체의 축 변경 — 두 심판이 v7 을 공용할 뿐.
- Qwen 을 litellm Router 에 태우는 일반화 (json_schema 미지원이 원인 —
  DashScope 가 지원하게 되면 그때 별도 과제).
