lsdf commited on
Commit
6b11536
·
1 Parent(s): ba71965

Optimizer: SSE log panel + static app.js (SES-safe nv), no global loader; HF_SES doc

Browse files
app.py CHANGED
@@ -1,7 +1,13 @@
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,6 +25,7 @@ from models import (
19
  UserAgentsResponse,
20
  OptimizerRequest,
21
  OptimizerResponse,
 
22
  )
23
  import logic
24
  import nlp_processor
@@ -31,6 +38,13 @@ import optimizer
31
 
32
  app = FastAPI(title="SEO AI Editor MVP")
33
 
 
 
 
 
 
 
 
34
  # Подключаем папку с шаблонами
35
  templates = Jinja2Templates(directory="templates")
36
 
@@ -258,6 +272,75 @@ async def run_optimizer(request: OptimizerRequest):
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))
 
1
  # app.py - Hugging Face Spaces entry point
2
 
3
+ import json
4
+ import threading
5
+ import uuid
6
+ from queue import Empty, Queue
7
+
8
  from fastapi import FastAPI, Request
9
+ from fastapi.responses import HTMLResponse, StreamingResponse
10
+ from fastapi.staticfiles import StaticFiles
11
  from fastapi.templating import Jinja2Templates
12
  import uvicorn
13
  import torch
 
25
  UserAgentsResponse,
26
  OptimizerRequest,
27
  OptimizerResponse,
28
+ OptimizerCancelRequest,
29
  )
30
  import logic
31
  import nlp_processor
 
38
 
39
  app = FastAPI(title="SEO AI Editor MVP")
40
 
41
+ _static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
42
+ if os.path.isdir(_static_dir):
43
+ app.mount("/static", StaticFiles(directory=_static_dir), name="static")
44
+
45
+ _OPTIMIZER_JOBS_LOCK = threading.Lock()
46
+ _OPTIMIZER_CANCEL_EVENTS: dict = {}
47
+
48
  # Подключаем папку с шаблонами
49
  templates = Jinja2Templates(directory="templates")
50
 
 
272
  except Exception as e:
273
  return OptimizerResponse(ok=False, error=str(e))
274
 
275
+
276
+ @app.post("/api/v1/optimizer/cancel")
277
+ async def optimizer_cancel(body: OptimizerCancelRequest):
278
+ with _OPTIMIZER_JOBS_LOCK:
279
+ ev = _OPTIMIZER_CANCEL_EVENTS.get(body.job_id)
280
+ if ev is not None:
281
+ ev.set()
282
+ return {"ok": True}
283
+
284
+
285
+ @app.post("/api/v1/optimizer/run-stream")
286
+ async def run_optimizer_stream(request: OptimizerRequest):
287
+ """SSE: события прогресса + финальный JSON. Клиент ведёт локальный лог, без глобального лоадера."""
288
+ job_id = str(uuid.uuid4())
289
+ cancel_ev = threading.Event()
290
+ payload = request.model_dump()
291
+ q: Queue = Queue()
292
+
293
+ with _OPTIMIZER_JOBS_LOCK:
294
+ _OPTIMIZER_CANCEL_EVENTS[job_id] = cancel_ev
295
+
296
+ def worker():
297
+ try:
298
+ def progress_cb(data):
299
+ q.put(("progress", data))
300
+
301
+ result = optimizer.optimize_text(
302
+ payload,
303
+ progress_callback=progress_cb,
304
+ cancel_event=cancel_ev,
305
+ )
306
+ q.put(("done", result))
307
+ except Exception as e:
308
+ q.put(("error", str(e)))
309
+
310
+ threading.Thread(target=worker, daemon=True).start()
311
+
312
+ def gen():
313
+ try:
314
+ yield f"data: {json.dumps({'event': 'job', 'job_id': job_id})}\n\n"
315
+ while True:
316
+ try:
317
+ kind, data = q.get(timeout=0.3)
318
+ except Empty:
319
+ yield ": ping\n\n"
320
+ continue
321
+ if kind == "progress":
322
+ yield f"data: {json.dumps(data)}\n\n"
323
+ elif kind == "done":
324
+ yield f"data: {json.dumps({'event': 'complete', 'result': data})}\n\n"
325
+ break
326
+ elif kind == "error":
327
+ yield f"data: {json.dumps({'event': 'error', 'error': data})}\n\n"
328
+ break
329
+ finally:
330
+ with _OPTIMIZER_JOBS_LOCK:
331
+ _OPTIMIZER_CANCEL_EVENTS.pop(job_id, None)
332
+
333
+ return StreamingResponse(
334
+ gen(),
335
+ media_type="text/event-stream",
336
+ headers={
337
+ "Cache-Control": "no-cache",
338
+ "Connection": "keep-alive",
339
+ "X-Accel-Buffering": "no",
340
+ },
341
+ )
342
+
343
+
344
  # Hugging Face Spaces использует порт 7860
