- BrainTools - https://www.braintools.ru -

Как я встроил AI‑агента прямо в интерфейс Apache Superset и не сломал ему CSP

Есть Apache Superset. Есть аналитик, который открывает SQL Lab и десять минут вспоминает, как называется таблица с заказами — orders, olist_orders или fact_orders_v2. Есть языковые модели, которые отлично пишут SQL, но понятия не имеют, что лежит в вашем хранилище.

Задача звучала просто: добавить в интерфейс Superset кнопку, по которой открывается чат с моделью. Причём агент должен работать по принципу MCP — не фантазировать имена колонок, а сходить и посмотреть их в метаданных. Подключаться к любому OpenAI‑совместимому API: локальная Ollama, llm7, OpenAI — что угодно.

Дальше — про то, как это делается без форка Superset, без пересборки фронтенда и без ослабления политики безопасности. И про несколько ловушек, каждая из которых стоила мне отдельного расследования.

Перед техническим разбором можно посмотреть короткую демонстрацию того, как AI‑ассистент работает непосредственно в интерфейсе Apache Superset: видео на YouTube [1].

Ограничения, которые определили архитектуру

Прежде чем писать код, я выписал условия задачи. Они оказались жёстче, чем выглядели.

Superset нельзя форкать. Он должен обновляться штатно, через pip install apache-superset==X.Y.Z. Значит правки в его файлах исключены.

Фронтенд нельзя пересобирать. Технически можно: у меня в проекте уже есть модуль, который клонирует superset-frontend, ставит Node, собирает бандл и подменяет статику. Это работает, но занимает 20–40 минут на сервере с 8 ГБ RAM и ломается при каждом обновлении Superset. Для кнопки в интерфейсе — неприемлемая цена.

Ставиться должно на уже работающий Superset. Одной кнопкой, без переустановки.

Права пользователей должны соблюдаться. Если аналитик не видит базу с зарплатами, агент тоже не должен её видеть. Это отдельно важно, потому что агент умеет выполнять SQL.

Из этих четырёх пунктов следует ровно один способ интеграции: расширение, которое живёт сбоку и подключается через официальную точку расширения Flask‑приложения.

Точка входа: FLASK_APP_MUTATOR

Superset — это Flask‑приложение, и в его конфиге есть хук FLASK_APP_MUTATOR. Документация описывает его скупо: «хук, дающий администраторам доступ к Flask APP после инициализации». Вопрос в том, что значит «после инициализации» — какие части приложения уже собраны, а какие нет.

Полез в исходники Superset, в superset/initialization/__init__.py:

def init_app_in_ctx(self) -> None:
    self.configure_fab()
    self.configure_subjects()
    self.configure_url_map_converters()
    self.configure_data_sources()
    self.configure_auth_provider()
    self.configure_async_queries()
    self.configure_ssh_manager()
    self.configure_stats_manager()
    self.configure_task_manager()
    # Hook that provides administrators a handle on the Flask APP
    # after initialization
    if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
        flask_app_mutator(self.superset_app)
    self.superset_app.sync_config_to_db()
    self.init_views()

Это важнее, чем кажется. Хук вызывается после configure_fab() — значит app.appbuilder уже существует, и в меню можно добавить пункт. И до init_views() — значит мои маршруты регистрируются раньше штатных, что для отдельного префикса безразлично.

Итого в superset_config.py [2] дописывается один блок между маркерами — 16 строк кода (27 вместе с маркерами и комментариями). Ниже он без комментариев:

# >>> superset-ai-assistant >>>
import sys as _sai_sys

_SAI_PATH = '/root/superset_ai'
if _SAI_PATH not in _sai_sys.path:
    _sai_sys.path.insert(0, _SAI_PATH)

AI_ASSISTANT_SETTINGS_PATH = '/root/superset_ai/ai_settings.json'


def FLASK_APP_MUTATOR(app):
    try:
        from superset_ai import init_ai_assistant
        init_ai_assistant(app)
    except Exception as _sai_error:
        import logging
        logging.getLogger(__name__).exception(
            "AI-ассистент не подключён: %s", _sai_error
        )
