nodekit 0.0.1a1__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.
Potentially problematic release.
This version of nodekit might be problematic. Click here for more details.
- nodekit/__init__.py +46 -0
- nodekit/_internal/__init__.py +0 -0
- nodekit/_internal/compilers/__init__.py +0 -0
- nodekit/_internal/compilers/events.py +59 -0
- nodekit/_internal/compilers/html_rendering.py +44 -0
- nodekit/_internal/compilers/node_graph_site_template.j2 +109 -0
- nodekit/_internal/models/__init__.py +0 -0
- nodekit/_internal/models/assets/__init__.py +0 -0
- nodekit/_internal/models/assets/base.py +43 -0
- nodekit/_internal/models/fields.py +60 -0
- nodekit/_internal/models/node_engine/__init__.py +0 -0
- nodekit/_internal/models/node_engine/base.py +22 -0
- nodekit/_internal/models/node_engine/board.py +9 -0
- nodekit/_internal/models/node_engine/bonus_policy.py +50 -0
- nodekit/_internal/models/node_engine/cards/__init__.py +0 -0
- nodekit/_internal/models/node_engine/cards/base.py +25 -0
- nodekit/_internal/models/node_engine/cards/cards.py +60 -0
- nodekit/_internal/models/node_engine/effects/__init__.py +0 -0
- nodekit/_internal/models/node_engine/effects/base.py +30 -0
- nodekit/_internal/models/node_engine/fields.py +85 -0
- nodekit/_internal/models/node_engine/node_graph.py +118 -0
- nodekit/_internal/models/node_engine/reinforcer_maps/__init__.py +0 -0
- nodekit/_internal/models/node_engine/reinforcer_maps/base.py +14 -0
- nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer/__init__.py +0 -0
- nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer/reinforcer.py +14 -0
- nodekit/_internal/models/node_engine/reinforcer_maps/reinforcer_maps.py +43 -0
- nodekit/_internal/models/node_engine/runtime_metrics.py +24 -0
- nodekit/_internal/models/node_engine/sensors/__init__.py +0 -0
- nodekit/_internal/models/node_engine/sensors/actions/__init__.py +0 -0
- nodekit/_internal/models/node_engine/sensors/actions/actions.py +48 -0
- nodekit/_internal/models/node_engine/sensors/actions/base.py +18 -0
- nodekit/_internal/models/node_engine/sensors/base.py +25 -0
- nodekit/_internal/models/node_engine/sensors/sensors.py +49 -0
- nodekit/_internal/rule_engine/__init__.py +0 -0
- nodekit/_internal/rule_engine/compute_bonus.py +32 -0
- nodekit/actions/__init__.py +13 -0
- nodekit/assets/__init__.py +9 -0
- nodekit/bonus_rules/__init__.py +10 -0
- nodekit/cards/__init__.py +13 -0
- nodekit/compile/__init__.py +10 -0
- nodekit/effects/__init__.py +7 -0
- nodekit/events/__init__.py +23 -0
- nodekit/reinforcer_maps/__init__.py +10 -0
- nodekit/sensors/__init__.py +12 -0
- nodekit/types/__init__.py +27 -0
- nodekit-0.0.1a1.dist-info/METADATA +217 -0
- nodekit-0.0.1a1.dist-info/RECORD +50 -0
- nodekit-0.0.1a1.dist-info/WHEEL +5 -0
- nodekit-0.0.1a1.dist-info/licenses/LICENSE +201 -0
- nodekit-0.0.1a1.dist-info/top_level.txt +1 -0
nodekit/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# This module exposes the public API for the psykit package.
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
'VERSION',
|
|
5
|
+
|
|
6
|
+
# Top-level models:
|
|
7
|
+
'NodeGraph',
|
|
8
|
+
'Node',
|
|
9
|
+
'NodeResult',
|
|
10
|
+
|
|
11
|
+
# Namespaced models:
|
|
12
|
+
'actions',
|
|
13
|
+
'assets',
|
|
14
|
+
'cards',
|
|
15
|
+
'effects',
|
|
16
|
+
'reinforcer_maps',
|
|
17
|
+
'sensors',
|
|
18
|
+
'bonus_rules',
|
|
19
|
+
'events',
|
|
20
|
+
|
|
21
|
+
# Types:
|
|
22
|
+
'types',
|
|
23
|
+
|
|
24
|
+
# Runtime API:
|
|
25
|
+
'compile',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Incoming models:
|
|
29
|
+
from nodekit._internal.models.node_engine.node_graph import (
|
|
30
|
+
NodeGraph,
|
|
31
|
+
Node,
|
|
32
|
+
NodeResult
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
import nodekit.cards as cards
|
|
36
|
+
import nodekit.assets as assets
|
|
37
|
+
import nodekit.effects as effects
|
|
38
|
+
import nodekit.reinforcer_maps as reinforcer_maps
|
|
39
|
+
import nodekit.sensors as sensors
|
|
40
|
+
import nodekit.actions as actions
|
|
41
|
+
import nodekit.bonus_rules as bonus_rules
|
|
42
|
+
import nodekit.types as types
|
|
43
|
+
import nodekit.compile as compile
|
|
44
|
+
import nodekit.events as events
|
|
45
|
+
|
|
46
|
+
VERSION = '0.0.1'
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from typing import Literal, Annotated, Union
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from nodekit._internal.models.fields import DatetimeUTC
|
|
6
|
+
from nodekit._internal.models.node_engine.base import NullValue
|
|
7
|
+
from nodekit._internal.models.node_engine.node_graph import NodeResult
|
|
8
|
+
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# %%
|
|
13
|
+
import enum
|
|
14
|
+
class EventTypeEnum(str, enum.Enum):
|
|
15
|
+
StartEvent = 'StartEvent'
|
|
16
|
+
NodeResultEvent = 'NodeResultEvent'
|
|
17
|
+
EndEvent = 'EndEvent'
|
|
18
|
+
|
|
19
|
+
# %%
|
|
20
|
+
class BaseEvent(pydantic.BaseModel):
|
|
21
|
+
event_id: UUID
|
|
22
|
+
run_id: UUID
|
|
23
|
+
event_type: EventTypeEnum
|
|
24
|
+
event_payload: NullValue
|
|
25
|
+
event_timestamp: DatetimeUTC
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# %%
|
|
29
|
+
class StartEvent(BaseEvent):
|
|
30
|
+
event_type: Literal[EventTypeEnum.StartEvent] = EventTypeEnum.StartEvent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EndEvent(BaseEvent):
|
|
34
|
+
event_type: Literal[EventTypeEnum.EndEvent] = EventTypeEnum.EndEvent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# %%
|
|
38
|
+
class NodeResultEvent(BaseEvent):
|
|
39
|
+
event_type: Literal[EventTypeEnum.NodeResultEvent] = EventTypeEnum.NodeResultEvent
|
|
40
|
+
event_payload: NodeResult
|
|
41
|
+
|
|
42
|
+
# %%
|
|
43
|
+
Event = Annotated[
|
|
44
|
+
Union[
|
|
45
|
+
StartEvent,
|
|
46
|
+
NodeResultEvent,
|
|
47
|
+
EndEvent,
|
|
48
|
+
],
|
|
49
|
+
pydantic.Field(discriminator='event_type')
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# %% Expected server communication protocol:
|
|
54
|
+
SubmitEventRequest = Event
|
|
55
|
+
|
|
56
|
+
class SubmitEventResponse(pydantic.BaseModel):
|
|
57
|
+
redirect_url: str | None = pydantic.Field(
|
|
58
|
+
description="The URL to which the Participant browser should redirect after submitting this Event. If None, no redirect is needed.",
|
|
59
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import jinja2
|
|
4
|
+
|
|
5
|
+
from nodekit._internal.models.node_engine.node_graph import NodeGraph
|
|
6
|
+
import pydantic
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# %%
|
|
10
|
+
class CompileHtmlOptions(pydantic.BaseModel):
|
|
11
|
+
event_submission_url: str | None = pydantic.Field(
|
|
12
|
+
description='An endpoint to which Events will be sent. If None, the Events will simply be shown at the end of the Run.',
|
|
13
|
+
default = None,
|
|
14
|
+
)
|
|
15
|
+
start_node_execution_index: int
|
|
16
|
+
|
|
17
|
+
run_id: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def html(
|
|
21
|
+
node_graph: NodeGraph,
|
|
22
|
+
options: CompileHtmlOptions | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
if options is None:
|
|
25
|
+
options = CompileHtmlOptions(
|
|
26
|
+
event_submission_url=None,
|
|
27
|
+
start_node_execution_index=0,
|
|
28
|
+
run_id='NO_RUN_ID',
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Render the node sequence using a Jinja2 template
|
|
32
|
+
template_location = Path(__file__).parent / 'node_graph_site_template.j2'
|
|
33
|
+
template = jinja2.Environment(loader=jinja2.FileSystemLoader(template_location.parent)).get_template(template_location.name)
|
|
34
|
+
|
|
35
|
+
html_string = template.render(
|
|
36
|
+
dict(
|
|
37
|
+
node_graph=node_graph.model_dump(mode='json'),
|
|
38
|
+
event_submission_url=options.event_submission_url,
|
|
39
|
+
run_id=options.run_id,
|
|
40
|
+
start_node_execution_index=options.start_node_execution_index,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return html_string
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="eng">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<title>NodeGraph</title>
|
|
6
|
+
<script src="https://unpkg.com/nodekit-browser@0.0.1/dist/nodekit.js"></script>
|
|
7
|
+
<script src="https://unpkg.com/@psychoscope/node-player@0.2.2/dist/node-player.js"></script>
|
|
8
|
+
<link rel="stylesheet" href="https://unpkg.com/@psychoscope/node-player@0.2.2/dist/node-player.css">
|
|
9
|
+
<script>
|
|
10
|
+
|
|
11
|
+
// Rendering context:
|
|
12
|
+
const nodeGraph = {{ node_graph | tojson(indent=4) | indent(8, first=False) }};
|
|
13
|
+
const event_submission_url = {{ event_submission_url | tojson}};
|
|
14
|
+
const run_id = "{{ run_id }}";
|
|
15
|
+
const start_node_execution_index = {{ start_node_execution_index }};
|
|
16
|
+
|
|
17
|
+
// Run:
|
|
18
|
+
window.onload = async () => {
|
|
19
|
+
let eventClient = null;
|
|
20
|
+
if (event_submission_url) {
|
|
21
|
+
eventClient = new EventClient(
|
|
22
|
+
run_id,
|
|
23
|
+
event_submission_url
|
|
24
|
+
);
|
|
25
|
+
await eventClient.sendStartEvent()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Play the Nodes in the NodeGraph:
|
|
29
|
+
const nodes = nodeGraph.nodes;
|
|
30
|
+
let nodePlayer = new NodePlayer();
|
|
31
|
+
const nodeResultsBuffer = [];
|
|
32
|
+
for (let i = start_node_execution_index; i < nodes.length; i++) {
|
|
33
|
+
const node = nodes[i];
|
|
34
|
+
const nodePlayId = await nodePlayer.prepare(node);
|
|
35
|
+
let nodeMeasurements = await nodePlayer.play(nodePlayId);
|
|
36
|
+
|
|
37
|
+
// Update the progress bar:
|
|
38
|
+
nodePlayer.setProgressBar((i + 1) / nodes.length * 100);
|
|
39
|
+
|
|
40
|
+
// Package the NodeResult:
|
|
41
|
+
const nodeResult = {
|
|
42
|
+
node_id: node.node_id,
|
|
43
|
+
timestamp_start: nodeMeasurements.timestamp_node_started,
|
|
44
|
+
timestamp_end: nodeMeasurements.timestamp_node_completed,
|
|
45
|
+
node_execution_index: i,
|
|
46
|
+
action: nodeMeasurements.action,
|
|
47
|
+
runtime_metrics: nodeMeasurements.runtime_metrics,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Queue the NodeResult being sent to the server:
|
|
51
|
+
if (eventClient) {
|
|
52
|
+
// Fire and forget
|
|
53
|
+
eventClient.sendNodeResultEvent(nodeResult)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
nodeResultsBuffer.push(nodeResult);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Compute and disclose the provisional result of the bonus amount:
|
|
60
|
+
const bonusRules = nodeGraph.bonus_rules;
|
|
61
|
+
let bonusComputed = 0;
|
|
62
|
+
for (let i = 0; i < nodeResultsBuffer.length; i++) {
|
|
63
|
+
const action = nodeResultsBuffer[i].action;
|
|
64
|
+
|
|
65
|
+
// Run bonus rule engine on this NodeResult:
|
|
66
|
+
for (let ruleIndex = 0; ruleIndex < bonusRules.length; ruleIndex++) {
|
|
67
|
+
const rule = bonusRules[ruleIndex];
|
|
68
|
+
// Dynamic dispatch:
|
|
69
|
+
if (rule.bonus_rule_type === 'ConstantBonusRule') {
|
|
70
|
+
const parameters = rule.bonus_rule_parameters;
|
|
71
|
+
if (parameters.sensor_id === action.sensor_id) {
|
|
72
|
+
bonusComputed += Number(parameters.bonus_amount_usd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
bonusComputed = Math.max(0, bonusComputed);
|
|
78
|
+
bonusComputed = Math.round(bonusComputed * 100) / 100; // Round to 2 decimals
|
|
79
|
+
|
|
80
|
+
// Play the end screen:
|
|
81
|
+
let bonusMessage = '';
|
|
82
|
+
if (bonusComputed > 0) {
|
|
83
|
+
bonusMessage = `Bonus: ${bonusComputed} USD (pending validation)`;
|
|
84
|
+
}
|
|
85
|
+
await nodePlayer.playEndScreen(
|
|
86
|
+
bonusMessage,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
// Close server connection and possibly redirect:
|
|
90
|
+
if (eventClient) {
|
|
91
|
+
let endResponse = await eventClient.sendEndEvent();
|
|
92
|
+
console.log('end response', endResponse)
|
|
93
|
+
if (endResponse.redirect_url) {
|
|
94
|
+
window.location.replace(endResponse.redirect_url);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If no redirect occurred, show the NodeResults:
|
|
99
|
+
nodePlayer.showConsoleMessageOverlay(
|
|
100
|
+
'NodeResults',
|
|
101
|
+
nodeResultsBuffer
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
</script>
|
|
105
|
+
</head>
|
|
106
|
+
<body></body>
|
|
107
|
+
</html>
|
|
108
|
+
|
|
109
|
+
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from typing import Self, Annotated, Union, Literal
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
from nodekit._internal.models.fields import SHA256, MimeType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# %%
|
|
11
|
+
class BaseAssetLink(pydantic.BaseModel, ABC):
|
|
12
|
+
mime_type: MimeType
|
|
13
|
+
sha256: SHA256
|
|
14
|
+
asset_url: pydantic.AnyHttpUrl
|
|
15
|
+
|
|
16
|
+
@pydantic.model_validator(mode='after')
|
|
17
|
+
def check_url(self) -> Self:
|
|
18
|
+
"""
|
|
19
|
+
Validate that the URL ends with the expected file extension
|
|
20
|
+
"""
|
|
21
|
+
extension = mimetypes.guess_extension(type=self.mime_type, strict=True)
|
|
22
|
+
if not extension:
|
|
23
|
+
raise ValueError(f"Could not determine file extension for mime type {self.mime_type}.")
|
|
24
|
+
|
|
25
|
+
if not str(self.asset_url).endswith(extension):
|
|
26
|
+
raise ValueError(f"AssetLink {self.asset_url} does not end with the expected file extension {extension}.")
|
|
27
|
+
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# %%
|
|
32
|
+
class ImageLink(BaseAssetLink):
|
|
33
|
+
mime_type: Literal['image/png'] = 'image/png'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# %%
|
|
37
|
+
AssetLink = Annotated[
|
|
38
|
+
Union[
|
|
39
|
+
ImageLink,
|
|
40
|
+
# Add other AssetLink types here as needed
|
|
41
|
+
],
|
|
42
|
+
pydantic.Field(discriminator='mime_type')
|
|
43
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import Literal, Annotated
|
|
4
|
+
|
|
5
|
+
import pydantic
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# %%
|
|
9
|
+
def _ensure_monetary_amount_precision(value: str) -> str:
|
|
10
|
+
SubcentMonetaryAmountAdapter = pydantic.TypeAdapter(
|
|
11
|
+
Annotated[Decimal, pydantic.Field(decimal_places=5)]
|
|
12
|
+
)
|
|
13
|
+
d = SubcentMonetaryAmountAdapter.validate_python(value)
|
|
14
|
+
return str(d)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
MonetaryAmountUsd = Annotated[
|
|
18
|
+
str,
|
|
19
|
+
pydantic.Field(description='An arbitrary amount of money in USD, including negative amounts, represented as a string with at most five decimal places, e.g., "1.00001".'),
|
|
20
|
+
pydantic.AfterValidator(_ensure_monetary_amount_precision)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# %%
|
|
25
|
+
def _ensure_payable_monetary_amount(value: str) -> str:
|
|
26
|
+
PayableMonetaryAmountAdapter = pydantic.TypeAdapter(
|
|
27
|
+
Annotated[Decimal, pydantic.Field(decimal_places=5)]
|
|
28
|
+
)
|
|
29
|
+
d = PayableMonetaryAmountAdapter.validate_python(value)
|
|
30
|
+
return str(d)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PayableMonetaryAmountUsd = Annotated[
|
|
34
|
+
str,
|
|
35
|
+
pydantic.Field(description='A semi-positive amount of money in USD that is payable to a worker, represented as a string with at most two decimal places, e.g., "1.00". This amount must be at least "0.01".'),
|
|
36
|
+
pydantic.AfterValidator(_ensure_payable_monetary_amount)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# %% Timestamps
|
|
41
|
+
def ensure_utc(t: datetime.datetime) -> datetime.datetime:
|
|
42
|
+
# Ensures that a datetime is timezone-aware and in UTC.
|
|
43
|
+
if t.tzinfo is None:
|
|
44
|
+
raise ValueError(f"Datetime must be timezone-aware: {t}")
|
|
45
|
+
return t.astimezone(datetime.timezone.utc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DatetimeUTC = Annotated[
|
|
49
|
+
datetime.datetime,
|
|
50
|
+
pydantic.Field(description='A timezone-aware datetime in UTC.'),
|
|
51
|
+
pydantic.AfterValidator(ensure_utc)
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# %%
|
|
55
|
+
SHA256 = Annotated[str, pydantic.Field(pattern=r'^[a-f0-9]{64}$')]
|
|
56
|
+
|
|
57
|
+
MimeType = Literal[
|
|
58
|
+
'image/png',
|
|
59
|
+
# Add other supported mime types here as needed
|
|
60
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import pydantic
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DslModel(pydantic.BaseModel):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NullParameters(DslModel):
|
|
10
|
+
"""
|
|
11
|
+
A sentinel model for *_parameter fields which do not require specification.
|
|
12
|
+
"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NullValue(DslModel):
|
|
17
|
+
"""
|
|
18
|
+
A sentinel model for *_value fields which do not require specification.
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from typing import Dict, Any, Annotated, Union, List, Literal
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from nodekit._internal.models.node_engine.fields import SensorId
|
|
7
|
+
from nodekit._internal.models.fields import (
|
|
8
|
+
MonetaryAmountUsd,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# %%
|
|
13
|
+
class BaseBonusRule(pydantic.BaseModel, ABC):
|
|
14
|
+
bonus_rule_type: str
|
|
15
|
+
bonus_rule_parameters: Dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConstantBonusRule(BaseBonusRule):
|
|
19
|
+
"""
|
|
20
|
+
A bonus rule that applies a bonus whenever a particular Sensor is triggered, regardless of the Action's value.
|
|
21
|
+
"""
|
|
22
|
+
bonus_rule_type: Literal['ConstantBonusRule'] = 'ConstantBonusRule'
|
|
23
|
+
|
|
24
|
+
class Parameters(pydantic.BaseModel):
|
|
25
|
+
"""
|
|
26
|
+
Parameters for the ConstantBonusRule.
|
|
27
|
+
"""
|
|
28
|
+
sensor_id: SensorId = pydantic.Field(
|
|
29
|
+
description='The ID of the sensor to which this bonus rule applies.'
|
|
30
|
+
)
|
|
31
|
+
bonus_amount_usd: MonetaryAmountUsd = pydantic.Field(
|
|
32
|
+
description='The change in bonus amount to apply when the sensor is triggered. Can be positive or negative.'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
bonus_rule_parameters: Parameters
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# %%
|
|
39
|
+
BonusRule = Annotated[
|
|
40
|
+
Union[ConstantBonusRule],
|
|
41
|
+
pydantic.Field(
|
|
42
|
+
discriminator='bonus_rule_type',
|
|
43
|
+
description='The type of bonus rule to apply.'
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# %%
|
|
49
|
+
class BonusPolicy(pydantic.BaseModel):
|
|
50
|
+
bonus_rules: List[BonusRule] = pydantic.Field(default_factory=list)
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
from nodekit._internal.models.node_engine.base import DslModel
|
|
7
|
+
from nodekit._internal.models.node_engine.fields import BoardRectangle, BoardLocation, Timespan, CardId
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseCard(DslModel, ABC):
|
|
12
|
+
"""
|
|
13
|
+
Cards are the atomic elements which constitute a single Node.
|
|
14
|
+
Cards are spatially and temporally bound on the Board.
|
|
15
|
+
Some Cards may have Sensors attached to them, which listen for Participant behavior, and emits an Action when triggered.
|
|
16
|
+
"""
|
|
17
|
+
# Identifiers
|
|
18
|
+
card_id: CardId = pydantic.Field(default_factory=uuid4)
|
|
19
|
+
card_type: str
|
|
20
|
+
card_parameters: Any
|
|
21
|
+
# Spatial
|
|
22
|
+
card_shape: BoardRectangle
|
|
23
|
+
card_location: BoardLocation
|
|
24
|
+
# Temporal
|
|
25
|
+
card_timespan: Timespan
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Literal, Annotated, Union, List
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
from nodekit._internal.models.node_engine.base import NullParameters, DslModel
|
|
6
|
+
from nodekit._internal.models.node_engine.cards.base import BaseCard
|
|
7
|
+
from nodekit._internal.models.node_engine.fields import TextContent, ColorHexString
|
|
8
|
+
from nodekit._internal.models.assets.base import ImageLink
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# %% Concrete card classes
|
|
12
|
+
class FixationPointCard(BaseCard):
|
|
13
|
+
card_type: Literal['FixationPointCard'] = 'FixationPointCard'
|
|
14
|
+
card_parameters: NullParameters = pydantic.Field(default_factory=NullParameters, frozen=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# %%
|
|
18
|
+
class MarkdownPagesCard(BaseCard):
|
|
19
|
+
class Parameters(DslModel):
|
|
20
|
+
pages: List[TextContent] = pydantic.Field(
|
|
21
|
+
description='A list of MarkdownContent objects representing the text content on the pages to be displayed.'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
card_type: Literal['MarkdownPagesCard'] = 'MarkdownPagesCard'
|
|
25
|
+
card_parameters: Parameters
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# %%
|
|
29
|
+
class ImageCard(BaseCard):
|
|
30
|
+
class Parameters(DslModel):
|
|
31
|
+
image_link: ImageLink
|
|
32
|
+
|
|
33
|
+
card_type: Literal['ImageCard'] = 'ImageCard'
|
|
34
|
+
card_parameters: Parameters
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# %%
|
|
38
|
+
class TextCard(BaseCard):
|
|
39
|
+
class Parameters(DslModel):
|
|
40
|
+
content: TextContent
|
|
41
|
+
background_color: ColorHexString = pydantic.Field(
|
|
42
|
+
default='#E6E6E6',
|
|
43
|
+
description='The background color of the TextCard in hexadecimal format.'
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
card_type: Literal['TextCard'] = 'TextCard'
|
|
47
|
+
card_parameters: Parameters
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# %%
|
|
51
|
+
Card = Annotated[
|
|
52
|
+
Union[
|
|
53
|
+
FixationPointCard,
|
|
54
|
+
ImageCard,
|
|
55
|
+
TextCard,
|
|
56
|
+
MarkdownPagesCard,
|
|
57
|
+
# Add other Card types here as needed
|
|
58
|
+
],
|
|
59
|
+
pydantic.Field(discriminator='card_type')
|
|
60
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from nodekit._internal.models.node_engine.fields import Timespan
|
|
2
|
+
from nodekit._internal.models.node_engine.base import NullParameters
|
|
3
|
+
from abc import ABC
|
|
4
|
+
import pydantic
|
|
5
|
+
from typing import TypeVar, Literal, Any, Annotated, Union
|
|
6
|
+
|
|
7
|
+
T = TypeVar('T', bound=str)
|
|
8
|
+
P = TypeVar('P', bound=pydantic.BaseModel)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseEffect(pydantic.BaseModel, ABC):
|
|
12
|
+
effect_type: T
|
|
13
|
+
effect_parameters: Any
|
|
14
|
+
effect_timespan: Timespan = pydantic.Field(default_factory=lambda: Timespan(start_time_msec=0, end_time_msec=None))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HidePointerEffect(BaseEffect):
|
|
18
|
+
"""
|
|
19
|
+
Effect to hide the pointer during a timespan.
|
|
20
|
+
"""
|
|
21
|
+
effect_type: Literal['HidePointerEffect'] = 'HidePointerEffect'
|
|
22
|
+
effect_parameters: NullParameters = pydantic.Field(default_factory=NullParameters)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Effect = Annotated[
|
|
26
|
+
Union[HidePointerEffect],
|
|
27
|
+
pydantic.Field(
|
|
28
|
+
discriminator='effect_type',
|
|
29
|
+
)
|
|
30
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from typing import Literal, Annotated, TypeAlias
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
|
|
6
|
+
SpatialSize = Annotated[float, pydantic.Field(
|
|
7
|
+
strict=True, ge=0, le=1, description='A spatial size relative to the smaller extent of the board (width or height, whichever is smaller). For example, a value of 0.5 corresponds to half the smaller extent of the board.'
|
|
8
|
+
)]
|
|
9
|
+
SpatialPoint = Annotated[float, pydantic.Field(strict=True, ge=-0.5, le=0.5)]
|
|
10
|
+
TimeDurationMsec = Annotated[int, pydantic.Field(strict=True, ge=0, description='A duration of time in milliseconds.')]
|
|
11
|
+
TimePointMsec = Annotated[int, pydantic.Field(strict=True, ge=0, description='A point in time relative to some start time in milliseconds.')]
|
|
12
|
+
CurrencyCode = Literal['USD']
|
|
13
|
+
MarkdownString = str
|
|
14
|
+
|
|
15
|
+
CardId = UUID
|
|
16
|
+
SensorId = UUID
|
|
17
|
+
NodeId = UUID
|
|
18
|
+
|
|
19
|
+
# Alpha-supporting hex RGB color string:
|
|
20
|
+
ColorHexString = Annotated[
|
|
21
|
+
str,
|
|
22
|
+
pydantic.Field(
|
|
23
|
+
pattern=r'^#(?:[0-9a-fA-F]{3}){1,2}$',
|
|
24
|
+
min_length=7,
|
|
25
|
+
max_length=9,
|
|
26
|
+
)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TextContent(pydantic.BaseModel):
|
|
31
|
+
text: MarkdownString
|
|
32
|
+
text_color: ColorHexString = '#000000'
|
|
33
|
+
font_size: SpatialSize = 0.0175
|
|
34
|
+
justification_horizontal: Literal['left', 'center', 'right'] = 'left'
|
|
35
|
+
justification_vertical: Literal['top', 'center', 'bottom'] = 'top'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BoardRectangle(pydantic.BaseModel):
|
|
39
|
+
"""
|
|
40
|
+
Describes a rectangle on the Board, in Board units.
|
|
41
|
+
"""
|
|
42
|
+
width: SpatialSize
|
|
43
|
+
height: SpatialSize
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BoardLocation(pydantic.BaseModel):
|
|
47
|
+
"""
|
|
48
|
+
Describes the location of a point on the Board. The coordinates of that location are given under a
|
|
49
|
+
coordinate system where:
|
|
50
|
+
- (0,0) is the center of the Board.
|
|
51
|
+
- A unit of 1 corresponds to the *smaller* extent of the Board (the full width of the Board or the full height of the Board; whichever is smaller.).
|
|
52
|
+
- A positive increase in the x-dimension is rightwards.
|
|
53
|
+
- A positive increase in the y-dimension is upwards.
|
|
54
|
+
"""
|
|
55
|
+
x: SpatialPoint
|
|
56
|
+
y: SpatialPoint
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Timespan(pydantic.BaseModel):
|
|
60
|
+
start_time_msec: TimePointMsec = pydantic.Field(
|
|
61
|
+
description="The start time of this Timespan relative to the NodePlay start, in milliseconds."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
end_time_msec: TimePointMsec | None = pydantic.Field(
|
|
65
|
+
description="The time, relative to NodePlay start, when the time span ends, in milliseconds. If None, the time span is open-ended and continues until the end of NodePlay.",
|
|
66
|
+
default=None,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def check_if_subset_of_other(self, timespan_other: 'Timespan'):
|
|
70
|
+
"""
|
|
71
|
+
Check if this Timespan is a subset of another Timespan.
|
|
72
|
+
"""
|
|
73
|
+
if self.start_time_msec < timespan_other.start_time_msec:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
if self.end_time_msec is not None and timespan_other.end_time_msec is not None:
|
|
77
|
+
if self.end_time_msec > timespan_other.end_time_msec:
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def is_finite(self) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Check if the Timespan is finite (i.e., has a defined end time).
|
|
84
|
+
"""
|
|
85
|
+
return self.end_time_msec is not None
|