#!/usr/bin/env python
"""유형 사전지식 그라운딩(실험) — 결손 문항 → 원본어 검색 → 일치 채택.

## 왜 이 단계가 필요한가 (2026-07-31 실측)

씨드 저작은 **시나리오 원문 + place spec** 두 가지만 근거로 쓴다. 그래서
둘 다 침묵한 항목은 이미지 모델이 임의로 정한다. `bg_multifamily_residence`
실측 — 외부 계단이 **무엇에 닿는지**가 어느 층에도 없었다:

    시나리오  "한 다세대 주택 외부 계단을 다급히 올라간다"   → 도착점 없음
    place spec "rises ... toward its access point"           → 자기참조
    브리프     "reaches the alley-side access point"          → 승계
    생성 프롬프트 "rises along the outside wall to the access landing"

"출입점으로 올라간다"는 동어반복이라 층이 비어 있고, 모델이 2층 문에
임의로 붙였다. 사용자 육안 반려("옥상으로 올라가는 계단이 아니야").

팩 v11 은 이미 "목록에 없다 = 없다가 아니다, 유형 통상에 위임하라"를
명문화했다. 이 모듈은 그 **'통상'을 모델 기억 대신 검색 사실로** 바꾼다.

## 계약

0. **대상 = 당연하지 않은 것만** (v3, 2026-08-03). 그 유형이면 늘 있는 것은
   묻지 않는다 — 그림 모델이 이미 알고, 물으면 하나뿐인 질의를 차지해
   정작 필요한 것을 묻는다. 남는 것은 보통 하나. 무엇이 특별한지는 명세가
   붙인 **이름이 아니라 원문 인용**을 보고 LLM 이 정한다.
1. **권위 = 결손 전용.** 시나리오 > place spec > 유형 사전지식.
   침묵한 항목에서만 쓰고, 충돌하면 시나리오·spec 이 이긴다.
2. **문항·질의는 유형 일반으로 환원.** 작품 고유명사·인물·사건·배치를
   넣지 않는다 — `search_grounded_ref` 의 기존 검색 계약과 동형
   ("must not contribute people, events, temporary objects, layout, or
   any story-specific name").
3. **이중 검색 후 일치만 채택.** 두 실행이 같은 사실을 말할 때만 쓴다.
   불일치·미발견은 폐기 — 채택하지 않아도 팩 v11 의 전형 위임으로
   되돌아갈 뿐이라 손해가 없다(fail-closed).
4. **질의 언어는 시나리오 원문어.** 영어로 물으면 그 지역의 전형이
   아니라 영어권 전형이 돌아온다(기존 계보 실측).

## 격리

순수 함수 + 호출만 담는다. DB 쓰기·체크포인트·스텝 오케스트레이션 없음.
"""
from __future__ import annotations

import json
import logging
import re
from typing import Any, Dict, List, Optional, Sequence

# ★모듈 상단 import — 함수 안에 두면 그 함수를 태우는 테스트가 없을 때
# 호출 시점까지 NameError 가 드러나지 않는다(2026-08-02 실측: 전 그룹이
# 조용히 failed 로 떨어졌다). 이 모듈은 표준 라이브러리만 끌어온다.
from app.modules.pipeline.search_grounded_ref import (
    SearchNotPerformed,
    build_web_search_tool,
    response_reasoning_effort,
    web_search_call_counts,
)

logger = logging.getLogger(__name__)

# v2 (2026-07-31): 문항 범위를 형태 결손에서 4축으로 넓혔다.
# v3 (2026-08-03 사용자 지적 + 실측): **넓힌 것이 결함이었다.** 4축은 그
# 유형이면 늘 있는 것까지 묻게 만들었다 — 8문항 중 7개가 입지·규모·건물
# 형태·전면구성·표시·작명처럼 그림 모델이 이미 아는 것이었고, 하나뿐인
# 특별한 대상은 **한 문항에 열 가지를 뭉쳐** 물어 "증거 없음"으로 끝났다.
# 낭비와 결손이 동시에 일어났다.
# → 축을 없애고 **당연하지 않은 것만** 남긴다(보통 하나, 많아야 둘).
#   문항은 한 줄. 무엇이 특별한지는 명세가 붙인 **이름이 아니라 원문 인용**
#   을 보고 LLM 이 정한다(이름은 명세가 지어낸 것이고 인용이 근거다 —
#   실측에서 이름 때문에 사진 속 실물을 "없다"고 판정해 통째로 빠졌다).
# v4 (2026-08-03): 확정된 지역·시대를 문항 저작 입력에 배선하고,
# 문항마다 그것을 적게 한다 — 안 그러면 "이 지역"으로 나가 조사가
# "지역이 명시되지 않았다"로 전건 폐기된다(2그룹 4건 실측).
TYPOLOGY_PRIOR_VERSION = "4"