# <<< superset-ai-assistant <<<

Обратите внимание [3] на try/except вокруг инициализации. Это не паранойя: если расширение упадёт на импорте, без обёртки не поднимется весь Superset. Аналитики останутся без BI из‑за сломанной кнопки чата — плохой обмен. Здесь и дальше я исходил из принципа: ассистент не имеет права ломать Superset ни при каких обстоятельствах.

Ни одна существующая строка конфига не меняется. FEATURE_FLAGS, TALISMAN_CONFIG, SECRET_KEY, база метаданных — всё остаётся как было.

Ловушка первая: strict‑dynamic

Кнопку надо как‑то показать. Superset отдаёт браузеру серверный HTML‑каркас, в который React уже монтирует интерфейс. Значит достаточно дописать в этот каркас <script> — сделать это можно во Flask‑хуке after_request.

Пишу инъекцию, открываю Superset — скрипт заблокирован. Смотрю в консоль: CSP.

Политика в генерируемом конфиге такая:

TALISMAN_CONFIG = {
    "content_security_policy": {
        "default-src": ["'self'"],
        "style-src": ["'self'", "'unsafe-inline'"],
        "script-src": ["'self'", "'strict-dynamic'", "'unsafe-eval'"],
        ...
    },
    "content_security_policy_nonce_in": ["script-src"],
}

Здесь ключевое — 'strict-dynamic'. Эта директива говорит браузеру: игнорируй разрешения по источникам. То есть 'self' в script-src при наличии strict-dynamic перестаёт работать вообще. Выполнится только скрипт с правильным одноразовым токеном — nonce.

Соблазнительный путь: раз конфиг генерирует мой же установщик, можно убрать strict-dynamic и добавить 'unsafe-inline'. Три строки правки. Но это осознанное ослабление защиты от XSS в BI‑системе, которая ходит в продовые базы. За кнопку чата — нет.

Значит нужен nonce. Его выдаёт flask-talisman, и надо понять, где он лежит. Открываю исходник flask_talisman/talisman.py:

def init_app(self, app, ...):
    ...
    app.jinja_env.globals['csp_nonce'] = self._get_nonce
    app.before_request(self._force_https)
    app.before_request(self._make_nonce)
    app.after_request(self._set_response_headers)

def _make_nonce(self):
    local_options = self._get_local_options()
    if (local_options['content_security_policy'] and
            self.content_security_policy_nonce_in and
            not getattr(flask.request, 'csp_nonce', None)):
        flask.request.csp_nonce = get_random_string(NONCE_LENGTH)

Вот и ответ. Nonce генерируется в before_request и живёт в flask.request.csp_nonce. Мой after_request выполняется позже — значит токен уже доступен:

def _csp_nonce():
    """
    Nonce, который flask-talisman положил в request на этот запрос.
    Без него браузер отклонит скрипт: в CSP Superset включён strict-dynamic.
    """
    try:
        import flask
        return getattr(flask.request, "csp_nonce", "") or ""
    except Exception:
        return ""

И сама инъекция:

@app.after_request
def inject_ai_widget(response):
    try:
        config = ai_settings.load()
        if not config.get("enabled") or not config.get("inject_widget"):
            return response
        if not _is_authenticated() and not config.get("allow_anonymous"):
            return response
        if not _should_inject(response):
            return response

        body = response.get_data(as_text=True)
        if "</body>" not in body or _INJECT_MARKER in body:
            return response

        nonce = _csp_nonce()
        nonce_attr = ' nonce="{}"'.format(nonce) if nonce else ""
        snippet = (
            '<link id="{marker}-css" rel="stylesheet" href="{prefix}/static/widget.css">'
            '<script id="{marker}" src="{prefix}/static/widget.js"'
            ' data-ai-base="{prefix}" defer{nonce}></script>'
        ).format(marker=_INJECT_MARKER, prefix=URL_PREFIX, nonce=nonce_attr)

        body = body.replace("</body>", snippet + "</body>", 1)
        response.set_data(body)
        response.headers.pop("Content-Length", None)
    except Exception as error:
        # Никогда не ломаем страницу Superset из-за виджета
        logger.warning("AI-ассистент: не удалось встроить виджет: %s", error)

    return response

