File size: 11,811 Bytes
465b477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
"""Command line: ``uv run graphtrace --help``."""

from __future__ import annotations

import json
from pathlib import Path

import typer
from rich.console import Console
from rich.table import Table

from .config import ExperimentConfig, load_project, load_yaml, make_client, write_json

app = typer.Typer(add_completion=False, no_args_is_help=True, help="GraphTrace-Bench: provenance-linked KG QA benchmark.")
console = Console()
CONFIG = typer.Option("configs/default.yaml", "--config", "-c", help="Project config YAML.")


def _exp(path: str) -> ExperimentConfig:
    return ExperimentConfig(**load_yaml(path))


@app.command()
def corpus(config: str = CONFIG) -> None:
    """Select the corpus from QASPER, chunk it, write data/processed/."""
    from .data.qasper import download
    from .pipeline import prepare_corpus

    cfg = load_project(config)
    download(cfg.paths.raw, cfg.corpus.splits)
    stats = prepare_corpus(cfg)
    console.print_json(json.dumps({k: v for k, v in stats.items() if k not in ("selection_order", "paper_datasets")}, default=str))


@app.command("demo-questions")
def demo_questions(config: str = CONFIG) -> None:
    """Select the demo's predefined QASPER questions (rule in graphtrace.demo) into data/processed/."""
    from .data.qasper import load_split
    from .demo import select_demo_questions
    from .pipeline import load_corpus, write_jsonl

    cfg = load_project(config)
    papers, chunks = load_corpus(cfg)
    questions = []
    for split in cfg.corpus.splits:
        questions.extend(load_split(cfg.paths.raw, split)[1])
    rows = select_demo_questions(questions, chunks, {d: p.title for d, p in papers.items()})
    write_jsonl(Path(cfg.paths.processed) / "demo_questions.jsonl", rows)
    console.print(f"{len(rows)} demo questions written to {Path(cfg.paths.processed) / 'demo_questions.jsonl'}")


@app.command("estimate-extraction")
def estimate_extraction(config: str = CONFIG, output_tokens_per_chunk: int = 400) -> None:
    """Rough cost of KG extraction (one call per chunk) before spending anything."""
    from .config import load_models
    from .pipeline import load_corpus
    from .prompts import EXTRACT_SYSTEM
    from .tokens import count_tokens

    cfg = load_project(config)
    _, chunks = load_corpus(cfg)
    spec = load_models(cfg.models_file)[cfg.extraction.model]
    tin = sum(c.n_tokens + count_tokens(EXTRACT_SYSTEM) + 60 for c in chunks)
    tout = len(chunks) * output_tokens_per_chunk
    usd = (tin * spec.price_in_per_mtok + tout * spec.price_out_per_mtok) / 1e6
    console.print_json(json.dumps({"model": spec.model, "calls": len(chunks), "input_tokens": tin, "output_tokens_assumed": tout, "usd": round(usd, 2)}))


@app.command()
def extract(config: str = CONFIG, limit: int = typer.Option(0, help="Only the first N chunks (0 = all).")) -> None:
    """Run LLM extraction over every chunk (resumable; raw calls cached in cache/llm)."""
    from .extraction import extract_corpus, extraction_stats
    from .pipeline import load_corpus, read_jsonl

    cfg = load_project(config)
    papers, chunks = load_corpus(cfg)
    if limit:
        chunks = chunks[:limit]
    client = make_client(cfg.extraction.model, cfg)
    out = Path(cfg.paths.graph) / "extractions.jsonl"
    summary = extract_corpus(client, papers, chunks, out, workers=cfg.extraction.workers)
    stats = extraction_stats(read_jsonl(out))
    write_json(Path(cfg.paths.graph) / "extraction_stats.json", {**summary, **stats})
    console.print_json(json.dumps({**summary, **stats}, default=str))


@app.command("build-graph")
def build_graph(config: str = CONFIG, nli: bool = typer.Option(True, help="Score edge support with the NLI model.")) -> None:
    """Canonicalise entities and write nodes/edges/results JSONL."""
    from .pipeline import build

    console.print_json(json.dumps(build(load_project(config), score_confidence=nli), default=str))


@app.command("graph-stats")
def graph_stats(config: str = CONFIG) -> None:
    from .graph import KG

    console.print_json(json.dumps(KG.load(load_project(config).paths.graph).stats(), default=str))


@app.command("generate-bench")
def generate_bench(config: str = CONFIG, paraphrase: bool = typer.Option(False, help="Paraphrase templated questions with the LLM.")) -> None:
    """Generate candidate items (all start unreviewed) into data/bench/candidates.jsonl."""
    from collections import defaultdict

    from .bench import Generator, write_items
    from .bench import paraphrase as para
    from .graph import KG
    from .pipeline import load_corpus

    cfg = load_project(config)
    _, chunks = load_corpus(cfg)
    by_doc = defaultdict(list)
    for c in chunks:
        by_doc[c.doc_id].append(c)
    kg = KG.load(cfg.paths.graph)
    items, stats = Generator(kg, dict(by_doc), cfg.bench).generate()
    if paraphrase:
        if not cfg.paraphrase_model:
            raise typer.BadParameter("set paraphrase_model in the project config")
        client = make_client(cfg.paraphrase_model, cfg)
        rejected = 0
        new = []
        for it in items:
            names = [kg.name(s.subject) for s in it.required_path[:1]] if it.category != "single_hop" else []
            q, why = para(client, it, [n for n in names if n in it.question_template])
            rejected += why is not None
            new.append(it.model_copy(update={"question": q}))
        items = new
        stats["paraphrase_rejected"] = rejected
    write_items(Path(cfg.paths.bench) / "candidates.jsonl", items)
    write_json(Path(cfg.paths.bench) / "generation_stats.json", {**stats, "bench_config": cfg.bench.model_dump(), "total": len(items)})
    console.print_json(json.dumps({**stats, "total": len(items)}))


@app.command()
def review(config: str = CONFIG, reviewer: str = typer.Option(..., help="Your name or initials, stored with each decision."), category: str = "") -> None:
    """Interactively review unreviewed items against their source evidence."""
    from .bench import append_review, apply_reviews, make_review, read_items, read_reviews
    from .graph import KG
    from .pipeline import load_corpus

    cfg = load_project(config)
    bench = Path(cfg.paths.bench)
    from .linking import EntityLinker

    kg = KG.load(cfg.paths.graph, cfg.bench.min_edge_confidence)
    _, chunks = load_corpus(cfg)
    ctext = {c.chunk_id: c.text for c in chunks}
    items = apply_reviews(read_items(bench / "candidates.jsonl"), read_reviews(bench / "reviews.jsonl"), EntityLinker(kg))
    todo = [i for i in items if i.review_status == "unreviewed" and (not category or i.category == category)]
    console.print(f"{len(todo)} items to review. Keys: [a]ccept [r]eject [e]dit [s]kip [q]uit")
    for it in todo:
        console.rule(f"{it.item_id}  ({it.category}, {it.template_id}, {it.difficulty} hop)")
        console.print(f"[bold]Q:[/bold] {it.question}")
        console.print(f"[bold]Gold decision:[/bold] {it.gold_decision}   [bold]Gold answer:[/bold] {it.gold_answer}")
        console.print(f"[dim]{it.annotation_notes}[/dim]")
        for s in it.required_path:
            if s.edge_id in kg.edges:
                e = kg.edges[s.edge_id]
                console.print(f"  ({kg.name(e.subject)}) -[{e.relation}]-> ({kg.name(e.object)})  [{e.source_chunk_id}]")
                console.print(f'    "{ctext.get(e.source_chunk_id, "")[e.start_char : e.end_char]}"')
            elif s.edge_id in kg.results:
                r = kg.results[s.edge_id]
                console.print(f"  result {kg.name(r.method)} / {kg.name(r.dataset)} / {kg.name(r.metric)} = {r.value} [{r.source_chunk_id}]")
                console.print(f'    "{ctext.get(r.source_chunk_id, "")[r.start_char : r.end_char]}"')
        choice = typer.prompt("decision", default="s").strip().lower()
        if choice == "q":
            break
        if choice == "s":
            continue
        notes = typer.prompt("notes", default="")
        if choice == "a":
            append_review(bench / "reviews.jsonl", make_review(it.item_id, "accepted", reviewer, notes))
        elif choice == "r":
            append_review(bench / "reviews.jsonl", make_review(it.item_id, "rejected", reviewer, notes))
        elif choice == "e":
            q = typer.prompt("question", default=it.question)
            dec = typer.prompt("gold decision (answer/abstain/flag_conflict)", default=it.gold_decision)
            ans = typer.prompt("gold answer (separate with ;)", default="; ".join(it.gold_answer))
            append_review(
                bench / "reviews.jsonl",
                make_review(it.item_id, "edited", reviewer, notes, question=q, gold_decision=dec, gold_answer=[a.strip() for a in ans.split(";") if a.strip()]),
            )


