"""#79 전체 분석 D — 조립된 roll 프롬프트 **한 줄의 출처**를 가른다.

## 무엇을 재나

A(팩 전수 #76)·B(코드 조립 #77)·C(샷당 분량 #78)는 각각 **다른 자리**를 쟀다.
D 는 그 셋을 한 축에 놓는다 — `records.json` 의 `roll_prompts` 를 한 줄씩 집어
**어디서 온 글자인가**를 묻는다.

★**「실발송」이라고 부르지 않는다** (Codex #38). `roll_prompts` 는 **조립본**이다.
 백엔드에 따라 보내기 직전에 더 손질된다 —
 grok 은 `grok_image_client.py:148` 이 상한(7,900바이트)을 넘는 롤에서
 일반 규칙 절을 덜어낸다. 실제로 나간 글은 **Opik** 에 남는다(코드 주석도
 「records.json 은 조립 명목판」이라고 적어 두었다). 지금 기본값 `nb2`
 (`gemini_image_client.py`)에는 그런 손질이 없다.

★**백엔드가 조립 자체도 가른다** — `still_image_backend == "grok2"` 분기가
 `still_recipe_service.py:437·1326·3526` 과 컴팩트 절 선택자
 (`still_recipe.py:490`)에 있다. 그래서 표본이 어느 백엔드 시절인지 밝혀
 적어야 한다. 두 시절을 각각 재 보면 비율은 거의 같고(저작 81.3% vs 80.2%,
 코드 3.2% vs 3.0%) **개별 절 길이만** 다르다(`no_text.md` 208자 ↔ 742자).

- `pack`     — `prompts/_base/**/<stem>.md` 에 그 문장이 있다 (버전 관리되는 저작물)
- `code`     — `backend/app/**/*.py` 의 문자열/f-string 에 있다 (하드 프롬프트)
- `both`     — **전문이 그대로 양쪽에 있다.** ★이 갈래가 재는 것은 그것뿐이다 —
               값이 박히는 **템플릿 수준 중복은 안 잰다**(템플릿은 코드 색인을
               먼저 보고 바로 돌아간다). 그러니 `both=0` 을 「같은 계약 두 벌
               0」으로 **일반화하면 안 된다**(Codex #38 지적, 수용).
- `runtime`  — 어느 쪽에도 없다 = 그 주행의 데이터(장소·인물·샷 서술 등)

## 내 도구가 세 번 틀렸다 (수치를 물린 기록)

| 판 | 낸 수치 | 무엇이 틀렸나 |
|---|---|---|
| 1 | 코드 **2.0%** | f-string 은 AST 에서 `JoinedStr` 로 쪼개져 `"- FRAMING SCALE: "` 가 **17자**가 되는데 25자 미만을 버렸다 → 150회 나가는 **코드 문장을 데이터로** 셌다 |
| 2 | 일부 누락 | `"- FRAME LAYOUT: " + …` 는 f-string 이 아니라 **16자 상수 이어붙이기**(`still_recipe.py:1643`) — 또 문턱에 걸렸다 |
| 3 | 저작 **73.9%** / 데이터 26.1% | 템플릿을 **앞머리 40자만** 저작으로 셌다. `people_clause.md` 처럼 `{char_names}` **뒤에** 긴 고정 계약이 이어지는 줄 402건에서 그 뒤 문장 전체가 데이터로 떨어졌다 (Codex #38 BLOCK) |
| 4 | 저작 **81.3%** / 데이터 18.7% | 값 자리로 쪼갠 **조각을 순서대로** 맞춰 저작 글자를 센다 |

## 대조 방법

1. 줄 정규화: 양끝 공백 제거 + 내부 공백 1칸으로 접기. 20자 미만은 버린다.
2. 전문 일치 → 그 자리에서 판정(팩·코드 둘 다면 `both`).
3. 안 맞으면 **템플릿 조각 맞추기**. 팩의 `{char_names}` 와 코드의 f-string
   자리를 표식으로 바꿔 두고, 앞 조각은 줄 머리에, 나머지는 순서대로 찾는다.
   **저작 글자 = 맞은 조각들의 합**, 나머지가 그 주행의 값이다.
4. 후보가 여럿이면 **저작 글자가 가장 많이 맞는** 템플릿을 고른다. 앞머리
   길이로 이기게 두면 `camera_frame_clause` 와 `…_no_dir` 처럼 앞부분이 같은
   두 스템에 기여가 반씩 갈린다(실제로 그랬다 — 둘 다 422/430 이 나왔다).
5. 글자 수로 잰다. 줄 수로 재면 긴 문단 한 줄이 짧은 줄 열 개와 같아진다.
6. 팩 기여는 **스템 단위로 합친다** — 같은 내용이 여러 판본에 복사돼 있어서
   파일 단위로 세면 한 스템이 상위를 다섯 줄 차지한다.

## 남은 한계 (크기까지 적는다)

앞머리가 `MIN_PREFIX`(12자)보다 짧은 라벨은 색인에 못 넣는다 — `- CAMERA: `
가 10자다. 그 줄들의 라벨은 「데이터」로 세어진다. 상한은 `runtime` 줄 수 ×
12자이고 215샷 표본에서 486줄 × 12 = **5,832자, 전체의 0.16%** 다.

## 양성 확인이 도구 안에 있다

분류가 틀리면 **수치를 내지 않는다**(`return 1`). 표본은 **실제 파일에서
읽어 온다** — 1판 자가진단에서 내가 **지어낸 기대 문장 둘**이 틀렸다.
값 뒤에 고정문이 이어지는 팩 템플릿도 표본에 들어간다(3판이 틀린 자리).
"""
from __future__ import annotations

