Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ item is not committed until it has an issue and an accepted design.
- External artifact registry integration.
- Catalog and governance-system integration.
- Open Data Contract Standard interchange.
- dbt schema/source export and import (see
[docs/dbt-fhir-tool-alignment.md](docs/dbt-fhir-tool-alignment.md)).
- FHIR R4 profile export and import for a small base-resource set (see
[docs/dbt-fhir-tool-alignment.md](docs/dbt-fhir-tool-alignment.md)).
- Additional artifact formats driven by concrete consumers.

## Later
Expand Down
2 changes: 2 additions & 0 deletions cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ dependencies = [
"referencing>=0.35",
"pygls>=2.1.1,<3",
"psycopg[binary]>=3.2",
"pyyaml>=6.0.3",
]

[project.urls]
Expand Down Expand Up @@ -111,6 +112,7 @@ module = [
"pygls.*",
"lsprotocol.*",
"psycopg.*",
"yaml.*",
]
ignore_missing_imports = true

Expand Down
85 changes: 84 additions & 1 deletion cli/src/modelable/commands/llm.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from dataclasses import asdict
from pathlib import Path

import click
Expand All @@ -12,19 +13,26 @@
from modelable.llm.context import build_workspace_summary
from modelable.llm.engine import (
answer_model_question_cli,
attach_external_version,
describe_path_or_ref,
explain_validation,
generate_entity_from_prompt,
import_definition,
recommend_cli,
render_attach_audit_summary,
render_update_audit_summary,
render_write_audit_summary,
suggest_projection,
transform_ref_to_target,
update_definition,
validate_generated_text,
)
from modelable.llm.provenance import build_write_provenance, write_provenance_sidecar
from modelable.llm.provenance import (
AttachmentRecord,
build_write_provenance,
write_attachment_record,
write_provenance_sidecar,
)
from modelable.llm.providers import build_provider

console = Console()
Expand All @@ -36,6 +44,7 @@ def register_llm_commands(cli_group: click.Group) -> None:
cli_group.add_command(import_model)
cli_group.add_command(diff)
cli_group.add_command(update)
cli_group.add_command(attach)
cli_group.add_command(transform)
cli_group.add_command(suggest_projection_cmd)
cli_group.add_command(ask)
Expand Down Expand Up @@ -240,6 +249,80 @@ def update(
console.print(render_update_audit_summary(result))


@click.command()
@click.argument("ref")
@click.option(
"--source",
"source",
type=click.Path(exists=True, path_type=Path),
required=True,
help="External dbt schema.yml or FHIR StructureDefinition JSON file.",
)
@click.option("--source-format", "source_format", type=click.Choice(["dbt", "fhir"]), required=True)
@click.option(
"--source-name",
"source_name",
default=None,
help="dbt model name or FHIR resource name to match, if the source defines multiple.",
)
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), required=True)
@click.option("--output", "output", type=click.Path(path_type=Path), default=None)
@click.option("--preview", is_flag=True, help="Show the new version diff without writing changes.")
def attach(
ref: str,
source: Path,
source_format: str,
source_name: str | None,
path: Path,
output: Path | None,
preview: bool,
) -> None:
"""Attach a model version to an external dbt or FHIR source and record drift as a new version."""
try:
result = attach_external_version(
path, ref, source, source_format, source_name=source_name, output=output, write=not preview
)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc

for warning in result.warnings:
console.print(f"[yellow]WARN[/yellow] {warning}")

if not result.attached:
console.print(
f"[green]OK[/green] {ref} already matches {result.source_descriptor} "
f"({result.source_format}); no new version created"
)
return

if preview:
from modelable.llm.chat import _render_update_preview

console.print(_render_update_preview(result))
return

write_attachment_record(
result.path,
AttachmentRecord(
ref=result.ref,
source_format=result.source_format,
source_name=result.source_name,
source_path=result.source_descriptor,
source_hash=result.source_hash,
from_version=result.from_version,
to_version=result.to_version,
change_kind=result.change_kind,
changes=[asdict(change) for change in result.changes],
),
)
console.print(result.content.rstrip())
console.print(
f"[green]OK[/green] attached {ref} to {result.source_descriptor} "
f"({result.source_format}); new version {result.to_version} ({result.change_kind})"
)
console.print(render_attach_audit_summary(result))


@click.command()
@click.argument("ref")
@click.option("--path", "path", type=click.Path(exists=True, path_type=Path), required=True)
Expand Down
185 changes: 185 additions & 0 deletions cli/src/modelable/llm/engine.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from os import environ
from pathlib import Path

