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

Upload research_synthesis.py

Browse files
Files changed (1) hide show
  1. metadata_search/research_synthesis.py +216 -503
metadata_search/research_synthesis.py CHANGED
@@ -1,417 +1,107 @@
1
  """
2
- Research-only multi-publication synthesis endpoint for WaterScope-AI Search.
3
 
4
- NEW FILE
5
- --------
6
- metadata_search/research_synthesis.py
7
 
8
- Purpose
9
- -------
10
- Expose POST /api/research/synthesis under the existing research Blueprint.
11
- The endpoint reuses the CURRENT production /api/chat view instead of
12
- reimplementing the platform pipeline. It temporarily raises the server-side
13
- selected-paper limit for the research call, instruments the production
14
- context-building functions, and returns an evidence trace for reproducibility.
15
-
16
- The public /api/chat route and its normal paper limits are not changed.
17
-
18
- Expected registration
19
- ---------------------
20
- In metadata_search/research_routes.py, after research_bp is created:
21
 
22
  from metadata_search.research_synthesis import register_research_synthesis_route
23
  register_research_synthesis_route(research_bp)
24
 
25
- The existing research_host_app.py can remain unchanged.
 
 
 
26
 
27
- Optional protection
28
- -------------------
29
- If environment variable WATERSCOPE_RESEARCH_TOKEN is set, requests must send:
 
 
 
30
 
31
- X-Research-Token: <same value>
 
 
32
 
33
- This is recommended for a research endpoint that can invoke the GPU model.
34
  """
35
 
36
  from __future__ import annotations
37
 
38
  import os
39
- import threading
40
  import time
41
- from typing import Any, Callable
42
 
43
- from flask import current_app, jsonify, request
 
 
44
 
45
 
46
  MAX_RESEARCH_PAPERS = 20
47
- _RESEARCH_LOCK = threading.RLock()
48
 
49
 
50
- def _clean_text(value: Any) -> str:
51
  if value is None:
52
  return ""
53
  return str(value).strip()
54
 
55
 
56
  def _word_count(text: str) -> int:
57
- return len(_clean_text(text).split())
58
-
59
-
60
- def _extract_text(value: Any) -> str:
61
- """Best-effort extraction of the main text from a function result."""
62
- if isinstance(value, str):
63
- return value
64
-
65
- if isinstance(value, dict):
66
- preferred = (
67
- "context",
68
- "text",
69
- "content",
70
- "prompt",
71
- "message",
72
- "response",
73
- "answer",
74
- )
75
- for key in preferred:
76
- item = value.get(key)
77
- if isinstance(item, str) and item.strip():
78
- return item
79
-
80
- for item in value.values():
81
- found = _extract_text(item)
82
- if found:
83
- return found
84
-
85
- if isinstance(value, (list, tuple)):
86
- # Prefer the longest string-like element; context builders often return
87
- # (context, metadata) or similar.
88
- candidates = []
89
- for item in value:
90
- found = _extract_text(item)
91
- if found:
92
- candidates.append(found)
93
- if candidates:
94
- return max(candidates, key=len)
95
-
96
- return ""
97
-
98
 
99
- def _extract_answer_from_payload(payload: Any) -> str:
100
- """Best-effort answer extraction from the existing /api/chat response."""
101
- if isinstance(payload, str):
102
- return payload.strip()
103
-
104
- if not isinstance(payload, dict):
105
- return _clean_text(payload)
106
-
107
- for key in (
108
- "response",
109
- "answer",
110
- "text",
111
- "output",
112
- "content",
113
- "assistant_response",
114
- "final_answer",
115
- "message",
116
- ):
117
- value = payload.get(key)
118
-
119
- if isinstance(value, str) and value.strip():
120
- return value.strip()
121
-
122
- if isinstance(value, dict):
123
- for subkey in ("content", "text", "response", "answer"):
124
- subvalue = value.get(subkey)
125
- if isinstance(subvalue, str) and subvalue.strip():
126
- return subvalue.strip()
127
-
128
- # One-level recursive fallback.
129
- for value in payload.values():
130
- if isinstance(value, dict):
131
- found = _extract_answer_from_payload(value)
132
- if found:
133
- return found
134
 
