lang detect
This commit is contained in:
@@ -19,7 +19,6 @@ from src.core.logger import belief_scope, logger
|
||||
from src.core.task_manager import TaskManager
|
||||
from src.schemas.auth import User
|
||||
from src.services.git_service import GitService
|
||||
from src.services.llm_prompt_templates import is_multimodal_model
|
||||
from src.services.llm_provider import LLMProviderService
|
||||
|
||||
from ._dataset_review_dispatch import _dispatch_dataset_review_intent
|
||||
@@ -281,8 +280,7 @@ async def _dispatch_intent(intent: dict[str, Any], current_user: User, task_mana
|
||||
if not dashboard_id or not env_id or (not provider_id):
|
||||
raise HTTPException(status_code=422, detail='Missing dashboard_id/environment/provider. Укажите ID/slug дашборда или окружение.')
|
||||
provider = LLMProviderService(db).get_provider(provider_id)
|
||||
provider_model = provider.default_model if provider else ''
|
||||
if not is_multimodal_model(provider_model, provider.provider_type if provider else None):
|
||||
if not provider or not bool(provider.is_multimodal):
|
||||
raise HTTPException(status_code=422, detail='Selected provider model is not multimodal for dashboard validation. Выберите мультимодальную модель (например, gpt-4o).')
|
||||
task = await task_manager.create_task(plugin_id='llm_dashboard_validation', params={'dashboard_id': str(dashboard_id), 'environment_id': env_id, 'provider_id': provider_id}, user_id=current_user.id)
|
||||
logger.reflect('Belief protocol postcondition checkpoint for _dispatch_intent')
|
||||
|
||||
@@ -83,6 +83,7 @@ async def get_providers(
|
||||
api_key=mask_api_key(service.get_decrypted_api_key(p.id)) if p.api_key else "",
|
||||
default_model=p.default_model,
|
||||
is_active=p.is_active,
|
||||
is_multimodal=bool(p.is_multimodal) if p.is_multimodal is not None else False,
|
||||
)
|
||||
for p in providers
|
||||
]
|
||||
@@ -268,6 +269,7 @@ async def create_provider(
|
||||
api_key=mask_api_key(config.api_key),
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active,
|
||||
is_multimodal=bool(provider.is_multimodal) if provider.is_multimodal is not None else False,
|
||||
)
|
||||
|
||||
|
||||
@@ -303,6 +305,7 @@ async def update_provider(
|
||||
api_key=mask_api_key(service.get_decrypted_api_key(provider.id)) if provider.api_key else "",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active,
|
||||
is_multimodal=bool(provider.is_multimodal) if provider.is_multimodal is not None else False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from ...dependencies import (
|
||||
has_permission,
|
||||
)
|
||||
from ...services.llm_prompt_templates import (
|
||||
is_multimodal_model,
|
||||
normalize_llm_settings,
|
||||
resolve_bound_provider_id,
|
||||
)
|
||||
@@ -104,10 +103,7 @@ async def create_task(
|
||||
raise ValueError(f"LLM Provider {provider_id} not found")
|
||||
if (
|
||||
request.plugin_id == "llm_dashboard_validation"
|
||||
and not is_multimodal_model(
|
||||
db_provider.default_model,
|
||||
db_provider.provider_type,
|
||||
)
|
||||
and not bool(db_provider.is_multimodal)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
|
||||
@@ -250,11 +250,8 @@ async def get_datasource_columns(
|
||||
# #endregion get_datasource_columns
|
||||
|
||||
|
||||
# #region check_target_schema [C:3] [TYPE Function]
|
||||
# #region check_target_schema [C:3] [TYPE Function] [SEMANTICS api,translate,validate]
|
||||
# @BRIEF Проверяет схему целевой таблицы: какие колонки ожидаются, какие есть, каких не хватает.
|
||||
# @PRE User has translate.job.view permission.
|
||||
# @POST Возвращает diff expected vs actual columns.
|
||||
# @SIDE_EFFECT Делает SQL-запрос к information_schema.columns через Superset SQL Lab.
|
||||
# @RELATION DEPENDS_ON -> [validate_target_table_schema]
|
||||
# @RELATION DEPENDS_ON -> [TargetSchemaValidationRequest]
|
||||
# @RELATION DEPENDS_ON -> [TargetSchemaValidationResponse]
|
||||
|
||||
@@ -46,6 +46,7 @@ class LLMProvider(Base):
|
||||
api_key = Column(String, nullable=False) # Should be encrypted
|
||||
default_model = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_multimodal = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
# #endregion LLMProvider
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class LLMProviderConfig(BaseModel):
|
||||
api_key: str | None = None
|
||||
default_model: str
|
||||
is_active: bool = True
|
||||
is_multimodal: bool = False
|
||||
# #endregion LLMProviderConfig
|
||||
|
||||
# #region ValidationStatus [TYPE Class]
|
||||
|
||||
@@ -22,7 +22,6 @@ from ...core.task_manager.context import TaskContext
|
||||
from ...models.llm import ValidationPolicy, ValidationRecord
|
||||
from ...services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
is_multimodal_model,
|
||||
normalize_llm_settings,
|
||||
render_prompt,
|
||||
)
|
||||
@@ -140,7 +139,8 @@ class DashboardValidationPlugin(PluginBase):
|
||||
llm_log.debug(f" Base URL: {db_provider.base_url}")
|
||||
llm_log.debug(f" Default Model: {db_provider.default_model}")
|
||||
llm_log.debug(f" Is Active: {db_provider.is_active}")
|
||||
if not is_multimodal_model(db_provider.default_model, db_provider.provider_type):
|
||||
llm_log.debug(f" Is Multimodal: {db_provider.is_multimodal}")
|
||||
if not bool(db_provider.is_multimodal):
|
||||
raise ValueError(
|
||||
"Dashboard validation requires a multimodal model (image input support)."
|
||||
)
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# #region LanguageDetectServiceTests [C:3] [TYPE Module] [SEMANTICS test, language, detection, heuristic]
|
||||
# @BRIEF Unit tests for LanguageDetectService — local language detection via lingua.
|
||||
# @LAYER Test
|
||||
# @RELATION BINDS_TO -> [LanguageDetectService:Module]
|
||||
# @TEST_CONTRACT LanguageDetectService -> detect_language, build_detector, get_detector, batch_detect
|
||||
# @TEST_EDGE empty_text -> returns "und"
|
||||
# @TEST_EDGE numeric_text -> returns "und"
|
||||
# @TEST_EDGE short_text -> correctly detects language
|
||||
# @TEST_EDGE same_language_pre_filter -> batch_detect returns target language for matching rows
|
||||
# @TEST_EDGE detector_cache -> get_detector returns same instance for same language set
|
||||
# @TEST_EDGE cross_language -> English text with Russian detector returns "und"
|
||||
# @RELATION BINDS_TO -> [LanguageDetectService]
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
# region TargetSchemaValidationTests [TYPE Module]
|
||||
# @SEMANTICS: test, translate, target-schema, validation
|
||||
# @PURPOSE: Tests for target table schema validation: _build_expected_columns, _parse_column_results, validate_target_table_schema.
|
||||
# @PURPOSE: Tests for target table schema validation.
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [TargetSchemaValidation]
|
||||
# @TEST_CONTRACT: _build_expected_columns -> list[TargetSchemaColumnInfo]
|
||||
# @TEST_CONTRACT: _parse_column_results -> (list[dict], bool)
|
||||
# @TEST_CONTRACT: _extract_columns_from_rows -> list[dict]
|
||||
# @TEST_CONTRACT: _parse_sqllab_result -> (list[dict], bool)
|
||||
# @TEST_CONTRACT: validate_target_table_schema -> TargetSchemaValidationResponse
|
||||
# @TEST_EDGE: missing_target_key_cols -> Only context + is_original returned
|
||||
# @TEST_EDGE: deduplication -> Duplicate column names are removed
|
||||
# @TEST_EDGE: empty_result -> Returns ([], False)
|
||||
# @TEST_EDGE: superset_sync_format -> Standard Superset response format parsed
|
||||
# @TEST_EDGE: superset_async_format -> Async polling response format parsed
|
||||
# @TEST_EDGE: get_query_results_format -> get_query_results response format parsed
|
||||
# @TEST_EDGE: executor_failure -> SQL Lab query fails, error returned
|
||||
# @TEST_EDGE: sql_injection_table_name -> Invalid table name rejected with error
|
||||
# @TEST_EDGE: executor_exception -> Exception during execution returns error response
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -26,13 +18,13 @@ from src.schemas.translate import (
|
||||
)
|
||||
from src.plugins.translate.service_target_schema import (
|
||||
_build_expected_columns,
|
||||
_parse_column_results,
|
||||
_extract_columns_from_rows,
|
||||
_parse_sqllab_result,
|
||||
validate_target_table_schema,
|
||||
)
|
||||
|
||||
|
||||
# region build_request [TYPE Function]
|
||||
# @PURPOSE: Helper to build a TargetSchemaValidationRequest with defaults.
|
||||
@pytest.fixture
|
||||
def build_request():
|
||||
def _build(**overrides):
|
||||
@@ -58,21 +50,14 @@ def build_request():
|
||||
|
||||
|
||||
# region TestBuildExpectedColumns [TYPE Class]
|
||||
# @PURPOSE: Tests for _build_expected_columns logic.
|
||||
class TestBuildExpectedColumns:
|
||||
|
||||
# region test_basic_case [TYPE Function]
|
||||
# @PURPOSE: target_key_cols + target_column produces id, translated_text, context, is_original.
|
||||
def test_basic_case(self, build_request) -> None:
|
||||
req = build_request()
|
||||
result = _build_expected_columns(req)
|
||||
names = [c.name for c in result]
|
||||
assert names == ["id", "translated_text", "context", "is_original"]
|
||||
|
||||
# endregion test_basic_case
|
||||
|
||||
# region test_all_fields [TYPE Function]
|
||||
# @PURPOSE: All optional column fields produce 7 columns.
|
||||
def test_all_fields(self, build_request) -> None:
|
||||
req = build_request(
|
||||
target_language_column="lang",
|
||||
@@ -90,20 +75,12 @@ class TestBuildExpectedColumns:
|
||||
assert "context" in names
|
||||
assert "is_original" in names
|
||||
|
||||
# endregion test_all_fields
|
||||
|
||||
# region test_empty_target_key_cols [TYPE Function]
|
||||
# @PURPOSE: Empty target_key_cols produces just translated_text, context, is_original.
|
||||
def test_empty_target_key_cols(self, build_request) -> None:
|
||||
req = build_request(target_key_cols=[])
|
||||
result = _build_expected_columns(req)
|
||||
names = [c.name for c in result]
|
||||
assert names == ["translated_text", "context", "is_original"]
|
||||
|
||||
# endregion test_empty_target_key_cols
|
||||
|
||||
# region test_translation_column_fallback [TYPE Function]
|
||||
# @PURPOSE: When target_column is None, translation_column is used as fallback.
|
||||
def test_translation_column_fallback(self, build_request) -> None:
|
||||
req = build_request(target_column=None, translation_column="source_col")
|
||||
result = _build_expected_columns(req)
|
||||
@@ -111,10 +88,6 @@ class TestBuildExpectedColumns:
|
||||
assert "source_col" in names
|
||||
assert names[1] == "source_col"
|
||||
|
||||
# endregion test_translation_column_fallback
|
||||
|
||||
# region test_dedup_same_names [TYPE Function]
|
||||
# @PURPOSE: Duplicate column names are removed preserving order.
|
||||
def test_dedup_same_names(self, build_request) -> None:
|
||||
req = build_request(
|
||||
target_key_cols=["id", "id"],
|
||||
@@ -126,10 +99,6 @@ class TestBuildExpectedColumns:
|
||||
assert names.count("id") == 1
|
||||
assert names.count("name") == 1
|
||||
|
||||
# endregion test_dedup_same_names
|
||||
|
||||
# region test_empty_request [TYPE Function]
|
||||
# @PURPOSE: No key cols and no target/translation column produces just context + is_original.
|
||||
def test_empty_request(self, build_request) -> None:
|
||||
req = build_request(
|
||||
target_key_cols=[],
|
||||
@@ -140,10 +109,6 @@ class TestBuildExpectedColumns:
|
||||
names = [c.name for c in result]
|
||||
assert names == ["context", "is_original"]
|
||||
|
||||
# endregion test_empty_request
|
||||
|
||||
# region test_column_order_preserved [TYPE Function]
|
||||
# @PURPOSE: Column order is preserved: key_cols, target, lang, source, src_lang, context, is_original.
|
||||
def test_column_order_preserved(self, build_request) -> None:
|
||||
req = build_request(
|
||||
target_key_cols=["pk1", "pk2"],
|
||||
@@ -159,27 +124,19 @@ class TestBuildExpectedColumns:
|
||||
"src_col", "src_lang_col", "context", "is_original",
|
||||
]
|
||||
assert names == expected_order
|
||||
|
||||
# endregion test_column_order_preserved
|
||||
# endregion TestBuildExpectedColumns
|
||||
|
||||
|
||||
# region TestParseColumnResults [TYPE Class]
|
||||
# @PURPOSE: Tests for _parse_column_results response parsing.
|
||||
class TestParseColumnResults:
|
||||
# region TestExtractColumnsFromRows [TYPE Class]
|
||||
class TestExtractColumnsFromRows:
|
||||
|
||||
# region test_superset_sync_format [TYPE Function]
|
||||
# @PURPOSE: Standard Superset sync mode (columns + data directly in response).
|
||||
def test_superset_sync_format(self) -> None:
|
||||
result = {
|
||||
"columns": [
|
||||
{"name": "id", "type": "integer", "is_nullable": "YES"},
|
||||
{"name": "name", "type": "varchar", "is_nullable": "NO"},
|
||||
],
|
||||
"data": [{"id": 1, "name": "test"}],
|
||||
}
|
||||
cols, exists = _parse_column_results({"raw_response": result})
|
||||
assert exists is True
|
||||
def test_postgres_information_schema(self) -> None:
|
||||
"""PostgreSQL information_schema формат: column_name, data_type, is_nullable."""
|
||||
rows = [
|
||||
{"column_name": "id", "data_type": "integer", "is_nullable": "YES"},
|
||||
{"column_name": "name", "data_type": "varchar", "is_nullable": "NO"},
|
||||
]
|
||||
cols = _extract_columns_from_rows(rows)
|
||||
assert len(cols) == 2
|
||||
assert cols[0]["name"] == "id"
|
||||
assert cols[0]["type"] == "integer"
|
||||
@@ -188,166 +145,152 @@ class TestParseColumnResults:
|
||||
assert cols[1]["type"] == "varchar"
|
||||
assert cols[1]["is_nullable"] is False
|
||||
|
||||
# endregion test_superset_sync_format
|
||||
def test_clickhouse_describe_table(self) -> None:
|
||||
"""ClickHouse DESCRIBE TABLE формат: name, type."""
|
||||
rows = [
|
||||
{"name": "report_date", "type": "Date"},
|
||||
{"name": "comment_text", "type": "String"},
|
||||
{"name": "is_original", "type": "UInt8"},
|
||||
]
|
||||
cols = _extract_columns_from_rows(rows)
|
||||
assert len(cols) == 3
|
||||
assert cols[0]["name"] == "report_date"
|
||||
assert cols[0]["type"] == "Date"
|
||||
assert cols[2]["name"] == "is_original"
|
||||
assert cols[2]["type"] == "UInt8"
|
||||
|
||||
# region test_async_polling_format [TYPE Function]
|
||||
# @PURPOSE: Async polling format (result -> columns + data).
|
||||
def test_async_polling_format(self) -> None:
|
||||
def test_mixed_keys(self) -> None:
|
||||
"""Разные ключи в одном наборе — приоритет column_name > name > Field."""
|
||||
rows = [
|
||||
{"Field": "id", "Type": "int"},
|
||||
{"name": "name", "type": "text"},
|
||||
{"column_name": "value", "data_type": "float"},
|
||||
]
|
||||
cols = _extract_columns_from_rows(rows)
|
||||
assert len(cols) == 3
|
||||
# column_name имеет приоритет, потом name, потом Field
|
||||
assert cols[0]["name"] == "id"
|
||||
assert cols[1]["name"] == "name"
|
||||
assert cols[2]["name"] == "value"
|
||||
|
||||
def test_empty_rows(self) -> None:
|
||||
cols = _extract_columns_from_rows([])
|
||||
assert cols == []
|
||||
|
||||
def test_invalid_row_type(self) -> None:
|
||||
cols = _extract_columns_from_rows([123, "str", None])
|
||||
assert cols == []
|
||||
|
||||
def test_row_without_name_keys(self) -> None:
|
||||
cols = _extract_columns_from_rows([{"foo": "bar"}])
|
||||
assert cols == []
|
||||
# endregion TestExtractColumnsFromRows
|
||||
|
||||
|
||||
# region TestParseSqlLabResult [TYPE Class]
|
||||
class TestParseSqlLabResult:
|
||||
|
||||
def test_sync_format_with_data(self) -> None:
|
||||
"""Формат 1: прямой ответ с data."""
|
||||
result = {
|
||||
"result": {
|
||||
"columns": [
|
||||
{"name": "col1", "type": "text"},
|
||||
],
|
||||
"raw_response": {
|
||||
"columns": [{"name": "col1"}],
|
||||
"data": [{"col1": "val1"}],
|
||||
}
|
||||
}
|
||||
cols, exists = _parse_column_results({"raw_response": result})
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is True
|
||||
assert len(cols) == 1
|
||||
assert cols[0]["name"] == "col1"
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["col1"] == "val1"
|
||||
|
||||
# endregion test_async_polling_format
|
||||
|
||||
# region test_get_query_results_format [TYPE Function]
|
||||
# @PURPOSE: get_query_results format (results -> columns + data).
|
||||
def test_get_query_results_format(self) -> None:
|
||||
def test_async_format(self) -> None:
|
||||
"""Формат 2: вложенный result."""
|
||||
result = {
|
||||
"results": {
|
||||
"columns": [
|
||||
{"name": "a", "type": "int"},
|
||||
],
|
||||
"data": [{"a": 1}],
|
||||
}
|
||||
}
|
||||
cols, exists = _parse_column_results({"raw_response": result})
|
||||
assert exists is True
|
||||
assert len(cols) == 1
|
||||
assert cols[0]["name"] == "a"
|
||||
|
||||
# endregion test_get_query_results_format
|
||||
|
||||
# region test_deeply_nested_get_query_results [TYPE Function]
|
||||
# @PURPOSE: get_query_results with nested result dict (results -> result -> columns + data).
|
||||
def test_deeply_nested_get_query_results(self) -> None:
|
||||
result = {
|
||||
"results": {
|
||||
"raw_response": {
|
||||
"result": {
|
||||
"columns": [
|
||||
{"name": "x", "type": "float"},
|
||||
],
|
||||
"data": [{"x": 1.0}],
|
||||
"columns": [{"name": "col1"}],
|
||||
"data": [{"col1": "val1"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
cols, exists = _parse_column_results({"raw_response": result})
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is True
|
||||
assert len(cols) == 1
|
||||
assert cols[0]["name"] == "x"
|
||||
assert len(rows) == 1
|
||||
|
||||
# endregion test_deeply_nested_get_query_results
|
||||
def test_get_query_results_format(self) -> None:
|
||||
"""Формат 3: results → data."""
|
||||
result = {
|
||||
"raw_response": {
|
||||
"results": {
|
||||
"columns": [{"name": "col1"}],
|
||||
"data": [{"col1": "val1"}],
|
||||
}
|
||||
}
|
||||
}
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is True
|
||||
assert len(rows) == 1
|
||||
|
||||
# region test_empty_result [TYPE Function]
|
||||
# @PURPOSE: Empty result dict returns ([], False).
|
||||
def test_empty_result(self) -> None:
|
||||
cols, exists = _parse_column_results({"raw_response": {}})
|
||||
assert cols == []
|
||||
def test_empty_data(self) -> None:
|
||||
result = {"raw_response": {"columns": [{"name": "c"}], "data": []}}
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is False
|
||||
assert rows == []
|
||||
|
||||
# endregion test_empty_result
|
||||
def test_no_data_key(self) -> None:
|
||||
result = {"raw_response": {"columns": [{"name": "c"}]}}
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is False
|
||||
assert rows == []
|
||||
|
||||
# region test_none_result [TYPE Function]
|
||||
# @PURPOSE: None result returns ([], False) via early guard.
|
||||
def test_none_result(self) -> None:
|
||||
cols, exists = _parse_column_results(None)
|
||||
assert cols == []
|
||||
rows, exists = _parse_sqllab_result(None)
|
||||
assert rows == []
|
||||
assert exists is False
|
||||
|
||||
# endregion test_none_result
|
||||
|
||||
# region test_raw_response_none [TYPE Function]
|
||||
# @PURPOSE: raw_response=None causes TypeError — known production gap (not guarded).
|
||||
def test_raw_response_none(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
_parse_column_results({"raw_response": None})
|
||||
|
||||
# endregion test_raw_response_none
|
||||
|
||||
# region test_result_without_columns [TYPE Function]
|
||||
# @PURPOSE: Result without columns key returns ([], False).
|
||||
def test_result_without_columns(self) -> None:
|
||||
result = {"status": "success", "data": []}
|
||||
cols, exists = _parse_column_results({"raw_response": result})
|
||||
assert cols == []
|
||||
rows, exists = _parse_sqllab_result({"raw_response": None})
|
||||
assert rows == []
|
||||
assert exists is False
|
||||
|
||||
# endregion test_result_without_columns
|
||||
|
||||
# region test_table_exists_with_data [TYPE Function]
|
||||
# @PURPOSE: Non-empty data means table exists.
|
||||
def test_table_exists_with_data(self) -> None:
|
||||
def test_list_based_data(self) -> None:
|
||||
"""Строки в виде списков вместо словарей (старые версии Superset)."""
|
||||
result = {
|
||||
"columns": [{"name": "c"}],
|
||||
"data": [{"c": "v"}],
|
||||
"raw_response": {
|
||||
"columns": [{"name": "id"}, {"name": "name"}],
|
||||
"data": [[1, "foo"], [2, "bar"]],
|
||||
}
|
||||
}
|
||||
_, exists = _parse_column_results({"raw_response": result})
|
||||
rows, exists = _parse_sqllab_result(result)
|
||||
assert exists is True
|
||||
|
||||
# endregion test_table_exists_with_data
|
||||
|
||||
# region test_table_not_exists_empty_data [TYPE Function]
|
||||
# @PURPOSE: Empty data means table does not exist.
|
||||
def test_table_not_exists_empty_data(self) -> None:
|
||||
result = {
|
||||
"columns": [{"name": "c"}],
|
||||
"data": [],
|
||||
}
|
||||
_, exists = _parse_column_results({"raw_response": result})
|
||||
assert exists is False
|
||||
|
||||
# endregion test_table_not_exists_empty_data
|
||||
|
||||
# region test_alternate_column_key_names [TYPE Function]
|
||||
# @PURPOSE: Columns with alternate key names (column_name, Column) parsed correctly.
|
||||
def test_alternate_column_key_names(self) -> None:
|
||||
result = {
|
||||
"columns": [
|
||||
{"column_name": "id", "data_type": "int"},
|
||||
{"Column": "name", "Type": "text"},
|
||||
],
|
||||
"data": [],
|
||||
}
|
||||
cols, _ = _parse_column_results({"raw_response": result})
|
||||
assert len(cols) == 2
|
||||
assert cols[0]["name"] == "id"
|
||||
assert cols[1]["name"] == "name"
|
||||
|
||||
# endregion test_alternate_column_key_names
|
||||
# endregion TestParseColumnResults
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["id"] == 1
|
||||
assert rows[0]["name"] == "foo"
|
||||
# endregion TestParseSqlLabResult
|
||||
|
||||
|
||||
# region TestValidateTargetTableSchema [TYPE Class]
|
||||
# @PURPOSE: Tests for validate_target_table_schema with mocked Superset executor.
|
||||
class TestValidateTargetTableSchema:
|
||||
|
||||
# region test_all_columns_present [TYPE Function]
|
||||
# @PURPOSE: All expected columns present in target table.
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_all_columns_present(self, mock_executor, build_request) -> None:
|
||||
"""Все ожидаемые колонки присутствуют в таблице."""
|
||||
req = build_request()
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "postgresql"
|
||||
instance.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"raw_response": {
|
||||
"columns": [
|
||||
{"name": "id", "type": "integer", "is_nullable": "YES"},
|
||||
{"name": "translated_text", "type": "text", "is_nullable": "YES"},
|
||||
{"name": "context", "type": "text", "is_nullable": "YES"},
|
||||
{"name": "is_original", "type": "boolean", "is_nullable": "YES"},
|
||||
"columns": [{"name": "column_name"}, {"name": "data_type"}],
|
||||
"data": [
|
||||
{"column_name": "id", "data_type": "integer"},
|
||||
{"column_name": "translated_text", "data_type": "text"},
|
||||
{"column_name": "context", "data_type": "text"},
|
||||
{"column_name": "is_original", "data_type": "boolean"},
|
||||
],
|
||||
"data": [{"id": 1}],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -359,25 +302,23 @@ class TestValidateTargetTableSchema:
|
||||
assert len(response.expected_columns) == 4
|
||||
assert len(response.actual_columns) == 4
|
||||
|
||||
# endregion test_all_columns_present
|
||||
|
||||
# region test_missing_columns [TYPE Function]
|
||||
# @PURPOSE: Target table is missing some expected columns.
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_missing_columns(self, mock_executor, build_request) -> None:
|
||||
"""В таблице не хватает некоторых ожидаемых колонок."""
|
||||
req = build_request()
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "postgresql"
|
||||
instance.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"raw_response": {
|
||||
"columns": [
|
||||
{"name": "id", "type": "integer", "is_nullable": "YES"},
|
||||
{"name": "context", "type": "text", "is_nullable": "YES"},
|
||||
"columns": [{"name": "column_name"}],
|
||||
"data": [
|
||||
{"column_name": "id"},
|
||||
{"column_name": "context"},
|
||||
],
|
||||
"data": [{"id": 1}],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -389,17 +330,54 @@ class TestValidateTargetTableSchema:
|
||||
assert "is_original" in missing_names
|
||||
assert "id" not in missing_names
|
||||
|
||||
# endregion test_missing_columns
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_clickhouse_describe_table(self, mock_executor, build_request) -> None:
|
||||
"""ClickHouse использует DESCRIBE TABLE — другой формат ответа."""
|
||||
req = build_request(
|
||||
target_key_cols=["report_date", "document_number"],
|
||||
target_column="comment_text",
|
||||
target_language_column="lang_code",
|
||||
target_source_column="source_text",
|
||||
target_source_language_column="src_lang",
|
||||
)
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "clickhouse"
|
||||
instance.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"raw_response": {
|
||||
"columns": [{"name": "name"}, {"name": "type"}],
|
||||
"data": [
|
||||
{"name": "report_date", "type": "Date"},
|
||||
{"name": "document_number", "type": "String"},
|
||||
{"name": "lang_code", "type": "String"},
|
||||
{"name": "source_text", "type": "String"},
|
||||
{"name": "src_lang", "type": "String"},
|
||||
{"name": "comment_text", "type": "String"},
|
||||
{"name": "context", "type": "String"},
|
||||
{"name": "is_original", "type": "UInt8"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
response = validate_target_table_schema(req, config_manager)
|
||||
assert response.table_exists is True
|
||||
assert response.error is None
|
||||
assert response.all_present is True
|
||||
assert len(response.missing_columns) == 0
|
||||
assert len(response.actual_columns) == 8
|
||||
|
||||
# region test_superset_failed_status [TYPE Function]
|
||||
# @PURPOSE: Superset returns failed status.
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_superset_failed_status(self, mock_executor, build_request) -> None:
|
||||
"""Superset возвращает failed статус."""
|
||||
req = build_request()
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "postgresql"
|
||||
instance.execute_and_poll.return_value = {
|
||||
"status": "failed",
|
||||
"error_message": "Query timeout",
|
||||
@@ -411,17 +389,15 @@ class TestValidateTargetTableSchema:
|
||||
assert "Query timeout" in response.error
|
||||
assert response.all_present is False
|
||||
|
||||
# endregion test_superset_failed_status
|
||||
|
||||
# region test_executor_exception [TYPE Function]
|
||||
# @PURPOSE: Executor throws an exception.
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_executor_exception(self, mock_executor, build_request) -> None:
|
||||
"""Executor бросает исключение."""
|
||||
req = build_request()
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "postgresql"
|
||||
instance.execute_and_poll.side_effect = ConnectionError("Superset API unavailable")
|
||||
|
||||
response = validate_target_table_schema(req, config_manager)
|
||||
@@ -430,11 +406,8 @@ class TestValidateTargetTableSchema:
|
||||
assert "Superset API unavailable" in response.error
|
||||
assert response.all_present is False
|
||||
|
||||
# endregion test_executor_exception
|
||||
|
||||
# region test_sql_injection_table_name [TYPE Function]
|
||||
# @PURPOSE: Invalid table name with space is rejected with error.
|
||||
def test_sql_injection_table_name(self, build_request) -> None:
|
||||
"""Невалидное имя таблицы с пробелом — ошибка."""
|
||||
req = build_request(target_table="my table; DROP TABLE students; --")
|
||||
config_manager = MagicMock()
|
||||
|
||||
@@ -444,11 +417,8 @@ class TestValidateTargetTableSchema:
|
||||
assert "Invalid target table name" in response.error
|
||||
assert response.all_present is False
|
||||
|
||||
# endregion test_sql_injection_table_name
|
||||
|
||||
# region test_sql_injection_schema_name [TYPE Function]
|
||||
# @PURPOSE: Invalid schema name with DROP is rejected with error.
|
||||
def test_sql_injection_schema_name(self, build_request) -> None:
|
||||
"""Невалидное имя схемы с DROP — ошибка."""
|
||||
req = build_request(target_schema="public; DROP TABLE students; --")
|
||||
config_manager = MagicMock()
|
||||
|
||||
@@ -458,23 +428,19 @@ class TestValidateTargetTableSchema:
|
||||
assert "Invalid target schema name" in response.error
|
||||
assert response.all_present is False
|
||||
|
||||
# endregion test_sql_injection_schema_name
|
||||
|
||||
# region test_table_not_exists [TYPE Function]
|
||||
# @PURPOSE: Target table does not exist (empty data from information_schema).
|
||||
@patch("src.plugins.translate.service_target_schema.SupersetSqlLabExecutor")
|
||||
def test_table_not_exists(self, mock_executor, build_request) -> None:
|
||||
"""Таблица не существует — пустой data."""
|
||||
req = build_request()
|
||||
config_manager = MagicMock()
|
||||
|
||||
instance = mock_executor.return_value
|
||||
instance.resolve_database_id.return_value = None
|
||||
instance.get_database_backend.return_value = "postgresql"
|
||||
instance.execute_and_poll.return_value = {
|
||||
"status": "success",
|
||||
"raw_response": {
|
||||
"columns": [
|
||||
{"name": "id", "type": "integer"},
|
||||
],
|
||||
"columns": [{"name": "column_name"}],
|
||||
"data": [],
|
||||
},
|
||||
}
|
||||
@@ -483,8 +449,6 @@ class TestValidateTargetTableSchema:
|
||||
assert response.table_exists is False
|
||||
assert response.error is None
|
||||
assert response.all_present is False
|
||||
assert len(response.missing_columns) == 3 # translated_text, context, is_original
|
||||
|
||||
# endregion test_table_not_exists
|
||||
assert len(response.missing_columns) == 4 # id, translated_text, context, is_original
|
||||
# endregion TestValidateTargetTableSchema
|
||||
# endregion TargetSchemaValidationTests
|
||||
|
||||
186
backend/src/plugins/translate/__tests__/test_text_cleaner.py
Normal file
186
backend/src/plugins/translate/__tests__/test_text_cleaner.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# #region TestTextCleaner [C:3] [TYPE Module] [SEMANTICS test, text, cleaner, whitespace, truncation]
|
||||
# @BRIEF Verify text cleaning contracts — normalize_whitespace, truncate_text, clean_text.
|
||||
# @RELATION BINDS_TO -> [_text_cleaner:Module]
|
||||
# @TEST_EDGE: empty_string — empty/whitespace-only input returns ""
|
||||
# @TEST_EDGE: whitespace_variants — multiple spaces, newlines, tabs all collapse to single space
|
||||
# @TEST_EDGE: truncation_boundary — text shorter than, equal to, and longer than max_length
|
||||
# @TEST_EDGE: clean_combined — clean_text correctly chains normalize + truncate
|
||||
# @TEST_EDGE: zero_max_length — max_length=0 forces truncation on any non-empty text
|
||||
|
||||
from src.plugins.translate._text_cleaner import normalize_whitespace, truncate_text, clean_text
|
||||
|
||||
|
||||
# region TestNormalizeWhitespace [TYPE Class]
|
||||
# @BRIEF Test suite for normalize_whitespace.
|
||||
class TestNormalizeWhitespace:
|
||||
|
||||
# region test_empty_string [TYPE Function]
|
||||
# @BRIEF Empty string returns empty string.
|
||||
def test_empty_string(self):
|
||||
assert normalize_whitespace("") == ""
|
||||
# endregion test_empty_string
|
||||
|
||||
# region test_whitespace_only [TYPE Function]
|
||||
# @BRIEF String with only whitespace returns empty string.
|
||||
def test_whitespace_only(self):
|
||||
assert normalize_whitespace(" ") == ""
|
||||
assert normalize_whitespace("\t") == ""
|
||||
assert normalize_whitespace("\n") == ""
|
||||
# endregion test_whitespace_only
|
||||
|
||||
# region test_multiple_spaces [TYPE Function]
|
||||
# @BRIEF Multiple spaces between words are collapsed to single space.
|
||||
def test_multiple_spaces(self):
|
||||
assert normalize_whitespace("hello world") == "hello world"
|
||||
# endregion test_multiple_spaces
|
||||
|
||||
# region test_multiple_newlines [TYPE Function]
|
||||
# @BRIEF Multiple newlines are collapsed to single space.
|
||||
def test_multiple_newlines(self):
|
||||
assert normalize_whitespace("hello\n\n\nworld") == "hello world"
|
||||
# endregion test_multiple_newlines
|
||||
|
||||
# region test_mixed_whitespace [TYPE Function]
|
||||
# @BRIEF Tabs, newlines, and spaces all collapse to single space.
|
||||
def test_mixed_whitespace(self):
|
||||
assert normalize_whitespace("hello\t \n world") == "hello world"
|
||||
# endregion test_mixed_whitespace
|
||||
|
||||
# region test_leading_trailing_whitespace [TYPE Function]
|
||||
# @BRIEF Leading and trailing whitespace is trimmed.
|
||||
def test_leading_trailing_whitespace(self):
|
||||
assert normalize_whitespace(" hello world ") == "hello world"
|
||||
assert normalize_whitespace("\nhello world\n") == "hello world"
|
||||
# endregion test_leading_trailing_whitespace
|
||||
|
||||
# region test_already_clean [TYPE Function]
|
||||
# @BRIEF Already clean text is returned unchanged.
|
||||
def test_already_clean(self):
|
||||
assert normalize_whitespace("hello world") == "hello world"
|
||||
# endregion test_already_clean
|
||||
|
||||
# region test_single_word [TYPE Function]
|
||||
# @BRIEF Single word with surrounding whitespace is trimmed to the word.
|
||||
def test_single_word(self):
|
||||
assert normalize_whitespace(" hello ") == "hello"
|
||||
# endregion test_single_word
|
||||
|
||||
# endregion TestNormalizeWhitespace
|
||||
|
||||
|
||||
# region TestTruncateText [TYPE Class]
|
||||
# @BRIEF Test suite for truncate_text.
|
||||
class TestTruncateText:
|
||||
|
||||
# region test_empty_string [TYPE Function]
|
||||
# @BRIEF Empty string stays empty regardless of max_length.
|
||||
def test_empty_string(self):
|
||||
assert truncate_text("") == ""
|
||||
assert truncate_text("", max_length=0) == ""
|
||||
# endregion test_empty_string
|
||||
|
||||
# region test_shorter_than_max [TYPE Function]
|
||||
# @BRIEF Text shorter than max_length is returned unchanged.
|
||||
def test_shorter_than_max(self):
|
||||
assert truncate_text("hi", max_length=5) == "hi"
|
||||
# endregion test_shorter_than_max
|
||||
|
||||
# region test_exactly_at_max [TYPE Function]
|
||||
# @BRIEF Text exactly at max_length is returned unchanged (no "..." appended).
|
||||
def test_exactly_at_max(self):
|
||||
assert truncate_text("hello", max_length=5) == "hello"
|
||||
# endregion test_exactly_at_max
|
||||
|
||||
# region test_longer_than_max [TYPE Function]
|
||||
# @BRIEF Text longer than max_length is truncated with "..." appended.
|
||||
def test_longer_than_max(self):
|
||||
result = truncate_text("hello world", max_length=5)
|
||||
assert result == "hello..."
|
||||
assert len(result) == 8 # 5 chars + "..."
|
||||
# endregion test_longer_than_max
|
||||
|
||||
# region test_zero_max_length [TYPE Function]
|
||||
# @BRIEF max_length=0 truncates any non-empty text to "...".
|
||||
def test_zero_max_length(self):
|
||||
assert truncate_text("hello", max_length=0) == "..."
|
||||
# endregion test_zero_max_length
|
||||
|
||||
# region test_default_max_length [TYPE Function]
|
||||
# @BRIEF Default max_length of 500 does not truncate short text.
|
||||
def test_default_max_length(self):
|
||||
text = "x" * 100
|
||||
assert truncate_text(text) == text
|
||||
# endregion test_default_max_length
|
||||
|
||||
# region test_truncation_at_default_boundary [TYPE Function]
|
||||
# @BRIEF Text exactly 500 chars is unchanged; 501 chars is truncated.
|
||||
def test_truncation_at_default_boundary(self):
|
||||
text_500 = "x" * 500
|
||||
text_501 = "x" * 501
|
||||
assert truncate_text(text_500) == text_500
|
||||
result = truncate_text(text_501)
|
||||
assert result == "x" * 500 + "..."
|
||||
assert len(result) == 503
|
||||
# endregion test_truncation_at_default_boundary
|
||||
|
||||
# endregion TestTruncateText
|
||||
|
||||
|
||||
# region TestCleanText [TYPE Class]
|
||||
# @BRIEF Test suite for clean_text (combined normalize + truncate).
|
||||
class TestCleanText:
|
||||
|
||||
# region test_empty_string [TYPE Function]
|
||||
# @BRIEF Empty string returns empty string.
|
||||
def test_empty_string(self):
|
||||
assert clean_text("") == ""
|
||||
# endregion test_empty_string
|
||||
|
||||
# region test_whitespace_only [TYPE Function]
|
||||
# @BRIEF Whitespace-only input returns empty string.
|
||||
def test_whitespace_only(self):
|
||||
assert clean_text(" \n \t ") == ""
|
||||
# endregion test_whitespace_only
|
||||
|
||||
# region test_clean_and_short [TYPE Function]
|
||||
# @BRIEF Already clean, short text is returned unchanged.
|
||||
def test_clean_and_short(self):
|
||||
assert clean_text("hello world") == "hello world"
|
||||
# endregion test_clean_and_short
|
||||
|
||||
# region test_normalize_then_truncate [TYPE Function]
|
||||
# @BRIEF Whitespace is normalized before truncation — counting clean chars.
|
||||
def test_normalize_then_truncate(self):
|
||||
# "hello world" has 15 chars raw, normalized to "hello world" (11 chars)
|
||||
# With max_length=5, should truncate normalized text to "hello..."
|
||||
result = clean_text("hello world", max_length=5)
|
||||
assert result == "hello..."
|
||||
# endregion test_normalize_then_truncate
|
||||
|
||||
# region test_normalize_keeps_text_under_limit [TYPE Function]
|
||||
# @BRIEF Normalization reduces length enough that truncation is not needed.
|
||||
def test_normalize_keeps_text_under_limit(self):
|
||||
# "A B" has 4 chars raw, normalizes to "A B" (3 chars), still under 5
|
||||
result = clean_text("A B", max_length=5)
|
||||
assert result == "A B"
|
||||
# endregion test_normalize_keeps_text_under_limit
|
||||
|
||||
# region test_leading_whitespace_then_truncate [TYPE Function]
|
||||
# @BRIEF Leading whitespace is removed before truncation, avoiding wasted chars.
|
||||
def test_leading_whitespace_then_truncate(self):
|
||||
# " hello world" normalizes to "hello world" (11 chars)
|
||||
# With max_length=5: truncates to "hello..."
|
||||
result = clean_text(" hello world", max_length=5)
|
||||
assert result == "hello..."
|
||||
# endregion test_leading_whitespace_then_truncate
|
||||
|
||||
# region test_default_max_length_passthrough [TYPE Function]
|
||||
# @BRIEF With default max_length=500, text under 500 after normalize is unchanged.
|
||||
def test_default_max_length_passthrough(self):
|
||||
text = " hello world "
|
||||
expected = "hello world"
|
||||
assert clean_text(text) == expected
|
||||
# endregion test_default_max_length_passthrough
|
||||
|
||||
# endregion TestCleanText
|
||||
# #endregion TestTextCleaner
|
||||
@@ -11,8 +11,15 @@
|
||||
# langdetect — only 55 languages, less accurate on short texts.
|
||||
# fasttext — requires native compilation, no pre-built wheel for this platform.
|
||||
|
||||
import re
|
||||
|
||||
from lingua import Language, LanguageDetector, LanguageDetectorBuilder
|
||||
|
||||
# Cyrillic character range (Basic + Supplement)
|
||||
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF\u0500-\u052F]")
|
||||
# ASCII letter range (Latin-based)
|
||||
_ASCII_LETTER_RE = re.compile(r"[a-zA-Z]")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build BCP-47 → lingua Language mapping from the lingua Language enum
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -128,10 +135,53 @@ def detect_language(text: str, detector: LanguageDetector) -> str:
|
||||
# #endregion detect_language
|
||||
|
||||
|
||||
# #region _character_block_fallback [C:2] [TYPE Function] [SEMANTICS language, heuristic, cyrillic]
|
||||
# @BRIEF When lingua returns "und", check if character-block dominance (e.g. Cyrillic)
|
||||
# suggests a match to one of the target languages. Catches texts with many numbers
|
||||
# and punctuation where n-gram-based detection is unreliable.
|
||||
# @RATIONALE Russian texts with numbers/punctuation (e.g. "По договору поставки. в объёме
|
||||
# 67 890 123,45 руб..") frequently get "und" from lingua because digits dilute
|
||||
# the n-gram signal. A simple letter-block ratio catches these reliably.
|
||||
# @REJECTED Relying solely on lingua for all cases — too many false "und" on number-heavy texts.
|
||||
# Using LLM for re-detection — defeats the purpose of local detection.
|
||||
def _character_block_fallback(
|
||||
texts: list[str],
|
||||
results: list[str],
|
||||
target_languages: list[str] | None,
|
||||
) -> list[str]:
|
||||
"""Apply character-block heuristics for 'und' results.
|
||||
|
||||
For each 'und' result, if the target list includes a Cyrillic language (ru/uk/be)
|
||||
and the text is Cyrillic-dominant (≥35% of letter chars), assign that language.
|
||||
"""
|
||||
if not target_languages:
|
||||
return results
|
||||
|
||||
tls_lower = [t.lower() for t in target_languages]
|
||||
cyrillic_targets = [t for t in tls_lower if t in ("ru", "uk", "be")]
|
||||
if not cyrillic_targets:
|
||||
return results # no Cyrillic target → nothing to do
|
||||
|
||||
for i, (text, result) in enumerate(zip(texts, results)):
|
||||
if result != "und" or not text:
|
||||
continue
|
||||
|
||||
ascii_letters = len(_ASCII_LETTER_RE.findall(text))
|
||||
cyrillic_letters = len(_CYRILLIC_RE.findall(text))
|
||||
total_letters = ascii_letters + cyrillic_letters
|
||||
|
||||
if total_letters > 0 and cyrillic_letters / total_letters >= 0.35:
|
||||
results[i] = cyrillic_targets[0]
|
||||
|
||||
return results
|
||||
# #endregion _character_block_fallback
|
||||
|
||||
|
||||
# #region batch_detect [C:3] [TYPE Function] [SEMANTICS language, detection, batch]
|
||||
# @BRIEF Detect language for multiple texts in batch. Builds detector if not provided.
|
||||
# @RELATION DEPENDS_ON -> [detect_language]
|
||||
# @RELATION DEPENDS_ON -> [get_detector]
|
||||
# @RELATION DEPENDS_ON -> [_character_block_fallback]
|
||||
def batch_detect(
|
||||
texts: list[str],
|
||||
target_languages: list[str] | None = None,
|
||||
@@ -150,6 +200,8 @@ def batch_detect(
|
||||
if detector is None:
|
||||
detector = get_detector(target_languages)
|
||||
|
||||
return [detect_language(t, detector) for t in texts]
|
||||
results = [detect_language(t, detector) for t in texts]
|
||||
results = _character_block_fallback(texts, results, target_languages)
|
||||
return results
|
||||
# #endregion batch_detect
|
||||
# #endregion LanguageDetectService
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...models.translate import TranslationJob, TranslationLanguage, TranslationRecord
|
||||
from ...models.translate import TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord
|
||||
from ...services.llm_prompt_templates import render_prompt
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ._llm_http import call_openai_compatible
|
||||
@@ -148,8 +149,42 @@ class LLMTranslationService:
|
||||
mid = len(batch_rows) // 2
|
||||
logger.explore("LLM output truncated — splitting batch",
|
||||
{"batch_id": batch_id, "batch_size": len(batch_rows), "split_at": mid, "depth": depth})
|
||||
left = self.call_llm_for_batch(job, run_id, batch_rows[:mid], dict_matches, batch_id + "_L", max_tokens, depth + 1)
|
||||
right = self.call_llm_for_batch(job, run_id, batch_rows[mid:], dict_matches, batch_id + "_R", max_tokens, depth + 1)
|
||||
|
||||
# Create proper TranslationBatch rows for child halves (fixes FK violation
|
||||
# where _L/_R string suffixes referenced non-existent translation_batches rows).
|
||||
# @RATIONALE Previous code appended "_L"/"_R" to batch_id string, violating
|
||||
# FK translation_records.batch_id → translation_batches.id.
|
||||
# @REJECTED Continuing with string-suffixed batch_ids — causes FK violation
|
||||
# when any child record is flushed.
|
||||
left_batch = TranslationBatch(
|
||||
id=str(uuid.uuid4()), run_id=run_id, batch_index=-1,
|
||||
status="RUNNING", total_records=len(batch_rows[:mid]),
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
right_batch = TranslationBatch(
|
||||
id=str(uuid.uuid4()), run_id=run_id, batch_index=-1,
|
||||
status="RUNNING", total_records=len(batch_rows[mid:]),
|
||||
started_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add_all([left_batch, right_batch])
|
||||
self.db.flush()
|
||||
|
||||
left = self.call_llm_for_batch(job, run_id, batch_rows[:mid], dict_matches,
|
||||
left_batch.id, max_tokens, depth + 1)
|
||||
right = self.call_llm_for_batch(job, run_id, batch_rows[mid:], dict_matches,
|
||||
right_batch.id, max_tokens, depth + 1)
|
||||
|
||||
# Finalise child batch stats
|
||||
left_batch.completed_at = datetime.now(UTC)
|
||||
right_batch.completed_at = datetime.now(UTC)
|
||||
left_batch.successful_records = left["successful"]
|
||||
right_batch.successful_records = right["successful"]
|
||||
left_batch.failed_records = left["failed"]
|
||||
right_batch.failed_records = right["failed"]
|
||||
left_batch.status = "COMPLETED" if left["failed"] == 0 else "COMPLETED_WITH_ERRORS"
|
||||
right_batch.status = "COMPLETED" if right["failed"] == 0 else "COMPLETED_WITH_ERRORS"
|
||||
self.db.flush()
|
||||
|
||||
return {"successful": left["successful"] + right["successful"],
|
||||
"failed": left["failed"] + right["failed"],
|
||||
"skipped": left["skipped"] + right["skipped"],
|
||||
|
||||
63
backend/src/plugins/translate/_text_cleaner.py
Normal file
63
backend/src/plugins/translate/_text_cleaner.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# #region TextCleaner [C:3] [TYPE Module] [SEMANTICS translate, text, cleaning, whitespace, truncation]
|
||||
# @BRIEF Text cleaning utilities for the translation pipeline — whitespace normalization
|
||||
# and character-length truncation. Used as a preprocessing step before LLM calls.
|
||||
# @LAYER Domain
|
||||
# @REJECTED Doing normalization inside the LLM prompt — wastes tokens and produces
|
||||
# inconsistent results. Preprocessing text before the LLM call is more reliable.
|
||||
# @RATIONALE Centralizes text preprocessing logic used across the translation pipeline.
|
||||
# Normalizing whitespace before truncation ensures accurate character counts.
|
||||
|
||||
# #region normalize_whitespace [C:2] [TYPE Function] [SEMANTICS text, whitespace, normalize]
|
||||
# @BRIEF Collapse multiple spaces, tabs, and newlines into single spaces; trim leading/trailing
|
||||
# whitespace. Returns empty string for empty or whitespace-only input.
|
||||
# @PRE text is a string (empty string allowed).
|
||||
# @POST Returns a string with no leading/trailing whitespace and single internal spaces.
|
||||
# @EXAMPLE normalize_whitespace(" hello world ") -> "hello world"
|
||||
# @EXAMPLE normalize_whitespace("") -> ""
|
||||
def normalize_whitespace(text: str) -> str:
|
||||
"""Collapse multiple spaces/newlines, trim, return clean text.
|
||||
|
||||
All Unicode whitespace characters (spaces, tabs, newlines, etc.) are
|
||||
collapsed into single ASCII spaces. Leading/trailing whitespace is removed.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
return " ".join(text.split())
|
||||
# #endregion normalize_whitespace
|
||||
|
||||
|
||||
# #region truncate_text [C:2] [TYPE Function] [SEMANTICS text, truncation, length]
|
||||
# @BRIEF Truncate text to max_length characters, appending "..." if truncation occurs.
|
||||
# @PRE text is a string. max_length >= 0.
|
||||
# @POST When len(text) <= max_length, returns text unchanged.
|
||||
# When len(text) > max_length, returns text[:max_length] + "...".
|
||||
# @EXAMPLE truncate_text("hello world", 5) -> "hello..."
|
||||
# @EXAMPLE truncate_text("hi", 5) -> "hi"
|
||||
def truncate_text(text: str, max_length: int = 500) -> str:
|
||||
"""Truncate text to max_length characters, adding '...' if truncated.
|
||||
|
||||
If the text is shorter than or equal to max_length, it is returned unchanged.
|
||||
If it exceeds max_length, it is cut at max_length and '...' is appended.
|
||||
"""
|
||||
if len(text) > max_length:
|
||||
return text[:max_length] + "..."
|
||||
return text
|
||||
# #endregion truncate_text
|
||||
|
||||
|
||||
# #region clean_text [C:2] [TYPE Function] [SEMANTICS text, clean, normalize, truncate]
|
||||
# @BRIEF Combine whitespace normalization and truncation into one step.
|
||||
# Applies normalize_whitespace first, then truncate_text.
|
||||
# @PRE text is a string. max_length >= 0.
|
||||
# @POST Returns normalized and optionally truncated text.
|
||||
# @RELATION CALLS -> [normalize_whitespace]
|
||||
# @RELATION CALLS -> [truncate_text]
|
||||
# @EXAMPLE clean_text(" hello world ", 5) -> "hello..." (normalized to "hello world",
|
||||
# then truncated since "hello world" has 11 chars > 5)
|
||||
def clean_text(text: str, max_length: int = 500) -> str:
|
||||
"""Normalize whitespace then truncate to max_length."""
|
||||
normalized = normalize_whitespace(text)
|
||||
return truncate_text(normalized, max_length)
|
||||
# #endregion clean_text
|
||||
|
||||
# #endregion TextCleaner
|
||||
@@ -1,4 +1,4 @@
|
||||
# #region TargetSchemaValidation [C:3] [TYPE Module] [SEMANTICS translate, schema, validation, target-table]
|
||||
# #region TargetSchemaValidation [C:4] [TYPE Module] [SEMANTICS translate, schema, validation, target-table]
|
||||
# @BRIEF Проверка схемы целевой таблицы: запрос колонок через Superset SQL Lab,
|
||||
# сравнение с ожидаемыми (из build_columns), возврат diff.
|
||||
# @LAYER Service
|
||||
@@ -8,15 +8,13 @@
|
||||
# @PRE Superset окружение доступно, target_database_id валиден.
|
||||
# @POST Возвращает актуальные, ожидаемые, отсутствующие и лишние колонки.
|
||||
# @SIDE_EFFECT Выполняет SQL-запрос через Superset SQL Lab.
|
||||
# @INVARIANT Если таблица не существует — table_exists=False без ошибки.
|
||||
# @INVARIANT Если Superset недоступен — error с описанием, all_present=False.
|
||||
# @INVARIANT Логика ожидаемых колонок sync с build_columns() в orchestrator_sql_rows.py.
|
||||
# @RATIONALE C4 — оркестрация: вызов executor, парсинг ответа, diff и построение ответа.
|
||||
# #endregion TargetSchemaValidation
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ...core.logger import belief_scope, logger
|
||||
from ...schemas.translate import (
|
||||
TargetSchemaColumnInfo,
|
||||
TargetSchemaValidationRequest,
|
||||
@@ -25,48 +23,34 @@ from ...schemas.translate import (
|
||||
from ...core.config_manager import ConfigManager
|
||||
from .superset_executor import SupersetSqlLabExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TABLE_NAME_RE = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
|
||||
|
||||
|
||||
# #region _build_expected_columns [C:2] [TYPE Function]
|
||||
# @BRIEF Собирает список ожидаемых колонок по конфигурации column mapping.
|
||||
# Логика идентична build_columns() из orchestrator_sql_rows.py,
|
||||
# но не требует объекта TranslationJob.
|
||||
def _build_expected_columns(req: TargetSchemaValidationRequest) -> list[TargetSchemaColumnInfo]:
|
||||
"""
|
||||
Определяет, какие колонки INSERT будет пытаться заполнить,
|
||||
на основе конфигурации column mapping.
|
||||
"""
|
||||
"""Определяет, какие колонки INSERT будет пытаться заполнить."""
|
||||
names: list[str] = []
|
||||
|
||||
# Ключевые колонки (user-defined, для upsert matching)
|
||||
if req.target_key_cols:
|
||||
names.extend(req.target_key_cols)
|
||||
|
||||
# Целевая колонка для переведённого текста
|
||||
effective_target = req.target_column or req.translation_column
|
||||
if effective_target:
|
||||
names.append(effective_target)
|
||||
|
||||
# Колонка с кодом языка перевода
|
||||
if req.target_language_column:
|
||||
names.append(req.target_language_column)
|
||||
|
||||
# Колонка с исходным текстом (source reference)
|
||||
if req.target_source_column:
|
||||
names.append(req.target_source_column)
|
||||
|
||||
# Колонка с определённым исходным языком
|
||||
if req.target_source_language_column:
|
||||
names.append(req.target_source_language_column)
|
||||
|
||||
# Служебные колонки — всегда
|
||||
names.append("context")
|
||||
names.append("is_original")
|
||||
|
||||
# Дедупликация с сохранением порядка
|
||||
seen: set[str] = set()
|
||||
deduped: list[str] = []
|
||||
for c in names:
|
||||
@@ -78,192 +62,264 @@ def _build_expected_columns(req: TargetSchemaValidationRequest) -> list[TargetSc
|
||||
# #endregion _build_expected_columns
|
||||
|
||||
|
||||
# #region _parse_column_results [C:2] [TYPE Function]
|
||||
# @BRIEF Извлекает информацию о колонках из ответа Superset SQL Lab.
|
||||
# #region _extract_columns_from_rows [C:2] [TYPE Function]
|
||||
# @BRIEF Извлекает список колонок таблицы из data-строк результата SQL Lab.
|
||||
#
|
||||
# Superset SQL Lab возвращает разные форматы в зависимости от версии.
|
||||
# Эта функция ищет колонки во всех возможных местах ответа.
|
||||
def _parse_column_results(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
||||
# Для PostgreSQL (information_schema.columns) каждая строка содержит:
|
||||
# {"column_name": "id", "data_type": "integer", "is_nullable": "YES"}
|
||||
#
|
||||
# Для ClickHouse (DESCRIBE TABLE) каждая строка содержит:
|
||||
# {"name": "id", "type": "Int32"}
|
||||
#
|
||||
# Функция ищет имя колонки по нескольким возможным ключам и возвращает
|
||||
# унифицированный список TargetSchemaColumnInfo.
|
||||
def _extract_columns_from_rows(data_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Парсит результат SQL Lab запроса для извлечения списка колонок таблицы.
|
||||
Returns (columns_info, table_exists).
|
||||
Извлекает информацию о колонках таблицы из data-строк SQL Lab ответа.
|
||||
"""
|
||||
columns_info: list[dict[str, Any]] = []
|
||||
|
||||
for row in data_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
|
||||
# Имя колонки: ищем по всем возможным ключам (PG, CH, разные версии Superset)
|
||||
col_name = (
|
||||
row.get("column_name") # PostgreSQL information_schema
|
||||
or row.get("name") # ClickHouse DESCRIBE TABLE / MySQL SHOW COLUMNS
|
||||
or row.get("Field") # MySQL SHOW COLUMNS
|
||||
)
|
||||
if not col_name:
|
||||
continue
|
||||
|
||||
# Тип данных
|
||||
col_type = (
|
||||
row.get("data_type") # PG information_schema
|
||||
or row.get("type") # ClickHouse DESCRIBE TABLE
|
||||
or row.get("Type") # MySQL SHOW COLUMNS
|
||||
)
|
||||
|
||||
# Nullable
|
||||
is_nullable = row.get("is_nullable") # PG
|
||||
if isinstance(is_nullable, str):
|
||||
is_nullable = is_nullable.upper() == "YES"
|
||||
elif not isinstance(is_nullable, bool):
|
||||
is_nullable = True # ClickHouse не возвращает is_nullable, считаем True
|
||||
|
||||
columns_info.append({
|
||||
"name": str(col_name),
|
||||
"type": str(col_type) if col_type else None,
|
||||
"is_nullable": is_nullable,
|
||||
})
|
||||
|
||||
return columns_info
|
||||
# #endregion _extract_columns_from_rows
|
||||
|
||||
|
||||
# #region _parse_sqllab_result [C:2] [TYPE Function]
|
||||
# @BRIEF Извлекает data-строки из ответа Superset SQL Lab.
|
||||
# Поддерживает 3 формата: sync mode, async polling, get_query_results.
|
||||
def _parse_sqllab_result(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""
|
||||
Извлекает строки данных из ответа SQL Lab.
|
||||
Returns (data_rows, table_exists).
|
||||
"""
|
||||
if not result:
|
||||
return [], False
|
||||
|
||||
raw = result.get("raw_response", result)
|
||||
if not raw:
|
||||
return [], False
|
||||
|
||||
# Пробуем извлечь данные из разных форматов ответа Superset
|
||||
columns_data = None
|
||||
data_rows = None
|
||||
|
||||
# Формат 1: прямой ответ с колонками (sync mode)
|
||||
if "columns" in raw and isinstance(raw["columns"], list):
|
||||
columns_data = raw["columns"]
|
||||
data_rows = raw.get("data") if isinstance(raw.get("data"), list) else None
|
||||
# Формат 1: прямой ответ с data
|
||||
if "data" in raw and isinstance(raw["data"], list):
|
||||
data_rows = raw["data"]
|
||||
|
||||
# Формат 2: вложенный result (async polling)
|
||||
elif "result" in raw and isinstance(raw["result"], dict):
|
||||
res = raw["result"]
|
||||
if "columns" in res:
|
||||
columns_data = res["columns"]
|
||||
data_rows = res.get("data") if isinstance(res.get("data"), list) else None
|
||||
|
||||
# Формат 3: ключ results (get_query_results)
|
||||
elif "results" in raw and isinstance(raw["results"], dict):
|
||||
res = raw["results"]
|
||||
if "columns" in res:
|
||||
columns_data = res["columns"]
|
||||
if "data" in res:
|
||||
if "data" in res and isinstance(res["data"], list):
|
||||
data_rows = res["data"]
|
||||
if isinstance(res.get("result"), dict):
|
||||
elif isinstance(res.get("result"), dict):
|
||||
sub = res["result"]
|
||||
if not columns_data and "columns" in sub:
|
||||
columns_data = sub["columns"]
|
||||
if not data_rows and "data" in sub:
|
||||
data_rows = sub["data"]
|
||||
data_rows = sub.get("data") if isinstance(sub.get("data"), list) else None
|
||||
|
||||
if not columns_data:
|
||||
# Формат 4: results — список строк (execute_and_poll sync mode возвращает так)
|
||||
elif "results" in raw and isinstance(raw["results"], list):
|
||||
data_rows = raw["results"]
|
||||
|
||||
if data_rows is None:
|
||||
return [], False
|
||||
|
||||
# Проверяем существование таблицы: есть ли хотя бы одна строка
|
||||
table_exists = data_rows is not None and len(data_rows) > 0
|
||||
table_exists = len(data_rows) > 0
|
||||
|
||||
# Извлекаем имена и типы колонок из метаданных ответа
|
||||
columns_info: list[dict[str, Any]] = []
|
||||
for col in columns_data:
|
||||
col_name = (
|
||||
col.get("name")
|
||||
or col.get("column_name")
|
||||
or col.get("Column", "")
|
||||
)
|
||||
col_type = (
|
||||
col.get("type")
|
||||
or col.get("data_type")
|
||||
or col.get("Type", "")
|
||||
or col.get("col_type", "")
|
||||
)
|
||||
is_nullable = col.get("is_nullable")
|
||||
# PostgreSQL returns 'YES'/'NO' for is_nullable
|
||||
if isinstance(is_nullable, str):
|
||||
is_nullable = is_nullable.upper() == "YES"
|
||||
# Нормализуем формат строк: если data_rows — список списков, а не словарей
|
||||
# (бывает в некоторых версиях Superset), конвертируем через columns metadata
|
||||
if data_rows and not isinstance(data_rows[0], dict):
|
||||
columns_raw = raw.get("columns") or (raw.get("result") or {}).get("columns") or []
|
||||
col_names = []
|
||||
for c in columns_raw:
|
||||
cn = c.get("name") or c.get("column_name") or ""
|
||||
if cn:
|
||||
col_names.append(cn)
|
||||
if col_names:
|
||||
normalized = []
|
||||
for row in data_rows:
|
||||
if isinstance(row, list) and len(row) == len(col_names):
|
||||
normalized.append(dict(zip(col_names, row)))
|
||||
data_rows = normalized
|
||||
|
||||
if col_name:
|
||||
columns_info.append({
|
||||
"name": str(col_name),
|
||||
"type": str(col_type) if col_type else None,
|
||||
"is_nullable": is_nullable if isinstance(is_nullable, bool) else True,
|
||||
})
|
||||
|
||||
return columns_info, table_exists
|
||||
# #endregion _parse_column_results
|
||||
return data_rows, table_exists
|
||||
# #endregion _parse_sqllab_result
|
||||
|
||||
|
||||
# #region validate_target_table_schema [C:3] [TYPE Function]
|
||||
# #region validate_target_table_schema [C:4] [TYPE Function] [SEMANTICS translate, schema, validate, orchestrate]
|
||||
# @BRIEF Основная функция: проверяет схему целевой таблицы через Superset SQL Lab.
|
||||
# @PRE Superset окружение и target_database_id валидны.
|
||||
# @POST Возвращает TargetSchemaValidationResponse с diff-анализом.
|
||||
# @SIDE_EFFECT Выполняет SQL-запрос к information_schema через Superset SQL Lab API.
|
||||
# @RELATION DEPENDS_ON -> [_build_expected_columns]
|
||||
# @RELATION DEPENDS_ON -> [_extract_columns_from_rows]
|
||||
# @RELATION DEPENDS_ON -> [_parse_sqllab_result]
|
||||
# @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor]
|
||||
def validate_target_table_schema(
|
||||
req: TargetSchemaValidationRequest,
|
||||
config_manager: ConfigManager,
|
||||
) -> TargetSchemaValidationResponse:
|
||||
"""
|
||||
Проверяет схему целевой таблицы:
|
||||
1. Вычисляет ожидаемые колонки (build_columns логика)
|
||||
2. Запрашивает актуальные колонки через Superset SQL Lab
|
||||
(SELECT column_name, data_type, is_nullable FROM information_schema.columns ...)
|
||||
1. Вычисляет ожидаемые колонки
|
||||
2. Запрашивает актуальные через Superset SQL Lab
|
||||
3. Сравнивает и возвращает diff
|
||||
"""
|
||||
# 1. Ожидаемые колонки
|
||||
expected = _build_expected_columns(req)
|
||||
expected_names = {c.name for c in expected}
|
||||
with belief_scope("validate_target_table_schema"):
|
||||
# 1. Ожидаемые колонки
|
||||
expected = _build_expected_columns(req)
|
||||
expected_names = {c.name for c in expected}
|
||||
logger.reason("Built expected columns",
|
||||
extra={"payload": {"count": len(expected), "columns": [c.name for c in expected]}})
|
||||
|
||||
# 2. Валидация имён схемы и таблицы (защита от SQL injection в identifier)
|
||||
if req.target_table and not _TABLE_NAME_RE.match(req.target_table):
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False,
|
||||
error=f"Invalid target table name: '{req.target_table}'. Only alphanumeric and underscore allowed.",
|
||||
expected_columns=expected,
|
||||
actual_columns=[],
|
||||
missing_columns=expected,
|
||||
extra_columns=[],
|
||||
all_present=False,
|
||||
)
|
||||
if req.target_schema and not _TABLE_NAME_RE.match(req.target_schema):
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False,
|
||||
error=f"Invalid target schema name: '{req.target_schema}'. Only alphanumeric and underscore allowed.",
|
||||
expected_columns=expected,
|
||||
actual_columns=[],
|
||||
missing_columns=expected,
|
||||
extra_columns=[],
|
||||
all_present=False,
|
||||
)
|
||||
|
||||
# 3. Запрос через Superset SQL Lab
|
||||
try:
|
||||
executor = SupersetSqlLabExecutor(config_manager, req.environment_id)
|
||||
executor.resolve_database_id(target_database_id=req.target_database_id)
|
||||
|
||||
safe_schema = (req.target_schema or "public").replace("'", "''")
|
||||
safe_table = req.target_table.replace("'", "''")
|
||||
|
||||
sql = (
|
||||
f"SELECT column_name, data_type, is_nullable "
|
||||
f"FROM information_schema.columns "
|
||||
f"WHERE table_schema = '{safe_schema}' "
|
||||
f"AND table_name = '{safe_table}' "
|
||||
f"ORDER BY ordinal_position"
|
||||
)
|
||||
|
||||
result = executor.execute_and_poll(sql=sql, max_polls=15, poll_interval_seconds=1.0)
|
||||
|
||||
status = result.get("status", "")
|
||||
if status == "failed":
|
||||
error_msg = result.get("error_message", "SQL Lab query failed")
|
||||
# 2. Валидация имён (SQL injection protection)
|
||||
if req.target_table and not _TABLE_NAME_RE.match(req.target_table):
|
||||
logger.explore("Invalid target table name rejected",
|
||||
extra={"payload": {"table": req.target_table},
|
||||
"error": "Invalid characters in table name"})
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False,
|
||||
error=f"Superset SQL Lab error: {error_msg}",
|
||||
expected_columns=expected,
|
||||
actual_columns=[],
|
||||
missing_columns=expected,
|
||||
extra_columns=[],
|
||||
all_present=False,
|
||||
error=f"Invalid target table name: '{req.target_table}'. Only alphanumeric and underscore allowed.",
|
||||
expected_columns=expected, actual_columns=[],
|
||||
missing_columns=expected, extra_columns=[], all_present=False,
|
||||
)
|
||||
if req.target_schema and not _TABLE_NAME_RE.match(req.target_schema):
|
||||
logger.explore("Invalid target schema name rejected",
|
||||
extra={"payload": {"schema": req.target_schema},
|
||||
"error": "Invalid characters in schema name"})
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False,
|
||||
error=f"Invalid target schema name: '{req.target_schema}'. Only alphanumeric and underscore allowed.",
|
||||
expected_columns=expected, actual_columns=[],
|
||||
missing_columns=expected, extra_columns=[], all_present=False,
|
||||
)
|
||||
|
||||
raw_columns, table_exists = _parse_column_results(result)
|
||||
# 3. Запрос через Superset SQL Lab
|
||||
target_ref = f"{req.target_schema or 'public'}.{req.target_table}"
|
||||
logger.reason("Querying target table schema",
|
||||
extra={"payload": {"table": target_ref, "env": req.environment_id, "db_id": req.target_database_id}})
|
||||
|
||||
actual_cols = []
|
||||
for col in raw_columns:
|
||||
actual_cols.append(TargetSchemaColumnInfo(
|
||||
name=col["name"],
|
||||
data_type=col.get("type"),
|
||||
is_nullable=col.get("is_nullable", True),
|
||||
))
|
||||
try:
|
||||
executor = SupersetSqlLabExecutor(config_manager, req.environment_id)
|
||||
executor.resolve_database_id(target_database_id=req.target_database_id)
|
||||
backend = executor.get_database_backend() or ""
|
||||
|
||||
actual_names = {c.name for c in actual_cols}
|
||||
safe_schema = (req.target_schema or "public").replace("'", "''")
|
||||
safe_table = req.target_table.replace("'", "''")
|
||||
|
||||
# 3. Diff
|
||||
missing = [col for col in expected if col.name not in actual_names]
|
||||
extra = [col for col in actual_cols if col.name not in expected_names]
|
||||
# ClickHouse использует system.columns, всё остальное — information_schema.columns
|
||||
is_clickhouse = any(kw in backend.lower() for kw in ("clickhouse", "ch"))
|
||||
if is_clickhouse:
|
||||
sql = (
|
||||
f"SELECT name, type, default_expression AS data_default "
|
||||
f"FROM system.columns "
|
||||
f"WHERE database = '{safe_schema}' "
|
||||
f"AND table = '{safe_table}' "
|
||||
f"ORDER BY position"
|
||||
)
|
||||
else:
|
||||
sql = (
|
||||
f"SELECT column_name, data_type, is_nullable "
|
||||
f"FROM information_schema.columns "
|
||||
f"WHERE table_schema = '{safe_schema}' "
|
||||
f"AND table_name = '{safe_table}' "
|
||||
f"ORDER BY ordinal_position"
|
||||
)
|
||||
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=table_exists,
|
||||
expected_columns=expected,
|
||||
actual_columns=actual_cols,
|
||||
missing_columns=missing,
|
||||
extra_columns=extra,
|
||||
all_present=len(missing) == 0,
|
||||
)
|
||||
logger.reason("Executing SQL Lab query",
|
||||
extra={"payload": {"sql": sql[:200], "backend": backend}})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Target schema validation failed: %s", str(e), exc_info=True)
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False,
|
||||
error=f"Failed to validate target schema: {e}",
|
||||
expected_columns=expected,
|
||||
actual_columns=[],
|
||||
missing_columns=expected,
|
||||
extra_columns=[],
|
||||
all_present=False,
|
||||
)
|
||||
result = executor.execute_and_poll(sql=sql, max_polls=15, poll_interval_seconds=1.0)
|
||||
status = result.get("status", "")
|
||||
|
||||
if status == "failed":
|
||||
error_msg = result.get("error_message", "SQL Lab query failed")
|
||||
logger.explore("SQL Lab query failed",
|
||||
extra={"payload": {"table": target_ref}, "error": error_msg})
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False, error=f"Superset SQL Lab error: {error_msg}",
|
||||
expected_columns=expected, actual_columns=[],
|
||||
missing_columns=expected, extra_columns=[], all_present=False,
|
||||
)
|
||||
|
||||
# Парсим data-строки результата (реальные колонки таблицы)
|
||||
data_rows, table_exists = _parse_sqllab_result(result)
|
||||
actual_cols_raw = _extract_columns_from_rows(data_rows)
|
||||
|
||||
actual_cols = []
|
||||
for col in actual_cols_raw:
|
||||
actual_cols.append(TargetSchemaColumnInfo(
|
||||
name=col["name"],
|
||||
data_type=col.get("type"),
|
||||
is_nullable=col.get("is_nullable", True),
|
||||
))
|
||||
|
||||
actual_names = {c.name for c in actual_cols}
|
||||
|
||||
# 4. Diff
|
||||
missing = [col for col in expected if col.name not in actual_names]
|
||||
extra = [col for col in actual_cols if col.name not in expected_names]
|
||||
|
||||
logger.reflect("Schema check complete",
|
||||
extra={"payload": {
|
||||
"table": target_ref, "table_exists": table_exists,
|
||||
"expected": len(expected), "found": len(actual_cols),
|
||||
"missing": len(missing), "extra": len(extra),
|
||||
"all_present": len(missing) == 0,
|
||||
"actual_columns": [c.name for c in actual_cols],
|
||||
}})
|
||||
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=table_exists,
|
||||
expected_columns=expected,
|
||||
actual_columns=actual_cols,
|
||||
missing_columns=missing,
|
||||
extra_columns=extra,
|
||||
all_present=len(missing) == 0,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.explore("Target schema validation failed",
|
||||
extra={"payload": {"table": target_ref, "env": req.environment_id},
|
||||
"error": str(e)})
|
||||
return TargetSchemaValidationResponse(
|
||||
table_exists=False, error=f"Failed to validate target schema: {e}",
|
||||
expected_columns=expected, actual_columns=[],
|
||||
missing_columns=expected, extra_columns=[], all_present=False,
|
||||
)
|
||||
# #endregion validate_target_table_schema
|
||||
# #endregion TargetSchemaValidation
|
||||
|
||||
@@ -75,6 +75,7 @@ async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
base_url="https://example.invalid/v1",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
|
||||
# region _FakeProviderService [TYPE Class]
|
||||
|
||||
@@ -258,3 +258,125 @@ def test_is_masked_or_placeholder():
|
||||
assert is_masked_or_placeholder("...xyz") is True
|
||||
assert is_masked_or_placeholder("sk-real-key-1234") is False
|
||||
assert is_masked_or_placeholder("short") is False
|
||||
|
||||
|
||||
# endregion test_is_masked_or_placeholder
|
||||
|
||||
|
||||
# region test_create_provider_with_multimodal_flag [TYPE Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify provider creation persists the is_multimodal flag.
|
||||
def test_create_provider_with_multimodal_flag(service, mock_db):
|
||||
"""Verify is_multimodal=True is stored during provider creation."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision Provider",
|
||||
base_url="https://api.openai.com",
|
||||
api_key="sk-vision-key",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
|
||||
provider = service.create_provider(config)
|
||||
|
||||
assert provider.is_multimodal is True
|
||||
mock_db.add.assert_called()
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
|
||||
# endregion test_create_provider_with_multimodal_flag
|
||||
|
||||
|
||||
# region test_create_provider_without_multimodal_flag [TYPE Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify is_multimodal defaults to False when not specified.
|
||||
def test_create_provider_without_multimodal_flag(service, mock_db):
|
||||
"""Verify is_multimodal defaults to False."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Text Provider",
|
||||
base_url="https://api.openai.com",
|
||||
api_key="sk-text-key",
|
||||
default_model="gpt-4.1-mini",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
provider = service.create_provider(config)
|
||||
|
||||
assert provider.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_create_provider_without_multimodal_flag
|
||||
|
||||
|
||||
# region test_update_provider_preserves_is_multimodal [TYPE Function]
|
||||
# @RELATION: BINDS_TO -> [test_llm_provider:Module]
|
||||
# @PURPOSE: Verify updating a provider preserves and correctly sets is_multimodal.
|
||||
def test_update_provider_preserves_is_multimodal(service, mock_db):
|
||||
"""Verify is_multimodal is updated correctly."""
|
||||
existing_encrypted = EncryptionManager().encrypt("secret-value")
|
||||
mock_provider = LLMProvider(
|
||||
id="p1",
|
||||
provider_type="openai",
|
||||
name="Vision",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key=existing_encrypted,
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
mock_db.query().filter().first.return_value = mock_provider
|
||||
|
||||
# Update with is_multimodal=False
|
||||
config = LLMProviderConfig(
|
||||
id="p1",
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="********",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=False,
|
||||
)
|
||||
|
||||
updated = service.update_provider("p1", config)
|
||||
|
||||
assert updated.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_update_provider_preserves_is_multimodal
|
||||
|
||||
|
||||
# region test_llm_provider_config_multimodal_default [TYPE Function]
|
||||
# @RELATION: VERIFIES -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig.is_multimodal defaults to False for schema stability.
|
||||
def test_llm_provider_config_multimodal_default():
|
||||
"""Verify default is_multimodal is False in schema."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Default Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4",
|
||||
)
|
||||
assert config.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_llm_provider_config_multimodal_default
|
||||
|
||||
|
||||
# region test_llm_provider_config_multimodal_explicit [TYPE Function]
|
||||
# @RELATION: VERIFIES -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig accepts explicit is_multimodal=True.
|
||||
def test_llm_provider_config_multimodal_explicit():
|
||||
"""Verify setting is_multimodal=True explicitly works."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4o",
|
||||
is_multimodal=True,
|
||||
)
|
||||
assert config.is_multimodal is True
|
||||
|
||||
@@ -131,7 +131,6 @@ def is_multimodal_model(model_name: str, provider_type: str | None = None) -> bo
|
||||
token = (model_name or "").strip().lower()
|
||||
if not token:
|
||||
return False
|
||||
provider = (provider_type or "").strip().lower()
|
||||
text_only_markers = (
|
||||
"text-only",
|
||||
"embedding",
|
||||
@@ -158,9 +157,7 @@ def is_multimodal_model(model_name: str, provider_type: str | None = None) -> bo
|
||||
"qwen-vl",
|
||||
"qwen2-vl",
|
||||
)
|
||||
if any(marker in token for marker in multimodal_markers):
|
||||
return True
|
||||
return False
|
||||
return any(marker in token for marker in multimodal_markers)
|
||||
# #endregion is_multimodal_model
|
||||
|
||||
|
||||
|
||||
@@ -197,6 +197,7 @@ class LLMProviderService:
|
||||
api_key=encrypted_key,
|
||||
default_model=config.default_model,
|
||||
is_active=config.is_active,
|
||||
is_multimodal=config.is_multimodal,
|
||||
)
|
||||
self.db.add(db_provider)
|
||||
self.db.commit()
|
||||
@@ -228,6 +229,7 @@ class LLMProviderService:
|
||||
db_provider.api_key = self.encryption.encrypt(config.api_key)
|
||||
db_provider.default_model = config.default_model
|
||||
db_provider.is_active = config.is_active
|
||||
db_provider.is_multimodal = config.is_multimodal
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_provider)
|
||||
|
||||
Reference in New Issue
Block a user