Чёрная распродажная пятница 13

Всё смешалось в доме у Обломова… На самом деле, оригинал этой крылатой фразы звучал как: «Все смешалось в доме Облонских», но тогда контекст будет каким-то уж больно невесёлым.

Все счастливые семьи похожи друг на друга, каждая несчастливая семья несчастлива по-своему. Все смешалось в доме Облонских. Жена узнала, что муж был в связи с бывшею в их доме француженкою-гувернанткой, и объявила мужу, что не может жить с ним в одном доме. © Л.Н. Толстой «Анна Кренина»

Ирония (h 100px)
Так что версия с Обломовым видится более забавной. Особенно если это не тот Обломов, который Илья Ильич, а какой-нибудь другой.

В общем, нынче чёрная пятница (распродажи, ога-ога, погнали покупать) смешалась с пятницей 13.

А ХЗ (h 100px)
Т.е. день когда поднятые накануне цены немного снизили, чтобы изобразить скидки, совпал с днём, когда не хочется лишний раз никуда выходить, да и делать что-либо, если честно, тоже.

Так что, первым делом (но сразу после чая), словно в насмешку над самим собой, я пошёл на Озон и заказал очки для Эль. У старых вчера сломалась дужка, и теперь Эль гоняет в моих запасных.

А вот вторым — поправил CSS в той части, которая отвечает за зачёркнутый текст. Теперь зачёркивание переносится нормально.

.content del{
    color: #344;
    padding: 0 5px;
    text-decoration: line-through;
    text-decoration-color: #a00;
    text-decoration-thickness: 4px;
    position: relative;
}
Одобряю (h 100px)
Результат нравится.

Следующим шагом — редактировал десятый урок по ИИ на моём флагманском блоге. В этот раз писал про ИИ-агент, на простом примере. Вот только в процессе решил, что нужно попытаться выдать не только ядро агента, но и пример его полной версии. А значит, надо кодить и тестировать.

Увлёкся. На выходе получился вот такой ИИноагент.

"""
Минимальный ИИ-агент для Ollama (интерактивный, блокирует ввод во время ожидания, показывает спиннер).

Основной цикл:
    messages = [system, user]
    повторять до MAX_STEPS:
        reply = chat(messages, tools)            # POST /api/chat
        if нет tool_calls: это финальный ответ
        else: result = run_tool(args); messages += {'role': 'tool', ...}

Примеры инструментов: current_date (сегодняшняя дата) и days_between
(количество дней между двумя датами).

Использование:
    python ollama_minimal.py                # интерактивный режим (диалог с историей)
    python ollama_minimal.py "вопрос"       # один вопрос -- один ответ

Хост/модель: переменные окружения OLLAMA_HOST и OLLAMA_MODEL.
"""

import datetime
import json
import os
import re
import sys
import threading
import urllib.error
import urllib.request

# Тут указываю хост (адрес ollama)
HOST = os.environ.get('OLLAMA_HOST', 'http://192.168.0.114:11434')
# Здесь указываю модель, которую будет использовать ollama
MODEL = os.environ.get('OLLAMA_MODEL', 'qwen3:8b')
# Это лимит токенов (контекстное окно)
NUM_CTX = 16384
# Максимальное количество шагов (запросов к модели), чтобы модель не зациклилась
MAX_STEPS = 8

SYSTEM_PROMPT = 'Отвечай кратко по-русски. Даты узнавай инструментами, не угадывай.'

TOOLS = [
    {'type': 'function', 'function': {
        'name': 'current_date',
        'description': 'Сегодняшняя дата в формате ГГГГ-ММ-ДД.',
        'parameters': {'type': 'object', 'properties': {}}},
     },
    {'type': 'function', 'function': {
        'name': 'days_between',
        'description': 'Сколько дней от даты from до даты to (формат ГГГГ-ММ-ДД).',
        'parameters': {'type': 'object',
                       'properties': {'from': {'type': 'string', 'description': 'начальная дата'},
                                      'to': {'type': 'string', 'description': 'конечная дата'}},
                       'required': ['from', 'to']}},
     },
]


