Spaces:
Runtime error
Runtime error
new
#1
by coingimp - opened
- 2026-02-07_12-32-45.png +0 -0
- 2026-02-07_13-16-47.png +0 -0
- 2026-02-07_19-13-16.png +0 -0
- 2026-02-07_23-21-43.png +0 -0
- 2026-02-08_00-16-10.png +0 -0
- Dockerfile +1 -3
- README.md +9 -55
- app.py +5 -255
- docs/FULL_FUNCTIONAL_DOCUMENTATION.md +0 -640
- docs/HF_SES_AND_UI.md +0 -28
- docs/TEXT_OPTIMIZER_PRINCIPLES.md +0 -99
- highlighter.py +0 -63
- logic.py +1 -3
- main.py +1 -1
- models.py +4 -119
- nlp_processor.py +0 -51
- optimizer.py +0 -0
- requirements.txt +1 -4
- search.py +0 -96
- semantic_graph.py +0 -304
- static/js/app.js +0 -0
- summarizer.py +0 -41
- templates/index.html +554 -182
- url_fetcher.py +0 -143
2026-02-07_12-32-45.png
ADDED
|
2026-02-07_13-16-47.png
ADDED
|
2026-02-07_19-13-16.png
ADDED
|
2026-02-07_23-21-43.png
ADDED
|
2026-02-08_00-16-10.png
ADDED
|
Dockerfile
CHANGED
|
@@ -18,9 +18,7 @@ RUN python -m spacy download en_core_web_sm && \
|
|
| 18 |
python -m spacy download ru_core_news_sm && \
|
| 19 |
python -m spacy download de_core_news_sm && \
|
| 20 |
python -m spacy download es_core_news_sm && \
|
| 21 |
-
python -m spacy download it_core_news_sm
|
| 22 |
-
python -m spacy download pl_core_news_sm && \
|
| 23 |
-
python -m spacy download pt_core_news_sm
|
| 24 |
|
| 25 |
# Копирование приложения
|
| 26 |
COPY . .
|
|
|
|
| 18 |
python -m spacy download ru_core_news_sm && \
|
| 19 |
python -m spacy download de_core_news_sm && \
|
| 20 |
python -m spacy download es_core_news_sm && \
|
| 21 |
+
python -m spacy download it_core_news_sm
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Копирование приложения
|
| 24 |
COPY . .
|
README.md
CHANGED
|
@@ -11,18 +11,15 @@ license: mit
|
|
| 11 |
|
| 12 |
# SEO AI Editor
|
| 13 |
|
| 14 |
-
Веб-приложение для анализа и оптимизации SEO-текстов с использованием искусственного интеллекта. Проект использует лингвистический анализ (spaCy), частотный анализ (BM25)
|
| 15 |
|
| 16 |
## 🚀 Возможности
|
| 17 |
|
| 18 |
-
- **Многоязычный анализ**: Поддержка русского, английского, немецкого, испанского
|
| 19 |
- **N-граммный анализ**: Статистика по униграммам, биграммам, триграммам и квадриграммам
|
| 20 |
- **BM25 рекомендации**: Автоматические рекомендации по добавлению/удалению слов и фраз
|
| 21 |
- **BERT семантический анализ**: Глубокий анализ релевантности текста ключевым фразам с использованием нейронных сетей
|
| 22 |
- **Сравнение с конкурентами**: Детальное сравнение вашего текста с текстами конкурентов
|
| 23 |
-
- **Title Analyzer**: Анализ title-тегов (длина, покрытие ключей, n-граммы, BERT)
|
| 24 |
-
- **Semantic Core (TextAnalyst Web Core)**: Семантический граф, разметка текста, реферат и смысловой поиск
|
| 25 |
-
- **Таблица мощных терминов**: Сравнение слов и фраз с сортировкой по `Мой вес`, `Avg K` и `Freq (X/Y)`
|
| 26 |
- **GPU ускорение**: Автоматическое использование GPU для ускорения BERT-анализа
|
| 27 |
|
| 28 |
## 📋 Требования
|
|
@@ -65,20 +62,18 @@ python -m spacy download ru_core_news_sm
|
|
| 65 |
python -m spacy download de_core_news_sm
|
| 66 |
python -m spacy download es_core_news_sm
|
| 67 |
python -m spacy download it_core_news_sm
|
| 68 |
-
python -m spacy download pl_core_news_sm
|
| 69 |
-
python -m spacy download pt_core_news_sm
|
| 70 |
```
|
| 71 |
|
| 72 |
## 🏃 Запуск
|
| 73 |
|
| 74 |
Запустите приложение:
|
| 75 |
```bash
|
| 76 |
-
python
|
| 77 |
```
|
| 78 |
|
| 79 |
Или используйте uvicorn напрямую:
|
| 80 |
```bash
|
| 81 |
-
uvicorn
|
| 82 |
```
|
| 83 |
|
| 84 |
Приложение будет доступно по адресу: `http://127.0.0.1:8001`
|
|
@@ -94,7 +89,7 @@ uvicorn app:app --host 127.0.0.1 --port 8001 --reload
|
|
| 94 |
|
| 95 |
### Интерфейс результатов
|
| 96 |
|
| 97 |
-
Приложение предоставляет
|
| 98 |
|
| 99 |
#### 🧠 BERT Семантика
|
| 100 |
- **Общий рейтинг релевантности**: Сравнение вашего текста и конкурентов по среднему сходству с ключевыми фразами
|
|
@@ -118,49 +113,25 @@ uvicorn app:app --host 127.0.0.1 --port 8001 --reload
|
|
| 118 |
- Сравнение с конкурентами
|
| 119 |
- Подсветка важных различий
|
| 120 |
|
| 121 |
-
#### 🏷️ Title
|
| 122 |
-
- Длина title (ваш vs конкуренты)
|
| 123 |
-
- Покрытие ключевых фраз и отсутствующие слова
|
| 124 |
-
- N-граммы title и BERT-сходство
|
| 125 |
-
|
| 126 |
-
#### 🧩 Semantic Core
|
| 127 |
-
- Построение семантического графа (узлы и связи)
|
| 128 |
-
- Разметка значимых понятий в тексте
|
| 129 |
-
- Автоматический реферат (extractive summary)
|
| 130 |
-
- Смысловой поиск по словам и фразам
|
| 131 |
-
- Сравнительная таблица мощных терминов (слова + фразы)
|
| 132 |
-
|
| 133 |
## 🏗️ Архитектура проекта
|
| 134 |
|
| 135 |
```
|
| 136 |
seo_ai_editor/
|
| 137 |
-
├──
|
| 138 |
-
├── main.py # Легаси MVP entrypoint
|
| 139 |
├── logic.py # Основная бизнес-логика анализа
|
| 140 |
├── models.py # Pydantic модели для API
|
| 141 |
-
├── nlp_processor.py # NLP-предобработка для Semantic Core
|
| 142 |
-
├── semantic_graph.py # Графовая семантика и веса терминов
|
| 143 |
-
├── highlighter.py # Гипертекстовая разметка
|
| 144 |
-
├── summarizer.py # Реферат (сжатие предложений)
|
| 145 |
-
├── search.py # Смысловой поиск
|
| 146 |
├── requirements.txt # Зависимости проекта
|
| 147 |
├── templates/
|
| 148 |
│ └── index.html # Frontend интерфейс
|
| 149 |
└── README.md # Документация
|
| 150 |
```
|
| 151 |
|
| 152 |
-
## 📚 Локальная документация
|
| 153 |
-
|
| 154 |
-
- `docs/FULL_FUNCTIONAL_DOCUMENTATION.md` — полное описание функционала, алгоритмов и API
|
| 155 |
-
- `docs/ARCHITECTURE.md` — архитектура системы и взаимодействие модулей
|
| 156 |
-
- `docs/DEVELOPMENT.md` — практики разработки, процессы и заметки по изменениям
|
| 157 |
-
|
| 158 |
### Основные компоненты
|
| 159 |
|
| 160 |
-
#### `
|
| 161 |
- FastAPI приложение
|
| 162 |
-
- Роутинг: `/`
|
| 163 |
-
-
|
| 164 |
|
| 165 |
#### `logic.py`
|
| 166 |
Содержит три основных модуля:
|
|
@@ -237,25 +208,10 @@ Pydantic модели:
|
|
| 237 |
"bert_analysis": {
|
| 238 |
"global_scores": [...],
|
| 239 |
"detailed": [...]
|
| 240 |
-
},
|
| 241 |
-
"title_analysis": {
|
| 242 |
-
"...": "..."
|
| 243 |
}
|
| 244 |
}
|
| 245 |
```
|
| 246 |
|
| 247 |
-
### Endpoint: `/api/v1/semantic/analyze`
|
| 248 |
-
|
| 249 |
-
**Метод:** POST
|
| 250 |
-
|
| 251 |
-
**Назначение:** Построение семантического графа, разметки, реферата и сравнения с конкурентами.
|
| 252 |
-
|
| 253 |
-
### Endpoint: `/api/v1/semantic/search`
|
| 254 |
-
|
| 255 |
-
**Метод:** POST
|
| 256 |
-
|
| 257 |
-
**Назначение:** Смысловой поиск по выбранному документу (поддержка слов и фраз).
|
| 258 |
-
|
| 259 |
## ⚙️ Настройки
|
| 260 |
|
| 261 |
### Языки
|
|
@@ -265,8 +221,6 @@ Pydantic модели:
|
|
| 265 |
- `de` - немецкий
|
| 266 |
- `es` - испанский
|
| 267 |
- `it` - итальянский
|
| 268 |
-
- `pl` - польский
|
| 269 |
-
- `pt` - португальский
|
| 270 |
|
| 271 |
### BERT модель
|
| 272 |
По умолчанию используется `paraphrase-multilingual-MiniLM-L12-v2`. Модель можно изменить в функции `get_bert_model()` в файле `logic.py`.
|
|
|
|
| 11 |
|
| 12 |
# SEO AI Editor
|
| 13 |
|
| 14 |
+
Веб-приложение для анализа и оптимизации SEO-текстов с использованием искусственного интеллекта. Проект использует лингвистический анализ (spaCy), частотный анализ (BM25) и семантический анализ (BERT) для сравнения вашего текста с текстами конкурентов.
|
| 15 |
|
| 16 |
## 🚀 Возможности
|
| 17 |
|
| 18 |
+
- **Многоязычный анализ**: Поддержка русского, английского, немецкого, испанского и итальянского языков
|
| 19 |
- **N-граммный анализ**: Статистика по униграммам, биграммам, триграммам и квадриграммам
|
| 20 |
- **BM25 рекомендации**: Автоматические рекомендации по добавлению/удалению слов и фраз
|
| 21 |
- **BERT семантический анализ**: Глубокий анализ релевантности текста ключевым фразам с использованием нейронных сетей
|
| 22 |
- **Сравнение с конкурентами**: Детальное сравнение вашего текста с текстами конкурентов
|
|
|
|
|
|
|
|
|
|
| 23 |
- **GPU ускорение**: Автоматическое использование GPU для ускорения BERT-анализа
|
| 24 |
|
| 25 |
## 📋 Требования
|
|
|
|
| 62 |
python -m spacy download de_core_news_sm
|
| 63 |
python -m spacy download es_core_news_sm
|
| 64 |
python -m spacy download it_core_news_sm
|
|
|
|
|
|
|
| 65 |
```
|
| 66 |
|
| 67 |
## 🏃 Запуск
|
| 68 |
|
| 69 |
Запустите приложение:
|
| 70 |
```bash
|
| 71 |
+
python main.py
|
| 72 |
```
|
| 73 |
|
| 74 |
Или используйте uvicorn напрямую:
|
| 75 |
```bash
|
| 76 |
+
uvicorn main:app --host 127.0.0.1 --port 8001 --reload
|
| 77 |
```
|
| 78 |
|
| 79 |
Приложение будет доступно по адресу: `http://127.0.0.1:8001`
|
|
|
|
| 89 |
|
| 90 |
### Интерфейс результатов
|
| 91 |
|
| 92 |
+
Приложение предоставляет три вкладки с результатами:
|
| 93 |
|
| 94 |
#### 🧠 BERT Семантика
|
| 95 |
- **Общий рейтинг релевантности**: Сравнение вашего текста и конкурентов по среднему сходству с ключевыми фразами
|
|
|
|
| 113 |
- Сравнение с конкурентами
|
| 114 |
- Подсветка важных различий
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
## 🏗️ Архитектура проекта
|
| 117 |
|
| 118 |
```
|
| 119 |
seo_ai_editor/
|
| 120 |
+
├── main.py # FastAPI приложение и роутинг
|
|
|
|
| 121 |
├── logic.py # Основная бизнес-логика анализа
|
| 122 |
├── models.py # Pydantic модели для API
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
├── requirements.txt # Зависимости проекта
|
| 124 |
├── templates/
|
| 125 |
│ └── index.html # Frontend интерфейс
|
| 126 |
└── README.md # Документация
|
| 127 |
```
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
### Основные компоненты
|
| 130 |
|
| 131 |
+
#### `main.py`
|
| 132 |
- FastAPI приложение
|
| 133 |
+
- Роутинг: `/` (главная страница) и `/analyze` (API анализа)
|
| 134 |
+
- Предзагрузка моделей при старте
|
| 135 |
|
| 136 |
#### `logic.py`
|
| 137 |
Содержит три основных модуля:
|
|
|
|
| 208 |
"bert_analysis": {
|
| 209 |
"global_scores": [...],
|
| 210 |
"detailed": [...]
|
|
|
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
}
|
| 213 |
```
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
## ⚙️ Настройки
|
| 216 |
|
| 217 |
### Языки
|
|
|
|
| 221 |
- `de` - немецкий
|
| 222 |
- `es` - испанский
|
| 223 |
- `it` - итальянский
|
|
|
|
|
|
|
| 224 |
|
| 225 |
### BERT модель
|
| 226 |
По умолчанию используется `paraphrase-multilingual-MiniLM-L12-v2`. Модель можно изменить в функции `get_bert_model()` в файле `logic.py`.
|
app.py
CHANGED
|
@@ -1,50 +1,17 @@
|
|
| 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
|
| 10 |
-
from fastapi.staticfiles import StaticFiles
|
| 11 |
from fastapi.templating import Jinja2Templates
|
| 12 |
import uvicorn
|
| 13 |
import torch
|
| 14 |
import os
|
| 15 |
|
| 16 |
-
from models import
|
| 17 |
-
|
| 18 |
-
AnalysisResponse,
|
| 19 |
-
SemanticAnalyzeRequest,
|
| 20 |
-
SemanticAnalyzeResponse,
|
| 21 |
-
SemanticSearchRequest,
|
| 22 |
-
SemanticSearchResponse,
|
| 23 |
-
UrlFetchRequest,
|
| 24 |
-
UrlFetchResponse,
|
| 25 |
-
UserAgentsResponse,
|
| 26 |
-
OptimizerRequest,
|
| 27 |
-
OptimizerResponse,
|
| 28 |
-
OptimizerCancelRequest,
|
| 29 |
-
)
|
| 30 |
-
import logic
|
| 31 |
-
import nlp_processor
|
| 32 |
-
import semantic_graph
|
| 33 |
-
import highlighter
|
| 34 |
-
import summarizer
|
| 35 |
-
import search
|
| 36 |
-
import url_fetcher
|
| 37 |
-
import optimizer
|
| 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 |
|
|
@@ -52,13 +19,11 @@ templates = Jinja2Templates(directory="templates")
|
|
| 52 |
async def startup_event():
|
| 53 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 54 |
print(f"🚀 Application starting. ML Device: {device}")
|
| 55 |
-
#
|
| 56 |
-
# не загружаем все spaCy модели на старте, чтобы не ловить OOM/Restarting.
|
| 57 |
-
# Модели подгружаются лениво в logic.get_doc() по факту запроса.
|
| 58 |
|
| 59 |
@app.get("/", response_class=HTMLResponse)
|
| 60 |
async def read_root(request: Request):
|
| 61 |
-
return templates.TemplateResponse(
|
| 62 |
|
| 63 |
@app.post("/analyze", response_model=AnalysisResponse)
|
| 64 |
async def analyze_text(request: AnalysisRequest):
|
|
@@ -126,221 +91,6 @@ async def analyze_text(request: AnalysisRequest):
|
|
| 126 |
title_analysis=title_data
|
| 127 |
)
|
| 128 |
|
| 129 |
-
|
| 130 |
-
@app.post("/api/v1/semantic/analyze", response_model=SemanticAnalyzeResponse)
|
| 131 |
-
async def semantic_analyze(request: SemanticAnalyzeRequest):
|
| 132 |
-
def _build_doc_semantic(text: str, doc_name: str, doc_id: int):
|
| 133 |
-
sentences_data = nlp_processor.preprocess_text(text, request.language)
|
| 134 |
-
graph, word_weights = semantic_graph.build_semantic_graph(
|
| 135 |
-
sentences_data,
|
| 136 |
-
lang=request.language,
|
| 137 |
-
)
|
| 138 |
-
graph_data = semantic_graph.get_graph_data_for_frontend(graph)
|
| 139 |
-
markup_text = highlighter.generate_markup_for_frontend(
|
| 140 |
-
sentences_data, word_weights, threshold=request.threshold
|
| 141 |
-
)
|
| 142 |
-
summary_data = summarizer.generate_summary(
|
| 143 |
-
sentences_data, word_weights, compression_ratio=request.compression_ratio
|
| 144 |
-
)
|
| 145 |
-
top_keywords = semantic_graph.get_top_keywords(word_weights, top_n=20)
|
| 146 |
-
return {
|
| 147 |
-
"id": doc_id,
|
| 148 |
-
"name": doc_name,
|
| 149 |
-
"text": text,
|
| 150 |
-
"graph": graph_data,
|
| 151 |
-
"markup_text": markup_text,
|
| 152 |
-
"summary": summary_data,
|
| 153 |
-
"top_keywords": top_keywords,
|
| 154 |
-
"word_weights": word_weights,
|
| 155 |
-
"stats": {
|
| 156 |
-
"nodes": len(graph_data.get("nodes", [])),
|
| 157 |
-
"links": len(graph_data.get("links", [])),
|
| 158 |
-
"summary_sentences": len(summary_data),
|
| 159 |
-
},
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
target_doc = _build_doc_semantic(request.text, "Мой текст", 0)
|
| 163 |
-
|
| 164 |
-
competitor_docs = []
|
| 165 |
-
valid_competitors = [c for c in request.competitors if c.strip()]
|
| 166 |
-
for idx, comp_text in enumerate(valid_competitors):
|
| 167 |
-
competitor_docs.append(_build_doc_semantic(comp_text, f"Конкурент #{idx + 1}", idx + 1))
|
| 168 |
-
|
| 169 |
-
if competitor_docs:
|
| 170 |
-
avg_nodes = round(sum(c["stats"]["nodes"] for c in competitor_docs) / len(competitor_docs), 2)
|
| 171 |
-
avg_links = round(sum(c["stats"]["links"] for c in competitor_docs) / len(competitor_docs), 2)
|
| 172 |
-
else:
|
| 173 |
-
avg_nodes = 0
|
| 174 |
-
avg_links = 0
|
| 175 |
-
|
| 176 |
-
# Таблица сравнения "мощных терминов" (слова + фразы).
|
| 177 |
-
num_competitors = len(competitor_docs)
|
| 178 |
-
target_weights = target_doc.get("word_weights", {})
|
| 179 |
-
all_terms = set(target_weights.keys())
|
| 180 |
-
for comp in competitor_docs:
|
| 181 |
-
all_terms.update(comp.get("word_weights", {}).keys())
|
| 182 |
-
|
| 183 |
-
term_power_table = []
|
| 184 |
-
for term in all_terms:
|
| 185 |
-
target_weight = int(target_weights.get(term, 0))
|
| 186 |
-
comp_weights = [int(comp.get("word_weights", {}).get(term, 0)) for comp in competitor_docs]
|
| 187 |
-
comp_avg_weight = round(sum(comp_weights) / max(num_competitors, 1), 2)
|
| 188 |
-
comp_occurrence = sum(1 for w in comp_weights if w > 0)
|
| 189 |
-
|
| 190 |
-
term_power_table.append(
|
| 191 |
-
{
|
| 192 |
-
"term": term,
|
| 193 |
-
"term_type": "phrase" if " " in term else "word",
|
| 194 |
-
"target_weight": target_weight,
|
| 195 |
-
"competitor_avg_weight": comp_avg_weight,
|
| 196 |
-
"competitor_weights": comp_weights,
|
| 197 |
-
"comp_occurrence": comp_occurrence,
|
| 198 |
-
"comp_total": num_competitors,
|
| 199 |
-
}
|
| 200 |
-
)
|
| 201 |
-
|
| 202 |
-
term_power_table.sort(
|
| 203 |
-
key=lambda x: (
|
| 204 |
-
max([x["target_weight"]] + x["competitor_weights"]),
|
| 205 |
-
x["comp_occurrence"],
|
| 206 |
-
x["term"],
|
| 207 |
-
),
|
| 208 |
-
reverse=True,
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
-
comparison = {
|
| 212 |
-
"target_nodes": target_doc["stats"]["nodes"],
|
| 213 |
-
"target_links": target_doc["stats"]["links"],
|
| 214 |
-
"avg_comp_nodes": avg_nodes,
|
| 215 |
-
"avg_comp_links": avg_links,
|
| 216 |
-
"num_competitors": num_competitors,
|
| 217 |
-
"term_power_table": term_power_table,
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
return SemanticAnalyzeResponse(
|
| 221 |
-
target=target_doc,
|
| 222 |
-
competitors=competitor_docs,
|
| 223 |
-
comparison=comparison,
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
@app.post("/api/v1/semantic/search", response_model=SemanticSearchResponse)
|
| 228 |
-
async def semantic_search_endpoint(request: SemanticSearchRequest):
|
| 229 |
-
sentences_data = nlp_processor.preprocess_text(request.text, request.language)
|
| 230 |
-
graph, word_weights = semantic_graph.build_semantic_graph(
|
| 231 |
-
sentences_data,
|
| 232 |
-
lang=request.language,
|
| 233 |
-
)
|
| 234 |
-
results = search.semantic_search(
|
| 235 |
-
request.query_text,
|
| 236 |
-
graph,
|
| 237 |
-
word_weights,
|
| 238 |
-
request.language,
|
| 239 |
-
top_n=request.top_n,
|
| 240 |
-
)
|
| 241 |
-
return SemanticSearchResponse(results=results)
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
@app.get("/api/v1/url/user-agents", response_model=UserAgentsResponse)
|
| 245 |
-
async def get_user_agents():
|
| 246 |
-
return UserAgentsResponse(user_agents=url_fetcher.get_user_agent_presets())
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
@app.post("/api/v1/url/fetch", response_model=UrlFetchResponse)
|
| 250 |
-
async def fetch_url_endpoint(request: UrlFetchRequest):
|
| 251 |
-
try:
|
| 252 |
-
parsed = url_fetcher.fetch_url_content(
|
| 253 |
-
url=request.url,
|
| 254 |
-
user_agent_key=request.user_agent,
|
| 255 |
-
timeout_seconds=request.timeout_seconds,
|
| 256 |
-
)
|
| 257 |
-
return UrlFetchResponse(**parsed)
|
| 258 |
-
except Exception as e:
|
| 259 |
-
return UrlFetchResponse(
|
| 260 |
-
ok=False,
|
| 261 |
-
url=request.url or "",
|
| 262 |
-
user_agent_key=request.user_agent or "",
|
| 263 |
-
error=str(e),
|
| 264 |
-
)
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
@app.post("/api/v1/optimizer/run", response_model=OptimizerResponse)
|
| 268 |
-
async def run_optimizer(request: OptimizerRequest):
|
| 269 |
-
try:
|
| 270 |
-
result = optimizer.optimize_text(request.model_dump())
|
| 271 |
-
return OptimizerResponse(**result)
|
| 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))
|
|
|
|
| 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
|
| 8 |
import os
|
| 9 |
|
| 10 |
+
from models import AnalysisRequest, AnalysisResponse
|
| 11 |
+
import logic
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
app = FastAPI(title="SEO AI Editor MVP")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# Подключаем папку с шаблонами
|
| 16 |
templates = Jinja2Templates(directory="templates")
|
| 17 |
|
|
|
|
| 19 |
async def startup_event():
|
| 20 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 21 |
print(f"🚀 Application starting. ML Device: {device}")
|
| 22 |
+
logic.load_models() # spaCy preload (optional)
|
|
|
|
|
|
|
| 23 |
|
| 24 |
@app.get("/", response_class=HTMLResponse)
|
| 25 |
async def read_root(request: Request):
|
| 26 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 27 |
|
| 28 |
@app.post("/analyze", response_model=AnalysisResponse)
|
| 29 |
async def analyze_text(request: AnalysisRequest):
|
|
|
|
| 91 |
title_analysis=title_data
|
| 92 |
)
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# Hugging Face Spaces использует порт 7860
|
| 95 |
if __name__ == "__main__":
|
| 96 |
port = int(os.environ.get("PORT", 7860))
|
docs/FULL_FUNCTIONAL_DOCUMENTATION.md
DELETED
|
@@ -1,640 +0,0 @@
|
|
| 1 |
-
# SEO AI Editor — исчерпывающая документация функционала, логики и алгоритмики
|
| 2 |
-
|
| 3 |
-
Документ описывает приложение как инженерную спецификацию: что делает каждый модуль, какие данные принимает и возвращает, какие формулы использует, какие ограничения применяет и как воспроизвести поведение системы без чтения исходного кода.
|
| 4 |
-
|
| 5 |
-
---
|
| 6 |
-
|
| 7 |
-
## 1) Концепция приложения
|
| 8 |
-
|
| 9 |
-
`SEO AI Editor` объединяет два аналитических контура и один контур улучшения текста:
|
| 10 |
-
|
| 11 |
-
1. **SEO-контур** (`POST /analyze`)
|
| 12 |
-
- Word Count (total/significant)
|
| 13 |
-
- N-gram анализ (1..4)
|
| 14 |
-
- BM25-рекомендации (`add/remove/ok`)
|
| 15 |
-
- BERT-семантика по ключам
|
| 16 |
-
- Title-анализ (length/ngrams/coverage/BERT)
|
| 17 |
-
|
| 18 |
-
2. **Semantic Core** (`POST /api/v1/semantic/analyze`, `POST /api/v1/semantic/search`)
|
| 19 |
-
- NLP-разбор и лемматизация
|
| 20 |
-
- семантический граф (слова + фразы)
|
| 21 |
-
- веса узлов и связей в шкале `1..100`
|
| 22 |
-
- гипертекстовая разметка
|
| 23 |
-
- реферат
|
| 24 |
-
- смысловой поиск по словам и фразам
|
| 25 |
-
- сравнение с конкурентами (включая таблицу мощных терминов)
|
| 26 |
-
|
| 27 |
-
3. **LLM Optimizer** (`POST /api/v1/optimizer/run` или в UI — SSE `run-stream`)
|
| 28 |
-
- итеративная локальная оптимизация текста
|
| 29 |
-
- многокритериальный скоринг с защитой от деградации
|
| 30 |
-
- каскад уровней правок (от минимальных к более широким)
|
| 31 |
-
- детализированный debug-лог по кандидатам
|
| 32 |
-
|
| 33 |
-
---
|
| 34 |
-
|
| 35 |
-
## 2) Архитектура и ответственность файлов
|
| 36 |
-
|
| 37 |
-
- `app.py` — FastAPI оркестратор, endpoint-ы, связывание модулей.
|
| 38 |
-
- `models.py` — Pydantic-модели входов/выходов.
|
| 39 |
-
- `logic.py` — SEO-ядро: токены, n-grams, BM25, BERT, Title.
|
| 40 |
-
- `nlp_processor.py` — NLP-предобработка для semantic-контура.
|
| 41 |
-
- `semantic_graph.py` — построение графа и вычисление смысловых весов.
|
| 42 |
-
- `highlighter.py` — разметка текста по semantic-весам.
|
| 43 |
-
- `summarizer.py` — генерация реферата.
|
| 44 |
-
- `search.py` — смысловой поиск в графе (фразы + слова).
|
| 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 |
-
|
| 53 |
-
## 3) Поддерживаемые языки
|
| 54 |
-
|
| 55 |
-
Поддерживаемые языки анализа:
|
| 56 |
-
- `ru`, `en`, `de`, `es`, `it`, `pl`, `pt`
|
| 57 |
-
|
| 58 |
-
Языки задаются кодом и сопоставляются с spaCy-моделями в `logic.py` (`MODEL_NAMES`).
|
| 59 |
-
|
| 60 |
-
---
|
| 61 |
-
|
| 62 |
-
## 4) Backend API и контракты
|
| 63 |
-
|
| 64 |
-
## 4.1 `POST /analyze`
|
| 65 |
-
|
| 66 |
-
### Назначение
|
| 67 |
-
Комплексный SEO-анализ target-текста относительно конкурентов и ключевых фраз.
|
| 68 |
-
|
| 69 |
-
### Вход (`AnalysisRequest`)
|
| 70 |
-
- `target_text: str`
|
| 71 |
-
- `competitors: List[str]`
|
| 72 |
-
- `keywords: List[str]`
|
| 73 |
-
- `language: str`
|
| 74 |
-
- `target_title: str`
|
| 75 |
-
- `competitor_titles: List[str]`
|
| 76 |
-
|
| 77 |
-
### Выход (`AnalysisResponse`)
|
| 78 |
-
- `ngram_stats`
|
| 79 |
-
- `bm25_recommendations`
|
| 80 |
-
- `bert_analysis`
|
| 81 |
-
- `word_counts`
|
| 82 |
-
- `title_analysis`
|
| 83 |
-
|
| 84 |
-
### Оркестрация в `app.py`
|
| 85 |
-
1. Word counts (`count_words`) для target и каждого competitor.
|
| 86 |
-
2. N-gram статистика (`calculate_ngram_stats`).
|
| 87 |
-
3. Нормализация ключей (`parse_keywords`) и BM25 (`calculate_bm25_recommendations`).
|
| 88 |
-
4. BERT-анализ (`perform_bert_analysis`).
|
| 89 |
-
5. Title-анализ (`analyze_title`) если `target_title` не пустой.
|
| 90 |
-
|
| 91 |
-
---
|
| 92 |
-
|
| 93 |
-
## 4.2 `POST /api/v1/semantic/analyze`
|
| 94 |
-
|
| 95 |
-
### Назначение
|
| 96 |
-
Построение semantic-среза по target и конкурентам.
|
| 97 |
-
|
| 98 |
-
### Вход (`SemanticAnalyzeRequest`)
|
| 99 |
-
- `text: str`
|
| 100 |
-
- `competitors: List[str]`
|
| 101 |
-
- `language: str`
|
| 102 |
-
- `threshold: int` (порог подсветки)
|
| 103 |
-
- `compression_ratio: float` (доля предложений �� реферате)
|
| 104 |
-
|
| 105 |
-
### Выход (`SemanticAnalyzeResponse`)
|
| 106 |
-
- `target`:
|
| 107 |
-
- `graph` (`nodes`, `links`)
|
| 108 |
-
- `markup_text`
|
| 109 |
-
- `summary`
|
| 110 |
-
- `top_keywords`
|
| 111 |
-
- `word_weights`
|
| 112 |
-
- `stats`
|
| 113 |
-
- `competitors[]`: тот же формат
|
| 114 |
-
- `comparison`:
|
| 115 |
-
- `target_nodes`, `target_links`
|
| 116 |
-
- `avg_comp_nodes`, `avg_comp_links`
|
| 117 |
-
- `num_competitors`
|
| 118 |
-
- `term_power_table`
|
| 119 |
-
|
| 120 |
-
### Логика таблицы `term_power_table`
|
| 121 |
-
Для каждого термина из объединения target + competitors:
|
| 122 |
-
- `target_weight`
|
| 123 |
-
- `competitor_avg_weight`
|
| 124 |
-
- `competitor_weights` (`K1..Kn`)
|
| 125 |
-
- `comp_occurrence` (`X` в `X/Y`)
|
| 126 |
-
- `comp_total` (`Y`)
|
| 127 |
-
- `term_type` (`word` или `phrase`)
|
| 128 |
-
|
| 129 |
-
---
|
| 130 |
-
|
| 131 |
-
## 4.3 `POST /api/v1/semantic/search`
|
| 132 |
-
|
| 133 |
-
### Назначение
|
| 134 |
-
Смысловой поиск по документу через граф.
|
| 135 |
-
|
| 136 |
-
### Вход (`SemanticSearchRequest`)
|
| 137 |
-
- `query_text`
|
| 138 |
-
- `text`
|
| 139 |
-
- `language`
|
| 140 |
-
- `top_n`
|
| 141 |
-
|
| 142 |
-
### Выход (`SemanticSearchResponse`)
|
| 143 |
-
- `results[]`: `lemma`, `score (1..100)`, `type (word|phrase)`
|
| 144 |
-
|
| 145 |
-
---
|
| 146 |
-
|
| 147 |
-
## 4.4 URL Import API
|
| 148 |
-
|
| 149 |
-
### `GET /api/v1/url/user-agents`
|
| 150 |
-
Возвращает список пресетов user-agent для выбора в UI.
|
| 151 |
-
|
| 152 |
-
### `POST /api/v1/url/fetch`
|
| 153 |
-
Извлекает `title` и основной `text` страницы:
|
| 154 |
-
- вход: `url`, `user_agent`, `timeout_seconds`
|
| 155 |
-
- выход: `ok`, `status_code`, `title`, `text`, `error`, `final_url`, agent-метаданные.
|
| 156 |
-
|
| 157 |
-
Обработка ошибок не ломает UI: endpoint возвращает `ok=false` и `error`.
|
| 158 |
-
|
| 159 |
-
---
|
| 160 |
-
|
| 161 |
-
## 4.5 `POST /api/v1/optimizer/run`
|
| 162 |
-
|
| 163 |
-
### Назначение
|
| 164 |
-
Итеративная локальная дооптимизация target-текста через LLM.
|
| 165 |
-
|
| 166 |
-
### Вход (`OptimizerRequest`)
|
| 167 |
-
- аналитические данные: `target_text`, `competitors`, `keywords`, `language`, `target_title`, `competitor_titles`
|
| 168 |
-
- LLM: `api_key`, `api_base_url`, `model`, `temperature`
|
| 169 |
-
- стратегия: `max_iterations`, `candidates_per_iteration`, `optimization_mode`, `phrase_strategy_mode`, `bert_stage_target`
|
| 170 |
-
- `phrase_strategy_mode`: `auto | distributed_preferred | exact_preferred | ensemble`
|
| 171 |
-
- `ensemble`: в пределах итерации циклически пробует несколько phrase-стратегий и ранжирует кандидаты общей utility-функцией.
|
| 172 |
-
- `bert_stage_target`: пользовательский порог завершения этапа A (BERT), например `0.61` вместо `0.70`.
|
| 173 |
-
|
| 174 |
-
### Выход (`OptimizerResponse`)
|
| 175 |
-
- `optimized_text` — итоговый body (target)
|
| 176 |
-
- `optimized_title` — итоговая строка для поля **Title**; в ответе она берётся из снимка `title_analysis.target_title` (тот же текст, что учитывался в метрике Title BERT), с запасным вариантом из переменной оптимизатора. В `final_metrics` дополнительно есть `resolved_title` с тем же смыслом (удобно для UI/fallback).
|
| 177 |
-
- `baseline_metrics`, `final_metrics`
|
| 178 |
-
- `iterations[]` (подробный лог шагов)
|
| 179 |
-
- `applied_changes`
|
| 180 |
-
- `optimization_mode`
|
| 181 |
-
- `phrase_strategy_mode`
|
| 182 |
-
- `bert_stage_target`
|
| 183 |
-
- `stopped_early`, `stop_reason` — при ручной остановке (частичный результат)
|
| 184 |
-
- `error` (если есть)
|
| 185 |
-
|
| 186 |
-
### `POST /api/v1/optimizer/run-stream` (SSE)
|
| 187 |
-
Тело как у `run`. Поток `text/event-stream`, события `job` (с `job_id`), `preparing`, `started`, `step_start`, `llm_call`, затем `complete` с полем `result` или `error`.
|
| 188 |
-
|
| 189 |
-
### `POST /api/v1/optimizer/cancel`
|
| 190 |
-
Тело: `{"job_id": "..."}`. Только флаг отмены; клиент дочитывает SSE до `complete`.
|
| 191 |
-
|
| 192 |
-
### UI и HF/SES
|
| 193 |
-
Клиентский код: **`static/js/app.js`** + `GET /static/js/app.js`. Прогресс оптимизатора — **локальная панель с текстовым логом** (и тонкая полоса), **без** `#loader`. Подробности про SES и «мёртвые кнопки»: `docs/HF_SES_AND_UI.md`.
|
| 194 |
-
|
| 195 |
-
---
|
| 196 |
-
|
| 197 |
-
## 5) Подробная алгоритмика по модулям
|
| 198 |
-
|
| 199 |
-
## 5.1 `logic.py` — SEO-ядро
|
| 200 |
-
|
| 201 |
-
### `load_model_if_missing(lang)`
|
| 202 |
-
Ленивая загрузка spaCy-модели конкретного языка. Цель: не загружать все модели на старте (критично для HF ресурсов).
|
| 203 |
-
|
| 204 |
-
### `load_models()`
|
| 205 |
-
Служебная массовая загрузка моделей (используется ограниченно; основной путь в проде — lazy).
|
| 206 |
-
|
| 207 |
-
### `get_doc(text, lang)`
|
| 208 |
-
Единая точка получения spaCy `Doc` с предобработкой языка/модели.
|
| 209 |
-
|
| 210 |
-
### `is_valid_token(t)`
|
| 211 |
-
Фильтр значимых токенов (исключает шумовые категории: punctuation/space/часть stop и др.).
|
| 212 |
-
|
| 213 |
-
### `get_lemmas_flat(text, lang)`
|
| 214 |
-
Плоский список лемм значимых токенов. Базовый строительный блок для метрик.
|
| 215 |
-
|
| 216 |
-
### `generate_ngrams_safe(text, lang, n)` — Smart Window
|
| 217 |
-
Ключевой принцип:
|
| 218 |
-
- размер окна задается по **значимым** словам;
|
| 219 |
-
- stop-слова внутри валидного окна могут сохраняться для естественных фраз;
|
| 220 |
-
- символные границы (punct/num/sym) не дают сшивать ложные фразы.
|
| 221 |
-
|
| 222 |
-
Это гарантирует более естественные n-grams и согласованность между разными подсистемами.
|
| 223 |
-
|
| 224 |
-
### `count_words(text, lang)`
|
| 225 |
-
Возвращает:
|
| 226 |
-
- `total` — количество словоформ
|
| 227 |
-
- `significant` — количество значимых токенов после фильтра
|
| 228 |
-
|
| 229 |
-
### `calculate_ngram_stats(target_text, competitor_texts, lang)`
|
| 230 |
-
Строит частотные словари 1..4-грамм, агрегирует:
|
| 231 |
-
- частоты target
|
| 232 |
-
- средние частоты competitors
|
| 233 |
-
- сигналы дефицита/избытка
|
| 234 |
-
- детализацию по каждому конкуренту (для интерфейсных таблиц)
|
| 235 |
-
|
| 236 |
-
### `parse_keywords(raw_phrases, lang)`
|
| 237 |
-
Нормализует сырые ключи пользователя в:
|
| 238 |
-
- фразовые ключи
|
| 239 |
-
- униграммы
|
| 240 |
-
с учетом текущего языка и лемматизации.
|
| 241 |
-
|
| 242 |
-
### `calculate_bm25_recommendations(...)` — Mirror Principle
|
| 243 |
-
BM25 использует тот же подход токенизации/фразогенерации, что и n-gram ядро.
|
| 244 |
-
Смысл:
|
| 245 |
-
- сравнить релевантность target и среднего competitor-профиля по тем же термам;
|
| 246 |
-
- выдать действие:
|
| 247 |
-
- `add` — недобор терма,
|
| 248 |
-
- `remove` — вероятный переспам,
|
| 249 |
-
- `ok` — баланс.
|
| 250 |
-
|
| 251 |
-
### `get_bert_model()`
|
| 252 |
-
Ленивая инициализация sentence-transformers модели.
|
| 253 |
-
|
| 254 |
-
### `perform_bert_analysis(target_text, competitor_texts, key_phrases, lang)`
|
| 255 |
-
Для каждой ключевой фразы:
|
| 256 |
-
- ищет наиболее близкие чанки текста;
|
| 257 |
-
- считает similarity для target и competitors;
|
| 258 |
-
- формирует детализацию (`my_max_score`, competitor-сравнение, статусы).
|
| 259 |
-
|
| 260 |
-
### `analyze_title(target_title, competitor_titles, raw_keywords, lang)`
|
| 261 |
-
Оркестратор Title-пайплайна:
|
| 262 |
-
- `_title_length`
|
| 263 |
-
- `_title_ngrams`
|
| 264 |
-
- `_title_keyword_coverage`
|
| 265 |
-
- `_title_bert`
|
| 266 |
-
|
| 267 |
-
#### `_title_length(...)`
|
| 268 |
-
Сравнивает длину target title с конкурентным диапазоном/средним.
|
| 269 |
-
|
| 270 |
-
#### `_title_ngrams(...)`
|
| 271 |
-
N-gram сопоставление title-уровня.
|
| 272 |
-
|
| 273 |
-
#### `_title_keyword_coverage(...)`
|
| 274 |
-
Проверяет покрытие пользовательских ключей в target и competitor title.
|
| 275 |
-
|
| 276 |
-
#### `_title_bert(...)`
|
| 277 |
-
Оценивает semantic-близость title к ключевому набору.
|
| 278 |
-
|
| 279 |
-
---
|
| 280 |
-
|
| 281 |
-
## 5.2 `nlp_processor.py`
|
| 282 |
-
|
| 283 |
-
### `preprocess_text(text, lang)`
|
| 284 |
-
Преобразует текст в структуру предложений:
|
| 285 |
-
- `raw_text`
|
| 286 |
-
- `tokens[]` с полями `text`, `whitespace`, `lemma`, `is_significant`, `is_punct`, `is_space`
|
| 287 |
-
- `lemmas_clean` (очищенный список лемм)
|
| 288 |
-
|
| 289 |
-
Критично: сохранение `whitespace` и исходных токенов позволяет восстановить текст UI-послойно без потери форматирования.
|
| 290 |
-
|
| 291 |
-
---
|
| 292 |
-
|
| 293 |
-
## 5.3 `semantic_graph.py` — математическое ядро
|
| 294 |
-
|
| 295 |
-
### `_normalize_to_1_100(values)`
|
| 296 |
-
Нормализация произвольных весов в целочисленную шкалу `1..100`.
|
| 297 |
-
|
| 298 |
-
### `_extract_significant_lemmas(sent)`
|
| 299 |
-
Достает значимые леммы из предложения.
|
| 300 |
-
|
| 301 |
-
### `_is_noise_sentence(text)`
|
| 302 |
-
Отбрасывает шумовые фрагменты (короткие CTA, boilerplate-паттерны).
|
| 303 |
-
|
| 304 |
-
### `_canonicalize_term(term)`
|
| 305 |
-
Rule-based каноникализация термов (снижение дублей и вариативности).
|
| 306 |
-
|
| 307 |
-
### `_extract_phrase_candidates(sentence_text, lang)`
|
| 308 |
-
Извлечение кандидатных фраз через `generate_ngrams_safe` и фильтры.
|
| 309 |
-
|
| 310 |
-
### `_normalize_lemma_sequence(lemmas)`
|
| 311 |
-
Нормализация последовательностей лемм для устранения артефактов.
|
| 312 |
-
|
| 313 |
-
### `build_semantic_graph(sentences_data, lang)`
|
| 314 |
-
Базовые шаги:
|
| 315 |
-
1. Сформировать множество терминов (слова + фразы).
|
| 316 |
-
2. Подсчитать частоты терминов и совместные появления.
|
| 317 |
-
3. Постр��ить направленный граф.
|
| 318 |
-
4. Рассчитать вес ребра:
|
| 319 |
-
- `P(B|A) = cooc(A,B) / occ(A) * 100`
|
| 320 |
-
- затем ограничение в `0..100`.
|
| 321 |
-
5. Рассчитать важность узлов:
|
| 322 |
-
- PageRank как глобальная связность,
|
| 323 |
-
- termness/coverage корректировки,
|
| 324 |
-
- штрафы для слишком общих доменных токенов.
|
| 325 |
-
6. Вернуть граф и карту `word_weights`.
|
| 326 |
-
|
| 327 |
-
### `get_graph_data_for_frontend(graph, top_edges_per_node=8)`
|
| 328 |
-
Сериализует `networkx` граф в плоский JSON:
|
| 329 |
-
- `nodes[]`
|
| 330 |
-
- `links[]`
|
| 331 |
-
с ограничением числа ребер на узел для управляемого рендера.
|
| 332 |
-
|
| 333 |
-
### `get_top_keywords(node_weights, top_n=20)`
|
| 334 |
-
Возвращает top-N терминов по весу.
|
| 335 |
-
|
| 336 |
-
---
|
| 337 |
-
|
| 338 |
-
## 5.4 `highlighter.py`
|
| 339 |
-
|
| 340 |
-
### `generate_markup_for_frontend(sentences_data, word_weights, threshold=50)`
|
| 341 |
-
Маркирует важные блоки:
|
| 342 |
-
- если вес леммы/фразы >= порога, блок становится `is_link=true`;
|
| 343 |
-
- соседние значимые токены могут объединяться в один кликабельный сегмент;
|
| 344 |
-
- возвращается структура, удобная для реактивного рендера в UI.
|
| 345 |
-
|
| 346 |
-
---
|
| 347 |
-
|
| 348 |
-
## 5.5 `summarizer.py`
|
| 349 |
-
|
| 350 |
-
### `generate_summary(sentences_data, word_weights, compression_ratio)`
|
| 351 |
-
Скоринг предложения:
|
| 352 |
-
- `score = sum(weight(unique_lemmas)) / sqrt(token_count)`
|
| 353 |
-
|
| 354 |
-
Далее:
|
| 355 |
-
1. сортировка по score убыв.
|
| 356 |
-
2. выбор top по `compression_ratio`
|
| 357 |
-
3. восстановление хронологического порядка для читабельности.
|
| 358 |
-
|
| 359 |
-
---
|
| 360 |
-
|
| 361 |
-
## 5.6 `search.py`
|
| 362 |
-
|
| 363 |
-
### `_normalize_query_text(text)`
|
| 364 |
-
Нормализует запрос для устойчивого поиска.
|
| 365 |
-
|
| 366 |
-
### `semantic_search(query_text, G, word_weights, language, top_n)`
|
| 367 |
-
Алгоритм:
|
| 368 |
-
1. Нормализовать и лемматизировать запрос.
|
| 369 |
-
2. Приоритетно проверить фразы (tri/bi) из запроса.
|
| 370 |
-
3. Fallback на слова.
|
| 371 |
-
4. Для найденных точек входа добавить соседей по графу.
|
| 372 |
-
5. Собрать score из силы связи и веса узла.
|
| 373 |
-
6. Нормализовать score в `1..100`.
|
| 374 |
-
7. Вернуть top-N и тип (`phrase`/`word`).
|
| 375 |
-
|
| 376 |
-
---
|
| 377 |
-
|
| 378 |
-
## 5.7 `url_fetcher.py`
|
| 379 |
-
|
| 380 |
-
### `get_user_agent_presets()`
|
| 381 |
-
Возвращает список пресетов (Googlebot, Bingbot, ChatGPT user-agent, GPTBot, Chrome Desktop и др.).
|
| 382 |
-
|
| 383 |
-
### `_normalize_whitespace(text)`
|
| 384 |
-
Схлопывает лишние пробелы/переводы строк.
|
| 385 |
-
|
| 386 |
-
### `_normalize_url(url)`
|
| 387 |
-
Приводит URL к валидному виду (схема, trimming).
|
| 388 |
-
|
| 389 |
-
### `_resolve_user_agent(user_agent_key)`
|
| 390 |
-
По ключу выбирает фактическую строку user-agent.
|
| 391 |
-
|
| 392 |
-
### `_extract_main_text_and_title(html)`
|
| 393 |
-
HTML extraction pipeline:
|
| 394 |
-
- удалить `script/style/noscript/nav/footer/header/form/svg` и прочий boilerplate;
|
| 395 |
-
- приоритетно извлекать `article/main`;
|
| 396 |
-
- fallback на абзацы/списки;
|
| 397 |
-
- final fallback на `body` текст;
|
| 398 |
-
- вернуть очищенный `title` и основной `text`.
|
| 399 |
-
|
| 400 |
-
### `fetch_url_content(url, user_agent_key, timeout_seconds)`
|
| 401 |
-
Выполняет HTTP-запрос и возвращает структурированный результат для UI/API.
|
| 402 |
-
|
| 403 |
-
---
|
| 404 |
-
|
| 405 |
-
## 5.8 `optimizer.py` — LLM-оптимизация текста
|
| 406 |
-
|
| 407 |
-
### Цель модуля
|
| 408 |
-
Итеративно улучшать конкретные проблемные зоны из аналитики, избегая полной перегенерации текста и сохраняя стиль/повествование.
|
| 409 |
-
|
| 410 |
-
### Служебные функции подготовки
|
| 411 |
-
- `_tokenize` — токенизация строки.
|
| 412 |
-
- `_filter_stopwords` — удаление stop-слов.
|
| 413 |
-
- `_split_sentences` — сегментация на предложения.
|
| 414 |
-
- `_max_sentences_for_level` — лимиты длины кандидата по каскаду.
|
| 415 |
-
- `_validate_candidate_text` — pre-check качества (пустота, дубль слова/сущности, подозрительные токен-склейки, превышение лимита предложений).
|
| 416 |
-
|
| 417 |
-
### Снимки аналитики
|
| 418 |
-
- `_build_analysis_snapshot` — пересчет `/analyze` локально.
|
| 419 |
-
- `_build_semantic_snapshot` — пересчет semantic среза локально.
|
| 420 |
-
|
| 421 |
-
### Скоринг и выбор цели
|
| 422 |
-
- `_compute_metrics` — единый набор метрик состояния:
|
| 423 |
-
- composite score
|
| 424 |
-
- `bert_low_count`
|
| 425 |
-
- `bert_phrase_scores`
|
| 426 |
-
- `bm25_remove_count`
|
| 427 |
-
- сигналы n-gram/semantic
|
| 428 |
-
- `title_bert_score`
|
| 429 |
-
- `semantic_gap_count`, `semantic_gap_sum`, `semantic_gap_terms`
|
| 430 |
-
- `_choose_optimization_goal` — выбирает приоритетную проблему.
|
| 431 |
-
- для BERT-целей используется порог `BERT_TARGET_THRESHOLD=0.7` (допустимый рабочий минимум может быть снижен до `0.65`), без конкурентного override;
|
| 432 |
-
- `_choose_sentence_idx` — выбирает релевантный чанк для правки.
|
| 433 |
-
|
| 434 |
-
### Параметры Semantic Gap в оптимизаторе
|
| 435 |
-
- `SEMANTIC_GAP_TOLERANCE_PCT = 0.15` — допускается отклонение до ~15% от target, без штрафа.
|
| 436 |
-
- `SEMANTIC_GAP_MIN_ABS = 3.0` — игнорируются микрошумы с маленькой абсолютной разницей.
|
| 437 |
-
- Gap считается значимым, если одновременно:
|
| 438 |
-
- `competitor_avg_weight > target_weight * (1 + tolerance)`
|
| 439 |
-
- `competitor_avg_weight - target_weight >= min_abs`
|
| 440 |
-
|
| 441 |
-
Это убирает пере-жесткую подгонку к среднему конкуренту и снижает ложные колебания `semantic_gap_count`.
|
| 442 |
-
|
| 443 |
-
### Генерация кандидатов
|
| 444 |
-
- `_llm_edit_chunk` — отправляет structured prompt в OpenAI-compatible API.
|
| 445 |
-
- роль модели в prompt: **semantic-vector optimizer for SEO**, а не общий “copy editor”.
|
| 446 |
-
- учитывает `cascade_level` и тип операции (`rewrite`/`insert`)
|
| 447 |
-
- явно требует грамматически корректный и естественный текст
|
| 448 |
-
- ограничивает число предложений по уровню
|
| 449 |
-
- для BERT динамически выбирает стратегию по длине целевой фразы:
|
| 450 |
-
- короткие цели: допустим один natural exact match;
|
| 451 |
-
- длинные multi-word цели: приоритет у distributed semantic coverage (части фразы/леммы/близкие формулировки), без forced exact match.
|
| 452 |
-
- exact phrase не должен повторяться: при неестественном звучании он запрещается в пользу распределённой формулировки.
|
| 453 |
-
- для `rewrite` явно требует сохранить исходный смысл `sentence-by-sentence` и не менять субъект/ключевую сущность без необходимости.
|
| 454 |
-
|
| 455 |
-
### Применение правок
|
| 456 |
-
- `_replace_span` — замена диапазона предложений.
|
| 457 |
-
- `_insert_after` — вставка после диапазона.
|
| 458 |
-
|
| 459 |
-
### Принятие/отклонение кандидата
|
| 460 |
-
- `_goal_improved`:
|
| 461 |
-
- для BERT: улучшение score целевой фразы минимум на `BERT_GOAL_DELTA_MIN=0.005` **или** снижение `bert_low_count`;
|
| 462 |
-
- для других целей: профильные метрики улучшения.
|
| 463 |
-
- `_candidate_utility`:
|
| 464 |
-
- многоцелевая функция полезности кандидата с динамическими весами;
|
| 465 |
-
- учитывает одновременно `bert_phrase_delta`, `chunk_goal_delta`, `score_delta`;
|
| 466 |
-
- добавляет мягкие штрафы за регрессии по BM25/BERT-low/N-gram/SemanticGap/Title;
|
| 467 |
-
- в BERT-push режиме (когда фраза ниже порога) усиливает вес phrase-level прогресса.
|
| 468 |
-
- `_is_candidate_valid`:
|
| 469 |
-
- hard constraints (не ухудшать критичные метрики сверх допустимого);
|
| 470 |
-
- режимы `conservative/balanced/aggressive` задают пороги регрессии;
|
| 471 |
-
- решение учитывает и `goal_improved`, и общий `delta_score`.
|
| 472 |
-
- `_is_stage_complete` для `bert`:
|
| 473 |
-
- этап считается завершённым только когда **каждая** отслеживаемая ключевая фраза достигает `bert_stage_target` (проверка по `min(bert_phrase_scores)`);
|
| 474 |
-
- достижение порога одной «сильной» фразой больше не завершает BERT-этап.
|
| 475 |
-
- унифицированный цикл по целям: базовые параметры запроса `max_iterations` и `candidates_per_iteration` задают «якорь», но для **каждой** цели вычисляется эффективный бюджет (`_per_goal_budget`): число попыток и ширина пула кандидатов **масштабируются по дефициту** до таргета — для BERT по разрыву score до порога, для semantic по `semantic_gap`, для n-gram по отставанию/перегрузу относительно целевого счётчика, для BM25 по «лишним» вхождениям слова, для title по разрыву `title_bert_score`. После исчерпания лимита по текущей цели оптимизатор переходит к следующей цели той же стадии.
|
| 476 |
-
- `_validate_candidate_text`:
|
| 477 |
-
- отклоняет некачественные/спамные кандидаты (дубли слов/сущностей, подозрительные склейки токенов);
|
| 478 |
-
- добавляет anti-stuffing фильтр для цели BERT (повторы exact phrase и чрезмерные повторы focus-термов).
|
| 479 |
-
|
| 480 |
-
### Главная функция `optimize_text`
|
| 481 |
-
Итерационный цикл:
|
| 482 |
-
1. baseline metrics.
|
| 483 |
-
- общий бюджет шагов оценивается как **сумма эффективных итераций по всем целям** (`_estimate_total_loop_budget`: для каждой цели — `_per_goal_budget`, затем сумма по стадиям с верхней отсечкой), то есть масштабируется и по числу целей, и по величине отставания от таргета. В SSE-событии `step_start` дополнительно передаются `goal_budget_iter` и `goal_budget_candidates` для текущей цели.
|
| 484 |
-
2. выбрать goal.
|
| 485 |
-
3. выбрать пул чанков и операцию каскада.
|
| 486 |
-
- **Этап `title`:** если средняя BERT-близость Title к ключам (`title_bert_score`) ниже порога (`TITLE_TARGET_THRESHOLD` ≈ 0.65), цель — **только переписать текст из поля Title** (`target_title`), а не абзац основного текста. LLM получает текущий title, выдержку из body и ключевые слова; метрики пересчитываются с новым title. Пакетные правки по body с title не смешиваются.
|
| 487 |
-
- **Проверка деплоя:** в debug кандидата для шага `title` в `llm_prompt_debug` должно быть `"operation": "title_rewrite"`, а `chunk_text` — короткая строка текущего Title. Если видите `"operation": "rewrite"` и длинный `chunk_text` из body — на сервере старая версия `optimizer.py` (или не пересобран образ).
|
| 488 |
-
- на шаг выбирается несколько span-кандидатов (multi-chunk selection), а не один;
|
| 489 |
-
- ранжирование учитывает `focus_terms/avoid_terms`, chunk-level relevance и шумовые эвристики (menu/CTA/header penalties);
|
| 490 |
-
- для **n-gram** целей предложения ранжируются через **скользящие перекрывающиеся окна** из 2–4 предложений (шаг 1): каждому предложению присваивается лучший балл среди окон, оценка штрафует локальные повторы фразы и шумовые блоки;
|
| 491 |
-
- для BERT-целей ранжирование не ограничивается участками с already-present вхождениями: дополнительно приоритизируются релевантные участки с недопредставленными core-термами, где их можно добавить естественно;
|
| 492 |
-
- используется `attempt_cursor` по цели и `attempted_spans`, чтобы избежать циклов по одному и тому же участку.
|
| 493 |
-
4. сгенерировать `N` кандидатов для каждого выбранного span (`N` зависит от эффективного бюджета кандидатов для цели и каскада, см. `_per_goal_budget` и деление по span).
|
| 494 |
-
5. pre-validation (формат/качество/длины).
|
| 495 |
-
6. chunk-level оценка:
|
| 496 |
-
- вычисляется `chunk_goal_delta` (релевантность чанка до/после к текущей цели);
|
| 497 |
-
- кандидат помечается `local_chunk_improved`, если прирост выше порога цели.
|
| 498 |
-
7. document-level оценка:
|
| 499 |
-
- полный пересчет метрик для всего текста;
|
| 500 |
-
- проверка глобальных ограничений через `_is_candidate_valid`.
|
| 501 |
-
8. staged acceptance:
|
| 502 |
-
- если кандидат улучшает цель/общий score и проходит глобальные ограничения — `applied`;
|
| 503 |
-
- если локально улучшает чанк, но глобально не проходит — кандидат кладется в queue.
|
| 504 |
-
- для BERT учитывается прямой документный `bert_phrase_delta` по целевой фразе: даже небольшой положительный рост считается полезным шагом при отсутствии регре��сий по guardrails.
|
| 505 |
-
- если нет `promotable` кандидата, но есть guardrail-valid кандидат с `local_chunk_improved`, применяется режим `applied_local_progress`: правка принимается локально и оптимизация переходит к следующему чанку (накопительная стратегия).
|
| 506 |
-
- ранжирование и выбор best-кандидата дополнительно учитывают `candidate_utility`, чтобы BERT-оптимизация не вредила следующим этапам по другим метрикам.
|
| 507 |
-
9. batch-логика queue:
|
| 508 |
-
- optimizer пробует совместно применить комбинации из 2..4 локально сильных не конфликтующих правок;
|
| 509 |
-
- batch принимается только при прохождении глобальных ограничений и положительном совокупном локальном приросте.
|
| 510 |
-
10. при серии неудач эскалировать каскад (`L1 -> L2 -> L3 -> L4`), при успехе сбрасывать на `L1`.
|
| 511 |
-
- `L1`: локальный rewrite (обычно 1 предложение),
|
| 512 |
-
- `L2`: rewrite расширенного окна (2-3 предложения, несколько вариантов радиусов),
|
| 513 |
-
- `L3`: insert bridge (вставка) с возможным fallback на rewrite,
|
| 514 |
-
- `L4`: более широкий rewrite окна (до 5 предложений с вариативным охватом).
|
| 515 |
-
11. вести подробный лог по каждому кандидату.
|
| 516 |
-
- в debug-таблице фиксируются и chunk-level сигналы (`local+`, `chunk Δ`, `rel before->after`) наряду с глобальными (`Δ score`, `valid`, `goal+`);
|
| 517 |
-
- для каждого кандидата сохраняется `llm_prompt_debug` (операция, цель, фокус-термы, chunk и ближайший контекст), что позволяет анализировать фактический вход в LLM;
|
| 518 |
-
- LLM возвращает поле `rationale` (1 строка) — краткое объяснение, почему правка должна повысить релевантность цели.
|
| 519 |
-
- также сохраняется `metrics_delta` (вклад BM25/BERT/Semantic/N-gram/Title в общий сдвиг), включая `semantic_gap_sum` и изменение состава gap-термов (`semantic_gap_terms_added/removed`), чтобы видеть, за счет чего падает или растет `score`.
|
| 520 |
-
|
| 521 |
-
---
|
| 522 |
-
|
| 523 |
-
## 6) Frontend (`templates/index.html` + `static/js/app.js`)
|
| 524 |
-
|
| 525 |
-
## 6.1 Ввод данных и URL import
|
| 526 |
-
- `loadUserAgentOptions` — загрузка пресетов UA.
|
| 527 |
-
- `fetchUrlPayload` — запрос к URL API.
|
| 528 |
-
- `fetchTargetFromUrl` — заполнение target text/title из URL.
|
| 529 |
-
- `fetchCompetitorsFromUrls` — массовое заполнение competitors.
|
| 530 |
-
|
| 531 |
-
Ручной ввод всегда остается рабочим fallback-сценарием.
|
| 532 |
-
|
| 533 |
-
## 6.2 Локальное сохранение проекта
|
| 534 |
-
- `saveProject` — экспорт JSON.
|
| 535 |
-
- `loadProject` — загрузка JSON.
|
| 536 |
-
- `applyProjectData` — восстановление полей и результатов.
|
| 537 |
-
- `clearProject` — новый проект/сброс.
|
| 538 |
-
|
| 539 |
-
API-ключ оптимизатора в persist-состояние не сохраняется.
|
| 540 |
-
|
| 541 |
-
## 6.3 Запуск аналитики и отрисовка
|
| 542 |
-
- `runAnalysis`
|
| 543 |
-
- `runSemanticAnalysis`
|
| 544 |
-
- `runSemanticSearch`
|
| 545 |
-
- `renderResults`
|
| 546 |
-
- `renderSemanticResults`
|
| 547 |
-
- `renderTitleResults`
|
| 548 |
-
- `showNgramTable`
|
| 549 |
-
|
| 550 |
-
## 6.4 Сводка и оптимизатор
|
| 551 |
-
- `renderActionSummary` — агрегирует рекомендации BERT/BM25/N-grams/Title/Semantic в табличный формат.
|
| 552 |
-
- `runLlmOptimization` — `POST /api/v1/optimizer/run-stream` (SSE); локальная панель **лога** + тонкий progress bar; **без** `#loader`.
|
| 553 |
-
- `requestStopOptimizer` — `POST /api/v1/optimizer/cancel`; поток дочитывается до `complete` (частичный результат).
|
| 554 |
-
- `optimizerLogAppend` / `applyOptimizerStreamEvent` — текстовый ход работы.
|
| 555 |
-
- `renderOptimizerResults` — итог и debug-лог; баннер при `stopped_early`.
|
| 556 |
-
- `applyOptimizedText` — перенос optimized текста в `target_text`.
|
| 557 |
-
- `nv(v, d)` — nullish-fallback без операторов `??` (SES на HF).
|
| 558 |
-
|
| 559 |
-
## 6.5 Сортировка таблицы мощных терминов
|
| 560 |
-
- `setSemanticTermSortBy`
|
| 561 |
-
- `toggleSemanticTermSortDir`
|
| 562 |
-
|
| 563 |
-
Поддерживаются сортировки по:
|
| 564 |
-
- `Мой вес`
|
| 565 |
-
- `Avg K`
|
| 566 |
-
- `Freq (X/Y)` (с приоритетом большего `X` при одинаковом `Y`)
|
| 567 |
-
|
| 568 |
-
---
|
| 569 |
-
|
| 570 |
-
## 7) Данные и модели (`models.py`)
|
| 571 |
-
|
| 572 |
-
Ключевые модели:
|
| 573 |
-
- `AnalysisRequest`, `AnalysisResponse`
|
| 574 |
-
- `SemanticAnalyzeRequest`, `SemanticAnalyzeResponse`
|
| 575 |
-
- `SemanticSearchRequest`, `SemanticSearchResponse`
|
| 576 |
-
- `UrlFetchRequest`, `UrlFetchResponse`, `UserAgentInfo`, `UserAgentsResponse`
|
| 577 |
-
- `OptimizerRequest`, `OptimizerResponse`
|
| 578 |
-
|
| 579 |
-
Роль моделей:
|
| 580 |
-
- жестко фиксируют API-контракты;
|
| 581 |
-
- упрощают валидацию;
|
| 582 |
-
- создают стабильный интерфейс между frontend/backend.
|
| 583 |
-
|
| 584 |
-
---
|
| 585 |
-
|
| 586 |
-
## 8) Практические вычислительные принципы
|
| 587 |
-
|
| 588 |
-
1. **Согласованная нормализация**
|
| 589 |
-
Одни и те же правила токенизации/лемматизации используются в нескольких модулях, чтобы избежать рассинхронизации метрик.
|
| 590 |
-
|
| 591 |
-
2. **Локальные правки вместо полной перегенерации**
|
| 592 |
-
Оптимизатор меняет только локальные участки текста и проверяет эффект после каждой правки.
|
| 593 |
-
|
| 594 |
-
3. **Многокритериальная защита**
|
| 595 |
-
Кандидат не принимается, если улучшение одной метрики достигается ценой неприемлемой деградации других.
|
| 596 |
-
|
| 597 |
-
4. **Объяснимость**
|
| 598 |
-
Подробный лог итераций фиксирует baseline шага, кандидатов, причины отклонения и примененный вариант.
|
| 599 |
-
|
| 600 |
-
---
|
| 601 |
-
|
| 602 |
-
## 9) Рекомендации по воспроизведению приложения по документации
|
| 603 |
-
|
| 604 |
-
Минимальный путь воспроизведения:
|
| 605 |
-
1. Поднять FastAPI-приложение с endpoint-ами из раздела 4.
|
| 606 |
-
2. Реализовать `logic.py` и `semantic_*` модули с описанными формулами и пайплайнами.
|
| 607 |
-
3. Сделать frontend с соответствующими сценариями (`runAnalysis`, `runSemanticAnalysis`, `runLlmOptimization`).
|
| 608 |
-
4. Добавить URL extractor и LLM optimizer как отдельные backend сервисы.
|
| 609 |
-
5. Проверить контракты ответов, чтобы UI-таблицы и вкладки заполнялись без адаптеров.
|
| 610 |
-
|
| 611 |
-
---
|
| 612 |
-
|
| 613 |
-
## 10) Эксплуатация и деплой (Hugging Face)
|
| 614 |
-
|
| 615 |
-
- Рекомендуемый режим: lazy загрузка моделей.
|
| 616 |
-
- При проблемах типа `Could not resolve host: huggingface.co` рассматривать как внешнюю инфраструктурную проблему DNS/egress.
|
| 617 |
-
- Для диагностики сверять:
|
| 618 |
-
- `repo_sha` и `runtime_sha`
|
| 619 |
-
- `runtime_stage`
|
| 620 |
-
- При зависании сборки использовать повторный trigger-build.
|
| 621 |
-
|
| 622 |
-
---
|
| 623 |
-
|
| 624 |
-
## 11) Smoke-check после любых изменений
|
| 625 |
-
|
| 626 |
-
1. `python -m py_compile app.py logic.py semantic_graph.py search.py optimizer.py url_fetcher.py`
|
| 627 |
-
2. Проверка endpoint-ов:
|
| 628 |
-
- `/analyze`
|
| 629 |
-
- `/api/v1/semantic/analyze`
|
| 630 |
-
- `/api/v1/semantic/search`
|
| 631 |
-
- `/api/v1/url/user-agents`
|
| 632 |
-
- `/api/v1/url/fetch`
|
| 633 |
-
- `/api/v1/optimizer/run`
|
| 634 |
-
3. Проверка UI:
|
| 635 |
-
- табы рендерятся;
|
| 636 |
-
- сортировки и таблицы работают;
|
| 637 |
-
- URL import заполняет text/title;
|
| 638 |
-
- save/load/new project работают;
|
| 639 |
-
- оптимизатор пишет лог и применяет текст.
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/HF_SES_AND_UI.md
DELETED
|
@@ -1,28 +0,0 @@
|
|
| 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`** для остановки с частичным результатом.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/TEXT_OPTIMIZER_PRINCIPLES.md
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 1 |
-
# Text Optimizer Principles
|
| 2 |
-
|
| 3 |
-
This document is a living spec for iterative text optimization behavior.
|
| 4 |
-
Update it whenever optimization policy changes.
|
| 5 |
-
|
| 6 |
-
## 1) Multi-objective optimization model
|
| 7 |
-
|
| 8 |
-
- **Primary objective (by stage):**
|
| 9 |
-
- Stage A: BERT phrase relevance
|
| 10 |
-
- Stage B: BM25 remove cleanup
|
| 11 |
-
- Stage C: N-gram balancing
|
| 12 |
-
- Stage D: Semantic gap balancing
|
| 13 |
-
- Stage E: Title alignment
|
| 14 |
-
- **Guardrails (always active):**
|
| 15 |
-
- Do not allow critical metric regressions beyond mode tolerances.
|
| 16 |
-
- Keep grammar, coherence, and non-spam writing.
|
| 17 |
-
|
| 18 |
-
## 2) Stage order and skipping
|
| 19 |
-
|
| 20 |
-
- Stage order:
|
| 21 |
-
- `bert -> bm25 -> ngram -> semantic -> title`
|
| 22 |
-
- A stage is skipped if no actionable goal exists.
|
| 23 |
-
- Plateau rule:
|
| 24 |
-
- If no primary progress for 3 steps, move to next stage.
|
| 25 |
-
|
| 26 |
-
## 3) BERT stage policy
|
| 27 |
-
|
| 28 |
-
- Default Stage A threshold: `0.70`.
|
| 29 |
-
- User may set custom threshold via UI (`BERT target A-stage`), e.g. `0.61`.
|
| 30 |
-
- Stage A is complete when max target phrase score reaches configured threshold.
|
| 31 |
-
|
| 32 |
-
## 4) BM25 stage policy
|
| 33 |
-
|
| 34 |
-
- Main target: reduce/remove over-optimization signals.
|
| 35 |
-
- A stage is considered healthy when `bm25_remove_count <= 3`.
|
| 36 |
-
|
| 37 |
-
## 5) N-gram stage policy (quantitative)
|
| 38 |
-
|
| 39 |
-
- Goal: bring target counts closer to competitor average, not force exact equality.
|
| 40 |
-
- Tolerance bands:
|
| 41 |
-
- if `avg >= 4`: acceptable range is `avg +/- 20%`
|
| 42 |
-
- if `avg < 4`: acceptable range is `avg +/- 50%`
|
| 43 |
-
- N-gram signal is counted only when term is outside tolerance and present in enough competitors.
|
| 44 |
-
- Selection rules (multi-competitor mode, `competitors > 1`):
|
| 45 |
-
- bi-grams and tri-grams are eligible when present in `>= 2` competitors;
|
| 46 |
-
- unigrams are eligible only if they are part of user keyword phrases and present in `>= 2` competitors.
|
| 47 |
-
- Target ranking (which n-gram to work on next):
|
| 48 |
-
- sort eligible **underrepresented** rows by **Freq(K)** (`comp_occurrence`) descending,
|
| 49 |
-
then **Avg(K)** (`competitor_avg`) descending,
|
| 50 |
-
then **deviation** from competitor average descending (larger gap first).
|
| 51 |
-
- Iteration behavior:
|
| 52 |
-
- optimizer works on one n-gram target at a time per step;
|
| 53 |
-
- per eligible n-gram target it allocates `3` attempts, then moves to the next target;
|
| 54 |
-
- if target list ends, stage advances to the next optimization stage.
|
| 55 |
-
- **Global step budget:** the UI `max_iterations` cap still limits total loop iterations, but the
|
| 56 |
-
optimizer **adds** extra steps reserved for the n-gram stage (`targets × 3`, capped) so a low
|
| 57 |
-
`max_iterations` value does not stop the run after only three n-gram rows while many targets remain.
|
| 58 |
-
- **Chunk selection (n-gram stage):** candidate sentences are ranked using **overlapping multi-sentence
|
| 59 |
-
windows** (stride 1). Each sentence receives the best window score; windows favor low local phrase
|
| 60 |
-
duplication, topical overlap with phrase tokens, and non-noisy prose. Document-level phrase count
|
| 61 |
-
remains the primary acceptance signal.
|
| 62 |
-
|
| 63 |
-
## 5.1 Summary logic memory (current)
|
| 64 |
-
|
| 65 |
-
- Summary recommendation triggers:
|
| 66 |
-
- BERT warning when phrase score `< 0.70`;
|
| 67 |
-
- BM25 warning when `REMOVE >= 4`;
|
| 68 |
-
- N-gram warning when term is underrepresented among competitors;
|
| 69 |
-
- Title warning when Title BERT `< 0.65`;
|
| 70 |
-
- Semantic warning when keyword terms are weaker than competitor average.
|
| 71 |
-
- For N-grams in summary:
|
| 72 |
-
- summary renders top rows for readability, but optimizer runs against the full eligible candidate set.
|
| 73 |
-
|
| 74 |
-
## 6) Local acceptance and batch accumulation
|
| 75 |
-
|
| 76 |
-
- First evaluate candidate locally (chunk-level), then globally (document-level).
|
| 77 |
-
- Locally improved candidates may be queued when global score does not move yet.
|
| 78 |
-
- Non-conflicting queued edits can be applied as a batch (2-4 edits) if guardrails pass.
|
| 79 |
-
|
| 80 |
-
## 7) Text quality constraints
|
| 81 |
-
|
| 82 |
-
- Reject candidates with:
|
| 83 |
-
- duplicated entities/words,
|
| 84 |
-
- suspicious token joins,
|
| 85 |
-
- excessive sentence count for current cascade level,
|
| 86 |
-
- obvious stuffing/redundancy.
|
| 87 |
-
- Keep narrative continuity and original subject/entity focus.
|
| 88 |
-
|
| 89 |
-
## 8) Diagnostics requirements
|
| 90 |
-
|
| 91 |
-
- For every iteration, store:
|
| 92 |
-
- stage, goal, cascade level,
|
| 93 |
-
- candidate validity, local improvement, metric deltas,
|
| 94 |
-
- selected strategy and prompt debug payload.
|
| 95 |
-
- UI must show:
|
| 96 |
-
- stage progression,
|
| 97 |
-
- stage transitions,
|
| 98 |
-
- candidate strategy and reason for rejection.
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
highlighter.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
from typing import Dict, List, Any
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
def generate_markup_for_frontend(
|
| 5 |
-
sentences_data: List[Dict[str, Any]],
|
| 6 |
-
word_weights: Dict[str, int],
|
| 7 |
-
threshold: int = 50,
|
| 8 |
-
) -> List[Dict[str, Any]]:
|
| 9 |
-
"""
|
| 10 |
-
Генерирует разметку текста для фронтенда.
|
| 11 |
-
Возвращает массив предложений, где каждое предложение состоит из блоков:
|
| 12 |
-
- обычный текст
|
| 13 |
-
- кликабельное понятие (is_link = True)
|
| 14 |
-
"""
|
| 15 |
-
output: List[Dict[str, Any]] = []
|
| 16 |
-
|
| 17 |
-
for sent in sentences_data:
|
| 18 |
-
blocks: List[Dict[str, Any]] = []
|
| 19 |
-
buffer_text = ""
|
| 20 |
-
|
| 21 |
-
for tok in sent.get("tokens", []):
|
| 22 |
-
text_piece = f"{tok.get('text', '')}{tok.get('whitespace', '')}"
|
| 23 |
-
lemma = tok.get("lemma", "")
|
| 24 |
-
is_link = bool(tok.get("is_significant")) and word_weights.get(lemma, 0) >= threshold
|
| 25 |
-
|
| 26 |
-
if is_link:
|
| 27 |
-
if buffer_text:
|
| 28 |
-
blocks.append({"text": buffer_text, "is_link": False})
|
| 29 |
-
buffer_text = ""
|
| 30 |
-
# Склеиваем подряд идущие значимые токены в одну "понятийную" фразу,
|
| 31 |
-
# чтобы подсветка выглядела как словосочетание, а не набор одиночных слов.
|
| 32 |
-
if blocks and blocks[-1].get("is_link"):
|
| 33 |
-
blocks[-1]["text"] += text_piece
|
| 34 |
-
blocks[-1]["lemmas"].append(lemma)
|
| 35 |
-
blocks[-1]["weight"] = max(
|
| 36 |
-
int(blocks[-1].get("weight", 0)),
|
| 37 |
-
int(word_weights.get(lemma, 0)),
|
| 38 |
-
)
|
| 39 |
-
else:
|
| 40 |
-
blocks.append(
|
| 41 |
-
{
|
| 42 |
-
"text": text_piece,
|
| 43 |
-
"is_link": True,
|
| 44 |
-
"lemmas": [lemma],
|
| 45 |
-
"weight": int(word_weights.get(lemma, 0)),
|
| 46 |
-
}
|
| 47 |
-
)
|
| 48 |
-
else:
|
| 49 |
-
buffer_text += text_piece
|
| 50 |
-
|
| 51 |
-
if buffer_text:
|
| 52 |
-
blocks.append({"text": buffer_text, "is_link": False})
|
| 53 |
-
|
| 54 |
-
output.append(
|
| 55 |
-
{
|
| 56 |
-
"index": sent.get("index", 0),
|
| 57 |
-
"raw_text": sent.get("raw_text", ""),
|
| 58 |
-
"blocks": blocks,
|
| 59 |
-
"lemmas_clean": sent.get("lemmas_clean", []),
|
| 60 |
-
}
|
| 61 |
-
)
|
| 62 |
-
|
| 63 |
-
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logic.py
CHANGED
|
@@ -18,9 +18,7 @@ MODEL_NAMES = {
|
|
| 18 |
"ru": "ru_core_news_sm",
|
| 19 |
"de": "de_core_news_sm",
|
| 20 |
"es": "es_core_news_sm",
|
| 21 |
-
"it": "it_core_news_sm"
|
| 22 |
-
"pl": "pl_core_news_sm",
|
| 23 |
-
"pt": "pt_core_news_sm",
|
| 24 |
}
|
| 25 |
|
| 26 |
# --- SPACY (Лингвистика) ---
|
|
|
|
| 18 |
"ru": "ru_core_news_sm",
|
| 19 |
"de": "de_core_news_sm",
|
| 20 |
"es": "es_core_news_sm",
|
| 21 |
+
"it": "it_core_news_sm"
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
# --- SPACY (Лингвистика) ---
|
main.py
CHANGED
|
@@ -25,7 +25,7 @@ async def startup_event():
|
|
| 25 |
@app.get("/", response_class=HTMLResponse)
|
| 26 |
async def read_root(request: Request):
|
| 27 |
# Рендерим файл index.html
|
| 28 |
-
return templates.TemplateResponse(
|
| 29 |
|
| 30 |
@app.post("/analyze", response_model=AnalysisResponse)
|
| 31 |
async def analyze_text(request: AnalysisRequest):
|
|
|
|
| 25 |
@app.get("/", response_class=HTMLResponse)
|
| 26 |
async def read_root(request: Request):
|
| 27 |
# Рендерим файл index.html
|
| 28 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 29 |
|
| 30 |
@app.post("/analyze", response_model=AnalysisResponse)
|
| 31 |
async def analyze_text(request: AnalysisRequest):
|
models.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from pydantic import BaseModel
|
| 2 |
from typing import List, Dict, Optional, Any
|
| 3 |
|
| 4 |
|
|
@@ -10,128 +10,13 @@ class AnalysisRequest(BaseModel):
|
|
| 10 |
target_text: str # Текст пользователя
|
| 11 |
competitors: List[str] # Список текстов конкурентов
|
| 12 |
keywords: List[str] # Список ключевых фраз (сырых)
|
| 13 |
-
language: str = "en" # en, ru, de, es, it
|
| 14 |
target_title: str = "" # Title пользователя
|
| 15 |
-
competitor_titles: List[str] =
|
| 16 |
|
| 17 |
class AnalysisResponse(BaseModel):
|
| 18 |
ngram_stats: dict # Статистика униграм/биграм
|
| 19 |
bm25_recommendations: List[dict] # Рекомендации "добавить/убрать"
|
| 20 |
bert_analysis: Dict[str, Any] # Векторный анализ
|
| 21 |
word_counts: Dict[str, Any] # {'target': 500, 'competitors': [600, 450], 'avg': 525}
|
| 22 |
-
title_analysis: Dict[str, Any] =
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
class SemanticAnalyzeRequest(BaseModel):
|
| 26 |
-
text: str
|
| 27 |
-
competitors: List[str] = Field(default_factory=list)
|
| 28 |
-
language: str = "ru"
|
| 29 |
-
threshold: int = 50
|
| 30 |
-
compression_ratio: float = 0.1
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class SemanticAnalyzeResponse(BaseModel):
|
| 34 |
-
target: Dict[str, Any]
|
| 35 |
-
competitors: List[Dict[str, Any]]
|
| 36 |
-
comparison: Dict[str, Any]
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
class SemanticSearchRequest(BaseModel):
|
| 40 |
-
query_text: str
|
| 41 |
-
text: str
|
| 42 |
-
language: str = "ru"
|
| 43 |
-
top_n: int = 20
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class SemanticSearchResponse(BaseModel):
|
| 47 |
-
results: List[Dict[str, Any]]
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
class UrlFetchRequest(BaseModel):
|
| 51 |
-
url: str
|
| 52 |
-
user_agent: str = "chrome_desktop"
|
| 53 |
-
timeout_seconds: int = 15
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
class UrlFetchResponse(BaseModel):
|
| 57 |
-
ok: bool = False
|
| 58 |
-
url: str = ""
|
| 59 |
-
final_url: str = ""
|
| 60 |
-
status_code: int = 0
|
| 61 |
-
user_agent_key: str = ""
|
| 62 |
-
user_agent_value: str = ""
|
| 63 |
-
title: str = ""
|
| 64 |
-
text: str = ""
|
| 65 |
-
error: str = ""
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class UserAgentInfo(BaseModel):
|
| 69 |
-
key: str
|
| 70 |
-
name: str
|
| 71 |
-
value: str
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
class UserAgentsResponse(BaseModel):
|
| 75 |
-
user_agents: List[UserAgentInfo] = Field(default_factory=list)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
class OptimizerRequest(BaseModel):
|
| 79 |
-
target_text: str
|
| 80 |
-
competitors: List[str] = Field(default_factory=list)
|
| 81 |
-
keywords: List[str] = Field(default_factory=list)
|
| 82 |
-
language: str = "en"
|
| 83 |
-
target_title: str = ""
|
| 84 |
-
competitor_titles: List[str] = Field(default_factory=list)
|
| 85 |
-
# Base for highlighting what changed in this optimization run.
|
| 86 |
-
# - diff_from_input: compare with `target_text` passed in this request (snapshot before optimization)
|
| 87 |
-
# - diff_from_original: compare with `original_target_text` from the first snapshot in session
|
| 88 |
-
diff_mode: str = "diff_from_input" # diff_from_input | diff_from_original
|
| 89 |
-
original_target_text: Optional[str] = None
|
| 90 |
-
original_target_title: Optional[str] = None
|
| 91 |
-
|
| 92 |
-
api_key: str
|
| 93 |
-
api_base_url: str = "https://api.deepseek.com/v1"
|
| 94 |
-
model: str = "deepseek-chat"
|
| 95 |
-
|
| 96 |
-
max_iterations: int = 2
|
| 97 |
-
candidates_per_iteration: int = 2
|
| 98 |
-
temperature: float = 0.25
|
| 99 |
-
optimization_mode: str = "balanced"
|
| 100 |
-
phrase_strategy_mode: str = "auto" # auto | exact_preferred | distributed_preferred | ensemble
|
| 101 |
-
bert_stage_target: float = 0.70
|
| 102 |
-
# Optional stage control. If empty -> default full pipeline order.
|
| 103 |
-
enabled_stages: List[str] = Field(default_factory=list) # bert|bm25|ngram|semantic|title
|
| 104 |
-
# Per-stage manual goal selection and custom additions.
|
| 105 |
-
# Example:
|
| 106 |
-
# {
|
| 107 |
-
# "bm25": {"mode":"mixed","selected":["canadian online casino"],"custom_add":["online casinos canada"]},
|
| 108 |
-
# "bert": {"mode":"manual","selected":["best payout casinos"],"custom_add":[]}
|
| 109 |
-
# }
|
| 110 |
-
stage_goal_overrides: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
class OptimizerResponse(BaseModel):
|
| 114 |
-
ok: bool = True
|
| 115 |
-
optimized_text: str = ""
|
| 116 |
-
optimized_title: str = ""
|
| 117 |
-
baseline_metrics: Dict[str, Any] = Field(default_factory=dict)
|
| 118 |
-
final_metrics: Dict[str, Any] = Field(default_factory=dict)
|
| 119 |
-
iterations: List[Dict[str, Any]] = Field(default_factory=list)
|
| 120 |
-
applied_changes: int = 0
|
| 121 |
-
optimization_mode: str = "balanced"
|
| 122 |
-
phrase_strategy_mode: str = "auto"
|
| 123 |
-
bert_stage_target: float = 0.70
|
| 124 |
-
diff_mode: str = ""
|
| 125 |
-
# HTML with <mark class="diff-changed"> around changed parts.
|
| 126 |
-
diff_body_html: str = ""
|
| 127 |
-
diff_title_html: str = ""
|
| 128 |
-
# List of (type/from/to) blocks for "что именно поменять".
|
| 129 |
-
diff_changes: List[Dict[str, str]] = Field(default_factory=list)
|
| 130 |
-
diff_title_changes: List[Dict[str, str]] = Field(default_factory=list)
|
| 131 |
-
error: str = ""
|
| 132 |
-
stopped_early: bool = False
|
| 133 |
-
stop_reason: str = ""
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
class OptimizerCancelRequest(BaseModel):
|
| 137 |
-
job_id: str = Field(..., min_length=8)
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
from typing import List, Dict, Optional, Any
|
| 3 |
|
| 4 |
|
|
|
|
| 10 |
target_text: str # Текст пользователя
|
| 11 |
competitors: List[str] # Список текстов конкурентов
|
| 12 |
keywords: List[str] # Список ключевых фраз (сырых)
|
| 13 |
+
language: str = "en" # en, ru, de, es, it
|
| 14 |
target_title: str = "" # Title пользователя
|
| 15 |
+
competitor_titles: List[str] = [] # Title конкурентов
|
| 16 |
|
| 17 |
class AnalysisResponse(BaseModel):
|
| 18 |
ngram_stats: dict # Статистика униграм/биграм
|
| 19 |
bm25_recommendations: List[dict] # Рекомендации "добавить/убрать"
|
| 20 |
bert_analysis: Dict[str, Any] # Векторный анализ
|
| 21 |
word_counts: Dict[str, Any] # {'target': 500, 'competitors': [600, 450], 'avg': 525}
|
| 22 |
+
title_analysis: Dict[str, Any] = {} # Анализ Title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
nlp_processor.py
DELETED
|
@@ -1,51 +0,0 @@
|
|
| 1 |
-
from typing import Dict, List, Any
|
| 2 |
-
|
| 3 |
-
import logic
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
def preprocess_text(text: str, lang: str) -> List[Dict[str, Any]]:
|
| 7 |
-
"""
|
| 8 |
-
Разбирает текст на предложения и токены.
|
| 9 |
-
Сохраняет:
|
| 10 |
-
- исходный текст токена (text)
|
| 11 |
-
- пробел после токена (whitespace)
|
| 12 |
-
- лемму (lemma)
|
| 13 |
-
- флаг значимости (is_significant)
|
| 14 |
-
"""
|
| 15 |
-
if not text or not text.strip():
|
| 16 |
-
return []
|
| 17 |
-
|
| 18 |
-
doc = logic.get_doc(text, lang)
|
| 19 |
-
result: List[Dict[str, Any]] = []
|
| 20 |
-
|
| 21 |
-
for idx, sent in enumerate(doc.sents):
|
| 22 |
-
tokens: List[Dict[str, Any]] = []
|
| 23 |
-
lemmas_clean: List[str] = []
|
| 24 |
-
|
| 25 |
-
for t in sent:
|
| 26 |
-
lemma = t.lemma_.lower().strip() if t.lemma_ else t.text.lower().strip()
|
| 27 |
-
is_significant = bool(logic.is_valid_token(t))
|
| 28 |
-
|
| 29 |
-
token_data = {
|
| 30 |
-
"text": t.text,
|
| 31 |
-
"whitespace": t.whitespace_,
|
| 32 |
-
"lemma": lemma,
|
| 33 |
-
"is_significant": is_significant,
|
| 34 |
-
"is_punct": bool(t.is_punct),
|
| 35 |
-
"is_space": bool(t.is_space),
|
| 36 |
-
}
|
| 37 |
-
tokens.append(token_data)
|
| 38 |
-
|
| 39 |
-
if is_significant and lemma:
|
| 40 |
-
lemmas_clean.append(lemma)
|
| 41 |
-
|
| 42 |
-
result.append(
|
| 43 |
-
{
|
| 44 |
-
"index": idx,
|
| 45 |
-
"raw_text": sent.text,
|
| 46 |
-
"tokens": tokens,
|
| 47 |
-
"lemmas_clean": lemmas_clean,
|
| 48 |
-
}
|
| 49 |
-
)
|
| 50 |
-
|
| 51 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
optimizer.py
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
CHANGED
|
@@ -8,7 +8,4 @@ sentence-transformers>=2.2.0
|
|
| 8 |
spacy>=3.7.0
|
| 9 |
python-multipart>=0.0.6
|
| 10 |
jinja2>=3.1.0
|
| 11 |
-
torch>=2.0.0
|
| 12 |
-
networkx>=3.2.0
|
| 13 |
-
requests>=2.31.0
|
| 14 |
-
beautifulsoup4>=4.12.0
|
|
|
|
| 8 |
spacy>=3.7.0
|
| 9 |
python-multipart>=0.0.6
|
| 10 |
jinja2>=3.1.0
|
| 11 |
+
torch>=2.0.0
|
|
|
|
|
|
|
|
|
search.py
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 1 |
-
from typing import Dict, List, Any
|
| 2 |
-
|
| 3 |
-
import logic
|
| 4 |
-
import networkx as nx
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def _normalize_query_text(text: str) -> str:
|
| 8 |
-
return " ".join((text or "").lower().replace("ё", "е").replace("-", " ").split())
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def semantic_search(
|
| 12 |
-
query_text: str,
|
| 13 |
-
graph: nx.DiGraph,
|
| 14 |
-
word_weights: Dict[str, int],
|
| 15 |
-
lang: str,
|
| 16 |
-
top_n: int = 20,
|
| 17 |
-
) -> List[Dict[str, Any]]:
|
| 18 |
-
"""
|
| 19 |
-
Смысловой поиск по графу:
|
| 20 |
-
- лемматизация запроса
|
| 21 |
-
- добавление соседей (successors)
|
| 22 |
-
- score = сила связи + вес узла
|
| 23 |
-
"""
|
| 24 |
-
if not query_text.strip() or graph.number_of_nodes() == 0:
|
| 25 |
-
return []
|
| 26 |
-
|
| 27 |
-
query_text_norm = _normalize_query_text(query_text)
|
| 28 |
-
query_lemmas = logic.get_lemmas_flat(query_text, lang)
|
| 29 |
-
# Фразовые кандидаты для точного phrase-match в графе.
|
| 30 |
-
phrase_terms: List[str] = []
|
| 31 |
-
for n in (3, 2):
|
| 32 |
-
phrase_terms.extend(logic.generate_ngrams_safe(query_text_norm, lang, n))
|
| 33 |
-
|
| 34 |
-
# Удаляем дубли с сохранением порядка.
|
| 35 |
-
phrase_terms = list(dict.fromkeys([_normalize_query_text(p) for p in phrase_terms if p.strip()]))
|
| 36 |
-
query_lemmas = list(dict.fromkeys([_normalize_query_text(q) for q in query_lemmas if q.strip()]))
|
| 37 |
-
|
| 38 |
-
if not query_lemmas and not phrase_terms:
|
| 39 |
-
return []
|
| 40 |
-
|
| 41 |
-
candidates: Dict[str, float] = {}
|
| 42 |
-
candidate_type: Dict[str, str] = {}
|
| 43 |
-
|
| 44 |
-
# 1) Сначала фразовый поиск.
|
| 45 |
-
for p in phrase_terms:
|
| 46 |
-
if p not in graph:
|
| 47 |
-
continue
|
| 48 |
-
base = float(word_weights.get(p, 0))
|
| 49 |
-
candidates[p] = max(candidates.get(p, 0.0), base)
|
| 50 |
-
candidate_type[p] = "phrase"
|
| 51 |
-
|
| 52 |
-
for neigh in graph.successors(p):
|
| 53 |
-
edge_data = graph.get_edge_data(p, neigh) or {}
|
| 54 |
-
edge_weight = float(edge_data.get("weight", 0.0))
|
| 55 |
-
node_weight = float(word_weights.get(neigh, 0))
|
| 56 |
-
score = edge_weight * 0.65 + node_weight * 0.35
|
| 57 |
-
if score > candidates.get(neigh, 0.0):
|
| 58 |
-
candidates[neigh] = score
|
| 59 |
-
candidate_type[neigh] = "phrase" if " " in neigh else "word"
|
| 60 |
-
|
| 61 |
-
# 2) Потом словный fallback/расширение.
|
| 62 |
-
for q in query_lemmas:
|
| 63 |
-
if q not in graph:
|
| 64 |
-
continue
|
| 65 |
-
|
| 66 |
-
# Само понятие запроса.
|
| 67 |
-
candidates[q] = max(candidates.get(q, 0.0), float(word_weights.get(q, 0)))
|
| 68 |
-
if q not in candidate_type:
|
| 69 |
-
candidate_type[q] = "word"
|
| 70 |
-
|
| 71 |
-
for neigh in graph.successors(q):
|
| 72 |
-
edge_data = graph.get_edge_data(q, neigh) or {}
|
| 73 |
-
edge_weight = float(edge_data.get("weight", 0.0))
|
| 74 |
-
node_weight = float(word_weights.get(neigh, 0))
|
| 75 |
-
score = edge_weight * 0.6 + node_weight * 0.4
|
| 76 |
-
if score > candidates.get(neigh, 0.0):
|
| 77 |
-
candidates[neigh] = score
|
| 78 |
-
candidate_type[neigh] = "phrase" if " " in neigh else "word"
|
| 79 |
-
|
| 80 |
-
ranked = sorted(candidates.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
| 81 |
-
|
| 82 |
-
if not ranked:
|
| 83 |
-
return []
|
| 84 |
-
|
| 85 |
-
max_score = ranked[0][1]
|
| 86 |
-
min_score = ranked[-1][1]
|
| 87 |
-
denom = max(1e-9, max_score - min_score)
|
| 88 |
-
|
| 89 |
-
return [
|
| 90 |
-
{
|
| 91 |
-
"lemma": lemma,
|
| 92 |
-
"score": int(round(1 + ((score - min_score) / denom) * 99)),
|
| 93 |
-
"type": candidate_type.get(lemma, "phrase" if " " in lemma else "word"),
|
| 94 |
-
}
|
| 95 |
-
for lemma, score in ranked
|
| 96 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
semantic_graph.py
DELETED
|
@@ -1,304 +0,0 @@
|
|
| 1 |
-
from collections import Counter, defaultdict
|
| 2 |
-
from typing import Dict, List, Any, Tuple
|
| 3 |
-
import math
|
| 4 |
-
|
| 5 |
-
import networkx as nx
|
| 6 |
-
import logic
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _normalize_to_1_100(values: Dict[str, float]) -> Dict[str, int]:
|
| 10 |
-
if not values:
|
| 11 |
-
return {}
|
| 12 |
-
v_min = min(values.values())
|
| 13 |
-
v_max = max(values.values())
|
| 14 |
-
if v_max == v_min:
|
| 15 |
-
return {k: 50 for k in values.keys()}
|
| 16 |
-
return {
|
| 17 |
-
k: int(round(1 + ((v - v_min) / (v_max - v_min)) * 99))
|
| 18 |
-
for k, v in values.items()
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _extract_significant_lemmas(sent: Dict[str, Any]) -> List[str]:
|
| 23 |
-
lemmas: List[str] = []
|
| 24 |
-
for tok in sent.get("tokens", []):
|
| 25 |
-
if tok.get("is_significant") and tok.get("lemma"):
|
| 26 |
-
lemmas.append(str(tok["lemma"]))
|
| 27 |
-
return lemmas
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
NOISE_PATTERNS = (
|
| 31 |
-
"contact us",
|
| 32 |
-
"responsible gaming",
|
| 33 |
-
"play responsibly",
|
| 34 |
-
"copyright",
|
| 35 |
-
"terms of use",
|
| 36 |
-
"new customers only",
|
| 37 |
-
"t&c apply",
|
| 38 |
-
"18+",
|
| 39 |
-
"info@",
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
GENERIC_TERMS = {
|
| 44 |
-
"игра", "играть", "казино", "ставка", "деньга", "деньги",
|
| 45 |
-
"демо", "режим", "уровень", "шаг", "выигрыш", "риск",
|
| 46 |
-
"game", "play", "casino", "bet", "demo", "mode", "level", "risk", "win",
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _is_noise_sentence(text: str) -> bool:
|
| 51 |
-
t = " ".join((text or "").lower().split())
|
| 52 |
-
if not t:
|
| 53 |
-
return True
|
| 54 |
-
if any(p in t for p in NOISE_PATTERNS):
|
| 55 |
-
return True
|
| 56 |
-
# Частые CTA/служебные короткие строки.
|
| 57 |
-
if len(t.split()) <= 3 and t in {"играть", "play", "chicken road", "chicken road game"}:
|
| 58 |
-
return True
|
| 59 |
-
return False
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _canonicalize_term(term: str) -> str:
|
| 63 |
-
t = " ".join((term or "").lower().replace("ё", "е").replace("-", " ").split())
|
| 64 |
-
if not t:
|
| 65 |
-
return t
|
| 66 |
-
|
| 67 |
-
# Бренд/наименование игры.
|
| 68 |
-
if t in {"чикен роад", "chicken road", "chickenroad", "chiken road"}:
|
| 69 |
-
return "chicken road"
|
| 70 |
-
if t in {"игры inout", "inout games", "inout game", "inout"}:
|
| 71 |
-
return "inout games"
|
| 72 |
-
|
| 73 |
-
# Полезная нормализация русской фразы под один термин.
|
| 74 |
-
if t in {"реальные деньги", "реальный деньги"}:
|
| 75 |
-
return "реальные деньги"
|
| 76 |
-
return t
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def _extract_phrase_candidates(sentence_text: str, lang: str) -> List[str]:
|
| 80 |
-
"""
|
| 81 |
-
Извлекает фразовые кандидаты через существующую n-gram логику проекта,
|
| 82 |
-
чтобы сохранить естественные сочетания со стоп-словами внутри.
|
| 83 |
-
"""
|
| 84 |
-
candidates: List[str] = []
|
| 85 |
-
for n in (2, 3):
|
| 86 |
-
candidates.extend(logic.generate_ngrams_safe(sentence_text, lang, n))
|
| 87 |
-
return candidates
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _normalize_lemma_sequence(lemmas: List[str]) -> List[str]:
|
| 91 |
-
"""
|
| 92 |
-
Убирает подряд идущие дубликаты, чтобы не рождать шумные
|
| 93 |
-
термины вроде "дорожка дорожка" из технических повторов.
|
| 94 |
-
"""
|
| 95 |
-
if not lemmas:
|
| 96 |
-
return []
|
| 97 |
-
normalized = [lemmas[0]]
|
| 98 |
-
for item in lemmas[1:]:
|
| 99 |
-
if item != normalized[-1]:
|
| 100 |
-
normalized.append(item)
|
| 101 |
-
return normalized
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def build_semantic_graph(
|
| 105 |
-
sentences_data: List[Dict[str, Any]],
|
| 106 |
-
window_size: int = 5,
|
| 107 |
-
min_phrase_freq: int = 2,
|
| 108 |
-
lang: str = "ru",
|
| 109 |
-
) -> Tuple[nx.DiGraph, Dict[str, int]]:
|
| 110 |
-
"""
|
| 111 |
-
Строит направленный граф из лемм и считает веса:
|
| 112 |
-
- edge_weight: условная вероятность P(B|A) * 100
|
| 113 |
-
- node_weight: нормализованный PageRank 1..100
|
| 114 |
-
|
| 115 |
-
Версия v5:
|
| 116 |
-
- связи считаются в локальном скользящем окне (а не "все со всеми")
|
| 117 |
-
- условная вероятность считается строго как cooc(a,b)/occ(a), поэтому <= 100
|
| 118 |
-
- узлы графа: слова + устойчивые словосочетания (bi/tri-grams)
|
| 119 |
-
- словосочетания извлекаются через Smart Window (как в основной логике n-gram)
|
| 120 |
-
- повышаем вклад устойчивых фраз в итоговый смысловой вес понятия
|
| 121 |
-
"""
|
| 122 |
-
term_occ = Counter()
|
| 123 |
-
pair_cooc = defaultdict(int)
|
| 124 |
-
phrase_occ = Counter()
|
| 125 |
-
phrase_sent_ids = defaultdict(set)
|
| 126 |
-
term_sent_ids = defaultdict(set)
|
| 127 |
-
sentence_words: List[List[str]] = []
|
| 128 |
-
sentence_phrases: List[List[str]] = []
|
| 129 |
-
|
| 130 |
-
for sent_id, sent in enumerate(sentences_data):
|
| 131 |
-
raw_text = sent.get("raw_text", "")
|
| 132 |
-
if _is_noise_sentence(raw_text):
|
| 133 |
-
sentence_words.append([])
|
| 134 |
-
sentence_phrases.append([])
|
| 135 |
-
continue
|
| 136 |
-
|
| 137 |
-
lemmas_raw = _extract_significant_lemmas(sent)
|
| 138 |
-
lemmas = [_canonicalize_term(x) for x in _normalize_lemma_sequence(lemmas_raw)]
|
| 139 |
-
lemmas = [x for x in lemmas if x]
|
| 140 |
-
sentence_words.append(lemmas)
|
| 141 |
-
|
| 142 |
-
phrase_candidates = _extract_phrase_candidates(raw_text, lang)
|
| 143 |
-
# Фильтр мусора фраз: минимум 2 слова, без дублирующегося подряд слова.
|
| 144 |
-
clean_phrases = []
|
| 145 |
-
for p in phrase_candidates:
|
| 146 |
-
parts = [x.strip() for x in p.split() if x.strip()]
|
| 147 |
-
if len(parts) < 2:
|
| 148 |
-
continue
|
| 149 |
-
bad_repeat = any(parts[i] == parts[i + 1] for i in range(len(parts) - 1))
|
| 150 |
-
if bad_repeat:
|
| 151 |
-
continue
|
| 152 |
-
clean_phrases.append(_canonicalize_term(" ".join(parts)))
|
| 153 |
-
sentence_phrases.append(clean_phrases)
|
| 154 |
-
|
| 155 |
-
if not lemmas:
|
| 156 |
-
continue
|
| 157 |
-
|
| 158 |
-
# Для каждого вхождения a считаем его локальный контекст в окне.
|
| 159 |
-
# cooc(a,b) увеличивается максимум на 1 на одно вхождение a,
|
| 160 |
-
# что гарантирует cooc(a,b) <= occ(a) и P(B|A) <= 100.
|
| 161 |
-
for i, a in enumerate(lemmas):
|
| 162 |
-
term_occ[a] += 1
|
| 163 |
-
left = max(0, i - max(1, window_size))
|
| 164 |
-
right = min(len(lemmas), i + max(1, window_size) + 1)
|
| 165 |
-
neighbors = set()
|
| 166 |
-
for j in range(left, right):
|
| 167 |
-
if j == i:
|
| 168 |
-
continue
|
| 169 |
-
b = lemmas[j]
|
| 170 |
-
if not b or a == b:
|
| 171 |
-
continue
|
| 172 |
-
neighbors.add(b)
|
| 173 |
-
for b in neighbors:
|
| 174 |
-
pair_cooc[(a, b)] += 1
|
| 175 |
-
for w in set(lemmas):
|
| 176 |
-
term_sent_ids[w].add(sent_id)
|
| 177 |
-
|
| 178 |
-
# Частоты/охват фраз по предложениям.
|
| 179 |
-
for sent_id, phrases in enumerate(sentence_phrases):
|
| 180 |
-
for phrase in phrases:
|
| 181 |
-
phrase_occ[phrase] += 1
|
| 182 |
-
phrase_sent_ids[phrase].add(sent_id)
|
| 183 |
-
|
| 184 |
-
# Оставляем только устойчивые фразы.
|
| 185 |
-
allowed_phrases = {
|
| 186 |
-
p for p, c in phrase_occ.items()
|
| 187 |
-
if c >= max(1, min_phrase_freq) and len(phrase_sent_ids[p]) >= max(1, min_phrase_freq)
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
# Добавляем связи phrase <-> word на уровне предложения.
|
| 191 |
-
for sent_id, words in enumerate(sentence_words):
|
| 192 |
-
if not words:
|
| 193 |
-
continue
|
| 194 |
-
phrases = [p for p in sentence_phrases[sent_id] if p in allowed_phrases]
|
| 195 |
-
if not phrases:
|
| 196 |
-
continue
|
| 197 |
-
uniq_words = set(words)
|
| 198 |
-
uniq_phrases = set(phrases)
|
| 199 |
-
|
| 200 |
-
for phrase in uniq_phrases:
|
| 201 |
-
term_occ[phrase] += 1
|
| 202 |
-
term_sent_ids[phrase].add(sent_id)
|
| 203 |
-
parts = set(phrase.split())
|
| 204 |
-
# Связываем фразу с ее компонентами всегда (ядро термина).
|
| 205 |
-
for w in parts:
|
| 206 |
-
pair_cooc[(phrase, w)] += 1
|
| 207 |
-
pair_cooc[(w, phrase)] += 1
|
| 208 |
-
# И с остальными словами предложения (контекст термина).
|
| 209 |
-
for w in uniq_words:
|
| 210 |
-
if w in parts:
|
| 211 |
-
continue
|
| 212 |
-
pair_cooc[(phrase, w)] += 1
|
| 213 |
-
pair_cooc[(w, phrase)] += 1
|
| 214 |
-
|
| 215 |
-
graph = nx.DiGraph()
|
| 216 |
-
|
| 217 |
-
for term, freq in term_occ.items():
|
| 218 |
-
term_type = "phrase" if " " in term else "word"
|
| 219 |
-
graph.add_node(term, frequency=int(freq), term_type=term_type)
|
| 220 |
-
|
| 221 |
-
for (a, b), cooc in pair_cooc.items():
|
| 222 |
-
base = term_occ.get(a, 0)
|
| 223 |
-
if base <= 0:
|
| 224 |
-
continue
|
| 225 |
-
weight = (cooc / base) * 100.0
|
| 226 |
-
weight = max(0.0, min(100.0, weight))
|
| 227 |
-
if weight > 0:
|
| 228 |
-
graph.add_edge(a, b, weight=round(weight, 3))
|
| 229 |
-
|
| 230 |
-
if graph.number_of_nodes() == 0:
|
| 231 |
-
return graph, {}
|
| 232 |
-
|
| 233 |
-
try:
|
| 234 |
-
pr = nx.pagerank(graph, weight="weight")
|
| 235 |
-
except Exception:
|
| 236 |
-
pr = {node: 1.0 for node in graph.nodes()}
|
| 237 |
-
|
| 238 |
-
# Комбинируем centrality с termness-фактором.
|
| 239 |
-
# Для слов: умеренный буст по контекстной связанности.
|
| 240 |
-
# Для фраз: более сильный буст по частоте/охвату, чтобы устойчивые термины поднимались в топ.
|
| 241 |
-
combined_scores = {}
|
| 242 |
-
total_sent = max(1, len(sentence_words))
|
| 243 |
-
for node, score in pr.items():
|
| 244 |
-
out_deg = graph.out_degree(node)
|
| 245 |
-
in_deg = graph.in_degree(node)
|
| 246 |
-
connectivity_factor = 1.0 + 0.025 * (out_deg + in_deg)
|
| 247 |
-
sent_df = len(term_sent_ids.get(node, set()))
|
| 248 |
-
idf_factor = 1.0 + 0.35 * math.log((1.0 + total_sent) / (1.0 + max(1, sent_df)))
|
| 249 |
-
|
| 250 |
-
if graph.nodes[node].get("term_type") == "phrase":
|
| 251 |
-
freq = max(1, int(graph.nodes[node].get("frequency", 1)))
|
| 252 |
-
sent_cover = len(phrase_sent_ids.get(node, set()))
|
| 253 |
-
termness = (1.0 + 0.22 * math.log1p(freq)) * (1.0 + 0.12 * math.log1p(sent_cover))
|
| 254 |
-
contains_generic = any(part in GENERIC_TERMS for part in node.split())
|
| 255 |
-
generic_penalty = 0.85 if contains_generic else 1.0
|
| 256 |
-
combined_scores[node] = score * connectivity_factor * termness * idf_factor * generic_penalty
|
| 257 |
-
else:
|
| 258 |
-
generic_penalty = 0.58 if node in GENERIC_TERMS else 1.0
|
| 259 |
-
combined_scores[node] = score * connectivity_factor * idf_factor * generic_penalty
|
| 260 |
-
|
| 261 |
-
node_weights = _normalize_to_1_100(combined_scores)
|
| 262 |
-
|
| 263 |
-
for node, w in node_weights.items():
|
| 264 |
-
graph.nodes[node]["weight"] = int(w)
|
| 265 |
-
|
| 266 |
-
return graph, node_weights
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
def get_graph_data_for_frontend(graph: nx.DiGraph, top_edges_per_node: int = 8) -> Dict[str, List[Dict[str, Any]]]:
|
| 270 |
-
nodes = []
|
| 271 |
-
links = []
|
| 272 |
-
|
| 273 |
-
for node, attrs in graph.nodes(data=True):
|
| 274 |
-
nodes.append(
|
| 275 |
-
{
|
| 276 |
-
"id": node,
|
| 277 |
-
"label": node,
|
| 278 |
-
"weight": int(attrs.get("weight", 1)),
|
| 279 |
-
"frequency": int(attrs.get("frequency", 0)),
|
| 280 |
-
}
|
| 281 |
-
)
|
| 282 |
-
|
| 283 |
-
for source in graph.nodes():
|
| 284 |
-
out_edges = sorted(
|
| 285 |
-
graph.out_edges(source, data=True),
|
| 286 |
-
key=lambda e: e[2].get("weight", 0),
|
| 287 |
-
reverse=True,
|
| 288 |
-
)[:top_edges_per_node]
|
| 289 |
-
|
| 290 |
-
for s, t, attrs in out_edges:
|
| 291 |
-
links.append(
|
| 292 |
-
{
|
| 293 |
-
"source": s,
|
| 294 |
-
"target": t,
|
| 295 |
-
"weight": float(attrs.get("weight", 0)),
|
| 296 |
-
}
|
| 297 |
-
)
|
| 298 |
-
|
| 299 |
-
return {"nodes": nodes, "links": links}
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def get_top_keywords(node_weights: Dict[str, int], top_n: int = 20) -> List[Dict[str, Any]]:
|
| 303 |
-
items = sorted(node_weights.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
| 304 |
-
return [{"lemma": lemma, "weight": int(weight)} for lemma, weight in items]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/js/app.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
summarizer.py
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
import math
|
| 2 |
-
from typing import Dict, List, Any
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
def generate_summary(
|
| 6 |
-
sentences_data: List[Dict[str, Any]],
|
| 7 |
-
word_weights: Dict[str, int],
|
| 8 |
-
compression_ratio: float = 0.1,
|
| 9 |
-
) -> List[Dict[str, Any]]:
|
| 10 |
-
"""
|
| 11 |
-
Реферат: отбирает самые значимые предложения.
|
| 12 |
-
score = сумма весов уникальных лемм / sqrt(длина_предложения)
|
| 13 |
-
"""
|
| 14 |
-
if not sentences_data:
|
| 15 |
-
return []
|
| 16 |
-
|
| 17 |
-
compression_ratio = max(0.05, min(0.8, compression_ratio))
|
| 18 |
-
ranked: List[Dict[str, Any]] = []
|
| 19 |
-
|
| 20 |
-
for sent in sentences_data:
|
| 21 |
-
uniq = set(sent.get("lemmas_clean", []))
|
| 22 |
-
if not uniq:
|
| 23 |
-
score = 0.0
|
| 24 |
-
else:
|
| 25 |
-
numerator = float(sum(word_weights.get(l, 0) for l in uniq))
|
| 26 |
-
penalty = math.sqrt(max(1, len(sent.get("tokens", []))))
|
| 27 |
-
score = numerator / penalty
|
| 28 |
-
|
| 29 |
-
ranked.append(
|
| 30 |
-
{
|
| 31 |
-
"index": sent.get("index", 0),
|
| 32 |
-
"text": sent.get("raw_text", ""),
|
| 33 |
-
"score": round(score, 3),
|
| 34 |
-
}
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
ranked.sort(key=lambda x: x["score"], reverse=True)
|
| 38 |
-
take_n = max(1, int(round(len(ranked) * compression_ratio)))
|
| 39 |
-
chosen = ranked[:take_n]
|
| 40 |
-
chosen.sort(key=lambda x: x["index"])
|
| 41 |
-
return chosen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
templates/index.html
CHANGED
|
@@ -9,8 +9,6 @@
|
|
| 9 |
body { background-color: #f8f9fa; }
|
| 10 |
.editor-box { min-height: 300px; font-family: 'Georgia', serif; font-size: 1.1rem; }
|
| 11 |
.stat-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
|
| 12 |
-
.diff-changed { background-color: #fff3cd; padding: 0 2px; border-radius: 4px; }
|
| 13 |
-
.diff-unchanged { }
|
| 14 |
.loading-overlay {
|
| 15 |
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 16 |
background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
|
|
@@ -47,29 +45,9 @@
|
|
| 47 |
<option value="de">🇩🇪 Deutsch</option>
|
| 48 |
<option value="it">🇮🇹 Italiano</option>
|
| 49 |
<option value="es">🇪🇸 Español</option>
|
| 50 |
-
<option value="pl">🇵🇱 Polski</option>
|
| 51 |
-
<option value="pt">🇵🇹 Português</option>
|
| 52 |
</select>
|
| 53 |
</div>
|
| 54 |
|
| 55 |
-
<div class="mb-3 border rounded p-3 bg-light">
|
| 56 |
-
<label class="form-label fw-bold mb-2">Импорт по URL (Title + текст)</label>
|
| 57 |
-
<div class="mb-2">
|
| 58 |
-
<label class="form-label small text-muted mb-1">User-Agent</label>
|
| 59 |
-
<select class="form-select form-select-sm" id="urlUserAgentSelect">
|
| 60 |
-
<option value="chrome_desktop">Chrome (Desktop)</option>
|
| 61 |
-
</select>
|
| 62 |
-
</div>
|
| 63 |
-
<div class="input-group input-group-sm mb-2">
|
| 64 |
-
<span class="input-group-text">Target URL</span>
|
| 65 |
-
<input type="text" id="targetUrlInput" class="form-control" placeholder="https://example.com/page">
|
| 66 |
-
<button class="btn btn-outline-primary" onclick="fetchTargetFromUrl()">Забрать</button>
|
| 67 |
-
</div>
|
| 68 |
-
<label class="form-label small text-muted mb-1">URL конкурентов (по одному в строке)</label>
|
| 69 |
-
<textarea class="form-control form-control-sm mb-2" id="competitorUrlsInput" rows="3" placeholder="https://example.com/competitor-1 https://example.com/competitor-2"></textarea>
|
| 70 |
-
<button class="btn btn-sm btn-outline-primary" onclick="fetchCompetitorsFromUrls()">Забрать конкурентов</button>
|
| 71 |
-
</div>
|
| 72 |
-
|
| 73 |
<div class="mb-3">
|
| 74 |
<label class="form-label fw-bold">Ваш текст (Target)</label>
|
| 75 |
<textarea class="form-control editor-box" id="targetText" placeholder="Пишите текст здесь..."></textarea>
|
|
@@ -111,11 +89,6 @@
|
|
| 111 |
|
| 112 |
<div class="d-grid gap-2">
|
| 113 |
<button class="btn btn-primary btn-lg" onclick="runAnalysis()">⚡ Анализировать (GPU)</button>
|
| 114 |
-
<div class="btn-group" role="group">
|
| 115 |
-
<button class="btn btn-outline-success" onclick="saveProject()">💾 Сохранить проект</button>
|
| 116 |
-
<button class="btn btn-outline-secondary" onclick="loadProject()">📂 Загрузить проект</button>
|
| 117 |
-
<button class="btn btn-outline-danger" onclick="clearProject()">🗑 Новый проект</button>
|
| 118 |
-
</div>
|
| 119 |
</div>
|
| 120 |
</div>
|
| 121 |
</div>
|
|
@@ -142,15 +115,6 @@
|
|
| 142 |
<li class="nav-item">
|
| 143 |
<button class="nav-link" id="title-tab" data-bs-toggle="tab" data-bs-target="#titlePane" type="button">🏷️ Title</button>
|
| 144 |
</li>
|
| 145 |
-
<li class="nav-item">
|
| 146 |
-
<button class="nav-link" id="semantic-tab" data-bs-toggle="tab" data-bs-target="#semanticPane" type="button">🧩 Semantic Core</button>
|
| 147 |
-
</li>
|
| 148 |
-
<li class="nav-item">
|
| 149 |
-
<button class="nav-link" id="summary-tab" data-bs-toggle="tab" data-bs-target="#summaryPane" type="button">✅ Сводка</button>
|
| 150 |
-
</li>
|
| 151 |
-
<li class="nav-item">
|
| 152 |
-
<button class="nav-link" id="optimizer-tab" data-bs-toggle="tab" data-bs-target="#optimizerPane" type="button">🤖 LLM Optimizer</button>
|
| 153 |
-
</li>
|
| 154 |
</ul>
|
| 155 |
|
| 156 |
<div class="tab-content" id="resultsContent">
|
|
@@ -223,177 +187,585 @@
|
|
| 223 |
</div>
|
| 224 |
</div>
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</div>
|
| 239 |
-
<div class="
|
| 240 |
-
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
-
<p class="small text-muted mb-3">
|
| 244 |
-
50 = подсветка только наиболее значимых понятий. 0.1 = реферат из ~10% самых информативных предложений.
|
| 245 |
-
</p>
|
| 246 |
|
| 247 |
-
<
|
| 248 |
-
|
| 249 |
-
<
|
| 250 |
-
|
| 251 |
-
</
|
| 252 |
</div>
|
| 253 |
|
| 254 |
-
<
|
| 255 |
-
|
| 256 |
-
<
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
</div>
|
| 260 |
-
<div id="semanticSearchResults" class="border rounded p-2 text-break mt-2">
|
| 261 |
-
<span class="text-muted small">Введите запрос и нажмите "Искать".</span>
|
| 262 |
</div>
|
| 263 |
</div>
|
| 264 |
-
</div>
|
| 265 |
|
| 266 |
-
<div id="semanticResultsContainer">
|
| 267 |
-
<div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>
|
| 268 |
</div>
|
| 269 |
</div>
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
<
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
<label class="form-label small text-muted mb-1">API Key (пользовательский)</label>
|
| 285 |
-
<input type="password" id="optimizerApiKey" class="form-control" placeholder="sk-...">
|
| 286 |
-
</div>
|
| 287 |
-
<div class="col-md-6">
|
| 288 |
-
<label class="form-label small text-muted mb-1">Base URL (OpenAI-compatible)</label>
|
| 289 |
-
<input type="text" id="optimizerBaseUrl" class="form-control" value="https://api.deepseek.com/v1">
|
| 290 |
-
</div>
|
| 291 |
-
<div class="col-md-4">
|
| 292 |
-
<label class="form-label small text-muted mb-1">Model</label>
|
| 293 |
-
<input type="text" id="optimizerModel" class="form-control" value="deepseek-chat">
|
| 294 |
-
</div>
|
| 295 |
-
<div class="col-md-3">
|
| 296 |
-
<label class="form-label small text-muted mb-1">Итерации</label>
|
| 297 |
-
<input type="number" id="optimizerIterations" class="form-control" min="1" max="8" value="2">
|
| 298 |
-
</div>
|
| 299 |
-
<div class="col-md-3">
|
| 300 |
-
<label class="form-label small text-muted mb-1">Кандидатов/шаг</label>
|
| 301 |
-
<input type="number" id="optimizerCandidates" class="form-control" min="1" max="5" value="2">
|
| 302 |
-
</div>
|
| 303 |
-
<div class="col-md-2">
|
| 304 |
-
<label class="form-label small text-muted mb-1">Temp</label>
|
| 305 |
-
<input type="number" id="optimizerTemp" class="form-control" min="0" max="1.2" step="0.05" value="0.25">
|
| 306 |
-
</div>
|
| 307 |
-
<div class="col-md-2">
|
| 308 |
-
<label class="form-label small text-muted mb-1">BERT target A-stage</label>
|
| 309 |
-
<input type="number" id="optimizerBertStageTarget" class="form-control" min="0" max="1" step="0.01" value="0.70">
|
| 310 |
</div>
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
</div>
|
| 319 |
-
<
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
</div>
|
| 332 |
</div>
|
| 333 |
-
<
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
</
|
| 342 |
-
<
|
| 343 |
-
<div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBert" data-stage="bert" checked><label class="form-check-label small" for="optStageBert">BERT</label></div></div>
|
| 344 |
-
<div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBm25" data-stage="bm25" checked><label class="form-check-label small" for="optStageBm25">BM25</label></div></div>
|
| 345 |
-
<div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageNgram" data-stage="ngram" checked><label class="form-check-label small" for="optStageNgram">N-gram</label></div></div>
|
| 346 |
-
<div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageSemantic" data-stage="semantic" checked><label class="form-check-label small" for="optStageSemantic">Semantic</label></div></div>
|
| 347 |
-
<div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageTitle" data-stage="title" checked><label class="form-check-label small" for="optStageTitle">Title</label></div></div>
|
| 348 |
-
</div>
|
| 349 |
-
<div id="optimizerStageGoalConfigContainer"></div>
|
| 350 |
</div>
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
<select id="optimizerDiffMode" class="form-select">
|
| 355 |
-
<option value="diff_from_input" selected>Относительно текста перед запуском</option>
|
| 356 |
-
<option value="diff_from_original">Относительно оригинала (снимок)</option>
|
| 357 |
-
</select>
|
| 358 |
-
<div class="form-text">
|
| 359 |
-
Если выбран режим «оригинал», при первом запуске автоматически сохраняется снимок текущего `Target`.
|
| 360 |
</div>
|
| 361 |
</div>
|
| 362 |
-
<div class="col-md-3 d-grid align-items-end">
|
| 363 |
-
<button type="button" class="btn btn-outline-secondary" onclick="resetOptimizerDiffOriginal()">Сбросить оригинал</button>
|
| 364 |
-
</div>
|
| 365 |
-
</div>
|
| 366 |
-
<div class="d-flex gap-2 mt-3 flex-wrap align-items-center">
|
| 367 |
-
<button type="button" class="btn btn-dark" id="btnRunLlmOpt" onclick="runLlmOptimization()">Запустить оптимизацию</button>
|
| 368 |
-
<button type="button" class="btn btn-outline-danger" id="btnStopLlmOpt" disabled onclick="requestStopOptimizer()">Остановить</button>
|
| 369 |
-
<button type="button" class="btn btn-outline-secondary" onclick="applyOptimizedText()">Применить в Target</button>
|
| 370 |
-
</div>
|
| 371 |
-
<div id="optimizerProgressPanel" class="mt-3 d-none border rounded bg-white p-3">
|
| 372 |
-
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 373 |
-
<span class="small fw-semibold text-secondary">Ход оптимизации (лог)</span>
|
| 374 |
-
<span id="optimizerElapsed" class="small text-muted"></span>
|
| 375 |
-
</div>
|
| 376 |
-
<div class="progress mb-2" style="height:6px;">
|
| 377 |
-
<div id="optimizerProgressBar" class="progress-bar bg-dark" style="width:0%;" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
| 378 |
-
</div>
|
| 379 |
-
<pre id="optimizerRunLog" class="small mb-0 text-body" style="max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word;background:#f8f9fa;border:1px solid #dee2e6;padding:8px;font-family:ui-monospace,monospace;"></pre>
|
| 380 |
-
<p class="small text-muted mb-0 mt-2">Экран целиком не перекрывается. «Остановить» — частичный результат после завершения потока.</p>
|
| 381 |
</div>
|
| 382 |
-
<p class="small text-muted mt-2 mb-0">API key не сохраняется в проект и используется только для текущего запроса.</p>
|
| 383 |
</div>
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
| 394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
|
| 396 |
-
|
| 397 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
</body>
|
| 399 |
</html>
|
|
|
|
| 9 |
body { background-color: #f8f9fa; }
|
| 10 |
.editor-box { min-height: 300px; font-family: 'Georgia', serif; font-size: 1.1rem; }
|
| 11 |
.stat-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
|
|
|
|
|
|
|
| 12 |
.loading-overlay {
|
| 13 |
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 14 |
background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
|
|
|
|
| 45 |
<option value="de">🇩🇪 Deutsch</option>
|
| 46 |
<option value="it">🇮🇹 Italiano</option>
|
| 47 |
<option value="es">🇪🇸 Español</option>
|
|
|
|
|
|
|
| 48 |
</select>
|
| 49 |
</div>
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
<div class="mb-3">
|
| 52 |
<label class="form-label fw-bold">Ваш текст (Target)</label>
|
| 53 |
<textarea class="form-control editor-box" id="targetText" placeholder="Пишите текст здесь..."></textarea>
|
|
|
|
| 89 |
|
| 90 |
<div class="d-grid gap-2">
|
| 91 |
<button class="btn btn-primary btn-lg" onclick="runAnalysis()">⚡ Анализировать (GPU)</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
</div>
|
| 94 |
</div>
|
|
|
|
| 115 |
<li class="nav-item">
|
| 116 |
<button class="nav-link" id="title-tab" data-bs-toggle="tab" data-bs-target="#titlePane" type="button">🏷️ Title</button>
|
| 117 |
</li>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
</ul>
|
| 119 |
|
| 120 |
<div class="tab-content" id="resultsContent">
|
|
|
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 196 |
+
<script>
|
| 197 |
+
let currentData = null;
|
| 198 |
+
|
| 199 |
+
// --- ФУНКЦИИ ИНТЕРФЕЙСА ---
|
| 200 |
+
|
| 201 |
+
document.getElementById('targetTitle').addEventListener('input', function() {
|
| 202 |
+
const len = this.value.length;
|
| 203 |
+
const counter = document.getElementById('titleCharCount');
|
| 204 |
+
counter.textContent = len;
|
| 205 |
+
counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted');
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
function addCompetitorTitleField() {
|
| 209 |
+
const div = document.createElement('div');
|
| 210 |
+
div.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Ещё Title конкурента..." maxlength="200">';
|
| 211 |
+
document.getElementById('competitorTitlesList').appendChild(div);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function addCompetitorField() {
|
| 215 |
+
const div = document.createElement('div');
|
| 216 |
+
div.innerHTML = '<textarea class="form-control mb-2 competitor-input" rows="3" placeholder="Ещё конкурент..."></textarea>';
|
| 217 |
+
document.getElementById('competitorsList').appendChild(div);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function runAnalysis() {
|
| 221 |
+
// Сбор данных
|
| 222 |
+
const targetText = document.getElementById('targetText').value;
|
| 223 |
+
const lang = document.getElementById('languageSelect').value;
|
| 224 |
+
const keywordsRaw = document.getElementById('keywordsInput').value.split('\n').filter(k => k.trim() !== '');
|
| 225 |
+
|
| 226 |
+
// Сбор конкурентов
|
| 227 |
+
const compInputs = document.querySelectorAll('#competitorsList textarea');
|
| 228 |
+
const competitors = [];
|
| 229 |
+
compInputs.forEach(input => {
|
| 230 |
+
if(input.value.trim() !== '') competitors.push(input.value);
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
if(!targetText) { alert("Введите ваш текст!"); return; }
|
| 234 |
+
|
| 235 |
+
// Title fields
|
| 236 |
+
const targetTitle = document.getElementById('targetTitle').value;
|
| 237 |
+
const compTitleInputs = document.querySelectorAll('#competitorTitlesList input');
|
| 238 |
+
const competitorTitles = [];
|
| 239 |
+
compTitleInputs.forEach(input => {
|
| 240 |
+
if(input.value.trim() !== '') competitorTitles.push(input.value);
|
| 241 |
+
});
|
| 242 |
+
|
| 243 |
+
// UI Loading
|
| 244 |
+
document.getElementById('loader').style.display = 'block';
|
| 245 |
+
|
| 246 |
+
const payload = {
|
| 247 |
+
target_text: targetText,
|
| 248 |
+
competitors: competitors,
|
| 249 |
+
keywords: keywordsRaw,
|
| 250 |
+
language: lang,
|
| 251 |
+
target_title: targetTitle,
|
| 252 |
+
competitor_titles: competitorTitles
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
try {
|
| 256 |
+
const response = await fetch('/analyze', {
|
| 257 |
+
method: 'POST',
|
| 258 |
+
headers: { 'Content-Type': 'application/json' },
|
| 259 |
+
body: JSON.stringify(payload)
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
|
| 263 |
+
|
| 264 |
+
const data = await response.json();
|
| 265 |
+
currentData = data;
|
| 266 |
+
renderResults(data);
|
| 267 |
+
|
| 268 |
+
} catch (error) {
|
| 269 |
+
alert("Ошибка: " + error.message);
|
| 270 |
+
console.error(error);
|
| 271 |
+
} finally {
|
| 272 |
+
document.getElementById('loader').style.display = 'none';
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function renderResults(data) {
|
| 277 |
+
// 0. General Stats Render (Word Count Dual Mode)
|
| 278 |
+
const statsContainer = document.getElementById('generalStats');
|
| 279 |
+
statsContainer.innerHTML = '';
|
| 280 |
+
|
| 281 |
+
if (data.word_counts) {
|
| 282 |
+
const myTotal = data.word_counts.target.total;
|
| 283 |
+
const mySig = data.word_counts.target.significant;
|
| 284 |
+
|
| 285 |
+
const avgTotal = data.word_counts.avg.total;
|
| 286 |
+
const avgSig = data.word_counts.avg.significant;
|
| 287 |
+
|
| 288 |
+
// Цвет зависит от ОБЩЕГО количества
|
| 289 |
+
let totalColor = 'text-dark';
|
| 290 |
+
if (avgTotal > 0) {
|
| 291 |
+
if (myTotal < avgTotal * 0.8) totalColor = 'text-danger';
|
| 292 |
+
else if (myTotal > avgTotal * 1.2) totalColor = 'text-success';
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
// HTML для конкурентов
|
| 296 |
+
let compsHtml = '';
|
| 297 |
+
data.word_counts.competitors.forEach((c, idx) => {
|
| 298 |
+
compsHtml += `
|
| 299 |
+
<div class="px-3 border-end text-center">
|
| 300 |
+
<small class="text-muted d-block mb-1">K${idx+1}</small>
|
| 301 |
+
<div class="fw-bold">${c.total}</div>
|
| 302 |
+
<div class="text-muted small" style="font-size: 0.75em;">(${c.significant})</div>
|
| 303 |
+
</div>`;
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
const html = `
|
| 307 |
+
<div class="card border-0 shadow-sm mb-4">
|
| 308 |
+
<div class="card-body py-3">
|
| 309 |
+
<div class="row align-items-center">
|
| 310 |
+
|
| 311 |
+
<!-- Мой результат -->
|
| 312 |
+
<div class="col-md-4 border-end">
|
| 313 |
+
<h6 class="text-uppercase text-muted small fw-bold mb-2">Мой текст</h6>
|
| 314 |
+
<div class="d-flex align-items-baseline">
|
| 315 |
+
<span class="display-6 fw-bold ${totalColor} me-2">${myTotal}</span>
|
| 316 |
+
<span class="text-muted">слов</span>
|
| 317 |
</div>
|
| 318 |
+
<div class="text-muted small">
|
| 319 |
+
Значимых: <strong>${mySig}</strong>
|
| 320 |
</div>
|
| 321 |
</div>
|
|
|
|
|
|
|
|
|
|
| 322 |
|
| 323 |
+
<!-- Среднее -->
|
| 324 |
+
<div class="col-md-3 border-end text-center">
|
| 325 |
+
<h6 class="text-uppercase text-muted small fw-bold mb-2">Среднее (Рынок)</h6>
|
| 326 |
+
<div class="h3 fw-bold mb-0">${avgTotal}</div>
|
| 327 |
+
<div class="text-muted small">Значимых: ${avgSig}</div>
|
| 328 |
</div>
|
| 329 |
|
| 330 |
+
<!-- Конкуренты -->
|
| 331 |
+
<div class="col-md-5">
|
| 332 |
+
<h6 class="text-uppercase text-muted small fw-bold mb-2 text-center">Детализация</h6>
|
| 333 |
+
<div class="d-flex justify-content-center align-items-center">
|
| 334 |
+
${compsHtml}
|
|
|
|
|
|
|
|
|
|
| 335 |
</div>
|
| 336 |
</div>
|
|
|
|
| 337 |
|
|
|
|
|
|
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
+
</div>`;
|
| 341 |
+
statsContainer.innerHTML = html;
|
| 342 |
+
}
|
| 343 |
|
| 344 |
+
// 1. BERT Render (ИСПРАВЛЕННЫЙ ПОД НОВУЮ СТРУКТУРУ)
|
| 345 |
+
const bertContainer = document.getElementById('bertResultsContainer');
|
| 346 |
+
bertContainer.innerHTML = '';
|
| 347 |
+
|
| 348 |
+
// Получаем объект данных. Теперь это объект, а не массив!
|
| 349 |
+
const bertData = data.bert_analysis;
|
| 350 |
+
|
| 351 |
+
// Проверяем, есть ли поле detailed (список фраз)
|
| 352 |
+
if (!bertData || !bertData.detailed || bertData.detailed.length === 0) {
|
| 353 |
+
bertContainer.innerHTML = '<div class="alert alert-warning">Добавьте ключе��ые фразы для анализа.</div>';
|
| 354 |
+
} else {
|
| 355 |
+
|
| 356 |
+
// А. Рендерим ГЛОБАЛЬНЫЙ СЧЕТ (Global Score)
|
| 357 |
+
if (bertData.global_scores && bertData.global_scores.length > 0) {
|
| 358 |
+
let globalHtml = '<div class="card mb-4 border-primary"><div class="card-body">';
|
| 359 |
+
globalHtml += '<h6 class="card-title text-primary fw-bold mb-3">🏆 Общий рейтинг релевантности (Global Score)</h6>';
|
| 360 |
+
|
| 361 |
+
bertData.global_scores.forEach(gs => {
|
| 362 |
+
const scorePct = Math.round(gs.score * 100);
|
| 363 |
+
const isMe = gs.is_me;
|
| 364 |
+
const barColor = isMe ? 'bg-primary' : 'bg-secondary';
|
| 365 |
+
const rowBg = isMe ? 'bg-light border-start border-primary border-3' : '';
|
| 366 |
+
const nameLabel = isMe ? `<strong>${gs.name} (Вы)</strong>` : gs.name;
|
| 367 |
|
| 368 |
+
globalHtml += `
|
| 369 |
+
<div class="d-flex align-items-center mb-2 p-2 rounded ${rowBg}">
|
| 370 |
+
<div style="width: 150px;">${nameLabel}</div>
|
| 371 |
+
<div class="flex-grow-1 mx-3">
|
| 372 |
+
<div class="progress" style="height: 20px;">
|
| 373 |
+
<div class="progress-bar ${barColor}" role="progressbar" style="width: ${scorePct}%">${scorePct}%</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
</div>
|
| 375 |
+
</div>
|
| 376 |
+
<div class="fw-bold">${gs.score}</div>
|
| 377 |
+
</div>`;
|
| 378 |
+
});
|
| 379 |
+
globalHtml += '</div></div>';
|
| 380 |
+
bertContainer.insertAdjacentHTML('beforeend', globalHtml);
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// Б. Рендерим ДЕТАЛИЗАЦИЮ (Аккордеоны)
|
| 384 |
+
// Итерируемся именно по .detailed, так как это массив
|
| 385 |
+
bertData.detailed.forEach((item, index) => {
|
| 386 |
+
let badgeClass = 'bg-secondary';
|
| 387 |
+
if(item.status === 'good') badgeClass = 'bg-success';
|
| 388 |
+
if(item.status === 'warning') badgeClass = 'bg-warning text-dark';
|
| 389 |
+
if(item.status === 'bad') badgeClass = 'bg-danger';
|
| 390 |
+
|
| 391 |
+
const collapseId = `collapseBert${index}`;
|
| 392 |
+
|
| 393 |
+
// Мои чанки
|
| 394 |
+
const myChunksHtml = item.my_top_chunks.map(c =>
|
| 395 |
+
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom">
|
| 396 |
+
<div class="small me-2">"${c.text}"</div>
|
| 397 |
+
<span class="badge bg-primary rounded-pill opacity-75">${c.score}</span>
|
| 398 |
+
</li>`
|
| 399 |
+
).join('');
|
| 400 |
+
|
| 401 |
+
// Чанки конкурентов (С АТРИБУЦИЕЙ ИСТОЧНИКА)
|
| 402 |
+
const compChunksHtml = (item.comp_top_chunks && item.comp_top_chunks.length > 0)
|
| 403 |
+
? item.comp_top_chunks.map(c =>
|
| 404 |
+
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom list-group-item-light">
|
| 405 |
+
<div class="me-2">
|
| 406 |
+
<span class="badge bg-secondary mb-1" style="font-size: 0.7em;">${c.source}</span>
|
| 407 |
+
<div class="small text-muted">"${c.text}"</div>
|
| 408 |
</div>
|
| 409 |
+
<span class="badge bg-dark rounded-pill opacity-50">${c.score}</span>
|
| 410 |
+
</li>`
|
| 411 |
+
).join('')
|
| 412 |
+
: '<li class="list-group-item text-muted small border-0">Нет данных</li>';
|
| 413 |
+
|
| 414 |
+
const html = `
|
| 415 |
+
<div class="card mb-3 border">
|
| 416 |
+
<div class="card-header bg-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#${collapseId}">
|
| 417 |
+
<div>
|
| 418 |
+
<div class="fw-bold text-dark">${item.phrase}</div>
|
| 419 |
+
<div class="text-muted small">
|
| 420 |
+
My: <b>${item.my_max_score}</b> vs Best Comp: <b>${item.comp_max_score}</b>
|
| 421 |
</div>
|
| 422 |
</div>
|
| 423 |
+
<span class="badge ${badgeClass}">${item.status.toUpperCase()}</span>
|
| 424 |
+
</div>
|
| 425 |
+
|
| 426 |
+
<div id="${collapseId}" class="collapse">
|
| 427 |
+
<div class="card-body bg-light">
|
| 428 |
+
<p class="small mb-3"><strong>Совет:</strong> ${item.recommendation}</p>
|
| 429 |
+
<div class="row">
|
| 430 |
+
<div class="col-md-6">
|
| 431 |
+
<h6 class="small fw-bold text-primary">Мой текст</h6>
|
| 432 |
+
<ul class="list-group shadow-sm mb-3">${myChunksHtml || '<li class="list-group-item small">Нет вхождений</li>'}</ul>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
</div>
|
| 434 |
+
<div class="col-md-6 border-start">
|
| 435 |
+
<h6 class="small fw-bold text-secondary">Лучшее у конкурентов</h6>
|
| 436 |
+
<ul class="list-group shadow-sm">${compChunksHtml}</ul>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
</div>
|
| 438 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
</div>
|
|
|
|
| 440 |
</div>
|
| 441 |
+
</div>`;
|
| 442 |
+
bertContainer.insertAdjacentHTML('beforeend', html);
|
| 443 |
+
});
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
// 2. BM25 Render (ОБНОВЛЕННЫЙ v2 - Полный список)
|
| 447 |
+
const bm25Body = document.getElementById('bm25TableBody');
|
| 448 |
+
bm25Body.innerHTML = '';
|
| 449 |
+
const bm25Msg = document.getElementById('bm25EmptyMsg');
|
| 450 |
+
|
| 451 |
+
// Теперь мы ожидаем, что список не пуст, если были введены ключи
|
| 452 |
+
if (data.bm25_recommendations && data.bm25_recommendations.length > 0) {
|
| 453 |
+
bm25Msg.style.display = 'none';
|
| 454 |
+
|
| 455 |
+
data.bm25_recommendations.forEach(item => {
|
| 456 |
+
let colorClass = '';
|
| 457 |
+
let actionText = '';
|
| 458 |
+
let countText = '';
|
| 459 |
+
let rowBg = '';
|
| 460 |
+
|
| 461 |
+
// Определяем стили в зависимости от действия
|
| 462 |
+
if (item.action === 'add') {
|
| 463 |
+
colorClass = 'text-success';
|
| 464 |
+
actionText = 'ДОБАВИТЬ';
|
| 465 |
+
countText = `+${item.count}`;
|
| 466 |
+
rowBg = 'table-success'; // Легкая зеленая подсветка всей строки (Bootstrap класс)
|
| 467 |
+
// Но лучше не красить всю строку, чтобы не рябило, покрасим только текст действия
|
| 468 |
+
rowBg = '';
|
| 469 |
+
} else if (item.action === 'remove') {
|
| 470 |
+
colorClass = 'text-danger';
|
| 471 |
+
actionText = 'УБРАТЬ';
|
| 472 |
+
countText = `-${item.count}`;
|
| 473 |
+
} else {
|
| 474 |
+
colorClass = 'text-muted'; // Серый цвет
|
| 475 |
+
actionText = 'НОРМА'; // Или OK
|
| 476 |
+
countText = '<span class="text-muted">-</span>';
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// Жирный шрифт для важных действий
|
| 480 |
+
const weight = item.action === 'ok' ? 'fw-normal' : 'fw-bold';
|
| 481 |
+
|
| 482 |
+
const row = `
|
| 483 |
+
<tr>
|
| 484 |
+
<td class="fw-bold text-dark">${item.word}</td>
|
| 485 |
+
<td class="${colorClass} ${weight}">${actionText}</td>
|
| 486 |
+
<td class="${colorClass} ${weight}">${countText}</td>
|
| 487 |
+
<td>${item.my_score}</td>
|
| 488 |
+
<td>${item.avg_comp_score}</td>
|
| 489 |
+
</tr>`;
|
| 490 |
+
bm25Body.insertAdjacentHTML('beforeend', row);
|
| 491 |
+
});
|
| 492 |
+
} else {
|
| 493 |
+
// Если список пуст (например, не ввели ключевые слова)
|
| 494 |
+
bm25Msg.style.display = 'block';
|
| 495 |
+
bm25Msg.textContent = "Введите ключевые фразы для расчета BM25.";
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
// 3. N-grams
|
| 499 |
+
showNgramTable('unigrams');
|
| 500 |
+
|
| 501 |
+
// 4. Title
|
| 502 |
+
renderTitleResults(data);
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
function showNgramTable(type) {
|
| 506 |
+
if(!currentData) return;
|
| 507 |
+
|
| 508 |
+
document.querySelectorAll('#ngrams .btn').forEach(b => {
|
| 509 |
+
if(type === 'unigrams' && b.innerText.includes('1')) b.classList.add('active');
|
| 510 |
+
else if(type === 'bigrams' && b.innerText.includes('2')) b.classList.add('active');
|
| 511 |
+
else if(type === 'trigrams' && b.innerText.includes('3')) b.classList.add('active');
|
| 512 |
+
else b.classList.remove('active');
|
| 513 |
+
});
|
| 514 |
+
|
| 515 |
+
const table = document.querySelector('#ngrams table');
|
| 516 |
+
const thead = table.querySelector('thead');
|
| 517 |
+
const tbody = document.getElementById('ngramTableBody');
|
| 518 |
+
|
| 519 |
+
tbody.innerHTML = '';
|
| 520 |
+
thead.innerHTML = '';
|
| 521 |
+
|
| 522 |
+
const list = currentData.ngram_stats[type];
|
| 523 |
+
|
| 524 |
+
let numCompetitors = 0;
|
| 525 |
+
if (list && list.length > 0 && list[0].competitor_detailed) {
|
| 526 |
+
numCompetitors = list[0].competitor_detailed.length;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// ЗАГОЛОВОК
|
| 530 |
+
let headerRow = '<tr>';
|
| 531 |
+
headerRow += '<th style="width: 35%;">Фраза</th>';
|
| 532 |
+
headerRow += '<th>Target</th>';
|
| 533 |
+
headerRow += '<th class="table-light">AVG</th>';
|
| 534 |
+
// НОВАЯ КОЛОНКА
|
| 535 |
+
headerRow += '<th class="table-secondary" title="Встречаемость у конкурентов">Freq</th>';
|
| 536 |
+
|
| 537 |
+
for (let i = 0; i < numCompetitors; i++) {
|
| 538 |
+
headerRow += `<th>K${i + 1}</th>`;
|
| 539 |
+
}
|
| 540 |
+
headerRow += '</tr>';
|
| 541 |
+
thead.innerHTML = headerRow;
|
| 542 |
+
|
| 543 |
+
// ТЕЛО
|
| 544 |
+
if(list && list.length > 0) {
|
| 545 |
+
list.forEach(item => {
|
| 546 |
+
let rowClass = "";
|
| 547 |
+
let countClass = "";
|
| 548 |
+
let icon = "";
|
| 549 |
+
|
| 550 |
+
if (item.target_count === 0 && item.competitor_avg > 0) {
|
| 551 |
+
rowClass = "table-warning";
|
| 552 |
+
countClass = "text-danger fw-bold";
|
| 553 |
+
icon = "⚠️";
|
| 554 |
+
} else if (item.target_count > 0 && item.competitor_avg === 0) {
|
| 555 |
+
countClass = "text-success";
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
let compCells = "";
|
| 559 |
+
if (item.competitor_detailed) {
|
| 560 |
+
item.competitor_detailed.forEach(count => {
|
| 561 |
+
const style = count > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
|
| 562 |
+
compCells += `<td class="${style}">${count}</td>`;
|
| 563 |
+
});
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
// ЗНАЧЕНИЕ ДЛЯ НОВОЙ КОЛОНКИ (Например: "3/5")
|
| 567 |
+
const freqText = numCompetitors > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numCompetitors}</span>` : '-';
|
| 568 |
+
|
| 569 |
+
const row = `
|
| 570 |
+
<tr class="${rowClass}">
|
| 571 |
+
<td>${item.ngram} ${icon}</td>
|
| 572 |
+
<td class="fw-bold ${countClass} border-end">${item.target_count}</td>
|
| 573 |
+
<td class="table-light fw-bold border-end">${item.competitor_avg}</td>
|
| 574 |
+
<!-- НОВАЯ ЯЧЕЙКА -->
|
| 575 |
+
<td class="table-secondary fw-bold text-center border-end">${freqText}</td>
|
| 576 |
+
${compCells}
|
| 577 |
+
</tr>`;
|
| 578 |
+
tbody.insertAdjacentHTML('beforeend', row);
|
| 579 |
+
});
|
| 580 |
+
} else {
|
| 581 |
+
tbody.innerHTML = `<tr><td colspan="${4 + numCompetitors}" class="text-center text-muted">Нет данных</td></tr>`;
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
function renderTitleResults(data) {
|
| 586 |
+
const container = document.getElementById('titleResultsContainer');
|
| 587 |
+
if (!data.title_analysis || !data.title_analysis.target_title) {
|
| 588 |
+
container.innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>';
|
| 589 |
+
return;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
const ta = data.title_analysis;
|
| 593 |
+
let html = '';
|
| 594 |
|
| 595 |
+
// === BLOCK 1: Length ===
|
| 596 |
+
const len = ta.length;
|
| 597 |
+
let charBadge = 'bg-success';
|
| 598 |
+
let charHint = 'Оптимально';
|
| 599 |
+
if (len.status === 'too_long') { charBadge = 'bg-danger'; charHint = 'Слишком длинный (>60)'; }
|
| 600 |
+
else if (len.status === 'too_short') { charBadge = 'bg-warning text-dark'; charHint = 'Слишком короткий (<30)'; }
|
| 601 |
+
|
| 602 |
+
let compLenRows = '';
|
| 603 |
+
if (ta.competitor_titles && ta.competitor_titles.length > 0) {
|
| 604 |
+
ta.competitor_titles.forEach((ct, idx) => {
|
| 605 |
+
const cd = len.comp_data[idx];
|
| 606 |
+
compLenRows += `
|
| 607 |
+
<tr>
|
| 608 |
+
<td class="text-truncate" style="max-width: 300px;" title="${ct}">K${idx+1}: ${ct}</td>
|
| 609 |
+
<td class="text-center">${cd.chars}</td>
|
| 610 |
+
<td class="text-center">${cd.words.total}</td>
|
| 611 |
+
</tr>`;
|
| 612 |
+
});
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
html += `
|
| 616 |
+
<div class="stat-card">
|
| 617 |
+
<h5 class="card-title mb-3">Длина Title</h5>
|
| 618 |
+
<div class="d-flex align-items-center mb-3">
|
| 619 |
+
<span class="display-6 fw-bold me-3">${len.target_chars}</span>
|
| 620 |
+
<div>
|
| 621 |
+
<span class="badge ${charBadge}">${charHint}</span>
|
| 622 |
+
<div class="text-muted small mt-1">Слов: ${len.target_words.total} (значимых: ${len.target_words.significant})</div>
|
| 623 |
+
</div>
|
| 624 |
</div>
|
| 625 |
+
${compLenRows ? `
|
| 626 |
+
<table class="table table-sm table-bordered">
|
| 627 |
+
<thead><tr><th>Title</th><th class="text-center">Символы</th><th class="text-center">Слова</th></tr></thead>
|
| 628 |
+
<tbody>${compLenRows}</tbody>
|
| 629 |
+
<tfoot><tr class="table-light fw-bold"><td>Среднее</td><td class="text-center">${len.avg_chars}</td><td class="text-center">${len.avg_words_total}</td></tr></tfoot>
|
| 630 |
+
</table>` : ''}
|
| 631 |
+
</div>`;
|
| 632 |
|
| 633 |
+
// === BLOCK 2: Keyword Coverage ===
|
| 634 |
+
if (ta.keyword_coverage && ta.keyword_coverage.length > 0) {
|
| 635 |
+
let coverageRows = '';
|
| 636 |
+
ta.keyword_coverage.forEach(kw => {
|
| 637 |
+
let badge = 'bg-danger';
|
| 638 |
+
let label = 'Нет';
|
| 639 |
+
if (kw.exact_match) { badge = 'bg-success'; label = 'Точное'; }
|
| 640 |
+
else if (kw.word_coverage >= 50) { badge = 'bg-warning text-dark'; label = 'Частично'; }
|
| 641 |
|
| 642 |
+
const missingHtml = kw.words_missing.length > 0
|
| 643 |
+
? `<span class="text-danger small">${kw.words_missing.join(', ')}</span>`
|
| 644 |
+
: '<span class="text-success small">-</span>';
|
| 645 |
+
|
| 646 |
+
const compPresence = kw.comp_total > 0 ? `${kw.comp_presence}/${kw.comp_total}` : '-';
|
| 647 |
+
|
| 648 |
+
coverageRows += `
|
| 649 |
+
<tr>
|
| 650 |
+
<td class="fw-bold">${kw.phrase}</td>
|
| 651 |
+
<td class="text-center"><span class="badge ${badge}">${label}</span></td>
|
| 652 |
+
<td class="text-center">${kw.word_coverage}%</td>
|
| 653 |
+
<td>${missingHtml}</td>
|
| 654 |
+
<td class="text-center">${compPresence}</td>
|
| 655 |
+
</tr>`;
|
| 656 |
+
});
|
| 657 |
+
|
| 658 |
+
html += `
|
| 659 |
+
<div class="stat-card">
|
| 660 |
+
<h5 class="card-title mb-3">Покрытие ключевых фраз</h5>
|
| 661 |
+
<table class="table table-sm table-hover">
|
| 662 |
+
<thead>
|
| 663 |
+
<tr>
|
| 664 |
+
<th>Фраза</th>
|
| 665 |
+
<th class="text-center">Вхождение</th>
|
| 666 |
+
<th class="text-center">Покрытие</th>
|
| 667 |
+
<th>Отсутствуют</th>
|
| 668 |
+
<th class="text-center">У конк.</th>
|
| 669 |
+
</tr>
|
| 670 |
+
</thead>
|
| 671 |
+
<tbody>${coverageRows}</tbody>
|
| 672 |
+
</table>
|
| 673 |
+
</div>`;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
// === BLOCK 3: Title N-grams ===
|
| 677 |
+
if (ta.ngrams) {
|
| 678 |
+
['unigrams', 'bigrams'].forEach(type => {
|
| 679 |
+
const list = ta.ngrams[type];
|
| 680 |
+
if (!list || list.length === 0) return;
|
| 681 |
+
|
| 682 |
+
const typeName = type === 'unigrams' ? '1 слово' : '2 слова';
|
| 683 |
+
let numComp = 0;
|
| 684 |
+
if (list[0].competitor_detailed) numComp = list[0].competitor_detailed.length;
|
| 685 |
+
|
| 686 |
+
let tHead = '<tr><th>Слово</th><th>Target</th><th class="table-light">AVG</th><th class="table-secondary">Freq</th>';
|
| 687 |
+
for (let i = 0; i < numComp; i++) tHead += `<th>K${i+1}</th>`;
|
| 688 |
+
tHead += '</tr>';
|
| 689 |
+
|
| 690 |
+
let tBody = '';
|
| 691 |
+
list.forEach(item => {
|
| 692 |
+
let rc = '';
|
| 693 |
+
let cc = '';
|
| 694 |
+
if (item.target_count === 0 && item.competitor_avg > 0) { rc = 'table-warning'; cc = 'text-danger fw-bold'; }
|
| 695 |
+
else if (item.target_count > 0 && item.competitor_avg === 0) { cc = 'text-success'; }
|
| 696 |
+
|
| 697 |
+
let cells = '';
|
| 698 |
+
if (item.competitor_detailed) {
|
| 699 |
+
item.competitor_detailed.forEach(cnt => {
|
| 700 |
+
const s = cnt > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
|
| 701 |
+
cells += `<td class="${s}">${cnt}</td>`;
|
| 702 |
+
});
|
| 703 |
+
}
|
| 704 |
+
const freq = numComp > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numComp}</span>` : '-';
|
| 705 |
+
|
| 706 |
+
tBody += `
|
| 707 |
+
<tr class="${rc}">
|
| 708 |
+
<td>${item.ngram}</td>
|
| 709 |
+
<td class="fw-bold ${cc} border-end">${item.target_count}</td>
|
| 710 |
+
<td class="table-light fw-bold border-end">${item.competitor_avg}</td>
|
| 711 |
+
<td class="table-secondary fw-bold text-center border-end">${freq}</td>
|
| 712 |
+
${cells}
|
| 713 |
+
</tr>`;
|
| 714 |
+
});
|
| 715 |
+
|
| 716 |
+
html += `
|
| 717 |
+
<div class="stat-card">
|
| 718 |
+
<h6 class="card-title">N-граммы Title (${typeName})</h6>
|
| 719 |
+
<div class="scrollable-table">
|
| 720 |
+
<table class="table table-sm">
|
| 721 |
+
<thead>${tHead}</thead>
|
| 722 |
+
<tbody>${tBody}</tbody>
|
| 723 |
+
</table>
|
| 724 |
+
</div>
|
| 725 |
+
</div>`;
|
| 726 |
+
});
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
// === BLOCK 4: BERT Semantic ===
|
| 730 |
+
if (ta.bert && ta.bert.per_keyword && ta.bert.per_keyword.length > 0) {
|
| 731 |
+
let bertRows = '';
|
| 732 |
+
ta.bert.per_keyword.forEach(kw => {
|
| 733 |
+
const compScores = kw.comp_scores.map((s, i) => `<td>${s}</td>`).join('');
|
| 734 |
+
const best = Math.max(...kw.comp_scores, 0);
|
| 735 |
+
let cls = '';
|
| 736 |
+
if (kw.target_score >= best - 0.05) cls = 'text-success';
|
| 737 |
+
else if (kw.target_score < best - 0.15) cls = 'text-danger';
|
| 738 |
+
else cls = 'text-warning';
|
| 739 |
+
|
| 740 |
+
bertRows += `
|
| 741 |
+
<tr>
|
| 742 |
+
<td class="fw-bold">${kw.keyword}</td>
|
| 743 |
+
<td class="fw-bold ${cls} border-end">${kw.target_score}</td>
|
| 744 |
+
${compScores}
|
| 745 |
+
</tr>`;
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
let compHeaders = '';
|
| 749 |
+
if (ta.bert.comp_scores) {
|
| 750 |
+
ta.bert.comp_scores.forEach((s, i) => { compHeaders += `<th>K${i+1}</th>`; });
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
html += `
|
| 754 |
+
<div class="stat-card">
|
| 755 |
+
<h5 class="card-title mb-3">Семантика Title (BERT)</h5>
|
| 756 |
+
<div class="d-flex align-items-baseline mb-3">
|
| 757 |
+
<span class="h4 fw-bold me-2">Мой: ${ta.bert.target_score}</span>
|
| 758 |
+
${ta.bert.comp_scores ? ta.bert.comp_scores.map((s,i) => `<span class="badge bg-secondary me-1">K${i+1}: ${s}</span>`).join('') : ''}
|
| 759 |
+
</div>
|
| 760 |
+
<table class="table table-sm table-hover">
|
| 761 |
+
<thead><tr><th>Ключ</th><th class="border-end">Target</th>${compHeaders}</tr></thead>
|
| 762 |
+
<tbody>${bertRows}</tbody>
|
| 763 |
+
</table>
|
| 764 |
+
</div>`;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
container.innerHTML = html;
|
| 768 |
+
}
|
| 769 |
+
</script>
|
| 770 |
</body>
|
| 771 |
</html>
|
url_fetcher.py
DELETED
|
@@ -1,143 +0,0 @@
|
|
| 1 |
-
from typing import Dict, List
|
| 2 |
-
from urllib.parse import urlparse
|
| 3 |
-
import re
|
| 4 |
-
|
| 5 |
-
import requests
|
| 6 |
-
from bs4 import BeautifulSoup
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
USER_AGENT_PRESETS: List[Dict[str, str]] = [
|
| 10 |
-
{
|
| 11 |
-
"key": "googlebot",
|
| 12 |
-
"name": "Googlebot",
|
| 13 |
-
"value": "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
| 14 |
-
},
|
| 15 |
-
{
|
| 16 |
-
"key": "bingbot",
|
| 17 |
-
"name": "Bingbot",
|
| 18 |
-
"value": "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
| 19 |
-
},
|
| 20 |
-
{
|
| 21 |
-
"key": "chatgpt_user",
|
| 22 |
-
"name": "ChatGPT-User",
|
| 23 |
-
"value": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot",
|
| 24 |
-
},
|
| 25 |
-
{
|
| 26 |
-
"key": "gptbot",
|
| 27 |
-
"name": "GPTBot",
|
| 28 |
-
"value": "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot",
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
"key": "chrome_desktop",
|
| 32 |
-
"name": "Chrome (Desktop)",
|
| 33 |
-
"value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
| 34 |
-
},
|
| 35 |
-
]
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def get_user_agent_presets() -> List[Dict[str, str]]:
|
| 39 |
-
return USER_AGENT_PRESETS
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _normalize_whitespace(text: str) -> str:
|
| 43 |
-
text = re.sub(r"[ \t]+", " ", text)
|
| 44 |
-
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 45 |
-
return text.strip()
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _normalize_url(url: str) -> str:
|
| 49 |
-
raw = (url or "").strip()
|
| 50 |
-
if not raw:
|
| 51 |
-
raise ValueError("URL пустой.")
|
| 52 |
-
|
| 53 |
-
if not raw.startswith(("http://", "https://")):
|
| 54 |
-
raw = "https://" + raw
|
| 55 |
-
|
| 56 |
-
parsed = urlparse(raw)
|
| 57 |
-
if not parsed.scheme or not parsed.netloc:
|
| 58 |
-
raise ValueError("Некорректный URL.")
|
| 59 |
-
return raw
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _resolve_user_agent(user_agent_key: str) -> Dict[str, str]:
|
| 63 |
-
key = (user_agent_key or "").strip()
|
| 64 |
-
for ua in USER_AGENT_PRESETS:
|
| 65 |
-
if ua["key"] == key:
|
| 66 |
-
return ua
|
| 67 |
-
for ua in USER_AGENT_PRESETS:
|
| 68 |
-
if ua["key"] == "chrome_desktop":
|
| 69 |
-
return ua
|
| 70 |
-
return USER_AGENT_PRESETS[0]
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _extract_main_text_and_title(html: str) -> Dict[str, str]:
|
| 74 |
-
soup = BeautifulSoup(html or "", "html.parser")
|
| 75 |
-
|
| 76 |
-
for bad in soup(["script", "style", "noscript", "svg", "nav", "footer", "header", "aside", "form", "iframe"]):
|
| 77 |
-
bad.decompose()
|
| 78 |
-
|
| 79 |
-
title = ""
|
| 80 |
-
if soup.title:
|
| 81 |
-
title = _normalize_whitespace(soup.title.get_text(" ", strip=True))
|
| 82 |
-
|
| 83 |
-
best_text = ""
|
| 84 |
-
|
| 85 |
-
# Priority 1: semantic containers usually containing article text.
|
| 86 |
-
candidates = []
|
| 87 |
-
for selector, boost in (("article", 1.2), ("main", 1.1)):
|
| 88 |
-
for node in soup.select(selector):
|
| 89 |
-
t = _normalize_whitespace(node.get_text("\n", strip=True))
|
| 90 |
-
if len(t) >= 200:
|
| 91 |
-
candidates.append((len(t) * boost, t))
|
| 92 |
-
|
| 93 |
-
if candidates:
|
| 94 |
-
best_text = max(candidates, key=lambda x: x[0])[1]
|
| 95 |
-
else:
|
| 96 |
-
# Priority 2: collect meaningful paragraphs and list content.
|
| 97 |
-
paragraphs: List[str] = []
|
| 98 |
-
for p in soup.find_all(["p", "li"]):
|
| 99 |
-
txt = _normalize_whitespace(p.get_text(" ", strip=True))
|
| 100 |
-
if len(txt) >= 40:
|
| 101 |
-
paragraphs.append(txt)
|
| 102 |
-
if len(paragraphs) >= 3:
|
| 103 |
-
best_text = "\n\n".join(paragraphs)
|
| 104 |
-
|
| 105 |
-
# Priority 3: fallback to body text.
|
| 106 |
-
if not best_text:
|
| 107 |
-
body = soup.body if soup.body else soup
|
| 108 |
-
best_text = _normalize_whitespace(body.get_text("\n", strip=True))
|
| 109 |
-
|
| 110 |
-
return {
|
| 111 |
-
"title": title,
|
| 112 |
-
"text": best_text,
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def fetch_url_content(url: str, user_agent_key: str = "chrome_desktop", timeout_seconds: int = 15) -> Dict[str, str]:
|
| 117 |
-
normalized_url = _normalize_url(url)
|
| 118 |
-
ua = _resolve_user_agent(user_agent_key)
|
| 119 |
-
|
| 120 |
-
headers = {
|
| 121 |
-
"User-Agent": ua["value"],
|
| 122 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 123 |
-
}
|
| 124 |
-
response = requests.get(
|
| 125 |
-
normalized_url,
|
| 126 |
-
headers=headers,
|
| 127 |
-
timeout=max(5, min(int(timeout_seconds or 15), 40)),
|
| 128 |
-
allow_redirects=True,
|
| 129 |
-
)
|
| 130 |
-
response.raise_for_status()
|
| 131 |
-
|
| 132 |
-
extracted = _extract_main_text_and_title(response.text or "")
|
| 133 |
-
return {
|
| 134 |
-
"ok": True,
|
| 135 |
-
"url": normalized_url,
|
| 136 |
-
"final_url": response.url,
|
| 137 |
-
"status_code": response.status_code,
|
| 138 |
-
"user_agent_key": ua["key"],
|
| 139 |
-
"user_agent_value": ua["value"],
|
| 140 |
-
"title": extracted["title"],
|
| 141 |
-
"text": extracted["text"],
|
| 142 |
-
"error": "",
|
| 143 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|