lsdf commited on
Commit
ba71965
·
1 Parent(s): 81ca2fd

Revert "Optimizer: SSE progress, cancel job with partial result, UI progress card"

Browse files

This reverts commit b6a4468ec1517d6b3943366319a5ff2181c7ec81.

app.py CHANGED
@@ -1,12 +1,7 @@
1
  # app.py - Hugging Face Spaces entry point
2
 
3
- import json
4
- import threading
5
- import uuid
6
- from queue import Queue
7
-
8
  from fastapi import FastAPI, Request
9
- from fastapi.responses import HTMLResponse, StreamingResponse
10
  from fastapi.templating import Jinja2Templates
11
  import uvicorn
12
  import torch
@@ -24,7 +19,6 @@ from models import (
24
  UserAgentsResponse,
25
  OptimizerRequest,
26
  OptimizerResponse,
27
- OptimizerCancelRequest,
28
  )
29
  import logic
30
  import nlp_processor
@@ -37,10 +31,6 @@ import optimizer
37
 
38
  app = FastAPI(title="SEO AI Editor MVP")
39
 
40
- # Один активный job_id на процесс достаточно для HF Space; для отмены без обрыва SSE.
41
- _OPTIMIZER_JOBS_LOCK = threading.Lock()
42
- _OPTIMIZER_CANCEL_EVENTS: dict = {}
43
-
44
  # Подключаем папку с шаблонами
45
  templates = Jinja2Templates(directory="templates")
46
 
@@ -268,71 +258,6 @@ async def run_optimizer(request: OptimizerRequest):
268
  except Exception as e:
269
  return OptimizerResponse(ok=False, error=str(e))
270
 
271
-
272
- @app.post("/api/v1/optimizer/cancel")
273
- async def optimizer_cancel(request: OptimizerCancelRequest):
274
- """Сигнал отмены: optimize_text завершится с частичным результатом; клиент продолжает читать SSE."""
275
- with _OPTIMIZER_JOBS_LOCK:
276
- ev = _OPTIMIZER_CANCEL_EVENTS.get(request.job_id)
277
- if ev is not None:
278
- ev.set()
279
- return {"ok": True, "job_id": request.job_id}
280
-
281
-
282
- @app.post("/api/v1/optimizer/run-stream")
283
- async def run_optimizer_stream(request: OptimizerRequest):
284
- """SSE: прогресс + финальный JSON (в т.ч. при остановке пользователем)."""
285
- data = request.model_dump()
286
- job_id = str(uuid.uuid4())
287
- cancel_ev = threading.Event()
288
- with _OPTIMIZER_JOBS_LOCK:
289
- _OPTIMIZER_CANCEL_EVENTS[job_id] = cancel_ev
290
- q: Queue = Queue(maxsize=500)
291
-
292
- def on_progress(evt: dict) -> None:
293
- out = dict(evt)
294
- out["job_id"] = job_id
295
- q.put(out)
296
-
297
- def worker() -> None:
298
- try:
299
- result = optimizer.optimize_text(data, progress_callback=on_progress, cancel_event=cancel_ev)
300
- q.put({"event": "__result__", "result": result})
301
- except Exception as e:
302
- q.put({"event": "__error__", "error": str(e)})
303
- finally:
304
- with _OPTIMIZER_JOBS_LOCK:
305
- _OPTIMIZER_CANCEL_EVENTS.pop(job_id, None)
306
-
307
- threading.Thread(target=worker, daemon=True).start()
308
-
309
- def sse_iter():
310
- try:
311
- while True:
312
- item = q.get()
313
- if item.get("event") == "__result__":
314
- payload = {"event": "complete", "result": item.get("result")}
315
- yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
316
- break
317
- if item.get("event") == "__error__":
318
- payload = {"event": "error", "error": item.get("error", "unknown")}
319
- yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
320
- break
321
- yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
322
- finally:
323
- with _OPTIMIZER_JOBS_LOCK:
324
- _OPTIMIZER_CANCEL_EVENTS.pop(job_id, None)
325
-
326
- return StreamingResponse(
327
- sse_iter(),
328
- media_type="text/event-stream",
329
- headers={
330
- "Cache-Control": "no-cache",
331
- "Connection": "keep-alive",
332
- "X-Accel-Buffering": "no",
333
- },
334
- )
335
-
336
  # Hugging Face Spaces использует порт 7860
