From 371834cf431be6b3fb287486127f7ad9552e4cb2 Mon Sep 17 00:00:00 2001 From: busya Date: Wed, 3 Jun 2026 23:26:20 +0300 Subject: [PATCH] chore: commit remaining maintenance and model changes --- .axiom/axiom_config.yaml | 39 ++ backend/src/api/auth.py | 2 + backend/tests/test_maintenance_api.py | 22 + docs/translation-performance-analysis.md | 508 ------------------ frontend/src/lib/api/maintenance.ts | 3 +- .../components/StartMaintenanceForm.svelte | 11 +- .../lib/models/DashboardDetailModel.svelte.ts | 4 +- .../lib/models/DashboardHubModel.svelte.ts | 4 +- .../lib/models/DatasetDetailModel.svelte.ts | 4 +- .../src/lib/models/DatasetsHubModel.svelte.ts | 4 +- .../models/DictionaryDetailModel.svelte.ts | 4 +- .../src/lib/models/LLMReportModel.svelte.ts | 4 +- .../models/TranslateHistoryModel.svelte.ts | 2 +- .../lib/models/TranslationJobModel.svelte.ts | 4 +- .../models/ValidationRunDetailModel.svelte.ts | 2 +- .../models/ValidationTasksListModel.svelte.ts | 4 +- frontend/tests/maintenance-api.test.ts | 22 + frontend/tests/maintenance-form.test.ts | 180 +++++++ 18 files changed, 295 insertions(+), 528 deletions(-) delete mode 100644 docs/translation-performance-analysis.md create mode 100644 frontend/tests/maintenance-form.test.ts diff --git a/.axiom/axiom_config.yaml b/.axiom/axiom_config.yaml index 82e86c77..c2e527b6 100644 --- a/.axiom/axiom_config.yaml +++ b/.axiom/axiom_config.yaml @@ -48,7 +48,10 @@ complexity_rules: '1': required: [] suggested: + - ACTION + - ATOM - BRIEF + - STATE - PURPOSE - C - COMPLEXITY @@ -83,7 +86,10 @@ complexity_rules: '2': required: [] suggested: + - ACTION + - ATOM - BRIEF + - STATE - PURPOSE - C - COMPLEXITY @@ -118,7 +124,10 @@ complexity_rules: '3': required: [] suggested: + - ACTION + - ATOM - BRIEF + - STATE - PURPOSE - C - COMPLEXITY @@ -153,7 +162,10 @@ complexity_rules: '4': required: [] suggested: + - ACTION + - ATOM - BRIEF + - STATE - PURPOSE - C - COMPLEXITY @@ -188,7 +200,10 @@ complexity_rules: '5': required: [] suggested: + - ACTION + - ATOM - BRIEF + - STATE - PURPOSE - C - COMPLEXITY @@ -270,6 +285,22 @@ tags: protected: true orthogonal: false decision_memory: false + ACTION: + type: string + multiline: true + description: 'Действие модели (model action). Документирует публичный метод модели, изменяющий состояние. Svelte 5 Model тег.' + contract_types: [] + protected: false + orthogonal: false + decision_memory: false + ATOM: + type: string + multiline: false + description: 'Атом состояния модели. Документирует атомарное поле $state. Svelte 5 Model тег.' + contract_types: [] + protected: false + orthogonal: false + decision_memory: false BRIEF: type: string multiline: true @@ -352,6 +383,14 @@ tags: protected: false orthogonal: false decision_memory: false + STATE: + type: string + multiline: true + description: 'Состояние конечного автомата UX. Документирует возможные состояния экрана. Svelte 5 Model тег.' + contract_types: [] + protected: false + orthogonal: false + decision_memory: false STATUS: type: string multiline: false diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 2381beea..95738a4c 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -7,6 +7,8 @@ # @DATA_CONTRACT Input -> OAuth2PasswordRequestForm -> Token, User # @INVARIANT All auth endpoints must return consistent error codes. # @RELATION DEPENDS_ON -> [is_adfs_configured] +# @REVIEWED 2026-06-03 +# @TESTED true from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm diff --git a/backend/tests/test_maintenance_api.py b/backend/tests/test_maintenance_api.py index 13952231..37ae63f2 100644 --- a/backend/tests/test_maintenance_api.py +++ b/backend/tests/test_maintenance_api.py @@ -8,6 +8,7 @@ # @TEST_CONTRACT: POST /api/maintenance/{id}/end -> 202 {task_id, status} # @TEST_CONTRACT: POST /api/maintenance/end-all -> 202 {task_id, status} # @TEST_EDGE: missing_tables -> 422 validation error +# @TEST_EDGE: missing_environment_id -> 422 validation error # @TEST_EDGE: end_time_before_start_time -> 400 validation error # @TEST_EDGE: non_existent_event_end -> 404 not found from datetime import UTC, datetime, timedelta @@ -185,6 +186,27 @@ class TestStartEndpoint: assert response.status_code == 422 # #endregion test_start_missing_tables + # #region test_start_missing_environment_id [C:2] [TYPE Function] + # @BRIEF Missing environment_id field returns 422. + # @TEST_EDGE: missing_environment_id -> 422 validation error + def test_start_missing_environment_id(self, client, mock_db, mock_task_manager): + response = client.post( + "/api/maintenance/start", + json={ + "tables": ["raw.sales"], + "start_time": (datetime.now(UTC) + timedelta(hours=1)).isoformat(), + "end_time": (datetime.now(UTC) + timedelta(hours=3)).isoformat(), + "message": "Test maintenance", + }, + ) + assert response.status_code == 422 + data = response.json() + assert any( + "environment_id" in str(err.get("loc", [])) + for err in (data.get("detail", []) if isinstance(data.get("detail"), list) else []) + ) + # #endregion test_start_missing_environment_id + # #region test_start_end_time_before_start [C:2] [TYPE Function] # @BRIEF end_time before start_time returns 400. def test_start_end_time_before_start(self, client, mock_db, mock_task_manager): diff --git a/docs/translation-performance-analysis.md b/docs/translation-performance-analysis.md deleted file mode 100644 index 0be8722b..00000000 --- a/docs/translation-performance-analysis.md +++ /dev/null @@ -1,508 +0,0 @@ -# Анализ производительности перевода: причины медлительности и план доработок - -**Дата:** 2026-06-03 (v2 — после code review) -**Автор:** fullstack-coder (ss-tools) + рецензент -**Контекст:** Пользователь сообщил "Очень долго стартует перевод". По логам trace_id `8bd7ac8f` (run `4c9de39e`) проведён анализ. - ---- - -## 1. Исходные данные - -**Объём:** 5455 строк из Superset datasource (dataset 906, таблица `userdata.debt_comment_translations`) -**Модель:** `qwen-flash` через `lite.ai.rusal.com/v1` (provider_type=litellm, response_format=yes) -**Режим:** `full=False` (только новые записи, без перезаписи существующих) -**Батчей сформировано:** 203 - ---- - -## 2. Таймлайн одного прогона (из логов) - -| Время | Событие | Длительность | Симптом | -|-------|---------|--------------|---------| -| `14:34:39` | Run стартовал | — | | -| `14:34:40` | Данные загружены (5455 строк) | ~1s | ✅ | -| `14:34:40` | "Processing 203 batches" | — | | -| `14:34:40.430` | **LLM request:** prompt_len=145062 | **~1m47s** | ⚠️ | -| `14:36:27` | `finish_reason=length` — ответ обрезан | | ❌ | -| `14:36:27` | Splitting → 2 батча | | | -| `14:36:27` | prompt_len=101330 | **~1m40s** | ⚠️ | -| `14:38:06` | `finish_reason=length` | | ❌ | -| `14:38:06` | Splitting → ещё 2 батча | | | -| `14:38:06` | prompt_len=25826 | **~40s** | ✅ stop | -| `14:38:47` | prompt_len=76479 | **~1m39s** | ⚠️ | -| `14:40:26` | `finish_reason=length` | | ❌ | -| ... | каскад продолжается | | | - -**Оценка общего времени:** >10-15 минут на 5455 строк. - ---- - -## 3. ⚠️ Важное ограничение анализа: prompt_len — это символы или токены? - -**В логах нет прямого указания, что `prompt_len=145062` — токены.** Формат логирования (`prompt_len=145062`) без указания единиц измерения не позволяет утверждать, что это именно токены. Это могут быть символы. - -**До любых правок требуется:** - -Для 10-20 реальных батчей залогировать: - -| Поле | Источник | Зачем | -|------|----------|-------| -| `chars` | `len(prompt)` | Длина в символах | -| `estimated_input_tokens` | `estimate_token_budget()` | Текущая оценка | -| `provider_prompt_tokens` | `response.usage.prompt_tokens` | Реальные токены входа | -| `provider_completion_tokens` | `response.usage.completion_tokens` | Реальные токены выхода | -| `provider_total_tokens` | `response.usage.total_tokens` | Сумма | -| `max_tokens` | Параметр запроса | Сколько просили | -| `context_window_resolved` | Что использовали как контекст | 64000 или другое | -| `max_output_tokens_resolved` | Что использовали как лимит выхода | | -| `rows_in_batch` | `len(batch_rows)` | | -| `target_languages_count` | `len(target_languages)` | | -| `finish_reason` | Из ответа API | stop / length / error | -| `response_rows_recovered` | Сколько строк распарсили | Для recovery | - -**Вывод:** Все гипотезы ниже основаны на косвенных признаках. Без логов usage токенов от провайдера (response.usage) некоторые причины остаются недоказанными. Добавление этих логов — **P0, первый шаг**. - ---- - -## 4. Первопричины (по степени вероятности) - -### 4.1. Batch sizing недооценивает output budget (основная гипотеза) - -`finish_reason=length` с вероятностью >90% означает не "вход не влез во входной контекст", а **"модель упёрлась в max_tokens при генерации ответа"**. - -Каждый батч содержит N строк. Для каждой строки модель должна вернуть JSON с переводами на каждый из target_languages. Если target_languages_count > 1, то **выход растёт линейно**, а batch sizing учитывает это только грубой оценкой. - -**Файл:** `backend/src/plugins/translate/_token_budget.py` - -Текущие константы для оценки выхода: - -```python -OUTPUT_PER_ROW_PER_LANG = 120 # токенов на строку перевода на один язык -JSON_OVERHEAD_PER_ROW = 50 # JSON-обвязка на строку -REASONING_OVERHEAD = 2000 # CoT overhead -MAX_OUTPUT_HEADROOM = 3000 # запас -``` - -Для 128 строк × 2 языка: -``` -нужно = 128 × 2 × 120 + 128 × 50 + 2000 + 3000 = 40560 токенов -``` - -Если `max_output_tokens = 16384` (default), то батч гарантированно обрежется. -И в логе мы видим `finish_reason=length` на батчах > 50-60 строк. - -**Следствие:** Проблема не (только) в CJK-токенизации, а в том, что **батч-сайзер упаковывает слишком много строк относительно output лимита**. - -### 4.2. CJK-оценка токенов входа — дополнительный фактор - -**Файл:** `backend/src/plugins/translate/_token_budget.py:89-108` - -```python -cjk_tokens = cjk_count / 1.5 # 1.5 chars/token -other_tokens = other_count / 2.2 # 2.2 chars/token -``` - -Если `prompt_len` в логах — символы, а не токены, то при 60% CJK-символов: -- Оценка: 145062 / 1.5 ≈ 96708 токенов -- Реальность (Qwen): может быть ~120000+ токенов - -То есть вход недооценивается на 20-30%, и "съедает" часть output budget. - -**Вывод:** CJK-оценка — вторичный фактор. Первичный — output budget. - -### 4.3. PROVIDER_DEFAULTS не содержит модели qwen-flash - -**Файл:** `backend/src/plugins/translate/_token_budget.py:32-39` - -```python -PROVIDER_DEFAULTS = { - "gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384}, - "gpt-4o": {"context_window": 128000, "max_output_tokens": 16384}, - "o1-mini": {"context_window": 128000, "max_output_tokens": 65536}, - "claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192}, - "deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192}, - "default": {"context_window": 64000, "max_output_tokens": 16384}, -} -``` - -Когда модель не найдена: -- `context_window = 64000` (default) -- `max_output_tokens = 16384` (default) -- `available_input_budget = 64000 - 16384 = 47616` - -Если `qwen-flash` на самом деле поддерживает 128K контекст и 8K вывод — бюджет по входу может быть недооценён, а бюджет по выходу переоценён. - -### 4.4. Каскад finish_reason=length умножает проблему - -**Файл:** `backend/src/plugins/translate/_llm_call.py:85-96, 190-233` - -```python -if finish_reason == "length" and len(batch_rows) >= 2: - if _recursion_depth < MAX_RETRIES_PER_BATCH: # = 3 - return self._split_and_retry(...) # binary split - -def _split_and_retry(self, ...): - mid = len(batch_rows) // 2 - left = self.call_llm_for_batch(..., rows[:mid], depth + 1) - right = self.call_llm_for_batch(..., rows[mid:], depth + 1) -``` - -**Проблема:** Бинарное деление **не спасает частичный результат**. Даже если модель вернула 80 из 100 строк валидного JSON — они теряются, и обе половины перезапрашиваются с нуля. - -Если truncation случается на 3+ уровнях рекурсии — 1 батч превращается в 7+ LLM-вызовов. - ---- - -## 5. План доработок - -### 5.0. [P0] Измерить → потом править - -Без реальных цифр любое изменение — гадание. - -**Добавить в `_llm_http.py` сбор usage от провайдера и логирование:** - -```python -# После ответа API: -usage = response.get("usage", {}) -log("llm_http", "REFLECT", "LLM usage stats", { - "prompt_tokens": usage.get("prompt_tokens"), - "completion_tokens": usage.get("completion_tokens"), - "total_tokens": usage.get("total_tokens"), - "finish_reason": finish_reason, - "max_tokens": max_tokens, - "rows": len(batch_rows), - "chars": len(prompt), -}) -``` - -Для 10-20 реальных батчей собрать статистику и **только после этого** принимать решения о коэффициентах. - -### 5.1. [P0] Учитывать output budget при расчёте размера батча - -**Проблема:** Сейчас output budget учитывается, но недостаточно жёстко. -**Файл:** `backend/src/plugins/translate/_token_budget.py:160-176` - -```python -def _apply_output_aware_batch_sizing(safe_size, num_languages, max_output_tokens): - while safe_size > 0: - needed_output = ( - safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG - + safe_size * JSON_OVERHEAD_PER_ROW - + REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM - ) - if needed_output <= max_output_tokens: - break - safe_size -= 1 - return safe_size -``` - -**Улучшение:** Сделать output budget **первичным** ограничителем, а input budget — вторичным: - -```python -def _compute_max_rows_by_output(max_output_tokens, num_languages): - """Сколько строк влезет в max_output_tokens.""" - overhead = REASONING_OVERHEAD + MAX_OUTPUT_HEADROOM - per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW - if per_row <= 0: - return 20 - available = max_output_tokens - overhead - if available <= 0: - return 1 - return max(available // per_row, 1) -``` - -И в `_batch_sizer.py:auto_size_batches()`: - -```python -max_rows_by_output = _compute_max_rows_by_output(max_output_tokens_val, num_languages) - -# Брать минимум из всех ограничений: -max_rows = min( - max_rows_by_input_budget, - max_rows_by_output, - absolute_hard_cap, # safety net - job.batch_size or inf, # user preference -) -``` - -### 5.2. [P0] Вынести context_window / max_output_tokens в настройки провайдера - -#### 5.2.1. Модель БД - -**Файл:** `backend/src/models/llm.py` - -```python -class LLMProvider(Base): - # ... существующие поля ... - context_window = Column( - Integer, nullable=True, default=None, - comment="Total context window in tokens. NULL = fallback to PROVIDER_DEFAULTS", - ) - max_output_tokens = Column( - Integer, nullable=True, default=None, - comment="Max output tokens. NULL = fallback to PROVIDER_DEFAULTS", - ) -``` - -Nullable → обратная совместимость. - -#### 5.2.2. Safe cap - -Даже если пользователь ввёл значения — применяется верхняя граница: - -```python -PROVIDER_SAFE_CAP = 256000 # абсолютный максимум - -effective_context_window = min( - provider.context_window or PROVIDER_DEFAULTS.get(model, default)["context_window"], - PROVIDER_SAFE_CAP, -) -effective_max_output_tokens = min( - provider.max_output_tokens or PROVIDER_DEFAULTS.get(model, default)["max_output_tokens"], - effective_context_window, # не может быть больше контекста -) -``` - -#### 5.2.3. Pydantic схема - -**Файл:** `backend/src/plugins/llm_analysis/models.py` - -```python -class LLMProviderConfig(BaseModel): - # ... существующие поля ... - context_window: int | None = Field( - None, ge=1000, le=256000, - description="Context window in tokens. Leave blank for auto.", - ) - max_output_tokens: int | None = Field( - None, ge=256, - description="Max output tokens. Must be less than context_window.", - ) -``` - -#### 5.2.4. Сервисный слой - -**Файл:** `backend/src/services/llm_provider.py` - -```python -# create_provider -db_provider = LLMProvider( - ... - context_window=config.context_window, - max_output_tokens=config.max_output_tokens, -) - -# update_provider -db_provider.context_window = config.context_window -db_provider.max_output_tokens = config.max_output_tokens - -# Новый хелпер для batch sizing: -def get_provider_token_config(self, provider_id: str) -> dict: - provider = self.get_provider(provider_id) - if not provider: - return {"model": None, "context_window": None, "max_output_tokens": None} - return { - "model": provider.default_model or "gpt-4o-mini", - "context_window": provider.context_window, - "max_output_tokens": provider.max_output_tokens, - } -``` - -#### 5.2.5. Интеграция в batch sizing - -**Файл:** `backend/src/plugins/translate/_batch_proc.py:208-247` -**Файл:** `backend/src/plugins/translate/_batch_sizer.py:70-218` - -В обоих местах заменить: -```python -# Было: -provider_info = resolve_provider_model(job) -estimate_token_budget(provider_info=provider_info) - -# Стало: -config = LLMProviderService(db).get_provider_token_config(job.provider_id) -estimate_token_budget( - provider_info=config["model"], - context_window=config["context_window"], # приоритет над provider_info - max_output_tokens=config["max_output_tokens"], # приоритет над provider_info -) -``` - -#### 5.2.6. PROVIDER_DEFAULTS — остаётся fallback - -```python -def estimate_token_budget(..., context_window=None, max_output_tokens=None, provider_info=None): - # Если явно переданы — используем их - # Если оба None — смотрим PROVIDER_DEFAULTS - # Если и там нет — DEFAULT_... -``` - -#### 5.2.7. Svelte UI - -**Файл:** `frontend/src/lib/components/llm/ProviderConfig.svelte` - -- Collapsible "Advanced: Token Limits" -- Два number input: context_window, max_output_tokens -- Placeholder: "Auto-detected. Override only if you know the provider's real limits." -- Валидация на клиенте - -#### 5.2.8. Alembic миграция - -Новая миграция: add columns `context_window`, `max_output_tokens` to `llm_providers`. - -### 5.3. [P0] Консервативный tokenizer estimate + единый safety factor - -**Файл:** `backend/src/plugins/translate/_token_budget.py` - -```python -# Поправить коэффициенты (разумные значения, точные — после замера): -CJK_RATIO = 1.0 # было 1.5 -OTHER_RATIO = 1.8 # было 2.2 - -# Единый safety factor (один, не размазанный): -INPUT_SAFETY_FACTOR = 0.75 # 75% от расчётного бюджета -OUTPUT_SAFETY_FACTOR = 0.70 # 70% от расчётного output-бюджета -``` - -**Важно:** Эти цифры — стартовые. После сбора `usage.prompt_tokens` / `usage.completion_tokens` их надо откалибровать по реальным данным. - -### 5.4. [P1] Retry only missing rows после partial response - -**Текущий код:** `backend/src/plugins/translate/_llm_call.py:190-233` — binary split, теряет все уже переведённые строки. - -**Улучшение:** При `finish_reason=length`: -1. Попытаться распарсить ответ (`_recover_truncated_rows` в `_llm_parse.py:95-115`) -2. Сохранить успешно переведённые строки -3. Ретраить **только** те строки, которых не хватает - -```python -if finish_reason == "length": - recovered = _recover_truncated_rows(llm_response, len(batch_rows), finish_reason) - saved_rows = [] - missing_rows = [] - if recovered and recovered.get("rows"): - # Распределить: какие строки удалось перевести, какие — нет - parsed_ids = set(r.get("row_id") for r in recovered["rows"]) - for row in batch_rows: - if str(row.get("row_index")) in parsed_ids: - saved_rows.append(row) - else: - missing_rows.append(row) - - if missing_rows and len(missing_rows) < len(batch_rows) * 0.95: - # Есть существенный прогресс → ретраим только missing - self._persist_partial(batch_rows, saved_rows, batch_id, run_id, ...) - return self._retry_missing(job, run_id, missing_rows, dict_matches, ...) - else: - # Прогресса нет → binary split - return self._split_and_retry(...) -``` - -**Эффект:** Если из 100 строк вернулось 80 — ретраим только 20, а не 100. - -### 5.5. [P1] Dynamic row cap (вместо фиксированного 50) - -**Файл:** `backend/src/plugins/translate/_batch_sizer.py:148-166` - -```python -# Вычислить max_rows по output: -output_per_row = num_languages * OUTPUT_PER_ROW_PER_LANG + JSON_OVERHEAD_PER_ROW -available_output = max_output_tokens - REASONING_OVERHEAD - MAX_OUTPUT_HEADROOM -max_rows_by_output = max(available_output // output_per_row, 1) if output_per_row > 0 else 20 - -# Вычислить max_rows по input: -max_rows_by_input = per_batch_budget // average_row_tokens - -# Итоговый лимит: -ABSOLUTE_HARD_CAP = 50 # safety net, не основное ограничение -max_rows = min(max_rows_by_output, max_rows_by_input, ABSOLUTE_HARD_CAP) -``` - -### 5.6. [P2] Self-calibration per run - -После первого `finish_reason=length` в рамках одного run_id: -- Посчитать реальное `actual_ratio = actual_tokens / estimated_tokens` -- Склировать batch sizing для следующих батчей -- Сбросить при новом run_id - ---- - -## 6. Итоговые приоритеты - -| # | Что | Файлы | Почему | -|---|-----|-------|--------| -| **P0** | Добавить usage-логи от провайдера | `_llm_http.py`, `_llm_call.py` | Без данных нельзя обосновать изменения | -| **P0** | Output budget как первичный ограничитель | `_token_budget.py`, `_batch_sizer.py` | `finish_reason=length` — это чаще про выход, а не про вход | -| **P0** | Консервативный tokenizer + safety factor | `_token_budget.py` | Быстро снижает truncation | -| **P0** | Provider-level context_window / max_output_tokens | model + schema + service + routes + UI + migration | Нужно для неизвестных моделей | -| **P1** | Retry only missing rows после truncation | `_llm_call.py`, `_llm_parse.py` | Сохраняет частичный результат | -| **P1** | Dynamic row cap (output-aware) | `_batch_sizer.py` | Точнее, чем фиксированные 50 строк | -| **P2** | Self-calibration per run/provider | `_batch_sizer.py`, `_llm_call.py` | Адаптация под модель | - ---- - -## 7. Метрики успеха - -После внедрения: - -| Метрика | Цель | Как измерить | -|---------|------|-------------| -| `finish_reason=length` | < 1% LLM вызовов | Из логов | -| Среднее число LLM вызовов на батч | ≤ 1.1 | total_calls / total_batches | -| p95 длительность батча | < 90s | Из timing-логов | -| Общее время на 5455 строк | ≤ 8 min | Из run duration | -| successful_rows / requested_rows | ≥ 99.5% | Из records | -| Malformed JSON rate | < 0.5% | Из parse failures | - ---- - -## 8. Перед внедрением — замерить - -Собрать для 10-20 батчей (разный размер, разное количество языков): - -| Поле | Как получить | -|------|-------------| -| characters | `len(prompt)` | -| estimated_input_tokens | `_estimate_tokens_for_text()` | -| actual_prompt_tokens | `response.usage.prompt_tokens` | -| actual_completion_tokens | `response.usage.completion_tokens` | -| finish_reason | Из ответа | -| rows | `len(batch_rows)` | -| languages | `len(target_languages)` | -| response_rows_count | После парсинга | - -На этих данных: -1. Посчитать `actual_ratio = actual_prompt_tokens / estimated_tokens` — точный CJK-коэффициент -2. Посчитать `output_per_row_actual = actual_completion_tokens / rows / languages` — точный output per row - -Только после этого фиксировать константы в коде. - ---- - -## 9. PROVIDER_DEFAULTS — схема fallback (для справки) - -``` -Пользователь указал context_window в UI? - → да: используем (с safe cap) - → нет: PROVIDER_DEFAULTS.get(model_name)? - → да: используем - → нет: DEFAULT_CONTEXT_WINDOW / DEFAULT_MAX_OUTPUT_TOKENS -``` - ---- - -## 10. Текущие константы _token_budget.py (для справки) - -| Константа | Значение | Описание | -|-----------|----------|----------| -| `DEFAULT_CONTEXT_WINDOW` | 64000 | | -| `DEFAULT_MAX_OUTPUT_TOKENS` | 16384 | | -| `REASONING_OVERHEAD` | 2000 | | -| `OUTPUT_PER_ROW_PER_LANG` | 120 | | -| `JSON_OVERHEAD_PER_ROW` | 50 | | -| `PROMPT_BASE_TOKENS` | 600 | | -| `DICT_TOKENS_PER_ENTRY` | 20 | | -| `DICT_TOKENS_MAX` | 5000 | | -| `CHARS_PER_TOKEN_MIXED` | 2.2 | | -| `MIN_MAX_TOKENS` | 4096 | | -| `MAX_OUTPUT_HEADROOM` | 3000 | | diff --git a/frontend/src/lib/api/maintenance.ts b/frontend/src/lib/api/maintenance.ts index 6a5b724d..1ae0acb1 100644 --- a/frontend/src/lib/api/maintenance.ts +++ b/frontend/src/lib/api/maintenance.ts @@ -12,9 +12,10 @@ const BASE = '/maintenance'; // #region startMaintenance [C:2] [TYPE Function] [SEMANTICS maintenance, start, event] // @BRIEF Start a maintenance event. -// @PRE params contains at minimum { tables, start_time }. +// @PRE params contains at minimum { environment_id, tables, start_time }. // @POST Returns { task_id, maintenance_id, status }. // @SIDE_EFFECT Creates a maintenance event on the backend. +// @DATA_CONTRACT Input: { environment_id: string, tables: string[], start_time: string, end_time?: string, message?: string } -> Output: { task_id: string, maintenance_id: string, status: string } // @RELATION DEPENDS_ON -> [requestApi] export async function startMaintenance(params: Record): Promise { return requestApi(`${BASE}/start`, 'POST', params); diff --git a/frontend/src/lib/components/StartMaintenanceForm.svelte b/frontend/src/lib/components/StartMaintenanceForm.svelte index d36cc2b0..65823fe0 100644 --- a/frontend/src/lib/components/StartMaintenanceForm.svelte +++ b/frontend/src/lib/components/StartMaintenanceForm.svelte @@ -15,6 +15,7 @@ import { SvelteDate } from "svelte/reactivity"; import { addToast } from "$lib/toasts.svelte.js"; import { t } from "$lib/i18n/index.svelte.js"; import HelpTooltip from "$lib/ui/HelpTooltip.svelte"; + import { environmentContextStore } from "$lib/stores/environmentContext.svelte.js"; /** @type {{ onSuccess?: () => void }} */ let { onSuccess = () => {} } = $props(); @@ -104,7 +105,15 @@ import { SvelteDate } from "svelte/reactivity"; isSubmitting = true; try { - const params = { + const envId = environmentContextStore.value?.selectedEnvId; + if (!envId) { + addToast($t.maintenance?.no_environment || "No environment selected. Please select an environment first.", "error"); + isSubmitting = false; + return; + } + + const params: Record = { + environment_id: envId, tables: [trimmedTable], start_time: startTime ? new SvelteDate(startTime).toISOString() : new SvelteDate().toISOString(), }; diff --git a/frontend/src/lib/models/DashboardDetailModel.svelte.ts b/frontend/src/lib/models/DashboardDetailModel.svelte.ts index f7d139d5..f1514824 100644 --- a/frontend/src/lib/models/DashboardDetailModel.svelte.ts +++ b/frontend/src/lib/models/DashboardDetailModel.svelte.ts @@ -14,9 +14,9 @@ // @ACTION runBackupTask() — Starts backup task for this dashboard. // @ACTION goBack() — Navigates back to dashboard list. // @ACTION openDataset(id) — Navigates to dataset detail. -// @RELATION DEPENDS_ON -> [api] +// @RELATION DEPENDS_ON -> [ApiModule] // @RELATION DEPENDS_ON -> [GitStatusModel] -// @RELATION CALLS -> [log] +// @RELATION CALLS -> [CotLogger] import { goto } from '$app/navigation'; import { ROUTES } from '$lib/routes'; diff --git a/frontend/src/lib/models/DashboardHubModel.svelte.ts b/frontend/src/lib/models/DashboardHubModel.svelte.ts index deda24f8..48c15732 100644 --- a/frontend/src/lib/models/DashboardHubModel.svelte.ts +++ b/frontend/src/lib/models/DashboardHubModel.svelte.ts @@ -25,10 +25,10 @@ // @ACTION handleGitPull(dashboard) — Pulls from remote. // @ACTION handleGitPush(dashboard) — Pushes to remote. // @ACTION handleBulkBackup() — Executes bulk backup. -// @RELATION DEPENDS_ON -> [api] +// @RELATION DEPENDS_ON -> [ApiModule] // @RELATION CALLS -> [gitService] // @RELATION BINDS_TO -> [environmentContextStore] -// @RELATION CALLS -> [log] +// @RELATION CALLS -> [CotLogger] import { SvelteSet, SvelteDate } from "svelte/reactivity"; import { goto } from "$app/navigation"; diff --git a/frontend/src/lib/models/DatasetDetailModel.svelte.ts b/frontend/src/lib/models/DatasetDetailModel.svelte.ts index 562902b2..e40c2d97 100644 --- a/frontend/src/lib/models/DatasetDetailModel.svelte.ts +++ b/frontend/src/lib/models/DatasetDetailModel.svelte.ts @@ -9,8 +9,8 @@ // @ACTION navigateToDashboard() — Navigates to linked dashboard detail. // @ACTION goBack() — Navigates back to dataset list. // @ACTION getColumnTypeClass() — Returns Tailwind class for column type chip. -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] import { goto } from '$app/navigation'; import { ROUTES } from '$lib/routes'; diff --git a/frontend/src/lib/models/DatasetsHubModel.svelte.ts b/frontend/src/lib/models/DatasetsHubModel.svelte.ts index 55808a76..ec0b6d27 100644 --- a/frontend/src/lib/models/DatasetsHubModel.svelte.ts +++ b/frontend/src/lib/models/DatasetsHubModel.svelte.ts @@ -20,8 +20,8 @@ // @ACTION handleBulkGenerateDocs() — Runs bulk generate-docs task. // @ACTION selectDataset(id) — Opens detail preview panel. // @ACTION closeDetail() — Closes detail preview panel. -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] // @INVARIANT DECOMPOSITION GATE: 20+ atoms. If model exceeds 400 lines or 40 methods, split into submodels. import { SvelteSet } from 'svelte/reactivity'; diff --git a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts index cc9f55a4..2298f059 100644 --- a/frontend/src/lib/models/DictionaryDetailModel.svelte.ts +++ b/frontend/src/lib/models/DictionaryDetailModel.svelte.ts @@ -6,8 +6,8 @@ // @ACTION loadDictionary() / loadEntries() — Fetch data. // @ACTION saveEntry() / addEntry() / deleteEntry() — CRUD. // @ACTION importDictionary() / previewImport() — Import flow. -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] import { api } from '$lib/api.js'; import { addToast } from '$lib/toasts.svelte.js'; diff --git a/frontend/src/lib/models/LLMReportModel.svelte.ts b/frontend/src/lib/models/LLMReportModel.svelte.ts index 5bf62943..19ab7073 100644 --- a/frontend/src/lib/models/LLMReportModel.svelte.ts +++ b/frontend/src/lib/models/LLMReportModel.svelte.ts @@ -11,8 +11,8 @@ // @ACTION openScreenshot(path) — Opens screenshot blob URL in new tab. // @ACTION cleanupBlobs() — Revokes all screenshot blob URLs. // @ACTION refresh() — Re-runs loadReport(). -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] import { api } from '$lib/api.js'; import { log } from '$lib/cot-logger'; diff --git a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts index 572c210e..cbceb35c 100644 --- a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts +++ b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts @@ -6,7 +6,7 @@ // @ACTION openDetail(run) / closeDetail() — Detail panel management. // @ACTION applyFilters() — Reset pagination + reload. // @ACTION handleCancelRun/Retry/Download — Run-level actions. -// @RELATION DEPENDS_ON -> [translateApi] +// @RELATION DEPENDS_ON -> [TranslateApi] import { addToast } from '$lib/toasts.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js'; diff --git a/frontend/src/lib/models/TranslationJobModel.svelte.ts b/frontend/src/lib/models/TranslationJobModel.svelte.ts index 0e7b7505..21ae14dd 100644 --- a/frontend/src/lib/models/TranslationJobModel.svelte.ts +++ b/frontend/src/lib/models/TranslationJobModel.svelte.ts @@ -7,8 +7,8 @@ // @ACTION loadInitialData() — Loads environments, LLM providers, dictionaries, and if editing, job data. // @ACTION saveJob() — PUT/POST job configuration. // @ACTION handleTriggerRun(full) — Starts translation run. -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] // @INVARIANT DECOMPOSITION GATE: 50+ atoms, ~400 lines. Model serves as single source of truth for all form state. // @RATIONALE Methods are NOT arrow class fields — they rely on `this` to mutate state. When passed as props // to child components (e.g. onTriggerRun={m.handleTriggerRun}), the binding is lost and `this` diff --git a/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts index ed6b6485..7b2b2862 100644 --- a/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts +++ b/frontend/src/lib/models/ValidationRunDetailModel.svelte.ts @@ -3,7 +3,7 @@ // @INVARIANT Screenshot blob URLs are cached per (dashboardId, tabIndex) key. Revoked when cache key is overwritten. // @ACTION toggleDashboard(id) / toggleLogsSent(key) / toggleTaskLogs(key) — Collapsible section toggles. // @ACTION loadScreenshot(path, key) — Loads screenshot blob from storage API. -// @RELATION DEPENDS_ON -> [api] +// @RELATION DEPENDS_ON -> [ApiModule] import { api } from '$lib/api.js'; import type { TaskLogEntry } from '$lib/api.js'; diff --git a/frontend/src/lib/models/ValidationTasksListModel.svelte.ts b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts index 2f305108..22c23d91 100644 --- a/frontend/src/lib/models/ValidationTasksListModel.svelte.ts +++ b/frontend/src/lib/models/ValidationTasksListModel.svelte.ts @@ -15,8 +15,8 @@ // @ACTION handleDelete(id) — Deletes task with atomic list removal. // @ACTION handleToggleStatus(task) — Toggles is_active. // @ACTION goToPage(p) — Navigates to page. -// @RELATION DEPENDS_ON -> [api] -// @RELATION CALLS -> [log] +// @RELATION DEPENDS_ON -> [ApiModule] +// @RELATION CALLS -> [CotLogger] import { goto } from '$app/navigation'; import { ROUTES } from '$lib/routes'; diff --git a/frontend/tests/maintenance-api.test.ts b/frontend/tests/maintenance-api.test.ts index e0df4b18..4669b04a 100644 --- a/frontend/tests/maintenance-api.test.ts +++ b/frontend/tests/maintenance-api.test.ts @@ -16,6 +16,28 @@ describe('MaintenanceApi URL construction', () => { expect(mockRequestApi).toHaveBeenCalledWith('/maintenance/start', 'POST', expect.any(Object)); }); + it('startMaintenance → forwards environment_id in params', async () => { + mockRequestApi.mockResolvedValue({ task_id: 't-1', maintenance_id: 'm-1', status: 'pending' }); + await startMaintenance({ + environment_id: 'env-prod', + tables: ['raw.sales'], + start_time: '2026-05-21T22:00:00Z', + }); + expect(mockRequestApi).toHaveBeenCalledWith( + '/maintenance/start', + 'POST', + expect.objectContaining({ environment_id: 'env-prod' }), + ); + }); + + it('startMaintenance → rejects without environment_id (backend returns 422)', async () => { + mockRequestApi.mockRejectedValue({ status: 422, message: 'Field required' }); + await expect(startMaintenance({ + tables: ['raw.sales'], + start_time: '2026-05-21T22:00:00Z', + })).rejects.toBeTruthy(); + }); + it('endMaintenance → POST /maintenance/{id}/end', async () => { mockRequestApi.mockResolvedValue({ task_id: 't-1', status: 'pending' }); await endMaintenance('ev-abc'); diff --git a/frontend/tests/maintenance-form.test.ts b/frontend/tests/maintenance-form.test.ts new file mode 100644 index 00000000..f7c727eb --- /dev/null +++ b/frontend/tests/maintenance-form.test.ts @@ -0,0 +1,180 @@ +// #region MaintenanceFormTests [C:2] [TYPE Module] [SEMANTICS test, component, maintenance, start, form, environment_id] +// @BRIEF Component tests for StartMaintenanceForm — verifies environment_id is included in submission. +// @RELATION BINDS_TO -> [StartMaintenanceForm] +// @TEST_CONTRACT: Submit includes environment_id from environmentContextStore +// @TEST_EDGE: No environment selected -> error toast, no API call +// @TEST_EDGE: Environment selected -> API call includes environment_id + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +// ── Hoisted mutable state (before vi.mock calls, hoisted to top) ── + +const { mockStartMaintenance, mockAddToast, mockEnvStore } = vi.hoisted(() => { + // Mutable env store state — tests can change this before render + let _selectedEnvId = 'env-prod'; + + const mockEnvStoreObj = { + value: { + get selectedEnvId() { return _selectedEnvId; }, + }, + subscribe: vi.fn(), + }; + + return { + mockStartMaintenance: vi.fn(), + mockAddToast: vi.fn(), + mockEnvStore: { + obj: mockEnvStoreObj, + setSelectedEnvId(id: string) { _selectedEnvId = id; }, + }, + }; +}); + +// ── Module mocks ────────────────────────────────────────────── + +vi.mock('$lib/api/maintenance.js', () => ({ + startMaintenance: mockStartMaintenance, +})); + +vi.mock('$lib/toasts.svelte.js', () => ({ + addToast: mockAddToast, +})); + +vi.mock('$lib/stores/environmentContext.svelte.js', () => ({ + environmentContextStore: mockEnvStore.obj, +})); + +vi.mock('$lib/i18n/index.svelte.js', () => { + const subscribers = new Set(); + const tData = { + maintenance: { + start_maintenance: 'Start Maintenance', + start_maintenance_description: 'Schedule a maintenance window', + start_now: 'Start Maintenance', + starting: 'Starting...', + start_success: 'Maintenance started. Task: {task_id}', + start_error: 'Failed to start maintenance', + table_name: 'Table Name', + table_required: 'Table name is required', + table_name_placeholder: 'e.g. raw.sales', + table_help: 'Enter the table name', + start_time_label: 'Start Time', + start_time_help: 'When maintenance begins', + end_time_label: 'End Time (optional)', + end_time_help: 'When maintenance ends', + message_label: 'Message (optional)', + message_placeholder: 'Scheduled data mart refresh', + template_label: 'Quick Template', + template_none: 'Custom', + template_daily_refresh: 'Daily Refresh', + template_daily_refresh_desc: '4h ETL window', + template_schema_migration: 'Schema Migration', + template_schema_migration_desc: '2h schema change', + template_emergency: 'Emergency', + template_emergency_desc: '1h critical fix', + template_extended: 'Extended', + template_extended_desc: '8h large refresh', + no_environment: 'No environment selected. Please select an environment first.', + }, + common: {}, + }; + return { + t: { + subscribe(run: (_value: unknown) => void) { + subscribers.add(run); + run(tData); + return () => subscribers.delete(run); + }, + }, + _: (key: string) => key, + getT: () => ({}), + }; +}); + +// ── Imports (after mocks) ───────────────────────────────────── + +import StartMaintenanceForm from '$lib/components/StartMaintenanceForm.svelte'; + +describe('StartMaintenanceForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset to default: environment selected + mockEnvStore.setSelectedEnvId('env-prod'); + }); + + // #region test_submits_with_environment_id [C:2] [TYPE Function] + // @BRIEF Submit with environment selected includes environment_id in API call. + // @TEST_EDGE: Environment selected -> API call includes environment_id + it('submits with environment_id from context store', async () => { + mockStartMaintenance.mockResolvedValue({ + task_id: 'task-1', + maintenance_id: 'm-1', + status: 'pending', + }); + + render(StartMaintenanceForm); + + // Fill in table name + const tableInput = screen.getByPlaceholderText('e.g. raw.sales'); + await fireEvent.input(tableInput, { target: { value: 'raw.my_table' } }); + + // Click the submit button (use role to avoid heading match) + const submitButton = screen.getByRole('button', { name: /Start Maintenance/i }); + await fireEvent.click(submitButton); + + // Verify startMaintenance was called with environment_id + expect(mockStartMaintenance).toHaveBeenCalledTimes(1); + const callParams = mockStartMaintenance.mock.calls[0][0]; + expect(callParams).toHaveProperty('environment_id', 'env-prod'); + expect(callParams).toHaveProperty('tables'); + expect(callParams.tables).toContain('raw.my_table'); + }); + // #endregion test_submits_with_environment_id + + // #region test_validation_fails_without_environment [C:2] [TYPE Function] + // @BRIEF No environment selected shows error toast and does not call API. + // @TEST_EDGE: No environment selected -> error toast, no API call + it('shows error toast when no environment is selected', async () => { + // Set empty environment + mockEnvStore.setSelectedEnvId(''); + + render(StartMaintenanceForm); + + // Fill in table name + const tableInput = screen.getByPlaceholderText('e.g. raw.sales'); + await fireEvent.input(tableInput, { target: { value: 'raw.my_table' } }); + + // Click the submit button + const submitButton = screen.getByRole('button', { name: /Start Maintenance/i }); + await fireEvent.click(submitButton); + + // API should NOT have been called + expect(mockStartMaintenance).not.toHaveBeenCalled(); + + // Error toast should have been shown with the missing environment message + expect(mockAddToast).toHaveBeenCalledWith( + expect.stringContaining('No environment selected'), + 'error', + ); + }); + // #endregion test_validation_fails_without_environment + + // #region test_validation_fails_without_table [C:2] [TYPE Function] + // @BRIEF Empty table name shows inline validation, no API call. + it('shows validation error when table name is empty', async () => { + render(StartMaintenanceForm); + + // Click Submit without filling table name + const submitButton = screen.getByRole('button', { name: /Start Maintenance/i }); + await fireEvent.click(submitButton); + + // API should NOT have been called + expect(mockStartMaintenance).not.toHaveBeenCalled(); + + // Inline validation error should be visible + expect(screen.getByText('Table name is required')).toBeTruthy(); + }); + // #endregion test_validation_fails_without_table +}); +// #endregion MaintenanceFormTests