| """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() |
| |
| 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() |
|
|