import ast
import json
import pathlib
import re
import sys
from collections import Counter, defaultdict

ROOT = pathlib.Path(__file__).resolve().parents[3]
PROMPTS = ROOT / "prompts" / "_base"
APP = ROOT / "backend" / "app"

MIN_LINE = 20          # 이보다 짧은 줄은 판정에서 뺀다
MIN_PREFIX = 12        # 템플릿 앞머리 최소 길이
MARK = "\x00"          # f-string 의 값 자리
_WS = re.compile(r"\s+")


def norm(s: str) -> str:
    return _WS.sub(" ", s).strip()


def head_of(tpl: str) -> str:
    """템플릿의 **첫 값 앞** 글자 = 앞머리."""
    return tpl.split(MARK, 1)[0].strip()


# ---------------------------------------------------------------- 팩 말뭉치

_PLACEHOLDER = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")


def as_template(s: str) -> str:
    """팩의 `{char_names}` 같은 값 자리를 표식으로 바꾼다.

    ★이걸 안 하면 렌더된 줄이 전문 일치에 실패하고, 값 **뒤**에 이어지는
     고정 계약까지 「그 주행 데이터」로 세어진다(Codex #38 BLOCK).
    """
    return _PLACEHOLDER.sub(MARK, s)


def load_pack() -> tuple[set[str], dict[str, list[str]]]:
    """모든 팩 .md — 값 자리 없는 전문 줄 집합 + 스템별 템플릿 목록."""
    full: set[str] = set()
    by_stem: dict[str, list[str]] = defaultdict(list)
    for f in sorted(PROMPTS.rglob("*.md")):
        stem = f"{f.parent.parent.name}/{f.name}"   # <module>/<stem>.md
        for raw in f.read_text(encoding="utf-8", errors="replace").splitlines():
            ln = as_template(norm(raw))
            if MARK in ln:
                if len(head_of(ln)) >= MIN_PREFIX:
                    by_stem[stem].append(ln)
                continue
            if len(ln) >= MIN_PREFIX:
                by_stem[stem].append(ln)
            if len(ln) >= MIN_LINE:
                full.add(ln)
    return full, by_stem


# --------------------------------------------------------------- 코드 말뭉치

def _joined_template(node: ast.JoinedStr) -> str:
    out = []
    for part in node.values:
        if isinstance(part, ast.Constant) and isinstance(part.value, str):
            out.append(part.value)
        else:
            out.append(MARK)
    return "".join(out)


