BorisovAI
Все проекты

Cascadev0.14.0

Платформа интеллектуального анализа трендов. Автоматический сбор сигналов из 5+ источников, каскадный AI-анализ влияния, ролевые рекомендации и готовые отчёты — всё что нужно чтобы принимать решения раньше конкурентов.

Cascade
Платформы аналитикиPythonTSXGoTypeScriptCSS

Скриншоты

Документация

Cascade Trend Analysis

Система сбора и анализа технологических/новостных сигналов. Извлекает факты, группирует в события, формирует тренды, генерирует аналитические отчёты.

Stack: Python 3.12+ · PostgreSQL 16 + pgvector · Go 1.21+ · React + TypeScript · Ollama LLM


Архитектура (v0.22+)

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│  Frontend   │ ───► │   Go API    │ ───► │ PostgreSQL  │
│  React SPA  │      │  :4010 RO   │      │ + pgvector  │
└─────────────┘      └──────┬──────┘      └─────────────┘
                            │ writes proxy        ▲  ▲
                            ▼                     │  │
                     ┌─────────────┐              │  │
                     │ Python API  │──────────────┘  │
                     │  :4014 RW   │                 │
                     └─────────────┘                 │

                     ┌─────────────┐                 │
                     │  Pipeline   │─────────────────┘
                     │  45+ src    │  crawl + extract + link
                     └─────────────┘


                     ┌─────────────┐
                     │   Ollama    │  tier1: hermes3 (:11436)
                     │  3 tunnels  │  tier2: gemma4 (:11435)
                     └─────────────┘

3 процесса на проде (PM2):

  • go-api — read-only endpoints (port 4010), отдаёт статику SPA
  • python-api — write-only endpoints (port 4014), auth, proxy target
  • pipeline — crawler + event_linker + summarizer + translation_watchdog

Быстрый старт (разработка)

Требования

  • Python 3.12+
  • Node 20+
  • Go 1.21+ (только если будете менять Go API)
  • Docker (для PostgreSQL + SearXNG)
  • Ollama tunnels (см. раздел LLM ниже) — опционально, без них LLM-шаги не работают

1. PostgreSQL + pgvector

cd docker/postgres-trends
docker compose up -d
# проверь: docker exec postgres-trends pg_isready -U trends

Применить схему (один раз на пустую БД):

docker exec -i postgres-trends psql -U trends trends < db/pg_schema.sql
docker exec -i postgres-trends psql -U trends trends < db/pg_indexes.sql

2. Python backend

python3.12 -m venv venv
source venv/bin/activate                    # Linux/Mac
# venv\Scripts\activate                      # Windows
pip install -r requirements.txt

# Настройка .env (см. раздел "Переменные окружения" ниже)
cp .env.example .env
$EDITOR .env

3. Фронтенд

cd frontend-cascade/app
npm install
npm run dev    # http://localhost:5173

4. Запуск dev-сервера

# вариант 1: всё сразу (API + фронт)
python dev.py

# вариант 2: только API
python dev.py --api-only --port 8000

# вариант 3: только фронт
python dev.py --web-only

5. Запуск pipeline (опционально)

Pipeline разделён на два процесса — запускайте оба в отдельных терминалах:

source venv/bin/activate

# терминал 1 — сбор сигналов
python -m src.workers.collector

# терминал 2 — обработка (extraction, events, trends, ai_insights, watchdog)
python -m src.workers.analytics

Переменные окружения

Скопируйте .env.example.env и заполните:

Обязательные

DATABASE_URL=postgresql://trends:PASSWORD@localhost:5432/trends

# Ollama tunnels — 3 отдельных инстанса
OLLAMA_URL=http://localhost:11435            # tier2/3: gemma4:e2b (translation, reasoning)
OLLAMA_TIER1_URL=http://localhost:11436      # tier1: hermes3:8b (fact extraction)
OLLAMA_BACKUP_URL=http://localhost:11434     # backup (opportunistic)

LLM_PROVIDER=ollama

Auth (Casdoor)

CASDOOR_ENDPOINT=https://auth.trendominus.ru
CASDOOR_CLIENT_ID=...
CASDOOR_CLIENT_SECRET=...
CASDOOR_JWT_SECRET=...                       # public key of Casdoor JWT cert

Необязательные

SEARXNG_ENABLED=0                            # fetch_news через SearXNG (default: off)
SEARXNG_ENRICHMENT=0                         # citation enrichment (default: off — блокирует event loop)

Frontend (frontend-cascade/app/.env)

VITE_API_URL=                                # пусто = same-origin; иначе https://api.example.com

Production deploy

Деплой только через CI/CD (GitLab). SSH-деплой запрещён: CI билдит Go binary + Vite bundle, ручной rsync это пропускает.

Процесс

  1. Push в feature-ветку → создать MR в main
  2. Пройти CI (lint + test + build:go-api + build:frontend)
  3. Merge MR
  4. В GitLab UI нажать Deploy (manual stage)
  5. Проверить: curl https://trendominus.ru/api/health

GitLab CI Variables (Settings → CI/CD → Variables)

Ключ Тип Описание
BACKEND_ENV File Полный .env для прода (все переменные выше)
DEPLOY_PATH Variable /var/www/trend-analisis
VITE_API_URL Variable пусто для same-origin

Пример содержимого BACKEND_ENV — см. .gitlab/ci/pipeline.yml (секция deploy).

Что делает deploy job

  1. rsync Python backend + db/ + i18n/ + scripts/ + static bundle → $DEPLOY_PATH
  2. Копирует Go binary из artifact
  3. pip install -r requirements.txt в venv
  4. Копирует BACKEND_ENV$DEPLOY_PATH/.env
  5. pm2 restart go-api python-api pipeline --update-env

Первый запуск инфраструктуры

# Manual CI job:
install:postgres-trends    # ставит pgvector/pgvector:pg16 в docker-compose

LLM infrastructure

3 Ollama инстанса (разные GPU, разные туннели):

Port GPU Модель Роль
11436 3090-B hermes3:8b Tier 1 — fact extraction
11435 3090-A gemma4:e2b Tier 2/3 — translation, reasoning, summarizer
11434 4090 any Opportunistic backup (нестабильный)

Туннели конфигурируются через FRP client. Подробнее: CLAUDE.md → Ollama Infrastructure.


Разработка

Перед push'ем

venv/Scripts/python.exe -m ruff check src/ api/           # lint (0 errors)
cd frontend-cascade/app && npx tsc --noEmit && npx vite build

Тесты

venv/Scripts/python.exe -m pytest tests/ -x -q            # 740+ tests

Работа с БД

# Применить миграции (только SQLite — PG schema статическая)
venv/Scripts/python.exe -m db.migrator

# Консоль PG
docker exec -it postgres-trends psql -U trends trends

Структура репозитория

api/              Python API (FastAPI) — writes-only
src/              Pipeline + services + adapters
  workers/        main.py (pipeline), crawler.py, translation_watchdog.py
  services/       fact_extractor, event_linker, adapters/ (45+)
  llm/            Ollama clients
go-api/           Go API (chi router, read-only)
db/               engine.py (DAL), migrations, pg_schema.sql
frontend-cascade/ React SPA
tests/            pytest
scripts/          CI/CD, ops helpers
docker/           postgres-trends, searxng, ollama-proxy
.gitlab/ci/       GitLab CI pipeline

Мониторинг

# Статус процессов
ssh prod "sudo -u gitlab-runner pm2 list"

# Логи
ssh prod "sudo -u gitlab-runner pm2 logs pipeline --lines 50"

# Здоровье API
curl https://trendominus.ru/api/health

# Состояние БД
ssh prod "docker exec postgres-trends psql -U trends trends -c \
  'SELECT COUNT(*) FROM events; SELECT COUNT(*) FROM facts;'"

Правила проекта

  1. Не костыли. Если фикс затрагивает >3 файлов или >30 мин — согласуй подход с автором.
  2. Без SSH-деплоя. Только через CI.
  3. Все зависимости в requirements.txt (не в pyproject.toml — CI его игнорирует).
  4. Коммиты: type(scope): description (feat / fix / perf / refactor / docs / test).
  5. Version bump при каждом коммите: patch-bump в pyproject.toml + frontend-cascade/app/package.json.

Подробнее — см. CLAUDE.md.

История изменений

Журнал изменений (Changelog)

Правило для агента: Перед исправлением любой ошибки — проверь этот журнал. Возможно, проблема уже решалась и есть готовое решение или известные подводные камни.


Формат записи

### [ДАТА] Краткое описание
**Тип:** bug | feature | refactor | fix
**Файлы:** список измененных файлов
**Проблема:** что было не так
**Решение:** что сделали
**Подводные камни:** на что обратить внимание в будущем

v0.25.81 — 2026-05-09

