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.
- sci_rag/__init__.py +78 -0
- sci_rag/answer/__init__.py +3 -0
- sci_rag/answer/compress.py +132 -0
- sci_rag/answer/generator.py +341 -0
- sci_rag/campaigns/__init__.py +50 -0
- sci_rag/campaigns/build.py +209 -0
- sci_rag/campaigns/discovery.py +330 -0
- sci_rag/campaigns/download.py +177 -0
- sci_rag/campaigns/http.py +125 -0
- sci_rag/campaigns/licensing_map.py +41 -0
- sci_rag/campaigns/manifest.py +50 -0
- sci_rag/campaigns/prisma.py +85 -0
- sci_rag/campaigns/resolve.py +93 -0
- sci_rag/campaigns/screen.py +474 -0
- sci_rag/campaigns/state.py +126 -0
- sci_rag/citations.py +152 -0
- sci_rag/cli/__init__.py +3 -0
- sci_rag/cli/doctor.py +611 -0
- sci_rag/cli/init.py +133 -0
- sci_rag/cli/main.py +1714 -0
- sci_rag/cli/new.py +126 -0
- sci_rag/config.py +176 -0
- sci_rag/corpus.py +282 -0
- sci_rag/db/__init__.py +28 -0
- sci_rag/db/engine.py +63 -0
- sci_rag/db/models.py +282 -0
- sci_rag/domain.py +212 -0
- sci_rag/embed/__init__.py +30 -0
- sci_rag/embed/cache.py +53 -0
- sci_rag/embed/google.py +106 -0
- sci_rag/embed/local_hash.py +53 -0
- sci_rag/embed/planner.py +174 -0
- sci_rag/embed/provider.py +70 -0
- sci_rag/enrich.py +176 -0
- sci_rag/evals/__init__.py +40 -0
- sci_rag/evals/answer_eval.py +135 -0
- sci_rag/evals/calibration.py +241 -0
- sci_rag/evals/diff.py +255 -0
- sci_rag/evals/judge.py +105 -0
- sci_rag/evals/report.py +291 -0
- sci_rag/evals/retrieval_eval.py +243 -0
- sci_rag/evals/seeds.py +57 -0
- sci_rag/evals/stats.py +142 -0
- sci_rag/graph/__init__.py +25 -0
- sci_rag/graph/communities.py +190 -0
- sci_rag/graph/extractor.py +445 -0
- sci_rag/graph/resolve.py +413 -0
- sci_rag/ingest/__init__.py +26 -0
- sci_rag/ingest/chunker.py +287 -0
- sci_rag/ingest/ingester.py +192 -0
- sci_rag/ingest/manifest.py +75 -0
- sci_rag/ingest/parsers.py +153 -0
- sci_rag/ingest/tokens.py +40 -0
- sci_rag/licensing.py +65 -0
- sci_rag/llm/__init__.py +68 -0
- sci_rag/llm/anthropic.py +193 -0
- sci_rag/llm/client.py +193 -0
- sci_rag/llm/google.py +107 -0
- sci_rag/llm/openai_compat.py +208 -0
- sci_rag/llm/spec.py +64 -0
- sci_rag/py.typed +0 -0
- sci_rag/retrieve/__init__.py +19 -0
- sci_rag/retrieve/fusion.py +39 -0
- sci_rag/retrieve/rerank.py +188 -0
- sci_rag/retrieve/retriever.py +411 -0
- sci_rag/retrieve/router.py +195 -0
- sci_rag/retrieve/stages/__init__.py +15 -0
- sci_rag/retrieve/stages/community.py +43 -0
- sci_rag/retrieve/stages/graph.py +226 -0
- sci_rag/retrieve/stages/hyde.py +59 -0
- sci_rag/retrieve/stages/keyword.py +34 -0
- sci_rag/retrieve/stages/vector.py +31 -0
- sci_rag/retrieve/types.py +167 -0
- sci_rag/scaffold/__init__.py +16 -0
- sci_rag/scaffold/answers.py +163 -0
- sci_rag/scaffold/apply.py +850 -0
- sci_rag/scaffold/fetch.py +161 -0
- sci_rag/scaffold/licenses.py +110 -0
- sci_rag/scaffold/manifests.py +246 -0
- sci_rag/scaffold/naming.py +13 -0
- sci_rag/scaffold/ontology.py +103 -0
- sci_rag/scaffold/questions.py +173 -0
- sci_rag/scaffold/runners.py +345 -0
- sci_rag/scaffold/wizard.py +217 -0
- sci_rag/server/__init__.py +5 -0
- sci_rag/server/app.py +139 -0
- sci_rag/server/auth.py +192 -0
- sci_rag/server/errors.py +90 -0
- sci_rag/server/mcp_server.py +385 -0
- sci_rag/server/routers/__init__.py +6 -0
- sci_rag/server/routers/answer.py +132 -0
- sci_rag/server/routers/documents.py +86 -0
- sci_rag/server/routers/meta.py +45 -0
- sci_rag/server/routers/query.py +88 -0
- sci_rag/server/schemas.py +240 -0
- sci_rag/server/service.py +349 -0
- sci_rag/snapshot.py +111 -0
- sci_rag_kit-0.3.0.dist-info/METADATA +247 -0
- sci_rag_kit-0.3.0.dist-info/RECORD +102 -0
- sci_rag_kit-0.3.0.dist-info/WHEEL +4 -0
- sci_rag_kit-0.3.0.dist-info/entry_points.txt +3 -0
- 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,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
|
+
]
|