def load_code() -> tuple[set[str], dict[str, list[str]]]:
    """`backend/app` 의 문자열·f-string. 독스트링은 뺀다(우연 일치 방지)."""
    full: set[str] = set()
    by_file: dict[str, list[str]] = defaultdict(list)
    for f in sorted(APP.rglob("*.py")):
        try:
            tree = ast.parse(f.read_text(encoding="utf-8", errors="replace"))
        except SyntaxError:
            continue
        docs = set()
        for node in ast.walk(tree):
            body = getattr(node, "body", None)
            if (isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef,
                                  ast.ClassDef)) and body
                    and isinstance(body[0], ast.Expr)
                    and isinstance(body[0].value, ast.Constant)
                    and isinstance(body[0].value.value, str)):
                docs.add(id(body[0].value))
        rel = str(f.relative_to(ROOT / "backend"))
        for node in ast.walk(tree):
            if isinstance(node, ast.Constant) and isinstance(node.value, str):
                if id(node) in docs:
                    continue
                tpl = as_template(node.value)
            elif isinstance(node, ast.JoinedStr):
                tpl = as_template(_joined_template(node))
            else:
                continue
            for raw in tpl.splitlines():
                ln = norm(raw)
                if MARK in ln:
                    if len(head_of(ln)) >= MIN_PREFIX:
                        by_file[rel].append(ln)
                    continue
                # ★상수 이어붙이기도 라벨이 된다 — `"- FRAME LAYOUT: " + …`
                #  (still_recipe.py:1643). 16자라 MIN_LINE 에 걸려 첫 판에서
                #  통째로 빠졌고, 그 줄들이 「데이터」로 세어졌다.
                if len(ln) >= MIN_PREFIX:
                    by_file[rel].append(ln)
                if len(ln) >= MIN_LINE:
                    full.add(ln)
    return full, by_file


# ------------------------------------------------------------------ 색인

def match_template(tpl: str, line: str) -> int | None:
    """템플릿이 이 줄에 맞나 — 맞으면 **저작 글자 수**를 돌린다.

    ★Codex 리뷰 #38 BLOCK. 앞판은 **앞머리 40자만** 저작으로 셌다.
     `people_clause.md` 처럼 `{char_names}` **뒤에** 긴 고정 계약이
     이어지는 줄이 402건 있었는데, 그 뒤 문장 전체가 「그 주행 데이터」로
     떨어졌다. 저작 73.9% / 데이터 26.1% 이 통째로 오독이었다.

    고친 법: 값 자리로 쪼갠 **조각을 순서대로** 맞춘다. 앞 조각은 줄
    머리에 붙어야 하고, 나머지는 앞에서부터 차례로 나와야 한다. 저작
    글자 = 맞은 조각들의 길이 합. 나머지가 그 주행의 값이다.
    """
    segs = tpl.split(MARK)
    pos = 0
    authored = 0
    if segs[0]:
        if not line.startswith(segs[0]):
            return None
        pos = len(segs[0])
        authored = pos
    for s in segs[1:]:
        if not s:
            continue
        i = line.find(s, pos)
        if i < 0:
            return None
        pos = i + len(s)
        authored += len(s)
    return authored


class Index:
    """앞머리 → (출처, 템플릿). 앞머리 길이가 제각각이라 전부 훑는다."""

    def __init__(self) -> None:
        self.by_len: dict[int, dict[str, list[tuple[str, str]]]] = defaultdict(dict)
        self.lens: list[int] = []

    def add(self, tpl: str, owner: str) -> None:
        head = head_of(tpl) if MARK in tpl else tpl
        if len(head) < MIN_PREFIX:
            return
        self.by_len[len(head)].setdefault(head, []).append((owner, tpl))

    def freeze(self) -> None:
        self.lens = sorted(self.by_len, reverse=True)

    def lookup(self, line: str) -> set[str] | None:
        hit = self.lookup_with_len(line)
        return hit[0] if hit else None

    def lookup_with_len(self, line: str) -> tuple[set[str], int] | None:
        """**저작 글자가 가장 많이** 맞는 템플릿을 고른다.

        앞머리가 짧아도 뒤 고정문이 길면 그쪽이 진짜 출처다 — 앞머리
        길이로 이기게 두면 앞판과 같은 과소 계산이 남는다.
        """
        best: tuple[int, set[str]] | None = None
        for n in self.lens:
            if n > len(line):
                continue
            for owner, tpl in self.by_len[n].get(line[:n], ()):
                got = match_template(tpl, line)
                if got is None:
                    continue
                if best is None or got > best[0]:
                    best = (got, {owner})
                elif got == best[0]:
                    best[1].add(owner)
        return (best[1], best[0]) if best else None


