From 29acfb4e69601b4b2ba6429686938972848fe9e6 Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 2 Jun 2026 16:36:00 +0300 Subject: [PATCH] freeze fix --- .opencode/skills/semantics-svelte/SKILL.md | 16 + backend/src/app.py | 4 +- backend/src/plugins/translate/_batch_proc.py | 18 +- backend/src/plugins/translate/_llm_call.py | 62 ++- docs/adr/ADR-0006-frontend-architecture.md | 252 ++++++++-- docs/adr/ADR-0010-model-decomposition-gate.md | 119 +++++ frontend/ai-native-design-audit.md | 271 +++++++---- frontend/src/lib/api.ts | 460 ++++++++++++++++-- .../TranslationRunGlobalIndicator.svelte | 22 +- .../translate/TranslationRunProgress.svelte | 58 ++- .../lib/models/DashboardHubModel.svelte.ts | 5 + .../src/lib/stores/translationRun.svelte.ts | 19 +- .../src/routes/translate/[id]/+page.svelte | 46 +- 13 files changed, 1171 insertions(+), 181 deletions(-) create mode 100644 docs/adr/ADR-0010-model-decomposition-gate.md diff --git a/.opencode/skills/semantics-svelte/SKILL.md b/.opencode/skills/semantics-svelte/SKILL.md index 0c8c1d3f..d52361f5 100644 --- a/.opencode/skills/semantics-svelte/SKILL.md +++ b/.opencode/skills/semantics-svelte/SKILL.md @@ -264,6 +264,22 @@ search_contracts query="users" type="Model" | **Store** (`BINDS_TO -> [storeId]`) | Global cross-route state (auth, notifications, task drawer). Persists across navigation. | | **Inline $state** | Local component UI state (accordion open, tooltip visible, input focus). No cross-component invariants. | +### Model Decomposition Gate + +Models accumulate methods as features grow. To prevent "god object" anti-pattern: + +| Threshold | Action | +|-----------|--------| +| Model > **400 lines** | Decompose — extract domain helpers or split into submodels | +| Model > **40 public methods** | Split into submodels by responsibility (e.g. `FiltersModel`, `SelectionModel`, `GitActionsModel`) | + +**Submodel split example for DashboardHubModel:** +- `DashboardFiltersModel` — search, column filters, sort +- `DashboardSelectionModel` — checkbox, select all/visible, bulk actions +- `DashboardGitActionsModel` — git init, sync, commit, pull, push + +Before decomposition, the model MUST carry `@INVARIANT DECOMPOSITION GATE` with the split plan and line count. + ## IV. IMPLEMENTATION & ACCESSIBILITY (A11Y) 1. **Event Handling:** Use native attributes (e.g., `onclick={handler}`, `onchange={handler}`). diff --git a/backend/src/app.py b/backend/src/app.py index 82d66764..5d432de2 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -829,10 +829,10 @@ async def translate_run_websocket(websocket: WebSocket, run_id: str): try: from .core.database import SessionLocal from .plugins.translate.orchestrator_aggregator import TranslationResultAggregator - from .core.event_log import EventLog + from .plugins.translate.events import TranslationEventLog db = SessionLocal() try: - event_log = EventLog(db) + event_log = TranslationEventLog(db) aggregator = TranslationResultAggregator(db, event_log) status = aggregator.get_run_status(run_id) total = status.get("total_records", 0) or 0 diff --git a/backend/src/plugins/translate/_batch_proc.py b/backend/src/plugins/translate/_batch_proc.py index 7a24b05d..18e5f8e2 100644 --- a/backend/src/plugins/translate/_batch_proc.py +++ b/backend/src/plugins/translate/_batch_proc.py @@ -224,12 +224,28 @@ class BatchProcessingService: if tb["warning"]: logger.explore("Token budget warning", {"batch_id": bid, "warning": tb["warning"]}) - return self._llm_service.call_llm_for_batch( + logger.reason( + f"LLM process batch start", { + "batch_id": bid, "llm_rows": len(rows_for_llm), + "provider_model": provider_model, + "max_output_needed": tb.get("max_output_needed"), + "estimated_input_tokens": tb.get("estimated_input_tokens"), + }, + ) + + result = self._llm_service.call_llm_for_batch( job=job, run_id=run_id, batch_rows=rows_for_llm, dict_matches=dict_matches, batch_id=bid, max_tokens=tb["max_output_needed"], ) + logger.reason( + f"LLM process batch complete", { + "batch_id": bid, **result, + }, + ) + return result + # -- Batch insert (delegation) -- def insert_batch_to_target(self, job: TranslationJob, batch_id: str, run_id: str) -> None: insert_batch_to_target(self.db, self.config_manager, job, batch_id, run_id) diff --git a/backend/src/plugins/translate/_llm_call.py b/backend/src/plugins/translate/_llm_call.py index 54783f69..4cd96e66 100644 --- a/backend/src/plugins/translate/_llm_call.py +++ b/backend/src/plugins/translate/_llm_call.py @@ -57,6 +57,15 @@ class LLMTranslationService: ) -> dict[str, int]: """Call LLM for a batch of rows; parse response; create records.""" with belief_scope("LLMTranslationService.call_llm_for_batch"): + provider_label = f"{job.provider_id}/{getattr(job, '_provider_model', '?')}" + logger.reason( + f"LLM batch start", { + "batch_id": batch_id, "row_count": len(batch_rows), + "provider": provider_label, "max_tokens": max_tokens, + "recursion_depth": _recursion_depth, + }, + ) + dictionary_section = self._build_dictionary_section(dict_matches, batch_rows) target_languages = self._resolve_target_languages(job) prompt = self._build_prompt(job, batch_rows, dictionary_section, target_languages) @@ -65,10 +74,22 @@ class LLMTranslationService: job, prompt, batch_id, max_tokens, ) if llm_response is None: + logger.explore( + f"LLM batch failed after {retries} retries", { + "batch_id": batch_id, "row_count": len(batch_rows), + "last_error": last_error, "retries": retries, + }, + ) return self._handle_llm_failure(batch_rows, run_id, batch_id, retries, last_error) if finish_reason == "length" and len(batch_rows) >= 2 and run_id: if _recursion_depth < MAX_RETRIES_PER_BATCH: + logger.reason( + f"Splitting truncated batch", { + "batch_id": batch_id, "size": len(batch_rows), + "depth": _recursion_depth, + }, + ) return self._split_and_retry(job, run_id, batch_rows, dict_matches, batch_id, max_tokens, _recursion_depth, retries) logger.explore("Truncation recursion depth exceeded", {"batch_id": batch_id, "depth": _recursion_depth}) @@ -76,11 +97,24 @@ class LLMTranslationService: try: translations = parse_llm_response(llm_response, len(batch_rows), target_languages=target_languages) except ValueError as e: + logger.explore( + f"LLM parse failure", { + "batch_id": batch_id, "error": str(e), + "response_len": len(llm_response), + "response_preview": llm_response[:500], + }, + ) return self._handle_parse_failure(batch_rows, run_id, batch_id, retries, e) - return self._create_records_from_translations( + result = self._create_records_from_translations( batch_rows, run_id, batch_id, target_languages, translations, dict_matches, retries, ) + logger.reason( + f"LLM batch complete", { + "batch_id": batch_id, **result, + }, + ) + return result # #endregion call_llm_for_batch def _build_dictionary_section(self, dict_matches, batch_rows) -> str: @@ -120,9 +154,16 @@ class LLMTranslationService: last_error = None retries = 0 finish_reason = None + logger.reason(f"LLM retry loop start", {"batch_id": batch_id, "max_retries": MAX_RETRIES_PER_BATCH, "prompt_len": len(prompt)}) for attempt in range(1, MAX_RETRIES_PER_BATCH + 1): try: llm_response, finish_reason = self.call_llm(job, prompt, max_tokens=max_tokens) + logger.reason( + f"LLM call succeeded (attempt {attempt})", { + "batch_id": batch_id, "finish_reason": finish_reason, + "response_len": len(llm_response) if llm_response else 0, + }, + ) break except Exception as e: last_error = str(e) @@ -130,6 +171,8 @@ class LLMTranslationService: logger.explore(f"LLM call failed (attempt {attempt})", {"batch_id": batch_id, "error": last_error}) if attempt < MAX_RETRIES_PER_BATCH: time.sleep(2 ** attempt) + if llm_response is None: + logger.explore(f"All LLM retries exhausted", {"batch_id": batch_id, "retries": retries, "last_error": last_error}) return llm_response, finish_reason, retries, last_error def _handle_llm_failure(self, batch_rows, run_id, batch_id, retries, last_error): @@ -292,12 +335,27 @@ class LLMTranslationService: provider_type = provider.provider_type.lower() if provider.provider_type else "openai" disable_reasoning = getattr(job, 'disable_reasoning', False) + logger.reason( + f"LLM provider resolved", { + "provider_id": job.provider_id, "model": model, + "provider_type": provider_type, "base_url": provider.base_url, + "disable_reasoning": disable_reasoning, "max_tokens": max_tokens, + }, + ) + if provider_type not in ("openai", "openai_compatible", "openrouter", "kilo", "litellm"): raise ValueError(f"Unsupported provider type '{provider_type}'") - return call_openai_compatible( + result = call_openai_compatible( base_url=provider.base_url, api_key=api_key, model=model, prompt=prompt, provider_type=provider_type, max_tokens=max_tokens, disable_reasoning=disable_reasoning, ) + logger.reason( + f"LLM provider call complete", { + "response_len": len(result[0]) if result and result[0] else 0, + "finish_reason": result[1], + }, + ) + return result # #endregion call_llm # -- Static methods delegated to sub-modules for backward compat -- diff --git a/docs/adr/ADR-0006-frontend-architecture.md b/docs/adr/ADR-0006-frontend-architecture.md index 01d9d03a..3afccc78 100644 --- a/docs/adr/ADR-0006-frontend-architecture.md +++ b/docs/adr/ADR-0006-frontend-architecture.md @@ -1,14 +1,19 @@ # [DEF:ADR-0006:ADR] # @STATUS ACTIVE -# @PURPOSE Define the frontend architecture for ss-tools: Svelte 5 runes reactivity model, SvelteKit routing, component composition rules, state management patterns, API client conventions, and UX contract expectations for C4/C5 components. +# @PURPOSE Define the frontend architecture for ss-tools: Svelte 5 runes reactivity model, SvelteKit routing, component composition rules, state management patterns (incl. Screen Models), design token system, UI atom reuse, API client conventions, and UX contract expectations for C4/C5 components. # @RELATION DEPENDS_ON -> [ADR-0001:ADR] # @RELATION DEPENDS_ON -> [ADR-0002:ADR] +# @RELATION DEPENDS_ON -> [ADR-0010:ADR] # @RATIONALE Svelte 5 introduces a fundamentally different reactivity model (runes: `$state`, `$derived`, `$effect`, `$props`) compared to Svelte 4's `$:` reactive statements. This ADR locks in the Svelte 5 runes approach and prevents backsliding into legacy patterns when new developers or AI agents contribute code. # @RATIONALE SvelteKit with static adapter (`@sveltejs/adapter-static`) was chosen over SvelteKit SSR because: (a) ss-tools is a Docker‑deployed SPA behind nginx — server‑side rendering provides no latency benefit, (b) static SPA simplifies Docker deployment (no Node.js server needed, just nginx serving static files), (c) all data is fetched via REST API, not server‑side load functions. # @RATIONALE Svelte 5 runes (`$state`, `$derived`, `$effect`) are mandated over Svelte 4 `$:` reactivity and `writable` stores because: (a) runes are the forward path — Svelte 4 patterns are deprecated, (b) runes provide fine‑grained reactivity without subscription boilerplate, (c) `$state` can be used outside `.svelte` files in `.svelte.js` modules, enabling reusable reactive logic. +# @RATIONALE Design tokens (semantic colors in `tailwind.config.js`) were introduced in 2026‑06‑02 (PR 0‑1) to eliminate raw Tailwind classes (`bg‑blue‑600`, `text‑gray‑900`) from page‑level code. This prevents visual drift and gives AI agents a single source of truth for color choices. See §"Design Token System" below. +# @RATIONALE Screen Model pattern (`[TYPE Model]` in `.svelte.ts` files) was formalised in 2026‑06‑02 (PR 3‑4) to extract cross‑widget state from page files. Models reduce page files from 2700+ lines to <1400 lines and enable unit‑testing of invariants without DOM render (~10ms). See §"State Management Pattern" and ADR‑0010. # @REJECTED React/Next.js — rejected by ADR-0003 (technology stack independence from Superset). Svelte 5 was chosen for its compile‑time approach, smaller bundle size, and superior DX for this project's scale. # @REJECTED Svelte 4 legacy patterns (`$:`, `writable`/`readable` stores) — rejected because Svelte 5 runes are the actively maintained reactivity model. Mixing two models creates confusion and potential reactivity bugs. # @REJECTED Server‑Side Rendering (SSR) with SvelteKit — rejected because the data is entirely API‑driven (no server‑side page data loading), and SSR adds deployment complexity (Node.js process) without latency benefit for authenticated SPA users. +# @REJECTED Raw Tailwind color classes (`bg‑blue‑600`, `text‑gray‑900`, `border‑indigo‑300`, etc.) in page and component code — rejected because they create visual drift across 70+ files (3892 violations catalogued 2026‑06‑02). Replaced by semantic token system (see §"Design Token System"). +# @REJECTED Inline `$state` + handler functions in complex pages (>5 `$state` atoms) — rejected because they produce monolithic, untestable page files (DashboardHub was 2765 lines before extraction). Replaced by Screen Model pattern (see §"State Management Pattern"). ## Decision @@ -19,7 +24,7 @@ | Framework | SvelteKit | 2.x | | UI Library | Svelte | 5.x (runes mode) | | Build | Vite | 7.x | -| Styling | Tailwind CSS | 3.x | +| Styling | Tailwind CSS 3.x + semantic design tokens | 3.x | | Adapter | @sveltejs/adapter-static | 3.x (SPA mode) | | Testing | vitest + @testing-library/svelte | 4.x / 5.x | @@ -32,24 +37,142 @@ let count = 0; let count = $state(0); $: doubled = count * 2; let doubled = $derived(count * 2); $: { /* side effect */ } $effect(() => { /* side effect */ }); export let name; let { name } = $props(); -import { writable } from ... ❌ stores are .svelte.js modules with $state +import { writable } from ... ❌ new stores MUST use .svelte.ts with $state + ⚠️ 8 existing writable stores are legacy — migrate, don't extend ``` +**Note on legacy stores:** 8 stores (`sidebar.ts`, `taskDrawer.ts`, `environmentContext.ts`, `datasetReviewSession.ts`, `assistantChat.ts`, `health.ts`, `activity.ts`, `translationRun.ts`) still use `writable()` from `svelte/store`. These predate Svelte 5 adoption. New reactive state MUST use `.svelte.ts` files with `$state`. Existing stores are read via `$store` syntax in `.svelte` files or `get()` in `.svelte.ts` modules. ADR‑0007 documents the `fromStore` + `$derived` rejection. + ### State Management Pattern -Three tiers of state, strictly separated: +**Four tiers of state**, strictly separated: -1. **Component‑local state**: `$state()` inside `.svelte` files. Never exported. -2. **Shared reactive state**: `.svelte.js` modules exporting `$state` objects. Imported by components. - ```js - // frontend/src/lib/stores/dashboard.svelte.js - export const dashboardState = $state({ - dashboards: [], - selectedId: null, - isLoading: false - }); - ``` -3. **Server state**: Fetched via API client, held in `$state` variables locally. No global cache — each route fetches what it needs. +| Tier | Pattern | File | Use case | Example | +|------|---------|------|----------|---------| +| 1. Component‑local | `$state()` in `.svelte` | `.svelte` | Accordion open, tooltip visible, input focus | `let isOpen = $state(false)` | +| 2. Screen Model | `class` with `$state` + methods | `.svelte.ts` in `lib/models/` | **Cross‑widget state with invariants** (filters, pagination, search, multi‑step) | `DashboardHubModel`, `MigrationModel` | +| 3. Global store | `$state` in `.svelte.ts` module | `.svelte.ts` in `lib/stores/` | Cross‑route state (auth, notifications, sidebar) | `maintenance.svelte.ts` | +| 4. Server state | Fetched via API, held in Model or component | N/A | HTTP responses from backend | `api.getDashboards()` → model atom | + +**Screen Model (tier 2) — the model‑first pattern:** + +For any page with **>5 `$state` atoms** or **cross‑widget invariants** (filter resets pagination, selection survives search), create a `[TYPE Model]` in `lib/models/` BEFORE implementing components. The Model declares `@STATE`, `@ACTION`, `@INVARIANT` tags and is testable without DOM render. + +```typescript +// frontend/src/lib/models/DashboardHubModel.svelte.ts +export class DashboardHubModel { + // Atoms + dashboards: DashboardRow[] = $state([]); + page: number = $state(1); + searchQuery: string = $state(""); + // ... < 40 atoms + + // Actions + async loadDashboards(): Promise { ... } + setPage(n: number): void { ... } + // ... < 40 methods +} +``` + +Page file becomes a thin render layer: +```svelte + + +``` + +**Decomposition gate** (ADR‑0010): If a Model exceeds **400 lines** or **40 public methods**, split into submodels by responsibility domain (e.g. `DashboardFiltersModel`, `DashboardSelectionModel`, `DashboardGitModel`). + +### Design Token System + +**Raw Tailwind color classes are DEPRECATED in page and component code.** All colors MUST use semantic tokens defined in `frontend/tailwind.config.js`. This is the single source of truth for the visual system. + +**Token families** (from `tailwind.config.js`): + +| Purpose | Token | Maps to | +|---------|-------|---------| +| Primary action | `bg-primary text-white` | blue‑600 | +| Page background | `bg-surface-page` | slate‑50 | +| Card surface | `bg-surface-card` | white | +| Muted surface | `bg-surface-muted` | slate‑100 | +| Default border | `border-border` | slate‑200 | +| Strong border (inputs) | `border-border-strong` | slate‑300 | +| Primary text | `text-text` | slate‑900 | +| Muted text | `text-text-muted` | slate‑500 | +| Subtle text (placeholders) | `text-text-subtle` | slate‑400 | +| Destructive / error | `bg-destructive-light text-destructive border-destructive-ring` | red family | +| Success | `bg-success-light text-success` | green family | +| Warning | `bg-warning-light text-warning` | amber family | +| Info | `bg-info-light text-info` | sky family | + +**Correct vs. wrong:** + +```svelte +// ✅ CORRECT — semantic tokens +
+