Skip to content

Commit 56f9512

Browse files
committed
Enhance PDF reading capabilities and improve agent response handling
- Introduced a new `PdfReadRequest` model to facilitate explicit PDF read actions from the UI, ensuring structured input for PDF-related queries. - Updated the agent's request handling to incorporate `pdf_read_request`, allowing for better management of user intent regarding PDF reading. - Enhanced the `build_agent_request_turn_context` function to process PDF read requests, integrating them into the agent's decision-making process. - Implemented fallback mechanisms for handling PDF read failures, improving user feedback and citation enrichment based on PDF read results. - Updated various components to support the new PDF reading features, including state management and response generation, ensuring a cohesive user experience.
1 parent 3d2471c commit 56f9512

72 files changed

Lines changed: 2531 additions & 780 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/analysis/external-research-tools-implementation-review-2026-05-15.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,3 +634,15 @@ The architecture is in good shape. The next improvement should be either:
634634

635635
**Execution detail:** Phase 3 **closeout** (optional OpenAlex live smoke, diagnostics `status` decision) and Phase 4 **staged delivery** (PDF artifact pipeline → SSE → trust → Ask UI → live matrix) are spelled out in `docs/analysis/external-research-tools-workplan-2026-05-15.md` (Phase 3 **Remaining / Closeout**, Phase 4 Stages 1–8, PR slicing PR 4b–PR 9).
636636

637+
## Phase 4 honest closure (2026-05-15) — shipped in repo
638+
639+
- **Typed API:** `PdfReadRequest` + relaxed `question` when `pdf_read_request` is present; machine token `__sg_pdf_read_action__` replaces legacy `"[pdf-read-action]"` in the canonical path.
640+
- **Orchestrator:** `pdf_read_orchestrator.execute_pdf_read` is the single fetch/parse/cache path for `read_external_pdf` and optional SSE prefetch (`pdf_read_validating` / `pdf_read_downloading` / `pdf_read_extracting`) before the LangGraph stream.
641+
- **Pipeline/cache:** `pdf_read_pipeline` (policy + fetch + parse), `BoundedTtlPdfReadCache` (LRU + TTL), operator knob `agent_pdf_read_cache_max_entries` (Settings + snapshot + PATCH allowlist).
642+
- **Policy:** `EXTERNAL_RESEARCH_WEB_TOOL_NAMES` vs `read_external_pdf`; explicit `pdf_read_request` bypasses web-research denylist for PDF only; `pdf_reading_mode=off` blocks agent PDF unless explicit request; LLM `allowed_domains` / `blocked_domains` on the tool are ignored (server policy).
643+
- **Evidence:** PDF success uses `evidence_quality=variable`; failed `read_external_pdf` hydrates citations like `web_fetch` / `unpaywall` failures.
644+
- **UI:** Native token + i18n user-turn label; pdf-only submit; product-step strings for PDF prefetch.
645+
- **Live matrix:** Operator checklist in `scripts/live_check/pdf_read_live_matrix.md` (after `make dev-up` + `config-check`).
646+
647+
**Residual:** durable artifact store / DB-backed job IDs beyond in-process cache — see `docs/backlog/refactor-backend.md` for follow-up.
648+

docs/analysis/external-research-tools-workplan-2026-05-15.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,8 @@ flowchart TD
355355

356356
### Acceptance (Phase 4)
357357

358+
**Status (2026-05-15):** Core backend+UI path shipped — typed `pdf_read_request`, `execute_pdf_read` orchestrator, bounded LRU+TTL cache, SSE prefetch steps, denylist/`pdf_reading_mode` alignment, citation hydration on PDF failures, variable evidence quality, Ask UI token+i18n. Durable cross-process artifact persistence remains backlog (see implementation review “Phase 4 honest closure”).
359+
358360
- Explicit PDF read produces visible progress + persisted artifact + answer grounded in extracted evidence with correct trust labels.
359361
- Agent never silently claims full-text reading when extraction did not run or failed.
360362