# 그룹당 문항 상한 — "보통 하나, 많아야 둘". 상한을 낮게 두는 것이 계약의
# 일부다: 넉넉히 두면 모델이 자리를 채우려고 당연한 것을 다시 끌어온다.
MAX_GAP_QUESTIONS = 2
# 검색 실행 2회의 오케스트레이터.
#
# ★2026-09-08: 둘 다 `gpt-6-astra` (사용자 지시 「gpt 텍스트는 전부」).
#  ★이것은 **이종 모델 교차 검증이 아니다.** 같은 모델을 두 번 따로 돌려
#   답이 일치하는지 보는 것뿐이다 (Codex 2026-09-08 정정). 호출이 별개라고
#   오류·검색 출처까지 독립인 것은 아니므로, 같은 URL 을 두 번 찾은 것을
#   근거 둘로 세지 않는다 — `combine_typology_answers` 가 `sources` 를
#   합집합으로 접는 것이 그 몫이다.
#  결합은 이름이 아니라 **자리**로 한다(`by_index[0]`/`[1]`) — 두 값이 같은
#  문자열이어도 한쪽이 다른 쪽을 덮지 않는다.
SEARCH_MODELS = ("gpt-6-astra", "gpt-6-astra")
GAP_MODEL = "gpt"          # 결손 문항 추출 = 분석 주력 (모델 분업 계약)
AGREE_MODEL = "gemini-pro"  # 일치 판정 = 확정 계열


