Spaces:
Sleeping
Sleeping
Upload research_synthesis.py
Browse files- metadata_search/research_synthesis.py +376 -135
metadata_search/research_synthesis.py
CHANGED
|
@@ -1,38 +1,45 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
REPLACE:
|
| 5 |
metadata_search/research_synthesis.py
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
"""
|
| 30 |
|
| 31 |
from __future__ import annotations
|
| 32 |
|
|
|
|
| 33 |
import os
|
| 34 |
import time
|
| 35 |
-
|
|
|
|
| 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
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
metadata_search.mcp_retrieval builds each context block as:
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
return []
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
|
|
|
| 77 |
|
| 78 |
-
|
| 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
|
| 86 |
|
| 87 |
|
| 88 |
-
def
|
| 89 |
sources: list[dict[str, Any]],
|
| 90 |
-
context: str,
|
| 91 |
) -> list[dict[str, Any]]:
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 109 |
-
"excerpt_chars": len(
|
| 110 |
-
"excerpt_words": _word_count(
|
| 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 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 161 |
raw_ids = body.get("document_ids") or []
|
| 162 |
|
| 163 |
-
if not
|
| 164 |
-
return jsonify(
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
| 172 |
|
| 173 |
if not document_ids:
|
| 174 |
return jsonify(
|
| 175 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 201 |
-
#
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
| 206 |
)
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
)
|
| 213 |
-
context_elapsed_s = time.perf_counter() - context_start
|
| 214 |
|
| 215 |
if not sources:
|
| 216 |
-
raise ValueError(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
#
|
| 219 |
-
|
| 220 |
-
|
| 221 |
sources,
|
| 222 |
)
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
| 234 |
)
|
| 235 |
-
model_elapsed_s = time.perf_counter() - model_start
|
| 236 |
|
| 237 |
-
|
|
|
|
|
|
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
-
#
|
| 243 |
-
#
|
| 244 |
-
|
| 245 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
"document_ids_requested": document_ids,
|
| 257 |
"document_ids_used": document_ids_used,
|
| 258 |
"n_papers_requested": len(document_ids),
|
| 259 |
-
"n_papers_used":
|
| 260 |
"sources": [
|
| 261 |
{
|
| 262 |
-
"document_id": _clean(
|
|
|
|
|
|
|
| 263 |
"title": _clean(s.get("title")),
|
| 264 |
-
"file_name": _clean(
|
|
|
|
|
|
|
| 265 |
}
|
| 266 |
for s in sources
|
| 267 |
],
|
| 268 |
"evidence": evidence,
|
| 269 |
-
"
|
| 270 |
-
"
|
| 271 |
-
"
|
|
|
|
|
|
|
| 272 |
"model_context": model_context,
|
| 273 |
"model_context_chars": len(model_context),
|
| 274 |
-
"model_context_words": _word_count(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
"question_to_model": question_to_model,
|
| 276 |
-
"
|
| 277 |
-
"model_call_elapsed_s":
|
| 278 |
"research_total_elapsed_s": total_elapsed_s,
|
| 279 |
-
"retrieval":
|
|
|
|
|
|
|
| 280 |
"diagnostics": diagnostics,
|
| 281 |
"llm_backend": chat_api.llm_backend_name(),
|
| 282 |
"trace_status": {
|
| 283 |
-
"
|
| 284 |
-
|
| 285 |
-
"captured_source_summaries": (
|
| 286 |
-
len(evidence) == len(sources)
|
| 287 |
-
and all(bool(row["full_summary"]) for row in evidence)
|
| 288 |
),
|
| 289 |
-
"
|
| 290 |
-
|
| 291 |
-
and all(bool(row["excerpt_used"]) for row in evidence)
|
| 292 |
),
|
| 293 |
-
"
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
),
|
|
|
|
| 297 |
"production_chat_limit_unchanged": True,
|
| 298 |
-
"research_paper_limit":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
},
|
| 300 |
}
|
| 301 |
|
|
@@ -314,7 +553,9 @@ def register_research_synthesis_route(research_bp):
|
|
| 314 |
return jsonify(
|
| 315 |
{
|
| 316 |
"ok": False,
|
| 317 |
-
"error":
|
|
|
|
|
|
|
| 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
|