def build_index(by_owner: dict[str, list[str]]) -> Index:
    idx = Index()
    for owner, lines in by_owner.items():
        for ln in lines:
            idx.add(ln, owner)
    idx.freeze()
    return idx


# ------------------------------------------------------------------ 분류

class Classifier:
    """줄을 가르되 **글자를 경계에서 나눈다**.

    `- FRAMING SCALE: wide shot` 은 라벨 17자가 코드고 값 9자는 그 주행의
    데이터다. 통째로 한쪽에 몰아 세면 코드 몫이든 데이터 몫이든 부풀려진다.
    그래서 `classify` 는 (갈래, 출처, **저작 글자 수**)를 돌린다.
    """

    def __init__(self) -> None:
        self.pack_full, self.pack_by_stem = load_pack()
        self.code_full, code_by_file = load_code()
        self.pack_idx = build_index(self.pack_by_stem)
        self.code_idx = build_index(code_by_file)
        self.n_pack_files = sum(1 for _ in PROMPTS.rglob("*.md"))
        self.n_code_files = len(code_by_file)

    def classify(self, ln: str) -> tuple[str, set[str], int]:
        """★`both` 는 **전문 일치 양쪽**에서만 난다 (Codex #38 지적).

        아래 템플릿 갈래는 `code_idx` 를 먼저 보고 **바로 돌아간다** — 팩
        템플릿에도 동시에 맞는 줄을 `both` 로 못 센다. 그러니 `both=0` 을
        「같은 계약 두 벌 0」으로 **일반화하면 안 된다.** 이 도구가 재는 것은
        「전문이 그대로 양쪽에 있는 줄」 하나뿐이다.
        """
        in_pack = ln in self.pack_full
        in_code = ln in self.code_full
        if in_pack and in_code:
            return "both", set(), len(ln)
        if in_pack:
            return "pack", self.pack_idx.lookup(ln) or set(), len(ln)
        if in_code:
            return "code", self.code_idx.lookup(ln) or set(), len(ln)
        # 코드 템플릿을 먼저 본다 — f-string 은 값이 박혀 전문 일치가 안 된다
        for kind, idx in (("code_tpl", self.code_idx), ("pack_tpl", self.pack_idx)):
            hit = idx.lookup_with_len(ln)
            if hit:
                owners, head_len = hit
                return kind, owners, head_len
        return "runtime", set(), 0


# ------------------------------------------------------------------ 양성 확인