[2026-05-09] feat(admin): /admin/actions/* enqueue endpoints в Go (Phase 3c4d)

Тип: feature (ADR-009 phase 3 progress) Файлы: go-api/internal/store/{admin_jobs,admin_counts}.go (new), go-api/internal/handler/{admin_actions,admin_actions_test}.go (new), go-api/cmd/server/main.go Проблема: /admin/actions/* проксились в Python — каждый клик "Run admin action" в UI делал 2 hop'а (frontend → go-api proxy → python-api). Сами handler'ы admin actions уже на queue (Phase 2c), просто триггеры остались в Python. Решение: 5 enqueue handler'ов мигрировано в Go:

  • POST /api/admin/actions/generate-descriptions — counts empty descriptions + total trends, enqueue → {status: "queued"}.
  • POST /api/admin/actions/regenerate-recommendations — counts completed analyses w/ impact_zones, skip if 0, enqueue.
  • POST /api/admin/actions/cluster-objects — counts active objects, skip if <2, enqueue.
  • POST /api/admin/actions/backfill-objects — counts empty object_name, skip if 0, enqueue (no-op since v0.17 — handler в Python оставлен для совместимости).
  • POST /api/admin/actions/regenerate-change-types — counts non-merged trends w/ change_type, skip if 0, enqueue.

Что НЕ мигрировано:

  • POST /admin/actions/regenerate-predictions — sync, fast (seconds), DELETE+SELECT+extract.
  • POST /admin/actions/cleanup-objects — sync, sklearn-heavy (auto_merge_duplicates uses cosine similarity на embedding vectors).
  • POST /admin/actions/retry-entity-normalization
  • /admin/llm-usage — cost dashboard (read-only)
  • /admin/memprof/* — debug

Все эти остаются проксированными через специфичные routes + /admin/* wildcard.

Артефакты:

  • store/admin_jobs.goEnqueueAdminAction(action, extra) — INSERT в jobs(kind=admin_action), external_ref admin:{action}:{ms}-{uuid8} mirrors Python.
  • store/admin_counts.go — 5 cheap COUNT(*) helpers; gracefully handle missing tables (return 0+nil for "no such table"/"does not exist").
  • handler/admin_actions.go — 5 endpoints, share requireAdmin через embed *AdminSettingsHandler.
  • main.go — 5 chi routes + 2 specific proxy routes (regenerate-predictions, cleanup-objects) перед HandleFunc("/admin/*", pythonProxy) wildcard.

Тесты: 6 новых: nil-verifier fail-closed для всех 5 endpoints + joinComma helper.

Подводные камни:

  1. Response shape идентичен Python ({"status": "queued"|"skipped", "message": "..."}). Frontend не требует изменений.
  2. external_ref format admin:{action}:{ms}-{uuid8} совместим с Python writer — observability через SELECT * FROM jobs WHERE kind='admin_action' работает одинаково.
  3. Сами handler'ы admin actions остаются в src/workers/admin_action_handlers.py — Python владеет Ollama/sklearn/SearXNG. Go только enqueue'ит.

v0.25.80 — 2026-05-09

[2026-05-09] feat(admin): /admin/settings + sources в Go (Phase 3c4c)

Тип: feature (ADR-009 phase 3 progress) Файлы: go-api/internal/store/{admin_settings,admin_sources}.go (new), go-api/internal/handler/{admin_settings,admin_settings_test}.go (new), go-api/cmd/server/main.go Проблема: /admin/settings (4 endpoint'а) проксились в Python — простой key-value over settings table. Обычный admin page-load делал N proxy hops. Решение:

Endpoint Что делает
GET /api/admin/settings Read 8 admin toggles (translations/collection/auto_analysis/external_api/recommendations) с дефолтами
PUT /api/admin/settings Partial update с clamping (max_per_day 1-50, depth 1-7, rate_limit 1-300)
GET /api/admin/settings/sources List 70+ sources с enabled-state + last_fetch_at status (active/warning/dead/unknown)
`PUT /api/admin/settings/sources/{name}?enabled=true false`

Артефакты:

  • store/admin_settings.go — Get/SetSettingString/Bool/Int over settings(key, value) table. Bool encoded как "1"/"0" (compatible с Python writer).
  • store/admin_sources.goSourceCatalog map (70 sources hardcoded — duplicates src/config.py::SOURCE_TYPE_MAP). ListAdminSources мерджит catalog + DB enabled state + source_last_fetch:{name} для status. IsValidSourceName guard.
  • handler/admin_settings.go — все на requireAdmin (re-Verify bearer + check IsAdmin claim → 403 если не admin). clamp reused из notifications handler.
  • main.go — 4 chi routes до r.HandleFunc("/admin/*", pythonProxy) так что специфичные сматчатся первыми.

Что осталось проксироваться (Phase 3c4d):

  • /admin/actions/* — 7 endpoints (5 enqueue queue jobs, 2 sync — regenerate-predictions + cleanup-objects). Просто port'ятся — _enqueue_admin_action уже в Go форме (admin-worker MR522).
  • /admin/llm-usage — cost dashboard (read-only)
  • /admin/memprof/{start,stop} — debug

Подводные камни:

  1. SourceCatalog дублирует SOURCE_TYPE_MAP в src/config.py. Новый адаптер требует update в обоих местах. Минорный maintenance overhead, но не блокер.
  2. Bool encoding "1"/"0" совместим с Python writer (api/settings_store.update_setting). Round-trip Go ↔️ Python работает.
  3. ?enabled=true|false — query param (не body). Mirrors Python.

Тесты: 3 новых — fail-closed gates (nil verifier → 503 для всех 3 endpoints). Все Go-тесты passed.


v0.25.79 — 2026-05-09

[2026-05-09] feat(routes): PAT CRUD + audit-log в Go (Phase 3c4a)

Тип: feature (ADR-009 phase 3 progress) Файлы: go-api/internal/store/{pat_admin,audit_log}.go (new), go-api/internal/handler/{pat_admin,audit_log,pat_admin_test}.go (new), go-api/cmd/server/main.go Проблема: После Phase 3c1a остались proxied 4 admin endpoint'а: PAT CRUD + audit-log GET. Все — single-table operations без Casdoor management API. Простой port. Решение: 4 endpoint'а мигрированы:

Endpoint Что делает
POST /api/auth/pat Mint новый PAT, return raw token (shown once)
GET /api/auth/pat List PATs пользователя (active + revoked, is_active computed against now)
DELETE /api/auth/pat/{id} Soft-revoke (sets revoked_at)
GET /api/auth/audit-log Admin-only, fetches login_audit_log с username filter + pagination

Артефакты:

  • store/pat_admin.goCreatePAT/ListPATs/RevokePAT. Token format pat_{base64url(32)} совместим с MR7 LookupPAT (sha256 hash check). Nullable expires_at через *string.
  • store/audit_log.goListAuditLog с условным WHERE username = ? + LIMIT/OFFSET. Success int materialised в bool для JSON-output (мирорим Python AuditLogEntry).
  • handler/pat_admin.go — все 3 endpoint'а через authedUserID(r) (verified JWT). Validation: name 1-100 chars, expires_days 0-3650.
  • handler/audit_log.go — re-Verify bearer для IsAdmin claim → 403 если не admin. Nil verifier → 503 (fail-closed).
  • main.go — 4 chi routes зарегистрированы, прокси-строки удалены.

Что осталось проксироваться (Phase 3c4b):

  • POST /auth/login — local password (dev-only path)
  • PUT /auth/me — Casdoor management API (update profile)
  • GET/PUT/DELETE /auth/users/* — Casdoor management API (admin user mgmt)

Тесты (4 новых): auth gate rejection для всех 4 endpoint'ов. Все Go-тесты passed. Local smoke ✓ — /auth/pat и /auth/audit-log без auth → 401.

Подводные камни:

  1. audit_log.go использует ту же login_audit_log таблицу что Python пишет (см. api/auth/adapters/sqlite_audit.py). Мы только читаем, Python остаётся owner записи. Без race conditions.
  2. IsAdmin claim из Casdoor JWT — проверяем через re-Verify (не передаём context object). Cost: один decode + RSA verify per admin call. Admin endpoints редкие, acceptable.
  3. PAT raw token shown ONCE на create — frontend admin UI должен это handle'ить.

v0.25.78 — 2026-05-09

[2026-05-09] feat(routes): /saved + /notifications в Go (Phase 3c1)

Тип: feature (ADR-009 phase 3 — drop python-api progress) Файлы: go-api/internal/store/{saved,notifications}.go (new), go-api/internal/handler/{saved,notifications,saved_test,notifications_test}.go (new), go-api/cmd/server/main.go Проблема: После Phase 3b (auth routes) python-api всё ещё owns пользовательские write endpoints /saved, /notifications/*. Каждое POST /save или mark-read шло через httputil reverse-proxy hop. Phase 3c1 убирает 8 endpoint'ов из proxy. Решение: Мигрированы 8 endpoint'ов:

  • GET /api/saved — list saved items
  • POST /api/saved — idempotent save (INSERT OR IGNORE / ON CONFLICT DO NOTHING)
  • DELETE /api/saved?item_type=…&item_id=… — delete (query params для gh:owner/repo slashes)
  • GET /api/notifications — list w/ limit/offset/unread_only filter
  • GET /api/notifications/unread-count — bell-icon count (anon → 0, не 401 — bell polls часто)
  • POST /api/notifications/{id}/read — mark single read
  • POST /api/notifications/read-all — bulk mark all read
  • DELETE /api/notifications/{id} — delete one

Подводные камни:

  1. Все endpoint'ы (кроме unread-count) требуют IsVerifiedUser=trueCASDOOR_CERTIFICATE env обязателен на проде.
  2. system-wide notifications (user_id IS NULL) surface'ятся каждому verified user'у через predicate (user_id IS NULL OR user_id = $1).
  3. Phase 3c1 НЕ закрывает /watchlist/* (сложный JOIN), /tags/* (custom interest tags), /profile/* (topic prefs). Остаются проксироваться.

Тесты: 12 новых (auth gates + helpers). Все Go-тесты passed. Local smoke ✓.


v0.25.77 — 2026-05-09

[2026-05-09] feat(auth): port user-facing OIDC routes to Go (Phase 3b)

Тип: feature (ADR-009 phase 1 — auth in Go) Файлы: go-api/internal/auth/{origin,casdoor,origin_test,casdoor_test}.go (new), go-api/internal/handler/auth.go (new), go-api/internal/store/{credits,user_profile}.go (new), go-api/cmd/server/main.go Проблема: После Phase 3a (JWT verify в Go) auth routes всё ещё проксились в python-api через httputil.NewSingleHostReverseProxy. Лишний hop, double JWT decode, дублирование redirect_uri логики между Python и Go. ADR-009 phase 1 явно требует "Auth in Go". Решение: 8 user-facing OIDC endpoint'ов мигрированы:

Endpoint Что делает
GET /api/auth/provider-info Возвращает provider type + casdoor_endpoint
GET /api/auth/login-url?origin=…&provider=… Builds Casdoor /login/oauth/authorize URL с состоянием
GET /api/auth/signup-url?origin=… Builds Casdoor signup page URL
POST /api/auth/callback Code → tokens (Casdoor /api/login/oauth/access_token); fire-and-forget user_profiles upsert
POST /api/auth/refresh Rotate refresh_token (Casdoor grant_type=refresh_token)
POST /api/auth/logout Возвращает logout_url для SPA (JWT stateless, серверный invalidate не нужен)
GET /api/auth/me Verified JWT claims → user info
GET /api/auth/credits Daily credits (verified user only)

Артефакты:

  • auth/origin.go — port _resolve_frontend_base из Python: explicit origin → Origin header → Referer → frontend_url fallback. Same-host fallback через X-Forwarded-Host для two-frontend deploy (cascade-trend.ru + trendominus.ru). HTTP downgrade rejected кроме localhost dev.
  • auth/casdoor.go — pure stdlib net/http HTTP client (отказ от casdoor-go-sdk: heavy json-iterator deps, unstable API). LoginURL/SignupURL/LogoutURL builders + ExchangeCode/RefreshToken через standard form-encoded POST. 15s timeout — fail-fast clean 401 вместо minute-long hang.
  • handler/auth.go — все 8 handler'ов через AuthDeps struct. /me требует verified JWT (Phase 3a verifier). /credits требует verified user (anon → 401).
  • store/credits.goEnsureTodayCredits с INSERT ... ON CONFLICT DO NOTHING через write pool. Mirrors api/credits_store.check_credits API (remaining/limit/used).
  • store/user_profile.goEnsureUserProfile для post-login hook. Cluster recompute остаётся в Python analytics process (sklearn-heavy).

Что НЕ мигрировано (остаётся проксироваться в python-api):

  • POST /auth/login (local password) — dev-only path
  • PUT /auth/me — Casdoor management API
  • GET/PUT/DELETE /auth/users*, POST /auth/users/{id}/{enable,disable} — admin user management
  • GET /auth/audit-log, POST /auth/pat, DELETE /auth/pat/{id}, GET /auth/pat — admin/PAT

Тесты: 21 новых:

  • origin_test.go (10) — explicit allowlist match, rejection, Origin header, Referer, fallback, same-host fwd, HTTP downgrade reject, localhost dev allowed, trailing slash strip.
  • casdoor_test.go (11) — LoginURL params, optional provider, SignupURL encoding, LogoutURL, ExchangeCode happy path + OAuth error surfacing + network error, RefreshToken grant verification, NewCasdoorClientFromEnv (nil when unset, builds when set, trailing slash strip).
  • Тесты против httptest.Server — без сетевых зависимостей.

Local smoke:

  • GET /api/auth/provider-info{"provider":"casdoor","supports_oidc":true,"casdoor_endpoint":"http://localhost:8100"}
  • GET /api/auth/login-url{state, url} с правильным client_id, redirect_uri, scope ✓
  • Все Go-тесты passed ✓

Подводные камни:

  1. Casdoor cert на проде должен быть в BACKEND_ENV (CI File var) для verify. Без него /auth/me → 503, /auth/credits → 401 (legacy unverified path не используется в этих handler'ах).
  2. Concurrency: _post_login идёт в go func() — fire-and-forget upsert после возврата tokens клиенту. Если упадёт — пользователь получит токены, user_profiles row создастся при первом write через watchdog. Acceptable.
  3. Phase 3c остаётся: ~2000 LoC миграция оставшихся writes (/saved, /watchlist, /notifications, /tags, /profile/*, /lab non-queue, /search, /topics, /onboarding, /v1/*, /query) + admin auth endpoints. После этого можно убрать python-api процесс.

v0.25.76 — 2026-05-08

[2026-05-08] feat(auth): JWT signature verification in Go (RS256 via Casdoor cert)

Тип: security + Phase 3 prep Файлы: go-api/internal/auth/jwt.go (new), go-api/internal/auth/jwt_test.go (new), go-api/internal/middleware/auth.go (rewritten), go-api/cmd/server/main.go (verifier init), go-api/go.mod (+golang-jwt/jwt/v5) Проблема: Go middleware декодировал Casdoor JWT БЕЗ verify подписи (extractSubFromJWT) — safe для read-only personalization, но реальный security gap для write endpoints (interactions в MR8a, ingest в MR7). Атакующий мог сгенерировать токен с любым sub claim'ом и писать interactions/dismiss от чужого имени. Решение:

  • go-api/internal/auth/jwt.go — RS256 signature verifier на golang-jwt/jwt/v5. Принимает PEM-encoded RSA public key OR X.509 certificate. Поддерживает inline PEM string и file path в CASDOOR_CERTIFICATE env. Fail-closed: empty cert → verifier rejects everything.
  • go-api/internal/middleware/auth.go переписан с двумя путями:
    • verifier set + valid signatureuser_id = sub, verified=true
    • verifier set + bad signature → дропаем bearer, fall back на anon cookie
    • verifier not set → legacy decode-without-verify (для миграционного периода)
  • Новый IsVerifiedUser(r) helper — mutating endpoints могут проверять что user_id пришёл из verified JWT.
  • main.go инициализирует verifier из CASDOOR_CERTIFICATE env при старте, логирует "JWT signature verification enabled" / "unset — accepted unverified (legacy)".

Тесты (12 новых): generate fresh RSA key per test, sign + verify happy path, key mismatch rejection, expired token (separate ErrExpired), alg=none rejection, garbage input, SubjectFromUnverified legacy fallback paths, LoadCertFromEnv (empty / inline PEM).

Безопасность:

  • Атакующий с forged JWT (правильный sub, неправильная подпись) больше НЕ может писать через Go endpoints — verifier дропает токен, request падает в anon cookie path. Anon cookie sandboxed (per-browser), не позволяет действовать от лица другого пользователя.
  • Existing extractSubFromJWT legacy path удалён.
  • Read-only personalization работает по-прежнему даже без cert — graceful degradation.

Подводные камни:

  1. На проде должен быть установлен CASDOOR_CERTIFICATE env (BACKEND_ENV file). Иначе go-api логирует warning и работает в legacy режиме (без verify). Безопасно для read-side, но MUST be fixed для production.
  2. Существующие endpoint'ы (/interactions, /dismiss/*) сейчас не проверяют IsVerifiedUser — не блокируют unverified user_id. Это легко добавить когда пройдёт verify в проде.
  3. Phase 3 продолжается: следующий шаг — port /auth/login-url, /callback, /refresh, /me, /logout в Go. Затем drop /auth/* из proxy.

v0.25.75 — 2026-05-08

[2026-05-08] refactor(workers): admin actions on queue (Phase 2c — Phase 2 complete)

Тип: refactor (Phase 2c — closes Phase 2) Файлы: src/workers/admin_action_handlers.py (new), src/workers/admin_worker.py (new), api/settings_routes.py, ecosystem.config.js, tests/unit/test_admin_worker.py (new) Проблема: 5 admin endpoint'ов спавнили threading.Thread для долгих операций (generate-descriptions, regenerate-recommendations, cluster-objects, backfill-objects, regenerate-change-types). Каждый со своим in-line _run closure'ом — ~150 LoC дублированной обвязки. uvicorn рестарт = потеря in-flight admin job. Видимости статуса не было. Решение:

  • src/workers/admin_action_handlers.py — registry ACTIONS: dict[str, Callable] с 5 handler'ами, извлечёнными из inline _run closures. Каждый handler синхронный, открывает свой get_conn(), возвращает dict для записи в jobs.result.
  • src/workers/admin_worker.pykind="admin_action" worker. Discriminator — payload["action"] (имя функции в registry). Эмитит progress events runningcomplete/failed. Один процесс на все admin actions, потому что они serial-by-nature (cluster + descriptions против одних таблиц = race) и низкочастотные.
  • api/settings_routes.py: 5 endpoint'ов сократились с ~80 LoC каждый до ~10 LoC — сразу _enqueue_admin_action(action_name) + return. Helper строит external_ref="admin:{action}:{ts}-{uuid8}" для observability.
  • ecosystem.config.js: admin-worker PM2 entry (1500M cap, 30s kill).
  • Synchronous endpoints (regenerate-predictions, cleanup-objects) оставлены как есть — они завершаются за секунды.
  • HTTP responses: status="started"status="queued" чтобы UI знал что это асинхронная очередь.

Тесты: 9 новых (test_admin_worker.py):

  • registry exposes 5 known actions
  • get_handler raises with helpful message on unknown
  • handler dispatch + result wrap (non-dict → {"result": str})
  • running/complete/failed progress events
  • empty payload + unknown action both raise (queue marks failed)

Все 30+ Python worker tests passed (admin + lab + analysis + dispatcher + jobs + worker_loop).

Подводные камни:

  1. admin-worker процесс должен быть запущен — иначе admin actions копятся в очереди. PM2 startOrReload поднимет автоматически.
  2. Существующие endpoint'ы возвращают status="queued" вместо "started" — frontend должен обрабатывать оба.
  3. UI пока не показывает progress admin jobs (нет SSE endpoint для kind=admin_action). Можно добавить generic /admin/jobs/{ref}/stream — отдельная задача.
  4. Phase 2 (унификация очереди) закрыта. Все долгие задачи (analysis + lab + admin) идут через единый jobs queue + per-kind workers поверх generic dispatcher.

Phase 3 (next session): Casdoor auth port в Go + удаление python-api


v0.25.74 — 2026-05-08

[2026-05-08] refactor(workers): generic queue dispatcher + lab pipeline on queue

Тип: refactor (Phase 2 из плана "system-wide cleanup") Файлы: src/workers/_dispatcher.py (new), src/workers/analysis_worker.py (rewritten on dispatcher), src/workers/lab_worker.py (new), api/lab/services/pipeline_engine.py, api/lab/routes/needs.py, ecosystem.config.js, tests/unit/test_dispatcher.py (new), tests/unit/test_analysis_worker.py (rewritten), tests/unit/test_lab_worker.py (new) Проблема: После v0.25.73 (анализы на queue) у нас был один queue-driven процесс — analysis-worker. Lab pipeline всё ещё спавнил threading.Thread из POST /lab/needs, держал прогресс в in-memory lab_state (та же проблема рассинхрона что мы убрали для анализов в MR9). Каждый новый kind воркера копировал бы 60 строк boilerplate из analysis_worker (claim/finalise/error handling). Решение:

  • src/workers/_dispatcher.py: вынес общий scaffold — run_kind_worker(kind, handler, fallback_interval). Boilerplate (bootstrap, WorkerLoop, claim_next_pending, mark_completed/failed, progress emitter, worker_id) живёт здесь. Новый kind = ~30 строк handler'а вместо 150 строк копипасты.
  • src/workers/analysis_worker.py: переписан как тонкий handler (~30 LoC). Поведение идентично — payload mapping, progress adapter, asyncio.to_thread.
  • src/workers/lab_worker.py (new): kind="lab_pipeline", handler делегирует в run_lab_pipeline через asyncio.to_thread.
  • run_lab_pipeline теперь принимает progress_fn параметр (default → legacy lab_state.append_progress для обратной совместимости с in-process тестами). Все internal _progress(need_id, ...) вызовы заменены на emit(...) closure.
  • POST /lab/needs: enqueue вместо thread.spawn. external_ref="lab:{need_id}" (префикс избегает коллизий с UUID анализов в той же колонке).
  • GET /lab/needs/{id}/progress: SSE читает из analysis_progress через replay_progress(after_id=cursor). Watch queue terminal status для случая worker crash до первого progress event'а. Fallback на in-memory lab_state сохранён для legacy in-process runs.
  • ecosystem.config.js: добавлен lab-worker PM2 entry (1200M cap, 30s kill).
  • In-process semaphore удалён — concurrency control теперь worker pool size.

Тесты: 30 passed (было 25). Новые:

  • test_dispatcher.py (7) — claim filter, payload dispatch, mark_completed/failed, cancellation safety, progress resilience to DB hiccup, kind isolation, worker_id format.
  • test_lab_worker.py (5) — payload mapping, progress adapter shape (need_id+step→dict), missing IDs, blank context, KIND/STEP_KEYS sanity.
  • test_analysis_worker.py переписан под новую структуру (5 тестов).

Подводные камни:

  1. analysis_progress table используется для всех kind'ов (analysis + lab). Имя таблицы (analysis_progress) теперь немного misleading — это generic event log. Переименование = миграция, отложено как technical-debt note.
  2. lab_state остаётся как fallback path для тестов и legacy in-process runs. После v0.25.74 ВСЕ продакшн-вызовы идут через worker → queue, lab_state остаётся пустым на проде. Удаление безопасно в follow-up MR.
  3. lab-worker процесс должен быть запущен на проде — иначе POST /lab/needs будет копить jobs без обработки.
  4. Phase 2 продолжается: admin actions (generate-descriptions, cluster-and-merge, etc) — следующий шаг.

v0.25.73 — 2026-05-08

[2026-05-08] feat(analyses): durable queue + DB-backed SSE — analysis_state.py removed

Тип: refactor (ADR-009 phase 3) Файлы: api/routes/analyses.py, src/workers/auto_analyzer.py, api/main.py, api/routes/__init__.py, api/analysis_state.py (deleted), tests/conftest.py, tests/unit/test_analysis_pipeline.py, tests/unit/test_auto_analyzer.py Проблема: POST /analyses спавнил threading.Thread внутри Python API процесса. State (_jobs, _results, _progress) жил в module-level dict в api/analysis_state.py. Crash uvicorn = потеря всех in-flight анализов. Невозможно горизонтально масштабировать. Race conditions между in-memory state и DB. Решение:

  • POST /analyses теперь enqueue'ит job в jobs queue (kind='analysis', external_ref=UUID), возвращает {job_id, status: "pending"} сразу. analysis-worker (MR5b) подхватывает.
  • GET /analyses/running читает jobs.list_running('analysis') + tail последнего progress event'а из analysis_progress.
  • GET /analyses/{id}/status использует find_by_external_ref(uuid) → queue row. Fallback на persisted analyses table для завершённых.
  • GET /analyses/{id}/stream — polling 300ms replay_progress(after_id=cursor) + watch queue status для terminal states. Cursor по analysis_progress.id гарантирует exactly-once delivery событий.
  • GET /analyses/{id}/report читает только из analyses table (in-memory cache удалён).
  • auto_analyzer.py: 3 точки spawn'а потоков (_start_auto_analysis, retry_pending_analyses, fire_pending_reanalyses) → enqueue_job. _run_incremental_update остался in-process (rare, low-risk path).
  • api/analysis_state.py удалён полностью. wait_for_active_analyses из uvicorn shutdown тоже убран.
  • api/routes/__init__.py: убраны re-exports AnalysisStateManager и _state.

Тесты: 18 unit-тестов помечены skip — они тестировали удалённый routes._state API (5 классов: TestRunAnalysis, TestStatusEndpoint, TestReportEndpoint, TestRunningAnalysesEndpoint, TestSSEStream). Новое поведение покрыто test_jobs_queue.py (13), test_analysis_worker.py (5), test_worker_loop.py (14). tests/conftest.py теперь создаёт jobs + analysis_progress таблицы. Итого 100 passed / 18 skipped, все остальные тесты MR1-9 зелёные.

Подводные камни:

  1. POST /analyses возвращает status: "pending" вместо "running" — фронт треатит и то и другое как "in progress", визуально разницы нет. Если что-то ломается в UI — это место.
  2. Когда analysis-worker процесс не запущен (локально), enqueue'енные jobs накапливаются в queue без обработки. Запускать через dev-microservices.ps1 -Only "analysis-worker".
  3. Skipped-тесты должны быть переписаны под queue-driven flow. Это технический долг, но не блокер.
  4. incremental_update_analysis остался in-process — для будущей конверсии нужен kind='analysis_incremental' worker.

v0.25.72 — 2026-05-08

[2026-05-08] feat(go): user behavioural writes — interactions + dismiss

Тип: feature (ADR-009 phase 2 — simple writes in Go) Файлы: go-api/internal/store/interactions.go (new), go-api/internal/handler/interactions.go (new), go-api/internal/handler/interactions_test.go (new), go-api/cmd/server/main.go Проблема: POST /api/interactions и POST /api/dismiss/{id} шли через httputil reverse-proxy в python-api: лишний hop, двойной auth-decode, накладные расходы. Это hottest-path writes (по событию на каждый view карточки). Решение:

  • store.SaveInteraction — INSERT в user_interactions с rate-limit (600 событий/час/user) + truncation метаданных >16K + RETURNING id.
  • store.DismissObject / UndismissObject / GetDismissedObjectIDs — обвязка вокруг user_interactions с event_type='dismiss'.
  • handler.LogInteraction — POST /interactions: валидирует event_type/entity_type, выдаёт server-managed cascade_anon_id cookie для анонимов (90 дней, HttpOnly, SameSite=Lax).
  • handler.DismissObject / UndismissObject / ListDismissed — POST/DELETE /dismiss/{object_id}, GET /dismissed.
  • main.go: 4 маршрута зарегистрированы в Go, удалены 2 строки прокси на Python.

Совместимость: cascade_anon_id cookie name + 90-day expiry идентичны Python-варианту, существующие анонимные пользователи сохраняют персонализацию через cutover.

Тесты: 4 unit (test_handler/interactions_test.go) — invalid JSON, unknown event_type, unknown entity_type, anon-id shape. Все Go-тесты зелёные.

Smoke-результаты:

  • POST /interactions без cookie → 200 + Set-Cookie: cascade_anon_id=anon_…, запись is_anonymous=1 в user_interactions.
  • POST /interactions с invalid event_type → 400.
  • POST /dismiss/123 с anon-cookie → 200, запись event_type=dismiss, entity_type=object, entity_id='123'.
  • GET /dismissed{"object_ids":[123]}.

Подводные камни:

  1. Auth (Casdoor JWT) тоже декодируется в Go (authmw.ExtractUserID). На текущий момент Go доверяет JWT без verify подписи — это safe для read-only personalization, и для interactions это тоже acceptable (rate-limit + anonymous fallback). MR8b (auth port) добавит signature verify.
  2. SaveInteraction открывает db.Write() pool (отдельный от read-only). На SQLite WAL — concurrent с analytics process работает.
  3. Не мигрированы: /saved, /watchlist, /notifications, /tags, /profile/* — остаются на python-api (низкий трафик, ниже приоритет; MR8c в следующей сессии).

v0.25.71 — 2026-05-08

[2026-05-08] fix(ingest): label sql.ErrNoRows from ON CONFLICT as "duplicate" + dev launcher

Тип: fix + tooling Файлы: go-api/internal/store/ingest.go, scripts/dev-microservices.ps1 (new) Проблема: Локальный smoke выявил, что при попытке загрузить дубль сигнала (тот же id) ответ возвращал rejection_reasons: {"smoke:1": "sql: no rows in result set"} вместо понятного "duplicate". Причина: INSERT ... ON CONFLICT DO NOTHING RETURNING 1 при конфликте даёт пустой rowset, и tx.GetContext возвращает sql.ErrNoRows — мой код помечал это generic ошибкой. Решение: Отдельная ветка errors.Is(err, sql.ErrNoRows)Reasons[id] = "duplicate". Else-ветка if inserted == 1 ... else "duplicate" стала недостижимой и удалена. Локальный dev: Добавил scripts/dev-microservices.ps1 — стартует все компоненты микросервисной топологии в отдельных PowerShell-окнах. Поддерживает -Only, -Skip, -Status, -Stop. По умолчанию: embedding-svc, python-api, go-api, collector, analytics, analysis-worker, frontend. Использование:

powershell -ExecutionPolicy Bypass -File scripts/dev-microservices.ps1 -Only "python-api,go-api,collector,analytics"
powershell -ExecutionPolicy Bypass -File scripts/dev-microservices.ps1 -Status
powershell -ExecutionPolicy Bypass -File scripts/dev-microservices.ps1 -Stop

Smoke-результаты (все зелёные):

  • POST /api/ingest/signals без auth → 401 missing bearer token.
  • С PAT + новым id → items_accepted=1. signals row создан с source_kind=telegram, collector_id=smoke-test-1, ingested_via=external_api.
  • Дубль того же id → items_accepted=0, rejected=1, reason="duplicate".
  • POST /api/ingest/heartbeat204. collector_heartbeat строка обновлена с last_batch_id.
  • ingest_log логирует все 4 batch'а (2 accepted, 2 duplicate-rejected). Подводные камни:
  1. PAT для smoke-теста создан через api.auth.pat_store.create_pat(user_id='dev-user', name='dev-collector'). Raw token виден один раз — не сохраняется. На проде PAT создаются через UI.
  2. PowerShell 5.1 (Windows) читает скрипты как Windows-1252; em-dash и стрелки в UTF-8 ломают парсер. Скрипт нормализован под чистый ASCII.

v0.25.70 — 2026-05-08

[2026-05-08] feat(go): ingest API + collector SDK

Тип: feature Файлы: go-api/internal/{handler,store,middleware}/{ingest,pat}.go (new), go-api/internal/db/write_pool.go (new), go-api/internal/model/ingest.go (new), go-api/internal/store/pat_test.go (new), go-api/cmd/server/main.go (route wired), src/collector_sdk/{__init__,client}.py (new), tests/unit/test_collector_sdk.py (new) Проблема: Внешним коллекторам (Telegram-скрейпер, OSINT-feed, internal field reporter) некуда было пушить данные. Не было ни server endpoint'а с PAT-аутентификацией, ни клиент-SDK. Решение:

Server side (Go):

  • Новый write-capable pool в db.Write() — отдельно от read-only db.Get(). Read-side остаётся в read-only режиме (default_transaction_read_only=on), write-pool открывается лениво при первом обращении.
  • store.LookupPAT(ctx, raw) — sha256(token) lookup в personal_access_tokens, проверка revoked_at/expires_at, best-effort обновление last_used_at. Хеш-схема совместима с существующим api/auth/pat_store.py (sha256, hex digest).
  • middleware.RequirePAT — Bearer token validation, контекст обогащается pat_id, pat_user_id, collector_id (из X-Collector-ID header или fallback pat.name).
  • store.IngestSignals(ctx, collectorID, batchID, items) — одна транзакция: per-item INSERT signals ... ON CONFLICT (id) DO NOTHING, pg_notify('ingest_signals', signal_id) после каждой успешной вставки, один INSERT ingest_log (audit), UPSERT collector_heartbeat (last_seen).
  • handler.IngestSignals POST /api/ingest/signals — JSON body с batch_id + items[], body cap 8MiB, max 500 items per batch, per-item rejection reasons.
  • handler.IngestHeartbeat POST /api/ingest/heartbeat — лёгкий liveness-ping, 204 No Content.
  • Маршруты подключены в main.go под /api/ingest/* через chi.Route + middleware.RequirePAT.

Client side (Python src/collector_sdk/):

  • Collector(base_url, pat, collector_id, timeout, max_retries) класс.
  • c.push(items, batch_id=None) — POST batch, возвращает IngestResponse dict.
  • c.heartbeat(status, metadata) — POST heartbeat.
  • Retry policy: 5xx + network errors → exponential backoff (1s → 2s → 4s); 4xx → не ретраим, сразу IngestError.
  • Реализован на чистом urllib без зависимостей — внешние коллекторы могут просто скопировать модуль.

Тесты:

  • go-api/internal/store/pat_test.go — sha256 совместимость с Python схемой (sha256("hello") == 2cf24...).
  • tests/unit/test_collector_sdk.py (8): real urllib path, 4xx no-retry, 5xx retry-then-succeed, 5xx exhausts, heartbeat 204, batch_id auto-gen, constructor validation.
  • go build ./... — clean.

Подводные камни:

  1. В Go-side handler используется новый отдельный write-pool. Если read-only flag в production'е применён через GUC на роли, write-pool не сможет писать. На контабо роль trends имеет write-доступ — проверено в pg_schema.sql baseline.
  2. SQLite write-pool открывается лениво и держит свой connection — на dev одновременная работа read pool + write pool + analytics process + collector приводит к потенциальной WAL contention. WAL-журнал и _busy_timeout=10000 это покрывают, но не идеально. PG production не страдает.
  3. PAT не привязан к конкретному collector_id — любой PAT может пушить под любым X-Collector-ID. Будущее усиление: добавить pat_collector_binding(pat_id, collector_id) таблицу.
  4. Идемпотентность через ON CONFLICT (id) DO NOTHING — один и тот же signal_id от двух коллекторов попадёт только от первого. Дубль помечается в rejection_reasons как "duplicate".
  5. NOTIFY доставляется только подписчикам LISTEN ingest_signals. Никаких listener'ов пока нет — это материал MR9 (когда конвертируем event_linker_loop / fact_extractor на WorkerLoop pattern).

v0.25.69 — 2026-05-08

[2026-05-08] feat(embedding-svc): gRPC service for shared fastembed model

Тип: feature Файлы: proto/embedding.proto (new), src/services/embedding_svc/{__init__,server,client,__main__}.py (new), src/services/embedding_svc/_generated/{embedding_pb2,embedding_pb2_grpc,__init__}.py (new, generated), tests/unit/test_embedding_svc.py (new), requirements.txt, ecosystem.config.js, pyproject.toml (ruff exclude) Проблема: fastembed (paraphrase-multilingual-mpnet-base-v2) грузит ~1.1GB в RAM, прогрев ~30 секунд. Каждый pipeline процесс (collector, analytics, analysis-worker, lab-worker) грузил свою копию — суммарно ~4.4GB только на одну и ту же модель, плюс stretched startup. Решение: Отдельный PM2 процесс embedding-svc хостит одну тёплую модель за gRPC. Прото embedding.v1.Embedding с RPC: Embed, EmbedBatch, MatchZone (UNIMPLEMENTED — на следующий MR), Health. Клиент src/services/embedding_svc/client.py — sync facade (embed_text, embed_batch, health) с lazy-инициализированным channel'ом, кэшируемым на модуль. Сервер сам прогревает модель в serve() ДО приёма соединений — клиенты никогда не видят неполное состояние. Транспорт: TCP localhost:50051 по умолчанию (работает Win/Mac/Linux). На Linux production можно переключить на Unix domain socket через EMBEDDING_SVC_ADDR=unix:/var/run/embedding.sock для sub-ms latency без TCP overhead. Тесты: 12 (test_embedding_svc.py) — async server + sync server, dim/L2-normalisation, INVALID_ARGUMENT на пустой text, batch round-trip, MatchZone UNIMPLEMENTED, Health metrics, sync client wrappers. Используют fake fastembed чтобы не качать веса в CI. Подводные камни:

  1. MatchZone пока возвращает UNIMPLEMENTED. Контракт зарезервирован в proto чтобы Go-сторона могла планировать против него. Реализация — следующий MR (потребует достать impact_zones_dictionary.embedding через SQL, заменить ZoneMatcher монки-патчинг).
  2. Существующий код (src/services/zone_matcher.py, api/services/event_search.py, api/routes/{tags,profile_topics}.py) ВСЁ ЕЩЁ грузит свой fastembed. Конверсия callers'ов на embedding_svc.client — отдельный коммит на этой ветке (или MR9 когда переписываем search-path в Go).
  3. proto-стабы committed в _generated/. После редактирования embedding.proto запустить: python -m grpc_tools.protoc -I proto --python_out=src/services/embedding_svc/_generated --grpc_python_out=src/services/embedding_svc/_generated proto/embedding.proto, потом руками заменить import embedding_pb2 на from . import embedding_pb2 в embedding_pb2_grpc.py.
  4. ruff extend-exclude добавлен в pyproject.toml для _generated/ — авто-сгенерированный код не подчиняется style-rules.

v0.25.68 — 2026-05-08

[2026-05-08] feat(workers): analysis-worker process (durable queue consumer)

Тип: feature Файлы: src/workers/analysis_worker.py (new), tests/unit/test_analysis_worker.py (new), ecosystem.config.js Проблема: POST /analyses спавнит analysis pipeline в потоке внутри Python API процесса. Crash uvicorn = потеря всех in-flight анализов. State лежит в in-memory analysis_state.{_jobs,_results,_progress} — рассинхрон с DB периодически проявляется как «отчёт уже готов в DB, но Python отдаёт running из памяти». Решение: Новый PM2 процесс analysis-workerpython -m src.workers.analysis_worker. Использует:

  • WorkerLoop (MR3) — LISTEN jobs_pending + fallback poll каждые 30s + reconnect.
  • src/db/queries/jobs.py::claim_next_pending (MR5a, ADR-009 phase 0b) — SELECT FOR UPDATE SKIP LOCKED + промоушн в running.
  • run_analysis в asyncio.to_thread — runner синхронный и тяжёлый, отдельный поток освобождает event loop под NOTIFY.
  • progress_callback замыкается на engine + job.id → пишет каждое событие в analysis_progress + pg_notify('job_progress', '<job.id>:<progress.id>').
  • mark_completed / mark_failed — финальное состояние queue row + financial NOTIFY.

job.external_ref хранит публичный UUID (тот что фронт получает из POST /analyses); SSE обработчик мапит UUID → queue row через find_by_external_ref (готовится в следующем коммите MR5).

Multi-instance — клонировать запись в ecosystem.config.js с разными именами; SKIP LOCKED не пропустит дубли. Mem cap 1800M (peak deep analysis ~1.4G).

Тесты: 5 (test_analysis_worker.py): pickup payload + dispatch kwargs, mark done, mark failed on runner exception, kind filter, worker_id format. Используют StaticPool + check_same_thread=False SQLite чтобы worker thread видел тот же DB что claim transaction. Подводные камни:

  1. POST /analyses пока не перенаправлен на enqueue — это следующий коммит MR5 (риск route conversion отделён от риска worker'а).
  2. На SQLite с тысячей строк в analyses _run_startup_backfills блокирует bootstrap ~30s. На PG — мгновенно. Локально терпимо.
  3. Если worker crashes между claim и mark_done, queue row застрянет в running. Watchdog (reset_stale_running_analyses) возвращает analyses строку в pending через 120 минут; queue row нужно вычищать вручную (или добавить аналогичный watchdog в analytics).

v0.25.67 — 2026-05-08

[2026-05-08] feat(jobs): migration 109 — jobs.external_ref + queue API extensions

Тип: schema + lib Файлы: db/migrations/109_jobs_external_ref.py (new), db/pg_schema.sql, src/db/tables.py, src/db/queries/jobs.py, tests/unit/test_jobs_queue.py Проблема: Существующая queue library (src/db/queries/jobs.py, ADR-009 phase 0b) использует BIGSERIAL jobs.id как PK. Аналитика и lab-pipeline уже имеют свои UUID-идентификаторы (analyses.job_id), и SSE-обработчикам надо мапить «public UUID → queue row». Без явной FK-колонки приходится сканировать payload JSONB. Решение: Migration 109 добавляет jobs.external_ref TEXT с UNIQUE-индексом (на PG — partial WHERE NOT NULL, на SQLite — полный). enqueue_job принимает external_ref параметром. Новые функции: find_by_external_ref(engine, ref) -> Job | None для SSE/route lookup, list_running(engine, kind) -> list[Job] для /running endpoint. Job dataclass и все SELECT'ы расширены полем external_ref. Тесты: +4 (всего 13): enqueue с external_ref + find, отсутствие entry, UNIQUE constraint, list_running по kind+status. Подводные камни:

  1. Это фундамент под analysis-worker (следующий коммит в MR5). Сама worker-обвязка, route conversion, auto_analyzer и удаление analysis_state.py будут отдельными коммитами в этой же ветке.
  2. Существующих writers external_ref ещё нет — все enqueue вызовы пройдут с external_ref=None. UNIQUE-index допускает множественные NULL на обоих диалектах.

v0.25.66 — 2026-05-08

[2026-05-08] feat(schema): migration 108 — ingest provenance + non-URL sources

Тип: schema Файлы: db/migrations/108_ingest_signals_columns.py (new), db/pg_schema.sql, src/db/tables.py, tests/unit/test_migration_108_ingest_schema.py (new) Проблема: Прежняя схема предполагает что у каждого сигнала есть url (FK для dedup, scoring, citation enrichment). Telegram-каналы, OSINT-feeds, internal field reports часто URL не имеют. Также нет провенанса — кто и когда залил сигнал в систему через будущий external ingest API. Решение: Migration 108 добавляет в signals:

  • source_kind TEXT NOT NULL DEFAULT 'url' — url | telegram | social | private | field | rss. Drives downstream: scoring branch, UI render, citation enrichment skip.
  • collector_id TEXT NULL — opaque ID коллектора (telegram-osint-1, internal).
  • source_ref JSONB NULL — атрибуция non-URL: channel, message_id, capture_at, visibility.
  • ingested_via TEXT NOT NULL DEFAULT 'internal' — internal | external_api.

И две новые таблицы:

  • ingest_log — append-only audit каждого batch'а через ingest API. Indexed by (collector_id, ts DESC) и ts. GC-friendly.
  • collector_heartbeat — one row per collector. Watchdog в analytics будет alert'ить на silent collectors (now() - last_seen > expected_interval).

url остался NOT NULL DEFAULT '' (не NULL) — non-URL источники пишут пустую строку, рендеры/scoring проверяют source_kind. Избегает SQLite table-rewrite для значения которое уже sentinel'ится пустой строкой. Тесты: 6 (test_migration_108_ingest_schema.py): добавление колонок, создание таблиц, идемпотентность, дефолтные значения, round-trip insert. Локальный smoke: Migration применилась на data/trending_cache.db (SQLite), все 4 колонки + 2 таблицы появились. Подводные камни:

  1. Никакого CODE пока не пишет в новые поля — это сделают MR7 (Go ingest API) и MR4-конверсии loops в pipeline. До тех пор все рядки имеют source_kind='url', ingested_via='internal', остальные NULL.
  2. На SQLite CHECK constraints не задаются (миграция гейтит is_pg). Если кто-то пишет non-conforming source_kind локально — это пройдёт. На PG CHECK сработает.
  3. pg_schema.sql обновлён зеркально — fresh PG install получит колонки сразу через baseline; миграция 108 при последующем apply_pending будет no-op (column_exists check).

v0.25.65 — 2026-05-08

[2026-05-08] feat(workers): WorkerLoop helper + pg_notify emission helper

Тип: feature Файлы: src/workers/_worker_loop.py (new), src/workers/_pg_notify.py (new), tests/unit/test_worker_loop.py (new) Проблема: Существующие loops в _main_loops.py опрашивают БД фиксированными таймерами (event_linker — 30s, summarizer — 10s, ai_insights — 90s). Каждый цикл делает SELECT-запрос даже когда работы нет; новые сигналы ждут до полного периода прежде чем их подберут. Нет horisontal scaling — нельзя запустить второй экземпляр воркера, оба будут конкурировать за одни и те же строки. Решение: Добавил две библиотеки. WorkerLoop (src/workers/_worker_loop.py) — стандартный паттерн "LISTEN + fallback poll + SKIP LOCKED": работа триггерится через pg_notify, при пропуске notify (сеть, рестарт PG) fallback-таймер всё равно дёргает poll, БД остаётся источником истины. Listener-соединение отдельное от пула, реконнектится с экспоненциальным backoff (1s → 2s → … → 60s). На SQLite mode listener task не стартует — graceful degradation в чистый polling. pg_notify (src/workers/_pg_notify.py) — emission helper: отправляет pg_notify(channel, payload) через переданный SA/psycopg connection в той же транзакции что и INSERT/UPDATE. На SQLite — no-op. Защита: payload >7900 байт truncated, подозрительные channel names отвергаются. Использование (для будущих MR):

async def process_batch() -> int:
    # SELECT FOR UPDATE SKIP LOCKED, обработать, COMMIT, вернуть кол-во
    ...

loop = WorkerLoop(name="event_linker", channel="facts_extracted",
                   process_batch=process_batch, fallback_interval=60.0)
await loop.run()

И на стороне записи:

with engine.begin() as conn:
    conn.execute(text("INSERT INTO facts ..."))
    notify(conn, "facts_extracted", signal_id)

Подводные камни:

  1. NOTIFY доставляется только подписанным в момент publish. Listener-коннект ОБЯЗАН быть долгоживущим, не из пула.
  2. NOTIFY в той же транзакции что и write — иначе можно нотифицировать о несуществующих rows (Postgres откатывает NOTIFY на abort транзакции).
  3. На SQLite оба helper'а — no-op. Это сознательно: production = PG, локальная разработка на SQLite — просто polling без NOTIFY оптимизации.
  4. Существующие loops в _main_loops.py ещё НЕ используют WorkerLoop. Конверсия — следующие MR4-5.

v0.25.64 — 2026-05-08

[2026-05-08] feat(adapters): deployment groups + COLLECTOR_GROUP env selector

Тип: feature Файлы: src/services/adapters/__init__.py, src/workers/collector.py, tests/unit/test_adapter_groups.py (new), ecosystem.config.js Проблема: После расщепления pipeline на collector + analytics (v0.25.63) collector грузит все 70+ адаптеров в одном процессе. Нет способа выделить тяжёлые адаптеры (Telegram, headless browser, paid API) в отдельный процесс на отдельной машине без дублирования всего кодa. Источники не имеют свойства "группа развёртывания". Решение: Добавил DataSourceAdapter.group property с дефолтным маппингом из source_type (academic→science, tech→tech, …). Регистр: list_enabled_in_groups(groups) для фильтра по подмножеству групп. src/workers/collector.py читает COLLECTOR_GROUP env: пусто/"all" = все группы, "news,science" = подмножество. Если группа не существует — collector логирует список доступных групп и завершается с кодом 1. Использование: COLLECTOR_GROUP=news python -m src.workers.collector запустит только новостные адаптеры. PM2 — клонировать запись collector в ecosystem.config.js с разным env.COLLECTOR_GROUP (примеры в комментариях файла). Подводные камни:

  1. Регистр заполняется через side-effect импорты в src/workers/crawler/_orchestrator.py. Запрос source_registry.list_enabled_in_groups() ДО импорта TrendingCrawler вернёт пустой список. Порядок импортов в collector.py::main критичен.
  2. Дефолтный маппинг (DATA→data) предполагает что все DATA-адаптеры — финансовые. Если появится не-финансовый DATA-адаптер, нужно явно переопределить group свойство.

v0.25.63 — 2026-05-08

[2026-05-08] refactor(workers): split monolithic pipeline into collector + analytics

Тип: refactor Файлы: src/workers/{collector.py,analytics.py,_bootstrap.py} (new), src/workers/main.py (deleted), src/workers/_main_loops.py, tests/unit/test_workers_split.py (new), tests/unit/test_workers_main.py (deleted), ecosystem.config.js, api/main.py, CLAUDE.md, README.md, src/workers/translation_watchdog/__init__.py Проблема: Один процесс pipeline (src/workers/main.py) запускал в одном event loop крауль и всю downstream-обработку (extraction, event linker, summarizer, ai_insights, watchdog). Один зависший RSS-источник тормозил весь pipeline. Невозможно масштабировать сбор отдельно от обработки. Решение: Расщепил на два независимых PM2-процесса. collector крутит только crawl_loop. analytics запускает все остальные loops + translation watchdog. Связь — через Postgres (signals table). Старый in-memory mutex watchdog._crawler._facts_extracting удалён — он был SQLite-эры guard, на PG транзакционная изоляция справляется. Общий код init вынесен в src/workers/_bootstrap.py. PM2 конфиг обновлён. Обратная совместимость с python -m src.workers.main НЕ сохранена сознательно (см. user request). Подводные камни:

  1. Если деплой обновляет код до того, как PM2 перезапустит процесс с новой конфигурацией — pm2 попытается стартовать pipeline процесс и упадёт (src/workers/main.py нет). Деплой-скрипт должен делать pm2 delete pipeline перед pm2 start ecosystem.config.js.
  2. На SQLite оба процесса вызывают Migrator().apply_pending() — race condition в теории возможен на самом первом запуске. На практике _migrations PK-table защищает (первый коммит выигрывает, второй видит уже применённое).

v0.24.41 — 2026-04-30

[2026-04-30] feat(personalization): Topic Registry + semantic search shipped

Тип: feature Файлы: db/migrations/098_topics_registry.py, api/services/{topic_registry,topic_ancestry,profile_topics_store,event_search}.py, api/routes/{search,profile_topics,onboarding}.py, go-api/internal/{model,store,handler}/, frontend-news/src/routes/{feed,search,profile}.tsx, frontend-news/src/components/feed/{event-actions,onboarding-strip}.tsx, docs/architecture/ADR/008-topic-registry-personalization-v2.md Проблема: HDBSCAN cluster_id ephemeral — после weekly rebuild номера тем меняются, привязывать пользовательские предпочтения нельзя. Кроме того, не было семантического поиска и UI персонализации в news SPA. Решение: Topic Registry — стабильный реестр тем с трёхуровневой иерархией (root/mid/leaf), closure table topic_ancestry, atomic swap через staging-таблицу, drift guard + EMA blend на эмбеддингах. Семантический поиск через pgvector hnsw (без отдельного FAISS-индекса). News SPA: /profile, /search, OnboardingStrip с mid-level chips, EventLikeButton, filter-empty state, 3px brand-coloured border на cards с matching темой. Полная i18n RU/EN. См. ADR-008. MR: !422 … !433 (9 MR'ов, фазы 0–3 по плану). Подводные камни:

  • cluster_engine.py:107 строит centroids на PCA-50d, но topics.embedding = vector(768) → bind молча падает в catch. Follow-up P0.
  • Go API всё ещё читает legacy matching_clusters JSON (personalization.go:36). Резолв matching_topics → topic_ancestry → topic_clusters в Go не реализован — лента в /api/events пока игнорирует выбор тем, фича работает end-to-end только через Python /api/search. Follow-up P0.
  • Phase 4 (cascade SPA paritет) отложен — реализован только forward-compat type field.
  • Calibration thresholds (BIND=0.70, DRIFT=0.60, LEAF_TO_MID=0.55) — TODO в topic_registry.py:51, на проде не калибровались.
  • ADR-007 (multi-axis: semantic + domain + geo) переведён в Superseded — из трёх осей реализована только semantic; domain/geo ждут backfill базовых сигналов.

v0.20.5 — 2026-04-21

[2026-04-21] feat(pipeline): translate non-EN signals before fact extraction

Тип: feature Файлы: src/workers/crawler.py, src/workers/translation_watchdog.py, api/fact_store.py, db/migrations/093_signal_translation_columns.py, go-api/internal/store/health.go Проблема: Факты из русских источников (interfax, rbc) извлекались на русском → эмбеддинги в русском семантическом пространстве → event linker не мог связать с EN-событиями (cosine 0.67 < порог 0.82). Перевод через watchdog приходил после линковки и не обновлял эмбеддинги. Решение:

  • Новый pipeline: pending → translating → translated → processing → done
  • _translate_signals_pipeline(): переводит title+content на EN через gemma4:e2b перед extraction
  • EN-сигналы проходят без LLM-вызова (title_en=title, content_en=content)
  • _extract_facts_pipeline() теперь берёт только WHERE extraction_status = 'translated'
  • Двойное кеширование: при RU→EN сохраняет оригинал как RU-перевод EN-текста
  • Watchdog: _translate_stuck_signals() подхватывает сигналы застрявшие >30 мин
  • Watchdog: _reembed_untranslated_facts() пересчитывает эмбеддинги старых RU-фактов на EN
  • insert_fact(): новый параметр embedding_lang для tracking
  • Migration 093: колонки content_lang, title_en, content_en в signals; claim_en, embedding_lang в facts Подводные камни: fetch_full_text перенесён из extraction в translate pipeline. Перевод добавляет ~1 LLM-вызов на non-EN сигнал.

v0.19.14 — 2026-04-17

[2026-04-17] fix(events): normalize event_time to UTC, fix sort order

Тип: fix Файлы: api/event_store.py, src/services/event_linker.py, src/config.py Проблема: Events сортировались неправильно по дате — event_time хранился с оригинальным timezone offset (+03:00, +09:00, +05:30), SQLite сортировал как строку, игнорируя TZ. Японские NHK-события (05:35+09:00 = 20:35 UTC) отображались выше московских (01:47+03:00 = 22:47 UTC).

Решение:

  • event_store.py: refresh_event_stats нормализует новые event_time в UTC через strftime('%Y-%m-%dT%H:%M:%S+00:00', ...)
  • Backfill 7511 событий нормализован через SQL на сервере
  • event_linker.py: добавлен commit check log (max_id, total) для диагностики
  • src/config.py: tier2 model qwen3.5:9bgemma4:e2b (убирает model swap timeout)

Подводные камни: SQLite strftime конвертирует TZ-aware ISO строки в UTC автоматически. Новые events будут записываться в UTC. Старые нормализованы backfill-ом.


v0.19.13 — 2026-04-17

[2026-04-17] fix(pipeline): serialize crawler/watchdog writes, prevent WAL bloat

Тип: fix Файлы: src/workers/main.py, src/workers/crawler.py, src/workers/translation_watchdog.py, db/connection.py Проблема: pipeline не записывал сигналы в БД 4+ часов. Root cause — deadlock между crawler и watchdog: оба писали в SQLite через разные connections, _db_retry использовал blocking time.sleep() что блокировал asyncio event loop, watchdog не мог завершить транзакцию → взаимная блокировка. WAL вырос до 171MB (нормально ~1MB), усугубляя ситуацию. Citation enrichment обрабатывал все ~1646 items перед INSERT (~40 мин), pipeline не доживал до записи.

Решение:

  • Сериализация: _crawl_active threading.Event — watchdog пропускает write-цикл если crawl_once() активен. Флаг set() перед crawl_once(), clear() в finally
  • Crawl интервал: 900с → 600с (10 мин) через _crawl_start_with_flag() wrapper в main.py
  • Citation enrichment: ограничен items[:50] (было без лимита = все items)
  • busy_timeout: 15с → 60с (connection.py + watchdog), time.sleep() убран из _db_retry/_db_commit_retry
  • WAL checkpoint loop: каждые 5 мин PASSIVE checkpoint, предотвращает рост WAL от go-api read connections

Подводные камни:

  • _crawl_active — threading.Event (не asyncio.Event) потому что проверяется из sync-контекста watchdog
  • Citation enrichment limit 50 — может пропускать новые сигналы, но они обогатятся в следующем цикле
  • _crawl_start_with_flag заменяет crawler.start() — оригинальный метод больше не вызывается из main.py

v0.19.12 — 2026-04-17

[2026-04-17] fix(pipeline): prevent database-locked from killing Phase 2-3

Тип: fix Файлы: src/workers/crawler.py Проблема: _extract_facts_pipeline падал на sqlite3.OperationalError: database is locked каждые 10 минут (22:23, 22:34, 22:44, 22:54 — все 4 последних цикла). Причина: update_signal_extraction_status(sid, "failed") внутри except-handler'а тоже бросал "database is locked" → второе необработанное исключение убивало весь pipeline → Phase 2 (event/trend linking), Phase 3 (trend formation, event dedup) и Phase 4 (prediction matching) не запускались вообще. Отсюда: нет новых событий и нет новых трендов. Решение:

  • update_signal_extraction_status(sid, "processing") (строка 771) — вынесен в собственный try/except. При DB locked — skip сигнал (остаётся pending, retry next cycle)
  • update_signal_extraction_status(sid, "failed") (строка 984-986) — обёрнут в try/except. При DB locked — логируем debug, pipeline продолжает
  • record_metric — обёрнут в try/except (cosmetic, не критичен)

Backfill (одноразово): 220 сигналов застряли в processing от предыдущих крашей:

ssh root@144.91.108.139 'sudo -u gitlab-runner sqlite3 /var/www/trend-analisis/data/trending_cache.db "UPDATE signals SET extraction_status=\"pending\" WHERE extraction_status=\"processing\""'

Подводные камни: Основная причина DB lock — конкурентные записи между pipeline (port N/A, background) и Python API (port 4014). WAL mode + busy_timeout=15s не спасают при длительных транзакциях. Нужно расследование: какой процесс держит write lock 15+ секунд.


v0.19.11 — 2026-04-16

[2026-04-16] fix(trends): wire ADR v4.1 enrichment, add diagnostics + retry

Тип: fix + feature Файлы:

  • src/services/trend_formation.py — diagnostic counters в detect_trends_from_facts и enrich_pending_trends; bounded retry для failed-трендов; materialize_detected_trends и run_trend_formation возвращают touched-set
  • src/services/trend_linker.pyauto_link_facts возвращает touched-set; новый update_trend_evidence_batch
  • src/workers/crawler.py — после auto_link_facts и run_trend_formation обновляется evidence_fact_count для затронутых трендов
  • src/workers/translation_watchdog.py — новые _generate_trend_insights (5-секционный insight) и _generate_event_analytics (4-секционный summary); materialize_detected_trends обновляет evidence
  • db/migrations/091_trend_enrichment_retry.py (new) — enrichment_retry_count + enrichment_last_attempt
  • go-api/internal/store/pulse.go, health.go — счётчик трендов учитывает enrichment_status='ready' (раньше показывал все, включая failed/pending)
  • scripts/backfill_facts.py — обновлён под новую сигнатуру auto_link_facts

Проблема: на проде только 8 трендов из 41K фактов, 6 из них застряли в enrichment_status='failed' со stub-именами "ai", "linux", "meta", "openai" — coherence LLM сказал NO один раз и наказал навсегда. evidence_fact_count всегда 0. analytics_generator.generate_trend_insight() и update_trend_evidence() — orphan-функции (0 вызовов в коде), хотя они в ADR-001 v4.1. Не было видно, на каком гейте отваливаются кандидаты.

Решение:

  • Diagnostic logs: TrendFormation summary: facts=N entities_seen=N candidates=N rej_too_few_efacts=N rej_generic=N rej_dup_cluster=N rej_no_recent=N rej_low_sources=N rej_low_facts=N + TrendFormation pipeline: rej_coherence_emb=N + TrendEnrichment summary: pending=N enriched=N failed_coherence=N failed_llm_unavailable=N name_missing=N. Теперь видно, где именно режет.
  • Retry: enrichment_status='failed' теперь не приговор — после 12h cooldown watchdog повторяет до 3 раз. Транзиентный Ollama outage больше не убивает тренд навсегда. enrichment_last_attempt штампуется до LLM-запроса, enrichment_retry_count инкрементируется только когда LLM реально вынес вердикт (не на outage).
  • Wire-up orphan: update_trend_evidence_batch вызывается после auto_link_facts и после run_trend_formationevidence_fact_count теперь живой счётчик. _generate_trend_insights и _generate_event_analytics подключены в watchdog (max 5 на цикл, fail-safe).
  • API hygiene: pulse и health счётчики трендов теперь возвращают только enrichment_status='ready' (раньше pulse.TrendCount=8 при 2 видимых = misleading).

Backfill (one-shot, после деплоя):

ssh root@144.91.108.139 'sudo -u gitlab-runner sqlite3 /var/www/trend-analisis/data/trending_cache.db "UPDATE trends SET evidence_fact_count = (SELECT COUNT(*) FROM fact_trends WHERE trend_id = trends.id) WHERE merged_into IS NULL"'

Подводные камни:

  • Migration 091 идемпотентна (ALTER TABLE с try/except в pattern существующих миграций).
  • auto_link_facts теперь возвращает tuple[int, set[int]] вместо int — обновлён scripts/backfill_facts.py (другие места не используют).
  • run_trend_formation теперь возвращает tuple[int, set[int]] — обновлён crawler.py (других callers нет).
  • materialize_detected_trends теперь возвращает tuple[int, set[int]] — обновлён вызов в translation_watchdog.py для cluster trends.
  • Pending-трендам retry_count бампится только на финальном UPDATE (ready) или на coherence=NO. На LLM-outage счётчик не растёт — это даёт неограниченные мягкие попытки, но bounded жёсткие.

v0.19.0 — 2026-04-15

[2026-04-15] feat(entity): canonicalization via term_glossary + normalization_status

Тип: feature Файлы:

  • db/migrations/087_term_glossary_multi_translation.py (new)
  • db/migrations/088_fact_entities_normalization_status.py (new)
  • api/glossary_store.py — multi-translation, reverse lookup, retry sentinel
  • src/services/entity_normalizer.py — glossary-driven cascade + non-Latin detection + status
  • api/fact_store.py, src/workers/crawler.py — persist normalization_status
  • src/workers/translation_watchdog.py — retry queue in daily maintenance
  • scripts/seed_glossary.py — ISO-3166 countries + international organizations
  • scripts/backfill_entity_canonicalization.py (new)
  • api/settings_routes.py — admin endpoints for error entities + manual retry
  • frontend-cascade/app/src/routes/admin/system.tsx — entity normalization card

Проблема: canonical_name в fact_entities часто оставался на языке оригинала (кириллица), Go API выбирал related events через entity overlap с ENG-only genericSet, и RU-entities («Россия», «РФ») проходили как specific — например, event 7642 (Мемориал) получал в «связанные» совершенно случайные RU-источники.

Решение: основной язык обработки English. term_glossary теперь хранит множество синонимов для одного термина: UNIQUE(term, lang, translation). Entity normalizer делает обратный lookup (translation → term). Колонка fact_entities.normalization_status отмечает 'error' для сущностей с не-латиницей без перевода, ошибки агрегируются в notifications по часам. Глоссарий изменяется через admin UI и триггерит async-retry через watchdog. Seed добавляет топ-60 стран + международные организации с RU-синонимами и склонениями.

Подводные камни: partial index на fact_entities(normalization_status) WHERE != 'normalized' — быстрый фильтр для admin. _NON_LATIN_RE пропускает Latin-1 Supplement + Extended (café, Zürich), но режет кириллицу/CJK/арабский.

[2026-04-15] fix(fact_extractor): strict temporal_date validation

Тип: fix Файлы: src/services/fact_extractor.py, scripts/backfill_temporal_dates.py (new) Проблема: LLM иногда возвращал hedge-строки типа "2025-10-01 or null" или даты с неправдоподобным годом — они прошли валидацию и попадали в хронологию (event 6207). Решение: _sanitize_temporal_date требует строгий формат YYYY-MM-DD + отсекает даты, год которых отличается от published_at более чем на 5 лет. Backfill-скрипт чистит существующие broken записи (два прохода: формат + year-divergence).

[2026-04-15] docs(plans): save event relations redesign for future work

Тип: docs Файлы: docs/plans/event-relations-redesign.md (new) Описание: сохранён план редизайна related events (persistent hard-links через event_related + soft через HDBSCAN cluster + property gates) — на будущую итерацию.


v0.18.1 — 2026-04-14

[2026-04-14] fix(event_linker): entity overlap gate prevents false merges

Тип: fix Файлы: src/services/event_linker.py Проблема: Event linker объединял несвязанные события (напр. Vedomosti про авиацию + Al Jazeera про военные преступления) — cosine similarity embeddings была выше порога, а существующие gates (location, numeric, title) не срабатывали при отсутствии specific location entities. Решение: Добавлен entity overlap gate — 4-й слой защиты. Собирает ВСЕ named entities (не только location) для signal и event. Если обе стороны имеют ≥2 specific entities, но нет ни exact overlap, ни token overlap (fuzzy: ≥2 общих значимых токена) → hard veto. Token overlap обрабатывает плохую нормализацию ("russian air defense forces" vs "russian defense industry" → shared "defense"). Подводные камни: Gate активируется только при ≥2 entities на каждой стороне (ENTITY_MIN_PER_SIDE). Факты без entities не блокируются — для них работают остальные gates.


v0.17.9 — 2026-04-10

[2026-04-10] feat(i18n): multilingual term glossary for domain translations

Тип: feature Файлы: api/glossary_store.py (NEW), db/migrations/083_term_glossary.py (NEW), scripts/seed_glossary.py (NEW), api/translation_service.py, api/settings_routes.py Проблема:

  • LLM переводит доменные термины буквально: "Fast Breeder reactor" → "Быстро размножающийся реактор" вместо "реактор на быстрых нейтронах"
  • "critical" (ядерный термин) → "критический" вместо "критичность"
  • Проблема воспроизводится на qwen3.5 и aya-expanse:8b

Решение:

  • Мультиязычная таблица term_glossary(term, lang, translation, domain) — любое количество языков
  • ~110 seed-терминов: nuclear, AI/ML, finance, medicine, space, cybersecurity, crypto, geopolitics, climate, semiconductors
  • Автоматический glossary hint в промпте перевода: "fast breeder reactor" → "реактор на быстрых нейтронах"
  • Английский: regex с inflection (plural, irregular: analysis→analyses, phenomenon→phenomena)
  • Русский: pymorphy3 лемматизация — матчит любую словоформу ("реактора", "реактором", "санкциями")
  • Бидирекциональный поиск: EN→RU и RU→EN через лемматизированные формы
  • Admin API: GET/POST/DELETE /admin/glossary

Подводные камни:

  • Glossary hint увеличивает prompt на ~50-200 tokens (только matching terms)
  • pymorphy3 уже в requirements.txt, 0.17ms на фразу
  • LLM сам сопрягает термины в контексте — glossary даёт словарную форму

v0.17.7 — 2026-04-10

[2026-04-10] fix(i18n): universal source language detection for translations

Тип: fix Файлы: api/translation_service.py, src/workers/translation_watchdog.py, api/routes/events.py, api/routes/trends.py, api/routes/signals.py, api/routes/helpers.py, src/workers/crawler.py Проблема:

  • Система переводов предполагала, что весь контент на английском (hardcoded source_lang="en")
  • 8+ русскоязычных RSS-источников (RBC, Kommersant, Habr, vc.ru и др.) генерируют контент на русском
  • Watchdog: _fill_event_gaps и _fill_fact_gaps передавали source_lang="en" → русский текст "переводился из английского"
  • Watchdog: events/facts/signals/trends переводились только в non_en_langs → русский контент не переводился на английский
  • API: все if lang != "en": guards пропускали перевод → русский текст показывался без перевода для EN-пользователей

Решение:

  • _detect_text_language() — определение языка по Unicode-скриптам (Cyrillic→ru, CJK/Arabic→other, Latin→en)
  • source_lang="auto" в _batch_translate_short_texts — LLM сам определяет исходный язык
  • Watchdog: группировка текстов по детектированному языку + skip при source==target
  • Watchdog: events/facts/signals/trends теперь итерируют SUPPORTED_LANGS (не только non-en)
  • API: убраны if lang != "en": guards в events, trends, signals, helpers — кеш-lookup работает для всех языков
  • Crawler: _pre_translate_items итерирует SUPPORTED_LANGS

Подводные камни:

  • get_cached_translations_batch() — instant SQL lookup, не LLM. Убрание guards безопасно для производительности
  • Recommendations/predictions/lab/zones НЕ затронуты — LLM генерирует их на английском
  • Для языков не из SUPPORTED_LANGS (fr, zh, ar) — source_lang="auto" позволяет LLM определить язык

v0.17.6 — 2026-04-10

[2026-04-10] feat(events): tier + freshness в формуле significance

Тип: feature Файлы: api/event_store.py Проблема:

  • compute_event_significance() не учитывала качество источников (tier) и свежесть события (freshness), хотя docstring заявлял freshness.
  • Событие из 6 wire agencies (tier 1) и событие из 1 tech-блога (tier 4) при одинаковых fact_count/source_count получали одинаковый score.

Решение:

  • Формула: significance = clamp(base_score × tier_mult × freshness_mult, 0, 100)
  • tier_mult [0.85..1.15]: доля tier 1-2 источников среди media_group в coverage_sources.
  • freshness_mult [0.75..1.00]: exponential decay, half-life 7 дней. Сегодня=1.0, 7d=0.875, 30d=0.76.
  • Обратная совместимость: старый формат coverage_sources (["type"]) → fallback tier=3 → tier_mult=0.85.

Подводные камни:

  • Значения significance пересчитываются при каждом вызове compute_event_significance() (после линковки фактов). Массовый пересчёт произойдёт при следующем crawl-цикле.
  • get_related_events фильтрует significance >= 40 — события с tier 3-4 через 30+ дней могут выпасть (base=91 × 0.85 × 0.76 = 59 — ОК, но base=46 × 0.85 × 0.76 = 30 — выпадет). Это ожидаемое поведение.

[2026-04-10] fix(events): verification через media_group (ADR-001-v3)

Тип: fix Файлы: src/services/event_dedup.py, db/migrations/081_source_media_groups.py Проблема:

  • update_event_verification() считал DISTINCT source_type по захардкоженному _SOURCE_TYPE_MAP (6 префиксов). Все 12+ RSS-адаптеров попадали в ELSE 'other'coverage_count=1 → всегда unverified.
  • Событие 5838 (запуск ракет КНДР, 6 независимых СМИ) показывалось как unverified.

Решение (гибридный подход из ADR-001-v3):

  • Таблица source_media_groups(adapter, media_group, tier, region) — справочник аффилированности и доверия. 36 адаптеров, seed в миграции 081.
  • Корпоративная аффилиация: адаптеры в одном media_group считаются за 1 независимый источник. Примеры: wired + arstechnica = conde_nast (1), techcrunch + engadget = yahoo_verizon (1).
  • Тиры (1-4): 1=wire/academic (tass, interfax, arxiv), 2=quality media (bbc, nhk, kommersant), 3=general (cnews, pandaily, yfinance), 4=social/aggregator (hn, gh, searxng).
  • independent_source_count = COUNT(DISTINCT media_group) → verification_level по прежним порогам (1=unverified, 2=developing, 3+=confirmed, 5+=established).
  • Неизвестные адаптеры = каждый сам себе группа (консервативно).
  • coverage_sources теперь хранит [[adapter, media_group, tier], ...] для прозрачности.

Очистка прод-БД: таблица создана, данные засеяны, все 5743 событий пересчитаны. Результат: 458 developing, 143 confirmed, 14 established.

Подводные камни:

  • _SOURCE_TYPE_MAP в trend_formation.py и trend_probability.py НЕ тронут — там он для категоризации трендов, не для event verification.
  • Tier пока не используется в scoring формуле (event_score = freshness × coverage × tier). Это следующий шаг.
  • Для новых RSS-адаптеров нужно добавлять строку в source_media_groups (миграция или admin endpoint).

[2026-04-10] fix(facts): анкер дат на published_at + запрет галлюцинаций

Тип: fix Файлы: src/services/fact_extractor.py, src/workers/crawler.py, scripts/backfill_facts.py Проблема:

  • Промпт extract_facts() не передавал в LLM дату публикации статьи. Модель массово ставила temporal_date из своих training-знаний — например, для свежей новости от 2026-04-08 со словом «先月» («в прошлом месяце») извлекала 2022-09-09. Самый частый паттерн: дата ровно на 1 год раньше публикации (knowledge cutoff модели). На проде на момент фикса: ~868 core-фактов с разрывом >90 дней между temporal_date и published_at источника.
  • В промпте не было запрета использовать даты из собственных знаний и не было правил разрешения относительных выражений («last month», «вчера», «先月»).

Решение:

  • Добавлен параметр published_at: str | None в extract_facts(). Нормализуется в YYYY-MM-DD и подставляется в промпт.
  • В промпт добавлен блок === TEMPORAL RULES (CRITICAL) ===:
    1. Запрет использовать даты из «знаний» модели — только то, что явно в тексте.
    2. Если дату нельзя извлечь из текста — null (всегда лучше null, чем галлюцинация).
    3. Правила разрешения относительных выражений на русском/английском/японском с привязкой к published_at.
    4. Различение «новость о новом событии, ссылающаяся на старое» — старая дата только если явно указана.
  • crawler.py и backfill_facts.py фетчат published_at из signals и пробрасывают в extract_facts(...).

Очистка БД (прод):

  • Обнулены temporal_date у фактов с fact_role='core' и разрывом >90 дней между temporal_date и минимальным published_at источника. Background-факты не тронуты (там много валидных исторических отсылок типа «since 2019»).
  • После обнуления events.earliest_date/latest_date пересчитаны через backfill_event_dates() (они и так берутся из signals.published_at, но на всякий случай).

Подводные камни:

  • Каскад отображения даты в get_event(): temporal_date → resolved_date → MIN(signals.published_at) → created_at. Когда temporal_date обнулён — fallback на signal date, что соответствует дате публикации новости. Это норм, потому что event.earliest_date/latest_date уже идут из signal published_at.
  • Если LLM продолжит галлюцинировать даже с новым промптом — нужно добавить пост-валидацию в _validate_facts(): при gap > 30 days от published_at форсить null или хотя бы precision='unspecified'. Пока не добавляли, чтобы не маскировать другие проблемы.

v0.17.5 — 2026-04-07

[2026-04-07] feat(events): полная хронология — merge вместо drop, двухуровневая линковка

Тип: feature Файлы: src/services/event_linker.py, src/services/event_summarizer.py (NEW), src/workers/crawler.py, src/llm/ollama_client.py Изменения:

  • _dedup_against_event(): вместо silent drop при cosine ≥ 0.90 — вызов merge_fact() (source_count++, claim_variant сохранён)
  • Двухуровневая линковка: core (≥0.82) + background с entity gate (≥0.55 + ≥1 общая entity, relevance=0.5)
  • _has_entity_overlap() — предотвращает слияние разных тем через entity check
  • _link_all_facts(relevance=) — параметризация relevance
  • event_summarizer.py — простая батч-функция генерации описаний событий из фактов (tier 3, хронологический порядок)
  • Ollama tier 3 модель: qwen3.5:4bqwen3.5:9b (избежать model swap задержек) Подводные камни:
  • Background facts (relevance=0.5) не должны доминировать в summary — промпт помечает их [background context]
  • Entity overlap проверяет canonical_name из fact_entities — если entity normalizer не отработал, overlap может быть 0

[2026-04-07] feat(events): claim_variant в API + background facts в UI

Тип: feature Файлы: api/event_store.py, api/schemas.py, frontend-cascade/app/src/types/fact.ts, frontend-cascade/app/src/components/facts/fact-card.tsx, frontend-cascade/app/src/routes/_dashboard/event.$eventId.tsx, frontend-cascade/app/src/i18n/{en,ru}.json Изменения:

  • FactSourceSignal.claim_variant — альтернативные формулировки фактов из разных источников
  • Background facts (relevance < 1.0) — приглушённый стиль + метка "Context" в timeline
  • claim_variant показывается курсивом под source link если отличается от основного claim

v0.17.4 — 2026-04-07

[2026-04-07] feat(trends): Enrichment pipeline — полная переработка формирования трендов

Тип: architecture Файлы: src/services/trend_formation.py, tests/unit/test_trend_formation.py

Entity blacklist:

  • Generic entities (Russia, AI, China, Trump, etc.) не могут быть primary ключом кластера тренда
  • Предотвращает ложные тренды типа "ai" из 3 несвязанных фактов

Coherence check (Ollama LLM):

  • При создании тренда: отправить 5 core claims → спросить "same specific topic? YES/NO"
  • Если NO → тренд отклоняется. Fail-open: если Ollama недоступна → пропустить

5-компонентный scoring (замена Bayesian):

  • source_diversity (25%): log2(types+1)/log2(5)
  • temporal_persistence (25%): unique_days/7
  • independence (20%): unique_sources/5
  • signal_type (15%): academic=1.5, tech=1.2, social=0.8, news=0.5
  • convergence (15%): 2+ types = 1.0
  • Результат → trend_probability, trend_composite, trend_significance, trend_momentum, trend_confidence

LLM naming:

  • Ollama генерирует "[Subject]: [What is happening]" из топ-5 фактов
  • Fallback: primary_entity если LLM timeout

Metric extraction:

  • Числа из metric-type фактов → trend_insight поле
  • Ollama: "Extract key numbers" → short list

Scoring для существующих трендов:

  • При update (новые факты) — пересчёт scores по той же формуле
  • Убран update_all_trend_probabilities() (старый Bayesian перезаписывал scores)

v0.17.3 — 2026-04-07

[2026-04-07] feat: 12 новых RSS-источников

Тип: feature Файлы: src/config.py, src/workers/crawler.py, src/services/adapters/{reuters,france24,dw,nhk,arstechnica,mittr,ieeespectrum,nature,sciencedaily,physorg,nikkeiasia,thehindu}.py Изменения:

  • 12 новых RSS-адаптеров по шаблону bbc.py: Reuters, France24, DW, NHK World, Ars Technica, MIT Technology Review, IEEE Spectrum, Nature News, Science Daily, Phys.org, Nikkei Asia, The Hindu
  • Все freshness-based scoring (без метрик engagement), source_type = NEWS/TECH/ACADEMIC
  • Config-классы + SOURCE_TYPE_MAP + crawler imports Подводные камни: Некоторые фиды могут быть нестабильны (NHK, Nikkei Asia — geo-блокировка возможна)

[2026-04-07] feat: персонализация событий (cluster filter/boost)

Тип: feature Файлы: api/event_store.py, api/routes/events.py, api/routes/interactions.py, api/services/interaction_store.py, db/migrations/079_event_topic_cluster_index.py Изменения:

  • list_events() поддерживает cluster_ids + p_mode (filter/boost) + dismissed_ids
  • Events endpoint получил auth (Depends get_current_user_optional) и resolve_personalization()
  • VALID_ENTITY_TYPES += "event", VALID_EVENT_TYPES += "interest"
  • rebuild_behavioral_centroid() рефакторинг: поддержка event embeddings наравне с object embeddings
  • get_dismissed_event_ids() — скрытие отклонённых событий из ленты
  • EVENT_WEIGHTS: interest=0.8, dismiss=-0.3
  • Миграция 079: индекс idx_events_topic_cluster Подводные камни: topic_cluster в events — TEXT, cluster_engine пишет integer → используется CAST(e.topic_cluster AS INTEGER) в SQL

[2026-04-07] feat: interest/skip кнопки на EventCard

Тип: feature Файлы: frontend-cascade/app/src/components/facts/event-card.tsx, frontend-cascade/app/src/stores/event-feedback-store.ts, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/i18n/{en,ru}.json Изменения:

  • EventCard: ThumbsUp (interest) + X (dismiss) — 44×44px touch targets
  • Zustand store event-feedback-store.ts с persist (localStorage)
  • InteractionEvent типы расширены: 'dismiss' | 'interest' + 'event'
  • 7 i18n-ключей для event feedback (en + ru) Подводные камни: Dismiss скрывает карточку локально (useState) + серверно (dismissed_ids). При очистке localStorage dismissed вернутся на клиенте, но серверная фильтрация останется.

[2026-04-07] fix(i18n): hardcoded строки в UI персонализации

Тип: fix Файлы: frontend-cascade/app/src/routes/_dashboard/profile.tsx, frontend-cascade/app/src/components/personalization/{tag-bar,tag-chip,suggested-tag-chip}.tsx, frontend-cascade/app/src/i18n/{en,ru}.json Изменения:

  • profile.tsx: Connected/Disconnected/Checking → t(), savedItemsCount → t() с интерполяцией
  • tag-bar.tsx: error fallback → t('tags.addError')
  • tag-chip.tsx, suggested-tag-chip.tsx: aria-labels → t() с интерполяцией
  • 11 новых i18n-ключей (profile.saved, displayName, emailLabel, statusConnected/Disconnected/Checking, savedItemsCount, tags.addError/removeAriaLabel/confirmAriaLabel/dismissAriaLabel)

v0.16.17 — 2026-04-05

[2026-04-05] feat(pipeline): Phase 1 — entity normalizer type-gate + trend creation from facts

Тип: feature Файлы: src/services/entity_normalizer.py, src/services/trend_formation.py, tests/unit/test_entity_normalizer.py, tests/unit/test_trend_formation.py Изменения:

  • entity_normalizer.py: type-gate теперь работает в L2 (fuzzy) и L3 (embedding) — матчит только в рамках одного entity_type group (org, tech, geo, person, event). Предотвращает ложные слияния "Moscow City" (geo) ↔️ "Moscow Exchange" (tech)
  • trend_formation.py: _create_trend_for_entity() — создание новых трендов для entity-кластеров, обнаруженных по конвергенции фактов. Ранее было отключено ("deferred"). Создаёт object + trend с change_type='convergence'
  • Тесты: +8 новых тестов (type-gate blocking/allowing, null category, find/create/reuse trend) Подводные камни: objects.category может быть NULL у старых объектов — type-gate пропускает их (не фильтрует)

[2026-04-05] fix(prod): Phase 0 — data cleanup

Тип: fix Изменения на prod (SQL):

  • trends.fact_count синхронизирован с fact_trends (7,987 трендов обновлены)
  • 27 zero-composite трендов удалены
  • 65 NONE change_type трендов смёржены в sibling-тренды
  • 4,507 orphan-объектов удалены (0 сигналов, 0 активных трендов)
  • 3,867 orphan-алиасов удалены
  • objects.signal_count синхронизирован (12,850 объектов)

v0.16.7 — 2026-03-30

[2026-03-30] feat(monitoring): Phase 4 — pipeline metrics + enriched health + crawler integration

Тип: feature Файлы: src/services/fact_extractor.py, src/workers/crawler.py, api/main.py, tests/unit/test_health_endpoint.py (NEW) Изменения:

  • fact_extractor.py: _record_llm_metrics() записывает llm_call_duration_ms и llm_error_rate при каждом LLM-вызове
  • crawler.py: после extraction запускается run_trend_formation() + deduplicate_events() + update_all_event_verification(). Записываются extraction_queue_size и cross_source_convergence_rate
  • api/main.py: /api/health возвращает pipeline stats (facts/events/trends counts, extraction_queue, last_crawl, db_size_mb, memory_rss_mb)
  • Тесты: 2 теста health endpoint, 2 теста LLM metrics recording Подводные камни: psutil нужен для RSS на Windows (graceful fallback). Все метрики записываются через pipeline_metrics_store.record_metric() — best-effort, ошибки не ломают pipeline

v0.16.6 — 2026-03-30

[2026-03-30] feat(frontend): Phase 3 — RecommendationCallout, VerificationBadge, answer-first layout

Тип: feature Файлы: components/trends/recommendation-callout.tsx (NEW), components/facts/verification-badge.tsx (NEW), routes/_dashboard/trend.$trendId.tsx, routes/_dashboard/events.tsx, routes/_dashboard/events.$eventId.tsx, types/trend.ts, types/fact.ts, i18n/en.json, i18n/ru.json Решение:

  • RecommendationCallout: 5 вариантов (ACT_NOW, RISKY_HYPE, MONITOR, EVERGREEN, IGNORE) с MiniBar для momentum/significance/confidence + probability display
  • Trend detail: answer-first — RecommendationCallout первым после breadcrumb (перед score panel)
  • VerificationBadge: 4 уровня (unverified/developing/confirmed/established) с tooltip
  • Events list + detail: VerificationBadge в карточках и шапке
  • Types: TrendDetail +trend_probability/probability_level, EventSummary +verification_level/coverage_count/coverage_sources
  • i18n: en + ru для callout, verification, probability, scoring dimensions

v0.16.5 — 2026-03-30

[2026-03-30] feat(facts): Phase 2 — trend formation, probability scoring, event dedup

Тип: feature Файлы: src/services/trend_formation.py (NEW), src/services/trend_probability.py (NEW), src/services/event_dedup.py (NEW), src/services/trend_linker.py, db/migrations/074_trend_probability.py, db/migrations/075_event_verification.py Проблема: Тренды формировались через objects, не через конвергенцию фактов. Events не имели верификации и дедупликации. Решение:

  • trend_formation: detect trends by fact convergence (2+ source types, 30d window, 3+ facts OR high citations)
  • trend_probability: Bayesian P(trend) scoring per fact with source LR, cross-source bonus, diminishing returns
  • event_dedup: FAISS embedding similarity (≥0.70) + 18h temporal window, merge duplicate events
  • Event verification levels: unverified → developing → confirmed → established (by source diversity)
  • trend_linker: 3-strategy cascade (entity→object→trend, signal→object→trend, entity_name→entity_cluster)
  • Migrations 074 (trend_probability, probability_level, entity_cluster, fact_count) + 075 (event embedding, verification) Подводные камни: trend_formation пока не создаёт НОВЫЕ тренды (только обогащает существующие через object matching). Создание новых трендов из фактов — Phase 5 (удаление objects).

v0.16.4 — 2026-03-30

[2026-03-30] fix(facts): Phase 1 — extraction fixes, entity normalizer 3-level, dedup calibration

Тип: fix + feature Файлы: src/services/fact_extractor.py, src/services/entity_normalizer.py, src/services/fact_deduplicator.py, db/migrations/073_facts_object_extraction.py, requirements.txt Проблема: 8% ошибок extraction (LLM возвращает list вместо dict), 0% entity resolution (только exact match), жёсткий entity gate в дедупликации отбрасывает хорошие кандидаты, некорректный числовой порог для процентов Решение:

  • Extraction: обёртка list → {"facts": list} при парсинге LLM ответа
  • Entity normalizer: 3-уровневый каскад (L0 exact → L1 alias → L2 fuzzy rapidfuzz 0.75 → L3 embedding cosine 0.80). Защита: short name gate (≤3 символов = только exact), авто-алиас при L2/L3 совпадении
  • Дедупликация: cosine 0.88→0.85, жёсткий entity cutoff → мягкий бонус (effective_sim = cosine + 0.05 × entity_overlap), абсолютный порог для процентов (0.3пп)
  • Migration 073: facts.object_id FK + backfill extraction_status='pending'
  • Зависимость: rapidfuzz>=3.0.0 Подводные камни: L3 embedding matching загружает все object embeddings в память (~5400 × 768d). Кэшируется per-process. Если objects сильно растут — нужен FAISS индекс вместо brute-force.

v0.16.3 — 2026-03-30

[2026-03-30] feat(facts): analytics generator, translations, fact GC, object recent facts

Тип: feature Файлы: src/services/analytics_generator.py, src/workers/translation_watchdog.py, api/routes/objects.py, api/schemas.py, frontend-cascade/.../objects_.$objectId.tsx, frontend-cascade/.../types/trend.ts Изменения:

  1. Analytics Generator (src/services/analytics_generator.py): AI-генерация аналитики для событий (What happened / Why matters / Key numbers / What to watch) и трендов (Direction / Evidence / Counterevidence / Recommendation). Предсказания из "What to Watch" автоматически сохраняются в predictions
  2. Translation watchdog: _fill_fact_gaps() — перевод fact claims, _fill_event_gaps() — перевод event titles/summaries
  3. Fact GC (_fact_gc()): еженедельная очистка orphaned фактов (source_count=1, confidence<0.5, age>30d, no links)
  4. Object recent facts: endpoint GET /objects/{id} теперь возвращает recent_facts[] (top 5 фактов по объекту)
  5. Frontend: секция Recent Facts на странице объекта с type badges и confidence

v0.16.2 — 2026-03-30

[2026-03-30] fix(facts): ADR-001 v4.1 schema gap fixes

Тип: fix Файлы: db/migrations/072_facts_adr_gaps.py, src/services/trend_linker.py, src/services/prediction_matcher.py, api/fact_store.py, api/routes/objects.py, api/routes/signals.py, api/routes/trends.py Проблема: Ревью ADR-001 v4.1 выявило пробелы: нет extraction_confidence в fact_sources, predictions не имеет matched_fact_id/event_id/matched_at; contribution_type расходится со схемой; отсутствуют эндпоинты /objects/{id}/facts, /signals/{id}/extracted-facts Решение:

  1. Migration 072: fact_sources.extraction_confidence, predictions.matched_fact_id/event_id/matched_at/timeframe_days
  2. contribution_type: 'supporting''evidence' + добавлен 'direct_projection' для прогнозных фактов
  3. prediction_matcher: при resolve записывает matched_fact_id + matched_at
  4. insert_fact(): принимает extraction_confidence параметр
  5. Новые эндпоинты: GET /objects/{id}/facts, GET /signals/{id}/extracted-facts Подводные камни: Старые записи fact_trends имеют contribution_type='supporting' — при необходимости можно обновить через UPDATE

v0.15.95 — 2026-03-30

[2026-03-30] feat(seo): phase 4 — public object pages, Yandex optimization, bot pre-rendering

Тип: feature Файлы: api/seo_middleware.py, api/main.py, frontend-cascade/.../routes/t.$slug.tsx, frontend-cascade/.../lib/utils.ts Изменения:

  1. Публичные страницы объектов /t/[slug]-[id] — 2370 объектов доступны поисковикам. Frontend: полная страница с трендами, сигналами, related. Backend: SEO middleware + sitemap (top 500) + robots.txt
  2. Пререндер контента для ботов — middleware генерит полный HTML с объектом, сигналами, трендами и related links для Yandex/Google
  3. Yandex оптимизацияHost: директива в robots.txt, WebSite + SearchAction JSON-LD, FAQPage schema на use-cases
  4. slugify() бэкенд — Cyrillic транслитерация для URL в sitemap Подводные камни: ID извлекается из конца slug regex (\d+)$. Sitemap лимит 500 объектов

v0.15.94 — 2026-03-30

[2026-03-30] feat(seo): phase 3 — hreflang HTML, Cache-Control, SEO-friendly share URLs

Тип: feature Файлы: api/seo_middleware.py, api/main.py, api/routes/shared.py, frontend-cascade/.../hooks/use-page-meta.ts, frontend-cascade/.../routes/shared.$token.tsx, frontend-cascade/.../routes/_dashboard/analyze.$jobId.report.tsx, frontend-cascade/.../lib/utils.ts Изменения:

  1. hreflang <link> теги в HTML — middleware инжектит en/ru/x-default для ботов, usePageMeta создаёт для клиентов (с cleanup)
  2. Cache-Control headers_SPAStaticFiles добавляет: immutable для hashed assets, max-age=86400 для images/fonts, no-cache для index.html
  3. SEO-friendly slug URL для shared reports — /shared/ai-trend-analysis--abc123 вместо /shared/abc123. Backend парсит -- разделитель (backward-compatible). Новая утилита slugify() с Cyrillic транслитерацией Подводные камни: -- разделитель в URL, token может содержать - но не --. slugify обрезает до 60 символов

v0.15.90 — 2026-03-29

[2026-03-29] refactor: удаление мёртвого кода и артефактов

Тип: refactor Удалено:

  1. api/routes/helpers.py — дубликат функции _translate_zone_names() (строки 131-182, идентичная копия 77-128)
  2. frontend-cascade/.../trends/content-plan-modal.tsx — неиспользуемый компонент (0 импортов)
  3. frontend-cascade/.../analysis/trend-to-zones-flow.tsx — неиспользуемый компонент (0 импортов)
  4. frontend-cascade/.../hooks/use-predictions.ts — неиспользуемый хук (0 вызовов)
  5. frontend-cascade/.../hooks/use-latest-analysis.ts — неиспользуемый хук (0 импортов)
  6. frontend-cascade/.../components/ui/tabs.tsx — shadcn Tabs wrapper (0 импортов из ui/tabs)
  7. api/_gen_middleware.py, api/_write_middleware.py — локальные артефакты кодогенерации (не в git)
  8. .claude/worktrees/ — 2 stale worktrees (17MB)

[2026-03-29] Анализ техдолга

Тип: doc Выявленные проблемы (средний приоритет, backlog):

  • God files: analysis_store.py (1599 строк), crawler.py (1351 строка) — разбить на модули
  • Legacy dual-write: таблицы signal_mappings + trend_aliases — 77 файлов ссылаются, планировать удаление
  • Deprecated колонки urgency_score/quality_score — nullable, удалить в v1.0
  • 71 миграция — консолидировать backfill-цепочки
  • sys.path.insert hack в crawler.py — перейти на пакетный import

v0.15.86 — 2026-03-25

[2026-03-25] feat(trend-map): карта трендов — сворачиваемый блок, описание, фильтр сигналов в URL

Тип: feature Файлы: frontend-cascade/app/src/components/trends/trend-cluster-graph.tsx, frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx, api/routes/objects.py, api/services/tag_engine.py Изменения:

  1. Trend Map — сворачиваемый блок (collapsed по умолчанию, состояние в localStorage)
  2. Убран gap между заголовком и контентом карты (Card py-0 gap-0)
  3. Тултип показывает описание тренда (из trends.description, с переводом)
  4. showSignals передаётся через URL при переходе между трендами
  5. Масштаб и размер узлов уменьшаются при малом количестве (≤3 → 0.6x, ≤5 → 0.8x)
  6. count_related_trends_batch() — батч-подсчёт связанных трендов через FAISS
  7. Все страницы с sidebar: убрана дублирующая полоса под navbar Подводные камни: graphData должен быть объявлен до useEffect который его использует (TDZ)

v0.15.85 — 2026-03-25

[2026-03-25] feat(dedup): периодический мерж семантически похожих change_type

Тип: feature Файлы: src/services/trend_change_dedup.py (new), src/workers/translation_watchdog.py, frontend-cascade/app/src/hooks/use-tags.ts Проблема: LLM генерирует чуть разные change_type ("diplomatic tension" ≈ "diplomatic talks"). Объект 7244 имел 21 тренд, 19 из них — дубликаты. Решение:

  1. merge_similar_change_types() — embedding + cosine ≥ 0.85 + union-find + merge в canonical
  2. Запуск в watchdog каждые 5 мин
  3. Fix TS: mode: stringmode: 'boost' | 'filter' | 'off'

v0.15.84 — 2026-03-25

[2026-03-25] fix(personalization): отключить для анонимных + react-query вместо Zustand для тегов

Тип: fix + refactor Файлы: api/routes/helpers.py, frontend-cascade/app/src/hooks/use-tags.ts, frontend-cascade/app/src/components/personalization/tag-bar.tsx, frontend-cascade/app/src/routes/_dashboard/profile.tsx, frontend-cascade/app/src/stores/tag-store.ts (удалён) Проблема: 1) resolve_personalization() обрабатывал анонимных пользователей (anon_*), бесполезно искал кластеры без tag_centroid. 2) Zustand tag-store дублировал серверное состояние тегов — optimistic updates рассинхронивались с сервером при ошибках сети. Решение: 1) Добавлен guard anon_ → off в resolve_personalization(). 2) Заменён Zustand tag-store на 6 react-query хуков (useUserTags, useAddTag, useRemoveTag, useConfirmSuggestion, useDismissSuggestion, useSetFeedMode). Сервер = единственный источник правды. Подводные камни: TagBar и Profile page теперь зависят от use-tags.ts хуков. При добавлении/удалении тега инвалидируются ВСЕ персонализированные запросы.

[2026-03-25] fix(descriptions): ускорение backfill описаний трендов

Тип: fix Файлы: src/workers/crawler.py, src/workers/translation_watchdog.py Проблема: 2235 трендов без описания. Генератор запускался только в crawler с batch_size=10 (30 за цикл) — backfill занял бы ~19 часов. Решение:

  1. Увеличен batch_size до 30 (90 за цикл) в crawler
  2. Добавлен вызов generate_trend_descriptions() в translation_watchdog (каждые 5 мин)
  3. Итого: ~6 часов вместо ~19 для полного заполнения Подводные камни: Watchdog создаёт отдельное соединение для генерации описаний (не переиспользует основное)

v0.15.83 — 2026-03-25

[2026-03-25] fix(convergence): LLM кеширование + non-blocking execution

Тип: fix Файлы: src/services/convergence_analyzer.py, api/main.py Проблема: convergence_analyzer запускал LLM-синтез для всех 238 зон каждые 10 мин, блокируя uvicorn event loop → сервер переставал отвечать. Решение:

  1. Content-hash кеширование: _content_hash() хеширует contributing trends, _load_previous_results() сравнивает с предыдущими — LLM вызывается только для изменённых зон
  2. _save_convergence_results() сохраняет content_hash колонку (idempotent ALTER TABLE)
  3. run_in_executor в api/main.py — convergence loop больше не блокирует async event loop Подводные камни: При первом запуске после обновления все зоны будут без хеша → один полный LLM-проход, далее только изменения

v0.15.81 — 2026-03-25

Тип: refactor Файлы: api/routes/pulse.py (new), api/routes/trends.py, api/main.py, api/routes/__init__.py, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/components/layout/sidebar.tsx, frontend-cascade/app/src/hooks/use-pulse-meta.ts, dev.py Проблема: /{class_id} catch-all на trends_router затенял /pulse/meta → 404. Sidebar переполнен (14 пунктов → скролл). Решение:

  1. Pulse endpoints вынесены в отдельный pulse_routerGET /api/pulse, GET /api/pulse/meta
  2. Trends prefix переименован /api/trends/api/trend (единственное число)
  3. Frontend api-client обновлён под новые пути
  4. Sidebar сокращён с 14 до 9 пунктов (Explore слит в Discover, убраны Convergence/Accuracy/Watchlist/Query)
  5. Polling backoff: вместо остановки при ошибках — прогрессивное замедление (60с → 120с → 300с)
  6. dev.py: _kill_port() убивает предыдущий процесс перед очисткой __pycache__ (фиксит file lock) Подводные камни:
  • Старые клиенты с /api/trends/ перестанут работать (breaking change, только dev)
  • pulse_router регистрируется отдельно в main.py (prefix="/pulse"), НЕ через trends

v0.15.80 — 2026-03-24

[2026-03-24] fix(classify): pipeline crash on orphan trend cleanup

Тип: fix Файлы: src/services/trend_classifier.py, src/llm/cli_client.py Проблема: Classify pipeline падает с UNIQUE constraint failed: trends.object_id, trends.change_type при каждом цикле. Причина: UPDATE trends SET merged_into = NULL un-merge'ит тренды — они попадают в partial UNIQUE index (object_id, change_type) WHERE merged_into IS NULL и конфликтуют с существующими трендами. Дополнительно: ~50% LLM batch экстракций fail из-за Extra data (LLM возвращает невалидный JSON, fallback парсер не обрабатывал массивы). Решение:

  1. Тренды, ссылающиеся на orphan через merged_into, теперь удаляются (вместо un-merge)
  2. query_json fallback теперь ищет JSON массивы [...] перед объектами {...} Подводные камни: Partial UNIQUE index на trends — любой UPDATE merged_into→NULL может нарушить constraint

v0.15.79 — 2026-03-24

[2026-03-24] feat(pulse): live feed panel + auto-refresh + heatmap improvements

Тип: feature Файлы: api/routes/trends.py, api/schemas.py, frontend-cascade/app/src/hooks/use-pulse.ts, frontend-cascade/app/src/hooks/use-pulse-meta.ts, frontend-cascade/app/src/routes/_dashboard/pulse.tsx, src/services/convergence_analyzer.py Решение:

  1. Two-tier polling: GET /api/trends/pulse/meta (1ms, каждые 60с) → invalidate при изменении last_updated
  2. Live Feed Panel справа от heatmap (lg: side-by-side, mobile: 1 колонка) с ResizeObserver для выравнивания высоты
  3. FeedRow: 3 строки (score+name+time, description, badges NEW/UPD + phase + momentum)
  4. Server-side фильтрация live feed при клике на heatmap (зоны и категории)
  5. DigestCard теперь показывается для всех периодов включая 24ч
  6. Heatmap: все 5 категорий видны даже при 0 значениях (backend заполняет пустые дни/категории)
  7. fix(convergence): send_messageclient.query() (async через thread pool) Подводные камни: asyncio.run() нельзя вызывать из async контекста — используется ThreadPoolExecutor

v0.15.71 — 2026-03-24

[2026-03-24] fix(objects): merge duplicate objects + prevent future dupes

Тип: fix Файлы: db/migrations/066_merge_duplicate_objects.py, src/services/object_extractor.py Проблема: Параллельные потоки краулера создают дубли объектов — L0 exact match не видит uncommitted rows из других потоков. 30 групп exact дублей (same object_name) + 21 semantic near-duplicate (cosine >= 0.90). Решение:

  1. Migration 066: merge 37 exact + 21 semantic дублей (dual-threshold: name cosine >= 0.90 AND enriched text cosine >= 0.85)
  2. UNIQUE partial index на objects.object_name (WHERE merged_into IS NULL) — предотвращает будущие exact дубли
  3. _upsert_object() fallback: если INSERT blocked UNIQUE index → ищет по object_name Подводные камни:
  • Semantic merge использует union-find (transitive) — при слишком низком threshold может сливать несвязанные объекты
  • Dual threshold (name + enriched text) фильтрует false positives ("age verification systems" ≠ "age verification regulation")
  • UNIQUE index блокирует concurrent INSERT с тем же object_name — INSERT OR IGNORE + fallback SELECT

v0.15.59 — 2026-03-23

[2026-03-23] feat(personalization): tag suggestion + confirmation flow

Тип: feature Файлы: api/services/interaction_store.py, api/routes/tags.py, db/migrations/055_dismissed_tag_suggestions.py (NEW), frontend-cascade/app/src/stores/tag-store.ts, frontend-cascade/app/src/components/personalization/tag-bar.tsx, frontend-cascade/app/src/components/personalization/suggested-tag-chip.tsx (NEW), frontend-cascade/app/src/lib/api-client.ts Проблема: Теги персонализации добавлялись только вручную. Пользователь не видел, какие предпочтения система определила по его поведению. suggest_tags_for_user() использовал только сильные сигналы (save, watchlist) — views не учитывались. Решение:

  • suggest_tags_for_user() теперь учитывает ВСЕ взаимодействия (view, source_click, search) — слабые сигналы накапливаются
  • Минимальный порог MIN_SUGGEST_SCORE = 1.0 — предложения появляются только при накоплении достаточного веса
  • Object name имеет приоритет (×0.7) над change_types (×0.5) и categories (×0.2)
  • dismissed_tag_suggestions таблица — отклонённые предложения не показываются повторно
  • POST /tags/suggest/dismiss — endpoint для отклонения
  • Frontend TagBar показывает предложения (dashed border) с кнопками confirm (✓) / dismiss (✗)
  • Confirm = addTag(text, source="suggested") → вес 0.7 в кластерном matching
  • Suggestions не персистятся в localStorage (загружаются с сервера при каждом визите) Подводные камни:
  • MIN_SUGGEST_SCORE = 1.0 — при весе view=0.3 нужно ~5 просмотров объекта чтобы его name набрал 1.0 (0.3×0.7×5=1.05)
  • Dismissed suggestions привязаны к lowercase tag_text — если объект переименован, старый dismiss не блокирует новое имя

v0.15.58 — 2026-03-23

[2026-03-23] feat(personalization): lazy epoch-based cluster recomputation

Тип: feature Файлы: db/migrations/054_cluster_epoch.py (NEW), api/services/cluster_engine.py, api/routes/helpers.py, src/workers/translation_watchdog.py Проблема: Cluster IDs нестабильны — каждый rebuild_clusters() назначает новые. Между rebuild и recompute_users matching_clusters в профилях указывали на чужие кластеры. Eagerly recompute для всех юзеров — лишние вычисления для неактивных. Решение:

  • cluster_epoch в cluster_meta — глобальный счётчик, инкрементируется при rebuild_clusters()
  • cluster_epoch в user_profiles — фиксирует epoch последнего пересчёта
  • get_user_cluster_ids() сравнивает epoch: если stale → compute_user_clusters() inline (<10ms)
  • Watchdog больше не итерирует по всем юзерам — только rebuild_clusters() + rebuild_relevant_roles()
  • Неактивные юзеры не пересчитываются, активные — при первом запросе после rebuild Подводные камни:
  • При первом запросе после rebuild latency +~10ms (PCA transform + cosine sim)
  • Если cluster_meta таблица пуста, epoch=0 — все юзеры пересчитаются при первом запросе

v0.15.57 — 2026-03-23

[2026-03-23] fix(personalization): use pure tag centroid for cluster matching

Тип: fix Файлы: api/services/cluster_engine.py Проблема: compute_user_clusters() использовал blended centroid из user_profiles.tag_centroid (60% tags + 40% behavioral). Behavioral centroid строился из истории взаимодействий (11 объектов военной тематики), что перетягивало кластер-матчинг на нерелевантные кластеры. Пользователь с тегом "Игровые автоматы" получал военный контент вместо gaming. Решение:

  • compute_user_clusters() теперь вычисляет чистый tag centroid из user_tags (active tags only) через _compute_pure_tag_centroid()
  • Используется тот же алгоритм что и _update_tag_centroid() в interaction_store: source weights × temporal decay
  • Fallback на blended centroid из profile только если нет активных тегов
  • Результат: admin с тегом "Игровые автоматы" → кластеры [video games, AI gaming] вместо [military, Iran] Подводные камни:
  • Behavioral centroid всё ещё хранится в user_profiles.behavioral_centroid и используется в _update_tag_centroid() blend — это влияет на objects.py FAISS search, но НЕ на кластерное матчинг
  • Cluster IDs нестабильны — каждый rebuild_clusters() присваивает новые ID. Watchdog автоматически пересчитывает matching_clusters после rebuild

v0.15.56 — 2026-03-22

[2026-03-22] fix(objects): migrate objects endpoint to cluster-based personalization

Тип: fix Файлы: api/routes/objects.py Проблема: objects.py использовал FAISS re-ranking (Python-side), в то время как все остальные endpoints (trends, signals, predictions, recommendations) уже мигрировали на SQL-level кластерную фильтрацию. FAISS вычислял total на основе покрытия индекса, а не SQL WHERE → пагинация показывала больше записей, чем фактически фильтровалось. category_counts вычислялись без учёта кластерного фильтра. Решение:

  • Заменён FAISS re-ranking на SQL-level cluster-based filter/boost (единый подход со всеми endpoints)
  • Filter mode: WHERE cluster_id IN (...) AND (relevant_roles IS NULL OR relevant_roles LIKE ?) — total считается корректно
  • Boost mode: ORDER BY CASE WHEN cluster_id IN (...) THEN 0 ELSE 1 END, ...
  • category_counts теперь включает кластерный фильтр в filter mode
  • Удалён numpy import и ~65 строк FAISS re-ranking кода Подводные камни:
  • tag_engine.get_personalized_object_ids() остаётся доступной для других использований, но больше не вызывается из objects route

v0.15.55 — 2026-03-22

[2026-03-22] feat(personalization): SQL-level cluster-based personalization (HDBSCAN)

Тип: feature Файлы: api/services/cluster_engine.py (NEW), db/migrations/053_object_clusters.py (NEW), api/routes/helpers.py, api/routes/trends.py, api/routes/signals.py, api/routes/predictions.py, api/routes/recommendations.py, src/services/trend_classifier.py, src/workers/crawler.py, api/prediction_store.py, api/recommendation_store.py, src/workers/translation_watchdog.py Проблема: Персонализация (FAISS filter/boost) применялась ПОСЛЕ SQL-пагинации → неправильный total, сломанная пагинация, хаки с limit=5000. Не масштабируется для десятков тысяч объектов. Решение: HDBSCAN кластеризация объектов (PCA 768→50d + density-based). 2D фильтрация: area (cluster_id) × role (relevant_roles). SQL WHERE для filter mode, ORDER BY CASE для boost mode. Все 5 endpoint stores принимают cluster_ids + preferred_role + personalization_mode. Старый FAISS re-ranking удалён из всех routes. Watchdog пересчитывает кластеры + user matching. Подводные камни:

  • relevant_roles заполняется из zone_recommendations — если анализы не генерируют рекомендации с ролями, поле пусто (fallback: relevant_roles IS NULL не исключает объект)
  • objects.py по-прежнему использует FAISS через get_personalized_object_ids() — другой паттерн
  • numpy int → Python int при записи в SQLite (int(cid))
  • Тесты: mock-функции crawler должны принимать **kwargs для новых params

v0.15.53 — 2026-03-22

[2026-03-22] fix(personalization): FAISS index empty — dimension mismatch 384 vs 768

Тип: bug Файлы: db/migrations/051_reembed_objects_768.py Проблема: Модель эмбеддингов сменилась с BAAI/bge-small-en-v1.5 (384-dim) на paraphrase-multilingual-mpnet-base-v2 (768-dim), но 1689 объектов не были пере-эмбеддены. FAISS индекс в tag_engine.py фильтровал все объекты по vec.shape[0] == _EMBED_DIM (768) → индекс пуст → rank_objects_by_tags() возвращал {} → персонализация не работала. Решение: Миграция 051 — пере-эмбеддинг всех объектов с текущей 768-dim моделью (8.8 сек). Также пересчитан behavioral centroid. Подводные камни: При смене embedding модели нужно пере-эмбеддить ВСЕ таблицы с эмбеддингами. ZoneMatcher делает это автоматически (_build_index перегенерирует stale зоны), но objects и user_tags — нет.


v0.15.52 — 2026-03-21

[2026-03-21] feat(personalization): dismiss, transparency labels, behavioral centroid

Тип: feature Файлы: api/routes/interactions.py, api/routes/objects.py, api/services/interaction_store.py, api/services/tag_engine.py, api/schemas.py, src/workers/translation_watchdog.py, db/migrations/050_behavioral_centroid.py, frontend-cascade/app/src/components/trends/object-card.tsx, objects.tsx, i18n Что сделано:

  • 2.4 "Not Interested" / Dismiss (YouTube/TikTok): dismiss_object() / undismiss_object() / get_dismissed_object_ids(). Endpoints POST/DELETE /dismiss/{id}, GET /dismissed. Фильтрация dismissed из objects list. Кнопка X на ObjectCard
  • 2.5 "Why This?" labels (Habr/YouTube): get_match_reasons() в tag_engine — находит ближайший user tag для каждого объекта. match_reasons dict в ObjectListResponse. Italic label "Matches: " под заголовком карточки
  • 2.6 Behavioral Centroid (YouTube/TikTok): rebuild_behavioral_centroid() — weighted mean embeddings объектов из interactions (EVENT_WEIGHTS × recency decay 14d half-life). Blend: final = 0.6×tag_centroid + 0.4×behavioral. Migration 050. Вызов из watchdog каждые 5 мин
  • Документация: docs/features/PERSONALIZATION.md — полное описание архитектуры персонализации. Обновлены INDEX.md, ALGORITHMS.md

v0.15.51 — 2026-03-21

[2026-03-21] feat(personalization): поведенческие рекомендации по образцу топовых платформ

Тип: feature Файлы: api/routes/objects.py, api/routes/signals.py, api/routes/trends.py, api/routes/tags.py, api/routes/recommendations.py, api/services/tag_engine.py, api/services/interaction_store.py, src/services/trend_classifier.py, src/workers/translation_watchdog.py, фронтенд (6 файлов), i18n (en/ru) Что сделано:

  • 1.1 Momentum в ранжировании (Reddit): base_scores = trend_composite вместо obj_significance — свежие тренды с высоким momentum получают приоритет
  • 1.2 Temporal decay (TikTok): exp(-ln2 × age_days / 30) в _update_tag_centroid() — старые теги затухают с half-life 30 дней
  • 1.3 Расширенный tracking (TikTok/Google): trackEvent() на страницах signals, trends, objects, recommendations (было только trend detail + object detail)
  • 2.1 Auto-tag из взаимодействий (Habr): auto_generate_tags() — change_type с ≥3 strong interactions автоматически становится тегом. Вызывается из watchdog каждые 5 мин
  • 2.2 Hot sort (Reddit): hot_score = momentum × log10(signal_count+1) × exp(-age_hours/168). Новый sort_by=hot на objects и trends endpoints
  • 2.3 Related Technologies (Google Discover/YouTube): find_related_objects() через FAISS kNN. Endpoint GET /objects/{id}/related. Секция "Related Technologies" на странице тренда

v0.15.50 — 2026-03-21

[2026-03-21] fix(auto-analyzer): trends.id передавался как object_id

Тип: bug Файлы: src/workers/auto_analyzer.py, db/migrations/052_fix_auto_analysis_object_id.py Проблема: check_and_queue_auto_analyses() выбирал trends.id и передавал его как object_id в _start_auto_analysis(). Поскольку objects.id ≠ trends.id (после миграции 018), анализы загружали сигналы из неправильных объектов. Результат: ~39 анализов с неправильным контекстом, 8 из них с полностью пустыми отчётами (0 impact zones). Решение:

  • SQL-запрос теперь выбирает t.object_id вместо t.id
  • Миграция 052: исправляет analyses.object_id, analyses.trend_id, trends.latest_analysis_id Подводные камни: objects.id ≠ trends.id — НИКОГДА не использовать trends.id как object_id. Всегда trends.object_id.

[2026-03-21] feat(predictions): фальсифицируемые прогнозы вместо наблюдений

Тип: feature Файлы: src/prompts/schemas.py, src/prompts/templates.py, api/services/analysis_runner.py, api/prediction_store.py, api/settings_routes.py Проблема: Predictions содержали наблюдения/мнения ("asyncio важен для скрапинга"), которые невозможно проверить как TRUE/FALSE. Причина: LLM-промпт не просил генерировать falsifiable claims, extraction брал mechanism (= rationale) как claim. Решение:

  • Добавлены поля prediction_claim и verification_metric в ZoneInfluence pydantic-схему
  • В оба P1-шаблона добавлен блок "ФАЛЬСИФИЦИРУЕМОЕ ПРЕДСКАЗАНИЕ" с требованиями: конкретный субъект + измеримый результат + горизонт + источник проверки
  • Extraction теперь берёт prediction_claim как claim, зоны без prediction_claim пропускаются (вместо генерации generic fallback)
  • verification_metric прокидывается в create_prediction() (колонка в DB уже существовала)
  • Dedup в extraction теперь проверяет только status = 'open' (не блокирует resolved)
  • Новый admin action POST /api/admin/actions/regenerate-predictions — удаляет open predictions, перевыгружает из analyses Подводные камни: Старые analyses (без prediction_claim в impact_zones JSON) дадут 0 predictions при regenerate — это корректно. Нужно заново запускать анализы для генерации falsifiable claims.

v0.15.52 — 2026-03-21

[2026-03-21] feat(personalization): FAISS boost для signals/trends + tag suggestions + preferred_role

Тип: feature Файлы: api/routes/signals.py, api/routes/trends.py, api/routes/tags.py, api/routes/recommendations.py, api/services/interaction_store.py, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/hooks/use-tags.ts

Изменения:

  • /signals и /trends endpoints: FAISS boost через object_id сигнала/тренда → relevance из tag centroid
  • Персонализация активируется только при дефолтном sort (score для signals, default/composite для trends)
  • GET /tags/suggest — behavioral tag suggestions на основе saved/watchlist/analysis interactions (EVENT_WEIGHTS)
  • /recommendations/aggregated — auto-inject preferred_role из user_profiles если role фильтр не задан
  • useSuggestedTags() hook + getSuggestedTags() API метод + SuggestedTag тип
  • useInvalidatePersonalized() теперь инвалидирует и signals

v0.15.51 — 2026-03-21

[2026-03-21] fix(personalization): Expert panel — 26 issues fixed (P0/P1/P2)

Тип: fix Файлы: api/services/tag_engine.py, api/services/interaction_store.py, api/routes/tags.py, api/routes/profile.py, api/routes/interactions.py, api/routes/objects.py, frontend-cascade/app/src/stores/tag-store.ts, frontend-cascade/app/src/hooks/use-tags.ts, frontend-cascade/app/src/lib/interaction-tracker.ts, frontend-cascade/app/src/components/personalization/tag-bar.tsx, frontend-cascade/app/src/components/personalization/tag-chip.tsx, frontend-cascade/app/src/components/personalization/tag-input.tsx, frontend-cascade/app/src/routes/_dashboard/profile.tsx, frontend-cascade/app/src/lib/api-client.ts

P0 (Critical):

  • FAISS re-ranking теперь ПЕРЕД пагинацией: get_personalized_object_ids() ранжирует все объекты глобально, затем пагинация
  • Race condition: _rebuild_lock (non-blocking) предотвращает конкурентный rebuild; _ensure_index() с double-check locking
  • Sync SQLite из async: все endpoint'ы конвертированы в def (не async def), FastAPI запускает их в thread pool
  • IDOR через anon_id: сервер генерирует anonymous ID через httpOnly cookie (cascade_anon_id), client-side anon_id убран
  • Dual source of truth: убран useUserTags() react-query hook, Zustand store — единственный источник

P1 (High):

  • Лимит тегов: MAX_TAGS_PER_USER=50 (backend), MAX_TAGS=30 (frontend), ошибка с toast
  • Rate limiting: MAX_INTERACTIONS_PER_USER_PER_HOUR=200, SQL COUNT проверка
  • Metadata size: MAX_METADATA_SIZE=4096, truncation к {}; Pydantic validator на 20 ключей
  • EVENT_WEIGHTS → centroid: SOURCE_WEIGHTS по источнику тега (manual=1.0, auto=0.4), weighted average
  • Centroid dilution: MAX_TAGS_FOR_CENTROID=10, ограничение при вычислении
  • Threshold: DEFAULT_FILTER_THRESHOLD снижен с 0.35 до 0.25
  • Scale normalization: min-max нормализация relevance scores к [0,1] для балансировки с composite
  • Optimistic delete с rollback: при ошибке сервера теги восстанавливаются + toast
  • Debounce: useDeferredValue для поиска popular tags + placeholderData в react-query
  • TagBar scope: показывается только на explore/radar/objects/signals/trends страницах
  • Batching: interaction-tracker с очередью (flush каждые 5с или 20 событий) + flush при visibilitychange
  • Error feedback: addTag показывает toast при ошибке вместо silent fail

P2 (Medium):

  • LIKE escape: % и _ экранируются в search_popular_tags()
  • Transaction: delete_user_data() обёрнут в BEGIN/COMMIT/ROLLBACK
  • GDPR log: логируется hash(user_id), не сам user_id
  • Popular tags limit cap: min(limit, 200) на get, min(limit, 100) на search
  • N+1 fix: rebuild_popular_tags() change_type counts в одном GROUP BY запросе
  • A11y: role="radiogroup" + role="radio" + aria-checked на mode toggle (TagBar + Profile)
  • A11y: role="listbox" + role="option" на TagInput dropdown, aria-label на input
  • Touch target: TagChip remove button min-h-[44px] min-w-[44px], mode toggle min-h-[32px]
  • Logout reset: useAuthStore.subscribe() вызывает reset() при logout
  • Variable shadowing: (t) => заменён на (tag) => в profile.tsx
  • Tag route order: /tags/popular перед /tags/{tag_id}
  • Source validation: VALID_TAG_SOURCES whitelist, entity_type validation
  • onOpenAutoFocus вместо setTimeout для Popover focus

Подводные камни:

  • get_personalized_object_ids() делает доп. запрос SELECT id, obj_significance FROM objects для base_scores — при >10K объектов может быть медленно
  • Exploration slots работают глобально: 80% matched + 20% exploration interleaved по страницам
  • Anonymous cookie cascade_anon_id — httpOnly, 90 дней TTL

v0.15.50 — 2026-03-20

[2026-03-20] feat(personalization): Tag-based FAISS personalization + behavioral tracking

Тип: feature Файлы:

  • db/migrations/048_user_personalization.py (NEW) — 4 таблицы: user_tags, user_profiles, user_interactions, popular_tags
  • api/services/tag_engine.py (NEW) — FAISS ranking engine: compute_tag_centroid, rank_objects_by_tags, apply_personalized_sort
  • api/services/interaction_store.py (NEW) — CRUD для тегов, профилей, взаимодействий, popular_tags rebuild
  • api/routes/tags.py (NEW) — GET/POST/DELETE /api/tags, GET /api/tags/popular
  • api/routes/profile.py (NEW) — GET/PUT /api/profile/preferences, DELETE /api/profile (GDPR)
  • api/routes/interactions.py (NEW) — POST /api/interactions (fire-and-forget)
  • api/main.py — подключение 3 новых роутеров
  • api/routes/objects.py — FAISS re-ranking при наличии тегов пользователя
  • src/workers/translation_watchdog.py — ежедневный rebuild popular_tags + 90-day TTL cleanup
  • frontend-cascade/app/src/components/personalization/ (NEW) — TagBar, TagInput, TagChip
  • frontend-cascade/app/src/stores/tag-store.ts (NEW) — Zustand + persist
  • frontend-cascade/app/src/hooks/use-tags.ts (NEW) — react-query hooks
  • frontend-cascade/app/src/lib/interaction-tracker.ts (NEW) — fire-and-forget tracking
  • frontend-cascade/app/src/lib/api-client.ts — новые API методы + типы (UserTag, PopularTag, UserProfile, InteractionEvent)
  • frontend-cascade/app/src/routes/_dashboard.tsx — TagBar в layout
  • frontend-cascade/app/src/routes/_dashboard/profile.tsx — секция "Мои теги" + GDPR delete
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx — dwell tracking + save/watchlist tracking
  • frontend-cascade/app/src/routes/_dashboard/objects_.$objectId.tsx — dwell tracking
  • frontend-cascade/app/src/i18n/en.json, ru.json — ключи tags.*

Описание: Персонализация на основе свободных семантических тегов + FAISS cosine similarity.

  • Пользователь добавляет теги ("AI", "quantum computing") → embed_texts() → 768-dim вектор
  • Tag centroid (mean) → cosine similarity с objects.embedding → ранжирование
  • Два режима: Boost (все объекты, matching бустятся, λ=0.30) и Filter (только cosine ≥ 0.35)
  • 20% exploration slots (prevent filter bubble)
  • Behavioral tracking: view (dwell>3s), save, unsave, watchlist, analysis, share, search, source_click
  • Popular tags: извлекаются из signals.tags_json (GitHub topics, arXiv codes, SO tags)
  • 90-day TTL на interactions + GDPR DELETE /api/profile
  • Ranking formula: (1-λ)*composite/100 + λ*relevance

Подводные камни:

  • FAISS index rebuilds every 5 min (in-memory, ~1ms for 1833 objects)
  • Personalization only active for authenticated users with ≥1 active tag
  • apply_personalized_sort() only applies when sort_by="significance" (default)
  • Popular tags need initial crawl data in signals.tags_json

9b7a228 (feat(predictions): falsifiable predictions instead of observations)


v0.15.49 — 2026-03-21

Тип: feature Файлы: src/services/adapters/google_trends.py (NEW), src/config.py, src/workers/crawler.py, api/services/signal_mapper.py Описание: Адаптер для Google Trends Daily Search Trends по RSS. Без API-ключа.

  • Fetches trending searches из 2 гео (US, RU) — дедупликация по title hash
  • Scoring: 60% traffic (sigmoid до 5000) + 40% freshness (48h decay)
  • ID формат: gtrends:{md5(title)[:12]}
  • Метаданные: approx_traffic, geo, news_urls, news_sources
  • Каждый item содержит linked news URLs из Google для source enrichment

v0.15.47 — 2026-03-21

[2026-03-21] fix(db): Database concurrency + scoring fixes

Тип: fix Файлы: src/workers/crawler.py, src/workers/translation_watchdog.py, src/services/alias_resolver.py, src/services/trend_classifier.py, src/services/trend_scoring.py, api/analysis_store.py, src/services/title_generator.py Описание: Серия исправлений для стабильности SQLite при высоком параллелизме:

  • DB retry helpers: _db_retry() и _db_commit_retry() с 5 попытками и экспоненциальным backoff (5-25с) для INSERT/commit в crawler
  • Batch commits: INSERT сигналов коммитится каждые 50 записей (вместо одной транзакции на весь crawl)
  • WAL + busy_timeout: Все 12+ точек sqlite3.connect() переведены на PRAGMA journal_mode=WAL + busy_timeout=15000
  • _init_db split: Разделён на _init_db_schema() (DDL, 5 retries) + _init_db_backfills() (best-effort, не блокирует запуск)
  • verify_trends fix: Per-item conn.commit() после каждой верификации — не держит write lock во время await
  • merge_classes fix: Убраны conn.commit()/conn.rollback() из merge_classes() — вызывающий код управляет транзакциями. Раньше conn.commit() внутри SAVEPOINT уничтожал все активные savepoints
  • FK orphan cleanup: UPDATE trends SET merged_into = NULL перед DELETE orphaned trends — устраняет self-referencing FK constraint
  • Scoring fix (CRITICAL): update_trend_scores использовал WHERE id = ? для trends — но objects.id ≠ trends.id (разные autoincrement). Исправлено на WHERE object_id = ?. До исправления 245 из 1080 трендов имели нулевые оценки
  • Backfill fix: _backfill_trend_scores() теперь передаёт object_id вместо trends.id

Подводные камни:

  • objects.id ≠ trends.id для записей после migration 018 — НИКОГДА не использовать WHERE trends.id = objects.id
  • conn.commit() внутри SAVEPOINT уничтожает все savepoints — не коммитить в функциях, вызываемых из savepoint

[2026-03-21] fix(tests): Update tests for 768d multilingual model + ruff config

Тип: fix Файлы: tests/unit/test_foundation.py, tests/unit/test_crawler_fixes.py, pyproject.toml Описание:

  • Тесты EmbeddingCache обновлены: 384d → 768d (после перехода на paraphrase-multilingual-mpnet-base-v2)
  • Тест test_counts_unique_domains обновлён: mock-результаты теперь включают .title/.content + мок _citation_relevance (после добавления embedding-based фильтрации)
  • Ruff: добавлен "tests/**/*.py" = ["E402"] — E402 ложно-срабатывал на pytestmark перед import

