fix(suche): KI-Suche sieht Scanner-Treffer

Der Scanner liegt in einer eigenen Datenbank (perinco_scanner); die
KI-Suche las nur die crm_*-Tabellen und fand deshalb nichts zum Scanner.
Treffer ab Anzeigeschwelle gehen jetzt als scanner_matches mit, Refs vom
Typ "scanner" fuehren in die Scanner-Ansicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-23 11:59:49 +02:00
co-authored by Claude Opus 5.5
parent 090f745b0d
commit e1b69fffbd
3 changed files with 67 additions and 6 deletions
+37 -3
View File
@@ -3,6 +3,7 @@ import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { askModelJson, aiConfigured, modelFor } from '../ai.js';
import { fetchIconContacts } from './iconCrm.js';
import { sequelize as scannerDb, Setting as ScannerSetting } from '../scanner/models/index.js';
const router = express.Router();
router.use(requireAuth);
@@ -143,7 +144,7 @@ const AI_LIMIT = 400;
const AI_SYSTEM = `Du beantwortest Fragen zum Datenbestand eines CRM für die Vermittlung von IT- und Management-Beratern.
Du bekommst den Bestand als JSON und eine Frage. Antworte ausschließlich mit JSON:
{ "answer": string, "refs": [{ "type": "lead"|"opportunity"|"acquisition"|"event", "id": number, "label": string }] }
{ "answer": string, "refs": [{ "type": "lead"|"opportunity"|"acquisition"|"event"|"scanner", "id": number, "label": string }] }
Regeln:
- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung.
- Stütze dich AUSSCHLIESSLICH auf die übergebenen Daten. Nichts hinzuerfinden, nicht schätzen.
@@ -153,7 +154,8 @@ Regeln:
- "leads" sind die Kontakte. "kind" ist die Art (Recruiter, Partner, Endkunde), "source" die Herkunft. Kontakte mit "mail_pool": true stehen erst im Vorbereich "Aus Mails" und sind noch nicht gepflegt - sie zählen mit, aber sag dazu, dass sie aus dem Postfach stammen.
- "opportunities" sind laufende Vermittlungen. "stage" ist die Phase, "daily_rate" ein TAGESsatz in Euro, "probability" die Wahrscheinlichkeit in Prozent. null heißt: nicht hinterlegt, nicht null Euro. Bei Fragen nach dem höchsten oder niedrigsten Satz nur Datensätze mit hinterlegtem Wert vergleichen und dazusagen, wie viele keinen haben.
- "acquisitions" sind eingegangene Projektangebote (Akquise). "converted_at" gesetzt heißt: daraus wurde eine Opportunity.
- "icon_contacts" sind Kontakte des Schwestersystems ICON CRM. Sie zählen mit, sind aber klar als solche zu benennen. Ihre "id" ist eine Zeichenkette ("icon-31") und gehört NICHT in refs.`;
- "icon_contacts" sind Kontakte des Schwestersystems ICON CRM. Sie zählen mit, sind aber klar als solche zu benennen. Ihre "id" ist eine Zeichenkette ("icon-31") und gehört NICHT in refs.
- "scanner_matches" sind Treffer des Projekt-Scanners: ein auf Portalen gefundenes Projekt ("project_title", "portal", "rate", "url"), bewertet für einen Suchagenten ("agent", meist ein Berater). "match_percentage" ist die Passung in Prozent, "status" der Bearbeitungsstand (new, reviewed, applied, rejected), "already_applied" heißt: dafür lief schon eine Bewerbung. Es sind nur Treffer ab der eingestellten Anzeigeschwelle enthalten. In refs mit type "scanner" und der "id" des Treffers.`;
router.post('/ai', async (req, res, next) => {
const question = String(req.body?.question || '').trim().slice(0, 2000);
@@ -198,7 +200,9 @@ router.post('/ai', async (req, res, next) => {
// "dazu steht nichts im Bestand", obwohl die Person in der Liste steht.
const icon = await fetchIconContacts();
const truncated = [leads, opportunities, acquisitions, events].some(
const scannerMatches = await loadScannerMatches();
const truncated = [leads, opportunities, acquisitions, events, scannerMatches].some(
(list) => list.length >= AI_LIMIT
);
@@ -220,6 +224,7 @@ router.post('/ai', async (req, res, next) => {
opportunities,
acquisitions,
events,
scanner_matches: scannerMatches,
})}`,
maxTokens: 4000,
});
@@ -236,4 +241,33 @@ router.post('/ai', async (req, res, next) => {
}
});
/*
* Die Treffer des Scanners liegen in einer eigenen Datenbank (siehe
* scanner/db/config.js) und waren deshalb lange unsichtbar fuer die KI-Suche.
* Mitgegeben wird, was die Scanner-Ansicht zeigt: Treffer ab der
* Anzeigeschwelle, ohne Projektbeschreibung - die ist lang und wuerde den
* Kontext sprengen. Faellt der Scanner aus, fehlt nur dieser Teil.
*/
async function loadScannerMatches() {
try {
const setting = await ScannerSetting.findOne({ where: { keyName: 'display_threshold' } });
const threshold = Number(setting?.value) || 0;
const [rows] = await scannerDb.query(
`SELECT m.id, m.match_percentage, m.status, m.created_at AS found_at,
a.name AS agent, p.title AS project_title, p.company, p.portal, p.location,
p.rate, p.published_at, p.already_applied, p.url
FROM matches m
JOIN projects p ON p.id = m.project_id
JOIN agents a ON a.id = m.agent_id
WHERE m.match_percentage >= ?
ORDER BY m.created_at DESC LIMIT ${AI_LIMIT}`,
{ replacements: [threshold] }
);
return rows.map((row) => ({ ...row, already_applied: Boolean(row.already_applied) }));
} catch (err) {
console.error('[Suche] Scanner-Treffer nicht lesbar:', err.message);
return [];
}
}
export default router;