feat(scanner): Funde mit Akquise und Opportunities abgleichen

Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den
Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14)
verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt
Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner,
Neupruefung von Hand im Detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 17:12:17 +02:00
co-authored by Claude Opus 5
parent 87ca52a34b
commit af3ca33743
15 changed files with 549 additions and 10 deletions
+30
View File
@@ -45,6 +45,36 @@ const Project = sequelize.define('Project', {
allowNull: true,
field: 'published_at',
},
// Abgleich mit Akquise und Opportunities (services/appliedCheck.js). Die
// Spalten zieht initScannerDatabase() per ALTER TABLE nach.
alreadyApplied: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'already_applied',
},
appliedRefs: {
type: DataTypes.TEXT,
allowNull: true,
field: 'applied_refs',
comment: 'JSON array: [{ type, id, title, consultant, reason }]',
get() {
const rawValue = this.getDataValue('appliedRefs');
try {
return rawValue ? JSON.parse(rawValue) : [];
} catch {
return [];
}
},
set(value) {
this.setDataValue('appliedRefs', value ? JSON.stringify(value) : null);
},
},
appliedCheckAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'applied_check_at',
},
}, {
tableName: 'projects',
indexes: [
+14 -1
View File
@@ -19,6 +19,17 @@ const initScannerDatabase = async () => {
await sequelize.sync();
console.log('[Scanner] Tabellen synchronisiert.');
// Spalten, die nach dem ersten Anlegen dazukamen. sync() ohne alter legt
// sie in bestehenden Tabellen nicht an; MariaDB kennt IF NOT EXISTS.
const columnMigrations = [
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS already_applied TINYINT(1) NOT NULL DEFAULT 0',
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_refs TEXT NULL',
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_check_at DATETIME NULL',
];
for (const statement of columnMigrations) {
await sequelize.query(statement);
}
// Ensure every supported portal exists. Per-row findOrCreate so newly added
// portals appear after upgrade without manual SQL.
const defaultPortals = [
@@ -56,7 +67,9 @@ const initScannerDatabase = async () => {
{ keyName: 'smtp_from', value: 'scanner@perinco.de' },
{ keyName: 'notification_email_recipient', value: '' },
{ keyName: 'match_threshold', value: '70' },
{ keyName: 'display_threshold', value: '50' }
{ keyName: 'display_threshold', value: '50' },
{ keyName: 'applied_check_active', value: 'true' },
{ keyName: 'applied_check_days', value: '14' }
];
for (const s of defaultSettings) {
+188
View File
@@ -0,0 +1,188 @@
import { OpenAI } from 'openai';
import { pool } from '../../db.js';
import Setting from '../models/Setting.js';
/*
* Abgleich "bereits beworben?".
*
* Findet der Scanner ein Projekt, prueft dieser Dienst, ob es in der Akquise
* oder bei den Opportunities des CRM schon auftaucht - dieselbe Ausschreibung
* kommt oft ueber mehrere Boersen oder Vermittler, mit anderem Titel und
* anderem Link. Ein reiner Textvergleich findet das nicht, deshalb entscheidet
* ein Sprachmodell.
*
* Geprueft wird einmal je Projekt (`applied_check_at`), und nur fuer Projekte
* mit mindestens einem Treffer - alles andere zeigt die Oberflaeche nie an.
*
* Einstellungen (Scanner-Tabelle `settings`):
* applied_check_active 'true' | 'false'
* applied_check_days Zeitraum in Tagen, Vorgabe 14
*/
export const APPLIED_CHECK_MODEL = 'gpt-5.6-luna';
export const APPLIED_CHECK_DEFAULT_DAYS = 14;
const MAX_DAYS = 365;
/** Mehr Kandidaten schicken wir nicht - bei 14 Tagen sind es in der Praxis wenige Dutzend. */
const MAX_CANDIDATES = 300;
const clip = (text, length) => {
const value = String(text ?? '').replace(/\s+/g, ' ').trim();
return value.length > length ? `${value.slice(0, length)}…` : value;
};
/** Link ohne Protokoll, www, Query und Slash am Ende - reicht fuer "gleiche Ausschreibung". */
const normalizeUrl = (url) =>
String(url ?? '')
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/[?#].*$/, '')
.replace(/\/+$/, '');
export async function appliedCheckSettings() {
const active = (await Setting.getVal('applied_check_active', 'true')) !== 'false';
const parsed = Number.parseInt(await Setting.getVal('applied_check_days', ''), 10);
const days = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_DAYS) : APPLIED_CHECK_DEFAULT_DAYS;
return { active, days };
}
/** Akquise-Eintraege und Opportunities der letzten `days` Tage, ohne Papierkorb. */
async function loadCandidates(days) {
const [acquisitions] = await pool.query(
`SELECT id, found_at, title, company, provider, sender, consultant, link, description
FROM crm_acquisitions
WHERE deleted_at IS NULL AND found_at >= CURDATE() - INTERVAL ? DAY
ORDER BY found_at DESC
LIMIT ?`,
[days, MAX_CANDIDATES]
);
const [opportunities] = await pool.query(
`SELECT id, created_at, title, company, consultant, stage, description
FROM crm_opportunities
WHERE deleted_at IS NULL AND created_at >= NOW() - INTERVAL ? DAY
ORDER BY created_at DESC
LIMIT ?`,
[days, MAX_CANDIDATES]
);
return [
...acquisitions.map((row) => ({
type: 'acquisition',
id: row.id,
title: row.title,
company: row.company,
provider: row.provider || row.sender,
consultant: row.consultant,
link: row.link,
date: row.found_at,
description: row.description,
})),
...opportunities.map((row) => ({
type: 'opportunity',
id: row.id,
title: row.title,
company: row.company,
consultant: row.consultant,
stage: row.stage,
date: row.created_at,
description: row.description,
})),
];
}
const toRef = (candidate, reason) => ({
type: candidate.type,
id: candidate.id,
title: candidate.title,
consultant: candidate.consultant || null,
reason,
});
async function askModel(project, candidates) {
const apiKey = (await Setting.getVal('openai_api_key')) || process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error('OpenAI API Key is not configured.');
const openai = new OpenAI({ apiKey });
const system = `Du pruefst, ob eine neu gefundene Projektausschreibung bereits im CRM erfasst ist.
Dieselbe Ausschreibung erscheint oft auf mehreren Portalen oder bei mehreren Vermittlern - mit anderem Titel, anderem Link oder gekuerzter Beschreibung.
Ein Kandidat gilt nur dann als dasselbe Projekt, wenn Aufgabe, Rolle und die wesentlichen Rahmendaten (Technologien, Branche/Kunde, Ort, Zeitraum) klar uebereinstimmen.
Aehnliche Rolle bei anderem Kunden oder anderem Inhalt ist KEIN Treffer. Im Zweifel kein Treffer.
Antworte ausschliesslich als JSON:
{ "matches": [ { "type": "acquisition" | "opportunity", "id": 123, "reason": "kurze Begruendung auf Deutsch" } ] }
Keine Treffer: { "matches": [] }`;
const user = `### NEU GEFUNDENES PROJEKT
Titel: ${clip(project.title, 300)}
Portal: ${project.portal}
Firma: ${clip(project.company, 200) || '-'}
Ort: ${clip(project.location, 200) || '-'}
Rate: ${clip(project.rate, 100) || '-'}
Beschreibung: ${clip(project.description, 4000) || '-'}
### KANDIDATEN IM CRM
${JSON.stringify(
candidates.map((c) => ({
type: c.type,
id: c.id,
title: clip(c.title, 200),
company: c.company || undefined,
provider: c.provider || undefined,
stage: c.stage || undefined,
link: c.link || undefined,
description: clip(c.description, 500) || undefined,
}))
)}`;
const response = await openai.chat.completions.create({
model: APPLIED_CHECK_MODEL,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
reasoning_effort: 'low',
});
const parsed = JSON.parse(response.choices[0]?.message?.content || '{}');
return Array.isArray(parsed.matches) ? parsed.matches : [];
}
/**
* Prueft ein Projekt und speichert das Ergebnis am Projekt.
* Wirft bei KI- oder Datenbankfehlern; das Projekt bleibt dann ungeprueft und
* kommt beim naechsten Lauf wieder dran.
*
* @param {import('sequelize').Model} project
* @param {{ days: number }} options
*/
export async function checkProjectAlreadyApplied(project, { days }) {
const candidates = await loadCandidates(days);
let refs = [];
if (candidates.length > 0) {
// Gleicher Link ist eindeutig - dafuer braucht es kein Modell.
const projectUrl = normalizeUrl(project.url);
refs = candidates
.filter((c) => c.link && projectUrl && normalizeUrl(c.link) === projectUrl)
.map((c) => toRef(c, 'Gleicher Link zur Ausschreibung.'));
if (refs.length === 0) {
const byKey = new Map(candidates.map((c) => [`${c.type}:${c.id}`, c]));
for (const hit of await askModel(project, candidates)) {
const candidate = byKey.get(`${hit?.type}:${Number(hit?.id)}`);
// Nur Ids, die wir auch geschickt haben - ein erfundener Treffer zaehlt nicht.
if (candidate) refs.push(toRef(candidate, clip(hit.reason, 300)));
}
}
}
project.alreadyApplied = refs.length > 0;
project.appliedRefs = refs;
project.appliedCheckAt = new Date();
await project.save();
return refs;
}
+26
View File
@@ -1,6 +1,7 @@
import { Agent, Portal, Project, Match, Setting } from '../models/index.js';
import { scrapeFreelancermap, scrapeFreelanceDe, scrapeHays, scrapeEtengo, scrapeSThreePortal, SThreePortals } from './scraper.js';
import { evaluateMatch } from './matchingEngine.js';
import { appliedCheckSettings, checkProjectAlreadyApplied } from './appliedCheck.js';
import nodemailer from 'nodemailer';
// Helper to encrypt/decrypt credentials if needed, or send email notifications
@@ -236,6 +237,13 @@ const executePipeline = async () => {
const thresholdStr = await Setting.getVal('match_threshold', '70');
const matchThreshold = parseInt(thresholdStr, 10) || 70;
// Abgleich "bereits beworben?" - einmal je Lauf gelesen. Scheitert die KI
// einmal (Schluessel, Quote, Netz), bleibt er fuer den Rest des Laufs aus,
// statt jedes Projekt mit demselben Fehler zu versuchen.
const appliedCheck = await appliedCheckSettings();
let appliedCheckAvailable = appliedCheck.active;
let appliedCheckedCount = 0;
for (const scrapedProj of scrapedProjects) {
try {
// Find or create project in DB
@@ -312,6 +320,23 @@ const executePipeline = async () => {
}
}
// Nur Projekte mit Treffer - nur die zeigt die Oberflaeche an.
if (appliedCheckAvailable && !project.appliedCheckAt) {
const hasMatch = await Match.count({ where: { projectId: project.id } });
if (hasMatch > 0) {
try {
const refs = await checkProjectAlreadyApplied(project, appliedCheck);
appliedCheckedCount++;
if (refs.length > 0) {
console.log(`[Abgleich] Projekt [${project.title}] bereits im CRM: ${refs.map((r) => `${r.type}#${r.id}`).join(', ')}`);
}
} catch (checkErr) {
appliedCheckAvailable = false;
console.error(`[Abgleich] fehlgeschlagen, fuer diesen Lauf ausgesetzt: ${checkErr.message}`);
}
}
}
} catch (dbErr) {
console.error('Error inserting project or creating match:', dbErr.message);
}
@@ -320,5 +345,6 @@ const executePipeline = async () => {
console.log(`--- PIPELINE COMPLETED ---`);
console.log(`Stored ${newProjectsStoredCount} new projects.`);
console.log(`Evaluated ${matchesEvaluatedCount} new matches.`);
console.log(`Checked ${appliedCheckedCount} projects against Akquise/Opportunities.`);
return true;
};