abhid1234 commited on
Commit
4a9ccd7
Β·
verified Β·
1 Parent(s): bb1d76d

cost controls: concise mode, read-only-survivors, flash-lite extraction

Browse files
Files changed (4) hide show
  1. server/app.py +29 -11
  2. server/static/index.html +8 -3
  3. src/agent.py +31 -13
  4. 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: str, max_papers: int, depth: int, model: str, year_min: int, ttl: int
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", 8))
271
  except (TypeError, ValueError):
272
- max_papers = 8
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, "cached": job.cached}
 
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 {"models": ALLOWED_MODELS, "max_papers": MAX_PAPERS_CAP, "max_depth": MAX_DEPTH}
 
 
 
 
 
 
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="n">Papers β€” <span id="nval">8</span></label>
220
- <input id="n" type="range" min="3" max="12" value="8" /></div>
 
 
 
 
 
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
- todo = [p for p in papers if not p.full_text]
157
- say(f"Reading {len(todo)} papers (parallel)...")
 
 
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"Write a structured literature review on: {state['topic']}\n\n"
197
  f"Papers:\n{papers_block}\n\n"
198
- "Structure the review with these Markdown sections:\n"
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
- # Generous ceiling so long, multi-paper reviews don't truncate mid-sentence.
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", "read")
250
- graph.add_edge("read", "score")
251
- graph.add_edge("score", "synthesize")
252
  graph.add_conditional_edges("synthesize", should_deepen, {"deepen": "deepen", END: END})
253
- graph.add_edge("deepen", "read")
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
  {