starfriend commited on
Commit
a69cf4e
·
verified ·
1 Parent(s): 96a85ba

Upload research_synthesis.py

Browse files
Files changed (1) hide show
  1. metadata_search/research_synthesis.py +376 -135
metadata_search/research_synthesis.py CHANGED
@@ -1,38 +1,45 @@
1
  """
2
- Research-only multi-publication synthesis route for WaterScope-AI Search.
3
 
4
  REPLACE:
5
  metadata_search/research_synthesis.py
6
 
7
- This module is registered by the existing metadata_search/research_routes.py:
8
-
9
- from metadata_search.research_synthesis import register_research_synthesis_route
10
- register_research_synthesis_route(research_bp)
11
-
12
- Design
13
- ------
14
- The research route reuses the CURRENT production functions in
15
- metadata_search.chat_api:
16
-
17
- focus_document_ids_for_question
18
- build_context_for_documents
19
- enhance_ordinal_question
20
- _generate_answer
21
- prefix_context
22
- sanitize/model/agent path through _generate_answer
23
-
24
- It bypasses ONLY the normal selected-paper count check so that a research
25
- request can supply up to 20 ordered document IDs. The production /api/chat
26
- route and MAX_CHAT_PAPERS=4 remain unchanged.
27
-
28
- No monkey-patching of production globals is used.
 
 
 
 
 
29
  """
30
 
31
  from __future__ import annotations
32
 
 
33
  import os
34
  import time
35
- from typing import Any
 
36
 
37
  from flask import jsonify, request
38
 
@@ -40,6 +47,8 @@ import metadata_search.chat_api as chat_api
40
 
41
 
42
  MAX_RESEARCH_PAPERS = 20
 
 
43
 
44
 
45
  def _clean(value: Any) -> str:
@@ -52,49 +61,58 @@ def _word_count(text: str) -> int:
52
  return len(_clean(text).split())
53
 
54
 
55
- def _split_context_excerpts(context: str) -> list[str]:
56
- """
57
- Extract the exact per-paper excerpt from the assembled production context.
 
 
 
58
 
59
- metadata_search.mcp_retrieval builds each context block as:
60
 
61
- Paper N: <title>
62
- File: <file_name>
 
 
 
 
63
 
64
- <excerpt>
 
 
 
 
65
 
66
- with blocks separated by "\n\n---\n\n".
 
 
 
67
 
68
- Reading excerpts back from the final assembled context guarantees that the
69
- trace records what was actually supplied to the model, rather than a
70
- separately reconstructed approximation.
71
- """
72
- if not context:
73
- return []
74
 
75
- blocks = context.split("\n\n---\n\n")
76
- excerpts: list[str] = []
 
77
 
78
- for block in blocks:
79
- if "\n\n" in block:
80
- _, excerpt = block.split("\n\n", 1)
81
- excerpts.append(excerpt.strip())
82
- else:
83
- excerpts.append("")
84
 
85
- return excerpts
86
 
87
 
88
- def _evidence_trace(
89
  sources: list[dict[str, Any]],
90
- context: str,
91
  ) -> list[dict[str, Any]]:
92
- excerpts = _split_context_excerpts(context)
 
 
 
93
  rows: list[dict[str, Any]] = []
94
 
95
  for index, source in enumerate(sources, start=1):
96
  full_summary = _clean(source.get("text"))
97
- excerpt = excerpts[index - 1] if index - 1 < len(excerpts) else ""
98
 
