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,311 @@
1
+ """DTO документной сессии: то, что возвращают `get_context`/`screenshot`/`checkpoint`/`restore`.
2
+
3
+ `RunResult` (результат `run_python`) сюда не входит — он принадлежит
4
+ `features/ksapi_automation/models.py` (см. `todo-tasks/E16-assembly-navigator/04b-*.md`),
5
+ хотя и использует `GeoDigest` отсюда.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from enum import StrEnum
12
+ from pathlib import Path
13
+ from typing import Annotated, Literal
14
+
15
+ from pydantic import Field
16
+
17
+ from kompas_kernel.kompas.models import BBox, EntityRef, UnitsContract
18
+ from kompas_kernel.utils.validation import CustomModel
19
+
20
+
21
+ class GeoDigest(CustomModel):
22
+ """Дешёвый структурный отпечаток модели из ядра — без скриншота. Всё в мм/мм³.
23
+
24
+ Это «зрение без картинки»: число тел/граней/рёбер, габарит, объём. Дёшево по
25
+ токенам и достаточно, чтобы поймать «операция не изменила тело». `None` —
26
+ честный признак «нет данных» (пустой документ, объём не посчитан), не ноль.
27
+ """
28
+
29
+ body_count: int = Field(ge=0)
30
+ face_count: int = Field(ge=0)
31
+ edge_count: int = Field(ge=0)
32
+ volume_mm3: float | None = None
33
+ bbox: BBox | None = None
34
+
35
+
36
+ class DocumentKind(StrEnum):
37
+ """Тип открытого документа КОМПАС (`ksDocumentTypeEnum`) — дискриминатор `DocumentContext`.
38
+
39
+ Дискриминируем по типу ДОКУМЕНТА В СЕССИИ, а не по расширению исходного файла:
40
+ импортированный `.step`/`.sat`/`.iges` открывается как деталь или сборка, и снимать
41
+ с него можно ровно то же. Значения перечислены полностью (никаких «молчаливых дыр» —
42
+ чертёж приходит как `DrawingContext`, а не как `UNKNOWN`).
43
+ """
44
+
45
+ PART = "part"
46
+ ASSEMBLY = "assembly"
47
+ DRAWING = "drawing"
48
+ FRAGMENT = "fragment"
49
+ SPECIFICATION = "spec"
50
+ TEXTUAL = "textual"
51
+ UNKNOWN = "unknown"
52
+
53
+
54
+ class PartContext(CustomModel):
55
+ """3D-деталь (`ksDocumentPart`): структурный отпечаток геометрии.
56
+
57
+ Здесь `digest` честный — у детали свои тела/грани/рёбра. У КОРНЯ сборки их нет
58
+ (там только оси СК), поэтому digest сборке не даём — см. `AssemblyContext`.
59
+ """
60
+
61
+ doc_kind: Literal[DocumentKind.PART] = DocumentKind.PART
62
+ digest: GeoDigest
63
+
64
+
65
+ class AssemblyContext(CustomModel):
66
+ """Сборка (`ksDocumentAssembly`): лёгкая сводка верхнего уровня.
67
+
68
+ `component_count`/`top_marking` — дёшево (`GetPartsArray`/`GetMarking`), без чтения
69
+ геометрии каждого компонента. Полный разбор компонентов — отдельный тул
70
+ `read_assembly_components`; здесь его не дублируем.
71
+ """
72
+
73
+ doc_kind: Literal[DocumentKind.ASSEMBLY] = DocumentKind.ASSEMBLY
74
+ component_count: int = Field(ge=0)
75
+ top_marking: str | None = None
76
+
77
+
78
+ class DrawingContext(CustomModel):
79
+ """Чертёж (`ksDocumentDrawing`, `.cdw`). Пока без payload — вариант честный, не `UNKNOWN`."""
80
+
81
+ doc_kind: Literal[DocumentKind.DRAWING] = DocumentKind.DRAWING
82
+
83
+
84
+ class FragmentContext(CustomModel):
85
+ """Фрагмент (`ksDocumentFragment`, `.frw`). Пока без payload."""
86
+
87
+ doc_kind: Literal[DocumentKind.FRAGMENT] = DocumentKind.FRAGMENT
88
+
89
+
90
+ class SpecificationContext(CustomModel):
91
+ """Спецификация (`ksDocumentSpecification`, `.spw`). Пока без payload."""
92
+
93
+ doc_kind: Literal[DocumentKind.SPECIFICATION] = DocumentKind.SPECIFICATION
94
+
95
+
96
+ class TextualContext(CustomModel):
97
+ """Текстовый документ (`ksDocumentTextual`, `.kdw`). Пока без payload."""
98
+
99
+ doc_kind: Literal[DocumentKind.TEXTUAL] = DocumentKind.TEXTUAL
100
+
101
+
102
+ class UnknownDocumentContext(CustomModel):
103
+ """Неопознанный тип документа — несёт исходный код типа для диагностики."""
104
+
105
+ doc_kind: Literal[DocumentKind.UNKNOWN] = DocumentKind.UNKNOWN
106
+ raw_type_code: int
107
+ reason: str | None = None
108
+
109
+
110
+ DocumentContext = Annotated[
111
+ PartContext
112
+ | AssemblyContext
113
+ | DrawingContext
114
+ | FragmentContext
115
+ | SpecificationContext
116
+ | TextualContext
117
+ | UnknownDocumentContext,
118
+ Field(discriminator="doc_kind"),
119
+ ]
120
+ """Полиморфный контекст документа: свой тип payload на каждый `DocumentKind`.
121
+
122
+ Присоединяется к `CadContext` динамически через дискриминатор `doc_kind` (как
123
+ `JsonPolymorphic`/`family` в других проектах). Так LLM не приходится гадать по
124
+ пустому digest, что за документ открыт.
125
+ """
126
+
127
+
128
+ def simple_document_context(kind: DocumentKind, raw_type_code: int) -> DocumentContext:
129
+ """Payload-less вариант для типов, где снимать пока нечего (2D/спец/текст).
130
+
131
+ `PART`/`ASSEMBLY` сюда не попадают — их читатель контекста строит явно, с payload.
132
+ Неизвестный код типа → `UnknownDocumentContext` с исходным кодом для диагностики.
133
+ """
134
+ match kind:
135
+ case DocumentKind.DRAWING:
136
+ return DrawingContext()
137
+ case DocumentKind.FRAGMENT:
138
+ return FragmentContext()
139
+ case DocumentKind.SPECIFICATION:
140
+ return SpecificationContext()
141
+ case DocumentKind.TEXTUAL:
142
+ return TextualContext()
143
+ case _:
144
+ return UnknownDocumentContext(raw_type_code=raw_type_code)
145
+
146
+
147
+ class CadContext(CustomModel):
148
+ """Снимок текущего состояния сессии CAD для оркестратора (`get_context`).
149
+
150
+ `document_context` — полиморфный по типу документа (см. `DocumentContext`):
151
+ `None` ⇔ `document_open=False`. Раньше здесь висел плоский `digest`, который для
152
+ сборки читался как «пусто» и вводил в заблуждение — теперь digest живёт только
153
+ в `PartContext`, где он честный.
154
+
155
+ `document_name` — короткое имя документа (`IKompasDocument.GetName()`, имя файла с
156
+ расширением). У НЕСОХРАНЁННОГО документа КОМПАС отдаёт пустую строку и по имени, и
157
+ по пути (проверено живьём 2026-08-02 на новом `ksDocumentPart`, visible и invisible),
158
+ поэтому здесь у него честный `None`, а не выдуманное «БЕЗ ИМЕНИ». Имя читается из
159
+ сессии, а НЕ вычисляется из `document.document_path`: путь у безымянного документа
160
+ тоже пуст, и вывод одного поля из другого дал бы ложную уверенность.
161
+ """
162
+
163
+ connected: bool
164
+ app_version: str
165
+ document_open: bool
166
+ document: EntityRef | None = None
167
+ document_name: str | None = None
168
+ document_context: DocumentContext | None = None
169
+ units: UnitsContract = UnitsContract()
170
+
171
+ @property
172
+ def is_assembly(self) -> bool:
173
+ return isinstance(self.document_context, AssemblyContext)
174
+
175
+
176
+ class ActiveDocument(CustomModel):
177
+ """Кто открыт в КОМПАС — и ничего больше. Дешёвое наблюдение без топологии.
178
+
179
+ Отдельный запрос от `CadContext` именно из-за цены: `get_context` для детали
180
+ перечисляет ВСЕ грани и рёбра (`GetObjects(o3d_face)`, `_count_bodies_via_faces`),
181
+ то есть стоит O(топологии) — живой замер 2026-08-02: 50 мс на детали из трёх
182
+ граней и тем больше, чем тяжелее модель. Здесь читаются четыре поля через
183
+ `GetActiveDocument`/`GetDocumentType`/`GetName`/`GetFullPath` — 4 мс и O(1)
184
+ независимо от сложности документа. Этого достаточно и для видимой модели
185
+ поверхности тулов, и для снимка контекста в промпте, а дайджест по требованию
186
+ отдаёт отдельный тул `geometry_digest`.
187
+
188
+ Инварианты те же, что у `CadContext`: документа нет → `document_open=False` и
189
+ остальные поля `None`; несохранённый документ → `doc_kind` есть, а `name`/`path`
190
+ равны `None` (КОМПАС отдаёт по нему пустые строки, проверено живьём).
191
+ """
192
+
193
+ model_config = CustomModel.model_config | {"frozen": True}
194
+
195
+ document_open: bool
196
+ doc_kind: DocumentKind | None = None
197
+ name: str | None = None
198
+ path: str | None = None
199
+
200
+
201
+ NO_ACTIVE_DOCUMENT = ActiveDocument(document_open=False)
202
+ """Пустая сессия. Она же — стартовое значение до первого наблюдения (самое узкое)."""
203
+
204
+
205
+ def active_document_of(context: CadContext) -> ActiveDocument:
206
+ """Спроецировать уже прочитанный полный контекст на его дешёвую часть.
207
+
208
+ Нужна там, где полный `CadContext` УЖЕ получен (ответ тула `get_context`): второй
209
+ поход в КОМПАС ради тех же четырёх полей был бы лишним.
210
+ """
211
+ if not context.document_open:
212
+ return NO_ACTIVE_DOCUMENT
213
+ return ActiveDocument(
214
+ document_open=True,
215
+ doc_kind=None if context.document_context is None else context.document_context.doc_kind,
216
+ name=context.document_name,
217
+ path=None if context.document is None else context.document.document_path,
218
+ )
219
+
220
+
221
+ class ImageFormat(StrEnum):
222
+ """Формат файла скриншота; значение = и расширение файла, и хвост `image/<...>` media type.
223
+
224
+ Провайдеры принимают png/jpeg/gif/webp (в pydantic-ai это
225
+ `messages._image_format_lookup`) — BMP не берёт никто.
226
+ """
227
+
228
+ JPEG = "jpeg"
229
+
230
+
231
+ SCREENSHOT_IMAGE_FORMAT = ImageFormat.JPEG
232
+ """Формат снимка окна КОМПАС — общий контракт сессии и тула (он же строит имя файла).
233
+
234
+ JPEG, а не PNG: скриншот уезжает в модель на КАЖДОМ следующем шаге хода (он остаётся
235
+ в истории) и целиком ложится в `trace.jsonl`. Живой замер окна 1312x1400: PNG 907 КБ
236
+ против JPEG q=90 257 КБ при одинаково читаемом дереве построения — экономия в 3.5 раза
237
+ на каждом запросе.
238
+ """
239
+
240
+
241
+ class ScreenshotResult(CustomModel):
242
+ """Результат `screenshot`: путь к файлу-картинке + метаданные.
243
+
244
+ В `return_value` тула едет путь, а не байты: сама картинка уходит модели
245
+ отдельным `BinaryContent`, и дублировать её base64 в тексте ответа тула незачем.
246
+ `width`/`height` — размеры сохранённого JPEG (уже после уменьшения под окно
247
+ модели), а не исходного окна КОМПАС.
248
+ """
249
+
250
+ path: Path
251
+ image_format: ImageFormat
252
+ width: int = Field(gt=0)
253
+ height: int = Field(gt=0)
254
+
255
+
256
+ class Checkpoint(CustomModel):
257
+ """Снимок состояния документа перед изменяющей операцией (страховка).
258
+
259
+ Снимок — это файл-копия документа (`path`) плюс дайджест на момент снятия.
260
+ `document_path` — стабильный хэндл исходного документа: именно этот файл
261
+ перезаписывается копией при `restore`, поэтому он обязателен и непустой.
262
+
263
+ Запись сериализуется в реестр сессии (`features/document_session/registry.py`),
264
+ откуда `restore` достаёт её по `checkpoint_id` — модель носит с собой только id,
265
+ а не три пути. Поэтому здесь же живут поля для человека, читающего реестр
266
+ глазами: `created_at` и `saved_untitled_document` (документ был безымянным и его
267
+ пришлось сохранить в рабочую область сессии, чтобы снимок вообще стал возможен).
268
+ """
269
+
270
+ checkpoint_id: str
271
+ path: Path
272
+ document_path: str
273
+ digest: GeoDigest
274
+ created_at: datetime
275
+ saved_untitled_document: bool
276
+
277
+
278
+ class RestoreResult(CustomModel):
279
+ """Результат `restore`: доступный дайджест до, дайджест после и честность отката.
280
+
281
+ `matches_checkpoint` сравнивает дайджест ПОСЛЕ отката с дайджестом на момент
282
+ снятия — это и есть проверка «откат действительно вернул то состояние»; она
283
+ честна только потому, что дайджест снимка приходит из реестра, а не из аргументов
284
+ модели. `digest_before=None` означает, что повреждённое промежуточное состояние
285
+ прочитать не удалось; это не должно блокировать восстановление из снимка.
286
+ """
287
+
288
+ ok: bool
289
+ restored_from: Path
290
+ document_path: str
291
+ digest_before: GeoDigest | None
292
+ digest_after: GeoDigest
293
+ matches_checkpoint: bool
294
+
295
+
296
+ class GuardStatus(StrEnum):
297
+ """Outcome of the shared checkpoint decorator around one Python operation."""
298
+
299
+ NOT_CAPTURED = "not_captured"
300
+ KEPT = "kept"
301
+ ROLLED_BACK = "rolled_back"
302
+ ROLLBACK_FAILED = "rollback_failed"
303
+
304
+
305
+ class GuardReport(CustomModel):
306
+ """Common checkpoint/rollback report attached to specialized tool results."""
307
+
308
+ status: GuardStatus
309
+ detail: str
310
+ checkpoint_id: str | None = None
311
+ matches_checkpoint: bool | None = None
@@ -0,0 +1,62 @@
1
+ """Framework-free public read API for document session, over `KompasSession`.
2
+
3
+ No Pydantic AI, no `kompas_copilot.bridge` import — raw KsAPI context/topology reads
4
+ live in `ksapi/context_reader.py`, screenshot capture in `ksapi/screenshot.py`; this
5
+ module only wires them through session callbacks receiving `KompasContext` directly.
6
+
7
+ CQS: everything here is a query — no document mutation. `screenshot` writes an image
8
+ FILE (an external side effect for returning data to the caller, same category as
9
+ `geometry_digest` computing a value), but it never changes CAD document state — that
10
+ distinction is what routes `checkpoint`/`restore` to `commands.py` instead.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from kompas_kernel.features.document_session.digest import build_digest
18
+ from kompas_kernel.features.document_session.ksapi import context_reader, screenshot
19
+ from kompas_kernel.features.document_session.models import (
20
+ ActiveDocument,
21
+ CadContext,
22
+ GeoDigest,
23
+ ScreenshotResult,
24
+ )
25
+ from kompas_kernel.kompas.session import KompasContext, KompasSession
26
+
27
+
28
+ async def get_context(session: KompasSession) -> CadContext:
29
+ """Снимок текущего состояния сессии CAD для оркестратора."""
30
+
31
+ def _operation(context: KompasContext) -> CadContext:
32
+ return context_reader.read_context(context.modules, context.app)
33
+
34
+ return await session.run(operation_name="document_session.get_context", operation=_operation)
35
+
36
+
37
+ async def get_active_document(session: KompasSession) -> ActiveDocument:
38
+ """Дешёвое «кто открыт» без топологии — наблюдение хода (см. `ActiveDocument`)."""
39
+
40
+ def _operation(context: KompasContext) -> ActiveDocument:
41
+ return context_reader.read_active_document(context.modules, context.app)
42
+
43
+ return await session.run(operation_name="document_session.get_active_document", operation=_operation)
44
+
45
+
46
+ async def geometry_digest(session: KompasSession) -> GeoDigest:
47
+ """Дешёвый структурный отпечаток активной 3D-детали."""
48
+
49
+ def _operation(context: KompasContext) -> GeoDigest:
50
+ raw = context_reader.read_active_topology(context.modules, context.app)
51
+ return build_digest(raw)
52
+
53
+ return await session.run(operation_name="document_session.geometry_digest", operation=_operation)
54
+
55
+
56
+ async def screenshot_window(session: KompasSession, out_path: Path) -> ScreenshotResult:
57
+ """Снять окно КОМПАС целиком и сохранить его в `out_path`."""
58
+
59
+ def _operation(context: KompasContext) -> ScreenshotResult:
60
+ return screenshot.capture_window(context.app, context.modules, out_path)
61
+
62
+ return await session.run(operation_name="document_session.screenshot", operation=_operation)
@@ -0,0 +1,101 @@
1
+ """Реестр чекпойнтов сессии: `checkpoints/index.json` под корнем рабочей области сессии.
2
+
3
+ Зачем реестр поверх сырых `checkpoint`/`restore`:
4
+
5
+ - **Место.** Снимки и сохранённые безымянные документы кладутся в рабочую область
6
+ СЕССИИ агента (`<workspace>/sessions/<session_id>/checkpoints|cad`), а не в каталог
7
+ логов: там агент видит их своими же тулами (`list_workspace`, `read_workspace_file`),
8
+ и там же пользователь ищет всё остальное, что произвёл прогон (todo, спул).
9
+ - **Адресация.** Модель носит с собой ОДИН `checkpoint_id`, а пути и дайджест снимка
10
+ берутся из реестра. Раньше `restore` требовал три строковых аргумента, и дайджест
11
+ снимка не передавался вовсе — `matches_checkpoint` сравнивался с нулевым дайджестом
12
+ и всегда врал. Реестр — единственный источник истины о снятых чекпойнтах (GPC4).
13
+
14
+ Реестр — `checkpoints/index.json`, обычный JSON (расширение входит в
15
+ `WorkspaceSettings.text_extensions`), поэтому агент читает его штатным
16
+ `read_workspace_file` и после сжатия контекста может найти забытый `checkpoint_id`
17
+ без нового глагола. Запись атомарная (temp-рядом + `os.replace`, GPC5): процесс,
18
+ упавший посреди записи, оставляет прежний валидный индекс.
19
+
20
+ Чистый Python: ни KsAPI, ни pydantic-ai, ни `workspace` здесь нет.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from pathlib import Path
27
+
28
+ from kompas_kernel.features.document_session.models import Checkpoint
29
+ from kompas_kernel.utils.filesystem import atomic_write_text
30
+
31
+ CHECKPOINTS_DIRNAME = "checkpoints"
32
+ """Подкаталог рабочей области сессии со снимками и реестром."""
33
+
34
+ UNTITLED_DOCUMENTS_DIRNAME = "cad"
35
+ """Подкаталог рабочей области сессии для документов КОМПАС, сохранённых нами:
36
+ безымянную деталь агента надо куда-то положить, прежде чем снимать с неё копию."""
37
+
38
+ INDEX_FILENAME = "index.json"
39
+ """Имя файла реестра внутри `CHECKPOINTS_DIRNAME`."""
40
+
41
+ _INDEX_KEY = "checkpoints"
42
+
43
+
44
+ class CheckpointError(Exception):
45
+ """Отказ уровня реестра: неизвестный `checkpoint_id`, битый/нечитаемый `index.json`.
46
+
47
+ Отдельно от `KompasError` (сбой CAD) намеренно: тут КОМПАС ни при чём, и лечится
48
+ это иначе — посмотреть реестр и взять существующий id.
49
+ """
50
+
51
+
52
+ class CheckpointRegistry:
53
+ """Список снятых чекпойнтов сессии в одном JSON-файле (порядок = порядок снятия).
54
+
55
+ Читается целиком при каждом обращении: файл заведомо маленький (десятки записей за
56
+ прогон), а «прочитать → дописать → атомарно заменить» переживает падение процесса
57
+ и параллельный процесс с тем же прогоном не рассматривается (одна сессия — один
58
+ агент, см. модель развёртывания в AGENTS.md).
59
+ """
60
+
61
+ def __init__(self, index_path: Path) -> None:
62
+ self._index_path = index_path
63
+
64
+ @property
65
+ def path(self) -> Path:
66
+ return self._index_path
67
+
68
+ def load(self) -> list[Checkpoint]:
69
+ """Все записи реестра в порядке снятия; файла ещё нет — пустой список (не ошибка)."""
70
+ if not self._index_path.is_file():
71
+ return []
72
+ raw = self._index_path.read_text(encoding="utf-8")
73
+ try:
74
+ payload = json.loads(raw)
75
+ entries = payload[_INDEX_KEY]
76
+ return [Checkpoint.model_validate(entry) for entry in entries]
77
+ except Exception as exc:
78
+ raise CheckpointError(
79
+ f"реестр чекпойнтов повреждён: {self._index_path} — {type(exc).__name__}: {exc}"
80
+ ) from exc
81
+
82
+ def append(self, checkpoint: Checkpoint) -> None:
83
+ """Дописать запись и атомарно заменить файл реестра."""
84
+ entries = [*self.load(), checkpoint]
85
+ payload = {_INDEX_KEY: [entry.model_dump(mode="json") for entry in entries]}
86
+ atomic_write_text(
87
+ self._index_path,
88
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
89
+ create_parents=True,
90
+ newline=None,
91
+ sync=False,
92
+ )
93
+
94
+ def get(self, checkpoint_id: str) -> Checkpoint:
95
+ """Запись по id. Нет такой — `CheckpointError` со списком доступных id (не молча)."""
96
+ entries = self.load()
97
+ for entry in entries:
98
+ if entry.checkpoint_id == checkpoint_id:
99
+ return entry
100
+ known = ", ".join(entry.checkpoint_id for entry in entries) or "(реестр пуст)"
101
+ raise CheckpointError(f"чекпойнт {checkpoint_id!r} не найден в реестре сессии; известные id: {known}")
@@ -0,0 +1,128 @@
1
+ """`DocumentSessionService` — thin holder of one `KompasSession`, delegating to
2
+ `queries`/`commands`.
3
+
4
+ Exists solely because composition and its typed fake need one object implementing the
5
+ narrow consumer-owned `DocumentSessionPort` Protocol declared in
6
+ `core/capabilities/document_session.py` (same shape as `GeometryService`/`GeometryPort`)
7
+ — no multi-step use case lives here beyond delegating. The session is a required
8
+ dependency: this class neither creates nor closes it — the transport that built the
9
+ session owns that lifecycle.
10
+
11
+ Registry-facing checkpoint orchestration (capture → append to session registry,
12
+ rollback → look up by `checkpoint_id`) is a SEPARATE class, `CheckpointService` (below):
13
+ it depends on this service for the raw `checkpoint`/`restore` KsAPI calls plus a
14
+ `session_dir` resolver for the session workspace, and is the only place that knows
15
+ about the checkpoint registry file. Keeping it separate from `DocumentSessionService`
16
+ avoids giving the raw query/command facade a workspace dependency it doesn't need.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ from kompas_kernel.features.document_session import commands, queries
26
+ from kompas_kernel.features.document_session.models import (
27
+ ActiveDocument,
28
+ CadContext,
29
+ Checkpoint,
30
+ GeoDigest,
31
+ RestoreResult,
32
+ ScreenshotResult,
33
+ )
34
+ from kompas_kernel.features.document_session.registry import (
35
+ CHECKPOINTS_DIRNAME,
36
+ INDEX_FILENAME,
37
+ UNTITLED_DOCUMENTS_DIRNAME,
38
+ CheckpointRegistry,
39
+ )
40
+ from kompas_kernel.kompas.session import KompasSession
41
+ from kompas_kernel.observability.logger import get_logger
42
+ from kompas_kernel.observability.taxonomy import LogDomain, LogEventType, event_name
43
+
44
+ logger = get_logger(__name__)
45
+
46
+
47
+ @runtime_checkable
48
+ class RawCheckpointBackend(Protocol):
49
+ """Narrow consumer-owned contract `CheckpointService` needs from a document session.
50
+
51
+ Satisfied structurally by `DocumentSessionService` (below) in production and by a
52
+ typed fake in tests — `CheckpointService` never requires the concrete class.
53
+ """
54
+
55
+ async def checkpoint(self, *, checkpoint_dir: Path, untitled_document_dir: Path) -> Checkpoint: ...
56
+
57
+ async def restore(self, target: Checkpoint) -> RestoreResult: ...
58
+
59
+
60
+ class DocumentSessionService:
61
+ """Delegates document-session queries/commands to `queries`/`commands` over one session."""
62
+
63
+ def __init__(self, session: KompasSession) -> None:
64
+ self._session = session
65
+
66
+ async def get_context(self) -> CadContext:
67
+ return await queries.get_context(self._session)
68
+
69
+ async def get_active_document(self) -> ActiveDocument:
70
+ return await queries.get_active_document(self._session)
71
+
72
+ async def geometry_digest(self) -> GeoDigest:
73
+ return await queries.geometry_digest(self._session)
74
+
75
+ async def screenshot(self, out_path: Path) -> ScreenshotResult:
76
+ return await queries.screenshot_window(self._session, out_path)
77
+
78
+ async def checkpoint(self, *, checkpoint_dir: Path, untitled_document_dir: Path) -> Checkpoint:
79
+ return await commands.checkpoint(
80
+ self._session, checkpoint_dir=checkpoint_dir, untitled_document_dir=untitled_document_dir
81
+ )
82
+
83
+ async def restore(self, target: Checkpoint) -> RestoreResult:
84
+ return await commands.restore(self._session, target)
85
+
86
+
87
+ class CheckpointService:
88
+ """Registry-facing checkpoint orchestration over `DocumentSessionService`.
89
+
90
+ `session_dir` — чистая функция «session_id → корень рабочей области сессии»
91
+ (инъекция из composition root). Каталоги создаются лениво, на первом снимке.
92
+ """
93
+
94
+ def __init__(self, document_session: RawCheckpointBackend, *, session_dir: Callable[[str], Path]) -> None:
95
+ self._document_session = document_session
96
+ self._session_dir = session_dir
97
+
98
+ def registry(self, session_id: str) -> CheckpointRegistry:
99
+ """Реестр сессии — публичен: тесты и тулы показывают его путь пользователю."""
100
+ return CheckpointRegistry(self._session_dir(session_id) / CHECKPOINTS_DIRNAME / INDEX_FILENAME)
101
+
102
+ async def capture(self, session_id: str) -> Checkpoint:
103
+ """Снять снимок активного документа и записать его в реестр сессии."""
104
+ session_root = self._session_dir(session_id)
105
+ checkpoint = await self._document_session.checkpoint(
106
+ checkpoint_dir=session_root / CHECKPOINTS_DIRNAME,
107
+ untitled_document_dir=session_root / UNTITLED_DOCUMENTS_DIRNAME,
108
+ )
109
+ self.registry(session_id).append(checkpoint)
110
+ logger.info(
111
+ event_name(LogDomain.BRIDGE, "checkpoint_capture", LogEventType.COMPLETED),
112
+ session_id=session_id,
113
+ checkpoint_id=checkpoint.checkpoint_id,
114
+ saved_untitled_document=checkpoint.saved_untitled_document,
115
+ )
116
+ return checkpoint
117
+
118
+ async def rollback(self, session_id: str, checkpoint_id: str) -> RestoreResult:
119
+ """Откатить документ к записи реестра — дайджест снимка берётся оттуда же."""
120
+ target = self.registry(session_id).get(checkpoint_id)
121
+ result = await self._document_session.restore(target)
122
+ logger.info(
123
+ event_name(LogDomain.BRIDGE, "checkpoint_rollback", LogEventType.COMPLETED),
124
+ session_id=session_id,
125
+ checkpoint_id=checkpoint_id,
126
+ matches_checkpoint=result.matches_checkpoint,
127
+ )
128
+ return result
@@ -0,0 +1,9 @@
1
+ """Geometry 3D feature slice: measurements, exact B-rep facts, dimension-line presentation.
2
+
3
+ Framework-free (no Pydantic AI, no `kompas_copilot.bridge`): owns its DTO (`models.py`),
4
+ raw KsAPI reader/measurements/presentation (`ksapi/`), public query/command API
5
+ (`queries.py`/`commands.py`) over a `KompasSession`, and a thin service holder
6
+ (`service.py`, tracks `IDistanceAngleMeasurement3D` references created by
7
+ `show_distance_dimension`). Thin Pydantic AI registration lives in
8
+ `core/capabilities/geometry_3d.py`.
9
+ """
@@ -0,0 +1,50 @@
1
+ """Shared pure axis-projection math for `wrench_zone.py` and `hex_detect.py`.
2
+
3
+ Both need the same honest fact — "where along the fastener's axis (mm from
4
+ `AxisLine.origin`, projected onto `AxisLine.direction`) does this point sit, and how far
5
+ is it radially from the axis line" — for ГОСТ 13682-80 Черт. 5 (K/R) and hex-prism
6
+ detection respectively. One formula, not two drifting copies (GPC8). No pydantic-ai, no
7
+ bridge port calls: stdlib/math only, same boundary as its callers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ from kompas_kernel.features.geometry_3d.models import AxisLine
15
+ from kompas_kernel.kompas.models import Point3D
16
+
17
+
18
+ def unit_axis_direction(axis: AxisLine) -> tuple[float, float, float]:
19
+ """Normalize `axis.direction` — the bridge adapter does not (see `AxisLine` docstring).
20
+
21
+ Raises:
22
+ ValueError: `axis.direction` has zero length (a degenerate axis is a caller bug,
23
+ not a legitimate "no data" case).
24
+ """
25
+ direction = axis.direction
26
+ length = math.sqrt(direction.x_mm**2 + direction.y_mm**2 + direction.z_mm**2)
27
+ if length == 0:
28
+ raise ValueError("axis.direction must be non-zero")
29
+ return direction.x_mm / length, direction.y_mm / length, direction.z_mm / length
30
+
31
+
32
+ def axial_and_radial_mm(point: Point3D, axis: AxisLine, unit: tuple[float, float, float]) -> tuple[float, float]:
33
+ """`(axial_mm, radial_mm)` of `point` relative to `axis.origin`, projected onto `unit`.
34
+
35
+ `axial_mm` — signed distance along the axis from `axis.origin`. `radial_mm` — distance
36
+ from `point` to the axis LINE (the perpendicular component), always non-negative.
37
+ `unit` — pre-normalized `axis.direction` (`unit_axis_direction`), passed in rather than
38
+ recomputed per point — callers project many points against the same axis in a loop.
39
+ """
40
+ origin = axis.origin
41
+ vx = point.x_mm - origin.x_mm
42
+ vy = point.y_mm - origin.y_mm
43
+ vz = point.z_mm - origin.z_mm
44
+ unit_x, unit_y, unit_z = unit
45
+ axial_mm = vx * unit_x + vy * unit_y + vz * unit_z
46
+ radial_x = vx - axial_mm * unit_x
47
+ radial_y = vy - axial_mm * unit_y
48
+ radial_z = vz - axial_mm * unit_z
49
+ radial_mm = math.sqrt(radial_x**2 + radial_y**2 + radial_z**2)
50
+ return axial_mm, radial_mm