metaobjects 0.9.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.
Files changed (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
@@ -0,0 +1,70 @@
1
+ """template_generator() — Python port of the TS rc.12 factory.
2
+
3
+ Walks the loaded MetaRoot -> renders shared Mustache templates via the
4
+ metaobjects.render engine -> returns EmittedFile[]. Same Generator Protocol
5
+ as the per-entity hand-coded generators; just adds the "Mustache template"
6
+ + "walk that yields a data dict per output" primitives.
7
+
8
+ Design: spec/design-docs/2026-05-28-cross-port-template-generator.md.
9
+ Cross-port byte-equivalence verified via fixtures/render-conformance/template-generator/.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable, Iterable, Sequence
15
+
16
+ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator
17
+ from metaobjects.render import escapers
18
+ from metaobjects.render.renderer import RenderRequest, render
19
+ from metaobjects.render.verify import Provider
20
+
21
+
22
+ @dataclass
23
+ class _TemplateGenerator:
24
+ name: str
25
+ template: str
26
+ walk: Callable[[Any], Sequence[dict]]
27
+ provider: Provider
28
+ format: str = escapers.FORMAT_TEXT
29
+
30
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
31
+ walk_results: Iterable[dict] = self.walk(ctx.loaded_root)
32
+ files: list[EmittedFile] = []
33
+ for entry in walk_results:
34
+ content = render(
35
+ RenderRequest(
36
+ payload=entry["data"],
37
+ provider=self.provider,
38
+ ref=self.template,
39
+ format=self.format,
40
+ )
41
+ )
42
+ files.append(EmittedFile(path=entry["output_path"], content=content))
43
+ return files
44
+
45
+
46
+ def template_generator(
47
+ *,
48
+ name: str,
49
+ template: str,
50
+ walk: Callable[[Any], Sequence[dict]],
51
+ provider: Provider,
52
+ format: str = escapers.FORMAT_TEXT,
53
+ ) -> Generator:
54
+ """Build a Generator that renders a Mustache template per walk entry.
55
+
56
+ Args:
57
+ name: kebab-case identifier; surfaces in diagnostics.
58
+ template: ref resolved by the provider (e.g. "custom/hello").
59
+ walk: callback that takes the loaded MetaRoot and returns a list of
60
+ dicts shaped {"data": <payload>, "output_path": <relative path>}.
61
+ provider: ref-resolver for the template.
62
+ format: render format ("text", "html", "markdown", ...). Defaults to text.
63
+ """
64
+ return _TemplateGenerator(
65
+ name=name,
66
+ template=template,
67
+ walk=walk,
68
+ provider=provider,
69
+ format=format,
70
+ )
@@ -0,0 +1,120 @@
1
+ """FR-017 Tier 4 — codegen-side descriptor for a table-per-hierarchy (TPH) base.
2
+
3
+ Mirrors the C# ``TphPlan`` / TS ``tph-discriminator.ts``: an ``object.entity``
4
+ carrying ``@discriminator`` is the TPH base; concrete entities that ``extends`` it
5
+ and declare ``@discriminatorValue`` are its subtypes, all sharing ONE physical
6
+ table (single-table inheritance). The plan is the single source of truth every
7
+ TPH-aware generator reads, so the route-segment rule and subtype set never drift.
8
+
9
+ The per-subtype REST route segment is the ``@discriminatorValue`` lowercased
10
+ (``"Bridge"`` → ``bridge``, ``"PriorAuth"`` → ``priorauth``) — derived in exactly
11
+ one place (:func:`route_segment`).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from metaobjects.meta.core.object.meta_object import MetaObject
18
+ from metaobjects.meta.core.object.object_constants import (
19
+ OBJECT_ATTR_DISCRIMINATOR,
20
+ OBJECT_ATTR_DISCRIMINATOR_VALUE,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TphSubtypePlan:
26
+ #: The concrete subtype entity.
27
+ entity: MetaObject
28
+ #: Its ``@discriminatorValue`` (e.g. ``"Bridge"``).
29
+ value: str
30
+ #: The per-subtype REST route segment (e.g. ``"bridge"``).
31
+ route_segment: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TphPlan:
36
+ #: The discriminator base entity.
37
+ base: MetaObject
38
+ #: The discriminator field name (the base's ``@discriminator``).
39
+ discriminator_field: str
40
+ #: Concrete subtypes in stable (name-sorted) order.
41
+ subtypes: list[TphSubtypePlan]
42
+
43
+
44
+ def route_segment(discriminator_value: str) -> str:
45
+ """The per-subtype REST route segment — the ONE place this rule lives: the
46
+ discriminator value lowercased."""
47
+ return discriminator_value.lower()
48
+
49
+
50
+ def _discriminator_root(obj: MetaObject) -> MetaObject | None:
51
+ """The nearest ``@discriminator``-bearing ancestor (or self), walking ``extends``."""
52
+ cursor: MetaObject | None = obj
53
+ while cursor is not None:
54
+ field = cursor.attr(OBJECT_ATTR_DISCRIMINATOR) # own attr
55
+ if isinstance(field, str) and field:
56
+ return cursor
57
+ cursor = cursor.super_data # type: ignore[assignment]
58
+ return None
59
+
60
+
61
+ def tph_subtype_binding(obj: MetaObject) -> tuple[str, str] | None:
62
+ """For a concrete TPH subtype, return ``(discriminator_field, discriminator_value)``
63
+ — the field NAME inherited from the ``@discriminator`` base + this subtype's own
64
+ ``@discriminatorValue``. ``None`` when *obj* is not a subtype. Used by the entity
65
+ generator to pin the inherited discriminator field to a ``Literal`` on the subtype."""
66
+ value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE) # own attr
67
+ if not isinstance(value, str) or not value:
68
+ return None
69
+ root = _discriminator_root(obj)
70
+ if root is None or root is obj:
71
+ return None
72
+ field = root.attr(OBJECT_ATTR_DISCRIMINATOR)
73
+ if not isinstance(field, str) or not field:
74
+ return None
75
+ return (field, value)
76
+
77
+
78
+ def is_tph_subtype(obj: MetaObject) -> bool:
79
+ """True when *obj* is a concrete TPH subtype: it declares ``@discriminatorValue``
80
+ and (transitively) extends a ``@discriminator``-bearing base. Such an entity emits
81
+ NO standalone table/router — it is folded into the base's single table."""
82
+ value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE) # own attr
83
+ if not isinstance(value, str) or not value:
84
+ return False
85
+ root = _discriminator_root(obj)
86
+ return root is not None and root is not obj
87
+
88
+
89
+ def tph_plan_for(base: MetaObject, object_index: dict[str, MetaObject]) -> TphPlan | None:
90
+ """The :class:`TphPlan` for a discriminator base, or ``None`` when *base* is not a
91
+ discriminator base (no ``@discriminator``, or no concrete subtypes)."""
92
+ disc_field = base.attr(OBJECT_ATTR_DISCRIMINATOR) # own attr
93
+ if not isinstance(disc_field, str) or not disc_field:
94
+ return None
95
+ subtypes: list[TphSubtypePlan] = []
96
+ for obj in object_index.values():
97
+ if obj is base or getattr(obj, "is_abstract", False):
98
+ continue
99
+ value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE) # own attr
100
+ if not isinstance(value, str) or not value:
101
+ continue
102
+ # Walk the extends chain looking for `base`.
103
+ cursor = obj.super_data
104
+ found = False
105
+ while cursor is not None:
106
+ if cursor is base:
107
+ found = True
108
+ break
109
+ cursor = cursor.super_data
110
+ if found:
111
+ subtypes.append(TphSubtypePlan(obj, value, route_segment(value)))
112
+ if not subtypes:
113
+ return None
114
+ subtypes.sort(key=lambda s: s.entity.name)
115
+ return TphPlan(base, disc_field, subtypes)
116
+
117
+
118
+ def is_tph_base(obj: MetaObject, object_index: dict[str, MetaObject]) -> bool:
119
+ """True when *obj* carries ``@discriminator`` AND has at least one concrete subtype."""
120
+ return tph_plan_for(obj, object_index) is not None
@@ -0,0 +1,336 @@
1
+ """Trace-helper codegen — one ``record_<entity>.py`` per concrete ``object.entity``
2
+ that (a) transitively ``extends`` ``metaobjects::ai::LlmCallBase`` AND (b) nests a
3
+ ``template.prompt`` carrying ``@responseRef`` and/or ``@payloadRef``.
4
+
5
+ AI LLM-call trace persistence — Unit 2 Slice 2 (Python port). Cross-port parity
6
+ with the TypeScript ``trace-helper-file.ts`` (``record<Entity>`` + ``call<Entity>``)
7
+ and the Java ``LlmTraceHelperGenerator`` (``record<Entity>`` only). This Python
8
+ port emits the ``record_<entity>`` half ONLY — the render→call→record loop needs an
9
+ ``LlmClient`` seam that is BYO / vendor-neutral (ADR-0024), so ``call<Entity>`` is
10
+ intentionally NOT emitted (matching Java).
11
+
12
+ The emitted helper exposes a single ``record_<snake>(recorder, input, redact=None)``
13
+ that:
14
+
15
+ 1. runs the tolerant extract (``extract`` from ``metaobjects.render.extract``,
16
+ which NEVER raises) of ``input.llm_response_text`` against a baked
17
+ ``_RESPONSE_SCHEMA`` (the response VO's field shape — emitted via the SAME
18
+ ``extract_schema_emitter`` path the output-parser generator uses for its
19
+ tolerant ``extract_lenient_*`` twin);
20
+ 2. derives ``status``/``error_detail`` from the extract report's lost-required gate
21
+ (a lost ``@required`` field → ``status="error"`` + a ``"lost required: …"`` detail);
22
+ 3. builds the base trace row via the Slice-1 ``build_llm_call_row`` (the 18
23
+ ``LlmCallBase`` base fields + raw ``llmRequest``/``llmResponse``), folding in the
24
+ derived status/error_detail by replacing the input;
25
+ 4. sets the typed ``voRequest`` (``input.llm_request``) and ``voResponse``
26
+ (``outcome.data`` — the extracted mirror dict) columns on the row → native jsonb;
27
+ 5. persists the row ONCE via the supplied recorder (the never-throwing Slice-1
28
+ ``persist_llm_call_row``, which redacts then records);
29
+ 6. returns a typed ``<Entity>TraceResult`` (``status``, ``error_detail``,
30
+ ``vo_response``).
31
+
32
+ Skips (no helper emitted) when the entity is abstract, does not derive from
33
+ ``LlmCallBase``, has no nested ``template.prompt``, or that prompt carries NEITHER
34
+ ``@responseRef`` NOR ``@payloadRef`` (both gate the helper — matching the TS
35
+ reference). The response VO is resolved via ``resolve_payload_vo`` (short-name OR
36
+ FQN); a non-``object.value`` target is a hard generator error (matching Java).
37
+
38
+ STI/TPH discriminator handling is DEFERRED (matching the Java port's Slice 2 — a
39
+ plain trace entity is the target).
40
+ """
41
+ from __future__ import annotations
42
+
43
+ from collections.abc import Callable
44
+
45
+ from metaobjects.codegen import extract_schema_emitter as rse
46
+ from metaobjects.codegen.constants import generated_header
47
+ from metaobjects.codegen.format import ruff_format
48
+ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator
49
+ from metaobjects.codegen.generators.payload_vo_generator import resolve_payload_vo
50
+ from metaobjects.meta.core.object.meta_object import MetaObject
51
+ from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_ENTITY
52
+ from metaobjects.meta.meta_data import MetaData
53
+ from metaobjects.meta.template import template_constants as tc
54
+ from metaobjects.meta.template.meta_template import MetaTemplate
55
+ from metaobjects.shared.base_types import TYPE_TEMPLATE
56
+
57
+ _GENERATOR_NAME = "trace-helper"
58
+
59
+ #: The abstract base entity a trace entity must (transitively) ``extends``.
60
+ #: Cross-port constant — mirrors TS ``LLM_CALL_BASE`` / Java ``LLM_CALL_BASE``.
61
+ LLM_CALL_BASE = "LlmCallBase"
62
+
63
+
64
+ def _snake_case(name: str) -> str:
65
+ """``GreetingCall`` → ``greeting_call``. PascalCase → snake_case with no
66
+ acronym handling — matches the convention used by sibling generators
67
+ (``output_parser_generator._snake_case`` etc.)."""
68
+ out: list[str] = []
69
+ for i, ch in enumerate(name):
70
+ if ch.isupper() and i > 0:
71
+ out.append("_")
72
+ out.append(ch.lower())
73
+ return "".join(out)
74
+
75
+
76
+ def _extends_base(entity: MetaObject) -> bool:
77
+ """Walk the resolved super chain looking for a node SHORT-named
78
+ :data:`LLM_CALL_BASE`. ``MetaData.name`` holds the short name only (the package
79
+ lives on ``MetaData.package``), so a plain ``name`` compare is the short-name
80
+ test — mirrors the Java ``getShortName()`` walk and the TS ``superResolved``
81
+ walk."""
82
+ cur = entity.super_data
83
+ visited: set[int] = set()
84
+ while cur is not None and id(cur) not in visited:
85
+ if cur.name == LLM_CALL_BASE:
86
+ return True
87
+ visited.add(id(cur))
88
+ cur = cur.super_data
89
+ return False
90
+
91
+
92
+ def _first_prompt(entity: MetaObject) -> MetaTemplate | None:
93
+ """First OWN ``template.prompt`` child of *entity*, or ``None``. Own-only —
94
+ the trace prompt is declared inline on the concrete trace entity (Slice 1/3
95
+ derive the typed columns from it)."""
96
+ for child in entity.own_children():
97
+ if (
98
+ isinstance(child, MetaTemplate)
99
+ and child.type == TYPE_TEMPLATE
100
+ and child.sub_type == tc.TEMPLATE_SUBTYPE_PROMPT
101
+ ):
102
+ return child
103
+ return None
104
+
105
+
106
+ def render_trace_helper(entity: MetaObject, root: MetaData) -> str | None:
107
+ """Render one ``record_<entity>.py`` for a concrete trace ``object.entity``.
108
+
109
+ Returns ``None`` when the entity is not a trace-helper target (abstract, not
110
+ ``LlmCallBase``-derived, no nested ``template.prompt``, or that prompt carries
111
+ neither ``@responseRef`` nor ``@payloadRef``).
112
+
113
+ Raises ``ValueError`` when the prompt's ``@responseRef`` does not resolve to an
114
+ ``object.value`` (matching the Java port's hard ``GeneratorException``)."""
115
+ if entity.is_abstract:
116
+ return None
117
+ if not _extends_base(entity):
118
+ return None
119
+
120
+ prompt = _first_prompt(entity)
121
+ if prompt is None:
122
+ return None
123
+
124
+ response_ref = prompt.response_ref()
125
+ payload_ref = prompt.payload_ref()
126
+ # Both gate the helper — at least one of @responseRef / @payloadRef must be
127
+ # present (matches the TS reference, which needs @responseRef to type the result
128
+ # and @payloadRef to type the request).
129
+ if response_ref is None and payload_ref is None:
130
+ return None
131
+
132
+ # The response VO drives the baked extract schema + the typed voResponse. When
133
+ # only @payloadRef is set we still emit a helper (the request is typed); the
134
+ # extract schema falls back to an empty descriptor (no response VO to shape it).
135
+ response_vo = resolve_payload_vo(root, response_ref) if response_ref else None
136
+ if response_ref is not None and response_vo is None:
137
+ raise ValueError(
138
+ f"{_GENERATOR_NAME}: entity {entity.name!r} prompt @responseRef "
139
+ f"{response_ref!r} does not resolve to an object.value"
140
+ )
141
+
142
+ entity_name = entity.name
143
+ snake = _snake_case(entity_name)
144
+ record_fn = f"record_{snake}"
145
+ result_class = f"{entity_name}TraceResult"
146
+
147
+ # Derive the parse format from the prompt's @format attr (default json) — the
148
+ # SAME rule the output-parser / extractor generators use.
149
+ fmt = prompt.attr(tc.TEMPLATE_ATTR_FORMAT)
150
+ fmt_str = fmt if isinstance(fmt, str) else tc.TEMPLATE_FORMAT_DEFAULT
151
+
152
+ fqn = entity.fqn()
153
+
154
+ # Baked response-extract schema. REUSE the extract_schema_emitter exactly as the
155
+ # output-parser generator does for its tolerant ``extract_lenient_*`` twin:
156
+ # ``schema_literal(vo, fmt, root_name)`` emits an
157
+ # ``ExtractSchema(Format.X, "<root>", [FieldSpec(...), …])`` literal, and
158
+ # ``extract_map_imports(vo)`` returns the sorted/deduped ``extract_map`` accessor
159
+ # names the literal's FieldSpec helpers reference. ``root_name`` is the response
160
+ # VO's short name (the JSON/XML root the tolerant reader locates). When there is
161
+ # no response VO (only @payloadRef set) the schema is an empty descriptor whose
162
+ # extract yields ``{}``.
163
+ if response_vo is not None:
164
+ schema_literal = rse.schema_literal(response_vo, fmt_str, response_vo.name)
165
+ helpers = rse.extract_map_imports(response_vo)
166
+ else:
167
+ schema_literal = f'ExtractSchema({_format_enum(fmt_str)}, "response", [])'
168
+ helpers = []
169
+
170
+ request_doc = payload_ref if payload_ref else "the structured request object"
171
+
172
+ lines: list[str] = [
173
+ generated_header(entity_name, fqn),
174
+ "from __future__ import annotations",
175
+ "",
176
+ "import dataclasses",
177
+ "from dataclasses import dataclass",
178
+ "",
179
+ "from metaobjects.render.extract import (",
180
+ " ExtractSchema,",
181
+ " FieldKind,",
182
+ " FieldSpec,",
183
+ " Format,",
184
+ " extract,",
185
+ ")",
186
+ ]
187
+ if helpers:
188
+ lines.append("from metaobjects.render.extract.extract_map import (")
189
+ for h in helpers:
190
+ lines.append(f" {h},")
191
+ lines.append(")")
192
+ lines.extend(
193
+ [
194
+ "from metaobjects.runtime import (",
195
+ " LlmCallInput,",
196
+ " LlmCallRecorder,",
197
+ " LlmCallRow,",
198
+ " STATUS_ERROR,",
199
+ " STATUS_OK,",
200
+ " build_llm_call_row,",
201
+ " persist_llm_call_row,",
202
+ ")",
203
+ "",
204
+ "from collections.abc import Callable",
205
+ "",
206
+ "",
207
+ "# AI-trace baked response-extract descriptor — the format/root/field shape",
208
+ "# the tolerant parser repairs the model's raw response text against.",
209
+ f"_RESPONSE_SCHEMA: ExtractSchema = {schema_literal}",
210
+ "",
211
+ "",
212
+ "@dataclass(frozen=True, slots=True)",
213
+ f"class {result_class}:",
214
+ f' """Typed result of ``{record_fn}``: the derived call outcome plus the',
215
+ " best-effort extracted response VO.",
216
+ "",
217
+ " * ``status`` — ``STATUS_OK`` | ``STATUS_ERROR`` (a lost ``@required``",
218
+ " response field → ``STATUS_ERROR``).",
219
+ " * ``error_detail`` — a ``\"lost required: …\"`` summary when ``status`` is",
220
+ " ``STATUS_ERROR``, else ``None``.",
221
+ " * ``vo_response`` — the extracted response mirror dict (``None`` only when",
222
+ ' extraction produced nothing)."""',
223
+ " status: str",
224
+ " error_detail: str | None",
225
+ " vo_response: dict | None",
226
+ "",
227
+ "",
228
+ f"def {record_fn}(",
229
+ " recorder: LlmCallRecorder,",
230
+ " input: LlmCallInput,",
231
+ " redact: Callable[[LlmCallRow], LlmCallRow] | None = None,",
232
+ f") -> {result_class}:",
233
+ f' """Record a single ``{entity_name}`` LLM call: extract the typed response',
234
+ " VO from ``input.llm_response_text`` and persist ONE trace row (the base",
235
+ " envelope + raw I/O + typed voRequest/voResponse) via ``recorder`` —",
236
+ " regardless of whether extraction succeeded.",
237
+ "",
238
+ " The tolerant ``extract`` NEVER raises; a lost ``@required`` response field",
239
+ " drives ``status``/``error_detail`` (it does not abort the persist). The",
240
+ f" request payload (``input.llm_request``, typed as ``{request_doc}`` at the",
241
+ " call site) is threaded through as the typed ``voRequest`` column.",
242
+ "",
243
+ " :param recorder: the never-throwing write-side seam (Slice-1 recorder).",
244
+ " :param input: the LLM call fields (envelope + raw request/response text).",
245
+ " :param redact: optional row redaction applied before the single record.",
246
+ ' """',
247
+ " outcome = extract(input.llm_response_text, _RESPONSE_SCHEMA)",
248
+ " failed = outcome.report.has_lost_required()",
249
+ " status = STATUS_ERROR if failed else STATUS_OK",
250
+ " error_detail = (",
251
+ ' "lost required: " + ", ".join(outcome.report.lost_required())',
252
+ " if failed",
253
+ " else None",
254
+ " )",
255
+ " # Extraction owns the derived status/error_detail — fold them into the input",
256
+ " # before building the base row (dataclasses.replace, leaving the original",
257
+ " # input untouched).",
258
+ " effective = dataclasses.replace(",
259
+ " input, status=status, error_detail=error_detail",
260
+ " )",
261
+ " row = build_llm_call_row(effective)",
262
+ " # Typed columns → native jsonb (pg8000 binds dict/list straight to jsonb).",
263
+ " row[\"voResponse\"] = outcome.data",
264
+ " row[\"voRequest\"] = input.llm_request",
265
+ " # Persist ONCE (redact-then-record; the recorder never raises).",
266
+ " persist_llm_call_row(recorder, row, redact)",
267
+ f" return {result_class}(status, error_detail, outcome.data)",
268
+ "",
269
+ "",
270
+ f'__all__ = ["{record_fn}", "{result_class}"]',
271
+ "",
272
+ ]
273
+ )
274
+ return "\n".join(lines)
275
+
276
+
277
+ def _format_enum(fmt: str) -> str:
278
+ """``"xml"`` → ``Format.XML``; anything else → ``Format.JSON``. Matches the
279
+ extract_schema_emitter's private ``_format_enum`` (kept local to avoid reaching
280
+ into a private helper)."""
281
+ return "Format.XML" if fmt.lower() == tc.TEMPLATE_FORMAT_XML else "Format.JSON"
282
+
283
+
284
+ class TraceHelperGenerator:
285
+ """Generator wrapping :func:`render_trace_helper`. Emits one
286
+ ``record_<entity>.py`` per concrete ``object.entity`` that derives from
287
+ ``LlmCallBase`` and nests a ``template.prompt`` with ``@responseRef`` /
288
+ ``@payloadRef`` (skips everything else)."""
289
+
290
+ name = _GENERATOR_NAME
291
+
292
+ def __init__(self, *, filter: Callable[[MetaObject], bool] | None = None) -> None:
293
+ # The ``filter`` arg matches the cross-generator contract.
294
+ self.filter = filter
295
+
296
+ def _render_module(self, entity: MetaObject, root: MetaData) -> str | None:
297
+ """EXTENSION SEAM — render the whole ``record_<entity>`` module for one trace
298
+ entity. Defaults to :func:`render_trace_helper`. Override to pre/post-process
299
+ the emitted source or replace the render path entirely."""
300
+ return render_trace_helper(entity, root)
301
+
302
+ def generate(self, ctx: GenContext) -> list[EmittedFile]:
303
+ root = ctx.loaded_root
304
+ if root is None:
305
+ return []
306
+ files: list[EmittedFile] = []
307
+ # Stable name order — deterministic emission (matches the other generators).
308
+ entities = sorted(
309
+ (
310
+ c
311
+ for c in root.own_children()
312
+ if isinstance(c, MetaObject) and c.sub_type == OBJECT_SUBTYPE_ENTITY
313
+ ),
314
+ key=lambda c: c.name,
315
+ )
316
+ for entity in entities:
317
+ if self.filter is not None and not self.filter(entity):
318
+ continue
319
+ content = self._render_module(entity, root)
320
+ if content is None:
321
+ continue
322
+ files.append(
323
+ EmittedFile(
324
+ path=f"record_{_snake_case(entity.name)}.py",
325
+ content=ruff_format(content),
326
+ )
327
+ )
328
+ return files
329
+
330
+
331
+ def trace_helper_generator(
332
+ *, filter: Callable[[MetaObject], bool] | None = None
333
+ ) -> Generator:
334
+ """Factory mirroring the TS ``traceHelperFile()`` and the Java
335
+ ``LlmTraceHelperGenerator``. Stable cross-port name ``trace-helper``."""
336
+ return TraceHelperGenerator(filter=filter)
@@ -0,0 +1,15 @@
1
+ """Guard for the abstract concept (mirrors the TS instance-artifacts module).
2
+
3
+ An abstract entity must never produce instance/write artifacts (routers, filter
4
+ allowlists, CREATE TABLE DDL). The Pydantic base *model* is a separate, configurable
5
+ shape concern (emit_abstract_shapes, default on) handled in entity_model.
6
+ """
7
+ from metaobjects.meta.core.object.meta_object import MetaObject
8
+
9
+
10
+ def is_abstract(entity: MetaObject) -> bool:
11
+ return entity.is_abstract is True
12
+
13
+
14
+ def emits_instance_artifacts(entity: MetaObject) -> bool:
15
+ return not is_abstract(entity)
@@ -0,0 +1,79 @@
1
+ """Turns a payload value-object + its ``template.output`` node into a Python source
2
+ literal for an :class:`~metaobjects.render.OutputFormatSpec` — the artifact-1 prompt
3
+ descriptor used by the FR-010 output-prompt codegen.
4
+
5
+ Emits ``OutputFormatSpec(Format.X, "rootName", PromptStyle.X, [PromptField(...), …])``.
6
+ Mirrors the C# / Java ``OutputFormatSpecEmitter`` adapted to Python (keyword args for
7
+ the long ``PromptField`` ctor to keep the emitted source readable + order-robust).
8
+ Bounded scope: scalar / enum. Nested object → ``FieldKind.OBJECT`` placeholder.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from metaobjects.codegen import fr010_field_mapping as fm
13
+ from metaobjects.meta.core.field import field_constants as fc
14
+ from metaobjects.meta.meta_data import MetaData
15
+ from metaobjects.meta.template import template_constants as tc
16
+
17
+
18
+ def _format_enum(template: MetaData) -> str:
19
+ fmt = template.attr(tc.TEMPLATE_ATTR_FORMAT)
20
+ return (
21
+ "Format.XML"
22
+ if isinstance(fmt, str) and fmt.lower() == "xml"
23
+ else "Format.JSON"
24
+ )
25
+
26
+
27
+ def _prompt_style_enum(template: MetaData) -> str:
28
+ style = template.attr(tc.TEMPLATE_ATTR_PROMPT_STYLE)
29
+ if style == tc.PROMPT_STYLE_INLINE:
30
+ return "PromptStyle.INLINE"
31
+ if style == tc.PROMPT_STYLE_EXAMPLE_ONLY:
32
+ return "PromptStyle.EXAMPLE_ONLY"
33
+ return "PromptStyle.GUIDE"
34
+
35
+
36
+ def _opt_string_attr(field: MetaData, attr_name: str) -> str:
37
+ v = field.attr(attr_name)
38
+ return fm.py_string_literal(v) if isinstance(v, str) else "None"
39
+
40
+
41
+ def _prompt_field_literal(field: MetaData) -> str:
42
+ name = field.name
43
+ req = "True" if fm.is_required(field) else "False"
44
+ array = "True" if fm.is_array(field) else "False"
45
+
46
+ if field.sub_type == fc.FIELD_SUBTYPE_OBJECT:
47
+ return (
48
+ f'PromptField("{name}", FieldKind.OBJECT, {req}, array={array})'
49
+ " # FR-010: nested prompt deferred"
50
+ )
51
+
52
+ example = _opt_string_attr(field, fc.FIELD_ATTR_EXAMPLE)
53
+ instruction = _opt_string_attr(field, fc.FIELD_ATTR_INSTRUCTION)
54
+
55
+ if field.sub_type == fc.FIELD_SUBTYPE_ENUM:
56
+ values_lit = fm.string_list_literal(fm.enum_values(field))
57
+ enum_doc_lit = fm.properties_map_literal(field.attr(fc.FIELD_ATTR_ENUM_DOC))
58
+ return (
59
+ f'PromptField("{name}", FieldKind.ENUM, {req}, array={array}, '
60
+ f"enum_values={values_lit}, enum_doc={enum_doc_lit}, "
61
+ f"example={example}, instruction={instruction})"
62
+ )
63
+
64
+ kind = fm.scalar_kind(field.sub_type) or "STRING"
65
+ return (
66
+ f'PromptField("{name}", FieldKind.{kind}, {req}, array={array}, '
67
+ f"example={example}, instruction={instruction})"
68
+ )
69
+
70
+
71
+ def spec_literal(vo: MetaData, template: MetaData, root_name: str) -> str:
72
+ """Emit ``OutputFormatSpec(Format.X, "rootName", PromptStyle.X, [PromptField(...), …])``."""
73
+ format_enum = _format_enum(template)
74
+ style_enum = _prompt_style_enum(template)
75
+ field_lits = [_prompt_field_literal(f) for f in fm.fields(vo)]
76
+ body = ", ".join(field_lits)
77
+ return (
78
+ f'OutputFormatSpec({format_enum}, "{root_name}", {style_enum}, [{body}])'
79
+ )
@@ -0,0 +1,27 @@
1
+ """Per-file write decision based on the @generated marker.
2
+ Mirrors codegen-ts/src/overwrite-policy.ts. Three-way merge is a later enhancement."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from .constants import GENERATED_MARKER
8
+
9
+ # status: "new" | "overwrite" | "refused" | "skipped"
10
+
11
+
12
+ def decide_and_write(path: str, content: str, strategy: str = "overwrite") -> str:
13
+ if not os.path.exists(path):
14
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
15
+ with open(path, "w", encoding="utf-8") as fh:
16
+ fh.write(content)
17
+ return "new"
18
+
19
+ with open(path, encoding="utf-8") as fh:
20
+ current = fh.read()
21
+ if GENERATED_MARKER not in current:
22
+ return "refused"
23
+ if strategy == "skip-existing":
24
+ return "skipped"
25
+ with open(path, "w", encoding="utf-8") as fh:
26
+ fh.write(content)
27
+ return "overwrite"