def selftest(c: Classifier) -> int:
    """★기대값을 **지어내지 않는다.**

    첫 판 자가진단에서 내가 손으로 적은 기대 문장 둘이 틀렸다(하나는 실재하지
    않는 문안, 하나는 라벨이 코드라는 걸 내가 몰랐다). 그래서 이 판은 표본을
    **실제 파일에서 읽어 온다** — 팩 줄은 팩 파일에서, 코드 줄은 알려진
    file:line 에서. 지어내는 것은 「어디에도 없는 문장」 하나뿐이다.
    """
    cases: list[tuple[str, str, str]] = []

    # ① 코드 f-string — still_recipe.py:1621 `- FRAMING SCALE: {scale}`
    cases.append(("- FRAMING SCALE: wide shot", "code_tpl",
                  "still_recipe.py f-string. 첫 판은 이걸 runtime 으로 셌다"))

    # ②' 코드 상수 이어붙이기 — still_recipe.py:1643 `"- FRAME LAYOUT: " + …`
    cases.append(("- FRAME LAYOUT: 김광석 in the middle-center of the frame.",
                  "code_tpl", "16자 상수라 둘째 판에서도 빠졌던 자리"))

    # ② 팩 전문 한 줄 — 파일에서 **읽어서** 넣는다
    pack_line = ""
    for stem, lns in c.pack_by_stem.items():
        if stem.startswith("still_recipe/realize_still"):
            long = [x for x in lns if len(x) >= 60]
            if long:
                pack_line = long[0]
                break
    if pack_line:
        cases.append((pack_line, "pack", "realize_still.md 에서 읽어 온 실제 줄"))

    # ③ 어느 말뭉치에도 없고 라벨도 없는 줄
    cases.append(("보라색 코끼리가 낡은 자전거를 타고 지나간다 " + "라" * 20, "runtime",
                  "실행 데이터 — 저작 글자 0 이어야 한다"))

    bad = 0
    print("=== 양성 확인 (분류가 맞는지 먼저 태운다) ===")
    for line, want, why in cases:
        got, owners, authored = c.classify(norm(line))
        ok = got == want and (authored == 0) == (want == "runtime")
        bad += 0 if ok else 1
        print(f"  {'OK ' if ok else '틀림'} {got:9} 저작 {authored:3}/{len(norm(line)):3}자"
              f"  (기대 {want}) {why}")
        if owners:
            print(f"       출처: {', '.join(sorted(owners))[:70]}")

    # ④ ★값 **뒤에** 고정 계약이 이어지는 팩 템플릿 — Codex #38 BLOCK 자리.
    #   팩 파일에서 실제 템플릿을 읽어 값을 넣어 렌더하고, **넣은 값만**
    #   데이터로 세는지 잰다. 앞판은 앞머리 40자만 저작으로 세서 뒤 고정문
    #   전체를 데이터로 흘렸다.
    VALUE = "김광석"
    probe = None
    for stem, lns in c.pack_by_stem.items():
        for tpl in lns:
            if MARK in tpl and len(tpl.split(MARK, 1)[1]) >= 80:
                probe = (stem, tpl)
                break
        if probe:
            break
    if probe:
        stem, tpl = probe
        rendered = norm(tpl.replace(MARK, VALUE))
        want_authored = len(rendered) - VALUE.count(VALUE) * len(VALUE) * tpl.count(MARK)
        got, owners, authored = c.classify(rendered)
        ok = got in ("pack_tpl", "pack") and authored >= want_authored
        bad += 0 if ok else 1
        print(f"  {'OK ' if ok else '틀림'} {got:9} 저작 {authored:3}/{len(rendered):3}자"
              f"  (기대 pack_tpl · 저작 {want_authored} 이상) "
              f"값 뒤 고정문이 있는 팩 템플릿 [{stem}]")
        if not ok:
            print(f"       템플릿: {tpl[:100]!r}")
    else:
        bad += 1
        print("  틀림 값 자리가 있는 팩 템플릿을 하나도 못 찾았다 — 표본이 없다")

    print(f"  → {len(cases) + 1 - bad}/{len(cases) + 1} 맞음\n")
    return bad


# ------------------------------------------------------------------ 본체

def load_sent(records: pathlib.Path) -> list[tuple[str, str, str]]:
    data = json.loads(records.read_text(encoding="utf-8"))
    out = []
    for key, rec in data.items():
        rolls = rec.get("roll_prompts")
        if isinstance(rolls, dict):
            for roll, text in rolls.items():
                if isinstance(text, str) and text.strip():
                    out.append((key, roll, text))
    return out