Три детали, которые легко упустить:

response.headers.pop("Content-Length", None) — тело выросло, старая длина стала ложью. Без этого браузер обрежет страницу.

Проверка _INJECT_MARKER in body — защита от повторной вставки.

Фильтр _should_inject — трогаем только HTML‑страницы, и только успешные:

def _should_inject(response):
    if request.path.startswith(URL_PREFIX):
        return False
    content_type = (response.headers.get("Content-Type") or "").lower()
    if "text/html" not in content_type:
        return False
    if response.status_code != 200 or response.direct_passthrough:
        return False
    return True

direct_passthrough — про отдачу файлов через send_file: у таких ответов тело читать нельзя. Проверка по Content-Type заодно спасает JSON‑ответы API от превращения в невалидные.

Как я это проверил

Утверждение «nonce подставляется правильно» проверяется ровно одним способом: поднять Flask с той же политикой, что у Superset, и сравнить nonce в теге с nonce в итоговом заголовке.

Talisman(
    app, force_https=False,
    content_security_policy={
        "default-src": ["'self'"],
        "style-src": ["'self'", "'unsafe-inline'"],
        "script-src": ["'self'", "'strict-dynamic'", "'unsafe-eval'"],
    },
    content_security_policy_nonce_in=["script-src"],
)
...
csp = re.search(r"'nonce-([^']+)'", response.headers["Content-Security-Policy"])
tag = re.search(r'id="superset-ai-widget-loader"[^>]*nonce="([^"]+)"', html)
assert csp.group(1) == tag.group(1)

Совпало. CSP не тронут ни на символ, фронтенд не пересобран.

Есть тонкость с порядком хуков. Flask выполняет after_request в обратном порядке регистрации: мой хук зарегистрирован позже, поэтому срабатывает раньше talisman’а. На момент инъекции заголовка CSP в ответе ещё нет — но он мне и не нужен, я беру nonce из request, куда его положили в before_request. Если бы я пытался вытащить токен из заголовка ответа, ничего бы не вышло.

Пункт меню: подводный камень с правами

Плавающая кнопка — хорошо, но пользователи ищут функции в меню. FLASK_APP_MUTATOR вызывается после configure_fab(), поэтому:

appbuilder.add_link(
    name="AI Assistant",
    label=_("AI Assistant"),
    href=URL_PREFIX + "/",
    icon="fa-robot",
    category="",
)

Ссылка появляется в навбаре Superset, потому что React‑меню строится из данных FAB.

Но есть нюанс. add_link создаёт разрешение menu_access на новый пункт, а вот выдать это разрешение роли Admin должен процесс синхронизации ролей. Он запускается командой superset init. Если её не выполнить, пункт меню будет существовать — и не будет виден никому.

Поэтому установщик делает superset init отдельным шагом. Он занимает около полуминуты, и я сознательно не считаю его провал фатальным:

code, _, err = self.run(command, timeout=900)
if code != 0:
    self.log(
        "⚠️ superset init завершился с ошибкой — плавающая кнопка всё равно "
        "будет работать, но пункта меню может не быть: {}".format(err.strip()[:200])
    )
    return True

Плавающая кнопка не зависит от разрешений FAB, так что деградация получается частичной, а не полной.

Почему «по принципу MCP», а не MCP

Здесь надо честно разделить понятия, потому что меня об этом справедливо спросили уже после того, как всё заработало.

В Superset 6.x есть встроенный MCP‑сервер: команда superset mcp run поднимает отдельный процесс на порту 5008, к которому подключаются внешние клиенты вроде Claude Desktop или Cursor. Это настоящий MCP по протоколу.

Мой агент — не он. Общее у них — принцип работы: модель получает список инструментов со схемами, сама решает, что вызвать, получает результат и продолжает рассуждение. Различие в том, что мои инструменты выполняются прямо внутри процесса Superset, а не по сети через MCP‑протокол.

Почему я выбрал такой путь, хотя проксировать к встроенному MCP было бы меньше кода:

Мой агент

Встроенный MCP Superset

Где живёт

