Conradf commited on
Commit
57a9a08
·
verified ·
1 Parent(s): d2f845c

Upload folder using huggingface_hub

Browse files
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 = None
75
  self._built: dict | None = None
76
  self._lock = asyncio.Lock()
77
 
78
  async def embed(self, text: str) -> list[float] | None:
79
- if not self.config.embeddings_enabled:
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
- return {"total": len(studies), "bucketCounts": counts, "inconclusiveReasons": reasons}
 
 
 
 
 
 
 
 
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
- if self._vector_source_filter:
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"]