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:
co-authored by
Claude Opus 5.5
parent
090f745b0d
commit
e1b69fffbd
+26
-2
@@ -66,10 +66,12 @@ sequenceDiagram
|
|||||||
actor B as Benutzer
|
actor B as Benutzer
|
||||||
participant E as Express
|
participant E as Express
|
||||||
participant M as MariaDB
|
participant M as MariaDB
|
||||||
|
participant S as perinco_scanner
|
||||||
participant O as OpenAI
|
participant O as OpenAI
|
||||||
|
|
||||||
B->>E: POST /api/search/ai { question }
|
B->>E: POST /api/search/ai { question }
|
||||||
E->>M: Bestand lesen (Kontakte, Opportunities, Akquise, Termine)
|
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->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen
|
||||||
E->>O: Systemprompt + Bestand + Frage
|
E->>O: Systemprompt + Bestand + Frage
|
||||||
O-->>E: { answer, refs }
|
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.
|
Antwort das dazu.
|
||||||
|
|
||||||
Die genannten Datensätze kommen als `refs` zurück und stehen unter der Antwort
|
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:
|
werden:
|
||||||
|
|
||||||
* `daily_rate` ist ein **Tagessatz**; `null` heißt *nicht hinterlegt*, nicht
|
* `daily_rate` ist ein **Tagessatz**; `null` heißt *nicht hinterlegt*, nicht
|
||||||
*null Euro*. Verglichen wird nur, wo ein Wert steht.
|
*null Euro*. Verglichen wird nur, wo ein Wert steht.
|
||||||
* Kontakte mit `mail_pool` sind noch nicht gepflegt – sie zählen mit, werden
|
* Kontakte mit `mail_pool` sind noch nicht gepflegt – sie zählen mit, werden
|
||||||
aber als aus dem Postfach stammend benannt.
|
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_contacts` sind Kontakte des ICON CRM. Ihre Kennung ist eine Zeichenkette
|
||||||
(`icon-31`) und gehört **nicht** in `refs`.
|
(`icon-31`) und gehört **nicht** in `refs`.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { pool } from '../db.js';
|
|||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { askModelJson, aiConfigured, modelFor } from '../ai.js';
|
import { askModelJson, aiConfigured, modelFor } from '../ai.js';
|
||||||
import { fetchIconContacts } from './iconCrm.js';
|
import { fetchIconContacts } from './iconCrm.js';
|
||||||
|
import { sequelize as scannerDb, Setting as ScannerSetting } from '../scanner/models/index.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
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.
|
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:
|
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:
|
Regeln:
|
||||||
- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung.
|
- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung.
|
||||||
- Stütze dich AUSSCHLIESSLICH auf die übergebenen Daten. Nichts hinzuerfinden, nicht schätzen.
|
- 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.
|
- "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.
|
- "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.
|
- "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) => {
|
router.post('/ai', async (req, res, next) => {
|
||||||
const question = String(req.body?.question || '').trim().slice(0, 2000);
|
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.
|
// "dazu steht nichts im Bestand", obwohl die Person in der Liste steht.
|
||||||
const icon = await fetchIconContacts();
|
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
|
(list) => list.length >= AI_LIMIT
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -220,6 +224,7 @@ router.post('/ai', async (req, res, next) => {
|
|||||||
opportunities,
|
opportunities,
|
||||||
acquisitions,
|
acquisitions,
|
||||||
events,
|
events,
|
||||||
|
scanner_matches: scannerMatches,
|
||||||
})}`,
|
})}`,
|
||||||
maxTokens: 4000,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ import { useNavigation } from '../lib/navigation';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/** Was ein Treffer öffnet - null heißt: nur anzeigen. */
|
/** 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<string, Target> = {
|
const OPENABLE: Record<string, Target> = {
|
||||||
lead: 'lead',
|
lead: 'lead',
|
||||||
opportunity: 'opportunity',
|
opportunity: 'opportunity',
|
||||||
acquisition: 'acquisition',
|
acquisition: 'acquisition',
|
||||||
event: 'event',
|
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
|
// Kontakte aus dem ICON CRM haben hier keine Karte - sie werden drüben
|
||||||
// gepflegt. Der Treffer zeigt sie, öffnet aber nichts.
|
// gepflegt. Der Treffer zeigt sie, öffnet aber nichts.
|
||||||
icon: null,
|
icon: null,
|
||||||
@@ -123,6 +125,7 @@ export const GlobalSearch: React.FC = () => {
|
|||||||
else if (target === 'opportunity') goTo('Opportunities', { opportunityId: Number(id) });
|
else if (target === 'opportunity') goTo('Opportunities', { opportunityId: Number(id) });
|
||||||
else if (target === 'acquisition') goTo('Akquise');
|
else if (target === 'acquisition') goTo('Akquise');
|
||||||
else if (target === 'event') goTo('Interviews');
|
else if (target === 'event') goTo('Interviews');
|
||||||
|
else if (target === 'scanner') goTo('Scanner');
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user