135
- return ""
136
-
137
-
138
- def _normalize_summaries(
139
- raw: Any,
140
- document_ids: list[str],
141
- ) -> list[dict[str, Any]]:
142
  """
143
- Normalize common summaries_for_documents() return shapes.
144
 
145
- Supported examples include:
146
- {document_id: "summary"}
147
- {document_id: {"summary": "...", "title": "..."}}
148
- [{"document_id": "...", "summary": "..."}]
149
- ["summary 1", "summary 2", ...]
150
- """
151
- rows: list[dict[str, Any]] = []
152
 
153
- if isinstance(raw, dict):
154
- # Sometimes the actual mapping is nested under "summaries".
155
- if "summaries" in raw and isinstance(raw["summaries"], (dict, list)):
156
- return _normalize_summaries(raw["summaries"], document_ids)
157
-
158
- for doc_id in document_ids:
159
- value = raw.get(doc_id)
160
- if value is None:
161
- # tolerate non-string keys
162
- value = raw.get(str(doc_id))
163
-
164
- if isinstance(value, dict):
165
- summary = _clean_text(
166
- value.get("summary")
167
- or value.get("text")
168
- or value.get("content")
169
- or value.get("paper_summary")
170
- )
171
- title = _clean_text(value.get("title"))
172
- else:
173
- summary = _clean_text(value)
174
- title = ""
175
-
176
- rows.append(
177
- {
178
- "document_id": str(doc_id),
179
- "title": title,
180
- "full_summary": summary,
181
- }
182
- )
183
- return rows
184
-
185
- if isinstance(raw, (list, tuple)):
186
- # List of dictionaries with IDs.
187
- if raw and all(isinstance(x, dict) for x in raw):
188
- by_id = {}
189
- no_id = []
190
- for item in raw:
191
- doc_id = _clean_text(
192
- item.get("document_id")
193
- or item.get("doc_id")
194
- or item.get("id")
195
- )
196
- if doc_id:
197
- by_id[doc_id] = item
198
- else:
199
- no_id.append(item)
200
-
201
- if by_id:
202
- for doc_id in document_ids:
203
- item = by_id.get(str(doc_id), {})
204
- summary = _clean_text(
205
- item.get("summary")
206
- or item.get("text")
207
- or item.get("content")
208
- or item.get("paper_summary")
209
- )
210
- rows.append(
211
- {
212
- "document_id": str(doc_id),
213
- "title": _clean_text(item.get("title")),
214
- "full_summary": summary,
215
- }
216
- )
217
- return rows
218
-
219
- # No IDs: preserve positional order.
220
- for i, doc_id in enumerate(document_ids):
221
- item = raw[i] if i < len(raw) else {}
222
- rows.append(
223
- {
224
- "document_id": str(doc_id),
225
- "title": _clean_text(item.get("title")),
226
- "full_summary": _clean_text(
227
- item.get("summary")
228
- or item.get("text")
229
- or item.get("content")
230
- or item.get("paper_summary")
231
- ),
232
- }
233
- )
234
- return rows
235
 
236
- # Positional list of strings.
237
- for i, doc_id in enumerate(document_ids):
238
- summary = _clean_text(raw[i]) if i < len(raw) else ""
239
- rows.append(
240
- {
241
- "document_id": str(doc_id),
242
- "title": "",
243
- "full_summary": summary,
244
- }
245
- )
246
- return rows
247
 
248
- # Unknown shape.
249
- for doc_id in document_ids:
250
- rows.append(
251
- {
252
- "document_id": str(doc_id),
253
- "title": "",
254
- "full_summary": "",
255
- }
256
- )
257
- return rows
258
-
259
-
260
- def _find_chat_view():
261
- """
262
- Find the existing production POST /api/chat Flask view by URL rule.
263
- This avoids depending on the Python function name.
264
- """
265
- for rule in current_app.url_map.iter_rules():
266
- if rule.rule.rstrip("/") == "/api/chat" and "POST" in rule.methods:
267
- view = current_app.view_functions.get(rule.endpoint)
268
- if callable(view):
269
- return view, rule.endpoint
270
-
271
- raise RuntimeError("Could not find the production POST /api/chat view.")
272
-
273
-
274
- class _PatchSet:
275
- """Small helper to patch and restore module/function globals safely."""
276
-
277
- def __init__(self):
278
- self._saved: list[tuple[dict[str, Any], str, Any, bool]] = []
279
- self._seen: set[tuple[int, str]] = set()
280
-
281
- def set(self, namespace: dict[str, Any], name: str, value: Any):
282
- key = (id(namespace), name)
283
- if key in self._seen:
284
- namespace[name] = value
285
- return
286
-
287
- self._seen.add(key)
288
- existed = name in namespace
289
- old = namespace.get(name)
290
- self._saved.append((namespace, name, old, existed))
291
- namespace[name] = value
292
-
293
- def restore(self):
294
- for namespace, name, old, existed in reversed(self._saved):
295
- if existed:
296
- namespace[name] = old
297
- else:
298
- namespace.pop(name, None)
299
- self._saved.clear()
300
- self._seen.clear()
301
-
302
-
303
- def _instrument_pipeline(
304
- chat_view: Callable,
305
- trace: dict[str, Any],
306
- patches: _PatchSet,
307
- ):
308
- """
309
- Instrument the same functions used by the production chat view.
310
 
311
- No context-building logic is duplicated here. Wrappers call the original
312
- functions unchanged and only record their inputs/outputs.
 
313
  """
