Scholar-Lens / local_inference.py
Mr-Gondal
feat: add local inference and submission notes
f724565
Raw
History Blame Contribute Delete
5.92 kB
"""Local, cloud-free inference path for Scholar Lens (llama.cpp / GGUF).
Why this exists
---------------
The hosted app uses Modal for convenience, but Scholar Lens is small enough to
run entirely on a single consumer GPU (or even CPU). This module proves it:
it runs the same summarize / synthesize prompts through a quantized GGUF model
with ``llama-cpp-python`` — no cloud APIs.
This unlocks the hackathon's **Off-Grid** (no cloud APIs) and **Llama Champion**
(llama.cpp runtime) merit badges, and it is the basis for the NVIDIA pitch:
"give me an RTX-class GPU and the whole thing is local, private, and free to run."
Setup
-----
pip install llama-cpp-python # add a CUDA wheel for GPU acceleration
Point it at a GGUF model (download once, e.g. a Qwen2.5 Instruct GGUF):
set SCHOLAR_LENS_GGUF=C:\\models\\qwen2.5-3b-instruct-q4_k_m.gguf # Windows
export SCHOLAR_LENS_GGUF=/models/qwen2.5-3b-instruct-q4_k_m.gguf # Unix
Usage
-----
python local_inference.py --benchmark
python local_inference.py --summarize "Long abstract text..."
python local_inference.py --ask "Question?" --context "[1] Title: ... Abstract: ..."
"""
from __future__ import annotations
import argparse
import os
import time
MODEL_PATH = os.getenv("SCHOLAR_LENS_GGUF", "").strip()
# Number of layers to offload to GPU. -1 = all layers (full GPU). 0 = CPU only.
GPU_LAYERS = int(os.getenv("SCHOLAR_LENS_GPU_LAYERS", "-1"))
CONTEXT_WINDOW = int(os.getenv("SCHOLAR_LENS_CTX", "8192"))
_LLM = None
def _load_model():
"""Load the GGUF model once (lazy)."""
global _LLM
if _LLM is not None:
return _LLM
if not MODEL_PATH:
raise SystemExit(
"Set SCHOLAR_LENS_GGUF to a local GGUF model file first "
"(see the module docstring)."
)
from llama_cpp import Llama
_LLM = Llama(
model_path=MODEL_PATH,
n_ctx=CONTEXT_WINDOW,
n_gpu_layers=GPU_LAYERS,
verbose=False,
)
return _LLM
def _chat(prompt: str, max_tokens: int = 350) -> tuple[str, dict]:
"""Run one chat completion; return (text, timing stats)."""
llm = _load_model()
start = time.perf_counter()
result = llm.create_chat_completion(
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.15,
)
elapsed = time.perf_counter() - start
text = result["choices"][0]["message"]["content"].strip()
completion_tokens = result.get("usage", {}).get("completion_tokens", 0)
stats = {
"seconds": round(elapsed, 2),
"completion_tokens": completion_tokens,
"tokens_per_sec": round(completion_tokens / elapsed, 1) if elapsed else 0.0,
}
return text, stats
def summarize(text: str) -> str:
prompt = (
"Summarize the following research paper context in 4-6 clear sentences. "
"Cover the main contribution, methods, and key results/findings.\n\n"
f"Paper context:\n{text}"
)
answer, _ = _chat(prompt, max_tokens=250)
return answer
def synthesize(question: str, context: str) -> str:
prompt = (
"You are a meticulous research assistant. Using ONLY the numbered paper "
"abstracts below, write a clear, synthesized answer to the question. "
"Cite every claim with the matching source number in square brackets, "
"e.g. [1] or [2][3]. Never invent sources.\n\n"
f"{context}\n\nQuestion: {question}\n\nSynthesized answer (with [n] citations):"
)
answer, _ = _chat(prompt, max_tokens=450)
return answer
def benchmark() -> None:
"""Print throughput so we can report consumer-GPU numbers in the README."""
sample_context = (
"[1] Title: Aerosol-cloud interactions in climate models\n"
"Abstract: We review parameterizations of aerosol-cloud interactions and "
"quantify the spread in radiative forcing estimates across CMIP6 models, "
"finding the largest uncertainty in the cloud-lifetime effect.\n\n"
"[2] Title: Machine learning for satellite precipitation\n"
"Abstract: A convolutional model improves sub-daily precipitation retrieval "
"skill over mountainous terrain relative to operational baselines."
)
question = "Where do papers disagree on aerosol-cloud interaction uncertainty?"
answer, stats = _chat(
"You are a research assistant. Using ONLY these abstracts, answer with "
f"[n] citations.\n\n{sample_context}\n\nQuestion: {question}\n\nAnswer:",
max_tokens=300,
)
print("=== Scholar Lens local benchmark ===")
print(f"Model file : {MODEL_PATH}")
print(f"GPU layers : {GPU_LAYERS} (-1 = all on GPU, 0 = CPU)")
print(f"Context window : {CONTEXT_WINDOW}")
print(f"Completion tokens : {stats['completion_tokens']}")
print(f"Latency (s) : {stats['seconds']}")
print(f"Throughput (tok/s): {stats['tokens_per_sec']}")
print("\n--- sample answer ---")
print(answer)
def main() -> None:
parser = argparse.ArgumentParser(description="Scholar Lens local (llama.cpp) inference.")
parser.add_argument("--benchmark", action="store_true", help="Run a throughput benchmark.")
parser.add_argument("--summarize", metavar="TEXT", help="Summarize the given paper text.")
parser.add_argument("--ask", metavar="QUESTION", help="Ask a question (use with --context).")
parser.add_argument("--context", metavar="CONTEXT", default="", help="Numbered abstracts for --ask.")
args = parser.parse_args()
if args.benchmark:
benchmark()
elif args.summarize:
print(summarize(args.summarize))
elif args.ask:
if not args.context:
raise SystemExit("--ask requires --context with numbered abstracts.")
print(synthesize(args.ask, args.context))
else:
parser.print_help()
if __name__ == "__main__":
main()