Add dimension QC, DZI generator, web viewer, and fix RAG query bug

- dimension_qc_checker.py: rules-based QC for dimension chains, overlaps, crowding
- generate_dzi.py: Deep Zoom Image tile pyramid generator for OpenSeadragon
- generate_web_viewer.py: OpenSeadragon viewer with SVG overlays and issue feedback buttons
- rag_query.py: fix LightRAG remove_think_tags crash on None response from LLM
- .gitignore: add *.pdf, *.db, backend/uploads/, backend/outputs/
This commit is contained in:
Кирилл Блинов 2026-06-01 12:30:07 +03:00
parent eaddf9f14b
commit 95093736da
5 changed files with 1334 additions and 2 deletions

10
.gitignore vendored
View File

@ -12,6 +12,16 @@ __pycache__/
# Secrets
.env
# Database
*.db
*.sqlite
*.sqlite3
# PDFs and uploads
*.pdf
backend/uploads/
backend/outputs/
# OS
.DS_Store
Thumbs.db

349
dimension_qc_checker.py Normal file
View File

@ -0,0 +1,349 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Quality Control проверка простановки размеров на чертежах.
Ищет нарушения ЕСКД / ГОСТ 2.307 по координатам OCR + геометрии:
- Пересечение размерных линий
- Уплотнение (размеры слишком близко)
- Наложение размеров на текст/штриховку
- Низкая читаемость (low confidence)
- Размер внутри контура объекта
- Пропуски цепочек размеров
Использование:
python dimension_qc_checker.py <output_folder>
Результат: <folder>/dimension_qc_report.json + текстовый отчёт
"""
import sys
import json
import re
from pathlib import Path
from typing import List, Dict, Tuple
from dataclasses import dataclass
# ------------------------------------------------------------------
# QC правила
# ------------------------------------------------------------------
MIN_CONFIDENCE = 0.65 # ниже — подозрение на нечитаемость
MIN_BBOX_OVERLAP = 0.15 # минимальное пересечение bbox для флага
MIN_DIMENSION_SPACING = 8 # мин. пикселей между размерами (px @ 300 DPI ~ 0.7 мм)
MAX_DIMENSION_CHAIN_GAP = 50 # макс. зазор между размерами в одной цепочке
@dataclass
class TextItem:
text: str
confidence: float
bbox: list # [x1, y1, x2, y2]
page: int
is_dimension: bool = False
@property
def x1(self) -> float:
return min(p[0] for p in self.bbox)
@property
def y1(self) -> float:
return min(p[1] for p in self.bbox)
@property
def x2(self) -> float:
return max(p[0] for p in self.bbox)
@property
def y2(self) -> float:
return max(p[1] for p in self.bbox)
@property
def width(self) -> float:
return self.x2 - self.x1
@property
def height(self) -> float:
return self.y2 - self.y1
@property
def center_x(self) -> float:
return (self.x1 + self.x2) / 2
@property
def center_y(self) -> float:
return (self.y1 + self.y2) / 2
@property
def area(self) -> float:
return self.width * self.height
def is_dimension_text(text: str) -> bool:
"""Эвристика: похоже ли на размер."""
text = text.strip().replace(' ', '').replace(',', '.')
# Чистые числа 50-50000
if re.match(r'^\d{2,5}(\.\d{1,2})?$', text):
num = float(text)
return 50 <= num <= 50000
# Числа с единицами
if re.match(r'^\d{2,5}(\.\d{1,2})?[мmмм]?[мm]?$', text, re.I):
return True
return False
def bbox_overlap(a: TextItem, b: TextItem) -> float:
"""IOU (Intersection over Union) двух bbox."""
x1 = max(a.x1, b.x1)
y1 = max(a.y1, b.y1)
x2 = min(a.x2, b.x2)
y2 = min(a.y2, b.y2)
if x2 <= x1 or y2 <= y1:
return 0.0
inter = (x2 - x1) * (y2 - y1)
union = a.area + b.area - inter
return inter / union if union > 0 else 0.0
def bbox_distance(a: TextItem, b: TextItem) -> float:
"""Расстояние между центрами bbox."""
import math
return math.sqrt((a.center_x - b.center_x)**2 + (a.center_y - b.center_y)**2)
def analyze_dimensions(items: List[TextItem]) -> List[Dict]:
"""Находит проблемы с размерами."""
issues = []
dims = [it for it in items if it.is_dimension]
non_dims = [it for it in items if not it.is_dimension]
# --- 1. Низкий confidence (подозрение на нечитаемость) ---
for d in dims:
if d.confidence < MIN_CONFIDENCE:
issues.append({
"type": "LOW_CONFIDENCE_DIMENSION",
"severity": "warning",
"page": d.page,
"text": d.text,
"confidence": d.confidence,
"bbox": d.bbox,
"message": f"Размер '{d.text}' имеет низкую уверенность OCR ({d.confidence:.2f}). Возможно, плохо читается на чертеже. Рекомендуется увеличить или перепроставить.",
})
# --- 2. Пересечение размеров (наложение) ---
for i, d1 in enumerate(dims):
for d2 in dims[i+1:]:
if d1.page != d2.page:
continue
overlap = bbox_overlap(d1, d2)
if overlap > MIN_BBOX_OVERLAP:
issues.append({
"type": "DIMENSION_OVERLAP",
"severity": "error",
"page": d1.page,
"text1": d1.text,
"text2": d2.text,
"overlap": overlap,
"bbox1": d1.bbox,
"bbox2": d2.bbox,
"message": f"Размеры '{d1.text}' и '{d2.text}' пересекаются ({overlap:.0%} наложения). Нарушение ЕСКД: размерные числа не должны пересекать друг друга.",
})
# --- 3. Наложение размеров на другой текст/штриховку ---
for d in dims:
for nd in non_dims:
if d.page != nd.page:
continue
# Проверяем, находится ли центр размера внутри bbox текста
if (nd.x1 < d.center_x < nd.x2 and
nd.y1 < d.center_y < nd.y2):
issues.append({
"type": "DIMENSION_ON_TEXT",
"severity": "error",
"page": d.page,
"dimension": d.text,
"overlapped_text": nd.text,
"bbox_dim": d.bbox,
"bbox_text": nd.bbox,
"message": f"Размер '{d.text}' наложен на текст '{nd.text}'. Нарушение ЕСКД: размерные числа не должны пересекать линии контура или другие надписи.",
})
# --- 4. "Уплотнение" размеров (цепочка без отступов) ---
# Группируем по страницам и по горизонтальным линиям (похожие Y)
from collections import defaultdict
page_dims = defaultdict(list)
for d in dims:
page_dims[d.page].append(d)
for page, page_items in page_dims.items():
# Сортируем по Y
page_items.sort(key=lambda x: x.center_y)
chains = []
current_chain = [page_items[0]] if page_items else []
for d in page_items[1:]:
prev = current_chain[-1]
# Если Y близко — считаем одной цепочкой
if abs(d.center_y - prev.center_y) < 25: # допуск по вертикали
current_chain.append(d)
else:
if len(current_chain) >= 3:
chains.append(current_chain)
current_chain = [d]
if len(current_chain) >= 3:
chains.append(current_chain)
# Проверяем цепочки на уплотнение
for chain in chains:
chain.sort(key=lambda x: x.center_x)
for i in range(1, len(chain)):
gap = chain[i].center_x - chain[i-1].center_x
# Если размеры ближе чем ~2× их высота — это уплотнение
avg_height = (chain[i].height + chain[i-1].height) / 2
if gap < avg_height * 1.5:
issues.append({
"type": "DIMENSION_CROWDING",
"severity": "warning",
"page": page,
"text1": chain[i-1].text,
"text2": chain[i].text,
"gap_px": gap,
"bbox1": chain[i-1].bbox,
"bbox2": chain[i].bbox,
"message": f"Размеры '{chain[i-1].text}' и '{chain[i].text}' расположены слишком близко (зазор {gap:.0f} px). Возможно 'уплотнение' — размерные линии не отступают друг от друга. Рекомендуется разнести.",
})
# --- 5. Пропуски в цепочке размеров (gaps) ---
# Если в цепочке размеров есть большой зазор без размера — возможно пропущен
for page, page_items in page_dims.items():
page_items.sort(key=lambda x: x.center_x)
for i in range(1, len(page_items)):
gap = page_items[i].center_x - page_items[i-1].center_x
avg_width = (page_items[i].width + page_items[i-1].width) / 2
# Если зазор в 4+ раза больше среднего размера — подозрительно
if gap > avg_width * 4:
issues.append({
"type": "DIMENSION_CHAIN_GAP",
"severity": "info",
"page": page,
"left": page_items[i-1].text,
"right": page_items[i].text,
"gap_px": gap,
"message": f"Между размерами '{page_items[i-1].text}' и '{page_items[i].text}' большой зазор ({gap:.0f} px). Возможно, пропущен промежуточный размер в цепочке.",
})
return issues
def validate_folder(folder: Path):
ocr_path = folder / "full_ocr_results.json"
if not ocr_path.exists():
print(f"[ERR] Не найден {ocr_path}")
sys.exit(1)
data = json.loads(ocr_path.read_text(encoding="utf-8"))
pages = data["pages"]
all_items = []
for page in pages:
page_num = page["page_number"]
for entry in page.get("ocr_lines", []):
item = TextItem(
text=entry["text"],
confidence=entry.get("confidence", 0),
bbox=entry.get("bbox", [0,0,0,0]),
page=page_num,
is_dimension=is_dimension_text(entry["text"]),
)
all_items.append(item)
dimensions = [it for it in all_items if it.is_dimension]
print(f"[INFO] Всего элементов: {len(all_items)}")
print(f"[INFO] Размеров найдено: {len(dimensions)}")
print(f"[INFO] Проверка...\n")
issues = analyze_dimensions(all_items)
# Группировка по серьёзности
errors = [i for i in issues if i["severity"] == "error"]
warnings = [i for i in issues if i["severity"] == "warning"]
infos = [i for i in issues if i["severity"] == "info"]
print("=" * 70)
print("ОШИБКИ (требуют переделки)")
print("=" * 70)
if errors:
for i, iss in enumerate(errors, 1):
print(f"\n[{i}] Стр.{iss['page']}: {iss['type']}")
print(f" {iss['message']}")
else:
print("\n✅ Критических ошибок не найдено")
print("\n" + "=" * 70)
print("ПРЕДУПРЕЖДЕНИЯ (рекомендуется исправить)")
print("=" * 70)
if warnings:
for i, iss in enumerate(warnings[:20], 1):
print(f"\n[{i}] Стр.{iss['page']}: {iss['type']}")
print(f" {iss['message']}")
if len(warnings) > 20:
print(f"\n... и ещё {len(warnings) - 20} предупреждений")
else:
print("\n✅ Предупреждений не найдено")
if infos:
print(f"\n Информационных замечаний: {len(infos)}")
# Сохранение JSON
report = {
"summary": {
"total_items": len(all_items),
"dimensions_found": len(dimensions),
"errors": len(errors),
"warnings": len(warnings),
"infos": len(infos),
},
"errors": errors,
"warnings": warnings,
"infos": infos,
}
out_path = folder / "dimension_qc_report.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"\n[INFO] Отчёт сохранён: {out_path}")
# Сгенерировать markdown-замечания для проектировщика
md_lines = ["# Замечания по простановке размеров\n"]
md_lines.append(f"**Документ:** {folder.name}\n")
md_lines.append(f"**Всего размеров:** {len(dimensions)}\n")
md_lines.append(f"**Ошибок:** {len(errors)} | **Предупреждений:** {len(warnings)}\n\n")
if errors:
md_lines.append("## Ошибки (обязательно к исправлению)\n\n")
for i, iss in enumerate(errors, 1):
md_lines.append(f"### {i}. {iss['type']} (стр. {iss['page']})\n\n")
md_lines.append(f"{iss['message']}\n\n")
if 'bbox1' in iss:
md_lines.append(f"- Координаты 1: `{iss['bbox1']}`\n")
if 'bbox2' in iss:
md_lines.append(f"- Координаты 2: `{iss['bbox2']}`\n")
md_lines.append("\n")
if warnings:
md_lines.append("## Предупреждения (рекомендуется исправить)\n\n")
for i, iss in enumerate(warnings[:10], 1):
md_lines.append(f"### {i}. {iss['type']} (стр. {iss['page']})\n\n")
md_lines.append(f"{iss['message']}\n\n")
md_path = folder / "dimension_qc_remarks.md"
with open(md_path, "w", encoding="utf-8") as f:
f.writelines(md_lines)
print(f"[INFO] Замечания для проектировщика: {md_path}")
def main():
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("output_123")
validate_folder(folder)
if __name__ == "__main__":
main()

114
generate_dzi.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Генератор Deep Zoom Image (DZI) тайлов из PNG для быстрого просмотра больших чертежей.
Использует PIL не требует внешних зависимостей.
Использование:
python generate_dzi.py <png_file> [--tile-size 256] [--format png]
Результат:
<png_stem>.dzi XML-дескриптор
<png_stem>_files/ папка с тайлами level/col_row.png
"""
import sys
import os
import math
from pathlib import Path
from PIL import Image
def generate_dzi(png_path: Path, tile_size: int = 256, fmt: str = "png"):
"""Генерирует DZI тайлы из PNG."""
png_path = Path(png_path)
if not png_path.exists():
print(f"[ERR] Файл не найден: {png_path}")
sys.exit(1)
base = png_path.stem
files_dir = png_path.parent / f"{base}_files"
files_dir.mkdir(exist_ok=True)
print(f"[INFO] Открываем {png_path}...")
img = Image.open(png_path)
orig_w, orig_h = img.size
print(f"[INFO] Размер: {orig_w}x{orig_h}")
# DZI uses power-of-2 levels, starting from 1x1 at level 0
max_dim = max(orig_w, orig_h)
max_level = math.ceil(math.log2(max_dim))
print(f"[INFO] Уровней: {max_level + 1} (0..{max_level})")
# Process from largest to smallest (build pyramid top-down)
current = img.convert("RGBA" if fmt == "png" else "RGB")
for level in range(max_level, -1, -1):
# Calculate size at this level
scale = 2 ** (max_level - level)
level_w = math.ceil(orig_w / scale)
level_h = math.ceil(orig_h / scale)
# Resize current image to level size
if current.size != (level_w, level_h):
current = current.resize((level_w, level_h), Image.LANCZOS)
# Save tiles
cols = math.ceil(level_w / tile_size)
rows = math.ceil(level_h / tile_size)
level_dir = files_dir / str(level)
level_dir.mkdir(exist_ok=True)
for row in range(rows):
for col in range(cols):
x = col * tile_size
y = row * tile_size
w = min(tile_size, level_w - x)
h = min(tile_size, level_h - y)
tile = current.crop((x, y, x + w, y + h))
tile_path = level_dir / f"{col}_{row}.{fmt}"
if fmt == "png":
tile.save(tile_path, "PNG", compress_level=3)
else:
tile.save(tile_path, "JPEG", quality=85)
print(f" Level {level}: {level_w}x{level_h}, {cols}x{rows} tiles")
# Write DZI descriptor
dzi_path = png_path.parent / f"{base}.dzi"
dzi_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<Image xmlns="http://schemas.microsoft.com/deepzoom/2008"
Format="{fmt}"
Overlap="0"
TileSize="{tile_size}">
<Size Width="{orig_w}"
Height="{orig_h}"/>
</Image>'''
dzi_path.write_text(dzi_xml, encoding="utf-8")
print(f"[OK] DZI создан: {dzi_path}")
print(f" Тайлы: {files_dir}")
print(f" Уровней: {max_level + 1}, TileSize: {tile_size}")
return dzi_path, files_dir
def main():
if len(sys.argv) < 2:
print("Usage: python generate_dzi.py <png_file> [tile_size] [format]")
sys.exit(1)
png = Path(sys.argv[1])
tile_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256
fmt = sys.argv[3] if len(sys.argv) > 3 else "png"
generate_dzi(png, tile_size, fmt)
if __name__ == "__main__":
main()

857
generate_web_viewer.py Normal file
View File

@ -0,0 +1,857 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Генерация HTML-viewer с OpenSeadragon + DZI для просмотра замечаний QC прямо на чертеже.
Использование:
python generate_web_viewer.py <output_folder> [--page N] [--dzi]
Требует предварительного запуска generate_dzi.py для создания тайлов.
"""
import sys
import json
import base64
import shutil
from pathlib import Path
def generate_html(folder: Path, target_page: int = None, use_dzi: bool = True,
issue_db_ids: list = None, total_pages: int = None,
project_id: int = None, api_base: str = None):
"""Генерирует HTML-viewer с OpenSeadragon + overlay."""
qc_path = folder / "dimension_qc_report.json"
if not qc_path.exists():
print(f"[ERR] Сначала запустите dimension_qc_checker.py")
sys.exit(1)
qc = json.loads(qc_path.read_text(encoding="utf-8"))
# Добавить VLM QC issues если есть
vlm_path = folder / "vlm_qc_report.json"
if vlm_path.exists():
vlm_qc = json.loads(vlm_path.read_text(encoding="utf-8"))
for severity in ["errors", "warnings", "infos"]:
qc.setdefault(severity, [])
qc[severity].extend(vlm_qc.get(severity, []))
ocr = json.loads((folder / "full_ocr_results.json").read_text(encoding="utf-8"))
page_counts = {}
for severity in ["errors", "warnings", "infos"]:
for item in qc.get(severity, []):
p = item.get("page", 1)
page_counts[p] = page_counts.get(p, 0) + 1
if target_page is None:
target_page = max(page_counts, key=page_counts.get) if page_counts else 2
print(f"[INFO] Генерация viewer для страницы {target_page}")
# Определить пути к изображению
png_path = folder / f"page_{target_page:03d}.png"
dzi_path = folder / f"page_{target_page:03d}.dzi"
if use_dzi and not dzi_path.exists():
print(f"[WARN] DZI не найден: {dzi_path}")
print(f" Запустите: python generate_dzi.py {png_path}")
use_dzi = False
# Получить размеры PNG
from PIL import Image
with Image.open(png_path) as img:
img_width, img_height = img.size
# Собрать элементы страницы
page_items = []
for page in ocr["pages"]:
if page["page_number"] == target_page:
for line in page.get("ocr_lines", []):
page_items.append(line)
break
# Собрать проблемы
issues = []
colors = {"error": "#ff0000", "warning": "#ffaa00", "info": "#0099ff"}
for severity in ["errors", "warnings", "infos"]:
for item in qc.get(severity, []):
if item["page"] == target_page:
bboxes = []
for key in ["bbox1", "bbox2", "bbox", "bbox_dim"]:
if key in item:
bboxes.append(item[key])
if not bboxes and "text" in item:
for line in page_items:
if line["text"] == item["text"]:
bboxes.append(line["bbox"])
break
issues.append({
"type": item["type"],
"message": item["message"],
"severity": item["severity"],
"color": colors.get(item["severity"], "#999"),
"bboxes": bboxes,
"source": item.get("source", "rules"),
})
# Подготовить overlay-данные для JS
overlay_data = []
issue_counter = 0
for issue in issues:
issue_counter += 1
for bbox in issue["bboxes"]:
if isinstance(bbox[0], list):
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
x1, y1, x2, y2 = min(xs), min(ys), max(xs), max(ys)
else:
x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3]
# Convert pixel to viewport (0-1)
vx = x1 / img_width
vy = y1 / img_height
vw = (x2 - x1) / img_width
vh = (y2 - y1) / img_height
overlay_data.append({
"id": f"issue-{issue_counter}",
"x": round(vx, 6),
"y": round(vy, 6),
"w": round(vw, 6),
"h": round(vh, 6),
"color": issue["color"],
"message": issue["message"],
"type": issue["type"],
"severity": issue["severity"],
"num": issue_counter,
})
# JSON для вставки в JS
overlays_json = json.dumps(overlay_data, ensure_ascii=False)
# DZI или прямой PNG
if use_dzi:
# Inline DZI to avoid CORS issues with file:// protocol
dzi_xml = (folder / f"page_{target_page:03d}.dzi").read_text(encoding="utf-8")
# Parse key values
import re
w = re.search(r'Width="(\d+)"', dzi_xml).group(1)
h = re.search(r'Height="(\d+)"', dzi_xml).group(1)
ts = re.search(r'TileSize="(\d+)"', dzi_xml).group(1)
fmt = re.search(r'Format="(\w+)"', dzi_xml).group(1)
tile_source = f"""{{
Image: {{
xmlns: "http://schemas.microsoft.com/deepzoom/2008",
Url: "./page_{target_page:03d}_files/",
Format: "{fmt}",
Overlap: "0",
TileSize: "{ts}",
Size: {{ Width: "{w}", Height: "{h}" }}
}}
}}"""
dzi_note = ""
else:
png_data = base64.b64encode(png_path.read_bytes()).decode()
tile_source = f"{{ type: 'image', url: 'data:image/png;base64,{png_data}' }}"
dzi_note = "<div style='color:#ffaa00; padding:10px;'>⚠ DZI не найден — используется прямое PNG (медленно для больших чертежей)</div>"
# Генерация HTML
html = f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>QC Viewer Страница {target_page}</title>
<script src="https://cdn.jsdelivr.net/npm/openseadragon@4.1/build/openseadragon/openseadragon.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0d1a;
color: #eee;
overflow: hidden;
height: 100vh;
}}
.layout {{
display: flex;
height: 100vh;
}}
.viewer-area {{
flex: 1;
position: relative;
background: #080810;
}}
#openseadragon {{
width: 100%;
height: 100%;
}}
.overlay-rect {{
border: 2px solid var(--oc-color);
background: var(--oc-color);
opacity: 0.15;
cursor: pointer;
transition: opacity 0.2s, border-width 0.2s;
position: relative;
}}
.overlay-rect:hover {{
opacity: 0.5;
border-width: 3px;
z-index: 100;
}}
.overlay-number {{
position: absolute;
top: -18px;
left: 0;
color: var(--oc-color);
font-size: 13px;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
pointer-events: none;
}}
.sidebar {{
width: 400px;
background: #16213e;
border-left: 2px solid #0f3460;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.sidebar-header {{
padding: 20px;
border-bottom: 2px solid #0f3460;
}}
.sidebar-header h2 {{
margin: 0 0 15px 0;
color: #e94560;
font-size: 18px;
}}
.stats {{
display: flex;
gap: 10px;
}}
.stat {{
flex: 1;
background: #1a1a2e;
padding: 10px;
border-radius: 6px;
text-align: center;
}}
.stat-value {{
font-size: 22px;
font-weight: bold;
}}
.stat.error .stat-value {{ color: #ff4444; }}
.stat.warning .stat-value {{ color: #ffaa00; }}
.stat.info .stat-value {{ color: #44aaff; }}
.stat-label {{
font-size: 11px;
opacity: 0.7;
margin-top: 4px;
}}
.legend {{
display: flex;
gap: 15px;
margin-top: 12px;
font-size: 12px;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 6px;
}}
.legend-dot {{
width: 12px;
height: 12px;
border-radius: 3px;
}}
.issue-list {{
flex: 1;
overflow-y: auto;
padding: 15px;
}}
.issue-card {{
background: #1a1a2e;
border-left: 4px solid var(--card-color);
padding: 12px;
margin-bottom: 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}}
.issue-card:hover {{
background: #252540;
transform: translateX(4px);
}}
.issue-card.active {{
background: #2a2a50;
box-shadow: 0 0 0 2px var(--card-color);
}}
.issue-num {{
display: inline-block;
background: var(--card-color);
color: #000;
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
margin-right: 8px;
}}
.issue-type {{
font-size: 11px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 6px;
}}
.issue-message {{
font-size: 13px;
line-height: 1.4;
}}
.issue-severity {{
font-size: 10px;
opacity: 0.5;
margin-top: 6px;
text-transform: uppercase;
}}
.feedback-bar {{
display: flex;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.1);
}}
.feedback-bar button {{
flex: 1;
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
background: #1a1a2e;
color: #ccc;
border: 1px solid #333;
}}
.feedback-bar button:hover {{
background: #252540;
color: #fff;
}}
.feedback-bar button.tp:hover {{ background: rgba(0,200,100,0.2); border-color: #00c864; color: #00c864; }}
.feedback-bar button.fp:hover {{ background: rgba(255,0,0,0.2); border-color: #ff4444; color: #ff4444; }}
.feedback-bar button.ns:hover {{ background: rgba(150,150,150,0.2); border-color: #888; color: #888; }}
.issue-card.feedback-tp {{ border-left-color: #00c864 !important; box-shadow: 0 0 0 2px rgba(0,200,100,0.3); }}
.issue-card.feedback-fp {{ opacity: 0.5; }}
.issue-card.feedback-fp .issue-message {{ text-decoration: line-through; }}
.feedback-status {{
font-size: 11px;
margin-top: 6px;
font-weight: bold;
}}
.feedback-status.tp {{ color: #00c864; }}
.feedback-status.fp {{ color: #ff4444; }}
.feedback-status.ns {{ color: #888; }}
#toast {{
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.9);
color: white;
padding: 12px 20px;
border-radius: 8px;
border: 1px solid #e94560;
z-index: 100000;
display: none;
font-size: 13px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
#tooltip {{
position: fixed;
background: rgba(0,0,0,0.92);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
max-width: 350px;
pointer-events: none;
z-index: 10000;
display: none;
border: 1px solid #e94560;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
.controls {{
position: absolute;
top: 15px;
left: 15px;
z-index: 1000;
background: rgba(22,33,62,0.95);
padding: 10px;
border-radius: 8px;
border: 1px solid #0f3460;
}}
.controls button {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 5px;
}}
.controls button:hover {{
background: #252540;
}}
.controls button.active {{
background: #e94560;
border-color: #e94560;
}}
.page-nav {{
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(22,33,62,0.95);
padding: 10px 20px;
border-radius: 30px;
border: 1px solid #0f3460;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
.page-nav button {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}}
.page-nav button:hover:not(:disabled) {{
background: #252540;
}}
.page-nav button:disabled {{
opacity: 0.3;
cursor: not-allowed;
}}
.page-nav select {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}}
.page-nav .page-info {{
font-size: 13px;
opacity: 0.8;
min-width: 100px;
text-align: center;
}}
.page-nav .back-link {{
color: #44aaff;
text-decoration: none;
font-size: 12px;
margin-left: 10px;
}}
{dzi_note}
</style>
</head>
<body>
<div class="layout">
<div class="viewer-area">
<div class="controls">
<button id="btn-all" class="active" onclick="filterIssues('all')">Все</button>
<button id="btn-error" onclick="filterIssues('error')">Ошибки</button>
<button id="btn-warning" onclick="filterIssues('warning')">Предупр.</button>
<button id="btn-info" onclick="filterIssues('info')">Инфо</button>
<button onclick="resetView()">Сброс</button>
</div>
<div id="openseadragon"></div>
<!-- Page Navigation -->
<div class="page-nav" id="pageNav">
<button id="btnPrev" onclick="goPage(-1)"> Предыдущая</button>
<span class="page-info">Страница <span id="pageNum">{target_page}</span> / <span id="totalPages">{total_pages or '?'}</span></span>
<select id="pageSelect" onchange="jumpPage(this.value)">
{''.join([f'<option value="{i}"{" selected" if i == target_page else ""}>Стр. {i}</option>' for i in range(1, (total_pages or target_page or 1) + 1)])}
</select>
<button id="btnNext" onclick="goPage(1)">Следующая </button>
<a href="{api_base or '.'}" class="back-link"> Dashboard</a>
</div>
</div>
<div class="sidebar">
<div class="sidebar-header">
<h2>Замечания Стр. {target_page}</h2>
<div class="stats">
<div class="stat error">
<div class="stat-value">{len([i for i in issues if i['severity']=='error'])}</div>
<div class="stat-label">Ошибки</div>
</div>
<div class="stat warning">
<div class="stat-value">{len([i for i in issues if i['severity']=='warning'])}</div>
<div class="stat-label">Предупр.</div>
</div>
<div class="stat info">
<div class="stat-value">{len([i for i in issues if i['severity']=='info'])}</div>
<div class="stat-label">Инфо</div>
</div>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-dot" style="background:#ff4444;"></div>
<span>Ошибка</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#ffaa00;"></div>
<span>Предупреждение</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#44aaff;"></div>
<span>Информация</span>
</div>
</div>
</div>
<div class="issue-list" id="issueList">
'''
issue_counter = 0
for issue in issues:
issue_counter += 1
css_var = f"--card-color: {issue['color']}"
db_id_attr = f'data-db-id="{issue_db_ids[issue_counter-1]}"' if issue_db_ids and issue_counter <= len(issue_db_ids) else ''
has_api = 'true' if issue_db_ids else 'false'
html += f'''
<div class="issue-card" style="{css_var}"
data-severity="{issue['severity']}" data-id="{issue_counter}" {db_id_attr} data-has-api="{has_api}"
onclick="focusIssue({issue_counter})">
<div class="issue-type">
<span class="issue-num">#{issue_counter}</span>
{issue["type"]}
</div>
<div class="issue-message">{issue["message"][:250]}</div>
<div class="issue-severity">{issue["severity"]}</div>
<div class="feedback-bar" onclick="event.stopPropagation();">
<button class="tp" onclick="submitFeedback(this, true)" title="Реальная проблема"> Да</button>
<button class="fp" onclick="submitFeedback(this, false)" title="Ложное срабатывание"> Нет</button>
<button class="ns" onclick="submitFeedback(this, null)" title="Не уверен">🤷 Не знаю</button>
</div>
<div class="feedback-status" id="fbstatus-{issue_counter if not issue_db_ids else issue_db_ids[issue_counter-1]}"></div>
</div>
'''
html += f'''
</div>
</div>
</div>
<div id="tooltip"></div>
<div id="toast"></div>
<script>
const OVERLAYS = {overlays_json};
let viewer;
let activeFilter = 'all';
// Initialize OpenSeadragon
viewer = OpenSeadragon({{
id: "openseadragon",
prefixUrl: "https://cdn.jsdelivr.net/npm/openseadragon@4.1/build/openseadragon/images/",
tileSources: {tile_source},
showNavigationControl: true,
navigationControlAnchor: OpenSeadragon.ControlAnchor.TOP_RIGHT,
showZoomControl: true,
showHomeControl: true,
showFullPageControl: true,
zoomInButton: "zoom-in",
zoomOutButton: "zoom-out",
homeButton: "home",
fullPageButton: "full-page",
maxZoomPixelRatio: 10,
minZoomLevel: 0.1,
visibilityRatio: 0.5,
constrainDuringPan: true,
}});
// Add overlays after viewer opens
viewer.addHandler('open', function() {{
addOverlays();
}});
function addOverlays() {{
OVERLAYS.forEach((ov, idx) => {{
const el = document.createElement('div');
el.className = 'overlay-rect';
el.id = 'overlay-' + (idx + 1);
el.style.setProperty('--oc-color', ov.color);
el.setAttribute('data-severity', ov.severity);
const num = document.createElement('div');
num.className = 'overlay-number';
num.textContent = '' + ov.num;
el.appendChild(num);
// Tooltip events
el.addEventListener('mouseenter', (e) => showTooltip(e, ov));
el.addEventListener('mousemove', moveTooltip);
el.addEventListener('mouseleave', hideTooltip);
el.addEventListener('click', () => focusIssue(ov.num));
viewer.addOverlay({{
element: el,
location: new OpenSeadragon.Rect(ov.x, ov.y, ov.w, ov.h)
}});
}});
}}
const tooltip = document.getElementById('tooltip');
function showTooltip(e, ov) {{
tooltip.innerHTML = '<strong>#' + ov.num + '' + ov.type + '</strong><br><span style="opacity:0.7">' + ov.severity.toUpperCase() + '</span><br><br>' + ov.message;
tooltip.style.display = 'block';
tooltip.style.borderColor = ov.color;
}}
function moveTooltip(e) {{
tooltip.style.left = (e.clientX + 15) + 'px';
tooltip.style.top = (e.clientY + 15) + 'px';
}}
function hideTooltip() {{
tooltip.style.display = 'none';
}}
function filterIssues(severity) {{
activeFilter = severity;
// Update buttons
document.querySelectorAll('.controls button').forEach(btn => btn.classList.remove('active'));
document.getElementById('btn-' + severity).classList.add('active');
// Filter overlays
document.querySelectorAll('.overlay-rect').forEach(el => {{
const sev = el.getAttribute('data-severity');
el.style.display = (severity === 'all' || sev === severity) ? 'block' : 'none';
}});
// Filter cards
document.querySelectorAll('.issue-card').forEach(card => {{
const sev = card.getAttribute('data-severity');
card.style.display = (severity === 'all' || sev === severity) ? 'block' : 'none';
}});
}}
function focusIssue(num) {{
console.log('focusIssue called for num:', num);
selectIssue(num);
// Find overlay and zoom to it
const overlay = OVERLAYS[num - 1];
console.log('Found overlay:', overlay);
if (!overlay) {{
console.warn('No overlay found for num', num);
return;
}}
// Wait for viewer to be ready
if (!viewer || !viewer.viewport) {{
console.warn('Viewer not ready yet, retrying...');
setTimeout(() => focusIssue(num), 500);
return;
}}
console.log('Viewer ready, fitting bounds to:', overlay);
const rect = new OpenSeadragon.Rect(
Math.max(0, overlay.x - 0.05),
Math.max(0, overlay.y - 0.05),
Math.min(1 - overlay.x + 0.05, overlay.w + 0.1),
Math.min(1 - overlay.y + 0.05, overlay.h + 0.1)
);
console.log('Rect:', rect);
viewer.viewport.fitBounds(rect);
console.log('fitBounds called');
}}
function selectIssue(num) {{
// Highlight card
document.querySelectorAll('.issue-card').forEach(c => c.classList.remove('active'));
const card = document.querySelector(`.issue-card[data-id="${{num}}"]`);
if (card) {{
card.classList.add('active');
card.scrollIntoView({{behavior: 'smooth', block: 'center'}});
}}
// Highlight overlay
document.querySelectorAll('.overlay-rect').forEach(el => {{
el.style.opacity = el.id === 'overlay-' + num ? '0.6' : '0.15';
el.style.borderWidth = el.id === 'overlay-' + num ? '4px' : '2px';
}});
}}
function resetView() {{
viewer.viewport.goHome();
document.querySelectorAll('.issue-card').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.overlay-rect').forEach(el => {{
el.style.opacity = '0.15';
el.style.borderWidth = '2px';
}});
}}
// ===== FEEDBACK SYSTEM =====
async function submitFeedback(btn, isTP) {{
const card = btn.closest('.issue-card');
const hasApi = card.getAttribute('data-has-api') === 'true';
const dbId = card.getAttribute('data-db-id');
if (!hasApi || !dbId) {{
showToast('API недоступен. Сохраните viewer через backend.', 'warning');
// Still update UI for demo
updateFeedbackUI(card, isTP, false);
return;
}}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = '...';
try {{
const res = await fetch('/api/feedback', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
issue_id: parseInt(dbId),
is_true_positive: isTP,
action_taken: isTP === true ? 'fixed' : (isTP === false ? 'ignored' : 'not_sure')
}})
}});
if (res.ok) {{
updateFeedbackUI(card, isTP, true);
showToast('Feedback сохранён!', 'success');
}} else {{
const err = await res.text();
showToast('Ошибка: ' + err, 'error');
btn.disabled = false;
btn.textContent = originalText;
}}
}} catch(e) {{
showToast('Сетевой сбой: ' + e.message, 'error');
btn.disabled = false;
btn.textContent = originalText;
}}
}}
function updateFeedbackUI(card, isTP, saved) {{
const statusDiv = card.querySelector('.feedback-status');
const btns = card.querySelector('.feedback-bar');
// Remove all feedback classes
card.classList.remove('feedback-tp', 'feedback-fp');
if (isTP === true) {{
card.classList.add('feedback-tp');
btns.innerHTML = '<span style="color:#00c864; font-size:12px;">✅ Подтверждено как реальная проблема</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="tp">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}} else if (isTP === false) {{
card.classList.add('feedback-fp');
btns.innerHTML = '<span style="color:#ff4444; font-size:12px;">❌ Отклонено (ложное срабатывание)</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="fp">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}} else {{
btns.innerHTML = '<span style="color:#888; font-size:12px;">🤷 Не уверен</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="ns">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}}
}}
function showToast(msg, type) {{
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.style.borderColor = type === 'success' ? '#00c864' : (type === 'error' ? '#ff4444' : '#ffaa00');
toast.style.display = 'block';
setTimeout(() => {{ toast.style.display = 'none'; }}, 3000);
}}
// ===== PAGE NAVIGATION =====
const PROJECT_ID = null;
const CURRENT_PAGE = null;
const TOTAL_PAGES = null;
const API_BASE = '';
function updatePageNav() {{
const btnPrev = document.getElementById('btnPrev');
const btnNext = document.getElementById('btnNext');
const pageSelect = document.getElementById('pageSelect');
if (btnPrev) btnPrev.disabled = CURRENT_PAGE <= 1;
if (btnNext) btnNext.disabled = TOTAL_PAGES && CURRENT_PAGE >= TOTAL_PAGES;
if (pageSelect) pageSelect.value = CURRENT_PAGE;
// If no backend project ID, disable nav and show message
if (!PROJECT_ID) {{
if (btnPrev) btnPrev.disabled = true;
if (btnNext) btnNext.disabled = true;
const nav = document.getElementById('pageNav');
if (nav) nav.title = 'Page navigation requires backend server';
}}
}}
function goPage(delta) {{
if (!PROJECT_ID || !TOTAL_PAGES) return;
const newPage = CURRENT_PAGE + delta;
if (newPage < 1 || newPage > TOTAL_PAGES) return;
jumpPage(newPage);
}}
function jumpPage(pageNum) {{
if (!PROJECT_ID) {{
showToast('Navigation requires backend server', 'warning');
return;
}}
const base = API_BASE || '';
window.location.href = base + '/viewer/' + PROJECT_ID + '/' + pageNum;
}}
// Initialize
updatePageNav();
</script>
</body>
</html>'''
out_dir = folder / "web_viewer"
out_dir.mkdir(exist_ok=True)
# Copy DZI tiles into web_viewer folder so relative paths work
if use_dzi:
src_files = folder / f"page_{target_page:03d}_files"
dst_files = out_dir / f"page_{target_page:03d}_files"
if src_files.exists():
if dst_files.exists():
shutil.rmtree(dst_files)
shutil.copytree(src_files, dst_files)
print(f"[INFO] Скопировано тайлов: {dst_files}")
else:
print(f"[WARN] Тайлы не найдены: {src_files}")
use_dzi = False
out_path = out_dir / "index.html"
out_path.write_text(html, encoding="utf-8")
print(f"[OK] Viewer создан: {out_path}")
print(f" {'DZI tiles' if use_dzi else 'Direct PNG'}")
print(f" Откройте в браузере: file://{out_path}")
def main():
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("output_123")
page = int(sys.argv[2]) if len(sys.argv) > 2 else None
use_dzi = "--dzi" in sys.argv or "--no-dzi" not in sys.argv
generate_html(folder, page, use_dzi)
if __name__ == "__main__":
main()

View File

@ -83,7 +83,8 @@ def get_lmstudio_backend(model: str = "qwen2.5:14b"):
temperature=kwargs.get("temperature", 0.3),
max_tokens=kwargs.get("max_tokens", 1024),
)
return response.choices[0].message.content
content = response.choices[0].message.content
return content if content is not None else ""
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
@ -117,7 +118,8 @@ def get_opencode_backend(model: str = "nemotron-3-super-free"):
temperature=kwargs.get("temperature", 0.3),
max_tokens=kwargs.get("max_tokens", 1024),
)
return response.choices[0].message.content
content = response.choices[0].message.content
return content if content is not None else ""
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")