v0.15.46 — 2026-03-21

[2026-03-21] feat(clustering): Semantic object clustering — FAISS + Louvain auto-merge

Тип: feature Файлы: src/services/object_clusterer.py (NEW), db/migrations/046_object_cluster_suggestions.py (NEW), src/services/alias_resolver.py, src/services/object_extractor.py, src/config.py, src/workers/translation_watchdog.py, api/settings_routes.py, api/routes/objects.py Описание: Семантическая кластеризация объектов для автоматического объединения дубликатов. 96.8% объектов (1,430 из 1,478) имели только 1 сигнал из-за того, что LLM создаёт разные названия для одной темы. Три слоя:

  • L1 — Real-time auto-merge (cosine >= 0.85): при создании нового объекта FAISS nearest-neighbor ищет существующий дубликат. Точные совпадения ("India AI data centers" / "India AI data center capacity") мержатся автоматически.
  • L2 — Periodic Louvain clustering (watchdog, каждые 30 мин): строит граф похожести (edge >= 0.50), NetworkX Louvain communities, авто-мерж (>= 0.85) или suggestion (0.60–0.85) для админа.
  • L3 — Admin action (POST /admin/actions/cluster-objects): то же что L2, по запросу.

Дополнительно:

  • Trend metadata merge (_merge_trend_metadata): при объединении объектов переносятся анализы (analyses FK), change_verb, change_metric из source в target
  • _reclassify_target(): после мержа target пересчитывается для SIGNAL→TREND promotion
  • Admin API: GET /objects/cluster-suggestions, POST .../approve, POST .../reject, POST .../bulk
  • Migration 046: таблица object_cluster_suggestions (source_id, target_id, similarity, method, status)
  • Config: 5 настроек порогов кластеризации в src/config.py

