init: llm-wiki-haness 하네스 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:21:35 +09:00
parent 42bf3db4fd
commit 6c53ded9cb
2436 changed files with 194486 additions and 1 deletions
@@ -0,0 +1,217 @@
"""extract.py — Tiered Extraction 브로커: bulk 발췌를 외부 구독 CLI 에 위임 + 결정론 검증.
설계 (rules/extraction-tiering.md):
- 드라이버가 파일을 *직접 읽어* 줄번호 매긴 본문을 프롬프트에 내장 — 외부 엔진은
repo 접근이 불필요하다 (쓰기 위험 0 / 입력 결정론 / codex·agy·mock 동일 동작).
- 외부 발췌는 신뢰하지 않는다: 모든 verbatim 인용을 원문에 re-match 해 검증/정정/폐기
(quote-verifier 내장). 통과분만 digest 로 방출 — 상위 티어(opus)는 digest 만 소비.
- engine funnel: 어떤 엔진이 몇 파일을 처리/실패했는지 항상 기록 (no silent engine swap).
CLI:
python3 -m deep_research.extract --backend codex|antigravity|auto \
--question "<연구 질문>" --files f1.md f2.md ... [--out digest.md] [--max-quotes 8]
exit: 0 (digest 생성, 부분 실패는 funnel 에 기록) / 1 (전 파일 실패) / 2 (인자 오류)
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
from pydantic import BaseModel, Field
MAX_FILE_CHARS = 60_000 # 파일당 프롬프트 상한 (초과분은 절단 + funnel 기록)
CONCURRENCY = 3
class Quote(BaseModel):
line: int = Field(description="1-based line number in the file")
quote: str = Field(description="byte-for-byte verbatim quote from that line (no paraphrase)")
class FileExtraction(BaseModel):
relevant: bool = Field(description="whether this file contains material relevant to the question")
summary: str = Field(description="2-4 sentence summary of what this file says about the question")
facts: list[str] = Field(default_factory=list,
description="key facts relevant to the question, each one sentence")
quotes: list[Quote] = Field(default_factory=list,
description="supporting verbatim quotes with line numbers")
PROMPT = """You are a read-only extraction worker. Below is ONE file with line numbers, \
and a research question. Extract ONLY what the file actually says — no inference, no outside \
knowledge, no paraphrase inside quotes.
Research question: {question}
Rules:
- quotes must be byte-for-byte substrings of a single line (copy exactly, without the line-number prefix).
- give the 1-based line number for each quote.
- at most {max_quotes} quotes; prefer the most decision-relevant lines.
- if the file is irrelevant to the question, set relevant=false with a one-line summary.
- respond ONLY with the JSON object matching the schema.
FILE: {path}
----------------------------------------
{numbered}
----------------------------------------"""
def numbered_content(text: str, limit: int = MAX_FILE_CHARS) -> tuple[str, bool]:
lines = text.splitlines()
out, total, truncated = [], 0, False
for i, line in enumerate(lines, start=1):
s = f"{i}\t{line}"
total += len(s) + 1
if total > limit:
truncated = True
break
out.append(s)
return "\n".join(out), truncated
def verify_quotes(path: Path, ext: FileExtraction) -> dict:
"""결정론 quote-verifier: verbatim 재대조. 반환: 검증 통계 + 정정된 quotes.
판정: 인용이 명시 라인에 있으면 PASS / 다른 라인에 있으면 CORRECTED(라인 정정) /
어디에도 없으면 DROPPED (위조·의역 — 폐기)."""
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
return {"pass": 0, "corrected": 0, "dropped": len(ext.quotes), "kept": []}
n_pass = n_corr = n_drop = 0
kept: list[Quote] = []
for q in ext.quotes:
quote = q.quote.strip()
if not quote:
n_drop += 1
continue
if 1 <= q.line <= len(lines) and quote in lines[q.line - 1]:
n_pass += 1
kept.append(q)
continue
hit = next((i for i, l in enumerate(lines, start=1) if quote in l), None)
if hit is not None:
n_corr += 1
kept.append(Quote(line=hit, quote=quote))
else:
n_drop += 1
return {"pass": n_pass, "corrected": n_corr, "dropped": n_drop, "kept": kept}
def make_backend(name: str):
if name == "codex":
from deep_research.backends.codex import CodexBackend
return CodexBackend()
if name == "antigravity":
from deep_research.backends.antigravity import AntigravityBackend
return AntigravityBackend()
raise ValueError(name)
async def extract_file(backends: list[tuple[str, object]], question: str, path: Path,
max_quotes: int, sem: asyncio.Semaphore) -> dict:
"""파일 1개 발췌 — fallback 사다리 순서대로 시도. 반환 dict 는 digest 렌더 입력."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return {"path": str(path), "engine": None, "error": f"read 실패: {e}"}
numbered, truncated = numbered_content(text)
prompt = PROMPT.format(question=question, max_quotes=max_quotes,
path=path, numbered=numbered)
async with sem:
for engine_name, backend in backends:
result = await backend.run_agent(prompt, FileExtraction, label=f"extract:{path.name}")
if result is not None:
v = verify_quotes(path, result)
return {"path": str(path), "engine": engine_name, "truncated": truncated,
"relevant": result.relevant, "summary": result.summary,
"facts": result.facts, "verify": v}
return {"path": str(path), "engine": None, "error": "전 엔진 실패 (가용성/스키마)"}
def render_digest(question: str, results: list[dict]) -> str:
ok = [r for r in results if "error" not in r]
failed = [r for r in results if "error" in r]
engines: dict[str, int] = {}
for r in ok:
engines[r["engine"]] = engines.get(r["engine"], 0) + 1
out = [f"# Extraction Digest", f"**Question:** {question}",
f"**Files:** {len(results)} (성공 {len(ok)} / 실패 {len(failed)})",
f"**Engines:** " + (", ".join(f"{k}×{v}" for k, v in engines.items()) or "없음"), ""]
for r in ok:
v = r["verify"]
out.append(f"## {r['path']}" + (" (TRUNCATED)" if r.get("truncated") else ""))
if not r["relevant"]:
out.append(f"- 무관: {r['summary']}")
out.append("")
continue
out.append(f"- 요약: {r['summary']}")
for f in r["facts"]:
out.append(f"- {f}")
for q in v["kept"]:
out.append(f" > \"{q.quote}\"{r['path']}:{q.line}")
out.append(f"- 인용 검증: PASS {v['pass']} / 정정 {v['corrected']} / **폐기 {v['dropped']}**")
if r["facts"] and not v["kept"]:
# 검증 통과 인용이 0건이면 위 facts 는 근거 없는 주장 (계명 2 — 무검증 발췌 소비 금지)
out.append("- ⚠ 검증 인용 0건 — 위 facts 는 미검증 주장 (haiku/sonnet 재발췌 후보)")
out.append("")
if failed:
out.append("## 실패 (haiku/sonnet 재발췌 대상)")
for r in failed:
out.append(f"- {r['path']}: {r['error']}")
out.append("")
total_q = sum(r["verify"]["pass"] + r["verify"]["corrected"] + r["verify"]["dropped"] for r in ok)
kept_q = sum(len(r["verify"]["kept"]) for r in ok)
out.append("```wiki-stats")
out.append("agent: extract-broker")
out.append(f"found: {len(results)}")
out.append(f"processed: {len(ok)}")
out.append(f"dropped: {len(failed)}")
if failed:
out.append(f"dropped_reason: 엔진 실패 {len(failed)}건 (위 실패 목록 — 상위 티어 재발췌)")
out.append("```")
out.append(f"<!-- quotes: total {total_q}, verified-kept {kept_q} -->")
return "\n".join(out)
async def run(args) -> int:
order = {"codex": ["codex", "antigravity"],
"antigravity": ["antigravity", "codex"],
"auto": ["codex", "antigravity"]}[args.backend]
backends = []
for name in order:
try:
backends.append((name, make_backend(name)))
except Exception:
continue
if not backends:
print("error: 사용 가능한 backend 없음", file=sys.stderr)
return 1
sem = asyncio.Semaphore(CONCURRENCY)
results = await asyncio.gather(*[
extract_file(backends, args.question, Path(f), args.max_quotes, sem)
for f in args.files])
digest = render_digest(args.question, list(results))
if args.out:
Path(args.out).write_text(digest, encoding="utf-8")
print(f"digest → {args.out} ({len(digest)} chars)")
else:
print(digest)
return 0 if any("error" not in r for r in results) else 1
def main() -> None:
ap = argparse.ArgumentParser(description="Tiered extraction 브로커 (외부 구독 CLI + 결정론 검증)")
ap.add_argument("--backend", default="auto", choices=["codex", "antigravity", "auto"])
ap.add_argument("--question", required=True)
ap.add_argument("--files", nargs="+", required=True)
ap.add_argument("--out", help="digest 출력 파일 (생략 시 stdout)")
ap.add_argument("--max-quotes", type=int, default=8)
args = ap.parse_args()
sys.exit(asyncio.run(run(args)))
if __name__ == "__main__":
main()