# -*- coding: utf-8 -*-
r"""
qa_forge.py — QA뱅크 대량 증식 파이프라인 [CEO 2026-07-18]

목적: 문서(공정서·씨앗·옵시디언) 하나하나에서 '현장에서 나올 법한 질문+답' 쌍을
로컬 Ollama(비용 0)로 대량 생성해 QA뱅크(E:\VLA시스템\_QA뱅크)에 적재.

CEO 원칙 반영:
- 용접은 사슬의 한 고리일 뿐 — 절단→선가공→제관·취부→(선)용접→그라인딩→도장→후가공이
  꼬리에 꼬리를 무는 "공정 연결 질문"을 반드시 섞는다.
- 숫자·사실은 문서에 있는 것만. 지어내면 그 쌍은 버린다(문서 대조 검증).
- 중요/판단형 질문 일부는 페이블(claude CLI)이 답해 증류쌍(FINETUNE/distill_pairs)으로 저장.

실행: python qa_forge.py            (밤샘 — Ctrl+C 안전, 재개 가능)
      python qa_forge.py --status  (진행 현황만 출력)
"""
import os, re, json, sys, time, subprocess, urllib.request

ROOTS = [r'E:\VLA시스템', r'E:\지식정제시스템\05_KNOWLEDGE\seeds', r'E:\옵시디언']
QA_DIR = r'E:\VLA시스템\_QA뱅크'
DISTILL_DIR = r'E:\VLA시스템\FINETUNE\distill_pairs'
STATE = os.path.join(QA_DIR, '_forge_state.json')          # 처리한 문서 기록 (재개용)
OUT = os.path.join(QA_DIR, 'auto_forge.qa.json')            # 생성 결과 (서버가 자동 로드)
OLLAMA = 'http://localhost:11434/api/generate'
GEN_MODEL = 'deepseek-r1:14b'        # [2026-07-18] llama3.1:8b=오답 지어냄, qwen27b=메모리 초과 → 14b 확정(품질시험 합격, 117초/문서)
CLAUDE_EXE = os.path.expanduser(r'~\.local\bin\claude.exe')
FABLE_PER_RUN = 60                   # 밤새 페이블 증류 답변 상한 (비용 절제)
MIN_DOC = 300                        # 이보다 짧은 문서는 건너뜀

CHAIN_HINT = ('용접은 전체 공정 사슬(절단→선가공→제관·취부→선용접→본용접→그라인딩→도장→후가공)의 '
              '한 고리다. 질문 중 최소 2개는 앞뒤 공정 연결 질문으로 만들어라 '
              '(예: "이 부재 절단면 상태가 취부에 어떤 영향 주나", "용접 후 그라인딩 전 확인할 것은").')

PROMPT = ('너는 용접·제관 현장 20년 숙련자다. 아래 문서를 읽고, 현장 작업자가 실제로 물어볼 법한 '
          '질문과 답 6개를 만들어라. 반말/구어체/오타 섞인 질문도 포함(현장 말투 다양화). '
          + CHAIN_HINT + ' '
          '답은 반드시 문서에 있는 내용만 사용하고, 문서에 없는 수치를 지어내지 마라. '
          '출력은 JSON 배열만: [{"q":"질문","a":"답","type":"조회|판단|공정연결"}]\n\n[문서: %s]\n%s')


def docs():
    for root in ROOTS:
        for dp, _, fns in os.walk(root):
            if any(x in dp for x in ('EMBEDDINGS', 'MODELS', 'OUTPUT', 'FINETUNE', '.obsidian', '.trash')):
                continue
            for fn in fns:
                if fn.endswith('.md'):
                    yield os.path.join(dp, fn)


def priority(p):
    n = os.path.basename(p)
    if 'GOLD' in n: return 0
    if '공정서' in n: return 1
    if 'SILVER' in n or 'BRONZE' in n: return 2
    return 3


def ollama(prompt, timeout=420):
    # r1은 <think> 사고과정에 토큰을 쓰므로 넉넉히 (실측 117초/문서)
    body = json.dumps({'model': GEN_MODEL, 'prompt': prompt, 'stream': False,
                       'options': {'temperature': 0.7, 'num_predict': 2600}}).encode()
    r = urllib.request.urlopen(urllib.request.Request(OLLAMA, data=body,
                               headers={'Content-Type': 'application/json'}), timeout=timeout)
    return json.loads(r.read()).get('response', '')


def parse_pairs(txt):
    txt = re.sub(r'<think>.*?</think>', '', txt, flags=re.S)  # r1 사고과정 제거
    m = re.search(r'\[.*\]', txt, re.S)
    if not m: return []
    try: arr = json.loads(m.group(0))
    except Exception: return []
    out = []
    for it in arr if isinstance(arr, list) else []:
        q, a = str(it.get('q', '')).strip(), str(it.get('a', '')).strip()
        if len(q) >= 5 and len(a) >= 5:
            out.append({'q': q, 'a': a, 'type': str(it.get('type', ''))})
    return out


