From 6fcf8c9b43974537827db0c17459a07845609698 Mon Sep 17 00:00:00 2001 From: yym Date: Sun, 26 Apr 2026 23:13:33 +0800 Subject: [PATCH 1/7] feat: Add SQLite and Hive database support with sample data for AI - Add SQLite support to data sources (file-based connection) - Add Hive support to data sources - Add SSL toggle option for MySQL and Doris databases - Add sample data (3 rows JSON format) to help AI better understand table schema - Fix SQLite schema table name issue (empty schema prefix) - Add SQLite.yaml template for SQL generation - Add Hive to haveSchema list in frontend Co-Authored-By: Claude Opus 4.6 --- backend/apps/chat/models/chat_model.py | 3 +- backend/apps/chat/task/llm.py | 16 ++- backend/apps/datasource/crud/datasource.py | 90 ++++++++++++++-- backend/apps/db/constant.py | 2 + backend/apps/db/db.py | 120 +++++++++++++++++++-- backend/apps/db/db_sql.py | 19 ++++ backend/templates/sql_examples/SQLite.yaml | 81 ++++++++++++++ backend/templates/template.yaml | 3 + frontend/src/views/ds/DatasourceForm.vue | 20 +++- frontend/src/views/ds/js/ds-type.ts | 8 +- 10 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 backend/templates/sql_examples/SQLite.yaml diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index 66ef2060d..b0a6c2c73 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -229,6 +229,7 @@ class AiModelQuestion(BaseModel): custom_prompt: str = "" error_msg: str = "" regenerate_record_id: Optional[int] = None + sample_data: str = "" def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = True): templates: dict[str, str] = {} @@ -256,7 +257,7 @@ def sql_sys_question(self, db_type: Union[str, DB], enable_query_limit: bool = T example_answer_1=_example_answer_1, example_answer_2=_example_answer_2, example_answer_3=_example_answer_3) - templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema) + templates['schema'] = _base_template['generate_basic_info'].format(engine=self.engine, schema=self.db_schema, sample_data=self.sample_data) if self.terminologies: templates['terminologies'] = _base_template['generate_terminologies_info'].format( diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 006b8bb9a..7953b5e3a 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -36,7 +36,7 @@ from apps.chat.models.chat_model import ChatQuestion, ChatRecord, Chat, RenameChat, ChatLog, OperationEnum, \ ChatFinishStep, AxisObj, SystemPromptMessage, HumanPromptMessage, AIPromptMessage from apps.data_training.curd.data_training import get_training_template -from apps.datasource.crud.datasource import get_table_schema +from apps.datasource.crud.datasource import get_table_schema, get_tables_sample_data from apps.datasource.crud.permission import get_row_permission_filters, is_normal_user from apps.datasource.embedding.ds_embedding import get_ds_embedding from apps.datasource.models.datasource import CoreDatasource @@ -384,6 +384,13 @@ def choose_table_schema(self, _session: Session): ds=self.ds, question=self.chat_question.question) + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds) + self.current_logs[OperationEnum.CHOOSE_TABLE] = end_log(session=_session, log=self.current_logs[OperationEnum.CHOOSE_TABLE], full_message=self.chat_question.db_schema) @@ -505,6 +512,13 @@ def generate_recommend_questions_task(self, _session: Session): question=self.chat_question.question, embedding=False) + # Get sample data for all tables + if not self.out_ds_instance: + self.chat_question.sample_data = get_tables_sample_data( + session=_session, + current_user=self.current_user, + ds=self.ds) + guess_msg: List[Union[BaseMessage, dict[str, Any]]] = [] guess_msg.append(SystemPromptMessage(content=self.chat_question.guess_sys_question(self.articles_number))) diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 5a4cc6223..fc7afb7cf 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -17,7 +17,7 @@ from common.core.config import settings from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.embedding_threads import run_save_table_embeddings, run_save_ds_embeddings -from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra +from common.utils.utils import SQLBotLogUtil, deepcopy_ignore_extra, equals_ignore_case from common.core.sqlbot_cache import cache, clear_cache from .table import get_tables_by_ds_id from ..crud.field import delete_field_by_ds_id, update_field @@ -357,12 +357,16 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table {where} LIMIT 100""" elif ds.type == "dm": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{conf.dbSchema}"."{data.table.table_name}" + {where} LIMIT 100""" elif ds.type == "es": - sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" - {where} + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" + {where} + LIMIT 100""" + elif ds.type == "sqlite": + sql = f"""SELECT "{'", "'.join(fields)}" FROM "{data.table.table_name}" + {where} LIMIT 100""" return exec_sql(ds, sql, True) @@ -430,6 +434,79 @@ def get_table_obj_by_ds(session: SessionDep, current_user: CurrentUser, ds: Core return _list +def get_table_sample_data(ds: CoreDatasource, table_name: str, fields: list) -> str: + """Get 3 sample rows from a table in JSON format to help AI understand the data""" + if not fields: + return "" + + db = DB.get_db(ds.type) + # Get prefix/suffix for identifier quoting + prefix = db.prefix if hasattr(db, 'prefix') else '"' + suffix = db.suffix if hasattr(db, 'suffix') else '"' + + # Build field list with proper quoting + field_names = [] + for field in fields[:10]: # Limit to first 10 fields to avoid too wide results + field_name = f"{prefix}{field.field_name}{suffix}" + field_names.append(field_name) + + # Build LIMIT query based on database type + if equals_ignore_case(ds.type, "sqlServer"): + query = f"SELECT TOP 3 {','.join(field_names)} FROM {prefix}{table_name}{suffix}" + elif equals_ignore_case(ds.type, "ck"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "hive"): + query = f"SELECT {','.join(field_names)} FROM {table_name} LIMIT 3" + elif equals_ignore_case(ds.type, "oracle"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + elif equals_ignore_case(ds.type, "dm"): + query = f"SELECT {','.join(field_names)} FROM \"{table_name}\" WHERE ROWNUM <= 3" + else: + query = f"SELECT {','.join(field_names)} FROM {prefix}{table_name}{suffix} LIMIT 3" + + try: + result = exec_sql(ds=ds, sql=query, origin_column=True) + if result and result.get('data') and len(result['data']) > 0: + import json + # Truncate long string values for readability + json_rows = [] + for row in result['data'][:3]: + truncated_row = {} + for key, value in row.items(): + if value is None: + truncated_row[key] = None + elif isinstance(value, str): + # Truncate long strings + if len(value) > 100: + value = value[:100] + '...' + truncated_row[key] = value.replace('\n', ' ').replace('\r', ' ') + else: + truncated_row[key] = value + json_rows.append(truncated_row) + return json.dumps(json_rows, ensure_ascii=False, indent=2) + except Exception: + pass + return "" + + +def get_tables_sample_data(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, + table_list: list[str] = None) -> str: + """Get sample data (3 rows) for all tables to help AI understand the data""" + table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) + if len(table_objs) == 0: + return "" + + sample_data_parts = [] + for obj in table_objs: + if table_list is not None and obj.table.table_name not in table_list: + continue + if obj.fields: + sample = get_table_sample_data(ds, obj.table.table_name, obj.fields) + if sample: + sample_data_parts.append(f"# Table: {obj.table.table_name}\n{sample}") + return "\n".join(sample_data_parts) + + def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource, question: str, embedding: bool = True, table_list: list[str] = None) -> str: schema_str = "" @@ -446,7 +523,8 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat continue schema_table = '' - schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type != "mysql" and ds.type != "es" else f"# Table: {obj.table.table_name}" + no_schema_types = ["mysql", "es", "sqlite", "hive", "doris", "starrocks"] + schema_table += f"# Table: {db_name}.{obj.table.table_name}" if ds.type not in no_schema_types and db_name else f"# Table: {obj.table.table_name}" table_comment = '' if obj.table.custom_comment: table_comment = obj.table.custom_comment.strip() diff --git a/backend/apps/db/constant.py b/backend/apps/db/constant.py index 1509fcf47..46c8a1df5 100644 --- a/backend/apps/db/constant.py +++ b/backend/apps/db/constant.py @@ -28,6 +28,8 @@ class DB(Enum): oracle = ('oracle', 'Oracle', '"', '"', ConnectType.sqlalchemy, 'Oracle', []) pg = ('pg', 'PostgreSQL', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) starrocks = ('starrocks', 'StarRocks', '`', '`', ConnectType.py_driver, 'StarRocks', []) + sqlite = ('sqlite', 'SQLite', '"', '"', ConnectType.sqlalchemy, 'SQLite', []) + hive = ('hive', 'Apache Hive', '"', '"', ConnectType.py_driver, 'Hive', []) def __init__(self, type, db_name, prefix, suffix, connect_type: ConnectType, template_name: str, illegalParams: List[str]): diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index bbf9e97e1..d92360724 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -36,6 +36,12 @@ from sqlglot import expressions as exp from sqlalchemy.pool import NullPool +try: + from pyhive import hive + PYHIVE_AVAILABLE = True +except ImportError: + PYHIVE_AVAILABLE = False + try: if os.path.exists(settings.ORACLE_CLIENT_PATH): oracledb.init_oracle_client( @@ -88,6 +94,8 @@ def get_uri_from_config(type: str, conf: DatasourceConf) -> str: db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}?{conf.extraJdbc}" else: db_url = f"clickhouse+http://{urllib.parse.quote(conf.username)}:{urllib.parse.quote(conf.password)}@{conf.host}:{conf.port}/{conf.database}" + elif equals_ignore_case(type, "sqlite"): + db_url = f"sqlite:///{conf.filename}" else: raise 'The datasource type not support.' return db_url @@ -157,6 +165,8 @@ def get_engine(ds: CoreDatasource, timeout: int = 0) -> Engine: elif equals_ignore_case(ds.type, 'mysql'): # mysql ssl_mode = {"require": True} if conf.ssl else None engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout, "ssl": ssl_mode}, poolclass=NullPool) + elif equals_ignore_case(ds.type, 'sqlite'): + engine = create_engine(get_uri(ds), connect_args={"check_same_thread": False}, poolclass=NullPool) else: # ck engine = create_engine(get_uri(ds), connect_args={"connect_timeout": conf.timeout}, poolclass=NullPool) return engine @@ -207,9 +217,10 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: try: cursor.execute('select 1') SQLBotLogUtil.info("success") @@ -247,6 +258,26 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs if is_raise: raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False + elif equals_ignore_case(ds.type, 'hive'): + if PYHIVE_AVAILABLE: + try: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute('select 1') + cursor.fetchall() + cursor.close() + conn.close() + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') + return False + else: + SQLBotLogUtil.error("pyhive not installed") + return False elif equals_ignore_case(ds.type, 'es'): es_conn = get_es_connect(conf) if es_conn.ping(): @@ -289,6 +320,8 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): # conf.timeout = 10 db = DB.get_db(ds.type) sql = get_version_sql(ds, conf) + if equals_ignore_case(ds.type, 'sqlite'): + return '' try: if db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: @@ -304,13 +337,14 @@ def get_version(ds: CoreDatasource | AssistantOutDsSchema): res = cursor.fetchall() version = res[0][0] elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=10, - read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=10, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql) res = cursor.fetchall() version = res[0][0] - elif equals_ignore_case(ds.type, 'redshift', 'es'): + elif equals_ignore_case(ds.type, 'redshift', 'es', 'hive'): version = '' except Exception as e: print(e) @@ -333,6 +367,8 @@ def get_schema(ds: CoreDatasource): elif equals_ignore_case(ds.type, "oracle"): sql = """select * from all_users""" + elif equals_ignore_case(ds.type, "sqlite"): + return ['main'] with session.execute(text(sql)) as result: res = result.fetchall() res_list = [item[0] for item in res] @@ -374,7 +410,16 @@ def get_tables(ds: CoreDatasource): "excel") else get_engine_config() db = DB.get_db(ds.type) sql, sql_param = get_table_sql(ds, conf, get_version(ds)) - if db.connect_type == ConnectType.sqlalchemy: + if equals_ignore_case(ds.type, "sqlite"): + engine = get_engine(ds) + with engine.raw_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + cursor.close() + res_list = [TableSchema(*item) for item in res] + return res_list + elif db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: with session.execute(text(sql), {"param": sql_param}) as result: res = result.fetchall() @@ -390,9 +435,10 @@ def get_tables(ds: CoreDatasource): res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql, (sql_param,)) res = cursor.fetchall() res_list = [TableSchema(*item) for item in res] @@ -418,6 +464,18 @@ def get_tables(ds: CoreDatasource): res = get_es_index(conf) res_list = [TableSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + if PYHIVE_AVAILABLE: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list + return [] def get_fields(ds: CoreDatasource, table_name: str = None): @@ -425,7 +483,16 @@ def get_fields(ds: CoreDatasource, table_name: str = None): "excel") else get_engine_config() db = DB.get_db(ds.type) sql, p1, p2 = get_field_sql(ds, conf, table_name) - if db.connect_type == ConnectType.sqlalchemy: + if equals_ignore_case(ds.type, "sqlite"): + engine = get_engine(ds) + with engine.raw_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + cursor.close() + res_list = [ColumnSchema(item[1], item[2], '') for item in res] + return res_list + elif db.connect_type == ConnectType.sqlalchemy: with get_session(ds) as session: with session.execute(text(sql), {"param1": p1, "param2": p2}) as result: res = result.fetchall() @@ -441,9 +508,10 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: cursor.execute(sql, (p1, p2)) res = cursor.fetchall() res_list = [ColumnSchema(*item) for item in res] @@ -469,6 +537,18 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res = get_es_fields(conf, table_name) res_list = [ColumnSchema(*item) for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + if PYHIVE_AVAILABLE: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list + return [] def convert_value(value, datetime_format='space'): @@ -587,9 +667,10 @@ def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column= except Exception as ex: raise ParseSQLResultError(str(ex)) elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + ssl_args = {'ssl': {'ssl_mode': 'REQUIRE'}} if conf.ssl else {} with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, port=conf.port, db=conf.database, connect_timeout=conf.timeout, - read_timeout=conf.timeout, **extra_config_dict) as conn, conn.cursor() as cursor: + read_timeout=conf.timeout, **extra_config_dict, **ssl_args) as conn, conn.cursor() as cursor: try: cursor.execute(sql) res = cursor.fetchall() @@ -655,6 +736,29 @@ def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column= "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} except Exception as ex: raise Exception(str(ex)) + elif equals_ignore_case(ds.type, 'hive'): + if PYHIVE_AVAILABLE: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + try: + cursor.execute(sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, + "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + finally: + cursor.close() + conn.close() + raise Exception("pyhive not installed") def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): diff --git a/backend/apps/db/db_sql.py b/backend/apps/db/db_sql.py index 566fbeb03..d4c3c74f8 100644 --- a/backend/apps/db/db_sql.py +++ b/backend/apps/db/db_sql.py @@ -31,6 +31,8 @@ def get_version_sql(ds: CoreDatasource, conf: DatasourceConf): """ elif equals_ignore_case(ds.type, "redshift"): return '' + elif equals_ignore_case(ds.type, "sqlite"): + return '' def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = ''): @@ -162,6 +164,17 @@ def get_table_sql(ds: CoreDatasource, conf: DatasourceConf, db_version: str = '' """, conf.dbSchema elif equals_ignore_case(ds.type, "es"): return "", None + elif equals_ignore_case(ds.type, "sqlite"): + return """ + SELECT name AS TABLE_NAME, '' + FROM sqlite_master + WHERE type='table' + ORDER BY name + """, None + elif equals_ignore_case(ds.type, "hive"): + return """ + SHOW TABLES + """, None def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = None): @@ -312,3 +325,9 @@ def get_field_sql(ds: CoreDatasource, conf: DatasourceConf, table_name: str = No return sql1 + sql2, conf.dbSchema, table_name elif equals_ignore_case(ds.type, "es"): return "", None, None + elif equals_ignore_case(ds.type, "sqlite"): + sql1 = f"PRAGMA table_info({table_name})" + return sql1, None, None + elif equals_ignore_case(ds.type, "hive"): + sql1 = f"DESCRIBE {table_name}" + return sql1, None, None diff --git a/backend/templates/sql_examples/SQLite.yaml b/backend/templates/sql_examples/SQLite.yaml new file mode 100644 index 000000000..bfcaacc94 --- /dev/null +++ b/backend/templates/sql_examples/SQLite.yaml @@ -0,0 +1,81 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加双引号(")。 + + 1. 点号(.)不能包含在引号内,必须写成 "table" + 2. 即使标识符不含特殊字符或非关键字,也需强制加双引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 SQLite 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 双引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ORDERS 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ORDERS LIMIT 100 -- 错误:未加引号、使用星号 + SELECT "订单ID", "金额" FROM "ORDERS" "t1" LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT("订单ID") FROM "ORDERS" "t1" -- 错误:函数未加别名 + + + SELECT + "t1"."订单ID" AS "order_id", + "t1"."金额" AS "amount", + COUNT("t1"."订单ID") AS "total_orders", + ROUND("t1"."折扣率" * 100, 2) || '%' AS "discount_percent" + FROM "ORDERS" "t1" + LIMIT 100 + + + + + 统计用户表 USERS(含关键字字段user)的活跃占比 + + SELECT user, status FROM USERS -- 错误:未处理关键字和引号 + SELECT "user", ROUND(active_ratio) FROM "USERS" -- 错误:百分比格式错误 + + + SELECT + "u"."user" AS "username", + ROUND("u"."active_ratio" * 100, 2) || '%' AS "active_percent" + FROM "USERS" "u" + WHERE "u"."status" = 1 + + + + + example_engine: SQLite 3.x + example_answer_1: | + {"success":true,"sql":"SELECT \"country_name\", \"continent_name\", \"year\", \"gdp\" FROM \"sample_country_gdp\" ORDER BY \"country_name\", \"year\"","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"continent_name\", \"year\", \"gdp\" FROM \"sample_country_gdp\" ORDER BY \"country_name\", \"year\" LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2024' ORDER BY \"gdp\" DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country_name\" = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT \"country_name\", \"gdp\" FROM \"sample_country_gdp\" WHERE \"year\" = '2025' AND \"country_name\" = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml index a447c8287..0179c4605 100644 --- a/backend/templates/template.yaml +++ b/backend/templates/template.yaml @@ -348,6 +348,9 @@ template: {schema} + + {sample_data} + user: | diff --git a/frontend/src/views/ds/DatasourceForm.vue b/frontend/src/views/ds/DatasourceForm.vue index 240692d13..892824006 100644 --- a/frontend/src/views/ds/DatasourceForm.vue +++ b/frontend/src/views/ds/DatasourceForm.vue @@ -99,6 +99,13 @@ const rules = reactive({ trigger: 'blur', }, ], + filename: [ + { + required: true, + message: t('datasource.please_enter') + t('common.empty') + t('ds.form.file_path'), + trigger: 'blur', + }, + ], }) const dialogVisible = ref(false) @@ -647,7 +654,16 @@ defineExpose({ {{ $t('common.not_exceed_50mb') }} -
+
+ + + +
+
- + diff --git a/frontend/src/views/ds/js/ds-type.ts b/frontend/src/views/ds/js/ds-type.ts index 800fdefd4..4de66d5cb 100644 --- a/frontend/src/views/ds/js/ds-type.ts +++ b/frontend/src/views/ds/js/ds-type.ts @@ -10,6 +10,8 @@ import redshift from '@/assets/datasource/icon_redshift.png' import es from '@/assets/datasource/icon_es.png' import kingbase from '@/assets/datasource/icon_kingbase.png' import starrocks from '@/assets/datasource/icon_starrocks.png' +import sqlite_icon from '@/assets/datasource/icon_starrocks.png' +import hive_icon from '@/assets/datasource/icon_starrocks.png' import { i18n } from '@/i18n' const t = i18n.global.t @@ -26,6 +28,8 @@ export const dsType = [ { label: 'Elasticsearch', value: 'es' }, { label: 'Kingbase', value: 'kingbase' }, { label: 'StarRocks', value: 'starrocks' }, + { label: 'SQLite', value: 'sqlite' }, + { label: 'Apache Hive', value: 'hive' }, ] export const dsTypeWithImg = [ @@ -41,6 +45,8 @@ export const dsTypeWithImg = [ { name: 'Elasticsearch', type: 'es', img: es }, { name: 'Kingbase', type: 'kingbase', img: kingbase }, { name: 'StarRocks', type: 'starrocks', img: starrocks }, + { name: 'SQLite', type: 'sqlite', img: sqlite_icon }, + { name: 'Apache Hive', type: 'hive', img: hive_icon }, ] -export const haveSchema = ['sqlServer', 'pg', 'oracle', 'dm', 'redshift', 'kingbase'] +export const haveSchema = ['sqlServer', 'pg', 'oracle', 'dm', 'redshift', 'kingbase', 'hive'] From 4aca03a1f4b5fecdd417357e05803c69320e245f Mon Sep 17 00:00:00 2001 From: yym Date: Tue, 28 Apr 2026 21:27:01 +0800 Subject: [PATCH 2/7] feat: Add SQLite and Hive icons for datasource type selection Co-Authored-By: Claude Opus 4.6 --- frontend/src/assets/datasource/icon_hive.png | Bin 0 -> 1604 bytes frontend/src/assets/datasource/icon_sqlite.png | Bin 0 -> 10360 bytes frontend/src/views/ds/js/ds-type.ts | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/datasource/icon_hive.png create mode 100644 frontend/src/assets/datasource/icon_sqlite.png diff --git a/frontend/src/assets/datasource/icon_hive.png b/frontend/src/assets/datasource/icon_hive.png new file mode 100644 index 0000000000000000000000000000000000000000..0fdebd0da7d858efb95a162ae76a9de7f41b0db3 GIT binary patch literal 1604 zcmaKs`#02i7{@=~nQ;vxgvPDNZHZ~EOYFGL6g3=!F0}n(%Jt@T~%3oyy^W)!!Z1VBGi?z)ML&odyON3R0aTO{{Mph&XWZx!_AqbgPGxJ zahX~4j98GBmF1N9dom-Mek9f@EhD~C{xc2$mF^&-Uj(yCCfR%5lmvRe^ zvup&7LHKKV4MY+JD{|xRP8qw7~~@);!lVl$S-pC)(>DCP~-2i(XD%llKj}OJ3}Am29#6KDCwcR1rpC z?D3GaIX}Q`96N_vA8=q<-%%+~5{6zLJWht9zyaD93|?EPb_lyPp}8k>HP?iV#m|2^ zCq?zZI}mxsto0^=S~`oyKONG9hsV@7& zqG+Z0qw7tbmLQ+%!Et%_&Zg;Cs1`4v&{m*Q(32?h+L6l9N8viNr(ELAWOsD9;$!Lu zN0t^3|B`e1q^H?IjN_9_T#1ShC@yV|p;E&L!VBBM)0}tRdy>*`m?Qgi*ii=euN5ts3ol>+3ePm3(Hr zd)EeGVWi2lR+Nc$=SIOqE6e&NG`^SEK(qJSIhiLgWA1EV)wF1HL~0&DafPqYYZbo{ zfoefTjwMGM>>jvzYWs7O5Z>&Go*UUw> zhUK*K-GIZU6F|=?8&aVeD)UyaEDc|3JaCHsHez~C8Ccd4;9EMe*SbW8O(B}T)B-r% zv`>&J#cW*y0&`Svda~W%3rpk>Cxe78s`f!L&Zk$DzTLJ2wv{*+zbf6K9)UK8WocL4 z83Jm}%!7jV^}=CJp8j-sq4`8Zi$og^hARY5mu{igG;jM5?h%ksQ3BkgT)Z0IX_5_I zNkHaHne9Img? z(+^@UK5cuxxTD`QvI1IvLYClqUguBO7|yg8O(p%y?;UCqv-($>dKYA{3A}c% zwqSbJlA>2;=d5R4Z4Z78fj(qt`j$1)f2rsQIJitzH*KQVhqdqoKZc3+{Zv1a_PDBl z7bDi8;_hz)!8fZ_7>OksWcT_^jyO=_rZW(XvEAXmsdVs|v$Rvx_0`A(Z**W>>vdqv znDEL~L*P#Dm~F@vr>Z!MFS&h`j$B_3RBawDI{eU8ahU8tX}^3q%FN|ig3(K(3Ehp~ cr5OH(9hYnG7Sa4}DnC95B83od`_oSU2di1khyVZp literal 0 HcmV?d00001 diff --git a/frontend/src/assets/datasource/icon_sqlite.png b/frontend/src/assets/datasource/icon_sqlite.png new file mode 100644 index 0000000000000000000000000000000000000000..e41576dd7e2663e940788e443b9035469d7c0b92 GIT binary patch literal 10360 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%0D1q#yv^TUBEZkjk9V-T*QpE# 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 literal 0 HcmV?d00001 diff --git a/frontend/src/views/ds/js/ds-type.ts b/frontend/src/views/ds/js/ds-type.ts index 4de66d5cb..cca0982e4 100644 --- a/frontend/src/views/ds/js/ds-type.ts +++ b/frontend/src/views/ds/js/ds-type.ts @@ -10,8 +10,8 @@ import redshift from '@/assets/datasource/icon_redshift.png' import es from '@/assets/datasource/icon_es.png' import kingbase from '@/assets/datasource/icon_kingbase.png' import starrocks from '@/assets/datasource/icon_starrocks.png' -import sqlite_icon from '@/assets/datasource/icon_starrocks.png' -import hive_icon from '@/assets/datasource/icon_starrocks.png' +import sqlite_icon from '@/assets/datasource/icon_sqlite.png' +import hive_icon from '@/assets/datasource/icon_hive.png' import { i18n } from '@/i18n' const t = i18n.global.t From 018daff48f794e466f2a5ac35155774d7b038fee Mon Sep 17 00:00:00 2001 From: yym Date: Wed, 29 Apr 2026 01:24:13 +0800 Subject: [PATCH 3/7] fix(Hive): normalize identifier quoting and stabilize metadata/query paths Use Hive-compatible identifier handling and template defaults so generated SQL returns real column values, while also fixing table schema parsing and adding required Hive runtime dependencies. Made-with: Cursor --- backend/apps/datasource/models/datasource.py | 2 +- backend/apps/db/constant.py | 2 +- backend/apps/db/db.py | 168 +++++++++++-------- backend/pyproject.toml | 4 +- backend/templates/sql_examples/Hive.yaml | 86 ++++++++++ 5 files changed, 190 insertions(+), 72 deletions(-) create mode 100644 backend/templates/sql_examples/Hive.yaml diff --git a/backend/apps/datasource/models/datasource.py b/backend/apps/datasource/models/datasource.py index 6a23e0b7f..3971318cf 100644 --- a/backend/apps/datasource/models/datasource.py +++ b/backend/apps/datasource/models/datasource.py @@ -143,7 +143,7 @@ def to_dict(self): class TableSchema: - def __init__(self, attr1, attr2): + def __init__(self, attr1, attr2=None): self.tableName = attr1 self.tableComment = attr2 if attr2 is None or isinstance(attr2, str) else attr2.decode("utf-8") diff --git a/backend/apps/db/constant.py b/backend/apps/db/constant.py index 46c8a1df5..6ee33f02f 100644 --- a/backend/apps/db/constant.py +++ b/backend/apps/db/constant.py @@ -29,7 +29,7 @@ class DB(Enum): pg = ('pg', 'PostgreSQL', '"', '"', ConnectType.sqlalchemy, 'PostgreSQL', []) starrocks = ('starrocks', 'StarRocks', '`', '`', ConnectType.py_driver, 'StarRocks', []) sqlite = ('sqlite', 'SQLite', '"', '"', ConnectType.sqlalchemy, 'SQLite', []) - hive = ('hive', 'Apache Hive', '"', '"', ConnectType.py_driver, 'Hive', []) + hive = ('hive', 'Apache Hive', '`', '`', ConnectType.py_driver, 'Hive', []) def __init__(self, type, db_name, prefix, suffix, connect_type: ConnectType, template_name: str, illegalParams: List[str]): diff --git a/backend/apps/db/db.py b/backend/apps/db/db.py index d92360724..99e5911b7 100644 --- a/backend/apps/db/db.py +++ b/backend/apps/db/db.py @@ -2,6 +2,7 @@ import json import os import platform +import re import urllib.parse from datetime import datetime, date, time, timedelta from decimal import Decimal @@ -35,12 +36,8 @@ import sqlglot from sqlglot import expressions as exp from sqlalchemy.pool import NullPool +from pyhive import hive -try: - from pyhive import hive - PYHIVE_AVAILABLE = True -except ImportError: - PYHIVE_AVAILABLE = False try: if os.path.exists(settings.ORACLE_CLIENT_PATH): @@ -259,25 +256,22 @@ def check_connection(trans: Optional[Trans], ds: CoreDatasource | AssistantOutDs raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False elif equals_ignore_case(ds.type, 'hive'): - if PYHIVE_AVAILABLE: - try: - conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, - database=conf.database, **extra_config_dict) - cursor = conn.cursor() - cursor.execute('select 1') - cursor.fetchall() - cursor.close() - conn.close() - SQLBotLogUtil.info("success") - return True - except Exception as e: - SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") - if is_raise: - raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') - return False - else: - SQLBotLogUtil.error("pyhive not installed") + try: + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute('select 1') + cursor.fetchall() + cursor.close() + conn.close() + SQLBotLogUtil.info("success") + return True + except Exception as e: + SQLBotLogUtil.error(f"Datasource {ds.id} connection failed: {e}") + if is_raise: + raise HTTPException(status_code=500, detail=trans('i18n_ds_invalid') + f': {e.args}') return False + elif equals_ignore_case(ds.type, 'es'): es_conn = get_es_connect(conf) if es_conn.ping(): @@ -403,6 +397,30 @@ def get_schema(ds: CoreDatasource): res = cursor.fetchall() res_list = [item[0] for item in res] return res_list + elif equals_ignore_case(ds.type, 'hive'): + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute('SHOW DATABASES') + res = cursor.fetchall() + res_list = [item[0] for item in res] + cursor.close() + conn.close() + return res_list + elif equals_ignore_case(ds.type, 'doris', 'starrocks'): + with pymysql.connect(user=conf.username, passwd=conf.password, host=conf.host, + port=conf.port, db=conf.database, connect_timeout=10, + read_timeout=10, **extra_config_dict) as conn, conn.cursor() as cursor: + cursor.execute('SHOW DATABASES') + res = cursor.fetchall() + res_list = [item[0] for item in res] + return res_list + elif equals_ignore_case(ds.type, 'ck'): + with get_session(ds) as session: + with session.execute(text('SHOW DATABASES')) as result: + res = result.fetchall() + res_list = [item[0] for item in res] + return res_list def get_tables(ds: CoreDatasource): @@ -465,17 +483,15 @@ def get_tables(ds: CoreDatasource): res_list = [TableSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'hive'): - if PYHIVE_AVAILABLE: - conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, - database=conf.database, **extra_config_dict) - cursor = conn.cursor() - cursor.execute(sql) - res = cursor.fetchall() - res_list = [TableSchema(*item) for item in res] - cursor.close() - conn.close() - return res_list - return [] + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [TableSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list def get_fields(ds: CoreDatasource, table_name: str = None): @@ -538,17 +554,15 @@ def get_fields(ds: CoreDatasource, table_name: str = None): res_list = [ColumnSchema(*item) for item in res] return res_list elif equals_ignore_case(ds.type, 'hive'): - if PYHIVE_AVAILABLE: - conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, - database=conf.database, **extra_config_dict) - cursor = conn.cursor() - cursor.execute(sql) - res = cursor.fetchall() - res_list = [ColumnSchema(*item) for item in res] - cursor.close() - conn.close() - return res_list - return [] + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + cursor.execute(sql) + res = cursor.fetchall() + res_list = [ColumnSchema(*item) for item in res] + cursor.close() + conn.close() + return res_list def convert_value(value, datetime_format='space'): @@ -737,37 +751,53 @@ def exec_sql(ds: CoreDatasource | AssistantOutDsSchema, sql: str, origin_column= except Exception as ex: raise Exception(str(ex)) elif equals_ignore_case(ds.type, 'hive'): - if PYHIVE_AVAILABLE: - conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, - database=conf.database, **extra_config_dict) - cursor = conn.cursor() - try: - cursor.execute(sql) - res = cursor.fetchall() - columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for - field in - cursor.description] - result_list = [ - {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in - res - ] - return {"fields": columns, "data": result_list, - "sql": bytes.decode(base64.b64encode(bytes(sql, 'utf-8')))} - except Exception as ex: - raise ParseSQLResultError(str(ex)) - finally: - cursor.close() - conn.close() - raise Exception("pyhive not installed") + conn = hive.connect(host=conf.host, port=conf.port, username=conf.username, + database=conf.database, **extra_config_dict) + cursor = conn.cursor() + try: + # Hive uses backticks for identifiers; normalize quoted identifiers as a compatibility fallback. + hive_sql = re.sub(r'"([A-Za-z_][A-Za-z0-9_]*)"', r'`\1`', sql) + cursor.execute(hive_sql) + res = cursor.fetchall() + columns = [field[0] for field in cursor.description] if origin_column else [field[0].lower() for + field in + cursor.description] + result_list = [ + {str(columns[i]): convert_value(value) for i, value in enumerate(tuple_item)} for tuple_item in + res + ] + return {"fields": columns, "data": result_list, + "sql": bytes.decode(base64.b64encode(bytes(hive_sql, 'utf-8')))} + except Exception as ex: + raise ParseSQLResultError(str(ex)) + finally: + cursor.close() + conn.close() def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): try: + normalized_sql = sql.strip().lstrip("(").strip() + first_keyword = normalized_sql.split(None, 1)[0].upper() if normalized_sql else "" + allowed_read_commands = {"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN"} + denied_write_commands = { + "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", + "TRUNCATE", "MERGE", "COPY", "REPLACE", "GRANT", "REVOKE", + "USE", "SET", "CALL" + } + + if not first_keyword: + raise ValueError("Parse SQL Error") + if first_keyword in denied_write_commands: + return False + dialect = None if equals_ignore_case(ds.type, 'mysql', 'doris', 'starrocks'): dialect = 'mysql' elif equals_ignore_case(ds.type, 'sqlServer'): dialect = 'tsql' + elif equals_ignore_case(ds.type, 'hive'): + dialect = 'hive' statements = sqlglot.parse(sql, dialect=dialect) @@ -777,7 +807,7 @@ def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): write_types = ( exp.Insert, exp.Update, exp.Delete, exp.Create, exp.Drop, exp.Alter, - exp.Merge, exp.Command, exp.Copy + exp.Merge, exp.Copy ) for stmt in statements: @@ -786,7 +816,7 @@ def check_sql_read(sql: str, ds: CoreDatasource | AssistantOutDsSchema): if isinstance(stmt, write_types): return False - return True + return first_keyword in allowed_read_commands except Exception as e: raise ValueError(f"Parse SQL Error: {e}") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f112f7eca..727efde3b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,7 +53,9 @@ dependencies = [ "elasticsearch[requests] (>=7.10,<8.0)", "ldap3>=2.9.1", "sqlglot>=28.6.0", - "numpy==2.3.5" + "numpy==2.3.5", + "pyhive[hive]>=0.7.0", + "thrift-sasl" ] [project.optional-dependencies] diff --git a/backend/templates/sql_examples/Hive.yaml b/backend/templates/sql_examples/Hive.yaml new file mode 100644 index 000000000..d4ef6e0e2 --- /dev/null +++ b/backend/templates/sql_examples/Hive.yaml @@ -0,0 +1,86 @@ +template: + quot_rule: | + + 必须对数据库名、表名、字段名、别名外层加反引号(`)。 + + 1. 点号(.)不能包含在引号内,必须写成 `database`.`table` + 2. 即使标识符不含特殊字符或非关键字,也需强制加反引号 + + + + limit_rule: | + + 当需要限制行数时,必须使用标准的LIMIT语法 + + + other_rule: | + 必须为每个表生成别名(不加AS) + {multi_table_condition} + 禁止使用星号(*),必须明确字段名 + 中文/特殊字符字段需保留原名并添加英文别名 + 不能用 + 拼接字符串,字符串必须使用单引号 + 分组非常严格:SELECT 里的字段必须出现在 GROUP BY 里,或者是聚合函数 + 函数字段必须加别名 + 百分比字段保留两位小数并以%结尾 + WHERE 条件中不能使用 >、<、>=、<= 等比较运算符,必须使用 = + HIVE 中没有 NOT IN 操作符,必须使用 LEFT JOIN 或 EXISTS 替代 + 判空使用 NVL()函数 + 避免与数据库关键字冲突 + + basic_example: | + + + 📌 以下示例严格遵循中的 Hive 规范,展示符合要求的 SQL 写法与典型错误案例。 + ⚠️ 注意:示例中的表名、字段名均为演示虚构,实际使用时需替换为用户提供的真实标识符。 + 🔍 重点观察: + 1. 反引号包裹所有数据库对象的规范用法 + 2. 中英别名/百分比/函数等特殊字段的处理 + 3. 关键字冲突的规避方式 + + + 查询 ods.orders 表的前100条订单(含中文字段和百分比) + + SELECT * FROM ods.orders LIMIT 100 -- 错误:未加引号、使用星号 + SELECT `订单ID`, `金额` FROM `ods`.`orders` `t1` LIMIT 100 -- 错误:缺少英文别名 + SELECT COUNT(`订单ID`) FROM `ods`.`orders` `t1` -- 错误:函数未加别名 + + + SELECT + `t1`.`订单ID` AS `order_id`, + `t1`.`金额` AS `amount`, + COUNT(`t1`.`订单ID`) AS `total_orders`, + CONCAT(CAST(ROUND(`t1`.`折扣率` * 100, 2) AS STRING), '%') AS `discount_percent` + FROM `ods`.`orders` `t1` + LIMIT 100 + + + + + 统计 dim.users(含关键字字段user)的活跃占比 + + SELECT user, status FROM dim.users -- 错误:未处理关键字和引号 + SELECT `user`, ROUND(active_ratio) FROM `dim`.`users` -- 错误:百分比格式错误 + + + SELECT + `u`.`user` AS `username`, + CONCAT(CAST(ROUND(`u`.`active_ratio` * 100, 2) AS STRING), '%') AS `active_percent` + FROM `dim`.`users` `u` + WHERE `u`.`status` = 1 + + + + + example_engine: Apache Hive 2.X + example_answer_1: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year`","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_1_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `continent` AS `continent_name`, `year` AS `year`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` ORDER BY `country`, `year` LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"line"} + example_answer_2: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_2_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2024' ORDER BY `gdp` DESC LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"pie"} + example_answer_3: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国'","tables":["sample_country_gdp"],"chart-type":"table"} + example_answer_3_with_limit: | + {"success":true,"sql":"SELECT `country` AS `country_name`, `gdp` AS `gdp` FROM `Sample_Database`.`sample_country_gdp` WHERE `year` = '2025' AND `country` = '中国' LIMIT 1000","tables":["sample_country_gdp"],"chart-type":"table"} From 4b816e91732375fcff644c7fa76b0b3706c532a7 Mon Sep 17 00:00:00 2001 From: yym Date: Sun, 3 May 2026 09:01:00 +0800 Subject: [PATCH 4/7] feat/fix(charts): Normalize qualified SQL column names for display - Add DataFormat.normalize_qualified_sql_column_keys* so driver keys like "_u2.table_name" also expose "table_name", matching chart columns[].value. - Apply after SQL execution (LLM save path), format_json_list_data (API/history), and get_chart_data_ds (live refresh). Hive prompts: Document explicit table aliases for duplicate column names in JOINs. Frontend: Add datasource form i18n key "file_path" (en/zh-CN/zh-TW/ko-KR). Update Hive/SQLite datasource icons. Remove hive from haveSchema list. --- backend/apps/chat/curd/chat.py | 3 ++- backend/apps/chat/task/llm.py | 1 + backend/common/utils/data_format.py | 28 ++++++++++++++++++++++++ backend/templates/sql_examples/Hive.yaml | 1 + 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index 1f8befdf5..eaddc671e 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -211,7 +211,7 @@ def format_json_list_data(origin_data: list[dict]): if len(decimal_str) > 15: value = str(value) _row[key] = value - data.append(_row) + data.append(DataFormat.normalize_qualified_sql_column_keys(_row)) return data @@ -253,6 +253,7 @@ def get_chart_data_ds(session: SessionDep,ds_id,sql): else: result = exec_sql(ds=datasource,sql=sql, origin_column=False) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) json_result['data'] = _data return json_result except Exception as e: diff --git a/backend/apps/chat/task/llm.py b/backend/apps/chat/task/llm.py index 7953b5e3a..cf469b371 100644 --- a/backend/apps/chat/task/llm.py +++ b/backend/apps/chat/task/llm.py @@ -1318,6 +1318,7 @@ def run_task(self, in_chat: bool = True, stream: bool = True, 'count': len(result.get('data'))}) _data = DataFormat.convert_large_numbers_in_object_array(result.get('data')) + _data = DataFormat.normalize_qualified_sql_column_keys_in_object_array(_data) result["data"] = _data self.save_sql_data(session=_session, data_obj=result) diff --git a/backend/common/utils/data_format.py b/backend/common/utils/data_format.py index 1991fb3e4..bfa9e88b5 100644 --- a/backend/common/utils/data_format.py +++ b/backend/common/utils/data_format.py @@ -17,6 +17,34 @@ def safe_convert_to_string(df): return df_copy + @staticmethod + def normalize_qualified_sql_column_keys(row: dict) -> dict: + """Add unqualified keys for names like ``alias.column`` (Hive/MySQL return shape). + + Chart bindings use the bare column name (``table_name``) while drivers may return + ``_u2.table_name``. Only adds ``short`` when absent to avoid clobbering real duplicates. + """ + if not row: + return row + out = dict(row) + for k, v in row.items(): + ks = str(k) + if "." not in ks: + continue + short = ks.rsplit(".", 1)[-1] + if short not in out: + out[short] = v + return out + + @staticmethod + def normalize_qualified_sql_column_keys_in_object_array(obj_array: list) -> list: + if not obj_array: + return obj_array + return [ + DataFormat.normalize_qualified_sql_column_keys(obj) if isinstance(obj, dict) else obj + for obj in obj_array + ] + @staticmethod def convert_large_numbers_in_object_array(obj_array, int_threshold=1e15, float_threshold=1e10): """处理对象数组,将每个对象中的大数字转换为字符串""" diff --git a/backend/templates/sql_examples/Hive.yaml b/backend/templates/sql_examples/Hive.yaml index d4ef6e0e2..813f6ab50 100644 --- a/backend/templates/sql_examples/Hive.yaml +++ b/backend/templates/sql_examples/Hive.yaml @@ -5,6 +5,7 @@ template: 1. 点号(.)不能包含在引号内,必须写成 `database`.`table` 2. 即使标识符不含特殊字符或非关键字,也需强制加反引号 + 3. 在多表关联(JOIN)/ 多子查询的 SQL 中,只要多个表 / 子查询存在 同名字段,所有引用该字段的位置,必须显式指定 表别名 From 99482af465b22a64629341fcfa785a9e96821574 Mon Sep 17 00:00:00 2001 From: yym Date: Sun, 3 May 2026 09:05:58 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(chat):=20Normalize=20driver=20column=20?= =?UTF-8?q?keys=20(e.g.=20=5Fu2.col=20=E2=86=92=20col)=20for=20chart/table?= =?UTF-8?q?=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - common/utils/data_format.py: normalize_qualified_sql_column_keys helpers - apps/chat/curd/chat.py: format_json_list_data + get_chart_data_ds - apps/chat/task/llm.py: persist normalized rows after SQL exec - templates/sql_examples/Hive.yaml: JOIN / duplicate column alias note Frontend — assets: - src/assets/datasource/icon_hive.png - src/assets/datasource/icon_sqlite.png Frontend — i18n (datasource.form.file_path): - src/i18n/en.json - src/i18n/zh-CN.json - src/i18n/zh-TW.json - src/i18n/ko-KR.json Frontend — datasource types: - src/views/ds/js/ds-type.ts: haveSchema list (remove hive) --- frontend/src/assets/datasource/icon_hive.png | Bin 1604 -> 7646 bytes .../src/assets/datasource/icon_sqlite.png | Bin 10360 -> 1676 bytes frontend/src/i18n/en.json | 3 ++- frontend/src/i18n/ko-KR.json | 3 ++- frontend/src/i18n/zh-CN.json | 3 ++- frontend/src/i18n/zh-TW.json | 3 ++- frontend/src/views/ds/js/ds-type.ts | 2 +- 7 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/assets/datasource/icon_hive.png b/frontend/src/assets/datasource/icon_hive.png index 0fdebd0da7d858efb95a162ae76a9de7f41b0db3..51b996f5e52d6123a6ea1407e605efedeb4dd0a0 100644 GIT binary patch literal 7646 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 diff --git a/frontend/src/assets/datasource/icon_sqlite.png b/frontend/src/assets/datasource/icon_sqlite.png index e41576dd7e2663e940788e443b9035469d7c0b92..1a198d0b4e8209497164216a42530236bd741b66 100644 GIT binary patch literal 1676 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%0D1q#yv^TUBEZkjk9V-T*QpE# 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 diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index df8d29c06..4670a92eb 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -395,7 +395,8 @@ "timeout": "Connection Timeout(second)", "address": "Address", "low_version": "Compatible with lower versions", - "ssl": "Enable SSL" + "ssl": "Enable SSL", + "file_path": "File Path" }, "sync_fields": "Sync Fields" }, diff --git a/frontend/src/i18n/ko-KR.json b/frontend/src/i18n/ko-KR.json index bb269ca1f..133c2813e 100644 --- a/frontend/src/i18n/ko-KR.json +++ b/frontend/src/i18n/ko-KR.json @@ -395,7 +395,8 @@ "timeout": "연결 시간 초과(초)", "address": "주소", "low_version": "낮은 버전 호환", - "ssl": "SSL 활성화" + "ssl": "SSL 활성화", + "file_path": "파일 경로" }, "sync_fields": "동기화된 테이블 구조" }, diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index 902b06421..8040db821 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -395,7 +395,8 @@ "timeout": "连接超时(秒)", "address": "地址", "low_version": "兼容低版本", - "ssl": "启用 SSL" + "ssl": "启用 SSL", + "file_path": "文件路径" }, "sync_fields": "同步表结构" }, diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index bd2aa57cb..d8973c4b8 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -395,7 +395,8 @@ "timeout": "連線逾時(秒)", "address": "位址", "low_version": "相容低版本", - "ssl": "啟用 SSL" + "ssl": "啟用 SSL", + "file_path": "文件路徑" }, "sync_fields": "同步表結構" }, diff --git a/frontend/src/views/ds/js/ds-type.ts b/frontend/src/views/ds/js/ds-type.ts index cca0982e4..b2d578b74 100644 --- a/frontend/src/views/ds/js/ds-type.ts +++ b/frontend/src/views/ds/js/ds-type.ts @@ -49,4 +49,4 @@ export const dsTypeWithImg = [ { name: 'Apache Hive', type: 'hive', img: hive_icon }, ] -export const haveSchema = ['sqlServer', 'pg', 'oracle', 'dm', 'redshift', 'kingbase', 'hive'] +export const haveSchema = ['sqlServer', 'pg', 'oracle', 'dm', 'redshift', 'kingbase'] From c6f41d71892c71828f5f563851d67b025b57bc51 Mon Sep 17 00:00:00 2001 From: yym Date: Mon, 4 May 2026 02:16:30 +0800 Subject: [PATCH 6/7] feat(chat): full-page stream, scatter chart, layout and backend updates Run question streaming from the chat index on the full page so hot cards and first turns no longer depend on ChartAnswer ref timing. Extract shared runQuestionStream and wire abort with global stop. Add scatter chart support in the frontend chart registry, G2 SSR, and i18n. Extend layout DSL and menus, chat list UX, templates and Oracle examples, datasource CRUD/API, and chat utilities including popular question clustering. Co-authored-by: Cursor --- backend/apps/chat/api/chat.py | 16 +- backend/apps/chat/curd/chat.py | 54 ++ .../chat/utils/popular_questions_cluster.py | 129 +++++ backend/apps/datasource/api/datasource.py | 1 + backend/apps/datasource/crud/datasource.py | 4 +- backend/templates/sql_examples/Oracle.yaml | 2 +- backend/templates/template.yaml | 40 +- frontend/src/api/chat.ts | 7 + .../svg/chart/icon_scatter_outlined.svg | 6 + frontend/src/components/layout/LayoutDsl.vue | 172 +++++- frontend/src/components/layout/Menu.vue | 5 + frontend/src/components/layout/MenuItem.vue | 6 +- frontend/src/components/layout/Person.vue | 3 +- frontend/src/i18n/en.json | 7 +- frontend/src/i18n/ko-KR.json | 7 +- frontend/src/i18n/zh-CN.json | 7 +- frontend/src/i18n/zh-TW.json | 7 +- frontend/src/views/chat/ChatList.vue | 7 +- frontend/src/views/chat/ChatListContainer.vue | 77 +-- .../src/views/chat/answer/ChartAnswer.vue | 160 ++--- .../views/chat/answer/runQuestionStream.ts | 168 ++++++ .../src/views/chat/chat-block/ChartBlock.vue | 7 + .../src/views/chat/component/BaseChart.ts | 2 +- .../views/chat/component/charts/Scatter.ts | 132 +++++ frontend/src/views/chat/component/index.ts | 2 + frontend/src/views/chat/index.vue | 545 ++++++++++++++++-- .../dashboard/components/sq-view/index.vue | 7 + g2-ssr/app.js | 3 + g2-ssr/charts/scatter.js | 112 ++++ 29 files changed, 1420 insertions(+), 275 deletions(-) create mode 100644 backend/apps/chat/utils/popular_questions_cluster.py create mode 100644 frontend/src/assets/svg/chart/icon_scatter_outlined.svg create mode 100644 frontend/src/views/chat/answer/runQuestionStream.ts create mode 100644 frontend/src/views/chat/component/charts/Scatter.ts create mode 100644 g2-ssr/charts/scatter.js diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 0e6ff7ee7..f06a8e13e 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -5,7 +5,7 @@ import orjson import pandas as pd -from fastapi import APIRouter, HTTPException, Path +from fastapi import APIRouter, HTTPException, Path, Query from fastapi.responses import StreamingResponse from sqlalchemy import and_, select from starlette.responses import JSONResponse @@ -13,7 +13,8 @@ from apps.chat.curd.chat import delete_chat_with_user, get_chart_data_with_user, get_chat_predict_data_with_user, \ list_chats, get_chat_with_records, create_chat, rename_chat, \ delete_chat, get_chat_chart_data, get_chat_predict_data, get_chat_with_records_with_data, get_chat_record_by_id, \ - format_json_data, format_json_list_data, get_chart_config, list_recent_questions, get_chat as get_chat_exec, \ + format_json_data, format_json_list_data, get_chart_config, list_recent_questions, list_popular_questions, \ + get_chat as get_chat_exec, \ rename_chat_with_user, get_chat_log_history, get_chart_data_with_user_live from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, AxisObj, QuickCommand, \ ChatInfo, Chat, ChatFinishStep @@ -34,6 +35,17 @@ async def chats(session: SessionDep, current_user: CurrentUser): return list_chats(session, current_user) +@router.get("/popular_questions", summary=f"{PLACEHOLDER_PREFIX}popular_questions_workspace") +async def popular_questions( + session: SessionDep, current_user: CurrentUser, limit: int = Query(8, ge=1, le=50) +): + """工作空间内提问频次统计(排除首条占位记录)。""" + def inner(): + return list_popular_questions(session=session, current_user=current_user, limit=limit) + + return await asyncio.to_thread(inner) + + @router.get("/{chart_id}", response_model=ChatInfo, summary=f"{PLACEHOLDER_PREFIX}get_chat") async def get_chat(session: SessionDep, current_user: CurrentUser, chart_id: int, current_assistant: CurrentAssistant, trans: Trans): diff --git a/backend/apps/chat/curd/chat.py b/backend/apps/chat/curd/chat.py index eaddc671e..810c1bb6e 100644 --- a/backend/apps/chat/curd/chat.py +++ b/backend/apps/chat/curd/chat.py @@ -20,6 +20,8 @@ from common.utils.data_format import DataFormat from common.utils.utils import extract_nested_json, SQLBotLogUtil +from apps.chat.utils.popular_questions_cluster import cluster_questions_for_datasource + def get_chat_record_by_id(session: SessionDep, record_id: int): record: ChatRecord | None = None @@ -66,6 +68,58 @@ def list_recent_questions(session: SessionDep, current_user: CurrentUser, dataso return [record[0] for record in chat_records] if chat_records else [] +def list_popular_questions(session: SessionDep, current_user: CurrentUser, limit: int = 8) -> List[Dict[str, Any]]: + """按数据源 + 语义合并统计热门问题(同一数据源内相近问句合并,非纯字面 group_by)。""" + oid = current_user.oid if current_user.oid is not None else 1 + limit = min(max(limit, 1), 50) + cnt = func.count(ChatRecord.id).label('cnt') + rows = ( + session.query(Chat.datasource, ChatRecord.question, cnt) + .join(Chat, ChatRecord.chat_id == Chat.id) + .filter( + Chat.oid == oid, + Chat.create_by == current_user.id, + Chat.datasource.isnot(None), + ChatRecord.question.isnot(None), + ChatRecord.question != '', + ChatRecord.first_chat.isnot(True), + ) + .group_by(Chat.datasource, ChatRecord.question) + .order_by(desc(cnt)) + .limit(400) + .all() + ) + by_ds: Dict[Any, List[tuple]] = {} + for ds_id, question, c in rows: + by_ds.setdefault(ds_id, []).append((question, int(c))) + + ds_ids = [k for k in by_ds.keys() if k is not None] + id_to_name: Dict[Any, str] = {} + if ds_ids: + ds_rows = session.query(CoreDatasource.id, CoreDatasource.name).filter( + CoreDatasource.id.in_(ds_ids), + CoreDatasource.oid == oid, + ).all() + id_to_name = {r[0]: r[1] for r in ds_rows} + + flat: List[Dict[str, Any]] = [] + for ds_id, weighted in by_ds.items(): + if ds_id is None: + continue + for rep_q, total in cluster_questions_for_datasource(weighted): + flat.append( + { + 'datasource_id': int(ds_id), + 'datasource_name': id_to_name.get(ds_id) or '', + 'question': rep_q, + 'count': total, + } + ) + + flat.sort(key=lambda x: (-x['count'], x.get('datasource_name') or '')) + return flat[:limit] + + def rename_chat_with_user(session: SessionDep, current_user: CurrentUser, rename_object: RenameChat) -> str: chat = session.get(Chat, rename_object.id) if not chat: diff --git a/backend/apps/chat/utils/popular_questions_cluster.py b/backend/apps/chat/utils/popular_questions_cluster.py new file mode 100644 index 000000000..061e041d5 --- /dev/null +++ b/backend/apps/chat/utils/popular_questions_cluster.py @@ -0,0 +1,129 @@ +""" +热门问题:按数据源聚合,并在同一数据源内做语义相近合并(非纯字面 group_by)。 + +1. 意图桶:库表/数据概览类中文问法合并为同一主题(见 META_OVERVIEW_PATTERN)。 +2. 向量聚类:对其余问句用本地中文 embedding 做余弦相似度合并(可选,失败则回退)。 +3. 回退:归一化 + difflib 合并相近字面。 +""" + +from __future__ import annotations + +import re +from difflib import SequenceMatcher +from typing import Any, Dict, List, Tuple + +import numpy as np + +# 表/数据量/有哪些数据 等「元信息」类问题归为一类(用户示例) +META_OVERVIEW_PATTERN = re.compile( + r"(几张表|哪些表|多少张表|有多少表|表.*数据量|数据量.*表|分别.*数据量|数据量.*多大|" + r"哪些数据|有什么数据|有哪些数据|什么数据|库表|schema|多少条数据|统计.*表|表的.*数量)", + re.IGNORECASE, +) + + +def normalize_question(s: str) -> str: + if not s: + return "" + t = s.strip() + t = re.sub(r"[\s\u3000]+", "", t) + t = re.sub(r"[。..!?!?;;,、]+$", "", t) + return t + + +def _split_meta_overview( + weighted: List[Tuple[str, int]], +) -> Tuple[List[Tuple[str, int]], List[Tuple[str, int]]]: + meta: List[Tuple[str, int]] = [] + rest: List[Tuple[str, int]] = [] + for q, c in weighted: + if META_OVERVIEW_PATTERN.search(q): + meta.append((q, c)) + else: + rest.append((q, c)) + out: List[Tuple[str, int]] = [] + if meta: + rep = max(meta, key=lambda x: x[1])[0] + total = sum(c for _, c in meta) + out.append((rep, total)) + return out, rest + + +def _merge_difflib(weighted: List[Tuple[str, int]], threshold: float = 0.78) -> List[Tuple[str, int]]: + if not weighted: + return [] + items = sorted(weighted, key=lambda x: -x[1]) + clusters: List[Dict[str, Any]] = [] + for q, c in items: + nq = normalize_question(q) + best_i = -1 + best_r = 0.0 + for i, cl in enumerate(clusters): + r = SequenceMatcher(None, nq, cl["norm"]).ratio() + if r >= threshold and r > best_r: + best_r = r + best_i = i + if best_i >= 0: + clusters[best_i]["count"] += c + if c > clusters[best_i].get("max_w", 0): + clusters[best_i]["rep"] = q + clusters[best_i]["max_w"] = c + else: + clusters.append({"rep": q, "count": c, "norm": nq, "max_w": c}) + return [(c["rep"], int(c["count"])) for c in clusters] + + +def _merge_embedding(weighted: List[Tuple[str, int]], threshold: float = 0.76) -> List[Tuple[str, int]]: + if len(weighted) <= 1: + return weighted + try: + from apps.ai_model.embedding import EmbeddingModelCache + + texts = [w[0] for w in weighted] + model = EmbeddingModelCache.get_model() + embs = model.embed_documents(texts) + arr = np.array(embs, dtype=np.float32) + norms = np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9 + arr = arr / norms + n = len(weighted) + parent = list(range(n)) + + def find(a: int) -> int: + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + sim = arr @ arr.T + for i in range(n): + for j in range(i + 1, n): + if float(sim[i, j]) >= threshold: + union(i, j) + groups: Dict[int, List[int]] = {} + for i in range(n): + r = find(i) + groups.setdefault(r, []).append(i) + out: List[Tuple[str, int]] = [] + for idxs in groups.values(): + total = sum(weighted[i][1] for i in idxs) + rep_q = max((weighted[i] for i in idxs), key=lambda x: x[1])[0] + out.append((rep_q, int(total))) + return out + except Exception: + return _merge_difflib(weighted, threshold=0.78) + + +def cluster_questions_for_datasource(weighted: List[Tuple[str, int]]) -> List[Tuple[str, int]]: + """同一数据源下多组 (原文, 次数) -> 合并后 (代表问句, 总次数)。""" + if not weighted: + return [] + meta_merged, rest = _split_meta_overview(weighted) + if not rest: + return meta_merged + embedded_or_fb = _merge_embedding(rest) + return meta_merged + embedded_or_fb diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index d4e24b7a0..a5a53fc9b 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -231,6 +231,7 @@ def inner(): try: return preview(session, current_user, id, data) except Exception as e: + SQLBotLogUtil.error(f"Preview failed: {e}, try another way") ds = session.query(CoreDatasource).filter(CoreDatasource.id == id).first() # check ds status status = check_status(session, trans, ds, True) diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index fc7afb7cf..ae547a712 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -329,8 +329,8 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table conf = DatasourceConf(**json.loads(aes_decrypt(ds.configuration))) if ds.type != "excel" else get_engine_config() sql: str = "" - if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks": - sql = f"""SELECT `{"`, `".join(fields)}` FROM `{data.table.table_name}` + if ds.type == "mysql" or ds.type == "doris" or ds.type == "starrocks" or ds.type == "hive": + sql = f"""SELECT `{"`, `".join(fields)}` FROM `{conf.database}`.`{data.table.table_name}` {where} LIMIT 100""" elif ds.type == "sqlServer": diff --git a/backend/templates/sql_examples/Oracle.yaml b/backend/templates/sql_examples/Oracle.yaml index 26e75297b..e32213128 100644 --- a/backend/templates/sql_examples/Oracle.yaml +++ b/backend/templates/sql_examples/Oracle.yaml @@ -8,7 +8,7 @@ template: 5. 应用其他规则(引号、别名、格式化等) 6. 最终验证:GROUP BY查询的ROWNUM位置是否正确? 7. 强制检查:验证SQL语法是否符合规范 - 8. 确定图表类型(根据规则选择table/column/bar/line/pie) + 8. 确定图表类型(根据规则选择table/column/bar/line/pie/scatter) 9. 确定对话标题 10. 生成JSON结果 11. 强制检查:JSON格式是否正确 diff --git a/backend/templates/template.yaml b/backend/templates/template.yaml index 0179c4605..c642014d9 100644 --- a/backend/templates/template.yaml +++ b/backend/templates/template.yaml @@ -17,7 +17,7 @@ template: 4. 强制检查:应用数据量限制规则(默认限制或用户指定数量) 5. 应用其他规则(引号、别名、格式化等) 6. 强制检查:验证SQL语法是否符合规范 - 7. 确定图表类型(根据规则选择table/column/bar/line/pie) + 7. 确定图表类型(根据规则选择table/column/bar/line/pie/scatter) 8. 确定对话标题 9. 生成JSON结果 10. 强制检查:JSON格式是否正确 @@ -137,8 +137,8 @@ template: 若不能生成,则返回格式如:{{"success":false,"message":"说明无法生成SQL的原因"}} - 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 返回的JSON内chart-type值则为 table/column/bar/line/pie 中的一个 - 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter), 返回的JSON内chart-type值则为 table/column/bar/line/pie/scatter 中的一个 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,两个度量间的相关性或分布用 scatter,原始数据查看用 table 图表字段维度与指标数量限制规则 @@ -150,6 +150,12 @@ template: 有分类维度时,只能有一个指标字段(纵轴) 没有分类维度时,可以有多个指标字段 + + 散点图(scatter): + 需要一个横轴度量字段(x)与一个纵轴度量字段(y),用于展示两个数值维度之间的关系或分布 + 可选一个分类字段(series)用于颜色分组;有分类字段时纵轴(y)仅保留一个指标字段 + 无分类字段且存在多个数值指标时,可与折线图类似使用multi-quota展示多系列散点 + 饼图(pie): 必须有一个分类维度字段(扇区) @@ -158,6 +164,11 @@ template: + + 如果图表类型为散点图(scatter) + 在生成的SQL中须包含用于图表横轴(x)与纵轴(y)的两个字段;若有分类字段(series),须包含在查询结果中,排序时优先按横轴字段,其次分类字段 + 除非用户明确要求聚合,否则散点图优先保留明细行,不对度量字段默认使用SUM等聚合(以便展示真实散点分布) + 如果图表类型为柱状图(column)、条形图(bar)或折线图(line) 在生成的SQL中必须指定一个维度字段和一个指标字段,其中维度字段必须参与排序 @@ -173,6 +184,9 @@ template: 在没有明确业务场景说明、或用户没有明确指定不需要聚合的情况下 必须对数值类型指标字段进行聚合计算(默认使用SUM函数) + + 散点图(scatter)一般不适用上一则针对 column/bar/line/pie 的强制SUM聚合规则;若用户明确要求先聚合再作散点,再使用聚合 + 如果问题是图表展示相关且与生成SQL查询无关时,请参考上一次回答的SQL来生成SQL @@ -194,7 +208,7 @@ template: 生成的SQL查询结果可以用来进行图表展示,需要注意排序字段的排序优先级,例如: - - 柱状图或折线图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 + - 柱状图、折线图或散点图:适合展示在横轴的字段优先排序,若SQL包含分类字段,则分类字段次一级排序 若需关联多表,优先使用中标记为"Primary key"/"ID"/"主键"的字段作为关联条件。 @@ -399,8 +413,8 @@ template: 以下是你必须遵守的规则和可以参考的基础示例: - 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie), 提供给你的值则为 table/column/bar/line/pie 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 - 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,原始数据查看用 table + 支持的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter), 提供给你的值则为 table/column/bar/line/pie/scatter 中的一个,若没有推荐类型,则由你自己选择一个合适的类型。 + 图表类型选择原则推荐:趋势 over time 用 line,分类对比用 column/bar,占比用 pie,两变量关系/分布用 scatter,原始数据查看用 table 不需要你提供创建图表的代码,你只需要负责根据要求生成JSON配置项 @@ -466,6 +480,16 @@ template: 折线图使用一个分类字段(series),一个维度轴字段(x)和一个数值轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + + 如果需要散点图,JSON格式应为(series为可选字段,仅当有分类字段时使用): + {{"type":"scatter", "title": "标题", "axis": {{"x": {{"name":"横轴度量的{lang}名称","value": "SQL 查询横轴对应的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"纵轴度量的{lang}名称","value": "SQL 查询纵轴对应的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} + 散点图配置说明: + 1. x轴与y轴:一般为两个数值度量字段,用于展示相关性或分布 + 2. series:可选,用于按类别着色 + 3. 结构与折线图类似;若存在多个指标且无分类字段,可使用multi-quota + 散点图使用可选的分类字段(series),一个横轴字段(x)和一个纵轴字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"(若有)。 + 如果SQL中没有分类列,那么JSON内的series字段不需要出现 + 如果需要饼图,JSON格式应为: {{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"数值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}} @@ -481,7 +505,7 @@ template: 如果SQL查询结果中存在可用于数据分类的字段(如国家、产品类型等),则必须提供series配置。如果不存在,则无需在JSON中包含series字段。 - 对于柱状图/条形图/折线图: + 对于柱状图/条形图/折线图/散点图: 1. 如果SQL查询中存在多个指标字段(如"收入"、"支出"、"利润"等数值字段)且不存在分类字段,则必须提供multi-quota配置,形如:"multi-quota":{{"name":"指标类型","value":["指标字段1","指标字段2",...]}} 2. 如果SQL查询中存在多个指标字段且同时存在分类字段,则以分类字段为主,选取多指标字段中的其中一个作为指标即可,不需要multi-quota配置 3. 如果只有一个指标字段,无论是否存在分类字段,都不需要multi-quota配置 @@ -556,7 +580,7 @@ template: 您的任务是根据给定的表结构,用户问题以及以往用户提问,推测用户接下来可能提问的1-{articles_number}个问题。 请遵循以下规则: - 推测的问题需要与提供的表结构相关,生成的提问例子如:["查询所有用户数据","使用饼图展示各产品类型的占比","使用折线图展示销售额趋势",...] - - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie) + - 推测问题如果涉及图形展示,支持的图形类型为:表格(table)、柱状图(column)、条形图(bar)、折线图(line)、饼图(pie)或散点图(scatter) - 推测的问题不能与当前用户问题重复 - 推测的问题必须与给出的表结构相关 - 若有以往用户提问列表,则根据以往用户提问列表,推测用户最频繁提问的问题,加入到你生成的推测问题中 diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 2a5870632..06276f2e4 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -492,6 +492,13 @@ export const chatApi = { recentQuestions: (datasource_id?: number): Promise => { return request.get(`/chat/recent_questions/${datasource_id}`) }, + popularQuestions: ( + limit = 8 + ): Promise< + Array<{ datasource_id: number; datasource_name: string; question: string; count: number }> + > => { + return request.get('/chat/popular_questions', { params: { limit } }) + }, checkLLMModel: () => request.get('/system/aimodel/default', { requestOptions: { silent: true } }), export2Excel: (record_id: number | undefined, chat_id: any) => request.get(`/chat/record/${record_id}/excel/export/${chat_id}`, { diff --git a/frontend/src/assets/svg/chart/icon_scatter_outlined.svg b/frontend/src/assets/svg/chart/icon_scatter_outlined.svg new file mode 100644 index 000000000..3debe5f6c --- /dev/null +++ b/frontend/src/assets/svg/chart/icon_scatter_outlined.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/components/layout/LayoutDsl.vue b/frontend/src/components/layout/LayoutDsl.vue index 3b8c7017a..88aeb74a4 100644 --- a/frontend/src/components/layout/LayoutDsl.vue +++ b/frontend/src/components/layout/LayoutDsl.vue @@ -1,5 +1,5 @@