внутри gunicorn

тдельный процесс, порт 5008

Клиент

кнопка в браузере

внешний клиент

Права

текущего пользователя Superset

от MCP_DEV_USERNAME, авторизация выключена

Последняя строка и решила дело. MCP‑сервер в штатной конфигурации работает в dev‑режиме: все операции от имени администратора, без авторизации. Если бы кнопка в браузере ходила через него, любой пользователь Superset получил бы доступ к данным с правами админа. Это обход модели прав, а не фича.

Два механизма независимы и могут работать одновременно.

Инструменты: семь функций вместо догадок

Реестр инструментов — обычный словарь, из которого генерируются схемы в формате OpenAI function calling:

REGISTRY = {
    "list_databases": {...},
    "list_datasets": {...},
    "describe_dataset": {...},
    "list_charts": {...},
    "list_dashboards": {...},
    "run_sql": {...},
    "get_page_context": {...},
}

Описания инструментов — это тоже часть промпта, и они написаны как инструкции модели:

"describe_dataset": {
    "func": describe_dataset,
    "description": "Колонки, типы данных и метрики датасета. "
    "Вызывай перед написанием SQL, чтобы не выдумывать имена колонок.",
    ...
}

Отдельно стоит get_page_context — он получает от виджета URL страницы и распознаёт, что открыто у пользователя, а затем обогащает это данными из БД:

