# 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'?', f'{total_pages}' ) # Получить 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)