""" app.py - Aplicação Flask Principal Web service para upload de documentos, geração de chunks e embeddings """ import os import time import json from flask import Flask, request, jsonify, abort from werkzeug.utils import secure_filename from dotenv import load_dotenv # Importa módulos locais from auth import initialize_auth, get_http_auth from document_processor import create_document_processor from embedding_service import initialize_embedding_service, get_embedding_service from database import initialize_database, get_database # Carrega variáveis de ambiente load_dotenv() # Inicializa Flask app = Flask(__name__) # Configurações app.config['MAX_CONTENT_LENGTH'] = int(os.environ.get('MAX_UPLOAD_SIZE', 52428800)) # 50MB default app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', '/home/ubuntu/doc-embedding-service/uploads') # ========================== # CORS # ========================== try: from flask_cors import CORS CORS( app, resources={r"/*": {"origins": "*"}}, supports_credentials=False, allow_headers=["Content-Type", "Authorization", "X-API-Key"], expose_headers=["Content-Type", "Authorization"], methods=["GET", "POST", "DELETE", "OPTIONS"] ) except Exception as e: print(f"AVISO: flask-cors não instalado; CORS mínimo será aplicado via after_request: {e}") @app.after_request def add_cors_headers(resp): resp.headers.setdefault("Access-Control-Allow-Origin", "*") resp.headers.setdefault("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") resp.headers.setdefault("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") return resp # ========================== # Inicialização de Serviços # ========================== def initialize_services(): """Inicializa todos os serviços da aplicação""" print("\n" + "="*60) print("Inicializando Document Embedding Service") print("="*60 + "\n") # Autenticação print("[init] Inicializando autenticação...") oci_auth, http_auth = initialize_auth( config_file=os.environ.get('OCI_CONFIG_FILE'), api_key=os.environ.get('API_KEY'), test_mode=os.environ.get('TEST_MODE', 'false').lower() == 'true', debug=os.environ.get('DEBUG_AUTH', 'false').lower() == 'true' ) # Embedding Service print("[init] Inicializando serviço de embeddings...") embedding_service = initialize_embedding_service( model_name=os.environ.get('EMBEDDING_MODEL'), device=os.environ.get('EMBEDDING_DEVICE', 'cpu') ) embedding_dim = embedding_service.get_dimension() print(f"[init] Dimensão dos embeddings: {embedding_dim}") # Database print("[init] Inicializando banco de dados...") db = initialize_database( user=os.environ.get('DB_USER'), password=os.environ.get('DB_PASSWORD'), dsn=os.environ.get('DB_DSN'), embedding_dimension=embedding_dim ) print("\n" + "="*60) print("Serviços inicializados com sucesso!") print("="*60 + "\n") # Inicializa serviços ao iniciar a aplicação initialize_services() # ========================== # Middleware de Autenticação # ========================== @app.before_request def before_all_requests(): """Middleware executado antes de cada requisição""" if request.method == "OPTIONS": return "", 204 # Valida autenticação http_auth = get_http_auth() if http_auth: http_auth.check_api_key() # ========================== # Endpoints # ========================== @app.route("/", methods=["GET"]) @app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint""" return jsonify({ "status": "ok", "version": "1.0.0", "service": "document-embedding-service" }) @app.route("/api/v1/documents/upload", methods=["POST"]) def upload_document(): """ Upload de documento para processamento Aceita: multipart/form-data - file: arquivo (obrigatório) - metadata: JSON com metadados (opcional) Retorna: informações do documento e chunks criados """ start_time = time.time() # Valida arquivo if 'file' not in request.files: return jsonify({"error": "Nenhum arquivo fornecido"}), 400 file = request.files['file'] if file.filename == '': return jsonify({"error": "Nome de arquivo vazio"}), 400 # Metadados opcionais metadata = None if 'metadata' in request.form: try: metadata = json.loads(request.form['metadata']) except json.JSONDecodeError: return jsonify({"error": "Metadados inválidos (JSON esperado)"}), 400 try: # Lê conteúdo do arquivo filename = secure_filename(file.filename) file_content = file.read() file_size = len(file_content) file_type = file.content_type or 'application/octet-stream' print(f"\n[upload] Processando arquivo: {filename}") print(f"[upload] Tipo: {file_type}, Tamanho: {file_size} bytes") # Processa documento doc_processor = create_document_processor() if not doc_processor.is_supported_file(filename, file_type): return jsonify({ "error": f"Tipo de arquivo não suportado: {filename}", "supported_types": doc_processor.SUPPORTED_EXTENSIONS }), 400 # Extrai texto e cria chunks process_result = doc_processor.process_document( content=file_content, filename=filename, mime_type=file_type ) # Gera embeddings embedding_service = get_embedding_service() chunks_with_embeddings = embedding_service.encode_chunks(process_result['chunks']) # Salva no banco de dados db = get_database() # Insere documento document_id = db.insert_document( filename=filename, file_type=file_type, file_size=file_size, content_hash=process_result['content_hash'], metadata=metadata ) # Insere chunks chunks_inserted = db.insert_chunks(document_id, chunks_with_embeddings) processing_time = time.time() - start_time print(f"[upload] Documento processado com sucesso em {processing_time:.2f}s") return jsonify({ "document_id": document_id, "filename": filename, "file_type": file_type, "file_size": file_size, "text_length": process_result['text_length'], "chunks_created": chunks_inserted, "embedding_dimension": embedding_service.get_dimension(), "processing_time": round(processing_time, 2) }), 201 except ValueError as e: return jsonify({"error": str(e)}), 400 except RuntimeError as e: return jsonify({"error": str(e)}), 500 except Exception as e: print(f"[upload] Erro inesperado: {e}") return jsonify({"error": f"Erro ao processar documento: {str(e)}"}), 500 @app.route("/api/v1/documents", methods=["GET"]) def list_documents(): """ Lista documentos Query params: - limit: número máximo de resultados (padrão: 100) - offset: offset para paginação (padrão: 0) """ try: limit = int(request.args.get('limit', 100)) offset = int(request.args.get('offset', 0)) # Valida parâmetros if limit < 1 or limit > 1000: return jsonify({"error": "Limit deve estar entre 1 e 1000"}), 400 if offset < 0: return jsonify({"error": "Offset deve ser >= 0"}), 400 db = get_database() documents = db.list_documents(limit=limit, offset=offset) return jsonify({ "documents": documents, "total": len(documents), "limit": limit, "offset": offset }) except ValueError as e: return jsonify({"error": f"Parâmetros inválidos: {str(e)}"}), 400 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/v1/documents/", methods=["GET"]) def get_document(document_id): """ Busca documento por ID Path params: - document_id: ID do documento """ try: db = get_database() document = db.get_document(document_id) if not document: return jsonify({"error": "Documento não encontrado"}), 404 return jsonify(document) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/v1/documents/", methods=["DELETE"]) def delete_document(document_id): """ Deleta documento e seus chunks Path params: - document_id: ID do documento """ try: db = get_database() deleted = db.delete_document(document_id) if not deleted: return jsonify({"error": "Documento não encontrado"}), 404 return jsonify({ "message": "Documento deletado com sucesso", "document_id": document_id }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/v1/search", methods=["POST"]) def search_documents(): """ Busca semântica em documentos Body (JSON): - query: texto de busca (obrigatório) - top_k: número de resultados (padrão: 5) - threshold: threshold mínimo de similaridade 0-1 (padrão: 0.0) """ try: body = request.get_json(force=True, silent=False) or {} query = body.get('query') if not query or not query.strip(): return jsonify({"error": "Campo 'query' é obrigatório"}), 400 top_k = body.get('top_k', 5) threshold = body.get('threshold', 0.0) # Valida parâmetros if not isinstance(top_k, int) or top_k < 1 or top_k > 100: return jsonify({"error": "top_k deve estar entre 1 e 100"}), 400 if not isinstance(threshold, (int, float)) or threshold < 0 or threshold > 1: return jsonify({"error": "threshold deve estar entre 0 e 1"}), 400 print(f"\n[search] Query: {query[:100]}...") print(f"[search] top_k={top_k}, threshold={threshold}") # Gera embedding da query embedding_service = get_embedding_service() query_embedding = embedding_service.encode_text(query) # Busca no banco de dados db = get_database() results = db.search_similar_chunks( query_embedding=query_embedding, top_k=top_k, threshold=threshold ) print(f"[search] Encontrados {len(results)} resultados") return jsonify({ "results": results, "query": query, "total_results": len(results), "top_k": top_k, "threshold": threshold }) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: print(f"[search] Erro: {e}") return jsonify({"error": str(e)}), 500 @app.route("/api/v1/stats", methods=["GET"]) def get_stats(): """ Retorna estatísticas do serviço """ try: db = get_database() documents = db.list_documents(limit=10000) total_documents = len(documents) total_chunks = sum(doc.get('chunks_count', 0) for doc in documents) total_size = sum(doc.get('file_size', 0) for doc in documents) embedding_service = get_embedding_service() return jsonify({ "total_documents": total_documents, "total_chunks": total_chunks, "total_size_bytes": total_size, "embedding_model": embedding_service.model_name, "embedding_dimension": embedding_service.get_dimension() }) except Exception as e: return jsonify({"error": str(e)}), 500 # ========================== # Error Handlers # ========================== @app.errorhandler(401) def unauthorized(e): return jsonify({"error": "Não autorizado", "message": str(e)}), 401 @app.errorhandler(404) def not_found(e): return jsonify({"error": "Não encontrado", "message": str(e)}), 404 @app.errorhandler(413) def request_entity_too_large(e): max_size = app.config['MAX_CONTENT_LENGTH'] return jsonify({ "error": "Arquivo muito grande", "max_size_bytes": max_size, "max_size_mb": round(max_size / (1024 * 1024), 2) }), 413 @app.errorhandler(500) def internal_server_error(e): return jsonify({"error": "Erro interno do servidor", "message": str(e)}), 500 # ========================== # Main # ========================== if __name__ == "__main__": port = int(os.environ.get("PORT", 8000)) debug = os.environ.get("FLASK_DEBUG", "false").lower() == "true" print(f"\n{'='*60}") print(f"Iniciando servidor na porta {port}") print(f"Debug mode: {debug}") print(f"{'='*60}\n") app.run( host="0.0.0.0", port=port, debug=debug )