def run_tool(name, args):
    """Выполняет инструмент по имени с заданными аргументами. Возвращает строковый результат."""
    try:
        if name == 'current_date':
            return datetime.date.today().isoformat()
        if name == 'days_between':
            d1 = datetime.date.fromisoformat(args.get('from', ''))
            d2 = datetime.date.fromisoformat(args.get('to', ''))
            return str((d2 - d1).days)
        return f'ОШИБКА: неизвестный инструмент {name}'
    except Exception as e:
        return f'ОШИБКА: {e}'


def chat(messages):
    """Отправляет историю сообщений в Ollama POST-запросом; возвращает словарь сообщения ассистента."""
    payload = {'model': MODEL, 'messages': messages, 'tools': TOOLS, 'stream': False,
               'options': {'num_ctx': NUM_CTX, 'temperature': 0.1}}
    req = urllib.request.Request(HOST + '/api/chat', data=json.dumps(payload).encode('utf-8'),
                                 headers={'Content-Type': 'application/json'})
    with urllib.request.urlopen(req, timeout=600) as resp:
        return json.loads(resp.read().decode('utf-8'))['message']


# ---------------------------------------------------------------- спиннер

class Spinner:
    """Рисует точки в фоновом потоке, пока выполняется запрос.

    Строка стирается с помощью '\\r' + пробелы + '\\r', чтобы не засорять вывод.
    """

    def __init__(self, text='Ожидание ответа агента', interval=0.4):
        self.text = text
        self.interval = interval
        self._stop = threading.Event()
        self._thread = None
        self._last_len = 0
        self._lock = threading.Lock()

    def _run(self):
        dots = 0
        while not self._stop.is_set():
            frame = self.text + '.' * (dots % 4)
            with self._lock:
                pad = max(0, self._last_len - len(frame))
                sys.stdout.write('\r' + frame + ' ' * pad)
                sys.stdout.flush()
                self._last_len = len(frame)
            dots += 1
            self._stop.wait(self.interval)

    def __enter__(self):
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()
        return self

    def __exit__(self, *exc):
        self._stop.set()
        if self._thread:
            self._thread.join()
        with self._lock:
            sys.stdout.write('\r' + ' ' * self._last_len + '\r')
            sys.stdout.flush()
            self._last_len = 0


# ---------------------------------------------------------------- агент

def agent_step(messages):
    """Добавляет один ход ассистента в `messages`. Возвращает финальный текст.

    Изменяет `messages` на месте (добавляет записи assistant и tool).
    """
    for step in range(1, MAX_STEPS + 1):
        msg = chat(messages)
        # Некоторые reasoning-модели (например, qwen3) оборачивают размышления в <think>...</think>;
        # убираем это перед сохранением видимого содержимого.
        msg['content'] = re.sub(r'<think>.*?</think>', '', msg.get('content') or '', flags=re.S).strip()

        stored = {'role': 'assistant', 'content': msg['content']}
        calls = msg.get('tool_calls')
        if calls:
            stored['tool_calls'] = calls
        messages.append(stored)

        if not calls:
            return msg['content']

        for tc in calls:
            name = tc['function']['name']
            args = tc['function'].get('arguments') or {}
            if isinstance(args, str):
                try:
                    args = json.loads(args)
                except json.JSONDecodeError:
                    args = {}
            result = run_tool(name, args)
            print(f'  шаг {step}: {name}({json.dumps(args, ensure_ascii=False)}) -> {result}')
            messages.append({'role': 'tool', 'content': result, 'tool_name': name})

    return '(лимит шагов исчерпан: модель не дала итог)'


def agent(question):
    messages = [{'role': 'system', 'content': SYSTEM_PROMPT},
                {'role': 'user', 'content': question}]
    return agent_step(messages)


def trim(messages, keep=12):
    """Оставляет системный промпт плюс последние `keep` сообщений.

    Сдвигает точку обрезки назад к записи 'user', чтобы сообщение assistant
    с tool_calls никогда не оказалось оторвано от своего результата tool.
    """
    if len(messages) <= keep + 1:
        return messages
    start = len(messages) - keep
    while start > 1 and messages[start].get('role') != 'user':
        start -= 1
    return [messages[0]] + messages[start:]


