diff --git a/.gitignore b/.gitignore
index 55d3ef197b..fb82def4d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,7 @@ backend/logs/
backend/uploads/
# Docker 数据
-data/
\ No newline at end of file
+data/
+# Personal configuration
+CLAUDE.md
+skills/
diff --git a/backend/app/api/graph.py b/backend/app/api/graph.py
index 759ff48b0e..300de49e9f 100644
--- a/backend/app/api/graph.py
+++ b/backend/app/api/graph.py
@@ -17,6 +17,7 @@
from ..utils.logger import get_logger
from ..utils.locale import t, get_locale, set_locale
from ..models.task import TaskManager, TaskStatus
+from ..utils.zep_rate_limiter import graph_data_cache
from ..models.project import ProjectManager, ProjectStatus
# 获取日志器
@@ -564,12 +565,35 @@ def list_tasks():
})
+# ============== 配置接口 ==============
+
+@graph_bp.route('/config', methods=['GET'])
+def get_graph_config():
+ """
+ 返回前端需要的图谱轮询配置。
+ 前端根据这些值决定是否自动轮询以及间隔。
+ """
+ # 初始化缓存 TTL(确保与 Config 同步)
+ graph_data_cache.ttl = Config.ZEP_CACHE_TTL
+
+ return jsonify({
+ "success": True,
+ "data": {
+ "poll_interval": Config.ZEP_GRAPH_POLL_INTERVAL, # 0 = 仅手动刷新
+ "cache_ttl": Config.ZEP_CACHE_TTL,
+ "rate_limit": Config.ZEP_RATE_LIMIT,
+ "rate_limit_window": Config.ZEP_RATE_LIMIT_WINDOW,
+ }
+ })
+
+
# ============== 图谱数据接口 ==============
@graph_bp.route('/data/', methods=['GET'])
def get_graph_data(graph_id: str):
"""
- 获取图谱数据(节点和边)
+ 获取图谱数据(节点和边)。
+ 使用响应缓存避免频繁调用 Zep API。
"""
try:
if not Config.ZEP_API_KEY:
@@ -578,12 +602,28 @@ def get_graph_data(graph_id: str):
"error": t('api.zepApiKeyMissing')
}), 500
+ # 检查缓存
+ cache_key = f"graph_data:{graph_id}"
+ cached = graph_data_cache.get(cache_key)
+ if cached is not None:
+ logger.debug(f"Serving cached graph data for {graph_id}")
+ return jsonify({
+ "success": True,
+ "data": cached,
+ "cached": True
+ })
+
+ # 缓存未命中,调用 Zep API
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
graph_data = builder.get_graph_data(graph_id)
+ # 缓存成功响应
+ graph_data_cache.set(cache_key, graph_data)
+
return jsonify({
"success": True,
- "data": graph_data
+ "data": graph_data,
+ "cached": False
})
except Exception as e:
diff --git a/backend/app/config.py b/backend/app/config.py
index 953dfa50a2..026378d928 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -32,9 +32,20 @@ class Config:
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
+ # Boost/Fallback LLM配置(可选,主 LLM 失败时自动回退)
+ LLM_BOOST_API_KEY = os.environ.get('LLM_BOOST_API_KEY')
+ LLM_BOOST_BASE_URL = os.environ.get('LLM_BOOST_BASE_URL')
+ LLM_BOOST_MODEL_NAME = os.environ.get('LLM_BOOST_MODEL_NAME')
+
# Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
+ # Zep 速率限制配置(可通过 .env 调整,升级付费计划后放宽)
+ ZEP_RATE_LIMIT = int(os.environ.get('ZEP_RATE_LIMIT', '5')) # 每个窗口期允许的请求数
+ ZEP_RATE_LIMIT_WINDOW = int(os.environ.get('ZEP_RATE_LIMIT_WINDOW', '60')) # 窗口期(秒)
+ ZEP_CACHE_TTL = int(os.environ.get('ZEP_CACHE_TTL', '30')) # graph data 缓存时间(秒),0=不缓存
+ ZEP_GRAPH_POLL_INTERVAL = int(os.environ.get('ZEP_GRAPH_POLL_INTERVAL', '0')) # 前端自动轮询间隔(秒),0=仅手动刷新
+
# 文件上传配置
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')
diff --git a/backend/app/services/graph_builder.py b/backend/app/services/graph_builder.py
index 37c9969c79..982ad3c17f 100644
--- a/backend/app/services/graph_builder.py
+++ b/backend/app/services/graph_builder.py
@@ -18,6 +18,7 @@
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale
+from ..utils.zep_retry import with_zep_retry
@dataclass
@@ -190,6 +191,7 @@ def _build_graph_worker(
error_msg = f"{str(e)}\n{traceback.format_exc()}"
self.task_manager.fail_task(task_id, error_msg)
+ @with_zep_retry(max_retries=3, operation_name="create_graph")
def create_graph(self, name: str) -> str:
"""创建Zep图谱(公开方法)"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
@@ -285,11 +287,14 @@ def safe_attr_name(attr_name: str) -> str:
# 调用Zep API设置本体
if entity_types or edge_definitions:
- self.client.graph.set_ontology(
- graph_ids=[graph_id],
- entities=entity_types if entity_types else None,
- edges=edge_definitions if edge_definitions else None,
- )
+ @with_zep_retry(max_retries=3, operation_name="set_ontology")
+ def _set_ontology():
+ self.client.graph.set_ontology(
+ graph_ids=[graph_id],
+ entities=entity_types if entity_types else None,
+ edges=edge_definitions if edge_definitions else None,
+ )
+ _set_ontology()
def add_text_batches(
self,
@@ -322,10 +327,14 @@ def add_text_batches(
# 发送到Zep
try:
- batch_result = self.client.graph.add_batch(
- graph_id=graph_id,
- episodes=episodes
- )
+ @with_zep_retry(max_retries=3, operation_name=f"add_batch {batch_num}/{total_batches}")
+ def _add_batch():
+ return self.client.graph.add_batch(
+ graph_id=graph_id,
+ episodes=episodes
+ )
+
+ batch_result = _add_batch()
# 收集返回的 episode uuid
if batch_result and isinstance(batch_result, list):
@@ -376,7 +385,11 @@ def _wait_for_episodes(
# 检查每个 episode 的处理状态
for ep_uuid in list(pending_episodes):
try:
- episode = self.client.graph.episode.get(uuid_=ep_uuid)
+ @with_zep_retry(max_retries=2, initial_delay=1.0, operation_name="get_episode")
+ def _get_episode():
+ return self.client.graph.episode.get(uuid_=ep_uuid)
+
+ episode = _get_episode()
is_processed = getattr(episode, 'processed', False)
if is_processed:
@@ -500,6 +513,7 @@ def get_graph_data(self, graph_id: str) -> Dict[str, Any]:
"edge_count": len(edges_data),
}
+ @with_zep_retry(max_retries=3, operation_name="delete_graph")
def delete_graph(self, graph_id: str):
"""删除图谱"""
self.client.graph.delete(graph_id=graph_id)
diff --git a/backend/app/services/oasis_profile_generator.py b/backend/app/services/oasis_profile_generator.py
index 7704a627eb..e76254317b 100644
--- a/backend/app/services/oasis_profile_generator.py
+++ b/backend/app/services/oasis_profile_generator.py
@@ -21,6 +21,7 @@
from ..config import Config
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, get_locale, set_locale, t
+from ..utils.zep_retry import with_zep_retry
from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.oasis_profile')
@@ -316,55 +317,27 @@ def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]:
comprehensive_query = t('progress.zepSearchQuery', name=entity_name)
+ @with_zep_retry(max_retries=3, initial_delay=2.0, operation_name="Zep Edge Search")
def search_edges():
"""搜索边(事实/关系)- 带重试机制"""
- max_retries = 3
- last_exception = None
- delay = 2.0
-
- for attempt in range(max_retries):
- try:
- return self.zep_client.graph.search(
- query=comprehensive_query,
- graph_id=self.graph_id,
- limit=30,
- scope="edges",
- reranker="rrf"
- )
- except Exception as e:
- last_exception = e
- if attempt < max_retries - 1:
- logger.debug(f"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
- time.sleep(delay)
- delay *= 2
- else:
- logger.debug(f"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}")
- return None
+ return self.zep_client.graph.search(
+ query=comprehensive_query,
+ graph_id=self.graph_id,
+ limit=30,
+ scope="edges",
+ reranker="rrf"
+ )
+ @with_zep_retry(max_retries=3, initial_delay=2.0, operation_name="Zep Node Search")
def search_nodes():
"""搜索节点(实体摘要)- 带重试机制"""
- max_retries = 3
- last_exception = None
- delay = 2.0
-
- for attempt in range(max_retries):
- try:
- return self.zep_client.graph.search(
- query=comprehensive_query,
- graph_id=self.graph_id,
- limit=20,
- scope="nodes",
- reranker="rrf"
- )
- except Exception as e:
- last_exception = e
- if attempt < max_retries - 1:
- logger.debug(f"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...")
- time.sleep(delay)
- delay *= 2
- else:
- logger.debug(f"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}")
- return None
+ return self.zep_client.graph.search(
+ query=comprehensive_query,
+ graph_id=self.graph_id,
+ limit=20,
+ scope="nodes",
+ reranker="rrf"
+ )
try:
# 并行执行edges和nodes搜索
diff --git a/backend/app/services/ontology_generator.py b/backend/app/services/ontology_generator.py
index 01a3d799a5..be1fefd2be 100644
--- a/backend/app/services/ontology_generator.py
+++ b/backend/app/services/ontology_generator.py
@@ -225,8 +225,9 @@ def generate(
return result
- # 传给 LLM 的文本最大长度(5万字)
- MAX_TEXT_LENGTH_FOR_LLM = 50000
+ # 传给 LLM 的文本最大长度(2万字)
+ # 本体分析只需识别实体/关系类型,不需要完整文本;完整文本仍用于后续图谱构建
+ MAX_TEXT_LENGTH_FOR_LLM = 20000
def _build_user_message(
self,
diff --git a/backend/app/services/zep_entity_reader.py b/backend/app/services/zep_entity_reader.py
index 71661be499..ca5a2a96b6 100644
--- a/backend/app/services/zep_entity_reader.py
+++ b/backend/app/services/zep_entity_reader.py
@@ -12,6 +12,7 @@
from ..config import Config
from ..utils.logger import get_logger
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
+from ..utils.zep_retry import with_zep_retry
logger = get_logger('mirofish.zep_entity_reader')
@@ -104,25 +105,11 @@ def _call_with_retry(
Returns:
API调用结果
"""
- last_exception = None
- delay = initial_delay
-
- for attempt in range(max_retries):
- try:
- return func()
- except Exception as e:
- last_exception = e
- if attempt < max_retries - 1:
- logger.warning(
- f"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, "
- f"{delay:.1f}秒后重试..."
- )
- time.sleep(delay)
- delay *= 2 # 指数退避
- else:
- logger.error(f"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}")
-
- raise last_exception
+ @with_zep_retry(max_retries=max_retries, initial_delay=initial_delay, operation_name=operation_name)
+ def _execute():
+ return func()
+
+ return _execute()
def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:
"""
diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py
index 6c1a81f49b..be58f3ee7d 100644
--- a/backend/app/utils/llm_client.py
+++ b/backend/app/utils/llm_client.py
@@ -1,18 +1,173 @@
"""
LLM客户端封装
统一使用OpenAI格式调用
+
+支持三层容错机制:
+1. 截断检测(finish_reason == 'length')
+2. JSON修复(尝试关闭未闭合的括号)
+3. 级联回退(自动切换到 Boost LLM)
"""
import json
+import logging
import re
-from typing import Optional, Dict, Any, List
+from typing import Optional, Dict, Any, List, Tuple
from openai import OpenAI
from ..config import Config
+logger = logging.getLogger(__name__)
+
+
+def repair_truncated_json(text: str) -> Optional[Dict[str, Any]]:
+ """
+ 尝试修复被截断的JSON字符串。
+
+ 两阶段策略:
+ 1. 精确修复:找到最后一个结构完整的安全截断点,关闭括号
+ 2. 激进修复:剥离末尾不完整的字符串/值,关闭所有括号
+
+ Args:
+ text: 被截断的JSON字符串
+
+ Returns:
+ 修复后的字典,如果无法修复则返回 None
+ """
+ if not text or not text.strip():
+ return None
+
+ text = text.strip()
+
+ # 清理 markdown 代码块标记
+ text = re.sub(r'^```(?:json)?\s*\n?', '', text, flags=re.IGNORECASE)
+ text = re.sub(r'\n?```\s*$', '', text)
+ text = text.strip()
+
+ # 先尝试直接解析(也许已经是有效JSON)
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+
+ # === 阶段1:精确安全点修复 ===
+ # 扫描结构,找到 }, ] 或顶层逗号作为安全截断点
+ safe_points = []
+ depth_brace = 0
+ depth_bracket = 0
+ in_string = False
+ escape_next = False
+
+ for i, ch in enumerate(text):
+ if escape_next:
+ escape_next = False
+ continue
+ if ch == '\\' and in_string:
+ escape_next = True
+ continue
+ if ch == '"' and not escape_next:
+ in_string = not in_string
+ continue
+ if in_string:
+ continue
+
+ if ch == '{':
+ depth_brace += 1
+ elif ch == '}':
+ depth_brace -= 1
+ safe_points.append(i + 1)
+ elif ch == '[':
+ depth_bracket += 1
+ elif ch == ']':
+ depth_bracket -= 1
+ safe_points.append(i + 1)
+ elif ch == ',' and depth_brace >= 1:
+ safe_points.append(i)
+
+ # 从最后一个安全点开始尝试
+ for point in reversed(safe_points):
+ candidate = text[:point].rstrip().rstrip(',')
+ result = _try_close_and_parse(candidate)
+ if result is not None:
+ logger.info(f"JSON repair (phase 1) succeeded at position {point}/{len(text)}")
+ return result
+
+ # === 阶段2:激进修复 ===
+ # 处理截断发生在字符串值中间的情况(如 "description": "A)
+ # 策略:从末尾向前找到最后一个完整的 }, 然后关闭括号
+
+ # 先尝试关闭可能未闭合的字符串
+ # 用正则找到最后一个看起来像截断字符串值的位置
+ # 模式:找最后一个 "key": "...(未闭合的字符串),截断到前一个完整的 }
+
+ # 逐步从末尾剥离,找到能解析的子串
+ for strip_len in range(1, min(len(text), 500)):
+ candidate = text[:len(text) - strip_len]
+
+ # 尝试在最后一个完整对象/数组闭合符处截断
+ # 找最后一个 } 或 ]
+ last_close = max(candidate.rfind('}'), candidate.rfind(']'))
+ if last_close < 0:
+ continue
+
+ truncated = candidate[:last_close + 1].rstrip().rstrip(',')
+ result = _try_close_and_parse(truncated)
+ if result is not None:
+ logger.info(f"JSON repair (phase 2) succeeded, stripped {strip_len + len(text) - last_close - 1} chars")
+ return result
+
+ logger.warning("JSON repair failed: no recoverable structure found")
+ return None
+
+
+def _try_close_and_parse(candidate: str) -> Optional[Dict[str, Any]]:
+ """
+ 使用栈追踪未闭合的括号,按正确顺序关闭它们,然后尝试解析。
+
+ JSON 关闭顺序很重要:{[{ }]} 而不是 {[{ ]}}
+
+ Returns:
+ 解析后的字典,或 None
+ """
+ stack = [] # 记录开启的括号类型,用于按正确顺序关闭
+ in_str = False
+ esc = False
+
+ for ch in candidate:
+ if esc:
+ esc = False
+ continue
+ if ch == '\\' and in_str:
+ esc = True
+ continue
+ if ch == '"':
+ in_str = not in_str
+ continue
+ if in_str:
+ continue
+ if ch == '{':
+ stack.append('}')
+ elif ch == '[':
+ stack.append(']')
+ elif ch in ('}', ']'):
+ if stack and stack[-1] == ch:
+ stack.pop()
+
+ # 如果字符串未闭合,不尝试此候选
+ if in_str:
+ return None
+
+ # 按栈逆序关闭(LIFO)
+ closing = ''.join(reversed(stack))
+ repaired = candidate + closing
+
+ try:
+ return json.loads(repaired)
+ except json.JSONDecodeError:
+ return None
+
class LLMClient:
- """LLM客户端"""
+ """LLM客户端,支持级联回退"""
def __init__(
self,
@@ -31,28 +186,38 @@ def __init__(
api_key=self.api_key,
base_url=self.base_url
)
+
+ # 检查是否有 Boost LLM 配置可用于回退
+ self._has_boost = bool(Config.LLM_BOOST_API_KEY)
- def chat(
+ def _chat_raw(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 4096,
- response_format: Optional[Dict] = None
- ) -> str:
+ response_format: Optional[Dict] = None,
+ client: Optional[OpenAI] = None,
+ model: Optional[str] = None
+ ) -> Tuple[str, str]:
"""
- 发送聊天请求
+ 底层聊天请求,返回 (content, finish_reason) 元组。
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
- response_format: 响应格式(如JSON模式)
+ response_format: 响应格式
+ client: 可选的替代客户端(用于 Boost 回退)
+ model: 可选的替代模型名
Returns:
- 模型响应文本
+ (content, finish_reason) 元组
"""
+ use_client = client or self.client
+ use_model = model or self.model
+
kwargs = {
- "model": self.model,
+ "model": use_model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
@@ -61,12 +226,52 @@ def chat(
if response_format:
kwargs["response_format"] = response_format
- response = self.client.chat.completions.create(**kwargs)
- content = response.choices[0].message.content
+ response = use_client.chat.completions.create(**kwargs)
+ content = response.choices[0].message.content or ""
+ finish_reason = response.choices[0].finish_reason or "unknown"
+
# 部分模型(如MiniMax M2.5)会在content中包含思考内容,需要移除
content = re.sub(r'[\s\S]*?', '', content).strip()
+
+ return content, finish_reason
+
+ def chat(
+ self,
+ messages: List[Dict[str, str]],
+ temperature: float = 0.7,
+ max_tokens: int = 4096,
+ response_format: Optional[Dict] = None
+ ) -> str:
+ """
+ 发送聊天请求
+
+ Args:
+ messages: 消息列表
+ temperature: 温度参数
+ max_tokens: 最大token数
+ response_format: 响应格式(如JSON模式)
+
+ Returns:
+ 模型响应文本
+ """
+ content, _ = self._chat_raw(
+ messages=messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ response_format=response_format
+ )
return content
+ def _create_boost_client(self) -> Tuple[OpenAI, str]:
+ """创建 Boost LLM 客户端(按需创建,不缓存)"""
+ return (
+ OpenAI(
+ api_key=Config.LLM_BOOST_API_KEY,
+ base_url=Config.LLM_BOOST_BASE_URL
+ ),
+ Config.LLM_BOOST_MODEL_NAME
+ )
+
def chat_json(
self,
messages: List[Dict[str, str]],
@@ -74,7 +279,9 @@ def chat_json(
max_tokens: int = 4096
) -> Dict[str, Any]:
"""
- 发送聊天请求并返回JSON
+ 发送聊天请求并返回JSON,支持三层容错:
+ 1. 截断检测 + JSON修复
+ 2. 级联回退到 Boost LLM
Args:
messages: 消息列表
@@ -84,20 +291,103 @@ def chat_json(
Returns:
解析后的JSON对象
"""
- response = self.chat(
- messages=messages,
- temperature=temperature,
- max_tokens=max_tokens,
- response_format={"type": "json_object"}
- )
- # 清理markdown代码块标记
- cleaned_response = response.strip()
- cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
- cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response)
- cleaned_response = cleaned_response.strip()
-
+ # === 第一层:尝试主 LLM ===
try:
- return json.loads(cleaned_response)
- except json.JSONDecodeError:
- raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")
-
+ content, finish_reason = self._chat_raw(
+ messages=messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ response_format={"type": "json_object"}
+ )
+
+ # 清理 markdown 代码块标记
+ cleaned = self._clean_json_response(content)
+
+ # 正常完成 → 尝试解析
+ if finish_reason == "stop":
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ logger.warning("Primary LLM returned invalid JSON despite finish_reason=stop, attempting repair")
+ repaired = repair_truncated_json(content)
+ if repaired is not None:
+ return repaired
+ # 回退到 Boost
+
+ # 截断 → 尝试修复
+ elif finish_reason == "length":
+ logger.warning(f"Primary LLM response truncated (finish_reason=length, {len(content)} chars)")
+ repaired = repair_truncated_json(content)
+ if repaired is not None:
+ logger.info("Truncated JSON repaired successfully from primary LLM")
+ return repaired
+ logger.warning("JSON repair failed, falling back to Boost LLM")
+
+ else:
+ logger.warning(f"Unexpected finish_reason='{finish_reason}', attempting parse")
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ pass
+
+ except Exception as e:
+ logger.warning(f"Primary LLM failed: {type(e).__name__}: {e}")
+
+ # === 第二层:回退到 Boost LLM ===
+ if not self._has_boost:
+ raise ValueError(
+ f"Primary LLM failed and no Boost LLM configured. "
+ f"Set LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME in .env"
+ )
+
+ logger.info(f"Falling back to Boost LLM: {Config.LLM_BOOST_BASE_URL} / {Config.LLM_BOOST_MODEL_NAME}")
+
+ try:
+ boost_client, boost_model = self._create_boost_client()
+ content, finish_reason = self._chat_raw(
+ messages=messages,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ response_format={"type": "json_object"},
+ client=boost_client,
+ model=boost_model
+ )
+
+ cleaned = self._clean_json_response(content)
+
+ if finish_reason == "stop":
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ repaired = repair_truncated_json(content)
+ if repaired is not None:
+ logger.info("Boost LLM JSON repaired successfully")
+ return repaired
+ raise ValueError(f"Boost LLM returned invalid JSON: {cleaned[:200]}...")
+
+ elif finish_reason == "length":
+ logger.warning(f"Boost LLM also truncated ({len(content)} chars), attempting repair")
+ repaired = repair_truncated_json(content)
+ if repaired is not None:
+ logger.info("Truncated JSON from Boost LLM repaired successfully")
+ return repaired
+ raise ValueError(f"Boost LLM response truncated and repair failed: {cleaned[:200]}...")
+
+ else:
+ try:
+ return json.loads(cleaned)
+ except json.JSONDecodeError:
+ raise ValueError(f"Boost LLM returned unparseable response: {cleaned[:200]}...")
+
+ except ValueError:
+ raise
+ except Exception as e:
+ raise ValueError(f"Both primary and Boost LLM failed. Boost error: {type(e).__name__}: {e}")
+
+ @staticmethod
+ def _clean_json_response(content: str) -> str:
+ """清理 LLM 响应中的 markdown 代码块标记"""
+ cleaned = content.strip()
+ cleaned = re.sub(r'^```(?:json)?\s*\n?', '', cleaned, flags=re.IGNORECASE)
+ cleaned = re.sub(r'\n?```\s*$', '', cleaned)
+ return cleaned.strip()
diff --git a/backend/app/utils/zep_paging.py b/backend/app/utils/zep_paging.py
index 943cd1ae29..41a5d88697 100644
--- a/backend/app/utils/zep_paging.py
+++ b/backend/app/utils/zep_paging.py
@@ -10,7 +10,6 @@
from collections.abc import Callable
from typing import Any
-from zep_cloud import InternalServerError
from zep_cloud.client import Zep
from .logger import get_logger
@@ -23,6 +22,8 @@
_DEFAULT_RETRY_DELAY = 2.0 # seconds, doubles each retry
+from .zep_retry import with_zep_retry
+
def _fetch_page_with_retry(
api_call: Callable[..., list[Any]],
*args: Any,
@@ -32,28 +33,11 @@ def _fetch_page_with_retry(
**kwargs: Any,
) -> list[Any]:
"""单页请求,失败时指数退避重试。仅重试网络/IO类瞬态错误。"""
- if max_retries < 1:
- raise ValueError("max_retries must be >= 1")
-
- last_exception: Exception | None = None
- delay = retry_delay
-
- for attempt in range(max_retries):
- try:
- return api_call(*args, **kwargs)
- except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
- last_exception = e
- if attempt < max_retries - 1:
- logger.warning(
- f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..."
- )
- time.sleep(delay)
- delay *= 2
- else:
- logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}")
-
- assert last_exception is not None
- raise last_exception
+ @with_zep_retry(max_retries=max_retries, initial_delay=retry_delay, operation_name=f"Zep {page_description}")
+ def execute_call():
+ return api_call(*args, **kwargs)
+
+ return execute_call()
def fetch_all_nodes(
diff --git a/backend/app/utils/zep_rate_limiter.py b/backend/app/utils/zep_rate_limiter.py
new file mode 100644
index 0000000000..8b2b3ea50b
--- /dev/null
+++ b/backend/app/utils/zep_rate_limiter.py
@@ -0,0 +1,95 @@
+"""
+Zep API 速率限制与响应缓存
+
+为 Zep Cloud FREE 计划提供保护:
+- 响应缓存:graph data 请求在 TTL 内返回缓存结果,避免重复调用 Zep API
+- 可通过 .env 配置参数,升级付费计划后可放宽限制
+"""
+
+import time
+import threading
+from typing import Any, Dict, Optional, Tuple
+
+from .logger import get_logger
+
+logger = get_logger('mirofish.zep_cache')
+
+
+class ZepResponseCache:
+ """
+ 线程安全的 Zep API 响应缓存。
+
+ 缓存 graph data 的成功响应,在 TTL 内直接返回缓存数据,
+ 避免频繁调用 Zep API 导致 429 错误。
+ """
+
+ def __init__(self, default_ttl: int = 30):
+ """
+ Args:
+ default_ttl: 默认缓存生存时间(秒)。0 表示不缓存。
+ """
+ self._cache: Dict[str, Tuple[float, Any]] = {} # key -> (expire_time, data)
+ self._lock = threading.Lock()
+ self._default_ttl = default_ttl
+
+ @property
+ def ttl(self) -> int:
+ return self._default_ttl
+
+ @ttl.setter
+ def ttl(self, value: int):
+ self._default_ttl = max(0, value)
+
+ def get(self, key: str) -> Optional[Any]:
+ """
+ 获取缓存数据。如果缓存存在且未过期,返回数据;否则返回 None。
+ """
+ with self._lock:
+ entry = self._cache.get(key)
+ if entry is None:
+ return None
+
+ expire_time, data = entry
+ if time.time() > expire_time:
+ # 缓存已过期
+ del self._cache[key]
+ logger.debug(f"Cache expired for key: {key}")
+ return None
+
+ remaining = int(expire_time - time.time())
+ logger.debug(f"Cache hit for key: {key} (expires in {remaining}s)")
+ return data
+
+ def set(self, key: str, data: Any, ttl: Optional[int] = None):
+ """
+ 设置缓存数据。
+
+ Args:
+ key: 缓存键
+ data: 要缓存的数据
+ ttl: 缓存生存时间(秒)。None 表示使用默认 TTL。
+ """
+ effective_ttl = ttl if ttl is not None else self._default_ttl
+ if effective_ttl <= 0:
+ return # 不缓存
+
+ with self._lock:
+ self._cache[key] = (time.time() + effective_ttl, data)
+ logger.debug(f"Cache set for key: {key} (TTL={effective_ttl}s)")
+
+ def invalidate(self, key: str):
+ """删除指定缓存。"""
+ with self._lock:
+ if key in self._cache:
+ del self._cache[key]
+ logger.debug(f"Cache invalidated for key: {key}")
+
+ def clear(self):
+ """清除所有缓存。"""
+ with self._lock:
+ self._cache.clear()
+ logger.debug("Cache cleared")
+
+
+# 全局缓存实例(在模块加载时创建,TTL 在 app 初始化时通过 Config 设置)
+graph_data_cache = ZepResponseCache(default_ttl=30)
diff --git a/backend/app/utils/zep_retry.py b/backend/app/utils/zep_retry.py
new file mode 100644
index 0000000000..25c11214ec
--- /dev/null
+++ b/backend/app/utils/zep_retry.py
@@ -0,0 +1,84 @@
+import time
+import functools
+from typing import Callable, Any, TypeVar, cast
+
+from zep_cloud.core.api_error import ApiError
+from zep_cloud import InternalServerError
+
+from .logger import get_logger
+from .locale import t
+
+logger = get_logger('mirofish.zep_retry')
+
+T = TypeVar('T', bound=Callable[..., Any])
+
+
+class ZepQuotaExceededError(Exception):
+ """Raised when Zep Account is over the episode usage limit (403 Quota limit)."""
+ pass
+
+
+def with_zep_retry(
+ max_retries: int = 3,
+ initial_delay: float = 2.0,
+ operation_name: str = "Zep API Call"
+) -> Callable[[T], T]:
+ """
+ Decorator to wrap Zep API calls with retry logic.
+ - Handles 429 Rate Limit by respecting 'Retry-After' headers.
+ - Handles 403 Quota Limit by failing fast with a clear exception.
+ - Retries other transient errors (ConnectionError, TimeoutError, etc) with exponential backoff.
+ """
+ def decorator(func: T) -> T:
+ @functools.wraps(func)
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
+ last_exception = None
+ delay = initial_delay
+
+ for attempt in range(max_retries):
+ try:
+ return func(*args, **kwargs)
+ except ApiError as e:
+ # 403 Forbidden: Account is over the episode usage limit
+ if e.status_code == 403 and hasattr(e, 'body') and 'forbidden: Account is over the episode usage limit' in str(e.body):
+ logger.error(f"{operation_name} Failed: Zep Free Plan quota exceeded (403).")
+ error_msg = t("api.zepQuotaExceeded")
+ if not error_msg or error_msg == "api.zepQuotaExceeded":
+ error_msg = "Zep Free Plan Quota Exceeded: Your account has reached the maximum allowed episode usage. Please upgrade your Zep plan or clear old data."
+ raise ZepQuotaExceededError(error_msg) from e
+
+ # 429 Rate limit
+ if e.status_code == 429:
+ retry_after = 60 # Default fallback
+ if hasattr(e, 'headers') and e.headers:
+ retry_after = int(e.headers.get('retry-after', retry_after))
+ elif hasattr(e, 'body') and 'retry-after' in str(e.body):
+ retry_after = 60
+
+ logger.warning(
+ f"Zep rate limit hit on {operation_name}, waiting {retry_after}s before retry (attempt {attempt + 1}/{max_retries})"
+ )
+ if attempt < max_retries - 1:
+ time.sleep(retry_after + 1)
+ continue
+ else:
+ logger.error(f"{operation_name} rate limited after {max_retries} attempts")
+ raise
+
+ # Other ApiErrors should not be retried unless we want to, but normally we fail fast
+ raise
+ except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
+ last_exception = e
+ if attempt < max_retries - 1:
+ logger.warning(
+ f"{operation_name} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..."
+ )
+ time.sleep(delay)
+ delay *= 2
+ else:
+ logger.error(f"{operation_name} failed after {max_retries} attempts: {str(e)}")
+
+ if last_exception:
+ raise last_exception
+ return cast(T, wrapper)
+ return decorator
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 4f5361d537..11f8880d07 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -51,5 +51,13 @@ dev = [
"pytest-asyncio>=0.23.0",
]
+[tool.uv]
+override-dependencies = [
+ "pillow>=11.0.0",
+ "tiktoken>=0.8.0",
+]
+
+[tool.uv.sources]
+
[tool.hatch.build.targets.wheel]
packages = ["app"]
diff --git a/backend/uv.lock b/backend/uv.lock
index f1ce4b60eb..6e09844983 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -6,6 +6,12 @@ resolution-markers = [
"python_full_version < '3.12'",
]
+[manifest]
+overrides = [
+ { name = "pillow", specifier = ">=11.0.0" },
+ { name = "tiktoken", specifier = ">=0.8.0" },
+]
+
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -1779,32 +1785,89 @@ wheels = [
[[package]]
name = "pillow"
-version = "10.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ef/43/c50c17c5f7d438e836c169e343695534c38c77f60e7c90389bd77981bc21/pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", size = 46572854, upload-time = "2024-04-01T12:19:40.048Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/51/e4b35e394b4e5ca24983e50361a1db3d7da05b1758074f9c4f5b4be4b22a/pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", size = 3528936, upload-time = "2024-04-01T12:17:29.322Z" },
- { url = "https://files.pythonhosted.org/packages/00/5c/7633f291def20082bad31b844fe5ed07742aae8504e4cfe2f331ee727178/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", size = 3352899, upload-time = "2024-04-01T12:17:31.843Z" },
- { url = "https://files.pythonhosted.org/packages/1d/29/abda81a079cccd1840b0b7b13ad67ffac87cc66395ae20973027280e9f9f/pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", size = 4317733, upload-time = "2024-04-01T12:17:34.494Z" },
- { url = "https://files.pythonhosted.org/packages/77/cd/5205fb43a6000d424291b0525b8201004700d9a34e034517ac4dfdc6eed5/pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", size = 4429430, upload-time = "2024-04-01T12:17:37.112Z" },
- { url = "https://files.pythonhosted.org/packages/8c/bb/9e8d2b1b54235bd44139ee387beeb65ad9d8d755b5c01f817070c6dabea7/pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", size = 4341711, upload-time = "2024-04-01T12:17:39.151Z" },
- { url = "https://files.pythonhosted.org/packages/81/ff/ad3c942d865f9e45ce84eeb31795e6d4d94e1f1eea51026d5154028510d7/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", size = 4507469, upload-time = "2024-04-01T12:17:41.159Z" },
- { url = "https://files.pythonhosted.org/packages/ab/ab/30cd50a12d9afa2c412efcb8b37dd3f5f1da4bc77b984ddfbc776d96cf5b/pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", size = 4533491, upload-time = "2024-04-01T12:17:43.813Z" },
- { url = "https://files.pythonhosted.org/packages/1f/f0/07419615ffa852cded35dfa3337bf70788f232a3dfe622b97d5eb0c32674/pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", size = 4598334, upload-time = "2024-04-01T12:17:46.271Z" },
- { url = "https://files.pythonhosted.org/packages/9c/f3/6e923786f2b2d167d16783fc079c003aadbcedc4995f54e8429d91aabfc4/pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", size = 2217293, upload-time = "2024-04-01T12:17:48.292Z" },
- { url = "https://files.pythonhosted.org/packages/0a/16/c83877524c47976f16703d2e05c363244bc1e60ab439e078b3cd046d07db/pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", size = 2531332, upload-time = "2024-04-01T12:17:50.844Z" },
- { url = "https://files.pythonhosted.org/packages/a8/3b/f64454549af90818774c3210b48987c3aeca5285787dbd69869d9a05b58f/pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", size = 2229546, upload-time = "2024-04-01T12:17:53.237Z" },
- { url = "https://files.pythonhosted.org/packages/cc/5d/b7fcd38cba0f7706f64c1674fc9f018e4c64f791770598c44affadea7c2f/pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", size = 3528535, upload-time = "2024-04-01T12:17:55.891Z" },
- { url = "https://files.pythonhosted.org/packages/5e/77/4cf407e7b033b4d8e5fcaac295b6e159cf1c70fa105d769f01ea2e1e5eca/pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", size = 3352281, upload-time = "2024-04-01T12:17:58.527Z" },
- { url = "https://files.pythonhosted.org/packages/53/7b/4f7b153a776725a87797d744ea1c73b83ac0b723f5e379297605dee118eb/pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", size = 4321427, upload-time = "2024-04-01T12:18:00.809Z" },
- { url = "https://files.pythonhosted.org/packages/45/08/d2cc751b790e77464f8648aa707e2327d6da5d95cf236a532e99c2e7a499/pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", size = 4435915, upload-time = "2024-04-01T12:18:03.084Z" },
- { url = "https://files.pythonhosted.org/packages/ef/97/f69d1932cf45bf5bd9fa1e2ae57bdf716524faa4fa9fb7dc62cdb1a19113/pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", size = 4347392, upload-time = "2024-04-01T12:18:05.319Z" },
- { url = "https://files.pythonhosted.org/packages/c6/c1/3521ddb9c1f3ac106af3e4512a98c785b6ed8a39e0f778480b8a4d340165/pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a", size = 4514536, upload-time = "2024-04-01T12:18:08.039Z" },
- { url = "https://files.pythonhosted.org/packages/c0/6f/347c241904a6514e59515284b01ba6f61765269a0d1a19fd2e6cbe331c8a/pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", size = 4555987, upload-time = "2024-04-01T12:18:10.106Z" },
- { url = "https://files.pythonhosted.org/packages/c3/e2/3cc490c6b2e262713da82ce849c34bd8e6c31242afb53be8595d820b9877/pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", size = 4623526, upload-time = "2024-04-01T12:18:12.172Z" },
- { url = "https://files.pythonhosted.org/packages/c1/b3/0209f70fa29b383e7618e47db95712a45788dea03bb960601753262a2883/pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", size = 2217547, upload-time = "2024-04-01T12:18:14.188Z" },
- { url = "https://files.pythonhosted.org/packages/d3/23/3927d888481ff7c44fdbca3bc2a2e97588c933db46723bf115201377c436/pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", size = 2531641, upload-time = "2024-04-01T12:18:16.081Z" },
- { url = "https://files.pythonhosted.org/packages/db/36/1ecaa0541d3a1b1362f937d386eeb1875847bfa06d5225f1b0e1588d1007/pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", size = 2229746, upload-time = "2024-04-01T12:18:18.174Z" },
+version = "12.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
+ { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
+ { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
+ { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
+ { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
+ { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
+ { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
+ { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
+ { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
+ { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
+ { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
+ { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
+ { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
+ { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
]
[[package]]
@@ -3007,28 +3070,56 @@ wheels = [
[[package]]
name = "tiktoken"
-version = "0.7.0"
+version = "0.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "regex" },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/4a/abaec53e93e3ef37224a4dd9e2fc6bb871e7a538c2b6b9d2a6397271daf4/tiktoken-0.7.0.tar.gz", hash = "sha256:1077266e949c24e0291f6c350433c6f0971365ece2b173a23bc3b9f9defef6b6", size = 33437, upload-time = "2024-05-13T18:03:28.793Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/22/eb/57492b2568eea1d546da5cc1ae7559d924275280db80ba07e6f9b89a914b/tiktoken-0.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:10c7674f81e6e350fcbed7c09a65bca9356eaab27fb2dac65a1e440f2bcfe30f", size = 961468, upload-time = "2024-05-13T18:02:43.788Z" },
- { url = "https://files.pythonhosted.org/packages/30/ef/e07dbfcb2f85c84abaa1b035a9279575a8da0236305491dc22ae099327f7/tiktoken-0.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:084cec29713bc9d4189a937f8a35dbdfa785bd1235a34c1124fe2323821ee93f", size = 907005, upload-time = "2024-05-13T18:02:45.327Z" },
- { url = "https://files.pythonhosted.org/packages/ea/9b/f36db825b1e9904c3a2646439cb9923fc1e09208e2e071c6d9dd64ead131/tiktoken-0.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811229fde1652fedcca7c6dfe76724d0908775b353556d8a71ed74d866f73f7b", size = 1049183, upload-time = "2024-05-13T18:02:46.574Z" },
- { url = "https://files.pythonhosted.org/packages/61/b4/b80d1fe33015e782074e96bbbf4108ccd283b8deea86fb43c15d18b7c351/tiktoken-0.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86b6e7dc2e7ad1b3757e8a24597415bafcfb454cebf9a33a01f2e6ba2e663992", size = 1080830, upload-time = "2024-05-13T18:02:48.444Z" },
- { url = "https://files.pythonhosted.org/packages/2a/40/c66ff3a21af6d62a7e0ff428d12002c4e0389f776d3ff96dcaa0bb354eee/tiktoken-0.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1063c5748be36344c7e18c7913c53e2cca116764c2080177e57d62c7ad4576d1", size = 1092967, upload-time = "2024-05-13T18:02:50.006Z" },
- { url = "https://files.pythonhosted.org/packages/2e/80/f4c9e255ff236e6a69ce44b927629cefc1b63d3a00e2d1c9ed540c9492d2/tiktoken-0.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20295d21419bfcca092644f7e2f2138ff947a6eb8cfc732c09cc7d76988d4a89", size = 1142682, upload-time = "2024-05-13T18:02:51.814Z" },
- { url = "https://files.pythonhosted.org/packages/b1/10/c04b4ff592a5f46b28ebf4c2353f735c02ae7f0ce1b165d00748ced6467e/tiktoken-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:959d993749b083acc57a317cbc643fb85c014d055b2119b739487288f4e5d1cb", size = 799009, upload-time = "2024-05-13T18:02:53.057Z" },
- { url = "https://files.pythonhosted.org/packages/1d/46/4cdda4186ce900608f522da34acf442363346688c71b938a90a52d7b84cc/tiktoken-0.7.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:71c55d066388c55a9c00f61d2c456a6086673ab7dec22dd739c23f77195b1908", size = 960446, upload-time = "2024-05-13T18:02:54.409Z" },
- { url = "https://files.pythonhosted.org/packages/b6/30/09ced367d280072d7a3e21f34263dfbbf6378661e7a0f6414e7c18971083/tiktoken-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09ed925bccaa8043e34c519fbb2f99110bd07c6fd67714793c21ac298e449410", size = 906652, upload-time = "2024-05-13T18:02:56.25Z" },
- { url = "https://files.pythonhosted.org/packages/e6/7b/c949e4954441a879a67626963dff69096e3c774758b9f2bb0853f7b4e1e7/tiktoken-0.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03c6c40ff1db0f48a7b4d2dafeae73a5607aacb472fa11f125e7baf9dce73704", size = 1047904, upload-time = "2024-05-13T18:02:57.707Z" },
- { url = "https://files.pythonhosted.org/packages/50/81/1842a22f15586072280364c2ab1e40835adaf64e42fe80e52aff921ee021/tiktoken-0.7.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d20b5c6af30e621b4aca094ee61777a44118f52d886dbe4f02b70dfe05c15350", size = 1079836, upload-time = "2024-05-13T18:02:59.009Z" },
- { url = "https://files.pythonhosted.org/packages/6d/87/51a133a3d5307cf7ae3754249b0faaa91d3414b85c3d36f80b54d6817aa6/tiktoken-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d427614c3e074004efa2f2411e16c826f9df427d3c70a54725cae860f09e4bf4", size = 1092472, upload-time = "2024-05-13T18:03:00.597Z" },
- { url = "https://files.pythonhosted.org/packages/a5/1f/c93517dc6d3b2c9e988b8e24f87a8b2d4a4ab28920a3a3f3ea338397ae0c/tiktoken-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c46d7af7b8c6987fac9b9f61041b452afe92eb087d29c9ce54951280f899a97", size = 1141881, upload-time = "2024-05-13T18:03:02.743Z" },
- { url = "https://files.pythonhosted.org/packages/bf/4b/48ca098cb580c099b5058bf62c4cb5e90ca6130fa43ef4df27088536245b/tiktoken-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:0bc603c30b9e371e7c4c7935aba02af5994a909fc3c0fe66e7004070858d3f8f", size = 799281, upload-time = "2024-05-13T18:03:04.036Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
+ { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
+ { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
+ { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
+ { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
+ { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
+ { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
+ { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
+ { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
+ { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
+ { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
+ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
[[package]]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3e56d752df..fdab7ac412 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1435,7 +1435,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -1913,7 +1912,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2053,7 +2051,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -2128,7 +2125,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
diff --git a/frontend/src/api/graph.js b/frontend/src/api/graph.js
index ef90a2b6f5..02df7c47e4 100644
--- a/frontend/src/api/graph.js
+++ b/frontend/src/api/graph.js
@@ -68,3 +68,14 @@ export function getProject(projectId) {
method: 'get'
})
}
+
+/**
+ * 获取图谱轮询配置(速率限制参数)
+ * @returns {Promise} { poll_interval, cache_ttl, rate_limit, rate_limit_window }
+ */
+export function getGraphConfig() {
+ return service({
+ url: '/api/graph/config',
+ method: 'get'
+ })
+}
diff --git a/frontend/src/components/Step1GraphBuild.vue b/frontend/src/components/Step1GraphBuild.vue
index 687d1c7bb0..990f3e0502 100644
--- a/frontend/src/components/Step1GraphBuild.vue
+++ b/frontend/src/components/Step1GraphBuild.vue
@@ -10,6 +10,7 @@
{{ $t('step1.ontologyCompleted') }}
+ FAILED
{{ $t('step1.ontologyGenerating') }}
{{ $t('step1.ontologyPending') }}
@@ -22,10 +23,16 @@
-
+
{{ ontologyProgress.message || $t('step1.analyzingDocs') }}
+
+
+
+ ⚠️
+ {{ errorMsg }}
+
@@ -114,6 +121,7 @@
{{ $t('step1.ontologyCompleted') }}
+ FAILED
{{ buildProgress?.progress || 0 }}%
{{ $t('step1.ontologyPending') }}
@@ -125,6 +133,12 @@
{{ $t('step1.graphRagDesc') }}
+
+
+ ⚠️
+ {{ errorMsg }}
+
+
@@ -201,7 +215,8 @@ const props = defineProps({
ontologyProgress: Object,
buildProgress: Object,
graphData: Object,
- systemLogs: { type: Array, default: () => [] }
+ systemLogs: { type: Array, default: () => [] },
+ errorMsg: { type: String, default: '' }
})
defineEmits(['next-step'])
@@ -349,6 +364,7 @@ watch(() => props.systemLogs.length, () => {
.badge.processing { background: #FF5722; color: #FFF; }
.badge.accent { background: #FF5722; color: #FFF; }
.badge.pending { background: #F5F5F5; color: #999; }
+.badge.error { background: #FFEBEE; color: #D32F2F; }
.api-note {
font-family: 'JetBrains Mono', monospace;
@@ -364,6 +380,28 @@ watch(() => props.systemLogs.length, () => {
margin-bottom: 16px;
}
+.error-alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ background: #FFF5F5;
+ border: 1px solid #FFCDD2;
+ border-radius: 6px;
+ padding: 12px;
+ margin-top: 8px;
+}
+
+.error-icon {
+ font-size: 14px;
+}
+
+.error-text {
+ font-size: 12px;
+ color: #C62828;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
/* Step 01 Tags */
.tags-container {
margin-top: 12px;
diff --git a/frontend/src/views/MainView.vue b/frontend/src/views/MainView.vue
index 513c70d833..e8c48bd6d0 100644
--- a/frontend/src/views/MainView.vue
+++ b/frontend/src/views/MainView.vue
@@ -59,6 +59,7 @@
:buildProgress="buildProgress"
:graphData="graphData"
:systemLogs="systemLogs"
+ :errorMsg="error"
@next-step="handleNextStep"
/>
@@ -83,7 +84,7 @@ import { useI18n } from 'vue-i18n'
import GraphPanel from '../components/GraphPanel.vue'
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
import Step2EnvSetup from '../components/Step2EnvSetup.vue'
-import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
+import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData, getGraphConfig } from '../api/graph'
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
@@ -114,6 +115,9 @@ const systemLogs = ref([])
let pollTimer = null
let graphPollTimer = null
+// Graph polling config (fetched from backend)
+const graphPollInterval = ref(0) // 0 = manual only
+
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
@@ -184,6 +188,19 @@ const handleGoBack = () => {
const initProject = async () => {
addLog('Project view initialized.')
+
+ // Fetch graph polling config from backend
+ try {
+ const configRes = await getGraphConfig()
+ if (configRes.success && configRes.data) {
+ graphPollInterval.value = configRes.data.poll_interval || 0
+ addLog(`Graph config loaded: poll_interval=${graphPollInterval.value}s, cache_ttl=${configRes.data.cache_ttl}s`)
+ }
+ } catch (err) {
+ addLog('Could not load graph config, defaulting to manual refresh only.')
+ graphPollInterval.value = 0
+ }
+
if (currentProjectId.value === 'new') {
await handleNewProject()
} else {
@@ -295,9 +312,17 @@ const startBuildGraph = async () => {
}
const startGraphPolling = () => {
- addLog('Started polling for graph data...')
+ // Always do one immediate fetch
fetchGraphData()
- graphPollTimer = setInterval(fetchGraphData, 10000)
+
+ // Only set up automatic polling if poll_interval > 0 (paid plan)
+ if (graphPollInterval.value > 0) {
+ const intervalMs = graphPollInterval.value * 1000
+ addLog(`Started automatic graph polling (every ${graphPollInterval.value}s)...`)
+ graphPollTimer = setInterval(fetchGraphData, intervalMs)
+ } else {
+ addLog('Automatic graph polling disabled (FREE plan). Use manual refresh.')
+ }
}
const fetchGraphData = async () => {
diff --git a/frontend/src/views/Process.vue b/frontend/src/views/Process.vue
index 2d2d3cc1ac..591c5473fe 100644
--- a/frontend/src/views/Process.vue
+++ b/frontend/src/views/Process.vue
@@ -414,7 +414,7 @@