314
- chat_globals = getattr(chat_view, "__globals__", None)
315
- if not isinstance(chat_globals, dict):
316
- return
317
-
318
- # Research-only override of server-side limits during this single call.
319
- for limit_name in ("MAX_CHAT_PAPERS", "MAX_AUTO_PAPERS"):
320
- if limit_name in chat_globals:
321
- trace.setdefault("patched_limits", {})[limit_name] = {
322
- "original": chat_globals[limit_name],
323
- "research": MAX_RESEARCH_PAPERS,
324
- }
325
- patches.set(chat_globals, limit_name, MAX_RESEARCH_PAPERS)
326
-
327
- build_fn = chat_globals.get("build_context_for_documents")
328
- if callable(build_fn):
329
- build_globals = getattr(build_fn, "__globals__", {})
330
-
331
- # Capture source summaries at the exact point used by the context builder.
332
- summaries_fn = build_globals.get("summaries_for_documents")
333
- if callable(summaries_fn):
334
-
335
- def summaries_wrapper(*args, **kwargs):
336
- result = summaries_fn(*args, **kwargs)
337
- trace["summaries_raw"] = result
338
- return result
339
-
340
- patches.set(
341
- build_globals,
342
- "summaries_for_documents",
343
- summaries_wrapper,
344
- )
345
 
346
- # Capture every question-aware summary excerpt actually produced.
347
- truncate_fn = build_globals.get("truncate_summary")
348
- if callable(truncate_fn):
349
 
350
- def truncate_wrapper(*args, **kwargs):
351
- result = truncate_fn(*args, **kwargs)
352
- trace.setdefault("truncate_calls", []).append(
353
- {
354
- "input_text": _extract_text(args[0]) if args else "",
355
- "output_text": _extract_text(result),
356
- }
357
- )
358
- return result
359
-
360
- patches.set(
361
- build_globals,
362
- "truncate_summary",
363
- truncate_wrapper,
364
- )
365
-
366
- def build_wrapper(*args, **kwargs):
367
- result = build_fn(*args, **kwargs)
368
- trace["context_builder_result"] = result
369
- trace["context_used"] = _extract_text(result)
370
- return result
371
-
372
- patches.set(
373
- chat_globals,
374
- "build_context_for_documents",
375
- build_wrapper,
376
- )
377
-
378
- # Capture the final prefixed context/prompt if the production route exposes
379
- # prefix_context in its own globals.
380
- prefix_fn = chat_globals.get("prefix_context")
381
- if callable(prefix_fn):
382
-
383
- def prefix_wrapper(*args, **kwargs):
384
- result = prefix_fn(*args, **kwargs)
385
- trace["prefixed_result"] = result
386
- trace["prefixed_text"] = _extract_text(result)
387
- return result
388
 
389
- patches.set(chat_globals, "prefix_context", prefix_wrapper)
390
 
391
 
392
- def _build_evidence_trace(
393
- document_ids: list[str],
394
- trace: dict[str, Any],
395
  ) -> list[dict[str, Any]]:
396
- summaries = _normalize_summaries(
397
- trace.get("summaries_raw"),
398
- document_ids,
399
- )
400
-
401
- truncate_calls = trace.get("truncate_calls") or []
402
 
