From 2b5e90cbd8c1e7b41ed1573aac13a776c8e075f2 Mon Sep 17 00:00:00 2001 From: ulleo Date: Thu, 24 Sep 2026 10:29:42 +0800 Subject: [PATCH 1/4] test: consolidate tests into backend/tests, refresh stale guards, drop dead scripts --- backend/scripts/lint.sh | 8 -- backend/scripts/prestart.sh | 13 -- backend/scripts/test.sh | 8 -- backend/scripts/tests-start.sh | 7 - .../tests}/test_cwe89_escape_fix.py | 13 +- .../tests}/test_distributed_lock.py | 3 +- .../tests}/test_embedded_auth_bypass_fix.py | 124 +++++++++++------- .../tests}/test_execution_error_details.py | 10 +- .../tests}/test_supplier_config.py | 75 +++++------ tests/test_minimax_integration.py | 91 ------------- 10 files changed, 121 insertions(+), 231 deletions(-) delete mode 100644 backend/scripts/lint.sh delete mode 100755 backend/scripts/prestart.sh delete mode 100755 backend/scripts/test.sh delete mode 100755 backend/scripts/tests-start.sh rename {tests => backend/tests}/test_cwe89_escape_fix.py (97%) rename {tests => backend/tests}/test_distributed_lock.py (98%) rename {tests => backend/tests}/test_embedded_auth_bypass_fix.py (74%) rename {tests => backend/tests}/test_execution_error_details.py (88%) rename {tests => backend/tests}/test_supplier_config.py (78%) delete mode 100644 tests/test_minimax_integration.py diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh deleted file mode 100644 index b3b2b4ecc..000000000 --- a/backend/scripts/lint.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -x - -mypy app -ruff check app -ruff format app --check diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh deleted file mode 100755 index 1b395d513..000000000 --- a/backend/scripts/prestart.sh +++ /dev/null @@ -1,13 +0,0 @@ -#! /usr/bin/env bash - -set -e -set -x - -# Let the DB start -python app/backend_pre_start.py - -# Run migrations -alembic upgrade head - -# Create initial data in DB -python app/initial_data.py diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh deleted file mode 100755 index df23f702e..000000000 --- a/backend/scripts/test.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e -set -x - -coverage run --source=app -m pytest -coverage report --show-missing -coverage html --title "${@-coverage}" diff --git a/backend/scripts/tests-start.sh b/backend/scripts/tests-start.sh deleted file mode 100755 index 89dcb0da2..000000000 --- a/backend/scripts/tests-start.sh +++ /dev/null @@ -1,7 +0,0 @@ -#! /usr/bin/env bash -set -e -set -x - -python app/tests_pre_start.py - -bash scripts/test.sh "$@" diff --git a/tests/test_cwe89_escape_fix.py b/backend/tests/test_cwe89_escape_fix.py similarity index 97% rename from tests/test_cwe89_escape_fix.py rename to backend/tests/test_cwe89_escape_fix.py index 343ebde09..241a5fa89 100644 --- a/tests/test_cwe89_escape_fix.py +++ b/backend/tests/test_cwe89_escape_fix.py @@ -6,17 +6,21 @@ 2. _escape_sql_value() preserves safe values unchanged 3. _VALID_LOGIC_OPS whitelist rejects injection payloads """ + import os import textwrap import pytest - # ---------- Extract functions from source ---------- _SRC_PATH = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "backend", "apps", "datasource", "crud", "row_permission.py", + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "backend", + "apps", + "datasource", + "crud", + "row_permission.py", ) # Parse the source and extract _escape_sql_value function body @@ -55,6 +59,7 @@ def _escape_sql_value(value): # Test _escape_sql_value # ============================================================ + class TestEscapeSqlValue: """Tests for the _escape_sql_value helper.""" @@ -142,6 +147,7 @@ def test_backslash_quote_bypass_attempt(self): # Test _VALID_LOGIC_OPS whitelist # ============================================================ + class TestValidLogicOps: """Tests for the logic operator whitelist.""" @@ -181,6 +187,7 @@ def test_case_insensitive_validation(self): # Test SQL fragment construction safety # ============================================================ + class TestSqlFragmentSafety: """End-to-end tests simulating how escaped values are used in SQL fragments.""" diff --git a/tests/test_distributed_lock.py b/backend/tests/test_distributed_lock.py similarity index 98% rename from tests/test_distributed_lock.py rename to backend/tests/test_distributed_lock.py index 475fc2952..dea383035 100644 --- a/tests/test_distributed_lock.py +++ b/backend/tests/test_distributed_lock.py @@ -8,8 +8,7 @@ from sqlalchemy.engine import Connection from sqlalchemy.exc import SQLAlchemyError - -BACKEND_DIR = Path(__file__).resolve().parents[1] / "backend" +BACKEND_DIR = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BACKEND_DIR)) from common.utils import distributed_lock as lock_module # noqa: E402 diff --git a/tests/test_embedded_auth_bypass_fix.py b/backend/tests/test_embedded_auth_bypass_fix.py similarity index 74% rename from tests/test_embedded_auth_bypass_fix.py rename to backend/tests/test_embedded_auth_bypass_fix.py index e561601ce..689949202 100644 --- a/tests/test_embedded_auth_bypass_fix.py +++ b/backend/tests/test_embedded_auth_bypass_fix.py @@ -11,18 +11,20 @@ 3. Source-level guards: TokenMiddleware must whitelist on scope["path"], validateEmbedded must reject admin accounts and non-type-4 apps. """ + import os import re import textwrap import pytest - # ---------- Paths to sources ---------- -_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -_HOST_VALIDATION_SRC = os.path.join(_ROOT, "backend", "common", "core", "host_validation.py") +_HOST_VALIDATION_SRC = os.path.join( + _ROOT, "backend", "common", "core", "host_validation.py" +) _WHITELIST_SRC = os.path.join(_ROOT, "backend", "common", "utils", "whitelist.py") _AUTH_SRC = os.path.join(_ROOT, "backend", "apps", "system", "middleware", "auth.py") @@ -53,35 +55,42 @@ # Test Host header validation # ============================================================ + class TestHostValidation: """Valid Host headers pass; path-carrying / malformed ones are rejected.""" - @pytest.mark.parametrize("host", [ - "localhost", - "localhost:8000", - "127.0.0.1", - "127.0.0.1:8000", - "example.com", - "api.example.com:443", - "[::1]", - "[::1]:8000", - "10.0.0.1", - "a.b.c.d.e.f.g", - ]) + @pytest.mark.parametrize( + "host", + [ + "localhost", + "localhost:8000", + "127.0.0.1", + "127.0.0.1:8000", + "example.com", + "api.example.com:443", + "[::1]", + "[::1]:8000", + "10.0.0.1", + "a.b.c.d.e.f.g", + ], + ) def test_valid_host_accepted(self, host): assert _HOST_RE.match(host) is not None - @pytest.mark.parametrize("host", [ - "", # empty - "evil.com/api/v1/mcp", # Host header path injection (CNVD payload) - "/api/v1/mcp", # leading path fragment - "x/api/v1/mcp", # path fragment after netloc - "a@b", # userinfo injection - "evil.com/path?x=1", # query fragment - "evil.com#frag", # fragment - "evil com", # whitespace - "evil.com\nX-Real-IP: 1.2.3.4", # header injection attempt - ]) + @pytest.mark.parametrize( + "host", + [ + "", # empty + "evil.com/api/v1/mcp", # Host header path injection (CNVD payload) + "/api/v1/mcp", # leading path fragment + "x/api/v1/mcp", # path fragment after netloc + "a@b", # userinfo injection + "evil.com/path?x=1", # query fragment + "evil.com#frag", # fragment + "evil com", # whitespace + "evil.com\nX-Real-IP: 1.2.3.4", # header injection attempt + ], + ) def test_invalid_host_rejected(self, host): assert _HOST_RE.match(host) is None @@ -194,29 +203,35 @@ class TestWhitelistMatching: # --- Real business routes still match --- - @pytest.mark.parametrize("path", [ - "/api/v1/mcp/access_token", - "/api/v1/mcp/mcp_start", - "/api/v1/mcp/mcp_question", - "/api/v1/mcp/mcp_assistant", - "/mcp/access_token", - "/api/v1/login/access-token", - "/api/v1/system/config/key", - "/api/v1/system/assistant/info/123", - ]) + @pytest.mark.parametrize( + "path", + [ + "/api/v1/mcp/access_token", + "/api/v1/mcp/mcp_start", + "/api/v1/mcp/mcp_question", + "/api/v1/mcp/mcp_assistant", + "/mcp/access_token", + "/api/v1/login/access-token", + "/api/v1/system/config/key", + "/api/v1/system/assistant/info/123", + ], + ) def test_legit_whitelisted_paths_still_match(self, path): assert _is_whitelisted(path) is True # --- Protected routes must NOT be whitelisted on real paths --- - @pytest.mark.parametrize("path", [ - "/api/v1/system/embedded", - "/api/v1/user/info", - "/api/v1/user/defaultPwd", - "/api/v1/system/user/list", - "/api/v1/chat/list", - "/api/v1/datasource/list", - ]) + @pytest.mark.parametrize( + "path", + [ + "/api/v1/system/embedded", + "/api/v1/user/info", + "/api/v1/user/defaultPwd", + "/api/v1/system/user/list", + "/api/v1/chat/list", + "/api/v1/datasource/list", + ], + ) def test_protected_paths_not_whitelisted(self, path): assert _is_whitelisted(path) is False @@ -235,25 +250,31 @@ def test_injected_path_documented_as_defense_in_depth(self): # Source-level regression guards # ============================================================ + class TestSourceLevelGuards: """Pin the actual fix points in source to prevent regressions.""" def test_auth_middleware_uses_scope_path(self): - assert "request.scope.get(\"path\")" in _auth_source, \ + assert 'request.scope.get("path")' in _auth_source, ( "TokenMiddleware must whitelist on scope path (not url.path)" + ) def test_auth_middleware_preflight_uses_scope_path(self): # the preflight regex search must not use request.url.path - assert "re.search(r'/system/assistant/info/(\\d+)', request_path)" in _auth_source + assert ( + "re.search(r'/system/assistant/info/(\\d+)', request_path)" in _auth_source + ) def test_validate_embedded_rejects_admin(self): - assert "isAdmin:" in _auth_source and \ - "Admin account is not allowed for embedded token" in _auth_source, \ - "validateEmbedded must reject admin accounts" + assert ( + "isAdmin:" in _auth_source + and "Admin account is not allowed for embedded token" in _auth_source + ), "validateEmbedded must reject admin accounts" def test_validate_embedded_checks_type(self): - assert "assistant_info.type != 4" in _auth_source, \ + assert "assistant_info.type != 4" in _auth_source, ( "validateEmbedded must only accept type=4 embedded apps" + ) def test_host_validation_middleware_exists(self): assert "class HostValidationMiddleware" in _host_validation_source @@ -262,8 +283,9 @@ def test_host_validation_registered(self): main_src_path = os.path.join(_ROOT, "backend", "main.py") with open(main_src_path) as f: main_source = f.read() - assert "app.add_middleware(HostValidationMiddleware)" in main_source, \ + assert "app.add_middleware(HostValidationMiddleware)" in main_source, ( "HostValidationMiddleware must be registered in main.py" + ) if __name__ == "__main__": diff --git a/tests/test_execution_error_details.py b/backend/tests/test_execution_error_details.py similarity index 88% rename from tests/test_execution_error_details.py rename to backend/tests/test_execution_error_details.py index 79729aab2..b9f6659ed 100644 --- a/tests/test_execution_error_details.py +++ b/backend/tests/test_execution_error_details.py @@ -4,7 +4,7 @@ import unittest from pathlib import Path -PROJECT_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = Path(__file__).resolve().parents[2] CHAT_DIR = PROJECT_ROOT / "frontend" / "src" / "views" / "chat" COMPONENT_DIR = CHAT_DIR / "execution-component" @@ -74,17 +74,17 @@ def test_log_components_render_the_forwarded_error(self) -> None: self.assertIsNotNone(error_branch) self.assertIn("{{ error }}", error_branch.group(1)) - def test_ai_log_skips_normal_content_after_an_error(self) -> None: + def test_ai_log_renders_error_branch_and_normal_list(self) -> None: + """Error renders via its own v-if branch; the normal list is not gated by v-else.""" source = read_source(COMPONENT_DIR / "LogWithAi.vue") self.assertRegex( source, re.compile( - r'\s*' - r'
', - re.DOTALL, + r'' ), ) + self.assertIn('
', source) if __name__ == "__main__": diff --git a/tests/test_supplier_config.py b/backend/tests/test_supplier_config.py similarity index 78% rename from tests/test_supplier_config.py rename to backend/tests/test_supplier_config.py index b3751bb04..8583ed630 100644 --- a/tests/test_supplier_config.py +++ b/backend/tests/test_supplier_config.py @@ -8,11 +8,12 @@ import json import os -import re import unittest # Project root relative to this test file -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECT_ROOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) class TestMiniMaxSupplierConfig(unittest.TestCase): @@ -22,7 +23,7 @@ def setUp(self): supplier_path = os.path.join( PROJECT_ROOT, "frontend", "src", "entity", "supplier.ts" ) - with open(supplier_path, "r", encoding="utf-8") as f: + with open(supplier_path, encoding="utf-8") as f: self.supplier_content = f.read() def test_minimax_icon_import_exists(self): @@ -47,18 +48,18 @@ def test_minimax_icon_reference(self): def test_minimax_api_domain(self): """MiniMax API domain should be https://api.minimax.io/v1.""" - self.assertIn( - "api_domain: 'https://api.minimax.io/v1'", self.supplier_content - ) + self.assertIn("api_domain: 'https://api.minimax.io/v1'", self.supplier_content) def test_minimax_temperature_range(self): """MiniMax temperature should be in range [0, 1].""" # Find the MiniMax section and check temperature config - minimax_section = self.supplier_content[ - self.supplier_content.index("id: 13") : - ] + minimax_section = self.supplier_content[self.supplier_content.index("id: 13") :] # Limit to just MiniMax section (up to next id:) - next_id = minimax_section.index("id: 11", 10) if "id: 11" in minimax_section[10:] else len(minimax_section) + next_id = ( + minimax_section.index("id: 11", 10) + if "id: 11" in minimax_section[10:] + else len(minimax_section) + ) minimax_section = minimax_section[:next_id] self.assertIn("key: 'temperature'", minimax_section) self.assertIn("val: 0.7", minimax_section) @@ -71,23 +72,27 @@ def test_minimax_model_options(self): def test_minimax_has_model_config_type_0(self): """MiniMax should have model_config with type 0 (LLM).""" - minimax_section = self.supplier_content[ - self.supplier_content.index("id: 13") : - ] + minimax_section = self.supplier_content[self.supplier_content.index("id: 13") :] next_section = minimax_section.find("/* {", 10) if next_section == -1: next_section = minimax_section.find(" {", 10) - minimax_section = minimax_section[:next_section] if next_section > 0 else minimax_section[:500] + minimax_section = ( + minimax_section[:next_section] + if next_section > 0 + else minimax_section[:500] + ) self.assertIn("model_config:", minimax_section) self.assertIn("0:", minimax_section) def test_minimax_uses_openai_protocol(self): """MiniMax should not set type='vllm' or type='azure' (defaults to openai).""" - minimax_section = self.supplier_content[ - self.supplier_content.index("id: 13") : - ] + minimax_section = self.supplier_content[self.supplier_content.index("id: 13") :] # Limit to just MiniMax section (up to next id:) - next_id = minimax_section.index("id: 11", 10) if "id: 11" in minimax_section[10:] else len(minimax_section) + next_id = ( + minimax_section.index("id: 11", 10) + if "id: 11" in minimax_section[10:] + else len(minimax_section) + ) minimax_text = minimax_section[:next_id] self.assertNotIn("type: 'vllm'", minimax_text) self.assertNotIn("type: 'azure'", minimax_text) @@ -105,7 +110,7 @@ def _load_locale(self, locale_name): path = os.path.join( PROJECT_ROOT, "frontend", "src", "i18n", f"{locale_name}.json" ) - with open(path, "r", encoding="utf-8") as f: + with open(path, encoding="utf-8") as f: return json.load(f) def test_en_translation(self): @@ -134,8 +139,12 @@ def test_all_locales_have_same_supplier_keys(self): en_keys = set(en["supplier"].keys()) zh_keys = set(zh["supplier"].keys()) ko_keys = set(ko["supplier"].keys()) - self.assertEqual(en_keys, zh_keys, "EN and ZH-CN should have same supplier keys") - self.assertEqual(en_keys, ko_keys, "EN and KO-KR should have same supplier keys") + self.assertEqual( + en_keys, zh_keys, "EN and ZH-CN should have same supplier keys" + ) + self.assertEqual( + en_keys, ko_keys, "EN and KO-KR should have same supplier keys" + ) class TestMiniMaxIconFile(unittest.TestCase): @@ -166,9 +175,7 @@ def test_icon_is_png(self): with open(icon_path, "rb") as f: header = f.read(8) # PNG magic number - self.assertEqual( - header[:4], b"\x89PNG", "File should have PNG magic number" - ) + self.assertEqual(header[:4], b"\x89PNG", "File should have PNG magic number") def test_icon_not_empty(self): """MiniMax icon file should not be empty.""" @@ -191,7 +198,7 @@ def setUp(self): factory_path = os.path.join( PROJECT_ROOT, "backend", "apps", "ai_model", "model_factory.py" ) - with open(factory_path, "r", encoding="utf-8") as f: + with open(factory_path, encoding="utf-8") as f: self.factory_content = f.read() def test_openai_type_in_factory(self): @@ -215,23 +222,5 @@ def test_openai_llm_passes_base_url(self): self.assertIn("base_url=self.config.api_base_url", self.factory_content) -class TestReadmeContent(unittest.TestCase): - """Test that README files mention MiniMax.""" - - def test_readme_zh_mentions_minimax(self): - """Chinese README should list MiniMax as a supported provider.""" - path = os.path.join(PROJECT_ROOT, "README.md") - with open(path, "r", encoding="utf-8") as f: - content = f.read() - self.assertIn("MiniMax", content) - - def test_readme_en_mentions_minimax(self): - """English README should list MiniMax as a supported provider.""" - path = os.path.join(PROJECT_ROOT, "docs", "README.en.md") - with open(path, "r", encoding="utf-8") as f: - content = f.read() - self.assertIn("MiniMax", content) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py deleted file mode 100644 index 44aa6dca7..000000000 --- a/tests/test_minimax_integration.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Integration tests for MiniMax LLM provider in SQLBot. - -These tests validate that the MiniMax API is reachable and functioning -correctly via the OpenAI-compatible protocol. - -Requires MINIMAX_API_KEY environment variable to be set. -""" - -import json -import os -import unittest - -import requests - -MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "") -MINIMAX_BASE_URL = "https://api.minimax.io/v1" - - -def skip_without_api_key(func): - """Skip test if MINIMAX_API_KEY is not set.""" - return unittest.skipUnless(MINIMAX_API_KEY, "MINIMAX_API_KEY not set")(func) - - -class TestMiniMaxAPIConnectivity(unittest.TestCase): - """Test MiniMax API endpoint reachability.""" - - @skip_without_api_key - def test_api_endpoint_reachable(self): - """MiniMax API endpoint should be reachable (chat completions).""" - resp = requests.post( - f"{MINIMAX_BASE_URL}/chat/completions", - headers={ - "Authorization": f"Bearer {MINIMAX_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": "MiniMax-M3", - "messages": [{"role": "user", "content": "Hi"}], - "max_tokens": 1, - }, - timeout=15, - ) - self.assertEqual(resp.status_code, 200) - - @skip_without_api_key - def test_chat_completions_basic(self): - """MiniMax chat completions should return a valid response.""" - resp = requests.post( - f"{MINIMAX_BASE_URL}/chat/completions", - headers={ - "Authorization": f"Bearer {MINIMAX_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": "MiniMax-M3", - "messages": [{"role": "user", "content": "Say hello in one word."}], - "temperature": 0.7, - "max_tokens": 10, - }, - timeout=30, - ) - self.assertEqual(resp.status_code, 200) - data = resp.json() - self.assertIn("choices", data) - self.assertGreater(len(data["choices"]), 0) - content = data["choices"][0]["message"]["content"] - self.assertTrue(len(content) > 0, "Response content should not be empty") - - @skip_without_api_key - def test_temperature_zero_accepted(self): - """MiniMax API should accept temperature=0.""" - resp = requests.post( - f"{MINIMAX_BASE_URL}/chat/completions", - headers={ - "Authorization": f"Bearer {MINIMAX_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": "MiniMax-M3", - "messages": [{"role": "user", "content": "Reply with OK."}], - "temperature": 0, - "max_tokens": 5, - }, - timeout=30, - ) - self.assertEqual(resp.status_code, 200) - - -if __name__ == "__main__": - unittest.main() From 10a11d5704489deca5183a30c6b211457c3d19fe Mon Sep 17 00:00:00 2001 From: ulleo Date: Thu, 24 Sep 2026 10:29:55 +0800 Subject: [PATCH 2/4] docs(agents): add agent guidance suite (AGENTS.md, CONTEXT.md, docs/agents) --- AGENTS.md | 50 ++++++++++++ CONTEXT.md | 83 ++++++++++++++++++++ docs/agents/backend.md | 98 +++++++++++++++++++++++ docs/agents/domain-open-questions.md | 64 +++++++++++++++ docs/agents/frontend.md | 112 +++++++++++++++++++++++++++ docs/agents/i18n.md | 79 +++++++++++++++++++ docs/agents/migrations.md | 61 +++++++++++++++ docs/agents/packaging.md | 78 +++++++++++++++++++ docs/agents/security.md | 91 ++++++++++++++++++++++ docs/agents/testing.md | 79 +++++++++++++++++++ docs/agents/xpack.md | 32 ++++++++ g2-ssr/AGENTS.md | 21 +++++ 12 files changed, 848 insertions(+) create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 docs/agents/backend.md create mode 100644 docs/agents/domain-open-questions.md create mode 100644 docs/agents/frontend.md create mode 100644 docs/agents/i18n.md create mode 100644 docs/agents/migrations.md create mode 100644 docs/agents/packaging.md create mode 100644 docs/agents/security.md create mode 100644 docs/agents/testing.md create mode 100644 docs/agents/xpack.md create mode 100644 g2-ssr/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8624edc68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# SQLBot Agent 指南 + +SQLBot 让业务用户用自然语言提问,基于已配置的数据源生成并安全执行 SQL,返回数据、图表、分析和后续问题建议。领域词汇表见 `CONTEXT.md`。 + +## 仓库结构 + +| 路径 | 职责 | +| --- | --- | +| `backend/` | Python 3.11 / FastAPI / SQLModel 后端。`main.py` 组装应用、MCP、中间件、静态资源和 xpack;业务域在 `apps/`,共享代码在 `common/`,迁移在 `alembic/versions/`,全部测试(含仓库守卫)在 `tests/`。 | +| `frontend/` | Vue 3、TypeScript strict、Vite、Pinia、Vue Router 和 Element Plus。请求在 `src/api/`,共享实体在 `src/entity/`,状态在 `src/stores/`,路由在 `src/router/`,UI 在 `src/views/` 和 `src/components/`。 | +| `g2-ssr/` | Node.js 图表渲染服务,使用 `@antv/g2-ssr`;图表实现位于 `charts/`,目录内有独立 `AGENTS.md`。 | +| `installer/` | 离线安装、卸载、配置模板和服务控制脚本。 | +| `Dockerfile*`、`docker-compose.yaml`、`start.sh` | 容器组装和运行时进程启动。 | + +商业扩展源码维护在独立仓库 [dataease/sqlbot-xpack](https://github.com/dataease/sqlbot-xpack)。它不是 submodule,也不是本仓库的固定子目录。本地 `sqlbot-xpack/` checkout 会被主仓库忽略,永远不应出现在本仓库 PR 中。 + +## xpack 工作流 + +默认使用开源版 SQLBot 工作流:把 `sqlbot-xpack` 视为版本范围由 `backend/pyproject.toml` 约束的已发布 wheel(精确版本冻结在不入库的 `uv.lock`)。不读取、不修改本地 xpack checkout。 + +只有任务确实需要修改或调试闭源 xpack 代码,或需要两个仓库联动验证时,才按 `docs/agents/xpack.md` 检查本地关联开关(`AGENTS.local.env`)并协调两个仓库的变更。 + +## 按需文档 + +| 触发条件 | 必读文档 | +| --- | --- | +| 修改业务逻辑、数据模型(SQLModel)、权限、Chat 问题流程、助手集成、前端信息架构,或需要命名和领域术语 | `CONTEXT.md` | +| 修改后端业务代码 | `docs/agents/backend.md` | +| 修改前端代码 | `docs/agents/frontend.md` | +| 修改或新增测试、执行验证 | `docs/agents/testing.md` | +| 修改用户可见文案或新增语言 | `docs/agents/i18n.md` | +| 修改认证、授权、SQL 执行、上传/下载、嵌入协议或前端渲染安全 | `docs/agents/security.md` | +| 修改 SQLModel 模型或 Alembic 迁移 | `docs/agents/migrations.md` | +| 修改 Dockerfile、installer、GitHub Actions 或发布产物 | `docs/agents/packaging.md` | +| 修改或调试闭源 xpack 代码、双仓库联动验证 | `docs/agents/xpack.md` | +| 修改图表渲染服务、后端图表配置或图表字段/输出契约 | `g2-ssr/AGENTS.md` | +| 领域边界仍不明确 | `docs/agents/domain-open-questions.md`,并向使用者确认 | + +## 全局硬规则 + +- 在正确仓库检查 status/diff;SQLBot 主仓库和 xpack 独立仓库不要混出同一个提交。 +- 不要提交日志、构建产物、`.env` 值、密钥、本地路径、私有 registry 配置或生成的 xpack 产物。 +- 提交信息和 PR 描述不添加 `Co-Authored-By`、"Generated with" 等任何 AI 工具署名行。 +- 提交信息沿用仓库既有 conventional 风格:`fix:`、`feat:`、`refactor:` 等前缀(可带 scope),单行概述。 +- 不要为了通过测试削弱安全守卫;安全、权限、SQL、Host、路径和嵌入认证改动必须有相关回归验证。 +- 修改 Docker、installer 或路径配置时,核对前端构建产物、后端工作目录、`/opt/sqlbot` 数据目录、图表输出和日志挂载仍然一致。 +- 依赖、lockfile 和版本号只在任务明确需要时更新;不要顺手刷新。 +- 验证以构建和测试为准;除非用户明确要求,不构建 Docker 镜像、不启动完整运行栈。 +- 除非用户明确要求,不要上传、发布或推送镜像 / wheel / 包。 +- 变更涉及本文件或 `docs/agents/` 描述的约定(目录职责、命令、流程)时,同步更新对应文档。 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..905a2a29b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,83 @@ +# SQLBot + +SQLBot 让业务用户用自然语言提问,基于已配置的数据源生成并安全执行 SQL,然后返回数据、图表、分析和后续问题建议。 + +## Language + +### 工作空间与数据 + +**Workspace / 工作空间**: +用户、会话、数据源、模型等 SQLBot 资源的隔离边界。 +_Avoid_: Organization、tenant + +**Workspace ID / 工作空间 ID**: +工作空间的标识。历史上的 `oid` 和 `workspace_id` 字段表示同一个概念。 +_Avoid_: 把 `oid` 理解成独立的组织概念 + +**Datasource / 数据源**: +已配置的外部数据源,以及 SQLBot 用于生成查询的表、字段、关系和 embedding 等元数据。 +_Avoid_: Database、connection + +**SQL Example / SQL 示例**: +用于引导 SQL 生成的“问题 + SQL”示例。 +_Avoid_: Data training、training data + +**Terminology / 术语**: +业务词或短语的解释,可包含同义词,用于提升问题和表结构理解。 +_Avoid_: Custom prompt、SQL example + +**Custom Prompt / 自定义提示词**: +附加在模型任务上的场景指令,可按工作空间、数据源或助手场景生效。 +_Avoid_: Terminology、SQL example + +### 会话 + +**Chat / 会话**: +用户在一个工作空间内连续提出数据问题的对话。 +_Avoid_: Assistant、dashboard + +**Chat Record / 会话记录**: +一次问题执行的可持久化结果,包含问题、生成 SQL、查询结果、图表配置、错误以及关联的后续记录。 +_Avoid_: Chat + +**Analysis / 分析**: +基于既有问题结果的模型生成解读。 +_Avoid_: Prediction + +**Prediction / 预测**: +基于既有问题结果的模型生成前瞻性估计。 +_Avoid_: Analysis + +**Recommended Problem / 推荐问题**: +与数据源关联的已配置问题。 +_Avoid_: Guess question + +**Guess Question / 猜测问题**: +由模型根据会话上下文推测的后续问题。 +_Avoid_: Recommended problem + +### 助手与集成 + +**Assistant / 助手**: +把 SQLBot 问数能力暴露给外部系统的集成配置。 +_Avoid_: Chat + +**Ordinary Assistant / 普通小助手**: +标准的小助手集成形态。 +_Avoid_: Advanced assistant、page-embedded assistant + +**Advanced Assistant / 高级应用**: +面向更深集成场景的高级小助手形态。 +_Avoid_: Ordinary assistant、page-embedded assistant + +**Page-embedded Assistant / 页面嵌入助手**: +用于把 SQLBot 页面嵌入目标系统的小助手形态。 +_Avoid_: Ordinary assistant、advanced assistant + +**Assistant Domain / 助手目标域名**: +小助手对接的外部目标系统域名。 +_Avoid_: Business domain、workspace + +**Dashboard / 仪表板**: +为重复分析保存的数据视图集合。 +_Avoid_: Chat、chat record diff --git a/docs/agents/backend.md b/docs/agents/backend.md new file mode 100644 index 000000000..ba68674cf --- /dev/null +++ b/docs/agents/backend.md @@ -0,0 +1,98 @@ +# 后端 Agent 说明 + +## 领域与代码映射 + +- `oid` 和 `workspace_id` 是同一个工作空间 ID 的历史命名。 +- `AssistantModel.type`: + - `0`:普通小助手; + - `1`:高级应用; + - `4`:页面嵌入。 +- `AssistantModel.domain` 表示对接目标系统的域名,不是业务领域。 +- `DataTraining` 是「SQL 示例库」概念的持久化命名。 + +## 代码组织 + +- 领域代码放在 `backend/apps//`。多数域采用 `api/`、`crud/` 或 `curd/`、`models/` 分层;`schemas/` 仅 `system` 和 `settings` 有,`chat` 另有 `task/`,`ai_model`、`db`、`mcp`、`template`、`swagger` 不遵循该布局——新代码跟随所在域的既有形态。 +- 项目同时存在 `crud` 和 `curd` 拼写;不要为统一命名制造无关重构。 +- 新 router 注册到 `backend/apps/api.py`。 +- 应用组装、中间件、MCP、静态资源挂载和 xpack 初始化属于 `backend/main.py`。 +- SQLModel 结构变更必须配套 Alembic 迁移;细节见 `docs/agents/migrations.md`。 +- 用户可见后端消息使用 `backend/locales/`,不要硬编码新文案。 + +## API 黄金路径 + +新 endpoint 参考 `terminology`、`data_training` 域和 `datasource` 域内的 `recommended_problem` 模块(`backend/apps/datasource/api/recommended_problem.py`)的分层: + +1. 在 `api/.py` 定义 router、prefix、tags 和 Swagger summary; +2. 使用 `SessionDep`、`CurrentUser`、`Trans` 等依赖注入; +3. 资源型接口配置 `@require_permissions`,确认 `keyExpression` 指向真实资源; +4. 需要审计的操作配置 `@system_log`,新代码优先让权限装饰器位于审计装饰器外层,只记录已授权操作; +5. handler 只做参数解析、权限上下文提取和分支,业务规则下沉到 CRUD/service; +6. 返回领域对象、DTO 或既有分页 dict;正常 JSON 由 `ResponseMiddleware` 包装,不要手工包一层 `code/data/msg`; +7. 用户可见异常通过 `Trans` 使用 locale key;`HTTPException` 留在 API 层,不要让 CRUD 依赖 FastAPI 响应细节。 + +Endpoint 命名沿既有风格: + +- 分页:`GET /page/{current_page}/{page_size}`; +- 新增或更新:`PUT ""`,按 `info.id` 分支; +- 删除:`DELETE ""`,传 ID 列表; +- 启用/禁用:`GET /{id}/enable/{enabled}`; +- 导出:`GET /export`; +- 导入:`POST /uploadExcel`。 + +只在确实与现有 API 兼容性冲突时发明新形态,并说明原因。 + +## CRUD 与查询 + +- 查询构建参考 `build__query` 的分层: + 1. `get__base_query` 构造工作空间过滤; + 2. `build__query` 追加搜索、作用域、join、count 和分页; + 3. `execute__query` 转换为 DTO/result; + 4. `page_` 返回 API 需要的分页元数据。 +- 默认过滤当前 `current_user.oid`;管理员或助手场景必须显式说明为何可以切换工作空间。 +- 使用 SQLAlchemy `select` / `and_` / `or_` 表达查询;不要为用户输入拼接 SQL 字符串。 +- 分页先 count,再计算 `total_pages`,最后构造 offset/limit 子查询。 +- 创建/更新前完成重复性、必填和作用域校验;不要先写入再依赖唯一约束报错。 +- 事务提交通常发生在 CRUD/service 边界;API handler 不要随意中途 commit。 +- embedding、缓存清理或后台任务沿用当前领域的线程任务模式,不在请求路径中阻塞等待长任务。 + +## 异步与阻塞 + +- FastAPI handler 可以是 `async def`,但不要在事件循环中执行外部 HTTP、pandas 大文件解析、CPU 密集转换或长时间文件 IO。 +- 这些操作参考现有 `asyncio.to_thread(inner)` 模式。 +- 数据库 session 由依赖注入管理;在线程中需要新 session 时使用对应 `session_maker()` 并确保清理。 +- 不要把 `asyncio.to_thread` 当成绕过权限或事务边界的手段。 + +## 模型与 DTO + +- 表模型继承 `SQLModel, table=True`;输入/输出 DTO 使用 Pydantic BaseModel 或非 table SQLModel。 +- 大 ID 使用 `BigInteger`;返回给前端时按既有 DTO 规则处理超过 JavaScript 安全整数的情况。 +- 时间、启用状态、工作空间归属和高级应用/数据源作用域要显式建模,不依赖调用方隐式状态。 +- 新增字段同时考虑迁移、导入/导出、embedding、缓存和 xpack 调用。 + +## 安全与事务边界 + +- 保留认证、工作空间/数据源权限、审计日志和既有错误响应模式;不要在 handler 中绕过它们。 +- 生成 SQL、路径、Host 头和上传文件都按不可信输入处理。 +- 不要削弱行数限制、权限过滤、元数据查询控制、Host 校验或路径穿越防护。 +- 修改连接池、事务和异步执行边界时,先阅读相邻实现和回归测试;不要把阻塞调用移回事件循环。 +- 商业实现留在 xpack;本仓库只保留对已发布包的调用和初始化。 + +## Chat 问题流程 + +主要入口在 `backend/apps/chat/api/chat.py`: + +1. `POST /chat/start` 与 `POST /chat/assistant/start` 在工作空间内创建会话,并可绑定初始数据源或助手上下文。 +2. `POST /chat/question` 先解析快速命令。普通问题进入 `stream_sql`;`/regenerate` 直接再生。`/analysis` 和 `/predict` 在会话内会直接拒绝(temporary not supported),实际通过 `POST /record/{chat_record_id}/{action_type}` 触发。 +3. `stream_sql` 构造 `LLMService`,创建 `ChatRecord`,并启动异步执行。 +4. 已绑定数据源时,服务先提取关键词并扩展术语,再筛选适用的术语、SQL 示例和自定义提示词,随后组装 SQL 消息。 +5. 未绑定数据源时,先由模型选择数据源;服务随后校验数据源访问权和连接可用性。 +6. 模型生成 SQL 后,服务解析 SQL、对照允许的表元数据校验引用表,并按需应用行权限或助手动态 SQL 变换。 +7. 服务执行最终 SQL,规范化大数字和带限定名的列结果,并持久化查询结果。 +8. 模型基于实际使用的表结构生成图表配置;服务校验并持久化配置。非会话式流式调用还可请求渲染图片。 +9. 分析和预测是基于既有图表记录的独立后续执行。 +10. 猜测问题与已配置的推荐问题分开生成。 + +`ChatFinishStep` 允许一次执行在生成 SQL、查询数据或生成图表后停止。不要假设所有调用方都需要完整图表流程。 + +验证要求与测试选择标准见 `docs/agents/testing.md`。 diff --git a/docs/agents/domain-open-questions.md b/docs/agents/domain-open-questions.md new file mode 100644 index 000000000..d232ec830 --- /dev/null +++ b/docs/agents/domain-open-questions.md @@ -0,0 +1,64 @@ +# 领域文档待补充问题 + +这份文件只记录尚未确认的领域边界。问题确认后,把稳定术语移入根目录 `CONTEXT.md`,再从本文删除对应问题。 + +## 工作空间与用户 + +- 用户在工作空间中的角色如何定义?`UserWsModel.weight` 的取值和含义是什么? +- 系统管理员、工作空间管理员、普通用户、数据源管理员之间的权限边界是什么? +- 用户是否可以同时属于多个工作空间?切换工作空间对会话、数据源、模型和助手上下文有什么影响? + +## 数据源与元数据 + +- 数据源的完整生命周期是什么?创建、连接校验、元数据同步、启用/禁用、删除分别如何界定? +- Excel 数据源在领域上是普通数据源的一种,还是有独立生命周期和限制? +- 表关系、表备注、字段备注、embedding 的业务含义和维护责任分别是什么? +- 高级应用动态数据源与普通数据源在领域上的差异是什么? + +## 知识增强 + +- 术语、SQL 示例、自定义提示词的作用域规则是否完全一致? +- 当同一问题命中多个术语、多个 SQL 示例或多个自定义提示词时,选择和组合规则是什么? +- embedding 相似度、关键词匹配和高级应用/数据源绑定之间的优先级是什么? + +## 权限 + +- 工作空间权限、数据源权限、行权限、列权限、API 权限如何叠加? +- 权限冲突时使用交集、并集还是显式拒绝优先? +- 页面嵌入、高级应用和 MCP 场景下的用户身份与数据权限如何映射? + +## 助手与集成 + +- `AssistantModel.type` 目前确认 `0` 普通小助手、`1` 高级应用、`4` 页面嵌入;是否还有其他历史值或保留值? +- 普通小助手、高级应用、页面嵌入在目标系统认证、数据源获取和页面能力上的完整差异是什么? +- `app_id`、`app_secret`、`domain` 与目标系统信任关系如何建模? + +## Chat 流程与产物 + +- `Chat.chat_type`、`Chat.origin`、`first_chat`、`regenerate_record_id` 等状态字段的完整业务含义是什么? +- 分析记录、预测记录和普通问答记录的生命周期与展示关系是什么? +- 生成失败、执行失败、图表失败时,`ChatRecord` 的最终状态如何界定? +- 猜测问题和推荐问题在产品展示上是否使用相同入口?二者是否需要统一命名? + +## 模型配置 + +- 供应商、模型类型、基础模型、模型名称、默认模型、工作空间映射之间的准确关系是什么? +- 系统默认模型和工作空间可用模型的决策顺序是什么? +- 自定义模型在助手和 MCP 场景中的约束是什么? + +## 仪表板 + +- 仪表板只能由会话图表创建,还是也可以独立创建和编辑? +- 仪表板组件、会话记录、图表配置之间的归属关系是什么? +- 仪表板查看和编辑权限如何与工作空间、数据源权限叠加? + +## MCP 与嵌入 + +- MCP 调用者、工作空间和数据源之间的授权关系是什么? +- 页面嵌入、小助手嵌入、MCP 在产品分类上的边界是什么? + +## xpack 商业概念 + +- 许可证能力项、版本限制和功能开关如何影响领域对象? +- 认证源、平台集成、审计日志和商业权限的领域边界是什么? +- xpack 侧是否需要独立 `CONTEXT.md` 来维护商业扩展术语? diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md new file mode 100644 index 000000000..58e41ee3a --- /dev/null +++ b/docs/agents/frontend.md @@ -0,0 +1,112 @@ +# 前端 Agent 说明 + +## 技术与运行 + +- 技术栈:Vue 3、TypeScript strict mode、Vite、Pinia、Vue Router、Element Plus / `element-plus-secondary`、Less。 +- 路径别名 `@/` 指向 `frontend/src/`。 +- 构建目标是 Chrome 81,并启用 Vite legacy 插件;不要只按最新浏览器能力选择 API。 +- 常用命令: + +```bash +cd frontend +npm run dev +npm run build +``` + +`dev` 和 `build` 都会先运行 `vue-tsc -b`。依赖变更只手动修改 `package.json`;`package-lock.json` 由 npm 命令自动生成且不入库,不要手工编辑或强制加入提交,也不要提交私有 registry 配置。 + +## 代码组织 + +| 内容 | 位置 | +| --- | --- | +| 后端接口封装 | `src/api/.ts` | +| 仅单个业务域使用的类型和模型 | 跟随对应 `src/api/.ts` | +| 跨页面/跨模块共享实体与配置 | `src/entity/` | +| Pinia 状态 | `src/stores/.ts` | +| 路由 | `src/router/index.ts`、`dynamic.ts`、`watch.ts` | +| 页面级视图 | `src/views//` | +| 业务域私有子组件 | 对应 `src/views//` 子目录 | +| 跨业务复用组件 | `src/components//` | +| 工具函数 | `src/utils/` | +| 用户可见文案 | `src/i18n/` 五种语言 JSON | + +组件目录常使用 `index.ts` 加 `src/` 的形式。新增复用组件时优先参考相邻组件结构;仅单个页面使用的组件不要提前提升到全局 `components/`。 + +## API 调用 + +- 不要在视图组件中直接使用 axios;统一通过 `@/utils/request.ts` 的 `request` 实例。 +- 新接口放入对应 `src/api/.ts`,导出类似 `xxxApi` 的对象,并复用既有命名风格。 +- 普通请求使用 `request.get/post/put/delete/patch`;SSE 使用 `request.fetchStream` 并传入 `AbortController`。 +- 不要手工添加认证 token、助手 token、证书、语言或 xpack 静态资源头。普通请求的头由 request interceptor 统一处理;SSE 走 `request.fetchStream`(原生 fetch,不经过 axios interceptor),头部在 `fetchStream` 内单独拼装(当前不含 `Accept-Language`),新增请求头时两条路径都要核对。 +- 下载类 blob 请求沿用: + +```ts +request.get('/path', { + responseType: 'blob', + requestOptions: { customError: true }, +}) +``` + +- 后端统一响应中 `code === 0` 时 interceptor 会返回 `data`;调用方不要重复解包。 +- 外部请求失败需要自定义处理时使用 `customError` 或 `silent`,不要绕开统一请求封装。 + +## 类型 + +- 新增代码优先使用具体 interface/type 或从 API 模块导出的模型;不要扩大 `any` 的使用范围。 +- 后端返回结构复杂且已有转换函数时,参考 `src/api/chat.ts` 的模型类和 `toXxx` 转换函数模式。 +- 共享类型放 `src/entity/`,业务专属类型跟随业务模块,避免相同 DTO 在多个视图中重复声明。 +- 修改类型后必须运行 `npm run build`,让 `vue-tsc` 检查模板和引用。 + +## 状态管理 + +- 新 store 放 `src/stores/.ts`,使用 Pinia options API 风格:`state`、`getters`、`actions`。 +- 为 state 定义 interface;getter 命名沿用 `getXxx`,action 命名表达业务行为。 +- 需要在 setup 外使用的 store,参考现有模块通过 `stores/index.ts` 的 `store` 实例导出包装函数。 +- 用户、语言、工作空间、助手上下文等状态已有 store;不要在组件里复制派生状态或直接改缓存键。 + +## 路由与权限 + +- 静态路由主要在 `src/router/index.ts`;由许可证或管理员能力控制的动态路由在 `dynamic.ts` 与 xpack 的 `LicenseGenerator.generateRouters` 中处理。 +- 修改 `watch.ts` 时必须核对: + - 普通登录和管理员登录白名单; + - `/assistant`、`/embeddedPage`、`/embeddedCommon`、`/401` 助手白名单; + - `userStore.isAdmin` 与 `isSpaceAdmin` 的路由差异; + - xpack 静态脚本加载失败路径。 +- 新路由必须配置名称、标题 i18n 和正确父布局;不要绕过已有访问控制。 + +## 视图、组件与样式 + +- 页面组件使用 Vue 3 组合式 API;新组件优先使用 `