345
  if __name__ == "__main__":
346
  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`)
28
  - итеративная локальная оптимизация текста
29
  - многокритериальный скоринг с защитой от деградации
30
  - каскад уровней правок (от минимальных к более широким)
@@ -45,7 +45,8 @@
45
  - `url_fetcher.py` — извлечение текста/title из URL с выбором user-agent.
46
  - `optimizer.py` — LLM-оптимизация с обратной связью от метрик.
47
  - `docs/TEXT_OPTIMIZER_PRINCIPLES.md` — живой регламент принципов оптимизатора (stage-пайплайн, допуски, guardrails).
48
- - `templates/index.html` — frontend (UI + клиентская логика JS).
 
49
 
50
  ---
51
 
@@ -178,8 +179,18 @@
178
  - `optimization_mode`
179
  - `phrase_strategy_mode`
180
  - `bert_stage_target`
 
181
  - `error` (если есть)
182
 
 
 
 
 
 
 
 
 
 
183
  ---
184
 
185
  ## 5) Подробная алгоритмика по модулям
@@ -502,7 +513,7 @@ HTML extraction pipeline:
502
 
503
  ---
504
 
505
- ## 6) Frontend (`templates/index.html`) сценарии и функции
506
 
507
  ## 6.1 Ввод данных и URL import
508
  - `loadUserAgentOptions` — загрузка пресетов UA.
@@ -531,9 +542,12 @@ API-ключ оптимизатора в persist-состояние не сох
531
 
532
  ## 6.4 Сводка и оптимизатор
533
  - `renderActionSummary` — агрегирует рекомендации BERT/BM25/N-grams/Title/Semantic в табличный формат.
534
- - `runLlmOptimization` — запуск оптимизации.
535
- - `renderOptimizerResults` — итог и debug-лог по шагам/кандидатам.
 
 
536
  - `applyOptimizedText` — перенос optimized текста в `target_text`.
 
537
 
538
  ## 6.5 Сортировка таблицы мощных терминов
539
  - `setSemanticTermSortBy`
 
24
  - смысловой поиск по словам и фразам
25
  - сравнение с конкурентами (включая таблицу мощных терминов)
26
 
27
+ 3. **LLM Optimizer** (`POST /api/v1/optimizer/run` или в UI — SSE `run-stream`)
28
  - итеративная локальная оптимизация текста
29
  - многокритериальный скоринг с защитой от деградации
30
  - каскад уровней правок (от минимальных к более широким)
 
45
  - `url_fetcher.py` — извлечение текста/title из URL с выбором user-agent.
46
  - `optimizer.py` — LLM-оптимизация с обратной связью от метрик.
47
  - `docs/TEXT_OPTIMIZER_PRINCIPLES.md` — живой регламент принципов оптимизатора (stage-пайплайн, допуски, guardrails).
48
+ - `templates/index.html` — разметка UI.
49
+ - `static/js/app.js` — вся клиентская логика (подключается как `/static/js/app.js`; без гигантского inline-скрипта — см. `docs/HF_SES_AND_UI.md`).
50
 
51
  ---
52
 
 
179
  - `optimization_mode`
180
  - `phrase_strategy_mode`
181
  - `bert_stage_target`
182
+ - `stopped_early`, `stop_reason` — при ручной остановке (частичный результат)
183
  - `error` (если есть)
184
 
185
+ ### `POST /api/v1/optimizer/run-stream` (SSE)
186
+ Тело как у `run`. Поток `text/event-stream`, события `job` (с `job_id`), `preparing`, `started`, `step_start`, `llm_call`, затем `complete` с полем `result` или `error`.
187
+
188
+ ### `POST /api/v1/optimizer/cancel`
189
+ Тело: `{"job_id": "..."}`. Только флаг отмены; клиент дочитывает SSE до `complete`.
190
+
191
+ ### UI и HF/SES
192
+ Клиентский код: **`static/js/app.js`** + `GET /static/js/app.js`. Прогресс оптимизатора — **локальная панель с текстовым логом** (и тонкая полоса), **без** `#loader`. Подробности про SES и «мёртвые кнопки»: `docs/HF_SES_AND_UI.md`.
193
+
194
  ---