def _bg(s):
    s = re.sub(r'\s', '', s)
    return {s[i:i+2] for i in range(len(s) - 1)} if len(s) > 1 else set()


def verify_numbers(pairs, doc_txt):
    """[2026-07-18 강화] ①답 속 수치가 문서에 실존 ②답 내용이 문서와 실제로 겹침(바이그램 12%+)
    — 둘 중 하나라도 어기면 폐기. llama3.1이 그럴싸한 오답을 지어내던 사고 재발 방지."""
    flat = re.sub(r'[\s,]', '', doc_txt)
    db = _bg(doc_txt)
    keep = []
    for p in pairs:
        nums = re.findall(r'\d+(?:\.\d+)?', p['a'])
        if any(len(x) >= 2 and x not in flat for x in nums): continue
        ab = _bg(p['a'])
        if not ab or len(ab & db) / len(ab) < 0.12: continue   # 문서와 안 겹치는 답 = 지어낸 답
        if re.search(r'[一-鿿぀-ゟ゠-ヿ]', p['a']): continue  # 중국어·일본어 혼입 차단
        keep.append(p)
    return keep


def fable_answer(q, ctx):
    prompt = ('너는 도진웍스 용접공정 코파일럿 SHIN이다. 근거 안에서만, 판단형이면 현상→원인→결론 순으로 한국어로 답하라. '
              '불확실하면 "확인 필요".\n\n[근거]\n' + ctx[:5000] + '\n\n[질문] ' + q)
    r = subprocess.run([CLAUDE_EXE, '-p', '--model', 'claude-fable-5'], input=prompt.encode('utf-8'),
                       capture_output=True, timeout=180)
    out = r.stdout.decode('utf-8', errors='ignore').strip()
    if r.returncode != 0 or not out: raise RuntimeError('fable 실패')
    return out


def load_json(p, default):
    try: return json.load(open(p, encoding='utf-8'))
    except Exception: return default


def main():
    st = load_json(STATE, {'done': [], 'fable_used': {}})
    bank = load_json(OUT, {'src': 'auto_forge(공정사슬 자동증식)', 'qa': []})
    done = set(st['done'])
    month = time.strftime('%Y%m')
    fused = st['fable_used'].get(month, 0)
    targets = sorted([p for p in docs() if p not in done], key=priority)
    print('[forge] 대상 %d개 문서 (완료 %d, QA뱅크 현재 %d쌍)' % (len(targets), len(done), len(bank['qa'])))
    os.makedirs(DISTILL_DIR, exist_ok=True)
    for i, p in enumerate(targets):
        try:
            txt = open(p, encoding='utf-8', errors='ignore').read()
            if len(txt) < MIN_DOC:
                done.add(p); continue
            name = os.path.basename(p)
            pairs = parse_pairs(ollama(PROMPT % (name, txt[:5000])))
            pairs = verify_numbers(pairs, txt)
            for pr in pairs: pr['src'] = p
            bank['qa'].extend(pairs)
            # 판단형/공정연결 1개는 페이블 증류 (상한 내에서)
            hard = [x for x in pairs if x.get('type') in ('판단', '공정연결')]
            if hard and fused < FABLE_PER_RUN and os.path.exists(CLAUDE_EXE):
                try:
                    fa = fable_answer(hard[0]['q'], txt[:5000])
                    with open(os.path.join(DISTILL_DIR, 'distill_%s.jsonl' % month), 'a', encoding='utf-8') as f:
                        f.write(json.dumps({'ts': time.strftime('%Y-%m-%d %H:%M:%S'), 'model': 'claude:claude-fable-5',
                                            'q': hard[0]['q'], 'ctx': txt[:5000], 'a': fa, 'via': 'qa_forge'},
                                           ensure_ascii=False) + '\n')
                    fused += 1
                except Exception: pass
            done.add(p)
            if i % 10 == 0 or i == len(targets) - 1:
                st['done'] = sorted(done); st['fable_used'][month] = fused
                json.dump(st, open(STATE, 'w', encoding='utf-8'), ensure_ascii=False)
                json.dump(bank, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=0)
                print('[forge] %d/%d | QA %d쌍 | 증류 %d | %s' % (i + 1, len(targets), len(bank['qa']), fused, name[:40]))
        except KeyboardInterrupt:
            break
        except Exception as e:
            print('[forge] 건너뜀 %s: %s' % (p, str(e)[:80])); done.add(p)
    st['done'] = sorted(done); st['fable_used'][month] = fused
    json.dump(st, open(STATE, 'w', encoding='utf-8'), ensure_ascii=False)
    json.dump(bank, open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=0)
    print('[forge] 종료 — QA %d쌍, 증류 %d건' % (len(bank['qa']), fused))


if __name__ == '__main__':
    if '--status' in sys.argv:
        st = load_json(STATE, {'done': []}); bank = load_json(OUT, {'qa': []})
        print('처리 문서 %d | 생성 QA %d쌍' % (len(st['done']), len(bank['qa'])))
    else:
        main()
