1849 lines
193 KiB
Markdown
1849 lines
193 KiB
Markdown
# Workers P6: Etage „Ohne Website“ und Kaltmail – Umsetzungsplan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Die vier Branchen-Scouts finden jede Nacht 10 neue Betriebe ohne oder mit veralteter Website, Bea wählt über alle Räume die besten 3 für eine automatische Kaltmail (Max), der Rest landet mit Grund in der Bot-Lead-Liste, und eine Mail geht erst raus, wenn der Datenschutz-Abschnitt „Interessenten“ live ist.
|
||
|
||
**Architecture:** Im Workers-Dienst (`knso/workers-dienst`) kommen Module unter `src/scouts/`, `src/scoring/`, `src/archive/`, `src/cold/`, `src/mail/` und `src/api/` dazu; ein Fassadenmodul `src/ohneWebsite.js` hängt sie über die Registries von P1/P3/P4 ein (Tick-Einträge, `routes`, `deps.drafters`, `registerGateCheck`). P1s `planNight` plant weiter die Suchaufträge der Scouts (Raum und Region); P6 liefert dazu den Läufer (Gewerk × Ort aus dem Suchprotokoll), die Top-3-Auswahl, die Entwürfe für P4s Sende-Tick (`erstmail`, `nachfass`) und die Zusatzprüfung am Sende-Tor. Google Places liefert live nur Kandidaten (dauerhaft bleibt nur die `place_id`), Anschrift, Telefon und Mail stammen aus der eigenen Website des Betriebs (Impressum, über `claude.search`) oder OpenStreetMap. WOMS bekommt den Reiter „Bot-Leads“; das Portal bekommt den neuen Datenschutz-Abschnitt 11, webklar.com die Weiterleitung mit Anker.
|
||
|
||
**Tech Stack:** Node 22 ESM (Dienst, keine Semikolons), `node:test`, `node-appwrite@14.1.0`, `@anthropic-ai/sdk@0.135.0` (über `src/claude.js`), Google Places API (New), Overpass API; React 18 + Vite (WOMS); Portal-Tests mit `node:test`; Vitest (webklar.com).
|
||
|
||
**Spec:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · **Schnittstellen:** /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)
|
||
|
||
## Global Constraints
|
||
|
||
- Alle Regeln aus Übersicht §2 gelten.
|
||
- Anschluss an die Nachbarpläne (maßgeblich, wo die Übersicht schweigt; P1-E3/E4/E7/E8, P3-E1/E2/E3/E5/E6/E8, P4-E1/E2/E3/E5): `deps = { db, clock, log, claude, notify, config, mailer, imap, imapState, drafters, … }`; `deps.clock.now()` liefert **Millisekunden**, Funktionen mit Parameter `now` erhalten `nowOf(deps)` (ein `Date`, aus P4s `src/gespraeche/util.js`, wie `getDoc` und `parsePayload`); `db.get` liefert `null`; Claude wird nur als `deps.claude.ask(deps, opts)` bzw. `deps.claude.search(deps, opts)` aufgerufen (hier über `llmOf`, T02); Aufträge sind P1-Jobs `{ id, kind, workerId, prospectId, threadId, attempts, payload }` (nicht `$id`/`payloadJson`; rohe Dokumente nur in Seeds); Datenbankabfragen nur mit `Query.equal/orderAsc/orderDesc/limit`, alles Weitere im Code filtern (P3-E2; P1s Fake kennt nicht mehr); `workersDaily` schreibt allein `book`/`updateDaily` (P1-E4); die Suchaufträge der Scouts plant P1s `planNight` (Payload `{ night, run, room, region, candidates, entrance }`); Mails an Interessenten versendet allein P4s Tor im Tick `senden` (Aufträge `erstmail`/`nachfass`, Text aus `deps.drafters`), Zusatzprüfungen hängen über `registerGateCheck` ein; den Schalter `settings.privacyInteressentenLive` legt P3 an (P3-E8) und er ist der einzige Datenschutz-Schalter.
|
||
- Arbeitsorte: Dienst `/home/webklar/apps/workers-dev` Branch `p6-ohne-website` (auf dem Stand, der P1–P4 enthält); WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers` Branch `feature/workers`; Portal-Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz` Branch `feature/workers-datenschutz` von `origin/main`; Website-Worktree `/home/webklar/apps/webklar.com/dev-workers-ds` Branch `fix/datenschutz-anker` von `origin/main`.
|
||
- Kein Agent pusht, deployt, startet Container oder ruft in Tests Google, Anthropic, Overpass, DNS, SMTP oder Appwrite auf (nur Fakes); Live-Prüfungen stehen ausschließlich in der Betriebsanleitung.
|
||
- Places: `POST https://places.googleapis.com/v1/places:searchText`, Feldmaske exakt `places.id,places.displayName,places.websiteUri,places.rating,places.userRatingCount,places.businessStatus,nextPageToken`, `pageSize` 20, höchstens 3 Seiten je Lauf, 0,035 $ je Anfrage; dauerhaft nur `place_id` (kein Name, keine Bewertungszahl, keine Adresse, kein Telefon aus Places).
|
||
- Overpass: höchstens 12 Anfragen je Lauf, mindestens 1 500 ms Abstand, nach dem ersten Fehler (429, 5xx, Timeout) keine weitere Anfrage im Lauf, nie E-Mail-Tags übernehmen.
|
||
- Eigene Website: nur über `search(...)` mit `allowedDomains` = Domain und `www.`-Variante, `maxUses` 4 (der Domainfilter gilt für Suche **und** Abruf, T02), keine Social- und Verzeichnis-Hosts (`normalizeDomain` aus P1-T17 liefert für sie `''`), `url_not_allowed` ⇒ überspringen; E-Mail nur wörtlich im abgerufenen Text einer Seite der eigenen Domain, nie vom Modell geliefert, keine Entschleierung („[at]“).
|
||
- Lauf: ein Suchauftrag je Nacht von P1s `planNight` (`runsPerNight`), `candidatesPerRun` = 10 frische Kandidaten, höchstens 20 Minuten; ein Kandidat wird erst unmittelbar vor seiner Verarbeitung geclaimt, vor jedem teuren Schritt.
|
||
- Heilberufe (Raum `aerzte` oder Gewerk in `GESUNDHEITS_GEWERKE`): `isHealth=true`, Telefonnummer wird nirgends gespeichert und nie ausgeliefert.
|
||
- Punkte (100) = Lücke 35 + Bewertungsanzahl 20 + Bewertungsnote 10 + Sympathie 10 + keine Kette 10 + Mail 15; Mail-Platz nur mit Mail, ohne Kette, Website fehlend oder Audit ≤ 5 (`MAX_AUDIT_FOR_MAIL`) und Punkten ≥ 55 (`MIN_SCORE_MAIL`).
|
||
- Mail-Plätze je Sendetag = `mailColdPerDay` (3) − fällige Nachfass-Mails − offene Erstmail-Aufträge − heute Gesendetes; Top-3-Auswahl um 05:30 Berlin über alle Räume der letzten 72 Stunden.
|
||
- Kaltmail nur über P4s Tor (`sendMail(... cold:true)`, ausgelöst vom Tick `senden`): Anrede „Sie“, Fließtext ohne Link, KI-Hinweis als erster Satz, Angebotssatz wörtlich aus `settings.offerText`, Pflichtblock aus `buildColdFooter` (für Erst- und Nachfass-Mail) mit genau drei Adressen (Quelle, `https://www.webklar.com/datenschutz#interessenten`, Abmelde-Link), nur bei `privacyInteressentenLive=true`; die Tagesgrenze setzt das Tor durch (P4), P6 reserviert nur die Plätze (T04, T13).
|
||
- Nachfass: höchstens 1 je Betrieb, `followupDays` (7) nach der Erstmail (Termin und Flag `followupSent` setzt P4s `onOutbound`), höchstens ein Auftrag `nachfass` je Faden in beliebigem Status, Text nur aus Code.
|
||
- Aufbewahrung: `purgeAfter = foundAt + retentionDays` (90; Einstellung darf 90 nicht übersteigen), Absage oder Widerspruch 30 Tage, ruhende Unterhaltung ohne Kunde 183 Tage; `workersKeys` und `workersSuppression` werden nie angefasst.
|
||
- Ereignistexte, Suchprotokoll und Aufträge enthalten nie Namen, Mail-Adressen oder Telefonnummern von Betrieben.
|
||
- Kein Schreibzugriff auf `customers`, `workorders`, `websiteProjects`, `customerPortalAccess` (Übersicht §2.13); der Integrationstest (P6-T24) prüft die Schreibliste.
|
||
- Commit-Nachrichten enden mit der Zeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||
|
||
## Review Focus
|
||
|
||
- **R1** Places liefert Name, Bewertungsnote, Bewertungsanzahl und Website → in keinem gespeicherten Dokument (Interessent, Ereignis, Auftrag, Suchprotokoll) steht davon etwas außer `placeId` und abgeleiteten Punkten. Test: P6-T09 (Baustein) und P6-T12 (ganzer Lauf).
|
||
- **R2** Das Impressum zeigt die Adresse nur verschleiert („info [at] …“), sie steht nur auf Facebook, `robots.txt` verbietet den Abruf, oder das Modell „findet“ eine Adresse, die nicht im abgerufenen Text steht → keine Mail, Bot-Lead mit Grund „keine veröffentlichte Mail“. Test: P6-T08 (Auslesen), P6-T10 (Grund), P6-T24 (ganzer Lauf).
|
||
- **R3** Eine Kaltmail wird als `cold:false` getarnt, `to` weicht von der gespeicherten veröffentlichten Adresse ab, die Quelle fehlt oder der Datenschutz-Schalter ist aus → das Gate verweigert, nichts geht raus. Test: P6-T15.
|
||
- **R4** Nachfass fällig + offene Erstmail-Aufträge + Wochenende, doppelter Top-3-Lauf, Absturz nach dem Senden → nie mehr als `mailColdPerDay` je Sendetag, kein Rückstau, Nachfass genau einmal. Test: P6-T04 (Plätze), P6-T13 (Auswahl), P6-T16 (ein Nachfass-Auftrag je Faden, auch nach Ende des Auftrags), P4-T08 (`idempotencyKey`: Versand je Auftrag höchstens einmal, auch nach Absturz), P6-T24 (ganze Woche).
|
||
- **R5** Heilberuf (Raum `aerzte` oder „Physiotherapie“ im Raum `selbst`) mit Telefonnummer im Impressum oder bei OSM → die Nummer wird weder gespeichert noch über die API ausgeliefert, auch nicht aus einem Altbestand. Test: P6-T09 und P6-T18.
|
||
|
||
## Ergänzung zur Übersicht
|
||
|
||
Diese Punkte fehlen in der Übersicht und gelten für P6; sie sind in dieser Form umzusetzen und der Übersicht nachzutragen. Wo P1–P4 sie schon festlegen, ist das vermerkt (P6 prüft dann nur).
|
||
|
||
| Nr. | Ergänzung | Umgesetzt in |
|
||
|---|---|---|
|
||
| E1 | `workersSettings.privacyInteressentenLive` Boolean, **nicht** Pflicht, Standard `false` legt P3 an (P3-E8: Schema, `DEFAULTS`, Prüfung, Skript `datenschutz-schalter.mjs`); P6 prüft das nur. `updateSettings` lehnt `retentionDays > 90` ab (der Datenschutztext verspricht höchstens 90 Tage; P1 erlaubt bis 365). | P6-T01 |
|
||
| E2 | Keine neuen Indizes. Genutzt werden `workersThreads.idx_status` (P1-E1), `workersJobs.idx_kind_status` (P1-E1), der Index auf `workersMessages.threadId` (P3-E9) und die Indizes aus Übersicht §4. | P6-T01 |
|
||
| E3 | `search(deps, opts)` liefert zusätzlich `pages: [{ url, text }]` (Klartext der abgerufenen Seiten, je höchstens 60 000 Zeichen, nur Textquellen); Abruffehler (z. B. `url_not_allowed`) stehen schon in P1s `toolErrors: [{ tool, code, url? }]`. `allowedDomains`/`blockedDomains` gelten nicht nur für `web_search`, sondern auch für `web_fetch`. | P6-T02 |
|
||
| E4 | `deps` bekommt `places` (`{ textSearch }`), `osm` (`{ lookup }`), `dns` (`{ resolveMx, resolve4 }`) und optional `llm` (`{ ask, search }`, Standard = `deps.claude`) als Prüfnaht. `src/main.js` baut `places` aus `config.places.apiKey`, `osm` mit `fetch`, `dns` aus `node:dns/promises` (`erweitereDeps`). | P6-T02, P6-T24 |
|
||
| E5 | Tick-Arten in `src/schedule.js`: `top3` täglich 05:30 Berlin, `kuerzen` täglich 03:00 (Tagesmarker wie die übrigen Ticks), `scouts` als Intervall von 2 Minuten (Muster `anfragen`, P3-E5). Die Einträge hängen als `tickJobs` an `buildTickJobs()` (P3-E5). | P6-T05, P6-T24 |
|
||
| E6 | Das Sende-Tor von P4 prüft bei `cold:true` zusätzlich, über `registerGateCheck`: Datenschutz-Schalter (Grund `datenschutz`, wie P4, vorübergehend), gespeicherte Adresse samt Quelle, Eingang und Status; `cold:false` an einen Betrieb der Etage „Ohne Website“ ohne eingegangene Antwort wird verweigert. `appendCompliance` bekommt den Zweig `cold:true` (P4-E3). Neue Verweigerungsgründe: `adresse_nicht_belegt`, `kein_kaltkontakt`, `kaltmail_ohne_antwort`, `status_unpassend`. | P6-T15 |
|
||
| E7 | API `GET /api/workers/botleads` nimmt zusätzlich `id=<prospectId>`; Ereignislink der Bot-Leads ist `/workers?tab=botleads&bot=<prospectId>` (Tab-Id `botleads`, P2-E7). Die Route hängt über die `routes`-Registry (P3-E6) ein. | P6-T18, P6-T19 |
|
||
| E8 | `deps.drafters.erstmail(deps, job)` und `deps.drafters.nachfass(deps, job)` → `{ subject, text }` liefert P6 (P4-E1); die Aufträge `erstmail` legt `selectNightTop3` an, die Aufträge `nachfass` legt `scheduleFollowups` an, versendet werden beide von P4s Tick `senden` (P4-E5). | P6-T16 |
|
||
|
||
## File Structure
|
||
|
||
**Dienst (`/home/webklar/apps/workers-dev`)**
|
||
|
||
| Datei | Zweck |
|
||
|---|---|
|
||
| `src/llm.js` | `llmOf(deps)`: Prüfnaht für `ask`/`search` (`deps.llm ?? deps.claude`) |
|
||
| `src/scouts/rooms.js` | vier Scout-Räume, Gewerke, Heilberufe |
|
||
| `src/scouts/regionen.js` | Orte je Bundesland für die Suchanfragen |
|
||
| `src/scouts/plan.js` | Abfragewahl (Gewerk × Ort) aus dem Suchprotokoll eines Raums; Raum und Region wählt P1s `planNight` |
|
||
| `src/scouts/places.js` | Places-Client (Text Search, feste Feldmaske) |
|
||
| `src/scouts/osm.js` | Overpass-Client, zurückhaltend |
|
||
| `src/scouts/ownsite.js` | eigene Website und Impressum lesen, E-Mail-Prüfung, MX-Prüfung |
|
||
| `src/scouts/assemble.js` | Platzhalter- und Fertig-Dokument des Interessenten ohne Places-Rohdaten |
|
||
| `src/scouts/run.js` | Suchlauf eines Scouts (`runScoutJob`) |
|
||
| `src/scoring/business.js` | Punkteformel, Ausschlussgründe, Rangfolge |
|
||
| `src/scoring/bea.js` | Beas Urteil (Sonnet) mit Ersatzurteil |
|
||
| `src/scoring/slots.js` | Sendetag und freie Mail-Plätze |
|
||
| `src/scoring/night.js` | Top-3-Auswahl über alle Räume (`selectNightTop3`) |
|
||
| `src/archive/botleads.js` | Arne: Bot-Lead ablegen |
|
||
| `src/archive/kuerzen.js` | Kürzung nach Frist (`runKuerzen`) |
|
||
| `src/cold/draft.js` | Max: Kaltmail entwerfen, Prüfung, Ersatztext |
|
||
| `src/cold/flow.js` | Entwürfe für P4s Sende-Tick (`deps.drafters`), Nachfass-Aufträge |
|
||
| `src/mail/coldFooter.js` | Pflichtblock der Kaltmail |
|
||
| `src/mail/coldChecks.js` | Zusatzprüfung am Sende-Tor für Kaltmails (`registerGateCheck`) |
|
||
| `src/mail/footer.js`, `src/mail/gate.js` | (P4) Zweig `cold:true` bzw. nur die Umstellung auf den einen Datenschutz-Schalter |
|
||
| `src/api/botleads.js` | Liste der Bot-Leads und `BOTLEADS_ROUTES` (Registry von P3-E6) |
|
||
| `src/ohneWebsite.js` | Fassade: `drainScouts`, `top3Tick`, `kuerzenTick`, `ohneWebsiteTickJobs`, `registerColdPieces`, `erweitereDeps` |
|
||
| `src/settings.js`, `src/claude.js`, `src/schedule.js`, `src/main.js` | (P1/P3) kleine Ergänzungen laut E1, E3–E6 |
|
||
| `scripts/p6-freischalten.mjs` | Datenschutz-Seite prüfen und Schalter `privacyInteressentenLive` setzen (nur nach Prüfung), `connectedRooms` der Scout-Räume setzen |
|
||
| `scripts/probe-scout.mjs` | manuelle Live-Probe (Kenso) |
|
||
| `test/support/p6.js`, `test/p6-*.test.js` | Testhilfen und Tests |
|
||
|
||
**WOMS (`.../dev-workers`)**: `src/lib/botleadsView.js` (reine Anzeigelogik), `src/components/workers/BotLeadList.jsx`, `src/pages/WorkersPage.jsx` (Reiter), `src/lib/workersApi.js` (`botleads` reicht `id` durch), `src/styles/workers.css`, `tests/unit/botleadsView.test.mjs`, `tests/unit/workersApi.test.mjs`.
|
||
|
||
**Portal (`.../dev-workers-datenschutz`)**: `public/datenschutz.html` (Abschnitte 2, 11, 12, 14), `docs/WEBklar-Datenschutzerklaerung.pdf` + `.sha256`, `scripts/rechtliches-hochladen.mjs` (nur `export`), `scripts/datenschutz-hochladen.mjs`, `test/legalTexts.test.js`, `test/datenschutzHochladen.test.js`.
|
||
|
||
**Website (`.../dev-workers-ds`)**: `src/pages/Datenschutz.tsx`, `src/test/datenschutz-weiterleitung.test.tsx`.
|
||
|
||
---
|
||
|
||
### P6-T01: Vorprüfung, Testhilfen, Aufbewahrungsgrenze (E1, E2)
|
||
|
||
**Files:**
|
||
- Create: `test/support/p6.js`
|
||
- Modify: `src/settings.js` (`retentionDays` höchstens 90); `src/schema.js` und `src/settings.js` zusätzlich nur für das, was P3 noch nicht geliefert hat
|
||
- Test: `test/p6-schema.test.js`, `test/p6-support.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes (P1/P3/P4): `SCHEMA`, `DEFAULTS`, `getSettings(deps)`, `updateSettings(deps, patch, by)`, `workerForRoom` (`src/roles.js`), `enqueue`, `next` (`src/jobs.js`); Testhilfen `makeDeps`, `fakeClock` (`test/support/helpers.js`), `createFakeDb` (`test/support/fakeDb.js`: `seed(col, over, id?)`, `all(col)`), `fakeSdk`, `msg`, `makeP4Deps`, `fakeMailer` (`test/support/p4.js`).
|
||
- Produces: `retentionDays` über 90 wird abgelehnt; `test/support/p6.js` mit `makeDeps(over)` (Step 4), den Re-Exporten `fakeClock`, `fakeMailer`, `fakeLlm(script)`, `at(iso)`, `page(url, text)`, `seedProspect(db, over)`, `seedThread(db, over)`, `seedJob(db, over)` (rohes Dokument), `takeJob(deps, { kind, workerId, prospectId?, threadId?, payload? })` (P1-Auftrag `{ id, …, payload }` über `enqueue` + `next`, Status `laeuft`), `logSearch(db, room, atIso, query?, region?)`.
|
||
|
||
- [ ] **Step 1: Vorprüfung (nur lesen) und Branch.** `cd /home/webklar/apps/workers-dev && git switch -c p6-ohne-website` (vom Stand, der P1–P4 enthält). Dann prüfen: `for n in claimKeys keysFor keyId emit assertCap checkBudget book BudgetStop enqueue next finish fail getSettings updateSettings getDaily updateDaily sendMail registerGateCheck appendCompliance unsubToken isDue berlinNow inSendWindow normalizeDomain normalizeName isSuppressed ensureThread lastOutbound nowOf getDoc planNight runSenden createFakeDb makeP4Deps fakeMailer; do rg -q "\b$n\b" src test/support || echo "FEHLT $n"; done` ⇒ keine Ausgabe. Von den Nachbarplänen festgelegt, hier nur bestätigen: **A1** `deps.clock.now()` liefert Millisekunden (P1-E3); **A2** `db.get` liefert `null`, `db.create` liefert das Dokument, `listAll` ein Array (P1-E3); **A3** `next(deps, { kinds, workerIds? })` liefert `{ id, kind, workerId, prospectId, threadId, attempts, payload }` oder `null`, `fail(deps, id, error, { retry })` (P1-E8, P3-E3); **A4** `deps.claude.ask/search(deps, opts)` (P3-E1); **A5** `registerGateCheck(fn)` mit `ctx = { prospect, thread, to, text, cold, author, settings, first }` und `deps.drafters.erstmail/nachfass` (P4-E1/E2); **A6** `buildTickJobs()`, `buildRoutes()` und Tick-Einträge `{ kind, workerId, pauseSafe, run(deps, { now, settings }) }` in `src/main.js` bzw. `src/tick.js` (P3-E5/E6). `rg -n "datenschutzOk|privacyInteressentenLive" src` zeigt, welche Datenschutz-Schalter es gibt (P4 liest vielleicht noch `config.datenschutzOk`; T15 stellt das um). Weicht der Code ab, wird das gemeldet und nur `test/support/p6.js` angepasst; die Pläne gelten.
|
||
- [ ] **Step 2: Fehlschlagende Tests schreiben.**
|
||
|
||
```js
|
||
// test/p6-schema.test.js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { SCHEMA } from '../src/schema.js'
|
||
import { getSettings, updateSettings } from '../src/settings.js'
|
||
import { makeDeps } from './support/helpers.js'
|
||
|
||
const attr = (col, key) => SCHEMA.find((c) => c.id === col).attributes.find((a) => a.key === key)
|
||
const indizes = (col) => SCHEMA.find((c) => c.id === col).indexes
|
||
|
||
test('E1 (P3): privacyInteressentenLive ist Boolean, nicht Pflicht, Standard false', async () => {
|
||
const a = attr('workersSettings', 'privacyInteressentenLive')
|
||
assert.deepEqual([a.type, a.required, a.default], ['boolean', false, false])
|
||
assert.equal((await getSettings(makeDeps())).privacyInteressentenLive, false)
|
||
})
|
||
test('E1: Schalter nur als Boolean; retentionDays über 90 wird abgelehnt', async () => {
|
||
const deps = makeDeps()
|
||
await updateSettings(deps, { privacyInteressentenLive: true }, 'script:test')
|
||
assert.equal((await getSettings(deps)).privacyInteressentenLive, true)
|
||
await assert.rejects(() => updateSettings(deps, { privacyInteressentenLive: 'ja' }, 'script:test'), /privacyInteressentenLive/)
|
||
await assert.rejects(() => updateSettings(deps, { retentionDays: 91 }, 'script:test'), /retentionDays/)
|
||
await updateSettings(deps, { retentionDays: 90 }, 'script:test')
|
||
assert.equal((await getSettings(deps)).retentionDays, 90)
|
||
})
|
||
test('E2: vorhandene Indizes genügen (Status der Fäden, Art+Status der Aufträge, threadId der Nachrichten)', () => {
|
||
assert.ok(indizes('workersThreads').some((i) => i.attributes.join() === 'status'))
|
||
assert.ok(indizes('workersJobs').some((i) => i.attributes.join() === 'kind,status'))
|
||
assert.ok(indizes('workersMessages').some((i) => i.attributes.join() === 'threadId'))
|
||
})
|
||
```
|
||
|
||
```js
|
||
// test/p6-support.test.js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { makeDeps, seedProspect, seedThread, seedJob, takeJob, logSearch } from './support/p6.js'
|
||
|
||
test('makeDeps: Uhr in Millisekunden, Einstellungen über den Standardwerten, docs() und Schreibliste', async () => {
|
||
const deps = makeDeps({ settings: { connectedRooms: ['handwerk'] } })
|
||
assert.equal(typeof deps.clock.now(), 'number')
|
||
assert.equal(new Date(deps.clock.now()).toISOString(), '2026-10-20T03:30:00.000Z')
|
||
const s = deps.db.docs('workersSettings')[0]
|
||
assert.deepEqual([s.$id, s.connectedRooms, s.dailyBudgetUsd, s.privacyInteressentenLive], ['main', ['handwerk'], 10, true])
|
||
const p = await seedProspect(deps.db, {})
|
||
await deps.db.update('workersProspects', p.$id, { score: 1 })
|
||
assert.deepEqual(deps.db.writes.slice(-1)[0], { op: 'update', col: 'workersProspects', id: p.$id })
|
||
assert.equal(await deps.db.get('workersProspects', 'gibtsnicht'), null)
|
||
})
|
||
test('takeJob liefert einen P1-Auftrag im Status laeuft; seedJob ein rohes Dokument', async () => {
|
||
const deps = makeDeps()
|
||
const j = await takeJob(deps, { kind: 'suche', workerId: 'hanna', payload: { room: 'handwerk' } })
|
||
assert.deepEqual([j.kind, j.workerId, j.status, j.attempts, j.payload], ['suche', 'hanna', 'laeuft', 1, { room: 'handwerk' }])
|
||
assert.ok(j.id && !('$id' in j))
|
||
const r = await seedJob(deps.db, {})
|
||
assert.deepEqual([r.kind, r.workerId, r.status, r.payloadJson], ['erstmail', 'max', 'offen', '{}'])
|
||
})
|
||
test('logSearch schreibt eine Protokollzeile, seedThread die Standardwerte', async () => {
|
||
const deps = makeDeps()
|
||
await logSearch(deps.db, 'handwerk', '2026-10-18T22:00:00Z')
|
||
const t = await seedThread(deps.db, {})
|
||
const z = deps.db.docs('workersSearchLog')[0]
|
||
assert.deepEqual([z.room, z.region, t.status, t.followupSent], ['handwerk', 'Rheinland-Pfalz', 'wartet_auf_kunde', false])
|
||
})
|
||
```
|
||
- [ ] **Step 3: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-schema.test.js test/p6-support.test.js` ⇒ FAIL: `retentionDays` 91 wird noch angenommen, `Cannot find module './support/p6.js'` (dazu alles, was P3 noch nicht geliefert hat).
|
||
- [ ] **Step 4: Implementieren.** `src/settings.js`: `retentionDays` ganzzahlig, Untergrenze unverändert (7), Obergrenze 90, Meldung beginnt mit `retentionDays`; fehlt `privacyInteressentenLive` (Schema, `DEFAULTS`, Boolean-Prüfung), nach P3-E8 nachziehen. `test/support/p6.js` nach diesem Vertrag, aufgebaut auf den Hilfen von P1/P4 (kein zweiter Fake-DB):
|
||
- Re-Exporte: `fakeClock` (P1: `now()` in Millisekunden, `set(isoOrMs)`, `advance(ms)`), `fakeMailer` (P4).
|
||
- `makeDeps({ now = '2026-10-20T03:30:00Z', settings = {}, ...over })`: `deps = makeP4Deps(over)` (Mailer, `config.smtp`/`unsub`, `notify`, `claude`), danach `deps.clock.set(now)`; `deps.db.seed('workersSettings', { ...DEFAULTS, privacyInteressentenLive: true, ...settings }, 'main')` (Standard der Testhilfe: Schalter an; Tests des ausgeschalteten Schalters übergeben ausdrücklich `false`); `deps.llm = fakeLlm({})`, `deps.places = { textSearch: async () => ({ places: [], nextPageToken: null }) }`, `deps.osm = { lookup: async () => null }`, `deps.dns = { resolveMx: async () => [{ exchange: 'mx.test', priority: 10 }], resolve4: async () => ['192.0.2.1'] }`; `deps.db.docs = (col) => deps.db.all(col)`; `deps.db.writes` = Liste `{ op, col, id }` (`create`, `update`, `remove` der Fake-DB werden umhüllt). Liest P4s Tor noch `config.datenschutzOk`, setzt `makeDeps` es passend zum Schalter (T15 entfernt das).
|
||
- `fakeLlm({ search, ask })`: `{ calls: { search: [], ask: [] }, search(deps, opts), ask(deps, opts) }`; jede Funktion zeichnet `opts` auf und ruft die übergebene Skriptfunktion (Standard: wirft `Error('nicht vorgesehen')`).
|
||
- `seedProspect(db, over)` über `db.seed('workersProspects', …)`; Standard: `entrance ohne_website, room handwerk, foundBy hanna, source places, sourceUrl https://beispiel-elektro.de/impressum, foundAt 2026-10-19T20:00:00Z, name 'Elektro Beispiel GmbH', email info@beispiel-elektro.de, emailSource https://beispiel-elektro.de/impressum, city Kaiserslautern, postalCode 67655, score 80, status bewertet, isHealth false, purgeAfter 2027-01-17T20:00:00Z, updatedAt 2026-10-19T20:00:00Z`. `seedThread` (`workersThreads`): `prospectId p1, channel mail, toEmail info@beispiel-elektro.de, subject 'Website-Vorschau für Elektro Beispiel GmbH', status wartet_auf_kunde, followupSent false, reminderSent false, createdAt 2026-10-19T20:00:00Z`. `seedJob` (rohes `workersJobs`-Dokument): `kind erstmail, workerId max, status offen, attempts 0, payloadJson '{}', createdAt 2026-10-19T20:00:00Z`. `logSearch(db, room, atIso, query = 'q', region = 'Rheinland-Pfalz')`: `workersSearchLog` mit `workerId = workerForRoom(room)`, `found 10`, `fresh 8`. `takeJob(deps, { kind, workerId, prospectId, threadId, payload = {} })`: `await enqueue(deps, …)`, danach `return next(deps, { kinds: [kind] })`. `at(iso) = new Date(iso)`, `page(url, text) = { url, text }`. Alle `seed*` sind mit `over` überschreibbar und geben das angelegte Dokument zurück; Felder ohne Wert werden weggelassen, nie `null`.
|
||
- [ ] **Step 5: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`. Danach `timeout 300 node --max-old-space-size=1024 --test test/*.test.js` ⇒ keine neuen Fehler gegenüber vorher.
|
||
- [ ] **Step 6: Commit.** `git add src/settings.js test/support/p6.js test/p6-schema.test.js test/p6-support.test.js && git commit -m "p6: retentionDays höchstens 90, Testhilfen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"` (`src/schema.js` nur mit aufnehmen, wenn Step 4 dort etwas ergänzt hat).
|
||
|
||
### P6-T02: `search` liefert Seitentext, Domainfilter auch am Abruf, Prüfnaht `llmOf` (E3, E4)
|
||
|
||
**Files:**
|
||
- Create: `src/llm.js`
|
||
- Modify: `src/claude.js`, `test/claudeSearch.test.js` (P1: Erwartung des `web_fetch`-Eintrags)
|
||
- Test: `test/p6-claude-pages.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes (P1-T13/T14): `createClaude({ apiKey, sdk, searchModel }) → { ask, search, batch }`; `search(deps, { workerId, system, task, maxUses, allowedDomains?, blockedDomains? }) → { text, sources, usage, costUsd, incomplete, toolErrors:[{ tool, code, url? }] }`; Testhilfen `fakeSdk(script)`, `msg(over)` (`test/support/fakeSdk.js`), `makeDeps` (`test/support/helpers.js`).
|
||
- Produces: `search(...)` liefert zusätzlich `pages: [{ url, text }]` (nur Textquellen, je höchstens 60 000 Zeichen, aus allen Runden); `allowedDomains` bzw. `blockedDomains` gelten für **beide** Werkzeuge (`web_search` und `web_fetch`), sonst könnte das Modell Seiten außerhalb der Betriebsdomain abrufen (Facebook, Verzeichnisse); `llmOf(deps) → { ask, search }` = `deps.llm`, sonst `deps.claude`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { createClaude } from '../src/claude.js'
|
||
import { llmOf } from '../src/llm.js'
|
||
import { makeDeps } from './support/helpers.js'
|
||
import { fakeSdk, msg } from './support/fakeSdk.js'
|
||
|
||
const ergebnis = (url, data) => ({ type: 'web_fetch_result', url, content: { type: 'document', source: { type: 'text', media_type: 'text/plain', data } } })
|
||
const opts = { workerId: 'hanna', system: 's', task: 't', maxUses: 4, allowedDomains: ['beispiel-elektro.de'] }
|
||
const lauf = (inhalt) => { const sdk = fakeSdk([msg({ content: inhalt })]); return { sdk, suche: () => createClaude({ apiKey: 'k', sdk }).search(makeDeps(), opts) } }
|
||
|
||
test('search liefert den Text der Textquellen; Abruffehler stehen in toolErrors, PDFs nicht in pages', async () => {
|
||
const { suche } = lauf([
|
||
{ type: 'server_tool_use', id: 's1', name: 'web_fetch', input: { url: 'https://beispiel-elektro.de/impressum' } },
|
||
{ type: 'web_fetch_tool_result', tool_use_id: 's1', content: ergebnis('https://beispiel-elektro.de/impressum', 'Impressum\nE-Mail: info@beispiel-elektro.de') },
|
||
{ type: 'server_tool_use', id: 's2', name: 'web_fetch', input: { url: 'https://beispiel-elektro.de/' } },
|
||
{ type: 'web_fetch_tool_result', tool_use_id: 's2', content: { type: 'web_fetch_tool_result_error', error_code: 'url_not_allowed' } },
|
||
{ type: 'server_tool_use', id: 's3', name: 'web_fetch', input: { url: 'https://beispiel-elektro.de/katalog.pdf' } },
|
||
{ type: 'web_fetch_tool_result', tool_use_id: 's3', content: { type: 'web_fetch_result', url: 'https://beispiel-elektro.de/katalog.pdf', content: { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0=' } } } },
|
||
{ type: 'text', text: 'fertig' },
|
||
])
|
||
const r = await suche()
|
||
assert.deepEqual(r.pages, [{ url: 'https://beispiel-elektro.de/impressum', text: 'Impressum\nE-Mail: info@beispiel-elektro.de' }])
|
||
assert.deepEqual(r.toolErrors, [{ tool: 'web_fetch', code: 'url_not_allowed', url: 'https://beispiel-elektro.de/' }])
|
||
assert.equal(r.text, 'fertig')
|
||
})
|
||
test('Seitentext wird auf 60 000 Zeichen gekürzt', async () => {
|
||
const { suche } = lauf([{ type: 'web_fetch_tool_result', tool_use_id: 'x', content: ergebnis('https://beispiel-elektro.de/', 'a'.repeat(70000)) }])
|
||
assert.equal((await suche()).pages[0].text.length, 60000)
|
||
})
|
||
test('der Domainfilter gilt für Suche und Abruf', async () => {
|
||
const { sdk, suche } = lauf([{ type: 'text', text: 'x' }])
|
||
await suche()
|
||
assert.deepEqual(sdk.calls[0].tools.map((t) => [t.name, t.allowed_domains]), [['web_search', ['beispiel-elektro.de']], ['web_fetch', ['beispiel-elektro.de']]])
|
||
})
|
||
test('llmOf nimmt deps.llm, sonst deps.claude', () => {
|
||
const fake = { ask() {}, search() {} }
|
||
assert.equal(llmOf({ llm: fake }), fake)
|
||
const claude = { ask() {}, search() {} }
|
||
assert.equal(llmOf({ claude }), claude)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-claude-pages.test.js` ⇒ FAIL (`Cannot find module '../src/llm.js'`).
|
||
- [ ] **Step 3: Implementieren.** In `src/claude.js` (`search`): `pages` aus jedem `web_fetch_tool_result` mit `content.type === 'web_fetch_result'` und `content.content.source.type === 'text'` (`url` = `content.url`, `text` auf 60 000 Zeichen gekürzt), gesammelt über alle `pause_turn`-Runden, doppelte URLs nur einmal; `allowed_domains` bzw. `blocked_domains` kommen in **beide** Werkzeug-Einträge. In P1s Test `Werkzeuge: …` (`test/claudeSearch.test.js`) erwartet der `web_fetch`-Eintrag dann ebenfalls `blocked_domains: ['facebook.com']`. `src/llm.js`: `export const llmOf = (deps) => deps.llm ?? deps.claude`. Alle bisherigen Rückgabefelder bleiben unverändert.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; zusätzlich `timeout 300 node --max-old-space-size=1024 --test test/claudeSearch.test.js` ⇒ grün.
|
||
- [ ] **Step 5: Commit.** `git add src/claude.js src/llm.js test/claudeSearch.test.js test/p6-claude-pages.test.js && git commit -m "p6: search liefert Seitentext, Domainfilter auch am Abruf, llmOf" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T03: Scout-Räume, Gewerke, Heilberufe, Orte
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/rooms.js`, `src/scouts/regionen.js`
|
||
- Test: `test/p6-rooms.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `role(workerId)` aus `src/roles.js`.
|
||
- Produces: `SCOUT_ROOMS` (`{ [room]: { workerId, label, gewerke: string[] } }`), `SCOUT_WORKER_IDS: string[]`, `GESUNDHEITS_GEWERKE: string[]`, `isHealth(room: string, gewerk: string): boolean`, `roomOfWorker(workerId): string|null`, `REGION_ORTE: { [bundesland]: string[] }`, `orteFuer(region: string): string[]`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { role } from '../src/roles.js'
|
||
import { SCOUT_ROOMS, SCOUT_WORKER_IDS, isHealth, roomOfWorker } from '../src/scouts/rooms.js'
|
||
import { orteFuer } from '../src/scouts/regionen.js'
|
||
|
||
test('vier Räume mit den Scouts der Besetzung', () => {
|
||
assert.deepEqual(Object.entries(SCOUT_ROOMS).map(([r, c]) => [r, c.workerId, c.label]),
|
||
[['aerzte', 'ali', 'Ärzte & Praxen'], ['handwerk', 'hanna', 'Handwerk'], ['selbst', 'sven', 'Selbstständige'], ['laden', 'lea', 'Läden & Gastro']])
|
||
for (const [room, c] of Object.entries(SCOUT_ROOMS)) {
|
||
assert.equal(role(c.workerId).room, room)
|
||
assert.equal(roomOfWorker(c.workerId), room)
|
||
assert.ok(c.gewerke.length >= 12 && new Set(c.gewerke).size === c.gewerke.length)
|
||
}
|
||
assert.deepEqual(SCOUT_WORKER_IDS, ['ali', 'hanna', 'sven', 'lea'])
|
||
assert.equal(roomOfWorker('rita'), null)
|
||
})
|
||
test('Heilberuf: Raum aerzte immer, Gewerkliste auch in anderen Räumen', () => {
|
||
assert.equal(isHealth('aerzte', 'Hausarzt'), true)
|
||
assert.equal(isHealth('selbst', 'Physiotherapie'), true)
|
||
assert.equal(isHealth('selbst', 'Steuerberater'), false)
|
||
assert.equal(isHealth('handwerk', 'Elektriker'), false)
|
||
})
|
||
test('Orte: Rheinland-Pfalz beginnt mit Kaiserslautern, unbekannte Region ist ihr eigener Ort', () => {
|
||
const rlp = orteFuer('Rheinland-Pfalz')
|
||
assert.equal(rlp[0], 'Kaiserslautern')
|
||
assert.ok(rlp.length >= 24 && new Set(rlp).size === rlp.length)
|
||
assert.deepEqual(orteFuer('Saarland'), ['Saarland'])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-rooms.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren (Daten, exakt so).**
|
||
|
||
```js
|
||
// src/scouts/rooms.js
|
||
export const SCOUT_ROOMS = {
|
||
aerzte: { workerId: 'ali', label: 'Ärzte & Praxen', gewerke: ['Hausarzt', 'Zahnarzt', 'Kieferorthopäde', 'Augenarzt', 'Frauenarzt', 'Kinderarzt', 'HNO-Arzt', 'Orthopäde', 'Hautarzt', 'Heilpraktiker', 'Tierarzt', 'Psychotherapie'] },
|
||
handwerk: { workerId: 'hanna', label: 'Handwerk', gewerke: ['Elektriker', 'Sanitär und Heizung', 'Maler und Lackierer', 'Schreiner', 'Dachdecker', 'Fliesenleger', 'Zimmerei', 'Gartenbau', 'Metallbau', 'Kfz-Werkstatt', 'Autolackiererei', 'Bauunternehmen'] },
|
||
selbst: { workerId: 'sven', label: 'Selbstständige', gewerke: ['Steuerberater', 'Fotograf', 'Coach', 'Grafikdesign', 'Architekt', 'Hundeschule', 'Nachhilfe', 'Fahrschule', 'Versicherungsmakler', 'Immobilienmakler', 'Physiotherapie', 'Übersetzer'] },
|
||
laden: { workerId: 'lea', label: 'Läden & Gastro', gewerke: ['Bäckerei', 'Metzgerei', 'Café', 'Restaurant', 'Blumenladen', 'Friseur', 'Kosmetikstudio', 'Boutique', 'Buchhandlung', 'Eisdiele', 'Geschenkeladen', 'Fahrradladen'] },
|
||
}
|
||
export const GESUNDHEITS_GEWERKE = ['Hausarzt', 'Zahnarzt', 'Kieferorthopäde', 'Augenarzt', 'Frauenarzt', 'Kinderarzt', 'HNO-Arzt', 'Orthopäde', 'Hautarzt', 'Heilpraktiker', 'Tierarzt', 'Psychotherapie', 'Physiotherapie']
|
||
```
|
||
`SCOUT_WORKER_IDS = Object.values(SCOUT_ROOMS).map((c) => c.workerId)`; `isHealth(room, gewerk) = room === 'aerzte' || GESUNDHEITS_GEWERKE.includes(gewerk)`; `roomOfWorker`. `src/scouts/regionen.js`: `REGION_ORTE['Rheinland-Pfalz'] = ['Kaiserslautern', 'Mainz', 'Ludwigshafen am Rhein', 'Koblenz', 'Trier', 'Worms', 'Neustadt an der Weinstraße', 'Speyer', 'Bad Kreuznach', 'Pirmasens', 'Zweibrücken', 'Landau in der Pfalz', 'Frankenthal', 'Idar-Oberstein', 'Neuwied', 'Bad Dürkheim', 'Alzey', 'Bingen am Rhein', 'Mayen', 'Andernach', 'Ingelheim am Rhein', 'Kirchheimbolanden', 'Rockenhausen', 'Winnweiler', 'Otterberg']` (Reihenfolge = Größe/Nähe, Kaiserslautern zuerst); `orteFuer(region) = REGION_ORTE[region] ?? [region]`.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/rooms.js src/scouts/regionen.js test/p6-rooms.test.js && git commit -m "p6: Scout-Räume, Gewerke, Heilberufe, Orte" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T04: Sendetag und freie Mail-Plätze
|
||
|
||
**Files:**
|
||
- Create: `src/scoring/slots.js`
|
||
- Test: `test/p6-slots.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings(deps)`, `getDaily(deps, day)` (P1-E4), `berlinNow` (P1-T08); Sammlungen `workersThreads` (Index `idx_status`), `workersJobs` (Index `status`).
|
||
- Produces: `berlinDay(date: Date) → { day: 'YYYY-MM-DD', minutes: number, weekday: 1..7 }`; `sendDayFor(now: Date, settings) → 'YYYY-MM-DD'`; `followupThreads(deps, { sendDay }) → Promise<Thread[]>` (fällige Nachfass-Fäden bis Ende des Sendetags); `mailSlots(deps, { now: Date }) → Promise<{ sendDay, cap, followups, openFirst, sentToday, free }>`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { sendDayFor, mailSlots } from '../src/scoring/slots.js'
|
||
import { makeDeps, at, seedThread, seedJob } from './support/p6.js'
|
||
|
||
const S = { sendWindowEnd: '16:00', sendWeekdaysOnly: true }
|
||
test('R4: sendDayFor: Berliner Werktag; nach Fensterende und am Wochenende der nächste Werktag', () => {
|
||
assert.equal(sendDayFor(at('2026-10-20T03:30:00Z'), S), '2026-10-20') // Di 05:30
|
||
assert.equal(sendDayFor(at('2026-10-20T15:00:00Z'), S), '2026-10-21') // Di 17:00
|
||
assert.equal(sendDayFor(at('2026-10-24T03:30:00Z'), S), '2026-10-26') // Sa 05:30
|
||
assert.equal(sendDayFor(at('2026-10-23T15:00:00Z'), S), '2026-10-26') // Fr 17:00
|
||
})
|
||
test('R4: mailSlots: Limit minus fällige Nachfass minus offene Erstmail-Aufträge', async () => {
|
||
const deps = makeDeps()
|
||
await seedThread(deps.db, { followupDueAt: '2026-10-20T05:00:00Z' }) // am Sendetag fällig
|
||
await seedThread(deps.db, { followupDueAt: '2026-10-21T05:00:00Z' }) // erst morgen
|
||
await seedThread(deps.db, { followupDueAt: '2026-10-19T05:00:00Z', followupSent: true }) // schon gesendet
|
||
await seedJob(deps.db, { kind: 'erstmail', status: 'offen' })
|
||
await seedJob(deps.db, { kind: 'erstmail', status: 'fertig' })
|
||
assert.deepEqual(await mailSlots(deps, { now: at('2026-10-20T03:30:00Z') }), { sendDay: '2026-10-20', cap: 3, followups: 1, openFirst: 1, sentToday: 0, free: 1 })
|
||
})
|
||
test('R4: mailSlots: heute schon Gesendetes zählt, nie negativ', async () => {
|
||
const deps = makeDeps()
|
||
await deps.db.create('workersDaily', { costUsd: 0, mailsCold: 3, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20')
|
||
const s = await mailSlots(deps, { now: at('2026-10-20T03:30:00Z') })
|
||
assert.deepEqual([s.sentToday, s.free], [3, 0])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-slots.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `berlinDay` ruft P1s `berlinNow({ now: () => date.getTime() })` (nicht neu rechnen) und liefert `{ day, minutes: hour * 60 + minute, weekday }`. `sendDayFor`: Start = Berliner Tag von `now`; ist `minutes ≥` Fensterende (aus `settings.sendWindowEnd`, als Minuten) oder (bei `sendWeekdaysOnly`) Wochenende, auf den nächsten zulässigen Tag vorrücken. `followupThreads`: `listAll('workersThreads', [Query.equal('status', ['wartet_auf_kunde'])])`, im Code `channel === 'mail'`, `followupSent` falsch, `followupDueAt` gesetzt und dessen Berliner Tag (`berlinNow({ now: () => Date.parse(followupDueAt) }).day`) ≤ `sendDay`. `mailSlots`: `followups` = `followupThreads(...).length`; `openFirst` = Aufträge mit `Query.equal('status', ['offen','laeuft'])`, im Code `kind === 'erstmail'`; `sentToday` = `(await getDaily(deps, sendDay))?.mailsCold ?? 0`, aber nur wenn `sendDay` der heutige Berliner Tag ist, sonst 0; `free = Math.max(0, cap - followups - openFirst - sentToday)`.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scoring/slots.js test/p6-slots.test.js && git commit -m "p6: Sendetag und freie Mail-Plätze" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T05: Abfragewahl der Scouts, Tick-Arten `top3`, `kuerzen`, `scouts` (E5)
|
||
|
||
Raum und Region eines Nachtlaufs wählt P1s `planNight` (P1-T20; `connectedRooms`, Pause, Budget, Rotation nach Suchprotokoll inbegriffen) und legt den Auftrag `suche` an. P6 wählt nur noch die konkrete Abfrage (Gewerk × Ort) im Läufer (T12); eine zweite Nachtplanung gibt es nicht.
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/plan.js`
|
||
- Modify: `src/schedule.js`
|
||
- Test: `test/p6-plan.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SCOUT_ROOMS`, `isHealth`, `orteFuer` (T03), Sammlung `workersSearchLog` (Index `room+at`).
|
||
- Produces: `naechsteAbfrage({ gewerke, orte, log: [{ query, at }], now: Date, sperrTage = 90 }) → { gewerk, ort, query } | null` (rein); `pickQuery(deps, { room, region, now: Date }) → { gewerk, ort, query, isHealth } | null` (`null` für Räume ohne Scout oder wenn alle Abfragen der letzten `sperrTage` gesucht sind); `isDue` kennt `top3` (täglich ab 05:30 Berlin), `kuerzen` (täglich ab 03:00) und `scouts` (Intervall 2 Minuten, wie `anfragen` in P3).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { naechsteAbfrage, pickQuery } from '../src/scouts/plan.js'
|
||
import { isDue, berlinNow } from '../src/schedule.js'
|
||
import { makeDeps, fakeClock, at, logSearch } from './support/p6.js'
|
||
|
||
const jetzt = at('2026-10-19T20:00:00Z') // Mo 22:00 Berlin
|
||
const G = ['Elektriker', 'Schreiner'], O = ['Kaiserslautern', 'Mainz']
|
||
|
||
test('naechsteAbfrage: Ort außen, Gewerk innen; Ungesuchtes zuerst, sonst das Älteste nach der Sperrfrist', () => {
|
||
assert.deepEqual(naechsteAbfrage({ gewerke: G, orte: O, log: [], now: jetzt }), { gewerk: 'Elektriker', ort: 'Kaiserslautern', query: 'Elektriker in Kaiserslautern' })
|
||
const log = [{ query: 'Elektriker in Kaiserslautern', at: '2026-10-09T00:00:00Z' }]
|
||
assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log, now: jetzt }).query, 'Schreiner in Kaiserslautern')
|
||
const alle = ['Elektriker in Kaiserslautern', 'Schreiner in Kaiserslautern', 'Elektriker in Mainz', 'Schreiner in Mainz']
|
||
assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log: alle.map((query) => ({ query, at: '2026-10-09T00:00:00Z' })), now: jetzt }), null)
|
||
const alt = alle.map((query, i) => ({ query, at: i === 2 ? '2026-07-01T00:00:00Z' : '2026-10-09T00:00:00Z' }))
|
||
assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log: alt, now: jetzt }).query, 'Elektriker in Mainz')
|
||
})
|
||
test('pickQuery: Orte der Region, Protokoll des Raums, Heilberuf-Merkmal, Räume ohne Scout', async () => {
|
||
const deps = makeDeps()
|
||
const wahl = (room) => pickQuery(deps, { room, region: 'Rheinland-Pfalz', now: jetzt })
|
||
assert.deepEqual(await wahl('handwerk'), { gewerk: 'Elektriker', ort: 'Kaiserslautern', query: 'Elektriker in Kaiserslautern', isHealth: false })
|
||
await logSearch(deps.db, 'handwerk', '2026-10-18T22:00:00Z', 'Elektriker in Kaiserslautern')
|
||
assert.equal((await wahl('handwerk')).query, 'Sanitär und Heizung in Kaiserslautern')
|
||
await logSearch(deps.db, 'handwerk', '2026-07-01T22:00:00Z', 'Maler und Lackierer in Kaiserslautern') // älter als 90 Tage: die Abfrage gilt trotzdem als bekannt
|
||
assert.equal((await wahl('handwerk')).query, 'Sanitär und Heizung in Kaiserslautern')
|
||
assert.deepEqual([(await wahl('aerzte')).isHealth, (await wahl('selbst')).isHealth], [true, false])
|
||
assert.equal(await wahl('netz'), null)
|
||
assert.equal(await pickQuery(deps, { room: 'handwerk', region: 'Nirgendwo', now: jetzt }).then((q) => q.ort), 'Nirgendwo')
|
||
})
|
||
test('Ticks top3 (05:30) und kuerzen (03:00): einmal je Berliner Tag, erst ab der Uhrzeit; scouts alle 2 Minuten', () => {
|
||
const t = (iso) => berlinNow(fakeClock(iso))
|
||
assert.equal(isDue('top3', t('2026-10-20T03:29:00Z'), null), false)
|
||
assert.equal(isDue('top3', t('2026-10-20T03:30:00Z'), null), true)
|
||
assert.equal(isDue('top3', t('2026-10-20T09:00:00Z'), '2026-10-20'), false)
|
||
assert.equal(isDue('kuerzen', t('2026-10-20T00:59:00Z'), null), false)
|
||
assert.equal(isDue('kuerzen', t('2026-10-20T01:00:00Z'), null), true)
|
||
const n = t('2026-10-20T09:00:00Z')
|
||
assert.deepEqual([isDue('scouts', n, null), isDue('scouts', n, n.ts - 119999), isDue('scouts', n, n.ts - 120000)], [true, false, true])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-plan.test.js` ⇒ FAIL, `Cannot find module '../src/scouts/plan.js'`.
|
||
- [ ] **Step 3: Implementieren.** `naechsteAbfrage`: Kandidatenliste in Reihenfolge `for ort of orte { for gewerk of gewerke }`, Abfrage `"<gewerk> in <ort>"`; je `query` zählt der jüngste Logeintrag; erster Kandidat ohne Eintrag gewinnt; sonst der Kandidat mit dem ältesten Eintrag, der älter als `sperrTage` ist; sonst `null`. `pickQuery`: `SCOUT_ROOMS[room]` fehlt ⇒ `null`; Orte = `orteFuer(region)`; Log = `list('workersSearchLog', [Query.equal('room', [room]), Query.orderDesc('at'), Query.limit(100)])`; Ergebnis von `naechsteAbfrage` mit `isHealth(room, gewerk)` ergänzt. In `src/schedule.js` die Tabelle von P1-T08 erweitern: `top3` und `kuerzen` als tägliche Arten wie `bericht` (ab 05:30 bzw. 03:00, Marker = Berliner Tag), `scouts` als Intervallart wie `posteingang` (120 000 ms).
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; `timeout 300 node --max-old-space-size=1024 --test test/schedule.test.js` (P1) ⇒ grün.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/plan.js src/schedule.js test/p6-plan.test.js && git commit -m "p6: Abfragewahl der Scouts, Tick-Arten top3, kuerzen, scouts" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T06: Places-Client (Text Search mit fester Feldmaske)
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/places.js`
|
||
- Test: `test/p6-places.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: `PLACES_FIELD_MASK: string`, `PLACES_REQUEST_USD = 0.035`, `class PlacesError extends Error { status: number }`, `createPlacesClient({ apiKey, fetchImpl = fetch }) → { textSearch({ query, pageToken? }) → Promise<{ places: [{ placeId, name, websiteUri, rating, userRatingCount, businessStatus }], nextPageToken: string|null }> }`. Passt zu `deps.places` (E4).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { createPlacesClient, PLACES_FIELD_MASK, PLACES_REQUEST_USD, PlacesError } from '../src/scouts/places.js'
|
||
|
||
const antwort = (status, body) => ({ ok: status < 300, status, json: async () => body, text: async () => JSON.stringify(body) })
|
||
|
||
test('textSearch: feste Feldmaske, Schlüssel nur im Header, schlanke Antwort', async () => {
|
||
const calls = []
|
||
const fetchImpl = async (url, init) => { calls.push({ url, init }); return antwort(200, { places: [{ id: 'ChIJa', displayName: { text: 'Elektro Beispiel' }, websiteUri: 'https://beispiel-elektro.de', rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' }], nextPageToken: 'T2' }) }
|
||
const r = await createPlacesClient({ apiKey: 'KEY123', fetchImpl }).textSearch({ query: 'Elektriker in Kaiserslautern' })
|
||
assert.equal(calls[0].url, 'https://places.googleapis.com/v1/places:searchText')
|
||
assert.equal(PLACES_FIELD_MASK, 'places.id,places.displayName,places.websiteUri,places.rating,places.userRatingCount,places.businessStatus,nextPageToken')
|
||
assert.equal(calls[0].init.headers['X-Goog-FieldMask'], PLACES_FIELD_MASK)
|
||
assert.equal(calls[0].init.headers['X-Goog-Api-Key'], 'KEY123')
|
||
assert.deepEqual(JSON.parse(calls[0].init.body), { textQuery: 'Elektriker in Kaiserslautern', languageCode: 'de', regionCode: 'DE', pageSize: 20 })
|
||
assert.deepEqual(r, { places: [{ placeId: 'ChIJa', name: 'Elektro Beispiel', websiteUri: 'https://beispiel-elektro.de', rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' }], nextPageToken: 'T2' })
|
||
assert.equal(PLACES_REQUEST_USD, 0.035)
|
||
})
|
||
test('Folgeseite sendet pageToken; fehlende Felder bekommen neutrale Werte; Einträge ohne id entfallen', async () => {
|
||
const bodies = []
|
||
const fetchImpl = async (_u, init) => { bodies.push(JSON.parse(init.body)); return antwort(200, { places: [{ id: 'ChIJb', displayName: { text: 'X' } }, { displayName: { text: 'ohne id' } }] }) }
|
||
const r = await createPlacesClient({ apiKey: 'k', fetchImpl }).textSearch({ query: 'q', pageToken: 'T2' })
|
||
assert.equal(bodies[0].pageToken, 'T2')
|
||
assert.deepEqual(r, { places: [{ placeId: 'ChIJb', name: 'X', websiteUri: '', rating: null, userRatingCount: 0, businessStatus: '' }], nextPageToken: null })
|
||
})
|
||
test('Fehler: PlacesError mit Status, ohne Schlüssel oder Antworttext in der Meldung', async () => {
|
||
const c = createPlacesClient({ apiKey: 'KEY123', fetchImpl: async () => antwort(403, { error: { message: 'API key KEY123 not valid' } }) })
|
||
await assert.rejects(() => c.textSearch({ query: 'q' }), (e) => e instanceof PlacesError && e.status === 403 && !e.message.includes('KEY123') && e.message === 'Places-Anfrage fehlgeschlagen (HTTP 403).')
|
||
const d = createPlacesClient({ apiKey: 'k', fetchImpl: async () => { throw new Error('ECONNRESET KEY123') } })
|
||
await assert.rejects(() => d.textSearch({ query: 'q' }), (e) => e instanceof PlacesError && e.status === 0 && !e.message.includes('KEY123'))
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-places.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `POST` mit Headern `Content-Type: application/json`, `X-Goog-Api-Key`, `X-Goog-FieldMask`; Körper wie im Test, `pageToken` nur wenn gesetzt; `signal: AbortSignal.timeout(20000)`; Antwort-Mapping `placeId = id`, `name = displayName?.text ?? ''`, `websiteUri ?? ''`, `rating ?? null`, `userRatingCount ?? 0`, `businessStatus ?? ''`, Einträge ohne `id` verwerfen, `nextPageToken ?? null`. Fehlermeldungen fest: `Places-Anfrage fehlgeschlagen (HTTP <status>).` bzw. `Places nicht erreichbar.` (Status 0), nie Text der Antwort oder des Netzfehlers.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/places.js test/p6-places.test.js && git commit -m "p6: Places-Client mit fester Feldmaske" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T07: OpenStreetMap-Client (Overpass, zurückhaltend)
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/osm.js`
|
||
- Test: `test/p6-osm.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `normalizeName` aus `src/register/normalize.js`.
|
||
- Produces: `overpassQuery({ name, ort }) → string`; `createOsmClient({ fetchImpl = fetch, sleep, userAgent = 'WEBklar-Workers/1.0 (support@webklar.com)', minGapMs = 1500, maxCalls = 12 }) → { lookup({ name, ort }) → Promise<null | { osmUrl, name, phone, website, street, postalCode, city }> }`. Passt zu `deps.osm` (E4).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { overpassQuery, createOsmClient } from '../src/scouts/osm.js'
|
||
|
||
const el = { type: 'node', id: 7, tags: { name: 'Elektro Müller', phone: '+49 631 111', 'contact:email': 'x@y.de', website: 'https://mueller-elektro.de', 'addr:street': 'Hauptstraße', 'addr:housenumber': '5', 'addr:postcode': '67655', 'addr:city': 'Kaiserslautern' } }
|
||
const fetchMit = (status, elements = []) => {
|
||
const calls = []
|
||
const f = async (url, init) => { calls.push({ url, init }); return { ok: status === 200, status, json: async () => ({ elements }), text: async () => '' } }
|
||
f.calls = calls
|
||
return f
|
||
}
|
||
|
||
test('overpassQuery: Suchwort nur aus [a-z0-9] (längstes Wort ab 4 Zeichen, bei Gleichstand das erste), Ort maskiert', () => {
|
||
const q = overpassQuery({ name: 'Müller "Elektro" & Söhne', ort: 'Bad "Dürkheim"' })
|
||
assert.ok(q.startsWith('[out:json][timeout:20];'))
|
||
assert.ok(q.includes('["name"~"mueller",i]'))
|
||
assert.ok(q.includes('["name"="Bad \\"Dürkheim\\""]'))
|
||
})
|
||
test('lookup: Treffer mit gleichem Namen liefert Telefon, Website, Anschrift und nie eine E-Mail; Abstand 1 500 ms', async () => {
|
||
const sleeps = []
|
||
const f = fetchMit(200, [el])
|
||
const osm = createOsmClient({ fetchImpl: f, sleep: async (ms) => sleeps.push(ms) })
|
||
const r = await osm.lookup({ name: 'Elektro Müller GmbH', ort: 'Kaiserslautern' })
|
||
assert.deepEqual(r, { osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Elektro Müller', phone: '+49 631 111', website: 'https://mueller-elektro.de', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern' })
|
||
assert.ok(!JSON.stringify(r).includes('x@y.de'))
|
||
assert.match(f.calls[0].init.headers['User-Agent'], /support@webklar\.com/)
|
||
await osm.lookup({ name: 'Elektro Müller GmbH', ort: 'Kaiserslautern' })
|
||
assert.deepEqual(sleeps, [1500])
|
||
})
|
||
test('anderer Name oder anderer Ort ergibt null', async () => {
|
||
const osm = createOsmClient({ fetchImpl: fetchMit(200, [el]), sleep: async () => {} })
|
||
assert.equal(await osm.lookup({ name: 'Bäckerei Schmitt', ort: 'Kaiserslautern' }), null)
|
||
assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
|
||
})
|
||
test('nach dem ersten Fehler keine weitere Anfrage im Lauf; höchstens 12 Anfragen', async () => {
|
||
const f = fetchMit(429)
|
||
const osm = createOsmClient({ fetchImpl: f, sleep: async () => {} })
|
||
assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
|
||
assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
|
||
assert.equal(f.calls.length, 1)
|
||
const g = fetchMit(200)
|
||
const osm2 = createOsmClient({ fetchImpl: g, sleep: async () => {} })
|
||
for (let i = 0; i < 15; i++) await osm2.lookup({ name: `Betrieb Nummer ${i}`, ort: 'Mainz' })
|
||
assert.equal(g.calls.length, 12)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-osm.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** Abfrage: `[out:json][timeout:20];area["boundary"="administrative"]["name"="<ort maskiert>"]->.a;nwr(area.a)["name"~"<wort>",i];out tags center 15;` (Maskierung: `\` und `"` mit vorgestelltem `\`, Steuerzeichen entfernen; das Wort kommt aus `normalizeName` und enthält nur `[a-z0-9]`). `lookup`: `POST https://overpass-api.de/api/interpreter`, Körper `data=<encodeURIComponent(query)>`, Header `Content-Type: application/x-www-form-urlencoded`, `User-Agent`; vor jeder weiteren Anfrage `await sleep(minGapMs)` (erste Anfrage ohne Wartezeit); Zähler `maxCalls`; nach Status ≠ 200 oder Netzfehler Schalter `gestoerrt` ⇒ alle weiteren Aufrufe `null` ohne Anfrage. Trefferwahl: erstes Element mit `tags.name`, dessen `normalizeName` gleich ist oder das kürzere (≥ 8 Zeichen) als ganze Wortfolge im längeren steht, und dessen `addr:city` fehlt oder gleich `ort` (`normalizeName`) ist. Felder: `phone` aus `phone` oder `contact:phone`, `website` aus `website` oder `contact:website`, `street` = `addr:street` + Leerzeichen + `addr:housenumber`, `osmUrl` = `https://www.openstreetmap.org/<type>/<id>`; E-Mail-Tags werden nie gelesen.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/osm.js test/p6-osm.test.js && git commit -m "p6: OSM-Client mit Abstand, Obergrenze und Fehlerschalter" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T08: Eigene Website und Impressum lesen (nur Veröffentlichtes)
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/ownsite.js`
|
||
- Test: `test/p6-ownsite.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llmOf(deps)` (T02), `normalizeDomain` (liefert `''` für Social- und Verzeichnis-Hosts, P1-T17), `normalizeName` aus `src/register/normalize.js`, `search(...)` mit `pages`/`toolErrors` (E3), `ask(deps, { workerId, system, input, schema, maxTokens })`, `deps.dns`.
|
||
- Produces: `OWNSITE_SCHEMA` (JSON-Schema, **ohne** E-Mail-Feld); `extractEmails(pages) → [{ email, url }]`; `pickEmail(list, host) → { email, url } | null`; `latestYear(text, nowYear) → number | null`; `mailDomainOk(dns, domain) → Promise<boolean>`; `readOwnSite(deps, { workerId, website, ort }) → Promise<OwnSite>` mit `OwnSite = { status: 'ok'|'keine_website'|'robots'|'nicht_lesbar', host, websiteOrigin, pages: [{url,text}] (nur im Speicher), companyName, contactName, email, emailSource, phone, phoneSource, street, postalCode, city, impressumUrl, latestYear }` (nicht belegte Felder `''`, `latestYear` `null`).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { extractEmails, pickEmail, latestYear, mailDomainOk, readOwnSite, OWNSITE_SCHEMA } from '../src/scouts/ownsite.js'
|
||
import { makeDeps, fakeLlm, page } from './support/p6.js'
|
||
|
||
const IMPRESSUM = 'Impressum\nElektro Beispiel GmbH\nGeschäftsführer: Max Beispiel\nHauptstraße 5, 67655 Kaiserslautern\nTelefon: 0631 123456\nE-Mail: info@beispiel-elektro.de'
|
||
const SEITEN = [page('https://beispiel-elektro.de/', '© 2016 Elektro Beispiel – Ihr Elektriker'), page('https://beispiel-elektro.de/impressum', IMPRESSUM)]
|
||
const FUND = { companyName: 'Elektro Beispiel GmbH', contactName: 'Max Beispiel', phone: '0631 123456', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern' }
|
||
const mit = (seiten, daten = FUND, toolErrors = []) => {
|
||
const deps = makeDeps()
|
||
deps.llm = fakeLlm({ search: async () => ({ text: '', sources: [], pages: seiten, toolErrors, usage: {}, costUsd: 0.003 }), ask: async () => ({ data: daten, usage: {}, costUsd: 0.001 }) })
|
||
return deps
|
||
}
|
||
const lesen = (deps, website = 'https://www.beispiel-elektro.de/start') => readOwnSite(deps, { workerId: 'hanna', website, ort: 'Kaiserslautern' })
|
||
|
||
test('extractEmails: nur wörtlich Veröffentlichtes, keine Verschleierung, keine Rollenadressen', () => {
|
||
const text = 'Mail: Info@Beispiel-Elektro.de. Auch: info [at] zweit.de, info(at)dritt.de, logo@2x.png, noreply@beispiel-elektro.de, datenschutz@beispiel-elektro.de, privat@gmail.com'
|
||
assert.deepEqual(extractEmails([page('https://beispiel-elektro.de/impressum', text)]).map((e) => e.email), ['info@beispiel-elektro.de', 'privat@gmail.com'])
|
||
})
|
||
test('pickEmail: eigene Domain zuerst, dann info/kontakt/mail/office/hallo/post, sonst die erste', () => {
|
||
const L = (...e) => e.map((email) => ({ email, url: 'u' }))
|
||
assert.equal(pickEmail(L('privat@gmail.com', 'chef@beispiel-elektro.de', 'info@beispiel-elektro.de'), 'beispiel-elektro.de').email, 'info@beispiel-elektro.de')
|
||
assert.equal(pickEmail(L('privat@gmail.com', 'kontakt@gmx.de'), 'beispiel-elektro.de').email, 'kontakt@gmx.de')
|
||
assert.equal(pickEmail(L('a@gmx.de', 'b@gmx.de'), 'x.de').email, 'a@gmx.de')
|
||
assert.equal(pickEmail([], 'x.de'), null)
|
||
})
|
||
test('latestYear: Jahr nach ©, Copyright, Stand, aktualisiert; Spannen zählen ihr Ende; keine Zukunft', () => {
|
||
assert.equal(latestYear('© 2016 Elektro Beispiel, Stand: März 2019', 2026), 2019)
|
||
assert.equal(latestYear('© 2016–2024 Elektro Beispiel', 2026), 2024)
|
||
assert.equal(latestYear('Telefon 0631 20 2010', 2026), null)
|
||
assert.equal(latestYear('© 2031 Elektro Beispiel', 2026), null)
|
||
})
|
||
test('mailDomainOk: ohne MX und Adresse unzustellbar, bei vorübergehenden Fehlern im Zweifel zustellbar', async () => {
|
||
const err = (code) => Object.assign(new Error(code), { code })
|
||
const dns = (mx, a) => ({ resolveMx: async () => { if (mx instanceof Error) throw mx; return mx }, resolve4: async () => { if (a instanceof Error) throw a; return a } })
|
||
assert.equal(await mailDomainOk(dns([{ exchange: 'mx.x.de', priority: 10 }], []), 'x.de'), true)
|
||
assert.equal(await mailDomainOk(dns([], ['192.0.2.1']), 'x.de'), true)
|
||
assert.equal(await mailDomainOk(dns(err('ENOTFOUND'), err('ENOTFOUND')), 'x.de'), false)
|
||
assert.equal(await mailDomainOk(dns([], err('ENODATA')), 'x.de'), false)
|
||
assert.equal(await mailDomainOk(dns(err('ETIMEOUT'), []), 'x.de'), true)
|
||
})
|
||
test('readOwnSite: Mail und Quelle aus dem Impressum, Suche nur auf der eigenen Domain', async () => {
|
||
const deps = mit(SEITEN)
|
||
const o = await lesen(deps)
|
||
assert.deepEqual([o.status, o.email, o.emailSource, o.phone, o.phoneSource, o.impressumUrl], ['ok', 'info@beispiel-elektro.de', 'https://beispiel-elektro.de/impressum', '0631 123456', 'https://beispiel-elektro.de/impressum', 'https://beispiel-elektro.de/impressum'])
|
||
assert.deepEqual([o.companyName, o.contactName, o.street, o.postalCode, o.city, o.websiteOrigin, o.latestYear], ['Elektro Beispiel GmbH', 'Max Beispiel', 'Hauptstraße 5', '67655', 'Kaiserslautern', 'https://beispiel-elektro.de', 2016])
|
||
const s = deps.llm.calls.search[0]
|
||
assert.deepEqual([s.allowedDomains, s.maxUses, s.workerId], [['beispiel-elektro.de', 'www.beispiel-elektro.de'], 4, 'hanna'])
|
||
assert.ok(s.task.includes('https://www.beispiel-elektro.de/start'))
|
||
})
|
||
test('R2: die Mail kommt nie vom Modell, Verschleiertes zählt nicht', async () => {
|
||
assert.equal('email' in OWNSITE_SCHEMA.properties, false)
|
||
const verschleiert = page('https://beispiel-elektro.de/impressum', IMPRESSUM.replace('info@beispiel-elektro.de', 'info [at] beispiel-elektro.de'))
|
||
const o = await lesen(mit([SEITEN[0], verschleiert]))
|
||
assert.deepEqual([o.status, o.email, o.emailSource], ['ok', '', ''])
|
||
})
|
||
test('R2: eine Adresse nur auf der Startseite zählt nicht, nur Impressum oder Kontaktseite (Spec 4.2, Datenschutztext T20)', async () => {
|
||
const start = page('https://beispiel-elektro.de/', '© 2016 Elektro Beispiel – info@beispiel-elektro.de')
|
||
const ohneMail = page('https://beispiel-elektro.de/impressum', IMPRESSUM.replace('E-Mail: info@beispiel-elektro.de', ''))
|
||
const nurStart = await lesen(mit([start, ohneMail]))
|
||
assert.deepEqual([nurStart.status, nurStart.email, nurStart.emailSource], ['ok', '', ''])
|
||
const kontakt = await lesen(mit([start, ohneMail, page('https://beispiel-elektro.de/kontakt', 'Schreiben Sie uns: kontakt@beispiel-elektro.de')]))
|
||
assert.deepEqual([kontakt.email, kontakt.emailSource], ['kontakt@beispiel-elektro.de', 'https://beispiel-elektro.de/kontakt'])
|
||
})
|
||
test('R2: robots.txt verbietet den Abruf ⇒ überspringen ohne Extraktionsaufruf; Seiten fremder Domains zählen nicht; Social-Hosts sind keine Website', async () => {
|
||
const robots = mit([], FUND, [{ tool: 'web_fetch', code: 'url_not_allowed', url: 'https://beispiel-elektro.de/' }])
|
||
assert.equal((await lesen(robots)).status, 'robots')
|
||
assert.equal(robots.llm.calls.ask.length, 0)
|
||
const fremd = mit([page('https://www.facebook.com/beispiel', 'info@beispiel-elektro.de')])
|
||
assert.deepEqual([(await lesen(fremd)).status, (await lesen(fremd)).email], ['nicht_lesbar', ''])
|
||
const social = mit(SEITEN)
|
||
assert.equal((await lesen(social, 'https://www.facebook.com/beispiel')).status, 'keine_website')
|
||
assert.equal(social.llm.calls.search.length, 0)
|
||
})
|
||
test('unplausible Modellwerte (nicht im Text belegt) werden verworfen', async () => {
|
||
const o = await lesen(mit(SEITEN, { ...FUND, phone: '0631 999999', postalCode: '99999', companyName: 'Völlig Anders GmbH', street: 'Nirgendwo 1' }))
|
||
assert.deepEqual([o.phone, o.phoneSource, o.postalCode, o.companyName, o.street], ['', '', '', '', ''])
|
||
assert.equal(o.email, 'info@beispiel-elektro.de')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-ownsite.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `extractEmails`: Muster `[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}` über den Text jeder Seite, klein geschrieben, Duplikate entfernt; verworfen werden Endungen `png jpg jpeg gif svg webp css js` und Lokalteile `noreply no-reply donotreply datenschutz privacy dpo abuse webmaster postmaster`. Keine Entschleierung.
|
||
- `pickEmail`: Punkte 2 für Domain gleich `host` oder Unterdomain, 1 für Lokalteil in `info kontakt mail office hallo post`; höchste Punktzahl, bei Gleichstand die erste.
|
||
- `latestYear`: Jahre (`19xx|20xx`) hinter `©`, `(c)`, `copyright`, `stand`, `aktualisiert`, `letzte änderung` (bis 25 Nicht-Ziffern dazwischen), mit optionalem Spannenende nach `–` oder `-`; größtes Jahr ≤ `nowYear`, sonst `null`.
|
||
- `mailDomainOk`: erst `resolveMx` (nicht leer ⇒ true), dann `resolve4`; `ENOTFOUND`/`ENODATA`/leere Antwort ⇒ nächster Schritt bzw. am Ende false; jeder andere Fehlercode ⇒ true.
|
||
- `readOwnSite`: `host = normalizeDomain(website)`; leer ⇒ `keine_website` ohne Aufruf (das deckt Facebook, Instagram und Verzeichnisse ab, weil `normalizeDomain` für sie `''` liefert). Sonst `llmOf(deps).search(deps, { workerId, system: SCOUT_SYSTEM, task, maxUses: 4, allowedDomains: [host, 'www.' + host] })`; `task` nennt die Adresse und verlangt Startseite, Impressum, notfalls Kontaktseite; `SCOUT_SYSTEM` verlangt: nur diese Domain, nichts erraten, keine sozialen Netzwerke, keine Zusammenfassung. `eigene = r.pages.filter(p => normalizeDomain(p.url) === host)`; leer ⇒ `robots`, wenn ein `toolErrors`-Eintrag mit `tool === 'web_fetch'` und `code === 'url_not_allowed'` vorliegt, sonst `nicht_lesbar`. Mail = `pickEmail(extractEmails(kontaktSeiten), host)` mit `kontaktSeiten` = die eigenen Seiten, deren URL-Pfad `impressum`, `imprint`, `kontakt` oder `contact` enthält (eine Adresse von der Startseite oder aus einem Beitrag zählt nicht, so verspricht es der Datenschutztext in T20); `emailSource` = deren Seiten-URL. `impressumUrl` = Seite der Mail, sonst erste Seite mit `impress` in der URL, sonst `''`. Extraktion mit `ask(deps, { workerId, system: EXTRACT_SYSTEM, input: <bis zu 3 eigene Seiten, Impressum zuerst, je 12 000 Zeichen, mit URL-Überschrift>, schema: OWNSITE_SCHEMA, maxTokens: 700 })`. Prüfung gegen den Seitentext: Telefon nur, wenn seine Ziffernfolge in der Ziffernfolge des Textes vorkommt (dann `phoneSource = impressumUrl`); PLZ, Straße und Ort nur, wenn wörtlich im Text; `companyName` und `contactName` nur, wenn `normalizeName(wert)` in `normalizeName(text)` steht; sonst `''`. `websiteOrigin` = Ursprung der ersten eigenen Seite; `latestYear` aus der ersten Seite.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/ownsite.js test/p6-ownsite.test.js && git commit -m "p6: eigene Website lesen, Mail nur wörtlich aus dem Text" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T09: Kandidat wird Interessent (ohne Places-Rohdaten, ohne Telefon bei Heilberufen)
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/assemble.js`
|
||
- Test: `test/p6-assemble.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Ergebnisse aus T06 (`place`), T07 (`osm`), T08 (`own`); `settings.retentionDays`.
|
||
- Produces: `mapsUrl(placeId) → 'https://www.google.com/maps/place/?q=place_id:' + placeId`; `buildStub({ now: Date, run, place, settings }) → Prospect-Dokumentfelder`; `buildPatch({ now: Date, run, place, own, osm, settings }) → Teilfelder für update`. `run = { room, workerId, gewerk, ort, isHealth }`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { buildStub, buildPatch } from '../src/scouts/assemble.js'
|
||
|
||
const jetzt = new Date('2026-10-20T03:30:00Z')
|
||
const run = { room: 'handwerk', workerId: 'hanna', gewerk: 'Elektriker', ort: 'Kaiserslautern', isHealth: false }
|
||
const place = { placeId: 'ChIJa', name: 'Original Google Name', websiteUri: 'https://alt.example/', rating: 4.7, userRatingCount: 987, businessStatus: 'OPERATIONAL' }
|
||
const own = { status: 'ok', websiteOrigin: 'https://beispiel-elektro.de', companyName: 'Elektro Beispiel GmbH', contactName: 'Max Beispiel', email: 'info@beispiel-elektro.de', emailSource: 'https://beispiel-elektro.de/impressum', phone: '0631 123456', phoneSource: 'https://beispiel-elektro.de/impressum', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern', impressumUrl: 'https://beispiel-elektro.de/impressum' }
|
||
const osm = { osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Elektro Müller', phone: '+49 631 111', website: 'https://mueller-elektro.de', street: 'Hauptstraße 9', postalCode: '67655', city: 'Kaiserslautern' }
|
||
const settings = { retentionDays: 90 }
|
||
|
||
test('R1: Stub und Patch enthalten keine Places-Rohdaten, nur placeId', () => {
|
||
const stub = buildStub({ now: jetzt, run, place, settings })
|
||
const alles = JSON.stringify([stub, buildPatch({ now: jetzt, run, place, own, osm: null, settings }), buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm: null, settings })])
|
||
for (const roh of ['Original Google Name', '4.7', '987', 'alt.example']) assert.ok(!alles.includes(roh), roh)
|
||
assert.deepEqual([stub.placeId, stub.name, stub.sourceUrl, stub.city], ['ChIJa', 'nicht verfügbar', 'https://www.google.com/maps/place/?q=place_id:ChIJa', 'Kaiserslautern'])
|
||
assert.deepEqual([stub.status, stub.entrance, stub.room, stub.foundBy, stub.source, stub.isHealth, stub.foundAt, stub.purgeAfter], ['gefunden', 'ohne_website', 'handwerk', 'hanna', 'places', false, '2026-10-20T03:30:00.000Z', '2027-01-18T03:30:00.000Z'])
|
||
})
|
||
test('Patch: Name aus dem Impressum vor OSM, Telefon nur mit Quelle, Website nur aus eigener Seite oder OSM', () => {
|
||
const p = buildPatch({ now: jetzt, run, place, own, osm, settings })
|
||
assert.deepEqual([p.name, p.contactName, p.email, p.emailSource, p.phone, p.phoneSource, p.website, p.street, p.postalCode, p.city, p.sourceUrl],
|
||
['Elektro Beispiel GmbH', 'Max Beispiel', 'info@beispiel-elektro.de', 'https://beispiel-elektro.de/impressum', '0631 123456', 'https://beispiel-elektro.de/impressum', 'https://beispiel-elektro.de', 'Hauptstraße 5', '67655', 'Kaiserslautern', 'https://beispiel-elektro.de/impressum'])
|
||
const nurOsm = buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm, settings })
|
||
assert.deepEqual([nurOsm.name, nurOsm.phone, nurOsm.phoneSource, nurOsm.website, nurOsm.sourceUrl], ['Elektro Müller', '+49 631 111', 'https://www.openstreetmap.org/node/7', 'https://mueller-elektro.de', 'https://www.openstreetmap.org/node/7'])
|
||
const nichts = buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm: null, settings })
|
||
assert.deepEqual([nichts.name, nichts.website, nichts.email, nichts.city, nichts.sourceUrl], ['nicht verfügbar', '', '', 'Kaiserslautern', 'https://www.google.com/maps/place/?q=place_id:ChIJa'])
|
||
})
|
||
test('R5: Heilberuf – keine Telefonnummer aus Impressum oder OSM', () => {
|
||
const arzt = { ...run, room: 'aerzte', workerId: 'ali', isHealth: true }
|
||
assert.deepEqual([buildPatch({ now: jetzt, run: arzt, place, own, osm, settings }).phone, buildPatch({ now: jetzt, run: arzt, place, own, osm, settings }).phoneSource], ['', ''])
|
||
const physio = { ...run, room: 'selbst', workerId: 'sven', gewerk: 'Physiotherapie', isHealth: true }
|
||
const q = buildPatch({ now: jetzt, run: physio, place, own: { status: 'keine_website' }, osm, settings })
|
||
assert.deepEqual([q.phone, q.phoneSource], ['', ''])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-assemble.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `buildStub`: `{ entrance: 'ohne_website', room, foundBy: workerId, source: 'places', sourceUrl: mapsUrl(placeId), foundAt: now.toISOString(), name: 'nicht verfügbar', placeId, city: run.ort, isHealth: run.isHealth, status: 'gefunden', purgeAfter: new Date(now.getTime() + retentionDays * 86400000).toISOString(), updatedAt: now.toISOString() }`. `buildPatch`: Vorrang je Feld `own` vor `osm` vor Standard (`name` ⇒ `'nicht verfügbar'`, Rest `''`, `city` ⇒ `run.ort`); `website` = `own.websiteOrigin` wenn `own.status === 'ok'`, sonst `osm.website` oder `''`; `sourceUrl` = `own.impressumUrl` oder `osm.osmUrl` oder `mapsUrl(placeId)`; `phone` und `phoneSource` (`own.phoneSource` bzw. `osm.osmUrl`) nur wenn `!run.isHealth`; E-Mail und Quelle nur aus `own`; **kein** Feld liest `place.name`, `place.rating`, `place.userRatingCount` oder `place.websiteUri`; `updatedAt` setzen.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/assemble.js test/p6-assemble.test.js && git commit -m "p6: Interessent ohne Places-Rohdaten, Heilberufe ohne Telefon" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T10: Bewertung (Punkteformel, Ausschlussgründe, Beas Urteil)
|
||
|
||
**Files:**
|
||
- Create: `src/scoring/business.js`, `src/scoring/bea.js`
|
||
- Test: `test/p6-scoring.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llmOf(deps)`, `ask(deps, { workerId:'bea', system, input, schema, maxTokens })`, `assertCap('bea','score')`.
|
||
- Produces (`business.js`, rein): `MIN_SCORE_MAIL = 55`, `MAX_AUDIT_FOR_MAIL = 5`, `GRUND` (feste Grundtexte, siehe Step 3), `reviewPoints(count) → 0..20`, `ratingPoints(rating|null) → 0..10`, `gapPoints({ websiteState, auditScore }) → 0..35`, `scoreBusiness(f) → { score, parts, reason }`, `ausschlussGrund(f) → string | null`, `rankCandidates(list) → list`. `f = { websiteState: 'keine'|'vorhanden', auditScore: number|null, reviewCount, rating, sympathie, kette, hasMail, mailProblem: 'mx'|null }`.
|
||
- Produces (`bea.js`): `BEA_SCHEMA`, `ersatzUrteil({ latestYear, nowYear }) → { auditScore, sympathie, kette, belege }`, `rateCandidates(deps, { items, nowYear }) → Promise<{ urteile: Map<id, { auditScore, sympathie, kette, belege }>, ersatz: boolean }>` mit `items = [{ id, gewerk, ort, websiteState, siteText, latestYear }]`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { MIN_SCORE_MAIL, reviewPoints, ratingPoints, gapPoints, scoreBusiness, ausschlussGrund, rankCandidates } from '../src/scoring/business.js'
|
||
import { ersatzUrteil, rateCandidates } from '../src/scoring/bea.js'
|
||
import { makeDeps, fakeLlm } from './support/p6.js'
|
||
|
||
const f = (o = {}) => ({ websiteState: 'keine', auditScore: null, reviewCount: 120, rating: 4.8, sympathie: 8, kette: false, hasMail: true, mailProblem: null, ...o })
|
||
|
||
test('Punkte: Beispielrechnungen mit Begründung ohne Rohwerte', () => {
|
||
const a = scoreBusiness(f())
|
||
assert.deepEqual(a.parts, { gap: 35, reviews: 20, rating: 10, sympathy: 8, noChain: 10, mail: 15 })
|
||
assert.equal(a.score, 98)
|
||
assert.equal(a.reason, 'Lücke 35, Bewertungen 20, Note 10, Sympathie 8, keine Kette 10, Mail 15; Summe 98')
|
||
assert.equal(scoreBusiness(f({ websiteState: 'vorhanden', auditScore: 4, reviewCount: 30, rating: 4.5, sympathie: 6 })).score, 70)
|
||
})
|
||
test('Stufen an den Grenzen', () => {
|
||
assert.deepEqual([100, 99, 50, 49, 25, 24, 15, 14, 5, 4].map(reviewPoints), [20, 16, 16, 12, 12, 8, 8, 4, 4, 0])
|
||
assert.deepEqual([4.8, 4.79, 4.6, 4.3, 4.0, 3.99, null].map(ratingPoints), [10, 8, 8, 6, 3, 0, 0])
|
||
assert.deepEqual([['keine', null], ['vorhanden', 0], ['vorhanden', 5], ['vorhanden', 10]].map(([websiteState, auditScore]) => gapPoints({ websiteState, auditScore })), [35, 35, 18, 0])
|
||
})
|
||
test('Ausschlussgründe in fester Reihenfolge', () => {
|
||
assert.equal(ausschlussGrund(f({ hasMail: false, kette: true })), 'keine veröffentlichte Mail')
|
||
assert.equal(ausschlussGrund(f({ hasMail: false, mailProblem: 'mx' })), 'Adresse nicht zustellbar')
|
||
assert.equal(ausschlussGrund(f({ kette: true })), 'Kette oder Franchise')
|
||
assert.equal(ausschlussGrund(f({ websiteState: 'vorhanden', auditScore: 6 })), 'Website in gutem Zustand')
|
||
assert.equal(ausschlussGrund(f({ websiteState: 'vorhanden', auditScore: 5 })), null)
|
||
assert.equal(MIN_SCORE_MAIL, 55)
|
||
})
|
||
test('rankCandidates: Punkte absteigend, bei Gleichstand früherer Fund, dann id', () => {
|
||
const l = [{ id: 'b', score: 70, foundAt: '2026-10-20T01:00:00Z' }, { id: 'a', score: 70, foundAt: '2026-10-20T01:00:00Z' }, { id: 'c', score: 90, foundAt: '2026-10-20T02:00:00Z' }, { id: 'd', score: 70, foundAt: '2026-10-19T23:00:00Z' }]
|
||
assert.deepEqual(rankCandidates(l).map((x) => x.id), ['c', 'd', 'a', 'b'])
|
||
})
|
||
|
||
const item = (id, o = {}) => ({ id, gewerk: 'Elektriker', ort: 'Mainz', websiteState: 'vorhanden', siteText: 'x', latestYear: 2016, ...o })
|
||
test('rateCandidates: Werte begrenzt, fehlende Urteile ersetzt, "keine Website" erzwingt Audit null', async () => {
|
||
const deps = makeDeps()
|
||
deps.llm = fakeLlm({ ask: async () => ({ data: { urteile: [{ id: 'a', auditScore: 14, sympathie: -3, kette: true, belege: ['Copyright 2016'] }, { id: 'b', auditScore: 3, sympathie: 7, kette: false, belege: [] }] }, usage: {}, costUsd: 0.01 }) })
|
||
const { urteile, ersatz } = await rateCandidates(deps, { items: [item('a'), item('b', { websiteState: 'keine', siteText: '', latestYear: null }), item('c', { latestYear: 2025 })], nowYear: 2026 })
|
||
assert.deepEqual(urteile.get('a'), { auditScore: 10, sympathie: 0, kette: true, belege: ['Copyright 2016'] })
|
||
assert.deepEqual(urteile.get('b'), { auditScore: null, sympathie: 7, kette: false, belege: [] })
|
||
assert.deepEqual(urteile.get('c'), { auditScore: 7, sympathie: 5, kette: false, belege: ['ohne KI-Urteil'] })
|
||
assert.equal(ersatz, false)
|
||
assert.equal(deps.llm.calls.ask[0].workerId, 'bea')
|
||
})
|
||
test('rateCandidates: scheitert der Aufruf, kommt ein Ersatzurteil statt Verlust', async () => {
|
||
const deps = makeDeps()
|
||
deps.llm = fakeLlm({ ask: async () => { throw new Error('API down') } })
|
||
const { urteile, ersatz } = await rateCandidates(deps, { items: [item('a')], nowYear: 2026 })
|
||
assert.deepEqual([ersatz, urteile.get('a').auditScore], [true, 3])
|
||
assert.deepEqual([2016, 2022, 2025, null].map((latestYear) => ersatzUrteil({ latestYear, nowYear: 2026 }).auditScore), [3, 5, 7, 5])
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-scoring.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- Stufen: `reviewPoints` ≥100:20, ≥50:16, ≥25:12, ≥15:8, ≥5:4, sonst 0; `ratingPoints` ≥4.8:10, ≥4.6:8, ≥4.3:6, ≥4.0:3, sonst 0 (`null` ⇒ 0); `gapPoints`: `keine` ⇒ 35, sonst `Math.round(35 - 3.5 * auditScore)`; `scoreBusiness`: `noChain` 10 wenn `!kette`, `mail` 15 wenn `hasMail`, `sympathy` = `sympathie`; `reason` genau im Format des Tests.
|
||
- `GRUND` (exakt): `KEINE_MAIL 'keine veröffentlichte Mail'`, `MX 'Adresse nicht zustellbar'`, `KETTE 'Kette oder Franchise'`, `WEBSITE_GUT 'Website in gutem Zustand'`, `SCHWELLE 'Punktzahl unter 55'`, `VERALTET 'veraltet (nicht ausgewählt)'`, `BEKANNT 'schon bekannt (E-Mail oder Telefon)'`, `GESPERRT 'Sperrliste'`, `LESEN 'Lesen fehlgeschlagen'`, `ABGEBROCHEN 'Lauf unterbrochen'`.
|
||
- `ausschlussGrund` prüft in der Reihenfolge `mailProblem` ⇒ MX, `!hasMail` ⇒ KEINE_MAIL, `kette` ⇒ KETTE, (`websiteState === 'vorhanden'` und `auditScore > MAX_AUDIT_FOR_MAIL`) ⇒ WEBSITE_GUT, sonst `null`.
|
||
- `rankCandidates`: Kopie, sortiert nach `score` absteigend, `foundAt` aufsteigend, `id` aufsteigend.
|
||
- `ersatzUrteil`: `latestYear` ≤ `nowYear − 5` ⇒ `auditScore` 3; ≤ `nowYear − 3` ⇒ 5; `null` ⇒ 5; sonst 7; `sympathie` 5, `kette` false, `belege ['ohne KI-Urteil']`.
|
||
- `rateCandidates`: `assertCap('bea','score')`; `ask(deps, { workerId: 'bea', system: BEA_SYSTEM, input: JSON.stringify({ kandidaten: items }), schema: BEA_SCHEMA, maxTokens: 2500 })`. `BEA_SCHEMA` = Objekt mit `urteile` (Liste aus Objekten mit `id` string, `auditScore` integer, `sympathie` integer, `kette` boolean, `belege` Liste von Strings), überall `additionalProperties:false`. `BEA_SYSTEM` verlangt: Website nur nach dem gelieferten Text beurteilen (0 = sehr schlecht, 10 = sehr gut; technisch 4, Inhalt 4, Pflege 2 Punkte), Sympathie 0–10 aus dem Ton des Textes (Bewertungstexte werden nicht abgerufen), `kette` true bei Filial- oder Franchise-Auftritt, höchstens 3 kurze Belege, nichts erfinden. Auswertung: Zahlen auf 0…10 begrenzen und runden, `websiteState 'keine'` ⇒ `auditScore null`, `belege` höchstens 3 Strings zu je 80 Zeichen, fehlende Ids ⇒ `ersatzUrteil`; wirft der Aufruf (auch `BudgetStop`), für alle Ersatzurteile und `ersatz: true`.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scoring/business.js src/scoring/bea.js test/p6-scoring.test.js && git commit -m "p6: Punkteformel, Ausschlussgründe, Beas Urteil" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T11: Arne legt Bot-Leads ab
|
||
|
||
**Files:**
|
||
- Create: `src/archive/botleads.js`
|
||
- Test: `test/p6-botleads-ablage.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `emit`, `assertCap('arne','archive')`, `SCOUT_ROOMS` (Raumbeschriftung).
|
||
- Produces: `fileBotLead(deps, { prospectId, reason, dropContact = true }) → Promise<void>`. Setzt `status 'botlead'`, `botLeadReason` (höchstens 500 Zeichen), leert bei `dropContact` `email`, `emailSource`, `contactName` (Datensparsamkeit: Bot-Leads werden nie angeschrieben), behält Telefon samt Quelle, Punkte und Register-Schlüssel; Ereignis `botlead` (Arne).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { fileBotLead } from '../src/archive/botleads.js'
|
||
import { makeDeps, seedProspect } from './support/p6.js'
|
||
|
||
test('fileBotLead: Status, Grund, Kontaktdaten weg, Ereignis ohne Namen oder Adressen', async () => {
|
||
const deps = makeDeps()
|
||
const p = await seedProspect(deps.db, { phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', contactName: 'Max Beispiel' })
|
||
const grund = 'Platz 4 von 6; freie Mail-Plätze: 3'
|
||
await fileBotLead(deps, { prospectId: p.$id, reason: grund })
|
||
const d = await deps.db.get('workersProspects', p.$id)
|
||
assert.deepEqual([d.status, d.botLeadReason, d.email, d.emailSource, d.contactName, d.phone, d.score], ['botlead', grund, '', '', '', '0631 111', 80])
|
||
const ev = deps.db.docs('workersEvents').find((e) => e.kind === 'botlead')
|
||
assert.deepEqual([ev.workerId, ev.text, ev.prospectId, ev.link], ['arne', `Bot-Lead (Handwerk, Kaiserslautern): ${grund}`, p.$id, `/workers?tab=botleads&bot=${p.$id}`])
|
||
assert.ok(!/Beispiel|@/.test(ev.text))
|
||
await fileBotLead(deps, { prospectId: p.$id, reason: grund })
|
||
assert.equal(deps.db.docs('workersEvents').filter((e) => e.kind === 'botlead').length, 1, 'zweiter Aufruf ändert nichts')
|
||
})
|
||
test('Grund wird auf 500 Zeichen gekürzt; weitergeschrittene oder für Mail vorgesehene Interessenten werden nie Bot-Lead', async () => {
|
||
const deps = makeDeps()
|
||
const a = await seedProspect(deps.db, {})
|
||
await fileBotLead(deps, { prospectId: a.$id, reason: 'x'.repeat(900) })
|
||
assert.equal((await deps.db.get('workersProspects', a.$id)).botLeadReason.length, 500)
|
||
const b = await seedProspect(deps.db, { status: 'kontaktiert' })
|
||
const c = await seedProspect(deps.db, { threadId: 't1' })
|
||
await assert.rejects(() => fileBotLead(deps, { prospectId: b.$id, reason: 'x' }), /Bot-Lead/)
|
||
await assert.rejects(() => fileBotLead(deps, { prospectId: c.$id, reason: 'x' }), /Bot-Lead/)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-botleads-ablage.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** Interessenten lesen (`db.get` liefert `null` ⇒ `Error('Interessent fehlt')`); ist `status` schon `botlead` ⇒ zurück ohne Schreiben; ist `status` nicht `gefunden` oder `bewertet` oder `threadId` gesetzt ⇒ `Error('Bot-Lead nur für Interessenten ohne Kontakt')`; `update` mit `status`, `botLeadReason`, bei `dropContact` die drei Felder auf `''`, `updatedAt`; Ereignis `emit(deps, { workerId: 'arne', kind: 'botlead', prospectId, link: '/workers?tab=botleads&bot=<id>', text: 'Bot-Lead (<Raumbeschriftung>, <Ort oder „Ort unbekannt“>): <Grund, höchstens 200 Zeichen>' })`.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/archive/botleads.js test/p6-botleads-ablage.test.js && git commit -m "p6: Arne legt Bot-Leads ab, ohne Kontaktdaten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T12: Suchlauf eines Scouts
|
||
|
||
**Files:**
|
||
- Create: `src/scouts/run.js`
|
||
- Test: `test/p6-run.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `claimKeys`, `keysFor`, `isSuppressed`, `emit`, `checkBudget`, `book`, `BudgetStop`, `getSettings`, `nowOf` (P4 `src/gespraeche/util.js`), `mailSlots` (T04), `pickQuery` (T05), `PLACES_REQUEST_USD`, `buildStub`, `buildPatch`, `readOwnSite`, `mailDomainOk`, `scoreBusiness`, `ausschlussGrund`, `GRUND`, `rateCandidates`, `fileBotLead`, `deps.places`, `deps.osm`, `deps.dns`, `llmOf`.
|
||
- Produces: `runScoutJob(deps, job) → Promise<{ fresh, doppelt, botleads, bewertet, gestoppt: null|'budget'|'zeit'|'plaetze_voll'|'keine_abfrage' }>`; `job` ist ein P1-Auftrag (`id`, `workerId`, `payload` = `{ night, run, room, region, candidates, entrance }` von P1s `planNight`; `region` ist das Bundesland aus `settings.regions`). `runScoutJob` beendet den Auftrag nicht selbst: `drainScouts` (T24) ruft danach `finish` bzw. `fail`. Ein Fehler beim Abruf einer Places-Seite wirft; Fehler bei einem einzelnen Kandidaten werden im Lauf aufgefangen (Schritt 7).
|
||
|
||
- [ ] **Step 1: Test schreiben.** Domain-Schlüssel gelten je registrierbarer Domain; die Testbetriebe haben deshalb je eine eigene Domain `chija-elektro.de`, `chijd-elektro.de` usw.
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { runScoutJob } from '../src/scouts/run.js'
|
||
import { claimKeys, keysFor } from '../src/register/keys.js'
|
||
import { CapError } from '../src/roles.js'
|
||
import { makeDeps, fakeLlm, page, seedJob, takeJob } from './support/p6.js'
|
||
|
||
const platz = (id, over = {}) => ({ placeId: id, name: `Original-Name ${id}`, websiteUri: `https://${id.toLowerCase()}-elektro.de`, rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL', ...over })
|
||
const seiten = (host) => [page(`https://${host}/`, '© 2016 Elektro Beispiel'), page(`https://${host}/impressum`, `Impressum Elektro Beispiel GmbH Hauptstraße 5 67655 Kaiserslautern Telefon 0631 123456 info@${host}`)]
|
||
const suche = async (_d, o) => ({ text: '', sources: [], toolErrors: [], pages: seiten(o.allowedDomains[0]), usage: {}, costUsd: 0.003 })
|
||
const jobIn = (deps, over = {}, kopf = {}) => takeJob(deps, { kind: 'suche', workerId: 'hanna', payload: { night: '2026-10-19', run: 1, room: 'handwerk', region: 'Rheinland-Pfalz', candidates: 10, entrance: 'ohne_website', ...over }, ...kopf })
|
||
|
||
function szenario({ places, search = suche } = {}) {
|
||
const deps = makeDeps({ settings: { connectedRooms: ['handwerk'] } })
|
||
deps.places = { textSearch: async () => ({ places: places ?? [platz('ChIJA'), platz('ChIJB'), platz('ChIJC', { businessStatus: 'CLOSED_PERMANENTLY' })], nextPageToken: null }) }
|
||
deps.llm = fakeLlm({
|
||
search,
|
||
ask: async (_d, o) => o.workerId === 'bea'
|
||
? { data: { urteile: JSON.parse(o.input).kandidaten.map((k) => ({ id: k.id, auditScore: 3, sympathie: 8, kette: false, belege: ['Copyright 2016'] })) }, usage: {}, costUsd: 0.01 }
|
||
: { data: { companyName: 'Elektro Beispiel GmbH', contactName: '', phone: '0631 123456', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern' }, usage: {}, costUsd: 0.001 },
|
||
})
|
||
return deps
|
||
}
|
||
const nach = (deps, id) => deps.db.docs('workersProspects').find((p) => p.placeId === id)
|
||
|
||
test('Register zuerst, nur der frische Kandidat kostet; Bewertung 82; R1: keine Places-Rohdaten gespeichert', async () => {
|
||
const deps = szenario()
|
||
await claimKeys(deps, keysFor({ placeId: 'ChIJB' }), { prospectId: '', origin: 'seed_kunde', blocked: true })
|
||
const r = await runScoutJob(deps, await jobIn(deps))
|
||
assert.deepEqual([r.fresh, r.doppelt, r.gestoppt], [1, 1, null])
|
||
assert.equal(deps.db.docs('workersProspects').length, 1)
|
||
const a = nach(deps, 'ChIJA')
|
||
assert.deepEqual([a.status, a.score, a.email, a.name, a.isHealth], ['bewertet', 82, 'info@chija-elektro.de', 'Elektro Beispiel GmbH', false])
|
||
assert.equal(deps.llm.calls.search.length, 1)
|
||
const r2 = await runScoutJob(deps, await jobIn(deps))
|
||
assert.deepEqual([r2.fresh, r2.doppelt], [0, 3])
|
||
assert.equal(deps.llm.calls.search.length, 1, 'zweiter Lauf kostet nichts')
|
||
const alles = JSON.stringify(['workersProspects', 'workersEvents', 'workersJobs', 'workersSearchLog'].map((c) => deps.db.docs(c)))
|
||
assert.ok(!alles.includes('Original-Name') && !alles.includes('4.7'))
|
||
const log = deps.db.docs('workersSearchLog')[0]
|
||
assert.deepEqual([log.workerId, log.room, log.region, log.query, log.found, log.fresh], ['hanna', 'handwerk', 'Rheinland-Pfalz', 'Elektriker in Kaiserslautern', 3, 1])
|
||
const ev = deps.db.docs('workersEvents')
|
||
for (const k of ['fund', 'doppelt', 'uebergabe', 'bewertet']) assert.ok(ev.some((e) => e.kind === k), k)
|
||
assert.ok(ev.every((e) => !/Beispiel|@chija|Original/.test(e.text)))
|
||
})
|
||
test('Budget aus ⇒ kein Places-Aufruf', async () => {
|
||
const deps = szenario()
|
||
await deps.db.create('workersDaily', { costUsd: 10, mailsCold: 0, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20')
|
||
let n = 0
|
||
deps.places = { textSearch: async () => { n++; return { places: [], nextPageToken: null } } }
|
||
const r = await runScoutJob(deps, await jobIn(deps))
|
||
assert.deepEqual([r.gestoppt, n], ['budget', 0])
|
||
})
|
||
test('Fehler bei einem Kandidaten ⇒ Bot-Lead "Lesen fehlgeschlagen", der Lauf geht weiter', async () => {
|
||
let n = 0
|
||
const deps = szenario({ places: [platz('ChIJA'), platz('ChIJD')], search: async (d, o) => { n++; if (n === 1) throw new Error('boom'); return suche(d, o) } })
|
||
const r = await runScoutJob(deps, await jobIn(deps))
|
||
assert.equal(r.fresh, 2)
|
||
assert.deepEqual([nach(deps, 'ChIJA').status, nach(deps, 'ChIJA').botLeadReason, nach(deps, 'ChIJD').status], ['botlead', 'Lesen fehlgeschlagen', 'bewertet'])
|
||
})
|
||
test('Zeitgrenze 20 Minuten: unverarbeitete Kandidaten bleiben ungeclaimt und kommen im nächsten Lauf dran', async () => {
|
||
const deps = szenario({ places: [platz('ChIJA'), platz('ChIJD'), platz('ChIJE')] })
|
||
const echt = deps.llm.search
|
||
deps.llm.search = async (d, o) => { deps.clock.advance(21 * 60 * 1000); return echt(d, o) }
|
||
const r = await runScoutJob(deps, await jobIn(deps))
|
||
assert.deepEqual([r.fresh, r.gestoppt], [1, 'zeit'])
|
||
deps.llm.search = echt
|
||
assert.equal((await runScoutJob(deps, await jobIn(deps))).fresh, 2)
|
||
})
|
||
test('R5: Heilberuf – keine Nummer gespeichert und kein Telefon-Schlüssel', async () => {
|
||
const deps = szenario({ places: [platz('ChIJH')] })
|
||
deps.osm = { lookup: async () => ({ osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Praxis', phone: '+49 631 111', website: '', street: '', postalCode: '', city: '' }) }
|
||
await runScoutJob(deps, await jobIn(deps, { room: 'aerzte' }, { workerId: 'ali' })) // Abfrage „Hausarzt in Kaiserslautern“ ⇒ Heilberuf
|
||
const h = nach(deps, 'ChIJH')
|
||
assert.deepEqual([h.isHealth, h.phone, h.phoneSource, h.room], [true, '', '', 'aerzte'])
|
||
assert.ok(deps.db.docs('workersKeys').every((k) => k.kind !== 'tel'))
|
||
})
|
||
test('Mail-Plätze voll ⇒ Leerlauf-Ereignis und kein Places-Aufruf', async () => {
|
||
const deps = szenario()
|
||
for (let i = 0; i < 3; i++) await seedJob(deps.db, { kind: 'erstmail', status: 'offen' })
|
||
let n = 0
|
||
deps.places = { textSearch: async () => { n++; return { places: [], nextPageToken: null } } }
|
||
const r = await runScoutJob(deps, await jobIn(deps))
|
||
assert.deepEqual([r.gestoppt, n], ['plaetze_voll', 0])
|
||
assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'leerlauf' && e.workerId === 'hanna'))
|
||
})
|
||
test('Rollentrennung: nur Scouts mit der Fähigkeit places suchen, und nur in ihrem Raum', async () => {
|
||
const deps = szenario()
|
||
await assert.rejects(async () => runScoutJob(deps, await jobIn(deps, {}, { workerId: 'rita' })), CapError)
|
||
await assert.rejects(async () => runScoutJob(deps, await jobIn(deps, { room: 'aerzte' }, { workerId: 'hanna' })), /Scout/)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-run.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren (Ablauf, in dieser Reihenfolge).**
|
||
1. `assertCap(job.workerId, 'places')` (wirft `CapError` für Rollen ohne diese Fähigkeit); `payload = job.payload`; `SCOUT_ROOMS[payload.room]?.workerId` muss `job.workerId` sein, sonst `Error('Raum passt nicht zum Scout')`; `now = nowOf(deps)`, `start = deps.clock.now()` (Millisekunden). Dann der Reihe nach: `mailSlots(deps, { now }).free <= 0` ⇒ Ereignis `leerlauf` (Scout, Text „<Scout>: Alle Mail-Plätze sind vergeben, kein Suchlauf.“, <Scout> = Rollenname) und `gestoppt 'plaetze_voll'`; `checkBudget(deps, 0.2)` nicht ok ⇒ `gestoppt 'budget'`; `pickQuery(deps, { room: payload.room, region: payload.region, now })` ist `null` ⇒ Ereignis `leerlauf` („… hat alle Abfragen der letzten 90 Tage gesucht.“) und `gestoppt 'keine_abfrage'`; sonst liefert es `gewerk`, `ort`, `query`, `isHealth` für den ganzen Lauf.
|
||
2. Seiten holen (höchstens 3, solange weniger als `payload.candidates` frische): vor jeder Seite `checkBudget`, dann `deps.places.textSearch({ query, pageToken })` und `book(deps, { workerId, usd: PLACES_REQUEST_USD, counters: { places: 1 } })`.
|
||
3. Je Treffer der Reihe nach, **erst jetzt**: Zeitgrenze (`deps.clock.now() − start > 20 · 60 000` ⇒ `gestoppt 'zeit'`, Schleife verlassen), `checkBudget`; dann `neueId` (20 Hexzeichen aus `crypto.randomBytes(10)`) und `claimKeys(deps, keysFor({ placeId, website: websiteUri }), { prospectId: neueId, origin: 'workers' })`. `fresh` zählt nur angelegte Dokumente. `fresh === false` ⇒ `doppelt++`, Ereignis `doppelt` (Arne, Text „Schon bekannt: Kandidat in <Ort>“), weiter. `businessStatus` gesetzt und ≠ `OPERATIONAL` ⇒ weiter (Schlüssel bleiben, kein Dokument). Sonst `db.create('workersProspects', buildStub(...), neueId)` und Ereignis `fund` (Scout, Text „<Scout>: neuer Kandidat in <Ort> (<Gewerk>)“).
|
||
4. `try`: Website benutzbar (`normalizeDomain(websiteUri) !== ''`) ⇒ `readOwnSite`, sonst `{ status: 'keine_website' }`. OSM-Aufruf (nach `assertCap(workerId, 'osm')`) `deps.osm.lookup({ name: place.name, ort })` (Name nur im Speicher), wenn `own.status !== 'ok'` oder Telefon oder Straße fehlen. Hat `own` keine Website und `osm.website` ist benutzbar ⇒ `readOwnSite(osm.website)` statt `own`.
|
||
5. Mail vorhanden: `isSuppressed` ⇒ `fileBotLead(GRUND.GESPERRT)`; `claimKeys(deps, keysFor({ email, phone (nur wenn nicht isHealth), name, postalCode }), …)` mit `fresh === false` ⇒ `fileBotLead(GRUND.BEKANNT)` und Ereignis `doppelt`; `mailDomainOk(deps.dns, Domain)` falsch ⇒ Mail verwerfen, `mailProblem 'mx'`.
|
||
6. `db.update` mit `buildPatch(...)` (ohne verworfene Mail); Merkmale für Beas Urteil im Speicher sammeln (`websiteState 'vorhanden'`, wenn Places oder OSM eine benutzbare Website nannten; `siteText` = erste 3 000 Zeichen der Startseite; `latestYear`; `reviewCount`, `rating`, `hasMail`, `mailProblem`).
|
||
7. `catch`: `BudgetStop` ⇒ `fileBotLead(GRUND.ABGEBROCHEN)`, `gestoppt 'budget'`, Schleife verlassen; sonst `fileBotLead(GRUND.LESEN)`, Ereignis `fehler` (Text ohne Namen), weiter.
|
||
8. Nach den Seiten (schon abgelegte Kandidaten aus Schritt 5 und 7 sind nicht mehr dabei): Protokollzeile in `workersSearchLog` (`found` = geprüfte Treffer, `fresh` = frische, `region` = `payload.region` (das Bundesland, damit P1s Raumwahl das Protokoll liest), `query` = gewählte Abfrage, `at` jetzt); Ereignis `uebergabe` (Scout → `to 'bea'`); `rateCandidates`; je Kandidat `scoreBusiness`, `update` mit `score` und `scoreReason`, dann `ausschlussGrund` ⇒ `fileBotLead(grund)` sonst `status 'bewertet'`; ein Ereignis `bewertet` (Bea, Text „<n> Kandidaten bewertet, bester Wert <max>“, `meta.score` = bester Wert, P2-E6); `book(deps, { workerId, usd: 0, counters: { gefunden: fresh, doppelt } })`; Rückgabe der Zahlen (kein `finish`).
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scouts/run.js test/p6-run.test.js && git commit -m "p6: Suchlauf eines Scouts mit Register, Places, Impressum und Bewertung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T13: Top 3 über alle Räume der Nacht
|
||
|
||
**Files:**
|
||
- Create: `src/scoring/night.js`
|
||
- Test: `test/p6-night.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `mailSlots` (T04), `rankCandidates`, `MIN_SCORE_MAIL`, `GRUND`, `fileBotLead`, `enqueue`, `emit`, `ensureThread(deps, { prospectId, toEmail, subject, channel? })` (P4 `src/gespraeche/threads.js`: legt den Faden an und setzt `prospect.threadId`).
|
||
- Produces: `selectNightTop3(deps, { now: Date }) → Promise<{ slots, selected: string[], botleads: string[] }>`. Auswahl: Interessenten mit `entrance 'ohne_website'`, `status 'bewertet'`, ohne `threadId`, `foundAt` der letzten 72 Stunden; je gewähltem Betrieb ein Faden (über `ensureThread`, `toEmail` = gespeicherte Adresse), Auftrag `erstmail` (Max, `threadId`), Ereignis `top3` (Bea → Max). Zusätzlich werden Nachzügler abgelegt: Interessenten `status 'gefunden'` mit `foundAt` älter als 12 Stunden (der Lauf ist abgestürzt, sie wurden nie bewertet) ⇒ Bot-Lead mit Grund `GRUND.ABGEBROCHEN`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { selectNightTop3 } from '../src/scoring/night.js'
|
||
import { makeDeps, at, seedProspect, seedThread } from './support/p6.js'
|
||
|
||
const JETZT = at('2026-10-20T03:30:00Z')
|
||
async function szenario(settings = {}) {
|
||
const deps = makeDeps({ settings: { privacyInteressentenLive: true, ...settings } })
|
||
const mk = (score, over = {}) => seedProspect(deps.db, { score, name: `Betrieb ${score}`, status: 'bewertet', foundAt: '2026-10-19T20:00:00Z', ...over })
|
||
const p = {}
|
||
for (const s of [90, 80, 70, 60, 54]) p[s] = await mk(s)
|
||
p.zugewiesen = await mk(99, { threadId: 't-alt' })
|
||
p.anfrage = await mk(95, { entrance: 'anfrage' })
|
||
p.alt = await mk(85, { foundAt: '2026-10-16T20:00:00Z' })
|
||
p.abgestuerzt = await mk(0, { status: 'gefunden', foundAt: '2026-10-19T10:00:00Z' }) // Lauf abgestürzt, nie bewertet
|
||
p.frisch = await mk(0, { status: 'gefunden', foundAt: '2026-10-20T00:00:00Z' }) // Lauf vielleicht noch aktiv
|
||
await seedThread(deps.db, { followupDueAt: '2026-10-20T05:00:00Z' })
|
||
return { deps, p }
|
||
}
|
||
const grund = async (deps, p) => (await deps.db.get('workersProspects', p.$id)).botLeadReason
|
||
|
||
test('Top 3 über alle Räume: freie Plätze = Limit minus fällige Nachfass; Rest mit Grund in die Liste', async () => {
|
||
const { deps, p } = await szenario()
|
||
const r = await selectNightTop3(deps, { now: JETZT })
|
||
assert.equal(r.slots.free, 2)
|
||
assert.deepEqual([...r.selected].sort(), [p[90].$id, p[80].$id].sort())
|
||
const jobs = deps.db.docs('workersJobs').filter((j) => j.kind === 'erstmail')
|
||
assert.deepEqual(jobs.map((j) => j.prospectId).sort(), [p[90].$id, p[80].$id].sort())
|
||
assert.ok(jobs.every((j) => j.workerId === 'max' && j.threadId))
|
||
assert.equal((await deps.db.get('workersProspects', p[90].$id)).threadId, jobs.find((j) => j.prospectId === p[90].$id).threadId)
|
||
assert.equal(await grund(deps, p[70]), 'Platz 3 von 4; freie Mail-Plätze: 2')
|
||
assert.equal(await grund(deps, p[60]), 'Platz 4 von 4; freie Mail-Plätze: 2')
|
||
assert.equal(await grund(deps, p[54]), 'Punktzahl unter 55')
|
||
assert.equal(await grund(deps, p.alt), 'veraltet (nicht ausgewählt)')
|
||
assert.equal(await grund(deps, p.abgestuerzt), 'Lauf unterbrochen')
|
||
assert.equal((await deps.db.get('workersProspects', p.frisch.$id)).status, 'gefunden')
|
||
for (const unberuehrt of [p.zugewiesen, p.anfrage]) assert.equal((await deps.db.get('workersProspects', unberuehrt.$id)).status, 'bewertet')
|
||
assert.deepEqual(deps.db.docs('workersEvents').filter((e) => e.kind === 'top3').map((e) => e.text).sort(), ['Platz 1 von 4: Punktzahl 90', 'Platz 2 von 4: Punktzahl 80'])
|
||
})
|
||
test('R4: ein zweiter Lauf ändert nichts', async () => {
|
||
const { deps } = await szenario()
|
||
await selectNightTop3(deps, { now: JETZT })
|
||
const vorher = deps.db.writes.length
|
||
const r2 = await selectNightTop3(deps, { now: JETZT })
|
||
assert.deepEqual([r2.selected, r2.botleads], [[], []])
|
||
assert.equal(deps.db.writes.length, vorher)
|
||
})
|
||
test('R4: ohne freie Plätze geht niemand in die Mail-Schlange', async () => {
|
||
const { deps, p } = await szenario({ mailColdPerDay: 1 }) // ein Platz, ein Nachfass fällig ⇒ 0 frei
|
||
const r = await selectNightTop3(deps, { now: JETZT })
|
||
assert.deepEqual(r.selected, [])
|
||
assert.equal(deps.db.docs('workersJobs').length, 0)
|
||
assert.equal(await grund(deps, p[90]), 'Platz 1 von 4; freie Mail-Plätze: 0')
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-night.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `slots = await mailSlots(deps, { now })`. Nachzügler: `Query.equal('entrance', ['ohne_website'])` und `Query.equal('status', ['gefunden'])` (Index `entrance+status`), im Code `foundAt` älter als 12 Stunden ⇒ `fileBotLead(GRUND.ABGEBROCHEN)`. Laden der Kandidaten: `Query.equal('entrance', ['ohne_website'])` und `Query.equal('status', ['bewertet'])`, danach im Code ohne `threadId`. Älter als 72 Stunden ⇒ `fileBotLead(GRUND.VERALTET)`. Rest: Punkte `< MIN_SCORE_MAIL` ⇒ `fileBotLead(GRUND.SCHWELLE)`; die übrigen (`m` Stück) mit `rankCandidates` ordnen: Rang `1…slots.free` bekommen Faden, Auftrag und Ereignis (Text `Platz <n> von <m>: Punktzahl <score>`), alle weiteren `fileBotLead('Platz <n> von <m>; freie Mail-Plätze: <free>')`. Ohne Kandidaten nichts schreiben. Reihenfolge je gewähltem Betrieb: `ensureThread(deps, { prospectId, toEmail: prospect.email, subject: '' })` (setzt `prospect.threadId`), Auftrag einreihen (`enqueue(deps, { kind: 'erstmail', workerId: 'max', prospectId, threadId, payload: { rank, of: m, score } })`), Ereignis.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/scoring/night.js test/p6-night.test.js && git commit -m "p6: Top-3-Auswahl über alle Räume der Nacht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T14: Max entwirft die Kaltmail (Prüfung, Ersatztext, nur Code für Pflichtteile)
|
||
|
||
**Files:**
|
||
- Create: `src/cold/draft.js`
|
||
- Test: `test/p6-draft.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `llmOf(deps)`, `ask(deps, { workerId:'max', system, input, schema, maxTokens })`, `assertCap('max','draft')`, `normalizeName`, `SCOUT_ROOMS` (Branche).
|
||
- Produces: `KI_ZEILE`, `MAX_KALT_SYSTEM`, `KALT_SCHEMA`, `qaColdMail({ subject, absaetze }, prospect) → string[]` (Befunde), `composeColdMail({ variante: 'erst'|'nachfass', prospect, absaetze?, settings, erstDatum? }) → string` (Text ohne Pflichtblock), `fallbackColdMail({ prospect, settings }) → { subject, absaetze }`, `draftColdMail(deps, { prospect, settings }) → Promise<{ subject, text, usedFallback }>`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { qaColdMail, composeColdMail, fallbackColdMail, draftColdMail, KI_ZEILE, MAX_KALT_SYSTEM } from '../src/cold/draft.js'
|
||
import { makeDeps, fakeLlm } from './support/p6.js'
|
||
|
||
const prospekt = { name: 'Elektro Beispiel GmbH', city: 'Kaiserslautern', room: 'handwerk', contactName: 'Max Beispiel', email: 'info@beispiel-elektro.de', phone: '0631 123456' }
|
||
const settings = { offerText: 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.' }
|
||
const gut = { subject: 'Website-Vorschau für Elektro Beispiel GmbH', absaetze: ['Ich bin auf Elektro Beispiel GmbH in Kaiserslautern gestoßen und habe mir erlaubt, Ihnen etwas vorzuschlagen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website, ohne dass Sie dafür etwas tun müssen.'] }
|
||
|
||
test('qaColdMail: gute Mail ohne Befund, jede Regel schlägt einzeln an', () => {
|
||
assert.deepEqual(qaColdMail(gut, prospekt), [])
|
||
const mit = (o) => qaColdMail({ ...gut, ...o }, prospekt)
|
||
assert.ok(mit({ subject: 'Jetzt Website sichern!' }).length)
|
||
assert.ok(mit({ subject: 'x'.repeat(56) }).length) // P4s checkMailDraft erlaubt höchstens 55 Zeichen
|
||
assert.deepEqual(mit({ subject: 'x'.repeat(55) }), [])
|
||
assert.ok(mit({ absaetze: [...gut.absaetze, 'Mehr unter https://beispiel.de'] }).length)
|
||
assert.ok(mit({ absaetze: [...gut.absaetze, 'Für nur 29 € im Monat.'] }).length)
|
||
assert.ok(mit({ absaetze: [...gut.absaetze, 'Wir geben 20 % Rabatt.'] }).length)
|
||
assert.ok(mit({ absaetze: [...gut.absaetze, 'Wie geht es dir und deinem Betrieb?'] }).length)
|
||
assert.ok(mit({ absaetze: ['Sehr geehrte Damen und Herren, wir bieten Websites an.'] }).length)
|
||
assert.ok(mit({ absaetze: [...gut.absaetze, 'wort '.repeat(100)] }).length)
|
||
assert.ok(mit({ absaetze: ['Wir bieten Websites an.'] }).length, 'weder Name noch Ort')
|
||
})
|
||
test('composeColdMail erst: Anrede, KI-Hinweis, Absätze, Angebot wörtlich, Antwortzeile, Gruß – ohne Link', () => {
|
||
const t = composeColdMail({ variante: 'erst', prospect: prospekt, absaetze: gut.absaetze, settings })
|
||
const z = t.split('\n\n')
|
||
assert.deepEqual([z[0], z[1]], ['Guten Tag Max Beispiel,', KI_ZEILE])
|
||
assert.equal(KI_ZEILE, 'Ich bin ein KI-Assistent von WEBklar und schreibe Ihnen im Auftrag von Kenso Grimm und Justin Klein.')
|
||
assert.ok(t.includes(settings.offerText))
|
||
assert.ok(t.includes('Wenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.'))
|
||
assert.ok(t.endsWith('Freundliche Grüße\nKI-Assistent von WEBklar, im Auftrag von Kenso Grimm und Justin Klein'))
|
||
assert.ok(!/https?:|www\./.test(t))
|
||
assert.equal(composeColdMail({ variante: 'erst', prospect: { ...prospekt, contactName: '' }, absaetze: gut.absaetze, settings }).split('\n\n')[0], 'Guten Tag,')
|
||
})
|
||
test('composeColdMail nachfass: kurze Erinnerung mit Datum, ohne Angebotssatz', () => {
|
||
const t = composeColdMail({ variante: 'nachfass', prospect: prospekt, settings, erstDatum: '2026-10-20' })
|
||
assert.ok(t.includes('Ich melde mich noch einmal kurz zu unserer Nachricht vom 20.10.2026'))
|
||
assert.ok(t.includes('Die Gratis-Vorschau einer neuen Website für Elektro Beispiel GmbH steht weiterhin bereit.'))
|
||
assert.ok(!t.includes(settings.offerText))
|
||
})
|
||
test('draftColdMail: zweite Antwort bekommt die Befundliste; die Eingabe an das Modell enthält weder Mail noch Telefon', async () => {
|
||
let n = 0
|
||
const deps = makeDeps()
|
||
deps.llm = fakeLlm({ ask: async () => { n++; return { data: n === 1 ? { subject: 'Hallo!', absaetze: ['Für 29 € im Monat.'] } : gut, usage: {}, costUsd: 0.01 } } })
|
||
const r = await draftColdMail(deps, { prospect: prospekt, settings })
|
||
assert.deepEqual([r.usedFallback, n, r.subject], [false, 2, gut.subject])
|
||
assert.ok(deps.llm.calls.ask[1].input.includes('Befund'))
|
||
const erste = deps.llm.calls.ask[0]
|
||
assert.ok(!erste.input.includes('info@beispiel-elektro.de') && !erste.input.includes('0631'))
|
||
assert.equal(erste.workerId, 'max')
|
||
for (const s of ['Sie-Form', 'keine Links', 'keine Preise']) assert.ok(MAX_KALT_SYSTEM.includes(s), s)
|
||
})
|
||
test('draftColdMail: zwei schlechte Antworten ⇒ Ersatztext, der selbst alle Prüfungen besteht', async () => {
|
||
const deps = makeDeps()
|
||
deps.llm = fakeLlm({ ask: async () => ({ data: { subject: 'Hallo!', absaetze: ['x'] }, usage: {}, costUsd: 0.01 }) })
|
||
const r = await draftColdMail(deps, { prospect: prospekt, settings })
|
||
const e = fallbackColdMail({ prospect: prospekt, settings })
|
||
assert.equal(r.usedFallback, true)
|
||
assert.deepEqual(qaColdMail(e, prospekt), [])
|
||
assert.ok(r.text.includes(settings.offerText))
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-draft.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- Befunde von `qaColdMail` (je ein deutscher Satz): Betreff länger als 55 Zeichen (wie P4s `checkMailDraft`), mit „!“ oder mit „Re:“; Absätze mit `https?:`, `www.` oder `@`; mit `€`, `EUR`, `Euro`, `%`, `Prozent`, `Rabatt`, `Preis`, `Festpreis`, `garantier`; Du-Form (`\b(du|dich|dir|dein\w*)\b`, ohne Beachtung der Schreibung); „Sehr geehrte“; mehr als 110 Wörter in den Absätzen; weder ein Wort (≥ 4 Zeichen) aus `normalizeName(prospect.name)` noch `prospect.city` kommt vor.
|
||
- `composeColdMail` baut aus Absätzen getrennt durch Leerzeile: Anrede (`Guten Tag <contactName>,` oder `Guten Tag,`), `KI_ZEILE`, bei `erst` die Absätze, dann `settings.offerText` wörtlich, dann `Wenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.`; bei `nachfass` statt dessen genau: `Ich melde mich noch einmal kurz zu unserer Nachricht vom <TT.MM.JJJJ>: Die Gratis-Vorschau einer neuen Website für <Name> steht weiterhin bereit. Wenn Sie sie sehen möchten, antworten Sie einfach mit „Ja“. Wenn nicht, ist das völlig in Ordnung – dann melden wir uns nicht mehr bei Ihnen.`; Schluss immer `Freundliche Grüße\nKI-Assistent von WEBklar, im Auftrag von Kenso Grimm und Justin Klein`.
|
||
- `fallbackColdMail`: Betreff `Website-Vorschau für <Name>` (ist er länger als 55 Zeichen: `Website-Vorschau für Ihren Betrieb`), Absätze `Wir sind auf <Name> in <Ort> aufmerksam geworden und möchten Ihnen etwas anbieten: Wir erstellen Ihnen eine Vorschau einer neuen Website, ohne dass Sie dafür etwas tun müssen.` und `Sie sehen sich die Vorschau in Ruhe an und entscheiden danach, ob sie Ihnen gefällt.`
|
||
- `draftColdMail`: `assertCap('max','draft')`; bis zu zwei Versuche mit `ask(deps, { workerId: 'max', system: MAX_KALT_SYSTEM, input: JSON.stringify({ betrieb, ort, branche: <Raumbeschriftung>, ansprechpartner }), schema: KALT_SCHEMA, maxTokens: 700 })` (zweiter Versuch hängt `\n\nBefund aus der letzten Prüfung:\n- …` an); `qaColdMail` leer ⇒ fertig; nach dem zweiten Befund oder bei einem Fehler, der kein `BudgetStop` ist, Ersatztext (`usedFallback: true`); `BudgetStop` wird nicht abgefangen. `MAX_KALT_SYSTEM` muss die Stichworte „Sie-Form“, „keine Links“, „keine Preise“ und „höchstens 90 Wörter“ enthalten und verlangen: persönlich und kurz, nur Name, Ort und Branche verwenden, nichts über die Website des Betriebs behaupten, nichts versprechen.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/cold/draft.js test/p6-draft.test.js && git commit -m "p6: Kaltmail entwerfen mit Prüfung und Ersatztext" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T15: Pflichtblock der Kaltmail und Zusatzprüfung am Sende-Tor (E6)
|
||
|
||
**Files:**
|
||
- Create: `src/mail/coldFooter.js`, `src/mail/coldChecks.js`
|
||
- Modify: `src/mail/footer.js` (P4: Zweig `cold:true`), `src/mail/gate.js` (P4: nur der eine Datenschutz-Schalter, Step 3), die davon betroffenen P4-Tests
|
||
- Test: `test/p6-gate-cold.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes (P4): `registerGateCheck(fn) → unregister` (`fn(deps, ctx) → reason|null`, `ctx = { prospect, thread, to, text, cold, author, settings, first }`), `sendMail(deps, { workerId:'max', prospectId, threadId?, to, subject, text, cold, kind?, author?, inReplyTo?, references?, idempotencyKey? }) → { sent, reason?, messageId? }`, `appendCompliance(text, { prospect, cold, unsubscribeUrl, first, human, offerText })` (P4-E3); `makeDeps`, `seedProspect`, `seedThread` (T01).
|
||
- Produces: `DATENSCHUTZ_URL = 'https://www.webklar.com/datenschutz#interessenten'`; `buildColdFooter({ prospect, unsubscribeUrl }) → string` (wirft `Error('Quelle der Adresse fehlt')` ohne `emailSource`); `appendCompliance(..., cold:true)` = `text.trimEnd()` + Leerzeile + Pflichtblock, für Erst- **und** Nachfass-Mail, unabhängig von `first`; `coldChecks({ prospect, thread, to, cold, settings }) → { ok: true } | { ok: false, reason }` mit Gründen `datenschutz` (derselbe Grund wie in P4, vorübergehend: der Auftrag bleibt offen), `kein_kaltkontakt`, `status_unpassend`, `adresse_nicht_belegt`, `kaltmail_ohne_antwort`; `coldGateCheck(deps, ctx) → reason|null`; `registerColdGateCheck() → unregister`.
|
||
|
||
- [ ] **Step 1: Test schreiben.** Das echte Tor, der Mailer und die Konfiguration (Abmelde-Basis, Geheimnis) kommen über `makeDeps` aus P4s Hilfen.
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { buildColdFooter, DATENSCHUTZ_URL } from '../src/mail/coldFooter.js'
|
||
import { appendCompliance } from '../src/mail/footer.js'
|
||
import { coldChecks, registerColdGateCheck } from '../src/mail/coldChecks.js'
|
||
import { sendMail } from '../src/mail/gate.js'
|
||
import { makeDeps, seedProspect, seedThread } from './support/p6.js'
|
||
|
||
const prospekt = { entrance: 'ohne_website', status: 'bewertet', email: 'info@beispiel-elektro.de', emailSource: 'https://beispiel-elektro.de/impressum' }
|
||
const UNSUB = 'https://entwurf.project.webklar.com/abmelden/abc.p1'
|
||
|
||
test('buildColdFooter: alle Pflichtangaben und genau drei Adressen', () => {
|
||
const f = buildColdFooter({ prospect: prospekt, unsubscribeUrl: UNSUB })
|
||
for (const s of ['KI-Assistent von WEBklar', 'WEBklar GbR (Kenso Grimm und Justin Klein), Am Schwimmbad 10, 67722 Winnweiler', 'Ihrem Impressum: https://beispiel-elektro.de/impressum', 'Art. 6 Abs. 1 lit. f DSGVO', 'widersprechen (Art. 21 DSGVO)', UNSUB, 'Mensch']) assert.ok(f.includes(s), s)
|
||
assert.equal(DATENSCHUTZ_URL, 'https://www.webklar.com/datenschutz#interessenten')
|
||
assert.deepEqual(f.match(/https?:\/\/\S+/g), ['https://beispiel-elektro.de/impressum', DATENSCHUTZ_URL, UNSUB])
|
||
assert.ok(buildColdFooter({ prospect: { ...prospekt, emailSource: 'https://beispiel-elektro.de/kontakt' }, unsubscribeUrl: UNSUB }).includes('Ihrer Kontaktseite: https://beispiel-elektro.de/kontakt'))
|
||
assert.throws(() => buildColdFooter({ prospect: { ...prospekt, emailSource: '' }, unsubscribeUrl: UNSUB }), /Quelle/)
|
||
})
|
||
test('appendCompliance cold: Text, Leerzeile, Pflichtblock – erste und spätere Kaltmail gleich', () => {
|
||
const erwartet = `Hallo\n\n${buildColdFooter({ prospect: prospekt, unsubscribeUrl: UNSUB })}`
|
||
for (const first of [true, false]) assert.equal(appendCompliance('Hallo\n', { prospect: prospekt, cold: true, first, unsubscribeUrl: UNSUB }), erwartet)
|
||
})
|
||
|
||
const T = (o = {}) => ({ prospect: { ...prospekt, ...o.prospect }, thread: { lastInboundAt: '', ...o.thread }, to: 'info@beispiel-elektro.de', cold: true, settings: { privacyInteressentenLive: true }, ...o.rest })
|
||
test('R3: coldChecks – jeder Verstoß hat seinen Grund, Tarnung als Antwort wird erkannt', () => {
|
||
assert.deepEqual(coldChecks(T()), { ok: true })
|
||
assert.equal(coldChecks(T({ rest: { settings: { privacyInteressentenLive: false } } })).reason, 'datenschutz')
|
||
assert.equal(coldChecks(T({ rest: { to: 'andere@beispiel-elektro.de' } })).reason, 'adresse_nicht_belegt')
|
||
assert.equal(coldChecks(T({ rest: { to: 'INFO@Beispiel-Elektro.de' } })).ok, true)
|
||
assert.equal(coldChecks(T({ prospect: { emailSource: '' } })).reason, 'adresse_nicht_belegt')
|
||
assert.equal(coldChecks(T({ prospect: { entrance: 'anfrage' } })).reason, 'kein_kaltkontakt')
|
||
assert.equal(coldChecks(T({ prospect: { status: 'gespraech' } })).reason, 'status_unpassend')
|
||
assert.equal(coldChecks(T({ rest: { cold: false } })).reason, 'kaltmail_ohne_antwort')
|
||
assert.equal(coldChecks(T({ rest: { cold: false }, thread: { lastInboundAt: '2026-10-20T09:00:00Z' } })).ok, true)
|
||
assert.equal(coldChecks(T({ prospect: { entrance: 'anfrage' }, rest: { cold: false } })).ok, true)
|
||
})
|
||
test('R3: Tor – Verstöße verweigert, sonst Pflichtblock, Abmelde-Header und Nachweis der Quelle', async (t0) => {
|
||
t0.after(registerColdGateCheck())
|
||
const deps = makeDeps({ now: '2026-10-20T09:00:00Z' }) // Di 11:00 Berlin, Schalter an
|
||
const p = await seedProspect(deps.db, {})
|
||
const t = await seedThread(deps.db, { prospectId: p.$id, toEmail: p.email })
|
||
const basis = { workerId: 'max', prospectId: p.$id, threadId: t.$id, to: p.email, subject: 'Website-Vorschau', text: 'Guten Tag,\n\nHallo.', cold: true }
|
||
assert.equal((await sendMail(deps, { ...basis, cold: false })).reason, 'kaltmail_ohne_antwort')
|
||
await deps.db.update('workersThreads', t.$id, { toEmail: 'andere@beispiel-elektro.de' }) // Faden und Empfänger stimmen überein (P4 lässt es zu), die veröffentlichte Adresse nicht
|
||
assert.equal((await sendMail(deps, { ...basis, to: 'andere@beispiel-elektro.de' })).reason, 'adresse_nicht_belegt')
|
||
await deps.db.update('workersThreads', t.$id, { toEmail: p.email })
|
||
assert.equal(deps.mailer.sent.length, 0)
|
||
assert.equal((await sendMail(deps, basis)).sent, true)
|
||
const m = deps.mailer.sent[0]
|
||
assert.ok(m.text.includes('Ihrem Impressum: https://beispiel-elektro.de/impressum') && m.text.includes('Art. 21 DSGVO'))
|
||
assert.match(m.headers['List-Unsubscribe'], /<https:\/\/entwurf\.project\.webklar\.com\/abmelden\/[A-Za-z0-9_-]{22}\.[^>]+>/)
|
||
assert.equal(m.headers['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click')
|
||
const msg = deps.db.docs('workersMessages').find((d) => d.direction === 'out')
|
||
assert.deepEqual([msg.countsAsCold, msg.contactSource], [true, p.emailSource])
|
||
const aus = makeDeps({ now: '2026-10-20T09:00:00Z', settings: { privacyInteressentenLive: false } })
|
||
const p2 = await seedProspect(aus.db, {})
|
||
const t2 = await seedThread(aus.db, { prospectId: p2.$id, toEmail: p2.email })
|
||
assert.equal((await sendMail(aus, { ...basis, prospectId: p2.$id, threadId: t2.$id, to: p2.email })).reason, 'datenschutz')
|
||
assert.equal(aus.mailer.sent.length, 0)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-gate-cold.test.js` ⇒ FAIL, `Cannot find module '../src/mail/coldFooter.js'`.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `coldFooter.js` (**exakter Wortlaut**, `<…>` wird eingesetzt; Quelle: „Ihrem Impressum“, wenn die URL `impress` enthält, sonst „Ihrer Kontaktseite“):
|
||
|
||
```
|
||
--
|
||
Hinweise zu dieser Nachricht
|
||
Diese Nachricht hat ein KI-Assistent von WEBklar geschrieben und verschickt, im Auftrag der WEBklar GbR (Kenso Grimm und Justin Klein), Am Schwimmbad 10, 67722 Winnweiler, Telefon 0176 23726355, support@webklar.com.
|
||
Ihre Kontaktdaten haben wir aus <Ihrem Impressum|Ihrer Kontaktseite>: <emailSource>
|
||
Wir nutzen sie, um Ihnen einmalig ein Angebot zu machen (berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO). Alle Angaben dazu: https://www.webklar.com/datenschutz#interessenten
|
||
Sie können der Verarbeitung Ihrer Daten jederzeit widersprechen (Art. 21 DSGVO): Antworten Sie einfach auf diese Nachricht oder melden Sie sich mit einem Klick ab: <unsubscribeUrl>
|
||
Danach schreiben wir Ihnen nicht mehr. Möchten Sie lieber mit einem Menschen sprechen? Schreiben Sie uns „Mensch“, dann antwortet Ihnen ein Mitarbeiter.
|
||
```
|
||
- `footer.js` (P4-T04): Zweig `cold` ⇒ `${text.trimEnd()}\n\n${buildColdFooter({ prospect, unsubscribeUrl })}` (statt P4s kurzem Block); `cold:false` unverändert. P4s Tests, die für Kaltmails den alten Block erwarten (`test/mail-footer.test.js`: Fall „Kalt, ohne_website“ und „Kalt ohne emailSource“; `test/mail-gate.test.js`: „Erfolg … body endet mit Impressum-Zeile“), werden auf `buildColdFooter` bzw. dessen letzte Zeile umgestellt.
|
||
- `coldChecks.js` (rein): bei `cold:true` in der Reihenfolge Datenschutz-Schalter (`settings.privacyInteressentenLive` falsch ⇒ `datenschutz`), `entrance !== 'ohne_website'` ⇒ `kein_kaltkontakt`, `status` nicht `bewertet|kontaktiert` ⇒ `status_unpassend`, `emailSource` leer oder `to` (klein geschrieben, getrimmt) ≠ `prospect.email` ⇒ `adresse_nicht_belegt`; bei `cold:false`: `entrance === 'ohne_website'` und `thread.lastInboundAt` leer ⇒ `kaltmail_ohne_antwort`; sonst `{ ok: true }`. `coldGateCheck = (deps, ctx) => { const r = coldChecks({ prospect: ctx.prospect, thread: ctx.thread, to: ctx.to, cold: ctx.cold, settings: ctx.settings }); return r.ok ? null : r.reason }`; `registerColdGateCheck = () => registerGateCheck(coldGateCheck)`. P4s Tor ruft die Prüfung nach der Sperrliste und vor Tageszähler und Sendefenster auf (P4-T07); den Nachweis der Quelle (`contactSource`), die Abmelde-Header und die Message-ID liefert P4-T08 bereits.
|
||
- Ein Schalter: Liest P4s `precheck` noch `deps.config.datenschutzOk` (Env `WORKERS_DATENSCHUTZ_OK`), wird diese Zeile durch `ctx.settings.privacyInteressentenLive` ersetzt (P3-E8 gilt für Mail und Clara), `datenschutzOk` fällt aus `loadConfig`, `makeP4Deps`, `makeDeps` (T01) und P4s Betriebsanleitung; P4s Gate-Tests setzen den Schalter dann mit `updateSettings(deps, { privacyInteressentenLive: false })`. Steht es dort schon so, entfällt dieser Punkt.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; danach `timeout 300 node --max-old-space-size=1024 --test test/mail-footer.test.js test/mail-gate-precheck.test.js test/mail-gate.test.js` (P4) ⇒ weiter grün.
|
||
- [ ] **Step 5: Commit.** `git add src/mail/coldFooter.js src/mail/coldChecks.js src/mail/footer.js src/mail/gate.js test/p6-gate-cold.test.js test/mail-footer.test.js test/mail-gate-precheck.test.js test/mail-gate.test.js && git commit -m "p6: Pflichtblock der Kaltmail und Zusatzprüfung am Sende-Tor" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"` (nur die tatsächlich geänderten Dateien aufnehmen).
|
||
|
||
### P6-T16: Entwürfe für P4s Sende-Tick und Nachfass-Aufträge (E8)
|
||
|
||
P4s Tick `senden` (`runSenden`, P4-T30) holt die Aufträge `erstmail` und `nachfass` selbst, prüft Pause, Sendefenster, Tageslimit (Nachfass vor Erstmails), Budget und Sperrliste, stellt zurück, ohne Versuche zu verbrauchen, ruft `deps.drafters[kind]` für den Text, sendet über das Tor und setzt danach Faden, Status `kontaktiert`, `followupDueAt` und `followupSent` (`onOutbound`). P6 baut deshalb keinen eigenen Sendeablauf, sondern nur die beiden Entwurfsfunktionen und legt die Nachfass-Aufträge an.
|
||
|
||
**Files:**
|
||
- Create: `src/cold/flow.js`
|
||
- Test: `test/p6-flow.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `draftColdMail`, `composeColdMail` (T14), `mailSlots`, `followupThreads` (T04), `getSettings`, `enqueue`, `emit`, `BudgetStop`; P4: `runSenden(deps)` (`src/gespraeche/senden.js`), `ensureThread`, `lastOutbound` (`src/gespraeche/threads.js`), `getDoc`, `nowOf` (`src/gespraeche/util.js`), `registerColdGateCheck` (T15), Hilfen aus T01.
|
||
- Produces: `erstmailDrafter(deps, job) → Promise<{ subject, text }>`; `nachfassDrafter(deps, job) → Promise<{ subject, text, inReplyTo, references }>`; `registerColdDrafters(deps) → deps` (setzt `deps.drafters.erstmail` und `.nachfass`, andere Einträge bleiben); `scheduleFollowups(deps) → Promise<{ erzeugt: number }>`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { BudgetStop } from '../src/claude.js'
|
||
import { runSenden } from '../src/gespraeche/senden.js'
|
||
import { ensureThread } from '../src/gespraeche/threads.js'
|
||
import { enqueue } from '../src/jobs.js'
|
||
import { registerColdGateCheck } from '../src/mail/coldChecks.js'
|
||
import { registerColdDrafters, scheduleFollowups } from '../src/cold/flow.js'
|
||
import { makeDeps, fakeLlm, seedProspect, seedThread } from './support/p6.js'
|
||
|
||
const gutes = { subject: 'Website-Vorschau für Elektro Beispiel GmbH', absaetze: ['Ich bin auf Elektro Beispiel GmbH in Kaiserslautern gestoßen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website.'] }
|
||
async function szenario(t0, settings = {}) {
|
||
t0.after(registerColdGateCheck())
|
||
const deps = makeDeps({ now: '2026-10-20T09:00:00Z', settings }) // Di 11:00 Berlin, Schalter an
|
||
deps.llm = fakeLlm({ ask: async () => ({ data: gutes, usage: {}, costUsd: 0.01 }) })
|
||
registerColdDrafters(deps)
|
||
const p = await seedProspect(deps.db, {})
|
||
const t = await ensureThread(deps, { prospectId: p.$id, toEmail: p.email, subject: '' })
|
||
const job = await enqueue(deps, { kind: 'erstmail', workerId: 'max', prospectId: p.$id, threadId: t.$id, payload: {} })
|
||
return { deps, p, t, job }
|
||
}
|
||
|
||
test('Erstmail: Entwurf von P6, Versand, Status und Nachfass-Termin durch P4s Tick', async (t0) => {
|
||
const { deps, p, t, job } = await szenario(t0)
|
||
assert.equal((await runSenden(deps)).sent, 1)
|
||
assert.equal(deps.mailer.sent.length, 1)
|
||
const m = deps.mailer.sent[0]
|
||
assert.ok(JSON.stringify(m.to).includes(p.email))
|
||
assert.equal(m.subject, gutes.subject)
|
||
assert.ok(m.text.includes('KI-Assistent von WEBklar') && m.text.includes('Art. 21 DSGVO'))
|
||
assert.equal((await deps.db.get('workersProspects', p.$id)).status, 'kontaktiert')
|
||
const td = await deps.db.get('workersThreads', t.$id)
|
||
assert.deepEqual([td.status, td.followupDueAt.slice(0, 10), td.followupSent], ['wartet_auf_kunde', '2026-10-27', false])
|
||
assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'entwurf' && e.workerId === 'max'))
|
||
assert.equal((await deps.db.get('workersJobs', job.id)).status, 'fertig')
|
||
})
|
||
test('Drafter ohne Budget: der Auftrag bleibt offen, nichts wird gesendet, kein Versuch verbraucht', async (t0) => {
|
||
const { deps, job } = await szenario(t0)
|
||
deps.llm = fakeLlm({ ask: async () => { throw new BudgetStop('tag') } }) // Konstruktor wie in P1
|
||
await runSenden(deps)
|
||
assert.equal(deps.mailer.sent.length, 0)
|
||
const jb = await deps.db.get('workersJobs', job.id)
|
||
assert.deepEqual([jb.status, jb.attempts], ['offen', 0])
|
||
})
|
||
test('scheduleFollowups: ein Auftrag je fälligem Faden bis Tagesende, nie ein zweiter', async () => {
|
||
const deps = makeDeps({ now: '2026-10-27T03:30:00Z' }) // Di 04:30 Berlin (MEZ)
|
||
const mk = async (over) => { const p = await seedProspect(deps.db, { status: 'kontaktiert' }); return seedThread(deps.db, { prospectId: p.$id, followupDueAt: '2026-10-27T09:05:00Z', ...over }) }
|
||
const faellig = await mk({})
|
||
await mk({ followupSent: true }) // schon gesendet
|
||
await mk({ followupDueAt: '2026-10-28T09:05:00Z' }) // erst morgen
|
||
await mk({ status: 'ja' }) // hat geantwortet
|
||
assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 1 })
|
||
assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 })
|
||
const j = deps.db.docs('workersJobs')
|
||
assert.deepEqual([j.length, j[0].kind, j[0].workerId, j[0].threadId, j[0].prospectId], [1, 'nachfass', 'max', faellig.$id, faellig.prospectId])
|
||
await deps.db.update('workersJobs', j[0].$id, { status: 'fertig' })
|
||
assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 }) // auch ein beendeter Auftrag zählt: höchstens ein Nachfass je Faden
|
||
})
|
||
test('R4: Nachfass genau einmal, mit Re:-Betreff, Bezug auf die Erstmail und deren Datum', async (t0) => {
|
||
const { deps, t } = await szenario(t0)
|
||
await runSenden(deps) // Erstmail am Di 20.10.
|
||
deps.clock.set('2026-10-27T03:30:00Z') // Di 27.10. 04:30 Berlin
|
||
assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 1 })
|
||
deps.clock.set('2026-10-27T09:00:00Z') // 10:00 Berlin, im Sendefenster
|
||
assert.equal((await runSenden(deps)).sent, 1)
|
||
const [erst, nach] = deps.mailer.sent
|
||
assert.equal(nach.subject, `Re: ${erst.subject}`)
|
||
assert.equal(nach.inReplyTo, erst.messageId)
|
||
assert.ok(nach.text.includes('Ich melde mich noch einmal kurz zu unserer Nachricht vom 20.10.2026'))
|
||
const td = await deps.db.get('workersThreads', t.$id)
|
||
assert.deepEqual([td.followupSent, td.followupDueAt ?? null], [true, null])
|
||
assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 })
|
||
assert.equal((await runSenden(deps)).sent, 0)
|
||
assert.equal(deps.mailer.sent.length, 2)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-flow.test.js` ⇒ FAIL, `Cannot find module '../src/cold/flow.js'`.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `erstmailDrafter(deps, job)`: `prospect = await getDoc(deps, 'workersProspects', job.prospectId)` (fehlt er ⇒ `Error('Interessent fehlt')`), `settings = await getSettings(deps)`; `draftColdMail` (T14) und `composeColdMail({ variante: 'erst', prospect, absaetze, settings })`; Ereignis `entwurf` (Max, `prospectId`, Text „Erstmail entworfen (<Ort>)“); Rückgabe `{ subject, text }`. `BudgetStop` wird nicht abgefangen.
|
||
- `nachfassDrafter(deps, job)`: Faden (`job.threadId`) und letzte ausgehende Nachricht (`lastOutbound`, P4) laden; fehlt eine ⇒ `Error('Erstmail fehlt')`; `erstDatum` = Berliner Datum von `sentAt`; `composeColdMail({ variante: 'nachfass', prospect, settings, erstDatum })`; Ereignis `entwurf` (Text „Nachfass entworfen (<Ort>)“); Rückgabe `{ subject: 'Re: ' + letzteNachricht.subject, text, inReplyTo: letzteNachricht.messageId, references: [letzteNachricht.messageId] }`.
|
||
- `registerColdDrafters(deps)`: `deps.drafters = { ...(deps.drafters ?? {}), erstmail: erstmailDrafter, nachfass: nachfassDrafter }`; Rückgabe `deps`.
|
||
- `scheduleFollowups(deps)`: `sendDay` aus `mailSlots(deps, { now: nowOf(deps) })`, Fäden aus `followupThreads(deps, { sendDay })` (T04, dieselbe Definition wie bei den reservierten Plätzen); je Faden mit vorhandenem Interessenten im Status `kontaktiert` und ohne irgendeinen Auftrag `nachfass` für diesen Faden (`listAll('workersJobs', [Query.equal('kind', ['nachfass'])])`, Rest im Code) genau ein `enqueue(deps, { kind: 'nachfass', workerId: 'max', prospectId, threadId, payload: {} })`.
|
||
- Drei Zusagen von P4s `runMailJob` (P4-T16), je mit einem Test im P4-Testfile ergänzen, falls sie dort fehlen: (1) `inReplyTo` und `references` des Drafters werden an `sendMail` durchgereicht; (2) `BudgetStop` aus einem Drafter ergibt `{ deferred: 'budget' }` (Auftrag wird mit `releaseJob` freigegeben); (3) Drafter-Texte laufen nicht durch `checkMailDraft` (P6 prüft sie in T14 selbst; die Grenze von 150 Wörtern würde KI-Zeile, Angebotssatz und Gruß mitzählen und jede Kaltmail verwerfen).
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; danach `timeout 300 node --max-old-space-size=1024 --test test/gespraeche-max.test.js test/gespraeche-senden.test.js` (P4) ⇒ grün.
|
||
- [ ] **Step 5: Commit.** `git add src/cold/flow.js test/p6-flow.test.js && git commit -m "p6: Entwurfsfunktionen für P4s Sende-Tick, Nachfass-Aufträge" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"` (P4-Dateien nur mit aufnehmen, wenn Step 3 sie geändert hat).
|
||
|
||
### P6-T17: Kürzung nach Frist (Arne, Tick `kuerzen`)
|
||
|
||
**Files:**
|
||
- Create: `src/archive/kuerzen.js`
|
||
- Test: `test/p6-kuerzen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Sammlungen `workersProspects` (Index `status`), `workersThreads`, `workersMessages` (Index auf `threadId`, P3-E9), `workersPult`, `workersOffers`; `book`, `nowOf` (P4 `src/gespraeche/util.js`).
|
||
- Produces: `FRIST_ABSAGE_TAGE = 30`, `FRIST_RUHE_TAGE = 183`, `runKuerzen(deps) → Promise<{ gekuerzt: number, uebersprungen: number }>`. Regeln: **A** `purgeAfter` erreicht und Status `gefunden|bewertet|botlead|kontaktiert` ohne eingegangene Antwort (`thread.lastInboundAt` leer) ⇒ kürzen; mit Antwort oder weiterem Status ⇒ nicht kürzen, `purgeAfter` leeren (ab dann gilt B oder C). **B** Status `verloren|gesperrt` und `updatedAt` älter als 30 Tage ⇒ kürzen. **C** Status `kontaktiert` (mit Antwort), `gespraech|ja|vorschau_bau|vorschau_gesendet|angebot` ohne `customerId` und letzte Aktivität älter als 183 Tage ⇒ kürzen. `deal` nie. Kürzen = persönliche Felder leeren, `name '[gekürzt]'`, `purgedAt` setzen, `purgeAfter` entfernen, Faden `toEmail`/`subject` leeren, Nachrichten `subject ''`, `body '[gekürzt]'`, `contactSource ''`, Pult-Einträge des Interessenten (`title '[gekürzt]'`, `originalText ''`, `draft ''`, `sourceUrl ''`) und Kauf-Links (`businessName`, `email`, `previewUrl` je `'[gekürzt]'`); `workersKeys` und `workersSuppression` bleiben unberührt.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { runKuerzen } from '../src/archive/kuerzen.js'
|
||
import { makeDeps, seedProspect } from './support/p6.js'
|
||
|
||
test('runKuerzen: Fristen A, B und C, Ausnahmen, Register und Sperrliste bleiben', async () => {
|
||
const deps = makeDeps({ now: '2026-10-20T01:30:00Z' })
|
||
const voll = { contactName: 'Max Beispiel', phone: '0631 123456', phoneSource: 'https://x.de/i', website: 'https://beispiel-elektro.de', street: 'Hauptstraße 5', placeId: 'ChIJa' }
|
||
const faellig = '2026-10-19T00:00:00Z'
|
||
const a = await seedProspect(deps.db, { ...voll, status: 'botlead', botLeadReason: 'keine veröffentlichte Mail', purgeAfter: faellig })
|
||
const b = await seedProspect(deps.db, { ...voll, status: 'kontaktiert', purgeAfter: faellig, threadId: 't-b' })
|
||
await deps.db.create('workersThreads', { prospectId: b.$id, channel: 'mail', toEmail: b.email, subject: 'S', status: 'wartet_auf_kunde', followupSent: true, reminderSent: false, createdAt: faellig }, 't-b')
|
||
await deps.db.create('workersMessages', { threadId: 't-b', direction: 'out', author: 'max', subject: 'S', body: 'Guten Tag Max Beispiel', channel: 'mail', contactSource: 'https://x.de/i', countsAsCold: true, createdAt: faellig })
|
||
const c = await seedProspect(deps.db, { ...voll, status: 'kontaktiert', purgeAfter: faellig, threadId: 't-c' })
|
||
await deps.db.create('workersThreads', { prospectId: c.$id, channel: 'mail', toEmail: c.email, subject: 'S', status: 'offen', lastInboundAt: '2026-10-10T08:00:00Z', followupSent: false, reminderSent: false, createdAt: faellig }, 't-c')
|
||
const d = await seedProspect(deps.db, { ...voll, status: 'gespraech', purgeAfter: faellig })
|
||
await seedProspect(deps.db, { ...voll, status: 'botlead', purgeAfter: '2026-10-21T00:00:00Z' })
|
||
const f = await seedProspect(deps.db, { ...voll, status: 'verloren', updatedAt: '2026-09-10T00:00:00Z' })
|
||
const g = await seedProspect(deps.db, { ...voll, status: 'gespraech', updatedAt: '2026-03-01T00:00:00Z' })
|
||
const h = await seedProspect(deps.db, { ...voll, status: 'deal', customerId: 'c1', updatedAt: '2025-01-01T00:00:00Z' })
|
||
await deps.db.create('workersPult', { kind: 'unsicher', prospectId: g.$id, title: 'Antwort von Elektro Beispiel GmbH', originalText: 'Guten Tag, wir hätten Interesse', draft: 'Entwurf', sourceUrl: 'https://x.de/i', status: 'offen', createdAt: faellig }, 'pu1')
|
||
await deps.db.create('workersOffers', { tokenHash: 'h1', prospectId: g.$id, previewId: 'v1', productKey: 'hosting', businessName: 'Elektro Beispiel GmbH', email: 'info@beispiel-elektro.de', previewUrl: 'https://entwurf.project.webklar.com/x/', status: 'abgelaufen', expiresAt: faellig, createdAt: faellig }, 'o1')
|
||
await deps.db.create('workersKeys', { kind: 'mail', prospectId: a.$id, origin: 'workers', blocked: false, hint: false, createdAt: faellig }, 'k1')
|
||
const vorher = deps.db.writes.length
|
||
|
||
assert.deepEqual(await runKuerzen(deps), { gekuerzt: 4, uebersprungen: 2 }) // a, b, f, g gekürzt; c, d übersprungen
|
||
const pa = await deps.db.get('workersProspects', a.$id)
|
||
assert.deepEqual([pa.name, pa.email, pa.emailSource, pa.phone, pa.website, pa.street, pa.placeId, pa.contactName, pa.sourceUrl], ['[gekürzt]', '', '', '', '', '', '', '', ''])
|
||
assert.deepEqual([pa.status, pa.room, pa.score, pa.botLeadReason], ['botlead', 'handwerk', 80, 'keine veröffentlichte Mail'])
|
||
assert.ok(pa.purgedAt && !pa.purgeAfter)
|
||
assert.deepEqual([(await deps.db.get('workersThreads', 't-b')).toEmail, deps.db.docs('workersMessages')[0].body, deps.db.docs('workersMessages')[0].contactSource], ['', '[gekürzt]', ''])
|
||
for (const x of [f, g]) assert.equal((await deps.db.get('workersProspects', x.$id)).email, '')
|
||
const pu = await deps.db.get('workersPult', 'pu1')
|
||
assert.deepEqual([pu.title, pu.originalText, pu.draft, pu.sourceUrl], ['[gekürzt]', '', '', ''])
|
||
const o = await deps.db.get('workersOffers', 'o1')
|
||
assert.deepEqual([o.businessName, o.email, o.previewUrl], ['[gekürzt]', '[gekürzt]', '[gekürzt]'])
|
||
for (const x of [c, d, h]) assert.equal((await deps.db.get('workersProspects', x.$id)).email, 'info@beispiel-elektro.de')
|
||
for (const x of [c, d]) assert.ok(!(await deps.db.get('workersProspects', x.$id)).purgeAfter, 'Antwort oder weiterer Status: keine Frist mehr')
|
||
assert.ok(deps.db.writes.slice(vorher).every((w) => !['workersKeys', 'workersSuppression'].includes(w.col)))
|
||
assert.deepEqual(await runKuerzen(deps), { gekuerzt: 0, uebersprungen: 0 })
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 300 node --max-old-space-size=1024 --test test/p6-kuerzen.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `jetzt = nowOf(deps)`. Alle Auswahlen über `Query.equal('status', [...])` (Index `status`), Fristen im Code: A über die Status `gefunden|bewertet|botlead|kontaktiert` mit `purgeAfter ≤ jetzt`, B über `verloren|gesperrt`, C über `kontaktiert|gespraech|ja|vorschau_bau|vorschau_gesendet|angebot`; C zählt nicht als „übersprungen“, wenn die Frist noch läuft. Interessenten mit `purgedAt` werden übersprungen, ohne zu zählen. Letzte Aktivität (C) = größtes aus `updatedAt` und den Zeitpunkten `lastInboundAt`, `lastOutboundAt` des Fadens (Faden über `prospect.threadId`, `db.get` kann `null` liefern). Pult-Einträge und Kauf-Links: je Lauf einmal `listAll('workersPult')` und `listAll('workersOffers')` (kleine Sammlungen, kein Index auf `prospectId` nötig) und im Code nach `prospectId` der zu kürzenden Interessenten zuordnen. Beim Kürzen leeren: `contactName email emailSource phone phoneSource website street postalCode placeId requestText sourceUrl scoreReason`; `city` bleibt (Ortsangabe ohne Personenbezug, für die Auswertung der Suchläufe). Am Ende `book(deps, { workerId: 'arne', usd: 0, counters: { gekuerzt: n } })` (nur wenn `n > 0`); keine Ereignisse.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/archive/kuerzen.js test/p6-kuerzen.test.js && git commit -m "p6: Kürzung nach Frist, Register und Sperrliste bleiben" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T18: API `GET /api/workers/botleads` (E7)
|
||
|
||
**Files:**
|
||
- Create: `src/api/botleads.js` (die Route hängt über die `routes`-Registry von P3-E6 ein; `src/api.js` bleibt unberührt)
|
||
- Test: `test/p6-botleads-api.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SCOUT_ROOMS`, `normalizeName`, `getDoc` (P4 `src/gespraeche/util.js`), Sammlung `workersProspects` (Index `entrance+status`).
|
||
- Produces: `class BadRequest extends Error`; `listBotLeads(deps, { room = '', q = '', page = 1, id = '' }, { pageSize = 50 } = {}) → Promise<{ items, total }>` mit `item = { id, room, roomLabel, name, street, postalCode, city, website, score, reason, source, sourceUrl, foundAt, isHealth, gekuerzt, phone, phoneSource }` (**kein** `email`, **kein** `emailSource`; `phone` und `phoneSource` nur, wenn `phoneSource` gesetzt und `isHealth` falsch, sonst `''`); `BOTLEADS_ROUTES = [{ method: 'GET', pattern: /^\/botleads$/, handler }]` mit `handler(deps, ctx) → { status, json }` (`ctx.query` ist `URLSearchParams` mit `room`, `q`, `page`, `id`; Team-Recht prüft die Registry vorher): `200 { items, total }`, `BadRequest` ⇒ `400 { error: 'Unbekannter Raum.' }`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { listBotLeads, BadRequest, BOTLEADS_ROUTES } from '../src/api/botleads.js'
|
||
import { makeDeps, seedProspect } from './support/p6.js'
|
||
|
||
async function szenario() {
|
||
const deps = makeDeps()
|
||
const mk = (over) => seedProspect(deps.db, { status: 'botlead', botLeadReason: 'keine veröffentlichte Mail', email: '', emailSource: '', ...over })
|
||
const a = await mk({ name: 'Elektro Beispiel GmbH', city: 'Kaiserslautern', phone: '0631 111', phoneSource: 'https://x.de/i', score: 82, foundAt: '2026-10-19T20:00:00Z' })
|
||
await mk({ name: 'Praxis Dr. Muster', room: 'aerzte', isHealth: true, phone: '0631 222', phoneSource: 'https://x.de/i', foundAt: '2026-10-18T20:00:00Z' }) // Altbestand mit Nummer
|
||
const c = await mk({ name: 'Bäckerei Korn', room: 'laden', city: 'Mainz', phone: '0613 333', phoneSource: '', foundAt: '2026-10-17T20:00:00Z' })
|
||
await mk({ name: 'Nicht Bot', status: 'bewertet' })
|
||
await mk({ name: 'Anfrage', entrance: 'anfrage' })
|
||
return { deps, a, c }
|
||
}
|
||
|
||
test('listBotLeads: nur Bot-Leads der Etage, neueste zuerst, ohne E-Mail', async () => {
|
||
const { deps } = await szenario()
|
||
const r = await listBotLeads(deps, {})
|
||
assert.equal(r.total, 3)
|
||
assert.deepEqual(r.items.map((i) => i.name), ['Elektro Beispiel GmbH', 'Praxis Dr. Muster', 'Bäckerei Korn'])
|
||
assert.ok(r.items.every((i) => !('email' in i) && !('emailSource' in i)))
|
||
assert.deepEqual([r.items[0].roomLabel, r.items[0].reason, r.items[0].score], ['Handwerk', 'keine veröffentlichte Mail', 82])
|
||
})
|
||
test('R5: Telefon nur mit Quelle und nie bei Heilberuf, auch nicht aus einem Altbestand', async () => {
|
||
const { deps } = await szenario()
|
||
const n = Object.fromEntries((await listBotLeads(deps, {})).items.map((i) => [i.name, i]))
|
||
assert.deepEqual([n['Elektro Beispiel GmbH'].phone, n['Elektro Beispiel GmbH'].phoneSource], ['0631 111', 'https://x.de/i'])
|
||
assert.deepEqual([n['Praxis Dr. Muster'].phone, n['Praxis Dr. Muster'].phoneSource], ['', ''])
|
||
assert.equal(n['Bäckerei Korn'].phone, '')
|
||
})
|
||
test('Filter room und q, Seiten, id, ungültiger Raum, gekürzte Einträge', async () => {
|
||
const { deps, a, c } = await szenario()
|
||
assert.deepEqual((await listBotLeads(deps, { room: 'laden' })).items.map((i) => i.id), [c.$id])
|
||
assert.deepEqual((await listBotLeads(deps, { q: 'beispiel kaiserslautern' })).items.map((i) => i.id), [a.$id])
|
||
assert.equal((await listBotLeads(deps, { q: 'mainz' })).total, 1)
|
||
const s2 = await listBotLeads(deps, { page: 2 }, { pageSize: 2 })
|
||
assert.deepEqual([s2.total, s2.items.length], [3, 1])
|
||
assert.deepEqual((await listBotLeads(deps, { id: a.$id })).items.map((i) => i.id), [a.$id])
|
||
assert.equal((await listBotLeads(deps, { id: 'gibtsnicht' })).total, 0)
|
||
await assert.rejects(() => listBotLeads(deps, { room: 'netz' }), BadRequest)
|
||
await seedProspect(deps.db, { status: 'botlead', name: '[gekürzt]', purgedAt: '2026-10-20T00:00:00Z', email: '' })
|
||
assert.equal((await listBotLeads(deps, {})).items.find((i) => i.name === '[gekürzt]').gekuerzt, true)
|
||
})
|
||
test('Route GET /botleads: Antwort 200 mit items und total, ungültiger Raum ist 400', async () => {
|
||
const [route] = BOTLEADS_ROUTES
|
||
assert.deepEqual([route.method, route.pattern.test('/botleads'), route.pattern.test('/botleads/x')], ['GET', true, false])
|
||
const { deps } = await szenario()
|
||
const ok = await route.handler(deps, { query: new URLSearchParams('room=laden&page=1') })
|
||
assert.deepEqual([ok.status, ok.json.total], [200, 1])
|
||
assert.deepEqual(await route.handler(deps, { query: new URLSearchParams('room=netz') }), { status: 400, json: { error: 'Unbekannter Raum.' } })
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-botleads-api.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.** `listBotLeads`: `room` nicht leer und nicht in `SCOUT_ROOMS` ⇒ `BadRequest('Unbekannter Raum.')`; `page` ganzzahlig ≥ 1 sonst 1; `q` auf 100 Zeichen gekürzt. Mit `id`: `getDoc(deps, 'workersProspects', id)`, nur wenn der Interessent existiert (`null` ⇒ leer), `entrance 'ohne_website'` und `status 'botlead'`, sonst `{ items: [], total: 0 }`. Sonst `db.listAll('workersProspects', [Query.equal('entrance', ['ohne_website']), Query.equal('status', ['botlead'])])`, im Speicher nach `room` filtern, `q`: alle Wörter von `normalizeName(q)` müssen in `normalizeName(name + ' ' + city + ' ' + botLeadReason + ' ' + roomLabel)` vorkommen; Sortierung `foundAt` absteigend, dann `score` absteigend; `total` vor dem Seitenschnitt; Seite 1-basiert. `gekuerzt = Boolean(purgedAt)`. `BOTLEADS_ROUTES` wie unter „Produces“: der Handler liest `room`, `q`, `page`, `id` aus `ctx.query` (`page` als Zahl, leer ⇒ 1), fängt `BadRequest` und gibt `{ status: 400, json: { error: e.message } }` zurück; die Team-Prüfung (Label `mitarbeiter` oder `admin`) übernimmt die Registry (P3-E6). Die Eintragung in `buildRoutes()` folgt in T24.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/api/botleads.js test/p6-botleads-api.test.js && git commit -m "p6: API für die Bot-Lead-Liste" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T19: WOMS-Reiter „Bot-Leads“
|
||
|
||
**Files (WOMS `/home/webklar/apps/ticket.webklar.com/dev-workers`, Branch `feature/workers`):**
|
||
- Create: `src/lib/botleadsView.js`, `src/components/workers/BotLeadList.jsx`
|
||
- Modify: `src/pages/WorkersPage.jsx` (P2-E7: `WORKERS_TABS` um den Reiter `botleads` ergänzen), `src/lib/workersApi.js` (P2: `botleads` reicht `id` durch), `src/styles/workers.css` (Klassen `wk-bl-*`)
|
||
- Test: `tests/unit/botleadsView.test.mjs`, `tests/unit/workersApi.test.mjs` (P2: eine Zeile ergänzen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `workersApi.botleads(params) → { items, total }` (P2: `createWorkersApi`, Instanz in `src/lib/workersClient.js`; P2 kennt `{ room, q, page }`, P6 ergänzt `id`).
|
||
- Produces (`botleadsView.js`, rein, ohne React): `ROOM_FILTERS`, `safeHref(url) → string`, `phoneLabel(item) → string`, `botleadRow(item) → { id, title, place, roomLabel, scoreLabel, reason, phone, sourceHref, found, gekuerzt }`, `pageInfo({ total, page, pageSize }) → { pages, hasPrev, hasNext, label }`; `BotLeadList({ api })` (React).
|
||
|
||
- [ ] **Step 1: Test schreiben.** Vorher: `cd /home/webklar/apps/ticket.webklar.com/dev-workers && ls node_modules >/dev/null 2>&1 || ln -s ../build/node_modules node_modules` (zeigt als `?? node_modules`, nie committen). Festgelegt von P2-E7: `WorkersPage.jsx` hält `WORKERS_TABS` (Ids `hochhaus`, `freigabe` von P3, `botleads` von P6) und die Instanz `const api = workersApi`; der Reiter wird über `?tab=botleads` gewählt. In `tests/unit/workersApi.test.mjs` kommt zu dem Test, der `api.botleads({ room: 'netz', q: 'Bäcker', page: 2 })` prüft, die Zeile `await api.botleads({ id: 'p1' })` mit der erwarteten Anfrage `['GET', '/api/workers/botleads?id=p1']` (der Test schlägt vor Step 3 fehl).
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs'
|
||
import { loadModule } from './load.mjs'
|
||
|
||
const { safeHref, phoneLabel, botleadRow, ROOM_FILTERS, pageInfo } = await loadModule('src/lib/botleadsView.js')
|
||
|
||
test('safeHref: nur http und https', () => {
|
||
assert.equal(safeHref('https://beispiel-elektro.de/impressum'), 'https://beispiel-elektro.de/impressum')
|
||
for (const bose of ['javascript:alert(1)', '//evil.example', 'data:text/html,x', '']) assert.equal(safeHref(bose), '', bose)
|
||
})
|
||
test('phoneLabel: Nummer nur mit Quelle, Heilberuf nie', () => {
|
||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: false }), '0631 111')
|
||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: '', isHealth: false }), 'nicht verfügbar')
|
||
assert.equal(phoneLabel({ phone: '', phoneSource: '', isHealth: true }), 'bewusst nicht gespeichert')
|
||
assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: true }), 'bewusst nicht gespeichert')
|
||
})
|
||
test('botleadRow, Raumfilter und Seitenzeile', () => {
|
||
const r = botleadRow({ id: 'a', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'javascript:x', phone: '', phoneSource: '', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false })
|
||
assert.deepEqual([r.title, r.place, r.scoreLabel, r.sourceHref, r.phone, r.found], ['Elektro Beispiel GmbH', '67655 Kaiserslautern', '82 Punkte', '', 'nicht verfügbar', '19.10.2026'])
|
||
assert.deepEqual(ROOM_FILTERS.map((x) => x.value), ['', 'aerzte', 'handwerk', 'selbst', 'laden'])
|
||
assert.deepEqual(pageInfo({ total: 120, page: 2, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: true, label: 'Seite 2 von 3 · 120 Einträge' })
|
||
assert.equal(pageInfo({ total: 0, page: 1, pageSize: 50 }).label, 'Keine Einträge')
|
||
})
|
||
test('BotLeadList.jsx: Links nur über safeHref, kein HTML-Einschub', () => {
|
||
const src = fs.readFileSync(new URL('../../src/components/workers/BotLeadList.jsx', import.meta.url), 'utf8')
|
||
assert.ok(src.includes('safeHref') && !src.includes('dangerouslySetInnerHTML'))
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `timeout 600 node --max-old-space-size=1024 --test tests/unit/botleadsView.test.mjs` ⇒ FAIL, `Cannot find module` bzw. Datei `BotLeadList.jsx` fehlt.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `botleadsView.js`: `ROOM_FILTERS` = `[{ value: '', label: 'Alle Räume' }, { value: 'aerzte', label: 'Ärzte & Praxen' }, { value: 'handwerk', label: 'Handwerk' }, { value: 'selbst', label: 'Selbstständige' }, { value: 'laden', label: 'Läden & Gastro' }]`; `safeHref`: `new URL(url)` in `try`, nur Protokoll `http:` oder `https:` ⇒ `href`, sonst `''`; `phoneLabel`: `isHealth` ⇒ „bewusst nicht gespeichert“, sonst Nummer wenn `phone` und `phoneSource` gesetzt, sonst „nicht verfügbar“; `botleadRow`: `title = name || 'nicht verfügbar'`, `place = [postalCode, city].filter(Boolean).join(' ')`, `scoreLabel = '<n> Punkte'`, `found` = Datum `TT.MM.JJJJ` in Europe/Berlin; `pageInfo`: `pages = max(1, ceil(total / pageSize))`, Label `Seite <p> von <n> · <total> Einträge`, bei `total 0` „Keine Einträge“.
|
||
- `BotLeadList.jsx` (WOMS-Weißdesign, Klassen aus `global.css`: `.toolbar`, `.select`, `.input`, `.table`, `.empty`, `.msg.err`, `.btn`): Kopfzeile mit Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“, Raumfilter (`<select>` aus `ROOM_FILTERS`), Suchfeld (300 ms Verzögerung, Seite springt auf 1), Tabelle mit den Spalten Betrieb (Titel, darunter Ort), Raum, Punkte, Grund, Telefon, Quelle (Link „Quelle öffnen“ nur wenn `sourceHref` nicht leer, `target="_blank" rel="noopener noreferrer"`), Gefunden; gekürzte Einträge grau mit „gekürzt“; Leerzustand „Noch keine Bot-Leads.“; Fehler als `.msg.err` mit dem Satz aus der API; Ladeanfragen mit Laufnummer (nur die neueste zählt); Parameter `bot` in der Adresszeile hebt die Zeile hervor und ruft die API mit `id` ab; Seitenwechsel „Zurück“/„Weiter“ mit `pageInfo`. Kein `dangerouslySetInnerHTML`, kein Polling.
|
||
- `workersApi.js`: `botleads({ room, q, page, id })` hängt `id` als Query-Parameter an, wenn gesetzt (Reihenfolge `room`, `q`, `page`, `id`, damit P2s Test unverändert bleibt).
|
||
- `WorkersPage.jsx`: `WORKERS_TABS` um `{ id: 'botleads', label: 'Bot-Leads' }` ergänzen (nach `freigabe`), im Inhalt dieses Reiters `<BotLeadList api={api} />` rendern; `workers.css`: Klassen mit Präfix `wk-bl-` (Tabelle scrollt auf dem Handy waagerecht innerhalb der Tabelle, die Seite nicht).
|
||
- [ ] **Step 4: Bestehen sehen.** `timeout 600 node --max-old-space-size=1024 --test tests/unit/botleadsView.test.mjs tests/unit/workersApi.test.mjs` ⇒ `# fail 0`. Dann Bau-Prüfung: `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 600 node node_modules/vite/bin/vite.js build --outDir "${TMPDIR:-/tmp}/p6-woms-dist"` ⇒ Zeile `built in`, keine Fehler.
|
||
- [ ] **Step 5: Commit.** `git add src/lib/botleadsView.js src/lib/workersApi.js src/components/workers/BotLeadList.jsx src/pages/WorkersPage.jsx src/styles/workers.css tests/unit/botleadsView.test.mjs tests/unit/workersApi.test.mjs && git commit -m "workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T20: Datenschutz-Abschnitt „Interessenten“ in der Datenschutzerklärung (Portal)
|
||
|
||
Die Datenschutzerklärung liegt zentral im Portal (`public/datenschutz.html`, live `https://project.webklar.com/datenschutz.html`); `webklar.com/datenschutz` leitet nur dorthin weiter (T22). Abschnitt 11 „Interessenten“ steht dort schon, mit anderem Inhalt (zwölf Monate, keine KI, keine Quellen); er wird ersetzt. Die Überschriftenliste 1–15 ist in `test/legalTexts.test.js` festgenagelt, deshalb bekommt Abschnitt 11 den Anker als eigene Zeile **vor** der Überschrift.
|
||
|
||
**Files (Portal-Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz`):**
|
||
- Modify: `public/datenschutz.html` (Stand, Abschnitte 2, 11, 12, 14), `docs/WEBklar-Datenschutzerklaerung.pdf`, `docs/WEBklar-Datenschutzerklaerung.pdf.sha256`, `test/legalTexts.test.js`
|
||
|
||
**Interfaces:**
|
||
- Produces: Anker `#interessenten` auf `https://project.webklar.com/datenschutz.html`; die Zusagen **90 Tage**, **30 Tage**, **sechs Monate**, **höchstens zwei E-Mails je Betrieb**, **höchstens drei Betriebe pro Tag**, **keine Telefonnummern bei Heilberufen** sind die Fristen und Grenzen aus P6-T17, T12 und `settings` (Übersicht §4.1); Änderungen an einer Seite verlangen die Änderung der anderen.
|
||
|
||
- [ ] **Step 0: Freigabe durch Kenso (Gate, ohne sie kein Weiterarbeiten an diesem Task).** Kenso liest den HTML-Text in Step 3 und entscheidet sechs Punkte: **Z1** Mail-Anbieter der Absenderdomain (Variante G = Google Workspace oder Variante M = Migadu; es bleibt genau eine); **Z2** Anthropic-Angaben (Vertragspartner `Anthropic Ireland, Limited`, Auftragsverarbeitung, Standardvertragsklauseln) gegen die aktuellen Anthropic-Bedingungen prüfen und nur belassen, wenn sie dort stehen; **Z3** die Fristen 90 Tage, 30 Tage, sechs Monate sind Zusagen, die der Code einhält (T17); **Z4** Betriebe, die nie angeschrieben werden (Bot-Leads), informieren wir nur durch diesen öffentlichen Text; ob das nach Art. 14 Abs. 5 lit. b DSGVO genügt, ist eine Kanzleifrage; **Z5** nach der Kürzung verschwinden auch der Text unserer Nachrichten und die Quelle der Adresse bei Betrieben, die nie geantwortet haben (kein Nachweis mehr für spätere Abmahnungen; der Sendezeitpunkt bleibt ohne Personenbezug erhalten; Alternative wäre eine längere Aufbewahrung des Nachweises, dann müssen Text und T17 geändert werden); **Z6** P3s Erstantworten auf öffentliche Anfragen (Etage „Anfragen“) und P4s Mails verlinken denselben Anker `#interessenten`; deshalb enthält der Abschnitt auch einen kurzen Absatz zu Anfragen (Quelle ist die Anfrage selbst). Soll dieser Absatz stattdessen in einen eigenen Abschnitt, entscheidet Kenso das hier, und P3 bekommt einen anderen Anker. Antwort „freigegeben“ plus Z1 ⇒ weiter; Änderungswünsche zuerst in diesem Plan und im Text einarbeiten.
|
||
- [ ] **Step 1: Worktree anlegen.** `cd /home/webklar/apps/kundenbereich.webklar.com/build && git fetch origin && git worktree add ../dev-workers-datenschutz -b feature/workers-datenschutz origin/main && cd ../dev-workers-datenschutz && ln -s ../build/node_modules node_modules` (zeigt als `?? node_modules`, nie committen). Hinweis: P5 ändert unter Umständen denselben Abschnitt 7; die Absätze sind getrennt, ein Konflikt ist trivial zu lösen.
|
||
- [ ] **Step 2: Fehlschlagende Tests schreiben** (ans Ende von `test/legalTexts.test.js`, dessen Hilfen `read`, `strip`, `between` und die Konstante `DS` schon da sind).
|
||
|
||
```js
|
||
test('legal_datenschutz_interessenten: Abschnitt 11 mit Anker, Quelle, Zweck, Rechtsgrundlage, Fristen und Widerspruch', () => {
|
||
const html = read(DS)
|
||
assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten<\/h2>/)
|
||
const s = between(strip(html), '11. Interessenten', '12. Empfänger im Überblick')
|
||
for (const t of ['höchstens zwei E-Mails', 'höchstens drei Betriebe pro Tag', 'KI-Assistent im Auftrag von WEBklar', 'Art. 14 DSGVO', 'Place-ID', 'öffentlich nach einer Website gefragt',
|
||
'ausschließlich dem Impressum oder der Kontaktseite Ihrer eigenen Website', 'bei Heilberufen nie', 'Art. 6 Abs. 1 lit. f DSGVO', 'teilweise automatisiert',
|
||
'Anthropic Ireland, Limited', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO)', 'spätestens 90 Tage nach dem Fund', 'innerhalb von 30 Tagen',
|
||
'sechs Monate nach der letzten Nachricht', 'Hash-Werte', 'Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)', 'support@webklar.com']) assert.ok(s.includes(t), t)
|
||
assert.ok(!s.includes('zwölf Monaten'))
|
||
assert.equal(Number(s.includes('Google Workspace')) + Number(s.includes('Migadu')), 1, 'genau ein Mail-Anbieter')
|
||
assert.ok(!/\{|TODO|XXX/.test(s))
|
||
})
|
||
test('legal_datenschutz_interessenten: Geltungsbereich, Empfänger und Art. 22 passen zu Abschnitt 11', () => {
|
||
const ds = strip(read(DS))
|
||
assert.ok(between(ds, '2. Geltungsbereich', '3. Hosting').includes('(Abschnitt 11)'))
|
||
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
|
||
assert.ok(emp.includes('Anthropic Ireland, Limited') && (emp.includes('Google Ireland Limited') || emp.includes('Migadu GmbH')))
|
||
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
|
||
assert.ok(s14.includes('Die automatisierte Bewertung von Interessenten beschreibt Abschnitt 11.') && !s14.includes('finden nicht statt'))
|
||
const stand = /Stand (\d{2})\.(\d{2})\.(\d{4})/.exec(ds)
|
||
assert.ok(`${stand[3]}-${stand[2]}-${stand[1]}` >= '2026-10-17', 'Stand nicht vor dem 17.10.2026')
|
||
})
|
||
```
|
||
- [ ] **Step 3: Fehlschlag sehen, dann Text einarbeiten.** `cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && timeout 900 node --max-old-space-size=1024 --test test/legalTexts.test.js` ⇒ nur die beiden neuen Tests schlagen fehl (Anker fehlt). Dann in `public/datenschutz.html` (**exakter Wortlaut**; `Stand` = Freigabedatum, dasselbe Datum wie später `--freigabe`):
|
||
- `<p class="meta">Stand 07.10.2026</p>` ⇒ `<p class="meta">Stand TT.MM.JJJJ</p>`.
|
||
- Abschnitt 2, erster Absatz endet mit „… (Hosting-Abo und Wartungs-Abo).“ ⇒ danach im selben Absatz anfügen: ` Sie gilt auch, wenn wir Unternehmen als mögliche Kunden ansprechen (Abschnitt 11).`
|
||
- Abschnitt 11 ersetzen (alter Absatz entfällt):
|
||
|
||
```html
|
||
<a id="interessenten"></a>
|
||
<h2>11. Interessenten</h2>
|
||
<p>Wir suchen Betriebe, die voraussichtlich eine neue oder überarbeitete Website brauchen, und schreiben einzelne davon an, um ihnen eine unverbindliche Gratis-Vorschau einer Website anzubieten. Pro Betrieb senden wir höchstens zwei E-Mails: ein Angebot und, wenn keine Antwort kommt, eine Erinnerung nach etwa sieben Tagen. Neu anschreiben wir höchstens drei Betriebe pro Tag. Die Nachrichten schreibt und versendet ein KI-Assistent im Auftrag von WEBklar; darauf weisen wir in jeder Nachricht hin. Antworten sortiert ebenfalls ein KI-Assistent; Beschwerden, rechtliche Anliegen und den Wunsch nach einem Menschen bearbeitet ein Mitarbeiter.</p>
|
||
<p>Weil wir Ihre Daten nicht bei Ihnen erhoben haben, informieren wir Sie hier und in unserer ersten Nachricht darüber (Art. 14 DSGVO). Wir verarbeiten öffentlich zugängliche Geschäftsdaten: Firmenname, Ansprechpartner, Anschrift, geschäftliche E-Mail-Adresse und Telefonnummer, die Adresse der Website, die Kennung des Karteneintrags (Place-ID), eine von uns vergebene Punktzahl mit Kurzbegründung sowie den Verlauf der Kontaktaufnahme (Text und Zeitpunkt unserer Nachrichten und die Quelle Ihrer E-Mail-Adresse).</p>
|
||
<p>Die Daten stammen von der Website des Betriebs, vor allem aus dem Impressum und der Kontaktseite, aus OpenStreetMap und aus dem Kartendienst Google Maps; von dort speichern wir nur die Kennung des Eintrags. Die E-Mail-Adresse, an die wir schreiben, entnehmen wir ausschließlich dem Impressum oder der Kontaktseite Ihrer eigenen Website. Wir erraten keine Adressen und lesen keine Profile in sozialen Netzwerken aus. Telefonnummern speichern wir nur, wenn sie öffentlich genannt sind, und bei Heilberufen nie.</p>
|
||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist die Gewinnung neuer Kunden. Wir verarbeiten nur öffentlich bereitgestellte Geschäftsdaten in geringem Umfang, und Sie können der Verarbeitung jederzeit widersprechen.</p>
|
||
<p>Welche Betriebe wir anschreiben, entscheiden wir teilweise automatisiert: Ein Programm bewertet öffentlich sichtbare Merkmale, etwa ob der Betrieb eine Website hat und wie aktuell sie wirkt, und legt danach die Reihenfolge fest. Die Bewertung hat für Sie keine rechtliche Wirkung; sie entscheidet nur, ob und wann wir Ihnen die beschriebenen Nachrichten schicken.</p>
|
||
<p>Haben Sie selbst öffentlich nach einer Website gefragt, etwa in einem Forum oder auf einem Kleinanzeigen- oder Projektportal, antworten wir Ihnen einmal auf diese Anfrage, auf dem Weg, den Sie dort gewählt haben. Dafür verarbeiten wir den Inhalt Ihrer Anfrage mit Link und Zeitpunkt sowie den Namen und die Kontaktmöglichkeit, die Sie dort selbst genannt haben. Quelle ist Ihre Anfrage. Rechtsgrundlage ist ebenfalls Art. 6 Abs. 1 lit. f DSGVO (unser Interesse, Ihre Anfrage zu beantworten); auch hier können Sie jederzeit widersprechen, und die Fristen unten gelten entsprechend.</p>
|
||
<p>Wir speichern die Daten auf unseren Servern bei der Hetzner Online GmbH in Deutschland. Zum Lesen und Schreiben der Nachrichten nutzen wir das Sprachmodell von Anthropic über die Anthropic Ireland, Limited, Dublin, Irland, als Auftragsverarbeiter; dabei können Daten in die USA übermittelt werden, abgesichert durch Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). Die E-Mails versenden wir über {Z1-SATZ}.</p>
|
||
<p>Haben Sie nicht geantwortet oder haben wir Sie nicht angeschrieben, löschen wir Ihre Daten spätestens 90 Tage nach dem Fund, einschließlich unserer Nachrichten an Sie. Antworten Sie, dass kein Interesse besteht, oder widersprechen Sie, löschen wir Ihre Daten innerhalb von 30 Tagen. Antworten Sie und kommt kein Vertrag zustande, löschen wir die Unterhaltung spätestens sechs Monate nach der letzten Nachricht; kommt ein Vertrag zustande, gelten die Abschnitte 7 und 9. In allen Fällen bleiben nur nicht umkehrbare Prüfwerte (Hash-Werte) Ihrer E-Mail-Adresse, Ihrer Domain und Ihrer Telefonnummer, damit wir Sie nicht noch einmal recherchieren oder anschreiben.</p>
|
||
<div class="box widerspruch">
|
||
<strong>Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)</strong>
|
||
<p>Sie können der Verarbeitung Ihrer Daten zu Werbezwecken jederzeit ohne Angabe von Gründen widersprechen. Antworten Sie dazu auf unsere Nachricht, nutzen Sie den Abmelde-Link in der Nachricht oder schreiben Sie an support@webklar.com. Wir schreiben Ihnen dann nicht mehr und löschen Ihre Daten bis auf die genannten Prüfwerte. Weitere Rechte (Art. 15 bis 20 DSGVO) und das Beschwerderecht beschreibt Abschnitt 13.</p>
|
||
</div>
|
||
```
|
||
`{Z1-SATZ}` ersetzen durch genau eine Variante: **G** `den E-Mail-Dienst Google Workspace der Google Ireland Limited, Irland (Auftragsverarbeiter)` oder **M** `den E-Mail-Dienst der Migadu GmbH, Schweiz (Auftragsverarbeiter; für die Schweiz besteht ein Angemessenheitsbeschluss der EU-Kommission)`.
|
||
- Abschnitt 12: nach dem Stripe-Eintrag zwei Listenpunkte einfügen: `<li>Anthropic Ireland, Limited: Lesen und Schreiben von Nachrichten an Interessenten durch ein Sprachmodell (Auftragsverarbeiter, Abschnitt 11)</li>` und `<li><Google Ireland Limited | Migadu GmbH>: Versand und Empfang der E-Mails an Interessenten (Auftragsverarbeiter, Abschnitt 11)</li>` (passend zu Z1).
|
||
- Abschnitt 14: den letzten Satz „Automatisierte Entscheidungen einschließlich Profiling im Sinne von Art. 22 DSGVO finden nicht statt.“ ersetzen durch `Entscheidungen, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen (Art. 22 DSGVO), treffen wir nicht automatisiert. Die automatisierte Bewertung von Interessenten beschreibt Abschnitt 11.`
|
||
- [ ] **Step 4: PDF neu drucken und alle Rechtstexttests laufen lassen.** `free -m`; dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 nice scripts/build-legal-pdfs.sh datenschutz` ⇒ Zeile `-> docs/WEBklar-Datenschutzerklaerung.pdf`, neue `.sha256`. Dann `timeout 900 node --max-old-space-size=1024 --test test/legalTexts.test.js` ⇒ `# fail 0` (inklusive `pdf_pruefsumme` und `datenschutz_pdf`).
|
||
- [ ] **Step 5: Commit.** `git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf docs/WEBklar-Datenschutzerklaerung.pdf.sha256 test/legalTexts.test.js && git commit -m "Datenschutz: Abschnitt 11 Interessenten (Quelle, Zweck, KI, Fristen, Widerspruch), Anker #interessenten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T21: Datenschutzerklärung als neue Version in WOMS „Rechtliches“ hochladen (Skript, Portal)
|
||
|
||
**Files (Portal-Worktree wie T20):**
|
||
- Create: `scripts/datenschutz-hochladen.mjs`
|
||
- Modify: `scripts/rechtliches-hochladen.mjs` (nur `export` vor vorhandenen Hilfen, kein neues Verhalten)
|
||
- Test: `test/datenschutzHochladen.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes aus `rechtliches-hochladen.mjs` (neu exportiert): `client(fetchFn, schluessel)`, `alleDokumente(api, sammlung)`, `versionen(api, documentKey)`, `neueId()`, `ddmmjjjj(iso)`, `planVersion({ alle, source, validFrom })`, `BUCKET`, `DB`, `KEY_DATEI`, `ROOT`.
|
||
- Produces: `parseArgs(argv) → { modus: 'probe'|'hochladen', freigabe: string|null }`; `main(argv, deps) → Promise<0|1>` mit `deps = { fetch, schluessel, log, git, dateien }` wie das Vorbild. Karte `datenschutz`; Dateien `docs/WEBklar-Datenschutzerklaerung.pdf` und `public/datenschutz.html`; `source = kundenbereich@<Commit>:public/datenschutz.html`; `changeNote = 'Fassung vom <TT.MM.JJJJ>: Abschnitt 11 Interessenten (Workers, Kaltmail)'`. Der Aufruf `dokumentFuer('datenschutz')` des Vorbilds bleibt unverändert ein Fehler (bestehender Test).
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import liest weder Schlüssel noch Netz
|
||
|
||
const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
|
||
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
|
||
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '' } = {}) {
|
||
const aufrufe = [], log = []
|
||
const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
|
||
const fetch = async (url, opt = {}) => {
|
||
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, '')
|
||
aufrufe.push({ methode, pfad, body: opt.body })
|
||
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
|
||
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: versionen.length, documents: versionen })
|
||
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
|
||
if (methode === 'POST' && pfad === '/storage/buckets/legal-files/files') return antwort({ $id: `f${aufrufe.length}` })
|
||
if (methode === 'POST' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ $id: 'v-neu' })
|
||
if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
|
||
throw new Error(`unerwartet: ${methode} ${pfad}`)
|
||
}
|
||
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: () => true, readFileSync: () => Buffer.from('x') } } }
|
||
}
|
||
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')
|
||
|
||
test('parseArgs: --probe allein oder --freigabe=Tag, sonst Aufruf-Fehler', () => {
|
||
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', freigabe: null })
|
||
assert.deepEqual(m.parseArgs(['--freigabe=2026-10-20']), { modus: 'hochladen', freigabe: '2026-10-20' })
|
||
for (const bad of [[], ['--freigabe=20.10.2026'], ['--freigabe=2026-13-40'], ['--probe', '--freigabe=2026-10-20'], ['datenschutz']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/)
|
||
})
|
||
test('Hochladen: Dateien zuerst (PDF, dann HTML), dann Version v7, dann die bisherige ablösen', async () => {
|
||
const b = backend()
|
||
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
|
||
assert.deepEqual(schreibend(b).map((a) => `${a.methode} ${a.pfad.split('/').slice(-1)[0]}`), ['POST files', 'POST files', 'POST documents', 'PATCH alt'])
|
||
const neu = JSON.parse(schreibend(b)[2].body).data
|
||
assert.deepEqual([neu.documentKey, neu.version, neu.validFrom, neu.status, neu.source, neu.fileIds.length], ['datenschutz', 'v7', '2026-10-20', 'current', 'kundenbereich@abc1234:public/datenschutz.html', 2])
|
||
assert.match(neu.changeNote, /^Fassung vom 20\.10\.2026: Abschnitt 11 Interessenten/)
|
||
assert.deepEqual(JSON.parse(schreibend(b)[3].body).data, { status: 'superseded', validTo: '2026-10-19' })
|
||
})
|
||
test('schon hochgeladen, fehlende Karte, nicht eingecheckter Stand und --probe schreiben nichts', async () => {
|
||
const schon = backend({ versionen: [{ ...AKTUELL, source: 'kundenbereich@abc1234:public/datenschutz.html' }] })
|
||
assert.equal(await m.main(['--freigabe=2026-10-20'], schon.deps), 0)
|
||
const ohneKarte = backend({ karten: [] })
|
||
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
|
||
const schmutzig = backend({ gitStatus: ' M public/datenschutz.html\n' })
|
||
assert.equal(await m.main(['--freigabe=2026-10-20'], schmutzig.deps), 1)
|
||
const probe = backend()
|
||
assert.equal(await m.main(['--probe'], probe.deps), 0)
|
||
for (const b of [schon, ohneKarte, schmutzig, probe]) assert.equal(schreibend(b).length, 0)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && timeout 900 node --max-old-space-size=1024 --test test/datenschutzHochladen.test.js` ⇒ FAIL, `Cannot find module '../scripts/datenschutz-hochladen.mjs'`.
|
||
- [ ] **Step 3: Implementieren.** In `rechtliches-hochladen.mjs` nur `export` ergänzen vor `BUCKET`, `DB`, `KEY_DATEI`, `ROOT`, `client`, `alleDokumente`, `versionen`, `neueId`, `ddmmjjjj` (kein Verhalten ändern; möglicher Merge-Konflikt mit `feature/kundenansicht-neu` bleibt auf diese Zeilen begrenzt). `datenschutz-hochladen.mjs` nach dem Muster der Funktion `hochladen` des Vorbilds: Schlüssel aus `deps.schluessel` (Standard Datei `KEY_DATEI`), Karte `datenschutz` muss existieren (sonst Meldung und Exit 1 vor jedem Schreiben), Dateien müssen existieren, `git status --porcelain -- <beide Dateien>` muss leer sein, `commit` per `git rev-parse --short HEAD`, `planVersion` ⇒ `schon` ⇒ Exit 0 ohne Schreiben; Reihenfolge: erst beide Dateien in den Bucket `legal-files` (PDF, dann HTML), dann Versionsdokument (`version` aus `planVersion`, `validFrom` = Freigabe, `validTo null`, `status 'current'`, `fileIds`, `source`, `changeNote`), zuletzt die bisherige `current`-Version auf `superseded` mit `validTo = planVersion.validToAltes`. `--probe` liest nur (Karte, Version-Liste, Bucket) und gibt aus. Der Einstieg am Dateiende wie im Vorbild (`process.argv[1]`-Prüfung).
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; `timeout 900 node --max-old-space-size=1024 --test test/rechtlichesHochladen.test.js` ⇒ weiter grün.
|
||
- [ ] **Step 5: Commit.** `git add scripts/datenschutz-hochladen.mjs scripts/rechtliches-hochladen.mjs test/datenschutzHochladen.test.js && git commit -m "Rechtliches: Skript zum Hochladen der Datenschutzerklärung als neue Version" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T22: webklar.com leitet `/datenschutz#interessenten` mit Anker weiter
|
||
|
||
Die Kaltmail verlinkt `https://www.webklar.com/datenschutz#interessenten`. Die Seite `src/pages/Datenschutz.tsx` leitet per `window.location.replace` auf `https://project.webklar.com/datenschutz.html` weiter und verliert dabei den Anker.
|
||
|
||
**Files (Website-Worktree `/home/webklar/apps/webklar.com/dev-workers-ds`, Branch `fix/datenschutz-anker`):**
|
||
- Create: `src/lib/datenschutzZiel.ts`
|
||
- Modify: `src/pages/Datenschutz.tsx`
|
||
- Test: `src/test/datenschutz-weiterleitung.test.tsx`
|
||
|
||
**Interfaces:**
|
||
- Produces: `weiterleitungsZiel(hash: string): string` (Basis `https://project.webklar.com/datenschutz.html`, Anker nur wenn er `^#[A-Za-z0-9_-]{1,64}$` erfüllt).
|
||
|
||
- [ ] **Step 1: Worktree und Test.** `cd /home/webklar/apps/webklar.com/build && git fetch origin && git worktree add ../dev-workers-ds -b fix/datenschutz-anker origin/main && cd ../dev-workers-ds && ln -s ../build/node_modules node_modules`. Test:
|
||
|
||
```tsx
|
||
import { describe, it, expect, vi } from "vitest";
|
||
import { render, screen } from "@testing-library/react";
|
||
import Datenschutz from "@/pages/Datenschutz";
|
||
import { weiterleitungsZiel } from "@/lib/datenschutzZiel";
|
||
|
||
const BASIS = "https://project.webklar.com/datenschutz.html";
|
||
|
||
describe("Weiterleitung /datenschutz", () => {
|
||
it("nimmt einen gültigen Anker mit", () => {
|
||
expect(weiterleitungsZiel("#interessenten")).toBe(`${BASIS}#interessenten`);
|
||
expect(weiterleitungsZiel("#chat-anhaenge")).toBe(`${BASIS}#chat-anhaenge`);
|
||
});
|
||
it("ohne Anker oder mit ungültigem Anker bleibt es bei der Seite", () => {
|
||
for (const hash of ["", "#", "#javascript:alert(1)", '#a"b', "#a b", `#${"a".repeat(65)}`]) {
|
||
expect(weiterleitungsZiel(hash)).toBe(BASIS);
|
||
}
|
||
});
|
||
it("der Link im Text trägt denselben Anker", () => {
|
||
vi.spyOn(console, "error").mockImplementation(() => {}); // jsdom meldet die nicht umgesetzte Navigation
|
||
window.history.pushState({}, "", "/datenschutz#interessenten");
|
||
render(<Datenschutz />);
|
||
expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", `${BASIS}#interessenten`);
|
||
});
|
||
});
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/webklar.com/dev-workers-ds && timeout 600 node --max-old-space-size=1024 node_modules/vitest/vitest.mjs run src/test/datenschutz-weiterleitung.test.tsx` ⇒ FAIL (`Failed to resolve import "@/lib/datenschutzZiel"`).
|
||
- [ ] **Step 3: Implementieren.** `src/lib/datenschutzZiel.ts`: `const BASIS = "https://project.webklar.com/datenschutz.html"; export const weiterleitungsZiel = (hash: string): string => (/^#[A-Za-z0-9_-]{1,64}$/.test(hash) ? BASIS + hash : BASIS);`. `Datenschutz.tsx`: `const ziel = weiterleitungsZiel(window.location.hash)`, `useEffect(() => { window.location.replace(ziel) }, [ziel])`, der sichtbare Link nutzt `ziel`; Kommentar zur zentralen Ablage im Portal bleibt.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ 3 Tests grün; dann `timeout 600 node --max-old-space-size=1024 node_modules/vitest/vitest.mjs run` ⇒ keine neuen Fehler; Bau-Prüfung `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 600 node node_modules/vite/bin/vite.js build --outDir "${TMPDIR:-/tmp}/p6-site-dist"` ⇒ `built in`.
|
||
- [ ] **Step 5: Commit.** `git add src/lib/datenschutzZiel.ts src/pages/Datenschutz.tsx src/test/datenschutz-weiterleitung.test.tsx && git commit -m "Datenschutz-Weiterleitung nimmt den Anker mit" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T23: Freischalt-Skript (Datenschutz-Schalter, Räume) und manuelle Live-Probe
|
||
|
||
**Files:**
|
||
- Create: `scripts/p6-freischalten.mjs`, `scripts/probe-scout.mjs`
|
||
- Test: `test/p6-freischalten.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSettings`, `updateSettings`, `SCOUT_ROOMS`, `loadConfig`, `createDb`; für die Probe `createPlacesClient`, `readOwnSite`, `erweitereDeps` (T24) und `evaluate(models)` aus `scripts/check-modelle.mjs` (P1-T14).
|
||
- Produces (`p6-freischalten.mjs`): `pruefeDatenschutzSeite(html) → { ok: boolean, fehlt: string[] }`; `main(argv, deps) → Promise<0|1>` mit `deps = { db, clock, log, config, fetchImpl? }` und den Aufrufen `datenschutz --check | --set | --off` sowie `raeume --set <a,b> | --clear`. Produces (`probe-scout.mjs`): `parseArgs(argv) → { room, query, max }`, `main(argv, deps)`.
|
||
|
||
- [ ] **Step 1: Test schreiben.**
|
||
|
||
```js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { pruefeDatenschutzSeite, main } from '../scripts/p6-freischalten.mjs'
|
||
import { parseArgs as probeArgs } from '../scripts/probe-scout.mjs'
|
||
import { getSettings } from '../src/settings.js'
|
||
import { makeDeps } from './support/p6.js'
|
||
|
||
const PFLICHT = ['Art. 6 Abs. 1 lit. f DSGVO', 'spätestens 90 Tage nach dem Fund', 'innerhalb von 30 Tagen', 'sechs Monate nach der letzten Nachricht', 'Anthropic Ireland, Limited', 'Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)']
|
||
const SEITE = `<h2>10. Domains</h2><a id="interessenten"></a><h2>11. Interessenten</h2><p>${PFLICHT.join(' … ')}</p>`
|
||
const seite = (text) => async () => ({ ok: true, status: 200, text: async () => text })
|
||
|
||
test('pruefeDatenschutzSeite: Anker und Pflichtsätze müssen live stehen', () => {
|
||
assert.deepEqual(pruefeDatenschutzSeite(SEITE), { ok: true, fehlt: [] })
|
||
assert.deepEqual(pruefeDatenschutzSeite(SEITE.replace('<a id="interessenten"></a>', '')).fehlt, ['Anker #interessenten'])
|
||
assert.deepEqual(pruefeDatenschutzSeite(SEITE.replace('90 Tage', '12 Monate')).fehlt, ['spätestens 90 Tage nach dem Fund'])
|
||
})
|
||
test('R3: datenschutz --set schaltet nur bei bestandener Prüfung, --check schreibt nie, --off schaltet aus', async () => {
|
||
const deps = makeDeps({ settings: { privacyInteressentenLive: false } })
|
||
assert.equal(await main(['datenschutz', '--set'], { ...deps, fetchImpl: seite('leer') }), 1)
|
||
assert.equal(await main(['datenschutz', '--check'], { ...deps, fetchImpl: seite(SEITE) }), 0)
|
||
assert.equal((await getSettings(deps)).privacyInteressentenLive, false)
|
||
assert.equal(await main(['datenschutz', '--set'], { ...deps, fetchImpl: seite(SEITE) }), 0)
|
||
assert.equal((await getSettings(deps)).privacyInteressentenLive, true)
|
||
assert.equal(await main(['datenschutz', '--off'], deps), 0)
|
||
assert.equal((await getSettings(deps)).privacyInteressentenLive, false)
|
||
})
|
||
test('raeume --set verbindet nur bekannte Scout-Räume bei vorhandenem Places-Schlüssel und ergänzt statt zu ersetzen', async () => {
|
||
const deps = makeDeps({ settings: { connectedRooms: ['netz'] }, config: { places: { apiKey: 'test' } } })
|
||
assert.equal(await main(['raeume', '--set', 'handwerk,xyz'], deps), 1)
|
||
assert.equal(await main(['raeume', '--set', 'handwerk'], deps), 0)
|
||
assert.deepEqual([...(await getSettings(deps)).connectedRooms].sort(), ['handwerk', 'netz'])
|
||
assert.equal(await main(['raeume', '--clear'], deps), 0)
|
||
assert.deepEqual((await getSettings(deps)).connectedRooms, ['netz'])
|
||
const ohneSchluessel = makeDeps({ config: { places: { apiKey: '' } } })
|
||
assert.equal(await main(['raeume', '--set', 'handwerk'], ohneSchluessel), 1)
|
||
})
|
||
test('probe-scout: Argumente', () => {
|
||
assert.deepEqual(probeArgs(['--room', 'handwerk', '--query', 'Elektriker in Kaiserslautern', '--max', '3']), { room: 'handwerk', query: 'Elektriker in Kaiserslautern', max: 3 })
|
||
assert.deepEqual(probeArgs(['--room', 'laden']), { room: 'laden', query: 'Bäckerei in Kaiserslautern', max: 3 })
|
||
for (const bad of [[], ['--room', 'netz'], ['--room', 'handwerk', '--max', '6'], ['--room', 'handwerk', '--max', '0'], ['--x']]) assert.throws(() => probeArgs(bad), /Aufruf/)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-freischalten.test.js` ⇒ FAIL, Modul fehlt.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `pruefeDatenschutzSeite`: Anker `<a id="interessenten"></a>` (Meldung „Anker #interessenten“) und die sechs Pflichtsätze aus `PFLICHT` im Klartext der Seite (Tags entfernt, Leerraum vereinheitlicht); `fehlt` enthält die Meldung bzw. den Satz.
|
||
- `main`: Aufrufform prüfen (sonst Hinweis und 1). `datenschutz`: Seite `https://project.webklar.com/datenschutz.html` mit `deps.fetchImpl ?? fetch` holen, `--check` und `--set` prüfen und die Funde ausgeben; `--set` setzt nur bei `ok` über `updateSettings(deps, { privacyInteressentenLive: true }, 'script:p6-freischalten')`; `--off` setzt `false` ohne Prüfung (derselbe Schalter wie P3s `scripts/datenschutz-schalter.mjs --an|--aus`, aber `--set` setzt ihn erst nach der Seitenprüfung; er gilt auch für Claras Erstantworten). `raeume`: Namen gegen `SCOUT_ROOMS` prüfen (unbekannt ⇒ 1, nichts schreiben), `deps.config.places.apiKey` muss gesetzt sein (sonst 1), `--set` ergänzt `connectedRooms` ohne Duplikate, `--clear` entfernt nur die vier Scout-Räume. Einstieg: `loadConfig(process.env)`, `createDb`, `{ db, clock: { now: () => Date.now() }, log: console.log, config }`.
|
||
- `probe-scout.mjs` (manuell, schreibt weder Interessenten noch Register noch Suchprotokoll, bucht nur Kosten): `parseArgs` (`--room` Pflicht und Scout-Raum, `--query` Standard `<erstes Gewerk> in Kaiserslautern`, `--max` 1…5, Standard 3, sonst `Error` mit „Aufruf: …“). `main`: (1) Modellprüfung mit P1s `evaluate` (nicht neu schreiben; `models.retrieve` je Modell wie in `check-modelle.mjs`) und die Zeile `Haiku-Websuche unterstützt: ja|nein|unbekannt` aus `capabilities.server_tools.web_search.supported` des Haiku-Modells; (2) eine Seite Places, Zeile `Places: <n> Treffer, davon mit Website: <k>`; (3) für die ersten `max` Treffer mit Website `readOwnSite`, je Zeile `<Host> status=<status> Mail=<gefunden|keine> Quelle=<Host der Quelle> Telefon=<ja|nein>`; (4) `Kosten gesamt: <x,xxx> $`; Schlüssel und Antworttexte werden nie ausgegeben.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add scripts/p6-freischalten.mjs scripts/probe-scout.mjs test/p6-freischalten.test.js && git commit -m "p6: Freischalt-Skript für Datenschutz-Schalter und Räume, manuelle Probe" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
### P6-T24: Fassade, Verdrahtung in `main.js` und Nachtlauf-Integrationstest
|
||
|
||
**Files:**
|
||
- Create: `src/ohneWebsite.js`
|
||
- Modify: `src/main.js` (P1/P3: `buildDeps`, `buildTickJobs()`, `buildRoutes()`, `start`)
|
||
- Test: `test/p6-nacht.test.js`, `test/p6-wiring.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Bausteine aus T03–T18; P1: `next(deps, { kinds, workerIds? })`, `finish`, `fail(deps, id, error, { retry })`, `planNight`; P4: `runSenden`, `registerGateCheck`; P3: `buildTickJobs()`, `buildRoutes()` in `src/main.js`.
|
||
- Produces: `SCOUT_WORKER_IDS` (Wiederausfuhr aus T03); `erweitereDeps(base, config) → deps` (E4: `places`, `osm`, `dns`); `registerColdPieces(deps) → unregister` (setzt `deps.drafters` über `registerColdDrafters` und hängt `registerColdGateCheck()` ein; einmal in `start`); `drainScouts(deps, { maxJobs = 2 } = {}) → Promise<{ fertig, fehler }>` (holt `suche`-Aufträge nur der Scouts, ruft `runScoutJob`, danach `finish`; wirft der Lauf, `fail(deps, job.id, <Meldung ohne Schlüssel>, { retry: false })` und Ereignis `fehler` (Scout, Text „Suchlauf fehlgeschlagen“), kein Absturz; Aufträge von Rita und Finn bleiben liegen, die holt P3); `kickScouts(deps)` (startet `drainScouts` ohne `await`, mit Wächter gegen Überlappung nach dem Muster `kickWerkstatt` von P4, damit ein Lauf von bis zu 20 Minuten den 60-s-Tick nicht blockiert); `top3Tick(deps)` (`now = nowOf(deps)`; erst `scheduleFollowups`, dann `selectNightTop3`); `kuerzenTick(deps) → { gekuerzt, uebersprungen }`; `ohneWebsiteTickJobs` = `[{ kind: 'scouts', workerId: 'otto', pauseSafe: false, run: kickScouts }, { kind: 'top3', workerId: 'bea', pauseSafe: false, run: top3Tick }, { kind: 'kuerzen', workerId: 'arne', pauseSafe: true, run: kuerzenTick }]` im Format von P1-T26.
|
||
|
||
- [ ] **Step 1: Tests schreiben.**
|
||
|
||
```js
|
||
// test/p6-nacht.test.js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { planNight } from '../src/otto/nachtplan.js'
|
||
import { enqueue } from '../src/jobs.js'
|
||
import { runSenden } from '../src/gespraeche/senden.js'
|
||
import { drainScouts, top3Tick, kuerzenTick, registerColdPieces } from '../src/ohneWebsite.js'
|
||
import { makeDeps, fakeLlm, page } from './support/p6.js'
|
||
|
||
const NUMMERN = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||
const host = (n) => `betrieb${n}-elektro.de`
|
||
const nummerAus = (s) => Number(/betrieb(\d)/i.exec(s)[1])
|
||
const platz = (n) => ({ placeId: `ChIJ${n}`, name: `Original-Google-Name ${n}`, websiteUri: n >= 8 ? '' : `https://${host(n)}`, rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' })
|
||
|
||
function nacht(t0) {
|
||
const deps = makeDeps({ now: '2026-10-19T20:00:00Z', settings: { connectedRooms: ['handwerk'] } }) // Mo 22:00 Berlin, Schalter an
|
||
deps.places = { textSearch: async () => ({ places: NUMMERN.map(platz), nextPageToken: null }) }
|
||
t0.after(registerColdPieces(deps)) // Entwürfe für P4s Tick und Zusatzprüfung am Tor, wie in main.js
|
||
deps.llm = fakeLlm({
|
||
search: async (_d, o) => {
|
||
const h = o.allowedDomains[0]
|
||
const n = nummerAus(h)
|
||
return { text: '', sources: [], toolErrors: [], usage: {}, costUsd: 0.003, pages: [page(`https://${h}/`, `© 2016 Elektro Betrieb${n}`), page(`https://${h}/impressum`, `Impressum Elektro Betrieb${n} GmbH Hauptstraße 5 Kaiserslautern${n <= 5 ? ` E-Mail: info@${h}` : ''}`)] }
|
||
},
|
||
ask: async (_d, o) => o.workerId === 'bea'
|
||
? { data: { urteile: JSON.parse(o.input).kandidaten.map((k, i) => ({ id: k.id, auditScore: 3, sympathie: 9 - i, kette: false, belege: [] })) }, usage: {}, costUsd: 0.01 }
|
||
: { data: { companyName: `Elektro Betrieb${nummerAus(o.input)} GmbH`, contactName: '', phone: '', street: '', postalCode: '', city: 'Kaiserslautern' }, usage: {}, costUsd: 0.001 },
|
||
})
|
||
return deps
|
||
}
|
||
|
||
test('Eine Nacht bis zur Kürzung: Top 3 gemailt, Rest mit Grund gelistet, Nachfass nach 7 Tagen, danach gekürzt', async (t0) => {
|
||
const deps = nacht(t0)
|
||
const senden = async (iso) => { deps.clock.set(iso); return (await runSenden(deps)).sent ?? 0 }
|
||
// 22:00: P1s planNight schickt Hanna los, die Scouts-Schleife sucht
|
||
assert.deepEqual((await planNight(deps, { run: 1 })).planned.map((x) => [x.workerId, x.room]), [['hanna', 'handwerk']])
|
||
assert.deepEqual(await drainScouts(deps), { fertig: 1, fehler: 0 })
|
||
assert.equal(deps.db.docs('workersProspects').length, 10)
|
||
// 05:30: Top 3 über alle Räume, der Rest mit Grund in die Liste
|
||
deps.clock.set('2026-10-20T03:30:00Z')
|
||
await top3Tick(deps)
|
||
const ps = deps.db.docs('workersProspects')
|
||
assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'erstmail').length, 3)
|
||
assert.deepEqual(ps.filter((p) => p.status === 'botlead').map((p) => p.botLeadReason).sort(), [
|
||
'Platz 4 von 6; freie Mail-Plätze: 3', 'Platz 5 von 6; freie Mail-Plätze: 3', 'Platz 6 von 6; freie Mail-Plätze: 3',
|
||
'keine veröffentlichte Mail', 'keine veröffentlichte Mail', 'keine veröffentlichte Mail', 'keine veröffentlichte Mail'])
|
||
// Sendetag 09:05 (MESZ): P4s Tick sendet die drei Mails, danach ist das Tageslimit erreicht
|
||
assert.equal(await senden('2026-10-20T07:05:00Z'), 3)
|
||
assert.equal(await senden('2026-10-20T07:10:00Z'), 0)
|
||
assert.equal(deps.mailer.sent.length, 3)
|
||
const adressen = ps.filter((p) => p.threadId).map((p) => p.email)
|
||
assert.equal(adressen.length, 3)
|
||
for (const m of deps.mailer.sent) {
|
||
assert.ok(adressen.some((a) => JSON.stringify(m.to).includes(a)), 'nur an gespeicherte veröffentlichte Adressen')
|
||
const [koerper, fuss] = m.text.split('\n--\n')
|
||
assert.ok(koerper.includes('KI-Assistent von WEBklar') && !/https?:|www\./.test(koerper))
|
||
assert.equal(fuss.match(/https?:\/\/\S+/g).length, 3)
|
||
assert.ok(m.headers['List-Unsubscribe'])
|
||
}
|
||
// R1 und Übersicht §2.13
|
||
const alles = JSON.stringify(['workersProspects', 'workersEvents', 'workersJobs', 'workersSearchLog', 'workersThreads', 'workersMessages'].map((c) => deps.db.docs(c)))
|
||
assert.ok(!alles.includes('Original-Google-Name'))
|
||
assert.ok(deps.db.writes.every((w) => w.col.startsWith('workers')), 'nur Workers-Sammlungen beschrieben')
|
||
assert.equal((await deps.db.get('workersDaily', '2026-10-20')).mailsCold, 3)
|
||
// sieben Tage später (MEZ, ab 25.10.): genau eine Nachfass-Mail je Betrieb
|
||
deps.clock.set('2026-10-27T03:30:00Z')
|
||
await top3Tick(deps)
|
||
assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'nachfass').length, 3)
|
||
assert.equal(await senden('2026-10-27T08:05:00Z'), 3) // 09:05 MEZ
|
||
assert.equal(await senden('2026-10-27T08:10:00Z'), 0)
|
||
assert.equal(deps.mailer.sent.length, 6)
|
||
assert.ok(deps.mailer.sent.slice(3).every((m) => m.subject.startsWith('Re: ')))
|
||
deps.clock.set('2026-11-03T03:30:00Z')
|
||
await top3Tick(deps)
|
||
assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'nachfass').length, 3, 'keine zweite Nachfass-Runde')
|
||
// 90 Tage nach dem Fund: alles gekürzt, Register bleibt
|
||
const schluessel = deps.db.docs('workersKeys').length
|
||
deps.clock.set('2027-01-20T01:30:00Z')
|
||
assert.equal((await kuerzenTick(deps)).gekuerzt, 10)
|
||
assert.ok(deps.db.docs('workersProspects').every((p) => p.email === '' && p.purgedAt))
|
||
assert.equal(deps.db.docs('workersKeys').length, schluessel)
|
||
})
|
||
test('drainScouts: Fehler im Lauf ⇒ Auftrag "fehler" und Ereignis, kein Absturz; Aufträge von Rita bleiben liegen', async (t0) => {
|
||
const deps = nacht(t0)
|
||
deps.places = { textSearch: async () => { throw new Error('Places down') } }
|
||
await planNight(deps, { run: 1 })
|
||
const fremd = await enqueue(deps, { kind: 'suche', workerId: 'rita', payload: { room: 'netz' } })
|
||
assert.deepEqual(await drainScouts(deps), { fertig: 0, fehler: 1 })
|
||
assert.equal(deps.db.docs('workersJobs').find((j) => j.workerId === 'hanna').status, 'fehler')
|
||
assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'fehler'))
|
||
assert.equal((await deps.db.get('workersJobs', fremd.id)).status, 'offen')
|
||
})
|
||
```
|
||
|
||
```js
|
||
// test/p6-wiring.test.js
|
||
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs'
|
||
import { buildTickJobs, buildRoutes } from '../src/main.js'
|
||
|
||
test('main.js hängt Tick-Einträge, Route und erweiterte deps ein', () => {
|
||
const kinds = buildTickJobs().map((j) => j.kind)
|
||
for (const k of ['scouts', 'top3', 'kuerzen']) assert.ok(kinds.includes(k), k)
|
||
assert.ok(buildRoutes().some((r) => r.method === 'GET' && r.pattern.test('/botleads')))
|
||
const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8')
|
||
for (const s of ['erweitereDeps', 'registerColdPieces']) assert.ok(src.includes(s), s)
|
||
})
|
||
```
|
||
- [ ] **Step 2: Fehlschlag sehen.** `cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-nacht.test.js test/p6-wiring.test.js` ⇒ FAIL, `Cannot find module '../src/ohneWebsite.js'`.
|
||
- [ ] **Step 3: Implementieren.**
|
||
- `src/ohneWebsite.js` nach den Signaturen oben. `erweitereDeps`: `places = createPlacesClient({ apiKey: config.places.apiKey })` (fehlt der Schlüssel, wirft `textSearch` mit `PlacesError('Places-Schlüssel fehlt.', 0)`), `osm = createOsmClient({ sleep: (ms) => new Promise((r) => setTimeout(r, ms)) })`, `dns = { resolveMx, resolve4 }` aus `node:dns/promises`. `registerColdPieces(deps)`: `registerColdDrafters(deps)`, `const aus = registerColdGateCheck()`, Rückgabe `aus`. `drainScouts`: Schleife bis `maxJobs`: `job = await next(deps, { kinds: ['suche'], workerIds: SCOUT_WORKER_IDS })`, `null` ⇒ Ende; `try { finish(deps, job.id, await runScoutJob(deps, job)) } catch`: `fail(...)`, `emit` `fehler`, Zähler `fehler`. `kickScouts`: Modulvariable `laeuft`; ist sie gesetzt, sofort zurück; sonst `laeuft = drainScouts(deps).catch((e) => deps.log(…Meldung ohne Schlüssel…)).finally(() => { laeuft = null })` ohne `await`.
|
||
- `src/main.js` (Stellen laut Vorprüfung A6): `buildDeps` ruft am Ende `erweitereDeps(deps, config)`; `buildTickJobs()` hängt `...ohneWebsiteTickJobs` an; `buildRoutes()` hängt `...BOTLEADS_ROUTES` an; `start` ruft einmal `registerColdPieces(deps)` und ruft das zurückgegebene `aus` beim Beenden. Die Nachtplanung (`planNight`), den Versand (`senden`) und den Posteingang berührt P6 nicht.
|
||
- [ ] **Step 4: Bestehen sehen.** Derselbe Befehl ⇒ `# fail 0`; danach die ganze Suite: `free -m`, dann `timeout 300 node --max-old-space-size=1024 --test test/*.test.js` ⇒ `# fail 0`.
|
||
- [ ] **Step 5: Commit.** `git add src/ohneWebsite.js src/main.js test/p6-nacht.test.js test/p6-wiring.test.js && git commit -m "p6: Fassade, Tick-Einträge und Nachtlauf-Integrationstest" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`
|
||
|
||
## Betriebsanleitung (Kenso)
|
||
|
||
Reihenfolge ist wichtig: Erst das Fundament (1–4), dann Probe (5), dann der Datenschutztext (6–7), erst danach Räume verbinden (8). Solange `privacyInteressentenLive` aus ist, geht keine Kaltmail raus (Tor-Grund `datenschutz`, die Aufträge bleiben offen). Verbinde deshalb keinen Raum vor Schritt 7: Sonst wählt die erste Nacht drei Betriebe aus, deren Erstmail-Aufträge liegen bleiben und die Mail-Plätze blockieren (keine weitere Suche, bis der Schalter an ist). Der Schalter gilt auch für Claras Erstantworten (P3).
|
||
|
||
1. **Voraussetzungen.** P1–P4 laufen: `docker ps --filter name=webklar-workers --format '{{.Names}} {{.Status}}'` ⇒ `webklar-workers Up …`. Absender-Domain und Postfach sind eingerichtet (SPF, DKIM, DMARC), die Aufwärmphase läuft seit mindestens zwei Wochen (Spec §11.4), eine von Hand verschickte Testmail aus dem Absender-Postfach erreicht bei mail-tester.com mindestens 9 von 10.
|
||
2. **Google-Places-Schlüssel.** Google Cloud Console: „Places API (New)“ aktivieren, Schlüssel anlegen, beschränken auf diese API und die Server-IP, Tageskontingent für Text Search auf 300 Anfragen, Budgetwarnung bei 20 €. Dann `cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-$(date +%F)-workers-p6`, Zeile `WORKERS_PLACES_API_KEY=<Schlüssel>` in `/home/webklar/apps/.env` eintragen (den Wert sieht Claude nie). Prüfen: `command grep -c '^WORKERS_PLACES_API_KEY=.' /home/webklar/apps/.env` ⇒ `1`; `command grep -c 'WORKERS_PLACES_API_KEY' /home/webklar/apps/docker-compose.yml` ⇒ mindestens `1` (sonst nach Backup `docker-compose.yml.bak-$(date +%F)-workers-p6` unter dem Dienst `workers` die Zeile `WORKERS_PLACES_API_KEY: ${WORKERS_PLACES_API_KEY:-}` ergänzen); `docker compose -f /home/webklar/apps/docker-compose.yml config -q` ⇒ keine Ausgabe, Exit 0.
|
||
3. **Dienst ausrollen.** `cd /home/webklar/apps/workers-dev && git push origin p6-ohne-website:main` (Gitea `knso/workers-dienst`), dann `git -C /home/webklar/apps/workers pull --ff-only && cd /home/webklar/apps && docker compose build workers && docker compose up -d --no-deps workers`. Prüfen: `docker logs --tail 20 webklar-workers` ohne Fehler; `docker exec webklar-workers wget -qO- http://localhost:8092/api/workers/health` ⇒ `{"ok":true,…}`.
|
||
4. **Schema prüfen.** P6 braucht kein neues Schema (E1/E2). `docker exec webklar-workers node scripts/setup-collections.mjs --check` ⇒ Exit 0; sonst fehlt etwas aus P3 oder P4 (`privacyInteressentenLive`, Index auf `workersMessages.threadId`): ohne `--check` ausführen und erneut prüfen.
|
||
5. **Live-Probe (manuell, kostet unter 0,30 $).** `docker exec webklar-workers node scripts/probe-scout.mjs --room handwerk --query "Elektriker in Kaiserslautern" --max 3`. Erwartet: `Haiku-Websuche unterstützt: ja`; `Places: 20 Treffer, davon mit Website: …`; je Betrieb eine Zeile mit `status=ok` und `Mail=gefunden` oder `keine`; `Kosten gesamt: …`. Bei `nein`: **Stopp**, Entscheidung bei Kenso; die Suche läuft dann über Sonnet: Zeile `WORKERS_SEARCH_MODEL=claude-sonnet-5-5` in `/home/webklar/apps/.env` (Backup vorher, P1-E6), höhere Suchkosten einplanen; die Rollentabelle bleibt unverändert. Bei `status=robots` oder `nicht_lesbar` für viele Betriebe: Probe mit einer anderen Anfrage wiederholen, sonst melden.
|
||
6. **Datenschutztext live.** (a) Kenso gibt den Text aus P6-T20 frei und nennt den Mail-Anbieter. (b) Der Ausführende committet in `dev-workers-datenschutz`; Kenso pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz push origin feature/workers-datenschutz:main`; nach 1–2 Minuten `curl -s https://project.webklar.com/datenschutz.html | command grep -c 'id="interessenten"'` ⇒ `1`. (c) Website: `git -C /home/webklar/apps/webklar.com/dev-workers-ds push origin fix/datenschutz-anker:main`; deployt der Webhook nicht binnen 3 Minuten: `docker exec webklar-webclaw-build sh -c 'DEPLOY_TRIGGER=manual sh "${WEBCLAW_DEPLOY_SCRIPT:-/deploy.sh}"'`. Prüfen im Browser: `https://www.webklar.com/datenschutz#interessenten` landet auf `https://project.webklar.com/datenschutz.html#interessenten` und zeigt „11. Interessenten“. (d) Rechtliches: Kenso erzeugt in der Appwrite-Konsole den kurzlebigen Schlüssel „Rechtliches-Upload“ (files.read, files.write, documents.read, documents.write, Ablauf 1 Tag) und legt ihn nach `/root/.appwrite-rechtliches.key` (chmod 600); der Ausführende (Claude) führt im Portal-Worktree `timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --probe` und dann `timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --freigabe=<Stand-Datum>` aus; Prüfen in WOMS „Rechtliches“: Karte „Datenschutzerklärung“ zeigt die neue Version als „aktuell“, die alte als „abgelöst“; danach löscht Kenso den Schlüssel in der Konsole.
|
||
7. **Schalter umlegen (erst jetzt dürfen Kaltmails raus).** `docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --check` ⇒ „Seite ok“; dann `… datenschutz --set` ⇒ „Schalter privacyInteressentenLive: an“ (derselbe Schalter wie P3s `datenschutz-schalter.mjs --an`, aber erst nach der Seitenprüfung; P3s Schritt „Clara einschalten“ ist damit mit erledigt).
|
||
8. **Räume verbinden, einen nach dem anderen.** `docker exec webklar-workers node scripts/p6-freischalten.mjs raeume --set handwerk` ⇒ „Verbundene Räume: …handwerk“. Ab der nächsten Nacht (22:00 Suche, 05:30 Auswahl, ab 09:00 Versand) arbeitet Hanna; weitere Räume erst, wenn Zustellung und Antworten gut aussehen (`aerzte`, `selbst`, `laden` mit demselben Befehl).
|
||
9. **WOMS.** `git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test` (wenn P2/P3 mit dran sind, laut Übersicht §1.1). Prüfen: `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/workers/botleads` ⇒ `401`; in WOMS `/workers` ⇒ Reiter „Bot-Leads“.
|
||
10. **Erste Nacht beobachten.** Morgens: Morgenbericht-Mail (06:30); „Bot-Leads“ zeigt rund 7 Einträge mit Grund; im Absender-Postfach „Gesendet“ höchstens 3 Mails, alle zwischen 09:00 und 16:00, jede mit Fußblock und Abmelde-Link (Klick testen: erscheint der Eintrag in der Sperrliste?); `workersDaily` des Tages zeigt `mailsCold` ≤ 3. Beschwerden und Antworten landen wie in P4 beschrieben im Pult.
|
||
11. **Not-Aus und Rückbau.** Sofort: Pausenschalter in WOMS. Nur Kaltmail aus (stoppt auch Claras Erstantworten, P3): `docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --off`. Suche aus: `… raeume --clear`. Beides ist ohne Neustart wirksam.
|
||
|