feat: alertas de picos, grafica de menciones por dia y estabilidad del traductor
- Backend: endpoint /api/entities/mentions (series por dia para N conceptos) - Backend: tabla alertas + escaner de picos automatico + endpoints (list/read/scan) - Frontend: pagina Evolucion con grafica SVG multi-concepto configurable - Frontend: pagina Alertas con badge de notificaciones en la barra - Traductor: memoria acotada (sub-lotes, batch 32, cuerpo max 12000 chars) - Traductor: prioridad baja en CPU (cpu_shares 512) y tope de 2 nucleos x worker - Escalado a 3 workers de traduccion (2x translator + translator-gpu)
This commit is contained in:
parent
eff656adea
commit
3991237685
11 changed files with 988 additions and 28 deletions
|
|
@ -76,6 +76,27 @@ func initDB() {
|
||||||
ON CONFLICT (key) DO NOTHING
|
ON CONFLICT (key) DO NOTHING
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Crear tabla de alertas si no existe
|
||||||
|
_, err = db.GetPool().Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS alertas (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
valor VARCHAR(255) NOT NULL,
|
||||||
|
tipo VARCHAR(32) NOT NULL,
|
||||||
|
periodo DATE NOT NULL,
|
||||||
|
hits INT NOT NULL,
|
||||||
|
baseline DOUBLE PRECISION NOT NULL,
|
||||||
|
ratio DOUBLE PRECISION NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'nueva',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(valor, tipo, periodo)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Could not create alertas table: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Table alertas ready")
|
||||||
|
}
|
||||||
|
|
||||||
// Crear tabla de remote_workers si no existe
|
// Crear tabla de remote_workers si no existe
|
||||||
_, err = db.GetPool().Exec(ctx, `
|
_, err = db.GetPool().Exec(ctx, `
|
||||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
CREATE TABLE IF NOT EXISTS remote_workers (
|
||||||
|
|
@ -175,6 +196,11 @@ func main() {
|
||||||
|
|
||||||
api.GET("/entities", handlers.GetEntities)
|
api.GET("/entities", handlers.GetEntities)
|
||||||
api.GET("/entities/news", handlers.GetEntityNews)
|
api.GET("/entities/news", handlers.GetEntityNews)
|
||||||
|
api.GET("/entities/mentions", handlers.GetEntityMentions)
|
||||||
|
|
||||||
|
api.GET("/alerts", handlers.GetAlertas)
|
||||||
|
api.POST("/alerts/:id/read", middleware.AuthRequired(), handlers.MarkAlertaRead)
|
||||||
|
api.POST("/alerts/read-all", middleware.AuthRequired(), handlers.MarkAllAlertasRead)
|
||||||
|
|
||||||
api.GET("/stats", handlers.GetStats)
|
api.GET("/stats", handlers.GetStats)
|
||||||
|
|
||||||
|
|
@ -195,6 +221,7 @@ func main() {
|
||||||
admin.POST("/users/:id/promote", handlers.PromoteUser)
|
admin.POST("/users/:id/promote", handlers.PromoteUser)
|
||||||
admin.POST("/users/:id/demote", handlers.DemoteUser)
|
admin.POST("/users/:id/demote", handlers.DemoteUser)
|
||||||
admin.POST("/reset-db", handlers.ResetDatabase)
|
admin.POST("/reset-db", handlers.ResetDatabase)
|
||||||
|
admin.POST("/alerts/scan", handlers.ScanAlertasAdmin)
|
||||||
admin.GET("/workers/status", handlers.GetWorkerStatus)
|
admin.GET("/workers/status", handlers.GetWorkerStatus)
|
||||||
admin.GET("/workers/stats", handlers.GetTranslationStats)
|
admin.GET("/workers/stats", handlers.GetTranslationStats)
|
||||||
admin.POST("/workers/config", handlers.SetWorkerConfig)
|
admin.POST("/workers/config", handlers.SetWorkerConfig)
|
||||||
|
|
@ -231,6 +258,7 @@ func main() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
handlers.StartJobAssigner()
|
handlers.StartJobAssigner()
|
||||||
|
handlers.StartAlertScanner()
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
|
||||||
217
backend/internal/handlers/alerts.go
Normal file
217
backend/internal/handlers/alerts.go
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rss2/backend/internal/db"
|
||||||
|
"github.com/rss2/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Alerta struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Valor string `json:"valor"`
|
||||||
|
Tipo string `json:"tipo"`
|
||||||
|
Periodo string `json:"periodo"`
|
||||||
|
Hits int `json:"hits"`
|
||||||
|
Baseline float64 `json:"baseline"`
|
||||||
|
Ratio float64 `json:"ratio"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAlertas(c *gin.Context) {
|
||||||
|
status := c.Query("status")
|
||||||
|
limitStr := c.DefaultQuery("limit", "100")
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
|
if limit < 1 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, valor, tipo, periodo::text, hits, baseline, ratio, status, to_char(created_at, 'YYYY-MM-DD HH24:MI') AS created_at
|
||||||
|
FROM alertas
|
||||||
|
`
|
||||||
|
args := []interface{}{}
|
||||||
|
if status != "" {
|
||||||
|
query += fmt.Sprintf(" WHERE status = $%d", len(args)+1)
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf(" ORDER BY periodo DESC, ratio DESC, id DESC LIMIT $%d", len(args)+1)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get alerts", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
alertas := []Alerta{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a Alerta
|
||||||
|
if err := rows.Scan(&a.ID, &a.Valor, &a.Tipo, &a.Periodo, &a.Hits, &a.Baseline, &a.Ratio, &a.Status, &a.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertas = append(alertas, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nuevas int
|
||||||
|
db.GetPool().QueryRow(c.Request.Context(), "SELECT COUNT(*)::int FROM alertas WHERE status = 'nueva'").Scan(&nuevas)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"alertas": alertas,
|
||||||
|
"total": len(alertas),
|
||||||
|
"nuevas": nuevas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkAlertaRead(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid alert id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE id = $1", id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alert", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkAllAlertasRead(c *gin.Context) {
|
||||||
|
_, err := db.GetPool().Exec(c.Request.Context(), "UPDATE alertas SET status = 'leida' WHERE status = 'nueva'")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to update alerts", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanAlertasAdmin(c *gin.Context) {
|
||||||
|
inserted, err := RunAlertScan(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Scan failed", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "new_alerts": inserted})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAlertScan busca conceptos (persona, lugar, organizacion, tema) cuya
|
||||||
|
// actividad de hoy supera significativamente su media de los últimos días.
|
||||||
|
func RunAlertScan(ctx context.Context) (int, error) {
|
||||||
|
minHits := 3
|
||||||
|
minRatio := 5.0
|
||||||
|
if v := os.Getenv("ALERTS_MIN_HITS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
minHits = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := os.Getenv("ALERTS_MIN_RATIO"); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
||||||
|
minRatio = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
WITH daily AS (
|
||||||
|
SELECT t.id AS tag_id, t.valor, t.tipo, n.fecha::date AS dia,
|
||||||
|
COUNT(DISTINCT n.id) AS cnt
|
||||||
|
FROM tags_noticia tn
|
||||||
|
JOIN tags t ON tn.tag_id = t.id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
JOIN noticias n ON tr.noticia_id = n.id
|
||||||
|
WHERE n.fecha >= CURRENT_DATE - 8
|
||||||
|
AND t.tipo IN ('persona', 'lugar', 'organizacion', 'tema')
|
||||||
|
GROUP BY t.id, t.valor, t.tipo, n.fecha::date
|
||||||
|
),
|
||||||
|
baseline AS (
|
||||||
|
SELECT tag_id, valor, tipo,
|
||||||
|
AVG(cnt)::float AS baseline,
|
||||||
|
MAX(cnt)::int AS max_prev
|
||||||
|
FROM daily
|
||||||
|
WHERE dia < CURRENT_DATE
|
||||||
|
GROUP BY tag_id, valor, tipo
|
||||||
|
),
|
||||||
|
today AS (
|
||||||
|
SELECT tag_id, valor, tipo, SUM(cnt)::int AS hits
|
||||||
|
FROM daily
|
||||||
|
WHERE dia = CURRENT_DATE
|
||||||
|
GROUP BY tag_id, valor, tipo
|
||||||
|
)
|
||||||
|
SELECT t.valor, t.tipo, t.hits, b.baseline,
|
||||||
|
(t.hits::float / NULLIF(b.baseline, 0)) AS ratio
|
||||||
|
FROM today t
|
||||||
|
JOIN baseline b USING (tag_id)
|
||||||
|
WHERE t.hits >= $1
|
||||||
|
AND b.baseline >= 0.5
|
||||||
|
AND (t.hits::float / NULLIF(b.baseline, 0)) >= $2
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := db.GetPool().Query(ctx, query, minHits, minRatio)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
inserted := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var valor, tipo string
|
||||||
|
var hits int
|
||||||
|
var baseline, ratio float64
|
||||||
|
if err := rows.Scan(&valor, &tipo, &hits, &baseline, &ratio); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, err := db.GetPool().Exec(ctx, `
|
||||||
|
INSERT INTO alertas (valor, tipo, periodo, hits, baseline, ratio)
|
||||||
|
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5)
|
||||||
|
ON CONFLICT (valor, tipo, periodo) DO NOTHING
|
||||||
|
`, valor, tipo, hits, baseline, ratio)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("alerts: insert failed for %s (%s): %v", valor, tipo, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := res.RowsAffected(); n > 0 {
|
||||||
|
inserted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAlertScanner lanza una goroutine que revisa picos de actividad
|
||||||
|
// periódicamente (cada ALERTS_SCAN_INTERVAL_MIN, por defecto 120 min).
|
||||||
|
func StartAlertScanner() {
|
||||||
|
interval := 120
|
||||||
|
if v := os.Getenv("ALERTS_SCAN_INTERVAL_MIN"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
interval = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(45 * time.Second)
|
||||||
|
for {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
inserted, err := RunAlertScan(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("alerts: scan error: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("alerts: scan finished, %d new alerts", inserted)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(interval) * time.Minute)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
@ -484,3 +486,140 @@ func GetEntities(c *gin.Context) {
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MentionPoint struct {
|
||||||
|
Fecha string `json:"fecha"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionSeries struct {
|
||||||
|
Valor string `json:"valor"`
|
||||||
|
Tipo string `json:"tipo"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Data []MentionPoint `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionsResponse struct {
|
||||||
|
Days int `json:"days"`
|
||||||
|
Series []MentionSeries `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetEntityMentions(c *gin.Context) {
|
||||||
|
valuesRaw := c.DefaultQuery("values", "")
|
||||||
|
days := 30
|
||||||
|
if v := c.Query("days"); v != "" {
|
||||||
|
if d, err := strconv.Atoi(v); err == nil && d >= 1 && d <= 365 {
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
for _, v := range strings.Split(valuesRaw, ",") {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v != "" {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "values is required (comma separated)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(values) > 20 {
|
||||||
|
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Too many values (max 20)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
series := make([]MentionSeries, 0, len(values))
|
||||||
|
for _, valor := range values {
|
||||||
|
s, err := buildMentionSeries(c.Request.Context(), valor, days)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get mentions", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := map[string]int{}
|
||||||
|
for _, p := range s.Data {
|
||||||
|
m[p.Fecha] = p.Count
|
||||||
|
}
|
||||||
|
data := make([]MentionPoint, 0, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
fecha := start.AddDate(0, 0, i).Format("2006-01-02")
|
||||||
|
data = append(data, MentionPoint{Fecha: fecha, Count: m[fecha]})
|
||||||
|
}
|
||||||
|
s.Data = data
|
||||||
|
s.Count = 0
|
||||||
|
for _, p := range s.Data {
|
||||||
|
s.Count += p.Count
|
||||||
|
}
|
||||||
|
if s.Tipo == "" {
|
||||||
|
s.Tipo = "desconocido"
|
||||||
|
}
|
||||||
|
series = append(series, *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, MentionsResponse{Days: days, Series: series})
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMentionSeries(ctx context.Context, valor string, days int) (*MentionSeries, error) {
|
||||||
|
s := &MentionSeries{Valor: valor}
|
||||||
|
|
||||||
|
var tagIDs []int64
|
||||||
|
rows, err := db.GetPool().Query(ctx, `
|
||||||
|
SELECT DISTINCT t.id, t.tipo
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN entity_aliases ea ON ea.tipo = t.tipo AND LOWER(ea.alias) = LOWER(t.valor)
|
||||||
|
WHERE LOWER(t.valor) = LOWER($1)
|
||||||
|
OR LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1)
|
||||||
|
`, valor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var tipo string
|
||||||
|
if err := rows.Scan(&id, &tipo); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tagIDs = append(tagIDs, id)
|
||||||
|
if s.Tipo == "" {
|
||||||
|
s.Tipo = tipo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if len(tagIDs) == 0 {
|
||||||
|
s.Data = []MentionPoint{}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
drows, err := db.GetPool().Query(ctx, `
|
||||||
|
SELECT n.fecha::date::text AS dia, COUNT(DISTINCT n.id) AS cnt
|
||||||
|
FROM tags_noticia tn
|
||||||
|
JOIN tags t ON tn.tag_id = t.id
|
||||||
|
JOIN traducciones tr ON tn.traduccion_id = tr.id
|
||||||
|
JOIN noticias n ON tr.noticia_id = n.id
|
||||||
|
WHERE t.id = ANY($1) AND n.fecha >= CURRENT_DATE - ($2::int - 1)
|
||||||
|
GROUP BY n.fecha::date
|
||||||
|
ORDER BY n.fecha::date
|
||||||
|
`, tagIDs, days)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer drows.Close()
|
||||||
|
|
||||||
|
for drows.Next() {
|
||||||
|
var fecha string
|
||||||
|
var cnt int
|
||||||
|
if err := drows.Scan(&fecha, &cnt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Data = append(s.Data, MentionPoint{Fecha: fecha, Count: cnt})
|
||||||
|
}
|
||||||
|
if s.Data == nil {
|
||||||
|
s.Data = []MentionPoint{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,14 @@ services:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Prioridad BAJA: el traductor no debe acaparar toda la CPU.
|
||||||
|
# cpu_shares menor que el resto (1024) + tope de 2 núcleos dejan CPU para NER, embeddings, etc.
|
||||||
|
cpu_shares: 512
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 6G
|
||||||
|
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
# TRANSLATOR CPU Nº2 - Segundo worker CPU (paralelo al translator)
|
# TRANSLATOR CPU Nº2 - Segundo worker CPU (paralelo al translator)
|
||||||
|
|
@ -377,6 +385,13 @@ services:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Prioridad BAJA: mismo trato que el translator.
|
||||||
|
cpu_shares: 512
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 6G
|
||||||
|
|
||||||
# ==================================================================================
|
# ==================================================================================
|
||||||
# TRANSLATION SCHEDULER - Creates translation jobs
|
# TRANSLATION SCHEDULER - Creates translation jobs
|
||||||
|
|
@ -440,6 +455,7 @@ services:
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
cpus: '2'
|
||||||
memory: 6G
|
memory: 6G
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import { Stats } from './pages/Stats'
|
||||||
import { Favorites } from './pages/Favorites'
|
import { Favorites } from './pages/Favorites'
|
||||||
import { Account } from './pages/Account'
|
import { Account } from './pages/Account'
|
||||||
import { Populares } from './pages/Populares'
|
import { Populares } from './pages/Populares'
|
||||||
|
import { Analisis } from './pages/Analisis'
|
||||||
|
import { Alertas } from './pages/Alertas'
|
||||||
import { AdminAliases } from './pages/AdminAliases'
|
import { AdminAliases } from './pages/AdminAliases'
|
||||||
import { AdminUsers } from './pages/AdminUsers'
|
import { AdminUsers } from './pages/AdminUsers'
|
||||||
import { AdminSettings } from './pages/AdminSettings'
|
import { AdminSettings } from './pages/AdminSettings'
|
||||||
|
|
@ -62,6 +64,8 @@ function App() {
|
||||||
<Route path="search" element={<Search />} />
|
<Route path="search" element={<Search />} />
|
||||||
<Route path="populares" element={<Populares />} />
|
<Route path="populares" element={<Populares />} />
|
||||||
<Route path="stats" element={<Stats />} />
|
<Route path="stats" element={<Stats />} />
|
||||||
|
<Route path="analisis" element={<Analisis />} />
|
||||||
|
<Route path="alertas" element={<Alertas />} />
|
||||||
<Route path="favorites" element={<Favorites />} />
|
<Route path="favorites" element={<Favorites />} />
|
||||||
<Route path="account" element={<Account />} />
|
<Route path="account" element={<Account />} />
|
||||||
<Route path="login" element={<Login />} />
|
<Route path="login" element={<Login />} />
|
||||||
|
|
|
||||||
146
frontend/src/components/LineChart.tsx
Normal file
146
frontend/src/components/LineChart.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
const COLORS = ['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d']
|
||||||
|
|
||||||
|
export interface ChartSeries {
|
||||||
|
valor: string
|
||||||
|
tipo?: string
|
||||||
|
count?: number
|
||||||
|
data: { fecha: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
series: ChartSeries[]
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineChart({ series, height = 320 }: Props) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null)
|
||||||
|
const width = 1000
|
||||||
|
const padL = 46
|
||||||
|
const padR = 14
|
||||||
|
const padT = 16
|
||||||
|
const padB = 34
|
||||||
|
const plotW = width - padL - padR
|
||||||
|
const plotH = height - padT - padB
|
||||||
|
|
||||||
|
const days = series[0]?.data?.length ?? 0
|
||||||
|
|
||||||
|
const max = useMemo(() => {
|
||||||
|
let m = 0
|
||||||
|
for (const s of series) for (const p of s.data) if (p.count > m) m = p.count
|
||||||
|
return Math.max(1, m)
|
||||||
|
}, [series])
|
||||||
|
|
||||||
|
const xAt = (i: number) => padL + (days <= 1 ? 0 : (i / (days - 1)) * plotW)
|
||||||
|
const yAt = (v: number) => padT + plotH - (v / max) * plotH
|
||||||
|
|
||||||
|
const paths = series.map((s, si) => {
|
||||||
|
const d = s.data
|
||||||
|
.map((p, i) => `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.count).toFixed(1)}`)
|
||||||
|
.join(' ')
|
||||||
|
return { d, color: COLORS[si % COLORS.length], s }
|
||||||
|
})
|
||||||
|
|
||||||
|
const yTicks = useMemo(() => {
|
||||||
|
const ticks = max <= 4 ? [0, max] : []
|
||||||
|
if (max <= 4) return ticks
|
||||||
|
const step = Math.pow(10, Math.floor(Math.log10(max / 4)))
|
||||||
|
const norm = Math.ceil(max / step / 4)
|
||||||
|
const nice = step * norm * 4
|
||||||
|
const out: number[] = []
|
||||||
|
for (let v = 0; v <= nice; v += step * norm) out.push(v)
|
||||||
|
return out
|
||||||
|
}, [max])
|
||||||
|
|
||||||
|
const xLabelStep = days > 12 ? Math.ceil(days / 8) : 1
|
||||||
|
const xLabels: { i: number; label: string }[] = []
|
||||||
|
for (let i = 0; i < days; i += xLabelStep) {
|
||||||
|
if (days > 0 && i % xLabelStep === 0) {
|
||||||
|
xLabels.push({ i, label: series[0]?.data[i]?.fecha?.slice(5) || '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (days > 0 && xLabels[xLabels.length - 1]?.i !== days - 1) {
|
||||||
|
xLabels.push({ i: days - 1, label: series[0]?.data[days - 1]?.fecha?.slice(5) || '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (days === 0) return
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * width
|
||||||
|
const idx = Math.max(0, Math.min(days - 1, Math.round(((x - padL) / plotW) * (days - 1))))
|
||||||
|
setHover(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverX = hover !== null ? xAt(hover) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div className="flex flex-wrap gap-4 mb-3">
|
||||||
|
{paths.map(({ color, s }) => (
|
||||||
|
<span key={s.valor + color} className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full inline-block" style={{ backgroundColor: color }} />
|
||||||
|
<span className="font-medium">{s.valor}</span>
|
||||||
|
<span className="text-gray-400">({s.count ?? 0})</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full min-w-[560px]"
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{yTicks.map((t) => (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={padL} x2={width - padR} y1={yAt(t)} y2={yAt(t)} stroke="#e5e7eb" strokeWidth={1} />
|
||||||
|
<text x={padL - 8} y={yAt(t) + 4} textAnchor="end" fontSize={11} fill="#9ca3af">
|
||||||
|
{t}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{xLabels.map(({ i, label }) => (
|
||||||
|
<text key={i} x={xAt(i)} y={height - 12} textAnchor="middle" fontSize={11} fill="#9ca3af">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{paths.map(({ d, color }) => (
|
||||||
|
<path key={color} d={d} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
))}
|
||||||
|
{paths.map(({ color, s }, si) =>
|
||||||
|
s.data.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={`${si}-${i}`}
|
||||||
|
cx={xAt(i)}
|
||||||
|
cy={yAt(p.count)}
|
||||||
|
r={hover === i ? 4.5 : 2.5}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{hover !== null && (
|
||||||
|
<g>
|
||||||
|
<line x1={hoverX} x2={hoverX} y1={padT} y2={height - padB} stroke="#cbd5e1" strokeWidth={1} strokeDasharray="3 3" />
|
||||||
|
{paths.map(({ color, s }) => (
|
||||||
|
<circle key={color + hover} cx={hoverX} cy={yAt(s.data[hover]?.count ?? 0)} r={5} fill={color} stroke="#fff" strokeWidth={2} />
|
||||||
|
))}
|
||||||
|
<g transform={`translate(${Math.min(hoverX + 12, width - 220)}, ${padT})`}>
|
||||||
|
<rect width={208} height={20 + paths.length * 18} rx={6} fill="#111827" opacity={0.92} />
|
||||||
|
<text x={10} y={16} fontSize={11} fill="#e5e7eb" fontWeight={600}>
|
||||||
|
{series[0]?.data[hover]?.fecha || ''}
|
||||||
|
</text>
|
||||||
|
{paths.map(({ color, s }, si) => (
|
||||||
|
<g key={color}>
|
||||||
|
<rect x={10} y={26 + si * 18} width={10} height={10} rx={2} fill={color} />
|
||||||
|
<text x={26} y={35 + si * 18} fontSize={11} fill="#d1d5db">
|
||||||
|
{s.valor}: {s.data[hover]?.count ?? 0}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||||
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server } from 'lucide-react'
|
import { Search, Rss, BarChart3, Home as HomeIcon, Heart, User, Flame, Settings, Users, Tags, Database, Server, TrendingUp, Bell } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { apiService } from '../../services/api'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -8,6 +9,7 @@ export function Layout() {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
const [showAdminMenu, setShowAdminMenu] = useState(false)
|
const [showAdminMenu, setShowAdminMenu] = useState(false)
|
||||||
|
const [nuevasAlertas, setNuevasAlertas] = useState(0)
|
||||||
|
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
@ -37,6 +39,20 @@ export function Layout() {
|
||||||
return () => window.removeEventListener('storage', checkAuth)
|
return () => window.removeEventListener('storage', checkAuth)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAlertas = async () => {
|
||||||
|
try {
|
||||||
|
const r = await apiService.getAlertas({ status: 'nueva', limit: 1 })
|
||||||
|
setNuevasAlertas(r.nuevas)
|
||||||
|
} catch {
|
||||||
|
setNuevasAlertas(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadAlertas()
|
||||||
|
const id = setInterval(loadAlertas, 60000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
|
|
@ -72,6 +88,19 @@ export function Layout() {
|
||||||
<Flame className="h-4 w-4" />
|
<Flame className="h-4 w-4" />
|
||||||
Popular
|
Popular
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/analisis" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Evolución
|
||||||
|
</Link>
|
||||||
|
<Link to="/alertas" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors relative">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
Alertas
|
||||||
|
{nuevasAlertas > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-3 bg-red-600 text-white text-[10px] font-bold min-w-[16px] h-4 px-1 rounded-full flex items-center justify-center">
|
||||||
|
{nuevasAlertas > 99 ? '99+' : nuevasAlertas}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
<Link to="/stats" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
|
<Link to="/stats" className="flex items-center gap-2 text-gray-600 hover:text-primary-600 transition-colors">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Stats
|
Stats
|
||||||
|
|
|
||||||
137
frontend/src/pages/Alertas.tsx
Normal file
137
frontend/src/pages/Alertas.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { apiService, Alerta } from '../services/api'
|
||||||
|
import { Bell, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
const TIPO_LABEL: Record<string, string> = {
|
||||||
|
persona: 'Persona',
|
||||||
|
lugar: 'Lugar',
|
||||||
|
organizacion: 'Organización',
|
||||||
|
tema: 'Tema',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alertas() {
|
||||||
|
const [alertas, setAlertas] = useState<Alerta[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [nuevas, setNuevas] = useState(0)
|
||||||
|
const [limit, setLimit] = useState(50)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await apiService.getAlertas({ limit })
|
||||||
|
setAlertas(r.alertas)
|
||||||
|
setNuevas(r.nuevas)
|
||||||
|
} catch {
|
||||||
|
setAlertas([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [limit])
|
||||||
|
|
||||||
|
const markRead = async (id: number) => {
|
||||||
|
await apiService.markAlertaRead(id)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAll = async () => {
|
||||||
|
await apiService.markAllAlertasRead()
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Bell className="h-7 w-7 text-primary-600" />
|
||||||
|
Alertas de actividad
|
||||||
|
</h1>
|
||||||
|
<button onClick={markAll} className="btn-secondary flex items-center gap-2" disabled={nuevas === 0}>
|
||||||
|
<CheckCheck className="h-4 w-4" />
|
||||||
|
Marcar todas como leídas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 -mt-4 mb-6">
|
||||||
|
Se genera un aviso cuando un concepto supera con claridad su actividad media de días anteriores.
|
||||||
|
{nuevas > 0 && (
|
||||||
|
<span className="ml-1 text-primary-600 font-semibold">Tienes {nuevas} sin leer.</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="card p-16 flex justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : alertas.length === 0 ? (
|
||||||
|
<div className="card p-16 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
No hay alertas todavía. El sistema revisa cada dos horas los picos de actividad.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Concepto</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Tipo</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Fecha</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Noticias</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Media previa</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-white">Ratio</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Estado</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 dark:text-white">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{alertas.map((a) => (
|
||||||
|
<tr key={a.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{a.valor}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
{TIPO_LABEL[a.tipo] || a.tipo}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{a.periodo}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-gray-900 dark:text-white">{a.hits}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-sm text-gray-500 dark:text-gray-400">{a.baseline.toFixed(1)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<span className={`text-sm font-semibold ${a.ratio >= 10 ? 'text-red-600' : 'text-orange-600'}`}>
|
||||||
|
×{a.ratio.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${a.status === 'nueva' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'}`}>
|
||||||
|
{a.status === 'nueva' ? 'Nueva' : 'Leída'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{a.status === 'nueva' ? (
|
||||||
|
<button onClick={() => markRead(a.id)} className="btn-secondary text-xs py-1 px-3">
|
||||||
|
Marcar leída
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-300 dark:text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setLimit(limit > 50 ? 50 : 200)}
|
||||||
|
className="btn-secondary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
{limit > 50 ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
Mostrar {limit > 50 ? 'menos' : 'más'} ({limit})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
frontend/src/pages/Analisis.tsx
Normal file
177
frontend/src/pages/Analisis.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { apiService, EntitySuggestion } from '../services/api'
|
||||||
|
import { LineChart } from '../components/LineChart'
|
||||||
|
import { X, Search, TrendingUp } from 'lucide-react'
|
||||||
|
|
||||||
|
const TIPOS = [
|
||||||
|
{ value: 'persona', label: 'Personas' },
|
||||||
|
{ value: 'lugar', label: 'Lugares' },
|
||||||
|
{ value: 'organizacion', label: 'Organizaciones' },
|
||||||
|
{ value: 'tema', label: 'Temas' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DIAS = [7, 15, 30, 60, 90]
|
||||||
|
|
||||||
|
interface Selected {
|
||||||
|
valor: string
|
||||||
|
tipo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Analisis() {
|
||||||
|
const [tipo, setTipo] = useState('persona')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<EntitySuggestion[]>([])
|
||||||
|
const [selected, setSelected] = useState<Selected[]>([])
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
const [series, setSeries] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showResults, setShowResults] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
apiService
|
||||||
|
.searchEntities({ tipo, q: query.trim(), per_page: 8 })
|
||||||
|
.then((r) => setResults(r.entities || []))
|
||||||
|
.catch(() => setResults([]))
|
||||||
|
}, 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query, tipo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
setSeries([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
apiService
|
||||||
|
.getEntityMentions({ values: selected.map((s) => s.valor), days })
|
||||||
|
.then((r) => setSeries(r.series))
|
||||||
|
.catch(() => setSeries([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [selected, days])
|
||||||
|
|
||||||
|
const add = (valor: string, t: string) => {
|
||||||
|
if (selected.some((s) => s.valor.toLowerCase() === valor.toLowerCase())) return
|
||||||
|
setSelected([...selected, { valor, tipo: t }])
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setShowResults(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (valor: string) => {
|
||||||
|
setSelected(selected.filter((s) => s.valor !== valor))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-7 w-7 text-primary-600" />
|
||||||
|
Evolución de menciones
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1 mb-6">
|
||||||
|
Selecciona personas, lugares, organizaciones o temas y compara su actividad día a día.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="card p-4 mb-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tipo</label>
|
||||||
|
<select value={tipo} onChange={(e) => setTipo(e.target.value)} className="input w-auto">
|
||||||
|
{TIPOS.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Buscar y añadir</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="h-4 w-4 absolute left-3 top-3 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setShowResults(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowResults(true)}
|
||||||
|
placeholder="Busca un concepto…"
|
||||||
|
className="input pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showResults && query.trim() && (
|
||||||
|
<ul className="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-72 overflow-auto">
|
||||||
|
{results.length === 0 && (
|
||||||
|
<li className="px-4 py-2 text-sm text-gray-500">Sin resultados</li>
|
||||||
|
)}
|
||||||
|
{results.map((r) => (
|
||||||
|
<li key={r.valor + r.tipo}>
|
||||||
|
<button
|
||||||
|
onClick={() => add(r.valor, r.tipo)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-gray-800 dark:text-gray-200">{r.valor}</span>
|
||||||
|
<span className="text-xs text-gray-400">{r.cnt} noticias</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ventana</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{DIAS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setDays(d)}
|
||||||
|
className={`px-3 py-2 rounded-md text-sm border transition-colors ${
|
||||||
|
days === d
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
{selected.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.valor}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-primary-50 dark:bg-gray-700 text-primary-800 dark:text-gray-200 text-sm px-3 py-1 rounded-full"
|
||||||
|
>
|
||||||
|
{s.valor}
|
||||||
|
<button onClick={() => remove(s.valor)} className="hover:text-red-600" title="Quitar">
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length === 0 ? (
|
||||||
|
<div className="card p-16 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
Añade al menos un concepto para ver su evolución por día.
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="card p-16 flex justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card p-4">
|
||||||
|
<LineChart series={series} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,41 @@ export interface Country {
|
||||||
continente: string
|
continente: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MentionPoint {
|
||||||
|
fecha: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MentionSeries {
|
||||||
|
valor: string
|
||||||
|
tipo: string
|
||||||
|
count: number
|
||||||
|
data: MentionPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MentionsResponse {
|
||||||
|
days: number
|
||||||
|
series: MentionSeries[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntitySuggestion {
|
||||||
|
valor: string
|
||||||
|
tipo: string
|
||||||
|
cnt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alerta {
|
||||||
|
id: number
|
||||||
|
valor: string
|
||||||
|
tipo: string
|
||||||
|
periodo: string
|
||||||
|
hits: number
|
||||||
|
baseline: number
|
||||||
|
ratio: number
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
total_news: number
|
total_news: number
|
||||||
total_feeds: number
|
total_feeds: number
|
||||||
|
|
@ -175,6 +210,31 @@ export const apiService = {
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getEntityMentions: async (params: { values: string[]; days?: number }): Promise<MentionsResponse> => {
|
||||||
|
const { data } = await api.get('/entities/mentions', {
|
||||||
|
params: { values: params.values.join(','), days: params.days || 30 },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
searchEntities: async (params: { tipo: string; q?: string; page?: number; per_page?: number }): Promise<{ entities: EntitySuggestion[]; total: number }> => {
|
||||||
|
const { data } = await api.get('/entities', { params })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
getAlertas: async (params?: { status?: string; limit?: number }): Promise<{ alertas: Alerta[]; total: number; nuevas: number }> => {
|
||||||
|
const { data } = await api.get('/alerts', { params })
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
markAlertaRead: async (id: number): Promise<void> => {
|
||||||
|
await api.post(`/alerts/${id}/read`)
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAlertasRead: async (): Promise<void> => {
|
||||||
|
await api.post('/alerts/read-all')
|
||||||
|
},
|
||||||
|
|
||||||
login: async (email: string, password: string): Promise<{ token: string; user: any }> => {
|
login: async (email: string, password: string): Promise<{ token: string; user: any }> => {
|
||||||
const { data } = await api.post('/auth/login', { email, password })
|
const { data } = await api.post('/auth/login', { email, password })
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ TARGET_LANGS = _env_list("TARGET_LANGS")
|
||||||
BATCH_SIZE = _env_int("TRANSLATOR_BATCH", 128)
|
BATCH_SIZE = _env_int("TRANSLATOR_BATCH", 128)
|
||||||
MAX_SRC_TOKENS = _env_int("MAX_SRC_TOKENS", 256)
|
MAX_SRC_TOKENS = _env_int("MAX_SRC_TOKENS", 256)
|
||||||
MAX_NEW_TOKENS = _env_int("MAX_NEW_TOKENS", 256)
|
MAX_NEW_TOKENS = _env_int("MAX_NEW_TOKENS", 256)
|
||||||
|
MAX_SEQ_PER_CALL = _env_int("MAX_SEQ_PER_CALL", 128)
|
||||||
|
|
||||||
CT2_MODEL_PATH = _env_str("CT2_MODEL_PATH", "/app/models/nllb-ct2")
|
CT2_MODEL_PATH = _env_str("CT2_MODEL_PATH", "/app/models/nllb-ct2")
|
||||||
CT2_DEVICE = _env_str("CT2_DEVICE", "cpu")
|
CT2_DEVICE = _env_str("CT2_DEVICE", "cpu")
|
||||||
|
|
@ -133,6 +134,7 @@ UNIVERSAL_MODEL = _env_str("UNIVERSAL_MODEL", "facebook/nllb-200-distilled-600M"
|
||||||
CT2_INTRA_THREADS = _env_int("CT2_INTRA_THREADS", 0)
|
CT2_INTRA_THREADS = _env_int("CT2_INTRA_THREADS", 0)
|
||||||
CT2_INTER_THREADS = _env_int("CT2_INTER_THREADS", 1)
|
CT2_INTER_THREADS = _env_int("CT2_INTER_THREADS", 1)
|
||||||
BODY_CHARS_CHUNK = _env_int("BODY_CHARS_CHUNK", 900)
|
BODY_CHARS_CHUNK = _env_int("BODY_CHARS_CHUNK", 900)
|
||||||
|
MAX_BODY_CHARS = _env_int("MAX_BODY_CHARS", 12000)
|
||||||
|
|
||||||
LANG_CODE_MAP = {
|
LANG_CODE_MAP = {
|
||||||
"en": "eng_Latn",
|
"en": "eng_Latn",
|
||||||
|
|
@ -302,42 +304,45 @@ def translate_texts(src: str, tgt: str, texts: List[str]) -> List[str]:
|
||||||
|
|
||||||
target_prefix = [[tgt_code]] * len(sources)
|
target_prefix = [[tgt_code]] * len(sources)
|
||||||
|
|
||||||
results = _translator.translate_batch(
|
|
||||||
sources,
|
|
||||||
target_prefix=target_prefix,
|
|
||||||
beam_size=1,
|
|
||||||
max_decoding_length=MAX_NEW_TOKENS,
|
|
||||||
repetition_penalty=1.2,
|
|
||||||
no_repeat_ngram_size=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
translated = []
|
translated = []
|
||||||
for result in results:
|
# Bounded sub-batches: a big batch of long chunks translated in one call
|
||||||
try:
|
# bloats RAM (several GB) on CPU and pushes the process into swap.
|
||||||
if result.hypotheses and len(result.hypotheses) > 0:
|
for i in range(0, len(sources), MAX_SEQ_PER_CALL):
|
||||||
hyp = result.hypotheses[0]
|
results = _translator.translate_batch(
|
||||||
if isinstance(hyp, list) and len(hyp) > 0:
|
sources[i : i + MAX_SEQ_PER_CALL],
|
||||||
first_hyp = hyp[0]
|
target_prefix=target_prefix[i : i + MAX_SEQ_PER_CALL],
|
||||||
if isinstance(first_hyp, dict) and "token_ids" in first_hyp:
|
beam_size=1,
|
||||||
tokens = first_hyp["token_ids"]
|
max_decoding_length=MAX_NEW_TOKENS,
|
||||||
text = _tokenizer.decode(tokens)
|
repetition_penalty=1.2,
|
||||||
translated.append(text.strip())
|
no_repeat_ngram_size=2,
|
||||||
elif isinstance(first_hyp, str):
|
)
|
||||||
token_strings = hyp[1:] if len(hyp) > 1 else []
|
|
||||||
if token_strings:
|
for result in results:
|
||||||
text = _tokenizer.convert_tokens_to_string(token_strings)
|
try:
|
||||||
|
if result.hypotheses and len(result.hypotheses) > 0:
|
||||||
|
hyp = result.hypotheses[0]
|
||||||
|
if isinstance(hyp, list) and len(hyp) > 0:
|
||||||
|
first_hyp = hyp[0]
|
||||||
|
if isinstance(first_hyp, dict) and "token_ids" in first_hyp:
|
||||||
|
tokens = first_hyp["token_ids"]
|
||||||
|
text = _tokenizer.decode(tokens)
|
||||||
translated.append(text.strip())
|
translated.append(text.strip())
|
||||||
|
elif isinstance(first_hyp, str):
|
||||||
|
token_strings = hyp[1:] if len(hyp) > 1 else []
|
||||||
|
if token_strings:
|
||||||
|
text = _tokenizer.convert_tokens_to_string(token_strings)
|
||||||
|
translated.append(text.strip())
|
||||||
|
else:
|
||||||
|
translated.append("")
|
||||||
else:
|
else:
|
||||||
translated.append("")
|
translated.append("")
|
||||||
else:
|
else:
|
||||||
translated.append("")
|
translated.append("")
|
||||||
else:
|
else:
|
||||||
translated.append("")
|
translated.append("")
|
||||||
else:
|
except Exception as e:
|
||||||
|
LOG.error(f"Error processing result: {e}")
|
||||||
translated.append("")
|
translated.append("")
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Error processing result: {e}")
|
|
||||||
translated.append("")
|
|
||||||
|
|
||||||
return translated
|
return translated
|
||||||
|
|
||||||
|
|
@ -482,6 +487,8 @@ def process_batch(conn, rows):
|
||||||
flat_ck = []
|
flat_ck = []
|
||||||
for item in items:
|
for item in items:
|
||||||
body = (item["resumen"] or "").strip()
|
body = (item["resumen"] or "").strip()
|
||||||
|
if len(body) > MAX_BODY_CHARS:
|
||||||
|
body = body[:MAX_BODY_CHARS]
|
||||||
if body:
|
if body:
|
||||||
chunks = split_body_into_chunks(body)
|
chunks = split_body_into_chunks(body)
|
||||||
flat_chunks.extend(chunks)
|
flat_chunks.extend(chunks)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue