Spaces:
Running on Zero
Running on Zero
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()
|