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.
Files changed (77) hide show
  1. kompas_kernel/__init__.py +3 -0
  2. kompas_kernel/features/__init__.py +9 -0
  3. kompas_kernel/features/assembly_navigator/__init__.py +8 -0
  4. kompas_kernel/features/assembly_navigator/commands.py +37 -0
  5. kompas_kernel/features/assembly_navigator/ksapi/__init__.py +5 -0
  6. kompas_kernel/features/assembly_navigator/ksapi/mapping.py +122 -0
  7. kompas_kernel/features/assembly_navigator/ksapi/presentation.py +107 -0
  8. kompas_kernel/features/assembly_navigator/ksapi/raw.py +74 -0
  9. kompas_kernel/features/assembly_navigator/ksapi/reader.py +287 -0
  10. kompas_kernel/features/assembly_navigator/ksapi/resolver.py +35 -0
  11. kompas_kernel/features/assembly_navigator/models.py +218 -0
  12. kompas_kernel/features/assembly_navigator/queries.py +42 -0
  13. kompas_kernel/features/assembly_navigator/service.py +31 -0
  14. kompas_kernel/features/assembly_navigator/summary.py +90 -0
  15. kompas_kernel/features/document_session/__init__.py +5 -0
  16. kompas_kernel/features/document_session/checkpointed.py +165 -0
  17. kompas_kernel/features/document_session/commands.py +94 -0
  18. kompas_kernel/features/document_session/digest.py +126 -0
  19. kompas_kernel/features/document_session/ksapi/__init__.py +5 -0
  20. kompas_kernel/features/document_session/ksapi/context_reader.py +174 -0
  21. kompas_kernel/features/document_session/ksapi/screenshot.py +365 -0
  22. kompas_kernel/features/document_session/ksapi/snapshot.py +181 -0
  23. kompas_kernel/features/document_session/models.py +311 -0
  24. kompas_kernel/features/document_session/queries.py +62 -0
  25. kompas_kernel/features/document_session/registry.py +101 -0
  26. kompas_kernel/features/document_session/service.py +128 -0
  27. kompas_kernel/features/geometry_3d/__init__.py +9 -0
  28. kompas_kernel/features/geometry_3d/axis_math.py +50 -0
  29. kompas_kernel/features/geometry_3d/commands.py +68 -0
  30. kompas_kernel/features/geometry_3d/hex_detect.py +113 -0
  31. kompas_kernel/features/geometry_3d/ksapi/__init__.py +5 -0
  32. kompas_kernel/features/geometry_3d/ksapi/component_geometry.py +559 -0
  33. kompas_kernel/features/geometry_3d/ksapi/measurements.py +368 -0
  34. kompas_kernel/features/geometry_3d/ksapi/presentation.py +204 -0
  35. kompas_kernel/features/geometry_3d/models.py +216 -0
  36. kompas_kernel/features/geometry_3d/queries.py +96 -0
  37. kompas_kernel/features/geometry_3d/service.py +69 -0
  38. kompas_kernel/features/geometry_3d/wrench_zone.py +161 -0
  39. kompas_kernel/features/ksapi_automation/__init__.py +9 -0
  40. kompas_kernel/features/ksapi_automation/gate.py +282 -0
  41. kompas_kernel/features/ksapi_automation/models.py +75 -0
  42. kompas_kernel/features/ksapi_automation/namespace.py +12 -0
  43. kompas_kernel/features/ksapi_automation/run_python.py +133 -0
  44. kompas_kernel/features/ksapi_automation/runner.py +126 -0
  45. kompas_kernel/features/ksapi_automation/service.py +85 -0
  46. kompas_kernel/features/part_authoring/__init__.py +5 -0
  47. kompas_kernel/features/part_authoring/build_geometry.py +122 -0
  48. kompas_kernel/features/part_authoring/models.py +99 -0
  49. kompas_kernel/features/wrench_clearance/__init__.py +7 -0
  50. kompas_kernel/features/wrench_clearance/models.py +87 -0
  51. kompas_kernel/features/wrench_clearance/service.py +154 -0
  52. kompas_kernel/kompas/__init__.py +14 -0
  53. kompas_kernel/kompas/errors.py +11 -0
  54. kompas_kernel/kompas/models.py +68 -0
  55. kompas_kernel/kompas/objects.py +93 -0
  56. kompas_kernel/kompas/platform.py +40 -0
  57. kompas_kernel/kompas/runtime.py +186 -0
  58. kompas_kernel/kompas/session.py +195 -0
  59. kompas_kernel/kompas/units.py +48 -0
  60. kompas_kernel/ksapi_static/__init__.py +8 -0
  61. kompas_kernel/ksapi_static/facts.py +132 -0
  62. kompas_kernel/ksapi_static/inventory.py +185 -0
  63. kompas_kernel/ksapi_static/recipes.py +114 -0
  64. kompas_kernel/ksapi_static/wrapper_grep.py +83 -0
  65. kompas_kernel/observability/__init__.py +1 -0
  66. kompas_kernel/observability/logger.py +24 -0
  67. kompas_kernel/observability/setup.py +115 -0
  68. kompas_kernel/observability/taxonomy.py +27 -0
  69. kompas_kernel/py.typed +1 -0
  70. kompas_kernel/standards/__init__.py +1 -0
  71. kompas_kernel/standards/wrench_clearance.py +234 -0
  72. kompas_kernel/utils/__init__.py +1 -0
  73. kompas_kernel/utils/filesystem.py +82 -0
  74. kompas_kernel/utils/validation.py +21 -0
  75. kompas_kernel-0.0.1.dist-info/METADATA +56 -0
  76. kompas_kernel-0.0.1.dist-info/RECORD +77 -0
  77. 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