modelable 1.0.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.
Potentially problematic release.
This version of modelable might be problematic. Click here for more details.
- modelable/__init__.py +1 -0
- modelable/__main__.py +3 -0
- modelable/_pydantic_py314_compat.py +31 -0
- modelable/cli.py +41 -0
- modelable/commands/__init__.py +1 -0
- modelable/commands/apicurio.py +84 -0
- modelable/commands/codegen.py +241 -0
- modelable/commands/common.py +43 -0
- modelable/commands/compile.py +237 -0
- modelable/commands/create.py +164 -0
- modelable/commands/diff.py +82 -0
- modelable/commands/graph.py +53 -0
- modelable/commands/llm.py +564 -0
- modelable/commands/lsp.py +15 -0
- modelable/commands/runtime.py +37 -0
- modelable/commands/scenario.py +104 -0
- modelable/commands/spec.py +197 -0
- modelable/commands/workspace.py +240 -0
- modelable/compat/__init__.py +11 -0
- modelable/compat/checker.py +179 -0
- modelable/compat/diff.py +169 -0
- modelable/compiler/__init__.py +3 -0
- modelable/compiler/compiler.py +19 -0
- modelable/compiler/workspace.py +346 -0
- modelable/diagnostics/__init__.py +3 -0
- modelable/diagnostics/model.py +27 -0
- modelable/emitters/__init__.py +0 -0
- modelable/emitters/base.py +22 -0
- modelable/emitters/csharp.py +245 -0
- modelable/emitters/dbt_yaml.py +290 -0
- modelable/emitters/diagnostics.py +25 -0
- modelable/emitters/fhir.py +694 -0
- modelable/emitters/fhir_validator.py +36 -0
- modelable/emitters/go.py +334 -0
- modelable/emitters/java.py +264 -0
- modelable/emitters/json_schema.py +458 -0
- modelable/emitters/markdown.py +252 -0
- modelable/emitters/odcs.py +355 -0
- modelable/emitters/openlineage.py +315 -0
- modelable/emitters/openmetadata.py +258 -0
- modelable/emitters/python.py +282 -0
- modelable/emitters/rust.py +643 -0
- modelable/emitters/shapes.py +261 -0
- modelable/emitters/sql.py +266 -0
- modelable/emitters/targets.py +141 -0
- modelable/emitters/typescript.py +352 -0
- modelable/expressions/__init__.py +0 -0
- modelable/expressions/cel.py +547 -0
- modelable/governance/__init__.py +3 -0
- modelable/governance/checker.py +271 -0
- modelable/governance/por.py +46 -0
- modelable/grammar/__init__.py +1 -0
- modelable/grammar/modelable.lark +257 -0
- modelable/graph/__init__.py +5 -0
- modelable/graph/export.py +442 -0
- modelable/llm/__init__.py +43 -0
- modelable/llm/chat.py +255 -0
- modelable/llm/config.py +87 -0
- modelable/llm/context.py +194 -0
- modelable/llm/engine.py +976 -0
- modelable/llm/importers.py +1077 -0
- modelable/llm/provenance.py +84 -0
- modelable/llm/providers.py +182 -0
- modelable/llm/qa.py +126 -0
- modelable/llm/recommendations.py +33 -0
- modelable/llm/redaction.py +19 -0
- modelable/llm/render.py +279 -0
- modelable/llm/update_plan.py +101 -0
- modelable/llm/validation_help.py +10 -0
- modelable/lsp/__init__.py +3 -0
- modelable/lsp/__main__.py +4 -0
- modelable/lsp/code_actions.py +210 -0
- modelable/lsp/completion.py +480 -0
- modelable/lsp/definition.py +343 -0
- modelable/lsp/diagnostics.py +31 -0
- modelable/lsp/document_symbols.py +197 -0
- modelable/lsp/federation.py +261 -0
- modelable/lsp/folding.py +33 -0
- modelable/lsp/formatting.py +64 -0
- modelable/lsp/highlight.py +30 -0
- modelable/lsp/hover.py +370 -0
- modelable/lsp/inlay_hints.py +158 -0
- modelable/lsp/references.py +511 -0
- modelable/lsp/rename.py +564 -0
- modelable/lsp/semantic_tokens.py +412 -0
- modelable/lsp/server.py +370 -0
- modelable/lsp/workspace.py +83 -0
- modelable/lsp/workspace_symbols.py +104 -0
- modelable/parser/__init__.py +94 -0
- modelable/parser/ir.py +451 -0
- modelable/parser/parse.py +47 -0
- modelable/parser/transformer.py +798 -0
- modelable/parser/wire.py +68 -0
- modelable/planner/__init__.py +0 -0
- modelable/planner/lineage.py +91 -0
- modelable/planner/planner.py +134 -0
- modelable/planner/plans.py +122 -0
- modelable/py.typed +0 -0
- modelable/registry/__init__.py +9 -0
- modelable/registry/apicurio.py +166 -0
- modelable/registry/base.py +18 -0
- modelable/registry/factory.py +18 -0
- modelable/registry/index.py +419 -0
- modelable/registry/local.py +26 -0
- modelable/registry/oci.py +22 -0
- modelable/registry/resolver.py +213 -0
- modelable/registry/schema.sql +119 -0
- modelable/registry/signature.py +26 -0
- modelable/release.py +125 -0
- modelable/runtime/__init__.py +5 -0
- modelable/runtime/adapter/__init__.py +17 -0
- modelable/runtime/adapter/base.py +18 -0
- modelable/runtime/adapter/postgres.py +82 -0
- modelable/specs/__init__.py +23 -0
- modelable/specs/tracking.py +220 -0
- modelable/validation/__init__.py +3 -0
- modelable/validation/semantic.py +659 -0
- modelable-1.0.0.dist-info/METADATA +61 -0
- modelable-1.0.0.dist-info/RECORD +122 -0
- modelable-1.0.0.dist-info/WHEEL +4 -0
- modelable-1.0.0.dist-info/entry_points.txt +2 -0
- modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
modelable/llm/render.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from modelable.parser.ir import (
|
|
4
|
+
AnnClassification,
|
|
5
|
+
AnnDeprecated,
|
|
6
|
+
AnnKey,
|
|
7
|
+
AnnOwner,
|
|
8
|
+
AnnPii,
|
|
9
|
+
AnnServer,
|
|
10
|
+
AnnWire,
|
|
11
|
+
ArrayType,
|
|
12
|
+
AutoProjectionDecl,
|
|
13
|
+
AutoProjectionTarget,
|
|
14
|
+
BindingDef,
|
|
15
|
+
ComputedMapping,
|
|
16
|
+
DecimalType,
|
|
17
|
+
DirectMapping,
|
|
18
|
+
DomainDef,
|
|
19
|
+
EnumType,
|
|
20
|
+
FieldDef,
|
|
21
|
+
FieldType,
|
|
22
|
+
GenerateTarget,
|
|
23
|
+
MapType,
|
|
24
|
+
MdlFile,
|
|
25
|
+
ModelVersion,
|
|
26
|
+
NamedType,
|
|
27
|
+
ObjectType,
|
|
28
|
+
PrimitiveType,
|
|
29
|
+
ProjectionField,
|
|
30
|
+
ProjectionVersion,
|
|
31
|
+
RefType,
|
|
32
|
+
VersionExact,
|
|
33
|
+
VersionMin,
|
|
34
|
+
VersionRange,
|
|
35
|
+
WorkspaceDef,
|
|
36
|
+
)
|
|
37
|
+
from modelable.parser.wire import render_wire_annotation
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_mdl(mdl: MdlFile) -> str:
|
|
41
|
+
lines: list[str] = []
|
|
42
|
+
for domain in mdl.domains:
|
|
43
|
+
lines.extend(_render_domain(domain))
|
|
44
|
+
lines.append("")
|
|
45
|
+
for binding in mdl.bindings:
|
|
46
|
+
lines.extend(_render_binding(binding))
|
|
47
|
+
lines.append("")
|
|
48
|
+
if mdl.workspace is not None:
|
|
49
|
+
lines.extend(_render_workspace(mdl.workspace))
|
|
50
|
+
lines.append("")
|
|
51
|
+
while lines and lines[-1] == "":
|
|
52
|
+
lines.pop()
|
|
53
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_model_version(
|
|
57
|
+
domain_name: str, model_name: str, version: ModelVersion, owner: str | None = None, description: str | None = None
|
|
58
|
+
) -> str:
|
|
59
|
+
return render_mdl(
|
|
60
|
+
MdlFile(
|
|
61
|
+
domains=[
|
|
62
|
+
DomainDef(
|
|
63
|
+
name=domain_name,
|
|
64
|
+
owner=owner,
|
|
65
|
+
description=description,
|
|
66
|
+
models={model_name: [version]},
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_projection_version(
|
|
74
|
+
domain_name: str,
|
|
75
|
+
projection_name: str,
|
|
76
|
+
version: ProjectionVersion,
|
|
77
|
+
owner: str | None = None,
|
|
78
|
+
description: str | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
return render_mdl(
|
|
81
|
+
MdlFile(
|
|
82
|
+
domains=[
|
|
83
|
+
DomainDef(
|
|
84
|
+
name=domain_name,
|
|
85
|
+
owner=owner,
|
|
86
|
+
description=description,
|
|
87
|
+
projections={projection_name: [version]},
|
|
88
|
+
)
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _render_domain(domain: DomainDef) -> list[str]:
|
|
95
|
+
lines = [f"domain {domain.name} {{"]
|
|
96
|
+
if domain.owner:
|
|
97
|
+
lines.append(f' owner: "{domain.owner}"')
|
|
98
|
+
if domain.description:
|
|
99
|
+
lines.append(f' description: "{domain.description}"')
|
|
100
|
+
for model_name in sorted(domain.models):
|
|
101
|
+
for mv in domain.models[model_name]:
|
|
102
|
+
lines.extend(_indent(_render_model(model_name, mv), 2))
|
|
103
|
+
for projection_name in sorted(domain.projections):
|
|
104
|
+
for pv in domain.projections[projection_name]:
|
|
105
|
+
lines.extend(_indent(_render_projection(projection_name, pv), 2))
|
|
106
|
+
for decl in domain.auto_projections:
|
|
107
|
+
lines.extend(_indent(_render_auto_projection(decl), 2))
|
|
108
|
+
for target in domain.generate_targets:
|
|
109
|
+
lines.extend(_indent(_render_generate_target(target), 2))
|
|
110
|
+
lines.append("}")
|
|
111
|
+
return lines
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _render_model(model_name: str, version: ModelVersion) -> list[str]:
|
|
115
|
+
lines = [f"{version.model_kind.value} {model_name} @ {version.version} ({version.change_kind.value}) {{"]
|
|
116
|
+
for field in version.fields:
|
|
117
|
+
lines.append(_render_field(field, 2))
|
|
118
|
+
lines.append("}")
|
|
119
|
+
return lines
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_field(field: FieldDef, indent: int = 0) -> str:
|
|
123
|
+
prefix = " ".join(_render_annotations(field.annotations))
|
|
124
|
+
suffix = f"{field.name}{'?' if field.optional else ''}: {_render_type(field.type)}"
|
|
125
|
+
return _with_prefix(prefix, suffix, indent)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_projection(projection_name: str, version: ProjectionVersion) -> list[str]:
|
|
129
|
+
lines = [f"projection {projection_name} @ {version.version}"]
|
|
130
|
+
lines.append(
|
|
131
|
+
f" from {version.source.model} @ {_render_version_spec(version.source.version)} as {version.source.alias}"
|
|
132
|
+
)
|
|
133
|
+
for join in version.joins:
|
|
134
|
+
lines.append(f" join {join.model} @ {_render_version_spec(join.version)} as {join.alias} on {join.on}")
|
|
135
|
+
if version.group_by:
|
|
136
|
+
lines.append(f" group by {', '.join(version.group_by)}")
|
|
137
|
+
lines.append("{")
|
|
138
|
+
for field in version.fields:
|
|
139
|
+
lines.append(_render_projection_field(field, 2))
|
|
140
|
+
lines.append("}")
|
|
141
|
+
return lines
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _render_projection_field(field: ProjectionField, indent: int = 0) -> str:
|
|
145
|
+
prefix = " ".join(_render_annotations(field.annotations))
|
|
146
|
+
if isinstance(field.mapping, DirectMapping):
|
|
147
|
+
body = f"{field.name} <- {field.mapping.source_alias}.{field.mapping.source_field}"
|
|
148
|
+
elif isinstance(field.mapping, ComputedMapping):
|
|
149
|
+
body = f"{field.name} = {field.mapping.expression}"
|
|
150
|
+
else:
|
|
151
|
+
body = field.name
|
|
152
|
+
return _with_prefix(prefix, body, indent)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _render_auto_projection(decl: AutoProjectionDecl) -> list[str]:
|
|
156
|
+
lines = [f"auto projections {decl.model} @ {decl.version} {{"]
|
|
157
|
+
for target in decl.targets:
|
|
158
|
+
lines.append(_render_auto_target(target, 2))
|
|
159
|
+
lines.append("}")
|
|
160
|
+
return lines
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _render_auto_target(target: AutoProjectionTarget, indent: int = 0) -> str:
|
|
164
|
+
parts = [target.kind]
|
|
165
|
+
if target.excluded_fields or target.excluded_annotations:
|
|
166
|
+
exclusions = [
|
|
167
|
+
*target.excluded_fields,
|
|
168
|
+
*(_render_annotation_literal(ann) for ann in target.excluded_annotations),
|
|
169
|
+
]
|
|
170
|
+
parts.append(f"exclude [{', '.join(exclusions)}]")
|
|
171
|
+
if target.operations:
|
|
172
|
+
parts.append(f"on [{', '.join(target.operations)}]")
|
|
173
|
+
return _join_tokens(parts, indent)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _render_generate_target(target: GenerateTarget) -> str:
|
|
177
|
+
name = f"{target.name}({target.dialect})" if target.dialect else target.name
|
|
178
|
+
if target.output_path:
|
|
179
|
+
return f'{name} -> "{target.output_path}"'
|
|
180
|
+
return name
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_binding(binding: BindingDef) -> list[str]:
|
|
184
|
+
lines = [f"binding {binding.name} {{"]
|
|
185
|
+
lines.append(f" model: {binding.model} @ {binding.model_version}")
|
|
186
|
+
lines.append(f" adapter: {binding.adapter}")
|
|
187
|
+
if binding.table:
|
|
188
|
+
lines.append(f' table: "{binding.table}"')
|
|
189
|
+
for mapping in binding.field_mappings:
|
|
190
|
+
lines.append(f" {mapping.source} -> {mapping.target}")
|
|
191
|
+
lines.append("}")
|
|
192
|
+
return lines
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _render_workspace(workspace: WorkspaceDef) -> list[str]:
|
|
196
|
+
lines = ["workspace default {"]
|
|
197
|
+
if workspace.ai is not None:
|
|
198
|
+
lines.append(" ai {")
|
|
199
|
+
if workspace.ai.provider:
|
|
200
|
+
lines.append(f' provider: "{workspace.ai.provider}"')
|
|
201
|
+
if workspace.ai.model:
|
|
202
|
+
lines.append(f' model: "{workspace.ai.model}"')
|
|
203
|
+
lines.append(" }")
|
|
204
|
+
for target in workspace.generate_targets:
|
|
205
|
+
lines.append(f" {_render_generate_target(target)}")
|
|
206
|
+
lines.append("}")
|
|
207
|
+
return lines
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _render_type(field_type: FieldType) -> str:
|
|
211
|
+
if isinstance(field_type, PrimitiveType):
|
|
212
|
+
return field_type.kind
|
|
213
|
+
if isinstance(field_type, DecimalType):
|
|
214
|
+
return f"decimal({field_type.precision}, {field_type.scale})"
|
|
215
|
+
if isinstance(field_type, ArrayType):
|
|
216
|
+
return f"array<{_render_type(field_type.item)}>"
|
|
217
|
+
if isinstance(field_type, MapType):
|
|
218
|
+
return f"map<{_render_type(field_type.key)}, {_render_type(field_type.value)}>"
|
|
219
|
+
if isinstance(field_type, RefType):
|
|
220
|
+
return f"ref<{field_type.target}>"
|
|
221
|
+
if isinstance(field_type, EnumType):
|
|
222
|
+
return f"enum({', '.join(field_type.values)})"
|
|
223
|
+
if isinstance(field_type, ObjectType):
|
|
224
|
+
inner = " ".join(_render_field(field, 0) for field in field_type.fields)
|
|
225
|
+
return f"object {{ {inner} }}"
|
|
226
|
+
if isinstance(field_type, NamedType):
|
|
227
|
+
return field_type.name
|
|
228
|
+
return "string"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _render_annotations(annotations) -> list[str]:
|
|
232
|
+
parts: list[str] = []
|
|
233
|
+
for ann in annotations:
|
|
234
|
+
parts.append(_render_annotation_literal(ann))
|
|
235
|
+
return parts
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _render_annotation_literal(annotation) -> str:
|
|
239
|
+
if isinstance(annotation, AnnKey):
|
|
240
|
+
return "@key"
|
|
241
|
+
if isinstance(annotation, AnnPii):
|
|
242
|
+
return "@pii"
|
|
243
|
+
if isinstance(annotation, AnnClassification):
|
|
244
|
+
return f'@classification("{annotation.level}")'
|
|
245
|
+
if isinstance(annotation, AnnDeprecated):
|
|
246
|
+
return f'@deprecated(replacedBy: "{annotation.replaced_by}")'
|
|
247
|
+
if isinstance(annotation, AnnOwner):
|
|
248
|
+
return f'@owner("{annotation.team}")'
|
|
249
|
+
if isinstance(annotation, AnnServer):
|
|
250
|
+
return "@server"
|
|
251
|
+
if isinstance(annotation, AnnWire):
|
|
252
|
+
return render_wire_annotation(annotation)
|
|
253
|
+
return "@unknown"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _render_version_spec(version_spec) -> str:
|
|
257
|
+
if isinstance(version_spec, VersionExact):
|
|
258
|
+
return str(version_spec.version)
|
|
259
|
+
if isinstance(version_spec, VersionRange):
|
|
260
|
+
return f">={version_spec.min_inclusive}<{version_spec.max_exclusive}"
|
|
261
|
+
if isinstance(version_spec, VersionMin):
|
|
262
|
+
return f">={version_spec.min_inclusive}"
|
|
263
|
+
return "0"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _join_tokens(parts: list[str], indent: int = 0) -> str:
|
|
267
|
+
text = " ".join(part for part in parts if part)
|
|
268
|
+
return f"{' ' * indent}{text}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _with_prefix(prefix: str, body: str, indent: int = 0) -> str:
|
|
272
|
+
if prefix:
|
|
273
|
+
return f"{' ' * indent}{prefix} {body}"
|
|
274
|
+
return f"{' ' * indent}{body}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _indent(lines: list[str], spaces: int) -> list[str]:
|
|
278
|
+
pad = " " * spaces
|
|
279
|
+
return [f"{pad}{line}" if line else line for line in lines]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Literal, cast
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from modelable.llm.providers import LLMRequest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UpdateChange(BaseModel):
|
|
13
|
+
kind: Literal[
|
|
14
|
+
"make_optional",
|
|
15
|
+
"make_required",
|
|
16
|
+
"rename_field",
|
|
17
|
+
"remove_field",
|
|
18
|
+
"change_type",
|
|
19
|
+
"add_field",
|
|
20
|
+
"change_source",
|
|
21
|
+
]
|
|
22
|
+
field: str
|
|
23
|
+
new_name: str | None = None
|
|
24
|
+
type: str | None = None
|
|
25
|
+
source: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UpdatePlan(BaseModel):
|
|
29
|
+
target: str
|
|
30
|
+
target_kind: Literal["model", "projection"]
|
|
31
|
+
rationale: str | None = None
|
|
32
|
+
warnings: list[str] = Field(default_factory=list)
|
|
33
|
+
changes: list[UpdateChange] = Field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SYSTEM_PROMPT = """You edit Modelable .mdl definitions.
|
|
37
|
+
Return JSON only matching the supplied schema.
|
|
38
|
+
Do not include markdown fences, prose, or commentary.
|
|
39
|
+
Prefer the smallest set of changes that satisfies the instruction.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
REPAIR_SYSTEM_PROMPT = """You edit Modelable .mdl definitions.
|
|
43
|
+
The previous response failed validation.
|
|
44
|
+
Return JSON only matching the supplied schema.
|
|
45
|
+
Do not include markdown fences, prose, or commentary.
|
|
46
|
+
Prefer the smallest set of changes that satisfies the instruction.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_update_request(*, ref: str, current_summary: str, current_text: str, instruction: str) -> LLMRequest:
|
|
51
|
+
user = (
|
|
52
|
+
f"Target reference: {ref}\n\n"
|
|
53
|
+
f"Current summary:\n{current_summary}\n\n"
|
|
54
|
+
f"Current .mdl:\n{current_text}\n\n"
|
|
55
|
+
f"Instruction:\n{instruction}\n"
|
|
56
|
+
)
|
|
57
|
+
return LLMRequest(
|
|
58
|
+
system=SYSTEM_PROMPT,
|
|
59
|
+
user=user,
|
|
60
|
+
temperature=0.1,
|
|
61
|
+
response_format="json",
|
|
62
|
+
schema=UpdatePlan.model_json_schema(),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_update_repair_request(
|
|
67
|
+
*,
|
|
68
|
+
ref: str,
|
|
69
|
+
current_summary: str,
|
|
70
|
+
current_text: str,
|
|
71
|
+
instruction: str,
|
|
72
|
+
validation_error: str,
|
|
73
|
+
) -> LLMRequest:
|
|
74
|
+
user = (
|
|
75
|
+
f"Target reference: {ref}\n\n"
|
|
76
|
+
f"Current summary:\n{current_summary}\n\n"
|
|
77
|
+
f"Current .mdl:\n{current_text}\n\n"
|
|
78
|
+
f"Instruction:\n{instruction}\n\n"
|
|
79
|
+
f"Previous response validation error:\n{validation_error}\n\n"
|
|
80
|
+
"Return a corrected JSON object only."
|
|
81
|
+
)
|
|
82
|
+
return LLMRequest(
|
|
83
|
+
system=REPAIR_SYSTEM_PROMPT,
|
|
84
|
+
user=user,
|
|
85
|
+
temperature=0.05,
|
|
86
|
+
response_format="json",
|
|
87
|
+
schema=UpdatePlan.model_json_schema(),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_update_plan(text: str) -> UpdatePlan:
|
|
92
|
+
payload = _extract_json(text)
|
|
93
|
+
return UpdatePlan.model_validate(payload)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_json(text: str) -> dict[str, object]:
|
|
97
|
+
stripped = text.strip()
|
|
98
|
+
if stripped.startswith("```"):
|
|
99
|
+
stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
|
|
100
|
+
stripped = re.sub(r"\s*```$", "", stripped)
|
|
101
|
+
return cast(dict[str, object], json.loads(stripped))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def explain_validation_errors(errors: list[str]) -> str:
|
|
5
|
+
if not errors:
|
|
6
|
+
return "No validation errors found."
|
|
7
|
+
lines = ["Validation guidance:"]
|
|
8
|
+
for error in errors:
|
|
9
|
+
lines.append(f"- {error}")
|
|
10
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from modelable.lsp.workspace import LspWorkspaceIndex
|
|
8
|
+
|
|
9
|
+
_DECL_PATTERN = re.compile(
|
|
10
|
+
r"^\s*(?P<kind>entity|aggregate|event|value|projection)\s+"
|
|
11
|
+
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*@\s*(?P<version>\d+)"
|
|
12
|
+
)
|
|
13
|
+
_MODEL_WITHOUT_VERSION_PATTERN = re.compile(
|
|
14
|
+
r"^\s*(?P<kind>entity|aggregate|event|value)\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\{"
|
|
15
|
+
)
|
|
16
|
+
_FIELD_PATTERN = re.compile(r"^\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?\s+)*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\??\s*:")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_code_actions(
|
|
20
|
+
index: LspWorkspaceIndex,
|
|
21
|
+
uri: str,
|
|
22
|
+
line: int,
|
|
23
|
+
character: int,
|
|
24
|
+
diagnostics: list[types.Diagnostic],
|
|
25
|
+
) -> list[types.CodeAction] | None:
|
|
26
|
+
source = index.documents.get(uri)
|
|
27
|
+
if source is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
if not source.text:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if _has_parse_eof_diagnostic(diagnostics):
|
|
34
|
+
lines = source.text.splitlines()
|
|
35
|
+
if not lines:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
last_line_index = len(lines) - 1
|
|
39
|
+
last_character = len(lines[-1])
|
|
40
|
+
edit = types.WorkspaceEdit(
|
|
41
|
+
changes={
|
|
42
|
+
uri: [
|
|
43
|
+
types.TextEdit(
|
|
44
|
+
range=types.Range(
|
|
45
|
+
start=types.Position(line=last_line_index, character=last_character),
|
|
46
|
+
end=types.Position(line=last_line_index, character=last_character),
|
|
47
|
+
),
|
|
48
|
+
new_text="\n}",
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
return [
|
|
54
|
+
types.CodeAction(
|
|
55
|
+
title="Insert missing closing brace",
|
|
56
|
+
kind=types.CodeActionKind.QuickFix,
|
|
57
|
+
diagnostics=diagnostics,
|
|
58
|
+
is_preferred=True,
|
|
59
|
+
edit=edit,
|
|
60
|
+
)
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
if _has_missing_key_diagnostic(diagnostics):
|
|
64
|
+
return _missing_key_action(source.text, uri, diagnostics)
|
|
65
|
+
|
|
66
|
+
if _has_missing_owner_diagnostic(diagnostics):
|
|
67
|
+
return _missing_owner_action(source.text, uri, diagnostics)
|
|
68
|
+
|
|
69
|
+
if _has_missing_version_diagnostic(diagnostics):
|
|
70
|
+
return _missing_version_action(source.text, uri, diagnostics)
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _has_parse_eof_diagnostic(diagnostics: list[types.Diagnostic]) -> bool:
|
|
76
|
+
for diagnostic in diagnostics:
|
|
77
|
+
if diagnostic.code != "PARSE":
|
|
78
|
+
continue
|
|
79
|
+
if "Unexpected end-of-input" in diagnostic.message:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _has_missing_key_diagnostic(diagnostics: list[types.Diagnostic]) -> bool:
|
|
85
|
+
return any(
|
|
86
|
+
diagnostic.code == "SEM" and "must have exactly one @key field" in diagnostic.message
|
|
87
|
+
for diagnostic in diagnostics
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _has_missing_owner_diagnostic(diagnostics: list[types.Diagnostic]) -> bool:
|
|
92
|
+
return any(
|
|
93
|
+
diagnostic.code == "SEM" and "must have an owner attribute" in diagnostic.message for diagnostic in diagnostics
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _has_missing_version_diagnostic(diagnostics: list[types.Diagnostic]) -> bool:
|
|
98
|
+
return any(
|
|
99
|
+
diagnostic.code == "SEM" and "must have a version header" in diagnostic.message for diagnostic in diagnostics
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _missing_key_action(text: str, uri: str, diagnostics: list[types.Diagnostic]) -> list[types.CodeAction] | None:
|
|
104
|
+
lines = text.splitlines()
|
|
105
|
+
in_model = False
|
|
106
|
+
for line_no, line in enumerate(lines):
|
|
107
|
+
if _DECL_PATTERN.match(line):
|
|
108
|
+
in_model = True
|
|
109
|
+
continue
|
|
110
|
+
if not in_model:
|
|
111
|
+
continue
|
|
112
|
+
field_match = _FIELD_PATTERN.match(line)
|
|
113
|
+
if field_match is None:
|
|
114
|
+
if line.strip() == "}":
|
|
115
|
+
in_model = False
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
insert_at = field_match.start("name")
|
|
119
|
+
edit = types.WorkspaceEdit(
|
|
120
|
+
changes={
|
|
121
|
+
uri: [
|
|
122
|
+
types.TextEdit(
|
|
123
|
+
range=types.Range(
|
|
124
|
+
start=types.Position(line=line_no, character=insert_at),
|
|
125
|
+
end=types.Position(line=line_no, character=insert_at),
|
|
126
|
+
),
|
|
127
|
+
new_text="@key ",
|
|
128
|
+
)
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return [
|
|
133
|
+
types.CodeAction(
|
|
134
|
+
title="Insert @key annotation",
|
|
135
|
+
kind=types.CodeActionKind.QuickFix,
|
|
136
|
+
diagnostics=diagnostics,
|
|
137
|
+
is_preferred=True,
|
|
138
|
+
edit=edit,
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _missing_owner_action(text: str, uri: str, diagnostics: list[types.Diagnostic]) -> list[types.CodeAction] | None:
|
|
146
|
+
lines = text.splitlines()
|
|
147
|
+
for line_no, line in enumerate(lines):
|
|
148
|
+
if "domain" in line and "{" in line:
|
|
149
|
+
# Insert owner right after the opening brace
|
|
150
|
+
indent = " "
|
|
151
|
+
if line_no + 1 < len(lines):
|
|
152
|
+
next_line = lines[line_no + 1]
|
|
153
|
+
if next_line.strip():
|
|
154
|
+
indent = next_line[: len(next_line) - len(next_line.lstrip())]
|
|
155
|
+
|
|
156
|
+
edit = types.WorkspaceEdit(
|
|
157
|
+
changes={
|
|
158
|
+
uri: [
|
|
159
|
+
types.TextEdit(
|
|
160
|
+
range=types.Range(
|
|
161
|
+
start=types.Position(line=line_no + 1, character=0),
|
|
162
|
+
end=types.Position(line=line_no + 1, character=0),
|
|
163
|
+
),
|
|
164
|
+
new_text=f'{indent}owner: "required-team"\n',
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
return [
|
|
170
|
+
types.CodeAction(
|
|
171
|
+
title='Insert owner: "required-team"',
|
|
172
|
+
kind=types.CodeActionKind.QuickFix,
|
|
173
|
+
diagnostics=diagnostics,
|
|
174
|
+
is_preferred=True,
|
|
175
|
+
edit=edit,
|
|
176
|
+
)
|
|
177
|
+
]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _missing_version_action(text: str, uri: str, diagnostics: list[types.Diagnostic]) -> list[types.CodeAction] | None:
|
|
182
|
+
lines = text.splitlines()
|
|
183
|
+
for line_no, line in enumerate(lines):
|
|
184
|
+
match = _MODEL_WITHOUT_VERSION_PATTERN.match(line)
|
|
185
|
+
if match:
|
|
186
|
+
# Insert @ 1 (additive) before the brace
|
|
187
|
+
opening_brace_at = line.find("{")
|
|
188
|
+
edit = types.WorkspaceEdit(
|
|
189
|
+
changes={
|
|
190
|
+
uri: [
|
|
191
|
+
types.TextEdit(
|
|
192
|
+
range=types.Range(
|
|
193
|
+
start=types.Position(line=line_no, character=opening_brace_at),
|
|
194
|
+
end=types.Position(line=line_no, character=opening_brace_at),
|
|
195
|
+
),
|
|
196
|
+
new_text="@ 1 (additive) ",
|
|
197
|
+
)
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
return [
|
|
202
|
+
types.CodeAction(
|
|
203
|
+
title="Insert @ 1 (additive)",
|
|
204
|
+
kind=types.CodeActionKind.QuickFix,
|
|
205
|
+
diagnostics=diagnostics,
|
|
206
|
+
is_preferred=True,
|
|
207
|
+
edit=edit,
|
|
208
|
+
)
|
|
209
|
+
]
|
|
210
|
+
return None
|