groundworkers 0.1.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.
- groundworkers/__init__.py +3 -0
- groundworkers/adapters/__init__.py +1 -0
- groundworkers/adapters/omop_emb.py +251 -0
- groundworkers/adapters/omop_graph.py +721 -0
- groundworkers/adapters/omop_vocab.py +582 -0
- groundworkers/base/__init__.py +17 -0
- groundworkers/base/errors.py +19 -0
- groundworkers/base/results.py +38 -0
- groundworkers/base/server.py +52 -0
- groundworkers/base/sql.py +109 -0
- groundworkers/config.py +139 -0
- groundworkers/server.py +127 -0
- groundworkers/tools/__init__.py +1 -0
- groundworkers/tools/concept_tools.py +237 -0
- groundworkers/tools/embedding_tools.py +83 -0
- groundworkers/tools/resolver_tools.py +90 -0
- groundworkers/tools/search_tools.py +163 -0
- groundworkers/tools/system_tools.py +67 -0
- groundworkers-0.1.0.dist-info/METADATA +116 -0
- groundworkers-0.1.0.dist-info/RECORD +23 -0
- groundworkers-0.1.0.dist-info/WHEEL +5 -0
- groundworkers-0.1.0.dist-info/entry_points.txt +2 -0
- groundworkers-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Direct ORM-backed vocabulary query primitives for agent-composable concept grounding.
|
|
3
|
+
|
|
4
|
+
EXTRACTION NOTE
|
|
5
|
+
---------------
|
|
6
|
+
This module is a stop-gap implementation inside groundworkers while related changes
|
|
7
|
+
settle in omop-graph (open PRs in flight). It is deliberately written with zero
|
|
8
|
+
groundworkers-specific dependencies so it can be extracted to omop-graph (suggested
|
|
9
|
+
path: omop_graph/graph/search.py) or a standalone omop-search package with
|
|
10
|
+
minimal friction.
|
|
11
|
+
|
|
12
|
+
Extraction checklist:
|
|
13
|
+
[ ] No imports from groundworkers.* (verified — none exist)
|
|
14
|
+
[ ] OmopVocabError: replace with the target package's exception type,
|
|
15
|
+
or retain as a thin domain exception and re-export from the package root
|
|
16
|
+
[ ] No MCP protocol concerns (error codes, tool names, server wiring) in here
|
|
17
|
+
[ ] Move file; add to target package __init__.py exports
|
|
18
|
+
[ ] Remove this docstring block
|
|
19
|
+
|
|
20
|
+
Context: omop_graph.reasoning.concept_handlers.concept_helpers.standardise_ids
|
|
21
|
+
raises NotImplementedError — the navigate_to_standard method here fills that gap.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
from omop_alchemy.cdm.model.vocabulary import (
|
|
29
|
+
Concept,
|
|
30
|
+
Concept_Relationship,
|
|
31
|
+
Concept_Synonym,
|
|
32
|
+
)
|
|
33
|
+
from sqlalchemy import column as sa_col
|
|
34
|
+
from sqlalchemy import func, inspect as sa_inspect, select
|
|
35
|
+
from sqlalchemy.engine import Engine
|
|
36
|
+
from sqlalchemy.orm import sessionmaker
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Domain exception — no dependency on groundworkers error types
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
class OmopVocabError(Exception):
|
|
44
|
+
"""Raised by OmopVocabAdapter for query or backend errors.
|
|
45
|
+
|
|
46
|
+
Callers (e.g. MCP tool registrations) are responsible for wrapping this
|
|
47
|
+
into their own error representation. This class intentionally has no
|
|
48
|
+
knowledge of GroundworkersError or any MCP protocol type.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Return-type dataclasses — portable; no MCP types
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ConceptMatch:
|
|
58
|
+
"""A single candidate returned by search_exact or search_fulltext."""
|
|
59
|
+
concept_id: int
|
|
60
|
+
concept_name: str
|
|
61
|
+
concept_code: str
|
|
62
|
+
vocabulary_id: str
|
|
63
|
+
domain_id: str
|
|
64
|
+
concept_class_id: str
|
|
65
|
+
standard_concept: bool
|
|
66
|
+
invalid_reason: str | None
|
|
67
|
+
match_source: str # "name" | "synonym"
|
|
68
|
+
matched_synonym: str | None = None
|
|
69
|
+
ts_rank: float | None = None # populated only by search_fulltext
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class MappedConcept:
|
|
74
|
+
"""A standard concept that a source concept maps to."""
|
|
75
|
+
concept_id: int
|
|
76
|
+
concept_name: str
|
|
77
|
+
vocabulary_id: str
|
|
78
|
+
domain_id: str
|
|
79
|
+
concept_class_id: str
|
|
80
|
+
relationship_id: str # e.g. "Maps to" or "self" when already standard
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class StandardMapping:
|
|
85
|
+
"""Navigation result for a single source concept_id."""
|
|
86
|
+
source_concept_id: int
|
|
87
|
+
source_concept_name: str
|
|
88
|
+
source_standard_concept: bool
|
|
89
|
+
standard_concepts: list[MappedConcept] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Adapter
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class OmopVocabAdapter:
|
|
97
|
+
"""
|
|
98
|
+
Vocabulary query primitives backed directly by omop-alchemy ORM queries.
|
|
99
|
+
|
|
100
|
+
These are the low-level operations that an agent (or a grounding pipeline)
|
|
101
|
+
can compose to find and navigate OMOP standard concepts. They deliberately
|
|
102
|
+
expose raw quality signals (ts_rank, standard_concept flag) rather than
|
|
103
|
+
making quality decisions internally — that is the caller's responsibility.
|
|
104
|
+
|
|
105
|
+
Three operations:
|
|
106
|
+
search_exact — case-insensitive exact name / synonym match
|
|
107
|
+
search_fulltext — PostgreSQL FTS with ts_rank exposed; graceful
|
|
108
|
+
degradation when tsvector sidecar absent
|
|
109
|
+
navigate_to_standard — batch concept_id → standard equivalents via
|
|
110
|
+
"Maps to" relationship edges
|
|
111
|
+
|
|
112
|
+
Raises OmopVocabError for database / query errors.
|
|
113
|
+
Raises ValueError for invalid arguments.
|
|
114
|
+
Never raises GroundworkersError.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# The OMOP relationship_id(s) that express cross-vocabulary standard mapping.
|
|
118
|
+
# "Maps to" is the primary identity relationship in all Athena vocabulary releases.
|
|
119
|
+
IDENTITY_RELATIONSHIP_IDS: frozenset[str] = frozenset({"Maps to"})
|
|
120
|
+
|
|
121
|
+
def __init__(self, engine: Engine) -> None:
|
|
122
|
+
self._engine = engine
|
|
123
|
+
self._session_factory = sessionmaker(engine)
|
|
124
|
+
# Sidecar column detection is lazy and cached after the first call.
|
|
125
|
+
self._fts_name_sidecar: bool | None = None
|
|
126
|
+
self._fts_synonym_sidecar: bool | None = None
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# FTS sidecar detection
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _detect_fts_sidecars(self) -> None:
|
|
133
|
+
"""Detect and cache tsvector sidecar column presence (runs once)."""
|
|
134
|
+
if self._fts_name_sidecar is not None:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
inspector = sa_inspect(self._engine)
|
|
138
|
+
concept_cols = {c["name"] for c in inspector.get_columns("concept")}
|
|
139
|
+
synonym_cols = {c["name"] for c in inspector.get_columns("concept_synonym")}
|
|
140
|
+
self._fts_name_sidecar = "concept_name_tsvector" in concept_cols
|
|
141
|
+
self._fts_synonym_sidecar = "concept_synonym_name_tsvector" in synonym_cols
|
|
142
|
+
except Exception:
|
|
143
|
+
self._fts_name_sidecar = False
|
|
144
|
+
self._fts_synonym_sidecar = False
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def fts_available(self) -> bool:
|
|
148
|
+
"""True when the concept_name_tsvector sidecar column is present."""
|
|
149
|
+
self._detect_fts_sidecars()
|
|
150
|
+
return bool(self._fts_name_sidecar)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# search_exact
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def search_exact(
|
|
157
|
+
self,
|
|
158
|
+
query: str,
|
|
159
|
+
*,
|
|
160
|
+
domain: str | None = None,
|
|
161
|
+
vocabulary_id: str | None = None,
|
|
162
|
+
standard_only: bool = False,
|
|
163
|
+
include_synonyms: bool = True,
|
|
164
|
+
limit: int = 20,
|
|
165
|
+
) -> list[ConceptMatch]:
|
|
166
|
+
"""
|
|
167
|
+
Case-insensitive exact match against concept_name, and optionally
|
|
168
|
+
concept_synonym_name.
|
|
169
|
+
|
|
170
|
+
standard_only defaults to False so the caller can inspect non-standard
|
|
171
|
+
candidates and decide whether to navigate to their standard equivalents.
|
|
172
|
+
|
|
173
|
+
Returns name matches before synonym matches; de-duplicates by concept_id
|
|
174
|
+
so a concept that matches both name and a synonym only appears once.
|
|
175
|
+
"""
|
|
176
|
+
q = query.strip()
|
|
177
|
+
if not q:
|
|
178
|
+
raise ValueError("query must be a non-empty string")
|
|
179
|
+
|
|
180
|
+
results: list[ConceptMatch] = []
|
|
181
|
+
seen_ids: set[int] = set()
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
with self._session_factory() as session:
|
|
185
|
+
# --- name match ---
|
|
186
|
+
name_stmt = self._apply_concept_filters(
|
|
187
|
+
select(
|
|
188
|
+
Concept.concept_id,
|
|
189
|
+
Concept.concept_name,
|
|
190
|
+
Concept.concept_code,
|
|
191
|
+
Concept.vocabulary_id,
|
|
192
|
+
Concept.domain_id,
|
|
193
|
+
Concept.concept_class_id,
|
|
194
|
+
Concept.standard_concept,
|
|
195
|
+
Concept.invalid_reason,
|
|
196
|
+
).where(func.lower(Concept.concept_name) == q.lower()),
|
|
197
|
+
domain=domain,
|
|
198
|
+
vocabulary_id=vocabulary_id,
|
|
199
|
+
standard_only=standard_only,
|
|
200
|
+
).limit(limit)
|
|
201
|
+
|
|
202
|
+
for row in session.execute(name_stmt).all():
|
|
203
|
+
seen_ids.add(int(row.concept_id))
|
|
204
|
+
results.append(_row_to_match(row, "name", None, None))
|
|
205
|
+
|
|
206
|
+
# --- synonym match (de-duplicated against name hits) ---
|
|
207
|
+
if include_synonyms:
|
|
208
|
+
remaining = limit - len(results)
|
|
209
|
+
if remaining > 0:
|
|
210
|
+
syn_stmt = self._apply_concept_filters(
|
|
211
|
+
select(
|
|
212
|
+
Concept.concept_id,
|
|
213
|
+
Concept.concept_name,
|
|
214
|
+
Concept.concept_code,
|
|
215
|
+
Concept.vocabulary_id,
|
|
216
|
+
Concept.domain_id,
|
|
217
|
+
Concept.concept_class_id,
|
|
218
|
+
Concept.standard_concept,
|
|
219
|
+
Concept.invalid_reason,
|
|
220
|
+
Concept_Synonym.concept_synonym_name,
|
|
221
|
+
)
|
|
222
|
+
.join(
|
|
223
|
+
Concept_Synonym,
|
|
224
|
+
Concept_Synonym.concept_id == Concept.concept_id,
|
|
225
|
+
)
|
|
226
|
+
.where(
|
|
227
|
+
func.lower(Concept_Synonym.concept_synonym_name) == q.lower(),
|
|
228
|
+
),
|
|
229
|
+
domain=domain,
|
|
230
|
+
vocabulary_id=vocabulary_id,
|
|
231
|
+
standard_only=standard_only,
|
|
232
|
+
).limit(remaining)
|
|
233
|
+
|
|
234
|
+
if seen_ids:
|
|
235
|
+
syn_stmt = syn_stmt.where(
|
|
236
|
+
Concept.concept_id.not_in(list(seen_ids))
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
for row in session.execute(syn_stmt).all():
|
|
240
|
+
results.append(
|
|
241
|
+
_row_to_match(row, "synonym", row.concept_synonym_name, None)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
except OmopVocabError:
|
|
245
|
+
raise
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
raise OmopVocabError(f"search_exact failed: {exc}") from exc
|
|
248
|
+
|
|
249
|
+
return results
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# search_fulltext
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def search_fulltext(
|
|
256
|
+
self,
|
|
257
|
+
query: str,
|
|
258
|
+
*,
|
|
259
|
+
domain: str | None = None,
|
|
260
|
+
vocabulary_id: str | None = None,
|
|
261
|
+
standard_only: bool = False,
|
|
262
|
+
include_synonyms: bool = True,
|
|
263
|
+
min_rank: float = 0.0,
|
|
264
|
+
limit: int = 20,
|
|
265
|
+
) -> tuple[list[ConceptMatch], bool]:
|
|
266
|
+
"""
|
|
267
|
+
PostgreSQL FTS match using the tsvector sidecar column (GIN-indexed).
|
|
268
|
+
|
|
269
|
+
Returns (results, fts_available). When fts_available=False the tsvector
|
|
270
|
+
sidecar column was not detected and results is always []; the caller
|
|
271
|
+
should fall through to another search strategy.
|
|
272
|
+
|
|
273
|
+
ts_rank is included in each result so the caller can apply its own
|
|
274
|
+
quality threshold. Synonym FTS is included when the synonym sidecar
|
|
275
|
+
column is also present; otherwise synonym results are silently omitted
|
|
276
|
+
(not an error).
|
|
277
|
+
|
|
278
|
+
standard_only defaults to False — see search_exact docstring.
|
|
279
|
+
"""
|
|
280
|
+
self._detect_fts_sidecars()
|
|
281
|
+
if not self._fts_name_sidecar:
|
|
282
|
+
return [], False
|
|
283
|
+
|
|
284
|
+
q = query.strip()
|
|
285
|
+
if not q:
|
|
286
|
+
raise ValueError("query must be a non-empty string")
|
|
287
|
+
|
|
288
|
+
results: list[ConceptMatch] = []
|
|
289
|
+
seen_ids: set[int] = set()
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
tsquery = func.plainto_tsquery("english", q)
|
|
293
|
+
name_rank = func.ts_rank(sa_col("concept_name_tsvector"), tsquery)
|
|
294
|
+
|
|
295
|
+
with self._session_factory() as session:
|
|
296
|
+
# --- name FTS ---
|
|
297
|
+
name_stmt = self._apply_concept_filters(
|
|
298
|
+
select(
|
|
299
|
+
Concept.concept_id,
|
|
300
|
+
Concept.concept_name,
|
|
301
|
+
Concept.concept_code,
|
|
302
|
+
Concept.vocabulary_id,
|
|
303
|
+
Concept.domain_id,
|
|
304
|
+
Concept.concept_class_id,
|
|
305
|
+
Concept.standard_concept,
|
|
306
|
+
Concept.invalid_reason,
|
|
307
|
+
name_rank.label("ts_rank"),
|
|
308
|
+
).where(
|
|
309
|
+
sa_col("concept_name_tsvector").op("@@")(tsquery)
|
|
310
|
+
),
|
|
311
|
+
domain=domain,
|
|
312
|
+
vocabulary_id=vocabulary_id,
|
|
313
|
+
standard_only=standard_only,
|
|
314
|
+
).order_by(name_rank.desc()).limit(limit)
|
|
315
|
+
|
|
316
|
+
if min_rank > 0.0:
|
|
317
|
+
name_stmt = name_stmt.where(name_rank >= min_rank)
|
|
318
|
+
|
|
319
|
+
for row in session.execute(name_stmt).all():
|
|
320
|
+
seen_ids.add(int(row.concept_id))
|
|
321
|
+
results.append(_row_to_match(row, "name", None, float(row.ts_rank)))
|
|
322
|
+
|
|
323
|
+
# --- synonym FTS (only when sidecar present) ---
|
|
324
|
+
if include_synonyms and self._fts_synonym_sidecar:
|
|
325
|
+
remaining = limit - len(results)
|
|
326
|
+
if remaining > 0:
|
|
327
|
+
syn_rank = func.ts_rank(sa_col("concept_synonym_name_tsvector"), tsquery)
|
|
328
|
+
syn_stmt = self._apply_concept_filters(
|
|
329
|
+
select(
|
|
330
|
+
Concept.concept_id,
|
|
331
|
+
Concept.concept_name,
|
|
332
|
+
Concept.concept_code,
|
|
333
|
+
Concept.vocabulary_id,
|
|
334
|
+
Concept.domain_id,
|
|
335
|
+
Concept.concept_class_id,
|
|
336
|
+
Concept.standard_concept,
|
|
337
|
+
Concept.invalid_reason,
|
|
338
|
+
Concept_Synonym.concept_synonym_name,
|
|
339
|
+
syn_rank.label("ts_rank"),
|
|
340
|
+
)
|
|
341
|
+
.join(
|
|
342
|
+
Concept_Synonym,
|
|
343
|
+
Concept_Synonym.concept_id == Concept.concept_id,
|
|
344
|
+
)
|
|
345
|
+
.where(
|
|
346
|
+
sa_col("concept_synonym_name_tsvector").op("@@")(tsquery)
|
|
347
|
+
),
|
|
348
|
+
domain=domain,
|
|
349
|
+
vocabulary_id=vocabulary_id,
|
|
350
|
+
standard_only=standard_only,
|
|
351
|
+
).order_by(syn_rank.desc()).limit(remaining)
|
|
352
|
+
|
|
353
|
+
if min_rank > 0.0:
|
|
354
|
+
syn_stmt = syn_stmt.where(syn_rank >= min_rank)
|
|
355
|
+
if seen_ids:
|
|
356
|
+
syn_stmt = syn_stmt.where(
|
|
357
|
+
Concept.concept_id.not_in(list(seen_ids))
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
for row in session.execute(syn_stmt).all():
|
|
361
|
+
results.append(
|
|
362
|
+
_row_to_match(
|
|
363
|
+
row, "synonym", row.concept_synonym_name, float(row.ts_rank)
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
except OmopVocabError:
|
|
368
|
+
raise
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
raise OmopVocabError(f"search_fulltext failed: {exc}") from exc
|
|
371
|
+
|
|
372
|
+
results.sort(key=lambda r: r.ts_rank or 0.0, reverse=True)
|
|
373
|
+
return results, True
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
# navigate_to_standard
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def navigate_to_standard(
|
|
380
|
+
self,
|
|
381
|
+
concept_ids: list[int],
|
|
382
|
+
) -> list[StandardMapping]:
|
|
383
|
+
"""
|
|
384
|
+
Given a list of concept_ids, return their standard equivalents via
|
|
385
|
+
IDENTITY-type ("Maps to") relationship edges.
|
|
386
|
+
|
|
387
|
+
For concept_ids that are already standard: standard_concepts = [self].
|
|
388
|
+
For concept_ids with no outbound "Maps to" relationship: standard_concepts = [].
|
|
389
|
+
concept_ids not found in the vocabulary are silently omitted.
|
|
390
|
+
|
|
391
|
+
All navigation is done in two queries (one for source metadata, one batch
|
|
392
|
+
join for mappings) regardless of the number of input ids.
|
|
393
|
+
|
|
394
|
+
This fills the gap left by omop_graph.reasoning.concept_handlers.
|
|
395
|
+
concept_helpers.standardise_ids, which currently raises NotImplementedError.
|
|
396
|
+
"""
|
|
397
|
+
if not concept_ids:
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
with self._session_factory() as session:
|
|
402
|
+
# Query full metadata for all source concepts in one round-trip
|
|
403
|
+
source_stmt = select(
|
|
404
|
+
Concept.concept_id,
|
|
405
|
+
Concept.concept_name,
|
|
406
|
+
Concept.vocabulary_id,
|
|
407
|
+
Concept.domain_id,
|
|
408
|
+
Concept.concept_class_id,
|
|
409
|
+
Concept.standard_concept,
|
|
410
|
+
).where(Concept.concept_id.in_(concept_ids))
|
|
411
|
+
|
|
412
|
+
source_rows = {
|
|
413
|
+
int(r.concept_id): r
|
|
414
|
+
for r in session.execute(source_stmt).all()
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
non_standard_ids = [
|
|
418
|
+
cid for cid, r in source_rows.items()
|
|
419
|
+
if r.standard_concept != "S"
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
# Batch navigate for all non-standard concepts in one query
|
|
423
|
+
mappings: dict[int, list[MappedConcept]] = {}
|
|
424
|
+
if non_standard_ids:
|
|
425
|
+
nav_stmt = (
|
|
426
|
+
select(
|
|
427
|
+
Concept_Relationship.concept_id_1.label("source_id"),
|
|
428
|
+
Concept_Relationship.relationship_id,
|
|
429
|
+
Concept.concept_id,
|
|
430
|
+
Concept.concept_name,
|
|
431
|
+
Concept.vocabulary_id,
|
|
432
|
+
Concept.domain_id,
|
|
433
|
+
Concept.concept_class_id,
|
|
434
|
+
)
|
|
435
|
+
.join(
|
|
436
|
+
Concept,
|
|
437
|
+
Concept.concept_id == Concept_Relationship.concept_id_2,
|
|
438
|
+
)
|
|
439
|
+
.where(
|
|
440
|
+
Concept_Relationship.concept_id_1.in_(non_standard_ids),
|
|
441
|
+
Concept_Relationship.relationship_id.in_(
|
|
442
|
+
self.IDENTITY_RELATIONSHIP_IDS
|
|
443
|
+
),
|
|
444
|
+
Concept_Relationship.invalid_reason.is_(None),
|
|
445
|
+
Concept.standard_concept == "S",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
for row in session.execute(nav_stmt).all():
|
|
449
|
+
src = int(row.source_id)
|
|
450
|
+
mappings.setdefault(src, []).append(
|
|
451
|
+
MappedConcept(
|
|
452
|
+
concept_id=int(row.concept_id),
|
|
453
|
+
concept_name=row.concept_name,
|
|
454
|
+
vocabulary_id=row.vocabulary_id,
|
|
455
|
+
domain_id=row.domain_id,
|
|
456
|
+
concept_class_id=row.concept_class_id,
|
|
457
|
+
relationship_id=row.relationship_id,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
except OmopVocabError:
|
|
462
|
+
raise
|
|
463
|
+
except Exception as exc:
|
|
464
|
+
raise OmopVocabError(f"navigate_to_standard failed: {exc}") from exc
|
|
465
|
+
|
|
466
|
+
results: list[StandardMapping] = []
|
|
467
|
+
for cid in concept_ids:
|
|
468
|
+
src = source_rows.get(cid)
|
|
469
|
+
if src is None:
|
|
470
|
+
continue # concept_id not found — silently skip
|
|
471
|
+
|
|
472
|
+
is_standard = src.standard_concept == "S"
|
|
473
|
+
if is_standard:
|
|
474
|
+
standard_concepts = [
|
|
475
|
+
MappedConcept(
|
|
476
|
+
concept_id=int(src.concept_id),
|
|
477
|
+
concept_name=src.concept_name,
|
|
478
|
+
vocabulary_id=src.vocabulary_id,
|
|
479
|
+
domain_id=src.domain_id,
|
|
480
|
+
concept_class_id=src.concept_class_id,
|
|
481
|
+
relationship_id="self",
|
|
482
|
+
)
|
|
483
|
+
]
|
|
484
|
+
else:
|
|
485
|
+
standard_concepts = mappings.get(cid, [])
|
|
486
|
+
|
|
487
|
+
results.append(
|
|
488
|
+
StandardMapping(
|
|
489
|
+
source_concept_id=cid,
|
|
490
|
+
source_concept_name=src.concept_name,
|
|
491
|
+
source_standard_concept=is_standard,
|
|
492
|
+
standard_concepts=standard_concepts,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return results
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
# Internal helpers
|
|
500
|
+
# ------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _apply_concept_filters(
|
|
504
|
+
stmt,
|
|
505
|
+
*,
|
|
506
|
+
domain: str | None,
|
|
507
|
+
vocabulary_id: str | None,
|
|
508
|
+
standard_only: bool,
|
|
509
|
+
):
|
|
510
|
+
"""Apply optional domain / vocabulary / standard_concept WHERE clauses."""
|
|
511
|
+
if standard_only:
|
|
512
|
+
stmt = stmt.where(Concept.standard_concept == "S")
|
|
513
|
+
if domain:
|
|
514
|
+
stmt = stmt.where(func.lower(Concept.domain_id) == domain.lower())
|
|
515
|
+
if vocabulary_id:
|
|
516
|
+
stmt = stmt.where(Concept.vocabulary_id == vocabulary_id)
|
|
517
|
+
return stmt
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# Module-level serialisation helpers (used by tool registration layer)
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def _row_to_match(
|
|
525
|
+
row,
|
|
526
|
+
match_source: str,
|
|
527
|
+
matched_synonym: str | None,
|
|
528
|
+
ts_rank: float | None,
|
|
529
|
+
) -> ConceptMatch:
|
|
530
|
+
"""Convert a SQLAlchemy row proxy to a ConceptMatch dataclass."""
|
|
531
|
+
return ConceptMatch(
|
|
532
|
+
concept_id=int(row.concept_id),
|
|
533
|
+
concept_name=row.concept_name,
|
|
534
|
+
concept_code=row.concept_code,
|
|
535
|
+
vocabulary_id=row.vocabulary_id,
|
|
536
|
+
domain_id=row.domain_id,
|
|
537
|
+
concept_class_id=row.concept_class_id,
|
|
538
|
+
standard_concept=row.standard_concept == "S",
|
|
539
|
+
invalid_reason=row.invalid_reason,
|
|
540
|
+
match_source=match_source,
|
|
541
|
+
matched_synonym=matched_synonym,
|
|
542
|
+
ts_rank=ts_rank,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def serialise_concept_match(match: ConceptMatch) -> dict:
|
|
547
|
+
"""Serialise a ConceptMatch to a JSON-safe dict for MCP tool responses."""
|
|
548
|
+
result: dict = {
|
|
549
|
+
"concept_id": match.concept_id,
|
|
550
|
+
"concept_name": match.concept_name,
|
|
551
|
+
"concept_code": match.concept_code,
|
|
552
|
+
"vocabulary_id": match.vocabulary_id,
|
|
553
|
+
"domain_id": match.domain_id,
|
|
554
|
+
"concept_class_id": match.concept_class_id,
|
|
555
|
+
"standard_concept": match.standard_concept,
|
|
556
|
+
"invalid_reason": match.invalid_reason,
|
|
557
|
+
"match_source": match.match_source,
|
|
558
|
+
"matched_synonym": match.matched_synonym,
|
|
559
|
+
}
|
|
560
|
+
if match.ts_rank is not None:
|
|
561
|
+
result["ts_rank"] = round(match.ts_rank, 6)
|
|
562
|
+
return result
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def serialise_standard_mapping(mapping: StandardMapping) -> dict:
|
|
566
|
+
"""Serialise a StandardMapping to a JSON-safe dict for MCP tool responses."""
|
|
567
|
+
return {
|
|
568
|
+
"source_concept_id": mapping.source_concept_id,
|
|
569
|
+
"source_concept_name": mapping.source_concept_name,
|
|
570
|
+
"source_standard_concept": mapping.source_standard_concept,
|
|
571
|
+
"standard_concepts": [
|
|
572
|
+
{
|
|
573
|
+
"concept_id": sc.concept_id,
|
|
574
|
+
"concept_name": sc.concept_name,
|
|
575
|
+
"vocabulary_id": sc.vocabulary_id,
|
|
576
|
+
"domain_id": sc.domain_id,
|
|
577
|
+
"concept_class_id": sc.concept_class_id,
|
|
578
|
+
"relationship_id": sc.relationship_id,
|
|
579
|
+
}
|
|
580
|
+
for sc in mapping.standard_concepts
|
|
581
|
+
],
|
|
582
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .errors import GroundworkersError, ERROR_CODES
|
|
2
|
+
from .results import DatasetStatus, DetailResult, ListResult, SearchHit, SearchResult
|
|
3
|
+
from .server import GroundcrewServer
|
|
4
|
+
from .sql import SQLResource, SQLTextSearchResource
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"GroundworkersError",
|
|
8
|
+
"GroundcrewServer",
|
|
9
|
+
"DatasetStatus",
|
|
10
|
+
"DetailResult",
|
|
11
|
+
"ERROR_CODES",
|
|
12
|
+
"ListResult",
|
|
13
|
+
"SearchHit",
|
|
14
|
+
"SearchResult",
|
|
15
|
+
"SQLResource",
|
|
16
|
+
"SQLTextSearchResource",
|
|
17
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
ERROR_CODES = {
|
|
5
|
+
"NOT_FOUND",
|
|
6
|
+
"INVALID_INPUT",
|
|
7
|
+
"BACKEND_UNAVAIL",
|
|
8
|
+
"QUERY_ERROR",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GroundworkersError(Exception):
|
|
13
|
+
def __init__(self, code: str, message: str):
|
|
14
|
+
self.code = code
|
|
15
|
+
self.message = message
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str | bool]:
|
|
19
|
+
return {"error": True, "code": self.code, "message": self.message}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DetailResult(BaseModel):
|
|
9
|
+
resource_id: str
|
|
10
|
+
item: dict[str, Any] | None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ListResult(BaseModel):
|
|
14
|
+
resource_id: str
|
|
15
|
+
items: list[dict[str, Any]] = Field(default_factory=list)
|
|
16
|
+
total: int
|
|
17
|
+
limit: int
|
|
18
|
+
offset: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SearchHit(BaseModel):
|
|
22
|
+
id: str | int
|
|
23
|
+
score: float
|
|
24
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SearchResult(BaseModel):
|
|
28
|
+
resource_id: str
|
|
29
|
+
query: str | None = None
|
|
30
|
+
items: list[SearchHit] = Field(default_factory=list)
|
|
31
|
+
limit: int = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DatasetStatus(BaseModel):
|
|
35
|
+
module: str
|
|
36
|
+
enabled: bool
|
|
37
|
+
resources: list[str] = Field(default_factory=list)
|
|
38
|
+
issues: list[str] = Field(default_factory=list)
|