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 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
+ ]
@@ -0,0 +1,5 @@
1
+ """Базовые исключения для библиотеки chllm."""
2
+
3
+
4
+ class CHLLMError(Exception):
5
+ """Базовый класс для всех исключений библиотеки chllm."""
@@ -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)