- BrainTools - https://www.braintools.ru -
Есть 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‑приложения.
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, база метаданных — всё остаётся как было.
Кнопку надо как‑то показать. 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, так что деградация получается частичной, а не полной.
Здесь надо честно разделить понятия, потому что меня об этом справедливо спросили уже после того, как всё заработало.
В Superset 6.x есть встроенный MCP‑сервер: команда superset mcp run поднимает отдельный процесс на порту 5008, к которому подключаются внешние клиенты вроде Claude Desktop или Cursor. Это настоящий MCP по протоколу.
Мой агент — не он. Общее у них — принцип работы: модель получает список инструментов со схемами, сама решает, что вызвать, получает результат и продолжает рассуждение. Различие в том, что мои инструменты выполняются прямо внутри процесса Superset, а не по сети через MCP‑протокол.
Почему я выбрал такой путь, хотя проксировать к встроенному MCP было бы меньше кода:
|
|
Мой агент |
Встроенный MCP Superset |
|
Где живёт |
внутри gunicorn |
тдельный процесс, порт 5008 |
|
Клиент |
кнопка в браузере |
внешний клиент |
|
Права |
текущего пользователя Superset |
от |
Последняя строка и решила дело. 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 между версиями переезжают. Чтобы расширение не привязывалось к одной версии, обращения написаны с перебором вариантов:
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, к остальным версиям код подготовлен, но не проверен.
Дать модели выполнять 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, откат без инструментов, ошибка инструмента, сетевая ошибка, упор в лимит итераций. Все ведут себя как задумано.
Когда всё было готово, я пошёл смотреть, какие модели отдаёт 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: список моделей провайдера, где бесплатные идут первыми, плюс пояснение под полем.
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
Нажмите здесь для печати.