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:
jlimolina 2026-08-11 04:18:37 +02:00
parent eff656adea
commit 3991237685
11 changed files with 988 additions and 28 deletions

View file

@ -76,6 +76,27 @@ func initDB() {
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
_, err = db.GetPool().Exec(ctx, `
CREATE TABLE IF NOT EXISTS remote_workers (
@ -175,6 +196,11 @@ func main() {
api.GET("/entities", handlers.GetEntities)
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)
@ -195,6 +221,7 @@ func main() {
admin.POST("/users/:id/promote", handlers.PromoteUser)
admin.POST("/users/:id/demote", handlers.DemoteUser)
admin.POST("/reset-db", handlers.ResetDatabase)
admin.POST("/alerts/scan", handlers.ScanAlertasAdmin)
admin.GET("/workers/status", handlers.GetWorkerStatus)
admin.GET("/workers/stats", handlers.GetTranslationStats)
admin.POST("/workers/config", handlers.SetWorkerConfig)
@ -231,6 +258,7 @@ func main() {
}()
handlers.StartJobAssigner()
handlers.StartAlertScanner()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

View 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)
}
}()
}

View file

@ -1,9 +1,11 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@ -484,3 +486,140 @@ func GetEntities(c *gin.Context) {
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
}

View file

@ -339,6 +339,14 @@ services:
db:
condition: service_healthy
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)
@ -377,6 +385,13 @@ services:
db:
condition: service_healthy
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
@ -440,6 +455,7 @@ services:
deploy:
resources:
limits:
cpus: '2'
memory: 6G
depends_on:
db:

View file

@ -11,6 +11,8 @@ import { Stats } from './pages/Stats'
import { Favorites } from './pages/Favorites'
import { Account } from './pages/Account'
import { Populares } from './pages/Populares'
import { Analisis } from './pages/Analisis'
import { Alertas } from './pages/Alertas'
import { AdminAliases } from './pages/AdminAliases'
import { AdminUsers } from './pages/AdminUsers'
import { AdminSettings } from './pages/AdminSettings'
@ -62,6 +64,8 @@ function App() {
<Route path="search" element={<Search />} />
<Route path="populares" element={<Populares />} />
<Route path="stats" element={<Stats />} />
<Route path="analisis" element={<Analisis />} />
<Route path="alertas" element={<Alertas />} />
<Route path="favorites" element={<Favorites />} />
<Route path="account" element={<Account />} />
<Route path="login" element={<Login />} />

View 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>
)
}

View file

@ -1,6 +1,7 @@
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 { apiService } from '../../services/api'
export function Layout() {
const navigate = useNavigate()
@ -8,6 +9,7 @@ export function Layout() {
const [username, setUsername] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [showAdminMenu, setShowAdminMenu] = useState(false)
const [nuevasAlertas, setNuevasAlertas] = useState(0)
const checkAuth = () => {
const token = localStorage.getItem('token')
@ -37,6 +39,20 @@ export function Layout() {
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 = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
@ -72,6 +88,19 @@ export function Layout() {
<Flame className="h-4 w-4" />
Popular
</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">
<BarChart3 className="h-4 w-4" />
Stats

View 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>
)
}

View 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>
)
}

View file

@ -95,6 +95,41 @@ export interface Country {
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 {
total_news: number
total_feeds: number
@ -175,6 +210,31 @@ export const apiService = {
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 }> => {
const { data } = await api.post('/auth/login', { email, password })
return data

View file

@ -125,6 +125,7 @@ TARGET_LANGS = _env_list("TARGET_LANGS")
BATCH_SIZE = _env_int("TRANSLATOR_BATCH", 128)
MAX_SRC_TOKENS = _env_int("MAX_SRC_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_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_INTER_THREADS = _env_int("CT2_INTER_THREADS", 1)
BODY_CHARS_CHUNK = _env_int("BODY_CHARS_CHUNK", 900)
MAX_BODY_CHARS = _env_int("MAX_BODY_CHARS", 12000)
LANG_CODE_MAP = {
"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)
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 = []
for result in results:
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())
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)
# Bounded sub-batches: a big batch of long chunks translated in one call
# bloats RAM (several GB) on CPU and pushes the process into swap.
for i in range(0, len(sources), MAX_SEQ_PER_CALL):
results = _translator.translate_batch(
sources[i : i + MAX_SEQ_PER_CALL],
target_prefix=target_prefix[i : i + MAX_SEQ_PER_CALL],
beam_size=1,
max_decoding_length=MAX_NEW_TOKENS,
repetition_penalty=1.2,
no_repeat_ngram_size=2,
)
for result in results:
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())
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:
translated.append("")
else:
translated.append("")
else:
translated.append("")
else:
except Exception as e:
LOG.error(f"Error processing result: {e}")
translated.append("")
except Exception as e:
LOG.error(f"Error processing result: {e}")
translated.append("")
return translated
@ -482,6 +487,8 @@ def process_batch(conn, rows):
flat_ck = []
for item in items:
body = (item["resumen"] or "").strip()
if len(body) > MAX_BODY_CHARS:
body = body[:MAX_BODY_CHARS]
if body:
chunks = split_body_into_chunks(body)
flat_chunks.extend(chunks)