diff --git a/src/tac/context/memory.py b/src/tac/context/memory.py index f723ce0..066b69e 100644 --- a/src/tac/context/memory.py +++ b/src/tac/context/memory.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Any, TypeVar import httpx @@ -350,12 +351,16 @@ async def create_observation( """ Create a new observation in Conversation Memory. + The Observations endpoint is a batch-create API, so the observation is + wrapped in an ``observations`` array. + Args: profile_id: Profile ID to associate observation with - content: Observation content (the summary text or extracted fact) + content: Observation content (an extracted fact or note about the profile) source: Source system identifier (default: "conversation-intelligence") conversation_ids: List of conversation IDs this observation relates to - occurred_at: Optional timestamp when observation occurred (ISO 8601 format) + occurred_at: Timestamp when observation occurred (ISO 8601 format). + Defaults to the current time when omitted or blank. Returns: Dict with created observation details @@ -366,14 +371,17 @@ async def create_observation( endpoint = f"/v1/Stores/{self.store_id}/Profiles/{profile_id}/Observations" url = f"{self.base_url}{endpoint}" - payload: dict[str, Any] = { + observation: dict[str, Any] = { "content": content, "source": source, } if conversation_ids: - payload["conversationIds"] = conversation_ids - if occurred_at: - payload["occurredAt"] = occurred_at + observation["conversationIds"] = conversation_ids + if occurred_at is None or not occurred_at.strip(): + occurred_at = datetime.now(timezone.utc).isoformat() + observation["occurredAt"] = occurred_at + + payload: dict[str, Any] = {"observations": [observation]} try: async with self._get_client() as client: diff --git a/src/tac/core/config.py b/src/tac/core/config.py index a643a28..d1d34d1 100644 --- a/src/tac/core/config.py +++ b/src/tac/core/config.py @@ -24,10 +24,6 @@ class ConversationIntelligenceConfig(BaseModel): configuration_id: str = Field( description="Conversation Intelligence Configuration ID", ) - observation_operator_sid: str | None = Field( - default=None, - description="Operator SID for observation extraction (e.g., LY...)", - ) summary_operator_sid: str | None = Field( default=None, description="Operator SID for summary extraction (e.g., LY...)", @@ -37,7 +33,6 @@ class ConversationIntelligenceConfig(BaseModel): json_schema_extra={ "example": { "configuration_id": "your_ci_configuration_id", - "observation_operator_sid": "LY00000000000000000000000000000001", "summary_operator_sid": "LY00000000000000000000000000000002", } }, @@ -45,18 +40,22 @@ class ConversationIntelligenceConfig(BaseModel): @classmethod def from_env(cls) -> "ConversationIntelligenceConfig | None": - """Create ConversationIntelligenceConfig from CONVERSATION_INTELLIGENCE_* env vars.""" + """Create ConversationIntelligenceConfig from CONVERSATION_INTELLIGENCE_* env vars. + + Blank or whitespace-only operator SIDs are treated as not configured. + """ configuration_id = os.environ.get("CONVERSATION_INTELLIGENCE_CONFIGURATION_ID") if not configuration_id: return None + summary_operator_sid = os.environ.get("CONVERSATION_INTELLIGENCE_SUMMARY_OPERATOR_SID") + if summary_operator_sid is not None and not summary_operator_sid.strip(): + summary_operator_sid = None + return cls( configuration_id=configuration_id, - observation_operator_sid=os.environ.get( - "CONVERSATION_INTELLIGENCE_OBSERVATION_OPERATOR_SID" - ), - summary_operator_sid=os.environ.get("CONVERSATION_INTELLIGENCE_SUMMARY_OPERATOR_SID"), + summary_operator_sid=summary_operator_sid, ) @@ -372,7 +371,6 @@ def _normalize_voice_public_domain(cls, v: str | None) -> str | None: }, "conversation_intelligence_config": { "configuration_id": "GAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "observation_operator_sid": "LYxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "summary_operator_sid": "LYyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", }, } @@ -427,8 +425,6 @@ def from_env(cls) -> "TACConfig": - `CONVERSATION_INTELLIGENCE_CONFIGURATION_ID`: CI Service configuration ID for webhook filtering - - `CONVERSATION_INTELLIGENCE_OBSERVATION_OPERATOR_SID`: Operator SID for - observation extraction - `CONVERSATION_INTELLIGENCE_SUMMARY_OPERATOR_SID`: Operator SID for summary extraction """ diff --git a/src/tac/intelligence/operator_result_processor.py b/src/tac/intelligence/operator_result_processor.py index cdcf058..7f80070 100644 --- a/src/tac/intelligence/operator_result_processor.py +++ b/src/tac/intelligence/operator_result_processor.py @@ -82,39 +82,6 @@ def _generate_content(operator_result: "OperatorResult") -> str | None: return json.dumps(result) if result else None -def _parse_observations_content(json_content: str) -> list[str]: - """ - Parse JSON content to extract individual observation contents. - - Expected format: {"observations": [{"content": "..."}, {"content": "..."}]} - Fallback: Treat raw content as single observation - - Args: - json_content: The JSON content string - - Returns: - List of observation content strings - """ - try: - payload = json.loads(json_content) - if isinstance(payload, dict) and "observations" in payload: - observations = payload["observations"] - if isinstance(observations, list): - contents = [] - for obs in observations: - if isinstance(obs, dict) and obs.get("content"): - contents.append(str(obs["content"])) - if contents: - return contents - except (json.JSONDecodeError, TypeError): - # If parsing fails or the structure is unexpected, fall back to treating - # the entire input as a single summary in the return statement below. - pass - - # Fallback: treat entire content as single observation - return [json_content] if json_content else [] - - def _parse_summaries_content(json_content: str) -> list[str]: """ Parse JSON content to extract individual summary contents. @@ -161,11 +128,11 @@ class OperatorResultProcessor: """Processor for Conversation Intelligence webhook events. This processor handles incoming CI webhook payloads, validates them, - and creates observations or summaries in Conversation Memory based on the event type. + and creates conversation summaries in Conversation Memory based on the event type. Events are filtered by: - Configuration ID matching the provided config - - Operator SID matching observation or summary operator SID in config + - Operator SID matching the summary operator SID in config Example usage: ```python @@ -176,16 +143,15 @@ class OperatorResultProcessor: conversation_memory_client = MemoryClient(...) config = ConversationIntelligenceConfig( configuration_id="GA...", - observation_operator_sid="LY...", summary_operator_sid="LY...", ) processor = OperatorResultProcessor(conversation_memory_client, config) result = await processor.process_event(webhook_payload) - if result.success: - print(f"Created {result.created_count} {result.event_type}(s)") - elif result.skipped: + if result.skipped: print(f"Skipped: {result.skip_reason}") + elif result.success: + print(f"Created {result.created_count} {result.event_type}(s)") else: print(f"Error: {result.error}") ``` @@ -200,7 +166,7 @@ def __init__( Initialize the CI event processor. Args: - conversation_memory_client: MemoryClient instance for creating observations/summaries + conversation_memory_client: MemoryClient instance for creating summaries config: ConversationIntelligenceConfig for filtering events by configuration ID and operator SIDs """ @@ -216,8 +182,9 @@ async def process_event(self, payload: dict[str, Any]) -> OperatorProcessingResu 1. Parses the payload into an OperatorResultEvent (Pydantic validates required fields) 2. Applies filtering logic based on intelligence configuration ID and operator SIDs 3. Iterates over operator_results array - 4. For each operator result: extracts profile IDs, generates content - 5. Creates observations or summaries in Conversation Memory + 4. For each operator result: filters by operator SID, then generates + content and extracts profile IDs + 5. Creates conversation summaries in Conversation Memory Args: payload: The raw webhook payload dictionary @@ -316,54 +283,23 @@ async def _process_operator_result( Returns: OperatorProcessingResult with status and count """ - # Extract profile IDs from this operator result - profile_ids = _extract_profile_ids(operator_result) - if not profile_ids: - error_msg = f"No profile IDs found in operator result {operator_result.id}" - self.logger.error(error_msg) - return OperatorProcessingResult( - success=False, - error=error_msg, - ) + # Filter by operator SID first so unrelated operators are skipped rather + # than failing the whole event. + operator_id = operator_result.operator.id if operator_result.operator else None - # Generate content from result - content = _generate_content(operator_result) - if not content: - error_msg = f"Failed to generate content from operator result {operator_result.id}" - self.logger.error(error_msg) + if self.config.summary_operator_sid is None: + # No summary operator configured - nothing to process + self.logger.debug("Skipping operator - summary operator SID not configured") return OperatorProcessingResult( - success=False, - error=error_msg, + success=True, + skipped=True, + skip_reason="Summary operator SID not configured", ) - # Determine event type by operator SID and process - operator_id = operator_result.operator.id if operator_result.operator else None - - # Check if operator matches configured SIDs - if ( - self.config.observation_operator_sid - and operator_id == self.config.observation_operator_sid - ): - # Process as observation (SID match) - return await self._process_observation_event( - event=event, - operator_result=operator_result, - content=content, - profile_ids=profile_ids, - ) - elif self.config.summary_operator_sid and operator_id == self.config.summary_operator_sid: - # Process as summary (SID match) - return await self._process_summary_event( - event=event, - operator_result=operator_result, - content=content, - profile_ids=profile_ids, - ) - else: - # SIDs are configured but don't match - skip + if operator_id != self.config.summary_operator_sid: + # Configured summary SID doesn't match this operator - skip self.logger.debug( f"Skipping operator - SID {operator_id} doesn't match " - f"observation ({self.config.observation_operator_sid}) or " f"summary ({self.config.summary_operator_sid})" ) return OperatorProcessingResult( @@ -372,71 +308,31 @@ async def _process_operator_result( skip_reason="Operator SID mismatch", ) - async def _process_observation_event( - self, - event: OperatorResultEvent, - operator_result: "OperatorResult", - content: str, - profile_ids: list[str], - ) -> OperatorProcessingResult: - """ - Process an observation event by creating observations in Conversation Memory. - - Args: - event: The parent webhook event (for conversation_id) - operator_result: The individual operator result - content: The generated content string - profile_ids: List of profile IDs to create observations for - - Returns: - OperatorProcessingResult with status and count - """ - # Parse observations from content - observation_contents = _parse_observations_content(content) - - if not observation_contents: - self.logger.info(f"No observations to create from operator result {operator_result.id}") + # Generate content from result + content = _generate_content(operator_result) + if not content: + self.logger.info(f"Skipping operator result {operator_result.id} with empty content") return OperatorProcessingResult( success=True, - event_type="observation", skipped=True, - skip_reason="No observation content found", + skip_reason="Operator result has empty content", ) - created_count = 0 - errors: list[str] = [] - - # Create observations for each profile - for profile_id in profile_ids: - for obs_content in observation_contents: - try: - await self.conversation_memory_client.create_observation( - profile_id=profile_id, - content=obs_content, - source="conversation-intelligence", - conversation_ids=[event.conversation_id], - occurred_at=operator_result.date_created, - ) - created_count += 1 - except Exception as e: - error_msg = f"Failed to create observation for profile {profile_id}: {e}" - self.logger.error(error_msg) - errors.append(error_msg) - - if created_count == 0 and errors: + # Extract profile IDs from this operator result + profile_ids = _extract_profile_ids(operator_result) + if not profile_ids: + self.logger.info(f"No profile IDs found in operator result {operator_result.id}") return OperatorProcessingResult( - success=False, - event_type="observation", - error="; ".join(errors), + success=True, + skipped=True, + skip_reason="No profile IDs found in operator result execution details", ) - self.logger.info( - f"Created {created_count} observation(s) from operator result {operator_result.id}" - ) - return OperatorProcessingResult( - success=True, - event_type="observation", - created_count=created_count, + return await self._process_summary_event( + event=event, + operator_result=operator_result, + content=content, + profile_ids=profile_ids, ) async def _process_summary_event( diff --git a/tests/test_config_from_env.py b/tests/test_config_from_env.py index 5f6cf6f..b975e22 100644 --- a/tests/test_config_from_env.py +++ b/tests/test_config_from_env.py @@ -3,7 +3,40 @@ import pytest from pydantic import ValidationError -from tac.core.config import TACConfig, TwilioMemoryConfig +from tac.core.config import ConversationIntelligenceConfig, TACConfig, TwilioMemoryConfig + + +class TestConversationIntelligenceConfigFromEnv: + """Test suite for ConversationIntelligenceConfig.from_env() factory method.""" + + def test_from_env_without_configuration_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test from_env() returns None when no configuration ID is set.""" + monkeypatch.delenv("CONVERSATION_INTELLIGENCE_CONFIGURATION_ID", raising=False) + + assert ConversationIntelligenceConfig.from_env() is None + + def test_from_env_reads_summary_operator_sid(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test from_env() reads the summary operator SID.""" + monkeypatch.setenv("CONVERSATION_INTELLIGENCE_CONFIGURATION_ID", "GA123") + monkeypatch.setenv("CONVERSATION_INTELLIGENCE_SUMMARY_OPERATOR_SID", "LY123") + + config = ConversationIntelligenceConfig.from_env() + + assert config is not None + assert config.summary_operator_sid == "LY123" + + @pytest.mark.parametrize("blank", ["", " "]) + def test_from_env_normalizes_blank_summary_operator_sid( + self, monkeypatch: pytest.MonkeyPatch, blank: str + ) -> None: + """Test blank/whitespace summary operator SID is treated as not configured.""" + monkeypatch.setenv("CONVERSATION_INTELLIGENCE_CONFIGURATION_ID", "GA123") + monkeypatch.setenv("CONVERSATION_INTELLIGENCE_SUMMARY_OPERATOR_SID", blank) + + config = ConversationIntelligenceConfig.from_env() + + assert config is not None + assert config.summary_operator_sid is None class TestTwilioMemoryConfigFromEnv: diff --git a/tests/test_intelligence.py b/tests/test_intelligence.py index 9e129df..4d05948 100644 --- a/tests/test_intelligence.py +++ b/tests/test_intelligence.py @@ -9,7 +9,6 @@ OperatorResultProcessor, _extract_profile_ids, _generate_content, - _parse_observations_content, _parse_summaries_content, ) from tac.models.intelligence import ( @@ -33,7 +32,7 @@ VALID_PROFILE_ID = "mem_profile_01234567890123456789abcdef" VALID_CONV_ID = "conv_conversation_01234567890123456789abcdef" VALID_CONFIG_ID = "GA00000000000000000000000000000000" -VALID_OBSERVATION_OPERATOR_SID = "LY00000000000000000000000000000001" +VALID_NON_SUMMARY_OPERATOR_SID = "LY00000000000000000000000000000001" VALID_SUMMARY_OPERATOR_SID = "LY00000000000000000000000000000002" @@ -44,7 +43,7 @@ def make_valid_event( conversation_id: str = VALID_CONV_ID, memory_store_id: str = VALID_STORE_ID, configuration_id: str = VALID_CONFIG_ID, - operator_id: str = VALID_OBSERVATION_OPERATOR_SID, + operator_id: str = VALID_NON_SUMMARY_OPERATOR_SID, result: Any = None, ) -> dict[str, Any]: """Create a valid webhook event payload for testing. @@ -95,7 +94,7 @@ def make_valid_event( def make_operator_result( operator_friendly_name: str = "Observation Extractor", - operator_id: str = VALID_OBSERVATION_OPERATOR_SID, + operator_id: str = VALID_NON_SUMMARY_OPERATOR_SID, profile_id: str = VALID_PROFILE_ID, result: Any = None, ) -> dict[str, Any]: @@ -346,29 +345,7 @@ def test_generate_content_text(self): class TestContentParsing: - """Test content parsing for observations and summaries.""" - - def test_parse_observations_array_format(self): - """Test parsing observations array format.""" - json_content = '{"observations": [{"content": "obs1"}, {"content": "obs2"}]}' - contents = _parse_observations_content(json_content) - assert len(contents) == 2 - assert contents[0] == "obs1" - assert contents[1] == "obs2" - - def test_parse_observations_fallback(self): - """Test observations fallback to raw content.""" - json_content = "Raw observation content" - contents = _parse_observations_content(json_content) - assert len(contents) == 1 - assert contents[0] == "Raw observation content" - - def test_parse_observations_empty_array(self): - """Test parsing empty observations array.""" - json_content = '{"observations": []}' - contents = _parse_observations_content(json_content) - assert len(contents) == 1 # Fallback to raw content - assert contents[0] == '{"observations": []}' + """Test summary content parsing.""" def test_parse_summaries_array_format(self): """Test parsing summaries array format.""" @@ -411,7 +388,6 @@ def ci_config(self): return ConversationIntelligenceConfig( configuration_id=VALID_CONFIG_ID, - observation_operator_sid=VALID_OBSERVATION_OPERATOR_SID, summary_operator_sid=VALID_SUMMARY_OPERATOR_SID, ) @@ -421,28 +397,60 @@ def processor(self, mock_memory_client, ci_config): return OperatorResultProcessor(mock_memory_client, ci_config) @pytest.mark.asyncio - async def test_process_event_requires_profile_ids(self, processor): - """Test that profile IDs are required.""" - payload = make_valid_event() + async def test_process_event_skips_when_no_profile_ids(self, processor): + """A matching operator result without profile IDs is skipped, not failed.""" + payload = make_valid_event( + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, + result={"payload": '{"summary": "Test summary"}'}, + ) # executionDetails is now nested inside operatorResults payload["operatorResults"][0]["executionDetails"]["participants"] = [] result = await processor.process_event(payload) - assert result.success is False - assert "No profile IDs" in result.error + assert result.success is True + assert result.skipped is True + assert "No profile IDs found" in result.skip_reason + + @pytest.mark.asyncio + async def test_process_event_skips_when_content_empty(self, processor): + """A matching operator result with empty content is skipped, not failed.""" + payload = make_valid_event( + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, + result={"payload": ""}, + ) + result = await processor.process_event(payload) + + assert result.success is True + assert result.skipped is True + assert "empty content" in result.skip_reason + + @pytest.mark.asyncio + async def test_non_summary_operator_does_not_fail_event(self, processor, mock_memory_client): + """A non-summary operator result skips even when it has no profile IDs.""" + payload = make_valid_event() + payload["operatorResults"][0]["executionDetails"]["participants"] = [] + result = await processor.process_event(payload) + + assert result.success is True + assert result.skipped is True + assert "Operator SID mismatch" in result.skip_reason + mock_memory_client.create_conversation_summaries.assert_not_called() @pytest.mark.asyncio - async def test_process_observation_event_success(self, processor, mock_memory_client): - """Test successful observation event processing.""" + async def test_observation_operator_event_no_longer_creates_observations( + self, processor, mock_memory_client + ): + """Observation auto-creation was removed; matching events are skipped.""" payload = make_valid_event( result={"payload": '{"observations": [{"content": "Test observation"}]}'} ) result = await processor.process_event(payload) assert result.success is True - assert result.event_type == "observation" - assert result.created_count == 1 - mock_memory_client.create_observation.assert_called_once() + assert result.skipped is True + mock_memory_client.create_observation.assert_not_called() @pytest.mark.asyncio async def test_process_summary_event_success(self, processor, mock_memory_client): @@ -483,11 +491,31 @@ async def test_process_event_skips_mismatched_operator_sid(self, processor): assert result.skipped is True assert "Operator SID mismatch" in result.skip_reason + @pytest.mark.asyncio + async def test_process_event_skips_when_summary_operator_not_configured( + self, mock_memory_client + ): + """Test skip reason when no summary operator SID is configured.""" + from tac.core.config import ConversationIntelligenceConfig + + config = ConversationIntelligenceConfig(configuration_id=VALID_CONFIG_ID) + processor = OperatorResultProcessor(mock_memory_client, config) + payload = make_valid_event() + result = await processor.process_event(payload) + + assert result.success is True + assert result.skipped is True + assert "Summary operator SID not configured" in result.skip_reason + @pytest.mark.asyncio async def test_process_event_multiple_customer_profiles(self, processor, mock_memory_client): - """Test processing with multiple CUSTOMER profiles.""" + """Test processing with multiple CUSTOMER profiles (summary path).""" second_profile_id = "mem_profile_11234567890123456789abcdef" - payload = make_valid_event(result={"payload": '{"observations": [{"content": "Test"}]}'}) + payload = make_valid_event( + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, + result={"payload": '{"summary": "Test summary"}'}, + ) # executionDetails is now nested inside operatorResults payload["operatorResults"][0]["executionDetails"]["participants"] = [ { @@ -505,29 +533,21 @@ async def test_process_event_multiple_customer_profiles(self, processor, mock_me assert result.success is True assert result.created_count == 2 # One for each CUSTOMER profile - assert mock_memory_client.create_observation.call_count == 2 + assert mock_memory_client.create_conversation_summaries.call_count == 2 @pytest.mark.asyncio - async def test_process_event_multiple_observations(self, processor, mock_memory_client): - """Test processing multiple observations from one event.""" + async def test_process_event_api_error_handling(self, processor, mock_memory_client): + """Test handling of API errors (summary path).""" + mock_memory_client.create_conversation_summaries.side_effect = Exception("API Error") payload = make_valid_event( - result={"payload": '{"observations": [{"content": "Obs1"}, {"content": "Obs2"}]}'} + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, + result={"payload": '{"summary": "Test summary"}'}, ) result = await processor.process_event(payload) - assert result.success is True - assert result.created_count == 2 - assert mock_memory_client.create_observation.call_count == 2 - - @pytest.mark.asyncio - async def test_process_event_api_error_handling(self, processor, mock_memory_client): - """Test handling of API errors.""" - mock_memory_client.create_observation.side_effect = Exception("API Error") - payload = make_valid_event(result={"payload": '{"observations": [{"content": "Test"}]}'}) - result = await processor.process_event(payload) - assert result.success is False - assert "Failed to create observation" in result.error + assert "Failed to create summaries" in result.error @pytest.mark.asyncio async def test_process_event_invalid_payload(self, processor): @@ -542,8 +562,10 @@ async def test_process_event_invalid_payload(self, processor): async def test_process_event_uses_memory_store_id(self, processor, mock_memory_client): """Test that memory_store_id is used when available.""" payload = make_valid_event( + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, memory_store_id=VALID_STORE_ID, - result={"payload": '{"observations": [{"content": "Test"}]}'}, + result={"payload": '{"summary": "Test summary"}'}, ) result = await processor.process_event(payload) @@ -555,9 +577,11 @@ async def test_process_event_extracts_store_id_from_friendly_name( ): """Test fallback to extracting store ID from friendly name.""" payload = make_valid_event( + operator_friendly_name="Summary Extractor", + operator_id=VALID_SUMMARY_OPERATOR_SID, friendly_name=f"CONVERSATION_MEMORY_{VALID_STORE_ID}", memory_store_id=None, - result={"payload": '{"observations": [{"content": "Test"}]}'}, + result={"payload": '{"summary": "Test summary"}'}, ) # Remove memory_store_id del payload["memoryStoreId"] diff --git a/tests/test_profile_retrieval.py b/tests/test_profile_retrieval.py index 5691d98..a555837 100644 --- a/tests/test_profile_retrieval.py +++ b/tests/test_profile_retrieval.py @@ -619,3 +619,145 @@ async def test_create_profile_surfaces_http_errors(self) -> None: with pytest.raises(httpx.HTTPError, match="boom"): await client.create_profile(traits={"Contact": {"phone": "+1"}}) + + +class TestCreateObservation: + """HTTP-level tests for MemoryClient.create_observation.""" + + @pytest.mark.asyncio + async def test_create_observation_wraps_body_in_observations_array(self) -> None: + client = MemoryClient( + store_id="mem_store_01abc", + api_key="SK123", + api_secret="secret", + ) + + mock_response = Mock() + mock_response.json.return_value = {"message": "Observations creation accepted"} + mock_response.raise_for_status = Mock() + + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_http + + await client.create_observation( + profile_id="mem_profile_01canonical", + content="Customer prefers email", + conversation_ids=["conv_1"], + occurred_at="2025-01-15T10:30:45+00:00", + ) + + mock_http.post.assert_called_once_with( + "https://memory.twilio.com/v1/Stores/mem_store_01abc" + "/Profiles/mem_profile_01canonical/Observations", + json={ + "observations": [ + { + "content": "Customer prefers email", + "source": "conversation-intelligence", + "conversationIds": ["conv_1"], + "occurredAt": "2025-01-15T10:30:45+00:00", + } + ] + }, + ) + + @pytest.mark.asyncio + async def test_create_observation_defaults_occurred_at(self) -> None: + client = MemoryClient( + store_id="mem_store_01abc", + api_key="SK123", + api_secret="secret", + ) + + mock_response = Mock() + mock_response.json.return_value = {"message": "accepted"} + mock_response.raise_for_status = Mock() + + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_http + + await client.create_observation( + profile_id="mem_profile_01canonical", + content="Customer prefers email", + ) + + _, kwargs = mock_http.post.call_args + body = kwargs["json"] + assert "observations" in body + assert len(body["observations"]) == 1 + observation = body["observations"][0] + assert observation["occurredAt"] # defaulted to current time + # Valid ISO 8601 timestamp + from datetime import datetime + + datetime.fromisoformat(observation["occurredAt"]) + + @pytest.mark.asyncio + async def test_create_observation_defaults_blank_occurred_at(self) -> None: + """A blank/whitespace occurred_at is replaced by a valid default timestamp.""" + from datetime import datetime + + client = MemoryClient( + store_id="mem_store_01abc", + api_key="SK123", + api_secret="secret", + ) + + for blank in ("", " "): + mock_response = Mock() + mock_response.json.return_value = {"message": "accepted"} + mock_response.raise_for_status = Mock() + + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_http + + await client.create_observation( + profile_id="mem_profile_01canonical", + content="Customer prefers email", + occurred_at=blank, + ) + + _, kwargs = mock_http.post.call_args + occurred_at = kwargs["json"]["observations"][0]["occurredAt"] + assert occurred_at + assert occurred_at.strip() != "" + # Replaced with a valid, parseable ISO 8601 timestamp + datetime.fromisoformat(occurred_at) + + @pytest.mark.asyncio + async def test_create_observation_preserves_provided_occurred_at(self) -> None: + """A non-empty ISO timestamp is sent unchanged.""" + client = MemoryClient( + store_id="mem_store_01abc", + api_key="SK123", + api_secret="secret", + ) + + mock_response = Mock() + mock_response.json.return_value = {"message": "accepted"} + mock_response.raise_for_status = Mock() + + mock_http = AsyncMock() + mock_http.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client_class.return_value.__aenter__.return_value = mock_http + + await client.create_observation( + profile_id="mem_profile_01canonical", + content="Customer prefers email", + occurred_at="2025-01-15T10:30:45+00:00", + ) + + _, kwargs = mock_http.post.call_args + observation = kwargs["json"]["observations"][0] + assert observation["occurredAt"] == "2025-01-15T10:30:45+00:00"