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:
co-authored by
Claude Opus 5.5
parent
1969d0b77a
commit
371512a47d
@@ -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
@@ -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` |
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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())),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user