Как я встроил AI‑агента прямо в интерфейс Apache Superset и не сломал ему CSP. ai.. ai. apache.. ai. apache. ApacheSuperset.. ai. apache. ApacheSuperset. Big Data.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm. mcp.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm. mcp. opensource.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm. mcp. opensource. python.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm. mcp. opensource. python. SQL.. ai. apache. ApacheSuperset. Big Data. BusinessIntelligence. Flask. llm. mcp. opensource. python. SQL. Superset.

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

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

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

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

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

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

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 дописывается один блок между маркерами — 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 <<<

Обратите внимание на 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. Три уровня защиты вместо одного, потому что стоимость ошибки здесь высокая.

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

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, повторяем. Максимум 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. Ответ оказался богаче, чем ожидал: у каждой модели есть флаг 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

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

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

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‑каналу. Там я публикую обновления Superset Installer, новые эксперименты, видео и практические материалы по Superset и анализу данных.

Автор: golubnichiy

Источник