var dashboardMatch = path.match(//(?:superset/)?dashboard/([^/]+)/);
if (dashboardMatch) {
  context.page = 'dashboard';
  context.dashboard_id = dashboardMatch[1];
} else if (path.indexOf('/explore') === 0) {
  context.page = 'chart';
  if (params.get('slice_id')) context.chart_id = params.get('slice_id');
}

Благодаря этому вопрос «объясни, что тут показано» работает без уточнений: агент подтягивает состав дашборда и типы визуализаций.

Совместимость с версиями Superset

Внутренности Superset между версиями переезжают. Чтобы расширение не привязывалось к одной версии, обращения написаны с перебором вариантов:

def _model_dataset():
    """SqlaTable по-разному расположен в разных версиях Superset."""
    for module_path in (
        "superset.connectors.sqla.models",
        "superset.connectors.sqlatable.models",
    ):
        try:
            module = __import__(module_path, fromlist=["SqlaTable"])
            return getattr(module, "SqlaTable")
        except (ImportError, AttributeError):
            continue
    raise ToolError("Не удалось найти модель датасета в этой версии Superset")

Та же история с движком SQLAlchemy: в Superset 3.x метод возвращал сам Engine, в 4.x и новее — контекстный менеджер:

engine_ctx = database.get_sqla_engine()
if hasattr(engine_ctx, "__enter__"):
    with engine_ctx as engine:
        columns, rows = _execute_select(engine, sa_text(limited_sql), limit)
else:
    columns, rows = _execute_select(engine_ctx, sa_text(limited_sql), limit)

Проверять версию через сравнение строк я не стал: проверка возможностей надёжнее и не ломается на форках и dev‑сборках. Оговорюсь: тестировал я на 6.1, к остальным версиям код подготовлен, но не проверен.

run_sql: самый опасный инструмент

Дать модели выполнять SQL — это дать ей выполнять SQL. Хорошая новость: у нас есть модель прав самого Superset, надо только ей воспользоваться.

Первое — только чтение. Проверка двухуровневая. Сначала грубый фильтр по ключевым словам и запрет нескольких инструкций:

_FORBIDDEN_SQL = re.compile(
    r"b("
    r"insert|update|delete|drop|alter|create|truncate|grant|revoke|"
    r"vacuum|attach|detach|call|merge|replace|copy|load|"
    r"pg_read_file|pg_sleep|lo_import|lo_export|dblink"
    r")b",
    re.IGNORECASE,
)

def _assert_select_only(sql):
    stripped = sql.strip().rstrip(";").strip()
    if ";" in stripped:
        raise ToolError("Разрешён только один SQL-запрос без ';'")
    if _FORBIDDEN_SQL.search(stripped):
        raise ToolError("Разрешены только запросы на чтение (SELECT)")
    ...

Затем — строгая проверка парсером самого Superset, тоже с перебором путей импорта:

for module_path, class_name in (
    ("superset.sql.parse", "SQLScript"),      # новые версии
    ("superset.sql_parse", "ParsedQuery"),    # старые
):
    ...

Если ни один парсер не доступен, остаётся регулярка ^s*(select|with)b. Три уровня защиты вместо одного, потому что стоимость ошибки [4] здесь высокая.

Второе — права текущего пользователя. Это принципиальный момент: агент работает не от имени администратора, а от имени того, кто нажал кнопку:

def _can_access_database(database):
    sm = _security_manager()
    for method_name in ("can_access_database", "can_access_datasource"):
        method = getattr(sm, method_name, None)
        if method is None:
            continue
        try:
            if method(database):
                return True
        except Exception:
            continue
    return _is_admin()

Третье — лимит строк. Причём накладывает его сам Superset, потому что он знает диалект целевой базы:

apply_limit = getattr(database, "apply_limit_to_sql", None)
if apply_limit is not None:
    try:
        limited_sql = apply_limit(safe_sql, effective_limit)
    except Exception:
        limited_sql = safe_sql

Четвёртое — выключатели. allow_sql: false полностью убирает инструмент из схем, отдаваемых модели. А send_sql_rows_to_model: false оставляет выполнение, но не передаёт значения строк — модель видит только имена колонок и количество записей. Для случаев, когда данные не должны покидать сервер.

Гард я проверил на наборе запросов, которые обязаны быть отклонены:

bad = ["delete from t", "select 1; drop table t", "update t set a=1",
       "drop table t", "insert into t values (1)", "select * from t; --",
       "truncate t", "call proc()"]
for q in bad:
    try:
        tools._assert_select_only(q)
        raise AssertionError("НЕ ЗАБЛОКИРОВАН: " + q)
    except tools.ToolError:
        pass

Все восемь заблокированы, три легитимных (select 1, SELECT a FROM t WHERE x=1, with q as (select 1) select * from q) прошли.

Цикл агента и модели

Сам цикл — прямолинейный: отправляем историю со схемами инструментов, если модель попросила вызов — выполняем, результат кладём в историю ролью tool, повторяем [5]. Максимум N итераций.

Интереснее то, что происходит с моделями без function calling. Их много, особенно среди локальных. Наивная реализация просто упадёт с HTTP 400.

Я сделал откат: если первый запрос с инструментами провалился и ошибка похожа на «не поддерживается», агент повторяет запрос без инструментов, но добавляет в системный промпт сводку метаданных:

try:
    data = _post(settings, _chat_payload(settings, messages, tool_schemas if use_tools else None))
except LLMError as error:
    if use_tools and iteration == 0 and _looks_like_tools_unsupported(str(error)):
        logger.info("Модель не приняла tools, переключаюсь на режим без инструментов")
        use_tools = False
        messages[0]["content"] += _tool_summary_for_fallback(settings, page_context)
        data = _post(settings, _chat_payload(settings, messages, None))
    else:
        raise

Сводка собирается теми же инструментами — просто вызывается заранее:

databases, _ = tools.call("list_databases", {}, settings, page_context)
if databases and databases.get("databases"):
    listed = ", ".join(
        "{} (id={}, {})".format(item["name"], item["id"], item["backend"])
        for item in databases["databases"][:20]
    )
    parts.append("Базы данных Superset: " + listed)

Ответ будет хуже, чем с полноценным tool calling, но он всё равно будет опираться на реальные имена датасетов. В интерфейсе при этом честно показывается плашка: модель не поддерживает инструменты, ответ построен по сводке.

Ошибки инструментов тоже не прерывают работу — они уходят модели как результат, и та может попробовать другой путь:

result, error = tools.call(name, arguments, settings, page_context)
if error:
    tool_content = json.dumps({"error": error}, ensure_ascii=False)
    step_status = "error"
else:
    tool_content = json.dumps(result, ensure_ascii=False, default=str)

Пять сценариев цикла я прогнал на подменённом _post: обычный tool calling, откат без инструментов, ошибка инструмента, сетевая ошибка, упор в лимит итераций. Все ведут себя как задумано.

Ловушка вторая: reasoning‑модель съедает max_tokens

Когда всё было готово, я пошёл смотреть, какие модели отдаёт https://api.llm7.io/v1/models [6]. Ответ оказался богаче, чем ожидал: у каждой модели есть флаг tools_calling.

26 моделей, из них 17 чатовых, и все 17 заявляют поддержку вызова инструментов. Тарифы делятся на два уровня: turbo отвечает без API‑ключа, pro требует ключ.

Проверяю бесплатную gpt-oss:20b живым запросом с описанием инструмента, max_tokens: 80. Получаю:

{"finish_reason": "length", "message": {"content": "", "reasoning": "The user asks: ..."}}

tool_calls нет. Модель начала рассуждать в поле reasoning, израсходовала все 80 токенов и до вызова инструмента не дошла.

Повторяю с max_tokens: 1500:

"tool_calls": [{"id": "call_4cgrapec", "type": "function",
  "function": {"name": "list_datasets", "arguments": "{"limit":10}"}}]

finish_reason: tool_calls, реально израсходовано 112 токенов.

Вывод практический: для reasoning‑моделей нельзя жадничать с max_tokens. Модель сначала думает, потом вызывает инструмент, и если лимит кончится на этапе размышлений, агент никогда не доберётся до данных. Дефолт 1500 работает с запасом, но опускать ниже ~800 нельзя.

Заодно выяснилось, что без ключа работают три модели (gpt-oss:20b, gemini-3.1-flash-lite, codestral-latest), а остальные 14 отдают 401 Missing API key. Это легло в UI: список моделей провайдера, где бесплатные идут первыми, плюс пояснение под полем.

CSRF: POST, кторый не проходит

Superset включает WTF_CSRF_ENABLED, и мой POST /superset-ai/api/chat попадает под защиту CSRFProtect наравне со штатными маршрутами. Без токена — HTTP 400.

Соблазн добавить свой эндпоинт в WTF_CSRF_EXEMPT_LIST есть, но это дыра. Правильный путь — брать токен там, где его берёт сам фронтенд Superset. Виджет пробует три источника по порядку убывания дешевизны:

function ensureCsrfToken() {
  if (state.csrfToken) return Promise.resolve(state.csrfToken);

  var bootstrap = window.bootstrapData;
  var fromBootstrap = (bootstrap && bootstrap.common && bootstrap.common.csrf_token)
                   || (bootstrap && bootstrap.csrf_token);
  if (fromBootstrap) { state.csrfToken = fromBootstrap; return Promise.resolve(state.csrfToken); }

  var input = document.querySelector('input[name="csrf_token"]');
  if (input && input.value) { state.csrfToken = input.value; return Promise.resolve(state.csrfToken); }

  return fetch('/api/v1/security/csrf_token/', { credentials: 'same-origin' })
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (d) { state.csrfToken = (d && (d.result || d.csrf_token)) || null; return state.csrfToken; });
}