99
  rows.append(
100
  {
@@ -105,26 +123,122 @@ def _evidence_trace(
105
  "full_summary": full_summary,
106
  "full_summary_chars": len(full_summary),
107
  "full_summary_words": _word_count(full_summary),
108
- "excerpt_used": excerpt,
109
- "excerpt_chars": len(excerpt),
110
- "excerpt_words": _word_count(excerpt),
111
- "excerpt_truncated_vs_full": (
112
- bool(full_summary)
113
- and bool(excerpt)
114
- and len(excerpt) < len(full_summary)
115
- ),
116
  }
117
  )
118
 
119
  return rows
120
 
121
 
122
- def _application_from_strategy(strategy: str) -> str:
123
- strategy = _clean(strategy)
124
- prefix = "waterscope:"
125
- if strategy.startswith(prefix):
126
- return strategy[len(prefix):]
127
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
 
130
  def register_research_synthesis_route(research_bp):
@@ -132,19 +246,14 @@ def register_research_synthesis_route(research_bp):
132
  Register:
133
  POST /api/research/synthesis
134
 
135
- Expected JSON:
136
  {
137
  "document_ids": ["...", "..."],
138
  "question": "...",
139
  "model": "base",
140
- "application": "auto"
 
141
  }
142
-
143
- Optional Space secret:
144
- WATERSCOPE_RESEARCH_TOKEN
145
-
146
- If set, the caller must send:
147
- X-Research-Token: <same value>
148
  """
149
 
150
  @research_bp.post("/synthesis")
@@ -157,22 +266,31 @@ def register_research_synthesis_route(research_bp):
157
 
158
  body = request.get_json(force=True, silent=True) or {}
159
 
160
- question = _clean(body.get("question") or body.get("message"))
 
 
161
  raw_ids = body.get("document_ids") or []
162
 
163
- if not question:
164
- return jsonify({"ok": False, "error": "question is required"}), 400
 
 
165
 
166
  if not isinstance(raw_ids, list):
167
  return jsonify(
168
  {"ok": False, "error": "document_ids must be a list"}
169
  ), 400
170
 
171
- document_ids = [_clean(x) for x in raw_ids if _clean(x)]
 
 
172
 
173
  if not document_ids:
174
  return jsonify(
175
- {"ok": False, "error": "At least one document_id is required"}
 
 
 
176
  ), 400
177
 
178
  if len(document_ids) > MAX_RESEARCH_PAPERS:
@@ -188,114 +306,235 @@ def register_research_synthesis_route(research_bp):
188
 
189
  if len(set(document_ids)) != len(document_ids):
190
  return jsonify(
191
- {"ok": False, "error": "Duplicate document_ids are not allowed"}
 
 
 
192
  ), 400
193
 
194
  application = _clean(body.get("application")) or "auto"
195
  model = _clean(body.get("model")) or None
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  total_start = time.perf_counter()
198
 
199
  try:
200
- # Same production behavior for ordinal-paper questions.
201
- # Part 3 prompts do not use "first paper", "second paper", etc.,
202
- # so formal runs should retain all supplied IDs.
203
- document_ids_used = chat_api.focus_document_ids_for_question(
204
- question,
205
- document_ids,
 
206
  )
207
 
208
- context_start = time.perf_counter()
209
- context, sources = chat_api.build_context_for_documents(
210
- document_ids_used,
211
- question=question,
 
 
 
 
 
 
 
 
212
  )
213
- context_elapsed_s = time.perf_counter() - context_start
214
 
215
  if not sources:
216
- raise ValueError("No summaries found for the selected papers.")
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- # Same production prompt enhancement.
219
- question_to_model = chat_api.enhance_ordinal_question(
220
- question,
221
  sources,
222
  )
223
 
224
- # This is the exact paper context after the same production
225
- # prefix_context() transformation used inside _generate_answer().
226
- model_context = chat_api.prefix_context(context)
227
 
228
- model_start = time.perf_counter()
229
- answer, strategy, diagnostics = chat_api._generate_answer(
230
- question_to_model,
231
- context,
232
- application=application,
233
- model=model,
 
234
  )
235
- model_elapsed_s = time.perf_counter() - model_start
236
 
237
- total_elapsed_s = time.perf_counter() - total_start
 
 
238
 
239
- evidence = _evidence_trace(sources, context)
240
- application_used = _application_from_strategy(strategy)
 
 
 
 
 
 
241
 
242
- # _generate_answer() does not accept a request-level token argument.
243
- # Its agent path uses WATERSCOPE_MAX_TOKENS, defaulting to 1024.
244
- effective_max_new_tokens = int(
245
- os.getenv("WATERSCOPE_MAX_TOKENS", "1024")
246
  )
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  response = {
249
  "ok": True,
250
  "answer": answer,
 
 
 
 
251
  "strategy": strategy,
252
  "application_requested": application,
253
  "application_used": application_used,
254
  "model_requested": model or "",
255
- "max_new_tokens_effective": effective_max_new_tokens,
 
 
 
 
 
 
 
 
 
256
  "document_ids_requested": document_ids,
257
  "document_ids_used": document_ids_used,
258
  "n_papers_requested": len(document_ids),
259
- "n_papers_used": len(sources),
260
  "sources": [
261
  {
262
- "document_id": _clean(s.get("document_id")),
 
 
263
  "title": _clean(s.get("title")),
264
- "file_name": _clean(s.get("file_name")),
 
 
265
  }
266
  for s in sources
267
  ],
268
  "evidence": evidence,
269
- "context_used": context,
270
- "context_used_chars": len(context),
271
- "context_used_words": _word_count(context),
 
 
272
  "model_context": model_context,
273
  "model_context_chars": len(model_context),
274
- "model_context_words": _word_count(model_context),
 
 
 
 
 
 
275
  "question_to_model": question_to_model,
276
- "context_build_elapsed_s": context_elapsed_s,
277
- "model_call_elapsed_s": model_elapsed_s,
278
  "research_total_elapsed_s": total_elapsed_s,
279
- "retrieval": "mcp" if chat_api.use_mcp() else "direct",
 
 
280
  "diagnostics": diagnostics,
281
  "llm_backend": chat_api.llm_backend_name(),
282
  "trace_status": {
283
- "captured_context": bool(context),
284
- "captured_model_context": bool(model_context),
285
- "captured_source_summaries": (
286
- len(evidence) == len(sources)
287
- and all(bool(row["full_summary"]) for row in evidence)
288
  ),
289
- "captured_per_paper_excerpts": (
290
- len(evidence) == len(sources)
291
- and all(bool(row["excerpt_used"]) for row in evidence)
292
  ),
293
- "source_count": len(sources),
294
- "excerpt_count": sum(
295
- bool(row["excerpt_used"]) for row in evidence
 
 
 
 
 
 
 
 
 
 
 
296
  ),
 
297
  "production_chat_limit_unchanged": True,
298
- "research_paper_limit": MAX_RESEARCH_PAPERS,
 
 
 
 
 
299
  },
300
  }
301
 
@@ -314,7 +553,9 @@ def register_research_synthesis_route(research_bp):
314
  return jsonify(
315
  {
316
  "ok": False,
317
- "error": f"{type(exc).__name__}: {exc}",
 
 
318
  "document_ids_requested": document_ids,
319
  }
320
  ), 500
 
1
  """
2
+ WaterScope-AI Part 3 research endpoint — v3
3
 
4
  REPLACE:
5
  metadata_search/research_synthesis.py
6
 
7
+ Research-only changes relative to the deployed production workflow
8
+ ------------------------------------------------------------------
9
+ 1. Up to 20 ordered selected papers.
10
+ 2. FULL stored summary for every selected paper:
11
+ - no per-paper truncation
12
+ - no research-side total input cap
13
+ 3. Output ceiling scales with the number of papers:
14
+ max_words = 300 * N
15
+ The word ceiling is stated explicitly in the prompt.
16
+ 4. Generation token allowance is sized conservatively at:
17
+ max_new_tokens = 2 * max_words = 600 * N
18
+ This is only a token safety allowance; the scientific output ceiling
19
+ remains 300 words per paper.
20
+ 5. Model = explicit request (normally "base").
21
+ 6. Research Task = explicit request (normally "auto"), so normal task
22
+ auto-detection is retained.
23
+ 7. Production /api/chat and its 4-paper limit are NOT changed.
24
+
25
+ Why a cloned generation function is used
26
+ ----------------------------------------
27
+ The deployed production _generate_answer() obtains WATERSCOPE_MAX_TOKENS
28
+ through its agent path rather than accepting a request-level token argument.
29
+
30
+ For the research endpoint only, v3 creates an isolated clone of the production
31
+ generation function (and its imported agent_answer function when present) with
32
+ a request-scoped WATERSCOPE_MAX_TOKENS override. It does NOT mutate os.environ,
33
+ does NOT patch module globals, and therefore does not change production calls.
34
  """
35
 
36
  from __future__ import annotations
37
 
38
+ import math
39
  import os
40
  import time
41
+ import types
42
+ from typing import Any, Callable
43
 
44
  from flask import jsonify, request
45
 
 
47
 
48
 
49
  MAX_RESEARCH_PAPERS = 20
50
+ DEFAULT_MAX_WORDS_PER_PAPER = 300
51
+ DEFAULT_TOKEN_RESERVE_PER_WORD = 2.0
52
 
53
 
54
  def _clean(value: Any) -> str:
 
61
  return len(_clean(text).split())
62
 
63
 
64
+ def _application_from_strategy(strategy: str) -> str:
65
+ strategy = _clean(strategy)
66
+ prefix = "waterscope:"
67
+ if strategy.startswith(prefix):
68
+ return strategy[len(prefix):]
69
+ return ""
70
 
 
71
 
72
+ def _build_full_context(
73
+ sources: list[dict[str, Any]],
74
+ ) -> str:
75
+ """
76
+ Build the model evidence context from the COMPLETE stored summary
77
+ contained in each production source record.
78
 
79
+ The preceding v2 smoke test demonstrated that source["text"] contains
80
+ the complete stored summary while the normal production context contains
81
+ a shorter question-aware excerpt. v3 deliberately uses source["text"].
82
+ """
83
+ blocks: list[str] = []
84
 
85
+ for index, source in enumerate(sources, start=1):
86
+ title = _clean(source.get("title")) or f"Paper {index}"
87
+ file_name = _clean(source.get("file_name"))
88
+ full_summary = _clean(source.get("text"))
89
 
90
+ if not full_summary:
91
+ raise ValueError(
92
+ f"Paper {index} ({_clean(source.get('document_id'))}) "
93
+ "has an empty stored summary."
94
+ )
 
95
 
96
+ header = f"Paper {index}: {title}"
97
+ if file_name:
98
+ header += f"\nFile: {file_name}"
99
 
100
+ blocks.append(f"{header}\n\n{full_summary}")
 
 
 
 
 
101
 
102
+ return "\n\n---\n\n".join(blocks)
103
 
104
 
105
+ def _build_evidence_trace(
106
  sources: list[dict[str, Any]],
 
107
  ) -> list[dict[str, Any]]:
108
+ """
109
+ In v3 the exact evidence supplied for each paper is the full stored
110
+ summary, so excerpt_used == full_summary by design.
111
+ """
112
  rows: list[dict[str, Any]] = []
113
 
114
  for index, source in enumerate(sources, start=1):
115
  full_summary = _clean(source.get("text"))
 
116
 
117
  rows.append(
118
  {
 
123
  "full_summary": full_summary,
124
  "full_summary_chars": len(full_summary),
125
  "full_summary_words": _word_count(full_summary),
126
+ "excerpt_used": full_summary,
127
+ "excerpt_chars": len(full_summary),
128
+ "excerpt_words": _word_count(full_summary),
129
+ "excerpt_truncated_vs_full": False,
 
 
 
 
130
  }
131
  )
132
 
133
  return rows
134
 
135
 
136
+ class _ResearchOSProxy:
137
+ """
138
+ Delegate normal os behavior to the real os module, but override only
139
+ WATERSCOPE_MAX_TOKENS for the cloned research call.
140
+ """
141
+
142
+ def __init__(self, max_new_tokens: int):
143
+ self._max_new_tokens = int(max_new_tokens)
144
+
145
+ def getenv(self, key: str, default: Any = None):
146
+ if key == "WATERSCOPE_MAX_TOKENS":
147
+ return str(self._max_new_tokens)
148
+ return os.getenv(key, default)
149
+
150
+ def __getattr__(self, name: str):
151
+ return getattr(os, name)
152
+
153
+
154
+ def _copy_function(
155
+ fn: Callable,
156
+ globals_dict: dict[str, Any],
157
+ ) -> Callable:
158
+ cloned = types.FunctionType(
159
+ fn.__code__,
160
+ globals_dict,
161
+ name=fn.__name__,
162
+ argdefs=fn.__defaults__,
163
+ closure=fn.__closure__,
164
+ )
165
+ cloned.__kwdefaults__ = getattr(fn, "__kwdefaults__", None)
166
+ cloned.__annotations__ = dict(getattr(fn, "__annotations__", {}))
167
+ cloned.__doc__ = fn.__doc__
168
+ return cloned
169
+
170
+
171
+ def _override_token_globals(
172
+ globals_dict: dict[str, Any],
173
+ max_new_tokens: int,
174
+ ) -> dict[str, Any]:
175
+ """
176
+ Create an isolated globals dictionary for a research-only cloned function.
177
+
178
+ Covers both common implementations:
179
+ - os.getenv("WATERSCOPE_MAX_TOKENS", ...)
180
+ - module-level MAX_*TOKEN* constants
181
+ """
182
+ new_globals = dict(globals_dict)
183
+ new_globals["os"] = _ResearchOSProxy(max_new_tokens)
184
+
185
+ for key, value in list(new_globals.items()):
186
+ upper = key.upper()
187
+ if "MAX" in upper and "TOKEN" in upper:
188
+ if isinstance(value, int):
189
+ new_globals[key] = int(max_new_tokens)
190
+ elif isinstance(value, str) and value.strip().isdigit():
191
+ new_globals[key] = str(int(max_new_tokens))
192
+
193
+ return new_globals
194
+
195
+
196
+ def _research_generate_answer(
197
+ question: str,
198
+ context: str,
199
+ *,
200
+ application: str,
201
+ model: str | None,
202
+ max_new_tokens: int,
203
+ ):
204
+ """
205
+ Execute the SAME deployed production _generate_answer code with an
206
+ isolated, request-scoped token allowance.
207
+
208
+ No production module global or environment variable is changed.
209
+ """
210
+ original_generate = chat_api._generate_answer
211
+
212
+ generate_globals = _override_token_globals(
213
+ original_generate.__globals__,
214
+ max_new_tokens,
215
+ )
216
+
217
+ # _generate_answer normally calls an imported agent_answer function.
218
+ # Clone that function too so its os.getenv / token constants are isolated.
219
+ original_agent_answer = generate_globals.get("agent_answer")
220
+ if isinstance(original_agent_answer, types.FunctionType):
221
+ agent_globals = _override_token_globals(
222
+ original_agent_answer.__globals__,
223
+ max_new_tokens,
224
+ )
225
+ cloned_agent_answer = _copy_function(
226
+ original_agent_answer,
227
+ agent_globals,
228
+ )
229
+ generate_globals["agent_answer"] = cloned_agent_answer
230
+
231
+ cloned_generate = _copy_function(
232
+ original_generate,
233
+ generate_globals,
234
+ )
235
+
236
+ return cloned_generate(
237
+ question,
238
+ context,
239
+ application=application,
240
+ model=model,
241
+ )
242
 
243
 
244
  def register_research_synthesis_route(research_bp):
 
246
  Register:
247
  POST /api/research/synthesis
248
 
249
+ Example request:
250
  {
251
  "document_ids": ["...", "..."],
252
  "question": "...",
253
  "model": "base",
254
+ "application": "auto",
255
+ "max_words_per_paper": 300
256
  }
 
 
 
 
 
 
257
  """
258
 
259
  @research_bp.post("/synthesis")
 
266
 
267
  body = request.get_json(force=True, silent=True) or {}
268
 
269
+ question_original = _clean(
270
+ body.get("question") or body.get("message")
271
+ )
272
  raw_ids = body.get("document_ids") or []
273
 
274
+ if not question_original:
275
+ return jsonify(
276
+ {"ok": False, "error": "question is required"}
277
+ ), 400
278
 
279
  if not isinstance(raw_ids, list):
280
  return jsonify(
281
  {"ok": False, "error": "document_ids must be a list"}
282
  ), 400
283
 
284
+ document_ids = [
285
+ _clean(x) for x in raw_ids if _clean(x)
286
+ ]
287
 
288
  if not document_ids:
289
  return jsonify(
290
+ {
291
+ "ok": False,
292
+ "error": "At least one document_id is required",
293
+ }
294
  ), 400
295
 
296
  if len(document_ids) > MAX_RESEARCH_PAPERS:
 
306
 
307
  if len(set(document_ids)) != len(document_ids):
308
  return jsonify(
309
+ {
310
+ "ok": False,
311
+ "error": "Duplicate document_ids are not allowed",
312
+ }
313
  ), 400
314
 
315
  application = _clean(body.get("application")) or "auto"
316
  model = _clean(body.get("model")) or None
317
 
318
+ try:
319
+ max_words_per_paper = int(
320
+ body.get(
321
+ "max_words_per_paper",
322
+ DEFAULT_MAX_WORDS_PER_PAPER,
323
+ )
324
+ )
325
+ except (TypeError, ValueError):
326
+ return jsonify(
327
+ {
328
+ "ok": False,
329
+ "error": "max_words_per_paper must be an integer",
330
+ }
331
+ ), 400
332
+
333
+ if not 1 <= max_words_per_paper <= 1000:
334
+ return jsonify(
335
+ {
336
+ "ok": False,
337
+ "error": (
338
+ "max_words_per_paper must be between 1 and 1000"
339
+ ),
340
+ }
341
+ ), 400
342
+
343
  total_start = time.perf_counter()
344
 
345
  try:
346
+ # Preserve current production behavior for questions that
347
+ # explicitly refer to an ordinal paper.
348
+ document_ids_used = (
349
+ chat_api.focus_document_ids_for_question(
350
+ question_original,
351
+ document_ids,
352
+ )
353
  )
354
 
355
+ # We use the production context builder ONLY to resolve/load the
356
+ # requested paper summaries and metadata. Its truncated context
357
+ # output is intentionally NOT sent to the model in this experiment.
358
+ load_start = time.perf_counter()
359
+ production_context, sources = (
360
+ chat_api.build_context_for_documents(
361
+ document_ids_used,
362
+ question=question_original,
363
+ )
364
+ )
365
+ source_load_elapsed_s = (
366
+ time.perf_counter() - load_start
367
  )
 
368
 
369
  if not sources:
370
+ raise ValueError(
371
+ "No summaries found for the selected papers."
372
+ )
373
+
374
+ if len(sources) != len(document_ids_used):
375
+ raise ValueError(
376
+ f"Requested {len(document_ids_used)} papers but "
377
+ f"loaded {len(sources)} complete summaries."
378
+ )
379
+
380
+ n_papers = len(sources)
381
+
382
+ # FULL-SUMMARY research context: no truncate_summary result is used.
383
+ full_context = _build_full_context(sources)
384
 
385
+ # Preserve the platform's ordinal-question enhancement.
386
+ question_enhanced = chat_api.enhance_ordinal_question(
387
+ question_original,
388
  sources,
389
  )
390
 
391
+ max_output_words = (
392
+ max_words_per_paper * n_papers
393
+ )
394
 
395
+ # Word ceiling is the scientific constraint.
396
+ length_instruction = (
397
+ "\n\nRESPONSE LENGTH CEILING: "
398
+ f"Do not exceed {max_output_words:,} words total "
399
+ f"({max_words_per_paper} words per supplied paper). "
400
+ "This is a maximum, not a target; be substantially shorter "
401
+ "when the question can be answered completely in fewer words."
402
  )
 
403
 
404
+ question_to_model = (
405
+ question_enhanced + length_instruction
406
+ )
407
 
408
+ # Token allowance is deliberately more generous than the word
409
+ # ceiling so tokenization does not truncate a valid response.
410
+ max_new_tokens_requested = int(
411
+ math.ceil(
412
+ max_output_words
413
+ * DEFAULT_TOKEN_RESERVE_PER_WORD
414
+ )
415
+ )
416
 
417
+ # Record the exact prefixed evidence the production generation
418
+ # path will construct from this full context.
419
+ model_context = chat_api.prefix_context(
420
+ full_context
421
  )
422
 
423
+ model_start = time.perf_counter()
424
+ answer, strategy, diagnostics = (
425
+ _research_generate_answer(
426
+ question_to_model,
427
+ full_context,
428
+ application=application,
429
+ model=model,
430
+ max_new_tokens=max_new_tokens_requested,
431
+ )
432
+ )
433
+ model_call_elapsed_s = (
434
+ time.perf_counter() - model_start
435
+ )
436
+
437
+ total_elapsed_s = (
438
+ time.perf_counter() - total_start
439
+ )
440
+
441
+ evidence = _build_evidence_trace(sources)
442
+ application_used = _application_from_strategy(
443
+ strategy
444
+ )
445
+ answer_words = _word_count(answer)
446
+
447
  response = {
448
  "ok": True,
449
  "answer": answer,
450
+ "answer_words": answer_words,
451
+ "answer_exceeded_word_ceiling": (
452
+ answer_words > max_output_words
453
+ ),
454
  "strategy": strategy,
455
  "application_requested": application,
456
  "application_used": application_used,
457
  "model_requested": model or "",
458
+ "input_mode": "full_stored_summaries",
459
+ "research_input_truncation": False,
460
+ "max_words_per_paper": max_words_per_paper,
461
+ "max_output_words": max_output_words,
462
+ "token_reserve_per_word": (
463
+ DEFAULT_TOKEN_RESERVE_PER_WORD
464
+ ),
465
+ "max_new_tokens_requested": (
466
+ max_new_tokens_requested
467
+ ),
468
  "document_ids_requested": document_ids,
469
  "document_ids_used": document_ids_used,
470
  "n_papers_requested": len(document_ids),
471
+ "n_papers_used": n_papers,
472
  "sources": [
473
  {
474
+ "document_id": _clean(
475
+ s.get("document_id")
476
+ ),
477
  "title": _clean(s.get("title")),
478
+ "file_name": _clean(
479
+ s.get("file_name")
480
+ ),
481
  }
482
  for s in sources
483
  ],
484
  "evidence": evidence,
485
+ "full_context": full_context,
486
+ "full_context_chars": len(full_context),
487
+ "full_context_words": _word_count(
488
+ full_context
489
+ ),
490
  "model_context": model_context,
491
  "model_context_chars": len(model_context),
492
+ "model_context_words": _word_count(
493
+ model_context
494
+ ),
495
+ "production_context_chars_not_used": len(
496
+ production_context or ""
497
+ ),
498
+ "question_original": question_original,
499
  "question_to_model": question_to_model,
500
+ "source_load_elapsed_s": source_load_elapsed_s,
501
+ "model_call_elapsed_s": model_call_elapsed_s,
502
  "research_total_elapsed_s": total_elapsed_s,
503
+ "retrieval": (
504
+ "mcp" if chat_api.use_mcp() else "direct"
505
+ ),
506
  "diagnostics": diagnostics,
507
  "llm_backend": chat_api.llm_backend_name(),
508
  "trace_status": {
509
+ "captured_full_context": bool(
510
+ full_context
 
 
 
511
  ),
512
+ "captured_model_context": bool(
513
+ model_context
 
514
  ),
515
+ "captured_source_summaries": (
516
+ len(evidence) == n_papers
517
+ and all(
518
+ bool(row["full_summary"])
519
+ for row in evidence
520
+ )
521
+ ),
522
+ "all_papers_untruncated": all(
523
+ row[
524
+ "excerpt_truncated_vs_full"
525
+ ] is False
526
+ and row["excerpt_chars"]
527
+ == row["full_summary_chars"]
528
+ for row in evidence
529
  ),
530
+ "source_count": n_papers,
531
  "production_chat_limit_unchanged": True,
532
+ "research_paper_limit": (
533
+ MAX_RESEARCH_PAPERS
534
+ ),
535
+ "token_override_scope": (
536
+ "isolated_function_clone"
537
+ ),
538
  },
539
  }
540
 
 
553
  return jsonify(
554
  {
555
  "ok": False,
556
+ "error": (
557
+ f"{type(exc).__name__}: {exc}"
558
+ ),
559
  "document_ids_requested": document_ids,
560
  }
561
  ), 500