Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
DB_SERVER=localhost\MSSQLSERVER2025
DB_DATABASE=job_postings_mvp
DB_USER=sa
DB_PASSWORD=tu_password
DB_USER=your_sql_user
DB_PASSWORD=your_sql_password
OPENAI_API_KEY=your_openai_api_key
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:

- name: Validate Python syntax
run: |
python -m compileall app modules tests api.py app.py services.py run_tests.py
python -m compileall app modules tests api.py app.py run_tests.py

lint:
runs-on: windows-latest
Expand All @@ -48,7 +48,7 @@ jobs:

- name: Run Ruff
run: |
ruff check app modules tests api.py app.py services.py run_tests.py
ruff check app modules tests api.py app.py run_tests.py

test:
runs-on: windows-latest
Expand Down
49 changes: 42 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Aplicacion para registrar vacantes, analizarlas contra un perfil profesional y g
- registrar vacantes
- listar, archivar, reactivar y eliminar vacantes
- analizar vacantes contra el perfil activo
- guardar vacantes desde la extension de Chrome
- mostrar progreso de guardado y analisis en la extension
- recuperar analisis historicos en la extension al reabrir una vacante ya registrada por `link`
- gestionar aplicaciones y su seguimiento
- gestionar perfil profesional, skills, experiencia, proyectos, educacion, cursos y certificaciones
- renderizar una vista CV consolidada desde la web
Expand All @@ -32,7 +35,7 @@ CVs-Optimizator/
| |-- infrastructure/
| `-- interfaces/
| `-- web/
|-- modules/
|-- chrome-extension/
|-- tests/
|-- sql_queries/
|-- run_tests.py
Expand All @@ -50,16 +53,25 @@ CVs-Optimizator/

1. Crea `.env` a partir de `.env.example`.
2. Ajusta credenciales de SQL Server.
3. Agrega `OPENAI_API_KEY` si vas a ejecutar analisis reales de vacantes.

Variables usadas:

```env
DB_SERVER=localhost\MSSQLSERVER2025
DB_DATABASE=job_postings_mvp
DB_USER=sa
DB_PASSWORD=tu_password
DB_USER=your_sql_user
DB_PASSWORD=your_sql_password
OPENAI_API_KEY=your_openai_api_key
```

Notas de configuracion:

- `DB_USER` y `DB_PASSWORD` son obligatorias para conectarse a SQL Server.
- `DB_SERVER` y `DB_DATABASE` mantienen defaults locales de desarrollo si no se definen.
- `OPENAI_API_KEY` es obligatoria para ejecutar analisis reales con OpenAI.
- La app puede arrancar sin BD, pero los flujos que persisten o consultan datos devolveran errores controlados o vistas vacias.

## Instalacion

