SantosPatazca commited on
Commit
c71e312
·
1 Parent(s): cb5fccb

primer despliegue FastAPI con Docker

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +37 -0
  2. Dockerfile +14 -0
  3. main.py +44 -0
  4. requirements.txt +111 -0
  5. src/expon/feedback/application/internal/generate_feedback_service.py +50 -0
  6. src/expon/feedback/application/internal/query_feedback_service.py +0 -0
  7. src/expon/feedback/domain/model/feedback.py +17 -0
  8. src/expon/feedback/domain/services/feedback_generator_service.py +86 -0
  9. src/expon/feedback/infrastructure/persistence/jpa/feedback_orm.py +18 -0
  10. src/expon/feedback/infrastructure/persistence/jpa/feedback_repository.py +33 -0
  11. src/expon/feedback/infrastructure/services/text_generation_service.py +69 -0
  12. src/expon/feedback/interfaces/rest/feedback_controller.py +35 -0
  13. src/expon/feedback/interfaces/rest/feedback_request.py +5 -0
  14. src/expon/feedback/interfaces/rest/feedback_response.py +14 -0
  15. src/expon/iam/application/acl/iam_context_facade.py +0 -0
  16. src/expon/iam/application/internal/commandservices/user_command_service.py +26 -0
  17. src/expon/iam/application/internal/queryservices/user_query_service.py +0 -0
  18. src/expon/iam/domain/model/aggregates/user.py +13 -0
  19. src/expon/iam/domain/model/commands/sign_up_command.py +10 -0
  20. src/expon/iam/domain/model/entities/role.py +6 -0
  21. src/expon/iam/domain/model/queries/get_user_by_email_query.py +6 -0
  22. src/expon/iam/domain/model/valueobjects/email.py +0 -0
  23. src/expon/iam/domain/services/user_query_service.py +0 -0
  24. src/expon/iam/infrastructure/authorization/sfs/auth_bearer.py +25 -0
  25. src/expon/iam/infrastructure/authorization/sfs/configuration/web_security_configuration.py +0 -0
  26. src/expon/iam/infrastructure/authorization/sfs/model/user_details_impl.py +0 -0
  27. src/expon/iam/infrastructure/authorization/sfs/model/username_password_auth_token_builder.py +0 -0
  28. src/expon/iam/infrastructure/authorization/sfs/pipeline/bearer_authorization_request_filter.py +0 -0
  29. src/expon/iam/infrastructure/authorization/sfs/pipeline/unauthorized_request_handler_entrypoint.py +0 -0
  30. src/expon/iam/infrastructure/authorization/sfs/user_details_service_impl.py +0 -0
  31. src/expon/iam/infrastructure/hashing/bcrypt/services/bcrypt_hashing_service.py +0 -0
  32. src/expon/iam/infrastructure/hashing/bcrypt/services/hashing_service.py +13 -0
  33. src/expon/iam/infrastructure/persistence/jpa/entities/user_entity.py +16 -0
  34. src/expon/iam/infrastructure/persistence/jpa/repositories/role_repository.py +0 -0
  35. src/expon/iam/infrastructure/persistence/jpa/repositories/user_repository.py +48 -0
  36. src/expon/iam/infrastructure/tokens/jwt/services/bearer_token_service.py +0 -0
  37. src/expon/iam/infrastructure/tokens/jwt/services/token_service_impl.py +29 -0
  38. src/expon/iam/interfaces/rest/controllers/auth_controller.py +74 -0
  39. src/expon/iam/interfaces/rest/schemas/auth_response.py +6 -0
  40. src/expon/iam/interfaces/rest/schemas/login_request.py +5 -0
  41. src/expon/presentation/application/internal/commandservices/audio_upload_service.py +71 -0
  42. src/expon/presentation/application/internal/queryservices/presentation_query_service.py +13 -0
  43. src/expon/presentation/domain/model/aggregates/presentation.py +17 -0
  44. src/expon/presentation/domain/model/valueobjects/audio_metadata.py +7 -0
  45. src/expon/presentation/domain/services/assemblyai_service.py +36 -0
  46. src/expon/presentation/domain/services/sentiment_analysis_service.py +73 -0
  47. src/expon/presentation/domain/services/transcription_service.py +54 -0
  48. src/expon/presentation/infrastructure/persistence/jpa/mappers/presentation_mapper.py +38 -0
  49. src/expon/presentation/infrastructure/persistence/jpa/models/presentation_orm.py +23 -0
  50. 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