337
  if __name__ == "__main__":
338
  port = int(os.environ.get("PORT", 7860))
 
1
  # app.py - Hugging Face Spaces entry point
2
 
 
 
 
 
 
3
  from fastapi import FastAPI, Request
4
+ from fastapi.responses import HTMLResponse
5
  from fastapi.templating import Jinja2Templates
6
  import uvicorn
7
  import torch
 
19
  UserAgentsResponse,
20
  OptimizerRequest,
21
  OptimizerResponse,
 
22
  )
23
  import logic
24
  import nlp_processor
 
31
 
32
  app = FastAPI(title="SEO AI Editor MVP")
33
 
 
 
 
 
34
  # Подключаем папку с шаблонами
35
  templates = Jinja2Templates(directory="templates")
36
 
 
258
  except Exception as e:
259
  return OptimizerResponse(ok=False, error=str(e))
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  # Hugging Face Spaces использует порт 7860
262
  if __name__ == "__main__":
263
  port = int(os.environ.get("PORT", 7860))
docs/FULL_FUNCTIONAL_DOCUMENTATION.md CHANGED
@@ -24,7 +24,7 @@
24
  - смысловой поиск по словам и фразам
25
  - сравнение с конкурентами (включая таблицу мощных терминов)
26
 
27
- 3. **LLM Optimizer** (`POST /api/v1/optimizer/run` или `run-stream` + `cancel` для прогресса и остановки)
28
  - итеративная локальная оптимизация текста
29
  - многокритериальный скоринг с защитой от деградации
30
  - каскад уровней правок (от минимальных к более широким)
@@ -179,11 +179,6 @@
179
  - `phrase_strategy_mode`
180
  - `bert_stage_target`
181
  - `error` (если есть)
182
- - при остановке пользователем во время streaming: `stopped_early: true`, `stop_reason: "user_cancelled"` — в `optimized_text` и метриках сохраняется **частичный** результат на момент остановки.
183
-
184
- ### UI: `POST /api/v1/optimizer/run-stream` (SSE)
185
- - Поток `text/event-stream`, события `data: {json}`: `preparing`, `started` (с `job_id`), `step_start`, `llm_call`, финал `complete` с полным телом как у `OptimizerResponse`.
186
- - **`POST /api/v1/optimizer/cancel`** с телом `{"job_id": "<id из событий>"}` — выставляет флаг отмены; **не** рвёт SSE: клиент дочитывает поток до `complete` с частичным результатом.
187
 
188
  ---
189
 
 
24
  - смысловой поиск по словам и фразам
25
  - сравнение с конкурентами (включая таблицу мощных терминов)
26
 
27
+ 3. **LLM Optimizer** (`POST /api/v1/optimizer/run`)
28
  - итеративная локальная оптимизация текста
29
  - многокритериальный скоринг с защитой от деградации
30
  - каскад уровней правок (от минимальных к более широким)
 
179
  - `phrase_strategy_mode`
180
  - `bert_stage_target`
181
  - `error` (если есть)
 
 
 
 
 
182
 
183
  ---
184
 
models.py CHANGED
@@ -105,10 +105,4 @@ class OptimizerResponse(BaseModel):
105
  optimization_mode: str = "balanced"
106
  phrase_strategy_mode: str = "auto"
107
  bert_stage_target: float = 0.70
108
- stopped_early: bool = False
109
- stop_reason: str = ""
110
- error: str = ""
111
-
112
-
113
- class OptimizerCancelRequest(BaseModel):
114
- job_id: str
 
