chore(agents): add wfdash-rep-visit-batch-pin skill
Bulk-pin a list of customers to a PocketBase site from a rep's visit log
(typically Gary's driving-mode WhatsApp pins). Encodes the PB quirks we
hit during the Khuseleka batch:
- PB filter field on customers is 'customer' (not 'name'); fuzzy match
with customer~"X"
- coordinates is a geoPoint on both customers and rep_visits
({"lat": X, "lon": Y} GeoJSON, not a flat string)
- site_location is free-text on customers and does NOT exist on
rep_visits (PB silently drops unknown keys on writes)
- position_source is a select with ["tracker", "manual"]; manual is
the backfill marker
- PB admin auth email lives in .env.local (PB_ADMIN_EMAIL), never
hardcode
- local pb_schema-2.json snapshot is stale (missing site_location) —
live PB is source of truth
- SAST = UTC+2; convert WhatsApp timestamps before stamping
- single-letter driving-mode typos → pin the canonical PB record,
ask before assuming
- zero-hit names → skip by default; ask before creating new
customers
Same folder as wfdash-giellie; available to any agent that loads the
project's skills folder.
This commit is contained in:
parent
41963f8dd9
commit
482c1adf25
1 changed files with 234 additions and 0 deletions
234
.agents/skills/wfdash-rep-visit-batch-pin/SKILL.md
Normal file
234
.agents/skills/wfdash-rep-visit-batch-pin/SKILL.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
---
|
||||
name: wfdash-rep-visit-batch-pin
|
||||
description: Bulk-pin a list of customers to a PocketBase site from a rep's visit log (typically Gary's driving-mode WhatsApp pins). Use whenever the user provides a coordinate (lat/lon or Google Maps URL), a site name, and a list of person names, and wants each person pinned to that location with a `rep_visits` record. Handles PB admin auth via `.env.local`, fuzzy match against the `customers` collection, presents ambiguity for unclear/duplicate/zero-hit names, then writes the pins in one batch. Covers the `customer~"X"` PB filter gotcha, the SAST→UTC timestamp conversion, the live-vs-snapshot schema drift warning, and the known skipped-name patterns. This is NOT the agent/MCP layer — for that, load `wfdash-giellie` instead.
|
||||
---
|
||||
|
||||
# WFDASH rep-visit batch pin
|
||||
|
||||
You are a small data-entry agent. Given a coordinate, site name, and a list of person names from a rep's visit log (typically Gary's WhatsApp driving-mode pins), pin each person to that location in PocketBase and create a `rep_visits` record per person.
|
||||
|
||||
**This is not the same scope as `wfdash-giellie`.** Giellie is the agent/MCP layer (Bearer token, `/api/mcp`). This skill talks **directly to PocketBase admin** because Giellie doesn't expose a bulk-pin-customers tool and won't gain one soon.
|
||||
|
||||
---
|
||||
|
||||
## When to load this skill
|
||||
|
||||
Load when the user does any of:
|
||||
|
||||
- "pin these names to [site]" / "backfill this WhatsApp batch"
|
||||
- Hands you a coordinate + place name + list of names
|
||||
- Pastes a raw WhatsApp export with a Google Maps link followed by names (most common case — parse it yourself)
|
||||
- Says "do the Cementation / Khuseleka / Doornkop thing" referring to a previous batch in this conversation
|
||||
|
||||
If the user wants to interact via the agent/MCP layer instead (`POST /api/mcp` or `/api/v1/*`), load `wfdash-giellie` — not this skill.
|
||||
|
||||
---
|
||||
|
||||
## Required inputs (ask for what's missing)
|
||||
|
||||
1. **Coordinates** — either:
|
||||
- Google Maps URL: `https://maps.google.com/?q=-26.217859,27.791794` (extract the `q=`)
|
||||
- Or raw: `-26.217859, 27.791794` (decimal degrees, lat first, lon second)
|
||||
2. **Site name** — free-text string, e.g. `Khuseleka`, `Cementation`, `Doornkop`, `Elandsrand / Kusasalethu`
|
||||
3. **Person names** — one per line, numbered, or pasted as a WhatsApp message block
|
||||
4. **(optional)** Per-message timestamps — use when each name was its own message; default to the single shared timestamp of the names message
|
||||
5. **(optional)** Rep ID — defaults to Gary `krpyec9lblra0qz`
|
||||
|
||||
If the user pastes a raw WhatsApp export (timestamps + Location URL + names), parse out coordinates, site, and names yourself. That's the dominant pattern.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Auth to PocketBase
|
||||
|
||||
**Never hardcode the PB admin email.** Always read `.env.local` in the WFDASH repo. Past bug: assistant assumed `david@wfsales.co.za` when the env file said `david.els1@outlook.com`.
|
||||
|
||||
```bash
|
||||
# From WFDASH repo root
|
||||
PB_EMAIL=$(grep -E '^PB_ADMIN_EMAIL=' .env.local | cut -d= -f2-)
|
||||
PB_PASSWORD=$(grep -E '^PB_ADMIN_PASSWORD=' .env.local | cut -d= -f2-)
|
||||
PB_URL=$(grep -E '^POCKETBASE_URL=' .env.local | cut -d= -f2-)
|
||||
```
|
||||
|
||||
POST to `${PB_URL}/api/collections/_superusers/auth-with-password`:
|
||||
|
||||
```json
|
||||
{ "identity": "<email>", "password": "<password>" }
|
||||
```
|
||||
|
||||
Cache the returned `token`. It's valid for the session. If you get a 401 mid-batch, re-auth.
|
||||
|
||||
### 2. Verify live schema (don't trust the local snapshot)
|
||||
|
||||
The local snapshot at `pb-exports/pb_schema-2.json` may be stale. As of last sync it was missing `site_location` (which exists in live PB). Before writing, `GET /api/collections/customers/records?perPage=1` and confirm the fields you intend to write are actually present in the live record. If `site_location` is gone from live PB too, drop it and ask the user how they want site tracked now.
|
||||
|
||||
### 3. Site check
|
||||
|
||||
`site_location` is a free-text `select` on `customers`. PB silently accepts new string values. Before writing, `GET /api/collections/customers/records?perPage=200&fields=site_location&filter=site_location!=''` to enumerate existing values and confirm whether the new site is genuinely new. Report site growth at the end of the batch.
|
||||
|
||||
### 4. Fuzzy-match each name against `customers`
|
||||
|
||||
**Critical gotchas:**
|
||||
|
||||
- **Field is `customer`, NOT `name`.** The PB filter operator is `~` (contains), not `LIKE`.
|
||||
- ✅ `GET /api/collections/customers/records?filter=customer~"Corrie"&perPage=10`
|
||||
- ❌ `?filter=name~"Corrie"` (returns 0 hits — the bug from the Khuseleka batch)
|
||||
- For each name, try exact match first, then substring if no exact.
|
||||
- If multiple hits, list all candidates with their IDs.
|
||||
- If zero hits, report zero and present skip/create options.
|
||||
- If one hit, mark ✓ clear.
|
||||
- Single-letter differences usually mean the same person (driving-mode typo) — flag with ⚠️ but don't auto-decide.
|
||||
|
||||
Run all searches in parallel (Promise.all / xargs -P / etc.). Don't burn a sequential round-trip per name.
|
||||
|
||||
### 5. Present match table + ask disambiguation
|
||||
|
||||
**ALWAYS present a table before writing anything.** Format:
|
||||
|
||||
```
|
||||
| # | WhatsApp | PB matches | Notes |
|
||||
|---|---------|-----------|-------|
|
||||
| 1 | Corrie Strydom | `Corrie Strydom` (`zfasu6tsfcj87if`) — exact | ✓ clear |
|
||||
| 2 | Johan Schultz | `Johan Schoultz Shaft Foreman` (`quvzzrufkn7xkqu`) | ⚠️ single-letter typo |
|
||||
| 3 | Mark Wilkie | 2 records: `4lgz...` and `8rfe...` | ❓ duplicate |
|
||||
| 4 | Andre Grobler | 0 hits | ❓ zero hit |
|
||||
```
|
||||
|
||||
Then ask the user for each ambiguous/zero-hit name. Group into one set of questions with a/b/c options per item. **Do not write anything until the user answers.**
|
||||
|
||||
Standard question templates:
|
||||
|
||||
| Situation | Options |
|
||||
|---|---|
|
||||
| Single-letter typo (only candidate exists) | (a) Pin as the typo-corrected record / (b) Skip / (c) Create new |
|
||||
| 2+ duplicates | (a) Pin both (I'll merge later) / (b) Pin one (tell me which) / (c) Skip — let's dedupe first |
|
||||
| 0 hits | (a) Skip (probably wrong name) / (b) Create new customer |
|
||||
| Ambiguous (multiple distinct candidates) | (a) Skip / (b) Pin [specific candidate] / (c) Create new |
|
||||
|
||||
### 6. Apply writes
|
||||
|
||||
For each confirmed person, run TWO operations:
|
||||
|
||||
**A. Update the customer record**
|
||||
|
||||
```
|
||||
PATCH ${PB_URL}/api/collections/customers/records/{id}
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"coordinates": { "lat": <lat>, "lon": <lon> },
|
||||
"site_location": "<site>",
|
||||
"lastvisiteddate": "<SAST-2h>Z"
|
||||
}
|
||||
```
|
||||
|
||||
**B. Create a `rep_visits` record**
|
||||
|
||||
```
|
||||
POST ${PB_URL}/api/collections/rep_visits/records
|
||||
Authorization: Bearer <token>
|
||||
|
||||
{
|
||||
"customer": "<customer_id>",
|
||||
"rep": "<rep_id>",
|
||||
"position_source": "manual",
|
||||
"coordinates": { "lat": <lat>, "lon": <lon> },
|
||||
"notes": "Manual backfill from WhatsApp pin: <site> (<lat>,<lon>). Driving-mode typos: <typo1> → <canonical1>, ...",
|
||||
"visited_at": "<SAST-2h>Z",
|
||||
"pinned_customer": true
|
||||
}
|
||||
```
|
||||
|
||||
Run all writes in parallel. Verify after.
|
||||
|
||||
### 7. Verify
|
||||
|
||||
After all writes:
|
||||
|
||||
- `GET /api/collections/customers/records/{id}` for one of the updated customers — confirm `coordinates`, `site_location`, `lastvisiteddate` persisted.
|
||||
- `GET /api/collections/rep_visits/records/{id}` for one of the created visits — confirm the fields persisted.
|
||||
- Report new site count across all customers.
|
||||
|
||||
### 8. Report
|
||||
|
||||
Final report should include:
|
||||
|
||||
1. **What was done** — table: customer, coords, site, timestamp, visit ID.
|
||||
2. **Spelling corrections** — e.g. "PB spelling: `Kobus Oberholtzer` (two `t`s), Gary's spelling was the typo."
|
||||
3. **Skipped names** — tally grows by N.
|
||||
4. **Site growth** — distinct `site_location` count.
|
||||
5. **Outstanding follow-ups**:
|
||||
- `site_location` is still free-text on `customers` (and not on `rep_visits`). Past ~10 sites, migrate to a structured `site` collection + `rep_visits.site` foreign key.
|
||||
- The driving-mode importer (you, but automated from a WhatsApp webhook) is a half-day project.
|
||||
|
||||
---
|
||||
|
||||
## Quirk table
|
||||
|
||||
| Quirk | Detail |
|
||||
|---|---|
|
||||
| Auth email | `.env.local` → `PB_ADMIN_EMAIL`, never hardcoded |
|
||||
| PB filter field | `customer~"X"`, NOT `name~"X"` or `LIKE` |
|
||||
| `customers.coordinates` | GeoJSON `{lat, lon}` |
|
||||
| `rep_visits.coordinates` | GeoJSON `{lat, lon}` (same as customers — both are `geoPoint`) |
|
||||
| `rep_visits.site_location` | Does not exist on the collection; do NOT include in visit writes. Site info lives on the customer record and in the visit's `notes` field. |
|
||||
| Timestamp | SAST = UTC+2; convert all WhatsApp times before writing |
|
||||
| Local PB snapshot | `pb-exports/pb_schema-2.json` may be stale — verify live schema before writing |
|
||||
| Driving typos | Single-letter differences are usually the same person (Kobus Oberholtzer, Enzo Msiza, Tinus van de Venter all pinned); always confirm with user |
|
||||
| Duplicates | If a name matches 2+ records, ask — usually "pin both, I'll merge later" |
|
||||
| Zero hits | Same shape as Gorden / Andre / Richard Dube — usually skip |
|
||||
| Per-message timestamps | Use when each name was its own WhatsApp message; default to shared timestamp |
|
||||
| Pin-vs-name gap | If the location pin and the names message have a >5 min gap, flag it — could mean a second un-pinned visit |
|
||||
|
||||
---
|
||||
|
||||
## Known skipped names (as of last sync)
|
||||
|
||||
If you encounter any of these again, **ask** before creating — they may be the same driving-typo situation:
|
||||
|
||||
- Gorden van Staden
|
||||
- Andre Grobler
|
||||
- Richard Dube
|
||||
- Lucky Mabena
|
||||
- Janu
|
||||
- Nthabising Maloisi
|
||||
|
||||
If the user later confirms any of these are real people (not typos), backfill is straightforward: same workflow as a normal batch, treat as zero-hit option (b).
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Don't write before presenting.** The user always wants a match table + disambiguation questions first.
|
||||
- **Don't guess on zero-hits.** Skip by default; ask before creating new customers.
|
||||
- **Don't auto-resolve duplicates.** Ask whether to pin both, one, or skip for dedup-first.
|
||||
- **Don't auto-resolve single-letter typos.** Most of them are right (Kobus, Enzo, Tinus), but phrase as a question, not a fact.
|
||||
- **Don't hardcode the PB email.** Always read `.env.local`.
|
||||
- **Don't include `site_location` on `rep_visits` writes** — the field doesn't exist on that collection.
|
||||
- **Don't use `name~` as a filter** — the field is `customer`.
|
||||
- **Don't write without verifying** — after each batch, re-fetch one customer and one visit to confirm persistence.
|
||||
- **Don't trust the local PB snapshot** — it's known to be stale on at least `site_location`.
|
||||
|
||||
---
|
||||
|
||||
## Worked example
|
||||
|
||||
User input:
|
||||
|
||||
```
|
||||
[2026/09/10, 10:07:15] Gary CNL: Location: https://maps.google.com/?q=-25.624908,27.256472 - Khuseleka
|
||||
[2026/09/10, 10:38:59] Gary CNL: Corrie Strydom / Hein haasbroek / Johan Schultz / Lucas barnard / Nthabising maloisi / Titus Negota
|
||||
```
|
||||
|
||||
What you do:
|
||||
|
||||
1. Parse: coords = `(-25.624908, 27.256472)`, site = `Khuseleka`, names = [`Corrie Strydom`, `Hein haasbroek`, `Johan Schultz`, `Lucas barnard`, `Nthabising maloisi`, `Titus Negota`], single shared timestamp `10:38:59 SAST → 08:38:59Z`.
|
||||
2. Read `.env.local`, auth to PB.
|
||||
3. Fuzzy-match all 6 in parallel.
|
||||
4. Present table (exact, exact, typo, exact, 0 hits, typo).
|
||||
5. Ask: "Pin Schoultz?" / "Pin Neqota?" / "Skip or create Maloisi?".
|
||||
6. After answers, run 2N writes (PATCH + POST each).
|
||||
7. Verify, report.
|
||||
|
||||
That's the whole loop.
|
||||
Loading…
Reference in a new issue