```bash
Expand All @@ -73,13 +85,13 @@ pip install -r requirements.txt
Interfaz principal web:

```bash
uvicorn api:app --reload
uvicorn api:app --reload --port 8001
```

Luego abre:

```text
http://127.0.0.1:8000/app
http://127.0.0.1:8001/app
```

Prueba rapida de BD:
Expand All @@ -106,6 +118,27 @@ La interfaz web ya cubre el flujo principal del producto:

Las vistas largas de `Inbox` y `Seguimiento` ya incluyen paginacion y tamano de pagina configurable para evitar listas demasiado pesadas.

## Extension de Chrome

La extension usa `side panel` en lugar de popup efimero.

Flujo actual:

1. extrae la vacante desde LinkedIn
2. guarda la vacante por API local
3. inicia el analisis en segundo plano
4. muestra estados intermedios en el panel lateral
5. si la vacante ya existia y tenia analisis, lo recupera desde la BD usando el `link`

Endpoints usados por la extension:

- `GET /health`
- `POST /vacantes`
- `POST /vacantes/async`
- `GET /vacantes/tasks/{task_id}`
- `GET /vacantes/{vacancy_id}/analysis`
- `GET /vacantes/by-link?link=...`

## Tests

Entrada unica recomendada:
Expand All @@ -121,6 +154,7 @@ Estado actual de la suite principal:
- tests de API
- tests de rutas web
- tests de flujos integrados con mocks
- tests del flujo async y de recuperacion de analisis por `link`

## CI

Expand All @@ -140,13 +174,14 @@ Resumen corto:
- `app/application`: casos de uso y servicios
- `app/infrastructure`: conexion y repositorios
- `app/interfaces/web`: interfaz HTML principal
- `modules`: codigo residual compartido, como `analizar_vacante.py`
- `chrome-extension`: integracion local con LinkedIn y la API

Detalle adicional en [ARCHITECTURE.md](/C:/Users/josem/PycharmProjects/CVs-Optimizator/docs/ARCHITECTURE.md).

## Notas

- la web es ahora la interfaz recomendada
- `app.py` en la raiz queda solo como acceso rapido informativo
- la suite principal corre localmente con `53` tests cubriendo la interfaz web principal
- la extension de Chrome ya opera con panel lateral y recuperacion de analisis historico por `link`
- la suite principal corre localmente con `68` tests
- el pipeline de CI ya esta preparado para validar cambios automaticamente
95 changes: 58 additions & 37 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import Optional
from uuid import uuid4

from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
Expand Down Expand Up @@ -38,8 +38,8 @@
profile_repository=profile_repository,
analysis_repository=analysis_repository,
)
analysis_jobs: dict[str, dict] = {}
analysis_jobs_lock = threading.Lock()
analysis_tasks: dict[str, dict] = {}
analysis_tasks_lock = threading.Lock()


app = FastAPI(
Expand Down Expand Up @@ -78,12 +78,12 @@ class VacanteResponse(BaseModel):


class AsyncVacanteResponse(VacanteResponse):
job_id: str
task_id: str
status: str


class AnalysisJobResponse(BaseModel):
job_id: str
class AnalysisTaskResponse(BaseModel):
task_id: str
vacancy_id: int
status: str
step: str
Expand All @@ -98,24 +98,30 @@ class VacancyAnalysisResponse(BaseModel):
analysis: dict


class VacancyByLinkResponse(BaseModel):
found: bool
vacancy: Optional[dict] = None
analysis: Optional[dict] = None


def _utc_now() -> str:
return datetime.utcnow().isoformat(timespec="seconds") + "Z"


def _set_job_state(job_id: str, **updates) -> dict:
with analysis_jobs_lock:
current = analysis_jobs.get(job_id, {}).copy()
def _set_task_state(task_id: str, **updates) -> dict:
with analysis_tasks_lock:
current = analysis_tasks.get(task_id, {}).copy()
current.update(updates)
current["updated_at"] = _utc_now()
analysis_jobs[job_id] = current
analysis_tasks[task_id] = current
return current.copy()


def _create_analysis_job(vacancy_id: int) -> dict:
job_id = str(uuid4())
def _create_analysis_task(vacancy_id: int) -> dict:
task_id = str(uuid4())
now = _utc_now()
job = {
"job_id": job_id,
task = {
"task_id": task_id,
"vacancy_id": vacancy_id,
"status": "queued",
"step": "saved",
Expand All @@ -124,14 +130,14 @@ def _create_analysis_job(vacancy_id: int) -> dict:
"created_at": now,
"updated_at": now,
}
with analysis_jobs_lock:
analysis_jobs[job_id] = job
return job.copy()
with analysis_tasks_lock:
analysis_tasks[task_id] = task
return task.copy()


def _run_analysis_job(job_id: str, vacancy_id: int) -> None:
_set_job_state(
job_id,
def _run_analysis_task(task_id: str, vacancy_id: int) -> None:
_set_task_state(
task_id,
status="running",
step="analyzing",
message="Analizando vacante contra el perfil activo.",
Expand All @@ -140,25 +146,25 @@ def _run_analysis_job(job_id: str, vacancy_id: int) -> None:
try:
result = analyze_vacancy_use_case.execute(vacancy_id)
if result["omitido"]:
_set_job_state(
job_id,
_set_task_state(
task_id,
status="completed",
step="completed",
message="Vacante guardada. Analisis omitido porque no hay perfil activo.",
error=None,
)
return
_set_job_state(
job_id,
_set_task_state(
task_id,
status="completed",
step="completed",
message="Vacante guardada y analizada correctamente.",
error=None,
)
except AnalysisError as exc:
logger.warning("La vacante %s se guardo, pero el analisis automatico fallo: %s", vacancy_id, exc)
_set_job_state(
job_id,
_set_task_state(
task_id,
status="failed",
step="failed",
message="La vacante se guardo, pero el analisis fallo.",
Expand Down Expand Up @@ -257,25 +263,30 @@ def crear_vacante_async(payload: VacantePayload, background_tasks: BackgroundTas
if not resultado["success"] or not resultado.get("id"):
raise HTTPException(status_code=500, detail=resultado["message"])

job = _create_analysis_job(resultado["id"])
background_tasks.add_task(_run_analysis_job, job["job_id"], resultado["id"])
task = _create_analysis_task(resultado["id"])
background_tasks.add_task(_run_analysis_task, task["task_id"], resultado["id"])

return AsyncVacanteResponse(
success=True,
message="Vacante guardada. Analisis en segundo plano iniciado.",
id=resultado["id"],
job_id=job["job_id"],
status=job["status"],
task_id=task["task_id"],
status=task["status"],
)


@app.get("/vacantes/jobs/{job_id}", response_model=AnalysisJobResponse)
def get_analysis_job(job_id: str):
with analysis_jobs_lock:
job = analysis_jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job no encontrado")
return AnalysisJobResponse(**job)
@app.get("/vacantes/tasks/{task_id}", response_model=AnalysisTaskResponse)
def get_analysis_task(task_id: str):
with analysis_tasks_lock:
task = analysis_tasks.get(task_id)
if not task:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
return AnalysisTaskResponse(**task)


@app.get("/vacantes/jobs/{task_id}", response_model=AnalysisTaskResponse)
def get_analysis_job_legacy(task_id: str):
return get_analysis_task(task_id)


@app.get("/vacantes/{vacancy_id}/analysis", response_model=VacancyAnalysisResponse)
Expand All @@ -284,3 +295,13 @@ def get_vacancy_analysis(vacancy_id: int):
if not analysis:
raise HTTPException(status_code=404, detail="Analisis no encontrado")
return VacancyAnalysisResponse(vacancy_id=vacancy_id, analysis=analysis)


@app.get("/vacantes/by-link", response_model=VacancyByLinkResponse)
def get_vacancy_by_link(link: str = Query(..., min_length=1)):
vacancy = vacancy_repository.get_by_link(link)
if not vacancy:
return VacancyByLinkResponse(found=False, vacancy=None, analysis=None)

analysis = analysis_repository.get_by_vacancy_id(vacancy["id"])
return VacancyByLinkResponse(found=True, vacancy=vacancy, analysis=analysis)
4 changes: 2 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@


def main() -> int:
print("La interfaz principal ahora es la web en http://127.0.0.1:8000/app")
print("Ejecuta: uvicorn api:app --reload")
print("La interfaz principal ahora es la web en http://127.0.0.1:8001/app")
print("Ejecuta: uvicorn api:app --reload --port 8001")
return 0


Expand Down
12 changes: 10 additions & 2 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@
load_dotenv()


def _optional_env(key: str) -> str | None:
value = os.getenv(key)
if value is None:
return None
value = value.strip()
return value or None


@dataclass(frozen=True)
class Settings:
db_server: str = os.getenv("DB_SERVER", "localhost\\MSSQLSERVER2025")
db_database: str = os.getenv("DB_DATABASE", "job_postings_mvp")
db_user: str = os.getenv("DB_USER", "sa")
db_password: str = os.getenv("DB_PASSWORD", "Micontraseña")
db_user: str | None = _optional_env("DB_USER")
db_password: str | None = _optional_env("DB_PASSWORD")


settings = Settings()
Loading
Loading