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.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""LLM-call trace recorder seam + base-row factory (AI LLM-call trace persistence).
|
|
2
|
+
|
|
3
|
+
Python port of the TS ``runtime-ts/src/llm-recorder.ts`` and Java
|
|
4
|
+
``omdb/.../ai/`` recorder. ``build_llm_call_row`` produces the base trace row —
|
|
5
|
+
exactly the 18 ``metaobjects::ai::LlmCallBase`` fields — and the recorder
|
|
6
|
+
persists it through the runtime write path (:meth:`ObjectManager.create`).
|
|
7
|
+
|
|
8
|
+
The recorder does NOT extract a typed VO; the typed ``voRequest``/``voResponse``
|
|
9
|
+
columns are set by the generated per-entity ``record_<entity>`` helper (Slice 2)
|
|
10
|
+
or by the caller on the returned row, matching the TS/Java contract.
|
|
11
|
+
|
|
12
|
+
Cross-port note (Tier-2, driver-driven): the raw ``llmRequest``/``llmResponse``
|
|
13
|
+
columns are ``field.string`` pinned to a jsonb column. pg8000 binds a native
|
|
14
|
+
Python ``dict``/``list`` straight to jsonb (a raw ``str`` would not cast), so this
|
|
15
|
+
port stores the request/response as native JSON values — unlike TS (JSON string)
|
|
16
|
+
and Java (verbatim string). The typed ``voResponse`` contract is identical across
|
|
17
|
+
all ports.
|
|
18
|
+
|
|
19
|
+
The call carries the model response as raw TEXT (``llm_response_text``, mirroring
|
|
20
|
+
TS ``llmResponseText`` / Java ``llmResponseText``) — what the model actually
|
|
21
|
+
returned. For the raw ``llmResponse`` jsonb column we parse that text into a native
|
|
22
|
+
JSON value when it IS valid JSON (clean structured responses store as jsonb), and
|
|
23
|
+
otherwise wrap it as ``{"text": <raw>}`` so the column is always a valid jsonb
|
|
24
|
+
value (prose / non-JSON / truncated responses never break the bind). See
|
|
25
|
+
:func:`_json_or_wrap`.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, Callable, Protocol
|
|
32
|
+
|
|
33
|
+
from .object_manager import ObjectManager
|
|
34
|
+
|
|
35
|
+
#: Call-outcome sentinels (mirror Java LlmCallInput.STATUS_*).
|
|
36
|
+
STATUS_OK = "ok"
|
|
37
|
+
STATUS_ERROR = "error"
|
|
38
|
+
|
|
39
|
+
#: A trace row keyed by metadata field name (the shape ObjectManager.create takes).
|
|
40
|
+
LlmCallRow = dict[str, Any]
|
|
41
|
+
|
|
42
|
+
# Base field names — the LlmCallBase contract (cross-port identical).
|
|
43
|
+
_F_TRACE_ID = "traceId"
|
|
44
|
+
_F_SPAN_ID = "spanId"
|
|
45
|
+
_F_PARENT_SPAN_ID = "parentSpanId"
|
|
46
|
+
_F_SESSION_ID = "sessionId"
|
|
47
|
+
_F_CALL_TYPE = "callType"
|
|
48
|
+
_F_SYSTEM = "system"
|
|
49
|
+
_F_REQUEST_MODEL = "requestModel"
|
|
50
|
+
_F_RESPONSE_MODEL = "responseModel"
|
|
51
|
+
_F_INPUT_TOKENS = "inputTokens"
|
|
52
|
+
_F_OUTPUT_TOKENS = "outputTokens"
|
|
53
|
+
_F_COST_MINOR = "costMinor"
|
|
54
|
+
_F_LATENCY_MS = "latencyMs"
|
|
55
|
+
_F_FINISH_REASON = "finishReason"
|
|
56
|
+
_F_STATUS = "status"
|
|
57
|
+
_F_ERROR_DETAIL = "errorDetail"
|
|
58
|
+
_F_STARTED_AT = "startedAt"
|
|
59
|
+
_F_LLM_REQUEST = "llmRequest"
|
|
60
|
+
_F_LLM_RESPONSE = "llmResponse"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class LlmCallInput:
|
|
65
|
+
"""The call fields driving a base trace row.
|
|
66
|
+
|
|
67
|
+
``llm_request`` is a STRUCTURED request object (dict/list/scalar) stored into
|
|
68
|
+
the raw ``llmRequest`` jsonb column as a native JSON value — see the module
|
|
69
|
+
docstring. ``llm_response_text`` is the raw model response TEXT (mirroring TS
|
|
70
|
+
``llmResponseText`` / Java ``llmResponseText``); the raw ``llmResponse`` jsonb
|
|
71
|
+
column is derived from it by :func:`_json_or_wrap`. ``started_at`` is an
|
|
72
|
+
ISO-8601 string (or a native ``datetime``); the field.timestamp write codec
|
|
73
|
+
coerces it.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
span_id: str
|
|
77
|
+
trace_id: str
|
|
78
|
+
call_type: str
|
|
79
|
+
started_at: Any
|
|
80
|
+
llm_request: Any
|
|
81
|
+
llm_response_text: str
|
|
82
|
+
status: str # STATUS_OK | STATUS_ERROR
|
|
83
|
+
error_detail: str | None
|
|
84
|
+
parent_span_id: str | None = None
|
|
85
|
+
session_id: str | None = None
|
|
86
|
+
system: str | None = None
|
|
87
|
+
request_model: str | None = None
|
|
88
|
+
response_model: str | None = None
|
|
89
|
+
input_tokens: int | None = None
|
|
90
|
+
output_tokens: int | None = None
|
|
91
|
+
cost_minor: int | None = None
|
|
92
|
+
latency_ms: int | None = None
|
|
93
|
+
finish_reason: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LlmCallRecorder(Protocol):
|
|
97
|
+
"""Write-side seam for persisting a trace row. Implementations MUST NOT raise
|
|
98
|
+
on a persistence failure (telemetry never breaks the app)."""
|
|
99
|
+
|
|
100
|
+
def record(self, row: LlmCallRow) -> None: ...
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NullLlmCallRecorder:
|
|
104
|
+
"""No-op recorder (testing / disabled tracing)."""
|
|
105
|
+
|
|
106
|
+
def record(self, row: LlmCallRow) -> None: # noqa: D401 - deliberate no-op
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ObjectManagerLlmCallRecorder:
|
|
111
|
+
"""Persist a trace row via :meth:`ObjectManager.create`. Never raises — a write
|
|
112
|
+
failure routes to ``on_error`` (default: swallow)."""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
om: ObjectManager,
|
|
117
|
+
entity_name: str,
|
|
118
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
self._om = om
|
|
121
|
+
self._entity_name = entity_name
|
|
122
|
+
self._on_error = on_error if on_error is not None else (lambda _e: None)
|
|
123
|
+
|
|
124
|
+
def record(self, row: LlmCallRow) -> None:
|
|
125
|
+
try:
|
|
126
|
+
self._om.create(self._entity_name, row)
|
|
127
|
+
except Exception as err: # noqa: BLE001 - telemetry must never propagate
|
|
128
|
+
self._on_error(err)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _json_or_wrap(text: str | None) -> Any:
|
|
132
|
+
"""Coerce a raw response TEXT into an always-valid jsonb value for the raw
|
|
133
|
+
``llmResponse`` column.
|
|
134
|
+
|
|
135
|
+
pg8000 binds a native ``dict``/``list``/scalar straight to jsonb (a raw ``str``
|
|
136
|
+
would not cast — see the module docstring), so clean JSON responses parse to
|
|
137
|
+
their native value and prose / non-JSON / truncated responses fall back to a
|
|
138
|
+
``{"text": <raw>}`` wrapper that is always valid jsonb. ``None`` text → ``None``.
|
|
139
|
+
"""
|
|
140
|
+
if text is None:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
return json.loads(text)
|
|
144
|
+
except (json.JSONDecodeError, TypeError):
|
|
145
|
+
return {"text": text}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_llm_call_row(inp: LlmCallInput) -> LlmCallRow:
|
|
149
|
+
"""Build the base trace row — key set is exactly LlmCallBase's 18 fields.
|
|
150
|
+
|
|
151
|
+
The typed ``voRequest``/``voResponse`` columns are NOT set here; the caller
|
|
152
|
+
(or the generated helper) adds them to the returned row.
|
|
153
|
+
"""
|
|
154
|
+
return {
|
|
155
|
+
_F_TRACE_ID: inp.trace_id,
|
|
156
|
+
_F_SPAN_ID: inp.span_id,
|
|
157
|
+
_F_PARENT_SPAN_ID: inp.parent_span_id,
|
|
158
|
+
_F_SESSION_ID: inp.session_id,
|
|
159
|
+
_F_CALL_TYPE: inp.call_type,
|
|
160
|
+
_F_SYSTEM: inp.system,
|
|
161
|
+
_F_REQUEST_MODEL: inp.request_model,
|
|
162
|
+
_F_RESPONSE_MODEL: inp.response_model,
|
|
163
|
+
_F_INPUT_TOKENS: inp.input_tokens,
|
|
164
|
+
_F_OUTPUT_TOKENS: inp.output_tokens,
|
|
165
|
+
_F_COST_MINOR: inp.cost_minor,
|
|
166
|
+
_F_LATENCY_MS: inp.latency_ms,
|
|
167
|
+
_F_FINISH_REASON: inp.finish_reason,
|
|
168
|
+
_F_STATUS: inp.status,
|
|
169
|
+
_F_ERROR_DETAIL: inp.error_detail,
|
|
170
|
+
_F_STARTED_AT: inp.started_at,
|
|
171
|
+
# Raw request → the structured request object bound straight to jsonb.
|
|
172
|
+
_F_LLM_REQUEST: inp.llm_request,
|
|
173
|
+
# Raw response → the response TEXT parsed to native JSON when it parses,
|
|
174
|
+
# else wrapped as {"text": ...} so the jsonb bind is always valid.
|
|
175
|
+
_F_LLM_RESPONSE: _json_or_wrap(inp.llm_response_text),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def truncate_row(row: LlmCallRow, max_chars: int) -> LlmCallRow:
|
|
180
|
+
"""Cap the raw ``llmRequest``/``llmResponse`` columns when they are strings.
|
|
181
|
+
|
|
182
|
+
Adopters compose this into a ``redact`` to bound trace-row size. Non-string
|
|
183
|
+
(native JSON) values pass through unchanged; all other fields are untouched.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def cap(v: Any) -> Any:
|
|
187
|
+
return v[:max_chars] if isinstance(v, str) and len(v) > max_chars else v
|
|
188
|
+
|
|
189
|
+
return {**row, _F_LLM_REQUEST: cap(row.get(_F_LLM_REQUEST)), _F_LLM_RESPONSE: cap(row.get(_F_LLM_RESPONSE))}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def persist_llm_call_row(
|
|
193
|
+
recorder: LlmCallRecorder,
|
|
194
|
+
row: LlmCallRow,
|
|
195
|
+
redact: Callable[[LlmCallRow], LlmCallRow] | None = None,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Shared persist step: redact then record. Used by record_llm_call AND the
|
|
198
|
+
generated typed helper, so redaction applies on both paths."""
|
|
199
|
+
recorder.record(redact(row) if redact is not None else row)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def record_llm_call(
|
|
203
|
+
inp: LlmCallInput,
|
|
204
|
+
recorder: LlmCallRecorder,
|
|
205
|
+
redact: Callable[[LlmCallRow], LlmCallRow] | None = None,
|
|
206
|
+
) -> tuple[str, str | None]:
|
|
207
|
+
"""Persist one base trace row (envelope + raw I/O). Generic — does not extract.
|
|
208
|
+
Returns ``(status, error_detail)``."""
|
|
209
|
+
persist_llm_call_row(recorder, build_llm_call_row(inp), redact)
|
|
210
|
+
return inp.status, inp.error_detail
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Generic, metadata-driven M:N (many-to-many) query resolver.
|
|
2
|
+
|
|
3
|
+
A M:N relationship declares only the slim FR-018 vocabulary on the source
|
|
4
|
+
entity: ``@cardinality: "many"`` + ``@objectRef: <target>`` + ``@through:
|
|
5
|
+
<junction>`` (plus optional ``@sourceRefField`` / ``@symmetric`` for self-joins).
|
|
6
|
+
It does NOT restate the junction FK columns — those are DERIVED from the junction
|
|
7
|
+
entity's two ``identity.reference`` children via the shared ``derive_m2m_fields``
|
|
8
|
+
helper (the SSOT for FK direction, the same one the loader validator + every
|
|
9
|
+
other port use).
|
|
10
|
+
|
|
11
|
+
Resolution has three modes (mirrors the TS reference ``n2m-resolver.ts``):
|
|
12
|
+
|
|
13
|
+
1. Hetero (source != target): junction WHERE sourceField (=|IN) source.pk,
|
|
14
|
+
collect targetField, then target WHERE pk IN (...).
|
|
15
|
+
2. Directed self-join (``@sourceRefField``): identical traversal; the helper
|
|
16
|
+
has already picked which junction FK is the source side.
|
|
17
|
+
3. Symmetric self-join (``@symmetric: true``): single-row storage, union on
|
|
18
|
+
read — junction WHERE sourceField (=|IN) id OR targetField (=|IN) id; for
|
|
19
|
+
each row the related id is whichever FK column is NOT the source id (a
|
|
20
|
+
self-loop row where both columns are the source yields the source itself).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from ..meta.core.object.meta_object import MetaObject
|
|
28
|
+
from ..meta.core.relationship.derive_m2m_fields import (
|
|
29
|
+
M2MDerivationError,
|
|
30
|
+
derive_m2m_fields,
|
|
31
|
+
)
|
|
32
|
+
from ..meta.core.relationship.meta_relationship import MetaRelationship
|
|
33
|
+
from ..meta.core.relationship.relationship_constants import CARDINALITY_MANY
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
36
|
+
from ..meta.meta_root import MetaRoot
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class N2mDescriptor:
|
|
41
|
+
"""The resolved coordinates of a M:N traversal.
|
|
42
|
+
|
|
43
|
+
``source_field`` / ``target_field`` are the *metadata field names* on the
|
|
44
|
+
junction (the runtime maps them to physical columns); ``symmetric`` selects
|
|
45
|
+
the union-on-read read path.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
source_entity_name: str
|
|
49
|
+
target_entity_name: str
|
|
50
|
+
junction_entity_name: str
|
|
51
|
+
source_field: str
|
|
52
|
+
target_field: str
|
|
53
|
+
symmetric: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class N2mResolutionError(Exception):
|
|
57
|
+
"""Raised when a named M:N relationship cannot be resolved."""
|
|
58
|
+
|
|
59
|
+
code = "ERR_INVALID_RELATIONSHIP"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_n2m_descriptor(
|
|
63
|
+
source_entity: MetaObject,
|
|
64
|
+
relation_name: str,
|
|
65
|
+
object_index: dict[str, MetaObject],
|
|
66
|
+
) -> N2mDescriptor | None:
|
|
67
|
+
"""Resolve a M:N relationship on *source_entity* by name.
|
|
68
|
+
|
|
69
|
+
Returns ``None`` when the named relationship is not M:N (no ``@through``) —
|
|
70
|
+
the caller should fall through to its 1:1 / 1:N relation path. Raises
|
|
71
|
+
:class:`N2mResolutionError` when the relationship IS M:N but malformed.
|
|
72
|
+
|
|
73
|
+
*object_index* is a bare-name → object map of the loaded model's top-level
|
|
74
|
+
objects (mirrors the TS ``root.findObject`` resolution surface).
|
|
75
|
+
"""
|
|
76
|
+
for child in source_entity.own_children():
|
|
77
|
+
if not isinstance(child, MetaRelationship):
|
|
78
|
+
continue
|
|
79
|
+
if child.name != relation_name:
|
|
80
|
+
continue
|
|
81
|
+
if child.cardinality() != CARDINALITY_MANY:
|
|
82
|
+
continue
|
|
83
|
+
if child.through() is None:
|
|
84
|
+
# Cardinality-many but no junction → 1:N, not M:N.
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
target_name = child.object_ref()
|
|
88
|
+
junction_name = child.through()
|
|
89
|
+
if not target_name or not junction_name:
|
|
90
|
+
raise N2mResolutionError(
|
|
91
|
+
f"M:N relationship '{relation_name}' on '{source_entity.name}' "
|
|
92
|
+
f"requires @objectRef + @through"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
fields = derive_m2m_fields(child, source_entity, object_index)
|
|
97
|
+
except M2MDerivationError as e:
|
|
98
|
+
raise N2mResolutionError(
|
|
99
|
+
f"M:N relationship '{relation_name}' on '{source_entity.name}': {e}"
|
|
100
|
+
) from e
|
|
101
|
+
|
|
102
|
+
return N2mDescriptor(
|
|
103
|
+
source_entity_name=source_entity.name,
|
|
104
|
+
target_entity_name=_strip_package(target_name),
|
|
105
|
+
junction_entity_name=_strip_package(junction_name),
|
|
106
|
+
source_field=fields.source_field,
|
|
107
|
+
target_field=fields.target_field,
|
|
108
|
+
symmetric=child.symmetric(),
|
|
109
|
+
)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def collect_symmetric_target_ids(
|
|
114
|
+
join_rows: list[dict[str, Any]],
|
|
115
|
+
source_col: str,
|
|
116
|
+
target_col: str,
|
|
117
|
+
source_ids: set[Any],
|
|
118
|
+
) -> list[Any]:
|
|
119
|
+
"""Symmetric union-on-read: for each junction row, the related id is whichever
|
|
120
|
+
of (source_col, target_col) is NOT a source id. A self-loop row (both columns
|
|
121
|
+
the source id) yields the source id itself.
|
|
122
|
+
|
|
123
|
+
Membership is compared by string-coerced key: the source ids come from the
|
|
124
|
+
in-process source record while the junction FK values come straight off the
|
|
125
|
+
driver, where a BIGINT key may arrive as a differing native type. Comparing
|
|
126
|
+
by ``str()`` bridges any number-vs-string mismatch — exactly the TS reference
|
|
127
|
+
behaviour.
|
|
128
|
+
"""
|
|
129
|
+
source_keys = {str(v) for v in source_ids}
|
|
130
|
+
seen: dict[str, Any] = {}
|
|
131
|
+
for row in join_rows:
|
|
132
|
+
a = row.get(source_col)
|
|
133
|
+
b = row.get(target_col)
|
|
134
|
+
a_is_source = a is not None and str(a) in source_keys
|
|
135
|
+
other = b if a_is_source else a
|
|
136
|
+
if other is None:
|
|
137
|
+
continue
|
|
138
|
+
seen.setdefault(str(other), other)
|
|
139
|
+
return list(seen.values())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_column_ids(join_rows: list[dict[str, Any]], col: str) -> list[Any]:
|
|
143
|
+
"""Distinct non-null values of one junction column (declaration order)."""
|
|
144
|
+
seen: dict[str, Any] = {}
|
|
145
|
+
for row in join_rows:
|
|
146
|
+
v = row.get(col)
|
|
147
|
+
if v is None:
|
|
148
|
+
continue
|
|
149
|
+
seen.setdefault(str(v), v)
|
|
150
|
+
return list(seen.values())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _strip_package(name: str) -> str:
|
|
154
|
+
idx = name.rfind("::")
|
|
155
|
+
return name[idx + 2:] if idx >= 0 else name
|