def _model_cfg(step_tag: str, model: str,
               project_config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    """모델 분업을 명시 배선한다.

    이 모듈의 step 이름은 STEP_MANIFEST 에 없다. `_resolve_model` 은 미등록
    step 을 **gemini-pro 로 조용히 폴백**하므로(실측 2026-07-31: 문항 추출이
    Sol 이 아니라 Gemini 로 갔다), project_config 에 step 을 실어 폴백
    경로를 타지 않게 한다 — `_resolve_model` 은 project_config 에 있는 step
    을 fallback 대상에서 제외한다. 호출자가 이미 지정했으면 그것을 존중한다.
    """
    cfg = dict(project_config or {})
    cfg.setdefault(step_tag, {"model": model})
    return cfg


# ─────────────────────────────────────────────────────────────────────
# 1. 결손 문항 추출 — 검색 없음, 시나리오·spec 만 읽는다
# ─────────────────────────────────────────────────────────────────────
GAP_SYSTEM = """You decide which things at a location are worth looking
up, and which are not.

You get everything the screenplay says about one real place, a structure
spec derived from it, and a list of the things that must be present there.
Some entries carry the source line that put them there.

★ MOST OF THIS NEEDS NO LOOKING UP. Whatever a place of this kind always
has, anyone drawing such a place already knows how it looks. Looking those
up spends the search on what was never in doubt, and — worse — lets them
crowd out the one thing that was.

Keep something ONLY if it meets one of these:
- a place of this kind does NOT always have it, so its ordinary form here
  cannot be assumed; or
- the source line shows the story turns on it, so getting its real form
  wrong would be plainly wrong on screen.

Usually that leaves ONE. Two at most. If nothing qualifies, return an
empty list — that is a good answer, not a failure.

★ JUDGE FROM THE SOURCE LINE, NOT FROM THE NAME. The spec's name for a
thing was written by someone reading the screenplay, and it is often the
nearest ordinary word rather than what actually stands there. The quoted
line is the evidence; the name is not.

For each one you keep, write:
- `target_native`: that one thing, in the plain everyday words local
  people use for it;
- `question_native`: ONE plain question about how it is ordinarily made
  and what it looks like there.

Three hard rules on how you write them:

1. ONE LINE, ONE THING. A question that stacks up material, shape, size,
   surroundings and whether it exists has no answer anywhere and comes
   back empty. Never fold two things into one question.
2. GENERALISE TO THE TYPE. Ask how such a thing is ordinarily made in
   this region — never about this particular story's place. Never name a
   character, a place name, an event, or anything that happens in the
   story, and never carry over its staging, weather, time of day or who
   is present. A reader must not be able to tell which work this came
   from.
3. WRITE IN THE LANGUAGE OF THE SCREENPLAY TEXT you were given, not in
   English. These go into a search engine, and the local convention is
   only documented in the local language.
4. NAME THE REGION AND ERA INSIDE EVERY QUESTION, taking them from the
   confirmed region-and-era block. The searcher is given nothing but your
   questions and your briefing, so a question that says "around here" or
   "in this area" points at nothing and comes back "the region was not
   specified" — measured, and everything asked that way was discarded.
   Write the place and period as words a stranger could search on.

Say in `dropped_reason_ko` (Korean, one line) why the rest were left out.

## ALSO WRITE THE RESEARCHER'S BRIEFING — IN THE SAME LANGUAGE

The person who runs these searches gets one short briefing, and it is the
only thing they read. Write it in the SAME language as the questions,
never in English. It must tell them: search only in this language; look at
the photographs the search returns and not only at the pages; report what
is ORDINARY rather than one striking example; for anything about size,
answer by comparison with what stands in the same view — people, doors,
storeys, rooms — and never in units of measurement, because a measurement
cannot be drawn; describe anything a viewer would SEE closely enough that
someone could draw it, saying how many parts there are, where each sits,
and which colours divide them; and say plainly when nothing was found
rather than reasoning it out."""


def build_gap_schema(max_items: int = MAX_GAP_QUESTIONS) -> Dict[str, Any]:
    """남길 대상 스키마. `target_native` 는 이미지 질의의 대상이 된다."""
    return {
        "type": "object",
        "properties": {
            "questions": {
                "type": "array",
                "maxItems": int(max_items),
                "items": {
                    "type": "object",
                    "properties": {
                        "target_native": {
                            "type": "string",
                            "description": (
                                "The one thing, in the plain everyday local "
                                "words, in the language of the supplied "
                                "screenplay text."),
                        },
                        "question_native": {
                            "type": "string",
                            "description": (
                                "ONE plain question about how it is "
                                "ordinarily made there, same language."),
                        },
                        "why_ko": {
                            "type": "string",
                            "description": "왜 이것만 남겼는지 한 줄(한국어).",
                        },
                    },
                    "required": [
                        "target_native", "question_native", "why_ko"],
                },
            },
            "search_brief_native": {
                "type": "string",
                "description": (
                    "One short briefing for the researcher, written in "
                    "the SAME language as the questions."),
            },
            "dropped_reason_ko": {
                "type": "string",
                "description": "버린 것들의 사유 한 줄(한국어).",
            },
        },
        "required": ["questions", "search_brief_native",
                     "dropped_reason_ko"],
    }


def build_spec_item_lines(spec_items: Sequence[Dict[str, Any]]) -> str:
    """명세가 든 항목을 **원문 인용과 함께** 늘어놓는다.

    ★인용이 근거이고 이름은 근거가 아니다. 이름은 명세를 쓴 쪽이 시나리오를
    읽고 고른 가장 가까운 보통 낱말일 뿐이라, 이름만 주면 실물과 어긋나도
    알 길이 없다(실측: 명세가 붙인 이름 때문에 사진 속 실물을 "없다"고
    판정해 요구된 물건이 통째로 빠졌다). 코드는 무엇이 특별한지 모른다 —
    고르는 것은 이 목록을 읽는 LLM 이다.
    """
    lines: List[str] = []
    for it in spec_items or []:
        if not isinstance(it, dict):
            continue
        name = str(it.get("name_en") or "").strip()
        if not name:
            continue
        quote = str(
            ((it.get("evidence") or {}) if isinstance(
                it.get("evidence"), dict) else {}).get("quote_ko") or ""
        ).strip()
        lines.append(f"- {name}"
                     + (f"\n    source line: “{quote}”" if quote else ""))
    return "\n".join(lines)


def build_gap_user_content(
    *,
    structure_desc: str,
    layout_narration_en: str,
    interior_note_en: str,
    exterior_note_en: str,
    scene_blocks: Sequence[str],
    spec_items: Optional[Sequence[Dict[str, Any]]] = None,
    world_facts_block: str = "",
    max_items: int = MAX_GAP_QUESTIONS,
) -> str:
    """대상 선정 입력 조립 — 시나리오 원문 전문 무절단 (절대 규칙).

    ★`world_facts_block` 이 없으면 문항이 "이 지역"처럼 가리킬 데 없는 말로
    나가고, 조사는 "지역이 명시되지 않았다"로 전건 폐기된다(2026-08-03 실측:
    6그룹 중 2그룹의 조사 4건이 통째로 버려졌다). 확정된 지역·시대는 스텝이
    이미 들고 있었는데 이 경로에만 배선되지 않았다.
    """
    if not (structure_desc or "").strip():
        raise ValueError("typology_prior: structure_desc 결손")
    if not scene_blocks:
        raise ValueError("typology_prior: 씬 원문 결손 — 근거 없는 문항 차단")

    parts: List[str] = []
    if (world_facts_block or "").strip():
        parts.append(
            "REGION AND ERA (creator-confirmed — every question you write "
            "must name these, because the searcher is given nothing "
            "else):\n" + world_facts_block)
    parts.append(
        "STRUCTURE SPEC (what the screenplay was read to establish):\n"
        + structure_desc)
    item_lines = build_spec_item_lines(spec_items or [])
    if item_lines:
        parts.append(
            "THINGS THAT MUST BE PRESENT AT THE PLACE — the name is what "
            "someone called it after reading the screenplay; the quoted "
            "line is the evidence:\n" + item_lines)
    if (layout_narration_en or "").strip():
        parts.append("SITE LAYOUT AS DERIVED:\n" + layout_narration_en)
    if (interior_note_en or "").strip():
        parts.append("INTERIOR EVIDENCE (size only):\n" + interior_note_en)
    if (exterior_note_en or "").strip():
        parts.append("EXTERIOR EVIDENCE:\n" + exterior_note_en)
    parts.append(
        "SCREENPLAY SCENES (original language, complete text — this is the "
        "primary source and also tells you which language to write your "
        "questions in):\n\n" + "\n\n".join(scene_blocks)
    )
    parts.append(
        f"Return at most {int(max_items)} questions.")
    return "\n\n".join(parts)


def validate_gap_output(data: Dict[str, Any],
                        max_items: int = MAX_GAP_QUESTIONS) -> List[str]:
    """구조 검증 — 형태와 상한만. **의미 판정은 하지 않는다.**

    무엇이 좋은 대상인지, 어떤 답이 맞는지는 여기서 판정하지 않는다. 글자
    패턴으로 의미를 가르지도 않는다.
    """
    violations: List[str] = []
    qs = data.get("questions")
    if not isinstance(qs, list):
        return ["questions 가 배열이 아님"]
    if len(qs) > max_items:
        violations.append(f"대상 수 {len(qs)} > 상한 {max_items}")
    for i, q in enumerate(qs):
        if not isinstance(q, dict):
            violations.append(f"[{i}] 항목이 객체가 아님")
            continue
        if not str(q.get("target_native") or "").strip():
            violations.append(f"[{i}] target_native 결손")
        if not str(q.get("question_native") or "").strip():
            violations.append(f"[{i}] question_native 결손")
    return violations


def extract_gap_questions(
    *,
    structure_desc: str,
    layout_narration_en: str,
    interior_note_en: str,
    exterior_note_en: str,
    scene_blocks: Sequence[str],
    spec_items: Optional[Sequence[Dict[str, Any]]] = None,
    world_facts_block: str = "",
    project_config: Optional[Dict[str, Any]] = None,
    max_items: int = MAX_GAP_QUESTIONS,
    step_tag: str = "typology_gap_extract",
) -> Dict[str, Any]:
    """당연하지 않은 대상만 남겨 한 줄 물음으로 만든다 (검색 없음)."""
    from app.modules.llm.llm_client import call_structured

    user = build_gap_user_content(
        structure_desc=structure_desc,
        layout_narration_en=layout_narration_en,
        interior_note_en=interior_note_en,
        exterior_note_en=exterior_note_en,
        scene_blocks=scene_blocks, spec_items=spec_items,
        world_facts_block=world_facts_block, max_items=max_items)
    data = call_structured(
        step=step_tag, system_prompt=GAP_SYSTEM, user_prompt=user,
        response_schema=build_gap_schema(max_items),
        project_config=_model_cfg(step_tag, GAP_MODEL, project_config),
        schema_name="typology_gaps")
    violations = validate_gap_output(data, max_items)
    if violations:
        raise ValueError("typology_prior 대상 구조 위반: " + "; ".join(
            violations))
    qs = list(data.get("questions") or [])[:max_items]
    logger.info(
        "typology_prior: 남긴 대상 %d개 — %s / 버린 이유: %s", len(qs),
        ", ".join(str(q.get("target_native")) for q in qs),
        str(data.get("dropped_reason_ko") or "")[:200])
    return {"questions": qs,
            "dropped_reason_ko": str(
                data.get("dropped_reason_ko") or "").strip(),
            "search_brief_native": str(
                data.get("search_brief_native") or "").strip()}


# ─────────────────────────────────────────────────────────────────────
# 2. 원본어 텍스트 검색 — 독립 2회
# ─────────────────────────────────────────────────────────────────────
# ★검색에 나가는 프롬프트는 무조건 원본어다 (2026-08-03 사용자 재지시).
# 지시 내용은 문항 추출이 원본어로 저작한 `search_brief_native` 가 담고,
# 코드에 남는 것은 **언어가 아닌 출력 형식**뿐이다. 영어 briefing 을 쓰면
# 지시의 절반이 영어라 영어권 전형이 섞여 돌아온다.
ANSWER_FORMAT = """

Reply with ONE JSON object and nothing else:

{"answers": [{"index": <question number, from 1>,
              "answer_en": "<what you found, in English>",
              "found": true|false,
              "sources": ["<url>", ...]}]}"""


def build_search_user(questions: Sequence[Dict[str, Any]]) -> str:
    """문항을 번호 붙여 원본어 그대로 넘긴다."""
    lines = []
    for i, q in enumerate(questions, start=1):
        lines.append(f"{i}. {str(q.get('question_native') or '').strip()}")
    return "\n".join(lines)


def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
    """응답 텍스트에서 JSON 객체 하나를 꺼낸다(코드펜스 허용)."""
    s = (text or "").strip()
    if not s:
        return None
    fence = re.search(r"```(?:json)?\s*(.+?)\s*```", s, re.S)
    if fence:
        s = fence.group(1).strip()
    start, depth = s.find("{"), 0
    if start < 0:
        return None
    for i in range(start, len(s)):
        if s[i] == "{":
            depth += 1
        elif s[i] == "}":
            depth -= 1
            if depth == 0:
                try:
                    obj = json.loads(s[start:i + 1])
                except json.JSONDecodeError:
                    return None
                return obj if isinstance(obj, dict) else None
    return None


def search_typology_answers(
    client: Any,
    *,
    questions: Sequence[Dict[str, Any]],
    model: str,
    brief_native: str = "",
) -> Dict[str, Any]:
    """원본어 문항과 **원본어 briefing** 으로 검색해 답한다.

    반환 = {"model", "queries", "answers": [{index, answer_en, found,
    sources[]}], "raw"}
    """
    if not questions:
        # ★물어볼 것이 없어 **호출조차 안 한다** — 검색 0은 결함이 아니라 N/A.
        return {"model": model, "queries": [], "answers": [], "raw": "",
                "search_calls": 0, "search_completed": 0}

    # ★검색 호출도 남긴다 (2026-08-07 사용자 지시) — `responses.create` 는
    #  litellm 밖이라 자동 추적이 안 닿는다.
    import time as _time

    from app.modules.llm.image_tracer import (
        ambient_call_meta, record_provider_call, resolve_step_name)

    _instructions = (brief_native or '').strip() + ANSWER_FORMAT
    _user = build_search_user(questions)
    _meta = ambient_call_meta()
    _step = resolve_step_name("typology_prior", _meta)
    _t0 = _time.monotonic()
    try:
        resp = client.responses.create(
            model=model,
            instructions=_instructions,
            input=[{"role": "user",
                    "content": [{"type": "input_text", "text": _user}]}],
            # ★사진도 받는다 (2026-08-03 사용자 지시 "이미지 검색은 필수").
            # 기본값은 텍스트만 돌려준다 — 같은 사실을 `search_grounded_ref`
            # 모듈 docstring 이 이미 적어 두었는데 이 경로에만 적용되지 않았다.
            # 규모는 글로 물어도 정확히 알 수 없고 수치를 얻어도 그림에 실리지
            # 않으므로, **실제 사진에서 사람·문·층 대비 비례를 읽는 것**이
            # 규모를 그림에 도달시키는 경로다. 사진은 근거로만 쓰고 생성
            # 참조로 붙이지 않는다 — 그 몫은 형태 참조 스텝이 따로 진다.
            tools=[build_web_search_tool()],
            include=["web_search_call.results"],
        )
    except Exception as _exc:
        record_provider_call(
            step=_step, model=model, prompt=f"{_instructions}\n---\n{_user}",
            status="error", duration_ms=int((_time.monotonic() - _t0) * 1000),
            meta=_meta, operation="web_search", provider="openai",
            error=str(_exc)[:500])
        raise

    queries: List[Any] = []
    said = ""
    for o in resp.output:
        if o.type == "web_search_call":
            act = getattr(o, "action", None)
            q = getattr(act, "queries", None) or getattr(act, "query", None)
            if q:
                queries.append(q)
        elif o.type == "message":
            for c in (getattr(o, "content", None) or []):
                said += getattr(c, "text", "") or ""

    parsed = _extract_json_object(said) or {}
    answers: List[Dict[str, Any]] = []
    for a in (parsed.get("answers") or []):
        if not isinstance(a, dict):
            continue
        try:
            idx = int(a.get("index"))
        except (TypeError, ValueError):
            continue
        if not (1 <= idx <= len(questions)):
            continue
        answers.append({
            "index": idx,
            "answer_en": str(a.get("answer_en") or "").strip(),
            "found": bool(a.get("found")),
            "sources": [str(u) for u in (a.get("sources") or []) if u],
        })
    # ★**응답을 다 읽고 나서** 적는다 — 계약은 `search_grounded_ref` 한 곳에
    #  있다. 검색이 안 돈 호출을 「완료」로 남기면 하류가 「찾았는데 없다」와
    #  「안 찾았다」를 못 가른다.
    _calls, _done = web_search_call_counts(resp)
    record_provider_call(
        step=_step, model=model, prompt=f"{_instructions}\n---\n{_user}",
        status="success" if _done else "error",
        duration_ms=int((_time.monotonic() - _t0) * 1000),
        meta=_meta, operation="web_search", provider="openai",
        output_text=(f"[web_search calls={_calls} completed={_done} "
                     f"answers={len(answers)} "
                     f"effort_requested=omitted "
                     f"effort_effective={response_reasoning_effort(resp) or '?'}]"),
        error="" if _done else "완료된 web_search 가 0 — 조사가 안 돌았다")
    if not _done:
        raise SearchNotPerformed(
            f"완료된 web_search 가 0 이다 (호출 {_calls}회) — "
            "이 답은 조사 결과가 아니다")

    logger.info("typology_prior[%s]: 검색 %d회(완료 %d) / 답 %d개(발견 %d)",
                model, _calls, _done, len(answers),
                sum(1 for a in answers if a["found"]))
    return {"model": model, "queries": queries, "answers": answers,
            "raw": said.strip(),
            "search_calls": _calls, "search_completed": _done}


# ─────────────────────────────────────────────────────────────────────
# 3. 일치 채택 — 갈리면 버린다 (fail-closed)
# ─────────────────────────────────────────────────────────────────────
AGREE_SYSTEM = """You compare two independent research results.

For one question about ordinary building convention, two researchers
searched separately and each reported what is typical. Decide whether
they are saying THE SAME THING about the fact being asked.

Agreement is about the fact, not the wording. Two answers agree if
someone drawing the building would draw the same thing from either one.
They disagree if the drawing would differ — a different termination, a
different count, a different arrangement. If one reports a range and the
other a value inside that range, that is agreement, and the agreed fact
is the range. If either researcher found nothing, there is no agreement.

When they agree, write the agreed fact as a single plain sentence stating
what is typical for this kind of structure. Do not add anything neither
researcher reported. Do not mention the researchers, the search, or this
comparison — the sentence will be read on its own."""


def build_agree_schema() -> Dict[str, Any]:
    return {
        "type": "object",
        "properties": {
            "agree": {
                "type": "boolean",
                "description": (
                    "true only if both reported the same fact and both "
                    "found something."),
            },
            "agreed_fact_en": {
                "type": "string",
                "description": (
                    "The agreed typical fact as one plain sentence; empty "
                    "when agree is false."),
            },
            "reason_ko": {
                "type": "string",
                "description": "한국어 한 문장 판정 사유(갤러리 표시용).",
            },
        },
        "required": ["agree", "agreed_fact_en", "reason_ko"],
    }


def combine_typology_answers(
    *,
    question: Dict[str, Any],
    answer_a: Optional[Dict[str, Any]],
    answer_b: Optional[Dict[str, Any]],
    project_config: Optional[Dict[str, Any]] = None,
    step_tag: str = "typology_agreement",
) -> Dict[str, Any]:
    """두 답의 일치를 판정한다. 한쪽이라도 미발견이면 LLM 없이 폐기."""
    a_ok = bool(answer_a and answer_a.get("found")
                and str(answer_a.get("answer_en") or "").strip())
    b_ok = bool(answer_b and answer_b.get("found")
                and str(answer_b.get("answer_en") or "").strip())
    base = {
        "target_native": question.get("target_native"),
        "question_native": question.get("question_native"),
        "answer_a": (answer_a or {}).get("answer_en", ""),
        "answer_b": (answer_b or {}).get("answer_en", ""),
        "sources": sorted({
            *((answer_a or {}).get("sources") or []),
            *((answer_b or {}).get("sources") or []),
        }),
    }
    # ── 양쪽 미발견 = 폐기 ──────────────────────────────────────
    if not a_ok and not b_ok:
        return {**base, "adopted": False, "agree": False, "strength": "none",
                "reason_ko": "양쪽 모두 근거 미발견 — 폐기",
                "agreed_fact_en": ""}

    # ── 한쪽만 발견 = 단독 근거로 채택하되 '약함' 등급 ──────────
    #
    # 초판은 한쪽이라도 미발견이면 버렸다. 그런데 실측(2026-07-31
    # bg_multifamily_residence)에서 **가장 필요한 항목이 바로 그렇게
    # 버려졌다** — 계단 도착점에 대해 한 조사는 "지역 관례가 하나로
    # 고정되지 않는다"고 정직하게 답했고, 다른 조사는 구체적인 통상을
    # 찾아냈다. 둘은 모순이 아니다. "못 찾았다"는 "반대다"가 아니다.
    #
    # 그래서 단독 근거는 버리지 않고 **약한 등급으로 채택**한다. 대신
    # 저작에는 단정이 아니라 경향으로 전달한다(build_typology_facts_block
    # 이 등급별로 어조를 바꾼다). 모순(양쪽 발견·불일치)만 폐기한다.
    if a_ok != b_ok:
        solo = answer_a if a_ok else answer_b
        who = "A" if a_ok else "B"
        return {**base, "adopted": True, "agree": False, "strength": "weak",
                "reason_ko": (f"한쪽({who})만 근거 확보 — 단독 근거로 "
                              f"약하게 채택(단정 아님)"),
                "agreed_fact_en": str((solo or {}).get("answer_en") or
                                      "").strip()}

    if answer_a is None or answer_b is None:  # 방어 — 위 분기로 도달 불가
        return {**base, "adopted": False, "agree": False, "strength": "none",
                "reason_ko": "답 결손 — 폐기", "agreed_fact_en": ""}

    from app.modules.llm.llm_client import call_structured

    user = (
        "QUESTION (original language):\n"
        + str(question.get("question_native") or "")
        + "\n\nRESEARCHER A:\n" + str(answer_a.get("answer_en") or "")
        + "\n\nRESEARCHER B:\n" + str(answer_b.get("answer_en") or "")
    )
    data = call_structured(
        step=step_tag, system_prompt=AGREE_SYSTEM, user_prompt=user,
        response_schema=build_agree_schema(),
        project_config=_model_cfg(step_tag, AGREE_MODEL, project_config),
        schema_name="typology_agree")
    agree = bool(data.get("agree"))
    fact = str(data.get("agreed_fact_en") or "").strip()
    # 일치라 해놓고 사실을 못 쓰면 채택하지 않는다(fail-closed).
    adopted = agree and bool(fact)
    return {**base, "adopted": adopted, "agree": agree,
            "strength": "strong" if adopted else "none",
            "agreed_fact_en": fact if adopted else "",
            "reason_ko": str(data.get("reason_ko") or "").strip()}


# ─────────────────────────────────────────────────────────────────────
# 4. 저작 주입 블록 — 권위 서열을 헤더에 박는다
# ─────────────────────────────────────────────────────────────────────
# ★2026-08-03 개정 — 초판 헤더는 "둘 다 침묵할 때만 쓰라 · 둘보다 아래다 ·
# 베끼지 마라"로 세 겹 억제였다. 그 결과 실측에서 조사는 정확한 사실을
# 가져왔는데(그 종류의 장소가 실제로 무엇을 어떻게 내거는지) 저작이 한
# 줄도 쓰지 않고 자기 통념으로 그렸다 — 사용자 육안 반려 4건.
# 서열은 그대로 두되(시나리오·spec 이 정하면 그것이 이긴다), **침묵한
# 자리에서는 이 사실이 확정한다**로 뒤집는다.
FACTS_BLOCK_HEADER = (
    "RESEARCHED FACTS — what a place of this KIND actually looks like, "
    "looked up from real examples (photographs included) because neither "
    "the screenplay nor the STRUCTURE SPEC says anything about it:\n"
    "Where the screenplay or the STRUCTURE SPEC settles a matter, that "
    "wins and you ignore the line below. EVERYWHERE ELSE THESE FACTS "
    "SETTLE IT — they are here to replace your own impression of the "
    "type, which is what would otherwise fill the gap. Write what they "
    "describe into the picture as concrete visual fact, in the same "
    "plain physical sentences as everything else. They are about the "
    "type and not about this particular place, so never present them as "
    "something the screenplay stated. Never state the absence of "
    "something merely because it is not listed here."
)


_STRENGTH_NOTE = (
    "\nEach line is marked [corroborated] when both researchers reported "
    "the same thing, or [single source] when only one found a basis. Treat "
    "a [single source] line as a tendency worth following, not as a fact to "
    "assert — never write it into the brief as something the place "
    "certainly has, and never let it force a count or a negation."
)


_OBSERVED_NAME_NOTE = (
    "\nIf a line mentions a real name it saw on an example, that is an "
    "observation about how such places are named, never a name to reuse. "
    "This place's own name comes from what the screenplay supplies."
)


def build_typology_facts_block(
    adopted: Sequence[Dict[str, Any]]) -> str:
    """채택된 사실을 저작 입력 블록으로 만든다. 없으면 빈 문자열.

    ★단독 근거는 버리지 않고 등급을 붙여 넘긴다 — 초판이 한쪽 미발견을
    폐기하는 바람에 가장 필요한 항목이 사라졌다(2026-07-31 실측).

    라벨은 **조사한 대상 그 자체**(원어)다. v2 의 축 이름은 그 축들을
    없애면서 함께 사라졌다.
    """
    strong, weak = [], []
    for item in adopted:
        if not item.get("adopted"):
            continue
        fact = str(item.get("agreed_fact_en") or "").strip()
        if not fact:
            continue
        label = str(item.get("target_native") or "").strip()
        head = f"- ({label}) " if label else "- "
        if item.get("strength") == "weak":
            weak.append(f"{head}[single source] {fact}")
        else:
            strong.append(f"{head}[corroborated] {fact}")
    lines = strong + weak
    if not lines:
        return ""
    return (FACTS_BLOCK_HEADER + _STRENGTH_NOTE + _OBSERVED_NAME_NOTE
            + "\n" + "\n".join(lines))


def summarize(record: Dict[str, Any]) -> str:
    """로그 한 줄 요약."""
    items = record.get("items") or []
    ad = sum(1 for i in items if i.get("adopted"))
    return (f"문항 {len(items)} · 채택 {ad} · 폐기 {len(items) - ad}")