def main() -> int:
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    c = Classifier()
    print(f"팩 .md {c.n_pack_files}개 · 코드 문자열 보유 .py {c.n_code_files}개\n")
    bad = selftest(c)
    if "--selftest" in sys.argv:
        return 1 if bad else 0
    if bad:
        print("★양성 확인이 틀렸다 — 수치를 내지 않는다")
        return 1
    if not args or not pathlib.Path(args[0]).is_file():
        print("usage: origin_split.py <records.json> [--selftest]")
        return 2

    sent = load_sent(pathlib.Path(args[0]))
    print(f"조립된 roll 프롬프트 {len(sent)}건 (샷 {len({k for k, _, _ in sent})}개)\n")

    chars: Counter = Counter()
    lines: Counter = Counter()
    pack_chars: Counter = Counter()
    code_chars: Counter = Counter()
    runtime_rep: Counter = Counter()
    both_rep: Counter = Counter()

    # 글자는 **경계에서** 나눈다: 템플릿 라벨은 저작, 뒤에 박힌 값은 데이터
    src_chars: Counter = Counter()   # pack / code / both / data

    stem_prompts: Counter = Counter()   # 스템이 **몇 건의 프롬프트에** 붙었나

    for _key, _roll, text in sent:
        seen_here: set[str] = set()
        for raw in text.splitlines():
            ln = norm(raw)
            if len(ln) < MIN_LINE:
                continue
            kind, owners, authored = c.classify(ln)
            chars[kind] += len(ln)
            lines[kind] += 1
            fam = {"pack": "pack", "pack_tpl": "pack", "code": "code",
                   "code_tpl": "code", "both": "both", "runtime": "data"}[kind]
            src_chars[fam] += authored
            src_chars["data"] += len(ln) - authored
            if kind in ("pack", "pack_tpl"):
                for o in owners:
                    pack_chars[o] += authored / max(1, len(owners))
                    seen_here.add(o)
            elif kind in ("code", "code_tpl"):
                for o in owners:
                    code_chars[o] += authored / max(1, len(owners))
            elif kind == "runtime":
                runtime_rep[ln[:100]] += 1
            elif kind == "both":
                both_rep[ln[:90]] += 1
        for o in seen_here:
            stem_prompts[o] += 1

    total = sum(chars.values())
    print("=== 줄 갈래 (20자 이상 줄, 줄 전체 글자 기준) ===")
    for kind in ("pack", "pack_tpl", "code", "code_tpl", "both", "runtime"):
        n = chars[kind]
        print(f"  {kind:9} {n:9,}자 {n/total:6.1%}   ({lines[kind]:,}줄)")
    print(f"  {'합':9} {total:9,}자          ({sum(lines.values()):,}줄)")

    st = sum(src_chars.values())
    print("\n=== ★글자의 출처 (템플릿은 라벨/값 경계에서 가름) ===")
    for fam in ("pack", "code", "both", "data"):
        n = src_chars[fam]
        print(f"  {fam:6} {n:9,}자 {n/st:6.1%}")
    authored = st - src_chars["data"]
    print(f"  저작 합 {authored:,}자 ({authored/st:.1%})")
    print(f"\n저작 {authored:,}자 중 **코드 하드 프롬프트 {src_chars['code']:,}자 "
          f"= {src_chars['code']/authored:.1%}**")
    print(f"전문이 양쪽에 다 있는 줄(both) {src_chars['both']:,}자 = 저작의 "
          f"{src_chars['both']/authored:.1%}")
    print("  ★이 수를 「같은 계약 두 벌 0」으로 읽지 마라 — 이 도구는 **전문**"
          "이 그대로\n   양쪽에 있는 줄만 센다. 값이 박히는 템플릿 수준 중복은"
          " 안 잰다.")

    print("\n=== 조립에 실린 팩 스템 상위 18 (판본은 스템으로 합침) ===")
    print("   총 기여      붙은 프롬프트    1건당      스템")
    for stem, n in pack_chars.most_common(18):
        hit = stem_prompts[stem]
        print(f"  {n:10,.0f}자   {hit:4}/{len(sent):<4} {hit/len(sent):5.0%}"
              f"  {n/max(1,hit):7,.0f}자  {stem}")
    fixed = sum(n for s, n in pack_chars.items() if stem_prompts[s] >= len(sent) * 0.95)
    var = sum(pack_chars.values()) - fixed
    print(f"\n  ★95% 이상 모든 샷에 붙는 스템 = {fixed:,.0f}자 "
          f"({fixed/max(1,sum(pack_chars.values())):.0%}) · 나머지 {var:,.0f}자")
    print("\n=== 조립에 실린 코드 파일 상위 10 ===")
    for rel, n in code_chars.most_common(10):
        print(f"  {n:11,.0f}자  {rel}")
    if both_rep:
        print("\n=== 두 벌인 문장 상위 10 ===")
        for ln, n in both_rep.most_common(10):
            print(f"  {n:5}회  {ln}")
    print("\n=== runtime 되풀이 상위 10 (아직 오분류가 남았나 점검) ===")
    for ln, n in runtime_rep.most_common(10):
        print(f"  {n:5}회  {ln}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
