Spaces:
Running
Running
cost controls: concise mode, read-only-survivors, flash-lite extraction
Browse files- server/app.py +29 -11
- server/static/index.html +8 -3
- src/agent.py +31 -13
- src/cli.py +4 -0
server/app.py
CHANGED
|
@@ -34,6 +34,9 @@ DB_PATH = os.getenv("JOBS_DB", "jobs.db")
|
|
| 34 |
ALLOWED_MODELS = [m.strip() for m in os.getenv(
|
| 35 |
"ALLOWED_MODELS", "gemini-2.5-flash,gemini-2.5-pro"
|
| 36 |
).split(",") if m.strip()]
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
HERE = os.path.dirname(__file__)
|
| 39 |
|
|
@@ -46,6 +49,7 @@ class Job:
|
|
| 46 |
depth: int = 1
|
| 47 |
model: str = ""
|
| 48 |
year_min: int = 0
|
|
|
|
| 49 |
status: str = "queued" # queued | running | done | error
|
| 50 |
log: list[str] = field(default_factory=list)
|
| 51 |
review: str = ""
|
|
@@ -94,7 +98,7 @@ class JobStore:
|
|
| 94 |
return Job(**json.loads(row[0])) if row else None
|
| 95 |
|
| 96 |
def find_cached(
|
| 97 |
-
self, topic
|
| 98 |
) -> "Job | None":
|
| 99 |
"""Most recent completed job with identical params, within ``ttl`` seconds."""
|
| 100 |
cutoff = time.time() - ttl
|
|
@@ -112,6 +116,7 @@ class JobStore:
|
|
| 112 |
and d.get("depth") == depth
|
| 113 |
and (d.get("model") or "") == (model or "")
|
| 114 |
and (d.get("year_min") or 0) == year_min
|
|
|
|
| 115 |
):
|
| 116 |
return Job(**d)
|
| 117 |
return None
|
|
@@ -130,12 +135,10 @@ class JobManager:
|
|
| 130 |
self._pool = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_JOBS)
|
| 131 |
self._store = JobStore(DB_PATH)
|
| 132 |
|
| 133 |
-
def submit(
|
| 134 |
-
self, topic: str, max_papers: int, depth: int, model: str, year_min: int
|
| 135 |
-
) -> Job:
|
| 136 |
# Return a recent identical completed review instead of re-spending.
|
| 137 |
cached = self._store.find_cached(
|
| 138 |
-
topic, max_papers, depth, model, year_min, CACHE_TTL_SECONDS
|
| 139 |
)
|
| 140 |
if cached:
|
| 141 |
cached.cached = True
|
|
@@ -148,6 +151,7 @@ class JobManager:
|
|
| 148 |
depth=depth,
|
| 149 |
model=model,
|
| 150 |
year_min=year_min,
|
|
|
|
| 151 |
)
|
| 152 |
with self._lock:
|
| 153 |
self._jobs[job.id] = job
|
|
@@ -181,6 +185,8 @@ class JobManager:
|
|
| 181 |
meter=meter,
|
| 182 |
model=job.model or None,
|
| 183 |
year_min=job.year_min,
|
|
|
|
|
|
|
| 184 |
)
|
| 185 |
result = agent.invoke(
|
| 186 |
{
|
|
@@ -267,11 +273,15 @@ async def create_review(request: Request) -> JSONResponse:
|
|
| 267 |
return JSONResponse({"error": "Topic is too long."}, status_code=400)
|
| 268 |
|
| 269 |
try:
|
| 270 |
-
max_papers = int(body.get("max_papers",
|
| 271 |
except (TypeError, ValueError):
|
| 272 |
-
max_papers =
|
| 273 |
max_papers = max(3, min(max_papers, MAX_PAPERS_CAP))
|
| 274 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
try:
|
| 276 |
depth = int(body.get("depth", 1))
|
| 277 |
except (TypeError, ValueError):
|
|
@@ -291,7 +301,7 @@ async def create_review(request: Request) -> JSONResponse:
|
|
| 291 |
|
| 292 |
# Rate limit only counts when we actually start a new run (cache hits are free).
|
| 293 |
cached = jobs._store.find_cached(
|
| 294 |
-
topic, max_papers, depth, model, year_min, CACHE_TTL_SECONDS
|
| 295 |
)
|
| 296 |
if not cached:
|
| 297 |
allowed, retry_in = limiter.allow(_client_ip(request))
|
|
@@ -301,9 +311,10 @@ async def create_review(request: Request) -> JSONResponse:
|
|
| 301 |
status_code=429,
|
| 302 |
)
|
| 303 |
|
| 304 |
-
job = jobs.submit(topic, max_papers, depth, model, year_min)
|
| 305 |
return JSONResponse(
|
| 306 |
-
{"job_id": job.id, "max_papers": max_papers, "depth": depth,
|
|
|
|
| 307 |
)
|
| 308 |
|
| 309 |
|
|
@@ -323,6 +334,7 @@ def get_review(job_id: str) -> JSONResponse:
|
|
| 323 |
"papers": job.papers,
|
| 324 |
"warnings": job.warnings,
|
| 325 |
"model": job.model,
|
|
|
|
| 326 |
"cached": job.cached,
|
| 327 |
"error": job.error,
|
| 328 |
"stats": job.stats,
|
|
@@ -332,7 +344,13 @@ def get_review(job_id: str) -> JSONResponse:
|
|
| 332 |
|
| 333 |
@app.get("/api/config")
|
| 334 |
def get_config() -> dict:
|
| 335 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
@app.get("/r/{job_id}", response_class=HTMLResponse)
|
|
|
|
| 34 |
ALLOWED_MODELS = [m.strip() for m in os.getenv(
|
| 35 |
"ALLOWED_MODELS", "gemini-2.5-flash,gemini-2.5-pro"
|
| 36 |
).split(",") if m.strip()]
|
| 37 |
+
DEFAULT_PAPERS = int(os.getenv("DEFAULT_PAPERS", "5"))
|
| 38 |
+
# Cheaper model for the ~20 mechanical extraction calls (blank = use run model).
|
| 39 |
+
EXTRACT_MODEL = os.getenv("EXTRACT_MODEL", "").strip()
|
| 40 |
|
| 41 |
HERE = os.path.dirname(__file__)
|
| 42 |
|
|
|
|
| 49 |
depth: int = 1
|
| 50 |
model: str = ""
|
| 51 |
year_min: int = 0
|
| 52 |
+
style: str = "concise"
|
| 53 |
status: str = "queued" # queued | running | done | error
|
| 54 |
log: list[str] = field(default_factory=list)
|
| 55 |
review: str = ""
|
|
|
|
| 98 |
return Job(**json.loads(row[0])) if row else None
|
| 99 |
|
| 100 |
def find_cached(
|
| 101 |
+
self, topic, max_papers, depth, model, year_min, style, ttl
|
| 102 |
) -> "Job | None":
|
| 103 |
"""Most recent completed job with identical params, within ``ttl`` seconds."""
|
| 104 |
cutoff = time.time() - ttl
|
|
|
|
| 116 |
and d.get("depth") == depth
|
| 117 |
and (d.get("model") or "") == (model or "")
|
| 118 |
and (d.get("year_min") or 0) == year_min
|
| 119 |
+
and (d.get("style") or "concise") == style
|
| 120 |
):
|
| 121 |
return Job(**d)
|
| 122 |
return None
|
|
|
|
| 135 |
self._pool = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_JOBS)
|
| 136 |
self._store = JobStore(DB_PATH)
|
| 137 |
|
| 138 |
+
def submit(self, topic, max_papers, depth, model, year_min, style) -> Job:
|
|
|
|
|
|
|
| 139 |
# Return a recent identical completed review instead of re-spending.
|
| 140 |
cached = self._store.find_cached(
|
| 141 |
+
topic, max_papers, depth, model, year_min, style, CACHE_TTL_SECONDS
|
| 142 |
)
|
| 143 |
if cached:
|
| 144 |
cached.cached = True
|
|
|
|
| 151 |
depth=depth,
|
| 152 |
model=model,
|
| 153 |
year_min=year_min,
|
| 154 |
+
style=style,
|
| 155 |
)
|
| 156 |
with self._lock:
|
| 157 |
self._jobs[job.id] = job
|
|
|
|
| 185 |
meter=meter,
|
| 186 |
model=job.model or None,
|
| 187 |
year_min=job.year_min,
|
| 188 |
+
style=job.style,
|
| 189 |
+
extract_model=EXTRACT_MODEL or None,
|
| 190 |
)
|
| 191 |
result = agent.invoke(
|
| 192 |
{
|
|
|
|
| 273 |
return JSONResponse({"error": "Topic is too long."}, status_code=400)
|
| 274 |
|
| 275 |
try:
|
| 276 |
+
max_papers = int(body.get("max_papers", DEFAULT_PAPERS))
|
| 277 |
except (TypeError, ValueError):
|
| 278 |
+
max_papers = DEFAULT_PAPERS
|
| 279 |
max_papers = max(3, min(max_papers, MAX_PAPERS_CAP))
|
| 280 |
|
| 281 |
+
style = (body.get("style") or "concise").strip().lower()
|
| 282 |
+
if style not in ("concise", "comprehensive"):
|
| 283 |
+
style = "concise"
|
| 284 |
+
|
| 285 |
try:
|
| 286 |
depth = int(body.get("depth", 1))
|
| 287 |
except (TypeError, ValueError):
|
|
|
|
| 301 |
|
| 302 |
# Rate limit only counts when we actually start a new run (cache hits are free).
|
| 303 |
cached = jobs._store.find_cached(
|
| 304 |
+
topic, max_papers, depth, model, year_min, style, CACHE_TTL_SECONDS
|
| 305 |
)
|
| 306 |
if not cached:
|
| 307 |
allowed, retry_in = limiter.allow(_client_ip(request))
|
|
|
|
| 311 |
status_code=429,
|
| 312 |
)
|
| 313 |
|
| 314 |
+
job = jobs.submit(topic, max_papers, depth, model, year_min, style)
|
| 315 |
return JSONResponse(
|
| 316 |
+
{"job_id": job.id, "max_papers": max_papers, "depth": depth,
|
| 317 |
+
"style": style, "cached": job.cached}
|
| 318 |
)
|
| 319 |
|
| 320 |
|
|
|
|
| 334 |
"papers": job.papers,
|
| 335 |
"warnings": job.warnings,
|
| 336 |
"model": job.model,
|
| 337 |
+
"style": job.style,
|
| 338 |
"cached": job.cached,
|
| 339 |
"error": job.error,
|
| 340 |
"stats": job.stats,
|
|
|
|
| 344 |
|
| 345 |
@app.get("/api/config")
|
| 346 |
def get_config() -> dict:
|
| 347 |
+
return {
|
| 348 |
+
"models": ALLOWED_MODELS,
|
| 349 |
+
"max_papers": MAX_PAPERS_CAP,
|
| 350 |
+
"default_papers": DEFAULT_PAPERS,
|
| 351 |
+
"max_depth": MAX_DEPTH,
|
| 352 |
+
"styles": ["concise", "comprehensive"],
|
| 353 |
+
}
|
| 354 |
|
| 355 |
|
| 356 |
@app.get("/r/{job_id}", response_class=HTMLResponse)
|
server/static/index.html
CHANGED
|
@@ -216,8 +216,13 @@
|
|
| 216 |
<details class="advanced">
|
| 217 |
<summary>Options</summary>
|
| 218 |
<div class="opts">
|
| 219 |
-
<div class="opt"><label for="
|
| 220 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
<div class="opt"><label for="depth">Depth</label>
|
| 222 |
<select id="depth">
|
| 223 |
<option value="1">1 β single pass</option>
|
|
@@ -322,7 +327,7 @@ form.addEventListener('submit', async (e) => {
|
|
| 322 |
res = await fetch('/api/review', { method:'POST', headers:{'Content-Type':'application/json'},
|
| 323 |
body: JSON.stringify({ topic, max_papers: parseInt(nInput.value,10),
|
| 324 |
depth: parseInt($('depth').value,10), model: $('model').value,
|
| 325 |
-
year_min: parseInt($('since').value,10) }) });
|
| 326 |
} catch { return fail('Network error β is the server up?'); }
|
| 327 |
const data = await res.json();
|
| 328 |
if (!res.ok) return fail(data.error || 'Request failed.');
|
|
|
|
| 216 |
<details class="advanced">
|
| 217 |
<summary>Options</summary>
|
| 218 |
<div class="opts">
|
| 219 |
+
<div class="opt"><label for="style">Length</label>
|
| 220 |
+
<select id="style">
|
| 221 |
+
<option value="concise">Concise Β· cheaper</option>
|
| 222 |
+
<option value="comprehensive">Comprehensive</option>
|
| 223 |
+
</select></div>
|
| 224 |
+
<div class="opt"><label for="n">Papers β <span id="nval">5</span></label>
|
| 225 |
+
<input id="n" type="range" min="3" max="12" value="5" /></div>
|
| 226 |
<div class="opt"><label for="depth">Depth</label>
|
| 227 |
<select id="depth">
|
| 228 |
<option value="1">1 β single pass</option>
|
|
|
|
| 327 |
res = await fetch('/api/review', { method:'POST', headers:{'Content-Type':'application/json'},
|
| 328 |
body: JSON.stringify({ topic, max_papers: parseInt(nInput.value,10),
|
| 329 |
depth: parseInt($('depth').value,10), model: $('model').value,
|
| 330 |
+
year_min: parseInt($('since').value,10), style: $('style').value }) });
|
| 331 |
} catch { return fail('Network error β is the server up?'); }
|
| 332 |
const data = await res.json();
|
| 333 |
if (!res.ok) return fail(data.error || 'Request failed.');
|
src/agent.py
CHANGED
|
@@ -95,15 +95,23 @@ def build_graph(
|
|
| 95 |
meter: dict | None = None,
|
| 96 |
model: str | None = None,
|
| 97 |
year_min: int = 0,
|
|
|
|
|
|
|
| 98 |
):
|
| 99 |
-
"""Build and compile the research agent graph.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
from langgraph.graph import END, StateGraph
|
| 101 |
|
| 102 |
say = progress or (lambda _msg: None)
|
| 103 |
searcher = SearchTool(max_results=RESULTS_PER_QUERY)
|
| 104 |
scholar = SemanticScholarTool(max_results=RESULTS_PER_QUERY, year_min=year_min)
|
| 105 |
downloader = DownloadTool()
|
| 106 |
-
extractor = ExtractionTool(model=model, meter=meter)
|
| 107 |
pool_cap = max_papers + POOL_BUFFER
|
| 108 |
|
| 109 |
def _run_searches(queries: list[str], existing: list[Paper]) -> list[Paper]:
|
|
@@ -153,8 +161,10 @@ def build_graph(
|
|
| 153 |
"No papers found from arXiv or Semantic Scholar β both sources may "
|
| 154 |
"be rate-limiting right now. Please try again in a minute."
|
| 155 |
)
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
| 158 |
|
| 159 |
def _read_one(paper: Paper) -> None:
|
| 160 |
paper.full_text = downloader.get_text(paper)
|
|
@@ -190,12 +200,21 @@ def build_graph(
|
|
| 190 |
|
| 191 |
def synthesize_node(state: AgentState) -> dict:
|
| 192 |
top = state["papers"][:max_papers]
|
| 193 |
-
say(f"Synthesizing review from top {len(top)} papers...")
|
| 194 |
papers_block = utils.format_papers_for_synthesis(top)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
prompt = (
|
| 196 |
-
f"
|
| 197 |
f"Papers:\n{papers_block}\n\n"
|
| 198 |
-
"
|
| 199 |
"1. ## Introduction\n"
|
| 200 |
"2. ## Key Themes (group by methodology)\n"
|
| 201 |
"3. ## Key Findings\n"
|
|
@@ -205,8 +224,7 @@ def build_graph(
|
|
| 205 |
"its source as [Author, Year] using the first author's surname. Do not "
|
| 206 |
"invent papers, authors, or findings not present above."
|
| 207 |
)
|
| 208 |
-
|
| 209 |
-
review = utils.complete(prompt, max_tokens=16384, meter=meter, model=model)
|
| 210 |
review = review.rstrip() + "\n" + utils.references_markdown(top)
|
| 211 |
|
| 212 |
gaps: list[str] = []
|
|
@@ -246,9 +264,9 @@ def build_graph(
|
|
| 246 |
graph.add_node("synthesize", synthesize_node)
|
| 247 |
graph.add_node("deepen", deepen_node)
|
| 248 |
graph.set_entry_point("search")
|
| 249 |
-
graph.add_edge("search", "
|
| 250 |
-
graph.add_edge("
|
| 251 |
-
graph.add_edge("
|
| 252 |
graph.add_conditional_edges("synthesize", should_deepen, {"deepen": "deepen", END: END})
|
| 253 |
-
graph.add_edge("deepen", "
|
| 254 |
return graph.compile()
|
|
|
|
| 95 |
meter: dict | None = None,
|
| 96 |
model: str | None = None,
|
| 97 |
year_min: int = 0,
|
| 98 |
+
style: str = "concise",
|
| 99 |
+
extract_model: str | None = None,
|
| 100 |
):
|
| 101 |
+
"""Build and compile the research agent graph.
|
| 102 |
+
|
| 103 |
+
``style`` is "concise" (default β shorter review, far cheaper since output
|
| 104 |
+
tokens dominate cost) or "comprehensive" (longer, more detailed).
|
| 105 |
+
``extract_model`` overrides the model for the ~20 mechanical extraction
|
| 106 |
+
calls (e.g. a cheaper flash-lite), keeping the run model for synthesis.
|
| 107 |
+
"""
|
| 108 |
from langgraph.graph import END, StateGraph
|
| 109 |
|
| 110 |
say = progress or (lambda _msg: None)
|
| 111 |
searcher = SearchTool(max_results=RESULTS_PER_QUERY)
|
| 112 |
scholar = SemanticScholarTool(max_results=RESULTS_PER_QUERY, year_min=year_min)
|
| 113 |
downloader = DownloadTool()
|
| 114 |
+
extractor = ExtractionTool(model=extract_model or model, meter=meter)
|
| 115 |
pool_cap = max_papers + POOL_BUFFER
|
| 116 |
|
| 117 |
def _run_searches(queries: list[str], existing: list[Paper]) -> list[Paper]:
|
|
|
|
| 161 |
"No papers found from arXiv or Semantic Scholar β both sources may "
|
| 162 |
"be rate-limiting right now. Please try again in a minute."
|
| 163 |
)
|
| 164 |
+
# Only read the top max_papers (already ranked by score_node); the rest
|
| 165 |
+
# were filtered/ranked on abstracts alone, so we never pay to read them.
|
| 166 |
+
todo = [p for p in papers[:max_papers] if not p.full_text]
|
| 167 |
+
say(f"Reading top {len(todo)} papers (parallel)...")
|
| 168 |
|
| 169 |
def _read_one(paper: Paper) -> None:
|
| 170 |
paper.full_text = downloader.get_text(paper)
|
|
|
|
| 200 |
|
| 201 |
def synthesize_node(state: AgentState) -> dict:
|
| 202 |
top = state["papers"][:max_papers]
|
| 203 |
+
say(f"Synthesizing {style} review from top {len(top)} papers...")
|
| 204 |
papers_block = utils.format_papers_for_synthesis(top)
|
| 205 |
+
if style == "comprehensive":
|
| 206 |
+
length_note = "Write a thorough, detailed review."
|
| 207 |
+
max_out = 16384
|
| 208 |
+
else: # concise (default) β output tokens dominate cost, so keep it tight
|
| 209 |
+
length_note = (
|
| 210 |
+
"Write a CONCISE, focused review of about 600-900 words total β "
|
| 211 |
+
"short paragraphs, high signal, no filler or repetition."
|
| 212 |
+
)
|
| 213 |
+
max_out = 4096
|
| 214 |
prompt = (
|
| 215 |
+
f"{length_note}\n\nLiterature review on: {state['topic']}\n\n"
|
| 216 |
f"Papers:\n{papers_block}\n\n"
|
| 217 |
+
"Use these Markdown sections:\n"
|
| 218 |
"1. ## Introduction\n"
|
| 219 |
"2. ## Key Themes (group by methodology)\n"
|
| 220 |
"3. ## Key Findings\n"
|
|
|
|
| 224 |
"its source as [Author, Year] using the first author's surname. Do not "
|
| 225 |
"invent papers, authors, or findings not present above."
|
| 226 |
)
|
| 227 |
+
review = utils.complete(prompt, max_tokens=max_out, meter=meter, model=model)
|
|
|
|
| 228 |
review = review.rstrip() + "\n" + utils.references_markdown(top)
|
| 229 |
|
| 230 |
gaps: list[str] = []
|
|
|
|
| 264 |
graph.add_node("synthesize", synthesize_node)
|
| 265 |
graph.add_node("deepen", deepen_node)
|
| 266 |
graph.set_entry_point("search")
|
| 267 |
+
graph.add_edge("search", "score") # rank on abstracts first...
|
| 268 |
+
graph.add_edge("score", "read") # ...then read only the top max_papers
|
| 269 |
+
graph.add_edge("read", "synthesize")
|
| 270 |
graph.add_conditional_edges("synthesize", should_deepen, {"deepen": "deepen", END: END})
|
| 271 |
+
graph.add_edge("deepen", "score")
|
| 272 |
return graph.compile()
|
src/cli.py
CHANGED
|
@@ -23,6 +23,9 @@ def review(
|
|
| 23 |
depth: int = typer.Option(1, "--depth", help="Iterative gap-filling passes."),
|
| 24 |
model: str = typer.Option("", "--model", help="Override the model for this run."),
|
| 25 |
since: int = typer.Option(0, "--since", help="Only papers from this year onward."),
|
|
|
|
|
|
|
|
|
|
| 26 |
) -> None:
|
| 27 |
"""Search arXiv, read papers, and write a structured literature review."""
|
| 28 |
from .agent import build_graph
|
|
@@ -41,6 +44,7 @@ def review(
|
|
| 41 |
meter=meter,
|
| 42 |
model=model or None,
|
| 43 |
year_min=since,
|
|
|
|
| 44 |
)
|
| 45 |
result = agent.invoke(
|
| 46 |
{
|
|
|
|
| 23 |
depth: int = typer.Option(1, "--depth", help="Iterative gap-filling passes."),
|
| 24 |
model: str = typer.Option("", "--model", help="Override the model for this run."),
|
| 25 |
since: int = typer.Option(0, "--since", help="Only papers from this year onward."),
|
| 26 |
+
comprehensive: bool = typer.Option(
|
| 27 |
+
False, "--comprehensive", help="Longer, more detailed review (costs more)."
|
| 28 |
+
),
|
| 29 |
) -> None:
|
| 30 |
"""Search arXiv, read papers, and write a structured literature review."""
|
| 31 |
from .agent import build_graph
|
|
|
|
| 44 |
meter=meter,
|
| 45 |
model=model or None,
|
| 46 |
year_min=since,
|
| 47 |
+
style="comprehensive" if comprehensive else "concise",
|
| 48 |
)
|
| 49 |
result = agent.invoke(
|
| 50 |
{
|