Skip to content

Commit 100d404

Browse files
lucas19919claude
andauthored
feat(ai): evidence ledger and Impressum-first lead research (#3)
The agent's only research tool was a single GET with a few regexes, and whatever it produced went straight into the lead with no record of where it came from — including over a value the operator had typed. This gives it eyes and a memory of what it saw. Evidence ledger (api/src/facts.ts, new `lead_facts` table) Every machine-written value is now an observation carrying its source, what the source literally said, and a grade. Only a claim the business makes about itself fills an empty field; anything weaker, and anything disagreeing with a hand-set value, becomes a suggestion a human accepts or rejects. Provenance is derived rather than stored: a lead value counts as machine-set only while it still matches an applied fact, so imports and manual edits are untouchable. Fields the leads table has no column for (USt-IdNr., Handelsregister, Rechtsform, Geschäftsführung) live in the ledger and are there when the lead becomes a Kunde. Impressum-first research (api/src/ai/research.ts) Replaces fetch_website. Walks homepage -> Impressum and returns observations with source URLs. §5 DDG obliges every German business site to publish exactly the fields we want, which makes it a free primary source. probeTech now measures mobile_friendly, tech and staleness_signal off the markup instead of letting the model imagine them. The SSRF guard is shared and now also covers redirect targets. Built for the local model: create_lead({ research: true, analyze: true }) does the whole URL-to-qualified-lead path in one call, research_company records what it observed itself rather than making a small model chain six record_fact calls, params are flat and enum-constrained, and every response explains why it did what it did. MCP reaches all of it — research_company, list_lead_facts, review_facts in the read tier; research_lead, record_fact, resolve_fact in the reversible-write tier — over new routes under /api/ai and /api/leads. Two bugs fixed on the way: - analyzeLead overwrote a priority a human set by dragging a card, on every re-run. It now only revises a priority it set itself. score is left alone since it is the model's own fit metric and the importer seeds it. - decode() handled no German named entities, so `M&uuml;ller` would have been filed as a Firmenname. UI: a Recherche & Herkunft panel in the lead drawer with the review queue and a provenance trail. vite.config.ts takes an API_URL override so the dev server can point at an API on another port; the default is unchanged. 195 api + 17 cli tests pass, including HTTP integration over the Bearer token path MCP uses. Verified in a running instance: grading correct over the wire, accept wrote through, reject left the field alone, unreachable site degraded cleanly. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 15ba14e commit 100d404

22 files changed

Lines changed: 2110 additions & 60 deletions

api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"import": "tsx scripts/import-xlsx.ts",
1010
"backup": "tsx scripts/backup.ts",
1111
"restore": "tsx scripts/restore.ts",
12-
"test": "node --import tsx --test --test-concurrency=1 src/documents.test.ts src/pdf.test.ts src/validate.test.ts src/facturx.test.ts src/mailer.test.ts src/export.test.ts src/dsgvo.test.ts src/payments.test.ts src/expenses.test.ts src/subscriptions.test.ts src/backup.test.ts src/recurring.test.ts src/contracts.test.ts src/catalog.test.ts src/dashboard.test.ts src/customers.test.ts src/storno.test.ts src/report.test.ts src/leads.test.ts src/leadIntel.test.ts src/ai-leadtools.test.ts src/ai-newtools.test.ts src/tokens.test.ts src/machine.test.ts"
12+
"test": "node --import tsx --test --test-concurrency=1 src/documents.test.ts src/pdf.test.ts src/validate.test.ts src/facturx.test.ts src/mailer.test.ts src/export.test.ts src/dsgvo.test.ts src/payments.test.ts src/expenses.test.ts src/subscriptions.test.ts src/backup.test.ts src/recurring.test.ts src/contracts.test.ts src/catalog.test.ts src/dashboard.test.ts src/customers.test.ts src/storno.test.ts src/report.test.ts src/leads.test.ts src/leadIntel.test.ts src/facts.test.ts src/research.test.ts src/facts-routes.test.ts src/ai-leadtools.test.ts src/ai-newtools.test.ts src/tokens.test.ts src/machine.test.ts"
1313
},
1414
"dependencies": {
1515
"@hono/node-server": "^1.13.7",

api/src/ai/leadIntel.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db, type LeadRow, type LeadAiRow, type OutreachRow } from '../db'
22
import { getSettings } from '../documents'
33
import { audit } from '../audit'
44
import { chatJSON, AI } from './provider'
5+
import { ledgerOnlyFacts } from '../facts'
56
import { LEAD_ANALYST_SYSTEM, OUTREACH_SYSTEM, INVOICE_DRAFTER_SYSTEM } from './prompts'
67