Дальше токен уходит в заголовке X-CSRFToken, который Flask-WTF проверяет по умолчанию. Прогнал отдельным тестом с настоящим CSRFProtect: без токена 400, с токеном 200.

Ещё один момент про XSS: ответ модели — это недоверенный текст. Мой markdown‑рендер разбирает блоки кода и инлайн‑разметку, но текст всегда попадает в DOM через textContent, никогда через innerHTML:

if (index % 2 === 1) {
  var pre = el('pre');
  pre.appendChild(el('code', null, code));   // textContent внутри el()
  container.appendChild(pre);
}

Хранение настроек: почему не в конфиге

API‑ключ можно было положить в superset_config.py — и это было бы ошибкой. Конфиг попадает в бэкапы, его читают глазами, он лежит с обычными правами.

Настройки живут в отдельном файле ~/superset_ai/ai_settings.json с правами 600. В конфиге — только путь к нему. Плюс приятный побочный эффект: файл перечитывается по mtime, поэтому смена ключа или модели применяется без перезапуска Superset:

def load(force=False):
    path = settings_path()
    try:
        mtime = os.path.getmtime(path)
    except OSError:
        mtime = None

    with _LOCK:
        cached_ok = (not force and _CACHE["data"] is not None
                     and _CACHE["path"] == path and _CACHE["mtime"] == mtime)
        if cached_ok:
            return _CACHE["data"]
        ...

