Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app/concepts.py +139 -0
- app/embeddings.py +20 -0
- app/gapmap_service.py +3 -8
- app/main.py +18 -1
- app/models.py +17 -0
- app/pipeline.py +13 -1
- app/repository.py +72 -8
app/concepts.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Concept-vector search: nearest papers to added concepts minus removed ones.
|
| 2 |
+
|
| 3 |
+
The document-level analogue of ``king − man + woman``. Each concept is embedded
|
| 4 |
+
once, the vectors are combined into one direction, and the index is searched by
|
| 5 |
+
nearest neighbour to it. Every match carries its cosine to each supplied concept
|
| 6 |
+
so the answer stays legible: what pulled a paper in, and what should have pushed
|
| 7 |
+
it away. Nothing here scores novelty or likelihood — only cosines and counts.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from app.config import Settings, settings
|
| 17 |
+
from app.embeddings import QueryEmbedder
|
| 18 |
+
from app.gapmap import _bucket, combine
|
| 19 |
+
from app.models import ConceptSearchRequest
|
| 20 |
+
|
| 21 |
+
VERSION = "concepts-v1"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ConceptError(Exception):
|
| 25 |
+
"""A concept search that cannot honestly return matches."""
|
| 26 |
+
|
| 27 |
+
status = 503
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class EmbeddingsUnavailable(ConceptError):
|
| 31 |
+
status = 503
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ConceptsCancelOut(ConceptError):
|
| 35 |
+
status = 422
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _unit(values) -> np.ndarray | None:
|
| 39 |
+
vector = np.asarray(values, dtype=np.float32)
|
| 40 |
+
norm = float(np.linalg.norm(vector))
|
| 41 |
+
return None if norm < 1e-6 else vector / norm
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _source(document: dict) -> str:
|
| 45 |
+
source = document.get("source") or "openalex"
|
| 46 |
+
return "clinicaltrials" if source == "ctgov" else str(source)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ConceptVectorService:
|
| 50 |
+
def __init__(self, repository, config: Settings = settings):
|
| 51 |
+
self.repo = repository
|
| 52 |
+
self.config = config
|
| 53 |
+
self.embedder = QueryEmbedder(config)
|
| 54 |
+
|
| 55 |
+
async def embed(self, text: str) -> list[float] | None:
|
| 56 |
+
return await self.embedder.embed(text)
|
| 57 |
+
|
| 58 |
+
async def resolve(
|
| 59 |
+
self, positive: list[str], negative: list[str]
|
| 60 |
+
) -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
| 61 |
+
"""Embed each distinct concept once and combine them into one direction.
|
| 62 |
+
|
| 63 |
+
The per-term vectors come back with the combination so a caller can
|
| 64 |
+
explain a result against each concept, or reuse the direction for a
|
| 65 |
+
query of its own without paying for the embeddings twice.
|
| 66 |
+
"""
|
| 67 |
+
terms = list(dict.fromkeys([*positive, *negative]))
|
| 68 |
+
try:
|
| 69 |
+
vectors = await asyncio.gather(*(self.embed(term) for term in terms))
|
| 70 |
+
except Exception as exc:
|
| 71 |
+
raise EmbeddingsUnavailable(
|
| 72 |
+
f"The embedding model could not be loaded ({type(exc).__name__}), "
|
| 73 |
+
"so the concepts could not be turned into vectors."
|
| 74 |
+
) from exc
|
| 75 |
+
if any(vector is None for vector in vectors):
|
| 76 |
+
raise EmbeddingsUnavailable(
|
| 77 |
+
"Embeddings are disabled, so the concepts could not be turned into vectors."
|
| 78 |
+
)
|
| 79 |
+
by_term = {
|
| 80 |
+
term: np.asarray(vector, dtype=np.float32) for term, vector in zip(terms, vectors)
|
| 81 |
+
}
|
| 82 |
+
combined = combine(
|
| 83 |
+
[by_term[term] for term in positive], [by_term[term] for term in negative]
|
| 84 |
+
)
|
| 85 |
+
if combined is None:
|
| 86 |
+
raise ConceptsCancelOut(
|
| 87 |
+
"The concepts cancel each other out, so the search has no direction."
|
| 88 |
+
)
|
| 89 |
+
return combined, by_term
|
| 90 |
+
|
| 91 |
+
async def search(self, request: ConceptSearchRequest) -> dict:
|
| 92 |
+
combined, by_term = await self.resolve(request.positive, request.negative)
|
| 93 |
+
filters = (
|
| 94 |
+
request.filters.model_dump()
|
| 95 |
+
if request.filters is not None and request.filters.active
|
| 96 |
+
else None
|
| 97 |
+
)
|
| 98 |
+
hits = await self.repo.knn_studies(
|
| 99 |
+
[float(value) for value in combined], limit=request.limit, filters=filters
|
| 100 |
+
)
|
| 101 |
+
signed = [(term, "positive") for term in request.positive]
|
| 102 |
+
signed += [(term, "negative") for term in request.negative]
|
| 103 |
+
warnings: list[str] = []
|
| 104 |
+
matches = []
|
| 105 |
+
for hit in hits:
|
| 106 |
+
vector = _unit(hit.get("embedding") or [])
|
| 107 |
+
if vector is None and not warnings:
|
| 108 |
+
warnings.append(
|
| 109 |
+
"Some matches were stored without a vector, so their per-concept "
|
| 110 |
+
"cosines are missing."
|
| 111 |
+
)
|
| 112 |
+
matches.append(
|
| 113 |
+
{
|
| 114 |
+
"id": str(hit.get("id", "")),
|
| 115 |
+
"title": hit.get("title", ""),
|
| 116 |
+
"year": hit.get("year") if isinstance(hit.get("year"), int) else None,
|
| 117 |
+
"url": hit.get("url") or "",
|
| 118 |
+
"source": _source(hit),
|
| 119 |
+
"verdict": _bucket(hit),
|
| 120 |
+
"citations": hit.get("cited_by_count") or 0,
|
| 121 |
+
"cosine": round(float(hit.get("cosine", 0.0)), 4),
|
| 122 |
+
"concepts": [
|
| 123 |
+
{
|
| 124 |
+
"text": term,
|
| 125 |
+
"sign": sign,
|
| 126 |
+
"cosine": round(float(vector @ by_term[term]), 4),
|
| 127 |
+
}
|
| 128 |
+
for term, sign in signed
|
| 129 |
+
]
|
| 130 |
+
if vector is not None
|
| 131 |
+
else [],
|
| 132 |
+
}
|
| 133 |
+
)
|
| 134 |
+
return {
|
| 135 |
+
"version": VERSION,
|
| 136 |
+
"concepts": [{"text": term, "sign": sign} for term, sign in signed],
|
| 137 |
+
"matches": matches,
|
| 138 |
+
"warnings": warnings,
|
| 139 |
+
}
|
app/embeddings.py
CHANGED
|
@@ -4,6 +4,7 @@ No hosted embedding endpoint is used. Loading/downloading a model is deferred
|
|
| 4 |
until embeddings are requested, so BM25 and metadata-only operation stay light.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
from functools import lru_cache
|
| 8 |
|
| 9 |
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
@@ -46,3 +47,22 @@ class Embedder:
|
|
| 46 |
@lru_cache(maxsize=4)
|
| 47 |
def get_embedder(model_name: str = DEFAULT_MODEL, device: str = "cpu") -> Embedder:
|
| 48 |
return Embedder(model_name=model_name, device=device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
until embeddings are requested, so BM25 and metadata-only operation stay light.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
import asyncio
|
| 8 |
from functools import lru_cache
|
| 9 |
|
| 10 |
DEFAULT_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
|
|
|
| 47 |
@lru_cache(maxsize=4)
|
| 48 |
def get_embedder(model_name: str = DEFAULT_MODEL, device: str = "cpu") -> Embedder:
|
| 49 |
return Embedder(model_name=model_name, device=device)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class QueryEmbedder:
|
| 53 |
+
"""One query embedding per call, loaded on first use and encoded off the loop.
|
| 54 |
+
|
| 55 |
+
``None`` rather than a zero vector when embeddings are disabled: a caller
|
| 56 |
+
must say it could not embed, not search with a direction it invented.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
def __init__(self, config):
|
| 60 |
+
self.config = config
|
| 61 |
+
self.embedder: Embedder | None = None
|
| 62 |
+
|
| 63 |
+
async def embed(self, text: str) -> list[float] | None:
|
| 64 |
+
if not self.config.embeddings_enabled:
|
| 65 |
+
return None
|
| 66 |
+
if self.embedder is None:
|
| 67 |
+
self.embedder = get_embedder(self.config.embedding_model, self.config.embedding_device)
|
| 68 |
+
return await asyncio.to_thread(self.embedder.embed_query, text)
|
app/gapmap_service.py
CHANGED
|
@@ -14,6 +14,7 @@ import asyncio
|
|
| 14 |
import numpy as np
|
| 15 |
|
| 16 |
from app.config import Settings, settings
|
|
|
|
| 17 |
from app.gapmap import (
|
| 18 |
_bucket,
|
| 19 |
_corpus,
|
|
@@ -71,18 +72,12 @@ class GapMapService:
|
|
| 71 |
def __init__(self, repository, config: Settings = settings):
|
| 72 |
self.repo = repository
|
| 73 |
self.config = config
|
| 74 |
-
self.embedder =
|
| 75 |
self._built: dict | None = None
|
| 76 |
self._lock = asyncio.Lock()
|
| 77 |
|
| 78 |
async def embed(self, text: str) -> list[float] | None:
|
| 79 |
-
|
| 80 |
-
return None
|
| 81 |
-
if self.embedder is None:
|
| 82 |
-
from app.embeddings import get_embedder
|
| 83 |
-
|
| 84 |
-
self.embedder = get_embedder(self.config.embedding_model, self.config.embedding_device)
|
| 85 |
-
return await asyncio.to_thread(self.embedder.embed_query, text)
|
| 86 |
|
| 87 |
async def build(self, refresh: bool = False) -> dict:
|
| 88 |
async with self._lock:
|
|
|
|
| 14 |
import numpy as np
|
| 15 |
|
| 16 |
from app.config import Settings, settings
|
| 17 |
+
from app.embeddings import QueryEmbedder
|
| 18 |
from app.gapmap import (
|
| 19 |
_bucket,
|
| 20 |
_corpus,
|
|
|
|
| 72 |
def __init__(self, repository, config: Settings = settings):
|
| 73 |
self.repo = repository
|
| 74 |
self.config = config
|
| 75 |
+
self.embedder = QueryEmbedder(config)
|
| 76 |
self._built: dict | None = None
|
| 77 |
self._lock = asyncio.Lock()
|
| 78 |
|
| 79 |
async def embed(self, text: str) -> list[float] | None:
|
| 80 |
+
return await self.embedder.embed(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
async def build(self, refresh: bool = False) -> dict:
|
| 83 |
async with self._lock:
|
app/main.py
CHANGED
|
@@ -11,10 +11,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 11 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
|
|
|
|
| 14 |
from app.config import settings
|
| 15 |
from app.gapmap_service import GapMapService
|
| 16 |
from app.llm import LLMService
|
| 17 |
-
from app.models import MapRequest, NoveltyRequest, SearchRequest
|
| 18 |
from app.novelty import NoveltyEngine
|
| 19 |
from app.pipeline import SearchPipeline
|
| 20 |
from app.repository import ElasticRepository
|
|
@@ -30,6 +31,7 @@ async def lifespan(app: FastAPI):
|
|
| 30 |
app.state.pipeline = SearchPipeline(repo, llm)
|
| 31 |
app.state.novelty = NoveltyEngine(llm, repo=repo)
|
| 32 |
app.state.gapmap = GapMapService(repo)
|
|
|
|
| 33 |
yield
|
| 34 |
await app.state.novelty.fulltext.close()
|
| 35 |
await llm.close()
|
|
@@ -170,6 +172,21 @@ async def gap_map(body: MapRequest, request: Request):
|
|
| 170 |
) from None
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
@app.get("/studies/{study_id:path}")
|
| 174 |
@app.get("/api/studies/{study_id:path}", include_in_schema=False)
|
| 175 |
async def study_detail(study_id: str, request: Request):
|
|
|
|
| 11 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
|
| 14 |
+
from app.concepts import ConceptError, ConceptVectorService
|
| 15 |
from app.config import settings
|
| 16 |
from app.gapmap_service import GapMapService
|
| 17 |
from app.llm import LLMService
|
| 18 |
+
from app.models import ConceptSearchRequest, MapRequest, NoveltyRequest, SearchRequest
|
| 19 |
from app.novelty import NoveltyEngine
|
| 20 |
from app.pipeline import SearchPipeline
|
| 21 |
from app.repository import ElasticRepository
|
|
|
|
| 31 |
app.state.pipeline = SearchPipeline(repo, llm)
|
| 32 |
app.state.novelty = NoveltyEngine(llm, repo=repo)
|
| 33 |
app.state.gapmap = GapMapService(repo)
|
| 34 |
+
app.state.concepts = ConceptVectorService(repo)
|
| 35 |
yield
|
| 36 |
await app.state.novelty.fulltext.close()
|
| 37 |
await llm.close()
|
|
|
|
| 172 |
) from None
|
| 173 |
|
| 174 |
|
| 175 |
+
@app.post("/concepts/search")
|
| 176 |
+
@app.post("/api/concepts/search", include_in_schema=False)
|
| 177 |
+
async def concept_search(body: ConceptSearchRequest, request: Request):
|
| 178 |
+
try:
|
| 179 |
+
return await request.app.state.concepts.search(body)
|
| 180 |
+
except ConceptError as exc:
|
| 181 |
+
raise HTTPException(status_code=exc.status, detail=str(exc)) from None
|
| 182 |
+
except Exception as exc:
|
| 183 |
+
logger.error("Concept search failed: %s", type(exc).__name__)
|
| 184 |
+
raise HTTPException(
|
| 185 |
+
status_code=503,
|
| 186 |
+
detail="Elasticsearch could not complete the concept search. Check the backend connection and index setup.",
|
| 187 |
+
) from None
|
| 188 |
+
|
| 189 |
+
|
| 190 |
@app.get("/studies/{study_id:path}")
|
| 191 |
@app.get("/api/studies/{study_id:path}", include_in_schema=False)
|
| 192 |
async def study_detail(study_id: str, request: Request):
|
app/models.py
CHANGED
|
@@ -240,6 +240,23 @@ class MapRequest(BaseModel):
|
|
| 240 |
refresh: bool = False
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
class Claim(BaseModel):
|
| 244 |
"""The testable content of a hypothesis, as retrieval terms and comparable facets."""
|
| 245 |
|
|
|
|
| 240 |
refresh: bool = False
|
| 241 |
|
| 242 |
|
| 243 |
+
ConceptTerm = Annotated[str, Field(min_length=2, max_length=200)]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class ConceptSearchRequest(BaseModel):
|
| 247 |
+
"""Concepts to add and to subtract before the index is searched by nearest neighbour.
|
| 248 |
+
|
| 249 |
+
The negatives are part of the query direction, not a post-filter: a paper is
|
| 250 |
+
pushed away by a subtracted concept rather than removed for mentioning it.
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, allow_inf_nan=False)
|
| 254 |
+
positive: list[ConceptTerm] = Field(min_length=1, max_length=8)
|
| 255 |
+
negative: list[ConceptTerm] = Field(default_factory=list, max_length=8)
|
| 256 |
+
limit: int = Field(default=20, ge=1, le=50)
|
| 257 |
+
filters: SearchFilters | None = None
|
| 258 |
+
|
| 259 |
+
|
| 260 |
class Claim(BaseModel):
|
| 261 |
"""The testable content of a hypothesis, as retrieval terms and comparable facets."""
|
| 262 |
|
app/pipeline.py
CHANGED
|
@@ -14,6 +14,7 @@ from app.llm import LLMService, Usage
|
|
| 14 |
from app.models import Pico, SearchRequest
|
| 15 |
from app.repository import (
|
| 16 |
BUCKETS,
|
|
|
|
| 17 |
ElasticRepository,
|
| 18 |
filter_clauses,
|
| 19 |
lexical_query,
|
|
@@ -116,12 +117,21 @@ def recount(studies: list[dict], sesoi: float, effect_type: str) -> dict:
|
|
| 116 |
"""Bucket counts over screened studies, replacing the keyword-match aggregation."""
|
| 117 |
counts = dict.fromkeys(BUCKETS, 0)
|
| 118 |
reasons = dict.fromkeys(INCONCLUSIVE_REASONS, 0)
|
|
|
|
| 119 |
for study in studies:
|
| 120 |
verdict = assign_bucket(study, sesoi, effect_type)
|
| 121 |
counts[verdict["bucket"]] += 1
|
| 122 |
if verdict["inconclusive_reason"]:
|
| 123 |
reasons[verdict["inconclusive_reason"]] += 1
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
def to_paper(study: dict, sesoi: float, effect_type: str) -> dict:
|
|
@@ -963,6 +973,8 @@ class SearchPipeline:
|
|
| 963 |
"pico": pico.model_dump(),
|
| 964 |
"bucketCounts": aggregation["bucketCounts"],
|
| 965 |
"inconclusiveReasons": aggregation.get("inconclusiveReasons"),
|
|
|
|
|
|
|
| 966 |
"yearCounts": aggregation["yearCounts"],
|
| 967 |
"countScope": count_scope,
|
| 968 |
# Null unless a bound was set, so a result can never look filtered when it is not.
|
|
|
|
| 14 |
from app.models import Pico, SearchRequest
|
| 15 |
from app.repository import (
|
| 16 |
BUCKETS,
|
| 17 |
+
EFFECT_DIRECTIONS,
|
| 18 |
ElasticRepository,
|
| 19 |
filter_clauses,
|
| 20 |
lexical_query,
|
|
|
|
| 117 |
"""Bucket counts over screened studies, replacing the keyword-match aggregation."""
|
| 118 |
counts = dict.fromkeys(BUCKETS, 0)
|
| 119 |
reasons = dict.fromkeys(INCONCLUSIVE_REASONS, 0)
|
| 120 |
+
directions = dict.fromkeys(EFFECT_DIRECTIONS.values(), 0)
|
| 121 |
for study in studies:
|
| 122 |
verdict = assign_bucket(study, sesoi, effect_type)
|
| 123 |
counts[verdict["bucket"]] += 1
|
| 124 |
if verdict["inconclusive_reason"]:
|
| 125 |
reasons[verdict["inconclusive_reason"]] += 1
|
| 126 |
+
if verdict["bucket"] == "effect":
|
| 127 |
+
key = study.get("result_direction") or "unclear"
|
| 128 |
+
directions[EFFECT_DIRECTIONS.get(key, "unclear")] += 1
|
| 129 |
+
return {
|
| 130 |
+
"total": len(studies),
|
| 131 |
+
"bucketCounts": counts,
|
| 132 |
+
"inconclusiveReasons": reasons,
|
| 133 |
+
"effectDirections": directions,
|
| 134 |
+
}
|
| 135 |
|
| 136 |
|
| 137 |
def to_paper(study: dict, sesoi: float, effect_type: str) -> dict:
|
|
|
|
| 973 |
"pico": pico.model_dump(),
|
| 974 |
"bucketCounts": aggregation["bucketCounts"],
|
| 975 |
"inconclusiveReasons": aggregation.get("inconclusiveReasons"),
|
| 976 |
+
# Over the full match set, unlike effectTrend, which covers the studies read.
|
| 977 |
+
"effectDirections": aggregation.get("effectDirections"),
|
| 978 |
"yearCounts": aggregation["yearCounts"],
|
| 979 |
"countScope": count_scope,
|
| 980 |
# Null unless a bound was set, so a result can never look filtered when it is not.
|
app/repository.py
CHANGED
|
@@ -25,6 +25,12 @@ from app.statistics import (
|
|
| 25 |
)
|
| 26 |
|
| 27 |
BUCKETS = ("effect", "credible_null", "reported_null", "inconclusive", "failed", "unreported")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
_CACHE_FIELDS = set(
|
| 29 |
(
|
| 30 |
"extracted_at extraction_version extraction_status extraction_evidence evidence_span "
|
|
@@ -875,16 +881,57 @@ class ElasticRepository:
|
|
| 875 |
"source",
|
| 876 |
],
|
| 877 |
}
|
| 878 |
-
|
| 879 |
-
try:
|
| 880 |
-
result = await self.client.search(**body, source_exclude_vectors=False)
|
| 881 |
-
except (BadRequestError, TypeError):
|
| 882 |
-
self._vector_source_filter = False
|
| 883 |
-
result = await self.client.search(**body)
|
| 884 |
-
else:
|
| 885 |
-
result = await self.client.search(**body)
|
| 886 |
return {"documents": self.hits(result), "corpus": total["count"]}
|
| 887 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
async def retrieve(self, query: dict, vector: list[float] | None) -> tuple[list[dict], str]:
|
| 889 |
base = {"index": self.index, "size": RETRIEVE_LIMIT, "source_excludes": ["embedding"]}
|
| 890 |
# Third ranking signal: citation authority over the same match set.
|
|
@@ -1136,6 +1183,18 @@ class ElasticRepository:
|
|
| 1136 |
},
|
| 1137 |
},
|
| 1138 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1139 |
"spin_candidates": {
|
| 1140 |
"filter": {
|
| 1141 |
"bool": {
|
|
@@ -1157,12 +1216,17 @@ class ElasticRepository:
|
|
| 1157 |
counts[bucket] += row["doc_count"]
|
| 1158 |
if reason:
|
| 1159 |
reasons[reason] += row["doc_count"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
registered = aggs["completed"]["doc_count"]
|
| 1161 |
unreported = aggs["completed"]["missing"]["doc_count"]
|
| 1162 |
return {
|
| 1163 |
"total": result["hits"]["total"]["value"],
|
| 1164 |
"bucketCounts": counts,
|
| 1165 |
"inconclusiveReasons": reasons,
|
|
|
|
| 1166 |
"yearCounts": [
|
| 1167 |
{"year": int(b["key_as_string"][:4]), "count": b["doc_count"]}
|
| 1168 |
for b in aggs["years"]["buckets"]
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
BUCKETS = ("effect", "credible_null", "reported_null", "inconclusive", "failed", "unreported")
|
| 28 |
+
# Direction is a property of an effect, not a bucket: these partition the effect bucket.
|
| 29 |
+
EFFECT_DIRECTIONS = {
|
| 30 |
+
"favours_intervention": "favoursIntervention",
|
| 31 |
+
"favours_comparator": "favoursComparator",
|
| 32 |
+
"unclear": "unclear",
|
| 33 |
+
}
|
| 34 |
_CACHE_FIELDS = set(
|
| 35 |
(
|
| 36 |
"extracted_at extraction_version extraction_status extraction_evidence evidence_span "
|
|
|
|
| 881 |
"source",
|
| 882 |
],
|
| 883 |
}
|
| 884 |
+
result = await self._search_with_vectors(body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
return {"documents": self.hits(result), "corpus": total["count"]}
|
| 886 |
|
| 887 |
+
async def _search_with_vectors(self, body: dict) -> Any:
|
| 888 |
+
"""Search keeping the stored vectors in `_source`, on 9.1 and on 9.2+."""
|
| 889 |
+
if not self._vector_source_filter:
|
| 890 |
+
return await self.client.search(**body)
|
| 891 |
+
try:
|
| 892 |
+
return await self.client.search(**body, source_exclude_vectors=False)
|
| 893 |
+
except (BadRequestError, TypeError):
|
| 894 |
+
self._vector_source_filter = False
|
| 895 |
+
return await self.client.search(**body)
|
| 896 |
+
|
| 897 |
+
async def knn_studies(
|
| 898 |
+
self, vector: list[float], limit: int, filters: dict | None = None
|
| 899 |
+
) -> list[dict]:
|
| 900 |
+
"""Nearest studies to an arbitrary vector, each with its own embedding.
|
| 901 |
+
|
| 902 |
+
The vectors travel back because the caller reports a cosine per concept,
|
| 903 |
+
which cannot be recomputed without them. Elasticsearch scores a `cosine`
|
| 904 |
+
dense vector as `(1 + cos) / 2`, so the score is converted back to the
|
| 905 |
+
cosine rather than reported as an opaque relevance number.
|
| 906 |
+
"""
|
| 907 |
+
await self.ensure_index()
|
| 908 |
+
body = {
|
| 909 |
+
"index": self.index,
|
| 910 |
+
"size": limit,
|
| 911 |
+
"knn": {
|
| 912 |
+
"field": "embedding",
|
| 913 |
+
"query_vector": vector,
|
| 914 |
+
"k": limit,
|
| 915 |
+
"num_candidates": min(10 * limit, 1000),
|
| 916 |
+
"filter": [{"term": {"record_kind": "study"}}, *filter_clauses(filters)],
|
| 917 |
+
},
|
| 918 |
+
"source_includes": [
|
| 919 |
+
"embedding",
|
| 920 |
+
"title",
|
| 921 |
+
"year",
|
| 922 |
+
"url",
|
| 923 |
+
"source",
|
| 924 |
+
"bucket",
|
| 925 |
+
"query_bucket",
|
| 926 |
+
"cited_by_count",
|
| 927 |
+
],
|
| 928 |
+
}
|
| 929 |
+
result = await self._search_with_vectors(body)
|
| 930 |
+
return [
|
| 931 |
+
dict(hit["_source"], id=hit["_id"], cosine=2 * float(hit["_score"]) - 1)
|
| 932 |
+
for hit in result["hits"]["hits"]
|
| 933 |
+
]
|
| 934 |
+
|
| 935 |
async def retrieve(self, query: dict, vector: list[float] | None) -> tuple[list[dict], str]:
|
| 936 |
base = {"index": self.index, "size": RETRIEVE_LIMIT, "source_excludes": ["embedding"]}
|
| 937 |
# Third ranking signal: citation authority over the same match set.
|
|
|
|
| 1183 |
},
|
| 1184 |
},
|
| 1185 |
},
|
| 1186 |
+
"effect_directions": {
|
| 1187 |
+
"filter": {"term": {"query_bucket": "effect"}},
|
| 1188 |
+
"aggs": {
|
| 1189 |
+
"directions": {
|
| 1190 |
+
"terms": {
|
| 1191 |
+
"field": "result_direction",
|
| 1192 |
+
"missing": "unclear",
|
| 1193 |
+
"size": len(EFFECT_DIRECTIONS),
|
| 1194 |
+
}
|
| 1195 |
+
}
|
| 1196 |
+
},
|
| 1197 |
+
},
|
| 1198 |
"spin_candidates": {
|
| 1199 |
"filter": {
|
| 1200 |
"bool": {
|
|
|
|
| 1216 |
counts[bucket] += row["doc_count"]
|
| 1217 |
if reason:
|
| 1218 |
reasons[reason] += row["doc_count"]
|
| 1219 |
+
directions = dict.fromkeys(EFFECT_DIRECTIONS.values(), 0)
|
| 1220 |
+
for row in aggs["effect_directions"]["directions"]["buckets"]:
|
| 1221 |
+
# An unrecognized direction is not a fourth answer; it is one nobody stated.
|
| 1222 |
+
directions[EFFECT_DIRECTIONS.get(row["key"], "unclear")] += row["doc_count"]
|
| 1223 |
registered = aggs["completed"]["doc_count"]
|
| 1224 |
unreported = aggs["completed"]["missing"]["doc_count"]
|
| 1225 |
return {
|
| 1226 |
"total": result["hits"]["total"]["value"],
|
| 1227 |
"bucketCounts": counts,
|
| 1228 |
"inconclusiveReasons": reasons,
|
| 1229 |
+
"effectDirections": directions,
|
| 1230 |
"yearCounts": [
|
| 1231 |
{"year": int(b["key_as_string"][:4]), "count": b["doc_count"]}
|
| 1232 |
for b in aggs["years"]["buckets"]
|