docs/backlog/refactor-backend.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,9 @@ Sequencing after **SSE stream lifecycle split** (`stream_lifecycle.py` + `stream
240240
- **Acceptance:** httpx-mocked unit tests, manifest/registry sync tests, product_step mapping, documented per-tool denylist/toggle; optional live smoke when key/rate limits allow.
241241
- **Raised:** 2026-05-15, **updated:** 2026-05-15 (OpenAlex search split out as delivered; backlog tracks Semantic Scholar only)
242242

243+
### [OPEN] Durable PDF read artifacts (cross-process)
244+
- **Area:** `science_graphrag/agent/tools/external/pdf_read_*`, future `stores/` or Postgres
245+
- **Issue:** Phase 4 ships in-process LRU+TTL cache and optional prefetch job bookkeeping only; workers lose cache on restart and there is no durable artifact row for audit/replay.
246+
- **Proposal:** add persisted artifact metadata (url hash, sizes, status, excerpt pointer) keyed by `parent_turn_id` / workspace; optional object storage for raw bytes; align GC with operator TTL knobs.
247+
- **Acceptance:** repeat read after cold start hits persisted excerpt or cleanly refetches; trace-review shows stable `artifact_id`; repository unit tests.
248+
- **Raised:** 2026-05-15

docs/specs/frontend-ui-api-contracts-v1.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,3 +442,22 @@ Implementation: [`science_graphrag/api/workspace_graph/router.py`](../../science
442442

443443
- **`progress_pct`**: `float | null` (0..1) — взвешенный прогресс по стадиям (running = 0.5 веса), чтобы UI рисовал общий бар без «рваных» процентов.
444444
- **`stages[i].expected_duration_ms`**: `int | null` — средняя длительность стадии по последним успешным jobs (Postgres), подсказка для ETA на фронте.
445+
446+
## 7) Settings — LLM snapshot (Settings page)
447+
448+
Реализация: `GET/PATCH /v1/settings/llm`, `DELETE /v1/settings/llm/secret`, `DELETE /v1/settings/llm/vision-secret` в [`science_graphrag/api/settings.py`](../../science_graphrag/api/settings.py); снимок материализуется через [`science_graphrag/settings/snapshot_llm.py`](../../science_graphrag/settings/snapshot_llm.py) и [`science_graphrag/settings/llm_runtime.py`](../../science_graphrag/settings/llm_runtime.py).
449+
450+
**`GET /v1/settings`** — в `llm` помимо `status`, `effective`, `advanced_controls` ожидаются:
451+
452+
- **`tasks`**: нормализованные профили задач (`extraction`, `chat`, `vision`, `embeddings`) с полями модели и **masked** метаданными ключа (`source`, опционально `masked`).
453+
- **`diagnostics`**: операторский блок (`operator_env_variables`, `notes`) — для раскрываемой секции «для операторов», не для основного UX.
454+
455+
**`PATCH /v1/settings/llm`** — тело включает обязательные поля провайдера (`base_url`, `model`, `temperature`, `timeout_seconds`) и опционально:
456+
457+
- **`chat_model`**, **`vl_model`**, **`vl_base_url`** — клиент может опускать поля, если persisted значение не меняется (рекомендуемый контракт для UI: не «материализовать» унаследованные из env дефолты в JSON при сохранении несвязанных полей).
458+
- **`api_key`** — сохраняется в server secret store как основной OpenAI-compatible ключ.
459+
- **`vision_api_key`** — отдельный UI-managed ключ для PDF/Vision; пустая строка может трактоваться как сброс override (см. обработку в API).
460+
461+
**`DELETE /v1/settings/llm/vision-secret`** — удаляет только vault-запись vision-ключа; основной `llm.api_key` и прочие поля не трогаются. После удаления effective vision снова наследует env/default по правилам resolver.
462+
463+
Клиент: `ui/src/pages/SettingsPage/settingsApi.js` (`deleteLlmVisionSecret`, `updateLlmSettings`).

science_graphrag/agent/citation_enrichment.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,60 @@ def _attach_unpaywall_lookup_failures_from_messages(
328328
c["fallback_message"] = msg_txt
329329

330330

331+
def _attach_read_external_pdf_failures_from_messages(
332+
citations: list[dict[str, Any]],
333+
messages: list[Any] | None,
334+
) -> None:
335+
"""Attach fallback hints when ``read_external_pdf`` failed (URL-keyed like ``web_fetch``)."""
336+
337+
failures: dict[str, tuple[str, str, str]] = {}
338+
for payload in _iter_tool_payload_dicts(messages, tool_name="read_external_pdf"):
339+
if payload.get("ok") is not False:
340+
continue
341+
err = payload.get("error")
342+
reason = normalize_tool_error_to_fallback_reason(str(err) if err is not None else None)
343+
if not reason:
344+
reason = FALLBACK_UNKNOWN
345+
msg_txt = human_fallback_message(reason)
346+
url = str(payload.get("url") or "").strip()
347+
hint = payload.get("sse_hint")
348+
if isinstance(hint, dict) and not url:
349+
url = str(hint.get("url") or "").strip()
350+
if not url:
351+
continue
352+
failures[_url_match_key(url)] = (reason, msg_txt, url)
353+
354+
if not failures:
355+
return
356+
357+
existing = {_url_match_key(str(c.get("url") or "")) for c in citations if isinstance(c, dict)}
358+
for key, (reason, msg_txt, display_url) in failures.items():
359+
if key and key not in existing:
360+
citations.append(
361+
{
362+
"source_type": "web",
363+
"url": display_url,
364+
"title": display_url,
365+
"source_tool": "read_external_pdf",
366+
"fallback_reason": reason,
367+
"fallback_message": msg_txt,
368+
}
369+
)
370+
existing.add(key)
371+
372+
for c in citations:
373+
if not isinstance(c, dict) or not citation_is_web_evidence(c):
374+
continue
375+
key = _url_match_key(str(c.get("url") or ""))
376+
if not key or key not in failures:
377+
continue
378+
reason, msg_txt, _disp = failures[key]
379+
if not str(c.get("fallback_reason") or "").strip():
380+
c["fallback_reason"] = reason
381+
if not str(c.get("fallback_message") or "").strip():
382+
c["fallback_message"] = msg_txt
383+
384+
331385
def _attach_web_fetch_failures_from_messages(
332386
citations: list[dict[str, Any]],
333387
messages: list[Any] | None,
@@ -581,6 +635,7 @@ def hydrate_citations_for_ui(
581635
merge_paper_profile_abstracts_into_citations(merged, abstracts)
582636
_merge_web_sources_into_citations(merged, list(web_sources or []), max_web=8)
583637
_attach_web_fetch_failures_from_messages(merged, messages)
638+
_attach_read_external_pdf_failures_from_messages(merged, messages)
584639
_attach_unpaywall_lookup_failures_from_messages(merged, messages)
585640
_attach_doi_resolver_provenance_from_messages(merged, messages)
586641
apply_trust_labels_to_citations(merged)

science_graphrag/agent/evidence_trust.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def normalize_tool_error_to_fallback_reason(error: str | None) -> str | None:
121121
"doi_parse_failed": FALLBACK_METADATA_ONLY_FALLBACK,
122122
"invalid_doi": FALLBACK_METADATA_ONLY_FALLBACK,
123123
"host_not_allowed": FALLBACK_HOST_NOT_ALLOWED,
124+
"private_host_not_allowed": FALLBACK_HOST_NOT_ALLOWED,
124125
"unsupported_scheme": FALLBACK_HOST_NOT_ALLOWED,
125126
"redirect_host_not_allowed": FALLBACK_REDIRECT_BLOCKED,
126127
"redirect_unsupported_scheme": FALLBACK_REDIRECT_BLOCKED,
@@ -160,9 +161,7 @@ def human_fallback_message(reason: str | None) -> str:
160161
FALLBACK_PDF_TOO_LARGE: "PDF exceeds configured size limit.",
161162
FALLBACK_PDF_PARSE_FAILED: "PDF text extraction failed.",
162163
FALLBACK_PDF_PAGE_LIMIT: "PDF exceeds configured page limit.",
163-
FALLBACK_UNSUPPORTED_PDF_TEXT: (
164-
"PDF full-text extraction is not available here."
165-
),
164+
FALLBACK_UNSUPPORTED_PDF_TEXT: ("PDF full-text extraction is not available here."),
166165
FALLBACK_UNKNOWN: "A tool step failed; see trace for details.",
167166
}.get(reason, "Evidence step failed; see trace for details.")
168167

@@ -204,7 +203,7 @@ def trust_fields_for_web_source_row(row: dict[str, Any]) -> dict[str, Any]:
204203
if st == "read_external_pdf":
205204
return {
206205
"provenance_kind": PROVENANCE_EXTRACTED_PDF_TEXT,
207-
"evidence_quality": EVIDENCE_STRONG,
206+
"evidence_quality": EVIDENCE_VARIABLE,
208207
"evidence_mode": MODE_PDF_READ,
209208
"is_external": True,
210209
}
@@ -289,7 +288,7 @@ def apply_trust_labels_to_citations(citations: list[dict[str, Any]]) -> None:
289288
or str(c.get("evidence_mode") or "").strip() == MODE_PDF_READ
290289
):
291290
c.setdefault("provenance_kind", PROVENANCE_EXTRACTED_PDF_TEXT)
292-
c.setdefault("evidence_quality", EVIDENCE_STRONG)
291+
c.setdefault("evidence_quality", EVIDENCE_VARIABLE)
293292
c.setdefault("evidence_mode", MODE_PDF_READ)
294293
c.setdefault("is_external", False)
295294
continue

science_graphrag/agent/graph/state.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from langgraph.graph.message import add_messages
1212

1313
from science_graphrag.agent.request_turn_policy import utc_now_iso_z
14+
from science_graphrag.agent.tools.external.pdf_read_pipeline import sanitize_pdf_url_for_prompt
1415
from science_graphrag.config import Settings, get_settings
1516

1617

@@ -135,10 +136,11 @@ def build_initial_agent_state(
135136
if isinstance(pdf_read_request, dict):
136137
pdf_url = str(pdf_read_request.get("pdf_url") or "").strip()
137138
if pdf_url:
139+
safe = sanitize_pdf_url_for_prompt(pdf_url)
138140
pdf_read_block = (
139141
"<pdf_read_request>\n"
140142
"User explicitly requested reading this external PDF via tool call.\n"
141-
f"- pdf_url: {pdf_url}\n"
143+
f"- pdf_url: {safe}\n"
142144
"</pdf_read_request>"
143145
)
144146
rp_block: str | None = None
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Stable client/server contract tokens for explicit PDF-read turns (Ask UI + API v2)."""
2+
3+
from __future__ import annotations
4+
5+
# Machine token persisted as the user "question" for PDF-only turns; UI must not surface raw.
6+
PDF_READ_USER_MESSAGE_TOKEN = "__sg_pdf_read_action__"
7+
8+
# Default excerpt budget for read_external_pdf (tool schema + API prefetch).
9+
# Keep aligned with ReadExternalPdfArgs defaults in pdf_read_tools.
10+
PDF_READ_DEFAULT_EXCERPT_CHARS = 6000
11+
12+
__all__ = ["PDF_READ_DEFAULT_EXCERPT_CHARS", "PDF_READ_USER_MESSAGE_TOKEN"]

science_graphrag/agent/request_turn_policy.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
seed_research_plan_if_empty,
1414
)
1515
from science_graphrag.agent.context.session_backend import get_session_memory_backend
16+
from science_graphrag.agent.pdf_read_contract import PDF_READ_USER_MESSAGE_TOKEN
1617
from science_graphrag.config import Settings
1718
from science_graphrag.external_intent_markers import (
1819
ARXIV_MARKERS,
@@ -30,6 +31,10 @@
3031
"read_external_pdf",
3132
}
3233
)
34+
# External HTTP tools denied when the user disables web research (PDF is gated separately).
35+
EXTERNAL_RESEARCH_WEB_TOOL_NAMES: frozenset[str] = frozenset(
36+
EXTERNAL_RESEARCH_TOOL_NAMES - frozenset({"read_external_pdf"})
37+
)
3338
WEB_RESEARCH_TOOL_NAMES: frozenset[str] = EXTERNAL_RESEARCH_TOOL_NAMES
3439

3540
AgentUiMode = Literal["agent", "plan"]
@@ -66,6 +71,7 @@ def build_agent_request_turn_context(
6671
question: str | None = None,
6772
web_research_enabled: bool | None,
6873
agent_mode: str,
74+
pdf_read_request: dict[str, Any] | None = None,
6975
) -> AgentRequestTurnContext:
7076
"""Apply session plan_mode start-of-turn + compute denylist and transparency metadata."""
7177
mode = (agent_mode or "agent").strip().lower()
@@ -89,14 +95,27 @@ def build_agent_request_turn_context(
8995
default_when_unspecified=bool(settings.external_research_default_enabled),
9096
)
9197
warn_req.extend(compute_request_warnings(settings, web_research_user_enabled=user_web))
92-
tdl = compute_turn_tool_denylist(settings, web_research_user_enabled=user_web)
98+
explicit_pdf = bool(
99+
isinstance(pdf_read_request, dict) and str(pdf_read_request.get("pdf_url") or "").strip()
100+
)
101+
q_raw = str(question or "").strip()
102+
explicit_pdf_only_turn = explicit_pdf and (
103+
not q_raw or q_raw == PDF_READ_USER_MESSAGE_TOKEN or q_raw == "[pdf-read-action]"
104+
)
105+
tdl = compute_turn_tool_denylist(
106+
settings,
107+
web_research_user_enabled=user_web,
108+
explicit_pdf_read_request=explicit_pdf,
109+
explicit_pdf_only_turn=explicit_pdf_only_turn,
110+
)
93111
frag = build_request_run_metadata_fragment(
94112
settings,
95113
web_research_enabled=web_research_enabled,
96114
agent_mode=mode,
97115
turn_tool_denylist=tdl,
98116
request_warnings=warn_req,
99117
external_research_default_enabled=bool(settings.external_research_default_enabled),
118+
request_pdf_reading_mode=str(getattr(settings, "pdf_reading_mode", "ask") or "ask"),
100119
)
101120
return AgentRequestTurnContext(
102121
mode=mode,
@@ -130,13 +149,23 @@ def compute_turn_tool_denylist(
130149
settings: Settings,
131150
*,
132151
web_research_user_enabled: bool,
152+
explicit_pdf_read_request: bool = False,
153+
explicit_pdf_only_turn: bool = False,
133154
) -> list[str]:
134-
"""Tool names denied for this turn when the user disables external HTTP research.
135-
136-
Covers Crossref/arXiv/Unpaywall/OpenAlex tools (see ``EXTERNAL_RESEARCH_TOOL_NAMES``).
137-
"""
138-
_ = settings
139-
return sorted(EXTERNAL_RESEARCH_TOOL_NAMES) if not web_research_user_enabled else []
155+
"""Compute per-turn tool denylist (web research toggle + PDF policy + operator gates)."""
156+
deny: set[str] = set()
157+
if explicit_pdf_only_turn:
158+
deny.update(EXTERNAL_RESEARCH_WEB_TOOL_NAMES)
159+
if not web_research_user_enabled:
160+
deny.update(EXTERNAL_RESEARCH_WEB_TOOL_NAMES)
161+
if not explicit_pdf_read_request:
162+
deny.add("read_external_pdf")
163+
if not bool(getattr(settings, "agent_pdf_read_tool_enabled", True)):
164+
deny.add("read_external_pdf")
165+
pdf_mode = str(getattr(settings, "pdf_reading_mode", "ask") or "ask").strip().lower()
166+
if pdf_mode == "off" and not explicit_pdf_read_request:
167+
deny.add("read_external_pdf")
168+
return sorted(deny)
140169

141170

142171
def compute_request_warnings(
@@ -210,25 +239,30 @@ def build_request_run_metadata_fragment(
210239
turn_tool_denylist: list[str],
211240
request_warnings: list[str],
212241
external_research_default_enabled: bool = True,
242+
request_pdf_reading_mode: str | None = None,
213243
) -> dict[str, Any]:
214244
"""Small fragment merged into run_metadata for UI transparency."""
215245
user_on = effective_web_research_user_enabled(
216246
web_research_enabled,
217247
default_when_unspecified=bool(external_research_default_enabled),
218248
)
219249
_ = settings
220-
return {
250+
out: dict[str, Any] = {
221251
"request_web_research_enabled": web_research_enabled,
222252
"effective_web_research_user_enabled": user_on,
223253
"effective_web_research_tools": bool(user_on),
224254
"request_agent_mode": (agent_mode or "agent").strip().lower(),
225255
"turn_tool_denylist": list(turn_tool_denylist),
226256
"request_turn_warnings": list(request_warnings),
227257
}
258+
if request_pdf_reading_mode:
259+
out["request_pdf_reading_mode"] = str(request_pdf_reading_mode).strip().lower()
260+
return out
228261

229262

230263
__all__ = [
231264
"EXTERNAL_RESEARCH_TOOL_NAMES",
265+
"EXTERNAL_RESEARCH_WEB_TOOL_NAMES",
232266
"WEB_RESEARCH_TOOL_NAMES",
233267
"AgentRequestTurnContext",
234268
"AgentUiMode",

science_graphrag/agent/runtime.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def _run_langgraph( # pylint: disable=too-many-locals
246246
question=question,
247247
web_research_enabled=web_research_enabled,
248248
agent_mode=agent_mode,
249+
pdf_read_request=pdf_read_request,
249250
)
250251
try:
251252
return self._run_langgraph_inner(

0 commit comments

Comments
 (0)