helios-model 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- helios_model/__init__.py +37 -0
- helios_model/build.py +198 -0
- helios_model/discovery.py +296 -0
- helios_model/graph.py +862 -0
- helios_model/harvest.py +165 -0
- helios_model/profile.py +240 -0
- helios_model/proposals.py +272 -0
- helios_model/propose.py +225 -0
- helios_model/publication.py +129 -0
- helios_model/py.typed +0 -0
- helios_model/review.py +230 -0
- helios_model-0.1.0.dist-info/METADATA +8 -0
- helios_model-0.1.0.dist-info/RECORD +14 -0
- helios_model-0.1.0.dist-info/WHEEL +4 -0
helios_model/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Helios model discovery: harvest a data source's tables, profile them, propose a
|
|
2
|
+
semantic model and publish it (burndown Phase 5).
|
|
3
|
+
|
|
4
|
+
The facades are ``Catalog``, ``Profiler``, ``Proposer`` and ``Publisher``
|
|
5
|
+
(``helios_model.discovery``), with the typed ``Proposals`` they pass along.
|
|
6
|
+
|
|
7
|
+
helios-model depends on helios-cortex alone; engines and catalogs come from plugins
|
|
8
|
+
(helios_cortex.plugins), so it runs on any warehouse an installed plugin supports.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_EXPORTS = {
|
|
12
|
+
"Catalog": "discovery", "Profiler": "discovery", "Proposer": "discovery", "Publisher": "discovery",
|
|
13
|
+
"Harvest": "discovery", "Profile": "discovery", "Publication": "discovery",
|
|
14
|
+
"DiscoveryError": "discovery", "ModelNotRegistered": "discovery",
|
|
15
|
+
"Proposals": "proposals", "Proposal": "proposals", "ProposedDataset": "proposals",
|
|
16
|
+
"ProposedField": "proposals", "ProposedRelationship": "proposals", "ProposedMetric": "proposals",
|
|
17
|
+
"ProposedTerm": "proposals",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
__all__ = sorted(_EXPORTS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name: str):
|
|
24
|
+
# Exports are imported on first use, so importing helios_model loads no engine,
|
|
25
|
+
# store or LLM code; __version__ is read from the installed metadata.
|
|
26
|
+
if name in _EXPORTS:
|
|
27
|
+
from importlib import import_module
|
|
28
|
+
|
|
29
|
+
return getattr(import_module(f"helios_model.{_EXPORTS[name]}"), name)
|
|
30
|
+
if name == "__version__":
|
|
31
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
return version("helios-model")
|
|
35
|
+
except PackageNotFoundError: # running from a source path without an install
|
|
36
|
+
return "0.0.0+unknown"
|
|
37
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
helios_model/build.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build an Apache Ossie semantic model document from a reviewed helios proposal.
|
|
3
|
+
Validating and writing the document is the format's (helios_cortex.ossie.format).
|
|
4
|
+
|
|
5
|
+
Mapping (Ossie core spec 0.2.0.dev0, schema vendored as ossie-schema.json):
|
|
6
|
+
|
|
7
|
+
dataset -> datasets[].name = physical table, source = database.table
|
|
8
|
+
business name goes into ai_context.synonyms (the spec has no dataset label)
|
|
9
|
+
field -> fields[].name = column, expression ANSI_SQL = column, label = business name
|
|
10
|
+
role time -> dimension.is_time = true
|
|
11
|
+
role dimension / attribute / foreign_key / identifier -> dimension.is_time = false
|
|
12
|
+
role measure -> no `dimension` block (Ossie has no measure flag)
|
|
13
|
+
relationship -> relationships[] (accepted only), unique name per from-column
|
|
14
|
+
metric -> metrics[], expression re-qualified as dataset.column
|
|
15
|
+
provenance -> custom_extensions [{vendor_name: HELIOS, data: <json>}] on every element:
|
|
16
|
+
role, confidence, source, glossary links, refers_to, run id.
|
|
17
|
+
|
|
18
|
+
Nothing in here talks to Atlas or the warehouse; proposed glossary terms are published separately.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from helios_cortex.ossie.format import DIALECT, SPEC_VERSION, VENDOR
|
|
26
|
+
|
|
27
|
+
_SQL_WORDS = {"group_concat", "date_trunc", "date_part", "date_add", "date_sub", "current_date", "current_timestamp",
|
|
28
|
+
"is_null", "not_null", "if_null", "if_not"}
|
|
29
|
+
|
|
30
|
+
_TYPE_MAP = [
|
|
31
|
+
(("bigint", "int", "smallint", "tinyint"), "Integer"),
|
|
32
|
+
(("decimal", "numeric"), "Decimal"),
|
|
33
|
+
(("double", "float", "real"), "Float"),
|
|
34
|
+
(("boolean", "bool"), "Boolean"),
|
|
35
|
+
(("timestamp",), "DateTime"),
|
|
36
|
+
(("date",), "Date"),
|
|
37
|
+
(("string", "varchar", "char", "text"), "String"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def datatype(engine_type: str | None) -> str:
|
|
42
|
+
t = (engine_type or "").lower()
|
|
43
|
+
for prefixes, dt in _TYPE_MAP:
|
|
44
|
+
if t.startswith(prefixes):
|
|
45
|
+
return dt
|
|
46
|
+
return "Opaque"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def slug(s: str) -> str:
|
|
50
|
+
s = re.sub(r"[^0-9a-zA-Z]+", "_", s.strip()).strip("_").lower()
|
|
51
|
+
return re.sub(r"_+", "_", s) or "unnamed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ext(data: dict) -> list[dict]:
|
|
55
|
+
clean = {k: v for k, v in data.items() if v not in (None, [], "", {})}
|
|
56
|
+
return [{"vendor_name": VENDOR, "data": json.dumps(clean, separators=(",", ":"))}]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _table_name(table: str) -> str:
|
|
60
|
+
return table.split(".")[-1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _qualify(expression: str, table: str, columns: set[str]) -> str:
|
|
64
|
+
"""Prefix bare column references with the dataset name, e.g. SUM(ss_net_paid) -> SUM(store_sales.ss_net_paid)."""
|
|
65
|
+
ds = _table_name(table)
|
|
66
|
+
pattern = re.compile(r"(?<![\w.])(" + "|".join(re.escape(c) for c in sorted(columns, key=len, reverse=True)) + r")(?![\w(])")
|
|
67
|
+
return pattern.sub(lambda m: f"{ds}.{m.group(1)}", expression) if columns else expression
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _pk_and_unique(d: dict) -> tuple[list[str], list[list[str]]]:
|
|
71
|
+
"""Surrogate key becomes the primary key; other single-column keys become unique keys."""
|
|
72
|
+
pks = list(d.get("primary_key") or [])
|
|
73
|
+
if not pks:
|
|
74
|
+
return [], []
|
|
75
|
+
sk = [c for c in pks if c.endswith("_sk")]
|
|
76
|
+
primary = sk[:1] or pks[:1]
|
|
77
|
+
unique = [[c] for c in pks if c not in primary]
|
|
78
|
+
return primary, unique
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_dataset(d: dict, run_id: str | None) -> dict:
|
|
82
|
+
name = _table_name(d["table"])
|
|
83
|
+
primary, unique = _pk_and_unique(d)
|
|
84
|
+
fields = []
|
|
85
|
+
for f in d["fields"]:
|
|
86
|
+
role = f.get("role", "attribute")
|
|
87
|
+
field = {
|
|
88
|
+
"name": f["column"],
|
|
89
|
+
"expression": {"dialects": [{"dialect": DIALECT, "expression": f["column"]}]},
|
|
90
|
+
"label": f.get("name") or f["column"],
|
|
91
|
+
"description": f.get("description") or "",
|
|
92
|
+
"datatype": datatype(f.get("type")),
|
|
93
|
+
}
|
|
94
|
+
if role == "time":
|
|
95
|
+
field["dimension"] = {"is_time": True}
|
|
96
|
+
elif role != "measure":
|
|
97
|
+
field["dimension"] = {"is_time": False}
|
|
98
|
+
terms = list(f.get("glossary_terms") or [])
|
|
99
|
+
if terms:
|
|
100
|
+
field["ai_context"] = {"synonyms": terms}
|
|
101
|
+
field["custom_extensions"] = _ext({
|
|
102
|
+
"role": role, "engine_type": f.get("type"), "confidence": f.get("confidence"),
|
|
103
|
+
"refers_to": f.get("refers_to"), "glossary_terms": terms,
|
|
104
|
+
})
|
|
105
|
+
fields.append(field)
|
|
106
|
+
|
|
107
|
+
ds = {
|
|
108
|
+
"name": name,
|
|
109
|
+
"source": d["table"],
|
|
110
|
+
"description": d.get("description") or "",
|
|
111
|
+
"fields": fields,
|
|
112
|
+
"custom_extensions": _ext({"kind": d.get("kind"), "confidence": d.get("confidence"), "run_id": run_id}),
|
|
113
|
+
}
|
|
114
|
+
if primary:
|
|
115
|
+
ds["primary_key"] = primary
|
|
116
|
+
if unique:
|
|
117
|
+
ds["unique_keys"] = unique
|
|
118
|
+
if d.get("name") and d["name"].lower() != name.replace("_", " "):
|
|
119
|
+
ds["ai_context"] = {"synonyms": [d["name"]]}
|
|
120
|
+
return ds
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_relationships(rels: list[dict]) -> list[dict]:
|
|
124
|
+
out, seen = [], set()
|
|
125
|
+
for r in rels:
|
|
126
|
+
if not r.get("accepted", True):
|
|
127
|
+
continue
|
|
128
|
+
frm, to = _table_name(r["from"]), _table_name(r["to"])
|
|
129
|
+
name = f"{frm}__{r['from_column']}__{to}"
|
|
130
|
+
if name in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(name)
|
|
133
|
+
out.append({
|
|
134
|
+
"name": name, "from": frm, "to": to,
|
|
135
|
+
"from_columns": [r["from_column"]], "to_columns": [r["to_column"]],
|
|
136
|
+
"custom_extensions": _ext({"source": r.get("source"), "confidence": r.get("confidence"),
|
|
137
|
+
"reason": r.get("reason"), "match_ratio": r.get("match_ratio")}),
|
|
138
|
+
})
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_metrics(metrics: list[dict], datasets: list[dict]) -> tuple[list[dict], list[str]]:
|
|
143
|
+
cols_by_table = {d["table"]: {f["column"] for f in d["fields"]} for d in datasets}
|
|
144
|
+
all_cols = {c for cs in cols_by_table.values() for c in cs}
|
|
145
|
+
out, seen, problems = [], set(), []
|
|
146
|
+
for m in metrics:
|
|
147
|
+
cols = cols_by_table.get(m["dataset"], set())
|
|
148
|
+
referenced = set(re.findall(r"\b[a-z][a-z0-9_]*\b", m["expression"].lower()))
|
|
149
|
+
foreign = (referenced & all_cols) - cols
|
|
150
|
+
if foreign:
|
|
151
|
+
problems.append(f"{m['name']}: references columns outside {m['dataset']}: {sorted(foreign)}")
|
|
152
|
+
continue
|
|
153
|
+
unknown = {t for t in referenced if "_" in t and t not in all_cols and t not in _SQL_WORDS}
|
|
154
|
+
if unknown:
|
|
155
|
+
problems.append(f"{m['name']}: unknown columns {sorted(unknown)}")
|
|
156
|
+
continue
|
|
157
|
+
name = slug(m["name"])
|
|
158
|
+
if name in seen:
|
|
159
|
+
problems.append(f"{m['name']}: duplicate metric name {name}")
|
|
160
|
+
continue
|
|
161
|
+
seen.add(name)
|
|
162
|
+
out.append({
|
|
163
|
+
"name": name,
|
|
164
|
+
"expression": {"dialects": [{"dialect": DIALECT, "expression": _qualify(m["expression"], m["dataset"], cols)}]},
|
|
165
|
+
"description": m.get("description") or "",
|
|
166
|
+
"datatype": "Float" if "/" in m["expression"] else "Decimal",
|
|
167
|
+
"ai_context": {"synonyms": [m["name"]]},
|
|
168
|
+
"custom_extensions": _ext({"dataset": m["dataset"], "source": m.get("source"),
|
|
169
|
+
"confidence": m.get("confidence")}),
|
|
170
|
+
})
|
|
171
|
+
return out, problems
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build(proposal: dict, model_name: str, description: str = "",
|
|
175
|
+
run_id: str | None = None, model_id: str | None = None) -> tuple[dict, list[str]]:
|
|
176
|
+
"""Return (ossie_document, problems). problems are non-fatal things dropped along the way."""
|
|
177
|
+
datasets = [build_dataset(d, run_id) for d in proposal["datasets"]]
|
|
178
|
+
relationships = build_relationships(proposal.get("relationships", []))
|
|
179
|
+
metrics, problems = build_metrics(proposal.get("metrics", []), proposal["datasets"])
|
|
180
|
+
doc = {
|
|
181
|
+
"version": SPEC_VERSION,
|
|
182
|
+
"name": model_name,
|
|
183
|
+
"description": description,
|
|
184
|
+
"ai_context": {"instructions": "Semantic model generated by helios from warehouse evidence and reviewed by a human. "
|
|
185
|
+
"Field labels are business names; custom_extensions carry helios provenance."},
|
|
186
|
+
"datasets": datasets,
|
|
187
|
+
"relationships": relationships,
|
|
188
|
+
"metrics": metrics,
|
|
189
|
+
"custom_extensions": _ext({"model_id": model_id, "run_id": run_id,
|
|
190
|
+
"llm": proposal.get("llm"), "review": proposal.get("review")}),
|
|
191
|
+
}
|
|
192
|
+
return doc, problems
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def preflight(proposal: dict) -> dict:
|
|
196
|
+
"""Problems build() would report for the proposal as it stands, keyed by metric name, for the review page."""
|
|
197
|
+
_, problems = build_metrics(proposal.get("metrics", []), proposal["datasets"])
|
|
198
|
+
return {p.split(":", 1)[0]: p.split(":", 1)[1].strip() for p in problems}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The facades a host calls to build a model: harvest, profile, propose, publish.
|
|
2
|
+
|
|
3
|
+
catalog = Catalog(settings)
|
|
4
|
+
harvest = catalog.harvest(source, caller, databases=["sales"], model_id="sales")
|
|
5
|
+
profile = Profiler(settings).profile(harvest, caller)
|
|
6
|
+
proposals = Proposer(settings).propose(profile)
|
|
7
|
+
proposals.accept_all(min_confidence=0.8) # or a person reviews them
|
|
8
|
+
publication = Publisher(settings).publish(proposals, caller)
|
|
9
|
+
|
|
10
|
+
Each stage keeps its document in the run store (``<run_id>/<stage>.json``) and returns
|
|
11
|
+
a result carrying the run id; the next stage takes that result or the run id, so the
|
|
12
|
+
stages can run in different processes. They are thin: the work is done by
|
|
13
|
+
``TableHarvester``, ``TableProfiler``, ``ModelProposer``, ``review`` and
|
|
14
|
+
``publication``, unchanged.
|
|
15
|
+
|
|
16
|
+
The ``caller`` (``helios_cortex.Caller``) is who the host says is asking. Harvest and
|
|
17
|
+
profile forward it to the engine, which runs the catalog reads and the profiling
|
|
18
|
+
queries as the caller under the connection's identity mode, so a model is built from
|
|
19
|
+
what that caller may see; ``None`` is the connection's service identity. Publish
|
|
20
|
+
records the caller's username as the publisher. Helios checks no permissions itself.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from types import MappingProxyType
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from helios_cortex.caller import Caller
|
|
32
|
+
from helios_cortex.catalog import catalog_for
|
|
33
|
+
from helios_cortex.domain import DataSource
|
|
34
|
+
from helios_cortex.engines import Engine, engine_for
|
|
35
|
+
from helios_cortex.registry import SemanticRevision
|
|
36
|
+
from helios_cortex.runs import RunStore
|
|
37
|
+
from helios_cortex.settings import Settings
|
|
38
|
+
|
|
39
|
+
from helios_model import publication as publishing
|
|
40
|
+
from helios_model.harvest import TableHarvester
|
|
41
|
+
from helios_model.profile import TableProfiler
|
|
42
|
+
from helios_model.proposals import Proposals
|
|
43
|
+
from helios_model.propose import ModelProposer
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from helios_cortex.artifacts import ArtifactStore
|
|
47
|
+
from helios_cortex.llm import LLMClient
|
|
48
|
+
from helios_cortex.registry import ModelRegistry
|
|
49
|
+
|
|
50
|
+
LOGGER = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DiscoveryError(RuntimeError):
|
|
54
|
+
"""A stage cannot run: its input run is missing, or nothing is configured to run it."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ModelNotRegistered(LookupError):
|
|
58
|
+
"""Publishing a model the registry does not hold. Register it first (``save_model``)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def new_run_id() -> str:
|
|
62
|
+
"""A run id: the UTC time to the second, so run ids sort by time."""
|
|
63
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _username(caller: Caller | None) -> str | None:
|
|
67
|
+
return caller.username if caller is not None else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _frozen(document: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
71
|
+
return MappingProxyType(dict(document))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Harvest:
|
|
76
|
+
"""A harvest kept in run ``run_id``: tables, columns, row counts, glossary, query log."""
|
|
77
|
+
|
|
78
|
+
run_id: str
|
|
79
|
+
model_id: str
|
|
80
|
+
data_source_id: str
|
|
81
|
+
document: Mapping[str, Any] = field(repr=False)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def tables(self) -> tuple[str, ...]:
|
|
85
|
+
"""``database.table`` for every harvested table."""
|
|
86
|
+
return tuple(f"{t['database']}.{t['table']}" for t in self.document.get("tables", []))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Profile:
|
|
91
|
+
"""A profile kept in run ``run_id``: statistics, keys and verified relationships."""
|
|
92
|
+
|
|
93
|
+
run_id: str
|
|
94
|
+
model_id: str
|
|
95
|
+
document: Mapping[str, Any] = field(repr=False)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def relationships(self) -> tuple[Mapping[str, Any], ...]:
|
|
99
|
+
"""Relationships found from both names and data."""
|
|
100
|
+
return tuple(self.document.get("relationships", []))
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def suggested_relationships(self) -> tuple[Mapping[str, Any], ...]:
|
|
104
|
+
"""Relationships the data supports but the names do not; the proposer decides them."""
|
|
105
|
+
return tuple(self.document.get("suggested_relationships", []))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Publication:
|
|
110
|
+
"""A published model: the registry's revision row, the Ossie document and the
|
|
111
|
+
artifact store's manifest (path, hash, size, counts, review)."""
|
|
112
|
+
|
|
113
|
+
revision: SemanticRevision
|
|
114
|
+
document: Mapping[str, Any] = field(repr=False)
|
|
115
|
+
manifest: Mapping[str, Any] = field(repr=False)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def model_id(self) -> str:
|
|
119
|
+
return self.revision.model_id
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def revision_id(self) -> str:
|
|
123
|
+
return self.revision.id
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Stage:
|
|
127
|
+
def __init__(self, settings: Settings, *, runs: RunStore | None = None):
|
|
128
|
+
self.settings = settings
|
|
129
|
+
self.runs = runs if runs is not None else RunStore.from_settings(settings)
|
|
130
|
+
|
|
131
|
+
def _engine(self, connection_ref: str | None) -> Engine:
|
|
132
|
+
connection = self.settings.connection(connection_ref)
|
|
133
|
+
if connection is None:
|
|
134
|
+
raise DiscoveryError(f"no connection {connection_ref!r} (and no default) in the settings")
|
|
135
|
+
return engine_for(connection)
|
|
136
|
+
|
|
137
|
+
def _load(self, run_id: str, stage: str, before: str) -> dict[str, Any]:
|
|
138
|
+
document = self.runs.load(run_id, stage)
|
|
139
|
+
if document is None:
|
|
140
|
+
raise DiscoveryError(f"run {run_id!r} has no {stage}; {before} first")
|
|
141
|
+
return document
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Catalog(_Stage):
|
|
145
|
+
"""Harvests a data source into a new run. ``Catalog(settings)`` keeps runs where the
|
|
146
|
+
settings say (``RunStore.from_settings``); pass ``runs=`` to choose."""
|
|
147
|
+
|
|
148
|
+
def harvest(self, source: DataSource, caller: Caller | None, *, databases: Sequence[str], model_id: str,
|
|
149
|
+
glossaries: Sequence[str] | None = None, query_dir: str | None = None,
|
|
150
|
+
catalog: str | None = None, run_id: str | None = None) -> Harvest:
|
|
151
|
+
"""Harvest ``databases`` of ``source`` as ``caller`` for model ``model_id``.
|
|
152
|
+
|
|
153
|
+
The engine is the connection ``source.connection_ref`` names; the glossary comes
|
|
154
|
+
from the catalog ``catalog`` names (the default catalog when None, none when the
|
|
155
|
+
settings have no catalogs). ``databases`` is required: Helios never guesses what
|
|
156
|
+
to harvest. ``run_id`` defaults to a new one; an existing run is never replaced."""
|
|
157
|
+
databases = [d for d in databases if d]
|
|
158
|
+
if not databases:
|
|
159
|
+
raise ValueError("name at least one database to harvest")
|
|
160
|
+
run_id = run_id or new_run_id()
|
|
161
|
+
if self.runs.load(run_id, "harvest") is not None:
|
|
162
|
+
raise DiscoveryError(f"run {run_id!r} already has a harvest; start a new run")
|
|
163
|
+
engine = self._engine(source.connection_ref)
|
|
164
|
+
harvester = TableHarvester(engine, catalog_for(self.settings.catalog(catalog)))
|
|
165
|
+
document = harvester.run(list(databases), list(glossaries) if glossaries else None, query_dir, caller=caller)
|
|
166
|
+
document.update({"model_id": model_id, "data_source_id": source.id,
|
|
167
|
+
"connection_ref": source.connection_ref, "harvested_by": _username(caller)})
|
|
168
|
+
self.runs.write(run_id, "harvest", document)
|
|
169
|
+
return Harvest(run_id, model_id, source.id, _frozen(document))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Profiler(_Stage):
|
|
173
|
+
"""Profiles a harvest on the connection it was harvested from."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, settings: Settings, *, runs: RunStore | None = None,
|
|
176
|
+
overlap_threshold: float = 0.95, max_pairs: int = 400):
|
|
177
|
+
super().__init__(settings, runs=runs)
|
|
178
|
+
self.overlap_threshold = overlap_threshold
|
|
179
|
+
self.max_pairs = max_pairs
|
|
180
|
+
|
|
181
|
+
def profile(self, harvest: Harvest | str, caller: Caller | None = None) -> Profile:
|
|
182
|
+
"""Profile the harvest (a ``Harvest`` or its run id) as ``caller``, or as the
|
|
183
|
+
service identity without one, and keep the result in the same run."""
|
|
184
|
+
run_id = harvest.run_id if isinstance(harvest, Harvest) else harvest
|
|
185
|
+
document = self._load(run_id, "harvest", "harvest")
|
|
186
|
+
if "connection_ref" not in document:
|
|
187
|
+
raise DiscoveryError(f"run {run_id!r} was not harvested by Catalog.harvest; it names no connection")
|
|
188
|
+
profiler = TableProfiler(self._engine(document["connection_ref"]), self.overlap_threshold, self.max_pairs)
|
|
189
|
+
result = profiler.run(document, caller=caller)
|
|
190
|
+
result.update({"model_id": document.get("model_id"), "profiled_by": _username(caller)})
|
|
191
|
+
self.runs.write(run_id, "profile", result)
|
|
192
|
+
return Profile(run_id, result["model_id"], _frozen(result))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Proposer(_Stage):
|
|
196
|
+
"""Drafts a model from a profiled run with an LLM: ``llm=`` or the settings' ``[llm]``."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, settings: Settings, *, runs: RunStore | None = None,
|
|
199
|
+
artifacts: ArtifactStore | None = None, llm: LLMClient | None = None):
|
|
200
|
+
super().__init__(settings, runs=runs)
|
|
201
|
+
self._artifacts = artifacts
|
|
202
|
+
self._llm = llm
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def artifacts(self) -> ArtifactStore:
|
|
206
|
+
if self._artifacts is None:
|
|
207
|
+
from helios_cortex.artifacts import ArtifactStore
|
|
208
|
+
|
|
209
|
+
self._artifacts = ArtifactStore.from_settings(self.settings)
|
|
210
|
+
return self._artifacts
|
|
211
|
+
|
|
212
|
+
def _client(self) -> LLMClient:
|
|
213
|
+
if self._llm is None:
|
|
214
|
+
unconfigured = DiscoveryError("no LLM configured: set [llm] provider (and its key or base URL)")
|
|
215
|
+
if self.settings.llm.provider is None:
|
|
216
|
+
raise unconfigured # before importing the client, which needs helios-cortex[llm]
|
|
217
|
+
from helios_cortex.llm import llm_from_settings
|
|
218
|
+
|
|
219
|
+
self._llm = llm_from_settings(self.settings.llm)
|
|
220
|
+
if self._llm is None:
|
|
221
|
+
raise unconfigured
|
|
222
|
+
return self._llm
|
|
223
|
+
|
|
224
|
+
def propose(self, profile: Profile | str) -> Proposals:
|
|
225
|
+
"""Propose a model from the run's harvest and profile, with no decisions yet. The
|
|
226
|
+
proposal is kept in the run and as the model's latest proposal in the artifact store."""
|
|
227
|
+
run_id = profile.run_id if isinstance(profile, Profile) else profile
|
|
228
|
+
harvest = self._load(run_id, "harvest", "harvest")
|
|
229
|
+
profiled = self._load(run_id, "profile", "profile")
|
|
230
|
+
model_id = harvest.get("model_id")
|
|
231
|
+
if not model_id:
|
|
232
|
+
raise DiscoveryError(f"run {run_id!r} names no model")
|
|
233
|
+
proposal = ModelProposer(self._client()).run(harvest, profiled)
|
|
234
|
+
proposal["model_id"] = model_id
|
|
235
|
+
self.runs.write(run_id, "propose", proposal)
|
|
236
|
+
self.artifacts.write_proposal(model_id, run_id, proposal)
|
|
237
|
+
return Proposals(proposal, run_id=run_id, model_id=model_id)
|
|
238
|
+
|
|
239
|
+
def load(self, run_id: str) -> Proposals:
|
|
240
|
+
"""A run's proposals with the decisions made on them so far."""
|
|
241
|
+
proposal = self._load(run_id, "propose", "propose")
|
|
242
|
+
model_id = proposal.get("model_id") or (self.runs.load(run_id, "harvest") or {}).get("model_id")
|
|
243
|
+
if not model_id:
|
|
244
|
+
raise DiscoveryError(f"run {run_id!r} names no model")
|
|
245
|
+
return Proposals(proposal, run_id=run_id, model_id=model_id, review=self.runs.load(run_id, "review"))
|
|
246
|
+
|
|
247
|
+
def save_review(self, proposals: Proposals, caller: Caller | None, *, at: datetime | None = None) -> None:
|
|
248
|
+
"""Keep the decisions on ``proposals`` in their run, as reviewed by ``caller`` at
|
|
249
|
+
``at`` (now by default). Who and when are stamped on ``proposals`` too, and
|
|
250
|
+
published with them."""
|
|
251
|
+
proposals.mark_reviewed(_username(caller), at)
|
|
252
|
+
self.runs.write(proposals.run_id, "review", proposals.review)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Publisher:
|
|
256
|
+
"""Publishes reviewed proposals: the Ossie artifact (validated, then made the model's
|
|
257
|
+
active revision) and its row in the registry."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, settings: Settings, *, registry: ModelRegistry | None = None,
|
|
260
|
+
artifacts: ArtifactStore | None = None, runs: RunStore | None = None):
|
|
261
|
+
self.settings = settings
|
|
262
|
+
if registry is None:
|
|
263
|
+
from helios_cortex.registry import SqlRegistry
|
|
264
|
+
|
|
265
|
+
registry = SqlRegistry.from_settings(settings)
|
|
266
|
+
if artifacts is None:
|
|
267
|
+
from helios_cortex.artifacts import ArtifactStore
|
|
268
|
+
|
|
269
|
+
artifacts = ArtifactStore.from_settings(settings)
|
|
270
|
+
self.registry = registry
|
|
271
|
+
self.artifacts = artifacts
|
|
272
|
+
self.runs = runs if runs is not None else RunStore.from_settings(settings)
|
|
273
|
+
|
|
274
|
+
def publish(self, proposals: Proposals, caller: Caller | None) -> Publication:
|
|
275
|
+
"""Publish the accepted and edited elements of ``proposals``; pending and rejected
|
|
276
|
+
ones are left out. The decisions are kept in the run, so the run records what was
|
|
277
|
+
published. Raises ModelNotRegistered, or ``publication.PublicationValidationError``
|
|
278
|
+
when the reviewed model is not valid Ossie, before writing anything."""
|
|
279
|
+
model_id = proposals.model_id
|
|
280
|
+
if self.registry.model(model_id) is None:
|
|
281
|
+
raise ModelNotRegistered(f"model {model_id!r} is not in the registry; register it before publishing")
|
|
282
|
+
harvest = self.runs.load(proposals.run_id, "harvest") or {}
|
|
283
|
+
proposal, review = proposals.proposal, proposals.review
|
|
284
|
+
published_by = _username(caller)
|
|
285
|
+
result = publishing.publish_reviewed_proposal( # validates before it writes
|
|
286
|
+
self.artifacts, model_id=model_id, model_name=publishing.model_name_for_proposal(proposal, harvest),
|
|
287
|
+
run_id=proposals.run_id, proposal=proposal, review=review, published_by=published_by)
|
|
288
|
+
self.runs.write(proposals.run_id, "review", review)
|
|
289
|
+
manifest = result.manifest
|
|
290
|
+
revision = self.registry.save_semantic_revision(SemanticRevision(
|
|
291
|
+
id=manifest["revision_id"], model_id=model_id, sha256=manifest["sha256"],
|
|
292
|
+
artifact_path=manifest["path"], ossie_version=manifest.get("ossie_version"),
|
|
293
|
+
discovery_run_id=proposals.run_id, published_at=datetime.fromisoformat(manifest["published_at"]),
|
|
294
|
+
published_by=published_by, size_bytes=manifest["size_bytes"]))
|
|
295
|
+
LOGGER.info("registered revision %s of model %s", revision.id, model_id)
|
|
296
|
+
return Publication(revision, _frozen(result.document), _frozen(manifest))
|