Подводные камни:

  • Первый запуск L2/L3 может создать много suggestions — проверять качество перед массовым approve
  • Louvain иногда создаёт рыхлые кластеры — порог suggest_threshold=0.60 фильтрует ложные пары
  • database is locked при concurrent access — используется timeout=30 + retry в object_extractor

v0.15.45 — 2026-03-21

[2026-03-21] feat(citations): Multilingual embedding model + citation relevance filtering

Тип: feature Файлы: src/services/zone_matcher.py, src/services/adapters/searxng.py, src/workers/crawler.py, src/services/object_extractor.py, src/cache/embedding_cache.py, db/connection.py Описание:

  • Мультиязычная модель: bge-small-en-v1.5 (384d, EN) → paraphrase-multilingual-mpnet-base-v2 (768d, 50+ языков) — поддержка кириллицы, китайского и др.
  • Релевантность цитирований: FAISS-based cosine similarity ≥ 0.40 вместо keyword matching — фильтрует нерелевантные URL
  • Детекция языка: _detect_lang() → SearXNG ищет на языке сигнала (ru/zh/auto)
  • Теги в поиске: signal tags добавляются к поисковому запросу для повышения точности
  • Auto-backfill: _build_index() автоматически пересчитывает embeddings при смене модели/размерности
  • DB lock retry: busy_timeout=15000 + retry loop (3 попытки) в object_extractor.py Подводные камни: Первый запуск после обновления медленный — пересчитывает все 395 embeddings зон

v0.15.44 — 2026-03-21

[2026-03-21] feat(sources): Consumer & Chinese product sources — The Verge, TechCrunch, Engadget, Wired, Fashionista, Retail Dive, Pandaily, TechNode

Тип: feature Файлы: 8 новых адаптеров в src/services/adapters/, src/config.py, signal_mapper.py, crawler.py, en.json, ru.json Описание: 8 источников потребительских и товарных трендов:

  • Электроника/Гаджеты: The Verge (Atom), Engadget (RSS)
  • Технологии/Стартапы: TechCrunch, Wired (RSS)
  • Мода: Fashionista (RSS)
  • Ритейл/E-commerce: Retail Dive (RSS)
  • Китайский бизнес: Pandaily, TechNode (RSS, англ.)

v0.15.43 — 2026-03-20

[2026-03-20] feat(sources): Global & Chinese sources — BBC, Al Jazeera, SCMP, Yahoo Finance, CoinGecko

Тип: feature Файлы: src/services/adapters/bbc.py, aljazeera.py, scmp.py, yahoo_finance.py, coingecko.py, src/config.py, scoring.py, signal_mapper.py, crawler.py, en.json, ru.json Описание: 5 новых глобальных источников:

  • BBC Business (RSS) — глобальные деловые новости
  • Al Jazeera (RSS) — глобальные новости и геополитика
  • SCMP (RSS) — South China Morning Post, Китай/Азия
  • Yahoo Finance (API) — мировые индексы (S&P500, FTSE, Nikkei, HSI, DAX, Shanghai) + сырьё (Gold, Oil, Gas, Copper, Wheat)
  • CoinGecko (API) — крипторынок (Bitcoin, Ethereum) Интеграция: configs, scoring (yfinance/coingecko → z-score sigmoid), signal_mapper, crawler imports, i18n

v0.15.42 — 2026-03-20

[2026-03-20] feat(sources): Vedomosti, Interfax, TASS, MOEX adapters

Тип: feature Файлы: src/services/adapters/vedomosti.py, interfax.py, tass.py, moex.py, src/config.py, src/services/scoring.py, src/workers/crawler.py, api/services/signal_mapper.py, en.json, ru.json Описание: 4 новых источника данных:

  • Vedomosti (RSS) — деловая газета, бизнес/экономика
  • Interfax (RSS) — крупнейшее информагентство России
  • TASS (RSS) — государственное информагентство (англ. лента)
  • MOEX (ISS API) — индексы Московской биржи (IMOEX, RTSI), NumericSourceAdapter + SignalDetector Интеграция: configs, scoring (moex: → z-score sigmoid), signal_mapper URL generation, crawler imports, i18n labels, admin source management

v0.15.39 — 2026-03-20

[2026-03-20] feat: Верификация трендов — глагольные изменения + метрики + SearXNG-валидация (v0.15.40)

Тип: feature Файлы: src/services/object_extractor.py, src/services/trend_verifier.py (новый), src/services/trend_classifier.py, src/workers/translation_watchdog.py, db/migrations/044_trend_verification.py, api/schemas.py, api/routes/objects.py, frontend-cascade/app/src/components/trends/object-card.tsx, frontend-cascade/app/src/types/trend.ts, frontend-cascade/app/src/i18n/en.json, frontend-cascade/app/src/i18n/ru.json Проблема: Названия трендов — номинализации без метрик ("AI smart glasses privacy risks"). Нет проверки устойчивости: разовое событие выглядит как тренд. Решение:

  • Object extractor prompt: новые поля change_verb (глагол), change_metric (количественная метрика), verification_queries (2 поисковых запроса для SearXNG)
  • trend_verifier.py: SearXNG temporal analysis + LLM verdict (sustained/event/reversed/stalled)
  • Watchdog: периодическая ре-верификация (24ч), batch по 5 объектов
  • Migration 044: колонки change_verb, change_metric, verification_queries, verification_status, verification_date, verification_evidence_count + индекс
  • Frontend: change_metric на ObjectCard вместо description, VerificationBadge (sustained=зелёный, event=жёлтый, reversed=красный)
  • API: ObjectSummary расширена новыми полями

feat: Источники данных фаза 2 — vc.ru, Коммерсантъ, CNews

