groundstore 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- groundstore-0.1.0/.github/PULL_REQUEST_TEMPLATE.md +10 -0
- groundstore-0.1.0/.github/release-drafter.yml +31 -0
- groundstore-0.1.0/.github/workflows/ci.yml +13 -0
- groundstore-0.1.0/.github/workflows/merge.yml +12 -0
- groundstore-0.1.0/.github/workflows/publish.yml +24 -0
- groundstore-0.1.0/.gitignore +43 -0
- groundstore-0.1.0/CONTRIBUTING.md +18 -0
- groundstore-0.1.0/PKG-INFO +14 -0
- groundstore-0.1.0/README.md +34 -0
- groundstore-0.1.0/pyproject.toml +47 -0
- groundstore-0.1.0/src/groundstore/__init__.py +30 -0
- groundstore-0.1.0/src/groundstore/context.py +245 -0
- groundstore-0.1.0/src/groundstore/contracts.py +108 -0
- groundstore-0.1.0/src/groundstore/engine.py +64 -0
- groundstore-0.1.0/src/groundstore/models.py +219 -0
- groundstore-0.1.0/src/groundstore/py.typed +0 -0
- groundstore-0.1.0/src/groundstore/store.py +398 -0
- groundstore-0.1.0/tests/conftest.py +17 -0
- groundstore-0.1.0/tests/fixtures/mapping_contract_cases.json +180 -0
- groundstore-0.1.0/tests/test_context.py +81 -0
- groundstore-0.1.0/tests/test_contract_fixtures.py +59 -0
- groundstore-0.1.0/tests/test_oa_resolution.py +35 -0
- groundstore-0.1.0/tests/test_store.py +154 -0
- groundstore-0.1.0/uv.lock +525 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## Summary
|
|
2
|
+
|
|
3
|
+
<!-- What does this PR do? This text becomes the changelog entry. Leave blank for chore PRs. -->
|
|
4
|
+
|
|
5
|
+
## Checklist
|
|
6
|
+
|
|
7
|
+
- [ ] Applied exactly one label (`breaking`, `feature`, `fix`, `dependencies`, or `chore`)
|
|
8
|
+
- [ ] Tests pass locally (`uv run pytest -q`)
|
|
9
|
+
- [ ] Type checking passes (`uv run ty check src/`)
|
|
10
|
+
- [ ] Lint passes (`uv run ruff check .`)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name-template: 'v$RESOLVED_VERSION'
|
|
2
|
+
tag-template: 'v$RESOLVED_VERSION'
|
|
3
|
+
commitish: main
|
|
4
|
+
|
|
5
|
+
categories:
|
|
6
|
+
- title: Breaking Changes
|
|
7
|
+
labels: ['breaking']
|
|
8
|
+
- title: Features
|
|
9
|
+
labels: ['feature']
|
|
10
|
+
- title: Fixes
|
|
11
|
+
labels: ['fix']
|
|
12
|
+
- title: Dependencies
|
|
13
|
+
labels: ['dependencies']
|
|
14
|
+
|
|
15
|
+
template: |
|
|
16
|
+
$CHANGES
|
|
17
|
+
|
|
18
|
+
change-template: '- **$TITLE** (#$NUMBER) @$AUTHOR'
|
|
19
|
+
|
|
20
|
+
version-resolver:
|
|
21
|
+
major:
|
|
22
|
+
labels: ['breaking']
|
|
23
|
+
minor:
|
|
24
|
+
labels: ['feature']
|
|
25
|
+
patch:
|
|
26
|
+
labels: ['fix', 'dependencies']
|
|
27
|
+
default: patch
|
|
28
|
+
|
|
29
|
+
exclude-labels: ['chore']
|
|
30
|
+
|
|
31
|
+
autolabeler: []
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
types: [opened, synchronize, reopened, labeled, unlabeled]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
label-gate:
|
|
10
|
+
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/label-gate.yml@main
|
|
11
|
+
|
|
12
|
+
build-test:
|
|
13
|
+
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test.yml@main
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: Release Update
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [closed]
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
draft:
|
|
10
|
+
if: github.event.pull_request.merged == true
|
|
11
|
+
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/release-drafter.yml@main
|
|
12
|
+
secrets: inherit
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
uses: AustralianCancerDataNetwork/cava-devops/.github/workflows/publish.yml@main
|
|
10
|
+
|
|
11
|
+
publish:
|
|
12
|
+
needs: build
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
environment:
|
|
17
|
+
name: pypi
|
|
18
|
+
url: https://pypi.org/p/groundstore
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/download-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python bytecode and local tooling
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
.venv/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.pyright/
|
|
10
|
+
.tox/
|
|
11
|
+
.nox/
|
|
12
|
+
.hypothesis/
|
|
13
|
+
|
|
14
|
+
# Build and distribution artefacts
|
|
15
|
+
build/
|
|
16
|
+
dist/
|
|
17
|
+
*.egg-info/
|
|
18
|
+
*.egg
|
|
19
|
+
.eggs/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
|
|
24
|
+
# Local configuration and credentials
|
|
25
|
+
.env
|
|
26
|
+
.env.*
|
|
27
|
+
!.env.example
|
|
28
|
+
*.local.toml
|
|
29
|
+
*.local.yaml
|
|
30
|
+
|
|
31
|
+
# Local databases and generated application data
|
|
32
|
+
*.db
|
|
33
|
+
*.db-*
|
|
34
|
+
data/
|
|
35
|
+
site/
|
|
36
|
+
_design/
|
|
37
|
+
|
|
38
|
+
# Editors and operating-system files
|
|
39
|
+
.DS_Store
|
|
40
|
+
.idea/
|
|
41
|
+
.vscode/
|
|
42
|
+
*.swp
|
|
43
|
+
*.swo
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Development setup
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv sync --all-extras --dev
|
|
7
|
+
uv run ty check src/
|
|
8
|
+
uv run ruff check .
|
|
9
|
+
uv run pytest -q
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Pull requests should carry exactly one of the repository labels:
|
|
13
|
+
`breaking`, `feature`, `fix`, `dependencies`, or `chore`. Public API changes
|
|
14
|
+
are breaking changes; compatible functionality is a feature; CI, refactoring,
|
|
15
|
+
tests, and documentation are chore changes.
|
|
16
|
+
|
|
17
|
+
Package versions are derived from Git tags. Releases are published from a
|
|
18
|
+
maintainer-approved `vX.Y.Z` tag through the repository's PyPI environment.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: groundstore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared mapping-task contracts for Groundworkers workflows.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: oa-configurator<2,>=1.3.0
|
|
7
|
+
Requires-Dist: pydantic<3,>=2
|
|
8
|
+
Requires-Dist: sqlalchemy<3,>=2.0.45
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
11
|
+
Requires-Dist: ruff<1,>=0.4; extra == 'dev'
|
|
12
|
+
Requires-Dist: ty>=0.0.59; extra == 'dev'
|
|
13
|
+
Provides-Extra: postgres
|
|
14
|
+
Requires-Dist: psycopg[binary]<4,>=3.1; extra == 'postgres'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# groundstore
|
|
2
|
+
|
|
3
|
+
Shared mapping-task contracts and persistence for Groundworkers-adjacent
|
|
4
|
+
mapping workflows.
|
|
5
|
+
|
|
6
|
+
## Current status
|
|
7
|
+
|
|
8
|
+
The package provides source-independent Pydantic contracts, SQLAlchemy models,
|
|
9
|
+
and a resumable `MappingStore` for runs, inputs, candidates, separate evidence
|
|
10
|
+
records, versioned decisions, decision history, provenance, and lifecycle
|
|
11
|
+
state. It can use an explicit SQLAlchemy URL for tests or resolve a named
|
|
12
|
+
database resource through `oa-configurator` for consuming packages.
|
|
13
|
+
|
|
14
|
+
`MappingReadContext` exposes common read-only status, coverage, evidence-packet,
|
|
15
|
+
and review-handoff operations. Packets are JSON-safe and carry the full run,
|
|
16
|
+
input, candidate, evidence, and decision-history lineage, so Groundworkers or a
|
|
17
|
+
standalone reviewer can consume the same handoff without importing SQLAlchemy
|
|
18
|
+
models.
|
|
19
|
+
|
|
20
|
+
Representative contract fixtures under `tests/fixtures/` cover one-to-one,
|
|
21
|
+
one-to-many, ambiguous, redirected/incomplete, unmappable, and retryable
|
|
22
|
+
failure outcomes.
|
|
23
|
+
|
|
24
|
+
## Development
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync --all-extras --dev
|
|
28
|
+
uv run ty check src/
|
|
29
|
+
uv run ruff check .
|
|
30
|
+
uv run pytest -q
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The package version is derived from a `vX.Y.Z` or `X.Y.Z` Git tag, with a
|
|
34
|
+
`0.1.0` fallback when Git metadata is unavailable.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "groundstore"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Shared mapping-task contracts for Groundworkers workflows."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"oa-configurator>=1.3.0,<2",
|
|
8
|
+
"pydantic>=2,<3",
|
|
9
|
+
"sqlalchemy>=2.0.45,<3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=8,<9",
|
|
15
|
+
"ruff>=0.4,<1",
|
|
16
|
+
"ty>=0.0.59",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
postgres = [
|
|
20
|
+
"psycopg[binary]>=3.1,<4",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version]
|
|
28
|
+
source = "vcs"
|
|
29
|
+
fallback-version = "0.1.0"
|
|
30
|
+
raw-options = { tag_regex = '^v?(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$' }
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/groundstore"]
|
|
34
|
+
|
|
35
|
+
[tool.uv]
|
|
36
|
+
cache-keys = [
|
|
37
|
+
{ file = "pyproject.toml" },
|
|
38
|
+
{ git = { commit = true, tags = true } },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.ruff]
|
|
42
|
+
line-length = 100
|
|
43
|
+
target-version = "py312"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
addopts = "-q"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared mapping-task contracts and persistence for Groundworkers workflows."""
|
|
2
|
+
|
|
3
|
+
from .context import MappingEvidencePacket, MappingReadContext, MappingReviewHandoff
|
|
4
|
+
from .contracts import (
|
|
5
|
+
DecisionStatus,
|
|
6
|
+
LifecycleStatus,
|
|
7
|
+
MappingCandidateSpec,
|
|
8
|
+
MappingDecisionSpec,
|
|
9
|
+
MappingEvidenceSpec,
|
|
10
|
+
MappingInputSpec,
|
|
11
|
+
MappingRunSpec,
|
|
12
|
+
)
|
|
13
|
+
from .engine import create_groundstore_engine, create_schema
|
|
14
|
+
from .store import MappingStore
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DecisionStatus",
|
|
18
|
+
"LifecycleStatus",
|
|
19
|
+
"MappingCandidateSpec",
|
|
20
|
+
"MappingDecisionSpec",
|
|
21
|
+
"MappingEvidencePacket",
|
|
22
|
+
"MappingEvidenceSpec",
|
|
23
|
+
"MappingInputSpec",
|
|
24
|
+
"MappingReadContext",
|
|
25
|
+
"MappingReviewHandoff",
|
|
26
|
+
"MappingRunSpec",
|
|
27
|
+
"MappingStore",
|
|
28
|
+
"create_groundstore_engine",
|
|
29
|
+
"create_schema",
|
|
30
|
+
]
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Read-only views over persisted mapping work.
|
|
2
|
+
|
|
3
|
+
The persistence API remains useful to standalone writers. This module adds
|
|
4
|
+
the small, transport-neutral packet that a host or review client can consume
|
|
5
|
+
without depending on SQLAlchemy model instances or lazy relationships.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
14
|
+
|
|
15
|
+
from .models import (
|
|
16
|
+
MappingCandidate,
|
|
17
|
+
MappingDecision,
|
|
18
|
+
MappingDecisionEvent,
|
|
19
|
+
MappingEvidence,
|
|
20
|
+
MappingInput,
|
|
21
|
+
MappingRun,
|
|
22
|
+
)
|
|
23
|
+
from .store import MappingStore
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MappingEvidencePacket(BaseModel):
|
|
27
|
+
"""Stable JSON contract for reviewing one persisted mapping input."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
schema_version: str = "groundstore.mapping-evidence-packet.v1"
|
|
32
|
+
run: dict[str, Any]
|
|
33
|
+
input: dict[str, Any]
|
|
34
|
+
candidates: list[dict[str, Any]] = Field(default_factory=list)
|
|
35
|
+
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
|
36
|
+
decision: dict[str, Any] | None = None
|
|
37
|
+
warnings: list[str] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MappingReviewHandoff(BaseModel):
|
|
41
|
+
"""Review-task reference carrying a self-contained evidence packet."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
schema_version: str = "groundstore.mapping-review-handoff.v1"
|
|
46
|
+
task_id: str = Field(min_length=1)
|
|
47
|
+
task_type: str = "mapping_review"
|
|
48
|
+
source_namespace: str = Field(min_length=1)
|
|
49
|
+
input_id: str = Field(min_length=1)
|
|
50
|
+
decision_status: str = "needs_review"
|
|
51
|
+
packet: MappingEvidencePacket
|
|
52
|
+
requested_by: str | None = None
|
|
53
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class MappingReadContext:
|
|
58
|
+
"""Read-only Groundstore façade for host tools and review consumers."""
|
|
59
|
+
|
|
60
|
+
store: MappingStore
|
|
61
|
+
|
|
62
|
+
def status(
|
|
63
|
+
self,
|
|
64
|
+
source_namespace: str,
|
|
65
|
+
*,
|
|
66
|
+
target_system: str | None = None,
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
"""Return the latest complete run and its common coverage summary."""
|
|
69
|
+
|
|
70
|
+
run = self.store.latest_successful_run(source_namespace, target_system=target_system)
|
|
71
|
+
if run is None:
|
|
72
|
+
return {
|
|
73
|
+
"source_namespace": source_namespace,
|
|
74
|
+
"target_system": target_system,
|
|
75
|
+
"latest_run": None,
|
|
76
|
+
"coverage": None,
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
"source_namespace": source_namespace,
|
|
80
|
+
"target_system": target_system,
|
|
81
|
+
"latest_run": _run_payload(run),
|
|
82
|
+
"coverage": self.store.coverage(run.id),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def evidence_packet(self, input_id: str) -> MappingEvidencePacket:
|
|
86
|
+
"""Build a detached packet with candidates, evidence, and decision history."""
|
|
87
|
+
|
|
88
|
+
input_record = self.store.get_input(input_id)
|
|
89
|
+
if input_record is None:
|
|
90
|
+
raise KeyError(f"unknown mapping input: {input_id}")
|
|
91
|
+
run = self.store.get_run(input_record.run_id)
|
|
92
|
+
if run is None: # pragma: no cover - protected by the database FK
|
|
93
|
+
raise KeyError(f"unknown mapping run: {input_record.run_id}")
|
|
94
|
+
|
|
95
|
+
candidates = self.store.get_candidates(input_id)
|
|
96
|
+
evidence = self.store.get_evidence(input_id)
|
|
97
|
+
decision = self.store.latest_decision(input_id)
|
|
98
|
+
selected_ids = (
|
|
99
|
+
self.store.get_decision_candidate_ids(decision.id) if decision is not None else []
|
|
100
|
+
)
|
|
101
|
+
events = self.store.get_decision_history(decision.id) if decision is not None else []
|
|
102
|
+
evidence_by_candidate: dict[str, list[dict[str, Any]]] = {}
|
|
103
|
+
unattached: list[dict[str, Any]] = []
|
|
104
|
+
for item in evidence:
|
|
105
|
+
payload = _evidence_payload(item)
|
|
106
|
+
if item.candidate_id is None:
|
|
107
|
+
unattached.append(payload)
|
|
108
|
+
else:
|
|
109
|
+
evidence_by_candidate.setdefault(item.candidate_id, []).append(payload)
|
|
110
|
+
|
|
111
|
+
candidate_payloads = []
|
|
112
|
+
for candidate in candidates:
|
|
113
|
+
payload = _candidate_payload(candidate)
|
|
114
|
+
payload["evidence"] = evidence_by_candidate.get(candidate.id, [])
|
|
115
|
+
candidate_payloads.append(payload)
|
|
116
|
+
|
|
117
|
+
decision_payload = None
|
|
118
|
+
if decision is not None:
|
|
119
|
+
decision_payload = _decision_payload(decision)
|
|
120
|
+
decision_payload["selected_candidate_ids"] = selected_ids
|
|
121
|
+
decision_payload["history"] = [_event_payload(event) for event in events]
|
|
122
|
+
|
|
123
|
+
return MappingEvidencePacket(
|
|
124
|
+
run=_run_payload(run),
|
|
125
|
+
input=_input_payload(input_record),
|
|
126
|
+
candidates=candidate_payloads,
|
|
127
|
+
evidence=unattached,
|
|
128
|
+
decision=decision_payload,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def review_handoff(
|
|
132
|
+
self,
|
|
133
|
+
input_id: str,
|
|
134
|
+
*,
|
|
135
|
+
requested_by: str | None = None,
|
|
136
|
+
metadata: dict[str, Any] | None = None,
|
|
137
|
+
) -> MappingReviewHandoff:
|
|
138
|
+
"""Return a deterministic review reference without mutating the store."""
|
|
139
|
+
|
|
140
|
+
packet = self.evidence_packet(input_id)
|
|
141
|
+
decision_status = (
|
|
142
|
+
packet.decision.get("decision_status", "needs_review")
|
|
143
|
+
if packet.decision
|
|
144
|
+
else "needs_review"
|
|
145
|
+
)
|
|
146
|
+
return MappingReviewHandoff(
|
|
147
|
+
task_id=f"mapping-review:{input_id}",
|
|
148
|
+
source_namespace=packet.input["source_namespace"],
|
|
149
|
+
input_id=input_id,
|
|
150
|
+
decision_status=str(decision_status),
|
|
151
|
+
packet=packet,
|
|
152
|
+
requested_by=requested_by,
|
|
153
|
+
metadata=metadata or {},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_payload(run: MappingRun) -> dict[str, Any]:
|
|
158
|
+
return {
|
|
159
|
+
"id": run.id,
|
|
160
|
+
"source_namespace": run.source_namespace,
|
|
161
|
+
"source_fingerprint": run.source_fingerprint,
|
|
162
|
+
"source_snapshot": run.source_snapshot,
|
|
163
|
+
"target_system": run.target_system,
|
|
164
|
+
"target_release": run.target_release,
|
|
165
|
+
"algorithm_version": run.algorithm_version,
|
|
166
|
+
"policy_version": run.policy_version,
|
|
167
|
+
"lifecycle_status": run.lifecycle_status,
|
|
168
|
+
"last_error": run.last_error,
|
|
169
|
+
"created_at": run.created_at.isoformat(),
|
|
170
|
+
"updated_at": run.updated_at.isoformat(),
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _input_payload(input_record: MappingInput) -> dict[str, Any]:
|
|
175
|
+
return {
|
|
176
|
+
"id": input_record.id,
|
|
177
|
+
"run_id": input_record.run_id,
|
|
178
|
+
"source_namespace": input_record.source_namespace,
|
|
179
|
+
"source_kind": input_record.source_kind,
|
|
180
|
+
"source_key": input_record.source_key,
|
|
181
|
+
"source_fingerprint": input_record.source_fingerprint,
|
|
182
|
+
"normalized_projection": input_record.normalized_projection,
|
|
183
|
+
"lifecycle_status": input_record.lifecycle_status,
|
|
184
|
+
"retry_count": input_record.retry_count,
|
|
185
|
+
"last_error": input_record.last_error,
|
|
186
|
+
"created_at": input_record.created_at.isoformat(),
|
|
187
|
+
"updated_at": input_record.updated_at.isoformat(),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _candidate_payload(candidate: MappingCandidate) -> dict[str, Any]:
|
|
192
|
+
return {
|
|
193
|
+
"id": candidate.id,
|
|
194
|
+
"target_namespace": candidate.target_namespace,
|
|
195
|
+
"target_vocabulary_id": candidate.target_vocabulary_id,
|
|
196
|
+
"target_concept_id": candidate.target_concept_id,
|
|
197
|
+
"target_code": candidate.target_code,
|
|
198
|
+
"target_grain": candidate.target_grain,
|
|
199
|
+
"target_role": candidate.target_role,
|
|
200
|
+
"method": candidate.method,
|
|
201
|
+
"rank": candidate.rank,
|
|
202
|
+
"score": candidate.score,
|
|
203
|
+
"confidence": candidate.confidence,
|
|
204
|
+
"rationale": candidate.rationale,
|
|
205
|
+
"metadata": candidate.metadata_,
|
|
206
|
+
"created_at": candidate.created_at.isoformat(),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _evidence_payload(evidence: MappingEvidence) -> dict[str, Any]:
|
|
211
|
+
return {
|
|
212
|
+
"id": evidence.id,
|
|
213
|
+
"candidate_id": evidence.candidate_id,
|
|
214
|
+
"decision_id": evidence.decision_id,
|
|
215
|
+
"evidence_key": evidence.evidence_key,
|
|
216
|
+
"evidence_type": evidence.evidence_type,
|
|
217
|
+
"source_reference": evidence.source_reference,
|
|
218
|
+
"method": evidence.method,
|
|
219
|
+
"payload": evidence.payload,
|
|
220
|
+
"created_at": evidence.created_at.isoformat(),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _decision_payload(decision: MappingDecision) -> dict[str, Any]:
|
|
225
|
+
return {
|
|
226
|
+
"id": decision.id,
|
|
227
|
+
"input_id": decision.input_id,
|
|
228
|
+
"decision_version": decision.decision_version,
|
|
229
|
+
"decision_status": decision.decision_status,
|
|
230
|
+
"outcome_code": decision.outcome_code,
|
|
231
|
+
"reason_codes": decision.reason_codes,
|
|
232
|
+
"decided_by": decision.decided_by,
|
|
233
|
+
"metadata": decision.metadata_,
|
|
234
|
+
"decided_at": decision.decided_at.isoformat(),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _event_payload(event: MappingDecisionEvent) -> dict[str, Any]:
|
|
239
|
+
return {
|
|
240
|
+
"id": event.id,
|
|
241
|
+
"decision_id": event.decision_id,
|
|
242
|
+
"event_type": event.event_type,
|
|
243
|
+
"detail": event.detail,
|
|
244
|
+
"created_at": event.created_at.isoformat(),
|
|
245
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Source-independent contracts for mapping workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LifecycleStatus(StrEnum):
|
|
12
|
+
"""Processing state for a run or input."""
|
|
13
|
+
|
|
14
|
+
PENDING = "pending"
|
|
15
|
+
IN_PROGRESS = "in_progress"
|
|
16
|
+
COMPLETE = "complete"
|
|
17
|
+
INCOMPLETE = "incomplete"
|
|
18
|
+
FAILED = "failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DecisionStatus(StrEnum):
|
|
22
|
+
"""Mapping outcome independent of the processing lifecycle."""
|
|
23
|
+
|
|
24
|
+
MAPPED = "mapped"
|
|
25
|
+
AMBIGUOUS = "ambiguous"
|
|
26
|
+
UNMAPPABLE = "unmappable"
|
|
27
|
+
NEEDS_REVIEW = "needs_review"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MappingRunSpec(BaseModel):
|
|
31
|
+
"""Stable identity and metadata for one mapping run."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
source_namespace: str = Field(min_length=1)
|
|
36
|
+
source_fingerprint: str = Field(min_length=1, max_length=128)
|
|
37
|
+
source_snapshot: dict[str, Any] = Field(default_factory=dict)
|
|
38
|
+
target_system: str = Field(min_length=1)
|
|
39
|
+
target_release: str | None = None
|
|
40
|
+
algorithm_version: str = Field(min_length=1)
|
|
41
|
+
policy_version: str = Field(min_length=1)
|
|
42
|
+
lifecycle_status: LifecycleStatus = LifecycleStatus.PENDING
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MappingInputSpec(BaseModel):
|
|
46
|
+
"""Source item presented to a mapping workflow."""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
source_namespace: str = Field(min_length=1)
|
|
51
|
+
source_kind: str = Field(min_length=1)
|
|
52
|
+
source_key: str = Field(min_length=1)
|
|
53
|
+
source_fingerprint: str = Field(min_length=1, max_length=128)
|
|
54
|
+
normalized_projection: dict[str, Any] = Field(default_factory=dict)
|
|
55
|
+
lifecycle_status: LifecycleStatus = LifecycleStatus.PENDING
|
|
56
|
+
retry_count: int = Field(default=0, ge=0)
|
|
57
|
+
last_error: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class MappingCandidateSpec(BaseModel):
|
|
61
|
+
"""One candidate target for a mapping input."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
target_namespace: str = Field(min_length=1)
|
|
66
|
+
target_vocabulary_id: str | None = None
|
|
67
|
+
target_concept_id: str | None = None
|
|
68
|
+
target_code: str | None = None
|
|
69
|
+
target_grain: str | None = None
|
|
70
|
+
target_role: str | None = None
|
|
71
|
+
method: str = Field(min_length=1)
|
|
72
|
+
rank: int = Field(default=1, ge=1)
|
|
73
|
+
score: float | None = None
|
|
74
|
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
|
75
|
+
rationale: str | None = None
|
|
76
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
@field_validator("target_vocabulary_id", "target_concept_id", "target_code")
|
|
79
|
+
@classmethod
|
|
80
|
+
def reject_blank_identifiers(cls, value: str | None) -> str | None:
|
|
81
|
+
if value is not None and not value.strip():
|
|
82
|
+
raise ValueError("target identifiers cannot be blank")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MappingEvidenceSpec(BaseModel):
|
|
87
|
+
"""Evidence attached to a candidate or decision."""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(extra="forbid")
|
|
90
|
+
|
|
91
|
+
evidence_key: str = Field(min_length=1, max_length=128)
|
|
92
|
+
evidence_type: str = Field(min_length=1)
|
|
93
|
+
source_reference: str | None = None
|
|
94
|
+
method: str | None = None
|
|
95
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MappingDecisionSpec(BaseModel):
|
|
99
|
+
"""Versioned decision for one mapping input."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(extra="forbid")
|
|
102
|
+
|
|
103
|
+
decision_status: DecisionStatus
|
|
104
|
+
selected_candidate_ids: list[str] = Field(default_factory=list)
|
|
105
|
+
outcome_code: str | None = None
|
|
106
|
+
reason_codes: list[str] = Field(default_factory=list)
|
|
107
|
+
decided_by: str | None = None
|
|
108
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|