diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index 1671ca9..fd94f6d 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -233,7 +233,9 @@ siehe [12 Scanner](12-scanner.md). **Schlagwörter mit Alarm** im Suchagenten: wird eines gefunden, gibt es einen kurzen Ton und ein Pop-up – egal, wo du im CRM gerade bist. *Gesehen* -quittiert für alle. +quittiert für alle. Rechts daneben steht **ab … %**: z. B. „Dringend, Medizin“ +mit 15 % – dann kommt der Alarm nur, wenn der Treffer mindestens 15 % Quote hat. +0 % = Alarm bei jedem Fund. Zeitplan (Wochentage, Uhrzeit, Intervall) und den kostenlosen Schlüssel für Adzuna pflegt ein Administrator unter diff --git a/docs/12-scanner.md b/docs/12-scanner.md index 99257f7..8e14bcf 100644 --- a/docs/12-scanner.md +++ b/docs/12-scanner.md @@ -128,6 +128,15 @@ Neben den Schlagwörtern trägt ein Agent **Schlagwörter mit Alarm** (`alarm_keywords`). Sie werden genauso gesucht und für die Quote gezählt; steht eines in einer **neu gefundenen** Anzeige, bekommt der Treffer `alarm = 1`. +Seit 20.10.2026 gilt dafür eine **Mindest-Quote** je Agent +(`alarm_min_score`, 0–100 %, Feld „ab … %“ neben den Alarm-Schlagwörtern): +Alarm nur, wenn ein Alarm-Schlagwort gefunden wurde **und** die Quote des +Treffers mindestens diesen Wert erreicht. 0 % = bei jedem Fund (bisheriges +Verhalten). Darunter wird der Treffer normal gespeichert, nur ohne Alarm. Wird +die Grenze angehoben, verlieren noch nicht quittierte Alarme darunter ihr +`alarm`-Kennzeichen; ein Absenken macht alte Treffer nicht nachträglich zu +Alarmen. + ```mermaid sequenceDiagram participant P as pipeline.js @@ -227,6 +236,7 @@ erDiagram text excludes "JSON-Array" text exclude_companies "JSON-Array, Abgleich mit Firmenname" text alarm_keywords "JSON-Array, Ton + Pop-up" + tinyint alarm_min_score "Alarm erst ab dieser Quote, 0 = immer" tinyint is_active } icon_scanner_jobs { @@ -281,7 +291,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`. | --- | --- | --- | --- | | GET | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf | | GET | `/agents` | alle | Suchagenten mit Trefferzahl | -| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], exclude_companies[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | +| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], alarm_min_score, sources[], excludes[], exclude_companies[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | | PUT | `/agents/:id` | alle | ändern (gleicher Körper); löscht vorhandene Treffer von Ausschluss-Unternehmen (außer „in Bearbeitung“) | | DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | | GET | `/matches` | alle | Filter `hours` (gefunden), `olderThan` / `youngerThan` (Tage ab Veröffentlichung, einzeln oder zusammen; `age=recent|old` = feste 21-Tage-Grenze, aus der ersten Fassung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` | diff --git a/server/src/db.js b/server/src/db.js index 6111948..772c626 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -191,6 +191,12 @@ async function migrateColumns() { 'alarm_keywords', 'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes', ], + // Scanner: Alarm erst ab einer Mindest-Quote, 20.10.2026. + [ + 'icon_scanner_agents', + 'alarm_min_score', + 'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_min_score TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER alarm_keywords', + ], // Scanner: Ausschluss-Unternehmen je Agent ("Hays"), 20.10.2026. [ 'icon_scanner_agents', diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js index 2956519..d0869c3 100644 --- a/server/src/routes/scanner.js +++ b/server/src/routes/scanner.js @@ -39,6 +39,7 @@ const agentOut = (row) => ({ excludes: parseList(row.excludes), exclude_companies: parseList(row.exclude_companies), alarm_keywords: parseList(row.alarm_keywords), + alarm_min_score: Number(row.alarm_min_score ?? 0), is_active: Boolean(row.is_active), matches: Number(row.matches ?? 0), created_at: row.created_at, @@ -124,22 +125,24 @@ function readAgent(body) { const excludes = cleanList(body?.excludes); const excludeCompanies = cleanList(body?.exclude_companies); const alarmKeywords = cleanList(body?.alarm_keywords); + const alarmMinScore = Math.min(100, Math.max(0, Math.round(Number(body?.alarm_min_score) || 0))); if (!name) return { error: 'Bitte einen Namen angeben.' }; if (keywords.length === 0 && alarmKeywords.length === 0) { return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' }; } if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' }; - return { name, keywords, alarmKeywords, sources, excludes, excludeCompanies, is_active: body?.is_active !== false }; + return { name, keywords, alarmKeywords, alarmMinScore, sources, excludes, excludeCompanies, is_active: body?.is_active !== false }; } /** - * Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, sources, - * excludes, exclude_companies, is_active. + * Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, + * alarm_min_score, sources, excludes, exclude_companies, is_active. */ const agentValues = (agent) => [ agent.name, JSON.stringify(agent.keywords), JSON.stringify(agent.alarmKeywords), + agent.alarmMinScore, JSON.stringify(agent.sources), JSON.stringify(agent.excludes), JSON.stringify(agent.excludeCompanies), @@ -168,8 +171,9 @@ router.post('/agents', async (req, res, next) => { try { const [result] = await pool.query( `INSERT INTO icon_scanner_agents - (name, keywords, alarm_keywords, sources, excludes, exclude_companies, is_active, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + (name, keywords, alarm_keywords, alarm_min_score, sources, excludes, exclude_companies, is_active, + created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [...agentValues(agent), req.user.id] ); await logHistory({ @@ -192,13 +196,18 @@ router.put('/agents/:id', async (req, res, next) => { try { const [result] = await pool.query( `UPDATE icon_scanner_agents - SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, exclude_companies = ?, - is_active = ? + SET name = ?, keywords = ?, alarm_keywords = ?, alarm_min_score = ?, sources = ?, excludes = ?, + exclude_companies = ?, is_active = ? WHERE id = ?`, [...agentValues(agent), Number(req.params.id)] ); if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' }); await dropExcludedMatches(Number(req.params.id), agent); + // Offene Alarme unter der (neuen) Mindest-Quote nicht mehr melden. + await pool.query( + 'UPDATE icon_scanner_matches SET alarm = 0 WHERE agent_id = ? AND alarm = 1 AND alarm_ack = 0 AND score < ?', + [Number(req.params.id), agent.alarmMinScore] + ); const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]); res.json({ agent: agentOut(row) }); } catch (err) { diff --git a/server/src/scanner/matching.js b/server/src/scanner/matching.js index af7303d..6bfbbc2 100644 --- a/server/src/scanner/matching.js +++ b/server/src/scanner/matching.js @@ -67,7 +67,8 @@ export function isExcludedCompany(agent, company) { /** * @returns {{ score: number, hits: string[], alarm: boolean } | null} * null = ausgeschlossen oder kein einziges Schlagwort gefunden. - * `alarm` = mindestens ein Alarm-Schlagwort steht in der Anzeige. + * `alarm` = mindestens ein Alarm-Schlagwort steht in der Anzeige und + * die Quote erreicht `agent.alarmMinScore` (0 = immer). */ export function scoreJob(agent, job) { const title = job.title ?? ''; @@ -93,10 +94,11 @@ export function scoreJob(agent, job) { if (hits.length === 0) return null; const alarmSet = new Set((agent.alarmKeywords ?? []).map((term) => term.toLowerCase())); + const score = Math.round((100 * points) / (2 * keywords.length)); return { - score: Math.round((100 * points) / (2 * keywords.length)), + score, hits, - alarm: hits.some((hit) => alarmSet.has(hit.toLowerCase())), + alarm: score >= (agent.alarmMinScore ?? 0) && hits.some((hit) => alarmSet.has(hit.toLowerCase())), }; } diff --git a/server/src/scanner/pipeline.js b/server/src/scanner/pipeline.js index 1bfa700..57f2df0 100644 --- a/server/src/scanner/pipeline.js +++ b/server/src/scanner/pipeline.js @@ -151,6 +151,7 @@ async function loadActiveAgents() { id: row.id, keywords: parseList(row.keywords), alarmKeywords: parseList(row.alarm_keywords), + alarmMinScore: Number(row.alarm_min_score ?? 0), sources: parseList(row.sources), excludes: parseList(row.excludes), excludeCompanies: parseList(row.exclude_companies), diff --git a/server/src/schema.sql b/server/src/schema.sql index 41abfcf..27a5eac 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -477,6 +477,8 @@ CREATE TABLE IF NOT EXISTS icon_scanner_agents ( -- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt, -- ein Fund meldet sich zusaetzlich mit Ton und Pop-up. alarm_keywords TEXT NULL, + -- Alarm erst ab dieser Quote (0-100 %); 0 = bei jedem Fund. + alarm_min_score TINYINT UNSIGNED NOT NULL DEFAULT 0, -- Unternehmen, deren Anzeigen dieser Agent nie zeigt (z. B. ["Hays"]). exclude_companies TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, diff --git a/src/components/scanner/ScannerAgents.tsx b/src/components/scanner/ScannerAgents.tsx index 3986135..2c23f70 100644 --- a/src/components/scanner/ScannerAgents.tsx +++ b/src/components/scanner/ScannerAgents.tsx @@ -29,6 +29,7 @@ const EMPTY: ScannerAgentInput = { excludes: [], exclude_companies: [], alarm_keywords: [], + alarm_min_score: 0, is_active: true, }; @@ -150,6 +151,7 @@ export const ScannerAgents: React.FC = () => { excludes: agent.excludes, exclude_companies: agent.exclude_companies, alarm_keywords: agent.alarm_keywords, + alarm_min_score: agent.alarm_min_score, is_active: agent.is_active, }, }) @@ -175,7 +177,7 @@ export const ScannerAgents: React.FC = () => { ))} {agent.alarm_keywords.length > 0 && ( - + 0 ? ` · ab ${agent.alarm_min_score} %` : ''}`}> {agent.alarm_keywords.map((term) => ( { - patch({ alarm_keywords })} - placeholder="z. B. Interim CFO" - tone="alarm" - /> +
+
+ patch({ alarm_keywords })} + placeholder="z. B. Dringend, Medizin" + tone="alarm" + /> +
+ +
diff --git a/src/lib/api.ts b/src/lib/api.ts index 030db40..3ccba47 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -483,6 +483,8 @@ export interface ScannerAgent { exclude_companies: string[]; /** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */ alarm_keywords: string[]; + /** Alarm erst ab dieser Quote (0–100 %); 0 = bei jedem Fund. */ + alarm_min_score: number; is_active: boolean; matches: number; } @@ -570,7 +572,7 @@ export interface ScannerSecrets { export type ScannerAgentInput = Pick< ScannerAgent, - 'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'exclude_companies' | 'is_active' + 'name' | 'keywords' | 'alarm_keywords' | 'alarm_min_score' | 'sources' | 'excludes' | 'exclude_companies' | 'is_active' >; const put = (path: string, body: unknown) =>