diff --git a/docs/architecture.md b/docs/architecture.md index 0a2a7f0..f4e3947 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,6 +120,8 @@ transport: - `TextService` for LLM-backed text preprocessing - `DomainService` for LLM-backed structured-field domain hints - `SourcePlanningService` for stateless source-planning pipelines +- `SemanticProjectionService` for deterministic output-definition projection + over `omop-semantics` — grounded concept in, one or more CDM rows out If the logic is something a Python caller would reasonably want without going through MCP, it probably belongs in a service. @@ -133,6 +135,13 @@ assembled runtime: - `SourcePlanningService` is always present and can be LLM-assisted when that adapter is configured +`SemanticProjectionService` is the one exception to the `Services`/adapter +composition described above: it needs no adapter (no database, no LLM — the +same properties that make `omop-semantics` itself portable), so it is not part +of `app.services`. It's constructed directly and registered behind its own +config flag, the same way `KnowledgeCatalogue` is. See +[SemanticProjectionService](services/semantic_projection.md). + ### Transport layers `groundworkers` exposes two transport styles over the same runtime: diff --git a/docs/reference/ref_services.md b/docs/reference/ref_services.md index 402bd7a..ce32ad8 100644 --- a/docs/reference/ref_services.md +++ b/docs/reference/ref_services.md @@ -61,3 +61,11 @@ service. workflows for pre-ingest artifacts. ::: groundworkers.services.source_planning + +## SemanticProjectionService + +`SemanticProjectionService` deterministically projects a grounded concept into +one or more CDM rows via `omop-semantics`. Not part of `app.services` — see +[SemanticProjectionService](../services/semantic_projection.md) for why. + +::: groundworkers.services.semantic_projection diff --git a/docs/reference/ref_tools.md b/docs/reference/ref_tools.md index 720782a..64eaab5 100644 --- a/docs/reference/ref_tools.md +++ b/docs/reference/ref_tools.md @@ -27,3 +27,7 @@ ## System Tools ::: groundworkers.tools.system_tools + +## Semantic Projection Tools + +::: groundworkers.tools.semantic_projection_tools diff --git a/docs/services/semantic_projection.md b/docs/services/semantic_projection.md new file mode 100644 index 0000000..53b355a --- /dev/null +++ b/docs/services/semantic_projection.md @@ -0,0 +1,219 @@ +# SemanticProjectionService + +`SemanticProjectionService` deterministically projects a grounded OMOP concept +into one or more CDM rows. It wraps [`omop-semantics`](https://australiancancerdatanetwork.github.io/omop-semantics/)'s +`OutputDefinitionRuntime`: no LLM call, no database access. The same input +always produces the same output. + +It exists for cases a single grounded `concept_id` can't express on its own — +a diagnosis paired with a separately-collected role/status field, a family- +history statement where the grounded condition belongs in the OMOP value slot, +or a Yes/No field whose negative answer should produce no record at all. +Ordinary single-concept mappings don't need it. + +## Construction + +Unlike the other services documented here, `SemanticProjectionService` is +**not** part of `app.services` — it needs no adapter (no database, no LLM), so +it doesn't participate in the `build_application()` / `Services` composition +described in [Architecture](../architecture.md). It's constructed directly +where it's registered, the same way `KnowledgeCatalogue` is: + +```python +from groundworkers.services.semantic_projection import SemanticProjectionService + +service = SemanticProjectionService() +``` + +Compiling the catalogue happens once, at construction — an invalid definition +(a `derivation_rules` entry pointing at a slot the profile doesn't allow, for +example) raises immediately rather than failing on the first request that +happens to hit it. `create_server()` builds one instance at startup when +`groundworkers.semantic_projection.enabled = true`; see +[Configuration](../usage/configuration.md#semantic_projection). + +Pass a custom `definitions` iterable to test against a smaller catalogue, or +to run a project-specific set instead of the built-in one: + +```python +service = SemanticProjectionService(definitions=my_definitions) +``` + +## The built-in catalogue + +Six definitions ship today: + +| Name | Pattern | +|---|---| +| `condition_with_status_from_secondary_field` | A diagnosis paired with a separately-collected role/status field (Primary/Contributing/Non-contributing). Populates `condition_status_concept_id` from the role field's raw code; the Non-contributing code drops the row. | +| `family_history_condition` | A value-carried family-history observation. The fixed OMOP entity concept `4167217` ("Family history of clinical finding") carries the family-history meaning; the grounded plain condition lands in `value_as_concept_id`. Known non-emitting family-history codes suppress the row. | +| `family_member_history_bundle` | A multi-row family-member bundle. One relative emits several coordinated observation rows: relationship label, birth year, age-at-death, age-at-onset, method-of-evaluation text, primary diagnosis, and secondary diagnosis, with row-level links showing that they belong to the same relative. | +| `criteria_gate_condition` | A Yes/No field phrased "meets criteria for X". The positive answer keeps the row; the negative answer drops it — a negative answer carries no positive clinical content of its own. | +| `yes_no_observation` | An ordinary Yes/No observation where both answers remain informative. Raw `1`/`0` map to OMOP answer concepts in `value_as_concept_id`; unlike a gate, `0` still writes a row. | +| `measurement_numeric_with_unit_from_context` | A quantitative measurement with a literal numeric value plus a derived OMOP unit concept. Shows how projection can bind both direct source values and code-mapped slots. | + +These definitions are deliberately illustrative as well as useful. The first +four cover the most common "why projection exists at all" cases: sibling-field +modifiers, value-carried family-history shapes, multi-row relative bundles, +and deterministic row suppression. The latter two show that projection is also +a good fit for ordinary coded observations and quantitative measurements once +callers already know the row shape they want. + +## Method + +### `project` + +```python +service.project(request: SemanticProjectionRequest) -> SemanticProjectionResult +``` + +`SemanticProjectionRequest` fields: + +| Field | Type | Notes | +|---|---|---| +| `grounded_concept_id` | `int` | required | +| `grounded_domain` | `str` | required | +| `grounded_concept_name` | `str \| null` | | +| `source_text` | `str \| null` | | +| `source_item_id` | `str \| null` | | +| `definition_hint` | `str \| null` | see below | +| `context` | `dict` | see below | + +`context` carries whatever the selected definition needs beyond the grounded +concept itself: + +- `raw_value` — the grounded field's own raw source code. Consulted by a + `SpecialValuePolicy` (e.g. `criteria_gate_condition`'s Yes/No check) or a + `DerivationRule` (e.g. `yes_no_observation`'s Yes/No answer mapping). +- `raw_source_fields` — a mapping of well-known slot name to raw value, for + definitions that resolve a row's slot from a *different* source field via a + `DerivationRule` (e.g. `condition_with_status_from_secondary_field`'s role + field). The key is whatever the definition documents in its `notes` — + `role_field` today — not the field's actual name in your source data. +- `numeric_value` — a literal numeric reading for quantitative projections such + as `measurement_numeric_with_unit_from_context`. + +### Selecting a definition + +Pass `definition_hint` to select a definition explicitly. Omit it and the +service falls back to matching on `grounded_domain` alone — but only resolves +when exactly one registered definition applies to that domain. With both +built-in `Condition` definitions, that fallback is still ambiguous for +`grounded_domain="Condition"`; `definition_hint` remains required there in +practice. Other domains now have one shipped definition each (`Observation` +and `Measurement`), so domain-only matching can resolve those unambiguously. +This is deliberate: the service reports `status="no_match"` with an audit note +rather than guessing. + +### Result + +`SemanticProjectionResult.status` is one of: + +| Status | Meaning | +|---|---| +| `ok` | A definition matched and every row is fully bound. | +| `partial` | A definition matched but some row still needs more context (`unresolved_fields`). | +| `suppressed` | A definition matched but every row it would have produced was dropped by a `DerivationRule` or `SpecialValuePolicy` (`suppressed_rows`) — nothing should be written. | +| `no_match` | No definition matched, including an ambiguous domain match with no `definition_hint`. | + +Suppressed rows are never silently absent from `rows` — they're always listed +in `suppressed_rows` with the reason, the source field consulted, and the raw +code that triggered it. A `status="suppressed"` result carries exactly as much +information as an `ok` one; it just says the deterministic answer is "write +nothing here." + +## Typical input/output + +```python +from groundworkers.services.semantic_projection import SemanticProjectionRequest + +result = service.project( + SemanticProjectionRequest( + grounded_concept_id=4152280, + grounded_domain="Condition", + definition_hint="condition_with_status_from_secondary_field", + context={"raw_source_fields": {"role_field": "1"}}, + ) +) +``` + +```python +SemanticProjectionResult( + definition_name="condition_with_status_from_secondary_field", + role="condition_modifier", + status="ok", + rows=[ + ProjectedRowModel( + row_id="condition", + table="condition_occurrence", + fields={"condition_concept_id": 4152280, "condition_status_concept_id": 32902}, + ) + ], + ... +) +``` + +Role field code `"3"` (Non-contributing) instead of `"1"` produces +`status="suppressed"`, `rows=[]`, and one `suppressed_rows` entry. + +## When to use it + +Use `SemanticProjectionService` when: + +- a single grounded concept needs a second CDM column populated from a + sibling source field's raw value +- a fixed entity concept should carry context like family history while the + grounded concept belongs in a value slot +- a source item should sometimes produce no CDM record at all, and that + decision needs to be deterministic and auditable rather than implicit in + caller code +- a quantitative projection needs both a direct numeric literal and a mapped + OMOP unit concept +- you want the same request to always produce the same result, with no LLM + call in the path + +Do not use it for: + +- ordinary single-concept grounding — that's `ConceptGroundingService` / + `concept_ground` +- deciding *which* definition applies from free text or ambiguous context — + that inference doesn't exist yet (see the implementation-plan notes in + agent-stack's `SEMANTIC_INTEGRATION` design docs); today's callers must + already know to pass `definition_hint` + +## Relationship to downstream mapping + +```mermaid +flowchart TD + G[grounded concept + domain] --> S[SemanticProjectionService.project] + C[context: raw_value / raw_source_fields] --> S + S -->|ok / partial| R[CDM rows] + S -->|suppressed| N[nothing written, reason recorded] + S -->|no_match| U[caller falls back to plain grounding] +``` + +## Interactive exploration + +Launch the real catalogue through the worker-backed TUI with: + +```bash +groundworkers --tui +``` + +This opens the same built-in definitions the service executes for +`semantic_project`, but in an interactive terminal flow where you can browse the +catalogue, start from definition-specific example payloads, run them, and +inspect the result without standing up an MCP client first. + +## Error handling + +- Raises `ValueError` at construction time for an invalid definition (bad + `derivation_rules`/`special_value_policy` reference, duplicate row id, + dangling link rule) — this is a startup-time failure, not a per-request one. +- `project()` itself does not raise for ordinary "nothing matched" outcomes — + those are `status="no_match"` results, not exceptions. +- A `SpecialValuePolicy` configured with `suppression_mode="fail"` raises + `ValueError` from `project()` if its trigger value actually occurs — that + mode exists for values that should never reach projection. +- `keep_as_value` and `keep_as_modifier` suppression modes raise + `NotImplementedError` if triggered; no shipped definition uses them yet. diff --git a/docs/tools/overview.md b/docs/tools/overview.md index c80abc8..4638f93 100644 --- a/docs/tools/overview.md +++ b/docs/tools/overview.md @@ -18,6 +18,7 @@ startup. | **Text** | `text_normalize`, `text_decompose`, `text_disambiguate` | LLM enabled | | **Domain** | `domain_classify` | LLM enabled | | **System** | `system_status`, `system_vocabulary_catalogue` | Always registered | +| **Semantic projection** | `semantic_project` | `groundworkers.semantic_projection.enabled = true` (default `false`) | ## What each group is for @@ -32,6 +33,8 @@ startup. - **Text** tools normalize or decompose free text before retrieval. - **Domain** tools classify structured labels into OMOP domains. - **System** tools expose runtime availability and vocabulary catalogue metadata. +- **Semantic projection** tools deterministically turn an already-grounded + concept into one or more CDM rows — no LLM call, disabled by default. ## Registration rules @@ -70,6 +73,11 @@ flowchart LR If you are building a Python application, call `app.services.*` directly rather than importing tool modules. +`SemanticProjectionService` is the one exception — it needs no adapter, so it +isn't part of `app.services`. Construct it directly: +`from groundworkers.services.semantic_projection import SemanticProjectionService`. +See [SemanticProjectionService](../services/semantic_projection.md). + ## Error response shape On failure, tools return a plain dict: diff --git a/docs/tools/semantic_projection.md b/docs/tools/semantic_projection.md new file mode 100644 index 0000000..3ea0eb1 --- /dev/null +++ b/docs/tools/semantic_projection.md @@ -0,0 +1,160 @@ +# Semantic Projection Tools + +`semantic_project` is the MCP surface for deterministic semantic projection — +turning a grounded OMOP concept into one or more CDM rows. It is registered +only when `groundworkers.semantic_projection.enabled = true` (default `false`; +see [Configuration](../usage/configuration.md#semantic_projection)). + +It is not a grounding tool. Callers ground a concept first (`concept_ground`, +or their own logic) and pass the result in. + +It is a thin wrapper over `SemanticProjectionService` — see +[SemanticProjectionService](../services/semantic_projection.md) for the full +definition catalogue and selection rules. + +--- + +## `semantic_project` + +```json +{ + "grounded_concept_id": 4152280, + "grounded_domain": "Condition", + "grounded_concept_name": "Major depressive disorder", + "definition_hint": "condition_with_status_from_secondary_field", + "context": { + "raw_source_fields": {"role_field": "1"} + } +} +``` + +`grounded_concept_id` and `grounded_domain` are required. Everything else is +optional. + +`definition_hint` selects a definition explicitly. Omit it only when exactly +one registered definition applies to `grounded_domain` — with four +built-in definitions on `Condition`, that domain still does not resolve by +itself, so treat `definition_hint` as required there in practice. + +`context` carries whatever the selected definition needs beyond the grounded +concept itself — see the field-by-field description in +[SemanticProjectionService](../services/semantic_projection.md#method). In +short: `raw_value` for a definition that checks the grounded field's own raw +code, `raw_source_fields` for one that reads a *different* source field, and +`numeric_value` for a definition that binds a literal measurement value. + +**Response (row kept):** + +```json +{ + "definition_name": "condition_with_status_from_secondary_field", + "role": "condition_modifier", + "status": "ok", + "rows": [ + { + "row_id": "condition", + "table": "condition_occurrence", + "fields": { + "condition_concept_id": 4152280, + "condition_status_concept_id": 32902 + } + } + ], + "links": [], + "constraint_checks": [], + "unresolved_fields": [], + "suppressed_rows": [], + "audit_notes": ["Diagnosis paired with a separately-collected Primary/Contributing/Non-contributing role field. ..."] +} +``` + +**Response (row suppressed — role field raw code `"3"`, Non-contributing):** + +```json +{ + "definition_name": "condition_with_status_from_secondary_field", + "role": "condition_modifier", + "status": "suppressed", + "rows": [], + "links": [], + "constraint_checks": [], + "unresolved_fields": [], + "suppressed_rows": [ + { + "row_id": "condition", + "reason": "derivation rule for 'condition_status_concept_id' matched a suppress code on 'source.raw_source_fields.role_field'", + "source_field": "source.raw_source_fields.role_field", + "source_code": "3" + } + ], + "audit_notes": [] +} +``` + +Nothing is silently missing here — `rows` is empty *and* `suppressed_rows` +explains why, with the exact source field and code that triggered it. + +**Response (no hint, ambiguous domain):** + +```json +{ + "definition_name": null, + "role": null, + "status": "no_match", + "rows": [], + "links": [], + "constraint_checks": [], + "unresolved_fields": [], + "suppressed_rows": [], + "audit_notes": [ + "4 definitions match domain 'Condition' ['condition_with_status_from_secondary_field', 'criteria_gate_condition', 'family_history_condition', 'family_member_history_bundle']; pass definition_hint to disambiguate" + ] +} +``` + +**When to use it:** + +Use `semantic_project` when: + +- a grounded item needs a second CDM column populated from a sibling source + field (a role/status pairing) +- a fixed entity concept should carry context like family history while the + grounded plain concept belongs in the OMOP value slot +- a source item should sometimes produce no CDM record at all, deterministically + and auditably, rather than via ad hoc caller logic +- a grounded quantitative finding needs both a literal numeric value and a + mapped OMOP unit concept +- you need the same input to always produce the same output — there is no LLM + call anywhere in this path + +Do not use it to ground a concept in the first place — that's `concept_ground`. +Do not expect it to guess which definition applies from free text; today's +callers must already know to pass `definition_hint`. + +**Error cases:** + +| Error code | Condition | +|---|---| +| `INVALID_INPUT` | Request failed validation (e.g. non-numeric `grounded_concept_id`) | +| `QUERY_ERROR` | Definition execution failed unexpectedly (e.g. a `suppression_mode="fail"` policy actually triggered) | + +A `NOT_FOUND`-style outcome for an unknown `definition_hint` is *not* an error +— it comes back as `status="no_match"` with an audit note naming the unknown +hint, since the request was well-formed and the server made a deterministic +decision about it. + +## Typical downstream use + +```mermaid +flowchart TD + C[concept_ground] --> G[grounded concept + domain] + G --> P[semantic_project] + CTX[context: raw_value / raw_source_fields] --> P + P -->|ok / partial| R[CDM rows to store] + P -->|suppressed| N[nothing stored, reason recorded] + P -->|no_match| F[caller falls back to the plain grounded concept] +``` + +The tool is deliberately narrow: it turns an already-grounded concept into +CDM rows under a known, registered pattern. It does not search for concepts, +and it does not infer which pattern applies — the caller supplies that. diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f894a4c..a4f2aff 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -27,6 +27,20 @@ config = build_app_config() app = build_application(config) ``` +The CLI entry point also accepts transport overrides and an interactive +semantic-projection explorer flag: + +```bash +groundworkers --transport streamable-http --host 127.0.0.1 --port 8000 +groundworkers --tui +``` + +`--tui` launches the semantic projection explorer directly and exits instead of +starting an MCP or REST server. Install the optional TUI dependencies with +`uv sync --extra tui` or `pip install groundworkers[tui]`, and make sure a +shared stack config is loadable with +`tools.groundworkers.semantic_projection.enabled = true` in the active profile. + ## Configuration ownership `groundworkers` does not own every setting it consumes. The shared stack keeps @@ -37,7 +51,7 @@ package ownership explicit: | Shared CDM resource and schema naming | `omop-alchemy` | | Graph traversal tuning | `omop-graph` | | Embedding backend, model, cache, and embedding store | `omop-emb` | -| MCP defaults, REST defaults, LLM worker settings, source-planning settings, knowledge-pack settings | `groundworkers` | +| MCP defaults, REST defaults, LLM worker settings, source-planning settings, knowledge-pack settings, semantic-projection settings | `groundworkers` | This keeps the `groundworkers` package config intentionally focused on worker- owned behavior and transport defaults. @@ -187,6 +201,23 @@ my-knowledge/ If a configured pack has the same `layer` and `name` as a bundled baseline pack, the configured copy wins. +### `semantic_projection` + +| Field | Type | Default | +|---|---|---| +| `enabled` | `bool` | `false` | + +Gates the `semantic_project` MCP tool and its backing `SemanticProjectionService`. +Off by default: the service is fully deterministic (no LLM, no database), so +there's no missing-dependency reason to disable it — the flag exists purely +for staged rollout, per agent-stack's `SEMANTIC_INTEGRATION` design notes +(enable in local/test environments first). + +```toml +[tools.groundworkers.semantic_projection] +enabled = true +``` + ## What becomes available at runtime ### With shared CDM and `omop_graph` configured @@ -218,6 +249,14 @@ You additionally get: - text and domain MCP tools - LLM-assisted source planning when `source_planning.llm_assisted_enabled = true` +### With `groundworkers.semantic_projection.enabled = true` + +You additionally get: + +- `SemanticProjectionService` (constructed directly, not part of `app.services` + — it needs no adapter) +- the `semantic_project` MCP tool + ## CLI selection rules By default: diff --git a/mkdocs.yaml b/mkdocs.yaml index b456b29..f4db58e 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -47,6 +47,7 @@ nav: - Text Tools: tools/text.md - Domain Tools: tools/domain.md - System Tools: tools/system.md + - Semantic Projection Tools: tools/semantic_projection.md - Services: - Graph Service: services/graph.md - Grounding Service: services/grounding.md @@ -55,6 +56,7 @@ nav: - Source Planning Service: services/source_planning.md - Text Service: services/text.md - Domain Service: services/domain.md + - Semantic Projection Service: services/semantic_projection.md - Adapters: - CDM: adapters/cdm.md - OmopGraph: adapters/omop_graph.md diff --git a/pyproject.toml b/pyproject.toml index 222aa05..db97963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "oa-configurator>=0.1.2", "omop-graph>=1.3.0", "omop-emb>=1.1.1", + "omop-semantics>=0.4.0", "uvicorn[standard]>=0.29,<1.0", ] @@ -27,6 +28,9 @@ xlsx = [ pdf = [ "pdfplumber>=0.10", ] +tui = [ + "omop-semantics[tui]>=0.4.0", +] docx = [ "python-docx>=1.0", ] @@ -49,6 +53,7 @@ dev = [ "pytest-cov>=4.0", "mypy>=1.8", "ruff>=0.4", + "textual>=0.60", "mkdocs-material>=9.7.1", "mkdocstrings-python>=2.0.1", "mkdocs>=1.6.1", diff --git a/src/groundworkers/config.py b/src/groundworkers/config.py index 246ab25..a53b438 100644 --- a/src/groundworkers/config.py +++ b/src/groundworkers/config.py @@ -83,6 +83,15 @@ class KnowledgeConfig(BaseModel): packs_root: str | None = None +class SemanticProjectionConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + # Deterministic, no LLM/DB dependency — off by default per the rollout plan + # in agent-stack's SEMANTIC_INTEGRATION design notes: enable in local/test + # environments first, before review/export surfaces consume it downstream. + enabled: bool = False + + class GroundworkersConfig(PackageConfigBase): """Package-level configuration owned by groundworkers.""" @@ -96,6 +105,7 @@ class GroundworkersConfig(PackageConfigBase): grounding: GroundingConfig = Field(default_factory=GroundingConfig) source_planning: SourcePlanningConfig = Field(default_factory=SourcePlanningConfig) knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig) + semantic_projection: SemanticProjectionConfig = Field(default_factory=SemanticProjectionConfig) @dataclass(frozen=True) @@ -139,6 +149,10 @@ def source_planning(self) -> SourcePlanningConfig: def knowledge(self) -> KnowledgeConfig: return self.groundworkers.knowledge + @property + def semantic_projection(self) -> SemanticProjectionConfig: + return self.groundworkers.semantic_projection + def describe(self) -> dict[str, Any]: llm = self.llm.model_dump(exclude_none=True) if llm.get("api_key"): @@ -167,6 +181,7 @@ def describe(self) -> dict[str, Any]: **self.knowledge.model_dump(exclude_none=True), "resolved_root": str(self.knowledge_root) if self.knowledge_root is not None else None, }, + "semantic_projection": self.semantic_projection.model_dump(), }, "omop_graph": { "configured": self.omop_graph is not None, diff --git a/src/groundworkers/server.py b/src/groundworkers/server.py index c50e518..7901c51 100644 --- a/src/groundworkers/server.py +++ b/src/groundworkers/server.py @@ -7,6 +7,7 @@ from groundworkers.base.server import GroundcrewServer from groundworkers.bootstrap import build_app_config from groundworkers.config import AppConfig +from groundworkers.services.semantic_projection.service import SemanticProjectionService from groundworkers.tools.concept_tools import register_concept_tools from groundworkers.tools.domain_tools import register_domain_tools from groundworkers.tools.embedding_tools import register_embedding_resources, register_embedding_tools @@ -14,6 +15,7 @@ from groundworkers.tools.mapping_tools import register_mapping_tools from groundworkers.tools.resolver_tools import register_resolver_tools from groundworkers.tools.search_tools import register_search_tools +from groundworkers.tools.semantic_projection_tools import register_semantic_projection_tools from groundworkers.tools.source_planning_tools import ( register_source_planning_resources, register_source_planning_tools, @@ -49,6 +51,8 @@ def create_server( register_source_planning_tools(server, app.services.source_planning) register_source_planning_resources(server) register_knowledge_tools(server, packs_root=config.knowledge_root) + if config.semantic_projection.enabled: + register_semantic_projection_tools(server, SemanticProjectionService()) register_text_prompts(server) register_system_tools(server, app.adapters.omop_graph, app.adapters.omop_emb, app.adapters.llm) register_system_resources(server, config, app.adapters.omop_graph) @@ -73,12 +77,25 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: ) parser.add_argument("--host", help="Bind host override for HTTP transports.") parser.add_argument("--port", type=int, help="Bind port override for HTTP transports.") + parser.add_argument( + "--tui", + action="store_true", + help="Launch the interactive semantic projection explorer and exit.", + ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> None: args = parse_args(argv) config = build_app_config(config_path=args.config_path, profile=args.profile) + if args.tui: + if not config.semantic_projection.enabled: + raise RuntimeError( + "semantic_projection is disabled in the active stack config. " + "Set [tools.groundworkers.semantic_projection] enabled = true to use --tui." + ) + _launch_semantic_projection_tui() + return application = build_application(config) server = create_server(config, application) if args.describe: @@ -108,6 +125,12 @@ def main(argv: list[str] | None = None) -> None: server.run(transport=transport, host=host, port=port) +def _launch_semantic_projection_tui() -> None: + from groundworkers.services.semantic_projection.tui_launcher import run_semantic_projection_tui + + run_semantic_projection_tui() + + def run_rest_api( config: AppConfig, application: GroundworkersApp, diff --git a/src/groundworkers/services/semantic_projection/__init__.py b/src/groundworkers/services/semantic_projection/__init__.py new file mode 100644 index 0000000..11697b4 --- /dev/null +++ b/src/groundworkers/services/semantic_projection/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from groundworkers.services.semantic_projection.definitions import BUILTIN_DEFINITIONS, DefinitionTrigger +from groundworkers.services.semantic_projection.models import ( + ProjectedRowModel, + ProjectionLinkModel, + SemanticProjectionRequest, + SemanticProjectionResult, + SuppressedRowModel, +) +from groundworkers.services.semantic_projection.service import SemanticProjectionService + +__all__ = [ + "BUILTIN_DEFINITIONS", + "DefinitionTrigger", + "ProjectedRowModel", + "ProjectionLinkModel", + "SemanticProjectionRequest", + "SemanticProjectionResult", + "SuppressedRowModel", + "SemanticProjectionService", +] diff --git a/src/groundworkers/services/semantic_projection/definitions.py b/src/groundworkers/services/semantic_projection/definitions.py new file mode 100644 index 0000000..80ba631 --- /dev/null +++ b/src/groundworkers/services/semantic_projection/definitions.py @@ -0,0 +1,323 @@ +"""Groundworkers' catalogue of deterministic output definitions. + +`omop_semantics` supplies the generic building blocks (`OutputDefinition`, +`DerivationRule`, `SpecialValuePolicy`, CDM profiles); the concrete definitions +below are groundworkers-specific content, the same way a downstream +application authors its own CDM profile catalogue on top of a generic schema +library. They correspond to the `diagnosis-role-modifier` and +`criteria-gate-yesno` core knowledge packs — this is the deterministic +counterpart to that guidance, not a replacement for it. + +Several definitions below apply to the Condition domain, so domain-only +matching cannot disambiguate between them — a caller must pass +`definition_hint` whenever multiple Condition-pattern definitions could fit. +Domain matching still resolves unambiguously once a single definition exists +for a non-colliding domain. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from omop_semantics.runtime import ( + ContextFieldRef, + DerivationRule, + OutputDefinition, + OutputLinkRule, + OutputRowProjection, + SpecialValuePolicy, +) + + +@dataclass(frozen=True) +class DefinitionTrigger: + """Declares which grounded domains a definition applies to. + + Deliberately simple: domain-only matching. Concept-group-aware matching + (e.g. "only for descendants of concept X") belongs in `omop_semantics` + itself if it turns out to be needed by more than one consumer — see + agent-stack's SEMANTIC_INTEGRATION design notes. Until then, an ambiguous + domain match is reported as `no_match` rather than guessed. + """ + + domains: frozenset[str] = frozenset() + + +_FH_NON_EMITTING_CODES = frozenset({"00", "05", "06", "12", "66", "88", "99"}) +_FH_DIAGNOSIS_CODE_MAP = { + "01": 378419, # Alzheimer's disease + "02": 4196433, # Senile dementia of the Lewy body type + "03": 443605, # Vascular dementia + "04": 381316, # Cerebrovascular accident + "07": 374631, # Motor neuron disease + "08": 381270, # Parkinson's disease + "09": 444407, # Prion disease + "10": 432586, # Mental disorder + "11": 4182210, # Dementia +} + + +CONDITION_WITH_STATUS_FROM_SECONDARY_FIELD = OutputDefinition( + name="condition_with_status_from_secondary_field", + role="condition_modifier", + row_projections=( + OutputRowProjection( + row_id="condition", + profile_name="condition_with_status", + field_bindings={ + "condition_concept_id": ContextFieldRef("grounded.concept_id"), + }, + ), + ), + derivation_rules=( + DerivationRule( + target_row="condition", + target_slot="condition_status_concept_id", + source_field=ContextFieldRef("source.raw_source_fields.role_field"), + code_map={ + "1": 32902, # Primary diagnosis + "2": 32908, # Secondary diagnosis — used for "Contributing"; no dedicated concept exists + }, + suppress_codes=frozenset({"3"}), # Non-contributing — the diagnosis is not asserted at all + ), + ), + notes=( + "Diagnosis paired with a separately-collected Primary/Contributing/" + "Non-contributing role field. Caller must populate " + "context.raw_source_fields.role_field with the role field's raw code " + "(1, 2, or 3), regardless of what that field is actually named in the " + "source data.", + ), +) + + +FAMILY_HISTORY_CONDITION = OutputDefinition( + name="family_history_condition", + role="history_modifier", + row_projections=( + OutputRowProjection( + row_id="family_history", + profile_name="observation_coded", + field_bindings={ + "observation_concept_id": 4167217, # Family history of clinical finding + "value_as_concept_id": ContextFieldRef("grounded.concept_id"), + }, + special_value_policy=SpecialValuePolicy( + source_field=ContextFieldRef("source.raw_value"), + allowed_special_values=_FH_NON_EMITTING_CODES, + suppression_mode="drop", + ), + ), + ), + notes=( + "Family-history value-carried shape: the fixed entity concept " + "4167217 ('Family history of clinical finding') carries the " + "family-history meaning, while the grounded plain condition lands in " + "value_as_concept_id. Caller may populate context.raw_value with the " + "source diagnosis code; known non-emitting family-history codes " + "(00, 05, 06, 12, 66, 88, 99) suppress the row.", + ), +) + + +FAMILY_MEMBER_HISTORY_BUNDLE = OutputDefinition( + name="family_member_history_bundle", + role="relative_history_bundle", + row_projections=( + OutputRowProjection( + row_id="relative_identity", + profile_name="observation_string", + field_bindings={ + "observation_concept_id": 0, + "value_as_string": ContextFieldRef("source.raw_source_fields.relationship_label"), + }, + ), + OutputRowProjection( + row_id="birth_year", + profile_name="observation_numeric", + field_bindings={ + "observation_concept_id": 3051549, + "value_as_number": ContextFieldRef("source.raw_source_fields.birth_year"), + }, + ), + OutputRowProjection( + row_id="age_at_death", + profile_name="observation_numeric", + field_bindings={ + "observation_concept_id": 3051544, + "value_as_number": ContextFieldRef("source.raw_source_fields.age_at_death"), + }, + ), + OutputRowProjection( + row_id="age_at_onset", + profile_name="observation_numeric", + field_bindings={ + "observation_concept_id": 3039465, + "value_as_number": ContextFieldRef("source.raw_source_fields.age_at_onset"), + }, + ), + OutputRowProjection( + row_id="method", + profile_name="observation_string", + field_bindings={ + "observation_concept_id": 0, + "value_as_string": ContextFieldRef("source.raw_source_fields.method_label"), + }, + ), + OutputRowProjection( + row_id="primary_diagnosis", + profile_name="observation_coded", + field_bindings={ + "observation_concept_id": 4167217, + "value_as_concept_id": ContextFieldRef("grounded.concept_id"), + }, + special_value_policy=SpecialValuePolicy( + source_field=ContextFieldRef("source.raw_source_fields.primary_dx_code"), + allowed_special_values=_FH_NON_EMITTING_CODES, + suppression_mode="drop", + ), + ), + OutputRowProjection( + row_id="secondary_diagnosis", + profile_name="observation_coded", + field_bindings={ + "observation_concept_id": 4167217, + }, + ), + ), + derivation_rules=( + DerivationRule( + target_row="secondary_diagnosis", + target_slot="value_as_concept_id", + source_field=ContextFieldRef("source.raw_source_fields.secondary_dx_code"), + code_map=_FH_DIAGNOSIS_CODE_MAP, + suppress_codes=_FH_NON_EMITTING_CODES, + ), + ), + link_rules=( + OutputLinkRule("relative_identity", "birth_year", "same_relative"), + OutputLinkRule("relative_identity", "age_at_death", "same_relative"), + OutputLinkRule("relative_identity", "age_at_onset", "same_relative"), + OutputLinkRule("relative_identity", "method", "same_relative"), + OutputLinkRule("relative_identity", "primary_diagnosis", "same_relative"), + OutputLinkRule("relative_identity", "secondary_diagnosis", "same_relative"), + ), + notes=( + "Family-member bundle for one relative. This illustrates why semantic " + "projection is useful beyond one-row cases: one source relative can " + "emit multiple coordinated OMOP rows. The relationship and method " + "surfaces are kept as observation strings here because the current " + "demo does not assert a standard OMOP concept for them. Primary " + "diagnosis uses the grounded plain condition as a value-carried family-" + "history observation; secondary diagnosis resolves from " + "context.raw_source_fields.secondary_dx_code through the family-" + "history diagnosis code map.", + ), +) + + +CRITERIA_GATE_CONDITION = OutputDefinition( + name="criteria_gate_condition", + role="condition_modifier", + row_projections=( + OutputRowProjection( + row_id="condition", + profile_name="condition_simple", + field_bindings={ + "condition_concept_id": ContextFieldRef("grounded.concept_id"), + }, + special_value_policy=SpecialValuePolicy( + source_field=ContextFieldRef("source.raw_value"), + allowed_special_values=frozenset({"0"}), + suppression_mode="drop", + ), + ), + ), + notes=( + "Yes/No field phrased 'meets criteria for X' (e.g. 'does the " + "participant meet the criteria for dementia?'). Caller must populate " + "context.raw_value with the field's own raw code; '0' (No) suppresses " + "the row — a negative answer carries no positive clinical content of " + "its own.", + ), +) + + +YES_NO_OBSERVATION = OutputDefinition( + name="yes_no_observation", + role="coded_observation", + row_projections=( + OutputRowProjection( + row_id="observation", + profile_name="observation_coded", + field_bindings={ + "observation_concept_id": ContextFieldRef("grounded.concept_id"), + }, + ), + ), + derivation_rules=( + DerivationRule( + target_row="observation", + target_slot="value_as_concept_id", + source_field=ContextFieldRef("source.raw_value"), + code_map={ + "1": 45877994, # Yes + "0": 45878245, # No + }, + ), + ), + notes=( + "Ordinary Yes/No observation where both directions are informative. " + "Unlike a criteria gate, '0' (No) still produces a row: caller must " + "populate context.raw_value with '1' or '0', which maps into the OMOP " + "value_as_concept_id slot.", + ), +) + + +MEASUREMENT_NUMERIC_WITH_UNIT_FROM_CONTEXT = OutputDefinition( + name="measurement_numeric_with_unit_from_context", + role="quantitative_measurement", + row_projections=( + OutputRowProjection( + row_id="measurement", + profile_name="measurement_numeric_with_unit", + field_bindings={ + "measurement_concept_id": ContextFieldRef("grounded.concept_id"), + "value_as_number": ContextFieldRef("source.numeric_value"), + }, + ), + ), + derivation_rules=( + DerivationRule( + target_row="measurement", + target_slot="unit_concept_id", + source_field=ContextFieldRef("source.raw_source_fields.unit_code"), + code_map={ + "cm": 8582, + "kg": 9529, + "mm[Hg]": 8876, + }, + ), + ), + notes=( + "Quantitative measurement shape: caller populates context.numeric_value " + "with the numeric reading and context.raw_source_fields.unit_code with " + "a unit token ('cm', 'kg', or 'mm[Hg]'). This shows how a grounded " + "measurement concept can combine a literal numeric value with a " + "derived OMOP unit concept.", + ), +) + + +BUILTIN_DEFINITIONS: tuple[tuple[OutputDefinition, DefinitionTrigger], ...] = ( + (CONDITION_WITH_STATUS_FROM_SECONDARY_FIELD, DefinitionTrigger(domains=frozenset({"Condition"}))), + (FAMILY_HISTORY_CONDITION, DefinitionTrigger(domains=frozenset({"Condition"}))), + (FAMILY_MEMBER_HISTORY_BUNDLE, DefinitionTrigger(domains=frozenset({"Condition"}))), + (CRITERIA_GATE_CONDITION, DefinitionTrigger(domains=frozenset({"Condition"}))), + (YES_NO_OBSERVATION, DefinitionTrigger(domains=frozenset({"Observation"}))), + ( + MEASUREMENT_NUMERIC_WITH_UNIT_FROM_CONTEXT, + DefinitionTrigger(domains=frozenset({"Measurement"})), + ), +) diff --git a/src/groundworkers/services/semantic_projection/models.py b/src/groundworkers/services/semantic_projection/models.py new file mode 100644 index 0000000..dd04902 --- /dev/null +++ b/src/groundworkers/services/semantic_projection/models.py @@ -0,0 +1,82 @@ +"""Request/response models for deterministic semantic projection. + +Mirrors the contract in agent-stack's agentive-design/_design/SEMANTIC_INTEGRATION/ +03_groundworkers_mcp_contract.md. `context` carries whatever a definition needs +beyond the grounded concept itself: + +- `raw_value` — the grounded field's own raw source code. Consulted by a + `SpecialValuePolicy` (e.g. a "meets criteria for X" field, where the negative + answer suppresses the row). +- `raw_source_fields` — a mapping of well-known slot name -> raw value, for + definitions that resolve a row's slot from a *different* source field via a + `DerivationRule` (e.g. a diagnosis's Primary/Contributing/Non-contributing + role, collected in a sibling field). + +Other context keys (`numeric_value`, `unit_concept_id`, `operator_concept_id`, +etc.) are accepted for forward compatibility with definitions not yet +implemented and are passed through under `source.*` without validation. +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class SemanticProjectionRequest(BaseModel): + """Input to `SemanticProjectionService.project()`.""" + + model_config = ConfigDict(extra="forbid") + + grounded_concept_id: int + grounded_domain: str + grounded_concept_name: str | None = None + source_text: str | None = None + source_item_id: str | None = None + definition_hint: str | None = None + context: dict[str, Any] = Field(default_factory=dict) + + +class ProjectedRowModel(BaseModel): + row_id: str + table: str + fields: dict[str, Any] + + +class ProjectionLinkModel(BaseModel): + relationship_type: str + source_row: str + target_row: str + + +class SuppressedRowModel(BaseModel): + row_id: str + reason: str + source_field: str + source_code: str + + +class SemanticProjectionResult(BaseModel): + """Deterministic projection outcome, transport-ready for the MCP response. + + `status` semantics: + - `ok` — a definition matched and every row is fully bound. + - `partial` — a definition matched but some row fields still need more + context (see `unresolved_fields`). + - `suppressed` — a definition matched but every row it would have produced + was dropped by a `DerivationRule` or `SpecialValuePolicy` (see + `suppressed_rows`) — nothing should be written for this item. + - `no_match` — no definition matched (including an ambiguous match with no + `definition_hint` to disambiguate). + """ + + definition_name: str | None + role: str | None + status: Literal["ok", "partial", "suppressed", "no_match"] + rows: list[ProjectedRowModel] = Field(default_factory=list) + links: list[ProjectionLinkModel] = Field(default_factory=list) + constraint_checks: list[dict[str, Any]] = Field(default_factory=list) + unresolved_fields: list[dict[str, Any]] = Field(default_factory=list) + suppressed_rows: list[SuppressedRowModel] = Field(default_factory=list) + audit_notes: list[str] = Field(default_factory=list) diff --git a/src/groundworkers/services/semantic_projection/service.py b/src/groundworkers/services/semantic_projection/service.py new file mode 100644 index 0000000..0978430 --- /dev/null +++ b/src/groundworkers/services/semantic_projection/service.py @@ -0,0 +1,143 @@ +"""SemanticProjectionService — deterministic projection of a grounded concept +into one or more OMOP CDM rows. + +Wraps `omop_semantics`'s `OutputDefinitionRuntime`. No LLM call, no database +access: the definition catalogue and domain-based matching are entirely +in-process. Given the same request, this always produces the same result. + +Invalid definitions fail at construction time (server startup), not per +request — `OutputDefinitionRuntime` validates row/slot/link/derivation +references when it is built. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from omop_semantics.runtime import ( + OmopSemanticEngine, + OutputDefinition, + OutputDefinitionRuntime, + ProjectedOutputBundle, + derive_status, +) + +from groundworkers.services.semantic_projection.definitions import BUILTIN_DEFINITIONS, DefinitionTrigger +from groundworkers.services.semantic_projection.models import ( + ProjectedRowModel, + ProjectionLinkModel, + SemanticProjectionRequest, + SemanticProjectionResult, + SuppressedRowModel, +) + + +class SemanticProjectionService: + def __init__( + self, + definitions: Iterable[tuple[OutputDefinition, DefinitionTrigger]] | None = None, + ) -> None: + catalogue = tuple(definitions) if definitions is not None else BUILTIN_DEFINITIONS + engine = OmopSemanticEngine.from_yaml_paths(registry_paths=[], profile_paths=[]) + self._runtime = engine.build_output_definition_runtime([definition for definition, _ in catalogue]) + self._triggers: dict[str, DefinitionTrigger] = {definition.name: trigger for definition, trigger in catalogue} + + @property + def runtime(self) -> OutputDefinitionRuntime: + return self._runtime + + def project(self, request: SemanticProjectionRequest) -> SemanticProjectionResult: + definition_name = request.definition_hint + if definition_name is not None and definition_name not in self._triggers: + return SemanticProjectionResult( + definition_name=None, + role=None, + status="no_match", + audit_notes=[f"Unknown definition_hint '{definition_name}'"], + ) + + if definition_name is None: + definition_name = self._match_by_domain(request.grounded_domain) + if definition_name is None: + return SemanticProjectionResult( + definition_name=None, + role=None, + status="no_match", + audit_notes=[self._no_match_reason(request.grounded_domain)], + ) + + context = self._build_context(request) + bundle = self._runtime.project(definition_name, context) + return self._to_result(bundle) + + def _match_by_domain(self, domain: str) -> str | None: + matches = [ + name for name, trigger in self._triggers.items() if not trigger.domains or domain in trigger.domains + ] + if len(matches) == 1: + return matches[0] + return None + + def _no_match_reason(self, domain: str) -> str: + matches = sorted( + name for name, trigger in self._triggers.items() if not trigger.domains or domain in trigger.domains + ) + if len(matches) > 1: + return ( + f"{len(matches)} definitions match domain '{domain}' {matches}; " + "pass definition_hint to disambiguate" + ) + return f"No definition matched grounded concept and context (domain '{domain}')" + + @staticmethod + def _build_context(request: SemanticProjectionRequest) -> dict[str, Any]: + return { + "grounded": { + "concept_id": request.grounded_concept_id, + "domain": request.grounded_domain, + "name": request.grounded_concept_name, + }, + "source": dict(request.context), + } + + @staticmethod + def _to_result(bundle: ProjectedOutputBundle) -> SemanticProjectionResult: + rows = [ + ProjectedRowModel(row_id=row.row_id, table=row.profile.cdm_table, fields=dict(row.fields)) + for row in bundle.rows + ] + links = [ + ProjectionLinkModel( + relationship_type=link.relationship_type, + source_row=link.source_row, + target_row=link.target_row, + ) + for link in bundle.links + ] + suppressed_rows = [ + SuppressedRowModel( + row_id=row.row_id, + reason=row.reason, + source_field=row.source_field, + source_code=row.source_code, + ) + for row in bundle.suppressed_rows + ] + + status = derive_status( + has_rows=bool(bundle.rows), + has_unresolved=bool(bundle.unresolved_fields), + has_suppressed=bool(suppressed_rows), + ) + + return SemanticProjectionResult( + definition_name=bundle.definition_name, + role=bundle.role, + status=status, + rows=rows, + links=links, + constraint_checks=list(bundle.constraint_checks), + unresolved_fields=list(bundle.unresolved_fields), + suppressed_rows=suppressed_rows, + audit_notes=list(bundle.audit_notes), + ) diff --git a/src/groundworkers/services/semantic_projection/tui_launcher.py b/src/groundworkers/services/semantic_projection/tui_launcher.py new file mode 100644 index 0000000..3fc660e --- /dev/null +++ b/src/groundworkers/services/semantic_projection/tui_launcher.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Callable, Mapping + +from groundworkers.services.semantic_projection.models import ( + SemanticProjectionRequest, + SemanticProjectionResult, +) +from groundworkers.services.semantic_projection.service import SemanticProjectionService + +ProjectCallable = Callable[[str, Mapping[str, Any]], SemanticProjectionResult] +_REQUIRED_CONTEXT_FIELDS = ("grounded_concept_id", "grounded_domain") +_DEFAULT_SERVICE_PAYLOAD = { + "grounded_concept_id": 0, + "grounded_domain": "", + "grounded_concept_name": None, + "source_item_id": None, + "source_text": None, + "context": {}, +} +_EXAMPLE_SERVICE_PAYLOADS: dict[str, dict[str, Any]] = { + "condition_with_status_from_secondary_field": { + "grounded_concept_id": 4152280, + "grounded_domain": "Condition", + "grounded_concept_name": "Major depressive disorder", + "source_item_id": "majdepdx", + "source_text": "Major depressive disorder", + "context": {"raw_source_fields": {"role_field": "1"}}, + }, + "family_history_condition": { + "grounded_concept_id": 378419, + "grounded_domain": "Condition", + "grounded_concept_name": "Alzheimer's disease", + "source_item_id": "mometpr", + "source_text": "Family history primary diagnosis", + "context": {"raw_value": "01"}, + }, + "family_member_history_bundle": { + "grounded_concept_id": 378419, + "grounded_domain": "Condition", + "grounded_concept_name": "Alzheimer's disease", + "source_item_id": "mom_bundle", + "source_text": "Mother family history bundle", + "context": { + "raw_source_fields": { + "relationship_label": "Mother", + "birth_year": 1940, + "age_at_death": 82, + "age_at_onset": 74, + "method_label": "Records", + "primary_dx_code": "01", + "secondary_dx_code": "03", + } + }, + }, + "criteria_gate_condition": { + "grounded_concept_id": 4182210, + "grounded_domain": "Condition", + "grounded_concept_name": "Dementia", + "source_item_id": "demented", + "source_text": "Meets criteria for dementia", + "context": {"raw_value": "1"}, + }, + "yes_no_observation": { + "grounded_concept_id": 42710016, + "grounded_domain": "Observation", + "grounded_concept_name": "Normal cognition", + "source_item_id": "normcog", + "source_text": "Unimpaired cognition and behavior", + "context": {"raw_value": "1"}, + }, + "measurement_numeric_with_unit_from_context": { + "grounded_concept_id": 3036277, + "grounded_domain": "Measurement", + "grounded_concept_name": "Body height", + "source_item_id": "height", + "source_text": "Body height", + "context": { + "numeric_value": 172.4, + "raw_source_fields": {"unit_code": "cm"}, + }, + }, +} + + +def build_service_project_fn(service: SemanticProjectionService) -> ProjectCallable: + def project(definition_hint: str, raw: Mapping[str, Any]) -> SemanticProjectionResult: + missing = [field for field in _REQUIRED_CONTEXT_FIELDS if field not in raw] + if missing: + fields = ", ".join(missing) + raise ValueError(f"Missing required context field(s): {fields}") + request = SemanticProjectionRequest( + grounded_concept_id=raw["grounded_concept_id"], + grounded_domain=raw["grounded_domain"], + grounded_concept_name=raw.get("grounded_concept_name"), + source_text=raw.get("source_text"), + source_item_id=raw.get("source_item_id"), + definition_hint=definition_hint, + context=dict(raw.get("context") or {}), + ) + return service.project(request) + + return project + + +def build_service_payload_template(definition_name: str) -> dict[str, Any]: + """Return a TUI-friendly starter payload for one shipped definition.""" + + template = deepcopy(_DEFAULT_SERVICE_PAYLOAD) + template.update(deepcopy(_EXAMPLE_SERVICE_PAYLOADS.get(definition_name, {}))) + return template + + +class GroundworkersProjectionExplorer: + """Factory for a custom TUI app with clearer starter payloads.""" + + @staticmethod + def create(service: SemanticProjectionService): + from omop_semantics.runtime.tui import OutputDefinitionExplorer + + class _GroundworkersProjectionExplorer(OutputDefinitionExplorer): + def _context_template(self, definition_name: str) -> dict[str, Any]: + if self._service_mode: + return build_service_payload_template(definition_name) + return super()._context_template(definition_name) + + return _GroundworkersProjectionExplorer( + service.runtime, + project_fn=build_service_project_fn(service), + ) + + +def run_semantic_projection_tui(service: SemanticProjectionService | None = None) -> None: + try: + from omop_semantics.runtime.tui import OutputDefinitionExplorer + except ImportError as exc: # pragma: no cover - environment guard + raise RuntimeError( + "Semantic projection TUI requires the omop-semantics TUI dependencies. " + "Install groundworkers with its 'tui' extra or ensure textual is installed." + ) from exc + _ = OutputDefinitionExplorer + + active_service = service or SemanticProjectionService() + GroundworkersProjectionExplorer.create(active_service).run() diff --git a/src/groundworkers/tools/semantic_projection_tools.py b/src/groundworkers/tools/semantic_projection_tools.py new file mode 100644 index 0000000..420490a --- /dev/null +++ b/src/groundworkers/tools/semantic_projection_tools.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import ValidationError + +from groundworkers.base.errors import GroundworkersError +from groundworkers.base.server import GroundcrewServer +from groundworkers.services.semantic_projection.models import SemanticProjectionRequest +from groundworkers.services.semantic_projection.service import SemanticProjectionService + + +def register_semantic_projection_tools(server: GroundcrewServer, service: SemanticProjectionService) -> None: + @server.tool("semantic_project") + def semantic_project( + grounded_concept_id: int, + grounded_domain: str, + grounded_concept_name: str | None = None, + source_text: str | None = None, + source_item_id: str | None = None, + definition_hint: str | None = None, + context: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Deterministically project a grounded concept into one or more CDM rows. + + Given a concept already grounded by other tools (e.g. `concept_ground`), + selects a registered output definition and returns the CDM rows, links, + and any suppressed rows it produces. No LLM call — the same input always + produces the same output. + + *context* carries whatever the definition needs beyond the grounded + concept itself: + - `raw_value` — the grounded field's own raw source code, consulted when + the field's own value decides whether the row exists at all (e.g. a + "meets criteria for X" field, where a negative answer suppresses it). + - `raw_source_fields` — a mapping of well-known slot name to raw value, + for definitions that resolve a row's slot from a *different* source + field (e.g. a diagnosis's Primary/Contributing/Non-contributing role, + collected in a sibling field). Populate the key the definition + documents in its notes, not the field's actual name in your source + data. + + Pass *definition_hint* to select a definition explicitly. Required + whenever more than one registered definition could apply to the same + *grounded_domain* — domain alone does not disambiguate them, and this + tool will not guess. + + Returns `status` of `ok`, `partial`, `suppressed`, or `no_match`. Rows + dropped by a derivation rule or special-value policy are never silently + absent — they appear in `suppressed_rows` with the reason. + """ + try: + request = SemanticProjectionRequest( + grounded_concept_id=grounded_concept_id, + grounded_domain=grounded_domain, + grounded_concept_name=grounded_concept_name, + source_text=source_text, + source_item_id=source_item_id, + definition_hint=definition_hint, + context=context or {}, + ) + except ValidationError as exc: + return {"error": True, "code": "INVALID_INPUT", "message": str(exc)} + + try: + result = service.project(request) + return result.model_dump() + except GroundworkersError as exc: + return exc.to_dict() + except Exception as exc: + return {"error": True, "code": "QUERY_ERROR", "message": repr(exc)} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5194b66 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/tests/test_server_registry.py b/tests/test_server_registry.py index 062d16b..a4b23dd 100644 --- a/tests/test_server_registry.py +++ b/tests/test_server_registry.py @@ -393,6 +393,75 @@ def fake_run_rest_api(app_config, application, *, host: str, port: int) -> None: assert "run" not in captured +def test_main_blocks_tui_when_semantic_projection_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = build_app_config_from_stack(_stack_without_backends()) + captured: dict[str, object] = {} + + def fake_build_app_config(*, config_path=None, profile=None): + captured["build_app_config"] = { + "config_path": config_path, + "profile": profile, + } + return config + + def fake_launch_tui() -> None: + captured["launch"] = True + + monkeypatch.setattr("groundworkers.server.build_app_config", fake_build_app_config) + monkeypatch.setattr("groundworkers.server._launch_semantic_projection_tui", fake_launch_tui) + + with pytest.raises(RuntimeError, match="semantic_projection"): + main(["--tui"]) + + assert captured["build_app_config"] == { + "config_path": None, + "profile": None, + } + assert "launch" not in captured + + +def test_main_launches_tui_when_semantic_projection_is_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = build_app_config_from_stack( + StackConfig( + tools={ + "groundworkers": ToolConfig( + extra={ + "semantic_projection": { + "enabled": True, + } + } + ) + } + ) + ) + captured: dict[str, object] = {} + + def fake_build_app_config(*, config_path=None, profile=None): + captured["build_app_config"] = { + "config_path": config_path, + "profile": profile, + } + return config + + def fake_launch_tui() -> None: + captured["launch"] = True + + monkeypatch.setattr("groundworkers.server.build_app_config", fake_build_app_config) + monkeypatch.setattr("groundworkers.server._launch_semantic_projection_tui", fake_launch_tui) + + main(["--tui", "--profile", "test"]) + + assert captured["build_app_config"] == { + "config_path": None, + "profile": "test", + } + assert captured["launch"] is True + + def test_run_rest_api_uses_uvicorn(monkeypatch: pytest.MonkeyPatch) -> None: config = build_app_config_from_stack(_stack_without_backends()) app = build_application(config) diff --git a/tests/unit/test_semantic_projection_service.py b/tests/unit/test_semantic_projection_service.py new file mode 100644 index 0000000..2f93eb4 --- /dev/null +++ b/tests/unit/test_semantic_projection_service.py @@ -0,0 +1,335 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from omop_semantics.runtime import ContextFieldRef, OutputDefinition, OutputRowProjection + +from groundworkers.services.semantic_projection.definitions import DefinitionTrigger +from groundworkers.services.semantic_projection.models import SemanticProjectionRequest +from groundworkers.services.semantic_projection.service import SemanticProjectionService + + +def _request(**overrides) -> SemanticProjectionRequest: + defaults = { + "grounded_concept_id": 4152280, + "grounded_domain": "Condition", + "definition_hint": "condition_with_status_from_secondary_field", + "context": {"raw_source_fields": {"role_field": "1"}}, + } + defaults.update(overrides) + return SemanticProjectionRequest(**defaults) + + +def test_condition_with_status_projects_primary_role() -> None: + service = SemanticProjectionService() + + result = service.project(_request()) + + assert result.status == "ok" + assert result.definition_name == "condition_with_status_from_secondary_field" + assert len(result.rows) == 1 + assert result.rows[0].table == "condition_occurrence" + assert result.rows[0].fields == { + "condition_concept_id": 4152280, + "condition_status_concept_id": 32902, + } + assert result.suppressed_rows == [] + + +def test_condition_with_status_maps_contributing_to_secondary_diagnosis() -> None: + service = SemanticProjectionService() + + result = service.project( + _request(context={"raw_source_fields": {"role_field": "2"}}) + ) + + assert result.rows[0].fields["condition_status_concept_id"] == 32908 + + +def test_condition_with_status_suppresses_non_contributing_role() -> None: + service = SemanticProjectionService() + + result = service.project( + _request(context={"raw_source_fields": {"role_field": "3"}}) + ) + + assert result.status == "suppressed" + assert result.rows == [] + assert len(result.suppressed_rows) == 1 + suppressed = result.suppressed_rows[0] + assert suppressed.row_id == "condition" + assert suppressed.source_field == "source.raw_source_fields.role_field" + assert suppressed.source_code == "3" + + +def test_condition_with_status_missing_secondary_field_is_partial_not_suppressed() -> None: + service = SemanticProjectionService() + + result = service.project(_request(context={})) + + assert result.status == "partial" + assert result.rows == [] + assert result.suppressed_rows == [] + assert result.unresolved_fields == [ + { + "row_id": "condition", + "missing_fields": ["condition_status_concept_id"], + "required": True, + } + ] + + +def test_criteria_gate_condition_keeps_row_on_positive_answer() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="criteria_gate_condition", + grounded_concept_id=4182210, + context={"raw_value": "1"}, + ) + ) + + assert result.status == "ok" + assert result.rows[0].fields == {"condition_concept_id": 4182210} + + +def test_criteria_gate_condition_suppresses_negative_answer() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="criteria_gate_condition", + grounded_concept_id=4182210, + context={"raw_value": "0"}, + ) + ) + + assert result.status == "suppressed" + assert result.rows == [] + assert result.suppressed_rows[0].source_code == "0" + + +def test_family_history_condition_projects_value_carried_observation() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="family_history_condition", + grounded_concept_id=378419, + context={"raw_value": "01"}, + ) + ) + + assert result.status == "ok" + assert result.rows[0].table == "observation" + assert result.rows[0].fields == { + "observation_concept_id": 4167217, + "value_as_concept_id": 378419, + } + + +def test_family_history_condition_suppresses_non_emitting_code() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="family_history_condition", + grounded_concept_id=378419, + context={"raw_value": "88"}, + ) + ) + + assert result.status == "suppressed" + assert result.rows == [] + assert result.suppressed_rows[0].source_code == "88" + + +def test_family_member_history_bundle_projects_multiple_rows_and_links() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="family_member_history_bundle", + grounded_concept_id=378419, + context={ + "raw_source_fields": { + "relationship_label": "Mother", + "birth_year": 1940, + "age_at_death": 82, + "age_at_onset": 74, + "method_label": "Records", + "primary_dx_code": "01", + "secondary_dx_code": "03", + } + }, + ) + ) + + assert result.status == "ok" + assert [row.row_id for row in result.rows] == [ + "relative_identity", + "birth_year", + "age_at_death", + "age_at_onset", + "method", + "primary_diagnosis", + "secondary_diagnosis", + ] + assert result.rows[0].fields == { + "observation_concept_id": 0, + "value_as_string": "Mother", + } + assert result.rows[-2].fields == { + "observation_concept_id": 4167217, + "value_as_concept_id": 378419, + } + assert result.rows[-1].fields == { + "observation_concept_id": 4167217, + "value_as_concept_id": 443605, + } + assert len(result.links) == 6 + assert result.suppressed_rows == [] + + +def test_family_member_history_bundle_suppresses_secondary_diagnosis_on_non_emitting_code() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="family_member_history_bundle", + grounded_concept_id=378419, + context={ + "raw_source_fields": { + "relationship_label": "Mother", + "birth_year": 1940, + "age_at_death": 82, + "age_at_onset": 74, + "method_label": "Records", + "primary_dx_code": "01", + "secondary_dx_code": "88", + } + }, + ) + ) + + assert result.status == "partial" + assert result.suppressed_rows[0].row_id == "secondary_diagnosis" + assert result.suppressed_rows[0].source_code == "88" + assert result.links[-1].target_row == "primary_diagnosis" + assert result.unresolved_fields == [ + { + "link": { + "source_row": "relative_identity", + "target_row": "secondary_diagnosis", + "relationship_type": "same_relative", + }, + "missing_rows": ["secondary_diagnosis"], + } + ] + + +def test_yes_no_observation_keeps_no_answer_as_informative_row() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="yes_no_observation", + grounded_concept_id=42710016, + grounded_domain="Observation", + context={"raw_value": "0"}, + ) + ) + + assert result.status == "ok" + assert result.rows[0].table == "observation" + assert result.rows[0].fields == { + "observation_concept_id": 42710016, + "value_as_concept_id": 45878245, + } + + +def test_measurement_numeric_with_unit_projects_numeric_and_derived_unit() -> None: + service = SemanticProjectionService() + + result = service.project( + _request( + definition_hint="measurement_numeric_with_unit_from_context", + grounded_concept_id=3036277, + grounded_domain="Measurement", + context={ + "numeric_value": 172.4, + "raw_source_fields": {"unit_code": "cm"}, + }, + ) + ) + + assert result.status == "ok" + assert result.rows[0].table == "measurement" + assert result.rows[0].fields == { + "measurement_concept_id": 3036277, + "value_as_number": 172.4, + "unit_concept_id": 8582, + } + + +def test_no_hint_reports_no_match_when_domain_is_ambiguous() -> None: + service = SemanticProjectionService() + + result = service.project(_request(definition_hint=None)) + + assert result.status == "no_match" + assert result.definition_name is None + assert "4 definitions match domain 'Condition'" in result.audit_notes[0] + + +def test_unknown_definition_hint_reports_no_match() -> None: + service = SemanticProjectionService() + + result = service.project(_request(definition_hint="not_a_real_definition")) + + assert result.status == "no_match" + assert result.audit_notes == ["Unknown definition_hint 'not_a_real_definition'"] + + +def test_domain_only_matching_resolves_observation_definition_from_builtins() -> None: + service = SemanticProjectionService() + + result = service.project( + SemanticProjectionRequest( + grounded_concept_id=42710016, + grounded_domain="Observation", + context={"raw_value": "1"}, + ) + ) + + assert result.status == "ok" + assert result.definition_name == "yes_no_observation" + + +def test_domain_only_matching_resolves_a_single_unambiguous_candidate() -> None: + definition = OutputDefinition( + name="observation_demo", + role="observation", + row_projections=( + OutputRowProjection( + row_id="observation", + profile_name="observation_simple", + field_bindings={"observation_concept_id": ContextFieldRef("grounded.concept_id")}, + ), + ), + ) + service = SemanticProjectionService( + definitions=((definition, DefinitionTrigger(domains=frozenset({"Observation"}))),) + ) + + result = service.project( + SemanticProjectionRequest(grounded_concept_id=999, grounded_domain="Observation") + ) + + assert result.status == "ok" + assert result.definition_name == "observation_demo" diff --git a/tests/unit/test_semantic_projection_tools.py b/tests/unit/test_semantic_projection_tools.py new file mode 100644 index 0000000..2bf041c --- /dev/null +++ b/tests/unit/test_semantic_projection_tools.py @@ -0,0 +1,122 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from groundworkers.base.errors import GroundworkersError +from groundworkers.base.server import GroundcrewServer +from groundworkers.services.semantic_projection.models import ( + ProjectedRowModel, + SemanticProjectionRequest, + SemanticProjectionResult, +) +from groundworkers.tools.semantic_projection_tools import register_semantic_projection_tools + + +class StubSemanticProjectionService: + def __init__(self, result: SemanticProjectionResult | None = None, error: Exception | None = None) -> None: + self.result = result + self.error = error + self.calls: list[SemanticProjectionRequest] = [] + + def project(self, request: SemanticProjectionRequest) -> SemanticProjectionResult: + self.calls.append(request) + if self.error is not None: + raise self.error + assert self.result is not None + return self.result + + +def test_semantic_project_tool_returns_result_dict() -> None: + service = StubSemanticProjectionService( + result=SemanticProjectionResult( + definition_name="condition_with_status_from_secondary_field", + role="condition_modifier", + status="ok", + rows=[ + ProjectedRowModel( + row_id="condition", + table="condition_occurrence", + fields={"condition_concept_id": 4152280, "condition_status_concept_id": 32902}, + ) + ], + ) + ) + server = GroundcrewServer("test-server") + register_semantic_projection_tools(server, service) # type: ignore[arg-type] + + result = server.call( + "semantic_project", + grounded_concept_id=4152280, + grounded_domain="Condition", + definition_hint="condition_with_status_from_secondary_field", + context={"raw_source_fields": {"role_field": "1"}}, + ) + + assert result["status"] == "ok" + assert result["rows"][0]["fields"] == { + "condition_concept_id": 4152280, + "condition_status_concept_id": 32902, + } + assert len(service.calls) == 1 + assert service.calls[0].grounded_concept_id == 4152280 + assert service.calls[0].context == {"raw_source_fields": {"role_field": "1"}} + + +def test_semantic_project_tool_defaults_context_to_empty_dict() -> None: + service = StubSemanticProjectionService( + result=SemanticProjectionResult(definition_name=None, role=None, status="no_match") + ) + server = GroundcrewServer("test-server") + register_semantic_projection_tools(server, service) # type: ignore[arg-type] + + server.call("semantic_project", grounded_concept_id=1, grounded_domain="Observation") + + assert service.calls[0].context == {} + + +def test_semantic_project_tool_returns_groundworkers_error_dict() -> None: + service = StubSemanticProjectionService(error=GroundworkersError("QUERY_ERROR", "definition compile failed")) + server = GroundcrewServer("test-server") + register_semantic_projection_tools(server, service) # type: ignore[arg-type] + + result = server.call("semantic_project", grounded_concept_id=1, grounded_domain="Condition") + + assert result == { + "error": True, + "code": "QUERY_ERROR", + "message": "definition compile failed", + } + + +def test_semantic_project_tool_returns_invalid_input_for_bad_request_shape() -> None: + service = StubSemanticProjectionService( + result=SemanticProjectionResult(definition_name=None, role=None, status="no_match") + ) + server = GroundcrewServer("test-server") + register_semantic_projection_tools(server, service) # type: ignore[arg-type] + + result = server.call( + "semantic_project", + grounded_concept_id="not-an-int-and-not-numeric", + grounded_domain="Condition", + ) + + assert result["error"] is True + assert result["code"] == "INVALID_INPUT" + assert service.calls == [] + + +def test_semantic_project_tool_returns_query_error_for_unexpected_exception() -> None: + service = StubSemanticProjectionService(error=RuntimeError("boom")) + server = GroundcrewServer("test-server") + register_semantic_projection_tools(server, service) # type: ignore[arg-type] + + result = server.call("semantic_project", grounded_concept_id=1, grounded_domain="Condition") + + assert result["error"] is True + assert result["code"] == "QUERY_ERROR" + assert "boom" in result["message"] diff --git a/tests/unit/test_semantic_projection_tui_launcher.py b/tests/unit/test_semantic_projection_tui_launcher.py new file mode 100644 index 0000000..f193d81 --- /dev/null +++ b/tests/unit/test_semantic_projection_tui_launcher.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from groundworkers.services.semantic_projection.models import SemanticProjectionRequest +from groundworkers.services.semantic_projection.service import SemanticProjectionService +from groundworkers.services.semantic_projection.tui_launcher import ( + build_service_payload_template, + build_service_project_fn, +) + + +def test_service_project_fn_matches_direct_service_call() -> None: + service = SemanticProjectionService() + project = build_service_project_fn(service) + + via_adapter = project( + "condition_with_status_from_secondary_field", + { + "grounded_concept_id": 4152280, + "grounded_domain": "Condition", + "context": {"raw_source_fields": {"role_field": "1"}}, + }, + ) + + direct = service.project( + SemanticProjectionRequest( + grounded_concept_id=4152280, + grounded_domain="Condition", + definition_hint="condition_with_status_from_secondary_field", + context={"raw_source_fields": {"role_field": "1"}}, + ) + ) + + assert via_adapter.model_dump() == direct.model_dump() + + +def test_service_project_fn_reports_missing_required_context_fields() -> None: + service = SemanticProjectionService() + project = build_service_project_fn(service) + + with pytest.raises(ValueError, match="grounded_domain"): + project( + "condition_with_status_from_secondary_field", + { + "grounded_concept_id": 4152280, + "context": {"raw_source_fields": {"role_field": "1"}}, + }, + ) + + +def test_service_project_fn_treats_null_context_as_empty_mapping() -> None: + service = SemanticProjectionService() + project = build_service_project_fn(service) + + result = project( + "criteria_gate_condition", + { + "grounded_concept_id": 4152280, + "grounded_domain": "Condition", + "context": None, + }, + ) + + assert result.definition_name == "criteria_gate_condition" + + +def test_service_payload_template_uses_explanatory_family_history_example() -> None: + payload = build_service_payload_template("family_history_condition") + + assert payload == { + "grounded_concept_id": 378419, + "grounded_domain": "Condition", + "grounded_concept_name": "Alzheimer's disease", + "source_item_id": "mometpr", + "source_text": "Family history primary diagnosis", + "context": {"raw_value": "01"}, + } + + +def test_service_payload_template_uses_bundle_example_for_family_member_history() -> None: + payload = build_service_payload_template("family_member_history_bundle") + + assert payload == { + "grounded_concept_id": 378419, + "grounded_domain": "Condition", + "grounded_concept_name": "Alzheimer's disease", + "source_item_id": "mom_bundle", + "source_text": "Mother family history bundle", + "context": { + "raw_source_fields": { + "relationship_label": "Mother", + "birth_year": 1940, + "age_at_death": 82, + "age_at_onset": 74, + "method_label": "Records", + "primary_dx_code": "01", + "secondary_dx_code": "03", + } + }, + } + + +def test_service_payload_template_falls_back_to_generic_shape_for_unknown_definition() -> None: + payload = build_service_payload_template("unknown_definition") + + assert payload == { + "grounded_concept_id": 0, + "grounded_domain": "", + "grounded_concept_name": None, + "source_item_id": None, + "source_text": None, + "context": {}, + } diff --git a/uv.lock b/uv.lock index c362fb3..6577a7d 100644 --- a/uv.lock +++ b/uv.lock @@ -74,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + [[package]] name = "arrow" version = "1.4.0" @@ -439,6 +448,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + [[package]] name = "coverage" version = "7.14.3" @@ -572,6 +590,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/97/db70517204cd8aec8cb0773036333407405f6552bf928234d68531b0acd2/curies-0.13.13-py3-none-any.whl", hash = "sha256:43051140cd1f089a832f139633b14d473c09fa48a2f36e1b9b94c1270812267e", size = 82043, upload-time = "2026-06-12T10:27:51.334Z" }, ] +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + [[package]] name = "decorator" version = "5.3.1" @@ -869,6 +908,7 @@ dependencies = [ { name = "oa-configurator" }, { name = "omop-emb" }, { name = "omop-graph" }, + { name = "omop-semantics" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pyyaml" }, @@ -894,6 +934,7 @@ dev = [ { name = "pytest-cov" }, { name = "requests" }, { name = "ruff" }, + { name = "textual" }, { name = "tornado" }, ] docx = [ @@ -911,6 +952,9 @@ llm = [ pdf = [ { name = "pdfplumber" }, ] +tui = [ + { name = "omop-semantics", extra = ["tui"] }, +] xlsx = [ { name = "openpyxl" }, ] @@ -931,6 +975,8 @@ requires-dist = [ { name = "omop-emb", extras = ["faiss-cpu"], marker = "extra == 'embedding-faiss'", specifier = ">=1.1.1" }, { name = "omop-emb", extras = ["pgvector"], marker = "extra == 'embedding-pgvector'", specifier = ">=1.1.1" }, { name = "omop-graph", specifier = ">=1.3.0" }, + { name = "omop-semantics", specifier = ">=0.4.0" }, + { name = "omop-semantics", extras = ["tui"], marker = "extra == 'tui'", specifier = ">=0.4.0" }, { name = "openai", marker = "extra == 'llm'", specifier = ">=1.0" }, { name = "openpyxl", marker = "extra == 'all-source'", specifier = ">=3.1,<4" }, { name = "openpyxl", marker = "extra == 'xlsx'", specifier = ">=3.1,<4" }, @@ -946,10 +992,11 @@ requires-dist = [ { name = "requests", marker = "extra == 'dev'", specifier = ">=2.33.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "sqlalchemy", specifier = ">=2,<3" }, + { name = "textual", marker = "extra == 'dev'", specifier = ">=0.60" }, { name = "tornado", marker = "extra == 'dev'", specifier = ">=6.5.5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29,<1.0" }, ] -provides-extras = ["llm", "xlsx", "pdf", "docx", "all-source", "embedding-pgvector", "embedding-faiss", "dev"] +provides-extras = ["llm", "xlsx", "pdf", "tui", "docx", "all-source", "embedding-pgvector", "embedding-faiss", "dev"] [[package]] name = "h11" @@ -1175,6 +1222,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + [[package]] name = "ipython" version = "9.15.0" @@ -1432,6 +1503,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + [[package]] name = "kgcl-rdflib" version = "0.5.0" @@ -1529,6 +1630,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "linkml" version = "1.11.1" @@ -1704,6 +1817,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1810,6 +1928,18 @@ cli = [ { name = "typer" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -2043,6 +2173,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/cb/091207dc0927363e48b2f92f79a8d8551a237842f535a8141ec533a16068/ndex2-3.11.0-py2.py3-none-any.whl", hash = "sha256:cafb2efa38e9faf3f7e47805429d2498cfd127b5a452df020bf3c8e199c9c072", size = 78233, upload-time = "2025-07-23T16:55:56.503Z" }, ] +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -2242,6 +2381,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7c/c8a82d69000c7a946906ad085a1f5ccbcd1f18c3df7aa75f55fe36925244/omop_graph-1.3.0-py3-none-any.whl", hash = "sha256:e58f51eb056d0df69509aa91d2139d6ccf7fed4aef482096f3762d3b8a096455", size = 79859, upload-time = "2026-07-03T02:09:19.9Z" }, ] +[[package]] +name = "omop-semantics" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "linkml" }, + { name = "linkml-runtime" }, + { name = "python-dotenv" }, + { name = "ruamel-yaml" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/98/9123288af9f8406ea775be80002a8e753adcd4f15994ec097e3b908fa4dd/omop_semantics-0.4.0.tar.gz", hash = "sha256:3fa16f7561ba5c7ba65bbeb360518089fc182ac7bf6047fac54fdf8bf5687a00", size = 43823, upload-time = "2026-07-19T05:42:30.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/e8/3f399259bc8116a8825c43e34baf44357f8c528c2a8451d064c8309cac14/omop_semantics-0.4.0-py3-none-any.whl", hash = "sha256:4dc0bff6502509fc3c4a98ece21b60d8195a65e792ca5898dd51bf20f2d814c4", size = 64858, upload-time = "2026-07-19T05:42:29.67Z" }, +] + +[package.optional-dependencies] +tui = [ + { name = "textual" }, +] + [[package]] name = "ontoportal-client" version = "0.0.9" @@ -3163,6 +3325,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + [[package]] name = "ratelimit" version = "2.2.1" @@ -3417,6 +3622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "ruff" version = "0.15.20" @@ -3792,6 +4006,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "tomli-w" version = "1.2.0" @@ -3884,6 +4115,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "uri-template" version = "1.3.0"