From eb829088b35e34f880b14db0212ed295eaf035f5 Mon Sep 17 00:00:00 2001 From: shiyi20060618-cmd <202430841089@mail.scut.edu.cn> Date: Fri, 3 Jul 2026 23:11:58 +0800 Subject: [PATCH] fix: normalize mixed datetime inputs --- .../background/task/screen-monitor-task.ts | 2 +- .../context_capture/vault_document_monitor.py | 14 +- .../generation/smart_tip_generator.py | 17 +- .../processor/screenshot_processor.py | 45 ++- opencontext/managers/consumption_manager.py | 27 +- opencontext/server/context_operations.py | 9 +- opencontext/utils/__init__.py | 11 +- opencontext/utils/datetime_utils.py | 51 +++ tests/test_datetime_normalization.py | 313 ++++++++++++++++++ 9 files changed, 435 insertions(+), 54 deletions(-) create mode 100644 opencontext/utils/datetime_utils.py create mode 100644 tests/test_datetime_normalization.py diff --git a/frontend/src/main/background/task/screen-monitor-task.ts b/frontend/src/main/background/task/screen-monitor-task.ts index 0b70a6f2..958eb24b 100644 --- a/frontend/src/main/background/task/screen-monitor-task.ts +++ b/frontend/src/main/background/task/screen-monitor-task.ts @@ -205,7 +205,7 @@ class ScreenMonitorTask extends ScheduleNextTask { const data = { path: url, window: type === 'screen' ? 'screen' : '', - create_time: createTime.format('YYYY-MM-DD HH:mm:ss'), + create_time: createTime.toISOString(), app: type === 'window' ? 'window' : '' } const res = await axios.post(`http://127.0.0.1:${getBackendPort()}/api/add_screenshot`, data) diff --git a/opencontext/context_capture/vault_document_monitor.py b/opencontext/context_capture/vault_document_monitor.py index 8e186b51..bd1d1fe7 100644 --- a/opencontext/context_capture/vault_document_monitor.py +++ b/opencontext/context_capture/vault_document_monitor.py @@ -10,7 +10,6 @@ import threading import time -from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Set @@ -18,6 +17,7 @@ from opencontext.models.context import RawContextProperties from opencontext.models.enums import ContentFormat, ContextSource from opencontext.storage.global_storage import get_storage +from opencontext.utils.datetime_utils import now_local, parse_local_datetime from opencontext.utils.logging_utils import get_logger logger = get_logger(__name__) @@ -63,7 +63,7 @@ def _initialize_impl(self, config: Dict[str, Any]) -> bool: self._monitor_interval = config.get("monitor_interval", 5) # Set initial scan time to current time - self._last_scan_time = datetime.now() + self._last_scan_time = now_local() logger.info( f"Vault document monitoring component initialized successfully, monitor interval: {self._monitor_interval}s" @@ -164,7 +164,7 @@ def _scan_existing_documents(self): "event_type": "existing", "vault_id": doc["id"], "document_data": doc, - "timestamp": datetime.now(), + "timestamp": now_local(), } with self._event_lock: @@ -180,7 +180,7 @@ def _scan_vault_changes(self): """Scan changes in the vaults table""" try: # Get recent documents (based on created_at and updated_at) - current_time = datetime.now() + current_time = now_local() documents = self._storage.get_vaults(limit=100, offset=0, is_deleted=False) new_documents = [] @@ -188,9 +188,9 @@ def _scan_vault_changes(self): for doc in documents: vault_id = doc["id"] - created_at = datetime.fromisoformat(doc["created_at"].replace("Z", "+00:00")) + created_at = parse_local_datetime(doc["created_at"]) updated_at = ( - datetime.fromisoformat(doc["updated_at"].replace("Z", "+00:00")) + parse_local_datetime(doc["updated_at"]) if doc.get("updated_at") else created_at ) @@ -267,7 +267,7 @@ def _create_context_from_event(self, event: Dict[str, Any]) -> Optional[RawConte source=ContextSource.VAULT, content_format=ContentFormat.TEXT, content_text=doc.get("title", "") + doc.get("summary", "") + doc.get("content", ""), - create_time=datetime.fromisoformat(doc["created_at"].replace("Z", "+00:00")), + create_time=parse_local_datetime(doc["created_at"]), filter_path=self._get_document_path(doc), additional_info={ "vault_id": vault_id, diff --git a/opencontext/context_consumption/generation/smart_tip_generator.py b/opencontext/context_consumption/generation/smart_tip_generator.py index 852c201c..c5855eef 100644 --- a/opencontext/context_consumption/generation/smart_tip_generator.py +++ b/opencontext/context_consumption/generation/smart_tip_generator.py @@ -21,6 +21,7 @@ from opencontext.storage.base_storage import DocumentData from opencontext.storage.global_storage import get_storage from opencontext.tools.tool_definitions import ALL_TOOL_DEFINITIONS +from opencontext.utils.datetime_utils import now_local, parse_local_datetime from opencontext.utils.logging_utils import get_logger logger = get_logger(__name__) @@ -90,7 +91,7 @@ def _analyze_activity_patterns(self, hours: int = 6) -> Dict[str, Any]: """Analyze activity patterns to find content that needs a reminder.""" try: # Calculate the time range - end_time = datetime.datetime.now() + end_time = now_local() start_time = end_time - datetime.timedelta(hours=hours) # Query recent activity records @@ -151,12 +152,8 @@ def _analyze_activity_patterns(self, hours: int = 6) -> Dict[str, Any]: time_diffs = [] for i in range(1, len(activities)): try: - prev_time = datetime.datetime.fromisoformat( - activities[i - 1]["end_time"].replace("Z", "+00:00") - ) - curr_time = datetime.datetime.fromisoformat( - activities[i]["start_time"].replace("Z", "+00:00") - ) + prev_time = parse_local_datetime(activities[i - 1]["end_time"]) + curr_time = parse_local_datetime(activities[i]["start_time"]) diff = (curr_time - prev_time).total_seconds() / 60 # Convert to minutes time_diffs.append(diff) except Exception: @@ -179,7 +176,7 @@ def _analyze_activity_patterns(self, hours: int = 6) -> Dict[str, Any]: def _get_recent_tips(self, days: int = 1) -> List[Dict[str, Any]]: """Get recent tips to avoid repetition.""" try: - end_time = datetime.datetime.now() + end_time = now_local() today_start = end_time.replace(hour=0, minute=0, second=0, microsecond=0) start_time = today_start - datetime.timedelta(days=days - 1) @@ -256,7 +253,7 @@ def _generate_intelligent_tip_with_patterns( # Format time information start_time_str = datetime.datetime.fromtimestamp(start_time).strftime("%H:%M:%S") end_time_str = datetime.datetime.fromtimestamp(end_time).strftime("%H:%M:%S") - current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + current_time = now_local().strftime("%Y-%m-%d %H:%M:%S") # Build the user prompt user_prompt = user_prompt_template.format( @@ -345,7 +342,7 @@ def cleanup_old_tips(self, keep_hours: int = 48): keep_hours: The number of hours to keep, default is 48 hours. """ try: - cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=keep_hours) + cutoff_time = now_local() - datetime.timedelta(hours=keep_hours) cutoff_timestamp = int(cutoff_time.timestamp()) # Get all tips diff --git a/opencontext/context_processing/processor/screenshot_processor.py b/opencontext/context_processing/processor/screenshot_processor.py index c18fcba6..9c977001 100644 --- a/opencontext/context_processing/processor/screenshot_processor.py +++ b/opencontext/context_processing/processor/screenshot_processor.py @@ -31,6 +31,7 @@ from opencontext.monitoring.monitor import record_processing_error from opencontext.storage.global_storage import get_storage from opencontext.tools.tool_definitions import ALL_TOOL_DEFINITIONS +from opencontext.utils.datetime_utils import ensure_local_naive, now_local, parse_local_datetime from opencontext.utils.image import calculate_phash, resize_image from opencontext.utils.json_parser import parse_json_from_response from opencontext.utils.logging_utils import get_logger @@ -266,11 +267,11 @@ async def _process_vlm_single(self, raw_context: RawContextProperties) -> List[P } ] - time_now = datetime.datetime.now() + time_now = now_local() user_prompt = user_prompt_template.format( current_date=time_now.isoformat(), current_timestamp=int(time_now.timestamp()), - current_timezone=time_now.tzname(), + current_timezone=datetime.datetime.now().astimezone().tzname(), ) content.insert(0, {"type": "text", "text": user_prompt}) system_prompt = system_prompt.format( @@ -362,7 +363,7 @@ async def _merge_items_with_llm(self, context_type: ContextType, new_items: List # Process results and build ProcessedContext objects result_contexts = [] - now = datetime.datetime.now() + now = now_local() if context_type.value not in self._processed_cache: self._processed_cache[context_type.value] = {} need_to_del_ids = [] @@ -383,10 +384,24 @@ async def _merge_items_with_llm(self, context_type: ContextType, new_items: List logger.error(f"No valid items for merged_ids: {merged_ids}") continue - min_create_time = min((i.properties.create_time for i in items_to_merge if i.properties.create_time), default=now) + min_create_time = min( + ( + ensure_local_naive(i.properties.create_time) + for i in items_to_merge + if i.properties.create_time + ), + default=now, + ) event_time = self._parse_event_time_str( data.get("event_time"), - max((i.properties.event_time for i in items_to_merge if i.properties.event_time), default=now) + max( + ( + ensure_local_naive(i.properties.event_time) + for i in items_to_merge + if i.properties.event_time + ), + default=now, + ), ) all_raw_props = [] @@ -458,23 +473,21 @@ async def _parse_single_context(self, item: ProcessedContext, entities: List[Dic item.extracted_data.entities = entities_results return item - def _parse_event_time_str(self, time_str: Optional[str], default: datetime.datetime) -> datetime.datetime: + def _parse_event_time_str( + self, time_str: Optional[str], default: datetime.datetime + ) -> datetime.datetime: """Parse ISO time string, return default if invalid.""" if not time_str or time_str == "null": - return default + return ensure_local_naive(default) try: if any( invalid_char in time_str for invalid_char in ["xxxx", "XXXX", "TZ:TZ", "TZ", "????"] ): - event_time = default - elif time_str.endswith("Z"): - time_str = time_str[:-1] + "+00:00" - event_time = datetime.datetime.fromisoformat(time_str) - return event_time - return default + return ensure_local_naive(default) + return parse_local_datetime(time_str) except (ValueError, TypeError): - return default + return ensure_local_naive(default) def _safe_int(self, value, default=0) -> int: """Safely convert to int.""" @@ -531,7 +544,7 @@ async def batch_process(self, raw_contexts: List[RawContextProperties]) -> List[ return newly_processed_contexts def _create_processed_context(self, analysis: Dict[str, Any], raw_context: RawContextProperties = None) -> ProcessedContext: - now = datetime.datetime.now() + now = now_local() if not analysis: logger.warning(f"Skipping incomplete item: {analysis}") return None @@ -565,7 +578,7 @@ def _create_processed_context(self, analysis: Dict[str, Any], raw_context: RawCo properties=ContextProperties( raw_properties=[raw_context] if raw_context else [], source=ContextSource.SCREENSHOT, - create_time=raw_context.create_time if raw_context else now, + create_time=ensure_local_naive(raw_context.create_time) if raw_context else now, update_time=now, event_time=event_time, enable_merge=True, diff --git a/opencontext/managers/consumption_manager.py b/opencontext/managers/consumption_manager.py index 0aebbc5a..c19955c3 100755 --- a/opencontext/managers/consumption_manager.py +++ b/opencontext/managers/consumption_manager.py @@ -23,6 +23,7 @@ from opencontext.managers.event_manager import EventType, get_event_manager from opencontext.models.enums import VaultType from opencontext.storage.global_storage import get_storage +from opencontext.utils.datetime_utils import now_local, parse_local_datetime from opencontext.utils.logging_utils import get_logger logger = get_logger(__name__) @@ -116,7 +117,7 @@ def _should_generate(self, task_type: str) -> bool: if last_time is None: return True - elapsed = (datetime.now() - last_time).total_seconds() + elapsed = (now_local() - last_time).total_seconds() interval = self._task_intervals.get(task_type, 0) should_generate = elapsed >= interval return should_generate @@ -171,7 +172,7 @@ def stop_scheduled_tasks(self): def _calculate_seconds_until_daily_time(self, target_time_str: str) -> float: try: hour, minute = map(int, target_time_str.split(":")) - now = datetime.now() + now = now_local() target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) if target <= now: @@ -192,10 +193,10 @@ def _get_last_report_time(self) -> datetime: if reports: created_at_str = reports[0]["created_at"] if created_at_str: - return datetime.fromisoformat(created_at_str.replace("Z", "+00:00")) - return datetime.now() + return parse_local_datetime(created_at_str) + return now_local() except Exception: - return datetime.now() + return now_local() def _start_report_timer(self): """Start daily report timer""" @@ -213,7 +214,7 @@ def check_and_generate_daily_report(): if not self._activity_generator or not self._task_enabled.get("report", True): return try: - now = datetime.now() + now = now_local() today = now.date() hour, minute = map(int, self._daily_report_time.split(":")) @@ -256,7 +257,7 @@ def generate_activity(): try: if self._should_generate("activity"): - end_time = int(datetime.now().timestamp()) + end_time = int(now_local().timestamp()) last_generation_time = self._last_generation_time("activity") start_time = ( int(last_generation_time.timestamp()) @@ -266,7 +267,7 @@ def generate_activity(): self._real_activity_monitor.generate_realtime_activity_summary( start_time, end_time ) - self._last_generation_times["activity"] = datetime.now() + self._last_generation_times["activity"] = now_local() except Exception as e: logger.exception(f"Failed to generate activity record: {e}") @@ -295,7 +296,7 @@ def generate_tips(): try: if self._should_generate("tips"): - end_time = int(datetime.now().timestamp()) + end_time = int(now_local().timestamp()) last_generation_time = self._last_generation_time("tips") start_time = ( int(last_generation_time.timestamp()) @@ -303,7 +304,7 @@ def generate_tips(): else end_time - self._task_intervals.get("tips", 60 * 60) ) self._smart_tip_generator.generate_smart_tip(start_time, end_time) - self._last_generation_times["tips"] = datetime.now() + self._last_generation_times["tips"] = now_local() except Exception as e: logger.exception(f"Failed to generate smart tip: {e}") @@ -332,7 +333,7 @@ def generate_todos(): try: if self._should_generate("todos"): - end_time = int(datetime.now().timestamp()) + end_time = int(now_local().timestamp()) last_generation_time = self._last_generation_time("todos") start_time = ( int(last_generation_time.timestamp()) @@ -342,7 +343,7 @@ def generate_todos(): self._smart_todo_manager.generate_todo_tasks( start_time=start_time, end_time=end_time ) - self._last_generation_times["todos"] = datetime.now() + self._last_generation_times["todos"] = now_local() except Exception as e: logger.exception(f"Failed to generate smart todo: {e}") @@ -521,4 +522,4 @@ def reset_statistics(self) -> None: self._statistics["total_queries"] = 0 self._statistics["total_contexts_consumed"] = 0 - self._statistics["errors"] = 0 \ No newline at end of file + self._statistics["errors"] = 0 diff --git a/opencontext/server/context_operations.py b/opencontext/server/context_operations.py index ca9bd53b..6f452f03 100644 --- a/opencontext/server/context_operations.py +++ b/opencontext/server/context_operations.py @@ -21,6 +21,7 @@ get_context_type_options, ) from opencontext.storage.global_storage import get_storage +from opencontext.utils.datetime_utils import now_local, parse_local_datetime from opencontext.utils.logging_utils import get_logger logger = get_logger(__name__) @@ -87,14 +88,10 @@ def add_screenshot( try: screenshot_format = os.path.splitext(path)[1][1:] - # Handle ISO format time string, supports Z suffix - if create_time.endswith("Z"): - create_time = create_time[:-1] + "+00:00" - raw_context = RawContextProperties( source=ContextSource.SCREENSHOT, content_format=ContentFormat.IMAGE, - create_time=datetime.datetime.fromisoformat(create_time), + create_time=parse_local_datetime(create_time), content_path=path, additional_info={ "window": window, @@ -135,7 +132,7 @@ def add_document(self, file_path: str, context_processor_callback) -> Optional[s raw_context = RawContextProperties( source=ContextSource.LOCAL_FILE, content_format=ContentFormat.FILE, - create_time=datetime.datetime.now(), + create_time=now_local(), object_id=object_id, content_path=str(path), additional_info={ diff --git a/opencontext/utils/__init__.py b/opencontext/utils/__init__.py index 20ca2346..682676ba 100755 --- a/opencontext/utils/__init__.py +++ b/opencontext/utils/__init__.py @@ -8,6 +8,15 @@ """ from opencontext.utils.file_utils import ensure_dir, get_file_extension, is_binary_file +from opencontext.utils.datetime_utils import ensure_local_naive, now_local, parse_local_datetime from opencontext.utils.logging_utils import setup_logging -__all__ = ["setup_logging", "ensure_dir", "get_file_extension", "is_binary_file"] +__all__ = [ + "setup_logging", + "ensure_dir", + "get_file_extension", + "is_binary_file", + "ensure_local_naive", + "parse_local_datetime", + "now_local", +] diff --git a/opencontext/utils/datetime_utils.py b/opencontext/utils/datetime_utils.py new file mode 100644 index 00000000..ced4f367 --- /dev/null +++ b/opencontext/utils/datetime_utils.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. +# SPDX-License-Identifier: Apache-2.0 + +""" +Datetime helpers for normalizing mixed timestamp inputs. + +MineContext currently stores a large amount of legacy timestamps as naive local +datetime strings, while some newer inputs arrive as ISO8601 strings with an +explicit offset or ``Z`` suffix. The rest of the codebase still compares +against naive ``datetime.now()`` values in many places, so the safest +compatibility strategy is to normalize all incoming values to local naive +datetimes before they enter comparison-heavy logic. +""" + +from __future__ import annotations + +from datetime import datetime, timezone, tzinfo + + +def get_local_timezone() -> tzinfo: + """Return the current local timezone, falling back to UTC.""" + return datetime.now().astimezone().tzinfo or timezone.utc + + +def ensure_local_naive(value: datetime) -> datetime: + """Normalize a datetime to a local naive value for safe comparisons.""" + if value.tzinfo is None: + return value + return value.astimezone(get_local_timezone()).replace(tzinfo=None) + + +def parse_local_datetime(value: str | datetime) -> datetime: + """Parse a datetime-like value and normalize it to a local naive datetime.""" + if isinstance(value, datetime): + return ensure_local_naive(value) + + if not isinstance(value, str): + raise TypeError(f"Unsupported datetime value type: {type(value)!r}") + + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + + return ensure_local_naive(datetime.fromisoformat(text)) + + +def now_local() -> datetime: + """Return the current local time as a naive datetime.""" + return ensure_local_naive(datetime.now().astimezone()) diff --git a/tests/test_datetime_normalization.py b/tests/test_datetime_normalization.py new file mode 100644 index 00000000..2cdcfb73 --- /dev/null +++ b/tests/test_datetime_normalization.py @@ -0,0 +1,313 @@ +import importlib.util +import sys +import tempfile +import threading +import types +import unittest +from datetime import datetime, timezone +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class DummyLogger: + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + +class FlexibleModel: + _counter = 0 + + def __init__(self, **kwargs): + if "id" not in kwargs: + FlexibleModel._counter += 1 + kwargs["id"] = f"obj-{FlexibleModel._counter}" + self.__dict__.update(kwargs) + + +def ensure_package(name: str) -> types.ModuleType: + module = sys.modules.get(name) + if module is None: + module = types.ModuleType(name) + module.__path__ = [] + sys.modules[name] = module + return module + + +def load_module(module_name: str, path: Path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def install_base_stubs(): + for package_name in [ + "opencontext", + "opencontext.utils", + "opencontext.models", + "opencontext.storage", + "opencontext.server", + "opencontext.context_capture", + "opencontext.context_processing", + "opencontext.context_processing.processor", + "opencontext.llm", + "opencontext.monitoring", + "opencontext.tools", + "opencontext.config", + ]: + ensure_package(package_name) + + logging_utils = types.ModuleType("opencontext.utils.logging_utils") + logging_utils.get_logger = lambda _name: DummyLogger() + sys.modules["opencontext.utils.logging_utils"] = logging_utils + + models_context = types.ModuleType("opencontext.models.context") + models_context.RawContextProperties = FlexibleModel + models_context.ContextProperties = FlexibleModel + models_context.ExtractedData = FlexibleModel + models_context.Vectorize = FlexibleModel + models_context.ProcessedContext = FlexibleModel + sys.modules["opencontext.models.context"] = models_context + + enum_like = lambda value: types.SimpleNamespace(value=value) + models_enums = types.ModuleType("opencontext.models.enums") + models_enums.ContentFormat = types.SimpleNamespace(IMAGE="image", FILE="file", TEXT="text") + models_enums.ContextSource = types.SimpleNamespace( + SCREENSHOT="screenshot", + LOCAL_FILE="local_file", + TEXT="text", + VAULT="vault", + ) + models_enums.ContextType = types.SimpleNamespace(ACTIVITY_CONTEXT=enum_like("activity_context")) + models_enums.get_context_type_options = lambda: [] + models_enums.get_context_type_descriptions_for_extraction = lambda: "" + models_enums.get_context_type_for_analysis = lambda value: enum_like(value) + sys.modules["opencontext.models.enums"] = models_enums + + models_context.ContextSource = models_enums.ContextSource + models_context.ContentFormat = models_enums.ContentFormat + + global_storage = types.ModuleType("opencontext.storage.global_storage") + global_storage.get_storage = lambda: None + sys.modules["opencontext.storage.global_storage"] = global_storage + + return models_context, models_enums + + +def load_datetime_utils(): + install_base_stubs() + return load_module( + "opencontext.utils.datetime_utils", + REPO_ROOT / "opencontext" / "utils" / "datetime_utils.py", + ) + + +def load_context_operations_module(): + install_base_stubs() + load_datetime_utils() + return load_module( + "tests_context_operations", + REPO_ROOT / "opencontext" / "server" / "context_operations.py", + ) + + +def load_vault_monitor_module(): + install_base_stubs() + load_datetime_utils() + + context_capture = types.ModuleType("opencontext.context_capture") + + class BaseCaptureComponent: + def __init__(self, name: str, description: str, source_type: str): + self._name = name + self._description = description + self._source_type = source_type + self._config = {} + self._callback = None + + context_capture.BaseCaptureComponent = BaseCaptureComponent + sys.modules["opencontext.context_capture"] = context_capture + + return load_module( + "tests_vault_document_monitor", + REPO_ROOT / "opencontext" / "context_capture" / "vault_document_monitor.py", + ) + + +def load_screenshot_processor_module(): + _, models_enums = install_base_stubs() + load_datetime_utils() + + base_processor = types.ModuleType("opencontext.context_processing.processor.base_processor") + + class BaseContextProcessor: + def __init__(self, config): + self.config = config + + base_processor.BaseContextProcessor = BaseContextProcessor + sys.modules["opencontext.context_processing.processor.base_processor"] = base_processor + + entity_processor = types.ModuleType("opencontext.context_processing.processor.entity_processor") + entity_processor.refresh_entities = lambda *args, **kwargs: None + entity_processor.validate_and_clean_entities = lambda entities: entities + sys.modules["opencontext.context_processing.processor.entity_processor"] = entity_processor + + embedding_client = types.ModuleType("opencontext.llm.global_embedding_client") + embedding_client.do_vectorize_async = lambda *args, **kwargs: None + sys.modules["opencontext.llm.global_embedding_client"] = embedding_client + + vlm_client = types.ModuleType("opencontext.llm.global_vlm_client") + vlm_client.generate_with_messages_async = lambda *args, **kwargs: None + sys.modules["opencontext.llm.global_vlm_client"] = vlm_client + + monitoring_module = types.ModuleType("opencontext.monitoring") + monitoring_module.increment_data_count = lambda *args, **kwargs: None + monitoring_module.increment_recording_stat = lambda *args, **kwargs: None + monitoring_module.record_processing_metrics = lambda *args, **kwargs: None + sys.modules["opencontext.monitoring"] = monitoring_module + + monitoring_monitor = types.ModuleType("opencontext.monitoring.monitor") + monitoring_monitor.record_processing_error = lambda *args, **kwargs: None + sys.modules["opencontext.monitoring.monitor"] = monitoring_monitor + + tools_module = types.ModuleType("opencontext.tools.tool_definitions") + tools_module.ALL_TOOL_DEFINITIONS = [] + sys.modules["opencontext.tools.tool_definitions"] = tools_module + + image_module = types.ModuleType("opencontext.utils.image") + image_module.calculate_phash = lambda *args, **kwargs: "0" + image_module.resize_image = lambda *args, **kwargs: None + sys.modules["opencontext.utils.image"] = image_module + + json_parser = types.ModuleType("opencontext.utils.json_parser") + json_parser.parse_json_from_response = lambda response: response + sys.modules["opencontext.utils.json_parser"] = json_parser + + config_module = types.ModuleType("opencontext.config.global_config") + config_module.get_prompt_group = lambda _name: {"system": "", "user": ""} + config_module.get_config = lambda _name=None: {} + sys.modules["opencontext.config.global_config"] = config_module + + return load_module( + "tests_screenshot_processor", + REPO_ROOT / "opencontext" / "context_processing" / "processor" / "screenshot_processor.py", + ) + + +class DatetimeNormalizationTests(unittest.TestCase): + def test_parse_local_datetime_converts_aware_input_to_local_naive(self): + datetime_utils = load_datetime_utils() + + parsed = datetime_utils.parse_local_datetime("2026-04-03T03:35:38Z") + expected = ( + datetime(2026, 4, 3, 3, 35, 38, tzinfo=timezone.utc) + .astimezone(datetime_utils.get_local_timezone()) + .replace(tzinfo=None) + ) + + self.assertEqual(parsed, expected) + self.assertIsNone(parsed.tzinfo) + self.assertEqual( + datetime_utils.parse_local_datetime("2026-04-03 11:35:38"), + datetime(2026, 4, 3, 11, 35, 38), + ) + + def test_context_operations_add_screenshot_normalizes_iso_timestamp(self): + datetime_utils = load_datetime_utils() + module = load_context_operations_module() + + captured = {} + + def callback(raw_context): + captured["raw_context"] = raw_context + return True + + with tempfile.NamedTemporaryFile() as tmp_file: + result = module.ContextOperations().add_screenshot( + tmp_file.name, + "screen", + "2026-04-03T03:35:38Z", + "window", + callback, + ) + + self.assertIsNone(result) + self.assertIn("raw_context", captured) + expected = datetime_utils.parse_local_datetime("2026-04-03T03:35:38Z") + self.assertEqual(captured["raw_context"].create_time, expected) + self.assertIsNone(captured["raw_context"].create_time.tzinfo) + + def test_vault_document_monitor_scans_aware_strings_without_typeerror(self): + datetime_utils = load_datetime_utils() + module = load_vault_monitor_module() + + monitor = module.VaultDocumentMonitor() + monitor._storage = types.SimpleNamespace( + get_vaults=lambda **kwargs: [ + { + "id": 7, + "title": "Doc", + "summary": "Summary", + "content": "Body", + "tags": "", + "document_type": "Report", + "created_at": "2026-04-03T03:35:38Z", + "updated_at": "2026-04-03T03:35:38Z", + } + ] + ) + monitor._last_scan_time = datetime_utils.parse_local_datetime("2026-04-03T03:30:00Z") + monitor._processed_vault_ids = set() + monitor._document_events = [] + monitor._event_lock = threading.RLock() + monitor._last_activity_time = None + + monitor._scan_vault_changes() + + self.assertEqual(len(monitor._document_events), 1) + self.assertIn(7, monitor._processed_vault_ids) + self.assertIsNone(monitor._document_events[0]["timestamp"].tzinfo) + + context = monitor._create_context_from_event(monitor._document_events[0]) + self.assertEqual( + context.create_time, + datetime_utils.parse_local_datetime("2026-04-03T03:35:38Z"), + ) + self.assertIsNone(context.create_time.tzinfo) + + def test_screenshot_processor_normalizes_event_time_before_comparison(self): + datetime_utils = load_datetime_utils() + module = load_screenshot_processor_module() + models_context = sys.modules["opencontext.models.context"] + models_enums = sys.modules["opencontext.models.enums"] + + processor = object.__new__(module.ScreenshotProcessor) + raw_context = models_context.RawContextProperties( + source=models_enums.ContextSource.SCREENSHOT, + content_format=models_enums.ContentFormat.IMAGE, + create_time=datetime(2026, 4, 3, 11, 34, 49), + content_path="/tmp/fake.png", + additional_info={}, + ) + + context = processor._create_processed_context( + { + "context_type": "semantic_context", + "event_time": "2026-04-03T03:35:38Z", + "title": "Title", + "summary": "Summary", + }, + raw_context, + ) + + expected_event_time = datetime_utils.parse_local_datetime("2026-04-03T03:35:38Z") + self.assertEqual(context.properties.event_time, expected_event_time) + self.assertIsNone(context.properties.event_time.tzinfo) + self.assertLessEqual(context.properties.event_time, datetime(2026, 4, 3, 11, 40, 0)) + + +if __name__ == "__main__": + unittest.main()