403
- evidence = []
404
- for i, row in enumerate(summaries):
405
- excerpt = ""
406
- if i < len(truncate_calls):
407
- excerpt = _clean_text(truncate_calls[i].get("output_text"))
408
 
409
- full_summary = _clean_text(row.get("full_summary"))
410
- evidence.append(
411
  {
412
- "paper_position": i + 1,
413
- "document_id": row["document_id"],
414
- "title": row.get("title", ""),
 
415
  "full_summary": full_summary,
416
  "full_summary_chars": len(full_summary),
417
  "full_summary_words": _word_count(full_summary),
@@ -426,74 +116,63 @@ def _build_evidence_trace(
426
  }
427
  )
428
 
429
- # If summaries were not captured but truncation calls were, still return a
430
- # positional trace rather than losing the evidence information.
431
- if all(not x["full_summary"] for x in evidence) and truncate_calls:
432
- evidence = []
433
- for i, doc_id in enumerate(document_ids):
434
- call = truncate_calls[i] if i < len(truncate_calls) else {}
435
- inp = _clean_text(call.get("input_text"))
436
- out = _clean_text(call.get("output_text"))
437
- evidence.append(
438
- {
439
- "paper_position": i + 1,
440
- "document_id": str(doc_id),
441
- "title": "",
442
- "full_summary": inp,
443
- "full_summary_chars": len(inp),
444
- "full_summary_words": _word_count(inp),
445
- "excerpt_used": out,
446
- "excerpt_chars": len(out),
447
- "excerpt_words": _word_count(out),
448
- "excerpt_truncated_vs_full": (
449
- bool(inp) and bool(out) and len(out) < len(inp)
450
- ),
451
- }
452
- )
453
 
454
- return evidence
 
 
 
 
 
455
 
456
 
457
  def register_research_synthesis_route(research_bp):
458
  """
459
- Register POST /synthesis on the existing research Blueprint.
460
-
461
- With the current research_host_app prefix, the public path is:
462
  POST /api/research/synthesis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  """
464
 
465
- @research_bp.route("/synthesis", methods=["POST"])
466
  def research_synthesis():
467
  expected_token = os.getenv("WATERSCOPE_RESEARCH_TOKEN", "").strip()
468
  if expected_token:
469
- supplied_token = request.headers.get("X-Research-Token", "").strip()
470
- if supplied_token != expected_token:
471
  return jsonify({"ok": False, "error": "Unauthorized"}), 401
472
 
473
- payload = request.get_json(silent=True) or {}
474
- if not isinstance(payload, dict):
475
- return jsonify({"ok": False, "error": "JSON object required."}), 400
476
 
477
- question = _clean_text(
478
- payload.get("question") or payload.get("message")
479
- )
480
- document_ids_raw = payload.get("document_ids") or []
481
 
482
  if not question:
483
- return jsonify({"ok": False, "error": "question is required."}), 400
484
 
485
- if not isinstance(document_ids_raw, list):
486
  return jsonify(
487
- {"ok": False, "error": "document_ids must be a list."}
488
  ), 400
489
 
490
- document_ids = [
491
- _clean_text(x) for x in document_ids_raw if _clean_text(x)
492
- ]
493
 
494
  if not document_ids:
495
  return jsonify(
496
- {"ok": False, "error": "At least one document_id is required."}
497
  ), 400
498
 
499
  if len(document_ids) > MAX_RESEARCH_PAPERS:
@@ -509,99 +188,133 @@ def register_research_synthesis_route(research_bp):
509
 
510
  if len(set(document_ids)) != len(document_ids):
511
  return jsonify(
512
- {
513
- "ok": False,
514
- "error": "Duplicate document_ids are not allowed.",
515
- }
516
  ), 400
517
 
518
- # Pass through the same fields that the production chat route expects.
519
- chat_payload = dict(payload)
520
- chat_payload["message"] = question
521
- chat_payload["document_ids"] = document_ids
522
- chat_payload.pop("question", None)
523
-
524
- trace: dict[str, Any] = {}
525
- patches = _PatchSet()
526
- t0 = time.perf_counter()
527
-
528
- # One research request at a time. This keeps trace capture deterministic
529
- # and minimizes the window in which production globals are patched.
530
- with _RESEARCH_LOCK:
531
- try:
532
- chat_view, chat_endpoint = _find_chat_view()
533
- trace["chat_endpoint"] = chat_endpoint
534
-
535
- _instrument_pipeline(chat_view, trace, patches)
536
-
537
- # Create a nested /api/chat request so the production view sees
538
- # exactly the payload it normally receives.
539
- with current_app.test_request_context(
540
- "/api/chat",
541
- method="POST",
542
- json=chat_payload,
543
- headers={
544
- "X-Research-Internal": "1",
545
- },
546
- ):
547
- raw_result = chat_view()
548
- flask_response = current_app.make_response(raw_result)
549
-
550
- status_code = int(flask_response.status_code)
551
- production_payload = flask_response.get_json(silent=True)
552
-
553
- if production_payload is None:
554
- production_payload = {
555
- "raw_text": flask_response.get_data(as_text=True)
556
- }
557
 
558
- except Exception as exc:
559
- return jsonify(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  {
561
- "ok": False,
562
- "error": f"Research synthesis failed: {exc}",
 
563
  }
564
- ), 500
565
- finally:
566
- patches.restore()
567
-
568
- total_elapsed_s = time.perf_counter() - t0
569
- answer = _extract_answer_from_payload(production_payload)
570
- evidence = _build_evidence_trace(document_ids, trace)
571
-
572
- context_used = _clean_text(trace.get("context_used"))
573
- prefixed_text = _clean_text(trace.get("prefixed_text"))
574
-
575
- response = {
576
- "ok": 200 <= status_code < 300,
577
- "production_status_code": status_code,
578
- "question": question,
579
- "document_ids_requested": document_ids,
580
- "n_papers_requested": len(document_ids),
581
- "answer": answer,
582
- "evidence": evidence,
583
- "context_used": context_used,
584
- "context_used_chars": len(context_used),
585
- "context_used_words": _word_count(context_used),
586
- "prefixed_text": prefixed_text,
587
- "prefixed_text_chars": len(prefixed_text),
588
- "research_total_elapsed_s": total_elapsed_s,
589
- "trace_status": {
590
- "captured_source_summaries": any(
591
- x.get("full_summary") for x in evidence
592
- ),
593
- "captured_per_paper_excerpts": any(
594
- x.get("excerpt_used") for x in evidence
595
- ),
596
- "captured_context": bool(context_used),
597
- "captured_prefixed_text": bool(prefixed_text),
598
- "truncate_call_count": len(
599
- trace.get("truncate_calls") or []
600
- ),
601
- "patched_limits": trace.get("patched_limits", {}),
602
- },
603
- "production_response": production_payload,
604
- }
 
 
 
 
 
605
 
606
- # Preserve the production HTTP status when it is an error.
607
- return jsonify(response), (200 if response["ok"] else status_code)
 
 
 
 
 
 
 
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
+
39
+ import metadata_search.chat_api as chat_api
40
 
41
 
42
  MAX_RESEARCH_PAPERS = 20
 
43
 
44
 
45
+ def _clean(value: Any) -> str:
46
  if value is None:
47
  return ""
48
  return str(value).strip()
49
 
50
 
51
  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
  {
101
+ "paper_position": index,
102
+ "document_id": _clean(source.get("document_id")),
103
+ "title": _clean(source.get("title")),
104
+ "file_name": _clean(source.get("file_name")),
105
  "full_summary": full_summary,
106
  "full_summary_chars": len(full_summary),
107
  "full_summary_words": _word_count(full_summary),
 
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):
131
  """
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")
151
  def research_synthesis():
152
  expected_token = os.getenv("WATERSCOPE_RESEARCH_TOKEN", "").strip()
153
  if expected_token:
154
+ supplied = request.headers.get("X-Research-Token", "").strip()
155
+ if supplied != expected_token:
156
  return jsonify({"ok": False, "error": "Unauthorized"}), 401
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
 
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
+
302
+ return jsonify(response)
303
+
304
+ except (TypeError, ValueError) as exc:
305
+ return jsonify(
306
+ {
307
+ "ok": False,
308
+ "error": str(exc),
309
+ "document_ids_requested": document_ids,
310
+ }
311
+ ), 400
312
 
313
+ except Exception as exc:
314
+ return jsonify(
315
+ {
316
+ "ok": False,
317
+ "error": f"{type(exc).__name__}: {exc}",
318
+ "document_ids_requested": document_ids,
319
+ }
320
+ ), 500