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
+153
View File
@@ -0,0 +1,153 @@
import pytest
from pydantic import ValidationError
from deep_research.schemas import Scope, Angle, Search, SearchResult, Extract, Claim, Verdict, Report
from deep_research.core import norm_url, Deduper, rank_claims, tally, build_synth_blocks, build_stats
from deep_research.config import Config
def test_scope_requires_min_3_angles():
with pytest.raises(ValidationError):
Scope(question="q", summary="s", angles=[Angle(label="a", query="x")])
def test_scope_accepts_5_angles():
angles = [Angle(label=f"a{i}", query="x") for i in range(5)]
s = Scope(question="q", summary="s", angles=angles)
assert len(s.angles) == 5
def test_search_relevance_enum_enforced():
with pytest.raises(ValidationError):
SearchResult(url="http://a", title="t", relevance="bogus")
def test_json_schema_export_works():
schema = Scope.model_json_schema()
assert "angles" in schema["properties"]
assert schema["properties"]["angles"]["minItems"] == 3
assert schema["properties"]["angles"]["maxItems"] == 6
def test_norm_url_strips_www_and_trailing_slash():
assert norm_url("http://www.Example.com/Path/") == "example.com/path"
def test_norm_url_bare_host():
assert norm_url("https://example.com") == "example.com"
def test_norm_url_fallback_on_garbage():
assert norm_url("not a url") == "not a url"
def test_deduper_filters_exact_dupes():
d = Deduper(Config(MAX_FETCH=15))
r = [{"url": "http://a.com/x", "title": "t", "relevance": "high"}]
assert len(d.filter_novel("angle1", r)) == 1
# same normalized url from another angle -> dup
r2 = [{"url": "http://www.a.com/x/", "title": "t2", "relevance": "high"}]
assert d.filter_novel("angle2", r2) == []
assert len(d.dupes) == 1
def test_deduper_budget_drops_medium_low_when_slots_exhausted():
d = Deduper(Config(MAX_FETCH=1))
first = [{"url": "http://a.com/1", "title": "t", "relevance": "high"}]
d.filter_novel("a1", first) # consumes the only slot
more = [
{"url": "http://b.com/2", "title": "t", "relevance": "medium"},
{"url": "http://c.com/3", "title": "t", "relevance": "low"},
]
assert d.filter_novel("a2", more) == []
assert len(d.budget_dropped) == 2
def test_deduper_high_passes_even_when_slots_exhausted():
# 원본: high(rank 0)는 budget 조건(rank>=1)에 안 걸려 slot<=0 이어도 통과
d = Deduper(Config(MAX_FETCH=1))
d.filter_novel("a1", [{"url": "http://a.com/1", "title": "t", "relevance": "high"}])
high = [{"url": "http://d.com/4", "title": "t", "relevance": "high"}]
assert len(d.filter_novel("a2", high)) == 1
assert d.fetch_slots == -1
def _claim(imp, qual, name="c"):
return {"claim": name, "importance": imp, "sourceQuality": qual}
def test_rank_claims_orders_by_importance_then_quality():
claims = [
_claim("tangential", "primary", "t-prim"),
_claim("central", "blog", "c-blog"),
_claim("central", "primary", "c-prim"),
]
ranked = rank_claims(claims, max_verify=25)
assert [c["claim"] for c in ranked] == ["c-prim", "c-blog", "t-prim"]
def test_rank_claims_truncates_to_max():
claims = [_claim("central", "primary", f"c{i}") for i in range(30)]
assert len(rank_claims(claims, max_verify=25)) == 25
def test_tally_survives_2_valid_0_refute():
v = [{"refuted": False}, {"refuted": False}, {"refuted": False}]
t = tally(v, votes_per_claim=3, refutations_required=2)
assert t["survives"] is True and t["refutedVotes"] == 0
def test_tally_killed_2_refute():
v = [{"refuted": True}, {"refuted": True}, {"refuted": False}]
t = tally(v, votes_per_claim=3, refutations_required=2)
assert t["survives"] is False and t["refutedVotes"] == 2
def test_tally_all_abstain_does_not_survive():
# ⚠️ 거짓 생존 차단: all-None -> refuted=0 이지만 valid<2 라 미생존
t = tally([None, None, None], votes_per_claim=3, refutations_required=2)
assert t["survives"] is False and t["abstained"] == 3
def test_tally_one_valid_two_abstain_does_not_survive():
t = tally([{"refuted": False}, None, None], votes_per_claim=3, refutations_required=2)
assert t["survives"] is False # valid(1) < 2
def test_tally_boundary_one_refute_two_valid_survives():
t = tally([{"refuted": True}, {"refuted": False}], votes_per_claim=3, refutations_required=2)
assert t["survives"] is True # 1 refute < 2 required, quorum met
def test_tally_boundary_two_refute_two_valid_killed():
t = tally([{"refuted": True}, {"refuted": True}], votes_per_claim=3, refutations_required=2)
assert t["survives"] is False # 2 refute == 2 required, strict < fails
def test_build_synth_blocks_includes_vote_and_source():
confirmed = [{
"claim": "X causes Y", "quote": "q", "sourceUrl": "http://a", "sourceQuality": "primary",
"valid": [{"refuted": False, "confidence": "high", "evidence": "e"}],
"refutedVotes": 0,
}]
killed = [{
"claim": "Z", "sourceUrl": "http://b", "valid": [{"refuted": True}], "refutedVotes": 1,
}]
block, killed_block = build_synth_blocks(confirmed, killed)
assert "X causes Y" in block and "1-0" in block
assert "Refuted claims" in killed_block and "Z" in killed_block
def test_build_synth_blocks_no_killed():
block, killed_block = build_synth_blocks([{
"claim": "X", "quote": "q", "sourceUrl": "http://a", "sourceQuality": "primary",
"valid": [{"refuted": False, "confidence": "high", "evidence": "e"}], "refutedVotes": 0,
}], [])
assert killed_block == ""
def test_build_stats_agent_calls_formula():
s = build_stats(angles=5, sources=12, claims=20, voted=18, confirmed=10, killed=8,
after_synth=6, dupes=3, budget_dropped=2, votes_per_claim=3)
# 1 + angles + sources + voted*votes_per_claim + 1
assert s["agentCalls"] == 1 + 5 + 12 + 18 * 3 + 1
assert s["confirmed"] == 10 and s["afterSynthesis"] == 6
@@ -0,0 +1,131 @@
"""extract.py(quote-verifier·funnel·fallback) + vote.py(블록 렌더) 테스트 — MockBackend."""
import asyncio
from pathlib import Path
import pytest
from deep_research.backends.mock import MockBackend
from deep_research.extract import (FileExtraction, Quote, extract_file,
numbered_content, render_digest, verify_quotes)
from deep_research.vote import FindingVote, VoteResult, render_vote_block
@pytest.fixture
def sample(tmp_path: Path) -> Path:
p = tmp_path / "note.md"
p.write_text("alpha line\nbeta line with policy X\ngamma line\n", encoding="utf-8")
return p
# ---------- quote-verifier (결정론 trust boundary) ----------
def test_verify_pass_corrected_dropped(sample):
ext = FileExtraction(relevant=True, summary="s", facts=[], quotes=[
Quote(line=2, quote="beta line with policy X"), # PASS
Quote(line=1, quote="gamma line"), # 다른 라인 → CORRECTED(3)
Quote(line=2, quote="완전 위조된 인용"), # DROPPED
])
v = verify_quotes(sample, ext)
assert (v["pass"], v["corrected"], v["dropped"]) == (1, 1, 1)
assert any(q.line == 3 and q.quote == "gamma line" for q in v["kept"])
assert all("위조" not in q.quote for q in v["kept"]) # 위조 인용은 digest 에 못 들어감
def test_numbered_content_truncation():
text = "\n".join(f"line-{i}" for i in range(1000))
numbered, truncated = numbered_content(text, limit=200)
assert truncated and numbered.startswith("1\tline-0")
# ---------- extract_file: fallback 사다리 + engine funnel ----------
def _ok(relevant=True):
return FileExtraction(relevant=relevant, summary="요약", facts=["사실1"],
quotes=[Quote(line=2, quote="beta line with policy X")])
def test_first_backend_wins(sample):
b1, b2 = MockBackend({"extract": _ok()}), MockBackend({"extract": _ok()})
r = asyncio.run(extract_file([("codex", b1), ("antigravity", b2)], "q", sample, 8,
asyncio.Semaphore(1)))
assert r["engine"] == "codex" and b2.calls == []
assert r["verify"]["pass"] == 1
def test_fallback_ladder(sample):
b1 = MockBackend({}, default=None) # codex 실패
b2 = MockBackend({"extract": _ok()}) # agy 성공
r = asyncio.run(extract_file([("codex", b1), ("antigravity", b2)], "q", sample, 8,
asyncio.Semaphore(1)))
assert r["engine"] == "antigravity" # silent engine swap 아님 — engine 기록
def test_all_fail_recorded(sample):
b = MockBackend({}, default=None)
r = asyncio.run(extract_file([("codex", b)], "q", sample, 8, asyncio.Semaphore(1)))
assert "error" in r
# ---------- digest 렌더: funnel 균형 ----------
def test_digest_funnel(sample):
ok = {"path": str(sample), "engine": "codex", "truncated": False, "relevant": True,
"summary": "s", "facts": ["f"],
"verify": {"pass": 1, "corrected": 0, "dropped": 1,
"kept": [Quote(line=2, quote="beta line with policy X")]}}
bad = {"path": "x.md", "engine": None, "error": "전 엔진 실패"}
d = render_digest("q", [ok, bad])
assert "found: 2" in d and "processed: 1" in d and "dropped: 1" in d
assert "dropped_reason" in d
assert f"{sample}:2" in d # file:line 포인터
assert "폐기 1" in d # 위조 인용 수 가시화
def test_digest_zero_quote_marker(sample):
"""인용 전멸(전부 DROPPED) 파일의 facts 는 미검증 주장 — 마커 필수 (계명 2)."""
ok = {"path": str(sample), "engine": "codex", "truncated": False, "relevant": True,
"summary": "s", "facts": ["근거 없는 주장"],
"verify": {"pass": 0, "corrected": 0, "dropped": 2, "kept": []}}
d = render_digest("q", [ok])
assert "검증 인용 0건" in d and "미검증 주장" in d
# 인용이 1건이라도 살아 있으면 마커 없음
ok["verify"] = {"pass": 1, "corrected": 0, "dropped": 1,
"kept": [Quote(line=2, quote="beta line with policy X")]}
assert "미검증 주장" not in render_digest("q", [ok])
# ---------- vote: wiki-verdict 블록 렌더 (wiki_quorum 호환) ----------
def _parse_with_wiki_rules(block: str):
"""wiki_quorum.py 의 실제 파서로 파싱 (엔진 불가지 계약 검증용)."""
import importlib.util
import sys
hooks = Path(__file__).resolve().parents[3] / ".claude" / "hooks"
sys.path.insert(0, str(hooks))
spec = importlib.util.spec_from_file_location("wr", hooks / "wiki_rules.py")
wr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wr)
return wr.parse_verdict_block(block)
def test_vote_block_quorum_compatible():
r = VoteResult(votes=[
FindingVote(id="4.1.1", action="KEEP", reason="ok"),
FindingVote(id="finding with space", action="REJECT", reason="bad"),
])
block = render_vote_block("codex", r)
assert "agent: codex-adversarial-vote" in block
assert "finding: 4.1.1 action: KEEP" in block
assert "finding: finding-with-space action: REJECT" in block # 공백 정규화
parsed = _parse_with_wiki_rules(block)
assert parsed and len(parsed["findings"]) == 2
def test_vote_block_truncation_note():
"""findings 절단 시 표 자체에 누락 사실 기록 (no-silent-caps) + quorum 파서 비파괴."""
r = VoteResult(votes=[FindingVote(id="1", action="KEEP", reason="ok")])
block = render_vote_block("codex", r, truncated_chars=99_999)
assert "잘린 finding 은 이 표에서 누락" in block
parsed = _parse_with_wiki_rules(block) # 노트는 fence 밖 — 파싱 영향 없음
assert parsed and len(parsed["findings"]) == 1
assert "누락" not in render_vote_block("codex", r) # 미절단 시 노트 없음
@@ -0,0 +1,136 @@
import pytest
from deep_research.pipeline import run_research
from deep_research.backends.mock import MockBackend
from deep_research.config import Config
from deep_research.schemas import Scope, Angle, Search, SearchResult, Extract, Claim, Verdict, Report
CFG = Config(MAX_FETCH=15, MAX_VERIFY_CLAIMS=25)
def _scope():
return Scope(question="Q", summary="s",
angles=[Angle(label=f"a{i}", query=f"q{i}") for i in range(3)])
def _search(prompt, label):
# 각 각도마다 고유 URL 1개
n = label.split(":")[1]
return Search(results=[SearchResult(url=f"http://{n}.com/x", title="t", relevance="high")])
def _extract(prompt, label):
return Extract(sourceQuality="primary",
claims=[Claim(claim="C-" + label, quote="q", importance="central")])
def _verdict_pass(prompt, label):
return Verdict(refuted=False, evidence="e", confidence="high")
def _report():
return Report(summary="done", findings=[], caveats="none")
async def test_happy_path_returns_report_and_stats():
backend = MockBackend({
"scope": _scope(),
"search:": _search,
"fetch:": _extract,
"v": _verdict_pass,
"synthesize": _report(),
})
out = await run_research("Q", backend, config=CFG)
assert out["summary"] == "done"
assert out["stats"]["confirmed"] == 3 # 3 각도 × 1 claim, 전부 생존
assert out["stats"]["angles"] == 3
async def test_empty_question_returns_error():
out = await run_research(" ", MockBackend({}), config=CFG)
assert "error" in out
async def test_no_claims_degenerate():
backend = MockBackend({
"scope": _scope(),
"search:": _search,
"fetch:": lambda p, l: Extract(sourceQuality="unreliable", claims=[]),
})
out = await run_research("Q", backend, config=CFG)
assert out["findings"] == [] and out["stats"]["claims"] == 0
async def test_all_refuted_degenerate():
backend = MockBackend({
"scope": _scope(),
"search:": _search,
"fetch:": _extract,
"v": lambda p, l: Verdict(refuted=True, evidence="e", confidence="high"),
})
out = await run_research("Q", backend, config=CFG)
assert out["findings"] == [] and out["stats"]["confirmed"] == 0
assert len(out["refuted"]) == 3
async def test_synth_failure_salvages_confirmed():
backend = MockBackend({
"scope": _scope(),
"search:": _search,
"fetch:": _extract,
"v": _verdict_pass,
"synthesize": None, # 합성 실패
})
out = await run_research("Q", backend, config=CFG)
assert out["findings"] == [] and len(out["confirmed"]) == 3
# ── Task 9: report.py ──────────────────────────────────────────────────────
from deep_research.report import to_markdown
def test_to_markdown_renders_summary_and_stats():
result = {
"question": "Q", "summary": "ans",
"findings": [{"claim": "F1", "confidence": "high", "sources": ["http://a"], "evidence": "e"}],
"caveats": "c", "refuted": [], "sources": [],
"stats": {"angles": 5, "confirmed": 1},
}
md = to_markdown(result)
assert "# Deep Research" in md and "ans" in md and "F1" in md
# ── Task 10: backends/codex.py ────────────────────────────────────────────
from deep_research.backends.codex import extract_final_json
def test_extract_final_json_from_jsonl():
jsonl = "\n".join([
'{"type":"reasoning","text":"thinking"}',
'{"type":"web_search","query":"x"}',
'{"type":"agent_message","text":"{\\"question\\":\\"Q\\",\\"summary\\":\\"s\\",\\"angles\\":[]}"}',
])
obj = extract_final_json(jsonl)
assert obj["summary"] == "s"
def test_extract_final_json_returns_none_when_absent():
assert extract_final_json('{"type":"reasoning","text":"only"}') is None
# ── Task 11: backends/antigravity.py ─────────────────────────────────────
from deep_research.backends.antigravity import extract_json_block
def test_extract_json_block_from_fenced():
text = 'prelude\n```json\n{"refuted": false, "evidence": "e", "confidence": "high"}\n```\ntrailing'
obj = extract_json_block(text)
assert obj["confidence"] == "high"
def test_extract_json_block_bare_object():
obj = extract_json_block('noise {"refuted": true, "evidence": "e", "confidence": "low"} more')
assert obj["refuted"] is True
def test_extract_json_block_none_when_absent():
assert extract_json_block("no json here") is None