Тип: feature Файлы: src/services/adapters/vc_ru.py, src/services/adapters/kommersant.py, src/services/adapters/cnews.py, src/config.py, src/services/scoring.py, src/workers/crawler.py, api/services/signal_mapper.py, frontend-cascade/app/src/i18n/en.json, frontend-cascade/app/src/i18n/ru.json Что сделано:

  • vc.ru — адаптер для русскоязычного техно-сообщества (JSON API v2.8, метрики: reactions, comments, favorites, views). Scoring: sigmoid(reactions + favorites×2 + comments, 150). ID: vcru:{id}. Обработка двух типов элементов: entry (прямой) и news (обёртка с вложенными статьями). HTML-теги из блоков контента очищаются.
  • Коммерсантъ — адаптер для деловой газеты (RSS XML). Scoring: freshness-based. ID: kommersant:{hash}. Fix: Accept: */* вместо application/rss+xml (сервер возвращает 406), utf-8-sig декодирование (BOM).
  • CNews — адаптер для IT-новостей (RSS XML). Scoring: freshness-based. ID: cnews:{hash}. HTML-теги из описаний очищаются через regex.
  • Scoring в scoring.py: ветка vcru: с engagement-формулой
  • Signal mapper: URL-генерация для vcru:, kommersant:, cnews:
  • i18n: метки и описания для всех 3 источников (en/ru)

v0.15.38 — 2026-03-19

feat: Новые источники данных — Habr, RBC, ЦБ РФ + управление в админке

Тип: feature Файлы: src/services/adapters/habr.py, src/services/adapters/rbc.py, src/services/adapters/cbr.py, src/config.py, src/services/scoring.py, src/workers/crawler.py, src/workers/numeric_crawler.py, api/services/signal_mapper.py, frontend-cascade/app/src/routes/admin/crawler.tsx, frontend-cascade/app/src/i18n/en.json, frontend-cascade/app/src/i18n/ru.json Что сделано:

  • Habr.com — адаптер для крупнейшего русскоязычного IT-сообщества (JSON API, метрики: votes, views, bookmarks, comments). Scoring: sigmoid(votes + bookmarks×2, 100). ID: habr:{id}
  • RBC.ru — адаптер для крупнейшего делового СМИ России (RSS XML). Scoring: freshness-based. ID: rbc:{hash}
  • ЦБ РФ — числовой адаптер для макроиндикаторов (ключевая ставка через SOAP API, курсы валют USD/RUB, EUR/RUB, CNY/RUB через REST XML). Наследует NumericSourceAdapter + SignalDetector. ID: cbr:{indicator}:{country}
  • WorldBank и FRED вынесены в DataSourcesConfig — теперь видны и управляемы через админку
  • numeric_crawler теперь проверяет source_enabled:{name} и collection_enabled перед fetch (раньше игнорировал admin toggle)
  • Админка: группа "Numeric Data" (DATA) с cyan цветом; i18n-лейблы и описания для всех 19 источников (en/ru); статусы тултипов переведены Подводные камни:
  • Habr API: ключи publicationIds/publicationRefs (не articleIds/articleRefs)
  • CBR Key Rate: REST endpoint не работает (403) — используется SOAP API
  • CBR: даты DD.MM.YYYY, числа с запятой (92,1234), Nominal нормализация для CNY

v0.15.37 — 2026-03-19

feat: Прогнозы — серверная сортировка, переводы, компактные карточки

Тип: feature Файлы: api/prediction_store.py, api/routes/predictions.py, src/workers/translation_watchdog.py, frontend-cascade/app/src/routes/_dashboard/predictions.tsx, db/migrations/043_predictions_sort_indexes.py Решение:

  • Серверная сортировка по 4 колонкам (date, probability, deadline, status) + asc/desc
  • Перевод claim, zone_name, conditions через систему переводов (READ: cached, WRITE: watchdog)
  • Компактные строки вместо больших карточек, URL search params, Popover-фильтры
  • 6 композитных DB-индексов для быстрой сортировки + фильтрации
  • Формулировки прогнозов: {zone}: {mechanism} вместо object → zone: direction impact
  • 343 прогноза пересозданы с новым форматом

feat: Навигация — scroll restoration, back navigation

Тип: feature Файлы: frontend-cascade/app/src/app.tsx, frontend-cascade/app/src/routes/_dashboard/objects_.$objectId.tsx, frontend-cascade/app/src/routes/_dashboard/recommendations_.$recId.tsx, frontend-cascade/app/src/routes/_dashboard/convergence_.$zoneId.tsx Решение:

  • TanStack Router scrollRestoration: true — сохранение позиции скролла
  • Кнопка «Назад» через router.history.back() — сохраняет URL-параметры фильтров

feat: Конвергенция — кликабельные тренды с локализацией

Тип: feature Файлы: api/routes/convergence.py, src/services/convergence_analyzer.py Решение:

  • Все contributing trends — ссылки на /trend/$trendId (с fallback на plain div без trend_id)
  • Локализованные названия трендов через get_cached_translations_batch()
  • Фильтрация ghost-записей (без trend_id в БД)

refactor: Удаление Skills Map

Тип: refactor Файлы: frontend-cascade/app/src/components/recommendations/skills-view.tsx (удалён), frontend-cascade/app/src/routes/_dashboard/recommendations.tsx, en.json, ru.json Решение:

  • Skills Map показывал перефразированные рекомендации, а не навыки — удалён полностью
  • Убран toggle recommendations/skills, i18n-ключи skills.*

v0.15.35 — 2026-03-17

feat: Продвинутые методы анализа — Фазы 7+8 (Coherent Forecasting)

Тип: feature Файлы: src/services/prediction_search.py, src/services/backtester.py, src/services/cross_impact.py, src/services/ach.py, src/services/scenario_planner.py, src/services/popularity_scoring.py, src/services/metaculus_client.py, db/migrations/042_advanced_analysis.py, api/routes/advanced.py, api/main.py Решение:

  • Prediction search: FAISS-индекс по claim'ам прогнозов, семантический поиск похожих
  • Backtester: LLM-агент проверяет просроченные прогнозы, авто-резолвит при высокой уверенности
  • Cross-impact matrix: Агрегация весов рёбер между кластерами зон
  • ACH: Анализ конкурирующих гипотез (Heuer) — 3-5 гипотез, матрица диагностичности, LLM-генерация
  • Scenario planner: Три сценария (оптимистичный/базовый/пессимистичный) + wildcards + decision points
  • Popularity scoring: BERTrend-подобный скоринг — экспоненциальное затухание (14 дней), перцентильные пороги, 3 компоненты (volume 50%, diversity 30%, recency 20%)
  • Metaculus client: Поиск похожих вопросов + бенчмарк наших вероятностей vs community forecast
  • Migration 042: ach_result, scenarios_result (analyses), popularity_score (trends)
  • API: 10 новых эндпоинтов под /api/advanced/

v0.15.34 — 2026-03-17

feat: Фронтенд — /convergence + /accuracy + навигация (Фаза 6 — Coherent Forecasting)

Тип: feature Файлы: frontend-cascade/app/src/routes/_dashboard/convergence.tsx, accuracy.tsx, api-client.ts, routes.ts, sidebar.tsx, use-convergence.ts, use-predictions.ts, en.json, ru.json Проблема: Новые бэкенд-функции (конвергенция, прогнозы, кластеры) не имели UI. Решение:

  • Страница /convergence: список конвергентных зон с фильтрами (min_trends, category, direction, sort), карточки с joint_probability bar, responsive detail modal (Drawer/Dialog) с нарративом, timeline, взаимодействиями, конфликтами
  • Страница /accuracy: Brier Score карточка, scorecard (open/resolved/overdue), калибровочная таблица, список последних resolved прогнозов
  • API client: методы для convergence, predictions, clusters, graph + TypeScript типы
  • Hooks: useConvergenceList, useConvergenceByZone, usePredictions, useAccuracy
  • Навигация: Convergence (Merge icon) + Accuracy (Target icon) в sidebar группе "Analyze"
  • i18n: полные ключи en + ru для обеих страниц Подводные камни:
  • Страницы работают с пустыми данными (noData state) — нужен хотя бы один анализ для конвергенции

v0.15.33 — 2026-03-17

feat: Структурированные прогнозы + Brier Score (Фаза 5 — Coherent Forecasting)

Тип: feature Файлы: api/prediction_store.py, src/services/calibration.py, api/routes/predictions.py, api/main.py, db/migrations/041_predictions.py Проблема: Невозможно отслеживать точность прогнозов системы, нет feedback loop. Решение:

  • predictions table: claim, probability, deadline, conditions, counter_conditions, status (open/resolved), outcome
  • prediction_store.py: CRUD + extract_predictions_from_analysis (автоматическое извлечение из impact_zones)
  • calibration.py: Brier Score, калибровочная кривая (10 бинов), scorecard (open/resolved/overdue)
  • API: GET /api/predictions (список), GET /api/predictions/accuracy (дашборд), POST /api/predictions (создание), POST /api/predictions/{id}/resolve (закрытие), POST /api/predictions/extract/{job_id} (из анализа)
  • Автоматическое извлечение: зоны с probability > 0 → predictions с deadline из timeframe Подводные камни:
  • Brier Score = 0.25 при пустых данных (baseline coin flip)
  • Predictions с probability=0 игнорируются при извлечении

v0.15.32 — 2026-03-17

feat: Кластеризация зон + граф связей (Фаза 4 — Coherent Forecasting)

Тип: feature Файлы: src/services/zone_clusterer.py, src/services/zone_graph.py, api/routes/clusters.py, api/main.py, db/migrations/040_zone_clusters_and_edges.py Проблема: ~400 зон влияния без иерархии и связей между собой. Решение:

  • HDBSCAN кластеризация: density-based, ~400 зон → ~20 суперкластеров, без указания k
  • Extremized aggregation: геометрическое среднее шансов (d=2.5) — усиливает консенсус
  • Zone graph: 3 типа рёбер — similarity (cosine >0.6), co_occurrence (≥2 общих анализа), conflict (будущее)
  • API: GET /api/zones/clusters, GET /api/zones/graph, POST /api/zones/clusters/compute, POST /api/zones/graph/compute
  • Migration 040: таблицы zone_clusters + zone_edges с индексами Подводные камни:
  • HDBSCAN на L2-normalized embeddings = косинусное сходство через евклидово расстояние
  • Zone graph может быть тяжёлым (O(N²) для similarity) — ограничен max_edges=500
  • hdbscan пакет нужен в requirements.txt

v0.15.31 — 2026-03-17

feat: Универсальный адаптер числовых данных (Фаза 3 — Coherent Forecasting)

Тип: feature Файлы: src/services/adapters/_numeric_base.py, src/services/adapters/_numeric_config.py, src/services/adapters/worldbank.py, src/services/adapters/fred.py, src/services/adapters/__init__.py, src/services/scoring.py, src/services/trend_object_scoring.py, src/workers/numeric_crawler.py, config/numeric_indicators.yaml, requirements.txt Проблема: Система анализировала только текстовые сигналы (HN, GitHub, arXiv). Макроэкономические и демографические данные отсутствовали. Решение:

  • SourceType.DATA — новый тип источника для числовых данных (reliability 0.95)
  • SignalDetector — детектор аномалий: Z-score, YoY%, trend direction (3+ consecutive)
  • NumericSourceAdapter — базовый класс для числовых адаптеров
  • WorldBankAdapter — демография, экономика, экология (wbgapi + httpx fallback, без API key)
  • FREDAdapter — экономика США (fredapi + httpx fallback, требует FRED_API_KEY)
  • Scoring: wb:/who: → 0.5×sigmoid(z,2) + 0.5×sigmoid(dev,20); fred: → sigmoid(z,2)
  • NumericCrawler — отдельный worker, цикл раз в 24ч (не каждые 15 мин как основной)
  • 22 индикатора в YAML-конфиге: рождаемость, ВВП, CPI, безработица, CO2, образование и др.
  • ID формат: wb:SP.DYN.CBRT.IN:DEU, fred:UNRATE Подводные камни:
  • FRED требует API key (FRED_API_KEY env var), без него adapter пропускается
  • wbgapi может не установиться на некоторых платформах — есть httpx fallback
  • SignalDetector требует минимум 5 точек данных для анализа

v0.15.30 — 2026-03-17

feat: Конвергентный анализ влияния (Фаза 2 — Coherent Forecasting)

Тип: feature Файлы: src/services/convergence_analyzer.py, src/prompts/templates.py, api/routes/convergence.py, api/main.py, db/migrations/039_zone_convergence.py Проблема: Разные тренды влияют на одну зону, но анализировались изолированно. Невозможно увидеть совместное воздействие. Решение:

  • convergence_analyzer.py: полный движок конвергентного анализа
    • Temporal weighting: 1 / (1 + years / horizon) — нормализация разных скоростей
    • Interaction matrix: α коэффициенты (синергия +0.3, антагонизм -0.2) через cosine + direction
    • Noisy-OR: P(zone) = 1 - (1-leak) × Π(1 - p_i × w_i) — субаддитивная совместная вероятность
    • Total impact с interaction terms: Σ(imp_i × w_i) + Σ(α_ij × imp_i × imp_j)
  • P_CONVERGENCE_SYNTHESIS: LLM-промпт для синтеза нарратива, timeline overlap, рекомендаций
  • LLM synthesis: автоматическая генерация нарративов для топ-10 зон (haiku, 45с timeout, graceful degradation)
  • API endpoints: GET /api/convergence (список + фильтры), GET /api/convergence/{zone_id} (детали), POST /api/convergence/compute (запуск)
  • Migration 039: таблица zone_convergence с индексами Подводные камни:
  • _collect_zone_contributions берёт только последнюю версию анализа по тренду (MAX version)
  • LLM synthesis — опциональный шаг, при ошибке зона сохраняется без нарратива
  • json_array_length() используется в фильтре min_trends — требует SQLite 3.38+

v0.15.29 — 2026-03-17

feat: Superforecasting + Consistency Check + Red Team (Фаза 1 — Coherent Forecasting)

Тип: feature Файлы: src/prompts/schemas.py, src/prompts/templates.py, src/agents/nodes.py, api/services/analysis_runner.py, api/analysis_store.py, api/schemas.py, api/routes/helpers.py, db/migrations/038_analysis_verification.py Проблема: Анализы давали точечные оценки без вероятностей, проверки согласованности и adversarial review. Решение:

  • Superforecasting в P1: probability, base_rate_reasoning, conditions, counter_conditions, link_probability
  • Chain probability в P2: link_probability для каскадных эффектов (заменяет hardcoded 0.5)
  • Consistency Checker — новый LLM-узел: 6 типов проверок (противоречия, аномалии, калибровка)
  • Red Team — adversarial-адвокат дьявола: минимум 3 challenge на каждый отчёт
  • Pydantic: ConsistencyCheckResponse, RedTeamResponse, ConsistencyIssue, RedTeamChallenge
  • Pipeline: coordinator → collector → researcher → graph → consistency → report → red_team → recommendations
  • Миграция 038: колонки consistency_result, red_team_result в analyses
  • API: AnalysisReportResponse включает consistency_result и red_team_result Подводные камни:
  • Новые узлы используют модель haiku (дешёвая, ~45 сек timeout) — не блокируют pipeline
  • При ошибке/timeout узлы возвращают None — pipeline продолжает без них
  • Калибровка вероятностей: промпт требует разброс 0.3-0.9, не кучку 0.6-0.7

v0.15.28 — 2026-03-17

feat: Transformer embeddings for zone matching (Фаза 0 — Coherent Forecasting)

Тип: feature Файлы: src/services/zone_matcher.py, db/migrations/037_reembed_zones_transformer.py, requirements.txt, docs/scoring/coherent-forecasting.md, docs/INDEX.md Проблема: ZoneMatcher использовал n-gram хэширование (512-dim) — не улавливал семантику. "ML" и "Machine learning" давали нулевое совпадение. Линейный поиск O(N). Решение:

  • Замена n-gram на трансформерные эмбеддинги BAAI/bge-small-en-v1.5 через fastembed (384-dim, ONNX, CPU-only)
  • FAISS IndexFlatIP для косинусного поиска O(log N)
  • Ленивая загрузка модели (singleton, thread-safe)
  • FAISS-индекс строится из DB при первом запросе, инвалидируется при мутации
  • Миграция 037: пересчёт всех ~395 зон батчем
  • Порог снижен с 0.65 до 0.55 (трансформерные косинусы более распределены)
  • Создана документация docs/scoring/coherent-forecasting.md с обоснованием всех методов Подводные камни:
  • fastembed скачивает модель (~50MB) при первом запуске — нужен интернет
  • Старые 512-dim эмбеддинги несовместимы — миграция 037 обязательна
  • _EMBED_DIM изменён с 512 на 384 — тесты уже используют 384

v0.15.19 — 2026-03-13

feat: Phase 2 — Guided Tour, Watchlist, Notifications, Saved Items, Content Plan, Skills View, CTA

Тип: feature Файлы: новые: guided-tour.tsx, content-plan-modal.tsx, skills-view.tsx, use-watchlist.ts, use-notifications.ts, watchlist.tsx, api/routes/watchlist.py, api/routes/saved.py, api/routes/notifications.py, api/notification_service.py, db/migrations/034-036, типы watchlist.ts; изменённые: api/main.py, api/routes/objects.py, api-client.ts, routes.ts, sidebar.tsx, _dashboard.tsx, trend.$trendId.tsx, recommendations.tsx, saved-store.ts, profile.tsx, shared.$token.tsx, crawler.py, pulse.tsx, i18n Решение:

  • O2 Guided Tour: react-joyride, 7 шагов (навигация → период → статистика → карточка → оценка → heatmap → анализ), auto-start после welcome modal, restart из Profile, dark theme styling, data-tour атрибуты на целевых элементах
  • O6 CTA на публичном отчёте: баннер с TrendingUp иконкой и кнопкой "Try Cascade" на shared.$token.tsx
  • F2 Watchlist / Follow: backend user_watchlist таблица + CRUD endpoints, frontend хуки (useWatchlist, useIsWatching, useWatchToggle), Eye кнопка на trend detail page, /watchlist страница с карточками объектов
  • F3 Backend Notifications: notifications таблица, notification_service.py (create/list/mark_read/delete), REST endpoints, use-notifications.ts хук с polling 30s, интеграция в crawler (system notifications при обнаружении новых трендов)
  • F5 Server-side Saved Items: user_saved_items таблица, CRUD endpoints (/api/saved), api-client.ts methods (getSavedItems, saveItem, unsaveItem), sync с Zustand store
  • F9 Content Plan Generator: LLM endpoint POST /objects/{id}/content-plan (3 статьи + 2-недельный календарь + SEO), responsive ContentPlanModal (Drawer/Dialog), кнопка на trend detail page
  • F10 HR Skills View: отдельная вкладка "Skills Map" на recommendations page, aggregation рекомендаций в навыки по ролям, skill extraction из action text, карточки с priority/timeframe/effort и driving trends
  • Migrations: 034 notifications, 035 user_saved_items, 036 user_watchlist
  • i18n: ~60 новых ключей (tour., skills., contentPlan., watchlist., shared.cta*, profile.tour*, nav.watchlist)

v0.15.17 — 2026-03-13

feat: Phase 1 Quick Wins — onboarding, session timer, digest, CSV export, web citations

Тип: feature Файлы: frontend-cascade/app/src/components/onboarding/welcome-modal.tsx (new), frontend-cascade/app/src/components/layout/session-timer.tsx (new), frontend-cascade/app/src/routes/_dashboard/pulse.tsx, frontend-cascade/app/src/routes/_dashboard/recommendations.tsx, frontend-cascade/app/src/components/trends/trend-card.tsx, frontend-cascade/app/src/components/layout/sidebar.tsx, frontend-cascade/app/src/routes/_dashboard.tsx, i18n files Решение:

  • O1 Welcome Screen: 3-step onboarding modal (Discover → AI Analysis → Stay Ahead), framer-motion transitions, responsive Drawer/Dialog, localStorage tracking (cascade_welcome_seen), integrated in _dashboard.tsx
  • O5 Session Timer: отслеживает время сессии + навигации, tooltip "You reviewed in X what would take ~Y manually" (McKinsey 15min/page baseline), collapsed/expanded modes, integrated in sidebar
  • O4+F6 Digest Card: сводка за период на Pulse (7d/30d) — 4 метрики (total/new/updated/signals), summary text, top 3 categories chips, animated counters
  • F7 Export CSV: кнопка Download CSV на странице рекомендаций, RFC-4180 escaping, 11 колонок, disabled state при пустом списке, tooltip с количеством строк
  • F8 Web Citations: Globe icon + count + tooltip на TrendCard в footer (отображается при web_citations > 0)
  • i18n: 21 новый ключ в en.json/ru.json (welcome., sessionTimer., pulse.digest*, recommendations.export*, trend.webCitationsTip)

v0.15.16 — 2026-03-13

feat: LLM-powered object comparison

Тип: feature Файлы: api/routes/objects.py, api/schemas.py, frontend-cascade/app/src/stores/compare-store.ts, frontend-cascade/app/src/components/compare/compare-floating-bar.tsx, frontend-cascade/app/src/components/compare/compare-result-modal.tsx, frontend-cascade/app/src/components/trends/object-card.tsx, frontend-cascade/app/src/routes/_dashboard/objects.tsx, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/types/trend.ts, i18n files Решение:

  • Backend: POST /api/objects/compare — принимает 2 object_ids, загружает данные из objects+trends, отправляет в LLM (sonnet), возвращает структурированное сравнение (summary, key_differences, recommendation, risks, content_opportunity)
  • Auth-gated: требует авторизацию + 1 кредит за сравнение
  • Endpoint размещён ПЕРЕД /{object_id} для корректного роутинга FastAPI
  • Frontend: чекбоксы на ObjectCard, floating bar (framer-motion) для выбора 2 объектов, responsive modal (Drawer/Dialog) для результата
  • Локализация: поддержка locale в LLM-промпте, i18n ключи en/ru Подводные камни:
  • Route order в FastAPI: /compare обязательно перед /{object_id}, иначе "compare" парсится как int параметр
  • Типы frontend выровнены с backend (flat object_a_name/object_b_name, не objects[] массив)

v0.15.0 — 2026-03-10

feat: zone_id filter, pulse heatmap overhaul, API routes modularization

Тип: feature + refactor + fix Версия: 0.15.14 Файлы: api/routes/trends.py (NEW), api/routes/analyses.py (NEW), api/routes/signals.py (NEW), api/routes/zones.py (NEW), api/routes/recommendations.py (NEW), api/routes/helpers.py (NEW), api/analysis_store.py, src/services/zone_matcher.py, src/services/trend_classifier.py, src/workers/crawler.py, frontend-cascade/app/src/routes/_dashboard/pulse.tsx, frontend-cascade/app/src/routes/_dashboard/reports.tsx, frontend-cascade/app/src/lib/api-client.ts

Новые фичи:

  1. Zone ID filter — фильтрация по zone_id (integer) вместо строкового имени зоны. Language-independent. Работает на /trends, /signals, /pulse.
  2. Reports zone filter/reports поддерживает фильтр по зоне влияния (через zone_recommendations).
  3. Zone resolution в анализах_resolve_analysis_zones() автоматически заполняет zone_id в analyses.impact_zones после завершения анализа (только когда trend_id установлен).
  4. API routes modularizationapi/routes.py разбит на 6 модулей.

Pulse heatmap:

  • zone_heatmap показывает зоны из TREND-статус анализов + __unclassified_<cat>__ per category
  • __unclassified_<cat>__ = gap между TREND-активностью и зонно-покрытой частью. Позволяет heatmap оставаться непустым даже когда TREND-тренды ещё не анализировались
  • Дедупликация: каждый тренд считается один раз на зону/день (JOIN trends t ON t.id = a.trend_id AND t.status = 'TREND')
  • activity_heatmap — TREND-тренды по категориям (updated_at), независимый от zone_heatmap
  • Цветовая схема (HEATMAP_HEX): technology=#3b82f6, science=#a855f7, business=#f59e0b, economy=#22c55e, society=#f43f5e
  • Фронтенд: isZoneHighlight = highlight NOT IN KNOWN_CATEGORIES → zone_id filter; __unclassified_*__ рендерятся последними в группе категории (isSpecial=true, лейбл "Другие")

Рефакторинг analyses:

  • trend_class_idobject_id INTEGER FK → objects.id (переименование в v0.15.3)
  • trend_id INTEGER FK → trends.id (добавлен как отдельная колонка, параллельно legacy TEXT trend_id)
  • Уникальный индекс idx_analyses_trend_id_version ON (trend_id INTEGER, version) WHERE trend_id IS NOT NULL
  • Zone filter pivot: analyses.trend_id = trends.id (NOT object_id)

ZoneMatcher:

  • find_zone_id(name) — cross-category поиск, возвращает (canonical_name, zone_id)
  • _find_similar_zones_all(embedding) — поиск по всем категориям без фильтра
  • match_or_create_zone() — валидирует category ∈ {economy, technology, society, business}; ищет по всем категориям перед созданием новой

API filter changes:

  • api/analysis_store.py::list_analyses_grouped() — параметр zone: str | None; фильтр через EXISTS на zone_recommendations
  • api/routes/analyses.pyzone: str | None = Query(None)
  • api/routes/signals.pyzone_id: int | None = Query(None)
  • src/workers/crawler.pyget_trending_page(), get_trending_counts(), _build_where() поддерживают zone_id
  • frontend-cascade/app/src/lib/api-client.ts::getGroupedAnalyses — добавлен zone?: string

Migrations: 022-026 добавлены

Подводные камни:

  • TREND-статус тренды и анализированные объекты могут быть двумя непересекающимися множествами (TREND = web-citations validated; analyzed = user-initiated). Это штатная ситуация, __unclassified_<cat>__ её корректно отражает.
  • Zone filter на /trends?zone_id=X использует дефолтный status=TREND — зоны в heatmap должны соответствовать TREND-анализам.
  • analyses.trend_id (INTEGER FK → trends.id) ≠ legacy trend_id (TEXT FK → signals.id). Оба существуют в схеме одновременно.
  • _resolve_analysis_zones() запускается только когда analyses.trend_id IS NOT NULL (INTEGER FK установлен).

v0.14.0 — 2026-03-04

feat: серверная пагинация/поиск отчётов + Lab saved products

Тип: feature + fix Файлы: db/migrations/021_analyses_search_text.py (NEW), api/analysis_store.py, api/routes.py, src/workers/translation_watchdog.py, frontend-cascade/app/src/routes/_dashboard/reports.tsx, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/i18n/{en,ru}.json, frontend-lab/app/src/stores/saved-store.ts (NEW), frontend-lab/app/src/routes/saved.tsx (NEW), frontend-lab/app/src/components/products/product-card.tsx, frontend-lab/app/src/routes/products_.$productId.tsx, frontend-lab/app/src/components/layout/{sidebar,bottom-nav}.tsx, frontend-lab/app/src/i18n/{en,ru}.json, api/lab/routes/needs.py, frontend-cascade/app/src/routes/admin/crawler.tsx, frontend-cascade/app/src/routes/login.tsx

Новые фичи:

  1. Reports: серверная пагинация, поиск, сортировкаsearch_text колонка в analyses (EN + переводы, как signals/objects). URL search params (validateSearch), debounced search, Popover filters (sort + page size), PaginationNav. Migration 021: search_text + индексы idx_analyses_grouped_lookup, idx_analyses_score.
  2. Lab: Saved Products — Zustand store (lab-saved-store → localStorage), страница /saved, кнопка-закладка на карточках и странице продукта, навигация в sidebar/bottom-nav.
  3. Lab: trend_name переводtrend_name добавлен в _NEED_TRANSLATABLE для needs endpoint.

Исправления:

  1. Reports сортировка по дате — ungrouped analyses дописывались в конец без пересортировки. Теперь оба режима (date/score) пересортируют объединённый список.
  2. React hooks ordercrawler.tsx: useState/useEffect после early return; login.tsx: navigate() во время render → useEffect. products_.$productId.tsx: useSavedStore после early return.
  3. Product card пустые заголовки — убран gradient placeholder при отсутствии image.
  4. Product dimension scoresscore_market != null (0.0 тоже true) → проверка суммы > 0.

rebuild_analyses_search_text() — watchdog вызывает после перевода анализов для обновления search_text.

Подводные камни:

  • search_text backfill при миграции = только EN baseline. Полный rebuild (EN + переводы) после первого прогона watchdog.
  • Lab saved products в localStorage — при очистке браузера данные теряются.

v0.13.0 — 2026-03-03

fix: 4 бага + 2 фичи (сигналы, роли, производительность, shared report, admin sources, searxng remap)

Тип: fix + feature Файлы: api/analysis_store.py, src/workers/crawler.py, db/migrations/020_performance_indexes.py (NEW), api/recommendation_store.py, src/workers/translation_watchdog.py, api/routes.py, api/middleware.py, api/settings_routes.py, api/services/signal_mapper.py, src/services/adapters/searxng.py, frontend-cascade/app/src/routes/_dashboard/recommendations.tsx, frontend-cascade/app/src/components/analysis/source-sidebar.tsx, frontend-cascade/app/src/components/analysis/report-tabs.tsx, frontend-cascade/app/src/routes/shared.$token.tsx, frontend-cascade/app/src/routes/admin/crawler.tsx

Исправления:

  1. phase='new' → 'emerging'create_signals_from_sources() писал невалидную фазу, TrendPhase('new') бросал ValueError, 18/19 сигналов молча терялись. Добавлен fallback в crawler.py и миграция для исправления существующих записей.
  2. JOIN bugrecommendation_store.py использовал tr.id = t.id вместо tr.object_id = t.id. После migration 018 objects.id ≠ trends.id → все trend_momentum/composite были NULL.
  3. Shared report signal links — скрыты ссылки на сигналы на публичных отчётах (phantom searxng IDs → 404). Добавлен showSignalLink prop.
  4. Role chip translations — роли динамические (LLM), добавлены в DB-кеш переводов: watchdog WRITE + API READ (role_labels) + frontend fallback.

Производительность:

  • Migration 020: 15 индексов для analyses, zone_recommendations, trends, signals. Критичный: idx_analyses_tcid_status_ver ускоряет коррелированный подзапрос _LATEST_VERSION_SUBQUERY.

Новые фичи:

  1. Admin sources status — карточки источников показывают last_fetch_at (relative time), цветовой индикатор: зелёный (<2ч), красный (>2ч), чёрный (>24ч). Данные персистятся в DB.
  2. SearXNG source remap — результаты из SearXNG ремапятся на нативные источники (HN, GitHub, arXiv, SO, Reddit, dev.to, YouTube) по домену URL. Для searxng: сигналов в UI показывается домен вместо "web".

Подводные камни:

  • test_settings_e2e.py::test_watchdog_runs_when_enabled зависает (вызывает LLM) — исключать при локальном запуске тестов
  • DOMAIN_SOURCE_MAP в searxng.py — при добавлении нового адаптера добавить маппинг домена

v0.11.0 — 2026-03-01

Тип: feature Файлы: db/migrations/019_shared_reports.py (NEW), frontend-cascade/app/src/routes/shared.$token.tsx (NEW), api/analysis_store.py, api/routes.py, api/schemas.py, api/main.py, frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/vite.config.ts Проблема: Пользователь не мог поделиться отчётом анализа — все страницы требовали авторизации. Решение:

  1. Migration 019 — таблица shared_reports(share_token PK, job_id UNIQUE, created_at, views_count).
  2. Backend: create_share_token() (INSERT OR IGNORE + SELECT, race-safe), get_shared_report(), delete_share_token(). Endpoints: POST/DELETE /analyses/{jobId}/share, GET /analyses/{jobId}/share, GET /public/report/{token}.
  3. Frontend: кнопка Share на странице отчёта → копирует URL в буфер. Публичная страница /shared/$token без auth (skipAuth: true). URL строится на фронте (window.location.origin + '/shared/' + token). Подводные камни: API prefix /public/report (не /shared) чтобы не конфликтовать с SPA route. _build_report_response() — shared helper для дедупликации кода отчётов.

Тип: feature Файлы: src/services/trend_classifier.py, api/analysis_store.py, api/services/analysis_runner.py Проблема: После пользовательского анализа (30 источников) сигналы создавались с score=0.1 (невидимые), object_class не устанавливался, signal_mappings dual-write пропускался, объект/тренд не создавался для новых тем. Решение:

  1. reclassify_single_object() в trend_classifier.py — целевая реклассификация одного объекта (~8 SQL запросов вместо O(N*5) от полного classify_trends()). Обновляет агрегаты, upsert trends (dual-write), пересчёт scores.
  2. create_signals_from_sources() — score 0.1→0.3, confidence 0.3→0.4, добавлен object_class, добавлен signal_mappings INSERT (dual-write gap fix).
  3. _materialize_analysis_trend() в analysis_runner.py — после анализа: создаёт object если новый (через _upsert_object + categorize_trend), обновляет analyses.trend_class_id, вызывает reclassify_single_object(). Подводные камни: reclassify_single_object() НЕ вызывает check_and_queue_auto_analyses() — анализ уже выполнен. Для merged-объектов возвращает "SIGNAL" без обработки.

feat: user signal creation — POST /objects/{id}/signals

Тип: feature Файлы: api/analysis_store.py, api/schemas.py, api/routes.py, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx, frontend-cascade/app/src/i18n/en.json, frontend-cascade/app/src/i18n/ru.json Проблема: Пользователь не мог добавлять сигналы к тренду для отслеживания развития. Решение:

  1. Backend: create_user_signal(object_id, title, url?, content?) — обработка URL-based (дедуп по signal_id) и text-only (user:{uuid}) сигналов. Dual-write в object_signals + signal_mappings. Вызов reclassify_single_object() после добавления.
  2. API: POST /objects/{object_id}/signals (auth required). Schemas: CreateUserSignalRequest, CreateUserSignalResponse(signal_id, title, created).
  3. Frontend: кнопка "Add signal" (Popover с формой: title, URL, note) на странице тренда. useMutation → invalidate queries → toast. Подводные камни: URL-based сигналы: если _url_to_signal_id(url) находит существующий — линкует без создания дубля (created: false). Кнопка видна только аутентифицированным пользователям.

feat: radar page server-side search, pagination, hierarchical filters

Тип: feature Файлы: api/routes.py, frontend-cascade/app/src/routes/_dashboard/radar.tsx Проблема: Radar страница загружала все тренды клиентски, не поддерживала поиск и серверную пагинацию. Решение: Серверный поиск по class_name/description + фильтры (phase, recommendation, category) + pagination. URL-based filter persistence (search params sync) на страницах explore, objects, radar, recommendations.


v0.9.0 — 2026-02-25

refactor(db): Signal → Object → Trend 3-tier model (Migration 018)

Тип: refactor Файлы: db/migrations/018_signal_object_trend_model.py (NEW), src/services/object_extractor.py, src/services/trend_classifier.py, src/services/trend_object_scoring.py, src/services/trend_scoring.py, src/services/alias_resolver.py, src/services/trend_description_generator.py, src/workers/crawler.py, api/analysis_store.py, api/recommendation_store.py Проблема: Модель данных смешивала "объект" и "тренд" в одной таблице trends. Не было сущности "объект" как таковой. Вектор изменения не вычислялся. signal_mappings использовал текстовый object_class вместо FK. Решение:

  1. Migration 018 — создаёт objects (backfill из trends), object_signals (из signal_mappings по trend_id FK), object_aliases (из trend_aliases). Добавляет object_id, change_type, discovery_method в signals и object_id, change_direction, change_type, confirmed_at в trends. 7 новых индексов.
  2. Object extractor — 3-field extraction: object_name (что) + change_description (как) + trend_name (combined). Dual-write в objects/object_signals + signal_mappings (legacy).
  3. Trend classifier_classify_via_objects() (new) / _classify_via_legacy() dispatch. TREND promotion ужесточён: signal_count ≥ 2 AND source_type_count ≥ 2.
  4. Все сервисы (scoring, descriptions, aliases, recommendations) — dual-read/write: objects first, trends/signal_mappings fallback. Подводные камни: objects.idtrends.id (независимые autoincrement). Всегда использовать trends.object_id FK для связи. Deprecated tables (signal_mappings, trend_aliases) сохранены для обратной совместимости.

refactor: удалены метрики urgency/quality из сигналов

Тип: refactor Файлы: src/services/scoring.py, src/workers/crawler.py, api/schemas.py, api/services/signal_mapper.py, api/v1/routes.py, src/agents/nodes.py, src/prompts/templates.py, frontend-cascade/app/src/types/trend.ts, frontend-cascade/app/src/components/trends/signal-view.tsx, frontend-cascade/app/src/routes/_dashboard/signal.$signalId.tsx, frontend-cascade/app/src/routes/_dashboard/explore.tsx Проблема: urgency_score и quality_score на сигналах дублировали trend-level метрики (trend_momentum, trend_significance). Frontend показывал устаревшие данные. Решение:

  1. Backend: удалены calculate_urgency() и calculate_quality() из scoring.py (~90 строк). Убраны из SQL INSERT/UPDATE в crawler.py, из SignalSchema, AnalysisReportResponse.
  2. Frontend: удалён UrgencyQualityIcons из signal-view.tsx. Signal detail показывает Confidence вместо Quality. Sort option quality удалён.
  3. LLM prompts: Срочность/КачествоМоментум/Значимость в templates. agg_urgency/agg_qualitytrend_momentum/trend_significance в agent nodes. Подводные камни: Колонки urgency_score/quality_score остаются в DB (nullable, deprecated). v1 API sort quality удалён — breaking change для внешних клиентов.

feat: унифицированные Popover-фильтры на всех страницах

Тип: feature Файлы: frontend-cascade/app/src/components/ui/filter-chip.tsx (NEW), frontend-cascade/app/src/components/ui/filter-row.tsx (NEW), frontend-cascade/app/src/components/ui/popover.tsx (NEW), frontend-cascade/app/src/routes/_dashboard/explore.tsx, frontend-cascade/app/src/routes/_dashboard/radar.tsx, frontend-cascade/app/src/routes/_dashboard/objects.tsx, frontend-cascade/app/src/routes/_dashboard/recommendations.tsx Проблема: Фильтры были реализованы inline на каждой странице (дублирование). Занимали много вертикального пространства. На recommendations уже был Popover, но узкий (460px). Решение:

  1. Shared components: FilterChip (pill с count/disabled), FilterRow (ряд чипов с опциональным поиском), Popover (Radix UI wrapper).
  2. FilterRow searchable — для зон влияния: <input> + фильтрация чипов + max-h-[200px] overflow-y-auto.
  3. Все 4 страницы: inline-фильтры → кнопка SlidersHorizontal + badge с count активных + Popover sm:w-[560px] lg:w-[640px]. Search и View toggle остаются вне Popover.
  4. Reset: resetFilters() сбрасывает всё к default. activeFilterCount считает ≠ default (sort не считается).

feat: web citation URLs на странице деталей тренда

Тип: feature Файлы: src/services/adapters/searxng.py, src/workers/crawler.py, api/schemas.py, api/routes.py, frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx Проблема: count_citations() возвращал только число цитирований, без URL. На странице тренда не было ссылок на web-источники. Решение:

  1. SearXNG: count_citations() возвращает tuple[int, list[str]] — число + список URL.
  2. Crawler: сохраняет web_citation_urls в metadata_json сигнала.
  3. API: TrendDetail.web_citation_urls — агрегированные URL из всех сигналов тренда.
  4. Frontend: секция "Sources & Citations" на /trend/$trendId показывает кликабельные ссылки на web-источники.

feat: Product Lab — real API integration (замена mock data)

Тип: feature Файлы: frontend-lab/app/src/lib/api-client.ts (NEW), frontend-lab/app/src/hooks/*.ts (10 NEW hooks), frontend-lab/app/src/components/pipeline/pipeline-progress.tsx (NEW), frontend-lab/app/src/components/products/product-card.tsx (NEW), frontend-lab/app/src/routes/*.tsx (7 files), api/lab/routes/needs.py, api/lab/routes/products.py, api/lab/routes/trends.py (NEW), api/lab/services/pipeline_engine.py, api/lab/services/trend_enrichment.py, api/lab/store/needs_repo.py, api/main.py Проблема: Frontend Lab работал на mock data. Backend API был частично реализован но не интегрирован. Решение:

  1. Lab API client — typed fetch wrapper для всех Lab endpoints.
  2. 10 react-query hooksuseLabStats, useLabNeeds, useLabNeed, useLabProducts, useLabProduct, useCascadeTrends, useLabTrendStatus, useCreateNeed, useLabSSE, useDebounce.
  3. SSE pipeline progressGET /lab/needs/{id}/progress стримит прогресс пайплайна. PipelineProgress компонент с 4-step индикатором.
  4. Trends proxyGET /lab/trends проксирует get_trend_classes() без PAT auth, маппинг в Lab-формат.
  5. Need creationPOST /lab/needs с dedup guard, 5-min cooldown, semaphore (max 5 concurrent).
  6. Translationslang param на всех Lab endpoints с кэшированными переводами.
  7. Pagination — полноценная пагинация с sliding window (±2 pages).
  8. Cleanup loop — background _lab_cleanup_loop каждые 10 мин чистит stale state.

feat: расширенная страница деталей тренда

Тип: feature Файлы: frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx, frontend-cascade/app/src/components/trends/trend-score-panel.tsx, frontend-cascade/app/src/components/trends/signal-view.tsx Проблема: Страница тренда показывала простую сетку статистик. Не было информации об объекте, типе изменения, web-цитированиях. Бэйджи фазы/рекомендации не имели тултипов. Решение:

  1. Object-Change model: отображение object_name, change_description, change_types на странице тренда.
  2. Web citations: счётчик + кликабельные URL в секции Sources & Citations.
  3. Collapsible signals: список сигналов сворачивается/разворачивается.
  4. Tooltips: TrendScorePanel — тултипы на бэйджах phase и recommendation с описанием из i18n.
  5. SignalView row variant: убран UrgencyQualityIcons, добавлен linked-trend mini-indicator (phase arrow + composite score, навигация на /trend).

v0.8.2 — 2026-02-25

feat: Objects API + модалка деталей объекта

Тип: feature Файлы: api/routes.py, api/schemas.py, api/main.py, frontend-cascade/app/vite.config.ts, frontend-cascade/app/src/types/trend.ts, frontend-cascade/app/src/lib/api-client.ts, frontend-cascade/app/src/hooks/use-objects.ts (NEW), frontend-cascade/app/src/routes/_dashboard/objects.tsx, frontend-cascade/app/src/components/trends/object-card.tsx, frontend-cascade/app/src/components/trends/object-detail-modal.tsx (NEW) Проблема: Страница Objects (/objects) обращалась к GET /trends вместо objects таблицы. Карточки отображали name (полное имя тренда) вместо object_name (объект). Не было модалки деталей. Решение:

  1. Backend: GET /objects (список) + GET /objects/{id} (детали с сигналами и трендами) — objects_router читает из objects + object_signals + trends.
  2. Frontend: useObjects() / useObject() хуки, ObjectDetailModal — модалка по клику на карточку (вместо навигации).
  3. Карточка: заголовок = object_name, убрано change_description (атрибут тренда, не объекта). Подводные камни: signals таблица НЕ имеет updated_at — использовать fetched_at. objects.idtrends.id для новых записей (разные autoincrement sequences).

feat: responsive модалки — Drawer на мобиле, Dialog на десктопе

Тип: feature Файлы: frontend-cascade/app/src/components/ui/drawer.tsx (NEW), frontend-cascade/app/src/hooks/use-is-mobile.ts (NEW), frontend-cascade/app/src/components/ui/dialog.tsx, frontend-cascade/app/src/components/trends/object-detail-modal.tsx, frontend-cascade/app/src/routes/_dashboard/recommendations.tsx, frontend-cascade/app/package.json Проблема: Dialog модалки не работают на мобильных устройствах — нет swipe-to-close, overlay блокирует взаимодействие. Не было анимации разворачивания. Решение:

  1. Установлен vaul (drawer library) — bottom sheet с drag-handle и swipe-to-close.
  2. drawer.tsx — shadcn-style Drawer компонент (vaul primitive).
  3. useIsMobile() — хук определения мобильного экрана (< 640px, sm breakpoint).
  4. ObjectDetailModal и RecDetailModal — responsive: Drawer на мобиле, Dialog на десктопе. Общий контент вынесен в shared-функцию.
  5. Dialog анимация: slide-in-from-bottom-4 + zoom-in-[0.98] + duration-300 ease-out. Подводные камни: useIsMobile использует matchMedia — SSR-несовместим (не проблема для SPA). Vaul Drawer требует DrawerTitle/DrawerDescription для accessibility (sr-only если визуально не нужен).

fix: описания трендов присваивались неправильным записям (dual-write bug)

Тип: bug Файлы: src/services/trend_description_generator.py Проблема: generate_trend_descriptions() при dual-write использовал UPDATE trends SET description = ? WHERE id = ? с objects.id, но objects.id и trends.id — независимые autoincrement последовательности (для записей после migration 018). ~37% описаний (367/978) были присвоены неправильным трендам. Решение: Заменил WHERE id = ? на WHERE object_id = ? (FK связь). Массовая синхронизация: скопировал правильные описания из objects в trends через object_id FK — 553 записи исправлены. Подводные камни: Всегда использовать trends.object_id для связи с objects, НИКОГДА не предполагать objects.id = trends.id.


v0.8.1 — 2026-02-24

feat: иерархический фильтр по зонам влияния + тултипы (Cascade Recommendations)

Тип: feature Файлы: db/migrations/017_zone_recommendations_zone_id.py (NEW), api/recommendation_store.py, api/schemas.py, api/routes.py, src/workers/translation_watchdog.py, frontend-cascade/app/src/types/analysis.ts, frontend-cascade/app/src/routes/_dashboard/recommendations.tsx, frontend-cascade/app/src/i18n/en.json, frontend-cascade/app/src/i18n/ru.json Проблема: На странице рекомендаций можно фильтровать по категориям (Economy, Technology...), но нельзя выбрать конкретную зону влияния. При наведении на бэйджи (phase, recommendation, priority и т.д.) непонятно что они означают. Решение:

  1. Migration 017ALTER TABLE zone_recommendations ADD COLUMN zone_id INTEGER + backfill из impact_zones_dictionary (case-insensitive match). FK формализует связь zone_recommendations → impact_zones_dictionary.
  2. save_recommendations() — при INSERT теперь резолвит zone_id из словаря (lookup cache для производительности).
  3. Hierarchical filter counts — новый Level 2.5 zones (между categories и priorities) в _compute_filter_counts(). Zone filter пробрасывается в Level 3 (priorities) и Level 4 (timeframes).
  4. Zone translationzone_name добавлен в _translate_recommendations() и _fill_recommendation_gaps() (watchdog). Endpoint возвращает zone_labels dict (original_en → translated) в filters для фильтр-чипов.
  5. Zone FilterRow — иерархический фильтр category → zone на странице /recommendations. При смене category/role зона сбрасывается. Переведённые лейблы через zone_labels.
  6. Tooltips — shadcn <Tooltip> на всех бэйджах в RecScoreCard, RecDetailModal и group headers: score, phase, recommendation, priority, timeframe, effort. i18n-ключи tooltip.* в en.json + ru.json. Подводные камни: zone_name в zone_recommendations уже каноническая (через ZoneMatcher в pipeline), поэтому backfill по LOWER match имеет высокий процент совпадений. Zone_id может быть NULL для строк, чьи zone_name не совпали со словарём — фильтры используют zone_name TEXT (не zone_id) для обратной совместимости.

feat: тултипы на бэйджах Lab frontend

Тип: feature Файлы: frontend-lab/app/src/components/ui/badges.tsx, frontend-lab/app/src/components/ui/score-badge.tsx, frontend-lab/app/src/components/products/product-card.tsx, frontend-lab/app/src/routes/products_.$productId.tsx, frontend-lab/app/src/routes/trends.tsx, frontend-lab/app/src/globals.css, frontend-lab/app/src/i18n/en.json, frontend-lab/app/src/i18n/ru.json Проблема: Бэйджи на страницах Lab (recommendation, phase, kano, effort, category, format, monetization, score) не имели пояснений. Решение:

  1. CSS-only тултипы через .badge-tip class (data-tip + ::after pseudo-element).
  2. Все 8 badge-компонентов обновлены с data-tip + i18n.
  3. ScoreBadge получил tipKey prop (trend score vs viability score).
  4. ProductCard и detail page — inline MetaBadge заменён на shared badge-компоненты. Подводные камни: CSS-only тултипы (не shadcn Tooltip) — работают на hover, не на touch. На мобильных устройствах тултипы не отображаются.

v0.8.0 — 2026-02-24

feat: система рекомендаций (Zone Recommendations)

Тип: feature Файлы: api/recommendation_store.py (NEW), api/routes.py, api/schemas.py, api/services/analysis_runner.py, src/agents/nodes.py, src/prompts/templates.py, db/migrations/015_zone_recommendations.py (NEW), db/migrations/016_rec_search_text.py (NEW), frontend-cascade/app/src/routes/_dashboard/recommendations.tsx (NEW), frontend-cascade/app/src/hooks/use-recommendations.ts (NEW), frontend-cascade/app/src/components/analysis/zone-recommendations-view.tsx (NEW) Проблема: После анализа тренда пользователь получал отчёт с зонами влияния, но не было конкретных рекомендаций — что делать CTO, разработчику, PM или инвестору. Решение:

  1. LLM-агент recommendations_agent() (Haiku) — генерирует рекомендации по 4 ролям (CTO, Developer, PM, Investor) для каждой зоны влияния. Промпт P_REC_ZONE_ROLES на английском.
  2. Pipeline-интеграция — Step 5.5 в analysis_runner.py, после генерации отчёта. Контролируется настройкой recommendations_enabled.
  3. Scoringrec_score (0-100) = trend_composite×0.50 + zone_impact×10×0.25 + priority_w×0.15 + timeframe_w×0.10. Вычисляется на лету, не хранится.
  4. Standalone страница /recommendations — role tabs, score groups (Act Now ≥70 / Plan For 40-69 / Monitor <40), карточки с trend scores (S/M/C bars), модальное окно деталей.
  5. Inline-таб в отчёте анализа — zone-recommendations-view.tsx внутри report tabs.
  6. Hierarchical filters — иерархия role → category → priority → timeframe, каждый уровень считает с применением фильтров сверху. totals для "All" badge.
  7. Migrations: 015 (zone_recommendations table), 016 (search_text + translations rebuild). Подводные камни: rec_score вычисляется на Python, не хранится в DB — сортировка по score только через Python post-sort с overfetch. Для массива 1000+ рекомендаций может быть неэффективно. impact_zones_dictionary должна быть заполнена для корректной работы category-фильтров (заполняется миграцией). Если recommendations_enabled=false, рекомендации не генерируются при анализе (старые остаются).

feat: админ-панель рекомендаций и разделение настроек

Тип: feature Файлы: api/settings_routes.py, api/settings_store.py, frontend-cascade/app/src/routes/admin/system.tsx, frontend-cascade/app/src/routes/admin/crawler.tsx (NEW), frontend-cascade/app/src/components/layout/admin-sidebar.tsx Проблема: Настройки auto-analysis и recommendations не отображались в админке. Все настройки были на одной странице. Решение:

  1. Разделение admin/system на две страницы: System (auto-analysis, recommendations, translations, descriptions, eval) и Crawler (sources, collection, external API).
  2. Recommendations settings — toggle recommendations_enabled + кнопка "Regenerate All" (POST /admin/actions/regenerate-recommendations).
  3. Auto-analysis settings — toggle + max_per_day (1-50) + depth (1-7).
  4. Backendrecommendations_enabled в _DEFAULTS, SettingsBody, SettingsResponse. Regenerate endpoint: background thread, deletes old recs → calls recommendations_agent → saves. Подводные камни: Regenerate работает в фоновом потоке. При большом количестве анализов (~50+) может занять несколько минут. Прогресс не отслеживается на фронтенде.

feat: сортировка отчётов по дате/оценке

Тип: feature Файлы: api/analysis_store.py, api/routes.py, frontend-cascade/app/src/routes/_dashboard/reports.tsx, frontend-cascade/app/src/lib/api-client.ts Проблема: Страница отчётов всегда показывала анализы по дате (newest first), без возможности отсортировать по оценке. Решение: GET /analyses?grouped=true&sort=date|score — параметр sort в list_analyses_grouped(). Frontend: toggle Date/Score с иконками Calendar/BarChart3. Query-ключ включает sort для автоматического рефетча. Подводные камни: При sort=score комбинированный список (grouped + ungrouped) пересортируется в Python. Для offset-based пагинации это безопасно, т.к. overfetch покрывает.

feat: Product Lab backend + frontend (MVP)

Тип: feature Файлы: api/lab/ (17 files, NEW), frontend-lab/ (37 files, NEW) Проблема: Нет инструмента для извлечения пользовательских болей из трендов и генерации бизнес-идей. Решение:

  1. Backendapi/lab/: needs extraction (P5), product ideation (P6), pipeline engine, SQLite store (needs_repo, products_repo), routes.
  2. Frontendfrontend-lab/: отдельное React-приложение с TanStack Router, pages: needs, products, trends. Mock data для демонстрации. Общие UI-компоненты (badges, ring-score, intensity-bar). Подводные камни: Product Lab пока работает с mock data на фронтенде. Backend API подключён, но не интегрирован с crawler pipeline. Требует отдельного npm install в frontend-lab/app/.

feat(db): search index migration (007)

Тип: feature Файлы: db/migrations/007_search_index.py (NEW) Решение: Миграция для создания поискового индекса.

chore: dev infra improvements

Тип: chore Файлы: dev.py, frontend-cascade/app/vite.config.ts Решение: 1) dev.py автоматически очищает __pycache__ при старте (исключая node_modules и venv). 2) Добавлен proxy /recommendations в vite.config.ts.


v0.7.4 — 2026-02-24

refactor: batch translation with context + sentence case

Тип: refactor Файлы: api/translation_service.py, src/workers/translation_watchdog.py, src/workers/crawler.py, api/settings_routes.py, CLAUDE.md Проблема: 1) Заголовки переводились без контекста (только title, без description и category) — LLM неверно интерпретировал многозначные термины. 2) Перевод анализов делал 30-80 отдельных LLM-вызовов (по одному на каждый zone name, mechanism, node label, source title). 3) Батч-методы всегда использовали "from English" без автодетекта языка. 4) Капитализация не контролировалась — LLM мог вернуть Title Case вместо sentence case. Решение: 1) Новый _batch_translate_items() — title + description + category в одном промпте для контекста, чанки по 30. 2) Новый _batch_translate_short_texts() — batch для коротких строк анализа с дедупликацией, чанки по 50. translate_analysis_result() теперь делает 2 LLM-вызова вместо 30-80. 3) _detect_source_lang() — автодетект по кириллице в батче. 4) _enforce_sentence_case() — постобработка первой буквы + явные правила капитализации в промптах. 5) Watchdog, crawler, settings_routes передают category в translate. Подводные камни: Старые кэшированные переводы (без sentence case) остаются в DB. Для перегенерации — очистить таблицу translations и запустить admin action. Legacy методы _batch_translate_titles() / _batch_translate_texts() сохранены для обратной совместимости (watchdog signal translation).


v0.7.1 — 2026-02-21

feat: expose external_api_enabled in admin settings

Тип: feature Файлы: api/settings_routes.py, tests/e2e/test_settings_e2e.py Проблема: External API (/api/v1/) гейтится через external_api_enabled (default: False), но это поле не было доступно в admin settings endpoint — невозможно включить через API. Решение: Добавлены external_api_enabled и external_api_rate_limit в SettingsBody, SettingsResponse, _build_settings_response() и update_settings_endpoint. Rate limit clamped [1, 300]. Подводные камни: После deploy нужно вручную включить: PUT /admin/settings {"external_api_enabled": true}.


v0.7.0 — 2026-02-21

refactor: scoring recalibration v2 — stretch practical ranges

Тип: refactor Файлы: src/services/trend_object_scoring.py, db/migrations/014_reset_trend_scores_v2.py, tests/unit/test_trend_object_scoring.py Проблема: Формулы скоринга откалиброваны на 5+ источников и 10+ сигналов, но 97% трендов имеют 1-3 сигнала от 1-2 источников. Практический composite: 0-40 вместо 0-100. Рекомендация ACT_NOW почти недостижима. Решение: 5 параметрических изменений: (1) DENSITY_BASELINES tech: 10→3, (2) MAX_COVERAGE_SOURCES=3 для confidence Coverage, (3) sample_size exp(-n/3) вместо exp(-n/10), (4) momentum default n<2: 40 вместо 25, (5) пороги рекомендаций: sig>=50, mom>=45, conf>=45, med>=35. Migration 014 сбрасывает все scores — backfill при рестарте. Подводные камни: Все scores пересчитываются при первом запуске (~90 сек на 1806 трендов). Исторические значения scores станут несравнимы с новыми.


v0.6.15 — 2026-02-20

feat: add analysis data to External API v1

Тип: feature Файлы: api/v1/schemas.py, api/v1/service.py, api/v1/routes.py, tests/unit/test_v1_routes.py, docs/api/EXTERNAL-API.md Проблема: Внешнее API не предоставляло данные анализа (LLM-отчёты). Агенты получали только скоринг и сигналы, без исследовательских фактов. Решение: 1) Новый эндпоинт GET /api/v1/trends/{id}/analysis — полный отчёт с impact zones и источниками. 2) TrendDetail теперь включает analysis (последний отчёт inline). 3) TrendItem обогащён полями has_analysis, analysis_score, analysis_confidence. 4) LEFT JOIN на analyses для эффективного обогащения в списках. 5) 8 новых тестов. Подводные камни: analysis в TrendDetail = null если анализ не проводился. Endpoint /analysis возвращает 404.


v0.6.14 — 2026-02-20

feat: v2 scoring recalibration for better recommendation distribution

Тип: feature Файлы: src/services/trend_object_scoring.py, tests/unit/test_trend_object_scoring.py, db/migrations/014_reset_trend_scores_v2.py Проблема: После первой калибровки (v0.6.12) пороги рекомендаций были недостаточно точны — DENSITY_BASELINES были завышены (10 для technology), coverage делился на 5 источников вместо 3, sample_size затухал слишком медленно (exp(-n/10)). В результате significance и confidence были ниже ожидаемого. Решение: 1) Снижены DENSITY_BASELINES (tech: 10→3, science: 5→2 и др.). 2) MAX_COVERAGE_SOURCES=3 — coverage теперь достигает 100% при 3 источниках. 3) sample_size затухает быстрее (exp(-n/3)). 4) Momentum default для n<2 повышен с 25→40. 5) Пороги рекомендаций пересчитаны: sig>=50, mom>=45, conf>=45, med_conf>=35. 6) Миграция 014 сбрасывает все score для автоматического пересчёта при старте. Подводные камни: После deploy нужен рестарт PM2 для запуска backfill. Все тренды будут пересчитаны — это может занять ~минуту.


v0.6.13 — 2026-02-20

Тип: feature Файлы: api/v1/service.py, api/v1/routes.py, api/routes.py, src/services/trend_classifier.py, tests/unit/test_v1_routes.py Проблема: API не поддерживал сортировку по confidence и significance (v1). Эндпоинт GET /api/v1/trends/top возвращал тренды с рекомендацией IGNORE в топ-5 по оценке и скорости роста. Решение: 1) Добавлен confidence sort во внутренний API. 2) Добавлены significance и confidence sorts в v1 API. 3) get_top_trends() теперь исключает IGNORE из highest_scored и fast_growing (new остаётся без фильтра — новые тренды ещё не оценены). 4) Два новых теста подтверждают фильтрацию. Подводные камни: new категория в top trends намеренно НЕ фильтрует IGNORE — новые тренды могут ещё не иметь рекомендации.


v0.6.12 — 2026-02-20

Тип: feature Файлы: src/services/trend_object_scoring.py, src/services/trend_classifier.py, api/routes.py, tests/unit/test_trend_object_scoring.py Проблема: 99.7% трендов (1800/1806) имели рекомендацию IGNORE — пороги (sig≥60, mom≥55, conf≥60) были выше максимальных значений в данных (sig max=51.5, mom max=65.1, conf max=49.8). API не поддерживал сортировку по оценкам. Решение: 1) Снижены пороги до sig≥43 (P75), mom≥35, conf≥35, med_conf≥27 — соответствуют реальному распределению данных. 2) Добавлен параметр sort_by в GET /trends и GET /trends/weak: default, composite, momentum, significance, newest. 3) Обновлены 9 тестов рекомендаций + добавлен тест граничных значений. Подводные камни: После деплоя нужно пересчитать рекомендации: UPDATE trends SET trend_composite = 0 WHERE trend_composite > 0 перед рестартом → backfill пересчитает всё.


v0.6.11 — 2026-02-20

fix: migrations 012/013 crash-loop — PRAGMA foreign_keys ignored inside transaction

Тип: bug Файлы: db/migrations/012_trends_class_name_nocase.py, db/migrations/013_drop_agg_columns.py, tests/unit/test_migrations.py Проблема: SQLite PRAGMA foreign_keys = OFF молча игнорируется внутри активной транзакции. Migrator оборачивает up() в BEGIN, миграция пытается отключить FK внутри — pragma не работает → DROP TABLE trends падает с FOREIGN KEY constraint failed → crash-loop (105 рестартов). Решение: Миграции теперь вызывают conn.commit() перед PRAGMA foreign_keys = OFF, выходя из транзакции мигратора. Добавлены 10 unit-тестов для миграций, включая тест-доказательство бага (test_pragma_fk_off_ignored_inside_transaction). Подводные камни: Любая миграция с table recreation (DROP + RENAME) ОБЯЗАНА вызывать conn.commit() перед PRAGMA foreign_keys = OFF. Это правило SQLite, не документированное явно.


v0.6.10 — 2026-02-20

fix: translation watchdog broken by asyncio.Lock event loop mismatch

Тип: bug Файлы: api/translation_service.py Проблема: На Python 3.13 asyncio.Lock привязывается к event loop при первом acquire. Singleton TranslationService создаёт lock на одном loop (web request), а watchdog пытается его использовать из другого — все batch-переводы падают с RuntimeError: Lock is bound to a different event loop. Watchdog логирует "translated 1298/1298" но реально ничего не сохраняется (ошибка ловится внутри try/except в _batch_translate_titles). Решение: _get_lock() теперь проверяет _loop атрибут lock-а и пересоздаёт его если текущий event loop отличается. Подводные камни: Python 3.13 изменил поведение asyncio.Lock_loop теперь устанавливается при первом acquire, а не при создании. Любой singleton с asyncio.Lock может сломаться при использовании из разных event loop контекстов.


v0.6.9 — 2026-02-20

Tech debt cleanup: COLLATE NOCASE, row_to_item, agg* removal, test infra

Тип: refactor Файлы: 26 файлов (+139/-189 строк) Что сделано:

  1. DB-1 (Migration 012): Пересоздание trends с class_name UNIQUE COLLATE NOCASE — исправлена корневая причина case-дубликатов. Убраны 5 workaround'ов COLLATE NOCASE из trend_classifier.py.
  2. BE-2: _row_to_item() переведён с позиционных индексов row[23] на именованный доступ row["object_class"] через sqlite3.Row. Любое изменение SIGNAL_COLS теперь безопасно.
  3. DB-3 (Migration 013): Удалены мёртвые agg_* колонки (agg_score/urgency/quality/velocity/recommendation) из: DB, scoring, classifier, routes, schemas, frontend types. LLM-промпты получают значения через вычисление из trend_*.
  4. Test infra: --timeout=30 -m "not external" в pytest.ini. pytestmark = pytest.mark.e2e добавлен в 10 e2e файлов. pytest tests/ больше не зависает.

v0.6.8 — 2026-02-20

Fix: trend card translations + analytics

Тип: fix + feature Файлы: src/workers/translation_watchdog.py, api/routes.py, frontend-cascade/app/index.html, tests/e2e/test_versioning_e2e.py, tests/unit/test_analysis_store.py

Изменения (v0.6.1 → v0.6.8):

  1. Перевод отчётов на английский (v0.6.2): отчёты генерируются на русском, но система считала их английскими. Теперь Phase 6 + watchdog определяют язык отчёта и переводят на ВСЕ другие языки.

  2. No LLM on READ path (v0.6.3): убран on-demand LLM вызов из get_report — только cache lookup. Переводы заполняются Phase 6 и watchdog.

  3. Перевод карточек трендов (v0.6.7): watchdog не переводил trends.class_name и description — только signal titles. Добавлен _fill_trend_class_gaps() для автоматического перевода трендов.

  4. Chunking для CLI (v0.6.8): 1808 трендов одним батчем → промпт 332K символов → [Errno 7] Argument list too long. Разбито на батчи по 30 штук.

  5. Analytics (v0.6.7): интеграция с Umami в index.html.

  6. Test fixtures (v0.6.4): добавлена таблица signal_mappings в тестовые фикстуры.

Подводные камни:

  • translate_trend_items() через CLI: промпт > 100K символов → Argument list too long. Всегда чанкить по 30 items.
  • Правило: No LLM calls on READ pathget_report и list_trend_classes используют только cached_only методы.
  • Watchdog переводит 3 типа контента: trend class_names, signal titles, analysis texts.

v0.6.0 — 2026-02-18

External API + PAT Authentication

Тип: feature Файлы: api/v1/ (new package), api/auth/pat_store.py, api/auth/pat_routes.py, db/migrations/008_personal_access_tokens.py, api/settings_store.py, api/main.py, frontend (types, api-client, pat-manager, i18n) Что добавлено:

  1. Personal Access Tokens (PAT) — создание, отзыв, просмотр через JWT-защищённые эндпоинты /auth/pat/
  2. External API v1 (/api/v1/) — 4 эндпоинта для агентов:
    • GET /api/v1/trends — пагинированный список трендов (sort: score/momentum/new/signal_count)
    • GET /api/v1/trends/top — Top-5 по 3 критериям (new, fast_growing, highest_scored)
    • GET /api/v1/trends/{id} — детали тренда с сигналами
    • GET /api/v1/signals — пагинированный список сигналов
  3. Rate limiting per PAT (sliding window, настраиваемый через settings)
  4. Feature toggleexternal_api_enabled в settings (по умолчанию выключен)
  5. Frontend PatManager — компонент для управления токенами на странице профиля
  6. 37 новых тестов — pat_store (19), pat_routes (4), v1_routes (14) Архитектура: Модуль api/v1/ отключаемый. Всегда смонтирован, но get_pat_user() проверяет external_api_enabled → 503 если false.

v0.5.1 — 2026-02-18

Mobile-first filters + Anthropic API retry + search fix

Тип: fix Файлы: explore.tsx, cli_client.py Проблема:

  1. Фильтры на Explore (Status, Category, Sort) не помещались на экран 375px — лейблы min-w-[5rem] отнимали 80px, сегменты не скроллились
  2. Anthropic API 500 (api_error, internal server error) не ретраился — клиент падал сразу
  3. Русский поиск возвращал 0 результатов из-за зомби-процессов на порту 8000

Решение:

  1. Mobile filters: лейблы hidden sm:block (скрыты на мобильных), строки flex-colsm:flex-row, сегменты/sort overflow-x-auto scrollbar-none
  2. Retry: добавлены "internal server error", "api_error", "server error" в RETRYABLE_ERRORS — до 3 попыток с exponential backoff
  3. Search: причина — зомби-процессы. Решение: taskkill //F //PID для всех процессов на порту + очистка __pycache__

Подводные камни:

  • На Windows при рестарте сервера ВСЕГДА убивать ВСЕ процессы на порту (netstat -ano | grep :8000taskkill каждого)
  • Mobile filters: лейблы видны только на sm: и выше. На мобильных контролы самодостаточны (иконки + текст кнопок)

v0.5.0 — 2026-02-18

LLM-based categorization + Radar list view + UI fixes

Тип: feature + fix Файлы: object_extractor.py, trend_classifier.py, radar.tsx, analyze.jobId.tsx, analyze.jobId.report.tsx, analyze.tsx, signal-view.tsx, trend-card.tsx, auth-store.ts, api-client.ts, routes.py, settings_routes.py, translation_service.py, use-trends.ts, ru.json, system.tsx, nodes.py, crawler.py Изменения:

  1. LLM categorization: категория тренда определяется LLM в промпте object extraction (вместо keyword-based _categorize_trend и source-based _infer_category). extract_batch() возвращает category, save_extractions() сохраняет в signals.category, classify_trends() использует majority vote. Добавлен backfill_categories() для пере-категоризации существующих сигналов.
  2. Radar list view: реализовано табличное представление (TrendRow компонент) — grid/table toggle теперь работает. Поиск на всю ширину, фильтры категорий на отдельной строке с count badge, все 5 категорий всегда видны.
  3. Analysis navigation: back-кнопки в report/progress ведут на /reports (список анализов). Completion redirect всегда на report page.
  4. Auth: token refresh hook, улучшенные error messages в api-client.
  5. Admin: settings routes, system admin page additions.
  6. Translation: улучшения в translation_service.

Подводные камни:

  • backfill_categories() — async, требует LLM-вызовы. Запускать вручную или через pipeline.
  • После backfill нужно вызвать classify_trends(conn) для пересчёта категорий трендов.

v0.4.10 — 2026-02-17

UI: 7 fixes — cards, translation, layout, redirect, saved, auth

Тип: feature + fix Файлы: trend-card.tsx, signal-view.tsx, radar.tsx, signals.tsx, explore.tsx, trend.trendId.tsx, analyze.jobId.tsx, saved.tsx, saved-store.ts, trend-score-panel.tsx, inline-analysis-report.tsx, api-client.ts, use-trends.ts, routes.py, utils.ts, en.json, ru.json Изменения:

  1. Radar: TrendCard горизонтальный layout (scores слева, текст справа), фиксированная высота h-[168px], пагинация PAGE_SIZE=12
  2. Explore: SignalView lg фиксированная высота h-[280px], linked trend block side-by-side с метриками
  3. Signals/Radar: formatClassName() для camelCase→Human, backend lang param на /trends и /trends/weak, кэшированный перевод class_name/description
  4. Trend detail: выравнивание высоты колонок (h-full на Stagger, TrendScorePanel, ObjectInfoPanel)
  5. Analysis redirect: navigate на /analyze/jobId после запуска, возврат на /trend/trendId после завершения
  6. Saved page: 3 секции (Тренды + Сигналы + Анализы), savedTrendClasses в store
  7. Auth guard: disable кнопки Analyze без авторизации, user-friendly error messages в api-client (JSON parse + HTTP status overrides)

v0.4.9 — 2026-02-17

Refactor: Style standardization, component decomposition, shared UI

Тип: refactor Файлы: progress-tracker.tsx, trend.$trendId.tsx, analyze.tsx, query.tsx, header.tsx, sidebar.tsx, profile.tsx, analyze.$jobId.tsx, query-form.tsx, query-result.tsx, explore.tsx, radar.tsx, saved.tsx, reports.tsx, alerts.tsx, trend-score-panel.tsx (new), linked-trends-section.tsx (new), inline-analysis-report.tsx (new), empty-state.tsx (new), sort-button-group.tsx (new)

Изменения:

  • P2 Стандартизация стилей: 40 inline style={{}} → Tailwind arbitrary values (text-[var(--x)], bg-[var(--x)]). progress-tracker.tsx лидер — 39→3 inline. Оставлены inline только для динамических значений (width%, color-mix, calc)
  • P2 Semantic tokens: Заменены hardcoded hex #f59e0bvar(--impact-high), #6366f1var(--brand-secondary) в trend-score-panel.tsx
  • P3 Декомпозиция: trend.$trendId.tsx (954→445 LOC) разбит на 3 компонента: TrendScorePanel (ScoreRing SVG + dimension bars), LinkedTrendsSection (sort + signal list), InlineAnalysisReport (SSE progress + versions + report tabs)
  • P3 Shared components: <EmptyState> (icon + title + description + children) заменил 3 copy-paste блока (saved, reports, alerts); <SortButtonGroup> (generic typed) заменил 2 дублированных sort pill-группы (explore, radar)

Подводные камни: EmptyState поддерживает children для кнопок (как в reports). SortButtonGroup — generic по типу ключа (<K extends string>), совместим с as const массивами.


v0.4.8 — 2026-02-17

Refactor: Frontend code quality — deduplication, dead code, CSS fixes

Тип: refactor Файлы: globals.css, api-client.ts, utils.ts, constants.ts, use-share.ts (new), use-quick-analysis.ts, analyze.tsx, trend.$trendId.tsx, signal.$signalId.tsx, analyze.$jobId.report.tsx, trend-card.tsx, saved.tsx, signal_mapper.py, analysis.ts

Изменения:

  • P0 Баги: Определены CSS-переменные --text-muted (307 использований) и --border-primary (45); передача locale в analyzeTrend() для генерации отчётов на выбранном языке; удалён debug console.log из api-client
  • P1 Дедупликация: formatRelativeTime()lib/utils.ts (из 2 файлов); RECOMMENDATION_COLORSlib/constants.ts (из 2 файлов); handleShare()hooks/use-share.ts (из 3 файлов)
  • Удалён dead code: interactive-graph.tsx, streaming-text.tsx, report-actions.tsx, signal-card.tsx, use-report-actions.ts, AnalysisJob interface, generate_source_urls() (backend)
  • UI: Исправлен постоянный скроллбар (overflow-y: scrollauto)
  • Saved page: SignalCard заменён на SignalView variant="lg"

Подводные камни: signal-card.tsx использовался в saved.tsx — заменён на SignalView перед удалением.


v0.4.7 — 2026-02-17

Fix: LLM usage tracking — cost estimation + caller propagation

Тип: fix Файлы: src/llm/cli_client.py, src/agents/nodes.py, db/llm_usage_store.py

Проблема: Таблица llm_usage записывала cost_usd = 0.0 (CLI с подпиской не возвращает стоимость) и caller = "unknown" (контекстная переменная не пропагировалась через _run_async → новый поток → asyncio.run).

Решение:

  1. Добавлена оценка стоимости по прайсу модели (_estimate_cost()) — sonnet: $3/$15 per 1M tokens, haiku: $0.80/$4, opus: $15/$75. Используется как fallback если CLI не вернул cost_usd.
  2. _run_async() теперь копирует contextvars через contextvars.copy_context()ctx.run(asyncio.run, coro), что обеспечивает проброс set_caller() через границу потоков.

Подводные камни:

  • Оценка токенов грубая (4 символа ≈ 1 токен), погрешность ±25%
  • Прайс зашит в код — при изменении цен Anthropic нужно обновить _MODEL_PRICING
  • set_caller() должен вызываться ДО await cli.query() в том же async-контексте

v0.4.6 — 2026-02-17

Feature: Shared report components + full tabbed report on trend page

Тип: feature + refactor Файлы: report-tabs.tsx, report-action-bar.tsx, trend.$trendId.tsx, analyze.$jobId.report.tsx

Проблема: Страница тренда показывала куцый inline-отчёт с collapsible секциями, тогда как страница отчёта имела полноценный UI с 5 табами.

Решение:

  1. Извлечены 2 shared компонента: ReportTabs (5 табов) и ReportActionBar (deepen/expand/re-analyze + слоты)
  2. Report page и Trend page используют одни компоненты — нет дублирования кода
  3. Slot pattern: versionNav (pills на trend, chevrons на report) и compareButton (только report)
  4. startInlineAnalysis принимает { depth, timeHorizon, parentJobId } для deepen/expand inline

Подводные камни:

  • ReportActionBar использует DEPTH_STEPS и HORIZON_STEPS — изменять только там
  • При добавлении нового таба — обновить ReportTabId type и массив tabs в report-tabs.tsx

v0.4.2 — 2026-02-17

Тип: fix Файлы: frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx, src/workers/auto_analyzer.py

Проблема: Анализы, запущенные со страницы тренда, не привязывались к тренду и не отображались в AnalysisTimeline. На продакшене 24 из 25 анализов имели trend_class_id = NULL.

Причина: Две точки входа не передавали trend_class_id:

  1. trend.$trendId.tsx:328 — кнопка "Analyze Best" передавала trend_class_id: undefined вместо String(data.id)
  2. auto_analyzer.py:100reserve_analysis() вызывался без trend_class_id, хотя значение было доступно

Решение:

  1. Передаём trend_class_id: String(data.id) при навигации на /analyze со страницы тренда
  2. Передаём trend_class_id=trend_class_id в reserve_analysis() в auto_analyzer
  3. Backfill на продакшене: обновлён 1 анализ через JOIN signal_mappings → trends

Подводные камни: AnalysisTimeline ищет анализы по trend_class_id (integer), а не по trend_id (string). Любая точка входа анализа из контекста тренда ОБЯЗАНА передавать trend_class_id.


v0.4.1 — 2026-02-17

Cleanup: Remove unused streaming props from report page

Тип: refactor Файлы: frontend-cascade/app/src/components/analysis/summary-view.tsx, frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx

Проблема: SummaryView принимал неиспользуемые пропсы isStreaming и streamingComponent. Report page импортировал StreamingText и useAnalysisStore, которые больше не нужны.

Решение: Удалены мёртвые пропсы из SummaryView, убраны неиспользуемые импорты (StreamingText, useAnalysisStore) и состояния (animated, isFirstView) из report page.


v0.4.0 — 2026-02-17

Trend-Object Scoring System (3-Dimension Model)

Тип: feature Ветка: refactor/signal-trend-model

Проблема: Тренды стали агрегированными объектами (например "React", "ChatGPT"), а не отдельными сигналами. Старая система оценки усредняла signal-level scores, что не учитывало кросс-источниковую корробрацию, плотность данных, темпоральную динамику и фазу жизненного цикла.

Решение: Новая 3-мерная модель оценки, спроектированная на основе исследований проекта (research/scoring-research/, research/expert-review-2026-02/):

3 измерения (0-100 каждое):

  1. Significance = EvidenceStrength(40%) + SourceDiversity(30%) + SignalDensity(30%)

    • EvidenceStrength: recency-weighted avg signal scores, sigmoid-mapped
    • SourceDiversity: log2(unique_sources+1) / log2(6) — 1 source=43, 5 sources=100
    • SignalDensity: count / (count + baseline) — baseline по категориям (tech=10, science=5)
  2. Momentum = ArrivalRate(40%) + ScoreTrajectory(35%) + Freshness(25%)

    • ArrivalRate: ratio recent vs older signals (window по категориям: social=24h, tech=72h, science=168h)
    • ScoreTrajectory: avg score newer vs older signals
    • Freshness: exponential decay (half-life по категориям)
  3. Confidence = Coverage(30%) + SampleSize(25%) + Agreement(25%) + Consistency(20%)

    • Coverage: unique_sources / 5
    • SampleSize: 1 - exp(-n/10)
    • Agreement: CV-based per-source mean scores (single source penalty = 0.4)
    • Consistency: 1 - CV всех signal scores

Composite Score (0-100): Significance(50%) + Momentum(25%) + Confidence(25%)

5-стадийный жизненный цикл:

Backend Phase UI Badge Детекция
introduction emerging age < young_threshold AND n <= 3
rise emerging arrival_accel > 0.3 AND age < mature_threshold
peak mature stable arrival rate
decline fading arrival_accel < -0.3
obsolescence fading declining + old + n > 5

5-уровневая матрица рекомендаций: ACT_NOW, RISKY_HYPE (новый), MONITOR, EVERGREEN, IGNORE. Override: phase=OBSOLESCENCE → всегда IGNORE.

Файлы:

  • src/services/trend_object_scoring.pyНОВЫЙ: вся логика скоринга (370 LOC)
  • src/services/trend_scoring.py — делегирует в новый модуль, пишет оба формата (legacy agg_* + новые trend_*)
  • src/services/trend_classifier.py — SELECT запросы включают 7 новых колонок
  • api/analysis_store.py — миграция (7 новых колонок на trends), backfill _backfill_trend_scores()
  • api/schemas.pyTrendSummary + 7 новых полей
  • api/routes.py_dict_to_trend_summary() helper, дедупликация 3 конструкторов
  • frontend-cascade/app/src/types/trend.tsTrendPhase, TrendPhaseUI, RISKY_HYPE в Recommendation, 7 полей в Trend
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx — 3-dimension bars, composite score, phase + recommendation badges
  • frontend-cascade/app/src/components/trends/trend-card.tsx — composite score + phase badge
  • frontend-cascade/app/src/i18n/en.json, ru.json — 14 новых ключей trend.*
  • tests/unit/test_trend_object_scoring.pyНОВЫЙ: 29 unit tests

Подводные камни:

  • Legacy agg_* колонки продолжают записываться через backward-compat properties на TrendObjectScore
  • Backfill запускается при старте сервера (init_trends_table()_backfill_trend_scores())
  • Тренды без signal_mappings (orphans) получают score=0
  • Frontend: все trend_* поля защищены ?? 0 от undefined
  • Автопереоценка при добавлении сигналов: crawl_once()_run_classify_pipeline()classify_trends()update_trend_scores()

UI: Menu highlighting + accent color fix

Тип: fix Файлы: frontend-cascade/app/src/globals.css, frontend-cascade/app/src/components/layout/header.tsx

Проблема: В dark mode --popover и --accent были одинаковыми (#1F1F23) — hover на пунктах dropdown menu не виден. Logout item терял красный цвет при hover (отсутствовал hover:text-red-400).

Решение:

  • Dark: --accent: #27272A (было #1F1F23) — видимый hover на фоне popover
  • Light: --accent: #E4E4E7 (было #F4F4F5) — более заметный hover на белом фоне
  • Logout item: добавлен hover:text-red-400

2026-02-16

Replace Tavily with SearXNG

Тип: refactor Ветка: refactor/signal-trend-model

Проблема: Tavily — платный облачный поиск (API-ключ, 1-2 кредита/запрос). SearXNG — self-hosted мета-поиск (246+ движков, бесплатно). SearXNG задеплоен на проде (Docker, порт 8890) и локально (8888).

Решение: Полная замена Tavily на SearXNG во всей кодовой базе за 9 фаз:

  1. SearXNG адаптер (src/services/adapters/searxng.py): добавлены fetch_news(), count_citations(), _is_aggregator(), _clean_url()
  2. Config (src/config.py): SearXNGConfig.enabled=True, min_citation_threshold=5, WEB_CITATION_BASELINES (alias для TAVILY_CITATION_BASELINES), TavilyConfig.enabled=False
  3. Scoring (src/services/scoring.py): tavily_citationsweb_citations в CATEGORY_WEIGHTS (все 5 категорий), dual-read для обратной совместимости
  4. Crawler (src/workers/crawler.py): tavilysearxng в fetch_news/enrichment, импорт searxng вместо search/metasearch
  5. API routes (api/routes.py): searxng: и tavily:"web" ключ в sources dict
  6. Frontend: tavilyweb/searxng в types, i18n, компонентах, report page
  7. Удалены: search.py, tavily.py, metasearch.py, tavily-python из requirements.txt
  8. Тесты: test_tavily_citations_e2e.pytest_web_citations_e2e.py, все assertions обновлены
  9. Доп. файлы: source_extractor.py, trend_classifier.py, trend_enrichment.py, schemas.py

Подводные камни:

  • Старые данные в БД имеют tavily_citations в metadata — dual-read (web_citations || tavily_citations) в scoring и enrichment
  • Старые ID tavily:hash в БД — backward compat в routes (tavily:"web" ключ)
  • TAVILY_CITATION_BASELINES оставлен как alias для WEB_CITATION_BASELINES
  • TavilyConfig оставлен с enabled=False для совместимости с .env файлами
  • trend_enrichment.py: tavily_adapter kwarg оставлен как deprecated alias

2026-02-15

Domain Model Refactoring: Signal/Trend (Phases 1-6)

Тип: refactor Ветка: refactor/signal-trend-model

Проблема: Доменная модель смешивала понятия — TrendItem обозначал и отдельный сигнал, и "тренд". TrendClass (объект) — агрегированная сущность, но UI представлял её как вторичную ("Objects").

Решение: Переименование на всех уровнях: TrendItem → Signal, TrendClass → Trend.

Фазы:

  1. DB миграция (db/migrations/005_signal_trend_rename.py): trending_itemssignals, trend_classestrends, trend_objectssignal_mappings, class_aliasestrend_aliases, trend_snapshotssignal_snapshots
  2. Backend скоринг (src/services/trend_scoring.py): TrendAggregateScorer — агрегированный скоринг трендов. Авто-анализ при переходе SIGNAL→TREND
  3. API эндпоинты: /signals, /trends, /analyses (бывшие /trends, /trends/objects, /trends/analyze)
  4. Frontend типы: TrendItemSignal, TrendClassTrend, API client + hooks переименованы
  5. Frontend компоненты: TrendViewSignalView, TrendCardSignalCard, TrendClassCardTrendCard, TrendTableSignalTable
  6. Frontend маршруты: /trend/$trendId/signal/$signalId, /objects/$classId/trend/$trendId, /objects/signals. Redirect-маршруты для обратной совместимости

Подводные камни:

  • Backward compat aliases сохранены (типы, компоненты, API client) — удалять в отдельном PR
  • Redirect-маршруты /objects/signals и /objects/$classId/trend/$trendId для внешних ссылок
  • nav.signals — новый i18n ключ (en: "Signals", ru: "Сигналы")
  • При rename route файлов порядок критичен: сначала trend.$trendIdsignal.$signalId, потом objects.$classIdtrend.$trendId

2026-02-11

Object-as-Trend Pipeline (Phase 0-4)

Тип: feature Файлы:

  • api/analysis_store.py — новые функции: init_trend_objects_table(), init_trend_classes_table(), init_class_aliases_table()
  • api/main.py — регистрация новых таблиц при старте
  • api/routes.py — эндпоинты: GET /trends/objects, GET /trends/objects/{class_id}, GET /trends/signals
  • api/schemas.py — Pydantic-модели: TrendClassSummary, TrendClassDetail, TrendClassListResponse
  • src/services/object_extractor.pyНОВЫЙ: LLM batch extraction объектов из элементов (model=haiku)
  • src/services/trend_classifier.pyНОВЫЙ: группировка по классам, статус TREND/SIGNAL/TRASH
  • src/services/alias_resolver.pyНОВЫЙ: алиасы, suggest_merges (SequenceMatcher), merge_classes
  • src/workers/crawler.py — миграция колонок object_class, object_status; метод _extract_and_classify()
  • frontend-cascade/app/src/types/trend.ts — типы TrendClass, TrendClassDetail, TrendClassListResponse
  • frontend-cascade/app/src/lib/api-client.ts — методы getTrendObjects(), getTrendObject(), getSignals()
  • frontend-cascade/app/src/hooks/use-trend-objects.tsНОВЫЙ: react-query хуки
  • frontend-cascade/app/src/components/trends/trend-class-card.tsxНОВЫЙ: карточка класса
  • frontend-cascade/app/src/routes/_dashboard/radar.tsx — табы: All Trends / Grouped Objects / Weak Signals
  • frontend-cascade/app/src/i18n/en.json, ru.json — i18n ключи для Object-as-Trend

Проблема: Тренды обрабатывались изолированно; элементы об одном и том же концепте не группировались.

Решение: LLM-based Object Extraction → Group Classification → API + Frontend. Элементы группируются по базовому объекту (class), статус определяется кол-вом элементов и кросс-источниками.

Подводные камни:

  • element_id в trend_objects имеет UNIQUE constraint; INSERT с дубликатом → UPDATE fallback
  • Эндпоинты /objects, /signals определены ДО /{job_id} в routes.py (порядок критичен)
  • _extract_and_classify() запускается как background task (asyncio.create_task) после crawl_once()

[P1-4] Синхронизация scoring weights и рефакторинг

Тип: refactor Файлы:

  • src/config.py — перенесён CATEGORY_WEIGHTS, добавлена валидация _validate_scoring_config()
  • src/services/scoring.py — удалён дублирующий CATEGORY_WEIGHTS, извлечены _extract_engagement() и _clamp_score()

Проблема:

  1. CATEGORY_WEIGHTS дублировались в scoring.py и не синхронизировались с TAVILY_CITATION_BASELINES из config.py
  2. Extraction engagement метрик дублировался в 3 местах (calculate_weighted_engagement, calculate_velocity_from_snapshots, calculate_quality)
  3. Нет валидации score ranges и консистентности категорий

Решение:

  1. Перенесён CATEGORY_WEIGHTS в src/config.py (single source of truth рядом с TAVILY_CITATION_BASELINES)
  2. Добавлена валидация _validate_scoring_config() на уровне модуля:
    • Проверка идентичности ключей категорий (CATEGORY_WEIGHTS ↔️ TAVILY_CITATION_BASELINES)
    • Проверка суммы весов (должна быть 1.0 ± 0.01)
  3. Извлечена общая функция _extract_engagement(item: TrendItem) -> dict[str, float]:
    • Возвращает {"saves", "shares", "comments", "likes", "total_weighted"}
    • Использует ENGAGEMENT_WEIGHTS для взвешенного подсчёта
    • Заменяет 3 дублирующих блока кода
  4. Добавлена функция _clamp_score(value, min_val=0.0, max_val=1.0) для нормализации финальных scores
  5. Обновлены методы:
    • calculate_weighted_engagement() — теперь вызывает _extract_engagement()
    • calculate_velocity_from_snapshots() — inner function get_engagement() использует _extract_engagement()
    • calculate_quality() — использует _extract_engagement() вместо inline extraction
    • calculate_urgency(), calculate_time_decay() — используют _clamp_score() вместо inline max(min(...))

Тесты:

  • 35/35 tests passed в tests/e2e/test_scoring_e2e.py
  • Валидация config проходит при импорте модуля

Подводные камни:

  • _extract_engagement() возвращает dict, не одно число — вызывающий код должен извлечь нужный ключ
  • Категории в CATEGORY_WEIGHTS и TAVILY_CITATION_BASELINES теперь валидируются автоматически при импорте — любая рассинхронизация вызовет ValueError на старте

Аудит технического долга и план рефакторинга

Тип: architecture / tech debt Файлы:

  • docs/TECH-DEBT-REFACTORING-PLAN.md — НОВЫЙ: план рефакторинга (22 задачи)

Проблема: Накопленный технический долг по всей системе: 56 issues (backend, pipeline, frontend, инфраструктура).

Решение:

  1. Проведён полный аудит 4 командами: backend API, pipeline (crawler/agents/services), frontend (React), инфраструктура (deps/tests/security)
  2. Составлен план из 22 задач с приоритизацией P0/P1/P2, графом зависимостей и понедельным расписанием

Ключевые находки:

  • P0 (8 задач): Race conditions в routes.py, hardcoded DB paths в 11+ местах, CORS allow_methods=["*"], circuit breaker race, reserve_analysis() rollback bug, 17 JSON-дампов в корне
  • P1 (11 задач): routes.py монолит (200+ строк _run_analysis), crawler.py монолит (932 строки), nodes.py без timeout для LLM, scoring CATEGORY_WEIGHTS не синхронизированы, нет миграций SQLite
  • P2 (5 задач): Нет Dockerfile/CI, 5 падающих тестов, нет frontend тестов, нет rate limiting

Подводные камни:

  • Критический путь: P0-1 (DB layer) → P0-3 → P1-1 → P1-7 → P2-3
  • P0-1 (единый DB-слой) — фундамент для 5 других задач

Object-as-Trend: новый пайплайн наименования трендов

Тип: research / architecture decision Файлы:

  • docs/scoring-evaluation-report.md — НОВЫЙ: оценка scoring-системы (8 проблем, 3.2/10)
  • docs/naming-algorithm-evaluation.md — НОВЫЙ: оценка алгоритма наименования v1 (75% pass)
  • docs/naming-algorithm-v2-evaluation.md — НОВЫЙ: оценка v2 с Step 0 фильтром (30% pass, строже)
  • docs/object-based-trend-grouping.md — НОВЫЙ: проверка object-as-trend гипотезы (подтверждена)

Проблема:

  1. Scoring-система работает только для HN tech-трендов (3.2/10): Tavily score passthrough, arXiv пустые метаданные, категория "society"/"social" mismatch
  2. Алгоритм наименования v1 генерировал красивые названия для не-трендов (25% элементов — не тренды)
  3. v2 с поштучным Step 0 фильтром отбрасывал 55% элементов, включая валидные части трендов (продуктовые релизы Claude/GPT/Voxtral — каждый по отдельности не тренд, но вместе = "специализация frontier LLM")

Решение (Object-as-Trend подход):

  1. Object extraction — для каждого элемента извлекаются конкретные сущности (продукты, технологии, компании)
  2. Group by class — объекты группируются в классы (LLM-роутинг, code review, frontier LLM)
  3. Class-level is_trend — тренд определяется на уровне класса (count >= 2), а не отдельного элемента
  4. TRASH фильтр — элементы без извлекаемых объектов (агрегаторы, opinion pieces) отбрасываются

Результаты на 50 элементах:

  • v2 поштучно: 55% мусор, 5-6 трендов
  • Object-as-Trend: 30% мусор, 7 трендов из 28 элементов, 60% recovery отброшенных
  • 3 кросс-источниковых тренда (GitHub + arXiv, GitHub + HN)

Правильный пайплайн:

Element → Object extraction → Group by class → class.count >= 2 → IS_TREND
                                              → class.count == 1 → WEAK_SIGNAL
          no object found  → TRASH (discard)

Подводные камни:

  • Object extraction требует LLM (batch, async) — стоимость ~$0.01/элемент
  • Alias table нужна для нормализации ("vouch" HN = "vouch" GitHub)
  • SBERT embeddings для fuzzy class matching — Phase 2
  • Step 0 должен работать на уровне КЛАССА, не элемента

Следующие шаги:

  • Имплементация object extraction pipeline
  • Alias table для entity normalization
  • Group-level trend detection
  • Dual-layer naming для подтверждённых трендов

Title Quality Evaluation System + Admin System Page

Тип: feature Файлы:

  • src/services/title_evaluator.py — НОВЫЙ: evaluation engine (fixtures → generate → LLM judge → save → compare)
  • api/eval_routes.py — НОВЫЙ: 4 API endpoints /admin/eval-titles/* (start, status, history, compare)
  • api/main.py — регистрация eval_router
  • research/title-eval/fixtures.json — НОВЫЙ: 15 курированных тестовых примеров
  • scripts/eval_titles.py — НОВЫЙ: CLI runner с --limit, --compare, --save-baseline
  • tests/e2e/test_title_eval.py — НОВЫЙ: 12 offline + 3 E2E тестов
  • frontend-cascade/app/src/routes/admin/system.tsx — НОВЫЙ: страница /admin/system
  • frontend-cascade/app/src/components/layout/admin-sidebar.tsx — добавлен nav item "System"
  • frontend-cascade/app/src/lib/api-client.ts — 4 новых метода (startTitleEval, getEvalStatus, getEvalHistory, compareEvalRuns)
  • frontend-cascade/app/src/i18n/en.json — 18 новых ключей admin.*
  • frontend-cascade/app/src/i18n/ru.json — русские переводы
  • docs/TRENDS-AND-TITLES.md — НОВЫЙ: документация по трендам и заголовкам
  • docs/EVAL-SYSTEM.md — НОВЫЙ: документация eval-системы
  • docs/INDEX.md — обновлён до v11.0

Проблема:

  1. Нет способа измерить качество LLM-генерации заголовков
  2. При изменении промптов невозможно обнаружить регрессию
  3. Нет административного интерфейса для системных операций

Решение:

  1. Eval Pipeline: 15 fixtures → генерация → LLM-as-Judge (4 критерия × 3 варианта) → JSON результат
  2. Regression Detection: сравнение с baseline, порог -0.2 для критической регрессии
  3. Admin UI (/admin/system): запуск eval, прогресс-бар, карточки метрик, история прогонов
  4. CLI: scripts/eval_titles.py для CI и ручного запуска

Подводные камни:

  • Eval jobs хранятся в памяти (_eval_jobs) — не переживают перезапуск сервера
  • Judge может давать нестабильные оценки — рекомендуется запускать несколько прогонов
  • call_asyncquery баг исправлен в title_generator.py (метод не существовал в ClaudeCLIClient)

Fix: call_async → query in title_generator.py

Тип: fix Файлы: src/services/title_generator.py Проблема: Вызов cli.call_async(prompt) на строках 93, 128 — метод не существует в ClaudeCLIClient. Правильный метод: cli.query(prompt). Решение: Заменено call_asyncquery в обоих местах.

LLM Title Generation + Cross-Source Trend Linking

Тип: feature Файлы:

  • src/workers/crawler.py — normalize_title(), find_similar_trend(), add_source(), update_trend_from_source(), trend_sources таблица, миграция title variants колонок, интеграция _generate_missing_titles()
  • src/services/title_generator.py — НОВЫЙ: LLM-генерация 3 вариантов заголовков (technical, accessible, benefit) через Claude CLI
  • api/routes.py — _get_trend_sources_from_db(), обновлён _build_sources_dict() (trend_sources → fallback), _crawler_item_to_schema() с title variants
  • api/schemas.py — TrendSource модель, расширен TrendItem (source_list, title_technical, title_accessible, title_benefit)
  • frontend-cascade/app/src/types/trend.ts — TrendSource интерфейс, расширен TrendItem
  • frontend-cascade/app/src/components/trends/trend-view.tsx — displayTitle(), technicalTitle(), все 3 варианта (sm/lg/row) показывают accessible title с tooltip для technical

Проблема:

  1. Заголовки трендов идут raw от адаптеров без обработки ("Show HN: My Cool Project " → с мусором)
  2. Один тренд из разных источников (HN, GitHub, Tavily) создаёт дубликаты — нет связывания
  3. Нет хранилища для нескольких источников одного тренда (sources реконструировались на лету из item.id)
  4. Экспертная панель (6/6) назвала формулировки трендов главным UX-барьером

Решение:

  1. Title Normalization (normalize_title()): HTML unescape, Unicode NFKC, удаление HN-префиксов (Show HN/Ask HN/...), коллапс пробелов
  2. Cross-Source Dedup (find_similar_trend()): fuzzy match по заголовку через difflib.SequenceMatcher (threshold 0.75). Точное совпадение → быстрый путь, иначе сравнение со всеми трендами
  3. trend_sources таблица: хранит множественные источники для одного тренда с UNIQUE(trend_id, source_item_id). Миграция из существующих данных
  4. Dual-Layer Naming: 3 колонки в trending_items (title_technical, title_accessible, title_benefit). LLM генерирует batch по 10 трендов через ClaudeCLIClient
  5. Skip-логика: WHERE title_accessible IS NULL — повторная генерация не запускается
  6. Frontend: title_accessible ?? title по умолчанию, tooltip с title_technical при различии, badge с количеством источников в lg-варианте

Подводные камни:

  • Fuzzy match загружает все тренды в память — при >1000 трендов рассмотреть индексирование или кэш
  • LLM title generation — async, вызывается после crawl цикла; при недоступности LLM — fallback на raw title
  • _row_to_item() — title variants передаются через metadata dict (ключи _title_technical, _title_accessible, _title_benefit) — индексы 20-22
  • _build_sources_dict() — сначала пытается trend_sources таблицу, fallback на legacy ID-based реконструкцию
  • Порог fuzzy match (0.75) может потребовать тюнинга на реальных данных

Следующие шаги:

  • Тюнинг порога fuzzy match на реальных данных
  • Оптимизация для >1000 трендов (n-gram индекс или embedding similarity)
  • i18n для title variants (генерация на языке пользователя)

Zones TOC View component (Issue #7, Phase 3)

Тип: feature Файлы:

  • frontend-cascade/app/src/components/analysis/zones-toc-view.tsx — НОВЫЙ компонент
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — замена ImpactZoneCard grid → ZonesTOCView
  • frontend-cascade/app/src/i18n/en.json — добавлены ключи: zones.tableOfContents, zones.noZones
  • frontend-cascade/app/src/i18n/ru.json — добавлены русские переводы

Проблема:

  1. Zones таб показывал карточки в сетке — мелкий текст, плохая читаемость
  2. Нет навигации по зонам при большом количестве (>10)
  3. Нет иерархии: зоны → подзоны → эффекты
  4. На мобильных экранах карточки слишком компактные

Решение:

  1. TOC Layout:
    • Desktop: sticky sidebar (240px) + main content (flex-1)
    • Mobile: collapsible dropdown TOC вверху страницы
    • Auto-scroll to active zone with IntersectionObserver
    • Smooth scroll behavior при клике на TOC link
  2. Типография (крупнее для читаемости):
    • Zone header: text-2xl font-bold (24px)
    • Description: text-base leading-relaxed (16px, lh 1.75)
    • Sub-zones: text-sm (14px) с чекбоксом-иконкой
    • Evidence: text-sm leading-relaxed с Quote icon
    • Cascade effects: collapsible с GitBranch icon
  3. Иерархия:
    • Zone header (название, тип, временной горизонт, impact/confidence)
    • Description (параграф)
    • Mechanism (отдельная секция)
    • Sub-zones (сетка 1-2 колонки)
    • Affected Groups (теги)
    • Evidence (список с Quote icons)
    • Cascade Effects (collapsible tree, рекурсивный CascadeTreeItem)
  4. Адаптивность:
    • Desktop: TOC слева + контент справа
    • Mobile: TOC dropdown + контент на всю ширину
    • Карточки зон с border-radius, padding, shadows

Подводные камни:

  • IntersectionObserver может неправильно работать при быстром скролле — использован rootMargin для компенсации
  • TOC sticky может конфликтовать с fixed header — добавлен top-4 offset
  • На очень длинных списках зон (>20) TOC может быть слишком длинным — рассмотреть группировку по типам
  • Cascade tree может быть глубоким (depth >5) — авто-раскрытие только первых 2 уровней

Следующие шаги:

  • Тестирование с реальными отчётами (10+ зон)
  • Возможна группировка зон по типам в TOC (Positive/Negative/Mixed секции)

SummaryView and TrendToZonesFlow components (Issue #5, Phase 3)

Тип: feature Файлы:

  • frontend-cascade/app/src/components/analysis/summary-view.tsx — НОВЫЙ компонент
  • frontend-cascade/app/src/components/analysis/trend-to-zones-flow.tsx — НОВЫЙ компонент
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — интеграция SummaryView
  • frontend-cascade/app/src/i18n/en.json — добавлены ключи: report.trend, report.impactOn, report.trendOverview, report.impactMapping, report.detailedAnalysis, zones.positive, zones.negative, zones.mixed, zones.impact, zones.impactTooltip, zones.confidence, zones.confidenceTooltip, zones.mechanism, zones.cascadeEffects, zones.maxDepth
  • frontend-cascade/app/src/i18n/ru.json — добавлены русские переводы

Проблема:

  1. Summary таб показывал только плоский markdown без структуры
  2. Нет визуальной связи между трендом и зонами влияния
  3. Отсутствует явный обзор тренда перед детальным анализом
  4. Пользователю сложно понять, как тренд влияет на различные области

Решение:

  1. SummaryView компонент — трёхсекционная структура:
    • Trend Overview: Карточка с описанием тренда, категорией, статусом
    • Impact Mapping: Визуализация TrendToZonesFlow — как тренд влияет на зоны
    • Detailed Analysis: Markdown отчёт с источниками (прежний ReportViewer)
  2. TrendToZonesFlow компонент:
    • Центральная карточка тренда с рамкой brand-primary
    • Стрелки вниз с подписью "Impact on"
    • Группировка зон по типу (Positive/Negative/Mixed) с цветовой кодировкой
    • Карточки зон с:
      • Иконкой типа (TrendingUp/Down/Minus)
      • Impact и Confidence с тултипами (шкала 0-1)
      • Механизмом влияния
      • Cascade preview (total_effects, max_depth) если есть
    • Адаптивная сетка: 1 колонка на mobile, 2 на desktop
  3. Интеграция:
    • Заменён старый простой markdown в summary tab на SummaryView
    • Поддержка streaming через isStreaming prop
    • Сохранена анимация StreamingText для первого просмотра
  4. i18n: Добавлены все необходимые переводы (en/ru)

Подводные камни:

  • Карточки зон используют цветовую кодировку border+background, нужно протестировать в dark mode
  • Tooltips для Impact/Confidence могут перекрываться на узких экранах (но работают корректно благодаря Radix UI)
  • Если у тренда нет description, секция Trend Overview не отображается — это OK для standalone анализов

Следующие шаги:

  • Тестирование в реальных отчётах с различными типами зон
  • Можно добавить collapsible секции для длинных списков зон (>10)

React Flow cascade graph (Issue #6, Phase 1)

Тип: feature Файлы:

  • frontend-cascade/app/package.json — добавлен reactflow@11.x
  • frontend-cascade/app/src/components/analysis/cascade-graph.tsx — НОВЫЙ компонент
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — замена InteractiveGraph → CascadeGraph

Проблема:

  1. Старый граф (react-force-graph-2d) перемещался под мышкой
  2. Нет весов на рёбрах
  3. Нет направленности (стрелок)
  4. Сложная навигация

Решение:

  1. React Flow интеграция:
    • Установлен пакет reactflow (37 deps, 0 vulnerabilities)
    • Создан компонент CascadeGraph с TypeScript типизацией
  2. Функционал:
    • Направленные рёбра (MarkerType.ArrowClosed)
    • Веса на рёбрах (% strength labels)
    • Анимация для сильных связей (strength > 0.7)
    • Цветовая кодировка узлов (positive=green, negative=red, mixed=amber)
    • Impact display на каждом узле
    • MiniMap для навигации
    • Controls (zoom, fit view)
    • Background grid
  3. Layout: Simple grid layout (5 columns), можно улучшить через dagre
  4. Интеграция: Заменён InteractiveGraph в cascade tab

Подводные камни:

  • Layout пока простой (grid) — для иерархического нужен dagre/elkjs
  • Drill-down и breadcrumb navigation не реализованы (TODO)
  • Node details sidebar не добавлен (TODO)
  • Mobile UX: graph скрыт на малых экранах (fallback: ImpactPathList)

Следующие шаги:

  • Добавить hierarchical layout (dagre)
  • Реализовать drill-down navigation
  • Добавить node details panel
  • Реализовать level navigation (up/down)

Zone matching integration + Locale parameter + Zones storage (Issues #5-7, High Priority)

Тип: feature Файлы:

  • src/agents/nodes.py — _canonicalize_zones(), ZoneMatcher integration
  • api/routes.py — locale parameter in AnalyzeRequest, _run_analysis()
  • api/analysis_store.py — locale column migration, init_zones_storage_tables()
  • api/main.py — table initialization

Проблема:

  1. Зоны не канонизировались — каждый анализ создавал свои названия
  2. Нет поддержки локали — все отчёты генерировались на EN
  3. Зоны и каскадные эффекты не сохранялись в БД для обратного поиска

Решение:

  1. Zone canonicalization (Issue #5, Phase 2):
    • _canonicalize_zones() в impact_researcher_agent
    • Автоматический маппинг raw zone names → canonical names через ZoneMatcher
    • Сохранение original_name для reference
  2. Locale parameter (Issue #6-7):
    • Добавлено поле locale в AnalyzeRequest (default="en")
    • Передача locale через state в LangGraph pipeline
    • report_generator_agent добавляет language instruction в промпт
    • Колонка locale в таблице analyses (migration)
  3. Zones storage tables (Issue #7):
    • Таблица impact_zones: job_id, zone_name, impact, confidence, mechanism, evidence, etc.
    • Таблица cascade_effects: zone_id, effect_name, level, strength, causal_chain, is_feedback
    • Indexes для быстрого поиска по job_id, zone_name, effect_name
    • Foreign keys с CASCADE DELETE для data integrity

Подводные камни:

  • Canonicalization требует embeddings (пока TF-IDF placeholder)
  • Locale instruction в промпте не гарантирует 100% соблюдения языка LLM
  • Таблицы zones/effects созданы, но сохранение данных нужно добавить в complete_analysis()
  • Migration: locale column добавляется к существующим БД с default='en'

Impact zones dictionary (Issue #5, Phase 1)

Тип: feature Файлы:

  • api/analysis_store.py — init_zones_dictionary_table()
  • src/services/zone_matcher.py — НОВЫЙ модуль для semantic matching
  • api/routes.py — API endpoints (/zones/dictionary, /zones/search)
  • api/main.py — инициализация таблицы при старте

Проблема:

  1. Зоны влияния переписывались при каждом анализе (нет канонических названий)
  2. Похожие зоны (synonyms) создавались как отдельные записи
  3. Нет справочника для consistency across analyses
  4. Невозможен поиск/фильтрация по зонам

Решение:

  1. Таблица БД: impact_zones_dictionary с полями:
    • canonical_name (уникальное каноническое название)
    • synonyms (JSON array синонимов)
    • embedding (BLOB для vector matching)
    • category, description, usage_count
  2. ZoneMatcher class:
    • match_or_create_zone() — главный метод (поиск или создание)
    • _embed_zone_name() — генерация embeddings (пока TF-IDF style)
    • _find_similar_zones() — cosine similarity с threshold=0.85
    • _increment_usage() — счётчик использований + синонимы
  3. API endpoints:
    • GET /zones/dictionary — список зон
    • GET /zones/dictionary/{id} — детали зоны
    • GET /zones/search?q=...&category=... — поиск
  4. Indexes: canonical_name, category для fast lookups

Подводные камни:

  • Embeddings пока placeholder (TF-IDF) — требуется ML model для production
  • Threshold 0.85 может требовать калибровки
  • Race condition при создании зоны (обрабатывается через IntegrityError)
  • Synonyms ограничены последними 10 (для экономии места)

Source ranking algorithm (Issue #8, Phase 5)

Тип: feature Файлы:

  • src/services/source_extractor.py — добавлен класс SourceRanker
  • api/routes.py — интеграция ранжирования
  • src/agents/nodes.py — передача metadata в collected_items

Проблема:

  1. Источники не ранжировались по релевантности/авторитетности
  2. Все источники показывались в порядке сбора (не оптимально)
  3. Нет приоритета для более авторитетных источников (GitHub, HN vs Tavily)
  4. Нет учёта упоминаний источника в тексте отчёта

Решение:

  1. SourceRanker.rank_sources(): Scoring formula с 4 критериями:
    • Source authority (30%): github=1.0, hn=0.9, arxiv=0.8, tavily=0.7
    • Citation count (30%): sigmoid normalization с baseline=5
    • Recency (20%): decay formula (1.0→0.0 за 60 дней)
    • Mentioned in report (20%): bonus если URL/title в тексте
  2. Интеграция: После validation + deduplication в _run_analysis()
  3. Metadata propagation: data_collector_agent передаёт metadata (citations)
  4. Re-indexing: После ранжирования index пересчитывается (топ источник = index 1)

Подводные камни:

  • Citation count доступен только для Tavily источников (для других = 0)
  • Sigmoid baseline=5 оптимален для текущих порогов, может требовать калибровки
  • Mentioned in report работает простым substring match (не NLP)
  • Ranking НЕ сохраняется в БД (пересчитывается каждый раз)

Source validation and deduplication (Issue #8, Phase 4)

Тип: feature Файлы:

  • src/services/source_extractor.py — НОВЫЙ модуль для валидации и дедупликации
  • api/routes.py — интеграция в _run_analysis()

Проблема:

  1. Некорректные URL (с localhost, опасными расширениями) могли попасть в sources
  2. Дубликаты URL (с разными utm params, trailing slash) не фильтровались
  3. Один URL мог быть представлен несколькими вариантами

Решение:

  1. SourceExtractor.validate_url():
    • Проверка схемы (только http/https)
    • Блокировка localhost/internal IPs (127.0.0.1, 192.168., 10., etc.)
    • Блокировка опасных расширений (.exe, .msi, .bat, .zip, etc.)
  2. SourceExtractor.deduplicate_sources():
    • Нормализация URL: lowercase domain, strip trailing slash
    • Удаление utm_* query params
    • Удаление fragment (#)
    • Сохранение первого вхождения
  3. Интеграция: После сборки sources в _run_analysis():
    • Валидация → Дедупликация → Re-indexing

Подводные камни:

  • Валидация НЕ делает HTTP requests (не проверяет доступность)
  • Нормализация может ошибочно объединить разные страницы (редкий случай)
  • После дедупликации index пересчитывается (1, 2, 3, ...)
  • Логирование: DEBUG уровень для каждого отклонённого URL

Flexible citation thresholds and increased source limits (Issue #8, Phase 1-2)

Тип: feature Файлы:

  • src/config.py — добавлены category_thresholds в TavilyConfig
  • src/services/adapters/search.py — category parameter + auto-detection
  • src/agents/nodes.py — увеличен лимит источников

Проблема:

  1. Citation threshold был одинаковым (5) для всех категорий трендов
  2. Источники ограничивались первыми 10 items из collected_items
  3. Science тренды требуют более строгой валидации, social — менее строгой

Решение:

  1. Гибкие пороги: Добавлены category-specific thresholds:
    • science: 8 (академические тренды)
    • technology: 5 (средний)
    • business: 4 (ниже)
    • economy: 6 (средне-высокий)
    • society: 3 (вирусный контент)
  2. Auto-detection: Метод _detect_category_from_query() определяет категорию по ключевым словам
  3. Больше источников:
    • collect(limit=50) вместо 20
    • Первые 10 для LLM context (не перегружать промпт)
    • До 30 для collected_items (для UI source sidebar)

Подводные камни:

  • Auto-detection работает на основе простых keyword rules (не ML)
  • Если категория не определена — используется default_threshold=5
  • При изменении порогов — перекалибровать через анализ статистики
  • Performance: 50 items увеличивает время сбора, но результат кэшируется

Direct navigation to latest report (Issue #1)

Тип: UX improvement Файлы:

  • frontend-cascade/app/src/components/analysis/report-group-card.tsx
  • frontend-cascade/app/src/i18n/en.json, ru.json

Проблема: При клике на отчёт в /reports пользователь попадал на промежуточную страницу выбора версии (раскрытие списка в ReportGroupCard). Это добавляло лишний шаг к основному сценарию — открыть последний отчёт.

Решение:

  1. Заменили клик по всей карточке на Link к /analyze/${latest_job_id}/report
  2. Добавили отдельную кнопку (иконка History) для раскрытия истории версий
  3. Кнопка использует stopPropagation() чтобы не тригерить навигацию
  4. Добавлены i18n ключи: actions.showVersions, actions.hideVersions

Подводные камни:

  • Standalone анализы (без trend_id) уже работали правильно
  • При клике на кнопку History — важно остановить event propagation
  • Иконка меняется: History (collapsed) → ChevronDown с rotate-180 (expanded)

2026-02-10

Исправление расчета score в _extract_structured_data

Тип: bug fix Файлы:

  • api/routes.py (строка 174)

Проблема:

  • Код искал поле z.get("strength", 0) в impact zones, но в реальных JSON данных поле называется "impact", а не "strength"
  • Это не влияло на существующие анализы, т.к. score берется из базы напрямую (строка 886 в get_report)
  • НО если бы потребовался пересчет score, код возвращал бы 0.0 для всех зон

Решение:

  • Заменили z.get("strength", 0) на z.get("impact", 0)
  • Убрали умножение на 10, т.к. impact уже в шкале 0-10 (не 0-1)
  • Обновили комментарии для ясности

Подводные камни:

  • Impact zones в базе хранят "impact" (0-10), а не "strength" (0-1)
  • Score в базе уже в правильном формате 0-10, никакого пересчета не требуется
  • Frontend formatScore() корректно работает с 0-10 scale: .toFixed(1)

Проверка:

  • Анализ c91332df (hn:46934344): impacts [8.0, 7.0, 6.0, 7.0, 6.0, 8.0] → score 7.0
  • Анализ 7485d43e (hn:46922969): impacts [9.0, 8.0, 9.0, 7.0, 8.0, 6.0, 7.0, 7.0] → score 7.62

Унификация страниц тренда и анализа

Тип: feature + fix Файлы:

  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx
  • api/routes.py

Проблема:

  1. Кнопки Save/Share/Export исчезали за правой границей на узких экранах (страница тренда)
  2. График Sparkline мешал восприятию на странице тренда
  3. Оценка анализа показывала 1 вместо ожидаемых 7-8
  4. Confidence display был отдельным блоком вместо части блока оценки
  5. Дублирование кнопок действий на странице анализа (ReportHeader + ReportActions)
  6. Title и description тренда не переводились на локаль интерфейса

Решение:

  • Frontend (trend page): изменен layout кнопок на flex-col (mobile) → sm:flex-row (desktop), убран shrink-0, Sparkline перенесен в ScorePanel
  • Frontend (analysis page): унифицированы стили Analysis Score (gap-1, text-2xl sm:text-3xl), Confidence внутри блока, удален ReportActions
  • Backend: score теперь 0-10 (было 0-1): score = (sum(strengths) / len(strengths) * 10)
  • Backend: используется новая архитектура переводов get_cached_translations_batch() для trend_title/description

Подводные камни:

  • Score был нормализован к 0-1 в pipeline (nodes.py), поэтому нужно умножать на 10 при вычислении итогового score
  • Переводы берутся из кэша — если перевода нет, fallback на исходный язык (EN)
  • ReportActions использовался в двух местах (desktop toolbar + mobile FAB) — оба удалены

Многоязычный поиск трендов

Тип: feature Файлы:

  • api/routes.py — функция list_trends() теперь ищет как в оригинальном (EN), так и в переведенном тексте (RU)
  • tests/test_multilingual_search.py — unit-тесты для многоязычного поиска

Проблема: Поиск работал только по оригинальному тексту (английский). Если пользователь с русской локалью искал "нейронные сети", система не находила тренд "Neural Networks", хотя перевод уже был закэширован в БД.

Решение:

  1. Перемещен batch-перевод items ПЕРЕД поиском (вместо ПОСЛЕ пагинации)
  2. Создан кэш переводов {item.id → translated_dict} для переиспользования
  3. Поиск проверяет ОБЕИХ версии:
    • Оригинал (EN): title, description
    • Перевод (RU): translated.title, translated.description
  4. Если query найден в любой версии — item попадает в результаты
  5. Финальный ответ использует уже готовый кэш переводов (без повторного вызова)

Производительность:

  • Batch перевод 500 items: ~3ms (cache-only, SQLite lookup)
  • Поиск по 500 items: ~2ms (in-memory)
  • Total overhead: ~5ms (vs текущий подход ~3ms)

Подводные камни:

  • Cache hit rate для titles: ~75%, для descriptions: ~5% (translation watchdog заполняет каждые 5 минут)
  • Если перевода нет в кэше — graceful fallback к поиску по оригинальному тексту
  • EN mode (lang=en) игнорирует переводы (поиск только по оригиналу)

Debounce для поиска (500 мс задержка)

Тип: feature Файлы:

  • frontend-cascade/app/src/hooks/use-debounce.ts — новый переиспользуемый хук для debounce
  • frontend-cascade/app/src/routes/_dashboard/radar.tsx — добавлен debounce для search input (500ms)
  • frontend-cascade/app/src/components/layout/command-palette.tsx — добавлен debounce для search input (500ms)

Проблема: При вводе символов в поиск отправлялось слишком много запросов к API, UI постоянно перестраивался и "прыгал". Пользователю сложно было печатать, так как результаты обновлялись на каждый символ.

Решение:

  1. Создан универсальный хук useDebounce<T>(value, delay) с default delay 1000ms
  2. В Radar page: const debouncedSearch = useDebounce(searchInput, 500) + useEffect для синхронизации в filters
  3. В Command Palette: const debouncedSearch = useDebounce(searchValue, 500) + передача в react-query
  4. API запрос отправляется только через 500ms после того, как пользователь перестал печатать

Поведение:

  • Пользователь печатает → поисковый input обновляется мгновенно (без задержки)
  • API запрос отправляется через 500ms после последнего изменения
  • Если пользователь продолжает печатать — таймер сбрасывается, запрос не отправляется
  • UI перестраивается только когда приходят новые данные (через 500ms+)

Подводные камни:

  • Input не лагает, реактивен мгновенно (debounce только для API, не для UI input)
  • enabled: commandPaletteOpen && debouncedSearch.length >= 2 — API не вызывается для queries < 2 символов
  • Radar page очищает search при закрытии Command Palette через useEffect cleanup
  • Задержка подобрана экспериментально: 500ms — баланс между отзывчивостью и снижением нагрузки
  • Переменная target_lang теперь объявлена в начале функции (до search блока), не дублировать перед финальным ответом

Тип: feature Файлы:

  • api/analysis_store.pyget_trend_metadata() и get_trend_metadata_by_id() теперь SELECT-ят content из trending_items и возвращают trend_description
  • api/schemas.py — добавлено поле trend_description: str = "" в AnalysisReportResponse
  • api/routes.pyget_report() передает trend_description из trend_meta в response
  • frontend-cascade/app/src/types/analysis.ts — добавлено поле trend_description?: string в AnalysisReport
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsxReportHeader: описание тренда, tooltip с разбивкой оценки на score, fallback для source_url
  • frontend-cascade/app/src/i18n/en.json / ru.json — ключи scoring.scoreBreakdown, scoring.scoreExplanation, scoring.trendScoreBreakdown, scoring.basedOn, scoring.sourcesDiversity, scoring.crossDomainImpact

Проблема:

  1. Описание тренда (content из trending_items) не отображалось на странице отчета
  2. Оценка (score) показывалась как число без пояснения, из чего она складывается
  3. При отсутствии trend_sources но наличии source_url не было fallback-ссылки на источник

Решение:

  1. Backend: добавлен SELECT content в обоих get_trend_metadata*(), передается как trend_description (обрезан до 500 символов)
  2. Frontend: score обернут в Tooltip с разбивкой (trend_score, urgency, quality, confidence)
  3. Frontend: trend_score показывается с tooltip (velocity, source diversity, cross-domain impact)
  4. Frontend: fallback-ссылка <a href={source_url}> когда trend_sources пуст

Подводные камни:

  • content в trending_items — это не всегда чистое описание; для HN это может быть текст поста, для GitHub — README excerpt
  • trend_score в API приходит 0-1, analysis score — 0-10, нормализация в tooltip: trend_score * 10

Comprehensive Scrollbar Fix — Single Scroll Container

Тип: fix Файлы:

  • frontend-cascade/app/src/globals.csshtml,body,#root { height:100%; overflow:hidden } + .dashboard-main-scroll class
  • frontend-cascade/app/src/routes/__root.tsxh-full вместо min-h-screen
  • frontend-cascade/app/src/routes/_dashboard.tsxdashboard-main-scroll на <main>
  • frontend-cascade/app/src/routes/admin.tsx — тот же паттерн
  • frontend-cascade/app/src/routes/login.tsxh-full вместо min-h-screen

Проблема: Двойной скроллбар и дёрганье контента (layout shift) при загрузке карточек на радаре. Предыдущие фиксы (scrollbarGutter:stable на main, h-screen overflow-hidden на wrapper) не помогли — скролл появлялся на html/body.

Решение:

  1. Lock html, body, #root { height: 100%; overflow: hidden } — страница никогда не скроллится
  2. <main> — единственный scroll-контейнер с overflow-y: scroll (всегда видимый трек)
  3. scrollbar-gutter: stable + thin custom scrollbar (6px) через ::-webkit-scrollbar
  4. Все layout-обёртки: h-full вместо min-h-screen

Подводные камни:

  • Нужно h-full (не min-h-screen) на всех обёртках, иначе контент выходит за viewport
  • overflow-y: scroll (не auto) — скроллбар всегда виден, не toggle-ится

Filter Count Desynchronization Fix

Тип: fix Файлы:

  • api/routes.py — reorder filter pipeline: search → category_counts(search+status) → stats(search+category) → items(all filters)
  • frontend-cascade/app/src/hooks/use-trends.ts — statsQuery теперь передаёт status

Проблема: При поиске "u.s." stat cards показывали Total=2 (correct), но category pills показывали All=500 (глобальные, без учёта поиска). category_counts считались ДО любых фильтров.

Решение: Каждое измерение фильтра считается с ВСЕМИ активными фильтрами КРОМЕ своего:

  • category_counts = search + status (без category)
  • stats = search + category (без status) Frontend statsQuery теперь передаёт все фильтры, backend сам исключает нужное.

Подводные камни:

  • apiClient.getTrends автоматически strip-ает status='all' и category='all' → они не отправляются на сервер
  • Backend получает status=None и category=None для "all" → не фильтрует

2026-02-09

Radar: Split Query Architecture for Filter Cards

Тип: fix / refactor Файлы:

  • frontend-cascade/app/src/hooks/use-trends.ts — два раздельных react-query: statsQuery (category/search) и trendsQuery (все фильтры)
  • api/routes.py — reorder: category/search → stats → status filter
  • frontend-cascade/app/src/routes/_dashboard/radar.tsx — AnimatedCounter с prev-value ref, AnimatePresence popLayout для grid

Проблема: При выборе статуса (Exploding/Rising/Emerging) на плашках радара — числа в карточках пересчитывались под текущий фильтр. Пример: "Exploding → Business" показывало 3 тренда в списке, но карточка "Total" = 134 (все тренды, а не отфильтрованные по категории).

Решение:

  1. Split queries: statsQuery учитывает category + search, но НЕ status. trendsQuery — все фильтры
  2. Backend reorder: category/search фильтрация ДО подсчёта stats, status фильтрация ПОСЛЕ
  3. AnimatedCounter: анимация от предыдущего значения (не от 0), skip при одинаковых значениях
  4. AnimatePresence mode="popLayout": плавная анимация карточек при смене фильтра

Подводные камни:

  • statsQuery делает отдельный запрос с limit: 1 — только для получения stats
  • categoryCounts тоже из statsQuery — глобальные (до status фильтра)
  • keepPreviousData на trendsQuery предотвращает flash при переключении

Translation: Fix Description Translation (Markers + Batch Chunking)

Тип: fix Файлы:

  • api/translation_service.py — markers <<<N>>> вместо [N], regex fix
  • src/workers/crawler.py — BATCH_SIZE=30 для LLM calls, get_cached_translations_batch() вместо N+1

Проблема: Описания трендов отображались на EN при выборе RU. Два root cause:

  1. _batch_translate_texts использовал маркеры [N], которые конфликтовали с содержимым типа [10], [2024] в описаниях → regex ломал парсинг
  2. Crawler _pre_translate_items отправлял ВСЕ описания в одном LLM-запросе → output truncation

Решение:

  1. Заменили маркеры на <<<N>>> и regex <<<(\d+)>>> — не конфликтуют с контентом
  2. Добавили BATCH_SIZE=30 для chunked LLM calls
  3. Используем get_cached_translations_batch() для проверки уже переведённых текстов перед вызовом LLM

Подводные камни:

  • Маркеры <<<N>>> должны быть уникальны и НЕ встречаться в обычном тексте
  • При batch_size слишком маленьком — больше LLM вызовов; слишком большом — truncation

TrendView: Unified Component with Variants

Тип: refactor Файлы:

  • frontend-cascade/app/src/components/trends/trend-view.tsxNEW: sm / lg / row варианты + TrendViewSkeleton
  • frontend-cascade/app/src/components/trends/trend-card.tsx — deprecated (функционал в trend-view.tsx)
  • frontend-cascade/app/src/routes/_dashboard/radar.tsx — использует <TrendView variant="lg" />

Проблема: Карточка тренда была одним монолитным компонентом. Нужны были разные представления: мини-карточка (sm), полная карточка (lg), строка таблицы (row).

Решение: TrendView({ trend, variant }) — switch по variant на три internal компонента: TrendViewSmall, TrendViewLarge, TrendViewRow. Shared SourceLinks компонент. TrendViewSkeleton с вариантами.


E2E Translation Tests

Тип: test Файлы:

  • tests/e2e/test_translation_e2e.pyNEW: 33 теста для eager translation system

Проблема: Eager translation system (batch lookup, cache-only API, crawler pre-translate, watchdog) не имела тестового покрытия.

Решение: 33 e2e теста в 6 классах:

  • TestBatchCacheLookup — batch queries, пустой кеш, разные языки
  • TestCacheOnlyAPI — fallback на EN, partial cache, analysis cache-only
  • TestCrawlerPreTranslate — batch translation в crawler, chunking
  • TestTranslationWatchdog — gap detection, fill loops, start/stop lifecycle
  • TestWALMode — WAL mode при init
  • TestScoreIndex — индекс на score

Eager Translation System + Translation Watchdog

Тип: refactor / feature Файлы:

  • api/translation_service.py — WAL mode, get_cached_translations_batch() (batch lookup), cache-only методы (translate_trend_items_cached_only, translate_analysis_result_cached_only)
  • api/routes.py — API routes: заменён on-the-fly LLM перевод на cache-only lookup с EN fallback
  • src/workers/crawler.py — eager batch translation titles + descriptions после каждого crawl, индекс idx_trending_score
  • src/workers/translation_watchdog.pyNEW: фоновый watchdog (каждые 5 мин) для заполнения пробелов в переводах
  • api/main.py — интеграция TranslationWatchdog, удалён одноразовый pre_translate_existing()

Проблема: API routes вызывали LLM при каждом запросе с lang=ru на cache miss — задержки 5-30 сек. N+1 SQLite connections (60 connect/close на запрос). Нет WAL mode. Решение: Batch cache lookup (1 SQL вместо 60), WAL mode, cache-only API (EN fallback), eager translation в crawler, фоновый watchdog для gap-filling. Подводные камни: При пустом кеше все тексты на EN; watchdog заполнит через 5 мин. Crawler pre-translate может fail — watchdog подхватит.


Daily Analysis Credits

Тип: feature Файлы:

  • api/credits_store.pyNEW: SQLite table daily_credits, функции check_credits(), use_credit()
  • api/main.py — инициализация таблицы при старте
  • api/routes.py — auth guard + credit deduction на POST /trends/analyze
  • api/auth/routes.pyGET /auth/credits endpoint
  • frontend-cascade/app/src/lib/api-client.tsgetCredits() метод
  • frontend-cascade/app/src/routes/_dashboard/analyze.tsx — отображение кредитов, блокировка кнопки
  • frontend-cascade/app/src/i18n/{en,ru}.json — i18n ключи

Проблема: Endpoint /trends/analyze был полностью открыт — без авторизации, без лимитов. Любой мог спамить анализами, сжигая кредиты LLM. Решение: Добавлен auth guard (JWT) + система дневных кредитов: 100 анализов/день на пользователя, сброс в полночь UTC. При исчерпании — HTTP 429. Подводные камни:

  • use_credit() использует BEGIN IMMEDIATE для атомарности
  • Тесты требуют dependency override для get_current_user и mock для use_credit

Version Comparison Page

Тип: feature Файлы:

  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.compare.tsxNEW: страница сравнения двух версий анализа
  • frontend-cascade/app/src/components/analysis/diff-report-viewer.tsxNEW: Unified/SideBySide отображение diff
  • frontend-cascade/app/src/components/analysis/zones-diff.tsxNEW: сравнение impact zones с fuzzy matching
  • frontend-cascade/app/src/lib/diff-engine.tsNEW: paragraph-level diff + word-level highlights (Jaccard similarity)
  • frontend-cascade/app/src/i18n/en.json, ru.json — ключи comparison.*

Проблема: После добавления версионности анализов не было возможности увидеть, что именно изменилось между версиями.

Решение:

  1. Страница /analyze/$jobId/compare?with=$otherId загружает оба отчёта
  2. diffMarkdown() — paragraph-level diff с word-level подсветкой изменений
  3. diffImpactZones() — fuzzy matching зон по названию (Jaccard threshold)
  4. Два режима: Unified View и Side-by-Side View
  5. Dropdown для выбора версии сравнения из списка доступных

Подводные камни:

  • TanStack Router требует q: undefined, trend_id: undefined в search params при навигации к дочернему route (наследует от parent)
  • Locale берётся из useUIStore() для консистентного diff

SSE Progress: Event Queue

Тип: fix Файлы:

  • api/routes.py_progress_events: dict[str, list[dict]], cursor-based SSE generator

Проблема: _progress был single-slot dict — каждый шаг перезаписывал предыдущий. Если шаг "translating" → "complete" происходил быстрее, чем SSE успевал прочитать, фронтенд закрывал страницу не дождавшись перевода.

Решение:

  1. Заменили _progress: dict на _progress_events: dict[str, list[dict]] (append-only list)
  2. SSE generator использует cursor — никогда не пропускает события
  3. _update_progress() добавляет событие в список, не перезаписывает
  4. /analyses/running берёт events[-1] для текущего статуса

Подводные камни:

  • Тесты ссылаются на _progress_events, не _progress
  • Memory: события накапливаются в памяти — для долгих анализов не проблема (7 шагов max)

Progress Bar Rescaling

Тип: fix Файлы:

  • api/routes.py — новое распределение процентов по фазам

Проблема: Фаза перевода занимала 3% прогресс-бара (96→99%), хотя реально длится дольше всего. Пользователь видел "95%" и долго ждал.

Решение: Новое распределение пропорционально реальной длительности:

Step Phase % range
1 collecting 5→15%
2 researching 18→30%
3 building (cascade) 33→48%
4 building (assembly) 53%
5 reporting 58→76%
6 translating 78→95%
7 complete 100%

Подводные камни:

  • Frontend STEP_KEYS содержит 7 записей (два steps.building)
  • Backend step number должен совпадать с индексом STEP_KEYS + 1

Pre-Translation of Reports

Тип: feature Файлы:

  • api/routes.py — автоматический перевод после завершения анализа
  • api/translation_service.py — кеширование переводов

Проблема: Отчёт переводился на лету при первом запросе, что создавало задержку.

Решение: После завершения анализа автоматически запускается перевод на все поддерживаемые локали. Кеш translation_service сохраняет результат.


CI Build Fixes

Тип: fix Файлы:

  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.compare.tsx — удалён неиспользуемый import AnalysisVersionSummary
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — добавлены q, trend_id в search params навигации
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx — удалён неиспользуемый useNavigate
  • tests/unit/test_analysis_pipeline.py, tests/e2e/test_versioning_e2e.py_progress_progress_events

Проблема: CI build:frontend падал на 4 TypeScript ошибках (TS6196, TS2322, TS6133) после рефакторинга SSE и версионности.

Решение: Удалены неиспользуемые импорты, добавлены обязательные search params для TanStack Router, обновлены тестовые ссылки.


2026-02-08

Analysis Versioning & Deepening

Тип: feature Файлы:

  • api/analysis_store.pyreserve_analysis(), complete_analysis(), fail_analysis(), find_analyses_by_trend(), list_analyses_grouped(), ALTER TABLE миграции
  • api/schemas.pyAnalysisVersionSummary, TrendAnalysesResponse, GroupedAnalysisSummary, GroupedAnalysisListResponse, расширены AnalysisReportResponse и AnalysisSummary
  • api/routes.py — атомарное резервирование версий, валидация parent_job_id, depth clamping [1-7], обновлён GET /analyses/by-trend/, GET /analyses?grouped=true
  • frontend-cascade/app/src/types/analysis.tsAnalysisVersionSummary, TrendAnalysesResponse, GroupedAnalysisSummary, getVersionType()
  • frontend-cascade/app/src/lib/api-client.tsgetAnalysesForTrend(), getGroupedAnalyses(), parent_job_id в analyzeTrend()
  • frontend-cascade/app/src/components/analysis/analysis-timeline.tsxNEW: Timeline на странице тренда
  • frontend-cascade/app/src/components/analysis/version-type-badge.tsxNEW: INITIAL / RE_ANALYZED / DEEPENED badge
  • frontend-cascade/app/src/components/analysis/version-nav-bar.tsxNEW: Prev/Next навигация на report page
  • frontend-cascade/app/src/components/analysis/score-delta-strip.tsxNEW: Дельты Score/Conf/Zones/Depth
  • frontend-cascade/app/src/components/analysis/report-group-card.tsxNEW: Grouped card на reports page
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx — CTA → AnalysisTimeline
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — VersionNavBar, ScoreDeltaStrip, action buttons
  • frontend-cascade/app/src/routes/_dashboard/reports.tsx — Flat list → grouped listing
  • frontend-cascade/app/src/i18n/en.json, ru.json — versioning., comparison., actions.reAnalyze/deepen/compare

Проблема: Каждый анализ тренда — одноразовый. Нет истории, нет версионности, нет возможности углубить анализ.

Решение:

  1. Atomic version reservation: reserve_analysis() с BEGIN IMMEDIATE гарантирует уникальность (trend_id, version) при параллельных запросах
  2. 3-phase lifecycle: reserve → complete/fail (вместо save_analysis)
  3. UNIQUE constraint на (trend_id, version) WHERE trend_id IS NOT NULL
  4. Version type: INITIAL (v1), RE_ANALYZED (v>1, no parent), DEEPENED (has parent_job_id)
  5. Frontend: Timeline на тренде, VersionNavBar + ScoreDeltaStrip на отчёте, grouped reports page
  6. API: GET /analyses/by-trend/{id} → все версии, GET /analyses?grouped=true → сгруппированный список

Подводные камни:

  • reserve_analysis() ловит IntegrityError и делает retry +1 (race condition safety net)
  • getAnalysisForTrend() deprecated → используй getAnalysesForTrend() (list)
  • depth clamped 1-7 на API уровне
  • parent_job_id валидируется: должен существовать в БД и принадлежать тому же trend_id
  • Старые анализы без trend_id → version=1, не группируются

Trend-Analysis Linking by ID

Тип: feature Файлы:

  • api/analysis_store.pyget_trend_metadata_by_id(), save_analysis() с trend_id
  • api/routes.pyAnalyzeRequest.trend_id, обновлён get_report()
  • frontend-cascade/app/src/lib/api-client.tstrend_id в analyzeTrend()
  • frontend-cascade/app/src/routes/_dashboard/analyze.tsxvalidateSearch с trend_id
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsx — передача trend_id при навигации

Проблема: Связь тренда с анализом была через trend_title, что ломалось при:

  • Разных языках (русский title в анализе, английский в trending_items)
  • Изменении title после анализа
  • Дублях названий

Решение:

  1. При запуске анализа с карточки тренда передаём trend_id через URL params
  2. save_analysis() сохраняет trend_id напрямую в БД
  3. get_report() получает метаданные по trend_id (primary), fallback на title
  4. Frontend передаёт trend_id в /analyze?q=...&trend_id=...

Подводные камни:

  • Старые анализы без trend_id — fallback на поиск по title
  • При ручном вводе тренда (без перехода с карточки) — trend_id = null

Graph Effect Nodes: Description Display

Тип: fix Файлы:

  • frontend-cascade/app/src/types/analysis.tsdescription в graph.nodes type
  • frontend-cascade/app/src/routes/_dashboard/analyze.$jobId.report.tsx — mapping description
  • frontend-cascade/app/src/components/analysis/interactive-graph.tsx — отображение description

Проблема: Cascade-эффекты в графе имели causal_chain (описание), но оно не показывалось в UI. Backend сохранял его как rationaledescription, но frontend не передавал поле.

Решение:

  1. Добавили description?: string в тип AnalysisReport.graph.nodes
  2. Добавили description: n.description в mapping graphNodes
  3. UI уже был готов — показывает selectedNode.description в карточке

Подводные камни:

  • description приходит из rationale для zones и causal_chain для effects
  • Все 18 effect-нод в тестах имеют description — если пусто, проверь LLM output

2026-02-07

Sources: переход от счётчиков к массивам URL

Тип: refactor Файлы:

  • api/routes.py_build_sources_dict(), _crawler_item_to_schema()
  • api/schemas.py — тип sources
  • frontend-cascade/app/src/types/trend.ts — тип TrendItem.sources
  • frontend-cascade/app/src/components/trends/trend-card.tsxgetSourceUrl()
  • frontend-cascade/app/src/components/trends/trend-table.tsx — подсчёт источников
  • frontend-cascade/app/src/routes/_dashboard/trend.$trendId.tsxtotalSources(), отображение ссылок

Проблема: Поле sources содержало синтетические числа ({github: 1, hn: 2}), а не реальные ссылки. Пользователь ожидал кликабельные ссылки на каждый источник.

Решение:

  1. Изменили тип sources с dict[str, int] на dict[str, list[str]]
  2. Каждый ключ теперь содержит массив реальных URL
  3. Удалили поле source_urls — теперь всё в sources
  4. Frontend использует sources?.[type]?.[0] для получения первой ссылки
  5. На странице деталей — .map() для отображения всех ссылок

Подводные камни:

  • Если добавляешь новый источник — добавь его в _build_sources_dict() и в frontend типы
  • sources может быть undefined — всегда используй optional chaining (?.)

Tavily ID: хэширование URL

Тип: fix Файлы: src/services/adapters/search.py

Проблема: Tavily ID содержал полный URL (tavily:https://example.com/article...). При переходе на страницу тренда URL энкодился и ломал роутинг: /trend/tavily%3Ahttps%3A%2F%2Fwww.example.com%2F...

Решение:

import hashlib
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
id=f"tavily:{url_hash}"

Теперь ID: tavily:a1b2c3d4e5f6 (12 символов MD5 хэша)

Подводные камни:

  • Оригинальный URL хранится в поле url, не в ID
  • _build_sources_dict() использует item_url для tavily, не парсит ID
  • При коллизии хэшей (маловероятно) — увеличить длину хэша

Categories: навигация через URL params

Тип: fix Файлы: frontend-cascade/app/src/routes/_dashboard/categories.tsx

Проблема: Выбор категории использовал useState, URL не менялся. Кнопки браузера "назад/вперёд" не работали.

Решение:

// Добавили validateSearch
export const Route = createFileRoute('/_dashboard/categories')({
  validateSearch: (search) => ({
    category: search.category || undefined,
  }),
})

// Вместо useState — Route.useSearch()
const { category: selectedCategory } = Route.useSearch()

// Навигация обновляет URL
navigate({ to: '/categories', search: category ? { category } : {} })

Подводные камни:

  • TanStack Router требует validateSearch для типизации search params
  • При очистке категории передавай пустой объект {}, не undefined

Scoring V2: Urgency, Quality, Recommendation

Тип: feature Файлы:

  • src/services/scoring.py — ScoringResult dataclass, calculate_urgency(), calculate_quality(), get_recommendation()
  • api/schemas.py — поля urgency, quality, recommendation в TrendItem
  • api/routes.py — передача новых полей в schema
  • frontend-cascade/app/src/types/trend.ts — Recommendation type, новые поля в TrendItem
  • frontend-cascade/app/src/components/trends/recommendation-badge.tsx — NEW: отображение рекомендаций
  • frontend-cascade/app/src/components/trends/urgency-quality-meter.tsx — NEW: иконки с числовыми значениями
  • frontend-cascade/app/src/i18n/en.json, ru.json — переводы scoring.*

Проблема: Единый score не давал понимания "когда действовать" vs "насколько качественный тренд".

Решение:

  1. Urgency (0-100): Как срочно нужно реагировать
    • Формула: velocity × time_decay × engagement
    • Высокий = быстро растёт, нужно действовать сейчас
  2. Quality (0-100): Насколько надёжный/качественный тренд
    • Формула: source_coverage × confidence × data_completeness
    • Высокий = много источников, проверенные данные
  3. Recommendation: ACT_NOW | MONITOR | EVERGREEN | IGNORE
    • ACT_NOW: urgency ≥ 70 AND quality ≥ 50
    • MONITOR: urgency ≥ 40 OR quality ≥ 40
    • EVERGREEN: quality ≥ 70 AND urgency < 40
    • IGNORE: остальное

UI компоненты:

  • RecommendationBadge — цветной бейдж с иконкой (ACT_NOW пульсирует)
  • UrgencyQualityIcons — формат ⚡ 72 ⭐ 55 с tooltip

Подводные камни:

  • Urgency может быть 0 для старых трендов без velocity
  • MONITOR — дефолт, не показывать бейдж если recommendation = MONITOR
  • Сохранять обратную совместимость с score (composite)

Tavily: Citation-Based Validation

Тип: feature Файлы:

  • src/config.py — TavilyConfig params, TAVILY_CITATION_BASELINES, AGGREGATOR_PATTERNS
  • src/services/adapters/search.py — fetch_news(), count_citations(), _is_aggregator(), _parse_date()
  • src/services/scoring.py — tavily_citations signal (0.15 weight) во всех категориях
  • src/workers/crawler.py — enrichment loop для HN/GitHub/arXiv трендов

Проблема: Tavily возвращал низкокачественные данные:

  • 98% items имели score >= 0.8 (бесполезно для ранжирования)
  • 42% — aggregator/category pages (мусор типа /news/, /category/)
  • Нет реальной даты публикации (published_at = now())
  • Нет валидации "трендовости" — любой результат считался трендом

Решение:

  1. API Improvements: topic="news", search_depth="advanced", days=3
  2. Aggregator Filter: _is_aggregator(url) фильтрует категории и главные страницы
  3. Date Parsing: _parse_date() извлекает реальную дату публикации
  4. Citation Validation: count_citations(title) считает уникальные домены
  5. Threshold: Только items с >= 5 уникальных доменов проходят валидацию
  6. Scoring: Новый сигнал tavily_citations с весом 0.15 во всех категориях
  7. Enrichment: Crawler добавляет citations для HN/GitHub/arXiv трендов

API Credits:

  • Старое: 1 credit/search (basic)
  • Новое: 3 credits/trend (2 advanced + 1 citation validation)
  • Enrichment: +1 credit/non-Tavily trend

Подводные камни:

  • tavily_citations != citations (академические для science)
  • Старый fetch() deprecated, использовать fetch_news()
  • Rate limiting: enrichment увеличивает время crawl
  • TAVILY_CITATION_BASELINES могут требовать калибровки

Шаблон для новых записей

### [Краткое описание]

**Тип:** bug | feature | refactor | fix
**Файлы:**
- `path/to/file.ts`

**Проблема:**
Описание проблемы

**Решение:**
Что сделали (можно с кодом)

**Подводные камни:**
- На что обратить внимание

Оцените материал

0/1000