78
function leadFacts(lead: LeadRow): string {
@@ -20,6 +21,11 @@ function leadFacts(lead: LeadRow): string {
2021
stage: lead.stage,
2122
notizen: lead.notes,
2223
}
24+
// Impressum-derived facts the leads table has no column for. They sharpen the
25+
// read considerably: knowing the Rechtsform and who signs tells the analyst
26+
// whether this is a one-person Betrieb or something with a Geschäftsführung.
27+
const extra = ledgerOnlyFacts(lead.id)
28+
if (Object.keys(extra).length) f.impressum = extra
2329
return JSON.stringify(f, null, 2)
2430
}
2531

@@ -41,8 +47,29 @@ const QUALIFICATION_PRIORITY: Record<string, string> = {
4147
disqualified: 'niedrig',
4248
}
4349

50+
/**
51+
* May the analysis still set `priority`, or has the operator taken it over?
52+
* Priority is the one ranking a human sets deliberately — by dragging a card —
53+
* so re-running the analysis must not quietly undo that. Derived the same way
54+
* the evidence ledger derives provenance: the value counts as machine-set while
55+
* it still matches what the machine last wrote, or sits untouched at its default.
56+
*
57+
* `score` deliberately gets no such protection: it is the model's own 0..100 fit
58+
* confidence (and the importer's staleness heuristic before that), not something
59+
* anyone curates by hand.
60+
*/
61+
function priorityIsMachineOwned(lead: LeadRow): boolean {
62+
if (lead.priority === 'mittel') return true // untouched default
63+
const prev = db.prepare('SELECT qualification FROM lead_ai WHERE lead_id = ?').get(lead.id) as
64+
| { qualification: string | null }
65+
| undefined
66+
const lastPriority = prev?.qualification ? QUALIFICATION_PRIORITY[prev.qualification] : undefined
67+
return !!lastPriority && lead.priority === lastPriority
68+
}
69+
4470
/** Run (or re-run) the AI assessment for a lead and cache it. */
4571
export async function analyzeLead(lead: LeadRow, actor: string): Promise<LeadAiRow> {
72+
const maySetPriority = priorityIsMachineOwned(lead)
4673
const a = await chatJSON<LeadAnalysis>(
4774
LEAD_ANALYST_SYSTEM,
4875
`Bewerte diesen Lead:\n\n${leadFacts(lead)}`,
@@ -73,10 +100,11 @@ export async function analyzeLead(lead: LeadRow, actor: string): Promise<LeadAiR
73100
// Let the AI assessment steer the pipeline board: the qualification sets the
74101
// priority (urgency) and the model's fit confidence (0..100) becomes the lead
75102
// score used to rank leads — so a freshly analysed lead no longer sits at 0.
103+
// ...but only where a human has not already made that call themselves.
76104
const mapped = a.qualification ? QUALIFICATION_PRIORITY[a.qualification] : undefined
77105
const sets: string[] = []
78106
const params: Record<string, string | number> = { id: lead.id }
79-
if (mapped && mapped !== lead.priority) {
107+
if (mapped && mapped !== lead.priority && maySetPriority) {
80108
sets.push('priority = @priority')
81109
params.priority = mapped
82110
}
@@ -88,7 +116,21 @@ export async function analyzeLead(lead: LeadRow, actor: string): Promise<LeadAiR
88116
sets.push("updated_at = datetime('now')")
89117
db.prepare(`UPDATE leads SET ${sets.join(', ')} WHERE id = @id`).run(params)
90118
}
91-
audit({ actor, action: 'ai.analyze_lead', entity: 'lead', entityId: lead.id, detail: { model: AI.model, qualification: a.qualification, priority: mapped, fit_score: fitScore } })
119+
audit({
120+
actor,
121+
action: 'ai.analyze_lead',
122+
entity: 'lead',
123+
entityId: lead.id,
124+
detail: {
125+
model: AI.model,
126+
qualification: a.qualification,
127+
fit_score: fitScore,
128+
priority: mapped,
129+
// Record when the verdict was deliberately *not* applied, so the trail
130+
// shows the human's ranking was respected rather than silently lost.
131+
priority_applied: !!params.priority,
132+
},
133+
})
92134
return db.prepare('SELECT * FROM lead_ai WHERE lead_id = ?').get(lead.id) as unknown as LeadAiRow
93135
}
94136

api/src/ai/prompts.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ Rechtliche Leitplanken (IMMER einhalten):
1414
- Erfinde keine Fakten über den Empfänger. Nutze nur, was in den Lead-Daten steht.
1515
`.trim()
1616

17+
// The one rule that decides whether the CRM stays trustworthy over time. Kept
18+
// short on purpose: it has to survive in the context of a small local model,
19+
// where every extra line pushes the actual task further down.
20+
export const EVIDENCE_RULES = `
21+
Umgang mit Fakten:
22+
- Schreibe nie einen Wert, den du nicht in einer Quelle gelesen hast. Ein leeres
23+
Feld ist besser als ein selbstbewusst falsches.
24+
- Belegte Beobachtungen hältst du mit \`record_fact\` fest — mit \`detail\`, also
25+
dem, was die Quelle wörtlich hergab. Erfinde niemals einen Beleg.
26+
- "primary" ist nur, was der Betrieb selbst über sich sagt (Impressum, Signatur,
27+
eigene Antwort). Suchtreffer und Erwähnungen Dritter sind "supporting".
28+
- Von Hand gesetzte Werte überschreibt das System nicht. Weicht deine Quelle ab,
29+
wird daraus ein Vorschlag zur Prüfung — das ist der gewollte Ausgang, kein
30+
Fehler. Versuche nicht, ihn zu umgehen.
31+
- Woher ein gespeicherter Wert stammt, zeigt \`list_facts\`. Prüfe das, bevor du
32+
einem Feld vertraust oder es infrage stellst.
33+
`.trim()
34+
1735
export const COPILOT_SYSTEM = `
1836
Du bist der KI-Kern von OpenLeads — einer selbst gehosteten Vertriebs- und
1937
Rechnungs-Suite. Der Betrieb, für den du arbeitest, ist eine Webagentur, die
@@ -45,12 +63,14 @@ Arbeitsweise:
4563
- „Vertrag" meint ein Vertragsdokument (\`create_contract\`), nicht die Pipeline.
4664
Frische Verträge sind Entwürfe; finalisieren (Nummer + AGB einfrieren) nur auf
4765
ausdrücklichen Wunsch und nach Klartext-Bestätigung.
48-
- Soll aus einer oder mehreren URLs ein Lead entstehen, lege ihn direkt an: pro
49-
URL einmal \`fetch_website\` (Firma/Kontakt auslesen), dann \`create_lead\`. Frage
50-
NICHT nach Firma/Ort/Gewerk, wenn die Website sie liefert — nur \`website\` ist
51-
Pflicht. Bewerte neue Leads standardmäßig voll mit (\`create_lead\` mit
52-
\`analyze: true\`): das qualifiziert den Lead und setzt die Priorität aus dem
53-
Ergebnis, statt sie auf „mittel“ zu lassen.
66+
- Soll aus einer oder mehreren URLs ein Lead entstehen, genügt PRO URL EIN
67+
Aufruf: \`create_lead({ website, research: true, analyze: true })\`. Das wertet
68+
Startseite und Impressum aus, füllt Firma/Ort/Kontakt/Technik belegt und
69+
bewertet den Lead anschließend. Frage NICHT nach Firma, Ort oder Gewerk — das
70+
Impressum liefert sie. Nur \`website\` ist Pflicht.
71+
- Willst du eine Seite nur ansehen, ohne etwas zu speichern, nimm
72+
\`research_company\`. Einen bereits angelegten Lead recherchierst du mit
73+
\`research_lead\` nach.
5474
- „Tab“, „Spalte“, „Section“, „Phase“ oder „Stage“ meinen die Pipeline-Stage (die
5575
gültigen Werte stehen im Tool-Schema von \`stage\`). Soll ein Lead in eine
5676
bestimmte Spalte (z. B. „ins Angebot“), setze beim Anlegen \`stage\` bzw. nutze
@@ -76,6 +96,8 @@ Arbeitsweise:
7696
- Geldbeträge sind in Cent (Ganzzahl) gespeichert; rechne sauber.
7797
- Wenn Daten fehlen, frage gezielt nach statt zu raten.
7898
99+
${EVIDENCE_RULES}
100+
79101
${COMPLIANCE_GUARDRAILS}
80102
`.trim()
81103

0 commit comments

Comments
 (0)