From 081722aef4e2152a01585386d238ec55b628daa6 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Thu, 4 Jun 2026 22:10:50 +0800 Subject: [PATCH 1/7] feat: cache repeated image caption results --- .../astrbot/group_chat_context.py | 48 ++++- astrbot/core/astr_main_agent.py | 46 ++++- astrbot/core/config/default.py | 6 + astrbot/core/utils/image_caption_cache.py | 169 ++++++++++++++++++ .../en-US/features/config-metadata.json | 4 + .../ru-RU/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + tests/test_group_chat_context.py | 43 +++++ tests/unit/test_astr_main_agent.py | 41 +++++ 9 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 astrbot/core/utils/image_caption_cache.py create mode 100644 tests/test_group_chat_context.py diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index 7fee3c0df9..aacd802e76 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -12,6 +12,10 @@ from astrbot.api.provider import Provider, ProviderRequest from astrbot.core.agent.message import TextPart from astrbot.core.astrbot_config_mgr import AstrBotConfigManager +from astrbot.core.utils.image_caption_cache import ( + image_caption_cache, + resolve_image_caption_cache_ttl, +) """ Group chat context awareness. @@ -67,6 +71,9 @@ def cfg(self, event: AstrMessageEvent): "image_caption": image_caption, "image_caption_prompt": image_caption_prompt, "image_caption_provider_id": image_caption_provider_id, + "image_caption_cache_ttl": resolve_image_caption_cache_ttl( + cfg.get("provider_settings", {}) + ), "enable_active_reply": enable_active_reply, "ar_method": ar_method, "ar_possibility": ar_possibility, @@ -79,22 +86,44 @@ async def get_image_caption( image_url: str, image_caption_provider_id: str, image_caption_prompt: str, + cache_ttl: int = 0, ) -> str: if not image_caption_provider_id: provider = self.context.get_using_provider() + provider_id = ( + provider.provider_config.get("id", "") + if isinstance(provider, Provider) + else "" + ) else: provider = self.context.get_provider_by_id(image_caption_provider_id) + provider_id = image_caption_provider_id if not provider: - raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商") + raise Exception( + f"Provider `{image_caption_provider_id}` was not found." + ) + if not isinstance(provider, Provider): - raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述") - response = await provider.text_chat( + raise Exception( + f"Provider type is invalid for image captioning: {type(provider)}." + ) + + async def _caption_factory() -> str: + response = await provider.text_chat( + prompt=image_caption_prompt, + session_id=uuid.uuid4().hex, + image_urls=[image_url], + persist=False, + ) + return response.completion_text + + return await image_caption_cache.get_or_create( + provider_id=provider_id, prompt=image_caption_prompt, - session_id=uuid.uuid4().hex, image_urls=[image_url], - persist=False, + ttl_seconds=cache_ttl, + caption_factory=_caption_factory, ) - return response.completion_text async def need_active_reply(self, event: AstrMessageEvent) -> bool: cfg = self.cfg(event) @@ -195,15 +224,16 @@ async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str: try: url = comp.url if comp.url else comp.file if not url: - raise Exception("图片 URL 为空") + raise Exception("Image URL is empty.") caption = await self.get_image_caption( url, cfg["image_caption_provider_id"], cfg["image_caption_prompt"], + cfg["image_caption_cache_ttl"], ) parts.append(f" [Image: {caption}]") except Exception as e: - logger.error(f"获取图片描述失败: {e}") + logger.error(f"Failed to get image caption: {e}") else: parts.append(" [Image]") elif isinstance(comp, At): @@ -212,7 +242,7 @@ async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str: "all", ) if is_at_self: - parts.insert(1, "⚠️[DIRECTED AT YOU] ") + parts.insert(1, "[DIRECTED AT YOU] ") parts.append(f" [At: {comp.name}]") return "".join(parts) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 1c4fd400a0..bd360b47c5 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -96,6 +96,10 @@ get_astrbot_workspaces_path, ) from astrbot.core.utils.file_extract import extract_file_moonshotai +from astrbot.core.utils.image_caption_cache import ( + image_caption_cache, + resolve_image_caption_cache_ttl, +) from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.media_utils import ( IMAGE_COMPRESS_DEFAULT_MAX_SIZE, @@ -583,6 +587,7 @@ async def _request_img_caption( cfg: dict, image_urls: list[str], plugin_context: Context, + prompt: str | None = None, ) -> str: prov = plugin_context.get_provider_by_id(provider_id) if prov is None: @@ -594,16 +599,27 @@ async def _request_img_caption( f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.", ) - img_cap_prompt = cfg.get( + img_cap_prompt = prompt or cfg.get( "image_caption_prompt", "Please describe the image.", ) + cache_ttl = resolve_image_caption_cache_ttl(cfg) logger.debug("Processing image caption with provider: %s", provider_id) - llm_resp = await prov.text_chat( + + async def _caption_factory() -> str: + llm_resp = await prov.text_chat( + prompt=img_cap_prompt, + image_urls=image_urls, + ) + return llm_resp.completion_text + + return await image_caption_cache.get_or_create( + provider_id=provider_id, prompt=img_cap_prompt, image_urls=image_urls, + ttl_seconds=cache_ttl, + caption_factory=_caption_factory, ) - return llm_resp.completion_text async def _ensure_img_caption( @@ -808,13 +824,21 @@ async def _process_quote_message( ) if path and _is_generated_compressed_image_path(path, compress_path): event.track_temporary_local_file(compress_path) - llm_resp = await prov.text_chat( + caption = await _request_img_caption( + prov.provider_config.get("id", img_cap_prov_id or ""), + { + "image_caption_prompt": "Please describe the image content.", + "image_caption_cache_ttl": resolve_image_caption_cache_ttl( + config.provider_settings if config else None + ), + }, + [compress_path], + _QuotedImageCaptionContext(prov), prompt="Please describe the image content.", - image_urls=[compress_path], ) - if llm_resp.completion_text: + if caption: content_parts.append( - f"[Image Caption in quoted message]: {llm_resp.completion_text}" + f"[Image Caption in quoted message]: {caption}" ) else: logger.warning("No provider found for image captioning in quote.") @@ -836,6 +860,14 @@ async def _process_quote_message( req.extra_user_content_parts.append(TextPart(text=quoted_text)) +class _QuotedImageCaptionContext: + def __init__(self, provider: Provider) -> None: + self._provider = provider + + def get_provider_by_id(self, provider_id: str) -> Provider: + return self._provider + + def _append_system_reminders( event: AstrMessageEvent, req: ProviderRequest, diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 22a53bb446..9d21ed023d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -103,6 +103,7 @@ "fallback_chat_models": [], "default_image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", + "image_caption_cache_ttl": 600, "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 "wake_prefix": "", "web_search": False, @@ -3193,6 +3194,11 @@ "description": "图片转述提示词", "type": "text", }, + "provider_settings.image_caption_cache_ttl": { + "description": "Image caption cache TTL (seconds)", + "type": "int", + "hint": "Set to 0 to disable cache", + }, }, "condition": { "provider_settings.enable": True, diff --git a/astrbot/core/utils/image_caption_cache.py b/astrbot/core/utils/image_caption_cache.py new file mode 100644 index 0000000000..c73feab5f4 --- /dev/null +++ b/astrbot/core/utils/image_caption_cache.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import time +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlparse + +from astrbot.core import logger + +DEFAULT_IMAGE_CAPTION_CACHE_TTL = 600 + + +def resolve_image_caption_cache_ttl(config: dict | None) -> int: + if not isinstance(config, dict): + return DEFAULT_IMAGE_CAPTION_CACHE_TTL + + ttl = config.get( + "image_caption_cache_ttl", + DEFAULT_IMAGE_CAPTION_CACHE_TTL, + ) + if isinstance(ttl, bool): + return DEFAULT_IMAGE_CAPTION_CACHE_TTL + try: + return max(int(ttl), 0) + except (TypeError, ValueError): + return DEFAULT_IMAGE_CAPTION_CACHE_TTL + + +@dataclass(slots=True) +class _ImageCaptionCacheEntry: + caption: str + expires_at: float + + +class ImageCaptionCache: + def __init__(self) -> None: + self._entries: dict[str, _ImageCaptionCacheEntry] = {} + self._locks: dict[str, asyncio.Lock] = {} + + def clear(self) -> None: + self._entries.clear() + self._locks.clear() + + async def get_or_create( + self, + *, + provider_id: str, + prompt: str, + image_urls: list[str], + ttl_seconds: int, + caption_factory, + ) -> str: + if ttl_seconds <= 0: + return await caption_factory() + + cache_key = await self._build_cache_key( + provider_id=provider_id, + prompt=prompt, + image_urls=image_urls, + ) + cached_caption = self._get(cache_key) + if cached_caption is not None: + logger.debug( + "Using cached image caption. provider=%s", + provider_id or "", + ) + return cached_caption + + lock = self._locks.setdefault(cache_key, asyncio.Lock()) + async with lock: + cached_caption = self._get(cache_key) + if cached_caption is not None: + logger.debug( + "Using cached image caption after lock wait. provider=%s", + provider_id or "", + ) + return cached_caption + + caption = await caption_factory() + self._entries[cache_key] = _ImageCaptionCacheEntry( + caption=caption, + expires_at=time.monotonic() + ttl_seconds, + ) + self._cleanup_expired_entries() + return caption + + def _get(self, cache_key: str) -> str | None: + entry = self._entries.get(cache_key) + if entry is None: + return None + if entry.expires_at <= time.monotonic(): + self._entries.pop(cache_key, None) + return None + return entry.caption + + def _cleanup_expired_entries(self) -> None: + now = time.monotonic() + expired_keys = [ + key for key, entry in self._entries.items() if entry.expires_at <= now + ] + for key in expired_keys: + self._entries.pop(key, None) + + async def _build_cache_key( + self, + *, + provider_id: str, + prompt: str, + image_urls: list[str], + ) -> str: + image_fingerprints = [] + for image_url in image_urls: + image_fingerprints.append(await self._fingerprint_image(image_url)) + + joined = "\n".join([provider_id, prompt, *image_fingerprints]) + return hashlib.sha256(joined.encode("utf-8")).hexdigest() + + async def _fingerprint_image(self, image_url: str) -> str: + if image_url.startswith("base64://"): + raw_base64 = image_url.removeprefix("base64://") + try: + image_bytes = base64.b64decode(raw_base64) + except Exception: + return f"ref:{image_url}" + return self._hash_bytes(image_bytes) + + if image_url.startswith("data:image"): + try: + _, encoded = image_url.split(",", 1) + image_bytes = base64.b64decode(encoded) + except Exception: + return f"ref:{image_url}" + return self._hash_bytes(image_bytes) + + if image_url.startswith(("http://", "https://")): + return f"url:{image_url}" + + local_path = self._to_local_path(image_url) + if local_path and local_path.is_file(): + image_bytes = await asyncio.to_thread(local_path.read_bytes) + return self._hash_bytes(image_bytes) + + return f"ref:{image_url}" + + def _to_local_path(self, image_url: str) -> Path | None: + if image_url.startswith("file://"): + parsed = urlparse(image_url) + parsed_path = unquote(parsed.path) + if ( + parsed_path.startswith("/") + and len(parsed_path) >= 3 + and parsed_path[2] == ":" + ): + parsed_path = parsed_path[1:] + return Path(parsed_path) + + if image_url.startswith(("http://", "https://", "base64://", "data:image")): + return None + + return Path(image_url) + + def _hash_bytes(self, payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +image_caption_cache = ImageCaptionCache() diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 618b95bac4..fe45870099 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -49,6 +49,10 @@ "description": "Default Image Caption Model", "hint": "Leave empty to disable; useful for non-multimodal models" }, + "image_caption_cache_ttl": { + "description": "Image caption cache TTL (seconds)", + "hint": "Reuse the cached vision result when the same image is received again within this period; set to 0 to disable caching" + }, "image_caption_prompt": { "description": "Image Caption Prompt" } diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index c42a3313a5..bbe5720423 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -49,6 +49,10 @@ "description": "Модель описания изображений", "hint": "Оставьте пустым для отключения; полезно для моделей без поддержки мультимодальности" }, + "image_caption_cache_ttl": { + "description": "TTL кеша описания изображений (секунды)", + "hint": "При повторном получении одного и того же изображения в пределах этого времени будет использоваться кэшированный результат распознавания; установите 0 для отключения" + }, "image_caption_prompt": { "description": "Промпт для описания изображений" } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 200b3b9fe1..090166acd8 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -49,6 +49,10 @@ "description": "默认图片转述模型", "hint": "留空代表不使用,可用于非多模态模型" }, + "image_caption_cache_ttl": { + "description": "图片转述缓存时长(秒)", + "hint": "在缓存时间内再次收到相同图片时,直接复用已缓存的视觉识别结果;设为 0 表示禁用缓存" + }, "image_caption_prompt": { "description": "图片转述提示词" } diff --git a/tests/test_group_chat_context.py b/tests/test_group_chat_context.py new file mode 100644 index 0000000000..c1e3a52899 --- /dev/null +++ b/tests/test_group_chat_context.py @@ -0,0 +1,43 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot.builtin_stars.astrbot.group_chat_context import GroupChatContext +from astrbot.core.provider import Provider +from astrbot.core.utils.image_caption_cache import image_caption_cache + + +@pytest.mark.asyncio +async def test_group_chat_context_reuses_cached_image_caption(tmp_path): + image_caption_cache.clear() + image_path = tmp_path / "same-image.png" + image_path.write_bytes(b"same-image-bytes") + + provider = MagicMock(spec=Provider) + provider.provider_config = {"id": "caption-provider"} + provider.text_chat = AsyncMock( + return_value=MagicMock(completion_text="cached caption") + ) + + context = MagicMock() + context.get_provider_by_id.return_value = provider + + group_chat_context = GroupChatContext(MagicMock(), context) + + caption1 = await group_chat_context.get_image_caption( + str(image_path), + "caption-provider", + "Please describe the image using Chinese.", + 600, + ) + caption2 = await group_chat_context.get_image_caption( + str(image_path), + "caption-provider", + "Please describe the image using Chinese.", + 600, + ) + + assert caption1 == "cached caption" + assert caption2 == "cached caption" + provider.text_chat.assert_awaited_once() + image_caption_cache.clear() diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index db729a23ba..58960fc727 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -16,6 +16,7 @@ from astrbot.core.provider.entities import ProviderRequest from astrbot.core.skills.skill_manager import SkillInfo from astrbot.core.star.star import StarMetadata +from astrbot.core.utils.image_caption_cache import image_caption_cache @pytest.fixture @@ -1173,6 +1174,46 @@ async def test_build_main_agent_skips_caption_when_main_provider_supports_images ) mock_provider.text_chat.assert_not_called() + @pytest.mark.asyncio + async def test_request_img_caption_reuses_cached_result( + self, tmp_path, mock_context + ): + """Test repeated image caption requests reuse the cached vision result.""" + module = ama + image_caption_cache.clear() + + image_path = tmp_path / "same-image.png" + image_path.write_bytes(b"same-image") + + caption_provider = MagicMock(spec=Provider) + caption_provider.text_chat = AsyncMock( + return_value=MagicMock(completion_text="cached caption") + ) + mock_context.get_provider_by_id.return_value = caption_provider + + cfg = { + "image_caption_prompt": "Please describe the image using Chinese.", + "image_caption_cache_ttl": 600, + } + + caption1 = await module._request_img_caption( + "caption-provider", + cfg, + [str(image_path)], + mock_context, + ) + caption2 = await module._request_img_caption( + "caption-provider", + cfg, + [str(image_path)], + mock_context, + ) + + assert caption1 == "cached caption" + assert caption2 == "cached caption" + caption_provider.text_chat.assert_awaited_once() + image_caption_cache.clear() + @pytest.mark.asyncio async def test_build_main_agent_uses_image_fallback_provider( self, mock_event, mock_context From 9d91a78eb0d4e0ae2c47fcf3c4e43f484c619f38 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Thu, 4 Jun 2026 22:59:49 +0800 Subject: [PATCH 2/7] fix: localize image caption cache ttl metadata --- astrbot/core/config/default.py | 4 ++-- .../mdi-subset/materialdesignicons-subset.css | 6 +++++- .../materialdesignicons-webfont-subset.woff | Bin 19228 -> 19304 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 15460 -> 15560 bytes 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 9d21ed023d..cbdf78a2e3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -3195,9 +3195,9 @@ "type": "text", }, "provider_settings.image_caption_cache_ttl": { - "description": "Image caption cache TTL (seconds)", + "description": "图片转述缓存时长(秒)", "type": "int", - "hint": "Set to 0 to disable cache", + "hint": "在缓存时间内再次收到相同图片时,直接复用已缓存的视觉识别结果;设为 0 表示禁用缓存", }, }, "condition": { diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 8e5fd76cd1..be565ba238 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 271 icons */ +/* Auto-generated MDI subset – 272 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -464,6 +464,10 @@ content: "\F1036"; } +.mdi-file-search-outline::before { + content: "\F0C7D"; +} + .mdi-file-upload::before { content: "\F0A4D"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index 181ce7f861d8ef9e5a7350046f16518f192c67c3..8e57a70b4f2bc99ab8b875480a4435c560e08fea 100644 GIT binary patch delta 18321 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 delta 18243 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 G<4T1e*=(Z# diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 931625c6d7f87f508ea630673ddc995d15673d3d..107a2670959946875764188748d86440e7fcea08 100644 GIT binary patch literal 15560 zcmV;(JU7F4Pew8T0RR9106fS53jhEB0EzSf06cd90RR9100000000000000000000 z0000SC)3JZiH00A}vBm;yb1Rw>0LI)rli*Xa~Fl-zEKs^r@MOkLm zQAkpk;_Ux+K!*&WH@VwHi)Q;9@7uVxY1vO92COG|;@Oj4vqXpxAr4)Z&4SwBq`AWE z)la=d2&9b#H2FdL*Qc2#9Fo*;NqD{=_nrGby>x1ul)}~~4&WKl_jq2n{wESjXdxj9 zkbs7SmUAcqp)_+tLhJWlz_N%-F^Pp$5V7UjW9fipbGikMUO7d3P4W5qSknJ|2?W@ZZ~$!2$XS4&2XHt6_B_D8V7y`C zrc_-FGWPgkCOK@dH^qy&3UPcfRD0W$_pT|g>=IAJ#a??+jVExkn9D+PvsTcgAM_)* zjqy-^9^Ms$7_#?sukSv9q(9&ageXxEbeKA99rpYAf7Kp)ygi$>Rc1=Yj0_P0c}x4c zDF^^=h|F5}u65a0vP88E@prD@eFgMmS&cI~pZOClRSXHOyrKIcFp7d~e& zkq4hMoXCgI`5;jMpL3 z)YLMSD3PUFjcl}9vvlb~IG~%>X{YJjafjZ0_vLx$p?ojAP~d<5y%c`jJd|QQJSBYi zC?zIVCe(E0eRE2GT6EP_(pYMT*oSk?PDbPrU{W=4#e#9s`3{w#Zun*x3$e z2bpiom<1f}dv#xXD}c3cHME#ArFDNu1q)Rc*R2?M^Je77kBOMrB1%e&B}%eHjvRqz znWasoO0S_%mYHp~<#yPi-7dRyY>!m1LS=Celumv6taRCBU4{)?#l~i}n{Ha;w%gXa z>#lV^_mFwtO9ksymb-V^Kt^Vx8D`j|LWRw$Roh~odA4fS%wmZpwpnML?Yec_p--Qk z`t{r8UGLiMiYxZGZq!~54*U0+a=@3q(!GBi74)hs4>&pCAU}Q%lae|jQk2)ligiqa z1aDYifj%u-9M`Vh3EjG#gyet$(_6<$38abjys0-g~V{YAIgZQo*CUsiSfKW6PU=mEP=_qTN0ScdsPC{ z`J|N@nX=7l(PB=QF7sP0frWhbBo_0zQkIMxx4bVUR`Oj?RxPy9nzhzicieFsPC9AR zJKnM76Q9`jm9OmVUy0rP)|5ThT(j?%TMjrl9BxYDD1TIr#fo*}j5ALC$9ZQ44Z7G% ziOcx%a%GM=uC;4-vmYgH?(&W)2UwC>+yd-EYue&etmasN~_X2Cm zha-;o+HA1-Spmj+81BIg$u@& zE7o8CxLr$BH=mQJTW%i(b?f!>e^K53N$Fg={AE>N*YdZs(hvS$uj>7-{GLBQ@>mZk zXs=HBTttw{9XsGI;ERdCO;P7Rn(#>E1_64pho^OnCP$CC+mVTBgj#MxG{(7@JOR2@sg%o)}kdU2oYrn4jl;j z4|W;BLWCn2^OzN9MmE+FY#Q7m6dh(MyJ>_pL5wgaP1_MD5AFeCNeq=0(7{)=5$VE5 z3XADwtyn;6wBT?}wWMINZ$@f1CIm{|pH5X61r&fI*m+5%ED!*sz`2Df*o%gEqdOl0 zaDQc>#M0R$5uhtRl`>Ovtr2aC#{x_3oB8K{K@IK~sC6S%aA=GRYbgfx{!xycrS@p2 z6L(j@XZpkv9bN*>0ENxcmnNgKKap-R?5*|`5JNc^ovM!es?B!6+v}&AP6#Cw2gt)r zt(#nAbr*m1R>0#*K!eBysHjzefCs3+sn$Ajc`Ww~6&t~Pom0jVz4cv~wp65FT+-}I zS-ocV0@Q>@$)*rGdSc!m?lx|u{rN;Ez7qA$-Md>uqrxL&SN?`PSusKeITH8%F1jgz z?Ki#aYW#-RB8j{kGpB7#CBNx8Kl61TOtC1YU1?nZDei6y-}u!pH41$3b{1S3(gJr0 zQT^=Ppw4p2c^g`@8G9;U??%sSs})~KH)Yy7RufE&mUWM}8Uk<~nkTf0 zl&;#@E-+qonKMAt5$4U8*3k-4Afc4#Ixfqwc!Oc!02&*1;#F9f@pr*G3;3zK#%cq85cHwHJ&C2i5@>&r)s!RkQs);u%V z2w?X8A|ipbs$cmoSSbe_UiS+Vo!CRpAf&QbD~*kaI-y0lFjUth2CpNg4tHHV&@r9E@`in4YIqW>k(#KX6r#Q}=zMca65zF?%JMbnb`L)(4q%{q2nEGgOvmd@7Mo zWgLUcN!fY`0XYH?ccbTLyZOUhQ847=kYY8;0xqfaufL;`kmedNL2>!rO6iO%^9YS6 zF*))PObjk<(DWVbYYz@`7cnVA5;?R2sh!S6ZRAQf(!)Y~QAGhCsgrc=HZcnmwOE*; zm&YGP3)gEuGkibEel<&!HaFhAchly9K`b?bg^8sUp%Yd8bS_LN88fC70{*oW4R@PG z4jDiQyD7$Cpm|=w#AI!-IzYwu;ZkNJpdf5iVK;8aS%U?$6Qc7&=bA^0iZJBS${QMY;-g}Hn}lw8qOZ(@WTLC4?JB1{VHObDk>S}3q~8DZzINMJNgbLQ?!d}ugmy7 zjo?bDB$Ru&bRm<}A`)A7Z)xC~jwI}%GsGz2sKycGc_7rK=L=DvApYXir!vRoO zxVmFI9wP|>fA|=$Ye`$wmh_-yz3&t7{jovn7YByl2ir*{AQY51?bax}qhYIs4ORiC z+%r2`+vuuG4~xJySKPjZT%yrXNRi(0;$pGK)d(incoJNNhro94tBg{|KsvR+Obo7KwvLn;X7%K{?+5#_xc*9&|I! zKbmb7Gbz%`ei!3B>zPZMlu;*MyUfN#7}oHn$0WG7lB4f8OdQf22jU^a$bk^zPsma+ zqjb=YL}j~kL=%k)OAPxysv8J|g$SWEg=r~<6)l3o0~ZT9#7G-uQd?q6PdQD*kd~^L z0Aqp>;by?*IL~#AFHgqCN26oI8>10NEknpTB1G|6&82(T6^<0n788%bslE3`!Faa6 zcq!-q7urzB39RCDsI#c?2+=JveJlXCjPI{0WH1CmLam1bd8|>COjj8Nq<|ICv`!p8 z72%m5smKu><`9m{2|(FU+lBNb^J zS7M|C&Ku?$=!uNVD#t{|jC4j73>qdVT|~5z5b}*92L2i(Oz|kPZ$BnJB)0Jca|cS7 zh11y`AC>Q~wKwA$-GTL%x)dHM^b_BaRQ8;dbei5JXB5RaJp!|NRzJghe+K4vE9Q?k zpbx1P#nK-Wb~y`3tKVa+pnmIS;#K1gM1r+u#@q1@L}lmE^mas7{9I$}8)rv(8`mBr zMsFE{Bhbnmt_v^=PC{@T-NltxoF*!5jkn{x&D)xVN~B~i0rZQAdmGD2kQ%B7-h=0r**;+XPAW;CQSOELC;b^NPOx5u@$bZ+$ZuK zrO*MC6gu!RWOz)6DqO#a%Fnm@yOpf5s;!wD`g-v=k3UW17X0M1?|%OHQwG29V3Hss zxgEEz5(Pt2BrW|y5gJ7mCPolH>TmOeZeAZ!sRu96-R0JF`cSVHqLCJ^7FzoHOO36PEs8ZQ)n92UVS&>UlP zqH*}Tu?m`gdT9<_pdvEhxvZ}{t_wFDgUir=K`bW8Prf45C5p4}KdX8kHPQ~3nfLeS zUEvO-?e1cM-Ww-)gX3Wj_crqU9ZW<3-8z_mxA2#c(O~kAqSvH zFI$0-`jX7F2$;L5Oftt}R71iLz-KmCHB^e$3=f27g?%&kKT_&(Kb9F<_d7yP`FEtm z5cy{+oPzcDlIfYJWv(vdT~|;Yhr7^B?53rMxU(L+sH=ZTX`tV`aMG;^dd59NH4qOolfvh{hBrEp)bl92Rg3CS<5e z_7FTRKy9PNs2bELgyi0U`vf&5%wD>a8+aS{`zbnwz){JekzRhjg*lp<0+bMiMItO3 zy%c#?4tomR4e`1V)dqY-_o0ZuK4@w@cSt4$4iuECAki5# zG-7;+!BUQyVft95R)>dOm0l%}R_?n+r3eoP7hY_@M~;d3eVl68V55kudug!|5-=Dd zZG4=Jv5`vJ{fe`nfI6h1Akhv_y~`ypZKWrW>N5$@n!N=i47B?`{P?~7Xm1{( zBioLT>^MHWV;gZB70%)o2W0p`-W9iDo165UZ`+Hnb!?v+cxzrztmzS4(L!W)2QDwF z=#(BsgUoElle*mL<>VB>XZJPxYEiYa7G~4i&|Qko?Qblpwr{3>(;??mOk;^RSKjGw zf70`=HdS(kR7O@2L}ya=GQ_ZIDV`s%K0-(19Z&9f!_+p#akn;7ark7JOVD<96@{qDrc>%3F`KrFi(9n8s>F-ul@!Z5gYsdrK9~tX$Q5tT8yW}E~h7(4X zOh=p)wv6!Bl(<^f=&}GIfmqG%cG$y2XcdTjT&(9TuFjQ#WYO)3uHy85t#6}_h)2gY z!GUceGJ;-*-WKCr*J_Wst-6a=wRmvRh}A=ljvg^6&n97WX52FRsTk<-fZ|4*-CWD0 zG^JPqmTs1Xor>Gl+Y=hhCRND2XfXb{fH+^sFRiZf` zN!ks*vHY-~XmT+XmG0f%^G?X(XiKd=oc;D;E?k)mCES`&{rdS)xDw|HZMr0IaD}L5fq8teVGGTGnQl| z;WbKrq**Wr0Twz|g}Fzp0ST`Xi|F7jf3V6GMkvcr`mK|a-~g`O%Z?Kd&nnU|r?@6B z+Zth0f~XAi(^k8tYN8VJ2nviay+l!E@iD4Lp7$XX6S}=tq;vF zi73#Vu`zMmf~t1u)7I86@{%_gX&-%--qHA`^a4NCokxf^|I0rjPkFK(-`Xr90X{uB z8#mp-ydG&6AOSGIP=65$)_g!YjA%-9AO=?vB>{_}4z}Ln53-sjZezNS8n42bjw769 zfmIdV9=)&g1PFD`0nzx2`d>JwLKIA4j2aTyR03C%v-aEzquCDa#DMk8_jGeUqEs45 zwxV+e^-t&7aYoPEN(b_LbK{i*~A+O5Q}>UpPM#sYX{;Q)&zG zULd=;&V?Iqw%wL&)gGD#pOE;V(-VQ!`Is0No}PoXn&70X2`6UN0rfU-Rss|jtQhQT zRaf+Dgp{Mrz0G>4&RN{cA=@rnLNjJ@npWW(_-k3UR)eo};;n^fO>}95lU3d6`rBd7 z4&DVECD#9B=77ah)fIVVOHu2n#|u$oH2^ZpNOaQk(K(pCPt*^oY9VSc4>D3&T)M&E z=Qg6mz*K*-c^e71Q8ZFWToKm4I_18x2%wM*e?MY%Ryn=w{(+J+bajc?Xs$!vpfls@ z8|`%rOw_HIKL!e5h{swH`fa9hkM4He5w0F+^TSlHOzM^A$(Kp(S-N>cXYbE_39wQ1!|=;tXUw)Ou0BYAOgN`Y!pGjX z;~&mGKUP1vY8{HzV^z(`q!F_ko{7&|=(*xnyA*tLYN}2Ksz4Xx<3{pj)Urt$HHTtKKsoaXv^Ev@C*tIzHrex<2dk325!*0xDo?NYFL2;4JUF88xyQ z3}vt-UJlffeW|`;rA^sY=|sKq>_zuZl(P6-?%bXX(IVZU8W47iF*={Np@^cba?5*e zCA2z_8b7z=$?rY1U5<4H74iY$Y8=wj4`=2-zM8r>J!dAVqUxcji=-G50=#wI&rP!I z0C7c`AnIHvB=1BaglAfDsLY=mLdYm0s(}TPGbM)q9Op@2UkdV|>E#dund@e^tKH&yA!c*%kr8JuuvWe^P_*$rG`Zf>y1x3;eL1AAL`6(o0g|fDX7fSi#jxTL zs0-mOSEIKvFvxujxbmgGA3-zXr|<|-nV@L$n7coSN2$2#SVjE{Y?0qC4lPJN`3zRa$DcwZtX+$1?-%bq z{_<%VQWMRGNP{tlRE0(+=Kx|4Sf3xRu!z{88J-uCFU`2}BCW7VOv9E4M2r4KxOr|u z;b|~EOvyMum%A+7b5=Wa0M4T9MH3>hcVRN}G$8lyqq-ug~4rzL1_i63#1m;ke?}a zSagcPl$iZpRT@LY0LYG7!eqCR9EHFJ*2xZO{74>`C0V(p+H~|=W~_Hm&G-Yz@+8ZO zi~!lfVgx}=jB~-Q-h+QLMi^=#^EAoknl84It>5y_)rS*g&A9xE zD2A*6cJ1`syO4f8t4BYkV8a^{mxE)_-|s3qf>StMpa!k1LM%JW7JCrXe?OXS^&ph{ zg31W}6x@=i1^ORDV=w$ZJn29(`wW$>`GVw_QqY$J(_6Y}AdenMlT$D=9nxMte~SA7 zuGG}Hy%yRZ3)x6aM2Q(Tj-kMm1GygWONMQ*$u?U|&?PcC6A-dtrIC_{lY#)Ren3O{WNEH&hFMpC5$H1I9{(GGq`}8 zM|A8Tw^f}DtW+)~^i2GG8*^-djjZO0DG zyU+BIeG9|!6$SFgK=*@HSaC4@WwhB=;Uh7}&O^B4=ss zfxqqt%D-<}>ZZB@*QQaPO_Wt&g0~@#CiE`A+J>MSqkZ+#$gzC~em%|(U%LF}p^F#h zF}6G5wN32*t@6+3|9nn9W4pcm*92cwR7Lc!3E=o(NW_n{|I%q~FWJegetYfm(bWMYT)j?EC9HC$X%7ku2~dxhO=yXD|7L?-Yn)wOUMY9CY>BI3PJQU= zTvXE<`^Fowt?Hs^08e9@p%HC1XFoP8oKL@={`FI*!u&WJ#f9?37-KO{7KgKaPv)QW z&Bk%E{O@mQ9Iz%yLMc6=lz;H-NMd|>TwHlPVY;hAkt7KWXcB39FkA2DLW>Zkh8T+` zO^Xk>f7Hpx<^{kZtYJGMh~*N%gG<`moizQ`SCD*S#R_NmV|dZ>2FqGMtp5B;==8LEeZ4K!!mg*SbUd|OR^Ir&r zE8_M(F1N3>ErwE8#QAT0&E~A#Kh50I;`~@IM0$RrUN;jZ5*B+!cCv<|H9C*Tn9i6L zhXb6XPIm@?BcVk|b#@Sm*Aaw~QMup_7!hU;4T4_t7c?%wP}7dEWH>m08EQHTIwdHe zv*p_mHsMPYNoMEg(E>UzF9(_5 z9aOh1CXHkC0&`>oRwIm5Hi~fu)!hvdq>+Rj*$Fdh9}6)ACO|<%+X)~d)2F@< zm$G5L_Ds8PW^4}it}xfh@&SPK#=Qyttjl$qjgh?)00l|8%d-(mif*KZ1sit$bjY;3 zLYa!h|0~F6ySm;d_x6aEIfGX(p555p&S=BcBm=f=~goHrU2!W1ec+)`hE zmD7}e?3fd@RCcs`caxR{4Tv60l~eZ&tJw@($uC2c88etNBP$|VOydg6yF+3-b|Hp9 z0rukF7Y^jWzNCsA2o@s&`A^~Q#ENwW>$FkmZM?+=KI%_b91S>ES^eo4L(EN4=E zll%-CKZRcklM=(UTr794*54JeeTJA28#NNK1a9w72|75CkQ+W=37+GK`Up-%VY96) zQPe6Yg)kQ7sLv17O09@>N?3m=G$Tu6xuUtZi=eM{=6ll*O=(i*#H>tzJov^c{ z2+j|&|DHQASp7IHyf)oOu*}2j)bFw_>09s@bn)SoPH?FbZ=?dxD zl0$1qoYLo5t%)ts^6y@ehXw@(28GIB`A#0)lGp&vDlqt#a;$i<1oMDWqtMpkXh`e>wUw@iu4vQQ`EM&dN@+ zpF%o+H_oK&k0T&76jf!FE^(mro8;6S;#jy}emK9XJ+disK|ug{R%6#_G$5ntAfFAdXPzu__?QpmKY60W?)s|f z)nWD9BjKwN3^5GTG;7Jbk3}KZWosf<`yCciiuy)B#w}Rp9V8iMw{ILg@0^ zmoWG`8a`6D(UIF6aLp&^DzvtdDnqJ5l+r^UyFBJbgd~T|HEgBWcDRHL?LVF?g)96l z9^NZIkCG~+l>nx$;kiSaRQ`o$m$1XXL)ZnvD&21uRb)7EFOfIIW6+y;Z$l#W!Y)a- zm=W&;niS1)j=CcEm|<|jwJMN2Avo86P( zdkP#kOMC^sSByJLPRuG_zrI{$QE9-DES*a4O*wgB(ZO{jg;i5NtyEEeBKtx?@!SpZAw2s5{W&WOT}S6_Ap?F5CZEQ$f07Tkcn{V?c2_fREm2L ziBM0nmV#;x0QcL8{68ZXoSm!l*Y(e{Ul#qfrBB{HHgb#rs` z=G4N?KYIbaRe~HyVr9`%M!_sa;HIF*-3CLLRGS<85MakJXr2n?StPxRl9-L^Phw!0 zN${=_>&iDX5QU?myf-A$a>?`9=aQc{uaXCIW^#h%RrLWg17P{Xcp3W=Oo$O?2M~o= zib5EhRTv@EKQTq6SD`VYD6ElYYCt3(kOEx7uWQE-ZfmEh3~JzefSNulK2z3yk&Vxq zUGPKfxHdthJR!}Oo={5f3H18jULZWqcDPXLxw8tMf>e4b&z<`M(QG7?>D}%=)KMIeCDN<_+^G;pWQvuopYGrf`QVq&%RMUAdp5;x4e6x0X5J3Ue zIe=9CS4qwD%+C?c-gK#uYy?aOsjiqQnv{w)^Lj;Q% zM(U@+|p*$f~%BP+UB*s%>0zpzFITaTn_Q!(*fkkN2Au0$=Ld(9MgzWtLu{id> zNl8FHrBTXH8fVvw%jy+X%nZGGg&~1;7Z_h;0ZF2tp(&XUonVPBUwxn9M{|ZOOcOwIz8unG9%RtPE?mPFTms zt&A0`B!IeDXI6Prcs*zAVO>g8*=&$1d0z;ETHP32-C?U+_n24A{I)+}{q3GAV_+DXZii(M9I!I9;d~dXRv~7ZCdk|1O zuDKlW;Cs1#NGu#y=8hjQ&SOo#q7mHKE7|KTwQ5sQ<%Ey+>HbxT(+gdTNoMnHlSjx*1vJi9M`-#QU0{cSrJg)n5RWRLuD)g z;x~~I#H&$O++d(R^|?(=P+C}~!MzBfUi^IN$gNb|zIu_-X*6iHmV>y#h0V=oe9#a+ zS1}hnS_<&}iBXKp>j(DzCdD+C{{*%4ZQ&BL*90rx^=v5B3uRenA&#^EB}wx=Bo9%^ z^nx;ZrRO`or^Y6YTWvfCC(mg7oRhtF?r3q66w3lT-bk*BNX1H{`CMooI(e}haq5e# z@tVBbry`^B5CYcw-rjD~6+ob>+U;QbOyp;hv$XU%1P`w06eo~{g2CvTj6aJ@auMS= zA@)QP>kt`3N><6ziDNh}4k-mnWJdjt1%6$555kNWq$uEM?LT$L0+j-=jOye%rsS?&A41eP3O?(1b2> zz6(xmb{8y!wK1KLu`Q-J^-}7g>IdAYzH^)7PQJ0of8Ia@H>rOUUQQST*Y@4|`(NMv zkMGW`cT#kq?lw8TT3x?Yx`z)?2#h_1JQWSRd;kOj2dNXU;DtJ$hrV* z_Tq{O-R}qV3xXlJ zurQ=CxUs09F+K7S-RpzK*+sFBXQvwB<^lbXK8csaDtUKSG0YId#P$SGhB7LOOq5Ja ztYo+VCuwl{nHKf+G|yTYJUw5}1&|5LdFQbB59d-zn9qeUlfg(xsgykF;dgSTD+3Ay zWSMgSS=!hAn_Rov9J|^mm%kqAUmwIO9<)Hg%nJOs7L_y|xmc}R89)T&+VC&I3yf~L zhAxA}ciD(5*SGSaYw7j>DWe%{>sdF@3akl76EufTY?_E;7bU!%(+Fab@5h651G5Ig zN_d;y4}G@5V3AoCE}R3-IIpS&xMpC49TN_O5KWQVH-r47N;Bk)JXmqQ4dvy{UOv1b z9zolC56M;(nkGTK_q=>q;j#9=ZUrFT?Dp@Y{3R%L`*G7P`JBgq0D3mmT}OiCBW62g zhiI?W)tM2spr*auHrc*7CbznTG=HDCRg!11&tqN1FCn>HaF#PS8xvk8#XN;pwAV#E zcfZK>^=c)so=fcOk`g2AvNRuDa1ISU9UFZbfqejASYF5O{Y{2xEcIOpP=26-LgGA=&SFQ%e9<)5xO7!{eIL%-kqkES6myc zb@#}2OP*GH<^77Pln+yMfB7Z%`zu0~p~<01WKe}rC=X0r`D2C~H_Dxx@z=`4K)Fz% z;dyJq1NXl1U+G-;D6U)jkMBKDP{9LB#yIY=&nvGId8iNN9oG5kpvRV@M^jQLhmD$g zW!o@4ybTy!F|0EDUs-cCvt)lp$s}mzpozj%MNL^(b#*W9nUfpeQkST%sZq~yBiy|n zm43G;oN*`6lEFJ+t*sAfoo?+qbz#GL=hZUJvWLNcpb5|{s!^+J66@ky=H&L^-scnCEUFjpQ@_gsu668*}8*$lH=lZ6kxfAG*;4S)wh7t- zas>`NK_Lg4AaO4S3V7JFsveVR3cz6&51cYavvfbY3fa2-CFrQm*1heOt~Br#mMiL3 zi|c7U2(buxgSJ4p=Q}EbXHEQidT*?%>_$CpVcM9$5VKa3cJAeEge2LilDp zcoLhiM0ZE!k^JX}g#%BXviP)B*R9^><5^3;_n>&`8_JR3&__S@ zSoY!hSu$x*syeT#;hCl4N%Q4}SN=XQGwuAv^vYKnXI$-QoVqO-8$9#=I|A;s#5U#X zUMP&qg>92n;f)y^UveS}F0N?yxT8qxetXl2Mz8iz?j=vo@mG`m9_26U7|dOs7?E7v zqDv7gT|czmEpfvXhf~LSI_sHODLDY!EFuv1-{y<6TejLKl#fiDu_9B=?@42 zV=H!m2Tb8bM0tg&LX0XgRCZpG3$Ca8{m(uLPbiq^;oxbT4FZ!Bf<`99=?q~=#Mqm4qzxs2Xx@erZpdJiL{J(h6)VM4g}a`=5VaLriSI<3~ZUn6-I z``$qIYlRHTAzM*$xa#fCKMH8HjEhHkC* z8sZD6f^J2|eKKfcEiQJhT}Og5ADdM~Hn_C+iJpf&fAc)#o69Il(|x$%Y;|^IS%<1} zWti^rQ5c3YWI`L43_FLHZp0?c+$i2KzKnHpD~-qqp%2t(Eu;|Hh(^HxWynt>M=qayrs1kDE4>wYV|u+?&HD9maS{(J zqF8pq#Qk{v?LiX!#<-bp*R$eqvlzT#hzM~}Jo!f(f!xH2zhwwgG!@l%f0nI95kiSq zwAf}Xoz~Sflg)W*h znR@8%35-k^ce{kXn_sK)i5W+_hz?Unq<*6LA+Y0!ButD6kucWBbjE~!uhlwpwM%3% z+8}fsL#p(Siull($~8RYm)aV4HzG#72oZyMXU*7wYx;kWfJgm(oIz;*R>dilEHZ3f zeNkTsffFGuY77kt0Z4gYXcg_+{K6i9z2~(5 z5X29OP3_U1qrD7f7^p|gh%xoaF)}0kJ)s;VLJR`;JutPzJ8bVP6#A^L+iyHWtA79} zrfZ5zajytnk_F1o2ZOLGFx2&Fj#icL9{>n_wX4@+7@^k1g*hmSC3~*|Mt=LJ1gRk) z5HYnN2OB5D{RG|tw+B{iW-?$!-AGo6%>`uBhPeZ)y^bv4b&bjoH=rPxRHzkey=sh~D2+raka!`xpe z6?U>ttfD+MJSPpbn?+_Np76r~I;iE6KJ0{ocKLrZg4KBoz+(WRe#XF|&>~eKa!`nJcPH$lbk(I5@#vDv`T#1OEX zK;sD6jYbva&^8)lFYW~n5uQp$>D&1MT@o+MT@U9zxh1q`v|<|3cu!szEDux_YH1S)A_A} zl2X`N%$&fq$AmTnk3GUmPqwSlU9cRP4bRzdWQ7V8+T!J9!1Gyr!KKXnl;o#jAc9PToPPXN|CVvHhdQeZ;pjl-mzE2JnTcm?>wIW*` zQCZ8R$xhEdPYq=`5z5!nL#~8(#Ik>csI;2jHQMN?8Kr$`+!Y=Hn{%|9^TjFdT5dBp z<9yHg^MID2v0MK85otQw+q-A5W;5J;(sPGIcJ#-diTq`%v~6)d_xj5Tm)i_ST)uqv z%+P3rm3BE@c)~d}HsrMaJ2a*orAX3AlGIqln?nzQj%BaO9 zRmb}*3*tom+3WM8$ISX-pMj)YakNfgd}}w=??><6ze@k=^~Tls^!|Bf2IY!<9{Wt< zH@hoh>zgtI{O`+Rp&<%>?x**;&rEce61{(9FCzSB3Ka8s+qBKRzx>KiEkk}Oa628s z??Y)ulBq3#;PPb9Qko>rIvX6c-C4Xgz;u#TXMWY)(^?SQxr^0mi+Iwm2b}$SNxOtN z&?9P8WFXHG+8!H0_^olVfPe^1$PRt;?zAdz>OqOUSqq0lSc*P_d~sns_MUr@;|7?+ zJ>;FmQE>3If|J$w$#&0aI)U0O_{3Kkk{ps0 zqJWADq1tE*USB)q3?JLe^!jKy!ZUY4bhJM57Di$Jo`APWw1*)qar6E$frtpNBd_W7 z9IoWKgd1RH1U26W;eJ-P(YvWRq9byDU9^Zj5h=koFbA#=V-s2d{;Jmvjv-#%m zmd#2YU2|S|t`JbjX;_%QY-Q)8l9CuUUsxd2%Ng?7^#>K`q(UCk8T0nU+6I~<>VxAp z%3v(DEhW5l*+$FDzx_4|+K}tkkT$VL4yTv_7T!G>*t_VsfJ){&2Xl&*DbU8DcY7~W zdYwQ9nCh(zv_Ata$HKZ1jFVo&wun|lb*ZVW(3;iT6|sNwO`*sQQn)^<--f8^t!MCP z(Y8z%6-r6&eAmaQ3#Bz#reA}yc8DvwzR5!wG_GYKiOj$(R?~!oruQN`1tSADJT5a)kZIb386p7-gadk~ zPRr+*h|e-21UpwunKEjp1%z83N2p0_oAMXXJ_11~<=fkWGG@&!HTxJAp*Ewq@ISO% zGnM}fX;2Zrodbqd1k^BcFD++^uy;4WGLTeljwQ)~8q4`Bo9^B0Hjs{I-NN}esj;`5 zHz*1n3+YdwG6%$gi#Slr#Uh1kMFqW!!47ifdXuBHEp_I>}?| zM-Rn-Rbdf3#W!tbNtDQTyKNCV%K+)7Nr3aXfdIj?n%uz8{@wdbgEvO6G&nBjY5^x4 zVubr&-4y15`8^yskRXtMXB&t_F#`dduHxurw;Q}S>FMlH&J8$R9&i9r{Bzkf@Q3Vt zx1xM*beTIyh!Q>Y>_Cx5?V2rPDB2==WS6J5TB69SmS#{Bx)c_{I8{y}@yw*Up|$F` WapXqJpcKlN`SNAJ4c^|m4FCYy)ao1n literal 15460 zcmV-qJe$LJPew8T0RR9106b&>3jhEB0Et)t06Y``0RR9100000000000000000000 z0000SC0LI)rl4{;OjFl-zE5I2t-83;Cx zfPmOM3TY)Lg=hb_1Ukmfs{vw`$WX1+B7&BPGpUJF><({ry*jGaGTHWw#_UzLuwcQ0 zJ-Dfmg0O9WH{?_H_1_~fT!6a{N$R&GJoRJi?MF3KDwIgF5R>VQh}^)rFZ_Rhw0qwF zE$g)aSJ2Y3qBP)ycTQ6cw~ZR_=Xu@ygM<-wLJ}YW2?;CrG6WLF$_oi$%JRJjt|FFU z5(iZfapk$=*np#2bi+$q{YsbcO{n8n(G9V-#*QgCI;QB zFamZF8{!Xl4k46&E=o6*=gX=6O!qdkdlMJ}-Cfz1tO7b|*RTk2Gmwow#_xY$uF}=I zQlMp<0|gA&B~IE1{@$il+8XwvXW=Mfto&|*^n&z~;OYM_clZComSlIg#=e#e(~=CW zmdn$U;Y6OYT6HS15C^E>Hhl zYpMR%vSqicNx6&iDHw%DA`C`O^*-pD`n>(b;Ik%AFg?IUMNp??R9C0Xd3^+JlhZ6H z2Gbb7VR#JLZJM6MpY#F56Jiq#278z~j2-+rzXKc{o7W<(77-$*7%_y9v7!CC3w$rSJM`DgzOWI9Y6$#6 zb5lAj*O$kfNJhaOs;kov6n206*Lem{!>SN4R4NP@+663N=nnSQSHK2_Q2{#`CIuW| zm=$n>VLr&+LIF1zP7MzjE)6dj9t|HDz70PZexC?{RZYT^xCxRXl|Y_+A_T2aRjP!e z(IQMY!(=U5NEsMJY12lw?Ft}=h@Az9hKRidh=GWK0>nbZPyyl~;$i{fA>#6(@Tt#U z=p9~P;UkfNP>L{OX~Kn5i54wgtXLW1#mkf^Q5GeoY-P&is4-10TCIGYIuZ8jqH)3r zT6f*0^S}dz9(hFXrI(8Q-@mWozvSsC#l=;|m#=a{LQ{pCtb&wOwR9P3WXVFxmye>M zK`T+B7Kv16rrD;eS8tXkO=dGNcy06j0)Rbj@U$~?+;GEO4iCJ(ul)kR+PAK1Heo`` z{!oB<5M}qnz=sbbfBsB_gyxfzTOdWMh4SPHmM>qcDplS5kxkt?VUIErXRPZ_0l8{&@Ql#}N zRob9Njg4lTZIdQVEEZa5v(;AHqDz;pdiC0-PoM2R@PQpJyKJZHM(pC?uxFn+_WIIS zy7rGxfF6j7@VJ3}{P{aTOzfZ-vEGy*!C}dgy=AVsdNpfyM4L88b?I_UzkbJ^b=C<( zhMaWA9jAQlYp3_U0`x&t;vW4nWMDOE#@DKq)nbdi?E@cpXCL_lco(8_$P)_ANR{e8 zd&6LMcE zbyt30XcXZ7a1=fBTxqWq6@0uZs`yMTP{XIWKpmgu1seEf98KABwKQwiUZ(;bd{-3c z;=8jz58um2->6Z8eW@|TZ|*TN&pcyC95HdsF;nk*-^?dIG53|PEcUNriT}!D<(g~O zZo6&6!C|`zjU55#*h`S$;G~m|-gU->0Rv8Zr8pBPJub~O)0H-DuJ@zH4T1j@w*=wi zPP%mW8Z~x^$Be2=2<1;aF}t52y9<+&KmD28T!s!Uc!QK0$oI zVds9H2jUA3$A>=(MXO)d9TNB_Ga1051c-eG#WU&K&aS&y(iqHu38qMx8l{O90>8jF1pRZkrrOlSy1Dg-3HTG3*WwsuEHyD z#Ku=+Iu6hP{;_lmEz6S80Otedlt`;xsVs(%W>~@GEa20uScOzc^wK030H&R{o1B0a zNWfCrMJ=^nV0_6y2rEmHi@HYdIwt_}^Q!|j*G?>|Kv!caHRcvtBifvg1>=zy%z=tay3zV&#TZQ{4^rGL!Aliw+m(-$4}Ip#nNgFF=Nw08dA#U}cfOoQ~=( zM@ceRpbJ7@bCftlX|JLD;F^|L>lPjRsDKr`DpGvp8j1N}dfK{P&ijp4{6zATr%(5~ zgCQa(8~;MyM7@$puf+4X=P)O*{iJ`}RM7KUSjeZj=X}h`lqM zg{K{S>rek_RK)7<1e_Al0=|Tpdv|Y8tK9nj7+aHR>q^Ig5fB>lKoJgyI?qKp^jrawC-{#**=Cz$g{E3_=(w(<_9xH50_q2L zjY3nF{vbtLgLA?;ZIj02RrMNKbIgpAA4`!C+A-bey4F*>`Q5a=3h9!z>Oakgtzx6q9s~WiWF*25e)GN*)q_k2molOXv(HdMB zv(3Wcb;8-WiStOO+%z?65C9IwC64VdG%F*jXVrJOiAQNg5h?vf*X-GY`h@hZBv|K} zM*7hNr}_;l>oY#iR`Nx!A(YipGl3wx0P!HQSnd6H_O6bJ4R3tp8C0*odKRUodt*9h{ zk5oy9ZX39T8LJg;?C0sTtZ}`L@ES!);j3Ap?pjYAI^aeIvC;&Vrq`#0&KP@tU1LVc zlyk`l_*YUiJZKg%GQbFSGa5l)hN02WY6g1&s!;@Wjs2J+T*u%rJ!{aEpOBm%IoCO~ ztPDqnCeh+SPF;&}3&GbPK;A=4bDfb%#b|T^au+c^`;mK? zOtC&{gFfT;bc1VUl294o+J~H_4zbv|+e;(Y4J2_~xr-Pj?3n^Vo<~A`xXKqR~i5k>2U*VyVZ~6eibs5?qA`!0WcTV~?yM zsjqKZx8B&VZkX3!&ewpDN#I=Ofd)F#xdT>GaFa_-B#f}I3uoUR8>iy-71jSwZ@}o) z64l+4QDi<<>eM#!WRU$KrWHQ0cTBa&<{tWuPirvj;7yH5aGy(#k-vpFq(u&xiDBeG z2=PU9P&uP?=trZn-ML&7jpo-G_5)Nm5(o=1LTM7yS_~Umg@s2h6>^A??UYID$Q>*1 zwGl&ls^S8Q8A1dvz!W*pHZE_@siFDD#GccT%AO24?yvalHtv!@jI*W0V`%E` z?NKmZ>@Qz01pmWZ5;=iYo(>JxvWO7vkhvM)gT~v3lwR%<0wJN^!+|_@D9T4Sc_qxi zifB?L4)2oiB8XJBJMOmKg1MXkgbm&EA$^tiBjBHzvTVbMK! zoW@ObGaWlK$oYc}But?Pvf4f60%TWy6GK7Rluo1k@W?tWx8C6PN*hE>jja!@7P};k z9l-%iMb~|Wkqx-)+aEwCDypCw6FKK(FsfkGa6#%LqD_U6?;J7k*QH>}N0EKwh4_lt zrgO|4s(lvD=C4JlzI|vul53txtoOE^q@+*}-y)^#{zlO$R-c@CP)^f5r~`}UU2G25 zVR^D^zk397g4Go3e@xWp4j@|n0po`Hn?6;q8gE4!tT!{h1>cH-+;*xkfx?zwYV3XI zY)^D?8$e=Yry+R~U3!4q7A%sNl|0Y#aT9f~jcV5v-HhmpuIZqfs@ppNc^h$dv1}Bs zLuNC=(ngT9I=u>e&{~PD;k6>A#eAJW5 z`hO!ShA6mTaOPce>oqg;PWgc)ReWD!o+td=&VE5l5Ae38@~h_v#Bs%vV~Rz`5vl9S zzqK--n=YoZ#GReoFD|nrJgH9AxKuXJl{i(Vn=DRV-Y6p<^-PWrtjH5nnvBqDBY8!@ zJ(!S@E}0~FT7lX|i%~VGQ3%Pkh4%T?sEj=&k{fxG`=b<{Lg2{c(8;dvAH$N)uL4Sl z!6FfsNH0m-P{VG6yAfU&p*i3qY6L|DjzCl698^DI( z$YO{JiAIcPF*eG!XN*r7bvoSqsthZ6^lINFDn&Rny69>P-D4)=_hH?z%S92lTkmQs zB498=T63IL`Gk>fzu<@8<5(OJnFLHD6Y+qr!^>sgx=N2BE$dlubz=b%MmjtfUU<%4 zut$&6>G8AElV_(U$BExs5*%SWKs|~pzP#65;e2QV<9EN?m{<+M4`Ez8u~PWROpuL9 ze7Mc{*h=FD$R`u{l<~ zSl)=EJfCw=S-YHhbF{P8y25sd{6Cm$5r zhEvPt-0uS4^Qm&0ZjLEWO%%}5&DZ*i4+h%s2iB8aCZ2mZ7!7m4{fV`1iqdf({3S0D z={Qm3$PC7r@tK6rNttVu8@(bxNDx*F`yCE37uf@%9#_hOBQ%9YAVu~EvM;dNujDQo zhZ)c5+&jP7NB@=Phxeavu3t;9Y~_+pQ&_z6;6>U`L%Y`R^p?8KwuBD(yAReb zuinqLH&Z#cCALP_hS|2DFjV`O26uW1N1z>cd~M^+vj@qg^NFm#A}zmuc|Ez55sBz7 zHrvZ9vM+M)I?gM4MpOcmmW68|fpO=PI0S6&xr+A~BH{ z1t5m8S0o88 z=}WDgKae>hvDbG`w;m)_uT7_+wU)Jd>bBYi@Ko~s!rdbTLxgKVu8}sSzBRzDCHfs- z#cfn)TButKo=$Etuhz=Yfl$6js!dWoct<!_5z*;a z_4ZcgH{TVh>j=eUqU5>)aUAK_rgG49(AI6Y)n5~(^W(+jZ@tr><2&!^zog5TCMv0) z`-LxR7MC!&AsplV1xFY&*$mQzC!KQ0c*2u)NzSV&Cw*T$?aPxtr`_y|Q}t2DT|pHW zj_2;?4yjoou`GdX_LBT}-DN6yS&yikK8Gx_DaO`sMJ${elFRn%r`{(z`))aY2OkZ!%;`xVRE)<;fzE&T2+$nmES>H`L>-;{%q5|cym3q zoZi@qzOt1W#F~%WgJkQ`_yyHvdt+1QLsg6i$rL=^RK&RO^*L;FP$vCzQMEWf+(`5)9oU zyr_6@rcHl}u?Bp+9AqR1@6t-!0O`u3`O<1CNZSVMQkiBK58RGSb)gExd#{348S!Cb6J%WrKP zV&^bs^Mq>9zN!rRpR=tFKz41pLR}o`Z&t7H(?jsE2HdA4h?gfbcoOwHHb1MX3Ycem zFWc-Re1MKG6sgH4c-&$QdI;xWd`g!nGssEiQ9=4;Q)Mz!^20FCNJz+>IP+@t@kfE~ zOb?@pheO{r%kMps6WVTC#%kSrnTA>@s5B?e6kZMuVaCeY8SV@W0$vH+#7g$U@L2H0 z84spHUeXi-{{RwHWlf~S2O)2kL>dV(zS;-xQJqFFI81O@QWM6&W*q0C>*Po?gzyjz zzoX8*UMo2r1yv%vi$;^dlQefh1^@{X9b%D&A&9vH&vnBjfmC8Fr5GL$|M-jbEO>~h zJoA;3;RSmVh%=!T$%RX>2F_oEsLZ?Sv3|OJ`NpdkBS=C^3(RZSXGHpUGuiuuJy3Ig z1fnu#qh@m1%H6Xw`rU#K=H(`{mq5%hsF<)UPAEE`AJ-_ER@RDFX<2Z(egJThl_{Cx zK!b}CSx(16Q0LEyu(^IQU7g*$dgs9!$O3iEgUFWSMvsE7R{2#Yvz#7$Q_+xnse~H|X z)tgcJ2GyVOcWl0$IWYYpZq_I@oN0J)^y5Kt%ji6W$T`}cdo6W7x;!l$+A!DmOksvy z4)9uLwHY#^ZUKm(g*i4fwo9R!{vAv*#`3?tNB zLX+z!fq;r7n%`pHoeO^;z-KV`omKnD|}`KN7L7iyIoc^oio-*IYw z)phDTZyIk7^waYAeK=Fnj(VE#C!Z{+I zB)|LpW!`yL@YIGd<7W{aS7i+Z(lb?Ua4p4m&WV;~b%W?*6zl)}6;guQKHQlYc{)+| zfC(Ol5>4nyAliqZ8l&yXSCo*Mm6)89U7K23mX=adTo=>Y8)3>W)n%0PigH}OfOXF&Y+6O!N>|SvSRSX z$lJA{?97ISOi+wW$c`{_S;u&nd5J>|ax5Vb)F-hF!c$<@t3Dzq*lME@tuSMRA+ZRG z3Y~pcaOUyg0)oP#ZGZ}3qQy$p%obACrlr*?iTMR1p@`FJ<7zx4T9cQmrQF(cem zNz-3_1zitImpc8Q!*iA|zpATy*0I#(>U3_}^bCaig{dzN*}fFaUVrnB=TC1bo;})= zP@M=fBe0E!uc=lDWCr z`PMEZYEFtyI|Zc>7JFrGnwp~3TF;pHj`*bqf}Etxb_RhXzJ*Wqb{GlsD8k4noNyD2 z2s4Mq!Kn3f8|GrDXKO?<863h2SZ;W|66Vp_`fUiC@Og?PbM=KZk1j0CgPH`7qw&6g zse?Z*;5DV1f@#FGGe9lRsWG)eOkBE>1afBsblFnUNS5*gXU*863N8{n{$xk+%*qBo zH4`D*zL0z&vX}}=n@su{_MGT54ZJYWL82ZoV|@uTFYg1f%O!Z`$Of!J7%6NNV|A*l z3lhj82|KbAX4E|gq!5?@fJk=JKt!TTf1NC5BmC^ycE9Y{817SPu9N5i!28y%0sf>d za9>T4ycPtRU2@#30dli%WJQD;w*9oDkWCkI21A!D46Viq+Gbu)`S# z%{Lx7kDq9Wbq#Jfaol;cUdC5Zswm6!=~vtu^@k5TK}k`2n@<;M9iRZwgDZZWu1Krd z47lRgA=Zo;Oqr1tku0WhjrH9nu^qb%h8WQF;GUNbb z4d61!Rsjg|_f*4g`~BDO>d387If z5{lsQ3026x{^WwFeoN>~N9-jyP5Di>sVRaMDanVqMv8i!Rhl{$E$vnGP8N_VM~k|V zF|Nblp!33YXI%TpVBNz$pBsFN?Fd`tw)~Kucg+iXUE}HBM<7@ROOPF7J7opb1WnO| z!659ivWJ}03fs>}24fZ%qk{+e+y^+*kEq4R_;&uUF+j+1`(nlwBlI5;F+`r3EW zxaO1&bX&mWNAeq`3q;6QZaFx@sK0J}B1_3kA5^a%FlP2o2CRrgnrqC;n$rSHa zmy0QyB4{{9(q9f;C0^?45Ai3*cT{zl{bl0i1>D9}zlngLDXPvUUBY1TH)%;>a`Dl^ zqevcHy|a;Lk;f(|nOYa>E6O_@yw9y~Xx*%}V98%EI~?V&j|!}Ai)oCRTNFf|Qrp#P zHOOo_#CPqR*~dy8zUIC9r%&bBLsvbyCSv-Q82EYwO$@^{<}duyC2keNwb7j z&=G!Tv(NfP6sXf{Jy^ursK>eK6s1iwQD{4`NhJc(<)bJdj0%I6`{ZL|~&)qLW|-$6U}4_X4#8+C+}9ze_Q{ zRmP@yQappf<#ir4CoT3PR#y4jjDdt+GvK^l=*RQBtX!EjC8uJ|nhJ$QA%RD8vG)BCoR(*-d&ZN*lSgc!iZ5}kw*7p51AneyCx#ugLUr_vyesxL4G2|b;Qgb}{> z2w;a00_z;epiI z-uLR#NYAQbV&qoX8Md|yHtOG_c}2WvACW8Ph3HG}Ab*5AA{`OEN_bw8(shK2;Y7By z@^|U)fJh;VRiGa(358Gb$l6?4S#z$cYGNX*pgmQ923dy`V`W-hZK@KFl647}#OGYH zJil68JpZ{R_k28ekXuGmiqkhVHEl>Q-u|l>jJJxA1Bt9GTExhhMF`vy^rXvT&`71Z z-c0~@41<=LP!N&ya!O=YasY{eV-~>Q^#oUjnSmIb4bs7oPD@2E5?+YD-o9EI>NdqK zR9byz(3Bul{UBZ?yaF9C!fZdH5KB=AW3vJygoY=jsPr{xj3^2#q?wu!$p@kUC-M2d z(PP`$MGAu+_ Sn5Cb|8$ZV;<;*DhAz@ULtdJiS>%~Xq;`=Gf-Y37)Nu5xRh{nW_8G$UFN3f8!q*5fijl3 zgjhHxL<}+|^#-b7lYpYXL_wiwLG4D}pnQBeBGl8u^>KS_Xx+VpJe{u}QVwFrf6>pu zh|@H0^KDjFRqVaPo;-G1jP;me@It3KiR;YfOKgsWJ-dVEolu; z`Mw{^JcCimq{S(^2vG-z;0Klagim_vXk@it6WE_=%qe5f4TStCWESF9^?@vjh#597WNOB@BL+m$$ zL!O0iG9jAqOnl4k?&Mtkg9M!Lza%B2kkT~yCk->E3#U%kR4Fs|8uCGuLK`2at&Y=$ zWIA-!4dUnfJ^u3P<8_XxTO^2D&8tgItyM+My0`=}i0sBooEWwn>t;Tr(SgN_%2@Kf zx@Em|5X*v$*J_<1fNt%jydS(}SRzI-qtwulr@CP0j#o$=fih!!@WEidnb=c?yASUT zJ`lXO3Q*&XBa;=`vCw~kLsN_b0ugE>ws$2w0l@LLh;0ZF2!bIgXY;nLPBUwxnCui& zZQ1PEwPkrd*$gORtPE?mj#)=Xt&9~53t)MIGpC}_znL@PfL0Ydbq1tnK4-&kuCOk@ z899@oj7AzPP`-6KpIEw7Y-PIEZPB9b-HPj(#n%;QpFDIowE7nmow;CH5H4R(T`gJg zxwG7O*rvMeoi};9PB%UBy}NXbRvSY_+~JcU)1FTYq4=3DiUyR1+B@_U)7 zF*ZK7@gPIF@57Ork=8L@?O{;qsQNtR$Bz^$#$(`LwxUwd`Uvrwv(^!aaM{)lZ*h3|+n~xDwE+z? zXS!z3j18b&&dQ*QPJ?D14Mpt%8T=wLM0hpH${Q@SCqAvZ5%RL8s&NlOs0Y7TG<-W9 zx2>3ObQ%pBjb$IMcVTmr8SgVh&63RmZyW{m`q;=urPKR&|0c#X7XP-pfBKc1zt+0a zhLb)v7&nKT!i|=wyTR3N=nF{51~^AguELTEvr;esv!Q<6x5Mq1UG=KU-dtKQ_z9-*CfMlzcfrC(eb8AtGwP1J#{y#{c5EgTsW)sZKru}+uH0TDV7Cx z+~EQp(IeCvjiY&L5J{4G%mm*+)=oaN;& zAa1)XGn_ycGAE;JKKx!@REQc!387=6ScODeQa*($ojrz4iAc_qBRlF3%=7Ood>COo zD@H+wYX7M_94zO7C6Cx`QFQ-xp2zH5NC`vSlFF72Iq?RWRus{-!4yX}t+7iavjElAV>?RI~i-Rb|AEw5I!ef_cC z{Aqvvwr6qOxic$!zdCoe5uJ1UE;PN#gEtTMw(Edi%lOjt^XdC*9&%!PPj5&(_SXD> z+5OR+)V}q231JLg)qDHze|sNXy*H)uRY-Wj1FN6XSc)}}LXt&nBgxQZ>>Js!2M_l5 zNBspy+1{=>CCTL8w*EtrwIMhh#1*w5&5roMT)bx4+iy>;aGK^{zYgNTTG8)(mqZk= zi;vSPgxnD>L1CfDYv~ge1jPu(hIo;r>%8Aj%G4*K7YWZ@_X~Do5B}qB>y2_j8GnCp z83_B8eR=SRH4n?~ec!K}8w%Fq;;@F$hLWO&^w7h!Zw?q|lq5Wvk#2;CCv}6mRBkFO z=iXh;FoO(}&^?1Pkx@`&ifC+NCC>-AK)u(`Fu%9EDSw%FcYiz+KsqAt{R6^3oQp&e zzGov$1|uP+RE6RL@8?aH2Nm(isonwNXkYYi3fyXA!V05Q`euxKa}X=T#lx;Nzp6*Bt=In#S~1p^Q+BKcSP`BkWDgzR zG?vKDPkt}20mP!vj~kf=Mumix@G;vT`wWA@BC*VyHxr!pUbPBxt-uI7#vBMCnj(#F z4h2btVTi3fRI}0Aii#$0U+y56pxwO(WGe~J5Fze|-o7mVaNA$EgAjK{+xM{nB9y-6 zxW$%2&NEODJs<30 z=sM+{a+{Tl32&2ZwoD_~)2htNidBizwKh{Uedxp_CM~+RGnRR;xZ%Y5($1>kE*D%fr*cQ^}A@K3^J~ zvh2q!cTTJaC+n|eDZx^{OwILCM+NVF>%THN9Ft3seMzY3fw|m}vxxiDo zzwm(8PYWZC96F>@u_7*N=H<;p^w4HtwB@kQ@PB3HmF%)TS!Ls(*bR*pr^}{I?X0Qk z!QC?plA7yMl+&guXSx#}-jB<_+Zn}p5NP4R-H4W!N3>SEYPGVsevR|WRQ2LVp?{z; zP%N0HR8C8&OKP53(2aX)YC5ONrWL1;9l+m}KlZNBI|mjb55mKUr(SWcX-u6phW-fM zsH?9pqYzaFY6=tXsCJpWJkyd9osYX*-$LJw8wv5vQz6OEusj{Q!g4n89#4 zwi66u;Ov;eO`8m*FJ6=w;O1%X^88okTfcKOUc7fMUvBs{D8kGMV!r**JEUv>vWs`N zycphB$QigFAZobt*Ks#Np2W!CHN+p}1{_}eTLCOO&Uf>OoZ~&hAMs|Mc>8z^J$GO4 z{b179_1@q<04ddk3vm5&6m&bnneA1Y!FLOY9{+iN^x_ULdQ(!Y?_zV+A8R`;X(1k> z6W!jy84ax-Uh&lrQmQid%Ktb~&^z*V>CBJ3-)81amiur=_=9|Xst5NvNHX!#LpWD7 zj&);fuX+l}hX(GB@to~3I=sR>WZY@WFIwBm?8xx$~3Ajw`)K&rsp!` zgQp1Fp(1OlyV~2^ARG(NIvyu_j#O2DXsDqJ&@I*0O}Vjq8hT+y>g2#I=8gR^R6tT>%H|y8@$`XIp@9H zj=Y}c|5!i2eV|}TN_1LHvsNXPyMAb$d&=4gH@7;sXZg>C8a^>)H%>of3vI0roa?8l zKKe`bo@2j!qJVb$^M61y%$z#gL)t)gTvqj)CGEeuopn9i6L9KjRC38|4~I|KTo9BT7nEVboX#+Y zM2x%HN!m~vh~f_NUl)I(wg>|@3bF+fokZ9gvmU|Fl4Dwhbq`p5+a&y$K@6LQNf>Yg z6w6Yy_&GlcTlQO8Ab2abA_g^f=p=L!Xf9-qIKAWnVZEQ z5lAzgoGK$SLh#crN;AnvM|3x@zn^{2qTK}!xmOE9fJrkSr2%F+&tg_#rshdU3)lp+ znd7XTQjoQ>P@}t=qr4#Zkrm$1>D;HSS(BJZX4sJ=uwy1c#%p8ulL%}`obujuRv2X# zg4YZYEi6eQe>WqLJCpERh9Cv=N+S>QZOyU?5won>mcL*O=~dM-l<7(d$-N-B5KMl! z1e7<}wm~&lRgq__tH+;KRPCcDrxqhmCO780Xu@Rbrn|;4GF{w_;`?oQqt2&h6ltT| zP3p%5jv|sUF(yR9Tp!;NAO5{Y<1ElDl*H?HCeF%W?J!_?};2nR*6mZxxm(3Z1 zRjdv1YX871_krutPm$BvBj5I$#ptzLZa(qA4@J5Qww2~1yVWjk%tz)mi6pi=zkQG` z(iau6ixbj~VMlJTAooB`1}xXNsSJKn!M56K`;@eg6h+fSa&o8Cwha$^J1IW(@BjDs z3R7F_rZuI}stsOpp|9fq|5E(pA$?S}gKbNOIKNN|>|~u-LHS*Hp%`j2bHHX?{>S}v zNb`AJ#8DaT3i#o8oAcp;^U5r$KDIHqxHz~mww}sTTGbT}XZvs5*<2{J8S&MmST--x ze*s16UUSOM!ti6CGjN7);j!v&g&iHt^yK+G_syO9x=kHpkVka-iVp4n;Dh~#L=pS! zvUnRkPJ@}$tL+u^Zi&BavLdisjnt^yW&=%QW58|8Wog7+Ng~^xCb~Oykte0 zTLObRMc$alAI*Atn{V#YifKgSorRsa{7B!^$Vn7BUj7?$f6)TuAD4&gbN_ zQx~$*sxT+-@y$ohM~@ymZ$7@UR4wgI$-LByQrSfL!gCbxyK} zX80pX@-B(&=yy7^`2|#E+k#^8{HqDK`wT^2xNz#^;7GKUb~#<Ej?D754bISBm-~QAB zVVss{eCKsPKch2x)7uhf`k%{Up&^NX?xS~m zOiA$&6FqFoxuz^*dS_b`9a0eay@55=vELpA5IQOVsf7H@yP@9)3 z%sCYrvc*}tD#&z_O=o}A)!kB*(6OD>XiB)^&WCP$bfPv9aUdyZkYyo15ZRp>P57^L zv0xApPjYtYx9?4=^R}Lp(1*3Sxe+;KpTj_5aT4~KbL(9N3rh`?L1Cd#?Y4#H7fv4O zF?*g)7w3!ivz-war;EOWQN%wX&|e< z9#*@~r>#11(FMQGpU)nR5n&tD(Q6{u6zk8m&$EZvH4GCe_vS*FQ+vpO%(;>XD5~~o%S+|`Kv?C^3qaS=&aD$g}Pbkb09ODUnRbh z7j(E1T*Dh*s~^1a`pf9FR|&5QlzXDXgAf#MjYX=-D;bEB%f8wQ-iBXe8B14u zTavIxVOU+<$Q~a^Dg7*b`%|rxT@^5QbMn9HiWQSUI}Unw>zZeRo*5whn4A>@`!d(h zyx$GO2=p?V0%{`8R%aUEC%c>$6@Ag2$4k*zc z8AozvBwdqZMAb6viNuOf$=Bq-AyDSmpvLZT#WiidWT0`aLK0$Z`>2m&IhNXr~3>HsAfsqvf&5ZWYa$baeZh&Pxsg4|$ zqzpAf@v^qhHg?vMF0gKO4o;fwhi4cNg?1i)0#!EmULRic9TaM*m8KY$d` zT%g&b&*{#}X<>I0MR@jj$Uza+3lqg6rd@vlDP~)jMYPY*#u0jmyZDGE11*)&C?e31 z7g2;RzVn24aaFDwLep$jQXQSc&QkT54?ve+;B?CX3x_V1W(y6ES))j+x+ZR>Qw_X2 zFsuQZDTn>OgZ*7H(fMrgS zM9Y5S*KEl1DTiJ{0E_xEGZEU(vLX_-yGQe(vpmQNjR@TC{~;)WPJ8R+{(gKX!Angi z<%;8WMH@~yRFnGut+tlAtNp$PAOXn#YXea#>N3!+Q;F2l?}lot{>=_k?f{1iKp#rK z#sZo!j8j>hqpaZ#X==sw Date: Thu, 4 Jun 2026 23:20:23 +0800 Subject: [PATCH 3/7] fix: address image caption cache review feedback --- .../astrbot/group_chat_context.py | 41 ++++++-- astrbot/core/utils/image_caption_cache.py | 72 +++++++++----- tests/test_group_chat_context.py | 98 ++++++++++++++++++- 3 files changed, 181 insertions(+), 30 deletions(-) diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index aacd802e76..4256dbea8a 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -1,5 +1,7 @@ import asyncio import datetime +import hashlib +import json import random import uuid from collections import defaultdict, deque @@ -90,14 +92,8 @@ async def get_image_caption( ) -> str: if not image_caption_provider_id: provider = self.context.get_using_provider() - provider_id = ( - provider.provider_config.get("id", "") - if isinstance(provider, Provider) - else "" - ) else: provider = self.context.get_provider_by_id(image_caption_provider_id) - provider_id = image_caption_provider_id if not provider: raise Exception( f"Provider `{image_caption_provider_id}` was not found." @@ -107,6 +103,10 @@ async def get_image_caption( raise Exception( f"Provider type is invalid for image captioning: {type(provider)}." ) + provider_id = _resolve_provider_cache_identity( + provider, + configured_provider_id=image_caption_provider_id, + ) async def _caption_factory() -> str: response = await provider.text_chat( @@ -269,3 +269,32 @@ def _trim_left( def _format_group_history_block(records: list[str]) -> str: return GROUP_HISTORY_HEADER + "\n".join(records) + GROUP_HISTORY_FOOTER + + +def _resolve_provider_cache_identity( + provider: Provider, + configured_provider_id: str, +) -> str: + if configured_provider_id: + return configured_provider_id + + provider_id = provider.provider_config.get("id", "") + if isinstance(provider_id, str) and provider_id: + return provider_id + + payload = { + "provider_class": ( + f"{provider.__class__.__module__}.{provider.__class__.__qualname__}" + ), + "provider_type": provider.provider_config.get("type", ""), + "model": provider.get_model(), + "provider_config": provider.provider_config, + } + raw_payload = json.dumps( + payload, + sort_keys=True, + ensure_ascii=True, + default=str, + ) + digest = hashlib.sha256(raw_payload.encode("utf-8")).hexdigest()[:16] + return f"default:{provider.__class__.__qualname__}:{digest}" diff --git a/astrbot/core/utils/image_caption_cache.py b/astrbot/core/utils/image_caption_cache.py index c73feab5f4..52929dc105 100644 --- a/astrbot/core/utils/image_caption_cache.py +++ b/astrbot/core/utils/image_caption_cache.py @@ -14,17 +14,14 @@ def resolve_image_caption_cache_ttl(config: dict | None) -> int: - if not isinstance(config, dict): - return DEFAULT_IMAGE_CAPTION_CACHE_TTL - - ttl = config.get( + raw = (config or {}).get( "image_caption_cache_ttl", DEFAULT_IMAGE_CAPTION_CACHE_TTL, ) - if isinstance(ttl, bool): + if isinstance(raw, bool): return DEFAULT_IMAGE_CAPTION_CACHE_TTL try: - return max(int(ttl), 0) + return max(int(raw), 0) except (TypeError, ValueError): return DEFAULT_IMAGE_CAPTION_CACHE_TTL @@ -35,10 +32,16 @@ class _ImageCaptionCacheEntry: expires_at: float +@dataclass(slots=True) +class _ImageCaptionCacheLockEntry: + lock: asyncio.Lock + users: int = 0 + + class ImageCaptionCache: def __init__(self) -> None: self._entries: dict[str, _ImageCaptionCacheEntry] = {} - self._locks: dict[str, asyncio.Lock] = {} + self._locks: dict[str, _ImageCaptionCacheLockEntry] = {} def clear(self) -> None: self._entries.clear() @@ -69,23 +72,26 @@ async def get_or_create( ) return cached_caption - lock = self._locks.setdefault(cache_key, asyncio.Lock()) - async with lock: - cached_caption = self._get(cache_key) - if cached_caption is not None: - logger.debug( - "Using cached image caption after lock wait. provider=%s", - provider_id or "", + lock_entry = self._acquire_lock_entry(cache_key) + try: + async with lock_entry.lock: + cached_caption = self._get(cache_key) + if cached_caption is not None: + logger.debug( + "Using cached image caption after lock wait. provider=%s", + provider_id or "", + ) + return cached_caption + + caption = await caption_factory() + self._entries[cache_key] = _ImageCaptionCacheEntry( + caption=caption, + expires_at=time.monotonic() + ttl_seconds, ) - return cached_caption - - caption = await caption_factory() - self._entries[cache_key] = _ImageCaptionCacheEntry( - caption=caption, - expires_at=time.monotonic() + ttl_seconds, - ) - self._cleanup_expired_entries() - return caption + self._cleanup_expired_entries() + return caption + finally: + self._release_lock_entry(cache_key, lock_entry) def _get(self, cache_key: str) -> str | None: entry = self._entries.get(cache_key) @@ -104,6 +110,26 @@ def _cleanup_expired_entries(self) -> None: for key in expired_keys: self._entries.pop(key, None) + def _acquire_lock_entry(self, cache_key: str) -> _ImageCaptionCacheLockEntry: + lock_entry = self._locks.get(cache_key) + if lock_entry is None: + lock_entry = _ImageCaptionCacheLockEntry(lock=asyncio.Lock()) + self._locks[cache_key] = lock_entry + lock_entry.users += 1 + return lock_entry + + def _release_lock_entry( + self, + cache_key: str, + lock_entry: _ImageCaptionCacheLockEntry, + ) -> None: + current_lock_entry = self._locks.get(cache_key) + if current_lock_entry is not lock_entry: + return + current_lock_entry.users -= 1 + if current_lock_entry.users <= 0: + self._locks.pop(cache_key, None) + async def _build_cache_key( self, *, diff --git a/tests/test_group_chat_context.py b/tests/test_group_chat_context.py index c1e3a52899..9fdbfe8e37 100644 --- a/tests/test_group_chat_context.py +++ b/tests/test_group_chat_context.py @@ -1,10 +1,14 @@ +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from astrbot.builtin_stars.astrbot.group_chat_context import GroupChatContext from astrbot.core.provider import Provider -from astrbot.core.utils.image_caption_cache import image_caption_cache +from astrbot.core.utils.image_caption_cache import ( + ImageCaptionCache, + image_caption_cache, +) @pytest.mark.asyncio @@ -41,3 +45,95 @@ async def test_group_chat_context_reuses_cached_image_caption(tmp_path): assert caption2 == "cached caption" provider.text_chat.assert_awaited_once() image_caption_cache.clear() + + +@pytest.mark.asyncio +async def test_image_caption_cache_releases_per_key_lock_after_waiters_complete(): + cache = ImageCaptionCache() + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def caption_factory() -> str: + nonlocal calls + calls += 1 + started.set() + await release.wait() + return "cached caption" + + task1 = asyncio.create_task( + cache.get_or_create( + provider_id="caption-provider", + prompt="Please describe the image using Chinese.", + image_urls=["same-image.png"], + ttl_seconds=600, + caption_factory=caption_factory, + ) + ) + await started.wait() + task2 = asyncio.create_task( + cache.get_or_create( + provider_id="caption-provider", + prompt="Please describe the image using Chinese.", + image_urls=["same-image.png"], + ttl_seconds=600, + caption_factory=caption_factory, + ) + ) + + await asyncio.sleep(0) + assert len(cache._locks) == 1 + + release.set() + + assert await task1 == "cached caption" + assert await task2 == "cached caption" + assert calls == 1 + assert cache._locks == {} + + +@pytest.mark.asyncio +async def test_group_chat_context_default_provider_cache_identity_is_stable_per_provider( + tmp_path, +): + image_caption_cache.clear() + image_path = tmp_path / "same-image.png" + image_path.write_bytes(b"same-image-bytes") + + provider1 = MagicMock(spec=Provider) + provider1.provider_config = {"type": "openai_chat_completion"} + provider1.get_model.return_value = "gpt-4o" + provider1.text_chat = AsyncMock( + return_value=MagicMock(completion_text="caption from provider one") + ) + + provider2 = MagicMock(spec=Provider) + provider2.provider_config = {"type": "google_genai"} + provider2.get_model.return_value = "gemini-2.5-pro" + provider2.text_chat = AsyncMock( + return_value=MagicMock(completion_text="caption from provider two") + ) + + context = MagicMock() + context.get_using_provider.side_effect = [provider1, provider2] + + group_chat_context = GroupChatContext(MagicMock(), context) + + caption1 = await group_chat_context.get_image_caption( + str(image_path), + "", + "Please describe the image using Chinese.", + 600, + ) + caption2 = await group_chat_context.get_image_caption( + str(image_path), + "", + "Please describe the image using Chinese.", + 600, + ) + + assert caption1 == "caption from provider one" + assert caption2 == "caption from provider two" + provider1.text_chat.assert_awaited_once() + provider2.text_chat.assert_awaited_once() + image_caption_cache.clear() From b9a7a00fd21f3b73760088ea584721bd2b65c749 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Sat, 6 Jun 2026 10:51:39 +0800 Subject: [PATCH 4/7] refactor: address reviewer feedback and fix memory leak --- .../astrbot/group_chat_context.py | 58 +++++----- astrbot/core/utils/image_caption_cache.py | 69 ++++++++---- tests/test_group_chat_context.py | 102 +++++++++++++++++- 3 files changed, 181 insertions(+), 48 deletions(-) diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index 4256dbea8a..22880acd5d 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -1,7 +1,5 @@ import asyncio import datetime -import hashlib -import json import random import uuid from collections import defaultdict, deque @@ -108,22 +106,31 @@ async def get_image_caption( configured_provider_id=image_caption_provider_id, ) - async def _caption_factory() -> str: - response = await provider.text_chat( - prompt=image_caption_prompt, - session_id=uuid.uuid4().hex, - image_urls=[image_url], - persist=False, - ) - return response.completion_text - return await image_caption_cache.get_or_create( provider_id=provider_id, prompt=image_caption_prompt, image_urls=[image_url], ttl_seconds=cache_ttl, - caption_factory=_caption_factory, + caption_factory=lambda: self._fetch_image_caption( + provider, + image_caption_prompt, + image_url, + ), + ) + + async def _fetch_image_caption( + self, + provider: Provider, + prompt: str, + image_url: str, + ) -> str: + response = await provider.text_chat( + prompt=prompt, + session_id=uuid.uuid4().hex, + image_urls=[image_url], + persist=False, ) + return response.completion_text async def need_active_reply(self, event: AstrMessageEvent) -> bool: cfg = self.cfg(event) @@ -278,23 +285,18 @@ def _resolve_provider_cache_identity( if configured_provider_id: return configured_provider_id - provider_id = provider.provider_config.get("id", "") + provider_config = provider.provider_config or {} + provider_id = provider_config.get("id", "") if isinstance(provider_id, str) and provider_id: return provider_id - payload = { - "provider_class": ( - f"{provider.__class__.__module__}.{provider.__class__.__qualname__}" - ), - "provider_type": provider.provider_config.get("type", ""), - "model": provider.get_model(), - "provider_config": provider.provider_config, - } - raw_payload = json.dumps( - payload, - sort_keys=True, - ensure_ascii=True, - default=str, + provider_type = provider_config.get("type", "") + model = provider.get_model() + return ":".join( + [ + provider.__class__.__module__, + provider.__class__.__qualname__, + "" if provider_type is None else str(provider_type), + "" if model is None else str(model), + ] ) - digest = hashlib.sha256(raw_payload.encode("utf-8")).hexdigest()[:16] - return f"default:{provider.__class__.__qualname__}:{digest}" diff --git a/astrbot/core/utils/image_caption_cache.py b/astrbot/core/utils/image_caption_cache.py index 52929dc105..d03a051018 100644 --- a/astrbot/core/utils/image_caption_cache.py +++ b/astrbot/core/utils/image_caption_cache.py @@ -3,7 +3,9 @@ import asyncio import base64 import hashlib +import inspect import time +from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path from urllib.parse import unquote, urlparse @@ -54,10 +56,10 @@ async def get_or_create( prompt: str, image_urls: list[str], ttl_seconds: int, - caption_factory, + caption_factory: Callable[[], Awaitable[str]], ) -> str: if ttl_seconds <= 0: - return await caption_factory() + return await self._invoke_caption_factory(caption_factory) cache_key = await self._build_cache_key( provider_id=provider_id, @@ -83,7 +85,7 @@ async def get_or_create( ) return cached_caption - caption = await caption_factory() + caption = await self._invoke_caption_factory(caption_factory) self._entries[cache_key] = _ImageCaptionCacheEntry( caption=caption, expires_at=time.monotonic() + ttl_seconds, @@ -93,6 +95,17 @@ async def get_or_create( finally: self._release_lock_entry(cache_key, lock_entry) + async def _invoke_caption_factory( + self, + caption_factory: Callable[[], Awaitable[str]], + ) -> str: + result = caption_factory() + if not inspect.isawaitable(result): + raise TypeError( + "caption_factory must be callable and return an awaitable resolving to str" + ) + return await result + def _get(self, cache_key: str) -> str | None: entry = self._entries.get(cache_key) if entry is None: @@ -109,13 +122,18 @@ def _cleanup_expired_entries(self) -> None: ] for key in expired_keys: self._entries.pop(key, None) + self._locks.pop(key, None) def _acquire_lock_entry(self, cache_key: str) -> _ImageCaptionCacheLockEntry: + lock_entry = self._get_lock_entry(cache_key) + lock_entry.users += 1 + return lock_entry + + def _get_lock_entry(self, cache_key: str) -> _ImageCaptionCacheLockEntry: lock_entry = self._locks.get(cache_key) if lock_entry is None: lock_entry = _ImageCaptionCacheLockEntry(lock=asyncio.Lock()) self._locks[cache_key] = lock_entry - lock_entry.users += 1 return lock_entry def _release_lock_entry( @@ -146,30 +164,42 @@ async def _build_cache_key( async def _fingerprint_image(self, image_url: str) -> str: if image_url.startswith("base64://"): - raw_base64 = image_url.removeprefix("base64://") - try: - image_bytes = base64.b64decode(raw_base64) - except Exception: - return f"ref:{image_url}" - return self._hash_bytes(image_bytes) + return self._fingerprint_base64_image(image_url) if image_url.startswith("data:image"): - try: - _, encoded = image_url.split(",", 1) - image_bytes = base64.b64decode(encoded) - except Exception: - return f"ref:{image_url}" - return self._hash_bytes(image_bytes) + return self._fingerprint_data_uri_image(image_url) if image_url.startswith(("http://", "https://")): - return f"url:{image_url}" + return self._fingerprint_remote_image(image_url) + return await self._fingerprint_local_image(image_url) + + def _fingerprint_base64_image(self, image_url: str) -> str: + raw_base64 = image_url.removeprefix("base64://") + try: + image_bytes = base64.b64decode(raw_base64) + except Exception: + return self._reference_fingerprint(image_url) + return self._hash_bytes(image_bytes) + + def _fingerprint_data_uri_image(self, image_url: str) -> str: + try: + _, encoded = image_url.split(",", 1) + image_bytes = base64.b64decode(encoded) + except Exception: + return self._reference_fingerprint(image_url) + return self._hash_bytes(image_bytes) + + def _fingerprint_remote_image(self, image_url: str) -> str: + return f"url:{image_url}" + + async def _fingerprint_local_image(self, image_url: str) -> str: local_path = self._to_local_path(image_url) if local_path and local_path.is_file(): image_bytes = await asyncio.to_thread(local_path.read_bytes) return self._hash_bytes(image_bytes) - return f"ref:{image_url}" + return self._reference_fingerprint(image_url) def _to_local_path(self, image_url: str) -> Path | None: if image_url.startswith("file://"): @@ -191,5 +221,8 @@ def _to_local_path(self, image_url: str) -> Path | None: def _hash_bytes(self, payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() + def _reference_fingerprint(self, image_url: str) -> str: + return f"ref:{image_url}" + image_caption_cache = ImageCaptionCache() diff --git a/tests/test_group_chat_context.py b/tests/test_group_chat_context.py index 9fdbfe8e37..679ce1b793 100644 --- a/tests/test_group_chat_context.py +++ b/tests/test_group_chat_context.py @@ -1,10 +1,15 @@ import asyncio +import base64 +import hashlib from unittest.mock import AsyncMock, MagicMock import pytest -from astrbot.builtin_stars.astrbot.group_chat_context import GroupChatContext -from astrbot.core.provider import Provider +from astrbot.api.provider import Provider +from astrbot.builtin_stars.astrbot.group_chat_context import ( + GroupChatContext, + _resolve_provider_cache_identity, +) from astrbot.core.utils.image_caption_cache import ( ImageCaptionCache, image_caption_cache, @@ -92,6 +97,62 @@ async def caption_factory() -> str: assert cache._locks == {} +@pytest.mark.asyncio +async def test_image_caption_cache_accepts_lambda_caption_factory(): + cache = ImageCaptionCache() + calls = 0 + + async def fetch_caption() -> str: + nonlocal calls + calls += 1 + return "cached caption" + + caption1 = await cache.get_or_create( + provider_id="caption-provider", + prompt="Please describe the image using Chinese.", + image_urls=["same-image.png"], + ttl_seconds=600, + caption_factory=lambda: fetch_caption(), + ) + caption2 = await cache.get_or_create( + provider_id="caption-provider", + prompt="Please describe the image using Chinese.", + image_urls=["same-image.png"], + ttl_seconds=600, + caption_factory=lambda: fetch_caption(), + ) + + assert caption1 == "cached caption" + assert caption2 == "cached caption" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_image_caption_cache_fingerprints_supported_image_reference_types( + tmp_path, +): + cache = ImageCaptionCache() + image_bytes = b"same-image-bytes" + expected_hash = hashlib.sha256(image_bytes).hexdigest() + image_path = tmp_path / "same-image.png" + image_path.write_bytes(image_bytes) + encoded = base64.b64encode(image_bytes).decode("ascii") + + assert await cache._fingerprint_image(f"base64://{encoded}") == expected_hash + assert ( + await cache._fingerprint_image(f"data:image/png;base64,{encoded}") + == expected_hash + ) + assert await cache._fingerprint_image(str(image_path)) == expected_hash + assert ( + await cache._fingerprint_image("https://example.com/image.png") + == "url:https://example.com/image.png" + ) + assert ( + await cache._fingerprint_image("missing-image.png") == "ref:missing-image.png" + ) + + @pytest.mark.asyncio async def test_group_chat_context_default_provider_cache_identity_is_stable_per_provider( tmp_path, @@ -137,3 +198,40 @@ async def test_group_chat_context_default_provider_cache_identity_is_stable_per_ provider1.text_chat.assert_awaited_once() provider2.text_chat.assert_awaited_once() image_caption_cache.clear() + + +def test_resolve_provider_cache_identity_prefers_configured_provider_id(): + provider = MagicMock(spec=Provider) + provider.provider_config = {"id": "provider-config-id", "type": "google_genai"} + provider.get_model.return_value = "gemini-2.5-pro" + + assert ( + _resolve_provider_cache_identity( + provider, + configured_provider_id="configured-provider-id", + ) + == "configured-provider-id" + ) + + +def test_resolve_provider_cache_identity_uses_provider_config_id_as_fallback(): + provider = MagicMock(spec=Provider) + provider.provider_config = {"id": "provider-config-id", "type": "google_genai"} + provider.get_model.return_value = "gemini-2.5-pro" + + assert ( + _resolve_provider_cache_identity(provider, configured_provider_id="") + == "provider-config-id" + ) + + +def test_resolve_provider_cache_identity_uses_deterministic_string_when_ids_absent(): + provider = MagicMock(spec=Provider) + provider.provider_config = {"type": "openai_chat_completion"} + provider.get_model.return_value = "gpt-4o" + + assert _resolve_provider_cache_identity(provider, configured_provider_id="") == ( + f"{provider.__class__.__module__}:" + f"{provider.__class__.__qualname__}:" + "openai_chat_completion:gpt-4o" + ) From 79af9fbd7a953d48b723fab34a5a3634067e87d3 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Sat, 6 Jun 2026 11:04:20 +0800 Subject: [PATCH 5/7] refactor: simplify locking and remove factory reflection --- astrbot/core/astr_main_agent.py | 8 ++- astrbot/core/utils/image_caption_cache.py | 83 ++++++----------------- tests/test_group_chat_context.py | 4 +- 3 files changed, 30 insertions(+), 65 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index bd360b47c5..b8a5814fac 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -863,11 +863,15 @@ async def _process_quote_message( class _QuotedImageCaptionContext: def __init__(self, provider: Provider) -> None: self._provider = provider - def get_provider_by_id(self, provider_id: str) -> Provider: + wrapped_id = getattr(self._provider, "id", None) + if wrapped_id is not None and provider_id != wrapped_id: + raise ValueError( + f"Requested provider_id '{provider_id}' does not match " + f"wrapped provider id '{wrapped_id}'." + ) return self._provider - def _append_system_reminders( event: AstrMessageEvent, req: ProviderRequest, diff --git a/astrbot/core/utils/image_caption_cache.py b/astrbot/core/utils/image_caption_cache.py index d03a051018..7e09253162 100644 --- a/astrbot/core/utils/image_caption_cache.py +++ b/astrbot/core/utils/image_caption_cache.py @@ -3,7 +3,6 @@ import asyncio import base64 import hashlib -import inspect import time from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -34,16 +33,10 @@ class _ImageCaptionCacheEntry: expires_at: float -@dataclass(slots=True) -class _ImageCaptionCacheLockEntry: - lock: asyncio.Lock - users: int = 0 - - class ImageCaptionCache: def __init__(self) -> None: self._entries: dict[str, _ImageCaptionCacheEntry] = {} - self._locks: dict[str, _ImageCaptionCacheLockEntry] = {} + self._locks: dict[str, asyncio.Lock] = {} def clear(self) -> None: self._entries.clear() @@ -59,7 +52,7 @@ async def get_or_create( caption_factory: Callable[[], Awaitable[str]], ) -> str: if ttl_seconds <= 0: - return await self._invoke_caption_factory(caption_factory) + return await caption_factory() cache_key = await self._build_cache_key( provider_id=provider_id, @@ -74,37 +67,23 @@ async def get_or_create( ) return cached_caption - lock_entry = self._acquire_lock_entry(cache_key) - try: - async with lock_entry.lock: - cached_caption = self._get(cache_key) - if cached_caption is not None: - logger.debug( - "Using cached image caption after lock wait. provider=%s", - provider_id or "", - ) - return cached_caption - - caption = await self._invoke_caption_factory(caption_factory) - self._entries[cache_key] = _ImageCaptionCacheEntry( - caption=caption, - expires_at=time.monotonic() + ttl_seconds, + lock = self._get_lock(cache_key) + async with lock: + cached_caption = self._get(cache_key) + if cached_caption is not None: + logger.debug( + "Using cached image caption after lock wait. provider=%s", + provider_id or "", ) - self._cleanup_expired_entries() - return caption - finally: - self._release_lock_entry(cache_key, lock_entry) + return cached_caption - async def _invoke_caption_factory( - self, - caption_factory: Callable[[], Awaitable[str]], - ) -> str: - result = caption_factory() - if not inspect.isawaitable(result): - raise TypeError( - "caption_factory must be callable and return an awaitable resolving to str" + caption = await caption_factory() + self._entries[cache_key] = _ImageCaptionCacheEntry( + caption=caption, + expires_at=time.monotonic() + ttl_seconds, ) - return await result + self._cleanup_expired_entries() + return caption def _get(self, cache_key: str) -> str | None: entry = self._entries.get(cache_key) @@ -122,31 +101,13 @@ def _cleanup_expired_entries(self) -> None: ] for key in expired_keys: self._entries.pop(key, None) - self._locks.pop(key, None) - - def _acquire_lock_entry(self, cache_key: str) -> _ImageCaptionCacheLockEntry: - lock_entry = self._get_lock_entry(cache_key) - lock_entry.users += 1 - return lock_entry - def _get_lock_entry(self, cache_key: str) -> _ImageCaptionCacheLockEntry: - lock_entry = self._locks.get(cache_key) - if lock_entry is None: - lock_entry = _ImageCaptionCacheLockEntry(lock=asyncio.Lock()) - self._locks[cache_key] = lock_entry - return lock_entry - - def _release_lock_entry( - self, - cache_key: str, - lock_entry: _ImageCaptionCacheLockEntry, - ) -> None: - current_lock_entry = self._locks.get(cache_key) - if current_lock_entry is not lock_entry: - return - current_lock_entry.users -= 1 - if current_lock_entry.users <= 0: - self._locks.pop(cache_key, None) + def _get_lock(self, cache_key: str) -> asyncio.Lock: + lock = self._locks.get(cache_key) + if lock is None: + lock = asyncio.Lock() + self._locks[cache_key] = lock + return lock async def _build_cache_key( self, diff --git a/tests/test_group_chat_context.py b/tests/test_group_chat_context.py index 679ce1b793..64150b3ba1 100644 --- a/tests/test_group_chat_context.py +++ b/tests/test_group_chat_context.py @@ -53,7 +53,7 @@ async def test_group_chat_context_reuses_cached_image_caption(tmp_path): @pytest.mark.asyncio -async def test_image_caption_cache_releases_per_key_lock_after_waiters_complete(): +async def test_image_caption_cache_reuses_per_key_lock_after_waiters_complete(): cache = ImageCaptionCache() started = asyncio.Event() release = asyncio.Event() @@ -94,7 +94,7 @@ async def caption_factory() -> str: assert await task1 == "cached caption" assert await task2 == "cached caption" assert calls == 1 - assert cache._locks == {} + assert len(cache._locks) == 1 @pytest.mark.asyncio From ccae55ad1c8912b2e9e10cf68a52ee2559185a00 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Sat, 6 Jun 2026 11:15:22 +0800 Subject: [PATCH 6/7] fix: correct cache key semantics and lock lifecycle --- astrbot/core/astr_main_agent.py | 12 +++++--- astrbot/core/utils/image_caption_cache.py | 1 + tests/unit/test_astr_main_agent.py | 35 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index b8a5814fac..526a82c6d8 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -863,15 +863,19 @@ async def _process_quote_message( class _QuotedImageCaptionContext: def __init__(self, provider: Provider) -> None: self._provider = provider + def get_provider_by_id(self, provider_id: str) -> Provider: wrapped_id = getattr(self._provider, "id", None) - if wrapped_id is not None and provider_id != wrapped_id: - raise ValueError( - f"Requested provider_id '{provider_id}' does not match " - f"wrapped provider id '{wrapped_id}'." + if provider_id and wrapped_id and provider_id != wrapped_id: + logger.warning( + "Quoted image caption provider id mismatch. " + "requested=%s wrapped=%s. Using wrapped provider instance.", + provider_id, + wrapped_id, ) return self._provider + def _append_system_reminders( event: AstrMessageEvent, req: ProviderRequest, diff --git a/astrbot/core/utils/image_caption_cache.py b/astrbot/core/utils/image_caption_cache.py index 7e09253162..4d160d2933 100644 --- a/astrbot/core/utils/image_caption_cache.py +++ b/astrbot/core/utils/image_caption_cache.py @@ -101,6 +101,7 @@ def _cleanup_expired_entries(self) -> None: ] for key in expired_keys: self._entries.pop(key, None) + self._locks.pop(key, None) def _get_lock(self, cache_key: str) -> asyncio.Lock: lock = self._locks.get(cache_key) diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 58960fc727..7c140440d7 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1214,6 +1214,41 @@ async def test_request_img_caption_reuses_cached_result( caption_provider.text_chat.assert_awaited_once() image_caption_cache.clear() + @pytest.mark.asyncio + async def test_request_img_caption_quoted_context_tolerates_provider_id_mismatch( + self, + tmp_path, + ): + """Test quoted image captions reuse the wrapped provider despite ID mismatches.""" + module = ama + image_caption_cache.clear() + + image_path = tmp_path / "quoted-image.png" + image_path.write_bytes(b"quoted-image") + + caption_provider = MagicMock(spec=Provider) + caption_provider.provider_config = {"id": "provider-config-id"} + caption_provider.id = "wrapped-provider-id" + caption_provider.text_chat = AsyncMock( + return_value=MagicMock(completion_text="quoted caption") + ) + + cfg = { + "image_caption_prompt": "Please describe the image content.", + "image_caption_cache_ttl": 600, + } + + caption = await module._request_img_caption( + "provider-config-id", + cfg, + [str(image_path)], + module._QuotedImageCaptionContext(caption_provider), + ) + + assert caption == "quoted caption" + caption_provider.text_chat.assert_awaited_once() + image_caption_cache.clear() + @pytest.mark.asyncio async def test_build_main_agent_uses_image_fallback_provider( self, mock_event, mock_context From a945632dfb4f12901d7be431abec132592cd4bc9 Mon Sep 17 00:00:00 2001 From: FloranceYeh Date: Sat, 6 Jun 2026 11:28:12 +0800 Subject: [PATCH 7/7] refactor: extract provider-centric caption helper --- astrbot/core/astr_main_agent.py | 84 ++++++++++++++++-------------- tests/unit/test_astr_main_agent.py | 58 ++++++++++++++++----- 2 files changed, 89 insertions(+), 53 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 526a82c6d8..542d3ec5c6 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -582,6 +582,35 @@ async def _ensure_persona_and_skills( pass +async def _request_img_caption_with_provider( + prov: Provider, + provider_id: str, + image_urls: list[str], + prompt: str, + cache_ttl: int | None = None, +) -> str: + if cache_ttl is None: + cache_ttl = resolve_image_caption_cache_ttl( + prov.provider_config if isinstance(prov.provider_config, dict) else None + ) + logger.debug("Processing image caption with provider: %s", provider_id) + + async def _caption_factory() -> str: + llm_resp = await prov.text_chat( + prompt=prompt, + image_urls=image_urls, + ) + return llm_resp.completion_text + + return await image_caption_cache.get_or_create( + provider_id=provider_id, + prompt=prompt, + image_urls=image_urls, + ttl_seconds=cache_ttl, + caption_factory=_caption_factory, + ) + + async def _request_img_caption( provider_id: str, cfg: dict, @@ -604,21 +633,12 @@ async def _request_img_caption( "Please describe the image.", ) cache_ttl = resolve_image_caption_cache_ttl(cfg) - logger.debug("Processing image caption with provider: %s", provider_id) - - async def _caption_factory() -> str: - llm_resp = await prov.text_chat( - prompt=img_cap_prompt, - image_urls=image_urls, - ) - return llm_resp.completion_text - - return await image_caption_cache.get_or_create( + return await _request_img_caption_with_provider( + prov=prov, provider_id=provider_id, - prompt=img_cap_prompt, image_urls=image_urls, - ttl_seconds=cache_ttl, - caption_factory=_caption_factory, + prompt=img_cap_prompt, + cache_ttl=cache_ttl, ) @@ -824,17 +844,19 @@ async def _process_quote_message( ) if path and _is_generated_compressed_image_path(path, compress_path): event.track_temporary_local_file(compress_path) - caption = await _request_img_caption( - prov.provider_config.get("id", img_cap_prov_id or ""), - { - "image_caption_prompt": "Please describe the image content.", - "image_caption_cache_ttl": resolve_image_caption_cache_ttl( - config.provider_settings if config else None - ), - }, - [compress_path], - _QuotedImageCaptionContext(prov), + provider_config = ( + prov.provider_config + if isinstance(prov.provider_config, dict) + else {} + ) + caption = await _request_img_caption_with_provider( + prov=prov, + provider_id=provider_config.get("id", img_cap_prov_id or ""), + image_urls=[compress_path], prompt="Please describe the image content.", + cache_ttl=resolve_image_caption_cache_ttl( + config.provider_settings if config else None + ), ) if caption: content_parts.append( @@ -860,22 +882,6 @@ async def _process_quote_message( req.extra_user_content_parts.append(TextPart(text=quoted_text)) -class _QuotedImageCaptionContext: - def __init__(self, provider: Provider) -> None: - self._provider = provider - - def get_provider_by_id(self, provider_id: str) -> Provider: - wrapped_id = getattr(self._provider, "id", None) - if provider_id and wrapped_id and provider_id != wrapped_id: - logger.warning( - "Quoted image caption provider id mismatch. " - "requested=%s wrapped=%s. Using wrapped provider instance.", - provider_id, - wrapped_id, - ) - return self._provider - - def _append_system_reminders( event: AstrMessageEvent, req: ProviderRequest, diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 7c140440d7..0b06075f93 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1215,37 +1215,67 @@ async def test_request_img_caption_reuses_cached_result( image_caption_cache.clear() @pytest.mark.asyncio - async def test_request_img_caption_quoted_context_tolerates_provider_id_mismatch( + async def test_process_quote_message_uses_provider_instance_for_image_caption( self, tmp_path, + mock_event, + mock_context, ): - """Test quoted image captions reuse the wrapped provider despite ID mismatches.""" + """Test quoted image captions use the resolved provider instance directly.""" module = ama image_caption_cache.clear() image_path = tmp_path / "quoted-image.png" image_path.write_bytes(b"quoted-image") + quoted_image = Image(file=f"file:///{image_path.as_posix()}") + quoted_reply = Reply( + id="reply-1", + chain=[Plain(text="quoted text"), quoted_image], + sender_nickname="", + message_str="quoted text", + ) + mock_event.message_obj.message = [quoted_reply] + caption_provider = MagicMock(spec=Provider) caption_provider.provider_config = {"id": "provider-config-id"} - caption_provider.id = "wrapped-provider-id" caption_provider.text_chat = AsyncMock( return_value=MagicMock(completion_text="quoted caption") ) + mock_context.get_provider_by_id.return_value = caption_provider - cfg = { - "image_caption_prompt": "Please describe the image content.", - "image_caption_cache_ttl": 600, - } + req = ProviderRequest(prompt="Hello") - caption = await module._request_img_caption( - "provider-config-id", - cfg, - [str(image_path)], - module._QuotedImageCaptionContext(caption_provider), - ) + with ( + patch( + "astrbot.core.astr_main_agent.extract_quoted_message_text", + AsyncMock(return_value="quoted text"), + ), + patch.object( + Image, + "convert_to_file_path", + AsyncMock(return_value=str(image_path)), + ), + patch( + "astrbot.core.astr_main_agent._compress_image_for_provider", + AsyncMock(return_value=str(image_path)), + ), + ): + await module._process_quote_message( + mock_event, + req, + "caption-provider", + mock_context, + config=module.MainAgentBuildConfig( + tool_call_timeout=60, + provider_settings={"image_caption_cache_ttl": 600}, + ), + ) - assert caption == "quoted caption" + assert any( + "[Image Caption in quoted message]: quoted caption" in part.text + for part in req.extra_user_content_parts + ) caption_provider.text_chat.assert_awaited_once() image_caption_cache.clear()