def interactive():
    messages = [{'role': 'system', 'content': SYSTEM_PROMPT}]
    print('Интерактивный режим. Пустая строка — пропуск ввода, exit / Ctrl+D — выход.')
    while True:
        try:
            q = input('\nВы: ').strip()
        except EOFError:
            print('\nПока.')
            return 0
        except KeyboardInterrupt:
            print('\nПрервано.')
            return 130

        if not q:
            continue
        if q.lower() in ('exit', 'quit', 'выход'):
            return 0

        messages.append({'role': 'user', 'content': q})
        print('  (отправлено, жду ответа...)')
        try:
            # Ввод блокируется здесь: мы не вызываем input() снова, пока не придёт ответ.
            with Spinner('Ожидание ответа агента'):
                answer = agent_step(messages)
        except urllib.error.HTTPError as e:
            print(f'\nOllama вернула HTTP {e.code}: {e.read().decode("utf-8", "replace")[:300]}')
            messages.pop()
            continue
        except urllib.error.URLError as e:
            print(f'\nOllama недоступна на {HOST}: {e.reason}')
            messages.pop()
            continue
        print('Агент:', answer)
        messages = trim(messages)


if __name__ == '__main__':
    if len(sys.argv) > 1:
        try:
            with Spinner('Ожидание ответа агента'):
                print(agent(' '.join(sys.argv[1:])))
        except urllib.error.HTTPError as e:
            sys.exit(f'Ollama вернула HTTP {e.code}: {e.read().decode("utf-8", "replace")[:300]}')
        except urllib.error.URLError as e:
            sys.exit(f'Ollama недоступна на {HOST}: {e.reason}')
    else:
        sys.exit(interactive())
Улыбка (h 100px)
Хотя это и учебная версия, но в ней можно полноценно чатиться с моделью и при этом она способна использовать заданные ей инструменты.

И вот казалось бы, только проснулся, а уже 14:00. Этот кодинг — натуральная машина времени. Но оно того стоило. Бы. Если бы в продаже вдруг появились дешёвые компьютеры с GPU хотя бы на 8 Gb, и при этом не требующие активного охлаждения. Даже если бы они были размером с советский ламповый телевизор Радуга 716Д, это имело был смысл для локальных ИИноагентов.

А что? Поставил куда-нибудь, и используешь по локальной сети, даже если весь интернет в мире отключили. Останется добавить распознавание/генерацию голоса, собственные инструменты — и плов готов и вот тебе готовая умная колонка, умеющая, к тому же, управлять освещением, шторами, и всем таким прочим.

В шоке (h 100px)
Да хоть чердачной системой ПВО, чтобы пугать голубей и изничтожать комаров на подлёте.

Но… Не завезли пока в нынешнюю реальность таких полезных девайсов. Так что, пользуем то что есть. Вот в моём детстве система умного дома с голосовым управлением работала на детях. Говоришь такому: переключи  на 8-й канал (каналов в Ленинграде было 3: первый, пятый и восьмой), и он встаёт, идёт к телевизору, щёлкает тугим переключателем…

Замыслил-затаил (h 100px)
Надо ли говорить, что этим ребёнком был именно я? Ну тогда и не стану.

Была у нас в детстве присказка: «Подарки не отдарки». Т.е. если что-то подарил, то назад не клянчи. Но то детство. Например у mail.ru всё по взрослому.

Подарили несколько лет назад 1 Тб облачного хранилища бесплатно?

Ну как подарили, так и отобрали назад. Делов то. Ещё и кнопочку «Увеличить место» добавили. Кликаешь, а там…

Ты только посмотри КАКИЕ скидки!

Это ок (h 100px)
Ну что тут сказать. Подарить, потом по тихой отобрать, и предложить вернуть по подписке. Это же тот самый маркетинГ, с большой буквы Г! Лучше бы и не дарили. Mail.ru! «Наша репутация и не такое переживёт». И ведь действительно переживёт.

Я бы и не узнал, если бы не тестировал сегодня проблемы с почтой у одного моего заказчика.

Напишите комментарий

Введите имя

Введите адрес электронной почты

Введите адрес вашего сайта

Нажмите эту кнопку, чтобы отправить комментарий.

Введите текст комментария