Message Model¶
Represents an individual message within a conversation.
Overview¶
The Message model represents a single message in a conversation, including the role (user/assistant/system), content, timestamp, and parent relationship for tree navigation.
API Reference¶
Message
¶
Bases: BaseModel
Immutable message structure from conversation export (per FR-223, FR-227).
This model represents a single message in an AI conversation, supporting message threading via parent_id references. Messages form a tree structure within conversations, enabling branching dialogue paths.
Immutability
This model is FROZEN - attempting to modify fields will raise ValidationError. Use .model_copy(update={...}) to create modified instances.
Example
from datetime import datetime, UTC
message = Message(
id="msg-001",
content="Hello, world!",
role="user",
timestamp=datetime.now(UTC),
parent_id=None # Root message
)
# Create a reply
reply = Message(
id="msg-002",
content="Hi! How can I help?",
role="assistant",
timestamp=datetime.now(UTC),
parent_id="msg-001" # References parent
)
Role Normalization
The role field is normalized to one of three standard values for
multi-provider consistency. Provider-specific roles are mapped as follows:
OpenAI role mappings: - "user" → "user" (human input) - "assistant" → "assistant" (AI response) - "system" → "system" (system messages) - "tool" → "assistant" (tool execution is assistant action) - unknown roles → "assistant" (safe fallback)
The original provider-specific role is preserved in metadata["original_role"] for debugging and provider-specific workflows.
Attributes:
| Name | Type | Description |
|---|---|---|
id |
str
|
Unique message identifier within conversation (non-empty string) |
content |
str
|
Message text content (may be empty for deleted messages) |
role |
Literal['user', 'assistant', 'system']
|
Normalized message role (user, assistant, or system) |
timestamp |
datetime
|
Message creation time (timezone-aware UTC) |
parent_id |
str | None
|
Parent message ID for threading (None for root messages) |
images |
list[ImageRef]
|
Image attachments extracted from multimodal content (empty for text-only) |
metadata |
dict[str, Any]
|
Provider-specific fields (e.g., original_role, token_count) |
validate_timezone_aware
classmethod
¶
Ensure timestamp is timezone-aware and normalized to UTC.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
v
|
datetime
|
Timestamp value to validate |
required |
Returns:
| Type | Description |
|---|---|
datetime
|
Timezone-aware datetime normalized to UTC |
Raises:
| Type | Description |
|---|---|
ValueError
|
If timestamp is timezone-naive |
Requirements
- FR-244: Timestamps must be timezone-aware
- FR-245: Timestamps normalized to UTC
- FR-246: Validation enforced at parse time
Source code in src/echomine/models/message.py
is_root
¶
Check if message is conversation root (no parent).
Returns:
| Type | Description |
|---|---|
bool
|
True if parent_id is None, False otherwise |
Example
Source code in src/echomine/models/message.py
Usage Examples¶
Basic Access¶
from echomine import OpenAIAdapter
from pathlib import Path
adapter = OpenAIAdapter()
conversation = adapter.get_conversation_by_id(Path("export.json"), "conv-abc123")
# Access messages
for message in conversation.messages:
print(f"[{message.timestamp}] {message.role}: {message.content[:50]}...")
Role Types¶
Messages have normalized roles:
from typing import Literal
# Message.role is Literal["user", "assistant", "system"]
for message in conversation.messages:
if message.role == "user":
print(f"User: {message.content}")
elif message.role == "assistant":
print(f"AI: {message.content}")
elif message.role == "system":
print(f"System: {message.content}")
# No other values possible - type safety guaranteed!
Timestamp Handling¶
All timestamps are timezone-aware UTC datetimes:
from datetime import timezone
for message in conversation.messages:
# Guaranteed to be UTC and timezone-aware
assert message.timestamp.tzinfo == timezone.utc
# Safe to compare and serialize
print(f"{message.timestamp.isoformat()}: {message.content}")
# Convert to local timezone for display
import datetime
local_tz = datetime.datetime.now().astimezone().tzinfo
for msg in conversation.messages:
local_time = msg.timestamp.astimezone(local_tz)
print(f"[{local_time}] {msg.role}: {msg.content[:30]}...")
Parent-Child Relationships¶
Messages are organized in a tree structure:
# Check if message is root (conversation starter)
is_root = message.parent_id is None
# Find message's parent
parent_id = message.parent_id
if parent_id:
parent = next((m for m in conversation.messages if m.id == parent_id), None)
if parent:
print(f"Parent: {parent.content[:50]}...")
# Find message's children
children = [m for m in conversation.messages if m.parent_id == message.id]
print(f"Message has {len(children)} children")
Immutability¶
Messages are frozen (immutable):
from pydantic import ValidationError
# ❌ This raises ValidationError
try:
message.content = "New content"
except ValidationError:
print("Error: Cannot modify frozen model")
# ✅ Create modified copy instead
updated = message.model_copy(update={"content": "New content"})
Validation¶
All fields are strictly validated:
from echomine.models import Message
from datetime import datetime, timezone
from pydantic import ValidationError
# ❌ Invalid: naive timestamp (no timezone)
try:
invalid = Message(
id="msg-123",
content="Hello",
role="user",
timestamp=datetime.now(), # ❌ Not timezone-aware!
parent_id=None
)
except ValidationError as e:
print(f"Error: {e}")
# ✅ Valid: timezone-aware UTC timestamp
valid = Message(
id="msg-123",
content="Hello",
role="user",
timestamp=datetime.now(timezone.utc),
parent_id=None
)
Model Fields¶
Required Fields¶
- id (
str): Unique message identifier (non-empty) - content (
str): Message text content - role (
Literal["user", "assistant", "system"]): Message role (normalized) - timestamp (
datetime): Message timestamp (UTC, timezone-aware)
Optional Fields¶
- parent_id (
str | None): ID of parent message,Nonefor root messages - metadata (
dict[str, Any]): Message-specific metadata (default: empty dict)
Role Normalization¶
All provider-specific roles are normalized to three standard values:
| Provider | Source Role | Normalized Role |
|---|---|---|
| OpenAI | "user" | "user" |
| OpenAI | "assistant" | "assistant" |
| OpenAI | "system" | "system" |
| Anthropic (future) | "human" | "user" |
| Anthropic (future) | "assistant" | "assistant" |
| Google (future) | "user" | "user" |
| Google (future) | "model" | "assistant" |
Timestamp Format¶
All timestamps follow these rules:
- Timezone-aware: Must have
tzinfoset - UTC: Normalized to UTC timezone
- ISO 8601: Serialized as ISO 8601 strings
Example:
from datetime import datetime, timezone
# Create message with current UTC time
message = Message(
id="msg-123",
content="Hello",
role="user",
timestamp=datetime.now(timezone.utc),
parent_id=None
)
# Serialize to ISO 8601
iso_timestamp = message.timestamp.isoformat()
# Output: "2024-01-15T10:30:00+00:00"
Metadata¶
Message-specific metadata (e.g., image attachments, code blocks):
# Access metadata
image_urls = message.metadata.get("image_urls", [])
code_blocks = message.metadata.get("code_blocks", [])
# Check if metadata exists
if "image_urls" in message.metadata:
print(f"Message has {len(message.metadata['image_urls'])} images")
Related Models¶
- Conversation: Container for messages
- Image: Image attachment model (if present in metadata)