aoa-ocel 1.0.0__tar.gz
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.
- aoa_ocel-1.0.0/.gitignore +92 -0
- aoa_ocel-1.0.0/CHANGELOG.md +16 -0
- aoa_ocel-1.0.0/PKG-INFO +12 -0
- aoa_ocel-1.0.0/pyproject.toml +24 -0
- aoa_ocel-1.0.0/src/aoa/ocel/README.md +118 -0
- aoa_ocel-1.0.0/src/aoa/ocel/__init__.py +39 -0
- aoa_ocel-1.0.0/src/aoa/ocel/contracts/__init__.py +4 -0
- aoa_ocel-1.0.0/src/aoa/ocel/contracts/ocel_frame.py +79 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/__init__.py +14 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_attribute.py +13 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_event.py +58 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object.py +17 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object_ref.py +12 -0
- aoa_ocel-1.0.0/src/aoa/ocel/dto/ocel_object_relationship.py +12 -0
- aoa_ocel-1.0.0/src/aoa/ocel/exceptions/__init__.py +12 -0
- aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_contract_error.py +11 -0
- aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_error.py +6 -0
- aoa_ocel-1.0.0/src/aoa/ocel/exceptions/ocel_resource_access_prohibited_error.py +11 -0
- aoa_ocel-1.0.0/src/aoa/ocel/plugin/__init__.py +10 -0
- aoa_ocel-1.0.0/src/aoa/ocel/plugin/ocel_plugin.py +333 -0
- aoa_ocel-1.0.0/src/aoa/ocel/py.typed +0 -0
- aoa_ocel-1.0.0/src/aoa/ocel/resource/__init__.py +12 -0
- aoa_ocel-1.0.0/src/aoa/ocel/resource/in_memory_ocel_store_resource.py +180 -0
- aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_protocol.py +24 -0
- aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_resource.py +45 -0
- aoa_ocel-1.0.0/src/aoa/ocel/resource/ocel_store_wrapper.py +45 -0
- aoa_ocel-1.0.0/src/aoa/ocel/type_id.py +31 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# Python
|
|
3
|
+
# ============================================================================
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*$py.class
|
|
7
|
+
*.so
|
|
8
|
+
.Python
|
|
9
|
+
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Virtual environments
|
|
12
|
+
# ============================================================================
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# Distribution / packaging
|
|
20
|
+
# ============================================================================
|
|
21
|
+
build/
|
|
22
|
+
dist/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
*.egg
|
|
25
|
+
.eggs/
|
|
26
|
+
wheels/
|
|
27
|
+
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# Testing
|
|
30
|
+
# ============================================================================
|
|
31
|
+
.pytest_cache/
|
|
32
|
+
.coverage
|
|
33
|
+
.coverage.*
|
|
34
|
+
htmlcov/
|
|
35
|
+
*.cover
|
|
36
|
+
.hypothesis/
|
|
37
|
+
.tox/
|
|
38
|
+
.nox/
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Type checking
|
|
42
|
+
# ============================================================================
|
|
43
|
+
.mypy_cache/
|
|
44
|
+
.dmypy.json
|
|
45
|
+
dmypy.json
|
|
46
|
+
.pyre/
|
|
47
|
+
.pytype/
|
|
48
|
+
|
|
49
|
+
# ============================================================================
|
|
50
|
+
# Node (Maxitor web client — source lives in git; node_modules stays local only)
|
|
51
|
+
# ============================================================================
|
|
52
|
+
packages/aoa-maxitor/client/node_modules/
|
|
53
|
+
packages/aoa-maxitor/client/.vite/
|
|
54
|
+
|
|
55
|
+
# ============================================================================
|
|
56
|
+
# IDE
|
|
57
|
+
# ============================================================================
|
|
58
|
+
.idea/
|
|
59
|
+
.vscode/
|
|
60
|
+
*.swp
|
|
61
|
+
*.swo
|
|
62
|
+
.DS_Store
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Logs and temporary files
|
|
66
|
+
# ============================================================================
|
|
67
|
+
*.log
|
|
68
|
+
code_quality.log
|
|
69
|
+
*.tmp
|
|
70
|
+
*.temp
|
|
71
|
+
|
|
72
|
+
# ============================================================================
|
|
73
|
+
# Project specific - ARCHIVE (ваши локальные снимки)
|
|
74
|
+
# ============================================================================
|
|
75
|
+
archive/
|
|
76
|
+
*.ver
|
|
77
|
+
code.txt
|
|
78
|
+
project_structure.txt
|
|
79
|
+
|
|
80
|
+
# ============================================================================
|
|
81
|
+
# Локальные черновики (русские версии документации и примеров)
|
|
82
|
+
# ============================================================================
|
|
83
|
+
*_draft.md
|
|
84
|
+
*_draft.py
|
|
85
|
+
.ipynb_checkpoints/
|
|
86
|
+
docs/ru/
|
|
87
|
+
|
|
88
|
+
# ============================================================================
|
|
89
|
+
# НЕ ИГНОРИРУЕМ - эти файлы должны быть в git!
|
|
90
|
+
# ============================================================================
|
|
91
|
+
!.python-version
|
|
92
|
+
!uv.lock
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `aoa-ocel` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] – 2026-06-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Initial standalone release, extracted from `aoa-action-machine`.** `OcelPlugin`, `OcelFrame`, `InMemoryOcelStoreResource`, `OcelStoreResource`, `OCEL_FRAMES_KEY`, and supporting DTOs are now distributed under the `aoa.ocel` namespace as a separate package (`pip install aoa-ocel`). The package depends on `aoa-action-machine` and `xxhash`. ([#71](https://github.com/bystrovmaxim/aoa/issues/71))
|
|
15
|
+
|
|
16
|
+
For the pre-extraction history of `OcelPlugin` (originally introduced in the monorepo at `[0.12.8]`), see the [aoa-action-machine CHANGELOG](../aoa-action-machine/CHANGELOG.md).
|
aoa_ocel-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aoa-ocel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: OCEL 2.0 export plugin for AOA — expose action runs as object-centric event logs for process mining.
|
|
5
|
+
Project-URL: Homepage, https://github.com/action-machine/action-machine
|
|
6
|
+
Project-URL: Documentation, https://action-machine.readthedocs.io
|
|
7
|
+
Project-URL: Repository, https://github.com/action-machine/action-machine
|
|
8
|
+
Author: @Bystrov.Maxim
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: aoa-action-machine>=1.0.0a5
|
|
12
|
+
Requires-Dist: xxhash<4,>=3.4
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# packages/aoa-ocel/pyproject.toml
|
|
2
|
+
[build-system]
|
|
3
|
+
requires = ["hatchling"]
|
|
4
|
+
build-backend = "hatchling.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "aoa-ocel"
|
|
8
|
+
version = "1.0.0"
|
|
9
|
+
description = "OCEL 2.0 export plugin for AOA — expose action runs as object-centric event logs for process mining."
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
authors = [{ name = "@Bystrov.Maxim" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aoa-action-machine>=1.0.0a5",
|
|
15
|
+
"xxhash>=3.4,<4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/action-machine/action-machine"
|
|
20
|
+
Documentation = "https://action-machine.readthedocs.io"
|
|
21
|
+
Repository = "https://github.com/action-machine/action-machine"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/aoa"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="../../../../../../../docs/assets/aoa-logo.png" alt="AOA" width="660"><br><br>
|
|
3
|
+
<a href="https://github.com/bystrovmaxim/aoa"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
4
|
+
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
|
|
5
|
+
<img src="https://img.shields.io/badge/tests-26-brightgreen" alt="26 tests">
|
|
6
|
+
<a href="https://pypi.org/project/aoa-ocel/"><img src="https://img.shields.io/badge/install-aoa--ocel--plugin-blue?logo=pypi&logoColor=white" alt="pip install aoa-ocel"></a>
|
|
7
|
+
<img src="https://img.shields.io/badge/OCEL-2.0-orange" alt="OCEL 2.0">
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
# OCEL export (`aoa.ocel`)
|
|
11
|
+
|
|
12
|
+
This module exports data in [OCEL 2.0](https://ocel-standard.org/) format. Install with ``pip install aoa-ocel``. It turns Action execution logs into object-centric event logs that Process Mining tools can consume.
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="../../../../../../../docs/assets/ocel-ocdfg.png" alt="Object-centric process graph (OCEL)" width="900">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
### Viewing logs in OC-PM
|
|
19
|
+
|
|
20
|
+
After ``await store.close()`` writes JSON, open the file in **[Object-Centric Process Mining (OC-PM)](https://www.ocpm.info/ocel.html)** — a browser app for OCEL JSON/XML logs. Upload the export, then use event/object explorers, OCDFG graphs, filters, and conformance views without extra tooling. See also ``examples/07_ocel.py`` and the Store batch export under ``packages/aoa-examples`` (``archive/logs/ocel.json`` in integration tests).
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
The builder (``OcelPlugin.build_ocel_event``) maps domain entities to ``OcelEvent`` DTOs. **v1 uses E2O only; O2O is not exported.**
|
|
25
|
+
|
|
26
|
+
We do **not** know in advance which questions process-mining users will ask. Export therefore prefers **reachability** (objects appear in event relationships when the aspect loaded them) over a minimal graph. Filters in PM tools can narrow views later; missing E2O cannot be recovered without re-export.
|
|
27
|
+
|
|
28
|
+
### `OcelFrame` input
|
|
29
|
+
|
|
30
|
+
Aspects return one or more `OcelFrame` rows in pipeline state:
|
|
31
|
+
|
|
32
|
+
| Field | Role |
|
|
33
|
+
|-------|------|
|
|
34
|
+
| `object` | Root domain entity for this participation row |
|
|
35
|
+
| `qualifier` | Required E2O role for the root (non-empty string) |
|
|
36
|
+
| `attributes` | Optional event-level attributes (merged across frames; name clash with different values → error) |
|
|
37
|
+
|
|
38
|
+
### E2O (event → object)
|
|
39
|
+
|
|
40
|
+
**Rule — loaded relations only:** E2O includes **only relation containers that are loaded on `frame.object` at export time** (`BaseEntity.get_foreign_keys(loaded_only=True)`). Scalar FK columns and relations not loaded on the instance are **not** exported. Nothing is read from the database beyond what the aspect already put on the entity.
|
|
41
|
+
|
|
42
|
+
**Rule — one hop:** From each `OcelFrame.object`, the builder walks **one level** of loaded relation fields only. It does **not** recurse into peers’ relations.
|
|
43
|
+
|
|
44
|
+
**Rule — no manual peer frames:** Loaded peers do not require separate `OcelFrame` rows. The builder materializes them automatically.
|
|
45
|
+
|
|
46
|
+
| Object | E2O qualifier |
|
|
47
|
+
|--------|----------------|
|
|
48
|
+
| `frame.object` (root) | `frame.qualifier` |
|
|
49
|
+
| Each loaded peer from a relation field `field_name` | `{frame.qualifier}.{field_name}` |
|
|
50
|
+
|
|
51
|
+
`AssociationMany` yields one E2O per peer id, same composite qualifier prefix.
|
|
52
|
+
|
|
53
|
+
### O2O (object → object)
|
|
54
|
+
|
|
55
|
+
**v1: not used.** `OcelObject.relationships` stays empty. Structural links are represented only through co-occurrence in E2O for the same event.
|
|
56
|
+
|
|
57
|
+
### Object attributes
|
|
58
|
+
|
|
59
|
+
| Source on domain entity | OCEL target |
|
|
60
|
+
|-------------------------|-------------|
|
|
61
|
+
| Loaded scalars on `frame.object` | `OcelObject.attributes` for the root |
|
|
62
|
+
| Loaded lifecycle snapshot on root | root attributes (state string, v1) |
|
|
63
|
+
| Loaded scalars on a one-hop peer | that peer’s `OcelObject.attributes` |
|
|
64
|
+
|
|
65
|
+
Event attributes: ``OcelPlugin`` merges ``OcelFrame.attributes`` across frames; not from entity fields.
|
|
66
|
+
|
|
67
|
+
### Example — “Doctor signed prescription”
|
|
68
|
+
|
|
69
|
+
Aspect loads on the doctor entity before wrapping `OcelFrame`:
|
|
70
|
+
|
|
71
|
+
- `patient` — loaded → **E2O** with qualifier `"Signed prescription.patient"`
|
|
72
|
+
- `clinic` where the doctor works — **not loaded** → no E2O for clinic (clinic is context, not a participant in this action)
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
OcelFrame(doctor, qualifier="Signed prescription")
|
|
76
|
+
│
|
|
77
|
+
├─ E2O doctor qualifier "Signed prescription"
|
|
78
|
+
├─ E2O patient qualifier "Signed prescription.patient" (loaded FK, one hop)
|
|
79
|
+
└─ (clinic omitted — relation not loaded on doctor)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If the aspect **did** load `clinic` on the doctor, the builder would add
|
|
83
|
+
`E2O clinic` with `"Signed prescription.clinic"`. That is valid but may add noise in PM graphs; the aspect controls participation via **partial loading**.
|
|
84
|
+
|
|
85
|
+
### E2O vs “everything in the database”
|
|
86
|
+
|
|
87
|
+
| In DB | On framed entity at export | In OCEL v1 |
|
|
88
|
+
|-------|----------------------------|------------|
|
|
89
|
+
| Order → Customer FK | `customer` loaded | E2O + materialized `OcelObject` |
|
|
90
|
+
| Order → Customer FK | `customer` not loaded | absent |
|
|
91
|
+
| Scalar `customer_id` only | in `get_scalar_fields()` | object attribute on order, not E2O |
|
|
92
|
+
|
|
93
|
+
Participation is **not** inferred from SQL or undeclared fields — only from what the aspect loaded on the entity inside `OcelFrame`.
|
|
94
|
+
|
|
95
|
+
## OcelPlugin
|
|
96
|
+
|
|
97
|
+
Register on ``ActionProductMachine(plugins=[OcelPlugin(store=resource)])``. The store must be opened by the owning action connection; the plugin only calls ``add_event``.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from aoa.ocel import OCEL_FRAMES_KEY, OcelFrame, OcelPlugin
|
|
101
|
+
|
|
102
|
+
@regular_aspect("Export")
|
|
103
|
+
@result_instance(OCEL_FRAMES_KEY, OcelFrame, required=False)
|
|
104
|
+
async def ocel_aspect(self, params, state, box, connections):
|
|
105
|
+
order = ... # partial load: only relations that should participate
|
|
106
|
+
return {
|
|
107
|
+
OCEL_FRAMES_KEY: [
|
|
108
|
+
OcelFrame(
|
|
109
|
+
object=order,
|
|
110
|
+
qualifier="Created order with identifier",
|
|
111
|
+
attributes=[OcelAttribute(name="domain", value="shop")],
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
On each trace, ``OcelPlugin`` reads ``GlobalFinishEvent.all_aspect_states`` (and optional frames on ``result``), builds one ``OcelEvent``, and appends to the store. The plugin does not mutate pipeline state.
|
|
118
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
OCEL 2.0 export plugin for AOA — install with ``pip install aoa-ocel``.
|
|
4
|
+
|
|
5
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
PURPOSE
|
|
7
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
Aspects return ``list[OcelFrame]``; ``OcelPlugin`` builds ``OcelEvent`` on
|
|
10
|
+
``GlobalFinishEvent``. Export policy (loaded FK → E2O, one hop, no O2O v1):
|
|
11
|
+
``packages/aoa-ocel/src/aoa/ocel/README.md``.
|
|
12
|
+
|
|
13
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
ARCHITECTURE / DATA FLOW
|
|
15
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
::
|
|
18
|
+
|
|
19
|
+
aspect → list[OcelFrame] (partial entity controls loaded relations)
|
|
20
|
+
│
|
|
21
|
+
▼
|
|
22
|
+
OcelPlugin → OcelEvent (E2O + OcelObject attributes) → OcelStoreResource
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from aoa.ocel.contracts import OcelFrame
|
|
28
|
+
from aoa.ocel.plugin import OCEL_FRAMES_KEY, OcelPlugin
|
|
29
|
+
from aoa.ocel.resource import InMemoryOcelStoreResource, OcelStoreResource
|
|
30
|
+
from aoa.ocel.type_id import make_oid
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"OCEL_FRAMES_KEY",
|
|
34
|
+
"InMemoryOcelStoreResource",
|
|
35
|
+
"OcelFrame",
|
|
36
|
+
"OcelPlugin",
|
|
37
|
+
"OcelStoreResource",
|
|
38
|
+
"make_oid",
|
|
39
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/contracts/ocel_frame.py
|
|
2
|
+
"""
|
|
3
|
+
OcelFrame[T] — explicit contract container for OCEL serialization.
|
|
4
|
+
|
|
5
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
PURPOSE
|
|
7
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
Aspects declare which domain object anchors an export row and the E2O role
|
|
10
|
+
(``qualifier``) for the root. The future builder adds E2O for **loaded**
|
|
11
|
+
one-hop relation peers on ``frame.object`` without separate frames.
|
|
12
|
+
|
|
13
|
+
Full export rules (E2O-only v1, loaded FK, one hop, analytics trade-off):
|
|
14
|
+
``packages/aoa-ocel/src/aoa/ocel/README.md`` — section **Export policy (v1)**.
|
|
15
|
+
|
|
16
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
17
|
+
ARCHITECTURE / DATA FLOW
|
|
18
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
::
|
|
21
|
+
|
|
22
|
+
aspect loads relations on entity → OcelFrame(object, qualifier, attributes?)
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
builder (PR-7): root E2O + one-hop loaded FK → E2O with "{qualifier}.{field}"
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
OcelEvent → OcelStoreResource
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import TypeVar
|
|
35
|
+
|
|
36
|
+
from aoa.action_machine.domain.entity import BaseEntity
|
|
37
|
+
from aoa.ocel.dto.ocel_attribute import OcelAttribute
|
|
38
|
+
from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T", bound=BaseEntity)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class OcelFrame[T: BaseEntity]:
|
|
45
|
+
"""Explicit contract that an aspect returns for OCEL serialization.
|
|
46
|
+
|
|
47
|
+
Builder mapping (v1, see package README)::
|
|
48
|
+
|
|
49
|
+
OcelFrame
|
|
50
|
+
├── object ──────────────────────► E2O (qualifier = frame.qualifier)
|
|
51
|
+
│ └── loaded FK peers (1 hop) ► E2O (qualifier = "{frame.qualifier}.{field}")
|
|
52
|
+
└── attributes ──────────────────► OcelPlugin merges → OcelEvent.attributes
|
|
53
|
+
|
|
54
|
+
Loaded-only rule: only ``get_foreign_keys(loaded_only=True)`` on ``object``;
|
|
55
|
+
undeclared DB relations and scalar ``*_id`` columns are not E2O.
|
|
56
|
+
|
|
57
|
+
AI-CORE-BEGIN
|
|
58
|
+
ROLE: Root participation row; builder derives secondary E2O from loaded one-hop relations on ``object``.
|
|
59
|
+
CONTRACT: ``object``, non-empty ``qualifier``, optional ``attributes``; multiple frames per trace allowed.
|
|
60
|
+
INVARIANTS: ``qualifier`` is non-empty; zero frames in finish snapshots → no OCEL write; v1 exports E2O only (no O2O); attribute merge is ``OcelPlugin`` policy on ``GlobalFinishEvent.all_aspect_states``.
|
|
61
|
+
AI-CORE-END
|
|
62
|
+
|
|
63
|
+
Example — one frame; patient loaded, clinic not loaded::
|
|
64
|
+
|
|
65
|
+
OcelFrame(
|
|
66
|
+
object=doctor, # patient relation loaded; clinic relation not loaded
|
|
67
|
+
qualifier="Signed prescription",
|
|
68
|
+
attributes=[OcelAttribute(name="channel", value="electronic")],
|
|
69
|
+
)
|
|
70
|
+
# builder E2O: doctor ("Signed prescription"), patient ("Signed prescription.patient")
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
object: T
|
|
74
|
+
qualifier: str
|
|
75
|
+
attributes: list[OcelAttribute] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
if not self.qualifier.strip():
|
|
79
|
+
raise OcelContractError("OcelFrame.qualifier must be non-empty")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/__init__.py
|
|
2
|
+
from aoa.ocel.dto.ocel_attribute import OcelAttribute
|
|
3
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
4
|
+
from aoa.ocel.dto.ocel_object import OcelObject
|
|
5
|
+
from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
|
|
6
|
+
from aoa.ocel.dto.ocel_object_relationship import OcelObjectRelationship
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"OcelAttribute",
|
|
10
|
+
"OcelEvent",
|
|
11
|
+
"OcelObject",
|
|
12
|
+
"OcelObjectRef",
|
|
13
|
+
"OcelObjectRelationship",
|
|
14
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/ocel_attribute.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class OcelAttribute:
|
|
10
|
+
"""Named attribute of an event or object (no ``time`` field = static)."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
value: Any
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/ocel_event.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from aoa.ocel.dto.ocel_attribute import OcelAttribute
|
|
8
|
+
from aoa.ocel.dto.ocel_object import OcelObject
|
|
9
|
+
from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class OcelEvent:
|
|
14
|
+
"""Composite event record passed to ``OcelStoreResource.add_event()``.
|
|
15
|
+
|
|
16
|
+
DTO graph (maps to OCEL 2.0 ``events[]`` + embedded ``objects[]`` facts)::
|
|
17
|
+
|
|
18
|
+
OcelEvent
|
|
19
|
+
├── id: str
|
|
20
|
+
├── type: str → eventTypes[].name
|
|
21
|
+
├── time: datetime → events[].time (UTC)
|
|
22
|
+
│
|
|
23
|
+
├── attributes: list[OcelAttribute]
|
|
24
|
+
│ OcelAttribute
|
|
25
|
+
│ ├── name: str
|
|
26
|
+
│ └── value: Any → static; no ``time`` field
|
|
27
|
+
│
|
|
28
|
+
├── relationships: list[OcelObjectRef] → E2O (event → object)
|
|
29
|
+
│ OcelObjectRef
|
|
30
|
+
│ ├── object_id: str → target ``OcelObject.id``
|
|
31
|
+
│ └── qualifier: str
|
|
32
|
+
│
|
|
33
|
+
└── objects: list[OcelObject] → objects[] section
|
|
34
|
+
OcelObject
|
|
35
|
+
├── id: str
|
|
36
|
+
├── type: str → objectTypes[].name
|
|
37
|
+
│
|
|
38
|
+
└── attributes: list[OcelAttribute]
|
|
39
|
+
└── static object attrs (scalars / lifecycle snapshot)
|
|
40
|
+
|
|
41
|
+
v1 builder policy (E2O only): ``relationships`` are built from ``OcelFrame`` rows
|
|
42
|
+
plus one-hop **loaded** relation peers on each ``frame.object``; composite
|
|
43
|
+
peer qualifier ``{frame.qualifier}.{field_name}``. No O2O export; see
|
|
44
|
+
``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
|
|
45
|
+
|
|
46
|
+
AI-CORE-BEGIN
|
|
47
|
+
ROLE: Single composite payload for one ``OcelStoreResource.add_event()`` call.
|
|
48
|
+
CONTRACT: ``relationships`` are E2O rows; ``objects`` materialize root and one-hop loaded peers.
|
|
49
|
+
INVARIANTS: ``time`` is UTC-aware before persist; attribute values are JSON-serializable primitives (plugin builds DTOs).
|
|
50
|
+
AI-CORE-END
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
type: str
|
|
55
|
+
time: datetime
|
|
56
|
+
attributes: list[OcelAttribute] = field(default_factory=list)
|
|
57
|
+
relationships: list[OcelObjectRef] = field(default_factory=list)
|
|
58
|
+
objects: list[OcelObject] = field(default_factory=list)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/ocel_object.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from aoa.ocel.dto.ocel_attribute import OcelAttribute
|
|
7
|
+
from aoa.ocel.dto.ocel_object_relationship import OcelObjectRelationship
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class OcelObject:
|
|
12
|
+
"""Full object entry for materializing the objects section."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
type: str
|
|
16
|
+
attributes: list[OcelAttribute] = field(default_factory=list)
|
|
17
|
+
relationships: list[OcelObjectRelationship] = field(default_factory=list)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/ocel_object_ref.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class OcelObjectRef:
|
|
9
|
+
"""Event-to-object reference (events[].relationships entry)."""
|
|
10
|
+
|
|
11
|
+
object_id: str
|
|
12
|
+
qualifier: str
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/dto/ocel_object_relationship.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class OcelObjectRelationship:
|
|
9
|
+
"""Object-to-object relationship derived from a relation container field."""
|
|
10
|
+
|
|
11
|
+
object_id: str
|
|
12
|
+
qualifier: str
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/exceptions/__init__.py
|
|
2
|
+
from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
|
|
3
|
+
from aoa.ocel.exceptions.ocel_error import OcelError
|
|
4
|
+
from aoa.ocel.exceptions.ocel_resource_access_prohibited_error import (
|
|
5
|
+
OcelResourceAccessProhibitedError,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"OcelContractError",
|
|
10
|
+
"OcelError",
|
|
11
|
+
"OcelResourceAccessProhibitedError",
|
|
12
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/exceptions/ocel_contract_error.py
|
|
2
|
+
"""OcelContractError — data/invariant contract violation."""
|
|
3
|
+
|
|
4
|
+
from aoa.ocel.exceptions.ocel_error import OcelError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OcelContractError(OcelError):
|
|
8
|
+
"""Raised when OCEL data or invariant contract is violated.
|
|
9
|
+
|
|
10
|
+
Catch with: ``except OcelContractError``.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/exceptions/ocel_resource_access_prohibited_error.py
|
|
2
|
+
"""OcelResourceAccessProhibitedError — lifecycle call from proxy connection."""
|
|
3
|
+
|
|
4
|
+
from aoa.ocel.exceptions.ocel_error import OcelError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OcelResourceAccessProhibitedError(OcelError):
|
|
8
|
+
"""Raised when action code calls ``open()`` or ``close()`` on a proxy connection.
|
|
9
|
+
|
|
10
|
+
Not a subclass of ``OcelContractError`` — lifecycle access violation, not data contract.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/plugin/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
OcelPlugin — builds ``OcelEvent`` from ``OcelFrame`` rows on ``GlobalFinishEvent``.
|
|
4
|
+
|
|
5
|
+
See ``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from aoa.ocel.plugin.ocel_plugin import OCEL_FRAMES_KEY, OcelPlugin
|
|
9
|
+
|
|
10
|
+
__all__ = ["OCEL_FRAMES_KEY", "OcelPlugin"]
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/plugin/ocel_plugin.py
|
|
2
|
+
"""
|
|
3
|
+
OcelPlugin — OCEL 2.0 export on ``GlobalFinishEvent``.
|
|
4
|
+
|
|
5
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
PURPOSE
|
|
7
|
+
═══════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
|
|
9
|
+
Read ``OcelFrame`` rows from ``GlobalFinishEvent.all_aspect_states`` (and
|
|
10
|
+
optional frames on ``result``), build one ``OcelEvent`` (E2O-only v1), append via
|
|
11
|
+
``OcelStoreProtocol.add_event``. Does not mutate pipeline state or the event.
|
|
12
|
+
|
|
13
|
+
Export policy: ``packages/aoa-ocel/src/aoa/ocel/README.md`` — **Export policy (v1)**.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from typing import Annotated, Any, Union, get_args, get_origin
|
|
22
|
+
|
|
23
|
+
from aoa.action_machine.domain.entity import BaseEntity
|
|
24
|
+
from aoa.action_machine.domain.relation_containers import BaseRelationMany, BaseRelationOne
|
|
25
|
+
from aoa.action_machine.intents.on import GlobalFinishEvent, on
|
|
26
|
+
from aoa.action_machine.plugin.core import Plugin
|
|
27
|
+
from aoa.ocel.contracts.ocel_frame import OcelFrame
|
|
28
|
+
from aoa.ocel.dto.ocel_attribute import OcelAttribute
|
|
29
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
30
|
+
from aoa.ocel.dto.ocel_object import OcelObject
|
|
31
|
+
from aoa.ocel.dto.ocel_object_ref import OcelObjectRef
|
|
32
|
+
from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
|
|
33
|
+
from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
|
|
34
|
+
from aoa.ocel.type_id import make_oid
|
|
35
|
+
|
|
36
|
+
OCEL_FRAMES_KEY = "ocel_frames"
|
|
37
|
+
_OCEL_TYPE_SUFFIXES = ("Action", "Entity", "Lifecycle")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OcelPlugin(Plugin):
|
|
41
|
+
"""
|
|
42
|
+
AI-CORE-BEGIN
|
|
43
|
+
ROLE: On ``GlobalFinishEvent``, scan ``all_aspect_states`` for ``OcelFrame`` rows and write one ``OcelEvent``.
|
|
44
|
+
CONTRACT: Requires injected ``OcelStoreProtocol``; optional ``short_names`` strips ``Action``/``Entity``/``Lifecycle`` suffixes from event and object type labels; read-only on event and pipeline state; E2O-only v1 (loaded one-hop FK, composite peer qualifiers).
|
|
45
|
+
INVARIANTS: Zero frames → no ``add_event``; event attribute name conflicts → ``OcelContractError``; store must already be open in the owning action.
|
|
46
|
+
AI-CORE-END
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
store: OcelStoreProtocol,
|
|
52
|
+
*,
|
|
53
|
+
short_names: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self._store = store
|
|
57
|
+
self._short_names = short_names
|
|
58
|
+
|
|
59
|
+
async def get_initial_state(self) -> dict[str, Any]:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
@on(GlobalFinishEvent, ignore_exceptions=False)
|
|
63
|
+
async def on_export_ocel(
|
|
64
|
+
self,
|
|
65
|
+
state: dict[str, Any],
|
|
66
|
+
event: GlobalFinishEvent,
|
|
67
|
+
log: Any,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Build ``OcelEvent`` from finish snapshots and append to the store."""
|
|
70
|
+
frames = collect_ocel_frames(event)
|
|
71
|
+
if not frames:
|
|
72
|
+
return state
|
|
73
|
+
ocel_event = self.build_ocel_event(frames, event)
|
|
74
|
+
await self._store.add_event(ocel_event)
|
|
75
|
+
return state
|
|
76
|
+
|
|
77
|
+
def build_ocel_event(
|
|
78
|
+
self,
|
|
79
|
+
frames: Iterable[OcelFrame[BaseEntity]],
|
|
80
|
+
event: GlobalFinishEvent,
|
|
81
|
+
) -> OcelEvent:
|
|
82
|
+
"""Assemble one composite ``OcelEvent`` from collected frames."""
|
|
83
|
+
frame_list = list(frames)
|
|
84
|
+
if not frame_list:
|
|
85
|
+
raise OcelContractError("build_ocel_event requires at least one OcelFrame")
|
|
86
|
+
|
|
87
|
+
relationships: list[OcelObjectRef] = []
|
|
88
|
+
objects_by_id: dict[str, OcelObject] = {}
|
|
89
|
+
for frame in frame_list:
|
|
90
|
+
refs, objects = self._materialize_frame(frame)
|
|
91
|
+
relationships.extend(refs)
|
|
92
|
+
for obj in objects:
|
|
93
|
+
objects_by_id[obj.id] = obj
|
|
94
|
+
|
|
95
|
+
return OcelEvent(
|
|
96
|
+
id=self._event_id(event),
|
|
97
|
+
type=self._ocel_type_name(event.action_class),
|
|
98
|
+
time=self._event_time(event),
|
|
99
|
+
attributes=self._merge_event_attributes(frame_list),
|
|
100
|
+
relationships=relationships,
|
|
101
|
+
objects=list(objects_by_id.values()),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def ensure_utc(dt: datetime) -> datetime:
|
|
106
|
+
"""Normalize datetime to UTC for OCEL 2.0 JSON (naive → UTC)."""
|
|
107
|
+
if dt.tzinfo is None:
|
|
108
|
+
return dt.replace(tzinfo=UTC)
|
|
109
|
+
return dt.astimezone(UTC)
|
|
110
|
+
|
|
111
|
+
def _merge_event_attributes(
|
|
112
|
+
self,
|
|
113
|
+
frames: Iterable[OcelFrame[BaseEntity]],
|
|
114
|
+
) -> list[OcelAttribute]:
|
|
115
|
+
merged: dict[str, OcelAttribute] = {}
|
|
116
|
+
for frame in frames:
|
|
117
|
+
for attr in frame.attributes:
|
|
118
|
+
existing = merged.get(attr.name)
|
|
119
|
+
if existing is not None and existing.value != attr.value:
|
|
120
|
+
raise OcelContractError(
|
|
121
|
+
f"Conflicting OcelEvent attribute {attr.name!r}: " f"{existing.value!r} vs {attr.value!r}"
|
|
122
|
+
)
|
|
123
|
+
merged[attr.name] = attr
|
|
124
|
+
return list(merged.values())
|
|
125
|
+
|
|
126
|
+
def _materialize_frame(
|
|
127
|
+
self,
|
|
128
|
+
frame: OcelFrame[BaseEntity],
|
|
129
|
+
) -> tuple[list[OcelObjectRef], list[OcelObject]]:
|
|
130
|
+
root = frame.object
|
|
131
|
+
root_id = self._entity_object_id(root)
|
|
132
|
+
objects = [self._build_ocel_object(root)]
|
|
133
|
+
refs = [OcelObjectRef(object_id=root_id, qualifier=frame.qualifier)]
|
|
134
|
+
|
|
135
|
+
for field_name, relation in root.get_foreign_keys().items():
|
|
136
|
+
peer_qualifier = f"{frame.qualifier}.{field_name}"
|
|
137
|
+
if isinstance(relation, BaseRelationOne):
|
|
138
|
+
refs.extend(
|
|
139
|
+
self._materialize_relation_one(
|
|
140
|
+
root,
|
|
141
|
+
field_name,
|
|
142
|
+
relation,
|
|
143
|
+
peer_qualifier,
|
|
144
|
+
objects,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
elif isinstance(relation, BaseRelationMany):
|
|
148
|
+
refs.extend(
|
|
149
|
+
self._materialize_relation_many(
|
|
150
|
+
root,
|
|
151
|
+
field_name,
|
|
152
|
+
relation,
|
|
153
|
+
peer_qualifier,
|
|
154
|
+
objects,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return refs, objects
|
|
158
|
+
|
|
159
|
+
def _materialize_relation_one(
|
|
160
|
+
self,
|
|
161
|
+
owner: BaseEntity,
|
|
162
|
+
field_name: str,
|
|
163
|
+
relation: BaseRelationOne[Any],
|
|
164
|
+
peer_qualifier: str,
|
|
165
|
+
objects: list[OcelObject],
|
|
166
|
+
) -> list[OcelObjectRef]:
|
|
167
|
+
if relation.entity is not None:
|
|
168
|
+
peer = relation.entity
|
|
169
|
+
peer_id = self._entity_object_id(peer)
|
|
170
|
+
if peer_id not in {obj.id for obj in objects}:
|
|
171
|
+
objects.append(self._build_ocel_object(peer))
|
|
172
|
+
return [OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier)]
|
|
173
|
+
|
|
174
|
+
related_cls = _related_entity_class(type(owner), field_name)
|
|
175
|
+
if related_cls is None:
|
|
176
|
+
raise OcelContractError(
|
|
177
|
+
f"Cannot materialize id-only relation {field_name!r} on "
|
|
178
|
+
f"{type(owner).__name__}: related entity type is unknown"
|
|
179
|
+
)
|
|
180
|
+
peer_id = make_oid(related_cls, relation.id)
|
|
181
|
+
if peer_id not in {obj.id for obj in objects}:
|
|
182
|
+
objects.append(
|
|
183
|
+
OcelObject(
|
|
184
|
+
id=peer_id,
|
|
185
|
+
type=self._ocel_type_name(related_cls),
|
|
186
|
+
attributes=[OcelAttribute(name="id", value=str(relation.id))],
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return [OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier)]
|
|
190
|
+
|
|
191
|
+
def _materialize_relation_many(
|
|
192
|
+
self,
|
|
193
|
+
owner: BaseEntity,
|
|
194
|
+
field_name: str,
|
|
195
|
+
relation: BaseRelationMany[Any],
|
|
196
|
+
peer_qualifier: str,
|
|
197
|
+
objects: list[OcelObject],
|
|
198
|
+
) -> list[OcelObjectRef]:
|
|
199
|
+
_ = owner
|
|
200
|
+
_ = field_name
|
|
201
|
+
if not relation.is_loaded:
|
|
202
|
+
return []
|
|
203
|
+
refs: list[OcelObjectRef] = []
|
|
204
|
+
known_ids = {obj.id for obj in objects}
|
|
205
|
+
for peer in relation.entities:
|
|
206
|
+
peer_id = self._entity_object_id(peer)
|
|
207
|
+
if peer_id not in known_ids:
|
|
208
|
+
objects.append(self._build_ocel_object(peer))
|
|
209
|
+
known_ids.add(peer_id)
|
|
210
|
+
refs.append(OcelObjectRef(object_id=peer_id, qualifier=peer_qualifier))
|
|
211
|
+
return refs
|
|
212
|
+
|
|
213
|
+
def _build_ocel_object(self, entity: BaseEntity) -> OcelObject:
|
|
214
|
+
attributes: list[OcelAttribute] = []
|
|
215
|
+
for name, value in entity.get_scalar_fields().items():
|
|
216
|
+
attributes.append(OcelAttribute(name=name, value=value))
|
|
217
|
+
for name, lifecycle in entity.get_lifecycle_fields().items():
|
|
218
|
+
attributes.append(OcelAttribute(name=name, value=lifecycle.current_state))
|
|
219
|
+
return OcelObject(
|
|
220
|
+
id=self._entity_object_id(entity),
|
|
221
|
+
type=self._ocel_type_name(type(entity)),
|
|
222
|
+
attributes=attributes,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _ocel_type_name(self, cls: type) -> str:
|
|
226
|
+
"""Return OCEL event/object type label (FQN or short class name)."""
|
|
227
|
+
return _ocel_type_name(cls, short_names=self._short_names)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _entity_object_id(entity: BaseEntity) -> str:
|
|
231
|
+
pk = entity.get_primary_key()
|
|
232
|
+
entity_id = pk.get("id")
|
|
233
|
+
if entity_id is None:
|
|
234
|
+
raise OcelContractError(f"Entity {type(entity).__name__} has no loaded primary key 'id' for OCEL export")
|
|
235
|
+
return make_oid(entity, entity_id)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _event_id(event: GlobalFinishEvent) -> str:
|
|
239
|
+
trace_id = event.context.request.trace_id
|
|
240
|
+
if trace_id:
|
|
241
|
+
return trace_id
|
|
242
|
+
return str(uuid.uuid4())
|
|
243
|
+
|
|
244
|
+
def _event_time(self, event: GlobalFinishEvent) -> datetime:
|
|
245
|
+
ts = event.context.request.request_timestamp
|
|
246
|
+
if ts is not None:
|
|
247
|
+
return self.ensure_utc(ts)
|
|
248
|
+
return datetime.now(UTC)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def collect_ocel_frames(
|
|
252
|
+
source: GlobalFinishEvent | Mapping[str, Any] | None,
|
|
253
|
+
) -> list[OcelFrame[BaseEntity]]:
|
|
254
|
+
"""Collect ``OcelFrame`` rows from a finish event or one state-like mapping."""
|
|
255
|
+
if source is None:
|
|
256
|
+
return []
|
|
257
|
+
if isinstance(source, GlobalFinishEvent):
|
|
258
|
+
frames: list[OcelFrame[BaseEntity]] = []
|
|
259
|
+
for snapshot in source.all_aspect_states:
|
|
260
|
+
frames.extend(collect_ocel_frames(snapshot))
|
|
261
|
+
frames.extend(collect_ocel_frames(_mapping_from_schema(source.result)))
|
|
262
|
+
return frames
|
|
263
|
+
|
|
264
|
+
payload = source
|
|
265
|
+
if not payload:
|
|
266
|
+
return []
|
|
267
|
+
mapping_frames: list[OcelFrame[BaseEntity]] = []
|
|
268
|
+
if OCEL_FRAMES_KEY in payload:
|
|
269
|
+
mapping_frames.extend(_normalize_frame_values(payload[OCEL_FRAMES_KEY]))
|
|
270
|
+
for value in payload.values():
|
|
271
|
+
if isinstance(value, OcelFrame):
|
|
272
|
+
mapping_frames.append(value)
|
|
273
|
+
return mapping_frames
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _normalize_frame_values(value: Any) -> list[OcelFrame[BaseEntity]]:
|
|
277
|
+
if isinstance(value, OcelFrame):
|
|
278
|
+
return [value]
|
|
279
|
+
if isinstance(value, (list, tuple)):
|
|
280
|
+
return [item for item in value if isinstance(item, OcelFrame)]
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _mapping_from_schema(value: Any) -> dict[str, Any]:
|
|
285
|
+
if isinstance(value, Mapping):
|
|
286
|
+
return dict(value)
|
|
287
|
+
model_dump = getattr(value, "model_dump", None)
|
|
288
|
+
if callable(model_dump):
|
|
289
|
+
dumped = model_dump()
|
|
290
|
+
if isinstance(dumped, Mapping):
|
|
291
|
+
return dict(dumped)
|
|
292
|
+
return {}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _ocel_type_name(cls: type, *, short_names: bool) -> str:
|
|
296
|
+
if not short_names:
|
|
297
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
298
|
+
name = cls.__name__
|
|
299
|
+
for suffix in _OCEL_TYPE_SUFFIXES:
|
|
300
|
+
if name.endswith(suffix) and len(name) > len(suffix):
|
|
301
|
+
return name[: -len(suffix)]
|
|
302
|
+
return name
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _unwrap_annotation(annotation: Any) -> Any:
|
|
306
|
+
origin = get_origin(annotation)
|
|
307
|
+
if origin is Annotated:
|
|
308
|
+
return get_args(annotation)[0]
|
|
309
|
+
if origin is Union:
|
|
310
|
+
non_none = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
311
|
+
if len(non_none) == 1:
|
|
312
|
+
return _unwrap_annotation(non_none[0])
|
|
313
|
+
return annotation
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _related_entity_class(
|
|
317
|
+
owner: type[BaseEntity],
|
|
318
|
+
field_name: str,
|
|
319
|
+
) -> type[BaseEntity] | None:
|
|
320
|
+
field = owner.model_fields.get(field_name)
|
|
321
|
+
if field is None:
|
|
322
|
+
return None
|
|
323
|
+
annotation = _unwrap_annotation(field.annotation)
|
|
324
|
+
origin = get_origin(annotation)
|
|
325
|
+
if origin is None:
|
|
326
|
+
return None
|
|
327
|
+
args = get_args(annotation)
|
|
328
|
+
if not args:
|
|
329
|
+
return None
|
|
330
|
+
related = _unwrap_annotation(args[0])
|
|
331
|
+
if isinstance(related, type) and issubclass(related, BaseEntity):
|
|
332
|
+
return related
|
|
333
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/resource/__init__.py
|
|
2
|
+
from aoa.ocel.resource.in_memory_ocel_store_resource import InMemoryOcelStoreResource
|
|
3
|
+
from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
|
|
4
|
+
from aoa.ocel.resource.ocel_store_resource import OcelStoreResource
|
|
5
|
+
from aoa.ocel.resource.ocel_store_wrapper import OcelStoreWrapper
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"InMemoryOcelStoreResource",
|
|
9
|
+
"OcelStoreProtocol",
|
|
10
|
+
"OcelStoreResource",
|
|
11
|
+
"OcelStoreWrapper",
|
|
12
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/resource/in_memory_ocel_store_resource.py
|
|
2
|
+
"""InMemoryOcelStoreResource — in-memory OCEL 2.0 backend."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aoa.action_machine.exceptions.connection_already_open_error import ConnectionAlreadyOpenError
|
|
13
|
+
from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
|
|
14
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
15
|
+
from aoa.ocel.dto.ocel_object import OcelObject
|
|
16
|
+
from aoa.ocel.exceptions.ocel_contract_error import OcelContractError
|
|
17
|
+
from aoa.ocel.resource.ocel_store_resource import OcelStoreResource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _type_catalog(type_attrs: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
|
|
21
|
+
"""Build OCEL 2.0 eventTypes/objectTypes JSON from name → {attr: ocel_type}."""
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
"name": type_name,
|
|
25
|
+
"attributes": [
|
|
26
|
+
{"name": attr_name, "type": attr_type} for attr_name, attr_type in sorted(type_attrs[type_name].items())
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
for type_name in sorted(type_attrs)
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _infer_ocel_type(value: Any) -> str:
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
return "boolean"
|
|
36
|
+
if isinstance(value, int):
|
|
37
|
+
return "integer"
|
|
38
|
+
if isinstance(value, float):
|
|
39
|
+
return "float"
|
|
40
|
+
if isinstance(value, datetime):
|
|
41
|
+
return "date"
|
|
42
|
+
return "string"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@exclude_graph_model
|
|
46
|
+
class InMemoryOcelStoreResource(OcelStoreResource):
|
|
47
|
+
"""In-memory backend that writes OCEL 2.0 JSON on ``close()``."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, output_file: Path) -> None:
|
|
50
|
+
self._output_file = output_file
|
|
51
|
+
self._lock = asyncio.Lock()
|
|
52
|
+
self._open = False
|
|
53
|
+
self._events: list[OcelEvent] = []
|
|
54
|
+
self._objects: dict[str, OcelObject] = {}
|
|
55
|
+
self._event_ids: set[str] = set()
|
|
56
|
+
|
|
57
|
+
async def open(self) -> None:
|
|
58
|
+
async with self._lock:
|
|
59
|
+
if self._open:
|
|
60
|
+
raise ConnectionAlreadyOpenError("InMemoryOcelStoreResource is already open.")
|
|
61
|
+
self._open = True
|
|
62
|
+
|
|
63
|
+
async def close(self) -> None:
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if not self._open:
|
|
66
|
+
return
|
|
67
|
+
await self._write_locked()
|
|
68
|
+
self._open = False
|
|
69
|
+
|
|
70
|
+
async def add_event(self, event: OcelEvent) -> None:
|
|
71
|
+
async with self._lock:
|
|
72
|
+
self._assert_open()
|
|
73
|
+
if event.id in self._event_ids:
|
|
74
|
+
raise OcelContractError(f"Duplicate OcelEvent.id: {event.id}")
|
|
75
|
+
self._event_ids.add(event.id)
|
|
76
|
+
self._events.append(event)
|
|
77
|
+
for obj_fact in event.objects:
|
|
78
|
+
self._merge_object_fact(obj_fact)
|
|
79
|
+
|
|
80
|
+
def _assert_open(self) -> None:
|
|
81
|
+
if not self._open:
|
|
82
|
+
raise OcelContractError("Resource is not open. Call await resource.open() first.")
|
|
83
|
+
|
|
84
|
+
def _merge_object_fact(self, incoming: OcelObject) -> None:
|
|
85
|
+
if incoming.id not in self._objects:
|
|
86
|
+
self._objects[incoming.id] = OcelObject(
|
|
87
|
+
id=incoming.id,
|
|
88
|
+
type=incoming.type,
|
|
89
|
+
attributes=list(incoming.attributes),
|
|
90
|
+
relationships=list(incoming.relationships),
|
|
91
|
+
)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
existing = self._objects[incoming.id]
|
|
95
|
+
existing_attrs = {a.name: a for a in existing.attributes}
|
|
96
|
+
for attr in incoming.attributes:
|
|
97
|
+
existing_attrs[attr.name] = attr
|
|
98
|
+
existing.attributes = list(existing_attrs.values())
|
|
99
|
+
|
|
100
|
+
existing_rels = {(r.object_id, r.qualifier) for r in existing.relationships}
|
|
101
|
+
for rel in incoming.relationships:
|
|
102
|
+
key = (rel.object_id, rel.qualifier)
|
|
103
|
+
if key not in existing_rels:
|
|
104
|
+
existing.relationships.append(rel)
|
|
105
|
+
existing_rels.add(key)
|
|
106
|
+
|
|
107
|
+
async def _write_locked(self) -> None:
|
|
108
|
+
doc = self._materialize()
|
|
109
|
+
self._output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
self._output_file.write_text(
|
|
111
|
+
json.dumps(doc, ensure_ascii=False, indent=2, default=self._json_default),
|
|
112
|
+
encoding="utf-8",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _json_default(obj: Any) -> Any:
|
|
117
|
+
if isinstance(obj, datetime):
|
|
118
|
+
return obj.isoformat()
|
|
119
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
|
120
|
+
|
|
121
|
+
def _materialize(self) -> dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"eventTypes": self._derive_event_types(),
|
|
124
|
+
"objectTypes": self._derive_object_types(),
|
|
125
|
+
"events": [self._serialize_event(ev) for ev in self._events],
|
|
126
|
+
"objects": [self._serialize_object(obj) for obj in sorted(self._objects.values(), key=lambda o: o.id)],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def _derive_event_types(self) -> list[dict[str, Any]]:
|
|
130
|
+
type_attrs: dict[str, dict[str, str]] = {}
|
|
131
|
+
for ev in self._events:
|
|
132
|
+
type_attrs.setdefault(ev.type, {})
|
|
133
|
+
for attr in ev.attributes:
|
|
134
|
+
if attr.name not in type_attrs[ev.type]:
|
|
135
|
+
type_attrs[ev.type][attr.name] = _infer_ocel_type(attr.value)
|
|
136
|
+
return _type_catalog(type_attrs)
|
|
137
|
+
|
|
138
|
+
def _derive_object_types(self) -> list[dict[str, Any]]:
|
|
139
|
+
type_attrs: dict[str, dict[str, str]] = {}
|
|
140
|
+
for obj in self._objects.values():
|
|
141
|
+
type_attrs.setdefault(obj.type, {})
|
|
142
|
+
for attr in obj.attributes:
|
|
143
|
+
if attr.name not in type_attrs[obj.type]:
|
|
144
|
+
type_attrs[obj.type][attr.name] = _infer_ocel_type(attr.value)
|
|
145
|
+
return _type_catalog(type_attrs)
|
|
146
|
+
|
|
147
|
+
def _serialize_event(self, ev: OcelEvent) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"id": ev.id,
|
|
150
|
+
"type": ev.type,
|
|
151
|
+
"time": ev.time.isoformat(),
|
|
152
|
+
"attributes": [{"name": a.name, "value": self._serialize_attr_value(a.value)} for a in ev.attributes],
|
|
153
|
+
"relationships": [{"objectId": r.object_id, "qualifier": r.qualifier} for r in ev.relationships],
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def _serialize_object(self, obj: OcelObject) -> dict[str, Any]:
|
|
157
|
+
attrs_out: list[dict[str, Any]] = []
|
|
158
|
+
for attr in sorted(obj.attributes, key=lambda a: a.name):
|
|
159
|
+
attrs_out.append({"name": attr.name, "value": self._serialize_attr_value(attr.value)})
|
|
160
|
+
return {
|
|
161
|
+
"id": obj.id,
|
|
162
|
+
"type": obj.type,
|
|
163
|
+
"attributes": attrs_out,
|
|
164
|
+
"relationships": [
|
|
165
|
+
{"objectId": r.object_id, "qualifier": r.qualifier}
|
|
166
|
+
for r in sorted(obj.relationships, key=lambda r: (r.object_id, r.qualifier))
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _serialize_attr_value(value: Any) -> Any:
|
|
172
|
+
if isinstance(value, datetime):
|
|
173
|
+
return value.isoformat()
|
|
174
|
+
if isinstance(value, bool):
|
|
175
|
+
return value
|
|
176
|
+
if isinstance(value, (int, float)):
|
|
177
|
+
return value
|
|
178
|
+
if value is None:
|
|
179
|
+
return None
|
|
180
|
+
return str(value)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_protocol.py
|
|
2
|
+
# pylint: disable=unnecessary-ellipsis # Protocol member bodies use ellipsis per PEP 544 stubs.
|
|
3
|
+
"""OcelStoreProtocol — full public OCEL store interface."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OcelStoreProtocol(Protocol):
|
|
14
|
+
"""Full public protocol for OCEL store (§5.27)."""
|
|
15
|
+
|
|
16
|
+
async def check_rollup_support(self) -> bool:
|
|
17
|
+
"""Same contract as ``BaseResource.check_rollup_support``."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
async def open(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
async def add_event(self, event: OcelEvent) -> None: ...
|
|
23
|
+
|
|
24
|
+
async def close(self) -> None: ...
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_resource.py
|
|
2
|
+
"""OcelStoreResource — abstract base for OCEL persistence backends."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
|
|
9
|
+
from aoa.action_machine.resources.base_resource import BaseResource
|
|
10
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
11
|
+
from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
|
|
12
|
+
from aoa.ocel.resource.ocel_store_wrapper import OcelStoreWrapper
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@exclude_graph_model
|
|
16
|
+
class OcelStoreResource(BaseResource, OcelStoreProtocol, ABC):
|
|
17
|
+
"""
|
|
18
|
+
AI-CORE-BEGIN
|
|
19
|
+
ROLE: Abstract persistent resource for OCEL 2.0 accumulation and persist on close.
|
|
20
|
+
CONTRACT: Public API is ``open``, ``add_event``, ``close``; nested actions get ``OcelStoreWrapper``.
|
|
21
|
+
INVARIANTS: ``add_event`` accepts only ``OcelEvent`` DTO; persistence runs in ``close()``.
|
|
22
|
+
AI-CORE-END
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def check_rollup_support(self) -> bool:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def get_wrapper_class(self) -> type[BaseResource] | None:
|
|
29
|
+
return OcelStoreWrapper
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
async def open(self) -> None:
|
|
33
|
+
"""Initialize the resource."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def add_event(self, event: OcelEvent) -> None:
|
|
37
|
+
"""Accumulate one composite OCEL event record.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
OcelContractError: duplicate ``event.id`` or resource not open.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def close(self) -> None:
|
|
45
|
+
"""Persist accumulated data and release resources."""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/resource/ocel_store_wrapper.py
|
|
2
|
+
"""OcelStoreWrapper — proxy for nested actions (mirrors WrapperSqlResource)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from aoa.action_machine.graph.core.exclude_graph_model import exclude_graph_model
|
|
7
|
+
from aoa.action_machine.resources.base_resource import BaseResource
|
|
8
|
+
from aoa.ocel.dto.ocel_event import OcelEvent
|
|
9
|
+
from aoa.ocel.exceptions.ocel_resource_access_prohibited_error import (
|
|
10
|
+
OcelResourceAccessProhibitedError,
|
|
11
|
+
)
|
|
12
|
+
from aoa.ocel.resource.ocel_store_protocol import OcelStoreProtocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@exclude_graph_model
|
|
16
|
+
class OcelStoreWrapper(BaseResource, OcelStoreProtocol):
|
|
17
|
+
"""Proxy wrapper for OCEL store passed into nested actions via ``ToolsBox.run``.
|
|
18
|
+
|
|
19
|
+
Delegates ``add_event``; ``open`` and ``close`` raise
|
|
20
|
+
``OcelResourceAccessProhibitedError`` (§5.27.2).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, resource: OcelStoreProtocol) -> None:
|
|
24
|
+
self._resource = resource
|
|
25
|
+
|
|
26
|
+
async def check_rollup_support(self) -> bool:
|
|
27
|
+
return await self._resource.check_rollup_support()
|
|
28
|
+
|
|
29
|
+
def get_wrapper_class(self) -> type[BaseResource] | None:
|
|
30
|
+
return OcelStoreWrapper
|
|
31
|
+
|
|
32
|
+
async def open(self) -> None:
|
|
33
|
+
raise OcelResourceAccessProhibitedError(
|
|
34
|
+
"Opening OCEL store is allowed only in the action that created the resource. "
|
|
35
|
+
"Current action received a proxy connection, so open is unavailable."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def add_event(self, event: OcelEvent) -> None:
|
|
39
|
+
await self._resource.add_event(event)
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
raise OcelResourceAccessProhibitedError(
|
|
43
|
+
"Closing OCEL store is allowed only in the action that created the resource. "
|
|
44
|
+
"Current action received a proxy connection, so close is unavailable."
|
|
45
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# packages/aoa-ocel/src/aoa/ocel/type_id.py
|
|
2
|
+
"""Short OCEL type prefixes and object IDs."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import xxhash
|
|
9
|
+
|
|
10
|
+
_prefix_cache: dict[str, str] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_oid(obj: Any, original_id: str | int | None = None) -> str:
|
|
14
|
+
"""Short OCEL type id, or object id when ``original_id`` is set.
|
|
15
|
+
|
|
16
|
+
Without ``original_id``: ``orde_a1b`` (type prefix for ``event.type`` / ``object.type``).
|
|
17
|
+
With ``original_id``: ``orde_a1b_123`` (full ``object.id``).
|
|
18
|
+
"""
|
|
19
|
+
cls = obj if isinstance(obj, type) else type(obj)
|
|
20
|
+
full_name = f"{cls.__module__}.{cls.__qualname__}"
|
|
21
|
+
|
|
22
|
+
prefix = _prefix_cache.get(full_name)
|
|
23
|
+
if prefix is None:
|
|
24
|
+
base = cls.__name__[:4].lower()
|
|
25
|
+
suffix = xxhash.xxh32(full_name.encode()).hexdigest()[:3]
|
|
26
|
+
prefix = f"{base}_{suffix}"
|
|
27
|
+
_prefix_cache[full_name] = prefix
|
|
28
|
+
|
|
29
|
+
if original_id is None:
|
|
30
|
+
return prefix
|
|
31
|
+
return f"{prefix}_{original_id}"
|