diff --git a/API.md b/API.md
index 0d07e7f..f1746fd 100644
--- a/API.md
+++ b/API.md
@@ -234,6 +234,7 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
dashboard/listado/detalle/software) también para usuarios con acceso compartido.
- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin).
+- `POST /api/devices/:id/cpu-year` → equipo con `cpu_year`: la IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos). Roles que editan equipos; `400` sin procesador o IA sin configurar, `422` si no se pudo determinar; 30/hora.
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md
index 5aeb096..b60fe67 100644
--- a/BASE_DE_DATOS.md
+++ b/BASE_DE_DATOS.md
@@ -273,3 +273,5 @@ en producción.
## Cambio de correo (migración 035, no aplicada)
`email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`.
+
+- `devices.cpu_year` / `cpu_year_for` (migración `039`): año del procesador consultado a la IA (solo por el nombre del procesador); se ignora si el procesador reportado cambia.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b46f9d2..952e8ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net
+## [4.14.0] - 2026-10-29
+### Added
+- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
+ **solo el nombre del procesador** (nada del equipo ni del usuario) y devuelve el año de
+ lanzamiento; si hay dos fechas, el más antiguo. Se valida el rango antes de guardar, queda en
+ auditoría y en el consumo de IA, y el año se descarta si el equipo cambia de procesador. Solo
+ roles que editan equipos; 30 consultas/hora. Migración `039` (`devices.cpu_year`, `cpu_year_for`).
+
## [4.13.1] - 2026-10-29
### Changed
- Equipos → listado: nueva columna "Asignado a" después de Nombre (usuario asignado a mano; "—" si no hay).
diff --git a/VERSION b/VERSION
index 56d2fb5..c412a4e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.13.1
+4.14.0
diff --git a/backend/database/migrations/039_device_cpu_year.sql b/backend/database/migrations/039_device_cpu_year.sql
new file mode 100644
index 0000000..7711425
--- /dev/null
+++ b/backend/database/migrations/039_device_cpu_year.sql
@@ -0,0 +1,6 @@
+-- Año de lanzamiento del procesador (el agente GLPI no lo reporta): lo consulta la IA solo con el
+-- nombre del procesador. cpu_year_for guarda el cpu_summary usado, para descartar el año si el
+-- procesador del equipo cambia. Idempotente.
+ALTER TABLE devices
+ ADD COLUMN IF NOT EXISTS cpu_year SMALLINT UNSIGNED NULL,
+ ADD COLUMN IF NOT EXISTS cpu_year_for VARCHAR(255) NULL;
diff --git a/backend/package.json b/backend/package.json
index c86e64e..303b383 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "4.13.1",
+ "version": "4.14.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js
index 4149b83..c850545 100644
--- a/backend/src/modules/devices/controller.js
+++ b/backend/src/modules/devices/controller.js
@@ -23,3 +23,5 @@ export const getAccess = asyncHandler(async (req, res) => ok(res, await service.
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
+
+export const postCpuYear = asyncHandler(async (req, res) => ok(res, await service.fetchCpuYear(Number(req.params.id), req.currentUser)));
diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js
index c5140d5..047f939 100644
--- a/backend/src/modules/devices/repository.js
+++ b/backend/src/modules/devices/repository.js
@@ -214,6 +214,8 @@ export async function findById(id) {
[id]
);
detail.changes = changes;
+ // El año del procesador solo vale mientras el equipo siga reportando el mismo procesador.
+ if (detail.cpu_year_for !== detail.cpu_summary) detail.cpu_year = null;
return detail;
}
@@ -225,6 +227,8 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n
return result.affectedRows > 0;
}
+export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, id]);
+
export async function findRawFile(id) {
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
return row?.raw_file || null;
diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js
index 59ff33b..0c7d9ed 100644
--- a/backend/src/modules/devices/routes.js
+++ b/backend/src/modules/devices/routes.js
@@ -1,15 +1,19 @@
import { Router } from 'express';
+import rateLimit from 'express-rate-limit';
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
import { requireModule } from '../../middleware/modules.js';
import { fail } from '../../utils/http.js';
import { getAccess as getAccessFor } from './service.js';
import {
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
- getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer,
+ getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
} from './controller.js';
const router = Router();
+// Cada consulta a la IA cuesta: tope por IP para no gastar saldo por error o abuso.
+const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
+
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
@@ -38,6 +42,7 @@ router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
router.get('/', canView, getDevices);
router.get('/:id', canView, getDevice);
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
+router.post('/:id/cpu-year', requireRole(...CAN_EDIT_DEVICES), aiLimiter, postCpuYear);
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
export default router;
diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js
index 595d15f..089bd85 100644
--- a/backend/src/modules/devices/service.js
+++ b/backend/src/modules/devices/service.js
@@ -6,6 +6,7 @@ import { HttpError } from '../../utils/http.js';
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
import { logAudit } from '../../utils/audit.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
+import { askStructured } from '../../utils/ai.js';
import * as repo from './repository.js';
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
@@ -42,6 +43,30 @@ export async function updateDevice(id, data, actor) {
return getDevice(id);
}
+// Año de lanzamiento del procesador, consultado a la IA SOLO con el nombre del procesador (nada
+// del equipo, usuario ni red). Si la IA da más de una fecha se pide la más antigua. La IA solo
+// sugiere: se valida el rango antes de guardar.
+export async function fetchCpuYear(id, actor) {
+ const device = await getDevice(id);
+ const cpuName = device.cpus.find((c) => c.name)?.name;
+ if (!cpuName) throw new HttpError(400, 'Este equipo no reporta procesador');
+ const result = await askStructured({
+ system: 'Eres un experto en hardware de computadores. Te dan SOLO el nombre de un procesador. Indica el AÑO en que se lanzó. Si hay más de una fecha asociada (por ejemplo anuncio y disponibilidad), devuelve el año MÁS ANTIGUO. Si no puedes determinarlo con seguridad, devuelve null: no inventes.',
+ prompt: `Procesador: ${String(cpuName).trim().slice(0, 200)}`,
+ toolName: 'cpu_release_year',
+ schema: { type: 'object', properties: { year: { type: ['integer', 'null'], description: 'Año de lanzamiento (4 dígitos) o null' } }, required: ['year'] },
+ maxTokens: 200,
+ usage: { userId: actor.id, action: 'device_cpu_year' },
+ });
+ const year = Number(result?.year);
+ if (!Number.isInteger(year) || year < 1990 || year > new Date().getFullYear() + 1) {
+ throw new HttpError(422, 'No se pudo determinar el año de este procesador');
+ }
+ await repo.saveCpuYear(id, year, device.cpu_summary);
+ await logAudit({ userId: actor.id, action: 'cpu_year', entityType: 'device', entityId: id, detail: { cpu: cpuName, year } });
+ return getDevice(id);
+}
+
export async function getRaw(id) {
const file = await repo.findRawFile(id);
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
diff --git a/frontend/package.json b/frontend/package.json
index 20bde31..64f55af 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "4.13.1",
+ "version": "4.14.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index f98d37f..ad76bc4 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -466,6 +466,10 @@ export const messages = {
eq_assigned_to: 'Asignado a',
eq_notes: 'Notas',
eq_cpu: 'Procesador',
+ eq_cpu_year: 'Año del procesador',
+ eq_cpu_year_get: 'Obtener fecha',
+ eq_cpu_year_loading: 'Consultando...',
+ eq_cpu_year_hint: 'La IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos).',
eq_ram: 'Memoria RAM',
eq_disk_total: 'Almacenamiento',
eq_disk_max_used: 'volumen más lleno',
@@ -1229,6 +1233,10 @@ export const messages = {
eq_assigned_to: 'Assigned to',
eq_notes: 'Notes',
eq_cpu: 'Processor',
+ eq_cpu_year: 'Processor year',
+ eq_cpu_year_get: 'Get date',
+ eq_cpu_year_loading: 'Looking up...',
+ eq_cpu_year_hint: 'The AI looks up only the processor name and returns its release year (the oldest if there are two).',
eq_ram: 'RAM',
eq_disk_total: 'Storage',
eq_disk_max_used: 'fullest volume',
diff --git a/frontend/src/pages/Equipos/EquiposPage.jsx b/frontend/src/pages/Equipos/EquiposPage.jsx
index 973a4b3..49cff07 100644
--- a/frontend/src/pages/Equipos/EquiposPage.jsx
+++ b/frontend/src/pages/Equipos/EquiposPage.jsx
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Navigate } from 'react-router-dom';
-import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
+import { Pencil, Save, Plus, Trash2, Power, Copy, Download, Sparkles } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import {
- listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
+ listDevices, getDevice, updateDevice, fetchDeviceCpuYear, getDevicesDashboard, listSoftware, listSoftwareDevices,
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
} from '../../services/devices.js';
@@ -69,6 +69,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
const [searches, setSearches] = useState({});
const [showComponents, setShowComponents] = useState(false);
+ const [fetchingYear, setFetchingYear] = useState(false);
const [editing, setEditing] = useState(false);
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
const draft = useDraftForm({
@@ -84,6 +85,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
+ // Año de lanzamiento del procesador: la IA lo consulta solo con el nombre del procesador.
+ function getCpuYear() {
+ setFetchingYear(true);
+ runAction(async () => {
+ await fetchDeviceCpuYear(id);
+ await reload();
+ pushToast({ tone: 'success', message: t('common_saved') });
+ }).catch(() => {}).finally(() => setFetchingYear(false));
+ }
+
function startEdit() {
setEditing(true);
}
@@ -147,6 +158,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
const hwRows = [
[t('eq_cpu'), val(cpuText), cpuText],
+ [t('eq_cpu_year'), (
+
+ {val(device.cpu_year)}
+ {canEdit && cpuText && (
+
+ )}
+
+ ), String(device.cpu_year ?? '')],
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && · {t('eq_disk_max_used')} {device.max_disk_used_pct}%}>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
];
diff --git a/frontend/src/services/devices.js b/frontend/src/services/devices.js
index 7d97464..9871b90 100644
--- a/frontend/src/services/devices.js
+++ b/frontend/src/services/devices.js
@@ -20,3 +20,4 @@ export const getDevicesAccess = () => api.get('/devices/access');
export const listDeviceViewers = () => api.get('/devices/viewers');
export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId });
export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`);
+export const fetchDeviceCpuYear = (id) => api.post(`/devices/${id}/cpu-year`);