Spaces:
Running
Running
Revert "Optimizer: SSE progress, cancel job with partial result, UI progress card"
Browse filesThis reverts commit b6a4468ec1517d6b3943366319a5ff2181c7ec81.
- app.py +1 -76
- docs/FULL_FUNCTIONAL_DOCUMENTATION.md +1 -6
- models.py +1 -7
- optimizer.py +2 -104
- templates/index.html +8 -168
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
|
| 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`
|
| 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 |
-
|
| 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,
|
| 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
|
| 332 |
-
<button
|
| 333 |
-
<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 =
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
|