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.
@@ -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)