195
 
196
  ## 5) Подробная алгоритмика по модулям
 
513
 
514
  ---
515
 
516
+ ## 6) Frontend (`templates/index.html` + `static/js/app.js`)
517
 
518
  ## 6.1 Ввод данных и URL import
519
  - `loadUserAgentOptions` — загрузка пресетов UA.
 
542
 
543
  ## 6.4 Сводка и оптимизатор
544
  - `renderActionSummary` — агрегирует рекомендации BERT/BM25/N-grams/Title/Semantic в табличный формат.
545
+ - `runLlmOptimization` — `POST /api/v1/optimizer/run-stream` (SSE); локальная панель **лога** + тонкий progress bar; **без** `#loader`.
546
+ - `requestStopOptimizer` — `POST /api/v1/optimizer/cancel`; поток дочитывается до `complete` астичный результат).
547
+ - `optimizerLogAppend` / `applyOptimizerStreamEvent` — текстовый ход работы.
548
+ - `renderOptimizerResults` — итог и debug-лог; баннер при `stopped_early`.
549
  - `applyOptimizedText` — перенос optimized текста в `target_text`.
550
+ - `nv(v, d)` — nullish-fallback без операторов `??` (SES на HF).
551
 
552
  ## 6.5 Сортировка таблицы мощных терминов
553
  - `setSemanticTermSortBy`
docs/HF_SES_AND_UI.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Почему на Hugging Face «умирали» все кнопки (версии с прогресс-баром)
2
+
3
+ По **фактам из консоли** (не гипотезы):
4
+
5
+ ## 1. `SES_UNCAUGHT_EXCEPTION: SyntaxError: missing : in conditional expression`
6
+
7
+ На странице Space подключается **SES lockdown** (`lockdown-install.js`). Его парсер/конвейер для кода страницы **не эквивалентен** последнему движку Firefox/Chrome в части синтаксиса ES2020+.
8
+
9
+ - Операторы **`??` (nullish coalescing)** и **`?.` (optional chaining)** в **одном исходнике** с большим inline-`<script>` давали ошибку разбора, интерпретируемую как **сломанный тернарный `? … :`** → *missing ':'*.
10
+ - После этой ошибки **весь** клиентский скрипт приложения **не выполняется** → не регистрируются **ни** `onclick`, **ни** делегирование `data-app-action` → кажется, что «сломались все кнопки».
11
+
12
+ **Исправление:** не использовать `??` / `?.` в коде приложения; вместо них — функция `nv(v, d)` и проверки `obj && obj.prop`.
13
+
14
+ ## 2. Глобальный `#loader`
15
+
16
+ Если на время оптимизатора включать полноэкранный оверлей и по какой-то причине не снять `display` в `finally`, **все клики** уходят в оверлей. Это уже не SES, а логика UI.
17
+
18
+ **Исправление:** во время LLM-оптимизации **не** трогать `#loader`; прогресс только в **локальной панели** под кнопками.
19
+
20
+ ## 3. Строки CSP про `inpage.js`, Stripe, `content.js`
21
+
22
+ В логе часто идут **расширения браузера** и iframe HF, а не ваш `index.html`. На диагностику кнопок приложения они обычно не влияют.
23
+
24
+ ## Текущая схема (после правок)
25
+
26
+ - Логика UI в **`/static/js/app.js`** (отдельный файл, не гигантский inline).
27
+ - Прогресс оптимизатора: **панель с `<pre>`-логом** + тонкая полоса; **без** блокировки всего экрана.
28
+ - Поток **`/api/v1/optimizer/run-stream`** (SSE) + **`/api/v1/optimizer/cancel`** для остановки с частичным результатом.
models.py CHANGED
@@ -105,4 +105,10 @@ class OptimizerResponse(BaseModel):
105
  optimization_mode: str = "balanced"
