sci-rag-kit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. sci_rag/__init__.py +78 -0
  2. sci_rag/answer/__init__.py +3 -0
  3. sci_rag/answer/compress.py +132 -0
  4. sci_rag/answer/generator.py +341 -0
  5. sci_rag/campaigns/__init__.py +50 -0
  6. sci_rag/campaigns/build.py +209 -0
  7. sci_rag/campaigns/discovery.py +330 -0
  8. sci_rag/campaigns/download.py +177 -0
  9. sci_rag/campaigns/http.py +125 -0
  10. sci_rag/campaigns/licensing_map.py +41 -0
  11. sci_rag/campaigns/manifest.py +50 -0
  12. sci_rag/campaigns/prisma.py +85 -0
  13. sci_rag/campaigns/resolve.py +93 -0
  14. sci_rag/campaigns/screen.py +474 -0
  15. sci_rag/campaigns/state.py +126 -0
  16. sci_rag/citations.py +152 -0
  17. sci_rag/cli/__init__.py +3 -0
  18. sci_rag/cli/doctor.py +611 -0
  19. sci_rag/cli/init.py +133 -0
  20. sci_rag/cli/main.py +1714 -0
  21. sci_rag/cli/new.py +126 -0
  22. sci_rag/config.py +176 -0
  23. sci_rag/corpus.py +282 -0
  24. sci_rag/db/__init__.py +28 -0
  25. sci_rag/db/engine.py +63 -0
  26. sci_rag/db/models.py +282 -0
  27. sci_rag/domain.py +212 -0
  28. sci_rag/embed/__init__.py +30 -0
  29. sci_rag/embed/cache.py +53 -0
  30. sci_rag/embed/google.py +106 -0
  31. sci_rag/embed/local_hash.py +53 -0
  32. sci_rag/embed/planner.py +174 -0
  33. sci_rag/embed/provider.py +70 -0
  34. sci_rag/enrich.py +176 -0
  35. sci_rag/evals/__init__.py +40 -0
  36. sci_rag/evals/answer_eval.py +135 -0
  37. sci_rag/evals/calibration.py +241 -0
  38. sci_rag/evals/diff.py +255 -0
  39. sci_rag/evals/judge.py +105 -0
  40. sci_rag/evals/report.py +291 -0
  41. sci_rag/evals/retrieval_eval.py +243 -0
  42. sci_rag/evals/seeds.py +57 -0
  43. sci_rag/evals/stats.py +142 -0
  44. sci_rag/graph/__init__.py +25 -0
  45. sci_rag/graph/communities.py +190 -0
  46. sci_rag/graph/extractor.py +445 -0
  47. sci_rag/graph/resolve.py +413 -0
  48. sci_rag/ingest/__init__.py +26 -0
  49. sci_rag/ingest/chunker.py +287 -0
  50. sci_rag/ingest/ingester.py +192 -0
  51. sci_rag/ingest/manifest.py +75 -0
  52. sci_rag/ingest/parsers.py +153 -0
  53. sci_rag/ingest/tokens.py +40 -0
  54. sci_rag/licensing.py +65 -0
  55. sci_rag/llm/__init__.py +68 -0
  56. sci_rag/llm/anthropic.py +193 -0
  57. sci_rag/llm/client.py +193 -0
  58. sci_rag/llm/google.py +107 -0
  59. sci_rag/llm/openai_compat.py +208 -0
  60. sci_rag/llm/spec.py +64 -0
  61. sci_rag/py.typed +0 -0
  62. sci_rag/retrieve/__init__.py +19 -0
  63. sci_rag/retrieve/fusion.py +39 -0
  64. sci_rag/retrieve/rerank.py +188 -0
  65. sci_rag/retrieve/retriever.py +411 -0
  66. sci_rag/retrieve/router.py +195 -0
  67. sci_rag/retrieve/stages/__init__.py +15 -0
  68. sci_rag/retrieve/stages/community.py +43 -0
  69. sci_rag/retrieve/stages/graph.py +226 -0
  70. sci_rag/retrieve/stages/hyde.py +59 -0
  71. sci_rag/retrieve/stages/keyword.py +34 -0
  72. sci_rag/retrieve/stages/vector.py +31 -0
  73. sci_rag/retrieve/types.py +167 -0
  74. sci_rag/scaffold/__init__.py +16 -0
  75. sci_rag/scaffold/answers.py +163 -0
  76. sci_rag/scaffold/apply.py +850 -0
  77. sci_rag/scaffold/fetch.py +161 -0
  78. sci_rag/scaffold/licenses.py +110 -0
  79. sci_rag/scaffold/manifests.py +246 -0
  80. sci_rag/scaffold/naming.py +13 -0
  81. sci_rag/scaffold/ontology.py +103 -0
  82. sci_rag/scaffold/questions.py +173 -0
  83. sci_rag/scaffold/runners.py +345 -0
  84. sci_rag/scaffold/wizard.py +217 -0
  85. sci_rag/server/__init__.py +5 -0
  86. sci_rag/server/app.py +139 -0
  87. sci_rag/server/auth.py +192 -0
  88. sci_rag/server/errors.py +90 -0
  89. sci_rag/server/mcp_server.py +385 -0
  90. sci_rag/server/routers/__init__.py +6 -0
  91. sci_rag/server/routers/answer.py +132 -0
  92. sci_rag/server/routers/documents.py +86 -0
  93. sci_rag/server/routers/meta.py +45 -0
  94. sci_rag/server/routers/query.py +88 -0
  95. sci_rag/server/schemas.py +240 -0
  96. sci_rag/server/service.py +349 -0
  97. sci_rag/snapshot.py +111 -0
  98. sci_rag_kit-0.3.0.dist-info/METADATA +247 -0
  99. sci_rag_kit-0.3.0.dist-info/RECORD +102 -0
  100. sci_rag_kit-0.3.0.dist-info/WHEEL +4 -0
  101. sci_rag_kit-0.3.0.dist-info/entry_points.txt +3 -0
  102. sci_rag_kit-0.3.0.dist-info/licenses/LICENSE +28 -0