from modelable.compat.diff import FieldChange, compare_model_versions
from modelable.compiler.workspace import load_workspace
from modelable.diagnostics.model import render_diagnostic
from modelable.emitters.csharp import emit_csharp
Expand Down Expand Up @@ -37,6 +39,7 @@
from modelable.llm.validation_help import explain_validation_errors
from modelable.parser.ir import (
AnnKey,
ChangeKind,
DirectMapping,
FieldDef,
MdlFile,
Expand Down Expand Up @@ -82,6 +85,25 @@ class UpdatePlanResult:
diagnostics_repaired: int


@dataclass(frozen=True)
class AttachResult:
path: Path
source_path: Path
ref: str
original_content: str
content: str
warnings: list[str]
attached: bool
from_version: int
to_version: int | None
change_kind: str | None
changes: list[FieldChange]
source_format: str
source_name: str
source_descriptor: str
source_hash: str


def describe_path_or_ref(path: Path | None = None, ref: str | None = None) -> str:
if ref and path is not None:
workspace = load_workspace(path)
Expand Down Expand Up @@ -365,6 +387,169 @@ def update_definition(
)


_BREAKING_ATTACH_CHANGE_KINDS = {"removed_field", "type_changed", "enum_changed", "identity_changed"}


def attach_external_version(
path: Path,
ref: str,
source: Path | str,
source_format: str,
*,
source_name: str | None = None,
output: Path | None = None,
write: bool = True,
) -> AttachResult:
"""Attach a model version to an external dbt or FHIR source.

If the external source's fields differ from the referenced model version, append a
new `.mdl` version block with a computed `additive`/`breaking` change kind.
"""
workspace = load_workspace(path)
model_ref = parse_model_ref(ref)
source_path = _find_source_path_for_ref(workspace, model_ref.domain, model_ref.name)
if source_path is None:
raise ValueError(f"Could not find source file for {ref}")

mdl_text = source_path.read_text(encoding="utf-8")
mdl = parse_text_to_ir(mdl_text)
domain = next((item for item in mdl.domains if item.name == model_ref.domain), None)
if domain is None:
raise ValueError(f"Unknown domain: {model_ref.domain}")
versions = domain.models.get(model_ref.name)
if not versions:
raise ValueError(f"Unknown model: {ref}")
current = next((item for item in versions if item.version == model_ref.version), None)
if current is None:
raise ValueError(f"Unknown model version: {ref}")

if isinstance(source, Path):
source_text = source.read_text(encoding="utf-8")
source_descriptor = str(source)
else:
source_text = source
source_descriptor = "inline"
imported = import_from_text(source_text, source_format, domain_name=model_ref.domain, source_name=source_name)
source_hash = hashlib.sha256(source_text.encode("utf-8")).hexdigest()

new_fields = _build_attached_fields(current.fields, imported.model_version.fields)
candidate_version = ModelVersion(
model_kind=current.model_kind,
version=current.version,
change_kind=current.change_kind,
fields=new_fields,
)
changes = compare_model_versions(current, candidate_version)

if not changes:
return AttachResult(
path=output or source_path,
source_path=source_path,
ref=ref,
original_content=mdl_text,
content=mdl_text,
warnings=imported.warnings,
attached=False,
from_version=current.version,
to_version=None,
change_kind=None,
changes=[],
source_format=source_format,
source_name=imported.source_name,
source_descriptor=source_descriptor,
source_hash=source_hash,
)

change_kind = _classify_attach_change_kind(changes)
next_version_number = max(item.version for item in versions) + 1
new_version = ModelVersion(
model_kind=current.model_kind,
version=next_version_number,
change_kind=ChangeKind(change_kind),
fields=new_fields,
)
versions.append(new_version)

new_text = render_mdl(mdl)
_, errors = validate_generated_text(new_text)
if errors:
raise ValueError("Attached definition failed validation: " + "; ".join(errors))

out_path = output or source_path
if write:
out_path.write_text(new_text, encoding="utf-8")

return AttachResult(
path=out_path,
source_path=source_path,
ref=ref,
original_content=mdl_text,
content=new_text,
warnings=imported.warnings,
attached=True,
from_version=current.version,
to_version=next_version_number,
change_kind=change_kind,
changes=changes,
source_format=source_format,
source_name=imported.source_name,
source_descriptor=source_descriptor,
source_hash=source_hash,
)


def _build_attached_fields(old_fields: list[FieldDef], candidate_fields: list[FieldDef]) -> list[FieldDef]:
"""Combine the current field set with imported fields, preserving existing annotations."""
candidate_by_name = {field.name: field for field in candidate_fields}
old_names = {field.name for field in old_fields}
new_fields: list[FieldDef] = []
for old_field in old_fields:
candidate = candidate_by_name.get(old_field.name)
if candidate is None:
continue
new_fields.append(
FieldDef(
name=old_field.name,
type=candidate.type,
optional=candidate.optional,
default=old_field.default,
annotations=list(old_field.annotations),
)
)
for candidate in candidate_fields:
if candidate.name not in old_names:
new_fields.append(
FieldDef(
name=candidate.name,
type=candidate.type,
optional=candidate.optional,
default=candidate.default,
annotations=list(candidate.annotations),
)
)
return new_fields


def _classify_attach_change_kind(changes: list[FieldChange]) -> str:
for change in changes:
if change.kind in _BREAKING_ATTACH_CHANGE_KINDS:
return "breaking"
if change.kind == "nullability_changed" and change.from_optional and not change.to_optional:
return "breaking"
return "additive"


def render_attach_audit_summary(result: AttachResult) -> str:
return render_write_audit_summary(
provider="local",
model="modelable-local",
validation_status="passed",
files_written=str(result.path),
inputs=f"ref={result.ref} source={result.source_descriptor} format={result.source_format}",
diagnostics_repaired=0,
)


def _summarize_update_target(workspace, ref: str) -> str:
model_ref = parse_model_ref(ref)
domain = next((item for item in workspace.mdl.domains if item.name == model_ref.domain), None)
Expand Down
Loading
Loading