chllm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chllm/__init__.py +39 -0
- chllm/antigravity.md +30 -0
- chllm/builder.py +89 -0
- chllm/context.py +83 -0
- chllm/exceptions/__init__.py +25 -0
- chllm/exceptions/base.py +5 -0
- chllm/exceptions/parsing.py +11 -0
- chllm/exceptions/provider.py +34 -0
- chllm/masking.py +119 -0
- chllm/metrics.py +122 -0
- chllm/orchestrator.py +293 -0
- chllm/parser.py +468 -0
- chllm/py.typed +1 -0
- chllm/utils.py +24 -0
- chllm-0.1.0.dist-info/METADATA +172 -0
- chllm-0.1.0.dist-info/RECORD +18 -0
- chllm-0.1.0.dist-info/WHEEL +4 -0
- chllm-0.1.0.dist-info/licenses/LICENSE +21 -0
chllm/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .builder import PromptBuilder
|
|
2
|
+
from .context import ContextBuilder, ContextItem
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
CHLLMError,
|
|
6
|
+
ContentBlockedError,
|
|
7
|
+
InvalidRequestError,
|
|
8
|
+
ParsingError,
|
|
9
|
+
QuotaExceededError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServiceUnavailableError,
|
|
12
|
+
)
|
|
13
|
+
from .masking import ContentMasker, MaskedResult
|
|
14
|
+
from .metrics import TokenCounter, UsageMetrics
|
|
15
|
+
from .orchestrator import AgentOrchestrator, LLMProvider, Orchestrator, RetryStrategy
|
|
16
|
+
from .parser import RobustLLMParser
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AgentOrchestrator",
|
|
20
|
+
"AuthenticationError",
|
|
21
|
+
"CHLLMError",
|
|
22
|
+
"ContentBlockedError",
|
|
23
|
+
"ContentMasker",
|
|
24
|
+
"ContextBuilder",
|
|
25
|
+
"ContextItem",
|
|
26
|
+
"InvalidRequestError",
|
|
27
|
+
"LLMProvider",
|
|
28
|
+
"MaskedResult",
|
|
29
|
+
"Orchestrator",
|
|
30
|
+
"ParsingError",
|
|
31
|
+
"PromptBuilder",
|
|
32
|
+
"QuotaExceededError",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"RetryStrategy",
|
|
35
|
+
"RobustLLMParser",
|
|
36
|
+
"ServiceUnavailableError",
|
|
37
|
+
"TokenCounter",
|
|
38
|
+
"UsageMetrics",
|
|
39
|
+
]
|
chllm/antigravity.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Манифест ИИ: chllm (Robust LLM Framework)
|
|
2
|
+
|
|
3
|
+
Модуль chllm представляет собой отказоустойчивый фреймворк для взаимодействия с LLM-провайдерами, управления контекстом промптов и надежного парсинга структурированных ответов.
|
|
4
|
+
|
|
5
|
+
## Назначение и Архитектура
|
|
6
|
+
|
|
7
|
+
Модуль изолирует работу с нейросетевыми моделями от доменной логики приложения.
|
|
8
|
+
|
|
9
|
+
### Ключевые компоненты:
|
|
10
|
+
|
|
11
|
+
1. **RobustLLMParser (parser.py)**:
|
|
12
|
+
- Извлечение JSON, Markdown и очистка ответа от технического шума LLM.
|
|
13
|
+
- Восстановление поврежденного JSON и валидация схем Pydantic.
|
|
14
|
+
|
|
15
|
+
2. **Orchestrator, AgentOrchestrator (orchestrator.py)**:
|
|
16
|
+
- Управление ротацией API-ключей и прокси.
|
|
17
|
+
- Стратегии повторных попыток (RetryStrategy) с экспоненциальной задержкой.
|
|
18
|
+
|
|
19
|
+
3. **ContentMasker (masking.py)**:
|
|
20
|
+
- Маскирование и защитная подстановка чувствительных данных и тегов Ren'Py перед отправкой в LLM.
|
|
21
|
+
|
|
22
|
+
4. **PromptBuilder, ContextBuilder (uilder.py, context.py)**:
|
|
23
|
+
- Динамическая сборка системных и пользовательских промптов с ограничением по токенам.
|
|
24
|
+
|
|
25
|
+
5. **TokenCounter, UsageMetrics (metrics.py)**:
|
|
26
|
+
- Подсчет токенов и сбор статистики использования API.
|
|
27
|
+
|
|
28
|
+
## Правила использования
|
|
29
|
+
- При работе с ответами LLM всегда использовать RobustLLMParser.
|
|
30
|
+
- Ошибки LLM обрабатывать через иерархию CHLLMError (например, RateLimitError, QuotaExceededError).
|
chllm/builder.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Компонент для генерации структурированных промптов для LLM.
|
|
3
|
+
Позволяет объединять инструкции, контекст и данные в единый запрос.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PromptBuilder:
|
|
13
|
+
"""Генератор промптов для работы с нейросетями."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, template: str | None = None):
|
|
16
|
+
"""
|
|
17
|
+
Args:
|
|
18
|
+
template: Основная системная инструкция (шаблон).
|
|
19
|
+
"""
|
|
20
|
+
self._template = template or ""
|
|
21
|
+
|
|
22
|
+
def build(
|
|
23
|
+
self,
|
|
24
|
+
input_data: list[Any] | dict[str, Any] | BaseModel,
|
|
25
|
+
context: list[str] | None = None,
|
|
26
|
+
instruction: str | list[str] | None = None,
|
|
27
|
+
data_label: str = "INPUT DATA",
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Собирает финальный текст промпта.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
input_data: Данные для обработки (список, словарь или Pydantic-модель).
|
|
33
|
+
context: Список строк контекста (предыдущие события/диалоги).
|
|
34
|
+
instruction: Дополнительная инструкция (строка) или список инструкций.
|
|
35
|
+
data_label: Заголовок для блока данных в промпте.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
str: Сформированный текст промпта.
|
|
39
|
+
"""
|
|
40
|
+
parts = []
|
|
41
|
+
|
|
42
|
+
# 1. Сбор инструкций (Системный промпт)
|
|
43
|
+
instructions = []
|
|
44
|
+
if self._template:
|
|
45
|
+
instructions.append(self._template.strip())
|
|
46
|
+
|
|
47
|
+
if instruction:
|
|
48
|
+
if isinstance(instruction, list):
|
|
49
|
+
instructions.extend([i.strip() for i in instruction if i])
|
|
50
|
+
else:
|
|
51
|
+
instructions.append(instruction.strip())
|
|
52
|
+
|
|
53
|
+
if instructions:
|
|
54
|
+
parts.append("\n".join(instructions))
|
|
55
|
+
|
|
56
|
+
# 2. Контекст
|
|
57
|
+
if context:
|
|
58
|
+
parts.append("\n--- CONTEXT ---")
|
|
59
|
+
parts.append("\n".join(context))
|
|
60
|
+
|
|
61
|
+
# 3. Данные (JSON)
|
|
62
|
+
parts.append(f"\n--- {data_label} ---")
|
|
63
|
+
|
|
64
|
+
json_data = self._serialize_data(input_data)
|
|
65
|
+
parts.append(json_data)
|
|
66
|
+
|
|
67
|
+
return "\n\n".join(parts)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _serialize_data(data: Any) -> str:
|
|
71
|
+
"""Сериализует данные в JSON строку."""
|
|
72
|
+
# 1. Проверяем наличие метода Pydantic v2 (важно для Mock-объектов в тестах)
|
|
73
|
+
if hasattr(data, "model_dump_json") and callable(data.model_dump_json):
|
|
74
|
+
serialized = data.model_dump_json(indent=2)
|
|
75
|
+
if isinstance(serialized, str):
|
|
76
|
+
return serialized
|
|
77
|
+
# Если это Mock, который вернул другой Mock, пробуем преобразовать в строку
|
|
78
|
+
return str(serialized)
|
|
79
|
+
|
|
80
|
+
# 2. Базовая сериализация для стандартных типов (dict, list)
|
|
81
|
+
if isinstance(data, (dict, list)):
|
|
82
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
83
|
+
|
|
84
|
+
# 3. Если это BaseModel (Pydantic v2)
|
|
85
|
+
if isinstance(data, BaseModel):
|
|
86
|
+
return data.model_dump_json(indent=2)
|
|
87
|
+
|
|
88
|
+
# 4. Фолбэк на строковое представление
|
|
89
|
+
return str(data)
|
chllm/context.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модуль для управления контекстом и историей в батч-запросах к LLM.
|
|
3
|
+
Реализует различные стратегии формирования предыстории для элементов.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ContextItem:
|
|
13
|
+
"""Элемент данных с привязанным к нему контекстом.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
data: Исходные данные элемента.
|
|
17
|
+
context: Список строк предыстории.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
data: Any
|
|
21
|
+
context: list[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ContextBuilder:
|
|
25
|
+
"""Универсальный строитель контекста для последовательных данных."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, strategy: str = "chain"):
|
|
28
|
+
"""Инициализирует строитель.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
strategy: Стратегия формирования контекста ("chain", "full").
|
|
32
|
+
"""
|
|
33
|
+
self._strategy = strategy
|
|
34
|
+
|
|
35
|
+
def build(
|
|
36
|
+
self, items: list[Any], initial_history: list[str] | None = None, formatter: Callable[[Any], str] | None = None
|
|
37
|
+
) -> list[ContextItem]:
|
|
38
|
+
"""Формирует контекст для списка элементов.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
items: Список элементов для обработки.
|
|
42
|
+
initial_history: Начальная история (для первого элемента).
|
|
43
|
+
formatter: Функция для преобразования элемента в строку контекста
|
|
44
|
+
для последующих элементов.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Список объектов ContextItem.
|
|
48
|
+
"""
|
|
49
|
+
if not items:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
# Используем стандартный строковый форматтер, если не задан кастомный
|
|
53
|
+
if formatter is None:
|
|
54
|
+
formatter = self._default_formatter
|
|
55
|
+
|
|
56
|
+
result = []
|
|
57
|
+
history = initial_history or []
|
|
58
|
+
|
|
59
|
+
if self._strategy == "chain":
|
|
60
|
+
for i, item in enumerate(items):
|
|
61
|
+
if i == 0:
|
|
62
|
+
# Первый элемент получает всю начальную историю
|
|
63
|
+
context_for_item = history
|
|
64
|
+
else:
|
|
65
|
+
# Последующие получают только предыдущий элемент
|
|
66
|
+
prev_item = items[i - 1]
|
|
67
|
+
context_for_item = [formatter(prev_item)]
|
|
68
|
+
|
|
69
|
+
result.append(ContextItem(data=item, context=context_for_item))
|
|
70
|
+
|
|
71
|
+
elif self._strategy == "full":
|
|
72
|
+
# Стратегия "Полный контекст": каждый получает всю предыдущую историю
|
|
73
|
+
current_full_history = list(history)
|
|
74
|
+
for item in items:
|
|
75
|
+
result.append(ContextItem(data=item, context=list(current_full_history)))
|
|
76
|
+
current_full_history.append(formatter(item))
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _default_formatter(item: Any) -> str:
|
|
82
|
+
"""Стандартный форматтер: преобразует данные в строку."""
|
|
83
|
+
return str(item)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Модуль с определениями исключений библиотеки chllm.
|
|
2
|
+
|
|
3
|
+
Обеспечивает единую иерархию ошибок для различных LLM провайдеров.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .base import CHLLMError
|
|
7
|
+
from .parsing import InvalidRequestError, ParsingError
|
|
8
|
+
from .provider import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ContentBlockedError,
|
|
11
|
+
QuotaExceededError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServiceUnavailableError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AuthenticationError",
|
|
18
|
+
"CHLLMError",
|
|
19
|
+
"ContentBlockedError",
|
|
20
|
+
"InvalidRequestError",
|
|
21
|
+
"ParsingError",
|
|
22
|
+
"QuotaExceededError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ServiceUnavailableError",
|
|
25
|
+
]
|
chllm/exceptions/base.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Исключения при валидации запросов и разборе ответов LLM."""
|
|
2
|
+
|
|
3
|
+
from .base import CHLLMError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidRequestError(CHLLMError):
|
|
7
|
+
"""Ошибка в структуре запроса или параметрах."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParsingError(CHLLMError):
|
|
11
|
+
"""Ошибка при разборе ответа от LLM."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Исключения, связанные с вызовами и квотами LLM-провайдеров."""
|
|
2
|
+
|
|
3
|
+
from .base import CHLLMError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RateLimitError(CHLLMError):
|
|
7
|
+
"""Превышен лимит запросов (RPM/TPM).
|
|
8
|
+
|
|
9
|
+
Обычно это временная ошибка, которую можно повторить через паузу.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, retry_delay: float = 60.0) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.retry_delay: float = retry_delay
|
|
15
|
+
"Задержка в секундах перед следующей попыткой"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QuotaExceededError(CHLLMError):
|
|
19
|
+
"""Исчерпана квота (обычно дневной лимит или баланс аккаунта).
|
|
20
|
+
|
|
21
|
+
Требует вмешательства пользователя или смены ключа.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ServiceUnavailableError(CHLLMError):
|
|
26
|
+
"""Сервер LLM временно недоступен или перегружен (Error 503/504)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthenticationError(CHLLMError):
|
|
30
|
+
"""Ошибка аутентификации (неверный API ключ)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ContentBlockedError(CHLLMError):
|
|
34
|
+
"""Запрос или ответ заблокирован фильтрами безопасности провайдера."""
|
chllm/masking.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модуль для маскирования (защиты) частей текста от изменений со стороны LLM.
|
|
3
|
+
Используется для защиты переменных, тегов и специальных символов.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class MaskedResult:
|
|
12
|
+
"""Результат операции маскирования.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
masked_text: Текст, в котором целевые части заменены на плейсхолдеры.
|
|
16
|
+
mapping: Словарь соответствия плейсхолдеров оригинальным значениям.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
masked_text: str
|
|
20
|
+
mapping: dict[str, str] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContentMasker:
|
|
24
|
+
"""Универсальный компонент для маскирования текста на основе регулярных выражений."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
patterns: list[str] | None = None,
|
|
29
|
+
placeholder_prefix: str = "VAR",
|
|
30
|
+
placeholder_template: str = "[[[{prefix}_{index}]]]",
|
|
31
|
+
):
|
|
32
|
+
"""Инициализирует маскер.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
patterns: Список регулярных выражений для поиска защищаемых частей.
|
|
36
|
+
placeholder_prefix: Префикс для плейсхолдеров (напр. "VAR", "TAG").
|
|
37
|
+
placeholder_template: Шаблон формирования плейсхолдера.
|
|
38
|
+
"""
|
|
39
|
+
self._patterns = patterns or []
|
|
40
|
+
self._prefix = placeholder_prefix
|
|
41
|
+
self._template = placeholder_template
|
|
42
|
+
|
|
43
|
+
def mask(
|
|
44
|
+
self,
|
|
45
|
+
text: str,
|
|
46
|
+
extra_patterns: list[str] | None = None,
|
|
47
|
+
excluded_values: set[str] | list[str] | None = None,
|
|
48
|
+
) -> MaskedResult:
|
|
49
|
+
"""Маскирует все вхождения паттернов в тексте.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text: Исходный текст.
|
|
53
|
+
extra_patterns: Дополнительные паттерны только для этого вызова.
|
|
54
|
+
excluded_values: Набор значений/паттернов, которые не должны маскироваться.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Объект MaskedResult с результатом и картой маскировки.
|
|
58
|
+
"""
|
|
59
|
+
if not text:
|
|
60
|
+
return MaskedResult(masked_text=text)
|
|
61
|
+
|
|
62
|
+
all_patterns = self._patterns + (extra_patterns or [])
|
|
63
|
+
if not all_patterns:
|
|
64
|
+
return MaskedResult(masked_text=text)
|
|
65
|
+
|
|
66
|
+
exclude_set = set(excluded_values or [])
|
|
67
|
+
mapping: dict[str, str] = {}
|
|
68
|
+
masked_text = text
|
|
69
|
+
|
|
70
|
+
# Объединяем паттерны в одно регулярное выражение для поиска всех вхождений сразу.
|
|
71
|
+
# Сортируем паттерны по длине в обратном порядке, чтобы длинные вхождения
|
|
72
|
+
# (напр. \\n) имели приоритет над короткими (напр. \n).
|
|
73
|
+
sorted_patterns = sorted(all_patterns, key=len, reverse=True)
|
|
74
|
+
combined_regex = re.compile("|".join(f"({p})" for p in sorted_patterns))
|
|
75
|
+
|
|
76
|
+
def replace_match(match: re.Match) -> str:
|
|
77
|
+
# Извлекаем реально совпавшую строку (первая не-None группа)
|
|
78
|
+
original_value = match.group(0)
|
|
79
|
+
|
|
80
|
+
# Если значение находится в списке исключений, оставляем его без маскировки
|
|
81
|
+
if original_value in exclude_set:
|
|
82
|
+
return original_value
|
|
83
|
+
|
|
84
|
+
# Проверяем, не маскировали ли мы это значение ранее
|
|
85
|
+
# (для экономии индексов и единообразия)
|
|
86
|
+
for p, v in mapping.items():
|
|
87
|
+
if v == original_value:
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
# Создаем новый плейсхолдер
|
|
91
|
+
index = len(mapping)
|
|
92
|
+
placeholder = self._template.format(prefix=self._prefix, index=index)
|
|
93
|
+
mapping[placeholder] = original_value
|
|
94
|
+
return placeholder
|
|
95
|
+
|
|
96
|
+
# Выполняем замену всех найденных вхождений
|
|
97
|
+
masked_text = combined_regex.sub(replace_match, masked_text)
|
|
98
|
+
|
|
99
|
+
return MaskedResult(masked_text=masked_text, mapping=mapping)
|
|
100
|
+
|
|
101
|
+
def demask(self, masked_text: str, mapping: dict[str, str]) -> str:
|
|
102
|
+
"""Восстанавливает оригинальный текст из маскированного.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
masked_text: Текст с плейсхолдерами.
|
|
106
|
+
mapping: Карта маскировки (из MaskedResult).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Восстановленный текст.
|
|
110
|
+
"""
|
|
111
|
+
if not masked_text or not mapping:
|
|
112
|
+
return masked_text
|
|
113
|
+
|
|
114
|
+
demasked_text = masked_text
|
|
115
|
+
# Заменяем в обратном порядке (плейсхолдеры на оригиналы)
|
|
116
|
+
for placeholder, original_value in mapping.items():
|
|
117
|
+
demasked_text = demasked_text.replace(placeholder, original_value)
|
|
118
|
+
|
|
119
|
+
return demasked_text
|
chllm/metrics.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модуль для работы с метриками и подсчета токенов в запросах к LLM.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, computed_field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UsageMetrics(BaseModel):
|
|
13
|
+
"""Модель для хранения данных об использовании токенов.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
prompt_tokens: Количество токенов в запросе.
|
|
17
|
+
candidates_tokens: Количество токенов в ответе.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
prompt_tokens: int = Field(default=0, ge=0)
|
|
21
|
+
candidates_tokens: int = Field(default=0, ge=0)
|
|
22
|
+
|
|
23
|
+
@computed_field # type: ignore[prop-decorator]
|
|
24
|
+
@property
|
|
25
|
+
def total_tokens(self) -> int:
|
|
26
|
+
"""Вычисляет общую сумму токенов (вычисляемое поле)."""
|
|
27
|
+
return self.prompt_tokens + self.candidates_tokens
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenCounter:
|
|
31
|
+
"""Универсальный калькулятор для оценки количества токенов.
|
|
32
|
+
|
|
33
|
+
Поддерживает различные стратегии подсчета: от простой эвристики
|
|
34
|
+
до использования библиотек-токенизаторов.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, strategy: str = "heuristic", model: str | None = None, chars_per_token: dict[str, float] | None = None
|
|
39
|
+
):
|
|
40
|
+
"""Инициализирует счетчик.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
strategy: Стратегия подсчета ("heuristic", "tiktoken").
|
|
44
|
+
model: Имя модели для точного подбора токенизатора.
|
|
45
|
+
chars_per_token: Кастомные коэффициенты символов на токен.
|
|
46
|
+
"""
|
|
47
|
+
self._strategy = strategy
|
|
48
|
+
self._model = model
|
|
49
|
+
|
|
50
|
+
# Константы по умолчанию (символов на токен)
|
|
51
|
+
default_ratios = {"en": 2.5, "ru": 2.5, "default": 3.5}
|
|
52
|
+
|
|
53
|
+
# Объединяем дефолты с пользовательскими настройками
|
|
54
|
+
self._chars_per_token = default_ratios
|
|
55
|
+
if chars_per_token:
|
|
56
|
+
self._chars_per_token.update(chars_per_token)
|
|
57
|
+
|
|
58
|
+
def count(self, data: Any, language: str | None = None) -> int:
|
|
59
|
+
"""Подсчитывает или оценивает количество токенов в данных.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: Текст, словарь или список данных для подсчета.
|
|
63
|
+
language: Код языка. Если None, используется коэффициент 'default'.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Оценочное или точное количество токенов.
|
|
67
|
+
"""
|
|
68
|
+
if data is None:
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
# Превращаем данные в строку для оценки
|
|
72
|
+
text = self._to_string(data)
|
|
73
|
+
|
|
74
|
+
if self._strategy == "heuristic":
|
|
75
|
+
# Если язык не указан, используем 'default'
|
|
76
|
+
return self._heuristic_count(text, language or "default")
|
|
77
|
+
|
|
78
|
+
# TODO: Реализовать интеграцию с tiktoken при необходимости
|
|
79
|
+
return self._heuristic_count(text, language or "default")
|
|
80
|
+
|
|
81
|
+
def estimate_completion_tokens(
|
|
82
|
+
self, items: list[str], scaling_factor: float = 1.2, per_item_overhead_chars: int = 50, language: str = "ru"
|
|
83
|
+
) -> int:
|
|
84
|
+
"""Оценивает примерное количество токенов в будущем ответе (Completion) ИИ.
|
|
85
|
+
|
|
86
|
+
Полезно для планирования лимитов и логирования ожидаемой нагрузки.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
items: Список исходных строк (элементов), которые будут обработаны.
|
|
90
|
+
scaling_factor: Коэффициент изменения объема текста (1.2 для перевода, 0.5 для суммаризации).
|
|
91
|
+
per_item_overhead_chars: Технический оверхед на один элемент (структура JSON).
|
|
92
|
+
language: Ожидаемый язык ответа.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Приблизительное количество токенов в ответе.
|
|
96
|
+
"""
|
|
97
|
+
if not items:
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
total_chars: float = 0.0
|
|
101
|
+
for text in items:
|
|
102
|
+
# Считаем длину результата + оверхед
|
|
103
|
+
item_len = len(text) * scaling_factor
|
|
104
|
+
total_chars += item_len + per_item_overhead_chars
|
|
105
|
+
|
|
106
|
+
return self.count(" " * int(total_chars), language=language)
|
|
107
|
+
|
|
108
|
+
def _heuristic_count(self, text: str, language: str) -> int:
|
|
109
|
+
"""Оценивает количество токенов по количеству символов."""
|
|
110
|
+
chars_per_token = self._chars_per_token.get(language, self._chars_per_token["default"])
|
|
111
|
+
if chars_per_token <= 0:
|
|
112
|
+
return 0
|
|
113
|
+
return math.ceil(len(text) / chars_per_token)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _to_string(data: Any) -> str:
|
|
117
|
+
"""Приводит любые входные данные к строковому виду."""
|
|
118
|
+
if isinstance(data, str):
|
|
119
|
+
return data
|
|
120
|
+
if isinstance(data, (dict, list)):
|
|
121
|
+
return json.dumps(data, ensure_ascii=False)
|
|
122
|
+
return str(data)
|