@app.command("freeze")
def freeze_cmd(config: str = CONFIG, version: str = "v1") -> None:
    """Export only human-accepted/edited items as the release benchmark."""
    from .bench import freeze, read_items, read_reviews, write_items
    from .graph import KG
    from .linking import EntityLinker

    cfg = load_project(config)
    bench = Path(cfg.paths.bench)
    linker = EntityLinker(KG.load(cfg.paths.graph, cfg.bench.min_edge_confidence))
    release, man = freeze(read_items(bench / "candidates.jsonl"), read_reviews(bench / "reviews.jsonl"), linker)
    write_items(bench / "release" / f"benchmark_{version}.jsonl", release)
    write_json(bench / "release" / f"benchmark_{version}.manifest.json", man)
    console.print_json(json.dumps(man))


@app.command()
def run(experiment: str, config: str = CONFIG, offline: bool = typer.Option(False, help="Replay cached calls only; fail on a miss.")) -> None:
    """Run all methods of an experiment over the benchmark (resumable)."""
    from .runner import run_experiment

    d = run_experiment(load_project(config), _exp(experiment), offline=offline)
    console.print(f"predictions written to {d / 'predictions.jsonl'}")


@app.command()
def evaluate(
    experiment: str,
    config: str = CONFIG,
    judge: bool = typer.Option(True, "--judge/--no-judge", help="Call the evaluation judge for non-gold cited chunks (default on)."),
) -> None:
    from .runner import evaluate_run

    m = evaluate_run(load_project(config), _exp(experiment), run_judge=judge)
    t = Table("method", "n (headline)", "errors", "accuracy", "path valid", "citation rate", "grounding", "false-premise rej.")
    for name, agg in m["headline"].items():
        t.add_row(
            name,
            str(agg["n"]),
            str(agg["errors"]),
            *(f"{agg[k]['mean']:.3f}" for k in ("correct", "path_all_valid", "citation_rate", "grounding_precision", "false_premise_rejected")),
        )
    console.print(t)
    console.print(f"evaluation judge: {m['judge_status']}")


@app.command()
def report(experiment: str, config: str = CONFIG, plots: bool = True) -> None:
    from .report import write_report

    cfg = load_project(config)
    console.print(f"wrote {write_report(Path(cfg.paths.runs) / _exp(experiment).name, with_plots=plots)}")


@app.command("estimate-cost")
def estimate_cost_cmd(experiment: str, config: str = CONFIG, n_items: int = 0) -> None:
    """Estimate the answer-stage cost of an experiment before running it."""
    from .bench import read_items
    from .runner import estimate_cost

    cfg = load_project(config)
    exp = _exp(experiment)
    n = n_items or len(read_items(exp.bench_file))
    console.print_json(json.dumps(estimate_cost(exp, cfg, n)))


@app.command()
def serve(config: str = CONFIG, host: str = "127.0.0.1", port: int = 8000) -> None:
    """Serve the JSON API (and the demo UI if built)."""
    import uvicorn

    from .app.server import create_app

    uvicorn.run(create_app(load_project(config)), host=host, port=port)


if __name__ == "__main__":
    app()