kompas-kernel 0.0.1__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.
- kompas_kernel/__init__.py +3 -0
- kompas_kernel/features/__init__.py +9 -0
- kompas_kernel/features/assembly_navigator/__init__.py +8 -0
- kompas_kernel/features/assembly_navigator/commands.py +37 -0
- kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
- kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
- kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
- kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
- kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
- kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
- kompas_kernel/features/assembly_navigator/models.py +218 -0
- kompas_kernel/features/assembly_navigator/queries.py +42 -0
- kompas_kernel/features/assembly_navigator/service.py +31 -0
- kompas_kernel/features/assembly_navigator/summary.py +90 -0
- kompas_kernel/features/document_session/__init__.py +5 -0
- kompas_kernel/features/document_session/checkpointed.py +165 -0
- kompas_kernel/features/document_session/commands.py +94 -0
- kompas_kernel/features/document_session/digest.py +126 -0
- kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
- kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
- kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
- kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
- kompas_kernel/features/document_session/models.py +311 -0
- kompas_kernel/features/document_session/queries.py +62 -0
- kompas_kernel/features/document_session/registry.py +101 -0
- kompas_kernel/features/document_session/service.py +128 -0
- kompas_kernel/features/geometry_3d/__init__.py +9 -0
- kompas_kernel/features/geometry_3d/axis_math.py +50 -0
- kompas_kernel/features/geometry_3d/commands.py +68 -0
- kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
- kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
- kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
- kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
- kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
- kompas_kernel/features/geometry_3d/models.py +216 -0
- kompas_kernel/features/geometry_3d/queries.py +96 -0
- kompas_kernel/features/geometry_3d/service.py +69 -0
- kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
- kompas_kernel/features/ksapi_automation/__init__.py +9 -0
- kompas_kernel/features/ksapi_automation/gate.py +282 -0
- kompas_kernel/features/ksapi_automation/models.py +75 -0
- kompas_kernel/features/ksapi_automation/namespace.py +12 -0
- kompas_kernel/features/ksapi_automation/run_python.py +133 -0
- kompas_kernel/features/ksapi_automation/runner.py +126 -0
- kompas_kernel/features/ksapi_automation/service.py +85 -0
- kompas_kernel/features/part_authoring/__init__.py +5 -0
- kompas_kernel/features/part_authoring/build_geometry.py +122 -0
- kompas_kernel/features/part_authoring/models.py +99 -0
- kompas_kernel/features/wrench_clearance/__init__.py +7 -0
- kompas_kernel/features/wrench_clearance/models.py +87 -0
- kompas_kernel/features/wrench_clearance/service.py +154 -0
- kompas_kernel/kompas/__init__.py +14 -0
- kompas_kernel/kompas/errors.py +11 -0
- kompas_kernel/kompas/models.py +68 -0
- kompas_kernel/kompas/objects.py +93 -0
- kompas_kernel/kompas/platform.py +40 -0
- kompas_kernel/kompas/runtime.py +186 -0
- kompas_kernel/kompas/session.py +195 -0
- kompas_kernel/kompas/units.py +48 -0
- kompas_kernel/ksapi_static/__init__.py +8 -0
- kompas_kernel/ksapi_static/facts.py +132 -0
- kompas_kernel/ksapi_static/inventory.py +185 -0
- kompas_kernel/ksapi_static/recipes.py +114 -0
- kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
- kompas_kernel/observability/__init__.py +1 -0
- kompas_kernel/observability/logger.py +24 -0
- kompas_kernel/observability/setup.py +115 -0
- kompas_kernel/observability/taxonomy.py +27 -0
- kompas_kernel/py.typed +1 -0
- kompas_kernel/standards/__init__.py +1 -0
- kompas_kernel/standards/wrench_clearance.py +234 -0
- kompas_kernel/utils/__init__.py +1 -0
- kompas_kernel/utils/filesystem.py +82 -0
- kompas_kernel/utils/validation.py +21 -0
- kompas_kernel-0.0.1.dist-info/METADATA +56 -0
- kompas_kernel-0.0.1.dist-info/RECORD +77 -0
- kompas_kernel-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Single component resolver: identifier <-> `IPart`, indexed by `GetPartsArray(ksAllParts)`.
|
|
2
|
+
|
|
3
|
+
Both `reader.py` (numbering components while reading) and `presentation.py` (resolving a
|
|
4
|
+
requested identifier back to an `IPart`) need the exact same `GetPartsArray(ksAllParts)`
|
|
5
|
+
listing and the exact same `str(index)` identifier convention — this module is the one
|
|
6
|
+
place that convention lives, replacing the previous split (`enumerate` in the old
|
|
7
|
+
`api/assembly_reader.py` reader vs `_parse_index` in the old adapter's
|
|
8
|
+
`_select_components_and_zoom`). Numbering and semantics are unchanged by this move.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from kompas_kernel.kompas.objects import constants3d_class, parse_index
|
|
16
|
+
from kompas_kernel.kompas.runtime import KsApiModules
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def list_components(modules: KsApiModules, top: Any) -> list[Any]:
|
|
20
|
+
"""Top-level components of `top`, in stable `GetPartsArray(ksAllParts)` order."""
|
|
21
|
+
constants3d = constants3d_class(modules)
|
|
22
|
+
return list(top.GetPartsArray(int(constants3d.ksAllParts)))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def component_identifier(index: int) -> str:
|
|
26
|
+
"""The `str(index)` identifier convention `read_assembly`/`show_component` share."""
|
|
27
|
+
return str(index)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_index(components: list[Any], identifier: str) -> int | None:
|
|
31
|
+
"""identifier -> index into `components`, or `None` if non-numeric/out-of-range."""
|
|
32
|
+
index = parse_index(identifier)
|
|
33
|
+
if index is None or not (0 <= index < len(components)):
|
|
34
|
+
return None
|
|
35
|
+
return index
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""DTO инспекции сборки — публичный контракт `queries.read_assembly`/`commands.show_components`.
|
|
2
|
+
|
|
3
|
+
Компоненты верхнего уровня активной КОМПАС-Сборки: вид/состояние/вставка/габарит
|
|
4
|
+
(`AssemblyView`/`AssemblyComponent`/...), плюс результат показа (`ShowComponentsResult`/
|
|
5
|
+
`ComponentShowResult`/`ComponentIdentifiers`). Перенесено из `bridge/models/assembly.py` +
|
|
6
|
+
assembly-часть `bridge/models/presentation.py` (E16.04) — `ShowDistanceResult` (геометрия)
|
|
7
|
+
остаётся в `bridge/models/presentation.py`, уедет в 04a. Framework-free: pydantic-схемы
|
|
8
|
+
на `utils.validation.CustomModel`, без Pydantic AI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Annotated, Self
|
|
15
|
+
|
|
16
|
+
from pydantic import Field, model_validator
|
|
17
|
+
|
|
18
|
+
from kompas_kernel.kompas.models import BBox, Point3D
|
|
19
|
+
from kompas_kernel.utils.validation import CustomModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ComponentKind(StrEnum):
|
|
23
|
+
"""Вид компонента в сборке КОМПАС: деталь или подсборка.
|
|
24
|
+
|
|
25
|
+
Проецируется из `IPart.IsDetail()` reader'ом: True → PART, False → SUBASSEMBLY.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
PART = "part"
|
|
29
|
+
SUBASSEMBLY = "subassembly"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ComponentLoadState(StrEnum):
|
|
33
|
+
"""Нейтральная проекция `ksLoadStateEnum` для DTO.
|
|
34
|
+
|
|
35
|
+
Маппинг сырого целочисленного enum → это поле — забота `ksapi/mapping.py`.
|
|
36
|
+
Значения подтверждены спайком-00 на живой сборке `Heat gun.a3d`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
FULL = "full"
|
|
40
|
+
UNLOADED = "unloaded"
|
|
41
|
+
TRIANGLES = "triangles"
|
|
42
|
+
PARTIAL = "partial"
|
|
43
|
+
GABARIT = "gabarit"
|
|
44
|
+
UNKNOWN = "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StandardReferenceSource(StrEnum):
|
|
48
|
+
"""Как найден опорный объект стандартного компонента."""
|
|
49
|
+
|
|
50
|
+
NAMED = "named"
|
|
51
|
+
DEFAULT = "default"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ComponentPlacement(CustomModel):
|
|
55
|
+
"""Сериализуемая система координат вставки компонента из `GetPlacement()`.
|
|
56
|
+
|
|
57
|
+
`origin_x/y/z` — начало СК в мм (из `IPlacement3D.GetOrigin()`).
|
|
58
|
+
|
|
59
|
+
Матрицы ориентации здесь нет намеренно: `GetMatrix3D()` отдаёт 16 float (4×4), их
|
|
60
|
+
никто в коде не читал, а модели они бесполезны — перемножать матрицу она не станет,
|
|
61
|
+
а ~450 байт на компонент платила (67.6 КиБ на сборку из 58 компонентов, прогон
|
|
62
|
+
2026-08-05). Ориентация стандартного компонента доступна точнее и дешевле —
|
|
63
|
+
`StandardComponentInfo.axis` (`GetVector(o3d_axisOZ)`), она и используется.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
origin_x: float
|
|
67
|
+
origin_y: float
|
|
68
|
+
origin_z: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class StandardReferenceObject(CustomModel):
|
|
72
|
+
"""Сериализуемая ссылка на опорный объект внутри стандартного компонента.
|
|
73
|
+
|
|
74
|
+
Raw `IModelObject` наружу не отдаём: только проверяемые факты, которые можно
|
|
75
|
+
логировать и передать модели.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str = Field(min_length=1)
|
|
79
|
+
model_object_type: int = Field(ge=0)
|
|
80
|
+
source: StandardReferenceSource
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class StandardComponentAxis(CustomModel):
|
|
84
|
+
"""Ось стандартного компонента в глобальной СК документа, мм."""
|
|
85
|
+
|
|
86
|
+
origin: Point3D
|
|
87
|
+
direction: Point3D
|
|
88
|
+
reference: StandardReferenceObject | None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class StandardComponentPlane(CustomModel):
|
|
92
|
+
"""Опорная плоскость стандартного компонента в глобальной СК документа, мм."""
|
|
93
|
+
|
|
94
|
+
origin: Point3D
|
|
95
|
+
normal: Point3D
|
|
96
|
+
reference: StandardReferenceObject | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class StandardComponentInfo(CustomModel):
|
|
100
|
+
"""Дополнительные факты `IPart.IsStandard()==True`.
|
|
101
|
+
|
|
102
|
+
`axis` — локальная OZ-ось вставки (`GetPlacement().GetVector(o3d_axisOZ)`).
|
|
103
|
+
`plane` — локальная XOY-плоскость вставки; её нормаль совпадает с OZ.
|
|
104
|
+
`reference` внутри axis/plane указывает, нашёлся ли именованный объект
|
|
105
|
+
("Axis"/"Plane") или взят системный default.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
file_name: str | None
|
|
109
|
+
axis: StandardComponentAxis | None
|
|
110
|
+
plane: StandardComponentPlane | None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AssemblyComponent(CustomModel):
|
|
114
|
+
"""Один компонент верхнего уровня КОМПАС-Сборки (вставка, не уникальный).
|
|
115
|
+
|
|
116
|
+
`identifier` — индекс компонента в `GetPartsArray(ksAllParts)` строкой;
|
|
117
|
+
стабилен в рамках сессии; используется как ключ для `show_component`.
|
|
118
|
+
`marking` — обозначение (`GetMarking()`); может быть пустой строкой.
|
|
119
|
+
`name` — наименование (`GetName()`); `None` если пустое.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
identifier: str
|
|
123
|
+
marking: str
|
|
124
|
+
name: str | None
|
|
125
|
+
kind: ComponentKind
|
|
126
|
+
is_standard: bool
|
|
127
|
+
placement: ComponentPlacement
|
|
128
|
+
bbox: BBox | None
|
|
129
|
+
standard: StandardComponentInfo | None = None
|
|
130
|
+
load_state: ComponentLoadState
|
|
131
|
+
visible: bool
|
|
132
|
+
valid: bool
|
|
133
|
+
excluded: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AssemblyView(CustomModel):
|
|
137
|
+
"""Снимок компонентов верхнего уровня активной КОМПАС-Сборки.
|
|
138
|
+
|
|
139
|
+
`component_count` дублирует `len(components)` явным полем для удобства LLM:
|
|
140
|
+
модель видит число прямо в JSON, не считая элементы списка.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
is_assembly: bool
|
|
144
|
+
top_marking: str
|
|
145
|
+
components: list[AssemblyComponent]
|
|
146
|
+
component_count: int
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def _check_component_count(self) -> Self:
|
|
150
|
+
if self.component_count != len(self.components):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"component_count ({self.component_count}) must equal len(components) ({len(self.components)})"
|
|
153
|
+
)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ComponentSummary(CustomModel):
|
|
158
|
+
"""Компонент в том виде, в каком его ВИДИТ МОДЕЛЬ (проекция `AssemblyComponent`).
|
|
159
|
+
|
|
160
|
+
Порт продолжает отдавать полный `AssemblyComponent` — коду нужны и габарит, и оси
|
|
161
|
+
стандартного изделия. Модели уходит только то, из чего она реально делает выводы:
|
|
162
|
+
для крепежа весь предмет разговора лежит в `name` («Болт 3М14x65 ГОСТ 15589-70»),
|
|
163
|
+
остальное было платой за контекст (см. `summary.py`).
|
|
164
|
+
|
|
165
|
+
`origin_mm` — [x, y, z] вставки, округлённые до микрометров: полная двоичная точность
|
|
166
|
+
(`-1.7147394615335543e-13`) — это шум, модель всё равно читает его как «примерно 0».
|
|
167
|
+
`status` — ТОЛЬКО аномалии (`excluded`/`invalid`/`not_loaded`); у здорового компонента
|
|
168
|
+
`None`, поэтому норма не стоит ни байта, а исключённый из сборки крепёж модель не
|
|
169
|
+
посчитает установленным.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
identifier: str
|
|
173
|
+
marking: str
|
|
174
|
+
name: str | None
|
|
175
|
+
kind: ComponentKind
|
|
176
|
+
is_standard: bool
|
|
177
|
+
origin_mm: tuple[float, float, float]
|
|
178
|
+
status: list[str] | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AssemblySummaryView(CustomModel):
|
|
182
|
+
"""Состав сборки для модели: `AssemblyView` без полей, которыми она не пользуется."""
|
|
183
|
+
|
|
184
|
+
is_assembly: bool
|
|
185
|
+
top_marking: str
|
|
186
|
+
components: list[ComponentSummary]
|
|
187
|
+
component_count: int
|
|
188
|
+
|
|
189
|
+
@model_validator(mode="after")
|
|
190
|
+
def _check_component_count(self) -> Self:
|
|
191
|
+
if self.component_count != len(self.components):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"component_count ({self.component_count}) must equal len(components) ({len(self.components)})"
|
|
194
|
+
)
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class ComponentShowResult(CustomModel):
|
|
199
|
+
"""Состояние одного запрошенного компонента в пакетном показе."""
|
|
200
|
+
|
|
201
|
+
identifier: str
|
|
202
|
+
found: bool
|
|
203
|
+
selected: bool
|
|
204
|
+
marking: str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ShowComponentsResult(CustomModel):
|
|
208
|
+
"""Результат атомарного показа нескольких компонентов.
|
|
209
|
+
|
|
210
|
+
`components` сохраняет порядок входных идентификаторов. Найденные компоненты
|
|
211
|
+
выделяются одним вызовом KsAPI, затем выполняется одна команда zoom selected.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
components: tuple[ComponentShowResult, ...] = Field(min_length=1)
|
|
215
|
+
zoomed: bool
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
type ComponentIdentifiers = Annotated[list[str], Field(min_length=1)]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Framework-free public read API for the assembly navigator, over `KompasSession`.
|
|
2
|
+
|
|
3
|
+
No Pydantic AI, no `kompas_copilot.bridge` import — the feature owns its own KsAPI reader
|
|
4
|
+
(`ksapi/reader.py`) and pure mapper (`ksapi/mapping.py`); this module only wires them
|
|
5
|
+
through one session callback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from kompas_kernel.features.assembly_navigator.ksapi.mapping import build_assembly_view
|
|
11
|
+
from kompas_kernel.features.assembly_navigator.ksapi.raw import RawAssembly
|
|
12
|
+
from kompas_kernel.features.assembly_navigator.ksapi.reader import read_raw_assembly
|
|
13
|
+
from kompas_kernel.features.assembly_navigator.models import AssemblyView
|
|
14
|
+
from kompas_kernel.kompas.session import KompasContext, KompasSession
|
|
15
|
+
from kompas_kernel.observability.logger import get_logger
|
|
16
|
+
from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def read_assembly(session: KompasSession) -> AssemblyView:
|
|
22
|
+
"""Прочитать компоненты верхнего уровня активной КОМПАС-Сборки (И1).
|
|
23
|
+
|
|
24
|
+
Не-сборка (ksDocumentPart, тип=4) — валидный ответ с `is_assembly=False`
|
|
25
|
+
и обычно пустым списком компонентов; не ошибка (ограничение снято).
|
|
26
|
+
Нет активного 3D-документа → `KompasError`.
|
|
27
|
+
"""
|
|
28
|
+
raw = await session.run(
|
|
29
|
+
operation_name="assembly_navigator.read",
|
|
30
|
+
operation=_read_raw_assembly,
|
|
31
|
+
)
|
|
32
|
+
return build_assembly_view(raw)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_raw_assembly(context: KompasContext) -> RawAssembly:
|
|
36
|
+
raw = read_raw_assembly(context.modules, context.app)
|
|
37
|
+
logger.info(
|
|
38
|
+
event_name(LogDomain.BRIDGE, "ksapi_read_assembly", LogEventType.COMPLETED),
|
|
39
|
+
is_assembly=raw.is_assembly,
|
|
40
|
+
component_count=len(raw.components),
|
|
41
|
+
)
|
|
42
|
+
return raw
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""`AssemblyNavigatorService` — thin holder of one `KompasSession`.
|
|
2
|
+
|
|
3
|
+
Exists solely because composition and its typed fake need one object implementing the
|
|
4
|
+
narrow consumer-owned Protocol declared in `core/capabilities/assembly_navigator.py`
|
|
5
|
+
(принятое решение владельца 1) — no logic lives here, no multi-step use case. The
|
|
6
|
+
session is a required dependency: this class neither creates nor closes it — the
|
|
7
|
+
transport that built the session owns that lifecycle.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from kompas_kernel.features.assembly_navigator import commands, queries
|
|
13
|
+
from kompas_kernel.features.assembly_navigator.models import (
|
|
14
|
+
AssemblyView,
|
|
15
|
+
ComponentIdentifiers,
|
|
16
|
+
ShowComponentsResult,
|
|
17
|
+
)
|
|
18
|
+
from kompas_kernel.kompas.session import KompasSession
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AssemblyNavigatorService:
|
|
22
|
+
"""Delegates `read_assembly`/`show_components` to `queries`/`commands` over one session."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, session: KompasSession) -> None:
|
|
25
|
+
self._session = session
|
|
26
|
+
|
|
27
|
+
async def read_assembly(self) -> AssemblyView:
|
|
28
|
+
return await queries.read_assembly(self._session)
|
|
29
|
+
|
|
30
|
+
async def show_components(self, identifiers: ComponentIdentifiers) -> ShowComponentsResult:
|
|
31
|
+
return await commands.show_components(self._session, identifiers)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Проекция полного `AssemblyView` в то, что видит модель.
|
|
2
|
+
|
|
3
|
+
Зачем слой: живой прогон 2026-08-05 («проверь ка мне крепежи», сборка из 58 компонентов)
|
|
4
|
+
стоил 67.6 КиБ состава — из них ~85% занимали матрица 4×4, габарит и координаты в полной
|
|
5
|
+
двоичной точности. Спул убрал этот объём из ответа тула, но модель вернула его в контекст
|
|
6
|
+
чтением спул-файла. Настоящая причина не в чтении: модель ПОЛУЧАЛА данные, которыми не
|
|
7
|
+
может пользоваться. Компактный состав той же сборки — 6.9 КиБ, он влезает в один ответ
|
|
8
|
+
целиком, и спул на нём не срабатывает вовсе.
|
|
9
|
+
|
|
10
|
+
Что осталось и почему: для сценария «проверь крепёж» весь предмет разговора лежит в
|
|
11
|
+
`name` («Болт 3М14x65 ГОСТ 15589-70» — вид, диаметр, длина, ГОСТ), а `identifier` нужен
|
|
12
|
+
как ключ для `show_component`. Координаты вставки дают группировку по узлам («эти четыре
|
|
13
|
+
болта в одном фланце»), но округлённые до микрометров.
|
|
14
|
+
|
|
15
|
+
Что срезано: матрица 4×4 (модель её не перемножает), габарит (bbox — размеры крепежа и так
|
|
16
|
+
в обозначении), `StandardComponentInfo` (путь к библиотечному `.m3d` и оси, дублирующие
|
|
17
|
+
`placement`), флаги в НОРМАЛЬНОМ состоянии. Аномалии не срезаны — см. `_status`.
|
|
18
|
+
|
|
19
|
+
Полный `AssemblyView` никуда не делся: порт отдаёт его по-прежнему, коду доступны и
|
|
20
|
+
габарит, и оси стандартных изделий (AGENTS.md / ADR 0005 — model-facing поверхность как
|
|
21
|
+
view-слой над DTO, а не вместо них).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from kompas_kernel.features.assembly_navigator.models import (
|
|
27
|
+
AssemblyComponent,
|
|
28
|
+
AssemblySummaryView,
|
|
29
|
+
AssemblyView,
|
|
30
|
+
ComponentLoadState,
|
|
31
|
+
ComponentSummary,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_ORIGIN_DECIMALS = 3
|
|
35
|
+
"""Микрометры: тоньше реального смысла для сборки и без хвоста двоичного шума."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _round_mm(value: float) -> float:
|
|
39
|
+
# `+ 0.0` схлопывает -0.0 в 0.0: «-0.0» в выдаче читается как значащий знак, а это
|
|
40
|
+
# тот же ноль (координаты приходят как -1.7e-13, то есть ноль с точностью до шума).
|
|
41
|
+
return round(value, _ORIGIN_DECIMALS) + 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _status(component: AssemblyComponent) -> list[str] | None:
|
|
45
|
+
"""Только отклонения от нормы; у здорового компонента — `None`.
|
|
46
|
+
|
|
47
|
+
Норма молчит намеренно: `visible`/`valid`/`excluded`/`load_state` в штатных значениях
|
|
48
|
+
— это ~80 байт на компонент, которые ничего не сообщают. Отклонение же обязано дойти
|
|
49
|
+
до модели: крепёж, исключённый из сборки, не установлен, и вывод «болт на месте» по
|
|
50
|
+
такому компоненту был бы ложным.
|
|
51
|
+
"""
|
|
52
|
+
status: list[str] = []
|
|
53
|
+
if component.excluded:
|
|
54
|
+
status.append("excluded")
|
|
55
|
+
if not component.valid:
|
|
56
|
+
status.append("invalid")
|
|
57
|
+
if component.load_state is not ComponentLoadState.FULL:
|
|
58
|
+
status.append(f"load_state={component.load_state.value}")
|
|
59
|
+
return status or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def summarize_component(component: AssemblyComponent) -> ComponentSummary:
|
|
63
|
+
"""Спроецировать один компонент в model-facing вид."""
|
|
64
|
+
placement = component.placement
|
|
65
|
+
return ComponentSummary(
|
|
66
|
+
identifier=component.identifier,
|
|
67
|
+
marking=component.marking,
|
|
68
|
+
name=component.name,
|
|
69
|
+
kind=component.kind,
|
|
70
|
+
is_standard=component.is_standard,
|
|
71
|
+
origin_mm=(
|
|
72
|
+
_round_mm(placement.origin_x),
|
|
73
|
+
_round_mm(placement.origin_y),
|
|
74
|
+
_round_mm(placement.origin_z),
|
|
75
|
+
),
|
|
76
|
+
status=_status(component),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def summarize_assembly(view: AssemblyView) -> AssemblySummaryView:
|
|
81
|
+
"""Спроецировать снимок сборки в то, что уходит модели.
|
|
82
|
+
|
|
83
|
+
Чистая функция: ни КОМПАС, ни сессия не нужны — проверяется юнит-тестом целиком.
|
|
84
|
+
"""
|
|
85
|
+
return AssemblySummaryView(
|
|
86
|
+
is_assembly=view.is_assembly,
|
|
87
|
+
top_marking=view.top_marking,
|
|
88
|
+
components=[summarize_component(component) for component in view.components],
|
|
89
|
+
component_count=view.component_count,
|
|
90
|
+
)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Document session capability: CAD context, geometry digest, screenshot, checkpoint/restore.
|
|
2
|
+
|
|
3
|
+
Framework-free vertical slice over one `KompasSession` (E16.04b). Owns its own KsAPI
|
|
4
|
+
context/screenshot/snapshot reading (`ksapi/`); no `kompas_copilot.bridge` import.
|
|
5
|
+
"""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""One checkpoint decorator for every model-facing Python execution facade."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Protocol, assert_never
|
|
10
|
+
|
|
11
|
+
from kompas_kernel.features.document_session.models import (
|
|
12
|
+
Checkpoint,
|
|
13
|
+
GuardReport,
|
|
14
|
+
GuardStatus,
|
|
15
|
+
RestoreResult,
|
|
16
|
+
)
|
|
17
|
+
from kompas_kernel.features.document_session.registry import CheckpointError
|
|
18
|
+
from kompas_kernel.kompas.errors import KompasError
|
|
19
|
+
from kompas_kernel.observability.logger import get_logger
|
|
20
|
+
from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CheckpointGuard(Protocol):
|
|
26
|
+
"""Session-scoped snapshot and rollback capability used by the decorator."""
|
|
27
|
+
|
|
28
|
+
async def capture(self, session_id: str) -> Checkpoint: ...
|
|
29
|
+
|
|
30
|
+
async def rollback(self, session_id: str, checkpoint_id: str) -> RestoreResult: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheckpointAction(StrEnum):
|
|
34
|
+
"""Caller-owned decision; the decorator owns how each action is executed."""
|
|
35
|
+
|
|
36
|
+
COMMIT = "commit"
|
|
37
|
+
ROLLBACK = "rollback"
|
|
38
|
+
NOT_EXECUTED = "not_executed"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class CheckpointDecision:
|
|
43
|
+
"""Transaction action plus caller-specific explanation for the tool result."""
|
|
44
|
+
|
|
45
|
+
action: CheckpointAction
|
|
46
|
+
detail: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _capture(guard: CheckpointGuard, session_id: str) -> tuple[Checkpoint | None, GuardReport | None]:
|
|
50
|
+
try:
|
|
51
|
+
return await guard.capture(session_id), None
|
|
52
|
+
except (KompasError, CheckpointError) as exc:
|
|
53
|
+
logger.warning(
|
|
54
|
+
event_name(LogDomain.AGENT, "checkpointed_execute_capture", LogEventType.FAILED),
|
|
55
|
+
session_id=session_id,
|
|
56
|
+
error=str(exc),
|
|
57
|
+
)
|
|
58
|
+
return None, GuardReport(
|
|
59
|
+
status=GuardStatus.NOT_CAPTURED,
|
|
60
|
+
detail=f"Снимок перед исполнением снять не удалось ({exc}) — код не исполнялся.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _rollback(
|
|
65
|
+
guard: CheckpointGuard,
|
|
66
|
+
session_id: str,
|
|
67
|
+
checkpoint: Checkpoint,
|
|
68
|
+
reason: str,
|
|
69
|
+
) -> GuardReport:
|
|
70
|
+
try:
|
|
71
|
+
restored = await guard.rollback(session_id, checkpoint.checkpoint_id)
|
|
72
|
+
except (KompasError, CheckpointError) as exc:
|
|
73
|
+
logger.warning(
|
|
74
|
+
event_name(LogDomain.AGENT, "checkpointed_execute_rollback", LogEventType.FAILED),
|
|
75
|
+
session_id=session_id,
|
|
76
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
77
|
+
error=str(exc),
|
|
78
|
+
)
|
|
79
|
+
return GuardReport(
|
|
80
|
+
status=GuardStatus.ROLLBACK_FAILED,
|
|
81
|
+
detail=f"{reason} Откат к снимку не удался ({exc}) — состояние документа неизвестно.",
|
|
82
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
83
|
+
)
|
|
84
|
+
if not restored.matches_checkpoint:
|
|
85
|
+
return GuardReport(
|
|
86
|
+
status=GuardStatus.ROLLBACK_FAILED,
|
|
87
|
+
detail=f"{reason} Откат выполнился, но документ НЕ совпал со снимком — продолжать нельзя.",
|
|
88
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
89
|
+
matches_checkpoint=False,
|
|
90
|
+
)
|
|
91
|
+
return GuardReport(
|
|
92
|
+
status=GuardStatus.ROLLED_BACK,
|
|
93
|
+
detail=f"{reason} Документ возвращён к снимку перед этим вызовом.",
|
|
94
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
95
|
+
matches_checkpoint=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _kept(checkpoint: Checkpoint, decision: CheckpointDecision) -> GuardReport:
|
|
100
|
+
return GuardReport(
|
|
101
|
+
status=GuardStatus.KEPT,
|
|
102
|
+
detail=(
|
|
103
|
+
f"{decision.detail} Checkpoint сохранён; при необходимости доступен через "
|
|
104
|
+
f"restore('{checkpoint.checkpoint_id}')."
|
|
105
|
+
),
|
|
106
|
+
checkpoint_id=checkpoint.checkpoint_id,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def checkpointed[**P, T](
|
|
111
|
+
*,
|
|
112
|
+
guard: Callable[P, CheckpointGuard],
|
|
113
|
+
session_id: Callable[P, str],
|
|
114
|
+
decide: Callable[[T], CheckpointDecision],
|
|
115
|
+
attach_guard: Callable[[T, GuardReport], T],
|
|
116
|
+
blocked_result: Callable[[GuardReport], T],
|
|
117
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
118
|
+
"""Decorate one operation with the canonical capture/decide/rollback mechanism.
|
|
119
|
+
|
|
120
|
+
The decorator owns every checkpoint side effect. Callers own only their typed
|
|
121
|
+
decision policy and the mechanical way their distinct result DTO carries a common
|
|
122
|
+
`GuardReport`. An unexpected exception is fail-closed: rollback is attempted and
|
|
123
|
+
the original exception is re-raised.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def _decorate(operation: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
127
|
+
@wraps(operation)
|
|
128
|
+
async def _wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
129
|
+
checkpoint_guard = guard(*args, **kwargs)
|
|
130
|
+
current_session_id = session_id(*args, **kwargs)
|
|
131
|
+
checkpoint, capture_failure = await _capture(checkpoint_guard, current_session_id)
|
|
132
|
+
if checkpoint is None:
|
|
133
|
+
if capture_failure is None: # pragma: no cover - `_capture` preserves this invariant.
|
|
134
|
+
raise AssertionError("checkpoint capture failed without GuardReport")
|
|
135
|
+
return blocked_result(capture_failure)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
result = await operation(*args, **kwargs)
|
|
139
|
+
except BaseException:
|
|
140
|
+
await _rollback(
|
|
141
|
+
checkpoint_guard,
|
|
142
|
+
current_session_id,
|
|
143
|
+
checkpoint,
|
|
144
|
+
"Исполнение завершилось неожиданным исключением.",
|
|
145
|
+
)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
decision = decide(result)
|
|
149
|
+
match decision.action:
|
|
150
|
+
case CheckpointAction.COMMIT | CheckpointAction.NOT_EXECUTED:
|
|
151
|
+
report = _kept(checkpoint, decision)
|
|
152
|
+
case CheckpointAction.ROLLBACK:
|
|
153
|
+
report = await _rollback(
|
|
154
|
+
checkpoint_guard,
|
|
155
|
+
current_session_id,
|
|
156
|
+
checkpoint,
|
|
157
|
+
decision.detail,
|
|
158
|
+
)
|
|
159
|
+
case _ as unreachable:
|
|
160
|
+
assert_never(unreachable)
|
|
161
|
+
return attach_guard(result, report)
|
|
162
|
+
|
|
163
|
+
return _wrapped
|
|
164
|
+
|
|
165
|
+
return _decorate
|