opencode/backend/app/main.py
Кирилл Блинов f37c477a0a Add FastAPI backend with DZI viewer and feedback system
- 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
2026-06-01 12:29:41 +03:00

402 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)