From e1b69fffbdb5c2e1c735d5b7dd7166db3b9bcd59 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Fri, 23 Oct 2026 11:59:49 +0200 Subject: [PATCH] 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 --- docs/12-suche.md | 28 +++++++++++++++++++++-- server/src/routes/search.js | 40 ++++++++++++++++++++++++++++++--- src/components/GlobalSearch.tsx | 5 ++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/12-suche.md b/docs/12-suche.md index 6f720f0..9eaba03 100644 --- a/docs/12-suche.md +++ b/docs/12-suche.md @@ -66,10 +66,12 @@ sequenceDiagram actor B as Benutzer participant E as Express participant M as MariaDB + participant S as perinco_scanner participant O as OpenAI B->>E: POST /api/search/ai { question } E->>M: Bestand lesen (Kontakte, Opportunities, Akquise, Termine) + E->>S: Scanner-Treffer lesen (ab Anzeigeschwelle) E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen E->>O: Systemprompt + Bestand + Frage O-->>E: { answer, refs } @@ -83,15 +85,37 @@ Bestand ist klein genug, um ihn vollständig mitzuliefern. Wird gekürzt, sagt d Antwort das dazu. Die genannten Datensätze kommen als `refs` zurück und stehen unter der Antwort -als anklickbare Schaltflächen. +als anklickbare Schaltflächen. Ein Scanner-Treffer (`type: "scanner"`) führt in +die Scanner-Ansicht. -Drei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch +### Scanner-Treffer + +Der Scanner hat eine **eigene Datenbank** (`perinco_scanner`, siehe +`server/src/scanner/db/config.js`). Bis Oktober 2026 las die erweiterte Suche +nur die `crm_*`-Tabellen und antwortete auf Fragen zum Scanner deshalb mit +„Dazu steht nichts im Bestand." Seitdem holt `loadScannerMatches()` die +Treffer dazu und gibt sie als `scanner_matches` mit: + +| Feld | Herkunft | +| --- | --- | +| `id`, `match_percentage`, `status`, `found_at` | `matches` | +| `agent` | `agents.name` | +| `project_title`, `company`, `portal`, `location`, `rate`, `published_at`, `already_applied`, `url` | `projects` | + +Mitgegeben wird nur, was auch die Scanner-Ansicht zeigt: Treffer ab der +Anzeigeschwelle (`display_threshold`), die neuesten zuerst, höchstens 400. Die +Projektbeschreibung bleibt draußen – sie ist lang und würde den Kontext +sprengen. Ist die Scanner-Datenbank nicht erreichbar, fehlt nur dieser Teil. + +Vier Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch werden: * `daily_rate` ist ein **Tagessatz**; `null` heißt *nicht hinterlegt*, nicht *null Euro*. Verglichen wird nur, wo ein Wert steht. * Kontakte mit `mail_pool` sind noch nicht gepflegt – sie zählen mit, werden aber als aus dem Postfach stammend benannt. +* `scanner_matches` sind Treffer des Projekt-Scanners; Bezug in `refs` mit + `type: "scanner"`. * `icon_contacts` sind Kontakte des ICON CRM. Ihre Kennung ist eine Zeichenkette (`icon-31`) und gehört **nicht** in `refs`. diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 42ebb29..2e71ab4 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -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; diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx index 0938571..21abc19 100644 --- a/src/components/GlobalSearch.tsx +++ b/src/components/GlobalSearch.tsx @@ -16,13 +16,15 @@ import { useNavigation } from '../lib/navigation'; */ /** Was ein Treffer öffnet - null heißt: nur anzeigen. */ -type Target = 'lead' | 'opportunity' | 'acquisition' | 'event' | null; +type Target = 'lead' | 'opportunity' | 'acquisition' | 'event' | 'scanner' | null; const OPENABLE: Record = { lead: 'lead', opportunity: 'opportunity', acquisition: 'acquisition', event: 'event', + // Scanner-Treffer nennt nur die KI-Suche; sie führen in die Scanner-Ansicht. + scanner: 'scanner', // Kontakte aus dem ICON CRM haben hier keine Karte - sie werden drüben // gepflegt. Der Treffer zeigt sie, öffnet aber nichts. icon: null, @@ -123,6 +125,7 @@ export const GlobalSearch: React.FC = () => { else if (target === 'opportunity') goTo('Opportunities', { opportunityId: Number(id) }); else if (target === 'acquisition') goTo('Akquise'); else if (target === 'event') goTo('Interviews'); + else if (target === 'scanner') goTo('Scanner'); setFocused(false); };