Skip to content

Commit c444501

Browse files
committed
feat(exams): endpoints operativos /v1/me/analytics y /v1/me/readiness desde Postgres
1 parent 35b5d62 commit c444501

5 files changed

Lines changed: 299 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package exams
2+
3+
// CeldaAgg agrega los intentos de un usuario por celda (tema, dificultad) dentro
4+
// de una certificación. Es la base operativa de la analítica por-usuario: se
5+
// calcula en vivo desde Postgres (sin OLAP).
6+
type CeldaAgg struct {
7+
Tema string
8+
Dificultad string
9+
Aciertos int
10+
Total int
11+
}
12+
13+
// PuntoFecha agrega los intentos de un usuario por día (tendencia diaria).
14+
type PuntoFecha struct {
15+
Fecha string // YYYY-MM-DD
16+
Aciertos int
17+
Total int
18+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package httpapi
2+
3+
import (
4+
"math"
5+
"net/http"
6+
"strings"
7+
8+
"github.com/certready/certready/libs/platform/auth"
9+
"github.com/certready/certready/libs/platform/httpx"
10+
)
11+
12+
// Estos handlers sustituyen a los antiguos endpoints por-usuario del DSS
13+
// (/v1/analytics, /v1/readiness sobre ClickHouse): la analítica del estudiante es
14+
// OPERATIVA (tiempo real, desde Postgres), no analítica por lotes. El JSON de
15+
// respuesta replica el del DSS para que web y móvil no cambien de contrato.
16+
17+
type temaAcierto struct {
18+
Tema string `json:"tema"`
19+
Aciertos int `json:"aciertos"`
20+
Total int `json:"total"`
21+
Pct float64 `json:"pct"`
22+
}
23+
24+
type puntoTendencia struct {
25+
Fecha string `json:"fecha"`
26+
Pct float64 `json:"pct"`
27+
Intentos int `json:"intentos"`
28+
}
29+
30+
type analiticaResponse struct {
31+
Certificacion string `json:"certificacion"`
32+
Total int `json:"total"`
33+
Aciertos int `json:"aciertos"`
34+
Pct float64 `json:"pct"`
35+
PorTema []temaAcierto `json:"por_tema"`
36+
Tendencia []puntoTendencia `json:"tendencia"`
37+
}
38+
39+
type celdaDominio struct {
40+
Tema string `json:"tema"`
41+
Dificultad string `json:"dificultad"`
42+
DominioPct float64 `json:"dominio_pct"`
43+
Intentos int `json:"intentos"`
44+
}
45+
46+
type siguienteAccion struct {
47+
Tema string `json:"tema"`
48+
Dificultad string `json:"dificultad"`
49+
Motivo string `json:"motivo"`
50+
}
51+
52+
type readinessResponse struct {
53+
UsuarioID string `json:"usuario_id"`
54+
Certificacion string `json:"certificacion"`
55+
ReadinessPct float64 `json:"readiness_pct"`
56+
ProbabilidadAprobar float64 `json:"probabilidad_aprobar"`
57+
HabilidadTheta float64 `json:"habilidad_theta"`
58+
PorCelda []celdaDominio `json:"por_celda"`
59+
SiguienteAccion *siguienteAccion `json:"siguiente_accion"`
60+
}
61+
62+
// pct1 devuelve el porcentaje a/n en [0,100] con un decimal (0 si n == 0).
63+
func pct1(a, n int) float64 {
64+
if n == 0 {
65+
return 0
66+
}
67+
return math.Round(1000.0*float64(a)/float64(n)) / 10.0
68+
}
69+
70+
// certParam lee y valida el parámetro de certificación (query).
71+
func certParam(w http.ResponseWriter, r *http.Request) (string, bool) {
72+
cert := strings.TrimSpace(r.URL.Query().Get("certificacion"))
73+
if cert == "" {
74+
httpx.WriteError(w, http.StatusBadRequest, "parametros_invalidos", "certificacion es requerida")
75+
return "", false
76+
}
77+
return cert, true
78+
}
79+
80+
// analitica devuelve el acierto por tema y la tendencia diaria del usuario en una
81+
// certificación (para los dashboards). Sin intentos: listas vacías (200).
82+
func (a *API) analitica(w http.ResponseWriter, r *http.Request) {
83+
ident, ok := auth.IdentityFromContext(r.Context())
84+
if !ok {
85+
httpx.WriteError(w, http.StatusUnauthorized, "no_autenticado", "se requiere autenticación")
86+
return
87+
}
88+
cert, ok := certParam(w, r)
89+
if !ok {
90+
return
91+
}
92+
93+
celdas, err := a.sesiones.AgregadoPorCelda(r.Context(), ident.Subject, cert)
94+
if err != nil {
95+
a.errorInterno(w, r, "agregado por celda", err)
96+
return
97+
}
98+
serie, err := a.sesiones.AgregadoPorFecha(r.Context(), ident.Subject, cert)
99+
if err != nil {
100+
a.errorInterno(w, r, "agregado por fecha", err)
101+
return
102+
}
103+
104+
// Agregamos celdas (tema, dificultad) a nivel de tema, preservando el orden.
105+
idx := make(map[string]int, len(celdas))
106+
porTema := make([]temaAcierto, 0, len(celdas))
107+
totA, totN := 0, 0
108+
for _, c := range celdas {
109+
i, exists := idx[c.Tema]
110+
if !exists {
111+
i = len(porTema)
112+
idx[c.Tema] = i
113+
porTema = append(porTema, temaAcierto{Tema: c.Tema})
114+
}
115+
porTema[i].Aciertos += c.Aciertos
116+
porTema[i].Total += c.Total
117+
totA += c.Aciertos
118+
totN += c.Total
119+
}
120+
for i := range porTema {
121+
porTema[i].Pct = pct1(porTema[i].Aciertos, porTema[i].Total)
122+
}
123+
124+
tendencia := make([]puntoTendencia, 0, len(serie))
125+
for _, p := range serie {
126+
tendencia = append(tendencia, puntoTendencia{Fecha: p.Fecha, Pct: pct1(p.Aciertos, p.Total), Intentos: p.Total})
127+
}
128+
129+
httpx.WriteJSON(w, http.StatusOK, analiticaResponse{
130+
Certificacion: cert,
131+
Total: totN,
132+
Aciertos: totA,
133+
Pct: pct1(totA, totN),
134+
PorTema: porTema,
135+
Tendencia: tendencia,
136+
})
137+
}
138+
139+
// readiness estima la preparación operativa del usuario en una certificación: el
140+
// acierto global, el dominio por celda y la siguiente acción (la celda con menor
141+
// acierto). A diferencia del DSS (IRT por lotes), es una métrica directa y fresca.
142+
// Returns 404 si el usuario no tiene intentos en la certificación.
143+
func (a *API) readiness(w http.ResponseWriter, r *http.Request) {
144+
ident, ok := auth.IdentityFromContext(r.Context())
145+
if !ok {
146+
httpx.WriteError(w, http.StatusUnauthorized, "no_autenticado", "se requiere autenticación")
147+
return
148+
}
149+
cert, ok := certParam(w, r)
150+
if !ok {
151+
return
152+
}
153+
154+
celdas, err := a.sesiones.AgregadoPorCelda(r.Context(), ident.Subject, cert)
155+
if err != nil {
156+
a.errorInterno(w, r, "agregado por celda", err)
157+
return
158+
}
159+
160+
totA, totN := 0, 0
161+
porCelda := make([]celdaDominio, 0, len(celdas))
162+
for _, c := range celdas {
163+
porCelda = append(porCelda, celdaDominio{
164+
Tema: c.Tema, Dificultad: c.Dificultad,
165+
DominioPct: pct1(c.Aciertos, c.Total), Intentos: c.Total,
166+
})
167+
totA += c.Aciertos
168+
totN += c.Total
169+
}
170+
if totN == 0 {
171+
httpx.WriteError(w, http.StatusNotFound, "sin_datos", "sin intentos suficientes para la certificación")
172+
return
173+
}
174+
175+
// Siguiente acción: la celda con menor dominio (desempate: más intentos).
176+
peor := -1
177+
for i := range porCelda {
178+
if porCelda[i].Intentos == 0 {
179+
continue
180+
}
181+
if peor == -1 ||
182+
porCelda[i].DominioPct < porCelda[peor].DominioPct ||
183+
(porCelda[i].DominioPct == porCelda[peor].DominioPct && porCelda[i].Intentos > porCelda[peor].Intentos) {
184+
peor = i
185+
}
186+
}
187+
var accion *siguienteAccion
188+
if peor != -1 {
189+
accion = &siguienteAccion{
190+
Tema: porCelda[peor].Tema, Dificultad: porCelda[peor].Dificultad,
191+
Motivo: "es el tema con menor acierto; reforzarlo sube más tu preparación",
192+
}
193+
}
194+
195+
readinessPct := pct1(totA, totN)
196+
httpx.WriteJSON(w, http.StatusOK, readinessResponse{
197+
UsuarioID: ident.Subject,
198+
Certificacion: cert,
199+
ReadinessPct: readinessPct,
200+
ProbabilidadAprobar: readinessPct / 100.0,
201+
HabilidadTheta: 0,
202+
PorCelda: porCelda,
203+
SiguienteAccion: accion,
204+
})
205+
}

services/exams/internal/httpapi/router.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type Options struct {
4545
// POST /v1/exams/sessions/{id}/submit entregar y calificar (auth, propia)
4646
// GET /v1/exams/sessions/{id} consultar/repasar (auth, propia)
4747
// GET /v1/me/exams listar mis sesiones (auth)
48+
// GET /v1/me/analytics acierto por tema + tendencia (auth)
49+
// GET /v1/me/readiness preparación operativa por cert (auth)
4850
// POST /v1/questions crear pregunta (admin)
4951
func NewRouter(opts Options) http.Handler {
5052
api := &API{
@@ -71,6 +73,8 @@ func NewRouter(opts Options) http.Handler {
7173
mux.Handle("POST /v1/exams/sessions/{id}/submit", authGate(opts.Auth, rls(http.HandlerFunc(api.enviar))))
7274
mux.Handle("GET /v1/exams/sessions/{id}", authGate(opts.Auth, rls(http.HandlerFunc(api.obtenerSesion))))
7375
mux.Handle("GET /v1/me/exams", authGate(opts.Auth, rls(http.HandlerFunc(api.listarMias))))
76+
mux.Handle("GET /v1/me/analytics", authGate(opts.Auth, rls(http.HandlerFunc(api.analitica))))
77+
mux.Handle("GET /v1/me/readiness", authGate(opts.Auth, rls(http.HandlerFunc(api.readiness))))
7478
mux.Handle("POST /v1/questions", adminGate(opts.Auth, http.HandlerFunc(api.crearPregunta)))
7579

7680
return httpx.Chain(mux,

services/exams/internal/httpapi/store.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ type SesionesStore interface {
2222
ListarSesiones(ctx context.Context, usuarioID string, limit, offset int) ([]exams.Sesion, error)
2323
ObtenerIntentos(ctx context.Context, sesionID string) ([]exams.Intento, error)
2424
Finalizar(ctx context.Context, usuarioID, id string, puntaje float64, intentos []exams.Intento) error
25+
AgregadoPorCelda(ctx context.Context, usuarioID, certificacion string) ([]exams.CeldaAgg, error)
26+
AgregadoPorFecha(ctx context.Context, usuarioID, certificacion string) ([]exams.PuntoFecha, error)
2527
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package store
2+
3+
import (
4+
"context"
5+
6+
"github.com/certready/certready/libs/platform/postgres"
7+
"github.com/certready/certready/services/exams/internal/exams"
8+
)
9+
10+
// AgregadoPorCelda devuelve los intentos del usuario por celda (tema, dificultad)
11+
// en una certificación, ya agregados (aciertos y total).
12+
//
13+
// Une intentos con su sesión para acotar por certificación (el tema/dificultad se
14+
// denormaliza en el intento; ver migración 0003). Es de solo lectura y honra la
15+
// transacción RLS de la petición vía postgres.Q.
16+
func (s *SesionesStore) AgregadoPorCelda(ctx context.Context, usuarioID, certificacion string) ([]exams.CeldaAgg, error) {
17+
rows, err := postgres.Q(ctx, s.pool).Query(ctx,
18+
`select i.tema, i.dificultad,
19+
sum(case when i.correcto then 1 else 0 end)::int as aciertos,
20+
count(*)::int as total
21+
from exams.intentos i
22+
join exams.sesiones s on s.id = i.sesion_id
23+
where i.usuario_id::text = $1 and s.certificacion = $2
24+
group by i.tema, i.dificultad
25+
order by i.tema, i.dificultad`,
26+
usuarioID, certificacion)
27+
if err != nil {
28+
return nil, err
29+
}
30+
defer rows.Close()
31+
32+
var out []exams.CeldaAgg
33+
for rows.Next() {
34+
var c exams.CeldaAgg
35+
if err := rows.Scan(&c.Tema, &c.Dificultad, &c.Aciertos, &c.Total); err != nil {
36+
return nil, err
37+
}
38+
out = append(out, c)
39+
}
40+
return out, rows.Err()
41+
}
42+
43+
// AgregadoPorFecha devuelve la tendencia diaria del usuario en una certificación:
44+
// aciertos y total por día (UTC), ordenado por fecha ascendente.
45+
func (s *SesionesStore) AgregadoPorFecha(ctx context.Context, usuarioID, certificacion string) ([]exams.PuntoFecha, error) {
46+
rows, err := postgres.Q(ctx, s.pool).Query(ctx,
47+
`select to_char(i.creado_en::date, 'YYYY-MM-DD') as fecha,
48+
sum(case when i.correcto then 1 else 0 end)::int as aciertos,
49+
count(*)::int as total
50+
from exams.intentos i
51+
join exams.sesiones s on s.id = i.sesion_id
52+
where i.usuario_id::text = $1 and s.certificacion = $2
53+
group by i.creado_en::date
54+
order by i.creado_en::date`,
55+
usuarioID, certificacion)
56+
if err != nil {
57+
return nil, err
58+
}
59+
defer rows.Close()
60+
61+
var out []exams.PuntoFecha
62+
for rows.Next() {
63+
var p exams.PuntoFecha
64+
if err := rows.Scan(&p.Fecha, &p.Aciertos, &p.Total); err != nil {
65+
return nil, err
66+
}
67+
out = append(out, p)
68+
}
69+
return out, rows.Err()
70+
}

0 commit comments

Comments
 (0)