- FastAPI app with SQLite DB (projects, pages, issues, feedback) - OpenSeadragon DZI viewer with inline SVG overlays - Dashboard: upload, project list, tiling toggle, review mode - Pipeline integration: tiling OCR → layout → elements → rules QC → DZI → DB - Feedback collection: true_positive / false_positive / not_sure per issue
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)
|