105
  optimization_mode: str = "balanced"
106
  phrase_strategy_mode: str = "auto"
107
  bert_stage_target: float = 0.70
108
+ error: str = ""
 
 
 
 
 
 
optimizer.py CHANGED
@@ -1,7 +1,7 @@
1
  import json
2
  import re
3
  from itertools import combinations
4
- from typing import Any, Callable, Dict, List, Optional, Tuple
5
 
6
  import requests
7
 
@@ -29,18 +29,6 @@ STAGE_ORDER = ["bert", "bm25", "ngram", "semantic", "title"]
29
  NGRAM_ATTEMPTS_PER_TERM = 3
30
 
31
 
32
- def _emit_progress(
33
- progress_callback: Optional[Callable[[Dict[str, Any]], None]],
34
- payload: Dict[str, Any],
35
- ) -> None:
36
- if progress_callback is None:
37
- return
38
- try:
39
- progress_callback(dict(payload))
40
- except Exception:
41
- pass
42
-
43
-
44
  def _tokenize(text: str) -> List[str]:
45
  return [
46
  x
@@ -1299,11 +1287,7 @@ def _is_candidate_valid(
1299
  return (len(reasons) == 0), reasons, improved
1300
 
1301
 
1302
- def optimize_text(
1303
- request_data: Dict[str, Any],
1304
- progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
1305
- cancel_event: Optional[Any] = None,
1306
- ) -> Dict[str, Any]:
1307
  target_text = str(request_data.get("target_text", "")).strip()
1308
  competitors = [str(x) for x in (request_data.get("competitors") or []) if str(x).strip()]
1309
  keywords = [str(x) for x in (request_data.get("keywords") or []) if str(x).strip()]
@@ -1328,13 +1312,6 @@ def optimize_text(
1328
  bert_stage_target = float(request_data.get("bert_stage_target", BERT_TARGET_THRESHOLD) or BERT_TARGET_THRESHOLD)
1329
  bert_stage_target = max(0.0, min(1.0, bert_stage_target))
1330
 
1331
- def _is_cancelled() -> bool:
1332
- return cancel_event is not None and cancel_event.is_set()
1333
-
1334
- _emit_progress(
1335
- progress_callback,
1336
- {"event": "preparing", "phase": "baseline", "message": "Считаем baseline (анализ текста и метрики)…"},
1337
- )
1338
  baseline_analysis = _build_analysis_snapshot(
1339
  target_text, competitors, keywords, language, target_title, competitor_titles
1340
  )
@@ -1364,32 +1341,8 @@ def optimize_text(
1364
  stage_idx = 0
1365
  stage_no_progress_steps = 0
1366
  stage_goal_cursor: Dict[str, Dict[str, int]] = {}
1367
- stopped_early = False
1368
- stop_reason: Optional[str] = None
1369
-
1370
- _emit_progress(
1371
- progress_callback,
1372
- {
1373
- "event": "started",
1374
- "total_steps": total_loop_steps,
1375
- "max_iterations_setting": max_iterations,
1376
- "ngram_targets": ngram_row_count,
1377
- "stages_order": list(STAGE_ORDER),
1378
- },
1379
- )
1380
 
1381
  for step in range(total_loop_steps):
1382
- if _is_cancelled():
1383
- stopped_early = True
1384
- stop_reason = "user_cancelled"
1385
- logs.append(
1386
- {
1387
- "step": step + 1,
1388
- "status": "stopped_early",
1389
- "reason": "Остановка пользователем (начало шага).",
1390
- }
1391
- )
1392
- break
1393
  while stage_idx < len(STAGE_ORDER) and _is_stage_complete(
1394
  STAGE_ORDER[stage_idx], current_metrics, bert_stage_target=bert_stage_target
1395
  ):
@@ -1427,27 +1380,6 @@ def optimize_text(
1427
  logs.append({"step": step + 1, "status": "stopped", "reason": "No sentences available for editing."})
1428
  break
1429
 
1430
- ng_cur = stage_goal_cursor.get(active_stage) or {}
1431
- _emit_progress(
1432
- progress_callback,
1433
- {
1434
- "event": "step_start",
1435
- "step": step + 1,
1436
- "total_steps": total_loop_steps,
1437
- "stage_index": stage_idx,
1438
- "active_stage": active_stage,
1439
- "goal_type": goal.get("type"),
1440
- "goal_label": str(goal.get("label", ""))[:160],
1441
- "cascade_level": cascade_level,
1442
- "score": current_metrics.get("score"),
1443
- "ngram_cursor": (
1444
- {"term_index": int(ng_cur.get("term_index", 0)), "attempt": int(ng_cur.get("attempt_count", 0))}
1445
- if active_stage == "ngram"
1446
- else None
1447
- ),
1448
- },
1449
- )
1450
-
1451
  goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
1452
  base_attempt_cursor = int(goal_attempt_cursor.get(goal_key, 0))
1453
  span_trials = 2 if cascade_level <= 2 else 3
@@ -1455,7 +1387,6 @@ def optimize_text(
1455
  candidates: List[Dict[str, Any]] = []
1456
  chosen_spans: List[Dict[str, Any]] = []
1457
  candidate_idx = 0
1458
- optimization_cancelled = False
1459
 
1460
  for st in range(span_trials):
1461
  attempt_cursor = base_attempt_cursor + st
@@ -1497,27 +1428,9 @@ def optimize_text(
1497
  per_span_candidates,
1498
  )
1499
  for strategy_variant in strategy_plan:
1500
- if _is_cancelled():
1501
- optimization_cancelled = True
1502
- stopped_early = True
1503
- stop_reason = "user_cancelled"
1504
- break
1505
  candidate_idx += 1
1506
  temp = min(1.1, max(0.0, temperature + (candidate_idx - 1) * 0.07))
1507
  try:
1508
- _emit_progress(
1509
- progress_callback,
1510
- {
1511
- "event": "llm_call",
1512
- "step": step + 1,
1513
- "total_steps": total_loop_steps,
1514
- "active_stage": active_stage,
1515
- "candidate_index": candidate_idx,
1516
- "span_trial": st + 1,
1517
- "span_trials": span_trials,
1518
- "strategy": strategy_variant,
1519
- },
1520
- )
1521
  llm_result = _llm_edit_chunk(
1522
  api_key=api_key,
1523
  base_url=base_url,
@@ -1724,19 +1637,6 @@ def optimize_text(
1724
  }
1725
  )
1726
 
1727
- if optimization_cancelled:
1728
- break
1729
-
1730
- if optimization_cancelled:
1731
- logs.append(
1732
- {
1733
- "step": step + 1,
1734
- "status": "stopped_early",
1735
- "reason": "Остановка пользователем: сохранён текст и метрики на момент остановки.",
1736
- }
1737
- )
1738
- break
1739
-
1740
  goal_attempt_cursor[goal_key] = base_attempt_cursor + span_trials
1741
  primary_span = chosen_spans[0] if chosen_spans else {"operation": "-", "span_start": 0, "span_end": 0, "sentence_index": 0, "span_variant": 0, "sentence_before": ""}
1742
 
@@ -2154,6 +2054,4 @@ def optimize_text(
2154
  "optimization_mode": optimization_mode,
2155
  "phrase_strategy_mode": phrase_strategy_mode,
2156
  "bert_stage_target": round(bert_stage_target, 4),
2157
- "stopped_early": stopped_early,
2158
- "stop_reason": stop_reason or "",
2159
  }
 
1
  import json
2
  import re
3
  from itertools import combinations
4
+ from typing import Any, Dict, List, Optional, Tuple
5
 
6
  import requests
7
 
 
29
  NGRAM_ATTEMPTS_PER_TERM = 3
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def _tokenize(text: str) -> List[str]:
33
  return [
34
  x
 
1287
  return (len(reasons) == 0), reasons, improved
1288
 
1289
 
1290
+ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
 
 
 
 
1291
  target_text = str(request_data.get("target_text", "")).strip()
1292
  competitors = [str(x) for x in (request_data.get("competitors") or []) if str(x).strip()]
1293
  keywords = [str(x) for x in (request_data.get("keywords") or []) if str(x).strip()]
 
1312
  bert_stage_target = float(request_data.get("bert_stage_target", BERT_TARGET_THRESHOLD) or BERT_TARGET_THRESHOLD)
1313
  bert_stage_target = max(0.0, min(1.0, bert_stage_target))
1314
 
 
 
 
 
 
 
 
1315
  baseline_analysis = _build_analysis_snapshot(
1316
  target_text, competitors, keywords, language, target_title, competitor_titles
1317
  )
 
1341
  stage_idx = 0
1342
  stage_no_progress_steps = 0
1343
  stage_goal_cursor: Dict[str, Dict[str, int]] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
1344
 
1345
  for step in range(total_loop_steps):
 
 
 
 
 
 
 
 
 
 
 
1346
  while stage_idx < len(STAGE_ORDER) and _is_stage_complete(
1347
  STAGE_ORDER[stage_idx], current_metrics, bert_stage_target=bert_stage_target
1348
  ):
 
1380
  logs.append({"step": step + 1, "status": "stopped", "reason": "No sentences available for editing."})
1381
  break
1382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1383
  goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
1384
  base_attempt_cursor = int(goal_attempt_cursor.get(goal_key, 0))
1385
  span_trials = 2 if cascade_level <= 2 else 3
 
1387
  candidates: List[Dict[str, Any]] = []
1388
  chosen_spans: List[Dict[str, Any]] = []
1389
  candidate_idx = 0
 
1390
 
1391
  for st in range(span_trials):
1392
  attempt_cursor = base_attempt_cursor + st
 
1428
  per_span_candidates,
1429
  )
1430
  for strategy_variant in strategy_plan:
 
 
 
 
 
1431
  candidate_idx += 1
1432
  temp = min(1.1, max(0.0, temperature + (candidate_idx - 1) * 0.07))
1433
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
1434
  llm_result = _llm_edit_chunk(
1435
  api_key=api_key,
1436
  base_url=base_url,
 
1637
  }
1638
  )
1639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1640
  goal_attempt_cursor[goal_key] = base_attempt_cursor + span_trials
1641
  primary_span = chosen_spans[0] if chosen_spans else {"operation": "-", "span_start": 0, "span_end": 0, "sentence_index": 0, "span_variant": 0, "sentence_before": ""}
1642
 
 
2054
  "optimization_mode": optimization_mode,
2055
  "phrase_strategy_mode": phrase_strategy_mode,
2056
  "bert_stage_target": round(bert_stage_target, 4),
 
 
2057
  }
templates/index.html CHANGED
@@ -328,24 +328,12 @@
328
  <div class="form-text">Выбор стратегии остается у пользователя.</div>
329
  </div>
330
  </div>
331
- <div class="d-flex gap-2 mt-3 flex-wrap align-items-center">
332
- <button type="button" class="btn btn-dark" id="btnRunLlmOpt" onclick="runLlmOptimization()">Запустить оптимизацию</button>
333
- <button type="button" class="btn btn-outline-danger" id="btnStopLlmOpt" disabled onclick="requestStopOptimizer()">Остановить и сохранить результат</button>
334
- <button type="button" class="btn btn-outline-secondary" onclick="applyOptimizedText()">Применить в Target</button>
335
  </div>
336
  <p class="small text-muted mt-2 mb-0">API key не сохраняется в проект и используется только для текущего запроса.</p>
337
  </div>
338
- <div id="optimizerProgressCard" class="stat-card mt-3 d-none border border-info">
339
- <h6 class="card-title">Ход оптимизации (LLM)</h6>
340
- <div class="progress mb-2" style="height: 24px;">
341
- <div id="optimizerProgressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-dark"
342
- role="progressbar" style="width: 0%" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
343
- </div>
344
- <p id="optimizerProgressLine1" class="small fw-semibold mb-1 text-dark"></p>
345
- <p id="optimizerProgressLine2" class="text-muted small mb-1"></p>
346
- <p id="optimizerProgressTimer" class="text-muted small mb-2"></p>
347
- <p class="small text-muted mb-0">«Остановить» запрашивает завершение на сервере: соединение не рвётся — приходит <strong>частичный</strong> текст и лог на момент остановки.</p>
348
- </div>
349
  <div id="optimizerResultsContainer">
350
  <div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>
351
  </div>
@@ -363,94 +351,6 @@
363
  let currentData = null;
364
  let semanticData = null;
365
  let optimizerData = null;
366
- /** Текущий run-stream job (для POST /optimizer/cancel без Abort fetch) */
367
- let optimizerStreamJobId = null;
368
-
369
- const OPTIMIZER_STAGE_LABELS = {
370
- bert: 'BERT — релевантность фраз',
371
- bm25: 'BM25 — снятие переспама',
372
- ngram: 'N-граммы',
373
- semantic: 'Семантика',
374
- title: 'Заголовок'
375
- };
376
-
377
- function resetOptimizerProgressUI() {
378
- const bar = document.getElementById('optimizerProgressBar');
379
- if (bar) {
380
- bar.style.width = '0%';
381
- bar.setAttribute('aria-valuenow', '0');
382
- }
383
- const l1 = document.getElementById('optimizerProgressLine1');
384
- const l2 = document.getElementById('optimizerProgressLine2');
385
- const tm = document.getElementById('optimizerProgressTimer');
386
- if (l1) l1.textContent = '';
387
- if (l2) l2.textContent = '';
388
- if (tm) tm.textContent = '';
389
- }
390
-
391
- function applyOptimizerStreamEvent(data, streamStartMs) {
392
- if (!data || typeof data !== 'object') return;
393
- const bar = document.getElementById('optimizerProgressBar');
394
- const line1 = document.getElementById('optimizerProgressLine1');
395
- const line2 = document.getElementById('optimizerProgressLine2');
396
- const timerEl = document.getElementById('optimizerProgressTimer');
397
- if (timerEl && streamStartMs) {
398
- timerEl.textContent = 'Прошло: ' + Math.floor((Date.now() - streamStartMs) / 1000) + ' с';
399
- }
400
- if (!bar || !line1 || !line2) return;
401
- const ev = data.event;
402
- if (ev === 'preparing') {
403
- bar.style.width = '2%';
404
- bar.setAttribute('aria-valuenow', '2');
405
- line1.textContent = 'Подготовка';
406
- line2.textContent = data.message || (data.phase || '');
407
- return;
408
- }
409
- if (ev === 'started') {
410
- bar.style.width = '3%';
411
- bar.setAttribute('aria-valuenow', '3');
412
- line1.textContent = 'Старт: до ' + (data.total_steps || '?') + ' шагов цикла';
413
- const parts = [];
414
- if (data.ngram_targets != null) parts.push('целей n-gram: ' + data.ngram_targets);
415
- if (data.max_iterations_setting != null) parts.push('базовых итераций: ' + data.max_iterations_setting);
416
- line2.textContent = (parts.length ? parts.join(' · ') + '. ') + 'Стадии: ' + (data.stages_order || []).join(' → ');
417
- return;
418
- }
419
- if (ev === 'step_start') {
420
- const tot = Math.max(1, data.total_steps || 1);
421
- const pct = Math.min(96, Math.round(((data.step - 1) / tot) * 85 + 8));
422
- bar.style.width = pct + '%';
423
- bar.setAttribute('aria-valuenow', String(pct));
424
- const stLabel = OPTIMIZER_STAGE_LABELS[data.active_stage] || data.active_stage || '';
425
- line1.textContent = 'Шаг ' + data.step + ' / ' + tot + ' · ' + stLabel;
426
- let g = (data.goal_type || '') + (data.goal_label ? ': ' + data.goal_label : '');
427
- if (data.score != null) g += ' · score ' + data.score;
428
- line2.textContent = g;
429
- if (data.ngram_cursor) {
430
- line2.textContent += ' · n-gram: попытка ' + (data.ngram_cursor.attempt + 1) + '/3, термин #' + (data.ngram_cursor.term_index + 1);
431
- }
432
- return;
433
- }
434
- if (ev === 'llm_call') {
435
- line2.textContent = 'Запрос к LLM… кандидат ' + data.candidate_index + ', окно ' + data.span_trial + '/' + data.span_trials + ', стратегия: ' + (data.strategy || '');
436
- }
437
- }
438
-
439
- async function requestStopOptimizer() {
440
- if (!optimizerStreamJobId) return;
441
- try {
442
- await fetch('/api/v1/optimizer/cancel', {
443
- method: 'POST',
444
- headers: { 'Content-Type': 'application/json' },
445
- body: JSON.stringify({ job_id: optimizerStreamJobId })
446
- });
447
- } catch (e) {
448
- console.warn(e);
449
- }
450
- const line2 = document.getElementById('optimizerProgressLine2');
451
- if (line2) line2.textContent = 'Остановка запрошена, ждём частичный результат на сервере…';
452
- }
453
-
454
  let semanticTermSortBy = 'target_weight';
455
  let semanticTermSortDir = 'desc';
456
  let availableUserAgents = [];
@@ -937,10 +837,6 @@
937
  return;
938
  }
939
 
940
- const earlyBanner = data.stopped_early
941
- ? `<div class="alert alert-warning mb-3"><strong>Остановлено вручную.</strong> Ниже — текст и метрики на момент остановки (частичный результат). Применённые шаги: <strong>${data.applied_changes ?? 0}</strong>.</div>`
942
- : '';
943
-
944
  const base = data.baseline_metrics || {};
945
  const fin = data.final_metrics || {};
946
  const rows = [
@@ -1054,7 +950,7 @@
1054
  </div>`;
1055
  }).join('');
1056
 
1057
- container.innerHTML = earlyBanner + `
1058
  <div class="stat-card">
1059
  <h6 class="card-title">Результат оптимизации</h6>
1060
  <div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div>
@@ -1124,77 +1020,21 @@
1124
  phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto'
1125
  };
1126
 
1127
- const runBtn = document.getElementById('btnRunLlmOpt');
1128
- const stopBtn = document.getElementById('btnStopLlmOpt');
1129
- const progCard = document.getElementById('optimizerProgressCard');
1130
- optimizerStreamJobId = null;
1131
- if (runBtn) runBtn.disabled = true;
1132
- if (stopBtn) stopBtn.disabled = false;
1133
- if (progCard) progCard.classList.remove('d-none');
1134
- resetOptimizerProgressUI();
1135
-
1136
- const streamStartMs = Date.now();
1137
- const tickTimer = setInterval(function () {
1138
- const te = document.getElementById('optimizerProgressTimer');
1139
- if (te) te.textContent = 'Прошло: ' + Math.floor((Date.now() - streamStartMs) / 1000) + ' с';
1140
- }, 1000);
1141
-
1142
  try {
1143
- const response = await fetch('/api/v1/optimizer/run-stream', {
1144
  method: 'POST',
1145
  headers: { 'Content-Type': 'application/json' },
1146
  body: JSON.stringify(payload)
1147
  });
1148
  if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
1149
- const reader = response.body.getReader();
1150
- const decoder = new TextDecoder();
1151
- let buffer = '';
1152
- let finalResult = null;
1153
- while (true) {
1154
- const chunk = await reader.read();
1155
- if (chunk.done) break;
1156
- buffer += decoder.decode(chunk.value, { stream: true });
1157
- for (;;) {
1158
- const sep = buffer.indexOf('\n\n');
1159
- if (sep < 0) break;
1160
- const raw = buffer.slice(0, sep);
1161
- buffer = buffer.slice(sep + 2);
1162
- const lines = raw.split('\n');
1163
- for (let li = 0; li < lines.length; li++) {
1164
- const line = lines[li];
1165
- if (line.indexOf('data: ') !== 0) continue;
1166
- let payloadJson;
1167
- try {
1168
- payloadJson = JSON.parse(line.slice(6));
1169
- } catch (e) {
1170
- continue;
1171
- }
1172
- if (payloadJson.job_id) optimizerStreamJobId = payloadJson.job_id;
1173
- if (payloadJson.event === 'complete') {
1174
- finalResult = payloadJson.result;
1175
- } else if (payloadJson.event === 'error') {
1176
- throw new Error(payloadJson.error || 'Ошибка оптимизатора');
1177
- } else {
1178
- applyOptimizerStreamEvent(payloadJson, streamStartMs);
1179
- }
1180
- }
1181
- }
1182
- }
1183
- if (!finalResult) throw new Error('Поток завершился без результата');
1184
- optimizerData = finalResult;
1185
  renderOptimizerResults(optimizerData);
1186
- if (optimizerData.stopped_early) {
1187
- alert('Оптимизация остановлена. Показан частичный результат — его можно применить в Target.');
1188
- }
1189
  } catch (error) {
1190
  alert('Ошибка LLM оптимизации: ' + error.message);
1191
  console.error(error);
1192
  } finally {
1193
- clearInterval(tickTimer);
1194
- optimizerStreamJobId = null;
1195
- if (runBtn) runBtn.disabled = false;
1196
- if (stopBtn) stopBtn.disabled = true;
1197
- if (progCard) progCard.classList.add('d-none');
1198
  }
1199
  }
1200
 
 
328
  <div class="form-text">Выбор стратегии остается у пользователя.</div>
329
  </div>
330
  </div>
331
+ <div class="d-flex gap-2 mt-3">
332
+ <button class="btn btn-dark" onclick="runLlmOptimization()">Запустить оптимизацию</button>
333
+ <button class="btn btn-outline-secondary" onclick="applyOptimizedText()">Применить в Target</button>
 
334
  </div>
335
  <p class="small text-muted mt-2 mb-0">API key не сохраняется в проект и используется только для текущего запроса.</p>
336
  </div>
 
 
 
 
 
 
 
 
 
 
 
337
  <div id="optimizerResultsContainer">
338
  <div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>
339
  </div>
 
351
  let currentData = null;
352
  let semanticData = null;
353
  let optimizerData = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  let semanticTermSortBy = 'target_weight';
355
  let semanticTermSortDir = 'desc';
356
  let availableUserAgents = [];
 
837
  return;
838
  }
839
 
 
 
 
 
840
  const base = data.baseline_metrics || {};
841
  const fin = data.final_metrics || {};
842
  const rows = [
 
950
  </div>`;
951
  }).join('');
952
 
953
+ container.innerHTML = `
954
  <div class="stat-card">
955
  <h6 class="card-title">Результат оптимизации</h6>
956
  <div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div>
 
1020
  phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto'
1021
  };
1022
 
1023
+ document.getElementById('loader').style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1024
  try {
1025
+ const response = await fetch('/api/v1/optimizer/run', {
1026
  method: 'POST',
1027
  headers: { 'Content-Type': 'application/json' },
1028
  body: JSON.stringify(payload)
1029
  });
1030
  if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
1031
+ optimizerData = await response.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  renderOptimizerResults(optimizerData);
 
 
 
1033
  } catch (error) {
1034
  alert('Ошибка LLM оптимизации: ' + error.message);
1035
  console.error(error);
1036
  } finally {
1037
+ document.getElementById('loader').style.display = 'none';
 
 
 
 
1038
  }
1039
  }
1040