Spaces:
Running
Running
SantosPatazca
commited on
Commit
·
c71e312
1
Parent(s):
cb5fccb
primer despliegue FastAPI con Docker
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +37 -0
- Dockerfile +14 -0
- main.py +44 -0
- requirements.txt +111 -0
- src/expon/feedback/application/internal/generate_feedback_service.py +50 -0
- src/expon/feedback/application/internal/query_feedback_service.py +0 -0
- src/expon/feedback/domain/model/feedback.py +17 -0
- src/expon/feedback/domain/services/feedback_generator_service.py +86 -0
- src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py +18 -0
- src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py +33 -0
- src/expon/feedback/infrastructure/services/text_generation_service.py +69 -0
- src/expon/feedback/interfaces/rest/feedback_controller.py +35 -0
- src/expon/feedback/interfaces/rest/feedback_request.py +5 -0
- src/expon/feedback/interfaces/rest/feedback_response.py +14 -0
- src/expon/iam/application/acl/iam_context_facade.py +0 -0
- src/expon/iam/application/internal/commandservices/user_command_service.py +26 -0
- src/expon/iam/application/internal/queryservices/user_query_service.py +0 -0
- src/expon/iam/domain/model/aggregates/user.py +13 -0
- src/expon/iam/domain/model/commands/sign_up_command.py +10 -0
- src/expon/iam/domain/model/entities/role.py +6 -0
- src/expon/iam/domain/model/queries/get_user_by_email_query.py +6 -0
- src/expon/iam/domain/model/valueobjects/email.py +0 -0
- src/expon/iam/domain/services/user_query_service.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py +25 -0
- src/expon/iam/infrastructure/authorization/sfs/configuration/web_security_configuration.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/model/user_details_impl.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/model/username_password_auth_token_builder.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/pipeline/bearer_authorization_request_filter.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/pipeline/unauthorized_request_handler_entrypoint.py +0 -0
- src/expon/iam/infrastructure/authorization/sfs/user_details_service_impl.py +0 -0
- src/expon/iam/infrastructure/hashing/bcrypt/services/bcrypt_hashing_service.py +0 -0
- src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py +13 -0
- src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py +16 -0
- src/expon/iam/infrastructure/persistence/jpa/repositories/role_repository.py +0 -0
- src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py +48 -0
- src/expon/iam/infrastructure/tokens/jwt/services/bearer_token_service.py +0 -0
- src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py +29 -0
- src/expon/iam/interfaces/rest/controllers/auth_controller.py +74 -0
- src/expon/iam/interfaces/rest/schemas/auth_response.py +6 -0
- src/expon/iam/interfaces/rest/schemas/login_request.py +5 -0
- src/expon/presentation/application/internal/commandservices/audio_upload_service.py +71 -0
- src/expon/presentation/application/internal/queryservices/presentation_query_service.py +13 -0
- src/expon/presentation/domain/model/aggregates/presentation.py +17 -0
- src/expon/presentation/domain/model/valueobjects/audio_metadata.py +7 -0
- src/expon/presentation/domain/services/assemblyai_service.py +36 -0
- src/expon/presentation/domain/services/sentiment_analysis_service.py +73 -0
- src/expon/presentation/domain/services/transcription_service.py +54 -0
- src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py +38 -0
- src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py +23 -0
- src/expon/presentation/infrastructure/persistence/jpa/repositories/presentation_repository.py +40 -0
.gitignore
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Ignorar entorno virtual
|
2 |
+
env/
|
3 |
+
ENV/
|
4 |
+
.venv/
|
5 |
+
.ENV/
|
6 |
+
*.env
|
7 |
+
|
8 |
+
# Python
|
9 |
+
__pycache__/
|
10 |
+
*.py[cod]
|
11 |
+
*.pyo
|
12 |
+
*.pyd
|
13 |
+
*.sqlite3
|
14 |
+
|
15 |
+
# Archivos del sistema
|
16 |
+
.DS_Store
|
17 |
+
Thumbs.db
|
18 |
+
|
19 |
+
# Archivos de configuración de editores
|
20 |
+
.vscode/
|
21 |
+
.idea/
|
22 |
+
|
23 |
+
# Archivos de log
|
24 |
+
*.log
|
25 |
+
|
26 |
+
# Variables de entorno
|
27 |
+
.env
|
28 |
+
|
29 |
+
# Compilados o binarios temporales
|
30 |
+
*.egg-info/
|
31 |
+
build/
|
32 |
+
dist/
|
33 |
+
|
34 |
+
# Ignorar archivos compilados por Python
|
35 |
+
__pycache__/
|
36 |
+
*.py[cod]
|
37 |
+
|
Dockerfile
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.11-slim
|
2 |
+
|
3 |
+
WORKDIR /app
|
4 |
+
|
5 |
+
COPY requirements.txt .
|
6 |
+
|
7 |
+
RUN pip install --upgrade pip
|
8 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
9 |
+
|
10 |
+
COPY . .
|
11 |
+
|
12 |
+
EXPOSE 7860
|
13 |
+
|
14 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
main.py
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import FastAPI
|
2 |
+
from fastapi.middleware.cors import CORSMiddleware # 👈 nuevo
|
3 |
+
|
4 |
+
# routers
|
5 |
+
from src.expon.iam.interfaces.rest.controllers.auth_controller import router as auth_router
|
6 |
+
from src.expon.profile.interfaces.rest.controllers.profile_controller import router as profile_router
|
7 |
+
from src.expon.presentation.interfaces.rest.controllers.presentation_controller import router as presentation_router
|
8 |
+
from src.expon.feedback.interfaces.rest.feedback_controller import router as feedback_router
|
9 |
+
from src.expon.subscription.interfaces.rest.controllers.subscription_controller import router as subscription_router
|
10 |
+
from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM
|
11 |
+
from src.expon.shared.infrastructure.database import Base, engine
|
12 |
+
|
13 |
+
app = FastAPI(
|
14 |
+
title="Expon Backend API",
|
15 |
+
version="1.0.0",
|
16 |
+
description="Backend estructurado por bounded contexts con FastAPI"
|
17 |
+
)
|
18 |
+
|
19 |
+
# 👇 middleware CORS
|
20 |
+
origins = [
|
21 |
+
"https://expon-frontend.netlify.app",
|
22 |
+
"http://localhost:4200",
|
23 |
+
]
|
24 |
+
|
25 |
+
app.add_middleware(
|
26 |
+
CORSMiddleware,
|
27 |
+
allow_origins=origins,
|
28 |
+
allow_credentials=True,
|
29 |
+
allow_methods=["*"],
|
30 |
+
allow_headers=["*"],
|
31 |
+
)
|
32 |
+
|
33 |
+
# routers
|
34 |
+
app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"])
|
35 |
+
app.include_router(profile_router, prefix="/api/v1/profile", tags=["Profile"])
|
36 |
+
app.include_router(presentation_router, prefix="/api/v1/presentation", tags=["Presentations"])
|
37 |
+
app.include_router(feedback_router, prefix="/api/v1/feedback", tags=["Feedback"])
|
38 |
+
app.include_router(subscription_router, prefix="/api/v1/subscription", tags=["Subscriptions"])
|
39 |
+
|
40 |
+
Base.metadata.create_all(bind=engine)
|
41 |
+
|
42 |
+
@app.get("/")
|
43 |
+
def read_root():
|
44 |
+
return {"mensaje": "¡Expon backend funcionando con estructura profesional!"}
|
requirements.txt
ADDED
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# ========================================
|
2 |
+
# DEPENDENCIAS PRINCIPALES UTILIZADAS
|
3 |
+
# ========================================
|
4 |
+
|
5 |
+
# Framework Principal
|
6 |
+
fastapi==0.115.12
|
7 |
+
starlette==0.46.2
|
8 |
+
uvicorn==0.34.3
|
9 |
+
|
10 |
+
# Validación de Datos
|
11 |
+
pydantic==2.11.6
|
12 |
+
pydantic_core==2.33.2
|
13 |
+
pydantic[email]
|
14 |
+
|
15 |
+
# Base de Datos
|
16 |
+
SQLAlchemy==2.0.41
|
17 |
+
psycopg2-binary==2.9.10
|
18 |
+
|
19 |
+
# Autenticación y Seguridad
|
20 |
+
passlib==1.7.4
|
21 |
+
bcrypt==4.3.0
|
22 |
+
PyJWT==2.10.1
|
23 |
+
|
24 |
+
# Configuración
|
25 |
+
python-dotenv==1.1.0
|
26 |
+
python-decouple==3.8
|
27 |
+
|
28 |
+
# Manejo de Archivos
|
29 |
+
python-multipart==0.0.20
|
30 |
+
|
31 |
+
# Audio y Multimedia
|
32 |
+
vosk==0.3.45
|
33 |
+
ffmpeg-python==0.2.0
|
34 |
+
pydub==0.25.1
|
35 |
+
|
36 |
+
# IA y Machine Learning
|
37 |
+
transformers==4.52.4
|
38 |
+
torch==2.7.1
|
39 |
+
tokenizers==0.21.1
|
40 |
+
safetensors==0.5.3
|
41 |
+
|
42 |
+
# Google AI Services
|
43 |
+
google-generativeai==0.8.5
|
44 |
+
google-ai-generativelanguage==0.6.15
|
45 |
+
google-api-core==2.25.1
|
46 |
+
google-api-python-client==2.174.0
|
47 |
+
google-auth==2.40.3
|
48 |
+
google-auth-httplib2==0.2.0
|
49 |
+
googleapis-common-protos==1.70.0
|
50 |
+
|
51 |
+
# HTTP Requests
|
52 |
+
requests==2.32.4
|
53 |
+
|
54 |
+
# Dependencias de Sistema
|
55 |
+
proto-plus==1.26.1
|
56 |
+
protobuf==5.29.5
|
57 |
+
grpcio==1.73.1
|
58 |
+
grpcio-status==1.71.0
|
59 |
+
pyasn1==0.6.1
|
60 |
+
pyasn1_modules==0.4.2
|
61 |
+
rsa==4.9.1
|
62 |
+
uritemplate==4.2.0
|
63 |
+
cachetools==5.5.2
|
64 |
+
|
65 |
+
# Servidor de Producción (opcional)
|
66 |
+
gunicorn==23.0.0
|
67 |
+
|
68 |
+
# ========================================
|
69 |
+
# DEPENDENCIAS A REMOVER (no utilizadas)
|
70 |
+
# ========================================
|
71 |
+
# annotated-types==0.7.0
|
72 |
+
# anyio==4.9.0
|
73 |
+
# certifi==2025.6.15
|
74 |
+
# cffi==1.17.1
|
75 |
+
# charset-normalizer==3.4.2
|
76 |
+
# click==8.2.1
|
77 |
+
# colorama==0.4.6
|
78 |
+
# distro==1.9.0
|
79 |
+
# dnspython==2.7.0
|
80 |
+
# email_validator==2.2.0
|
81 |
+
# filelock==3.18.0
|
82 |
+
# fsspec==2025.5.1
|
83 |
+
# future==1.0.0
|
84 |
+
# greenlet==3.2.3
|
85 |
+
# h11==0.16.0
|
86 |
+
# httpcore==1.0.9
|
87 |
+
# httplib2==0.22.0
|
88 |
+
# httpx==0.28.1
|
89 |
+
# huggingface-hub==0.33.0
|
90 |
+
# idna==3.10
|
91 |
+
# Jinja2==3.1.6
|
92 |
+
# jiter==0.10.0
|
93 |
+
# MarkupSafe==3.0.2
|
94 |
+
# mpmath==1.3.0
|
95 |
+
# networkx==3.5
|
96 |
+
# numpy==2.3.1
|
97 |
+
# openai==1.91.0
|
98 |
+
# packaging==25.0
|
99 |
+
# pycparser==2.22
|
100 |
+
# pyparsing==3.2.3
|
101 |
+
# PyYAML==6.0.2
|
102 |
+
# regex==2024.11.6
|
103 |
+
# sentencepiece==0.2.0
|
104 |
+
# sniffio==1.3.1
|
105 |
+
# srt==3.5.3
|
106 |
+
# sympy==1.14.0
|
107 |
+
# tqdm==4.67.1
|
108 |
+
# typing-inspection==0.4.1
|
109 |
+
# typing_extensions==4.14.0
|
110 |
+
# urllib3==2.5.0
|
111 |
+
# websockets==15.0.1
|
src/expon/feedback/application/internal/generate_feedback_service.py
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from datetime import datetime
|
2 |
+
from uuid import uuid4
|
3 |
+
from src.expon.feedback.domain.model.feedback import Feedback
|
4 |
+
from src.expon.feedback.infrastructure.services.text_generation_service import TextGenerationService
|
5 |
+
from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository
|
6 |
+
from src.expon.feedback.infrastructure.persistence.jpa.feedback_repository import FeedbackRepository
|
7 |
+
from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM
|
8 |
+
|
9 |
+
class GenerateFeedbackService:
|
10 |
+
def __init__(self, feedback_repo: FeedbackRepository, presentation_repo: PresentationRepository):
|
11 |
+
self.feedback_repo = feedback_repo
|
12 |
+
self.presentation_repo = presentation_repo
|
13 |
+
self.text_gen_service = TextGenerationService()
|
14 |
+
|
15 |
+
def generate_feedback(self, presentation_id: str) -> Feedback:
|
16 |
+
# 1. Buscar presentación
|
17 |
+
presentation: PresentationORM = self.presentation_repo.get_by_id(presentation_id)
|
18 |
+
|
19 |
+
if presentation is None:
|
20 |
+
raise ValueError("Presentación no encontrada")
|
21 |
+
|
22 |
+
user_id = presentation.user_id
|
23 |
+
emotion = presentation.dominant_emotion
|
24 |
+
transcription = presentation.transcript or ""
|
25 |
+
confidence = presentation.confidence or 0.0
|
26 |
+
anxiety = 0.3
|
27 |
+
|
28 |
+
# 2. Generar contenido dinámico con IA
|
29 |
+
general, language, confidence_fb, anxiety_fb, suggestions = self.text_gen_service.generate_structured_feedback(
|
30 |
+
transcription=transcription,
|
31 |
+
emotion=emotion,
|
32 |
+
confidence=confidence,
|
33 |
+
anxiety=anxiety
|
34 |
+
)
|
35 |
+
|
36 |
+
feedback = Feedback(
|
37 |
+
id=uuid4(),
|
38 |
+
user_id=user_id,
|
39 |
+
presentation_id=presentation_id,
|
40 |
+
general_feedback=general,
|
41 |
+
language_feedback=language,
|
42 |
+
confidence_feedback=confidence_fb,
|
43 |
+
anxiety_feedback=anxiety_fb,
|
44 |
+
suggestions=suggestions,
|
45 |
+
created_at=datetime.utcnow()
|
46 |
+
)
|
47 |
+
|
48 |
+
self.feedback_repo.save(feedback)
|
49 |
+
return feedback
|
50 |
+
|
src/expon/feedback/application/internal/query_feedback_service.py
ADDED
File without changes
|
src/expon/feedback/domain/model/feedback.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from datetime import datetime
|
2 |
+
from uuid import UUID
|
3 |
+
from pydantic import BaseModel, Field
|
4 |
+
|
5 |
+
class Feedback(BaseModel):
|
6 |
+
id: UUID
|
7 |
+
user_id: UUID
|
8 |
+
presentation_id: UUID
|
9 |
+
general_feedback: str
|
10 |
+
language_feedback: str
|
11 |
+
confidence_feedback: str
|
12 |
+
anxiety_feedback: str
|
13 |
+
suggestions: str
|
14 |
+
created_at: datetime
|
15 |
+
|
16 |
+
class Config:
|
17 |
+
orm_mode = True
|
src/expon/feedback/domain/services/feedback_generator_service.py
ADDED
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import re
|
2 |
+
from typing import Tuple
|
3 |
+
|
4 |
+
|
5 |
+
class FeedbackGeneratorService:
|
6 |
+
|
7 |
+
def analyze_emotion_consistency(self, emotion: str, transcription: str) -> str:
|
8 |
+
"""
|
9 |
+
Evalúa si el contenido de la presentación es coherente con la emoción dominante detectada.
|
10 |
+
"""
|
11 |
+
keywords = {
|
12 |
+
"motivado": ["lograr", "puedo", "importante", "avanzar"],
|
13 |
+
"ansioso": ["eh", "bueno", "mmm", "no sé", "tal vez"],
|
14 |
+
"entusiasta": ["me encanta", "disfruté", "fue genial"],
|
15 |
+
"seguro": ["claramente", "sin duda", "obviamente"],
|
16 |
+
"inseguro": ["creo", "quizás", "podría ser"]
|
17 |
+
}
|
18 |
+
palabras = transcription.lower().split()
|
19 |
+
count = sum(p in palabras for p in keywords.get(emotion, []))
|
20 |
+
|
21 |
+
if count >= 2:
|
22 |
+
return f"La emoción detectada ({emotion}) fue coherente con el contenido del discurso."
|
23 |
+
else:
|
24 |
+
return f"La emoción detectada ({emotion}) parece no coincidir completamente con lo expresado verbalmente. Podrías trabajar en alinear tu expresión emocional con tus ideas."
|
25 |
+
|
26 |
+
def analyze_language_quality(self, transcription: str) -> str:
|
27 |
+
"""
|
28 |
+
Detecta uso de jerga, muletillas y falta de conectores.
|
29 |
+
"""
|
30 |
+
muletillas = ["eh", "mmm", "bueno", "o sea", "este"]
|
31 |
+
jergas = ["chévere", "cool", "super", "bacán"]
|
32 |
+
conectores_formales = ["por lo tanto", "además", "en conclusión", "sin embargo"]
|
33 |
+
repetidas = set([w for w in transcription.lower().split() if transcription.lower().split().count(w) > 4])
|
34 |
+
|
35 |
+
issues = []
|
36 |
+
|
37 |
+
if any(m in transcription.lower() for m in muletillas):
|
38 |
+
issues.append("Se detectaron muletillas frecuentes, como 'eh' o 'bueno'. Esto puede restar claridad.")
|
39 |
+
|
40 |
+
if any(j in transcription.lower() for j in jergas):
|
41 |
+
issues.append("El uso de jerga informal no es recomendable en presentaciones académicas.")
|
42 |
+
|
43 |
+
if not any(c in transcription.lower() for c in conectores_formales):
|
44 |
+
issues.append("No se identificaron conectores formales. Usarlos ayuda a organizar mejor tus ideas.")
|
45 |
+
|
46 |
+
if len(repetidas) > 0:
|
47 |
+
issues.append("Detectamos repetición excesiva de algunas palabras, lo que puede afectar la riqueza del discurso.")
|
48 |
+
|
49 |
+
return " ".join(issues) if issues else "El lenguaje utilizado fue adecuado, claro y apropiado para el contexto académico."
|
50 |
+
|
51 |
+
def evaluate_confidence(self, confidence_score: float) -> str:
|
52 |
+
if confidence_score >= 0.8:
|
53 |
+
return "Tu nivel de confianza fue alto. Mantuviste un discurso fluido y seguro."
|
54 |
+
elif confidence_score >= 0.5:
|
55 |
+
return "Confianza aceptable, aunque con espacio para mejorar la entonación o firmeza."
|
56 |
+
else:
|
57 |
+
return "Bajo nivel de confianza detectado. Practicar la presentación con antelación puede ayudarte a mejorar."
|
58 |
+
|
59 |
+
def evaluate_anxiety(self, anxiety_score: float) -> str:
|
60 |
+
if anxiety_score < 0.3:
|
61 |
+
return "Se detectó buen control de ansiedad durante tu exposición."
|
62 |
+
elif anxiety_score < 0.6:
|
63 |
+
return "Ansiedad moderada. Considera técnicas como respiración profunda o pausas conscientes."
|
64 |
+
else:
|
65 |
+
return "Alta ansiedad percibida. Practica con simulaciones o ensayos en voz alta para mejorar tu seguridad."
|
66 |
+
|
67 |
+
def generate_suggestions(self) -> str:
|
68 |
+
return "Prueba practicar en voz alta usando grabaciones. Mejora tu entonación, usa conectores y reduce muletillas."
|
69 |
+
|
70 |
+
def generate_structured_feedback(
|
71 |
+
self,
|
72 |
+
emotion: str,
|
73 |
+
transcription: str,
|
74 |
+
confidence_score: float,
|
75 |
+
anxiety_score: float
|
76 |
+
) -> Tuple[str, str, str, str, str]:
|
77 |
+
"""
|
78 |
+
Devuelve feedback completo dividido en cinco secciones.
|
79 |
+
"""
|
80 |
+
general = self.analyze_emotion_consistency(emotion, transcription)
|
81 |
+
language = self.analyze_language_quality(transcription)
|
82 |
+
confidence = self.evaluate_confidence(confidence_score)
|
83 |
+
anxiety = self.evaluate_anxiety(anxiety_score)
|
84 |
+
suggestions = self.generate_suggestions()
|
85 |
+
|
86 |
+
return general, language, confidence, anxiety, suggestions
|
src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import Column, String, DateTime
|
2 |
+
from sqlalchemy.dialects.postgresql import UUID as SA_UUID
|
3 |
+
from src.expon.shared.infrastructure.database import Base
|
4 |
+
import uuid
|
5 |
+
import datetime
|
6 |
+
|
7 |
+
class FeedbackORM(Base):
|
8 |
+
__tablename__ = "feedback"
|
9 |
+
|
10 |
+
id = Column(SA_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
11 |
+
user_id = Column(SA_UUID(as_uuid=True), nullable=False)
|
12 |
+
presentation_id = Column(SA_UUID(as_uuid=True), nullable=False)
|
13 |
+
general_feedback = Column(String, nullable=False)
|
14 |
+
language_feedback = Column(String, nullable=False)
|
15 |
+
confidence_feedback = Column(String, nullable=False)
|
16 |
+
anxiety_feedback = Column(String, nullable=False)
|
17 |
+
suggestions = Column(String, nullable=False)
|
18 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py
ADDED
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import Session
|
2 |
+
from src.expon.feedback.infrastructure.persistence.jpa.feedback_orm import FeedbackORM
|
3 |
+
from src.expon.feedback.domain.model.feedback import Feedback
|
4 |
+
from datetime import datetime
|
5 |
+
import uuid
|
6 |
+
|
7 |
+
class FeedbackRepository:
|
8 |
+
def __init__(self, db: Session):
|
9 |
+
self.db = db
|
10 |
+
|
11 |
+
def save(self, feedback: Feedback):
|
12 |
+
orm_obj = FeedbackORM(
|
13 |
+
id=uuid.uuid4(),
|
14 |
+
user_id=feedback.user_id,
|
15 |
+
presentation_id=feedback.presentation_id,
|
16 |
+
general_feedback=feedback.general_feedback,
|
17 |
+
language_feedback=feedback.language_feedback,
|
18 |
+
confidence_feedback=feedback.confidence_feedback,
|
19 |
+
anxiety_feedback=feedback.anxiety_feedback,
|
20 |
+
suggestions=feedback.suggestions,
|
21 |
+
created_at=datetime.utcnow()
|
22 |
+
)
|
23 |
+
self.db.add(orm_obj)
|
24 |
+
self.db.commit()
|
25 |
+
|
26 |
+
def get_all(self):
|
27 |
+
return self.db.query(FeedbackORM).all()
|
28 |
+
|
29 |
+
def get_by_user(self, user_id):
|
30 |
+
return self.db.query(FeedbackORM).filter_by(user_id=user_id).all()
|
31 |
+
|
32 |
+
def get_by_presentation(self, presentation_id):
|
33 |
+
return self.db.query(FeedbackORM).filter_by(presentation_id=presentation_id).all()
|
src/expon/feedback/infrastructure/services/text_generation_service.py
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import google.generativeai as genai
|
3 |
+
from dotenv import load_dotenv
|
4 |
+
|
5 |
+
# Cargar variables desde .env
|
6 |
+
load_dotenv()
|
7 |
+
|
8 |
+
class TextGenerationService:
|
9 |
+
def __init__(self, model="gemini-1.5-flash"):
|
10 |
+
self.model_name = model
|
11 |
+
api_key = os.getenv("GEMINI_API_KEY")
|
12 |
+
if not api_key:
|
13 |
+
raise ValueError("GEMINI_API_KEY no encontrada en variables de entorno")
|
14 |
+
|
15 |
+
genai.configure(api_key=api_key)
|
16 |
+
# Usar modelo sin configuración fija para permitir ajustes dinámicos
|
17 |
+
self.model = genai.GenerativeModel(model)
|
18 |
+
|
19 |
+
def generate_structured_feedback(self, transcription: str, emotion: str, confidence: float, anxiety: float) -> tuple[str, str, str, str, str]:
|
20 |
+
# Contexto base con información de la presentación
|
21 |
+
context = (
|
22 |
+
f"ANÁLISIS DE PRESENTACIÓN ACADÉMICA\n"
|
23 |
+
f"====================================\n"
|
24 |
+
f"Transcripción: \"{transcription}\"\n\n"
|
25 |
+
f"Métricas detectadas:\n"
|
26 |
+
f"- Emoción dominante: {emotion}\n"
|
27 |
+
f"- Nivel de confianza: {int(confidence * 100)}%\n"
|
28 |
+
f"- Nivel de ansiedad: {int(anxiety * 100)}%\n"
|
29 |
+
)
|
30 |
+
|
31 |
+
def ask(prompt: str) -> str:
|
32 |
+
try:
|
33 |
+
# Crear el prompt completo con contexto
|
34 |
+
full_prompt = f"""Eres un experto en análisis de presentaciones académicas.
|
35 |
+
|
36 |
+
{context}
|
37 |
+
|
38 |
+
{prompt}
|
39 |
+
|
40 |
+
IMPORTANTE: Responde en máximo 60 palabras, de forma directa y profesional, sin usar comillas dobles."""
|
41 |
+
|
42 |
+
# Configuración dinámica como sugiere GPT
|
43 |
+
response = self.model.generate_content(
|
44 |
+
full_prompt,
|
45 |
+
generation_config={
|
46 |
+
"temperature": 0.7,
|
47 |
+
"max_output_tokens": 100
|
48 |
+
}
|
49 |
+
)
|
50 |
+
|
51 |
+
# Limpiar caracteres de escape y limitaciones
|
52 |
+
clean_text = response.text.strip().replace('\\"', '"').replace('\\n', ' ')
|
53 |
+
# Limitar palabras si es muy largo
|
54 |
+
words = clean_text.split()
|
55 |
+
if len(words) > 60:
|
56 |
+
clean_text = ' '.join(words[:60]) + "..."
|
57 |
+
return clean_text
|
58 |
+
except Exception as e:
|
59 |
+
print(f"Error al generar feedback con Gemini: {e}")
|
60 |
+
return f"Error al generar análisis. Verifique la configuración de la API."
|
61 |
+
|
62 |
+
# Pedir feedback por secciones con prompts más específicos
|
63 |
+
general = ask("Analiza brevemente la presentación general: fortalezas principales y área de mejora más importante.")
|
64 |
+
language = ask("Evalúa el lenguaje: ¿es académico o informal? Menciona 2 mejoras específicas para el vocabulario.")
|
65 |
+
confidence_fb = ask("¿Cómo se percibe la confianza del orador? Analiza el tono y seguridad proyectada.")
|
66 |
+
anxiety_fb = ask("¿Se detecta ansiedad? Proporciona 2 técnicas específicas para reducirla.")
|
67 |
+
suggestions = ask("Lista exactamente 3 mejoras concretas y accionables para futuras presentaciones.")
|
68 |
+
|
69 |
+
return general, language, confidence_fb, anxiety_fb, suggestions
|
src/expon/feedback/interfaces/rest/feedback_controller.py
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
from uuid import UUID
|
4 |
+
|
5 |
+
from src.expon.shared.infrastructure.dependencies import get_db
|
6 |
+
from src.expon.feedback.interfaces.rest.feedback_request import FeedbackRequest
|
7 |
+
from src.expon.feedback.interfaces.rest.feedback_response import FeedbackResponse
|
8 |
+
from src.expon.feedback.infrastructure.persistence.jpa.feedback_repository import FeedbackRepository
|
9 |
+
from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository
|
10 |
+
from src.expon.feedback.application.internal.generate_feedback_service import GenerateFeedbackService
|
11 |
+
|
12 |
+
router = APIRouter()
|
13 |
+
|
14 |
+
|
15 |
+
@router.post("/", response_model=FeedbackResponse)
|
16 |
+
def generate_feedback(request: FeedbackRequest, db: Session = Depends(get_db)):
|
17 |
+
feedback_repo = FeedbackRepository(db)
|
18 |
+
presentation_repo = PresentationRepository(db)
|
19 |
+
service = GenerateFeedbackService(feedback_repo, presentation_repo)
|
20 |
+
result = service.generate_feedback(request.presentation_id)
|
21 |
+
return result
|
22 |
+
|
23 |
+
|
24 |
+
@router.get("/user/{user_id}", response_model=list[FeedbackResponse])
|
25 |
+
def get_feedback_by_user(user_id: UUID, db: Session = Depends(get_db)):
|
26 |
+
repo = FeedbackRepository(db)
|
27 |
+
results = repo.get_by_user(user_id)
|
28 |
+
return results
|
29 |
+
|
30 |
+
|
31 |
+
@router.get("/presentation/{presentation_id}", response_model=list[FeedbackResponse])
|
32 |
+
def get_feedback_by_presentation(presentation_id: UUID, db: Session = Depends(get_db)):
|
33 |
+
repo = FeedbackRepository(db)
|
34 |
+
results = repo.get_by_presentation(presentation_id)
|
35 |
+
return results
|
src/expon/feedback/interfaces/rest/feedback_request.py
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from uuid import UUID
|
3 |
+
|
4 |
+
class FeedbackRequest(BaseModel):
|
5 |
+
presentation_id: UUID
|
src/expon/feedback/interfaces/rest/feedback_response.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
from uuid import UUID
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
class FeedbackResponse(BaseModel):
|
6 |
+
id: UUID
|
7 |
+
user_id: UUID
|
8 |
+
presentation_id: UUID
|
9 |
+
general_feedback: str
|
10 |
+
language_feedback: str
|
11 |
+
confidence_feedback: str
|
12 |
+
anxiety_feedback: str
|
13 |
+
suggestions: str
|
14 |
+
created_at: datetime
|
src/expon/iam/application/acl/iam_context_facade.py
ADDED
File without changes
|
src/expon/iam/application/internal/commandservices/user_command_service.py
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from uuid import uuid4
|
2 |
+
from datetime import datetime
|
3 |
+
from src.expon.iam.domain.model.aggregates.user import User
|
4 |
+
from src.expon.iam.domain.model.commands.sign_up_command import SignUpCommand
|
5 |
+
from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository
|
6 |
+
from src.expon.iam.infrastructure.hashing.bcrypt.services.hashing_service import HashingService
|
7 |
+
|
8 |
+
|
9 |
+
class UserCommandService:
|
10 |
+
def __init__(self, user_repository: UserRepository, hashing_service: HashingService):
|
11 |
+
self.user_repository = user_repository
|
12 |
+
self.hashing_service = hashing_service
|
13 |
+
|
14 |
+
def handle_sign_up(self, command: SignUpCommand) -> User:
|
15 |
+
hashed_password = self.hashing_service.hash(command.password)
|
16 |
+
|
17 |
+
user = User(
|
18 |
+
id=uuid4(),
|
19 |
+
username=command.username,
|
20 |
+
email=command.email,
|
21 |
+
password=hashed_password,
|
22 |
+
created_at=datetime.utcnow(),
|
23 |
+
updated_at=datetime.utcnow()
|
24 |
+
)
|
25 |
+
|
26 |
+
return self.user_repository.save(user)
|
src/expon/iam/application/internal/queryservices/user_query_service.py
ADDED
File without changes
|
src/expon/iam/domain/model/aggregates/user.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from dataclasses import dataclass
|
2 |
+
from uuid import UUID
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
@dataclass
|
7 |
+
class User:
|
8 |
+
id: UUID
|
9 |
+
username: str
|
10 |
+
email: str
|
11 |
+
password: str
|
12 |
+
created_at: datetime
|
13 |
+
updated_at: datetime
|
src/expon/iam/domain/model/commands/sign_up_command.py
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, EmailStr, Field
|
2 |
+
|
3 |
+
|
4 |
+
class SignUpCommand(BaseModel):
|
5 |
+
username: str = Field(..., min_length=3, max_length=50)
|
6 |
+
email: EmailStr
|
7 |
+
password: str = Field(..., min_length=6)
|
8 |
+
|
9 |
+
class Config:
|
10 |
+
arbitrary_types_allowed = True
|
src/expon/iam/domain/model/entities/role.py
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from enum import Enum
|
2 |
+
|
3 |
+
|
4 |
+
class Role(str, Enum):
|
5 |
+
ROLE_USER = "ROLE_USER"
|
6 |
+
ROLE_ADMIN = "ROLE_ADMIN"
|
src/expon/iam/domain/model/queries/get_user_by_email_query.py
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import EmailStr
|
2 |
+
|
3 |
+
|
4 |
+
class GetUserByEmailQuery:
|
5 |
+
def __init__(self, email: EmailStr):
|
6 |
+
self.email = email
|
src/expon/iam/domain/model/valueobjects/email.py
ADDED
File without changes
|
src/expon/iam/domain/services/user_query_service.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import Depends, HTTPException, status
|
2 |
+
from fastapi.security import HTTPBearer
|
3 |
+
from src.expon.iam.infrastructure.tokens.jwt.services.token_service_impl import TokenService
|
4 |
+
from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository
|
5 |
+
from src.expon.shared.infrastructure.dependencies import get_db
|
6 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
7 |
+
|
8 |
+
oauth2_scheme = HTTPBearer()
|
9 |
+
|
10 |
+
def get_current_user(
|
11 |
+
token: HTTPAuthorizationCredentials = Depends(oauth2_scheme),
|
12 |
+
db=Depends(get_db)
|
13 |
+
):
|
14 |
+
payload = TokenService.decode_token(token.credentials)
|
15 |
+
if not payload:
|
16 |
+
raise HTTPException(
|
17 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
18 |
+
detail="Token inválido o expirado"
|
19 |
+
)
|
20 |
+
user_id = payload.get("sub")
|
21 |
+
user_repo = UserRepository(db)
|
22 |
+
user = user_repo.find_by_id(user_id)
|
23 |
+
if not user:
|
24 |
+
raise HTTPException(status_code=404, detail="Usuario no encontrado")
|
25 |
+
return user
|
src/expon/iam/infrastructure/authorization/sfs/configuration/web_security_configuration.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/model/user_details_impl.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/model/username_password_auth_token_builder.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/pipeline/bearer_authorization_request_filter.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/pipeline/unauthorized_request_handler_entrypoint.py
ADDED
File without changes
|
src/expon/iam/infrastructure/authorization/sfs/user_details_service_impl.py
ADDED
File without changes
|
src/expon/iam/infrastructure/hashing/bcrypt/services/bcrypt_hashing_service.py
ADDED
File without changes
|
src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from passlib.context import CryptContext
|
2 |
+
|
3 |
+
|
4 |
+
class HashingService:
|
5 |
+
def __init__(self):
|
6 |
+
# Puedes ajustar el esquema si deseas usar otro algoritmo como argon2
|
7 |
+
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
8 |
+
|
9 |
+
def hash(self, password: str) -> str:
|
10 |
+
return self.pwd_context.hash(password)
|
11 |
+
|
12 |
+
def verify(self, plain_password: str, hashed_password: str) -> bool:
|
13 |
+
return self.pwd_context.verify(plain_password, hashed_password)
|
src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import Column, String, DateTime
|
2 |
+
from uuid import uuid4
|
3 |
+
from sqlalchemy.dialects.postgresql import UUID
|
4 |
+
from datetime import datetime
|
5 |
+
from src.expon.shared.infrastructure.database import Base
|
6 |
+
|
7 |
+
|
8 |
+
class UserEntity(Base):
|
9 |
+
__tablename__ = "users"
|
10 |
+
|
11 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
12 |
+
username = Column(String(50), unique=True, nullable=False)
|
13 |
+
email = Column(String(100), unique=True, nullable=False)
|
14 |
+
password = Column(String(200), nullable=False)
|
15 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
16 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
src/expon/iam/infrastructure/persistence/jpa/repositories/role_repository.py
ADDED
File without changes
|
src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import Session
|
2 |
+
from src.expon.iam.infrastructure.persistence.jpa.entities.user_entity import UserEntity
|
3 |
+
from src.expon.iam.domain.model.aggregates.user import User
|
4 |
+
|
5 |
+
|
6 |
+
class UserRepository:
|
7 |
+
def __init__(self, db: Session):
|
8 |
+
self.db = db
|
9 |
+
|
10 |
+
def save(self, user: User) -> User:
|
11 |
+
entity = UserEntity(
|
12 |
+
id=user.id,
|
13 |
+
username=user.username,
|
14 |
+
email=user.email,
|
15 |
+
password=user.password,
|
16 |
+
created_at=user.created_at,
|
17 |
+
updated_at=user.updated_at
|
18 |
+
)
|
19 |
+
self.db.add(entity)
|
20 |
+
self.db.commit()
|
21 |
+
self.db.refresh(entity)
|
22 |
+
return user
|
23 |
+
|
24 |
+
def find_by_email(self, email: str) -> User | None:
|
25 |
+
entity = self.db.query(UserEntity).filter_by(email=email).first()
|
26 |
+
if not entity:
|
27 |
+
return None
|
28 |
+
return User(
|
29 |
+
id=entity.id,
|
30 |
+
username=entity.username,
|
31 |
+
email=entity.email,
|
32 |
+
password=entity.password,
|
33 |
+
created_at=entity.created_at,
|
34 |
+
updated_at=entity.updated_at
|
35 |
+
)
|
36 |
+
|
37 |
+
def find_by_id(self, user_id: str) -> User | None:
|
38 |
+
entity = self.db.query(UserEntity).filter_by(id=user_id).first()
|
39 |
+
if not entity:
|
40 |
+
return None
|
41 |
+
return User(
|
42 |
+
id=entity.id,
|
43 |
+
username=entity.username,
|
44 |
+
email=entity.email,
|
45 |
+
password=entity.password,
|
46 |
+
created_at=entity.created_at,
|
47 |
+
updated_at=entity.updated_at
|
48 |
+
)
|
src/expon/iam/infrastructure/tokens/jwt/services/bearer_token_service.py
ADDED
File without changes
|
src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import jwt
|
2 |
+
from datetime import datetime, timedelta
|
3 |
+
from src.expon.iam.domain.model.aggregates.user import User
|
4 |
+
from decouple import config
|
5 |
+
|
6 |
+
class TokenService:
|
7 |
+
SECRET_KEY = config("JWT_SECRET_KEY", default="secret")
|
8 |
+
ALGORITHM = "HS256"
|
9 |
+
EXPIRE_MINUTES = 60
|
10 |
+
|
11 |
+
@classmethod
|
12 |
+
def generate_token(cls, user: User) -> str:
|
13 |
+
payload = {
|
14 |
+
"sub": str(user.id),
|
15 |
+
"username": user.username,
|
16 |
+
"email": user.email,
|
17 |
+
"exp": datetime.utcnow() + timedelta(minutes=cls.EXPIRE_MINUTES)
|
18 |
+
}
|
19 |
+
return jwt.encode(payload, cls.SECRET_KEY, algorithm=cls.ALGORITHM)
|
20 |
+
|
21 |
+
@classmethod
|
22 |
+
def decode_token(cls, token: str) -> dict | None:
|
23 |
+
try:
|
24 |
+
payload = jwt.decode(token, cls.SECRET_KEY, algorithms=[cls.ALGORITHM])
|
25 |
+
return payload
|
26 |
+
except jwt.ExpiredSignatureError:
|
27 |
+
return None
|
28 |
+
except jwt.InvalidTokenError:
|
29 |
+
return None
|
src/expon/iam/interfaces/rest/controllers/auth_controller.py
ADDED
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
2 |
+
from sqlalchemy.orm import Session
|
3 |
+
|
4 |
+
# Commands y modelos
|
5 |
+
from src.expon.iam.domain.model.commands.sign_up_command import SignUpCommand
|
6 |
+
from src.expon.iam.domain.model.aggregates.user import User
|
7 |
+
|
8 |
+
# Servicios de dominio
|
9 |
+
from src.expon.iam.application.internal.commandservices.user_command_service import UserCommandService
|
10 |
+
|
11 |
+
# Infraestructura: hashing y token
|
12 |
+
from src.expon.iam.infrastructure.hashing.bcrypt.services.hashing_service import HashingService
|
13 |
+
from src.expon.iam.infrastructure.tokens.jwt.services.token_service_impl import TokenService
|
14 |
+
|
15 |
+
# Infraestructura: repositorio e inyección de dependencias
|
16 |
+
from src.expon.iam.infrastructure.persistence.jpa.repositories.user_repository import UserRepository
|
17 |
+
from src.expon.shared.infrastructure.dependencies import get_db
|
18 |
+
|
19 |
+
# Middleware JWT
|
20 |
+
from src.expon.iam.infrastructure.authorization.sfs.auth_bearer import get_current_user
|
21 |
+
|
22 |
+
# Esquemas (DTOs REST)
|
23 |
+
from src.expon.iam.interfaces.rest.schemas.login_request import LoginRequest
|
24 |
+
from src.expon.iam.interfaces.rest.schemas.auth_response import AuthResponse
|
25 |
+
|
26 |
+
router = APIRouter()
|
27 |
+
|
28 |
+
|
29 |
+
@router.post("/signup")
|
30 |
+
def signup(command: SignUpCommand, db: Session = Depends(get_db)):
|
31 |
+
user_repository = UserRepository(db)
|
32 |
+
hashing_service = HashingService()
|
33 |
+
user_command_service = UserCommandService(user_repository, hashing_service)
|
34 |
+
|
35 |
+
existing_user = user_repository.find_by_email(command.email)
|
36 |
+
if existing_user:
|
37 |
+
raise HTTPException(
|
38 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
39 |
+
detail="Email already registered"
|
40 |
+
)
|
41 |
+
|
42 |
+
user = user_command_service.handle_sign_up(command)
|
43 |
+
return {
|
44 |
+
"id": str(user.id),
|
45 |
+
"username": user.username,
|
46 |
+
"email": user.email,
|
47 |
+
"created_at": user.created_at.isoformat()
|
48 |
+
}
|
49 |
+
|
50 |
+
|
51 |
+
@router.post("/login", response_model=AuthResponse)
|
52 |
+
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
53 |
+
user_repository = UserRepository(db)
|
54 |
+
hashing_service = HashingService()
|
55 |
+
|
56 |
+
user = user_repository.find_by_email(request.email)
|
57 |
+
if not user or not hashing_service.verify(request.password, user.password):
|
58 |
+
raise HTTPException(status_code=401, detail="Credenciales inválidas")
|
59 |
+
|
60 |
+
token = TokenService.generate_token(user)
|
61 |
+
return AuthResponse(
|
62 |
+
access_token=token,
|
63 |
+
token_type="bearer",
|
64 |
+
user_id=str(user.id)
|
65 |
+
)
|
66 |
+
|
67 |
+
|
68 |
+
@router.get("/me")
|
69 |
+
def get_me(current_user: User = Depends(get_current_user)):
|
70 |
+
return {
|
71 |
+
"id": str(current_user.id),
|
72 |
+
"username": current_user.username,
|
73 |
+
"email": current_user.email
|
74 |
+
}
|
src/expon/iam/interfaces/rest/schemas/auth_response.py
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel
|
2 |
+
|
3 |
+
class AuthResponse(BaseModel):
|
4 |
+
access_token: str
|
5 |
+
token_type: str = "bearer"
|
6 |
+
user_id: str
|
src/expon/iam/interfaces/rest/schemas/login_request.py
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, EmailStr
|
2 |
+
|
3 |
+
class LoginRequest(BaseModel):
|
4 |
+
email: EmailStr
|
5 |
+
password: str
|
src/expon/presentation/application/internal/commandservices/audio_upload_service.py
ADDED
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from uuid import UUID, uuid4
|
2 |
+
from datetime import datetime, timezone
|
3 |
+
from fastapi import UploadFile
|
4 |
+
from pydub.utils import mediainfo
|
5 |
+
|
6 |
+
from src.expon.presentation.domain.model.aggregates.presentation import Presentation
|
7 |
+
from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata
|
8 |
+
from src.expon.presentation.domain.services.transcription_service import TranscriptionService
|
9 |
+
from src.expon.presentation.domain.services.sentiment_analysis_service import SentimentAnalysisService
|
10 |
+
from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository
|
11 |
+
from src.expon.presentation.infrastructure.services.storage.local_storage_service import LocalStorageService
|
12 |
+
|
13 |
+
import os
|
14 |
+
|
15 |
+
class AudioUploadService:
|
16 |
+
def __init__(
|
17 |
+
self,
|
18 |
+
storage_service: LocalStorageService,
|
19 |
+
transcription_service: TranscriptionService,
|
20 |
+
sentiment_service: SentimentAnalysisService,
|
21 |
+
repository: PresentationRepository
|
22 |
+
):
|
23 |
+
self.storage_service = storage_service
|
24 |
+
self.transcription_service = transcription_service
|
25 |
+
self.sentiment_service = sentiment_service
|
26 |
+
self.repository = repository
|
27 |
+
|
28 |
+
def upload_and_analyze(self, file: UploadFile, user_id: UUID = UUID("00000000-0000-0000-0000-000000000000")):
|
29 |
+
# 1. Guardar archivo original
|
30 |
+
file_path = self.storage_service.save(file)
|
31 |
+
|
32 |
+
# 2. Transcribir directamente con AssemblyAI
|
33 |
+
result = self.transcription_service.transcribe(file_path)
|
34 |
+
|
35 |
+
transcript = result["text"]
|
36 |
+
confidence = result.get("confidence", 1.0)
|
37 |
+
|
38 |
+
# 3. Simular metadata básica (AssemblyAI no devuelve duración ni sample_rate)
|
39 |
+
metadata = AudioMetadata(
|
40 |
+
duration=0.0, # Placeholder, se puede estimar si se requiere
|
41 |
+
sample_rate=16000, # Valor asumido estándar
|
42 |
+
language="es"
|
43 |
+
)
|
44 |
+
|
45 |
+
# 4. Analizar emoción
|
46 |
+
emotion_data = self.sentiment_service.analyze(transcript)
|
47 |
+
print("[DEBUG] Transcripción exitosa. Texto:", transcript[:50])
|
48 |
+
|
49 |
+
# 5. Crear entidad Presentation
|
50 |
+
presentation = Presentation(
|
51 |
+
id=uuid4(),
|
52 |
+
user_id=user_id,
|
53 |
+
filename=file.filename,
|
54 |
+
transcript=transcript,
|
55 |
+
dominant_emotion=emotion_data["dominant_emotion"],
|
56 |
+
emotion_probabilities=emotion_data["emotion_probabilities"],
|
57 |
+
confidence=emotion_data["confidence"],
|
58 |
+
metadata=metadata,
|
59 |
+
created_at=datetime.now(timezone.utc)
|
60 |
+
)
|
61 |
+
|
62 |
+
# 6. Guardar en base de datos
|
63 |
+
self.repository.save(presentation)
|
64 |
+
|
65 |
+
# 7. Eliminar archivo temporal
|
66 |
+
try:
|
67 |
+
os.remove(file_path)
|
68 |
+
except Exception:
|
69 |
+
pass
|
70 |
+
|
71 |
+
return presentation
|
src/expon/presentation/application/internal/queryservices/presentation_query_service.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import List, Optional
|
2 |
+
from src.expon.presentation.infrastructure.persistence.jpa.repositories.presentation_repository import PresentationRepository
|
3 |
+
from src.expon.presentation.domain.model.aggregates.presentation import Presentation
|
4 |
+
|
5 |
+
class PresentationQueryService:
|
6 |
+
def __init__(self, repository: PresentationRepository):
|
7 |
+
self.repository = repository
|
8 |
+
|
9 |
+
def get_presentations_by_user(self, user_id: int) -> List[Presentation]:
|
10 |
+
return self.repository.get_by_user_id(user_id)
|
11 |
+
|
12 |
+
def get_presentation_by_id_and_user(self, presentation_id: int, user_id: int) -> Optional[Presentation]:
|
13 |
+
return self.repository.get_by_id_and_user(presentation_id, user_id)
|
src/expon/presentation/domain/model/aggregates/presentation.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from dataclasses import dataclass
|
2 |
+
from uuid import UUID
|
3 |
+
from datetime import datetime
|
4 |
+
from typing import Dict
|
5 |
+
from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata
|
6 |
+
|
7 |
+
@dataclass
|
8 |
+
class Presentation:
|
9 |
+
id: UUID
|
10 |
+
user_id: UUID
|
11 |
+
filename: str
|
12 |
+
transcript: str
|
13 |
+
dominant_emotion: str # <- antes era 'sentiment'
|
14 |
+
emotion_probabilities: Dict[str, float] # <- nuevo campo
|
15 |
+
confidence: float
|
16 |
+
created_at: datetime
|
17 |
+
metadata: AudioMetadata
|
src/expon/presentation/domain/model/valueobjects/audio_metadata.py
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from dataclasses import dataclass
|
2 |
+
|
3 |
+
@dataclass
|
4 |
+
class AudioMetadata:
|
5 |
+
duration: float # en segundos
|
6 |
+
sample_rate: int
|
7 |
+
language: str
|
src/expon/presentation/domain/services/assemblyai_service.py
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# presentation/infrastructure/services/transcription/assemblyai_service.py
|
2 |
+
import requests
|
3 |
+
import time
|
4 |
+
|
5 |
+
ASSEMBLYAI_API_KEY = "550f6809220c48b29da16e609ab5ae44"
|
6 |
+
UPLOAD_ENDPOINT = "https://api.assemblyai.com/v2/upload"
|
7 |
+
TRANSCRIPT_ENDPOINT = "https://api.assemblyai.com/v2/transcript"
|
8 |
+
|
9 |
+
headers = {
|
10 |
+
"authorization": ASSEMBLYAI_API_KEY
|
11 |
+
}
|
12 |
+
|
13 |
+
def upload_audio(file_path: str) -> str:
|
14 |
+
with open(file_path, "rb") as f:
|
15 |
+
response = requests.post(UPLOAD_ENDPOINT, headers=headers, files={'file': f})
|
16 |
+
response.raise_for_status()
|
17 |
+
return response.json()["upload_url"]
|
18 |
+
|
19 |
+
def transcribe_audio(upload_url: str) -> dict:
|
20 |
+
transcript_request = {
|
21 |
+
"audio_url": upload_url,
|
22 |
+
"language_code": "es" # Español
|
23 |
+
}
|
24 |
+
response = requests.post(TRANSCRIPT_ENDPOINT, json=transcript_request, headers=headers)
|
25 |
+
response.raise_for_status()
|
26 |
+
transcript_id = response.json()["id"]
|
27 |
+
|
28 |
+
# Polling: esperar hasta que se procese
|
29 |
+
while True:
|
30 |
+
polling_response = requests.get(f"{TRANSCRIPT_ENDPOINT}/{transcript_id}", headers=headers)
|
31 |
+
polling_data = polling_response.json()
|
32 |
+
if polling_data["status"] == "completed":
|
33 |
+
return polling_data
|
34 |
+
elif polling_data["status"] == "error":
|
35 |
+
raise Exception(f"Transcripción falló: {polling_data['error']}")
|
36 |
+
time.sleep(3)
|
src/expon/presentation/domain/services/sentiment_analysis_service.py
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from transformers import pipeline
|
2 |
+
from typing import Dict
|
3 |
+
from huggingface_hub import login
|
4 |
+
import shutil
|
5 |
+
import os
|
6 |
+
|
7 |
+
class SentimentAnalysisService:
|
8 |
+
def __init__(self):
|
9 |
+
try:
|
10 |
+
print("[LOG] Intentando iniciar sesión en Hugging Face...")
|
11 |
+
token = os.getenv("HUGGINGFACE_TOKEN")
|
12 |
+
if not token:
|
13 |
+
raise ValueError("No se encontró HUGGINGFACE_TOKEN en variables de entorno")
|
14 |
+
login(token)
|
15 |
+
print("[LOG] Sesión iniciada correctamente.")
|
16 |
+
except Exception as e:
|
17 |
+
print("[ERROR] Falló el login:", e)
|
18 |
+
raise
|
19 |
+
|
20 |
+
try:
|
21 |
+
print("[LOG] Cargando pipeline...")
|
22 |
+
self.pipeline = pipeline(
|
23 |
+
"text2text-generation", # ← importante: este es el task correcto para T5
|
24 |
+
model="mrm8488/t5-base-finetuned-emotion" # ← nombre correcto del modelo
|
25 |
+
)
|
26 |
+
print("[LOG] Pipeline cargado correctamente.")
|
27 |
+
except Exception as e:
|
28 |
+
print("[ERROR] Falló la carga del modelo:", e)
|
29 |
+
raise
|
30 |
+
|
31 |
+
def analyze(self, transcript: str) -> Dict:
|
32 |
+
print("[LOG] Análisis de transcripción recibido.")
|
33 |
+
prompt = f"emocion: {transcript}"
|
34 |
+
try:
|
35 |
+
output = self.pipeline(prompt, max_length=20)
|
36 |
+
print("[LOG] Resultado del modelo:", output)
|
37 |
+
raw_emotion = output[0]['generated_text'].strip().lower()
|
38 |
+
except Exception as e:
|
39 |
+
print("[ERROR] Falló la predicción:", e)
|
40 |
+
return {
|
41 |
+
"dominant_emotion": "error",
|
42 |
+
"emotion_probabilities": {},
|
43 |
+
"confidence": 0.0
|
44 |
+
}
|
45 |
+
|
46 |
+
emotion_mapping = {
|
47 |
+
"confianza": "motivado",
|
48 |
+
"alegría": "entusiasta",
|
49 |
+
"tristeza": "desmotivado",
|
50 |
+
"miedo": "ansioso",
|
51 |
+
"enfado": "frustrado",
|
52 |
+
"amor": "conectado",
|
53 |
+
"sorpresa": "sorprendido",
|
54 |
+
# etiquetas del modelo (en inglés)
|
55 |
+
"joy": "entusiasta",
|
56 |
+
"fear": "ansioso",
|
57 |
+
"anger": "frustrado",
|
58 |
+
"love": "conectado",
|
59 |
+
"surprise": "sorprendido",
|
60 |
+
"sadness": "desmotivado",
|
61 |
+
"trust": "motivado"
|
62 |
+
}
|
63 |
+
|
64 |
+
|
65 |
+
mapped_emotion = emotion_mapping.get(raw_emotion, "desconocido")
|
66 |
+
|
67 |
+
return {
|
68 |
+
"dominant_emotion": mapped_emotion,
|
69 |
+
"emotion_probabilities": {
|
70 |
+
mapped_emotion: 1.0
|
71 |
+
},
|
72 |
+
"confidence": 1.0
|
73 |
+
}
|
src/expon/presentation/domain/services/transcription_service.py
ADDED
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import requests
|
3 |
+
import time
|
4 |
+
from tempfile import NamedTemporaryFile
|
5 |
+
|
6 |
+
ASSEMBLYAI_API_KEY = "550f6809220c48b29da16e609ab5ae44"
|
7 |
+
UPLOAD_URL = "https://api.assemblyai.com/v2/upload"
|
8 |
+
TRANSCRIBE_URL = "https://api.assemblyai.com/v2/transcript"
|
9 |
+
|
10 |
+
class TranscriptionService:
|
11 |
+
def transcribe(self, file_path: str) -> dict:
|
12 |
+
print(f"[DEBUG] Transcribiendo desde archivo: {file_path}")
|
13 |
+
if not os.path.exists(file_path):
|
14 |
+
raise Exception("Archivo no encontrado para transcripción")
|
15 |
+
|
16 |
+
if os.path.getsize(file_path) == 0:
|
17 |
+
raise Exception("El archivo está vacío. Verifica que se haya subido correctamente.")
|
18 |
+
|
19 |
+
try:
|
20 |
+
# Paso 1: Subir archivo
|
21 |
+
with open(file_path, "rb") as f:
|
22 |
+
upload_res = requests.post(
|
23 |
+
UPLOAD_URL,
|
24 |
+
headers={"authorization": ASSEMBLYAI_API_KEY},
|
25 |
+
data=f
|
26 |
+
)
|
27 |
+
upload_res.raise_for_status()
|
28 |
+
audio_url = upload_res.json()["upload_url"]
|
29 |
+
|
30 |
+
# Paso 2: Solicitar transcripción
|
31 |
+
transcript_res = requests.post(
|
32 |
+
TRANSCRIBE_URL,
|
33 |
+
json={"audio_url": audio_url, "language_code": "es"},
|
34 |
+
headers={"authorization": ASSEMBLYAI_API_KEY}
|
35 |
+
)
|
36 |
+
transcript_res.raise_for_status()
|
37 |
+
transcript_id = transcript_res.json()["id"]
|
38 |
+
|
39 |
+
# Paso 3: Polling
|
40 |
+
while True:
|
41 |
+
poll_res = requests.get(f"{TRANSCRIBE_URL}/{transcript_id}", headers={"authorization": ASSEMBLYAI_API_KEY})
|
42 |
+
poll_data = poll_res.json()
|
43 |
+
if poll_data["status"] == "completed":
|
44 |
+
return {
|
45 |
+
"text": poll_data["text"],
|
46 |
+
"confidence": poll_data.get("confidence", 1.0)
|
47 |
+
}
|
48 |
+
elif poll_data["status"] == "error":
|
49 |
+
raise Exception(f"Error en AssemblyAI: {poll_data['error']}")
|
50 |
+
time.sleep(3)
|
51 |
+
|
52 |
+
except Exception as e:
|
53 |
+
print(f"[ERROR] Error en transcripción: {e}")
|
54 |
+
raise
|
src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from src.expon.presentation.domain.model.aggregates.presentation import Presentation
|
2 |
+
from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata
|
3 |
+
from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM
|
4 |
+
|
5 |
+
class PresentationMapper:
|
6 |
+
|
7 |
+
def to_domain(self, orm: PresentationORM) -> Presentation:
|
8 |
+
metadata = AudioMetadata(
|
9 |
+
duration=orm.duration,
|
10 |
+
sample_rate=orm.sample_rate,
|
11 |
+
language=orm.language
|
12 |
+
)
|
13 |
+
return Presentation(
|
14 |
+
id=orm.id,
|
15 |
+
user_id=orm.user_id,
|
16 |
+
filename=orm.filename,
|
17 |
+
transcript=orm.transcript,
|
18 |
+
dominant_emotion=orm.dominant_emotion,
|
19 |
+
emotion_probabilities=orm.emotion_probabilities,
|
20 |
+
confidence=orm.confidence,
|
21 |
+
metadata=metadata,
|
22 |
+
created_at=orm.created_at
|
23 |
+
)
|
24 |
+
|
25 |
+
def to_orm(self, entity: Presentation) -> PresentationORM:
|
26 |
+
return PresentationORM(
|
27 |
+
id=entity.id,
|
28 |
+
user_id=entity.user_id,
|
29 |
+
filename=entity.filename,
|
30 |
+
transcript=entity.transcript,
|
31 |
+
dominant_emotion=entity.dominant_emotion,
|
32 |
+
emotion_probabilities=entity.emotion_probabilities,
|
33 |
+
confidence=entity.confidence,
|
34 |
+
duration=entity.metadata.duration,
|
35 |
+
sample_rate=entity.metadata.sample_rate,
|
36 |
+
language=entity.metadata.language,
|
37 |
+
created_at=entity.created_at
|
38 |
+
)
|
src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy import Column, String, Float, DateTime, ForeignKey, JSON
|
2 |
+
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
3 |
+
from src.expon.shared.infrastructure.database import Base
|
4 |
+
from sqlalchemy.dialects.postgresql import JSONB
|
5 |
+
import datetime
|
6 |
+
|
7 |
+
class PresentationORM(Base):
|
8 |
+
__tablename__ = "presentations"
|
9 |
+
|
10 |
+
id = Column(PGUUID(as_uuid=True), primary_key=True, index=True)
|
11 |
+
user_id = Column(PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
12 |
+
|
13 |
+
filename = Column(String, nullable=False)
|
14 |
+
transcript = Column(String, nullable=True)
|
15 |
+
dominant_emotion = Column(String, nullable=True) # <- antes 'sentiment'
|
16 |
+
emotion_probabilities = Column(JSONB, nullable=True) # requiere PostgreSQL
|
17 |
+
confidence = Column(Float, nullable=True)
|
18 |
+
|
19 |
+
duration = Column(Float, nullable=True)
|
20 |
+
sample_rate = Column(Float, nullable=True)
|
21 |
+
language = Column(String, nullable=True)
|
22 |
+
|
23 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
src/expon/presentation/infrastructure/persistence/jpa/repositories/presentation_repository.py
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from sqlalchemy.orm import Session
|
2 |
+
from src.expon.presentation.domain.model.aggregates.presentation import Presentation
|
3 |
+
from src.expon.presentation.domain.model.valueobjects.audio_metadata import AudioMetadata
|
4 |
+
from src.expon.presentation.infrastructure.persistence.jpa.models.presentation_orm import PresentationORM
|
5 |
+
from typing import List, Optional
|
6 |
+
from src.expon.presentation.infrastructure.persistence.jpa.mappers.presentation_mapper import PresentationMapper
|
7 |
+
|
8 |
+
class PresentationRepository:
|
9 |
+
def __init__(self, db: Session):
|
10 |
+
self.db = db
|
11 |
+
self.mapper = PresentationMapper()
|
12 |
+
|
13 |
+
def save(self, presentation: Presentation) -> None:
|
14 |
+
db_model = PresentationORM(
|
15 |
+
id=presentation.id,
|
16 |
+
user_id=presentation.user_id,
|
17 |
+
filename=presentation.filename,
|
18 |
+
transcript=presentation.transcript,
|
19 |
+
dominant_emotion=presentation.dominant_emotion,
|
20 |
+
emotion_probabilities=presentation.emotion_probabilities,
|
21 |
+
confidence=presentation.confidence,
|
22 |
+
duration=presentation.metadata.duration,
|
23 |
+
sample_rate=presentation.metadata.sample_rate,
|
24 |
+
language=presentation.metadata.language,
|
25 |
+
created_at=presentation.created_at
|
26 |
+
)
|
27 |
+
self.db.add(db_model)
|
28 |
+
self.db.commit()
|
29 |
+
# return db_model # Descomenta si necesitas retornar el objeto guardado
|
30 |
+
|
31 |
+
def get_by_id(self, presentation_id: str) -> Optional[PresentationORM]:
|
32 |
+
return self.db.query(PresentationORM).filter(PresentationORM.id == presentation_id).first()
|
33 |
+
|
34 |
+
def get_by_user_id(self, user_id: int) -> List[Presentation]:
|
35 |
+
entities = self.db.query(PresentationORM).filter_by(user_id=user_id).all()
|
36 |
+
return [self.mapper.to_domain(e) for e in entities]
|
37 |
+
|
38 |
+
def get_by_id_and_user(self, presentation_id: int, user_id: int) -> Optional[Presentation]:
|
39 |
+
entity = self.db.query(PresentationORM).filter_by(id=presentation_id, user_id=user_id).first()
|
40 |
+
return self.mapper.to_domain(entity) if entity else None
|