402 lines
15 KiB
Python
402 lines
15 KiB
Python
|
|
# app/main.py
|
|||
|
|
"""
|
|||
|
|
FastAPI backend для Blueprint QC Service.
|
|||
|
|
|
|||
|
|
API Endpoints:
|
|||
|
|
- POST /api/projects/upload — загрузка PDF
|
|||
|
|
- GET /api/projects — список проектов
|
|||
|
|
- GET /api/projects/{id} — детали проекта
|
|||
|
|
- GET /api/projects/{id}/issues — замечания проекта
|
|||
|
|
- POST /api/feedback — отметить замечание (TP/FP)
|
|||
|
|
- GET /api/feedback/stats — статистика feedback
|
|||
|
|
- GET /api/training/data — экспорт данных для обучения
|
|||
|
|
- GET /api/stats — общая статистика
|
|||
|
|
- GET /viewer/{project_id}/{page} — HTML viewer (серверный, без file://)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import re
|
|||
|
|
import json
|
|||
|
|
import shutil
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import List, Optional
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Query
|
|||
|
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|||
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|||
|
|
from fastapi.staticfiles import StaticFiles
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
# Добавить пути
|
|||
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|||
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|||
|
|
|
|||
|
|
from app.database import engine, get_db, Base
|
|||
|
|
from app import models, schemas, crud, processing
|
|||
|
|
|
|||
|
|
# Создать таблицы
|
|||
|
|
Base.metadata.create_all(bind=engine)
|
|||
|
|
|
|||
|
|
# Создать папку static если нет
|
|||
|
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|||
|
|
STATIC_DIR.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
app = FastAPI(
|
|||
|
|
title="Blueprint QC API",
|
|||
|
|
description="Сервис автоматической проверки чертежей и сбора данных для обучения",
|
|||
|
|
version="0.1.0"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# CORS
|
|||
|
|
app.add_middleware(
|
|||
|
|
CORSMiddleware,
|
|||
|
|
allow_origins=["*"],
|
|||
|
|
allow_credentials=True,
|
|||
|
|
allow_methods=["*"],
|
|||
|
|
allow_headers=["*"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Пути
|
|||
|
|
# Абсолютные пути (backend запускается из разных директорий)
|
|||
|
|
BASE_DIR = Path(__file__).parent.parent # backend/
|
|||
|
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
|||
|
|
OUTPUT_DIR = BASE_DIR / "outputs"
|
|||
|
|
UPLOAD_DIR.mkdir(exist_ok=True)
|
|||
|
|
OUTPUT_DIR.mkdir(exist_ok=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== PROJECTS ====================
|
|||
|
|
|
|||
|
|
@app.post("/api/projects/upload", response_model=schemas.ProjectResponse)
|
|||
|
|
async def upload_pdf(
|
|||
|
|
file: UploadFile = File(...),
|
|||
|
|
name: Optional[str] = None,
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
"""Загрузка PDF файла. Запускает фоновую обработку."""
|
|||
|
|
if not file.filename.endswith('.pdf'):
|
|||
|
|
raise HTTPException(400, "Only PDF files allowed")
|
|||
|
|
|
|||
|
|
# Сохранить файл
|
|||
|
|
safe_name = Path(file.filename).name
|
|||
|
|
pdf_path = UPLOAD_DIR / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{safe_name}"
|
|||
|
|
with open(pdf_path, "wb") as f:
|
|||
|
|
shutil.copyfileobj(file.file, f)
|
|||
|
|
|
|||
|
|
# Создать проект в БД (статус = uploaded, анализ запускается вручную)
|
|||
|
|
project = crud.create_project(db, pdf_filename=safe_name, name=name or safe_name)
|
|||
|
|
|
|||
|
|
# НЕ запускаем обработку автоматически — пользователь жмет "Анализировать"
|
|||
|
|
# вручную, когда готов
|
|||
|
|
|
|||
|
|
return project
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/projects", response_model=List[schemas.ProjectDetail])
|
|||
|
|
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
|||
|
|
"""Список всех проектов со страницами и замечаниями."""
|
|||
|
|
return crud.get_projects(db, skip=skip, limit=limit)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/projects/{project_id}", response_model=schemas.ProjectDetail)
|
|||
|
|
def get_project(project_id: int, db: Session = Depends(get_db)):
|
|||
|
|
"""Детали проекта со страницами и замечаниями."""
|
|||
|
|
project = crud.get_project(db, project_id)
|
|||
|
|
if not project:
|
|||
|
|
raise HTTPException(404, "Project not found")
|
|||
|
|
return project
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.delete("/api/projects/{project_id}")
|
|||
|
|
def delete_project(project_id: int, db: Session = Depends(get_db)):
|
|||
|
|
"""Удалить проект и все связанные данные."""
|
|||
|
|
project = crud.get_project(db, project_id)
|
|||
|
|
if not project:
|
|||
|
|
raise HTTPException(404, "Project not found")
|
|||
|
|
|
|||
|
|
# Удалить файлы проекта
|
|||
|
|
if project.output_folder:
|
|||
|
|
import shutil
|
|||
|
|
try:
|
|||
|
|
shutil.rmtree(project.output_folder, ignore_errors=True)
|
|||
|
|
except:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
db.delete(project)
|
|||
|
|
db.commit()
|
|||
|
|
return {"status": "deleted", "project_id": project_id}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/api/projects/{project_id}/analyze")
|
|||
|
|
def analyze_project(project_id: int, use_tiling: bool = False, db: Session = Depends(get_db)):
|
|||
|
|
"""Запустить анализ (OCR + Layout + QC + DZI) для проекта."""
|
|||
|
|
project = crud.get_project(db, project_id)
|
|||
|
|
if not project:
|
|||
|
|
raise HTTPException(404, "Project not found")
|
|||
|
|
|
|||
|
|
if project.status == "processing":
|
|||
|
|
return {"status": "already_processing", "project_id": project_id}
|
|||
|
|
|
|||
|
|
if project.status == "completed":
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
pdf_files = list(UPLOAD_DIR.glob(f"*_{project.pdf_filename}"))
|
|||
|
|
if not pdf_files:
|
|||
|
|
pdf_files = list(UPLOAD_DIR.glob(project.pdf_filename))
|
|||
|
|
|
|||
|
|
if not pdf_files:
|
|||
|
|
raise HTTPException(404, f"PDF file not found in {UPLOAD_DIR}")
|
|||
|
|
|
|||
|
|
pdf_path = pdf_files[0]
|
|||
|
|
|
|||
|
|
crud.update_project_status(db, project_id, "processing")
|
|||
|
|
|
|||
|
|
import threading
|
|||
|
|
def run_in_background():
|
|||
|
|
db_local = next(get_db())
|
|||
|
|
try:
|
|||
|
|
processing.run_pipeline(project.id, pdf_path, OUTPUT_DIR, db_local, use_tiling=use_tiling)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[ERROR] Pipeline failed for project {project.id}: {e}")
|
|||
|
|
import traceback
|
|||
|
|
traceback.print_exc()
|
|||
|
|
crud.update_project_status(db_local, project.id, "error", error_message=str(e))
|
|||
|
|
finally:
|
|||
|
|
db_local.close()
|
|||
|
|
|
|||
|
|
thread = threading.Thread(target=run_in_background)
|
|||
|
|
thread.start()
|
|||
|
|
|
|||
|
|
return {"status": "processing_started", "project_id": project_id, "ocr_engine": "tiling" if use_tiling else "standard"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== ISSUES ====================
|
|||
|
|
|
|||
|
|
@app.get("/api/projects/{project_id}/issues")
|
|||
|
|
def get_issues(
|
|||
|
|
project_id: int,
|
|||
|
|
severity: Optional[str] = Query(None, description="error / warning / info"),
|
|||
|
|
issue_type: Optional[str] = Query(None),
|
|||
|
|
has_feedback: Optional[bool] = Query(None, description="Filter by feedback presence"),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
"""Получить замечания проекта."""
|
|||
|
|
issues = crud.get_issues(
|
|||
|
|
db, project_id=project_id, severity=severity,
|
|||
|
|
issue_type=issue_type, has_feedback=has_feedback
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Build response with page_number manually
|
|||
|
|
result = []
|
|||
|
|
for issue in issues:
|
|||
|
|
item = {
|
|||
|
|
"id": issue.id,
|
|||
|
|
"project_id": issue.project_id,
|
|||
|
|
"page_id": issue.page_id,
|
|||
|
|
"page_number": issue.page.page_number if issue.page else None,
|
|||
|
|
"issue_type": issue.issue_type,
|
|||
|
|
"severity": issue.severity,
|
|||
|
|
"message": issue.message,
|
|||
|
|
"bbox_x1": issue.bbox_x1,
|
|||
|
|
"bbox_y1": issue.bbox_y1,
|
|||
|
|
"bbox_x2": issue.bbox_x2,
|
|||
|
|
"bbox_y2": issue.bbox_y2,
|
|||
|
|
"dimension_text": issue.dimension_text,
|
|||
|
|
"confidence": issue.confidence,
|
|||
|
|
"created_at": issue.created_at.isoformat() if issue.created_at else None,
|
|||
|
|
"feedback": {
|
|||
|
|
"id": issue.feedback.id,
|
|||
|
|
"issue_id": issue.feedback.issue_id,
|
|||
|
|
"is_true_positive": issue.feedback.is_true_positive,
|
|||
|
|
"comment": issue.feedback.comment,
|
|||
|
|
"action_taken": issue.feedback.action_taken,
|
|||
|
|
"created_at": issue.feedback.created_at.isoformat() if issue.feedback.created_at else None
|
|||
|
|
} if issue.feedback else None
|
|||
|
|
}
|
|||
|
|
result.append(item)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/issues/{issue_id}", response_model=schemas.IssueResponse)
|
|||
|
|
def get_issue(issue_id: int, db: Session = Depends(get_db)):
|
|||
|
|
"""Одно замечание."""
|
|||
|
|
issue = crud.get_issue(db, issue_id)
|
|||
|
|
if not issue:
|
|||
|
|
raise HTTPException(404, "Issue not found")
|
|||
|
|
return issue
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== FEEDBACK ====================
|
|||
|
|
|
|||
|
|
@app.post("/api/feedback", response_model=schemas.FeedbackResponse)
|
|||
|
|
def submit_feedback(feedback: schemas.FeedbackCreate, db: Session = Depends(get_db)):
|
|||
|
|
"""
|
|||
|
|
Отправить feedback по замечанию.
|
|||
|
|
|
|||
|
|
is_true_positive:
|
|||
|
|
- true = реальная проблема (правильное срабатывание)
|
|||
|
|
- false = ложное срабатывание (false positive)
|
|||
|
|
- null = не уверен
|
|||
|
|
|
|||
|
|
action_taken: fixed / ignored / not_sure
|
|||
|
|
"""
|
|||
|
|
issue = crud.get_issue(db, feedback.issue_id)
|
|||
|
|
if not issue:
|
|||
|
|
raise HTTPException(404, "Issue not found")
|
|||
|
|
|
|||
|
|
return crud.create_feedback(db, feedback)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/api/feedback/stats")
|
|||
|
|
def feedback_stats(project_id: Optional[int] = None, db: Session = Depends(get_db)):
|
|||
|
|
"""Статистика feedback."""
|
|||
|
|
return crud.get_feedback_stats(db, project_id=project_id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== VIEWER ====================
|
|||
|
|
|
|||
|
|
@app.get("/viewer/{project_id}/{page_number}", response_class=HTMLResponse)
|
|||
|
|
def get_viewer(project_id: int, page_number: int, db: Session = Depends(get_db)):
|
|||
|
|
"""HTML viewer с overlay замечаний + feedback buttons."""
|
|||
|
|
project = crud.get_project(db, project_id)
|
|||
|
|
if not project or not project.output_folder:
|
|||
|
|
raise HTTPException(404, "Project or output not found")
|
|||
|
|
|
|||
|
|
# ВСЕГДА перегенерировать viewer для нужной страницы
|
|||
|
|
# (иначе тайлы указывают на другую страницу)
|
|||
|
|
result = processing.generate_viewer_html(db, project_id, page_number)
|
|||
|
|
if not result:
|
|||
|
|
raise HTTPException(404, "Viewer not available")
|
|||
|
|
|
|||
|
|
viewer_path = Path(result)
|
|||
|
|
content = viewer_path.read_text(encoding="utf-8")
|
|||
|
|
|
|||
|
|
# Подменить пути тайлов — теперь они будут правильные
|
|||
|
|
# (generate_web_viewer.py уже сгенерировал для page_{page_number:03d}_files/)
|
|||
|
|
content = content.replace(
|
|||
|
|
'Url: "./page_',
|
|||
|
|
f'Url: "/viewer_tiles/{project_id}/page_'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Внедрить project_id и page info для навигации
|
|||
|
|
total_pages = len(project.pages) if project.pages else page_number
|
|||
|
|
content = content.replace('const PROJECT_ID = null;', f'const PROJECT_ID = {project_id};')
|
|||
|
|
content = content.replace('const TOTAL_PAGES = null;', f'const TOTAL_PAGES = {total_pages};')
|
|||
|
|
content = content.replace('const CURRENT_PAGE = null;', f'const CURRENT_PAGE = {page_number};')
|
|||
|
|
|
|||
|
|
# Обновить счётчик страниц в навбаре
|
|||
|
|
content = content.replace(
|
|||
|
|
f'<span id="totalPages">?</span>',
|
|||
|
|
f'<span id="totalPages">{total_pages}</span>'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Получить issue IDs из БД для этой страницы
|
|||
|
|
page = crud.get_page_by_number(db, project_id, page_number)
|
|||
|
|
if page:
|
|||
|
|
issues = crud.get_issues(db, project_id=project_id, page_id=page.id)
|
|||
|
|
issue_db_ids = [str(i.id) for i in issues]
|
|||
|
|
|
|||
|
|
# Внедрить DB IDs в HTML
|
|||
|
|
content = content.replace('data-has-api="false"', 'data-has-api="true"')
|
|||
|
|
|
|||
|
|
def inject_db_id(match):
|
|||
|
|
idx = int(match.group(1)) - 1
|
|||
|
|
if idx < len(issue_db_ids):
|
|||
|
|
return match.group(0) + f' data-db-id="{issue_db_ids[idx]}"'
|
|||
|
|
return match.group(0)
|
|||
|
|
|
|||
|
|
content = re.sub(r'data-id="(\d+)"', inject_db_id, content)
|
|||
|
|
|
|||
|
|
return HTMLResponse(content=content)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/viewer_tiles/{project_id}/{filename:path}")
|
|||
|
|
def get_tile(project_id: int, filename: str, db: Session = Depends(get_db)):
|
|||
|
|
"""Отдаёт DZI тайлы."""
|
|||
|
|
project = crud.get_project(db, project_id)
|
|||
|
|
if not project or not project.output_folder:
|
|||
|
|
raise HTTPException(404)
|
|||
|
|
|
|||
|
|
tile_path = Path(project.output_folder) / filename
|
|||
|
|
if tile_path.exists() and str(tile_path).startswith(str(project.output_folder)):
|
|||
|
|
return FileResponse(tile_path)
|
|||
|
|
raise HTTPException(404)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== TRAINING DATA ====================
|
|||
|
|
|
|||
|
|
@app.get("/api/training/data", response_model=schemas.TrainingDataExport)
|
|||
|
|
def export_training_data(
|
|||
|
|
project_id: Optional[int] = Query(None),
|
|||
|
|
only_labeled: bool = Query(True),
|
|||
|
|
format: str = Query("json", description="json / yolo / csv"),
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Экспорт данных для обучения ML-модели.
|
|||
|
|
|
|||
|
|
only_labeled=true — только размеченные feedback'ом замечания.
|
|||
|
|
"""
|
|||
|
|
samples = crud.export_training_data(db, project_id=project_id, only_labeled=only_labeled)
|
|||
|
|
|
|||
|
|
if format == "json":
|
|||
|
|
return schemas.TrainingDataExport(
|
|||
|
|
total_samples=len(samples),
|
|||
|
|
labeled_samples=len([s for s in samples if s.is_true_positive is not None]),
|
|||
|
|
samples=samples,
|
|||
|
|
export_format="json"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# TODO: YOLO / CSV форматы
|
|||
|
|
raise HTTPException(400, f"Format {format} not yet implemented")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/api/training/export")
|
|||
|
|
def download_training_export(
|
|||
|
|
project_id: Optional[int] = None,
|
|||
|
|
only_labeled: bool = True,
|
|||
|
|
db: Session = Depends(get_db)
|
|||
|
|
):
|
|||
|
|
"""Скачать training data как JSON файл."""
|
|||
|
|
samples = crud.export_training_data(db, project_id=project_id, only_labeled=only_labeled)
|
|||
|
|
|
|||
|
|
export = {
|
|||
|
|
"generated_at": datetime.utcnow().isoformat(),
|
|||
|
|
"total_samples": len(samples),
|
|||
|
|
"samples": [s.dict() for s in samples]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return JSONResponse(content=export, media_type="application/json")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== STATS ====================
|
|||
|
|
|
|||
|
|
@app.get("/api/stats", response_model=schemas.StatsResponse)
|
|||
|
|
def get_stats(db: Session = Depends(get_db)):
|
|||
|
|
"""Общая статистика системы."""
|
|||
|
|
return crud.get_stats(db)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== HEALTH ====================
|
|||
|
|
|
|||
|
|
@app.get("/api/health")
|
|||
|
|
def health_check():
|
|||
|
|
return {"status": "ok", "version": "0.1.0"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ==================== STATIC (для демо) ====================
|
|||
|
|
|
|||
|
|
# Подключить статику
|
|||
|
|
try:
|
|||
|
|
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
|
|||
|
|
except RuntimeError:
|
|||
|
|
pass # Already mounted
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
import uvicorn
|
|||
|
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|