Битый JSON обрабатывается специально — не дефолтами, а выключением:

except Exception:
    # Битый файл — работаем на значениях по умолчанию, но выключенными,
    # чтобы не бомбардировать неизвестный эндпоинт.
    data = dict(DEFAULTS)
    data["enabled"] = False

Логика [7] простая: если мы не знаем, куда отправлять запросы, лучше не отправлять их никуда, чем стучаться в дефолтный адрес.

Наружу отдаётся урезанное представление, где ключа нет вообще:

def public_view(data=None):
    return {
        "enabled": bool(data.get("enabled")),
        "model": data.get("model", ""),
        "api_base": api_base,
        "has_api_key": bool(data.get("api_key")),   # только факт наличия
        ...
    }

Что получилось в цифрах

Серверная часть — 7 файлов, около 2000 строк:

superset_ai/
├── __init__.py      343   blueprint, инъекция виджета, пункт меню
├── tools.py         610   семь инструментов агента
├── llm.py           317   OpenAI-совместимый клиент и цикл агента
├── settings.py      166   настройки с кешем по mtime
├── static/
│   ├── widget.js    792   кнопка, панель, темы, CSRF
│   └── widget.css   446   29 CSS-переменных, светлая и тёмная темы
└── templates/superset_ai/panel.html   158

Плюс в приложении‑установщике: модуль деплоя по SSH (905 строк), вкладка настроек (635) и 49 ключей локализации в каждом из семи языков.

Что изменяется в самом Superset: один блок в конце superset_config.py — 16 строк кода. Ни одного файла Superset не тронуто, фронтенд не пересобран, CSP не ослаблен.

Полезное на вынос

Несколько вещей из этой работы применимы к любому расширению Superset, а не только к AI‑агенту:

1. FLASK_APP_MUTATOR вызывается после configure_fab() и до init_views(). Значит app.appbuilder доступен, а маршруты можно регистрировать.

2. flask-talisman кладёт CSP‑nonce в flask.request.csp_nonce (before_request) и регистрирует jinja‑global csp_nonce(). При strict-dynamic разрешения по источникам не работают — нужен только nonce.

3. Внедряя что‑то в HTML через after_request, не забудьте убрать Content-Length и отфильтровать direct_passthrough и не‑HTML ответы.

4. appbuilder.add_link создаёт разрешение, но не выдаёт его. Нужен superset init.

5. CSRF‑токен Superset доступен на /api/v1/security/csrf_token/ и принимается в заголовке X-CSRFToken.

6. Для reasoning‑моделей max_tokens должен быть с запасом — токены уходят на рассуждения раньше, чем на вызов инструмента.

Если будете делать что‑то похожее — самое ценное здесь не код, а порядок: сначала выяснить, какие точки расширения даёт платформа, и только потом решать, что писать. Соблазн «пропатчить и забыть» силён, но за него платят при каждом обновлении.

Если тема интеграции AI в Apache Superset вам интересна, присоединяйтесь к моему Telegram‑каналу [8]. Там я публикую обновления Superset Installer, новые эксперименты, видео и практические материалы по Superset и анализу данных.

Автор: golubnichiy

Источник [9]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/33951

URLs in this post:

[1] видео на YouTube: https://youtu.be/zf3-mppsvRE

[2] config.py: http://config.py

[3] внимание: http://www.braintools.ru/article/7595

[4] ошибки: http://www.braintools.ru/article/4192

[5] повторяем: http://www.braintools.ru/article/4012

[6] https://api.llm7.io/v1/models: https://api.llm7.io/v1/models%60

[7] Логика: http://www.braintools.ru/article/7640

[8] Telegram‑каналу: https://t.me/apache_superset_russia

[9] Источник: https://habr.com/ru/articles/1066586/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1066586

www.BrainTools.ru

Rambler's Top100