106
  phrase_strategy_mode: str = "auto"
107
  bert_stage_target: float = 0.70
108
- error: str = ""
 
 
 
 
 
 
 
105
  optimization_mode: str = "balanced"
106
  phrase_strategy_mode: str = "auto"
107
  bert_stage_target: float = 0.70
108
+ error: str = ""
109
+ stopped_early: bool = False
110
+ stop_reason: str = ""
111
+
112
+
113
+ class OptimizerCancelRequest(BaseModel):
114
+ job_id: str = Field(..., min_length=8)
optimizer.py CHANGED
@@ -1287,7 +1287,11 @@ def _is_candidate_valid(
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()]
@@ -1326,12 +1330,47 @@ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
1326
  ngram_step_addon = min(56, max(0, ngram_row_count) * NGRAM_ATTEMPTS_PER_TERM)
1327
  total_loop_steps = min(80, max_iterations + ngram_step_addon)
1328
 
 
 
 
 
 
 
 
 
 
1329
  current_text = target_text
1330
  current_analysis = baseline_analysis
1331
  current_semantic = baseline_semantic
1332
  current_metrics = baseline_metrics
1333
  logs: List[Dict[str, Any]] = []
1334
  applied_changes = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1335
  seen_candidate_rewrites = set()
1336
  cascade_level = 1
1337
  consecutive_failures = 0
@@ -1343,6 +1382,16 @@ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
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,6 +1429,16 @@ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
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
@@ -1430,6 +1489,22 @@ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
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,
@@ -2044,14 +2119,4 @@ def optimize_text(request_data: Dict[str, Any]) -> Dict[str, Any]:
2044
  cascade_level = 1
2045
  goal_attempt_cursor[goal_key] = 0
2046
 
2047
- return {
2048
- "ok": True,
2049
- "optimized_text": current_text,
2050
- "baseline_metrics": baseline_metrics,
2051
- "final_metrics": current_metrics,
2052
- "iterations": logs,
2053
- "applied_changes": applied_changes,
2054
- "optimization_mode": optimization_mode,
2055
- "phrase_strategy_mode": phrase_strategy_mode,
2056
- "bert_stage_target": round(bert_stage_target, 4),
2057
- }
 
1287
  return (len(reasons) == 0), reasons, improved
1288
 
1289
 
1290
+ def optimize_text(
1291
+ request_data: Dict[str, Any],
1292
+ progress_callback: Optional[Any] = None,
1293
+ cancel_event: Optional[Any] = None,
1294
+ ) -> Dict[str, Any]:
1295
  target_text = str(request_data.get("target_text", "")).strip()
1296
  competitors = [str(x) for x in (request_data.get("competitors") or []) if str(x).strip()]
1297
  keywords = [str(x) for x in (request_data.get("keywords") or []) if str(x).strip()]
 
1330
  ngram_step_addon = min(56, max(0, ngram_row_count) * NGRAM_ATTEMPTS_PER_TERM)
1331
  total_loop_steps = min(80, max_iterations + ngram_step_addon)
1332
 
1333
+ _emit("preparing", message="Подготовка", phase="baseline")
1334
+ _emit(
1335
+ "started",
1336
+ total_steps=total_loop_steps,
1337
+ max_iterations_setting=max_iterations,
1338
+ ngram_targets=ngram_row_count,
1339
+ stages_order=list(STAGE_ORDER),
1340
+ )
1341
+
1342
  current_text = target_text
