diff --git a/packages/narada-core/pyproject.toml b/packages/narada-core/pyproject.toml index e088258..b9ac9e6 100644 --- a/packages/narada-core/pyproject.toml +++ b/packages/narada-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "narada-core" -version = "0.1.4" +version = "0.1.5" description = "Code shared by the `narada` and `narada-pyodide` packages." license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-core/src/narada_core/models.py b/packages/narada-core/src/narada_core/models.py index 73095c6..cc5d7e9 100644 --- a/packages/narada-core/src/narada_core/models.py +++ b/packages/narada-core/src/narada_core/models.py @@ -96,6 +96,9 @@ class RemoteDispatchChatHistoryItem(TypedDict): class OperatorActionTraceItem(TypedDict): url: str action: str + startTs: str + endTs: str + durationMs: int class GoToUrlTrace(TypedDict): diff --git a/packages/narada-core/src/narada_core/tracing/model.py b/packages/narada-core/src/narada_core/tracing/model.py index f655cf8..e22890e 100644 --- a/packages/narada-core/src/narada_core/tracing/model.py +++ b/packages/narada-core/src/narada_core/tracing/model.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timedelta from typing import Annotated, Any, Literal from pydantic import ( @@ -29,6 +30,38 @@ def _normalize_agent_type(agent_type: object) -> str: class OperatorActionTraceItem(BaseModel): url: str action: str + start_ts: str = Field(alias="startTs") + end_ts: str = Field(alias="endTs") + duration_ms: NonNegativeInt = Field(alias="durationMs") + + @model_validator(mode="after") + def _check_timestamp_range(self) -> OperatorActionTraceItem: + start_timestamp = _parse_utc_iso_timestamp(self.start_ts, field_name="startTs") + end_timestamp = _parse_utc_iso_timestamp(self.end_ts, field_name="endTs") + if end_timestamp < start_timestamp: + raise ValueError( + f"OperatorActionTraceItem: endTs ({self.end_ts}) must be >= " + f"startTs ({self.start_ts})" + ) + expected_duration_ms = round( + (end_timestamp - start_timestamp).total_seconds() * 1000 + ) + if self.duration_ms != expected_duration_ms: + raise ValueError( + f"OperatorActionTraceItem: durationMs ({self.duration_ms}) must equal " + f"endTs - startTs ({expected_duration_ms})" + ) + return self + + +def _parse_utc_iso_timestamp(value: str, *, field_name: str) -> datetime: + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError(f"{field_name} must be an ISO 8601 timestamp") from error + if timestamp.utcoffset() != timedelta(0): + raise ValueError(f"{field_name} must be a UTC ISO 8601 timestamp") + return timestamp class GoToUrlTrace(BaseModel): diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 8c48183..a6e5c8a 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "narada-pyodide" -version = "0.1.4" +version = "0.1.5" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.1.4", + "narada-core==0.1.5", # Must be a supported version in https://pyodide.org/en/stable/usage/packages-in-pyodide.html "packaging==24.2", ] diff --git a/packages/narada/pyproject.toml b/packages/narada/pyproject.toml index 06dd4f6..6d915f8 100644 --- a/packages/narada/pyproject.toml +++ b/packages/narada/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "narada" -version = "0.2.7" +version = "0.2.8" description = "Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.1.4", + "narada-core==0.1.5", "aiohttp>=3.12.13", "playwright>=1.53.0", "rich>=14.0.0", diff --git a/packages/narada/tests/test_action_trace.py b/packages/narada/tests/test_action_trace.py new file mode 100644 index 0000000..22f2f10 --- /dev/null +++ b/packages/narada/tests/test_action_trace.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import pytest +from narada_core.tracing.model import OperatorActionTraceItem, parse_action_trace +from pydantic import ValidationError + + +def test_parse_operator_action_trace_exposes_timestamps() -> None: + trace = parse_action_trace( + [ + { + "url": "https://example.com", + "action": "Clicked Submit", + "startTs": "2026-07-20T17:00:00.000Z", + "endTs": "2026-07-20T17:00:01.500Z", + "durationMs": 1_500, + } + ] + ) + + item = trace[0] + assert isinstance(item, OperatorActionTraceItem) + assert item.start_ts == "2026-07-20T17:00:00.000Z" + assert item.end_ts == "2026-07-20T17:00:01.500Z" + assert item.duration_ms == 1_500 + + +@pytest.mark.parametrize( + "timestamps", + [ + {"startTs": "2026-07-20T17:00:00.000Z", "durationMs": 1_000}, + {"endTs": "2026-07-20T17:00:01.000Z", "durationMs": 1_000}, + { + "startTs": "2026-07-20T17:00:02.000Z", + "endTs": "2026-07-20T17:00:01.000Z", + "durationMs": 0, + }, + { + "startTs": "not-a-timestamp", + "endTs": "2026-07-20T17:00:01.000Z", + "durationMs": 1_000, + }, + { + "startTs": "2026-07-20T17:00:00", + "endTs": "2026-07-20T17:00:01", + "durationMs": 1_000, + }, + {"startTs": 1_000, "endTs": 2_000, "durationMs": 1_000}, + { + "startTs": "2026-07-20T17:00:00.000Z", + "endTs": "2026-07-20T17:00:01.000Z", + }, + { + "startTs": "2026-07-20T17:00:00.000Z", + "endTs": "2026-07-20T17:00:01.000Z", + "durationMs": 999, + }, + ], +) +def test_operator_action_trace_rejects_invalid_timing( + timestamps: dict[str, str | int], +) -> None: + with pytest.raises(ValidationError): + OperatorActionTraceItem( + url="https://example.com", + action="Clicked Submit", + **timestamps, + ) + + +def test_operator_action_trace_accepts_explicit_utc_offset() -> None: + item = OperatorActionTraceItem( + url="https://example.com", + action="Clicked Submit", + startTs="2026-07-20T17:00:00.000+00:00", + endTs="2026-07-20T17:00:01.000+00:00", + durationMs=1_000, + ) + + assert item.start_ts.endswith("+00:00") diff --git a/uv.lock b/uv.lock index 99e42c4..ad9ba35 100644 --- a/uv.lock +++ b/uv.lock @@ -312,7 +312,7 @@ wheels = [ [[package]] name = "narada" -version = "0.2.7" +version = "0.2.8" source = { editable = "packages/narada" } dependencies = [ { name = "aiohttp" }, @@ -345,7 +345,7 @@ dev = [ [[package]] name = "narada-core" -version = "0.1.4" +version = "0.1.5" source = { editable = "packages/narada-core" } dependencies = [ { name = "pydantic" }, @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.1.4" +version = "0.1.5" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" },