feat(scanner): Alarm erst ab Mindest-Quote je Suchagent

Neues Feld alarm_min_score (0-100 %): Alarm nur, wenn ein
Alarm-Schlagwort gefunden wurde und die Quote den Wert erreicht.
0 % = bei jedem Fund wie bisher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-10-20 14:44:27 +02:00
co-authored by Claude Opus 5.5
parent 1969d0b77a
commit 371512a47d
9 changed files with 76 additions and 21 deletions
+3 -1
View File
@@ -233,7 +233,9 @@ siehe [12 Scanner](12-scanner.md).
**Schlagwörter mit Alarm** im Suchagenten: wird eines gefunden, gibt es einen **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* 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 Zeitplan (Wochentage, Uhrzeit, Intervall) und den kostenlosen
Schlüssel für Adzuna pflegt ein Administrator unter Schlüssel für Adzuna pflegt ein Administrator unter
+11 -1
View File
@@ -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 (`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`. 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 ```mermaid
sequenceDiagram sequenceDiagram
participant P as pipeline.js participant P as pipeline.js
@@ -227,6 +236,7 @@ erDiagram
text excludes "JSON-Array" text excludes "JSON-Array"
text exclude_companies "JSON-Array, Abgleich mit Firmenname" text exclude_companies "JSON-Array, Abgleich mit Firmenname"
text alarm_keywords "JSON-Array, Ton + Pop-up" text alarm_keywords "JSON-Array, Ton + Pop-up"
tinyint alarm_min_score "Alarm erst ab dieser Quote, 0 = immer"
tinyint is_active tinyint is_active
} }
icon_scanner_jobs { 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 | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf |
| GET | `/agents` | alle | Suchagenten mit Trefferzahl | | 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“) | | 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 | | 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` | | 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` |
+6
View File
@@ -191,6 +191,12 @@ async function migrateColumns() {
'alarm_keywords', 'alarm_keywords',
'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes', '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. // Scanner: Ausschluss-Unternehmen je Agent ("Hays"), 20.10.2026.
[ [
'icon_scanner_agents', 'icon_scanner_agents',
+16 -7
View File
@@ -39,6 +39,7 @@ const agentOut = (row) => ({
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
exclude_companies: parseList(row.exclude_companies), exclude_companies: parseList(row.exclude_companies),
alarm_keywords: parseList(row.alarm_keywords), alarm_keywords: parseList(row.alarm_keywords),
alarm_min_score: Number(row.alarm_min_score ?? 0),
is_active: Boolean(row.is_active), is_active: Boolean(row.is_active),
matches: Number(row.matches ?? 0), matches: Number(row.matches ?? 0),
created_at: row.created_at, created_at: row.created_at,
@@ -124,22 +125,24 @@ function readAgent(body) {
const excludes = cleanList(body?.excludes); const excludes = cleanList(body?.excludes);
const excludeCompanies = cleanList(body?.exclude_companies); const excludeCompanies = cleanList(body?.exclude_companies);
const alarmKeywords = cleanList(body?.alarm_keywords); 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 (!name) return { error: 'Bitte einen Namen angeben.' };
if (keywords.length === 0 && alarmKeywords.length === 0) { if (keywords.length === 0 && alarmKeywords.length === 0) {
return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' }; return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' };
} }
if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' }; 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, * Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords,
* excludes, exclude_companies, is_active. * alarm_min_score, sources, excludes, exclude_companies, is_active.
*/ */
const agentValues = (agent) => [ const agentValues = (agent) => [
agent.name, agent.name,
JSON.stringify(agent.keywords), JSON.stringify(agent.keywords),
JSON.stringify(agent.alarmKeywords), JSON.stringify(agent.alarmKeywords),
agent.alarmMinScore,
JSON.stringify(agent.sources), JSON.stringify(agent.sources),
JSON.stringify(agent.excludes), JSON.stringify(agent.excludes),
JSON.stringify(agent.excludeCompanies), JSON.stringify(agent.excludeCompanies),
@@ -168,8 +171,9 @@ router.post('/agents', async (req, res, next) => {
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO icon_scanner_agents `INSERT INTO icon_scanner_agents
(name, keywords, alarm_keywords, sources, excludes, exclude_companies, is_active, created_by) (name, keywords, alarm_keywords, alarm_min_score, sources, excludes, exclude_companies, is_active,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[...agentValues(agent), req.user.id] [...agentValues(agent), req.user.id]
); );
await logHistory({ await logHistory({
@@ -192,13 +196,18 @@ router.put('/agents/:id', async (req, res, next) => {
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`UPDATE icon_scanner_agents `UPDATE icon_scanner_agents
SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, exclude_companies = ?, SET name = ?, keywords = ?, alarm_keywords = ?, alarm_min_score = ?, sources = ?, excludes = ?,
is_active = ? exclude_companies = ?, is_active = ?
WHERE id = ?`, WHERE id = ?`,
[...agentValues(agent), Number(req.params.id)] [...agentValues(agent), Number(req.params.id)]
); );
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
await dropExcludedMatches(Number(req.params.id), agent); 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)]); const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
res.json({ agent: agentOut(row) }); res.json({ agent: agentOut(row) });
} catch (err) { } catch (err) {
+5 -3
View File
@@ -67,7 +67,8 @@ export function isExcludedCompany(agent, company) {
/** /**
* @returns {{ score: number, hits: string[], alarm: boolean } | null} * @returns {{ score: number, hits: string[], alarm: boolean } | null}
* null = ausgeschlossen oder kein einziges Schlagwort gefunden. * 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) { export function scoreJob(agent, job) {
const title = job.title ?? ''; const title = job.title ?? '';
@@ -93,10 +94,11 @@ export function scoreJob(agent, job) {
if (hits.length === 0) return null; if (hits.length === 0) return null;
const alarmSet = new Set((agent.alarmKeywords ?? []).map((term) => term.toLowerCase())); const alarmSet = new Set((agent.alarmKeywords ?? []).map((term) => term.toLowerCase()));
const score = Math.round((100 * points) / (2 * keywords.length));
return { return {
score: Math.round((100 * points) / (2 * keywords.length)), score,
hits, hits,
alarm: hits.some((hit) => alarmSet.has(hit.toLowerCase())), alarm: score >= (agent.alarmMinScore ?? 0) && hits.some((hit) => alarmSet.has(hit.toLowerCase())),
}; };
} }
+1
View File
@@ -151,6 +151,7 @@ async function loadActiveAgents() {
id: row.id, id: row.id,
keywords: parseList(row.keywords), keywords: parseList(row.keywords),
alarmKeywords: parseList(row.alarm_keywords), alarmKeywords: parseList(row.alarm_keywords),
alarmMinScore: Number(row.alarm_min_score ?? 0),
sources: parseList(row.sources), sources: parseList(row.sources),
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
excludeCompanies: parseList(row.exclude_companies), excludeCompanies: parseList(row.exclude_companies),
+2
View File
@@ -477,6 +477,8 @@ CREATE TABLE IF NOT EXISTS icon_scanner_agents (
-- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt, -- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt,
-- ein Fund meldet sich zusaetzlich mit Ton und Pop-up. -- ein Fund meldet sich zusaetzlich mit Ton und Pop-up.
alarm_keywords TEXT NULL, 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"]). -- Unternehmen, deren Anzeigen dieser Agent nie zeigt (z. B. ["Hays"]).
exclude_companies TEXT NULL, exclude_companies TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
+24 -3
View File
@@ -29,6 +29,7 @@ const EMPTY: ScannerAgentInput = {
excludes: [], excludes: [],
exclude_companies: [], exclude_companies: [],
alarm_keywords: [], alarm_keywords: [],
alarm_min_score: 0,
is_active: true, is_active: true,
}; };
@@ -150,6 +151,7 @@ export const ScannerAgents: React.FC = () => {
excludes: agent.excludes, excludes: agent.excludes,
exclude_companies: agent.exclude_companies, exclude_companies: agent.exclude_companies,
alarm_keywords: agent.alarm_keywords, alarm_keywords: agent.alarm_keywords,
alarm_min_score: agent.alarm_min_score,
is_active: agent.is_active, is_active: agent.is_active,
}, },
}) })
@@ -175,7 +177,7 @@ export const ScannerAgents: React.FC = () => {
))} ))}
</Row> </Row>
{agent.alarm_keywords.length > 0 && ( {agent.alarm_keywords.length > 0 && (
<Row label="Schlagwörter mit Alarm"> <Row label={`Schlagwörter mit Alarm${agent.alarm_min_score > 0 ? ` · ab ${agent.alarm_min_score} %` : ''}`}>
{agent.alarm_keywords.map((term) => ( {agent.alarm_keywords.map((term) => (
<span <span
key={term} key={term}
@@ -265,14 +267,33 @@ export const ScannerAgents: React.FC = () => {
<Field <Field
label="Schlagwörter mit Alarm" label="Schlagwörter mit Alarm"
hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM." hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden und erreicht der Treffer die Mindest-Quote, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM. 0 % = bei jedem Fund."
> >
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<TermInput <TermInput
value={value.alarm_keywords} value={value.alarm_keywords}
onChange={(alarm_keywords) => patch({ alarm_keywords })} onChange={(alarm_keywords) => patch({ alarm_keywords })}
placeholder="z. B. Interim CFO" placeholder="z. B. Dringend, Medizin"
tone="alarm" tone="alarm"
/> />
</div>
<label className="flex shrink-0 items-center gap-1.5 text-xs text-ink-500" title="Alarm erst ab dieser Quote">
ab
<input
type="number"
min={0}
max={100}
step={5}
value={value.alarm_min_score}
onChange={(event) =>
patch({ alarm_min_score: Math.min(100, Math.max(0, Math.round(Number(event.target.value) || 0))) })
}
className={`${inputClass} w-20 text-right`}
/>
%
</label>
</div>
</Field> </Field>
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert."> <Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
+3 -1
View File
@@ -483,6 +483,8 @@ export interface ScannerAgent {
exclude_companies: string[]; exclude_companies: string[];
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */ /** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
alarm_keywords: string[]; alarm_keywords: string[];
/** Alarm erst ab dieser Quote (0–100 %); 0 = bei jedem Fund. */
alarm_min_score: number;
is_active: boolean; is_active: boolean;
matches: number; matches: number;
} }
@@ -570,7 +572,7 @@ export interface ScannerSecrets {
export type ScannerAgentInput = Pick< export type ScannerAgentInput = Pick<
ScannerAgent, 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 = <T>(path: string, body: unknown) => const put = <T>(path: string, body: unknown) =>