1343
  current_analysis = baseline_analysis
1344
  current_semantic = baseline_semantic
1345
  current_metrics = baseline_metrics
1346
  logs: List[Dict[str, Any]] = []
1347
  applied_changes = 0
1348
+
1349
+ def _emit(ev: str, **kwargs: Any) -> None:
1350
+ if progress_callback:
1351
+ try:
1352
+ progress_callback({"event": ev, **kwargs})
1353
+ except Exception:
1354
+ pass
1355
+
1356
+ def _cancelled() -> bool:
1357
+ return cancel_event is not None and getattr(cancel_event, "is_set", lambda: False)()
1358
+
1359
+ def _pack_result(stopped_early: bool = False, stop_reason: str = "") -> Dict[str, Any]:
1360
+ return {
1361
+ "ok": True,
1362
+ "optimized_text": current_text,
1363
+ "baseline_metrics": baseline_metrics,
1364
+ "final_metrics": current_metrics,
1365
+ "iterations": logs,
1366
+ "applied_changes": applied_changes,
1367
+ "optimization_mode": optimization_mode,
1368
+ "phrase_strategy_mode": phrase_strategy_mode,
1369
+ "bert_stage_target": round(bert_stage_target, 4),
1370
+ "stopped_early": stopped_early,
1371
+ "stop_reason": stop_reason,
1372
+ }
1373
+
1374
  seen_candidate_rewrites = set()
1375
  cascade_level = 1
1376
  consecutive_failures = 0
 
1382
  stage_goal_cursor: Dict[str, Dict[str, int]] = {}
1383
 
1384
  for step in range(total_loop_steps):
1385
+ if _cancelled():
1386
+ logs.append(
1387
+ {
1388
+ "step": step + 1,
1389
+ "status": "stopped",
1390
+ "reason": "Остановка пользователем (сохранён текущий текст и метрики).",
1391
+ }
1392
+ )
1393
+ return _pack_result(stopped_early=True, stop_reason="user_cancelled")
1394
+
1395
  while stage_idx < len(STAGE_ORDER) and _is_stage_complete(
1396
  STAGE_ORDER[stage_idx], current_metrics, bert_stage_target=bert_stage_target
1397
  ):
 
1429
  logs.append({"step": step + 1, "status": "stopped", "reason": "No sentences available for editing."})
1430
  break
1431
 
1432
+ _emit(
1433
+ "step_start",
1434
+ step=step + 1,
1435
+ total_steps=total_loop_steps,
1436
+ active_stage=active_stage,
1437
+ goal_type=goal.get("type"),
1438
+ goal_label=goal.get("label"),
1439
+ score=current_metrics.get("score"),
1440
+ )
1441
+
1442
  goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
1443
  base_attempt_cursor = int(goal_attempt_cursor.get(goal_key, 0))
1444
  span_trials = 2 if cascade_level <= 2 else 3
 
1489
  for strategy_variant in strategy_plan:
1490
  candidate_idx += 1
1491
  temp = min(1.1, max(0.0, temperature + (candidate_idx - 1) * 0.07))
1492
+ if _cancelled():
1493
+ logs.append(
1494
+ {
1495
+ "step": step + 1,
1496
+ "status": "stopped",
1497
+ "reason": "Остановка пользователем перед запросом к LLM.",
1498
+ }
1499
+ )
1500
+ return _pack_result(stopped_early=True, stop_reason="user_cancelled")
1501
+ _emit(
1502
+ "llm_call",
1503
+ candidate_index=candidate_idx,
1504
+ span_trial=st + 1,
1505
+ span_trials=span_trials,
1506
+ strategy=strategy_variant,
1507
+ )
1508
  try:
1509
  llm_result = _llm_edit_chunk(
1510
  api_key=api_key,
 
2119
  cascade_level = 1
2120
  goal_attempt_cursor[goal_key] = 0
2121
 
2122
+ return _pack_result()
 
 
 
 
 
 
 
 
 
 
static/js/app.js ADDED
The diff for this file is too large to render. See raw diff
 
templates/index.html CHANGED
The diff for this file is too large to render. See raw diff