sci_rag/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """sci-rag-kit: a DIY GraphRAG factory for scientific domains.
2
+
3
+ Point it at a folder of papers and reports, and it gives you a grounded,
4
+ citation-backed question-answering system for your field: ingestion with
5
+ structure-aware chunking, a knowledge graph stored natively in Postgres,
6
+ five fused retrieval layers, an honest evaluation harness, and serving
7
+ over REST and MCP.
8
+
9
+ The pieces are importable directly for notebook and library use::
10
+
11
+ from sci_rag import Retriever, AnswerEngine, RetrievalScope
12
+
13
+ retriever = Retriever()
14
+ result = await retriever.retrieve("rice straw availability", profile="deep")
15
+
16
+ Exports resolve lazily (PEP 562), so ``import sci_rag`` stays cheap and
17
+ circular imports are impossible.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from importlib import import_module
23
+ from importlib.metadata import PackageNotFoundError
24
+ from importlib.metadata import version as _installed_version
25
+ from typing import Any
26
+
27
+ try:
28
+ #: Read from the installed distribution rather than declared here.
29
+ #: A hand-maintained copy drifts, and this one had: v0.2.0 shipped
30
+ #: reporting 0.1.0a0. `sci-rag-new` resolves the template tag from this
31
+ #: same metadata, so the two cannot disagree about what version this is.
32
+ __version__ = _installed_version("sci-rag-kit")
33
+ except PackageNotFoundError: # pragma: no cover - only in a bare source tree
34
+ __version__ = "0.0.0+unknown"
35
+
36
+ _EXPORTS: dict[str, str] = {
37
+ # configuration and domain
38
+ "Settings": "sci_rag.config",
39
+ "get_settings": "sci_rag.config",
40
+ "DomainProfile": "sci_rag.domain",
41
+ "load_domain": "sci_rag.domain",
42
+ # ingestion
43
+ "CorpusEntry": "sci_rag.ingest",
44
+ "discover_folder": "sci_rag.ingest",
45
+ "load_manifest": "sci_rag.ingest",
46
+ "ingest_entries": "sci_rag.ingest",
47
+ # providers
48
+ "get_embedder": "sci_rag.embed",
49
+ "get_llm": "sci_rag.llm",
50
+ # graph
51
+ "extract_graph": "sci_rag.graph",
52
+ "build_communities": "sci_rag.graph",
53
+ # retrieval and answering
54
+ "Retriever": "sci_rag.retrieve",
55
+ "RetrievalResult": "sci_rag.retrieve",
56
+ "RetrievalScope": "sci_rag.retrieve",
57
+ "AnswerEngine": "sci_rag.answer",
58
+ # evaluation
59
+ "load_seed_questions": "sci_rag.evals",
60
+ "run_retrieval_eval": "sci_rag.evals",
61
+ "run_answer_eval": "sci_rag.evals",
62
+ # serving
63
+ "create_app": "sci_rag.server",
64
+ "build_mcp_server": "sci_rag.server",
65
+ }
66
+
67
+ __all__ = ["__version__", *sorted(_EXPORTS)]
68
+
69
+
70
+ def __getattr__(name: str) -> Any:
71
+ module_path = _EXPORTS.get(name)
72
+ if module_path is None:
73
+ raise AttributeError(f"module 'sci_rag' has no attribute {name!r}")
74
+ return getattr(import_module(module_path), name)
75
+
76
+
77
+ def __dir__() -> list[str]:
78
+ return sorted(set(globals()) | set(_EXPORTS))
@@ -0,0 +1,3 @@
1
+ from sci_rag.answer.generator import AnswerEngine, AnswerEvent, AnswerResult, SourceCitation
2
+
3
+ __all__ = ["AnswerEngine", "AnswerEvent", "AnswerResult", "SourceCitation"]
@@ -0,0 +1,132 @@
1
+ """Question-aware compression of retrieved chunks before answer generation.
2
+
3
+ The model output is an optimization hint, never new evidence. Valid summaries
4
+ retain the original chunk identity and citation metadata. Missing, malformed,
5
+ failed, or over-budget summaries fall back to the complete retrieved text so a
6
+ provider problem cannot silently erase evidence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, replace
13
+ from typing import Any
14
+
15
+ import structlog
16
+ from pydantic import BaseModel, Field, ValidationError
17
+
18
+ from sci_rag.domain import DomainProfile
19
+ from sci_rag.ingest.tokens import count_tokens
20
+ from sci_rag.llm import LLMClient
21
+ from sci_rag.retrieve import RetrievedItem
22
+
23
+ log = structlog.get_logger(__name__)
24
+
25
+
26
+ class _Snippet(BaseModel):
27
+ index: int = Field(ge=1, strict=True)
28
+ relevance_score: float = Field(ge=0.0, le=1.0, strict=True)
29
+ summary: str = Field(min_length=1, strict=True)
30
+
31
+
32
+ @dataclass
33
+ class CompressionResult:
34
+ items: list[RetrievedItem]
35
+ failure_count: int = 0
36
+ dropped_count: int = 0
37
+
38
+
39
+ class SnippetCompressor:
40
+ """Compress chunk text in one batched JSON call.
41
+
42
+ Community summaries already are compressed, cross-document evidence and
43
+ therefore pass through untouched. The compressor only has authority over
44
+ ordinary chunks returned by retrieval.
45
+ """
46
+
47
+ def __init__(self, domain: DomainProfile, llm: LLMClient) -> None:
48
+ self.domain = domain
49
+ self.llm = llm
50
+
51
+ async def compress(
52
+ self,
53
+ query: str,
54
+ items: list[RetrievedItem],
55
+ *,
56
+ relevance_floor: float,
57
+ max_tokens_per_chunk: int,
58
+ ) -> CompressionResult:
59
+ chunks = [
60
+ (index, item) for index, item in enumerate(items, start=1) if item.kind == "chunk"
61
+ ]
62
+ if not chunks:
63
+ return CompressionResult(items=list(items))
64
+
65
+ try:
66
+ prompt = self.domain.render_prompt(
67
+ "snippet_compression",
68
+ QUERY=query,
69
+ MAX_TOKENS_PER_CHUNK=str(max_tokens_per_chunk),
70
+ CHUNKS_JSON=json.dumps(
71
+ [{"index": index, "text": item.content} for index, item in chunks],
72
+ ensure_ascii=False,
73
+ ),
74
+ )
75
+ payload = await self.llm.generate_json(
76
+ prompt,
77
+ max_tokens=min(8192, max(512, len(chunks) * max_tokens_per_chunk + 256)),
78
+ )
79
+ except Exception as exc:
80
+ log.warning(
81
+ "snippet_compression_failed",
82
+ error=type(exc).__name__,
83
+ chunk_count=len(chunks),
84
+ )
85
+ return CompressionResult(items=list(items), failure_count=len(chunks))
86
+
87
+ snippets = _validated_snippets(payload)
88
+ if snippets is None:
89
+ return CompressionResult(items=list(items), failure_count=len(chunks))
90
+
91
+ by_index: dict[int, _Snippet] = {}
92
+ duplicate_indexes: set[int] = set()
93
+ for snippet in snippets:
94
+ if snippet.index in by_index:
95
+ duplicate_indexes.add(snippet.index)
96
+ by_index[snippet.index] = snippet
97
+
98
+ output: list[RetrievedItem] = []
99
+ failures = 0
100
+ dropped = 0
101
+ chunk_indexes = {index for index, _item in chunks}
102
+ for index, item in enumerate(items, start=1):
103
+ if item.kind != "chunk":
104
+ output.append(item)
105
+ continue
106
+ selected = by_index.get(index)
107
+ if (
108
+ selected is None
109
+ or index in duplicate_indexes
110
+ or not selected.summary.strip()
111
+ or count_tokens(selected.summary.strip()) > max_tokens_per_chunk
112
+ ):
113
+ failures += 1
114
+ output.append(item)
115
+ elif selected.relevance_score < relevance_floor:
116
+ dropped += 1
117
+ else:
118
+ output.append(replace(item, content=selected.summary.strip()))
119
+
120
+ unexpected = set(by_index) - chunk_indexes
121
+ if unexpected:
122
+ log.warning("snippet_compression_ignored_indexes", count=len(unexpected))
123
+ return CompressionResult(items=output, failure_count=failures, dropped_count=dropped)
124
+
125
+
126
+ def _validated_snippets(payload: Any) -> list[_Snippet] | None:
127
+ if not isinstance(payload, dict) or not isinstance(payload.get("snippets"), list):
128
+ return None
129
+ try:
130
+ return [_Snippet.model_validate(value) for value in payload["snippets"]]
131
+ except ValidationError:
132
+ return None
@@ -0,0 +1,341 @@
1
+ """Grounded answer generation with inline citations.
2
+
3
+ The contract with users: every claim in an answer is backed by a numbered
4
+ source the reader can check, and when the corpus does not contain the
5
+ answer, the answer says so instead of improvising. The prompt enforces it,
6
+ and the evaluation harness's blind judge checks it after the fact.
7
+
8
+ Both entry points share the same preparation (retrieve, build the numbered
9
+ source block, render the domain's answer prompt):
10
+
11
+ * :meth:`AnswerEngine.answer` returns the complete result.
12
+ * :meth:`AnswerEngine.answer_stream` yields typed events (retrieval
13
+ progress, text deltas, citations, done) that the REST SSE endpoint and
14
+ MCP tools translate directly.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import AsyncIterator
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ import structlog
25
+
26
+ from sci_rag.config import Settings, get_settings
27
+ from sci_rag.domain import DomainProfile
28
+ from sci_rag.ingest.tokens import count_tokens
29
+ from sci_rag.llm import LLMClient, get_llm
30
+ from sci_rag.retrieve import RetrievalResult, RetrievalScope, Retriever
31
+
32
+ from .compress import SnippetCompressor
33
+
34
+ log = structlog.get_logger(__name__)
35
+
36
+ _CITATION_RE = re.compile(r"\[(\d+)\]")
37
+
38
+
39
+ @dataclass
40
+ class SourceCitation:
41
+ index: int
42
+ kind: str # "chunk" | "community"
43
+ title: str
44
+ citation: str | None
45
+ license_class: str
46
+ document_id: str | None
47
+ chunk_id: str | None
48
+ section_path: str | None
49
+ cited: bool = False
50
+
51
+
52
+ @dataclass
53
+ class AnswerEvent:
54
+ type: str # retrieval_started | retrieval_done | compression_done | generation_started | delta | citations | done | error
55
+ data: dict[str, Any] = field(default_factory=dict)
56
+
57
+
58
+ @dataclass
59
+ class AnswerResult:
60
+ text: str
61
+ sources: list[SourceCitation]
62
+ retrieval: RetrievalResult
63
+ model: str
64
+ prompt_retrieval: RetrievalResult | None = None
65
+ prompt_tokens_before: int = 0
66
+ prompt_tokens_after: int = 0
67
+ compression_failure_count: int = 0
68
+ compression_dropped_count: int = 0
69
+
70
+ @property
71
+ def cited_sources(self) -> list[SourceCitation]:
72
+ return [s for s in self.sources if s.cited]
73
+
74
+
75
+ class AnswerEngine:
76
+ def __init__(
77
+ self,
78
+ *,
79
+ settings: Settings | None = None,
80
+ retriever: Retriever | None = None,
81
+ llm: LLMClient | None = None,
82
+ ) -> None:
83
+ self.settings = settings or get_settings()
84
+ self.retriever = retriever or Retriever(settings=self.settings)
85
+ self.domain: DomainProfile = self.retriever.domain
86
+ self._llm = llm
87
+
88
+ def _resolve_llm(self, api_key_override: str | None = None) -> LLMClient:
89
+ if api_key_override:
90
+ # Bring-your-own-key: a per-request client that is never stored.
91
+ return get_llm(self.settings, api_key_override=api_key_override)
92
+ if self._llm is None:
93
+ self._llm = get_llm(self.settings)
94
+ return self._llm
95
+
96
+ async def answer(
97
+ self,
98
+ query: str,
99
+ *,
100
+ profile: str = "deep",
101
+ limit: int = 8,
102
+ scope: RetrievalScope | None = None,
103
+ api_key_override: str | None = None,
104
+ max_tokens: int = 2048,
105
+ include_compression: bool | None = None,
106
+ ) -> AnswerResult:
107
+ events = self.answer_stream(
108
+ query,
109
+ profile=profile,
110
+ limit=limit,
111
+ scope=scope,
112
+ api_key_override=api_key_override,
113
+ max_tokens=max_tokens,
114
+ include_compression=include_compression,
115
+ )
116
+ text_parts: list[str] = []
117
+ sources: list[SourceCitation] = []
118
+ retrieval: RetrievalResult | None = None
119
+ prompt_retrieval: RetrievalResult | None = None
120
+ model = str(self.settings.model_spec_for("answer"))
121
+ prompt_tokens_before = 0
122
+ prompt_tokens_after = 0
123
+ compression_failure_count = 0
124
+ compression_dropped_count = 0
125
+ async for event in events:
126
+ if event.type == "delta":
127
+ text_parts.append(event.data["text"])
128
+ elif event.type == "retrieval_done":
129
+ retrieval = event.data["_result"]
130
+ elif event.type == "generation_started":
131
+ model = event.data["model"]
132
+ elif event.type == "compression_done":
133
+ prompt_retrieval = event.data["_result"]
134
+ prompt_tokens_before = event.data["prompt_tokens_before"]
135
+ prompt_tokens_after = event.data["prompt_tokens_after"]
136
+ compression_failure_count = event.data["failure_count"]
137
+ compression_dropped_count = event.data["dropped_count"]
138
+ elif event.type == "citations":
139
+ sources = event.data["_sources"]
140
+ elif event.type == "error":
141
+ raise RuntimeError(event.data.get("message", "answer generation failed"))
142
+ assert retrieval is not None
143
+ if prompt_retrieval is None:
144
+ prompt_retrieval = retrieval
145
+ return AnswerResult(
146
+ text="".join(text_parts),
147
+ sources=sources,
148
+ retrieval=retrieval,
149
+ model=model,
150
+ prompt_retrieval=prompt_retrieval,
151
+ prompt_tokens_before=prompt_tokens_before,
152
+ prompt_tokens_after=prompt_tokens_after,
153
+ compression_failure_count=compression_failure_count,
154
+ compression_dropped_count=compression_dropped_count,
155
+ )
156
+
157
+ async def answer_stream(
158
+ self,
159
+ query: str,
160
+ *,
161
+ profile: str = "deep",
162
+ limit: int = 8,
163
+ scope: RetrievalScope | None = None,
164
+ api_key_override: str | None = None,
165
+ max_tokens: int = 2048,
166
+ include_compression: bool | None = None,
167
+ ) -> AsyncIterator[AnswerEvent]:
168
+ yield AnswerEvent(type="retrieval_started", data={"profile": profile})
169
+ if scope is None:
170
+ scope = RetrievalScope(exclude_retracted=True)
171
+ retrieval = await self.retriever.retrieve(query, profile=profile, limit=limit, scope=scope)
172
+ yield AnswerEvent(
173
+ type="retrieval_done",
174
+ data={
175
+ "item_count": len(retrieval.items),
176
+ "degraded_stages": retrieval.degraded_stages,
177
+ "traces": [
178
+ {
179
+ "stage": t.stage,
180
+ "status": t.status,
181
+ "duration_ms": t.duration_ms,
182
+ "candidates": t.candidate_count,
183
+ }
184
+ for t in retrieval.traces
185
+ ],
186
+ "_result": retrieval,
187
+ },
188
+ )
189
+
190
+ if not retrieval.items:
191
+ text = (
192
+ "The knowledge base has no material matching this question within "
193
+ "the allowed scope, so I cannot give a grounded answer."
194
+ )
195
+ yield AnswerEvent(type="delta", data={"text": text})
196
+ yield AnswerEvent(type="citations", data={"citations": [], "_sources": []})
197
+ yield AnswerEvent(type="done", data={"finish_reason": "no_sources"})
198
+ return
199
+
200
+ try:
201
+ llm = self._resolve_llm(api_key_override)
202
+ except Exception as exc:
203
+ yield AnswerEvent(type="error", data={"code": "llm_unavailable", "message": str(exc)})
204
+ return
205
+
206
+ raw_prompt = self._render_answer_prompt(query, retrieval)
207
+ prompt_retrieval = retrieval
208
+ failures = 0
209
+ dropped = 0
210
+ compression_on = (
211
+ include_compression
212
+ if include_compression is not None
213
+ else self.domain.config.compression.enabled
214
+ )
215
+ if compression_on:
216
+ tuning = self.domain.config.compression
217
+ compressed = await SnippetCompressor(self.domain, llm).compress(
218
+ query,
219
+ retrieval.items,
220
+ relevance_floor=tuning.relevance_floor,
221
+ max_tokens_per_chunk=tuning.max_tokens_per_chunk,
222
+ )
223
+ prompt_retrieval = RetrievalResult(
224
+ items=compressed.items,
225
+ traces=retrieval.traces,
226
+ profile=retrieval.profile,
227
+ )
228
+ failures = compressed.failure_count
229
+ dropped = compressed.dropped_count
230
+
231
+ prompt = self._render_answer_prompt(query, prompt_retrieval)
232
+ prompt_tokens_before = count_tokens(raw_prompt)
233
+ prompt_tokens_after = count_tokens(prompt)
234
+ yield AnswerEvent(
235
+ type="compression_done",
236
+ data={
237
+ "enabled": compression_on,
238
+ "source_count_before": len(retrieval.items),
239
+ "source_count_after": len(prompt_retrieval.items),
240
+ "failure_count": failures,
241
+ "dropped_count": dropped,
242
+ "prompt_tokens_before": prompt_tokens_before,
243
+ "prompt_tokens_after": prompt_tokens_after,
244
+ "_result": prompt_retrieval,
245
+ },
246
+ )
247
+
248
+ if not prompt_retrieval.items:
249
+ text = (
250
+ "The retrieved material did not contain evidence relevant enough "
251
+ "to this question, so I cannot give a grounded answer."
252
+ )
253
+ yield AnswerEvent(type="delta", data={"text": text})
254
+ yield AnswerEvent(type="citations", data={"citations": [], "_sources": []})
255
+ yield AnswerEvent(type="done", data={"finish_reason": "no_relevant_sources"})
256
+ return
257
+
258
+ sources = [
259
+ SourceCitation(
260
+ index=i,
261
+ kind=item.kind,
262
+ title=item.title,
263
+ citation=item.citation,
264
+ license_class=item.license_class,
265
+ document_id=item.document_id,
266
+ chunk_id=item.id if item.kind == "chunk" else None,
267
+ section_path=item.section_path,
268
+ )
269
+ for i, item in enumerate(prompt_retrieval.items, start=1)
270
+ ]
271
+ model = llm.describe()
272
+ yield AnswerEvent(type="generation_started", data={"model": model})
273
+
274
+ collected: list[str] = []
275
+ try:
276
+ async for delta in llm.stream(prompt, max_tokens=max_tokens):
277
+ collected.append(delta)
278
+ yield AnswerEvent(type="delta", data={"text": delta})
279
+ except Exception as exc:
280
+ # Full detail goes to the server log; the client gets the class
281
+ # name only, since provider exception text can echo request
282
+ # internals.
283
+ log.warning("answer_generation_failed", error=type(exc).__name__, detail=str(exc)[:500])
284
+ yield AnswerEvent(
285
+ type="error",
286
+ data={
287
+ "code": "generation_failed",
288
+ "message": f"{type(exc).__name__}: generation failed (see server logs)",
289
+ },
290
+ )
291
+ return
292
+
293
+ text = "".join(collected)
294
+ cited_indices = {int(m) for m in _CITATION_RE.findall(text)}
295
+ for source in sources:
296
+ source.cited = source.index in cited_indices
297
+ yield AnswerEvent(
298
+ type="citations",
299
+ data={
300
+ "citations": [
301
+ {
302
+ "index": s.index,
303
+ "kind": s.kind,
304
+ "title": s.title,
305
+ "citation": s.citation,
306
+ "license_class": s.license_class,
307
+ "document_id": s.document_id,
308
+ "chunk_id": s.chunk_id,
309
+ "section_path": s.section_path,
310
+ "cited": s.cited,
311
+ }
312
+ for s in sources
313
+ ],
314
+ "_sources": sources,
315
+ },
316
+ )
317
+ yield AnswerEvent(type="done", data={"finish_reason": "stop"})
318
+
319
+ def _render_answer_prompt(self, query: str, retrieval: RetrievalResult) -> str:
320
+ return self.domain.render_prompt(
321
+ "answer",
322
+ DOMAIN_NAME=self.domain.name,
323
+ QUERY=query,
324
+ SOURCES=format_sources(retrieval),
325
+ )
326
+
327
+
328
+ def format_sources(retrieval: RetrievalResult) -> str:
329
+ """The numbered source block shown to the answer model (and to the blind
330
+ grounding judge, which must see exactly what the assistant saw)."""
331
+ blocks: list[str] = []
332
+ for i, item in enumerate(retrieval.items, start=1):
333
+ header = f"[{i}] {item.title}"
334
+ if item.section_path:
335
+ header += f" ({item.section_path})"
336
+ if item.kind == "community":
337
+ header += " [knowledge graph overview]"
338
+ if item.citation:
339
+ header += f"\nCitation: {item.citation}"
340
+ blocks.append(f"{header}\n{item.content}")
341
+ return "\n\n---\n\n".join(blocks)
@@ -0,0 +1,50 @@
1
+ """Build, screen, and report reproducible scientific-document campaigns."""
2
+
3
+ from sci_rag.campaigns.build import (
4
+ CampaignBuildReport,
5
+ build_campaign,
6
+ load_discovered_candidates,
7
+ )
8
+ from sci_rag.campaigns.discovery import (
9
+ CandidateWork,
10
+ DiscoveryReport,
11
+ discover_by_dois,
12
+ discover_by_topic,
13
+ normalize_doi,
14
+ )
15
+ from sci_rag.campaigns.download import DownloadOutcome, download_pdf, pdf_filename
16
+ from sci_rag.campaigns.licensing_map import license_class_for
17
+ from sci_rag.campaigns.manifest import ManifestItem, write_campaign_manifest
18
+ from sci_rag.campaigns.resolve import OaResolution, resolve_unpaywall
19
+ from sci_rag.campaigns.screen import (
20
+ ScreeningDecision,
21
+ ScreeningReport,
22
+ apply_human_review,
23
+ screen_campaign,
24
+ )
25
+ from sci_rag.campaigns.state import CampaignRecord, CampaignState
26
+
27
+ __all__ = [
28
+ "CampaignBuildReport",
29
+ "CampaignRecord",
30
+ "CampaignState",
31
+ "CandidateWork",
32
+ "DiscoveryReport",
33
+ "DownloadOutcome",
34
+ "ManifestItem",
35
+ "OaResolution",
36
+ "ScreeningDecision",
37
+ "ScreeningReport",
38
+ "apply_human_review",
39
+ "build_campaign",
40
+ "discover_by_dois",
41
+ "discover_by_topic",
42
+ "download_pdf",
43
+ "license_class_for",
44
+ "load_discovered_candidates",
45
+ "normalize_doi",
46
+ "pdf_filename",
47
+ "resolve_unpaywall",
48
+ "screen_campaign",
49
+ "write_campaign_manifest",
50
+ ]