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:
co-authored by
Claude Opus 5
parent
87ca52a34b
commit
af3ca33743
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user