muchas cosas

This commit is contained in:
jlimolina 2026-08-11 02:37:48 +02:00
parent e3936fd4cf
commit eff656adea
20 changed files with 697 additions and 51 deletions

View file

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
@ -386,6 +387,51 @@ func GetWorkerStatus(c *gin.Context) {
})
}
// GetTranslationStats returns real translation throughput and live worker count.
func GetTranslationStats(c *gin.Context) {
ctx := c.Request.Context()
var last1min, last5min, activeWorkers int64
var elapsed1min, elapsed5min float64
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '1 minute'
`).Scan(&last1min, &elapsed1min)
db.GetPool().QueryRow(ctx, `
SELECT COALESCE(SUM(items_translated),0), COALESCE(SUM(elapsed_ms),0)
FROM translation_stats WHERE created_at >= NOW() - INTERVAL '5 minutes'
`).Scan(&last5min, &elapsed5min)
// Live workers = distinct hosts that recorded stats in the last 2 minutes
db.GetPool().QueryRow(ctx, `
SELECT COUNT(DISTINCT hostname) FROM translation_stats
WHERE created_at >= NOW() - INTERVAL '2 minutes' AND hostname IS NOT NULL
`).Scan(&activeWorkers)
// Also count WS remote workers currently online
remoteOnline := 0
if err := db.GetPool().QueryRow(ctx, `
SELECT COUNT(*) FROM remote_workers WHERE status = 'online'
`).Scan(&remoteOnline); err != nil {
remoteOnline = 0
}
rateSec := 0.0
if elapsed1min > 0 {
rateSec = float64(last1min) / (elapsed1min / 1000.0)
}
c.JSON(http.StatusOK, gin.H{
"translations_last_1min": last1min,
"translations_last_5min": last5min,
"rate_per_second": math.Round(rateSec*100) / 100,
"rate_per_minute": math.Round(rateSec*60*100) / 100,
"active_workers": activeWorkers + int64(remoteOnline),
"remote_workers_online": remoteOnline,
})
}
func SetWorkerConfig(c *gin.Context) {
var req WorkerConfig
if err := c.ShouldBindJSON(&req); err != nil {

View file

@ -49,7 +49,13 @@ func GetNews(c *gin.Context) {
argNum := 1
if query != "" {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
if translatedOnly {
// With translated_only, search the translated fields too so users can
// find translated news by the Spanish (translated) wording.
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d OR t.titulo_trad ILIKE $%d OR t.resumen_trad ILIKE $%d)", argNum, argNum, argNum, argNum)
} else {
where += fmt.Sprintf(" AND (n.titulo ILIKE $%d OR n.resumen ILIKE $%d)", argNum, argNum)
}
args = append(args, "%"+query+"%")
argNum++
}
@ -149,6 +155,117 @@ func GetNews(c *gin.Context) {
})
}
// GetEntityNews returns the (translated) news that mention a given entity/alias value.
func GetEntityNews(c *gin.Context) {
valor := c.Query("valor")
tipo := c.DefaultQuery("tipo", "persona")
pageStr := c.DefaultQuery("page", "1")
perPageStr := c.DefaultQuery("per_page", "20")
page, _ := strconv.Atoi(pageStr)
perPage, _ := strconv.Atoi(perPageStr)
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 50 {
perPage = 20
}
offset := (page - 1) * perPage
daysStr := c.Query("days")
days := 0
if daysStr != "" {
days, _ = strconv.Atoi(daysStr)
if days < 1 {
days = 0
}
}
today := c.Query("today") == "true"
offsetStr := c.Query("day_offset")
dayOffset := 0
useDayOffset := false
if offsetStr != "" {
dayOffset, _ = strconv.Atoi(offsetStr)
if dayOffset < 0 {
dayOffset = 0
}
useDayOffset = true
}
params := []interface{}{valor, tipo}
next := 3
timeCond := ""
switch {
case useDayOffset:
// Día exacto: desde el inicio del día (hoy - offset) hasta el inicio del día siguiente
timeCond = fmt.Sprintf(
" AND n.fecha >= (CURRENT_DATE - %d)::timestamp AND n.fecha < (CURRENT_DATE - %d + 1)::timestamp",
dayOffset, dayOffset)
case today:
timeCond = " AND n.fecha >= date_trunc('day', NOW())"
case days > 0:
timeCond = fmt.Sprintf(" AND n.fecha >= NOW() - make_interval(days => $%d)", next)
params = append(params, days)
next++
}
base := fmt.Sprintf(`
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
LEFT JOIN entity_aliases ea ON LOWER(ea.alias) = LOWER(t.valor) AND ea.tipo = t.tipo
WHERE LOWER(COALESCE(ea.canonical_name, t.valor)) = LOWER($1) AND t.tipo = $2%s`, timeCond)
var total int
if err := db.GetPool().QueryRow(c.Request.Context(),
fmt.Sprintf("SELECT COUNT(DISTINCT tn.noticia_id) %s", base), params...).Scan(&total); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to count entity news", Message: err.Error()})
return
}
query := fmt.Sprintf(`
SELECT DISTINCT n.id, n.titulo, COALESCE(n.resumen,''), n.url, n.fecha, n.imagen_url,
n.fuente_nombre, tr.titulo_trad, tr.resumen_trad
%s
ORDER BY n.fecha DESC
LIMIT $%d OFFSET $%d`, base, next, next+1)
rows, err := db.GetPool().Query(c.Request.Context(), query, append(params, perPage, offset)...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get entity news", Message: err.Error()})
return
}
defer rows.Close()
var news []NewsResponse
for rows.Next() {
var n NewsResponse
var img *string
if err := rows.Scan(&n.ID, &n.Titulo, &n.Resumen, &n.URL, &n.Fecha, &img,
&n.FuenteNombre, &n.TitleTranslated, &n.SummaryTranslated); err != nil {
continue
}
if img != nil {
n.ImagenURL = img
}
news = append(news, n)
}
if news == nil {
news = []NewsResponse{}
}
totalPages := (total + perPage - 1) / perPage
c.JSON(http.StatusOK, gin.H{
"news": news,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
func GetNewsByID(c *gin.Context) {
id := c.Param("id")

View file

@ -3,13 +3,82 @@ package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rss2/backend/internal/auth"
"github.com/rss2/backend/internal/db"
"github.com/rss2/backend/internal/models"
"github.com/rss2/backend/internal/services"
)
// LogSearch records a logged-in user's search term so frequent searches gain priority.
func LogSearch(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
var req struct {
Query string `json:"q"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request"})
return
}
term := strings.ToLower(strings.TrimSpace(req.Query))
if term == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "q requerido"})
return
}
if len(term) > 100 {
term = term[:100]
}
_, err := db.GetPool().Exec(c.Request.Context(), `
INSERT INTO user_search_tags (user_id, term, count, last_used)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (user_id, term)
DO UPDATE SET count = user_search_tags.count + 1, last_used = NOW()
`, user.UserID, term)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to save search", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// SearchSuggestions returns the user's most used search terms (dynamic tags).
func SearchSuggestions(c *gin.Context) {
user := c.MustGet("user").(*auth.Claims)
q := strings.TrimSpace(c.Query("q"))
query := `
SELECT term FROM user_search_tags
WHERE user_id = $1`
args := []interface{}{user.UserID}
if q != "" {
query += ` AND term ILIKE $2`
args = append(args, "%"+q+"%")
}
query += ` ORDER BY count DESC, last_used DESC LIMIT 10`
rows, err := db.GetPool().Query(c.Request.Context(), query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: "Failed to get suggestions", Message: err.Error()})
return
}
defer rows.Close()
terms := []string{}
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
continue
}
terms = append(terms, t)
}
c.JSON(http.StatusOK, gin.H{"terms": terms})
}
func SearchNews(c *gin.Context) {
query := c.Query("q")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))